From 7c9b8897ed3a35d3b946b1580d859aec5525a5f0 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Thu, 10 Sep 2026 17:19:35 +0800 Subject: [PATCH] fix(aliases): preserve configuration when editing model aliases Resolve existing aliases from their configuration, preserve metadata and unrelated payload parameters, and keep shared rules intact when renaming or changing sources. Fix dialog feedback, keyboard focus, and narrow layouts. Add regression coverage for alias edits and browser interactions. --- src-tauri/src/agents/commands.rs | 72 ++--- src-tauri/src/core_config.rs | 2 + src-tauri/src/core_config/alias_edit.rs | 413 ++++++++++++++++++++++++ src-tauri/src/core_config/aliases.rs | 40 --- src-tauri/src/main.rs | 1 + src-tauri/src/tests.rs | 1 + src-tauri/src/tests/alias_edit.rs | 315 ++++++++++++++++++ src-tauri/src/tests/model_aliases.rs | 9 +- src/i18n/ja.ts | 3 +- src/i18n/locales/en.ts | 1 + src/i18n/locales/zh-CN.ts | 1 + src/pages/ThinkingAliasesPage.tsx | 99 ++++-- src/styles.css | 6 +- tests/fixtures/model-alias-editor.html | 5 + tests/fixtures/model-alias-editor.tsx | 37 +++ tests/model-alias-editor-ui.cjs | 91 ++++++ 16 files changed, 979 insertions(+), 117 deletions(-) create mode 100644 src-tauri/src/core_config/alias_edit.rs create mode 100644 src-tauri/src/tests/alias_edit.rs create mode 100644 tests/fixtures/model-alias-editor.html create mode 100644 tests/fixtures/model-alias-editor.tsx create mode 100644 tests/model-alias-editor-ui.cjs diff --git a/src-tauri/src/agents/commands.rs b/src-tauri/src/agents/commands.rs index 55afb5b..6690aa9 100644 --- a/src-tauri/src/agents/commands.rs +++ b/src-tauri/src/agents/commands.rs @@ -492,6 +492,18 @@ pub(crate) async fn get_thinking_alias_sources( .collect()) } +#[tauri::command] +pub(crate) async fn get_model_alias_edit_source( + gui_config_state: tauri::State<'_, GuiConfigState>, + alias: String, +) -> Result { + let config = gui_config_state.snapshot()?; + let alias = validate_thinking_alias_model_id(&alias, "别名模型")?; + let content = fetch_management_config_yaml(&config).await?; + let definitions = fetch_oauth_model_definitions(&config).await; + Ok(resolve_model_alias_edit_source(&content, &alias, &definitions)?.source) +} + #[tauri::command] pub(crate) async fn create_thinking_alias( gui_config_state: tauri::State<'_, GuiConfigState>, @@ -514,10 +526,6 @@ pub(crate) async fn create_thinking_alias( }; let fast = fast.unwrap_or(false); let content = fetch_management_config_yaml(&config).await?; - let working_content = match original_alias.as_deref() { - Some(original) => prepare_model_alias_edit(&content, original)?, - None => content.clone(), - }; let available_models = fetch_agent_models(config.port, effective_agent_api_key(&config)).await?; let definitions = fetch_oauth_model_definitions(&config).await; @@ -528,36 +536,18 @@ pub(crate) async fn create_thinking_alias( } else { AliasSourceCapability::Base }; - let sources = resolved_oauth_alias_sources( - &working_content, - &definitions, - &available_models, - capability, - )?; - // Removing a configured alias can shift model indices in source IDs. - let original_sources = - resolved_oauth_alias_sources(&content, &definitions, &available_models, capability)?; - let original_source = original_sources - .iter() - .find(|source| source.source.id == source_id); - let candidates = sources - .iter() - .filter(|source| { - if original_alias.is_none() { - return source.source.id == source_id; - } - original_source.is_some_and(|original| { - source.source.model == original.source.model - && source.source.kind == original.source.kind - && source.source.provider == original.source.provider - }) - }) - .collect::>(); - let source = (candidates.len() == 1) - .then(|| candidates[0].clone()) - .ok_or_else(|| { - "原模型已不在内核当前可用模型中,或其配置来源已经变化,请刷新后重新选择".to_string() - })?; + let source = if let Some(original) = original_alias.as_deref() + .filter(|original| source_id == model_alias_edit_source_id(original)) + { + resolve_model_alias_edit_source(&content, original, &definitions)? + } else { + resolved_oauth_alias_sources(&content, &definitions, &available_models, capability)? + .into_iter() + .find(|source| source.source.id == source_id) + .ok_or_else(|| { + "原模型已不在内核当前可用模型中,或其配置来源已经变化,请刷新后重新选择".to_string() + })? + }; if fast && !alias_source_supports_fast(&source) { return Err("Fast 仅支持 OpenAI 兼容 API、Codex API 或 Codex OAuth 模型源".to_string()); } @@ -585,16 +575,10 @@ pub(crate) async fn create_thinking_alias( }) { return Err(format!("{alias} 已经是实际模型 ID,不能再作为别名")); } - let document = serde_norway::from_str::(&working_content) - .map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?; - let root = document - .as_mapping() - .ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?; - if configured_model_alias_exists(root, &alias) { - return Err(format!("别名模型 {alias} 已存在")); - } - - let updated = add_model_alias_to_yaml(&working_content, &source, &alias, &effort, fast)?; + let updated = match original_alias.as_deref() { + Some(original) => edit_model_alias_in_yaml(&content, original, &source, &alias, &effort, fast)?, + None => add_model_alias_to_yaml(&content, &source, &alias, &effort, fast)?, + }; put_management_alias_config_changes(&config, &content, &updated).await?; thinking_aliases_from_yaml(&updated) } diff --git a/src-tauri/src/core_config.rs b/src-tauri/src/core_config.rs index 7f39d72..e16a8df 100644 --- a/src-tauri/src/core_config.rs +++ b/src-tauri/src/core_config.rs @@ -2,10 +2,12 @@ use super::*; +mod alias_edit; mod aliases; mod commands; mod settings; mod yaml; +pub(crate) use alias_edit::*; pub(crate) use aliases::*; pub(crate) use commands::*; pub(crate) use settings::*; diff --git a/src-tauri/src/core_config/alias_edit.rs b/src-tauri/src/core_config/alias_edit.rs new file mode 100644 index 0000000..e59f442 --- /dev/null +++ b/src-tauri/src/core_config/alias_edit.rs @@ -0,0 +1,413 @@ +//! Edit aliases without rebuilding their configuration from a base model. +use super::*; + +struct EditableModelAlias { + source: ResolvedThinkingAliasSource, + value: serde_norway::Value, +} + +pub(crate) fn model_alias_edit_source_id(alias: &str) -> String { + format!("alias-edit:{}", alias.to_ascii_lowercase()) +} + +fn editable_model_alias( + root: &serde_norway::Mapping, + alias: &str, +) -> Result { + let mut matches = Vec::new(); + for (section, provider, kind, protocol) in [ + ("codex-api-key", "Codex API", "codex-api", "codex"), + ( + "openai-compatibility", + "OpenAI 兼容", + "openai-compatible", + "openai", + ), + ("claude-api-key", "Claude API", "claude-api", "claude"), + ("gemini-api-key", "Gemini API", "gemini-api", "gemini"), + ] { + let Some(providers) = yaml_mapping_value(root, section) else { + continue; + }; + let providers = providers + .as_sequence() + .ok_or_else(|| format!("{section} 必须是数组"))?; + for (provider_index, value) in providers.iter().enumerate() { + let Some(value) = value.as_mapping() else { + continue; + }; + let Some(models) = yaml_mapping_value(value, "models") else { + continue; + }; + let models = models + .as_sequence() + .ok_or_else(|| format!("{section}.models 必须是数组"))?; + for (model_index, model) in models.iter().enumerate() { + let Some((upstream, client, display_name)) = configured_model_identity(model) + else { + continue; + }; + if upstream == client || !client.eq_ignore_ascii_case(alias) { + continue; + } + matches.push(EditableModelAlias { + source: ResolvedThinkingAliasSource { + source: ThinkingAliasSource { + id: model_alias_edit_source_id(alias), + model: upstream, + display_name, + provider: thinking_alias_provider_name(value, provider, provider_index), + kind: kind.to_string(), + protocol: protocol.to_string(), + reasoning_levels: configured_model_reasoning_levels(model, protocol), + }, + location: ThinkingAliasSourceLocation::ConfigModel { + section, + provider_index, + model_index, + }, + }, + value: model.clone(), + }); + } + } + } + if let Some(channels) = yaml_mapping_value(root, "oauth-model-alias") { + let channels = channels + .as_mapping() + .ok_or("oauth-model-alias 必须是 YAML 映射")?; + for (channel_name, models) in channels { + let models = models + .as_sequence() + .ok_or("oauth-model-alias 的通道配置必须是数组")?; + for model in models { + let Some((upstream, client, display_name)) = configured_model_identity(model) + else { + continue; + }; + if upstream == client || !client.eq_ignore_ascii_case(alias) { + continue; + } + let channel = channel_name + .as_str() + .and_then(oauth_alias_channel) + .ok_or("暂不支持编辑此 OAuth 通道的模型别名")?; + matches.push(EditableModelAlias { + source: ResolvedThinkingAliasSource { + source: ThinkingAliasSource { + id: model_alias_edit_source_id(alias), + model: upstream, + display_name, + provider: channel.provider.to_string(), + kind: channel.kind.to_string(), + protocol: channel.protocol.to_string(), + reasoning_levels: Vec::new(), + }, + location: ThinkingAliasSourceLocation::Oauth { + channel: channel.key, + force_mapping: channel.force_mapping, + }, + }, + value: model.clone(), + }); + } + } + } + if matches.len() != 1 { + return Err("别名不存在或存在多个同名映射,请刷新并检查配置后重试".to_string()); + } + Ok(matches.remove(0)) +} + +pub(crate) fn resolve_model_alias_edit_source( + content: &str, + alias: &str, + definitions: &[OAuthModelDefinitions], +) -> Result { + let document = serde_norway::from_str::(content) + .map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?; + let root = document + .as_mapping() + .ok_or("内核配置顶层必须是 YAML 映射")?; + let mut source = editable_model_alias(root, alias)?.source; + if let ThinkingAliasSourceLocation::Oauth { channel, .. } = source.location { + if let Some(model) = definitions + .iter() + .filter(|set| set.channel.key == channel) + .flat_map(|set| &set.models) + .find(|model| model.id.eq_ignore_ascii_case(&source.source.model)) + { + source.source.reasoning_levels = model.reasoning_levels.clone(); + } + } + // An unavailable catalog must not prevent keeping an existing effort setting. + if let Some(effort) = find_thinking_alias_effort(root, alias, &source.source.protocol) { + if !source.source.reasoning_levels.contains(&effort) { + source.source.reasoning_levels.push(effort); + } + } + Ok(source) +} + +pub(crate) fn edit_model_alias_in_yaml( + content: &str, + original_alias: &str, + source: &ResolvedThinkingAliasSource, + alias: &str, + effort: &str, + fast: bool, +) -> Result { + if fast && !alias_source_supports_fast(source) { + return Err("Fast 仅支持 OpenAI 兼容 API、Codex API 或 Codex OAuth 模型源".to_string()); + } + let mut document = yaml_serde_edit::YamlValue::parse(content) + .map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?; + let mut updated = document.get().clone(); + let root = updated + .as_mapping_mut() + .ok_or("内核配置顶层必须是 YAML 映射")?; + let original = editable_model_alias(root, original_alias)?; + if !alias.eq_ignore_ascii_case(original_alias) && configured_model_alias_exists(root, alias) { + return Err(format!("别名模型 {alias} 已存在")); + } + // Capture the destination before removing anything so array indices cannot shift. + let mut replacement = match &source.location { + ThinkingAliasSourceLocation::ConfigModel { + section, + provider_index, + model_index, + } => { + let model = yaml_mapping_value(root, section) + .and_then(serde_norway::Value::as_sequence) + .and_then(|providers| providers.get(*provider_index)) + .and_then(serde_norway::Value::as_mapping) + .and_then(|provider| yaml_mapping_value(provider, "models")) + .and_then(serde_norway::Value::as_sequence) + .and_then(|models| models.get(*model_index)) + .ok_or("原模型配置已经变化,请刷新后重试")?; + let (upstream, _, _) = configured_model_identity(model).ok_or("原模型配置格式无效")?; + let same_model = matches!(&original.source.location, + ThinkingAliasSourceLocation::ConfigModel { section: old_section, provider_index: old_provider, .. } + if old_section == section && old_provider == provider_index) + && original.source.source.model == upstream; + let mut mapping = if same_model { + original.value.as_mapping().cloned() + } else { + model.as_mapping().cloned() + } + .unwrap_or_default(); + mapping.insert(yaml_key("name"), serde_norway::Value::String(upstream)); + mapping + } + ThinkingAliasSourceLocation::Oauth { + channel, + force_mapping, + } => { + let same_channel = matches!(&original.source.location, + ThinkingAliasSourceLocation::Oauth { channel: old_channel, .. } if old_channel == channel); + let mut mapping = if same_channel { + original.value.as_mapping().cloned().unwrap_or_default() + } else { + serde_norway::Mapping::new() + }; + mapping.insert( + yaml_key("name"), + serde_norway::Value::String(source.source.model.clone()), + ); + if !same_channel { + mapping.insert(yaml_key("fork"), serde_norway::Value::Bool(true)); + if *force_mapping { + mapping.insert(yaml_key("force-mapping"), serde_norway::Value::Bool(true)); + } + } + mapping + } + }; + replacement.insert( + yaml_key("alias"), + serde_norway::Value::String(alias.to_string()), + ); + // Preserve ordering and metadata for edits within the same provider/channel. + let same_group = match (&original.source.location, &source.location) { + ( + ThinkingAliasSourceLocation::ConfigModel { + section: a, + provider_index: ai, + .. + }, + ThinkingAliasSourceLocation::ConfigModel { + section: b, + provider_index: bi, + .. + }, + ) => a == b && ai == bi, + ( + ThinkingAliasSourceLocation::Oauth { channel: a, .. }, + ThinkingAliasSourceLocation::Oauth { channel: b, .. }, + ) => a == b, + _ => false, + }; + if !same_group { + remove_existing_claude_model_alias(root, original_alias)?; + } + let models = match &source.location { + ThinkingAliasSourceLocation::ConfigModel { + section, + provider_index, + .. + } => yaml_mapping_value_mut(root, section) + .and_then(serde_norway::Value::as_sequence_mut) + .and_then(|providers| providers.get_mut(*provider_index)) + .and_then(serde_norway::Value::as_mapping_mut) + .and_then(|provider| yaml_mapping_value_mut(provider, "models")) + .and_then(serde_norway::Value::as_sequence_mut) + .ok_or("模型提供商已经变化,请刷新后重试")?, + ThinkingAliasSourceLocation::Oauth { channel, .. } => root + .entry(yaml_key("oauth-model-alias")) + .or_insert_with(|| serde_norway::Value::Mapping(Default::default())) + .as_mapping_mut() + .ok_or("oauth-model-alias 必须是 YAML 映射")? + .entry(yaml_key(channel)) + .or_insert_with(|| serde_norway::Value::Sequence(Vec::new())) + .as_sequence_mut() + .ok_or("oauth-model-alias 的通道配置必须是数组")?, + }; + if same_group { + let model = models + .iter_mut() + .find(|model| { + configured_model_identity(model) + .is_some_and(|(_, name, _)| name.eq_ignore_ascii_case(original_alias)) + }) + .ok_or("原别名已经变化,请刷新后重试")?; + *model = serde_norway::Value::Mapping(replacement); + } else { + models.push(serde_norway::Value::Mapping(replacement)); + } + edit_alias_payload( + root, + original_alias, + &original.source.source.protocol, + alias, + &source.source.protocol, + )?; + let mut params = serde_norway::Mapping::new(); + if !effort.is_empty() { + insert_thinking_effort_params(&mut params, &source.source, effort)?; + } + if fast { + params.insert( + yaml_key("service_tier"), + serde_norway::Value::String("priority".to_string()), + ); + } + append_alias_payload_override(root, alias, &source.source.protocol, params)?; + render_updated_core_yaml(&mut document, updated) +} + +fn edit_alias_payload( + root: &mut serde_norway::Mapping, + original_alias: &str, + original_protocol: &str, + alias: &str, + protocol: &str, +) -> Result<(), String> { + let Some(payload) = yaml_mapping_value_mut(root, "payload") else { + return Ok(()); + }; + let payload = payload.as_mapping_mut().ok_or("payload 必须是 YAML 映射")?; + for section in [ + "default", + "default-raw", + "override", + "override-raw", + "filter", + ] { + let Some(rules) = yaml_mapping_value_mut(payload, section) else { + continue; + }; + let rules = rules + .as_sequence_mut() + .ok_or_else(|| format!("payload.{section} 必须是数组"))?; + let mut result = Vec::new(); + for rule in rules.iter() { + let Some(mapping) = rule.as_mapping() else { + result.push(rule.clone()); + continue; + }; + let Some(models) = yaml_mapping_value(mapping, "models") else { + result.push(rule.clone()); + continue; + }; + let models = models.as_sequence().ok_or("payload.models 必须是数组")?; + let (mut target, others): (Vec<_>, Vec<_>) = + models.iter().cloned().partition(|model| { + thinking_payload_model_name_matches(model, original_alias) + && model + .as_mapping() + .and_then(|model| yaml_mapping_value(model, "protocol")) + .is_none_or(|value| { + value.as_str().is_some_and(|value| { + value.eq_ignore_ascii_case(original_protocol) + }) + }) + }); + if target.is_empty() { + result.push(rule.clone()); + continue; + } + if !others.is_empty() { + let mut shared = mapping.clone(); + shared.insert(yaml_key("models"), serde_norway::Value::Sequence(others)); + result.push(serde_norway::Value::Mapping(shared)); + } + for model in &mut target { + let model = model + .as_mapping_mut() + .ok_or("payload.models 条目必须是映射")?; + model.insert( + yaml_key("name"), + serde_norway::Value::String(alias.to_string()), + ); + if model.contains_key(yaml_key("protocol")) { + model.insert( + yaml_key("protocol"), + serde_norway::Value::String(protocol.to_string()), + ); + } + } + let mut edited = mapping.clone(); + edited.insert(yaml_key("models"), serde_norway::Value::Sequence(target)); + if matches!(section, "override" | "override-raw") { + if let Some(params) = yaml_mapping_value_mut(&mut edited, "params") { + let params = params + .as_mapping_mut() + .ok_or("payload.override.params 必须是映射")?; + for key in [ + "reasoning.effort", + "reasoning_effort", + "output_config.effort", + "generationConfig.thinkingConfig.thinkingLevel", + "thinking.effort", + "thinking.type", + "service_tier", + ] { + params.remove(yaml_key(key)); + } + if params.is_empty() { + continue; + } + } + } + result.push(serde_norway::Value::Mapping(edited)); + } + *rules = result; + if rules.is_empty() { + payload.remove(yaml_key(section)); + } + } + if payload.is_empty() { + root.remove(yaml_key("payload")); + } + Ok(()) +} diff --git a/src-tauri/src/core_config/aliases.rs b/src-tauri/src/core_config/aliases.rs index fdc9ca4..1f262b0 100644 --- a/src-tauri/src/core_config/aliases.rs +++ b/src-tauri/src/core_config/aliases.rs @@ -1619,46 +1619,6 @@ pub(crate) fn insert_thinking_effort_params( Ok(()) } -// Build the replacement in memory; callers only persist after all validation succeeds. -pub(crate) fn prepare_model_alias_edit(content: &str, alias: &str) -> Result { - let mut document = yaml_serde_edit::YamlValue::parse(content) - .map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?; - let mut updated = document.get().clone(); - let root = updated - .as_mapping_mut() - .ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?; - let matches = |model: &&serde_norway::Value| { - configured_model_identity(model) - .is_some_and(|(_, name, _)| name.eq_ignore_ascii_case(alias)) - }; - let api_count = MODEL_ALIAS_CONFIG_SECTIONS - .into_iter() - .filter_map(|section| yaml_mapping_value(root, section)) - .filter_map(serde_norway::Value::as_sequence) - .flatten() - .filter_map(serde_norway::Value::as_mapping) - .filter_map(|provider| yaml_mapping_value(provider, "models")) - .filter_map(serde_norway::Value::as_sequence) - .flatten() - .filter(matches) - .count(); - let oauth_count = yaml_mapping_value(root, "oauth-model-alias") - .and_then(serde_norway::Value::as_mapping) - .into_iter() - .flat_map(|channels| channels.values()) - .filter_map(serde_norway::Value::as_sequence) - .flatten() - .filter(matches) - .count(); - if api_count + oauth_count != 1 { - return Err("别名不存在或存在多个同名映射,请刷新并检查配置后重试".to_string()); - } - remove_existing_claude_model_alias(root, alias)?; - remove_thinking_payload_model(root, alias)?; - remove_speed_payload_model(root, alias)?; - render_updated_core_yaml(&mut document, updated) -} - pub(crate) fn add_model_alias_to_yaml( content: &str, source: &ResolvedThinkingAliasSource, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 67377a3..9b74c34 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2500,6 +2500,7 @@ fn main() { save_codex_model_catalog_editor, get_thinking_aliases, get_model_alias_sources, + get_model_alias_edit_source, get_thinking_alias_sources, create_thinking_alias, delete_thinking_alias, diff --git a/src-tauri/src/tests.rs b/src-tauri/src/tests.rs index e4a6eca..741aaf8 100644 --- a/src-tauri/src/tests.rs +++ b/src-tauri/src/tests.rs @@ -3,6 +3,7 @@ use super::*; mod agent_configuration; mod agent_state; mod agent_transactions; +mod alias_edit; mod app_settings; mod app_update; mod core_config; diff --git a/src-tauri/src/tests/alias_edit.rs b/src-tauri/src/tests/alias_edit.rs new file mode 100644 index 0000000..be96f1c --- /dev/null +++ b/src-tauri/src/tests/alias_edit.rs @@ -0,0 +1,315 @@ +use super::support::*; +use super::*; + +fn edit(content: &str, original: &str, alias: &str, effort: &str, fast: bool) -> String { + let source = resolve_model_alias_edit_source(content, original, &[]).unwrap(); + edit_model_alias_in_yaml(content, original, &source, alias, effort, fast).unwrap() +} + +fn value(content: &str) -> serde_json::Value { + serde_json::to_value(serde_norway::from_str::(content).unwrap()).unwrap() +} + +const MIXED_PAYLOAD: &str = r#"# Keep this comment +port: 8317 +oauth-model-alias: + codex: + - name: gpt-test + alias: my-alias + fork: false +payload: + override: + - models: + - name: my-alias + protocol: codex + - name: other-alias + protocol: codex + params: + reasoning.effort: high + service_tier: priority + temperature: 0.2 + max_output_tokens: 1024 +"#; + +#[test] +fn alias_edit_preserves_other_parameters_and_shared_models() { + let updated = edit(MIXED_PAYLOAD, "my-alias", "my-alias", "low", false); + let before = value(MIXED_PAYLOAD); + let after = value(&updated); + let rules = after["payload"]["override"].as_array().unwrap(); + assert_eq!(rules.len(), 3); + assert_eq!(rules[0]["models"].as_array().unwrap().len(), 1); + assert_eq!(rules[0]["models"][0]["name"], "other-alias"); + assert_eq!( + rules[0]["params"], + before["payload"]["override"][0]["params"] + ); + assert_eq!(rules[1]["models"][0]["name"], "my-alias"); + assert_eq!( + rules[1]["params"], + serde_json::json!({"temperature": 0.2, "max_output_tokens": 1024}) + ); + assert_eq!( + rules[2]["params"], + serde_json::json!({"reasoning.effort": "low"}) + ); + assert_eq!(after["oauth-model-alias"], before["oauth-model-alias"]); + assert!(updated.contains("# Keep this comment")); + assert_eq!(after["port"], before["port"]); +} + +#[test] +fn alias_edit_resolves_remapped_api_model_without_a_visible_upstream() { + let content = "openai-compatibility:\n - name: my-provider\n models:\n - name: upstream-gpt\n alias: public-gpt\n"; + let sources = resolved_oauth_alias_sources( + content, + &[], + &test_agent_models(&["public-gpt"]), + AliasSourceCapability::Base, + ) + .unwrap(); + let created = add_model_alias_to_yaml(content, &sources[0], "my-alias", "high", false).unwrap(); + let source = resolve_model_alias_edit_source(&created, "my-alias", &[]).unwrap(); + assert_eq!(source.source.model, "upstream-gpt"); + assert_eq!(source.source.id, model_alias_edit_source_id("my-alias")); + assert!(source.source.reasoning_levels.contains(&"high".to_string())); + let updated = + edit_model_alias_in_yaml(&created, "my-alias", &source, "renamed", "low", true).unwrap(); + let after = value(&updated); + assert_eq!( + after["openai-compatibility"][0]["models"][0], + value(content)["openai-compatibility"][0]["models"][0] + ); + assert_eq!( + after["openai-compatibility"][0]["models"][1]["name"], + "upstream-gpt" + ); + assert_eq!( + after["openai-compatibility"][0]["models"][1]["alias"], + "renamed" + ); + let entries = thinking_aliases_from_yaml(&updated).unwrap(); + assert_eq!( + entries + .iter() + .find(|entry| entry.alias == "renamed") + .unwrap() + .effort + .as_deref(), + Some("low") + ); + assert_eq!( + speed_aliases_from_yaml(&updated).unwrap()[0].service_tier, + "priority" + ); +} + +#[test] +fn alias_edit_keeps_api_metadata_and_order_on_an_unchanged_save() { + let content = "openai-compatibility:\n - name: my-provider\n models:\n - name: gpt-test\n display-name: Base Model\n - name: gpt-test\n alias: my-alias\n display-name: Custom Alias Label\n thinking:\n levels: [low, high]\n custom: {keep: true}\n - name: other\n"; + let updated = edit(content, "my-alias", "my-alias", "", false); + assert_eq!(value(&updated), value(content)); + // Selecting the base entry for the same upstream must preserve alias metadata too. + let source = resolved_oauth_alias_sources( + content, + &[], + &test_agent_models(&["gpt-test"]), + AliasSourceCapability::Base, + ) + .unwrap() + .remove(0); + let updated = + edit_model_alias_in_yaml(content, "my-alias", &source, "my-alias", "", false).unwrap(); + assert_eq!(value(&updated), value(content)); +} + +#[test] +fn alias_edit_renames_default_raw_and_filter_references() { + let content = format!("{MIXED_PAYLOAD} default:\n - models: [{{name: my-alias}}]\n params: {{temperature: 0.7}}\n default-raw:\n - models: [{{name: my-alias, protocol: codex}}]\n params: {{store: 'false'}}\n override-raw:\n - models: [{{name: my-alias, protocol: codex}}]\n params: {{service_tier: '\"priority\"', store: 'false'}}\n filter:\n - models: [{{name: my-alias, protocol: codex}}]\n params: [metadata]\n"); + let updated = edit(&content, "my-alias", "renamed", "", false); + let after = value(&updated); + for section in ["default", "default-raw", "override-raw", "filter"] { + assert_eq!(after["payload"][section][0]["models"][0]["name"], "renamed"); + } + assert_eq!( + after["payload"]["filter"][0]["params"], + serde_json::json!(["metadata"]) + ); + assert_eq!( + after["payload"]["override-raw"][0]["params"], + serde_json::json!({"store":"false"}) + ); + assert!(!updated.contains("my-alias")); +} + +#[test] +fn alias_edit_keeps_payload_rules_for_another_protocol() { + let content = format!("{MIXED_PAYLOAD} - models: [{{name: my-alias, protocol: openai}}]\n params: {{reasoning_effort: high}}\n"); + let updated = edit(&content, "my-alias", "my-alias", "", false); + let after = value(&updated); + assert!(after["payload"]["override"] + .as_array() + .unwrap() + .iter() + .any(|rule| rule["models"][0]["protocol"] == "openai" + && rule["params"]["reasoning_effort"] == "high")); +} + +#[test] +fn alias_edit_switches_to_a_later_model_without_shifting_indices() { + let content = "codex-api-key:\n - models:\n - name: old-upstream\n alias: my-alias\n - name: new-upstream\n display-name: New Model\n"; + let source = resolved_oauth_alias_sources( + content, + &[], + &test_agent_models(&["new-upstream"]), + AliasSourceCapability::Base, + ) + .unwrap() + .remove(0); + let updated = + edit_model_alias_in_yaml(content, "my-alias", &source, "my-alias", "high", true).unwrap(); + let after = value(&updated); + assert_eq!( + after["codex-api-key"][0]["models"][0]["name"], + "new-upstream" + ); + assert_eq!( + after["codex-api-key"][0]["models"][1], + value(content)["codex-api-key"][0]["models"][1] + ); + assert_eq!( + after["codex-api-key"][0]["models"] + .as_array() + .unwrap() + .len(), + 2 + ); +} + +#[test] +fn alias_edit_selects_exact_provider_even_with_identical_display_names() { + let content = "openai-compatibility:\n - name: shared\n models: [{name: model-a, alias: my-alias}, {name: shared-model}]\n - name: shared\n models: [{name: shared-model}]\n"; + let sources = resolved_oauth_alias_sources( + content, + &[], + &test_agent_models(&["shared-model"]), + AliasSourceCapability::Base, + ) + .unwrap(); + let source = sources + .iter() + .find(|source| source.source.id == "openai-compatibility:1:0") + .unwrap(); + let updated = + edit_model_alias_in_yaml(content, "my-alias", source, "my-alias", "", false).unwrap(); + let after = value(&updated); + assert_eq!( + after["openai-compatibility"][0]["models"] + .as_array() + .unwrap() + .len(), + 1 + ); + assert_eq!( + after["openai-compatibility"][1]["models"][1]["alias"], + "my-alias" + ); +} + +#[test] +fn alias_edit_moves_between_api_and_oauth_and_updates_payload_protocol() { + let content = "openai-compatibility:\n - name: provider\n models: [{name: api-model, alias: my-alias}]\noauth-model-alias:\n codex:\n - name: oauth-model\n alias: other-alias\n fork: true\npayload:\n override:\n - models: [{name: my-alias, protocol: openai}]\n params: {temperature: 0.2, reasoning_effort: high}\n"; + let source = resolve_model_alias_edit_source(content, "other-alias", &[]).unwrap(); + let updated = + edit_model_alias_in_yaml(content, "my-alias", &source, "my-alias", "low", true).unwrap(); + let after = value(&updated); + assert!(after["openai-compatibility"][0]["models"] + .as_array() + .unwrap() + .is_empty()); + assert_eq!( + after["oauth-model-alias"]["codex"] + .as_array() + .unwrap() + .len(), + 2 + ); + assert_eq!( + after["payload"]["override"][0]["models"][0]["protocol"], + "codex" + ); + assert_eq!( + after["payload"]["override"][0]["params"], + serde_json::json!({"temperature":0.2}) + ); + let content = format!("{updated}codex-api-key:\n - models: [{{name: back-to-api}}]\n"); + let source = resolved_oauth_alias_sources( + &content, + &[], + &test_agent_models(&["back-to-api"]), + AliasSourceCapability::Base, + ) + .unwrap() + .remove(0); + let updated = + edit_model_alias_in_yaml(&content, "my-alias", &source, "renamed", "", false).unwrap(); + let after = value(&updated); + assert_eq!( + after["oauth-model-alias"]["codex"] + .as_array() + .unwrap() + .len(), + 1 + ); + assert_eq!(after["codex-api-key"][0]["models"][1]["alias"], "renamed"); +} + +#[test] +fn alias_edit_rejects_duplicates_missing_aliases_and_name_collisions() { + let source = resolve_model_alias_edit_source(MIXED_PAYLOAD, "my-alias", &[]).unwrap(); + assert!( + edit_model_alias_in_yaml("port: 8317\n", "missing", &source, "renamed", "", false).is_err() + ); + let duplicate = MIXED_PAYLOAD.replace( + " fork: false", + " fork: false\n - name: duplicate\n alias: MY-ALIAS", + ); + assert!( + edit_model_alias_in_yaml(&duplicate, "my-alias", &source, "renamed", "", false).is_err() + ); + let occupied = MIXED_PAYLOAD.replace( + " fork: false", + " fork: false\n - name: another\n alias: taken", + ); + assert!(edit_model_alias_in_yaml(&occupied, "my-alias", &source, "TAKEN", "", false).is_err()); + // A real model entry without an alias is not an editable alias. + assert!(resolve_model_alias_edit_source( + "codex-api-key:\n - models: [{name: real-model}]\n", + "real-model", + &[] + ) + .is_err()); +} + +#[test] +fn alias_edit_keeps_an_unavailable_oauth_source_and_existing_effort() { + let source = resolve_model_alias_edit_source(MIXED_PAYLOAD, "my-alias", &[]).unwrap(); + assert_eq!(source.source.model, "gpt-test"); + assert_eq!(source.source.reasoning_levels, ["high"]); + let updated = edit(MIXED_PAYLOAD, "my-alias", "renamed", "high", true); + assert_eq!( + value(&updated)["oauth-model-alias"]["codex"][0]["fork"], + false + ); +} + +#[test] +fn alias_edit_does_not_change_implicit_or_explicit_oauth_routing_flags() { + for channel in ["codex", "antigravity"] { + for flags in ["", " fork: false\n force-mapping: false\n"] { + let content = format!("oauth-model-alias:\n {channel}:\n - name: upstream\n alias: my-alias\n{flags}"); + let updated = edit(&content, "my-alias", "my-alias", "", false); + assert_eq!(value(&updated), value(&content)); + } + } +} diff --git a/src-tauri/src/tests/model_aliases.rs b/src-tauri/src/tests/model_aliases.rs index 880f8ec..2379268 100644 --- a/src-tauri/src/tests/model_aliases.rs +++ b/src-tauri/src/tests/model_aliases.rs @@ -677,8 +677,7 @@ fn edit_model_alias_replaces_name_effort_and_fast_in_memory() { let source = test_oauth_thinking_source("codex", "gpt-test"); let original = add_model_alias_to_yaml("port: 8317\n", &source, "old-alias", "high", true).unwrap(); - let prepared = prepare_model_alias_edit(&original, "old-alias").unwrap(); - let updated = add_model_alias_to_yaml(&prepared, &source, "new-alias", "low", false).unwrap(); + let updated = edit_model_alias_in_yaml(&original, "old-alias", &source, "new-alias", "low", false).unwrap(); let entries = thinking_aliases_from_yaml(&updated).unwrap(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].alias, "new-alias"); @@ -687,7 +686,7 @@ fn edit_model_alias_replaces_name_effort_and_fast_in_memory() { assert!(updated.contains("port: 8317")); assert!(!updated.contains("old-alias")); let unchanged_name = - add_model_alias_to_yaml(&prepared, &source, "old-alias", "", false).unwrap(); + edit_model_alias_in_yaml(&original, "old-alias", &source, "old-alias", "", false).unwrap(); assert_eq!( thinking_aliases_from_yaml(&unchanged_name).unwrap()[0].effort, None @@ -696,7 +695,7 @@ fn edit_model_alias_replaces_name_effort_and_fast_in_memory() { #[test] fn edit_model_alias_rejects_missing_and_ambiguous_aliases() { - assert!(prepare_model_alias_edit("port: 8317\n", "missing").is_err()); + assert!(resolve_model_alias_edit_source("port: 8317\n", "missing", &[]).is_err()); let content = "oauth-model-alias:\n codex:\n - name: model-a\n alias: shared\n claude:\n - name: model-b\n alias: shared\n"; - assert!(prepare_model_alias_edit(content, "shared").is_err()); + assert!(resolve_model_alias_edit_source(content, "shared", &[]).is_err()); } diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 7eeca01..fb10a95 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -410,6 +410,7 @@ export const jaOverrides = { 'aliases.error.unsupportedEffort': 'このモデルソースには利用可能な推論レベルがありません', 'aliases.created': '{alias} を作成し、推論レベルを {effort} に固定しました', 'aliases.createdPlain': 'モデルエイリアス {alias} を作成しました', + 'aliases.updated': 'モデルエイリアス {alias} を更新しました', 'aliases.deleteConfirm': 'エイリアスモデル「{alias}」を削除しますか?\n対応する推論レベルの上書きルールも削除されます。', 'aliases.deleted': '{alias} を削除しました', 'aliases.create.title': 'モデルエイリアスを作成', @@ -441,7 +442,7 @@ export const jaOverrides = { 'aliases.createdList.description': '各エイリアスに固定された推論レベルを表示します', 'aliases.loadingConfig': '設定を読み込んでいます', 'aliases.empty.title': 'モデルエイリアスはまだありません', - 'aliases.empty.description': '左側でモデルとエイリアスオプションを選択して作成できます', + 'aliases.empty.description': '「エイリアスを作成」をクリックして最初のモデルエイリアスを追加できます', 'aliases.unboundEffort': 'レベル未設定', 'aliases.delete': '{alias} を削除', 'config.routing.roundRobin': 'ラウンドロビン', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 989ded8..89c182a 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -400,6 +400,7 @@ export const en: Record = { 'aliases.created': 'Created {alias} with reasoning effort fixed to {effort}', 'aliases.createdCombined': 'Created {alias} with reasoning effort {effort} and Fast enabled', 'aliases.createdPlain': 'Created model alias {alias}', + 'aliases.updated': 'Updated model alias {alias}', 'aliases.deleteConfirm': 'Delete the model alias “{alias}”?\nIts reasoning-effort and Fast overrides will also be removed.', 'aliases.deleted': 'Deleted {alias}', 'aliases.create.title': 'Create Model Alias', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 471896c..c7a9041 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -399,6 +399,7 @@ export const zhCN = { 'aliases.created': '已创建 {alias},思考强度固定为 {effort}', 'aliases.createdCombined': '已创建 {alias},思考强度固定为 {effort},同时启用 Fast', 'aliases.createdPlain': '已创建别名 {alias}', + 'aliases.updated': '已更新别名 {alias}', 'aliases.deleteConfirm': '确定删除别名模型“{alias}”吗?\n对应的思考强度和 Fast 覆写规则也会被移除。', 'aliases.deleted': '已删除 {alias}', 'aliases.create.title': '创建模型别名', diff --git a/src/pages/ThinkingAliasesPage.tsx b/src/pages/ThinkingAliasesPage.tsx index 80a218c..9e1967c 100644 --- a/src/pages/ThinkingAliasesPage.tsx +++ b/src/pages/ThinkingAliasesPage.tsx @@ -190,15 +190,17 @@ export function ThinkingAliasesPage() { const [fastEnabled, setFastEnabled] = useState(false); const [alias, setAlias] = useState(''); const [editingEntry, setEditingEntry] = useState(null); + const [editingSource, setEditingSource] = useState(null); const [editorOpen, setEditorOpen] = useState(false); const [search, setSearch] = useState(''); const [modelPickerOpen, setModelPickerOpen] = useState(false); const [activeSourceIndex, setActiveSourceIndex] = useState(0); const modelPickerRef = useRef(null); + const editorRef = useRef(null); const generatedAliasRef = useRef(''); const [loading, setLoading] = useState(true); const [busyAlias, setBusyAlias] = useState(''); - const [busyAction, setBusyAction] = useState<'create' | 'delete' | ''>(''); + const [busyAction, setBusyAction] = useState<'create' | 'edit' | 'delete' | ''>(''); const [error, setError] = useState(''); const [notice, setNotice] = useState(''); @@ -253,8 +255,16 @@ export function ThinkingAliasesPage() { }, [modelPickerOpen]); const sources = useMemo( - () => combineModelAliasSources(baseSources, thinkingSources, speedSources), - [baseSources, speedSources, thinkingSources], + () => { + const choices = combineModelAliasSources(baseSources, thinkingSources, speedSources); + if (!editingSource) return choices; + return [{ + ...editingSource, + supportsReasoning: editingSource.reasoningLevels.length > 0, + supportsFast: ['codex-oauth', 'codex-api', 'openai-compatible'].includes(editingSource.kind), + }, ...choices]; + }, + [baseSources, speedSources, thinkingSources, editingSource], ); const entries = useMemo( () => combineModelAliasEntries(thinkingEntries, speedEntries), @@ -320,10 +330,11 @@ export function ThinkingAliasesPage() { }; const handleModelSearchKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { + if (event.key === 'Escape' && modelPickerOpen) { + event.preventDefault(); + event.stopPropagation(); setModelPickerOpen(false); setSearch(''); - event.currentTarget.blur(); return; } if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { @@ -395,8 +406,7 @@ export function ThinkingAliasesPage() { sourceId: selectedSource.id, alias: normalizedAlias, effort: normalizedEffort, fast: fastEnabled, originalAlias: editingEntry.alias, }); - setNotice(''); - setEditingEntry(null); + setNotice(t('aliases.updated', { alias: normalizedAlias })); } else if (normalizedEffort) { await invoke('create_thinking_alias', { sourceId: selectedSource.id, @@ -426,6 +436,7 @@ export function ThinkingAliasesPage() { setAlias(''); await load(); setEditorOpen(false); + resetEditor(); } catch (requestError) { setError(String(requestError)); } finally { @@ -436,6 +447,7 @@ export function ThinkingAliasesPage() { const resetEditor = () => { setEditingEntry(null); + setEditingSource(null); setSelectedSourceId(''); setEffort(''); setFastEnabled(false); @@ -458,26 +470,61 @@ export function ThinkingAliasesPage() { setEditorOpen(true); }; - const editAlias = (entry: AliasListEntry) => { - const source = sources.find((candidate) => candidate.model === entry.sourceModel - && candidate.kind === entry.kind && candidate.provider === entry.provider); - if (!source) { - setError(`${entry.sourceModel}: ${t('common.unavailable')}`); - return; - } - setEditingEntry(entry); - setSelectedSourceId(source.id); - setEffort(entry.effort ?? ''); - setFastEnabled(Boolean(entry.serviceTier)); - setAlias(entry.alias); - generatedAliasRef.current = ''; - setModelPickerOpen(false); - setSearch(''); + const editAlias = async (entry: AliasListEntry) => { + setBusyAlias(entry.alias); + setBusyAction('edit'); setError(''); setNotice(''); - setEditorOpen(true); + try { + const source = await invoke('get_model_alias_edit_source', { alias: entry.alias }); + setEditingEntry(entry); + setEditingSource(source); + setSelectedSourceId(source.id); + setEffort(entry.effort ?? ''); + setFastEnabled(entry.serviceTier === 'priority'); + setAlias(entry.alias); + generatedAliasRef.current = ''; + setModelPickerOpen(false); + setSearch(''); + setEditorOpen(true); + } catch (requestError) { + setError(String(requestError)); + } finally { + setBusyAlias(''); + setBusyAction(''); + } }; + useEffect(() => { + if (!editorOpen) return; + const previousFocus = document.activeElement; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + editorRef.current?.querySelector('input')?.focus(); + const trapFocus = (event: globalThis.KeyboardEvent) => { + if (event.key !== 'Tab') return; + const controls = Array.from(editorRef.current?.querySelectorAll( + 'button:not(:disabled), input:not(:disabled), [tabindex="0"]', + ) ?? []); + const first = controls[0]; + const last = controls[controls.length - 1]; + const outside = !editorRef.current?.contains(document.activeElement); + if (event.shiftKey && (outside || document.activeElement === first)) { + event.preventDefault(); + last?.focus(); + } else if (!event.shiftKey && (outside || document.activeElement === last)) { + event.preventDefault(); + first?.focus(); + } + }; + document.addEventListener('keydown', trapFocus); + return () => { + document.removeEventListener('keydown', trapFocus); + document.body.style.overflow = previousOverflow; + if (previousFocus instanceof HTMLElement && previousFocus.isConnected) previousFocus.focus(); + }; + }, [editorOpen]); + const deleteAlias = async (entry: AliasListEntry) => { if (!await askConfirmation({ title: t('common.delete'), message: t('aliases.deleteConfirm', { alias: entry.alias }), confirmText: t('common.delete'), variant: 'danger' })) return; setBusyAlias(entry.alias); @@ -510,7 +557,7 @@ export function ThinkingAliasesPage() {
{confirmationDialog}
- {error ?
{error}
: null} + {error && !editorOpen ?
{error}
: null} {!error && notice ?
{notice}
: null}
@@ -536,6 +583,7 @@ export function ThinkingAliasesPage() { {editorOpen ? (
event.currentTarget === event.target && closeEditor()}>
+ {error ?
{error}
: null}
@@ -805,7 +854,7 @@ export function ThinkingAliasesPage() { {t('speedAliases.fast.title')} ) : null}
diff --git a/src/styles.css b/src/styles.css index 37af0c8..96bc64f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -4782,7 +4782,7 @@ textarea:focus-visible { overflow: visible; } -.thinking-alias-dialog { +.management-dialog.thinking-alias-dialog { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; width: min(640px, calc(100vw - 32px)); @@ -4809,6 +4809,8 @@ textarea:focus-visible { .thinking-alias-dialog-body { display: grid; + grid-auto-rows: max-content; + align-content: start; gap: 18px; min-height: 0; padding: 18px 20px; @@ -4839,7 +4841,7 @@ textarea:focus-visible { white-space: normal; } -.thinking-alias-dialog .thinking-fast-option { +.thinking-alias-dialog .thinking-alias-field > .thinking-fast-option { display: flex; grid-template-rows: none; } diff --git a/tests/fixtures/model-alias-editor.html b/tests/fixtures/model-alias-editor.html new file mode 100644 index 0000000..f2a8faa --- /dev/null +++ b/tests/fixtures/model-alias-editor.html @@ -0,0 +1,5 @@ + + + Model alias editor fixture +
+ diff --git a/tests/fixtures/model-alias-editor.tsx b/tests/fixtures/model-alias-editor.tsx new file mode 100644 index 0000000..30065c0 --- /dev/null +++ b/tests/fixtures/model-alias-editor.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { mockIPC } from '@tauri-apps/api/mocks'; +import { I18nProvider } from '../../src/i18n'; +import { ThinkingAliasesPage } from '../../src/pages/ThinkingAliasesPage'; +import '../../src/styles.css'; + +localStorage.setItem('easy-cli-proxy-api.locale', 'en'); +const calls: { cmd: string; args: Record }[] = []; +(window as any).fixtureCalls = calls; +(window as any).fixtureFailSave = false; +const source = { + id: 'openai-compatibility:0:0', model: 'public-gpt', displayName: null, + provider: 'Provider', kind: 'openai-compatible', protocol: 'openai', reasoningLevels: ['low', 'high'], +}; +let entry = { + sourceModel: 'upstream-gpt', alias: 'my-alias', effort: 'high', provider: 'Provider', + kind: 'openai-compatible', oauthChannel: null, +}; +mockIPC(async (cmd, rawArgs) => { + const args = (rawArgs ?? {}) as Record; + calls.push({ cmd, args }); + if (cmd === 'set_app_locale') return null; + if (cmd === 'get_thinking_aliases') return [entry]; + if (cmd === 'get_speed_aliases') return []; + if (['get_model_alias_sources', 'get_thinking_alias_sources', 'get_speed_alias_sources'].includes(cmd)) return [source]; + if (cmd === 'get_model_alias_edit_source') return { ...source, id: `alias-edit:${args.alias}`, model: 'upstream-gpt' }; + if (cmd === 'create_thinking_alias') { + if ((window as any).fixtureFailSave) throw new Error('Save failed: configuration changed'); + entry = { ...entry, alias: args.alias, effort: args.effort }; + return [entry]; + } + throw new Error(`Unhandled fixture command: ${cmd}`); +}); +createRoot(document.getElementById('root')!).render( +
, +); diff --git a/tests/model-alias-editor-ui.cjs b/tests/model-alias-editor-ui.cjs new file mode 100644 index 0000000..1f9af4f --- /dev/null +++ b/tests/model-alias-editor-ui.cjs @@ -0,0 +1,91 @@ +// Run Vite on port 1423, then: node tests/model-alias-editor-ui.cjs +// Set PLAYWRIGHT_MODULE to an installed Playwright module path when needed. +const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright'); +const assert = require('node:assert/strict'); +const { mkdirSync } = require('node:fs'); + +(async () => { + const browser = await chromium.launch({ channel: 'msedge', headless: true }); + try { + const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); + page.setDefaultTimeout(10000); + // External font loading is unrelated to the editor's behavior. + await page.route('**/*', route => route.request().url().startsWith('http://127.0.0.1:1423/') + ? 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' }); + 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'); + await dialog.waitFor(); + assert.equal(await dialog.locator('#thinking-model-search').inputValue(), 'upstream-gpt'); + assert.equal(await dialog.locator('#thinking-alias-name').inputValue(), 'my-alias'); + // Escape dismisses the model picker before dismissing the editor. + await page.keyboard.press('Escape'); + assert.equal(await dialog.count(), 1); + assert.equal(await page.getByRole('listbox').count(), 0); + await dialog.getByRole('button', { name: 'Save', exact: true }).focus(); + await page.keyboard.press('Tab'); + assert.equal(await dialog.getByRole('button', { name: 'Close', exact: true }).evaluate(el => el === document.activeElement), true); + await page.keyboard.press('Shift+Tab'); + assert.equal(await dialog.getByRole('button', { name: 'Save', exact: true }).evaluate(el => el === document.activeElement), true); + await dialog.getByRole('button', { name: 'Cancel', exact: true }).click(); + assert.equal((await writes()).length, 0); + assert.equal(await page.getByRole('button', { name: 'Edit', exact: true }).evaluate(el => el === document.activeElement), true); + + await page.getByRole('button', { name: 'Edit', exact: true }).click(); + await dialog.locator('#thinking-alias-name').fill('renamed-alias'); + await dialog.getByRole('button', { name: 'Low', exact: true }).click(); + await page.evaluate(() => { window.fixtureFailSave = true; }); + await dialog.getByRole('button', { name: 'Save', exact: true }).click(); + await dialog.getByRole('alert').waitFor(); + assert.match(await dialog.getByRole('alert').innerText(), /configuration changed/); + assert.equal(await dialog.locator('#thinking-alias-name').inputValue(), 'renamed-alias'); + await page.evaluate(() => { window.fixtureFailSave = false; }); + await dialog.getByRole('button', { name: 'Save', exact: true }).click(); + await dialog.waitFor({ state: 'detached' }); + const saved = (await writes()).at(-1).args; + assert.equal(saved.sourceId, 'alias-edit:my-alias'); + assert.equal(saved.originalAlias, 'my-alias'); + assert.equal(saved.alias, 'renamed-alias'); + assert.equal(saved.effort, 'low'); + await page.getByText('Updated model alias renamed-alias', { exact: true }).waitFor(); + + // Switch from the original configuration source to a live model source. + await page.getByRole('button', { name: 'Edit', exact: true }).click(); + await dialog.locator('#thinking-model-search').fill('public-gpt'); + await page.getByRole('option').click(); + assert.equal(await dialog.locator('#thinking-alias-name').inputValue(), 'renamed-alias'); + await dialog.getByRole('button', { name: 'Save', exact: true }).click(); + await dialog.waitFor({ state: 'detached' }); + assert.equal((await writes()).at(-1).args.sourceId, 'openai-compatibility:0:0'); + + await page.setViewportSize({ width: 640, height: 600 }); + await page.getByRole('button', { name: 'Create Alias', exact: true }).click(); + await page.keyboard.press('Escape'); + await dialog.getByRole('button', { name: 'Create Alias', exact: true }).click(); + await dialog.getByRole('alert').waitFor(); + await dialog.locator('#thinking-model-search').fill('public-gpt'); + await page.getByRole('option').click(); + await dialog.locator('#thinking-alias-name').fill('new-alias'); + const rect = await dialog.boundingBox(); + assert.ok(rect.x >= 0 && rect.y >= 0 && rect.x + rect.width <= 640 && rect.y + rect.height <= 600); + const overlap = await dialog.locator('.thinking-alias-dialog-body').evaluate(body => { + const fields = Array.from(body.children); + return fields.some((field, index) => index > 0 && field.getBoundingClientRect().top < fields[index - 1].getBoundingClientRect().bottom - 1); + }); + assert.equal(overlap, false, 'Narrow dialog fields must scroll instead of overlapping'); + assert.equal(await dialog.locator('.thinking-fast-option').evaluate(el => getComputedStyle(el).display), 'flex'); + mkdirSync('misc', { recursive: true }); + await page.screenshot({ path: 'misc/model-alias-editor-narrow.png', fullPage: true }); + await dialog.getByRole('button', { name: 'Create Alias', exact: true }).click(); + await dialog.waitFor({ state: 'detached' }); + assert.equal((await writes()).at(-1).args.originalAlias, undefined); + assert.equal((await writes()).at(-1).args.alias, 'new-alias'); + assert.deepEqual(errors, []); + console.log('PASS: remapped source, cancel, focus trap, Escape, inline errors, retry, rename, source switch, create, narrow dialog'); + } finally { + await browser.close(); + } +})().catch(error => { console.error(error); process.exitCode = 1; });