From a956fe1d60b1ec5ee3e2780d1b921c812e9bd78a Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:16:05 +0800 Subject: [PATCH] fix(models): show inline preset name conflicts --- .../src/components/settings/SettingsPage.tsx | 5 ++ .../settings/models/ModelsSettings.tsx | 55 +++++++++++++++---- .../models/useModelSettingsActions.ts | 47 +++++++++++++++- .../settings/models/useModelSettingsState.ts | 3 + .../settings/useSettingsController.ts | 6 +- webui/src/globals.css | 15 +++++ webui/src/i18n/locales/en/common.json | 1 + webui/src/i18n/locales/es/common.json | 1 + webui/src/i18n/locales/fr/common.json | 1 + webui/src/i18n/locales/id/common.json | 1 + webui/src/i18n/locales/ja/common.json | 1 + webui/src/i18n/locales/ko/common.json | 1 + webui/src/i18n/locales/pt-BR/common.json | 1 + webui/src/i18n/locales/vi/common.json | 1 + webui/src/i18n/locales/zh-CN/common.json | 1 + webui/src/i18n/locales/zh-TW/common.json | 1 + webui/src/tests/settings-models.test.tsx | 54 ++++++++++++++++++ 17 files changed, 180 insertions(+), 15 deletions(-) diff --git a/webui/src/components/settings/SettingsPage.tsx b/webui/src/components/settings/SettingsPage.tsx index a7840e2b5..ed76dcd2c 100644 --- a/webui/src/components/settings/SettingsPage.tsx +++ b/webui/src/components/settings/SettingsPage.tsx @@ -137,6 +137,7 @@ export function SettingsPage({ modelPresetBeforeCreateRef, modelPresetCreating, modelPresetEditingName, + modelPresetNameError, modelPresetPendingDelete, nanobotFeatureAction, nanobotFeatureConfirm, @@ -187,6 +188,7 @@ export function SettingsPage({ setMcpOAuthCallbackUrl, setModelPresetCreating, setModelPresetEditingName, + setModelPresetNameError, setModelPresetPendingDelete, setNanobotFeatureConfirm, setNanobotFeatures, @@ -243,6 +245,7 @@ export function SettingsPage({ form={form} setForm={setForm} editingPresetName={modelPresetEditingName} + presetNameError={modelPresetNameError} settings={settings} dirty={modelDirty} creating={modelPresetCreating} @@ -259,9 +262,11 @@ export function SettingsPage({ onMigrate={handleMigrateModelConfigurations} onBeginCreate={beginModelPresetCreation} onCancelCreate={cancelModelPresetCreation} + onClearPresetNameError={() => setModelPresetNameError(null)} onSelectConfiguration={(name) => { setModelPresetCreating(false); setModelPresetEditingName(name); + setModelPresetNameError(null); modelPresetBeforeCreateRef.current = null; }} onDeleteConfiguration={setModelPresetPendingDelete} diff --git a/webui/src/components/settings/models/ModelsSettings.tsx b/webui/src/components/settings/models/ModelsSettings.tsx index 962d76579..41438991b 100644 --- a/webui/src/components/settings/models/ModelsSettings.tsx +++ b/webui/src/components/settings/models/ModelsSettings.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, type Dispatch, type SetStateAction } from "react"; +import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react"; import { ChevronDown, ChevronRight, @@ -160,6 +160,7 @@ export function ModelsSettings({ form, setForm, editingPresetName, + presetNameError, settings, dirty, creating, @@ -176,6 +177,7 @@ export function ModelsSettings({ onMigrate, onBeginCreate, onCancelCreate, + onClearPresetNameError, onSelectConfiguration, onDeleteConfiguration, }: { @@ -183,6 +185,7 @@ export function ModelsSettings({ form: AgentSettingsDraft; setForm: Dispatch>; editingPresetName: string; + presetNameError: string | null; settings: SettingsPayload; dirty: boolean; creating: boolean; @@ -199,6 +202,7 @@ export function ModelsSettings({ onMigrate: () => void; onBeginCreate: () => void; onCancelCreate: () => void; + onClearPresetNameError: () => void; onSelectConfiguration: (name: string) => void; onDeleteConfiguration: (preset: SettingsPayload["model_presets"][number]) => void; }) { @@ -206,10 +210,15 @@ export function ModelsSettings({ const tx = (key: string, fallback: string, values?: Record) => t(key, { defaultValue: fallback, ...(values ?? {}) }); const [editorOpen, setEditorOpen] = useState(false); + const presetNameInputRef = useRef(null); const [editorRowKey, setEditorRowKey] = useState(null); const [advancedOpen, setAdvancedOpen] = useState(false); const [draggedCallOrderIndex, setDraggedCallOrderIndex] = useState(null); const [dragOverCallOrderIndex, setDragOverCallOrderIndex] = useState(null); + + useEffect(() => { + if (presetNameError) presetNameInputRef.current?.focus(); + }, [presetNameError]); const namedPresets = settings.model_presets.filter((preset) => !preset.is_default); const namedPresetsByName = new Map(namedPresets.map((preset) => [preset.name, preset])); const unorderedPresets = namedPresets.filter((preset) => !callOrder.includes(preset.name)); @@ -355,16 +364,40 @@ export function ModelsSettings({ "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]" - /> +
+ { + onClearPresetNameError(); + setForm((prev) => ({ ...prev, modelPreset: event.target.value })); + }} + className={cn( + "h-8 rounded-full text-[13px]", + presetNameError && + "border-destructive/70 focus-visible:border-destructive focus-visible:ring-destructive/25", + )} + /> + {presetNameError ? ( + + ) : null} +
{ + const normalized = name.toLowerCase(); + return settings?.model_presets.some( + (preset) => + !preset.is_default && + preset.name !== currentName && + preset.name.toLowerCase() === normalized, + ) ?? false; + }; + + const showPresetNameConflict = () => { + setModelPresetNameError( + t("settings.models.presetNameDuplicate", { + defaultValue: "A preset with this name already exists.", + }), + ); + setError(null); + }; + + const handlePresetSaveError = (reason: unknown) => { + if (reason instanceof ApiError && reason.status === 409) { + showPresetNameConflict(); + return; + } + setError((reason as Error).message); + }; + const saveModelSettings = async () => { if ( !settings || @@ -144,6 +173,11 @@ export function useModelSettingsActions({ ) { return; } + if (presetNameConflict(name)) { + showPresetNameConflict(); + return; + } + setModelPresetNameError(null); setModelConfigurationSaving(true); try { const payload = await createModelConfiguration(client, { @@ -175,9 +209,10 @@ export function useModelSettingsActions({ } modelPresetBeforeCreateRef.current = null; onModelNameChange(finalPayload.agent.model || null); + setModelPresetNameError(null); setError(null); } catch (err) { - setError((err as Error).message); + handlePresetSaveError(err); } finally { setModelConfigurationSaving(false); } @@ -190,6 +225,11 @@ export function useModelSettingsActions({ ); if (!selectedPreset) return; const nextName = form.modelPreset.trim(); + if (presetNameConflict(nextName, selectedPreset.name)) { + showPresetNameConflict(); + return; + } + setModelPresetNameError(null); const reasoningEffort = form.reasoningEffort || null; setSaving(true); try { @@ -214,9 +254,10 @@ export function useModelSettingsActions({ setForm(agentDraftFromPayload(payload, nextName)); setModelPresetEditingName(nextName); onModelNameChange(payload.agent.model || null); + setModelPresetNameError(null); setError(null); } catch (err) { - setError((err as Error).message); + handlePresetSaveError(err); } finally { setSaving(false); } @@ -235,6 +276,7 @@ export function useModelSettingsActions({ configuredModelProviderOptions[0]?.name ?? ""; modelPresetBeforeCreateRef.current = modelPresetEditingName; + setModelPresetNameError(null); setForm((prev) => ({ ...prev, modelPreset: "", @@ -254,6 +296,7 @@ export function useModelSettingsActions({ if (!settings || modelConfigurationSaving) return; const previousPreset = modelPresetBeforeCreateRef.current; setModelPresetCreating(false); + setModelPresetNameError(null); 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 c373ca550..8ce7c0814 100644 --- a/webui/src/components/settings/models/useModelSettingsState.ts +++ b/webui/src/components/settings/models/useModelSettingsState.ts @@ -14,6 +14,7 @@ export function useModelSettingsState(initialSettings: SettingsPayload | null) { : DEFAULT_AGENT_SETTINGS_DRAFT; const [saving, setSaving] = useState(false); const [modelPresetCreating, setModelPresetCreating] = useState(false); + const [modelPresetNameError, setModelPresetNameError] = useState(null); const [modelConfigurationSaving, setModelConfigurationSaving] = useState(false); const [modelCallOrderSaving, setModelCallOrderSaving] = useState(false); const [modelMigrationSaving, setModelMigrationSaving] = useState(false); @@ -50,6 +51,7 @@ export function useModelSettingsState(initialSettings: SettingsPayload | null) { modelPresetBeforeCreateRef, modelPresetCreating, modelPresetEditingName, + modelPresetNameError, modelPresetPendingDelete, providerForms, providerOAuthCompleting, @@ -68,6 +70,7 @@ export function useModelSettingsState(initialSettings: SettingsPayload | null) { setModelMigrationSaving, setModelPresetCreating, setModelPresetEditingName, + setModelPresetNameError, setModelPresetPendingDelete, setProviderForms, setProviderOAuthCompleting, diff --git a/webui/src/components/settings/useSettingsController.ts b/webui/src/components/settings/useSettingsController.ts index d88f71c50..326f380e9 100644 --- a/webui/src/components/settings/useSettingsController.ts +++ b/webui/src/components/settings/useSettingsController.ts @@ -93,11 +93,11 @@ export function useSettingsController({ const { editingProviderKeys, expandedProvider, form, modelCallOrder, modelCallOrderSaving, modelConfigurationSaving, modelMigrationSaving, modelPresetBeforeCreateRef, - modelPresetCreating, modelPresetEditingName, modelPresetPendingDelete, + modelPresetCreating, modelPresetEditingName, modelPresetNameError, modelPresetPendingDelete, providerForms, providerOAuthCompleting, providerOAuthDialogError, providerOAuthFlow, providerOAuthFlowRef, providerOAuthResponse, providerSaving, saving, setForm, - setModelCallOrder, setModelPresetCreating, setModelPresetEditingName, + setModelCallOrder, setModelPresetCreating, setModelPresetEditingName, setModelPresetNameError, setModelPresetPendingDelete, setProviderForms, setProviderOAuthCompleting, setProviderOAuthDialogError, setProviderOAuthFlow, setProviderOAuthResponse, visibleProviderKeys, @@ -538,6 +538,7 @@ export function useSettingsController({ modelPresetBeforeCreateRef, modelPresetCreating, modelPresetEditingName, + modelPresetNameError, modelPresetPendingDelete, nanobotFeatureAction, nanobotFeatureConfirm, @@ -588,6 +589,7 @@ export function useSettingsController({ setMcpOAuthCallbackUrl, setModelPresetCreating, setModelPresetEditingName, + setModelPresetNameError, setModelPresetPendingDelete, setNanobotFeatureConfirm, setNanobotFeatures, diff --git a/webui/src/globals.css b/webui/src/globals.css index 3a4ccda76..91d20e702 100644 --- a/webui/src/globals.css +++ b/webui/src/globals.css @@ -696,6 +696,21 @@ transform: scale(0.98); } +@keyframes preset-name-shake { + 0%, + 100% { + transform: translateX(0); + } + + 35% { + transform: translateX(-2px); + } + + 70% { + transform: translateX(2px); + } +} + @keyframes composer-model-pill-viewport-enter { from { transform: scale(0.9074); diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index 1f6cbbbb3..128a5d04c 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -144,6 +144,7 @@ "editPreset": "Edit preset", "presetName": "Preset name", "presetNameHelp": "Used in the interface and /model commands. Names must be unique.", + "presetNameDuplicate": "A preset with this name already exists.", "presetNamePlaceholder": "Fast writing", "advancedOptions": "Advanced options", "advancedSummary": "Context {{context}} · Max {{max}} tokens", diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index d3918c68d..268d0344a 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -420,6 +420,7 @@ "editPreset": "Editar preajuste", "presetName": "Nombre del preajuste", "presetNameHelp": "Se usa en la interfaz y en /model. Los nombres deben ser únicos.", + "presetNameDuplicate": "Ya existe un preajuste con este nombre.", "presetNamePlaceholder": "Escritura rápida", "advancedOptions": "Opciones avanzadas", "advancedSummary": "Contexto {{context}} · Máx. {{max}} tokens", diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index 0dd1694ad..30225cc60 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -420,6 +420,7 @@ "editPreset": "Modifier le préréglage", "presetName": "Nom du préréglage", "presetNameHelp": "Utilisé dans l’interface et avec /model. Les noms doivent être uniques.", + "presetNameDuplicate": "Un préréglage portant ce nom existe déjà.", "presetNamePlaceholder": "Rédaction rapide", "advancedOptions": "Options avancées", "advancedSummary": "Contexte {{context}} · Max. {{max}} tokens", diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index bc7bbffdf..fe6636610 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -420,6 +420,7 @@ "editPreset": "Ubah prasetel", "presetName": "Nama prasetel", "presetNameHelp": "Digunakan di antarmuka dan perintah /model. Nama harus unik.", + "presetNameDuplicate": "Preset dengan nama ini sudah ada.", "presetNamePlaceholder": "Menulis cepat", "advancedOptions": "Opsi lanjutan", "advancedSummary": "Konteks {{context}} · Maks. {{max}} token", diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index 0eb82fae7..a28c659cb 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -420,6 +420,7 @@ "editPreset": "プリセットを編集", "presetName": "プリセット名", "presetNameHelp": "画面と /model コマンドで使用します。名前は一意である必要があります。", + "presetNameDuplicate": "同じ名前のプリセットがすでに存在します。", "presetNamePlaceholder": "高速執筆", "advancedOptions": "詳細オプション", "advancedSummary": "コンテキスト {{context}} · 最大 {{max}} トークン", diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index 45b44fb38..f3bbd31ad 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -420,6 +420,7 @@ "editPreset": "프리셋 편집", "presetName": "프리셋 이름", "presetNameHelp": "화면과 /model 명령에서 사용됩니다. 이름은 고유해야 합니다.", + "presetNameDuplicate": "같은 이름의 프리셋이 이미 있습니다.", "presetNamePlaceholder": "빠른 작성", "advancedOptions": "고급 옵션", "advancedSummary": "컨텍스트 {{context}} · 최대 {{max}} 토큰", diff --git a/webui/src/i18n/locales/pt-BR/common.json b/webui/src/i18n/locales/pt-BR/common.json index 85e01582e..f4a2d7307 100644 --- a/webui/src/i18n/locales/pt-BR/common.json +++ b/webui/src/i18n/locales/pt-BR/common.json @@ -144,6 +144,7 @@ "editPreset": "Editar predefinição", "presetName": "Nome da predefinição", "presetNameHelp": "Usado na interface e nos comandos /model. Os nomes devem ser únicos.", + "presetNameDuplicate": "Já existe uma predefinição com este nome.", "presetNamePlaceholder": "Escrita rápida", "advancedOptions": "Opções avançadas", "advancedSummary": "Contexto {{context}} · Máx. {{max}} tokens", diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index 580421319..50b2eafb7 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -420,6 +420,7 @@ "editPreset": "Sửa cấu hình đặt trước", "presetName": "Tên cấu hình đặt trước", "presetNameHelp": "Dùng trong giao diện và lệnh /model. Tên phải là duy nhất.", + "presetNameDuplicate": "Đã có một cấu hình sẵn với tên này.", "presetNamePlaceholder": "Viết nhanh", "advancedOptions": "Tùy chọn nâng cao", "advancedSummary": "Ngữ cảnh {{context}} · Tối đa {{max}} token", diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index 36275c1e2..4e334ee95 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -144,6 +144,7 @@ "editPreset": "编辑预设", "presetName": "预设名称", "presetNameHelp": "用于界面和 /model 命令;名称不能重复。", + "presetNameDuplicate": "已存在同名预设。", "presetNamePlaceholder": "快速写作", "advancedOptions": "高级选项", "advancedSummary": "上下文 {{context}} · 最大输出 {{max}} tokens", diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index 12f95a974..7d39631de 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -420,6 +420,7 @@ "editPreset": "編輯預設", "presetName": "預設名稱", "presetNameHelp": "用於介面和 /model 命令;名稱不得重複。", + "presetNameDuplicate": "已有同名預設。", "presetNamePlaceholder": "快速寫作", "advancedOptions": "進階選項", "advancedSummary": "上下文 {{context}} · 最大輸出 {{max}} tokens", diff --git a/webui/src/tests/settings-models.test.tsx b/webui/src/tests/settings-models.test.tsx index 048b43744..afd9ea75a 100644 --- a/webui/src/tests/settings-models.test.tsx +++ b/webui/src/tests/settings-models.test.tsx @@ -145,6 +145,25 @@ describe("Settings models", () => { expect(screen.getByRole("textbox", { name: "Preset name" })).toHaveValue("Codex"); }); + it("maps a server-side name conflict back to the preset name field", async () => { + const payload = settingsPayload(); + vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {}))); + requestMutationMock.mockRejectedValueOnce({ status: 409 }); + + 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" })); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "A preset with this name already exists.", + ); + expect(nameInput).toHaveAttribute("aria-invalid", "true"); + expect(nameInput).toHaveFocus(); + }); + it("keeps generation parameters collapsed until advanced options are opened", async () => { vi.stubGlobal( "fetch", @@ -578,6 +597,41 @@ describe("Settings models", () => { expect(screen.queryByRole("button", { name: "Save order" })).not.toBeInTheDocument(); }); + it("shows an inline error when a new preset name already exists", async () => { + const { payload } = settingsPayloadWithBackup(); + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: false, status: 404, json: async () => ({}) }) as Response), + ); + + renderSettingsView({ initialSection: "models", initialSettings: payload }); + + fireEvent.click(screen.getByRole("button", { name: "New model preset" })); + const nameInput = screen.getByRole("textbox", { name: "Preset name" }); + fireEvent.change(nameInput, { target: { value: "PRIMARY" } }); + await openPopover(screen.getByRole("button", { name: "Select model" })); + const modelSearch = await screen.findByRole("combobox", { + name: "Search or type model ID", + }); + fireEvent.change(modelSearch, { target: { value: "openai/gpt-4o-mini" } }); + fireEvent.keyDown(modelSearch, { key: "Enter" }); + fireEvent.click(screen.getByRole("button", { name: "Save preset" })); + + expect(requestMutationMock).not.toHaveBeenCalled(); + expect(nameInput).toHaveAttribute("aria-invalid", "true"); + expect(nameInput).toHaveAttribute("aria-describedby", "model-preset-name-error"); + expect(screen.getByRole("alert")).toHaveTextContent( + "A preset with this name already exists.", + ); + expect(nameInput.parentElement).toHaveClass( + "animate-[preset-name-shake_180ms_ease-in-out]", + ); + + fireEvent.change(nameInput, { target: { value: "Writer" } }); + expect(nameInput).toHaveAttribute("aria-invalid", "false"); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + it("converts legacy model settings into presets before editing call order", async () => { const migratedPayload = settingsPayload(); const defaultPreset = {