mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-11 06:48:39 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0e8330ecc | ||
|
|
52e0a6a1e3 | ||
|
|
8e77f3f8a4 | ||
|
|
b3b0517611 |
+41
-4
@@ -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:
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
+109
-2051
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
+455
-977
File diff suppressed because it is too large
Load Diff
@@ -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})
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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],
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"),
|
||||
[
|
||||
|
||||
@@ -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
|
||||
@@ -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>;
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 d’une 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 l’URL de rappel dans nanobot.",
|
||||
"manualCallbackHelp": "Après avoir autorisé l’accès, la page localhost ne se chargera pas. Copiez son URL complète depuis la barre d’adresse 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 n’a 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 n’est 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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 をチャットアプリに接続します。サポートのインストールは統合パッケージを追加するだけで、多くのチャンネルでは引き続きトークンやワークスペース設定が必要です。",
|
||||
|
||||
@@ -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을 채팅 앱에 연결합니다. 지원 설치는 통합 패키지만 추가하며, 대부분의 채널은 여전히 토큰이나 워크스페이스 설정이 필요합니다.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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。",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"}}}',
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user