feat: redesign advanced settings and client navigation, fix config compatibility
- Reorganize settings cards and improve responsive layouts, save states, and navigation - Move model aliases to a dedicated tab and improve search and keyboard interactions - Add pagination to the agent client list and update localized management text - Use public model IDs consistently for Codex selection and catalogs - Fix v8 migration and preserve keys, providers, and aliases in mixed configurations - Improve the stop-core button styling and expand UI and configuration regression tests
This commit is contained in:
1 parent
2649df000c
commit
245319be2d
29 files changed
+1065
-282
No files matched your search
@@ -839,10 +839,12 @@ pub(crate) async fn fetch_codex_catalog_runtime_models(
|
||||
config: &GuiConfigFile,
|
||||
) -> Result<Vec<codex_catalog::CodexRuntimeModel>, String> {
|
||||
let (runtime, content) = tokio::join!(
|
||||
fetch_codex_runtime_models(config.port, effective_agent_api_key(config)),
|
||||
fetch_agent_model_payload(config.port, effective_agent_api_key(config)),
|
||||
fetch_management_config_yaml(config),
|
||||
);
|
||||
let mut runtime = runtime?;
|
||||
// Codex selection and its written catalog must use the public /v1/models
|
||||
// IDs, not the alternate catalog selected by client_version.
|
||||
let mut runtime = codex_catalog::parse_public_models(&runtime?)?;
|
||||
codex_catalog::apply_configured_context_limits(&mut runtime, &content?)?;
|
||||
Ok(runtime)
|
||||
}
|
||||
|
||||
@@ -126,6 +126,25 @@ pub(crate) fn current_catalog_json() -> Result<String, String> {
|
||||
Ok(state.json.clone())
|
||||
}
|
||||
|
||||
pub(crate) fn parse_public_models(payload: &Value) -> Result<Vec<CodexRuntimeModel>, String> {
|
||||
let values = payload.get("data").and_then(Value::as_array)
|
||||
.ok_or("Public model list response is missing a data array")?;
|
||||
let mut entries = Vec::with_capacity(values.len());
|
||||
for value in values {
|
||||
let id = value.get("id").and_then(Value::as_str).map(str::trim)
|
||||
.filter(|id| !id.is_empty())
|
||||
.ok_or("Public model list entry is missing a model ID")?;
|
||||
let mut entry = value.as_object().cloned().unwrap_or_default();
|
||||
entry.insert("slug".into(), Value::String(id.to_string()));
|
||||
// Public IDs are already resolved by the core. Never reinterpret them
|
||||
// using configuration mapping fields or generate additional IDs.
|
||||
entry.remove("alias");
|
||||
entry.remove("fork");
|
||||
entries.push(Value::Object(entry));
|
||||
}
|
||||
parse_runtime_models(&serde_json::json!({"data": entries}))
|
||||
}
|
||||
|
||||
pub(crate) fn parse_runtime_models(payload: &Value) -> Result<Vec<CodexRuntimeModel>, String> {
|
||||
let values = payload
|
||||
.get("models")
|
||||
|
||||
@@ -483,6 +483,7 @@ pub(crate) fn management_v8_yaml_to_legacy_view(content: &str) -> Result<String,
|
||||
let client_keys = yaml_mapping_value(root, "access")
|
||||
.and_then(serde_norway::Value::as_mapping)
|
||||
.and_then(|access| yaml_mapping_value(access, "api-keys"))
|
||||
.or_else(|| yaml_mapping_value(root, "api-keys").filter(|value| value.is_sequence()))
|
||||
.cloned();
|
||||
let oauth_aliases = yaml_mapping_value(root, "oauth")
|
||||
.and_then(serde_norway::Value::as_mapping)
|
||||
@@ -529,13 +530,14 @@ pub(crate) fn management_v8_yaml_to_legacy_view(content: &str) -> Result<String,
|
||||
root.insert(yaml_key("payload"), payload);
|
||||
}
|
||||
for (legacy, provider) in V8_PROVIDER_FAMILIES {
|
||||
let groups = yaml_mapping_value(&upstreams, provider)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| serde_norway::Value::Sequence(Vec::new()));
|
||||
root.insert(
|
||||
yaml_key(legacy),
|
||||
flatten_v8_provider_groups(provider, &groups)?,
|
||||
);
|
||||
// The version marker alone does not migrate a legacy file. Only an
|
||||
// explicitly present v8 family replaces its legacy spelling (including []).
|
||||
if let Some(groups) = yaml_mapping_value(&upstreams, provider) {
|
||||
root.insert(yaml_key(legacy), flatten_v8_provider_groups(provider, groups)?);
|
||||
} else {
|
||||
root.entry(yaml_key(legacy))
|
||||
.or_insert_with(|| serde_norway::Value::Sequence(Vec::new()));
|
||||
}
|
||||
}
|
||||
serde_norway::to_string(&document)
|
||||
.map_err(|error| format!("Failed to serialize kernel YAML configuration: {error}"))
|
||||
@@ -622,9 +624,12 @@ async fn put_management_legacy_alias_view_changes(
|
||||
let provider_changes = provider_changes.into_iter().map(|(provider, before, after)| {
|
||||
let groups = match after {
|
||||
Some(after) => Some(if let Some(native) = &native {
|
||||
let groups = nested_yaml_value(native, &["api-keys", provider])
|
||||
.cloned().unwrap_or_else(|| serde_norway::Value::Sequence(Vec::new()));
|
||||
update_v8_provider_group_models(provider, &groups, &before.unwrap_or_else(|| serde_norway::Value::Sequence(Vec::new())), &after)?
|
||||
let before = before.unwrap_or_else(|| serde_norway::Value::Sequence(Vec::new()));
|
||||
let groups = match nested_yaml_value(native, &["api-keys", provider]) {
|
||||
Some(groups) => groups.clone(),
|
||||
None => group_legacy_provider_records(provider, &before)?,
|
||||
};
|
||||
update_v8_provider_group_models(provider, &groups, &before, &after)?
|
||||
} else { group_legacy_provider_records(provider, &after)? }),
|
||||
None => None,
|
||||
};
|
||||
|
||||
@@ -291,7 +291,15 @@ pub(crate) fn merge_core_config_yaml(
|
||||
config: &GuiConfigFile,
|
||||
) -> Result<String, String> {
|
||||
let base = match current {
|
||||
Some(current) => current.to_string(),
|
||||
Some(current) => {
|
||||
let template_document: serde_norway::Value = serde_norway::from_str(template)
|
||||
.map_err(|error| format!("Failed to parse kernel configuration template: {error}"))?;
|
||||
if core_config_uses_v8(&template_document) {
|
||||
migrate_legacy_core_config_to_v8(template, current)?
|
||||
} else {
|
||||
current.to_string()
|
||||
}
|
||||
}
|
||||
None => merge_core_config_fields(template, None)?,
|
||||
};
|
||||
apply_gui_managed_settings(&base, config)
|
||||
|
||||
@@ -1258,7 +1258,7 @@ pub(crate) fn migrate_legacy_core_config_to_v8(
|
||||
).cloned() {
|
||||
set_core_yaml_path_value(&mut migrated, &["config-version"], version)?;
|
||||
} else {
|
||||
remove_core_yaml_path_value(&mut migrated, &["config-version"]);
|
||||
set_core_yaml_path_value(&mut migrated, &["config-version"], serde_norway::Value::Number(8.into()))?;
|
||||
}
|
||||
lift_legacy_core_config_fields(
|
||||
&mut migrated,
|
||||
|
||||
@@ -1450,6 +1450,54 @@ fn agent_model_list_parser_exposes_aliases_as_selectable_model_ids() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_configuration_uses_public_v1_model_ids_for_selection_and_files() {
|
||||
use std::io::{Read, Write};
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = std::thread::spawn(move || {
|
||||
let mut paths = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let (mut socket, _) = listener.accept().unwrap();
|
||||
socket.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
let mut request = Vec::new();
|
||||
let mut buffer = [0; 2048];
|
||||
while !request.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
let count = socket.read(&mut buffer).unwrap();
|
||||
assert!(count > 0);
|
||||
request.extend_from_slice(&buffer[..count]);
|
||||
}
|
||||
let request = String::from_utf8(request).unwrap();
|
||||
let path = request.split_whitespace().nth(1).unwrap().to_string();
|
||||
let body = if path == "/v1/models" {
|
||||
assert!(request.to_lowercase().contains("authorization: bearer public-test-key\r\n"));
|
||||
r#"{"data":[{"id":"public-model","alias":"not-an-id","slug":"not-public","context_length":64000},{"id":"another-model"}]}"#
|
||||
} else {
|
||||
assert_eq!(path, "/v8/management/config.yaml");
|
||||
"config-version: 8\n"
|
||||
};
|
||||
write!(socket, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body).unwrap();
|
||||
paths.push(path);
|
||||
}
|
||||
assert!(paths.iter().any(|path| path == "/v1/models"));
|
||||
});
|
||||
let config = GuiConfigFile {
|
||||
port,
|
||||
management_secret_key: "management-test-key".into(),
|
||||
api_keys: vec![GuiApiKeyEntry { key: "public-test-key".into(), remark: String::new() }],
|
||||
..GuiConfigFile::default()
|
||||
};
|
||||
let prepared = fetch_prepared_agent_models(AgentClient::Codex, &config).await.unwrap();
|
||||
server.join().unwrap();
|
||||
let mut ids: Vec<_> = prepared.models.iter().map(|model| model.name.as_str()).collect();
|
||||
ids.sort();
|
||||
assert_eq!(ids, ["another-model", "public-model"]);
|
||||
let catalog: serde_json::Value = serde_json::from_str(prepared.codex_catalog.as_deref().unwrap()).unwrap();
|
||||
let mut written: Vec<_> = catalog["models"].as_array().unwrap().iter().map(|model| model["slug"].as_str().unwrap()).collect();
|
||||
written.sort();
|
||||
assert_eq!(ids, written);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kimi_grok_and_zcode_use_cpa_runtime_context_windows() {
|
||||
for client in [
|
||||
|
||||
@@ -37,6 +37,19 @@ fn non_v8_management_yaml_is_returned_without_reformatting() {
|
||||
assert_eq!(management_v8_yaml_to_legacy_view(input).unwrap(), input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v8_version_marker_preserves_legacy_values_until_canonical_fields_exist() {
|
||||
let input = "config-version: 8\napi-keys: [client-key]\ncodex-api-key: [{api-key: upstream-key, models: [{name: model-a}]}]\n";
|
||||
let view = yaml_json(&management_v8_yaml_to_legacy_view(input).unwrap());
|
||||
assert_eq!(view["api-keys"], serde_json::json!(["client-key"]));
|
||||
assert_eq!(view["codex-api-key"][0]["models"][0]["name"], "model-a");
|
||||
let mixed = "config-version: 8\napi-keys: {codex: []}\naccess: {api-keys: []}\ncodex-api-key: [{api-key: old-key}]\nclaude-api-key: [{api-key: retained-key}]\n";
|
||||
let view = yaml_json(&management_v8_yaml_to_legacy_view(mixed).unwrap());
|
||||
assert_eq!(view["api-keys"], serde_json::json!([]));
|
||||
assert_eq!(view["codex-api-key"], serde_json::json!([]));
|
||||
assert_eq!(view["claude-api-key"][0]["api-key"], "retained-key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v8_provider_names_belong_to_groups_and_preserve_legacy_view_identity() {
|
||||
let groups = serde_norway::from_str(
|
||||
|
||||
@@ -639,7 +639,7 @@ 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 ["", "server: {host: 127.0.0.1}\n"] {
|
||||
for prefix in ["", "config-version: 7\n", "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", ""] {
|
||||
@@ -651,6 +651,7 @@ fn v8_core_updates_preserve_partially_migrated_config_and_credentials() {
|
||||
fs::write(install_dir.join(CORE_CONFIG_FILE), &original).unwrap();
|
||||
|
||||
let document = serde_norway::from_str::<serde_norway::Value>(&migrated).unwrap();
|
||||
assert_eq!(document["config-version"].as_u64(), Some(8));
|
||||
let settings = core_config_settings_from_value(&document).unwrap();
|
||||
assert_eq!(settings.auth_dir, "../oauth", "credential directory changed during update");
|
||||
assert_eq!(settings.port, 9527);
|
||||
@@ -671,6 +672,7 @@ fn v8_core_updates_preserve_partially_migrated_config_and_credentials() {
|
||||
gui.proxy_url = settings.proxy_url.clone();
|
||||
let startup = merge_core_config_yaml(template, Some(&migrated), &gui).unwrap();
|
||||
let startup = serde_norway::from_str::<serde_norway::Value>(&startup).unwrap();
|
||||
assert_eq!(startup["config-version"].as_u64(), Some(8));
|
||||
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);
|
||||
@@ -682,6 +684,19 @@ fn v8_core_updates_preserve_partially_migrated_config_and_credentials() {
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v7_config_startup_with_v8_template_writes_v8_version() {
|
||||
for marker in ["", "config-version: 8\n"] {
|
||||
let template = format!("{marker}server: {{port: 8317}}\naccess: {{api-keys: []}}\n");
|
||||
let current = "config-version: 7\ncodex-api-key: [{api-key: upstream-test, models: [{name: example}]}]\n";
|
||||
let rendered = merge_core_config_yaml(&template, Some(current), &GuiConfigFile::default()).unwrap();
|
||||
let document: serde_norway::Value = serde_norway::from_str(&rendered).unwrap();
|
||||
assert_eq!(document["config-version"].as_u64(), Some(8));
|
||||
assert_eq!(document["api-keys"]["codex"][0]["keys"][0]["api-key"], "upstream-test");
|
||||
assert!(document.get("codex-api-key").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_a_core_rejects_invalid_config_without_overwriting_files() {
|
||||
let root = agent_test_home("core-config-migrate-invalid");
|
||||
|
||||
@@ -128,6 +128,131 @@ impl TestCore {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires CPA_V8_TEST_CORE pointing to a v8 executable"]
|
||||
async fn v8_claude_desktop_and_code_share_persisted_model_routes() {
|
||||
check_claude_model_routes(None).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires CPA_V8_TEST_CORE pointing to a v8 executable"]
|
||||
async fn v7_claude_routes_survive_v8_migration_and_reapply() {
|
||||
for marker in ["", "config-version: 7\n"] {
|
||||
check_claude_model_routes(Some(marker)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_claude_model_routes(legacy_marker: Option<&str>) {
|
||||
let executable = fs::canonicalize(std::env::var_os("CPA_V8_TEST_CORE").expect("set CPA_V8_TEST_CORE")).unwrap();
|
||||
let directory = agent_test_home("v8-contract-claude-routes");
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
drop(listener);
|
||||
let mut core = TestCore {
|
||||
child: None, config: directory.join("config.yaml"), directory, executable,
|
||||
origin: format!("http://127.0.0.1:{port}"),
|
||||
client: reqwest::Client::builder().no_proxy().timeout(Duration::from_secs(3)).build().unwrap(),
|
||||
};
|
||||
let gui = GuiConfigFile {
|
||||
port, management_secret_key: "isolated-test-secret".into(),
|
||||
auth_dir: path_to_string(&core.directory.join("auth")),
|
||||
api_keys: vec![GuiApiKeyEntry { key: "isolated-client-key".into(), remark: String::new() }],
|
||||
..GuiConfigFile::default()
|
||||
};
|
||||
let initial = "config-version: 8\nmanagement: {disable-control-panel: true, disable-auto-update-panel: true}\napi-keys:\n codex:\n - name: test-upstream\n base-url: http://127.0.0.1:1/v1\n models: [{name: model-one}]\n keys: [{api-key: upstream-test, weight: 2}]\n";
|
||||
let migrated;
|
||||
let initial = if let Some(marker) = legacy_marker {
|
||||
let legacy = format!("{marker}codex-api-key:\n - api-key: upstream-test\n base-url: http://127.0.0.1:1/v1\n models: [{{name: model-one, alias: claude-sonnet-custom}}]\noauth-model-alias:\n codex: [{{name: oauth-original, alias: claude-opus-legacy, fork: true}}]\npayload:\n override:\n - models: [{{name: claude-sonnet-custom, protocol: codex}}]\n params: {{reasoning.effort: high, service_tier: priority}}\n");
|
||||
migrated = migrate_legacy_core_config_to_v8("config-version: 8\nserver: {port: 8317}\nmanagement: {disable-control-panel: true, disable-auto-update-panel: true}\n", &legacy).unwrap();
|
||||
let document: serde_norway::Value = serde_norway::from_str(&migrated).unwrap();
|
||||
assert_eq!(document["config-version"].as_u64(), Some(8));
|
||||
assert_eq!(document["oauth"]["model-alias"]["codex"][0]["alias"], "claude-opus-legacy");
|
||||
assert_eq!(document["requests"]["payload"]["override"][0]["params"]["service_tier"], "priority");
|
||||
for key in ["codex-api-key", "oauth-model-alias", "payload"] {
|
||||
assert!(document.get(key).is_none(), "legacy field {key} must be migrated");
|
||||
}
|
||||
migrated.as_str()
|
||||
} else { initial };
|
||||
fs::write(&core.config, apply_gui_managed_settings(initial, &gui).unwrap()).unwrap();
|
||||
core.start().await;
|
||||
let before = core.config_view().await;
|
||||
let desktop = fetch_prepared_agent_models(AgentClient::ClaudeDesktop, &gui).await.unwrap();
|
||||
let mappings = ClaudeDesktopModelMappings {
|
||||
desktop_models: Some(vec![ClaudeDesktopModelMapping {
|
||||
model: if legacy_marker.is_some() { "claude-sonnet-custom" } else { "model-one" }.into(),
|
||||
alias: if legacy_marker.is_some() { "" } else { "claude-sonnet-custom" }.into(), context_1m: false,
|
||||
}]),
|
||||
..ClaudeDesktopModelMappings::all("model-one")
|
||||
};
|
||||
let profile = commit_agent_with_core(&gui, Some(&mappings), &desktop.models, || {
|
||||
build_claude_desktop_profile(None, &core.origin, "isolated-client-key", "model-one", &desktop.models, Some(&mappings))
|
||||
}).await.unwrap();
|
||||
let profile: serde_json::Value = serde_json::from_str(&profile).unwrap();
|
||||
assert_eq!(profile["inferenceModels"][0]["name"], "claude-sonnet-custom");
|
||||
assert_eq!(profile["inferenceGatewayBaseUrl"], core.origin);
|
||||
let saved = core.config_view().await;
|
||||
if legacy_marker.is_some() {
|
||||
// Reapplying an existing public route must preserve its upstream,
|
||||
// credentials, OAuth mappings, and reasoning/Fast overrides.
|
||||
assert_eq!(saved["api-keys"], before["api-keys"]);
|
||||
assert_eq!(saved["oauth"]["model-alias"], before["oauth"]["model-alias"]);
|
||||
assert_eq!(saved["requests"]["payload"], before["requests"]["payload"]);
|
||||
}
|
||||
assert_eq!(saved["api-keys"]["codex"][0]["keys"], before["api-keys"]["codex"][0]["keys"]);
|
||||
assert!(saved["api-keys"]["codex"][0]["models"].as_array().unwrap().iter()
|
||||
.any(|entry| entry["name"] == "model-one" && entry["alias"] == "claude-sonnet-custom"));
|
||||
|
||||
for client in [AgentClient::ClaudeDesktop, AgentClient::ClaudeCode] {
|
||||
let mut available = None;
|
||||
for _ in 0..50 {
|
||||
let prepared = fetch_prepared_agent_models(client, &gui).await.unwrap();
|
||||
if prepared.models.iter().any(|model| model.name == "claude-sonnet-custom") {
|
||||
available = Some(prepared.models);
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
let models = available.expect("Claude client must see the route published by the core");
|
||||
if client == AgentClient::ClaudeCode {
|
||||
let settings = build_claude_agent_config(None, &core.origin, "isolated-client-key", "claude-sonnet-custom", &models, None).unwrap();
|
||||
let settings: serde_json::Value = serde_json::from_str(&settings).unwrap();
|
||||
assert_eq!(settings["env"]["ANTHROPIC_MODEL"], "claude-sonnet-custom");
|
||||
assert_eq!(settings["env"]["ANTHROPIC_BASE_URL"], core.origin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires CPA_V8_TEST_CORE pointing to a v8 executable"]
|
||||
async fn v8_model_write_migrates_legacy_provider_with_version_marker() {
|
||||
let executable = fs::canonicalize(std::env::var_os("CPA_V8_TEST_CORE").expect("set CPA_V8_TEST_CORE")).unwrap();
|
||||
let directory = agent_test_home("v8-contract-mixed-models");
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
drop(listener);
|
||||
let mut core = TestCore {
|
||||
child: None, config: directory.join("config.yaml"), directory, executable,
|
||||
origin: format!("http://127.0.0.1:{port}"),
|
||||
client: reqwest::Client::builder().no_proxy().timeout(Duration::from_secs(3)).build().unwrap(),
|
||||
};
|
||||
let gui = GuiConfigFile {
|
||||
port, management_secret_key: "isolated-test-secret".into(),
|
||||
..GuiConfigFile::default()
|
||||
};
|
||||
let initial = format!("config-version: 8\nserver: {{host: 127.0.0.1, port: {port}}}\nmanagement: {{secret-key: isolated-test-secret, disable-control-panel: true, disable-auto-update-panel: true}}\noauth: {{auth-dir: '{}' }}\napi-keys: [isolated-client-key]\ncodex-api-key:\n - api-key: upstream-test\n base-url: http://127.0.0.1:1/v1\n models: [{{name: model-one}}]\n", path_to_string(&core.directory.join("auth")));
|
||||
fs::write(&core.config, initial).unwrap();
|
||||
core.start().await;
|
||||
let current = fetch_management_config_yaml(&gui).await.unwrap();
|
||||
let mut updated: serde_norway::Value = serde_norway::from_str(¤t).unwrap();
|
||||
updated["codex-api-key"][0]["models"].as_sequence_mut().unwrap()
|
||||
.push(serde_norway::from_str("{name: model-two}").unwrap());
|
||||
put_management_alias_config_changes(&gui, ¤t, &serde_norway::to_string(&updated).unwrap()).await.unwrap();
|
||||
let saved = core.config_view().await;
|
||||
assert_eq!(saved["access"]["api-keys"], serde_json::json!(["isolated-client-key"]));
|
||||
assert_eq!(saved["api-keys"]["codex"][0]["models"], serde_json::json!([{"name":"model-one"},{"name":"model-two"}]));
|
||||
assert_eq!(saved["api-keys"]["codex"][0]["keys"][0]["api-key"], "upstream-test");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires CPA_V8_TEST_CORE pointing to a v8 executable"]
|
||||
async fn v8_alias_save_preserves_multi_key_groups_and_restores_inheritance_after_failure() {
|
||||
|
||||
@@ -175,7 +175,7 @@ export function TemplateConfigSection({ groups, visibleGroups, onDirtyGroupsChan
|
||||
{groups.map((group) => {
|
||||
const dirty = group.fields.some((field) => drafts[templateFieldKey(field.path)]);
|
||||
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}`}>
|
||||
return <section key={group.id} id={`config-group-${group.id}`} tabIndex={-1} className={`config-card template-config-card${group.fields.some(field => ['custom', 'json', 'string-list'].includes(field.type)) ? ' config-settings-wide' : ''}${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);
|
||||
@@ -187,7 +187,7 @@ export function TemplateConfigSection({ groups, visibleGroups, onDirtyGroupsChan
|
||||
const id = `template-${group.id}-${index}`;
|
||||
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' : ''}`}>
|
||||
return <div key={key} id={`template-field-${group.id}-${index}`} data-field-type={field.type} 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}
|
||||
|
||||
+7
-5
@@ -1083,15 +1083,17 @@ export const jaOverrides = {
|
||||
'agents.description.kimiCode': 'CPA の OpenAI Chat Completions API と動的モデルカタログを使用',
|
||||
'agents.description.grokBuild': 'CPA の OpenAI Chat Completions API とカスタムモデルカタログを使用',
|
||||
'agents.description.pi': 'CLIProxyAPI プロバイダーを使用して Pi CLI のモデルを動的に読み込みます',
|
||||
'agents.clients.switch': 'クライアントを切り替え',
|
||||
'agents.clients.switchHint': 'クライアントを検索して選択します。選択中のクライアントはショートカット一覧に表示されます。',
|
||||
'agents.clients.pages': 'クライアントのページ',
|
||||
'agents.clients.previous': '前のクライアントページ',
|
||||
'agents.clients.next': '次のクライアントページ',
|
||||
'agents.clients.page': '{page} / {total} ページ',
|
||||
'agents.clients.listed': 'クライアント {count} 件',
|
||||
'agents.clients.manage': 'クライアントを管理',
|
||||
'agents.clients.description': 'ショートカットに優先表示するクライアントを選択します。選択中のクライアントは常に表示され、その他は「クライアントを切り替え」から選べます。',
|
||||
'agents.clients.description': '一覧に残すクライアントを選択して保存します。順序は固定で、収まらない場合はページに分けて表示します。クライアントをクリックすると設定が開きます。',
|
||||
'agents.clients.search': 'クライアントを検索',
|
||||
'agents.clients.shown': '{count} / {total} 件を表示',
|
||||
'agents.clients.selected': '{count} 件のクライアントを選択中',
|
||||
'agents.clients.automatic': '自動表示に戻す',
|
||||
'agents.clients.automaticHint': '既定では、インストール済みまたは設定済みのクライアントを優先表示します。',
|
||||
'agents.clients.automaticHint': '既定では、インストール済みまたは設定済みのクライアントを表示します。',
|
||||
'agents.clients.minimum': '少なくとも 1 件のクライアントを表示してください。',
|
||||
'agents.clients.noResults': '一致するクライアントが見つかりません',
|
||||
'agents.list.detecting': '検出中',
|
||||
|
||||
@@ -1083,15 +1083,17 @@ export const en: Record<MessageKey, string> = {
|
||||
'agents.description.kimiCode': 'Uses the CPA OpenAI Chat Completions API and dynamic model catalog',
|
||||
'agents.description.grokBuild': 'Uses the CPA OpenAI Chat Completions API and custom model catalog',
|
||||
'agents.description.pi': 'Loads models dynamically in Pi through the CLIProxyAPI provider',
|
||||
'agents.clients.switch': 'Switch Client',
|
||||
'agents.clients.switchHint': 'Search and select a client. Your current selection stays in the shortcut list.',
|
||||
'agents.clients.pages': 'Client pages',
|
||||
'agents.clients.previous': 'Previous client page',
|
||||
'agents.clients.next': 'Next client page',
|
||||
'agents.clients.page': 'Page {page} / {total}',
|
||||
'agents.clients.listed': '{count} clients',
|
||||
'agents.clients.manage': 'Manage Clients',
|
||||
'agents.clients.description': 'Choose which clients take priority in the shortcuts. Your current selection stays visible; use Switch Client to access any other client.',
|
||||
'agents.clients.description': 'Choose the clients to keep in the list, then save. Clients stay in a fixed order across pages. Click a client to open its settings.',
|
||||
'agents.clients.search': 'Search clients',
|
||||
'agents.clients.shown': 'Showing {count} / {total}',
|
||||
'agents.clients.selected': '{count} clients selected',
|
||||
'agents.clients.automatic': 'Restore automatic display',
|
||||
'agents.clients.automaticHint': 'Installed or configured clients take priority by default.',
|
||||
'agents.clients.automaticHint': 'Installed or configured clients are shown by default.',
|
||||
'agents.clients.minimum': 'Keep at least one client visible.',
|
||||
'agents.clients.noResults': 'No matching clients found',
|
||||
'agents.list.detecting': 'Detecting',
|
||||
|
||||
@@ -1082,15 +1082,17 @@ export const zhCN = {
|
||||
'agents.description.kimiCode': '使用 CPA 的 OpenAI Chat Completions 接口和动态模型目录',
|
||||
'agents.description.grokBuild': '使用 CPA 的 OpenAI Chat Completions 接口和自定义模型目录',
|
||||
'agents.description.pi': '使用 Pi CLI 和 CLIProxyAPI 提供方动态加载模型',
|
||||
'agents.clients.switch': '切换客户端',
|
||||
'agents.clients.switchHint': '搜索并选择客户端,当前选择会显示在快捷列表中。',
|
||||
'agents.clients.pages': '客户端分页',
|
||||
'agents.clients.previous': '上一页客户端',
|
||||
'agents.clients.next': '下一页客户端',
|
||||
'agents.clients.page': '第 {page} / {total} 页',
|
||||
'agents.clients.listed': '{count} 个客户端',
|
||||
'agents.clients.manage': '管理客户端',
|
||||
'agents.clients.description': '选择快捷列表优先展示的客户端。当前选择始终保留,其他客户端可通过“切换客户端”访问。',
|
||||
'agents.clients.description': '勾选要保留在列表中的客户端,保存后生效。列表顺序固定,放不下时分页显示;点击客户端切换配置。',
|
||||
'agents.clients.search': '搜索客户端',
|
||||
'agents.clients.shown': '显示 {count} / {total}',
|
||||
'agents.clients.selected': '已选择 {count} 个客户端',
|
||||
'agents.clients.automatic': '恢复自动显示',
|
||||
'agents.clients.automaticHint': '默认优先展示已安装或已有配置的客户端。',
|
||||
'agents.clients.automaticHint': '默认显示已安装或已有配置的客户端。',
|
||||
'agents.clients.minimum': '至少保留一个客户端。',
|
||||
'agents.clients.noResults': '未找到匹配的客户端',
|
||||
'agents.list.detecting': '正在检测',
|
||||
|
||||
@@ -50,36 +50,29 @@
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.agent-client-switch {
|
||||
display: inline-flex;
|
||||
.agent-client-pagination {
|
||||
display: grid;
|
||||
grid-template-columns: 30px minmax(0, 1fr) 30px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.agent-client-pagination > span {
|
||||
text-align: center;
|
||||
font-size: var(--font-size-meta);
|
||||
color: var(--theme-8a847c);
|
||||
}
|
||||
|
||||
.agent-client-pagination > button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.agent-client-list-footer > .compact-button {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.agent-client-manager button.agent-client-option {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.agent-client-manager button.agent-client-option:hover,
|
||||
.agent-client-manager button.agent-client-option[aria-pressed="true"] {
|
||||
border-color: var(--ui-accent);
|
||||
background: var(--ui-accent-soft);
|
||||
}
|
||||
|
||||
.agent-client-manager button.agent-client-option > svg {
|
||||
flex: none;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.agent-client-list-footer small,
|
||||
.agent-client-manager small,
|
||||
.agent-client-manager p,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useLayoutEffect, useId, useRef, useState, type ReactNode } from 'react';
|
||||
import { Check, RefreshCw, Search, SlidersHorizontal, X } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, RefreshCw, Search, SlidersHorizontal, X } from 'lucide-react';
|
||||
import { MessageNotice } from '../appNotice';
|
||||
import { useDialogFocusTrap } from '../components/useDialogFocusTrap';
|
||||
import { useI18n } from '../i18n';
|
||||
@@ -41,25 +41,26 @@ export function AgentClientList<Id extends string>({
|
||||
const { t } = useI18n();
|
||||
const [visibleIds, setVisibleIds] = useState(() => readVisibleClients(clients));
|
||||
const [managing, setManaging] = useState(false);
|
||||
const [picking, setPicking] = useState(false);
|
||||
const [capacity, setCapacity] = useState(1);
|
||||
const [page, setPage] = useState(0);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const close = () => { setManaging(false); setPicking(false); };
|
||||
const close = () => setManaging(false);
|
||||
const [draftIds, setDraftIds] = useState<Id[] | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
// Keep the current client reachable even when detection fails or it was uninstalled.
|
||||
const automaticIds = clients.filter((client) => client.detected || client.id === selected)
|
||||
.map((client) => client.id);
|
||||
const visibleClients = clients.filter((client) => (visibleIds ?? automaticIds).includes(client.id));
|
||||
const shortcuts = visibleClients.slice(0, capacity);
|
||||
const current = visibleClients.find((client) => client.id === selected);
|
||||
if (current && !shortcuts.includes(current)) shortcuts[shortcuts.length - 1] = current;
|
||||
const pageCount = Math.max(1, Math.ceil(visibleClients.length / capacity));
|
||||
const currentPage = Math.min(page, pageCount - 1);
|
||||
const pageClients = visibleClients.slice(currentPage * capacity, (currentPage + 1) * capacity);
|
||||
const selectedIndex = visibleClients.findIndex((client) => client.id === selected);
|
||||
const draftSelection = draftIds ?? automaticIds;
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const titleId = useId();
|
||||
const descriptionId = useId();
|
||||
const dialogRef = useDialogFocusTrap<HTMLDialogElement>({
|
||||
active: managing || picking,
|
||||
active: managing,
|
||||
onEscape: close,
|
||||
initialFocusRef: searchRef,
|
||||
});
|
||||
@@ -67,13 +68,19 @@ export function AgentClientList<Id extends string>({
|
||||
useLayoutEffect(() => {
|
||||
const list = listRef.current;
|
||||
if (!list) return;
|
||||
const resize = () => setCapacity(Math.max(1, Math.min(6, Math.floor((list.clientHeight + 4) / 60))));
|
||||
const resize = () => setCapacity(Math.max(1, Math.floor((list.clientHeight + 4) / 60)));
|
||||
resize();
|
||||
const observer = new ResizeObserver(resize);
|
||||
observer.observe(list);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Navigation is a contiguous page of the saved list, never a replacement of its last row.
|
||||
// Opening a client or resizing reveals its page; browsing pages does not select a client.
|
||||
useLayoutEffect(() => {
|
||||
setPage(Math.floor(Math.max(0, selectedIndex) / capacity));
|
||||
}, [capacity, selected, selectedIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!busy && visibleIds && !visibleIds.includes(selected) && visibleIds.length) {
|
||||
onSelect(visibleIds[0]);
|
||||
@@ -81,19 +88,8 @@ export function AgentClientList<Id extends string>({
|
||||
}, [busy, onSelect, selected, visibleIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (managing || picking) dialogRef.current?.showModal();
|
||||
}, [dialogRef, managing, picking]);
|
||||
|
||||
const pick = (id: Id) => {
|
||||
// A manually hidden client can be brought back directly from the switcher.
|
||||
if (visibleIds && !visibleIds.includes(id)) {
|
||||
const next = [...visibleIds, id];
|
||||
setVisibleIds(next);
|
||||
try { window.localStorage.setItem(VISIBLE_CLIENTS_KEY, JSON.stringify(next)); } catch { /* Session only. */ }
|
||||
}
|
||||
onSelect(id);
|
||||
close();
|
||||
};
|
||||
if (managing) dialogRef.current?.showModal();
|
||||
}, [dialogRef, managing]);
|
||||
|
||||
const save = () => {
|
||||
if (!draftSelection.length) return;
|
||||
@@ -124,7 +120,7 @@ export function AgentClientList<Id extends string>({
|
||||
</div>
|
||||
{error ? <MessageNotice message={error} onDismiss={onDismissError} /> : null}
|
||||
<div className="agent-list-items" ref={listRef}>
|
||||
{shortcuts.map((client) => <button type="button" key={client.id}
|
||||
{pageClients.map((client) => <button type="button" key={client.id}
|
||||
className={selected === client.id ? 'active' : ''} aria-pressed={selected === client.id}
|
||||
onClick={() => onSelect(client.id)} disabled={busy}>
|
||||
<span className="agent-client-icon">{client.icon}</span>
|
||||
@@ -133,38 +129,38 @@ export function AgentClientList<Id extends string>({
|
||||
</button>)}
|
||||
</div>
|
||||
<div className="agent-client-list-footer">
|
||||
<button type="button" className="secondary-button agent-client-switch" disabled={busy || loading}
|
||||
onClick={() => { setQuery(''); setPicking(true); }}>
|
||||
<Search size={15} aria-hidden="true" />{t('agents.clients.switch')}
|
||||
</button>
|
||||
<small>{t('agents.clients.shown', { count: shortcuts.length, total: clients.length })}</small>
|
||||
<nav className="agent-client-pagination" aria-label={t('agents.clients.pages')}>
|
||||
<button type="button" className="icon-button quiet" disabled={busy || currentPage === 0}
|
||||
aria-label={t('agents.clients.previous')} title={t('agents.clients.previous')}
|
||||
onClick={() => setPage(currentPage - 1)}><ChevronLeft size={16} aria-hidden="true" /></button>
|
||||
<span aria-live="polite">{t('agents.clients.page', { page: currentPage + 1, total: pageCount })}</span>
|
||||
<button type="button" className="icon-button quiet" disabled={busy || currentPage + 1 === pageCount}
|
||||
aria-label={t('agents.clients.next')} title={t('agents.clients.next')}
|
||||
onClick={() => setPage(currentPage + 1)}><ChevronRight size={16} aria-hidden="true" /></button>
|
||||
</nav>
|
||||
<small>{t('agents.clients.listed', { count: visibleClients.length })}</small>
|
||||
<button type="button" className="secondary-button compact-button" disabled={busy || loading}
|
||||
onClick={() => { setDraftIds(visibleIds); setQuery(''); setManaging(true); }}>
|
||||
<SlidersHorizontal size={15} aria-hidden="true" />{t('agents.clients.manage')}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
{managing || picking ? <dialog className="agent-client-manager" ref={dialogRef} aria-labelledby={titleId}
|
||||
{managing ? <dialog className="agent-client-manager" ref={dialogRef} aria-labelledby={titleId}
|
||||
aria-describedby={descriptionId} onCancel={(event) => { event.preventDefault(); close(); }}>
|
||||
<header>
|
||||
<h2 id={titleId}>{t(picking ? 'agents.clients.switch' : 'agents.clients.manage')}</h2>
|
||||
<h2 id={titleId}>{t('agents.clients.manage')}</h2>
|
||||
<button type="button" className="icon-button quiet" aria-label={t('common.close')} onClick={close}>
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
<p id={descriptionId}>{t(picking ? 'agents.clients.switchHint' : 'agents.clients.description')}</p>
|
||||
<p id={descriptionId}>{t('agents.clients.description')}</p>
|
||||
<div className="agent-client-search">
|
||||
<Search size={16} aria-hidden="true" />
|
||||
<input ref={searchRef} type="search" value={query} onChange={(event) => setQuery(event.currentTarget.value)}
|
||||
placeholder={t('agents.clients.search')} aria-label={t('agents.clients.search')} />
|
||||
</div>
|
||||
<div className="agent-client-catalog">
|
||||
{matches.map((client) => picking ? <button type="button" key={client.id} className="agent-client-option"
|
||||
aria-label={client.name} aria-pressed={selected === client.id} onClick={() => pick(client.id)}>
|
||||
<span className="agent-client-icon">{client.icon}</span>
|
||||
<span className="agent-client-option-copy"><strong>{client.name}</strong><small title={client.summary}>{client.summary}</small></span>
|
||||
{selected === client.id ? <Check size={16} aria-hidden="true" /> : null}
|
||||
</button> : <label key={client.id} className="agent-client-option">
|
||||
{matches.map((client) => <label key={client.id} className="agent-client-option">
|
||||
<input type="checkbox" aria-label={client.name} checked={draftSelection.includes(client.id)}
|
||||
onChange={(event) => setDraftIds(event.currentTarget.checked
|
||||
? [...draftSelection, client.id] : draftSelection.filter((id) => id !== client.id))} />
|
||||
@@ -173,7 +169,7 @@ export function AgentClientList<Id extends string>({
|
||||
</label>)}
|
||||
{!matches.length ? <p role="status">{t('agents.clients.noResults')}</p> : null}
|
||||
</div>
|
||||
{managing ? <><div className="agent-client-manager-defaults">
|
||||
<div className="agent-client-manager-defaults">
|
||||
<button type="button" className="secondary-button compact-button" onClick={() => setDraftIds(null)}>
|
||||
{t('agents.clients.automatic')}
|
||||
</button>
|
||||
@@ -185,7 +181,7 @@ export function AgentClientList<Id extends string>({
|
||||
<button type="button" className="secondary-button" onClick={() => setManaging(false)}>{t('common.cancel')}</button>
|
||||
<button type="button" className="primary-button" onClick={save} disabled={!draftSelection.length}>{t('common.save')}</button>
|
||||
</div>
|
||||
</footer></> : null}
|
||||
</footer>
|
||||
</dialog> : null}
|
||||
</>;
|
||||
}
|
||||
+84
-82
@@ -47,8 +47,8 @@ import { TemplateConfigSection } from '../components/TemplateConfigSection';
|
||||
import { SettingsHelp } from '../components/SettingsHelp';
|
||||
import { templateMessages, templateText } from '../i18n/templateConfig';
|
||||
import {
|
||||
settingsCategories, settingsViews, settingsTemplateGroups,
|
||||
allSettingsTemplateGroups, settingsMessages, type SettingsCategory, type SettingsView,
|
||||
settingsCategories, settingsTemplateGroups,
|
||||
allSettingsTemplateGroups, settingsMessages, type SettingsCategory,
|
||||
} from '../services/settingsNavigation';
|
||||
|
||||
type CoreConfigSettings = {
|
||||
@@ -96,8 +96,8 @@ type ConfigAction =
|
||||
| null;
|
||||
type ConfigSubpage = SettingsCategory;
|
||||
const CONFIG_SUBPAGES = settingsCategories.map(category => category.id);
|
||||
const CATEGORY_ICONS = { general: ShieldCheck, routing: Route, requests: SlidersHorizontal, oauth: KeyRound, diagnostics: FileText, extensions: Puzzle, software: Settings2 };
|
||||
type SettingDestination = { category: ConfigSubpage; view?: SettingsView; target: string; field?: string };
|
||||
const CATEGORY_ICONS = { general: ShieldCheck, aliases: Link2, routing: Route, requests: SlidersHorizontal, oauth: KeyRound, diagnostics: FileText, extensions: Puzzle, software: Settings2 };
|
||||
type SettingDestination = { category: ConfigSubpage; target: string; field?: string };
|
||||
type SettingSearchEntry = SettingDestination & { title: string; context: string; keywords: string };
|
||||
|
||||
type CloseBehavior = 'ask' | 'exit' | 'minimize-to-tray';
|
||||
@@ -213,8 +213,6 @@ export function ConfigPanelPage() {
|
||||
<FloatingNotice key={feedback.revision} notice={feedback.notice} onDismiss={feedback.clearNotice} />
|
||||
);
|
||||
const [activeSubpage, setActiveSubpage] = useState<ConfigSubpage>('general');
|
||||
const [categoryViews, setCategoryViews] = useState<Partial<Record<ConfigSubpage, SettingsView>>>({ general: 'access', requests: 'behavior' });
|
||||
const activeView = categoryViews[activeSubpage] ?? 'behavior';
|
||||
const [settingsSearch, setSettingsSearch] = useState('');
|
||||
const [dirtyTemplateGroups, setDirtyTemplateGroups] = useState<readonly string[]>([]);
|
||||
const [sensitiveWordsDirty, setSensitiveWordsDirty] = useState(false);
|
||||
@@ -1036,22 +1034,20 @@ export function ConfigPanelPage() {
|
||||
onEscape: busyAction === 'delete-key' ? undefined : () => setDeleteIndex(null),
|
||||
preventEscape: busyAction === 'delete-key',
|
||||
});
|
||||
const activateConfigSubpage = (subpage: ConfigSubpage, view?: SettingsView) => {
|
||||
const activateConfigSubpage = (subpage: ConfigSubpage) => {
|
||||
setSettingsSearch('');
|
||||
setActiveSubpage(subpage);
|
||||
const nextView = view ?? categoryViews[subpage] ?? 'behavior';
|
||||
if (subpage === 'general' || subpage === 'requests') {
|
||||
setCategoryViews(previous => ({ ...previous, [subpage]: nextView }));
|
||||
if (subpage === 'requests') {
|
||||
setSensitiveWordsVisited(true);
|
||||
}
|
||||
if (nextView === 'sensitive-words') setSensitiveWordsVisited(true);
|
||||
if (nextView === 'aliases') setAliasesVisited(true);
|
||||
if (subpage === 'aliases') setAliasesVisited(true);
|
||||
};
|
||||
const handleConfigTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>, subpage: ConfigSubpage) => {
|
||||
handleHorizontalTabKey(event, CONFIG_SUBPAGES, subpage, activateConfigSubpage,
|
||||
next => document.getElementById(`config-subpage-tab-${next}`));
|
||||
};
|
||||
const navigateToSetting = (destination: SettingDestination) => {
|
||||
activateConfigSubpage(destination.category, destination.view);
|
||||
activateConfigSubpage(destination.category);
|
||||
setPendingDestination(destination);
|
||||
};
|
||||
const nativeDirty = {
|
||||
@@ -1060,20 +1056,20 @@ export function ConfigPanelPage() {
|
||||
retry: retrySettingsDirty, logging: loggingSettingsDirty, software: softwareSettingsDirty,
|
||||
};
|
||||
const nativeEntries: SettingSearchEntry[] = [
|
||||
{ category: 'general', view: 'access', target: 'config-native-keys', title: t('config.keys.title'), context: '', keywords: 'API key token 鉴权密钥 api-keys access' },
|
||||
{ category: 'general', view: 'access', target: 'config-native-management', title: t('config.webuiKey.title'), context: '', keywords: 'WebUI secret-key 密钥 管理接口 面板 密码 management security' },
|
||||
{ category: 'general', view: 'connection', target: 'config-native-network', title: st('nativeNetwork'), context: '', keywords: [t('config.network.port'), t('config.network.listenHost'), t('config.network.proxyUrl'), t('config.network.systemProxy'), 'server host port proxy URL 8317 127.0.0.1 0.0.0.0 监听 地址 系统代理'].join(' ') },
|
||||
{ category: 'general', view: 'connection', target: 'config-native-tls', title: t('config.tls.enable'), context: '', keywords: [t('config.tls.cert'), t('config.tls.key'), 'TLS HTTPS cert key certificate 证书 私钥 加密'].join(' ') },
|
||||
{ category: 'general', target: 'config-native-keys', title: t('config.keys.title'), context: '', keywords: 'API key token 鉴权密钥 api-keys access' },
|
||||
{ category: 'general', target: 'config-native-management', title: t('config.webuiKey.title'), context: '', keywords: 'WebUI secret-key 密钥 管理接口 面板 密码 management security' },
|
||||
{ category: 'general', target: 'config-native-network', title: st('nativeNetwork'), context: '', keywords: [t('config.network.port'), t('config.network.listenHost'), t('config.network.proxyUrl'), t('config.network.systemProxy'), 'server host port proxy URL 8317 127.0.0.1 0.0.0.0 监听 地址 系统代理'].join(' ') },
|
||||
{ category: 'general', target: 'config-native-tls', title: t('config.tls.enable'), context: '', keywords: [t('config.tls.cert'), t('config.tls.key'), 'TLS HTTPS cert key certificate 证书 私钥 加密'].join(' ') },
|
||||
{ category: 'routing', target: 'config-native-routing', title: st('nativeRouting'), context: '', keywords: [t('config.network.sessionAffinity'), t('config.network.sessionTtl'), t('config.routing.title'), 'session affinity ttl routing strategy round-robin 会话 粘性 加权 轮询'].join(' ') },
|
||||
{ category: 'routing', target: 'config-native-retry', title: st('nativeRetry'), context: '', keywords: [t('config.network.disableCooling'), t('config.network.requestRetry'), t('config.network.maxRetryCredentials'), t('config.network.maxRetryInterval'), t('config.network.streamingBootstrapRetries'), 'retry retries cooldown 重试 冷却 流式 失败 fallback'].join(' ') },
|
||||
{ category: 'diagnostics', target: 'config-native-logging', title: t('config.diagnostics.title'), context: '', keywords: [t('config.diagnostics.debug.title'), t('config.diagnostics.commercial.title'), t('config.diagnostics.fileLogging.title'), t('config.diagnostics.usage.title'), t('config.diagnostics.maxSize.title'), t('config.diagnostics.errorFiles.title'), t('config.diagnostics.redisRetention.title'), 'debug commercial-mode logging logs usage statistics redis retention 日志 调试 商业模式 用量 统计 留存 文件 容量'].join(' ') },
|
||||
{ category: 'software', target: 'config-native-software', title: t('config.software.title'), context: '', keywords: [t('config.software.autostart'), t('config.software.startCoreOnLaunch'), t('config.software.silentStart'), t('config.software.closeBehavior'), t('config.software.defaultTerminal'), 'startup autostart silent tray close terminal 软件 自启动 启动 静默 托盘 关闭 终端'].join(' ') },
|
||||
{ category: 'requests', view: 'aliases', target: 'config-native-aliases', title: t('app.nav.thinkingAliases'), context: '', keywords: 'thinking reasoning speed aliases effort 思考 推理 速度 别名 模型' },
|
||||
{ category: 'requests', view: 'sensitive-words', target: 'config-native-sensitive-words', title: t('config.sensitiveWords.title'), context: '', keywords: 'sensitive words filters antigravity devin 敏感词 内容 过滤' },
|
||||
{ category: 'aliases', target: 'config-native-aliases', title: t('app.nav.thinkingAliases'), context: '', keywords: 'thinking reasoning speed aliases effort 思考 推理 速度 别名 模型' },
|
||||
{ category: 'requests', target: 'config-native-sensitive-words', title: t('config.sensitiveWords.title'), context: '', keywords: 'sensitive words filters antigravity devin 敏感词 内容 过滤' },
|
||||
];
|
||||
const category = settingsCategories.find(item => item.id === activeSubpage)!;
|
||||
const nativeCategoryDirty: Record<ConfigSubpage, boolean> = {
|
||||
general: nativeDirty.management || networkSettingsDirty || tlsSettingsDirty,
|
||||
aliases: false,
|
||||
routing: sessionRoutingDirty || retrySettingsDirty,
|
||||
requests: sensitiveWordsDirty,
|
||||
diagnostics: loggingSettingsDirty,
|
||||
@@ -1083,12 +1079,11 @@ export function ConfigPanelPage() {
|
||||
};
|
||||
const categoryDirty = (id: ConfigSubpage) => nativeCategoryDirty[id]
|
||||
|| settingsTemplateGroups[id].some(group => dirtyTemplateGroups.includes(group.id));
|
||||
const templateGroups = (activeSubpage === 'general' && activeView !== 'access')
|
||||
|| (activeSubpage === 'requests' && activeView !== 'behavior') ? [] : settingsTemplateGroups[activeSubpage];
|
||||
const templateGroups = settingsTemplateGroups[activeSubpage];
|
||||
const searchEntries: SettingSearchEntry[] = [
|
||||
...nativeEntries,
|
||||
...settingsCategories.flatMap(item => settingsTemplateGroups[item.id].flatMap(group => group.fields.map((field, index) => ({
|
||||
category: item.id, view: item.id === 'general' ? 'access' as const : item.id === 'requests' ? 'behavior' as const : undefined,
|
||||
category: item.id,
|
||||
target: `config-group-${group.id}`, field: `template-field-${group.id}-${index}`,
|
||||
title: templateText(field.label, locale), context: templateText(group.title, locale),
|
||||
keywords: `${JSON.stringify(field)} ${templateText(group.description, locale)} ${field.path.join('.')}`,
|
||||
@@ -1100,7 +1095,6 @@ export function ConfigPanelPage() {
|
||||
const text = `${entry.title} ${entry.context} ${entry.keywords} ${categoryTitle}`.toLocaleLowerCase();
|
||||
return searchTerms.every(term => text.includes(term));
|
||||
});
|
||||
const views = activeSubpage === 'general' ? settingsViews.general : activeSubpage === 'requests' ? settingsViews.requests : [];
|
||||
|
||||
return (
|
||||
<section className="page config-page">
|
||||
@@ -1120,11 +1114,9 @@ export function ConfigPanelPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="config-settings-content" id="config-subpage-panel" role="tabpanel" aria-labelledby={`config-subpage-tab-${activeSubpage}`}>
|
||||
{searching ? <section className="config-search-results" aria-label={st('results')}><div className="config-search-heading"><h2>{st('results')}</h2><span role="status">{searchResults.length}</span></div>{searchResults.length ? <div className="config-search-result-list">{searchResults.map(entry => <button type="button" key={`${entry.target}-${entry.field ?? ''}`} onClick={() => navigateToSetting(entry)}><span className="config-search-result-copy"><strong>{entry.title}</strong><small>{templateText(settingsCategories.find(item => item.id === entry.category)!.title, locale)}{entry.context ? ` / ${entry.context}` : ''}</small></span><ChevronRight size={17} aria-hidden="true" /></button>)}</div> : <p className="config-search-empty">{st('noResults')}</p>}</section> : <>
|
||||
{views.length ? <div className="config-settings-views" role="group" aria-label={templateText(category.title, locale)}>{views.map(view => <button key={view.id} id={`config-view-${view.id}`} type="button" className={activeView === view.id ? 'active' : ''} aria-pressed={activeView === view.id} onClick={() => activateConfigSubpage(activeSubpage, view.id)}>{templateText(view.title, locale)}</button>)}</div> : null}
|
||||
</>}
|
||||
{searching ? <section className="config-search-results" aria-label={st('results')}><div className="config-search-heading"><h2>{st('results')}</h2><span role="status">{searchResults.length}</span></div>{searchResults.length ? <div className="config-search-result-list">{searchResults.map(entry => <button type="button" key={`${entry.target}-${entry.field ?? ''}`} onClick={() => navigateToSetting(entry)}><span className="config-search-result-copy"><strong>{entry.title}</strong><small>{templateText(settingsCategories.find(item => item.id === entry.category)!.title, locale)}{entry.context ? ` / ${entry.context}` : ''}</small></span><ChevronRight size={17} aria-hidden="true" /></button>)}</div> : <p className="config-search-empty">{st('noResults')}</p>}</section> : null}
|
||||
<div className="config-settings-cards">
|
||||
<section id="config-native-keys" tabIndex={-1} hidden={searching || activeSubpage !== 'general' || activeView !== 'access'} className="panel config-keys-panel">
|
||||
<section id="config-native-keys" tabIndex={-1} hidden={searching || activeSubpage !== 'general'} className="panel config-keys-panel">
|
||||
<div className="config-panel-heading">
|
||||
<div className="config-heading-title">
|
||||
<KeyRound size={18} aria-hidden="true" />
|
||||
@@ -1226,7 +1218,7 @@ export function ConfigPanelPage() {
|
||||
</div>
|
||||
{!addDialogOpen && deleteIndex === null ? renderFeedback(keyFeedback) : null}
|
||||
</section>
|
||||
<section id="config-native-management" tabIndex={-1} hidden={searching || activeSubpage !== 'general' || activeView !== 'access'} className="panel config-management-panel">
|
||||
<section id="config-native-management" tabIndex={-1} hidden={searching || activeSubpage !== 'general'} className="panel config-management-panel">
|
||||
<div className="config-panel-heading">
|
||||
<div className="config-heading-title">
|
||||
<ShieldCheck size={18} aria-hidden="true" />
|
||||
@@ -1336,7 +1328,7 @@ export function ConfigPanelPage() {
|
||||
{renderFeedback(managementFeedback)}
|
||||
</div>
|
||||
</section>
|
||||
<section id="config-native-network" tabIndex={-1} hidden={searching || activeSubpage !== 'general' || activeView !== 'connection'}
|
||||
<section id="config-native-network" tabIndex={-1} hidden={searching || activeSubpage !== 'general'}
|
||||
className="config-network-section"
|
||||
aria-labelledby="config-network-section-title"
|
||||
>
|
||||
@@ -1346,15 +1338,7 @@ export function ConfigPanelPage() {
|
||||
<h3 id="config-network-section-title">{st('nativeNetwork')}</h3>
|
||||
</div>
|
||||
<span className="config-card-status" role="status">{nativeDirty.network ? st('dirty') : ''}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button compact-button"
|
||||
disabled={controlsDisabled || !networkSettingsDirty}
|
||||
onClick={() => void saveNetworkEndpointSettings()}
|
||||
>
|
||||
<Check size={16} aria-hidden="true" />
|
||||
{busyAction === 'network' ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
|
||||
</div>
|
||||
{renderFeedback(networkFeedback)}
|
||||
<div className="config-network-grid">
|
||||
@@ -1461,8 +1445,18 @@ export function ConfigPanelPage() {
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="config-native-tls" tabIndex={-1} hidden={searching || activeSubpage !== 'general' || activeView !== 'connection'}
|
||||
|
||||
<div className="config-settings-save-row"><button
|
||||
type="button"
|
||||
className="primary-button compact-button"
|
||||
disabled={controlsDisabled || !networkSettingsDirty}
|
||||
onClick={() => void saveNetworkEndpointSettings()}
|
||||
>
|
||||
<Check size={16} aria-hidden="true" />
|
||||
{busyAction === 'network' ? t('common.saving') : t('common.save')}
|
||||
</button></div>
|
||||
</section>
|
||||
<section id="config-native-tls" tabIndex={-1} hidden={searching || activeSubpage !== 'general'}
|
||||
className="config-network-section"
|
||||
aria-labelledby="config-tls-section-title"
|
||||
>
|
||||
@@ -1477,15 +1471,7 @@ export function ConfigPanelPage() {
|
||||
{tlsStatusLabel}
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button compact-button"
|
||||
disabled={tlsSettingsLoading || tlsSettings === null || busyAction !== null || tlsFileSelecting !== null || !tlsSettingsDirty}
|
||||
onClick={() => void saveTlsSettings()}
|
||||
>
|
||||
<Check size={16} aria-hidden="true" />
|
||||
{busyAction === 'tls' ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1584,6 +1570,16 @@ export function ConfigPanelPage() {
|
||||
|
||||
{renderFeedback(tlsFeedback)}
|
||||
</div>
|
||||
|
||||
<div className="config-settings-save-row"><button
|
||||
type="button"
|
||||
className="primary-button compact-button"
|
||||
disabled={tlsSettingsLoading || tlsSettings === null || busyAction !== null || tlsFileSelecting !== null || !tlsSettingsDirty}
|
||||
onClick={() => void saveTlsSettings()}
|
||||
>
|
||||
<Check size={16} aria-hidden="true" />
|
||||
{busyAction === 'tls' ? t('common.saving') : t('common.save')}
|
||||
</button></div>
|
||||
</section>
|
||||
<section id="config-native-routing" tabIndex={-1} hidden={searching || activeSubpage !== 'routing'}
|
||||
className="config-network-section"
|
||||
@@ -1595,15 +1591,7 @@ export function ConfigPanelPage() {
|
||||
<h3 id="config-routing-section-title">{st('nativeRouting')}</h3>
|
||||
</div>
|
||||
<span className="config-card-status" role="status">{nativeDirty.routing ? st('dirty') : ''}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button compact-button"
|
||||
disabled={controlsDisabled || !sessionRoutingDirty}
|
||||
onClick={() => void saveSessionRoutingSettings()}
|
||||
>
|
||||
<Check size={16} aria-hidden="true" />
|
||||
{busyAction === 'routing' ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
|
||||
</div>
|
||||
{renderFeedback(routingFeedback)}
|
||||
<div className="config-network-grid">
|
||||
@@ -1684,7 +1672,17 @@ export function ConfigPanelPage() {
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="config-settings-save-row"><button
|
||||
type="button"
|
||||
className="primary-button compact-button"
|
||||
disabled={controlsDisabled || !sessionRoutingDirty}
|
||||
onClick={() => void saveSessionRoutingSettings()}
|
||||
>
|
||||
<Check size={16} aria-hidden="true" />
|
||||
{busyAction === 'routing' ? t('common.saving') : t('common.save')}
|
||||
</button></div>
|
||||
</section>
|
||||
<section id="config-native-retry" tabIndex={-1} hidden={searching || activeSubpage !== 'routing'}
|
||||
className="config-network-section"
|
||||
aria-labelledby="config-retry-section-title"
|
||||
@@ -1695,15 +1693,7 @@ export function ConfigPanelPage() {
|
||||
<h3 id="config-retry-section-title">{st('nativeRetry')}</h3>
|
||||
</div>
|
||||
<span className="config-card-status" role="status">{nativeDirty.retry ? st('dirty') : ''}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button compact-button"
|
||||
disabled={controlsDisabled || !retrySettingsDirty}
|
||||
onClick={() => void saveRetrySettings()}
|
||||
>
|
||||
<Check size={16} aria-hidden="true" />
|
||||
{busyAction === 'retry' ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
|
||||
</div>
|
||||
{renderFeedback(retryFeedback)}
|
||||
<div className="config-network-grid">
|
||||
@@ -1845,7 +1835,17 @@ export function ConfigPanelPage() {
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="config-settings-save-row"><button
|
||||
type="button"
|
||||
className="primary-button compact-button"
|
||||
disabled={controlsDisabled || !retrySettingsDirty}
|
||||
onClick={() => void saveRetrySettings()}
|
||||
>
|
||||
<Check size={16} aria-hidden="true" />
|
||||
{busyAction === 'retry' ? t('common.saving') : t('common.save')}
|
||||
</button></div>
|
||||
</section>
|
||||
<section id="config-native-logging" tabIndex={-1} hidden={searching || activeSubpage !== 'diagnostics'} className="panel config-diagnostics-panel">
|
||||
<div className="config-panel-heading">
|
||||
<div className="config-heading-title">
|
||||
@@ -2045,15 +2045,7 @@ export function ConfigPanelPage() {
|
||||
{softwareStatusLabel}
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button compact-button"
|
||||
disabled={softwareSettingsLoading || softwareSettings === null || busyAction !== null || !softwareSettingsDirty}
|
||||
onClick={() => void saveSoftwareSettings()}
|
||||
>
|
||||
<Check size={16} aria-hidden="true" />
|
||||
{busyAction === 'software' ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div className="config-software-content">
|
||||
@@ -2188,11 +2180,21 @@ export function ConfigPanelPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div id="config-native-aliases" tabIndex={-1} role="region" aria-label={t('app.nav.thinkingAliases')} hidden={searching || activeSubpage !== 'requests' || activeView !== 'aliases'}>{aliasesVisited ? <ThinkingAliasesPage embedded /> : null}</div>
|
||||
<div id="config-native-sensitive-words" tabIndex={-1} role="region" aria-label={t('config.sensitiveWords.title')} hidden={searching || activeSubpage !== 'requests' || activeView !== 'sensitive-words'}>{sensitiveWordsVisited ? <SensitiveWordsPage onDirtyChange={setSensitiveWordsDirty} /> : null}</div>
|
||||
|
||||
<div className="config-settings-save-row"><button
|
||||
type="button"
|
||||
className="primary-button compact-button"
|
||||
disabled={softwareSettingsLoading || softwareSettings === null || busyAction !== null || !softwareSettingsDirty}
|
||||
onClick={() => void saveSoftwareSettings()}
|
||||
>
|
||||
<Check size={16} aria-hidden="true" />
|
||||
{busyAction === 'software' ? t('common.saving') : t('common.save')}
|
||||
</button></div>
|
||||
</section>
|
||||
</div>
|
||||
<div id="config-template-panel" hidden={searching || templateGroups.length === 0}><TemplateConfigSection groups={allSettingsTemplateGroups} visibleGroups={templateGroups.map(group => group.id)} onDirtyGroupsChange={setDirtyTemplateGroups} /></div>
|
||||
<div id="config-native-aliases" tabIndex={-1} role="region" aria-label={t('app.nav.thinkingAliases')} hidden={searching || activeSubpage !== 'aliases'}>{aliasesVisited ? <ThinkingAliasesPage embedded /> : null}</div>
|
||||
<div id="config-native-sensitive-words" tabIndex={-1} role="region" aria-label={t('config.sensitiveWords.title')} hidden={searching || activeSubpage !== 'requests'}>{sensitiveWordsVisited ? <SensitiveWordsPage onDirtyChange={setSensitiveWordsDirty} /> : null}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -125,8 +125,6 @@
|
||||
.home-runtime-state.pending { color: var(--amber-text); }
|
||||
.home-runtime-dot { width: 8px; height: 8px; flex: none; border-radius: 50%; background: currentColor; }
|
||||
.home-runtime-error { margin: 0; color: var(--error-color); font-size: 14px; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.home-dashboard .control-action-row .home-runtime-stop { color: var(--text-secondary); }
|
||||
.home-dashboard .control-action-row .home-runtime-stop:hover:not(:disabled) { color: var(--error-color); border-color: color-mix(in srgb, var(--error-color) 35%, var(--border-color)); background: var(--bg-error-light); }
|
||||
.home-dashboard .home-runtime-refresh { width: 32px; height: 32px; min-width: 32px; border-color: transparent; background: transparent; }
|
||||
.home-dashboard .home-runtime-refresh svg { width: 18px; height: 18px; }
|
||||
|
||||
|
||||
@@ -259,7 +259,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
|
||||
<div className="button-row panel-action-row control-action-row">
|
||||
<button
|
||||
type="button"
|
||||
className={coreRunning ? 'secondary-button home-runtime-stop' : 'primary-button'}
|
||||
className={coreRunning ? 'danger-button home-runtime-stop' : 'primary-button'}
|
||||
disabled={!coreInstalled || coreProcessBusy}
|
||||
onClick={() =>
|
||||
void runCoreProcessCommand(
|
||||
|
||||
@@ -207,6 +207,7 @@ export function ThinkingAliasesPage({ embedded = false }: { embedded?: boolean }
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [modelPickerOpen, setModelPickerOpen] = useState(false);
|
||||
const modelSearchRef = useRef<HTMLInputElement>(null);
|
||||
const [activeSourceIndex, setActiveSourceIndex] = useState(0);
|
||||
const modelPickerRef = useRef<HTMLDivElement>(null);
|
||||
const generatedAliasRef = useRef('');
|
||||
@@ -486,7 +487,11 @@ export function ThinkingAliasesPage({ embedded = false }: { embedded?: boolean }
|
||||
|
||||
const editorRef = useDialogFocusTrap<HTMLElement>({
|
||||
active: editorOpen,
|
||||
onEscape: busyAlias ? undefined : closeEditor,
|
||||
initialFocusRef: modelSearchRef,
|
||||
onEscape: busyAlias ? undefined : () => {
|
||||
if (modelPickerOpen) setModelPickerOpen(false);
|
||||
else closeEditor();
|
||||
},
|
||||
preventEscape: Boolean(busyAlias),
|
||||
});
|
||||
|
||||
@@ -607,6 +612,7 @@ export function ThinkingAliasesPage({ embedded = false }: { embedded?: boolean }
|
||||
{loading ? <LoaderCircle size={15} className="spin" /> : <Search size={15} />}
|
||||
<input
|
||||
id="thinking-model-search"
|
||||
ref={modelSearchRef}
|
||||
role="combobox"
|
||||
autoFocus
|
||||
aria-autocomplete="list"
|
||||
|
||||
@@ -7,6 +7,7 @@ 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: 'aliases', title: text('模型别名', 'Model aliases', 'モデル別名') },
|
||||
{ id: 'routing', title: text('路由与稳定性', 'Routing & reliability', 'ルーティングと安定性') },
|
||||
{ id: 'requests', title: text('模型与请求', 'Models & requests', 'モデルとリクエスト') },
|
||||
{ id: 'oauth', title: text('上游与凭据', 'Upstreams & credentials', '上流と認証情報') },
|
||||
@@ -15,23 +16,13 @@ export const settingsCategories = [
|
||||
{ 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'),
|
||||
aliases: [],
|
||||
routing: [...routingTemplateGroups, ...extensionTemplateGroups.filter(group => group.id === 'extensions-concurrency')],
|
||||
requests: [...requestTemplateGroups, ...payloadTemplateGroups],
|
||||
oauth: oauthTemplateGroups,
|
||||
oauth: ['oauth-common', 'oauth-codex', 'oauth-others', 'oauth-claude', 'oauth-models', 'oauth-media']
|
||||
.map(id => oauthTemplateGroups.find(group => group.id === id)!),
|
||||
diagnostics: [...generalTemplateGroups.filter(group => group.id === 'diagnostics'), ...extensionTemplateGroups.filter(group => group.id === 'extensions-inflight')],
|
||||
extensions: extensionTemplateGroups.filter(group => group.id === 'extensions-plugins'),
|
||||
software: [],
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
.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-topnav { position: sticky; top: 0; z-index: 10; min-width: 0; padding-top: 8px; background: var(--bg-secondary); }
|
||||
.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); }
|
||||
@@ -21,12 +21,42 @@
|
||||
.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); }
|
||||
/* Only flatten page sections. Dialogs may be rendered inside these sections. */
|
||||
.config-page .config-settings-cards > section,
|
||||
.config-page #config-template-panel > .template-config-section > .template-config-card,
|
||||
.config-page #config-native-sensitive-words > .config-sensitive-words-panel,
|
||||
.config-page #config-native-aliases > .thinking-alias-page > .thinking-alias-list-panel {
|
||||
margin: 0;
|
||||
padding: 20px 0;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.config-page .config-settings-content .template-config-section,
|
||||
.config-page .config-settings-content .config-settings-cards { gap: 0; margin-top: 0; }
|
||||
.config-page .config-settings-content .config-sensitive-words-panel { width: 100%; }
|
||||
.config-page .config-settings-content .thinking-alias-list-panel { height: auto; min-height: 0; }
|
||||
.config-page #config-native-aliases > .thinking-alias-page { gap: 12px; padding-top: 20px; }
|
||||
.config-page #config-native-aliases > .thinking-alias-page > .management-header { margin: 0; padding: 0; }
|
||||
.config-page #config-native-aliases > .thinking-alias-page > .thinking-alias-list-panel { padding-top: 0; }
|
||||
.config-page .config-sensitive-words-grid { gap: 16px; }
|
||||
.config-page .config-sensitive-words-grid > .config-sensitive-words-card {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.config-page .config-sensitive-words-grid > .config-sensitive-words-card + .config-sensitive-words-card {
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
.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, .config-settings-content .template-config-card { scroll-margin-top: 80px; border-radius: 12px; }
|
||||
.config-settings-content :is(input, select, textarea, button, .template-config-field) { scroll-margin-top: 80px; }
|
||||
.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; }
|
||||
@@ -97,7 +127,393 @@
|
||||
.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; }
|
||||
}
|
||||
|
||||
/* Settings rows share a label column; controls size to their content, not the window. */
|
||||
.config-page .config-settings-content {
|
||||
--settings-label-width: 250px;
|
||||
--settings-row-gap: 24px;
|
||||
max-width: 1000px;
|
||||
width: 100%;
|
||||
}
|
||||
.config-page .config-settings-content :is(.config-network-grid, .config-diagnostics-toggle-grid, .config-diagnostics-fields, .config-management-form, .template-config-fields) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
.config-page .config-settings-content :is(.config-network-field, .config-management-field, .config-diagnostics-field, .template-config-field:not(.template-config-field-wide)) {
|
||||
display: grid;
|
||||
grid-template-columns: var(--settings-label-width) minmax(0, 1fr);
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
gap: 8px var(--settings-row-gap);
|
||||
min-height: 40px;
|
||||
height: auto;
|
||||
}
|
||||
.config-page .config-settings-content :is(.config-network-field, .config-management-field, .config-diagnostics-field) > :is(input, .config-secret-input, .config-tls-path-control) {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
min-width: 0;
|
||||
}
|
||||
.config-page .config-settings-content .config-network-field > input { max-width: 320px; }
|
||||
.config-page .config-settings-content :is(.config-network-port-field, #config-native-retry .config-network-field, .config-diagnostics-field) > input { max-width: 160px; }
|
||||
.config-page .config-settings-content #config-input-config-network-sessionTtl { max-width: 200px; }
|
||||
.config-page .config-settings-content .config-network-toggle { justify-content: start; }
|
||||
.config-page .config-settings-content .config-network-toggle > .switch-control { justify-self: start; }
|
||||
.config-page .config-settings-content .template-config-label { gap: 6px; font-weight: 500; }
|
||||
.config-page .config-settings-content .template-config-label-tools { margin-left: 0; }
|
||||
.config-page .config-settings-content .template-config-control { max-width: 360px; }
|
||||
.config-page .config-settings-content [data-field-type='number'] > .template-config-control { max-width: 160px; }
|
||||
.config-page .config-settings-content .template-config-toggle > .template-config-control { justify-self: start; }
|
||||
.config-page .config-settings-content .template-config-field > small { grid-column: 2; }
|
||||
.config-page .config-settings-content .template-config-field-wide { gap: 10px; }
|
||||
.config-page .config-settings-content .template-config-field-wide > .template-config-control { max-width: 720px; }
|
||||
.config-page .config-settings-content .template-config-custom { max-width: 100%; }
|
||||
.config-page .config-settings-content .template-config-custom .template-config-control { max-width: none; }
|
||||
.config-page .config-settings-content .template-config-actions {
|
||||
border-top: 0;
|
||||
padding-top: 0;
|
||||
margin-top: 20px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.config-page .config-settings-content .template-config-actions > div { margin-left: 0; }
|
||||
.config-page .config-settings-content .template-config-actions > span:empty { display: none; }
|
||||
.config-page .config-settings-content .config-diagnostics-setting {
|
||||
grid-template-columns: 24px calc(var(--settings-label-width) - 34px) auto;
|
||||
gap: 10px;
|
||||
justify-content: start;
|
||||
min-height: 40px;
|
||||
padding: 0;
|
||||
}
|
||||
.config-page .config-settings-content .config-diagnostics-setting > .switch-control { margin-left: 14px; }
|
||||
.config-page .config-settings-content .config-software-setting-row {
|
||||
display: grid;
|
||||
grid-template-columns: var(--settings-label-width) minmax(0, 1fr);
|
||||
gap: var(--settings-row-gap);
|
||||
justify-content: start;
|
||||
padding: 12px 0;
|
||||
min-height: 56px;
|
||||
}
|
||||
.config-page .config-settings-content .config-software-settings-list { border: 0; background: transparent; }
|
||||
.config-page .config-settings-content .config-software-select { width: min(100%, 320px); padding: 0; }
|
||||
.config-page .config-settings-content .config-network-routing-field > .routing-segmented { width: 100%; max-width: 540px; }
|
||||
.config-page .config-settings-content .config-network-routing-field > .config-network-label { flex-wrap: wrap; }
|
||||
.config-page .config-settings-content .config-inline-status { margin-left: 8px; }
|
||||
.config-page .config-settings-content .config-management-form-footer { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; }
|
||||
.config-page .config-settings-content .config-management-error:not(.visible) { display: none; }
|
||||
.config-page .config-settings-content .config-management-actions { justify-content: flex-start; flex-wrap: wrap; }
|
||||
.config-page .config-settings-content :is(.config-management-form-footer, .config-diagnostics-actions, .template-config-actions) { padding-left: calc(var(--settings-label-width) + var(--settings-row-gap)); }
|
||||
.config-page .config-settings-content .config-diagnostics-actions { justify-content: flex-start; }
|
||||
.config-page .config-settings-content .config-proxy-field { align-items: start; }
|
||||
.config-page .config-settings-content .config-proxy-heading { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.config-page .config-settings-content .config-proxy-heading > .settings-help { margin-right: 0; }
|
||||
.config-page .config-settings-content .config-proxy-system-toggle { margin: 4px 0 0; width: 100%; justify-content: flex-start; gap: 12px; }
|
||||
.config-page .config-settings-content .config-proxy-field > input { max-width: 420px; }
|
||||
.config-page .config-settings-content .config-keys-panel { min-height: 0; }
|
||||
.config-page .config-settings-content .config-sensitive-words-grid { max-width: 720px; }
|
||||
@media (max-width: 900px) {
|
||||
.config-page .config-settings-content { --settings-label-width: 210px; --settings-row-gap: 16px; }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.config-page .config-settings-content :is(.config-network-field:not(.config-network-toggle), .config-management-field, .config-diagnostics-field, .template-config-field:not(.template-config-toggle)) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.config-page .config-settings-content :is(.config-network-toggle, .template-config-field.template-config-toggle, .config-software-setting-row) {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
}
|
||||
.config-page .config-settings-content .config-diagnostics-setting { grid-template-columns: 24px minmax(0, 1fr) auto; }
|
||||
.config-page .config-settings-content .config-diagnostics-setting > .switch-control { margin-left: 0; }
|
||||
.config-page .config-settings-content .template-config-field > small { grid-column: 1 / -1; }
|
||||
.config-page .config-settings-content :is(.config-management-form-footer, .config-diagnostics-actions, .template-config-actions) { padding-left: 0; }
|
||||
.config-page .config-settings-content .config-software-setting-row:has(select) { grid-template-columns: minmax(0, 1fr); }
|
||||
.config-page .config-settings-content .config-proxy-system-toggle { width: auto; margin: 0 0 0 auto; }
|
||||
}
|
||||
.config-page .config-settings-content .config-settings-save-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
padding-left: calc(var(--settings-label-width) + var(--settings-row-gap));
|
||||
}
|
||||
.config-page .config-settings-content .config-diagnostics-footer { display: flex; flex-direction: column; align-items: stretch; gap: 8px; }
|
||||
.config-page .config-settings-content .config-diagnostics-footer > .config-form-message:not(.error) { display: none; }
|
||||
.config-page .config-settings-content .config-diagnostics-actions .config-card-status { margin-left: 0; }
|
||||
.config-page .config-settings-content .config-diagnostics-actions .config-card-status:empty { display: none; }
|
||||
.config-page .config-settings-content .config-proxy-heading { justify-content: flex-start; }
|
||||
.config-page #config-native-software > :is(.config-panel-heading, .config-software-content) { width: 100%; margin: 0; }
|
||||
@media (max-width: 720px) {
|
||||
.config-page .config-settings-content .config-settings-save-row { padding-left: 0; }
|
||||
.config-page .config-settings-content .template-config-actions > div { width: auto; }
|
||||
.config-page .config-settings-content .template-config-actions button { flex: none; }
|
||||
}
|
||||
|
||||
/* Section headings stay light; paired fields read as individual form controls. */
|
||||
.config-page .config-settings-layout { gap: 12px; }
|
||||
.config-page .config-settings-cards > section > :is(.config-panel-heading, .config-network-section-heading),
|
||||
.config-page #config-template-panel > .template-config-section > .template-config-card > .template-config-heading,
|
||||
.config-page #config-native-aliases > .thinking-alias-page > .management-header,
|
||||
.config-page #config-native-sensitive-words > .config-sensitive-words-panel > .config-panel-heading {
|
||||
min-height: 32px;
|
||||
margin: 0 0 16px;
|
||||
padding: 0 0 10px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.config-page .config-settings-content .template-config-fields { margin-top: 0; }
|
||||
.config-page .config-settings-content :is(.config-settings-save-row, .template-config-actions) { margin-top: 12px; }
|
||||
.config-page .config-settings-content .config-network-grid { row-gap: 12px; }
|
||||
@media (min-width: 1160px) {
|
||||
.config-page .config-settings-content { --settings-label-width: 210px; --settings-row-gap: 14px; }
|
||||
.config-page .config-settings-content :is(.config-network-grid, .config-diagnostics-toggle-grid, .config-diagnostics-fields, .template-config-fields) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px 32px;
|
||||
}
|
||||
.config-page .config-settings-content .config-diagnostics-setting {
|
||||
grid-template-columns: 24px calc(var(--settings-label-width) - 34px) auto;
|
||||
}
|
||||
.config-page .config-settings-content .config-diagnostics-setting > .switch-control { margin-left: 4px; }
|
||||
.config-page .config-settings-content .template-config-field-wide { grid-column: 1 / -1; }
|
||||
.config-page .config-settings-content :is(.config-network-routing-field, .config-proxy-field) { grid-column: 1 / -1; }
|
||||
.config-page .config-settings-content .config-network-routing-field > .routing-segmented { max-width: 540px; }
|
||||
.config-page .config-settings-content .config-software-settings-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
column-gap: 32px;
|
||||
border-radius: 0;
|
||||
}
|
||||
.config-page .config-settings-content .config-software-setting-row {
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
min-height: 54px;
|
||||
padding-block: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Keep labels attached to their controls rather than spanning empty grid tracks. */
|
||||
.config-page .config-settings-content { max-width: 880px; }
|
||||
.config-page .config-settings-content :is(.config-network-field:not(.config-network-toggle), .config-management-field, .config-diagnostics-field, .template-config-field:not(.template-config-toggle)) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-content: start;
|
||||
gap: 7px;
|
||||
}
|
||||
.config-page .config-settings-content :is(.config-network-toggle, .template-config-field.template-config-toggle, .config-software-setting-row) {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 16px;
|
||||
}
|
||||
.config-page .config-settings-content .config-network-toggle { align-self: end; min-height: 38px; }
|
||||
.config-page .config-settings-content .template-config-toggle { align-self: end; min-height: 38px; }
|
||||
.config-page .config-settings-content :is(.config-network-toggle, .template-config-toggle) > .template-config-control { justify-self: end; }
|
||||
.config-page .config-settings-content .config-diagnostics-setting {
|
||||
grid-template-columns: 24px minmax(0, 1fr) auto;
|
||||
}
|
||||
.config-page .config-settings-content .config-diagnostics-setting > .switch-control { margin: 0; }
|
||||
.config-page .config-settings-content .config-network-toggle > .switch-control { justify-self: end; }
|
||||
.config-page .config-settings-content .template-config-field > small { grid-column: 1 / -1; }
|
||||
.config-page .config-settings-content :is(.config-settings-save-row, .template-config-actions, .config-management-form-footer, .config-diagnostics-actions) {
|
||||
padding-left: 0;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.config-page .config-settings-content .template-config-actions > span { margin-right: auto; }
|
||||
.config-page .config-settings-content .config-management-form-footer { align-items: stretch; }
|
||||
.config-page .config-settings-content .config-management-actions { justify-content: flex-end; }
|
||||
.config-page .config-settings-content .config-proxy-system-toggle { width: auto; margin: 0 0 0 auto; }
|
||||
.config-page .config-settings-content .config-proxy-field > input { max-width: 100%; }
|
||||
.config-page .config-settings-content .config-network-routing-field > .routing-segmented { max-width: 100%; }
|
||||
.config-page .config-settings-content :is(.config-network-grid, .template-config-fields) { row-gap: 18px; }
|
||||
.config-page .config-settings-content .config-management-form { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.config-page .config-settings-content :is(.config-management-field, .config-proxy-field, .config-network-routing-field) > .config-field-label { min-height: 26px; }
|
||||
@media (min-width: 1160px) {
|
||||
.config-page .config-settings-content :is(.config-network-grid, .template-config-fields, .config-diagnostics-fields, .config-diagnostics-toggle-grid) { column-gap: 40px; }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.config-page .config-settings-content .config-management-form { grid-template-columns: minmax(0, 1fr); }
|
||||
.config-page .config-settings-content :is(.config-settings-save-row, .template-config-actions, .config-management-actions, .config-diagnostics-actions) { justify-content: flex-start; }
|
||||
}
|
||||
.config-page .config-settings-cards > section > :is(.config-panel-heading, .config-network-section-heading) { margin-bottom: 0; }
|
||||
.config-page .config-settings-content :is(.config-heading-title h2, .config-network-section-title h3, .template-config-heading h3) { font-size: 15px; font-weight: 600; line-height: 1.5; }
|
||||
.config-page .config-settings-content :is(.config-network-label, .config-field-label, .config-management-field > span) { font-size: 13px; font-weight: 500; }
|
||||
.config-page .config-settings-content .primary-button:disabled { background: var(--bg-secondary); border-color: var(--border-color); color: var(--text-tertiary); opacity: 1; box-shadow: none; }
|
||||
.config-page .config-settings-content .config-proxy-heading { max-width: 420px; }
|
||||
.config-page .config-settings-content :is(.config-settings-save-row, .template-config-actions) { margin-top: 4px; }
|
||||
|
||||
/* Each functional group is a card; complex groups span the available workspace. */
|
||||
.config-page .config-settings-content {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
gap: 20px;
|
||||
max-width: none;
|
||||
}
|
||||
.config-page .config-settings-content > .config-settings-cards,
|
||||
.config-page .config-settings-content > #config-template-panel:not([hidden]),
|
||||
.config-page #config-template-panel:not([hidden]) > .template-config-section:not([hidden]) { display: contents; }
|
||||
.config-page .config-settings-cards > section:not([hidden]),
|
||||
.config-page #config-template-panel > .template-config-section > .template-config-card:not([hidden]),
|
||||
.config-page #config-native-aliases:not([hidden]),
|
||||
.config-page #config-native-sensitive-words:not([hidden]) {
|
||||
container: settings-card / inline-size;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-card);
|
||||
box-shadow: none;
|
||||
}
|
||||
.config-page #config-native-aliases > .thinking-alias-page { padding-top: 0; }
|
||||
.config-page #config-native-aliases > .thinking-alias-page > .thinking-alias-list-panel,
|
||||
.config-page #config-native-sensitive-words > .config-sensitive-words-panel { padding: 0; border: 0; }
|
||||
.config-page .config-settings-content .config-keys-panel { max-height: none; }
|
||||
.config-page .config-settings-content .config-key-list { max-height: 300px; overflow-y: auto; border-top: 0; }
|
||||
.config-page .config-settings-content > .config-search-results,
|
||||
.config-page #config-template-panel .template-config-notice { grid-column: 1 / -1; }
|
||||
.config-page .config-settings-content .config-sensitive-words-grid { max-width: none; }
|
||||
@media (min-width: 1240px) {
|
||||
.config-page .config-settings-content { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
/* Plan complete rows rather than sizing cards by their number of fields. */
|
||||
.config-page .config-settings-content :is(.config-settings-cards > section, .template-config-card, #config-native-aliases, #config-native-sensitive-words) { grid-column: 1 / -1; }
|
||||
.config-page .config-settings-content :is(#config-native-network, #config-native-tls, #config-native-routing, #config-native-retry, #config-group-codex-client, #config-group-multimedia, #config-group-oauth-codex, #config-group-oauth-others) { grid-column: auto; }
|
||||
}
|
||||
/* Equal-height peers keep their content at the top and actions on one baseline. */
|
||||
.config-page .config-settings-cards > section:not([hidden]):not(#config-native-keys),
|
||||
.config-page #config-template-panel > .template-config-section > .template-config-card:not([hidden]) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-self: stretch;
|
||||
}
|
||||
.config-page .config-settings-cards > section > .config-settings-save-row,
|
||||
.config-page #config-template-panel > .template-config-section > .template-config-card > .template-config-actions {
|
||||
margin-top: auto;
|
||||
padding-top: 16px;
|
||||
}
|
||||
@container settings-card (max-width: 620px) {
|
||||
.config-page .config-settings-content :is(.config-network-grid, .config-management-form, .template-config-fields, .config-diagnostics-fields, .config-diagnostics-toggle-grid, .config-software-settings-list) { grid-template-columns: minmax(0, 1fr); gap: 14px; }
|
||||
.config-page .config-settings-content .config-management-form-footer { grid-column: 1; }
|
||||
.config-page .config-settings-content :is(.config-network-toggle, .template-config-field.template-config-toggle) {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-rows: auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 38px;
|
||||
}
|
||||
.config-page .config-settings-content .template-config-toggle > .template-config-control { grid-column: 2; grid-row: 1; }
|
||||
.config-page .config-settings-content :is(.config-network-toggle, .template-config-toggle) > :first-child { min-height: 0; }
|
||||
.config-page .config-settings-content .config-management-actions { gap: 8px; }
|
||||
.config-page .config-settings-content .config-proxy-system-toggle { flex-wrap: wrap; }
|
||||
}
|
||||
|
||||
/* Three related short cards form a complete row when each has room to breathe. */
|
||||
@media (min-width: 1560px) {
|
||||
.config-page .config-settings-content { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||
.config-page .config-settings-content :is(#config-native-network, #config-native-tls, #config-group-oauth-codex, #config-group-oauth-others) { grid-column: span 3; }
|
||||
.config-page .config-settings-content :is(#config-native-routing, #config-native-retry, #config-group-routing-advanced, #config-group-request-behavior, #config-group-codex-client, #config-group-multimedia) { grid-column: span 2; }
|
||||
}
|
||||
/* A single-purpose page has one centered workspace instead of an empty grid cell. */
|
||||
.config-page .config-settings-content #config-native-software { width: min(100%, 1000px); justify-self: center; }
|
||||
.config-page .config-settings-content #config-group-extensions-plugins { width: min(100%, 1100px); justify-self: center; }
|
||||
@container settings-card (min-width: 1040px) {
|
||||
.config-page :is(#config-group-extensions-concurrency, #config-group-extensions-inflight, #config-group-diagnostics, #config-group-request-behavior) .template-config-fields { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.config-page :is(#config-group-extensions-concurrency, #config-group-extensions-inflight, #config-group-diagnostics, #config-group-request-behavior) .template-config-field:not(.template-config-toggle) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-content: start;
|
||||
}
|
||||
.config-page :is(#config-group-extensions-concurrency, #config-group-extensions-inflight, #config-group-diagnostics, #config-group-request-behavior) .template-config-field:not(.template-config-toggle) > .template-config-control { justify-self: start; }
|
||||
}
|
||||
|
||||
/* Internal form layout follows the card width, independently of the page grid. */
|
||||
.config-page .config-settings-content :is(.template-config-fields, .config-network-grid) {
|
||||
gap: 16px 28px;
|
||||
}
|
||||
.config-page .config-settings-content .template-config-field:not(.template-config-field-wide) {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 200px);
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
min-height: 48px;
|
||||
gap: 8px 16px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.config-page .config-settings-content .template-config-field:not(.template-config-field-wide) > .template-config-control {
|
||||
justify-self: end;
|
||||
}
|
||||
.config-page .config-settings-content .template-config-field.template-config-toggle {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-rows: auto;
|
||||
align-self: stretch;
|
||||
}
|
||||
.config-page .config-settings-content .template-config-toggle > .template-config-control {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
align-self: center;
|
||||
}
|
||||
.config-page .config-settings-content .template-config-field-wide > .template-config-control { max-width: none; }
|
||||
.config-page .config-settings-content .config-network-toggle {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-rows: auto;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
border-radius: 0;
|
||||
}
|
||||
.config-page .config-settings-content .config-diagnostics-setting {
|
||||
min-height: 48px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
border-radius: 0;
|
||||
}
|
||||
.config-page .config-settings-content .config-diagnostics-field {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 160px);
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
border-radius: 0;
|
||||
}
|
||||
.config-page .config-settings-content .config-proxy-heading { max-width: none; }
|
||||
.config-page .config-settings-content :is(.config-settings-save-row, .template-config-actions) { margin-top: 16px; }
|
||||
|
||||
@container settings-card (min-width: 460px) {
|
||||
.config-page #config-native-network .config-network-grid { grid-template-columns: minmax(0, 160px) minmax(0, 1fr); }
|
||||
.config-page #config-native-network .config-network-field > input { max-width: none; }
|
||||
}
|
||||
/* Short numeric values share a row with their label, even in paired cards. */
|
||||
.config-page #config-native-retry .config-network-grid { grid-template-columns: minmax(0, 1fr); gap: 12px; }
|
||||
@container settings-card (min-width: 360px) {
|
||||
.config-page .config-settings-content #config-native-retry .config-network-field:not(.config-network-toggle),
|
||||
.config-page .config-settings-content #config-native-routing .config-network-field:has(#config-input-config-network-sessionTtl) {
|
||||
grid-template-columns: minmax(0, 1fr) 120px;
|
||||
align-items: center;
|
||||
min-height: 40px;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
@container settings-card (max-width: 620px) {
|
||||
.config-page .config-settings-content .template-config-field:not(.template-config-field-wide):not(.template-config-toggle) {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 160px);
|
||||
}
|
||||
}
|
||||
@container settings-card (max-width: 420px) {
|
||||
.config-page .config-settings-content .config-diagnostics-field { grid-template-columns: minmax(0, 1fr); }
|
||||
.config-page .config-settings-content .template-config-field:not(.template-config-field-wide):not(.template-config-toggle) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 7px;
|
||||
}
|
||||
.config-page .config-settings-content .template-config-field:not(.template-config-field-wide):not(.template-config-toggle) > .template-config-control { justify-self: start; }
|
||||
}
|
||||
/* Paths and repository URLs need more room than short scalar settings. */
|
||||
.config-page #config-group-management .template-config-field:not(.template-config-toggle),
|
||||
.config-page #config-group-oauth-common .template-config-field:has(.template-config-directory) {
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: minmax(180px, 1fr) minmax(0, 2fr);
|
||||
}
|
||||
.config-page #config-group-management .template-config-field:not(.template-config-toggle) > .template-config-control,
|
||||
.config-page #config-group-oauth-common .template-config-field:has(.template-config-directory) > .template-config-control { max-width: none; }
|
||||
@container settings-card (max-width: 620px) {
|
||||
.config-page #config-group-management .template-config-field:not(.template-config-toggle),
|
||||
.config-page #config-group-oauth-common .template-config-field:has(.template-config-directory) { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
@@ -16,6 +16,7 @@ const screenshots = path.join(os.tmpdir(), 'easycliproxy-agent-clients');
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1460, height: 1060 } });
|
||||
page.setDefaultTimeout(10000);
|
||||
page.setDefaultNavigationTimeout(30000);
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(String(error)));
|
||||
await page.route('**/*', route => route.request().url().startsWith(base + '/') ? route.continue() : route.abort());
|
||||
@@ -38,7 +39,7 @@ const screenshots = path.join(os.tmpdir(), 'easycliproxy-agent-clients');
|
||||
const list = document.querySelector('.agent-list-items');
|
||||
const actual = Array.from(list.querySelectorAll('button strong'), node => node.textContent);
|
||||
const selected = list.querySelector('button.active strong')?.textContent;
|
||||
return actual.length > 0 && actual.length <= 6 && actual.every(name => expected.includes(name))
|
||||
return actual.length > 0 && actual.length <= expected.length && actual.every(name => expected.includes(name))
|
||||
&& expected.includes(selected) && list.scrollHeight <= list.clientHeight + 1;
|
||||
}, names);
|
||||
await manage().click();
|
||||
@@ -175,14 +176,18 @@ const screenshots = path.join(os.tmpdir(), 'easycliproxy-agent-clients');
|
||||
await page.screenshot({ path: path.join(screenshots, 'manager-desktop.png'), fullPage: true });
|
||||
await closeAndFocus(() => cancel().click());
|
||||
|
||||
// Reproduce the reported desktop size with twelve previously saved clients.
|
||||
// Every saved client is reachable exactly once, in the same order, without a second switcher.
|
||||
await page.setViewportSize({ width: 1368, height: 912 });
|
||||
const twelve = ['claude-code', 'claude-desktop', 'codex', 'deepseek-harness', 'opencode', 'pi',
|
||||
'grok-build', 'antigravity-cli', 'workbuddy', 'zcode', 'kimi-code', 'openclaw'];
|
||||
const twelveNames = ['Claude Code', 'Claude Desktop', 'Codex', 'DeepSeek Harness', 'OpenCode', 'Pi',
|
||||
'Grok Build', 'Antigravity CLI', 'WorkBuddy', 'ZCode', 'Kimi Code', 'OpenClaw'];
|
||||
await page.evaluate(({ key, ids }) => localStorage.setItem(key, JSON.stringify(ids)), { key: storageKey, ids: twelve });
|
||||
await page.goto(`${base}/tests/fixtures/agent-backups.html?shell&client=claude-code`, { waitUntil: 'domcontentloaded' });
|
||||
await ready();
|
||||
await page.waitForFunction(() => document.querySelectorAll('.agent-list-items button').length === 6);
|
||||
const previous = () => page.getByRole('button', { name: '上一页客户端', exact: true });
|
||||
const next = () => page.getByRole('button', { name: '下一页客户端', exact: true });
|
||||
const namesOnPage = () => page.locator('.agent-list-items strong').allTextContents();
|
||||
const assertFits = async () => {
|
||||
const metrics = await page.locator('.agent-list-items').evaluate(list => {
|
||||
const rect = list.getBoundingClientRect();
|
||||
@@ -193,29 +198,63 @@ const screenshots = path.join(os.tmpdir(), 'easycliproxy-agent-clients');
|
||||
}) };
|
||||
});
|
||||
assert.ok(metrics.scrollHeight <= metrics.height + 1 && metrics.scrollWidth <= metrics.width + 1);
|
||||
assert.equal(metrics.clipped, false, 'all shortcuts are fully visible without hiding overflowing rows');
|
||||
assert.equal(metrics.clipped, false, 'all rows are fully visible on each page');
|
||||
};
|
||||
await assertFits();
|
||||
assert.deepEqual(JSON.parse(await stored()), twelve, 'limiting shortcuts preserves saved preferences');
|
||||
const right = await page.locator('.agent-config-scroll-region').evaluate(node => [node.clientHeight, node.scrollHeight]);
|
||||
assert.ok(right[1] <= right[0] + 1, 'Claude Code settings fit at the reported desktop size');
|
||||
const switchClient = () => page.getByRole('button', { name: '切换客户端', exact: true });
|
||||
await switchClient().click();
|
||||
const picker = page.getByRole('dialog', { name: '切换客户端', exact: true });
|
||||
await picker.getByRole('searchbox').fill('herMES');
|
||||
await picker.getByRole('button', { name: 'Hermes Agent', exact: true }).click();
|
||||
assert.equal(await activeClient(), 'Hermes Agent', 'a client outside the first six remains in the shortcuts after selection');
|
||||
assert.ok(JSON.parse(await stored()).includes('hermes'), 'switching to a hidden client brings it back');
|
||||
assert.equal(await switchClient().evaluate(node => node === document.activeElement), true);
|
||||
await assertFits();
|
||||
const collectPages = async () => {
|
||||
while (await previous().isEnabled()) await previous().click();
|
||||
const names = [];
|
||||
for (let index = 0; index < 13; index++) {
|
||||
await assertFits();
|
||||
names.push(...await namesOnPage());
|
||||
if (await next().isDisabled()) break;
|
||||
await next().click();
|
||||
}
|
||||
return names;
|
||||
};
|
||||
assert.equal(await page.getByRole('button', { name: '切换客户端', exact: true }).count(), 0);
|
||||
assert.deepEqual(await collectPages(), twelveNames, 'no saved client is omitted, duplicated or reordered');
|
||||
assert.deepEqual(JSON.parse(await stored()), twelve, 'browsing leaves the management list unchanged');
|
||||
assert.equal(await page.evaluate(() => localStorage.getItem('cpa-gui.agent-selected-client.v1')), 'claude-code',
|
||||
'browsing pages does not select a different client');
|
||||
const lastPage = await namesOnPage();
|
||||
await page.locator('.agent-list-items button').filter({ has: page.getByText('OpenClaw', { exact: true }) }).click();
|
||||
assert.deepEqual(await namesOnPage(), lastPage, 'selecting a client does not replace or shuffle any row');
|
||||
assert.equal(await activeClient(), 'OpenClaw');
|
||||
assert.deepEqual(JSON.parse(await stored()), twelve, 'selecting leaves the management list unchanged');
|
||||
await page.setViewportSize({ width: 360, height: 600 });
|
||||
await page.waitForFunction(() => document.querySelectorAll('.agent-list-items button').length < 6);
|
||||
await page.waitForFunction(() => document.querySelector('.agent-list-items button.active strong')?.textContent === 'OpenClaw'
|
||||
&& document.querySelectorAll('.agent-list-items button').length <= 2);
|
||||
await assertFits();
|
||||
assert.equal(await activeClient(), 'Hermes Agent', 'resizing always retains the active client');
|
||||
assert.deepEqual(await collectPages(), twelveNames, 'all managed clients remain reachable after resizing');
|
||||
assert.deepEqual(JSON.parse(await stored()), twelve);
|
||||
// Add/remove only in management. Cancel is inert; Save controls the entire list.
|
||||
await manage().click();
|
||||
await checkbox('Hermes Agent').check();
|
||||
await checkbox('OpenClaw').uncheck();
|
||||
await closeAndFocus(() => cancel().click());
|
||||
assert.deepEqual(JSON.parse(await stored()), twelve);
|
||||
assert.equal(await activeClient(), 'OpenClaw');
|
||||
await manage().click();
|
||||
await checkbox('Hermes Agent').check();
|
||||
await checkbox('OpenClaw').uncheck();
|
||||
await closeAndFocus(() => save().click());
|
||||
const updatedNames = [...twelveNames.filter(name => name !== 'OpenClaw'), 'Hermes Agent'];
|
||||
assert.equal(await activeClient(), 'Claude Code', 'explicitly removing the current client selects a remaining client');
|
||||
assert.deepEqual(await collectPages(), updatedNames);
|
||||
await page.locator('.agent-list-items button').filter({ has: page.getByText('Hermes Agent', { exact: true }) }).click();
|
||||
assert.equal(await page.evaluate(() => window.fixtureCalls.some(call =>
|
||||
/^(update_agent_config|set_agent_config_enabled|launch_agent)$/.test(call.cmd))), false,
|
||||
'list management and navigation must not write config or launch a client');
|
||||
const savedAfterManagement = await stored();
|
||||
await page.reload();
|
||||
await ready();
|
||||
assert.equal(await stored(), savedAfterManagement);
|
||||
assert.deepEqual(await collectPages(), updatedNames, 'saved management choices survive reload');
|
||||
await page.setViewportSize({ width: 1368, height: 912 });
|
||||
await switchClient().click();
|
||||
await picker.getByRole('button', { name: 'Claude Code', exact: true }).click();
|
||||
await page.locator('.agent-workbench').screenshot({ path: path.join(screenshots, 'shortcuts-desktop.png') });
|
||||
await page.locator('.agent-workbench').screenshot({ path: path.join(screenshots, 'client-pagination.png') });
|
||||
assert.equal(await page.evaluate(() => window.fixtureCalls.some(call =>
|
||||
/^(update_agent_config|set_agent_config_enabled|launch_agent)$/.test(call.cmd))), false,
|
||||
'list management and navigation must not write config or launch a client');
|
||||
|
||||
// Both entry points remain usable with narrow windows and English text in dark mode.
|
||||
for (const embedded of [false, true]) {
|
||||
|
||||
@@ -130,7 +130,7 @@ const desktopViewports = [
|
||||
return metrics;
|
||||
};
|
||||
|
||||
const assertClientShortcuts = async label => {
|
||||
const assertClientPage = async label => {
|
||||
const metrics = await page.locator('.agent-list-items').evaluate(list => {
|
||||
const rect = list.getBoundingClientRect();
|
||||
const buttons = Array.from(list.querySelectorAll('button'));
|
||||
@@ -141,9 +141,9 @@ const desktopViewports = [
|
||||
return r.top < rect.top - 1 || r.bottom > rect.bottom + 1 || r.left < rect.left - 1 || r.right > rect.right + 1;
|
||||
}).map(button => button.textContent) };
|
||||
});
|
||||
assert.ok(metrics.count >= 1 && metrics.count <= 6, `${label}: show at most six shortcuts`);
|
||||
assert.equal(metrics.overflow, false, `${label}: shortcuts must fit without scrolling`);
|
||||
assert.deepEqual(metrics.clipped, [], `${label}: no shortcut may be clipped`);
|
||||
assert.ok(metrics.count >= 1 && metrics.count <= 13, `${label}: render a nonempty client page`);
|
||||
assert.equal(metrics.overflow, false, `${label}: each client page must fit without scrolling`);
|
||||
assert.deepEqual(metrics.clipped, [], `${label}: no client row may be clipped`);
|
||||
};
|
||||
|
||||
const assertDesktopLayout = async label => {
|
||||
@@ -159,7 +159,7 @@ const desktopViewports = [
|
||||
assert.ok(Math.abs(clientList.y - configuration.y) <= 1,
|
||||
`${label}: the desktop panels must share a top edge`);
|
||||
await assertNaturalPanel(label);
|
||||
await assertClientShortcuts(label);
|
||||
await assertClientPage(label);
|
||||
return { clientList, configuration };
|
||||
};
|
||||
|
||||
@@ -175,7 +175,7 @@ const desktopViewports = [
|
||||
assert.ok(clientList.y + clientList.height <= configuration.y + 1,
|
||||
`${label}: the client list must stay above the configuration panel`);
|
||||
await assertNaturalPanel(label, { checkControls: false });
|
||||
await assertClientShortcuts(label);
|
||||
await assertClientPage(label);
|
||||
const pageWidth = await page.evaluate(() => ({
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
@@ -324,7 +324,7 @@ const desktopViewports = [
|
||||
`${label}: fixed-height desktop layout must not create a far-right page scrollbar`);
|
||||
assert.ok(metrics.documentScrollWidth <= metrics.documentClientWidth + 1,
|
||||
`${label}: fixed-height desktop layout must not create horizontal page scrolling`);
|
||||
await assertClientShortcuts(label);
|
||||
await assertClientPage(label);
|
||||
await assertShellScrollRegion(label, { requireOverflow: requireRightOverflow });
|
||||
return metrics;
|
||||
};
|
||||
@@ -344,10 +344,10 @@ const desktopViewports = [
|
||||
const label = `${viewport.width}x${viewport.height}/${embedded ? 'embedded' : 'full'}/${client}/core`;
|
||||
await assertDesktopLayout(label);
|
||||
if (client === 'zcode' && viewport.height === 941) {
|
||||
const status = page.locator('.agent-list-items button').filter({ hasText: 'Claude Code' }).locator('small');
|
||||
const status = page.locator('.agent-list-items button').filter({ hasText: 'ZCode' }).locator('small');
|
||||
const longModel = 'gemini-3.8-flash-high-with-a-long-client-status-for-layout-verification';
|
||||
await page.evaluate(model => {
|
||||
window.fixtureClientStatusesOverride = { 'claude-code': { appliedModel: model } };
|
||||
window.fixtureClientStatusesOverride = { 'zcode': { appliedModel: model } };
|
||||
}, longModel);
|
||||
await page.locator('.agent-client-list-heading button').click();
|
||||
await page.waitForFunction(model => Array.from(document.querySelectorAll('.agent-list-items small'))
|
||||
@@ -440,7 +440,7 @@ const desktopViewports = [
|
||||
`${shellNarrowLabel}: the stacked workbench must preserve the fixed window-edge gap`);
|
||||
assert.ok(shellNarrow.documentScrollHeight <= shellNarrow.documentClientHeight + 1,
|
||||
`${shellNarrowLabel}: the bounded one-column shell must not leak scrolling to the page`);
|
||||
await assertClientShortcuts(shellNarrowLabel);
|
||||
await assertClientPage(shellNarrowLabel);
|
||||
await assertShellScrollRegion(shellNarrowLabel);
|
||||
|
||||
// At phone width the shell follows Usage records and returns to natural document scrolling.
|
||||
@@ -456,7 +456,7 @@ const desktopViewports = [
|
||||
`${shellPhoneLabel}: phone-width client list must remain above configuration content`);
|
||||
assert.ok(shellPhone.documentScrollWidth <= shellPhone.documentClientWidth + 1,
|
||||
`${shellPhoneLabel}: natural document flow must not create horizontal scrolling`);
|
||||
await assertClientShortcuts(shellPhoneLabel);
|
||||
await assertClientPage(shellPhoneLabel);
|
||||
await assertShellScrollRegion(shellPhoneLabel, { natural: true });
|
||||
|
||||
for (const viewport of [{ width: 640, height: 700 }, { width: 360, height: 941 }]) {
|
||||
|
||||
@@ -8,17 +8,21 @@ const assert = require('node:assert/strict');
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
|
||||
await page.route('**/*', route => route.request().url().startsWith('http://127.0.0.1:1421/') ? route.continue() : route.abort());
|
||||
page.setDefaultTimeout(10000);
|
||||
page.setDefaultNavigationTimeout(30000);
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(String(error)));
|
||||
const tab = name => page.getByRole('tab', { name, exact: true });
|
||||
const button = name => page.getByRole('button', { name, exact: true });
|
||||
const client = name => ({ click: async () => {
|
||||
const shortcut = page.locator('.agent-list-items button').filter({ has: page.getByText(name, { exact: true }) });
|
||||
if (await shortcut.count()) await shortcut.click();
|
||||
else {
|
||||
await button('切换客户端').click();
|
||||
await page.getByRole('dialog', { name: '切换客户端', exact: true }).getByRole('button', { name, exact: true }).click();
|
||||
const row = page.locator('.agent-list-items button').filter({ has: page.getByText(name, { exact: true }) });
|
||||
const previous = button('上一页客户端');
|
||||
while (await previous.isEnabled()) await previous.click();
|
||||
for (let index = 0; index < 13 && !(await row.count()); index++) {
|
||||
const next = button('下一页客户端');
|
||||
assert.ok(await next.isEnabled(), `${name} must be reachable in the saved client list`);
|
||||
await next.click();
|
||||
}
|
||||
await row.click();
|
||||
} });
|
||||
const active = async name => {
|
||||
await page.waitForFunction(name => Array.from(document.querySelectorAll('[role=tab]'))
|
||||
|
||||
@@ -46,7 +46,7 @@ const path = require('node:path');
|
||||
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-general', 'config-subpage-tab-aliases', 'config-subpage-tab-routing', 'config-subpage-tab-requests',
|
||||
'config-subpage-tab-oauth', 'config-subpage-tab-diagnostics', 'config-subpage-tab-extensions',
|
||||
'config-subpage-tab-software',
|
||||
]);
|
||||
@@ -58,7 +58,7 @@ const path = require('node:path');
|
||||
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');
|
||||
assert.equal(await page.locator('#config-subpage-tab-aliases').getAttribute('aria-selected'), 'true');
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
assert.equal(await configTabs.first().getAttribute('aria-selected'), 'true');
|
||||
await page.keyboard.press('End');
|
||||
@@ -75,9 +75,8 @@ const path = require('node:path');
|
||||
}, 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);
|
||||
assert.equal(await page.locator('.config-settings-views').count(), 0);
|
||||
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'));
|
||||
|
||||
+136
-30
@@ -2,8 +2,8 @@ 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 }];
|
||||
const categories = ['general', 'aliases', 'routing', 'requests', 'oauth', 'diagnostics', 'extensions', 'software'];
|
||||
const viewports = [{ width: 640, height: 600 }, { width: 964, height: 700 }, { width: 1280, height: 800 }, { width: 1600, height: 1000 }];
|
||||
|
||||
(async () => {
|
||||
let server;
|
||||
@@ -36,6 +36,7 @@ const viewports = [{ width: 640, height: 600 }, { width: 964, height: 700 }, { w
|
||||
const label = `${locale} ${viewport.width}x${viewport.height}`;
|
||||
const page = await browser.newPage({ viewport });
|
||||
page.setDefaultTimeout(10000);
|
||||
page.setDefaultNavigationTimeout(60000);
|
||||
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);
|
||||
@@ -57,12 +58,6 @@ const viewports = [{ width: 640, height: 600 }, { width: 964, height: 700 }, { w
|
||||
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`);
|
||||
};
|
||||
@@ -75,6 +70,29 @@ const viewports = [{ width: 640, height: 600 }, { width: 964, height: 700 }, { w
|
||||
].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 narrowLists = await page.evaluate(() => {
|
||||
const width = document.querySelector('.config-settings-content').getBoundingClientRect().width;
|
||||
return [...document.querySelectorAll('#config-native-keys, #config-native-aliases, #config-native-sensitive-words, .template-config-card:has([data-field-type="custom"], [data-field-type="json"], [data-field-type="string-list"])')]
|
||||
.filter(card => card.id !== 'config-group-extensions-plugins' && card.getBoundingClientRect().width > 0 && Math.abs(card.getBoundingClientRect().width - width) > 1)
|
||||
.map(card => card.id);
|
||||
});
|
||||
assert.deepEqual(narrowLists, [], `${label} ${context}: expandable lists and rules occupy independent full rows`);
|
||||
if (viewport.width >= 1240) {
|
||||
const incompleteRows = await page.evaluate(() => {
|
||||
const content = document.querySelector('.config-settings-content').getBoundingClientRect();
|
||||
const cards = [...document.querySelectorAll('.config-settings-cards > section, .template-config-card, #config-native-aliases, #config-native-sensitive-words')]
|
||||
.map(card => ({ id: card.id, box: card.getBoundingClientRect() })).filter(card => card.box.width > 0);
|
||||
return cards.filter(card => {
|
||||
if (['config-native-software', 'config-group-extensions-plugins'].includes(card.id)) {
|
||||
return Math.abs(card.box.left + card.box.width / 2 - content.left - content.width / 2) > 1;
|
||||
}
|
||||
const peers = cards.filter(peer => Math.abs(peer.box.top - card.box.top) < 2);
|
||||
return Math.abs(Math.min(...peers.map(peer => peer.box.left)) - content.left) > 1
|
||||
|| Math.abs(Math.max(...peers.map(peer => peer.box.right)) - content.right) > 1;
|
||||
}).map(card => card.id);
|
||||
});
|
||||
assert.deepEqual(incompleteRows, [], `${label} ${context}: card rows must not leave an orphan half-width card`);
|
||||
}
|
||||
};
|
||||
const reachable = async (locator, context) => {
|
||||
await locator.scrollIntoViewIfNeeded();
|
||||
@@ -163,7 +181,7 @@ const viewports = [{ width: 640, height: 600 }, { width: 964, height: 700 }, { w
|
||||
};
|
||||
});
|
||||
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.singleRow, true, `${label}: all eight 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`);
|
||||
@@ -181,19 +199,48 @@ const viewports = [{ width: 640, height: 600 }, { width: 964, height: 700 }, { w
|
||||
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);
|
||||
assert.equal(await page.locator('#config-native-network').isVisible(), true, 'connection settings share the service page');
|
||||
assert.equal(await page.locator('#config-native-tls').isVisible(), true, 'TLS settings share the service page');
|
||||
assert.equal(await page.locator('.config-settings-views').count(), 0, 'secondary pagination is removed');
|
||||
const sectionStyle = await page.locator('.config-keys-panel').evaluate(node => {
|
||||
const style = getComputedStyle(node);
|
||||
return { radius: style.borderRadius, shadow: style.boxShadow, background: style.backgroundColor };
|
||||
});
|
||||
assert.equal(sectionStyle.radius, '12px');
|
||||
assert.notEqual(sectionStyle.background, 'rgba(0, 0, 0, 0)', 'settings groups have their own surface');
|
||||
if (viewport.width >= 1240) {
|
||||
const keys = await page.locator('#config-native-keys').boundingBox();
|
||||
const management = await page.locator('#config-native-management').boundingBox();
|
||||
const content = await page.locator('.config-settings-content').boundingBox();
|
||||
assert.ok(Math.abs(keys.width - content.width) <= 1, 'the expandable key list occupies its own full row');
|
||||
assert.ok(management.y >= keys.y + keys.height, 'fixed settings follow the independent key list');
|
||||
const network = await page.locator('#config-native-network').boundingBox();
|
||||
const tls = await page.locator('#config-native-tls').boundingBox();
|
||||
assert.ok(Math.abs(network.y - tls.y) <= 1 && Math.abs(network.height - tls.height) <= 1, 'paired native cards align at both edges');
|
||||
}
|
||||
await templateVisible('management');
|
||||
{
|
||||
const row = await page.locator('#template-field-management-0').boundingBox();
|
||||
const toggle = await page.locator('#template-field-management-0 .switch-control').boundingBox();
|
||||
const caption = await page.locator('#template-field-management-0 .template-config-label').boundingBox();
|
||||
assert.ok(Math.abs(row.x + row.width - toggle.x - toggle.width) <= 1, `${label}: switches align with the end of their setting row`);
|
||||
assert.ok(Math.abs(caption.y + caption.height / 2 - toggle.y - toggle.height / 2) <= 1, `${label}: switch and label stay on the same line`);
|
||||
}
|
||||
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.ok((await nativePort.boundingBox()).width <= 160, `${label}: port input stays compact`);
|
||||
if (viewport.width >= 964) {
|
||||
const secret = await page.locator('.config-secret-input').boundingBox();
|
||||
assert.ok(secret.width <= 420, `${label}: secret input must not stretch across the page`);
|
||||
}
|
||||
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();
|
||||
@@ -205,11 +252,36 @@ const viewports = [{ width: 640, height: 600 }, { width: 964, height: 700 }, { w
|
||||
assert.equal(await page.locator('#config-retry-section-title').isVisible(), true);
|
||||
await templateVisible('routing-advanced');
|
||||
await templateVisible('extensions-concurrency');
|
||||
if (viewport.width >= 964) {
|
||||
const toggle = await page.locator('#config-native-routing .switch-control').boundingBox();
|
||||
const ttl = await page.locator('#config-input-config-network-sessionTtl').boundingBox();
|
||||
assert.ok(toggle.width > 0 && ttl.width > 0, `${label}: controls remain visible inside their card`);
|
||||
assert.ok(ttl.width <= 200, `${label}: duration input stays compact`);
|
||||
const retryInput = await page.locator('#config-input-config-network-requestRetry').boundingBox();
|
||||
assert.ok(retryInput.width <= 160, `${label}: retry count stays compact`);
|
||||
}
|
||||
await templateVisible('extensions-plugins', false);
|
||||
await noOverflow('routing and stability');
|
||||
if (viewport.width >= 964) {
|
||||
const value = await page.locator('#config-input-config-network-requestRetry').boundingBox();
|
||||
const caption = await page.locator('label[for="config-input-config-network-requestRetry"]').boundingBox();
|
||||
assert.ok(Math.abs(value.y + value.height / 2 - caption.y - caption.height / 2) <= 2,
|
||||
`${label}: compact numeric settings keep labels next to their values`);
|
||||
if (viewport.width === 1280) {
|
||||
assert.ok((await retrySection.boundingBox()).height < 460,
|
||||
`${label}: paired routing cards should not create excessive vertical whitespace`);
|
||||
}
|
||||
}
|
||||
await page.locator('.template-config-card:visible').last().scrollIntoViewIfNeeded();
|
||||
await settle();
|
||||
const pinnedCategory = await page.locator('#config-subpage-tab-routing').evaluate(element => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
const hit = document.elementFromPoint(bounds.left + bounds.width / 2, bounds.top + bounds.height / 2);
|
||||
return bounds.top >= 0 && bounds.bottom <= innerHeight && Boolean(hit && element.contains(hit));
|
||||
});
|
||||
assert.equal(pinnedCategory, true, `${label}: categories remain visible and clickable while scrolling long forms`);
|
||||
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`);
|
||||
assert.equal(await page.locator('.config-settings-views').count(), 0, `${label}: secondary pagination is removed`);
|
||||
await tab('routing');
|
||||
const routingOptions = await page.locator('.routing-segmented').evaluate(group => {
|
||||
const container = group.getBoundingClientRect();
|
||||
@@ -233,14 +305,58 @@ const viewports = [{ width: 640, height: 600 }, { width: 964, height: 700 }, { w
|
||||
`${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);
|
||||
if (viewport.width >= 1240) {
|
||||
const left = await page.locator('#config-group-codex-client').boundingBox();
|
||||
const right = await page.locator('#config-group-multimedia').boundingBox();
|
||||
assert.ok(Math.abs(left.y - right.y) <= 1 && Math.abs(left.height - right.height) <= 1, 'paired template cards align at both edges');
|
||||
const leftSave = await page.locator('#config-group-codex-client .template-config-actions button').last().boundingBox();
|
||||
const rightSave = await page.locator('#config-group-multimedia .template-config-actions button').last().boundingBox();
|
||||
assert.ok(Math.abs(leftSave.y + leftSave.height - rightSave.y - rightSave.height) <= 1, 'paired save buttons share a bottom baseline');
|
||||
}
|
||||
await noOverflow('model and request behavior');
|
||||
await view('aliases');
|
||||
assert.equal(await page.locator('#config-native-aliases').isVisible(), false, 'aliases are separate from model and request settings');
|
||||
await tab('aliases');
|
||||
await templateVisible('request-behavior', false);
|
||||
assert.equal(await page.locator('#config-native-sensitive-words').isVisible(), false);
|
||||
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');
|
||||
// Exercise the editor inside the settings page, where ancestor CSS applies.
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await page.evaluate(theme => { document.documentElement.dataset.theme = theme; }, theme);
|
||||
await page.locator('.thinking-alias-page > .management-header button').click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.waitFor();
|
||||
await page.waitForFunction(() => document.activeElement?.id === 'thinking-model-search');
|
||||
await page.keyboard.press('Escape');
|
||||
assert.equal(await dialog.isVisible(), true, 'Escape closes the model picker before the editor');
|
||||
assert.equal(await dialog.getByRole('listbox').count(), 0);
|
||||
const surface = await dialog.evaluate(node => {
|
||||
const style = getComputedStyle(node);
|
||||
const bounds = node.getBoundingClientRect();
|
||||
return {
|
||||
background: style.backgroundColor, radius: parseFloat(style.borderRadius),
|
||||
padding: style.padding, withinViewport: bounds.left >= 0 && bounds.top >= 0
|
||||
&& bounds.right <= innerWidth && bounds.bottom <= innerHeight,
|
||||
};
|
||||
});
|
||||
assert.ok(!/rgba\([^)]*,\s*0\)$/.test(surface.background) && surface.background !== 'transparent', `${label} ${theme}: editor needs an opaque surface`);
|
||||
assert.ok(surface.radius > 0, `${label} ${theme}: editor retains its own rounded container`);
|
||||
assert.equal(surface.padding, '0px', `${label} ${theme}: page spacing must not override editor layout`);
|
||||
assert.equal(surface.withinViewport, true, `${label} ${theme}: editor fits the window`);
|
||||
await reachable(dialog.getByRole('button', { name: locale === 'en' ? 'Cancel' : '取消', exact: true }), 'alias dialog cancel');
|
||||
if (process.env.SETTINGS_SCREENSHOT_DIR) {
|
||||
const fs = require('node:fs');
|
||||
const directory = path.resolve(process.env.SETTINGS_SCREENSHOT_DIR);
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
await page.screenshot({ path: path.join(directory, `alias-dialog-${locale}-${viewport.width}-${theme}.png`) });
|
||||
}
|
||||
await dialog.getByRole('button', { name: locale === 'en' ? 'Cancel' : '取消', exact: true }).click();
|
||||
await dialog.waitFor({ state: 'detached' });
|
||||
}
|
||||
await page.evaluate(() => { document.documentElement.dataset.theme = 'light'; });
|
||||
await tab('requests');
|
||||
await page.locator('.config-sensitive-words-row input').first().waitFor();
|
||||
await noOverflow('sensitive words');
|
||||
|
||||
@@ -248,8 +364,7 @@ const viewports = [{ width: 640, height: 600 }, { width: 964, height: 700 }, { w
|
||||
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`);
|
||||
assert.equal(await page.locator('.config-settings-views').count(), 0, `${label}: secondary pagination is removed`);
|
||||
await tab('diagnostics');
|
||||
assert.equal(await page.locator('.config-diagnostics-panel').isVisible(), true);
|
||||
await templateVisible('diagnostics');
|
||||
@@ -265,7 +380,7 @@ const viewports = [{ width: 640, height: 600 }, { width: 964, height: 700 }, { w
|
||||
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.
|
||||
// select the category before locating the integrated section.
|
||||
await search.fill(locale === 'en' ? 'Retries & cooldowns' : '重试与冷却');
|
||||
const results = page.locator('.config-search-results');
|
||||
await results.getByRole('button').first().waitFor();
|
||||
@@ -278,19 +393,17 @@ const viewports = [{ width: 640, height: 600 }, { width: 964, height: 700 }, { w
|
||||
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));
|
||||
assert.equal(await page.locator('#config-subpage-tab-aliases').getAttribute('aria-selected'), 'true');
|
||||
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`);
|
||||
@@ -318,7 +431,6 @@ const viewports = [{ width: 640, height: 600 }, { width: 964, height: 700 }, { w
|
||||
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') });
|
||||
@@ -335,7 +447,6 @@ const viewports = [{ width: 640, height: 600 }, { width: 964, height: 700 }, { w
|
||||
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') });
|
||||
@@ -347,7 +458,6 @@ const viewports = [{ width: 640, height: 600 }, { width: 964, height: 700 }, { w
|
||||
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') });
|
||||
@@ -359,7 +469,6 @@ const viewports = [{ width: 640, height: 600 }, { width: 964, height: 700 }, { w
|
||||
// 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`);
|
||||
@@ -378,7 +487,6 @@ const viewports = [{ width: 640, height: 600 }, { width: 964, height: 700 }, { w
|
||||
}, 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]');
|
||||
@@ -387,20 +495,18 @@ const viewports = [{ width: 640, height: 600 }, { width: 964, height: 700 }, { w
|
||||
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.');
|
||||
console.log('PASS: eight settings categories including independent model aliases, adaptive cards, search, retained drafts, save feedback, and Chinese/English responsive layouts.');
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.close();
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||
const assert = require('node:assert/strict');
|
||||
const { mkdirSync } = require('node:fs');
|
||||
const base = process.env.ALIAS_TEST_BASE_URL || 'http://127.0.0.1:1423';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
page.setDefaultTimeout(10000);
|
||||
await page.route('**/*', route => route.request().url().startsWith('http://127.0.0.1:1423/')
|
||||
await page.route('**/*', route => route.request().url().startsWith(base + '/')
|
||||
? route.continue() : route.abort());
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(String(error)));
|
||||
await page.goto('http://127.0.0.1:1423/tests/fixtures/model-alias-editor.html', { waitUntil: 'domcontentloaded' });
|
||||
await page.goto(`${base}/tests/fixtures/model-alias-editor.html`, { waitUntil: 'domcontentloaded' });
|
||||
const writes = () => page.evaluate(() => window.fixtureCalls.filter(call => call.cmd === 'create_thinking_alias'));
|
||||
await page.getByRole('button', { name: 'Edit', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
|
||||
@@ -229,16 +229,7 @@ const viewports = [
|
||||
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`);
|
||||
}
|
||||
}
|
||||
assert.equal(await page.locator('.config-settings-views').count(), 0, 'settings are integrated without secondary pagination');
|
||||
await page.getByLabel('搜索设置', { exact: true }).fill('重试');
|
||||
await page.locator('.config-search-results button').first().waitFor();
|
||||
await settle();
|
||||
|
||||
@@ -121,7 +121,7 @@ async function audit(page, scope, label) {
|
||||
await page.locator('.auth-file-card').first().waitFor();
|
||||
await audit(page, '.auth-files-page', `${theme} credential cards`);
|
||||
await screenshot(`credential-cards-${theme}`);
|
||||
await page.locator('.auth-file-card').first().getByRole('button', { name: '设置', exact: true }).click();
|
||||
await page.locator('.auth-file-card').first().getByRole('button', { name: '凭证设置', exact: true }).click();
|
||||
await page.locator('.credential-settings-body[aria-busy="false"]').waitFor();
|
||||
await audit(page, '.credential-settings-dialog', `${theme} credential settings`);
|
||||
await screenshot(`credential-settings-${theme}`);
|
||||
|
||||
Reference in new issue
Block a user