diff --git a/src-tauri/src/agents/commands.rs b/src-tauri/src/agents/commands.rs index 2602145f..9e811071 100644 --- a/src-tauri/src/agents/commands.rs +++ b/src-tauri/src/agents/commands.rs @@ -504,6 +504,7 @@ pub(crate) async fn create_thinking_alias( alias: String, effort: String, fast: Option, + original_alias: Option, ) -> Result, String> { let config = gui_config_state.snapshot()?; let source_id = source_id.trim().to_string(); @@ -518,6 +519,10 @@ 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,12 +533,33 @@ pub(crate) async fn create_thinking_alias( } else { AliasSourceCapability::Base }; - let sources = + 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 source = sources + let original_source = original_sources .iter() - .find(|source| source.source.id == source_id) - .cloned() + .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() })?; @@ -556,13 +582,15 @@ pub(crate) async fn create_thinking_alias( return Err("别名模型不能和原模型相同".to_string()); } - if available_models - .iter() - .any(|model| model.name.eq_ignore_ascii_case(&alias)) - { + if available_models.iter().any(|model| { + model.name.eq_ignore_ascii_case(&alias) + && !original_alias + .as_deref() + .is_some_and(|original| original.eq_ignore_ascii_case(&alias)) + }) { return Err(format!("{alias} 已经是实际模型 ID,不能再作为别名")); } - let document = serde_norway::from_str::(&content) + let document = serde_norway::from_str::(&working_content) .map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?; let root = document .as_mapping() @@ -571,7 +599,7 @@ pub(crate) async fn create_thinking_alias( return Err(format!("别名模型 {alias} 已存在")); } - let updated = add_model_alias_to_yaml(&content, &source, &alias, &effort, fast)?; + let updated = add_model_alias_to_yaml(&working_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/aliases.rs b/src-tauri/src/core_config/aliases.rs index 1f262b00..fdc9ca4a 100644 --- a/src-tauri/src/core_config/aliases.rs +++ b/src-tauri/src/core_config/aliases.rs @@ -1619,6 +1619,46 @@ 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/tests/model_aliases.rs b/src-tauri/src/tests/model_aliases.rs index d65fcd8e..880f8ecf 100644 --- a/src-tauri/src/tests/model_aliases.rs +++ b/src-tauri/src/tests/model_aliases.rs @@ -671,3 +671,32 @@ fn speed_alias_supports_codex_api_model_entries() { assert_eq!(entries.len(), 1); assert_eq!(entries[0].kind, "codex-api"); } + +#[test] +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 entries = thinking_aliases_from_yaml(&updated).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].alias, "new-alias"); + assert_eq!(entries[0].effort.as_deref(), Some("low")); + assert!(speed_aliases_from_yaml(&updated).unwrap().is_empty()); + assert!(updated.contains("port: 8317")); + assert!(!updated.contains("old-alias")); + let unchanged_name = + add_model_alias_to_yaml(&prepared, &source, "old-alias", "", false).unwrap(); + assert_eq!( + thinking_aliases_from_yaml(&unchanged_name).unwrap()[0].effort, + None + ); +} + +#[test] +fn edit_model_alias_rejects_missing_and_ambiguous_aliases() { + assert!(prepare_model_alias_edit("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()); +} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index a22291ea..49c76753 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -436,7 +436,7 @@ export const en: Record = { 'aliases.createdList.description': 'Each alias shows its fixed reasoning effort and Fast status', 'aliases.loadingConfig': 'Loading configuration', 'aliases.empty.title': 'No model aliases yet', - 'aliases.empty.description': 'Select a model and alias option on the left to create one', + 'aliases.empty.description': 'Click “Create Alias” to add your first model alias', 'aliases.unboundEffort': 'No effort bound', 'aliases.delete': 'Delete {alias}', 'speedAliases.created': 'Created {alias} with the Fast request tier fixed', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 7a326280..59762fce 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -435,7 +435,7 @@ export const zhCN = { 'aliases.createdList.description': '每个别名会显示已固定的思考强度和 Fast 状态', 'aliases.loadingConfig': '读取配置中', 'aliases.empty.title': '尚未创建模型别名', - 'aliases.empty.description': '从左侧选择模型和别名选项即可创建', + 'aliases.empty.description': '点击“创建别名”添加第一个模型别名', 'aliases.unboundEffort': '未绑定强度', 'aliases.delete': '删除 {alias}', 'speedAliases.created': '已创建 {alias},并固定使用 Fast 请求层级', diff --git a/src/pages/ThinkingAliasesPage.tsx b/src/pages/ThinkingAliasesPage.tsx index 595ff499..80a218c5 100644 --- a/src/pages/ThinkingAliasesPage.tsx +++ b/src/pages/ThinkingAliasesPage.tsx @@ -14,8 +14,11 @@ import { Check, GitFork, LoaderCircle, + Plus, Search, + Pencil, Trash2, + X, Zap, } from 'lucide-react'; import { getCurrentLocale, translate, useI18n } from '../i18n'; @@ -186,6 +189,8 @@ export function ThinkingAliasesPage() { const [effort, setEffort] = useState(''); const [fastEnabled, setFastEnabled] = useState(false); const [alias, setAlias] = useState(''); + const [editingEntry, setEditingEntry] = useState(null); + const [editorOpen, setEditorOpen] = useState(false); const [search, setSearch] = useState(''); const [modelPickerOpen, setModelPickerOpen] = useState(false); const [activeSourceIndex, setActiveSourceIndex] = useState(0); @@ -304,7 +309,7 @@ export function ThinkingAliasesPage() { setSelectedSourceId(source.id); setEffort(''); setFastEnabled((current) => current && source.supportsFast); - setAlias(''); + if (!editingEntry) setAlias(''); generatedAliasRef.current = ''; setModelPickerOpen(false); setSearch(''); @@ -352,6 +357,7 @@ export function ThinkingAliasesPage() { }); useEffect(() => { + if (editingEntry) return; setAlias((current) => { const currentValue = current.trim(); const canReplace = !currentValue || currentValue === generatedAliasRef.current; @@ -359,7 +365,7 @@ export function ThinkingAliasesPage() { generatedAliasRef.current = uniqueDefaultAlias; return uniqueDefaultAlias; }); - }, [uniqueDefaultAlias]); + }, [uniqueDefaultAlias, editingEntry]); const createAlias = async () => { if (!selectedSource) { @@ -384,7 +390,14 @@ export function ThinkingAliasesPage() { setError(''); setNotice(''); try { - if (normalizedEffort) { + if (editingEntry) { + await invoke('create_thinking_alias', { + sourceId: selectedSource.id, alias: normalizedAlias, + effort: normalizedEffort, fast: fastEnabled, originalAlias: editingEntry.alias, + }); + setNotice(''); + setEditingEntry(null); + } else if (normalizedEffort) { await invoke('create_thinking_alias', { sourceId: selectedSource.id, alias: normalizedAlias, @@ -412,6 +425,7 @@ export function ThinkingAliasesPage() { } setAlias(''); await load(); + setEditorOpen(false); } catch (requestError) { setError(String(requestError)); } finally { @@ -420,6 +434,50 @@ export function ThinkingAliasesPage() { } }; + const resetEditor = () => { + setEditingEntry(null); + setSelectedSourceId(''); + setEffort(''); + setFastEnabled(false); + setAlias(''); + setSearch(''); + setModelPickerOpen(false); + generatedAliasRef.current = ''; + }; + + const closeEditor = () => { + if (busyAlias) return; + setEditorOpen(false); + resetEditor(); + }; + + const addAlias = () => { + resetEditor(); + setError(''); + setNotice(''); + 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(''); + setError(''); + setNotice(''); + setEditorOpen(true); + }; + 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); @@ -456,16 +514,46 @@ export function ThinkingAliasesPage() { {!error && notice ?
{notice}
: null} -
-
+
+
+

{t('app.nav.thinkingAliases')}

+
+
+ {entries.length} + +
+
+ + {editorOpen ? ( +
event.currentTarget === event.target && closeEditor()}> +
{ if (event.key === 'Escape') closeEditor(); }} + >
-

{t('aliases.create.title')}

+

{t(editingEntry ? 'common.edit' : 'aliases.create.title')}

{t('aliases.create.description')}

+
+
@@ -497,7 +585,7 @@ export function ThinkingAliasesPage() { setSelectedSourceId(''); setEffort(''); setFastEnabled(false); - setAlias(''); + if (!editingEntry) setAlias(''); generatedAliasRef.current = ''; } }} @@ -505,7 +593,7 @@ export function ThinkingAliasesPage() { placeholder={loading ? t('aliases.loadingModels') : t('aliases.searchModel')} autoComplete="off" spellCheck={false} - disabled={loading} + disabled={loading || Boolean(busyAlias)} /> {!modelPickerOpen && selectedSource ? ( @@ -662,7 +750,12 @@ export function ThinkingAliasesPage() { {selectedSource?.model || t('aliases.notSelected')} {alias || t('aliases.enterAlias')}
+
+
+ +
+
+ ) : null}
-
-
-

{t('aliases.createdList.title')}

- {t('aliases.createdList.description')} -
- {entries.length} -
-
{loading ? (
{t('aliases.loadingConfig')}
@@ -716,10 +804,14 @@ export function ThinkingAliasesPage() { {entry.serviceTier ? ( {t('speedAliases.fast.title')} ) : null} +
- ); } diff --git a/src/styles.css b/src/styles.css index a5725332..4677826c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -4782,6 +4782,85 @@ textarea:focus-visible { overflow: visible; } +.thinking-alias-dialog { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + width: min(640px, calc(100vw - 32px)); + height: auto; + min-height: 0; + max-height: min(780px, calc(100vh - 40px)); + padding: 0; + gap: 0; + overflow: hidden; + border-radius: 12px; +} + +.thinking-alias-dialog .thinking-alias-panel-heading { + padding: 18px 20px 16px; +} + +.thinking-alias-dialog .thinking-alias-panel-heading > div { + flex: 1; +} + +.thinking-alias-dialog .thinking-alias-panel-heading > .icon-button { + margin-left: auto; +} + +.thinking-alias-dialog-body { + display: grid; + gap: 18px; + min-height: 0; + padding: 18px 20px; + overflow-y: auto; + scrollbar-gutter: stable; +} + +.thinking-alias-dialog .thinking-alias-field > label { + display: block; + height: auto; + grid-template-rows: none; +} + +.thinking-alias-dialog .thinking-field-heading > strong { + flex: none; +} + +.thinking-alias-dialog .thinking-field-heading { + display: grid; + justify-content: stretch; + gap: 3px; +} + +.thinking-alias-dialog .thinking-field-heading > span { + min-width: 0; + line-height: var(--line-height-body); + text-overflow: initial; + white-space: normal; +} + +.thinking-alias-dialog .thinking-fast-option { + display: flex; + grid-template-rows: none; +} + +.thinking-alias-dialog-actions { + display: flex; + gap: 10px; + padding: 14px 20px 18px; + border-top: 1px solid var(--theme-e3e1db); + background: var(--theme-fffdf8); +} + +.thinking-alias-dialog-actions > button { + width: auto; + flex: 1; +} + +.thinking-alias-dialog-actions .thinking-alias-create { + margin-top: 0; +} + .thinking-alias-section-divider { height: 1px; flex: none; @@ -5204,8 +5283,7 @@ textarea:focus-visible { .thinking-alias-list-panel { display: grid; - grid-template-rows: 64px minmax(0, 1fr); - gap: 12px; + grid-template-rows: minmax(0, 1fr); padding: 16px; } @@ -5345,25 +5423,6 @@ textarea:focus-visible { color: var(--theme-805a24); } -@media (min-width: 1081px) { - .thinking-alias-list-panel .thinking-alias-row { - grid-template-columns: minmax(0, 1fr) auto 34px; - min-height: 94px; - } - - .thinking-alias-list-panel .thinking-alias-route { - grid-column: 1 / -1; - } - - .thinking-alias-list-panel .thinking-alias-badges { - grid-column: 2; - } - - .thinking-alias-list-panel .thinking-alias-row > .icon-button { - grid-column: 3; - } -} - .agent-workbench { display: grid; grid-template-columns: 230px minmax(0, 1fr); @@ -13700,3 +13759,23 @@ body.table-col-resizing * { flex: 1; } } + +.thinking-alias-page > .management-header { + align-items: center; + flex-direction: row; +} + +.thinking-alias-page > .management-header .management-heading-actions { + width: auto; + flex-wrap: nowrap; +} + +.thinking-alias-page > .management-header .muted-summary { + width: auto; +} + +.thinking-alias-page > .management-header .management-heading-actions button { + width: 42px; + flex: 0 0 42px; + padding: 0; +}