From c15e6f2a37c76faf300946af8043d9ae21d1bf51 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:09:06 +0800 Subject: [PATCH] fix(models): support atomic preset renames --- .../websocket/tests/test_websocket_channel.py | 18 +++--- nanobot/webui/settings_models.py | 42 +++++++++++++- tests/webui/test_settings_api.py | 55 +++++++++++++++++++ .../src/components/settings/SettingsPage.tsx | 6 +- .../settings/models/ModelsSettings.tsx | 45 +++++++-------- .../models/useModelSettingsActions.ts | 14 ++++- .../settings/models/useModelSettingsState.ts | 10 +++- .../settings/useSettingsController.ts | 17 ++++-- webui/src/lib/api.ts | 1 + webui/src/lib/types.ts | 1 + webui/src/tests/api.test.ts | 2 + webui/src/tests/settings-models.test.tsx | 41 +++++++++++++- 12 files changed, 203 insertions(+), 49 deletions(-) diff --git a/nanobot/channels/websocket/tests/test_websocket_channel.py b/nanobot/channels/websocket/tests/test_websocket_channel.py index dd763cabb..bb967a0b0 100644 --- a/nanobot/channels/websocket/tests/test_websocket_channel.py +++ b/nanobot/channels/websocket/tests/test_websocket_channel.py @@ -3308,7 +3308,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( "settings.model_configuration.update", { "name": "fast-writing", - "label": "Codex", + "new_name": "Codex", "provider": "openai", "model": "openai/gpt-5.5", }, @@ -3320,24 +3320,24 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( updated_presets = { preset["name"]: preset for preset in updated_preset_body["model_presets"] } - assert updated_presets["fast-writing"]["label"] == "fast-writing" + assert updated_presets["Codex"]["label"] == "Codex" call_order_updated = await _webui_mutate( webui_client, "settings.model_call_order.update", - {"order": ["fast-writing", "deep"]}, + {"order": ["Codex", "deep"]}, ) assert call_order_updated.status_code == 200 call_order_body = call_order_updated.json() - assert call_order_body["agent"]["model_preset"] == "fast-writing" + assert call_order_body["agent"]["model_preset"] == "Codex" assert call_order_body["agent"]["model"] == "openai/gpt-5.5" - assert call_order_body["model_call_order"] == ["fast-writing", "deep"] + assert call_order_body["model_call_order"] == ["Codex", "deep"] duplicate_preset = await _webui_mutate( webui_client, "settings.model_configuration.create", { - "label": "Fast writing", + "name": "codex", "provider": "openai", "model": "openai/gpt-4.1-mini", }, @@ -3435,10 +3435,10 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( saved = load_config(config_path) assert saved.agents.defaults.model == "atomic_chat/test" assert saved.agents.defaults.provider == "atomic_chat" - assert saved.agents.defaults.model_preset == "fast-writing" + assert saved.agents.defaults.model_preset == "Codex" assert saved.agents.defaults.fallback_models == ["deep"] - assert saved.model_presets["fast-writing"].model == "openai/gpt-5.5" - assert saved.model_presets["fast-writing"].provider == "openai" + assert saved.model_presets["Codex"].model == "openai/gpt-5.5" + assert saved.model_presets["Codex"].provider == "openai" assert saved.agents.defaults.timezone == "Asia/Shanghai" assert saved.agents.defaults.bot_name == "nanobot" assert saved.agents.defaults.bot_icon == "🐈" diff --git a/nanobot/webui/settings_models.py b/nanobot/webui/settings_models.py index eab71e5c1..0dbc5b827 100644 --- a/nanobot/webui/settings_models.py +++ b/nanobot/webui/settings_models.py @@ -792,9 +792,40 @@ def _model_configuration_name(value: str) -> str: return name -def _model_configuration_name_exists(config: Config, name: str) -> bool: +def _model_configuration_name_exists( + config: Config, + name: str, + *, + exclude: str | None = None, +) -> bool: normalized = name.casefold() - return any(existing.casefold() == normalized for existing in config.model_presets) + return any( + existing != exclude and existing.casefold() == normalized + for existing in config.model_presets + ) + + +def _rename_model_configuration(config: Config, old_name: str, new_name: str) -> bool: + """Rename one preset and every config reference to it.""" + if old_name == new_name: + return False + if _model_configuration_name_exists(config, new_name, exclude=old_name): + raise WebUISettingsError("configuration already exists", status=409) + + config.model_presets = { + (new_name if name == old_name else name): preset + for name, preset in config.model_presets.items() + } + defaults = config.agents.defaults + if defaults.model_preset == old_name: + defaults.model_preset = new_name + defaults.fallback_models = [ + new_name if fallback == old_name else fallback + for fallback in defaults.fallback_models + ] + if defaults.dream.model_override == old_name: + defaults.dream.model_override = new_name + return True def _custom_provider_key(config: Config, display_name: str) -> str: @@ -1139,6 +1170,13 @@ def update_model_configuration( raise WebUISettingsError("unknown model configuration") changed = False + new_name_value = query_first_alias(query, "new_name", "newName") + if new_name_value is not None: + new_name = _model_configuration_name(new_name_value) + changed = _rename_model_configuration(config, name, new_name) or changed + name = new_name + preset = config.model_presets[name] + model = query_first(query, "model") if model is not None: model = model.strip() diff --git a/tests/webui/test_settings_api.py b/tests/webui/test_settings_api.py index 0e13f25a5..b4d249011 100644 --- a/tests/webui/test_settings_api.py +++ b/tests/webui/test_settings_api.py @@ -418,6 +418,61 @@ def test_update_model_configuration_edits_named_preset_without_selecting( assert saved.model_presets["codex"].model == "openai-codex/gpt-5.5" +def test_update_model_configuration_renames_preset_and_config_references( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = tmp_path / "config.json" + config = Config() + config.model_presets = { + "openai": ModelPresetConfig(model="openai/gpt-4.1"), + "backup": ModelPresetConfig(model="anthropic/claude-sonnet-4"), + } + defaults = config.agents.defaults + defaults.model_preset = "openai" + defaults.fallback_models = ["backup", "openai"] + defaults.dream.model_override = "openai" + save_config(config, config_path) + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + + payload = update_model_configuration( + {"name": ["openai"], "new_name": ["Codex"]} + ) + + assert payload["agent"]["model_preset"] == "Codex" + assert payload["model_call_order"] == ["Codex", "backup", "Codex"] + assert [row["name"] for row in payload["model_presets"]] == [ + "default", + "Codex", + "backup", + ] + saved = load_config(config_path) + assert list(saved.model_presets) == ["Codex", "backup"] + assert saved.agents.defaults.model_preset == "Codex" + assert saved.agents.defaults.fallback_models == ["backup", "Codex"] + assert saved.agents.defaults.dream.model_override == "Codex" + + +def test_update_model_configuration_rejects_duplicate_rename( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = tmp_path / "config.json" + config = Config() + config.model_presets = { + "openai": ModelPresetConfig(model="openai/gpt-4.1"), + "Codex": ModelPresetConfig(model="openai/gpt-5.5"), + } + save_config(config, config_path) + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + + with pytest.raises(WebUISettingsError) as duplicate: + update_model_configuration({"name": ["openai"], "new_name": ["codex"]}) + + assert duplicate.value.status == 409 + assert set(load_config(config_path).model_presets) == {"openai", "Codex"} + + def test_settings_payload_exposes_named_model_call_order( tmp_path, monkeypatch: pytest.MonkeyPatch, diff --git a/webui/src/components/settings/SettingsPage.tsx b/webui/src/components/settings/SettingsPage.tsx index 53e3352e8..a7840e2b5 100644 --- a/webui/src/components/settings/SettingsPage.tsx +++ b/webui/src/components/settings/SettingsPage.tsx @@ -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} diff --git a/webui/src/components/settings/models/ModelsSettings.tsx b/webui/src/components/settings/models/ModelsSettings.tsx index e5b8231dc..962d76579 100644 --- a/webui/src/components/settings/models/ModelsSettings.tsx +++ b/webui/src/components/settings/models/ModelsSettings.tsx @@ -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>; + 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} - {creating ? ( - - setForm((prev) => ({ ...prev, modelPreset: event.target.value })) - } - className="h-8 w-[min(280px,70vw)] rounded-full text-[13px]" - /> - ) : ( - {form.modelPreset} + description={tx( + "settings.models.presetNameHelp", + "Used everywhere, including /model commands. Names must be unique.", )} + > + + setForm((prev) => ({ ...prev, modelPreset: event.target.value })) + } + className="h-8 w-[min(280px,70vw)] rounded-full text-[13px]" + /> !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; }; diff --git a/webui/src/components/settings/models/useModelSettingsState.ts b/webui/src/components/settings/models/useModelSettingsState.ts index ec758618d..c373ca550 100644 --- a/webui/src/components/settings/models/useModelSettingsState.ts +++ b/webui/src/components/settings/models/useModelSettingsState.ts @@ -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>({}); const [visibleProviderKeys, setVisibleProviderKeys] = useState>({}); const [editingProviderKeys, setEditingProviderKeys] = useState>({}); - const [form, setForm] = useState(() => - initialSettings ? agentDraftFromPayload(initialSettings) : DEFAULT_AGENT_SETTINGS_DRAFT, + const [form, setForm] = useState(initialForm); + const [modelPresetEditingName, setModelPresetEditingName] = useState( + initialForm.modelPreset, ); const [modelCallOrder, setModelCallOrder] = useState( () => 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, diff --git a/webui/src/components/settings/useSettingsController.ts b/webui/src/components/settings/useSettingsController.ts index c7d0a7831..d88f71c50 100644 --- a/webui/src/components/settings/useSettingsController.ts +++ b/webui/src/components/settings/useSettingsController.ts @@ -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, diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts index 627ba7577..aaf23c6ca 100644 --- a/webui/src/lib/api.ts +++ b/webui/src/lib/api.ts @@ -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), diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 5d9822a26..56d18627c 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -1097,6 +1097,7 @@ export interface ModelConfigurationCreate { export interface ModelConfigurationUpdate { name: string; + newName?: string; provider?: string; model?: string; maxTokens?: number; diff --git a/webui/src/tests/api.test.ts b/webui/src/tests/api.test.ts index 24a1e0900..66e93dc13 100644 --- a/webui/src/tests/api.test.ts +++ b/webui/src/tests/api.test.ts @@ -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, diff --git a/webui/src/tests/settings-models.test.tsx b/webui/src/tests/settings-models.test.tsx index 6b1197253..048b43744 100644 --- a/webui/src/tests/settings-models.test.tsx +++ b/webui/src/tests/settings-models.test.tsx @@ -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(() => {}))); + 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");