From 404b68cdd44684379f3d888887b23f49b4b06140 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Fri, 29 May 2026 04:23:30 +0800 Subject: [PATCH] feat(webui): add context window setting --- nanobot/webui/settings_api.py | 33 +++++ tests/agent/test_runtime_refresh.py | 32 ++++- tests/webui/test_settings_api.py | 55 ++++++++ .../src/components/settings/SettingsView.tsx | 131 ++++++++++++------ webui/src/i18n/locales/en/common.json | 6 +- webui/src/i18n/locales/es/common.json | 6 +- webui/src/i18n/locales/fr/common.json | 6 +- webui/src/i18n/locales/id/common.json | 6 +- webui/src/i18n/locales/ja/common.json | 6 +- webui/src/i18n/locales/ko/common.json | 6 +- webui/src/i18n/locales/vi/common.json | 6 +- webui/src/i18n/locales/zh-CN/common.json | 6 +- webui/src/i18n/locales/zh-TW/common.json | 6 +- webui/src/lib/api.ts | 6 + webui/src/lib/types.ts | 2 + webui/src/tests/api.test.ts | 6 +- webui/src/tests/settings-view.test.tsx | 25 +++- 17 files changed, 280 insertions(+), 64 deletions(-) diff --git a/nanobot/webui/settings_api.py b/nanobot/webui/settings_api.py index efd836b2e..255dccc5e 100644 --- a/nanobot/webui/settings_api.py +++ b/nanobot/webui/settings_api.py @@ -85,6 +85,7 @@ _IMAGE_GENERATION_ASPECT_RATIOS = { "2:3", "21:9", } +_CONTEXT_WINDOW_TOKEN_OPTIONS = {65_536, 262_144} _MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+") @@ -257,6 +258,18 @@ def _parse_bool(value: str, field: str) -> bool: return normalized in {"1", "true", "yes"} +def _parse_context_window_tokens(value: str | None) -> int | None: + if value is None: + return None + try: + parsed = int(value) + except ValueError: + raise WebUISettingsError("context_window_tokens must be an integer") from None + if parsed not in _CONTEXT_WINDOW_TOKEN_OPTIONS: + raise WebUISettingsError("context_window_tokens must be 65536 or 262144") + return parsed + + def _model_configuration_slug(label: str) -> str: normalized = _MODEL_CONFIGURATION_SLUG_RE.sub("-", label.strip().lower()) normalized = normalized.strip("-_") @@ -544,6 +557,16 @@ def update_agent_settings(query: QueryParams) -> dict[str, Any]: defaults.provider = provider changed = True + context_window_tokens = _parse_context_window_tokens( + _query_first_alias(query, "context_window_tokens", "contextWindowTokens") + ) + if ( + context_window_tokens is not None + and defaults.context_window_tokens != context_window_tokens + ): + defaults.context_window_tokens = context_window_tokens + changed = True + timezone = _query_first(query, "timezone") if timezone is not None: timezone = timezone.strip() @@ -671,6 +694,16 @@ def update_model_configuration(query: QueryParams) -> dict[str, Any]: preset.provider = provider changed = True + context_window_tokens = _parse_context_window_tokens( + _query_first_alias(query, "context_window_tokens", "contextWindowTokens") + ) + if ( + context_window_tokens is not None + and preset.context_window_tokens != context_window_tokens + ): + preset.context_window_tokens = context_window_tokens + changed = True + if config.agents.defaults.model_preset != name: config.agents.defaults.model_preset = name changed = True diff --git a/tests/agent/test_runtime_refresh.py b/tests/agent/test_runtime_refresh.py index b36b1899b..1c7ca01c5 100644 --- a/tests/agent/test_runtime_refresh.py +++ b/tests/agent/test_runtime_refresh.py @@ -4,7 +4,10 @@ from unittest.mock import MagicMock from nanobot.agent.loop import AgentLoop from nanobot.bus.queue import MessageBus -from nanobot.providers.factory import ProviderSnapshot +from nanobot.config.loader import save_config +from nanobot.config.schema import Config +from nanobot.providers.factory import ProviderSnapshot, load_provider_snapshot +from nanobot.webui.settings_api import update_agent_settings def _provider(default_model: str, max_tokens: int = 123) -> MagicMock: @@ -72,3 +75,30 @@ def test_llm_runtime_refreshes_provider_snapshot(tmp_path: Path) -> None: assert runtime.model == "new-model" assert loop.provider is new_provider assert loop.runner.provider is new_provider + + +def test_settings_context_window_refreshes_runtime_state( + tmp_path: Path, + monkeypatch, +) -> None: + config_path = tmp_path / "config.json" + config = Config() + config.agents.defaults.workspace = str(tmp_path / "workspace") + config.agents.defaults.model = "openai/gpt-4o" + config.agents.defaults.provider = "openai" + config.agents.defaults.context_window_tokens = 65_536 + config.providers.openai.api_key = "sk-test" + save_config(config, config_path) + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + + def loader(*, preset_name: str | None = None) -> ProviderSnapshot: + return load_provider_snapshot(config_path, preset_name=preset_name) + + loop = AgentLoop.from_config(config, provider_snapshot_loader=loader) + + payload = update_agent_settings({"context_window_tokens": ["262144"]}) + loop._refresh_provider_snapshot() + + assert payload["requires_restart"] is False + assert loop.context_window_tokens == 262_144 + assert loop.consolidator.context_window_tokens == 262_144 diff --git a/tests/webui/test_settings_api.py b/tests/webui/test_settings_api.py index ce8f74789..6cd8da493 100644 --- a/tests/webui/test_settings_api.py +++ b/tests/webui/test_settings_api.py @@ -11,6 +11,7 @@ from nanobot.webui.settings_api import ( _oauth_provider_status, create_model_configuration, settings_payload, + update_agent_settings, update_model_configuration, update_network_safety_settings, ) @@ -119,6 +120,60 @@ def test_update_model_configuration_edits_named_preset_and_selects( assert saved.model_presets["codex"].model == "openai-codex/gpt-5.5" +def test_update_agent_settings_accepts_context_window_options( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = tmp_path / "config.json" + config = Config() + save_config(config, config_path) + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + + payload = update_agent_settings({"context_window_tokens": ["262144"]}) + + assert payload["agent"]["context_window_tokens"] == 262144 + saved = load_config(config_path) + assert saved.agents.defaults.context_window_tokens == 262144 + + +def test_update_model_configuration_accepts_context_window_options( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = tmp_path / "config.json" + config = Config() + config.model_presets["codex"] = ModelPresetConfig( + label="Codex", + provider="openai", + model="openai/gpt-4.1", + ) + save_config(config, config_path) + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + + payload = update_model_configuration( + { + "name": ["codex"], + "context_window_tokens": ["262144"], + } + ) + + assert payload["agent"]["context_window_tokens"] == 262144 + saved = load_config(config_path) + assert saved.model_presets["codex"].context_window_tokens == 262144 + + +def test_update_context_window_rejects_unknown_values( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = tmp_path / "config.json" + save_config(Config(), config_path) + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + + with pytest.raises(WebUISettingsError, match="context_window_tokens must be 65536 or 262144"): + update_agent_settings({"context_window_tokens": ["128000"]}) + + def test_update_model_configuration_rejects_default_preset( tmp_path, monkeypatch: pytest.MonkeyPatch, diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index 0dabc07a3..811af69c0 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -139,6 +139,7 @@ interface AgentSettingsDraft { provider: string; modelPreset: string; presetLabel: string; + contextWindowTokens: number; timezone: string; botName: string; botIcon: string; @@ -164,6 +165,7 @@ type ProviderForm = { apiKey: string; apiBase: string; apiType: ProviderApiType type CustomMcpTransport = "stdio" | "streamableHttp" | "sse"; const NANOBOT_ICON_SRC = "/brand/nanobot_icon.png"; +const CONTEXT_WINDOW_TOKEN_OPTIONS = [65_536, 262_144] as const; const FALLBACK_TIMEZONES = [ "UTC", @@ -279,6 +281,10 @@ function defaultPreset(payload: SettingsPayload): SettingsPayload["model_presets return payload.model_presets.find((preset) => preset.is_default) ?? null; } +function normalizeContextWindowTokens(value: number | null | undefined): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 65_536; +} + function editableDefaultProvider(payload: SettingsPayload): string { const base = defaultPreset(payload); return base?.provider ?? payload.agent.provider ?? payload.agent.resolved_provider ?? ""; @@ -373,6 +379,7 @@ export function SettingsView({ provider: "", modelPreset: "default", presetLabel: "Default", + contextWindowTokens: 65_536, timezone: "UTC", botName: "nanobot", botIcon: "", @@ -398,6 +405,9 @@ export function SettingsView({ : activePreset?.provider ?? editableDefaultProvider(payload), modelPreset: activePresetName, presetLabel: activePreset?.label ?? activePresetName, + contextWindowTokens: normalizeContextWindowTokens( + activePreset?.context_window_tokens ?? payload.agent.context_window_tokens, + ), timezone: payload.agent.timezone, botName: payload.agent.bot_name, botIcon: payload.agent.bot_icon, @@ -533,6 +543,7 @@ export function SettingsView({ form.modelPreset !== activePresetName || form.model !== selectedPreset.model || form.provider !== selectedProvider || + form.contextWindowTokens !== normalizeContextWindowTokens(selectedPreset.context_window_tokens) || (!selectedPreset.is_default && form.presetLabel.trim() !== selectedPreset.label) ); }, [form, settings]); @@ -644,14 +655,23 @@ export function SettingsView({ label: form.presetLabel.trim(), model: form.model, provider: form.provider, + ...(form.contextWindowTokens !== selectedPreset.context_window_tokens + ? { contextWindowTokens: form.contextWindowTokens } + : {}), }); } else { const defaultModel = defaultPreset(settings)?.model ?? settings.agent.model; const defaultProvider = editableDefaultProvider(settings); + const defaultContextWindowTokens = normalizeContextWindowTokens( + defaultPreset(settings)?.context_window_tokens ?? settings.agent.context_window_tokens, + ); payload = await updateSettings(token, { modelPreset: form.modelPreset, ...(form.model !== defaultModel ? { model: form.model } : {}), ...(form.provider !== defaultProvider ? { provider: form.provider } : {}), + ...(form.contextWindowTokens !== defaultContextWindowTokens + ? { contextWindowTokens: form.contextWindowTokens } + : {}), }); } applyPayload(payload); @@ -1876,6 +1896,9 @@ function ModelsSettings({ ? editableDefaultProvider(settings) : nextPreset?.provider ?? prev.provider, presetLabel: nextPreset?.label ?? modelPreset, + contextWindowTokens: normalizeContextWindowTokens( + nextPreset?.context_window_tokens ?? prev.contextWindowTokens, + ), })); }} onCreateConfiguration={onCreateConfiguration} @@ -1895,49 +1918,73 @@ function ModelsSettings({ /> ) : null} - + setForm((prev) => ({ ...prev, provider }))} + /> + + {selectedProviderNeedsSignIn ? ( + + - - ) : null} - - setForm((prev) => ({ ...prev, model: event.target.value }))} - className="h-8 w-[min(280px,70vw)] rounded-full text-[13px]" - /> - + {selectedProviderSigningIn ? ( + + ) : null} + {selectedProviderSigningIn + ? tx("settings.oauth.signingIn", "Signing in...") + : tx("settings.oauth.signIn", "Sign in")} + + + ) : null} + + setForm((prev) => ({ ...prev, model: event.target.value }))} + className="h-8 w-[min(280px,70vw)] rounded-full text-[13px]" + /> + + + ({ + value: String(tokens), + label: tokens === 262_144 ? "256K" : "64K", + }))} + onChange={(value) => + setForm((prev) => ({ + ...prev, + contextWindowTokens: normalizeContextWindowTokens(Number(value)), + })) + } + /> + ( `${base}/api/settings/model-configurations/update?${query}`, token, diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index f63036f19..23462b043 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -517,6 +517,7 @@ export interface SettingsUpdate { model?: string; provider?: string; modelPreset?: string | null; + contextWindowTokens?: number; timezone?: string; botName?: string; botIcon?: string; @@ -535,6 +536,7 @@ export interface ModelConfigurationUpdate { label?: string; provider?: string; model?: string; + contextWindowTokens?: number; } export interface ProviderSettingsUpdate { diff --git a/webui/src/tests/api.test.ts b/webui/src/tests/api.test.ts index 8675ec308..ea7973a2a 100644 --- a/webui/src/tests/api.test.ts +++ b/webui/src/tests/api.test.ts @@ -65,6 +65,7 @@ describe("webui API helpers", () => { modelPreset: "default", model: "openrouter/test", provider: "openrouter", + contextWindowTokens: 262144, timezone: "Asia/Shanghai", botName: "nanobot", botIcon: "nb", @@ -72,7 +73,7 @@ describe("webui API helpers", () => { }); expect(fetch).toHaveBeenCalledWith( - "/api/settings/update?model_preset=default&model=openrouter%2Ftest&provider=openrouter&timezone=Asia%2FShanghai&bot_name=nanobot&bot_icon=nb&tool_hint_max_length=120", + "/api/settings/update?model_preset=default&model=openrouter%2Ftest&provider=openrouter&context_window_tokens=262144&timezone=Asia%2FShanghai&bot_name=nanobot&bot_icon=nb&tool_hint_max_length=120", expect.objectContaining({ headers: { Authorization: "Bearer tok" }, }), @@ -100,10 +101,11 @@ describe("webui API helpers", () => { label: "Codex", provider: "openai_codex", model: "openai-codex/gpt-5.5", + contextWindowTokens: 65536, }); expect(fetch).toHaveBeenCalledWith( - "/api/settings/model-configurations/update?name=codex&label=Codex&provider=openai_codex&model=openai-codex%2Fgpt-5.5", + "/api/settings/model-configurations/update?name=codex&label=Codex&provider=openai_codex&model=openai-codex%2Fgpt-5.5&context_window_tokens=65536", expect.objectContaining({ headers: { Authorization: "Bearer tok" }, }), diff --git a/webui/src/tests/settings-view.test.tsx b/webui/src/tests/settings-view.test.tsx index b209a6f8e..6b0dfd37a 100644 --- a/webui/src/tests/settings-view.test.tsx +++ b/webui/src/tests/settings-view.test.tsx @@ -121,7 +121,7 @@ const installedAnyGen = { function renderSettingsView( options: { - initialSection?: "apps" | "advanced"; + initialSection?: "apps" | "advanced" | "models"; onSettingsChange?: (payload: SettingsPayload) => void; } = {}, ) { @@ -222,6 +222,29 @@ describe("SettingsView Apps catalog", () => { await waitFor(() => expect(onSettingsChange).toHaveBeenCalledWith(payload)); }); + it("shows context window options in model settings", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === "/api/settings") return jsonResponse(settingsPayload()); + if (url === "/api/settings/cli-apps") { + return jsonResponse({ apps: [], installed_count: 0 }); + } + if (url === "/api/settings/mcp-presets") { + return jsonResponse({ presets: [], installed_count: 0 }); + } + return { ok: false, status: 404, json: async () => ({}) } as Response; + }), + ); + + renderSettingsView({ initialSection: "models" }); + + expect(await screen.findByText("Context window")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "64K" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "256K" })).toBeInTheDocument(); + }); + it("saves network safety without exposing technical SSRF copy", async () => { const payload = settingsPayload(); const fetchMock = vi.fn(async (input: RequestInfo | URL) => {