feat(webui): simplify model preset settings (#5061)

This commit is contained in:
chengyongru
2026-07-24 00:55:06 +08:00
committed by GitHub
parent d993c81f08
commit aae259c790
29 changed files with 4548 additions and 1182 deletions
+94 -7
View File
@@ -4,6 +4,8 @@ import {
configureChannel,
completeProviderOAuth,
createModelConfiguration,
createProviderSettings,
deleteModelConfiguration,
deleteSession,
fetchFilePreview,
fetchFilePreviewAvailability,
@@ -26,6 +28,7 @@ import {
listSlashCommands,
loginProviderOAuth,
logoutProviderOAuth,
migrateModelConfigurations,
disableNanobotFeature,
enableNanobotFeature,
runAutomationAction,
@@ -40,6 +43,7 @@ import {
updateAutomation,
updateSidebarState,
updateImageGenerationSettings,
updateModelCallOrder,
updateModelConfiguration,
updateMcpServerTools,
updateNetworkSafetySettings,
@@ -340,10 +344,14 @@ describe("webui API helpers", () => {
label: "Fast writing",
provider: "openai",
model: "openai/gpt-4.1-mini",
maxTokens: 4096,
contextWindowTokens: 128000,
temperature: 0.4,
reasoningEffort: "high",
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/model-configurations/create?label=Fast+writing&provider=openai&model=openai%2Fgpt-4.1-mini",
"/api/settings/model-configurations/create?label=Fast+writing&provider=openai&model=openai%2Fgpt-4.1-mini&max_tokens=4096&context_window_tokens=128000&temperature=0.4&reasoning_effort=high",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
@@ -356,11 +364,45 @@ describe("webui API helpers", () => {
label: "Codex",
provider: "openai_codex",
model: "openai-codex/gpt-5.5",
maxTokens: 8192,
contextWindowTokens: 65536,
temperature: 0,
reasoningEffort: null,
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/model-configurations/update?name=codex&label=Codex&provider=openai_codex&model=openai-codex%2Fgpt-5.5&context_window_tokens=65536",
"/api/settings/model-configurations/update?name=codex&label=Codex&provider=openai_codex&model=openai-codex%2Fgpt-5.5&max_tokens=8192&context_window_tokens=65536&temperature=0&reasoning_effort=",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("serializes model preset deletion and migration", async () => {
await deleteModelConfiguration("tok", "spare");
await migrateModelConfigurations("tok");
expect(fetch).toHaveBeenNthCalledWith(
1,
"/api/settings/model-configurations/delete?name=spare",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
expect(fetch).toHaveBeenNthCalledWith(
2,
"/api/settings/model-configurations/migrate",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("serializes model call order as an ordered JSON array", async () => {
await updateModelCallOrder("tok", ["backup", "primary"]);
expect(fetch).toHaveBeenCalledWith(
"/api/settings/model-call-order/update?order=%5B%22backup%22%2C%22primary%22%5D",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
@@ -425,23 +467,68 @@ describe("webui API helpers", () => {
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/update?provider=openrouter&api_key=sk-or-test&api_base=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1",
"/api/settings/provider/update?provider=openrouter",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
headers: {
Authorization: "Bearer tok",
"X-Nanobot-Provider-Values": encodeURIComponent(JSON.stringify({
apiKey: "sk-or-test",
apiBase: "https://openrouter.ai/api/v1",
})),
},
}),
);
});
it("serializes OAuth provider proxy updates", async () => {
it("serializes OAuth provider advanced settings", async () => {
await updateProviderSettings("tok", {
provider: "xai_grok",
proxy: "http://127.0.0.1:7890",
extraBody: '{"service_tier":"priority"}',
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/update?provider=xai_grok&proxy=http%3A%2F%2F127.0.0.1%3A7890",
"/api/settings/provider/update?provider=xai_grok",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
headers: {
Authorization: "Bearer tok",
"X-Nanobot-Provider-Values": encodeURIComponent(JSON.stringify({
proxy: "http://127.0.0.1:7890",
extraBody: '{"service_tier":"priority"}',
})),
},
}),
);
});
it("serializes custom provider creation with advanced settings", async () => {
await createProviderSettings("tok", {
name: "Company Gateway",
apiKey: "sk-company",
apiBase: "https://gateway.example/v1",
extraHeaders: '{"X-Tenant":"engineering"}',
extraBody: '{"service_tier":"priority"}',
extraQuery: '{"api-version":"2026-01-01"}',
proxy: "http://127.0.0.1:7890",
thinkingStyle: "enable_thinking",
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/create",
expect.objectContaining({
headers: {
Authorization: "Bearer tok",
"X-Nanobot-Provider-Values": encodeURIComponent(JSON.stringify({
name: "Company Gateway",
apiKey: "sk-company",
apiBase: "https://gateway.example/v1",
extraHeaders: '{"X-Tenant":"engineering"}',
extraBody: '{"service_tier":"priority"}',
extraQuery: '{"api-version":"2026-01-01"}',
proxy: "http://127.0.0.1:7890",
thinkingStyle: "enable_thinking",
})),
},
}),
);
});
+73 -37
View File
@@ -74,6 +74,8 @@ function baseSettingsPayload() {
temperature: 0.1,
reasoning_effort: null,
}],
model_call_order: [],
model_call_order_editable: false,
providers: [],
web_search: {
provider: "duckduckgo",
@@ -370,7 +372,10 @@ describe("App layout", () => {
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
expect((await screen.findAllByRole("heading", { name: "Channels" })).length).toBeGreaterThan(0);
expect(
await screen.findByRole("navigation", { name: "Settings sections" }),
).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Channels" })).not.toBeInTheDocument();
expect(window.location.hash).toBe("#/settings?section=channels");
});
@@ -1403,7 +1408,7 @@ describe("App layout", () => {
provider: "auto",
resolved_provider: "openai",
has_api_key: true,
model_preset: "default",
model_preset: "primary",
max_tokens: 8192,
context_window_tokens: 65536,
temperature: 0.1,
@@ -1415,12 +1420,13 @@ describe("App layout", () => {
},
model_presets: [
{
name: "default",
label: "Default",
name: "primary",
label: "Primary",
active: true,
is_default: true,
is_default: false,
model: "openai/gpt-4o",
provider: "auto",
resolved_provider: "openai",
max_tokens: 8192,
context_window_tokens: 65536,
temperature: 0.1,
@@ -1439,6 +1445,8 @@ describe("App layout", () => {
reasoning_effort: "high",
},
],
model_call_order: ["primary", "deep"],
model_call_order_editable: true,
providers: [
{
name: "openai",
@@ -1599,7 +1607,10 @@ describe("App layout", () => {
expect(searchButton.compareDocumentPosition(appsButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" }));
expect(await screen.findByRole("heading", { name: "Overview" })).toBeInTheDocument();
expect(
await screen.findByRole("navigation", { name: "Settings sections" }),
).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Overview" })).not.toBeInTheDocument();
expect(document.title).toBe("Settings · nanobot");
expect(screen.getByTestId("overview-logo-openai")).toBeInTheDocument();
expect(screen.getByTestId("overview-logo-brave")).toBeInTheDocument();
@@ -1630,21 +1641,26 @@ describe("App layout", () => {
fireEvent.pointerDown(within(settingsNav).getByRole("button", { name: "Settings: Appearance" }));
fireEvent.click(await screen.findByRole("menuitem", { name: "Models" }));
expect(screen.queryByText("AI")).not.toBeInTheDocument();
expect(screen.getByText("Current configuration")).toBeInTheDocument();
expect(screen.queryByText("Presets")).not.toBeInTheDocument();
fireEvent.pointerDown(screen.getByRole("button", { name: "Current configuration" }));
fireEvent.click(screen.getByRole("menuitem", { name: "Add configuration" }));
const modelDialog = await screen.findByRole("dialog", { name: "New model configuration" });
expect(within(modelDialog).getByText("Save a provider and model as a one-click option.")).toBeInTheDocument();
fireEvent.change(within(modelDialog).getByPlaceholderText("Fast writing"), {
expect(screen.getByText("Model presets")).toBeInTheDocument();
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"), {
target: { value: "Fast writing" },
});
fireEvent.change(within(modelDialog).getByPlaceholderText("openai/gpt-4.1"), {
target: { value: "openai/gpt-4.1-mini" },
});
expect(within(modelDialog).getByRole("button", { name: /OpenAI/ })).toBeInTheDocument();
expect(within(modelDialog).getByRole("button", { name: "Save" })).toBeEnabled();
fireEvent.click(within(modelDialog).getByRole("button", { name: "Cancel" }));
expect(
screen
.getAllByRole("button", { name: /OpenAI/ })
.some((button) => button.getAttribute("aria-haspopup") === "menu"),
).toBe(true);
fireEvent.pointerDown(screen.getByRole("button", { name: "Select model" }));
fireEvent.click(await screen.findByText("openai/gpt-4o-mini"));
expect(screen.getByRole("button", { name: "Save preset" })).toBeEnabled();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.queryByText("Up to date.")).not.toBeInTheDocument();
fireEvent.click(
within(screen.getByTestId("model-call-order-row-primary")).getAllByRole("button")[0],
);
fireEvent.pointerDown(screen.getByRole("button", { name: /Auto/ }));
expect(screen.getAllByTestId("provider-picker-logo-openai").length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole("menuitem", { name: /Auto/ }));
@@ -1655,18 +1671,20 @@ describe("App layout", () => {
openModelPicker();
await screen.findByText("openai/gpt-4o-mini");
fireEvent.click(screen.getAllByText("openai/gpt-4o-mini")[0]);
expect(screen.getByText("Unsaved changes.").parentElement?.className).toContain(
"text-blue-600",
);
const updatedModelButtons = screen.getAllByRole("button", { name: /openai\/gpt-4o-mini/ });
fireEvent.pointerDown(updatedModelButtons[updatedModelButtons.length - 1]);
await screen.findByText("openai/gpt-4o");
fireEvent.click(screen.getAllByText("openai/gpt-4o")[0]);
expect(screen.getByText("OpenRouter")).toBeInTheDocument();
expect(screen.getByText("Ant Ling")).toBeInTheDocument();
expect(screen.queryByText("Unsaved changes.")).not.toBeInTheDocument();
expect(screen.getByText("Model providers")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Add your own model provider" })).toBeInTheDocument();
expect(screen.queryByText("OpenRouter")).not.toBeInTheDocument();
expect(screen.queryByText("Ant Ling")).not.toBeInTheDocument();
expect(
screen.queryByText(
"Bring your own provider keys. Nanobot reads these values from the current config and only configured providers can be used in model presets.",
),
).not.toBeInTheDocument();
expect(screen.queryByText("azure_openai")).not.toBeInTheDocument();
expect(screen.getByTestId("provider-logo-openai")).toBeInTheDocument();
expect(screen.getByText(/Product names, logos, and brands/)).toBeInTheDocument();
expect(screen.getAllByText("Not configured").length).toBeGreaterThan(0);
expect(screen.queryByText(/Product names, logos, and brands/)).not.toBeInTheDocument();
expect(screen.queryByText("Not configured")).not.toBeInTheDocument();
const clickProviderRow = (label: string) => {
const providerLabel = screen
.getAllByText(label)
@@ -1674,23 +1692,33 @@ describe("App layout", () => {
expect(providerLabel).toBeTruthy();
fireEvent.click(providerLabel!);
};
const chooseProvider = async (label: string) => {
fireEvent.pointerDown(
screen.getByRole("button", { name: "Add your own model provider" }),
);
fireEvent.click(await screen.findByRole("menuitem", { name: label }));
};
clickProviderRow("OpenAI");
fireEvent.click(screen.getByRole("button", { name: "Edit" }));
fireEvent.change(screen.getByPlaceholderText("Leave blank to keep the current key"), {
target: { value: "unsaved-openai-key" },
});
clickProviderRow("OpenAI");
await chooseProvider("OpenRouter");
clickProviderRow("OpenRouter");
clickProviderRow("OpenAI");
expect(screen.getByText("open••••-key")).toBeInTheDocument();
expect(screen.queryByDisplayValue("unsaved-openai-key")).not.toBeInTheDocument();
clickProviderRow("Ant Ling");
clickProviderRow("OpenAI");
await chooseProvider("Ant Ling");
expect(screen.getByDisplayValue("https://api.ant-ling.com/v1")).toBeInTheDocument();
clickProviderRow("Atomic Chat");
clickProviderRow("Ant Ling");
await chooseProvider("Atomic Chat");
expect(screen.getByDisplayValue("http://localhost:1337/v1")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save provider" })).toBeEnabled();
fireEvent.click(within(settingsNav).getByRole("button", { name: "Image" }));
expect(screen.getByRole("heading", { name: "Image" })).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Image" })).not.toBeInTheDocument();
expect(screen.getByRole("switch", { name: "Image generation" })).toBeInTheDocument();
expect(screen.getByText("Provider status")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "openai/gpt-5.4-image-2" })).toBeInTheDocument();
@@ -1750,7 +1778,10 @@ describe("App layout", () => {
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
expect(await screen.findByRole("heading", { name: "Overview" })).toBeInTheDocument();
expect(
await screen.findByRole("navigation", { name: "Settings sections" }),
).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Overview" })).not.toBeInTheDocument();
});
it("updates the URL hash when switching settings sections", async () => {
@@ -1761,13 +1792,16 @@ describe("App layout", () => {
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" }));
expect(await screen.findByRole("heading", { name: "Overview" })).toBeInTheDocument();
expect(
await screen.findByRole("navigation", { name: "Settings sections" }),
).toBeInTheDocument();
expect(window.location.hash).toBe("#/settings");
const settingsNav = screen.getByRole("navigation", { name: "Settings sections" });
fireEvent.click(within(settingsNav).getByRole("button", { name: "Models" }));
expect(await screen.findByRole("heading", { name: "Models" })).toBeInTheDocument();
expect(await screen.findByText("Model presets")).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Models" })).not.toBeInTheDocument();
expect(window.location.hash).toBe("#/settings?section=models");
fireEvent.click(within(settingsNav).getByRole("button", { name: "Voice" }));
@@ -1939,7 +1973,9 @@ describe("App layout", () => {
await waitFor(() => expect(document.title).toBe("nanobot"));
fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" }));
expect(await screen.findByRole("heading", { name: "Overview" })).toBeInTheDocument();
expect(
await screen.findByRole("navigation", { name: "Settings sections" }),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Back to chat" }));
await waitFor(() => expect(document.title).toBe("nanobot"));
File diff suppressed because it is too large Load Diff
+2
View File
@@ -170,6 +170,8 @@ function modelSettings(model: string, provider: string): SettingsPayload {
temperature: 0.7,
reasoning_effort: null,
}],
model_call_order: [],
model_call_order_editable: false,
providers: [
{ name: "deepseek", label: "DeepSeek", configured: true },
{ name: "openai_codex", label: "OpenAI Codex", configured: true },