Compare commits

..
Author SHA1 Message Date
chengyongru e0e8330ecc refactor(webui): split settings frontend by domain 2026-08-11 11:25:10 +08:00
56 changed files with 17001 additions and 16540 deletions
+1 -1
View File
@@ -202,7 +202,7 @@ When changing tools, channels, file access, WebUI workspace behavior, or network
| Channel | Export a `ChannelPlugin` descriptor, keep its runtime and optional setup surfaces in one package, and follow [`channel-package-guide.md`](./channel-package-guide.md) |
| Tool | Implement a tool under `agent/tools/` or expose a plugin entry point |
| MCP | Add `tools.mcpServers` config |
| Skill | Add workspace skills under `<workspace>/skills/`, Agent Plugins v1 under `<workspace>/plugins/`, or built-in skills under `nanobot/skills/` |
| Skill | Add workspace skill files under `<workspace>/skills/` or built-in skills under `nanobot/skills/` |
Prefer existing registry/discovery patterns over ad hoc wiring.
-19
View File
@@ -2343,25 +2343,6 @@ Disabled skills are excluded from the main agent's skill summary, from always-on
|--------|---------|-------------|
| `agents.defaults.disabledSkills` | `[]` | List of skill directory names to exclude from loading. Applies to both built-in skills and workspace skills. |
### Agent Plugins v1
nanobot discovers [Agent Plugins](https://agent-plugins.org/) in
`<workspace>/plugins/<plugin>/`. A v1 package has `plugin.json` and may add `mcp.json`,
`skills/<name>/SKILL.md`, or both.
Directory presence means installed; activation is an explicit trust decision in **Apps**.
Enabled skills use normal progressive loading and `$skill-name` invocation. Workspace skills
override plugin skills, which override built-ins. Enabled `stdio` servers from `mcp.json` receive
contained `PLUGIN_ROOT` and isolated `PLUGIN_DATA` paths; explicit `tools.mcpServers` entries win
name collisions. Invalid manifests, components, nested skills, and escaping paths are ignored.
Enabled plugins run as the nanobot user; declared permissions are descriptive, not an OS sandbox.
The optional `extensions.dev.nanobot.logo` accepts a contained PNG, JPEG, or WebP up to 256 KiB.
WebUI-installed CLI Apps use the same package layout as skills-only plugins. Their external
executables remain managed by the CLI Apps installer; update refreshes the package and uninstall
removes it. Future catalogs can acquire and place packages before using this same activation path.
## Tool Hint Max Length
Tool hints are the short progress messages shown when the agent calls tools (e.g. `$ cd …/project && npm test`). By default, these are truncated at 40 characters, which can make long commands hard to read.
+1 -3
View File
@@ -485,8 +485,6 @@ class AgentLoop:
config,
provider_snapshot_loader,
)
from nanobot.agent.plugins import agent_plugin_mcp_servers
return cls(
bus=bus,
provider=provider,
@@ -501,7 +499,7 @@ class AgentLoop:
provider_retry_mode=defaults.provider_retry_mode,
tool_hint_max_length=defaults.tool_hint_max_length,
restrict_to_workspace=config.tools.restrict_to_workspace,
mcp_servers=agent_plugin_mcp_servers(config.workspace_path, config.tools.mcp_servers),
mcp_servers=config.tools.mcp_servers,
channels_config=config.channels,
timezone=defaults.timezone,
unified_session=defaults.unified_session,
-361
View File
@@ -1,361 +0,0 @@
"""Load and activate locally installed Agent Plugin packages."""
from __future__ import annotations
import base64
import json
import re
from dataclasses import dataclass
from hashlib import sha256
from pathlib import Path
from typing import cast
from loguru import logger
from pydantic import ValidationError
from nanobot.agent.skills import parse_skill_metadata, valid_skill_metadata
from nanobot.config.loader import get_config_path
from nanobot.config.schema import MCPServerConfig
AGENT_PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"
AGENT_PLUGIN_MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json"
_PLUGIN_NAME = re.compile(r"^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$")
_MCP_SERVER_FIELDS = {"type", "command", "args", "env", "cwd"}
_MAX_LOGO_BYTES = 256 * 1024
@dataclass(frozen=True)
class AgentPlugin:
"""A validated, locally installed Agent Plugins v1 package."""
name: str
root: Path
description: str
repository: str
display_name: str
category: str
accent_color: str | None
logo: str | None
permissions: tuple[str, ...]
@dataclass(frozen=True)
class AgentPluginState:
"""Runtime state for one discovered Agent Plugin."""
plugin: AgentPlugin
mcp_servers: tuple[str, ...]
enabled: bool
def _discover_agent_plugins(workspace: Path) -> list[AgentPlugin]:
"""Return installed packages found under ``<workspace>/plugins/*``."""
workspace = workspace.expanduser().resolve()
root = _contained(workspace / "plugins", workspace, directory=True)
if root is None:
return []
plugins: list[AgentPlugin] = []
for candidate in _children(root, "Agent Plugins directory"):
plugin_root = _contained(candidate, root, directory=True)
if plugin_root is None:
continue
plugin = _load_manifest(plugin_root)
if plugin is not None:
plugins.append(plugin)
return plugins
def enabled_agent_plugin_skills(workspace: Path) -> list[tuple[str, Path]]:
"""Return skills from plugins the user has explicitly enabled."""
return [
skill
for plugin in _discover_agent_plugins(workspace)
if _enabled(workspace, plugin.name)
for skill in _discover_plugin_skills(plugin.name, plugin.root)
]
def _load_manifest(plugin_root: Path) -> AgentPlugin | None:
payload = _read_object(plugin_root / "plugin.json", plugin_root)
if payload is None:
return None
if payload.get("$schema") != AGENT_PLUGIN_SCHEMA:
return None
name = payload.get("name")
if (
not isinstance(name, str)
or len(name) > 64
or _PLUGIN_NAME.fullmatch(name) is None
):
logger.warning("Ignoring Agent Plugin manifest in '{}': invalid name", plugin_root)
return None
extension = payload.get("extensions")
extension_payload = cast(dict[str, object], extension) if isinstance(extension, dict) else {}
nanobot_value = extension_payload.get("dev.nanobot")
nanobot = cast(dict[str, object], nanobot_value) if isinstance(nanobot_value, dict) else {}
return AgentPlugin(
name=name,
root=plugin_root,
description=_string(payload.get("description")),
repository=_string(payload.get("repository")),
display_name=_string(nanobot.get("displayName")) or name,
category=_string(nanobot.get("category")) or "Plugin",
accent_color=_accent_color(nanobot.get("accentColor")),
logo=_plugin_logo(nanobot.get("logo"), plugin_root),
permissions=_string_tuple(nanobot.get("permissions")),
)
def agent_plugin_mcp_servers(
workspace: Path,
configured: dict[str, MCPServerConfig] | None = None,
) -> dict[str, MCPServerConfig]:
"""Merge explicitly enabled plugin MCP servers with user configuration.
User configuration wins on the unlikely event of a namespaced collision.
"""
servers: dict[str, MCPServerConfig] = {}
for plugin in _discover_agent_plugins(workspace):
if not _enabled(workspace, plugin.name):
continue
plugin_servers = _plugin_mcp_servers(workspace, plugin)
for name, server in plugin_servers.items():
host_name = plugin.name if len(plugin_servers) == 1 else f"{plugin.name}-{name}"
servers[host_name] = server
configured = configured or {}
if collisions := servers.keys() & configured.keys():
logger.warning("Configured MCP servers override Agent Plugins: {}", ", ".join(sorted(collisions)))
return servers | configured
def discover_agent_plugin_states(workspace: Path) -> list[AgentPluginState]:
"""Return component and lifecycle state for discovered plugins."""
return [
AgentPluginState(
plugin=plugin,
mcp_servers=tuple(sorted(_plugin_mcp_servers(workspace, plugin))),
enabled=_enabled(workspace, plugin.name),
)
for plugin in _discover_agent_plugins(workspace)
]
def set_agent_plugin_enabled(workspace: Path, name: str, enabled: bool) -> AgentPlugin:
"""Enable or disable one installed plugin."""
plugin = next((item for item in _discover_agent_plugins(workspace) if item.name == name), None)
if plugin is None:
raise ValueError(f"unknown Agent Plugin '{name}'")
data = _plugin_data_dir(workspace, plugin.name, create=True)
marker = data / "enabled"
if enabled:
marker.write_text("1", encoding="utf-8")
marker.chmod(0o600)
else:
marker.unlink(missing_ok=True)
return plugin
def _string(value: object) -> str:
return value.strip() if isinstance(value, str) else ""
def _string_tuple(value: object) -> tuple[str, ...]:
items = cast(list[object], value) if isinstance(value, list) else []
return tuple(item.strip() for item in items if isinstance(item, str) and item.strip())
def _accent_color(value: object) -> str | None:
return value if isinstance(value, str) and re.fullmatch(r"#[0-9a-fA-F]{6}", value) else None
def _plugin_logo(value: object, plugin_root: Path) -> str | None:
"""Resolve nanobot's optional packaged logo extension."""
if value is None:
return None
if not isinstance(value, str) or not value.startswith("./"):
logger.warning("Ignoring invalid Agent Plugin logo in '{}'", plugin_root)
return None
logo = _contained(plugin_root / value[2:], plugin_root)
try:
data = logo.read_bytes() if logo is not None else b""
suffix = logo.suffix.lower() if logo is not None else ""
if len(data) <= _MAX_LOGO_BYTES and (
suffix == ".png" and data.startswith(b"\x89PNG\r\n\x1a\n")
or suffix in {".jpg", ".jpeg"} and data.startswith(b"\xff\xd8\xff")
or suffix == ".webp" and data.startswith(b"RIFF") and data[8:12] == b"WEBP"
):
mime = "jpeg" if suffix in {".jpg", ".jpeg"} else suffix[1:]
return f"data:image/{mime};base64,{base64.b64encode(data).decode('ascii')}"
except OSError:
pass
logger.warning("Ignoring invalid Agent Plugin logo in '{}'", plugin_root)
return None
def _plugin_mcp_servers(workspace: Path, plugin: AgentPlugin) -> dict[str, MCPServerConfig]:
payload = _read_object(plugin.root / "mcp.json", plugin.root)
if payload is None:
return {}
raw_servers = payload.get("mcpServers")
if (
payload.keys() != {"$schema", "mcpServers"}
or payload.get("$schema") != AGENT_PLUGIN_MCP_SCHEMA
or not isinstance(raw_servers, dict)
):
logger.warning("Ignoring invalid MCP component for Agent Plugin '{}'", plugin.name)
return {}
data = _plugin_data_dir(workspace, plugin.name, create=True)
servers: dict[str, MCPServerConfig] = {}
for name, raw in cast(dict[str, object], raw_servers).items():
if not name or len(name) > 128 or any(ord(char) < 32 for char in name):
logger.warning("Ignoring invalid MCP server name in Agent Plugin '{}'", plugin.name)
continue
server = _plugin_mcp_server(raw, plugin.root, data)
if server is None:
logger.warning("Ignoring invalid MCP server '{}' in Agent Plugin '{}'", name, plugin.name)
continue
servers[name] = server
return servers
def _plugin_mcp_server(raw: object, root: Path, data: Path) -> MCPServerConfig | None:
if not isinstance(raw, dict):
return None
payload = cast(dict[str, object], raw)
if payload.keys() - _MCP_SERVER_FIELDS:
return None
try:
server = MCPServerConfig.model_validate(payload)
except ValidationError:
return None
command = _stdio_command(server.command, root)
cwd = _stdio_cwd(payload.get("cwd"), root, data)
if server.type != "stdio" or command is None or cwd is None:
return None
if {"PLUGIN_ROOT", "PLUGIN_DATA"} & server.env.keys():
return None
return server.model_copy(
update={
"command": command,
"args": [_expand(item, root, data) for item in server.args],
"env": {
**{key: _expand(value, root, data) for key, value in server.env.items()},
"PLUGIN_ROOT": str(root),
"PLUGIN_DATA": str(data),
},
"cwd": str(cwd),
}
)
def _stdio_command(value: object, root: Path) -> str | None:
if not isinstance(value, str) or not value:
return None
if value.startswith("./"):
executable = _contained(root / value[2:], root)
return str(executable) if executable is not None else None
if any(char.isspace() for char in value) or "/" in value or "\\" in value:
return None
return value
def _stdio_cwd(value: object, root: Path, data: Path) -> Path | None:
if value is None:
return root
if not isinstance(value, str):
return None
if value.startswith("./"):
return _contained(root / value[2:], root, directory=True)
for placeholder, base in (("${PLUGIN_ROOT}", root), ("${PLUGIN_DATA}", data)):
if value == placeholder or value.startswith(f"{placeholder}/"):
relative = value[len(placeholder):].lstrip("/")
candidate = (base / relative).resolve()
if not candidate.is_relative_to(base):
return None
if base == data:
candidate.mkdir(parents=True, exist_ok=True)
candidate.chmod(0o700)
return candidate if candidate.is_dir() else None
return None
def _expand(value: str, root: Path, data: Path) -> str:
return value.replace("${PLUGIN_ROOT}", str(root)).replace("${PLUGIN_DATA}", str(data))
def _plugin_data_dir(workspace: Path, name: str, *, create: bool) -> Path:
workspace_id = sha256(str(workspace.expanduser().resolve()).encode()).hexdigest()[:12]
current = get_config_path().expanduser().resolve().parent
for segment in ("plugin-data", workspace_id, name):
path = current / segment
if create:
path.mkdir(parents=True, exist_ok=True)
try:
resolved = path.resolve(strict=create)
except OSError as exc:
raise RuntimeError("Agent Plugin data directory is unavailable") from exc
if not resolved.is_relative_to(current):
raise RuntimeError("Agent Plugin data directory escapes its parent")
if create:
resolved.chmod(0o700)
current = resolved
return current
def _enabled(workspace: Path, name: str) -> bool:
return (_plugin_data_dir(workspace, name, create=False) / "enabled").is_file()
def _discover_plugin_skills(plugin_name: str, plugin_root: Path) -> list[tuple[str, Path]]:
skills_root = _contained(plugin_root / "skills", plugin_root, directory=True)
if skills_root is None:
return []
skills: list[tuple[str, Path]] = []
for candidate in _children(skills_root, f"Agent Plugin '{plugin_name}' skills"):
skill_root = _contained(candidate, skills_root, directory=True)
if skill_root is None:
continue
skill_file = _contained(skill_root / "SKILL.md", plugin_root)
if skill_file is None:
continue
try:
metadata = parse_skill_metadata(skill_file.read_text(encoding="utf-8"))
except (OSError, UnicodeError):
metadata = None
if metadata is None or not valid_skill_metadata(metadata, candidate.name):
logger.warning("Ignoring Agent Plugin '{}' skill '{}': invalid metadata", plugin_name, candidate.name)
continue
skills.append((candidate.name, skill_file))
return skills
def _children(root: Path, label: str) -> list[Path]:
try:
return sorted(root.iterdir(), key=lambda path: path.name)
except OSError as exc:
logger.warning("Could not inspect {}: {}", label, exc)
return []
def _contained(path: Path, root: Path, *, directory: bool = False) -> Path | None:
try:
resolved = path.resolve(strict=True)
except OSError:
return None
expected_kind = resolved.is_dir() if directory else resolved.is_file()
return resolved if expected_kind and resolved.is_relative_to(root) else None
def _read_object(path: Path, root: Path) -> dict[str, object] | None:
contained = _contained(path, root)
if contained is None:
return None
try:
value = cast(object, json.loads(contained.read_text(encoding="utf-8")))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
logger.warning("Ignoring invalid Agent Plugin component '{}': {}", contained, exc)
return None
return cast(dict[str, object], value) if isinstance(value, dict) else None
+28 -62
View File
@@ -17,48 +17,9 @@ _STRIP_SKILL_FRONTMATTER = re.compile(
r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?",
re.DOTALL,
)
_SKILL_NAME = re.compile(r"^(?!.*--)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
_SKILL_NAME_LINE = re.compile(r"^name\s*:.*$", re.MULTILINE)
_SKILL_REFERENCE = re.compile(r"(?<![\w$])\$([A-Za-z0-9_-]+)")
def parse_skill_metadata(content: str) -> dict[str, object] | None:
"""Parse a skill document's YAML frontmatter."""
if not (match := _STRIP_SKILL_FRONTMATTER.match(content)):
return None
try:
parsed = yaml.safe_load(match.group(1))
except yaml.YAMLError:
return None
if not isinstance(parsed, dict):
return None
return {str(key): value for key, value in cast(dict[object, object], parsed).items()}
def valid_skill_metadata(metadata: dict[str, object], name: str) -> bool:
"""Return whether metadata satisfies the Agent Skills identity contract."""
description = metadata.get("description")
return (
metadata.get("name") == name
and len(name) <= 64
and _SKILL_NAME.fullmatch(name) is not None
and isinstance(description, str)
and 1 <= len(description.strip()) <= 1024
)
def normalize_skill_document(content: str, name: str) -> str | None:
"""Return a valid skill document with a canonical name."""
match = _STRIP_SKILL_FRONTMATTER.match(content)
metadata = parse_skill_metadata(content)
if match is None or metadata is None or not valid_skill_metadata(metadata | {"name": name}, name):
return None
frontmatter, replaced = _SKILL_NAME_LINE.subn(f"name: {name}", match.group(1), count=1)
if not replaced:
frontmatter = f"name: {name}\n{frontmatter}"
return f"---\n{frontmatter.strip()}\n---\n\n{content[match.end():].lstrip()}"
class SkillsLoader:
"""
Loader for agent skills.
@@ -99,25 +60,11 @@ class SkillsLoader:
Returns:
List of skill info dicts with 'name', 'path', 'source'.
"""
from nanobot.agent.plugins import enabled_agent_plugin_skills
plugin_skills = enabled_agent_plugin_skills(self.workspace)
skills = self._skill_entries_from_dir(self.workspace_skills, "workspace")
seen_names = {entry["name"] for entry in skills}
for name, path in plugin_skills:
if name in seen_names:
continue
skills.append(
{
"name": name,
"path": str(path),
"source": "plugin",
}
)
seen_names.add(name)
workspace_names = {entry["name"] for entry in skills}
if self.builtin_skills and self.builtin_skills.exists():
skills.extend(
self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=seen_names)
self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=workspace_names)
)
if self.disabled_skills:
@@ -137,11 +84,14 @@ class SkillsLoader:
Returns:
Skill content or None if not found.
"""
entry = next(
(skill for skill in self.list_skills(filter_unavailable=False) if skill["name"] == name),
None,
)
return Path(entry["path"]).read_text(encoding="utf-8") if entry else None
roots = [self.workspace_skills]
if self.builtin_skills:
roots.append(self.builtin_skills)
for root in roots:
path = root / name / "SKILL.md"
if path.exists():
return path.read_text(encoding="utf-8")
return None
def load_skills_for_context(self, skill_names: list[str]) -> str:
"""
@@ -195,7 +145,6 @@ class SkillsLoader:
sections: list[str] = []
groups = (
("Workspace skills", "workspace", self.workspace_skills),
("Agent Plugin skills", "plugin", self.workspace / "plugins"),
("Built-in skills", "builtin", self.builtin_skills),
)
for label, source, root in groups:
@@ -329,4 +278,21 @@ class SkillsLoader:
Returns:
Metadata dict or None.
"""
return parse_skill_metadata(self.load_skill(name) or "")
content = self.load_skill(name)
if not content or not content.startswith("---"):
return None
match = _STRIP_SKILL_FRONTMATTER.match(content)
if not match:
return None
try:
parsed = yaml.safe_load(match.group(1))
except yaml.YAMLError:
return None
if not isinstance(parsed, dict):
return None
# yaml.safe_load returns native types (int, bool, list, etc.);
# keep values as-is so downstream consumers get correct types.
metadata: dict[str, object] = {}
for key, value in cast(dict[object, object], parsed).items():
metadata[str(key)] = value
return metadata
+1 -5
View File
@@ -1340,14 +1340,10 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
"requires_restart": True,
}
try:
from nanobot.agent.plugins import agent_plugin_mcp_servers
from nanobot.config.loader import load_config, resolve_config_env_vars
config = resolve_config_env_vars(load_config())
next_servers = agent_plugin_mcp_servers(
config.workspace_path,
config.tools.mcp_servers,
)
next_servers = dict(config.tools.mcp_servers)
except Exception as exc:
logger.warning("MCP hot reload could not read config: {}", exc)
return {
+17 -51
View File
@@ -20,7 +20,6 @@ from urllib.parse import urlparse
import httpx
from loguru import logger
from nanobot.agent.skills import normalize_skill_document
from nanobot.apps.protocol import app_manifest, compact_dict
from nanobot.config.paths import get_runtime_subdir
from nanobot.security.workspace_policy import is_path_within
@@ -28,7 +27,6 @@ from nanobot.security.workspace_policy import is_path_within
CLI_ANYTHING_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/registry.json"
CLI_ANYTHING_PUBLIC_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/public_registry.json"
CLI_ANYTHING_RAW_BASE = "https://raw.githubusercontent.com/HKUDS/CLI-Anything/main"
AGENT_PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"
NANOBOT_EXTENSION_REGISTRY_URL = "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main/registry.json"
NANOBOT_EXTENSION_RAW_BASE = "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main"
_CATALOG_SOURCES = (
@@ -212,27 +210,11 @@ def _as_object_dict(value: object) -> dict[str, Any] | None:
return cast(dict[str, Any], value) if isinstance(value, dict) else None
def _skill_name(name: str, *, legacy: bool = False) -> str:
def _safe_skill_name(name: str) -> str:
clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-")
if not legacy:
clean = clean.replace("_", "-")
return f"cli-app-{clean or 'app'}"
def _plugin_skill_relative_path(name: str) -> str:
skill_name = _skill_name(name)
return f"plugins/{skill_name}/skills/{skill_name}/SKILL.md"
def cli_app_skill_relative_path(workspace: Path, name: str) -> str:
"""Return a CLI App's skill path, including the legacy location."""
canonical = _plugin_skill_relative_path(name)
legacy = f"skills/{_skill_name(name, legacy=True)}/SKILL.md"
if not (workspace / canonical).is_file() and (workspace / legacy).is_file():
return legacy
return canonical
def _has_shell_meta(command: str) -> bool:
return any(char in command for char in _SHELL_META_CHARS)
@@ -631,7 +613,7 @@ class CliAppManager:
"name": installed_name,
"entry_point": entry_point,
"source": str(data.get("source") or ""),
"skill": cli_app_skill_relative_path(self.workspace, installed_name),
"skill": f"skills/{_safe_skill_name(installed_name)}/SKILL.md",
"tool": "run_cli_app",
}
)
@@ -657,6 +639,9 @@ class CliAppManager:
install_cmd = str(app.get("install_cmd") or "")
return not _has_shell_meta(install_cmd)
def _skill_path(self, name: str) -> Path:
return self.workspace / "skills" / _safe_skill_name(name) / "SKILL.md"
def _app_payload(
self,
app: dict[str, Any],
@@ -692,7 +677,7 @@ class CliAppManager:
"status": status,
"logo_url": logo_url,
"brand_color": brand_color,
"skill_installed": (self.workspace / cli_app_skill_relative_path(self.workspace, name)).is_file(),
"skill_installed": self._skill_path(name).is_file(),
"manifest": self._manifest_payload(app, logo_url=logo_url, brand_color=brand_color),
}
@@ -728,8 +713,7 @@ class CliAppManager:
name = str(app["name"])
entry_point = str(app.get("entry_point") or "")
strategy = self._strategy(app)
skill_path = _plugin_skill_relative_path(name)
plugin_path = f"plugins/{_skill_name(name)}"
skill_path = f"skills/{_safe_skill_name(name)}/SKILL.md"
capabilities = [
compact_dict({
"type": "cli",
@@ -742,13 +726,13 @@ class CliAppManager:
install = compact_dict({
"supported": install_supported,
"strategy": strategy,
"managed_paths": [plugin_path],
"managed_paths": [skill_path],
"verification": ["entry_point_available"] if entry_point else [],
})
remove = compact_dict({
"supported": strategy != "unsupported",
"strategy": strategy,
"managed_paths": [plugin_path],
"managed_paths": [skill_path],
"verification": (
["package_manager_ok", "entry_point_absent", "managed_paths_absent"]
if strategy not in {"bundled", "unsupported"}
@@ -1048,10 +1032,11 @@ class CliAppManager:
name = str(app.get("name") or "unknown")
display = str(app.get("display_name") or name)
entry = str(app.get("entry_point") or f"cli-anything-{name}")
description = (_catalog_description(app) or f"Use {display} from nanobot.")[:1024]
description = _catalog_description(app) or f"Use {display} from nanobot."
return f"""---
name: {_skill_name(name)}
description: {json.dumps(description, ensure_ascii=False)}
name: {_safe_skill_name(name)}
description: >-
{description}
---
# {display}
@@ -1088,43 +1073,24 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
return note + "\n" + content
def install_skill(self, app: dict[str, Any]) -> Path:
name = str(app["name"])
path = self.workspace / _plugin_skill_relative_path(name)
path = self._skill_path(str(app["name"]))
path.parent.mkdir(parents=True, exist_ok=True)
content = self._fetch_skill_content(app) or self._fallback_skill(app)
content = normalize_skill_document(content, _skill_name(name)) or self._fallback_skill(app)
content = self._with_nanobot_skill_note(content, app)
path.write_text(content, encoding="utf-8")
plugin_root = path.parents[2]
manifest = compact_dict({
"$schema": AGENT_PLUGIN_SCHEMA,
"name": _skill_name(str(app["name"])),
"version": str(app.get("version") or ""),
"description": _catalog_description(app),
})
_write_json(plugin_root / "plugin.json", manifest)
legacy_dir = self.workspace / "skills" / _skill_name(str(app["name"]), legacy=True)
if legacy_dir.is_dir():
shutil.rmtree(legacy_dir)
return path
def remove_skill(self, name: str) -> None:
plugin_root = (self.workspace / _plugin_skill_relative_path(name)).parents[2]
if plugin_root.is_dir():
shutil.rmtree(plugin_root)
legacy_dir = self.workspace / "skills" / _skill_name(name, legacy=True)
if legacy_dir.is_dir():
shutil.rmtree(legacy_dir)
skill_dir = self._skill_path(name).parent
if skill_dir.is_dir():
shutil.rmtree(skill_dir)
def _record_installed(self, app: dict[str, Any]) -> dict[str, Any]:
from nanobot.agent.plugins import set_agent_plugin_enabled
installed = self._load_installed()
entry = self._installed_entry(app)
installed[str(app["name"])] = entry
self._save_installed(installed)
self.install_skill(app)
set_agent_plugin_enabled(self.workspace, _skill_name(str(app["name"])), True)
return entry
def install(self, name: str) -> dict[str, Any]:
+1 -3
View File
@@ -20,8 +20,6 @@ def runtime_lines_for_request(
"""Return CLI App annotations from an immutable request snapshot."""
structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
if isinstance(structured, list):
from nanobot.apps.cli.service import cli_app_skill_relative_path
structured_items = cast(list[Any], structured)
mentions = [
cast(Mapping[str, Any], item) for item in structured_items
@@ -34,7 +32,7 @@ def runtime_lines_for_request(
f"@{str(item['name']).strip().lower()} "
f"(installed; tool=run_cli_app; "
f"entry_point={str(item.get('entry_point') or 'unknown')}; "
f"skill={cli_app_skill_relative_path(workspace, str(item['name']))}). "
f"skill=skills/cli-app-{str(item['name']).strip().lower()}/SKILL.md). "
"Read the skill when useful, then run this app with `run_cli_app`; do not bypass it with shell."
for item in mentions
if str(item.get("name") or "").strip()
@@ -2090,14 +2090,6 @@ async def test_mcp_presets_routes_require_token_and_return_payload(
assert body["hot_reload"]["ok"] is True
assert body["restart_required_sections"] == []
disabled = await _webui_mutate(
channel,
"settings.mcp.disable",
{"name": "browserbase"},
)
assert disabled.status_code == 200
assert preset_queries[-1][0] == "disable"
custom = await _webui_mutate(
channel,
"settings.mcp.custom",
+3 -65
View File
@@ -16,11 +16,6 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, Mapping, cast
from nanobot.agent.plugins import (
AgentPluginState,
discover_agent_plugin_states,
set_agent_plugin_enabled,
)
from nanobot.agent.tools.mcp_oauth import (
delete_mcp_oauth_credentials,
mcp_oauth_has_credentials,
@@ -60,6 +55,7 @@ _MAX_TEST_TOOLS = 16
_DEFAULT_TEST_TIMEOUT = 20
_DEFAULT_CUSTOM_TIMEOUT = 30
_CUSTOM_ACTIONS = {"custom", "import", "import-cursor", "tools"}
McpReload = Callable[[], Awaitable[dict[str, Any]]]
@@ -915,31 +911,6 @@ def _custom_payload(
}
def _agent_plugin_payload(state: AgentPluginState) -> dict[str, Any]:
plugin = state.plugin
return {
"name": f"plugin-{plugin.name}",
"display_name": plugin.display_name,
"category": plugin.category,
"description": plugin.description or "Agent Plugin",
"docs_url": plugin.repository,
"transport": "stdio",
"requires": ", ".join(plugin.permissions),
"note": "",
"install_supported": False,
"installed": True,
"configured": True,
"enabled": state.enabled,
"available": state.enabled,
"status": "enabled" if state.enabled else "disabled",
"logo_url": plugin.logo,
"brand_color": plugin.accent_color,
"required_fields": [],
"connection_summary": ", ".join(state.mcp_servers),
"source": "agent-plugin",
}
def mcp_presets_payload(
*,
last_action: dict[str, Any] | None = None,
@@ -958,16 +929,9 @@ def mcp_presets_payload(
for name, cfg in sorted(config.tools.mcp_servers.items())
if name not in known
]
existing_names = {str(row["name"]) for row in (*preset_rows, *custom_rows)}
plugin_rows = [
_agent_plugin_payload(state)
for state in discover_agent_plugin_states(config.workspace_path)
if f"plugin-{state.plugin.name}" not in existing_names
]
payload: dict[str, Any] = {
"presets": [*preset_rows, *custom_rows, *plugin_rows],
"installed_count": len(config.tools.mcp_servers)
+ sum(int(row["enabled"]) for row in plugin_rows),
"presets": [*preset_rows, *custom_rows],
"installed_count": len(config.tools.mcp_servers),
}
if last_action is not None:
payload["last_action"] = last_action
@@ -1578,32 +1542,6 @@ async def mcp_presets_settings_action(
config_path = config.path if config is not None else None
if action is None:
return mcp_presets_payload(config_path=config_path)
name = (_query_first(query, "name") or "").strip()
if name.startswith("plugin-"):
plugin_config = load_config(config_path) if config_path is not None else load_config()
plugin_name = name.removeprefix("plugin-")
plugin_states = discover_agent_plugin_states(plugin_config.workspace_path)
plugin_state = next((state for state in plugin_states if state.plugin.name == plugin_name), None)
if (
name not in plugin_config.tools.mcp_servers
and plugin_state is not None
):
if action not in {"enable", "disable"}:
raise McpPresetError("Agent Plugins support enable and disable actions only")
plugin = await asyncio.to_thread(
set_agent_plugin_enabled,
plugin_config.workspace_path,
plugin_name,
action == "enable",
)
verb = "enabled" if action == "enable" else "disabled"
payload = mcp_presets_payload(
last_action={"ok": True, "message": f"{plugin.display_name} {verb}."},
config_path=config_path,
)
if reload_mcp is not None:
payload = attach_mcp_hot_reload_result(payload, await reload_mcp())
return payload
if action == "test":
return await mcp_presets_test_action(query, config_path=config_path)
if config is not None:
-1
View File
@@ -91,7 +91,6 @@ def _channel_connect_route(path: str) -> tuple[str, str] | None:
_MCP_PRESET_ACTIONS_BY_PATH = {
"/api/settings/mcp-presets/enable": "enable",
"/api/settings/mcp-presets/disable": "disable",
"/api/settings/mcp-presets/remove": "remove",
"/api/settings/mcp-presets/test": "test",
"/api/settings/mcp-presets/custom": "custom",
-1
View File
@@ -160,7 +160,6 @@ _WEBUI_MUTATION_PATHS = {
"settings.pairing.approve": "/api/settings/pairing/approve",
"settings.pairing.deny": "/api/settings/pairing/deny",
"settings.mcp.enable": "/api/settings/mcp-presets/enable",
"settings.mcp.disable": "/api/settings/mcp-presets/disable",
"settings.mcp.remove": "/api/settings/mcp-presets/remove",
"settings.mcp.test": "/api/settings/mcp-presets/test",
"settings.mcp.custom": "/api/settings/mcp-presets/custom",
-294
View File
@@ -1,294 +0,0 @@
import json
import shutil
from pathlib import Path
import pytest
from nanobot.agent import plugins as agent_plugins
from nanobot.agent.plugins import (
AGENT_PLUGIN_MCP_SCHEMA,
AGENT_PLUGIN_SCHEMA,
agent_plugin_mcp_servers,
discover_agent_plugin_states,
enabled_agent_plugin_skills,
set_agent_plugin_enabled,
)
from nanobot.agent.skills import SkillsLoader
@pytest.fixture(autouse=True)
def _isolate_plugin_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
agent_plugins,
"get_config_path",
lambda: tmp_path / "config" / "config.json",
)
def _write_skill(root: Path, name: str, *, description: str = "Plugin skill.") -> Path:
skill = root / "skills" / name
skill.mkdir(parents=True)
(skill / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n",
encoding="utf-8",
)
return skill
def _manifest(name: str, **fields: object) -> dict[str, object]:
return {"$schema": AGENT_PLUGIN_SCHEMA, "name": name, **fields}
def _write_plugin(
workspace: Path,
directory: str,
*,
name: str | None = None,
manifest: dict[str, object] | None = None,
) -> Path:
root = workspace / "plugins" / directory
root.mkdir(parents=True)
payload = manifest or _manifest(name or directory)
(root / "plugin.json").write_text(json.dumps(payload), encoding="utf-8")
return root
def _write_mcp(root: Path, servers: dict[str, object], **fields: object) -> None:
payload = {"$schema": AGENT_PLUGIN_MCP_SCHEMA, "mcpServers": servers, **fields}
(root / "mcp.json").write_text(json.dumps(payload), encoding="utf-8")
def _loaded_plugin_skills(workspace: Path) -> list[str]:
return [name for name, _ in enabled_agent_plugin_skills(workspace)]
def test_skills_loader_discovers_agent_plugin_skill(tmp_path: Path) -> None:
loader = SkillsLoader(tmp_path, builtin_skills_dir=tmp_path / "builtin")
assert loader.list_skills() == []
plugin = _write_plugin(tmp_path, "acme-tools")
_write_skill(plugin, "release-notes", description="Draft release notes from changes.")
set_agent_plugin_enabled(tmp_path, "acme-tools", True)
assert loader.list_skills() == [
{
"name": "release-notes",
"path": str(plugin / "skills" / "release-notes" / "SKILL.md"),
"source": "plugin",
}
]
assert loader.get_explicitly_invoked_skills("Use $release-notes") == ["release-notes"]
assert "Draft release notes" in (loader.load_skill("release-notes") or "")
assert "### Agent Plugin skills" in loader.build_skills_summary()
assert "`acme-tools/skills/release-notes/SKILL.md`" in loader.build_skills_summary()
shutil.rmtree(plugin)
assert loader.list_skills() == []
assert loader.build_skills_summary() == ""
def test_agent_plugin_skills_are_direct_and_contained(tmp_path: Path) -> None:
plugin = _write_plugin(tmp_path, "acme-tools")
_write_skill(plugin, "direct")
nested = plugin / "skills" / "group" / "nested"
nested.mkdir(parents=True)
(nested / "SKILL.md").write_text(
"---\nname: nested\ndescription: Nested skill.\n---\n",
encoding="utf-8",
)
outside = tmp_path / "outside"
_write_skill(outside, "escaped")
try:
(plugin / "skills" / "escaped").symlink_to(
outside / "skills" / "escaped",
target_is_directory=True,
)
except OSError as exc:
pytest.skip(f"directory symlink unavailable: {exc}")
set_agent_plugin_enabled(tmp_path, "acme-tools", True)
assert _loaded_plugin_skills(tmp_path) == ["direct"]
@pytest.mark.parametrize(
("manifest", "valid"),
[
(
{"$schema": "https://agent-plugins.org/schemas/2.0.0/plugin.schema.json", "name": "demo"},
False,
),
({"$schema": AGENT_PLUGIN_SCHEMA, "name": "Bad-Name"}, False),
(
_manifest(
"demo",
futureField=True,
extensions="invalid but non-fatal",
),
True,
),
],
)
def test_agent_plugin_manifest_failure_boundary(
tmp_path: Path,
manifest: dict[str, object],
valid: bool,
) -> None:
plugin = _write_plugin(tmp_path, "demo", manifest=manifest)
_write_skill(plugin, "example")
if valid:
set_agent_plugin_enabled(tmp_path, "demo", True)
assert bool(discover_agent_plugin_states(tmp_path)) is valid
assert _loaded_plugin_skills(tmp_path) == (["example"] if valid else [])
def test_agent_plugin_logo_is_validated_and_contained(tmp_path: Path) -> None:
plugin = _write_plugin(
tmp_path,
"demo",
manifest=_manifest(
"demo",
extensions={"dev.nanobot": {"logo": "./assets/icon.png"}},
),
)
assets = plugin / "assets"
assets.mkdir()
(assets / "icon.png").write_bytes(b"\x89PNG\r\n\x1a\nlogo")
outside = tmp_path / "outside.png"
outside.write_bytes(b"\x89PNG\r\n\x1a\nlogo")
escaped = _write_plugin(
tmp_path,
"escaped",
manifest=_manifest(
"escaped",
extensions={"dev.nanobot": {"logo": "./assets/icon.png"}},
),
)
escaped_assets = escaped / "assets"
escaped_assets.mkdir()
try:
(escaped_assets / "icon.png").symlink_to(outside)
except OSError as exc:
pytest.skip(f"file symlink unavailable: {exc}")
logos = {state.plugin.name: state.plugin.logo for state in discover_agent_plugin_states(tmp_path)}
assert logos == {
"demo": "data:image/png;base64,iVBORw0KGgpsb2dv",
"escaped": None,
}
@pytest.mark.parametrize(
("skill_name", "frontmatter"),
[
("wrong-directory", "name: another\ndescription: Mismatch."),
("missing-description", "name: missing-description"),
("Bad-Name", "name: Bad-Name\ndescription: Invalid name."),
],
)
def test_invalid_agent_skill_is_skipped(
tmp_path: Path,
skill_name: str,
frontmatter: str,
) -> None:
plugin = _write_plugin(tmp_path, "demo")
skill = plugin / "skills" / skill_name
skill.mkdir(parents=True)
(skill / "SKILL.md").write_text(f"---\n{frontmatter}\n---\n", encoding="utf-8")
set_agent_plugin_enabled(tmp_path, "demo", True)
assert _loaded_plugin_skills(tmp_path) == []
def test_skill_precedence_follows_plugin_lifecycle(tmp_path: Path) -> None:
plugin = _write_plugin(tmp_path, "demo")
skill = _write_skill(plugin, "shared", description="Plugin version.")
(skill / "SKILL.md").write_text(
"---\nname: shared\ndescription: Plugin version.\nalways: true\n---\n\nPlugin body.\n",
encoding="utf-8",
)
builtin = tmp_path / "builtin"
builtin_skill = builtin / "shared"
builtin_skill.mkdir(parents=True)
(builtin_skill / "SKILL.md").write_text(
"---\nname: shared\ndescription: Built-in version.\n---\n\nBuilt-in body.\n",
encoding="utf-8",
)
workspace_skill = tmp_path / "skills" / "shared"
workspace_skill.mkdir(parents=True)
(workspace_skill / "SKILL.md").write_text(
"---\nname: shared\ndescription: Workspace version.\n---\n",
encoding="utf-8",
)
loader = SkillsLoader(tmp_path, builtin_skills_dir=builtin)
assert [entry["source"] for entry in loader.list_skills()] == ["workspace"]
assert "Workspace version" in (loader.load_skill("shared") or "")
assert loader.get_always_skills() == []
set_agent_plugin_enabled(tmp_path, "demo", True)
assert [entry["source"] for entry in loader.list_skills()] == ["workspace"]
shutil.rmtree(workspace_skill)
assert [entry["source"] for entry in loader.list_skills()] == ["plugin"]
assert "Plugin body" in (loader.load_skill("shared") or "")
assert loader.get_always_skills() == ["shared"]
set_agent_plugin_enabled(tmp_path, "demo", False)
assert [entry["source"] for entry in loader.list_skills()] == ["builtin"]
assert "Built-in version" in (loader.load_skill("shared") or "")
def test_plugin_mcp_requires_explicit_enable(tmp_path: Path) -> None:
plugin = _write_plugin(tmp_path, "desktop")
executable = plugin / "bin" / "server"
executable.parent.mkdir()
executable.write_text("#!/bin/sh\n", encoding="utf-8")
_write_mcp(
plugin,
{
"desktop": {
"type": "stdio",
"command": "./bin/server",
"args": ["--data", "${PLUGIN_DATA}/state"],
"cwd": "${PLUGIN_ROOT}",
},
"public-http": {"type": "streamable-http", "url": "http://example.com/mcp"},
"escape": {"type": "stdio", "command": "../outside"},
},
)
assert agent_plugin_mcp_servers(tmp_path) == {}
set_agent_plugin_enabled(tmp_path, "desktop", True)
servers = agent_plugin_mcp_servers(tmp_path)
server = servers["desktop"]
assert server.command == str(executable)
assert server.cwd == str(plugin)
assert server.env["PLUGIN_ROOT"] == str(plugin)
assert server.args[0] == "--data"
assert server.args[1].endswith("/state")
set_agent_plugin_enabled(tmp_path, "desktop", False)
assert agent_plugin_mcp_servers(tmp_path) == {}
def test_plugin_state_symlink_cannot_escape_config_root(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config = tmp_path / "config"
outside = tmp_path / "outside"
config.mkdir()
outside.mkdir()
try:
(config / "plugin-data").symlink_to(outside, target_is_directory=True)
except OSError as exc:
pytest.skip(f"directory symlink unavailable: {exc}")
monkeypatch.setattr(
agent_plugins,
"get_config_path",
lambda: config / "config.json",
)
_write_plugin(tmp_path, "desktop")
with pytest.raises(RuntimeError, match="escapes its parent"):
set_agent_plugin_enabled(tmp_path, "desktop", True)
-1
View File
@@ -266,7 +266,6 @@ def test_disabled_skills_excluded_from_list(tmp_path: Path) -> None:
assert len(entries) == 1
assert entries[0]["name"] == "beta"
assert entries[0]["path"] == str(beta_path)
assert loader.load_skill("alpha") is None
def test_disabled_skills_empty_set_no_effect(tmp_path: Path) -> None:
+6 -46
View File
@@ -9,20 +9,9 @@ from types import SimpleNamespace
import pytest
from nanobot.agent import plugins as agent_plugins
from nanobot.agent.skills import SkillsLoader
from nanobot.apps.cli.service import CliAppError, CliAppManager, CliAppsRuntimeConfig
@pytest.fixture(autouse=True)
def _isolate_plugin_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
agent_plugins,
"get_config_path",
lambda: tmp_path / "config" / "config.json",
)
def _write_cache(path: Path, registry: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
@@ -402,9 +391,6 @@ def test_install_dispatches_safe_pip_and_installs_skill(
"_fetch_skill_content",
lambda app: "---\nname: cli-anything-gimp\ndescription: GIMP\n---\n# GIMP\n",
)
legacy = manager.workspace / "skills" / "cli-app-gimp" / "SKILL.md"
legacy.parent.mkdir(parents=True)
legacy.write_text("legacy", encoding="utf-8")
payload = manager.install("gimp")
@@ -414,23 +400,9 @@ def test_install_dispatches_safe_pip_and_installs_skill(
assert "state_recorded" in payload["last_action"]["verification"]
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
assert installed["gimp"]["entry_point"] == "cli-anything-gimp"
plugin = manager.workspace / "plugins" / "cli-app-gimp"
skill = plugin / "skills" / "cli-app-gimp" / "SKILL.md"
skill = manager.workspace / "skills" / "cli-app-gimp" / "SKILL.md"
assert skill.is_file()
assert json.loads((plugin / "plugin.json").read_text(encoding="utf-8")) == {
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "cli-app-gimp",
"version": "1.0.0",
"description": "Public duplicate entry",
}
assert "name: cli-app-gimp" in skill.read_text(encoding="utf-8")
assert 'run_cli_app` tool with `name="gimp"' in skill.read_text(encoding="utf-8")
assert [
item["name"]
for item in SkillsLoader(manager.workspace).list_skills()
if item["source"] == "plugin"
] == ["cli-app-gimp"]
assert not legacy.exists()
def test_run_argv_logs_command_exit_and_output(
@@ -515,7 +487,7 @@ def test_install_records_available_cli_without_reinstalling(
assert "entry_point_available" in payload["last_action"]["verification"]
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
assert installed["feishu"]["entry_point_path"] == str(resolved)
skill = manager.workspace / "plugins/cli-app-feishu/skills/cli-app-feishu/SKILL.md"
skill = manager.workspace / "skills" / "cli-app-feishu" / "SKILL.md"
assert skill.is_file()
assert 'run_cli_app` tool with `name="feishu"' in skill.read_text(encoding="utf-8")
@@ -732,8 +704,7 @@ def test_uninstall_removes_installed_state_and_generated_skill(
manager = _manager(tmp_path)
_seed_catalog(manager)
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
plugin_dir = manager.workspace / "plugins" / "cli-app-gimp"
skill_dir = plugin_dir / "skills" / "cli-app-gimp"
skill_dir = manager.workspace / "skills" / "cli-app-gimp"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text("# GIMP\n", encoding="utf-8")
monkeypatch.setattr(
@@ -746,7 +717,7 @@ def test_uninstall_removes_installed_state_and_generated_skill(
assert payload["last_action"]["ok"] is True
assert "gimp" not in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
assert not plugin_dir.exists()
assert not skill_dir.exists()
def test_uninstall_uses_safe_python_m_pip_uninstall_command(
@@ -874,30 +845,19 @@ def test_mentioned_installed_apps_only_returns_installed_mentions(tmp_path: Path
"name": "zoom",
"entry_point": "cli-anything-zoom",
"source": "public",
"skill": "plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md",
"skill": "skills/cli-app-zoom/SKILL.md",
"tool": "run_cli_app",
},
{
"name": "gimp",
"entry_point": "cli-anything-gimp",
"source": "harness",
"skill": "plugins/cli-app-gimp/skills/cli-app-gimp/SKILL.md",
"skill": "skills/cli-app-gimp/SKILL.md",
"tool": "run_cli_app",
},
]
def test_remove_skill_cleans_legacy_underscored_name(tmp_path: Path) -> None:
manager = _manager(tmp_path)
legacy = manager.workspace / "skills" / "cli-app-unimol_tools" / "SKILL.md"
legacy.parent.mkdir(parents=True)
legacy.write_text("# Legacy Uni-Mol\n", encoding="utf-8")
manager.remove_skill("unimol_tools")
assert not legacy.exists()
def test_install_rejects_unknown_and_script_strategy(tmp_path: Path) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
+9 -11
View File
@@ -38,26 +38,24 @@ def test_cli_app_mentions_inject_runtime_metadata(tmp_path, monkeypatch):
assert "CLI App Mention: @zoom" in joined
assert "tool=run_cli_app" in joined
assert "entry_point=cli-anything-zoom" in joined
assert "skill=plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md" in joined
assert "skill=skills/cli-app-zoom/SKILL.md" in joined
def test_structured_cli_app_attachment_uses_existing_legacy_skill(tmp_path):
legacy = tmp_path / "skills" / "cli-app-unimol_tools" / "SKILL.md"
legacy.parent.mkdir(parents=True)
legacy.write_text("# Legacy Uni-Mol\n", encoding="utf-8")
def test_structured_cli_app_attachment_injects_runtime_metadata(tmp_path):
lines = runtime_lines_for_request(
"please use @unimol_tools",
"please use @zoom tonight",
{
"cli_apps": [{
"name": "unimol_tools",
"entry_point": "cli-anything-unimol-tools",
"name": "zoom",
"entry_point": "cli-anything-zoom",
"display_name": "Zoom",
}],
},
tmp_path,
)
joined = "\n".join(lines)
assert "CLI App Attachment: @unimol_tools" in joined
assert "CLI App Attachment: @zoom" in joined
assert "tool=run_cli_app" in joined
assert "entry_point=cli-anything-unimol-tools" in joined
assert "skill=skills/cli-app-unimol_tools/SKILL.md" in joined
assert "entry_point=cli-anything-zoom" in joined
assert "skill=skills/cli-app-zoom/SKILL.md" in joined
+1 -107
View File
@@ -1,14 +1,10 @@
from __future__ import annotations
import asyncio
import json
from functools import partial
from pathlib import Path
import pytest
from mcp.shared.auth import OAuthToken
from nanobot.agent.plugins import AGENT_PLUGIN_MCP_SCHEMA, AGENT_PLUGIN_SCHEMA
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 (
@@ -16,58 +12,13 @@ from nanobot.webui.mcp_presets_api import (
custom_mcp_action,
mcp_presets_action,
mcp_presets_payload,
mcp_presets_settings_action,
mcp_presets_test_action,
normalize_mcp_preset_mentions,
)
def _use_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps({"agents": {"defaults": {"workspace": str(tmp_path / "workspace")}}}),
encoding="utf-8",
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
def _write_agent_plugin(workspace: Path) -> None:
root = workspace / "plugins" / "desktop"
command = root / "bin" / "server"
command.parent.mkdir(parents=True, exist_ok=True)
command.write_text("#!/bin/sh\n", encoding="utf-8")
assets = root / "assets"
assets.mkdir()
(assets / "icon.png").write_bytes(b"\x89PNG\r\n\x1a\nlogo")
(root / "plugin.json").write_text(
json.dumps(
{
"$schema": AGENT_PLUGIN_SCHEMA,
"name": "desktop",
"description": "Control the local desktop.",
"extensions": {
"dev.nanobot": {
"displayName": "Desktop Control",
"accentColor": "#ff7a1a",
"logo": "./assets/icon.png",
"permissions": ["screen-recording"],
}
},
}
),
encoding="utf-8",
)
(root / "mcp.json").write_text(
json.dumps(
{
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
"mcpServers": {
"desktop": {"type": "stdio", "command": "./bin/server"},
},
}
),
encoding="utf-8",
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", tmp_path / "config.json")
def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -109,63 +60,6 @@ def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest
assert manifest["trust"]["review_status"] == "builtin_preset"
def test_agent_plugin_reuses_mcp_catalog_and_runtime_action(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_config(tmp_path, monkeypatch)
_write_agent_plugin(load_config().workspace_path)
row = next(item for item in mcp_presets_payload()["presets"] if item["source"] == "agent-plugin")
assert row["name"] == "plugin-desktop"
assert row["display_name"] == "Desktop Control"
assert row["logo_url"] == "data:image/png;base64,iVBORw0KGgpsb2dv"
assert row["install_supported"] is False
assert row["installed"] is True
assert row["configured"] is True
assert row["enabled"] is False
assert row["status"] == "disabled"
async def reload() -> dict[str, object]:
return {"ok": True, "message": "MCP reloaded.", "requires_restart": False}
plugin_action = partial(
mcp_presets_settings_action,
query={"name": ["plugin-desktop"]},
)
enabled = asyncio.run(plugin_action("enable", reload_mcp=reload))
enabled_row = next(item for item in enabled["presets"] if item["name"] == "plugin-desktop")
assert enabled_row["configured"] is True
assert enabled_row["enabled"] is True
assert enabled_row["status"] == "enabled"
assert enabled["requires_restart"] is False
disabled = asyncio.run(plugin_action("disable", reload_mcp=reload))
disabled_row = next(item for item in disabled["presets"] if item["name"] == "plugin-desktop")
assert disabled_row["installed"] is True
assert disabled_row["configured"] is True
assert disabled_row["enabled"] is False
assert disabled_row["status"] == "disabled"
with pytest.raises(McpPresetError, match="enable and disable"):
asyncio.run(plugin_action("remove"))
(load_config().workspace_path / "plugins" / "desktop" / "mcp.json").unlink()
assert any(item["name"] == "plugin-desktop" for item in mcp_presets_payload()["presets"])
config_path = tmp_path / "config.json"
config = json.loads(config_path.read_text(encoding="utf-8"))
config["tools"] = {
"mcpServers": {"plugin-desktop": {"type": "stdio", "command": "echo"}}
}
config_path.write_text(json.dumps(config), encoding="utf-8")
rows = [
item for item in mcp_presets_payload()["presets"] if item["name"] == "plugin-desktop"
]
assert len(rows) == 1
assert rows[0]["source"] == "custom"
@pytest.mark.asyncio
async def test_oauth_preset_is_one_click_configured_after_token_storage(
tmp_path,
@@ -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>;
+1 -1
View File
@@ -766,7 +766,7 @@ export async function fetchProviderModels(
export async function runMcpPresetAction(
transport: WebUIMutationTransport,
action: "enable" | "disable" | "remove" | "test",
action: "enable" | "remove" | "test",
name: string,
values: Record<string, string> = {},
): Promise<McpPresetsPayload> {
+1 -3
View File
@@ -9,9 +9,7 @@ export function isMcpPresetsPayload(value: unknown): value is McpPresetsPayload
}
export function installedMcpPresetsFromPayload(payload: McpPresetsPayload): McpPresetInfo[] {
return payload.presets.filter(
(preset) => preset.enabled ?? (preset.installed && preset.configured),
);
return payload.presets.filter((preset) => preset.installed && preset.configured);
}
export function notifyMcpPresetsChanged(payload: McpPresetsPayload): void {
-1
View File
@@ -962,7 +962,6 @@ export interface McpPresetInfo {
install_supported: boolean;
installed: boolean;
configured: boolean;
enabled?: boolean;
available: boolean;
status: "not_installed" | "configured" | "missing_credentials" | "missing_dependency" | "coming_soon" | string;
logo_url?: string | null;
-7
View File
@@ -904,13 +904,6 @@ describe("webui API helpers", () => {
20_000,
);
await runMcpPresetAction(mutationTransport, "disable", "plugin-desktop");
expect(requestMutation).toHaveBeenCalledWith(
"settings.mcp.disable",
{ name: "plugin-desktop" },
20_000,
);
await startMcpOAuth(mutationTransport, "notion", true);
expect(requestMutation).toHaveBeenCalledWith(
"settings.mcp.oauth_start",
@@ -0,0 +1,566 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import {
installSettingsViewTestHooks,
jsonResponse,
renderSettingsView,
requestMutationMock,
settingsPayload,
} from "@/tests/settings-test-utils";
const xmindMcpPreset = {
name: "xmind",
display_name: "Xmind",
category: "productivity",
description: "Create, read, and edit cloud mind maps through Xmind.",
docs_url: "https://xmind.com/user-guide/xmind-mcp",
transport: "streamableHttp",
auth: "oauth" as const,
requires: "Xmind account",
note: "Connects securely in your browser with Xmind OAuth.",
install_supported: true,
installed: false,
configured: false,
available: false,
status: "not_installed",
logo_url: null,
brand_color: "#F4B41A",
required_fields: [],
connection_summary: "",
enabled_tools: ["*"],
source: "preset",
};
describe("SettingsView Apps catalog", () => {
installSettingsViewTestHooks();
it("connects an OAuth MCP from the Apps catalog without manual callback input", async () => {
let connected = false;
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({
presets: [connected
? {
...xmindMcpPreset,
installed: true,
configured: true,
available: true,
status: "configured",
connection_summary: "https://app.xmind.com/api/mcp",
}
: xmindMcpPreset],
installed_count: connected ? 1 : 0,
});
}
if (url === "/api/settings/mcp-oauth/status?flow_id=flow-123") {
connected = true;
return jsonResponse({
flow_id: "flow-123",
name: "xmind",
status: "connected",
expires_in: 295,
hot_reload: {
ok: false,
requires_restart: false,
connected: ["xmind"],
failed: ["notion"],
message: "MCP config reloaded, but some servers did not connect: notion",
},
});
}
return { ok: false, status: 404, text: async () => "Not found" } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockImplementation(async (action: string) => {
if (action === "settings.mcp.oauth_start") {
return {
flow_id: "flow-123",
name: "xmind",
status: "authorization_required",
expires_in: 300,
authorization_url: "https://accounts.xmind.test/authorize?state=state-123",
};
}
return settingsPayload();
});
const replace = vi.fn();
const popup = {
opener: window,
closed: false,
location: { replace },
document: { title: "", body: { textContent: "" } },
focus: vi.fn(),
close: vi.fn(),
};
const open = vi.fn(() => popup);
vi.stubGlobal("open", open);
renderSettingsView({ initialSection: "apps" });
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
expect(screen.getByText("MCP tools")).toBeInTheDocument();
const connectButton = await screen.findByRole("button", { name: "Connect Xmind" });
expect(connectButton).toHaveTextContent("Connect");
fireEvent.click(connectButton);
expect(open).toHaveBeenCalledWith(
"about:blank",
"nanobot-mcp-oauth",
"popup,width=560,height=720,resizable=yes,scrollbars=yes",
);
await waitFor(() => expect(replace).toHaveBeenCalledWith(
"https://accounts.xmind.test/authorize?state=state-123",
));
expect(popup.opener).toBeNull();
expect(screen.queryByRole("textbox", { name: /authorization/i })).not.toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent(
"Finish signing in in the browser window.",
);
expect(screen.getByRole("button", { name: "Connecting Xmind" })).toHaveTextContent(
"Connecting…",
);
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
expect(await screen.findByRole("button", { name: "Xmind: Configured" }, { timeout: 2500 }))
.toHaveTextContent("Configured");
expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument();
expect(screen.queryByText("Xmind connected.")).not.toBeInTheDocument();
expect(screen.queryByText(/some servers did not connect: notion/i)).not.toBeInTheDocument();
expect(popup.close).toHaveBeenCalledTimes(1);
expect(replace).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/mcp-oauth/status?flow_id=flow-123",
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
);
});
it("configures OAuth for a custom remote MCP without importing JSON", async () => {
const customPreset = {
...xmindMcpPreset,
name: "team-mcp",
display_name: "team-mcp",
source: "custom",
installed: true,
status: "authorization_required",
connection_summary: "https://mcp.example.com/mcp",
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, text: async () => "Not found" } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockImplementation(async (action: string) => {
if (action === "settings.mcp.custom") {
return {
presets: [customPreset],
installed_count: 1,
hot_reload: {
ok: false,
message: "MCP config reloaded, but some servers did not connect: team-mcp",
failed: ["team-mcp"],
},
last_action: { ok: true, message: "Saved custom MCP server team-mcp." },
};
}
return settingsPayload();
});
renderSettingsView({ initialSection: "apps" });
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
fireEvent.click(await screen.findByRole("button", { name: "Custom" }));
expect(screen.queryByText("Authentication")).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Server name"), {
target: { value: "team-mcp" },
});
fireEvent.click(screen.getByRole("button", { name: "HTTP" }));
fireEvent.change(screen.getByLabelText("URL"), {
target: { value: "https://mcp.example.com/mcp" },
});
const authentication = screen.getByRole("group", { name: "Authentication" });
const oauth = within(authentication).getByRole("button", { name: "OAuth" });
expect(oauth).toHaveAttribute("aria-pressed", "false");
fireEvent.click(within(authentication).getByRole("button", { name: "Headers" }));
fireEvent.change(screen.getByLabelText("Headers JSON"), {
target: { value: '{"Authorization":"Bearer stale"}' },
});
expect(screen.getByText("Add the request headers used by this server.")).toBeInTheDocument();
fireEvent.click(oauth);
expect(oauth).toHaveAttribute("aria-pressed", "true");
expect(screen.queryByLabelText("Headers JSON")).not.toBeInTheDocument();
expect(
screen.getByText("Save the server, then select Connect to sign in."),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Save MCP" }));
await waitFor(() => {
const saveCall = requestMutationMock.mock.calls.find(
([action]) => action === "settings.mcp.custom",
);
expect(saveCall).toBeDefined();
const values = saveCall?.[1] as Record<string, string>;
expect(values).toMatchObject({
name: "team-mcp",
transport: "streamableHttp",
url: "https://mcp.example.com/mcp",
auth: "oauth",
});
expect(values).not.toHaveProperty("headers");
expect(saveCall?.[2]).toBe(20_000);
});
expect(await screen.findByRole("button", { name: "Connect team-mcp" }))
.toBeInTheDocument();
expect(
screen.queryByText("MCP config reloaded, but some servers did not connect: team-mcp"),
).not.toBeInTheDocument();
});
it("offers a pasted callback flow when the remote WebUI uses HTTP", async () => {
let completed = false;
const callbackUrl =
"http://127.0.0.1:8765/auth/mcp/callback?code=oauth-code&state=manual-state";
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({
presets: [completed
? {
...xmindMcpPreset,
installed: true,
configured: true,
available: true,
status: "configured",
connection_summary: "https://app.xmind.com/api/mcp",
}
: xmindMcpPreset],
installed_count: completed ? 1 : 0,
});
}
if (url === "/api/settings/mcp-oauth/status?flow_id=flow-manual") {
return jsonResponse({
flow_id: "flow-manual",
name: "xmind",
status: completed ? "connected" : "authorization_required",
expires_in: 298,
completion_input: "callback_url",
authorization_url: completed
? undefined
: "https://accounts.xmind.test/authorize?state=manual-state",
hot_reload: completed ? { ok: true, requires_restart: false } : undefined,
});
}
return { ok: false, status: 404, text: async () => "Not found" } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockImplementation(async (action: string) => {
if (action === "settings.mcp.oauth_start") {
return {
flow_id: "flow-manual",
name: "xmind",
status: "authorization_required",
expires_in: 300,
completion_input: "callback_url",
authorization_url: "https://accounts.xmind.test/authorize?state=manual-state",
};
}
if (action === "settings.mcp.oauth_complete") {
completed = true;
return {
flow_id: "flow-manual",
name: "xmind",
status: "connecting",
expires_in: 299,
completion_input: "callback_url",
};
}
return settingsPayload();
});
const popup = {
opener: window,
closed: false,
location: { replace: vi.fn() },
document: { title: "", body: { textContent: "" } },
focus: vi.fn(),
close: vi.fn(),
};
vi.stubGlobal("open", vi.fn(() => popup));
renderSettingsView({ initialSection: "apps" });
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" }));
const callbackInput = await screen.findByRole("textbox", { name: "Full callback URL" });
expect(screen.getByText(/localhost page will not load/i)).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent(
"Finish signing in, then paste the callback URL into nanobot.",
);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
fireEvent.change(callbackInput, { target: { value: callbackUrl } });
fireEvent.click(screen.getByRole("button", { name: "Finish sign-in" }));
await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith(
"settings.mcp.oauth_complete",
{ flow_id: "flow-manual", callback_url: callbackUrl },
20_000,
));
expect(await screen.findByRole("button", { name: "Xmind: Configured" }, { timeout: 2500 }))
.toHaveTextContent("Configured");
expect(screen.queryByRole("textbox", { name: "Full callback URL" })).not.toBeInTheDocument();
expect(popup.close).toHaveBeenCalledTimes(1);
});
it("lets the user cancel an active OAuth connection after closing the popup", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [xmindMcpPreset], installed_count: 0 });
}
if (url === "/api/settings/mcp-oauth/status?flow_id=flow-cancel") {
return new Promise<Response>(() => {});
}
return { ok: false, status: 404, text: async () => "Not found" } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockImplementation(async (action: string) => {
if (action === "settings.mcp.oauth_start") {
return {
flow_id: "flow-cancel",
name: "xmind",
status: "authorization_required",
expires_in: 300,
authorization_url: "https://accounts.xmind.test/authorize?state=cancel",
};
}
if (action === "settings.mcp.oauth_cancel") {
return {
flow_id: "flow-cancel",
name: "xmind",
status: "cancelled",
expires_in: 299,
};
}
return settingsPayload();
});
const popup = {
opener: window,
closed: false,
location: { replace: vi.fn() },
document: { title: "", body: { textContent: "" } },
focus: vi.fn(),
close: vi.fn(),
};
vi.stubGlobal("open", vi.fn(() => popup));
renderSettingsView({ initialSection: "apps" });
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" }));
const cancelButton = await screen.findByRole("button", { name: "Cancel" });
expect(screen.getByRole("button", { name: "Connecting Xmind" })).toBeInTheDocument();
popup.closed = true;
fireEvent.click(cancelButton);
await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith(
"settings.mcp.oauth_cancel",
{ flow_id: "flow-cancel" },
20_000,
));
expect(await screen.findByRole("button", { name: "Connect Xmind" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Connecting Xmind" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument();
expect(popup.close).not.toHaveBeenCalled();
});
it("silently removes an MCP when the card already shows the result", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({
presets: [{
...xmindMcpPreset,
installed: true,
configured: true,
available: true,
status: "configured",
connection_summary: "https://app.xmind.com/api/mcp",
}],
installed_count: 1,
});
}
return { ok: false, status: 404, text: async () => "Not found" } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockResolvedValueOnce({
presets: [xmindMcpPreset],
installed_count: 0,
requires_restart: false,
hot_reload: {
ok: true,
message: "MCP config reloaded without restarting nanobot.",
},
last_action: {
ok: true,
message: "Removed MCP preset for Xmind. MCP config reloaded without restarting nanobot.",
removed: true,
},
});
renderSettingsView({ initialSection: "apps" });
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
fireEvent.click(await screen.findByRole("button", { name: "Remove" }));
await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith(
"settings.mcp.remove",
{ name: "xmind" },
20_000,
));
expect(await screen.findByRole("button", { name: "Connect Xmind" })).toBeInTheDocument();
expect(screen.queryByText(/Removed MCP preset|reloaded without restarting/)).not.toBeInTheDocument();
});
it("offers a one-click recovery when the OAuth popup is blocked", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [xmindMcpPreset], installed_count: 0 });
}
return new Promise<Response>(() => {});
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockImplementation(async (action: string) => {
if (action === "settings.mcp.oauth_start") {
return {
flow_id: "flow-blocked",
name: "xmind",
status: "authorization_required",
expires_in: 300,
authorization_url: "https://accounts.xmind.test/authorize?state=blocked",
};
}
return settingsPayload();
});
const popup = {
opener: window,
closed: false,
location: { replace: vi.fn() },
focus: vi.fn(),
close: vi.fn(),
};
const open = vi.fn()
.mockReturnValueOnce(null)
.mockReturnValueOnce(popup);
vi.stubGlobal("open", open);
renderSettingsView({ initialSection: "apps" });
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" }));
const continueButton = await screen.findByRole("button", { name: "Continue sign-in" });
expect(screen.getByRole("status")).toHaveTextContent("Open the sign-in page to continue.");
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
fireEvent.click(continueButton);
expect(open).toHaveBeenLastCalledWith(
"https://accounts.xmind.test/authorize?state=blocked",
"nanobot-mcp-oauth",
"popup,width=560,height=720,resizable=yes,scrollbars=yes",
);
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
});
it("does not mistake a COOP-isolated OAuth tab for a blocked popup", async () => {
let popupIsolated = false;
let statusCalls = 0;
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [xmindMcpPreset], installed_count: 0 });
}
if (url === "/api/settings/mcp-oauth/status?flow_id=flow-coop") {
statusCalls += 1;
return jsonResponse({
flow_id: "flow-coop",
name: "xmind",
status: statusCalls === 1 ? "authorization_required" : "failed",
expires_in: 299,
error: statusCalls === 1 ? undefined : "Cancelled for test cleanup.",
authorization_url: statusCalls === 1
? "https://accounts.xmind.test/authorize?state=coop"
: undefined,
});
}
return { ok: false, status: 404, text: async () => "Not found" } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockImplementation(async (action: string) => {
if (action === "settings.mcp.oauth_start") {
return {
flow_id: "flow-coop",
name: "xmind",
status: "authorization_required",
expires_in: 300,
authorization_url: "https://accounts.xmind.test/authorize?state=coop",
};
}
return settingsPayload();
});
const popup = {
opener: window,
get closed() {
return popupIsolated;
},
location: {
replace: vi.fn(() => {
popupIsolated = true;
}),
},
document: { title: "", body: { textContent: "" } },
focus: vi.fn(),
close: vi.fn(),
};
vi.stubGlobal("open", vi.fn(() => popup));
renderSettingsView({ initialSection: "apps" });
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" }));
await waitFor(() => expect(statusCalls).toBe(1), { timeout: 2000 });
expect(screen.getByRole("status")).toHaveTextContent(
"Finish signing in in the browser window.",
);
expect(screen.queryByRole("button", { name: "Continue sign-in" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
});
});
@@ -0,0 +1,255 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { expect, it, vi } from "vitest";
import type { SettingsPayload } from "@/lib/types";
import { requestMutationMock, jsonResponse, settingsPayload, renderSettingsView, openPopover, installSettingsViewTestHooks } from "@/tests/settings-test-utils";
describe("Settings capabilities", () => {
installSettingsViewTestHooks();
it("selects image models from provider-specific options", async () => {
const base = settingsPayload();
const payload: SettingsPayload = {
...base,
image_generation: {
...base.image_generation,
providers: [
{
name: "openrouter",
label: "OpenRouter",
configured: true,
models: ["openai/gpt-5.4-image-2"],
default_model: "openai/gpt-5.4-image-2",
},
{
name: "gemini",
label: "Gemini",
configured: true,
models: ["gemini-2.5-flash-image", "imagen-4.0-generate-001"],
default_model: "gemini-2.5-flash-image",
},
{
name: "custom",
label: "Custom",
configured: true,
models: [],
default_model: null,
},
],
},
};
renderSettingsView({ initialSection: "image", initialSettings: payload });
expect(screen.queryByDisplayValue("openai/gpt-5.4-image-2")).not.toBeInTheDocument();
fireEvent.pointerDown(screen.getByRole("button", { name: "OpenRouter" }));
fireEvent.click(await screen.findByRole("menuitem", { name: "Gemini" }));
expect(await screen.findByRole("button", { name: "gemini-2.5-flash-image" })).toBeInTheDocument();
await openPopover(screen.getByRole("button", { name: "gemini-2.5-flash-image" }));
fireEvent.click(await screen.findByRole("option", { name: "imagen-4.0-generate-001" }));
await waitFor(() =>
expect(screen.getByRole("button", { name: "imagen-4.0-generate-001" })).toBeInTheDocument(),
);
await openPopover(screen.getByRole("button", { name: "imagen-4.0-generate-001" }));
const modelInput = await screen.findByRole("combobox", { name: "Search or type model ID" });
fireEvent.change(modelInput, { target: { value: "imagen-5-preview" } });
fireEvent.click(await screen.findByRole("option", { name: "Use “imagen-5-preview”" }));
expect(await screen.findByRole("button", { name: "imagen-5-preview" })).toBeInTheDocument();
fireEvent.pointerDown(screen.getByRole("button", { name: "Gemini" }));
fireEvent.click(await screen.findByRole("menuitem", { name: "Custom" }));
expect(screen.getByRole("button", { name: "imagen-5-preview" })).toBeInTheDocument();
await openPopover(screen.getByRole("button", { name: "imagen-5-preview" }));
const customProviderInput = await screen.findByRole("combobox", {
name: "Search or type model ID",
});
fireEvent.change(customProviderInput, { target: { value: "private/image-v2" } });
fireEvent.keyDown(customProviderInput, { key: "Enter" });
expect(await screen.findByRole("button", { name: "private/image-v2" })).toBeInTheDocument();
});
it("saves network safety without exposing technical SSRF copy", async () => {
const payload = settingsPayload();
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockResolvedValueOnce({
...payload,
advanced: { ...payload.advanced, webui_allow_local_service_access: false },
requires_restart: true,
restart_required_sections: ["runtime"],
});
renderSettingsView({ initialSection: "advanced" });
expect(await screen.findByText("Web safety")).toBeInTheDocument();
expect(screen.queryByText(/SSRF/i)).not.toBeInTheDocument();
expect(screen.queryByText("Private Service Protection")).not.toBeInTheDocument();
expect(screen.getByText("Default access")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Restricted" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Default Permission" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Full Access" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("switch", { name: "Local services" }));
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.network_safety.update",
{
webui_allow_local_service_access: false,
webui_default_access_mode: "default",
},
20_000,
),
);
});
it("saves optional-key web search providers without an API key", async () => {
const payload = {
...settingsPayload(),
web_search: {
...settingsPayload().web_search,
provider: "duckduckgo",
providers: [
{ name: "duckduckgo", label: "DuckDuckGo", credential: "none" as const },
{ name: "keenable", label: "Keenable", credential: "optional_api_key" as const },
],
},
};
const updatedPayload = {
...payload,
web_search: {
...payload.web_search,
provider: "keenable",
},
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 });
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockResolvedValueOnce(updatedPayload);
renderSettingsView({ initialSection: "browser" });
fireEvent.pointerDown(await screen.findByRole("button", { name: /DuckDuckGo/ }));
fireEvent.click(await screen.findByRole("menuitem", { name: "Keenable" }));
const saveButton = screen
.getAllByRole("button", { name: "Save" })
.find((button) => !(button as HTMLButtonElement).disabled);
if (!saveButton) throw new Error("enabled Save button was not found");
fireEvent.click(saveButton);
await waitFor(() =>
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.web_search.update",
{
provider: "keenable",
max_results: 5,
timeout: 30,
use_jina_reader: true,
},
20_000,
),
);
});
it("uses native host safety copy on the native surface", async () => {
const payload = {
...settingsPayload(),
surface: "native" as const,
runtime_surface: "native" as const,
};
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 });
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
renderSettingsView({ initialSection: "advanced" });
expect(await screen.findByText("App safety")).toBeInTheDocument();
expect(screen.queryByText("Web safety")).not.toBeInTheDocument();
expect(screen.getByText("Allow Full Access shell commands to reach services on this Mac.")).toBeInTheDocument();
});
it("refreshes settings with a fresh token after native engine restart", async () => {
const payload = {
...settingsPayload(),
surface: "native" as const,
runtime_surface: "native" as const,
runtime_capabilities: {
can_restart_engine: true,
can_pick_folder: true,
can_open_logs: true,
can_export_diagnostics: true,
},
};
const restartedPayload = {
...payload,
advanced: { ...payload.advanced, webui_allow_local_service_access: false },
requires_restart: true,
restart_required_sections: ["runtime"],
};
const refreshedPayload = {
...restartedPayload,
requires_restart: false,
restart_required_sections: [],
};
const restartEngine = vi.fn(async () => "fresh-token");
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const auth = (init?.headers as Record<string, string> | undefined)?.Authorization;
if (url === "/api/settings" && auth === "Bearer fresh-token") {
return jsonResponse(refreshedPayload);
}
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 });
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockResolvedValueOnce(restartedPayload);
renderSettingsView({
initialSection: "advanced",
onNativeEngineRestart: restartEngine,
});
expect(await screen.findByText("App safety")).toBeInTheDocument();
fireEvent.click(screen.getByRole("switch", { name: "Local services" }));
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(restartEngine).toHaveBeenCalledTimes(1));
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings",
expect.objectContaining({
headers: { Authorization: "Bearer fresh-token" },
}),
),
);
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+241
View File
@@ -0,0 +1,241 @@
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
import { expect, it, vi } from "vitest";
import type { SettingsPayload } from "@/lib/types";
import { jsonResponse, settingsPayload, renderSettingsView, installSettingsViewTestHooks } from "@/tests/settings-test-utils";
const thirdPartyBrandNotice =
"Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement.";
describe("Settings overview and appearance", () => {
installSettingsViewTestHooks();
it("persists the file edit display local preference", async () => {
renderSettingsView({
initialSection: "appearance",
initialSettings: settingsPayload(),
showSidebar: true,
});
expect(screen.getByText("File edit display")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Diff" }));
await waitFor(() => {
const saved = JSON.parse(localStorage.getItem("nanobot-webui.settings-preferences") || "{}");
expect(saved.fileEditDisplayMode).toBe("diff");
});
});
it("shows the third-party brand notice only with the brand logo preference", () => {
renderSettingsView({
initialSection: "appearance",
initialSettings: settingsPayload(),
showSidebar: true,
});
const brandLogosTitle = screen.getByText("Brand logos");
const brandLogosRow = brandLogosTitle.parentElement?.parentElement;
expect(brandLogosRow).not.toBeNull();
expect(
within(brandLogosRow as HTMLElement).getByText(thirdPartyBrandNotice),
).toBeInTheDocument();
expect(screen.getAllByText(thirdPartyBrandNotice)).toHaveLength(1);
});
it.each(["apps", "channels"] as const)(
"does not repeat the third-party brand notice in %s",
(initialSection) => {
renderSettingsView({ initialSection, initialSettings: settingsPayload() });
expect(screen.queryByText(thirdPartyBrandNotice)).not.toBeInTheDocument();
},
);
it("publishes the latest settings payload to the shell", async () => {
const payload = settingsPayload();
const onSettingsChange = vi.fn();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
renderSettingsView({ onSettingsChange });
await waitFor(() => expect(onSettingsChange).toHaveBeenCalledWith(payload));
});
it("does not keep Apps loading while an empty CLI catalog refresh is pending", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") {
return jsonResponse({
apps: [],
installed_count: 0,
catalog_updated_at: null,
catalog_refresh_pending: true,
});
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
renderSettingsView();
expect(await screen.findByText("No apps available.")).toBeInTheDocument();
expect(screen.queryByText("Loading Apps...")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Browse MCP tools" }));
expect(await screen.findByText("Add MCP server")).toBeInTheDocument();
});
it("shows token activity on the overview", async () => {
const payload: SettingsPayload = {
...settingsPayload(),
usage: {
days: [
{
date: "2026-06-03",
prompt_tokens: 1200,
completion_tokens: 300,
cached_tokens: 500,
total_tokens: 1500,
requests: 2,
},
],
total_tokens: 1500,
total_tokens_30d: 1500,
total_tokens_365d: 1500,
peak_day_tokens: 1500,
current_streak_days: 1,
longest_streak_days: 1,
active_days_30d: 1,
requests_30d: 2,
updated_at: "2026-06-03T00:00:00Z",
},
};
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
renderSettingsView({ initialSection: "overview" });
expect(await screen.findByLabelText("Token activity")).toBeInTheDocument();
expect(screen.getByText("Token Usage")).toBeInTheDocument();
expect(screen.queryByText("Token activity")).not.toBeInTheDocument();
expect(screen.queryByText("Total tokens")).not.toBeInTheDocument();
expect(screen.queryByText("Peak tokens")).not.toBeInTheDocument();
});
it("coalesces focus refreshes while usage is already loading", async () => {
const payload: SettingsPayload = {
...settingsPayload(),
usage: {
days: [],
total_tokens: 0,
total_tokens_30d: 0,
total_tokens_365d: 0,
peak_day_tokens: 0,
current_streak_days: 0,
longest_streak_days: 0,
active_days_30d: 0,
requests_30d: 0,
updated_at: null,
},
};
let resolveUsage!: (response: Response) => void;
const pendingUsage = new Promise<Response>((resolve) => {
resolveUsage = resolve;
});
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/usage") return pendingUsage;
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView({ initialSection: "overview", initialSettings: payload });
await waitFor(() => {
expect(fetchMock.mock.calls.filter(([input]) => (
String(input) === "/api/settings/usage"
))).toHaveLength(1);
});
window.dispatchEvent(new Event("focus"));
window.dispatchEvent(new Event("focus"));
expect(fetchMock.mock.calls.filter(([input]) => (
String(input) === "/api/settings/usage"
))).toHaveLength(1);
await act(async () => {
resolveUsage(jsonResponse(payload.usage));
await pendingUsage;
});
});
it("aligns token activity days with the configured timezone", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-02T18:00:00Z"));
const basePayload = settingsPayload();
const payload: SettingsPayload = {
...basePayload,
agent: {
...basePayload.agent,
timezone: "Asia/Shanghai",
},
usage: {
days: [
{
date: "2026-06-03",
prompt_tokens: 1200,
completion_tokens: 300,
cached_tokens: 500,
total_tokens: 1500,
requests: 2,
},
],
total_tokens: 1500,
total_tokens_30d: 1500,
total_tokens_365d: 1500,
peak_day_tokens: 1500,
current_streak_days: 1,
longest_streak_days: 1,
active_days_30d: 1,
requests_30d: 2,
updated_at: "2026-06-03T00:00:00Z",
},
};
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {})));
renderSettingsView({ initialSection: "overview", initialSettings: payload });
expect(screen.getByLabelText("2026-06-03: 1.5K tokens, 2 requests")).toBeInTheDocument();
});
});
+849
View File
@@ -0,0 +1,849 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { expect, it, vi } from "vitest";
import type { SettingsPayload } from "@/lib/types";
import { requestMutationMock, jsonResponse, settingsPayload, renderSettingsView, installSettingsViewTestHooks } from "@/tests/settings-test-utils";
async function chooseProviderToConfigure(label: string) {
fireEvent.pointerDown(
await screen.findByRole("button", { name: "Add your own model provider" }),
);
fireEvent.click(await screen.findByRole("menuitem", { name: label }));
}
describe("Settings providers", () => {
installSettingsViewTestHooks();
it("signs in to the xAI Grok provider", async () => {
const base = settingsPayload();
const xaiProvider = {
name: "xai_grok",
label: "xAI Grok",
configured: false,
auth_type: "oauth" as const,
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://cli-chat-proxy.grok.com/v1",
model_catalog: "builtin",
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
};
const payload: SettingsPayload = { ...base, providers: [xaiProvider] };
const signedIn: SettingsPayload = {
...payload,
providers: [{ ...xaiProvider, configured: true, oauth_account: "user@example.com" }],
};
const authorization = {
status: "authorization_required",
provider: "xai_grok",
flow_id: "flow-123",
authorization_url: "https://auth.x.ai/oauth2/authorize?state=test",
expires_in: 600,
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock
.mockResolvedValueOnce(authorization)
.mockResolvedValueOnce(signedIn);
const popup = {
opener: window,
location: { href: "about:blank" },
close: vi.fn(),
};
vi.stubGlobal("open", vi.fn(() => popup));
renderSettingsView({ initialSection: "models", initialSettings: payload });
await chooseProviderToConfigure("xAI Grok");
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
await waitFor(() =>
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.provider.oauth_login",
{ provider: "xai_grok" },
20_000,
),
);
expect(popup.opener).toBeNull();
expect(popup.location.href).toBe(authorization.authorization_url);
expect(
screen.getByText(
"Complete sign-in in your browser. Nanobot usually finishes automatically; if it does not, paste the authorization code below.",
),
).toBeInTheDocument();
const callbackInput = await screen.findByRole("textbox", {
name: "Authorization code",
});
fireEvent.change(callbackInput, {
target: { value: "secret" },
});
fireEvent.click(screen.getByRole("button", { name: "Finish sign-in" }));
await waitFor(() =>
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.provider.oauth_complete",
{
provider: "xai_grok",
flow_id: "flow-123",
authorization_response: "secret",
},
20_000,
),
);
expect(await screen.findByText("Signed in as user@example.com")).toBeInTheDocument();
});
it("recognizes remote access before starting xAI Grok sign-in", async () => {
const happyWindow = window as typeof window & {
happyDOM: { setURL: (url: string) => void };
};
const originalUrl = window.location.href;
happyWindow.happyDOM.setURL("http://203.0.113.10:18887/#/settings?section=models");
try {
const base = settingsPayload();
const xaiProvider = {
name: "xai_grok",
label: "xAI Grok",
configured: false,
auth_type: "oauth" as const,
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://cli-chat-proxy.grok.com/v1",
model_catalog: "builtin",
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
};
const payload: SettingsPayload = { ...base, providers: [xaiProvider] };
const authorization = {
status: "authorization_required",
provider: "xai_grok",
flow_id: "flow-remote",
authorization_url: "https://auth.x.ai/oauth2/authorize?state=remote",
expires_in: 600,
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockResolvedValueOnce(authorization);
const popup = {
opener: window,
location: { href: "about:blank" },
close: vi.fn(),
};
const openMock = vi.fn(() => popup);
vi.stubGlobal("open", openMock);
renderSettingsView({ initialSection: "models", initialSettings: payload });
await chooseProviderToConfigure("xAI Grok");
expect(
screen.getByText(
"Select Sign in to open xAI on your computer, then paste the authorization code shown after login.",
),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
const dialog = await screen.findByRole("dialog");
expect(openMock).not.toHaveBeenCalled();
expect(
within(dialog).getByText(
"Select Sign in to open xAI on your computer. After signing in, paste the authorization code shown by xAI below.",
),
).toBeInTheDocument();
expect(
within(dialog).queryByRole("textbox", { name: "xAI sign-in URL" }),
).not.toBeInTheDocument();
expect(
within(dialog).queryByRole("button", { name: "Copy" }),
).not.toBeInTheDocument();
expect(
within(dialog).getByRole("textbox", { name: "Authorization code" }),
).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole("button", { name: "Sign in" }));
expect(openMock).toHaveBeenCalledWith(
authorization.authorization_url,
"_blank",
"noopener,noreferrer",
);
expect(popup.opener).toBeNull();
} finally {
happyWindow.happyDOM.setURL(originalUrl);
}
});
it("polls local OpenAI Codex sign-in until the loopback callback completes", async () => {
const base = settingsPayload();
const codexProvider = {
name: "openai_codex",
label: "OpenAI Codex",
configured: false,
auth_type: "oauth" as const,
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://chatgpt.com/backend-api",
model_catalog: "builtin",
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
};
const payload: SettingsPayload = { ...base, providers: [codexProvider] };
const signedIn: SettingsPayload = {
...payload,
providers: [{ ...codexProvider, configured: true, oauth_account: "acct-codex" }],
};
const authorization = {
status: "authorization_required",
provider: "openai_codex",
flow_id: "flow-codex-local",
authorization_url: "https://auth.openai.com/oauth/authorize?state=local",
expires_in: 600,
completion_input: "callback_url",
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock
.mockResolvedValueOnce(authorization)
.mockResolvedValueOnce(signedIn);
const openMock = vi.fn();
vi.stubGlobal("open", openMock);
renderSettingsView({ initialSection: "models", initialSettings: payload });
await chooseProviderToConfigure("OpenAI Codex");
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
const dialog = await screen.findByRole("dialog");
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.provider.oauth_login",
{ provider: "openai_codex" },
20_000,
);
expect(openMock).not.toHaveBeenCalled();
expect(
within(dialog).getByText(
"Complete sign-in in your browser. Nanobot usually finishes automatically; if it does not, copy the full localhost callback URL from the address bar and paste it below.",
),
).toBeInTheDocument();
expect(within(dialog).getByText("Waiting for the browser callback…")).toBeInTheDocument();
expect(
within(dialog).queryByText("Paste the callback URL to continue."),
).not.toBeInTheDocument();
expect(
await screen.findByText("Signed in as acct-codex", {}, { timeout: 2500 }),
).toBeInTheDocument();
});
it("completes remote OpenAI Codex sign-in with the full callback URL", async () => {
const happyWindow = window as typeof window & {
happyDOM: { setURL: (url: string) => void };
};
const originalUrl = window.location.href;
happyWindow.happyDOM.setURL("http://203.0.113.10:18887/#/settings?section=models");
try {
const base = settingsPayload();
const codexProvider = {
name: "openai_codex",
label: "OpenAI Codex",
configured: false,
auth_type: "oauth" as const,
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://chatgpt.com/backend-api",
model_catalog: "builtin",
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
};
const payload: SettingsPayload = { ...base, providers: [codexProvider] };
const signedIn: SettingsPayload = {
...payload,
providers: [{ ...codexProvider, configured: true, oauth_account: "acct-codex" }],
};
const authorization = {
status: "authorization_required",
provider: "openai_codex",
flow_id: "flow-codex",
authorization_url: "https://auth.openai.com/oauth/authorize?state=test",
expires_in: 600,
completion_input: "callback_url",
};
const callbackUrl =
"http://localhost:1455/auth/callback?code=secret&state=test";
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockImplementation(async (
action: string,
mutationPayload: Record<string, unknown>,
) => {
if (action === "settings.provider.oauth_login") return authorization;
if (mutationPayload.authorization_response === callbackUrl) return signedIn;
return {
status: "pending",
provider: "openai_codex",
flow_id: "flow-codex",
};
});
const popup = {
opener: window,
location: { href: "about:blank" },
close: vi.fn(),
};
const openMock = vi.fn(() => popup);
vi.stubGlobal("open", openMock);
renderSettingsView({ initialSection: "models", initialSettings: payload });
await chooseProviderToConfigure("OpenAI Codex");
expect(
screen.getByText(
"Sign in through this browser, then paste the full localhost callback URL back into nanobot.",
),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
const dialog = await screen.findByRole("dialog");
expect(openMock).not.toHaveBeenCalled();
expect(
within(dialog).getByText(
"Open ChatGPT in this browser and finish signing in. When the localhost page fails to load, copy the full URL from the address bar and paste it below.",
),
).toBeInTheDocument();
expect(within(dialog).getByText("Paste the callback URL to continue.")).toBeInTheDocument();
const callbackInput = within(dialog).getByRole("textbox", {
name: "Full callback URL",
});
expect(callbackInput).toHaveAttribute(
"placeholder",
"http://localhost:1455/auth/callback?code=…&state=…",
);
fireEvent.click(within(dialog).getByRole("button", { name: "Open ChatGPT" }));
expect(openMock).toHaveBeenCalledWith(
authorization.authorization_url,
"_blank",
"noopener,noreferrer",
);
expect(popup.opener).toBeNull();
fireEvent.change(callbackInput, { target: { value: callbackUrl } });
fireEvent.click(within(dialog).getByRole("button", { name: "Finish sign-in" }));
await waitFor(() =>
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.provider.oauth_complete",
{
provider: "openai_codex",
flow_id: "flow-codex",
authorization_response: callbackUrl,
},
20_000,
),
);
expect(await screen.findByText("Signed in as acct-codex")).toBeInTheDocument();
} finally {
happyWindow.happyDOM.setURL(originalUrl);
}
});
it("saves scoped proxies for xAI and OpenAI Codex OAuth providers", async () => {
const base = settingsPayload();
const providers: SettingsPayload["providers"] = [
{
name: "xai_grok",
label: "xAI Grok",
configured: false,
auth_type: "oauth",
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://cli-chat-proxy.grok.com/v1",
model_catalog: "builtin",
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
proxy: "http://127.0.0.1:7000",
advanced_fields: ["extra_body", "proxy"],
extra_body: null,
},
{
name: "openai_codex",
label: "OpenAI Codex",
configured: false,
auth_type: "oauth",
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://chatgpt.com/backend-api",
model_catalog: "builtin",
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
proxy: null,
advanced_fields: ["extra_body", "proxy"],
extra_body: null,
},
];
let payload: SettingsPayload = { ...base, providers };
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockImplementation(async (
_action: string,
values: { provider?: string; proxy?: string; extraBody?: string },
) => {
payload = {
...payload,
providers: payload.providers.map((provider) =>
provider.name === values.provider
? {
...provider,
proxy: values.proxy || null,
extra_body: values.extraBody ? JSON.parse(values.extraBody) : null,
}
: provider,
),
};
return payload;
});
renderSettingsView({ initialSection: "models", initialSettings: payload });
await chooseProviderToConfigure("xAI Grok");
fireEvent.click(screen.getByRole("button", { name: "Advanced options" }));
const xaiProxy = screen.getByLabelText("Network proxy");
expect(xaiProxy).toHaveValue("http://127.0.0.1:7000");
fireEvent.change(xaiProxy, { target: { value: "http://127.0.0.1:7890" } });
expect(screen.getByRole("button", { name: "Sign in" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Sign in" })).toHaveAttribute(
"title",
"Save advanced changes before signing in.",
);
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() =>
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.provider.update",
{
provider: "xai_grok",
extraBody: "",
proxy: "http://127.0.0.1:7890",
},
20_000,
),
);
await waitFor(() => expect(screen.getByRole("button", { name: "Sign in" })).toBeEnabled());
fireEvent.click(screen.getByRole("button", { name: "xAI Grok" }));
await chooseProviderToConfigure("OpenAI Codex");
fireEvent.click(screen.getByRole("button", { name: "Advanced options" }));
const codexProxy = screen.getByLabelText("Network proxy");
expect(codexProxy).toHaveValue("");
fireEvent.change(codexProxy, { target: { value: "http://proxy.example:8080" } });
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() =>
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.provider.update",
{
provider: "openai_codex",
extraBody: "",
proxy: "http://proxy.example:8080",
},
20_000,
),
);
});
it("maps provider request switches to raw extraBody fields", async () => {
const base = settingsPayload();
const providers: SettingsPayload["providers"] = [
{
name: "xai_grok",
label: "xAI Grok",
configured: true,
auth_type: "oauth",
api_key_required: false,
oauth_account: "grok@example.com",
oauth_login_supported: true,
advanced_fields: ["extra_body", "proxy"],
extra_body: null,
},
{
name: "openai_codex",
label: "OpenAI Codex",
configured: true,
auth_type: "oauth",
api_key_required: false,
oauth_account: "codex@example.com",
oauth_login_supported: true,
advanced_fields: ["extra_body", "proxy"],
extra_body: null,
},
{
name: "deepseek",
label: "DeepSeek",
configured: true,
api_key_required: true,
api_key_hint: "deep••••test",
api_base: "https://api.deepseek.com",
advanced_fields: ["extra_body"],
extra_body: null,
},
{
name: "openai",
label: "OpenAI",
configured: true,
api_key_required: true,
api_key_hint: "sk-••••test",
api_base: "https://api.openai.com/v1",
api_type: "auto",
advanced_fields: ["api_type", "extra_body"],
extra_body: null,
},
];
const payload: SettingsPayload = { ...base, providers };
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockResolvedValue(payload);
renderSettingsView({ initialSection: "models", initialSettings: payload });
fireEvent.click(screen.getByRole("button", { name: "xAI Grok" }));
const xSearch = screen.getByRole("switch", { name: "X Search" });
expect(xSearch).toHaveAttribute("aria-checked", "true");
fireEvent.click(xSearch);
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith(
"settings.provider.update",
expect.objectContaining({ provider: "xai_grok" }),
20_000,
));
await waitFor(() => expect(
screen.getByRole("button", { name: "Save provider" }),
).toBeEnabled());
fireEvent.click(screen.getByRole("button", { name: "xAI Grok" }));
fireEvent.click(screen.getByRole("button", { name: "OpenAI Codex" }));
fireEvent.click(screen.getByRole("switch", { name: "Fast mode" }));
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith(
"settings.provider.update",
expect.objectContaining({ provider: "openai_codex" }),
20_000,
));
await waitFor(() => expect(
screen.getByRole("button", { name: "Save provider" }),
).toBeEnabled());
fireEvent.click(screen.getByRole("button", { name: "OpenAI Codex" }));
fireEvent.click(screen.getByRole("button", { name: /^DeepSeek/ }));
expect(screen.getByText(/DeepSeek V4 Flash/)).toBeInTheDocument();
const deepSeekSearch = screen.getByRole("switch", { name: "DeepSeek web search" });
expect(deepSeekSearch).toHaveAttribute("aria-checked", "true");
fireEvent.click(deepSeekSearch);
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() => expect(
screen.queryByRole("switch", { name: "DeepSeek web search" }),
).not.toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: /^OpenAI https:/ }));
fireEvent.click(screen.getByRole("switch", { name: "OpenAI web search" }));
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() => expect(
screen.queryByRole("switch", { name: "OpenAI web search" }),
).not.toBeInTheDocument());
await waitFor(() => {
const requestUpdates = requestMutationMock.mock.calls
.filter(([action]) => action === "settings.provider.update")
.map(([, values]) => {
const update = values as {
provider: string;
apiType?: string;
extraBody?: string;
};
return [update.provider, {
...(update.apiType ? { apiType: update.apiType } : {}),
extraBody: JSON.parse(update.extraBody ?? "{}"),
}] as const;
});
expect(requestUpdates).toEqual([
["xai_grok", { extraBody: { tools: [] } }],
["openai_codex", { extraBody: { service_tier: "priority" } }],
["deepseek", { extraBody: { tools: [] } }],
["openai", {
apiType: "responses",
extraBody: { tools: [{ type: "web_search" }] },
}],
]);
});
});
it("recognizes and removes versioned web search tools without losing raw settings", async () => {
const base = settingsPayload();
const payload: SettingsPayload = {
...base,
providers: [{
name: "openai",
label: "OpenAI",
configured: true,
api_key_required: true,
api_key_hint: "sk-••••test",
api_base: "https://api.openai.com/v1",
api_type: "auto",
advanced_fields: ["api_type", "extra_body"],
extra_body: {
metadata: { owner: "legacy-config" },
tools: [
{ type: "web_search_preview", search_context_size: "medium" },
{ type: "file_search", vector_store_ids: ["vs_legacy"] },
],
},
}],
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockResolvedValueOnce(payload);
renderSettingsView({ initialSection: "models", initialSettings: payload });
fireEvent.click(await screen.findByRole("button", { name: /^OpenAI https:/ }));
const searchSwitch = screen.getByRole("switch", { name: "OpenAI web search" });
expect(searchSwitch).toHaveAttribute("aria-checked", "true");
fireEvent.click(searchSwitch);
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() => {
const updateCall = requestMutationMock.mock.calls.find(
([action]) => action === "settings.provider.update",
);
expect(updateCall).toBeTruthy();
const values = updateCall?.[1] as { extraBody: string };
expect(JSON.parse(values.extraBody)).toEqual({
metadata: { owner: "legacy-config" },
tools: [{ type: "file_search", vector_store_ids: ["vs_legacy"] }],
});
});
});
it("creates a custom provider with folded advanced request settings", async () => {
const base = settingsPayload();
let payload: SettingsPayload = {
...base,
providers: [
{
name: "deepseek",
label: "DeepSeek",
configured: true,
api_key_required: true,
api_key_hint: "deep••••test",
api_base: "https://api.deepseek.com",
},
{
name: "openrouter",
label: "OpenRouter",
configured: false,
api_key_required: true,
api_key_hint: null,
api_base: null,
default_api_base: "https://openrouter.ai/api/v1",
},
],
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockImplementationOnce(async (
_action: string,
values: Record<string, string>,
) => {
payload = {
...payload,
created_provider: "custom-company-gateway",
providers: [
...payload.providers,
{
name: "custom-company-gateway",
label: values.name,
is_custom: true,
configured: true,
api_key_required: false,
api_key_hint: "sk-c••••pany",
api_base: values.apiBase,
default_api_base: null,
advanced_fields: [
"extra_headers",
"extra_body",
"extra_query",
"proxy",
"thinking_style",
],
extra_headers: JSON.parse(values.extraHeaders),
extra_body: JSON.parse(values.extraBody),
extra_query: JSON.parse(values.extraQuery),
proxy: values.proxy,
thinking_style: values.thinkingStyle,
},
],
};
return payload;
});
renderSettingsView({ initialSection: "models", initialSettings: payload });
fireEvent.pointerDown(
screen.getByRole("button", { name: "Add your own model provider" }),
);
const customOption = await screen.findByRole("menuitem", { name: "Custom provider" });
const openRouterOption = screen.getByRole("menuitem", { name: "OpenRouter" });
expect(customOption.querySelector("svg, img")).not.toBeNull();
expect(openRouterOption.querySelector("svg, img")).not.toBeNull();
fireEvent.click(customOption);
expect(
screen.queryByRole("button", { name: "Add your own model provider" }),
).not.toBeInTheDocument();
expect(screen.queryByLabelText("Extra headers")).not.toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText("My model provider"), {
target: { value: "Company Gateway" },
});
fireEvent.change(screen.getByPlaceholderText("https://api.example.com/v1"), {
target: { value: "https://gateway.example/v1" },
});
fireEvent.change(screen.getByPlaceholderText("Enter API key"), {
target: { value: "sk-company" },
});
fireEvent.click(screen.getByRole("button", { name: "Advanced options" }));
fireEvent.change(screen.getByLabelText("Extra headers"), {
target: { value: '{"X-Tenant":"engineering"}' },
});
fireEvent.change(screen.getByLabelText("Extra body"), {
target: { value: '{"service_tier":"priority"}' },
});
fireEvent.change(screen.getByLabelText("Extra query"), {
target: { value: '{"api-version":"2026-01-01"}' },
});
fireEvent.change(screen.getByLabelText("Network proxy"), {
target: { value: "http://127.0.0.1:7890" },
});
fireEvent.pointerDown(screen.getByRole("button", { name: "Thinking style" }));
fireEvent.click(await screen.findByRole("menuitem", { name: "enable_thinking" }));
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() => {
const createCall = requestMutationMock.mock.calls.find(
([action]) => action === "settings.provider.create",
);
expect(createCall).toBeTruthy();
expect(createCall?.[1]).toEqual({
name: "Company Gateway",
apiKey: "sk-company",
apiBase: "https://gateway.example/v1",
proxy: "http://127.0.0.1:7890",
extraHeaders: '{"X-Tenant":"engineering"}',
extraBody: '{"service_tier":"priority"}',
extraQuery: '{"api-version":"2026-01-01"}',
thinkingStyle: "enable_thinking",
});
});
expect(
await screen.findByRole("button", { name: /Company Gateway/ }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Add your own model provider" }),
).toBeInTheDocument();
});
});
+381
View File
@@ -0,0 +1,381 @@
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import { expect, it, vi } from "vitest";
import { requestMutationMock, jsonResponse, settingsPayload, renderSettingsView, installSettingsViewTestHooks } from "@/tests/settings-test-utils";
const installedAnyGen = {
name: "anygen",
display_name: "AnyGen",
category: "generation",
description: "Generate docs, slides, websites and more via AnyGen cloud API",
requires: "ANYGEN_API_KEY",
source: "harness",
entry_point: "cli-anything-anygen",
install_supported: true,
installed: true,
available: true,
status: "installed",
logo_url: "https://www.google.com/s2/favicons?domain=anygen.io&sz=64",
brand_color: "#111827",
skill_installed: true,
};
describe("Settings system domains", () => {
installSettingsViewTestHooks();
it("does not show the Settings kicker on the standalone Automations surface", async () => {
const onBackToChat = vi.fn();
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/webui/automations") return jsonResponse({ jobs: [] });
return jsonResponse({});
}));
renderSettingsView({
initialSection: "automations",
initialSettings: settingsPayload(),
showSidebar: false,
onBackToChat,
});
expect(screen.getByRole("heading", { name: "Automations" })).toBeInTheDocument();
expect(await screen.findByText("No automations yet.")).toBeInTheDocument();
expect(screen.queryByText("Settings")).not.toBeInTheDocument();
expect(
screen.queryByPlaceholderText("Search task, message, linked chat, or schedule"),
).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Open a chat" }));
expect(onBackToChat).toHaveBeenCalledTimes(1);
});
it("offers a way out of an empty automations filter", async () => {
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/webui/automations") {
return jsonResponse({
jobs: [{
id: "job-1",
name: "Daily summary",
enabled: true,
schedule: { kind: "cron", expr: "0 9 * * *" },
payload: { message: "Summarize the day" },
state: {},
}],
});
}
return jsonResponse({});
}));
renderSettingsView({
initialSection: "automations",
initialSettings: settingsPayload(),
showSidebar: false,
});
expect(await screen.findByRole("heading", { name: "Daily summary" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Paused 0" }));
expect(await screen.findByText("No automations match this view.")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Clear filters" }));
expect(await screen.findByRole("heading", { name: "Daily summary" })).toBeInTheDocument();
});
it("coalesces focus refreshes while automations are already loading", async () => {
let resolveAutomations!: (response: Response) => void;
const pendingAutomations = new Promise<Response>((resolve) => {
resolveAutomations = resolve;
});
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/webui/automations") return pendingAutomations;
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView({
initialSection: "automations",
initialSettings: settingsPayload(),
showSidebar: false,
});
await waitFor(() => {
expect(fetchMock.mock.calls.filter(([input]) => (
String(input) === "/api/webui/automations"
))).toHaveLength(1);
});
window.dispatchEvent(new Event("focus"));
window.dispatchEvent(new Event("focus"));
expect(fetchMock.mock.calls.filter(([input]) => (
String(input) === "/api/webui/automations"
))).toHaveLength(1);
await act(async () => {
resolveAutomations(jsonResponse({ jobs: [] }));
await pendingAutomations;
});
});
it("starts the managed API server from System", async () => {
const base = settingsPayload();
const stopped = {
installed: false,
running: false,
managed: false,
host: "127.0.0.1",
port: 8900,
timeout: 120,
api_key_hint: null,
endpoint: "http://127.0.0.1:8900/v1",
command: "nanobot serve",
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(base);
if (url === "/api/settings/api-service") return jsonResponse(stopped);
if (url === "/api/settings/nanobot-features") {
return jsonResponse({ features: [], enabled_count: 0 });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockResolvedValueOnce({
...stopped,
installed: true,
running: true,
managed: true,
});
renderSettingsView({ initialSection: "runtime", initialSettings: base, showSidebar: true });
const startButton = await screen.findByRole("button", { name: "Start API server" });
await waitFor(() => expect(startButton).toBeEnabled());
fireEvent.click(startButton);
await waitFor(() => {
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.api_service.start",
{ host: "127.0.0.1", port: 8900, timeout: 120 },
150_000,
);
});
});
it("shows a visible uninstall button for installed CLI apps and calls uninstall", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") {
return jsonResponse(settingsPayload());
}
if (url === "/api/settings/cli-apps") {
return jsonResponse({
apps: [installedAnyGen],
installed_count: 1,
catalog_updated_at: "2026-04-18",
});
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockResolvedValueOnce({
apps: [{ ...installedAnyGen, installed: false, status: "available" }],
installed_count: 0,
catalog_updated_at: "2026-04-18",
last_action: {
ok: true,
message: "Uninstalled CLI for AnyGen.",
still_available: false,
},
});
renderSettingsView();
expect(screen.queryByRole("heading", { name: "Apps" })).not.toBeInTheDocument();
expect(await screen.findByText("AnyGen")).toBeInTheDocument();
const uninstall = screen.getByRole("button", { name: "Uninstall app" });
fireEvent.click(uninstall);
await waitFor(() =>
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.cli_app.uninstall",
{ name: "anygen" },
20_000,
),
);
expect(await screen.findByText("Uninstalled CLI for AnyGen.")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
expect(screen.queryByText("Uninstalled CLI for AnyGen.")).not.toBeInTheDocument();
});
it("keeps runtime dependencies out of Apps and explains chat mentions", async () => {
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") {
return jsonResponse({
apps: [{ ...installedAnyGen, installed: false, status: "available" }],
installed_count: 0,
});
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
if (url === "/api/settings/nanobot-features") {
return jsonResponse({
features: [
{
name: "api",
display_name: "Api",
type: "feature",
enabled: true,
installed: true,
ready: true,
status: "enabled",
install_supported: true,
requires_restart: true,
},
],
enabled_count: 1,
});
}
return jsonResponse({});
}));
renderSettingsView({ initialSection: "apps" });
expect(await screen.findByText("AnyGen")).toBeInTheDocument();
expect(
screen.queryByText("Add tools to nanobot, then @ them in chat."),
).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Ready" })).toHaveAttribute("aria-pressed", "false");
expect(screen.getByRole("button", { name: "Apps" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "MCP" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Plugins" })).not.toBeInTheDocument();
expect(screen.queryByText("Api")).not.toBeInTheDocument();
expect(screen.queryByText("0 ready")).not.toBeInTheDocument();
});
it("shows nanobot optional features and enables one", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 });
if (url === "/api/settings/nanobot-features") {
return jsonResponse({
features: [{
name: "matrix",
display_name: "Matrix",
webui: "webui/index.ts",
type: "channel",
enabled: false,
installed: false,
ready: false,
status: "missing_dependency",
install_supported: true,
requires_restart: true,
}],
enabled_count: 0,
});
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockImplementation(async (action: string) => {
if (action === "settings.feature.enable") {
return {
features: [{
name: "matrix",
display_name: "Matrix",
webui: "webui/index.ts",
type: "channel",
enabled: true,
running: true,
runtime_status: "running",
installed: true,
ready: true,
status: "enabled",
install_supported: true,
requires_restart: true,
}],
enabled_count: 1,
last_action: { ok: true, message: "Enabled channel 'matrix'", enabled: true },
};
}
if (action === "settings.feature.disable") {
return {
features: [{
name: "matrix",
display_name: "Matrix",
webui: "webui/index.ts",
type: "channel",
enabled: false,
installed: true,
ready: false,
status: "not_enabled",
install_supported: true,
requires_restart: true,
}],
enabled_count: 0,
requires_restart: true,
last_action: { ok: true, message: "Disabled channel 'matrix'", enabled: false },
};
}
return settingsPayload();
});
renderSettingsView({ initialSection: "channels" });
const matrixRow = await screen.findByRole("button", { name: "View Matrix settings" });
expect(matrixRow).toHaveAttribute("aria-pressed", "true");
expect(screen.getAllByText("Matrix")).toHaveLength(2);
expect(screen.getAllByText("Use nanobot from Matrix rooms.")).toHaveLength(2);
expect(screen.queryByText(/Enabling Nanobot features may install Python packages/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("switch", { name: "Matrix channel" }));
expect(screen.getByRole("dialog", { name: "Install support for Matrix?" })).toBeInTheDocument();
expect(screen.getByText("nanobot will add what Matrix needs, then turn it on. Continue?")).toBeInTheDocument();
expect(requestMutationMock).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Install and enable" }));
await waitFor(() =>
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.feature.enable",
{ name: "matrix" },
150_000,
),
);
await waitFor(() =>
expect(screen.getByRole("switch", { name: "Matrix channel" })).toHaveAttribute("aria-checked", "true"),
);
expect(screen.queryByText("Enabled channel 'matrix'")).not.toBeInTheDocument();
expect(screen.queryByText("Restart nanobot to apply updated channel support.")).not.toBeInTheDocument();
expect(screen.getAllByText("On").length).toBeGreaterThan(0);
expect(screen.getByLabelText("Homeserver")).toBeInTheDocument();
expect(screen.getByLabelText("User ID")).toBeInTheDocument();
expect(screen.getByLabelText("Device ID")).toBeInTheDocument();
expect(screen.queryByText("channels.matrix.homeserver")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("switch", { name: "Matrix channel" }));
await waitFor(() =>
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.feature.disable",
{ name: "matrix" },
20_000,
),
);
await waitFor(() =>
expect(screen.getByRole("switch", { name: "Matrix channel" })).toHaveAttribute("aria-checked", "false"),
);
expect(screen.queryByText("Disabled channel 'matrix'")).not.toBeInTheDocument();
});
});
+195
View File
@@ -0,0 +1,195 @@
import { cleanup, render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, vi } from "vitest";
import { SettingsView } from "@/components/settings/SettingsView";
import { ClientProvider } from "@/providers/ClientProvider";
import type { SettingsPayload } from "@/lib/types";
export const requestMutationMock = vi.fn();
export function jsonResponse(body: unknown): Response {
return {
ok: true,
status: 200,
json: async () => body,
} as Response;
}
export function settingsPayload(): SettingsPayload {
return {
agent: {
model: "openai/gpt-4o",
provider: "auto",
resolved_provider: "openai",
has_api_key: true,
model_preset: "primary",
max_tokens: 8192,
context_window_tokens: 200000,
temperature: 0.1,
reasoning_effort: null,
timezone: "UTC",
tool_hint_max_length: 40,
},
model_presets: [{
name: "primary",
label: "Primary",
active: true,
is_default: false,
model: "openai/gpt-4o",
provider: "auto",
resolved_provider: "openai",
max_tokens: 8192,
context_window_tokens: 200000,
temperature: 0.1,
reasoning_effort: null,
}],
model_call_order: ["primary"],
model_call_order_editable: true,
providers: [],
web_search: {
provider: "duckduckgo",
api_key_hint: null,
base_url: null,
max_results: 5,
timeout: 30,
providers: [{ name: "duckduckgo", label: "DuckDuckGo", credential: "none" }],
},
web: {
enable: true,
proxy: null,
user_agent: null,
search: { max_results: 5, timeout: 30 },
fetch: { use_jina_reader: true },
},
api: {
host: "127.0.0.1",
port: 8900,
timeout: 120,
api_key_hint: null,
},
observability: {
provider: "langfuse",
configured: false,
base_url: "https://cloud.langfuse.com",
},
image_generation: {
enabled: false,
provider: "openrouter",
provider_configured: false,
model: "openai/gpt-5.4-image-2",
default_aspect_ratio: "1:1",
default_image_size: "1K",
max_images_per_turn: 4,
save_dir: "generated",
providers: [],
},
runtime: {
config_path: "/tmp/config.json",
workspace_path: "/tmp/workspace",
gateway_host: "127.0.0.1",
gateway_port: 18790,
heartbeat: {
enabled: true,
interval_s: 1800,
keep_recent_messages: 8,
},
dream: {
schedule: "every 2h",
},
unified_session: false,
},
advanced: {
restrict_to_workspace: false,
webui_allow_local_service_access: true,
webui_default_access_mode: "default",
private_service_protection_enabled: true,
ssrf_whitelist_count: 0,
mcp_server_count: 0,
exec_enabled: true,
exec_sandbox: null,
exec_path_prepend_set: false,
exec_path_append_set: false,
},
requires_restart: false,
version: {
current: "0.2.2",
},
docs: {
version: "0.2.2",
base_url: "https://nanobot.wiki/docs/0.2.2",
chat_apps_url: "https://nanobot.wiki/docs/0.2.2/getting-started/chat-apps",
latest_url: "https://nanobot.wiki/docs/latest",
},
};
}
export function renderSettingsView(
options: {
initialSection?:
| "overview"
| "appearance"
| "apps"
| "channels"
| "automations"
| "advanced"
| "models"
| "image"
| "browser"
| "runtime";
initialSettings?: SettingsPayload;
showSidebar?: boolean;
onBackToChat?: () => void;
onSettingsChange?: (payload: SettingsPayload) => void;
onNativeEngineRestart?: () => Promise<string>;
} = {},
) {
render(
<ClientProvider client={{ requestMutation: requestMutationMock } as never} token="tok">
<SettingsView
theme="light"
initialSection={options.initialSection ?? "apps"}
initialSettings={options.initialSettings}
showSidebar={options.showSidebar}
onToggleTheme={() => {}}
onBackToChat={options.onBackToChat ?? (() => {})}
onModelNameChange={() => {}}
onSettingsChange={options.onSettingsChange}
onNativeEngineRestart={options.onNativeEngineRestart}
/>
</ClientProvider>,
);
}
export async function openPopover(trigger: HTMLElement) {
await userEvent.setup().click(trigger);
}
export function installSettingsViewTestHooks() {
beforeEach(() => {
requestMutationMock.mockReset().mockResolvedValue(settingsPayload());
vi.stubGlobal(
"matchMedia",
vi.fn((query: string) => ({
matches: query === "(min-width: 1280px)",
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
);
vi.stubGlobal(
"fetch",
vi.fn(() => new Promise<Response>(() => {})),
);
});
afterEach(() => {
cleanup();
localStorage.removeItem("nanobot-webui.settings-preferences");
vi.useRealTimers();
vi.unstubAllGlobals();
});
}
File diff suppressed because it is too large Load Diff