Compare commits

...
76 changed files with 23882 additions and 17348 deletions
+41 -4
View File
@@ -1971,15 +1971,52 @@ Add MCP servers to your `config.json`:
}
```
Two transport modes are supported:
MCP servers can run locally over stdio or connect remotely over HTTP:
| Mode | Config | Example |
| Connection | Config | Example |
|------|--------|---------|
| **Stdio** | `command` + `args` | Local process via `npx` / `uvx` |
| **HTTP** | `url` + `headers` (optional) | Remote endpoint (`https://mcp.example.com/sse`) |
| **Streamable HTTP / SSE** | `url` + `headers` (optional) | Remote endpoint (`https://mcp.example.com/mcp`) |
Remote HTTP servers may use browser OAuth instead of static headers. In the
WebUI, open **Apps → MCP → Add MCP server**, choose **Custom**, select HTTP or
SSE, and choose **OAuth** under **Authentication**. Save the server, then choose
**Connect**. For manual configuration, add `auth: "oauth"` and open
**Apps → MCP** to connect. Known presets such as Xmind, Notion, and Linear add
the config automatically on first click.
```json
{
"tools": {
"mcpServers": {
"notion": {
"type": "streamableHttp",
"url": "https://mcp.notion.com/mcp",
"auth": "oauth"
}
}
}
}
```
nanobot opens the server's authorization page and handles the callback through
the gateway. The tools become available immediately when hot reload succeeds;
otherwise the WebUI asks for a restart. OAuth tokens and dynamic client
registration data are stored in the nanobot data directory under
`auth/mcp.json`; they are not written to `config.json`. Removing the MCP server
from Apps also removes its saved OAuth credentials. Normal gateway startup never
opens a browser or registers a new OAuth client when credentials are
missing—interactive authorization starts only after a user clicks **Connect**.
For a remotely accessed WebUI, HTTPS is recommended. Configure
`channels.websocket.publicWsUrl` with the browser-facing `wss://` endpoint so
nanobot can register the matching HTTPS callback and finish automatically. A
loopback WebUI may use HTTP. When a remote WebUI is served over plain HTTP,
nanobot instead registers a localhost callback and asks you to paste the complete
callback URL from the browser address bar after authorization.
> [!IMPORTANT]
> HTTP/SSE MCP URLs are validated before probing or connecting, and every outgoing MCP HTTP request is validated again before redirects are followed. `localhost`, `127.0.0.1`, RFC1918/private IPs, CGNAT/Tailscale ranges, link-local addresses, and cloud metadata endpoints are blocked by default. This can break previously working local or private HTTP MCP configs until the endpoint is explicitly allowed with `tools.ssrfWhitelist`, preferably with a single-host CIDR such as `127.0.0.1/32`, `::1/128`, or `192.168.1.50/32`. Stdio MCP servers are not affected.
> HTTP/SSE MCP URLs are validated before probing or connecting, and every outgoing MCP HTTP request—including OAuth metadata, client registration, token exchange, and redirects—is validated again. `localhost`, `127.0.0.1`, RFC1918/private IPs, CGNAT/Tailscale ranges, link-local addresses, and cloud metadata endpoints are blocked by default. This can break previously working local or private HTTP MCP configs until the endpoint is explicitly allowed with `tools.ssrfWhitelist`, preferably with a single-host CIDR such as `127.0.0.1/32`, `::1/128`, or `192.168.1.50/32`. Stdio MCP servers are not affected.
Use `toolTimeout` to override the default 30s per-call timeout for slow servers:
+12 -3
View File
@@ -30,10 +30,15 @@ remote HTTP endpoint.
For local interactive setup:
1. Run `nanobot webui` and open **Apps**.
2. Choose a known integration preset, or add a custom stdio, HTTP, or SSE server.
2. Choose a known MCP server preset, or add a custom stdio, HTTP, or SSE server.
For a custom OAuth server, choose **OAuth** under **Authentication**, save it,
and click **Connect**. Presets such as Xmind, Notion, and Linear go straight to
**Connect**. Approve access in the browser window. HTTPS and localhost WebUIs
return automatically. From a remote plain-HTTP WebUI, copy the complete
localhost callback URL from the browser address bar and paste it into nanobot.
3. Limit the enabled tools when the server exposes more than the task needs.
4. Save and restart when prompted.
5. Mention the integration with `@` in the next message and ask for a small test action.
5. Mention the connected MCP server with `@` in the next message and ask for a small test action.
For manual or deployment-managed config, add this to `~/.nanobot/config.json`:
@@ -58,12 +63,16 @@ Restart nanobot and ask a question that requires the MCP tool.
- Prefer `enabledTools` over exposing every tool by default.
- Use `toolTimeout` for slow MCP operations.
- Use HTTP MCP only for endpoints you trust.
- For deployment-managed OAuth servers, set `auth` to `oauth` and complete the
browser connection from **Apps → MCP**.
- Keep MCP server commands stable and versioned in deployment docs or scripts.
## Security notes
- Stdio MCP starts a local process; review the command before enabling it.
- HTTP/SSE MCP uses nanobot's SSRF guard.
- HTTP/SSE MCP uses nanobot's SSRF guard, including OAuth discovery, registration,
token exchange, and redirects.
- OAuth credentials live in the nanobot data directory, not in `config.json`.
- Allow private HTTP MCP hosts only with narrow `tools.ssrfWhitelist` CIDRs.
- Do not place secrets in command arguments when environment variables or
headers can be used.
+9 -4
View File
@@ -204,8 +204,13 @@ turn. The default **Ready** view shows only tools that can be used immediately:
- **Apps** are local command-line adapters that nanobot runs on your machine.
Installing an adapter does not modify the native desktop or web app it
connects to.
- **Integrations** are MCP servers. Presets provide known configurations, and
the custom integration panel accepts stdio, HTTP, and SSE servers.
- **MCP** lists Model Context Protocol servers. Presets provide known
configurations, and the **Add MCP server** panel accepts stdio, HTTP, and SSE
servers. Custom HTTP/SSE servers can use no authentication, OAuth, or request
headers. After saving an OAuth server, choose **Connect** to open its sign-in
page. Presets such as Xmind, Notion, and Linear already use OAuth. HTTPS and
localhost WebUIs return automatically; a remote plain-HTTP WebUI shows one
field for pasting the complete localhost callback URL.
Apps intentionally does not list nanobot runtime support packages such as
`api` or `bedrock`. Those packages enable providers, servers, or channels; they
@@ -226,8 +231,8 @@ endpoint and exposes `web_search` and `web_fetch` without requiring an API key.
It is an optional integration and does not replace nanobot's built-in web search
provider; mention `@parallel-search` when a turn should use it.
After an App or integration is available, mention it from the composer with
`@` to attach that tool to the next message.
After an App or MCP server is available, mention it from the composer with `@`
to attach that tool to the next message.
## Skills
+6 -2
View File
@@ -827,7 +827,8 @@ class EditFileTool(_FsTool):
def description(self) -> str:
return (
"Perform a small, exact replacement in one file by replacing "
"old_text with new_text. Use this for narrow text substitutions "
"old_text with new_text. When replacing text in an existing file, "
"old_text and new_text must be different. Use this for narrow text substitutions "
"with old_text copied from read_file. For multi-file, structural, "
"or generated code edits, prefer apply_patch. If old_text matches "
"multiple times, provide more context or set occurrence, line_hint, "
@@ -862,9 +863,12 @@ class EditFileTool(_FsTool):
return ToolResult.error("Error: expected_replacements must be >= 1.")
fp = self._resolve_write(path)
file_exists = fp.exists()
if file_exists and old_text == new_text:
return ToolResult.error("Error: new_text must be different from old_text.")
# Create-file semantics: old_text='' + file doesn't exist → create
if not fp.exists():
if not file_exists:
if old_text == "":
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(new_text, encoding="utf-8")
+78 -13
View File
@@ -38,6 +38,7 @@ if TYPE_CHECKING:
from mcp.types import Prompt, Resource
from mcp.types import Tool as MCPToolDefinition
from nanobot.agent.tools.mcp_oauth import MCPOAuthHandlers
from nanobot.config.schema import MCPServerConfig
# Transient connection errors that warrant a single retry.
@@ -184,6 +185,25 @@ def _is_transient(exc: BaseException) -> bool:
return type(exc).__name__ in _TRANSIENT_EXC_NAMES
def _is_transient_connection_failure(exc: BaseException) -> bool:
if isinstance(exc, BaseExceptionGroup):
group = cast(BaseExceptionGroup[BaseException], exc)
return bool(group.exceptions) and all(
_is_transient_connection_failure(nested) for nested in group.exceptions
)
return isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout)) or _is_transient(exc)
def _log_mcp_connection_failure(name: str, exc: BaseException, hint: str = "") -> None:
if _is_transient_connection_failure(exc):
logger.warning("MCP server '{}': transient connection failure", name)
logger.opt(exception=exc).debug(
"MCP server '{}' transient connection failure details", name
)
return
logger.opt(exception=exc).error("MCP server '{}': failed to connect: {}", name, hint)
def _is_session_terminated(exc: BaseException) -> bool:
"""Return True when the MCP SDK reports a dead client session."""
if _is_transient(exc):
@@ -961,7 +981,10 @@ class MCPPromptWrapper(_MCPWrapperBase):
async def connect_mcp_servers(
mcp_servers: "dict[str, MCPServerConfig]", registry: ToolRegistry
mcp_servers: "dict[str, MCPServerConfig]",
registry: ToolRegistry,
*,
oauth_handlers: Mapping[str, "MCPOAuthHandlers"] | None = None,
) -> dict[str, MCPConnection]:
"""Connect to configured MCP servers and register their tools, resources, prompts.
@@ -1001,6 +1024,29 @@ async def connect_mcp_servers(
)
return False
oauth_auth: httpx.Auth | None = None
if cfg.auth == "oauth":
if transport_type not in {"sse", "streamableHttp"}:
logger.warning(
"MCP server '{}': OAuth requires an SSE or Streamable HTTP transport",
name,
)
return False
from nanobot.agent.tools.mcp_oauth import (
MCPAuthorizationRequiredError,
create_mcp_oauth_auth,
)
try:
oauth_auth = await create_mcp_oauth_auth(
name,
cfg.url,
(oauth_handlers or {}).get(name),
)
except MCPAuthorizationRequiredError:
logger.info("MCP server '{}': waiting for browser authorization", name)
return False
if transport_type == "stdio":
command, args, env = _normalize_windows_stdio_command(
cfg.command,
@@ -1038,22 +1084,30 @@ async def connect_mcp_servers(
**_pinned_transport_kwargs(),
)
sse_kwargs: dict[str, Any] = {
"httpx_client_factory": httpx_client_factory,
}
if oauth_auth is not None:
sse_kwargs["auth"] = oauth_auth
read, write = await server_stack.enter_async_context(
sse_client(cfg.url, httpx_client_factory=httpx_client_factory)
sse_client(cfg.url, **sse_kwargs)
)
elif transport_type == "streamableHttp":
if not await _probe_http_url(cfg.url):
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
return False
http_client_kwargs: dict[str, Any] = {
"headers": cfg.headers or None,
"event_hooks": {"request": [_validate_mcp_request_url]},
"follow_redirects": True,
"timeout": httpx.Timeout(30.0, connect=10.0),
**_pinned_transport_kwargs(),
}
if oauth_auth is not None:
http_client_kwargs["auth"] = oauth_auth
http_client = await server_stack.enter_async_context(
httpx.AsyncClient(
headers=cfg.headers or None,
event_hooks={"request": [_validate_mcp_request_url]},
follow_redirects=True,
timeout=httpx.Timeout(30.0, connect=10.0),
**_pinned_transport_kwargs(),
)
httpx.AsyncClient(**http_client_kwargs)
)
read, write, _ = await server_stack.enter_async_context(
streamable_http_client(cfg.url, http_client=http_client)
@@ -1182,7 +1236,7 @@ async def connect_mcp_servers(
" Hint: this looks like stdio protocol pollution. Make sure the MCP server writes "
"only JSON-RPC to stdout and sends logs/debug output to stderr instead."
)
logger.exception("MCP server '{}': failed to connect: {}", name, hint)
_log_mcp_connection_failure(name, e, hint)
return False
async def connect_single_server(
@@ -1229,7 +1283,7 @@ async def connect_mcp_servers(
try:
result = await connect_single_server(name, cfg)
except Exception as e:
logger.exception("MCP server '{}' connection failed: {}", name, e)
_log_mcp_connection_failure(name, e)
continue
if result[1] is not None:
server_stacks[result[0]] = result[1]
@@ -1302,6 +1356,13 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
current_servers = dict(state._mcp_servers)
current_names = set(current_servers)
next_names = set(next_servers)
from nanobot.agent.tools.mcp_oauth import mcp_oauth_has_credentials
authorization_pending = {
name
for name, cfg in next_servers.items()
if cfg.auth == "oauth" and not mcp_oauth_has_credentials(name, cfg.url)
}
removed = sorted(current_names - next_names)
added = sorted(next_names - current_names)
changed = sorted(
@@ -1319,9 +1380,13 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
retry_missing = sorted(
name
for name in next_names
if name not in state._mcp_stacks and name not in set(added) | set(changed)
if name not in state._mcp_stacks
and name not in set(added) | set(changed)
and name not in authorization_pending
)
to_connect_names = sorted(
(set(added) | set(changed) | set(retry_missing)) - authorization_pending
)
to_connect_names = sorted(set(added) | set(changed) | set(retry_missing))
to_connect = {name: next_servers[name] for name in to_connect_names}
connected: dict[str, MCPConnection] = {}
if to_connect:
+401
View File
@@ -0,0 +1,401 @@
"""OAuth support for remote MCP servers.
This module intentionally owns MCP OAuth end to end. Provider OAuth has a
different lifecycle and storage contract, so sharing a higher-level workflow
would couple unrelated extension boundaries.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import os
import secrets
from collections.abc import Awaitable, Callable
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import Any, TypedDict, cast
from filelock import FileLock
from loguru import logger
from mcp.client.auth import OAuthClientProvider
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
from pydantic import AnyHttpUrl, AnyUrl
from nanobot.config.paths import get_data_dir
from nanobot.utils.helpers import _write_text_atomic # pyright: ignore[reportPrivateUsage]
MCP_OAUTH_CALLBACK_PATH = "/auth/mcp/callback"
_STORE_VERSION = 1
_STORE_LOCK_TIMEOUT_S = 15
_DEFAULT_REDIRECT_URI = f"http://127.0.0.1{MCP_OAUTH_CALLBACK_PATH}"
_CLIENT_URI = AnyHttpUrl("https://github.com/HKUDS/nanobot")
_LOGO_URI = AnyHttpUrl(
"https://raw.githubusercontent.com/HKUDS/nanobot/main/"
"webui/public/brand/nanobot_apple_touch.png"
)
class _StoredServer(TypedDict, total=False):
server_fingerprint: str
write_lease: str
tokens: dict[str, Any]
client_info: dict[str, Any]
redirect_uri: str
class _CredentialStore(TypedDict):
version: int
servers: dict[str, _StoredServer]
generations: dict[str, str]
class MCPAuthorizationRequiredError(RuntimeError):
"""Raised when a background MCP connection needs interactive authorization."""
@dataclass(frozen=True)
class MCPOAuthHandlers:
"""Browser callbacks supplied only for a user-initiated OAuth attempt."""
redirect_uri: str
redirect_handler: Callable[[str], Awaitable[None]]
callback_handler: Callable[[], Awaitable[tuple[str, str | None]]]
reset_credentials: bool = False
def _store_path() -> Path:
return get_data_dir() / "auth" / "mcp.json"
def _server_fingerprint(server_url: str) -> str:
return hashlib.sha256(server_url.strip().encode("utf-8")).hexdigest()
def _empty_store() -> _CredentialStore:
return {"version": _STORE_VERSION, "servers": {}, "generations": {}}
def _stored_server(value: object) -> _StoredServer | None:
if not isinstance(value, dict):
return None
raw = cast(dict[object, object], value)
entry: _StoredServer = {}
fingerprint = raw.get("server_fingerprint")
if isinstance(fingerprint, str):
entry["server_fingerprint"] = fingerprint
write_lease = raw.get("write_lease")
if isinstance(write_lease, str) and write_lease:
entry["write_lease"] = write_lease
redirect_uri = raw.get("redirect_uri")
if isinstance(redirect_uri, str):
entry["redirect_uri"] = redirect_uri
tokens = raw.get("tokens")
if isinstance(tokens, dict):
token_values = cast(dict[object, object], tokens)
if all(isinstance(key, str) for key in token_values):
entry["tokens"] = cast(dict[str, Any], token_values)
client_info = raw.get("client_info")
if isinstance(client_info, dict):
client_values = cast(dict[object, object], client_info)
if all(isinstance(key, str) for key in client_values):
entry["client_info"] = cast(dict[str, Any], client_values)
return entry
def _read_store_unlocked(path: Path) -> _CredentialStore:
try:
raw = cast(object, json.loads(path.read_text(encoding="utf-8")))
except FileNotFoundError:
return _empty_store()
except (OSError, ValueError, TypeError) as exc:
logger.warning("Could not read MCP OAuth credentials: {}", type(exc).__name__)
return _empty_store()
if not isinstance(raw, dict):
return _empty_store()
payload = cast(dict[object, object], raw)
raw_servers = payload.get("servers")
if not isinstance(raw_servers, dict):
return _empty_store()
servers: dict[str, _StoredServer] = {}
for name, value in cast(dict[object, object], raw_servers).items():
entry = _stored_server(value)
if isinstance(name, str) and entry is not None:
servers[name] = entry
generations: dict[str, str] = {}
raw_generations = payload.get("generations")
if isinstance(raw_generations, dict):
for name, value in cast(dict[object, object], raw_generations).items():
if isinstance(name, str) and isinstance(value, str) and value:
generations[name] = value
return {
"version": _STORE_VERSION,
"servers": servers,
"generations": generations,
}
def _with_store_lock(path: Path) -> FileLock:
path.parent.mkdir(parents=True, exist_ok=True)
return FileLock(str(path.with_suffix(".lock")), timeout=_STORE_LOCK_TIMEOUT_S)
def _write_store_unlocked(path: Path, payload: _CredentialStore) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with suppress(OSError):
os.chmod(path.parent, 0o700)
_write_text_atomic(path, json.dumps(payload, indent=2, ensure_ascii=False))
with suppress(OSError):
os.chmod(path, 0o600)
class MCPOAuthStorage:
"""Persistent MCP SDK token storage, isolated by config name and server URL."""
def __init__(self, server_name: str, server_url: str) -> None:
self.server_name = server_name
self.server_fingerprint = _server_fingerprint(server_url)
self._observed_generation = self._read_generation_sync()
self._write_lease: str | None = None
def _read_generation_sync(self) -> str | None:
path = _store_path()
if not path.exists():
return None
# Writes replace the whole file atomically, so this observes either side
# of a concurrent deletion without blocking the async connection path.
return _read_store_unlocked(path)["generations"].get(self.server_name)
def _generation_is_current(self, payload: _CredentialStore) -> bool:
return payload["generations"].get(self.server_name) == self._observed_generation
def _entry_unlocked(self, payload: _CredentialStore) -> _StoredServer | None:
servers = payload["servers"]
entry = servers.get(self.server_name)
if entry is None or entry.get("server_fingerprint") != self.server_fingerprint:
return None
return entry
def _bind_entry_unlocked(
self,
payload: _CredentialStore,
*,
create: bool,
) -> tuple[_StoredServer | None, bool]:
if not self._generation_is_current(payload):
return None, False
entry = self._entry_unlocked(payload)
if self._write_lease is not None:
if entry is None or entry.get("write_lease") != self._write_lease:
return None, False
return entry, False
if entry is None:
if not create:
return None, False
self._write_lease = secrets.token_urlsafe(24)
entry = _StoredServer(
server_fingerprint=self.server_fingerprint,
write_lease=self._write_lease,
)
payload["servers"][self.server_name] = entry
return entry, True
write_lease = entry.get("write_lease")
changed = not isinstance(write_lease, str) or not write_lease
if changed:
write_lease = secrets.token_urlsafe(24)
entry["write_lease"] = write_lease
self._write_lease = write_lease
return entry, changed
def _read_entry_sync(self) -> _StoredServer | None:
path = _store_path()
with _with_store_lock(path):
payload = _read_store_unlocked(path)
entry, changed = self._bind_entry_unlocked(payload, create=False)
if changed:
_write_store_unlocked(path, payload)
return entry
def _update_entry_sync(
self,
update: Callable[[_StoredServer], None],
*,
create: bool = True,
claim: bool = False,
) -> bool:
path = _store_path()
with _with_store_lock(path):
payload = _read_store_unlocked(path)
if claim:
# A browser flow owns subsequent SDK writes until another flow
# claims the entry or the configured server is removed.
if not self._generation_is_current(payload):
logger.info(
"Ignored stale MCP OAuth credential claim for '{}'",
self.server_name,
)
return False
entry = self._entry_unlocked(payload)
if entry is None:
entry = _StoredServer(server_fingerprint=self.server_fingerprint)
payload["servers"][self.server_name] = entry
self._write_lease = secrets.token_urlsafe(24)
entry["write_lease"] = self._write_lease
else:
entry, _ = self._bind_entry_unlocked(payload, create=create)
if entry is None:
if self._write_lease is not None:
logger.info(
"Ignored stale MCP OAuth credential update for '{}'",
self.server_name,
)
return False
update(entry)
payload["version"] = _STORE_VERSION
_write_store_unlocked(path, payload)
return True
async def get_tokens(self) -> OAuthToken | None:
entry = await asyncio.to_thread(self._read_entry_sync)
raw = entry.get("tokens") if entry is not None else None
if not isinstance(raw, dict):
return None
try:
return OAuthToken.model_validate(raw)
except (ValueError, TypeError):
logger.warning("Ignoring invalid MCP OAuth tokens for '{}'", self.server_name)
return None
async def set_tokens(self, tokens: OAuthToken) -> None:
raw = tokens.model_dump(mode="json", exclude_none=True)
def update(entry: _StoredServer) -> None:
entry["tokens"] = raw
await asyncio.to_thread(self._update_entry_sync, update)
async def clear_tokens(self) -> None:
def update(entry: _StoredServer) -> None:
entry.pop("tokens", None)
await asyncio.to_thread(self._update_entry_sync, update, create=False)
async def get_client_info(self) -> OAuthClientInformationFull | None:
entry = await asyncio.to_thread(self._read_entry_sync)
raw = entry.get("client_info") if entry is not None else None
if not isinstance(raw, dict):
return None
try:
return OAuthClientInformationFull.model_validate(raw)
except (ValueError, TypeError):
logger.warning("Ignoring invalid MCP OAuth client info for '{}'", self.server_name)
return None
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
raw = client_info.model_dump(mode="json", exclude_none=True)
def update(entry: _StoredServer) -> None:
entry["client_info"] = raw
await asyncio.to_thread(self._update_entry_sync, update)
async def redirect_uri(self) -> str | None:
entry = await asyncio.to_thread(self._read_entry_sync)
value = entry.get("redirect_uri") if entry is not None else None
return value if isinstance(value, str) and value else None
async def prepare_redirect_uri(self, redirect_uri: str, *, reset: bool = False) -> None:
def update(entry: _StoredServer) -> None:
changed = entry.get("redirect_uri") != redirect_uri
if reset:
entry.pop("tokens", None)
entry.pop("client_info", None)
elif changed:
# Dynamic registrations bind a client to its redirect URI.
entry.pop("client_info", None)
entry["redirect_uri"] = redirect_uri
claimed = await asyncio.to_thread(self._update_entry_sync, update, claim=True)
if not claimed:
raise MCPAuthorizationRequiredError("MCP authorization was cancelled")
def has_credentials(self) -> bool:
entry = self._read_entry_sync()
raw_tokens = entry.get("tokens") if entry is not None else None
if not isinstance(raw_tokens, dict):
return False
tokens = cast(dict[str, object], raw_tokens)
access_token = tokens.get("access_token")
return isinstance(access_token, str) and bool(access_token)
async def _missing_callback() -> tuple[str, str | None]:
raise MCPAuthorizationRequiredError("MCP server requires browser authorization")
async def create_mcp_oauth_auth(
server_name: str,
server_url: str,
handlers: MCPOAuthHandlers | None = None,
) -> OAuthClientProvider:
"""Build the official MCP SDK OAuth provider for one configured server."""
storage = MCPOAuthStorage(server_name, server_url)
if handlers is not None:
await storage.prepare_redirect_uri(
handlers.redirect_uri,
reset=handlers.reset_credentials,
)
redirect_uri = handlers.redirect_uri
redirect_handler = handlers.redirect_handler
callback_handler = handlers.callback_handler
else:
if not await asyncio.to_thread(storage.has_credentials):
# Do not perform discovery or dynamic registration from a background
# startup. Interactive OAuth begins only after an explicit user action.
raise MCPAuthorizationRequiredError("MCP server requires browser authorization")
redirect_uri = await storage.redirect_uri() or _DEFAULT_REDIRECT_URI
async def authorization_required(_authorization_url: str) -> None:
await storage.clear_tokens()
raise MCPAuthorizationRequiredError("MCP server requires browser authorization")
redirect_handler = authorization_required
callback_handler = _missing_callback
metadata = OAuthClientMetadata(
redirect_uris=[AnyUrl(redirect_uri)],
token_endpoint_auth_method="none",
client_name="nanobot",
client_uri=_CLIENT_URI,
logo_uri=_LOGO_URI,
software_id="https://github.com/HKUDS/nanobot",
)
return OAuthClientProvider(
server_url,
metadata,
storage,
redirect_handler=redirect_handler,
callback_handler=callback_handler,
timeout=300,
)
def mcp_oauth_has_credentials(server_name: str, server_url: str) -> bool:
"""Return whether this exact configured MCP instance has an access token."""
return MCPOAuthStorage(server_name, server_url).has_credentials()
def delete_mcp_oauth_credentials(server_name: str) -> bool:
"""Delete credentials for one config name without touching other MCP instances."""
path = _store_path()
with _with_store_lock(path):
payload = _read_store_unlocked(path)
servers = payload["servers"]
removed = servers.pop(server_name, None) is not None
# Rotate even when no entry exists so a flow created before removal cannot
# claim the name later and resurrect credentials.
payload["generations"][server_name] = secrets.token_urlsafe(24)
_write_store_unlocked(path, payload)
return removed
+1
View File
@@ -373,6 +373,7 @@ class MCPServerConfig(Base):
"""MCP server connection configuration (stdio or HTTP)."""
type: Literal["stdio", "sse", "streamableHttp"] | None = None # auto-detected if omitted
auth: Literal["oauth"] | None = None # Remote MCP OAuth; tokens are stored outside config
command: str = "" # Stdio: command to run (e.g. "npx")
args: list[str] = Field(default_factory=list) # Stdio: command arguments
env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars
+415
View File
@@ -0,0 +1,415 @@
"""Gateway-owned browser authorization flows for remote MCP servers."""
from __future__ import annotations
import asyncio
import re
import secrets
import time
from collections.abc import Awaitable, Callable
from contextlib import suppress
from dataclasses import dataclass, field
from typing import Any
from urllib.parse import SplitResult, parse_qs, urlsplit, urlunsplit
from nanobot.agent.tools.mcp import MCPConnection, connect_mcp_servers
from nanobot.agent.tools.mcp_oauth import MCP_OAUTH_CALLBACK_PATH, MCPOAuthHandlers
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.config.schema import MCPServerConfig
from nanobot.security.network import validate_url_target
from nanobot.webui.http_utils import is_loopback_host
McpReload = Callable[[], Awaitable[dict[str, Any]]]
_FLOW_TTL_S = 300
_START_WAIT_S = 20
_OAUTH_ERROR_RE = re.compile(r"^[a-zA-Z0-9_.-]{1,80}$")
class McpOAuthError(Exception):
"""Safe WebUI error for an MCP OAuth request."""
def __init__(self, message: str, status: int = 400) -> None:
super().__init__(message)
self.message = message
self.status = status
class _OAuthCallbackError(RuntimeError):
pass
@dataclass
class _McpOAuthFlow:
flow_id: str
name: str
cfg: MCPServerConfig
redirect_uri: str
manual_callback: bool
expires_at: float
authorization_ready: asyncio.Event = field(default_factory=asyncio.Event)
callback_result: asyncio.Future[tuple[str, str | None]] | None = None
task: asyncio.Task[bool] | None = None
authorization_url: str | None = None
state: str | None = None
callback_received: bool = False
error: str | None = None
reload_result: dict[str, Any] | None = None
def _parse_mcp_oauth_redirect_uri(redirect_uri: str) -> tuple[str, SplitResult, int | None]:
cleaned = redirect_uri.strip()
parsed = urlsplit(cleaned)
try:
port = parsed.port
except ValueError as exc:
raise McpOAuthError("Invalid MCP OAuth callback URL") from exc
if (
not parsed.netloc
or not parsed.hostname
or parsed.path != MCP_OAUTH_CALLBACK_PATH
or parsed.query
or parsed.fragment
or parsed.username is not None
or parsed.password is not None
):
raise McpOAuthError("Invalid MCP OAuth callback URL")
return cleaned, parsed, port
def validate_mcp_oauth_redirect_uri(redirect_uri: str) -> str:
"""Allow HTTPS callbacks, plus loopback HTTP for a local gateway."""
cleaned, parsed, _port = _parse_mcp_oauth_redirect_uri(redirect_uri)
if parsed.scheme == "https":
return cleaned
if parsed.scheme == "http" and is_loopback_host(parsed.netloc):
return cleaned
raise McpOAuthError("MCP OAuth callbacks must use HTTPS or localhost")
def prepare_mcp_oauth_redirect_uri(redirect_uri: str) -> tuple[str, bool]:
"""Use a pasteable loopback callback when a remote WebUI is served over HTTP."""
cleaned, parsed, port = _parse_mcp_oauth_redirect_uri(redirect_uri)
if parsed.scheme != "http" or is_loopback_host(parsed.netloc):
return validate_mcp_oauth_redirect_uri(cleaned), False
loopback = "127.0.0.1" if port is None else f"127.0.0.1:{port}"
manual_redirect_uri = urlunsplit(("http", loopback, parsed.path, "", ""))
return validate_mcp_oauth_redirect_uri(manual_redirect_uri), True
class McpOAuthManager:
"""Own short-lived browser flows while the gateway process is running."""
def __init__(self) -> None:
self._flows: dict[str, _McpOAuthFlow] = {}
self._states: dict[str, str] = {}
async def start(
self,
name: str,
cfg: MCPServerConfig,
redirect_uri: str,
*,
reload_mcp: McpReload,
reset_credentials: bool = False,
) -> dict[str, Any]:
self._prune()
redirect_uri, manual_callback = prepare_mcp_oauth_redirect_uri(redirect_uri)
await self._cancel_name(name)
loop = asyncio.get_running_loop()
now = time.monotonic()
flow = _McpOAuthFlow(
flow_id=secrets.token_urlsafe(24),
name=name,
cfg=cfg,
redirect_uri=redirect_uri,
manual_callback=manual_callback,
expires_at=now + _FLOW_TTL_S,
callback_result=loop.create_future(),
)
self._flows[flow.flow_id] = flow
handlers = MCPOAuthHandlers(
redirect_uri=redirect_uri,
redirect_handler=lambda url: self._receive_authorization_url(flow, url),
callback_handler=lambda: self._wait_for_callback(flow),
reset_credentials=reset_credentials,
)
flow.task = asyncio.create_task(
self._connect_and_reload(flow, handlers, reload_mcp),
name=f"mcp-oauth:{name}",
)
ready_waiter = asyncio.create_task(flow.authorization_ready.wait())
try:
await asyncio.wait(
{ready_waiter, flow.task},
timeout=_START_WAIT_S,
return_when=asyncio.FIRST_COMPLETED,
)
finally:
ready_waiter.cancel()
with suppress(asyncio.CancelledError):
await ready_waiter
return self._payload(flow)
async def status(self, flow_id: str) -> dict[str, Any]:
self._prune()
flow = self._flow(flow_id)
return self._payload(flow)
def submit_callback(
self,
*,
state: str,
code: str | None,
error: str | None,
) -> str:
self._prune()
flow_id = self._states.pop(state, None)
if flow_id is None:
raise McpOAuthError("This MCP authorization request has expired", status=410)
flow = self._flow(flow_id)
callback_result = flow.callback_result
if callback_result is None or callback_result.done():
raise McpOAuthError("This MCP authorization callback was already used", status=409)
flow.callback_received = True
if error:
safe_error = error if _OAUTH_ERROR_RE.fullmatch(error) else "authorization_failed"
flow.error = f"Authorization was not completed ({safe_error})."
callback_result.set_exception(_OAuthCallbackError(flow.error))
raise McpOAuthError(flow.error)
elif not code or len(code) > 8192:
flow.error = "The MCP server did not return an authorization code."
callback_result.set_exception(_OAuthCallbackError(flow.error))
raise McpOAuthError(flow.error)
else:
callback_result.set_result((code, state))
return flow.name
def submit_callback_url(self, *, flow_id: str, callback_url: str) -> dict[str, Any]:
"""Complete a flow from a full browser callback URL pasted into the WebUI."""
self._prune()
flow = self._flow(flow_id)
parsed = urlsplit(callback_url.strip())
expected = urlsplit(flow.redirect_uri)
if (
not parsed.query
or parsed.fragment
or parsed.username is not None
or parsed.password is not None
or parsed.scheme != expected.scheme
or parsed.netloc != expected.netloc
or parsed.path != expected.path
):
raise McpOAuthError(
"Paste the complete callback URL from the browser address bar."
)
try:
query = parse_qs(parsed.query, keep_blank_values=True, max_num_fields=16)
except ValueError as exc:
raise McpOAuthError(
"Paste the complete callback URL from the browser address bar."
) from exc
states = query.get("state", [])
state = states[0] if len(states) == 1 else ""
if not state or state != flow.state:
raise McpOAuthError(
"This callback belongs to a different or expired authorization request. "
"Start again.",
status=410,
)
codes = query.get("code", [])
errors = query.get("error", [])
if len(codes) > 1 or len(errors) > 1 or (codes and errors):
raise McpOAuthError(
"Paste the complete callback URL from the browser address bar."
)
code = codes[0] if len(codes) == 1 else None
error = errors[0] if len(errors) == 1 else None
if (not code and not error) or (code is not None and len(code) > 8192):
raise McpOAuthError(
"Paste the complete callback URL from the browser address bar."
)
self.submit_callback(state=state, code=code, error=error)
return self._payload(flow)
async def cancel(self, flow_id: str) -> dict[str, Any]:
self._prune()
flow = self._flow(flow_id)
await self._cancel_flow(flow)
return self._payload(flow)
async def _receive_authorization_url(
self,
flow: _McpOAuthFlow,
authorization_url: str,
) -> None:
parsed = urlsplit(authorization_url)
if (
parsed.scheme != "https"
or not parsed.netloc
or parsed.username is not None
or parsed.password is not None
or parsed.fragment
):
flow.error = "The MCP server returned an unsafe authorization URL."
raise McpOAuthError(flow.error)
ok, _error = validate_url_target(authorization_url)
if not ok:
flow.error = "The MCP server returned an unsafe authorization URL."
raise McpOAuthError(flow.error)
states = parse_qs(parsed.query).get("state", [])
state = states[0] if len(states) == 1 else ""
if not state or len(state) > 512:
flow.error = "The MCP server returned an invalid authorization URL."
raise McpOAuthError(flow.error)
if state in self._states:
flow.error = "The MCP server reused an OAuth state value."
raise McpOAuthError(flow.error)
flow.authorization_url = authorization_url
flow.state = state
self._states[state] = flow.flow_id
flow.authorization_ready.set()
async def _wait_for_callback(self, flow: _McpOAuthFlow) -> tuple[str, str | None]:
callback_result = flow.callback_result
if callback_result is None:
raise _OAuthCallbackError("MCP OAuth callback is unavailable")
remaining = max(0.1, flow.expires_at - time.monotonic())
try:
return await asyncio.wait_for(asyncio.shield(callback_result), timeout=remaining)
except asyncio.TimeoutError as exc:
flow.error = "MCP authorization timed out."
raise _OAuthCallbackError(flow.error) from exc
async def _connect(self, flow: _McpOAuthFlow, handlers: MCPOAuthHandlers) -> bool:
connections: dict[str, MCPConnection] = {}
try:
connections = await connect_mcp_servers(
{flow.name: flow.cfg},
ToolRegistry(),
oauth_handlers={flow.name: handlers},
)
succeeded = flow.name in connections
if not succeeded and flow.error is None:
flow.error = "Could not complete the MCP OAuth connection."
return succeeded
except asyncio.CancelledError:
raise
except Exception:
if flow.error is None:
flow.error = "Could not complete the MCP OAuth connection."
return False
finally:
for connection in connections.values():
with suppress(Exception):
await connection.aclose()
async def _connect_and_reload(
self,
flow: _McpOAuthFlow,
handlers: MCPOAuthHandlers,
reload_mcp: McpReload,
) -> bool:
succeeded = await self._connect(flow, handlers)
if not succeeded:
return False
try:
flow.reload_result = await reload_mcp()
failed = flow.reload_result.get("failed")
if (
not flow.reload_result.get("ok")
and not flow.reload_result.get("requires_restart")
and isinstance(failed, list)
and flow.name in failed
):
flow.reload_result = await reload_mcp()
except Exception:
flow.reload_result = {
"ok": False,
"message": "Signed in, but nanobot could not activate the MCP tools.",
"requires_restart": True,
}
return True
def _flow(self, flow_id: str) -> _McpOAuthFlow:
flow = self._flows.get(flow_id)
if flow is None:
raise McpOAuthError("Unknown or expired MCP OAuth flow", status=404)
return flow
def _payload(self, flow: _McpOAuthFlow) -> dict[str, Any]:
task = flow.task
connected = flow.reload_result.get("connected") if flow.reload_result is not None else None
if task is not None and task.cancelled():
status = "cancelled"
elif task is not None and task.done():
try:
succeeded = task.result()
except Exception:
succeeded = False
if not succeeded:
status = "failed"
elif flow.reload_result is None:
status = "authorized"
elif flow.reload_result.get("ok") or (
isinstance(connected, list) and flow.name in connected
):
status = "connected"
else:
status = "authorized"
elif flow.callback_received:
status = "connecting"
elif flow.authorization_url:
status = "authorization_required"
else:
status = "starting"
payload: dict[str, Any] = {
"flow_id": flow.flow_id,
"name": flow.name,
"status": status,
"expires_in": max(0, int(flow.expires_at - time.monotonic())),
}
if flow.manual_callback:
payload["completion_input"] = "callback_url"
if flow.authorization_url and status == "authorization_required":
payload["authorization_url"] = flow.authorization_url
if flow.error:
payload["error"] = flow.error
if flow.reload_result is not None:
payload["hot_reload"] = flow.reload_result
return payload
async def _cancel_name(self, name: str) -> None:
for flow in list(self._flows.values()):
if flow.name == name and flow.task is not None and not flow.task.done():
await self._cancel_flow(flow)
async def _cancel_flow(self, flow: _McpOAuthFlow) -> None:
if flow.state:
self._states.pop(flow.state, None)
task = flow.task
if task is not None and not task.done():
task.cancel()
with suppress(BaseException):
await task
def _prune(self) -> None:
now = time.monotonic()
for flow_id, flow in list(self._flows.items()):
if flow.expires_at > now:
continue
if flow.state:
self._states.pop(flow.state, None)
if flow.task is not None and not flow.task.done():
flow.task.cancel()
callback_result = flow.callback_result
if callback_result is not None and not callback_result.done():
callback_result.cancel()
self._flows.pop(flow_id, None)
+160 -6
View File
@@ -16,6 +16,10 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, Mapping, cast
from nanobot.agent.tools.mcp_oauth import (
delete_mcp_oauth_credentials,
mcp_oauth_has_credentials,
)
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.apps.protocol import app_manifest, compact_dict
from nanobot.config.loader import load_config, resolve_config_env_vars, save_config
@@ -337,6 +341,63 @@ MCP_PRESETS: tuple[McpPreset, ...] = (
),
note="Requires Figma Desktop Dev Mode MCP to be running locally.",
),
McpPreset(
name="xmind",
display_name="Xmind",
category="productivity",
description="Create, read, and edit cloud mind maps through Xmind.",
docs_url="https://xmind.com/user-guide/xmind-mcp",
transport="streamableHttp",
install_supported=True,
brand_domain="xmind.com",
brand_color="#F4B41A",
requires="Xmind account",
server=MCPServerConfig(
type="streamableHttp",
auth="oauth",
url="https://app.xmind.com/api/mcp",
tool_timeout=60,
),
note="Connects securely in your browser with Xmind OAuth.",
),
McpPreset(
name="notion",
display_name="Notion",
category="productivity",
description="Read and update your Notion workspace through Notion MCP.",
docs_url="https://developers.notion.com/guides/mcp/get-started-with-mcp",
transport="streamableHttp",
install_supported=True,
brand_domain="notion.so",
brand_color="#111111",
requires="Notion account",
server=MCPServerConfig(
type="streamableHttp",
auth="oauth",
url="https://mcp.notion.com/mcp",
tool_timeout=60,
),
note="Connects securely in your browser with Notion OAuth.",
),
McpPreset(
name="linear",
display_name="Linear",
category="productivity",
description="Find and manage Linear issues, projects, and comments.",
docs_url="https://linear.app/docs/mcp",
transport="streamableHttp",
install_supported=True,
brand_domain="linear.app",
brand_color="#5E6AD2",
requires="Linear account",
server=MCPServerConfig(
type="streamableHttp",
auth="oauth",
url="https://mcp.linear.app/mcp",
tool_timeout=60,
),
note="Connects securely in your browser with Linear OAuth.",
),
McpPreset(
name="github",
display_name="GitHub",
@@ -657,6 +718,8 @@ def _status_for(preset: McpPreset, cfg: MCPServerConfig | None) -> str:
return "not_installed" if preset.install_supported else "coming_soon"
if any(field.required and not _field_configured(field, cfg) for field in preset.fields):
return "missing_credentials"
if cfg.auth == "oauth" and not mcp_oauth_has_credentials(preset.name, cfg.url):
return "authorization_required"
if cfg.command and not _command_available(cfg.command):
return "missing_dependency"
return "configured"
@@ -702,6 +765,7 @@ def _preset_manifest(preset: McpPreset, *, logo_url: str) -> dict[str, Any]:
compact_dict({
"type": "mcp",
"transport": preset.transport,
"auth": server.auth if server and server.auth else None,
"command": server.command if server and server.command else None,
"args": list(server.args) if server and server.command else None,
"url": _connection_summary(server) if server and server.url else None,
@@ -752,6 +816,7 @@ def _custom_manifest(name: str, cfg: MCPServerConfig) -> dict[str, Any]:
compact_dict({
"type": "mcp",
"transport": transport,
"auth": cfg.auth,
"command": cfg.command or None,
"url": _connection_summary(cfg) if cfg.url else None,
})
@@ -779,7 +844,7 @@ def _custom_manifest(name: str, cfg: MCPServerConfig) -> dict[str, Any]:
def _preset_payload(preset: McpPreset, configured_servers: dict[str, MCPServerConfig]) -> dict[str, Any]:
cfg = configured_servers.get(preset.name)
status = _status_for(preset, cfg)
configured = cfg is not None and status not in {"missing_credentials"}
configured = cfg is not None and status not in {"missing_credentials", "authorization_required"}
logo_url = _favicon_url(preset.brand_domain)
return {
"name": preset.name,
@@ -788,6 +853,7 @@ def _preset_payload(preset: McpPreset, configured_servers: dict[str, MCPServerCo
"description": preset.description,
"docs_url": preset.docs_url,
"transport": preset.transport,
"auth": (cfg.auth if cfg is not None else (preset.server.auth if preset.server else None)),
"requires": preset.requires,
"note": preset.note,
"install_supported": preset.install_supported,
@@ -814,7 +880,11 @@ def _custom_payload(
transport = cfg.type
if not transport:
transport = "stdio" if cfg.command else ("sse" if cfg.url.rstrip("/").endswith("/sse") else "streamableHttp")
status = "missing_dependency" if cfg.command and not _command_available(cfg.command) else "configured"
if cfg.auth == "oauth" and not mcp_oauth_has_credentials(name, cfg.url):
status = "authorization_required"
else:
status = "missing_dependency" if cfg.command and not _command_available(cfg.command) else "configured"
configured = status != "authorization_required"
return {
"name": name,
"display_name": name,
@@ -822,12 +892,13 @@ def _custom_payload(
"description": "Custom MCP server from nanobot config.",
"docs_url": "",
"transport": transport,
"auth": cfg.auth,
"requires": "",
"note": "",
"install_supported": True,
"installed": True,
"configured": True,
"available": _config_available(cfg),
"configured": configured,
"available": configured and _config_available(cfg),
"status": status,
"logo_url": None,
"brand_color": "#64748B",
@@ -1127,6 +1198,32 @@ def _normalize_transport(value: str | None, *, command: str = "", url: str = "")
return normalized # type: ignore[return-value]
def _normalize_auth(
value: object,
*,
transport: Literal["stdio", "sse", "streamableHttp"],
url: str,
headers: Mapping[str, str],
) -> Literal["oauth"] | None:
raw = str(value or "").strip().lower()
if not raw and url and not headers:
normalized_url = url.rstrip("/")
if any(
preset.server is not None
and preset.server.auth == "oauth"
and preset.server.url.rstrip("/") == normalized_url
for preset in MCP_PRESETS
):
raw = "oauth"
if not raw:
return None
if raw != "oauth":
raise McpPresetError("unsupported MCP auth type")
if transport == "stdio":
raise McpPresetError("MCP OAuth requires a remote HTTP transport")
return "oauth"
def _validated_server_name(name: str) -> str:
if not name or _MCP_PRESET_NAME_RE.match(name) is None:
raise McpPresetError("invalid MCP server name")
@@ -1142,6 +1239,13 @@ def _custom_server_from_query(query: QueryParams) -> tuple[str, MCPServerConfig]
raise McpPresetError("stdio MCP servers require a command")
if transport in {"sse", "streamableHttp"} and not url:
raise McpPresetError("remote MCP servers require a URL")
headers = _parse_string_map(_query_first(query, "headers"))
auth = _normalize_auth(
_query_first(query, "auth"),
transport=transport,
url=url,
headers=headers,
)
raw_timeout = (_query_first(query, "tool_timeout") or "").strip()
tool_timeout = _DEFAULT_CUSTOM_TIMEOUT
if raw_timeout:
@@ -1151,12 +1255,13 @@ def _custom_server_from_query(query: QueryParams) -> tuple[str, MCPServerConfig]
raise McpPresetError("tool_timeout must be an integer") from exc
cfg = MCPServerConfig(
type=transport,
auth=auth,
command=command if transport == "stdio" else "",
args=_parse_string_list(_query_first(query, "args")),
env=_parse_string_map(_query_first(query, "env")),
cwd=(_query_first(query, "cwd") or "").strip() if transport == "stdio" else "",
url=url if transport in {"sse", "streamableHttp"} else "",
headers=_parse_string_map(_query_first(query, "headers")),
headers=headers,
tool_timeout=tool_timeout,
enabled_tools=_parse_enabled_tools(_query_first(query, "enabled_tools")),
)
@@ -1201,6 +1306,13 @@ def _mcp_server_config(name: str, raw: Any) -> tuple[str, MCPServerConfig]:
headers = cast(dict[object, object], headers_value)
if not all(isinstance(k, str) and isinstance(v, str) for k, v in headers.items()):
raise McpPresetError(f"MCP server '{server_name}' headers must be a string object")
typed_headers = cast(dict[str, str], headers)
auth = _normalize_auth(
server.get("auth"),
transport=transport,
url=url,
headers=typed_headers,
)
if not isinstance(enabled_tools_value, list):
enabled_tools_value = ["*"]
else:
@@ -1209,12 +1321,13 @@ def _mcp_server_config(name: str, raw: Any) -> tuple[str, MCPServerConfig]:
enabled_tools_value = ["*"]
return server_name, MCPServerConfig(
type=transport,
auth=auth,
command=command if transport == "stdio" else "",
args=cast(list[str], args),
env=cast(dict[str, str], env),
cwd=cwd if transport == "stdio" else "",
url=url if transport in {"sse", "streamableHttp"} else "",
headers=cast(dict[str, str], headers),
headers=typed_headers,
tool_timeout=timeout_int,
enabled_tools=cast(list[str], enabled_tools_value),
)
@@ -1239,6 +1352,15 @@ def _import_mcp_servers(raw_json: str | None) -> dict[str, MCPServerConfig]:
return out
def _oauth_credentials_replaced(
previous: MCPServerConfig | None,
replacement: MCPServerConfig,
) -> bool:
if previous is None or previous.auth != "oauth":
return False
return replacement.auth != "oauth" or replacement.url != previous.url
def custom_mcp_action(
action: str,
query: QueryParams,
@@ -1248,8 +1370,11 @@ def custom_mcp_action(
config = load_config(config_path) if config_path is not None else load_config()
if action == "custom":
name, cfg = _custom_server_from_query(query)
delete_credentials = _oauth_credentials_replaced(config.tools.mcp_servers.get(name), cfg)
config.tools.mcp_servers[name] = cfg
save_config(config, config_path)
if delete_credentials:
delete_mcp_oauth_credentials(name)
payload = mcp_presets_payload(
last_action=_server_action_message(action, name),
config_path=config_path,
@@ -1259,8 +1384,15 @@ def custom_mcp_action(
if action in {"import", "import-cursor"}:
servers = _import_mcp_servers(_query_first(query, "config"))
delete_credentials = [
name
for name, cfg in servers.items()
if _oauth_credentials_replaced(config.tools.mcp_servers.get(name), cfg)
]
config.tools.mcp_servers.update(servers)
save_config(config, config_path)
for name in delete_credentials:
delete_mcp_oauth_credentials(name)
payload = mcp_presets_payload(
last_action={
"ok": True,
@@ -1289,6 +1421,27 @@ def custom_mcp_action(
raise McpPresetError(f"unknown MCP action '{action}'", status=404)
def ensure_mcp_oauth_server(
query: QueryParams,
*,
config_path: Path | None = None,
) -> tuple[str, MCPServerConfig]:
"""Materialize an OAuth preset on first click and return its saved config."""
name = _validated_server_name((_query_first(query, "name") or "").strip())
config = load_config(config_path) if config_path is not None else load_config()
cfg = config.tools.mcp_servers.get(name)
if cfg is None:
preset = _preset_by_name(name)
if preset.server is None or preset.server.auth != "oauth":
raise McpPresetError("MCP server does not support browser authorization", status=409)
cfg = _materialize_server(preset, query, None)
config.tools.mcp_servers[name] = cfg
save_config(config, config_path)
if cfg.auth != "oauth" or cfg.type not in {"sse", "streamableHttp"} or not cfg.url:
raise McpPresetError("MCP server is not configured for OAuth", status=409)
return name, cfg
def mcp_presets_action(
action: str,
query: QueryParams,
@@ -1328,6 +1481,7 @@ def mcp_presets_action(
cleanup_error = str(exc)
del config.tools.mcp_servers[name]
save_config(config, config_path)
delete_mcp_oauth_credentials(name)
last_action = (
_action_message(action, preset)
if preset is not None
File diff suppressed because it is too large Load Diff
+804
View File
@@ -0,0 +1,804 @@
"""Capability settings domain logic for Web, media, network, and API features."""
from __future__ import annotations
import asyncio
import os
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Awaitable, Callable, TypedDict
from nanobot.agent.tools.web import SEARCH_PROVIDER_OPTIONS
from nanobot.api.runtime import ApiRuntime, ApiStartOptions
from nanobot.audio.transcription import resolve_transcription_config
from nanobot.audio.transcription_registry import (
resolve_transcription_provider,
transcription_provider_names,
)
from nanobot.config.schema import Config
from nanobot.optional_features import (
OptionalFeatureError,
extra_installed,
optional_dependency_groups,
)
from nanobot.providers.image_generation import (
get_image_gen_provider,
image_gen_provider_names,
)
from nanobot.providers.registry import find_by_name
from nanobot.security.network import is_loopback_host
from nanobot.webui.settings_contracts import (
QueryParams,
SettingsRequest,
SettingsRouteResult,
WebUISettingsError,
parse_bool,
query_first,
query_first_alias,
)
from nanobot.webui.settings_models import (
OAuthStatusReader,
mask_secret_hint,
provider_configured_for_settings,
)
from nanobot.webui.workspaces import (
read_webui_default_access_mode,
)
if TYPE_CHECKING:
from nanobot.webui.settings_services import WebUISettingsServices
SettingsOperation = Callable[..., dict[str, Any]]
@dataclass(frozen=True)
class CapabilitySettingsOperations:
update_web_search: SettingsOperation
update_api: SettingsOperation
update_image: SettingsOperation
update_transcription: SettingsOperation
update_network: SettingsOperation
nanobot_features_action: SettingsOperation
api_runtime: Callable[[], ApiRuntime]
reload_image: Callable[[], Awaitable[dict[str, Any]]]
class CapabilitySettingsPayload(TypedDict):
web_search: dict[str, Any]
web: dict[str, Any]
api: dict[str, Any]
observability: dict[str, Any]
image_generation: dict[str, Any]
transcription: dict[str, Any]
_WEB_SEARCH_PROVIDER_OPTIONS = SEARCH_PROVIDER_OPTIONS
_WEB_SEARCH_PROVIDER_BY_NAME = {
provider["name"]: provider for provider in _WEB_SEARCH_PROVIDER_OPTIONS
}
_IMAGE_GENERATION_ASPECT_RATIOS = {
"1:1",
"3:4",
"9:16",
"4:3",
"16:9",
"3:2",
"2:3",
"21:9",
}
def _image_generation_provider_rows(
config: Config,
*,
oauth_status: OAuthStatusReader,
) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for name in image_gen_provider_names():
image_provider = get_image_gen_provider(name)
spec = find_by_name(name)
provider_config = getattr(config.providers, name, None)
configured = (
provider_configured_for_settings(spec, provider_config, oauth_status)
if spec is not None and provider_config is not None
else bool(getattr(provider_config, "api_key", None))
)
rows.append(
{
"name": name,
"label": spec.label if spec is not None else name,
"configured": configured,
"auth_type": "oauth" if spec is not None and spec.is_oauth else "api_key",
"api_key_hint": mask_secret_hint(getattr(provider_config, "api_key", None)),
"api_base": getattr(provider_config, "api_base", None),
"default_api_base": (
spec.default_api_base if spec and spec.default_api_base else None
),
"models": list(image_provider.model_options) if image_provider else [],
"default_model": (
image_provider.model_options[0]
if image_provider and image_provider.model_options
else None
),
}
)
return rows
def _transcription_provider_rows(config: Config) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for name in transcription_provider_names():
spec = find_by_name(name)
provider_config = getattr(config.providers, name, None)
rows.append(
{
"name": name,
"label": spec.label if spec is not None else name,
"configured": bool(getattr(provider_config, "api_key", None)),
"api_key_hint": mask_secret_hint(getattr(provider_config, "api_key", None)),
"api_base": getattr(provider_config, "api_base", None),
"default_api_base": (
spec.default_api_base if spec and spec.default_api_base else None
),
}
)
return rows
def capability_settings_payload(
config: Config,
*,
oauth_status: OAuthStatusReader,
) -> CapabilitySettingsPayload:
search_config = config.tools.web.search
image_config = config.tools.image_generation
transcription = resolve_transcription_config(config)
search_provider = (
search_config.provider
if search_config.provider in _WEB_SEARCH_PROVIDER_BY_NAME
else "duckduckgo"
)
image_providers = _image_generation_provider_rows(config, oauth_status=oauth_status)
selected_image_provider = next(
(
provider
for provider in image_providers
if provider["name"] == image_config.provider
),
None,
)
return {
"web_search": {
"provider": search_provider,
"api_key_hint": mask_secret_hint(search_config.api_key),
"base_url": search_config.base_url or None,
"max_results": search_config.max_results,
"timeout": search_config.timeout,
"providers": list(_WEB_SEARCH_PROVIDER_OPTIONS),
},
"web": {
"enable": config.tools.web.enable,
"proxy": config.tools.web.proxy,
"user_agent": config.tools.web.user_agent,
"search": {
"max_results": search_config.max_results,
"timeout": search_config.timeout,
},
"fetch": {
"use_jina_reader": config.tools.web.fetch.use_jina_reader,
},
},
"api": {
"host": config.api.host,
"port": config.api.port,
"timeout": config.api.timeout,
"api_key_hint": mask_secret_hint(config.api.api_key),
},
"observability": {
"provider": "langfuse",
"configured": bool(
os.environ.get("LANGFUSE_SECRET_KEY")
and os.environ.get("LANGFUSE_PUBLIC_KEY")
),
"base_url": os.environ.get("LANGFUSE_BASE_URL")
or "https://cloud.langfuse.com",
},
"image_generation": {
"enabled": image_config.enabled,
"provider": image_config.provider,
"provider_configured": bool(
selected_image_provider and selected_image_provider["configured"]
),
"model": image_config.model,
"default_aspect_ratio": image_config.default_aspect_ratio,
"default_image_size": image_config.default_image_size,
"max_images_per_turn": image_config.max_images_per_turn,
"save_dir": image_config.save_dir,
"providers": image_providers,
},
"transcription": {
"enabled": transcription.enabled,
"provider": transcription.provider,
"provider_configured": transcription.configured,
"model": transcription.model,
"language": transcription.language,
"max_duration_sec": transcription.max_duration_sec,
"max_upload_mb": transcription.max_upload_mb,
"providers": _transcription_provider_rows(config),
},
}
def update_network_safety_settings(
config: Config,
query: QueryParams,
) -> tuple[bool, str | None]:
raw_allow = (
query_first_alias(
query,
"webui_allow_local_service_access",
"webuiAllowLocalServiceAccess",
)
or query_first_alias(
query,
"allow_local_preview_access",
"allowLocalPreviewAccess",
)
)
raw_default_access_mode = query_first_alias(
query,
"webui_default_access_mode",
"webuiDefaultAccessMode",
)
if raw_allow is None and raw_default_access_mode is None:
raise WebUISettingsError(
"webui_allow_local_service_access or webui_default_access_mode is required"
)
changed = False
if raw_allow is not None:
allow_local = parse_bool(raw_allow, "webui_allow_local_service_access")
if config.tools.webui_allow_local_service_access != allow_local:
config.tools.webui_allow_local_service_access = allow_local
changed = True
default_access_mode: str | None = None
if raw_default_access_mode is not None:
default_access_mode = raw_default_access_mode.strip().lower()
if default_access_mode == "restricted":
default_access_mode = "default"
if default_access_mode not in {"default", "full"}:
raise WebUISettingsError(
"webui_default_access_mode must be default or full"
)
return changed, default_access_mode
def update_web_search_settings(config: Config, query: QueryParams) -> tuple[bool, bool]:
provider_name = (query_first(query, "provider") or "").strip().lower()
provider_option = _WEB_SEARCH_PROVIDER_BY_NAME.get(provider_name)
if provider_option is None:
raise WebUISettingsError("unknown web search provider")
search_config = config.tools.web.search
web_config = config.tools.web
previous_provider = search_config.provider
changed = False
restart_required = False
def set_search_value(attr: str, value: object) -> None:
nonlocal changed
if getattr(search_config, attr) != value:
setattr(search_config, attr, value)
changed = True
def set_fetch_value(attr: str, value: object) -> None:
nonlocal changed
if getattr(web_config.fetch, attr) != value:
setattr(web_config.fetch, attr, value)
changed = True
if search_config.provider != provider_name:
search_config.provider = provider_name
changed = True
credential = provider_option["credential"]
if credential == "none":
set_search_value("api_key", "")
set_search_value("base_url", "")
elif credential == "base_url":
base_url = query_first_alias(query, "base_url", "baseUrl")
base_url = base_url.strip() if base_url is not None else None
if not base_url and previous_provider == provider_name and search_config.base_url:
base_url = search_config.base_url
if not base_url:
raise WebUISettingsError("base_url is required")
set_search_value("base_url", base_url)
set_search_value("api_key", "")
elif credential in {"api_key", "optional_api_key"}:
raw_api_key = query_first_alias(query, "api_key", "apiKey")
api_key = raw_api_key.strip() if raw_api_key is not None else None
if api_key is None and previous_provider == provider_name and search_config.api_key:
api_key = search_config.api_key
if credential == "api_key" and not api_key:
raise WebUISettingsError("api_key is required")
set_search_value("api_key", api_key or "")
set_search_value("base_url", "")
else:
raise WebUISettingsError("unknown web search credential type")
max_results = query_first_alias(query, "max_results", "maxResults")
if max_results is not None:
try:
parsed = int(max_results)
except ValueError:
raise WebUISettingsError("max_results must be an integer") from None
if parsed < 1 or parsed > 10:
raise WebUISettingsError("max_results must be between 1 and 10")
set_search_value("max_results", parsed)
timeout = query_first(query, "timeout")
if timeout is not None:
try:
parsed_timeout = int(timeout)
except ValueError:
raise WebUISettingsError("timeout must be an integer") from None
if parsed_timeout < 1 or parsed_timeout > 120:
raise WebUISettingsError("timeout must be between 1 and 120")
set_search_value("timeout", parsed_timeout)
use_jina_reader = query_first_alias(query, "use_jina_reader", "useJinaReader")
if use_jina_reader is not None:
previous_jina_reader = web_config.fetch.use_jina_reader
set_fetch_value("use_jina_reader", parse_bool(use_jina_reader, "use_jina_reader"))
if web_config.fetch.use_jina_reader != previous_jina_reader:
restart_required = True
return changed, restart_required
def update_api_settings(config: Config, query: QueryParams) -> None:
"""Update the managed OpenAI-compatible API configuration."""
api = config.api
host = query_first(query, "host")
if host is not None:
host = host.strip()
if not host:
raise WebUISettingsError("host is required")
api.host = host
port = query_first(query, "port")
if port is not None:
try:
parsed_port = int(port)
except ValueError:
raise WebUISettingsError("port must be an integer") from None
if parsed_port < 1 or parsed_port > 65535:
raise WebUISettingsError("port must be between 1 and 65535")
api.port = parsed_port
timeout = query_first(query, "timeout")
if timeout is not None:
try:
parsed_timeout = float(timeout)
except ValueError:
raise WebUISettingsError("timeout must be a number") from None
if parsed_timeout < 1 or parsed_timeout > 3600:
raise WebUISettingsError("timeout must be between 1 and 3600")
api.timeout = parsed_timeout
api_key = query_first_alias(query, "api_key", "apiKey")
if api_key is not None:
api.api_key = api_key.strip()
if not is_loopback_host(api.host) and not api.api_key.strip():
raise WebUISettingsError(
"an API key is required when the API is available on the network"
)
def update_image_generation_settings(
config: Config,
query: QueryParams,
*,
oauth_status: OAuthStatusReader,
) -> bool:
image_config = config.tools.image_generation
changed = False
provider_name = query_first(query, "provider")
if provider_name is not None:
provider_name = provider_name.strip().lower()
if not provider_name:
raise WebUISettingsError("image generation provider is required")
if get_image_gen_provider(provider_name) is None:
raise WebUISettingsError("unknown image generation provider")
if image_config.provider != provider_name:
image_config.provider = provider_name
changed = True
enabled = query_first(query, "enabled")
if enabled is not None:
parsed_enabled = parse_bool(enabled, "enabled")
if image_config.enabled != parsed_enabled:
image_config.enabled = parsed_enabled
changed = True
model = query_first(query, "model")
if model is not None:
model = model.strip()
if not model:
raise WebUISettingsError("image generation model is required")
if len(model) > 200:
raise WebUISettingsError("image generation model is too long")
if image_config.model != model:
image_config.model = model
changed = True
default_aspect_ratio = query_first_alias(
query,
"default_aspect_ratio",
"defaultAspectRatio",
)
if default_aspect_ratio is not None:
default_aspect_ratio = default_aspect_ratio.strip()
if default_aspect_ratio not in _IMAGE_GENERATION_ASPECT_RATIOS:
raise WebUISettingsError("unsupported image generation aspect ratio")
if image_config.default_aspect_ratio != default_aspect_ratio:
image_config.default_aspect_ratio = default_aspect_ratio
changed = True
default_image_size = query_first_alias(
query,
"default_image_size",
"defaultImageSize",
)
if default_image_size is not None:
default_image_size = default_image_size.strip()
if not default_image_size:
raise WebUISettingsError("default image size is required")
if len(default_image_size) > 32 or not all(
char.isascii() and (char.isalnum() or char in {"x", "X", ":", "-", "_"})
for char in default_image_size
):
raise WebUISettingsError("unsupported image generation size")
if image_config.default_image_size != default_image_size:
image_config.default_image_size = default_image_size
changed = True
max_images_per_turn = query_first_alias(
query,
"max_images_per_turn",
"maxImagesPerTurn",
)
if max_images_per_turn is not None:
try:
parsed_max = int(max_images_per_turn)
except ValueError:
raise WebUISettingsError("max_images_per_turn must be an integer") from None
if parsed_max < 1 or parsed_max > 8:
raise WebUISettingsError("max_images_per_turn must be between 1 and 8")
if image_config.max_images_per_turn != parsed_max:
image_config.max_images_per_turn = parsed_max
changed = True
if image_config.enabled:
selected_provider = next(
(
provider
for provider in _image_generation_provider_rows(
config,
oauth_status=oauth_status,
)
if provider["name"] == image_config.provider
),
None,
)
if not selected_provider or not selected_provider["configured"]:
raise WebUISettingsError("image generation provider is not configured")
return changed
def update_transcription_settings(config: Config, query: QueryParams) -> bool:
transcription = config.transcription
changed = False
enabled = query_first(query, "enabled")
if enabled is not None:
parsed_enabled = parse_bool(enabled, "enabled")
if transcription.enabled != parsed_enabled:
transcription.enabled = parsed_enabled
changed = True
provider = query_first(query, "provider")
if provider is not None:
provider = provider.strip().lower()
provider_spec = resolve_transcription_provider(provider)
if provider_spec is None:
raise WebUISettingsError("unknown transcription provider")
provider = provider_spec.name
if transcription.provider != provider:
transcription.provider = provider
changed = True
model = query_first(query, "model")
if model is not None:
model = model.strip() or None
if model is not None and len(model) > 200:
raise WebUISettingsError("transcription model is too long")
if transcription.model != model:
transcription.model = model
changed = True
language = query_first(query, "language")
if language is not None:
language = language.strip().lower() or None
if language is not None and not re.fullmatch(r"[a-z]{2,3}", language):
raise WebUISettingsError(
"transcription language must be 2-3 lowercase letters"
)
if transcription.language != language:
transcription.language = language
changed = True
max_duration_sec = query_first_alias(query, "max_duration_sec", "maxDurationSec")
if max_duration_sec is not None:
try:
parsed_duration = int(max_duration_sec)
except ValueError:
raise WebUISettingsError("max_duration_sec must be an integer") from None
if parsed_duration < 1 or parsed_duration > 600:
raise WebUISettingsError("max_duration_sec must be between 1 and 600")
if transcription.max_duration_sec != parsed_duration:
transcription.max_duration_sec = parsed_duration
changed = True
max_upload_mb = query_first_alias(query, "max_upload_mb", "maxUploadMb")
if max_upload_mb is not None:
try:
parsed_upload = int(max_upload_mb)
except ValueError:
raise WebUISettingsError("max_upload_mb must be an integer") from None
if parsed_upload < 1 or parsed_upload > 100:
raise WebUISettingsError("max_upload_mb must be between 1 and 100")
if transcription.max_upload_mb != parsed_upload:
transcription.max_upload_mb = parsed_upload
changed = True
return changed
def network_safety_payload(config: Config) -> dict[str, Any]:
"""Return the network-related fields embedded in the advanced DTO."""
return {
"webui_allow_local_service_access": config.tools.webui_allow_local_service_access,
"allow_local_preview_access": config.tools.webui_allow_local_service_access,
"webui_default_access_mode": read_webui_default_access_mode(),
"private_service_protection_enabled": True,
"ssrf_whitelist_count": len(config.tools.ssrf_whitelist),
}
def masked_api_secret(value: str) -> str | None:
value = value.strip()
if not value:
return None
return f"{value[:3]}...{value[-4:]}" if len(value) > 8 else "configured"
def api_runtime_message(message: str) -> str:
known = {
"api_exited_during_startup": "API server exited during startup. Check its log for details.",
"api_stop_timeout": "API server did not stop in time.",
"api_state_stale": "API server state was stale; try starting it again.",
}
if message in known:
return known[message]
if message.startswith("api_"):
return f"API server {message.removeprefix('api_').replace('_', ' ')}"
return message.replace("_", " ")
def api_service_payload(
settings: WebUISettingsServices,
runtime: ApiRuntime,
*,
last_action: str | None = None,
) -> dict[str, Any]:
config = settings.config.load()
status = runtime.status()
extras = optional_dependency_groups()
connect_host = (
"127.0.0.1" if config.api.host in {"0.0.0.0", "::"} else config.api.host
)
payload = {
"installed": extra_installed("api", extras.get("api")),
"running": status.running,
"managed": status.running,
"host": config.api.host,
"port": config.api.port,
"timeout": config.api.timeout,
"api_key_hint": masked_api_secret(config.api.api_key),
"endpoint": f"http://{connect_host}:{config.api.port}/v1",
"command": "nanobot serve",
"log_path": str(status.log_path),
}
if last_action:
payload["last_action"] = last_action
return payload
class CapabilitySettingsHandler:
"""Handle capability commands after transport authentication and decoding."""
def __init__(self, settings: WebUISettingsServices, logger: Any) -> None:
self.settings = settings
self.logger = logger
async def handle(
self,
action: str,
request: SettingsRequest,
operations: CapabilitySettingsOperations,
) -> SettingsRouteResult:
if action == "api-status":
return SettingsRouteResult.success(
api_service_payload(self.settings, operations.api_runtime())
)
if action == "api-start":
return await self._start_api(request, operations)
if action == "api-stop":
return await self._stop_api(operations)
mutation = {
"web-search-update": (
operations.update_web_search,
"browser",
False,
),
"transcription-update": (
operations.update_transcription,
None,
False,
),
"network-update": (
operations.update_network,
"runtime",
False,
),
"image-update": (
operations.update_image,
"image",
True,
),
}.get(action)
if mutation is None:
return SettingsRouteResult.failure(404, "unknown settings action")
operation, section, apply_image_reload = mutation
try:
payload = self.settings.mutate(operation, request.query)
except WebUISettingsError as exc:
return SettingsRouteResult.failure(exc.status, exc.message)
if apply_image_reload:
payload, image_restart_cleared = await self.apply_image_runtime_change(
payload,
operations.reload_image,
)
else:
image_restart_cleared = False
return SettingsRouteResult.success(
payload,
decorate_restart=True,
restart_section=section,
clear_restart_section=("image" if image_restart_cleared else None),
)
async def apply_image_runtime_change(
self,
payload: dict[str, Any],
reload_image: Callable[[], Awaitable[dict[str, Any]]],
) -> tuple[dict[str, Any], bool]:
"""Hot-apply image settings, preserving restart fallback on failure."""
if not payload.get("requires_restart"):
return payload, False
try:
result = await reload_image()
except Exception:
self.logger.exception("failed to hot-reload image generation settings")
return payload, False
applied = bool(result.get("ok")) and not result.get("requires_restart")
updated = dict(payload)
updated["requires_restart"] = not applied
if not applied:
self.logger.warning(
"image generation settings were saved but require restart: {}",
result.get("message") or "hot reload failed",
)
return updated, applied
async def _start_api(
self,
request: SettingsRequest,
operations: CapabilitySettingsOperations,
) -> SettingsRouteResult:
api_key = (request.payload or {}).get("api_key")
if api_key is not None and not isinstance(api_key, str):
return SettingsRouteResult.failure(
400,
"API service API key must be a string",
)
try:
await asyncio.to_thread(
self.settings.mutate,
operations.nanobot_features_action,
"enable",
{"name": ["api"]},
allow_install=self._allow_feature_package_install(request),
)
self.settings.mutate(operations.update_api, request.query)
config = self.settings.config.load()
runtime = operations.api_runtime()
options = ApiStartOptions(
host=config.api.host,
port=config.api.port,
workspace=str(config.workspace_path),
config_path=str(self.settings.config.path),
)
current = runtime.status()
result = await asyncio.to_thread(
runtime.restart if current.running else runtime.start_background,
options,
)
if not result.ok:
return SettingsRouteResult.failure(
500,
api_runtime_message(result.message),
)
except (WebUISettingsError, OptionalFeatureError) as exc:
return SettingsRouteResult.failure(
getattr(exc, "status", 400),
getattr(exc, "message", str(exc)),
)
except Exception as exc:
self.logger.exception("failed to start managed API service")
return SettingsRouteResult.failure(500, str(exc))
return SettingsRouteResult.success(
api_service_payload(
self.settings,
operations.api_runtime(),
last_action="started",
)
)
async def _stop_api(
self,
operations: CapabilitySettingsOperations,
) -> SettingsRouteResult:
runtime = operations.api_runtime()
try:
result = await asyncio.to_thread(runtime.stop)
except Exception as exc:
self.logger.exception("failed to stop managed API service")
return SettingsRouteResult.failure(500, str(exc))
if not result.ok and result.message != "api_not_running":
return SettingsRouteResult.failure(
500,
api_runtime_message(result.message),
)
return SettingsRouteResult.success(
api_service_payload(
self.settings,
operations.api_runtime(),
last_action="stopped",
)
)
def _allow_feature_package_install(self, request: SettingsRequest) -> bool:
if request.local_browser:
return True
try:
return bool(
self.settings.config.load().tools.webui_allow_remote_package_install
)
except Exception:
self.logger.exception("failed to load remote package install policy")
return False
+82
View File
@@ -0,0 +1,82 @@
"""Stable request and error contracts shared by WebUI settings domains."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
QueryParams = dict[str, list[str]]
@dataclass(frozen=True)
class SettingsRequest:
"""Transport-neutral input decoded by the settings route facade."""
query: QueryParams
payload: dict[str, Any] | None = None
local_browser: bool = False
@dataclass(frozen=True)
class SettingsRouteResult:
"""Transport-neutral result returned by a settings domain handler."""
payload: dict[str, Any] | None = None
status: int = 200
error: str | None = None
decorate_restart: bool = False
restart_section: str | None = None
clear_restart_section: str | None = None
restart_payload_key: str | None = None
@classmethod
def success(
cls,
payload: dict[str, Any],
*,
decorate_restart: bool = False,
restart_section: str | None = None,
clear_restart_section: str | None = None,
restart_payload_key: str | None = None,
) -> SettingsRouteResult:
return cls(
payload=payload,
decorate_restart=decorate_restart,
restart_section=restart_section,
clear_restart_section=clear_restart_section,
restart_payload_key=restart_payload_key,
)
@classmethod
def failure(cls, status: int, error: str) -> SettingsRouteResult:
return cls(status=status, error=error)
class WebUISettingsError(ValueError):
"""User-facing settings validation failure."""
def __init__(self, message: str, *, status: int = 400) -> None:
super().__init__(message)
self.message = message
self.status = status
def query_first(query: QueryParams, key: str) -> str | None:
values = query.get(key)
return values[0] if values else None
def query_first_alias(query: QueryParams, snake: str, camel: str) -> str | None:
value = query_first(query, snake)
return query_first(query, camel) if value is None else value
def query_has_alias(query: QueryParams, snake: str, camel: str) -> bool:
return snake in query or camel in query
def parse_bool(value: str, field: str) -> bool:
normalized = value.strip().lower()
if normalized not in {"1", "0", "true", "false", "yes", "no"}:
raise WebUISettingsError(f"{field} must be boolean")
return normalized in {"1", "true", "yes"}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+957
View File
@@ -0,0 +1,957 @@
"""System and channel settings domain logic."""
from __future__ import annotations
import asyncio
import inspect
import re
import time
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, TypedDict, cast
from zoneinfo import ZoneInfo
from nanobot.channels._setup import channel_setup_spec
from nanobot.channels.connect import ChannelConnectError
from nanobot.channels.contracts import (
RouteFieldType,
channel_instance_config,
channel_update_instance_config,
)
from nanobot.config.schema import Config
from nanobot.optional_features import OptionalFeatureError, with_channel_runtime_status
from nanobot.security.workspace_access import workspace_sandbox_status
from nanobot.webui.settings_capabilities import network_safety_payload
from nanobot.webui.settings_contracts import (
QueryParams,
SettingsRequest,
SettingsRouteResult,
WebUISettingsError,
query_first,
query_first_alias,
)
from nanobot.webui.token_usage import token_usage_payload
if TYPE_CHECKING:
from nanobot.webui.settings_services import WebUISettingsServices
LoadChannelPlugin = Callable[[str], Any]
ListPendingPairings = Callable[[], Iterable[dict[str, Any]]]
SettingsOperation = Callable[..., Any]
@dataclass(frozen=True)
class SystemSettingsOperations:
cli_apps_payload: SettingsOperation
cli_apps_action: SettingsOperation
nanobot_features_payload: SettingsOperation
nanobot_features_action: SettingsOperation
nanobot_feature_instance_target: SettingsOperation
validate_channel_config: SettingsOperation
load_channel_plugin: LoadChannelPlugin
list_pending: ListPendingPairings
approve_code: SettingsOperation
deny_code: SettingsOperation
mcp_presets_action: SettingsOperation
reload_mcp: SettingsOperation
check_for_update: SettingsOperation
channel_feature_action: SettingsOperation | None = None
channel_runtime_status: Callable[[], dict[str, Any]] | None = None
class SystemSettingsPayload(TypedDict):
runtime: dict[str, Any]
usage: dict[str, Any]
advanced: dict[str, Any]
version: dict[str, Any]
docs: dict[str, Any]
_DOCS_STABLE_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:\.post\d+)?$")
_DOCS_LATEST_URL = "https://nanobot.wiki/docs/latest"
_SKIP_FIELD = object()
def docs_version(version: str) -> str:
"""Map package versions to the matching public docs path."""
normalized = version.strip()
if _DOCS_STABLE_VERSION_RE.fullmatch(normalized):
return normalized
return "latest"
def docs_payload(version: str) -> dict[str, Any]:
selected_version = docs_version(version)
base_url = f"https://nanobot.wiki/docs/{selected_version}"
return {
"version": selected_version,
"base_url": base_url,
"chat_apps_url": f"{base_url}/getting-started/chat-apps",
"latest_url": _DOCS_LATEST_URL,
}
def system_settings_payload(
config: Config,
*,
config_path: Path,
version: str,
) -> SystemSettingsPayload:
defaults = config.agents.defaults
exec_config = config.tools.exec
sandbox_status = workspace_sandbox_status(
restrict_to_workspace=config.tools.restrict_to_workspace,
workspace=config.workspace_path,
)
return {
"runtime": {
"config_path": str(config_path.expanduser()),
"workspace_path": str(config.workspace_path),
"gateway_host": config.gateway.host,
"gateway_port": config.gateway.port,
"heartbeat": {
"enabled": config.gateway.heartbeat.enabled,
"interval_s": config.gateway.heartbeat.interval_s,
"keep_recent_messages": config.gateway.heartbeat.keep_recent_messages,
},
"dream": {
"schedule": defaults.dream.describe_schedule(),
},
"unified_session": defaults.unified_session,
},
"usage": token_usage_payload(timezone_name=defaults.timezone),
"advanced": {
"restrict_to_workspace": config.tools.restrict_to_workspace,
"workspace_sandbox": sandbox_status.as_dict(),
**network_safety_payload(config),
"mcp_server_count": len(config.tools.mcp_servers),
"exec_enabled": exec_config.enable,
"exec_sandbox": exec_config.sandbox or None,
"exec_path_prepend_set": bool(exec_config.path_prepend),
"exec_path_append_set": bool(exec_config.path_append),
},
"version": {"current": version},
"docs": docs_payload(version),
}
def settings_usage_payload(config: Config) -> dict[str, Any]:
"""Return the lightweight token usage slice for Overview refreshes."""
return token_usage_payload(timezone_name=config.agents.defaults.timezone)
def update_agent_system_settings(config: Config, query: QueryParams) -> tuple[bool, bool]:
defaults = config.agents.defaults
changed = False
restart_required = False
timezone = query_first(query, "timezone")
if timezone is not None:
timezone = timezone.strip()
if not timezone:
raise WebUISettingsError("timezone is required")
try:
ZoneInfo(timezone)
except Exception:
raise WebUISettingsError("invalid timezone") from None
timezone_changed = defaults.timezone != timezone
if timezone_changed or defaults.timezone_mode != "manual":
defaults.timezone = timezone
defaults.timezone_mode = "manual"
changed = True
restart_required = timezone_changed
tool_hint_max_length = query_first_alias(
query,
"tool_hint_max_length",
"toolHintMaxLength",
)
if tool_hint_max_length is not None:
try:
parsed = int(tool_hint_max_length)
except ValueError:
raise WebUISettingsError(
"tool_hint_max_length must be an integer"
) from None
if parsed < 20 or parsed > 500:
raise WebUISettingsError(
"tool_hint_max_length must be between 20 and 500"
)
if defaults.tool_hint_max_length != parsed:
defaults.tool_hint_max_length = parsed
changed = True
restart_required = True
return changed, restart_required
def save_channel_config_values(
config: Config,
name: str,
raw_values: dict[str, Any],
instance_id: str = "default",
*,
load_channel_plugin: LoadChannelPlugin,
) -> list[str]:
if not name:
raise WebUISettingsError("missing channel name")
try:
plugin = load_channel_plugin(name)
except ImportError:
raise WebUISettingsError(f"unknown channel '{name}'", status=404) from None
setup_spec = channel_setup_spec(name, plugin=plugin)
if setup_spec is None:
raise WebUISettingsError(
f"channel '{name}' cannot be configured from WebUI",
status=404,
)
field_types = setup_spec.route_field_types
if not raw_values:
return []
section = getattr(config.channels, name, None)
channel_config = channel_instance_config(
plugin,
section,
instance_id=instance_id,
)
saved: list[str] = []
prefix = f"channels.{name}."
for raw_key, raw_value in raw_values.items():
if not raw_key:
raise WebUISettingsError(
"channel settings payload contains an invalid key"
)
field = raw_key[len(prefix) :] if raw_key.startswith(prefix) else raw_key
value_type = field_types.get(field)
if value_type is None:
raise WebUISettingsError(f"'{raw_key}' cannot be configured from WebUI")
value = coerce_channel_value(raw_key, raw_value, value_type)
if value is _SKIP_FIELD:
continue
assign_channel_config_value(channel_config, field, value)
saved.append(raw_key)
try:
updated_section = channel_update_instance_config(
plugin,
section,
channel_config,
instance_id=instance_id,
)
except ValueError as exc:
raise WebUISettingsError(
f"Invalid {name} configuration: {exc}",
status=400,
) from exc
setattr(config.channels, name, updated_section)
return saved
def coerce_channel_value(
raw_key: str,
raw_value: Any,
value_type: RouteFieldType,
) -> Any:
if isinstance(value_type, tuple):
kind = value_type[0]
allowed = value_type[1]
else:
kind = value_type
allowed = None
if kind in {"string", "secret"}:
value = raw_value.strip() if isinstance(raw_value, str) else str(raw_value)
if kind == "secret" and not value:
return _SKIP_FIELD
return value
if kind == "list":
if raw_value is None:
return []
if isinstance(raw_value, str):
return [item.strip() for item in raw_value.split(",") if item.strip()]
if isinstance(raw_value, list):
return [
str(item).strip()
for item in cast(list[Any], raw_value)
if str(item).strip()
]
raise WebUISettingsError(f"'{raw_key}' must be a comma-separated list")
if kind == "int":
if raw_value in (None, ""):
return _SKIP_FIELD
try:
return int(raw_value)
except (TypeError, ValueError) as exc:
raise WebUISettingsError(f"'{raw_key}' must be a number") from exc
if kind == "bool":
if isinstance(raw_value, bool):
return raw_value
value = str(raw_value).strip().lower()
if value in {"true", "1", "yes", "on"}:
return True
if value in {"false", "0", "no", "off"}:
return False
raise WebUISettingsError(f"'{raw_key}' must be true or false")
if kind == "enum":
value = raw_value.strip() if isinstance(raw_value, str) else str(raw_value)
if not value:
return _SKIP_FIELD
if allowed is None or value not in allowed:
options = ", ".join(sorted(allowed or ()))
raise WebUISettingsError(f"'{raw_key}' must be one of: {options}")
return value
raise WebUISettingsError(f"'{raw_key}' has an unsupported field type")
def assign_channel_config_value(
channel_config: dict[str, Any],
field: str,
value: Any,
) -> None:
target = channel_config
parts = field.split(".")
for part in parts[:-1]:
current: object = target.get(part)
if not isinstance(current, dict):
current = {}
target[part] = current
target = cast(dict[str, Any], current)
target[parts[-1]] = value
def pairing_payload(
list_pending: ListPendingPairings,
last_action: dict[str, Any] | None = None,
*,
now: float | None = None,
) -> dict[str, Any]:
current_time = time.time() if now is None else now
requests: list[dict[str, Any]] = []
for item in list_pending():
expires_at = float(item.get("expires_at", 0) or 0)
created_at = float(item.get("created_at", 0) or 0)
requests.append(
{
"code": str(item.get("code", "")),
"channel": str(item.get("channel", "")),
"sender_id": str(item.get("sender_id", "")),
"created_at_ms": int(created_at * 1000) if created_at else None,
"expires_at_ms": int(expires_at * 1000) if expires_at else None,
"expires_in_seconds": (
max(0, int(expires_at - current_time)) if expires_at else None
),
}
)
payload: dict[str, Any] = {"requests": requests}
if last_action is not None:
payload["last_action"] = last_action
return payload
class SystemSettingsHandler:
"""Handle channel and system commands behind a transport-neutral request DTO."""
def __init__(self, settings: WebUISettingsServices, logger: Any) -> None:
self.settings = settings
self.logger = logger
self._channel_connectors: dict[str, Any] = {}
async def handle(
self,
action: str,
request: SettingsRequest,
operations: SystemSettingsOperations,
*,
channel_name: str | None = None,
connect_action: str | None = None,
) -> SettingsRouteResult:
if action == "cli-list":
return await self._cli_apps(request, operations)
if action.startswith("cli-"):
return await self._cli_apps_action(
request,
action.removeprefix("cli-"),
operations,
)
if action == "features-list":
return await self._features(operations)
if action in {"features-enable", "features-disable"}:
return await self._features_action(
request,
action.removeprefix("features-"),
operations,
)
if action == "channel-validate":
return await self._channel_validate(request, operations)
if action == "channel-configure":
return await self._channel_configure(request, operations)
if action == "channel-connect" and channel_name and connect_action:
return await self._channel_connect(
request,
channel_name,
connect_action,
operations,
)
if action == "pairing-list":
return SettingsRouteResult.success(pairing_payload(operations.list_pending))
if action in {"pairing-approve", "pairing-deny"}:
return self._pairing_action(
request,
action.removeprefix("pairing-"),
operations,
)
if action == "mcp-list":
return await self._mcp_presets(request, None, operations)
if action.startswith("mcp-"):
return await self._mcp_presets(
request,
action.removeprefix("mcp-"),
operations,
)
if action == "version-check":
return await self._version_check(operations)
return SettingsRouteResult.failure(404, "unknown settings action")
async def _cli_apps(
self,
request: SettingsRequest,
operations: SystemSettingsOperations,
) -> SettingsRouteResult:
installed_only = (query_first(request.query, "installed_only") or "").lower() in {
"1",
"true",
"yes",
}
try:
payload = await operations.cli_apps_payload(
installed_only=installed_only,
config_path=self.settings.config.path,
)
except Exception:
self.logger.exception("failed to load CLI Apps payload")
return SettingsRouteResult.failure(500, "failed to load CLI Apps")
return SettingsRouteResult.success(payload)
async def _cli_apps_action(
self,
request: SettingsRequest,
action: str,
operations: SystemSettingsOperations,
) -> SettingsRouteResult:
try:
payload = await asyncio.to_thread(
operations.cli_apps_action,
action,
request.query,
config_path=self.settings.config.path,
)
except WebUISettingsError as exc:
return SettingsRouteResult.failure(exc.status, exc.message)
except Exception as exc:
status = getattr(exc, "status", 500)
message = getattr(exc, "message", str(exc))
if status >= 500:
self.logger.exception("CLI Apps action '{}' failed", action)
return SettingsRouteResult.failure(status, message)
return SettingsRouteResult.success(payload)
async def _features(
self,
operations: SystemSettingsOperations,
) -> SettingsRouteResult:
try:
payload = await asyncio.to_thread(
operations.nanobot_features_payload,
config_path=self.settings.config.path,
)
except Exception:
self.logger.exception("failed to load nanobot features")
return SettingsRouteResult.failure(500, "failed to load nanobot features")
return SettingsRouteResult.success(
self._with_channel_runtime_status(payload, operations)
)
def _nanobot_features_payload(
self,
operations: SystemSettingsOperations,
) -> dict[str, Any]:
return operations.nanobot_features_payload(config_path=self.settings.config.path)
def _nanobot_features_action(
self,
action: str,
query: QueryParams,
operations: SystemSettingsOperations,
*,
allow_install: bool = True,
) -> dict[str, Any]:
return self.settings.mutate(
operations.nanobot_features_action,
action,
query,
allow_install=allow_install,
)
async def _features_action(
self,
request: SettingsRequest,
action: str,
operations: SystemSettingsOperations,
) -> SettingsRouteResult:
try:
payload = await asyncio.to_thread(
self._nanobot_features_action,
action,
request.query,
operations,
allow_install=(
action != "enable"
or self.allow_feature_package_install(request)
),
)
except OptionalFeatureError as exc:
return SettingsRouteResult.failure(exc.status, exc.message)
except Exception as exc:
status = getattr(exc, "status", 500)
message = getattr(exc, "message", str(exc))
if status >= 500:
self.logger.exception(
"nanobot feature action '{}' failed",
action,
)
return SettingsRouteResult.failure(status, message)
payload = await self._apply_feature_runtime_change(
action,
request.query,
payload,
operations,
)
payload = self._with_channel_runtime_status(payload, operations)
return SettingsRouteResult.success(
payload,
decorate_restart=True,
restart_section="runtime",
)
def _with_channel_runtime_status(
self,
payload: dict[str, Any],
operations: SystemSettingsOperations,
) -> dict[str, Any]:
if operations.channel_runtime_status is None:
return payload
try:
return with_channel_runtime_status(
payload,
operations.channel_runtime_status(),
)
except Exception:
self.logger.exception("failed to load channel runtime status")
return payload
async def _apply_feature_runtime_change(
self,
action: str,
query: QueryParams,
payload: dict[str, Any],
operations: SystemSettingsOperations,
) -> dict[str, Any]:
if operations.channel_feature_action is None:
return payload
name = (query_first(query, "name") or "").strip()
if not name:
return payload
try:
instance_id = operations.nanobot_feature_instance_target(query)
result = operations.channel_feature_action(action, name, instance_id)
if inspect.isawaitable(result):
result = await result
except Exception as exc:
self.logger.exception("failed to apply channel '{}' without restart", name)
return self.feature_runtime_fallback(
payload,
message=(
f"{name} channel config was saved, but hot reload failed: {exc}"
),
)
if not isinstance(result, dict):
return payload
result = cast(dict[str, Any], result)
if not result.get("handled"):
return payload
updated = dict(payload)
updated["requires_restart"] = bool(result.get("requires_restart"))
message = result.get("message")
if isinstance(message, str) and message:
last_action = dict(updated.get("last_action") or {})
previous = last_action.get("message")
last_action["message"] = (
f"{previous}. {message}"
if isinstance(previous, str) and previous
else message
)
last_action["hot_reload"] = not updated["requires_restart"]
if "ok" in result:
last_action["ok"] = bool(result["ok"])
updated["last_action"] = last_action
return updated
@staticmethod
def feature_runtime_fallback(
payload: dict[str, Any],
*,
message: str,
) -> dict[str, Any]:
updated = dict(payload)
updated["requires_restart"] = True
last_action = dict(updated.get("last_action") or {})
previous = last_action.get("message")
last_action["message"] = (
f"{previous}. {message}"
if isinstance(previous, str) and previous
else message
)
last_action["hot_reload"] = False
updated["last_action"] = last_action
return updated
async def _channel_configure(
self,
request: SettingsRequest,
operations: SystemSettingsOperations,
) -> SettingsRouteResult:
name = (query_first(request.query, "name") or "").strip()
instance_id = (
query_first(request.query, "instance_id") or "default"
).strip()
enable = (query_first(request.query, "enable") or "").strip().lower() in {
"1",
"true",
"yes",
}
try:
saved = await asyncio.to_thread(
self._save_channel_config_values,
name,
self.parse_channel_values(request),
instance_id,
operations,
)
except WebUISettingsError as exc:
return SettingsRouteResult.failure(exc.status, exc.message)
except Exception:
self.logger.exception("failed to save channel '{}' settings", name)
return SettingsRouteResult.failure(500, "failed to save channel settings")
payload: dict[str, Any] = {
"name": name,
"saved": True,
"saved_keys": saved,
}
if not enable:
features = await asyncio.to_thread(
self._nanobot_features_payload,
operations,
)
payload["nanobot_features"] = self._with_channel_runtime_status(
features,
operations,
)
return SettingsRouteResult.success(
payload,
decorate_restart=True,
restart_section="runtime",
restart_payload_key="nanobot_features",
)
feature_query = {"name": [name]}
if instance_id:
feature_query["instance_id"] = [instance_id]
try:
features = await asyncio.to_thread(
self._nanobot_features_action,
"enable",
feature_query,
operations,
allow_install=self.allow_feature_package_install(request),
)
except OptionalFeatureError as exc:
return SettingsRouteResult.failure(
exc.status,
f"Settings saved, but {exc.message}",
)
except Exception as exc:
self.logger.exception(
"failed to enable channel '{}' after settings save",
name,
)
return SettingsRouteResult.failure(
500,
f"Settings saved, but enabling {name} failed: {exc}",
)
features = await self._apply_feature_runtime_change(
"enable",
feature_query,
features,
operations,
)
payload["nanobot_features"] = self._with_channel_runtime_status(
features,
operations,
)
return SettingsRouteResult.success(
payload,
decorate_restart=True,
restart_section="runtime",
restart_payload_key="nanobot_features",
)
async def _channel_validate(
self,
request: SettingsRequest,
operations: SystemSettingsOperations,
) -> SettingsRouteResult:
name = (query_first(request.query, "name") or "").strip()
instance_id = (
query_first(request.query, "instance_id") or "default"
).strip()
try:
payload = await asyncio.to_thread(
operations.validate_channel_config,
name,
self.parse_channel_values(request),
instance_id=instance_id,
)
except WebUISettingsError as exc:
return SettingsRouteResult.failure(exc.status, exc.message)
except Exception:
self.logger.exception("failed to validate channel '{}' settings", name)
return SettingsRouteResult.failure(
500,
"failed to validate channel settings",
)
return SettingsRouteResult.success(payload)
@staticmethod
def parse_channel_values(request: SettingsRequest) -> dict[str, Any]:
if request.payload is None or "values" not in request.payload:
return {}
values = request.payload.get("values")
if not isinstance(values, dict):
raise WebUISettingsError(
"channel settings payload must be a JSON object"
)
return cast(dict[str, Any], values)
def _save_channel_config_values(
self,
name: str,
raw_values: dict[str, Any],
instance_id: str,
operations: SystemSettingsOperations,
) -> list[str]:
return self.settings.config.update(
lambda config: save_channel_config_values(
config,
name,
raw_values,
instance_id,
load_channel_plugin=operations.load_channel_plugin,
)
)
async def _channel_connect(
self,
request: SettingsRequest,
channel_name: str,
action: str,
operations: SystemSettingsOperations,
) -> SettingsRouteResult:
try:
connector = self._channel_connectors.get(channel_name)
if connector is None:
plugin = operations.load_channel_plugin(channel_name)
connector = plugin.load_connector()
self._channel_connectors[channel_name] = connector
except ImportError:
return SettingsRouteResult.failure(
404,
f"channel '{channel_name}' does not support connect",
)
try:
payload = await connector.handle(action, request.query)
except ChannelConnectError as exc:
return SettingsRouteResult.failure(exc.status, exc.message)
except Exception:
self.logger.exception(
"failed to run {} WebUI connect action for {}",
action,
channel_name,
)
return SettingsRouteResult.failure(
500,
f"failed to {action} {channel_name} connection",
)
if payload.get("status") != "succeeded":
return SettingsRouteResult.success(payload)
payload = await self._with_channel_connect_success(
request,
channel_name,
payload,
operations,
)
return SettingsRouteResult.success(
payload,
decorate_restart=True,
restart_section="runtime",
restart_payload_key="nanobot_features",
)
async def _with_channel_connect_success(
self,
request: SettingsRequest,
channel_name: str,
payload: dict[str, Any],
operations: SystemSettingsOperations,
) -> dict[str, Any]:
target = {"name": [channel_name]}
if payload.get("instance_id"):
target["instance_id"] = [str(payload["instance_id"])]
try:
features = await asyncio.to_thread(
self._nanobot_features_action,
"enable",
target,
operations,
allow_install=self.allow_feature_package_install(request),
)
except OptionalFeatureError as exc:
features = self.feature_runtime_fallback(
self._nanobot_features_payload(operations),
message=(
f"{channel_name} connected, but enabling channel support failed: "
f"{exc.message}"
),
)
else:
features = await self._apply_feature_runtime_change(
"enable",
target,
features,
operations,
)
updated = dict(payload)
updated["nanobot_features"] = self._with_channel_runtime_status(
features,
operations,
)
return updated
def allow_feature_package_install(self, request: SettingsRequest) -> bool:
if request.local_browser:
return True
try:
return bool(
self.settings.config.load().tools.webui_allow_remote_package_install
)
except Exception:
self.logger.exception("failed to load remote package install policy")
return False
def _pairing_action(
self,
request: SettingsRequest,
action: str,
operations: SystemSettingsOperations,
) -> SettingsRouteResult:
code = (query_first(request.query, "code") or "").strip()
if not code:
return SettingsRouteResult.failure(400, "Missing pairing code")
if action == "approve":
result = operations.approve_code(code)
if result is None:
return SettingsRouteResult.failure(
404,
"Pairing code not found or expired",
)
channel, sender_id = result
return SettingsRouteResult.success(
pairing_payload(
operations.list_pending,
{
"ok": True,
"action": "approve",
"message": f"Approved {sender_id} for {channel}",
"channel": channel,
"sender_id": sender_id,
"code": code,
},
)
)
if not operations.deny_code(code):
return SettingsRouteResult.failure(
404,
"Pairing code not found or expired",
)
return SettingsRouteResult.success(
pairing_payload(
operations.list_pending,
{
"ok": True,
"action": "deny",
"message": f"Denied pairing code {code}",
"code": code,
},
)
)
async def _mcp_presets(
self,
request: SettingsRequest,
action: str | None,
operations: SystemSettingsOperations,
) -> SettingsRouteResult:
try:
payload = await operations.mcp_presets_action(
action,
request.query,
reload_mcp=operations.reload_mcp,
config=self.settings.config,
)
except Exception as exc:
status = getattr(exc, "status", 500)
message = getattr(exc, "message", str(exc))
if status >= 500:
self.logger.exception(
"MCP preset action '{}' failed",
action or "list",
)
return SettingsRouteResult.failure(status, message)
return SettingsRouteResult.success(
payload,
decorate_restart=action is not None,
restart_section="runtime" if action is not None else None,
)
async def _version_check(
self,
operations: SystemSettingsOperations,
) -> SettingsRouteResult:
try:
update_info = await asyncio.to_thread(operations.check_for_update)
except Exception:
self.logger.exception("version check failed")
return SettingsRouteResult.failure(500, "version check failed")
return SettingsRouteResult.success({"updateAvailable": update_info})
+13 -1
View File
@@ -17,7 +17,7 @@ import time
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
from urllib.parse import quote, unquote
from urllib.parse import quote, unquote, urlsplit, urlunsplit
from loguru import logger
from websockets.datastructures import Headers
@@ -166,6 +166,9 @@ _WEBUI_MUTATION_PATHS = {
"settings.mcp.import": "/api/settings/mcp-presets/import",
"settings.mcp.import_cursor": "/api/settings/mcp-presets/import-cursor",
"settings.mcp.tools": "/api/settings/mcp-presets/tools",
"settings.mcp.oauth_start": "/api/settings/mcp-oauth/start",
"settings.mcp.oauth_complete": "/api/settings/mcp-oauth/complete",
"settings.mcp.oauth_cancel": "/api/settings/mcp-oauth/cancel",
}
_WEBUI_CHANNEL_CONNECT_ACTIONS = {
@@ -344,6 +347,7 @@ class GatewayHTTPHandler:
runtime_capabilities=self._capabilities,
channel_feature_action=channel_feature_action,
channel_runtime_status=channel_runtime_status,
mcp_oauth_redirect_uri=self._mcp_oauth_redirect_uri,
)
def workspace_controls_available(self, connection: Any) -> bool:
@@ -617,6 +621,14 @@ class GatewayHTTPHandler:
expected_path = _normalize_config_path(self.config.path)
return f"{scheme}://{host}{expected_path}"
def _mcp_oauth_redirect_uri(self, request: WsRequest) -> str:
"""Derive the browser callback from the same public origin as WebSocket bootstrap."""
from nanobot.agent.tools.mcp_oauth import MCP_OAUTH_CALLBACK_PATH
public_ws_url = urlsplit(self._bootstrap_ws_url(request))
scheme = "https" if public_ws_url.scheme == "wss" else "http"
return urlunsplit((scheme, public_ws_url.netloc, MCP_OAUTH_CALLBACK_PATH, "", ""))
# -- Session routes -----------------------------------------------------
async def _dispatch_session_routes(self, request: WsRequest, got: str) -> Response | None:
+46
View File
@@ -406,6 +406,52 @@ async def test_reload_mcp_servers_retries_configured_server_without_live_stack(
await loop.close_mcp()
@pytest.mark.asyncio
async def test_reload_mcp_servers_skips_oauth_server_waiting_for_authorization(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
):
config_path = tmp_path / "config.json"
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
config = load_config()
notion = MCPServerConfig(
type="streamableHttp",
auth="oauth",
url="https://mcp.notion.test/mcp",
)
linear = MCPServerConfig(
type="streamableHttp",
auth="oauth",
url="https://mcp.linear.test/mcp",
)
config.tools.mcp_servers.update({"notion": notion, "linear": linear})
save_config(config)
attempted: list[str] = []
async def _fake_connect(servers, _registry):
attempted.extend(servers)
stack = AsyncExitStack()
await stack.__aenter__()
return {"linear": stack}
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
monkeypatch.setattr(
"nanobot.agent.tools.mcp_oauth.mcp_oauth_has_credentials",
lambda name, _url: name == "linear",
)
loop = _make_loop(tmp_path, mcp_servers={"notion": notion})
result = await mcp_runtime.reload_servers(loop, loop.tools)
assert attempted == ["linear"]
assert result["ok"] is True
assert result["failed"] == []
assert result["retried"] == []
assert result["connected"] == ["linear"]
await loop.close_mcp()
@pytest.mark.asyncio
async def test_mcp_tool_reconnects_after_session_terminated(
tmp_path,
+10
View File
@@ -133,6 +133,16 @@ class TestEditFileTool:
assert "Successfully" in result
assert f.read_text() == "hello earth"
@pytest.mark.asyncio
async def test_identical_replacement_returns_clear_error(self, tool, tmp_path):
f = tmp_path / "a.py"
f.write_text("hello world", encoding="utf-8")
result = await tool.execute(path=str(f), old_text="world", new_text="world")
assert result == "Error: new_text must be different from old_text."
assert f.read_text(encoding="utf-8") == "hello world"
@pytest.mark.asyncio
async def test_crlf_normalisation(self, tool, tmp_path):
f = tmp_path / "crlf.py"
+325
View File
@@ -0,0 +1,325 @@
from __future__ import annotations
import json
from urllib.parse import parse_qs, urlsplit
import httpx
import pytest
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
from nanobot.agent.tools.mcp_oauth import (
MCPAuthorizationRequiredError,
MCPOAuthHandlers,
MCPOAuthStorage,
create_mcp_oauth_auth,
delete_mcp_oauth_credentials,
mcp_oauth_has_credentials,
)
from nanobot.config.schema import MCPServerConfig
def _use_data_dir(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("nanobot.agent.tools.mcp_oauth.get_data_dir", lambda: tmp_path)
def test_mcp_server_config_accepts_explicit_oauth() -> None:
config = MCPServerConfig.model_validate({
"type": "streamableHttp",
"url": "https://mcp.example.com/mcp",
"auth": "oauth",
})
assert config.auth == "oauth"
assert config.model_dump(by_alias=True)["auth"] == "oauth"
@pytest.mark.asyncio
async def test_mcp_oauth_storage_isolates_name_and_server_url(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_data_dir(tmp_path, monkeypatch)
storage = MCPOAuthStorage("notion-work", "https://mcp.example.com/mcp")
tokens = OAuthToken(access_token="access-secret", refresh_token="refresh-secret")
client_info = OAuthClientInformationFull(
redirect_uris=["https://agent.example/auth/mcp/callback"],
client_id="client-id",
client_secret="client-secret",
)
await storage.prepare_redirect_uri("https://agent.example/auth/mcp/callback")
await storage.set_tokens(tokens)
await storage.set_client_info(client_info)
assert await storage.get_tokens() == tokens
assert await storage.get_client_info() == client_info
assert await storage.redirect_uri() == "https://agent.example/auth/mcp/callback"
assert mcp_oauth_has_credentials("notion-work", "https://mcp.example.com/mcp")
assert not mcp_oauth_has_credentials("notion-home", "https://mcp.example.com/mcp")
assert not mcp_oauth_has_credentials("notion-work", "https://other.example.com/mcp")
payload = json.loads((tmp_path / "auth" / "mcp.json").read_text(encoding="utf-8"))
assert "https://mcp.example.com/mcp" not in str(payload)
assert "access-secret" in str(payload)
@pytest.mark.asyncio
async def test_changed_redirect_uri_discards_dynamic_registration_but_keeps_tokens(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_data_dir(tmp_path, monkeypatch)
storage = MCPOAuthStorage("linear", "https://mcp.linear.example/mcp")
await storage.prepare_redirect_uri("https://old.example/auth/mcp/callback")
await storage.set_tokens(OAuthToken(access_token="access-secret"))
await storage.set_client_info(OAuthClientInformationFull(
redirect_uris=["https://old.example/auth/mcp/callback"],
client_id="old-client",
))
await storage.prepare_redirect_uri("https://new.example/auth/mcp/callback")
assert await storage.get_tokens() is not None
assert await storage.get_client_info() is None
@pytest.mark.asyncio
async def test_reset_and_delete_credentials_are_scoped_to_one_server(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_data_dir(tmp_path, monkeypatch)
first = MCPOAuthStorage("first", "https://mcp.example.com/mcp")
second = MCPOAuthStorage("second", "https://mcp.example.com/mcp")
await first.set_tokens(OAuthToken(access_token="first-token"))
await second.set_tokens(OAuthToken(access_token="second-token"))
await first.prepare_redirect_uri(
"https://agent.example/auth/mcp/callback",
reset=True,
)
assert await first.get_tokens() is None
assert await second.get_tokens() is not None
assert delete_mcp_oauth_credentials("first")
assert not delete_mcp_oauth_credentials("first")
assert await second.get_tokens() is not None
@pytest.mark.asyncio
async def test_deleted_credentials_reject_late_writes_from_stale_oauth_flow(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_data_dir(tmp_path, monkeypatch)
server_url = "https://mcp.linear.example/mcp"
stale = MCPOAuthStorage("linear", server_url)
await stale.prepare_redirect_uri("https://old.example/auth/mcp/callback")
assert delete_mcp_oauth_credentials("linear")
await stale.set_tokens(OAuthToken(access_token="late-after-delete"))
assert not mcp_oauth_has_credentials("linear", server_url)
replacement = MCPOAuthStorage("linear", server_url)
await replacement.prepare_redirect_uri("https://new.example/auth/mcp/callback")
await stale.set_tokens(OAuthToken(access_token="late-after-replacement"))
assert not mcp_oauth_has_credentials("linear", server_url)
assert await replacement.get_tokens() is None
await replacement.set_tokens(OAuthToken(access_token="fresh-token"))
stored = await replacement.get_tokens()
assert stored is not None
assert stored.access_token == "fresh-token"
@pytest.mark.asyncio
async def test_delete_before_oauth_claim_rejects_late_credential_writes(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_data_dir(tmp_path, monkeypatch)
server_url = "https://mcp.linear.example/mcp"
stale = MCPOAuthStorage("linear", server_url)
assert not delete_mcp_oauth_credentials("linear")
with pytest.raises(MCPAuthorizationRequiredError, match="cancelled"):
await stale.prepare_redirect_uri("https://old.example/auth/mcp/callback")
await stale.set_tokens(OAuthToken(access_token="late-after-delete"))
assert not mcp_oauth_has_credentials("linear", server_url)
replacement = MCPOAuthStorage("linear", server_url)
await replacement.prepare_redirect_uri("https://new.example/auth/mcp/callback")
await replacement.set_tokens(OAuthToken(access_token="fresh-token"))
assert mcp_oauth_has_credentials("linear", server_url)
@pytest.mark.asyncio
async def test_create_mcp_oauth_auth_uses_browser_handlers_and_persists_redirect(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_data_dir(tmp_path, monkeypatch)
async def redirect(_url: str) -> None:
return None
async def callback() -> tuple[str, str | None]:
return "code", "state"
handlers = MCPOAuthHandlers(
redirect_uri="https://agent.example/auth/mcp/callback",
redirect_handler=redirect,
callback_handler=callback,
)
auth = await create_mcp_oauth_auth(
"xmind",
"https://app.xmind.example/api/mcp",
handlers,
)
assert str(auth.context.client_metadata.redirect_uris[0]) == (
"https://agent.example/auth/mcp/callback"
)
assert str(auth.context.client_metadata.client_uri) == "https://github.com/HKUDS/nanobot"
assert str(auth.context.client_metadata.logo_uri) == (
"https://raw.githubusercontent.com/HKUDS/nanobot/main/"
"webui/public/brand/nanobot_apple_touch.png"
)
assert auth.context.redirect_handler is redirect
assert auth.context.callback_handler is callback
storage = MCPOAuthStorage("xmind", "https://app.xmind.example/api/mcp")
assert await storage.redirect_uri() == "https://agent.example/auth/mcp/callback"
@pytest.mark.asyncio
async def test_background_authorization_without_tokens_stops_locally(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_data_dir(tmp_path, monkeypatch)
with pytest.raises(MCPAuthorizationRequiredError):
await create_mcp_oauth_auth("notion", "https://mcp.notion.example/mcp")
assert not (tmp_path / "auth" / "mcp.json").exists()
@pytest.mark.asyncio
async def test_background_authorization_request_clears_rejected_token(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_data_dir(tmp_path, monkeypatch)
server_url = "https://mcp.example.com/mcp"
storage = MCPOAuthStorage("notion", server_url)
client_info = OAuthClientInformationFull(
redirect_uris=["https://agent.example/auth/mcp/callback"],
client_id="registered-client",
)
await storage.set_tokens(OAuthToken(access_token="rejected-token"))
await storage.set_client_info(client_info)
auth = await create_mcp_oauth_auth("notion", server_url)
redirect_handler = auth.context.redirect_handler
assert redirect_handler is not None
with pytest.raises(MCPAuthorizationRequiredError):
await redirect_handler("https://accounts.example.com/authorize?state=state")
assert await storage.get_tokens() is None
assert await storage.get_client_info() == client_info
@pytest.mark.asyncio
async def test_official_mcp_sdk_completes_discovery_registration_and_token_exchange(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_data_dir(tmp_path, monkeypatch)
server_url = "https://mcp.example.com/mcp"
authorization_url = ""
requests: list[tuple[str, str]] = []
async def redirect(url: str) -> None:
nonlocal authorization_url
authorization_url = url
async def callback() -> tuple[str, str | None]:
state = parse_qs(urlsplit(authorization_url).query)["state"][0]
return "authorization-code", state
auth = await create_mcp_oauth_auth(
"company-mcp",
server_url,
MCPOAuthHandlers(
redirect_uri="https://agent.example/auth/mcp/callback",
redirect_handler=redirect,
callback_handler=callback,
),
)
async def respond(request: httpx.Request) -> httpx.Response:
requests.append((request.method, str(request.url)))
if str(request.url) == server_url:
if request.headers.get("Authorization") == "Bearer access-token":
return httpx.Response(200, json={"ok": True})
return httpx.Response(
401,
headers={
"WWW-Authenticate": (
'Bearer resource_metadata="https://mcp.example.com/'
'.well-known/oauth-protected-resource"'
)
},
)
if request.url.path == "/.well-known/oauth-protected-resource":
return httpx.Response(200, json={
"resource": server_url,
"authorization_servers": ["https://auth.example.com"],
})
if request.url.path == "/.well-known/oauth-authorization-server":
return httpx.Response(200, json={
"issuer": "https://auth.example.com",
"authorization_endpoint": "https://auth.example.com/authorize",
"token_endpoint": "https://auth.example.com/token",
"registration_endpoint": "https://auth.example.com/register",
"response_types_supported": ["code"],
"code_challenge_methods_supported": ["S256"],
})
if request.url.path == "/register":
registration = json.loads(request.content)
assert registration["client_uri"] == "https://github.com/HKUDS/nanobot"
assert registration["logo_uri"].endswith(
"/webui/public/brand/nanobot_apple_touch.png"
)
return httpx.Response(201, json={
"client_id": "nanobot-client",
"redirect_uris": ["https://agent.example/auth/mcp/callback"],
"token_endpoint_auth_method": "none",
})
if request.url.path == "/token":
return httpx.Response(200, json={
"access_token": "access-token",
"refresh_token": "refresh-token",
"token_type": "Bearer",
"expires_in": 3600,
})
return httpx.Response(404)
async with httpx.AsyncClient(
transport=httpx.MockTransport(respond),
auth=auth,
) as client:
response = await client.get(server_url)
assert response.status_code == 200
assert urlsplit(authorization_url)._replace(query="").geturl() == (
"https://auth.example.com/authorize"
)
assert ("POST", "https://auth.example.com/register") in requests
assert ("POST", "https://auth.example.com/token") in requests
stored = await MCPOAuthStorage("company-mcp", server_url).get_tokens()
assert stored is not None
assert stored.access_token == "access-token"
assert stored.refresh_token == "refresh-token"
+162 -5
View File
@@ -826,19 +826,23 @@ async def test_connect_mcp_servers_logs_stdio_pollution_hint(
) -> None:
messages: list[str] = []
def _error(message: str, *args: object) -> None:
messages.append(message.format(*args))
@asynccontextmanager
async def _broken_stdio_client(_params: object):
raise RuntimeError("Parse error: Unexpected token 'INFO' before JSON-RPC headers")
yield # pragma: no cover
monkeypatch.setattr(sys.modules["mcp.client.stdio"], "stdio_client", _broken_stdio_client)
monkeypatch.setattr("nanobot.agent.tools.mcp.logger.exception", _error)
sink = mcp_mod.logger.add(
lambda message: messages.append(message.record["message"]), level="ERROR"
)
registry = ToolRegistry()
stacks = await connect_mcp_servers({"gh": MCPServerConfig(command="github-mcp")}, registry)
try:
stacks = await connect_mcp_servers(
{"gh": MCPServerConfig(command="github-mcp")}, registry
)
finally:
mcp_mod.logger.remove(sink)
assert stacks == {}
assert messages
@@ -847,6 +851,36 @@ async def test_connect_mcp_servers_logs_stdio_pollution_hint(
assert "stderr" in messages[-1]
def test_transient_connection_group_logs_brief_warning_and_debug_trace() -> None:
records: list[dict] = []
sink = mcp_mod.logger.add(lambda message: records.append(message.record), level="DEBUG")
error = ExceptionGroup("transport failed", [httpx.ConnectError("")])
try:
mcp_mod._log_mcp_connection_failure("notion", error)
finally:
mcp_mod.logger.remove(sink)
warning = next(record for record in records if record["level"].name == "WARNING")
debug = next(record for record in records if record["level"].name == "DEBUG")
assert warning["exception"] is None
assert "transient connection failure" in warning["message"]
assert debug["exception"] is not None
assert not any(record["level"].name == "ERROR" for record in records)
def test_unexpected_connection_failure_keeps_error_trace() -> None:
records: list[dict] = []
sink = mcp_mod.logger.add(lambda message: records.append(message.record), level="DEBUG")
try:
mcp_mod._log_mcp_connection_failure("notion", RuntimeError("boom"))
finally:
mcp_mod.logger.remove(sink)
error = next(record for record in records if record["level"].name == "ERROR")
assert error["exception"] is not None
assert not any(record["level"].name == "WARNING" for record in records)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"config",
@@ -1210,6 +1244,129 @@ async def test_connect_mcp_servers_streamable_http_uses_finite_timeout(
assert timeout.pool == 30.0
@pytest.mark.parametrize("transport", ["sse", "streamableHttp"])
@pytest.mark.asyncio
async def test_connect_mcp_servers_attaches_oauth_to_remote_http_client(
transport: str,
fake_mcp_runtime: dict[str, object | None],
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_mcp_runtime["session"] = _make_fake_session(["demo"])
oauth_auth = object()
oauth_handlers = object()
captured: dict[str, object] = {}
async def _reachable(_url: str) -> bool:
return True
def _validate(_url: str) -> tuple[bool, str]:
return True, ""
async def _create_auth(name: str, url: str, handlers: object) -> object:
captured.update(name=name, url=url, handlers=handlers)
return oauth_auth
oauth_mod = ModuleType("nanobot.agent.tools.mcp_oauth")
oauth_mod.MCPAuthorizationRequiredError = RuntimeError # type: ignore[attr-defined]
oauth_mod.create_mcp_oauth_auth = _create_auth # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "nanobot.agent.tools.mcp_oauth", oauth_mod)
class FakeAsyncClient:
def __init__(self, *args: object, **kwargs: object) -> None:
captured["client_kwargs"] = kwargs
async def __aenter__(self) -> object:
return self
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> bool:
return False
@asynccontextmanager
async def _capturing_sse_client(
_url: str,
httpx_client_factory=None,
auth=None,
):
captured["transport_auth"] = auth
yield object(), object()
@asynccontextmanager
async def _capturing_streamable_http_client(_url: str, http_client=None):
assert http_client is not None
yield object(), object(), object()
monkeypatch.setattr(mcp_mod, "validate_url_target", _validate)
monkeypatch.setattr(mcp_mod, "_probe_http_url", _reachable)
monkeypatch.setattr(mcp_mod.httpx, "AsyncClient", FakeAsyncClient)
monkeypatch.setattr(sys.modules["mcp.client.sse"], "sse_client", _capturing_sse_client)
monkeypatch.setattr(
sys.modules["mcp.client.streamable_http"],
"streamable_http_client",
_capturing_streamable_http_client,
)
url = "https://mcp.example.com/sse" if transport == "sse" else "https://mcp.example.com/mcp"
registry = ToolRegistry()
stacks = await connect_mcp_servers(
{"remote": MCPServerConfig(type=transport, url=url, auth="oauth")},
registry,
oauth_handlers={"remote": oauth_handlers}, # type: ignore[arg-type]
)
for stack in stacks.values():
await stack.aclose()
assert captured["name"] == "remote"
assert captured["url"] == url
assert captured["handlers"] is oauth_handlers
if transport == "sse":
assert captured["transport_auth"] is oauth_auth
else:
client_kwargs = captured["client_kwargs"]
assert isinstance(client_kwargs, dict)
assert client_kwargs["auth"] is oauth_auth
assert client_kwargs["event_hooks"] == {"request": [mcp_mod._validate_mcp_request_url]}
@pytest.mark.asyncio
async def test_connect_mcp_servers_skips_background_oauth_without_credentials(
fake_mcp_runtime: dict[str, object | None],
monkeypatch: pytest.MonkeyPatch,
) -> None:
class AuthorizationRequiredError(RuntimeError):
pass
async def _create_auth(*_args: object) -> object:
raise AuthorizationRequiredError
probe_called = False
async def _probe(_url: str) -> bool:
nonlocal probe_called
probe_called = True
return True
oauth_mod = ModuleType("nanobot.agent.tools.mcp_oauth")
oauth_mod.MCPAuthorizationRequiredError = AuthorizationRequiredError # type: ignore[attr-defined]
oauth_mod.create_mcp_oauth_auth = _create_auth # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "nanobot.agent.tools.mcp_oauth", oauth_mod)
monkeypatch.setattr(mcp_mod, "validate_url_target", lambda _url: (True, ""))
monkeypatch.setattr(mcp_mod, "_probe_http_url", _probe)
stacks = await connect_mcp_servers(
{
"remote": MCPServerConfig(
type="streamableHttp",
url="https://mcp.example.com/mcp",
auth="oauth",
)
},
ToolRegistry(),
)
assert stacks == {}
assert not probe_called
@pytest.mark.asyncio
async def test_connect_mcp_servers_wraps_windows_stdio_launchers(
fake_mcp_runtime: dict[str, object | None],
+283
View File
@@ -0,0 +1,283 @@
from __future__ import annotations
import asyncio
import pytest
from nanobot.config.schema import MCPServerConfig
from nanobot.webui.mcp_oauth_api import (
McpOAuthError,
McpOAuthManager,
prepare_mcp_oauth_redirect_uri,
validate_mcp_oauth_redirect_uri,
)
class _Connection:
def __init__(self) -> None:
self.closed = False
async def aclose(self) -> None:
self.closed = True
def _config() -> MCPServerConfig:
return MCPServerConfig(
type="streamableHttp",
auth="oauth",
url="https://mcp.example.com/mcp",
)
@pytest.mark.asyncio
async def test_browser_flow_retries_current_server_and_ignores_unrelated_reload_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = McpOAuthManager()
connection = _Connection()
received: dict[str, object] = {}
reload_calls = 0
monkeypatch.setattr(
"nanobot.webui.mcp_oauth_api.validate_url_target",
lambda _url: (True, ""),
)
async def connect(servers, _registry, *, oauth_handlers):
assert set(servers) == {"xmind"}
handlers = oauth_handlers["xmind"]
await handlers.redirect_handler(
"https://accounts.example.com/authorize?client_id=test&state=state-123"
)
received["callback"] = await handlers.callback_handler()
return {"xmind": connection}
async def reload_mcp() -> dict[str, object]:
nonlocal reload_calls
reload_calls += 1
if reload_calls == 1:
return {
"ok": False,
"requires_restart": False,
"failed": ["xmind"],
}
return {
"ok": False,
"requires_restart": False,
"connected": ["xmind"],
"failed": ["notion"],
"message": "MCP config reloaded, but some servers did not connect: notion",
}
monkeypatch.setattr("nanobot.webui.mcp_oauth_api.connect_mcp_servers", connect)
started = await manager.start(
"xmind",
_config(),
"https://agent.example.com/auth/mcp/callback",
reload_mcp=reload_mcp,
)
assert started["status"] == "authorization_required"
assert started["authorization_url"].startswith("https://accounts.example.com/authorize?")
manager.submit_callback(state="state-123", code="oauth-code", error=None)
with pytest.raises(McpOAuthError, match="expired"):
manager.submit_callback(state="state-123", code="replayed-code", error=None)
for _ in range(10):
await asyncio.sleep(0)
if reload_calls == 2:
break
assert reload_calls == 2
for _ in range(10):
first, second = await asyncio.gather(
manager.status(started["flow_id"]),
manager.status(started["flow_id"]),
)
if first["status"] == "connected":
break
await asyncio.sleep(0)
assert first["status"] == "connected"
assert second["status"] == "connected"
assert first["hot_reload"]["failed"] == ["notion"]
assert received["callback"] == ("oauth-code", "state-123")
assert reload_calls == 2
assert connection.closed is True
@pytest.mark.asyncio
async def test_remote_http_flow_accepts_a_pasted_loopback_callback(
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = McpOAuthManager()
connection = _Connection()
received: dict[str, object] = {}
monkeypatch.setattr(
"nanobot.webui.mcp_oauth_api.validate_url_target",
lambda _url: (True, ""),
)
async def connect(_servers, _registry, *, oauth_handlers):
handlers = oauth_handlers["linear"]
received["redirect_uri"] = handlers.redirect_uri
await handlers.redirect_handler(
"https://accounts.example.com/authorize?client_id=test&state=manual-state"
)
received["callback"] = await handlers.callback_handler()
return {"linear": connection}
monkeypatch.setattr("nanobot.webui.mcp_oauth_api.connect_mcp_servers", connect)
started = await manager.start(
"linear",
_config(),
"http://192.0.2.10:8765/auth/mcp/callback",
reload_mcp=lambda: asyncio.sleep(
0,
result={"ok": True, "requires_restart": False},
),
)
assert started["status"] == "authorization_required"
assert started["completion_input"] == "callback_url"
assert received["redirect_uri"] == "http://127.0.0.1:8765/auth/mcp/callback"
with pytest.raises(McpOAuthError, match="complete callback URL"):
manager.submit_callback_url(
flow_id=started["flow_id"],
callback_url=(
"http://127.0.0.1:8765/wrong?code=oauth-code&state=manual-state"
),
)
with pytest.raises(McpOAuthError, match="different or expired"):
manager.submit_callback_url(
flow_id=started["flow_id"],
callback_url=(
"http://127.0.0.1:8765/auth/mcp/callback"
"?code=oauth-code&state=other-state"
),
)
submitted = manager.submit_callback_url(
flow_id=started["flow_id"],
callback_url=(
"http://127.0.0.1:8765/auth/mcp/callback"
"?code=oauth-code&state=manual-state"
),
)
assert submitted["status"] == "connecting"
for _ in range(20):
await asyncio.sleep(0)
result = await manager.status(started["flow_id"])
if result["status"] == "connected":
break
assert result["status"] == "connected"
assert result["completion_input"] == "callback_url"
assert received["callback"] == ("oauth-code", "manual-state")
assert connection.closed is True
@pytest.mark.asyncio
async def test_browser_flow_surfaces_provider_denial_without_callback_description(
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = McpOAuthManager()
monkeypatch.setattr(
"nanobot.webui.mcp_oauth_api.validate_url_target",
lambda _url: (True, ""),
)
async def connect(_servers, _registry, *, oauth_handlers):
handlers = oauth_handlers["notion"]
await handlers.redirect_handler("https://accounts.example.com/auth?state=deny-state")
await handlers.callback_handler()
return {}
monkeypatch.setattr("nanobot.webui.mcp_oauth_api.connect_mcp_servers", connect)
started = await manager.start(
"notion",
_config(),
"https://agent.example.com/auth/mcp/callback",
reload_mcp=lambda: asyncio.sleep(0, result={"ok": True}),
)
with pytest.raises(McpOAuthError, match="access_denied"):
manager.submit_callback(state="deny-state", code=None, error="access_denied")
for _ in range(10):
await asyncio.sleep(0)
result = await manager.status(started["flow_id"])
if result["status"] == "failed":
break
assert result["status"] == "failed"
assert result["error"] == "Authorization was not completed (access_denied)."
@pytest.mark.parametrize(
("authorization_url", "url_is_safe", "state"),
[
("https://127.0.0.1/authorize?state=private-state", False, "private-state"),
("http://accounts.example.com/authorize?state=http-state", True, "http-state"),
],
)
@pytest.mark.asyncio
async def test_browser_flow_blocks_unsafe_authorization_url(
monkeypatch: pytest.MonkeyPatch,
authorization_url: str,
url_is_safe: bool,
state: str,
) -> None:
manager = McpOAuthManager()
monkeypatch.setattr(
"nanobot.webui.mcp_oauth_api.validate_url_target",
lambda _url: (url_is_safe, "private address"),
)
async def connect(_servers, _registry, *, oauth_handlers):
await oauth_handlers["linear"].redirect_handler(authorization_url)
return {}
monkeypatch.setattr("nanobot.webui.mcp_oauth_api.connect_mcp_servers", connect)
result = await manager.start(
"linear",
_config(),
"https://agent.example.com/auth/mcp/callback",
reload_mcp=lambda: asyncio.sleep(0, result={"ok": True}),
)
assert result["status"] == "failed"
assert result["error"] == "The MCP server returned an unsafe authorization URL."
with pytest.raises(McpOAuthError, match="expired"):
manager.submit_callback(state=state, code="code", error=None)
def test_redirect_uri_requires_https_except_for_loopback() -> None:
assert validate_mcp_oauth_redirect_uri(
"https://agent.example.com/auth/mcp/callback"
) == "https://agent.example.com/auth/mcp/callback"
assert validate_mcp_oauth_redirect_uri(
"http://127.0.0.1:8765/auth/mcp/callback"
) == "http://127.0.0.1:8765/auth/mcp/callback"
with pytest.raises(McpOAuthError, match="HTTPS or localhost"):
validate_mcp_oauth_redirect_uri("http://192.0.2.10/auth/mcp/callback")
with pytest.raises(McpOAuthError, match="Invalid"):
validate_mcp_oauth_redirect_uri("https://agent.example.com/wrong")
def test_remote_http_redirect_prepares_a_manual_loopback_callback() -> None:
assert prepare_mcp_oauth_redirect_uri(
"https://agent.example.com/auth/mcp/callback"
) == ("https://agent.example.com/auth/mcp/callback", False)
assert prepare_mcp_oauth_redirect_uri(
"http://127.0.0.1:8765/auth/mcp/callback"
) == ("http://127.0.0.1:8765/auth/mcp/callback", False)
assert prepare_mcp_oauth_redirect_uri(
"http://agent.example.com:9443/auth/mcp/callback"
) == ("http://127.0.0.1:9443/auth/mcp/callback", True)
+104 -2
View File
@@ -3,7 +3,9 @@ from __future__ import annotations
import asyncio
import pytest
from mcp.shared.auth import OAuthToken
from nanobot.agent.tools.mcp_oauth import MCPOAuthStorage, mcp_oauth_has_credentials
from nanobot.config.loader import load_config
from nanobot.webui.mcp_presets_api import (
McpPresetError,
@@ -38,6 +40,9 @@ def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest
"aws-docs",
"brave-search",
"postman",
"xmind",
"notion",
"linear",
}.issubset(names)
browserbase = next(preset for preset in payload["presets"] if preset["name"] == "browserbase")
assert browserbase["installed"] is False
@@ -55,6 +60,37 @@ def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest
assert manifest["trust"]["review_status"] == "builtin_preset"
@pytest.mark.asyncio
async def test_oauth_preset_is_one_click_configured_after_token_storage(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_config(tmp_path, monkeypatch)
payload = mcp_presets_action("enable", {"name": ["xmind"]})
row = next(item for item in payload["presets"] if item["name"] == "xmind")
assert row["installed"] is True
assert row["configured"] is False
assert row["status"] == "authorization_required"
assert row["transport"] == "streamableHttp"
assert row["auth"] == "oauth"
config = load_config()
cfg = config.tools.mcp_servers["xmind"]
assert cfg.type == "streamableHttp"
assert cfg.auth == "oauth"
assert cfg.url == "https://app.xmind.com/api/mcp"
await MCPOAuthStorage("xmind", cfg.url).set_tokens(OAuthToken(access_token="secret"))
connected = mcp_presets_payload()
row = next(item for item in connected["presets"] if item["name"] == "xmind")
assert row["configured"] is True
assert row["status"] == "configured"
mcp_presets_action("remove", {"name": ["xmind"]})
assert await MCPOAuthStorage("xmind", cfg.url).get_tokens() is None
def test_enable_browserbase_writes_scrubbed_config_payload(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
@@ -296,11 +332,11 @@ def test_test_mcp_preset_scrubs_connection_errors(
assert "<redacted>" in payload["last_action"]["error"]
def test_unlisted_oauth_placeholder_is_not_enabled(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
def test_unknown_oauth_placeholder_is_not_enabled(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
_use_config(tmp_path, monkeypatch)
with pytest.raises(McpPresetError) as exc:
mcp_presets_action("enable", {"name": ["linear"]})
mcp_presets_action("enable", {"name": ["asana"]})
assert exc.value.status == 404
@@ -414,6 +450,72 @@ def test_import_mcp_config_and_tool_allowlist(
assert load_config().tools.mcp_servers["docs"].enabled_tools == []
def test_import_recognizes_known_and_explicit_oauth_servers(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_config(tmp_path, monkeypatch)
payload = custom_mcp_action(
"import",
{
"config": [
(
'{"mcpServers":{'
'"notion-work":{"url":"https://mcp.notion.com/mcp"},'
'"company-mcp":{"url":"https://mcp.example.com/mcp","auth":"oauth"},'
'"notion-pat":{"url":"https://mcp.notion.com/mcp",'
'"headers":{"Authorization":"Bearer secret"}}'
'}}'
)
],
},
)
config = load_config()
assert config.tools.mcp_servers["notion-work"].auth == "oauth"
assert config.tools.mcp_servers["company-mcp"].auth == "oauth"
assert config.tools.mcp_servers["notion-pat"].auth is None
rows = {row["name"]: row for row in payload["presets"]}
assert rows["notion-work"]["status"] == "authorization_required"
assert rows["company-mcp"]["status"] == "authorization_required"
assert rows["notion-pat"]["status"] == "configured"
assert "Bearer secret" not in str(payload)
@pytest.mark.asyncio
async def test_replacing_oauth_config_removes_its_stored_credentials(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_config(tmp_path, monkeypatch)
server_url = "https://mcp.example.com/mcp"
custom_mcp_action(
"custom",
{
"name": ["company-mcp"],
"transport": ["streamableHttp"],
"url": [server_url],
"auth": ["oauth"],
},
)
await MCPOAuthStorage("company-mcp", server_url).set_tokens(
OAuthToken(access_token="secret")
)
assert mcp_oauth_has_credentials("company-mcp", server_url)
custom_mcp_action(
"custom",
{
"name": ["company-mcp"],
"transport": ["streamableHttp"],
"url": [server_url],
},
)
assert not mcp_oauth_has_credentials("company-mcp", server_url)
def test_normalize_mcp_preset_mentions_accepts_configured_custom_server(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
from typing import Any
from nanobot.config.schema import Config
from nanobot.webui.settings_capabilities import (
capability_settings_payload,
update_api_settings,
update_image_generation_settings,
update_network_safety_settings,
update_transcription_settings,
update_web_search_settings,
)
def _oauth_status(_spec: Any) -> dict[str, Any]:
return {"configured": False}
def test_capability_domain_updates_representative_settings() -> None:
config = Config()
config.providers.openrouter.api_key = "sk-test"
web_changed, web_restart = update_web_search_settings(
config,
{
"provider": ["duckduckgo"],
"max_results": ["7"],
"use_jina_reader": ["false"],
},
)
update_api_settings(
config,
{"host": ["127.0.0.2"], "port": ["8900"], "timeout": ["90"]},
)
image_changed = update_image_generation_settings(
config,
{"enabled": ["true"], "provider": ["openrouter"]},
oauth_status=_oauth_status,
)
transcription_changed = update_transcription_settings(
config,
{"provider": ["openrouter"], "model": ["openai/whisper-large-v3"]},
)
network_changed, access_mode = update_network_safety_settings(
config,
{
"webui_allow_local_service_access": ["false"],
"webui_default_access_mode": ["restricted"],
},
)
payload = capability_settings_payload(config, oauth_status=_oauth_status)
assert (web_changed, web_restart) == (True, True)
assert image_changed is True
assert transcription_changed is True
assert (network_changed, access_mode) == (True, "default")
assert payload["web_search"]["max_results"] == 7
assert payload["api"]["host"] == "127.0.0.2"
assert payload["api"]["port"] == 8900
assert payload["image_generation"]["enabled"] is True
assert payload["transcription"]["provider"] == "openrouter"
+58
View File
@@ -0,0 +1,58 @@
from __future__ import annotations
from typing import Any
from nanobot.config.schema import Config
from nanobot.webui.settings_models import (
model_settings_payload,
update_agent_model_settings,
update_provider_settings,
)
def _oauth_status(_spec: Any) -> dict[str, Any]:
return {
"configured": False,
"account": None,
"expires_at": None,
"login_supported": True,
}
def test_model_domain_owns_dto_and_config_updates() -> None:
config = Config()
config.providers.openrouter.api_key = "sk-before"
agent_changed = update_agent_model_settings(
config,
{
"model": ["openai/gpt-5.4"],
"provider": ["openrouter"],
"context_window_tokens": ["200000"],
},
oauth_status=_oauth_status,
)
provider_changed, restart_required = update_provider_settings(
config,
{
"provider": ["openrouter"],
"api_key": ["sk-after"],
},
)
payload = model_settings_payload(config, oauth_status=_oauth_status)
assert agent_changed is True
assert provider_changed is True
assert restart_required is False
assert config.agents.defaults.model == "openai/gpt-5.4"
assert config.agents.defaults.provider == "openrouter"
assert config.agents.defaults.context_window_tokens == 200_000
assert config.providers.openrouter.api_key == "sk-after"
assert set(payload) == {
"agent",
"model_presets",
"model_call_order",
"model_call_order_editable",
"providers",
}
assert payload["agent"]["model"] == "openai/gpt-5.4"
+117 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import json
from types import SimpleNamespace
from unittest.mock import MagicMock
from unittest.mock import ANY, AsyncMock, MagicMock
from urllib.parse import parse_qs, urlsplit
import pytest
@@ -28,6 +28,7 @@ def _router(*, authorized: bool = True) -> WebUISettingsRouter:
),
runtime_surface="browser",
runtime_capabilities={},
mcp_oauth_redirect_uri=lambda _request: "https://gateway.example/auth/mcp/callback",
)
@@ -39,6 +40,121 @@ def _mutation_request(path: str, payload: dict[str, object]) -> SimpleNamespace:
return request
@pytest.mark.asyncio
async def test_mcp_oauth_start_uses_gateway_callback_and_requires_api_auth(monkeypatch) -> None:
config = SimpleNamespace(
type="streamableHttp",
auth="oauth",
url="https://app.xmind.com/api/mcp",
)
monkeypatch.setattr(
"nanobot.webui.settings_routes.ensure_mcp_oauth_server",
lambda _query, *, config_path=None: ("xmind", config),
)
router = _router()
start = AsyncMock(return_value={
"status": "authorization_required",
"flow_id": "flow-123",
"name": "xmind",
"authorization_url": "https://xmind.example/authorize?state=state-123",
})
router._mcp_oauth = SimpleNamespace(start=start)
request = _mutation_request(
"/api/settings/mcp-oauth/start",
{"name": "xmind"},
)
response = await router.dispatch(None, request, "/api/settings/mcp-oauth/start")
assert response is not None
assert response.status_code == 200
assert json.loads(response.body)["flow_id"] == "flow-123"
start.assert_awaited_once_with(
"xmind",
config,
"https://gateway.example/auth/mcp/callback",
reload_mcp=ANY,
reset_credentials=False,
)
denied = _router(authorized=False)
denied_response = await denied.dispatch(None, request, "/api/settings/mcp-oauth/start")
assert denied_response is not None
assert denied_response.status_code == 401
failed = _router()
failed._mcp_oauth = SimpleNamespace(
start=AsyncMock(side_effect=RuntimeError("upstream secret response"))
)
failed_response = await failed.dispatch(None, request, "/api/settings/mcp-oauth/start")
assert failed_response is not None
assert failed_response.status_code == 500
assert json.loads(failed_response.body) == {"error": "MCP OAuth start failed"}
assert b"upstream secret response" not in failed_response.body
@pytest.mark.asyncio
async def test_mcp_oauth_callback_is_state_authenticated_and_returns_close_page() -> None:
router = _router(authorized=False)
submit = MagicMock(return_value="xmind")
router._mcp_oauth = SimpleNamespace(submit_callback=submit)
request = SimpleNamespace(
path="/auth/mcp/callback?code=oauth-code&state=state-123",
headers=Headers(),
)
response = await router.dispatch(None, request, "/auth/mcp/callback")
assert response is not None
assert response.status_code == 200
assert response.headers["Content-Type"] == "text/html; charset=utf-8"
assert response.headers["Cache-Control"] == "no-store"
assert "frame-ancestors 'none'" in response.headers["Content-Security-Policy"]
assert b"window.close" in response.body
assert b"Authorization received" in response.body
assert b"oauth-code" not in response.body
submit.assert_called_once_with(state="state-123", code="oauth-code", error=None)
@pytest.mark.asyncio
async def test_mcp_oauth_manual_completion_reads_websocket_payload() -> None:
callback_url = (
"http://127.0.0.1:8765/auth/mcp/callback?code=oauth-code&state=state-123"
)
router = _router()
submit = MagicMock(
return_value={
"flow_id": "flow-123",
"name": "linear",
"status": "connecting",
"expires_in": 299,
"completion_input": "callback_url",
}
)
router._mcp_oauth = SimpleNamespace(submit_callback_url=submit)
request = _mutation_request(
"/api/settings/mcp-oauth/complete",
{"flow_id": "flow-123", "callback_url": callback_url},
)
response = await router.dispatch(None, request, "/api/settings/mcp-oauth/complete")
assert response is not None
assert response.status_code == 200
assert json.loads(response.body)["status"] == "connecting"
assert b"oauth-code" not in response.body
submit.assert_called_once_with(flow_id="flow-123", callback_url=callback_url)
denied = _router(authorized=False)
denied_response = await denied.dispatch(
None,
request,
"/api/settings/mcp-oauth/complete",
)
assert denied_response is not None
assert denied_response.status_code == 401
@pytest.mark.parametrize(
("provider", "authorization_response"),
[
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
from nanobot.config.schema import Config
from nanobot.webui.settings_system import (
coerce_channel_value,
system_settings_payload,
update_agent_system_settings,
)
def test_system_domain_owns_runtime_dto_and_agent_updates(tmp_path) -> None:
config = Config()
changed, restart_required = update_agent_system_settings(
config,
{
"timezone": ["Asia/Shanghai"],
"tool_hint_max_length": ["120"],
},
)
payload = system_settings_payload(
config,
config_path=tmp_path / "config.json",
version="0.3.0",
)
assert changed is True
assert restart_required is True
assert config.agents.defaults.timezone == "Asia/Shanghai"
assert config.agents.defaults.timezone_mode == "manual"
assert config.agents.defaults.tool_hint_max_length == 120
assert payload["runtime"]["config_path"] == str(tmp_path / "config.json")
assert payload["version"] == {"current": "0.3.0"}
assert payload["docs"]["version"] == "0.3.0"
assert set(payload) == {"runtime", "usage", "advanced", "version", "docs"}
def test_system_domain_validates_channel_field_values() -> None:
assert coerce_channel_value("allow_from", "alice, bob", "list") == [
"alice",
"bob",
]
assert coerce_channel_value("enabled", "yes", "bool") is True
assert coerce_channel_value("port", "8765", "int") == 8765
+38
View File
@@ -0,0 +1,38 @@
from __future__ import annotations
from types import SimpleNamespace
from typing import cast
from websockets.datastructures import Headers
from websockets.http11 import Request as WsRequest
from nanobot.channels.websocket.runtime import WebSocketConfig
from nanobot.webui.ws_http import GatewayHTTPHandler
def _handler(config: WebSocketConfig) -> GatewayHTTPHandler:
handler = object.__new__(GatewayHTTPHandler)
handler.config = config
return handler
def _request(**headers: str) -> WsRequest:
return cast(WsRequest, SimpleNamespace(headers=Headers(headers)))
def test_mcp_oauth_callback_uses_configured_public_websocket_origin() -> None:
handler = _handler(WebSocketConfig(path="/ws", public_ws_url="wss://agent.example/ws"))
redirect_uri = handler._mcp_oauth_redirect_uri(_request(Host="ignored.example"))
assert redirect_uri == "https://agent.example/auth/mcp/callback"
def test_mcp_oauth_callback_uses_safe_forwarded_request_origin() -> None:
handler = _handler(WebSocketConfig(path="/ws", host="127.0.0.1", port=8765))
redirect_uri = handler._mcp_oauth_redirect_uri(
_request(Host="nanobot.example:9443", **{"X-Forwarded-Proto": "https"})
)
assert redirect_uri == "https://nanobot.example:9443/auth/mcp/callback"
@@ -0,0 +1,654 @@
import { ChevronLeft, Loader2 } from "lucide-react";
import { SkillsCatalogSettings } from "@/components/settings/SkillsCatalogSettings";
import { ImageGenerationSettings } from "@/components/settings/capabilities/ImageGenerationSettings";
import { AdvancedSettings } from "@/components/settings/capabilities/SecuritySettings";
import { TranscriptionSettings } from "@/components/settings/capabilities/TranscriptionSettings";
import { WebSettings } from "@/components/settings/capabilities/WebSettings";
import {
ModelPresetDeleteDialog,
ModelsSettings,
} from "@/components/settings/models/ModelsSettings";
import {
ProviderOAuthLoginDialog,
ProvidersSettings,
providerFormFromRow,
} from "@/components/settings/models/ProviderSettings";
import { AppearanceSettings, OverviewSettings } from "@/components/settings/overview/OverviewSettings";
import { SettingsSidebar, standaloneSectionTitle } from "@/components/settings/SettingsSidebar";
import {
NanobotFeatureInstallDialog,
SettingsGroup,
SettingsRow,
} from "@/components/settings/shared/SettingsControls";
import { AppsCatalogSettings } from "@/components/settings/system/AppsSettings";
import {
AutomationDeleteDialog,
AutomationEditDialog,
AutomationsSettings,
} from "@/components/settings/system/AutomationsSettings";
import { ChannelsSettings } from "@/components/settings/system/ChannelsSettings";
import { RuntimeSettings } from "@/components/settings/system/RuntimeSettings";
import type { SettingsController } from "@/components/settings/useSettingsController";
import type { SkillSummary } from "@/lib/types";
import { cn } from "@/lib/utils";
interface SettingsPageProps {
controller: SettingsController;
theme: "light" | "dark";
showSidebar: boolean;
onToggleTheme: () => void;
onBackToChat: () => void;
skills: SkillSummary[];
onLogout?: () => void;
isRestarting: boolean;
hostChromeInset: boolean;
}
export function SettingsPage({
controller,
theme,
showSidebar,
onToggleTheme,
onBackToChat,
skills,
onLogout,
isRestarting,
hostChromeInset,
}: SettingsPageProps) {
const {
activeSection,
apiService,
apiServiceAction,
apiServiceError,
apiServiceLoading,
appsKindFilter,
appsQuery,
automationAction,
automationPendingDelete,
automationPendingEdit,
automations,
automationsError,
automationsFilter,
automationsLoading,
automationsQuery,
automationsSort,
beginModelPresetCreation,
cancelModelPresetCreation,
changeModelCallOrder,
channelsQuery,
cliApps,
cliAppsAction,
cliAppsError,
cliAppsFocusName,
cliAppsLoading,
cliAppsMessage,
closeProviderOAuthFlow,
completeProviderOAuthResponse,
createCustomProvider,
customMcpForm,
editingProviderKeys,
error,
expandedProvider,
featureCatalog,
form,
handleApiServiceAction,
handleAutomationAction,
handleAutomationEdit,
handleCliAppAction,
handleDeleteModelConfiguration,
handleImportMcpConfig,
handleMcpOAuthCancel,
handleMcpOAuthComplete,
handleMcpOAuthConnect,
handleMcpOAuthOpen,
handleMcpPresetAction,
handleMcpToolsChange,
handleMigrateModelConfigurations,
handleNanobotFeatureAction,
handleSaveCustomMcp,
handleToggleProvider,
handleWebSearchProviderChange,
hasPendingRestart,
hostEngineApplying,
imageGenerationDirty,
imageGenerationForm,
imageGenerationSaving,
installCapabilities,
loading,
localPrefs,
mcpConfigImport,
mcpError,
mcpFieldValues,
mcpMessage,
mcpOAuthCallbackError,
mcpOAuthCallbackUrl,
mcpOAuthCompleting,
mcpOAuthFlow,
mcpOAuthPopupBlocked,
mcpPresetAction,
mcpPresets,
mcpPresetsLoading,
modelCallOrder,
modelCallOrderSaving,
modelConfigurationSaving,
modelDirty,
modelMigrationSaving,
modelPresetBeforeCreateRef,
modelPresetCreating,
modelPresetPendingDelete,
nanobotFeatureAction,
nanobotFeatureConfirm,
nanobotFeatures,
nanobotFeaturesError,
nanobotFeaturesLoading,
networkSafetyDirty,
networkSafetyForm,
networkSafetySaving,
pendingRestartSections,
providerForms,
providerOAuthCompleting,
providerOAuthDialogError,
providerOAuthFlow,
providerOAuthResponse,
providerSaving,
remoteBrowserAccess,
resetWebSearchDraft,
restartViaSettingsSurface,
runProviderOAuth,
saveImageGenerationSettings,
saveModelSettings,
saveNetworkSafetySettings,
saveProvider,
saveTranscriptionSettings,
saveWebSearch,
saving,
selectSection,
setAppsKindFilter,
setAppsQuery,
setAutomationPendingDelete,
setAutomationPendingEdit,
setAutomationsFilter,
setAutomationsQuery,
setAutomationsSort,
setChannelsQuery,
setCliAppsError,
setCliAppsMessage,
setCustomMcpForm,
setForm,
setImageGenerationForm,
setLocalPrefs,
setMcpConfigImport,
setMcpError,
setMcpFieldValues,
setMcpMessage,
setMcpOAuthCallbackError,
setMcpOAuthCallbackUrl,
setModelPresetCreating,
setModelPresetPendingDelete,
setNanobotFeatureConfirm,
setNanobotFeatures,
setNanobotFeaturesError,
setNetworkSafetyForm,
setProviderForms,
setProviderOAuthDialogError,
setProviderOAuthResponse,
setTranscriptionForm,
setWebSearchForm,
setWebSearchKeyEditing,
setWebSearchKeyVisible,
settings,
t,
toggleProviderKeyEditing,
toggleProviderKeyVisibility,
token,
transcriptionDirty,
transcriptionForm,
transcriptionSaving,
visibleProviderKeys,
webSearchForm,
webSearchKeyEditing,
webSearchKeyVisible,
webSearchSaving,
} = controller;
const renderSection = () => {
if (!settings) return null;
switch (activeSection) {
case "overview":
return (
<OverviewSettings
settings={settings}
requiresRestart={hasPendingRestart}
showBrandLogos={localPrefs.brandLogos}
onSelectSection={selectSection}
/>
);
case "appearance":
return (
<AppearanceSettings
theme={theme}
onToggleTheme={onToggleTheme}
localPrefs={localPrefs}
onChangeLocalPrefs={setLocalPrefs}
/>
);
case "models":
return (
<div className="space-y-8">
<ModelsSettings
token={token}
form={form}
setForm={setForm}
settings={settings}
dirty={modelDirty}
creating={modelPresetCreating}
creatingSaving={modelConfigurationSaving}
callOrder={modelCallOrder}
saving={saving}
orderSaving={modelCallOrderSaving || modelConfigurationSaving}
migrationSaving={modelMigrationSaving}
showBrandLogos={localPrefs.brandLogos}
providerSaving={providerSaving}
onChangeCallOrder={changeModelCallOrder}
onProviderOAuthLogin={(provider) => runProviderOAuth(provider, "login")}
onSave={saveModelSettings}
onMigrate={handleMigrateModelConfigurations}
onBeginCreate={beginModelPresetCreation}
onCancelCreate={cancelModelPresetCreation}
onSelectConfiguration={() => {
setModelPresetCreating(false);
modelPresetBeforeCreateRef.current = null;
}}
onDeleteConfiguration={setModelPresetPendingDelete}
/>
<ProvidersSettings
settings={settings}
nanobotFeatures={nanobotFeatures}
featureAction={nanobotFeatureAction}
capabilityError={nanobotFeaturesError}
expandedProvider={expandedProvider}
providerForms={providerForms}
visibleProviderKeys={visibleProviderKeys}
editingProviderKeys={editingProviderKeys}
providerSaving={providerSaving}
showBrandLogos={localPrefs.brandLogos}
remoteBrowserAccess={remoteBrowserAccess}
onToggleProvider={handleToggleProvider}
onToggleProviderKey={toggleProviderKeyVisibility}
onToggleProviderKeyEditing={toggleProviderKeyEditing}
onChangeProviderForm={(provider, value) =>
setProviderForms((prev) => ({
...prev,
[provider]: {
...(prev[provider] ?? providerFormFromRow(
settings.providers.find((row) => row.name === provider) ?? {
name: provider,
label: provider,
configured: false,
},
)),
...value,
},
}))
}
onSaveProvider={saveProvider}
onCreateCustomProvider={createCustomProvider}
onProviderOAuthLogin={(provider) => runProviderOAuth(provider, "login")}
onProviderOAuthLogout={(provider) => runProviderOAuth(provider, "logout")}
imageProviderRestartPending={pendingRestartSections.image || pendingRestartSections.runtime}
onRestart={restartViaSettingsSurface}
isRestarting={isRestarting || hostEngineApplying}
/>
</div>
);
case "image":
return (
<ImageGenerationSettings
token={token}
settings={settings}
form={imageGenerationForm}
dirty={imageGenerationDirty}
saving={imageGenerationSaving}
onChangeForm={setImageGenerationForm}
onSave={saveImageGenerationSettings}
onOpenProviders={() => selectSection("models")}
showBrandLogos={localPrefs.brandLogos}
onRestart={restartViaSettingsSurface}
isRestarting={isRestarting || hostEngineApplying}
requiresRestartPending={pendingRestartSections.image}
/>
);
case "voice":
return (
<TranscriptionSettings
settings={settings}
form={transcriptionForm}
dirty={transcriptionDirty}
saving={transcriptionSaving}
onChangeForm={setTranscriptionForm}
onSave={saveTranscriptionSettings}
onOpenProviders={() => selectSection("models")}
showBrandLogos={localPrefs.brandLogos}
onRestart={restartViaSettingsSurface}
isRestarting={isRestarting || hostEngineApplying}
requiresRestartPending={pendingRestartSections.browser}
/>
);
case "browser":
return (
<WebSettings
settings={settings}
form={webSearchForm}
keyVisible={webSearchKeyVisible}
keyEditing={webSearchKeyEditing}
saving={webSearchSaving}
onChangeForm={setWebSearchForm}
onChangeProvider={handleWebSearchProviderChange}
onToggleKey={() => setWebSearchKeyVisible((visible) => !visible)}
onToggleKeyEditing={() => {
setWebSearchKeyEditing((editing) => !editing);
setWebSearchKeyVisible(false);
setWebSearchForm((prev) => ({ ...prev, apiKey: "" }));
}}
onReset={resetWebSearchDraft}
onSave={saveWebSearch}
showBrandLogos={localPrefs.brandLogos}
onRestart={restartViaSettingsSurface}
isRestarting={isRestarting || hostEngineApplying}
requiresRestartPending={pendingRestartSections.browser}
olostepFeature={featureCatalog.find((feature) => feature.name === "olostep")}
olostepInstalling={nanobotFeatureAction === "enable:olostep"}
capabilityError={nanobotFeaturesError}
/>
);
case "channels":
return (
<ChannelsSettings
token={token}
nanobotFeatures={nanobotFeatures}
loading={nanobotFeaturesLoading}
query={channelsQuery}
actionKey={nanobotFeatureAction}
chatAppsDocsUrl={settings.docs?.chat_apps_url}
showBrandLogos={localPrefs.brandLogos}
error={nanobotFeaturesError}
requiresRestartPending={pendingRestartSections.runtime}
onQueryChange={setChannelsQuery}
onAction={handleNanobotFeatureAction}
onFeaturesUpdate={setNanobotFeatures}
onDismissStatus={() => {
setNanobotFeaturesError(null);
}}
onRestart={restartViaSettingsSurface}
isRestarting={isRestarting || hostEngineApplying}
/>
);
case "apps":
return (
<AppsCatalogSettings
cliApps={cliApps}
mcpPresets={mcpPresets}
cliAppsLoading={cliAppsLoading}
mcpPresetsLoading={mcpPresetsLoading}
query={appsQuery}
filter={appsKindFilter}
cliActionKey={cliAppsAction}
mcpActionKey={mcpPresetAction}
mcpOAuthFlow={mcpOAuthFlow}
mcpOAuthPopupBlocked={mcpOAuthPopupBlocked}
mcpOAuthCallbackUrl={mcpOAuthCallbackUrl}
mcpOAuthCompleting={mcpOAuthCompleting}
mcpOAuthCallbackError={mcpOAuthCallbackError}
cliMessage={cliAppsMessage}
cliError={cliAppsError}
cliFocusName={cliAppsFocusName}
mcpMessage={mcpMessage}
mcpError={mcpError}
mcpFieldValues={mcpFieldValues}
customMcpForm={customMcpForm}
mcpConfigImport={mcpConfigImport}
showBrandLogos={localPrefs.brandLogos}
requiresRestartPending={pendingRestartSections.runtime}
onQueryChange={setAppsQuery}
onFilterChange={setAppsKindFilter}
onCliAction={handleCliAppAction}
onMcpAction={handleMcpPresetAction}
onMcpOAuthConnect={handleMcpOAuthConnect}
onMcpOAuthCancel={() => void handleMcpOAuthCancel()}
onMcpOAuthOpen={handleMcpOAuthOpen}
onMcpOAuthCallbackUrlChange={(value) => {
setMcpOAuthCallbackUrl(value);
setMcpOAuthCallbackError(null);
}}
onMcpOAuthComplete={() => void handleMcpOAuthComplete()}
onDismissStatus={() => {
setCliAppsMessage(null);
setCliAppsError(null);
setMcpMessage(null);
setMcpError(null);
}}
onBackToChat={onBackToChat}
onMcpFieldChange={(presetName, fieldName, value) => {
setMcpFieldValues((prev) => ({
...prev,
[presetName]: {
...(prev[presetName] ?? {}),
[fieldName]: value,
},
}));
}}
onCustomMcpFormChange={setCustomMcpForm}
onMcpConfigImportChange={setMcpConfigImport}
onSaveCustomMcp={handleSaveCustomMcp}
onImportMcpConfig={handleImportMcpConfig}
onMcpToolsChange={handleMcpToolsChange}
onRestart={restartViaSettingsSurface}
isRestarting={isRestarting || hostEngineApplying}
/>
);
case "automations":
return (
<AutomationsSettings
payload={automations}
loading={automationsLoading}
query={automationsQuery}
filter={automationsFilter}
sort={automationsSort}
actionKey={automationAction}
error={automationsError}
onQueryChange={setAutomationsQuery}
onFilterChange={setAutomationsFilter}
onSortChange={setAutomationsSort}
onAction={handleAutomationAction}
onRequestEdit={setAutomationPendingEdit}
onRequestDelete={setAutomationPendingDelete}
onBackToChat={onBackToChat}
/>
);
case "skills":
return <SkillsCatalogSettings skills={skills} />;
case "runtime":
return (
<RuntimeSettings
form={form}
settings={settings}
onRestart={restartViaSettingsSurface}
isRestarting={isRestarting || hostEngineApplying}
requiresRestartPending={pendingRestartSections.runtime}
apiService={apiService}
apiServiceLoading={apiServiceLoading}
apiServiceAction={apiServiceAction}
apiServiceError={apiServiceError}
langfuseFeature={featureCatalog.find((feature) => feature.name === "langfuse")}
capabilitiesLoading={nanobotFeaturesLoading}
capabilityAction={nanobotFeatureAction}
capabilityError={nanobotFeaturesError}
onApiServiceAction={handleApiServiceAction}
onInstallCapability={(name) => void installCapabilities([name])}
/>
);
case "advanced":
return (
<AdvancedSettings
form={networkSafetyForm}
dirty={networkSafetyDirty}
saving={networkSafetySaving}
isNativeHostSurface={(settings.surface ?? settings.runtime_surface) === "native"}
onChangeForm={setNetworkSafetyForm}
onSave={saveNetworkSafetySettings}
onRestart={restartViaSettingsSurface}
isRestarting={isRestarting || hostEngineApplying}
requiresRestartPending={pendingRestartSections.runtime}
/>
);
default:
return null;
}
};
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-settings-canvas lg:flex-row">
{showSidebar ? (
<SettingsSidebar
activeSection={activeSection}
onSelectSection={selectSection}
onBackToChat={onBackToChat}
onLogout={onLogout}
hostChromeInset={hostChromeInset}
/>
) : null}
<ModelPresetDeleteDialog
preset={modelPresetPendingDelete}
deleting={saving}
onOpenChange={(open) => {
if (!open) setModelPresetPendingDelete(null);
}}
onConfirm={handleDeleteModelConfiguration}
/>
<ProviderOAuthLoginDialog
flow={providerOAuthFlow}
providerLabel={
providerOAuthFlow
? settings?.providers.find((provider) => provider.name === providerOAuthFlow.provider)
?.label ?? providerOAuthFlow.provider
: ""
}
authorizationResponse={providerOAuthResponse}
completing={providerOAuthCompleting}
error={providerOAuthDialogError}
remoteBrowserAccess={remoteBrowserAccess}
onAuthorizationResponseChange={(value) => {
setProviderOAuthResponse(value);
setProviderOAuthDialogError(null);
}}
onOpenAuthorization={() => {
if (!providerOAuthFlow) return;
const opened = window.open(
providerOAuthFlow.authorization_url,
"_blank",
"noopener,noreferrer",
);
if (opened) opened.opener = null;
}}
onComplete={() => void completeProviderOAuthResponse()}
onClose={closeProviderOAuthFlow}
/>
<NanobotFeatureInstallDialog
feature={nanobotFeatureConfirm}
installing={nanobotFeatureAction === `enable:${nanobotFeatureConfirm?.name ?? ""}`}
onOpenChange={(open) => {
if (!open) setNanobotFeatureConfirm(null);
}}
onConfirm={(feature) => handleNanobotFeatureAction("enable", feature.name, true)}
/>
<AutomationDeleteDialog
job={automationPendingDelete}
deleting={automationAction === `delete:${automationPendingDelete?.id ?? ""}`}
onOpenChange={(open) => {
if (!open) setAutomationPendingDelete(null);
}}
onConfirm={(job) => handleAutomationAction("delete", job)}
/>
<AutomationEditDialog
job={automationPendingEdit}
saving={automationAction === `update:${automationPendingEdit?.id ?? ""}`}
onOpenChange={(open) => {
if (!open) setAutomationPendingEdit(null);
}}
onSave={handleAutomationEdit}
/>
<div
className={cn(
"min-w-0 flex-1 bg-settings-canvas [scrollbar-gutter:stable]",
activeSection === "channels" ? "overflow-y-auto xl:overflow-hidden" : "overflow-y-auto",
)}
>
<div
key={activeSection}
data-testid="settings-section-transition"
data-settings-section={activeSection}
className={cn(
"mx-auto w-full animate-in fade-in-0 slide-in-from-bottom-1 px-4 py-6 duration-200 ease-out",
"motion-reduce:animate-none sm:px-8 sm:py-8 lg:py-12",
activeSection === "channels" ? "max-w-[1240px] xl:px-10" : "max-w-[920px]",
activeSection === "channels" && "flex min-h-full flex-col xl:h-full xl:min-h-0",
hostChromeInset && "pt-[4.25rem] sm:pt-[4.25rem] lg:pt-[4.75rem]",
)}
>
{!showSidebar ? (
<div className="mb-7">
<button
type="button"
onClick={onBackToChat}
className="touch-target mb-4 inline-flex items-center gap-1.5 rounded-full px-2.5 py-1.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground lg:hidden"
>
<ChevronLeft className="h-3.5 w-3.5" aria-hidden />
{t("settings.backToChat")}
</button>
<h1 className="text-[24px] font-normal leading-tight tracking-normal text-foreground sm:text-[28px]">
{t(`settings.nav.${activeSection}`, {
defaultValue: standaloneSectionTitle(activeSection),
})}
</h1>
</div>
) : null}
{loading ? (
<div className="flex h-48 items-center justify-center rounded-[22px] bg-settings-surface text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("settings.status.loading")}
</div>
) : error && !settings ? (
<SettingsGroup>
<SettingsRow title={t("settings.status.loadError")}>
<span className="max-w-[520px] text-sm text-muted-foreground">{error}</span>
</SettingsRow>
</SettingsGroup>
) : settings ? (
<div
className={cn(
"space-y-5",
activeSection === "channels" &&
"flex min-h-0 flex-1 flex-col xl:overflow-hidden",
)}
>
{error ? (
<div className="rounded-[18px] border border-destructive/20 bg-destructive/5 px-4 py-3 text-[13px] text-destructive">
{error}
</div>
) : null}
{renderSection()}
</div>
) : null}
</div>
</div>
</div>
);
}
@@ -0,0 +1,188 @@
import { useRef } from "react";
import {
Activity,
Check,
ChevronDown,
ChevronLeft,
Globe2,
ImageIcon,
LogOut,
MessageCircle,
Mic,
Palette,
Server,
ShieldCheck,
SlidersHorizontal,
type LucideIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import {
SIDEBAR_SELECTION_ITEM_CLASS,
SidebarSelectionHighlight,
} from "@/components/SidebarSelectionHighlight";
import type { SettingsSectionKey } from "@/components/settings/contracts";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
const SETTINGS_NAV_ITEMS: Array<{ key: SettingsSectionKey; icon: LucideIcon; fallback: string }> = [
{ key: "overview", icon: Activity, fallback: "Overview" },
{ key: "appearance", icon: Palette, fallback: "Appearance" },
{ key: "models", icon: SlidersHorizontal, fallback: "Models" },
{ key: "image", icon: ImageIcon, fallback: "Image" },
{ key: "voice", icon: Mic, fallback: "Voice" },
{ key: "browser", icon: Globe2, fallback: "Web" },
{ key: "channels", icon: MessageCircle, fallback: "Channels" },
{ key: "runtime", icon: Server, fallback: "System" },
{ key: "advanced", icon: ShieldCheck, fallback: "Security" },
];
export function standaloneSectionTitle(section: SettingsSectionKey): string {
if (section === "apps") return "Apps";
if (section === "automations") return "Automations";
if (section === "skills") return "Skills";
return SETTINGS_NAV_ITEMS.find((item) => item.key === section)?.fallback ?? "Settings";
}
export function SettingsSidebar({
activeSection,
onSelectSection,
onBackToChat,
onLogout,
hostChromeInset,
}: {
activeSection: SettingsSectionKey;
onSelectSection: (section: SettingsSectionKey) => void;
onBackToChat: () => void;
onLogout?: () => void;
hostChromeInset?: boolean;
}) {
const { t } = useTranslation();
const activeNavItemRef = useRef<HTMLButtonElement>(null);
const activeItem = SETTINGS_NAV_ITEMS.find((item) => item.key === activeSection)
?? SETTINGS_NAV_ITEMS[0];
const ActiveIcon = activeItem.icon;
const activeLabel = t(`settings.nav.${activeItem.key}`, {
defaultValue: activeItem.fallback,
});
return (
<aside
className={cn(
"flex w-full shrink-0 flex-col bg-settings-surface px-3 pb-2 lg:w-[17rem] lg:px-3 lg:pb-4",
hostChromeInset ? "pt-[4.25rem] lg:pt-[4.25rem]" : "pt-4 lg:pt-4",
)}
>
<button
type="button"
onClick={onBackToChat}
className="touch-target mb-2 inline-flex w-fit items-center gap-1.5 rounded-full px-2.5 py-1.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground lg:mb-3"
>
<ChevronLeft className="h-3.5 w-3.5" aria-hidden />
{t("settings.backToChat")}
</button>
<div className="mb-3 px-1 lg:mb-4 lg:px-2">
<h1 className="text-[18px] font-normal tracking-normal text-foreground">
{t("settings.sidebar.title")}
</h1>
</div>
<nav
aria-label={t("settings.sidebar.ariaLabel")}
className="w-full"
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={`${t("settings.sidebar.title")}: ${activeLabel}`}
className="touch-target flex h-11 w-full items-center gap-2.5 rounded-[14px] bg-sidebar-accent px-3 text-left text-[13px] font-medium text-foreground transition-colors hover:bg-sidebar-accent/80 lg:hidden"
>
<ActiveIcon className="h-4 w-4 shrink-0" strokeWidth={2} aria-hidden />
<span className="min-w-0 flex-1 truncate">{activeLabel}</span>
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
sideOffset={6}
className="w-[var(--radix-dropdown-menu-trigger-width)] max-w-[calc(100vw-1.5rem)]"
>
{SETTINGS_NAV_ITEMS.map(({ key, icon: Icon, fallback }) => {
const active = key === activeSection;
return (
<DropdownMenuItem
key={key}
aria-current={active ? "page" : undefined}
onSelect={() => onSelectSection(key)}
className={cn(
"flex h-10 cursor-default items-center gap-2.5 px-2.5 text-[13px] font-medium",
active && "bg-sidebar-accent text-foreground focus:bg-sidebar-accent",
)}
>
<Icon className="h-4 w-4 shrink-0" strokeWidth={2} aria-hidden />
<span className="min-w-0 flex-1 truncate">
{t(`settings.nav.${key}`, { defaultValue: fallback })}
</span>
{active ? <Check className="h-4 w-4 shrink-0" aria-hidden /> : null}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
<SidebarSelectionHighlight
targetRef={activeNavItemRef}
activeId={activeSection}
scope="settings"
className="relative hidden space-y-1 lg:block"
>
{SETTINGS_NAV_ITEMS.map(({ key, icon: Icon, fallback }) => {
const active = key === activeSection;
return (
<button
ref={active ? activeNavItemRef : undefined}
key={key}
type="button"
aria-current={active ? "page" : undefined}
onClick={() => onSelectSection(key)}
className={cn(
"touch-target flex h-9 w-full items-center gap-2 rounded-xl px-2.5 text-left text-[13px] font-medium",
SIDEBAR_SELECTION_ITEM_CLASS,
active
? "text-sidebar-accent-foreground"
: "text-muted-foreground/78 hover:bg-muted/45 hover:text-foreground",
)}
>
<Icon className="h-4 w-4 shrink-0" strokeWidth={2} aria-hidden />
<span className="truncate">
{t(`settings.nav.${key}`, { defaultValue: fallback })}
</span>
</button>
);
})}
</SidebarSelectionHighlight>
</nav>
<div className="hidden lg:mt-auto lg:block lg:pt-4">
{onLogout && !hostChromeInset ? (
<Button
type="button"
variant="ghost"
onClick={onLogout}
className="h-9 w-full justify-start gap-2 rounded-[10px] px-2.5 text-[13px] font-medium text-muted-foreground hover:bg-destructive/8 hover:text-destructive"
>
<LogOut className="h-4 w-4" aria-hidden />
{t("app.account.logout")}
</Button>
) : null}
</div>
</aside>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,213 @@
import type { Dispatch, SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import { ModelIdPicker, ProviderPicker, optionRowsWithCurrent } from "@/components/settings/shared/ModelControls";
import {
NumberInput,
ReadOnlyRow,
RestartSettingsFooter,
SettingsGroup,
SettingsRow,
SettingsSectionTitle,
StatusPill,
} from "@/components/settings/shared/SettingsControls";
import { ToggleButton } from "@/components/settings/ToggleButton";
import { Button } from "@/components/ui/button";
import type { ImageGenerationSettingsUpdate, SettingsPayload } from "@/lib/types";
const IMAGE_ASPECT_RATIO_OPTIONS = ["1:1", "3:4", "9:16", "4:3", "16:9", "3:2", "2:3", "21:9"];
const IMAGE_SIZE_OPTIONS = ["1K", "2K", "4K", "1024x1024", "1536x1024", "1024x1536"];
export const DEFAULT_IMAGE_GENERATION_FORM: ImageGenerationSettingsUpdate = {
enabled: false,
provider: "openrouter",
model: "openai/gpt-5.4-image-2",
defaultAspectRatio: "1:1",
defaultImageSize: "1K",
maxImagesPerTurn: 4,
};
export function imageGenerationFormFromPayload(payload: SettingsPayload): ImageGenerationSettingsUpdate {
return {
enabled: payload.image_generation.enabled,
provider: payload.image_generation.provider,
model: payload.image_generation.model,
defaultAspectRatio: payload.image_generation.default_aspect_ratio,
defaultImageSize: payload.image_generation.default_image_size,
maxImagesPerTurn: payload.image_generation.max_images_per_turn,
};
}
export function ImageGenerationSettings({
token,
settings,
form,
dirty,
saving,
onChangeForm,
onSave,
onOpenProviders,
showBrandLogos,
onRestart,
isRestarting,
requiresRestartPending,
}: {
token: string;
settings: SettingsPayload;
form: ImageGenerationSettingsUpdate;
dirty: boolean;
saving: boolean;
onChangeForm: Dispatch<SetStateAction<ImageGenerationSettingsUpdate>>;
onSave: () => void;
onOpenProviders: () => void;
showBrandLogos: boolean;
onRestart?: () => void;
isRestarting?: boolean;
requiresRestartPending: boolean;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const selectedProvider =
settings.image_generation.providers.find((provider) => provider.name === form.provider) ??
settings.image_generation.providers[0];
const providerConfigured = !!selectedProvider?.configured;
const missingCredential = form.enabled && !providerConfigured;
const aspectOptions = optionRowsWithCurrent(
IMAGE_ASPECT_RATIO_OPTIONS.map((value) => ({ name: value, label: value })),
form.defaultAspectRatio,
);
const sizeOptions = optionRowsWithCurrent(
IMAGE_SIZE_OPTIONS.map((value) => ({ name: value, label: value })),
form.defaultImageSize,
);
const selectProvider = (provider: string) => {
const nextProvider = settings.image_generation.providers.find((row) => row.name === provider);
onChangeForm((prev) => ({
...prev,
provider,
model: nextProvider?.default_model || nextProvider?.models?.[0] || prev.model,
}));
};
return (
<div className="space-y-7">
<section>
<SettingsSectionTitle>{tx("settings.sections.imageGeneration", "Image generation")}</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow title={tx("settings.rows.imageGeneration", "Image generation")}>
<ToggleButton
checked={form.enabled}
onChange={(enabled) => onChangeForm((prev) => ({ ...prev, enabled }))}
ariaLabel={tx("settings.rows.imageGeneration", "Image generation")}
label={form.enabled ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</SettingsRow>
<SettingsRow title={tx("settings.rows.imageProvider", "Image provider")}>
<ProviderPicker
providers={settings.image_generation.providers}
value={form.provider}
emptyLabel={tx("settings.image.selectProvider", "Select provider")}
showProviderLogos={showBrandLogos}
onChange={selectProvider}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.imageProviderStatus", "Provider status")}
description={tx("settings.help.imageProviderStatus", "Image generation reuses provider credentials from Providers.")}
>
<div className="flex flex-wrap items-center justify-end gap-2">
<StatusPill tone={providerConfigured ? "success" : "neutral"}>
{providerConfigured
? tx("settings.values.configured", "Configured")
: tx("settings.values.notConfigured", "Not configured")}
</StatusPill>
{!providerConfigured ? (
<Button size="sm" variant="outline" onClick={onOpenProviders} className="rounded-full">
{tx("settings.image.configureProvider", "Configure provider")}
</Button>
) : null}
</div>
</SettingsRow>
<SettingsRow title={tx("settings.rows.imageProviderBase", "Provider base")}>
<span className="max-w-[320px] truncate text-right text-[13px] text-muted-foreground">
{selectedProvider?.api_base || selectedProvider?.default_api_base || selectedProvider?.name || tx("settings.values.notAvailable", "Not available")}
</span>
</SettingsRow>
</SettingsGroup>
</section>
<section>
<SettingsSectionTitle>{tx("settings.sections.imageDefaults", "Defaults")}</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow title={tx("settings.rows.imageModel", "Image model")}>
<ModelIdPicker
token={token}
settings={settings}
provider={form.provider}
models={selectedProvider?.models ?? []}
value={form.model}
showProviderLogos={showBrandLogos}
emptyLabel={tx("settings.image.selectModel", "Select image model")}
searchPlaceholder={tx(
"settings.image.searchOrTypeModel",
"Search or type model ID",
)}
emptyMessage={tx(
"settings.image.typeModelId",
"Type the model ID supported by this provider.",
)}
onChange={(model) => onChangeForm((prev) => ({ ...prev, model }))}
/>
</SettingsRow>
<SettingsRow title={tx("settings.rows.defaultAspectRatio", "Default aspect")}>
<ProviderPicker
providers={aspectOptions}
value={form.defaultAspectRatio}
emptyLabel={tx("settings.image.selectAspect", "Select aspect")}
onChange={(defaultAspectRatio) =>
onChangeForm((prev) => ({ ...prev, defaultAspectRatio }))
}
/>
</SettingsRow>
<SettingsRow title={tx("settings.rows.defaultImageSize", "Default size")}>
<ProviderPicker
providers={sizeOptions}
value={form.defaultImageSize}
emptyLabel={tx("settings.image.selectSize", "Select size")}
onChange={(defaultImageSize) =>
onChangeForm((prev) => ({ ...prev, defaultImageSize }))
}
/>
</SettingsRow>
<SettingsRow title={tx("settings.rows.maxImagesPerTurn", "Max images per turn")}>
<NumberInput
value={form.maxImagesPerTurn}
min={1}
max={8}
onChange={(maxImagesPerTurn) =>
onChangeForm((prev) => ({ ...prev, maxImagesPerTurn }))
}
/>
</SettingsRow>
<ReadOnlyRow title={tx("settings.rows.imageSaveDir", "Save directory")} value={settings.image_generation.save_dir} />
<RestartSettingsFooter
dirty={dirty}
saving={saving}
pendingRestart={requiresRestartPending}
disabled={missingCredential}
message={
missingCredential
? tx("settings.image.missingCredential", "Configure this provider before enabling image generation.")
: undefined
}
dirtyMessage={tx("settings.status.restartAfterSaving", "Save changes, then restart when ready.")}
pendingMessage={tx("settings.status.savedRestartApply", "Saved. Restart when ready.")}
onSave={onSave}
onRestart={onRestart}
isRestarting={isRestarting}
/>
</SettingsGroup>
</section>
</div>
);
}
@@ -0,0 +1,131 @@
import type { Dispatch, SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import {
RestartSettingsFooter,
SettingsGroup,
SettingsRow,
SettingsSectionTitle,
} from "@/components/settings/shared/SettingsControls";
import { ToggleButton } from "@/components/settings/ToggleButton";
import { SegmentedControl } from "@/components/ui/segmented-control";
import type {
NetworkSafetySettingsUpdate,
SettingsPayload,
WebuiDefaultAccessMode,
} from "@/lib/types";
export const DEFAULT_NETWORK_SAFETY_FORM: NetworkSafetySettingsUpdate = {
webuiAllowLocalServiceAccess: true,
webuiDefaultAccessMode: "default",
};
export function networkSafetyFormFromPayload(payload: SettingsPayload): NetworkSafetySettingsUpdate {
return {
webuiAllowLocalServiceAccess:
payload.advanced.webui_allow_local_service_access ??
payload.advanced.allow_local_preview_access ??
true,
webuiDefaultAccessMode: visibleWebuiDefaultAccessMode(
payload.advanced.webui_default_access_mode,
),
};
}
export function visibleWebuiDefaultAccessMode(mode: string | null | undefined): WebuiDefaultAccessMode {
return mode === "full" ? "full" : "default";
}
export function AdvancedSettings({
form,
dirty,
saving,
requiresRestartPending,
isNativeHostSurface,
onChangeForm,
onSave,
onRestart,
isRestarting,
}: {
form: NetworkSafetySettingsUpdate;
dirty: boolean;
saving: boolean;
requiresRestartPending: boolean;
isNativeHostSurface: boolean;
onChangeForm: Dispatch<SetStateAction<NetworkSafetySettingsUpdate>>;
onSave: () => void;
onRestart?: () => void;
isRestarting?: boolean;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
return (
<div className="space-y-7">
<section>
<SettingsSectionTitle>
{isNativeHostSurface
? tx("settings.sections.hostSafety", "App safety")
: tx("settings.sections.webuiSafety", "Web safety")}
</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow
title={tx("settings.rows.localServiceAccess", "Local Service Access")}
description={tx(
isNativeHostSurface ? "settings.help.localServiceAccessNative" : "settings.help.localServiceAccess",
isNativeHostSurface
? "Allow Full Access shell commands to reach services on this Mac."
: "Allow Full Access shell commands to reach localhost services.",
)}
>
<ToggleButton
checked={form.webuiAllowLocalServiceAccess}
onChange={(webuiAllowLocalServiceAccess) =>
onChangeForm((prev) => ({ ...prev, webuiAllowLocalServiceAccess }))
}
ariaLabel={tx("settings.rows.localServiceAccess", "Local Service Access")}
label={form.webuiAllowLocalServiceAccess ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.webuiDefaultAccess", "Default access")}
description={tx(
isNativeHostSurface ? "settings.help.webuiDefaultAccessNative" : "settings.help.webuiDefaultAccess",
isNativeHostSurface
? "Used by native chats without a project-specific permission."
: "Used by web chats without a project-specific permission.",
)}
>
<SegmentedControl
value={form.webuiDefaultAccessMode}
options={[
{ value: "default", label: tx("settings.values.defaultPermission", "Default Permission") },
{ value: "full", label: tx("settings.values.fullAccess", "Full Access") },
]}
onChange={(webuiDefaultAccessMode) =>
onChangeForm((prev) => ({
...prev,
webuiDefaultAccessMode: webuiDefaultAccessMode as WebuiDefaultAccessMode,
}))
}
/>
</SettingsRow>
<RestartSettingsFooter
dirty={dirty}
saving={saving}
pendingRestart={requiresRestartPending}
onSave={onSave}
onRestart={onRestart}
isRestarting={isRestarting}
/>
</SettingsGroup>
</section>
<p className="max-w-3xl px-1 text-sm leading-6 text-muted-foreground">
{tx(
"settings.help.securityManagedControls",
"Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
)}
</p>
</div>
);
}
@@ -0,0 +1,176 @@
import type { Dispatch, SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import { ProviderPicker } from "@/components/settings/shared/ModelControls";
import {
NumberInput,
RestartSettingsFooter,
SettingsGroup,
SettingsRow,
SettingsSectionTitle,
StatusPill,
} from "@/components/settings/shared/SettingsControls";
import { ToggleButton } from "@/components/settings/ToggleButton";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { SettingsPayload, TranscriptionSettingsUpdate } from "@/lib/types";
export const DEFAULT_TRANSCRIPTION_FORM: TranscriptionSettingsUpdate = {
enabled: true,
provider: "groq",
model: "",
language: "",
maxDurationSec: 120,
maxUploadMb: 25,
};
export const DEFAULT_TRANSCRIPTION_SETTINGS: NonNullable<SettingsPayload["transcription"]> = {
enabled: true,
provider: "groq",
provider_configured: false,
model: "whisper-large-v3",
language: null,
max_duration_sec: 120,
max_upload_mb: 25,
providers: [],
};
export function transcriptionFormFromPayload(payload: SettingsPayload): TranscriptionSettingsUpdate {
const transcription = payload.transcription ?? DEFAULT_TRANSCRIPTION_SETTINGS;
return {
enabled: transcription.enabled,
provider: transcription.provider,
model: transcription.model,
language: transcription.language ?? "",
maxDurationSec: transcription.max_duration_sec,
maxUploadMb: transcription.max_upload_mb,
};
}
export function TranscriptionSettings({
settings,
form,
dirty,
saving,
onChangeForm,
onSave,
onOpenProviders,
showBrandLogos,
onRestart,
isRestarting,
requiresRestartPending,
}: {
settings: SettingsPayload;
form: TranscriptionSettingsUpdate;
dirty: boolean;
saving: boolean;
onChangeForm: Dispatch<SetStateAction<TranscriptionSettingsUpdate>>;
onSave: () => void;
onOpenProviders: () => void;
showBrandLogos: boolean;
onRestart?: () => void;
isRestarting?: boolean;
requiresRestartPending: boolean;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const transcription = settings.transcription ?? DEFAULT_TRANSCRIPTION_SETTINGS;
const selectedProvider =
transcription.providers.find((provider) => provider.name === form.provider) ??
transcription.providers[0];
const providerConfigured = !!selectedProvider?.configured;
return (
<section>
<SettingsSectionTitle>{tx("settings.sections.voiceInput", "Voice input")}</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow
title={tx("settings.rows.transcription", "Transcription")}
description={tx("settings.help.transcription", "Transcribe microphone input before sending it. Chat channel voice messages use the same settings.")}
>
<ToggleButton
checked={form.enabled}
onChange={(enabled) => onChangeForm((prev) => ({ ...prev, enabled }))}
ariaLabel={tx("settings.rows.transcription", "Transcription")}
label={form.enabled ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</SettingsRow>
<SettingsRow title={tx("settings.rows.transcriptionProvider", "Provider")}>
<ProviderPicker
providers={transcription.providers}
value={form.provider}
emptyLabel={tx("settings.voice.selectProvider", "Select provider")}
showProviderLogos={showBrandLogos}
onChange={(provider) => onChangeForm((prev) => ({ ...prev, provider }))}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.transcriptionProviderStatus", "Provider status")}
description={tx("settings.help.transcriptionProviderStatus", "API keys stay under providers, not in transcription settings.")}
>
<div className="flex flex-wrap items-center justify-end gap-2">
<StatusPill tone={providerConfigured ? "success" : "neutral"}>
{providerConfigured
? tx("settings.values.configured", "Configured")
: tx("settings.values.notConfigured", "Not configured")}
</StatusPill>
{!providerConfigured ? (
<Button size="sm" variant="outline" onClick={onOpenProviders} className="rounded-full">
{tx("settings.voice.configureProvider", "Configure provider")}
</Button>
) : null}
</div>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.transcriptionModel", "Model")}
description={tx("settings.help.transcriptionModel", "Leave as the resolved default unless your provider needs a custom model id.")}
>
<Input
value={form.model}
onChange={(event) => onChangeForm((prev) => ({ ...prev, model: event.target.value }))}
className="h-8 w-[min(300px,70vw)] rounded-full text-[13px]"
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.transcriptionLanguage", "Language")}
description={tx("settings.help.transcriptionLanguage", "Optional ISO-639 hint such as en, zh, ja, or ko.")}
>
<Input
value={form.language}
onChange={(event) => onChangeForm((prev) => ({ ...prev, language: event.target.value }))}
placeholder={tx("settings.voice.languageAuto", "Auto")}
className="h-8 w-[min(180px,60vw)] rounded-full text-[13px]"
/>
</SettingsRow>
<SettingsRow title={tx("settings.rows.voiceLimits", "Limits")}>
<div className="flex flex-wrap justify-end gap-2">
<NumberInput
value={form.maxDurationSec}
min={1}
max={600}
suffix="s"
onChange={(maxDurationSec) => onChangeForm((prev) => ({ ...prev, maxDurationSec }))}
/>
<NumberInput
value={form.maxUploadMb}
min={1}
max={100}
suffix="MB"
onChange={(maxUploadMb) => onChangeForm((prev) => ({ ...prev, maxUploadMb }))}
/>
</div>
</SettingsRow>
<RestartSettingsFooter
dirty={dirty}
saving={saving}
pendingRestart={requiresRestartPending}
dirtyMessage={tx("settings.status.restartAfterSaving", "Save changes, then restart when ready.")}
pendingMessage={tx("settings.status.savedRestartApply", "Saved. Restart when ready.")}
onSave={onSave}
onRestart={onRestart}
isRestarting={isRestarting}
/>
</SettingsGroup>
</section>
);
}
@@ -0,0 +1,293 @@
import type { Dispatch, SetStateAction } from "react";
import { Eye, EyeOff, Pencil } from "lucide-react";
import { useTranslation } from "react-i18next";
import { ProviderPicker } from "@/components/settings/shared/ModelControls";
import {
CapabilityInstallNotice,
NumberInput,
RestartSettingsFooter,
SettingsGroup,
SettingsRow,
SettingsSectionTitle,
StatusPill,
} from "@/components/settings/shared/SettingsControls";
import { ToggleButton } from "@/components/settings/ToggleButton";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type {
NanobotFeatureInfo,
SettingsPayload,
WebSearchSettingsUpdate,
} from "@/lib/types";
export const DEFAULT_WEB_SEARCH_FORM: WebSearchSettingsUpdate = {
provider: "duckduckgo",
apiKey: "",
baseUrl: "",
maxResults: 5,
timeout: 30,
useJinaReader: true,
};
export function webSearchFormFromPayload(
payload: SettingsPayload,
previous?: WebSearchSettingsUpdate,
): WebSearchSettingsUpdate {
return {
provider: payload.web_search.provider,
apiKey: previous?.provider === payload.web_search.provider ? previous.apiKey ?? "" : "",
baseUrl: payload.web_search.base_url ?? "",
maxResults: payload.web_search.max_results,
timeout: payload.web_search.timeout,
useJinaReader: payload.web.fetch.use_jina_reader,
};
}
type WebSearchProviderOption = SettingsPayload["web_search"]["providers"][number];
export function webSearchProviderAcceptsApiKey(provider?: WebSearchProviderOption): boolean {
return provider?.credential === "api_key" || provider?.credential === "optional_api_key";
}
export function webSearchProviderRequiresApiKey(provider?: WebSearchProviderOption): boolean {
return provider?.credential === "api_key";
}
export function WebSettings({
settings,
form,
keyVisible,
keyEditing,
saving,
onChangeForm,
onChangeProvider,
onToggleKey,
onToggleKeyEditing,
onReset,
onSave,
showBrandLogos,
onRestart,
isRestarting,
requiresRestartPending,
olostepFeature,
olostepInstalling,
capabilityError,
}: {
settings: SettingsPayload;
form: WebSearchSettingsUpdate;
keyVisible: boolean;
keyEditing: boolean;
saving: boolean;
onChangeForm: Dispatch<SetStateAction<WebSearchSettingsUpdate>>;
onChangeProvider: (provider: string) => void;
onToggleKey: () => void;
onToggleKeyEditing: () => void;
onReset: () => void;
onSave: () => void;
showBrandLogos: boolean;
onRestart?: () => void;
isRestarting?: boolean;
requiresRestartPending: boolean;
olostepFeature?: NanobotFeatureInfo;
olostepInstalling: boolean;
capabilityError: string | null;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const selectedProvider =
settings.web_search.providers.find((provider) => provider.name === form.provider) ??
settings.web_search.providers[0];
const hasExistingSecret =
webSearchProviderAcceptsApiKey(selectedProvider) &&
form.provider === settings.web_search.provider &&
!!settings.web_search.api_key_hint;
const showKeyInput = webSearchProviderAcceptsApiKey(selectedProvider) && (!hasExistingSecret || keyEditing);
const apiKey = form.apiKey?.trim() ?? "";
const baseUrl = form.baseUrl?.trim() ?? "";
const effectiveJinaReader = form.useJinaReader ?? settings.web.fetch.use_jina_reader;
const dirty =
form.provider !== settings.web_search.provider ||
apiKey.length > 0 ||
baseUrl !== (settings.web_search.base_url ?? "") ||
form.maxResults !== settings.web_search.max_results ||
form.timeout !== settings.web_search.timeout ||
effectiveJinaReader !== settings.web.fetch.use_jina_reader;
const jinaReaderDirty = effectiveJinaReader !== settings.web.fetch.use_jina_reader;
const missingCredential =
webSearchProviderRequiresApiKey(selectedProvider)
? !apiKey && !hasExistingSecret
: selectedProvider?.credential === "base_url"
? !baseUrl
: false;
return (
<div className="space-y-7">
<section>
<SettingsSectionTitle>{tx("settings.sections.webSearch", "Web search")}</SettingsSectionTitle>
{form.provider === "olostep" && olostepFeature && !olostepFeature.installed ? (
<div className="mb-3">
<CapabilityInstallNotice
title={tx("settings.capabilities.searchSupport", "Search provider support")}
description={tx(
"settings.capabilities.searchInstallOnSave",
"Olostep support will be installed automatically when you save.",
)}
installing={olostepInstalling}
/>
</div>
) : null}
{capabilityError ? (
<p className="mb-3 text-[12px] text-destructive">{capabilityError}</p>
) : null}
<SettingsGroup>
<SettingsRow title={t("settings.byok.webSearch.provider")}>
<ProviderPicker
providers={settings.web_search.providers}
value={form.provider}
emptyLabel={t("settings.byok.webSearch.selectProvider")}
showProviderLogos={showBrandLogos}
onChange={onChangeProvider}
/>
</SettingsRow>
{selectedProvider?.credential === "none" ? (
<SettingsRow title={t("settings.byok.webSearch.credentials")}>
<StatusPill tone="success">{t("settings.byok.webSearch.noCredentialRequired")}</StatusPill>
</SettingsRow>
) : null}
{webSearchProviderAcceptsApiKey(selectedProvider) ? (
<SettingsRow
title={t("settings.byok.apiKey")}
description={t("settings.byok.webSearch.apiKeyHelp")}
>
<div className="relative w-[280px] max-w-full">
{showKeyInput ? (
<>
<Input
type={keyVisible ? "text" : "password"}
value={form.apiKey ?? ""}
onChange={(event) =>
onChangeForm((prev) => ({ ...prev, apiKey: event.target.value }))
}
placeholder={
hasExistingSecret
? t("settings.byok.apiKeyConfiguredPlaceholder")
: t("settings.byok.apiKeyPlaceholder")
}
className="h-9 rounded-full pr-11 text-[13px]"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onToggleKey}
aria-label={
keyVisible ? t("settings.byok.hideApiKey") : t("settings.byok.showApiKey")
}
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
>
{keyVisible ? (
<EyeOff className="h-3.5 w-3.5" aria-hidden />
) : (
<Eye className="h-3.5 w-3.5" aria-hidden />
)}
</Button>
</>
) : (
<>
<div className="flex h-9 items-center rounded-full border border-input bg-background px-3 pr-11 text-[13px] text-muted-foreground">
{settings.web_search.api_key_hint ?? t("settings.byok.configuredKeyHint")}
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onToggleKeyEditing}
aria-label={t("settings.actions.edit")}
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
>
<Pencil className="h-3.5 w-3.5" aria-hidden />
</Button>
</>
)}
</div>
</SettingsRow>
) : null}
{selectedProvider?.credential === "base_url" ? (
<SettingsRow
title={t("settings.byok.webSearch.baseUrl")}
description={t("settings.byok.webSearch.baseUrlHelp")}
>
<Input
value={form.baseUrl ?? ""}
onChange={(event) =>
onChangeForm((prev) => ({ ...prev, baseUrl: event.target.value }))
}
placeholder={t("settings.byok.webSearch.baseUrlPlaceholder")}
className="h-9 w-[280px] rounded-full text-[13px]"
/>
</SettingsRow>
) : null}
</SettingsGroup>
</section>
<section>
<SettingsSectionTitle>{tx("settings.sections.webBehavior", "Behavior")}</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow title={tx("settings.rows.maxResults", "Max results")}>
<NumberInput
value={form.maxResults ?? settings.web_search.max_results}
min={1}
max={10}
onChange={(maxResults) => onChangeForm((prev) => ({ ...prev, maxResults }))}
/>
</SettingsRow>
<SettingsRow title={tx("settings.rows.timeout", "Timeout")}>
<NumberInput
value={form.timeout ?? settings.web_search.timeout}
min={1}
max={120}
onChange={(timeout) => onChangeForm((prev) => ({ ...prev, timeout }))}
suffix="s"
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.jinaReader", "Jina reader")}
description={tx("settings.help.jinaReader", "Use Jina Reader for web_fetch when available.")}
>
<ToggleButton
checked={effectiveJinaReader}
onChange={(useJinaReader) => onChangeForm((prev) => ({ ...prev, useJinaReader }))}
ariaLabel={tx("settings.rows.jinaReader", "Jina reader")}
label={effectiveJinaReader ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</SettingsRow>
<RestartSettingsFooter
dirty={dirty}
saving={saving}
pendingRestart={requiresRestartPending}
disabled={missingCredential}
message={
missingCredential
? t("settings.byok.webSearch.missingCredential")
: requiresRestartPending && !dirty
? tx("settings.status.savedRestartApply", "Saved. Restart when ready.")
: jinaReaderDirty
? tx("settings.status.restartAfterSaving", "Save changes, then restart when ready.")
: dirty
? t("settings.byok.webSearch.saveHint")
: undefined
}
onSave={onSave}
onRestart={onRestart}
onReset={onReset}
isRestarting={isRestarting}
/>
</SettingsGroup>
</section>
</div>
);
}
@@ -0,0 +1,224 @@
import { useCallback, type Dispatch, type SetStateAction } from "react";
import type { TFunction } from "i18next";
import {
webSearchProviderAcceptsApiKey,
webSearchProviderRequiresApiKey,
} from "@/components/settings/capabilities/WebSettings";
import type { CapabilitySettingsState } from "@/components/settings/capabilities/useCapabilitySettingsState";
import type {
ApplySettingsPayload,
MaybeRestartHostEngine,
PendingRestartSections,
} from "@/components/settings/contracts";
import {
updateImageGenerationSettings,
updateNetworkSafetySettings,
updateTranscriptionSettings,
updateWebSearchSettings,
} from "@/lib/api";
import type { NanobotClient } from "@/lib/nanobot-client";
import type { SettingsPayload, WebSearchSettingsUpdate } from "@/lib/types";
interface CapabilitySettingsActionsOptions {
state: CapabilitySettingsState;
settings: SettingsPayload | null;
client: NanobotClient;
t: TFunction;
applyPayload: ApplySettingsPayload;
maybeRestartHostEngine: MaybeRestartHostEngine;
setPendingRestartSections: Dispatch<SetStateAction<PendingRestartSections>>;
setError: Dispatch<SetStateAction<string | null>>;
installCapabilities: (names: string[]) => Promise<boolean>;
imageGenerationDirty: boolean;
transcriptionDirty: boolean;
networkSafetyDirty: boolean;
}
export function useCapabilitySettingsActions({
state,
settings,
client,
t,
applyPayload,
maybeRestartHostEngine,
setPendingRestartSections,
setError,
installCapabilities,
imageGenerationDirty,
transcriptionDirty,
networkSafetyDirty,
}: CapabilitySettingsActionsOptions) {
const {
imageGenerationForm,
imageGenerationSaving,
networkSafetyForm,
networkSafetySaving,
setImageGenerationSaving,
setNetworkSafetySaving,
setTranscriptionSaving,
setWebSearchForm,
setWebSearchKeyEditing,
setWebSearchKeyVisible,
setWebSearchSaving,
transcriptionForm,
transcriptionSaving,
webSearchForm,
webSearchKeyEditing,
webSearchSaving,
} = state;
const saveImageGenerationSettings = async () => {
if (!settings || !imageGenerationDirty || imageGenerationSaving) return;
setImageGenerationSaving(true);
try {
const payload = await updateImageGenerationSettings(client, imageGenerationForm);
applyPayload(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, image: true }));
}
await maybeRestartHostEngine(payload);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setImageGenerationSaving(false);
}
};
const saveTranscriptionSettings = async () => {
if (!settings || !transcriptionDirty || transcriptionSaving) return;
setTranscriptionSaving(true);
try {
const payload = await updateTranscriptionSettings(client, transcriptionForm);
applyPayload(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, browser: true }));
}
await maybeRestartHostEngine(payload);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setTranscriptionSaving(false);
}
};
const saveNetworkSafetySettings = async () => {
if (!settings || !networkSafetyDirty || networkSafetySaving) return;
setNetworkSafetySaving(true);
try {
const payload = await updateNetworkSafetySettings(client, networkSafetyForm);
applyPayload(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
}
await maybeRestartHostEngine(payload);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setNetworkSafetySaving(false);
}
};
const saveWebSearch = async () => {
if (!settings || webSearchSaving) return;
const provider = settings.web_search.providers.find((item) => item.name === webSearchForm.provider);
if (!provider) return;
const apiKey = webSearchForm.apiKey?.trim() ?? "";
const baseUrl = webSearchForm.baseUrl?.trim() ?? "";
const hasExistingSecret =
webSearchProviderAcceptsApiKey(provider) &&
webSearchForm.provider === settings.web_search.provider &&
!!settings.web_search.api_key_hint;
if (webSearchProviderRequiresApiKey(provider) && !apiKey && !hasExistingSecret) {
setError(t("settings.byok.webSearch.apiKeyRequired"));
return;
}
if (provider.credential === "base_url" && !baseUrl) {
setError(t("settings.byok.webSearch.baseUrlRequired"));
return;
}
setWebSearchSaving(true);
try {
if (provider.name === "olostep" && !(await installCapabilities(["olostep"]))) return;
const webFetchRestartRequired =
(webSearchForm.useJinaReader ?? settings.web.fetch.use_jina_reader) !==
settings.web.fetch.use_jina_reader;
const update: WebSearchSettingsUpdate = {
provider: webSearchForm.provider,
maxResults: webSearchForm.maxResults,
timeout: webSearchForm.timeout,
useJinaReader: webSearchForm.useJinaReader,
};
if (
webSearchProviderAcceptsApiKey(provider) &&
(apiKey || (provider.credential === "optional_api_key" && webSearchKeyEditing))
) {
update.apiKey = apiKey;
}
if (provider.credential === "base_url") update.baseUrl = baseUrl;
const payload = await updateWebSearchSettings(client, update);
applyPayload(payload);
if (payload.requires_restart || webFetchRestartRequired) {
setPendingRestartSections((prev) => ({ ...prev, browser: true }));
}
await maybeRestartHostEngine(payload);
setWebSearchForm((prev) => ({
provider: payload.web_search.provider,
apiKey: "",
baseUrl: payload.web_search.base_url ?? prev.baseUrl ?? "",
maxResults: payload.web_search.max_results,
timeout: payload.web_search.timeout,
useJinaReader: payload.web.fetch.use_jina_reader,
}));
setWebSearchKeyVisible(false);
setWebSearchKeyEditing(false);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setWebSearchSaving(false);
}
};
const resetWebSearchDraft = useCallback(() => {
if (!settings) return;
setWebSearchForm({
provider: settings.web_search.provider,
apiKey: "",
baseUrl: settings.web_search.base_url ?? "",
maxResults: settings.web_search.max_results,
timeout: settings.web_search.timeout,
useJinaReader: settings.web.fetch.use_jina_reader,
});
setWebSearchKeyVisible(false);
setWebSearchKeyEditing(false);
}, [settings]);
const handleWebSearchProviderChange = useCallback((provider: string) => {
if (!settings) return;
setWebSearchForm((prev) => ({
provider,
apiKey: "",
baseUrl: provider === settings.web_search.provider ? settings.web_search.base_url ?? "" : "",
maxResults: prev.maxResults ?? settings.web_search.max_results,
timeout: prev.timeout ?? settings.web_search.timeout,
useJinaReader: prev.useJinaReader ?? settings.web.fetch.use_jina_reader,
}));
setWebSearchKeyVisible(false);
setWebSearchKeyEditing(false);
}, [settings]);
return {
handleWebSearchProviderChange,
resetWebSearchDraft,
saveImageGenerationSettings,
saveNetworkSafetySettings,
saveTranscriptionSettings,
saveWebSearch,
};
}
@@ -0,0 +1,73 @@
import { useState } from "react";
import {
DEFAULT_IMAGE_GENERATION_FORM,
imageGenerationFormFromPayload,
} from "@/components/settings/capabilities/ImageGenerationSettings";
import {
DEFAULT_NETWORK_SAFETY_FORM,
networkSafetyFormFromPayload,
} from "@/components/settings/capabilities/SecuritySettings";
import {
DEFAULT_TRANSCRIPTION_FORM,
transcriptionFormFromPayload,
} from "@/components/settings/capabilities/TranscriptionSettings";
import {
DEFAULT_WEB_SEARCH_FORM,
webSearchFormFromPayload,
} from "@/components/settings/capabilities/WebSettings";
import type {
ImageGenerationSettingsUpdate,
NetworkSafetySettingsUpdate,
SettingsPayload,
TranscriptionSettingsUpdate,
WebSearchSettingsUpdate,
} from "@/lib/types";
export function useCapabilitySettingsState(initialSettings: SettingsPayload | null) {
const [webSearchSaving, setWebSearchSaving] = useState(false);
const [imageGenerationSaving, setImageGenerationSaving] = useState(false);
const [transcriptionSaving, setTranscriptionSaving] = useState(false);
const [networkSafetySaving, setNetworkSafetySaving] = useState(false);
const [webSearchForm, setWebSearchForm] = useState<WebSearchSettingsUpdate>(() =>
initialSettings ? webSearchFormFromPayload(initialSettings) : DEFAULT_WEB_SEARCH_FORM,
);
const [imageGenerationForm, setImageGenerationForm] = useState<ImageGenerationSettingsUpdate>(
() => initialSettings
? imageGenerationFormFromPayload(initialSettings)
: DEFAULT_IMAGE_GENERATION_FORM,
);
const [transcriptionForm, setTranscriptionForm] = useState<TranscriptionSettingsUpdate>(
() => initialSettings ? transcriptionFormFromPayload(initialSettings) : DEFAULT_TRANSCRIPTION_FORM,
);
const [networkSafetyForm, setNetworkSafetyForm] = useState<NetworkSafetySettingsUpdate>(() =>
initialSettings ? networkSafetyFormFromPayload(initialSettings) : DEFAULT_NETWORK_SAFETY_FORM,
);
const [webSearchKeyVisible, setWebSearchKeyVisible] = useState(false);
const [webSearchKeyEditing, setWebSearchKeyEditing] = useState(false);
return {
imageGenerationForm,
imageGenerationSaving,
networkSafetyForm,
networkSafetySaving,
setImageGenerationForm,
setImageGenerationSaving,
setNetworkSafetyForm,
setNetworkSafetySaving,
setTranscriptionForm,
setTranscriptionSaving,
setWebSearchForm,
setWebSearchKeyEditing,
setWebSearchKeyVisible,
setWebSearchSaving,
transcriptionForm,
transcriptionSaving,
webSearchForm,
webSearchKeyEditing,
webSearchKeyVisible,
webSearchSaving,
};
}
export type CapabilitySettingsState = ReturnType<typeof useCapabilitySettingsState>;
@@ -0,0 +1,32 @@
import type { SettingsPayload } from "@/lib/types";
export type SettingsSectionKey =
| "overview"
| "appearance"
| "models"
| "image"
| "voice"
| "browser"
| "channels"
| "apps"
| "automations"
| "skills"
| "runtime"
| "advanced";
export type PendingRestartSection = "runtime" | "browser" | "image";
export type PendingRestartSections = Record<PendingRestartSection, boolean>;
export type RestartAwarePayload = {
requires_restart?: boolean;
surface?: SettingsPayload["surface"];
runtime_surface?: SettingsPayload["runtime_surface"];
runtime_capabilities?: SettingsPayload["runtime_capabilities"];
};
export type ApplySettingsPayload = (
payload: SettingsPayload,
options?: { preserveAgentForm?: boolean },
) => void;
export type MaybeRestartHostEngine = (payload: RestartAwarePayload) => Promise<void>;
@@ -0,0 +1,923 @@
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
import {
ChevronDown,
ChevronRight,
GripVertical,
ListOrdered,
Loader2,
Plus,
Trash2,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import {
ModelIdPicker,
ProviderPicker,
ProviderPickerIcon,
formatContextWindow,
formatModelContextWindow,
normalizeContextWindowTokens,
settingsProviderConfigured,
} from "@/components/settings/shared/ModelControls";
import {
SettingsGroup,
SettingsRow,
SettingsSectionTitle,
SettingsStatusMessage,
StatusPill,
} from "@/components/settings/shared/SettingsControls";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { cn } from "@/lib/utils";
import type { SettingsPayload } from "@/lib/types";
export interface AgentSettingsDraft {
model: string;
provider: string;
modelPreset: string;
presetLabel: string;
maxTokens: number;
contextWindowTokens: number;
temperature: number;
reasoningEffort: string;
timezone: string;
toolHintMaxLength: number;
}
const CONTEXT_WINDOW_TOKEN_OPTIONS = [65_536, 200_000, 262_144, 500_000, 1_048_576] as const;
function modelPresetValue(payload: SettingsPayload): string {
return (
payload.model_call_order?.[0] ??
payload.model_presets.find((preset) => !preset.is_default)?.name ??
""
);
}
export const DEFAULT_AGENT_SETTINGS_DRAFT: AgentSettingsDraft = {
model: "",
provider: "",
modelPreset: "",
presetLabel: "",
maxTokens: 8192,
contextWindowTokens: 200_000,
temperature: 0.1,
reasoningEffort: "",
timezone: "UTC",
toolHintMaxLength: 40,
};
export function agentDraftFromPayload(
payload: SettingsPayload,
preferredPresetName?: string,
): AgentSettingsDraft {
const activePresetName = preferredPresetName ?? modelPresetValue(payload);
const activePreset =
payload.model_presets.find(
(preset) => !preset.is_default && preset.name === activePresetName,
) ?? null;
return {
model: activePreset?.model ?? payload.agent.model,
provider: activePreset?.provider ?? payload.agent.provider ?? payload.agent.resolved_provider ?? "",
modelPreset: activePresetName,
presetLabel: activePreset?.label ?? activePresetName,
maxTokens: activePreset?.max_tokens ?? payload.agent.max_tokens,
contextWindowTokens: normalizeContextWindowTokens(
activePreset?.context_window_tokens ?? payload.agent.context_window_tokens,
),
temperature: activePreset?.temperature ?? payload.agent.temperature,
reasoningEffort: activePreset?.reasoning_effort ?? "",
timezone: payload.agent.timezone,
toolHintMaxLength: payload.agent.tool_hint_max_length,
};
}
export function ModelPresetDeleteDialog({
preset,
deleting,
onOpenChange,
onConfirm,
}: {
preset: SettingsPayload["model_presets"][number] | null;
deleting: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
t(key, { defaultValue: fallback, ...(values ?? {}) });
return (
<Dialog open={preset !== null} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[440px] rounded-[24px]">
<DialogHeader className="text-left">
<DialogTitle>
{tx("settings.models.deletePresetTitle", "Delete model preset?")}
</DialogTitle>
<DialogDescription className="leading-5">
{tx(
"settings.models.deletePresetHelp",
"This removes the preset “{{name}}”. Provider credentials are not affected.",
{ name: preset?.label ?? "" },
)}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:space-x-0">
<Button
type="button"
variant="ghost"
className="rounded-full"
disabled={deleting}
onClick={() => onOpenChange(false)}
>
{tx("settings.actions.cancel", "Cancel")}
</Button>
<Button
type="button"
variant="destructive"
className="rounded-full"
disabled={deleting}
onClick={onConfirm}
>
{deleting ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : null}
{deleting
? tx("settings.actions.deleting", "Deleting...")
: tx("settings.actions.delete", "Delete")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function ModelsSettings({
token,
form,
setForm,
settings,
dirty,
creating,
creatingSaving,
callOrder,
saving,
orderSaving,
migrationSaving,
showBrandLogos,
providerSaving,
onChangeCallOrder,
onProviderOAuthLogin,
onSave,
onMigrate,
onBeginCreate,
onCancelCreate,
onSelectConfiguration,
onDeleteConfiguration,
}: {
token: string;
form: AgentSettingsDraft;
setForm: Dispatch<SetStateAction<AgentSettingsDraft>>;
settings: SettingsPayload;
dirty: boolean;
creating: boolean;
creatingSaving: boolean;
callOrder: string[];
saving: boolean;
orderSaving: boolean;
migrationSaving: boolean;
showBrandLogos: boolean;
providerSaving: string | null;
onChangeCallOrder: (order: string[]) => void;
onProviderOAuthLogin: (provider: string) => void;
onSave: () => void;
onMigrate: () => void;
onBeginCreate: () => void;
onCancelCreate: () => void;
onSelectConfiguration: () => void;
onDeleteConfiguration: (preset: SettingsPayload["model_presets"][number]) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
t(key, { defaultValue: fallback, ...(values ?? {}) });
const [editorOpen, setEditorOpen] = useState(false);
const [editorRowKey, setEditorRowKey] = useState<string | null>(null);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [draggedCallOrderIndex, setDraggedCallOrderIndex] = useState<number | null>(null);
const [dragOverCallOrderIndex, setDragOverCallOrderIndex] = useState<number | null>(null);
const namedPresets = settings.model_presets.filter((preset) => !preset.is_default);
const namedPresetsByName = new Map(namedPresets.map((preset) => [preset.name, preset]));
const unorderedPresets = namedPresets.filter((preset) => !callOrder.includes(preset.name));
const callOrderOccurrences = new Map<string, number>();
const presetRows = [
...callOrder.map((name, orderIndex) => {
const occurrence = callOrderOccurrences.get(name) ?? 0;
callOrderOccurrences.set(name, occurrence + 1);
return {
key: `ordered:${name}:${occurrence}`,
name,
orderIndex,
preset: namedPresetsByName.get(name),
};
}),
...unorderedPresets.map((preset) => ({
key: `disabled:${preset.name}`,
name: preset.name,
orderIndex: -1,
preset,
})),
];
const selectedPreset = namedPresetsByName.get(form.modelPreset) ?? null;
const activeEditorRowKey =
editorRowKey ??
presetRows.find((row) => row.name === selectedPreset?.name)?.key ??
null;
useEffect(() => {
setAdvancedOpen(false);
}, [editorOpen, selectedPreset?.name]);
const configuredProviders = settings.providers.filter((provider) => provider.configured);
const selectedProvider = settings.providers.find((provider) => provider.name === form.provider);
const selectableProviders = uniqueProviders([
...configuredProviders,
...(selectedProvider ? [selectedProvider] : []),
]);
const showAutoProvider = selectedPreset?.provider === "auto" || form.provider === "auto";
const providerOptions = showAutoProvider
? [{ name: "auto", label: tx("settings.values.auto", "Auto") }, ...selectableProviders]
: selectableProviders;
const providerValue = providerOptions.some((provider) => provider.name === form.provider)
? form.provider
: "";
const selectedProviderNeedsSignIn =
selectedProvider?.auth_type === "oauth" && !selectedProvider.configured;
const selectedProviderSigningIn = providerSaving === selectedProvider?.name;
const selectedProviderConfigured = settingsProviderConfigured(
settings,
form.provider,
selectedPreset?.resolved_provider,
);
const modelFieldsMissing =
!form.model.trim() ||
!form.provider.trim() ||
!form.presetLabel.trim() ||
form.maxTokens <= 0 ||
form.temperature < 0 ||
form.temperature > 2;
const selectedPresetReferenced = Boolean(
selectedPreset && callOrder.includes(selectedPreset.name),
);
const callOrderBusy = orderSaving || saving;
const selectPreset = (
preset: SettingsPayload["model_presets"][number],
rowKey: string,
) => {
const toggleCurrentPreset =
!creating && selectedPreset?.name === preset.name && activeEditorRowKey === rowKey;
onSelectConfiguration();
if (toggleCurrentPreset) {
setEditorOpen((open) => !open);
return;
}
setForm((prev) => ({
...prev,
modelPreset: preset.name,
model: preset.model,
provider: preset.provider,
presetLabel: preset.label,
maxTokens: preset.max_tokens,
contextWindowTokens: normalizeContextWindowTokens(preset.context_window_tokens),
temperature: preset.temperature,
reasoningEffort: preset.reasoning_effort ?? "",
}));
setEditorRowKey(rowKey);
setEditorOpen(true);
};
const moveCallOrderItem = (index: number, offset: -1 | 1) => {
if (callOrderBusy) return;
const nextIndex = index + offset;
if (nextIndex < 0 || nextIndex >= callOrder.length) return;
const next = [...callOrder];
[next[index], next[nextIndex]] = [next[nextIndex], next[index]];
onChangeCallOrder(next);
};
const removeCallOrderItem = (index: number) => {
if (callOrderBusy || callOrder.length <= 1) return;
onChangeCallOrder(callOrder.filter((_, itemIndex) => itemIndex !== index));
};
const dropCallOrderItem = (targetIndex: number) => {
if (
callOrderBusy ||
draggedCallOrderIndex === null ||
draggedCallOrderIndex === targetIndex
) {
setDraggedCallOrderIndex(null);
setDragOverCallOrderIndex(null);
return;
}
const next = [...callOrder];
const moved = next.splice(draggedCallOrderIndex, 1)[0];
if (!moved) {
setDraggedCallOrderIndex(null);
setDragOverCallOrderIndex(null);
return;
}
next.splice(targetIndex, 0, moved);
setDraggedCallOrderIndex(null);
setDragOverCallOrderIndex(null);
onChangeCallOrder(next);
};
const renderPresetEditor = () => (
<div
id="model-preset-editor"
data-testid="model-preset-editor"
className="mx-3 mb-3 divide-y divide-border/45 overflow-hidden rounded-[18px] border border-border/45 bg-background/80 shadow-sm motion-reduce:animate-none animate-in fade-in-0 slide-in-from-top-1 duration-200 sm:mx-5 lg:mx-auto lg:w-[calc(100%-2.5rem)] lg:max-w-6xl"
>
{creating ? (
<div className="flex min-h-[52px] items-center px-4 py-3 sm:px-5">
<span className="text-[13px] font-semibold text-foreground/85">
{tx("settings.models.newPreset", "New model preset")}
</span>
</div>
) : null}
<SettingsRow title={tx("settings.models.presetName", "Preset name")}>
<Input
autoFocus={creating}
value={form.presetLabel}
placeholder={tx("settings.models.presetNamePlaceholder", "Fast writing")}
onChange={(event) =>
setForm((prev) => ({ ...prev, presetLabel: event.target.value }))
}
className="h-8 w-[min(280px,70vw)] rounded-full text-[13px]"
/>
</SettingsRow>
<SettingsRow title={t("settings.rows.provider")}>
<ProviderPicker
providers={providerOptions}
value={providerValue}
emptyLabel={t("settings.byok.noConfiguredProviders")}
showProviderLogos={showBrandLogos}
onChange={(provider) =>
setForm((prev) => ({
...prev,
provider,
model: provider === prev.provider ? prev.model : "",
}))
}
/>
</SettingsRow>
{selectedProviderNeedsSignIn ? (
<SettingsRow
title={tx("settings.oauth.signInRequired", "Sign in required")}
description={tx(
"settings.oauth.signInBeforeSaving",
"Sign in before saving this provider in the preset.",
)}
>
<Button
size="sm"
variant="outline"
onClick={() => selectedProvider && onProviderOAuthLogin(selectedProvider.name)}
disabled={!selectedProvider?.oauth_login_supported || selectedProviderSigningIn}
className="rounded-full"
>
{selectedProviderSigningIn ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : null}
{selectedProviderSigningIn
? tx("settings.oauth.signingIn", "Signing in...")
: tx("settings.oauth.signIn", "Sign in")}
</Button>
</SettingsRow>
) : null}
<SettingsRow title={t("settings.rows.model")}>
<ModelIdPicker
token={token}
settings={settings}
provider={form.provider}
value={form.model}
showProviderLogos={showBrandLogos}
onChange={(model) => setForm((prev) => ({ ...prev, model }))}
/>
</SettingsRow>
<button
type="button"
aria-expanded={advancedOpen}
onClick={() => setAdvancedOpen((value) => !value)}
className="flex min-h-[62px] w-full items-center justify-between gap-4 px-4 py-3.5 text-left transition-colors hover:bg-muted/30 sm:px-5"
>
<span>
<span className="block text-[14px] font-medium text-foreground">
{tx("settings.models.advancedOptions", "Advanced options")}
</span>
<span className="mt-0.5 block text-[12px] text-muted-foreground">
{tx(
"settings.models.advancedSummary",
"Context {{context}} · Max {{max}} tokens",
{
context: formatModelContextWindow(form.contextWindowTokens),
max: formatContextWindow(form.maxTokens),
},
)}
</span>
</span>
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
advancedOpen && "rotate-180",
)}
aria-hidden
/>
</button>
{advancedOpen ? (
<div className="bg-muted/12 px-4 py-4 sm:px-5">
<ModelAdvancedFields
maxTokens={form.maxTokens}
contextWindowTokens={form.contextWindowTokens}
temperature={form.temperature}
reasoningEffort={form.reasoningEffort}
onChange={(value) => setForm((prev) => ({ ...prev, ...value }))}
/>
</div>
) : null}
<div className="flex min-h-[58px] flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5">
{creating ? (
<Button
size="sm"
variant="ghost"
className="self-start rounded-full text-muted-foreground"
disabled={creatingSaving}
onClick={() => {
setEditorOpen(false);
onCancelCreate();
}}
>
{tx("settings.actions.cancel", "Cancel")}
</Button>
) : selectedPreset ? (
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<Button
size="sm"
variant="ghost"
className="rounded-full text-muted-foreground hover:text-destructive"
disabled={selectedPresetReferenced || saving || orderSaving}
aria-describedby={
selectedPresetReferenced ? "model-preset-delete-hint" : undefined
}
onClick={() => onDeleteConfiguration(selectedPreset)}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" aria-hidden />
{tx("settings.actions.delete", "Delete")}
</Button>
{selectedPresetReferenced ? (
<span
id="model-preset-delete-hint"
className="text-[11px] leading-4 text-muted-foreground"
>
{tx(
"settings.models.removeBeforeDelete",
"Remove this preset from the call order before deleting it.",
)}
</span>
) : null}
</div>
) : null}
<div className="flex items-center justify-end gap-3">
<Button
size="sm"
variant="outline"
className="rounded-full"
disabled={
(!creating && !dirty) ||
!selectedProviderConfigured ||
modelFieldsMissing ||
saving ||
orderSaving
}
onClick={onSave}
>
{saving || creatingSaving
? tx("settings.actions.saving", "Saving...")
: tx("settings.actions.savePreset", "Save preset")}
</Button>
</div>
</div>
</div>
);
return (
<div className="space-y-7">
<section>
<SettingsSectionTitle>
{tx("settings.models.presets", "Model presets")}
</SettingsSectionTitle>
<SettingsGroup>
{!settings.model_call_order_editable ? (
<div className="flex flex-col gap-4 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<div className="flex min-w-0 items-start gap-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-[12px] bg-muted text-muted-foreground">
<ListOrdered className="h-4 w-4" aria-hidden />
</span>
<div className="min-w-0">
<p className="text-[14px] font-medium text-foreground">
{tx("settings.models.convertTitle", "Convert the current model setup")}
</p>
<p className="mt-0.5 max-w-[34rem] text-[12px] leading-5 text-muted-foreground">
{tx(
"settings.models.convertHelp",
"Turn the existing primary and fallback models into presets so their order can be managed here.",
)}
</p>
</div>
</div>
<Button
size="sm"
variant="outline"
className="shrink-0 rounded-full"
disabled={migrationSaving}
onClick={onMigrate}
>
{migrationSaving ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : null}
{migrationSaving
? tx("settings.models.converting", "Converting...")
: tx("settings.models.convertAction", "Convert to presets")}
</Button>
</div>
) : (
<>
<div role="list" className="divide-y divide-border/45">
{presetRows.map(({ key, name, orderIndex, preset }) => {
const ordered = orderIndex >= 0;
const provider = preset
? modelPresetProviderKey(preset, settings)
: settings.agent.resolved_provider ?? settings.agent.provider;
const presetConfigured = preset
? settingsProviderConfigured(
settings,
preset.provider,
preset.resolved_provider,
)
: true;
const isDropTarget =
ordered &&
dragOverCallOrderIndex === orderIndex &&
draggedCallOrderIndex !== orderIndex;
const dropAfterTarget =
isDropTarget &&
draggedCallOrderIndex !== null &&
draggedCallOrderIndex < orderIndex;
const isSelected =
editorOpen &&
!creating &&
activeEditorRowKey === key &&
selectedPreset?.name === name;
const presetRow = (
<div
tabIndex={ordered ? 0 : -1}
draggable={ordered && !callOrderBusy}
aria-label={
ordered
? `${preset?.label ?? name}. ${tx(
"settings.models.dragToReorder",
"Drag to reorder",
)}`
: preset?.label ?? name
}
data-testid={`model-call-order-row-${name}`}
onDragStart={(event) => {
if (!ordered || callOrderBusy) {
event.preventDefault();
return;
}
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", name);
setDraggedCallOrderIndex(orderIndex);
setDragOverCallOrderIndex(orderIndex);
}}
onDragEnd={() => {
setDraggedCallOrderIndex(null);
setDragOverCallOrderIndex(null);
}}
onDragEnter={(event) => {
if (ordered && draggedCallOrderIndex !== null) {
event.preventDefault();
setDragOverCallOrderIndex(orderIndex);
}
}}
onDragOver={(event) => {
if (!ordered || draggedCallOrderIndex === null) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
}}
onDrop={(event) => {
if (!ordered) return;
event.preventDefault();
dropCallOrderItem(orderIndex);
}}
onKeyDown={(event) => {
if (event.currentTarget !== event.target) return;
if (ordered && event.key === "ArrowUp") {
event.preventDefault();
moveCallOrderItem(orderIndex, -1);
} else if (ordered && event.key === "ArrowDown") {
event.preventDefault();
moveCallOrderItem(orderIndex, 1);
} else if ((event.key === "Enter" || event.key === " ") && preset) {
event.preventDefault();
selectPreset(preset, key);
}
}}
className={cn(
"group relative flex min-h-[76px] select-none items-center gap-3 px-4 py-3 outline-none transition-[background-color,opacity] duration-150 sm:px-5",
ordered &&
(callOrderBusy
? "cursor-wait"
: "cursor-grab active:cursor-grabbing"),
"hover:bg-muted/25",
isDropTarget &&
!dropAfterTarget &&
"before:absolute before:inset-x-4 before:top-0 before:z-10 before:h-0.5 before:rounded-full before:bg-foreground sm:before:inset-x-5",
isDropTarget &&
dropAfterTarget &&
"after:absolute after:inset-x-4 after:bottom-0 after:z-10 after:h-0.5 after:rounded-full after:bg-foreground sm:after:inset-x-5",
ordered && draggedCallOrderIndex === orderIndex && "opacity-35",
isSelected && "bg-muted/45 hover:bg-muted/45",
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
)}
>
{ordered ? (
<GripVertical
className="pointer-events-none h-4 w-4 shrink-0 text-muted-foreground/40 transition-colors group-hover:text-muted-foreground"
aria-hidden
/>
) : (
<span className="h-4 w-4 shrink-0" aria-hidden />
)}
<button
type="button"
aria-pressed={selectedPreset?.name === name}
aria-expanded={isSelected}
aria-controls={isSelected ? "model-preset-editor" : undefined}
disabled={!preset}
onClick={() => preset && selectPreset(preset, key)}
className="flex min-w-0 flex-1 items-center gap-3 rounded-[12px] text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{ordered ? (
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-muted font-mono text-[11px] font-semibold tabular-nums text-muted-foreground">
{orderIndex + 1}
</span>
) : (
<span className="h-7 w-7 shrink-0" aria-hidden />
)}
<ProviderPickerIcon
provider={provider}
showBrandLogos={showBrandLogos}
unconfigured={!presetConfigured}
/>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 flex-wrap items-center gap-2">
<span className="truncate text-[14px] font-medium text-foreground">
{preset?.label ?? name}
</span>
{orderIndex === 0 ? (
<StatusPill tone="success">
{tx("settings.models.primary", "Primary")}
</StatusPill>
) : !ordered ? (
<StatusPill tone="neutral">
{tx("settings.models.disabled", "Disabled")}
</StatusPill>
) : null}
{!presetConfigured ? (
<span className="text-[11px] font-medium text-amber-700 dark:text-amber-300">
{tx(
"settings.models.providerSetupRequired",
"Provider setup required",
)}
</span>
) : null}
</span>
<span className="mt-0.5 block truncate text-[12px] text-muted-foreground">
{preset?.model ?? name}
</span>
</span>
<ChevronRight
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
isSelected && "rotate-90",
)}
aria-hidden
/>
</button>
<button
type="button"
role="switch"
aria-checked={ordered}
aria-label={
ordered
? tx("settings.models.removeFromOrder", "Disable preset")
: tx("settings.models.addToOrder", "Enable preset")
}
disabled={callOrderBusy || (ordered && callOrder.length <= 1)}
onClick={() => {
if (ordered) {
removeCallOrderItem(orderIndex);
} else if (preset) {
onChangeCallOrder([...callOrder, preset.name]);
}
}}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-40",
ordered ? "bg-foreground" : "bg-muted-foreground/25",
)}
>
<span
className={cn(
"h-4 w-4 rounded-full bg-background shadow-sm transition-transform",
ordered ? "translate-x-[18px]" : "translate-x-0.5",
)}
aria-hidden
/>
</button>
</div>
);
return (
<div key={key} role="listitem">
{presetRow}
{isSelected ? renderPresetEditor() : null}
</div>
);
})}
</div>
<div className="flex min-h-[58px] flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5">
{!creating ? (
<Button
size="sm"
variant="ghost"
className="rounded-full"
disabled={callOrderBusy}
onClick={() => {
setEditorRowKey(null);
setEditorOpen(true);
onBeginCreate();
}}
>
<Plus className="mr-1.5 h-3.5 w-3.5" aria-hidden />
{tx("settings.models.newPreset", "New model preset")}
</Button>
) : (
<span />
)}
{orderSaving ? (
<SettingsStatusMessage>
<span className="inline-flex items-center gap-1.5">
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
{tx("settings.actions.saving", "Saving...")}
</span>
</SettingsStatusMessage>
) : null}
</div>
{creating && editorOpen ? renderPresetEditor() : null}
</>
)}
</SettingsGroup>
</section>
</div>
);
}
function ModelAdvancedFields({
maxTokens,
contextWindowTokens,
temperature,
reasoningEffort,
onChange,
}: {
maxTokens: number;
contextWindowTokens: number;
temperature: number;
reasoningEffort: string;
onChange: (
value: Partial<
Pick<
AgentSettingsDraft,
"maxTokens" | "contextWindowTokens" | "temperature" | "reasoningEffort"
>
>,
) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const contextWindowOptions = Array.from(
new Set([...CONTEXT_WINDOW_TOKEN_OPTIONS, contextWindowTokens]),
).sort((left, right) => left - right);
return (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<label className="block">
<span className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
{tx("settings.models.maxTokens", "Max output tokens")}
</span>
<Input
type="number"
min={1}
step={1}
value={maxTokens}
onChange={(event) => {
const value = Number(event.target.value);
if (Number.isFinite(value)) onChange({ maxTokens: value });
}}
className="h-9 rounded-[12px] text-[13px]"
/>
</label>
<label className="block">
<span className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
{tx("settings.models.temperature", "Temperature")}
</span>
<Input
type="number"
min={0}
max={2}
step={0.1}
value={temperature}
onChange={(event) => {
const value = Number(event.target.value);
if (Number.isFinite(value)) onChange({ temperature: value });
}}
className="h-9 rounded-[12px] text-[13px]"
/>
</label>
</div>
<div>
<span className="mb-2 block text-[12px] font-medium text-muted-foreground">
{tx("settings.rows.contextWindow", "Context window")}
</span>
<SegmentedControl
value={String(contextWindowTokens)}
options={contextWindowOptions.map((tokens) => ({
value: String(tokens),
label: formatModelContextWindow(tokens),
}))}
onChange={(value) =>
onChange({ contextWindowTokens: normalizeContextWindowTokens(Number(value)) })
}
/>
</div>
<label className="block">
<span className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
{tx("settings.models.reasoningEffort", "Reasoning effort")}
</span>
<Input
value={reasoningEffort}
onChange={(event) => onChange({ reasoningEffort: event.target.value })}
placeholder={tx("settings.values.default", "Default")}
autoCapitalize="none"
spellCheck={false}
className="h-9 rounded-[12px] text-[13px]"
/>
</label>
</div>
);
}
function uniqueProviders(
providers: SettingsPayload["providers"],
): SettingsPayload["providers"] {
const seen = new Set<string>();
return providers.filter((provider) => {
if (seen.has(provider.name)) return false;
seen.add(provider.name);
return true;
});
}
function modelPresetProviderKey(
preset: SettingsPayload["model_presets"][number],
settings: SettingsPayload,
options: { draftProvider?: string } = {},
): string {
const provider = options.draftProvider ?? preset.provider;
if (provider === "auto") {
return (
preset.resolved_provider ||
settings.agent.resolved_provider ||
settings.agent.provider ||
preset.provider
);
}
return provider;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,562 @@
import { useCallback, type Dispatch, type SetStateAction } from "react";
import type { TFunction } from "i18next";
import type {
ApplySettingsPayload,
MaybeRestartHostEngine,
PendingRestartSections,
} from "@/components/settings/contracts";
import { agentDraftFromPayload } from "@/components/settings/models/ModelsSettings";
import {
CUSTOM_PROVIDER_CREATION_KEY,
providerFormFromRow,
type CustomProviderDraft,
} from "@/components/settings/models/ProviderSettings";
import type { ModelSettingsState } from "@/components/settings/models/useModelSettingsState";
import { normalizeContextWindowTokens } from "@/components/settings/shared/ModelControls";
import {
completeProviderOAuth,
createModelConfiguration,
createProviderSettings,
deleteModelConfiguration,
loginProviderOAuth,
logoutProviderOAuth,
migrateModelConfigurations,
updateModelCallOrder,
updateModelConfiguration,
updateProviderSettings,
} from "@/lib/api";
import type { NanobotClient } from "@/lib/nanobot-client";
import type {
ProviderOAuthAuthorizationRequired,
ProviderOAuthCompletionResult,
ProviderOAuthLoginResult,
ProviderOAuthPending,
ProviderSettingsUpdate,
SettingsPayload,
} from "@/lib/types";
function isProviderOAuthAuthorizationRequired(
payload: ProviderOAuthLoginResult,
): payload is ProviderOAuthAuthorizationRequired {
return (payload as ProviderOAuthAuthorizationRequired).status === "authorization_required";
}
function isProviderOAuthPending(
payload: ProviderOAuthCompletionResult,
): payload is ProviderOAuthPending {
return (payload as ProviderOAuthPending).status === "pending";
}
interface ModelSettingsActionsOptions {
state: ModelSettingsState;
settings: SettingsPayload | null;
client: NanobotClient;
t: TFunction;
applyPayload: ApplySettingsPayload;
maybeRestartHostEngine: MaybeRestartHostEngine;
setPendingRestartSections: Dispatch<SetStateAction<PendingRestartSections>>;
setError: Dispatch<SetStateAction<string | null>>;
onModelNameChange: (modelName: string | null) => void;
remoteBrowserAccess: boolean;
closeProviderOAuthFlow: () => void;
installCapabilities: (names: string[]) => Promise<boolean>;
modelDirty: boolean;
configuredModelProviderOptions: Array<{ name: string; label: string }>;
}
export function useModelSettingsActions({
state,
settings,
client,
t,
applyPayload,
maybeRestartHostEngine,
setPendingRestartSections,
setError,
onModelNameChange,
remoteBrowserAccess,
closeProviderOAuthFlow,
installCapabilities,
modelDirty,
configuredModelProviderOptions,
}: ModelSettingsActionsOptions) {
const {
expandedProvider,
form,
modelCallOrder,
modelCallOrderSaving,
modelConfigurationSaving,
modelMigrationSaving,
modelPresetBeforeCreateRef,
modelPresetCreating,
modelPresetPendingDelete,
providerForms,
providerOAuthCompleting,
providerOAuthFlowRef,
providerOAuthResponse,
providerSaving,
saving,
setEditingProviderKeys,
setExpandedProvider,
setForm,
setModelCallOrder,
setModelCallOrderSaving,
setModelConfigurationSaving,
setModelMigrationSaving,
setModelPresetCreating,
setModelPresetPendingDelete,
setProviderForms,
setProviderOAuthCompleting,
setProviderOAuthDialogError,
setProviderOAuthFlow,
setProviderOAuthResponse,
setProviderSaving,
setSaving,
setVisibleProviderKeys,
visibleProviderKeys,
} = state;
const saveModelSettings = async () => {
if (
!settings ||
saving ||
modelCallOrderSaving ||
modelConfigurationSaving
) {
return;
}
if (modelPresetCreating) {
const label = form.presetLabel.trim();
const provider = form.provider.trim();
const model = form.model.trim();
if (
!label ||
!provider ||
!model ||
form.maxTokens <= 0 ||
form.contextWindowTokens <= 0 ||
form.temperature < 0 ||
form.temperature > 2
) {
return;
}
setModelConfigurationSaving(true);
try {
const payload = await createModelConfiguration(client, {
label,
provider,
model,
maxTokens: form.maxTokens,
contextWindowTokens: form.contextWindowTokens,
temperature: form.temperature,
reasoningEffort: form.reasoningEffort || null,
});
const createdPreset = payload.created_model_preset;
const nextOrder = createdPreset ? [...modelCallOrder, createdPreset] : null;
applyPayload(payload);
if (createdPreset) {
setForm(agentDraftFromPayload(payload, createdPreset));
}
let finalPayload = payload;
if (nextOrder) {
const orderedPayload = await updateModelCallOrder(client, nextOrder);
applyPayload(orderedPayload);
finalPayload = orderedPayload;
}
if (createdPreset) {
setForm(agentDraftFromPayload(finalPayload, createdPreset));
}
modelPresetBeforeCreateRef.current = null;
onModelNameChange(finalPayload.agent.model || null);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setModelConfigurationSaving(false);
}
return;
}
if (!modelDirty) return;
const selectedPreset = settings.model_presets.find(
(preset) => !preset.is_default && preset.name === form.modelPreset,
);
if (!selectedPreset) return;
const reasoningEffort = form.reasoningEffort || null;
setSaving(true);
try {
const payload = await updateModelConfiguration(client, {
name: selectedPreset.name,
label:
form.presetLabel.trim() !== selectedPreset.label
? form.presetLabel.trim()
: undefined,
model: form.model !== selectedPreset.model ? form.model : undefined,
provider: form.provider !== selectedPreset.provider ? form.provider : undefined,
maxTokens:
form.maxTokens !== selectedPreset.max_tokens ? form.maxTokens : undefined,
contextWindowTokens:
form.contextWindowTokens !==
normalizeContextWindowTokens(selectedPreset.context_window_tokens)
? form.contextWindowTokens
: undefined,
temperature:
form.temperature !== selectedPreset.temperature ? form.temperature : undefined,
reasoningEffort:
reasoningEffort !== selectedPreset.reasoning_effort ? reasoningEffort : undefined,
});
applyPayload(payload);
setForm(agentDraftFromPayload(payload, selectedPreset.name));
onModelNameChange(payload.agent.model || null);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setSaving(false);
}
};
const beginModelPresetCreation = () => {
if (!settings || saving || modelCallOrderSaving || modelConfigurationSaving) return;
const primaryPreset = settings.model_presets.find(
(preset) => !preset.is_default && preset.name === settings.model_call_order?.[0],
);
const currentProvider = primaryPreset?.provider === "auto"
? primaryPreset.resolved_provider ?? settings.agent.resolved_provider
: primaryPreset?.provider ?? settings.agent.provider;
const provider =
configuredModelProviderOptions.find((option) => option.name === currentProvider)?.name ??
configuredModelProviderOptions[0]?.name ??
"";
modelPresetBeforeCreateRef.current = form.modelPreset;
setForm((prev) => ({
...prev,
modelPreset: "",
presetLabel: "",
provider,
model: "",
maxTokens: primaryPreset?.max_tokens ?? settings.agent.max_tokens,
contextWindowTokens: normalizeContextWindowTokens(
primaryPreset?.context_window_tokens ?? settings.agent.context_window_tokens,
),
temperature: primaryPreset?.temperature ?? settings.agent.temperature,
reasoningEffort: primaryPreset?.reasoning_effort ?? settings.agent.reasoning_effort ?? "",
}));
setModelPresetCreating(true);
};
const cancelModelPresetCreation = () => {
if (!settings || modelConfigurationSaving) return;
const previousPreset = modelPresetBeforeCreateRef.current;
setModelPresetCreating(false);
setForm(agentDraftFromPayload(settings, previousPreset ?? undefined));
modelPresetBeforeCreateRef.current = null;
};
const changeModelCallOrder = async (nextOrder: string[]) => {
const unchanged =
nextOrder.length === modelCallOrder.length &&
nextOrder.every((name, index) => name === modelCallOrder[index]);
if (
!settings ||
saving ||
modelCallOrderSaving ||
modelConfigurationSaving ||
nextOrder.length === 0 ||
unchanged
) {
return;
}
const previousOrder = [...modelCallOrder];
setModelCallOrder(nextOrder);
setModelCallOrderSaving(true);
try {
const payload = await updateModelCallOrder(client, nextOrder);
applyPayload(payload, { preserveAgentForm: true });
onModelNameChange(payload.agent.model || null);
setError(null);
} catch (err) {
setModelCallOrder(previousOrder);
setError((err as Error).message);
} finally {
setModelCallOrderSaving(false);
}
};
const handleMigrateModelConfigurations = async () => {
if (modelMigrationSaving) return;
setModelMigrationSaving(true);
try {
const payload = await migrateModelConfigurations(client);
applyPayload(payload);
onModelNameChange(payload.agent.model || null);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setModelMigrationSaving(false);
}
};
const handleDeleteModelConfiguration = async () => {
if (
!modelPresetPendingDelete ||
saving ||
modelCallOrderSaving ||
modelConfigurationSaving
) {
return;
}
setSaving(true);
try {
const payload = await deleteModelConfiguration(client, modelPresetPendingDelete.name);
applyPayload(payload);
setModelPresetPendingDelete(null);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setSaving(false);
}
};
const saveProvider = async (providerName: string) => {
if (providerSaving) return;
const provider = settings?.providers.find((item) => item.name === providerName);
if (!provider) return;
const isOauthProvider = provider.auth_type === "oauth";
const providerForm = providerForms[providerName] ?? providerFormFromRow(provider);
const apiKey = providerForm.apiKey.trim();
const apiKeyRequired = provider.api_key_required ?? true;
if (!isOauthProvider && !provider.configured && apiKeyRequired && !apiKey) {
setError(t("settings.byok.apiKeyRequired"));
return;
}
setProviderSaving(providerName);
try {
const supportName = providerName === "bedrock"
? "bedrock"
: providerName === "azure_openai"
? "azure"
: null;
if (supportName && !(await installCapabilities([supportName]))) return;
const update: ProviderSettingsUpdate = { provider: providerName };
if (!isOauthProvider) {
update.apiKey = apiKey || undefined;
update.apiBase = providerForm.apiBase.trim();
if (provider.is_custom) update.displayName = providerForm.displayName.trim();
}
for (const field of provider.advanced_fields ?? []) {
if (field === "api_type") update.apiType = providerForm.apiType;
if (field === "proxy") update.proxy = providerForm.proxy.trim();
if (field === "extra_headers") {
update.extraHeaders = providerForm.extraHeaders.trim();
}
if (field === "extra_body") update.extraBody = providerForm.extraBody.trim();
if (field === "extra_query") update.extraQuery = providerForm.extraQuery.trim();
if (field === "thinking_style") {
update.thinkingStyle = providerForm.thinkingStyle.trim();
}
if (field === "region") update.region = providerForm.region.trim();
if (field === "profile") update.profile = providerForm.profile.trim();
}
const payload = await updateProviderSettings(client, update);
applyPayload(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, image: true }));
}
await maybeRestartHostEngine(payload);
setProviderForms((prev) => ({
...prev,
[providerName]: {
...providerForm,
displayName: providerForm.displayName.trim(),
apiKey: "",
apiBase: providerForm.apiBase.trim(),
proxy: providerForm.proxy.trim(),
thinkingStyle: providerForm.thinkingStyle.trim(),
region: providerForm.region.trim(),
profile: providerForm.profile.trim(),
},
}));
setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: false }));
setEditingProviderKeys((prev) => ({ ...prev, [providerName]: false }));
if (!isOauthProvider) setExpandedProvider(null);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setProviderSaving(null);
}
};
const createCustomProvider = async (draft: CustomProviderDraft): Promise<boolean> => {
if (providerSaving) return false;
setProviderSaving(CUSTOM_PROVIDER_CREATION_KEY);
try {
const payload = await createProviderSettings(client, {
name: draft.name.trim(),
apiKey: draft.apiKey.trim() || undefined,
apiBase: draft.apiBase.trim(),
proxy: draft.proxy.trim(),
extraHeaders: draft.extraHeaders.trim(),
extraBody: draft.extraBody.trim(),
extraQuery: draft.extraQuery.trim(),
thinkingStyle: draft.thinkingStyle.trim(),
});
applyPayload(payload);
setExpandedProvider(null);
setError(null);
return true;
} catch (err) {
setError((err as Error).message);
return false;
} finally {
setProviderSaving(null);
}
};
const runProviderOAuth = async (providerName: string, action: "login" | "logout") => {
if (providerSaving) return;
let popup: Window | null = null;
if (
action === "login"
&& providerName === "xai_grok"
&& !remoteBrowserAccess
) {
try {
popup = window.open("about:blank", "_blank");
if (popup) popup.opener = null;
} catch {
popup = null;
}
}
setProviderSaving(providerName);
try {
const payload =
action === "login"
? await loginProviderOAuth(
client,
providerName,
providerName === "openai_codex" && remoteBrowserAccess,
)
: await logoutProviderOAuth(client, providerName);
if (isProviderOAuthAuthorizationRequired(payload)) {
try {
if (popup && !popup.closed) popup.location.href = payload.authorization_url;
} catch {
// The dialog keeps the authorization link available when the popup was closed.
}
providerOAuthFlowRef.current = payload;
setProviderOAuthFlow(payload);
setProviderOAuthResponse("");
setProviderOAuthDialogError(null);
setExpandedProvider(providerName);
setError(null);
return;
}
popup?.close();
closeProviderOAuthFlow();
applyPayload(payload);
setExpandedProvider(providerName);
setError(null);
} catch (err) {
popup?.close();
setError((err as Error).message);
} finally {
setProviderSaving(null);
}
};
const completeProviderOAuthResponse = async () => {
const flow = providerOAuthFlowRef.current;
const authorizationResponse = providerOAuthResponse.trim();
if (!flow || !authorizationResponse || providerOAuthCompleting) return;
setProviderOAuthCompleting(true);
setProviderOAuthDialogError(null);
try {
const payload = await completeProviderOAuth(
client,
flow.provider,
flow.flow_id,
authorizationResponse,
);
if (providerOAuthFlowRef.current?.flow_id !== flow.flow_id) return;
if (isProviderOAuthPending(payload)) return;
applyPayload(payload);
setExpandedProvider(flow.provider);
setError(null);
closeProviderOAuthFlow();
} catch (err) {
if (providerOAuthFlowRef.current?.flow_id === flow.flow_id) {
setProviderOAuthDialogError((err as Error).message);
}
} finally {
setProviderOAuthCompleting(false);
}
};
const resetProviderDraft = useCallback((providerName: string) => {
const provider = settings?.providers.find((item) => item.name === providerName);
if (!provider) return;
setProviderForms((prev) => ({
...prev,
[providerName]: providerFormFromRow(provider),
}));
setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: false }));
setEditingProviderKeys((prev) => ({ ...prev, [providerName]: false }));
}, [settings]);
const handleToggleProvider = useCallback((providerName: string) => {
if (expandedProvider) resetProviderDraft(expandedProvider);
setExpandedProvider(expandedProvider === providerName ? null : providerName);
}, [expandedProvider, resetProviderDraft]);
const toggleProviderKeyVisibility = (providerName: string) => {
const isVisible = visibleProviderKeys[providerName];
setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: !isVisible }));
};
const toggleProviderKeyEditing = (providerName: string) => {
setEditingProviderKeys((prev) => {
const nextEditing = !prev[providerName];
if (!nextEditing) {
setProviderForms((forms) => ({
...forms,
[providerName]: {
...(forms[providerName] ?? providerFormFromRow(
settings?.providers.find((provider) => provider.name === providerName) ?? {
name: providerName,
label: providerName,
configured: false,
},
)),
apiKey: "",
},
}));
setVisibleProviderKeys((visible) => ({ ...visible, [providerName]: false }));
}
return { ...prev, [providerName]: nextEditing };
});
};
return {
beginModelPresetCreation,
cancelModelPresetCreation,
changeModelCallOrder,
completeProviderOAuthResponse,
createCustomProvider,
handleDeleteModelConfiguration,
handleMigrateModelConfigurations,
handleToggleProvider,
resetProviderDraft,
runProviderOAuth,
saveModelSettings,
saveProvider,
toggleProviderKeyEditing,
toggleProviderKeyVisibility,
};
}
@@ -0,0 +1,97 @@
import { useEffect, type Dispatch, type SetStateAction } from "react";
import type { ApplySettingsPayload } from "@/components/settings/contracts";
import { providerFormFromRow } from "@/components/settings/models/ProviderSettings";
import type { ModelSettingsState } from "@/components/settings/models/useModelSettingsState";
import { completeProviderOAuth } from "@/lib/api";
import type { NanobotClient } from "@/lib/nanobot-client";
import type {
ProviderOAuthCompletionResult,
ProviderOAuthPending,
SettingsPayload,
} from "@/lib/types";
function isProviderOAuthPending(
payload: ProviderOAuthCompletionResult,
): payload is ProviderOAuthPending {
return (payload as ProviderOAuthPending).status === "pending";
}
interface ProviderOAuthPollingOptions {
state: ModelSettingsState;
client: NanobotClient;
applyPayload: ApplySettingsPayload;
setError: Dispatch<SetStateAction<string | null>>;
closeProviderOAuthFlow: () => void;
}
export function useProviderOAuthPolling({
state,
client,
applyPayload,
setError,
closeProviderOAuthFlow,
}: ProviderOAuthPollingOptions) {
const {
providerOAuthFlow,
providerOAuthFlowRef,
setExpandedProvider,
} = state;
useEffect(() => {
if (!providerOAuthFlow) return;
let cancelled = false;
let timer: number | null = null;
const poll = async () => {
try {
const payload = await completeProviderOAuth(
client,
providerOAuthFlow.provider,
providerOAuthFlow.flow_id,
);
if (
cancelled
|| providerOAuthFlowRef.current?.flow_id !== providerOAuthFlow.flow_id
) return;
if (isProviderOAuthPending(payload)) {
timer = window.setTimeout(() => void poll(), 1000);
return;
}
applyPayload(payload);
setExpandedProvider(providerOAuthFlow.provider);
setError(null);
closeProviderOAuthFlow();
} catch (err) {
if (
cancelled
|| providerOAuthFlowRef.current?.flow_id !== providerOAuthFlow.flow_id
) return;
setError((err as Error).message);
closeProviderOAuthFlow();
}
};
timer = window.setTimeout(() => void poll(), 1000);
return () => {
cancelled = true;
if (timer !== null) window.clearTimeout(timer);
};
}, [applyPayload, client, closeProviderOAuthFlow, providerOAuthFlow]);
}
export function useProviderFormsSync(
state: ModelSettingsState,
settings: SettingsPayload | null,
) {
const { setProviderForms } = state;
useEffect(() => {
if (!settings) return;
setProviderForms((prev) => {
const next = { ...prev };
for (const provider of settings.providers) {
next[provider.name] = next[provider.name] ?? providerFormFromRow(provider);
}
return next;
});
}, [settings]);
}
@@ -0,0 +1,78 @@
import { useRef, useState } from "react";
import {
DEFAULT_AGENT_SETTINGS_DRAFT,
agentDraftFromPayload,
type AgentSettingsDraft,
} from "@/components/settings/models/ModelsSettings";
import type { ProviderForm } from "@/components/settings/models/ProviderSettings";
import type { ProviderOAuthAuthorizationRequired, SettingsPayload } from "@/lib/types";
export function useModelSettingsState(initialSettings: SettingsPayload | null) {
const [saving, setSaving] = useState(false);
const [modelPresetCreating, setModelPresetCreating] = useState(false);
const [modelConfigurationSaving, setModelConfigurationSaving] = useState(false);
const [modelCallOrderSaving, setModelCallOrderSaving] = useState(false);
const [modelMigrationSaving, setModelMigrationSaving] = useState(false);
const [modelPresetPendingDelete, setModelPresetPendingDelete] =
useState<SettingsPayload["model_presets"][number] | null>(null);
const modelPresetBeforeCreateRef = useRef<string | null>(null);
const [providerSaving, setProviderSaving] = useState<string | null>(null);
const [providerOAuthFlow, setProviderOAuthFlow] =
useState<ProviderOAuthAuthorizationRequired | null>(null);
const providerOAuthFlowRef = useRef<ProviderOAuthAuthorizationRequired | null>(null);
const [providerOAuthResponse, setProviderOAuthResponse] = useState("");
const [providerOAuthCompleting, setProviderOAuthCompleting] = useState(false);
const [providerOAuthDialogError, setProviderOAuthDialogError] = useState<string | null>(null);
const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
const [providerForms, setProviderForms] = useState<Record<string, ProviderForm>>({});
const [visibleProviderKeys, setVisibleProviderKeys] = useState<Record<string, boolean>>({});
const [editingProviderKeys, setEditingProviderKeys] = useState<Record<string, boolean>>({});
const [form, setForm] = useState<AgentSettingsDraft>(() =>
initialSettings ? agentDraftFromPayload(initialSettings) : DEFAULT_AGENT_SETTINGS_DRAFT,
);
const [modelCallOrder, setModelCallOrder] = useState<string[]>(
() => initialSettings?.model_call_order ?? [],
);
return {
editingProviderKeys,
expandedProvider,
form,
modelCallOrder,
modelCallOrderSaving,
modelConfigurationSaving,
modelMigrationSaving,
modelPresetBeforeCreateRef,
modelPresetCreating,
modelPresetPendingDelete,
providerForms,
providerOAuthCompleting,
providerOAuthDialogError,
providerOAuthFlow,
providerOAuthFlowRef,
providerOAuthResponse,
providerSaving,
saving,
setEditingProviderKeys,
setExpandedProvider,
setForm,
setModelCallOrder,
setModelCallOrderSaving,
setModelConfigurationSaving,
setModelMigrationSaving,
setModelPresetCreating,
setModelPresetPendingDelete,
setProviderForms,
setProviderOAuthCompleting,
setProviderOAuthDialogError,
setProviderOAuthFlow,
setProviderOAuthResponse,
setProviderSaving,
setSaving,
setVisibleProviderKeys,
visibleProviderKeys,
};
}
export type ModelSettingsState = ReturnType<typeof useModelSettingsState>;
@@ -0,0 +1,526 @@
import { useState, type Dispatch, type SetStateAction } from "react";
import {
ArrowUpCircle,
Bot,
Check,
ChevronRight,
ExternalLink,
Globe2,
HardDrive,
ImageIcon,
Loader2,
Mic,
Server,
type LucideIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { DEFAULT_TRANSCRIPTION_SETTINGS } from "@/components/settings/capabilities/TranscriptionSettings";
import type { SettingsSectionKey } from "@/components/settings/contracts";
import { settingsProviderConfigured } from "@/components/settings/shared/ModelControls";
import {
SettingsGroup,
SettingsRow,
SettingsSectionTitle,
} from "@/components/settings/shared/SettingsControls";
import { TokenUsageHeatmap } from "@/components/settings/TokenUsageHeatmap";
import { ToggleButton } from "@/components/settings/ToggleButton";
import { Button } from "@/components/ui/button";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { useLogoFallback } from "@/hooks/useLogoFallback";
import { checkVersion } from "@/lib/api";
import type {
FileEditDisplayMode,
LocalActivityMode,
LocalDensity,
LocalPreferences,
} from "@/lib/local-preferences";
import { providerBrand, providerDisplayLabel } from "@/lib/provider-brand";
import type { SettingsPayload } from "@/lib/types";
import { cn } from "@/lib/utils";
import { shortWorkspacePath } from "@/lib/workspace";
import { useClient } from "@/providers/ClientProvider";
export function OverviewSettings({
settings,
requiresRestart,
onSelectSection,
showBrandLogos,
}: {
settings: SettingsPayload;
requiresRestart: boolean;
onSelectSection: (section: SettingsSectionKey) => void;
showBrandLogos: boolean;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const activePresetName = settings.agent.model_preset;
const activePreset =
activePresetName && activePresetName !== "default"
? settings.model_presets.find((preset) => preset.name === activePresetName)?.label ??
activePresetName
: null;
const activeProvider = settings.agent.resolved_provider ?? settings.agent.provider;
const activeProviderConfigured = settingsProviderConfigured(settings, activeProvider);
const activeProviderLabel = providerDisplayLabel(settings.providers, activeProvider);
const activeModelValue = activeProviderConfigured
? settings.agent.model
: tx("settings.values.notConfigured", "Not configured");
const activeModelCaption = activeProviderConfigured
? [activeProvider, activePreset].filter(Boolean).join(" · ")
: activeProviderLabel || settings.agent.model
? [activeProviderLabel, settings.agent.model].filter(Boolean).join(" · ")
: tx("settings.byok.noConfiguredProviders", "No configured providers");
const webStatus = settings.web.enable
? tx("settings.values.enabled", "Enabled")
: tx("settings.values.disabled", "Disabled");
const webSearchProvider =
settings.web_search.providers.find((provider) => provider.name === settings.web_search.provider) ??
settings.web_search.providers[0];
const webSearchProviderLabel = providerDisplayLabel(
settings.web_search.providers,
settings.web_search.provider,
);
const webSearchCredentialStatus =
webSearchProvider?.credential === "none"
? tx("settings.byok.webSearch.noCredentialRequired", "No key required")
: webSearchProvider?.credential === "optional_api_key"
? settings.web_search.api_key_hint
? tx("settings.values.configured", "Configured")
: tx("settings.byok.webSearch.noCredentialRequired", "No key required")
: webSearchProvider?.credential === "base_url"
? settings.web_search.base_url
? tx("settings.values.configured", "Configured")
: tx("settings.values.notConfigured", "Not configured")
: settings.web_search.api_key_hint
? tx("settings.values.configured", "Configured")
: tx("settings.values.notConfigured", "Not configured");
const webCaption = `${webSearchProviderLabel} · ${webSearchCredentialStatus}`;
const imageStatus = settings.image_generation.enabled
? tx("settings.values.enabled", "Enabled")
: tx("settings.values.disabled", "Disabled");
const imageCaption = `${providerDisplayLabel(settings.image_generation.providers, settings.image_generation.provider)} · ${
settings.image_generation.provider_configured
? tx("settings.values.configured", "Configured")
: tx("settings.values.notConfigured", "Not configured")
}`;
const transcription = settings.transcription ?? DEFAULT_TRANSCRIPTION_SETTINGS;
const voiceStatus = transcription.enabled
? tx("settings.values.enabled", "Enabled")
: tx("settings.values.disabled", "Disabled");
const voiceCaption = `${providerDisplayLabel(transcription.providers, transcription.provider)} · ${
transcription.provider_configured
? tx("settings.values.configured", "Configured")
: tx("settings.values.notConfigured", "Not configured")
}`;
const isNativeHost = (settings.surface ?? settings.runtime_surface) === "native";
const workspaceCaption = shortWorkspacePath(settings.runtime.workspace_path);
const runtimeTitle = isNativeHost
? tx("settings.rows.engine", "Engine")
: tx("settings.rows.gateway", "Gateway");
const runtimeValue = isNativeHost
? tx("settings.values.privateEngine", "Private engine")
: `${settings.runtime.gateway_host}:${settings.runtime.gateway_port}`;
const runtimeCaption = isNativeHost
? tx("settings.values.unixSocket", "Unix socket")
: requiresRestart
? tx("settings.values.restartPending", "Restart pending")
: tx("settings.values.ready", "Ready");
return (
<div className="space-y-7">
<section className="rounded-[22px] bg-settings-surface px-4 py-4 sm:px-5">
<TokenUsageHeatmap usage={settings.usage} timeZone={settings.agent.timezone} />
</section>
<section>
<SettingsSectionTitle>{tx("settings.sections.ai", "AI")}</SettingsSectionTitle>
<SettingsGroup>
<OverviewListRow
icon={Bot}
valueLogoProvider={activeProvider}
title={tx("settings.overview.model", "Current model")}
value={activeModelValue}
caption={activeModelCaption}
showBrandLogos={showBrandLogos}
onClick={() => onSelectSection("models")}
/>
</SettingsGroup>
</section>
<section>
<SettingsSectionTitle>{tx("settings.sections.capabilities", "Capabilities")}</SettingsSectionTitle>
<SettingsGroup>
<OverviewListRow
icon={Globe2}
valueLogoProvider={settings.web_search.provider}
title={tx("settings.overview.webSearch", "Web search")}
value={webStatus}
caption={webCaption}
showBrandLogos={showBrandLogos}
onClick={() => onSelectSection("browser")}
/>
<OverviewListRow
icon={ImageIcon}
valueLogoProvider={settings.image_generation.provider}
title={tx("settings.overview.imageGeneration", "Image generation")}
value={imageStatus}
caption={imageCaption}
showBrandLogos={showBrandLogos}
onClick={() => onSelectSection("image")}
/>
<OverviewListRow
icon={Mic}
valueLogoProvider={transcription.provider}
title={tx("settings.overview.voiceInput", "Voice input")}
value={voiceStatus}
caption={voiceCaption}
showBrandLogos={showBrandLogos}
onClick={() => onSelectSection("voice")}
/>
</SettingsGroup>
</section>
<section>
<SettingsSectionTitle>{tx("settings.sections.system", "System")}</SettingsSectionTitle>
<SettingsGroup>
<OverviewListRow
icon={Server}
title={runtimeTitle}
value={runtimeValue}
caption={runtimeCaption}
onClick={() => onSelectSection("runtime")}
/>
<OverviewListRow
icon={HardDrive}
title={tx("settings.overview.workspace", "Workspace")}
value={tx("settings.values.defaultWorkspace", "Default workspace")}
caption={workspaceCaption}
onClick={() => onSelectSection("runtime")}
/>
</SettingsGroup>
</section>
<section>
<SettingsSectionTitle>{tx("settings.sections.about", "About")}</SettingsSectionTitle>
<SettingsGroup>
<VersionCheckRow currentVersion={settings.version?.current} />
</SettingsGroup>
</section>
</div>
);
}
function VersionCheckRow({ currentVersion }: { currentVersion?: string }) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const { token } = useClient();
const [checking, setChecking] = useState(false);
const [result, setResult] = useState<
| { type: "up-to-date" }
| { type: "update"; latestVersion: string; pypiUrl?: string }
| { type: "error"; message: string }
| null
>(null);
const handleCheck = async () => {
setChecking(true);
setResult(null);
try {
const res = await checkVersion(token);
if (res.updateAvailable) {
setResult({
type: "update",
latestVersion: res.updateAvailable.latestVersion,
pypiUrl: res.updateAvailable.pypiUrl,
});
} else {
setResult({ type: "up-to-date" });
}
} catch (err) {
setResult({ type: "error", message: (err as Error).message });
} finally {
setChecking(false);
}
};
return (
<div className="flex min-h-[62px] flex-col gap-3 px-4 py-3.5 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<div className="min-w-0">
<div className="text-[14px] font-medium leading-5 text-foreground">
{tx("settings.about.version", "Version")}
</div>
<div className="mt-0.5 text-[12px] leading-5 text-muted-foreground">
{currentVersion ? `v${currentVersion}` : "nanobot"}
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-2">
<Button
size="sm"
variant="outline"
onClick={() => void handleCheck()}
disabled={checking}
className="rounded-full"
>
{checking ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<ArrowUpCircle className="mr-1.5 h-3.5 w-3.5" aria-hidden />
)}
{checking
? tx("settings.about.checking", "Checking...")
: tx("settings.about.checkForUpdates", "Check for updates")}
</Button>
{result?.type === "up-to-date" ? (
<span className="inline-flex items-center gap-1.5 text-[12px] text-emerald-600 dark:text-emerald-300">
<Check className="h-3 w-3" aria-hidden />
{tx("settings.about.upToDate", "You're up to date")}
</span>
) : null}
{result?.type === "update" ? (
<span className="inline-flex items-center gap-1.5 text-[12px] text-blue-600 dark:text-blue-300">
<ArrowUpCircle className="h-3 w-3" aria-hidden />
{t("settings.about.updateAvailable", {
defaultValue: "Update available v{{version}}",
version: result.latestVersion,
})}
{result.pypiUrl ? (
<a
href={result.pypiUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 underline-offset-2 hover:underline"
>
PyPI
<ExternalLink className="h-2.5 w-2.5" aria-hidden />
</a>
) : null}
</span>
) : null}
{result?.type === "error" ? (
<span className="text-[12px] text-destructive">{result.message}</span>
) : null}
</div>
</div>
);
}
export function AppearanceSettings({
theme,
onToggleTheme,
localPrefs,
onChangeLocalPrefs,
}: {
theme: "light" | "dark";
onToggleTheme: () => void;
localPrefs: LocalPreferences;
onChangeLocalPrefs: Dispatch<SetStateAction<LocalPreferences>>;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
return (
<div className="space-y-7">
<section>
<SettingsSectionTitle>{t("settings.sections.interface")}</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow title={t("settings.rows.theme")}>
<button
type="button"
onClick={onToggleTheme}
className="inline-flex h-8 items-center rounded-full bg-muted p-0.5 text-[12px] font-medium text-muted-foreground"
>
<span
className={cn(
"rounded-full px-3 py-1 transition-colors",
theme === "light" &&
"bg-background text-foreground ring-1 ring-inset ring-border/45",
)}
>
{t("settings.values.light")}
</span>
<span
className={cn(
"rounded-full px-3 py-1 transition-colors",
theme === "dark" &&
"bg-background text-foreground ring-1 ring-inset ring-border/45",
)}
>
{t("settings.values.dark")}
</span>
</button>
</SettingsRow>
<SettingsRow title={t("settings.rows.language")}>
<LanguageSwitcher />
</SettingsRow>
</SettingsGroup>
</section>
<section>
<SettingsSectionTitle>{tx("settings.sections.localPreferences", "Local preferences")}</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow title={tx("settings.rows.density", "Density")}>
<SegmentedControl
value={localPrefs.density}
options={[
{ value: "comfortable", label: tx("settings.values.comfortable", "Comfortable") },
{ value: "compact", label: tx("settings.values.compact", "Compact") },
]}
onChange={(density) =>
onChangeLocalPrefs((prev) => ({ ...prev, density: density as LocalDensity }))
}
/>
</SettingsRow>
<SettingsRow title={tx("settings.rows.activityMode", "Activity detail")}>
<SegmentedControl
value={localPrefs.activityMode}
options={[
{ value: "auto", label: tx("settings.values.auto", "Auto") },
{ value: "expanded", label: tx("settings.values.expanded", "Expanded") },
]}
onChange={(activityMode) =>
onChangeLocalPrefs((prev) => ({ ...prev, activityMode: activityMode as LocalActivityMode }))
}
/>
</SettingsRow>
<SettingsRow title={tx("settings.rows.fileEditDisplay", "File edit display")}>
<SegmentedControl
value={localPrefs.fileEditDisplayMode}
options={[
{ value: "summary", label: tx("settings.values.summary", "Summary") },
{ value: "diff", label: tx("settings.values.diff", "Diff") },
{ value: "collapsed_diff", label: tx("settings.values.collapsedDiff", "Collapsed diff") },
]}
onChange={(fileEditDisplayMode) =>
onChangeLocalPrefs((prev) => ({
...prev,
fileEditDisplayMode: fileEditDisplayMode as FileEditDisplayMode,
}))
}
/>
</SettingsRow>
<SettingsRow title={tx("settings.rows.codeWrap", "Code wrapping")}>
<ToggleButton
checked={localPrefs.codeWrap}
onChange={(codeWrap) => onChangeLocalPrefs((prev) => ({ ...prev, codeWrap }))}
ariaLabel={tx("settings.rows.codeWrap", "Code wrapping")}
label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.brandLogos", "Brand logos")}
description={tx(
"settings.legal.thirdPartyBrands",
"Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement.",
)}
>
<ToggleButton
checked={localPrefs.brandLogos}
onChange={(brandLogos) => onChangeLocalPrefs((prev) => ({ ...prev, brandLogos }))}
ariaLabel={tx("settings.rows.brandLogos", "Brand logos")}
label={localPrefs.brandLogos ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</SettingsRow>
</SettingsGroup>
</section>
</div>
);
}
function OverviewRowIcon({
icon: Icon,
}: {
icon: LucideIcon;
}) {
return (
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-[12px] bg-muted text-foreground/82 transition-colors group-hover:bg-muted/80 dark:bg-muted/70">
<Icon className="h-4 w-4" aria-hidden />
</span>
);
}
function OverviewValueLogo({
provider,
showBrandLogos,
}: {
provider: string | null | undefined;
showBrandLogos: boolean;
}) {
const brand = provider ? providerBrand(provider) : null;
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls);
if (!provider || !showBrandLogos || !brand) return null;
if (logoUrl) {
return (
<span
data-testid={`overview-logo-${provider}`}
className="grid h-5 w-5 shrink-0 place-items-center overflow-hidden rounded-md border border-border/35 bg-background"
aria-hidden
>
<img
src={logoUrl}
alt=""
decoding="async"
loading="lazy"
className="h-3.5 w-3.5 object-contain"
onLoad={onLogoLoad}
onError={onLogoError}
/>
</span>
);
}
return (
<span
data-testid={`overview-logo-fallback-${provider}`}
className="grid h-5 w-5 shrink-0 place-items-center rounded-md text-[7.5px] font-semibold text-white"
style={{ backgroundColor: brand.color }}
aria-hidden
>
{brand.initials}
</span>
);
}
function OverviewListRow({
icon: Icon,
valueLogoProvider,
title,
value,
caption,
showBrandLogos = false,
onClick,
}: {
icon: LucideIcon;
valueLogoProvider?: string | null;
title: string;
value: string;
caption: string;
showBrandLogos?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className="group flex min-h-[68px] w-full items-center gap-3 px-4 py-3.5 text-left transition-colors hover:bg-muted/30 sm:px-5"
>
<OverviewRowIcon icon={Icon} />
<span className="min-w-0 flex-1">
<span className="block text-[14px] font-medium leading-5 text-foreground">{title}</span>
<span className="mt-0.5 block truncate text-[12px] leading-5 text-muted-foreground">{caption}</span>
</span>
<span className="ml-auto flex min-w-0 max-w-[48%] items-center gap-2">
<OverviewValueLogo provider={valueLogoProvider} showBrandLogos={showBrandLogos} />
<span className="truncate text-right text-[13px] leading-5 text-muted-foreground">
{value}
</span>
<ChevronRight
className="h-4 w-4 shrink-0 text-muted-foreground/60 transition-transform group-hover:translate-x-0.5"
aria-hidden
/>
</span>
</button>
);
}
@@ -0,0 +1,619 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
Bot,
Brain,
Check,
ChevronDown,
CircleAlert,
Cloud,
Cpu,
Database,
Gem,
Grid3X3,
Hexagon,
Layers,
Loader2,
Moon,
Orbit,
Pencil,
Search,
Sparkles,
Triangle,
Waves,
Zap,
type LucideIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { ComboboxOption, useComboboxNavigation } from "@/components/ui/combobox";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useLogoFallback } from "@/hooks/useLogoFallback";
import { fetchProviderModels } from "@/lib/api";
import { providerBrand } from "@/lib/provider-brand";
import type { ProviderModelsPayload, SettingsPayload } from "@/lib/types";
import { cn } from "@/lib/utils";
const DEFERRED_MODEL_LIST_PROVIDERS = new Set([
"aihubmix",
"atomic_chat",
"byteplus",
"byteplus_coding_plan",
"huggingface",
"lm_studio",
"modelscope",
"novita",
"ollama",
"openrouter",
"ovms",
"siliconflow",
"vllm",
"volcengine",
"volcengine_coding_plan",
]);
const DEFERRED_MODEL_LIST_QUERY_MIN_LENGTH = 2;
export function normalizeContextWindowTokens(value: number | null | undefined): number {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 200_000;
}
function settingsProviderRow(
payload: SettingsPayload,
provider: string | null | undefined,
): SettingsPayload["providers"][number] | null {
if (!provider) return null;
return payload.providers.find((row) => row.name === provider) ?? null;
}
export function settingsProviderConfigured(
payload: SettingsPayload,
provider: string | null | undefined,
resolvedProvider?: string | null,
): boolean {
const row = settingsProviderRow(payload, provider);
if (row) return row.configured;
if (provider === "auto") {
const resolvedRow = settingsProviderRow(
payload,
resolvedProvider ?? payload.agent.resolved_provider ?? payload.agent.provider,
);
if (resolvedRow) return resolvedRow.configured;
}
return payload.agent.has_api_key;
}
export function ProviderPicker({
providers,
value,
emptyLabel,
showProviderLogos = false,
onChange,
}: {
providers: Array<{ name: string; label: string }>;
value: string;
emptyLabel: string;
showProviderLogos?: boolean;
onChange: (provider: string) => void;
}) {
const selectedProvider = providers.find((provider) => provider.name === value) ?? null;
const disabled = providers.length === 0;
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild disabled={disabled}>
<Button
type="button"
variant="outline"
disabled={disabled}
className={cn(
"h-8 w-[210px] justify-between rounded-full border-input bg-background px-3 text-[13px] font-normal shadow-none",
"hover:bg-accent/55 focus-visible:ring-2 focus-visible:ring-ring",
disabled && "text-muted-foreground",
)}
>
<span className="flex min-w-0 items-center gap-2">
{selectedProvider && showProviderLogos ? (
<ProviderPickerIcon
provider={selectedProvider.name}
showBrandLogos={showProviderLogos}
/>
) : null}
<span className="truncate">{selectedProvider?.label ?? emptyLabel}</span>
</span>
<ChevronDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="max-h-[18rem] w-[240px] overflow-y-auto scrollbar-thin scrollbar-track-transparent"
>
{providers.map((provider) => {
const selected = provider.name === value;
return (
<DropdownMenuItem
key={provider.name}
onSelect={() => onChange(provider.name)}
className={cn(
"flex cursor-default items-center justify-between gap-2 text-[13px]",
selected && "bg-muted/80 text-foreground focus:bg-muted",
)}
>
<span className="flex min-w-0 items-center gap-2">
{showProviderLogos ? (
<ProviderPickerIcon
provider={provider.name}
showBrandLogos={showProviderLogos}
/>
) : null}
<span className="truncate">{provider.label}</span>
</span>
{selected ? <Check className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
export function ModelIdPicker({
token,
settings,
provider,
models,
value,
showProviderLogos,
emptyLabel,
searchPlaceholder,
emptyMessage,
onChange,
}: {
token: string;
settings: SettingsPayload;
provider: string;
models?: string[];
value: string;
showProviderLogos: boolean;
emptyLabel?: string;
searchPlaceholder?: string;
emptyMessage?: string;
onChange: (model: string) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const tokenRef = useRef(token);
tokenRef.current = token;
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [payload, setPayload] = useState<ProviderModelsPayload | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const effectiveProvider =
provider === "auto" ? settings.agent.resolved_provider ?? provider : provider;
const hasConcreteProvider = Boolean(effectiveProvider && effectiveProvider !== "auto");
const hasStaticModels = models !== undefined;
const providerRow = settingsProviderRow(settings, effectiveProvider);
const providerConfigured = settingsProviderConfigured(settings, effectiveProvider);
const providerRequiresConfiguration =
!hasStaticModels && hasConcreteProvider && !providerConfigured;
const providerHasBuiltinModels = providerRow?.model_catalog === "builtin";
const providerUsesManualModelIds =
!hasStaticModels &&
hasConcreteProvider &&
providerConfigured &&
providerRow?.auth_type === "oauth" &&
!providerHasBuiltinModels;
const canFetchModels =
!hasStaticModels &&
hasConcreteProvider && providerConfigured && !providerUsesManualModelIds;
const normalizedQuery = query.trim().toLowerCase();
const providerModels: ProviderModelsPayload["models"] = useMemo(
() => hasStaticModels
? (models?.map((id) => ({ id })) ?? [])
: (payload?.models ?? []),
[hasStaticModels, models, payload?.models],
);
const visibleModels = useMemo(
() => providerModels
.filter((model) => {
if (!normalizedQuery) return true;
return [model.id, model.label ?? "", model.description ?? "", model.owned_by ?? ""]
.some((field) => field.toLowerCase().includes(normalizedQuery));
})
.slice(0, 80),
[normalizedQuery, providerModels],
);
const isCatalog = payload?.catalog_kind === "catalog";
const defersModelList = DEFERRED_MODEL_LIST_PROVIDERS.has(effectiveProvider);
const hasDeferredSearchQuery =
normalizedQuery.length >= DEFERRED_MODEL_LIST_QUERY_MIN_LENGTH;
const shouldFetchModels =
canFetchModels && (!defersModelList || hasDeferredSearchQuery);
const waitingForModelSearch =
open && canFetchModels && defersModelList && !hasDeferredSearchQuery;
const hasModelList = hasStaticModels || payload?.status === "available";
const showModels = Boolean(
hasModelList && (hasStaticModels || (payload && (!isCatalog || normalizedQuery))),
);
const customCandidate = query.trim();
const allowCustomModel = !providerRequiresConfiguration;
const exactQueryMatch = providerModels.some((model) => model.id === customCandidate);
const showCustomModel = Boolean(
allowCustomModel && customCandidate && !exactQueryMatch && customCandidate !== value,
);
const providerModelCount = payload?.model_count ?? providerModels.length;
const modelUnconfigured = !value.trim() || !providerConfigured;
useEffect(() => {
if (!open) return;
setQuery(providerUsesManualModelIds || !hasConcreteProvider ? value : "");
}, [open, effectiveProvider, hasConcreteProvider, providerUsesManualModelIds, value]);
useEffect(() => {
if (!open || !shouldFetchModels) {
setPayload(null);
setError(null);
setLoading(false);
return;
}
let cancelled = false;
setPayload(null);
setError(null);
setLoading(true);
fetchProviderModels(tokenRef.current, effectiveProvider)
.then((nextPayload) => {
if (!cancelled) setPayload(nextPayload);
})
.catch((err) => {
if (!cancelled) setError((err as Error).message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [effectiveProvider, open, shouldFetchModels]);
const selectModel = (model: string) => {
onChange(model);
setOpen(false);
};
const navigationValues = useMemo(
() => [
...(showModels ? visibleModels.map((model) => model.id) : []),
...(showCustomModel ? [customCandidate] : []),
],
[customCandidate, showCustomModel, showModels, visibleModels],
);
const navigation = useComboboxNavigation({
open,
values: navigationValues,
selectedValue: value,
onSelect: selectModel,
onClose: () => setOpen(false),
});
const renderModelRow = (
model: ProviderModelsPayload["models"][number],
options: { selected?: boolean } = {},
) => (
<ComboboxOption
key={model.id}
{...navigation.getOptionProps(model.id)}
className={cn(
"flex cursor-default items-center justify-between gap-2 rounded-[12px] px-2 py-1.5 text-[12px]",
options.selected && "text-foreground",
)}
>
<span className="flex min-w-0 items-center gap-2">
<ProviderPickerIcon
provider={effectiveProvider}
showBrandLogos={showProviderLogos}
unconfigured={!providerConfigured}
/>
<span className="min-w-0">
<span className="block truncate font-medium text-foreground">
{model.label ?? model.id}
</span>
{model.description || (model.label && model.label !== model.id) ? (
<span className="mt-0.5 block truncate text-[10.5px] text-muted-foreground">
{[model.label && model.label !== model.id ? model.id : null, model.description]
.filter(Boolean)
.join(" · ")}
</span>
) : null}
</span>
</span>
<span className="ml-2 flex shrink-0 items-center gap-2 text-[11px] text-muted-foreground">
{model.context_window ? <span>{formatContextWindow(model.context_window)}</span> : null}
{options.selected ? <Check className="h-3.5 w-3.5 text-foreground" aria-hidden /> : null}
</span>
</ComboboxOption>
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className={cn(
"h-9 w-[min(360px,70vw)] justify-between rounded-full border-input bg-background px-3 text-[12px] font-normal shadow-none",
"hover:bg-accent/55 focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<span className="flex min-w-0 items-center gap-2">
<ProviderPickerIcon
provider={effectiveProvider}
showBrandLogos={showProviderLogos}
unconfigured={modelUnconfigured}
/>
<span
className={cn(
"min-w-0 truncate font-medium",
value ? "text-foreground" : "text-muted-foreground",
)}
>
{value || emptyLabel || tx("settings.models.selectModel", "Select model")}
</span>
</span>
<ChevronDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
className="w-[360px] max-w-[calc(100vw-2rem)] p-1.5"
>
<div className="p-1 pb-1.5">
<div className="relative">
<Search
className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
aria-hidden
/>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
{...navigation.inputProps}
placeholder={
searchPlaceholder || tx("settings.models.searchModels", "Search or type model ID")
}
aria-label={
searchPlaceholder || tx("settings.models.searchModels", "Search or type model ID")
}
className="h-8 rounded-full pl-8 pr-3 text-[12px]"
/>
</div>
</div>
{providerRequiresConfiguration ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.providerNotConfigured", "Configure this provider before loading models.")}
</div>
) : hasStaticModels && !providerModels.length ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{emptyMessage || tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
</div>
) : providerUsesManualModelIds ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
</div>
) : !canFetchModels ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.autoProviderCustomOnly", "Auto provider mode uses custom model IDs.")}
</div>
) : waitingForModelSearch ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.searchCatalog", "Search provider catalog to choose a model.")}
</div>
) : loading ? (
<div className="flex items-center gap-2 px-2 py-1.5 text-[11px] text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
{tx("settings.models.loadingModels", "Loading models...")}
</div>
) : error || payload?.status === "error" ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{payload?.message || error || tx("settings.models.loadFailed", "Model list unavailable.")}
</div>
) : payload?.status === "not_configured" ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.providerNotConfigured", "Configure this provider before loading models.")}
</div>
) : payload?.status === "unsupported" || payload?.status === "missing_api_base" ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{payload.message || tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
</div>
) : isCatalog && !normalizedQuery ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.searchCatalog", "Search provider catalog to choose a model.")}
{providerModelCount ? ` ${providerModelCount} ${tx("settings.models.modelsAvailable", "available")}.` : ""}
</div>
) : null}
{navigationValues.length ? (
<div
{...navigation.listProps}
aria-label={searchPlaceholder || tx("settings.models.selectModel", "Select model")}
className="max-h-[16rem] overflow-y-auto pr-0.5 scrollbar-thin scrollbar-track-transparent"
>
{showModels
? visibleModels.map((model) =>
renderModelRow(model, { selected: model.id === value }),
)
: null}
{showCustomModel ? (
<>
{showModels && visibleModels.length ? (
<div role="separator" className="-mx-1.5 my-1.5 h-px bg-border/50" />
) : null}
<ComboboxOption
{...navigation.getOptionProps(customCandidate)}
className="flex cursor-default items-center gap-2 rounded-[12px] px-2 py-1.5 text-[12px]"
>
<span className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-muted/80 text-muted-foreground">
<Pencil className="h-3 w-3" aria-hidden />
</span>
<span className="min-w-0 truncate">
{tx("settings.models.useCustomModel", "Use")}{" "}
<span className="font-medium text-foreground">{customCandidate}</span>
</span>
</ComboboxOption>
</>
) : null}
</div>
) : showModels ? (
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">
{tx("settings.models.noModelResults", "No matching models.")}
</div>
) : null}
</PopoverContent>
</Popover>
);
}
export function formatContextWindow(tokens: number): string {
if (tokens >= 1_000_000) {
const value = tokens / 1_000_000;
return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}M`;
}
if (tokens >= 1_000) {
const value = tokens / 1_000;
return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}K`;
}
return String(tokens);
}
export function formatModelContextWindow(tokens: number): string {
if (tokens === 65_536) return "64K";
if (tokens === 262_144) return "256K";
if (tokens === 1_048_576) return "1M";
return formatContextWindow(tokens);
}
export function ProviderPickerIcon({
provider,
showBrandLogos,
unconfigured = false,
}: {
provider: string;
showBrandLogos: boolean;
unconfigured?: boolean;
}) {
const brand = providerBrand(provider);
const Icon = PROVIDER_ICONS[provider] ?? Hexagon;
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls);
if (unconfigured) {
return (
<span
data-testid="provider-picker-unconfigured-icon"
className="grid h-5 w-5 shrink-0 place-items-center text-amber-700 dark:text-amber-200"
aria-hidden
>
<CircleAlert className="h-4 w-4" strokeWidth={1.8} />
</span>
);
}
if (showBrandLogos && logoUrl) {
return (
<span
data-testid={`provider-picker-logo-${provider}`}
className="grid h-5 w-5 shrink-0 place-items-center overflow-hidden rounded-md border border-border/35 bg-background"
aria-hidden
>
<img
src={logoUrl}
alt=""
decoding="async"
loading="lazy"
className="h-3.5 w-3.5 object-contain"
onLoad={onLogoLoad}
onError={onLogoError}
/>
</span>
);
}
if (showBrandLogos && brand) {
return (
<span
data-testid={`provider-picker-logo-fallback-${provider}`}
className="grid h-5 w-5 shrink-0 place-items-center rounded-md text-[7.5px] font-semibold text-white"
style={{ backgroundColor: brand.color }}
aria-hidden
>
{brand.initials}
</span>
);
}
return (
<span
className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground"
aria-hidden
>
<Icon className="h-3 w-3" strokeWidth={2} />
</span>
);
}
export function optionRowsWithCurrent(
options: Array<{ name: string; label: string }>,
value: string,
): Array<{ name: string; label: string }> {
if (!value || options.some((option) => option.name === value)) return options;
return [{ name: value, label: value }, ...options];
}
export const PROVIDER_ICONS: Record<string, LucideIcon> = {
custom: Hexagon,
openrouter: Sparkles,
skywork: Sparkles,
aihubmix: Triangle,
anthropic: Brain,
openai: Bot,
deepseek: Waves,
zhipu: Grid3X3,
dashscope: Cloud,
modelscope: Layers,
moonshot: Moon,
minimax: Zap,
minimax_anthropic: Brain,
groq: Cpu,
huggingface: Layers,
gemini: Gem,
mistral: Orbit,
siliconflow: Layers,
volcengine: Cloud,
volcengine_coding_plan: Cloud,
byteplus: Cloud,
byteplus_coding_plan: Cloud,
qianfan: Database,
ant_ling: Sparkles,
azure_openai: Cloud,
bedrock: Database,
bocha: Search,
brave: Search,
duckduckgo: Search,
exa: Search,
jina: Search,
kagi: Search,
olostep: Search,
searxng: Search,
tavily: Search,
vllm: Cpu,
ollama: Cpu,
lm_studio: Cpu,
atomic_chat: Cpu,
ovms: Cpu,
nvidia: Zap,
};
@@ -0,0 +1,410 @@
import type { ReactNode } from "react";
import { CircleAlert, Loader2, RotateCcw, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { isNativeRuntime } from "@/lib/runtime";
import type { NanobotFeatureInfo } from "@/lib/types";
import { cn } from "@/lib/utils";
export const SETTINGS_SEARCH_INPUT_CLASS = cn(
"border-border/45 bg-settings-surface transition-colors hover:border-border/70",
"focus-visible:border-border/70 focus-visible:bg-background",
"focus-visible:ring-0 focus-visible:ring-offset-0",
);
export function CapabilityInstallNotice({
title,
description,
installing = false,
}: {
title: string;
description: string;
installing?: boolean;
}) {
return (
<div className="flex items-start gap-3 rounded-[14px] border border-border/55 bg-muted/22 px-3.5 py-3">
{installing ? (
<Loader2 className="mt-0.5 h-4 w-4 shrink-0 animate-spin text-muted-foreground" aria-hidden />
) : (
<CircleAlert className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
)}
<div className="min-w-0">
<p className="text-[12.5px] font-medium text-foreground">{title}</p>
<p className="mt-0.5 text-[12px] leading-5 text-muted-foreground">{description}</p>
</div>
</div>
);
}
export function NanobotFeatureInstallDialog({
feature,
installing,
onOpenChange,
onConfirm,
}: {
feature: NanobotFeatureInfo | null;
installing: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (feature: NanobotFeatureInfo) => void | Promise<void>;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
t(key, { defaultValue: fallback, ...(values ?? {}) });
const name = feature?.display_name || feature?.name || "";
return (
<Dialog open={Boolean(feature)} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className="w-[min(calc(100vw-2rem),24rem)] gap-0 p-5 text-center"
>
<DialogHeader className="items-center space-y-0 text-center">
<DialogTitle className="text-center text-[20px] font-semibold leading-tight tracking-[-0.02em] text-foreground">
{tx("settings.nanobotFeatures.installConfirmTitle", "Install support for {{name}}?", { name })}
</DialogTitle>
<DialogDescription className="mt-3 max-w-[20rem] text-center text-[14px] leading-6 text-muted-foreground">
{tx(
"settings.nanobotFeatures.installConfirmDescription",
"nanobot will add what {{name}} needs, then turn it on. Continue?",
{ name },
)}
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-7 !grid grid-cols-1 gap-3 space-x-0 sm:grid-cols-2 sm:space-x-0">
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={installing}
className="h-11 w-full min-w-0 rounded-full bg-muted/70 px-5 text-[15px] font-semibold text-foreground shadow-none hover:bg-muted"
>
{tx("settings.automations.cancel", "Cancel")}
</Button>
<Button
type="button"
onClick={() => feature && void onConfirm(feature)}
disabled={!feature || installing}
className="h-11 w-full min-w-0 !whitespace-normal rounded-full px-5 text-center text-[15px] font-semibold"
>
{installing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden /> : null}
{tx("settings.nanobotFeatures.installConfirmAction", "Install and enable")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function DismissibleStatusMessage({
message,
isError,
onDismiss,
}: {
message: string;
isError: boolean;
onDismiss: () => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
return (
<div
className={cn(
"flex items-center justify-between gap-3 rounded-[12px] border py-2.5 pl-4 pr-2 text-[13px]",
isError
? "border-destructive/20 bg-destructive/5 text-destructive"
: "border-border/55 bg-muted/35 text-muted-foreground",
)}
>
<span className="min-w-0">{message}</span>
<button
type="button"
aria-label={tx("settings.actions.dismiss", "Dismiss")}
title={tx("settings.actions.dismiss", "Dismiss")}
onClick={onDismiss}
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors",
isError
? "text-destructive/70 hover:bg-destructive/10 hover:text-destructive"
: "text-muted-foreground/70 hover:bg-muted hover:text-foreground",
)}
>
<X className="h-3.5 w-3.5" aria-hidden />
</button>
</div>
);
}
export function RestartRequiredNotice({
message,
onRestart,
isRestarting,
}: {
message: string;
onRestart?: () => void;
isRestarting?: boolean;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-3 rounded-[12px] border border-amber-500/20 bg-amber-500/8 px-4 py-3 text-[12.5px] text-amber-800 dark:text-amber-200 sm:flex-row sm:items-center sm:justify-between">
<span>{message}</span>
{onRestart ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={onRestart}
disabled={isRestarting}
className="h-8 rounded-full bg-background/80 px-3 text-[12px] font-semibold"
>
{isRestarting ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
)}
{isRestarting ? t("app.system.restarting") : t("app.system.restart")}
</Button>
) : null}
</div>
);
}
export function SettingsSectionTitle({ children }: { children: ReactNode }) {
return (
<h2 className="mb-2 px-1 text-[13px] font-semibold tracking-[-0.01em] text-foreground/85">
{children}
</h2>
);
}
export function SettingsGroup({ children }: { children: ReactNode }) {
return (
<div className="overflow-hidden rounded-[22px] bg-settings-surface">
<div className="divide-y divide-border/45">{children}</div>
</div>
);
}
export function SettingsRow({
title,
description,
children,
}: {
title: string;
description?: string;
children?: ReactNode;
}) {
return (
<div className="flex min-h-[62px] flex-col gap-3 px-4 py-3.5 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<div className="min-w-0">
<div className="text-[14px] font-medium leading-5 text-foreground">{title}</div>
{description ? (
<div className="mt-0.5 max-w-[28rem] text-[12px] leading-5 text-muted-foreground">
{description}
</div>
) : null}
</div>
{children ? <div className="min-w-0 sm:ml-6 sm:shrink-0">{children}</div> : null}
</div>
);
}
export function ReadOnlyRow({
title,
value,
description,
}: {
title: string;
value: string;
description?: string;
}) {
return (
<SettingsRow title={title} description={description}>
<span className="block max-w-full truncate text-left text-[13px] text-muted-foreground sm:max-w-[320px] sm:text-right">
{value}
</span>
</SettingsRow>
);
}
export function RestartSettingsFooter({
dirty,
saving,
pendingRestart,
disabled = false,
message,
dirtyMessage,
pendingMessage,
onSave,
onRestart,
onReset,
isRestarting,
}: {
dirty: boolean;
saving: boolean;
pendingRestart: boolean;
disabled?: boolean;
message?: string;
dirtyMessage?: string;
pendingMessage?: string;
onSave: () => void;
onRestart?: () => void;
onReset?: () => void;
isRestarting?: boolean;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const isNativeHost = isNativeRuntime();
const restartLabel = isNativeHost
? tx("app.system.restartEngine", "Restart engine")
: t("app.system.restart");
const restartingLabel = isNativeHost
? tx("app.system.restartingEngine", "Restarting engine...")
: t("app.system.restarting");
const statusMessage =
message ??
(pendingRestart && !dirty
? pendingMessage ?? tx("settings.status.savedRestartApply", "Saved. Restart when ready.")
: dirty
? dirtyMessage ?? t("settings.status.unsaved")
: undefined);
const statusTone = disabled ? "danger" : dirty || pendingRestart ? "accent" : undefined;
return (
<div className="flex min-h-[58px] flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<div className="min-w-0 text-[13px] leading-5 text-muted-foreground">
<SettingsStatusMessage tone={statusTone}>{statusMessage}</SettingsStatusMessage>
</div>
<div className="flex w-full shrink-0 flex-wrap justify-end gap-2 sm:w-auto">
{pendingRestart && !dirty && onRestart ? (
<Button
size="sm"
variant="ghost"
onClick={onRestart}
disabled={isRestarting}
className="rounded-full"
>
{isRestarting ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
)}
{isRestarting ? restartingLabel : restartLabel}
</Button>
) : null}
{onReset ? (
<Button
size="sm"
variant="ghost"
onClick={onReset}
disabled={!dirty || saving}
className="rounded-full"
>
{t("settings.actions.cancel")}
</Button>
) : null}
<Button
size="sm"
variant="outline"
onClick={onSave}
disabled={!dirty || disabled || saving}
className="rounded-full"
>
{saving ? t("settings.actions.saving") : t("settings.actions.save")}
</Button>
</div>
</div>
);
}
export function SettingsStatusMessage({
children,
tone,
}: {
children?: ReactNode;
tone?: "accent" | "danger";
}) {
if (!children) return null;
return (
<span
className={cn(
"inline-flex items-center gap-2",
tone === "accent" && "font-medium text-blue-600 dark:text-blue-300",
tone === "danger" && "font-medium text-destructive",
)}
>
{tone ? (
<span
className={cn(
"h-1.5 w-1.5 shrink-0 rounded-full",
tone === "accent" &&
"bg-blue-500 dark:bg-blue-400",
tone === "danger" && "bg-destructive/70",
)}
aria-hidden
/>
) : null}
<span>{children}</span>
</span>
);
}
export function StatusPill({
children,
tone = "neutral",
}: {
children: ReactNode;
tone?: "neutral" | "success" | "warning";
}) {
return (
<span
className={cn(
"inline-flex max-w-[260px] items-center rounded-full px-2.5 py-1 text-[12px] font-medium",
tone === "success" && "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
tone === "warning" && "bg-amber-500/10 text-amber-700 dark:text-amber-300",
tone === "neutral" && "bg-muted text-muted-foreground",
)}
>
<span className="truncate">{children}</span>
</span>
);
}
export function NumberInput({
value,
min,
max,
onChange,
suffix,
}: {
value: number;
min: number;
max: number;
onChange: (value: number) => void;
suffix?: string;
}) {
return (
<div className="flex items-center gap-2">
<Input
type="number"
min={min}
max={max}
value={value}
onChange={(event) => {
const parsed = Number(event.target.value);
if (Number.isFinite(parsed)) onChange(parsed);
}}
className="h-8 w-24 max-w-full rounded-full text-[13px]"
/>
{suffix ? <span className="text-[12px] text-muted-foreground">{suffix}</span> : null}
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,257 @@
import { useEffect, useRef, useState } from "react";
import { ChevronLeft, Loader2, Search } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
channelIsRunning,
channelMatchesFilter,
channelSearchText,
localizedChannelDisplayName,
type ChannelFilter,
} from "@/components/settings/channels/ChannelIdentity";
import { ChannelCatalogRow, ChannelSetupPanel } from "@/components/settings/channels/ChannelSetupPanel";
import {
DismissibleStatusMessage,
RestartRequiredNotice,
SETTINGS_SEARCH_INPUT_CLASS,
} from "@/components/settings/shared/SettingsControls";
import { Input } from "@/components/ui/input";
import { useMediaQuery } from "@/hooks/useMediaQuery";
import type { NanobotFeaturesPayload } from "@/lib/types";
import { cn } from "@/lib/utils";
export function ChannelsSettings({
token,
nanobotFeatures,
loading,
query,
actionKey,
chatAppsDocsUrl,
showBrandLogos,
error,
requiresRestartPending,
onQueryChange,
onAction,
onFeaturesUpdate,
onDismissStatus,
onRestart,
isRestarting,
}: {
token: string;
nanobotFeatures: NanobotFeaturesPayload | null;
loading: boolean;
query: string;
actionKey: string | null;
chatAppsDocsUrl?: string;
showBrandLogos: boolean;
error: string | null;
requiresRestartPending: boolean;
onQueryChange: (value: string) => void;
onAction: (action: "enable" | "disable", name: string) => void;
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
onDismissStatus: () => void;
onRestart?: () => void;
isRestarting?: boolean;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const normalizedQuery = query.trim().toLowerCase();
const [filter, setFilter] = useState<ChannelFilter>("all");
const splitLayout = useMediaQuery("(min-width: 1280px)");
const containerRef = useRef<HTMLDivElement>(null);
const compactDetailTopRef = useRef<HTMLButtonElement>(null);
const [compactDetailOpen, setCompactDetailOpen] = useState(false);
const allChannels = (nanobotFeatures?.features ?? [])
.filter((feature) => feature.type === "channel")
.filter((feature) => feature.settings_visible !== false)
.filter((feature) => !normalizedQuery || channelSearchText(feature, t).includes(normalizedQuery))
.sort((left, right) => {
const rank = Number(!left.ready) - Number(!right.ready);
return rank || localizedChannelDisplayName(left, t).localeCompare(
localizedChannelDisplayName(right, t),
);
});
const channels = allChannels.filter((feature) => channelMatchesFilter(feature, filter));
const [selectedChannelName, setSelectedChannelName] = useState<string | null>(null);
const selectedChannel =
channels.find((feature) => feature.name === selectedChannelName) ?? channels[0] ?? null;
const enabledCount = allChannels.filter(channelIsRunning).length;
const offCount = Math.max(0, allChannels.length - enabledCount);
const filterOptions: Array<{ value: ChannelFilter; label: string; count: number }> = [
{ value: "all", label: tx("settings.channels.filterAll", "All"), count: allChannels.length },
{ value: "on", label: tx("settings.channels.filterOn", "On"), count: enabledCount },
{ value: "off", label: tx("settings.channels.filterOff", "Off"), count: offCount },
];
const statusMessage = error;
const statusIsError = true;
useEffect(() => {
if (!channels.length) {
if (selectedChannelName !== null) setSelectedChannelName(null);
setCompactDetailOpen(false);
return;
}
if (!selectedChannelName || !channels.some((feature) => feature.name === selectedChannelName)) {
setSelectedChannelName(channels[0].name);
setCompactDetailOpen(false);
}
}, [channels, selectedChannelName]);
useEffect(() => {
if (splitLayout) return;
const resetScroll = () => {
let node = containerRef.current?.parentElement ?? null;
while (node) {
node.scrollTop = 0;
node = node.parentElement;
}
if (compactDetailOpen) {
compactDetailTopRef.current?.scrollIntoView?.({ block: "start" });
}
};
resetScroll();
const frame = window.requestAnimationFrame(resetScroll);
return () => window.cancelAnimationFrame(frame);
}, [compactDetailOpen, selectedChannelName, splitLayout]);
const openChannel = (name: string) => {
setSelectedChannelName(name);
if (!splitLayout) setCompactDetailOpen(true);
};
const setupPanel = selectedChannel ? (
<ChannelSetupPanel
token={token}
feature={selectedChannel}
actionKey={actionKey}
chatAppsDocsUrl={chatAppsDocsUrl}
showBrandLogos={showBrandLogos}
onAction={onAction}
onFeaturesUpdate={onFeaturesUpdate}
/>
) : null;
const showingCompactDetail = !splitLayout && compactDetailOpen && selectedChannel !== null;
return (
<div
ref={containerRef}
className="flex min-h-full flex-1 flex-col xl:min-h-0 xl:overflow-hidden"
>
{!showingCompactDetail ? (
<section className="shrink-0 space-y-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
<Input
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder={tx("settings.channels.searchPlaceholder", "Search channels")}
className={cn(
"h-12 rounded-[14px] pl-11 text-[15px]",
SETTINGS_SEARCH_INPUT_CLASS,
)}
/>
</div>
<div className="flex shrink-0 flex-wrap gap-1.5 rounded-[14px] bg-muted/55 p-1">
{filterOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setFilter(option.value)}
className={cn(
"rounded-[11px] px-3 py-1.5 text-[12px] font-medium transition-colors",
filter === option.value
? "bg-background text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{option.label}
<span className="ml-1 text-[11px] text-muted-foreground">{option.count}</span>
</button>
))}
</div>
</div>
</section>
) : null}
{statusMessage ? (
<div className="mt-3 shrink-0">
<DismissibleStatusMessage
message={statusMessage}
isError={statusIsError}
onDismiss={onDismissStatus}
/>
</div>
) : null}
{requiresRestartPending ? (
<div className="mt-3 shrink-0">
<RestartRequiredNotice
message={tx("settings.channels.restartRequired", "Restart nanobot to apply updated channel support.")}
onRestart={onRestart}
isRestarting={isRestarting}
/>
</div>
) : null}
<section
className={cn(
"flex flex-1 flex-col",
showingCompactDetail ? "mt-1" : "mt-5",
splitLayout && "min-h-0 overflow-hidden",
)}
>
{loading && !nanobotFeatures ? (
<div className="flex h-36 items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
{tx("settings.channels.loading", "Loading Channels...")}
</div>
) : channels.length ? splitLayout ? (
<div className="grid min-h-0 flex-1 grid-cols-[minmax(0,1fr)_minmax(400px,460px)] gap-6 overflow-hidden">
<div className="min-h-0 space-y-1 overflow-y-auto overscroll-contain pr-1">
{channels.map((feature) => (
<ChannelCatalogRow
key={feature.name}
feature={feature}
selected={selectedChannel?.name === feature.name}
showBrandLogos={showBrandLogos}
onSelect={() => openChannel(feature.name)}
/>
))}
</div>
<div className="min-h-0 overflow-y-auto overscroll-contain pr-1">{setupPanel}</div>
</div>
) : showingCompactDetail ? (
<div className="pb-6">
<button
ref={compactDetailTopRef}
type="button"
onClick={() => setCompactDetailOpen(false)}
className="mb-4 inline-flex h-9 items-center gap-1.5 rounded-full px-2.5 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground"
>
<ChevronLeft className="h-4 w-4" aria-hidden />
{tx("settings.channels.backToChannels", "All channels")}
</button>
{setupPanel}
</div>
) : (
<div className="space-y-1 pb-6">
{channels.map((feature) => (
<ChannelCatalogRow
key={feature.name}
feature={feature}
selected={false}
showBrandLogos={showBrandLogos}
onSelect={() => openChannel(feature.name)}
/>
))}
</div>
) : (
<div className="min-h-0 flex-1 px-3 py-12 text-center text-sm text-muted-foreground">
{tx("settings.channels.empty", "No channels match this filter.")}
</div>
)}
</section>
</div>
);
}
@@ -0,0 +1,406 @@
import { useEffect, useState } from "react";
import { Eye, EyeOff, Loader2, PauseCircle, PlayCircle, RotateCcw } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { AgentSettingsDraft } from "@/components/settings/models/ModelsSettings";
import {
NumberInput,
ReadOnlyRow,
SettingsGroup,
SettingsRow,
SettingsSectionTitle,
StatusPill,
} from "@/components/settings/shared/SettingsControls";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { isLoopbackHost } from "@/lib/network";
import { getRuntimeHost, isNativeRuntime } from "@/lib/runtime";
import type { ApiServicePayload, NanobotFeatureInfo, SettingsPayload } from "@/lib/types";
export function RuntimeSettings({
form,
settings,
onRestart,
isRestarting,
requiresRestartPending,
apiService,
apiServiceLoading,
apiServiceAction,
apiServiceError,
langfuseFeature,
capabilitiesLoading,
capabilityAction,
capabilityError,
onApiServiceAction,
onInstallCapability,
}: {
form: AgentSettingsDraft;
settings: SettingsPayload;
onRestart?: () => void;
isRestarting?: boolean;
requiresRestartPending: boolean;
apiService: ApiServicePayload | null;
apiServiceLoading: boolean;
apiServiceAction: "start" | "stop" | null;
apiServiceError: string | null;
langfuseFeature?: NanobotFeatureInfo;
capabilitiesLoading: boolean;
capabilityAction: string | null;
capabilityError: string | null;
onApiServiceAction: (
action: "start" | "stop",
values?: { host: string; port: number; timeout: number; apiKey?: string },
) => void;
onInstallCapability: (name: string) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const runtimeSurface = settings.surface ?? settings.runtime_surface;
const runtimeHost = getRuntimeHost(runtimeSurface, settings.runtime_capabilities);
const openLogs = runtimeHost.openLogs;
const exportDiagnostics = runtimeHost.exportDiagnostics;
const isNativeHost = isNativeRuntime(runtimeSurface);
const restartActionLabel = isNativeHost
? tx("app.system.restartEngine", "Restart engine")
: t("app.system.restart");
const restartingActionLabel = isNativeHost
? tx("app.system.restartingEngine", "Restarting engine...")
: t("app.system.restarting");
const [diagnosticsPath, setDiagnosticsPath] = useState<string | null>(null);
const [hostActionMessage, setHostActionMessage] = useState<{
target: "logs" | "diagnostics";
message: string;
} | null>(null);
const [hostActionBusy, setHostActionBusy] =
useState<"logs" | "diagnostics" | null>(null);
const apiDefaults = apiService ?? {
installed: false,
running: false,
managed: false,
host: settings.api?.host ?? "127.0.0.1",
port: settings.api?.port ?? 8900,
timeout: settings.api?.timeout ?? 120,
api_key_hint: settings.api?.api_key_hint,
endpoint: `http://127.0.0.1:${settings.api?.port ?? 8900}/v1`,
command: "nanobot serve",
};
const [apiHost, setApiHost] = useState(apiDefaults.host);
const [apiPort, setApiPort] = useState(apiDefaults.port);
const [apiKey, setApiKey] = useState("");
const [apiKeyVisible, setApiKeyVisible] = useState(false);
useEffect(() => {
if (!apiService) return;
setApiHost(apiService.host);
setApiPort(apiService.port);
setApiKey("");
setApiKeyVisible(false);
}, [apiService]);
const apiNetworkAccess = !isLoopbackHost(apiHost);
const apiMissingNetworkKey = apiNetworkAccess && !apiKey.trim() && !apiDefaults.api_key_hint;
const engineState = isRestarting
? tx("settings.values.restartingEngine", "Restarting")
: settings.apply_state?.status === "pending"
? tx("settings.values.pending", "Pending")
: tx("settings.values.ready", "Ready");
const runHostAction = async (
target: "logs" | "diagnostics",
action: (() => Promise<string | void>) | undefined,
successMessage: (result: string | void) => string,
failureMessage: string,
) => {
if (!action) {
setHostActionMessage({
target,
message: tx(
"settings.status.hostApiUnavailable",
"Host actions are only available inside the native app.",
),
});
return;
}
setHostActionBusy(target);
setHostActionMessage(null);
try {
const result = await action();
setHostActionMessage({ target, message: successMessage(result) });
} catch {
setHostActionMessage({ target, message: failureMessage });
} finally {
setHostActionBusy(null);
}
};
return (
<div className="space-y-7">
{isNativeHost ? (
<section>
<SettingsSectionTitle>{tx("settings.sections.nativeHost", "Native host")}</SettingsSectionTitle>
<SettingsGroup>
<ReadOnlyRow title={tx("settings.rows.engine", "Engine")} value={engineState} />
{settings.runtime_capabilities?.can_open_logs ? (
<SettingsRow
title={tx("settings.rows.logs", "Logs")}
description={
hostActionMessage?.target === "logs" ? hostActionMessage.message : undefined
}
>
<Button
size="sm"
variant="outline"
onClick={() =>
void runHostAction(
"logs",
openLogs,
() => tx("settings.status.logsOpened", "Opened logs folder."),
tx("settings.status.logsOpenFailed", "Could not open logs folder."),
)
}
disabled={hostActionBusy !== null}
className="rounded-full"
>
{hostActionBusy === "logs"
? tx("settings.actions.opening", "Opening...")
: tx("settings.actions.open", "Open")}
</Button>
</SettingsRow>
) : null}
{settings.runtime_capabilities?.can_export_diagnostics ? (
<SettingsRow
title={tx("settings.rows.diagnostics", "Diagnostics")}
description={
hostActionMessage?.target === "diagnostics"
? hostActionMessage.message
: diagnosticsPath || undefined
}
>
<Button
size="sm"
variant="outline"
onClick={() =>
void runHostAction(
"diagnostics",
exportDiagnostics ? async () => {
const path = await exportDiagnostics();
setDiagnosticsPath(path);
return path;
} : undefined,
(path) =>
t("settings.status.diagnosticsExported", {
path: String(path ?? ""),
defaultValue: "Diagnostics exported to {{path}}.",
}),
tx("settings.status.diagnosticsExportFailed", "Could not export diagnostics."),
)
}
disabled={hostActionBusy !== null}
className="rounded-full"
>
{hostActionBusy === "diagnostics"
? tx("settings.actions.exporting", "Exporting...")
: tx("settings.actions.export", "Export")}
</Button>
</SettingsRow>
) : null}
</SettingsGroup>
</section>
) : null}
<section>
<SettingsSectionTitle>{tx("settings.api.title", "API server")}</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow
title={tx("settings.api.openaiCompatible", "OpenAI-compatible API")}
description={
apiServiceError
? apiServiceError
: apiDefaults.running
? apiDefaults.endpoint
: undefined
}
>
<div className="flex items-center justify-end gap-2">
<StatusPill tone={apiDefaults.running ? "success" : "neutral"}>
{apiServiceLoading
? tx("settings.values.checking", "Checking")
: apiDefaults.running
? tx("settings.values.running", "Running")
: tx("settings.values.off", "Off")}
</StatusPill>
<Button
size="sm"
variant="outline"
disabled={apiServiceLoading || apiServiceAction !== null || apiMissingNetworkKey}
onClick={() =>
onApiServiceAction(
apiDefaults.running ? "stop" : "start",
apiDefaults.running
? undefined
: {
host: apiHost,
port: apiPort,
timeout: apiDefaults.timeout,
apiKey: apiKey.trim() || undefined,
},
)
}
className="rounded-full"
>
{apiServiceAction ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : apiDefaults.running ? (
<PauseCircle className="mr-1.5 h-3.5 w-3.5" aria-hidden />
) : (
<PlayCircle className="mr-1.5 h-3.5 w-3.5" aria-hidden />
)}
{apiServiceAction === "start"
? tx("settings.api.starting", "Starting...")
: apiServiceAction === "stop"
? tx("settings.api.stopping", "Stopping...")
: apiDefaults.running
? tx("settings.api.stop", "Stop")
: tx("settings.api.start", "Start API server")}
</Button>
</div>
</SettingsRow>
{!apiDefaults.running ? (
<>
<SettingsRow
title={tx("settings.api.access", "Access")}
description={
apiNetworkAccess
? tx("settings.api.networkHelp", "Other devices can connect; an API key is required.")
: tx("settings.api.localHelp", "Only this device can connect.")
}
>
<SegmentedControl
value={apiNetworkAccess ? "network" : "local"}
options={[
{ value: "local", label: tx("settings.api.thisDevice", "This device") },
{ value: "network", label: tx("settings.api.localNetwork", "Local network") },
]}
onChange={(value) => setApiHost(value === "network" ? "0.0.0.0" : "127.0.0.1")}
/>
</SettingsRow>
<SettingsRow title={tx("settings.api.port", "Port")}>
<NumberInput value={apiPort} min={1} max={65535} onChange={setApiPort} />
</SettingsRow>
{apiNetworkAccess ? (
<SettingsRow
title={tx("settings.api.apiKey", "API key")}
description={
apiMissingNetworkKey
? tx("settings.api.apiKeyRequired", "Required before exposing the API to your network.")
: tx("settings.api.apiKeyHelp", "Clients send this as a Bearer token.")
}
>
<div className="relative w-[280px] max-w-full">
<Input
type={apiKeyVisible ? "text" : "password"}
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
placeholder={apiDefaults.api_key_hint ?? tx("settings.api.apiKeyPlaceholder", "Enter an API key")}
className="h-9 rounded-full pr-10 text-[13px]"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setApiKeyVisible((visible) => !visible)}
aria-label={apiKeyVisible ? tx("settings.byok.hideApiKey", "Hide API key") : tx("settings.byok.showApiKey", "Show API key")}
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 rounded-full"
>
{apiKeyVisible ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</Button>
</div>
</SettingsRow>
) : null}
</>
) : null}
</SettingsGroup>
</section>
<section>
<SettingsSectionTitle>{tx("settings.observability.title", "Observability")}</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow
title="Langfuse"
description={
settings.observability?.configured
? undefined
: tx(
"settings.observability.environment",
"Set LANGFUSE_SECRET_KEY and LANGFUSE_PUBLIC_KEY, then restart nanobot.",
)
}
>
{capabilitiesLoading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" aria-hidden />
) : langfuseFeature?.installed ? (
<StatusPill tone={settings.observability?.configured ? "success" : "neutral"}>
{settings.observability?.configured
? tx("settings.values.ready", "Ready")
: tx("settings.values.needsSetup", "Needs setup")}
</StatusPill>
) : (
<Button
size="sm"
variant="outline"
disabled={capabilityAction === "enable:langfuse"}
onClick={() => onInstallCapability("langfuse")}
className="rounded-full"
>
{capabilityAction === "enable:langfuse" ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : null}
{capabilityAction === "enable:langfuse"
? tx("settings.capabilities.installing", "Installing support...")
: tx("settings.observability.enable", "Enable tracing support")}
</Button>
)}
</SettingsRow>
</SettingsGroup>
{capabilityError ? <p className="mt-2 text-[12px] text-destructive">{capabilityError}</p> : null}
</section>
<section>
<SettingsSectionTitle>{t("settings.sections.system")}</SettingsSectionTitle>
<SettingsGroup>
{!isNativeHost ? (
<ReadOnlyRow
title={tx("settings.rows.gateway", "Gateway")}
value={`${settings.runtime.gateway_host}:${settings.runtime.gateway_port}`}
/>
) : null}
<ReadOnlyRow title={t("settings.rows.configPath")} value={settings.runtime.config_path} />
<ReadOnlyRow title={tx("settings.rows.workspacePath", "Default workspace")} value={settings.runtime.workspace_path} />
<ReadOnlyRow title={tx("settings.rows.timezone", "Timezone")} value={form.timezone} />
{onRestart ? (
<SettingsRow
title={t("settings.rows.restart")}
description={
requiresRestartPending
? tx("settings.status.savedRestartApply", "Saved. Restart when ready.")
: undefined
}
>
<Button
size="sm"
variant="outline"
onClick={onRestart}
disabled={isRestarting}
className="rounded-full"
>
{isRestarting ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
)}
{isRestarting ? restartingActionLabel : restartActionLabel}
</Button>
</SettingsRow>
) : null}
</SettingsGroup>
</section>
</div>
);
}
@@ -0,0 +1,656 @@
import type { Dispatch, SetStateAction } from "react";
import type { TFunction } from "i18next";
import type {
ApplySettingsPayload,
MaybeRestartHostEngine,
PendingRestartSections,
} from "@/components/settings/contracts";
import type { AutomationAction } from "@/components/settings/system/AutomationsSettings";
import { DEFAULT_CUSTOM_MCP_FORM } from "@/components/settings/system/AppsSettings";
import type { SystemSettingsState } from "@/components/settings/system/useSystemSettingsState";
import {
cancelMcpOAuth,
completeMcpOAuth,
disableNanobotFeature,
enableNanobotFeature,
fetchNanobotFeatures,
fetchSettings,
fetchMcpOAuthStatus,
fetchMcpPresets,
importMcpConfig,
runAutomationAction,
runCliAppAction,
runMcpPresetAction,
saveCustomMcpServer,
startMcpOAuth,
startApiService,
stopApiService,
updateAutomation,
updateMcpServerTools,
} from "@/lib/api";
import { notifyCliAppsChanged } from "@/lib/cli-app-events";
import { notifyMcpPresetsChanged } from "@/lib/mcp-preset-events";
import type { NanobotClient } from "@/lib/nanobot-client";
import type {
AutomationUpdatePayload,
McpOAuthFlowPayload,
McpPresetsPayload,
NanobotFeatureInfo,
SessionAutomationJob,
} from "@/lib/types";
function isExpectedMcpOAuthPendingReloadFailure(
payload: McpPresetsPayload,
expectedName?: string,
): boolean {
if (
!expectedName
|| payload.last_action?.ok === false
|| payload.hot_reload?.ok !== false
) return false;
const normalizedName = expectedName.trim().toLowerCase();
const failed = payload.hot_reload.failed ?? [];
if (
!normalizedName
|| failed.length !== 1
|| failed[0].trim().toLowerCase() !== normalizedName
) return false;
return payload.presets.some((preset) => (
preset.name.trim().toLowerCase() === normalizedName
&& preset.auth === "oauth"
&& preset.status === "authorization_required"
));
}
interface SystemSettingsActionsOptions {
state: SystemSettingsState;
featureCatalog: NanobotFeatureInfo[];
client: NanobotClient;
token: string;
getToken: () => string;
t: TFunction;
applyPayload: ApplySettingsPayload;
maybeRestartHostEngine: MaybeRestartHostEngine;
setPendingRestartSections: Dispatch<SetStateAction<PendingRestartSections>>;
refreshAutomations: (showLoading?: boolean) => Promise<void>;
}
export function createSystemSettingsActions({
state,
featureCatalog,
client,
token,
getToken,
t,
applyPayload,
maybeRestartHostEngine,
setPendingRestartSections,
refreshAutomations,
}: SystemSettingsActionsOptions) {
const {
apiServiceAction,
customMcpForm,
mcpConfigImport,
mcpOAuthCallbackUrl,
mcpOAuthFlowRef,
mcpOAuthNavigatedUrlRef,
mcpOAuthPopupRef,
nanobotFeatures,
setApiService,
setApiServiceAction,
setApiServiceError,
setAutomationAction,
setAutomationPendingDelete,
setAutomationPendingEdit,
setAutomations,
setAutomationsError,
setCliApps,
setCliAppsAction,
setCliAppsError,
setCliAppsFocusName,
setCliAppsMessage,
setCustomMcpForm,
setMcpConfigImport,
setMcpError,
setMcpFieldValues,
setMcpMessage,
setMcpOAuthCallbackError,
setMcpOAuthCallbackUrl,
setMcpOAuthCompleting,
setMcpOAuthFlow,
setMcpOAuthPopupBlocked,
setMcpPresetAction,
setMcpPresets,
setNanobotFeatureAction,
setNanobotFeatureConfirm,
setNanobotFeatures,
setNanobotFeaturesError,
} = state;
const installCapabilities = async (names: string[]): Promise<boolean> => {
const missing = names.filter(
(name) => !featureCatalog.find((feature) => feature.name === name)?.installed,
);
if (!missing.length) return true;
setNanobotFeatureAction(`enable:${names.join("+")}`);
setNanobotFeaturesError(null);
try {
let latest = nanobotFeatures;
for (const name of missing) {
latest = await enableNanobotFeature(client, name);
if (latest.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
}
}
if (latest) setNanobotFeatures(latest);
return true;
} catch (err) {
setNanobotFeaturesError((err as Error).message);
return false;
} finally {
setNanobotFeatureAction(null);
}
};
const handleApiServiceAction = async (
action: "start" | "stop",
values?: { host: string; port: number; timeout: number; apiKey?: string },
) => {
if (apiServiceAction) return;
setApiServiceAction(action);
setApiServiceError(null);
try {
const payload = action === "start"
? await startApiService(client, values!)
: await stopApiService(client);
setApiService(payload);
const refreshed = await fetchNanobotFeatures(token);
setNanobotFeatures(refreshed);
const nextSettings = await fetchSettings(token);
applyPayload(nextSettings);
} catch (err) {
setApiServiceError((err as Error).message);
} finally {
setApiServiceAction(null);
}
};
const handleCliAppAction = async (
action: "install" | "update" | "uninstall" | "test",
name: string,
) => {
const key = `${action}:${name}`;
setCliAppsAction(key);
setCliAppsMessage(null);
setCliAppsError(null);
try {
const payload = await runCliAppAction(client, action, name);
setCliApps(payload);
if (action !== "test") {
notifyCliAppsChanged(payload);
}
setCliAppsMessage(payload.last_action?.message ?? null);
setCliAppsFocusName(action === "uninstall" ? null : name);
} catch (err) {
setCliAppsError((err as Error).message);
} finally {
setCliAppsAction(null);
}
};
const handleNanobotFeatureAction = async (
action: "enable" | "disable",
name: string,
confirmed = false,
) => {
const feature = featureCatalog.find((item) => item.name === name);
if (action === "enable" && !confirmed && feature && !feature.installed && feature.install_supported) {
setNanobotFeaturesError(null);
setNanobotFeatureConfirm(feature);
return;
}
const key = `${action}:${name}`;
setNanobotFeatureAction(key);
setNanobotFeatureConfirm(null);
setNanobotFeaturesError(null);
try {
const payload = action === "enable"
? await enableNanobotFeature(client, name)
: await disableNanobotFeature(client, name);
setNanobotFeatures(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
}
} catch (err) {
setNanobotFeaturesError((err as Error).message);
} finally {
setNanobotFeatureAction(null);
}
};
const handleAutomationAction = async (
action: AutomationAction,
job: SessionAutomationJob,
) => {
const key = `${action}:${job.id}`;
setAutomationAction(key);
setAutomationsError(null);
try {
const payload = await runAutomationAction(client, action, job.id);
setAutomations(payload);
if (action === "delete") setAutomationPendingDelete(null);
if (action === "run") {
window.setTimeout(() => void refreshAutomations(false), 1200);
window.setTimeout(() => void refreshAutomations(false), 4000);
}
} catch (err) {
setAutomationsError((err as Error).message);
} finally {
setAutomationAction(null);
}
};
const handleAutomationEdit = async (
job: SessionAutomationJob,
values: AutomationUpdatePayload,
) => {
const key = `update:${job.id}`;
setAutomationAction(key);
setAutomationsError(null);
try {
const payload = await updateAutomation(client, job.id, values);
setAutomations(payload);
setAutomationPendingEdit(null);
} catch (err) {
setAutomationsError((err as Error).message);
} finally {
setAutomationAction(null);
}
};
const closeMcpOAuthPopup = () => {
const popup = mcpOAuthPopupRef.current;
mcpOAuthPopupRef.current = null;
mcpOAuthNavigatedUrlRef.current = null;
if (!popup) return;
try {
if (!popup.closed) popup.close();
} catch {
// The authorization page may have navigated cross-origin before it closed itself.
}
};
const openMcpOAuthPopup = (authorizationUrl?: string): Window | null => {
let popup: Window | null = null;
try {
popup = window.open(
authorizationUrl ?? "about:blank",
"nanobot-mcp-oauth",
"popup,width=560,height=720,resizable=yes,scrollbars=yes",
);
if (popup) {
mcpOAuthPopupRef.current = popup;
mcpOAuthNavigatedUrlRef.current = authorizationUrl ?? null;
if (!authorizationUrl) {
try {
popup.document.title = t("settings.oauth.signingIn", { defaultValue: "Preparing sign-in…" });
popup.document.body.textContent = t("settings.mcp.preparingSignIn", {
defaultValue: "Preparing secure sign-in…",
});
} catch {
// about:blank can become unavailable if the window is reused mid-navigation.
}
}
try {
popup.opener = null;
popup.focus();
} catch {
// A cross-origin authorization page can restrict window access.
}
}
} catch {
// Browsers can reject popup creation before returning a window handle.
}
setMcpOAuthPopupBlocked(!popup);
return popup;
};
const navigateMcpOAuthPopup = (flow: McpOAuthFlowPayload) => {
const authorizationUrl = flow.authorization_url;
if (!authorizationUrl) return;
const popup = mcpOAuthPopupRef.current;
// OAuth pages can use Cross-Origin-Opener-Policy, which severs the
// WindowProxy and makes an open tab appear closed. Once navigation was
// requested, do not mistake that browser isolation for a blocked popup.
if (popup && mcpOAuthNavigatedUrlRef.current === authorizationUrl) return;
try {
if (popup && !popup.closed) {
popup.location.replace(authorizationUrl);
mcpOAuthNavigatedUrlRef.current = authorizationUrl;
popup.focus();
setMcpOAuthPopupBlocked(false);
return;
}
if (popup) return;
} catch {
// Fall through to the explicit Continue in browser action.
}
setMcpOAuthPopupBlocked(true);
};
const finishMcpOAuthFlow = async (flow: McpOAuthFlowPayload) => {
if (mcpOAuthFlowRef.current?.flow_id !== flow.flow_id) return;
closeMcpOAuthPopup();
mcpOAuthFlowRef.current = null;
setMcpOAuthFlow(null);
setMcpPresetAction(null);
setMcpOAuthCallbackUrl("");
setMcpOAuthCompleting(false);
setMcpOAuthCallbackError(null);
if (flow.status === "connected") {
try {
const payload = await fetchMcpPresets(getToken());
setMcpPresets(payload);
notifyMcpPresetsChanged(payload);
setMcpMessage(null);
setMcpError(null);
} catch (err) {
setMcpError((err as Error).message);
}
return;
}
if (flow.status === "authorized" && flow.hot_reload) {
if (flow.hot_reload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
}
setMcpError(
flow.hot_reload.message
|| t("settings.mcp.reloadFailed", {
defaultValue: "Signed in, but nanobot could not connect the tools. Try restarting nanobot.",
}),
);
return;
}
if (flow.status === "failed") {
setMcpError(
flow.error
|| t("settings.mcp.oauthFailed", {
defaultValue: "Unable to connect. Try signing in again.",
}),
);
}
};
const monitorMcpOAuthFlow = async (initial: McpOAuthFlowPayload) => {
let current = initial;
while (mcpOAuthFlowRef.current?.flow_id === current.flow_id) {
navigateMcpOAuthPopup(current);
const terminal =
current.status === "connected"
|| current.status === "failed"
|| current.status === "cancelled"
|| (current.status === "authorized" && Boolean(current.hot_reload));
if (terminal) {
await finishMcpOAuthFlow(current);
return;
}
await new Promise((resolve) => window.setTimeout(resolve, 800));
if (mcpOAuthFlowRef.current?.flow_id !== current.flow_id) return;
try {
current = await fetchMcpOAuthStatus(getToken(), current.flow_id);
if (mcpOAuthFlowRef.current?.flow_id !== current.flow_id) return;
mcpOAuthFlowRef.current = current;
setMcpOAuthFlow(current);
} catch (err) {
if (mcpOAuthFlowRef.current?.flow_id !== current.flow_id) return;
closeMcpOAuthPopup();
mcpOAuthFlowRef.current = null;
setMcpOAuthFlow(null);
setMcpPresetAction(null);
setMcpOAuthCallbackUrl("");
setMcpOAuthCompleting(false);
setMcpOAuthCallbackError(null);
setMcpError((err as Error).message);
return;
}
}
};
const handleMcpOAuthConnect = async (name: string) => {
openMcpOAuthPopup();
const key = `oauth:${name}`;
setMcpPresetAction(key);
setMcpMessage(null);
setMcpError(null);
setMcpOAuthCallbackUrl("");
setMcpOAuthCompleting(false);
setMcpOAuthCallbackError(null);
try {
const flow = await startMcpOAuth(client, name);
mcpOAuthFlowRef.current = flow;
setMcpOAuthFlow(flow);
navigateMcpOAuthPopup(flow);
void monitorMcpOAuthFlow(flow);
} catch (err) {
closeMcpOAuthPopup();
mcpOAuthFlowRef.current = null;
setMcpOAuthFlow(null);
setMcpPresetAction(null);
setMcpOAuthCallbackUrl("");
setMcpOAuthCompleting(false);
setMcpOAuthCallbackError(null);
setMcpError((err as Error).message);
}
};
const handleMcpOAuthCancel = async () => {
const flow = mcpOAuthFlowRef.current;
if (!flow) return;
mcpOAuthFlowRef.current = null;
setMcpOAuthFlow(null);
setMcpPresetAction(null);
setMcpOAuthCallbackUrl("");
setMcpOAuthCompleting(false);
setMcpOAuthCallbackError(null);
closeMcpOAuthPopup();
try {
await cancelMcpOAuth(client, flow.flow_id);
} catch (err) {
setMcpError((err as Error).message);
}
};
const handleMcpOAuthOpen = () => {
const authorizationUrl = mcpOAuthFlowRef.current?.authorization_url;
if (!authorizationUrl) return;
openMcpOAuthPopup(authorizationUrl);
};
const handleMcpOAuthComplete = async () => {
const flow = mcpOAuthFlowRef.current;
const callbackUrl = mcpOAuthCallbackUrl.trim();
if (!flow || flow.completion_input !== "callback_url") return;
if (!callbackUrl) {
setMcpOAuthCallbackError(t("settings.oauth.pasteCallbackToContinue"));
return;
}
setMcpOAuthCompleting(true);
setMcpOAuthCallbackError(null);
try {
const next = await completeMcpOAuth(client, flow.flow_id, callbackUrl);
if (mcpOAuthFlowRef.current?.flow_id !== flow.flow_id) return;
mcpOAuthFlowRef.current = next;
setMcpOAuthFlow(next);
} catch (err) {
if (mcpOAuthFlowRef.current?.flow_id !== flow.flow_id) return;
setMcpOAuthCallbackError((err as Error).message);
} finally {
if (mcpOAuthFlowRef.current?.flow_id === flow.flow_id) {
setMcpOAuthCompleting(false);
}
}
};
const applyMcpActionFeedback = (
payload: McpPresetsPayload,
announceSuccess = false,
expectedOAuthPendingName?: string,
) => {
const expectedOAuthPending = isExpectedMcpOAuthPendingReloadFailure(
payload,
expectedOAuthPendingName,
);
const actionError = payload.last_action?.ok === false
? payload.last_action.error || payload.last_action.message
: payload.hot_reload?.ok === false && !expectedOAuthPending
? payload.hot_reload.message
: null;
setMcpError(actionError || null);
setMcpMessage(
actionError || !announceSuccess
? null
: payload.last_action?.message ?? null,
);
};
const handleMcpPresetAction = async (
action: "enable" | "remove" | "test",
name: string,
values: Record<string, string> = {},
) => {
const key = `${action}:${name}`;
setMcpPresetAction(key);
setMcpMessage(null);
setMcpError(null);
try {
const payload = await runMcpPresetAction(client, action, name, values);
setMcpPresets(payload);
applyMcpActionFeedback(payload, action === "test");
if (action !== "test") {
notifyMcpPresetsChanged(payload);
}
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
}
await maybeRestartHostEngine(payload);
if (action === "enable") {
setMcpFieldValues((prev) => ({ ...prev, [name]: {} }));
}
} catch (err) {
setMcpError((err as Error).message);
} finally {
setMcpPresetAction(null);
}
};
const handleSaveCustomMcp = async () => {
const name = customMcpForm.name.trim();
const expectsOAuthAuthorization = (
customMcpForm.transport !== "stdio" && customMcpForm.auth === "oauth"
);
const key = `custom:${name || "new"}`;
setMcpPresetAction(key);
setMcpMessage(null);
setMcpError(null);
try {
const payload = await saveCustomMcpServer(client, {
name,
transport: customMcpForm.transport,
auth:
customMcpForm.transport !== "stdio" && customMcpForm.auth === "oauth"
? "oauth"
: "",
command: customMcpForm.command,
args: customMcpForm.args,
url: customMcpForm.url,
env: customMcpForm.env,
headers:
customMcpForm.transport !== "stdio" && customMcpForm.auth === "headers"
? customMcpForm.headers
: "",
tool_timeout: customMcpForm.toolTimeout,
});
setMcpPresets(payload);
applyMcpActionFeedback(
payload,
false,
expectsOAuthAuthorization ? name : undefined,
);
notifyMcpPresetsChanged(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
}
await maybeRestartHostEngine(payload);
setCustomMcpForm((prev) => ({ ...DEFAULT_CUSTOM_MCP_FORM, transport: prev.transport }));
} catch (err) {
setMcpError((err as Error).message);
} finally {
setMcpPresetAction(null);
}
};
const handleImportMcpConfig = async () => {
setMcpPresetAction("import");
setMcpMessage(null);
setMcpError(null);
try {
const payload = await importMcpConfig(client, mcpConfigImport);
setMcpPresets(payload);
applyMcpActionFeedback(payload);
notifyMcpPresetsChanged(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
}
await maybeRestartHostEngine(payload);
setMcpConfigImport("");
} catch (err) {
setMcpError((err as Error).message);
} finally {
setMcpPresetAction(null);
}
};
const handleMcpToolsChange = async (name: string, enabledTools: string[]) => {
setMcpPresetAction(`tools:${name}`);
setMcpMessage(null);
setMcpError(null);
try {
const payload = await updateMcpServerTools(client, name, enabledTools);
setMcpPresets(payload);
applyMcpActionFeedback(payload);
notifyMcpPresetsChanged(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
}
await maybeRestartHostEngine(payload);
} catch (err) {
setMcpError((err as Error).message);
} finally {
setMcpPresetAction(null);
}
};
return {
handleApiServiceAction,
handleAutomationAction,
handleAutomationEdit,
handleCliAppAction,
handleImportMcpConfig,
handleMcpOAuthCancel,
handleMcpOAuthComplete,
handleMcpOAuthConnect,
handleMcpOAuthOpen,
handleMcpPresetAction,
handleMcpToolsChange,
handleNanobotFeatureAction,
handleSaveCustomMcp,
installCapabilities,
};
}
@@ -0,0 +1,221 @@
import { useCallback, useEffect } from "react";
import type { SettingsSectionKey } from "@/components/settings/contracts";
import {
CLI_APPS_REFRESH_MAX_RETRIES,
CLI_APPS_REFRESH_RETRY_MS,
} from "@/components/settings/system/AppsSettings";
import type { SystemSettingsState } from "@/components/settings/system/useSystemSettingsState";
import {
fetchApiService,
fetchAutomations,
fetchCliApps,
fetchMcpPresets,
fetchNanobotFeatures,
} from "@/lib/api";
interface SystemSettingsEffectsOptions {
state: SystemSettingsState;
activeSection: SettingsSectionKey;
getToken: () => string;
pageVisible: boolean;
}
export function useSystemSettingsEffects({
state,
activeSection,
getToken,
pageVisible,
}: SystemSettingsEffectsOptions) {
const {
setApiService,
setApiServiceError,
setApiServiceLoading,
setAutomations,
setAutomationsError,
setAutomationsLoading,
setCliApps,
setCliAppsError,
setCliAppsLoading,
setMcpError,
setMcpPresets,
setMcpPresetsLoading,
setNanobotFeatures,
setNanobotFeaturesError,
setNanobotFeaturesLoading,
} = state;
useEffect(() => {
if (activeSection !== "apps") return;
let cancelled = false;
let retry: number | null = null;
let retryCount = 0;
const loadCliApps = (showLoading: boolean) => {
if (showLoading) setCliAppsLoading(true);
fetchCliApps(getToken())
.then((payload) => {
if (cancelled) return;
if (payload.catalog_refresh_pending && retryCount < CLI_APPS_REFRESH_MAX_RETRIES) {
retryCount += 1;
retry = window.setTimeout(() => {
retry = null;
loadCliApps(false);
}, CLI_APPS_REFRESH_RETRY_MS);
}
setCliApps(payload);
setCliAppsError(null);
setCliAppsLoading(false);
})
.catch((err) => {
if (!cancelled) {
setCliAppsError((err as Error).message);
setCliAppsLoading(false);
}
});
};
loadCliApps(true);
return () => {
cancelled = true;
if (retry !== null) window.clearTimeout(retry);
};
}, [activeSection, getToken]);
useEffect(() => {
if (
!pageVisible
|| !["channels", "models", "browser", "runtime"].includes(activeSection)
) {
return;
}
let cancelled = false;
let refreshing = false;
const refresh = async (showLoading = false): Promise<void> => {
if (refreshing) return;
refreshing = true;
if (showLoading) setNanobotFeaturesLoading(true);
try {
const payload = await fetchNanobotFeatures(getToken());
if (!cancelled) {
setNanobotFeatures(payload);
setNanobotFeaturesError(null);
}
} catch (err) {
const message = (err as Error).message;
if (!cancelled && message !== "HTTP 404") setNanobotFeaturesError(message);
} finally {
refreshing = false;
if (!cancelled && showLoading) setNanobotFeaturesLoading(false);
}
};
void refresh(true);
const interval = activeSection === "channels"
? window.setInterval(() => void refresh(false), 5000)
: null;
const refreshOnFocus = () => {
if (activeSection === "channels" && document.visibilityState !== "hidden") {
void refresh(false);
}
};
window.addEventListener("focus", refreshOnFocus);
document.addEventListener("visibilitychange", refreshOnFocus);
return () => {
cancelled = true;
if (interval !== null) window.clearInterval(interval);
window.removeEventListener("focus", refreshOnFocus);
document.removeEventListener("visibilitychange", refreshOnFocus);
};
}, [activeSection, getToken, pageVisible]);
useEffect(() => {
if (activeSection !== "runtime") return;
let cancelled = false;
setApiServiceLoading(true);
fetchApiService(getToken())
.then((payload) => {
if (!cancelled) {
setApiService(payload);
setApiServiceError(null);
}
})
.catch((err) => {
if (!cancelled) setApiServiceError((err as Error).message);
})
.finally(() => {
if (!cancelled) setApiServiceLoading(false);
});
return () => {
cancelled = true;
};
}, [activeSection, getToken]);
useEffect(() => {
if (activeSection !== "apps") return;
let cancelled = false;
setMcpPresetsLoading(true);
fetchMcpPresets(getToken())
.then((payload) => {
if (!cancelled) {
setMcpPresets(payload);
setMcpError(null);
}
})
.catch((err) => {
if (!cancelled) setMcpError((err as Error).message);
})
.finally(() => {
if (!cancelled) setMcpPresetsLoading(false);
});
return () => {
cancelled = true;
};
}, [activeSection, getToken]);
const refreshAutomations = useCallback(
async (showLoading = false) => {
if (showLoading) setAutomationsLoading(true);
try {
const payload = await fetchAutomations(getToken());
setAutomations(payload);
setAutomationsError(null);
} catch (err) {
setAutomationsError((err as Error).message);
} finally {
if (showLoading) setAutomationsLoading(false);
}
},
[getToken],
);
useEffect(() => {
if (activeSection !== "automations" || !pageVisible) return;
let cancelled = false;
let refreshing = false;
const refresh = async (showLoading = false) => {
if (cancelled || refreshing) return;
refreshing = true;
if (showLoading) setAutomationsLoading(true);
try {
const payload = await fetchAutomations(getToken());
if (cancelled) return;
setAutomations(payload);
setAutomationsError(null);
} catch (err) {
if (!cancelled) setAutomationsError((err as Error).message);
} finally {
refreshing = false;
if (!cancelled && showLoading) setAutomationsLoading(false);
}
};
void refresh(true);
const interval = window.setInterval(() => void refresh(false), 5000);
const refreshOnFocus = () => void refresh(false);
window.addEventListener("focus", refreshOnFocus);
return () => {
cancelled = true;
window.clearInterval(interval);
window.removeEventListener("focus", refreshOnFocus);
};
}, [activeSection, getToken, pageVisible]);
return { refreshAutomations };
}
@@ -0,0 +1,157 @@
import { useRef, useState } from "react";
import type {
AutomationFilter,
AutomationSort,
} from "@/components/settings/system/AutomationsSettings";
import {
DEFAULT_CUSTOM_MCP_FORM,
type AppsKindFilter,
type CustomMcpForm,
} from "@/components/settings/system/AppsSettings";
import type {
ApiServicePayload,
AutomationsPayload,
CliAppsPayload,
McpOAuthFlowPayload,
McpPresetsPayload,
NanobotFeatureInfo,
NanobotFeaturesPayload,
SessionAutomationJob,
} from "@/lib/types";
export function useSystemSettingsState() {
const [cliApps, setCliApps] = useState<CliAppsPayload | null>(null);
const [nanobotFeatures, setNanobotFeatures] = useState<NanobotFeaturesPayload | null>(null);
const [mcpPresets, setMcpPresets] = useState<McpPresetsPayload | null>(null);
const [automations, setAutomations] = useState<AutomationsPayload | null>(null);
const [cliAppsLoading, setCliAppsLoading] = useState(true);
const [nanobotFeaturesLoading, setNanobotFeaturesLoading] = useState(true);
const [mcpPresetsLoading, setMcpPresetsLoading] = useState(true);
const [automationsLoading, setAutomationsLoading] = useState(false);
const [cliAppsAction, setCliAppsAction] = useState<string | null>(null);
const [nanobotFeatureAction, setNanobotFeatureAction] = useState<string | null>(null);
const [nanobotFeatureConfirm, setNanobotFeatureConfirm] = useState<NanobotFeatureInfo | null>(null);
const [mcpPresetAction, setMcpPresetAction] = useState<string | null>(null);
const [mcpOAuthFlow, setMcpOAuthFlow] = useState<McpOAuthFlowPayload | null>(null);
const mcpOAuthFlowRef = useRef<McpOAuthFlowPayload | null>(null);
const mcpOAuthPopupRef = useRef<Window | null>(null);
const mcpOAuthNavigatedUrlRef = useRef<string | null>(null);
const [mcpOAuthPopupBlocked, setMcpOAuthPopupBlocked] = useState(false);
const [mcpOAuthCallbackUrl, setMcpOAuthCallbackUrl] = useState("");
const [mcpOAuthCompleting, setMcpOAuthCompleting] = useState(false);
const [mcpOAuthCallbackError, setMcpOAuthCallbackError] = useState<string | null>(null);
const [apiService, setApiService] = useState<ApiServicePayload | null>(null);
const [apiServiceLoading, setApiServiceLoading] = useState(false);
const [apiServiceAction, setApiServiceAction] = useState<"start" | "stop" | null>(null);
const [apiServiceError, setApiServiceError] = useState<string | null>(null);
const [appsQuery, setAppsQuery] = useState("");
const [channelsQuery, setChannelsQuery] = useState("");
const [automationsQuery, setAutomationsQuery] = useState("");
const [automationsFilter, setAutomationsFilter] = useState<AutomationFilter>("all");
const [automationsSort, setAutomationsSort] = useState<AutomationSort>("next");
const [cliAppsMessage, setCliAppsMessage] = useState<string | null>(null);
const [cliAppsError, setCliAppsError] = useState<string | null>(null);
const [nanobotFeaturesError, setNanobotFeaturesError] = useState<string | null>(null);
const [cliAppsFocusName, setCliAppsFocusName] = useState<string | null>(null);
const [appsKindFilter, setAppsKindFilter] = useState<AppsKindFilter>("cli");
const [mcpMessage, setMcpMessage] = useState<string | null>(null);
const [mcpError, setMcpError] = useState<string | null>(null);
const [automationsError, setAutomationsError] = useState<string | null>(null);
const [automationAction, setAutomationAction] = useState<string | null>(null);
const [automationPendingDelete, setAutomationPendingDelete] =
useState<SessionAutomationJob | null>(null);
const [automationPendingEdit, setAutomationPendingEdit] =
useState<SessionAutomationJob | null>(null);
const [mcpFieldValues, setMcpFieldValues] = useState<Record<string, Record<string, string>>>({});
const [customMcpForm, setCustomMcpForm] = useState<CustomMcpForm>(DEFAULT_CUSTOM_MCP_FORM);
const [mcpConfigImport, setMcpConfigImport] = useState("");
return {
apiService,
apiServiceAction,
apiServiceError,
apiServiceLoading,
appsKindFilter,
appsQuery,
automationAction,
automationPendingDelete,
automationPendingEdit,
automations,
automationsError,
automationsFilter,
automationsLoading,
automationsQuery,
automationsSort,
channelsQuery,
cliApps,
cliAppsAction,
cliAppsError,
cliAppsFocusName,
cliAppsLoading,
cliAppsMessage,
customMcpForm,
mcpConfigImport,
mcpError,
mcpFieldValues,
mcpMessage,
mcpOAuthCallbackError,
mcpOAuthCallbackUrl,
mcpOAuthCompleting,
mcpOAuthFlow,
mcpOAuthFlowRef,
mcpOAuthNavigatedUrlRef,
mcpOAuthPopupBlocked,
mcpOAuthPopupRef,
mcpPresetAction,
mcpPresets,
mcpPresetsLoading,
nanobotFeatureAction,
nanobotFeatureConfirm,
nanobotFeatures,
nanobotFeaturesError,
nanobotFeaturesLoading,
setApiService,
setApiServiceAction,
setApiServiceError,
setApiServiceLoading,
setAppsKindFilter,
setAppsQuery,
setAutomationAction,
setAutomationPendingDelete,
setAutomationPendingEdit,
setAutomations,
setAutomationsError,
setAutomationsFilter,
setAutomationsLoading,
setAutomationsQuery,
setAutomationsSort,
setChannelsQuery,
setCliApps,
setCliAppsAction,
setCliAppsError,
setCliAppsFocusName,
setCliAppsLoading,
setCliAppsMessage,
setCustomMcpForm,
setMcpConfigImport,
setMcpError,
setMcpFieldValues,
setMcpMessage,
setMcpOAuthCallbackError,
setMcpOAuthCallbackUrl,
setMcpOAuthCompleting,
setMcpOAuthFlow,
setMcpOAuthPopupBlocked,
setMcpPresetAction,
setMcpPresets,
setMcpPresetsLoading,
setNanobotFeatureAction,
setNanobotFeatureConfirm,
setNanobotFeatures,
setNanobotFeaturesError,
setNanobotFeaturesLoading,
};
}
export type SystemSettingsState = ReturnType<typeof useSystemSettingsState>;
@@ -0,0 +1,613 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { imageGenerationFormFromPayload } from "@/components/settings/capabilities/ImageGenerationSettings";
import {
networkSafetyFormFromPayload,
visibleWebuiDefaultAccessMode,
} from "@/components/settings/capabilities/SecuritySettings";
import {
DEFAULT_TRANSCRIPTION_SETTINGS,
transcriptionFormFromPayload,
} from "@/components/settings/capabilities/TranscriptionSettings";
import { useCapabilitySettingsActions } from "@/components/settings/capabilities/useCapabilitySettingsActions";
import { useCapabilitySettingsState } from "@/components/settings/capabilities/useCapabilitySettingsState";
import { webSearchFormFromPayload } from "@/components/settings/capabilities/WebSettings";
import type {
ApplySettingsPayload,
PendingRestartSections,
RestartAwarePayload,
SettingsSectionKey,
} from "@/components/settings/contracts";
import { agentDraftFromPayload } from "@/components/settings/models/ModelsSettings";
import { useModelSettingsActions } from "@/components/settings/models/useModelSettingsActions";
import {
useProviderFormsSync,
useProviderOAuthPolling,
} from "@/components/settings/models/useModelSettingsEffects";
import { useModelSettingsState } from "@/components/settings/models/useModelSettingsState";
import { normalizeContextWindowTokens } from "@/components/settings/shared/ModelControls";
import { createSystemSettingsActions } from "@/components/settings/system/createSystemSettingsActions";
import { useSystemSettingsEffects } from "@/components/settings/system/useSystemSettingsEffects";
import { useSystemSettingsState } from "@/components/settings/system/useSystemSettingsState";
import { usePageVisibility } from "@/hooks/usePageVisibility";
import { fetchSettings, fetchSettingsUsage } from "@/lib/api";
import {
readLocalPreferences,
writeLocalPreferences,
type LocalPreferences,
} from "@/lib/local-preferences";
import { isLoopbackHost } from "@/lib/network";
import type { SettingsPayload } from "@/lib/types";
import { useClient } from "@/providers/ClientProvider";
interface SettingsControllerOptions {
initialSection: SettingsSectionKey;
initialSettings: SettingsPayload | null;
onModelNameChange: (modelName: string | null) => void;
onSettingsChange?: (payload: SettingsPayload) => void;
onSectionChange?: (section: SettingsSectionKey) => void;
onRestart?: () => void;
onNativeEngineRestart?: () => Promise<string>;
}
const EMPTY_PENDING_RESTART_SECTIONS: PendingRestartSections = {
runtime: false,
browser: false,
image: false,
};
function pendingRestartSectionsFromPayload(payload: SettingsPayload): PendingRestartSections {
const sections = payload.restart_required_sections ?? [];
return {
runtime: sections.includes("runtime"),
browser: sections.includes("browser"),
image: sections.includes("image"),
};
}
export function useSettingsController({
initialSection,
initialSettings,
onModelNameChange,
onSettingsChange,
onSectionChange,
onRestart,
onNativeEngineRestart,
}: SettingsControllerOptions) {
const { t } = useTranslation();
const { client, getToken, token } = useClient();
const pageVisible = usePageVisibility();
const remoteBrowserAccess =
typeof window !== "undefined" && !isLoopbackHost(window.location.hostname);
const [settings, setSettings] = useState<SettingsPayload | null>(() => initialSettings);
const [loading, setLoading] = useState(() => initialSettings === null);
const [hostEngineApplying, setHostEngineApplying] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeSection, setActiveSection] = useState<SettingsSectionKey>(initialSection);
const [pendingRestartSections, setPendingRestartSections] = useState<PendingRestartSections>(
EMPTY_PENDING_RESTART_SECTIONS,
);
const [localPrefs, setLocalPrefs] = useState<LocalPreferences>(() => readLocalPreferences());
const modelState = useModelSettingsState(initialSettings);
const {
editingProviderKeys, expandedProvider, form, modelCallOrder, modelCallOrderSaving,
modelConfigurationSaving, modelMigrationSaving, modelPresetBeforeCreateRef,
modelPresetCreating, modelPresetPendingDelete, providerForms, providerOAuthCompleting,
providerOAuthDialogError, providerOAuthFlow, providerOAuthFlowRef, providerOAuthResponse,
providerSaving, saving, setForm,
setModelCallOrder, setModelPresetCreating, setModelPresetPendingDelete,
setProviderForms, setProviderOAuthCompleting, setProviderOAuthDialogError,
setProviderOAuthFlow, setProviderOAuthResponse, visibleProviderKeys,
} = modelState;
const capabilityState = useCapabilitySettingsState(initialSettings);
const {
imageGenerationForm, imageGenerationSaving, networkSafetyForm, networkSafetySaving,
setImageGenerationForm, setNetworkSafetyForm, setTranscriptionForm, setWebSearchForm,
setWebSearchKeyEditing, setWebSearchKeyVisible, transcriptionForm,
transcriptionSaving, webSearchForm, webSearchKeyEditing, webSearchKeyVisible,
webSearchSaving,
} = capabilityState;
const systemState = useSystemSettingsState();
const {
apiService, apiServiceAction, apiServiceError, apiServiceLoading, appsKindFilter, appsQuery,
automationAction, automationPendingDelete, automationPendingEdit, automations,
automationsError, automationsFilter, automationsLoading, automationsQuery, automationsSort,
channelsQuery, cliApps, cliAppsAction, cliAppsError, cliAppsFocusName, cliAppsLoading,
cliAppsMessage, customMcpForm, mcpConfigImport, mcpError, mcpFieldValues, mcpMessage,
mcpOAuthCallbackError, mcpOAuthCallbackUrl, mcpOAuthCompleting, mcpOAuthFlow,
mcpOAuthPopupBlocked, mcpPresetAction, mcpPresets, mcpPresetsLoading, nanobotFeatureAction,
nanobotFeatureConfirm, nanobotFeatures, nanobotFeaturesError, nanobotFeaturesLoading,
setAppsKindFilter, setAppsQuery, setAutomationPendingDelete,
setAutomationPendingEdit, setAutomationsFilter,
setAutomationsQuery, setAutomationsSort, setChannelsQuery,
setCliAppsError,
setCliAppsMessage, setCustomMcpForm, setMcpConfigImport, setMcpError, setMcpFieldValues,
setMcpMessage, setMcpOAuthCallbackError, setMcpOAuthCallbackUrl,
setNanobotFeatureConfirm, setNanobotFeatures,
setNanobotFeaturesError,
} = systemState;
const featureCatalog = nanobotFeatures?.features ?? [];
useEffect(() => {
setActiveSection(initialSection);
}, [initialSection]);
const selectSection = useCallback(
(section: SettingsSectionKey) => {
setActiveSection(section);
onSectionChange?.(section);
},
[onSectionChange],
);
const applyPayload: ApplySettingsPayload = useCallback(
(
payload: SettingsPayload,
options: { preserveAgentForm?: boolean } = {},
) => {
setSettings(payload);
if (!options.preserveAgentForm) {
setForm(agentDraftFromPayload(payload));
setModelPresetCreating(false);
}
setModelCallOrder(payload.model_call_order ?? []);
setWebSearchForm((prev) => webSearchFormFromPayload(payload, prev));
setImageGenerationForm(imageGenerationFormFromPayload(payload));
setTranscriptionForm(transcriptionFormFromPayload(payload));
setNetworkSafetyForm(networkSafetyFormFromPayload(payload));
if (payload.restart_required_sections) {
setPendingRestartSections(pendingRestartSectionsFromPayload(payload));
}
onSettingsChange?.(payload);
},
[onSettingsChange],
);
const closeProviderOAuthFlow = useCallback(() => {
providerOAuthFlowRef.current = null;
setProviderOAuthFlow(null);
setProviderOAuthResponse("");
setProviderOAuthCompleting(false);
setProviderOAuthDialogError(null);
}, []);
useProviderOAuthPolling({
state: modelState,
client,
applyPayload,
setError,
closeProviderOAuthFlow,
});
useEffect(() => {
if (!initialSettings || settings !== null) return;
applyPayload(initialSettings);
setLoading(false);
}, [applyPayload, initialSettings, settings]);
useEffect(() => {
let cancelled = false;
const showLoading = settings === null;
if (showLoading) setLoading(true);
fetchSettings(getToken())
.then((payload) => {
if (!cancelled) {
applyPayload(payload);
setError(null);
}
})
.catch((err) => {
if (!cancelled && showLoading) setError((err as Error).message);
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [applyPayload, getToken]);
const hasSettings = settings !== null;
useEffect(() => {
if (activeSection !== "overview" || !hasSettings || !pageVisible) return;
let cancelled = false;
let refreshing = false;
const refresh = async () => {
if (refreshing) return;
refreshing = true;
try {
const usage = await fetchSettingsUsage(getToken());
if (!cancelled) {
setSettings((current) => (current ? { ...current, usage } : current));
}
} catch {
// Usage is best-effort telemetry; the settings snapshot remains usable.
} finally {
refreshing = false;
}
};
void refresh();
const interval = window.setInterval(() => void refresh(), 5000);
const onFocus = () => void refresh();
window.addEventListener("focus", onFocus);
return () => {
cancelled = true;
window.clearInterval(interval);
window.removeEventListener("focus", onFocus);
};
}, [activeSection, getToken, hasSettings, pageVisible]);
const { refreshAutomations } = useSystemSettingsEffects({
state: systemState,
activeSection,
getToken,
pageVisible,
});
useEffect(() => {
writeLocalPreferences(localPrefs);
}, [localPrefs]);
useProviderFormsSync(modelState, settings);
const modelDirty = useMemo(() => {
if (!settings) return false;
const selectedPreset = settings.model_presets.find(
(preset) => !preset.is_default && preset.name === form.modelPreset,
);
if (!selectedPreset) return false;
return (
form.model !== selectedPreset.model ||
form.provider !== selectedPreset.provider ||
form.maxTokens !== selectedPreset.max_tokens ||
form.contextWindowTokens !== normalizeContextWindowTokens(selectedPreset.context_window_tokens) ||
form.temperature !== selectedPreset.temperature ||
form.reasoningEffort !== (selectedPreset.reasoning_effort ?? "") ||
form.presetLabel.trim() !== selectedPreset.label
);
}, [form, settings]);
const imageGenerationDirty = useMemo(() => {
if (!settings) return false;
return (
imageGenerationForm.enabled !== settings.image_generation.enabled ||
imageGenerationForm.provider !== settings.image_generation.provider ||
imageGenerationForm.model !== settings.image_generation.model ||
imageGenerationForm.defaultAspectRatio !== settings.image_generation.default_aspect_ratio ||
imageGenerationForm.defaultImageSize !== settings.image_generation.default_image_size ||
imageGenerationForm.maxImagesPerTurn !== settings.image_generation.max_images_per_turn
);
}, [imageGenerationForm, settings]);
const transcriptionDirty = useMemo(() => {
if (!settings) return false;
const transcription = settings.transcription ?? DEFAULT_TRANSCRIPTION_SETTINGS;
return (
transcriptionForm.enabled !== transcription.enabled ||
transcriptionForm.provider !== transcription.provider ||
transcriptionForm.model !== transcription.model ||
transcriptionForm.language !== (transcription.language ?? "") ||
transcriptionForm.maxDurationSec !== transcription.max_duration_sec ||
transcriptionForm.maxUploadMb !== transcription.max_upload_mb
);
}, [settings, transcriptionForm]);
const networkSafetyDirty = useMemo(() => {
if (!settings) return false;
const currentLocalServiceAccess =
settings.advanced.webui_allow_local_service_access ?? settings.advanced.allow_local_preview_access ?? true;
const currentDefaultAccess = visibleWebuiDefaultAccessMode(settings.advanced.webui_default_access_mode);
return (
networkSafetyForm.webuiAllowLocalServiceAccess !== currentLocalServiceAccess ||
networkSafetyForm.webuiDefaultAccessMode !== currentDefaultAccess
);
}, [networkSafetyForm, settings]);
const configuredModelProviderOptions = useMemo(
() =>
settings?.providers
.filter((provider) => provider.configured && provider.model_selectable !== false)
.map((provider) => ({ name: provider.name, label: provider.label })) ?? [],
[settings],
);
const hasPendingRestart = useMemo(
() =>
!!settings?.requires_restart ||
pendingRestartSections.runtime ||
pendingRestartSections.browser ||
pendingRestartSections.image,
[pendingRestartSections, settings?.requires_restart],
);
const restartViaSettingsSurface = useCallback(async () => {
const isNativeHost = (settings?.surface ?? settings?.runtime_surface) === "native";
if (
isNativeHost &&
settings?.runtime_capabilities?.can_restart_engine &&
onNativeEngineRestart
) {
setHostEngineApplying(true);
try {
const nextToken = await onNativeEngineRestart();
const payload = await fetchSettings(nextToken);
applyPayload(payload);
setPendingRestartSections(EMPTY_PENDING_RESTART_SECTIONS);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setHostEngineApplying(false);
}
return;
}
onRestart?.();
}, [applyPayload, onNativeEngineRestart, onRestart, settings]);
const maybeRestartHostEngine = useCallback(
async (payload: RestartAwarePayload) => {
const surface = payload.surface ?? payload.runtime_surface ?? settings?.surface ?? settings?.runtime_surface;
const capabilities = payload.runtime_capabilities ?? settings?.runtime_capabilities;
const isNativeHost = surface === "native";
if (
!payload.requires_restart ||
!isNativeHost ||
!capabilities?.can_restart_engine ||
!onNativeEngineRestart
) {
return;
}
setHostEngineApplying(true);
try {
const nextToken = await onNativeEngineRestart();
const refreshed = await fetchSettings(nextToken);
applyPayload(refreshed);
setPendingRestartSections(EMPTY_PENDING_RESTART_SECTIONS);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setHostEngineApplying(false);
}
},
[applyPayload, onNativeEngineRestart, settings],
);
const systemActions = createSystemSettingsActions({
state: systemState,
featureCatalog,
client,
token,
getToken,
t,
applyPayload,
maybeRestartHostEngine,
setPendingRestartSections,
refreshAutomations,
});
const { installCapabilities } = systemActions;
const modelActions = useModelSettingsActions({
state: modelState,
settings,
client,
t,
applyPayload,
maybeRestartHostEngine,
setPendingRestartSections,
setError,
onModelNameChange,
remoteBrowserAccess,
closeProviderOAuthFlow,
installCapabilities,
modelDirty,
configuredModelProviderOptions,
});
const capabilityActions = useCapabilitySettingsActions({
state: capabilityState,
settings,
client,
t,
applyPayload,
maybeRestartHostEngine,
setPendingRestartSections,
setError,
installCapabilities,
imageGenerationDirty,
transcriptionDirty,
networkSafetyDirty,
});
const {
beginModelPresetCreation,
cancelModelPresetCreation,
changeModelCallOrder,
completeProviderOAuthResponse,
createCustomProvider,
handleDeleteModelConfiguration,
handleMigrateModelConfigurations,
handleToggleProvider,
runProviderOAuth,
saveModelSettings,
saveProvider,
toggleProviderKeyEditing,
toggleProviderKeyVisibility,
} = modelActions;
const {
handleWebSearchProviderChange,
resetWebSearchDraft,
saveImageGenerationSettings,
saveNetworkSafetySettings,
saveTranscriptionSettings,
saveWebSearch,
} = capabilityActions;
const {
handleApiServiceAction,
handleAutomationAction,
handleAutomationEdit,
handleCliAppAction,
handleImportMcpConfig,
handleMcpOAuthCancel,
handleMcpOAuthComplete,
handleMcpOAuthConnect,
handleMcpOAuthOpen,
handleMcpPresetAction,
handleMcpToolsChange,
handleNanobotFeatureAction,
handleSaveCustomMcp,
} = systemActions;
return {
activeSection,
apiService,
apiServiceAction,
apiServiceError,
apiServiceLoading,
appsKindFilter,
appsQuery,
automationAction,
automationPendingDelete,
automationPendingEdit,
automations,
automationsError,
automationsFilter,
automationsLoading,
automationsQuery,
automationsSort,
beginModelPresetCreation,
cancelModelPresetCreation,
changeModelCallOrder,
channelsQuery,
cliApps,
cliAppsAction,
cliAppsError,
cliAppsFocusName,
cliAppsLoading,
cliAppsMessage,
closeProviderOAuthFlow,
completeProviderOAuthResponse,
createCustomProvider,
customMcpForm,
editingProviderKeys,
error,
expandedProvider,
featureCatalog,
form,
handleApiServiceAction,
handleAutomationAction,
handleAutomationEdit,
handleCliAppAction,
handleDeleteModelConfiguration,
handleImportMcpConfig,
handleMcpOAuthCancel,
handleMcpOAuthComplete,
handleMcpOAuthConnect,
handleMcpOAuthOpen,
handleMcpPresetAction,
handleMcpToolsChange,
handleMigrateModelConfigurations,
handleNanobotFeatureAction,
handleSaveCustomMcp,
handleToggleProvider,
handleWebSearchProviderChange,
hasPendingRestart,
hostEngineApplying,
imageGenerationDirty,
imageGenerationForm,
imageGenerationSaving,
installCapabilities,
loading,
localPrefs,
mcpConfigImport,
mcpError,
mcpFieldValues,
mcpMessage,
mcpOAuthCallbackError,
mcpOAuthCallbackUrl,
mcpOAuthCompleting,
mcpOAuthFlow,
mcpOAuthPopupBlocked,
mcpPresetAction,
mcpPresets,
mcpPresetsLoading,
modelCallOrder,
modelCallOrderSaving,
modelConfigurationSaving,
modelDirty,
modelMigrationSaving,
modelPresetBeforeCreateRef,
modelPresetCreating,
modelPresetPendingDelete,
nanobotFeatureAction,
nanobotFeatureConfirm,
nanobotFeatures,
nanobotFeaturesError,
nanobotFeaturesLoading,
networkSafetyDirty,
networkSafetyForm,
networkSafetySaving,
pendingRestartSections,
providerForms,
providerOAuthCompleting,
providerOAuthDialogError,
providerOAuthFlow,
providerOAuthResponse,
providerSaving,
remoteBrowserAccess,
resetWebSearchDraft,
restartViaSettingsSurface,
runProviderOAuth,
saveImageGenerationSettings,
saveModelSettings,
saveNetworkSafetySettings,
saveProvider,
saveTranscriptionSettings,
saveWebSearch,
saving,
selectSection,
setAppsKindFilter,
setAppsQuery,
setAutomationPendingDelete,
setAutomationPendingEdit,
setAutomationsFilter,
setAutomationsQuery,
setAutomationsSort,
setChannelsQuery,
setCliAppsError,
setCliAppsMessage,
setCustomMcpForm,
setForm,
setImageGenerationForm,
setLocalPrefs,
setMcpConfigImport,
setMcpError,
setMcpFieldValues,
setMcpMessage,
setMcpOAuthCallbackError,
setMcpOAuthCallbackUrl,
setModelPresetCreating,
setModelPresetPendingDelete,
setNanobotFeatureConfirm,
setNanobotFeatures,
setNanobotFeaturesError,
setNetworkSafetyForm,
setProviderForms,
setProviderOAuthDialogError,
setProviderOAuthResponse,
setTranscriptionForm,
setWebSearchForm,
setWebSearchKeyEditing,
setWebSearchKeyVisible,
settings,
t,
toggleProviderKeyEditing,
toggleProviderKeyVisibility,
token,
transcriptionDirty,
transcriptionForm,
transcriptionSaving,
visibleProviderKeys,
webSearchForm,
webSearchKeyEditing,
webSearchKeyVisible,
webSearchSaving,
};
}
export type SettingsController = ReturnType<typeof useSettingsController>;
+30 -9
View File
@@ -319,8 +319,8 @@
"filterInstalled": "Enabled",
"filterNotInstalled": "Not enabled",
"searchPlaceholder": "Search MCP presets",
"moreOptions": "Add integration",
"moreOptionsSubtitle": "Connect a custom tool server or import an existing configuration.",
"moreOptions": "Add MCP server",
"moreOptionsSubtitle": "Connect a custom MCP server or import an existing configuration.",
"customTitle": "Custom MCP",
"customSubtitle": "Add any stdio, HTTP, or SSE MCP server.",
"customAction": "Custom",
@@ -328,9 +328,14 @@
"serverName": "Server name",
"serverUrl": "URL",
"transport": "Transport",
"authentication": "Authentication",
"authNone": "None",
"authHeaders": "Headers",
"command": "Command",
"args": "Args JSON",
"headers": "Headers JSON",
"oauthAfterSave": "Save the server, then select Connect to sign in.",
"headersHelp": "Add the request headers used by this server.",
"env": "Env JSON",
"timeout": "Tool timeout",
"advancedOptions": "Advanced options",
@@ -357,6 +362,21 @@
"keepExisting": "Leave blank to keep existing",
"statusConfigured": "Configured",
"statusMissingCredentials": "Needs key",
"connectingAccount": "Connecting {{name}}",
"connectingLabel": "Connecting…",
"continueSignIn": "Continue sign-in",
"preparingSignIn": "Preparing secure sign-in…",
"openSignInToContinue": "Open the sign-in page to continue.",
"finishSignInInBrowser": "Finish signing in in the browser window.",
"manualCallbackRequired": "Finish signing in, then paste the callback URL into nanobot.",
"manualCallbackHelp": "After approving access, the localhost page will not load. Copy its full URL from the address bar and paste it here.",
"finishingConnection": "Finishing connection…",
"activatingTools": "Activating tools…",
"connected": "Connected.",
"connectionFailed": "Connection failed.",
"connectionCancelled": "Connection cancelled.",
"reloadFailed": "Signed in, but nanobot could not connect the tools. Try restarting nanobot.",
"oauthFailed": "Unable to connect. Try signing in again.",
"statusMissingDependency": "Needs dependency",
"statusComingSoon": "Coming soon",
"comingSoon": "Coming soon",
@@ -584,27 +604,28 @@
"apps": {
"description": "Add tools to nanobot, then @ them in chat.",
"cliLabel": "App",
"mcpLabel": "Integration",
"mcpLabel": "MCP",
"channelLabel": "Channel",
"featureLabel": "Feature",
"filterAll": "Ready",
"filterPlugins": "Plugins",
"filterCli": "Apps",
"filterMcp": "Integrations",
"filterMcp": "MCP",
"enabledSummary": "{{count}} ready",
"caption": "{{cli}} apps · {{mcp}} integrations",
"caption": "{{cli}} apps · {{mcp}} MCP tools",
"searchPlaceholder": "Search tools",
"featured": "Tools",
"mcpTools": "MCP tools",
"loading": "Loading Apps...",
"empty": "No tools match your search.",
"emptyApps": "No apps available.",
"emptyIntegrations": "No integrations available.",
"emptyIntegrations": "No MCP tools available.",
"emptyReady": "No tools are ready yet.",
"clearSearch": "Clear search",
"browseApps": "Browse apps",
"browseIntegrations": "Browse integrations",
"emptyIntegrationsHint": "Add a custom integration below.",
"restartRequired": "Restart nanobot to apply updated apps and integrations."
"browseIntegrations": "Browse MCP tools",
"emptyIntegrationsHint": "Add a custom MCP server below.",
"restartRequired": "Restart nanobot to apply updated apps and MCP tools."
},
"channels": {
"description": "Connect chat apps, email, and WebUI to nanobot.",
+30 -9
View File
@@ -504,8 +504,8 @@
"filterInstalled": "Habilitados",
"filterNotInstalled": "No habilitados",
"searchPlaceholder": "Buscar preajustes MCP",
"moreOptions": "Más opciones de MCP",
"moreOptionsSubtitle": "Añade un servidor personalizado o importa mcp.json.",
"moreOptions": "Añadir servidor MCP",
"moreOptionsSubtitle": "Conecta un servidor MCP personalizado o importa una configuración existente.",
"customTitle": "MCP personalizado",
"customSubtitle": "Añade cualquier servidor MCP stdio, HTTP o SSE.",
"customAction": "Personalizado",
@@ -513,9 +513,14 @@
"serverName": "Nombre del servidor",
"serverUrl": "URL",
"transport": "Transporte",
"authentication": "Autenticación",
"authNone": "Ninguna",
"authHeaders": "Encabezados",
"command": "Comando",
"args": "Argumentos JSON",
"headers": "Encabezados JSON",
"oauthAfterSave": "Guarda el servidor y selecciona Conectar para iniciar sesión.",
"headersHelp": "Añade los encabezados de solicitud que utiliza este servidor.",
"env": "Entorno JSON",
"timeout": "Tiempo límite de herramienta",
"advancedOptions": "Opciones avanzadas",
@@ -542,6 +547,21 @@
"keepExisting": "Déjalo en blanco para conservar el valor actual",
"statusConfigured": "Configurado",
"statusMissingCredentials": "Necesita clave",
"connectingAccount": "Conectando {{name}}",
"connectingLabel": "Conectando…",
"continueSignIn": "Continuar inicio de sesión",
"preparingSignIn": "Preparando un inicio de sesión seguro…",
"openSignInToContinue": "Abre la página de inicio de sesión para continuar.",
"finishSignInInBrowser": "Termina de iniciar sesión en la ventana del navegador.",
"manualCallbackRequired": "Termina de iniciar sesión y pega la URL de devolución en nanobot.",
"manualCallbackHelp": "Después de aprobar el acceso, la página de localhost no se cargará. Copia la URL completa de la barra de direcciones y pégala aquí.",
"finishingConnection": "Finalizando la conexión…",
"activatingTools": "Activando herramientas…",
"connected": "Conectado.",
"connectionFailed": "Error de conexión.",
"connectionCancelled": "Conexión cancelada.",
"reloadFailed": "Has iniciado sesión, pero nanobot no pudo conectar las herramientas. Prueba a reiniciar nanobot.",
"oauthFailed": "No se pudo conectar. Intenta iniciar sesión de nuevo.",
"statusMissingDependency": "Necesita dependencia",
"statusComingSoon": "Próximamente",
"comingSoon": "Próximamente",
@@ -571,27 +591,28 @@
"apps": {
"description": "Añade herramientas a nanobot y luego úsalas con @ en el chat.",
"cliLabel": "Aplicación",
"mcpLabel": "Integración",
"mcpLabel": "MCP",
"channelLabel": "Canal",
"featureLabel": "Función",
"filterAll": "Listo",
"filterPlugins": "Complementos",
"filterCli": "Aplicaciones",
"filterMcp": "Integraciones",
"filterMcp": "MCP",
"enabledSummary": "{{count}} listos",
"caption": "{{cli}} aplicaciones · {{mcp}} integraciones",
"caption": "{{cli}} aplicaciones · {{mcp}} herramientas MCP",
"searchPlaceholder": "Buscar aplicaciones",
"featured": "Herramientas",
"mcpTools": "Herramientas MCP",
"loading": "Cargando aplicaciones...",
"empty": "Ninguna herramienta coincide con tu búsqueda.",
"emptyApps": "No hay aplicaciones disponibles.",
"emptyIntegrations": "No hay integraciones disponibles.",
"emptyIntegrations": "No hay herramientas MCP disponibles.",
"emptyReady": "Todavía no hay herramientas listas.",
"clearSearch": "Borrar búsqueda",
"browseApps": "Explorar aplicaciones",
"browseIntegrations": "Explorar integraciones",
"emptyIntegrationsHint": "Añade una integración personalizada abajo.",
"restartRequired": "Reinicia nanobot para aplicar las aplicaciones y funciones actualizadas."
"browseIntegrations": "Explorar herramientas MCP",
"emptyIntegrationsHint": "Añade un servidor MCP personalizado abajo.",
"restartRequired": "Reinicia nanobot para aplicar las aplicaciones y herramientas MCP actualizadas."
},
"channels": {
"description": "Conecta nanobot con aplicaciones de chat. Instalar el soporte solo añade el paquete de integración; la mayoría de canales aún necesitan tokens o configuración del espacio de trabajo.",
+30 -9
View File
@@ -504,8 +504,8 @@
"filterInstalled": "Activés",
"filterNotInstalled": "Non activés",
"searchPlaceholder": "Rechercher des préréglages MCP",
"moreOptions": "Plus d'options MCP",
"moreOptionsSubtitle": "Ajoutez un serveur personnalisé ou importez mcp.json.",
"moreOptions": "Ajouter un serveur MCP",
"moreOptionsSubtitle": "Connectez un serveur MCP personnalisé ou importez une configuration existante.",
"customTitle": "MCP personnalisé",
"customSubtitle": "Ajoutez n'importe quel serveur MCP stdio, HTTP ou SSE.",
"customAction": "Personnalisé",
@@ -513,9 +513,14 @@
"serverName": "Nom du serveur",
"serverUrl": "URL",
"transport": "Transport",
"authentication": "Authentification",
"authNone": "Aucune",
"authHeaders": "En-têtes",
"command": "Commande",
"args": "Arguments JSON",
"headers": "En-têtes JSON",
"oauthAfterSave": "Enregistrez le serveur, puis sélectionnez Se connecter pour vous identifier.",
"headersHelp": "Ajoutez les en-têtes de requête utilisés par ce serveur.",
"env": "Environnement JSON",
"timeout": "Délai d'outil",
"advancedOptions": "Options avancées",
@@ -542,6 +547,21 @@
"keepExisting": "Laissez vide pour conserver la valeur actuelle",
"statusConfigured": "Configuré",
"statusMissingCredentials": "Clé requise",
"connectingAccount": "Connexion à {{name}}",
"connectingLabel": "Connexion…",
"continueSignIn": "Continuer la connexion",
"preparingSignIn": "Préparation dune connexion sécurisée…",
"openSignInToContinue": "Ouvrez la page de connexion pour continuer.",
"finishSignInInBrowser": "Terminez la connexion dans la fenêtre du navigateur.",
"manualCallbackRequired": "Terminez la connexion, puis collez lURL de rappel dans nanobot.",
"manualCallbackHelp": "Après avoir autorisé laccès, la page localhost ne se chargera pas. Copiez son URL complète depuis la barre dadresse et collez-la ici.",
"finishingConnection": "Finalisation de la connexion…",
"activatingTools": "Activation des outils…",
"connected": "Connecté.",
"connectionFailed": "Échec de la connexion.",
"connectionCancelled": "Connexion annulée.",
"reloadFailed": "Connexion réussie, mais nanobot na pas pu activer les outils. Essayez de redémarrer nanobot.",
"oauthFailed": "Connexion impossible. Essayez de vous reconnecter.",
"statusMissingDependency": "Dépendance requise",
"statusComingSoon": "Bientôt disponible",
"comingSoon": "Bientôt disponible",
@@ -570,27 +590,28 @@
"apps": {
"description": "Ajoutez des outils à nanobot, puis utilisez-les avec @ dans le chat.",
"cliLabel": "Application",
"mcpLabel": "Intégration",
"mcpLabel": "MCP",
"channelLabel": "Canal",
"featureLabel": "Fonction",
"filterAll": "Prêts",
"filterPlugins": "Extensions",
"filterCli": "Applications",
"filterMcp": "Intégrations",
"filterMcp": "MCP",
"enabledSummary": "{{count}} prêts",
"caption": "{{cli}} applications · {{mcp}} intégrations",
"caption": "{{cli}} applications · {{mcp}} outils MCP",
"searchPlaceholder": "Rechercher des applications",
"featured": "Outils",
"mcpTools": "Outils MCP",
"loading": "Chargement des applications...",
"empty": "Aucun outil ne correspond à votre recherche.",
"emptyApps": "Aucune application disponible.",
"emptyIntegrations": "Aucune intégration disponible.",
"emptyIntegrations": "Aucun outil MCP disponible.",
"emptyReady": "Aucun outil nest encore prêt.",
"clearSearch": "Effacer la recherche",
"browseApps": "Parcourir les applications",
"browseIntegrations": "Parcourir les intégrations",
"emptyIntegrationsHint": "Ajoutez une intégration personnalisée ci-dessous.",
"restartRequired": "Redémarrez nanobot pour appliquer les applications et fonctions mises à jour."
"browseIntegrations": "Parcourir les outils MCP",
"emptyIntegrationsHint": "Ajoutez un serveur MCP personnalisé ci-dessous.",
"restartRequired": "Redémarrez nanobot pour appliquer les applications et outils MCP mis à jour."
},
"channels": {
"description": "Connectez nanobot aux applications de discussion. L'installation du support ajoute seulement le paquet d'intégration ; la plupart des canaux nécessitent encore des jetons ou des réglages d'espace de travail.",
+30 -9
View File
@@ -504,8 +504,8 @@
"filterInstalled": "Aktif",
"filterNotInstalled": "Tidak aktif",
"searchPlaceholder": "Cari prasetel MCP",
"moreOptions": "Opsi MCP lainnya",
"moreOptionsSubtitle": "Tambahkan server khusus atau impor mcp.json.",
"moreOptions": "Tambahkan server MCP",
"moreOptionsSubtitle": "Hubungkan server MCP khusus atau impor konfigurasi yang ada.",
"customTitle": "MCP khusus",
"customSubtitle": "Tambahkan server MCP stdio, HTTP, atau SSE apa pun.",
"customAction": "Khusus",
@@ -513,9 +513,14 @@
"serverName": "Nama server",
"serverUrl": "URL",
"transport": "Transport",
"authentication": "Autentikasi",
"authNone": "Tidak ada",
"authHeaders": "Header",
"command": "Perintah",
"args": "Argumen JSON",
"headers": "Header JSON",
"oauthAfterSave": "Simpan server, lalu pilih Hubungkan untuk masuk.",
"headersHelp": "Tambahkan header permintaan yang digunakan server ini.",
"env": "Lingkungan JSON",
"timeout": "Batas waktu alat",
"advancedOptions": "Opsi lanjutan",
@@ -542,6 +547,21 @@
"keepExisting": "Biarkan kosong untuk mempertahankan nilai saat ini",
"statusConfigured": "Terkonfigurasi",
"statusMissingCredentials": "Butuh kunci",
"connectingAccount": "Menghubungkan {{name}}",
"connectingLabel": "Menghubungkan…",
"continueSignIn": "Lanjutkan masuk",
"preparingSignIn": "Menyiapkan proses masuk yang aman…",
"openSignInToContinue": "Buka halaman masuk untuk melanjutkan.",
"finishSignInInBrowser": "Selesaikan proses masuk di jendela browser.",
"manualCallbackRequired": "Selesaikan proses masuk, lalu tempel URL callback ke nanobot.",
"manualCallbackHelp": "Setelah menyetujui akses, halaman localhost tidak akan terbuka. Salin URL lengkap dari bilah alamat lalu tempel di sini.",
"finishingConnection": "Menyelesaikan koneksi…",
"activatingTools": "Mengaktifkan alat…",
"connected": "Terhubung.",
"connectionFailed": "Koneksi gagal.",
"connectionCancelled": "Koneksi dibatalkan.",
"reloadFailed": "Anda sudah masuk, tetapi nanobot tidak dapat menghubungkan alat. Coba mulai ulang nanobot.",
"oauthFailed": "Tidak dapat terhubung. Coba masuk lagi.",
"statusMissingDependency": "Butuh dependensi",
"statusComingSoon": "Segera hadir",
"comingSoon": "Segera hadir",
@@ -570,27 +590,28 @@
"apps": {
"description": "Tambahkan alat ke nanobot, lalu gunakan dengan @ di chat.",
"cliLabel": "Aplikasi",
"mcpLabel": "Integrasi",
"mcpLabel": "MCP",
"channelLabel": "Kanal",
"featureLabel": "Fitur",
"filterAll": "Siap",
"filterPlugins": "Plugin",
"filterCli": "Aplikasi",
"filterMcp": "Integrasi",
"filterMcp": "MCP",
"enabledSummary": "{{count}} siap",
"caption": "{{cli}} aplikasi · {{mcp}} integrasi",
"caption": "{{cli}} aplikasi · {{mcp}} alat MCP",
"searchPlaceholder": "Cari aplikasi",
"featured": "Alat",
"mcpTools": "Alat MCP",
"loading": "Memuat aplikasi...",
"empty": "Tidak ada alat yang cocok dengan pencarian Anda.",
"emptyApps": "Tidak ada aplikasi yang tersedia.",
"emptyIntegrations": "Tidak ada integrasi yang tersedia.",
"emptyIntegrations": "Tidak ada alat MCP yang tersedia.",
"emptyReady": "Belum ada alat yang siap.",
"clearSearch": "Hapus pencarian",
"browseApps": "Jelajahi aplikasi",
"browseIntegrations": "Jelajahi integrasi",
"emptyIntegrationsHint": "Tambahkan integrasi khusus di bawah.",
"restartRequired": "Mulai ulang nanobot untuk menerapkan aplikasi dan fitur yang diperbarui."
"browseIntegrations": "Jelajahi alat MCP",
"emptyIntegrationsHint": "Tambahkan server MCP khusus di bawah.",
"restartRequired": "Mulai ulang nanobot untuk menerapkan aplikasi dan alat MCP yang diperbarui."
},
"channels": {
"description": "Hubungkan nanobot ke aplikasi chat. Memasang dukungan hanya menambahkan paket integrasi; sebagian besar kanal tetap memerlukan token atau pengaturan ruang kerja.",
+30 -9
View File
@@ -504,8 +504,8 @@
"filterInstalled": "有効",
"filterNotInstalled": "未有効",
"searchPlaceholder": "MCP プリセットを検索",
"moreOptions": "その他の MCP オプション",
"moreOptionsSubtitle": "カスタムサーバーを追加するか mcp.json をインポートします。",
"moreOptions": "MCP サーバーを追加",
"moreOptionsSubtitle": "カスタム MCP サーバーを接続するか、既存の設定をインポートします。",
"customTitle": "カスタム MCP",
"customSubtitle": "任意の stdio、HTTP、SSE MCP サーバーを追加します。",
"customAction": "カスタム",
@@ -513,9 +513,14 @@
"serverName": "サーバー名",
"serverUrl": "URL",
"transport": "トランスポート",
"authentication": "認証",
"authNone": "なし",
"authHeaders": "ヘッダー",
"command": "コマンド",
"args": "引数 JSON",
"headers": "ヘッダー JSON",
"oauthAfterSave": "サーバーを保存してから、[接続]を選択してサインインします。",
"headersHelp": "このサーバーで使用するリクエストヘッダーを追加します。",
"env": "環境変数 JSON",
"timeout": "ツールのタイムアウト",
"advancedOptions": "詳細オプション",
@@ -542,6 +547,21 @@
"keepExisting": "既存の値を維持するには空欄のままにします",
"statusConfigured": "設定済み",
"statusMissingCredentials": "キーが必要",
"connectingAccount": "{{name}} に接続しています",
"connectingLabel": "接続中…",
"continueSignIn": "サインインを続ける",
"preparingSignIn": "安全なサインインを準備しています…",
"openSignInToContinue": "サインインページを開いて続行してください。",
"finishSignInInBrowser": "ブラウザウィンドウでサインインを完了してください。",
"manualCallbackRequired": "サインインを完了し、コールバック URL を nanobot に貼り付けてください。",
"manualCallbackHelp": "アクセスを承認すると localhost ページは開きません。アドレスバーから完全な URL をコピーして、ここに貼り付けてください。",
"finishingConnection": "接続を完了しています…",
"activatingTools": "ツールを有効にしています…",
"connected": "接続しました。",
"connectionFailed": "接続に失敗しました。",
"connectionCancelled": "接続をキャンセルしました。",
"reloadFailed": "サインインしましたが、nanobot はツールに接続できませんでした。nanobot を再起動してください。",
"oauthFailed": "接続できません。もう一度サインインしてください。",
"statusMissingDependency": "依存関係が必要",
"statusComingSoon": "近日公開",
"comingSoon": "近日公開",
@@ -570,27 +590,28 @@
"apps": {
"description": "nanobot にツールを追加し、チャットで @ を付けて使用できます。",
"cliLabel": "アプリ",
"mcpLabel": "連携",
"mcpLabel": "MCP",
"channelLabel": "チャンネル",
"featureLabel": "機能",
"filterAll": "使用可能",
"filterPlugins": "プラグイン",
"filterCli": "アプリ",
"filterMcp": "連携",
"filterMcp": "MCP",
"enabledSummary": "{{count}} 件使用可能",
"caption": "アプリ {{cli}} 件 · 連携 {{mcp}} 件",
"caption": "アプリ {{cli}} 件 · MCP ツール {{mcp}} 件",
"searchPlaceholder": "アプリを検索",
"featured": "ツール",
"mcpTools": "MCP ツール",
"loading": "アプリを読み込み中...",
"empty": "検索条件に一致するツールはありません。",
"emptyApps": "利用できるアプリはありません。",
"emptyIntegrations": "利用できる連携はありません。",
"emptyIntegrations": "利用できる MCP ツールはありません。",
"emptyReady": "使用可能なツールはまだありません。",
"clearSearch": "検索をクリア",
"browseApps": "アプリを見る",
"browseIntegrations": "連携を見る",
"emptyIntegrationsHint": "下からカスタム連携を追加できます。",
"restartRequired": "更新したアプリと機能を反映するには nanobot を再起動してください。"
"browseIntegrations": "MCP ツールを見る",
"emptyIntegrationsHint": "下からカスタム MCP サーバーを追加できます。",
"restartRequired": "更新したアプリと MCP ツールを反映するには nanobot を再起動してください。"
},
"channels": {
"description": "nanobot をチャットアプリに接続します。サポートのインストールは統合パッケージを追加するだけで、多くのチャンネルでは引き続きトークンやワークスペース設定が必要です。",
+30 -9
View File
@@ -504,8 +504,8 @@
"filterInstalled": "활성화됨",
"filterNotInstalled": "비활성",
"searchPlaceholder": "MCP 프리셋 검색",
"moreOptions": "추가 MCP 옵션",
"moreOptionsSubtitle": "사용자 지정 서버를 추가하거나 mcp.json을 가져옵니다.",
"moreOptions": "MCP 서버 추가",
"moreOptionsSubtitle": "사용자 지정 MCP 서버를 연결하거나 기존 구성을 가져옵니다.",
"customTitle": "사용자 지정 MCP",
"customSubtitle": "stdio, HTTP 또는 SSE MCP 서버를 추가합니다.",
"customAction": "사용자 지정",
@@ -513,9 +513,14 @@
"serverName": "서버 이름",
"serverUrl": "URL",
"transport": "전송 방식",
"authentication": "인증",
"authNone": "없음",
"authHeaders": "헤더",
"command": "명령",
"args": "인자 JSON",
"headers": "헤더 JSON",
"oauthAfterSave": "서버를 저장한 다음 연결을 선택하여 로그인하세요.",
"headersHelp": "이 서버에서 사용하는 요청 헤더를 추가하세요.",
"env": "환경 변수 JSON",
"timeout": "도구 제한 시간",
"advancedOptions": "고급 옵션",
@@ -542,6 +547,21 @@
"keepExisting": "기존 값을 유지하려면 비워 두세요",
"statusConfigured": "구성됨",
"statusMissingCredentials": "키 필요",
"connectingAccount": "{{name}} 연결 중",
"connectingLabel": "연결 중…",
"continueSignIn": "로그인 계속",
"preparingSignIn": "안전한 로그인을 준비하는 중…",
"openSignInToContinue": "계속하려면 로그인 페이지를 여세요.",
"finishSignInInBrowser": "브라우저 창에서 로그인을 완료하세요.",
"manualCallbackRequired": "로그인을 완료한 다음 콜백 URL을 nanobot에 붙여 넣으세요.",
"manualCallbackHelp": "접근을 승인하면 localhost 페이지가 열리지 않습니다. 주소 표시줄에서 전체 URL을 복사해 여기에 붙여 넣으세요.",
"finishingConnection": "연결을 마무리하는 중…",
"activatingTools": "도구를 활성화하는 중…",
"connected": "연결됨.",
"connectionFailed": "연결에 실패했습니다.",
"connectionCancelled": "연결을 취소했습니다.",
"reloadFailed": "로그인했지만 nanobot에서 도구를 연결하지 못했습니다. nanobot을 다시 시작해 보세요.",
"oauthFailed": "연결할 수 없습니다. 다시 로그인해 보세요.",
"statusMissingDependency": "의존성 필요",
"statusComingSoon": "곧 제공",
"comingSoon": "곧 제공",
@@ -570,27 +590,28 @@
"apps": {
"description": "nanobot에 도구를 추가한 뒤 채팅에서 @로 사용하세요.",
"cliLabel": "앱",
"mcpLabel": "연동",
"mcpLabel": "MCP",
"channelLabel": "채널",
"featureLabel": "기능",
"filterAll": "사용 가능",
"filterPlugins": "플러그인",
"filterCli": "앱",
"filterMcp": "연동",
"filterMcp": "MCP",
"enabledSummary": "{{count}}개 사용 가능",
"caption": "앱 {{cli}}개 · 연동 {{mcp}}개",
"caption": "앱 {{cli}}개 · MCP 도구 {{mcp}}개",
"searchPlaceholder": "앱 검색",
"featured": "도구",
"mcpTools": "MCP 도구",
"loading": "앱을 불러오는 중...",
"empty": "검색과 일치하는 도구가 없습니다.",
"emptyApps": "사용 가능한 앱이 없습니다.",
"emptyIntegrations": "사용 가능한 연동이 없습니다.",
"emptyIntegrations": "사용 가능한 MCP 도구가 없습니다.",
"emptyReady": "아직 준비된 도구가 없습니다.",
"clearSearch": "검색 지우기",
"browseApps": "앱 둘러보기",
"browseIntegrations": "연동 둘러보기",
"emptyIntegrationsHint": "아래에서 사용자 지정 연동을 추가하세요.",
"restartRequired": "업데이트된 앱과 기능을 적용하려면 nanobot을 다시 시작하세요."
"browseIntegrations": "MCP 도구 둘러보기",
"emptyIntegrationsHint": "아래에서 사용자 지정 MCP 서버를 추가하세요.",
"restartRequired": "업데이트된 앱과 MCP 도구를 적용하려면 nanobot을 다시 시작하세요."
},
"channels": {
"description": "nanobot을 채팅 앱에 연결합니다. 지원 설치는 통합 패키지만 추가하며, 대부분의 채널은 여전히 토큰이나 워크스페이스 설정이 필요합니다.",
+30 -9
View File
@@ -319,8 +319,8 @@
"filterInstalled": "Habilitadas",
"filterNotInstalled": "Não habilitadas",
"searchPlaceholder": "Buscar predefinições MCP",
"moreOptions": "Adicionar integração",
"moreOptionsSubtitle": "Conecte um servidor de ferramentas personalizado ou importe uma configuração existente.",
"moreOptions": "Adicionar servidor MCP",
"moreOptionsSubtitle": "Conecte um servidor MCP personalizado ou importe uma configuração existente.",
"customTitle": "MCP personalizado",
"customSubtitle": "Adicione qualquer servidor MCP stdio, HTTP ou SSE.",
"customAction": "Personalizado",
@@ -328,9 +328,14 @@
"serverName": "Nome do servidor",
"serverUrl": "URL",
"transport": "Transporte",
"authentication": "Autenticação",
"authNone": "Nenhuma",
"authHeaders": "Cabeçalhos",
"command": "Comando",
"args": "Argumentos JSON",
"headers": "Cabeçalhos JSON",
"oauthAfterSave": "Salve o servidor e selecione Conectar para entrar.",
"headersHelp": "Adicione os cabeçalhos de solicitação usados por este servidor.",
"env": "Ambiente JSON",
"timeout": "Tempo limite da ferramenta",
"advancedOptions": "Opções avançadas",
@@ -357,6 +362,21 @@
"keepExisting": "Deixe em branco para manter o valor atual",
"statusConfigured": "Configurado",
"statusMissingCredentials": "Precisa de chave",
"connectingAccount": "Conectando {{name}}",
"connectingLabel": "Conectando…",
"continueSignIn": "Continuar login",
"preparingSignIn": "Preparando login seguro…",
"openSignInToContinue": "Abra a página de login para continuar.",
"finishSignInInBrowser": "Conclua o login na janela do navegador.",
"manualCallbackRequired": "Conclua o login e cole a URL de callback no nanobot.",
"manualCallbackHelp": "Depois de autorizar o acesso, a página localhost não será carregada. Copie a URL completa da barra de endereços e cole-a aqui.",
"finishingConnection": "Finalizando conexão…",
"activatingTools": "Ativando ferramentas…",
"connected": "Conectado.",
"connectionFailed": "Falha na conexão.",
"connectionCancelled": "Conexão cancelada.",
"reloadFailed": "Login concluído, mas o nanobot não conseguiu conectar as ferramentas. Tente reiniciar o nanobot.",
"oauthFailed": "Não foi possível conectar. Tente fazer login novamente.",
"statusMissingDependency": "Precisa de dependência",
"statusComingSoon": "Em breve",
"comingSoon": "Em breve",
@@ -584,27 +604,28 @@
"apps": {
"description": "Adicione ferramentas ao nanobot e mencione-as com @ na conversa.",
"cliLabel": "Aplicativo",
"mcpLabel": "Integração",
"mcpLabel": "MCP",
"channelLabel": "Canal",
"featureLabel": "Recurso",
"filterAll": "Prontos",
"filterPlugins": "Complementos",
"filterCli": "Aplicativos",
"filterMcp": "Integrações",
"filterMcp": "MCP",
"enabledSummary": "{{count}} prontos",
"caption": "{{cli}} aplicativos · {{mcp}} integrações",
"caption": "{{cli}} aplicativos · {{mcp}} ferramentas MCP",
"searchPlaceholder": "Buscar ferramentas",
"featured": "Ferramentas",
"mcpTools": "Ferramentas MCP",
"loading": "Carregando aplicativos...",
"empty": "Nenhuma ferramenta corresponde à sua busca.",
"emptyApps": "Nenhum aplicativo disponível.",
"emptyIntegrations": "Nenhuma integração disponível.",
"emptyIntegrations": "Nenhuma ferramenta MCP disponível.",
"emptyReady": "Ainda não há ferramentas prontas.",
"clearSearch": "Limpar busca",
"browseApps": "Explorar aplicativos",
"browseIntegrations": "Explorar integrações",
"emptyIntegrationsHint": "Adicione uma integração personalizada abaixo.",
"restartRequired": "Reinicie o nanobot para aplicar os aplicativos e integrações atualizados."
"browseIntegrations": "Explorar ferramentas MCP",
"emptyIntegrationsHint": "Adicione um servidor MCP personalizado abaixo.",
"restartRequired": "Reinicie o nanobot para aplicar os aplicativos e ferramentas MCP atualizados."
},
"channels": {
"description": "Conecte aplicativos de chat, e-mail e WebUI ao nanobot.",
+30 -9
View File
@@ -504,8 +504,8 @@
"filterInstalled": "Đã bật",
"filterNotInstalled": "Chưa bật",
"searchPlaceholder": "Tìm cấu hình đặt trước MCP",
"moreOptions": "Tùy chọn MCP khác",
"moreOptionsSubtitle": "Thêm máy chủ tùy chỉnh hoặc nhập mcp.json.",
"moreOptions": "Thêm máy ch MCP",
"moreOptionsSubtitle": "Kết nối máy chủ MCP tùy chỉnh hoặc nhập cấu hình hiện có.",
"customTitle": "MCP tùy chỉnh",
"customSubtitle": "Thêm bất kỳ máy chủ MCP stdio, HTTP hoặc SSE nào.",
"customAction": "Tùy chỉnh",
@@ -513,9 +513,14 @@
"serverName": "Tên máy chủ",
"serverUrl": "URL",
"transport": "Giao thức truyền",
"authentication": "Xác thực",
"authNone": "Không có",
"authHeaders": "Header",
"command": "Lệnh",
"args": "Đối số JSON",
"headers": "Header JSON",
"oauthAfterSave": "Lưu máy chủ, sau đó chọn Kết nối để đăng nhập.",
"headersHelp": "Thêm các header yêu cầu mà máy chủ này sử dụng.",
"env": "Môi trường JSON",
"timeout": "Thời gian chờ công cụ",
"advancedOptions": "Tùy chọn nâng cao",
@@ -542,6 +547,21 @@
"keepExisting": "Để trống để giữ giá trị hiện tại",
"statusConfigured": "Đã cấu hình",
"statusMissingCredentials": "Cần khóa",
"connectingAccount": "Đang kết nối {{name}}",
"connectingLabel": "Đang kết nối…",
"continueSignIn": "Tiếp tục đăng nhập",
"preparingSignIn": "Đang chuẩn bị đăng nhập an toàn…",
"openSignInToContinue": "Mở trang đăng nhập để tiếp tục.",
"finishSignInInBrowser": "Hoàn tất đăng nhập trong cửa sổ trình duyệt.",
"manualCallbackRequired": "Hoàn tất đăng nhập, rồi dán URL callback vào nanobot.",
"manualCallbackHelp": "Sau khi phê duyệt quyền truy cập, trang localhost sẽ không tải được. Hãy sao chép URL đầy đủ từ thanh địa chỉ và dán vào đây.",
"finishingConnection": "Đang hoàn tất kết nối…",
"activatingTools": "Đang kích hoạt công cụ…",
"connected": "Đã kết nối.",
"connectionFailed": "Kết nối thất bại.",
"connectionCancelled": "Đã hủy kết nối.",
"reloadFailed": "Đã đăng nhập nhưng nanobot không thể kết nối các công cụ. Hãy thử khởi động lại nanobot.",
"oauthFailed": "Không thể kết nối. Hãy thử đăng nhập lại.",
"statusMissingDependency": "Cần phụ thuộc",
"statusComingSoon": "Sắp ra mắt",
"comingSoon": "Sắp ra mắt",
@@ -570,27 +590,28 @@
"apps": {
"description": "Thêm công cụ vào nanobot, sau đó dùng @ trong cuộc trò chuyện.",
"cliLabel": "Ứng dụng",
"mcpLabel": "Tích hợp",
"mcpLabel": "MCP",
"channelLabel": "Kênh",
"featureLabel": "Tính năng",
"filterAll": "Sẵn sàng",
"filterPlugins": "Plugin",
"filterCli": "Ứng dụng",
"filterMcp": "Tích hợp",
"filterMcp": "MCP",
"enabledSummary": "{{count}} sẵn sàng",
"caption": "{{cli}} ứng dụng · {{mcp}} tích hợp",
"caption": "{{cli}} ứng dụng · {{mcp}} công cụ MCP",
"searchPlaceholder": "Tìm ứng dụng",
"featured": "Công cụ",
"mcpTools": "Công cụ MCP",
"loading": "Đang tải ứng dụng...",
"empty": "Không có công cụ phù hợp với tìm kiếm của bạn.",
"emptyApps": "Không có ứng dụng nào.",
"emptyIntegrations": "Không có tích hợp nào.",
"emptyIntegrations": "Không có công cụ MCP nào.",
"emptyReady": "Chưa có công cụ nào sẵn sàng.",
"clearSearch": "Xóa tìm kiếm",
"browseApps": "Xem ứng dụng",
"browseIntegrations": "Xem tích hợp",
"emptyIntegrationsHint": "Thêm tích hợp tùy chỉnh ở bên dưới.",
"restartRequired": "Khởi động lại nanobot để áp dụng ứng dụng và tính năng đã cập nhật."
"browseIntegrations": "Xem công cụ MCP",
"emptyIntegrationsHint": "Thêm máy chủ MCP tùy chỉnh ở bên dưới.",
"restartRequired": "Khởi động lại nanobot để áp dụng ứng dụng và công cụ MCP đã cập nhật."
},
"channels": {
"description": "Kết nối nanobot với các ứng dụng chat. Cài đặt hỗ trợ chỉ thêm gói tích hợp; hầu hết kênh vẫn cần token hoặc cấu hình không gian làm việc.",
+31 -10
View File
@@ -319,8 +319,8 @@
"filterInstalled": "已启用",
"filterNotInstalled": "未启用",
"searchPlaceholder": "搜索 MCP 预设",
"moreOptions": "添加集成",
"moreOptionsSubtitle": "连接自定义工具服务,或导入已有配置。",
"moreOptions": "添加 MCP 服务",
"moreOptionsSubtitle": "连接自定义 MCP 服务,或导入已有配置。",
"customTitle": "自定义 MCP",
"customSubtitle": "添加任意 stdio、HTTP 或 SSE MCP 服务。",
"customAction": "自定义",
@@ -328,9 +328,14 @@
"serverName": "服务名",
"serverUrl": "URL",
"transport": "传输方式",
"authentication": "身份验证",
"authNone": "无",
"authHeaders": "请求头",
"command": "命令",
"args": "Args JSON",
"headers": "Headers JSON",
"headers": "请求头 JSON",
"oauthAfterSave": "保存服务器后,选择“连接”以完成登录。",
"headersHelp": "添加此服务器要求的请求头。",
"env": "Env JSON",
"timeout": "工具超时",
"advancedOptions": "高级选项",
@@ -357,6 +362,21 @@
"keepExisting": "留空则保留当前值",
"statusConfigured": "已配置",
"statusMissingCredentials": "需要密钥",
"connectingAccount": "正在连接 {{name}}",
"connectingLabel": "正在连接…",
"continueSignIn": "继续登录",
"preparingSignIn": "正在准备安全登录…",
"openSignInToContinue": "打开登录页面以继续。",
"finishSignInInBrowser": "请在浏览器窗口中完成登录。",
"manualCallbackRequired": "完成登录后,将回调 URL 粘贴到 nanobot。",
"manualCallbackHelp": "授权后,localhost 页面将无法打开。请复制地址栏中的完整 URL 并粘贴到这里。",
"finishingConnection": "正在完成连接…",
"activatingTools": "正在启用工具…",
"connected": "已连接。",
"connectionFailed": "连接失败。",
"connectionCancelled": "已取消连接。",
"reloadFailed": "已登录,但 nanobot 无法连接这些工具。请尝试重启 nanobot。",
"oauthFailed": "无法连接,请重新登录。",
"statusMissingDependency": "缺少依赖",
"statusComingSoon": "暂不支持",
"comingSoon": "即将推出",
@@ -584,27 +604,28 @@
"apps": {
"description": "将工具接入 nanobot,然后在对话中通过 @ 调用。",
"cliLabel": "应用",
"mcpLabel": "集成",
"mcpLabel": "MCP",
"channelLabel": "渠道",
"featureLabel": "能力",
"filterAll": "可用",
"filterPlugins": "插件",
"filterCli": "应用",
"filterMcp": "集成",
"filterMcp": "MCP",
"enabledSummary": "{{count}} 个可用",
"caption": "{{cli}} 个应用 · {{mcp}} 个集成",
"caption": "{{cli}} 个应用 · {{mcp}} 个 MCP 工具",
"searchPlaceholder": "搜索工具",
"featured": "工具",
"mcpTools": "MCP 工具",
"loading": "正在加载应用...",
"empty": "没有与搜索条件匹配的工具。",
"emptyApps": "暂无可用应用。",
"emptyIntegrations": "暂无可用集成。",
"emptyIntegrations": "暂无可用 MCP 工具。",
"emptyReady": "还没有就绪的工具。",
"clearSearch": "清除搜索",
"browseApps": "浏览应用",
"browseIntegrations": "浏览集成",
"emptyIntegrationsHint": "可在下方添加自定义集成。",
"restartRequired": "重启 nanobot 以应用更新后的应用和集成。"
"browseIntegrations": "浏览 MCP 工具",
"emptyIntegrationsHint": "可在下方添加自定义 MCP 服务器。",
"restartRequired": "重启 nanobot 以应用更新后的应用和 MCP 工具。"
},
"channels": {
"description": "把聊天应用、邮箱和 WebUI 连接到 nanobot。",
+31 -10
View File
@@ -504,8 +504,8 @@
"filterInstalled": "已啟用",
"filterNotInstalled": "未啟用",
"searchPlaceholder": "搜尋 MCP 預設",
"moreOptions": "新增整合",
"moreOptionsSubtitle": "連線自訂工具伺服器,或匯入現有設定。",
"moreOptions": "新增 MCP 服務",
"moreOptionsSubtitle": "連線自訂 MCP 服務,或匯入現有設定。",
"customTitle": "自訂 MCP",
"customSubtitle": "新增任何 stdio、HTTP 或 SSE MCP 伺服器。",
"customAction": "自訂",
@@ -513,9 +513,14 @@
"serverName": "伺服器名稱",
"serverUrl": "URL",
"transport": "傳輸方式",
"authentication": "驗證方式",
"authNone": "無",
"authHeaders": "請求標頭",
"command": "指令",
"args": "Args JSON",
"headers": "Headers JSON",
"headers": "請求標頭 JSON",
"oauthAfterSave": "儲存伺服器後,選擇「連線」以登入。",
"headersHelp": "新增此伺服器使用的請求標頭。",
"env": "Env JSON",
"timeout": "工具逾時",
"advancedOptions": "進階選項",
@@ -542,6 +547,21 @@
"keepExisting": "留空以保留目前值",
"statusConfigured": "已設定",
"statusMissingCredentials": "需要金鑰",
"connectingAccount": "正在連接 {{name}}",
"connectingLabel": "正在連線…",
"continueSignIn": "繼續登入",
"preparingSignIn": "正在準備安全登入…",
"openSignInToContinue": "開啟登入頁面以繼續。",
"finishSignInInBrowser": "請在瀏覽器視窗中完成登入。",
"manualCallbackRequired": "完成登入後,將回呼 URL 貼到 nanobot。",
"manualCallbackHelp": "授權後,localhost 頁面將無法開啟。請複製網址列中的完整 URL 並貼到這裡。",
"finishingConnection": "正在完成連線…",
"activatingTools": "正在啟用工具…",
"connected": "已連線。",
"connectionFailed": "連線失敗。",
"connectionCancelled": "已取消連線。",
"reloadFailed": "已登入,但 nanobot 無法連接這些工具。請嘗試重新啟動 nanobot。",
"oauthFailed": "無法連線,請重新登入。",
"statusMissingDependency": "需要相依項",
"statusComingSoon": "即將推出",
"comingSoon": "即將推出",
@@ -570,27 +590,28 @@
"apps": {
"description": "將工具新增至 nanobot,再於聊天中使用 @ 指定工具。",
"cliLabel": "應用程式",
"mcpLabel": "整合",
"mcpLabel": "MCP",
"channelLabel": "通訊管道",
"featureLabel": "功能",
"filterAll": "就緒",
"filterPlugins": "外掛程式",
"filterCli": "應用程式",
"filterMcp": "整合",
"filterMcp": "MCP",
"enabledSummary": "{{count}} 個就緒",
"caption": "{{cli}} 個應用程式 · {{mcp}} 個整合服務",
"caption": "{{cli}} 個應用程式 · {{mcp}} 個 MCP 工具",
"searchPlaceholder": "搜尋工具",
"featured": "工具",
"mcpTools": "MCP 工具",
"loading": "正在載入應用程式…",
"empty": "沒有符合搜尋條件的工具。",
"emptyApps": "沒有可用的應用程式。",
"emptyIntegrations": "沒有可用的整合服務。",
"emptyIntegrations": "沒有可用的 MCP 工具。",
"emptyReady": "尚無就緒的工具。",
"clearSearch": "清除搜尋",
"browseApps": "瀏覽應用程式",
"browseIntegrations": "瀏覽整合服務",
"emptyIntegrationsHint": "可在下方新增自訂整合服務。",
"restartRequired": "重新啟動 nanobot 以套用更新後的應用程式與整合服務。"
"browseIntegrations": "瀏覽 MCP 工具",
"emptyIntegrationsHint": "可在下方新增自訂 MCP 伺服器。",
"restartRequired": "重新啟動 nanobot 以套用更新後的應用程式與 MCP 工具。"
},
"channels": {
"description": "將聊天應用程式、電子郵件與 WebUI 連線至 nanobot。",
+64 -1
View File
@@ -10,6 +10,7 @@ import type {
FilePreviewPayload,
ImageGenerationSettingsUpdate,
McpPresetsPayload,
McpOAuthFlowPayload,
MarketplaceProvider,
NanobotFeaturesPayload,
ModelConfigurationCreate,
@@ -97,7 +98,19 @@ async function request<T>(
);
if (!res.ok) {
const text = typeof res.text === "function" ? (await res.text()).trim() : "";
throw new ApiError(res.status, text || `HTTP ${res.status}`);
let message = text;
if (text.startsWith("{")) {
try {
const payload: unknown = JSON.parse(text);
if (payload && typeof payload === "object") {
const error = (payload as { error?: unknown }).error;
if (typeof error === "string" && error.trim()) message = error.trim();
}
} catch {
// Preserve non-JSON error bodies exactly as returned by the gateway.
}
}
throw new ApiError(res.status, message || `HTTP ${res.status}`);
}
const contentType = res.headers?.get?.("content-type") ?? "";
if (contentType && !contentType.toLowerCase().includes("application/json")) {
@@ -686,6 +699,56 @@ export async function fetchMcpPresets(
);
}
export async function startMcpOAuth(
transport: WebUIMutationTransport,
name: string,
reset: boolean = false,
): Promise<McpOAuthFlowPayload> {
return mutation<McpOAuthFlowPayload>(
transport,
"settings.mcp.oauth_start",
{ name, ...(reset ? { reset: true } : {}) },
30_000,
);
}
export async function fetchMcpOAuthStatus(
token: string,
flowId: string,
base: string = "",
): Promise<McpOAuthFlowPayload> {
const query = new URLSearchParams({ flow_id: flowId });
return request<McpOAuthFlowPayload>(
`${base}/api/settings/mcp-oauth/status?${query}`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function completeMcpOAuth(
transport: WebUIMutationTransport,
flowId: string,
callbackUrl: string,
): Promise<McpOAuthFlowPayload> {
return mutation<McpOAuthFlowPayload>(
transport,
"settings.mcp.oauth_complete",
{ flow_id: flowId, callback_url: callbackUrl },
);
}
export async function cancelMcpOAuth(
transport: WebUIMutationTransport,
flowId: string,
): Promise<McpOAuthFlowPayload> {
return mutation<McpOAuthFlowPayload>(
transport,
"settings.mcp.oauth_cancel",
{ flow_id: flowId },
);
}
export async function fetchProviderModels(
token: string,
provider: string,
+25
View File
@@ -956,6 +956,7 @@ export interface McpPresetInfo {
description: string;
docs_url: string;
transport: "stdio" | "streamableHttp" | "sse" | "oauth" | string;
auth?: "oauth" | null;
requires: string;
note: string;
install_supported: boolean;
@@ -976,6 +977,30 @@ export interface McpPresetInfo {
manifest?: AppManifest;
}
export type McpOAuthFlowStatus =
| "starting"
| "authorization_required"
| "connecting"
| "authorized"
| "connected"
| "failed"
| "cancelled";
export interface McpOAuthFlowPayload {
flow_id: string;
name: string;
status: McpOAuthFlowStatus;
expires_in: number;
authorization_url?: string;
completion_input?: "callback_url";
error?: string;
hot_reload?: {
ok: boolean;
message?: string;
requires_restart?: boolean;
};
}
export interface McpPresetsPayload {
presets: McpPresetInfo[];
installed_count: number;
+63
View File
@@ -1,7 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
cancelMcpOAuth,
configureChannel,
completeMcpOAuth,
completeProviderOAuth,
createModelConfiguration,
createProviderSettings,
@@ -14,6 +16,7 @@ import {
fetchApiService,
fetchCliApps,
fetchInstalledCliApps,
fetchMcpOAuthStatus,
fetchMcpPresets,
fetchMarketplaceSkillTrends,
fetchNanobotFeatures,
@@ -41,6 +44,7 @@ import {
saveCustomMcpServer,
searchMarketplaceSkills,
startApiService,
startMcpOAuth,
stopApiService,
cancelChannelConnect,
pollChannelConnect,
@@ -545,6 +549,23 @@ describe("webui API helpers", () => {
});
});
it("extracts user-facing messages from JSON API errors", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
status: 400,
headers: new Headers({ "content-type": "application/json" }),
text: async () => JSON.stringify({ error: "Paste the complete callback URL." }),
}),
);
await expect(fetchMcpOAuthStatus("tok", "flow-123")).rejects.toMatchObject({
status: 400,
message: "Paste the complete callback URL.",
});
});
it("times out when an API request never responds", async () => {
vi.useFakeTimers();
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {})));
@@ -882,6 +903,35 @@ describe("webui API helpers", () => {
{ name: "browserbase", browserbase_api_key: "bb_live_test" },
20_000,
);
await startMcpOAuth(mutationTransport, "notion", true);
expect(requestMutation).toHaveBeenCalledWith(
"settings.mcp.oauth_start",
{ name: "notion", reset: true },
30_000,
);
await fetchMcpOAuthStatus("tok", "flow-123");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/mcp-oauth/status?flow_id=flow-123",
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
);
const callbackUrl =
"http://127.0.0.1:8765/auth/mcp/callback?code=secret&state=state-123";
await completeMcpOAuth(mutationTransport, "flow-123", callbackUrl);
expect(requestMutation).toHaveBeenCalledWith(
"settings.mcp.oauth_complete",
{ flow_id: "flow-123", callback_url: callbackUrl },
20_000,
);
await cancelMcpOAuth(mutationTransport, "flow-123");
expect(requestMutation).toHaveBeenCalledWith(
"settings.mcp.oauth_cancel",
{ flow_id: "flow-123" },
20_000,
);
});
it("serializes custom MCP, mcp.json import, and tool allowlist actions", async () => {
@@ -899,6 +949,19 @@ describe("webui API helpers", () => {
20_000,
);
const oauthCustom = {
name: "company-mcp",
transport: "streamableHttp",
url: "https://mcp.example.com/mcp",
auth: "oauth",
};
await saveCustomMcpServer(mutationTransport, oauthCustom);
expect(requestMutation).toHaveBeenLastCalledWith(
"settings.mcp.custom",
oauthCustom,
20_000,
);
await importMcpConfig(
mutationTransport,
'{"mcpServers":{"docs":{"command":"npx"}}}',
+12
View File
@@ -75,6 +75,18 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
"settings.apps.description",
"settings.apps.caption",
"settings.apps.restartRequired",
"settings.mcp.connectingAccount",
"settings.mcp.continueSignIn",
"settings.mcp.preparingSignIn",
"settings.mcp.openSignInToContinue",
"settings.mcp.finishSignInInBrowser",
"settings.mcp.finishingConnection",
"settings.mcp.activatingTools",
"settings.mcp.connected",
"settings.mcp.connectionFailed",
"settings.mcp.connectionCancelled",
"settings.mcp.reloadFailed",
"settings.mcp.oauthFailed",
"settings.skills.views",
"settings.skills.installedTab",
"settings.skills.discoverTab",
@@ -0,0 +1,566 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import {
installSettingsViewTestHooks,
jsonResponse,
renderSettingsView,
requestMutationMock,
settingsPayload,
} from "@/tests/settings-test-utils";
const xmindMcpPreset = {
name: "xmind",
display_name: "Xmind",
category: "productivity",
description: "Create, read, and edit cloud mind maps through Xmind.",
docs_url: "https://xmind.com/user-guide/xmind-mcp",
transport: "streamableHttp",
auth: "oauth" as const,
requires: "Xmind account",
note: "Connects securely in your browser with Xmind OAuth.",
install_supported: true,
installed: false,
configured: false,
available: false,
status: "not_installed",
logo_url: null,
brand_color: "#F4B41A",
required_fields: [],
connection_summary: "",
enabled_tools: ["*"],
source: "preset",
};
describe("SettingsView Apps catalog", () => {
installSettingsViewTestHooks();
it("connects an OAuth MCP from the Apps catalog without manual callback input", async () => {
let connected = false;
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({
presets: [connected
? {
...xmindMcpPreset,
installed: true,
configured: true,
available: true,
status: "configured",
connection_summary: "https://app.xmind.com/api/mcp",
}
: xmindMcpPreset],
installed_count: connected ? 1 : 0,
});
}
if (url === "/api/settings/mcp-oauth/status?flow_id=flow-123") {
connected = true;
return jsonResponse({
flow_id: "flow-123",
name: "xmind",
status: "connected",
expires_in: 295,
hot_reload: {
ok: false,
requires_restart: false,
connected: ["xmind"],
failed: ["notion"],
message: "MCP config reloaded, but some servers did not connect: notion",
},
});
}
return { ok: false, status: 404, text: async () => "Not found" } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockImplementation(async (action: string) => {
if (action === "settings.mcp.oauth_start") {
return {
flow_id: "flow-123",
name: "xmind",
status: "authorization_required",
expires_in: 300,
authorization_url: "https://accounts.xmind.test/authorize?state=state-123",
};
}
return settingsPayload();
});
const replace = vi.fn();
const popup = {
opener: window,
closed: false,
location: { replace },
document: { title: "", body: { textContent: "" } },
focus: vi.fn(),
close: vi.fn(),
};
const open = vi.fn(() => popup);
vi.stubGlobal("open", open);
renderSettingsView({ initialSection: "apps" });
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
expect(screen.getByText("MCP tools")).toBeInTheDocument();
const connectButton = await screen.findByRole("button", { name: "Connect Xmind" });
expect(connectButton).toHaveTextContent("Connect");
fireEvent.click(connectButton);
expect(open).toHaveBeenCalledWith(
"about:blank",
"nanobot-mcp-oauth",
"popup,width=560,height=720,resizable=yes,scrollbars=yes",
);
await waitFor(() => expect(replace).toHaveBeenCalledWith(
"https://accounts.xmind.test/authorize?state=state-123",
));
expect(popup.opener).toBeNull();
expect(screen.queryByRole("textbox", { name: /authorization/i })).not.toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent(
"Finish signing in in the browser window.",
);
expect(screen.getByRole("button", { name: "Connecting Xmind" })).toHaveTextContent(
"Connecting…",
);
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
expect(await screen.findByRole("button", { name: "Xmind: Configured" }, { timeout: 2500 }))
.toHaveTextContent("Configured");
expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument();
expect(screen.queryByText("Xmind connected.")).not.toBeInTheDocument();
expect(screen.queryByText(/some servers did not connect: notion/i)).not.toBeInTheDocument();
expect(popup.close).toHaveBeenCalledTimes(1);
expect(replace).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/mcp-oauth/status?flow_id=flow-123",
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
);
});
it("configures OAuth for a custom remote MCP without importing JSON", async () => {
const customPreset = {
...xmindMcpPreset,
name: "team-mcp",
display_name: "team-mcp",
source: "custom",
installed: true,
status: "authorization_required",
connection_summary: "https://mcp.example.com/mcp",
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, text: async () => "Not found" } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockImplementation(async (action: string) => {
if (action === "settings.mcp.custom") {
return {
presets: [customPreset],
installed_count: 1,
hot_reload: {
ok: false,
message: "MCP config reloaded, but some servers did not connect: team-mcp",
failed: ["team-mcp"],
},
last_action: { ok: true, message: "Saved custom MCP server team-mcp." },
};
}
return settingsPayload();
});
renderSettingsView({ initialSection: "apps" });
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
fireEvent.click(await screen.findByRole("button", { name: "Custom" }));
expect(screen.queryByText("Authentication")).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Server name"), {
target: { value: "team-mcp" },
});
fireEvent.click(screen.getByRole("button", { name: "HTTP" }));
fireEvent.change(screen.getByLabelText("URL"), {
target: { value: "https://mcp.example.com/mcp" },
});
const authentication = screen.getByRole("group", { name: "Authentication" });
const oauth = within(authentication).getByRole("button", { name: "OAuth" });
expect(oauth).toHaveAttribute("aria-pressed", "false");
fireEvent.click(within(authentication).getByRole("button", { name: "Headers" }));
fireEvent.change(screen.getByLabelText("Headers JSON"), {
target: { value: '{"Authorization":"Bearer stale"}' },
});
expect(screen.getByText("Add the request headers used by this server.")).toBeInTheDocument();
fireEvent.click(oauth);
expect(oauth).toHaveAttribute("aria-pressed", "true");
expect(screen.queryByLabelText("Headers JSON")).not.toBeInTheDocument();
expect(
screen.getByText("Save the server, then select Connect to sign in."),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Save MCP" }));
await waitFor(() => {
const saveCall = requestMutationMock.mock.calls.find(
([action]) => action === "settings.mcp.custom",
);
expect(saveCall).toBeDefined();
const values = saveCall?.[1] as Record<string, string>;
expect(values).toMatchObject({
name: "team-mcp",
transport: "streamableHttp",
url: "https://mcp.example.com/mcp",
auth: "oauth",
});
expect(values).not.toHaveProperty("headers");
expect(saveCall?.[2]).toBe(20_000);
});
expect(await screen.findByRole("button", { name: "Connect team-mcp" }))
.toBeInTheDocument();
expect(
screen.queryByText("MCP config reloaded, but some servers did not connect: team-mcp"),
).not.toBeInTheDocument();
});
it("offers a pasted callback flow when the remote WebUI uses HTTP", async () => {
let completed = false;
const callbackUrl =
"http://127.0.0.1:8765/auth/mcp/callback?code=oauth-code&state=manual-state";
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({
presets: [completed
? {
...xmindMcpPreset,
installed: true,
configured: true,
available: true,
status: "configured",
connection_summary: "https://app.xmind.com/api/mcp",
}
: xmindMcpPreset],
installed_count: completed ? 1 : 0,
});
}
if (url === "/api/settings/mcp-oauth/status?flow_id=flow-manual") {
return jsonResponse({
flow_id: "flow-manual",
name: "xmind",
status: completed ? "connected" : "authorization_required",
expires_in: 298,
completion_input: "callback_url",
authorization_url: completed
? undefined
: "https://accounts.xmind.test/authorize?state=manual-state",
hot_reload: completed ? { ok: true, requires_restart: false } : undefined,
});
}
return { ok: false, status: 404, text: async () => "Not found" } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockImplementation(async (action: string) => {
if (action === "settings.mcp.oauth_start") {
return {
flow_id: "flow-manual",
name: "xmind",
status: "authorization_required",
expires_in: 300,
completion_input: "callback_url",
authorization_url: "https://accounts.xmind.test/authorize?state=manual-state",
};
}
if (action === "settings.mcp.oauth_complete") {
completed = true;
return {
flow_id: "flow-manual",
name: "xmind",
status: "connecting",
expires_in: 299,
completion_input: "callback_url",
};
}
return settingsPayload();
});
const popup = {
opener: window,
closed: false,
location: { replace: vi.fn() },
document: { title: "", body: { textContent: "" } },
focus: vi.fn(),
close: vi.fn(),
};
vi.stubGlobal("open", vi.fn(() => popup));
renderSettingsView({ initialSection: "apps" });
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" }));
const callbackInput = await screen.findByRole("textbox", { name: "Full callback URL" });
expect(screen.getByText(/localhost page will not load/i)).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent(
"Finish signing in, then paste the callback URL into nanobot.",
);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
fireEvent.change(callbackInput, { target: { value: callbackUrl } });
fireEvent.click(screen.getByRole("button", { name: "Finish sign-in" }));
await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith(
"settings.mcp.oauth_complete",
{ flow_id: "flow-manual", callback_url: callbackUrl },
20_000,
));
expect(await screen.findByRole("button", { name: "Xmind: Configured" }, { timeout: 2500 }))
.toHaveTextContent("Configured");
expect(screen.queryByRole("textbox", { name: "Full callback URL" })).not.toBeInTheDocument();
expect(popup.close).toHaveBeenCalledTimes(1);
});
it("lets the user cancel an active OAuth connection after closing the popup", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [xmindMcpPreset], installed_count: 0 });
}
if (url === "/api/settings/mcp-oauth/status?flow_id=flow-cancel") {
return new Promise<Response>(() => {});
}
return { ok: false, status: 404, text: async () => "Not found" } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockImplementation(async (action: string) => {
if (action === "settings.mcp.oauth_start") {
return {
flow_id: "flow-cancel",
name: "xmind",
status: "authorization_required",
expires_in: 300,
authorization_url: "https://accounts.xmind.test/authorize?state=cancel",
};
}
if (action === "settings.mcp.oauth_cancel") {
return {
flow_id: "flow-cancel",
name: "xmind",
status: "cancelled",
expires_in: 299,
};
}
return settingsPayload();
});
const popup = {
opener: window,
closed: false,
location: { replace: vi.fn() },
document: { title: "", body: { textContent: "" } },
focus: vi.fn(),
close: vi.fn(),
};
vi.stubGlobal("open", vi.fn(() => popup));
renderSettingsView({ initialSection: "apps" });
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" }));
const cancelButton = await screen.findByRole("button", { name: "Cancel" });
expect(screen.getByRole("button", { name: "Connecting Xmind" })).toBeInTheDocument();
popup.closed = true;
fireEvent.click(cancelButton);
await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith(
"settings.mcp.oauth_cancel",
{ flow_id: "flow-cancel" },
20_000,
));
expect(await screen.findByRole("button", { name: "Connect Xmind" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Connecting Xmind" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument();
expect(popup.close).not.toHaveBeenCalled();
});
it("silently removes an MCP when the card already shows the result", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({
presets: [{
...xmindMcpPreset,
installed: true,
configured: true,
available: true,
status: "configured",
connection_summary: "https://app.xmind.com/api/mcp",
}],
installed_count: 1,
});
}
return { ok: false, status: 404, text: async () => "Not found" } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockResolvedValueOnce({
presets: [xmindMcpPreset],
installed_count: 0,
requires_restart: false,
hot_reload: {
ok: true,
message: "MCP config reloaded without restarting nanobot.",
},
last_action: {
ok: true,
message: "Removed MCP preset for Xmind. MCP config reloaded without restarting nanobot.",
removed: true,
},
});
renderSettingsView({ initialSection: "apps" });
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
fireEvent.click(await screen.findByRole("button", { name: "Remove" }));
await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith(
"settings.mcp.remove",
{ name: "xmind" },
20_000,
));
expect(await screen.findByRole("button", { name: "Connect Xmind" })).toBeInTheDocument();
expect(screen.queryByText(/Removed MCP preset|reloaded without restarting/)).not.toBeInTheDocument();
});
it("offers a one-click recovery when the OAuth popup is blocked", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [xmindMcpPreset], installed_count: 0 });
}
return new Promise<Response>(() => {});
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockImplementation(async (action: string) => {
if (action === "settings.mcp.oauth_start") {
return {
flow_id: "flow-blocked",
name: "xmind",
status: "authorization_required",
expires_in: 300,
authorization_url: "https://accounts.xmind.test/authorize?state=blocked",
};
}
return settingsPayload();
});
const popup = {
opener: window,
closed: false,
location: { replace: vi.fn() },
focus: vi.fn(),
close: vi.fn(),
};
const open = vi.fn()
.mockReturnValueOnce(null)
.mockReturnValueOnce(popup);
vi.stubGlobal("open", open);
renderSettingsView({ initialSection: "apps" });
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" }));
const continueButton = await screen.findByRole("button", { name: "Continue sign-in" });
expect(screen.getByRole("status")).toHaveTextContent("Open the sign-in page to continue.");
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
fireEvent.click(continueButton);
expect(open).toHaveBeenLastCalledWith(
"https://accounts.xmind.test/authorize?state=blocked",
"nanobot-mcp-oauth",
"popup,width=560,height=720,resizable=yes,scrollbars=yes",
);
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
});
it("does not mistake a COOP-isolated OAuth tab for a blocked popup", async () => {
let popupIsolated = false;
let statusCalls = 0;
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [xmindMcpPreset], installed_count: 0 });
}
if (url === "/api/settings/mcp-oauth/status?flow_id=flow-coop") {
statusCalls += 1;
return jsonResponse({
flow_id: "flow-coop",
name: "xmind",
status: statusCalls === 1 ? "authorization_required" : "failed",
expires_in: 299,
error: statusCalls === 1 ? undefined : "Cancelled for test cleanup.",
authorization_url: statusCalls === 1
? "https://accounts.xmind.test/authorize?state=coop"
: undefined,
});
}
return { ok: false, status: 404, text: async () => "Not found" } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockImplementation(async (action: string) => {
if (action === "settings.mcp.oauth_start") {
return {
flow_id: "flow-coop",
name: "xmind",
status: "authorization_required",
expires_in: 300,
authorization_url: "https://accounts.xmind.test/authorize?state=coop",
};
}
return settingsPayload();
});
const popup = {
opener: window,
get closed() {
return popupIsolated;
},
location: {
replace: vi.fn(() => {
popupIsolated = true;
}),
},
document: { title: "", body: { textContent: "" } },
focus: vi.fn(),
close: vi.fn(),
};
vi.stubGlobal("open", vi.fn(() => popup));
renderSettingsView({ initialSection: "apps" });
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" }));
await waitFor(() => expect(statusCalls).toBe(1), { timeout: 2000 });
expect(screen.getByRole("status")).toHaveTextContent(
"Finish signing in in the browser window.",
);
expect(screen.queryByRole("button", { name: "Continue sign-in" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
});
});
@@ -0,0 +1,255 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { expect, it, vi } from "vitest";
import type { SettingsPayload } from "@/lib/types";
import { requestMutationMock, jsonResponse, settingsPayload, renderSettingsView, openPopover, installSettingsViewTestHooks } from "@/tests/settings-test-utils";
describe("Settings capabilities", () => {
installSettingsViewTestHooks();
it("selects image models from provider-specific options", async () => {
const base = settingsPayload();
const payload: SettingsPayload = {
...base,
image_generation: {
...base.image_generation,
providers: [
{
name: "openrouter",
label: "OpenRouter",
configured: true,
models: ["openai/gpt-5.4-image-2"],
default_model: "openai/gpt-5.4-image-2",
},
{
name: "gemini",
label: "Gemini",
configured: true,
models: ["gemini-2.5-flash-image", "imagen-4.0-generate-001"],
default_model: "gemini-2.5-flash-image",
},
{
name: "custom",
label: "Custom",
configured: true,
models: [],
default_model: null,
},
],
},
};
renderSettingsView({ initialSection: "image", initialSettings: payload });
expect(screen.queryByDisplayValue("openai/gpt-5.4-image-2")).not.toBeInTheDocument();
fireEvent.pointerDown(screen.getByRole("button", { name: "OpenRouter" }));
fireEvent.click(await screen.findByRole("menuitem", { name: "Gemini" }));
expect(await screen.findByRole("button", { name: "gemini-2.5-flash-image" })).toBeInTheDocument();
await openPopover(screen.getByRole("button", { name: "gemini-2.5-flash-image" }));
fireEvent.click(await screen.findByRole("option", { name: "imagen-4.0-generate-001" }));
await waitFor(() =>
expect(screen.getByRole("button", { name: "imagen-4.0-generate-001" })).toBeInTheDocument(),
);
await openPopover(screen.getByRole("button", { name: "imagen-4.0-generate-001" }));
const modelInput = await screen.findByRole("combobox", { name: "Search or type model ID" });
fireEvent.change(modelInput, { target: { value: "imagen-5-preview" } });
fireEvent.click(await screen.findByRole("option", { name: "Use “imagen-5-preview”" }));
expect(await screen.findByRole("button", { name: "imagen-5-preview" })).toBeInTheDocument();
fireEvent.pointerDown(screen.getByRole("button", { name: "Gemini" }));
fireEvent.click(await screen.findByRole("menuitem", { name: "Custom" }));
expect(screen.getByRole("button", { name: "imagen-5-preview" })).toBeInTheDocument();
await openPopover(screen.getByRole("button", { name: "imagen-5-preview" }));
const customProviderInput = await screen.findByRole("combobox", {
name: "Search or type model ID",
});
fireEvent.change(customProviderInput, { target: { value: "private/image-v2" } });
fireEvent.keyDown(customProviderInput, { key: "Enter" });
expect(await screen.findByRole("button", { name: "private/image-v2" })).toBeInTheDocument();
});
it("saves network safety without exposing technical SSRF copy", async () => {
const payload = settingsPayload();
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockResolvedValueOnce({
...payload,
advanced: { ...payload.advanced, webui_allow_local_service_access: false },
requires_restart: true,
restart_required_sections: ["runtime"],
});
renderSettingsView({ initialSection: "advanced" });
expect(await screen.findByText("Web safety")).toBeInTheDocument();
expect(screen.queryByText(/SSRF/i)).not.toBeInTheDocument();
expect(screen.queryByText("Private Service Protection")).not.toBeInTheDocument();
expect(screen.getByText("Default access")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Restricted" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Default Permission" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Full Access" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("switch", { name: "Local services" }));
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.network_safety.update",
{
webui_allow_local_service_access: false,
webui_default_access_mode: "default",
},
20_000,
),
);
});
it("saves optional-key web search providers without an API key", async () => {
const payload = {
...settingsPayload(),
web_search: {
...settingsPayload().web_search,
provider: "duckduckgo",
providers: [
{ name: "duckduckgo", label: "DuckDuckGo", credential: "none" as const },
{ name: "keenable", label: "Keenable", credential: "optional_api_key" as const },
],
},
};
const updatedPayload = {
...payload,
web_search: {
...payload.web_search,
provider: "keenable",
},
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 });
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockResolvedValueOnce(updatedPayload);
renderSettingsView({ initialSection: "browser" });
fireEvent.pointerDown(await screen.findByRole("button", { name: /DuckDuckGo/ }));
fireEvent.click(await screen.findByRole("menuitem", { name: "Keenable" }));
const saveButton = screen
.getAllByRole("button", { name: "Save" })
.find((button) => !(button as HTMLButtonElement).disabled);
if (!saveButton) throw new Error("enabled Save button was not found");
fireEvent.click(saveButton);
await waitFor(() =>
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.web_search.update",
{
provider: "keenable",
max_results: 5,
timeout: 30,
use_jina_reader: true,
},
20_000,
),
);
});
it("uses native host safety copy on the native surface", async () => {
const payload = {
...settingsPayload(),
surface: "native" as const,
runtime_surface: "native" as const,
};
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 });
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
renderSettingsView({ initialSection: "advanced" });
expect(await screen.findByText("App safety")).toBeInTheDocument();
expect(screen.queryByText("Web safety")).not.toBeInTheDocument();
expect(screen.getByText("Allow Full Access shell commands to reach services on this Mac.")).toBeInTheDocument();
});
it("refreshes settings with a fresh token after native engine restart", async () => {
const payload = {
...settingsPayload(),
surface: "native" as const,
runtime_surface: "native" as const,
runtime_capabilities: {
can_restart_engine: true,
can_pick_folder: true,
can_open_logs: true,
can_export_diagnostics: true,
},
};
const restartedPayload = {
...payload,
advanced: { ...payload.advanced, webui_allow_local_service_access: false },
requires_restart: true,
restart_required_sections: ["runtime"],
};
const refreshedPayload = {
...restartedPayload,
requires_restart: false,
restart_required_sections: [],
};
const restartEngine = vi.fn(async () => "fresh-token");
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const auth = (init?.headers as Record<string, string> | undefined)?.Authorization;
if (url === "/api/settings" && auth === "Bearer fresh-token") {
return jsonResponse(refreshedPayload);
}
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 });
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockResolvedValueOnce(restartedPayload);
renderSettingsView({
initialSection: "advanced",
onNativeEngineRestart: restartEngine,
});
expect(await screen.findByText("App safety")).toBeInTheDocument();
fireEvent.click(screen.getByRole("switch", { name: "Local services" }));
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(restartEngine).toHaveBeenCalledTimes(1));
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings",
expect.objectContaining({
headers: { Authorization: "Bearer fresh-token" },
}),
),
);
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+241
View File
@@ -0,0 +1,241 @@
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
import { expect, it, vi } from "vitest";
import type { SettingsPayload } from "@/lib/types";
import { jsonResponse, settingsPayload, renderSettingsView, installSettingsViewTestHooks } from "@/tests/settings-test-utils";
const thirdPartyBrandNotice =
"Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement.";
describe("Settings overview and appearance", () => {
installSettingsViewTestHooks();
it("persists the file edit display local preference", async () => {
renderSettingsView({
initialSection: "appearance",
initialSettings: settingsPayload(),
showSidebar: true,
});
expect(screen.getByText("File edit display")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Diff" }));
await waitFor(() => {
const saved = JSON.parse(localStorage.getItem("nanobot-webui.settings-preferences") || "{}");
expect(saved.fileEditDisplayMode).toBe("diff");
});
});
it("shows the third-party brand notice only with the brand logo preference", () => {
renderSettingsView({
initialSection: "appearance",
initialSettings: settingsPayload(),
showSidebar: true,
});
const brandLogosTitle = screen.getByText("Brand logos");
const brandLogosRow = brandLogosTitle.parentElement?.parentElement;
expect(brandLogosRow).not.toBeNull();
expect(
within(brandLogosRow as HTMLElement).getByText(thirdPartyBrandNotice),
).toBeInTheDocument();
expect(screen.getAllByText(thirdPartyBrandNotice)).toHaveLength(1);
});
it.each(["apps", "channels"] as const)(
"does not repeat the third-party brand notice in %s",
(initialSection) => {
renderSettingsView({ initialSection, initialSettings: settingsPayload() });
expect(screen.queryByText(thirdPartyBrandNotice)).not.toBeInTheDocument();
},
);
it("publishes the latest settings payload to the shell", async () => {
const payload = settingsPayload();
const onSettingsChange = vi.fn();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
renderSettingsView({ onSettingsChange });
await waitFor(() => expect(onSettingsChange).toHaveBeenCalledWith(payload));
});
it("does not keep Apps loading while an empty CLI catalog refresh is pending", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") {
return jsonResponse({
apps: [],
installed_count: 0,
catalog_updated_at: null,
catalog_refresh_pending: true,
});
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
renderSettingsView();
expect(await screen.findByText("No apps available.")).toBeInTheDocument();
expect(screen.queryByText("Loading Apps...")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Browse MCP tools" }));
expect(await screen.findByText("Add MCP server")).toBeInTheDocument();
});
it("shows token activity on the overview", async () => {
const payload: SettingsPayload = {
...settingsPayload(),
usage: {
days: [
{
date: "2026-06-03",
prompt_tokens: 1200,
completion_tokens: 300,
cached_tokens: 500,
total_tokens: 1500,
requests: 2,
},
],
total_tokens: 1500,
total_tokens_30d: 1500,
total_tokens_365d: 1500,
peak_day_tokens: 1500,
current_streak_days: 1,
longest_streak_days: 1,
active_days_30d: 1,
requests_30d: 2,
updated_at: "2026-06-03T00:00:00Z",
},
};
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
renderSettingsView({ initialSection: "overview" });
expect(await screen.findByLabelText("Token activity")).toBeInTheDocument();
expect(screen.getByText("Token Usage")).toBeInTheDocument();
expect(screen.queryByText("Token activity")).not.toBeInTheDocument();
expect(screen.queryByText("Total tokens")).not.toBeInTheDocument();
expect(screen.queryByText("Peak tokens")).not.toBeInTheDocument();
});
it("coalesces focus refreshes while usage is already loading", async () => {
const payload: SettingsPayload = {
...settingsPayload(),
usage: {
days: [],
total_tokens: 0,
total_tokens_30d: 0,
total_tokens_365d: 0,
peak_day_tokens: 0,
current_streak_days: 0,
longest_streak_days: 0,
active_days_30d: 0,
requests_30d: 0,
updated_at: null,
},
};
let resolveUsage!: (response: Response) => void;
const pendingUsage = new Promise<Response>((resolve) => {
resolveUsage = resolve;
});
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/usage") return pendingUsage;
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView({ initialSection: "overview", initialSettings: payload });
await waitFor(() => {
expect(fetchMock.mock.calls.filter(([input]) => (
String(input) === "/api/settings/usage"
))).toHaveLength(1);
});
window.dispatchEvent(new Event("focus"));
window.dispatchEvent(new Event("focus"));
expect(fetchMock.mock.calls.filter(([input]) => (
String(input) === "/api/settings/usage"
))).toHaveLength(1);
await act(async () => {
resolveUsage(jsonResponse(payload.usage));
await pendingUsage;
});
});
it("aligns token activity days with the configured timezone", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-02T18:00:00Z"));
const basePayload = settingsPayload();
const payload: SettingsPayload = {
...basePayload,
agent: {
...basePayload.agent,
timezone: "Asia/Shanghai",
},
usage: {
days: [
{
date: "2026-06-03",
prompt_tokens: 1200,
completion_tokens: 300,
cached_tokens: 500,
total_tokens: 1500,
requests: 2,
},
],
total_tokens: 1500,
total_tokens_30d: 1500,
total_tokens_365d: 1500,
peak_day_tokens: 1500,
current_streak_days: 1,
longest_streak_days: 1,
active_days_30d: 1,
requests_30d: 2,
updated_at: "2026-06-03T00:00:00Z",
},
};
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {})));
renderSettingsView({ initialSection: "overview", initialSettings: payload });
expect(screen.getByLabelText("2026-06-03: 1.5K tokens, 2 requests")).toBeInTheDocument();
});
});
+849
View File
@@ -0,0 +1,849 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { expect, it, vi } from "vitest";
import type { SettingsPayload } from "@/lib/types";
import { requestMutationMock, jsonResponse, settingsPayload, renderSettingsView, installSettingsViewTestHooks } from "@/tests/settings-test-utils";
async function chooseProviderToConfigure(label: string) {
fireEvent.pointerDown(
await screen.findByRole("button", { name: "Add your own model provider" }),
);
fireEvent.click(await screen.findByRole("menuitem", { name: label }));
}
describe("Settings providers", () => {
installSettingsViewTestHooks();
it("signs in to the xAI Grok provider", async () => {
const base = settingsPayload();
const xaiProvider = {
name: "xai_grok",
label: "xAI Grok",
configured: false,
auth_type: "oauth" as const,
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://cli-chat-proxy.grok.com/v1",
model_catalog: "builtin",
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
};
const payload: SettingsPayload = { ...base, providers: [xaiProvider] };
const signedIn: SettingsPayload = {
...payload,
providers: [{ ...xaiProvider, configured: true, oauth_account: "user@example.com" }],
};
const authorization = {
status: "authorization_required",
provider: "xai_grok",
flow_id: "flow-123",
authorization_url: "https://auth.x.ai/oauth2/authorize?state=test",
expires_in: 600,
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock
.mockResolvedValueOnce(authorization)
.mockResolvedValueOnce(signedIn);
const popup = {
opener: window,
location: { href: "about:blank" },
close: vi.fn(),
};
vi.stubGlobal("open", vi.fn(() => popup));
renderSettingsView({ initialSection: "models", initialSettings: payload });
await chooseProviderToConfigure("xAI Grok");
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
await waitFor(() =>
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.provider.oauth_login",
{ provider: "xai_grok" },
20_000,
),
);
expect(popup.opener).toBeNull();
expect(popup.location.href).toBe(authorization.authorization_url);
expect(
screen.getByText(
"Complete sign-in in your browser. Nanobot usually finishes automatically; if it does not, paste the authorization code below.",
),
).toBeInTheDocument();
const callbackInput = await screen.findByRole("textbox", {
name: "Authorization code",
});
fireEvent.change(callbackInput, {
target: { value: "secret" },
});
fireEvent.click(screen.getByRole("button", { name: "Finish sign-in" }));
await waitFor(() =>
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.provider.oauth_complete",
{
provider: "xai_grok",
flow_id: "flow-123",
authorization_response: "secret",
},
20_000,
),
);
expect(await screen.findByText("Signed in as user@example.com")).toBeInTheDocument();
});
it("recognizes remote access before starting xAI Grok sign-in", async () => {
const happyWindow = window as typeof window & {
happyDOM: { setURL: (url: string) => void };
};
const originalUrl = window.location.href;
happyWindow.happyDOM.setURL("http://203.0.113.10:18887/#/settings?section=models");
try {
const base = settingsPayload();
const xaiProvider = {
name: "xai_grok",
label: "xAI Grok",
configured: false,
auth_type: "oauth" as const,
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://cli-chat-proxy.grok.com/v1",
model_catalog: "builtin",
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
};
const payload: SettingsPayload = { ...base, providers: [xaiProvider] };
const authorization = {
status: "authorization_required",
provider: "xai_grok",
flow_id: "flow-remote",
authorization_url: "https://auth.x.ai/oauth2/authorize?state=remote",
expires_in: 600,
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockResolvedValueOnce(authorization);
const popup = {
opener: window,
location: { href: "about:blank" },
close: vi.fn(),
};
const openMock = vi.fn(() => popup);
vi.stubGlobal("open", openMock);
renderSettingsView({ initialSection: "models", initialSettings: payload });
await chooseProviderToConfigure("xAI Grok");
expect(
screen.getByText(
"Select Sign in to open xAI on your computer, then paste the authorization code shown after login.",
),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
const dialog = await screen.findByRole("dialog");
expect(openMock).not.toHaveBeenCalled();
expect(
within(dialog).getByText(
"Select Sign in to open xAI on your computer. After signing in, paste the authorization code shown by xAI below.",
),
).toBeInTheDocument();
expect(
within(dialog).queryByRole("textbox", { name: "xAI sign-in URL" }),
).not.toBeInTheDocument();
expect(
within(dialog).queryByRole("button", { name: "Copy" }),
).not.toBeInTheDocument();
expect(
within(dialog).getByRole("textbox", { name: "Authorization code" }),
).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole("button", { name: "Sign in" }));
expect(openMock).toHaveBeenCalledWith(
authorization.authorization_url,
"_blank",
"noopener,noreferrer",
);
expect(popup.opener).toBeNull();
} finally {
happyWindow.happyDOM.setURL(originalUrl);
}
});
it("polls local OpenAI Codex sign-in until the loopback callback completes", async () => {
const base = settingsPayload();
const codexProvider = {
name: "openai_codex",
label: "OpenAI Codex",
configured: false,
auth_type: "oauth" as const,
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://chatgpt.com/backend-api",
model_catalog: "builtin",
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
};
const payload: SettingsPayload = { ...base, providers: [codexProvider] };
const signedIn: SettingsPayload = {
...payload,
providers: [{ ...codexProvider, configured: true, oauth_account: "acct-codex" }],
};
const authorization = {
status: "authorization_required",
provider: "openai_codex",
flow_id: "flow-codex-local",
authorization_url: "https://auth.openai.com/oauth/authorize?state=local",
expires_in: 600,
completion_input: "callback_url",
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock
.mockResolvedValueOnce(authorization)
.mockResolvedValueOnce(signedIn);
const openMock = vi.fn();
vi.stubGlobal("open", openMock);
renderSettingsView({ initialSection: "models", initialSettings: payload });
await chooseProviderToConfigure("OpenAI Codex");
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
const dialog = await screen.findByRole("dialog");
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.provider.oauth_login",
{ provider: "openai_codex" },
20_000,
);
expect(openMock).not.toHaveBeenCalled();
expect(
within(dialog).getByText(
"Complete sign-in in your browser. Nanobot usually finishes automatically; if it does not, copy the full localhost callback URL from the address bar and paste it below.",
),
).toBeInTheDocument();
expect(within(dialog).getByText("Waiting for the browser callback…")).toBeInTheDocument();
expect(
within(dialog).queryByText("Paste the callback URL to continue."),
).not.toBeInTheDocument();
expect(
await screen.findByText("Signed in as acct-codex", {}, { timeout: 2500 }),
).toBeInTheDocument();
});
it("completes remote OpenAI Codex sign-in with the full callback URL", async () => {
const happyWindow = window as typeof window & {
happyDOM: { setURL: (url: string) => void };
};
const originalUrl = window.location.href;
happyWindow.happyDOM.setURL("http://203.0.113.10:18887/#/settings?section=models");
try {
const base = settingsPayload();
const codexProvider = {
name: "openai_codex",
label: "OpenAI Codex",
configured: false,
auth_type: "oauth" as const,
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://chatgpt.com/backend-api",
model_catalog: "builtin",
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
};
const payload: SettingsPayload = { ...base, providers: [codexProvider] };
const signedIn: SettingsPayload = {
...payload,
providers: [{ ...codexProvider, configured: true, oauth_account: "acct-codex" }],
};
const authorization = {
status: "authorization_required",
provider: "openai_codex",
flow_id: "flow-codex",
authorization_url: "https://auth.openai.com/oauth/authorize?state=test",
expires_in: 600,
completion_input: "callback_url",
};
const callbackUrl =
"http://localhost:1455/auth/callback?code=secret&state=test";
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockImplementation(async (
action: string,
mutationPayload: Record<string, unknown>,
) => {
if (action === "settings.provider.oauth_login") return authorization;
if (mutationPayload.authorization_response === callbackUrl) return signedIn;
return {
status: "pending",
provider: "openai_codex",
flow_id: "flow-codex",
};
});
const popup = {
opener: window,
location: { href: "about:blank" },
close: vi.fn(),
};
const openMock = vi.fn(() => popup);
vi.stubGlobal("open", openMock);
renderSettingsView({ initialSection: "models", initialSettings: payload });
await chooseProviderToConfigure("OpenAI Codex");
expect(
screen.getByText(
"Sign in through this browser, then paste the full localhost callback URL back into nanobot.",
),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
const dialog = await screen.findByRole("dialog");
expect(openMock).not.toHaveBeenCalled();
expect(
within(dialog).getByText(
"Open ChatGPT in this browser and finish signing in. When the localhost page fails to load, copy the full URL from the address bar and paste it below.",
),
).toBeInTheDocument();
expect(within(dialog).getByText("Paste the callback URL to continue.")).toBeInTheDocument();
const callbackInput = within(dialog).getByRole("textbox", {
name: "Full callback URL",
});
expect(callbackInput).toHaveAttribute(
"placeholder",
"http://localhost:1455/auth/callback?code=…&state=…",
);
fireEvent.click(within(dialog).getByRole("button", { name: "Open ChatGPT" }));
expect(openMock).toHaveBeenCalledWith(
authorization.authorization_url,
"_blank",
"noopener,noreferrer",
);
expect(popup.opener).toBeNull();
fireEvent.change(callbackInput, { target: { value: callbackUrl } });
fireEvent.click(within(dialog).getByRole("button", { name: "Finish sign-in" }));
await waitFor(() =>
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.provider.oauth_complete",
{
provider: "openai_codex",
flow_id: "flow-codex",
authorization_response: callbackUrl,
},
20_000,
),
);
expect(await screen.findByText("Signed in as acct-codex")).toBeInTheDocument();
} finally {
happyWindow.happyDOM.setURL(originalUrl);
}
});
it("saves scoped proxies for xAI and OpenAI Codex OAuth providers", async () => {
const base = settingsPayload();
const providers: SettingsPayload["providers"] = [
{
name: "xai_grok",
label: "xAI Grok",
configured: false,
auth_type: "oauth",
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://cli-chat-proxy.grok.com/v1",
model_catalog: "builtin",
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
proxy: "http://127.0.0.1:7000",
advanced_fields: ["extra_body", "proxy"],
extra_body: null,
},
{
name: "openai_codex",
label: "OpenAI Codex",
configured: false,
auth_type: "oauth",
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://chatgpt.com/backend-api",
model_catalog: "builtin",
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
proxy: null,
advanced_fields: ["extra_body", "proxy"],
extra_body: null,
},
];
let payload: SettingsPayload = { ...base, providers };
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockImplementation(async (
_action: string,
values: { provider?: string; proxy?: string; extraBody?: string },
) => {
payload = {
...payload,
providers: payload.providers.map((provider) =>
provider.name === values.provider
? {
...provider,
proxy: values.proxy || null,
extra_body: values.extraBody ? JSON.parse(values.extraBody) : null,
}
: provider,
),
};
return payload;
});
renderSettingsView({ initialSection: "models", initialSettings: payload });
await chooseProviderToConfigure("xAI Grok");
fireEvent.click(screen.getByRole("button", { name: "Advanced options" }));
const xaiProxy = screen.getByLabelText("Network proxy");
expect(xaiProxy).toHaveValue("http://127.0.0.1:7000");
fireEvent.change(xaiProxy, { target: { value: "http://127.0.0.1:7890" } });
expect(screen.getByRole("button", { name: "Sign in" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Sign in" })).toHaveAttribute(
"title",
"Save advanced changes before signing in.",
);
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() =>
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.provider.update",
{
provider: "xai_grok",
extraBody: "",
proxy: "http://127.0.0.1:7890",
},
20_000,
),
);
await waitFor(() => expect(screen.getByRole("button", { name: "Sign in" })).toBeEnabled());
fireEvent.click(screen.getByRole("button", { name: "xAI Grok" }));
await chooseProviderToConfigure("OpenAI Codex");
fireEvent.click(screen.getByRole("button", { name: "Advanced options" }));
const codexProxy = screen.getByLabelText("Network proxy");
expect(codexProxy).toHaveValue("");
fireEvent.change(codexProxy, { target: { value: "http://proxy.example:8080" } });
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() =>
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.provider.update",
{
provider: "openai_codex",
extraBody: "",
proxy: "http://proxy.example:8080",
},
20_000,
),
);
});
it("maps provider request switches to raw extraBody fields", async () => {
const base = settingsPayload();
const providers: SettingsPayload["providers"] = [
{
name: "xai_grok",
label: "xAI Grok",
configured: true,
auth_type: "oauth",
api_key_required: false,
oauth_account: "grok@example.com",
oauth_login_supported: true,
advanced_fields: ["extra_body", "proxy"],
extra_body: null,
},
{
name: "openai_codex",
label: "OpenAI Codex",
configured: true,
auth_type: "oauth",
api_key_required: false,
oauth_account: "codex@example.com",
oauth_login_supported: true,
advanced_fields: ["extra_body", "proxy"],
extra_body: null,
},
{
name: "deepseek",
label: "DeepSeek",
configured: true,
api_key_required: true,
api_key_hint: "deep••••test",
api_base: "https://api.deepseek.com",
advanced_fields: ["extra_body"],
extra_body: null,
},
{
name: "openai",
label: "OpenAI",
configured: true,
api_key_required: true,
api_key_hint: "sk-••••test",
api_base: "https://api.openai.com/v1",
api_type: "auto",
advanced_fields: ["api_type", "extra_body"],
extra_body: null,
},
];
const payload: SettingsPayload = { ...base, providers };
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockResolvedValue(payload);
renderSettingsView({ initialSection: "models", initialSettings: payload });
fireEvent.click(screen.getByRole("button", { name: "xAI Grok" }));
const xSearch = screen.getByRole("switch", { name: "X Search" });
expect(xSearch).toHaveAttribute("aria-checked", "true");
fireEvent.click(xSearch);
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith(
"settings.provider.update",
expect.objectContaining({ provider: "xai_grok" }),
20_000,
));
await waitFor(() => expect(
screen.getByRole("button", { name: "Save provider" }),
).toBeEnabled());
fireEvent.click(screen.getByRole("button", { name: "xAI Grok" }));
fireEvent.click(screen.getByRole("button", { name: "OpenAI Codex" }));
fireEvent.click(screen.getByRole("switch", { name: "Fast mode" }));
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith(
"settings.provider.update",
expect.objectContaining({ provider: "openai_codex" }),
20_000,
));
await waitFor(() => expect(
screen.getByRole("button", { name: "Save provider" }),
).toBeEnabled());
fireEvent.click(screen.getByRole("button", { name: "OpenAI Codex" }));
fireEvent.click(screen.getByRole("button", { name: /^DeepSeek/ }));
expect(screen.getByText(/DeepSeek V4 Flash/)).toBeInTheDocument();
const deepSeekSearch = screen.getByRole("switch", { name: "DeepSeek web search" });
expect(deepSeekSearch).toHaveAttribute("aria-checked", "true");
fireEvent.click(deepSeekSearch);
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() => expect(
screen.queryByRole("switch", { name: "DeepSeek web search" }),
).not.toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: /^OpenAI https:/ }));
fireEvent.click(screen.getByRole("switch", { name: "OpenAI web search" }));
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() => expect(
screen.queryByRole("switch", { name: "OpenAI web search" }),
).not.toBeInTheDocument());
await waitFor(() => {
const requestUpdates = requestMutationMock.mock.calls
.filter(([action]) => action === "settings.provider.update")
.map(([, values]) => {
const update = values as {
provider: string;
apiType?: string;
extraBody?: string;
};
return [update.provider, {
...(update.apiType ? { apiType: update.apiType } : {}),
extraBody: JSON.parse(update.extraBody ?? "{}"),
}] as const;
});
expect(requestUpdates).toEqual([
["xai_grok", { extraBody: { tools: [] } }],
["openai_codex", { extraBody: { service_tier: "priority" } }],
["deepseek", { extraBody: { tools: [] } }],
["openai", {
apiType: "responses",
extraBody: { tools: [{ type: "web_search" }] },
}],
]);
});
});
it("recognizes and removes versioned web search tools without losing raw settings", async () => {
const base = settingsPayload();
const payload: SettingsPayload = {
...base,
providers: [{
name: "openai",
label: "OpenAI",
configured: true,
api_key_required: true,
api_key_hint: "sk-••••test",
api_base: "https://api.openai.com/v1",
api_type: "auto",
advanced_fields: ["api_type", "extra_body"],
extra_body: {
metadata: { owner: "legacy-config" },
tools: [
{ type: "web_search_preview", search_context_size: "medium" },
{ type: "file_search", vector_store_ids: ["vs_legacy"] },
],
},
}],
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockResolvedValueOnce(payload);
renderSettingsView({ initialSection: "models", initialSettings: payload });
fireEvent.click(await screen.findByRole("button", { name: /^OpenAI https:/ }));
const searchSwitch = screen.getByRole("switch", { name: "OpenAI web search" });
expect(searchSwitch).toHaveAttribute("aria-checked", "true");
fireEvent.click(searchSwitch);
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() => {
const updateCall = requestMutationMock.mock.calls.find(
([action]) => action === "settings.provider.update",
);
expect(updateCall).toBeTruthy();
const values = updateCall?.[1] as { extraBody: string };
expect(JSON.parse(values.extraBody)).toEqual({
metadata: { owner: "legacy-config" },
tools: [{ type: "file_search", vector_store_ids: ["vs_legacy"] }],
});
});
});
it("creates a custom provider with folded advanced request settings", async () => {
const base = settingsPayload();
let payload: SettingsPayload = {
...base,
providers: [
{
name: "deepseek",
label: "DeepSeek",
configured: true,
api_key_required: true,
api_key_hint: "deep••••test",
api_base: "https://api.deepseek.com",
},
{
name: "openrouter",
label: "OpenRouter",
configured: false,
api_key_required: true,
api_key_hint: null,
api_base: null,
default_api_base: "https://openrouter.ai/api/v1",
},
],
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockImplementationOnce(async (
_action: string,
values: Record<string, string>,
) => {
payload = {
...payload,
created_provider: "custom-company-gateway",
providers: [
...payload.providers,
{
name: "custom-company-gateway",
label: values.name,
is_custom: true,
configured: true,
api_key_required: false,
api_key_hint: "sk-c••••pany",
api_base: values.apiBase,
default_api_base: null,
advanced_fields: [
"extra_headers",
"extra_body",
"extra_query",
"proxy",
"thinking_style",
],
extra_headers: JSON.parse(values.extraHeaders),
extra_body: JSON.parse(values.extraBody),
extra_query: JSON.parse(values.extraQuery),
proxy: values.proxy,
thinking_style: values.thinkingStyle,
},
],
};
return payload;
});
renderSettingsView({ initialSection: "models", initialSettings: payload });
fireEvent.pointerDown(
screen.getByRole("button", { name: "Add your own model provider" }),
);
const customOption = await screen.findByRole("menuitem", { name: "Custom provider" });
const openRouterOption = screen.getByRole("menuitem", { name: "OpenRouter" });
expect(customOption.querySelector("svg, img")).not.toBeNull();
expect(openRouterOption.querySelector("svg, img")).not.toBeNull();
fireEvent.click(customOption);
expect(
screen.queryByRole("button", { name: "Add your own model provider" }),
).not.toBeInTheDocument();
expect(screen.queryByLabelText("Extra headers")).not.toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText("My model provider"), {
target: { value: "Company Gateway" },
});
fireEvent.change(screen.getByPlaceholderText("https://api.example.com/v1"), {
target: { value: "https://gateway.example/v1" },
});
fireEvent.change(screen.getByPlaceholderText("Enter API key"), {
target: { value: "sk-company" },
});
fireEvent.click(screen.getByRole("button", { name: "Advanced options" }));
fireEvent.change(screen.getByLabelText("Extra headers"), {
target: { value: '{"X-Tenant":"engineering"}' },
});
fireEvent.change(screen.getByLabelText("Extra body"), {
target: { value: '{"service_tier":"priority"}' },
});
fireEvent.change(screen.getByLabelText("Extra query"), {
target: { value: '{"api-version":"2026-01-01"}' },
});
fireEvent.change(screen.getByLabelText("Network proxy"), {
target: { value: "http://127.0.0.1:7890" },
});
fireEvent.pointerDown(screen.getByRole("button", { name: "Thinking style" }));
fireEvent.click(await screen.findByRole("menuitem", { name: "enable_thinking" }));
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() => {
const createCall = requestMutationMock.mock.calls.find(
([action]) => action === "settings.provider.create",
);
expect(createCall).toBeTruthy();
expect(createCall?.[1]).toEqual({
name: "Company Gateway",
apiKey: "sk-company",
apiBase: "https://gateway.example/v1",
proxy: "http://127.0.0.1:7890",
extraHeaders: '{"X-Tenant":"engineering"}',
extraBody: '{"service_tier":"priority"}',
extraQuery: '{"api-version":"2026-01-01"}',
thinkingStyle: "enable_thinking",
});
});
expect(
await screen.findByRole("button", { name: /Company Gateway/ }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Add your own model provider" }),
).toBeInTheDocument();
});
});
+381
View File
@@ -0,0 +1,381 @@
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import { expect, it, vi } from "vitest";
import { requestMutationMock, jsonResponse, settingsPayload, renderSettingsView, installSettingsViewTestHooks } from "@/tests/settings-test-utils";
const installedAnyGen = {
name: "anygen",
display_name: "AnyGen",
category: "generation",
description: "Generate docs, slides, websites and more via AnyGen cloud API",
requires: "ANYGEN_API_KEY",
source: "harness",
entry_point: "cli-anything-anygen",
install_supported: true,
installed: true,
available: true,
status: "installed",
logo_url: "https://www.google.com/s2/favicons?domain=anygen.io&sz=64",
brand_color: "#111827",
skill_installed: true,
};
describe("Settings system domains", () => {
installSettingsViewTestHooks();
it("does not show the Settings kicker on the standalone Automations surface", async () => {
const onBackToChat = vi.fn();
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/webui/automations") return jsonResponse({ jobs: [] });
return jsonResponse({});
}));
renderSettingsView({
initialSection: "automations",
initialSettings: settingsPayload(),
showSidebar: false,
onBackToChat,
});
expect(screen.getByRole("heading", { name: "Automations" })).toBeInTheDocument();
expect(await screen.findByText("No automations yet.")).toBeInTheDocument();
expect(screen.queryByText("Settings")).not.toBeInTheDocument();
expect(
screen.queryByPlaceholderText("Search task, message, linked chat, or schedule"),
).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Open a chat" }));
expect(onBackToChat).toHaveBeenCalledTimes(1);
});
it("offers a way out of an empty automations filter", async () => {
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/webui/automations") {
return jsonResponse({
jobs: [{
id: "job-1",
name: "Daily summary",
enabled: true,
schedule: { kind: "cron", expr: "0 9 * * *" },
payload: { message: "Summarize the day" },
state: {},
}],
});
}
return jsonResponse({});
}));
renderSettingsView({
initialSection: "automations",
initialSettings: settingsPayload(),
showSidebar: false,
});
expect(await screen.findByRole("heading", { name: "Daily summary" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Paused 0" }));
expect(await screen.findByText("No automations match this view.")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Clear filters" }));
expect(await screen.findByRole("heading", { name: "Daily summary" })).toBeInTheDocument();
});
it("coalesces focus refreshes while automations are already loading", async () => {
let resolveAutomations!: (response: Response) => void;
const pendingAutomations = new Promise<Response>((resolve) => {
resolveAutomations = resolve;
});
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/webui/automations") return pendingAutomations;
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView({
initialSection: "automations",
initialSettings: settingsPayload(),
showSidebar: false,
});
await waitFor(() => {
expect(fetchMock.mock.calls.filter(([input]) => (
String(input) === "/api/webui/automations"
))).toHaveLength(1);
});
window.dispatchEvent(new Event("focus"));
window.dispatchEvent(new Event("focus"));
expect(fetchMock.mock.calls.filter(([input]) => (
String(input) === "/api/webui/automations"
))).toHaveLength(1);
await act(async () => {
resolveAutomations(jsonResponse({ jobs: [] }));
await pendingAutomations;
});
});
it("starts the managed API server from System", async () => {
const base = settingsPayload();
const stopped = {
installed: false,
running: false,
managed: false,
host: "127.0.0.1",
port: 8900,
timeout: 120,
api_key_hint: null,
endpoint: "http://127.0.0.1:8900/v1",
command: "nanobot serve",
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(base);
if (url === "/api/settings/api-service") return jsonResponse(stopped);
if (url === "/api/settings/nanobot-features") {
return jsonResponse({ features: [], enabled_count: 0 });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockResolvedValueOnce({
...stopped,
installed: true,
running: true,
managed: true,
});
renderSettingsView({ initialSection: "runtime", initialSettings: base, showSidebar: true });
const startButton = await screen.findByRole("button", { name: "Start API server" });
await waitFor(() => expect(startButton).toBeEnabled());
fireEvent.click(startButton);
await waitFor(() => {
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.api_service.start",
{ host: "127.0.0.1", port: 8900, timeout: 120 },
150_000,
);
});
});
it("shows a visible uninstall button for installed CLI apps and calls uninstall", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") {
return jsonResponse(settingsPayload());
}
if (url === "/api/settings/cli-apps") {
return jsonResponse({
apps: [installedAnyGen],
installed_count: 1,
catalog_updated_at: "2026-04-18",
});
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockResolvedValueOnce({
apps: [{ ...installedAnyGen, installed: false, status: "available" }],
installed_count: 0,
catalog_updated_at: "2026-04-18",
last_action: {
ok: true,
message: "Uninstalled CLI for AnyGen.",
still_available: false,
},
});
renderSettingsView();
expect(screen.queryByRole("heading", { name: "Apps" })).not.toBeInTheDocument();
expect(await screen.findByText("AnyGen")).toBeInTheDocument();
const uninstall = screen.getByRole("button", { name: "Uninstall app" });
fireEvent.click(uninstall);
await waitFor(() =>
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.cli_app.uninstall",
{ name: "anygen" },
20_000,
),
);
expect(await screen.findByText("Uninstalled CLI for AnyGen.")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
expect(screen.queryByText("Uninstalled CLI for AnyGen.")).not.toBeInTheDocument();
});
it("keeps runtime dependencies out of Apps and explains chat mentions", async () => {
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") {
return jsonResponse({
apps: [{ ...installedAnyGen, installed: false, status: "available" }],
installed_count: 0,
});
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
if (url === "/api/settings/nanobot-features") {
return jsonResponse({
features: [
{
name: "api",
display_name: "Api",
type: "feature",
enabled: true,
installed: true,
ready: true,
status: "enabled",
install_supported: true,
requires_restart: true,
},
],
enabled_count: 1,
});
}
return jsonResponse({});
}));
renderSettingsView({ initialSection: "apps" });
expect(await screen.findByText("AnyGen")).toBeInTheDocument();
expect(
screen.queryByText("Add tools to nanobot, then @ them in chat."),
).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Ready" })).toHaveAttribute("aria-pressed", "false");
expect(screen.getByRole("button", { name: "Apps" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "MCP" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Plugins" })).not.toBeInTheDocument();
expect(screen.queryByText("Api")).not.toBeInTheDocument();
expect(screen.queryByText("0 ready")).not.toBeInTheDocument();
});
it("shows nanobot optional features and enables one", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 });
if (url === "/api/settings/nanobot-features") {
return jsonResponse({
features: [{
name: "matrix",
display_name: "Matrix",
webui: "webui/index.ts",
type: "channel",
enabled: false,
installed: false,
ready: false,
status: "missing_dependency",
install_supported: true,
requires_restart: true,
}],
enabled_count: 0,
});
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockImplementation(async (action: string) => {
if (action === "settings.feature.enable") {
return {
features: [{
name: "matrix",
display_name: "Matrix",
webui: "webui/index.ts",
type: "channel",
enabled: true,
running: true,
runtime_status: "running",
installed: true,
ready: true,
status: "enabled",
install_supported: true,
requires_restart: true,
}],
enabled_count: 1,
last_action: { ok: true, message: "Enabled channel 'matrix'", enabled: true },
};
}
if (action === "settings.feature.disable") {
return {
features: [{
name: "matrix",
display_name: "Matrix",
webui: "webui/index.ts",
type: "channel",
enabled: false,
installed: true,
ready: false,
status: "not_enabled",
install_supported: true,
requires_restart: true,
}],
enabled_count: 0,
requires_restart: true,
last_action: { ok: true, message: "Disabled channel 'matrix'", enabled: false },
};
}
return settingsPayload();
});
renderSettingsView({ initialSection: "channels" });
const matrixRow = await screen.findByRole("button", { name: "View Matrix settings" });
expect(matrixRow).toHaveAttribute("aria-pressed", "true");
expect(screen.getAllByText("Matrix")).toHaveLength(2);
expect(screen.getAllByText("Use nanobot from Matrix rooms.")).toHaveLength(2);
expect(screen.queryByText(/Enabling Nanobot features may install Python packages/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("switch", { name: "Matrix channel" }));
expect(screen.getByRole("dialog", { name: "Install support for Matrix?" })).toBeInTheDocument();
expect(screen.getByText("nanobot will add what Matrix needs, then turn it on. Continue?")).toBeInTheDocument();
expect(requestMutationMock).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Install and enable" }));
await waitFor(() =>
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.feature.enable",
{ name: "matrix" },
150_000,
),
);
await waitFor(() =>
expect(screen.getByRole("switch", { name: "Matrix channel" })).toHaveAttribute("aria-checked", "true"),
);
expect(screen.queryByText("Enabled channel 'matrix'")).not.toBeInTheDocument();
expect(screen.queryByText("Restart nanobot to apply updated channel support.")).not.toBeInTheDocument();
expect(screen.getAllByText("On").length).toBeGreaterThan(0);
expect(screen.getByLabelText("Homeserver")).toBeInTheDocument();
expect(screen.getByLabelText("User ID")).toBeInTheDocument();
expect(screen.getByLabelText("Device ID")).toBeInTheDocument();
expect(screen.queryByText("channels.matrix.homeserver")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("switch", { name: "Matrix channel" }));
await waitFor(() =>
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.feature.disable",
{ name: "matrix" },
20_000,
),
);
await waitFor(() =>
expect(screen.getByRole("switch", { name: "Matrix channel" })).toHaveAttribute("aria-checked", "false"),
);
expect(screen.queryByText("Disabled channel 'matrix'")).not.toBeInTheDocument();
});
});
+195
View File
@@ -0,0 +1,195 @@
import { cleanup, render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, vi } from "vitest";
import { SettingsView } from "@/components/settings/SettingsView";
import { ClientProvider } from "@/providers/ClientProvider";
import type { SettingsPayload } from "@/lib/types";
export const requestMutationMock = vi.fn();
export function jsonResponse(body: unknown): Response {
return {
ok: true,
status: 200,
json: async () => body,
} as Response;
}
export function settingsPayload(): SettingsPayload {
return {
agent: {
model: "openai/gpt-4o",
provider: "auto",
resolved_provider: "openai",
has_api_key: true,
model_preset: "primary",
max_tokens: 8192,
context_window_tokens: 200000,
temperature: 0.1,
reasoning_effort: null,
timezone: "UTC",
tool_hint_max_length: 40,
},
model_presets: [{
name: "primary",
label: "Primary",
active: true,
is_default: false,
model: "openai/gpt-4o",
provider: "auto",
resolved_provider: "openai",
max_tokens: 8192,
context_window_tokens: 200000,
temperature: 0.1,
reasoning_effort: null,
}],
model_call_order: ["primary"],
model_call_order_editable: true,
providers: [],
web_search: {
provider: "duckduckgo",
api_key_hint: null,
base_url: null,
max_results: 5,
timeout: 30,
providers: [{ name: "duckduckgo", label: "DuckDuckGo", credential: "none" }],
},
web: {
enable: true,
proxy: null,
user_agent: null,
search: { max_results: 5, timeout: 30 },
fetch: { use_jina_reader: true },
},
api: {
host: "127.0.0.1",
port: 8900,
timeout: 120,
api_key_hint: null,
},
observability: {
provider: "langfuse",
configured: false,
base_url: "https://cloud.langfuse.com",
},
image_generation: {
enabled: false,
provider: "openrouter",
provider_configured: false,
model: "openai/gpt-5.4-image-2",
default_aspect_ratio: "1:1",
default_image_size: "1K",
max_images_per_turn: 4,
save_dir: "generated",
providers: [],
},
runtime: {
config_path: "/tmp/config.json",
workspace_path: "/tmp/workspace",
gateway_host: "127.0.0.1",
gateway_port: 18790,
heartbeat: {
enabled: true,
interval_s: 1800,
keep_recent_messages: 8,
},
dream: {
schedule: "every 2h",
},
unified_session: false,
},
advanced: {
restrict_to_workspace: false,
webui_allow_local_service_access: true,
webui_default_access_mode: "default",
private_service_protection_enabled: true,
ssrf_whitelist_count: 0,
mcp_server_count: 0,
exec_enabled: true,
exec_sandbox: null,
exec_path_prepend_set: false,
exec_path_append_set: false,
},
requires_restart: false,
version: {
current: "0.2.2",
},
docs: {
version: "0.2.2",
base_url: "https://nanobot.wiki/docs/0.2.2",
chat_apps_url: "https://nanobot.wiki/docs/0.2.2/getting-started/chat-apps",
latest_url: "https://nanobot.wiki/docs/latest",
},
};
}
export function renderSettingsView(
options: {
initialSection?:
| "overview"
| "appearance"
| "apps"
| "channels"
| "automations"
| "advanced"
| "models"
| "image"
| "browser"
| "runtime";
initialSettings?: SettingsPayload;
showSidebar?: boolean;
onBackToChat?: () => void;
onSettingsChange?: (payload: SettingsPayload) => void;
onNativeEngineRestart?: () => Promise<string>;
} = {},
) {
render(
<ClientProvider client={{ requestMutation: requestMutationMock } as never} token="tok">
<SettingsView
theme="light"
initialSection={options.initialSection ?? "apps"}
initialSettings={options.initialSettings}
showSidebar={options.showSidebar}
onToggleTheme={() => {}}
onBackToChat={options.onBackToChat ?? (() => {})}
onModelNameChange={() => {}}
onSettingsChange={options.onSettingsChange}
onNativeEngineRestart={options.onNativeEngineRestart}
/>
</ClientProvider>,
);
}
export async function openPopover(trigger: HTMLElement) {
await userEvent.setup().click(trigger);
}
export function installSettingsViewTestHooks() {
beforeEach(() => {
requestMutationMock.mockReset().mockResolvedValue(settingsPayload());
vi.stubGlobal(
"matchMedia",
vi.fn((query: string) => ({
matches: query === "(min-width: 1280px)",
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
);
vi.stubGlobal(
"fetch",
vi.fn(() => new Promise<Response>(() => {})),
);
});
afterEach(() => {
cleanup();
localStorage.removeItem("nanobot-webui.settings-preferences");
vi.useRealTimers();
vi.unstubAllGlobals();
});
}
File diff suppressed because it is too large Load Diff