From b440e76d2f0e27b5085b6b96022e22a08ab61154 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Sat, 25 Apr 2026 18:05:06 +0000 Subject: [PATCH] feat(webui): add model settings runtime refresh --- nanobot/agent/loop.py | 43 +++ nanobot/agent/runtime.py | 112 ++++++++ nanobot/channels/websocket.py | 75 ++++++ nanobot/cli/commands.py | 86 ++---- nanobot/nanobot.py | 60 +---- tests/agent/test_runtime_refresh.py | 49 ++++ tests/channels/test_websocket_channel.py | 68 +++++ webui/src/App.tsx | 45 +++- webui/src/components/Sidebar.tsx | 84 +++--- .../src/components/settings/SettingsView.tsx | 245 ++++++++++++++++++ webui/src/lib/api.ts | 20 +- webui/src/lib/types.ts | 22 ++ webui/src/tests/api.test.ts | 16 +- webui/src/tests/app-layout.test.tsx | 40 +++ 14 files changed, 793 insertions(+), 172 deletions(-) create mode 100644 nanobot/agent/runtime.py create mode 100644 tests/agent/test_runtime_refresh.py create mode 100644 webui/src/components/settings/SettingsView.tsx diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 5a448004..7f830a0a 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -18,6 +18,7 @@ from nanobot.agent.context import ContextBuilder from nanobot.agent.hook import AgentHook, AgentHookContext, CompositeHook from nanobot.agent.memory import Consolidator, Dream from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunner, AgentRunSpec +from nanobot.agent.runtime import AgentRuntime from nanobot.agent.skills import BUILTIN_SKILLS_DIR from nanobot.agent.subagent import SubagentManager from nanobot.agent.tools.ask import ( @@ -194,6 +195,8 @@ class AgentLoop: unified_session: bool = False, disabled_skills: list[str] | None = None, tools_config: ToolsConfig | None = None, + runtime_loader: Callable[[], AgentRuntime] | None = None, + runtime_signature: tuple[object, ...] | None = None, ): from nanobot.config.schema import ExecToolConfig, ToolsConfig, WebToolsConfig @@ -202,6 +205,8 @@ class AgentLoop: self.bus = bus self.channels_config = channels_config self.provider = provider + self._runtime_loader = runtime_loader + self._runtime_signature = runtime_signature self.workspace = workspace self.model = model or provider.get_default_model() self.max_iterations = ( @@ -288,6 +293,43 @@ class AgentLoop: self.commands = CommandRouter() register_builtin_commands(self.commands) + def _apply_runtime(self, runtime: AgentRuntime) -> None: + """Swap model/provider for future turns without disturbing an active one.""" + provider = runtime.provider + model = runtime.model + context_window_tokens = runtime.context_window_tokens + if self.provider is provider and self.model == model: + return + old_model = self.model + self.provider = provider + self.model = model + self.context_window_tokens = context_window_tokens + self.runner.provider = provider + self.subagents.provider = provider + self.subagents.model = model + self.subagents.runner.provider = provider + self.consolidator.provider = provider + self.consolidator.model = model + self.consolidator.context_window_tokens = context_window_tokens + self.consolidator.max_completion_tokens = provider.generation.max_tokens + self.dream.provider = provider + self.dream.model = model + self.dream._runner.provider = provider + self._runtime_signature = runtime.signature + logger.info("Runtime model switched for next turn: {} -> {}", old_model, model) + + def _refresh_runtime(self) -> None: + if self._runtime_loader is None: + return + try: + runtime = self._runtime_loader() + except Exception: + logger.exception("Failed to refresh runtime config") + return + if runtime.signature == self._runtime_signature: + return + self._apply_runtime(runtime) + def _register_default_tools(self) -> None: """Register the default set of tools.""" allowed_dir = ( @@ -766,6 +808,7 @@ class AgentLoop: pending_queue: asyncio.Queue | None = None, ) -> OutboundMessage | None: """Process a single inbound message and return the response.""" + self._refresh_runtime() # System messages: parse origin from chat_id ("channel:chat_id") if msg.channel == "system": channel, chat_id = ( diff --git a/nanobot/agent/runtime.py b/nanobot/agent/runtime.py new file mode 100644 index 00000000..af7ed109 --- /dev/null +++ b/nanobot/agent/runtime.py @@ -0,0 +1,112 @@ +"""Runtime model/provider resolution for agent turns.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from nanobot.config.schema import Config +from nanobot.providers.base import GenerationSettings, LLMProvider +from nanobot.providers.registry import find_by_name + + +@dataclass(frozen=True) +class AgentRuntime: + provider: LLMProvider + model: str + context_window_tokens: int + signature: tuple[object, ...] + + +def make_provider(config: Config) -> LLMProvider: + """Create the LLM provider implied by config.""" + model = config.agents.defaults.model + provider_name = config.get_provider_name(model) + p = config.get_provider(model) + spec = find_by_name(provider_name) if provider_name else None + backend = spec.backend if spec else "openai_compat" + + if backend == "azure_openai": + if not p or not p.api_key or not p.api_base: + raise ValueError("Azure OpenAI requires api_key and api_base in config.") + elif backend == "openai_compat" and not model.startswith("bedrock/"): + needs_key = not (p and p.api_key) + exempt = spec and (spec.is_oauth or spec.is_local or spec.is_direct) + if needs_key and not exempt: + raise ValueError(f"No API key configured for provider '{provider_name}'.") + + if backend == "openai_codex": + from nanobot.providers.openai_codex_provider import OpenAICodexProvider + + provider = OpenAICodexProvider(default_model=model) + elif backend == "azure_openai": + from nanobot.providers.azure_openai_provider import AzureOpenAIProvider + + provider = AzureOpenAIProvider( + api_key=p.api_key, + api_base=p.api_base, + default_model=model, + ) + elif backend == "github_copilot": + from nanobot.providers.github_copilot_provider import GitHubCopilotProvider + + provider = GitHubCopilotProvider(default_model=model) + elif backend == "anthropic": + from nanobot.providers.anthropic_provider import AnthropicProvider + + provider = AnthropicProvider( + api_key=p.api_key if p else None, + api_base=config.get_api_base(model), + default_model=model, + extra_headers=p.extra_headers if p else None, + ) + else: + from nanobot.providers.openai_compat_provider import OpenAICompatProvider + + provider = OpenAICompatProvider( + api_key=p.api_key if p else None, + api_base=config.get_api_base(model), + default_model=model, + extra_headers=p.extra_headers if p else None, + spec=spec, + ) + + defaults = config.agents.defaults + provider.generation = GenerationSettings( + temperature=defaults.temperature, + max_tokens=defaults.max_tokens, + reasoning_effort=defaults.reasoning_effort, + ) + return provider + + +def runtime_signature(config: Config) -> tuple[object, ...]: + """Return the config fields that affect the primary LLM runtime.""" + model = config.agents.defaults.model + defaults = config.agents.defaults + return ( + model, + defaults.provider, + config.get_provider_name(model), + config.get_api_key(model), + config.get_api_base(model), + defaults.max_tokens, + defaults.temperature, + defaults.reasoning_effort, + defaults.context_window_tokens, + ) + + +def build_agent_runtime(config: Config) -> AgentRuntime: + return AgentRuntime( + provider=make_provider(config), + model=config.agents.defaults.model, + context_window_tokens=config.agents.defaults.context_window_tokens, + signature=runtime_signature(config), + ) + + +def load_agent_runtime(config_path: Path | None = None) -> AgentRuntime: + from nanobot.config.loader import load_config, resolve_config_env_vars + + return build_agent_runtime(resolve_config_env_vars(load_config(config_path))) diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index ff923d81..eba9ed79 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -539,6 +539,12 @@ class WebSocketChannel(BaseChannel): if got == "/api/sessions": return self._handle_sessions_list(request) + if got == "/api/settings": + return self._handle_settings(request) + + if got == "/api/settings/update": + return self._handle_settings_update(request) + m = re.match(r"^/api/sessions/([^/]+)/messages$", got) if m: return self._handle_session_messages(request, m.group(1)) @@ -647,6 +653,75 @@ class WebSocketChannel(BaseChannel): ] return _http_json_response({"sessions": cleaned}) + def _settings_payload(self, *, requires_restart: bool = False) -> dict[str, Any]: + from nanobot.config.loader import get_config_path, load_config + from nanobot.providers.registry import PROVIDERS, find_by_name + + config = load_config() + defaults = config.agents.defaults + provider_name = config.get_provider_name(defaults.model) or defaults.provider + provider = config.get_provider(defaults.model) + selected_provider = provider_name + if defaults.provider != "auto": + spec = find_by_name(defaults.provider) + selected_provider = spec.name if spec else provider_name + return { + "agent": { + "model": defaults.model, + "provider": selected_provider, + "resolved_provider": provider_name, + "has_api_key": bool(provider and provider.api_key), + }, + "providers": [ + {"name": "auto", "label": "Auto"} + ] + [ + {"name": spec.name, "label": spec.label} + for spec in PROVIDERS + ], + "runtime": { + "config_path": str(get_config_path().expanduser()), + }, + "requires_restart": requires_restart, + } + + def _handle_settings(self, request: WsRequest) -> Response: + if not self._check_api_token(request): + return _http_error(401, "Unauthorized") + return _http_json_response(self._settings_payload()) + + def _handle_settings_update(self, request: WsRequest) -> Response: + if not self._check_api_token(request): + return _http_error(401, "Unauthorized") + from nanobot.config.loader import load_config, save_config + from nanobot.providers.registry import find_by_name + + query = _parse_query(request.path) + config = load_config() + defaults = config.agents.defaults + changed = False + + model = _query_first(query, "model") + if model is not None: + model = model.strip() + if not model: + return _http_error(400, "model is required") + if defaults.model != model: + defaults.model = model + changed = True + + provider = _query_first(query, "provider") + if provider is not None: + provider = provider.strip() or "auto" + if provider != "auto" and find_by_name(provider) is None: + return _http_error(400, "unknown provider") + if defaults.provider != provider: + defaults.provider = provider + changed = True + + if changed: + save_config(config) + return _http_json_response(self._settings_payload(requires_restart=changed)) + @staticmethod def _is_webui_session_key(key: str) -> bool: """Return True when *key* belongs to the webui's websocket-only surface.""" diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index c4cd2b1b..08843305 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -412,73 +412,13 @@ def _make_provider(config: Config): Routing is driven by ``ProviderSpec.backend`` in the registry. """ - from nanobot.providers.base import GenerationSettings - from nanobot.providers.registry import find_by_name + from nanobot.agent.runtime import make_provider - model = config.agents.defaults.model - provider_name = config.get_provider_name(model) - p = config.get_provider(model) - spec = find_by_name(provider_name) if provider_name else None - backend = spec.backend if spec else "openai_compat" - - # --- validation --- - if backend == "azure_openai": - if not p or not p.api_key or not p.api_base: - console.print("[red]Error: Azure OpenAI requires api_key and api_base.[/red]") - console.print("Set them in ~/.nanobot/config.json under providers.azure_openai section") - console.print("Use the model field to specify the deployment name.") - raise typer.Exit(1) - elif backend == "openai_compat" and not model.startswith("bedrock/"): - needs_key = not (p and p.api_key) - exempt = spec and (spec.is_oauth or spec.is_local or spec.is_direct) - if needs_key and not exempt: - console.print("[red]Error: No API key configured.[/red]") - console.print("Set one in ~/.nanobot/config.json under providers section") - raise typer.Exit(1) - - # --- instantiation by backend --- - if backend == "openai_codex": - from nanobot.providers.openai_codex_provider import OpenAICodexProvider - - provider = OpenAICodexProvider(default_model=model) - elif backend == "azure_openai": - from nanobot.providers.azure_openai_provider import AzureOpenAIProvider - - provider = AzureOpenAIProvider( - api_key=p.api_key, - api_base=p.api_base, - default_model=model, - ) - elif backend == "github_copilot": - from nanobot.providers.github_copilot_provider import GitHubCopilotProvider - provider = GitHubCopilotProvider(default_model=model) - elif backend == "anthropic": - from nanobot.providers.anthropic_provider import AnthropicProvider - - provider = AnthropicProvider( - api_key=p.api_key if p else None, - api_base=config.get_api_base(model), - default_model=model, - extra_headers=p.extra_headers if p else None, - ) - else: - from nanobot.providers.openai_compat_provider import OpenAICompatProvider - - provider = OpenAICompatProvider( - api_key=p.api_key if p else None, - api_base=config.get_api_base(model), - default_model=model, - extra_headers=p.extra_headers if p else None, - spec=spec, - ) - - defaults = config.agents.defaults - provider.generation = GenerationSettings( - temperature=defaults.temperature, - max_tokens=defaults.max_tokens, - reasoning_effort=defaults.reasoning_effort, - ) - return provider + try: + return make_provider(config) + except ValueError as exc: + console.print(f"[red]Error: {exc}[/red]") + raise typer.Exit(1) from exc def _load_runtime_config(config: str | None = None, workspace: str | None = None) -> Config: @@ -656,6 +596,7 @@ def _run_gateway( ) -> None: """Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up.""" from nanobot.agent.loop import AgentLoop + from nanobot.agent.runtime import build_agent_runtime, load_agent_runtime from nanobot.bus.queue import MessageBus from nanobot.channels.manager import ChannelManager from nanobot.cron.service import CronService @@ -668,7 +609,12 @@ def _run_gateway( console.print(f"{__logo__} Starting nanobot gateway version {__version__} on port {port}...") sync_workspace_templates(config.workspace_path) bus = MessageBus() - provider = _make_provider(config) + try: + runtime = build_agent_runtime(config) + except ValueError as exc: + console.print(f"[red]Error: {exc}[/red]") + raise typer.Exit(1) from exc + provider = runtime.provider session_manager = SessionManager(config.workspace_path) # Preserve existing single-workspace installs, but keep custom workspaces clean. @@ -684,9 +630,9 @@ def _run_gateway( bus=bus, provider=provider, workspace=config.workspace_path, - model=config.agents.defaults.model, + model=runtime.model, max_iterations=config.agents.defaults.max_tool_iterations, - context_window_tokens=config.agents.defaults.context_window_tokens, + context_window_tokens=runtime.context_window_tokens, web_config=config.tools.web, context_block_limit=config.agents.defaults.context_block_limit, max_tool_result_chars=config.agents.defaults.max_tool_result_chars, @@ -702,6 +648,8 @@ def _run_gateway( disabled_skills=config.agents.defaults.disabled_skills, session_ttl_minutes=config.agents.defaults.session_ttl_minutes, tools_config=config.tools, + runtime_loader=load_agent_runtime, + runtime_signature=runtime.signature, ) # Set cron callback (needs agent) diff --git a/nanobot/nanobot.py b/nanobot/nanobot.py index 96102e3d..374f9354 100644 --- a/nanobot/nanobot.py +++ b/nanobot/nanobot.py @@ -119,62 +119,6 @@ class Nanobot: def _make_provider(config: Any) -> Any: """Create the LLM provider from config (extracted from CLI).""" - from nanobot.providers.base import GenerationSettings - from nanobot.providers.registry import find_by_name + from nanobot.agent.runtime import make_provider - model = config.agents.defaults.model - provider_name = config.get_provider_name(model) - p = config.get_provider(model) - spec = find_by_name(provider_name) if provider_name else None - backend = spec.backend if spec else "openai_compat" - - if backend == "azure_openai": - if not p or not p.api_key or not p.api_base: - raise ValueError("Azure OpenAI requires api_key and api_base in config.") - elif backend == "openai_compat" and not model.startswith("bedrock/"): - needs_key = not (p and p.api_key) - exempt = spec and (spec.is_oauth or spec.is_local or spec.is_direct) - if needs_key and not exempt: - raise ValueError(f"No API key configured for provider '{provider_name}'.") - - if backend == "openai_codex": - from nanobot.providers.openai_codex_provider import OpenAICodexProvider - - provider = OpenAICodexProvider(default_model=model) - elif backend == "github_copilot": - from nanobot.providers.github_copilot_provider import GitHubCopilotProvider - - provider = GitHubCopilotProvider(default_model=model) - elif backend == "azure_openai": - from nanobot.providers.azure_openai_provider import AzureOpenAIProvider - - provider = AzureOpenAIProvider( - api_key=p.api_key, api_base=p.api_base, default_model=model - ) - elif backend == "anthropic": - from nanobot.providers.anthropic_provider import AnthropicProvider - - provider = AnthropicProvider( - api_key=p.api_key if p else None, - api_base=config.get_api_base(model), - default_model=model, - extra_headers=p.extra_headers if p else None, - ) - else: - from nanobot.providers.openai_compat_provider import OpenAICompatProvider - - provider = OpenAICompatProvider( - api_key=p.api_key if p else None, - api_base=config.get_api_base(model), - default_model=model, - extra_headers=p.extra_headers if p else None, - spec=spec, - ) - - defaults = config.agents.defaults - provider.generation = GenerationSettings( - temperature=defaults.temperature, - max_tokens=defaults.max_tokens, - reasoning_effort=defaults.reasoning_effort, - ) - return provider + return make_provider(config) diff --git a/tests/agent/test_runtime_refresh.py b/tests/agent/test_runtime_refresh.py new file mode 100644 index 00000000..505a4b32 --- /dev/null +++ b/tests/agent/test_runtime_refresh.py @@ -0,0 +1,49 @@ +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock + +from nanobot.agent.loop import AgentLoop +from nanobot.agent.runtime import AgentRuntime +from nanobot.bus.queue import MessageBus + + +def _provider(default_model: str, max_tokens: int = 123) -> MagicMock: + provider = MagicMock() + provider.get_default_model.return_value = default_model + provider.generation = SimpleNamespace(max_tokens=max_tokens) + return provider + + +def test_runtime_refresh_updates_loop_dependents(tmp_path: Path) -> None: + old_provider = _provider("old-model") + new_provider = _provider("new-model", max_tokens=456) + loop = AgentLoop( + bus=MessageBus(), + provider=old_provider, + workspace=tmp_path, + model="old-model", + context_window_tokens=1000, + runtime_loader=lambda: AgentRuntime( + provider=new_provider, + model="new-model", + context_window_tokens=2000, + signature=("new-model",), + ), + ) + + loop._refresh_runtime() + + assert loop.provider is new_provider + assert loop.model == "new-model" + assert loop.context_window_tokens == 2000 + assert loop.runner.provider is new_provider + assert loop.subagents.provider is new_provider + assert loop.subagents.model == "new-model" + assert loop.subagents.runner.provider is new_provider + assert loop.consolidator.provider is new_provider + assert loop.consolidator.model == "new-model" + assert loop.consolidator.context_window_tokens == 2000 + assert loop.consolidator.max_completion_tokens == 456 + assert loop.dream.provider is new_provider + assert loop.dream.model == "new-model" + assert loop.dream._runner.provider is new_provider diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index a1d459b9..b5dc830b 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -26,6 +26,8 @@ from nanobot.channels.websocket import ( _parse_query, _parse_request_path, ) +from nanobot.config.loader import load_config, save_config +from nanobot.config.schema import Config # -- Shared helpers (aligned with test_websocket_integration.py) --------------- @@ -439,6 +441,72 @@ async def test_http_route_issues_token_then_websocket_requires_it(bus: MagicMock await server_task +@pytest.mark.asyncio +async def test_settings_api_returns_safe_subset_and_updates_whitelist( + bus: MagicMock, + monkeypatch, + tmp_path, +) -> None: + port = 29891 + config_path = tmp_path / "config.json" + config = Config() + config.agents.defaults.model = "openai/gpt-4o" + config.providers.openai.api_key = "secret-key" + save_config(config, config_path) + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + + channel = _ch(bus, port=port) + channel._api_tokens["tok"] = time.monotonic() + 300 + + server_task = asyncio.create_task(channel.start()) + await asyncio.sleep(0.3) + + try: + settings = await _http_get( + f"http://127.0.0.1:{port}/api/settings", + headers={"Authorization": "Bearer tok"}, + ) + assert settings.status_code == 200 + body = settings.json() + assert body["agent"]["model"] == "openai/gpt-4o" + assert body["agent"]["provider"] == "openai" + assert {"name": "auto", "label": "Auto"} in body["providers"] + assert body["agent"]["has_api_key"] is True + assert "secret-key" not in settings.text + + updated = await _http_get( + "http://127.0.0.1:" + f"{port}/api/settings/update?model=openrouter/test" + "&provider=openrouter", + headers={"Authorization": "Bearer tok"}, + ) + assert updated.status_code == 200 + assert updated.json()["requires_restart"] is True + + saved = load_config(config_path) + assert saved.agents.defaults.model == "openrouter/test" + assert saved.agents.defaults.provider == "openrouter" + finally: + await channel.stop() + await server_task + + +def test_settings_payload_normalizes_camel_case_provider( + bus: MagicMock, + monkeypatch, + tmp_path, +) -> None: + config_path = tmp_path / "config.json" + config = Config() + config.agents.defaults.provider = "minimaxAnthropic" + save_config(config, config_path) + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + + body = _ch(bus)._settings_payload() + + assert body["agent"]["provider"] == "minimax_anthropic" + + @pytest.mark.asyncio async def test_end_to_end_server_pushes_streaming_deltas_to_client(bus: MagicMock) -> None: port = 29880 diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 43beae9a..c6ad6f06 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { DeleteConfirm } from "@/components/DeleteConfirm"; import { Sidebar } from "@/components/Sidebar"; +import { SettingsView } from "@/components/settings/SettingsView"; import { ThreadShell } from "@/components/thread/ThreadShell"; import { Sheet, SheetContent } from "@/components/ui/sheet"; import { preloadMarkdownText } from "@/components/MarkdownText"; @@ -25,6 +26,7 @@ type BootState = const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar"; const SIDEBAR_WIDTH = 279; +type ShellView = "chat" | "settings"; function readSidebarOpen(): boolean { if (typeof window === "undefined") return true; @@ -136,22 +138,29 @@ export default function App() { ); } + const handleModelNameChange = (modelName: string | null) => { + setState((current) => + current.status === "ready" ? { ...current, modelName } : current, + ); + }; + return ( - + ); } -function Shell() { +function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | null) => void }) { const { t, i18n } = useTranslation(); const { theme, toggle } = useTheme(); const { sessions, loading, refresh, createChat, deleteChat } = useSessions(); const [activeKey, setActiveKey] = useState(null); + const [view, setView] = useState("chat"); const [desktopSidebarOpen, setDesktopSidebarOpen] = useState(readSidebarOpen); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); @@ -208,6 +217,7 @@ function Shell() { try { const chatId = await createChat(); setActiveKey(`websocket:${chatId}`); + setView("chat"); setMobileSidebarOpen(false); return chatId; } catch (e) { @@ -219,6 +229,7 @@ function Shell() { const onSelectChat = useCallback( (key: string) => { setActiveKey(key); + setView("chat"); setMobileSidebarOpen(false); }, [], @@ -266,6 +277,11 @@ function Shell() { onRefresh: () => void refresh(), onRequestDelete: (key: string, label: string) => setPendingDelete({ key, label }), + activeView: view, + onOpenSettings: () => { + setView("settings" as const); + setMobileSidebarOpen(false); + }, }; return ( @@ -303,14 +319,23 @@ function Shell() {
- setActiveKey(null)} - onNewChat={onNewChat} - hideSidebarToggleOnDesktop={desktopSidebarOpen} - /> + {view === "settings" ? ( + setView("chat")} + onModelNameChange={onModelNameChange} + /> + ) : ( + setActiveKey(null)} + onNewChat={onNewChat} + hideSidebarToggleOnDesktop={desktopSidebarOpen} + /> + )}
void; onRequestDelete: (key: string, label: string) => void; onCollapse: () => void; + activeView?: "chat" | "settings"; + onOpenSettings: () => void; } export function Sidebar(props: SidebarProps) { const { t } = useTranslation(); return ( ); diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx new file mode 100644 index 00000000..c24ff97d --- /dev/null +++ b/webui/src/components/settings/SettingsView.tsx @@ -0,0 +1,245 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { ChevronLeft, Loader2 } from "lucide-react"; + +import { LanguageSwitcher } from "@/components/LanguageSwitcher"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { fetchSettings, updateSettings } from "@/lib/api"; +import { cn } from "@/lib/utils"; +import { useClient } from "@/providers/ClientProvider"; +import type { SettingsPayload } from "@/lib/types"; + +interface SettingsViewProps { + theme: "light" | "dark"; + onToggleTheme: () => void; + onBackToChat: () => void; + onModelNameChange: (modelName: string | null) => void; +} + +export function SettingsView({ + onBackToChat, + onModelNameChange, +}: SettingsViewProps) { + const { token } = useClient(); + const [settings, setSettings] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [form, setForm] = useState({ + model: "", + provider: "auto", + }); + + const applyPayload = useCallback((payload: SettingsPayload) => { + setSettings(payload); + setForm({ + model: payload.agent.model, + provider: payload.agent.provider, + }); + }, []); + + useEffect(() => { + let cancelled = false; + setLoading(true); + fetchSettings(token) + .then((payload) => { + if (!cancelled) { + applyPayload(payload); + setError(null); + } + }) + .catch((err) => { + if (!cancelled) setError((err as Error).message); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [applyPayload, token]); + + const dirty = useMemo(() => { + if (!settings) return false; + return ( + form.model !== settings.agent.model || + form.provider !== settings.agent.provider + ); + }, [form, settings]); + + const save = async () => { + if (!dirty || saving) return; + setSaving(true); + try { + const payload = await updateSettings(token, form); + applyPayload(payload); + onModelNameChange(payload.agent.model || null); + setError(null); + } catch (err) { + setError((err as Error).message); + } finally { + setSaving(false); + } + }; + + return ( +
+
+ + +

General

+ + {loading ? ( +
+ + Loading settings... +
+ ) : error ? ( + + + {error} + + + ) : settings ? ( + + ) : null} +
+
+ ); +} + +function SettingsSection({ + form, + setForm, + settings, + dirty, + saving, + onSave, +}: { + form: { + model: string; + provider: string; + }; + setForm: React.Dispatch>; + settings: SettingsPayload; + dirty: boolean; + saving: boolean; + onSave: () => void; +}) { + return ( +
+
+

AI

+ + + + + + + setForm((prev) => ({ ...prev, model: event.target.value }))} + className="h-8 w-[280px]" + /> + + + {(dirty || saving || settings.requires_restart) ? ( + + ) : null} + +
+ +
+

Interface

+ + + + + +
+
+ ); +} + +function SettingsGroup({ children }: { children: React.ReactNode }) { + return ( +
+
{children}
+
+ ); +} + +function SettingsRow({ + title, + children, +}: { + title: string; + children?: React.ReactNode; +}) { + return ( +
+
+
{title}
+
+ {children ?
{children}
: null} +
+ ); +} + +function SettingsFooter({ + dirty, + saving, + saved, + onSave, +}: { + dirty: boolean; + saving: boolean; + saved: boolean; + onSave: () => void; +}) { + return ( +
+
+ {saved ? "Saved. Restart nanobot to apply." : "Unsaved changes."} +
+ +
+ ); +} diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts index 9012ca0d..56fed32c 100644 --- a/webui/src/lib/api.ts +++ b/webui/src/lib/api.ts @@ -1,4 +1,4 @@ -import type { ChatSummary } from "./types"; +import type { ChatSummary, SettingsPayload, SettingsUpdate } from "./types"; export class ApiError extends Error { status: number; @@ -104,3 +104,21 @@ export async function deleteSession( ); return body.deleted; } + +export async function fetchSettings( + token: string, + base: string = "", +): Promise { + return request(`${base}/api/settings`, token); +} + +export async function updateSettings( + token: string, + update: SettingsUpdate, + base: string = "", +): Promise { + const query = new URLSearchParams(); + if (update.model !== undefined) query.set("model", update.model); + if (update.provider !== undefined) query.set("provider", update.provider); + return request(`${base}/api/settings/update?${query}`, token); +} diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 96935b09..1b857a17 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -66,6 +66,28 @@ export interface BootstrapResponse { model_name?: string | null; } +export interface SettingsPayload { + agent: { + model: string; + provider: string; + resolved_provider: string | null; + has_api_key: boolean; + }; + providers: Array<{ + name: string; + label: string; + }>; + runtime: { + config_path: string; + }; + requires_restart: boolean; +} + +export interface SettingsUpdate { + model?: string; + provider?: string; +} + export type ConnectionStatus = | "idle" | "connecting" diff --git a/webui/src/tests/api.test.ts b/webui/src/tests/api.test.ts index fdefac77..aab940d5 100644 --- a/webui/src/tests/api.test.ts +++ b/webui/src/tests/api.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { deleteSession, fetchSessionMessages } from "@/lib/api"; +import { deleteSession, fetchSessionMessages, updateSettings } from "@/lib/api"; describe("webui API helpers", () => { beforeEach(() => { @@ -34,4 +34,18 @@ describe("webui API helpers", () => { }), ); }); + + it("serializes settings updates as a narrow query string", async () => { + await updateSettings("tok", { + model: "openrouter/test", + provider: "openrouter", + }); + + expect(fetch).toHaveBeenCalledWith( + "/api/settings/update?model=openrouter%2Ftest&provider=openrouter", + expect.objectContaining({ + headers: { Authorization: "Bearer tok" }, + }), + ); + }); }); diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index 4c8485b7..77b9420d 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -146,4 +146,44 @@ describe("App layout", () => { expect(screen.queryByText('Delete “First chat”?')).not.toBeInTheDocument(); expect(document.body.style.pointerEvents).not.toBe("none"); }, 15_000); + + it("opens the Cursor-style settings view from the sidebar", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + if (String(input).includes("/api/settings")) { + return { + ok: true, + status: 200, + json: async () => ({ + agent: { + model: "openai/gpt-4o", + provider: "auto", + resolved_provider: "openai", + has_api_key: true, + }, + providers: [ + { name: "auto", label: "Auto" }, + { name: "openai", label: "OpenAI" }, + ], + runtime: { + config_path: "/tmp/config.json", + }, + requires_restart: false, + }), + }; + } + return { ok: false, status: 404, json: async () => ({}) }; + }), + ); + + render(); + + await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + fireEvent.click(screen.getByRole("button", { name: "Settings" })); + + expect(await screen.findByRole("heading", { name: "General" })).toBeInTheDocument(); + expect(screen.getByText("AI")).toBeInTheDocument(); + expect(screen.getByDisplayValue("openai/gpt-4o")).toBeInTheDocument(); + }); });