feat(webui): add model settings runtime refresh
This commit is contained in:
@@ -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 = (
|
||||
|
||||
@@ -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)))
|
||||
@@ -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."""
|
||||
|
||||
+17
-69
@@ -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)
|
||||
|
||||
+2
-58
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
+35
-10
@@ -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 (
|
||||
<ClientProvider
|
||||
client={state.client}
|
||||
token={state.token}
|
||||
modelName={state.modelName}
|
||||
>
|
||||
<Shell />
|
||||
<Shell onModelNameChange={handleModelNameChange} />
|
||||
</ClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(null);
|
||||
const [view, setView] = useState<ShellView>("chat");
|
||||
const [desktopSidebarOpen, setDesktopSidebarOpen] =
|
||||
useState<boolean>(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() {
|
||||
</Sheet>
|
||||
|
||||
<main className="flex h-full min-w-0 flex-1 flex-col">
|
||||
<ThreadShell
|
||||
session={activeSession}
|
||||
title={headerTitle}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
onGoHome={() => setActiveKey(null)}
|
||||
onNewChat={onNewChat}
|
||||
hideSidebarToggleOnDesktop={desktopSidebarOpen}
|
||||
/>
|
||||
{view === "settings" ? (
|
||||
<SettingsView
|
||||
theme={theme}
|
||||
onToggleTheme={toggle}
|
||||
onBackToChat={() => setView("chat")}
|
||||
onModelNameChange={onModelNameChange}
|
||||
/>
|
||||
) : (
|
||||
<ThreadShell
|
||||
session={activeSession}
|
||||
title={headerTitle}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
onGoHome={() => setActiveKey(null)}
|
||||
onNewChat={onNewChat}
|
||||
hideSidebarToggleOnDesktop={desktopSidebarOpen}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<DeleteConfirm
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Moon, PanelLeftClose, Plus, RefreshCcw, Sun } from "lucide-react";
|
||||
import { Moon, PanelLeftClose, RefreshCcw, Settings, SquarePen, Sun } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ChatList } from "@/components/ChatList";
|
||||
import { ConnectionBadge } from "@/components/ConnectionBadge";
|
||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import type { ChatSummary } from "@/lib/types";
|
||||
@@ -19,48 +18,60 @@ interface SidebarProps {
|
||||
onRefresh: () => void;
|
||||
onRequestDelete: (key: string, label: string) => void;
|
||||
onCollapse: () => void;
|
||||
activeView?: "chat" | "settings";
|
||||
onOpenSettings: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar(props: SidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<aside className="flex h-full w-full flex-col border-r border-sidebar-border/70 bg-sidebar text-sidebar-foreground">
|
||||
<div className="flex items-center justify-between px-2 py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("sidebar.collapse")}
|
||||
onClick={props.onCollapse}
|
||||
className="h-7 w-7 rounded-lg text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||
>
|
||||
<PanelLeftClose className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("sidebar.toggleTheme")}
|
||||
onClick={props.onToggleTheme}
|
||||
className="h-7 w-7 rounded-lg text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||
>
|
||||
{props.theme === "dark" ? (
|
||||
<Sun className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Moon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex items-center justify-between px-3 pb-2 pt-3">
|
||||
<picture className="block min-w-0">
|
||||
<source srcSet="/brand/nanobot_logo.webp" type="image/webp" />
|
||||
<img
|
||||
src="/brand/nanobot_logo.png"
|
||||
alt="nanobot"
|
||||
className="h-7 w-auto select-none object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
</picture>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("sidebar.toggleTheme")}
|
||||
onClick={props.onToggleTheme}
|
||||
className="h-7 w-7 rounded-lg text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||
>
|
||||
{props.theme === "dark" ? (
|
||||
<Sun className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Moon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("sidebar.collapse")}
|
||||
onClick={props.onCollapse}
|
||||
className="h-7 w-7 rounded-lg text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||
>
|
||||
<PanelLeftClose className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-2 pb-2.5">
|
||||
<div className="px-2 pb-2">
|
||||
<Button
|
||||
onClick={props.onNewChat}
|
||||
className="h-8.5 w-full justify-start gap-2 rounded-lg border border-sidebar-border/80 bg-card/25 px-3 text-[13px] font-medium text-sidebar-foreground shadow-none hover:bg-sidebar-accent/80"
|
||||
variant="outline"
|
||||
className="h-9 w-full justify-start gap-2 rounded-full px-3 text-[13px] font-medium text-sidebar-foreground/90 hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||
variant="ghost"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
<SquarePen className="h-3.5 w-3.5" />
|
||||
{t("sidebar.newChat")}
|
||||
</Button>
|
||||
</div>
|
||||
<Separator className="bg-sidebar-border/70" />
|
||||
<div className="flex items-center justify-between px-2.5 py-2 text-[11px] font-medium text-muted-foreground">
|
||||
<div className="flex items-center justify-between px-3 pb-1.5 pt-2.5 text-[11px] font-medium text-muted-foreground">
|
||||
<span>{t("sidebar.recent")}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -81,10 +92,17 @@ export function Sidebar(props: SidebarProps) {
|
||||
onRequestDelete={props.onRequestDelete}
|
||||
/>
|
||||
</div>
|
||||
<Separator className="bg-sidebar-border/70" />
|
||||
<Separator className="bg-sidebar-border/50" />
|
||||
<div className="flex items-center justify-between gap-2 px-2.5 py-2 text-xs">
|
||||
<ConnectionBadge />
|
||||
<LanguageSwitcher />
|
||||
<Button
|
||||
onClick={props.onOpenSettings}
|
||||
className="h-7 gap-1.5 rounded-md px-2 text-[11px] text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||
variant={props.activeView === "settings" ? "secondary" : "ghost"}
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
Settings
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -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<SettingsPayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto bg-background">
|
||||
<main className="mx-auto w-full max-w-[1000px] px-6 py-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBackToChat}
|
||||
className="mb-4 inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
Back to chat
|
||||
</button>
|
||||
|
||||
<h1 className="mb-6 text-base font-semibold tracking-tight">General</h1>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Loading settings...
|
||||
</div>
|
||||
) : error ? (
|
||||
<SettingsGroup>
|
||||
<SettingsRow title="Could not load settings">
|
||||
<span className="max-w-[520px] text-sm text-muted-foreground">{error}</span>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
) : settings ? (
|
||||
<SettingsSection
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
settings={settings}
|
||||
dirty={dirty}
|
||||
saving={saving}
|
||||
onSave={save}
|
||||
/>
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsSection({
|
||||
form,
|
||||
setForm,
|
||||
settings,
|
||||
dirty,
|
||||
saving,
|
||||
onSave,
|
||||
}: {
|
||||
form: {
|
||||
model: string;
|
||||
provider: string;
|
||||
};
|
||||
setForm: React.Dispatch<React.SetStateAction<{
|
||||
model: string;
|
||||
provider: string;
|
||||
}>>;
|
||||
settings: SettingsPayload;
|
||||
dirty: boolean;
|
||||
saving: boolean;
|
||||
onSave: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-7">
|
||||
<section>
|
||||
<h2 className="mb-2 px-2 text-xs font-medium text-muted-foreground">AI</h2>
|
||||
<SettingsGroup>
|
||||
<SettingsRow title="Provider">
|
||||
<select
|
||||
value={form.provider}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, provider: event.target.value }))}
|
||||
className={cn(
|
||||
"h-8 w-[210px] rounded-md border border-input bg-background px-2 text-sm",
|
||||
"outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
>
|
||||
{settings.providers.map((provider) => (
|
||||
<option key={provider.name} value={provider.name}>
|
||||
{provider.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow title="Model">
|
||||
<Input
|
||||
value={form.model}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, model: event.target.value }))}
|
||||
className="h-8 w-[280px]"
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
{(dirty || saving || settings.requires_restart) ? (
|
||||
<SettingsFooter
|
||||
dirty={dirty}
|
||||
saving={saving}
|
||||
saved={settings.requires_restart && !dirty}
|
||||
onSave={onSave}
|
||||
/>
|
||||
) : null}
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-2 px-2 text-xs font-medium text-muted-foreground">Interface</h2>
|
||||
<SettingsGroup>
|
||||
<SettingsRow title="Language">
|
||||
<LanguageSwitcher />
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsGroup({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-border/60 bg-card/80">
|
||||
<div className="divide-y divide-border/50">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsRow({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-[52px] flex-col gap-3 px-3 py-2.5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium leading-5">{title}</div>
|
||||
</div>
|
||||
{children ? <div className="shrink-0 sm:ml-6">{children}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsFooter({
|
||||
dirty,
|
||||
saving,
|
||||
saved,
|
||||
onSave,
|
||||
}: {
|
||||
dirty: boolean;
|
||||
saving: boolean;
|
||||
saved: boolean;
|
||||
onSave: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-[52px] items-center justify-between gap-4 px-3 py-2.5">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{saved ? "Saved. Restart nanobot to apply." : "Unsaved changes."}
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={onSave} disabled={!dirty || saving}>
|
||||
{saving ? "Saving" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+19
-1
@@ -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<SettingsPayload> {
|
||||
return request<SettingsPayload>(`${base}/api/settings`, token);
|
||||
}
|
||||
|
||||
export async function updateSettings(
|
||||
token: string,
|
||||
update: SettingsUpdate,
|
||||
base: string = "",
|
||||
): Promise<SettingsPayload> {
|
||||
const query = new URLSearchParams();
|
||||
if (update.model !== undefined) query.set("model", update.model);
|
||||
if (update.provider !== undefined) query.set("provider", update.provider);
|
||||
return request<SettingsPayload>(`${base}/api/settings/update?${query}`, token);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(<App />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user