mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-02 17:22:06 +03:00
fix(webui): make preset names immediately saveable
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -423,7 +423,7 @@
|
||||
"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",
|
||||
"presetNamePlaceholder": "p. ex. Rédaction rapide",
|
||||
"advancedOptions": "Options avancées",
|
||||
"advancedSummary": "Contexte {{context}} · Max. {{max}} tokens",
|
||||
"maxTokens": "Tokens de sortie max.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -423,7 +423,7 @@
|
||||
"presetName": "プリセット名",
|
||||
"presetNameHelp": "画面と /model コマンドで使用します。名前は一意である必要があります。",
|
||||
"presetNameDuplicate": "同じ名前のプリセットがすでに存在します。",
|
||||
"presetNamePlaceholder": "高速執筆",
|
||||
"presetNamePlaceholder": "例:高速執筆",
|
||||
"advancedOptions": "詳細オプション",
|
||||
"advancedSummary": "コンテキスト {{context}} · 最大 {{max}} トークン",
|
||||
"maxTokens": "最大出力トークン",
|
||||
|
||||
@@ -423,7 +423,7 @@
|
||||
"presetName": "프리셋 이름",
|
||||
"presetNameHelp": "화면과 /model 명령에서 사용됩니다. 이름은 고유해야 합니다.",
|
||||
"presetNameDuplicate": "같은 이름의 프리셋이 이미 있습니다.",
|
||||
"presetNamePlaceholder": "빠른 작성",
|
||||
"presetNamePlaceholder": "예: 빠른 작성",
|
||||
"advancedOptions": "고급 옵션",
|
||||
"advancedSummary": "컨텍스트 {{context}} · 최대 {{max}} 토큰",
|
||||
"maxTokens": "최대 출력 토큰",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
"presetName": "预设名称",
|
||||
"presetNameHelp": "用于界面和 /model 命令;名称不能重复。",
|
||||
"presetNameDuplicate": "已存在同名预设。",
|
||||
"presetNamePlaceholder": "快速写作",
|
||||
"presetNamePlaceholder": "例如:快速写作",
|
||||
"advancedOptions": "高级选项",
|
||||
"advancedSummary": "上下文 {{context}} · 最大输出 {{max}} tokens",
|
||||
"maxTokens": "最大输出 tokens",
|
||||
|
||||
@@ -423,7 +423,7 @@
|
||||
"presetName": "預設名稱",
|
||||
"presetNameHelp": "用於介面和 /model 命令;名稱不得重複。",
|
||||
"presetNameDuplicate": "已有同名預設。",
|
||||
"presetNamePlaceholder": "快速寫作",
|
||||
"presetNamePlaceholder": "例如:快速寫作",
|
||||
"advancedOptions": "進階選項",
|
||||
"advancedSummary": "上下文 {{context}} · 最大輸出 {{max}} tokens",
|
||||
"maxTokens": "最大輸出 tokens",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user