fix(models): support atomic preset renames

This commit is contained in:
Xubin Ren
2026-08-16 11:50:56 +08:00
parent 3dc38f6396
commit c15e6f2a37
12 changed files with 203 additions and 49 deletions
@@ -136,6 +136,7 @@ export function SettingsPage({
modelMigrationSaving,
modelPresetBeforeCreateRef,
modelPresetCreating,
modelPresetEditingName,
modelPresetPendingDelete,
nanobotFeatureAction,
nanobotFeatureConfirm,
@@ -185,6 +186,7 @@ export function SettingsPage({
setMcpOAuthCallbackError,
setMcpOAuthCallbackUrl,
setModelPresetCreating,
setModelPresetEditingName,
setModelPresetPendingDelete,
setNanobotFeatureConfirm,
setNanobotFeatures,
@@ -240,6 +242,7 @@ export function SettingsPage({
token={token}
form={form}
setForm={setForm}
editingPresetName={modelPresetEditingName}
settings={settings}
dirty={modelDirty}
creating={modelPresetCreating}
@@ -256,8 +259,9 @@ export function SettingsPage({
onMigrate={handleMigrateModelConfigurations}
onBeginCreate={beginModelPresetCreation}
onCancelCreate={cancelModelPresetCreation}
onSelectConfiguration={() => {
onSelectConfiguration={(name) => {
setModelPresetCreating(false);
setModelPresetEditingName(name);
modelPresetBeforeCreateRef.current = null;
}}
onDeleteConfiguration={setModelPresetPendingDelete}
@@ -159,6 +159,7 @@ export function ModelsSettings({
token,
form,
setForm,
editingPresetName,
settings,
dirty,
creating,
@@ -181,6 +182,7 @@ export function ModelsSettings({
token: string;
form: AgentSettingsDraft;
setForm: Dispatch<SetStateAction<AgentSettingsDraft>>;
editingPresetName: string;
settings: SettingsPayload;
dirty: boolean;
creating: boolean;
@@ -197,7 +199,7 @@ export function ModelsSettings({
onMigrate: () => void;
onBeginCreate: () => void;
onCancelCreate: () => void;
onSelectConfiguration: () => void;
onSelectConfiguration: (name: string) => void;
onDeleteConfiguration: (preset: SettingsPayload["model_presets"][number]) => void;
}) {
const { t } = useTranslation();
@@ -230,7 +232,7 @@ export function ModelsSettings({
preset,
})),
];
const selectedPreset = namedPresetsByName.get(form.modelPreset) ?? null;
const selectedPreset = namedPresetsByName.get(editingPresetName) ?? null;
const activeEditorRowKey =
editorRowKey ??
presetRows.find((row) => row.name === selectedPreset?.name)?.key ??
@@ -263,7 +265,7 @@ export function ModelsSettings({
const modelFieldsMissing =
!form.model.trim() ||
!form.provider.trim() ||
(creating && !form.modelPreset.trim()) ||
!form.modelPreset.trim() ||
form.maxTokens <= 0 ||
form.temperature < 0 ||
form.temperature > 2;
@@ -277,7 +279,7 @@ export function ModelsSettings({
) => {
const toggleCurrentPreset =
!creating && selectedPreset?.name === preset.name && activeEditorRowKey === rowKey;
onSelectConfiguration();
onSelectConfiguration(preset.name);
if (toggleCurrentPreset) {
setEditorOpen((open) => !open);
return;
@@ -348,28 +350,21 @@ export function ModelsSettings({
) : null}
<SettingsRow
title={tx("settings.models.presetName", "Preset name")}
description={
creating
? tx(
"settings.models.presetNameHelp",
"Used everywhere, including /model commands. Names must be unique.",
)
: undefined
}
>
{creating ? (
<Input
autoFocus
value={form.modelPreset}
placeholder={tx("settings.models.presetNamePlaceholder", "Fast writing")}
onChange={(event) =>
setForm((prev) => ({ ...prev, modelPreset: event.target.value }))
}
className="h-8 w-[min(280px,70vw)] rounded-full text-[13px]"
/>
) : (
<span className="text-[13px] font-medium text-foreground">{form.modelPreset}</span>
description={tx(
"settings.models.presetNameHelp",
"Used everywhere, including /model commands. Names must be unique.",
)}
>
<Input
autoFocus={creating}
aria-label={tx("settings.models.presetName", "Preset name")}
value={form.modelPreset}
placeholder={tx("settings.models.presetNamePlaceholder", "Fast writing")}
onChange={(event) =>
setForm((prev) => ({ ...prev, modelPreset: event.target.value }))
}
className="h-8 w-[min(280px,70vw)] rounded-full text-[13px]"
/>
</SettingsRow>
<SettingsRow title={t("settings.rows.provider")}>
<ProviderPicker
@@ -90,6 +90,7 @@ export function useModelSettingsActions({
modelMigrationSaving,
modelPresetBeforeCreateRef,
modelPresetCreating,
modelPresetEditingName,
modelPresetPendingDelete,
providerForms,
providerOAuthCompleting,
@@ -105,6 +106,7 @@ export function useModelSettingsActions({
setModelConfigurationSaving,
setModelMigrationSaving,
setModelPresetCreating,
setModelPresetEditingName,
setModelPresetPendingDelete,
setProviderForms,
setProviderOAuthCompleting,
@@ -158,6 +160,7 @@ export function useModelSettingsActions({
applyPayload(payload);
if (createdPreset) {
setForm(agentDraftFromPayload(payload, createdPreset));
setModelPresetEditingName(createdPreset);
}
let finalPayload = payload;
@@ -168,6 +171,7 @@ export function useModelSettingsActions({
}
if (createdPreset) {
setForm(agentDraftFromPayload(finalPayload, createdPreset));
setModelPresetEditingName(createdPreset);
}
modelPresetBeforeCreateRef.current = null;
onModelNameChange(finalPayload.agent.model || null);
@@ -182,14 +186,16 @@ export function useModelSettingsActions({
if (!modelDirty) return;
const selectedPreset = settings.model_presets.find(
(preset) => !preset.is_default && preset.name === form.modelPreset,
(preset) => !preset.is_default && preset.name === modelPresetEditingName,
);
if (!selectedPreset) return;
const nextName = form.modelPreset.trim();
const reasoningEffort = form.reasoningEffort || null;
setSaving(true);
try {
const payload = await updateModelConfiguration(client, {
name: selectedPreset.name,
newName: nextName !== selectedPreset.name ? nextName : undefined,
model: form.model !== selectedPreset.model ? form.model : undefined,
provider: form.provider !== selectedPreset.provider ? form.provider : undefined,
maxTokens:
@@ -205,7 +211,8 @@ export function useModelSettingsActions({
reasoningEffort !== selectedPreset.reasoning_effort ? reasoningEffort : undefined,
});
applyPayload(payload);
setForm(agentDraftFromPayload(payload, selectedPreset.name));
setForm(agentDraftFromPayload(payload, nextName));
setModelPresetEditingName(nextName);
onModelNameChange(payload.agent.model || null);
setError(null);
} catch (err) {
@@ -227,7 +234,7 @@ export function useModelSettingsActions({
configuredModelProviderOptions.find((option) => option.name === currentProvider)?.name ??
configuredModelProviderOptions[0]?.name ??
"";
modelPresetBeforeCreateRef.current = form.modelPreset;
modelPresetBeforeCreateRef.current = modelPresetEditingName;
setForm((prev) => ({
...prev,
modelPreset: "",
@@ -248,6 +255,7 @@ export function useModelSettingsActions({
const previousPreset = modelPresetBeforeCreateRef.current;
setModelPresetCreating(false);
setForm(agentDraftFromPayload(settings, previousPreset ?? undefined));
setModelPresetEditingName(previousPreset ?? agentDraftFromPayload(settings).modelPreset);
modelPresetBeforeCreateRef.current = null;
};
@@ -9,6 +9,9 @@ import type { ProviderForm } from "@/components/settings/models/ProviderSettings
import type { ProviderOAuthAuthorizationRequired, SettingsPayload } from "@/lib/types";
export function useModelSettingsState(initialSettings: SettingsPayload | null) {
const initialForm = initialSettings
? agentDraftFromPayload(initialSettings)
: DEFAULT_AGENT_SETTINGS_DRAFT;
const [saving, setSaving] = useState(false);
const [modelPresetCreating, setModelPresetCreating] = useState(false);
const [modelConfigurationSaving, setModelConfigurationSaving] = useState(false);
@@ -28,8 +31,9 @@ export function useModelSettingsState(initialSettings: SettingsPayload | null) {
const [providerForms, setProviderForms] = useState<Record<string, ProviderForm>>({});
const [visibleProviderKeys, setVisibleProviderKeys] = useState<Record<string, boolean>>({});
const [editingProviderKeys, setEditingProviderKeys] = useState<Record<string, boolean>>({});
const [form, setForm] = useState<AgentSettingsDraft>(() =>
initialSettings ? agentDraftFromPayload(initialSettings) : DEFAULT_AGENT_SETTINGS_DRAFT,
const [form, setForm] = useState<AgentSettingsDraft>(initialForm);
const [modelPresetEditingName, setModelPresetEditingName] = useState(
initialForm.modelPreset,
);
const [modelCallOrder, setModelCallOrder] = useState<string[]>(
() => initialSettings?.model_call_order ?? [],
@@ -45,6 +49,7 @@ export function useModelSettingsState(initialSettings: SettingsPayload | null) {
modelMigrationSaving,
modelPresetBeforeCreateRef,
modelPresetCreating,
modelPresetEditingName,
modelPresetPendingDelete,
providerForms,
providerOAuthCompleting,
@@ -62,6 +67,7 @@ export function useModelSettingsState(initialSettings: SettingsPayload | null) {
setModelConfigurationSaving,
setModelMigrationSaving,
setModelPresetCreating,
setModelPresetEditingName,
setModelPresetPendingDelete,
setProviderForms,
setProviderOAuthCompleting,
@@ -93,10 +93,12 @@ export function useSettingsController({
const {
editingProviderKeys, expandedProvider, form, modelCallOrder, modelCallOrderSaving,
modelConfigurationSaving, modelMigrationSaving, modelPresetBeforeCreateRef,
modelPresetCreating, modelPresetPendingDelete, providerForms, providerOAuthCompleting,
modelPresetCreating, modelPresetEditingName, modelPresetPendingDelete,
providerForms, providerOAuthCompleting,
providerOAuthDialogError, providerOAuthFlow, providerOAuthFlowRef, providerOAuthResponse,
providerSaving, saving, setForm,
setModelCallOrder, setModelPresetCreating, setModelPresetPendingDelete,
setModelCallOrder, setModelPresetCreating, setModelPresetEditingName,
setModelPresetPendingDelete,
setProviderForms, setProviderOAuthCompleting, setProviderOAuthDialogError,
setProviderOAuthFlow, setProviderOAuthResponse, visibleProviderKeys,
} = modelState;
@@ -147,7 +149,9 @@ export function useSettingsController({
) => {
setSettings(payload);
if (!options.preserveAgentForm) {
setForm(agentDraftFromPayload(payload));
const nextForm = agentDraftFromPayload(payload);
setForm(nextForm);
setModelPresetEditingName(nextForm.modelPreset);
setModelPresetCreating(false);
}
setModelCallOrder(payload.model_call_order ?? []);
@@ -252,10 +256,11 @@ export function useSettingsController({
const modelDirty = useMemo(() => {
if (!settings) return false;
const selectedPreset = settings.model_presets.find(
(preset) => !preset.is_default && preset.name === form.modelPreset,
(preset) => !preset.is_default && preset.name === modelPresetEditingName,
);
if (!selectedPreset) return false;
return (
form.modelPreset.trim() !== selectedPreset.name ||
form.model !== selectedPreset.model ||
form.provider !== selectedPreset.provider ||
form.maxTokens !== selectedPreset.max_tokens ||
@@ -263,7 +268,7 @@ export function useSettingsController({
form.temperature !== selectedPreset.temperature ||
form.reasoningEffort !== (selectedPreset.reasoning_effort ?? "")
);
}, [form, settings]);
}, [form, modelPresetEditingName, settings]);
const imageGenerationDirty = useMemo(() => {
if (!settings) return false;
@@ -532,6 +537,7 @@ export function useSettingsController({
modelMigrationSaving,
modelPresetBeforeCreateRef,
modelPresetCreating,
modelPresetEditingName,
modelPresetPendingDelete,
nanobotFeatureAction,
nanobotFeatureConfirm,
@@ -581,6 +587,7 @@ export function useSettingsController({
setMcpOAuthCallbackError,
setMcpOAuthCallbackUrl,
setModelPresetCreating,
setModelPresetEditingName,
setModelPresetPendingDelete,
setNanobotFeatureConfirm,
setNanobotFeatures,
+1
View File
@@ -927,6 +927,7 @@ export async function updateModelConfiguration(
"settings.model_configuration.update",
{
name: configuration.name,
...(configuration.newName !== undefined ? { new_name: configuration.newName } : {}),
...(configuration.provider !== undefined ? { provider: configuration.provider } : {}),
...(configuration.model !== undefined ? { model: configuration.model } : {}),
...modelGenerationSettingsPayload(configuration),
+1
View File
@@ -1097,6 +1097,7 @@ export interface ModelConfigurationCreate {
export interface ModelConfigurationUpdate {
name: string;
newName?: string;
provider?: string;
model?: string;
maxTokens?: number;
+2
View File
@@ -466,6 +466,7 @@ describe("webui API helpers", () => {
it("serializes model configuration updates", async () => {
await updateModelConfiguration(mutationTransport, {
name: "codex",
newName: "Codex",
provider: "openai_codex",
model: "openai-codex/gpt-5.5",
maxTokens: 8192,
@@ -478,6 +479,7 @@ describe("webui API helpers", () => {
"settings.model_configuration.update",
{
name: "codex",
new_name: "Codex",
provider: "openai_codex",
model: "openai-codex/gpt-5.5",
max_tokens: 8192,
+39 -2
View File
@@ -106,10 +106,45 @@ describe("Settings models", () => {
const editor = screen.getByTestId("model-preset-editor");
expect(within(editor).getByText("Preset name")).toBeInTheDocument();
expect(within(editor).getByText("openai")).toBeInTheDocument();
expect(within(editor).getByRole("textbox", { name: "Preset name" })).toHaveValue(
"openai",
);
expect(within(editor).queryByText("minimax")).not.toBeInTheDocument();
});
it("renames an existing preset without losing the editor selection", async () => {
const payload = settingsPayload();
const renamedPayload: SettingsPayload = {
...payload,
agent: { ...payload.agent, model_preset: "Codex" },
model_presets: payload.model_presets.map((preset) => ({
...preset,
name: "Codex",
label: "Codex",
})),
model_call_order: ["Codex"],
};
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {})));
requestMutationMock.mockResolvedValueOnce(renamedPayload);
renderSettingsView({ initialSection: "models", initialSettings: payload });
await togglePresetEditor();
const nameInput = screen.getByRole("textbox", { name: "Preset name" });
fireEvent.change(nameInput, { target: { value: "Codex" } });
fireEvent.click(screen.getByRole("button", { name: "Save preset" }));
await waitFor(() => {
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.model_configuration.update",
{ name: "primary", new_name: "Codex" },
20_000,
);
});
expect(await screen.findByTestId("model-call-order-row-Codex")).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Preset name" })).toHaveValue("Codex");
});
it("keeps generation parameters collapsed until advanced options are opened", async () => {
vi.stubGlobal(
"fetch",
@@ -187,7 +222,9 @@ describe("Settings models", () => {
"lg:max-w-6xl",
"rounded-floating",
);
expect(within(editor).getByText("primary")).toBeInTheDocument();
expect(within(editor).getByRole("textbox", { name: "Preset name" })).toHaveValue(
"primary",
);
const deleteButton = within(editor).getByRole("button", { name: "Delete" });
expect(deleteButton).toBeDisabled();
expect(deleteButton).toHaveAttribute("aria-describedby", "model-preset-delete-hint");