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; });