diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index f1efc16e2..3c893c38b 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -42,6 +42,7 @@ from nanobot.bus.queue import MessageBus from nanobot.command import CommandContext, CommandRouter, register_builtin_commands from nanobot.config.schema import AgentDefaults from nanobot.providers.base import LLMProvider +from nanobot.providers.factory import ProviderSnapshot from nanobot.session.manager import Session, SessionManager from nanobot.utils.document import extract_documents from nanobot.utils.helpers import image_placeholder_text @@ -195,6 +196,8 @@ class AgentLoop: unified_session: bool = False, disabled_skills: list[str] | None = None, tools_config: ToolsConfig | None = None, + provider_snapshot_loader: Callable[[], ProviderSnapshot] | None = None, + provider_signature: tuple[object, ...] | None = None, ): from nanobot.config.schema import ExecToolConfig, ToolsConfig, WebToolsConfig @@ -203,6 +206,8 @@ class AgentLoop: self.bus = bus self.channels_config = channels_config self.provider = provider + self._provider_snapshot_loader = provider_snapshot_loader + self._provider_signature = provider_signature self.workspace = workspace self.model = model or provider.get_default_model() self.max_iterations = ( @@ -290,6 +295,36 @@ class AgentLoop: self.commands = CommandRouter() register_builtin_commands(self.commands) + def _apply_provider_snapshot(self, snapshot: ProviderSnapshot) -> None: + """Swap model/provider for future turns without disturbing an active one.""" + provider = snapshot.provider + model = snapshot.model + context_window_tokens = snapshot.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.set_provider(provider, model) + self.consolidator.set_provider(provider, model, context_window_tokens) + self.dream.set_provider(provider, model) + self._provider_signature = snapshot.signature + logger.info("Runtime model switched for next turn: {} -> {}", old_model, model) + + def _refresh_provider_snapshot(self) -> None: + if self._provider_snapshot_loader is None: + return + try: + snapshot = self._provider_snapshot_loader() + except Exception: + logger.exception("Failed to refresh provider config") + return + if snapshot.signature == self._provider_signature: + return + self._apply_provider_snapshot(snapshot) + def _register_default_tools(self) -> None: """Register the default set of tools.""" allowed_dir = ( @@ -768,6 +803,7 @@ class AgentLoop: pending_queue: asyncio.Queue | None = None, ) -> OutboundMessage | None: """Process a single inbound message and return the response.""" + self._refresh_provider_snapshot() # System messages: parse origin from chat_id ("channel:chat_id") if msg.channel == "system": channel, chat_id = ( diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index cc14ea744..91160d4a5 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -450,6 +450,17 @@ class Consolidator: weakref.WeakValueDictionary() ) + def set_provider( + self, + provider: LLMProvider, + model: str, + context_window_tokens: int, + ) -> None: + self.provider = provider + self.model = model + self.context_window_tokens = context_window_tokens + self.max_completion_tokens = provider.generation.max_tokens + def get_lock(self, session_key: str) -> asyncio.Lock: """Return the shared consolidation lock for one session.""" return self._locks.setdefault(session_key, asyncio.Lock()) @@ -710,6 +721,11 @@ class Dream: self._runner = AgentRunner(provider) self._tools = self._build_tools() + def set_provider(self, provider: LLMProvider, model: str) -> None: + self.provider = provider + self.model = model + self._runner.provider = provider + # -- tool registry ------------------------------------------------------- def _build_tools(self) -> ToolRegistry: diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 7db62dcf4..5795a5386 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -96,6 +96,11 @@ class SubagentManager: self._task_statuses: dict[str, SubagentStatus] = {} self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...} + def set_provider(self, provider: LLMProvider, model: str) -> None: + self.provider = provider + self.model = model + self.runner.provider = provider + async def spawn( self, task: str, diff --git a/nanobot/agent/tools/ask.py b/nanobot/agent/tools/ask.py index c2aa8e0e8..db8c83a84 100644 --- a/nanobot/agent/tools/ask.py +++ b/nanobot/agent/tools/ask.py @@ -6,7 +6,7 @@ from typing import Any from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.schema import ArraySchema, StringSchema, tool_parameters_schema -BUTTON_CHANNELS = frozenset({"telegram"}) +STRUCTURED_BUTTON_CHANNELS = frozenset({"telegram", "websocket"}) class AskUserInterrupt(BaseException): @@ -130,7 +130,7 @@ def ask_user_outbound( ) -> tuple[str | None, list[list[str]]]: if not options: return content, [] - if channel in BUTTON_CHANNELS: + if channel in STRUCTURED_BUTTON_CHANNELS: return content, [options] option_text = "\n".join(f"{index}. {option}" for index, option in enumerate(options, 1)) return f"{content}\n\n{option_text}" if content else option_text, [] diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index c76371e98..eba9ed79a 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -54,6 +54,14 @@ def _normalize_config_path(path: str) -> str: return _strip_trailing_slash(path) +def _append_buttons_as_text(text: str, buttons: list[list[str]]) -> str: + labels = [label for row in buttons for label in row if label] + if not labels: + return text + fallback = "\n".join(f"{index}. {label}" for index, label in enumerate(labels, 1)) + return f"{text}\n\n{fallback}" if text else fallback + + class WebSocketConfig(Base): """WebSocket server channel configuration. @@ -531,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)) @@ -639,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.""" @@ -1146,11 +1229,17 @@ class WebSocketChannel(BaseChannel): if not conns: logger.warning("websocket: no active subscribers for chat_id={}", msg.chat_id) return + text = msg.content + if msg.buttons: + text = _append_buttons_as_text(text, msg.buttons) payload: dict[str, Any] = { "event": "message", "chat_id": msg.chat_id, - "text": msg.content, + "text": text, } + if msg.buttons: + payload["buttons"] = msg.buttons + payload["button_prompt"] = msg.content if msg.media: payload["media"] = msg.media urls: list[dict[str, str]] = [] diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index e1b317ed1..ce88ece58 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.providers.factory 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: @@ -664,6 +604,7 @@ def _run_gateway( from nanobot.cron.service import CronService from nanobot.cron.types import CronJob from nanobot.heartbeat.service import HeartbeatService + from nanobot.providers.factory import build_provider_snapshot, load_provider_snapshot from nanobot.session.manager import SessionManager port = port if port is not None else config.gateway.port @@ -671,7 +612,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: + provider_snapshot = build_provider_snapshot(config) + except ValueError as exc: + console.print(f"[red]Error: {exc}[/red]") + raise typer.Exit(1) from exc + provider = provider_snapshot.provider session_manager = SessionManager(config.workspace_path) # Preserve existing single-workspace installs, but keep custom workspaces clean. @@ -687,9 +633,9 @@ def _run_gateway( bus=bus, provider=provider, workspace=config.workspace_path, - model=config.agents.defaults.model, + model=provider_snapshot.model, max_iterations=config.agents.defaults.max_tool_iterations, - context_window_tokens=config.agents.defaults.context_window_tokens, + context_window_tokens=provider_snapshot.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, @@ -706,6 +652,8 @@ def _run_gateway( session_ttl_minutes=config.agents.defaults.session_ttl_minutes, consolidation_ratio=config.agents.defaults.consolidation_ratio, tools_config=config.tools, + provider_snapshot_loader=load_provider_snapshot, + provider_signature=provider_snapshot.signature, ) from nanobot.agent.loop import UNIFIED_SESSION_KEY diff --git a/nanobot/nanobot.py b/nanobot/nanobot.py index f9aeae84e..d2bff97d7 100644 --- a/nanobot/nanobot.py +++ b/nanobot/nanobot.py @@ -120,62 +120,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.providers.factory 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/nanobot/providers/factory.py b/nanobot/providers/factory.py new file mode 100644 index 000000000..f4ec6a4c2 --- /dev/null +++ b/nanobot/providers/factory.py @@ -0,0 +1,112 @@ +"""Create LLM providers from config.""" + +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 ProviderSnapshot: + 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 provider_signature(config: Config) -> tuple[object, ...]: + """Return the config fields that affect the primary LLM provider.""" + 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_provider_snapshot(config: Config) -> ProviderSnapshot: + return ProviderSnapshot( + provider=make_provider(config), + model=config.agents.defaults.model, + context_window_tokens=config.agents.defaults.context_window_tokens, + signature=provider_signature(config), + ) + + +def load_provider_snapshot(config_path: Path | None = None) -> ProviderSnapshot: + from nanobot.config.loader import load_config, resolve_config_env_vars + + return build_provider_snapshot(resolve_config_env_vars(load_config(config_path))) diff --git a/tests/agent/test_ask_user.py b/tests/agent/test_ask_user.py index 4d5b5be93..a192ee4a6 100644 --- a/tests/agent/test_ask_user.py +++ b/tests/agent/test_ask_user.py @@ -205,3 +205,37 @@ async def test_ask_user_keeps_buttons_for_telegram(tmp_path): assert response is not None assert response.content == "Install the optional package?" assert response.buttons == [["Install", "Skip"]] + + +@pytest.mark.asyncio +async def test_ask_user_keeps_buttons_for_websocket(tmp_path): + async def chat_with_retry(**kwargs): + return LLMResponse( + content="", + finish_reason="tool_calls", + tool_calls=[ + ToolCallRequest( + id="call_ask", + name="ask_user", + arguments={ + "question": "Install the optional package?", + "options": ["Install", "Skip"], + }, + ) + ], + ) + + loop = AgentLoop( + bus=MessageBus(), + provider=_make_provider(chat_with_retry), + workspace=tmp_path, + model="test-model", + ) + + response = await loop._process_message( + InboundMessage(channel="websocket", sender_id="user", chat_id="123", content="set it up") + ) + + assert response is not None + assert response.content == "Install the optional package?" + assert response.buttons == [["Install", "Skip"]] diff --git a/tests/agent/test_runtime_refresh.py b/tests/agent/test_runtime_refresh.py new file mode 100644 index 000000000..a6b19a9d8 --- /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.bus.queue import MessageBus +from nanobot.providers.factory import ProviderSnapshot + + +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_provider_refresh_updates_all_model_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, + provider_snapshot_loader=lambda: ProviderSnapshot( + provider=new_provider, + model="new-model", + context_window_tokens=2000, + signature=("new-model",), + ), + ) + + loop._refresh_provider_snapshot() + + 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 c92c88ba8..b5dc830b4 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) --------------- @@ -178,6 +180,7 @@ async def test_send_delivers_json_message_with_media_and_reply() -> None: content="hello", reply_to="m1", media=["/tmp/a.png"], + buttons=[["Yes", "No"]], ) await channel.send(msg) @@ -185,9 +188,11 @@ async def test_send_delivers_json_message_with_media_and_reply() -> None: payload = json.loads(mock_ws.send.call_args[0][0]) assert payload["event"] == "message" assert payload["chat_id"] == "chat-1" - assert payload["text"] == "hello" + assert payload["text"] == "hello\n\n1. Yes\n2. No" + assert payload["button_prompt"] == "hello" assert payload["reply_to"] == "m1" assert payload["media"] == ["/tmp/a.png"] + assert payload["buttons"] == [["Yes", "No"]] @pytest.mark.asyncio @@ -436,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/tests/cli/test_commands.py b/tests/cli/test_commands.py index 403f53fb3..47b610da0 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -12,6 +12,7 @@ from nanobot.bus.events import OutboundMessage from nanobot.cli.commands import _make_provider, app from nanobot.config.schema import Config from nanobot.cron.types import CronJob, CronPayload +from nanobot.providers.factory import ProviderSnapshot from nanobot.providers.openai_codex_provider import _strip_model_prefix from nanobot.providers.registry import find_by_name @@ -776,6 +777,15 @@ def _stop_gateway_provider(_config) -> object: raise _StopGatewayError("stop") +def _test_provider_snapshot(provider: object, config: Config) -> ProviderSnapshot: + return ProviderSnapshot( + provider=provider, + model=config.agents.defaults.model, + context_window_tokens=config.agents.defaults.context_window_tokens, + signature=("test",), + ) + + def _patch_cli_command_runtime( monkeypatch, config: Config, @@ -788,6 +798,8 @@ def _patch_cli_command_runtime( cron_service=None, get_cron_dir=None, ) -> None: + provider_factory = make_provider or (lambda _config: object()) + monkeypatch.setattr( "nanobot.config.loader.set_config_path", set_config_path or (lambda _path: None), @@ -800,7 +812,15 @@ def _patch_cli_command_runtime( ) monkeypatch.setattr( "nanobot.cli.commands._make_provider", - make_provider or (lambda _config: object()), + provider_factory, + ) + monkeypatch.setattr( + "nanobot.providers.factory.build_provider_snapshot", + lambda _config: _test_provider_snapshot(provider_factory(_config), _config), + ) + monkeypatch.setattr( + "nanobot.providers.factory.load_provider_snapshot", + lambda _config_path=None: _test_provider_snapshot(provider_factory(config), config), ) if message_bus is not None: @@ -941,6 +961,14 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context( monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: provider) + monkeypatch.setattr( + "nanobot.providers.factory.build_provider_snapshot", + lambda _config: _test_provider_snapshot(provider, _config), + ) + monkeypatch.setattr( + "nanobot.providers.factory.load_provider_snapshot", + lambda _config_path=None: _test_provider_snapshot(provider, config), + ) monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: bus) class _FakeSession: @@ -1082,6 +1110,14 @@ def test_gateway_cron_job_suppresses_intermediate_progress( monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object()) + monkeypatch.setattr( + "nanobot.providers.factory.build_provider_snapshot", + lambda _config: _test_provider_snapshot(object(), _config), + ) + monkeypatch.setattr( + "nanobot.providers.factory.load_provider_snapshot", + lambda _config_path=None: _test_provider_snapshot(object(), config), + ) monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: bus) monkeypatch.setattr("nanobot.session.manager.SessionManager", lambda _workspace: object()) diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 43beae9ac..c6ad6f067 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 000000000..c24ff97da --- /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/components/thread/AskUserPrompt.tsx b/webui/src/components/thread/AskUserPrompt.tsx new file mode 100644 index 000000000..3ab20f5e8 --- /dev/null +++ b/webui/src/components/thread/AskUserPrompt.tsx @@ -0,0 +1,108 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { MessageSquareText } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +interface AskUserPromptProps { + question: string; + buttons: string[][]; + onAnswer: (answer: string) => void; +} + +export function AskUserPrompt({ + question, + buttons, + onAnswer, +}: AskUserPromptProps) { + const [customOpen, setCustomOpen] = useState(false); + const [custom, setCustom] = useState(""); + const inputRef = useRef(null); + const options = buttons.flat().filter(Boolean); + + useEffect(() => { + if (customOpen) { + inputRef.current?.focus(); + } + }, [customOpen]); + + const submitCustom = useCallback(() => { + const answer = custom.trim(); + if (!answer) return; + onAnswer(answer); + setCustom(""); + setCustomOpen(false); + }, [custom, onAnswer]); + + if (options.length === 0) return null; + + return ( +
+
+
+ +
+

+ {question} +

+
+ +
+ {options.map((option) => ( + + ))} + +
+ + {customOpen ? ( +
+