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, modelPresetBeforeCreateRef,
modelPresetCreating, modelPresetCreating,
modelPresetEditingName, modelPresetEditingName,
modelPresetNameError,
modelPresetPendingDelete, modelPresetPendingDelete,
nanobotFeatureAction, nanobotFeatureAction,
nanobotFeatureConfirm, nanobotFeatureConfirm,
@@ -187,6 +188,7 @@ export function SettingsPage({
setMcpOAuthCallbackUrl, setMcpOAuthCallbackUrl,
setModelPresetCreating, setModelPresetCreating,
setModelPresetEditingName, setModelPresetEditingName,
setModelPresetNameError,
setModelPresetPendingDelete, setModelPresetPendingDelete,
setNanobotFeatureConfirm, setNanobotFeatureConfirm,
setNanobotFeatures, setNanobotFeatures,
@@ -243,6 +245,7 @@ export function SettingsPage({
form={form} form={form}
setForm={setForm} setForm={setForm}
editingPresetName={modelPresetEditingName} editingPresetName={modelPresetEditingName}
presetNameError={modelPresetNameError}
settings={settings} settings={settings}
dirty={modelDirty} dirty={modelDirty}
creating={modelPresetCreating} creating={modelPresetCreating}
@@ -259,9 +262,11 @@ export function SettingsPage({
onMigrate={handleMigrateModelConfigurations} onMigrate={handleMigrateModelConfigurations}
onBeginCreate={beginModelPresetCreation} onBeginCreate={beginModelPresetCreation}
onCancelCreate={cancelModelPresetCreation} onCancelCreate={cancelModelPresetCreation}
onClearPresetNameError={() => setModelPresetNameError(null)}
onSelectConfiguration={(name) => { onSelectConfiguration={(name) => {
setModelPresetCreating(false); setModelPresetCreating(false);
setModelPresetEditingName(name); setModelPresetEditingName(name);
setModelPresetNameError(null);
modelPresetBeforeCreateRef.current = null; modelPresetBeforeCreateRef.current = null;
}} }}
onDeleteConfiguration={setModelPresetPendingDelete} 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 { import {
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
@@ -160,6 +160,7 @@ export function ModelsSettings({
form, form,
setForm, setForm,
editingPresetName, editingPresetName,
presetNameError,
settings, settings,
dirty, dirty,
creating, creating,
@@ -176,6 +177,7 @@ export function ModelsSettings({
onMigrate, onMigrate,
onBeginCreate, onBeginCreate,
onCancelCreate, onCancelCreate,
onClearPresetNameError,
onSelectConfiguration, onSelectConfiguration,
onDeleteConfiguration, onDeleteConfiguration,
}: { }: {
@@ -183,6 +185,7 @@ export function ModelsSettings({
form: AgentSettingsDraft; form: AgentSettingsDraft;
setForm: Dispatch<SetStateAction<AgentSettingsDraft>>; setForm: Dispatch<SetStateAction<AgentSettingsDraft>>;
editingPresetName: string; editingPresetName: string;
presetNameError: string | null;
settings: SettingsPayload; settings: SettingsPayload;
dirty: boolean; dirty: boolean;
creating: boolean; creating: boolean;
@@ -199,6 +202,7 @@ export function ModelsSettings({
onMigrate: () => void; onMigrate: () => void;
onBeginCreate: () => void; onBeginCreate: () => void;
onCancelCreate: () => void; onCancelCreate: () => void;
onClearPresetNameError: () => void;
onSelectConfiguration: (name: string) => void; onSelectConfiguration: (name: string) => void;
onDeleteConfiguration: (preset: SettingsPayload["model_presets"][number]) => 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>) => const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
t(key, { defaultValue: fallback, ...(values ?? {}) }); t(key, { defaultValue: fallback, ...(values ?? {}) });
const [editorOpen, setEditorOpen] = useState(false); const [editorOpen, setEditorOpen] = useState(false);
const presetNameInputRef = useRef<HTMLInputElement>(null);
const [editorRowKey, setEditorRowKey] = useState<string | null>(null); const [editorRowKey, setEditorRowKey] = useState<string | null>(null);
const [advancedOpen, setAdvancedOpen] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false);
const [draggedCallOrderIndex, setDraggedCallOrderIndex] = useState<number | null>(null); const [draggedCallOrderIndex, setDraggedCallOrderIndex] = useState<number | null>(null);
const [dragOverCallOrderIndex, setDragOverCallOrderIndex] = 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 namedPresets = settings.model_presets.filter((preset) => !preset.is_default);
const namedPresetsByName = new Map(namedPresets.map((preset) => [preset.name, preset])); const namedPresetsByName = new Map(namedPresets.map((preset) => [preset.name, preset]));
const unorderedPresets = namedPresets.filter((preset) => !callOrder.includes(preset.name)); 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.", "Used everywhere, including /model commands. Names must be unique.",
)} )}
> >
<Input <div
autoFocus={creating} className={cn(
aria-label={tx("settings.models.presetName", "Preset name")} "w-[min(280px,70vw)] motion-reduce:animate-none",
value={form.modelPreset} presetNameError && "animate-[preset-name-shake_180ms_ease-in-out]",
placeholder={tx("settings.models.presetNamePlaceholder", "Fast writing")} )}
onChange={(event) => >
setForm((prev) => ({ ...prev, modelPreset: event.target.value })) <Input
} ref={presetNameInputRef}
className="h-8 w-[min(280px,70vw)] rounded-full text-[13px]" 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>
<SettingsRow title={t("settings.rows.provider")}> <SettingsRow title={t("settings.rows.provider")}>
<ProviderPicker <ProviderPicker
@@ -15,6 +15,7 @@ import {
import type { ModelSettingsState } from "@/components/settings/models/useModelSettingsState"; import type { ModelSettingsState } from "@/components/settings/models/useModelSettingsState";
import { normalizeContextWindowTokens } from "@/components/settings/shared/ModelControls"; import { normalizeContextWindowTokens } from "@/components/settings/shared/ModelControls";
import { import {
ApiError,
completeProviderOAuth, completeProviderOAuth,
createModelConfiguration, createModelConfiguration,
createProviderSettings, createProviderSettings,
@@ -107,6 +108,7 @@ export function useModelSettingsActions({
setModelMigrationSaving, setModelMigrationSaving,
setModelPresetCreating, setModelPresetCreating,
setModelPresetEditingName, setModelPresetEditingName,
setModelPresetNameError,
setModelPresetPendingDelete, setModelPresetPendingDelete,
setProviderForms, setProviderForms,
setProviderOAuthCompleting, setProviderOAuthCompleting,
@@ -119,6 +121,33 @@ export function useModelSettingsActions({
visibleProviderKeys, visibleProviderKeys,
} = state; } = 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 () => { const saveModelSettings = async () => {
if ( if (
!settings || !settings ||
@@ -144,6 +173,11 @@ export function useModelSettingsActions({
) { ) {
return; return;
} }
if (presetNameConflict(name)) {
showPresetNameConflict();
return;
}
setModelPresetNameError(null);
setModelConfigurationSaving(true); setModelConfigurationSaving(true);
try { try {
const payload = await createModelConfiguration(client, { const payload = await createModelConfiguration(client, {
@@ -175,9 +209,10 @@ export function useModelSettingsActions({
} }
modelPresetBeforeCreateRef.current = null; modelPresetBeforeCreateRef.current = null;
onModelNameChange(finalPayload.agent.model || null); onModelNameChange(finalPayload.agent.model || null);
setModelPresetNameError(null);
setError(null); setError(null);
} catch (err) { } catch (err) {
setError((err as Error).message); handlePresetSaveError(err);
} finally { } finally {
setModelConfigurationSaving(false); setModelConfigurationSaving(false);
} }
@@ -190,6 +225,11 @@ export function useModelSettingsActions({
); );
if (!selectedPreset) return; if (!selectedPreset) return;
const nextName = form.modelPreset.trim(); const nextName = form.modelPreset.trim();
if (presetNameConflict(nextName, selectedPreset.name)) {
showPresetNameConflict();
return;
}
setModelPresetNameError(null);
const reasoningEffort = form.reasoningEffort || null; const reasoningEffort = form.reasoningEffort || null;
setSaving(true); setSaving(true);
try { try {
@@ -214,9 +254,10 @@ export function useModelSettingsActions({
setForm(agentDraftFromPayload(payload, nextName)); setForm(agentDraftFromPayload(payload, nextName));
setModelPresetEditingName(nextName); setModelPresetEditingName(nextName);
onModelNameChange(payload.agent.model || null); onModelNameChange(payload.agent.model || null);
setModelPresetNameError(null);
setError(null); setError(null);
} catch (err) { } catch (err) {
setError((err as Error).message); handlePresetSaveError(err);
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -235,6 +276,7 @@ export function useModelSettingsActions({
configuredModelProviderOptions[0]?.name ?? configuredModelProviderOptions[0]?.name ??
""; "";
modelPresetBeforeCreateRef.current = modelPresetEditingName; modelPresetBeforeCreateRef.current = modelPresetEditingName;
setModelPresetNameError(null);
setForm((prev) => ({ setForm((prev) => ({
...prev, ...prev,
modelPreset: "", modelPreset: "",
@@ -254,6 +296,7 @@ export function useModelSettingsActions({
if (!settings || modelConfigurationSaving) return; if (!settings || modelConfigurationSaving) return;
const previousPreset = modelPresetBeforeCreateRef.current; const previousPreset = modelPresetBeforeCreateRef.current;
setModelPresetCreating(false); setModelPresetCreating(false);
setModelPresetNameError(null);
setForm(agentDraftFromPayload(settings, previousPreset ?? undefined)); setForm(agentDraftFromPayload(settings, previousPreset ?? undefined));
setModelPresetEditingName(previousPreset ?? agentDraftFromPayload(settings).modelPreset); setModelPresetEditingName(previousPreset ?? agentDraftFromPayload(settings).modelPreset);
modelPresetBeforeCreateRef.current = null; modelPresetBeforeCreateRef.current = null;
@@ -14,6 +14,7 @@ export function useModelSettingsState(initialSettings: SettingsPayload | null) {
: DEFAULT_AGENT_SETTINGS_DRAFT; : DEFAULT_AGENT_SETTINGS_DRAFT;
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [modelPresetCreating, setModelPresetCreating] = useState(false); const [modelPresetCreating, setModelPresetCreating] = useState(false);
const [modelPresetNameError, setModelPresetNameError] = useState<string | null>(null);
const [modelConfigurationSaving, setModelConfigurationSaving] = useState(false); const [modelConfigurationSaving, setModelConfigurationSaving] = useState(false);
const [modelCallOrderSaving, setModelCallOrderSaving] = useState(false); const [modelCallOrderSaving, setModelCallOrderSaving] = useState(false);
const [modelMigrationSaving, setModelMigrationSaving] = useState(false); const [modelMigrationSaving, setModelMigrationSaving] = useState(false);
@@ -50,6 +51,7 @@ export function useModelSettingsState(initialSettings: SettingsPayload | null) {
modelPresetBeforeCreateRef, modelPresetBeforeCreateRef,
modelPresetCreating, modelPresetCreating,
modelPresetEditingName, modelPresetEditingName,
modelPresetNameError,
modelPresetPendingDelete, modelPresetPendingDelete,
providerForms, providerForms,
providerOAuthCompleting, providerOAuthCompleting,
@@ -68,6 +70,7 @@ export function useModelSettingsState(initialSettings: SettingsPayload | null) {
setModelMigrationSaving, setModelMigrationSaving,
setModelPresetCreating, setModelPresetCreating,
setModelPresetEditingName, setModelPresetEditingName,
setModelPresetNameError,
setModelPresetPendingDelete, setModelPresetPendingDelete,
setProviderForms, setProviderForms,
setProviderOAuthCompleting, setProviderOAuthCompleting,
@@ -93,11 +93,11 @@ export function useSettingsController({
const { const {
editingProviderKeys, expandedProvider, form, modelCallOrder, modelCallOrderSaving, editingProviderKeys, expandedProvider, form, modelCallOrder, modelCallOrderSaving,
modelConfigurationSaving, modelMigrationSaving, modelPresetBeforeCreateRef, modelConfigurationSaving, modelMigrationSaving, modelPresetBeforeCreateRef,
modelPresetCreating, modelPresetEditingName, modelPresetPendingDelete, modelPresetCreating, modelPresetEditingName, modelPresetNameError, modelPresetPendingDelete,
providerForms, providerOAuthCompleting, providerForms, providerOAuthCompleting,
providerOAuthDialogError, providerOAuthFlow, providerOAuthFlowRef, providerOAuthResponse, providerOAuthDialogError, providerOAuthFlow, providerOAuthFlowRef, providerOAuthResponse,
providerSaving, saving, setForm, providerSaving, saving, setForm,
setModelCallOrder, setModelPresetCreating, setModelPresetEditingName, setModelCallOrder, setModelPresetCreating, setModelPresetEditingName, setModelPresetNameError,
setModelPresetPendingDelete, setModelPresetPendingDelete,
setProviderForms, setProviderOAuthCompleting, setProviderOAuthDialogError, setProviderForms, setProviderOAuthCompleting, setProviderOAuthDialogError,
setProviderOAuthFlow, setProviderOAuthResponse, visibleProviderKeys, setProviderOAuthFlow, setProviderOAuthResponse, visibleProviderKeys,
@@ -538,6 +538,7 @@ export function useSettingsController({
modelPresetBeforeCreateRef, modelPresetBeforeCreateRef,
modelPresetCreating, modelPresetCreating,
modelPresetEditingName, modelPresetEditingName,
modelPresetNameError,
modelPresetPendingDelete, modelPresetPendingDelete,
nanobotFeatureAction, nanobotFeatureAction,
nanobotFeatureConfirm, nanobotFeatureConfirm,
@@ -588,6 +589,7 @@ export function useSettingsController({
setMcpOAuthCallbackUrl, setMcpOAuthCallbackUrl,
setModelPresetCreating, setModelPresetCreating,
setModelPresetEditingName, setModelPresetEditingName,
setModelPresetNameError,
setModelPresetPendingDelete, setModelPresetPendingDelete,
setNanobotFeatureConfirm, setNanobotFeatureConfirm,
setNanobotFeatures, setNanobotFeatures,
+15
View File
@@ -696,6 +696,21 @@
transform: scale(0.98); 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 { @keyframes composer-model-pill-viewport-enter {
from { from {
transform: scale(0.9074); transform: scale(0.9074);
+1
View File
@@ -144,6 +144,7 @@
"editPreset": "Edit preset", "editPreset": "Edit preset",
"presetName": "Preset name", "presetName": "Preset name",
"presetNameHelp": "Used in the interface and /model commands. Names must be unique.", "presetNameHelp": "Used in the interface and /model commands. Names must be unique.",
"presetNameDuplicate": "A preset with this name already exists.",
"presetNamePlaceholder": "Fast writing", "presetNamePlaceholder": "Fast writing",
"advancedOptions": "Advanced options", "advancedOptions": "Advanced options",
"advancedSummary": "Context {{context}} · Max {{max}} tokens", "advancedSummary": "Context {{context}} · Max {{max}} tokens",
+1
View File
@@ -420,6 +420,7 @@
"editPreset": "Editar preajuste", "editPreset": "Editar preajuste",
"presetName": "Nombre del preajuste", "presetName": "Nombre del preajuste",
"presetNameHelp": "Se usa en la interfaz y en /model. Los nombres deben ser únicos.", "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", "presetNamePlaceholder": "Escritura rápida",
"advancedOptions": "Opciones avanzadas", "advancedOptions": "Opciones avanzadas",
"advancedSummary": "Contexto {{context}} · Máx. {{max}} tokens", "advancedSummary": "Contexto {{context}} · Máx. {{max}} tokens",
+1
View File
@@ -420,6 +420,7 @@
"editPreset": "Modifier le préréglage", "editPreset": "Modifier le préréglage",
"presetName": "Nom du préréglage", "presetName": "Nom du préréglage",
"presetNameHelp": "Utilisé dans linterface et avec /model. Les noms doivent être uniques.", "presetNameHelp": "Utilisé dans linterface et avec /model. Les noms doivent être uniques.",
"presetNameDuplicate": "Un préréglage portant ce nom existe déjà.",
"presetNamePlaceholder": "Rédaction rapide", "presetNamePlaceholder": "Rédaction rapide",
"advancedOptions": "Options avancées", "advancedOptions": "Options avancées",
"advancedSummary": "Contexte {{context}} · Max. {{max}} tokens", "advancedSummary": "Contexte {{context}} · Max. {{max}} tokens",
+1
View File
@@ -420,6 +420,7 @@
"editPreset": "Ubah prasetel", "editPreset": "Ubah prasetel",
"presetName": "Nama prasetel", "presetName": "Nama prasetel",
"presetNameHelp": "Digunakan di antarmuka dan perintah /model. Nama harus unik.", "presetNameHelp": "Digunakan di antarmuka dan perintah /model. Nama harus unik.",
"presetNameDuplicate": "Preset dengan nama ini sudah ada.",
"presetNamePlaceholder": "Menulis cepat", "presetNamePlaceholder": "Menulis cepat",
"advancedOptions": "Opsi lanjutan", "advancedOptions": "Opsi lanjutan",
"advancedSummary": "Konteks {{context}} · Maks. {{max}} token", "advancedSummary": "Konteks {{context}} · Maks. {{max}} token",
+1
View File
@@ -420,6 +420,7 @@
"editPreset": "プリセットを編集", "editPreset": "プリセットを編集",
"presetName": "プリセット名", "presetName": "プリセット名",
"presetNameHelp": "画面と /model コマンドで使用します。名前は一意である必要があります。", "presetNameHelp": "画面と /model コマンドで使用します。名前は一意である必要があります。",
"presetNameDuplicate": "同じ名前のプリセットがすでに存在します。",
"presetNamePlaceholder": "高速執筆", "presetNamePlaceholder": "高速執筆",
"advancedOptions": "詳細オプション", "advancedOptions": "詳細オプション",
"advancedSummary": "コンテキスト {{context}} · 最大 {{max}} トークン", "advancedSummary": "コンテキスト {{context}} · 最大 {{max}} トークン",
+1
View File
@@ -420,6 +420,7 @@
"editPreset": "프리셋 편집", "editPreset": "프리셋 편집",
"presetName": "프리셋 이름", "presetName": "프리셋 이름",
"presetNameHelp": "화면과 /model 명령에서 사용됩니다. 이름은 고유해야 합니다.", "presetNameHelp": "화면과 /model 명령에서 사용됩니다. 이름은 고유해야 합니다.",
"presetNameDuplicate": "같은 이름의 프리셋이 이미 있습니다.",
"presetNamePlaceholder": "빠른 작성", "presetNamePlaceholder": "빠른 작성",
"advancedOptions": "고급 옵션", "advancedOptions": "고급 옵션",
"advancedSummary": "컨텍스트 {{context}} · 최대 {{max}} 토큰", "advancedSummary": "컨텍스트 {{context}} · 최대 {{max}} 토큰",
+1
View File
@@ -144,6 +144,7 @@
"editPreset": "Editar predefinição", "editPreset": "Editar predefinição",
"presetName": "Nome da predefinição", "presetName": "Nome da predefinição",
"presetNameHelp": "Usado na interface e nos comandos /model. Os nomes devem ser únicos.", "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", "presetNamePlaceholder": "Escrita rápida",
"advancedOptions": "Opções avançadas", "advancedOptions": "Opções avançadas",
"advancedSummary": "Contexto {{context}} · Máx. {{max}} tokens", "advancedSummary": "Contexto {{context}} · Máx. {{max}} tokens",
+1
View File
@@ -420,6 +420,7 @@
"editPreset": "Sửa cấu hình đặt trước", "editPreset": "Sửa cấu hình đặt trước",
"presetName": "Tên 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.", "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", "presetNamePlaceholder": "Viết nhanh",
"advancedOptions": "Tùy chọn nâng cao", "advancedOptions": "Tùy chọn nâng cao",
"advancedSummary": "Ngữ cảnh {{context}} · Tối đa {{max}} token", "advancedSummary": "Ngữ cảnh {{context}} · Tối đa {{max}} token",
+1
View File
@@ -144,6 +144,7 @@
"editPreset": "编辑预设", "editPreset": "编辑预设",
"presetName": "预设名称", "presetName": "预设名称",
"presetNameHelp": "用于界面和 /model 命令;名称不能重复。", "presetNameHelp": "用于界面和 /model 命令;名称不能重复。",
"presetNameDuplicate": "已存在同名预设。",
"presetNamePlaceholder": "快速写作", "presetNamePlaceholder": "快速写作",
"advancedOptions": "高级选项", "advancedOptions": "高级选项",
"advancedSummary": "上下文 {{context}} · 最大输出 {{max}} tokens", "advancedSummary": "上下文 {{context}} · 最大输出 {{max}} tokens",
+1
View File
@@ -420,6 +420,7 @@
"editPreset": "編輯預設", "editPreset": "編輯預設",
"presetName": "預設名稱", "presetName": "預設名稱",
"presetNameHelp": "用於介面和 /model 命令;名稱不得重複。", "presetNameHelp": "用於介面和 /model 命令;名稱不得重複。",
"presetNameDuplicate": "已有同名預設。",
"presetNamePlaceholder": "快速寫作", "presetNamePlaceholder": "快速寫作",
"advancedOptions": "進階選項", "advancedOptions": "進階選項",
"advancedSummary": "上下文 {{context}} · 最大輸出 {{max}} tokens", "advancedSummary": "上下文 {{context}} · 最大輸出 {{max}} tokens",
+54
View File
@@ -145,6 +145,25 @@ describe("Settings models", () => {
expect(screen.getByRole("textbox", { name: "Preset name" })).toHaveValue("Codex"); 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<Response>(() => {})));
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 () => { it("keeps generation parameters collapsed until advanced options are opened", async () => {
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",
@@ -578,6 +597,41 @@ describe("Settings models", () => {
expect(screen.queryByRole("button", { name: "Save order" })).not.toBeInTheDocument(); 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 () => { it("converts legacy model settings into presets before editing call order", async () => {
const migratedPayload = settingsPayload(); const migratedPayload = settingsPayload();
const defaultPreset = { const defaultPreset = {