fix(models): show inline preset name conflicts

This commit is contained in:
Xubin Ren
2026-08-16 11:50:56 +08:00
parent c15e6f2a37
commit a956fe1d60
17 changed files with 180 additions and 15 deletions
@@ -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}
@@ -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<SetStateAction<AgentSettingsDraft>>;
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<string, unknown>) =>
t(key, { defaultValue: fallback, ...(values ?? {}) });
const [editorOpen, setEditorOpen] = useState(false);
const presetNameInputRef = useRef<HTMLInputElement>(null);
const [editorRowKey, setEditorRowKey] = useState<string | null>(null);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [draggedCallOrderIndex, setDraggedCallOrderIndex] = useState<number | null>(null);
const [dragOverCallOrderIndex, setDragOverCallOrderIndex] = useState<number | null>(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.",
)}
>
<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]"
/>
<div
className={cn(
"w-[min(280px,70vw)] motion-reduce:animate-none",
presetNameError && "animate-[preset-name-shake_180ms_ease-in-out]",
)}
>
<Input
ref={presetNameInputRef}
autoFocus={creating}
aria-label={tx("settings.models.presetName", "Preset name")}
aria-invalid={Boolean(presetNameError)}
aria-describedby={presetNameError ? "model-preset-name-error" : undefined}
value={form.modelPreset}
placeholder={tx("settings.models.presetNamePlaceholder", "Fast writing")}
onChange={(event) => {
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 ? (
<p
id="model-preset-name-error"
role="alert"
className="mt-1.5 px-1 text-[12px] leading-4 text-destructive"
>
{presetNameError}
</p>
) : null}
</div>
</SettingsRow>
<SettingsRow title={t("settings.rows.provider")}>
<ProviderPicker
@@ -15,6 +15,7 @@ import {
import type { ModelSettingsState } from "@/components/settings/models/useModelSettingsState";
import { normalizeContextWindowTokens } from "@/components/settings/shared/ModelControls";
import {
ApiError,
completeProviderOAuth,
createModelConfiguration,
createProviderSettings,
@@ -107,6 +108,7 @@ export function useModelSettingsActions({
setModelMigrationSaving,
setModelPresetCreating,
setModelPresetEditingName,
setModelPresetNameError,
setModelPresetPendingDelete,
setProviderForms,
setProviderOAuthCompleting,
@@ -119,6 +121,33 @@ export function useModelSettingsActions({
visibleProviderKeys,
} = state;
const presetNameConflict = (name: string, currentName?: string) => {
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;
@@ -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<string | null>(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,
@@ -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,