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.
This commit is contained in:
1 parent
ee223424af
commit
7c9b8897ed
16 files changed
+979
-117
No files matched your search
@@ -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<ThinkingAliasSource, String> {
|
||||
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::<Vec<_>>();
|
||||
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::<serde_norway::Value>(&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)
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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<EditableModelAlias, String> {
|
||||
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<ResolvedThinkingAliasSource, String> {
|
||||
let document = serde_norway::from_str::<serde_norway::Value>(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<String, String> {
|
||||
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(())
|
||||
}
|
||||
@@ -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<String, 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_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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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::<serde_norway::Value>(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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
+2
-1
@@ -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': 'ラウンドロビン',
|
||||
|
||||
@@ -400,6 +400,7 @@ export const en: Record<MessageKey, string> = {
|
||||
'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',
|
||||
|
||||
@@ -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': '创建模型别名',
|
||||
|
||||
@@ -190,15 +190,17 @@ export function ThinkingAliasesPage() {
|
||||
const [fastEnabled, setFastEnabled] = useState(false);
|
||||
const [alias, setAlias] = useState('');
|
||||
const [editingEntry, setEditingEntry] = useState<AliasListEntry | null>(null);
|
||||
const [editingSource, setEditingSource] = useState<ThinkingAliasSource | null>(null);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [modelPickerOpen, setModelPickerOpen] = useState(false);
|
||||
const [activeSourceIndex, setActiveSourceIndex] = useState(0);
|
||||
const modelPickerRef = useRef<HTMLDivElement>(null);
|
||||
const editorRef = useRef<HTMLElement>(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<HTMLInputElement>) => {
|
||||
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<ThinkingAliasEntry[]>('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<ThinkingAliasSource>('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<HTMLInputElement>('input')?.focus();
|
||||
const trapFocus = (event: globalThis.KeyboardEvent) => {
|
||||
if (event.key !== 'Tab') return;
|
||||
const controls = Array.from(editorRef.current?.querySelectorAll<HTMLElement>(
|
||||
'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() {
|
||||
<section className="page management-page thinking-alias-page">
|
||||
{confirmationDialog}
|
||||
<div className="thinking-alias-feedback" aria-live="polite">
|
||||
{error ? <div className="management-alert error">{error}</div> : null}
|
||||
{error && !editorOpen ? <div className="management-alert error">{error}</div> : null}
|
||||
{!error && notice ? <div className="management-alert success">{notice}</div> : null}
|
||||
</div>
|
||||
|
||||
@@ -536,6 +583,7 @@ export function ThinkingAliasesPage() {
|
||||
{editorOpen ? (
|
||||
<div className="config-dialog-backdrop" onMouseDown={(event) => event.currentTarget === event.target && closeEditor()}>
|
||||
<section
|
||||
ref={editorRef}
|
||||
className="panel management-dialog thinking-alias-editor-panel thinking-alias-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@@ -554,6 +602,7 @@ export function ThinkingAliasesPage() {
|
||||
</div>
|
||||
|
||||
<div className="thinking-alias-dialog-body">
|
||||
{error ? <div className="management-alert error" role="alert">{error}</div> : null}
|
||||
<div className="thinking-alias-field thinking-model-field">
|
||||
<label htmlFor="thinking-model-search">{t('aliases.originalModel')}</label>
|
||||
<div className="thinking-model-picker" ref={modelPickerRef}>
|
||||
@@ -805,7 +854,7 @@ export function ThinkingAliasesPage() {
|
||||
<span className="thinking-effort-badge fast">{t('speedAliases.fast.title')}</span>
|
||||
) : null}
|
||||
<button type="button" className="icon-button quiet" disabled={loading || Boolean(busyAlias)}
|
||||
onClick={() => editAlias(entry)} title={t('common.edit')} aria-label={t('common.edit')}>
|
||||
onClick={() => void editAlias(entry)} title={t('common.edit')} aria-label={t('common.edit')}>
|
||||
<Pencil size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
+4
-2
@@ -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;
|
||||
}
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Model alias editor fixture</title></head>
|
||||
<body><div id="root"></div><script type="module" src="./model-alias-editor.tsx"></script></body>
|
||||
</html>
|
||||
+37
@@ -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<string, unknown> }[] = [];
|
||||
(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<string, any>;
|
||||
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(
|
||||
<I18nProvider><main style={{ padding: 24 }}><ThinkingAliasesPage /></main></I18nProvider>,
|
||||
);
|
||||
@@ -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; });
|
||||
Reference in new issue
Block a user