mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-11 06:48:39 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
393e5dbe09 | ||
|
|
3198567cef | ||
|
|
2787b2630d | ||
|
|
976b2842bc | ||
|
|
82e50b0f1b | ||
|
|
5d0805d9d1 | ||
|
|
66ecc0e3ec | ||
|
|
7c44bd17b9 | ||
|
|
3700e1a9f7 | ||
|
|
839c13d46f | ||
|
|
d531d1cdf8 | ||
|
|
8b03beef27 | ||
|
|
3529bda1b2 | ||
|
|
11fe666f7b | ||
|
|
e4b040d9cb | ||
|
|
ff39344ad6 | ||
|
|
c4802344fa |
@@ -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) |
|
| 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 |
|
| Tool | Implement a tool under `agent/tools/` or expose a plugin entry point |
|
||||||
| MCP | Add `tools.mcpServers` config |
|
| MCP | Add `tools.mcpServers` config |
|
||||||
| Skill | Add workspace skill files under `<workspace>/skills/` or built-in skills under `nanobot/skills/` |
|
| Skill | Add workspace skills under `<workspace>/skills/`, Agent Plugins v1 under `<workspace>/plugins/`, or built-in skills under `nanobot/skills/` |
|
||||||
|
|
||||||
Prefer existing registry/discovery patterns over ad hoc wiring.
|
Prefer existing registry/discovery patterns over ad hoc wiring.
|
||||||
|
|
||||||
|
|||||||
@@ -2343,6 +2343,25 @@ 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. |
|
| `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 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.
|
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.
|
||||||
|
|||||||
@@ -485,6 +485,8 @@ class AgentLoop:
|
|||||||
config,
|
config,
|
||||||
provider_snapshot_loader,
|
provider_snapshot_loader,
|
||||||
)
|
)
|
||||||
|
from nanobot.agent.plugins import agent_plugin_mcp_servers
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
bus=bus,
|
bus=bus,
|
||||||
provider=provider,
|
provider=provider,
|
||||||
@@ -499,7 +501,7 @@ class AgentLoop:
|
|||||||
provider_retry_mode=defaults.provider_retry_mode,
|
provider_retry_mode=defaults.provider_retry_mode,
|
||||||
tool_hint_max_length=defaults.tool_hint_max_length,
|
tool_hint_max_length=defaults.tool_hint_max_length,
|
||||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
restrict_to_workspace=config.tools.restrict_to_workspace,
|
||||||
mcp_servers=config.tools.mcp_servers,
|
mcp_servers=agent_plugin_mcp_servers(config.workspace_path, config.tools.mcp_servers),
|
||||||
channels_config=config.channels,
|
channels_config=config.channels,
|
||||||
timezone=defaults.timezone,
|
timezone=defaults.timezone,
|
||||||
unified_session=defaults.unified_session,
|
unified_session=defaults.unified_session,
|
||||||
|
|||||||
@@ -0,0 +1,361 @@
|
|||||||
|
"""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
|
||||||
+62
-28
@@ -17,9 +17,48 @@ _STRIP_SKILL_FRONTMATTER = re.compile(
|
|||||||
r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?",
|
r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?",
|
||||||
re.DOTALL,
|
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_-]+)")
|
_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:
|
class SkillsLoader:
|
||||||
"""
|
"""
|
||||||
Loader for agent skills.
|
Loader for agent skills.
|
||||||
@@ -60,11 +99,25 @@ class SkillsLoader:
|
|||||||
Returns:
|
Returns:
|
||||||
List of skill info dicts with 'name', 'path', 'source'.
|
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")
|
skills = self._skill_entries_from_dir(self.workspace_skills, "workspace")
|
||||||
workspace_names = {entry["name"] for entry in skills}
|
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)
|
||||||
if self.builtin_skills and self.builtin_skills.exists():
|
if self.builtin_skills and self.builtin_skills.exists():
|
||||||
skills.extend(
|
skills.extend(
|
||||||
self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=workspace_names)
|
self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=seen_names)
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.disabled_skills:
|
if self.disabled_skills:
|
||||||
@@ -84,14 +137,11 @@ class SkillsLoader:
|
|||||||
Returns:
|
Returns:
|
||||||
Skill content or None if not found.
|
Skill content or None if not found.
|
||||||
"""
|
"""
|
||||||
roots = [self.workspace_skills]
|
entry = next(
|
||||||
if self.builtin_skills:
|
(skill for skill in self.list_skills(filter_unavailable=False) if skill["name"] == name),
|
||||||
roots.append(self.builtin_skills)
|
None,
|
||||||
for root in roots:
|
)
|
||||||
path = root / name / "SKILL.md"
|
return Path(entry["path"]).read_text(encoding="utf-8") if entry else None
|
||||||
if path.exists():
|
|
||||||
return path.read_text(encoding="utf-8")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def load_skills_for_context(self, skill_names: list[str]) -> str:
|
def load_skills_for_context(self, skill_names: list[str]) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -145,6 +195,7 @@ class SkillsLoader:
|
|||||||
sections: list[str] = []
|
sections: list[str] = []
|
||||||
groups = (
|
groups = (
|
||||||
("Workspace skills", "workspace", self.workspace_skills),
|
("Workspace skills", "workspace", self.workspace_skills),
|
||||||
|
("Agent Plugin skills", "plugin", self.workspace / "plugins"),
|
||||||
("Built-in skills", "builtin", self.builtin_skills),
|
("Built-in skills", "builtin", self.builtin_skills),
|
||||||
)
|
)
|
||||||
for label, source, root in groups:
|
for label, source, root in groups:
|
||||||
@@ -278,21 +329,4 @@ class SkillsLoader:
|
|||||||
Returns:
|
Returns:
|
||||||
Metadata dict or None.
|
Metadata dict or None.
|
||||||
"""
|
"""
|
||||||
content = self.load_skill(name)
|
return parse_skill_metadata(self.load_skill(name) or "")
|
||||||
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
|
|
||||||
|
|||||||
@@ -1340,10 +1340,14 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
|||||||
"requires_restart": True,
|
"requires_restart": True,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
|
from nanobot.agent.plugins import agent_plugin_mcp_servers
|
||||||
from nanobot.config.loader import load_config, resolve_config_env_vars
|
from nanobot.config.loader import load_config, resolve_config_env_vars
|
||||||
|
|
||||||
config = resolve_config_env_vars(load_config())
|
config = resolve_config_env_vars(load_config())
|
||||||
next_servers = dict(config.tools.mcp_servers)
|
next_servers = agent_plugin_mcp_servers(
|
||||||
|
config.workspace_path,
|
||||||
|
config.tools.mcp_servers,
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("MCP hot reload could not read config: {}", exc)
|
logger.warning("MCP hot reload could not read config: {}", exc)
|
||||||
return {
|
return {
|
||||||
|
|||||||
+51
-17
@@ -20,6 +20,7 @@ from urllib.parse import urlparse
|
|||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from nanobot.agent.skills import normalize_skill_document
|
||||||
from nanobot.apps.protocol import app_manifest, compact_dict
|
from nanobot.apps.protocol import app_manifest, compact_dict
|
||||||
from nanobot.config.paths import get_runtime_subdir
|
from nanobot.config.paths import get_runtime_subdir
|
||||||
from nanobot.security.workspace_policy import is_path_within
|
from nanobot.security.workspace_policy import is_path_within
|
||||||
@@ -27,6 +28,7 @@ from nanobot.security.workspace_policy import is_path_within
|
|||||||
CLI_ANYTHING_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/registry.json"
|
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_PUBLIC_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/public_registry.json"
|
||||||
CLI_ANYTHING_RAW_BASE = "https://raw.githubusercontent.com/HKUDS/CLI-Anything/main"
|
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_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"
|
NANOBOT_EXTENSION_RAW_BASE = "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main"
|
||||||
_CATALOG_SOURCES = (
|
_CATALOG_SOURCES = (
|
||||||
@@ -210,11 +212,27 @@ def _as_object_dict(value: object) -> dict[str, Any] | None:
|
|||||||
return cast(dict[str, Any], value) if isinstance(value, dict) else None
|
return cast(dict[str, Any], value) if isinstance(value, dict) else None
|
||||||
|
|
||||||
|
|
||||||
def _safe_skill_name(name: str) -> str:
|
def _skill_name(name: str, *, legacy: bool = False) -> str:
|
||||||
clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-")
|
clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-")
|
||||||
|
if not legacy:
|
||||||
|
clean = clean.replace("_", "-")
|
||||||
return f"cli-app-{clean or 'app'}"
|
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:
|
def _has_shell_meta(command: str) -> bool:
|
||||||
return any(char in command for char in _SHELL_META_CHARS)
|
return any(char in command for char in _SHELL_META_CHARS)
|
||||||
|
|
||||||
@@ -613,7 +631,7 @@ class CliAppManager:
|
|||||||
"name": installed_name,
|
"name": installed_name,
|
||||||
"entry_point": entry_point,
|
"entry_point": entry_point,
|
||||||
"source": str(data.get("source") or ""),
|
"source": str(data.get("source") or ""),
|
||||||
"skill": f"skills/{_safe_skill_name(installed_name)}/SKILL.md",
|
"skill": cli_app_skill_relative_path(self.workspace, installed_name),
|
||||||
"tool": "run_cli_app",
|
"tool": "run_cli_app",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -639,9 +657,6 @@ class CliAppManager:
|
|||||||
install_cmd = str(app.get("install_cmd") or "")
|
install_cmd = str(app.get("install_cmd") or "")
|
||||||
return not _has_shell_meta(install_cmd)
|
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(
|
def _app_payload(
|
||||||
self,
|
self,
|
||||||
app: dict[str, Any],
|
app: dict[str, Any],
|
||||||
@@ -677,7 +692,7 @@ class CliAppManager:
|
|||||||
"status": status,
|
"status": status,
|
||||||
"logo_url": logo_url,
|
"logo_url": logo_url,
|
||||||
"brand_color": brand_color,
|
"brand_color": brand_color,
|
||||||
"skill_installed": self._skill_path(name).is_file(),
|
"skill_installed": (self.workspace / cli_app_skill_relative_path(self.workspace, name)).is_file(),
|
||||||
"manifest": self._manifest_payload(app, logo_url=logo_url, brand_color=brand_color),
|
"manifest": self._manifest_payload(app, logo_url=logo_url, brand_color=brand_color),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -713,7 +728,8 @@ class CliAppManager:
|
|||||||
name = str(app["name"])
|
name = str(app["name"])
|
||||||
entry_point = str(app.get("entry_point") or "")
|
entry_point = str(app.get("entry_point") or "")
|
||||||
strategy = self._strategy(app)
|
strategy = self._strategy(app)
|
||||||
skill_path = f"skills/{_safe_skill_name(name)}/SKILL.md"
|
skill_path = _plugin_skill_relative_path(name)
|
||||||
|
plugin_path = f"plugins/{_skill_name(name)}"
|
||||||
capabilities = [
|
capabilities = [
|
||||||
compact_dict({
|
compact_dict({
|
||||||
"type": "cli",
|
"type": "cli",
|
||||||
@@ -726,13 +742,13 @@ class CliAppManager:
|
|||||||
install = compact_dict({
|
install = compact_dict({
|
||||||
"supported": install_supported,
|
"supported": install_supported,
|
||||||
"strategy": strategy,
|
"strategy": strategy,
|
||||||
"managed_paths": [skill_path],
|
"managed_paths": [plugin_path],
|
||||||
"verification": ["entry_point_available"] if entry_point else [],
|
"verification": ["entry_point_available"] if entry_point else [],
|
||||||
})
|
})
|
||||||
remove = compact_dict({
|
remove = compact_dict({
|
||||||
"supported": strategy != "unsupported",
|
"supported": strategy != "unsupported",
|
||||||
"strategy": strategy,
|
"strategy": strategy,
|
||||||
"managed_paths": [skill_path],
|
"managed_paths": [plugin_path],
|
||||||
"verification": (
|
"verification": (
|
||||||
["package_manager_ok", "entry_point_absent", "managed_paths_absent"]
|
["package_manager_ok", "entry_point_absent", "managed_paths_absent"]
|
||||||
if strategy not in {"bundled", "unsupported"}
|
if strategy not in {"bundled", "unsupported"}
|
||||||
@@ -1032,11 +1048,10 @@ class CliAppManager:
|
|||||||
name = str(app.get("name") or "unknown")
|
name = str(app.get("name") or "unknown")
|
||||||
display = str(app.get("display_name") or name)
|
display = str(app.get("display_name") or name)
|
||||||
entry = str(app.get("entry_point") or f"cli-anything-{name}")
|
entry = str(app.get("entry_point") or f"cli-anything-{name}")
|
||||||
description = _catalog_description(app) or f"Use {display} from nanobot."
|
description = (_catalog_description(app) or f"Use {display} from nanobot.")[:1024]
|
||||||
return f"""---
|
return f"""---
|
||||||
name: {_safe_skill_name(name)}
|
name: {_skill_name(name)}
|
||||||
description: >-
|
description: {json.dumps(description, ensure_ascii=False)}
|
||||||
{description}
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# {display}
|
# {display}
|
||||||
@@ -1073,24 +1088,43 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
|
|||||||
return note + "\n" + content
|
return note + "\n" + content
|
||||||
|
|
||||||
def install_skill(self, app: dict[str, Any]) -> Path:
|
def install_skill(self, app: dict[str, Any]) -> Path:
|
||||||
path = self._skill_path(str(app["name"]))
|
name = str(app["name"])
|
||||||
|
path = self.workspace / _plugin_skill_relative_path(name)
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
content = self._fetch_skill_content(app) or self._fallback_skill(app)
|
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)
|
content = self._with_nanobot_skill_note(content, app)
|
||||||
path.write_text(content, encoding="utf-8")
|
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
|
return path
|
||||||
|
|
||||||
def remove_skill(self, name: str) -> None:
|
def remove_skill(self, name: str) -> None:
|
||||||
skill_dir = self._skill_path(name).parent
|
plugin_root = (self.workspace / _plugin_skill_relative_path(name)).parents[2]
|
||||||
if skill_dir.is_dir():
|
if plugin_root.is_dir():
|
||||||
shutil.rmtree(skill_dir)
|
shutil.rmtree(plugin_root)
|
||||||
|
legacy_dir = self.workspace / "skills" / _skill_name(name, legacy=True)
|
||||||
|
if legacy_dir.is_dir():
|
||||||
|
shutil.rmtree(legacy_dir)
|
||||||
|
|
||||||
def _record_installed(self, app: dict[str, Any]) -> dict[str, Any]:
|
def _record_installed(self, app: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
from nanobot.agent.plugins import set_agent_plugin_enabled
|
||||||
|
|
||||||
installed = self._load_installed()
|
installed = self._load_installed()
|
||||||
entry = self._installed_entry(app)
|
entry = self._installed_entry(app)
|
||||||
installed[str(app["name"])] = entry
|
installed[str(app["name"])] = entry
|
||||||
self._save_installed(installed)
|
self._save_installed(installed)
|
||||||
self.install_skill(app)
|
self.install_skill(app)
|
||||||
|
set_agent_plugin_enabled(self.workspace, _skill_name(str(app["name"])), True)
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
def install(self, name: str) -> dict[str, Any]:
|
def install(self, name: str) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ def runtime_lines_for_request(
|
|||||||
"""Return CLI App annotations from an immutable request snapshot."""
|
"""Return CLI App annotations from an immutable request snapshot."""
|
||||||
structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
|
structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
|
||||||
if isinstance(structured, list):
|
if isinstance(structured, list):
|
||||||
|
from nanobot.apps.cli.service import cli_app_skill_relative_path
|
||||||
|
|
||||||
structured_items = cast(list[Any], structured)
|
structured_items = cast(list[Any], structured)
|
||||||
mentions = [
|
mentions = [
|
||||||
cast(Mapping[str, Any], item) for item in structured_items
|
cast(Mapping[str, Any], item) for item in structured_items
|
||||||
@@ -32,7 +34,7 @@ def runtime_lines_for_request(
|
|||||||
f"@{str(item['name']).strip().lower()} "
|
f"@{str(item['name']).strip().lower()} "
|
||||||
f"(installed; tool=run_cli_app; "
|
f"(installed; tool=run_cli_app; "
|
||||||
f"entry_point={str(item.get('entry_point') or 'unknown')}; "
|
f"entry_point={str(item.get('entry_point') or 'unknown')}; "
|
||||||
f"skill=skills/cli-app-{str(item['name']).strip().lower()}/SKILL.md). "
|
f"skill={cli_app_skill_relative_path(workspace, str(item['name']))}). "
|
||||||
"Read the skill when useful, then run this app with `run_cli_app`; do not bypass it with shell."
|
"Read the skill when useful, then run this app with `run_cli_app`; do not bypass it with shell."
|
||||||
for item in mentions
|
for item in mentions
|
||||||
if str(item.get("name") or "").strip()
|
if str(item.get("name") or "").strip()
|
||||||
|
|||||||
@@ -2090,6 +2090,14 @@ async def test_mcp_presets_routes_require_token_and_return_payload(
|
|||||||
assert body["hot_reload"]["ok"] is True
|
assert body["hot_reload"]["ok"] is True
|
||||||
assert body["restart_required_sections"] == []
|
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(
|
custom = await _webui_mutate(
|
||||||
channel,
|
channel,
|
||||||
"settings.mcp.custom",
|
"settings.mcp.custom",
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ from datetime import datetime, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Literal, Mapping, cast
|
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 (
|
from nanobot.agent.tools.mcp_oauth import (
|
||||||
delete_mcp_oauth_credentials,
|
delete_mcp_oauth_credentials,
|
||||||
mcp_oauth_has_credentials,
|
mcp_oauth_has_credentials,
|
||||||
@@ -55,7 +60,6 @@ _MAX_TEST_TOOLS = 16
|
|||||||
_DEFAULT_TEST_TIMEOUT = 20
|
_DEFAULT_TEST_TIMEOUT = 20
|
||||||
_DEFAULT_CUSTOM_TIMEOUT = 30
|
_DEFAULT_CUSTOM_TIMEOUT = 30
|
||||||
_CUSTOM_ACTIONS = {"custom", "import", "import-cursor", "tools"}
|
_CUSTOM_ACTIONS = {"custom", "import", "import-cursor", "tools"}
|
||||||
|
|
||||||
McpReload = Callable[[], Awaitable[dict[str, Any]]]
|
McpReload = Callable[[], Awaitable[dict[str, Any]]]
|
||||||
|
|
||||||
|
|
||||||
@@ -911,6 +915,31 @@ 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(
|
def mcp_presets_payload(
|
||||||
*,
|
*,
|
||||||
last_action: dict[str, Any] | None = None,
|
last_action: dict[str, Any] | None = None,
|
||||||
@@ -929,9 +958,16 @@ def mcp_presets_payload(
|
|||||||
for name, cfg in sorted(config.tools.mcp_servers.items())
|
for name, cfg in sorted(config.tools.mcp_servers.items())
|
||||||
if name not in known
|
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] = {
|
payload: dict[str, Any] = {
|
||||||
"presets": [*preset_rows, *custom_rows],
|
"presets": [*preset_rows, *custom_rows, *plugin_rows],
|
||||||
"installed_count": len(config.tools.mcp_servers),
|
"installed_count": len(config.tools.mcp_servers)
|
||||||
|
+ sum(int(row["enabled"]) for row in plugin_rows),
|
||||||
}
|
}
|
||||||
if last_action is not None:
|
if last_action is not None:
|
||||||
payload["last_action"] = last_action
|
payload["last_action"] = last_action
|
||||||
@@ -1542,6 +1578,32 @@ async def mcp_presets_settings_action(
|
|||||||
config_path = config.path if config is not None else None
|
config_path = config.path if config is not None else None
|
||||||
if action is None:
|
if action is None:
|
||||||
return mcp_presets_payload(config_path=config_path)
|
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":
|
if action == "test":
|
||||||
return await mcp_presets_test_action(query, config_path=config_path)
|
return await mcp_presets_test_action(query, config_path=config_path)
|
||||||
if config is not None:
|
if config is not None:
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ def _channel_connect_route(path: str) -> tuple[str, str] | None:
|
|||||||
|
|
||||||
_MCP_PRESET_ACTIONS_BY_PATH = {
|
_MCP_PRESET_ACTIONS_BY_PATH = {
|
||||||
"/api/settings/mcp-presets/enable": "enable",
|
"/api/settings/mcp-presets/enable": "enable",
|
||||||
|
"/api/settings/mcp-presets/disable": "disable",
|
||||||
"/api/settings/mcp-presets/remove": "remove",
|
"/api/settings/mcp-presets/remove": "remove",
|
||||||
"/api/settings/mcp-presets/test": "test",
|
"/api/settings/mcp-presets/test": "test",
|
||||||
"/api/settings/mcp-presets/custom": "custom",
|
"/api/settings/mcp-presets/custom": "custom",
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ _WEBUI_MUTATION_PATHS = {
|
|||||||
"settings.pairing.approve": "/api/settings/pairing/approve",
|
"settings.pairing.approve": "/api/settings/pairing/approve",
|
||||||
"settings.pairing.deny": "/api/settings/pairing/deny",
|
"settings.pairing.deny": "/api/settings/pairing/deny",
|
||||||
"settings.mcp.enable": "/api/settings/mcp-presets/enable",
|
"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.remove": "/api/settings/mcp-presets/remove",
|
||||||
"settings.mcp.test": "/api/settings/mcp-presets/test",
|
"settings.mcp.test": "/api/settings/mcp-presets/test",
|
||||||
"settings.mcp.custom": "/api/settings/mcp-presets/custom",
|
"settings.mcp.custom": "/api/settings/mcp-presets/custom",
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
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)
|
||||||
@@ -266,6 +266,7 @@ def test_disabled_skills_excluded_from_list(tmp_path: Path) -> None:
|
|||||||
assert len(entries) == 1
|
assert len(entries) == 1
|
||||||
assert entries[0]["name"] == "beta"
|
assert entries[0]["name"] == "beta"
|
||||||
assert entries[0]["path"] == str(beta_path)
|
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:
|
def test_disabled_skills_empty_set_no_effect(tmp_path: Path) -> None:
|
||||||
|
|||||||
@@ -9,9 +9,20 @@ from types import SimpleNamespace
|
|||||||
|
|
||||||
import pytest
|
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
|
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:
|
def _write_cache(path: Path, registry: dict) -> None:
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
path.write_text(
|
path.write_text(
|
||||||
@@ -391,6 +402,9 @@ def test_install_dispatches_safe_pip_and_installs_skill(
|
|||||||
"_fetch_skill_content",
|
"_fetch_skill_content",
|
||||||
lambda app: "---\nname: cli-anything-gimp\ndescription: GIMP\n---\n# GIMP\n",
|
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")
|
payload = manager.install("gimp")
|
||||||
|
|
||||||
@@ -400,9 +414,23 @@ def test_install_dispatches_safe_pip_and_installs_skill(
|
|||||||
assert "state_recorded" in payload["last_action"]["verification"]
|
assert "state_recorded" in payload["last_action"]["verification"]
|
||||||
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
||||||
assert installed["gimp"]["entry_point"] == "cli-anything-gimp"
|
assert installed["gimp"]["entry_point"] == "cli-anything-gimp"
|
||||||
skill = manager.workspace / "skills" / "cli-app-gimp" / "SKILL.md"
|
plugin = manager.workspace / "plugins" / "cli-app-gimp"
|
||||||
|
skill = plugin / "skills" / "cli-app-gimp" / "SKILL.md"
|
||||||
assert skill.is_file()
|
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 '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(
|
def test_run_argv_logs_command_exit_and_output(
|
||||||
@@ -487,7 +515,7 @@ def test_install_records_available_cli_without_reinstalling(
|
|||||||
assert "entry_point_available" in payload["last_action"]["verification"]
|
assert "entry_point_available" in payload["last_action"]["verification"]
|
||||||
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
||||||
assert installed["feishu"]["entry_point_path"] == str(resolved)
|
assert installed["feishu"]["entry_point_path"] == str(resolved)
|
||||||
skill = manager.workspace / "skills" / "cli-app-feishu" / "SKILL.md"
|
skill = manager.workspace / "plugins/cli-app-feishu/skills/cli-app-feishu/SKILL.md"
|
||||||
assert skill.is_file()
|
assert skill.is_file()
|
||||||
assert 'run_cli_app` tool with `name="feishu"' in skill.read_text(encoding="utf-8")
|
assert 'run_cli_app` tool with `name="feishu"' in skill.read_text(encoding="utf-8")
|
||||||
|
|
||||||
@@ -704,7 +732,8 @@ def test_uninstall_removes_installed_state_and_generated_skill(
|
|||||||
manager = _manager(tmp_path)
|
manager = _manager(tmp_path)
|
||||||
_seed_catalog(manager)
|
_seed_catalog(manager)
|
||||||
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
|
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
|
||||||
skill_dir = manager.workspace / "skills" / "cli-app-gimp"
|
plugin_dir = manager.workspace / "plugins" / "cli-app-gimp"
|
||||||
|
skill_dir = plugin_dir / "skills" / "cli-app-gimp"
|
||||||
skill_dir.mkdir(parents=True)
|
skill_dir.mkdir(parents=True)
|
||||||
(skill_dir / "SKILL.md").write_text("# GIMP\n", encoding="utf-8")
|
(skill_dir / "SKILL.md").write_text("# GIMP\n", encoding="utf-8")
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
@@ -717,7 +746,7 @@ def test_uninstall_removes_installed_state_and_generated_skill(
|
|||||||
|
|
||||||
assert payload["last_action"]["ok"] is True
|
assert payload["last_action"]["ok"] is True
|
||||||
assert "gimp" not in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
assert "gimp" not in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
||||||
assert not skill_dir.exists()
|
assert not plugin_dir.exists()
|
||||||
|
|
||||||
|
|
||||||
def test_uninstall_uses_safe_python_m_pip_uninstall_command(
|
def test_uninstall_uses_safe_python_m_pip_uninstall_command(
|
||||||
@@ -845,19 +874,30 @@ def test_mentioned_installed_apps_only_returns_installed_mentions(tmp_path: Path
|
|||||||
"name": "zoom",
|
"name": "zoom",
|
||||||
"entry_point": "cli-anything-zoom",
|
"entry_point": "cli-anything-zoom",
|
||||||
"source": "public",
|
"source": "public",
|
||||||
"skill": "skills/cli-app-zoom/SKILL.md",
|
"skill": "plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md",
|
||||||
"tool": "run_cli_app",
|
"tool": "run_cli_app",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "gimp",
|
"name": "gimp",
|
||||||
"entry_point": "cli-anything-gimp",
|
"entry_point": "cli-anything-gimp",
|
||||||
"source": "harness",
|
"source": "harness",
|
||||||
"skill": "skills/cli-app-gimp/SKILL.md",
|
"skill": "plugins/cli-app-gimp/skills/cli-app-gimp/SKILL.md",
|
||||||
"tool": "run_cli_app",
|
"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:
|
def test_install_rejects_unknown_and_script_strategy(tmp_path: Path) -> None:
|
||||||
manager = _manager(tmp_path)
|
manager = _manager(tmp_path)
|
||||||
_seed_catalog(manager)
|
_seed_catalog(manager)
|
||||||
|
|||||||
@@ -38,24 +38,26 @@ def test_cli_app_mentions_inject_runtime_metadata(tmp_path, monkeypatch):
|
|||||||
assert "CLI App Mention: @zoom" in joined
|
assert "CLI App Mention: @zoom" in joined
|
||||||
assert "tool=run_cli_app" in joined
|
assert "tool=run_cli_app" in joined
|
||||||
assert "entry_point=cli-anything-zoom" in joined
|
assert "entry_point=cli-anything-zoom" in joined
|
||||||
assert "skill=skills/cli-app-zoom/SKILL.md" in joined
|
assert "skill=plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md" in joined
|
||||||
|
|
||||||
|
|
||||||
def test_structured_cli_app_attachment_injects_runtime_metadata(tmp_path):
|
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")
|
||||||
lines = runtime_lines_for_request(
|
lines = runtime_lines_for_request(
|
||||||
"please use @zoom tonight",
|
"please use @unimol_tools",
|
||||||
{
|
{
|
||||||
"cli_apps": [{
|
"cli_apps": [{
|
||||||
"name": "zoom",
|
"name": "unimol_tools",
|
||||||
"entry_point": "cli-anything-zoom",
|
"entry_point": "cli-anything-unimol-tools",
|
||||||
"display_name": "Zoom",
|
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
tmp_path,
|
tmp_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
joined = "\n".join(lines)
|
joined = "\n".join(lines)
|
||||||
assert "CLI App Attachment: @zoom" in joined
|
assert "CLI App Attachment: @unimol_tools" in joined
|
||||||
assert "tool=run_cli_app" in joined
|
assert "tool=run_cli_app" in joined
|
||||||
assert "entry_point=cli-anything-zoom" in joined
|
assert "entry_point=cli-anything-unimol-tools" in joined
|
||||||
assert "skill=skills/cli-app-zoom/SKILL.md" in joined
|
assert "skill=skills/cli-app-unimol_tools/SKILL.md" in joined
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
|
from functools import partial
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from mcp.shared.auth import OAuthToken
|
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.agent.tools.mcp_oauth import MCPOAuthStorage, mcp_oauth_has_credentials
|
||||||
from nanobot.config.loader import load_config
|
from nanobot.config.loader import load_config
|
||||||
from nanobot.webui.mcp_presets_api import (
|
from nanobot.webui.mcp_presets_api import (
|
||||||
@@ -12,13 +16,58 @@ from nanobot.webui.mcp_presets_api import (
|
|||||||
custom_mcp_action,
|
custom_mcp_action,
|
||||||
mcp_presets_action,
|
mcp_presets_action,
|
||||||
mcp_presets_payload,
|
mcp_presets_payload,
|
||||||
|
mcp_presets_settings_action,
|
||||||
mcp_presets_test_action,
|
mcp_presets_test_action,
|
||||||
normalize_mcp_preset_mentions,
|
normalize_mcp_preset_mentions,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _use_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
def _use_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", tmp_path / "config.json")
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
@@ -60,6 +109,63 @@ def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest
|
|||||||
assert manifest["trust"]["review_status"] == "builtin_preset"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_oauth_preset_is_one_click_configured_after_token_storage(
|
async def test_oauth_preset_is_one_click_configured_after_token_storage(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
|
|||||||
@@ -1,654 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
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
@@ -1,213 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,293 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
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>;
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
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>;
|
|
||||||
@@ -1,923 +0,0 @@
|
|||||||
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
@@ -1,562 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
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]);
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
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>;
|
|
||||||
@@ -1,526 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,619 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
@@ -1,410 +0,0 @@
|
|||||||
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
@@ -1,257 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,406 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,656 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
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>;
|
|
||||||
@@ -1,613 +0,0 @@
|
|||||||
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>;
|
|
||||||
@@ -766,7 +766,7 @@ export async function fetchProviderModels(
|
|||||||
|
|
||||||
export async function runMcpPresetAction(
|
export async function runMcpPresetAction(
|
||||||
transport: WebUIMutationTransport,
|
transport: WebUIMutationTransport,
|
||||||
action: "enable" | "remove" | "test",
|
action: "enable" | "disable" | "remove" | "test",
|
||||||
name: string,
|
name: string,
|
||||||
values: Record<string, string> = {},
|
values: Record<string, string> = {},
|
||||||
): Promise<McpPresetsPayload> {
|
): Promise<McpPresetsPayload> {
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ export function isMcpPresetsPayload(value: unknown): value is McpPresetsPayload
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function installedMcpPresetsFromPayload(payload: McpPresetsPayload): McpPresetInfo[] {
|
export function installedMcpPresetsFromPayload(payload: McpPresetsPayload): McpPresetInfo[] {
|
||||||
return payload.presets.filter((preset) => preset.installed && preset.configured);
|
return payload.presets.filter(
|
||||||
|
(preset) => preset.enabled ?? (preset.installed && preset.configured),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function notifyMcpPresetsChanged(payload: McpPresetsPayload): void {
|
export function notifyMcpPresetsChanged(payload: McpPresetsPayload): void {
|
||||||
|
|||||||
@@ -962,6 +962,7 @@ export interface McpPresetInfo {
|
|||||||
install_supported: boolean;
|
install_supported: boolean;
|
||||||
installed: boolean;
|
installed: boolean;
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
|
enabled?: boolean;
|
||||||
available: boolean;
|
available: boolean;
|
||||||
status: "not_installed" | "configured" | "missing_credentials" | "missing_dependency" | "coming_soon" | string;
|
status: "not_installed" | "configured" | "missing_credentials" | "missing_dependency" | "coming_soon" | string;
|
||||||
logo_url?: string | null;
|
logo_url?: string | null;
|
||||||
|
|||||||
@@ -904,6 +904,13 @@ describe("webui API helpers", () => {
|
|||||||
20_000,
|
20_000,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await runMcpPresetAction(mutationTransport, "disable", "plugin-desktop");
|
||||||
|
expect(requestMutation).toHaveBeenCalledWith(
|
||||||
|
"settings.mcp.disable",
|
||||||
|
{ name: "plugin-desktop" },
|
||||||
|
20_000,
|
||||||
|
);
|
||||||
|
|
||||||
await startMcpOAuth(mutationTransport, "notion", true);
|
await startMcpOAuth(mutationTransport, "notion", true);
|
||||||
expect(requestMutation).toHaveBeenCalledWith(
|
expect(requestMutation).toHaveBeenCalledWith(
|
||||||
"settings.mcp.oauth_start",
|
"settings.mcp.oauth_start",
|
||||||
|
|||||||
@@ -1,566 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
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
@@ -1,241 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,849 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,381 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
import { cleanup, render } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { afterEach, beforeEach, vi } from "vitest";
|
|
||||||
import { SettingsView } from "@/components/settings/SettingsView";
|
|
||||||
import { ClientProvider } from "@/providers/ClientProvider";
|
|
||||||
import type { SettingsPayload } from "@/lib/types";
|
|
||||||
|
|
||||||
export const requestMutationMock = vi.fn();
|
|
||||||
|
|
||||||
export function jsonResponse(body: unknown): Response {
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
json: async () => body,
|
|
||||||
} as Response;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function settingsPayload(): SettingsPayload {
|
|
||||||
return {
|
|
||||||
agent: {
|
|
||||||
model: "openai/gpt-4o",
|
|
||||||
provider: "auto",
|
|
||||||
resolved_provider: "openai",
|
|
||||||
has_api_key: true,
|
|
||||||
model_preset: "primary",
|
|
||||||
max_tokens: 8192,
|
|
||||||
context_window_tokens: 200000,
|
|
||||||
temperature: 0.1,
|
|
||||||
reasoning_effort: null,
|
|
||||||
timezone: "UTC",
|
|
||||||
tool_hint_max_length: 40,
|
|
||||||
},
|
|
||||||
model_presets: [{
|
|
||||||
name: "primary",
|
|
||||||
label: "Primary",
|
|
||||||
active: true,
|
|
||||||
is_default: false,
|
|
||||||
model: "openai/gpt-4o",
|
|
||||||
provider: "auto",
|
|
||||||
resolved_provider: "openai",
|
|
||||||
max_tokens: 8192,
|
|
||||||
context_window_tokens: 200000,
|
|
||||||
temperature: 0.1,
|
|
||||||
reasoning_effort: null,
|
|
||||||
}],
|
|
||||||
model_call_order: ["primary"],
|
|
||||||
model_call_order_editable: true,
|
|
||||||
providers: [],
|
|
||||||
web_search: {
|
|
||||||
provider: "duckduckgo",
|
|
||||||
api_key_hint: null,
|
|
||||||
base_url: null,
|
|
||||||
max_results: 5,
|
|
||||||
timeout: 30,
|
|
||||||
providers: [{ name: "duckduckgo", label: "DuckDuckGo", credential: "none" }],
|
|
||||||
},
|
|
||||||
web: {
|
|
||||||
enable: true,
|
|
||||||
proxy: null,
|
|
||||||
user_agent: null,
|
|
||||||
search: { max_results: 5, timeout: 30 },
|
|
||||||
fetch: { use_jina_reader: true },
|
|
||||||
},
|
|
||||||
api: {
|
|
||||||
host: "127.0.0.1",
|
|
||||||
port: 8900,
|
|
||||||
timeout: 120,
|
|
||||||
api_key_hint: null,
|
|
||||||
},
|
|
||||||
observability: {
|
|
||||||
provider: "langfuse",
|
|
||||||
configured: false,
|
|
||||||
base_url: "https://cloud.langfuse.com",
|
|
||||||
},
|
|
||||||
image_generation: {
|
|
||||||
enabled: false,
|
|
||||||
provider: "openrouter",
|
|
||||||
provider_configured: false,
|
|
||||||
model: "openai/gpt-5.4-image-2",
|
|
||||||
default_aspect_ratio: "1:1",
|
|
||||||
default_image_size: "1K",
|
|
||||||
max_images_per_turn: 4,
|
|
||||||
save_dir: "generated",
|
|
||||||
providers: [],
|
|
||||||
},
|
|
||||||
runtime: {
|
|
||||||
config_path: "/tmp/config.json",
|
|
||||||
workspace_path: "/tmp/workspace",
|
|
||||||
gateway_host: "127.0.0.1",
|
|
||||||
gateway_port: 18790,
|
|
||||||
heartbeat: {
|
|
||||||
enabled: true,
|
|
||||||
interval_s: 1800,
|
|
||||||
keep_recent_messages: 8,
|
|
||||||
},
|
|
||||||
dream: {
|
|
||||||
schedule: "every 2h",
|
|
||||||
},
|
|
||||||
unified_session: false,
|
|
||||||
},
|
|
||||||
advanced: {
|
|
||||||
restrict_to_workspace: false,
|
|
||||||
webui_allow_local_service_access: true,
|
|
||||||
webui_default_access_mode: "default",
|
|
||||||
private_service_protection_enabled: true,
|
|
||||||
ssrf_whitelist_count: 0,
|
|
||||||
mcp_server_count: 0,
|
|
||||||
exec_enabled: true,
|
|
||||||
exec_sandbox: null,
|
|
||||||
exec_path_prepend_set: false,
|
|
||||||
exec_path_append_set: false,
|
|
||||||
},
|
|
||||||
requires_restart: false,
|
|
||||||
version: {
|
|
||||||
current: "0.2.2",
|
|
||||||
},
|
|
||||||
docs: {
|
|
||||||
version: "0.2.2",
|
|
||||||
base_url: "https://nanobot.wiki/docs/0.2.2",
|
|
||||||
chat_apps_url: "https://nanobot.wiki/docs/0.2.2/getting-started/chat-apps",
|
|
||||||
latest_url: "https://nanobot.wiki/docs/latest",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderSettingsView(
|
|
||||||
options: {
|
|
||||||
initialSection?:
|
|
||||||
| "overview"
|
|
||||||
| "appearance"
|
|
||||||
| "apps"
|
|
||||||
| "channels"
|
|
||||||
| "automations"
|
|
||||||
| "advanced"
|
|
||||||
| "models"
|
|
||||||
| "image"
|
|
||||||
| "browser"
|
|
||||||
| "runtime";
|
|
||||||
initialSettings?: SettingsPayload;
|
|
||||||
showSidebar?: boolean;
|
|
||||||
onBackToChat?: () => void;
|
|
||||||
onSettingsChange?: (payload: SettingsPayload) => void;
|
|
||||||
onNativeEngineRestart?: () => Promise<string>;
|
|
||||||
} = {},
|
|
||||||
) {
|
|
||||||
render(
|
|
||||||
<ClientProvider client={{ requestMutation: requestMutationMock } as never} token="tok">
|
|
||||||
<SettingsView
|
|
||||||
theme="light"
|
|
||||||
initialSection={options.initialSection ?? "apps"}
|
|
||||||
initialSettings={options.initialSettings}
|
|
||||||
showSidebar={options.showSidebar}
|
|
||||||
onToggleTheme={() => {}}
|
|
||||||
onBackToChat={options.onBackToChat ?? (() => {})}
|
|
||||||
onModelNameChange={() => {}}
|
|
||||||
onSettingsChange={options.onSettingsChange}
|
|
||||||
onNativeEngineRestart={options.onNativeEngineRestart}
|
|
||||||
/>
|
|
||||||
</ClientProvider>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function openPopover(trigger: HTMLElement) {
|
|
||||||
await userEvent.setup().click(trigger);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function installSettingsViewTestHooks() {
|
|
||||||
beforeEach(() => {
|
|
||||||
requestMutationMock.mockReset().mockResolvedValue(settingsPayload());
|
|
||||||
vi.stubGlobal(
|
|
||||||
"matchMedia",
|
|
||||||
vi.fn((query: string) => ({
|
|
||||||
matches: query === "(min-width: 1280px)",
|
|
||||||
media: query,
|
|
||||||
onchange: null,
|
|
||||||
addEventListener: vi.fn(),
|
|
||||||
removeEventListener: vi.fn(),
|
|
||||||
addListener: vi.fn(),
|
|
||||||
removeListener: vi.fn(),
|
|
||||||
dispatchEvent: vi.fn(),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
vi.stubGlobal(
|
|
||||||
"fetch",
|
|
||||||
vi.fn(() => new Promise<Response>(() => {})),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
localStorage.removeItem("nanobot-webui.settings-preferences");
|
|
||||||
vi.useRealTimers();
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user