feat(aliases): support editing model aliases

This commit is contained in:
FakeTrader authored and lzt404 committed 2026-09-11 16:25:14 +08:00
1 parent 5a05ad83fa
commit ee223424af
7 files changed
+319 -52

No files matched your search

+38 -10
View File
@@ -499,6 +499,7 @@ pub(crate) async fn create_thinking_alias(
alias: String,
effort: String,
fast: Option<bool>,
original_alias: Option<String>,
) -> Result<Vec<ThinkingAliasEntry>, String> {
let config = gui_config_state.snapshot()?;
let source_id = source_id.trim().to_string();
@@ -513,6 +514,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;
@@ -523,12 +528,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::<Vec<_>>();
let source = (candidates.len() == 1)
.then(|| candidates[0].clone())
.ok_or_else(|| {
"原模型已不在内核当前可用模型中,或其配置来源已经变化,请刷新后重新选择".to_string()
})?;
@@ -551,13 +577,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::<serde_norway::Value>(&content)
let document = serde_norway::from_str::<serde_norway::Value>(&working_content)
.map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?;
let root = document
.as_mapping()
@@ -566,7 +594,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)
}
+40
View File
@@ -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<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,
+29
View File
@@ -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());
}
+1 -1
View File
@@ -435,7 +435,7 @@ export const en: Record<MessageKey, string> = {
'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',
+1 -1
View File
@@ -434,7 +434,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 请求层级',
+110 -19
View File
@@ -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<AliasListEntry | null>(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<ThinkingAliasEntry[]>('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 ? <div className="management-alert success">{notice}</div> : null}
</div>
<div className="thinking-alias-workbench">
<section className="panel thinking-alias-editor-panel">
<header className="management-header">
<div>
<h1>{t('app.nav.thinkingAliases')}</h1>
</div>
<div className="management-heading-actions">
<span className="muted-summary">{entries.length}</span>
<button
type="button"
className="primary-button compact-button"
onClick={addAlias}
disabled={loading || Boolean(busyAlias)}
title={t('aliases.create')}
aria-label={t('aliases.create')}
>
<Plus size={18} aria-hidden="true" />
</button>
</div>
</header>
{editorOpen ? (
<div className="config-dialog-backdrop" onMouseDown={(event) => event.currentTarget === event.target && closeEditor()}>
<section
className="panel management-dialog thinking-alias-editor-panel thinking-alias-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="thinking-alias-editor-title"
onKeyDown={(event) => { if (event.key === 'Escape') closeEditor(); }}
>
<div className="thinking-alias-panel-heading">
<span><GitFork size={18} /></span>
<div>
<h2>{t('aliases.create.title')}</h2>
<h2 id="thinking-alias-editor-title">{t(editingEntry ? 'common.edit' : 'aliases.create.title')}</h2>
<p>{t('aliases.create.description')}</p>
</div>
<button type="button" className="icon-button quiet" onClick={closeEditor} disabled={Boolean(busyAlias)} title={t('common.close')} aria-label={t('common.close')}>
<X size={18} />
</button>
</div>
<div className="thinking-alias-dialog-body">
<div className="thinking-alias-field thinking-model-field">
<label htmlFor="thinking-model-search">{t('aliases.originalModel')}</label>
<div className="thinking-model-picker" ref={modelPickerRef}>
@@ -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 ? (
<span className="thinking-source-kind">
@@ -662,7 +750,12 @@ export function ThinkingAliasesPage() {
<span>{selectedSource?.model || t('aliases.notSelected')} <ArrowRight size={13} /> {alias || t('aliases.enterAlias')}</span>
</div>
</div>
</div>
<div className="thinking-alias-dialog-actions">
<button type="button" className="secondary-button" disabled={Boolean(busyAlias)} onClick={closeEditor}>
{t('common.cancel')}
</button>
<button
type="button"
className="primary-button thinking-alias-create"
@@ -672,19 +765,14 @@ export function ThinkingAliasesPage() {
{busyAction === 'create'
? <LoaderCircle size={16} className="spin" />
: fastEnabled && !normalizedEffort ? <Zap size={16} /> : <GitFork size={16} />}
{busyAction === 'create' ? t('aliases.creating') : t('aliases.create')}
{busyAction === 'create' ? t(editingEntry ? 'common.saving' : 'aliases.creating') : t(editingEntry ? 'common.save' : 'aliases.create')}
</button>
</div>
</section>
</div>
) : null}
<section className="panel thinking-alias-list-panel">
<div className="thinking-alias-list-heading">
<div>
<h2>{t('aliases.createdList.title')}</h2>
<span>{t('aliases.createdList.description')}</span>
</div>
<strong>{entries.length}</strong>
</div>
<div className="thinking-alias-list">
{loading ? (
<div className="management-loading"><LoaderCircle size={20} className="spin" />{t('aliases.loadingConfig')}</div>
@@ -716,10 +804,14 @@ export function ThinkingAliasesPage() {
{entry.serviceTier ? (
<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')}>
<Pencil size={15} />
</button>
</div>
<button
type="button"
className="icon-button danger"
className="icon-button quiet danger"
onClick={() => void deleteAlias(entry)}
disabled={Boolean(busyAlias)}
title={t('aliases.delete', { alias: entry.alias })}
@@ -732,7 +824,6 @@ export function ThinkingAliasesPage() {
))}
</div>
</section>
</div>
</section>
);
}
+100 -21
View File
@@ -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);
@@ -13741,3 +13800,23 @@ body.table-col-resizing * {
.agent-save-bar { flex-wrap: wrap; gap: 12px; }
.agent-save-actions { margin-left: auto; }
}
.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;
}