fix(webui): make preset names immediately saveable

This commit is contained in:
Xubin Ren
2026-09-02 13:04:47 +08:00
parent f180766064
commit dec4d1325f
13 changed files with 89 additions and 20 deletions
@@ -62,6 +62,26 @@ function modelPresetValue(payload: SettingsPayload): string {
);
}
function suggestedPresetName(
model: string,
presets: SettingsPayload["model_presets"],
): string {
const modelName = model.trim().split("/").filter(Boolean).at(-1) ?? "";
const base = (modelName.toLowerCase() === "default" ? "model" : modelName).slice(0, 48);
if (!base) return "";
const existing = new Set(
presets.filter((preset) => !preset.is_default).map((preset) => preset.name.toLowerCase()),
);
if (!existing.has(base.toLowerCase())) return base;
for (let index = 2; ; index += 1) {
const suffix = ` ${index}`;
const candidate = `${base.slice(0, 48 - suffix.length)}${suffix}`;
if (!existing.has(candidate.toLowerCase())) return candidate;
}
}
export const DEFAULT_AGENT_SETTINGS_DRAFT: AgentSettingsDraft = {
model: "",
provider: "",
@@ -211,6 +231,7 @@ export function ModelsSettings({
t(key, { defaultValue: fallback, ...(values ?? {}) });
const [editorOpen, setEditorOpen] = useState(false);
const presetNameInputRef = useRef<HTMLInputElement>(null);
const suggestedPresetNameRef = useRef<string | null>(null);
const [editorRowKey, setEditorRowKey] = useState<string | null>(null);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [draggedCallOrderIndex, setDraggedCallOrderIndex] = useState<number | null>(null);
@@ -219,6 +240,9 @@ export function ModelsSettings({
useEffect(() => {
if (presetNameError) presetNameInputRef.current?.focus();
}, [presetNameError]);
useEffect(() => {
if (!creating) suggestedPresetNameRef.current = null;
}, [creating]);
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));
@@ -377,8 +401,9 @@ export function ModelsSettings({
aria-invalid={Boolean(presetNameError)}
aria-describedby={presetNameError ? "model-preset-name-error" : undefined}
value={form.modelPreset}
placeholder={tx("settings.models.presetNamePlaceholder", "Fast writing")}
placeholder={tx("settings.models.presetNamePlaceholder", "e.g. Fast writing")}
onChange={(event) => {
suggestedPresetNameRef.current = null;
onClearPresetNameError();
setForm((prev) => ({ ...prev, modelPreset: event.target.value }));
}}
@@ -405,13 +430,21 @@ export function ModelsSettings({
value={providerValue}
emptyLabel={t("settings.byok.noConfiguredProviders")}
showProviderLogos={showBrandLogos}
onChange={(provider) =>
onChange={(provider) => {
const providerChanged = provider !== form.provider;
const clearSuggestedName =
creating &&
providerChanged &&
suggestedPresetNameRef.current !== null &&
form.modelPreset === suggestedPresetNameRef.current;
if (clearSuggestedName) suggestedPresetNameRef.current = null;
setForm((prev) => ({
...prev,
provider,
model: provider === prev.provider ? prev.model : "",
}))
}
modelPreset: clearSuggestedName ? "" : prev.modelPreset,
}));
}}
/>
</SettingsRow>
{selectedProviderNeedsSignIn ? (
@@ -445,7 +478,20 @@ export function ModelsSettings({
provider={form.provider}
value={form.model}
showProviderLogos={showBrandLogos}
onChange={(model) => setForm((prev) => ({ ...prev, model }))}
onChange={(model) => {
const canSuggestName =
creating &&
(!form.modelPreset.trim() || form.modelPreset === suggestedPresetNameRef.current);
const suggestion = canSuggestName
? suggestedPresetName(model, settings.model_presets)
: "";
if (canSuggestName) suggestedPresetNameRef.current = suggestion;
setForm((prev) => ({
...prev,
model,
modelPreset: canSuggestName ? suggestion : prev.modelPreset,
}));
}}
/>
</SettingsRow>
<button
+1 -1
View File
@@ -145,7 +145,7 @@
"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",
"presetNamePlaceholder": "e.g. Fast writing",
"advancedOptions": "Advanced options",
"advancedSummary": "Context {{context}} · Max {{max}} tokens",
"maxTokens": "Max output tokens",
+1 -1
View File
@@ -423,7 +423,7 @@
"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",
"presetNamePlaceholder": "p. ej., Escritura rápida",
"advancedOptions": "Opciones avanzadas",
"advancedSummary": "Contexto {{context}} · Máx. {{max}} tokens",
"maxTokens": "Máx. tokens de salida",
+1 -1
View File
@@ -423,7 +423,7 @@
"presetName": "Nom du préréglage",
"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": "p. ex. Rédaction rapide",
"advancedOptions": "Options avancées",
"advancedSummary": "Contexte {{context}} · Max. {{max}} tokens",
"maxTokens": "Tokens de sortie max.",
+1 -1
View File
@@ -423,7 +423,7 @@
"presetName": "Nama prasetel",
"presetNameHelp": "Digunakan di antarmuka dan perintah /model. Nama harus unik.",
"presetNameDuplicate": "Preset dengan nama ini sudah ada.",
"presetNamePlaceholder": "Menulis cepat",
"presetNamePlaceholder": "mis. Menulis cepat",
"advancedOptions": "Opsi lanjutan",
"advancedSummary": "Konteks {{context}} · Maks. {{max}} token",
"maxTokens": "Maks. token keluaran",
+1 -1
View File
@@ -423,7 +423,7 @@
"presetName": "プリセット名",
"presetNameHelp": "画面と /model コマンドで使用します。名前は一意である必要があります。",
"presetNameDuplicate": "同じ名前のプリセットがすでに存在します。",
"presetNamePlaceholder": "高速執筆",
"presetNamePlaceholder": "例:高速執筆",
"advancedOptions": "詳細オプション",
"advancedSummary": "コンテキスト {{context}} · 最大 {{max}} トークン",
"maxTokens": "最大出力トークン",
+1 -1
View File
@@ -423,7 +423,7 @@
"presetName": "프리셋 이름",
"presetNameHelp": "화면과 /model 명령에서 사용됩니다. 이름은 고유해야 합니다.",
"presetNameDuplicate": "같은 이름의 프리셋이 이미 있습니다.",
"presetNamePlaceholder": "빠른 작성",
"presetNamePlaceholder": "예: 빠른 작성",
"advancedOptions": "고급 옵션",
"advancedSummary": "컨텍스트 {{context}} · 최대 {{max}} 토큰",
"maxTokens": "최대 출력 토큰",
+1 -1
View File
@@ -145,7 +145,7 @@
"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",
"presetNamePlaceholder": "ex.: Escrita rápida",
"advancedOptions": "Opções avançadas",
"advancedSummary": "Contexto {{context}} · Máx. {{max}} tokens",
"maxTokens": "Máx. de tokens de saída",
+1 -1
View File
@@ -423,7 +423,7 @@
"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",
"presetNamePlaceholder": "ví dụ: Viết nhanh",
"advancedOptions": "Tùy chọn nâng cao",
"advancedSummary": "Ngữ cảnh {{context}} · Tối đa {{max}} token",
"maxTokens": "Token đầu ra tối đa",
+1 -1
View File
@@ -145,7 +145,7 @@
"presetName": "预设名称",
"presetNameHelp": "用于界面和 /model 命令;名称不能重复。",
"presetNameDuplicate": "已存在同名预设。",
"presetNamePlaceholder": "快速写作",
"presetNamePlaceholder": "例如:快速写作",
"advancedOptions": "高级选项",
"advancedSummary": "上下文 {{context}} · 最大输出 {{max}} tokens",
"maxTokens": "最大输出 tokens",
+1 -1
View File
@@ -423,7 +423,7 @@
"presetName": "預設名稱",
"presetNameHelp": "用於介面和 /model 命令;名稱不得重複。",
"presetNameDuplicate": "已有同名預設。",
"presetNamePlaceholder": "快速寫作",
"presetNamePlaceholder": "例如:快速寫作",
"advancedOptions": "進階選項",
"advancedSummary": "上下文 {{context}} · 最大輸出 {{max}} tokens",
"maxTokens": "最大輸出 tokens",
+1 -1
View File
@@ -2553,7 +2553,7 @@ describe("App layout", () => {
expect(screen.queryByText("Model call order")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "New model preset" }));
expect(screen.queryByRole("dialog", { name: "New model preset" })).not.toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText("Fast writing"), {
fireEvent.change(screen.getByRole("textbox", { name: "Preset name" }), {
target: { value: "Fast writing" },
});
expect(
+27 -4
View File
@@ -608,7 +608,7 @@ describe("Settings models", () => {
expect(
screen.queryByText("Complete the preset before saving."),
).not.toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText("Fast writing"), {
fireEvent.change(screen.getByRole("textbox", { name: "Preset name" }), {
target: { value: "Writer" },
});
await openPopover(screen.getByRole("button", { name: "Select model" }));
@@ -1421,11 +1421,13 @@ describe("Settings models", () => {
});
it("creates presets in the inline editor and can cancel without opening a dialog", async () => {
const payload = settingsPayload();
payload.providers = [{ name: "openai", label: "OpenAI", configured: true }];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
@@ -1441,7 +1443,7 @@ describe("Settings models", () => {
fireEvent.click(await screen.findByRole("button", { name: "New model preset" }));
expect(screen.queryByRole("dialog", { name: "New model preset" })).not.toBeInTheDocument();
expect(screen.getByPlaceholderText("Fast writing")).toHaveValue("");
expect(screen.getByRole("textbox", { name: "Preset name" })).toHaveValue("");
expect(screen.queryByText("Temperature")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /Advanced options/ }));
expect(screen.getByText("Temperature")).toBeInTheDocument();
@@ -1452,7 +1454,28 @@ describe("Settings models", () => {
expect(document.body.style.pointerEvents).not.toBe("none");
fireEvent.click(screen.getByRole("button", { name: "New model preset" }));
expect(await screen.findByPlaceholderText("Fast writing")).toHaveValue("");
const nameInput = await screen.findByRole("textbox", { name: "Preset name" });
expect(nameInput).toHaveValue("");
expect(nameInput).toHaveAttribute("placeholder", "e.g. Fast writing");
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" });
expect(nameInput).toHaveValue("gpt-4o-mini");
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
fireEvent.change(nameInput, { target: { value: "Writer" } });
await openPopover(screen.getByRole("button", { name: /openai\/gpt-4o-mini/ }));
const nextModelSearch = await screen.findByRole("combobox", {
name: "Search or type model ID",
});
fireEvent.change(nextModelSearch, { target: { value: "openai/gpt-4.1-mini" } });
fireEvent.keyDown(nextModelSearch, { key: "Enter" });
expect(nameInput).toHaveValue("Writer");
});
it("loads provider models and lets users choose one without typing the id manually", async () => {