mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-12 07:09:19 +03:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f94e1d73e2 | ||
|
|
abfcdd481a | ||
|
|
a0e60116a3 | ||
|
|
ec3dfb21ba | ||
|
|
72d3ce6b23 | ||
|
|
b14ac4c401 | ||
|
|
f5cf4dcd2c | ||
|
|
99e07e138e | ||
|
|
057e8f7af6 | ||
|
|
d45c893f68 | ||
|
|
1edfd268db | ||
|
|
c0e8b8afff | ||
|
|
95287f7435 | ||
|
|
43ca12960b | ||
|
|
7703cd22eb | ||
|
|
247c474e64 | ||
|
|
86c7508607 | ||
|
|
a2979c3a4b | ||
|
|
d5e0df6963 | ||
|
|
57d81bc1cd | ||
|
|
3778e7e628 | ||
|
|
cac39477ba | ||
|
|
eab017766b |
+2
-1
@@ -19,7 +19,7 @@ The recommended first-run path is:
|
||||
3. Configure a provider and model in **Settings → Models**.
|
||||
4. Send `Hello!` before configuring anything else.
|
||||
|
||||
Most people do not need to edit JSON for the first run. The WebUI handles the initial provider, model, and local browser settings. SSH, headless, existing-config, and older-release installs retain `nanobot onboard --wizard` as a terminal fallback. After the WebUI opens, use **Settings** for models and built-in capabilities, **Settings → Channels** for chat apps, and **Apps** for CLI App or MCP integrations.
|
||||
Most people do not need to edit JSON for the first run. The WebUI handles the initial provider, model, and local browser settings. SSH, headless, existing-config, and older-release installs retain `nanobot onboard --wizard` as a terminal fallback. After the WebUI opens, use **Settings** for models and built-in capabilities, **Settings → Channels** for chat apps, and **Apps** for Agent Plugins, CLI Apps, and MCP integrations.
|
||||
|
||||
## Add One Capability
|
||||
|
||||
@@ -32,6 +32,7 @@ Pick the row that matches what you want to accomplish next:
|
||||
| Choose a hosted, OAuth, company, or local model | [Provider Cookbook](./provider-cookbook.md) |
|
||||
| Add model fallbacks | [Configure Model Fallback](./guides/configure-model-fallback.md) |
|
||||
| Enable web search | [Configure Web Search](./guides/configure-web-search.md) |
|
||||
| Manage Agent Plugins, CLI Apps, or MCP integrations | [WebUI Apps](./webui.md#apps) |
|
||||
| Add an MCP tool server | [Configure MCP Tools](./guides/configure-mcp-tools.md) |
|
||||
| Generate images | [Image Generation](./image-generation.md) |
|
||||
| Schedule work or create a local trigger | [Automations](./automations.md) |
|
||||
|
||||
@@ -201,8 +201,10 @@ When changing tools, channels, file access, WebUI workspace behavior, or network
|
||||
| Provider | Add `ProviderSpec` in `providers/registry.py`, add schema field in `config/schema.py`, implement provider only if the generic backend is not enough |
|
||||
| Channel | Export a `ChannelPlugin` descriptor, keep its runtime and optional setup surfaces in one package, and follow [`channel-package-guide.md`](./channel-package-guide.md) |
|
||||
| Tool | Implement a tool under `agent/tools/` or expose a plugin entry point |
|
||||
| MCP | Add `tools.mcpServers` config |
|
||||
| Skill | Add workspace skill files under `<workspace>/skills/` or built-in skills under `nanobot/skills/` |
|
||||
| Agent Plugin | Add a v1 package under `<workspace>/plugins/` and enable it from Apps |
|
||||
| MCP | Add `tools.mcpServers` config or bundle the server in an Agent Plugin |
|
||||
| Skill | Add workspace skills under `<workspace>/skills/`, bundle them in an Agent Plugin, or add built-in skills under `nanobot/skills/` |
|
||||
| CLI App | Add it to the CLI Apps catalog; the installer owns its executable lifecycle and writes a skills-only Agent Plugin |
|
||||
|
||||
Prefer existing registry/discovery patterns over ad hoc wiring.
|
||||
|
||||
|
||||
@@ -132,6 +132,26 @@ Dream is a periodic consolidation job. It reads accumulated history and updates
|
||||
|
||||
See [`memory.md`](./memory.md) for the detailed design.
|
||||
|
||||
## Apps and Agent Plugins
|
||||
|
||||
Agent Plugins are nanobot's common package and activation boundary for
|
||||
installable capabilities. They organize existing extension types instead of
|
||||
replacing them:
|
||||
|
||||
| Part | Role |
|
||||
|---|---|
|
||||
| Agent Plugin | Installable package that can bundle skills, MCP servers, or both |
|
||||
| Skill | Workflow guidance loaded progressively or invoked with `$skill-name` |
|
||||
| MCP server | Runtime tools exposed to the agent |
|
||||
| CLI App | Locally managed executable whose adapter is packaged and activated like a plugin |
|
||||
| Apps | WebUI surface for reviewing and managing these capabilities |
|
||||
|
||||
Native providers, channels, built-in tools, standalone workspace skills, and
|
||||
directly configured MCP servers keep their existing extension paths. See
|
||||
[`webui.md#apps`](./webui.md#apps) for the user-facing flow and
|
||||
[`configuration.md#agent-plugins-v1`](./configuration.md#agent-plugins-v1) for
|
||||
the package contract.
|
||||
|
||||
## Tools and Safety
|
||||
|
||||
Tools are discovered automatically from built-in modules and plugin entry points. Common tool groups include:
|
||||
|
||||
+19
-1
@@ -330,7 +330,11 @@ By default, OpenAI uses `apiType: "auto"`: nanobot calls Chat Completions normal
|
||||
|
||||
Valid `apiType` values are exactly `auto`, `chat_completions`, and `responses`.
|
||||
|
||||
`extraBody` follows the selected OpenAI API surface. With Chat Completions, nanobot passes it through as the SDK `extra_body` value. With Responses, configure it in Responses API body shape; nanobot merges ordinary top-level fields into the Responses request body, appends `extraBody.tools` after generated function tools, and merges `extraBody.include` without duplicates:
|
||||
`extraBody` follows the selected OpenAI API surface. With Chat Completions, nanobot passes
|
||||
ordinary fields through as the SDK `extra_body` value; list-valued `extraBody.tools` is handled
|
||||
specially and appended after generated function tools. With Responses, configure it in Responses
|
||||
API body shape; nanobot merges ordinary top-level fields into the Responses request body, appends
|
||||
`extraBody.tools` after generated function tools, and merges `extraBody.include` without duplicates:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -2343,6 +2347,20 @@ Disabled skills are excluded from the main agent's skill summary, from always-on
|
||||
|--------|---------|-------------|
|
||||
| `agents.defaults.disabledSkills` | `[]` | List of skill directory names to exclude from loading. Applies to both built-in skills and workspace skills. |
|
||||
|
||||
### Agent Plugins v1
|
||||
|
||||
nanobot discovers [Agent Plugins](https://agent-plugins.org/) under `<workspace>/plugins/`; a v1 package has `plugin.json` and may add `mcp.json`, `skills/<name>/SKILL.md`, or both. Agent Plugins are the common package and activation boundary for installable capabilities; they do not replace native providers, channels, tools, standalone workspace skills, or directly configured MCP servers.
|
||||
|
||||
Directory presence means installed; activation is explicit in **Apps**. Skills use progressive loading and `$skill-name` invocation, with workspace > plugin > built-in precedence.
|
||||
Enabled `stdio` servers receive contained `PLUGIN_ROOT` and isolated `PLUGIN_DATA` paths; explicit
|
||||
`tools.mcpServers` entries win collisions. Invalid or escaping components are ignored.
|
||||
An enabled package is treated as immutable: changing any packaged file disables it until the user
|
||||
reviews and enables it again. Runtime state belongs under `PLUGIN_DATA`, not the package root.
|
||||
|
||||
Enabled plugins run as the nanobot user; permissions are descriptive, not an OS sandbox. The optional `extensions.dev.nanobot.logo` accepts a contained PNG, JPEG, or WebP up to 256 KiB.
|
||||
|
||||
CLI Apps use the same skills-only package layout while their installer manages executables, updates, and removal. Future catalogs can place packages before using this activation path.
|
||||
|
||||
## Tool Hint Max Length
|
||||
|
||||
Tool hints are the short progress messages shown when the agent calls tools (e.g. `$ cd …/project && npm test`). By default, these are truncated at 40 characters, which can make long commands hard to read.
|
||||
|
||||
@@ -100,6 +100,29 @@ Gateway-style setup for model IDs served through OpenRouter.
|
||||
|
||||
Use the model ID exactly as OpenRouter lists it.
|
||||
|
||||
To opt into OpenRouter server-managed search and fetch, add:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"extraBody": {
|
||||
"tools": [
|
||||
{ "type": "openrouter:web_search" },
|
||||
{ "type": "openrouter:web_fetch" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Chat Completions-compatible OpenRouter
|
||||
[server tools](https://openrouter.ai/docs/guides/features/server-tools), such as those above, are
|
||||
appended to nanobot's generated functions. This keeps unrelated local tools such as `write_file`
|
||||
available in the same request. Responses-only server tools require an API surface that the
|
||||
OpenRouter provider does not currently enable.
|
||||
|
||||
### Eden AI Gateway
|
||||
|
||||
Eden AI exposes an OpenAI-compatible chat-completions endpoint at
|
||||
|
||||
+13
-7
@@ -198,12 +198,16 @@ Test a new channel with a private DM. When a supported channel sends a pairing c
|
||||
|
||||
## Apps
|
||||
|
||||
Open Apps from the sidebar to manage tools that nanobot can attach to a chat
|
||||
turn. The default **Ready** view shows only tools that can be used immediately:
|
||||
Open Apps from the sidebar to review and manage installable capabilities. The
|
||||
default **Ready** view shows only capabilities that can be used immediately:
|
||||
|
||||
- **Apps** are local command-line adapters that nanobot runs on your machine.
|
||||
Installing an adapter does not modify the native desktop or web app it
|
||||
connects to.
|
||||
- **Agent Plugins** are local packages that can bundle skills, MCP servers, or
|
||||
both. A package under `<workspace>/plugins/` is installed but remains inactive
|
||||
until you enable it in Apps.
|
||||
- **CLI Apps** are local command-line adapters that nanobot runs on your
|
||||
machine. Their installer manages the executable and exposes its adapter
|
||||
through the same plugin activation model. Installing an adapter does not
|
||||
modify the native desktop or web app it connects to.
|
||||
- **MCP** lists Model Context Protocol servers. Presets provide known
|
||||
configurations, and the **Add MCP server** panel accepts stdio, HTTP, and SSE
|
||||
servers. Custom HTTP/SSE servers can use no authentication, OAuth, or request
|
||||
@@ -219,6 +223,7 @@ are not tools that can be attached to a turn with `@`. Manage them from
|
||||
included in nanobot and activate automatically when a file is attached. The
|
||||
equivalent CLI for optional integrations remains `nanobot plugins`. See
|
||||
[`cli-reference.md`](./cli-reference.md#optional-features).
|
||||
That command manages nanobot runtime extras, not Agent Plugin packages.
|
||||
|
||||
Some MCP presets connect to hosted keyless endpoints. For example, the Firecrawl
|
||||
preset uses Firecrawl's hosted MCP endpoint for search, scrape, crawl, and
|
||||
@@ -231,8 +236,9 @@ endpoint and exposes `web_search` and `web_fetch` without requiring an API key.
|
||||
It is an optional integration and does not replace nanobot's built-in web search
|
||||
provider; mention `@parallel-search` when a turn should use it.
|
||||
|
||||
After an App or MCP server is available, mention it from the composer with `@`
|
||||
to attach that tool to the next message.
|
||||
After a CLI App or MCP server is available, mention it from the composer with
|
||||
`@` to attach that tool to the next message. Plugin-provided skills participate
|
||||
in normal skill discovery and can be invoked with `$skill-name`.
|
||||
|
||||
## Skills
|
||||
|
||||
|
||||
@@ -46,6 +46,10 @@ async def connect_mcp(state: Any, tools: ToolRegistry) -> None:
|
||||
await mcp_tools.connect_missing_servers(state, tools)
|
||||
|
||||
|
||||
def mcp_runtime_status(state: Any) -> dict[str, mcp_tools.MCPRuntimeStatus]:
|
||||
return mcp_tools.runtime_status(state)
|
||||
|
||||
|
||||
async def close_mcp(state: Any) -> None:
|
||||
await mcp_tools.close_mcp_servers(state)
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ from nanobot.utils.runtime import (
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.agent.tools.mcp import MCPConnection
|
||||
from nanobot.agent.tools.mcp import MCPConnection, MCPRuntimeStatus
|
||||
from nanobot.config.schema import (
|
||||
ChannelsConfig,
|
||||
Config,
|
||||
@@ -401,6 +401,7 @@ class AgentLoop:
|
||||
self._running = False
|
||||
self._mcp_servers = mcp_servers or {}
|
||||
self._mcp_stacks: dict[str, MCPConnection] = {}
|
||||
self._mcp_runtime_statuses: dict[str, MCPRuntimeStatus] = {}
|
||||
self._mcp_connecting = False
|
||||
self._runtime_context_providers: list[RuntimeContextProvider] = []
|
||||
self._active_tasks: dict[str, set[asyncio.Task[Any]]] = {}
|
||||
@@ -485,6 +486,8 @@ class AgentLoop:
|
||||
config,
|
||||
provider_snapshot_loader,
|
||||
)
|
||||
from nanobot.agent.plugins import agent_plugin_mcp_servers
|
||||
|
||||
return cls(
|
||||
bus=bus,
|
||||
provider=provider,
|
||||
@@ -499,7 +502,7 @@ class AgentLoop:
|
||||
provider_retry_mode=defaults.provider_retry_mode,
|
||||
tool_hint_max_length=defaults.tool_hint_max_length,
|
||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
||||
mcp_servers=config.tools.mcp_servers,
|
||||
mcp_servers=agent_plugin_mcp_servers(config.workspace_path, config.tools.mcp_servers),
|
||||
channels_config=config.channels,
|
||||
timezone=defaults.timezone,
|
||||
unified_session=defaults.unified_session,
|
||||
@@ -644,6 +647,10 @@ class AgentLoop:
|
||||
"""Connect configured MCP servers."""
|
||||
await agent_context.connect_mcp(self, self.tools)
|
||||
|
||||
def mcp_runtime_status(self) -> dict[str, MCPRuntimeStatus]:
|
||||
"""Return connection state learned from real MCP runtime attempts."""
|
||||
return agent_context.mcp_runtime_status(self)
|
||||
|
||||
def register_runtime_context_provider(
|
||||
self,
|
||||
provider: RuntimeContextProvider,
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
"""Load and activate locally installed Agent Plugin packages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, replace
|
||||
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
|
||||
_SKILL_CACHE: dict[tuple[Path, Path], tuple[tuple[str, Path], ...]] = {}
|
||||
|
||||
|
||||
@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, ...]
|
||||
mcp_servers: tuple[str, ...] = ()
|
||||
enabled: bool = False
|
||||
|
||||
|
||||
def _installed_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: dict[str, AgentPlugin | None] = {}
|
||||
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:
|
||||
if plugin.name in plugins:
|
||||
logger.warning("Ignoring duplicate Agent Plugin identity '{}'", plugin.name)
|
||||
plugins[plugin.name] = None
|
||||
else:
|
||||
plugins[plugin.name] = plugin
|
||||
return [plugin for plugin in plugins.values() if plugin is not None]
|
||||
|
||||
|
||||
def enabled_agent_plugin_skills(workspace: Path) -> list[tuple[str, Path]]:
|
||||
"""Verify and return skills from plugins the user has explicitly enabled."""
|
||||
skills = [
|
||||
skill
|
||||
for plugin in _installed_plugins(workspace)
|
||||
if _enabled(workspace, plugin)
|
||||
for skill in _discover_plugin_skills(plugin.name, plugin.root)
|
||||
]
|
||||
_SKILL_CACHE[_skill_cache_key(workspace)] = tuple(skills)
|
||||
return skills
|
||||
|
||||
|
||||
def enabled_agent_plugin_skill_dirs(workspace: Path) -> tuple[Path, ...]:
|
||||
"""Return the last verified skill roots, verifying once on a cache miss."""
|
||||
key = _skill_cache_key(workspace)
|
||||
skills = _SKILL_CACHE.get(key)
|
||||
if skills is None:
|
||||
skills = tuple(enabled_agent_plugin_skills(workspace))
|
||||
return tuple(path.parent for _name, path in skills)
|
||||
|
||||
|
||||
def _skill_cache_key(workspace: Path) -> tuple[Path, Path]:
|
||||
return (
|
||||
workspace.expanduser().resolve(),
|
||||
get_config_path().expanduser().resolve(),
|
||||
)
|
||||
|
||||
|
||||
def _invalidate_skill_cache(workspace: Path) -> None:
|
||||
_SKILL_CACHE.pop(_skill_cache_key(workspace), None)
|
||||
|
||||
|
||||
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 _installed_plugins(workspace):
|
||||
if not _enabled(workspace, plugin):
|
||||
continue
|
||||
plugin_servers = _plugin_mcp_servers(workspace, plugin)
|
||||
for name, server in plugin_servers.items():
|
||||
# ``--`` cannot occur in a valid plugin identity, so multi-server
|
||||
# namespaces cannot collide with a single-server plugin name.
|
||||
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_plugins(workspace: Path) -> list[AgentPlugin]:
|
||||
"""Return component and lifecycle state for discovered plugins."""
|
||||
return [
|
||||
replace(
|
||||
plugin,
|
||||
mcp_servers=tuple(sorted(_plugin_mcp_servers(workspace, plugin))),
|
||||
enabled=_enabled(workspace, plugin),
|
||||
)
|
||||
for plugin in _installed_plugins(workspace)
|
||||
]
|
||||
|
||||
|
||||
def set_agent_plugin_enabled(workspace: Path, name: str, enabled: bool) -> None:
|
||||
"""Enable or disable one installed plugin."""
|
||||
plugin = next((item for item in _installed_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:
|
||||
activation = _activation_marker(plugin)
|
||||
if activation is None:
|
||||
raise RuntimeError(f"Agent Plugin '{name}' changed while it was being enabled")
|
||||
marker.write_text(activation, encoding="utf-8")
|
||||
marker.chmod(0o600)
|
||||
else:
|
||||
marker.unlink(missing_ok=True)
|
||||
_invalidate_skill_cache(workspace)
|
||||
|
||||
|
||||
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()},
|
||||
"PYTHONDONTWRITEBYTECODE": "1",
|
||||
"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, plugin: AgentPlugin) -> bool:
|
||||
marker = _plugin_data_dir(workspace, plugin.name, create=False) / "enabled"
|
||||
try:
|
||||
if not marker.is_file():
|
||||
return False
|
||||
current = marker.read_text(encoding="utf-8")
|
||||
activation = _activation_marker(plugin)
|
||||
if activation is None:
|
||||
marker.unlink(missing_ok=True)
|
||||
_invalidate_skill_cache(workspace)
|
||||
return False
|
||||
if current == activation:
|
||||
return True
|
||||
if current == str(plugin.root):
|
||||
marker.write_text(activation, encoding="utf-8")
|
||||
marker.chmod(0o600)
|
||||
return True
|
||||
marker.unlink(missing_ok=True)
|
||||
_invalidate_skill_cache(workspace)
|
||||
return False
|
||||
except OSError:
|
||||
_invalidate_skill_cache(workspace)
|
||||
return False
|
||||
|
||||
|
||||
def _activation_marker(plugin: AgentPlugin) -> str | None:
|
||||
"""Bind activation to one immutable package snapshot."""
|
||||
digest = sha256()
|
||||
try:
|
||||
for candidate in sorted(plugin.root.rglob("*")):
|
||||
relative = candidate.relative_to(plugin.root).as_posix()
|
||||
digest.update(relative.encode())
|
||||
if candidate.is_symlink():
|
||||
digest.update(b"\0link\0")
|
||||
digest.update(candidate.readlink().as_posix().encode())
|
||||
elif candidate.is_file():
|
||||
digest.update(b"\0file\0")
|
||||
digest.update(candidate.read_bytes())
|
||||
elif candidate.is_dir():
|
||||
digest.update(b"\0dir\0")
|
||||
else:
|
||||
return None
|
||||
digest.update(b"\0")
|
||||
except OSError:
|
||||
return None
|
||||
return json.dumps(
|
||||
{"fingerprint": digest.hexdigest(), "root": str(plugin.root)},
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
+66
-30
@@ -17,9 +17,35 @@ _STRIP_SKILL_FRONTMATTER = re.compile(
|
||||
r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?",
|
||||
re.DOTALL,
|
||||
)
|
||||
_SKILL_NAME = re.compile(r"^(?!.*--)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
|
||||
_SKILL_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
|
||||
)
|
||||
|
||||
|
||||
class SkillsLoader:
|
||||
"""
|
||||
Loader for agent skills.
|
||||
@@ -34,6 +60,15 @@ class SkillsLoader:
|
||||
self.builtin_skills = builtin_skills_dir or BUILTIN_SKILLS_DIR
|
||||
self.disabled_skills = disabled_skills or set()
|
||||
|
||||
def _skill_aliases(self) -> dict[str, str]:
|
||||
"""Return compatibility aliases owned by installed CLI Apps."""
|
||||
from nanobot.apps.cli import CliAppManager
|
||||
|
||||
try:
|
||||
return CliAppManager(workspace=self.workspace).installed_skill_aliases()
|
||||
except OSError:
|
||||
return {}
|
||||
|
||||
def _skill_entries_from_dir(self, base: Path, source: str, *, skip_names: set[str] | None = None) -> list[dict[str, str]]:
|
||||
if not base.exists():
|
||||
return []
|
||||
@@ -60,15 +95,33 @@ class SkillsLoader:
|
||||
Returns:
|
||||
List of skill info dicts with 'name', 'path', 'source'.
|
||||
"""
|
||||
from nanobot.agent.plugins import enabled_agent_plugin_skills
|
||||
|
||||
plugin_skills = enabled_agent_plugin_skills(self.workspace)
|
||||
skills = self._skill_entries_from_dir(self.workspace_skills, "workspace")
|
||||
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():
|
||||
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:
|
||||
skills = [s for s in skills if s["name"] not in self.disabled_skills]
|
||||
disabled = set(self.disabled_skills)
|
||||
for legacy, canonical in self._skill_aliases().items():
|
||||
if legacy in disabled or canonical in disabled:
|
||||
disabled.update((legacy, canonical))
|
||||
skills = [s for s in skills if s["name"] not in disabled]
|
||||
|
||||
if filter_unavailable:
|
||||
return [skill for skill in skills if self._check_requirements(self._get_skill_meta(skill["name"]))]
|
||||
@@ -84,14 +137,11 @@ class SkillsLoader:
|
||||
Returns:
|
||||
Skill content or None if not found.
|
||||
"""
|
||||
roots = [self.workspace_skills]
|
||||
if self.builtin_skills:
|
||||
roots.append(self.builtin_skills)
|
||||
for root in roots:
|
||||
path = root / name / "SKILL.md"
|
||||
if path.exists():
|
||||
return path.read_text(encoding="utf-8")
|
||||
return None
|
||||
skills = self.list_skills(filter_unavailable=False)
|
||||
available = {skill["name"] for skill in skills}
|
||||
resolved = name if name in available else self._skill_aliases().get(name, name)
|
||||
entry = next((skill for skill in skills if skill["name"] == resolved), None)
|
||||
return Path(entry["path"]).read_text(encoding="utf-8") if entry else None
|
||||
|
||||
def load_skills_for_context(self, skill_names: list[str]) -> str:
|
||||
"""
|
||||
@@ -118,9 +168,11 @@ class SkillsLoader:
|
||||
entry["name"]
|
||||
for entry in self.list_skills(filter_unavailable=True)
|
||||
}
|
||||
aliases = self._skill_aliases()
|
||||
invoked: list[str] = []
|
||||
for match in _SKILL_REFERENCE.finditer(text):
|
||||
name = match.group(1)
|
||||
requested = match.group(1)
|
||||
name = requested if requested in available else aliases.get(requested, requested)
|
||||
if name in available and name not in invoked:
|
||||
invoked.append(name)
|
||||
return invoked
|
||||
@@ -145,6 +197,7 @@ class SkillsLoader:
|
||||
sections: list[str] = []
|
||||
groups = (
|
||||
("Workspace skills", "workspace", self.workspace_skills),
|
||||
("Agent Plugin skills", "plugin", self.workspace / "plugins"),
|
||||
("Built-in skills", "builtin", self.builtin_skills),
|
||||
)
|
||||
for label, source, root in groups:
|
||||
@@ -278,21 +331,4 @@ class SkillsLoader:
|
||||
Returns:
|
||||
Metadata dict or None.
|
||||
"""
|
||||
content = self.load_skill(name)
|
||||
if not content or not content.startswith("---"):
|
||||
return None
|
||||
match = _STRIP_SKILL_FRONTMATTER.match(content)
|
||||
if not match:
|
||||
return None
|
||||
try:
|
||||
parsed = yaml.safe_load(match.group(1))
|
||||
except yaml.YAMLError:
|
||||
return None
|
||||
if not isinstance(parsed, dict):
|
||||
return None
|
||||
# yaml.safe_load returns native types (int, bool, list, etc.);
|
||||
# keep values as-is so downstream consumers get correct types.
|
||||
metadata: dict[str, object] = {}
|
||||
for key, value in cast(dict[object, object], parsed).items():
|
||||
metadata[str(key)] = value
|
||||
return metadata
|
||||
return parse_skill_metadata(self.load_skill(name) or "")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Base class for agent tools."""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import typing
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Callable
|
||||
@@ -67,6 +68,8 @@ class Schema(ABC):
|
||||
return [f"{label} should be number"]
|
||||
if t in _JSON_TYPE_MAP and t not in ("integer", "number") and not isinstance(val, _JSON_TYPE_MAP[t]):
|
||||
return [f"{label} should be {t}"]
|
||||
if t == "number" and isinstance(val, float) and not math.isfinite(val):
|
||||
return [f"{label} must be finite"]
|
||||
|
||||
errors: list[str] = []
|
||||
if "enum" in schema and val not in schema["enum"]:
|
||||
|
||||
@@ -148,9 +148,19 @@ class _FsTool(Tool):
|
||||
)
|
||||
|
||||
def _resolve_read(self, path: str) -> Path:
|
||||
plugin_skill_dirs: list[Path] = []
|
||||
if self._workspace is not None:
|
||||
from nanobot.agent.plugins import enabled_agent_plugin_skill_dirs
|
||||
|
||||
try:
|
||||
plugin_skill_dirs = list(
|
||||
enabled_agent_plugin_skill_dirs(Path(self._workspace))
|
||||
)
|
||||
except (OSError, RuntimeError):
|
||||
pass
|
||||
return self._resolve_with_extra(
|
||||
path,
|
||||
self._extra_read_allowed_dirs,
|
||||
[*self._extra_read_allowed_dirs, *plugin_skill_dirs],
|
||||
self._extra_read_allowed_files,
|
||||
include_media_dir=True,
|
||||
extra_files_require_allowed_root=True,
|
||||
|
||||
+101
-3
@@ -9,7 +9,7 @@ import shutil
|
||||
import urllib.parse
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
from contextlib import AsyncExitStack, suppress
|
||||
from typing import TYPE_CHECKING, Any, Mapping, Protocol, cast
|
||||
from typing import TYPE_CHECKING, Any, Literal, Mapping, Protocol, cast
|
||||
from weakref import WeakKeyDictionary
|
||||
|
||||
import httpx
|
||||
@@ -62,6 +62,10 @@ _WINDOWS_SHELL_LAUNCHERS: frozenset[str] = frozenset(("npx", "npm", "pnpm", "yar
|
||||
_SANITIZE_RE = re.compile(r"_+")
|
||||
_RELOAD_LOCKS: WeakKeyDictionary[Any, asyncio.Lock] = WeakKeyDictionary()
|
||||
_ReconnectCallback = Callable[[str, str, Tool], Awaitable[Tool | None]]
|
||||
MCPRuntimeStatus = Literal["connecting", "connected", "failed"]
|
||||
_MCP_RUNTIME_STATUSES: frozenset[MCPRuntimeStatus] = frozenset(
|
||||
("connecting", "connected", "failed")
|
||||
)
|
||||
|
||||
|
||||
class MCPConnection(Protocol):
|
||||
@@ -1297,17 +1301,92 @@ def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
return {"mcp_presets": mcp_presets} if isinstance(mcp_presets, list) and mcp_presets else {}
|
||||
|
||||
|
||||
def _runtime_status_store(
|
||||
state: Any,
|
||||
*,
|
||||
create: bool = False,
|
||||
) -> dict[str, MCPRuntimeStatus] | None:
|
||||
raw_statuses: object = getattr(state, "_mcp_runtime_statuses", None)
|
||||
if isinstance(raw_statuses, dict):
|
||||
return cast(dict[str, MCPRuntimeStatus], raw_statuses)
|
||||
if not create:
|
||||
return None
|
||||
statuses: dict[str, MCPRuntimeStatus] = {}
|
||||
state._mcp_runtime_statuses = statuses
|
||||
return statuses
|
||||
|
||||
|
||||
def runtime_status(state: Any) -> dict[str, MCPRuntimeStatus]:
|
||||
"""Return the latest connection-attempt result for configured MCP servers."""
|
||||
statuses = _runtime_status_store(state)
|
||||
raw_configured: object = getattr(state, "_mcp_servers", None)
|
||||
if statuses is None or not isinstance(raw_configured, dict):
|
||||
return {}
|
||||
configured = cast(dict[str, Any], raw_configured)
|
||||
return {
|
||||
name: status
|
||||
for name, status in statuses.items()
|
||||
if name in configured and status in _MCP_RUNTIME_STATUSES
|
||||
}
|
||||
|
||||
|
||||
def _set_runtime_status(
|
||||
state: Any,
|
||||
server_names: Mapping[str, Any] | set[str] | list[str] | tuple[str, ...],
|
||||
status: MCPRuntimeStatus,
|
||||
) -> None:
|
||||
statuses = _runtime_status_store(state, create=True)
|
||||
assert statuses is not None
|
||||
for name in server_names:
|
||||
statuses[name] = status
|
||||
|
||||
|
||||
def _record_connection_result(
|
||||
state: Any,
|
||||
attempted: Mapping[str, Any] | set[str] | list[str] | tuple[str, ...],
|
||||
connected: Mapping[str, Any] | set[str] | list[str] | tuple[str, ...],
|
||||
) -> None:
|
||||
attempted_names = set(attempted)
|
||||
connected_names = set(connected)
|
||||
_set_runtime_status(state, connected_names, "connected")
|
||||
_set_runtime_status(state, attempted_names - connected_names, "failed")
|
||||
|
||||
|
||||
async def connect_missing_servers(state: Any, registry: ToolRegistry) -> None:
|
||||
"""Connect configured MCP servers that are not currently live."""
|
||||
async with _reload_lock(state):
|
||||
if getattr(state, "_mcp_closing", False):
|
||||
return
|
||||
missing_servers = {
|
||||
configured_missing = {
|
||||
name: cfg for name, cfg in state._mcp_servers.items() if name not in state._mcp_stacks
|
||||
}
|
||||
oauth_servers = {
|
||||
name: cfg
|
||||
for name, cfg in configured_missing.items()
|
||||
if getattr(cfg, "auth", None) == "oauth"
|
||||
}
|
||||
authorization_pending: set[str] = set()
|
||||
if oauth_servers:
|
||||
from nanobot.agent.tools.mcp_oauth import mcp_oauth_has_credentials
|
||||
|
||||
authorization_pending = {
|
||||
name
|
||||
for name, cfg in oauth_servers.items()
|
||||
if not mcp_oauth_has_credentials(name, cfg.url)
|
||||
}
|
||||
statuses = _runtime_status_store(state)
|
||||
if statuses is not None:
|
||||
for name in authorization_pending:
|
||||
statuses.pop(name, None)
|
||||
missing_servers = {
|
||||
name: cfg
|
||||
for name, cfg in configured_missing.items()
|
||||
if name not in authorization_pending
|
||||
}
|
||||
if state._mcp_connecting or not missing_servers:
|
||||
return
|
||||
state._mcp_connecting = True
|
||||
_set_runtime_status(state, missing_servers, "connecting")
|
||||
try:
|
||||
connected = await connect_mcp_servers(missing_servers, registry)
|
||||
if getattr(state, "_mcp_closing", False):
|
||||
@@ -1315,6 +1394,7 @@ async def connect_missing_servers(state: Any, registry: ToolRegistry) -> None:
|
||||
await connection.aclose()
|
||||
return
|
||||
state._mcp_stacks.update(connected)
|
||||
_record_connection_result(state, missing_servers, connected)
|
||||
_attach_reconnect_handlers(state, registry, connected)
|
||||
if connected:
|
||||
logger.info("MCP connected servers: {}", sorted(connected))
|
||||
@@ -1323,8 +1403,10 @@ async def connect_missing_servers(state: Any, registry: ToolRegistry) -> None:
|
||||
except asyncio.CancelledError:
|
||||
if task_is_cancelling():
|
||||
raise
|
||||
_set_runtime_status(state, missing_servers, "failed")
|
||||
logger.warning("MCP connection cancelled (will retry next message)")
|
||||
except BaseException as e:
|
||||
_set_runtime_status(state, missing_servers, "failed")
|
||||
logger.warning("Failed to connect MCP servers (will retry next message): {}", e)
|
||||
finally:
|
||||
state._mcp_connecting = False
|
||||
@@ -1340,10 +1422,14 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
||||
"requires_restart": True,
|
||||
}
|
||||
try:
|
||||
from nanobot.agent.plugins import agent_plugin_mcp_servers
|
||||
from nanobot.config.loader import load_config, resolve_config_env_vars
|
||||
|
||||
config = resolve_config_env_vars(load_config())
|
||||
next_servers = dict(config.tools.mcp_servers)
|
||||
next_servers = agent_plugin_mcp_servers(
|
||||
config.workspace_path,
|
||||
config.tools.mcp_servers,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("MCP hot reload could not read config: {}", exc)
|
||||
return {
|
||||
@@ -1376,6 +1462,11 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
||||
tools_removed += _unregister_server_tools(registry, name)
|
||||
await _close_server(state, name)
|
||||
|
||||
runtime_statuses = _runtime_status_store(state)
|
||||
if runtime_statuses is not None:
|
||||
for name in [*removed, *authorization_pending]:
|
||||
runtime_statuses.pop(name, None)
|
||||
|
||||
state._mcp_servers = next_servers
|
||||
retry_missing = sorted(
|
||||
name
|
||||
@@ -1390,6 +1481,7 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
||||
to_connect = {name: next_servers[name] for name in to_connect_names}
|
||||
connected: dict[str, MCPConnection] = {}
|
||||
if to_connect:
|
||||
_set_runtime_status(state, to_connect, "connecting")
|
||||
connected = await connect_mcp_servers(to_connect, registry)
|
||||
if getattr(state, "_mcp_closing", False):
|
||||
for connection in connected.values():
|
||||
@@ -1400,6 +1492,7 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
||||
"requires_restart": True,
|
||||
}
|
||||
state._mcp_stacks.update(connected)
|
||||
_record_connection_result(state, to_connect, connected)
|
||||
_attach_reconnect_handlers(state, registry, connected)
|
||||
|
||||
failed = sorted(set(to_connect) - set(connected))
|
||||
@@ -1558,12 +1651,14 @@ async def _refresh_terminated_server(
|
||||
_unregister_server_tools(registry, server_name)
|
||||
await _close_server(state, server_name)
|
||||
|
||||
_set_runtime_status(state, {server_name}, "connecting")
|
||||
connected = await connect_mcp_servers({server_name: cfg}, registry)
|
||||
if getattr(state, "_mcp_closing", False):
|
||||
for connection in connected.values():
|
||||
await connection.aclose()
|
||||
return None
|
||||
state._mcp_stacks.update(connected)
|
||||
_record_connection_result(state, {server_name}, connected)
|
||||
_attach_reconnect_handlers(state, registry, connected)
|
||||
if server_name not in connected:
|
||||
logger.warning("MCP server '{}' reconnect failed after session termination", server_name)
|
||||
@@ -1617,6 +1712,9 @@ async def close_mcp_servers(state: Any) -> None:
|
||||
async with _reload_lock(state):
|
||||
connections = list(state._mcp_stacks.items())
|
||||
state._mcp_stacks.clear()
|
||||
statuses = _runtime_status_store(state)
|
||||
if statuses is not None:
|
||||
statuses.clear()
|
||||
for name, connection in connections:
|
||||
try:
|
||||
await connection.aclose()
|
||||
|
||||
+131
-23
@@ -20,6 +20,7 @@ from urllib.parse import urlparse
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.skills import parse_skill_metadata, valid_skill_metadata
|
||||
from nanobot.apps.protocol import app_manifest, compact_dict
|
||||
from nanobot.config.paths import get_runtime_subdir
|
||||
from nanobot.security.workspace_policy import is_path_within
|
||||
@@ -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_PUBLIC_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/public_registry.json"
|
||||
CLI_ANYTHING_RAW_BASE = "https://raw.githubusercontent.com/HKUDS/CLI-Anything/main"
|
||||
AGENT_PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"
|
||||
NANOBOT_EXTENSION_REGISTRY_URL = "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main/registry.json"
|
||||
NANOBOT_EXTENSION_RAW_BASE = "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main"
|
||||
_CATALOG_SOURCES = (
|
||||
@@ -102,7 +104,6 @@ _BRANDS: dict[str, tuple[str, str]] = {
|
||||
"audacity": ("audacity", "#0000CC"),
|
||||
"blender": ("blender", "#E87D0D"),
|
||||
"browser": ("googlechrome", "#4285F4"),
|
||||
"calibre": ("calibre", "#45B29D"),
|
||||
"chromadb": ("chroma", "#FFDE2D"),
|
||||
"comfyui": ("comfyui", "#111827"),
|
||||
"contentful": ("contentful", "#2478CC"),
|
||||
@@ -156,6 +157,7 @@ _BRANDS: dict[str, tuple[str, str]] = {
|
||||
_BRAND_DOMAINS: dict[str, tuple[str, str]] = {
|
||||
"3mf": ("3mf.io", "#00A1DE"),
|
||||
"anygen": ("anygen.io", "#111827"),
|
||||
"calibre": ("calibre-ebook.com", "#45B29D"),
|
||||
"clibrowser": ("github.com/allthingssecurity/clibrowser", "#24292F"),
|
||||
"cloudanalyzer": ("github.com/rsasaki0109/CloudAnalyzer", "#2563EB"),
|
||||
"cloudcompare": ("cloudcompare.org", "#4D83C3"),
|
||||
@@ -199,6 +201,13 @@ _BRAND_ALIASES: dict[str, str] = {
|
||||
}
|
||||
|
||||
_BRAND_TRAILING_WORDS = ("cli", "workflow", "workflows", "app", "apps", "tool", "tools")
|
||||
_GENERIC_HOMEPAGE_HOSTS = frozenset({
|
||||
"bitbucket.org",
|
||||
"github.com",
|
||||
"gitlab.com",
|
||||
"npmjs.com",
|
||||
"pypi.org",
|
||||
})
|
||||
|
||||
|
||||
def _now() -> float:
|
||||
@@ -210,11 +219,27 @@ def _as_object_dict(value: object) -> dict[str, Any] | 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("-")
|
||||
if not legacy:
|
||||
clean = clean.replace("_", "-")
|
||||
return f"cli-app-{clean or 'app'}"
|
||||
|
||||
|
||||
def _plugin_skill_relative_path(name: str) -> str:
|
||||
skill_name = _skill_name(name)
|
||||
return f"plugins/{skill_name}/skills/{skill_name}/SKILL.md"
|
||||
|
||||
|
||||
def cli_app_skill_relative_path(workspace: Path, name: str) -> str:
|
||||
"""Return a CLI App's skill path, including the legacy location."""
|
||||
canonical = _plugin_skill_relative_path(name)
|
||||
legacy = f"skills/{_skill_name(name, legacy=True)}/SKILL.md"
|
||||
if not (workspace / canonical).is_file() and (workspace / legacy).is_file():
|
||||
return legacy
|
||||
return canonical
|
||||
|
||||
|
||||
def _has_shell_meta(command: str) -> bool:
|
||||
return any(char in command for char in _SHELL_META_CHARS)
|
||||
|
||||
@@ -315,11 +340,25 @@ def _brand_candidates(app: dict[str, Any]) -> list[str]:
|
||||
return candidates
|
||||
|
||||
|
||||
def _homepage_domain(app: dict[str, Any]) -> str | None:
|
||||
value = str(app.get("homepage") or "").strip()
|
||||
try:
|
||||
parsed = urlparse(value)
|
||||
except ValueError:
|
||||
return None
|
||||
host = (parsed.hostname or "").lower().removeprefix("www.")
|
||||
if parsed.scheme not in {"http", "https"} or host in _GENERIC_HOMEPAGE_HOSTS:
|
||||
return None
|
||||
if not host or "." not in host or any(not label for label in host.split(".")):
|
||||
return None
|
||||
return host
|
||||
|
||||
|
||||
def _brand_payload(app: dict[str, Any]) -> tuple[str | None, str | None]:
|
||||
declared_logo = str(app.get("logo_url") or "").strip()
|
||||
declared_color = str(app.get("brand_color") or "").strip() or None
|
||||
if declared_logo.startswith(("https://", "/")):
|
||||
declared_color = str(app.get("brand_color") or "").strip()
|
||||
return declared_logo, declared_color or None
|
||||
return declared_logo, declared_color
|
||||
|
||||
brand = None
|
||||
domain_brand = None
|
||||
@@ -331,13 +370,21 @@ def _brand_payload(app: dict[str, Any]) -> tuple[str | None, str | None]:
|
||||
domain_brand = _BRAND_DOMAINS.get(key)
|
||||
if domain_brand:
|
||||
break
|
||||
|
||||
brand_color = declared_color or (brand or domain_brand or (None, None))[1]
|
||||
homepage_domain = _homepage_domain(app)
|
||||
if homepage_domain:
|
||||
return (
|
||||
f"https://www.google.com/s2/favicons?domain={homepage_domain}&sz=64",
|
||||
brand_color,
|
||||
)
|
||||
if not brand:
|
||||
if not domain_brand:
|
||||
return None, None
|
||||
domain, color = domain_brand
|
||||
return f"https://www.google.com/s2/favicons?domain={domain}&sz=64", color
|
||||
slug, color = brand
|
||||
return f"https://cdn.simpleicons.org/{slug}/{color.lstrip('#')}", color
|
||||
return f"https://cdn.simpleicons.org/{slug}/{color.lstrip('#')}", brand_color
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any] | None:
|
||||
@@ -442,6 +489,16 @@ class CliAppManager:
|
||||
"""Return registry names explicitly installed through CLI Apps."""
|
||||
return sorted(str(name) for name in self._load_installed())
|
||||
|
||||
def installed_skill_aliases(self) -> dict[str, str]:
|
||||
"""Map pre-plugin CLI App skill names to their portable identities."""
|
||||
aliases: dict[str, str] = {}
|
||||
for name in self.installed_names():
|
||||
legacy = _skill_name(name, legacy=True)
|
||||
canonical = _skill_name(name)
|
||||
if legacy != canonical:
|
||||
aliases[legacy] = canonical
|
||||
return aliases
|
||||
|
||||
def _fetch_registry(
|
||||
self,
|
||||
url: str,
|
||||
@@ -613,7 +670,7 @@ class CliAppManager:
|
||||
"name": installed_name,
|
||||
"entry_point": entry_point,
|
||||
"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",
|
||||
}
|
||||
)
|
||||
@@ -639,9 +696,6 @@ class CliAppManager:
|
||||
install_cmd = str(app.get("install_cmd") or "")
|
||||
return not _has_shell_meta(install_cmd)
|
||||
|
||||
def _skill_path(self, name: str) -> Path:
|
||||
return self.workspace / "skills" / _safe_skill_name(name) / "SKILL.md"
|
||||
|
||||
def _app_payload(
|
||||
self,
|
||||
app: dict[str, Any],
|
||||
@@ -677,7 +731,7 @@ class CliAppManager:
|
||||
"status": status,
|
||||
"logo_url": logo_url,
|
||||
"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),
|
||||
}
|
||||
|
||||
@@ -713,7 +767,8 @@ class CliAppManager:
|
||||
name = str(app["name"])
|
||||
entry_point = str(app.get("entry_point") or "")
|
||||
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 = [
|
||||
compact_dict({
|
||||
"type": "cli",
|
||||
@@ -726,13 +781,13 @@ class CliAppManager:
|
||||
install = compact_dict({
|
||||
"supported": install_supported,
|
||||
"strategy": strategy,
|
||||
"managed_paths": [skill_path],
|
||||
"managed_paths": [plugin_path],
|
||||
"verification": ["entry_point_available"] if entry_point else [],
|
||||
})
|
||||
remove = compact_dict({
|
||||
"supported": strategy != "unsupported",
|
||||
"strategy": strategy,
|
||||
"managed_paths": [skill_path],
|
||||
"managed_paths": [plugin_path],
|
||||
"verification": (
|
||||
["package_manager_ok", "entry_point_absent", "managed_paths_absent"]
|
||||
if strategy not in {"bundled", "unsupported"}
|
||||
@@ -964,6 +1019,35 @@ class CliAppManager:
|
||||
return None
|
||||
raise CliAppError("this CLI app uses an unsupported install strategy")
|
||||
|
||||
def _subprocess_env(self) -> dict[str, str]:
|
||||
"""Minimal env for CLI app subprocesses — no API keys or secrets.
|
||||
|
||||
Mirrors the shell tool's allowlist so installed apps cannot read
|
||||
provider credentials from the parent process environment.
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
sr = os.environ.get("SYSTEMROOT", r"C:\Windows")
|
||||
env = {
|
||||
"SYSTEMROOT": sr,
|
||||
"COMSPEC": os.environ.get("COMSPEC", f"{sr}\\system32\\cmd.exe"),
|
||||
"USERPROFILE": os.environ.get("USERPROFILE", ""),
|
||||
"HOMEDRIVE": os.environ.get("HOMEDRIVE", "C:"),
|
||||
"HOMEPATH": os.environ.get("HOMEPATH", "\\"),
|
||||
"TEMP": os.environ.get("TEMP", f"{sr}\\Temp"),
|
||||
"TMP": os.environ.get("TMP", f"{sr}\\Temp"),
|
||||
"PATHEXT": os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD"),
|
||||
"PATH": os.environ.get("PATH", f"{sr}\\system32;{sr}"),
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
}
|
||||
return env
|
||||
return {
|
||||
"HOME": os.environ.get("HOME", "/tmp"),
|
||||
"LANG": os.environ.get("LANG", "C.UTF-8"),
|
||||
"TERM": os.environ.get("TERM", "dumb"),
|
||||
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
}
|
||||
|
||||
def _run_argv(self, argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
|
||||
command = subprocess.list2cmdline(argv)
|
||||
logger.info("CLI Apps: running {}", command)
|
||||
@@ -1032,11 +1116,10 @@ class CliAppManager:
|
||||
name = str(app.get("name") or "unknown")
|
||||
display = str(app.get("display_name") or name)
|
||||
entry = str(app.get("entry_point") or f"cli-anything-{name}")
|
||||
description = _catalog_description(app) or f"Use {display} from nanobot."
|
||||
description = (_catalog_description(app) or f"Use {display} from nanobot.")[:1024]
|
||||
return f"""---
|
||||
name: {_safe_skill_name(name)}
|
||||
description: >-
|
||||
{description}
|
||||
name: {_skill_name(name)}
|
||||
description: {json.dumps(description, ensure_ascii=False)}
|
||||
---
|
||||
|
||||
# {display}
|
||||
@@ -1056,10 +1139,17 @@ Prefer machine-readable output when the CLI supports `--json`.
|
||||
"""
|
||||
|
||||
def _with_nanobot_skill_note(self, content: str, app: dict[str, Any]) -> str:
|
||||
name = str(app.get("name") or "unknown")
|
||||
skill_name = _skill_name(name)
|
||||
metadata = parse_skill_metadata(content)
|
||||
if metadata is None or not valid_skill_metadata(metadata | {"name": skill_name}, skill_name):
|
||||
content = self._fallback_skill(app)
|
||||
content, replaced = re.subn(r"(?m)^name\s*:.*$", f"name: {skill_name}", content, count=1)
|
||||
if not replaced:
|
||||
content = content.replace("---\n", f"---\nname: {skill_name}\n", 1)
|
||||
marker = "<!-- nanobot-cli-app-note -->"
|
||||
if marker in content:
|
||||
return content
|
||||
name = str(app.get("name") or "unknown")
|
||||
note = f"""{marker}
|
||||
## Nanobot execution
|
||||
|
||||
@@ -1073,24 +1163,42 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
|
||||
return note + "\n" + content
|
||||
|
||||
def install_skill(self, app: dict[str, Any]) -> Path:
|
||||
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)
|
||||
content = self._fetch_skill_content(app) or self._fallback_skill(app)
|
||||
content = self._with_nanobot_skill_note(content, app)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
plugin_root = path.parents[2]
|
||||
manifest = compact_dict({
|
||||
"$schema": AGENT_PLUGIN_SCHEMA,
|
||||
"name": _skill_name(str(app["name"])),
|
||||
"version": str(app.get("version") or ""),
|
||||
"description": _catalog_description(app),
|
||||
})
|
||||
_write_json(plugin_root / "plugin.json", manifest)
|
||||
legacy_dir = self.workspace / "skills" / _skill_name(str(app["name"]), legacy=True)
|
||||
if legacy_dir.is_dir():
|
||||
shutil.rmtree(legacy_dir)
|
||||
return path
|
||||
|
||||
def remove_skill(self, name: str) -> None:
|
||||
skill_dir = self._skill_path(name).parent
|
||||
if skill_dir.is_dir():
|
||||
shutil.rmtree(skill_dir)
|
||||
plugin_root = (self.workspace / _plugin_skill_relative_path(name)).parents[2]
|
||||
if plugin_root.is_dir():
|
||||
shutil.rmtree(plugin_root)
|
||||
legacy_dir = self.workspace / "skills" / _skill_name(name, legacy=True)
|
||||
if legacy_dir.is_dir():
|
||||
shutil.rmtree(legacy_dir)
|
||||
|
||||
def _record_installed(self, app: dict[str, Any]) -> dict[str, Any]:
|
||||
from nanobot.agent.plugins import set_agent_plugin_enabled
|
||||
|
||||
installed = self._load_installed()
|
||||
entry = self._installed_entry(app)
|
||||
installed[str(app["name"])] = entry
|
||||
self._save_installed(installed)
|
||||
self.install_skill(app)
|
||||
set_agent_plugin_enabled(self.workspace, _skill_name(str(app["name"])), True)
|
||||
return entry
|
||||
|
||||
def install(self, name: str) -> dict[str, Any]:
|
||||
@@ -1381,7 +1489,7 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=effective_timeout,
|
||||
env=os.environ.copy(),
|
||||
env=self._subprocess_env(),
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return f"CLI app '{name}' timed out after {effective_timeout}s"
|
||||
|
||||
@@ -20,6 +20,8 @@ def runtime_lines_for_request(
|
||||
"""Return CLI App annotations from an immutable request snapshot."""
|
||||
structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
|
||||
if isinstance(structured, list):
|
||||
from nanobot.apps.cli.service import cli_app_skill_relative_path
|
||||
|
||||
structured_items = cast(list[Any], structured)
|
||||
mentions = [
|
||||
cast(Mapping[str, Any], item) for item in structured_items
|
||||
@@ -32,7 +34,7 @@ def runtime_lines_for_request(
|
||||
f"@{str(item['name']).strip().lower()} "
|
||||
f"(installed; tool=run_cli_app; "
|
||||
f"entry_point={str(item.get('entry_point') or 'unknown')}; "
|
||||
f"skill=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."
|
||||
for item in mentions
|
||||
if str(item.get("name") or "").strip()
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Small, fail-safe registry for the Apps page Featured section."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
REGISTRY_URL = "https://nanobot.wiki/registry/v1/discovery.json"
|
||||
CACHE_TTL_S = 60 * 60
|
||||
_MAX_RESPONSE_BYTES = 64 * 1024
|
||||
_APP_ID_RE = re.compile(r"^(?:cli|mcp):[a-z0-9][a-z0-9._-]*$")
|
||||
_FALLBACK = {
|
||||
"schema_version": 1,
|
||||
"updated_at": "2026-08-12T00:00:00Z",
|
||||
"featured": [
|
||||
"mcp:github",
|
||||
"mcp:playwright",
|
||||
"mcp:notion",
|
||||
"mcp:figma",
|
||||
"mcp:context7",
|
||||
"cli:obsidian",
|
||||
"mcp:linear",
|
||||
"cli:browser",
|
||||
"cli:1password-cli",
|
||||
"cli:blender",
|
||||
"cli:libreoffice",
|
||||
"cli:zotero",
|
||||
],
|
||||
}
|
||||
_refresh_tasks: dict[Path, asyncio.Task[None]] = {}
|
||||
|
||||
|
||||
def _validated_payload(value: Any) -> dict[str, Any] | None:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
payload = cast(dict[str, object], value)
|
||||
if payload.get("schema_version") != 1:
|
||||
return None
|
||||
updated_at = payload.get("updated_at")
|
||||
raw_featured = payload.get("featured")
|
||||
if not isinstance(updated_at, str) or not updated_at.strip():
|
||||
return None
|
||||
if not isinstance(raw_featured, list):
|
||||
return None
|
||||
featured_values = cast(list[object], raw_featured)
|
||||
if not 1 <= len(featured_values) <= 12:
|
||||
return None
|
||||
featured: list[str] = []
|
||||
for item in featured_values:
|
||||
if not isinstance(item, str) or _APP_ID_RE.fullmatch(item) is None:
|
||||
return None
|
||||
featured.append(item)
|
||||
if len(featured) != len(set(featured)):
|
||||
return None
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"updated_at": updated_at,
|
||||
"featured": featured,
|
||||
}
|
||||
|
||||
|
||||
def _read_cache(path: Path) -> dict[str, Any] | None:
|
||||
try:
|
||||
return _validated_payload(json.loads(path.read_text(encoding="utf-8")))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_remote() -> dict[str, Any]:
|
||||
request = urllib.request.Request(
|
||||
REGISTRY_URL,
|
||||
headers={"Accept": "application/json", "User-Agent": "nanobot-apps/1"},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=3) as response:
|
||||
raw = response.read(_MAX_RESPONSE_BYTES + 1)
|
||||
if len(raw) > _MAX_RESPONSE_BYTES:
|
||||
raise ValueError("Apps discovery response is too large")
|
||||
payload = _validated_payload(json.loads(raw))
|
||||
if payload is None:
|
||||
raise ValueError("Invalid Apps discovery response")
|
||||
return payload
|
||||
|
||||
|
||||
def _write_cache(path: Path, payload: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
temporary.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
async def _refresh(path: Path) -> None:
|
||||
try:
|
||||
payload = await asyncio.to_thread(_fetch_remote)
|
||||
await asyncio.to_thread(_write_cache, path, payload)
|
||||
except Exception:
|
||||
# Discovery is optional: the bundled list remains usable offline.
|
||||
pass
|
||||
|
||||
|
||||
def _schedule_refresh(path: Path) -> None:
|
||||
task = _refresh_tasks.get(path)
|
||||
if task is not None and not task.done():
|
||||
return
|
||||
task = asyncio.create_task(_refresh(path))
|
||||
_refresh_tasks[path] = task
|
||||
task.add_done_callback(lambda completed: _refresh_tasks.pop(path, None))
|
||||
|
||||
|
||||
async def discovery_payload(*, data_dir: Path) -> dict[str, Any]:
|
||||
"""Return cached Featured IDs immediately and refresh stale data in the background."""
|
||||
cache_path = data_dir / "apps-discovery.json"
|
||||
cached = _read_cache(cache_path)
|
||||
try:
|
||||
fresh = cached is not None and time.time() - cache_path.stat().st_mtime < CACHE_TTL_S
|
||||
except OSError:
|
||||
fresh = False
|
||||
if fresh and cached is not None:
|
||||
return cached
|
||||
_schedule_refresh(cache_path)
|
||||
return {**(cached or _FALLBACK), "refresh_pending": True}
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import hashlib
|
||||
import inspect
|
||||
from collections.abc import Callable, Iterable
|
||||
from collections.abc import Callable, Iterable, Mapping
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
@@ -100,6 +100,7 @@ class ChannelManager:
|
||||
webui_static_dist: bool = True,
|
||||
webui_runtime_surface: str = "browser",
|
||||
webui_runtime_capabilities: dict[str, Any] | None = None,
|
||||
webui_mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
|
||||
webui_skill_state_action: Callable[[set[str]], None] | None = None,
|
||||
config_path: Path | None = None,
|
||||
):
|
||||
@@ -119,6 +120,7 @@ class ChannelManager:
|
||||
self._webui_static_dist = webui_static_dist
|
||||
self._webui_runtime_surface = webui_runtime_surface
|
||||
self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {})
|
||||
self._webui_mcp_runtime_status = webui_mcp_runtime_status
|
||||
self._webui_skill_state_action = webui_skill_state_action
|
||||
self.channels: dict[str, BaseChannel] = {}
|
||||
self._channel_owners: dict[str, str] = {}
|
||||
@@ -187,6 +189,7 @@ class ChannelManager:
|
||||
local_trigger_pending_ids=self._webui_local_trigger_pending_ids,
|
||||
channel_feature_action=self.apply_channel_feature_action,
|
||||
channel_runtime_status=self.get_status,
|
||||
mcp_runtime_status=self._webui_mcp_runtime_status,
|
||||
skill_state_action=self._webui_skill_state_action,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
@@ -968,6 +968,11 @@ class MatrixChannel(BaseChannel):
|
||||
meta["thread_reply_to_event_id"] = reply_to
|
||||
return meta
|
||||
|
||||
def _thread_session_key(self, room_id: str, event: RoomMessage) -> str | None:
|
||||
if not (root_id := self._event_thread_root_id(event)):
|
||||
return None
|
||||
return f"{self.name}:{room_id}:thread:{root_id}"
|
||||
|
||||
@staticmethod
|
||||
def _build_thread_relates_to(metadata: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if not metadata:
|
||||
@@ -1171,6 +1176,7 @@ class MatrixChannel(BaseChannel):
|
||||
await self._handle_message(
|
||||
sender_id=event.sender, chat_id=room.room_id,
|
||||
content=event.body, metadata=self._base_metadata(room, event),
|
||||
session_key=self._thread_session_key(room.room_id, event),
|
||||
is_dm=self._is_direct_room(room),
|
||||
)
|
||||
except Exception:
|
||||
@@ -1209,6 +1215,7 @@ class MatrixChannel(BaseChannel):
|
||||
content="\n".join(parts),
|
||||
media=[attachment["path"]] if attachment else [],
|
||||
metadata=meta,
|
||||
session_key=self._thread_session_key(room.room_id, event),
|
||||
is_dm=self._is_direct_room(room),
|
||||
)
|
||||
except Exception:
|
||||
|
||||
@@ -971,6 +971,81 @@ async def test_on_message_sets_thread_metadata_when_threaded_event() -> None:
|
||||
assert metadata["thread_root_event_id"] == "$root1"
|
||||
assert metadata["thread_reply_to_event_id"] == "$reply1"
|
||||
assert metadata["event_id"] == "$reply1"
|
||||
assert handled[0]["session_key"] == "matrix:!room:matrix.org:thread:$root1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_keeps_matrix_thread_sessions_independent() -> None:
|
||||
channel = MatrixChannel(_make_config(), MessageBus())
|
||||
client = _FakeAsyncClient("", "", "", None)
|
||||
channel.client = client
|
||||
|
||||
handled: list[dict[str, object]] = []
|
||||
|
||||
async def _fake_handle_message(**kwargs) -> None:
|
||||
handled.append(kwargs)
|
||||
|
||||
channel._handle_message = _fake_handle_message # type: ignore[method-assign]
|
||||
|
||||
room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=3)
|
||||
|
||||
def _thread_event(body: str, event_id: str, root_id: str) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
sender="@alice:matrix.org",
|
||||
body=body,
|
||||
event_id=event_id,
|
||||
source={
|
||||
"content": {
|
||||
"m.relates_to": {
|
||||
"rel_type": "m.thread",
|
||||
"event_id": root_id,
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
await channel._on_message(room, _thread_event("Plan the wedding", "$reply1", "$root1"))
|
||||
await channel._on_message(room, _thread_event("Pick a gift", "$reply2", "$root1"))
|
||||
await channel._on_message(room, _thread_event("/new", "$reply3", "$root2"))
|
||||
|
||||
assert [message["chat_id"] for message in handled] == [
|
||||
"!room:matrix.org",
|
||||
"!room:matrix.org",
|
||||
"!room:matrix.org",
|
||||
]
|
||||
assert [message["session_key"] for message in handled] == [
|
||||
"matrix:!room:matrix.org:thread:$root1",
|
||||
"matrix:!room:matrix.org:thread:$root1",
|
||||
"matrix:!room:matrix.org:thread:$root2",
|
||||
]
|
||||
assert handled[2]["content"] == "/new"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_keeps_non_threaded_room_session() -> None:
|
||||
channel = MatrixChannel(_make_config(), MessageBus())
|
||||
client = _FakeAsyncClient("", "", "", None)
|
||||
channel.client = client
|
||||
|
||||
handled: list[dict[str, object]] = []
|
||||
|
||||
async def _fake_handle_message(**kwargs) -> None:
|
||||
handled.append(kwargs)
|
||||
|
||||
channel._handle_message = _fake_handle_message # type: ignore[method-assign]
|
||||
|
||||
room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=3)
|
||||
event = SimpleNamespace(
|
||||
sender="@alice:matrix.org",
|
||||
body="Hello",
|
||||
event_id="$event1",
|
||||
source={"content": {}},
|
||||
)
|
||||
|
||||
await channel._on_message(room, event)
|
||||
|
||||
assert len(handled) == 1
|
||||
assert handled[0]["session_key"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1076,6 +1151,7 @@ async def test_on_media_message_sets_thread_metadata_when_threaded_event(
|
||||
assert metadata["thread_root_event_id"] == "$root1"
|
||||
assert metadata["thread_reply_to_event_id"] == "$event1"
|
||||
assert metadata["event_id"] == "$event1"
|
||||
assert handled[0]["session_key"] == "matrix:!room:matrix.org:thread:$root1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -129,6 +129,14 @@ def _basic_handler(bus: Any, **kw: Any) -> GatewayServices:
|
||||
)
|
||||
|
||||
|
||||
async def _connect_when_ready(url: str) -> Any:
|
||||
while True:
|
||||
try:
|
||||
return await websockets.connect(url)
|
||||
except OSError:
|
||||
await asyncio.sleep(0.02)
|
||||
|
||||
|
||||
async def _webui_mutate(
|
||||
client: Any,
|
||||
action: str,
|
||||
@@ -2865,10 +2873,13 @@ async def test_end_to_end_client_receives_ready_and_agent_sees_inbound(bus: Magi
|
||||
channel = _ch(bus, port=port)
|
||||
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
try:
|
||||
async with websockets.connect(f"ws://127.0.0.1:{port}/ws?client_id=tester") as client:
|
||||
client = await asyncio.wait_for(
|
||||
_connect_when_ready(f"ws://127.0.0.1:{port}/ws?client_id=tester"),
|
||||
timeout=5,
|
||||
)
|
||||
async with client:
|
||||
ready_raw = await client.recv()
|
||||
ready = json.loads(ready_raw)
|
||||
assert ready["event"] == "ready"
|
||||
|
||||
@@ -2090,6 +2090,14 @@ async def test_mcp_presets_routes_require_token_and_return_payload(
|
||||
assert body["hot_reload"]["ok"] is True
|
||||
assert body["restart_required_sections"] == []
|
||||
|
||||
disabled = await _webui_mutate(
|
||||
channel,
|
||||
"settings.mcp.disable",
|
||||
{"name": "browserbase"},
|
||||
)
|
||||
assert disabled.status_code == 200
|
||||
assert preset_queries[-1][0] == "disable"
|
||||
|
||||
custom = await _webui_mutate(
|
||||
channel,
|
||||
"settings.mcp.custom",
|
||||
|
||||
@@ -668,6 +668,7 @@ def _run_gateway(
|
||||
webui_static_dist=webui_static_dist,
|
||||
webui_runtime_surface=webui_runtime_surface,
|
||||
webui_runtime_capabilities=webui_runtime_capabilities,
|
||||
webui_mcp_runtime_status=agent.mcp_runtime_status,
|
||||
webui_skill_state_action=_webui_skill_state_action,
|
||||
config_path=Path(config_path),
|
||||
)
|
||||
|
||||
@@ -447,6 +447,28 @@ def _merge_unique_list(base: object, override: object) -> object:
|
||||
return result
|
||||
|
||||
|
||||
def _merge_chat_extra_body(
|
||||
kwargs: dict[str, Any],
|
||||
extra_body: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Merge configured Chat Completions fields without clobbering tools."""
|
||||
regular_extra = {key: value for key, value in extra_body.items() if key != "tools"}
|
||||
merged = dict(kwargs)
|
||||
if regular_extra:
|
||||
existing = kwargs.get("extra_body", {})
|
||||
merged["extra_body"] = _deep_merge(existing, regular_extra)
|
||||
|
||||
if "tools" in extra_body:
|
||||
current_tools = kwargs.get("tools")
|
||||
configured_tools = extra_body["tools"]
|
||||
if isinstance(current_tools, list) and isinstance(configured_tools, list):
|
||||
merged["tools"] = [*current_tools, *configured_tools]
|
||||
else:
|
||||
merged["tools"] = configured_tools
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
def _merge_responses_extra_body(
|
||||
body: dict[str, Any],
|
||||
extra_body: dict[str, Any],
|
||||
@@ -501,9 +523,6 @@ class OpenAICompatProvider(LLMProvider):
|
||||
self._proxy = proxy or None
|
||||
self._native_compaction_available = True
|
||||
|
||||
if api_key and spec and spec.env_key:
|
||||
self._setup_env(api_key, api_base)
|
||||
|
||||
effective_base = api_base or (spec.default_api_base if spec else None) or None
|
||||
self._effective_base = effective_base
|
||||
self._default_headers = {"x-session-affinity": uuid.uuid4().hex}
|
||||
@@ -596,20 +615,6 @@ class OpenAICompatProvider(LLMProvider):
|
||||
raise RuntimeError("OpenAI client initialization did not produce a client")
|
||||
return self._client
|
||||
|
||||
def _setup_env(self, api_key: str, api_base: str | None) -> None:
|
||||
"""Set environment variables based on provider spec."""
|
||||
spec = self._spec
|
||||
if not spec or not spec.env_key:
|
||||
return
|
||||
if spec.is_gateway:
|
||||
os.environ[spec.env_key] = api_key
|
||||
else:
|
||||
os.environ.setdefault(spec.env_key, api_key)
|
||||
effective_base = api_base or spec.default_api_base
|
||||
for env_name, env_val in spec.env_extras:
|
||||
resolved = env_val.replace("{api_key}", api_key).replace("{api_base}", effective_base)
|
||||
os.environ.setdefault(env_name, resolved)
|
||||
|
||||
@classmethod
|
||||
def _apply_cache_control(
|
||||
cls,
|
||||
@@ -968,14 +973,11 @@ class OpenAICompatProvider(LLMProvider):
|
||||
if msg.get("role") == "assistant" and "reasoning_content" not in msg:
|
||||
msg["reasoning_content"] = ""
|
||||
|
||||
# Merge user-configured extra_body last so it can override or
|
||||
# extend provider-specific defaults (e.g. chat_template_kwargs,
|
||||
# guided_json, repetition_penalty). Uses recursive merge so
|
||||
# nested dicts like {"chat_template_kwargs": {"enable_thinking": false}}
|
||||
# do not clobber sibling keys already set by thinking-style logic.
|
||||
# Merge user-configured extra_body last so ordinary fields can override
|
||||
# provider defaults. Keep configured tools at the top level: the SDK
|
||||
# otherwise lets extra_body.tools replace nanobot's generated functions.
|
||||
if self._extra_body:
|
||||
existing = kwargs.get("extra_body", {})
|
||||
kwargs["extra_body"] = _deep_merge(existing, self._extra_body)
|
||||
kwargs = _merge_chat_extra_body(kwargs, self._extra_body)
|
||||
|
||||
return kwargs
|
||||
|
||||
|
||||
@@ -11,23 +11,39 @@ Two free services, no API keys needed.
|
||||
|
||||
## wttr.in (primary)
|
||||
|
||||
Quick one-liner:
|
||||
Choose one request that matches the user's scope. Do not fetch current
|
||||
conditions separately when a today or forecast request already includes them.
|
||||
|
||||
Platform notes:
|
||||
- On Windows PowerShell, use `curl.exe`; bare `curl` may resolve to
|
||||
`Invoke-WebRequest`.
|
||||
- On macOS and Linux, use `curl`.
|
||||
|
||||
Current conditions only:
|
||||
```bash
|
||||
curl -s "wttr.in/London?format=3"
|
||||
curl -s "https://wttr.in/London?format=3"
|
||||
# Output: London: ⛅️ +8°C
|
||||
```
|
||||
|
||||
Compact format:
|
||||
Custom current conditions format:
|
||||
```bash
|
||||
curl -s "wttr.in/London?format=%l:+%c+%t+%h+%w"
|
||||
curl -s "https://wttr.in/London?format=%l:+%c+%t+%h+%w"
|
||||
# Output: London: ⛅️ +8°C 71% ↙5km/h
|
||||
```
|
||||
|
||||
Today's weather, including current conditions (use this single request for
|
||||
questions about today's weather):
|
||||
```bash
|
||||
curl -s "https://wttr.in/London?1&m"
|
||||
```
|
||||
|
||||
Full forecast:
|
||||
```bash
|
||||
curl -s "wttr.in/London?T"
|
||||
curl -s "https://wttr.in/London?T&m"
|
||||
```
|
||||
|
||||
On Windows PowerShell, replace `curl` with `curl.exe` in the commands above.
|
||||
|
||||
Format codes: `%c` condition · `%t` temp · `%h` humidity · `%w` wind · `%l` location · `%m` moon
|
||||
|
||||
Tips:
|
||||
@@ -35,7 +51,8 @@ Tips:
|
||||
- Airport codes: `wttr.in/JFK`
|
||||
- Units: `?m` (metric) `?u` (USCS)
|
||||
- Today only: `?1` · Current only: `?0`
|
||||
- PNG: `curl -s "wttr.in/Berlin.png" -o /tmp/weather.png`
|
||||
- PNG (macOS/Linux): `curl -s "https://wttr.in/Berlin.png" -o weather.png`
|
||||
- PNG (Windows PowerShell): `curl.exe -s "https://wttr.in/Berlin.png" -o weather.png`
|
||||
|
||||
## Open-Meteo (fallback, JSON)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
@@ -64,6 +65,7 @@ def build_gateway_services(
|
||||
local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
|
||||
channel_feature_action: Callable[..., Any] | None = None,
|
||||
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
|
||||
mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
|
||||
skill_state_action: Callable[[set[str]], None] | None = None,
|
||||
logger: Any = default_logger,
|
||||
) -> GatewayServices:
|
||||
@@ -116,6 +118,7 @@ def build_gateway_services(
|
||||
local_trigger_pending_ids=local_trigger_pending_ids,
|
||||
channel_feature_action=channel_feature_action,
|
||||
channel_runtime_status=channel_runtime_status,
|
||||
mcp_runtime_status=mcp_runtime_status,
|
||||
skill_state_action=skill_state_action,
|
||||
log=logger,
|
||||
)
|
||||
|
||||
@@ -16,6 +16,11 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Literal, Mapping, cast
|
||||
|
||||
from nanobot.agent.plugins import (
|
||||
AgentPlugin,
|
||||
discover_agent_plugins,
|
||||
set_agent_plugin_enabled,
|
||||
)
|
||||
from nanobot.agent.tools.mcp_oauth import (
|
||||
delete_mcp_oauth_credentials,
|
||||
mcp_oauth_has_credentials,
|
||||
@@ -55,8 +60,10 @@ _MAX_TEST_TOOLS = 16
|
||||
_DEFAULT_TEST_TIMEOUT = 20
|
||||
_DEFAULT_CUSTOM_TIMEOUT = 30
|
||||
_CUSTOM_ACTIONS = {"custom", "import", "import-cursor", "tools"}
|
||||
_MCP_RUNTIME_STATUSES = {"connecting", "connected", "failed"}
|
||||
|
||||
McpReload = Callable[[], Awaitable[dict[str, Any]]]
|
||||
McpRuntimeStatus = Callable[[], Mapping[str, str]]
|
||||
|
||||
|
||||
class McpPresetError(Exception):
|
||||
@@ -911,10 +918,35 @@ def _custom_payload(
|
||||
}
|
||||
|
||||
|
||||
def _agent_plugin_payload(plugin: AgentPlugin) -> dict[str, Any]:
|
||||
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": plugin.enabled,
|
||||
"available": plugin.enabled,
|
||||
"status": "enabled" if plugin.enabled else "disabled",
|
||||
"logo_url": plugin.logo,
|
||||
"brand_color": plugin.accent_color,
|
||||
"required_fields": [],
|
||||
"connection_summary": ", ".join(plugin.mcp_servers),
|
||||
"source": "agent-plugin",
|
||||
}
|
||||
|
||||
|
||||
def mcp_presets_payload(
|
||||
*,
|
||||
last_action: dict[str, Any] | None = None,
|
||||
tool_preview: Mapping[str, list[str]] | None = None,
|
||||
runtime_status: Mapping[str, str] | None = None,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
config = load_config(config_path) if config_path is not None else load_config()
|
||||
@@ -929,13 +961,51 @@ def mcp_presets_payload(
|
||||
for name, cfg in sorted(config.tools.mcp_servers.items())
|
||||
if name not in known
|
||||
]
|
||||
existing_names = {str(row["name"]) for row in (*preset_rows, *custom_rows)}
|
||||
plugin_rows = [
|
||||
_agent_plugin_payload(plugin)
|
||||
for plugin in discover_agent_plugins(config.workspace_path)
|
||||
if f"plugin-{plugin.name}" not in existing_names
|
||||
]
|
||||
payload: dict[str, Any] = {
|
||||
"presets": [*preset_rows, *custom_rows],
|
||||
"installed_count": len(config.tools.mcp_servers),
|
||||
"presets": [*preset_rows, *custom_rows, *plugin_rows],
|
||||
"installed_count": len(config.tools.mcp_servers)
|
||||
+ sum(int(row["enabled"]) for row in plugin_rows),
|
||||
}
|
||||
if last_action is not None:
|
||||
payload["last_action"] = last_action
|
||||
return payload
|
||||
return attach_mcp_runtime_status(payload, runtime_status)
|
||||
|
||||
|
||||
def attach_mcp_runtime_status(
|
||||
payload: dict[str, Any],
|
||||
runtime_status: Mapping[str, str] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Project safe, connection-attempt state onto configured MCP rows."""
|
||||
if runtime_status is None:
|
||||
return payload
|
||||
projected = dict(payload)
|
||||
raw_rows: object = payload.get("presets", [])
|
||||
preset_rows = cast(list[object], raw_rows) if isinstance(raw_rows, list) else []
|
||||
rows: list[Any] = []
|
||||
for raw_row in preset_rows:
|
||||
if not isinstance(raw_row, dict):
|
||||
rows.append(raw_row)
|
||||
continue
|
||||
row = dict(cast(dict[str, Any], raw_row))
|
||||
name = row.get("name")
|
||||
status = runtime_status.get(name) if isinstance(name, str) else None
|
||||
if (
|
||||
status in _MCP_RUNTIME_STATUSES
|
||||
and row.get("installed") is True
|
||||
and row.get("configured") is True
|
||||
):
|
||||
row["runtime_status"] = status
|
||||
else:
|
||||
row.pop("runtime_status", None)
|
||||
rows.append(row)
|
||||
projected["presets"] = rows
|
||||
return projected
|
||||
|
||||
|
||||
def _display_name_for(name: str, preset: McpPreset | None = None) -> str:
|
||||
@@ -968,6 +1038,7 @@ def _server_action_message(action: str, name: str, *, ok: bool = True) -> dict[s
|
||||
"import-cursor": "Imported",
|
||||
"tools": "Updated tools for",
|
||||
"remove": "Removed",
|
||||
"reconnect": "Retried connection for",
|
||||
}.get(action, "Updated")
|
||||
payload: dict[str, Any] = {
|
||||
"ok": ok,
|
||||
@@ -982,6 +1053,24 @@ def _server_action_message(action: str, name: str, *, ok: bool = True) -> dict[s
|
||||
return payload
|
||||
|
||||
|
||||
def mcp_reconnect_action(
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate a configured server before asking the live runtime to retry it."""
|
||||
name = _validated_server_name((_query_first(query, "name") or "").strip())
|
||||
config = load_config(config_path) if config_path is not None else load_config()
|
||||
if name not in config.tools.mcp_servers:
|
||||
raise McpPresetError("unknown MCP server", status=404)
|
||||
payload = mcp_presets_payload(
|
||||
last_action=_server_action_message("reconnect", name),
|
||||
config_path=config_path,
|
||||
)
|
||||
payload["requires_restart"] = True
|
||||
return payload
|
||||
|
||||
|
||||
def _scrub_test_error(text: str) -> str:
|
||||
scrubbed = _SECRET_QUERY_RE.sub(r"\1<redacted>", text.strip())
|
||||
scrubbed = _SECRET_ASSIGNMENT_RE.sub(r"\1<redacted>", scrubbed)
|
||||
@@ -1536,15 +1625,52 @@ async def mcp_presets_settings_action(
|
||||
query: QueryParams,
|
||||
*,
|
||||
reload_mcp: McpReload | None = None,
|
||||
mcp_runtime_status: McpRuntimeStatus | None = None,
|
||||
config: WebUISettingsConfig | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Run a WebUI MCP preset action and hot-reload the agent when config changes."""
|
||||
config_path = config.path if config is not None else None
|
||||
if action is None:
|
||||
return mcp_presets_payload(config_path=config_path)
|
||||
return mcp_presets_payload(
|
||||
runtime_status=mcp_runtime_status() if mcp_runtime_status is not None else None,
|
||||
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-")
|
||||
plugins = discover_agent_plugins(plugin_config.workspace_path)
|
||||
plugin = next((item for item in plugins if item.name == plugin_name), None)
|
||||
if name not in plugin_config.tools.mcp_servers and plugin is not None:
|
||||
if action not in {"enable", "disable"}:
|
||||
raise McpPresetError("Agent Plugins support enable and disable actions only")
|
||||
await asyncio.to_thread(
|
||||
set_agent_plugin_enabled,
|
||||
plugin_config.workspace_path,
|
||||
plugin_name,
|
||||
action == "enable",
|
||||
)
|
||||
verb = "enabled" if action == "enable" else "disabled"
|
||||
payload = mcp_presets_payload(
|
||||
last_action={"ok": True, "message": f"{plugin.display_name} {verb}."},
|
||||
config_path=config_path,
|
||||
)
|
||||
if reload_mcp is not None:
|
||||
payload = attach_mcp_hot_reload_result(payload, await reload_mcp())
|
||||
return payload
|
||||
if action == "test":
|
||||
return await mcp_presets_test_action(query, config_path=config_path)
|
||||
if config is not None:
|
||||
payload = await mcp_presets_test_action(query, config_path=config_path)
|
||||
return attach_mcp_runtime_status(
|
||||
payload,
|
||||
mcp_runtime_status() if mcp_runtime_status is not None else None,
|
||||
)
|
||||
if action == "reconnect":
|
||||
payload = await asyncio.to_thread(
|
||||
mcp_reconnect_action,
|
||||
query,
|
||||
config_path=config_path,
|
||||
)
|
||||
elif config is not None:
|
||||
operation = custom_mcp_action if action in _CUSTOM_ACTIONS else mcp_presets_action
|
||||
payload = await asyncio.to_thread(
|
||||
config.run_serialized,
|
||||
@@ -1556,4 +1682,7 @@ async def mcp_presets_settings_action(
|
||||
payload = await asyncio.to_thread(mcp_presets_action, action, query)
|
||||
if reload_mcp is not None:
|
||||
payload = attach_mcp_hot_reload_result(payload, await reload_mcp())
|
||||
return payload
|
||||
return attach_mcp_runtime_status(
|
||||
payload,
|
||||
mcp_runtime_status() if mcp_runtime_status is not None else None,
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import html
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any, cast
|
||||
|
||||
from websockets.http11 import Request as WsRequest
|
||||
@@ -15,6 +15,7 @@ from nanobot.agent.tools.image_generation import request_image_generation_reload
|
||||
from nanobot.agent.tools.mcp import request_mcp_reload
|
||||
from nanobot.agent.tools.mcp_oauth import MCP_OAUTH_CALLBACK_PATH
|
||||
from nanobot.api.runtime import ApiRuntime, api_runtime_paths
|
||||
from nanobot.apps.discovery import discovery_payload
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.registry import load_channel_plugin
|
||||
from nanobot.channels.validation import validate_channel_config
|
||||
@@ -91,8 +92,10 @@ def _channel_connect_route(path: str) -> tuple[str, str] | None:
|
||||
|
||||
_MCP_PRESET_ACTIONS_BY_PATH = {
|
||||
"/api/settings/mcp-presets/enable": "enable",
|
||||
"/api/settings/mcp-presets/disable": "disable",
|
||||
"/api/settings/mcp-presets/remove": "remove",
|
||||
"/api/settings/mcp-presets/test": "test",
|
||||
"/api/settings/mcp-presets/reconnect": "reconnect",
|
||||
"/api/settings/mcp-presets/custom": "custom",
|
||||
"/api/settings/mcp-presets/import": "import",
|
||||
"/api/settings/mcp-presets/import-cursor": "import-cursor",
|
||||
@@ -125,6 +128,7 @@ _CAPABILITY_ROUTES = {
|
||||
}
|
||||
|
||||
_SYSTEM_ROUTES = {
|
||||
"/api/settings/apps-discovery": "apps-discovery",
|
||||
"/api/settings/cli-apps": "cli-list",
|
||||
"/api/settings/cli-apps/install": "cli-install",
|
||||
"/api/settings/cli-apps/update": "cli-update",
|
||||
@@ -224,6 +228,7 @@ class WebUISettingsRouter:
|
||||
runtime_capabilities: dict[str, Any],
|
||||
channel_feature_action: Callable[..., Any] | None = None,
|
||||
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
|
||||
mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
|
||||
mcp_oauth_redirect_uri: Callable[[WsRequest], str] | None = None,
|
||||
) -> None:
|
||||
self.settings = settings
|
||||
@@ -237,6 +242,7 @@ class WebUISettingsRouter:
|
||||
self._runtime_capabilities = runtime_capabilities
|
||||
self._channel_feature_action = channel_feature_action
|
||||
self._channel_runtime_status = channel_runtime_status
|
||||
self._mcp_runtime_status = mcp_runtime_status
|
||||
self._mcp_oauth_redirect_uri = mcp_oauth_redirect_uri
|
||||
self._mcp_oauth = McpOAuthManager()
|
||||
self._restart_sections: set[str] = set()
|
||||
@@ -457,6 +463,7 @@ class WebUISettingsRouter:
|
||||
|
||||
def _system_operations(self) -> system_domain.SystemSettingsOperations:
|
||||
return system_domain.SystemSettingsOperations(
|
||||
apps_discovery_payload=discovery_payload,
|
||||
cli_apps_payload=cli_apps_payload,
|
||||
cli_apps_action=cli_apps_action,
|
||||
nanobot_features_payload=nanobot_features_payload,
|
||||
@@ -469,6 +476,7 @@ class WebUISettingsRouter:
|
||||
deny_code=deny_code,
|
||||
mcp_presets_action=mcp_presets_settings_action,
|
||||
reload_mcp=lambda: request_mcp_reload(self.bus),
|
||||
mcp_runtime_status=self._mcp_runtime_status,
|
||||
check_for_update=check_for_update,
|
||||
channel_feature_action=self._channel_feature_action,
|
||||
channel_runtime_status=self._channel_runtime_status,
|
||||
|
||||
@@ -6,7 +6,7 @@ import asyncio
|
||||
import inspect
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Callable, Iterable
|
||||
from collections.abc import Callable, Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, TypedDict, cast
|
||||
@@ -43,6 +43,7 @@ SettingsOperation = Callable[..., Any]
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SystemSettingsOperations:
|
||||
apps_discovery_payload: SettingsOperation
|
||||
cli_apps_payload: SettingsOperation
|
||||
cli_apps_action: SettingsOperation
|
||||
nanobot_features_payload: SettingsOperation
|
||||
@@ -55,6 +56,7 @@ class SystemSettingsOperations:
|
||||
deny_code: SettingsOperation
|
||||
mcp_presets_action: SettingsOperation
|
||||
reload_mcp: SettingsOperation
|
||||
mcp_runtime_status: Callable[[], Mapping[str, str]] | None
|
||||
check_for_update: SettingsOperation
|
||||
channel_feature_action: SettingsOperation | None = None
|
||||
channel_runtime_status: Callable[[], dict[str, Any]] | None = None
|
||||
@@ -371,6 +373,8 @@ class SystemSettingsHandler:
|
||||
channel_name: str | None = None,
|
||||
connect_action: str | None = None,
|
||||
) -> SettingsRouteResult:
|
||||
if action == "apps-discovery":
|
||||
return await self._apps_discovery(operations)
|
||||
if action == "cli-list":
|
||||
return await self._cli_apps(request, operations)
|
||||
if action.startswith("cli-"):
|
||||
@@ -418,6 +422,15 @@ class SystemSettingsHandler:
|
||||
return await self._version_check(operations)
|
||||
return SettingsRouteResult.failure(404, "unknown settings action")
|
||||
|
||||
async def _apps_discovery(
|
||||
self,
|
||||
operations: SystemSettingsOperations,
|
||||
) -> SettingsRouteResult:
|
||||
payload = await operations.apps_discovery_payload(
|
||||
data_dir=self.settings.config.path.parent / "catalog",
|
||||
)
|
||||
return SettingsRouteResult.success(payload)
|
||||
|
||||
async def _cli_apps(
|
||||
self,
|
||||
request: SettingsRequest,
|
||||
@@ -928,6 +941,7 @@ class SystemSettingsHandler:
|
||||
action,
|
||||
request.query,
|
||||
reload_mcp=operations.reload_mcp,
|
||||
mcp_runtime_status=operations.mcp_runtime_status,
|
||||
config=self.settings.config,
|
||||
)
|
||||
except Exception as exc:
|
||||
|
||||
@@ -14,7 +14,7 @@ import json
|
||||
import mimetypes
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Mapping
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from urllib.parse import quote, unquote, urlsplit, urlunsplit
|
||||
@@ -160,8 +160,10 @@ _WEBUI_MUTATION_PATHS = {
|
||||
"settings.pairing.approve": "/api/settings/pairing/approve",
|
||||
"settings.pairing.deny": "/api/settings/pairing/deny",
|
||||
"settings.mcp.enable": "/api/settings/mcp-presets/enable",
|
||||
"settings.mcp.disable": "/api/settings/mcp-presets/disable",
|
||||
"settings.mcp.remove": "/api/settings/mcp-presets/remove",
|
||||
"settings.mcp.test": "/api/settings/mcp-presets/test",
|
||||
"settings.mcp.reconnect": "/api/settings/mcp-presets/reconnect",
|
||||
"settings.mcp.custom": "/api/settings/mcp-presets/custom",
|
||||
"settings.mcp.import": "/api/settings/mcp-presets/import",
|
||||
"settings.mcp.import_cursor": "/api/settings/mcp-presets/import-cursor",
|
||||
@@ -305,6 +307,7 @@ class GatewayHTTPHandler:
|
||||
local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
|
||||
channel_feature_action: Callable[..., Any] | None = None,
|
||||
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
|
||||
mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
|
||||
skill_state_action: Callable[[set[str]], None] | None = None,
|
||||
log: Any = logger,
|
||||
) -> None:
|
||||
@@ -347,6 +350,7 @@ class GatewayHTTPHandler:
|
||||
runtime_capabilities=self._capabilities,
|
||||
channel_feature_action=channel_feature_action,
|
||||
channel_runtime_status=channel_runtime_status,
|
||||
mcp_runtime_status=mcp_runtime_status,
|
||||
mcp_oauth_redirect_uri=self._mcp_oauth_redirect_uri,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
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_plugins,
|
||||
enabled_agent_plugin_skill_dirs,
|
||||
enabled_agent_plugin_skills,
|
||||
set_agent_plugin_enabled,
|
||||
)
|
||||
from nanobot.agent.skills import SkillsLoader
|
||||
from nanobot.agent.tools.context import ToolContext
|
||||
from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool
|
||||
from nanobot.config.schema import ToolsConfig
|
||||
from nanobot.security.workspace_access import (
|
||||
bind_workspace_scope,
|
||||
reset_workspace_scope,
|
||||
validate_workspace_scope_payload,
|
||||
)
|
||||
|
||||
|
||||
@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_json(path: Path, value: object) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(value), encoding="utf-8")
|
||||
|
||||
|
||||
def _manifest(name: str, **fields: object) -> dict[str, object]:
|
||||
return {"$schema": AGENT_PLUGIN_SCHEMA, "name": name, **fields}
|
||||
|
||||
|
||||
def _plugin(workspace: Path, name: str = "demo", **fields: object) -> Path:
|
||||
root = workspace / "plugins" / name
|
||||
_write_json(root / "plugin.json", _manifest(name, **fields))
|
||||
return root
|
||||
|
||||
|
||||
def _skill(root: Path, name: str, frontmatter: str | None = None, body: str = "") -> Path:
|
||||
path = root / name
|
||||
path.mkdir(parents=True)
|
||||
metadata = frontmatter or f"name: {name}\ndescription: Plugin skill."
|
||||
(path / "SKILL.md").write_text(f"---\n{metadata}\n---\n\n{body}\n", encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def _loaded_skills(workspace: Path) -> list[str]:
|
||||
return [name for name, _ in enabled_agent_plugin_skills(workspace)]
|
||||
|
||||
|
||||
def test_plugin_skill_lifecycle_and_precedence(tmp_path: Path) -> None:
|
||||
plugin = _plugin(tmp_path)
|
||||
_skill(
|
||||
plugin / "skills",
|
||||
"shared",
|
||||
"name: shared\ndescription: Plugin version.\nalways: true",
|
||||
"Plugin body.",
|
||||
)
|
||||
_skill(tmp_path / "builtin", "shared", body="Built-in body.")
|
||||
workspace_skill = _skill(
|
||||
tmp_path / "skills", "shared", "name: shared\ndescription: Workspace version."
|
||||
)
|
||||
loader = SkillsLoader(tmp_path, builtin_skills_dir=tmp_path / "builtin")
|
||||
|
||||
assert [entry["source"] for entry in loader.list_skills()] == ["workspace"]
|
||||
assert "Workspace version" in (loader.load_skill("shared") or "")
|
||||
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 loader.get_explicitly_invoked_skills("Use $shared") == ["shared"]
|
||||
assert loader.get_always_skills() == ["shared"]
|
||||
assert "Plugin body" in (loader.load_skill("shared") or "")
|
||||
assert "`demo/skills/shared/SKILL.md`" in loader.build_skills_summary()
|
||||
|
||||
set_agent_plugin_enabled(tmp_path, "demo", False)
|
||||
assert [entry["source"] for entry in loader.list_skills()] == ["builtin"]
|
||||
assert "Built-in body" in (loader.load_skill("shared") or "")
|
||||
|
||||
|
||||
def test_plugin_skills_are_direct_valid_and_contained(tmp_path: Path) -> None:
|
||||
plugin = _plugin(tmp_path)
|
||||
skills = plugin / "skills"
|
||||
_skill(skills, "direct")
|
||||
_skill(skills / "group", "nested")
|
||||
for name, frontmatter in (
|
||||
("wrong-directory", "name: another\ndescription: Mismatch."),
|
||||
("missing-description", "name: missing-description"),
|
||||
("Bad-Name", "name: Bad-Name\ndescription: Invalid name."),
|
||||
):
|
||||
_skill(skills, name, frontmatter)
|
||||
outside = _skill(tmp_path / "outside", "escaped")
|
||||
try:
|
||||
(skills / "escaped").symlink_to(outside, target_is_directory=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
set_agent_plugin_enabled(tmp_path, "demo", True)
|
||||
assert _loaded_skills(tmp_path) == ["direct"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("manifest", "valid"),
|
||||
[
|
||||
({"$schema": "https://agent-plugins.org/schemas/2.0.0/plugin.schema.json", "name": "demo"}, False),
|
||||
(_manifest("Bad-Name"), False),
|
||||
(_manifest("demo", futureField=True, extensions="invalid but non-fatal"), True),
|
||||
],
|
||||
)
|
||||
def test_plugin_manifest_boundary(tmp_path: Path, manifest: object, valid: bool) -> None:
|
||||
_write_json(tmp_path / "plugins" / "candidate" / "plugin.json", manifest)
|
||||
assert bool(discover_agent_plugins(tmp_path)) is valid
|
||||
|
||||
|
||||
def test_plugin_logo_is_validated_and_contained(tmp_path: Path) -> None:
|
||||
extension = {"extensions": {"dev.nanobot": {"logo": "./assets/icon.png"}}}
|
||||
plugin = _plugin(tmp_path, "demo", **extension)
|
||||
icon = plugin / "assets" / "icon.png"
|
||||
icon.parent.mkdir()
|
||||
icon.write_bytes(b"\x89PNG\r\n\x1a\nlogo")
|
||||
escaped = _plugin(tmp_path, "escaped", **extension)
|
||||
(escaped / "assets").mkdir()
|
||||
try:
|
||||
(escaped / "assets" / "icon.png").symlink_to(icon)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
assert {plugin.name: plugin.logo for plugin in discover_agent_plugins(tmp_path)} == {
|
||||
"demo": "data:image/png;base64,iVBORw0KGgpsb2dv",
|
||||
"escaped": None,
|
||||
}
|
||||
|
||||
|
||||
def test_plugin_mcp_requires_explicit_enable(tmp_path: Path) -> None:
|
||||
plugin = _plugin(tmp_path, "desktop")
|
||||
executable = plugin / "bin" / "server"
|
||||
executable.parent.mkdir()
|
||||
executable.write_text("#!/bin/sh\n", encoding="utf-8")
|
||||
_write_json(
|
||||
plugin / "mcp.json",
|
||||
{
|
||||
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
|
||||
"mcpServers": {
|
||||
"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)
|
||||
server = agent_plugin_mcp_servers(tmp_path)["desktop"]
|
||||
assert (server.command, server.cwd, server.env["PLUGIN_ROOT"]) == (
|
||||
str(executable),
|
||||
str(plugin),
|
||||
str(plugin),
|
||||
)
|
||||
assert server.args[1].endswith("/state")
|
||||
set_agent_plugin_enabled(tmp_path, "desktop", False)
|
||||
assert agent_plugin_mcp_servers(tmp_path) == {}
|
||||
|
||||
|
||||
def test_plugin_mcp_namespaces_cannot_shadow_plugin_identities(tmp_path: Path) -> None:
|
||||
single = _plugin(tmp_path, "foo-bar")
|
||||
multi = _plugin(tmp_path, "foo")
|
||||
for root, servers in (
|
||||
(single, {"main": {"type": "stdio", "command": "echo", "args": ["single"]}}),
|
||||
(
|
||||
multi,
|
||||
{
|
||||
"bar": {"type": "stdio", "command": "echo", "args": ["multi"]},
|
||||
"other": {"type": "stdio", "command": "echo"},
|
||||
},
|
||||
),
|
||||
):
|
||||
_write_json(
|
||||
root / "mcp.json",
|
||||
{"$schema": AGENT_PLUGIN_MCP_SCHEMA, "mcpServers": servers},
|
||||
)
|
||||
set_agent_plugin_enabled(tmp_path, "foo-bar", True)
|
||||
set_agent_plugin_enabled(tmp_path, "foo", True)
|
||||
|
||||
servers = agent_plugin_mcp_servers(tmp_path)
|
||||
|
||||
assert set(servers) == {"foo-bar", "foo--bar", "foo--other"}
|
||||
assert servers["foo-bar"].args == ["single"]
|
||||
assert servers["foo--bar"].args == ["multi"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restricted_project_can_read_only_enabled_plugin_skill(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
agent_workspace = tmp_path / "agent"
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
plugin = _plugin(agent_workspace)
|
||||
skill = _skill(plugin / "skills", "demo-skill")
|
||||
resource = skill / "reference.md"
|
||||
resource.write_text("plugin reference", encoding="utf-8")
|
||||
ctx = ToolContext(
|
||||
config=ToolsConfig(restrict_to_workspace=True),
|
||||
workspace=str(agent_workspace),
|
||||
)
|
||||
read_tool = ReadFileTool.create(ctx)
|
||||
write_tool = WriteFileTool.create(ctx)
|
||||
set_agent_plugin_enabled(agent_workspace, "demo", True)
|
||||
activation_checks = 0
|
||||
activation_marker = agent_plugins._activation_marker
|
||||
|
||||
def count_activation_checks(plugin: agent_plugins.AgentPlugin) -> str | None:
|
||||
nonlocal activation_checks
|
||||
activation_checks += 1
|
||||
return activation_marker(plugin)
|
||||
|
||||
monkeypatch.setattr(agent_plugins, "_activation_marker", count_activation_checks)
|
||||
scope = validate_workspace_scope_payload(
|
||||
{"project_path": str(project), "access_mode": "restricted"},
|
||||
default_workspace=agent_workspace,
|
||||
default_restrict_to_workspace=True,
|
||||
)
|
||||
|
||||
token = bind_workspace_scope(scope)
|
||||
try:
|
||||
read_result = await read_tool.execute(path=str(resource))
|
||||
repeated_read_result = await read_tool.execute(path=str(resource))
|
||||
write_result = await write_tool.execute(path=str(resource), content="changed")
|
||||
set_agent_plugin_enabled(agent_workspace, "demo", False)
|
||||
disabled_result = await read_tool.execute(path=str(resource))
|
||||
finally:
|
||||
reset_workspace_scope(token)
|
||||
|
||||
assert "plugin reference" in read_result
|
||||
assert "File unchanged since last read" in repeated_read_result
|
||||
assert activation_checks == 1
|
||||
assert "outside allowed directory" in write_result
|
||||
assert "outside allowed directory" in disabled_result
|
||||
assert resource.read_text(encoding="utf-8") == "plugin reference"
|
||||
|
||||
|
||||
def test_plugin_state_symlink_cannot_escape_config_root(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
config = tmp_path / "config"
|
||||
config.mkdir()
|
||||
outside = tmp_path / "outside"
|
||||
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")
|
||||
_plugin(tmp_path, "desktop")
|
||||
|
||||
with pytest.raises(RuntimeError, match="escapes its parent"):
|
||||
set_agent_plugin_enabled(tmp_path, "desktop", True)
|
||||
|
||||
|
||||
def test_plugin_activation_requires_one_stable_package_identity(tmp_path: Path) -> None:
|
||||
roots = [tmp_path / "plugins" / directory for directory in ("first", "second")]
|
||||
for root, marker in zip(roots, ("trusted", "replacement"), strict=True):
|
||||
_write_json(root / "plugin.json", _manifest("duplicate"))
|
||||
_write_json(
|
||||
root / "mcp.json",
|
||||
{
|
||||
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
|
||||
"mcpServers": {
|
||||
"server": {"type": "stdio", "command": "echo", "args": [marker]}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert discover_agent_plugins(tmp_path) == []
|
||||
with pytest.raises(ValueError, match="unknown Agent Plugin"):
|
||||
set_agent_plugin_enabled(tmp_path, "duplicate", True)
|
||||
|
||||
shutil.rmtree(roots[1])
|
||||
set_agent_plugin_enabled(tmp_path, "duplicate", True)
|
||||
assert discover_agent_plugins(tmp_path)[0].enabled is True
|
||||
|
||||
moved = tmp_path / "plugins" / "moved"
|
||||
roots[0].rename(moved)
|
||||
assert discover_agent_plugins(tmp_path)[0].enabled is False
|
||||
assert agent_plugin_mcp_servers(tmp_path) == {}
|
||||
|
||||
|
||||
def test_legacy_path_activation_is_upgraded_to_package_fingerprint(tmp_path: Path) -> None:
|
||||
plugin = _plugin(tmp_path)
|
||||
set_agent_plugin_enabled(tmp_path, "demo", True)
|
||||
marker = next((tmp_path / "config" / "plugin-data").glob("*/demo/enabled"))
|
||||
marker.write_text(str(plugin), encoding="utf-8")
|
||||
|
||||
assert discover_agent_plugins(tmp_path)[0].enabled is True
|
||||
assert marker.read_text(encoding="utf-8").startswith('{"fingerprint":')
|
||||
|
||||
|
||||
def test_plugin_activation_does_not_survive_in_place_contract_replacement(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
plugin = _plugin(tmp_path, "desktop")
|
||||
mcp = plugin / "mcp.json"
|
||||
|
||||
def write_server(marker: str) -> None:
|
||||
_write_json(
|
||||
mcp,
|
||||
{
|
||||
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
|
||||
"mcpServers": {
|
||||
"server": {"type": "stdio", "command": "echo", "args": [marker]}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
write_server("trusted")
|
||||
set_agent_plugin_enabled(tmp_path, "desktop", True)
|
||||
assert agent_plugin_mcp_servers(tmp_path)["desktop"].args == ["trusted"]
|
||||
|
||||
write_server("replacement")
|
||||
|
||||
assert discover_agent_plugins(tmp_path)[0].enabled is False
|
||||
assert agent_plugin_mcp_servers(tmp_path) == {}
|
||||
|
||||
|
||||
def test_plugin_activation_does_not_survive_in_place_code_replacement(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
plugin = _plugin(tmp_path, "desktop")
|
||||
_skill(plugin / "skills", "demo")
|
||||
executable = plugin / "server.py"
|
||||
executable.write_text("print('trusted')\n", encoding="utf-8")
|
||||
_write_json(
|
||||
plugin / "mcp.json",
|
||||
{
|
||||
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
|
||||
"mcpServers": {
|
||||
"server": {
|
||||
"type": "stdio",
|
||||
"command": "python",
|
||||
"args": ["${PLUGIN_ROOT}/server.py"],
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
set_agent_plugin_enabled(tmp_path, "desktop", True)
|
||||
assert discover_agent_plugins(tmp_path)[0].enabled is True
|
||||
assert enabled_agent_plugin_skill_dirs(tmp_path) == (plugin / "skills" / "demo",)
|
||||
|
||||
executable.write_text("print('replacement')\n", encoding="utf-8")
|
||||
|
||||
assert discover_agent_plugins(tmp_path)[0].enabled is False
|
||||
assert enabled_agent_plugin_skill_dirs(tmp_path) == ()
|
||||
assert agent_plugin_mcp_servers(tmp_path) == {}
|
||||
@@ -0,0 +1,16 @@
|
||||
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
||||
|
||||
|
||||
def test_weather_skill_uses_windows_safe_single_today_request() -> None:
|
||||
content = (BUILTIN_SKILLS_DIR / "weather" / "SKILL.md").read_text(encoding="utf-8")
|
||||
normalized = " ".join(content.split())
|
||||
|
||||
assert "On Windows PowerShell, use `curl.exe`" in normalized
|
||||
assert "bare `curl` may resolve to `Invoke-WebRequest`" in normalized
|
||||
assert "https://wttr.in/London?1&m" in content
|
||||
assert 'curl.exe -s "https://wttr.in/Berlin.png" -o weather.png' in content
|
||||
assert "/tmp/weather.png" not in content
|
||||
assert (
|
||||
"Do not fetch current conditions separately when a today or forecast "
|
||||
"request already includes them."
|
||||
) in normalized
|
||||
@@ -6,7 +6,7 @@ import asyncio
|
||||
from contextlib import AsyncExitStack
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
@@ -155,6 +155,31 @@ async def test_connect_mcp_retries_when_no_servers_connect(tmp_path, monkeypatch
|
||||
|
||||
assert attempts == 2
|
||||
assert loop._mcp_stacks == {}
|
||||
assert loop.mcp_runtime_status() == {"test": "failed"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_mcp_does_not_report_failure_before_oauth_authorization(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
cfg = MCPServerConfig(
|
||||
type="streamableHttp",
|
||||
auth="oauth",
|
||||
url="https://mcp.example.com/mcp",
|
||||
)
|
||||
loop = _make_loop(tmp_path, mcp_servers={"oauth-app": cfg})
|
||||
connect = AsyncMock()
|
||||
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", connect)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.agent.tools.mcp_oauth.mcp_oauth_has_credentials",
|
||||
lambda _name, _url: False,
|
||||
)
|
||||
|
||||
await loop._connect_mcp()
|
||||
|
||||
connect.assert_not_awaited()
|
||||
assert loop.mcp_runtime_status() == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -266,6 +266,7 @@ def test_disabled_skills_excluded_from_list(tmp_path: Path) -> None:
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["name"] == "beta"
|
||||
assert entries[0]["path"] == str(beta_path)
|
||||
assert loader.load_skill("alpha") is None
|
||||
|
||||
|
||||
def test_disabled_skills_empty_set_no_effect(tmp_path: Path) -> None:
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""CLI app subprocesses must not inherit API keys from the parent environ."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from nanobot.apps.cli.service import CliAppManager
|
||||
|
||||
|
||||
def test_subprocess_env_excludes_api_keys(monkeypatch, tmp_path) -> None:
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-should-not-leak")
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-leak")
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-leak")
|
||||
|
||||
manager = CliAppManager(workspace=tmp_path, data_dir=tmp_path / "cli-apps")
|
||||
env = manager._subprocess_env()
|
||||
|
||||
assert "OPENAI_API_KEY" not in env
|
||||
assert "ANTHROPIC_API_KEY" not in env
|
||||
assert "OPENROUTER_API_KEY" not in env
|
||||
assert env.get("PYTHONUNBUFFERED") == "1"
|
||||
assert "PATH" in env
|
||||
|
||||
|
||||
def test_subprocess_env_excludes_api_keys_on_windows(monkeypatch, tmp_path) -> None:
|
||||
monkeypatch.setattr("nanobot.apps.cli.service.sys.platform", "win32")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-should-not-leak")
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-leak")
|
||||
|
||||
manager = CliAppManager(workspace=tmp_path, data_dir=tmp_path / "cli-apps")
|
||||
env = manager._subprocess_env()
|
||||
|
||||
assert "OPENAI_API_KEY" not in env
|
||||
assert "ANTHROPIC_API_KEY" not in env
|
||||
assert env["PYTHONUNBUFFERED"] == "1"
|
||||
assert env["SYSTEMROOT"]
|
||||
assert all(isinstance(value, str) for value in env.values())
|
||||
|
||||
|
||||
def test_run_passes_filtered_env(monkeypatch, tmp_path) -> None:
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-should-not-leak")
|
||||
manager = CliAppManager(workspace=tmp_path, data_dir=tmp_path / "cli-apps")
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_run(*args, **kwargs):
|
||||
captured.update(kwargs)
|
||||
|
||||
class Result:
|
||||
returncode = 0
|
||||
stdout = "ok"
|
||||
stderr = ""
|
||||
|
||||
return Result()
|
||||
|
||||
monkeypatch.setattr("nanobot.apps.cli.service.subprocess.run", fake_run)
|
||||
monkeypatch.setattr(manager, "get_app", lambda name: {"name": name, "entry_point": "echo"})
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"_load_installed",
|
||||
lambda: {"echo": {"entry_point": "echo"}},
|
||||
)
|
||||
monkeypatch.setattr("nanobot.apps.cli.service.shutil.which", lambda entry: "/bin/echo")
|
||||
monkeypatch.setattr(manager, "_resolve_cwd", lambda *a, **k: tmp_path)
|
||||
monkeypatch.setattr(manager, "_artifact_snapshot", lambda cwd: {})
|
||||
monkeypatch.setattr(manager, "_changed_artifacts", lambda cwd, snap: [])
|
||||
|
||||
manager.run("echo", ["hi"])
|
||||
|
||||
env = captured.get("env")
|
||||
assert isinstance(env, dict)
|
||||
assert "OPENAI_API_KEY" not in env
|
||||
@@ -84,6 +84,10 @@ class _GatewayAgentContractStub:
|
||||
|
||||
tools = ToolRegistry()
|
||||
|
||||
@staticmethod
|
||||
def mcp_runtime_status() -> dict[str, str]:
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def pending_cron_job_ids_for_session(_session_key: str) -> set[str]:
|
||||
return set()
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.apps import discovery
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discovery_returns_fallback_then_caches_remote_registry(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
remote = {
|
||||
"schema_version": 1,
|
||||
"updated_at": "2026-08-12T01:00:00Z",
|
||||
"featured": ["mcp:notion", "cli:obsidian"],
|
||||
}
|
||||
monkeypatch.setattr(discovery, "_fetch_remote", lambda: remote)
|
||||
|
||||
initial = await discovery.discovery_payload(data_dir=tmp_path)
|
||||
assert initial["featured"][0] == "mcp:github"
|
||||
assert initial["refresh_pending"] is True
|
||||
|
||||
await asyncio.gather(*discovery._refresh_tasks.values())
|
||||
cached = await discovery.discovery_payload(data_dir=tmp_path)
|
||||
assert cached == remote
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discovery_keeps_last_valid_registry_when_refresh_fails(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
cached = {
|
||||
"schema_version": 1,
|
||||
"updated_at": "2026-08-11T01:00:00Z",
|
||||
"featured": ["mcp:github"],
|
||||
}
|
||||
discovery._write_cache(tmp_path / "apps-discovery.json", cached)
|
||||
monkeypatch.setattr(discovery, "CACHE_TTL_S", -1)
|
||||
monkeypatch.setattr(
|
||||
discovery,
|
||||
"_fetch_remote",
|
||||
lambda: (_ for _ in ()).throw(OSError("offline")),
|
||||
)
|
||||
|
||||
payload = await discovery.discovery_payload(data_dir=tmp_path)
|
||||
assert payload == {**cached, "refresh_pending": True}
|
||||
await asyncio.gather(*discovery._refresh_tasks.values())
|
||||
assert discovery._read_cache(tmp_path / "apps-discovery.json") == cached
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"featured",
|
||||
[[], ["unknown:github"], ["mcp:github", "mcp:github"], ["mcp:github"] * 13],
|
||||
)
|
||||
def test_discovery_rejects_invalid_featured_lists(featured: list[str]) -> None:
|
||||
assert discovery._validated_payload({
|
||||
"schema_version": 1,
|
||||
"updated_at": "2026-08-12T01:00:00Z",
|
||||
"featured": featured,
|
||||
}) is None
|
||||
@@ -9,9 +9,16 @@ from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent import plugins as agent_plugins
|
||||
from nanobot.agent.skills import SkillsLoader
|
||||
from nanobot.apps.cli.service import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_plugin_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(agent_plugins, "get_config_path", lambda: tmp_path / "config/config.json")
|
||||
|
||||
|
||||
def _write_cache(path: Path, registry: dict) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
@@ -197,6 +204,76 @@ def test_payload_uses_anygen_official_domain_for_logo(tmp_path: Path) -> None:
|
||||
assert app["logo_url"] == "https://www.google.com/s2/favicons?domain=anygen.io&sz=64"
|
||||
|
||||
|
||||
def test_payload_uses_calibre_official_domain_for_logo(tmp_path: Path) -> None:
|
||||
manager = _manager(tmp_path)
|
||||
_write_cache(
|
||||
manager._cache_path("harness"),
|
||||
{
|
||||
"meta": {"updated": "2026-04-16"},
|
||||
"clis": [
|
||||
{
|
||||
"name": "calibre",
|
||||
"display_name": "Calibre",
|
||||
"entry_point": "cli-anything-calibre",
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
app = next(app for app in manager.payload()["apps"] if app["name"] == "calibre")
|
||||
|
||||
assert app["logo_url"] == (
|
||||
"https://www.google.com/s2/favicons?domain=calibre-ebook.com&sz=64"
|
||||
)
|
||||
|
||||
|
||||
def test_payload_prefers_colored_official_homepage_logo(tmp_path: Path) -> None:
|
||||
manager = _manager(tmp_path)
|
||||
_write_cache(
|
||||
manager._cache_path("harness"),
|
||||
{
|
||||
"meta": {"updated": "2026-04-16"},
|
||||
"clis": [
|
||||
{
|
||||
"name": "blender",
|
||||
"display_name": "Blender",
|
||||
"homepage": "https://www.blender.org/features/",
|
||||
"entry_point": "cli-anything-blender",
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
app = next(app for app in manager.payload()["apps"] if app["name"] == "blender")
|
||||
|
||||
assert app["logo_url"] == (
|
||||
"https://www.google.com/s2/favicons?domain=blender.org&sz=64"
|
||||
)
|
||||
assert app["brand_color"] == "#E87D0D"
|
||||
|
||||
|
||||
def test_payload_does_not_use_repository_host_as_the_app_logo(tmp_path: Path) -> None:
|
||||
manager = _manager(tmp_path)
|
||||
_write_cache(
|
||||
manager._cache_path("harness"),
|
||||
{
|
||||
"meta": {"updated": "2026-04-16"},
|
||||
"clis": [
|
||||
{
|
||||
"name": "gimp",
|
||||
"display_name": "GIMP",
|
||||
"homepage": "https://github.com/example/gimp-wrapper",
|
||||
"entry_point": "cli-anything-gimp",
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
app = next(app for app in manager.payload()["apps"] if app["name"] == "gimp")
|
||||
|
||||
assert app["logo_url"] == "https://cdn.simpleicons.org/gimp/5C5543"
|
||||
|
||||
|
||||
def test_payload_resolves_obsidian_agent_cli_brand(tmp_path: Path) -> None:
|
||||
manager = _manager(tmp_path)
|
||||
_write_cache(
|
||||
@@ -391,6 +468,9 @@ def test_install_dispatches_safe_pip_and_installs_skill(
|
||||
"_fetch_skill_content",
|
||||
lambda app: "---\nname: cli-anything-gimp\ndescription: GIMP\n---\n# GIMP\n",
|
||||
)
|
||||
legacy = manager.workspace / "skills" / "cli-app-gimp" / "SKILL.md"
|
||||
legacy.parent.mkdir(parents=True)
|
||||
legacy.write_text("legacy", encoding="utf-8")
|
||||
|
||||
payload = manager.install("gimp")
|
||||
|
||||
@@ -400,9 +480,15 @@ def test_install_dispatches_safe_pip_and_installs_skill(
|
||||
assert "state_recorded" in payload["last_action"]["verification"]
|
||||
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
||||
assert installed["gimp"]["entry_point"] == "cli-anything-gimp"
|
||||
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()
|
||||
manifest = json.loads((plugin / "plugin.json").read_text(encoding="utf-8"))
|
||||
assert (manifest["name"], manifest["version"]) == ("cli-app-gimp", "1.0.0")
|
||||
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 SkillsLoader(manager.workspace).load_skill("cli-app-gimp") is not None
|
||||
assert not legacy.exists()
|
||||
|
||||
|
||||
def test_run_argv_logs_command_exit_and_output(
|
||||
@@ -487,7 +573,7 @@ def test_install_records_available_cli_without_reinstalling(
|
||||
assert "entry_point_available" in payload["last_action"]["verification"]
|
||||
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
||||
assert installed["feishu"]["entry_point_path"] == str(resolved)
|
||||
skill = manager.workspace / "skills" / "cli-app-feishu" / "SKILL.md"
|
||||
skill = manager.workspace / "plugins/cli-app-feishu/skills/cli-app-feishu/SKILL.md"
|
||||
assert skill.is_file()
|
||||
assert 'run_cli_app` tool with `name="feishu"' in skill.read_text(encoding="utf-8")
|
||||
|
||||
@@ -704,7 +790,8 @@ def test_uninstall_removes_installed_state_and_generated_skill(
|
||||
manager = _manager(tmp_path)
|
||||
_seed_catalog(manager)
|
||||
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
|
||||
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 / "SKILL.md").write_text("# GIMP\n", encoding="utf-8")
|
||||
monkeypatch.setattr(
|
||||
@@ -717,7 +804,7 @@ def test_uninstall_removes_installed_state_and_generated_skill(
|
||||
|
||||
assert payload["last_action"]["ok"] is True
|
||||
assert "gimp" not in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
||||
assert not skill_dir.exists()
|
||||
assert not plugin_dir.exists()
|
||||
|
||||
|
||||
def test_uninstall_uses_safe_python_m_pip_uninstall_command(
|
||||
@@ -845,19 +932,62 @@ def test_mentioned_installed_apps_only_returns_installed_mentions(tmp_path: Path
|
||||
"name": "zoom",
|
||||
"entry_point": "cli-anything-zoom",
|
||||
"source": "public",
|
||||
"skill": "skills/cli-app-zoom/SKILL.md",
|
||||
"skill": "plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md",
|
||||
"tool": "run_cli_app",
|
||||
},
|
||||
{
|
||||
"name": "gimp",
|
||||
"entry_point": "cli-anything-gimp",
|
||||
"source": "harness",
|
||||
"skill": "skills/cli-app-gimp/SKILL.md",
|
||||
"skill": "plugins/cli-app-gimp/skills/cli-app-gimp/SKILL.md",
|
||||
"tool": "run_cli_app",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_remove_skill_cleans_legacy_underscored_name(tmp_path: Path) -> None:
|
||||
manager = _manager(tmp_path)
|
||||
legacy = manager.workspace / "skills" / "cli-app-unimol_tools" / "SKILL.md"
|
||||
legacy.parent.mkdir(parents=True)
|
||||
legacy.write_text("# Legacy Uni-Mol\n", encoding="utf-8")
|
||||
|
||||
manager.remove_skill("unimol_tools")
|
||||
|
||||
assert not legacy.exists()
|
||||
|
||||
|
||||
def test_migrated_cli_app_skill_keeps_legacy_identity_alias(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
data_dir = tmp_path / "data"
|
||||
monkeypatch.setattr(
|
||||
"nanobot.apps.cli.service.get_runtime_subdir",
|
||||
lambda _name: data_dir,
|
||||
)
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
manager = CliAppManager(workspace=workspace)
|
||||
manager._save_installed({"unimol_tools": {"entry_point": "unimol-tools"}})
|
||||
manager.install_skill({
|
||||
"name": "unimol_tools",
|
||||
"display_name": "Uni-Mol Tools",
|
||||
"entry_point": "unimol-tools",
|
||||
})
|
||||
agent_plugins.set_agent_plugin_enabled(workspace, "cli-app-unimol-tools", True)
|
||||
|
||||
loader = SkillsLoader(workspace)
|
||||
assert loader.get_explicitly_invoked_skills("Use $cli-app-unimol_tools") == [
|
||||
"cli-app-unimol-tools"
|
||||
]
|
||||
assert loader.load_skill("cli-app-unimol_tools") is not None
|
||||
|
||||
disabled = SkillsLoader(workspace, disabled_skills={"cli-app-unimol_tools"})
|
||||
assert "cli-app-unimol-tools" not in {
|
||||
skill["name"] for skill in disabled.list_skills(filter_unavailable=False)
|
||||
}
|
||||
|
||||
|
||||
def test_install_rejects_unknown_and_script_strategy(tmp_path: Path) -> None:
|
||||
manager = _manager(tmp_path)
|
||||
_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 "tool=run_cli_app" 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(
|
||||
"please use @zoom tonight",
|
||||
"please use @unimol_tools",
|
||||
{
|
||||
"cli_apps": [{
|
||||
"name": "zoom",
|
||||
"entry_point": "cli-anything-zoom",
|
||||
"display_name": "Zoom",
|
||||
"name": "unimol_tools",
|
||||
"entry_point": "cli-anything-unimol-tools",
|
||||
}],
|
||||
},
|
||||
tmp_path,
|
||||
)
|
||||
|
||||
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 "entry_point=cli-anything-zoom" in joined
|
||||
assert "skill=skills/cli-app-zoom/SKILL.md" in joined
|
||||
assert "entry_point=cli-anything-unimol-tools" in joined
|
||||
assert "skill=skills/cli-app-unimol_tools/SKILL.md" in joined
|
||||
|
||||
@@ -117,6 +117,30 @@ class TestBuildKwargsExtraBody:
|
||||
"chat_template_kwargs": {"enable_thinking": False},
|
||||
}
|
||||
|
||||
def test_extra_body_appends_tools_without_clobbering_functions(self) -> None:
|
||||
function_tool = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "write_file",
|
||||
"description": "Write a local file",
|
||||
"parameters": {"type": "object"},
|
||||
},
|
||||
}
|
||||
server_tool = {"type": "openrouter:web_search"}
|
||||
provider = _make_provider({
|
||||
"tools": [server_tool],
|
||||
"custom_param": "value",
|
||||
})
|
||||
|
||||
kwargs = provider._build_kwargs(
|
||||
messages=_simple_messages(),
|
||||
tools=[function_tool], model=None, max_tokens=100,
|
||||
temperature=0.1, reasoning_effort=None, tool_choice=None,
|
||||
)
|
||||
|
||||
assert kwargs["tools"] == [function_tool, server_tool]
|
||||
assert kwargs["extra_body"] == {"custom_param": "value"}
|
||||
|
||||
def test_extra_body_merges_with_thinking(self) -> None:
|
||||
"""Config extra_body should merge with (and override) thinking params."""
|
||||
from nanobot.providers.registry import ProviderSpec
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Provider credentials must not leak through process-global os.environ."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||
from nanobot.providers.registry import find_by_name
|
||||
|
||||
|
||||
def test_provider_init_does_not_mutate_shared_env_keys(monkeypatch) -> None:
|
||||
"""Multi-provider setups must not overwrite or pin each other's keys."""
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
|
||||
openai_spec = find_by_name("openai")
|
||||
openrouter_spec = find_by_name("openrouter")
|
||||
assert openai_spec is not None and openrouter_spec is not None
|
||||
|
||||
OpenAICompatProvider(
|
||||
api_key="sk-openai-secret",
|
||||
default_model="gpt-4o",
|
||||
spec=openai_spec,
|
||||
)
|
||||
OpenAICompatProvider(
|
||||
api_key="sk-or-secret",
|
||||
default_model="openrouter/auto",
|
||||
spec=openrouter_spec,
|
||||
api_base="https://openrouter.ai/api/v1",
|
||||
)
|
||||
|
||||
assert "OPENAI_API_KEY" not in os.environ
|
||||
assert "OPENROUTER_API_KEY" not in os.environ
|
||||
|
||||
|
||||
def test_provider_init_preserves_preexisting_env_keys(monkeypatch) -> None:
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "preexisting-user-key")
|
||||
|
||||
openai_spec = find_by_name("openai")
|
||||
assert openai_spec is not None
|
||||
provider = OpenAICompatProvider(
|
||||
api_key="sk-from-config",
|
||||
default_model="gpt-4o",
|
||||
spec=openai_spec,
|
||||
)
|
||||
|
||||
assert os.environ["OPENAI_API_KEY"] == "preexisting-user-key"
|
||||
assert provider._api_key_for_client == "sk-from-config"
|
||||
@@ -181,6 +181,72 @@ async def test_connect_missing_servers_propagates_external_cancellation(monkeypa
|
||||
assert state._mcp_connecting is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_saved_oauth_http_403_projects_failed_runtime_without_details(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class OAuthAuth(httpx.Auth):
|
||||
def auth_flow(self, request: httpx.Request):
|
||||
request.headers["Authorization"] = "Bearer saved-oauth-secret"
|
||||
yield request
|
||||
|
||||
class AuthorizationRequiredError(RuntimeError):
|
||||
pass
|
||||
|
||||
async def create_auth(_name: str, _url: str, _handlers=None) -> httpx.Auth:
|
||||
return OAuthAuth()
|
||||
|
||||
@asynccontextmanager
|
||||
async def rejected_streamable_http(url: str, http_client=None):
|
||||
request = httpx.Request("POST", f"{url}?access_token=saved-oauth-secret")
|
||||
response = httpx.Response(403, request=request)
|
||||
raise httpx.HTTPStatusError(
|
||||
"403 Forbidden for saved-oauth-secret",
|
||||
request=request,
|
||||
response=response,
|
||||
)
|
||||
yield object(), object(), object()
|
||||
|
||||
async def reachable(_url: str) -> bool:
|
||||
return True
|
||||
|
||||
oauth_mod = ModuleType("nanobot.agent.tools.mcp_oauth")
|
||||
oauth_mod.MCPAuthorizationRequiredError = AuthorizationRequiredError # type: ignore[attr-defined]
|
||||
oauth_mod.create_mcp_oauth_auth = create_auth # type: ignore[attr-defined]
|
||||
oauth_mod.mcp_oauth_has_credentials = lambda _name, _url: True # type: ignore[attr-defined]
|
||||
monkeypatch.setitem(sys.modules, "nanobot.agent.tools.mcp_oauth", oauth_mod)
|
||||
monkeypatch.setattr(mcp_mod, "validate_url_target", lambda _url: (True, ""))
|
||||
monkeypatch.setattr(mcp_mod, "_probe_http_url", reachable)
|
||||
monkeypatch.setattr(
|
||||
sys.modules["mcp.client.streamable_http"],
|
||||
"streamable_http_client",
|
||||
rejected_streamable_http,
|
||||
)
|
||||
|
||||
class State:
|
||||
pass
|
||||
|
||||
state = State()
|
||||
state._mcp_closing = False
|
||||
state._mcp_servers = {
|
||||
"xmind": MCPServerConfig(
|
||||
type="streamableHttp",
|
||||
auth="oauth",
|
||||
url="https://app.xmind.com/api/mcp",
|
||||
)
|
||||
}
|
||||
state._mcp_stacks = {}
|
||||
state._mcp_runtime_statuses = {}
|
||||
state._mcp_connecting = False
|
||||
|
||||
await mcp_mod.connect_missing_servers(state, ToolRegistry())
|
||||
|
||||
snapshot = mcp_mod.runtime_status(state)
|
||||
assert snapshot == {"xmind": "failed"}
|
||||
assert "saved-oauth-secret" not in str(snapshot)
|
||||
assert "app.xmind.com" not in str(snapshot)
|
||||
|
||||
|
||||
def test_wrapper_preserves_non_nullable_unions() -> None:
|
||||
tool_def = SimpleNamespace(
|
||||
name="demo",
|
||||
|
||||
@@ -598,6 +598,24 @@ def test_cast_params_invalid_string_to_number() -> None:
|
||||
assert result["rate"] == "not_a_number"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[float("nan"), float("inf"), float("-inf"), "NaN", "Infinity", "-Infinity"],
|
||||
)
|
||||
def test_cast_params_rejects_non_finite_numbers(value: float | str) -> None:
|
||||
"""JSON number parameters must remain finite after schema-driven casting."""
|
||||
tool = CastTestTool(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"rate": {"type": "number"}},
|
||||
}
|
||||
)
|
||||
|
||||
result = tool.cast_params({"rate": value})
|
||||
|
||||
assert tool.validate_params(result) == ["rate must be finite"]
|
||||
|
||||
|
||||
def test_validate_params_bool_not_accepted_as_number() -> None:
|
||||
"""Booleans should not pass number validation."""
|
||||
tool = CastTestTool(
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from mcp.shared.auth import OAuthToken
|
||||
|
||||
from nanobot.agent.plugins import AGENT_PLUGIN_MCP_SCHEMA, AGENT_PLUGIN_SCHEMA
|
||||
from nanobot.agent.tools.mcp_oauth import MCPOAuthStorage, mcp_oauth_has_credentials
|
||||
from nanobot.config.loader import load_config
|
||||
from nanobot.webui.mcp_presets_api import (
|
||||
@@ -12,13 +16,48 @@ from nanobot.webui.mcp_presets_api import (
|
||||
custom_mcp_action,
|
||||
mcp_presets_action,
|
||||
mcp_presets_payload,
|
||||
mcp_presets_settings_action,
|
||||
mcp_presets_test_action,
|
||||
normalize_mcp_preset_mentions,
|
||||
)
|
||||
|
||||
|
||||
def _use_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
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"
|
||||
root.mkdir(parents=True)
|
||||
for filename, payload in (
|
||||
(
|
||||
"plugin.json",
|
||||
{
|
||||
"$schema": AGENT_PLUGIN_SCHEMA,
|
||||
"name": "desktop",
|
||||
"description": "Control the local desktop.",
|
||||
"extensions": {
|
||||
"dev.nanobot": {
|
||||
"displayName": "Desktop Control",
|
||||
"permissions": ["screen-recording"],
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
(
|
||||
"mcp.json",
|
||||
{
|
||||
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
|
||||
"mcpServers": {"desktop": {"type": "stdio", "command": "echo"}},
|
||||
},
|
||||
),
|
||||
):
|
||||
(root / filename).write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
|
||||
def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -60,6 +99,52 @@ def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest
|
||||
assert manifest["trust"]["review_status"] == "builtin_preset"
|
||||
|
||||
|
||||
def test_agent_plugin_reuses_mcp_catalog_and_runtime_action(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_config(tmp_path, monkeypatch)
|
||||
_write_agent_plugin(load_config().workspace_path)
|
||||
|
||||
row = next(item for item in mcp_presets_payload()["presets"] if item["source"] == "agent-plugin")
|
||||
assert (row["name"], row["display_name"], row["requires"]) == (
|
||||
"plugin-desktop", "Desktop Control", "screen-recording"
|
||||
)
|
||||
assert row["installed"] and row["configured"] and not row["enabled"]
|
||||
|
||||
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["enabled"], enabled_row["status"], enabled["requires_restart"]) == (
|
||||
True, "enabled", 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"], disabled_row["enabled"], disabled_row["status"]) == (
|
||||
True, False, "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 and rows[0]["source"] == "custom"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oauth_preset_is_one_click_configured_after_token_storage(
|
||||
tmp_path,
|
||||
@@ -87,10 +172,72 @@ async def test_oauth_preset_is_one_click_configured_after_token_storage(
|
||||
assert row["configured"] is True
|
||||
assert row["status"] == "configured"
|
||||
|
||||
failed = mcp_presets_payload(runtime_status={"xmind": "failed"})
|
||||
row = next(item for item in failed["presets"] if item["name"] == "xmind")
|
||||
assert row["configured"] is True
|
||||
assert row["status"] == "configured"
|
||||
assert row["runtime_status"] == "failed"
|
||||
assert "secret" not in str(row)
|
||||
|
||||
healthy = mcp_presets_payload(runtime_status={"xmind": "connected"})
|
||||
row = next(item for item in healthy["presets"] if item["name"] == "xmind")
|
||||
assert row["runtime_status"] == "connected"
|
||||
|
||||
mcp_presets_action("remove", {"name": ["xmind"]})
|
||||
assert await MCPOAuthStorage("xmind", cfg.url).get_tokens() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_list_projects_runtime_snapshot_and_reconnects_custom_server(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_config(tmp_path, monkeypatch)
|
||||
custom_mcp_action(
|
||||
"custom",
|
||||
{
|
||||
"name": ["team-docs"],
|
||||
"transport": ["streamableHttp"],
|
||||
"url": ["https://mcp.example.com/mcp"],
|
||||
},
|
||||
)
|
||||
statuses = {"team-docs": "failed"}
|
||||
reload_calls = 0
|
||||
|
||||
async def reload_mcp() -> dict[str, object]:
|
||||
nonlocal reload_calls
|
||||
reload_calls += 1
|
||||
statuses["team-docs"] = "connected"
|
||||
return {
|
||||
"ok": True,
|
||||
"connected": ["team-docs"],
|
||||
"failed": [],
|
||||
"requires_restart": False,
|
||||
}
|
||||
|
||||
listed = await mcp_presets_settings_action(
|
||||
None,
|
||||
{},
|
||||
reload_mcp=reload_mcp,
|
||||
mcp_runtime_status=lambda: statuses,
|
||||
)
|
||||
row = next(item for item in listed["presets"] if item["name"] == "team-docs")
|
||||
assert row["configured"] is True
|
||||
assert row["runtime_status"] == "failed"
|
||||
assert reload_calls == 0
|
||||
|
||||
reconnected = await mcp_presets_settings_action(
|
||||
"reconnect",
|
||||
{"name": ["team-docs"]},
|
||||
reload_mcp=reload_mcp,
|
||||
mcp_runtime_status=lambda: statuses,
|
||||
)
|
||||
row = next(item for item in reconnected["presets"] if item["name"] == "team-docs")
|
||||
assert row["runtime_status"] == "connected"
|
||||
assert reconnected["requires_restart"] is False
|
||||
assert reload_calls == 1
|
||||
|
||||
|
||||
def test_enable_browserbase_writes_scrubbed_config_payload(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable, Mapping
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import ANY, AsyncMock, MagicMock
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
@@ -10,13 +12,19 @@ from websockets.datastructures import Headers
|
||||
|
||||
from nanobot.config.loader import get_config_path
|
||||
from nanobot.webui.http_utils import http_json_response
|
||||
from nanobot.webui.mcp_presets_api import custom_mcp_action
|
||||
from nanobot.webui.settings_routes import WebUISettingsRouter
|
||||
from nanobot.webui.settings_services import WebUISettingsServices
|
||||
|
||||
|
||||
def _router(*, authorized: bool = True) -> WebUISettingsRouter:
|
||||
def _router(
|
||||
*,
|
||||
authorized: bool = True,
|
||||
config_path: Path | None = None,
|
||||
mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
|
||||
) -> WebUISettingsRouter:
|
||||
return WebUISettingsRouter(
|
||||
settings=WebUISettingsServices.create(get_config_path()),
|
||||
settings=WebUISettingsServices.create(config_path or get_config_path()),
|
||||
bus=SimpleNamespace(),
|
||||
logger=SimpleNamespace(exception=lambda *_args: None),
|
||||
check_api_token=lambda _request: authorized,
|
||||
@@ -28,6 +36,7 @@ def _router(*, authorized: bool = True) -> WebUISettingsRouter:
|
||||
),
|
||||
runtime_surface="browser",
|
||||
runtime_capabilities={},
|
||||
mcp_runtime_status=mcp_runtime_status,
|
||||
mcp_oauth_redirect_uri=lambda _request: "https://gateway.example/auth/mcp/callback",
|
||||
)
|
||||
|
||||
@@ -40,6 +49,65 @@ def _mutation_request(path: str, payload: dict[str, object]) -> SimpleNamespace:
|
||||
return request
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_list_serializes_local_runtime_failure_snapshot(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
custom_mcp_action(
|
||||
"custom",
|
||||
{
|
||||
"name": ["team-docs"],
|
||||
"transport": ["streamableHttp"],
|
||||
"url": ["https://mcp.example.com/mcp"],
|
||||
},
|
||||
config_path=config_path,
|
||||
)
|
||||
snapshot_calls = 0
|
||||
|
||||
def runtime_snapshot() -> Mapping[str, str]:
|
||||
nonlocal snapshot_calls
|
||||
snapshot_calls += 1
|
||||
return {"team-docs": "failed"}
|
||||
|
||||
router = _router(
|
||||
config_path=config_path,
|
||||
mcp_runtime_status=runtime_snapshot,
|
||||
)
|
||||
request = SimpleNamespace(
|
||||
path="/api/settings/mcp-presets",
|
||||
headers=Headers(),
|
||||
)
|
||||
|
||||
response = await router.dispatch(None, request, "/api/settings/mcp-presets")
|
||||
|
||||
assert response is not None
|
||||
assert response.status_code == 200
|
||||
payload = json.loads(response.body)
|
||||
row = next(item for item in payload["presets"] if item["name"] == "team-docs")
|
||||
assert row["status"] == "configured"
|
||||
assert row["runtime_status"] == "failed"
|
||||
assert b'"runtime_status": "failed"' in response.body
|
||||
assert snapshot_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apps_discovery_route_returns_registry(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
payload = {
|
||||
"schema_version": 1,
|
||||
"updated_at": "2026-08-12T00:00:00Z",
|
||||
"featured": ["mcp:github"],
|
||||
}
|
||||
discover = AsyncMock(return_value=payload)
|
||||
monkeypatch.setattr("nanobot.webui.settings_routes.discovery_payload", discover)
|
||||
request = SimpleNamespace(path="/api/settings/apps-discovery", headers=Headers())
|
||||
|
||||
response = await _router().dispatch(None, request, request.path)
|
||||
|
||||
assert response is not None
|
||||
assert response.status_code == 200
|
||||
assert json.loads(response.body) == payload
|
||||
discover.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_oauth_start_uses_gateway_callback_and_requires_api_auth(monkeypatch) -> None:
|
||||
config = SimpleNamespace(
|
||||
|
||||
+17
-1
@@ -2,7 +2,10 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1, user-scalable=no"
|
||||
/>
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta
|
||||
name="description"
|
||||
@@ -11,9 +14,14 @@
|
||||
/>
|
||||
<meta name="theme-color" content="#fafaf9" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#161618" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="nanobot" />
|
||||
<link rel="icon" type="image/svg+xml" href="/brand/nanobot_mark.svg" />
|
||||
<link rel="alternate icon" type="image/png" sizes="32x32" href="/brand/nanobot_favicon_32.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/brand/nanobot_apple_touch.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
@@ -21,6 +29,14 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* App-like feel on touch devices: no double-tap zoom on interactive
|
||||
elements (system-level accessibility zoom still works), and no text
|
||||
size inflation on orientation change. */
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #ffffff;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "nanobot",
|
||||
"short_name": "nanobot",
|
||||
"description": "nanobot AI assistant",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#161618",
|
||||
"theme_color": "#161618",
|
||||
"orientation": "any",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/brand/nanobot_apple_touch.png",
|
||||
"sizes": "180x180",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/brand/nanobot_icon_192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/brand/nanobot_icon_512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/brand/nanobot_icon_maskable.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
const CACHE_NAME = "nanobot-static-v1";
|
||||
const PRECACHE = ["/", "/manifest.json"];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE))
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Collect same-origin paths referenced by the given HTML document.
|
||||
function referencedAssetPaths(html) {
|
||||
const refs = new Set();
|
||||
const re = /(?:src|href)="(\/[^"]*)"/g;
|
||||
let match;
|
||||
while ((match = re.exec(html))) {
|
||||
const url = new URL(match[1], self.location.origin);
|
||||
if (url.origin === self.location.origin) refs.add(url.pathname + url.search);
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
// Drop cached entries that the current index.html no longer references.
|
||||
// CACHE_NAME is stable across deployments, so without this, hashed assets from
|
||||
// previous builds would pile up in the same cache forever. The cached
|
||||
// index.html is the latest one this client saw (navigation is network-first
|
||||
// and overwrites it on every successful visit), so pruning against it keeps
|
||||
// the offline shell consistent with the last loaded build.
|
||||
async function pruneStaleEntries() {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
const cachedIndex = await cache.match("/");
|
||||
if (!cachedIndex) return;
|
||||
const refs = referencedAssetPaths(await cachedIndex.text());
|
||||
const keys = await cache.keys();
|
||||
await Promise.all(
|
||||
keys.map(async (request) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === "/" || url.pathname === "/manifest.json") return;
|
||||
if (refs.has(url.pathname + url.search)) return;
|
||||
await cache.delete(request);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) =>
|
||||
Promise.all(
|
||||
keys
|
||||
.filter((k) => k !== CACHE_NAME)
|
||||
.map((k) => caches.delete(k))
|
||||
)
|
||||
)
|
||||
.then(() => pruneStaleEntries())
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const { request } = event;
|
||||
|
||||
// Only handle same-origin GET requests. Requests are handed to fetch() as-is
|
||||
// (never reconstructed), so their credentials mode is preserved and gateway
|
||||
// auth cookies flow through on every path we touch. WebSocket upgrades are
|
||||
// never dispatched to a service worker's fetch handler, so the WS endpoint
|
||||
// cannot be cached; the /__nanobot exclusion below still protects its HTTP
|
||||
// polling/socket bootstrap endpoints.
|
||||
if (request.method !== "GET") return;
|
||||
if (new URL(request.url).origin !== self.location.origin) return;
|
||||
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
|
||||
// Never cache API, auth, WebSocket, HMR, or WebUI endpoint paths. In
|
||||
// particular /webui/bootstrap issues fresh gateway credentials on every page
|
||||
// load and must never be cached or replayed offline. The /auth prefix covers
|
||||
// the default token endpoint; custom token_issue_path values should be kept
|
||||
// under one of these prefixes.
|
||||
if (
|
||||
path.startsWith("/api") ||
|
||||
path.startsWith("/auth") ||
|
||||
path.startsWith("/__nanobot") ||
|
||||
path.startsWith("/webui")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Static assets: cache-first. Only files under /assets/ carry content hashes
|
||||
// (the gateway serves them immutable); brand icons, the favicon and other
|
||||
// un-hashed files can change between releases and stay on the network-first
|
||||
// path below so updates reach installed clients.
|
||||
if (path.startsWith("/assets/")) {
|
||||
event.respondWith(
|
||||
caches.match(request).then((cached) => {
|
||||
if (cached) return cached;
|
||||
return fetch(request).then((response) => {
|
||||
if (response.ok) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((c) => c.put(request, clone));
|
||||
}
|
||||
return response;
|
||||
});
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Everything else: network-first (index.html, manifest, brand assets, etc.)
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((c) => c.put(request, clone));
|
||||
// The shell just changed; prune entries the new index.html no longer
|
||||
// references so hashed assets from old builds do not accumulate even
|
||||
// when sw.js itself is unchanged between deployments.
|
||||
if (path === "/") pruneStaleEntries();
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
// Offline: serve the app shell for navigations (deep links resolve
|
||||
// client-side), the last cached copy for everything else.
|
||||
if (request.mode === "navigate") return caches.match("/");
|
||||
return caches.match(request);
|
||||
})
|
||||
);
|
||||
});
|
||||
+1
-1
@@ -362,7 +362,7 @@ function AuthForm({
|
||||
disabled={submitting}
|
||||
aria-invalid={validationError ? true : undefined}
|
||||
aria-describedby={validationError ? "webui-auth-error" : undefined}
|
||||
className="pr-10 focus-visible:ring-1 focus-visible:ring-ring/30 focus-visible:ring-offset-0"
|
||||
className="pr-10"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,656 @@
|
||||
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,
|
||||
appsDiscovery,
|
||||
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
|
||||
discovery={appsDiscovery}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
cliAppsLoading={cliAppsLoading}
|
||||
mcpPresetsLoading={mcpPresetsLoading}
|
||||
query={appsQuery}
|
||||
filter={appsKindFilter}
|
||||
cliActionKey={cliAppsAction}
|
||||
mcpActionKey={mcpPresetAction}
|
||||
mcpOAuthFlow={mcpOAuthFlow}
|
||||
mcpOAuthPopupBlocked={mcpOAuthPopupBlocked}
|
||||
mcpOAuthCallbackUrl={mcpOAuthCallbackUrl}
|
||||
mcpOAuthCompleting={mcpOAuthCompleting}
|
||||
mcpOAuthCallbackError={mcpOAuthCallbackError}
|
||||
cliMessage={cliAppsMessage}
|
||||
cliError={cliAppsError}
|
||||
cliFocusName={cliAppsFocusName}
|
||||
mcpMessage={mcpMessage}
|
||||
mcpError={mcpError}
|
||||
mcpFieldValues={mcpFieldValues}
|
||||
customMcpForm={customMcpForm}
|
||||
mcpConfigImport={mcpConfigImport}
|
||||
showBrandLogos={localPrefs.brandLogos}
|
||||
requiresRestartPending={pendingRestartSections.runtime}
|
||||
onQueryChange={setAppsQuery}
|
||||
onFilterChange={setAppsKindFilter}
|
||||
onCliAction={handleCliAppAction}
|
||||
onMcpAction={handleMcpPresetAction}
|
||||
onMcpOAuthConnect={handleMcpOAuthConnect}
|
||||
onMcpOAuthCancel={() => void handleMcpOAuthCancel()}
|
||||
onMcpOAuthOpen={handleMcpOAuthOpen}
|
||||
onMcpOAuthCallbackUrlChange={(value) => {
|
||||
setMcpOAuthCallbackUrl(value);
|
||||
setMcpOAuthCallbackError(null);
|
||||
}}
|
||||
onMcpOAuthComplete={() => void handleMcpOAuthComplete()}
|
||||
onDismissStatus={() => {
|
||||
setCliAppsMessage(null);
|
||||
setCliAppsError(null);
|
||||
setMcpMessage(null);
|
||||
setMcpError(null);
|
||||
}}
|
||||
onBackToChat={onBackToChat}
|
||||
onMcpFieldChange={(presetName, fieldName, value) => {
|
||||
setMcpFieldValues((prev) => ({
|
||||
...prev,
|
||||
[presetName]: {
|
||||
...(prev[presetName] ?? {}),
|
||||
[fieldName]: value,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
onCustomMcpFormChange={setCustomMcpForm}
|
||||
onMcpConfigImportChange={setMcpConfigImport}
|
||||
onSaveCustomMcp={handleSaveCustomMcp}
|
||||
onImportMcpConfig={handleImportMcpConfig}
|
||||
onMcpToolsChange={handleMcpToolsChange}
|
||||
onRestart={restartViaSettingsSurface}
|
||||
isRestarting={isRestarting || hostEngineApplying}
|
||||
/>
|
||||
);
|
||||
case "automations":
|
||||
return (
|
||||
<AutomationsSettings
|
||||
payload={automations}
|
||||
loading={automationsLoading}
|
||||
query={automationsQuery}
|
||||
filter={automationsFilter}
|
||||
sort={automationsSort}
|
||||
actionKey={automationAction}
|
||||
error={automationsError}
|
||||
onQueryChange={setAutomationsQuery}
|
||||
onFilterChange={setAutomationsFilter}
|
||||
onSortChange={setAutomationsSort}
|
||||
onAction={handleAutomationAction}
|
||||
onRequestEdit={setAutomationPendingEdit}
|
||||
onRequestDelete={setAutomationPendingDelete}
|
||||
onBackToChat={onBackToChat}
|
||||
/>
|
||||
);
|
||||
case "skills":
|
||||
return <SkillsCatalogSettings skills={skills} />;
|
||||
case "runtime":
|
||||
return (
|
||||
<RuntimeSettings
|
||||
form={form}
|
||||
settings={settings}
|
||||
onRestart={restartViaSettingsSurface}
|
||||
isRestarting={isRestarting || hostEngineApplying}
|
||||
requiresRestartPending={pendingRestartSections.runtime}
|
||||
apiService={apiService}
|
||||
apiServiceLoading={apiServiceLoading}
|
||||
apiServiceAction={apiServiceAction}
|
||||
apiServiceError={apiServiceError}
|
||||
langfuseFeature={featureCatalog.find((feature) => feature.name === "langfuse")}
|
||||
capabilitiesLoading={nanobotFeaturesLoading}
|
||||
capabilityAction={nanobotFeatureAction}
|
||||
capabilityError={nanobotFeaturesError}
|
||||
onApiServiceAction={handleApiServiceAction}
|
||||
onInstallCapability={(name) => void installCapabilities([name])}
|
||||
/>
|
||||
);
|
||||
case "advanced":
|
||||
return (
|
||||
<AdvancedSettings
|
||||
form={networkSafetyForm}
|
||||
dirty={networkSafetyDirty}
|
||||
saving={networkSafetySaving}
|
||||
isNativeHostSurface={(settings.surface ?? settings.runtime_surface) === "native"}
|
||||
onChangeForm={setNetworkSafetyForm}
|
||||
onSave={saveNetworkSafetySettings}
|
||||
onRestart={restartViaSettingsSurface}
|
||||
isRestarting={isRestarting || hostEngineApplying}
|
||||
requiresRestartPending={pendingRestartSections.runtime}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-settings-canvas lg:flex-row">
|
||||
{showSidebar ? (
|
||||
<SettingsSidebar
|
||||
activeSection={activeSection}
|
||||
onSelectSection={selectSection}
|
||||
onBackToChat={onBackToChat}
|
||||
onLogout={onLogout}
|
||||
hostChromeInset={hostChromeInset}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<ModelPresetDeleteDialog
|
||||
preset={modelPresetPendingDelete}
|
||||
deleting={saving}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setModelPresetPendingDelete(null);
|
||||
}}
|
||||
onConfirm={handleDeleteModelConfiguration}
|
||||
/>
|
||||
|
||||
<ProviderOAuthLoginDialog
|
||||
flow={providerOAuthFlow}
|
||||
providerLabel={
|
||||
providerOAuthFlow
|
||||
? settings?.providers.find((provider) => provider.name === providerOAuthFlow.provider)
|
||||
?.label ?? providerOAuthFlow.provider
|
||||
: ""
|
||||
}
|
||||
authorizationResponse={providerOAuthResponse}
|
||||
completing={providerOAuthCompleting}
|
||||
error={providerOAuthDialogError}
|
||||
remoteBrowserAccess={remoteBrowserAccess}
|
||||
onAuthorizationResponseChange={(value) => {
|
||||
setProviderOAuthResponse(value);
|
||||
setProviderOAuthDialogError(null);
|
||||
}}
|
||||
onOpenAuthorization={() => {
|
||||
if (!providerOAuthFlow) return;
|
||||
const opened = window.open(
|
||||
providerOAuthFlow.authorization_url,
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
if (opened) opened.opener = null;
|
||||
}}
|
||||
onComplete={() => void completeProviderOAuthResponse()}
|
||||
onClose={closeProviderOAuthFlow}
|
||||
/>
|
||||
|
||||
<NanobotFeatureInstallDialog
|
||||
feature={nanobotFeatureConfirm}
|
||||
installing={nanobotFeatureAction === `enable:${nanobotFeatureConfirm?.name ?? ""}`}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setNanobotFeatureConfirm(null);
|
||||
}}
|
||||
onConfirm={(feature) => handleNanobotFeatureAction("enable", feature.name, true)}
|
||||
/>
|
||||
|
||||
<AutomationDeleteDialog
|
||||
job={automationPendingDelete}
|
||||
deleting={automationAction === `delete:${automationPendingDelete?.id ?? ""}`}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setAutomationPendingDelete(null);
|
||||
}}
|
||||
onConfirm={(job) => handleAutomationAction("delete", job)}
|
||||
/>
|
||||
|
||||
<AutomationEditDialog
|
||||
job={automationPendingEdit}
|
||||
saving={automationAction === `update:${automationPendingEdit?.id ?? ""}`}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setAutomationPendingEdit(null);
|
||||
}}
|
||||
onSave={handleAutomationEdit}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 flex-1 bg-settings-canvas [scrollbar-gutter:stable]",
|
||||
activeSection === "channels" ? "overflow-y-auto xl:overflow-hidden" : "overflow-y-auto",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
key={activeSection}
|
||||
data-testid="settings-section-transition"
|
||||
data-settings-section={activeSection}
|
||||
className={cn(
|
||||
"mx-auto w-full animate-in fade-in-0 slide-in-from-bottom-1 px-4 py-6 duration-200 ease-out",
|
||||
"motion-reduce:animate-none sm:px-8 sm:py-8 lg:py-12",
|
||||
activeSection === "channels" ? "max-w-[1240px] xl:px-10" : "max-w-[920px]",
|
||||
activeSection === "channels" && "flex min-h-full flex-col xl:h-full xl:min-h-0",
|
||||
hostChromeInset && "pt-[4.25rem] sm:pt-[4.25rem] lg:pt-[4.75rem]",
|
||||
)}
|
||||
>
|
||||
{!showSidebar ? (
|
||||
<div className="mb-7">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBackToChat}
|
||||
className="touch-target mb-4 inline-flex items-center gap-1.5 rounded-full px-2.5 py-1.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground lg:hidden"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" aria-hidden />
|
||||
{t("settings.backToChat")}
|
||||
</button>
|
||||
<h1 className="text-[24px] font-normal leading-tight tracking-normal text-foreground sm:text-[28px]">
|
||||
{t(`settings.nav.${activeSection}`, {
|
||||
defaultValue: standaloneSectionTitle(activeSection),
|
||||
})}
|
||||
</h1>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex h-48 items-center justify-center rounded-[22px] bg-settings-surface text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("settings.status.loading")}
|
||||
</div>
|
||||
) : error && !settings ? (
|
||||
<SettingsGroup>
|
||||
<SettingsRow title={t("settings.status.loadError")}>
|
||||
<span className="max-w-[520px] text-sm text-muted-foreground">{error}</span>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
) : settings ? (
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-5",
|
||||
activeSection === "channels" &&
|
||||
"flex min-h-0 flex-1 flex-col xl:overflow-hidden",
|
||||
)}
|
||||
>
|
||||
{error ? (
|
||||
<div className="rounded-[18px] border border-destructive/20 bg-destructive/5 px-4 py-3 text-[13px] text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{renderSection()}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useRef } from "react";
|
||||
import {
|
||||
Activity,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
Globe2,
|
||||
ImageIcon,
|
||||
LogOut,
|
||||
MessageCircle,
|
||||
Mic,
|
||||
Palette,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
SIDEBAR_SELECTION_ITEM_CLASS,
|
||||
SidebarSelectionHighlight,
|
||||
} from "@/components/SidebarSelectionHighlight";
|
||||
import type { SettingsSectionKey } from "@/components/settings/contracts";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SETTINGS_NAV_ITEMS: Array<{ key: SettingsSectionKey; icon: LucideIcon; fallback: string }> = [
|
||||
{ key: "overview", icon: Activity, fallback: "Overview" },
|
||||
{ key: "appearance", icon: Palette, fallback: "Appearance" },
|
||||
{ key: "models", icon: SlidersHorizontal, fallback: "Models" },
|
||||
{ key: "image", icon: ImageIcon, fallback: "Image" },
|
||||
{ key: "voice", icon: Mic, fallback: "Voice" },
|
||||
{ key: "browser", icon: Globe2, fallback: "Web" },
|
||||
{ key: "channels", icon: MessageCircle, fallback: "Channels" },
|
||||
{ key: "runtime", icon: Server, fallback: "System" },
|
||||
{ key: "advanced", icon: ShieldCheck, fallback: "Security" },
|
||||
];
|
||||
|
||||
export function standaloneSectionTitle(section: SettingsSectionKey): string {
|
||||
if (section === "apps") return "Apps";
|
||||
if (section === "automations") return "Automations";
|
||||
if (section === "skills") return "Skills";
|
||||
return SETTINGS_NAV_ITEMS.find((item) => item.key === section)?.fallback ?? "Settings";
|
||||
}
|
||||
|
||||
export function SettingsSidebar({
|
||||
activeSection,
|
||||
onSelectSection,
|
||||
onBackToChat,
|
||||
onLogout,
|
||||
hostChromeInset,
|
||||
}: {
|
||||
activeSection: SettingsSectionKey;
|
||||
onSelectSection: (section: SettingsSectionKey) => void;
|
||||
onBackToChat: () => void;
|
||||
onLogout?: () => void;
|
||||
hostChromeInset?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const activeNavItemRef = useRef<HTMLButtonElement>(null);
|
||||
const activeItem = SETTINGS_NAV_ITEMS.find((item) => item.key === activeSection)
|
||||
?? SETTINGS_NAV_ITEMS[0];
|
||||
const ActiveIcon = activeItem.icon;
|
||||
const activeLabel = t(`settings.nav.${activeItem.key}`, {
|
||||
defaultValue: activeItem.fallback,
|
||||
});
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"flex w-full shrink-0 flex-col bg-settings-surface px-3 pb-2 lg:w-[17rem] lg:px-3 lg:pb-4",
|
||||
hostChromeInset ? "pt-[4.25rem] lg:pt-[4.25rem]" : "pt-4 lg:pt-4",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBackToChat}
|
||||
className="touch-target mb-2 inline-flex w-fit items-center gap-1.5 rounded-full px-2.5 py-1.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground lg:mb-3"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" aria-hidden />
|
||||
{t("settings.backToChat")}
|
||||
</button>
|
||||
<div className="mb-3 px-1 lg:mb-4 lg:px-2">
|
||||
<h1 className="text-[18px] font-normal tracking-normal text-foreground">
|
||||
{t("settings.sidebar.title")}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
aria-label={t("settings.sidebar.ariaLabel")}
|
||||
className="w-full"
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t("settings.sidebar.title")}: ${activeLabel}`}
|
||||
className="touch-target flex h-11 w-full items-center gap-2.5 rounded-[14px] bg-sidebar-accent px-3 text-left text-[13px] font-medium text-foreground transition-colors hover:bg-sidebar-accent/80 lg:hidden"
|
||||
>
|
||||
<ActiveIcon className="h-4 w-4 shrink-0" strokeWidth={2} aria-hidden />
|
||||
<span className="min-w-0 flex-1 truncate">{activeLabel}</span>
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="w-[var(--radix-dropdown-menu-trigger-width)] max-w-[calc(100vw-1.5rem)]"
|
||||
>
|
||||
{SETTINGS_NAV_ITEMS.map(({ key, icon: Icon, fallback }) => {
|
||||
const active = key === activeSection;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={key}
|
||||
aria-current={active ? "page" : undefined}
|
||||
onSelect={() => onSelectSection(key)}
|
||||
className={cn(
|
||||
"flex h-10 cursor-default items-center gap-2.5 px-2.5 text-[13px] font-medium",
|
||||
active && "bg-sidebar-accent text-foreground focus:bg-sidebar-accent",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" strokeWidth={2} aria-hidden />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{t(`settings.nav.${key}`, { defaultValue: fallback })}
|
||||
</span>
|
||||
{active ? <Check className="h-4 w-4 shrink-0" aria-hidden /> : null}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<SidebarSelectionHighlight
|
||||
targetRef={activeNavItemRef}
|
||||
activeId={activeSection}
|
||||
scope="settings"
|
||||
className="relative hidden space-y-1 lg:block"
|
||||
>
|
||||
{SETTINGS_NAV_ITEMS.map(({ key, icon: Icon, fallback }) => {
|
||||
const active = key === activeSection;
|
||||
return (
|
||||
<button
|
||||
ref={active ? activeNavItemRef : undefined}
|
||||
key={key}
|
||||
type="button"
|
||||
aria-current={active ? "page" : undefined}
|
||||
onClick={() => onSelectSection(key)}
|
||||
className={cn(
|
||||
"touch-target flex h-9 w-full items-center gap-2 rounded-xl px-2.5 text-left text-[13px] font-medium",
|
||||
SIDEBAR_SELECTION_ITEM_CLASS,
|
||||
active
|
||||
? "text-sidebar-accent-foreground"
|
||||
: "text-muted-foreground/78 hover:bg-muted/45 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" strokeWidth={2} aria-hidden />
|
||||
<span className="truncate">
|
||||
{t(`settings.nav.${key}`, { defaultValue: fallback })}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</SidebarSelectionHighlight>
|
||||
</nav>
|
||||
|
||||
<div className="hidden lg:mt-auto lg:block lg:pt-4">
|
||||
{onLogout && !hostChromeInset ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onLogout}
|
||||
className="h-9 w-full justify-start gap-2 rounded-[10px] px-2.5 text-[13px] font-medium text-muted-foreground hover:bg-destructive/8 hover:text-destructive"
|
||||
>
|
||||
<LogOut className="h-4 w-4" aria-hidden />
|
||||
{t("app.account.logout")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,213 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ModelIdPicker, ProviderPicker, optionRowsWithCurrent } from "@/components/settings/shared/ModelControls";
|
||||
import {
|
||||
NumberInput,
|
||||
ReadOnlyRow,
|
||||
RestartSettingsFooter,
|
||||
SettingsGroup,
|
||||
SettingsRow,
|
||||
SettingsSectionTitle,
|
||||
StatusPill,
|
||||
} from "@/components/settings/shared/SettingsControls";
|
||||
import { ToggleButton } from "@/components/settings/ToggleButton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ImageGenerationSettingsUpdate, SettingsPayload } from "@/lib/types";
|
||||
|
||||
const IMAGE_ASPECT_RATIO_OPTIONS = ["1:1", "3:4", "9:16", "4:3", "16:9", "3:2", "2:3", "21:9"];
|
||||
const IMAGE_SIZE_OPTIONS = ["1K", "2K", "4K", "1024x1024", "1536x1024", "1024x1536"];
|
||||
|
||||
export const DEFAULT_IMAGE_GENERATION_FORM: ImageGenerationSettingsUpdate = {
|
||||
enabled: false,
|
||||
provider: "openrouter",
|
||||
model: "openai/gpt-5.4-image-2",
|
||||
defaultAspectRatio: "1:1",
|
||||
defaultImageSize: "1K",
|
||||
maxImagesPerTurn: 4,
|
||||
};
|
||||
|
||||
export function imageGenerationFormFromPayload(payload: SettingsPayload): ImageGenerationSettingsUpdate {
|
||||
return {
|
||||
enabled: payload.image_generation.enabled,
|
||||
provider: payload.image_generation.provider,
|
||||
model: payload.image_generation.model,
|
||||
defaultAspectRatio: payload.image_generation.default_aspect_ratio,
|
||||
defaultImageSize: payload.image_generation.default_image_size,
|
||||
maxImagesPerTurn: payload.image_generation.max_images_per_turn,
|
||||
};
|
||||
}
|
||||
|
||||
export function ImageGenerationSettings({
|
||||
token,
|
||||
settings,
|
||||
form,
|
||||
dirty,
|
||||
saving,
|
||||
onChangeForm,
|
||||
onSave,
|
||||
onOpenProviders,
|
||||
showBrandLogos,
|
||||
onRestart,
|
||||
isRestarting,
|
||||
requiresRestartPending,
|
||||
}: {
|
||||
token: string;
|
||||
settings: SettingsPayload;
|
||||
form: ImageGenerationSettingsUpdate;
|
||||
dirty: boolean;
|
||||
saving: boolean;
|
||||
onChangeForm: Dispatch<SetStateAction<ImageGenerationSettingsUpdate>>;
|
||||
onSave: () => void;
|
||||
onOpenProviders: () => void;
|
||||
showBrandLogos: boolean;
|
||||
onRestart?: () => void;
|
||||
isRestarting?: boolean;
|
||||
requiresRestartPending: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const selectedProvider =
|
||||
settings.image_generation.providers.find((provider) => provider.name === form.provider) ??
|
||||
settings.image_generation.providers[0];
|
||||
const providerConfigured = !!selectedProvider?.configured;
|
||||
const missingCredential = form.enabled && !providerConfigured;
|
||||
const aspectOptions = optionRowsWithCurrent(
|
||||
IMAGE_ASPECT_RATIO_OPTIONS.map((value) => ({ name: value, label: value })),
|
||||
form.defaultAspectRatio,
|
||||
);
|
||||
const sizeOptions = optionRowsWithCurrent(
|
||||
IMAGE_SIZE_OPTIONS.map((value) => ({ name: value, label: value })),
|
||||
form.defaultImageSize,
|
||||
);
|
||||
const selectProvider = (provider: string) => {
|
||||
const nextProvider = settings.image_generation.providers.find((row) => row.name === provider);
|
||||
onChangeForm((prev) => ({
|
||||
...prev,
|
||||
provider,
|
||||
model: nextProvider?.default_model || nextProvider?.models?.[0] || prev.model,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-7">
|
||||
<section>
|
||||
<SettingsSectionTitle>{tx("settings.sections.imageGeneration", "Image generation")}</SettingsSectionTitle>
|
||||
<SettingsGroup>
|
||||
<SettingsRow title={tx("settings.rows.imageGeneration", "Image generation")}>
|
||||
<ToggleButton
|
||||
checked={form.enabled}
|
||||
onChange={(enabled) => onChangeForm((prev) => ({ ...prev, enabled }))}
|
||||
ariaLabel={tx("settings.rows.imageGeneration", "Image generation")}
|
||||
label={form.enabled ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow title={tx("settings.rows.imageProvider", "Image provider")}>
|
||||
<ProviderPicker
|
||||
providers={settings.image_generation.providers}
|
||||
value={form.provider}
|
||||
emptyLabel={tx("settings.image.selectProvider", "Select provider")}
|
||||
showProviderLogos={showBrandLogos}
|
||||
onChange={selectProvider}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={tx("settings.rows.imageProviderStatus", "Provider status")}
|
||||
description={tx("settings.help.imageProviderStatus", "Image generation reuses provider credentials from Providers.")}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<StatusPill tone={providerConfigured ? "success" : "neutral"}>
|
||||
{providerConfigured
|
||||
? tx("settings.values.configured", "Configured")
|
||||
: tx("settings.values.notConfigured", "Not configured")}
|
||||
</StatusPill>
|
||||
{!providerConfigured ? (
|
||||
<Button size="sm" variant="outline" onClick={onOpenProviders} className="rounded-full">
|
||||
{tx("settings.image.configureProvider", "Configure provider")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</SettingsRow>
|
||||
<SettingsRow title={tx("settings.rows.imageProviderBase", "Provider base")}>
|
||||
<span className="max-w-[320px] truncate text-right text-[13px] text-muted-foreground">
|
||||
{selectedProvider?.api_base || selectedProvider?.default_api_base || selectedProvider?.name || tx("settings.values.notAvailable", "Not available")}
|
||||
</span>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SettingsSectionTitle>{tx("settings.sections.imageDefaults", "Defaults")}</SettingsSectionTitle>
|
||||
<SettingsGroup>
|
||||
<SettingsRow title={tx("settings.rows.imageModel", "Image model")}>
|
||||
<ModelIdPicker
|
||||
token={token}
|
||||
settings={settings}
|
||||
provider={form.provider}
|
||||
models={selectedProvider?.models ?? []}
|
||||
value={form.model}
|
||||
showProviderLogos={showBrandLogos}
|
||||
emptyLabel={tx("settings.image.selectModel", "Select image model")}
|
||||
searchPlaceholder={tx(
|
||||
"settings.image.searchOrTypeModel",
|
||||
"Search or type model ID",
|
||||
)}
|
||||
emptyMessage={tx(
|
||||
"settings.image.typeModelId",
|
||||
"Type the model ID supported by this provider.",
|
||||
)}
|
||||
onChange={(model) => onChangeForm((prev) => ({ ...prev, model }))}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow title={tx("settings.rows.defaultAspectRatio", "Default aspect")}>
|
||||
<ProviderPicker
|
||||
providers={aspectOptions}
|
||||
value={form.defaultAspectRatio}
|
||||
emptyLabel={tx("settings.image.selectAspect", "Select aspect")}
|
||||
onChange={(defaultAspectRatio) =>
|
||||
onChangeForm((prev) => ({ ...prev, defaultAspectRatio }))
|
||||
}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow title={tx("settings.rows.defaultImageSize", "Default size")}>
|
||||
<ProviderPicker
|
||||
providers={sizeOptions}
|
||||
value={form.defaultImageSize}
|
||||
emptyLabel={tx("settings.image.selectSize", "Select size")}
|
||||
onChange={(defaultImageSize) =>
|
||||
onChangeForm((prev) => ({ ...prev, defaultImageSize }))
|
||||
}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow title={tx("settings.rows.maxImagesPerTurn", "Max images per turn")}>
|
||||
<NumberInput
|
||||
value={form.maxImagesPerTurn}
|
||||
min={1}
|
||||
max={8}
|
||||
onChange={(maxImagesPerTurn) =>
|
||||
onChangeForm((prev) => ({ ...prev, maxImagesPerTurn }))
|
||||
}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<ReadOnlyRow title={tx("settings.rows.imageSaveDir", "Save directory")} value={settings.image_generation.save_dir} />
|
||||
<RestartSettingsFooter
|
||||
dirty={dirty}
|
||||
saving={saving}
|
||||
pendingRestart={requiresRestartPending}
|
||||
disabled={missingCredential}
|
||||
message={
|
||||
missingCredential
|
||||
? tx("settings.image.missingCredential", "Configure this provider before enabling image generation.")
|
||||
: undefined
|
||||
}
|
||||
dirtyMessage={tx("settings.status.restartAfterSaving", "Save changes, then restart when ready.")}
|
||||
pendingMessage={tx("settings.status.savedRestartApply", "Saved. Restart when ready.")}
|
||||
onSave={onSave}
|
||||
onRestart={onRestart}
|
||||
isRestarting={isRestarting}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
RestartSettingsFooter,
|
||||
SettingsGroup,
|
||||
SettingsRow,
|
||||
SettingsSectionTitle,
|
||||
} from "@/components/settings/shared/SettingsControls";
|
||||
import { ToggleButton } from "@/components/settings/ToggleButton";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import type {
|
||||
NetworkSafetySettingsUpdate,
|
||||
SettingsPayload,
|
||||
WebuiDefaultAccessMode,
|
||||
} from "@/lib/types";
|
||||
|
||||
export const DEFAULT_NETWORK_SAFETY_FORM: NetworkSafetySettingsUpdate = {
|
||||
webuiAllowLocalServiceAccess: true,
|
||||
webuiDefaultAccessMode: "default",
|
||||
};
|
||||
|
||||
export function networkSafetyFormFromPayload(payload: SettingsPayload): NetworkSafetySettingsUpdate {
|
||||
return {
|
||||
webuiAllowLocalServiceAccess:
|
||||
payload.advanced.webui_allow_local_service_access ??
|
||||
payload.advanced.allow_local_preview_access ??
|
||||
true,
|
||||
webuiDefaultAccessMode: visibleWebuiDefaultAccessMode(
|
||||
payload.advanced.webui_default_access_mode,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function visibleWebuiDefaultAccessMode(mode: string | null | undefined): WebuiDefaultAccessMode {
|
||||
return mode === "full" ? "full" : "default";
|
||||
}
|
||||
|
||||
export function AdvancedSettings({
|
||||
form,
|
||||
dirty,
|
||||
saving,
|
||||
requiresRestartPending,
|
||||
isNativeHostSurface,
|
||||
onChangeForm,
|
||||
onSave,
|
||||
onRestart,
|
||||
isRestarting,
|
||||
}: {
|
||||
form: NetworkSafetySettingsUpdate;
|
||||
dirty: boolean;
|
||||
saving: boolean;
|
||||
requiresRestartPending: boolean;
|
||||
isNativeHostSurface: boolean;
|
||||
onChangeForm: Dispatch<SetStateAction<NetworkSafetySettingsUpdate>>;
|
||||
onSave: () => void;
|
||||
onRestart?: () => void;
|
||||
isRestarting?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
return (
|
||||
<div className="space-y-7">
|
||||
<section>
|
||||
<SettingsSectionTitle>
|
||||
{isNativeHostSurface
|
||||
? tx("settings.sections.hostSafety", "App safety")
|
||||
: tx("settings.sections.webuiSafety", "Web safety")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsGroup>
|
||||
<SettingsRow
|
||||
title={tx("settings.rows.localServiceAccess", "Local Service Access")}
|
||||
description={tx(
|
||||
isNativeHostSurface ? "settings.help.localServiceAccessNative" : "settings.help.localServiceAccess",
|
||||
isNativeHostSurface
|
||||
? "Allow Full Access shell commands to reach services on this Mac."
|
||||
: "Allow Full Access shell commands to reach localhost services.",
|
||||
)}
|
||||
>
|
||||
<ToggleButton
|
||||
checked={form.webuiAllowLocalServiceAccess}
|
||||
onChange={(webuiAllowLocalServiceAccess) =>
|
||||
onChangeForm((prev) => ({ ...prev, webuiAllowLocalServiceAccess }))
|
||||
}
|
||||
ariaLabel={tx("settings.rows.localServiceAccess", "Local Service Access")}
|
||||
label={form.webuiAllowLocalServiceAccess ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={tx("settings.rows.webuiDefaultAccess", "Default access")}
|
||||
description={tx(
|
||||
isNativeHostSurface ? "settings.help.webuiDefaultAccessNative" : "settings.help.webuiDefaultAccess",
|
||||
isNativeHostSurface
|
||||
? "Used by native chats without a project-specific permission."
|
||||
: "Used by web chats without a project-specific permission.",
|
||||
)}
|
||||
>
|
||||
<SegmentedControl
|
||||
value={form.webuiDefaultAccessMode}
|
||||
options={[
|
||||
{ value: "default", label: tx("settings.values.defaultPermission", "Default Permission") },
|
||||
{ value: "full", label: tx("settings.values.fullAccess", "Full Access") },
|
||||
]}
|
||||
onChange={(webuiDefaultAccessMode) =>
|
||||
onChangeForm((prev) => ({
|
||||
...prev,
|
||||
webuiDefaultAccessMode: webuiDefaultAccessMode as WebuiDefaultAccessMode,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<RestartSettingsFooter
|
||||
dirty={dirty}
|
||||
saving={saving}
|
||||
pendingRestart={requiresRestartPending}
|
||||
onSave={onSave}
|
||||
onRestart={onRestart}
|
||||
isRestarting={isRestarting}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
|
||||
<p className="max-w-3xl px-1 text-sm leading-6 text-muted-foreground">
|
||||
{tx(
|
||||
"settings.help.securityManagedControls",
|
||||
"Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ProviderPicker } from "@/components/settings/shared/ModelControls";
|
||||
import {
|
||||
NumberInput,
|
||||
RestartSettingsFooter,
|
||||
SettingsGroup,
|
||||
SettingsRow,
|
||||
SettingsSectionTitle,
|
||||
StatusPill,
|
||||
} from "@/components/settings/shared/SettingsControls";
|
||||
import { ToggleButton } from "@/components/settings/ToggleButton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { SettingsPayload, TranscriptionSettingsUpdate } from "@/lib/types";
|
||||
|
||||
export const DEFAULT_TRANSCRIPTION_FORM: TranscriptionSettingsUpdate = {
|
||||
enabled: true,
|
||||
provider: "groq",
|
||||
model: "",
|
||||
language: "",
|
||||
maxDurationSec: 120,
|
||||
maxUploadMb: 25,
|
||||
};
|
||||
|
||||
export const DEFAULT_TRANSCRIPTION_SETTINGS: NonNullable<SettingsPayload["transcription"]> = {
|
||||
enabled: true,
|
||||
provider: "groq",
|
||||
provider_configured: false,
|
||||
model: "whisper-large-v3",
|
||||
language: null,
|
||||
max_duration_sec: 120,
|
||||
max_upload_mb: 25,
|
||||
providers: [],
|
||||
};
|
||||
|
||||
export function transcriptionFormFromPayload(payload: SettingsPayload): TranscriptionSettingsUpdate {
|
||||
const transcription = payload.transcription ?? DEFAULT_TRANSCRIPTION_SETTINGS;
|
||||
return {
|
||||
enabled: transcription.enabled,
|
||||
provider: transcription.provider,
|
||||
model: transcription.model,
|
||||
language: transcription.language ?? "",
|
||||
maxDurationSec: transcription.max_duration_sec,
|
||||
maxUploadMb: transcription.max_upload_mb,
|
||||
};
|
||||
}
|
||||
|
||||
export function TranscriptionSettings({
|
||||
settings,
|
||||
form,
|
||||
dirty,
|
||||
saving,
|
||||
onChangeForm,
|
||||
onSave,
|
||||
onOpenProviders,
|
||||
showBrandLogos,
|
||||
onRestart,
|
||||
isRestarting,
|
||||
requiresRestartPending,
|
||||
}: {
|
||||
settings: SettingsPayload;
|
||||
form: TranscriptionSettingsUpdate;
|
||||
dirty: boolean;
|
||||
saving: boolean;
|
||||
onChangeForm: Dispatch<SetStateAction<TranscriptionSettingsUpdate>>;
|
||||
onSave: () => void;
|
||||
onOpenProviders: () => void;
|
||||
showBrandLogos: boolean;
|
||||
onRestart?: () => void;
|
||||
isRestarting?: boolean;
|
||||
requiresRestartPending: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const transcription = settings.transcription ?? DEFAULT_TRANSCRIPTION_SETTINGS;
|
||||
const selectedProvider =
|
||||
transcription.providers.find((provider) => provider.name === form.provider) ??
|
||||
transcription.providers[0];
|
||||
const providerConfigured = !!selectedProvider?.configured;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SettingsSectionTitle>{tx("settings.sections.voiceInput", "Voice input")}</SettingsSectionTitle>
|
||||
<SettingsGroup>
|
||||
<SettingsRow
|
||||
title={tx("settings.rows.transcription", "Transcription")}
|
||||
description={tx("settings.help.transcription", "Transcribe microphone input before sending it. Chat channel voice messages use the same settings.")}
|
||||
>
|
||||
<ToggleButton
|
||||
checked={form.enabled}
|
||||
onChange={(enabled) => onChangeForm((prev) => ({ ...prev, enabled }))}
|
||||
ariaLabel={tx("settings.rows.transcription", "Transcription")}
|
||||
label={form.enabled ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow title={tx("settings.rows.transcriptionProvider", "Provider")}>
|
||||
<ProviderPicker
|
||||
providers={transcription.providers}
|
||||
value={form.provider}
|
||||
emptyLabel={tx("settings.voice.selectProvider", "Select provider")}
|
||||
showProviderLogos={showBrandLogos}
|
||||
onChange={(provider) => onChangeForm((prev) => ({ ...prev, provider }))}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={tx("settings.rows.transcriptionProviderStatus", "Provider status")}
|
||||
description={tx("settings.help.transcriptionProviderStatus", "API keys stay under providers, not in transcription settings.")}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<StatusPill tone={providerConfigured ? "success" : "neutral"}>
|
||||
{providerConfigured
|
||||
? tx("settings.values.configured", "Configured")
|
||||
: tx("settings.values.notConfigured", "Not configured")}
|
||||
</StatusPill>
|
||||
{!providerConfigured ? (
|
||||
<Button size="sm" variant="outline" onClick={onOpenProviders} className="rounded-full">
|
||||
{tx("settings.voice.configureProvider", "Configure provider")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={tx("settings.rows.transcriptionModel", "Model")}
|
||||
description={tx("settings.help.transcriptionModel", "Leave as the resolved default unless your provider needs a custom model id.")}
|
||||
>
|
||||
<Input
|
||||
value={form.model}
|
||||
onChange={(event) => onChangeForm((prev) => ({ ...prev, model: event.target.value }))}
|
||||
className="h-8 w-[min(300px,70vw)] rounded-full text-[13px]"
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={tx("settings.rows.transcriptionLanguage", "Language")}
|
||||
description={tx("settings.help.transcriptionLanguage", "Optional ISO-639 hint such as en, zh, ja, or ko.")}
|
||||
>
|
||||
<Input
|
||||
value={form.language}
|
||||
onChange={(event) => onChangeForm((prev) => ({ ...prev, language: event.target.value }))}
|
||||
placeholder={tx("settings.voice.languageAuto", "Auto")}
|
||||
className="h-8 w-[min(180px,60vw)] rounded-full text-[13px]"
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow title={tx("settings.rows.voiceLimits", "Limits")}>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<NumberInput
|
||||
value={form.maxDurationSec}
|
||||
min={1}
|
||||
max={600}
|
||||
suffix="s"
|
||||
onChange={(maxDurationSec) => onChangeForm((prev) => ({ ...prev, maxDurationSec }))}
|
||||
/>
|
||||
<NumberInput
|
||||
value={form.maxUploadMb}
|
||||
min={1}
|
||||
max={100}
|
||||
suffix="MB"
|
||||
onChange={(maxUploadMb) => onChangeForm((prev) => ({ ...prev, maxUploadMb }))}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
<RestartSettingsFooter
|
||||
dirty={dirty}
|
||||
saving={saving}
|
||||
pendingRestart={requiresRestartPending}
|
||||
dirtyMessage={tx("settings.status.restartAfterSaving", "Save changes, then restart when ready.")}
|
||||
pendingMessage={tx("settings.status.savedRestartApply", "Saved. Restart when ready.")}
|
||||
onSave={onSave}
|
||||
onRestart={onRestart}
|
||||
isRestarting={isRestarting}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { Eye, EyeOff, Pencil } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ProviderPicker } from "@/components/settings/shared/ModelControls";
|
||||
import {
|
||||
CapabilityInstallNotice,
|
||||
NumberInput,
|
||||
RestartSettingsFooter,
|
||||
SettingsGroup,
|
||||
SettingsRow,
|
||||
SettingsSectionTitle,
|
||||
StatusPill,
|
||||
} from "@/components/settings/shared/SettingsControls";
|
||||
import { ToggleButton } from "@/components/settings/ToggleButton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type {
|
||||
NanobotFeatureInfo,
|
||||
SettingsPayload,
|
||||
WebSearchSettingsUpdate,
|
||||
} from "@/lib/types";
|
||||
|
||||
export const DEFAULT_WEB_SEARCH_FORM: WebSearchSettingsUpdate = {
|
||||
provider: "duckduckgo",
|
||||
apiKey: "",
|
||||
baseUrl: "",
|
||||
maxResults: 5,
|
||||
timeout: 30,
|
||||
useJinaReader: true,
|
||||
};
|
||||
|
||||
export function webSearchFormFromPayload(
|
||||
payload: SettingsPayload,
|
||||
previous?: WebSearchSettingsUpdate,
|
||||
): WebSearchSettingsUpdate {
|
||||
return {
|
||||
provider: payload.web_search.provider,
|
||||
apiKey: previous?.provider === payload.web_search.provider ? previous.apiKey ?? "" : "",
|
||||
baseUrl: payload.web_search.base_url ?? "",
|
||||
maxResults: payload.web_search.max_results,
|
||||
timeout: payload.web_search.timeout,
|
||||
useJinaReader: payload.web.fetch.use_jina_reader,
|
||||
};
|
||||
}
|
||||
|
||||
type WebSearchProviderOption = SettingsPayload["web_search"]["providers"][number];
|
||||
|
||||
export function webSearchProviderAcceptsApiKey(provider?: WebSearchProviderOption): boolean {
|
||||
return provider?.credential === "api_key" || provider?.credential === "optional_api_key";
|
||||
}
|
||||
|
||||
export function webSearchProviderRequiresApiKey(provider?: WebSearchProviderOption): boolean {
|
||||
return provider?.credential === "api_key";
|
||||
}
|
||||
|
||||
export function WebSettings({
|
||||
settings,
|
||||
form,
|
||||
keyVisible,
|
||||
keyEditing,
|
||||
saving,
|
||||
onChangeForm,
|
||||
onChangeProvider,
|
||||
onToggleKey,
|
||||
onToggleKeyEditing,
|
||||
onReset,
|
||||
onSave,
|
||||
showBrandLogos,
|
||||
onRestart,
|
||||
isRestarting,
|
||||
requiresRestartPending,
|
||||
olostepFeature,
|
||||
olostepInstalling,
|
||||
capabilityError,
|
||||
}: {
|
||||
settings: SettingsPayload;
|
||||
form: WebSearchSettingsUpdate;
|
||||
keyVisible: boolean;
|
||||
keyEditing: boolean;
|
||||
saving: boolean;
|
||||
onChangeForm: Dispatch<SetStateAction<WebSearchSettingsUpdate>>;
|
||||
onChangeProvider: (provider: string) => void;
|
||||
onToggleKey: () => void;
|
||||
onToggleKeyEditing: () => void;
|
||||
onReset: () => void;
|
||||
onSave: () => void;
|
||||
showBrandLogos: boolean;
|
||||
onRestart?: () => void;
|
||||
isRestarting?: boolean;
|
||||
requiresRestartPending: boolean;
|
||||
olostepFeature?: NanobotFeatureInfo;
|
||||
olostepInstalling: boolean;
|
||||
capabilityError: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const selectedProvider =
|
||||
settings.web_search.providers.find((provider) => provider.name === form.provider) ??
|
||||
settings.web_search.providers[0];
|
||||
const hasExistingSecret =
|
||||
webSearchProviderAcceptsApiKey(selectedProvider) &&
|
||||
form.provider === settings.web_search.provider &&
|
||||
!!settings.web_search.api_key_hint;
|
||||
const showKeyInput = webSearchProviderAcceptsApiKey(selectedProvider) && (!hasExistingSecret || keyEditing);
|
||||
const apiKey = form.apiKey?.trim() ?? "";
|
||||
const baseUrl = form.baseUrl?.trim() ?? "";
|
||||
const effectiveJinaReader = form.useJinaReader ?? settings.web.fetch.use_jina_reader;
|
||||
const dirty =
|
||||
form.provider !== settings.web_search.provider ||
|
||||
apiKey.length > 0 ||
|
||||
baseUrl !== (settings.web_search.base_url ?? "") ||
|
||||
form.maxResults !== settings.web_search.max_results ||
|
||||
form.timeout !== settings.web_search.timeout ||
|
||||
effectiveJinaReader !== settings.web.fetch.use_jina_reader;
|
||||
const jinaReaderDirty = effectiveJinaReader !== settings.web.fetch.use_jina_reader;
|
||||
const missingCredential =
|
||||
webSearchProviderRequiresApiKey(selectedProvider)
|
||||
? !apiKey && !hasExistingSecret
|
||||
: selectedProvider?.credential === "base_url"
|
||||
? !baseUrl
|
||||
: false;
|
||||
|
||||
return (
|
||||
<div className="space-y-7">
|
||||
<section>
|
||||
<SettingsSectionTitle>{tx("settings.sections.webSearch", "Web search")}</SettingsSectionTitle>
|
||||
{form.provider === "olostep" && olostepFeature && !olostepFeature.installed ? (
|
||||
<div className="mb-3">
|
||||
<CapabilityInstallNotice
|
||||
title={tx("settings.capabilities.searchSupport", "Search provider support")}
|
||||
description={tx(
|
||||
"settings.capabilities.searchInstallOnSave",
|
||||
"Olostep support will be installed automatically when you save.",
|
||||
)}
|
||||
installing={olostepInstalling}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{capabilityError ? (
|
||||
<p className="mb-3 text-[12px] text-destructive">{capabilityError}</p>
|
||||
) : null}
|
||||
<SettingsGroup>
|
||||
<SettingsRow title={t("settings.byok.webSearch.provider")}>
|
||||
<ProviderPicker
|
||||
providers={settings.web_search.providers}
|
||||
value={form.provider}
|
||||
emptyLabel={t("settings.byok.webSearch.selectProvider")}
|
||||
showProviderLogos={showBrandLogos}
|
||||
onChange={onChangeProvider}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
{selectedProvider?.credential === "none" ? (
|
||||
<SettingsRow title={t("settings.byok.webSearch.credentials")}>
|
||||
<StatusPill tone="success">{t("settings.byok.webSearch.noCredentialRequired")}</StatusPill>
|
||||
</SettingsRow>
|
||||
) : null}
|
||||
|
||||
{webSearchProviderAcceptsApiKey(selectedProvider) ? (
|
||||
<SettingsRow
|
||||
title={t("settings.byok.apiKey")}
|
||||
description={t("settings.byok.webSearch.apiKeyHelp")}
|
||||
>
|
||||
<div className="relative w-[280px] max-w-full">
|
||||
{showKeyInput ? (
|
||||
<>
|
||||
<Input
|
||||
type={keyVisible ? "text" : "password"}
|
||||
value={form.apiKey ?? ""}
|
||||
onChange={(event) =>
|
||||
onChangeForm((prev) => ({ ...prev, apiKey: event.target.value }))
|
||||
}
|
||||
placeholder={
|
||||
hasExistingSecret
|
||||
? t("settings.byok.apiKeyConfiguredPlaceholder")
|
||||
: t("settings.byok.apiKeyPlaceholder")
|
||||
}
|
||||
className="h-9 rounded-full pr-11 text-[13px]"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onToggleKey}
|
||||
aria-label={
|
||||
keyVisible ? t("settings.byok.hideApiKey") : t("settings.byok.showApiKey")
|
||||
}
|
||||
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
{keyVisible ? (
|
||||
<EyeOff className="h-3.5 w-3.5" aria-hidden />
|
||||
) : (
|
||||
<Eye className="h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex h-9 items-center rounded-full border border-input bg-background px-3 pr-11 text-[13px] text-muted-foreground">
|
||||
{settings.web_search.api_key_hint ?? t("settings.byok.configuredKeyHint")}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onToggleKeyEditing}
|
||||
aria-label={t("settings.actions.edit")}
|
||||
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" aria-hidden />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SettingsRow>
|
||||
) : null}
|
||||
|
||||
{selectedProvider?.credential === "base_url" ? (
|
||||
<SettingsRow
|
||||
title={t("settings.byok.webSearch.baseUrl")}
|
||||
description={t("settings.byok.webSearch.baseUrlHelp")}
|
||||
>
|
||||
<Input
|
||||
value={form.baseUrl ?? ""}
|
||||
onChange={(event) =>
|
||||
onChangeForm((prev) => ({ ...prev, baseUrl: event.target.value }))
|
||||
}
|
||||
placeholder={t("settings.byok.webSearch.baseUrlPlaceholder")}
|
||||
className="h-9 w-[280px] rounded-full text-[13px]"
|
||||
/>
|
||||
</SettingsRow>
|
||||
) : null}
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SettingsSectionTitle>{tx("settings.sections.webBehavior", "Behavior")}</SettingsSectionTitle>
|
||||
<SettingsGroup>
|
||||
<SettingsRow title={tx("settings.rows.maxResults", "Max results")}>
|
||||
<NumberInput
|
||||
value={form.maxResults ?? settings.web_search.max_results}
|
||||
min={1}
|
||||
max={10}
|
||||
onChange={(maxResults) => onChangeForm((prev) => ({ ...prev, maxResults }))}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow title={tx("settings.rows.timeout", "Timeout")}>
|
||||
<NumberInput
|
||||
value={form.timeout ?? settings.web_search.timeout}
|
||||
min={1}
|
||||
max={120}
|
||||
onChange={(timeout) => onChangeForm((prev) => ({ ...prev, timeout }))}
|
||||
suffix="s"
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={tx("settings.rows.jinaReader", "Jina reader")}
|
||||
description={tx("settings.help.jinaReader", "Use Jina Reader for web_fetch when available.")}
|
||||
>
|
||||
<ToggleButton
|
||||
checked={effectiveJinaReader}
|
||||
onChange={(useJinaReader) => onChangeForm((prev) => ({ ...prev, useJinaReader }))}
|
||||
ariaLabel={tx("settings.rows.jinaReader", "Jina reader")}
|
||||
label={effectiveJinaReader ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<RestartSettingsFooter
|
||||
dirty={dirty}
|
||||
saving={saving}
|
||||
pendingRestart={requiresRestartPending}
|
||||
disabled={missingCredential}
|
||||
message={
|
||||
missingCredential
|
||||
? t("settings.byok.webSearch.missingCredential")
|
||||
: requiresRestartPending && !dirty
|
||||
? tx("settings.status.savedRestartApply", "Saved. Restart when ready.")
|
||||
: jinaReaderDirty
|
||||
? tx("settings.status.restartAfterSaving", "Save changes, then restart when ready.")
|
||||
: dirty
|
||||
? t("settings.byok.webSearch.saveHint")
|
||||
: undefined
|
||||
}
|
||||
onSave={onSave}
|
||||
onRestart={onRestart}
|
||||
onReset={onReset}
|
||||
isRestarting={isRestarting}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useCallback, type Dispatch, type SetStateAction } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
import {
|
||||
webSearchProviderAcceptsApiKey,
|
||||
webSearchProviderRequiresApiKey,
|
||||
} from "@/components/settings/capabilities/WebSettings";
|
||||
import type { CapabilitySettingsState } from "@/components/settings/capabilities/useCapabilitySettingsState";
|
||||
import type {
|
||||
ApplySettingsPayload,
|
||||
MaybeRestartHostEngine,
|
||||
PendingRestartSections,
|
||||
} from "@/components/settings/contracts";
|
||||
import {
|
||||
updateImageGenerationSettings,
|
||||
updateNetworkSafetySettings,
|
||||
updateTranscriptionSettings,
|
||||
updateWebSearchSettings,
|
||||
} from "@/lib/api";
|
||||
import type { NanobotClient } from "@/lib/nanobot-client";
|
||||
import type { SettingsPayload, WebSearchSettingsUpdate } from "@/lib/types";
|
||||
|
||||
interface CapabilitySettingsActionsOptions {
|
||||
state: CapabilitySettingsState;
|
||||
settings: SettingsPayload | null;
|
||||
client: NanobotClient;
|
||||
t: TFunction;
|
||||
applyPayload: ApplySettingsPayload;
|
||||
maybeRestartHostEngine: MaybeRestartHostEngine;
|
||||
setPendingRestartSections: Dispatch<SetStateAction<PendingRestartSections>>;
|
||||
setError: Dispatch<SetStateAction<string | null>>;
|
||||
installCapabilities: (names: string[]) => Promise<boolean>;
|
||||
imageGenerationDirty: boolean;
|
||||
transcriptionDirty: boolean;
|
||||
networkSafetyDirty: boolean;
|
||||
}
|
||||
|
||||
export function useCapabilitySettingsActions({
|
||||
state,
|
||||
settings,
|
||||
client,
|
||||
t,
|
||||
applyPayload,
|
||||
maybeRestartHostEngine,
|
||||
setPendingRestartSections,
|
||||
setError,
|
||||
installCapabilities,
|
||||
imageGenerationDirty,
|
||||
transcriptionDirty,
|
||||
networkSafetyDirty,
|
||||
}: CapabilitySettingsActionsOptions) {
|
||||
const {
|
||||
imageGenerationForm,
|
||||
imageGenerationSaving,
|
||||
networkSafetyForm,
|
||||
networkSafetySaving,
|
||||
setImageGenerationSaving,
|
||||
setNetworkSafetySaving,
|
||||
setTranscriptionSaving,
|
||||
setWebSearchForm,
|
||||
setWebSearchKeyEditing,
|
||||
setWebSearchKeyVisible,
|
||||
setWebSearchSaving,
|
||||
transcriptionForm,
|
||||
transcriptionSaving,
|
||||
webSearchForm,
|
||||
webSearchKeyEditing,
|
||||
webSearchSaving,
|
||||
} = state;
|
||||
|
||||
const saveImageGenerationSettings = async () => {
|
||||
if (!settings || !imageGenerationDirty || imageGenerationSaving) return;
|
||||
setImageGenerationSaving(true);
|
||||
try {
|
||||
const payload = await updateImageGenerationSettings(client, imageGenerationForm);
|
||||
applyPayload(payload);
|
||||
if (payload.requires_restart) {
|
||||
setPendingRestartSections((prev) => ({ ...prev, image: true }));
|
||||
}
|
||||
await maybeRestartHostEngine(payload);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setImageGenerationSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveTranscriptionSettings = async () => {
|
||||
if (!settings || !transcriptionDirty || transcriptionSaving) return;
|
||||
setTranscriptionSaving(true);
|
||||
try {
|
||||
const payload = await updateTranscriptionSettings(client, transcriptionForm);
|
||||
applyPayload(payload);
|
||||
if (payload.requires_restart) {
|
||||
setPendingRestartSections((prev) => ({ ...prev, browser: true }));
|
||||
}
|
||||
await maybeRestartHostEngine(payload);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setTranscriptionSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveNetworkSafetySettings = async () => {
|
||||
if (!settings || !networkSafetyDirty || networkSafetySaving) return;
|
||||
setNetworkSafetySaving(true);
|
||||
try {
|
||||
const payload = await updateNetworkSafetySettings(client, networkSafetyForm);
|
||||
applyPayload(payload);
|
||||
if (payload.requires_restart) {
|
||||
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
|
||||
}
|
||||
await maybeRestartHostEngine(payload);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setNetworkSafetySaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveWebSearch = async () => {
|
||||
if (!settings || webSearchSaving) return;
|
||||
const provider = settings.web_search.providers.find((item) => item.name === webSearchForm.provider);
|
||||
if (!provider) return;
|
||||
const apiKey = webSearchForm.apiKey?.trim() ?? "";
|
||||
const baseUrl = webSearchForm.baseUrl?.trim() ?? "";
|
||||
const hasExistingSecret =
|
||||
webSearchProviderAcceptsApiKey(provider) &&
|
||||
webSearchForm.provider === settings.web_search.provider &&
|
||||
!!settings.web_search.api_key_hint;
|
||||
|
||||
if (webSearchProviderRequiresApiKey(provider) && !apiKey && !hasExistingSecret) {
|
||||
setError(t("settings.byok.webSearch.apiKeyRequired"));
|
||||
return;
|
||||
}
|
||||
if (provider.credential === "base_url" && !baseUrl) {
|
||||
setError(t("settings.byok.webSearch.baseUrlRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setWebSearchSaving(true);
|
||||
try {
|
||||
if (provider.name === "olostep" && !(await installCapabilities(["olostep"]))) return;
|
||||
const webFetchRestartRequired =
|
||||
(webSearchForm.useJinaReader ?? settings.web.fetch.use_jina_reader) !==
|
||||
settings.web.fetch.use_jina_reader;
|
||||
const update: WebSearchSettingsUpdate = {
|
||||
provider: webSearchForm.provider,
|
||||
maxResults: webSearchForm.maxResults,
|
||||
timeout: webSearchForm.timeout,
|
||||
useJinaReader: webSearchForm.useJinaReader,
|
||||
};
|
||||
if (
|
||||
webSearchProviderAcceptsApiKey(provider) &&
|
||||
(apiKey || (provider.credential === "optional_api_key" && webSearchKeyEditing))
|
||||
) {
|
||||
update.apiKey = apiKey;
|
||||
}
|
||||
if (provider.credential === "base_url") update.baseUrl = baseUrl;
|
||||
const payload = await updateWebSearchSettings(client, update);
|
||||
applyPayload(payload);
|
||||
if (payload.requires_restart || webFetchRestartRequired) {
|
||||
setPendingRestartSections((prev) => ({ ...prev, browser: true }));
|
||||
}
|
||||
await maybeRestartHostEngine(payload);
|
||||
setWebSearchForm((prev) => ({
|
||||
provider: payload.web_search.provider,
|
||||
apiKey: "",
|
||||
baseUrl: payload.web_search.base_url ?? prev.baseUrl ?? "",
|
||||
maxResults: payload.web_search.max_results,
|
||||
timeout: payload.web_search.timeout,
|
||||
useJinaReader: payload.web.fetch.use_jina_reader,
|
||||
}));
|
||||
setWebSearchKeyVisible(false);
|
||||
setWebSearchKeyEditing(false);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setWebSearchSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetWebSearchDraft = useCallback(() => {
|
||||
if (!settings) return;
|
||||
setWebSearchForm({
|
||||
provider: settings.web_search.provider,
|
||||
apiKey: "",
|
||||
baseUrl: settings.web_search.base_url ?? "",
|
||||
maxResults: settings.web_search.max_results,
|
||||
timeout: settings.web_search.timeout,
|
||||
useJinaReader: settings.web.fetch.use_jina_reader,
|
||||
});
|
||||
setWebSearchKeyVisible(false);
|
||||
setWebSearchKeyEditing(false);
|
||||
}, [settings]);
|
||||
|
||||
const handleWebSearchProviderChange = useCallback((provider: string) => {
|
||||
if (!settings) return;
|
||||
setWebSearchForm((prev) => ({
|
||||
provider,
|
||||
apiKey: "",
|
||||
baseUrl: provider === settings.web_search.provider ? settings.web_search.base_url ?? "" : "",
|
||||
maxResults: prev.maxResults ?? settings.web_search.max_results,
|
||||
timeout: prev.timeout ?? settings.web_search.timeout,
|
||||
useJinaReader: prev.useJinaReader ?? settings.web.fetch.use_jina_reader,
|
||||
}));
|
||||
setWebSearchKeyVisible(false);
|
||||
setWebSearchKeyEditing(false);
|
||||
}, [settings]);
|
||||
|
||||
return {
|
||||
handleWebSearchProviderChange,
|
||||
resetWebSearchDraft,
|
||||
saveImageGenerationSettings,
|
||||
saveNetworkSafetySettings,
|
||||
saveTranscriptionSettings,
|
||||
saveWebSearch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
DEFAULT_IMAGE_GENERATION_FORM,
|
||||
imageGenerationFormFromPayload,
|
||||
} from "@/components/settings/capabilities/ImageGenerationSettings";
|
||||
import {
|
||||
DEFAULT_NETWORK_SAFETY_FORM,
|
||||
networkSafetyFormFromPayload,
|
||||
} from "@/components/settings/capabilities/SecuritySettings";
|
||||
import {
|
||||
DEFAULT_TRANSCRIPTION_FORM,
|
||||
transcriptionFormFromPayload,
|
||||
} from "@/components/settings/capabilities/TranscriptionSettings";
|
||||
import {
|
||||
DEFAULT_WEB_SEARCH_FORM,
|
||||
webSearchFormFromPayload,
|
||||
} from "@/components/settings/capabilities/WebSettings";
|
||||
import type {
|
||||
ImageGenerationSettingsUpdate,
|
||||
NetworkSafetySettingsUpdate,
|
||||
SettingsPayload,
|
||||
TranscriptionSettingsUpdate,
|
||||
WebSearchSettingsUpdate,
|
||||
} from "@/lib/types";
|
||||
|
||||
export function useCapabilitySettingsState(initialSettings: SettingsPayload | null) {
|
||||
const [webSearchSaving, setWebSearchSaving] = useState(false);
|
||||
const [imageGenerationSaving, setImageGenerationSaving] = useState(false);
|
||||
const [transcriptionSaving, setTranscriptionSaving] = useState(false);
|
||||
const [networkSafetySaving, setNetworkSafetySaving] = useState(false);
|
||||
const [webSearchForm, setWebSearchForm] = useState<WebSearchSettingsUpdate>(() =>
|
||||
initialSettings ? webSearchFormFromPayload(initialSettings) : DEFAULT_WEB_SEARCH_FORM,
|
||||
);
|
||||
const [imageGenerationForm, setImageGenerationForm] = useState<ImageGenerationSettingsUpdate>(
|
||||
() => initialSettings
|
||||
? imageGenerationFormFromPayload(initialSettings)
|
||||
: DEFAULT_IMAGE_GENERATION_FORM,
|
||||
);
|
||||
const [transcriptionForm, setTranscriptionForm] = useState<TranscriptionSettingsUpdate>(
|
||||
() => initialSettings ? transcriptionFormFromPayload(initialSettings) : DEFAULT_TRANSCRIPTION_FORM,
|
||||
);
|
||||
const [networkSafetyForm, setNetworkSafetyForm] = useState<NetworkSafetySettingsUpdate>(() =>
|
||||
initialSettings ? networkSafetyFormFromPayload(initialSettings) : DEFAULT_NETWORK_SAFETY_FORM,
|
||||
);
|
||||
const [webSearchKeyVisible, setWebSearchKeyVisible] = useState(false);
|
||||
const [webSearchKeyEditing, setWebSearchKeyEditing] = useState(false);
|
||||
|
||||
return {
|
||||
imageGenerationForm,
|
||||
imageGenerationSaving,
|
||||
networkSafetyForm,
|
||||
networkSafetySaving,
|
||||
setImageGenerationForm,
|
||||
setImageGenerationSaving,
|
||||
setNetworkSafetyForm,
|
||||
setNetworkSafetySaving,
|
||||
setTranscriptionForm,
|
||||
setTranscriptionSaving,
|
||||
setWebSearchForm,
|
||||
setWebSearchKeyEditing,
|
||||
setWebSearchKeyVisible,
|
||||
setWebSearchSaving,
|
||||
transcriptionForm,
|
||||
transcriptionSaving,
|
||||
webSearchForm,
|
||||
webSearchKeyEditing,
|
||||
webSearchKeyVisible,
|
||||
webSearchSaving,
|
||||
};
|
||||
}
|
||||
|
||||
export type CapabilitySettingsState = ReturnType<typeof useCapabilitySettingsState>;
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { SettingsPayload } from "@/lib/types";
|
||||
|
||||
export type SettingsSectionKey =
|
||||
| "overview"
|
||||
| "appearance"
|
||||
| "models"
|
||||
| "image"
|
||||
| "voice"
|
||||
| "browser"
|
||||
| "channels"
|
||||
| "apps"
|
||||
| "automations"
|
||||
| "skills"
|
||||
| "runtime"
|
||||
| "advanced";
|
||||
|
||||
export type PendingRestartSection = "runtime" | "browser" | "image";
|
||||
export type PendingRestartSections = Record<PendingRestartSection, boolean>;
|
||||
|
||||
export type RestartAwarePayload = {
|
||||
requires_restart?: boolean;
|
||||
surface?: SettingsPayload["surface"];
|
||||
runtime_surface?: SettingsPayload["runtime_surface"];
|
||||
runtime_capabilities?: SettingsPayload["runtime_capabilities"];
|
||||
};
|
||||
|
||||
export type ApplySettingsPayload = (
|
||||
payload: SettingsPayload,
|
||||
options?: { preserveAgentForm?: boolean },
|
||||
) => void;
|
||||
|
||||
export type MaybeRestartHostEngine = (payload: RestartAwarePayload) => Promise<void>;
|
||||
@@ -0,0 +1,923 @@
|
||||
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
GripVertical,
|
||||
ListOrdered,
|
||||
Loader2,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
ModelIdPicker,
|
||||
ProviderPicker,
|
||||
ProviderPickerIcon,
|
||||
formatContextWindow,
|
||||
formatModelContextWindow,
|
||||
normalizeContextWindowTokens,
|
||||
settingsProviderConfigured,
|
||||
} from "@/components/settings/shared/ModelControls";
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsRow,
|
||||
SettingsSectionTitle,
|
||||
SettingsStatusMessage,
|
||||
StatusPill,
|
||||
} from "@/components/settings/shared/SettingsControls";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SettingsPayload } from "@/lib/types";
|
||||
|
||||
export interface AgentSettingsDraft {
|
||||
model: string;
|
||||
provider: string;
|
||||
modelPreset: string;
|
||||
presetLabel: string;
|
||||
maxTokens: number;
|
||||
contextWindowTokens: number;
|
||||
temperature: number;
|
||||
reasoningEffort: string;
|
||||
timezone: string;
|
||||
toolHintMaxLength: number;
|
||||
}
|
||||
|
||||
const CONTEXT_WINDOW_TOKEN_OPTIONS = [65_536, 200_000, 262_144, 500_000, 1_048_576] as const;
|
||||
|
||||
function modelPresetValue(payload: SettingsPayload): string {
|
||||
return (
|
||||
payload.model_call_order?.[0] ??
|
||||
payload.model_presets.find((preset) => !preset.is_default)?.name ??
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
export const DEFAULT_AGENT_SETTINGS_DRAFT: AgentSettingsDraft = {
|
||||
model: "",
|
||||
provider: "",
|
||||
modelPreset: "",
|
||||
presetLabel: "",
|
||||
maxTokens: 8192,
|
||||
contextWindowTokens: 200_000,
|
||||
temperature: 0.1,
|
||||
reasoningEffort: "",
|
||||
timezone: "UTC",
|
||||
toolHintMaxLength: 40,
|
||||
};
|
||||
|
||||
export function agentDraftFromPayload(
|
||||
payload: SettingsPayload,
|
||||
preferredPresetName?: string,
|
||||
): AgentSettingsDraft {
|
||||
const activePresetName = preferredPresetName ?? modelPresetValue(payload);
|
||||
const activePreset =
|
||||
payload.model_presets.find(
|
||||
(preset) => !preset.is_default && preset.name === activePresetName,
|
||||
) ?? null;
|
||||
return {
|
||||
model: activePreset?.model ?? payload.agent.model,
|
||||
provider: activePreset?.provider ?? payload.agent.provider ?? payload.agent.resolved_provider ?? "",
|
||||
modelPreset: activePresetName,
|
||||
presetLabel: activePreset?.label ?? activePresetName,
|
||||
maxTokens: activePreset?.max_tokens ?? payload.agent.max_tokens,
|
||||
contextWindowTokens: normalizeContextWindowTokens(
|
||||
activePreset?.context_window_tokens ?? payload.agent.context_window_tokens,
|
||||
),
|
||||
temperature: activePreset?.temperature ?? payload.agent.temperature,
|
||||
reasoningEffort: activePreset?.reasoning_effort ?? "",
|
||||
timezone: payload.agent.timezone,
|
||||
toolHintMaxLength: payload.agent.tool_hint_max_length,
|
||||
};
|
||||
}
|
||||
|
||||
export function ModelPresetDeleteDialog({
|
||||
preset,
|
||||
deleting,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: {
|
||||
preset: SettingsPayload["model_presets"][number] | null;
|
||||
deleting: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
|
||||
t(key, { defaultValue: fallback, ...(values ?? {}) });
|
||||
return (
|
||||
<Dialog open={preset !== null} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[440px] rounded-[24px]">
|
||||
<DialogHeader className="text-left">
|
||||
<DialogTitle>
|
||||
{tx("settings.models.deletePresetTitle", "Delete model preset?")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="leading-5">
|
||||
{tx(
|
||||
"settings.models.deletePresetHelp",
|
||||
"This removes the preset “{{name}}”. Provider credentials are not affected.",
|
||||
{ name: preset?.label ?? "" },
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2 sm:space-x-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="rounded-full"
|
||||
disabled={deleting}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{tx("settings.actions.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="rounded-full"
|
||||
disabled={deleting}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{deleting ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : null}
|
||||
{deleting
|
||||
? tx("settings.actions.deleting", "Deleting...")
|
||||
: tx("settings.actions.delete", "Delete")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelsSettings({
|
||||
token,
|
||||
form,
|
||||
setForm,
|
||||
settings,
|
||||
dirty,
|
||||
creating,
|
||||
creatingSaving,
|
||||
callOrder,
|
||||
saving,
|
||||
orderSaving,
|
||||
migrationSaving,
|
||||
showBrandLogos,
|
||||
providerSaving,
|
||||
onChangeCallOrder,
|
||||
onProviderOAuthLogin,
|
||||
onSave,
|
||||
onMigrate,
|
||||
onBeginCreate,
|
||||
onCancelCreate,
|
||||
onSelectConfiguration,
|
||||
onDeleteConfiguration,
|
||||
}: {
|
||||
token: string;
|
||||
form: AgentSettingsDraft;
|
||||
setForm: Dispatch<SetStateAction<AgentSettingsDraft>>;
|
||||
settings: SettingsPayload;
|
||||
dirty: boolean;
|
||||
creating: boolean;
|
||||
creatingSaving: boolean;
|
||||
callOrder: string[];
|
||||
saving: boolean;
|
||||
orderSaving: boolean;
|
||||
migrationSaving: boolean;
|
||||
showBrandLogos: boolean;
|
||||
providerSaving: string | null;
|
||||
onChangeCallOrder: (order: string[]) => void;
|
||||
onProviderOAuthLogin: (provider: string) => void;
|
||||
onSave: () => void;
|
||||
onMigrate: () => void;
|
||||
onBeginCreate: () => void;
|
||||
onCancelCreate: () => void;
|
||||
onSelectConfiguration: () => void;
|
||||
onDeleteConfiguration: (preset: SettingsPayload["model_presets"][number]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
|
||||
t(key, { defaultValue: fallback, ...(values ?? {}) });
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editorRowKey, setEditorRowKey] = useState<string | null>(null);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [draggedCallOrderIndex, setDraggedCallOrderIndex] = useState<number | null>(null);
|
||||
const [dragOverCallOrderIndex, setDragOverCallOrderIndex] = useState<number | null>(null);
|
||||
const namedPresets = settings.model_presets.filter((preset) => !preset.is_default);
|
||||
const namedPresetsByName = new Map(namedPresets.map((preset) => [preset.name, preset]));
|
||||
const unorderedPresets = namedPresets.filter((preset) => !callOrder.includes(preset.name));
|
||||
const callOrderOccurrences = new Map<string, number>();
|
||||
const presetRows = [
|
||||
...callOrder.map((name, orderIndex) => {
|
||||
const occurrence = callOrderOccurrences.get(name) ?? 0;
|
||||
callOrderOccurrences.set(name, occurrence + 1);
|
||||
return {
|
||||
key: `ordered:${name}:${occurrence}`,
|
||||
name,
|
||||
orderIndex,
|
||||
preset: namedPresetsByName.get(name),
|
||||
};
|
||||
}),
|
||||
...unorderedPresets.map((preset) => ({
|
||||
key: `disabled:${preset.name}`,
|
||||
name: preset.name,
|
||||
orderIndex: -1,
|
||||
preset,
|
||||
})),
|
||||
];
|
||||
const selectedPreset = namedPresetsByName.get(form.modelPreset) ?? null;
|
||||
const activeEditorRowKey =
|
||||
editorRowKey ??
|
||||
presetRows.find((row) => row.name === selectedPreset?.name)?.key ??
|
||||
null;
|
||||
useEffect(() => {
|
||||
setAdvancedOpen(false);
|
||||
}, [editorOpen, selectedPreset?.name]);
|
||||
|
||||
const configuredProviders = settings.providers.filter((provider) => provider.configured);
|
||||
const selectedProvider = settings.providers.find((provider) => provider.name === form.provider);
|
||||
const selectableProviders = uniqueProviders([
|
||||
...configuredProviders,
|
||||
...(selectedProvider ? [selectedProvider] : []),
|
||||
]);
|
||||
const showAutoProvider = selectedPreset?.provider === "auto" || form.provider === "auto";
|
||||
const providerOptions = showAutoProvider
|
||||
? [{ name: "auto", label: tx("settings.values.auto", "Auto") }, ...selectableProviders]
|
||||
: selectableProviders;
|
||||
const providerValue = providerOptions.some((provider) => provider.name === form.provider)
|
||||
? form.provider
|
||||
: "";
|
||||
const selectedProviderNeedsSignIn =
|
||||
selectedProvider?.auth_type === "oauth" && !selectedProvider.configured;
|
||||
const selectedProviderSigningIn = providerSaving === selectedProvider?.name;
|
||||
const selectedProviderConfigured = settingsProviderConfigured(
|
||||
settings,
|
||||
form.provider,
|
||||
selectedPreset?.resolved_provider,
|
||||
);
|
||||
const modelFieldsMissing =
|
||||
!form.model.trim() ||
|
||||
!form.provider.trim() ||
|
||||
!form.presetLabel.trim() ||
|
||||
form.maxTokens <= 0 ||
|
||||
form.temperature < 0 ||
|
||||
form.temperature > 2;
|
||||
const selectedPresetReferenced = Boolean(
|
||||
selectedPreset && callOrder.includes(selectedPreset.name),
|
||||
);
|
||||
const callOrderBusy = orderSaving || saving;
|
||||
const selectPreset = (
|
||||
preset: SettingsPayload["model_presets"][number],
|
||||
rowKey: string,
|
||||
) => {
|
||||
const toggleCurrentPreset =
|
||||
!creating && selectedPreset?.name === preset.name && activeEditorRowKey === rowKey;
|
||||
onSelectConfiguration();
|
||||
if (toggleCurrentPreset) {
|
||||
setEditorOpen((open) => !open);
|
||||
return;
|
||||
}
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
modelPreset: preset.name,
|
||||
model: preset.model,
|
||||
provider: preset.provider,
|
||||
presetLabel: preset.label,
|
||||
maxTokens: preset.max_tokens,
|
||||
contextWindowTokens: normalizeContextWindowTokens(preset.context_window_tokens),
|
||||
temperature: preset.temperature,
|
||||
reasoningEffort: preset.reasoning_effort ?? "",
|
||||
}));
|
||||
setEditorRowKey(rowKey);
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const moveCallOrderItem = (index: number, offset: -1 | 1) => {
|
||||
if (callOrderBusy) return;
|
||||
const nextIndex = index + offset;
|
||||
if (nextIndex < 0 || nextIndex >= callOrder.length) return;
|
||||
const next = [...callOrder];
|
||||
[next[index], next[nextIndex]] = [next[nextIndex], next[index]];
|
||||
onChangeCallOrder(next);
|
||||
};
|
||||
|
||||
const removeCallOrderItem = (index: number) => {
|
||||
if (callOrderBusy || callOrder.length <= 1) return;
|
||||
onChangeCallOrder(callOrder.filter((_, itemIndex) => itemIndex !== index));
|
||||
};
|
||||
|
||||
const dropCallOrderItem = (targetIndex: number) => {
|
||||
if (
|
||||
callOrderBusy ||
|
||||
draggedCallOrderIndex === null ||
|
||||
draggedCallOrderIndex === targetIndex
|
||||
) {
|
||||
setDraggedCallOrderIndex(null);
|
||||
setDragOverCallOrderIndex(null);
|
||||
return;
|
||||
}
|
||||
const next = [...callOrder];
|
||||
const moved = next.splice(draggedCallOrderIndex, 1)[0];
|
||||
if (!moved) {
|
||||
setDraggedCallOrderIndex(null);
|
||||
setDragOverCallOrderIndex(null);
|
||||
return;
|
||||
}
|
||||
next.splice(targetIndex, 0, moved);
|
||||
setDraggedCallOrderIndex(null);
|
||||
setDragOverCallOrderIndex(null);
|
||||
onChangeCallOrder(next);
|
||||
};
|
||||
|
||||
const renderPresetEditor = () => (
|
||||
<div
|
||||
id="model-preset-editor"
|
||||
data-testid="model-preset-editor"
|
||||
className="mx-3 mb-3 divide-y divide-border/45 overflow-hidden rounded-[18px] border border-border/45 bg-background/80 shadow-sm motion-reduce:animate-none animate-in fade-in-0 slide-in-from-top-1 duration-200 sm:mx-5 lg:mx-auto lg:w-[calc(100%-2.5rem)] lg:max-w-6xl"
|
||||
>
|
||||
{creating ? (
|
||||
<div className="flex min-h-[52px] items-center px-4 py-3 sm:px-5">
|
||||
<span className="text-[13px] font-semibold text-foreground/85">
|
||||
{tx("settings.models.newPreset", "New model preset")}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<SettingsRow title={tx("settings.models.presetName", "Preset name")}>
|
||||
<Input
|
||||
autoFocus={creating}
|
||||
value={form.presetLabel}
|
||||
placeholder={tx("settings.models.presetNamePlaceholder", "Fast writing")}
|
||||
onChange={(event) =>
|
||||
setForm((prev) => ({ ...prev, presetLabel: event.target.value }))
|
||||
}
|
||||
className="h-8 w-[min(280px,70vw)] rounded-full text-[13px]"
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow title={t("settings.rows.provider")}>
|
||||
<ProviderPicker
|
||||
providers={providerOptions}
|
||||
value={providerValue}
|
||||
emptyLabel={t("settings.byok.noConfiguredProviders")}
|
||||
showProviderLogos={showBrandLogos}
|
||||
onChange={(provider) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
provider,
|
||||
model: provider === prev.provider ? prev.model : "",
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingsRow>
|
||||
{selectedProviderNeedsSignIn ? (
|
||||
<SettingsRow
|
||||
title={tx("settings.oauth.signInRequired", "Sign in required")}
|
||||
description={tx(
|
||||
"settings.oauth.signInBeforeSaving",
|
||||
"Sign in before saving this provider in the preset.",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => selectedProvider && onProviderOAuthLogin(selectedProvider.name)}
|
||||
disabled={!selectedProvider?.oauth_login_supported || selectedProviderSigningIn}
|
||||
className="rounded-full"
|
||||
>
|
||||
{selectedProviderSigningIn ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : null}
|
||||
{selectedProviderSigningIn
|
||||
? tx("settings.oauth.signingIn", "Signing in...")
|
||||
: tx("settings.oauth.signIn", "Sign in")}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
) : null}
|
||||
<SettingsRow title={t("settings.rows.model")}>
|
||||
<ModelIdPicker
|
||||
token={token}
|
||||
settings={settings}
|
||||
provider={form.provider}
|
||||
value={form.model}
|
||||
showProviderLogos={showBrandLogos}
|
||||
onChange={(model) => setForm((prev) => ({ ...prev, model }))}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={advancedOpen}
|
||||
onClick={() => setAdvancedOpen((value) => !value)}
|
||||
className="flex min-h-[62px] w-full items-center justify-between gap-4 px-4 py-3.5 text-left transition-colors hover:bg-muted/30 sm:px-5"
|
||||
>
|
||||
<span>
|
||||
<span className="block text-[14px] font-medium text-foreground">
|
||||
{tx("settings.models.advancedOptions", "Advanced options")}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[12px] text-muted-foreground">
|
||||
{tx(
|
||||
"settings.models.advancedSummary",
|
||||
"Context {{context}} · Max {{max}} tokens",
|
||||
{
|
||||
context: formatModelContextWindow(form.contextWindowTokens),
|
||||
max: formatContextWindow(form.maxTokens),
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
|
||||
advancedOpen && "rotate-180",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
{advancedOpen ? (
|
||||
<div className="bg-muted/12 px-4 py-4 sm:px-5">
|
||||
<ModelAdvancedFields
|
||||
maxTokens={form.maxTokens}
|
||||
contextWindowTokens={form.contextWindowTokens}
|
||||
temperature={form.temperature}
|
||||
reasoningEffort={form.reasoningEffort}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, ...value }))}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex min-h-[58px] flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
||||
{creating ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="self-start rounded-full text-muted-foreground"
|
||||
disabled={creatingSaving}
|
||||
onClick={() => {
|
||||
setEditorOpen(false);
|
||||
onCancelCreate();
|
||||
}}
|
||||
>
|
||||
{tx("settings.actions.cancel", "Cancel")}
|
||||
</Button>
|
||||
) : selectedPreset ? (
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="rounded-full text-muted-foreground hover:text-destructive"
|
||||
disabled={selectedPresetReferenced || saving || orderSaving}
|
||||
aria-describedby={
|
||||
selectedPresetReferenced ? "model-preset-delete-hint" : undefined
|
||||
}
|
||||
onClick={() => onDeleteConfiguration(selectedPreset)}
|
||||
>
|
||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
{tx("settings.actions.delete", "Delete")}
|
||||
</Button>
|
||||
{selectedPresetReferenced ? (
|
||||
<span
|
||||
id="model-preset-delete-hint"
|
||||
className="text-[11px] leading-4 text-muted-foreground"
|
||||
>
|
||||
{tx(
|
||||
"settings.models.removeBeforeDelete",
|
||||
"Remove this preset from the call order before deleting it.",
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-full"
|
||||
disabled={
|
||||
(!creating && !dirty) ||
|
||||
!selectedProviderConfigured ||
|
||||
modelFieldsMissing ||
|
||||
saving ||
|
||||
orderSaving
|
||||
}
|
||||
onClick={onSave}
|
||||
>
|
||||
{saving || creatingSaving
|
||||
? tx("settings.actions.saving", "Saving...")
|
||||
: tx("settings.actions.savePreset", "Save preset")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-7">
|
||||
<section>
|
||||
<SettingsSectionTitle>
|
||||
{tx("settings.models.presets", "Model presets")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsGroup>
|
||||
{!settings.model_call_order_editable ? (
|
||||
<div className="flex flex-col gap-4 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-[12px] bg-muted text-muted-foreground">
|
||||
<ListOrdered className="h-4 w-4" aria-hidden />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[14px] font-medium text-foreground">
|
||||
{tx("settings.models.convertTitle", "Convert the current model setup")}
|
||||
</p>
|
||||
<p className="mt-0.5 max-w-[34rem] text-[12px] leading-5 text-muted-foreground">
|
||||
{tx(
|
||||
"settings.models.convertHelp",
|
||||
"Turn the existing primary and fallback models into presets so their order can be managed here.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="shrink-0 rounded-full"
|
||||
disabled={migrationSaving}
|
||||
onClick={onMigrate}
|
||||
>
|
||||
{migrationSaving ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : null}
|
||||
{migrationSaving
|
||||
? tx("settings.models.converting", "Converting...")
|
||||
: tx("settings.models.convertAction", "Convert to presets")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div role="list" className="divide-y divide-border/45">
|
||||
{presetRows.map(({ key, name, orderIndex, preset }) => {
|
||||
const ordered = orderIndex >= 0;
|
||||
const provider = preset
|
||||
? modelPresetProviderKey(preset, settings)
|
||||
: settings.agent.resolved_provider ?? settings.agent.provider;
|
||||
const presetConfigured = preset
|
||||
? settingsProviderConfigured(
|
||||
settings,
|
||||
preset.provider,
|
||||
preset.resolved_provider,
|
||||
)
|
||||
: true;
|
||||
const isDropTarget =
|
||||
ordered &&
|
||||
dragOverCallOrderIndex === orderIndex &&
|
||||
draggedCallOrderIndex !== orderIndex;
|
||||
const dropAfterTarget =
|
||||
isDropTarget &&
|
||||
draggedCallOrderIndex !== null &&
|
||||
draggedCallOrderIndex < orderIndex;
|
||||
const isSelected =
|
||||
editorOpen &&
|
||||
!creating &&
|
||||
activeEditorRowKey === key &&
|
||||
selectedPreset?.name === name;
|
||||
const presetRow = (
|
||||
<div
|
||||
tabIndex={ordered ? 0 : -1}
|
||||
draggable={ordered && !callOrderBusy}
|
||||
aria-label={
|
||||
ordered
|
||||
? `${preset?.label ?? name}. ${tx(
|
||||
"settings.models.dragToReorder",
|
||||
"Drag to reorder",
|
||||
)}`
|
||||
: preset?.label ?? name
|
||||
}
|
||||
data-testid={`model-call-order-row-${name}`}
|
||||
onDragStart={(event) => {
|
||||
if (!ordered || callOrderBusy) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("text/plain", name);
|
||||
setDraggedCallOrderIndex(orderIndex);
|
||||
setDragOverCallOrderIndex(orderIndex);
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setDraggedCallOrderIndex(null);
|
||||
setDragOverCallOrderIndex(null);
|
||||
}}
|
||||
onDragEnter={(event) => {
|
||||
if (ordered && draggedCallOrderIndex !== null) {
|
||||
event.preventDefault();
|
||||
setDragOverCallOrderIndex(orderIndex);
|
||||
}
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
if (!ordered || draggedCallOrderIndex === null) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
if (!ordered) return;
|
||||
event.preventDefault();
|
||||
dropCallOrderItem(orderIndex);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.currentTarget !== event.target) return;
|
||||
if (ordered && event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
moveCallOrderItem(orderIndex, -1);
|
||||
} else if (ordered && event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
moveCallOrderItem(orderIndex, 1);
|
||||
} else if ((event.key === "Enter" || event.key === " ") && preset) {
|
||||
event.preventDefault();
|
||||
selectPreset(preset, key);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"group relative flex min-h-[76px] select-none items-center gap-3 px-4 py-3 outline-none transition-[background-color,opacity] duration-150 sm:px-5",
|
||||
ordered &&
|
||||
(callOrderBusy
|
||||
? "cursor-wait"
|
||||
: "cursor-grab active:cursor-grabbing"),
|
||||
"hover:bg-muted/25",
|
||||
isDropTarget &&
|
||||
!dropAfterTarget &&
|
||||
"before:absolute before:inset-x-4 before:top-0 before:z-10 before:h-0.5 before:rounded-full before:bg-foreground sm:before:inset-x-5",
|
||||
isDropTarget &&
|
||||
dropAfterTarget &&
|
||||
"after:absolute after:inset-x-4 after:bottom-0 after:z-10 after:h-0.5 after:rounded-full after:bg-foreground sm:after:inset-x-5",
|
||||
ordered && draggedCallOrderIndex === orderIndex && "opacity-35",
|
||||
isSelected && "bg-muted/45 hover:bg-muted/45",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
|
||||
)}
|
||||
>
|
||||
{ordered ? (
|
||||
<GripVertical
|
||||
className="pointer-events-none h-4 w-4 shrink-0 text-muted-foreground/40 transition-colors group-hover:text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<span className="h-4 w-4 shrink-0" aria-hidden />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={selectedPreset?.name === name}
|
||||
aria-expanded={isSelected}
|
||||
aria-controls={isSelected ? "model-preset-editor" : undefined}
|
||||
disabled={!preset}
|
||||
onClick={() => preset && selectPreset(preset, key)}
|
||||
className="flex min-w-0 flex-1 items-center gap-3 rounded-[12px] text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{ordered ? (
|
||||
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-muted font-mono text-[11px] font-semibold tabular-nums text-muted-foreground">
|
||||
{orderIndex + 1}
|
||||
</span>
|
||||
) : (
|
||||
<span className="h-7 w-7 shrink-0" aria-hidden />
|
||||
)}
|
||||
<ProviderPickerIcon
|
||||
provider={provider}
|
||||
showBrandLogos={showBrandLogos}
|
||||
unconfigured={!presetConfigured}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span className="truncate text-[14px] font-medium text-foreground">
|
||||
{preset?.label ?? name}
|
||||
</span>
|
||||
{orderIndex === 0 ? (
|
||||
<StatusPill tone="success">
|
||||
{tx("settings.models.primary", "Primary")}
|
||||
</StatusPill>
|
||||
) : !ordered ? (
|
||||
<StatusPill tone="neutral">
|
||||
{tx("settings.models.disabled", "Disabled")}
|
||||
</StatusPill>
|
||||
) : null}
|
||||
{!presetConfigured ? (
|
||||
<span className="text-[11px] font-medium text-amber-700 dark:text-amber-300">
|
||||
{tx(
|
||||
"settings.models.providerSetupRequired",
|
||||
"Provider setup required",
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-[12px] text-muted-foreground">
|
||||
{preset?.model ?? name}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
|
||||
isSelected && "rotate-90",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={ordered}
|
||||
aria-label={
|
||||
ordered
|
||||
? tx("settings.models.removeFromOrder", "Disable preset")
|
||||
: tx("settings.models.addToOrder", "Enable preset")
|
||||
}
|
||||
disabled={callOrderBusy || (ordered && callOrder.length <= 1)}
|
||||
onClick={() => {
|
||||
if (ordered) {
|
||||
removeCallOrderItem(orderIndex);
|
||||
} else if (preset) {
|
||||
onChangeCallOrder([...callOrder, preset.name]);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-40",
|
||||
ordered ? "bg-foreground" : "bg-muted-foreground/25",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"h-4 w-4 rounded-full bg-background shadow-sm transition-transform",
|
||||
ordered ? "translate-x-[18px]" : "translate-x-0.5",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div key={key} role="listitem">
|
||||
{presetRow}
|
||||
{isSelected ? renderPresetEditor() : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex min-h-[58px] flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
||||
{!creating ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="rounded-full"
|
||||
disabled={callOrderBusy}
|
||||
onClick={() => {
|
||||
setEditorRowKey(null);
|
||||
setEditorOpen(true);
|
||||
onBeginCreate();
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
{tx("settings.models.newPreset", "New model preset")}
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{orderSaving ? (
|
||||
<SettingsStatusMessage>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
{tx("settings.actions.saving", "Saving...")}
|
||||
</span>
|
||||
</SettingsStatusMessage>
|
||||
) : null}
|
||||
</div>
|
||||
{creating && editorOpen ? renderPresetEditor() : null}
|
||||
</>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelAdvancedFields({
|
||||
maxTokens,
|
||||
contextWindowTokens,
|
||||
temperature,
|
||||
reasoningEffort,
|
||||
onChange,
|
||||
}: {
|
||||
maxTokens: number;
|
||||
contextWindowTokens: number;
|
||||
temperature: number;
|
||||
reasoningEffort: string;
|
||||
onChange: (
|
||||
value: Partial<
|
||||
Pick<
|
||||
AgentSettingsDraft,
|
||||
"maxTokens" | "contextWindowTokens" | "temperature" | "reasoningEffort"
|
||||
>
|
||||
>,
|
||||
) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const contextWindowOptions = Array.from(
|
||||
new Set([...CONTEXT_WINDOW_TOKEN_OPTIONS, contextWindowTokens]),
|
||||
).sort((left, right) => left - right);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
||||
{tx("settings.models.maxTokens", "Max output tokens")}
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={maxTokens}
|
||||
onChange={(event) => {
|
||||
const value = Number(event.target.value);
|
||||
if (Number.isFinite(value)) onChange({ maxTokens: value });
|
||||
}}
|
||||
className="h-9 rounded-[12px] text-[13px]"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
||||
{tx("settings.models.temperature", "Temperature")}
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={temperature}
|
||||
onChange={(event) => {
|
||||
const value = Number(event.target.value);
|
||||
if (Number.isFinite(value)) onChange({ temperature: value });
|
||||
}}
|
||||
className="h-9 rounded-[12px] text-[13px]"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<span className="mb-2 block text-[12px] font-medium text-muted-foreground">
|
||||
{tx("settings.rows.contextWindow", "Context window")}
|
||||
</span>
|
||||
<SegmentedControl
|
||||
value={String(contextWindowTokens)}
|
||||
options={contextWindowOptions.map((tokens) => ({
|
||||
value: String(tokens),
|
||||
label: formatModelContextWindow(tokens),
|
||||
}))}
|
||||
onChange={(value) =>
|
||||
onChange({ contextWindowTokens: normalizeContextWindowTokens(Number(value)) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
||||
{tx("settings.models.reasoningEffort", "Reasoning effort")}
|
||||
</span>
|
||||
<Input
|
||||
value={reasoningEffort}
|
||||
onChange={(event) => onChange({ reasoningEffort: event.target.value })}
|
||||
placeholder={tx("settings.values.default", "Default")}
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
className="h-9 rounded-[12px] text-[13px]"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function uniqueProviders(
|
||||
providers: SettingsPayload["providers"],
|
||||
): SettingsPayload["providers"] {
|
||||
const seen = new Set<string>();
|
||||
return providers.filter((provider) => {
|
||||
if (seen.has(provider.name)) return false;
|
||||
seen.add(provider.name);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function modelPresetProviderKey(
|
||||
preset: SettingsPayload["model_presets"][number],
|
||||
settings: SettingsPayload,
|
||||
options: { draftProvider?: string } = {},
|
||||
): string {
|
||||
const provider = options.draftProvider ?? preset.provider;
|
||||
if (provider === "auto") {
|
||||
return (
|
||||
preset.resolved_provider ||
|
||||
settings.agent.resolved_provider ||
|
||||
settings.agent.provider ||
|
||||
preset.provider
|
||||
);
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,562 @@
|
||||
import { useCallback, type Dispatch, type SetStateAction } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
import type {
|
||||
ApplySettingsPayload,
|
||||
MaybeRestartHostEngine,
|
||||
PendingRestartSections,
|
||||
} from "@/components/settings/contracts";
|
||||
import { agentDraftFromPayload } from "@/components/settings/models/ModelsSettings";
|
||||
import {
|
||||
CUSTOM_PROVIDER_CREATION_KEY,
|
||||
providerFormFromRow,
|
||||
type CustomProviderDraft,
|
||||
} from "@/components/settings/models/ProviderSettings";
|
||||
import type { ModelSettingsState } from "@/components/settings/models/useModelSettingsState";
|
||||
import { normalizeContextWindowTokens } from "@/components/settings/shared/ModelControls";
|
||||
import {
|
||||
completeProviderOAuth,
|
||||
createModelConfiguration,
|
||||
createProviderSettings,
|
||||
deleteModelConfiguration,
|
||||
loginProviderOAuth,
|
||||
logoutProviderOAuth,
|
||||
migrateModelConfigurations,
|
||||
updateModelCallOrder,
|
||||
updateModelConfiguration,
|
||||
updateProviderSettings,
|
||||
} from "@/lib/api";
|
||||
import type { NanobotClient } from "@/lib/nanobot-client";
|
||||
import type {
|
||||
ProviderOAuthAuthorizationRequired,
|
||||
ProviderOAuthCompletionResult,
|
||||
ProviderOAuthLoginResult,
|
||||
ProviderOAuthPending,
|
||||
ProviderSettingsUpdate,
|
||||
SettingsPayload,
|
||||
} from "@/lib/types";
|
||||
|
||||
function isProviderOAuthAuthorizationRequired(
|
||||
payload: ProviderOAuthLoginResult,
|
||||
): payload is ProviderOAuthAuthorizationRequired {
|
||||
return (payload as ProviderOAuthAuthorizationRequired).status === "authorization_required";
|
||||
}
|
||||
|
||||
function isProviderOAuthPending(
|
||||
payload: ProviderOAuthCompletionResult,
|
||||
): payload is ProviderOAuthPending {
|
||||
return (payload as ProviderOAuthPending).status === "pending";
|
||||
}
|
||||
|
||||
interface ModelSettingsActionsOptions {
|
||||
state: ModelSettingsState;
|
||||
settings: SettingsPayload | null;
|
||||
client: NanobotClient;
|
||||
t: TFunction;
|
||||
applyPayload: ApplySettingsPayload;
|
||||
maybeRestartHostEngine: MaybeRestartHostEngine;
|
||||
setPendingRestartSections: Dispatch<SetStateAction<PendingRestartSections>>;
|
||||
setError: Dispatch<SetStateAction<string | null>>;
|
||||
onModelNameChange: (modelName: string | null) => void;
|
||||
remoteBrowserAccess: boolean;
|
||||
closeProviderOAuthFlow: () => void;
|
||||
installCapabilities: (names: string[]) => Promise<boolean>;
|
||||
modelDirty: boolean;
|
||||
configuredModelProviderOptions: Array<{ name: string; label: string }>;
|
||||
}
|
||||
|
||||
export function useModelSettingsActions({
|
||||
state,
|
||||
settings,
|
||||
client,
|
||||
t,
|
||||
applyPayload,
|
||||
maybeRestartHostEngine,
|
||||
setPendingRestartSections,
|
||||
setError,
|
||||
onModelNameChange,
|
||||
remoteBrowserAccess,
|
||||
closeProviderOAuthFlow,
|
||||
installCapabilities,
|
||||
modelDirty,
|
||||
configuredModelProviderOptions,
|
||||
}: ModelSettingsActionsOptions) {
|
||||
const {
|
||||
expandedProvider,
|
||||
form,
|
||||
modelCallOrder,
|
||||
modelCallOrderSaving,
|
||||
modelConfigurationSaving,
|
||||
modelMigrationSaving,
|
||||
modelPresetBeforeCreateRef,
|
||||
modelPresetCreating,
|
||||
modelPresetPendingDelete,
|
||||
providerForms,
|
||||
providerOAuthCompleting,
|
||||
providerOAuthFlowRef,
|
||||
providerOAuthResponse,
|
||||
providerSaving,
|
||||
saving,
|
||||
setEditingProviderKeys,
|
||||
setExpandedProvider,
|
||||
setForm,
|
||||
setModelCallOrder,
|
||||
setModelCallOrderSaving,
|
||||
setModelConfigurationSaving,
|
||||
setModelMigrationSaving,
|
||||
setModelPresetCreating,
|
||||
setModelPresetPendingDelete,
|
||||
setProviderForms,
|
||||
setProviderOAuthCompleting,
|
||||
setProviderOAuthDialogError,
|
||||
setProviderOAuthFlow,
|
||||
setProviderOAuthResponse,
|
||||
setProviderSaving,
|
||||
setSaving,
|
||||
setVisibleProviderKeys,
|
||||
visibleProviderKeys,
|
||||
} = state;
|
||||
|
||||
const saveModelSettings = async () => {
|
||||
if (
|
||||
!settings ||
|
||||
saving ||
|
||||
modelCallOrderSaving ||
|
||||
modelConfigurationSaving
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (modelPresetCreating) {
|
||||
const label = form.presetLabel.trim();
|
||||
const provider = form.provider.trim();
|
||||
const model = form.model.trim();
|
||||
if (
|
||||
!label ||
|
||||
!provider ||
|
||||
!model ||
|
||||
form.maxTokens <= 0 ||
|
||||
form.contextWindowTokens <= 0 ||
|
||||
form.temperature < 0 ||
|
||||
form.temperature > 2
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setModelConfigurationSaving(true);
|
||||
try {
|
||||
const payload = await createModelConfiguration(client, {
|
||||
label,
|
||||
provider,
|
||||
model,
|
||||
maxTokens: form.maxTokens,
|
||||
contextWindowTokens: form.contextWindowTokens,
|
||||
temperature: form.temperature,
|
||||
reasoningEffort: form.reasoningEffort || null,
|
||||
});
|
||||
const createdPreset = payload.created_model_preset;
|
||||
const nextOrder = createdPreset ? [...modelCallOrder, createdPreset] : null;
|
||||
applyPayload(payload);
|
||||
if (createdPreset) {
|
||||
setForm(agentDraftFromPayload(payload, createdPreset));
|
||||
}
|
||||
|
||||
let finalPayload = payload;
|
||||
if (nextOrder) {
|
||||
const orderedPayload = await updateModelCallOrder(client, nextOrder);
|
||||
applyPayload(orderedPayload);
|
||||
finalPayload = orderedPayload;
|
||||
}
|
||||
if (createdPreset) {
|
||||
setForm(agentDraftFromPayload(finalPayload, createdPreset));
|
||||
}
|
||||
modelPresetBeforeCreateRef.current = null;
|
||||
onModelNameChange(finalPayload.agent.model || null);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setModelConfigurationSaving(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!modelDirty) return;
|
||||
const selectedPreset = settings.model_presets.find(
|
||||
(preset) => !preset.is_default && preset.name === form.modelPreset,
|
||||
);
|
||||
if (!selectedPreset) return;
|
||||
const reasoningEffort = form.reasoningEffort || null;
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = await updateModelConfiguration(client, {
|
||||
name: selectedPreset.name,
|
||||
label:
|
||||
form.presetLabel.trim() !== selectedPreset.label
|
||||
? form.presetLabel.trim()
|
||||
: undefined,
|
||||
model: form.model !== selectedPreset.model ? form.model : undefined,
|
||||
provider: form.provider !== selectedPreset.provider ? form.provider : undefined,
|
||||
maxTokens:
|
||||
form.maxTokens !== selectedPreset.max_tokens ? form.maxTokens : undefined,
|
||||
contextWindowTokens:
|
||||
form.contextWindowTokens !==
|
||||
normalizeContextWindowTokens(selectedPreset.context_window_tokens)
|
||||
? form.contextWindowTokens
|
||||
: undefined,
|
||||
temperature:
|
||||
form.temperature !== selectedPreset.temperature ? form.temperature : undefined,
|
||||
reasoningEffort:
|
||||
reasoningEffort !== selectedPreset.reasoning_effort ? reasoningEffort : undefined,
|
||||
});
|
||||
applyPayload(payload);
|
||||
setForm(agentDraftFromPayload(payload, selectedPreset.name));
|
||||
onModelNameChange(payload.agent.model || null);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const beginModelPresetCreation = () => {
|
||||
if (!settings || saving || modelCallOrderSaving || modelConfigurationSaving) return;
|
||||
const primaryPreset = settings.model_presets.find(
|
||||
(preset) => !preset.is_default && preset.name === settings.model_call_order?.[0],
|
||||
);
|
||||
const currentProvider = primaryPreset?.provider === "auto"
|
||||
? primaryPreset.resolved_provider ?? settings.agent.resolved_provider
|
||||
: primaryPreset?.provider ?? settings.agent.provider;
|
||||
const provider =
|
||||
configuredModelProviderOptions.find((option) => option.name === currentProvider)?.name ??
|
||||
configuredModelProviderOptions[0]?.name ??
|
||||
"";
|
||||
modelPresetBeforeCreateRef.current = form.modelPreset;
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
modelPreset: "",
|
||||
presetLabel: "",
|
||||
provider,
|
||||
model: "",
|
||||
maxTokens: primaryPreset?.max_tokens ?? settings.agent.max_tokens,
|
||||
contextWindowTokens: normalizeContextWindowTokens(
|
||||
primaryPreset?.context_window_tokens ?? settings.agent.context_window_tokens,
|
||||
),
|
||||
temperature: primaryPreset?.temperature ?? settings.agent.temperature,
|
||||
reasoningEffort: primaryPreset?.reasoning_effort ?? settings.agent.reasoning_effort ?? "",
|
||||
}));
|
||||
setModelPresetCreating(true);
|
||||
};
|
||||
|
||||
const cancelModelPresetCreation = () => {
|
||||
if (!settings || modelConfigurationSaving) return;
|
||||
const previousPreset = modelPresetBeforeCreateRef.current;
|
||||
setModelPresetCreating(false);
|
||||
setForm(agentDraftFromPayload(settings, previousPreset ?? undefined));
|
||||
modelPresetBeforeCreateRef.current = null;
|
||||
};
|
||||
|
||||
const changeModelCallOrder = async (nextOrder: string[]) => {
|
||||
const unchanged =
|
||||
nextOrder.length === modelCallOrder.length &&
|
||||
nextOrder.every((name, index) => name === modelCallOrder[index]);
|
||||
if (
|
||||
!settings ||
|
||||
saving ||
|
||||
modelCallOrderSaving ||
|
||||
modelConfigurationSaving ||
|
||||
nextOrder.length === 0 ||
|
||||
unchanged
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const previousOrder = [...modelCallOrder];
|
||||
setModelCallOrder(nextOrder);
|
||||
setModelCallOrderSaving(true);
|
||||
try {
|
||||
const payload = await updateModelCallOrder(client, nextOrder);
|
||||
applyPayload(payload, { preserveAgentForm: true });
|
||||
onModelNameChange(payload.agent.model || null);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setModelCallOrder(previousOrder);
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setModelCallOrderSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMigrateModelConfigurations = async () => {
|
||||
if (modelMigrationSaving) return;
|
||||
setModelMigrationSaving(true);
|
||||
try {
|
||||
const payload = await migrateModelConfigurations(client);
|
||||
applyPayload(payload);
|
||||
onModelNameChange(payload.agent.model || null);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setModelMigrationSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteModelConfiguration = async () => {
|
||||
if (
|
||||
!modelPresetPendingDelete ||
|
||||
saving ||
|
||||
modelCallOrderSaving ||
|
||||
modelConfigurationSaving
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = await deleteModelConfiguration(client, modelPresetPendingDelete.name);
|
||||
applyPayload(payload);
|
||||
setModelPresetPendingDelete(null);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveProvider = async (providerName: string) => {
|
||||
if (providerSaving) return;
|
||||
const provider = settings?.providers.find((item) => item.name === providerName);
|
||||
if (!provider) return;
|
||||
const isOauthProvider = provider.auth_type === "oauth";
|
||||
const providerForm = providerForms[providerName] ?? providerFormFromRow(provider);
|
||||
const apiKey = providerForm.apiKey.trim();
|
||||
const apiKeyRequired = provider.api_key_required ?? true;
|
||||
if (!isOauthProvider && !provider.configured && apiKeyRequired && !apiKey) {
|
||||
setError(t("settings.byok.apiKeyRequired"));
|
||||
return;
|
||||
}
|
||||
setProviderSaving(providerName);
|
||||
try {
|
||||
const supportName = providerName === "bedrock"
|
||||
? "bedrock"
|
||||
: providerName === "azure_openai"
|
||||
? "azure"
|
||||
: null;
|
||||
if (supportName && !(await installCapabilities([supportName]))) return;
|
||||
const update: ProviderSettingsUpdate = { provider: providerName };
|
||||
if (!isOauthProvider) {
|
||||
update.apiKey = apiKey || undefined;
|
||||
update.apiBase = providerForm.apiBase.trim();
|
||||
if (provider.is_custom) update.displayName = providerForm.displayName.trim();
|
||||
}
|
||||
for (const field of provider.advanced_fields ?? []) {
|
||||
if (field === "api_type") update.apiType = providerForm.apiType;
|
||||
if (field === "proxy") update.proxy = providerForm.proxy.trim();
|
||||
if (field === "extra_headers") {
|
||||
update.extraHeaders = providerForm.extraHeaders.trim();
|
||||
}
|
||||
if (field === "extra_body") update.extraBody = providerForm.extraBody.trim();
|
||||
if (field === "extra_query") update.extraQuery = providerForm.extraQuery.trim();
|
||||
if (field === "thinking_style") {
|
||||
update.thinkingStyle = providerForm.thinkingStyle.trim();
|
||||
}
|
||||
if (field === "region") update.region = providerForm.region.trim();
|
||||
if (field === "profile") update.profile = providerForm.profile.trim();
|
||||
}
|
||||
const payload = await updateProviderSettings(client, update);
|
||||
applyPayload(payload);
|
||||
if (payload.requires_restart) {
|
||||
setPendingRestartSections((prev) => ({ ...prev, image: true }));
|
||||
}
|
||||
await maybeRestartHostEngine(payload);
|
||||
setProviderForms((prev) => ({
|
||||
...prev,
|
||||
[providerName]: {
|
||||
...providerForm,
|
||||
displayName: providerForm.displayName.trim(),
|
||||
apiKey: "",
|
||||
apiBase: providerForm.apiBase.trim(),
|
||||
proxy: providerForm.proxy.trim(),
|
||||
thinkingStyle: providerForm.thinkingStyle.trim(),
|
||||
region: providerForm.region.trim(),
|
||||
profile: providerForm.profile.trim(),
|
||||
},
|
||||
}));
|
||||
setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: false }));
|
||||
setEditingProviderKeys((prev) => ({ ...prev, [providerName]: false }));
|
||||
if (!isOauthProvider) setExpandedProvider(null);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setProviderSaving(null);
|
||||
}
|
||||
};
|
||||
|
||||
const createCustomProvider = async (draft: CustomProviderDraft): Promise<boolean> => {
|
||||
if (providerSaving) return false;
|
||||
setProviderSaving(CUSTOM_PROVIDER_CREATION_KEY);
|
||||
try {
|
||||
const payload = await createProviderSettings(client, {
|
||||
name: draft.name.trim(),
|
||||
apiKey: draft.apiKey.trim() || undefined,
|
||||
apiBase: draft.apiBase.trim(),
|
||||
proxy: draft.proxy.trim(),
|
||||
extraHeaders: draft.extraHeaders.trim(),
|
||||
extraBody: draft.extraBody.trim(),
|
||||
extraQuery: draft.extraQuery.trim(),
|
||||
thinkingStyle: draft.thinkingStyle.trim(),
|
||||
});
|
||||
applyPayload(payload);
|
||||
setExpandedProvider(null);
|
||||
setError(null);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
return false;
|
||||
} finally {
|
||||
setProviderSaving(null);
|
||||
}
|
||||
};
|
||||
|
||||
const runProviderOAuth = async (providerName: string, action: "login" | "logout") => {
|
||||
if (providerSaving) return;
|
||||
let popup: Window | null = null;
|
||||
if (
|
||||
action === "login"
|
||||
&& providerName === "xai_grok"
|
||||
&& !remoteBrowserAccess
|
||||
) {
|
||||
try {
|
||||
popup = window.open("about:blank", "_blank");
|
||||
if (popup) popup.opener = null;
|
||||
} catch {
|
||||
popup = null;
|
||||
}
|
||||
}
|
||||
setProviderSaving(providerName);
|
||||
try {
|
||||
const payload =
|
||||
action === "login"
|
||||
? await loginProviderOAuth(
|
||||
client,
|
||||
providerName,
|
||||
providerName === "openai_codex" && remoteBrowserAccess,
|
||||
)
|
||||
: await logoutProviderOAuth(client, providerName);
|
||||
if (isProviderOAuthAuthorizationRequired(payload)) {
|
||||
try {
|
||||
if (popup && !popup.closed) popup.location.href = payload.authorization_url;
|
||||
} catch {
|
||||
// The dialog keeps the authorization link available when the popup was closed.
|
||||
}
|
||||
providerOAuthFlowRef.current = payload;
|
||||
setProviderOAuthFlow(payload);
|
||||
setProviderOAuthResponse("");
|
||||
setProviderOAuthDialogError(null);
|
||||
setExpandedProvider(providerName);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
popup?.close();
|
||||
closeProviderOAuthFlow();
|
||||
applyPayload(payload);
|
||||
setExpandedProvider(providerName);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
popup?.close();
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setProviderSaving(null);
|
||||
}
|
||||
};
|
||||
|
||||
const completeProviderOAuthResponse = async () => {
|
||||
const flow = providerOAuthFlowRef.current;
|
||||
const authorizationResponse = providerOAuthResponse.trim();
|
||||
if (!flow || !authorizationResponse || providerOAuthCompleting) return;
|
||||
setProviderOAuthCompleting(true);
|
||||
setProviderOAuthDialogError(null);
|
||||
try {
|
||||
const payload = await completeProviderOAuth(
|
||||
client,
|
||||
flow.provider,
|
||||
flow.flow_id,
|
||||
authorizationResponse,
|
||||
);
|
||||
if (providerOAuthFlowRef.current?.flow_id !== flow.flow_id) return;
|
||||
if (isProviderOAuthPending(payload)) return;
|
||||
applyPayload(payload);
|
||||
setExpandedProvider(flow.provider);
|
||||
setError(null);
|
||||
closeProviderOAuthFlow();
|
||||
} catch (err) {
|
||||
if (providerOAuthFlowRef.current?.flow_id === flow.flow_id) {
|
||||
setProviderOAuthDialogError((err as Error).message);
|
||||
}
|
||||
} finally {
|
||||
setProviderOAuthCompleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetProviderDraft = useCallback((providerName: string) => {
|
||||
const provider = settings?.providers.find((item) => item.name === providerName);
|
||||
if (!provider) return;
|
||||
setProviderForms((prev) => ({
|
||||
...prev,
|
||||
[providerName]: providerFormFromRow(provider),
|
||||
}));
|
||||
setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: false }));
|
||||
setEditingProviderKeys((prev) => ({ ...prev, [providerName]: false }));
|
||||
}, [settings]);
|
||||
|
||||
const handleToggleProvider = useCallback((providerName: string) => {
|
||||
if (expandedProvider) resetProviderDraft(expandedProvider);
|
||||
setExpandedProvider(expandedProvider === providerName ? null : providerName);
|
||||
}, [expandedProvider, resetProviderDraft]);
|
||||
|
||||
const toggleProviderKeyVisibility = (providerName: string) => {
|
||||
const isVisible = visibleProviderKeys[providerName];
|
||||
setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: !isVisible }));
|
||||
};
|
||||
|
||||
const toggleProviderKeyEditing = (providerName: string) => {
|
||||
setEditingProviderKeys((prev) => {
|
||||
const nextEditing = !prev[providerName];
|
||||
if (!nextEditing) {
|
||||
setProviderForms((forms) => ({
|
||||
...forms,
|
||||
[providerName]: {
|
||||
...(forms[providerName] ?? providerFormFromRow(
|
||||
settings?.providers.find((provider) => provider.name === providerName) ?? {
|
||||
name: providerName,
|
||||
label: providerName,
|
||||
configured: false,
|
||||
},
|
||||
)),
|
||||
apiKey: "",
|
||||
},
|
||||
}));
|
||||
setVisibleProviderKeys((visible) => ({ ...visible, [providerName]: false }));
|
||||
}
|
||||
return { ...prev, [providerName]: nextEditing };
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
beginModelPresetCreation,
|
||||
cancelModelPresetCreation,
|
||||
changeModelCallOrder,
|
||||
completeProviderOAuthResponse,
|
||||
createCustomProvider,
|
||||
handleDeleteModelConfiguration,
|
||||
handleMigrateModelConfigurations,
|
||||
handleToggleProvider,
|
||||
resetProviderDraft,
|
||||
runProviderOAuth,
|
||||
saveModelSettings,
|
||||
saveProvider,
|
||||
toggleProviderKeyEditing,
|
||||
toggleProviderKeyVisibility,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useEffect, type Dispatch, type SetStateAction } from "react";
|
||||
|
||||
import type { ApplySettingsPayload } from "@/components/settings/contracts";
|
||||
import { providerFormFromRow } from "@/components/settings/models/ProviderSettings";
|
||||
import type { ModelSettingsState } from "@/components/settings/models/useModelSettingsState";
|
||||
import { completeProviderOAuth } from "@/lib/api";
|
||||
import type { NanobotClient } from "@/lib/nanobot-client";
|
||||
import type {
|
||||
ProviderOAuthCompletionResult,
|
||||
ProviderOAuthPending,
|
||||
SettingsPayload,
|
||||
} from "@/lib/types";
|
||||
|
||||
function isProviderOAuthPending(
|
||||
payload: ProviderOAuthCompletionResult,
|
||||
): payload is ProviderOAuthPending {
|
||||
return (payload as ProviderOAuthPending).status === "pending";
|
||||
}
|
||||
|
||||
interface ProviderOAuthPollingOptions {
|
||||
state: ModelSettingsState;
|
||||
client: NanobotClient;
|
||||
applyPayload: ApplySettingsPayload;
|
||||
setError: Dispatch<SetStateAction<string | null>>;
|
||||
closeProviderOAuthFlow: () => void;
|
||||
}
|
||||
|
||||
export function useProviderOAuthPolling({
|
||||
state,
|
||||
client,
|
||||
applyPayload,
|
||||
setError,
|
||||
closeProviderOAuthFlow,
|
||||
}: ProviderOAuthPollingOptions) {
|
||||
const {
|
||||
providerOAuthFlow,
|
||||
providerOAuthFlowRef,
|
||||
setExpandedProvider,
|
||||
} = state;
|
||||
|
||||
useEffect(() => {
|
||||
if (!providerOAuthFlow) return;
|
||||
let cancelled = false;
|
||||
let timer: number | null = null;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const payload = await completeProviderOAuth(
|
||||
client,
|
||||
providerOAuthFlow.provider,
|
||||
providerOAuthFlow.flow_id,
|
||||
);
|
||||
if (
|
||||
cancelled
|
||||
|| providerOAuthFlowRef.current?.flow_id !== providerOAuthFlow.flow_id
|
||||
) return;
|
||||
if (isProviderOAuthPending(payload)) {
|
||||
timer = window.setTimeout(() => void poll(), 1000);
|
||||
return;
|
||||
}
|
||||
applyPayload(payload);
|
||||
setExpandedProvider(providerOAuthFlow.provider);
|
||||
setError(null);
|
||||
closeProviderOAuthFlow();
|
||||
} catch (err) {
|
||||
if (
|
||||
cancelled
|
||||
|| providerOAuthFlowRef.current?.flow_id !== providerOAuthFlow.flow_id
|
||||
) return;
|
||||
setError((err as Error).message);
|
||||
closeProviderOAuthFlow();
|
||||
}
|
||||
};
|
||||
timer = window.setTimeout(() => void poll(), 1000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
};
|
||||
}, [applyPayload, client, closeProviderOAuthFlow, providerOAuthFlow]);
|
||||
}
|
||||
|
||||
export function useProviderFormsSync(
|
||||
state: ModelSettingsState,
|
||||
settings: SettingsPayload | null,
|
||||
) {
|
||||
const { setProviderForms } = state;
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings) return;
|
||||
setProviderForms((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const provider of settings.providers) {
|
||||
next[provider.name] = next[provider.name] ?? providerFormFromRow(provider);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [settings]);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
DEFAULT_AGENT_SETTINGS_DRAFT,
|
||||
agentDraftFromPayload,
|
||||
type AgentSettingsDraft,
|
||||
} from "@/components/settings/models/ModelsSettings";
|
||||
import type { ProviderForm } from "@/components/settings/models/ProviderSettings";
|
||||
import type { ProviderOAuthAuthorizationRequired, SettingsPayload } from "@/lib/types";
|
||||
|
||||
export function useModelSettingsState(initialSettings: SettingsPayload | null) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [modelPresetCreating, setModelPresetCreating] = useState(false);
|
||||
const [modelConfigurationSaving, setModelConfigurationSaving] = useState(false);
|
||||
const [modelCallOrderSaving, setModelCallOrderSaving] = useState(false);
|
||||
const [modelMigrationSaving, setModelMigrationSaving] = useState(false);
|
||||
const [modelPresetPendingDelete, setModelPresetPendingDelete] =
|
||||
useState<SettingsPayload["model_presets"][number] | null>(null);
|
||||
const modelPresetBeforeCreateRef = useRef<string | null>(null);
|
||||
const [providerSaving, setProviderSaving] = useState<string | null>(null);
|
||||
const [providerOAuthFlow, setProviderOAuthFlow] =
|
||||
useState<ProviderOAuthAuthorizationRequired | null>(null);
|
||||
const providerOAuthFlowRef = useRef<ProviderOAuthAuthorizationRequired | null>(null);
|
||||
const [providerOAuthResponse, setProviderOAuthResponse] = useState("");
|
||||
const [providerOAuthCompleting, setProviderOAuthCompleting] = useState(false);
|
||||
const [providerOAuthDialogError, setProviderOAuthDialogError] = useState<string | null>(null);
|
||||
const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
|
||||
const [providerForms, setProviderForms] = useState<Record<string, ProviderForm>>({});
|
||||
const [visibleProviderKeys, setVisibleProviderKeys] = useState<Record<string, boolean>>({});
|
||||
const [editingProviderKeys, setEditingProviderKeys] = useState<Record<string, boolean>>({});
|
||||
const [form, setForm] = useState<AgentSettingsDraft>(() =>
|
||||
initialSettings ? agentDraftFromPayload(initialSettings) : DEFAULT_AGENT_SETTINGS_DRAFT,
|
||||
);
|
||||
const [modelCallOrder, setModelCallOrder] = useState<string[]>(
|
||||
() => initialSettings?.model_call_order ?? [],
|
||||
);
|
||||
|
||||
return {
|
||||
editingProviderKeys,
|
||||
expandedProvider,
|
||||
form,
|
||||
modelCallOrder,
|
||||
modelCallOrderSaving,
|
||||
modelConfigurationSaving,
|
||||
modelMigrationSaving,
|
||||
modelPresetBeforeCreateRef,
|
||||
modelPresetCreating,
|
||||
modelPresetPendingDelete,
|
||||
providerForms,
|
||||
providerOAuthCompleting,
|
||||
providerOAuthDialogError,
|
||||
providerOAuthFlow,
|
||||
providerOAuthFlowRef,
|
||||
providerOAuthResponse,
|
||||
providerSaving,
|
||||
saving,
|
||||
setEditingProviderKeys,
|
||||
setExpandedProvider,
|
||||
setForm,
|
||||
setModelCallOrder,
|
||||
setModelCallOrderSaving,
|
||||
setModelConfigurationSaving,
|
||||
setModelMigrationSaving,
|
||||
setModelPresetCreating,
|
||||
setModelPresetPendingDelete,
|
||||
setProviderForms,
|
||||
setProviderOAuthCompleting,
|
||||
setProviderOAuthDialogError,
|
||||
setProviderOAuthFlow,
|
||||
setProviderOAuthResponse,
|
||||
setProviderSaving,
|
||||
setSaving,
|
||||
setVisibleProviderKeys,
|
||||
visibleProviderKeys,
|
||||
};
|
||||
}
|
||||
|
||||
export type ModelSettingsState = ReturnType<typeof useModelSettingsState>;
|
||||
@@ -0,0 +1,526 @@
|
||||
import { useState, type Dispatch, type SetStateAction } from "react";
|
||||
import {
|
||||
ArrowUpCircle,
|
||||
Bot,
|
||||
Check,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Globe2,
|
||||
HardDrive,
|
||||
ImageIcon,
|
||||
Loader2,
|
||||
Mic,
|
||||
Server,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||
import { DEFAULT_TRANSCRIPTION_SETTINGS } from "@/components/settings/capabilities/TranscriptionSettings";
|
||||
import type { SettingsSectionKey } from "@/components/settings/contracts";
|
||||
import { settingsProviderConfigured } from "@/components/settings/shared/ModelControls";
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsRow,
|
||||
SettingsSectionTitle,
|
||||
} from "@/components/settings/shared/SettingsControls";
|
||||
import { TokenUsageHeatmap } from "@/components/settings/TokenUsageHeatmap";
|
||||
import { ToggleButton } from "@/components/settings/ToggleButton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||
import { checkVersion } from "@/lib/api";
|
||||
import type {
|
||||
FileEditDisplayMode,
|
||||
LocalActivityMode,
|
||||
LocalDensity,
|
||||
LocalPreferences,
|
||||
} from "@/lib/local-preferences";
|
||||
import { providerBrand, providerDisplayLabel } from "@/lib/provider-brand";
|
||||
import type { SettingsPayload } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { shortWorkspacePath } from "@/lib/workspace";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
|
||||
export function OverviewSettings({
|
||||
settings,
|
||||
requiresRestart,
|
||||
onSelectSection,
|
||||
showBrandLogos,
|
||||
}: {
|
||||
settings: SettingsPayload;
|
||||
requiresRestart: boolean;
|
||||
onSelectSection: (section: SettingsSectionKey) => void;
|
||||
showBrandLogos: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const activePresetName = settings.agent.model_preset;
|
||||
const activePreset =
|
||||
activePresetName && activePresetName !== "default"
|
||||
? settings.model_presets.find((preset) => preset.name === activePresetName)?.label ??
|
||||
activePresetName
|
||||
: null;
|
||||
const activeProvider = settings.agent.resolved_provider ?? settings.agent.provider;
|
||||
const activeProviderConfigured = settingsProviderConfigured(settings, activeProvider);
|
||||
const activeProviderLabel = providerDisplayLabel(settings.providers, activeProvider);
|
||||
const activeModelValue = activeProviderConfigured
|
||||
? settings.agent.model
|
||||
: tx("settings.values.notConfigured", "Not configured");
|
||||
const activeModelCaption = activeProviderConfigured
|
||||
? [activeProvider, activePreset].filter(Boolean).join(" · ")
|
||||
: activeProviderLabel || settings.agent.model
|
||||
? [activeProviderLabel, settings.agent.model].filter(Boolean).join(" · ")
|
||||
: tx("settings.byok.noConfiguredProviders", "No configured providers");
|
||||
const webStatus = settings.web.enable
|
||||
? tx("settings.values.enabled", "Enabled")
|
||||
: tx("settings.values.disabled", "Disabled");
|
||||
const webSearchProvider =
|
||||
settings.web_search.providers.find((provider) => provider.name === settings.web_search.provider) ??
|
||||
settings.web_search.providers[0];
|
||||
const webSearchProviderLabel = providerDisplayLabel(
|
||||
settings.web_search.providers,
|
||||
settings.web_search.provider,
|
||||
);
|
||||
const webSearchCredentialStatus =
|
||||
webSearchProvider?.credential === "none"
|
||||
? tx("settings.byok.webSearch.noCredentialRequired", "No key required")
|
||||
: webSearchProvider?.credential === "optional_api_key"
|
||||
? settings.web_search.api_key_hint
|
||||
? tx("settings.values.configured", "Configured")
|
||||
: tx("settings.byok.webSearch.noCredentialRequired", "No key required")
|
||||
: webSearchProvider?.credential === "base_url"
|
||||
? settings.web_search.base_url
|
||||
? tx("settings.values.configured", "Configured")
|
||||
: tx("settings.values.notConfigured", "Not configured")
|
||||
: settings.web_search.api_key_hint
|
||||
? tx("settings.values.configured", "Configured")
|
||||
: tx("settings.values.notConfigured", "Not configured");
|
||||
const webCaption = `${webSearchProviderLabel} · ${webSearchCredentialStatus}`;
|
||||
const imageStatus = settings.image_generation.enabled
|
||||
? tx("settings.values.enabled", "Enabled")
|
||||
: tx("settings.values.disabled", "Disabled");
|
||||
const imageCaption = `${providerDisplayLabel(settings.image_generation.providers, settings.image_generation.provider)} · ${
|
||||
settings.image_generation.provider_configured
|
||||
? tx("settings.values.configured", "Configured")
|
||||
: tx("settings.values.notConfigured", "Not configured")
|
||||
}`;
|
||||
const transcription = settings.transcription ?? DEFAULT_TRANSCRIPTION_SETTINGS;
|
||||
const voiceStatus = transcription.enabled
|
||||
? tx("settings.values.enabled", "Enabled")
|
||||
: tx("settings.values.disabled", "Disabled");
|
||||
const voiceCaption = `${providerDisplayLabel(transcription.providers, transcription.provider)} · ${
|
||||
transcription.provider_configured
|
||||
? tx("settings.values.configured", "Configured")
|
||||
: tx("settings.values.notConfigured", "Not configured")
|
||||
}`;
|
||||
const isNativeHost = (settings.surface ?? settings.runtime_surface) === "native";
|
||||
const workspaceCaption = shortWorkspacePath(settings.runtime.workspace_path);
|
||||
const runtimeTitle = isNativeHost
|
||||
? tx("settings.rows.engine", "Engine")
|
||||
: tx("settings.rows.gateway", "Gateway");
|
||||
const runtimeValue = isNativeHost
|
||||
? tx("settings.values.privateEngine", "Private engine")
|
||||
: `${settings.runtime.gateway_host}:${settings.runtime.gateway_port}`;
|
||||
const runtimeCaption = isNativeHost
|
||||
? tx("settings.values.unixSocket", "Unix socket")
|
||||
: requiresRestart
|
||||
? tx("settings.values.restartPending", "Restart pending")
|
||||
: tx("settings.values.ready", "Ready");
|
||||
return (
|
||||
<div className="space-y-7">
|
||||
<section className="rounded-[22px] bg-settings-surface px-4 py-4 sm:px-5">
|
||||
<TokenUsageHeatmap usage={settings.usage} timeZone={settings.agent.timezone} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SettingsSectionTitle>{tx("settings.sections.ai", "AI")}</SettingsSectionTitle>
|
||||
<SettingsGroup>
|
||||
<OverviewListRow
|
||||
icon={Bot}
|
||||
valueLogoProvider={activeProvider}
|
||||
title={tx("settings.overview.model", "Current model")}
|
||||
value={activeModelValue}
|
||||
caption={activeModelCaption}
|
||||
showBrandLogos={showBrandLogos}
|
||||
onClick={() => onSelectSection("models")}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SettingsSectionTitle>{tx("settings.sections.capabilities", "Capabilities")}</SettingsSectionTitle>
|
||||
<SettingsGroup>
|
||||
<OverviewListRow
|
||||
icon={Globe2}
|
||||
valueLogoProvider={settings.web_search.provider}
|
||||
title={tx("settings.overview.webSearch", "Web search")}
|
||||
value={webStatus}
|
||||
caption={webCaption}
|
||||
showBrandLogos={showBrandLogos}
|
||||
onClick={() => onSelectSection("browser")}
|
||||
/>
|
||||
<OverviewListRow
|
||||
icon={ImageIcon}
|
||||
valueLogoProvider={settings.image_generation.provider}
|
||||
title={tx("settings.overview.imageGeneration", "Image generation")}
|
||||
value={imageStatus}
|
||||
caption={imageCaption}
|
||||
showBrandLogos={showBrandLogos}
|
||||
onClick={() => onSelectSection("image")}
|
||||
/>
|
||||
<OverviewListRow
|
||||
icon={Mic}
|
||||
valueLogoProvider={transcription.provider}
|
||||
title={tx("settings.overview.voiceInput", "Voice input")}
|
||||
value={voiceStatus}
|
||||
caption={voiceCaption}
|
||||
showBrandLogos={showBrandLogos}
|
||||
onClick={() => onSelectSection("voice")}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SettingsSectionTitle>{tx("settings.sections.system", "System")}</SettingsSectionTitle>
|
||||
<SettingsGroup>
|
||||
<OverviewListRow
|
||||
icon={Server}
|
||||
title={runtimeTitle}
|
||||
value={runtimeValue}
|
||||
caption={runtimeCaption}
|
||||
onClick={() => onSelectSection("runtime")}
|
||||
/>
|
||||
<OverviewListRow
|
||||
icon={HardDrive}
|
||||
title={tx("settings.overview.workspace", "Workspace")}
|
||||
value={tx("settings.values.defaultWorkspace", "Default workspace")}
|
||||
caption={workspaceCaption}
|
||||
onClick={() => onSelectSection("runtime")}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SettingsSectionTitle>{tx("settings.sections.about", "About")}</SettingsSectionTitle>
|
||||
<SettingsGroup>
|
||||
<VersionCheckRow currentVersion={settings.version?.current} />
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VersionCheckRow({ currentVersion }: { currentVersion?: string }) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const { token } = useClient();
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [result, setResult] = useState<
|
||||
| { type: "up-to-date" }
|
||||
| { type: "update"; latestVersion: string; pypiUrl?: string }
|
||||
| { type: "error"; message: string }
|
||||
| null
|
||||
>(null);
|
||||
|
||||
const handleCheck = async () => {
|
||||
setChecking(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await checkVersion(token);
|
||||
if (res.updateAvailable) {
|
||||
setResult({
|
||||
type: "update",
|
||||
latestVersion: res.updateAvailable.latestVersion,
|
||||
pypiUrl: res.updateAvailable.pypiUrl,
|
||||
});
|
||||
} else {
|
||||
setResult({ type: "up-to-date" });
|
||||
}
|
||||
} catch (err) {
|
||||
setResult({ type: "error", message: (err as Error).message });
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[62px] flex-col gap-3 px-4 py-3.5 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[14px] font-medium leading-5 text-foreground">
|
||||
{tx("settings.about.version", "Version")}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[12px] leading-5 text-muted-foreground">
|
||||
{currentVersion ? `v${currentVersion}` : "nanobot"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void handleCheck()}
|
||||
disabled={checking}
|
||||
className="rounded-full"
|
||||
>
|
||||
{checking ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<ArrowUpCircle className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
{checking
|
||||
? tx("settings.about.checking", "Checking...")
|
||||
: tx("settings.about.checkForUpdates", "Check for updates")}
|
||||
</Button>
|
||||
{result?.type === "up-to-date" ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-[12px] text-emerald-600 dark:text-emerald-300">
|
||||
<Check className="h-3 w-3" aria-hidden />
|
||||
{tx("settings.about.upToDate", "You're up to date")}
|
||||
</span>
|
||||
) : null}
|
||||
{result?.type === "update" ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-[12px] text-blue-600 dark:text-blue-300">
|
||||
<ArrowUpCircle className="h-3 w-3" aria-hidden />
|
||||
{t("settings.about.updateAvailable", {
|
||||
defaultValue: "Update available v{{version}}",
|
||||
version: result.latestVersion,
|
||||
})}
|
||||
{result.pypiUrl ? (
|
||||
<a
|
||||
href={result.pypiUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-0.5 underline-offset-2 hover:underline"
|
||||
>
|
||||
PyPI
|
||||
<ExternalLink className="h-2.5 w-2.5" aria-hidden />
|
||||
</a>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
{result?.type === "error" ? (
|
||||
<span className="text-[12px] text-destructive">{result.message}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppearanceSettings({
|
||||
theme,
|
||||
onToggleTheme,
|
||||
localPrefs,
|
||||
onChangeLocalPrefs,
|
||||
}: {
|
||||
theme: "light" | "dark";
|
||||
onToggleTheme: () => void;
|
||||
localPrefs: LocalPreferences;
|
||||
onChangeLocalPrefs: Dispatch<SetStateAction<LocalPreferences>>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
return (
|
||||
<div className="space-y-7">
|
||||
<section>
|
||||
<SettingsSectionTitle>{t("settings.sections.interface")}</SettingsSectionTitle>
|
||||
<SettingsGroup>
|
||||
<SettingsRow title={t("settings.rows.theme")}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleTheme}
|
||||
className="inline-flex h-8 items-center rounded-full bg-muted p-0.5 text-[12px] font-medium text-muted-foreground"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 transition-colors",
|
||||
theme === "light" &&
|
||||
"bg-background text-foreground ring-1 ring-inset ring-border/45",
|
||||
)}
|
||||
>
|
||||
{t("settings.values.light")}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 transition-colors",
|
||||
theme === "dark" &&
|
||||
"bg-background text-foreground ring-1 ring-inset ring-border/45",
|
||||
)}
|
||||
>
|
||||
{t("settings.values.dark")}
|
||||
</span>
|
||||
</button>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow title={t("settings.rows.language")}>
|
||||
<LanguageSwitcher />
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SettingsSectionTitle>{tx("settings.sections.localPreferences", "Local preferences")}</SettingsSectionTitle>
|
||||
<SettingsGroup>
|
||||
<SettingsRow title={tx("settings.rows.density", "Density")}>
|
||||
<SegmentedControl
|
||||
value={localPrefs.density}
|
||||
options={[
|
||||
{ value: "comfortable", label: tx("settings.values.comfortable", "Comfortable") },
|
||||
{ value: "compact", label: tx("settings.values.compact", "Compact") },
|
||||
]}
|
||||
onChange={(density) =>
|
||||
onChangeLocalPrefs((prev) => ({ ...prev, density: density as LocalDensity }))
|
||||
}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow title={tx("settings.rows.activityMode", "Activity detail")}>
|
||||
<SegmentedControl
|
||||
value={localPrefs.activityMode}
|
||||
options={[
|
||||
{ value: "auto", label: tx("settings.values.auto", "Auto") },
|
||||
{ value: "expanded", label: tx("settings.values.expanded", "Expanded") },
|
||||
]}
|
||||
onChange={(activityMode) =>
|
||||
onChangeLocalPrefs((prev) => ({ ...prev, activityMode: activityMode as LocalActivityMode }))
|
||||
}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow title={tx("settings.rows.fileEditDisplay", "File edit display")}>
|
||||
<SegmentedControl
|
||||
value={localPrefs.fileEditDisplayMode}
|
||||
options={[
|
||||
{ value: "summary", label: tx("settings.values.summary", "Summary") },
|
||||
{ value: "diff", label: tx("settings.values.diff", "Diff") },
|
||||
{ value: "collapsed_diff", label: tx("settings.values.collapsedDiff", "Collapsed diff") },
|
||||
]}
|
||||
onChange={(fileEditDisplayMode) =>
|
||||
onChangeLocalPrefs((prev) => ({
|
||||
...prev,
|
||||
fileEditDisplayMode: fileEditDisplayMode as FileEditDisplayMode,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow title={tx("settings.rows.codeWrap", "Code wrapping")}>
|
||||
<ToggleButton
|
||||
checked={localPrefs.codeWrap}
|
||||
onChange={(codeWrap) => onChangeLocalPrefs((prev) => ({ ...prev, codeWrap }))}
|
||||
ariaLabel={tx("settings.rows.codeWrap", "Code wrapping")}
|
||||
label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={tx("settings.rows.brandLogos", "Brand logos")}
|
||||
description={tx(
|
||||
"settings.help.brandLogos",
|
||||
"Load third-party brand logos from external icon services. Turn this off to use local initials.",
|
||||
)}
|
||||
>
|
||||
<ToggleButton
|
||||
checked={localPrefs.brandLogos}
|
||||
onChange={(brandLogos) => onChangeLocalPrefs((prev) => ({ ...prev, brandLogos }))}
|
||||
ariaLabel={tx("settings.rows.brandLogos", "Brand logos")}
|
||||
label={localPrefs.brandLogos ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewRowIcon({
|
||||
icon: Icon,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
}) {
|
||||
return (
|
||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-[12px] bg-muted text-foreground/82 transition-colors group-hover:bg-muted/80 dark:bg-muted/70">
|
||||
<Icon className="h-4 w-4" aria-hidden />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewValueLogo({
|
||||
provider,
|
||||
showBrandLogos,
|
||||
}: {
|
||||
provider: string | null | undefined;
|
||||
showBrandLogos: boolean;
|
||||
}) {
|
||||
const brand = provider ? providerBrand(provider) : null;
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls);
|
||||
|
||||
if (!provider || !showBrandLogos || !brand) return null;
|
||||
|
||||
if (logoUrl) {
|
||||
return (
|
||||
<span
|
||||
data-testid={`overview-logo-${provider}`}
|
||||
className="grid h-5 w-5 shrink-0 place-items-center overflow-hidden rounded-md border border-border/35 bg-background"
|
||||
aria-hidden
|
||||
>
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className="h-3.5 w-3.5 object-contain"
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
data-testid={`overview-logo-fallback-${provider}`}
|
||||
className="grid h-5 w-5 shrink-0 place-items-center rounded-md text-[7.5px] font-semibold text-white"
|
||||
style={{ backgroundColor: brand.color }}
|
||||
aria-hidden
|
||||
>
|
||||
{brand.initials}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewListRow({
|
||||
icon: Icon,
|
||||
valueLogoProvider,
|
||||
title,
|
||||
value,
|
||||
caption,
|
||||
showBrandLogos = false,
|
||||
onClick,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
valueLogoProvider?: string | null;
|
||||
title: string;
|
||||
value: string;
|
||||
caption: string;
|
||||
showBrandLogos?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="group flex min-h-[68px] w-full items-center gap-3 px-4 py-3.5 text-left transition-colors hover:bg-muted/30 sm:px-5"
|
||||
>
|
||||
<OverviewRowIcon icon={Icon} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[14px] font-medium leading-5 text-foreground">{title}</span>
|
||||
<span className="mt-0.5 block truncate text-[12px] leading-5 text-muted-foreground">{caption}</span>
|
||||
</span>
|
||||
<span className="ml-auto flex min-w-0 max-w-[48%] items-center gap-2">
|
||||
<OverviewValueLogo provider={valueLogoProvider} showBrandLogos={showBrandLogos} />
|
||||
<span className="truncate text-right text-[13px] leading-5 text-muted-foreground">
|
||||
{value}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className="h-4 w-4 shrink-0 text-muted-foreground/60 transition-transform group-hover:translate-x-0.5"
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,619 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Bot,
|
||||
Brain,
|
||||
Check,
|
||||
ChevronDown,
|
||||
CircleAlert,
|
||||
Cloud,
|
||||
Cpu,
|
||||
Database,
|
||||
Gem,
|
||||
Grid3X3,
|
||||
Hexagon,
|
||||
Layers,
|
||||
Loader2,
|
||||
Moon,
|
||||
Orbit,
|
||||
Pencil,
|
||||
Search,
|
||||
Sparkles,
|
||||
Triangle,
|
||||
Waves,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ComboboxOption, useComboboxNavigation } from "@/components/ui/combobox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||
import { fetchProviderModels } from "@/lib/api";
|
||||
import { providerBrand } from "@/lib/provider-brand";
|
||||
import type { ProviderModelsPayload, SettingsPayload } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DEFERRED_MODEL_LIST_PROVIDERS = new Set([
|
||||
"aihubmix",
|
||||
"atomic_chat",
|
||||
"byteplus",
|
||||
"byteplus_coding_plan",
|
||||
"huggingface",
|
||||
"lm_studio",
|
||||
"modelscope",
|
||||
"novita",
|
||||
"ollama",
|
||||
"openrouter",
|
||||
"ovms",
|
||||
"siliconflow",
|
||||
"vllm",
|
||||
"volcengine",
|
||||
"volcengine_coding_plan",
|
||||
]);
|
||||
const DEFERRED_MODEL_LIST_QUERY_MIN_LENGTH = 2;
|
||||
|
||||
export function normalizeContextWindowTokens(value: number | null | undefined): number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 200_000;
|
||||
}
|
||||
|
||||
function settingsProviderRow(
|
||||
payload: SettingsPayload,
|
||||
provider: string | null | undefined,
|
||||
): SettingsPayload["providers"][number] | null {
|
||||
if (!provider) return null;
|
||||
return payload.providers.find((row) => row.name === provider) ?? null;
|
||||
}
|
||||
|
||||
export function settingsProviderConfigured(
|
||||
payload: SettingsPayload,
|
||||
provider: string | null | undefined,
|
||||
resolvedProvider?: string | null,
|
||||
): boolean {
|
||||
const row = settingsProviderRow(payload, provider);
|
||||
if (row) return row.configured;
|
||||
if (provider === "auto") {
|
||||
const resolvedRow = settingsProviderRow(
|
||||
payload,
|
||||
resolvedProvider ?? payload.agent.resolved_provider ?? payload.agent.provider,
|
||||
);
|
||||
if (resolvedRow) return resolvedRow.configured;
|
||||
}
|
||||
return payload.agent.has_api_key;
|
||||
}
|
||||
|
||||
export function ProviderPicker({
|
||||
providers,
|
||||
value,
|
||||
emptyLabel,
|
||||
showProviderLogos = false,
|
||||
onChange,
|
||||
}: {
|
||||
providers: Array<{ name: string; label: string }>;
|
||||
value: string;
|
||||
emptyLabel: string;
|
||||
showProviderLogos?: boolean;
|
||||
onChange: (provider: string) => void;
|
||||
}) {
|
||||
const selectedProvider = providers.find((provider) => provider.name === value) ?? null;
|
||||
const disabled = providers.length === 0;
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild disabled={disabled}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"h-8 w-[210px] justify-between rounded-full border-input bg-background px-3 text-[13px] font-normal shadow-none",
|
||||
"hover:bg-accent/55 focus-visible:ring-2 focus-visible:ring-ring",
|
||||
disabled && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{selectedProvider && showProviderLogos ? (
|
||||
<ProviderPickerIcon
|
||||
provider={selectedProvider.name}
|
||||
showBrandLogos={showProviderLogos}
|
||||
/>
|
||||
) : null}
|
||||
<span className="truncate">{selectedProvider?.label ?? emptyLabel}</span>
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="max-h-[18rem] w-[240px] overflow-y-auto scrollbar-thin scrollbar-track-transparent"
|
||||
>
|
||||
{providers.map((provider) => {
|
||||
const selected = provider.name === value;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={provider.name}
|
||||
onSelect={() => onChange(provider.name)}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-between gap-2 text-[13px]",
|
||||
selected && "bg-muted/80 text-foreground focus:bg-muted",
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{showProviderLogos ? (
|
||||
<ProviderPickerIcon
|
||||
provider={provider.name}
|
||||
showBrandLogos={showProviderLogos}
|
||||
/>
|
||||
) : null}
|
||||
<span className="truncate">{provider.label}</span>
|
||||
</span>
|
||||
{selected ? <Check className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelIdPicker({
|
||||
token,
|
||||
settings,
|
||||
provider,
|
||||
models,
|
||||
value,
|
||||
showProviderLogos,
|
||||
emptyLabel,
|
||||
searchPlaceholder,
|
||||
emptyMessage,
|
||||
onChange,
|
||||
}: {
|
||||
token: string;
|
||||
settings: SettingsPayload;
|
||||
provider: string;
|
||||
models?: string[];
|
||||
value: string;
|
||||
showProviderLogos: boolean;
|
||||
emptyLabel?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyMessage?: string;
|
||||
onChange: (model: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const tokenRef = useRef(token);
|
||||
tokenRef.current = token;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [payload, setPayload] = useState<ProviderModelsPayload | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const effectiveProvider =
|
||||
provider === "auto" ? settings.agent.resolved_provider ?? provider : provider;
|
||||
const hasConcreteProvider = Boolean(effectiveProvider && effectiveProvider !== "auto");
|
||||
const hasStaticModels = models !== undefined;
|
||||
const providerRow = settingsProviderRow(settings, effectiveProvider);
|
||||
const providerConfigured = settingsProviderConfigured(settings, effectiveProvider);
|
||||
const providerRequiresConfiguration =
|
||||
!hasStaticModels && hasConcreteProvider && !providerConfigured;
|
||||
const providerHasBuiltinModels = providerRow?.model_catalog === "builtin";
|
||||
const providerUsesManualModelIds =
|
||||
!hasStaticModels &&
|
||||
hasConcreteProvider &&
|
||||
providerConfigured &&
|
||||
providerRow?.auth_type === "oauth" &&
|
||||
!providerHasBuiltinModels;
|
||||
const canFetchModels =
|
||||
!hasStaticModels &&
|
||||
hasConcreteProvider && providerConfigured && !providerUsesManualModelIds;
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const providerModels: ProviderModelsPayload["models"] = useMemo(
|
||||
() => hasStaticModels
|
||||
? (models?.map((id) => ({ id })) ?? [])
|
||||
: (payload?.models ?? []),
|
||||
[hasStaticModels, models, payload?.models],
|
||||
);
|
||||
const visibleModels = useMemo(
|
||||
() => providerModels
|
||||
.filter((model) => {
|
||||
if (!normalizedQuery) return true;
|
||||
return [model.id, model.label ?? "", model.description ?? "", model.owned_by ?? ""]
|
||||
.some((field) => field.toLowerCase().includes(normalizedQuery));
|
||||
})
|
||||
.slice(0, 80),
|
||||
[normalizedQuery, providerModels],
|
||||
);
|
||||
const isCatalog = payload?.catalog_kind === "catalog";
|
||||
const defersModelList = DEFERRED_MODEL_LIST_PROVIDERS.has(effectiveProvider);
|
||||
const hasDeferredSearchQuery =
|
||||
normalizedQuery.length >= DEFERRED_MODEL_LIST_QUERY_MIN_LENGTH;
|
||||
const shouldFetchModels =
|
||||
canFetchModels && (!defersModelList || hasDeferredSearchQuery);
|
||||
const waitingForModelSearch =
|
||||
open && canFetchModels && defersModelList && !hasDeferredSearchQuery;
|
||||
const hasModelList = hasStaticModels || payload?.status === "available";
|
||||
const showModels = Boolean(
|
||||
hasModelList && (hasStaticModels || (payload && (!isCatalog || normalizedQuery))),
|
||||
);
|
||||
const customCandidate = query.trim();
|
||||
const allowCustomModel = !providerRequiresConfiguration;
|
||||
const exactQueryMatch = providerModels.some((model) => model.id === customCandidate);
|
||||
const showCustomModel = Boolean(
|
||||
allowCustomModel && customCandidate && !exactQueryMatch && customCandidate !== value,
|
||||
);
|
||||
const providerModelCount = payload?.model_count ?? providerModels.length;
|
||||
const modelUnconfigured = !value.trim() || !providerConfigured;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setQuery(providerUsesManualModelIds || !hasConcreteProvider ? value : "");
|
||||
}, [open, effectiveProvider, hasConcreteProvider, providerUsesManualModelIds, value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !shouldFetchModels) {
|
||||
setPayload(null);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setPayload(null);
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
fetchProviderModels(tokenRef.current, effectiveProvider)
|
||||
.then((nextPayload) => {
|
||||
if (!cancelled) setPayload(nextPayload);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError((err as Error).message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [effectiveProvider, open, shouldFetchModels]);
|
||||
|
||||
const selectModel = (model: string) => {
|
||||
onChange(model);
|
||||
setOpen(false);
|
||||
};
|
||||
const navigationValues = useMemo(
|
||||
() => [
|
||||
...(showModels ? visibleModels.map((model) => model.id) : []),
|
||||
...(showCustomModel ? [customCandidate] : []),
|
||||
],
|
||||
[customCandidate, showCustomModel, showModels, visibleModels],
|
||||
);
|
||||
const navigation = useComboboxNavigation({
|
||||
open,
|
||||
values: navigationValues,
|
||||
selectedValue: value,
|
||||
onSelect: selectModel,
|
||||
onClose: () => setOpen(false),
|
||||
});
|
||||
|
||||
const renderModelRow = (
|
||||
model: ProviderModelsPayload["models"][number],
|
||||
options: { selected?: boolean } = {},
|
||||
) => (
|
||||
<ComboboxOption
|
||||
key={model.id}
|
||||
{...navigation.getOptionProps(model.id)}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-between gap-2 rounded-[12px] px-2 py-1.5 text-[12px]",
|
||||
options.selected && "text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<ProviderPickerIcon
|
||||
provider={effectiveProvider}
|
||||
showBrandLogos={showProviderLogos}
|
||||
unconfigured={!providerConfigured}
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-medium text-foreground">
|
||||
{model.label ?? model.id}
|
||||
</span>
|
||||
{model.description || (model.label && model.label !== model.id) ? (
|
||||
<span className="mt-0.5 block truncate text-[10.5px] text-muted-foreground">
|
||||
{[model.label && model.label !== model.id ? model.id : null, model.description]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
<span className="ml-2 flex shrink-0 items-center gap-2 text-[11px] text-muted-foreground">
|
||||
{model.context_window ? <span>{formatContextWindow(model.context_window)}</span> : null}
|
||||
{options.selected ? <Check className="h-3.5 w-3.5 text-foreground" aria-hidden /> : null}
|
||||
</span>
|
||||
</ComboboxOption>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-9 w-[min(360px,70vw)] justify-between rounded-full border-input bg-background px-3 text-[12px] font-normal shadow-none",
|
||||
"hover:bg-accent/55 focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<ProviderPickerIcon
|
||||
provider={effectiveProvider}
|
||||
showBrandLogos={showProviderLogos}
|
||||
unconfigured={modelUnconfigured}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate font-medium",
|
||||
value ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{value || emptyLabel || tx("settings.models.selectModel", "Select model")}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="w-[360px] max-w-[calc(100vw-2rem)] p-1.5"
|
||||
>
|
||||
<div className="p-1 pb-1.5">
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
{...navigation.inputProps}
|
||||
placeholder={
|
||||
searchPlaceholder || tx("settings.models.searchModels", "Search or type model ID")
|
||||
}
|
||||
aria-label={
|
||||
searchPlaceholder || tx("settings.models.searchModels", "Search or type model ID")
|
||||
}
|
||||
className="h-8 rounded-full pl-8 pr-3 text-[12px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{providerRequiresConfiguration ? (
|
||||
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||
{tx("settings.models.providerNotConfigured", "Configure this provider before loading models.")}
|
||||
</div>
|
||||
) : hasStaticModels && !providerModels.length ? (
|
||||
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||
{emptyMessage || tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
|
||||
</div>
|
||||
) : providerUsesManualModelIds ? (
|
||||
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||
{tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
|
||||
</div>
|
||||
) : !canFetchModels ? (
|
||||
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||
{tx("settings.models.autoProviderCustomOnly", "Auto provider mode uses custom model IDs.")}
|
||||
</div>
|
||||
) : waitingForModelSearch ? (
|
||||
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||
{tx("settings.models.searchCatalog", "Search provider catalog to choose a model.")}
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 text-[11px] text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
{tx("settings.models.loadingModels", "Loading models...")}
|
||||
</div>
|
||||
) : error || payload?.status === "error" ? (
|
||||
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||
{payload?.message || error || tx("settings.models.loadFailed", "Model list unavailable.")}
|
||||
</div>
|
||||
) : payload?.status === "not_configured" ? (
|
||||
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||
{tx("settings.models.providerNotConfigured", "Configure this provider before loading models.")}
|
||||
</div>
|
||||
) : payload?.status === "unsupported" || payload?.status === "missing_api_base" ? (
|
||||
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||
{payload.message || tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
|
||||
</div>
|
||||
) : isCatalog && !normalizedQuery ? (
|
||||
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||
{tx("settings.models.searchCatalog", "Search provider catalog to choose a model.")}
|
||||
{providerModelCount ? ` ${providerModelCount} ${tx("settings.models.modelsAvailable", "available")}.` : ""}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{navigationValues.length ? (
|
||||
<div
|
||||
{...navigation.listProps}
|
||||
aria-label={searchPlaceholder || tx("settings.models.selectModel", "Select model")}
|
||||
className="max-h-[16rem] overflow-y-auto pr-0.5 scrollbar-thin scrollbar-track-transparent"
|
||||
>
|
||||
{showModels
|
||||
? visibleModels.map((model) =>
|
||||
renderModelRow(model, { selected: model.id === value }),
|
||||
)
|
||||
: null}
|
||||
{showCustomModel ? (
|
||||
<>
|
||||
{showModels && visibleModels.length ? (
|
||||
<div role="separator" className="-mx-1.5 my-1.5 h-px bg-border/50" />
|
||||
) : null}
|
||||
<ComboboxOption
|
||||
{...navigation.getOptionProps(customCandidate)}
|
||||
className="flex cursor-default items-center gap-2 rounded-[12px] px-2 py-1.5 text-[12px]"
|
||||
>
|
||||
<span className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-muted/80 text-muted-foreground">
|
||||
<Pencil className="h-3 w-3" aria-hidden />
|
||||
</span>
|
||||
<span className="min-w-0 truncate">
|
||||
{tx("settings.models.useCustomModel", "Use")}{" "}
|
||||
<span className="font-medium text-foreground">“{customCandidate}”</span>
|
||||
</span>
|
||||
</ComboboxOption>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : showModels ? (
|
||||
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">
|
||||
{tx("settings.models.noModelResults", "No matching models.")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export function formatContextWindow(tokens: number): string {
|
||||
if (tokens >= 1_000_000) {
|
||||
const value = tokens / 1_000_000;
|
||||
return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}M`;
|
||||
}
|
||||
if (tokens >= 1_000) {
|
||||
const value = tokens / 1_000;
|
||||
return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}K`;
|
||||
}
|
||||
return String(tokens);
|
||||
}
|
||||
|
||||
export function formatModelContextWindow(tokens: number): string {
|
||||
if (tokens === 65_536) return "64K";
|
||||
if (tokens === 262_144) return "256K";
|
||||
if (tokens === 1_048_576) return "1M";
|
||||
return formatContextWindow(tokens);
|
||||
}
|
||||
|
||||
export function ProviderPickerIcon({
|
||||
provider,
|
||||
showBrandLogos,
|
||||
unconfigured = false,
|
||||
}: {
|
||||
provider: string;
|
||||
showBrandLogos: boolean;
|
||||
unconfigured?: boolean;
|
||||
}) {
|
||||
const brand = providerBrand(provider);
|
||||
const Icon = PROVIDER_ICONS[provider] ?? Hexagon;
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls);
|
||||
|
||||
if (unconfigured) {
|
||||
return (
|
||||
<span
|
||||
data-testid="provider-picker-unconfigured-icon"
|
||||
className="grid h-5 w-5 shrink-0 place-items-center text-amber-700 dark:text-amber-200"
|
||||
aria-hidden
|
||||
>
|
||||
<CircleAlert className="h-4 w-4" strokeWidth={1.8} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (showBrandLogos && logoUrl) {
|
||||
return (
|
||||
<span
|
||||
data-testid={`provider-picker-logo-${provider}`}
|
||||
className="grid h-5 w-5 shrink-0 place-items-center overflow-hidden rounded-md border border-border/35 bg-background"
|
||||
aria-hidden
|
||||
>
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className="h-3.5 w-3.5 object-contain"
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (showBrandLogos && brand) {
|
||||
return (
|
||||
<span
|
||||
data-testid={`provider-picker-logo-fallback-${provider}`}
|
||||
className="grid h-5 w-5 shrink-0 place-items-center rounded-md text-[7.5px] font-semibold text-white"
|
||||
style={{ backgroundColor: brand.color }}
|
||||
aria-hidden
|
||||
>
|
||||
{brand.initials}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground"
|
||||
aria-hidden
|
||||
>
|
||||
<Icon className="h-3 w-3" strokeWidth={2} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function optionRowsWithCurrent(
|
||||
options: Array<{ name: string; label: string }>,
|
||||
value: string,
|
||||
): Array<{ name: string; label: string }> {
|
||||
if (!value || options.some((option) => option.name === value)) return options;
|
||||
return [{ name: value, label: value }, ...options];
|
||||
}
|
||||
|
||||
export const PROVIDER_ICONS: Record<string, LucideIcon> = {
|
||||
custom: Hexagon,
|
||||
openrouter: Sparkles,
|
||||
skywork: Sparkles,
|
||||
aihubmix: Triangle,
|
||||
anthropic: Brain,
|
||||
openai: Bot,
|
||||
deepseek: Waves,
|
||||
zhipu: Grid3X3,
|
||||
dashscope: Cloud,
|
||||
modelscope: Layers,
|
||||
moonshot: Moon,
|
||||
minimax: Zap,
|
||||
minimax_anthropic: Brain,
|
||||
groq: Cpu,
|
||||
huggingface: Layers,
|
||||
gemini: Gem,
|
||||
mistral: Orbit,
|
||||
siliconflow: Layers,
|
||||
volcengine: Cloud,
|
||||
volcengine_coding_plan: Cloud,
|
||||
byteplus: Cloud,
|
||||
byteplus_coding_plan: Cloud,
|
||||
qianfan: Database,
|
||||
ant_ling: Sparkles,
|
||||
azure_openai: Cloud,
|
||||
bedrock: Database,
|
||||
bocha: Search,
|
||||
brave: Search,
|
||||
duckduckgo: Search,
|
||||
exa: Search,
|
||||
jina: Search,
|
||||
kagi: Search,
|
||||
olostep: Search,
|
||||
searxng: Search,
|
||||
tavily: Search,
|
||||
vllm: Cpu,
|
||||
ollama: Cpu,
|
||||
lm_studio: Cpu,
|
||||
atomic_chat: Cpu,
|
||||
ovms: Cpu,
|
||||
nvidia: Zap,
|
||||
};
|
||||
@@ -0,0 +1,409 @@
|
||||
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",
|
||||
);
|
||||
|
||||
export function CapabilityInstallNotice({
|
||||
title,
|
||||
description,
|
||||
installing = false,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
installing?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-[14px] border border-border/55 bg-muted/22 px-3.5 py-3">
|
||||
{installing ? (
|
||||
<Loader2 className="mt-0.5 h-4 w-4 shrink-0 animate-spin text-muted-foreground" aria-hidden />
|
||||
) : (
|
||||
<CircleAlert className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="text-[12.5px] font-medium text-foreground">{title}</p>
|
||||
<p className="mt-0.5 text-[12px] leading-5 text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NanobotFeatureInstallDialog({
|
||||
feature,
|
||||
installing,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: {
|
||||
feature: NanobotFeatureInfo | null;
|
||||
installing: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: (feature: NanobotFeatureInfo) => void | Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
|
||||
t(key, { defaultValue: fallback, ...(values ?? {}) });
|
||||
const name = feature?.display_name || feature?.name || "";
|
||||
return (
|
||||
<Dialog open={Boolean(feature)} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="w-[min(calc(100vw-2rem),24rem)] gap-0 p-5 text-center"
|
||||
>
|
||||
<DialogHeader className="items-center space-y-0 text-center">
|
||||
<DialogTitle className="text-center text-[20px] font-semibold leading-tight tracking-[-0.02em] text-foreground">
|
||||
{tx("settings.nanobotFeatures.installConfirmTitle", "Install support for {{name}}?", { name })}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-3 max-w-[20rem] text-center text-[14px] leading-6 text-muted-foreground">
|
||||
{tx(
|
||||
"settings.nanobotFeatures.installConfirmDescription",
|
||||
"nanobot will add what {{name}} needs, then turn it on. Continue?",
|
||||
{ name },
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="mt-7 !grid grid-cols-1 gap-3 space-x-0 sm:grid-cols-2 sm:space-x-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={installing}
|
||||
className="h-11 w-full min-w-0 rounded-full bg-muted/70 px-5 text-[15px] font-semibold text-foreground shadow-none hover:bg-muted"
|
||||
>
|
||||
{tx("settings.automations.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => feature && void onConfirm(feature)}
|
||||
disabled={!feature || installing}
|
||||
className="h-11 w-full min-w-0 !whitespace-normal rounded-full px-5 text-center text-[15px] font-semibold"
|
||||
>
|
||||
{installing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden /> : null}
|
||||
{tx("settings.nanobotFeatures.installConfirmAction", "Install and enable")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function DismissibleStatusMessage({
|
||||
message,
|
||||
isError,
|
||||
onDismiss,
|
||||
}: {
|
||||
message: string;
|
||||
isError: boolean;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-3 rounded-[12px] border py-2.5 pl-4 pr-2 text-[13px]",
|
||||
isError
|
||||
? "border-destructive/20 bg-destructive/5 text-destructive"
|
||||
: "border-border/55 bg-muted/35 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0">{message}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={tx("settings.actions.dismiss", "Dismiss")}
|
||||
title={tx("settings.actions.dismiss", "Dismiss")}
|
||||
onClick={onDismiss}
|
||||
className={cn(
|
||||
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors",
|
||||
isError
|
||||
? "text-destructive/70 hover:bg-destructive/10 hover:text-destructive"
|
||||
: "text-muted-foreground/70 hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RestartRequiredNotice({
|
||||
message,
|
||||
onRestart,
|
||||
isRestarting,
|
||||
}: {
|
||||
message: string;
|
||||
onRestart?: () => void;
|
||||
isRestarting?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-[12px] border border-amber-500/20 bg-amber-500/8 px-4 py-3 text-[12.5px] text-amber-800 dark:text-amber-200 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span>{message}</span>
|
||||
{onRestart ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onRestart}
|
||||
disabled={isRestarting}
|
||||
className="h-8 rounded-full bg-background/80 px-3 text-[12px] font-semibold"
|
||||
>
|
||||
{isRestarting ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
{isRestarting ? t("app.system.restarting") : t("app.system.restart")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsSectionTitle({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<h2 className="mb-2 px-1 text-[13px] font-semibold tracking-[-0.01em] text-foreground/85">
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsGroup({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[22px] bg-settings-surface">
|
||||
<div className="divide-y divide-border/45">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsRow({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-[62px] flex-col gap-3 px-4 py-3.5 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[14px] font-medium leading-5 text-foreground">{title}</div>
|
||||
{description ? (
|
||||
<div className="mt-0.5 max-w-[28rem] text-[12px] leading-5 text-muted-foreground">
|
||||
{description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{children ? <div className="min-w-0 sm:ml-6 sm:shrink-0">{children}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReadOnlyRow({
|
||||
title,
|
||||
value,
|
||||
description,
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
}) {
|
||||
return (
|
||||
<SettingsRow title={title} description={description}>
|
||||
<span className="block max-w-full truncate text-left text-[13px] text-muted-foreground sm:max-w-[320px] sm:text-right">
|
||||
{value}
|
||||
</span>
|
||||
</SettingsRow>
|
||||
);
|
||||
}
|
||||
|
||||
export function RestartSettingsFooter({
|
||||
dirty,
|
||||
saving,
|
||||
pendingRestart,
|
||||
disabled = false,
|
||||
message,
|
||||
dirtyMessage,
|
||||
pendingMessage,
|
||||
onSave,
|
||||
onRestart,
|
||||
onReset,
|
||||
isRestarting,
|
||||
}: {
|
||||
dirty: boolean;
|
||||
saving: boolean;
|
||||
pendingRestart: boolean;
|
||||
disabled?: boolean;
|
||||
message?: string;
|
||||
dirtyMessage?: string;
|
||||
pendingMessage?: string;
|
||||
onSave: () => void;
|
||||
onRestart?: () => void;
|
||||
onReset?: () => void;
|
||||
isRestarting?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const isNativeHost = isNativeRuntime();
|
||||
const restartLabel = isNativeHost
|
||||
? tx("app.system.restartEngine", "Restart engine")
|
||||
: t("app.system.restart");
|
||||
const restartingLabel = isNativeHost
|
||||
? tx("app.system.restartingEngine", "Restarting engine...")
|
||||
: t("app.system.restarting");
|
||||
const statusMessage =
|
||||
message ??
|
||||
(pendingRestart && !dirty
|
||||
? pendingMessage ?? tx("settings.status.savedRestartApply", "Saved. Restart when ready.")
|
||||
: dirty
|
||||
? dirtyMessage ?? t("settings.status.unsaved")
|
||||
: undefined);
|
||||
const statusTone = disabled ? "danger" : dirty || pendingRestart ? "accent" : undefined;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[58px] flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
||||
<div className="min-w-0 text-[13px] leading-5 text-muted-foreground">
|
||||
<SettingsStatusMessage tone={statusTone}>{statusMessage}</SettingsStatusMessage>
|
||||
</div>
|
||||
<div className="flex w-full shrink-0 flex-wrap justify-end gap-2 sm:w-auto">
|
||||
{pendingRestart && !dirty && onRestart ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onRestart}
|
||||
disabled={isRestarting}
|
||||
className="rounded-full"
|
||||
>
|
||||
{isRestarting ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
{isRestarting ? restartingLabel : restartLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
{onReset ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onReset}
|
||||
disabled={!dirty || saving}
|
||||
className="rounded-full"
|
||||
>
|
||||
{t("settings.actions.cancel")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onSave}
|
||||
disabled={!dirty || disabled || saving}
|
||||
className="rounded-full"
|
||||
>
|
||||
{saving ? t("settings.actions.saving") : t("settings.actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsStatusMessage({
|
||||
children,
|
||||
tone,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
tone?: "accent" | "danger";
|
||||
}) {
|
||||
if (!children) return null;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2",
|
||||
tone === "accent" && "font-medium text-blue-600 dark:text-blue-300",
|
||||
tone === "danger" && "font-medium text-destructive",
|
||||
)}
|
||||
>
|
||||
{tone ? (
|
||||
<span
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 shrink-0 rounded-full",
|
||||
tone === "accent" &&
|
||||
"bg-blue-500 dark:bg-blue-400",
|
||||
tone === "danger" && "bg-destructive/70",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
<span>{children}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusPill({
|
||||
children,
|
||||
tone = "neutral",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
tone?: "neutral" | "success" | "warning";
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex max-w-[260px] items-center rounded-full px-2.5 py-1 text-[12px] font-medium",
|
||||
tone === "success" && "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
||||
tone === "warning" && "bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
||||
tone === "neutral" && "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{children}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function NumberInput({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
onChange,
|
||||
suffix,
|
||||
}: {
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
onChange: (value: number) => void;
|
||||
suffix?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
const parsed = Number(event.target.value);
|
||||
if (Number.isFinite(parsed)) onChange(parsed);
|
||||
}}
|
||||
className="h-8 w-24 max-w-full rounded-full text-[13px]"
|
||||
/>
|
||||
{suffix ? <span className="text-[12px] text-muted-foreground">{suffix}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,257 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ChevronLeft, Loader2, Search } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
channelIsRunning,
|
||||
channelMatchesFilter,
|
||||
channelSearchText,
|
||||
localizedChannelDisplayName,
|
||||
type ChannelFilter,
|
||||
} from "@/components/settings/channels/ChannelIdentity";
|
||||
import { ChannelCatalogRow, ChannelSetupPanel } from "@/components/settings/channels/ChannelSetupPanel";
|
||||
import {
|
||||
DismissibleStatusMessage,
|
||||
RestartRequiredNotice,
|
||||
SETTINGS_SEARCH_INPUT_CLASS,
|
||||
} from "@/components/settings/shared/SettingsControls";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useMediaQuery } from "@/hooks/useMediaQuery";
|
||||
import type { NanobotFeaturesPayload } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ChannelsSettings({
|
||||
token,
|
||||
nanobotFeatures,
|
||||
loading,
|
||||
query,
|
||||
actionKey,
|
||||
chatAppsDocsUrl,
|
||||
showBrandLogos,
|
||||
error,
|
||||
requiresRestartPending,
|
||||
onQueryChange,
|
||||
onAction,
|
||||
onFeaturesUpdate,
|
||||
onDismissStatus,
|
||||
onRestart,
|
||||
isRestarting,
|
||||
}: {
|
||||
token: string;
|
||||
nanobotFeatures: NanobotFeaturesPayload | null;
|
||||
loading: boolean;
|
||||
query: string;
|
||||
actionKey: string | null;
|
||||
chatAppsDocsUrl?: string;
|
||||
showBrandLogos: boolean;
|
||||
error: string | null;
|
||||
requiresRestartPending: boolean;
|
||||
onQueryChange: (value: string) => void;
|
||||
onAction: (action: "enable" | "disable", name: string) => void;
|
||||
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
||||
onDismissStatus: () => void;
|
||||
onRestart?: () => void;
|
||||
isRestarting?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const [filter, setFilter] = useState<ChannelFilter>("all");
|
||||
const splitLayout = useMediaQuery("(min-width: 1280px)");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const compactDetailTopRef = useRef<HTMLButtonElement>(null);
|
||||
const [compactDetailOpen, setCompactDetailOpen] = useState(false);
|
||||
const allChannels = (nanobotFeatures?.features ?? [])
|
||||
.filter((feature) => feature.type === "channel")
|
||||
.filter((feature) => feature.settings_visible !== false)
|
||||
.filter((feature) => !normalizedQuery || channelSearchText(feature, t).includes(normalizedQuery))
|
||||
.sort((left, right) => {
|
||||
const rank = Number(!left.ready) - Number(!right.ready);
|
||||
return rank || localizedChannelDisplayName(left, t).localeCompare(
|
||||
localizedChannelDisplayName(right, t),
|
||||
);
|
||||
});
|
||||
const channels = allChannels.filter((feature) => channelMatchesFilter(feature, filter));
|
||||
const [selectedChannelName, setSelectedChannelName] = useState<string | null>(null);
|
||||
const selectedChannel =
|
||||
channels.find((feature) => feature.name === selectedChannelName) ?? channels[0] ?? null;
|
||||
const enabledCount = allChannels.filter(channelIsRunning).length;
|
||||
const offCount = Math.max(0, allChannels.length - enabledCount);
|
||||
const filterOptions: Array<{ value: ChannelFilter; label: string; count: number }> = [
|
||||
{ value: "all", label: tx("settings.channels.filterAll", "All"), count: allChannels.length },
|
||||
{ value: "on", label: tx("settings.channels.filterOn", "On"), count: enabledCount },
|
||||
{ value: "off", label: tx("settings.channels.filterOff", "Off"), count: offCount },
|
||||
];
|
||||
const statusMessage = error;
|
||||
const statusIsError = true;
|
||||
|
||||
useEffect(() => {
|
||||
if (!channels.length) {
|
||||
if (selectedChannelName !== null) setSelectedChannelName(null);
|
||||
setCompactDetailOpen(false);
|
||||
return;
|
||||
}
|
||||
if (!selectedChannelName || !channels.some((feature) => feature.name === selectedChannelName)) {
|
||||
setSelectedChannelName(channels[0].name);
|
||||
setCompactDetailOpen(false);
|
||||
}
|
||||
}, [channels, selectedChannelName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (splitLayout) return;
|
||||
const resetScroll = () => {
|
||||
let node = containerRef.current?.parentElement ?? null;
|
||||
while (node) {
|
||||
node.scrollTop = 0;
|
||||
node = node.parentElement;
|
||||
}
|
||||
if (compactDetailOpen) {
|
||||
compactDetailTopRef.current?.scrollIntoView?.({ block: "start" });
|
||||
}
|
||||
};
|
||||
resetScroll();
|
||||
const frame = window.requestAnimationFrame(resetScroll);
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [compactDetailOpen, selectedChannelName, splitLayout]);
|
||||
|
||||
const openChannel = (name: string) => {
|
||||
setSelectedChannelName(name);
|
||||
if (!splitLayout) setCompactDetailOpen(true);
|
||||
};
|
||||
|
||||
const setupPanel = selectedChannel ? (
|
||||
<ChannelSetupPanel
|
||||
token={token}
|
||||
feature={selectedChannel}
|
||||
actionKey={actionKey}
|
||||
chatAppsDocsUrl={chatAppsDocsUrl}
|
||||
showBrandLogos={showBrandLogos}
|
||||
onAction={onAction}
|
||||
onFeaturesUpdate={onFeaturesUpdate}
|
||||
/>
|
||||
) : null;
|
||||
const showingCompactDetail = !splitLayout && compactDetailOpen && selectedChannel !== null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex min-h-full flex-1 flex-col xl:min-h-0 xl:overflow-hidden"
|
||||
>
|
||||
{!showingCompactDetail ? (
|
||||
<section className="shrink-0 space-y-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
placeholder={tx("settings.channels.searchPlaceholder", "Search channels")}
|
||||
className={cn(
|
||||
"h-12 rounded-[14px] pl-11 text-[15px]",
|
||||
SETTINGS_SEARCH_INPUT_CLASS,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-1.5 rounded-[14px] bg-muted/55 p-1">
|
||||
{filterOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setFilter(option.value)}
|
||||
className={cn(
|
||||
"rounded-[11px] px-3 py-1.5 text-[12px] font-medium transition-colors",
|
||||
filter === option.value
|
||||
? "bg-background text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
<span className="ml-1 text-[11px] text-muted-foreground">{option.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{statusMessage ? (
|
||||
<div className="mt-3 shrink-0">
|
||||
<DismissibleStatusMessage
|
||||
message={statusMessage}
|
||||
isError={statusIsError}
|
||||
onDismiss={onDismissStatus}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{requiresRestartPending ? (
|
||||
<div className="mt-3 shrink-0">
|
||||
<RestartRequiredNotice
|
||||
message={tx("settings.channels.restartRequired", "Restart nanobot to apply updated channel support.")}
|
||||
onRestart={onRestart}
|
||||
isRestarting={isRestarting}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section
|
||||
className={cn(
|
||||
"flex flex-1 flex-col",
|
||||
showingCompactDetail ? "mt-1" : "mt-5",
|
||||
splitLayout && "min-h-0 overflow-hidden",
|
||||
)}
|
||||
>
|
||||
{loading && !nanobotFeatures ? (
|
||||
<div className="flex h-36 items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
|
||||
{tx("settings.channels.loading", "Loading Channels...")}
|
||||
</div>
|
||||
) : channels.length ? splitLayout ? (
|
||||
<div className="grid min-h-0 flex-1 grid-cols-[minmax(0,1fr)_minmax(400px,460px)] gap-6 overflow-hidden">
|
||||
<div className="min-h-0 space-y-1 overflow-y-auto overscroll-contain pr-1">
|
||||
{channels.map((feature) => (
|
||||
<ChannelCatalogRow
|
||||
key={feature.name}
|
||||
feature={feature}
|
||||
selected={selectedChannel?.name === feature.name}
|
||||
showBrandLogos={showBrandLogos}
|
||||
onSelect={() => openChannel(feature.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="min-h-0 overflow-y-auto overscroll-contain pr-1">{setupPanel}</div>
|
||||
</div>
|
||||
) : showingCompactDetail ? (
|
||||
<div className="pb-6">
|
||||
<button
|
||||
ref={compactDetailTopRef}
|
||||
type="button"
|
||||
onClick={() => setCompactDetailOpen(false)}
|
||||
className="mb-4 inline-flex h-9 items-center gap-1.5 rounded-full px-2.5 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" aria-hidden />
|
||||
{tx("settings.channels.backToChannels", "All channels")}
|
||||
</button>
|
||||
{setupPanel}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 pb-6">
|
||||
{channels.map((feature) => (
|
||||
<ChannelCatalogRow
|
||||
key={feature.name}
|
||||
feature={feature}
|
||||
selected={false}
|
||||
showBrandLogos={showBrandLogos}
|
||||
onSelect={() => openChannel(feature.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 px-3 py-12 text-center text-sm text-muted-foreground">
|
||||
{tx("settings.channels.empty", "No channels match this filter.")}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Eye, EyeOff, Loader2, PauseCircle, PlayCircle, RotateCcw } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { AgentSettingsDraft } from "@/components/settings/models/ModelsSettings";
|
||||
import {
|
||||
NumberInput,
|
||||
ReadOnlyRow,
|
||||
SettingsGroup,
|
||||
SettingsRow,
|
||||
SettingsSectionTitle,
|
||||
StatusPill,
|
||||
} from "@/components/settings/shared/SettingsControls";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import { isLoopbackHost } from "@/lib/network";
|
||||
import { getRuntimeHost, isNativeRuntime } from "@/lib/runtime";
|
||||
import type { ApiServicePayload, NanobotFeatureInfo, SettingsPayload } from "@/lib/types";
|
||||
|
||||
export function RuntimeSettings({
|
||||
form,
|
||||
settings,
|
||||
onRestart,
|
||||
isRestarting,
|
||||
requiresRestartPending,
|
||||
apiService,
|
||||
apiServiceLoading,
|
||||
apiServiceAction,
|
||||
apiServiceError,
|
||||
langfuseFeature,
|
||||
capabilitiesLoading,
|
||||
capabilityAction,
|
||||
capabilityError,
|
||||
onApiServiceAction,
|
||||
onInstallCapability,
|
||||
}: {
|
||||
form: AgentSettingsDraft;
|
||||
settings: SettingsPayload;
|
||||
onRestart?: () => void;
|
||||
isRestarting?: boolean;
|
||||
requiresRestartPending: boolean;
|
||||
apiService: ApiServicePayload | null;
|
||||
apiServiceLoading: boolean;
|
||||
apiServiceAction: "start" | "stop" | null;
|
||||
apiServiceError: string | null;
|
||||
langfuseFeature?: NanobotFeatureInfo;
|
||||
capabilitiesLoading: boolean;
|
||||
capabilityAction: string | null;
|
||||
capabilityError: string | null;
|
||||
onApiServiceAction: (
|
||||
action: "start" | "stop",
|
||||
values?: { host: string; port: number; timeout: number; apiKey?: string },
|
||||
) => void;
|
||||
onInstallCapability: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const runtimeSurface = settings.surface ?? settings.runtime_surface;
|
||||
const runtimeHost = getRuntimeHost(runtimeSurface, settings.runtime_capabilities);
|
||||
const openLogs = runtimeHost.openLogs;
|
||||
const exportDiagnostics = runtimeHost.exportDiagnostics;
|
||||
const isNativeHost = isNativeRuntime(runtimeSurface);
|
||||
const restartActionLabel = isNativeHost
|
||||
? tx("app.system.restartEngine", "Restart engine")
|
||||
: t("app.system.restart");
|
||||
const restartingActionLabel = isNativeHost
|
||||
? tx("app.system.restartingEngine", "Restarting engine...")
|
||||
: t("app.system.restarting");
|
||||
const [diagnosticsPath, setDiagnosticsPath] = useState<string | null>(null);
|
||||
const [hostActionMessage, setHostActionMessage] = useState<{
|
||||
target: "logs" | "diagnostics";
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const [hostActionBusy, setHostActionBusy] =
|
||||
useState<"logs" | "diagnostics" | null>(null);
|
||||
const apiDefaults = apiService ?? {
|
||||
installed: false,
|
||||
running: false,
|
||||
managed: false,
|
||||
host: settings.api?.host ?? "127.0.0.1",
|
||||
port: settings.api?.port ?? 8900,
|
||||
timeout: settings.api?.timeout ?? 120,
|
||||
api_key_hint: settings.api?.api_key_hint,
|
||||
endpoint: `http://127.0.0.1:${settings.api?.port ?? 8900}/v1`,
|
||||
command: "nanobot serve",
|
||||
};
|
||||
const [apiHost, setApiHost] = useState(apiDefaults.host);
|
||||
const [apiPort, setApiPort] = useState(apiDefaults.port);
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [apiKeyVisible, setApiKeyVisible] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!apiService) return;
|
||||
setApiHost(apiService.host);
|
||||
setApiPort(apiService.port);
|
||||
setApiKey("");
|
||||
setApiKeyVisible(false);
|
||||
}, [apiService]);
|
||||
const apiNetworkAccess = !isLoopbackHost(apiHost);
|
||||
const apiMissingNetworkKey = apiNetworkAccess && !apiKey.trim() && !apiDefaults.api_key_hint;
|
||||
const engineState = isRestarting
|
||||
? tx("settings.values.restartingEngine", "Restarting")
|
||||
: settings.apply_state?.status === "pending"
|
||||
? tx("settings.values.pending", "Pending")
|
||||
: tx("settings.values.ready", "Ready");
|
||||
const runHostAction = async (
|
||||
target: "logs" | "diagnostics",
|
||||
action: (() => Promise<string | void>) | undefined,
|
||||
successMessage: (result: string | void) => string,
|
||||
failureMessage: string,
|
||||
) => {
|
||||
if (!action) {
|
||||
setHostActionMessage({
|
||||
target,
|
||||
message: tx(
|
||||
"settings.status.hostApiUnavailable",
|
||||
"Host actions are only available inside the native app.",
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setHostActionBusy(target);
|
||||
setHostActionMessage(null);
|
||||
try {
|
||||
const result = await action();
|
||||
setHostActionMessage({ target, message: successMessage(result) });
|
||||
} catch {
|
||||
setHostActionMessage({ target, message: failureMessage });
|
||||
} finally {
|
||||
setHostActionBusy(null);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="space-y-7">
|
||||
{isNativeHost ? (
|
||||
<section>
|
||||
<SettingsSectionTitle>{tx("settings.sections.nativeHost", "Native host")}</SettingsSectionTitle>
|
||||
<SettingsGroup>
|
||||
<ReadOnlyRow title={tx("settings.rows.engine", "Engine")} value={engineState} />
|
||||
{settings.runtime_capabilities?.can_open_logs ? (
|
||||
<SettingsRow
|
||||
title={tx("settings.rows.logs", "Logs")}
|
||||
description={
|
||||
hostActionMessage?.target === "logs" ? hostActionMessage.message : undefined
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
void runHostAction(
|
||||
"logs",
|
||||
openLogs,
|
||||
() => tx("settings.status.logsOpened", "Opened logs folder."),
|
||||
tx("settings.status.logsOpenFailed", "Could not open logs folder."),
|
||||
)
|
||||
}
|
||||
disabled={hostActionBusy !== null}
|
||||
className="rounded-full"
|
||||
>
|
||||
{hostActionBusy === "logs"
|
||||
? tx("settings.actions.opening", "Opening...")
|
||||
: tx("settings.actions.open", "Open")}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
) : null}
|
||||
{settings.runtime_capabilities?.can_export_diagnostics ? (
|
||||
<SettingsRow
|
||||
title={tx("settings.rows.diagnostics", "Diagnostics")}
|
||||
description={
|
||||
hostActionMessage?.target === "diagnostics"
|
||||
? hostActionMessage.message
|
||||
: diagnosticsPath || undefined
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
void runHostAction(
|
||||
"diagnostics",
|
||||
exportDiagnostics ? async () => {
|
||||
const path = await exportDiagnostics();
|
||||
setDiagnosticsPath(path);
|
||||
return path;
|
||||
} : undefined,
|
||||
(path) =>
|
||||
t("settings.status.diagnosticsExported", {
|
||||
path: String(path ?? ""),
|
||||
defaultValue: "Diagnostics exported to {{path}}.",
|
||||
}),
|
||||
tx("settings.status.diagnosticsExportFailed", "Could not export diagnostics."),
|
||||
)
|
||||
}
|
||||
disabled={hostActionBusy !== null}
|
||||
className="rounded-full"
|
||||
>
|
||||
{hostActionBusy === "diagnostics"
|
||||
? tx("settings.actions.exporting", "Exporting...")
|
||||
: tx("settings.actions.export", "Export")}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
) : null}
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section>
|
||||
<SettingsSectionTitle>{tx("settings.api.title", "API server")}</SettingsSectionTitle>
|
||||
<SettingsGroup>
|
||||
<SettingsRow
|
||||
title={tx("settings.api.openaiCompatible", "OpenAI-compatible API")}
|
||||
description={
|
||||
apiServiceError
|
||||
? apiServiceError
|
||||
: apiDefaults.running
|
||||
? apiDefaults.endpoint
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<StatusPill tone={apiDefaults.running ? "success" : "neutral"}>
|
||||
{apiServiceLoading
|
||||
? tx("settings.values.checking", "Checking")
|
||||
: apiDefaults.running
|
||||
? tx("settings.values.running", "Running")
|
||||
: tx("settings.values.off", "Off")}
|
||||
</StatusPill>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={apiServiceLoading || apiServiceAction !== null || apiMissingNetworkKey}
|
||||
onClick={() =>
|
||||
onApiServiceAction(
|
||||
apiDefaults.running ? "stop" : "start",
|
||||
apiDefaults.running
|
||||
? undefined
|
||||
: {
|
||||
host: apiHost,
|
||||
port: apiPort,
|
||||
timeout: apiDefaults.timeout,
|
||||
apiKey: apiKey.trim() || undefined,
|
||||
},
|
||||
)
|
||||
}
|
||||
className="rounded-full"
|
||||
>
|
||||
{apiServiceAction ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : apiDefaults.running ? (
|
||||
<PauseCircle className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
) : (
|
||||
<PlayCircle className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
{apiServiceAction === "start"
|
||||
? tx("settings.api.starting", "Starting...")
|
||||
: apiServiceAction === "stop"
|
||||
? tx("settings.api.stopping", "Stopping...")
|
||||
: apiDefaults.running
|
||||
? tx("settings.api.stop", "Stop")
|
||||
: tx("settings.api.start", "Start API server")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
{!apiDefaults.running ? (
|
||||
<>
|
||||
<SettingsRow
|
||||
title={tx("settings.api.access", "Access")}
|
||||
description={
|
||||
apiNetworkAccess
|
||||
? tx("settings.api.networkHelp", "Other devices can connect; an API key is required.")
|
||||
: tx("settings.api.localHelp", "Only this device can connect.")
|
||||
}
|
||||
>
|
||||
<SegmentedControl
|
||||
value={apiNetworkAccess ? "network" : "local"}
|
||||
options={[
|
||||
{ value: "local", label: tx("settings.api.thisDevice", "This device") },
|
||||
{ value: "network", label: tx("settings.api.localNetwork", "Local network") },
|
||||
]}
|
||||
onChange={(value) => setApiHost(value === "network" ? "0.0.0.0" : "127.0.0.1")}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow title={tx("settings.api.port", "Port")}>
|
||||
<NumberInput value={apiPort} min={1} max={65535} onChange={setApiPort} />
|
||||
</SettingsRow>
|
||||
{apiNetworkAccess ? (
|
||||
<SettingsRow
|
||||
title={tx("settings.api.apiKey", "API key")}
|
||||
description={
|
||||
apiMissingNetworkKey
|
||||
? tx("settings.api.apiKeyRequired", "Required before exposing the API to your network.")
|
||||
: tx("settings.api.apiKeyHelp", "Clients send this as a Bearer token.")
|
||||
}
|
||||
>
|
||||
<div className="relative w-[280px] max-w-full">
|
||||
<Input
|
||||
type={apiKeyVisible ? "text" : "password"}
|
||||
value={apiKey}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
placeholder={apiDefaults.api_key_hint ?? tx("settings.api.apiKeyPlaceholder", "Enter an API key")}
|
||||
className="h-9 rounded-full pr-10 text-[13px]"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setApiKeyVisible((visible) => !visible)}
|
||||
aria-label={apiKeyVisible ? tx("settings.byok.hideApiKey", "Hide API key") : tx("settings.byok.showApiKey", "Show API key")}
|
||||
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 rounded-full"
|
||||
>
|
||||
{apiKeyVisible ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SettingsSectionTitle>{tx("settings.observability.title", "Observability")}</SettingsSectionTitle>
|
||||
<SettingsGroup>
|
||||
<SettingsRow
|
||||
title="Langfuse"
|
||||
description={
|
||||
settings.observability?.configured
|
||||
? undefined
|
||||
: tx(
|
||||
"settings.observability.environment",
|
||||
"Set LANGFUSE_SECRET_KEY and LANGFUSE_PUBLIC_KEY, then restart nanobot.",
|
||||
)
|
||||
}
|
||||
>
|
||||
{capabilitiesLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" aria-hidden />
|
||||
) : langfuseFeature?.installed ? (
|
||||
<StatusPill tone={settings.observability?.configured ? "success" : "neutral"}>
|
||||
{settings.observability?.configured
|
||||
? tx("settings.values.ready", "Ready")
|
||||
: tx("settings.values.needsSetup", "Needs setup")}
|
||||
</StatusPill>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={capabilityAction === "enable:langfuse"}
|
||||
onClick={() => onInstallCapability("langfuse")}
|
||||
className="rounded-full"
|
||||
>
|
||||
{capabilityAction === "enable:langfuse" ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : null}
|
||||
{capabilityAction === "enable:langfuse"
|
||||
? tx("settings.capabilities.installing", "Installing support...")
|
||||
: tx("settings.observability.enable", "Enable tracing support")}
|
||||
</Button>
|
||||
)}
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
{capabilityError ? <p className="mt-2 text-[12px] text-destructive">{capabilityError}</p> : null}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SettingsSectionTitle>{t("settings.sections.system")}</SettingsSectionTitle>
|
||||
<SettingsGroup>
|
||||
{!isNativeHost ? (
|
||||
<ReadOnlyRow
|
||||
title={tx("settings.rows.gateway", "Gateway")}
|
||||
value={`${settings.runtime.gateway_host}:${settings.runtime.gateway_port}`}
|
||||
/>
|
||||
) : null}
|
||||
<ReadOnlyRow title={t("settings.rows.configPath")} value={settings.runtime.config_path} />
|
||||
<ReadOnlyRow title={tx("settings.rows.workspacePath", "Default workspace")} value={settings.runtime.workspace_path} />
|
||||
<ReadOnlyRow title={tx("settings.rows.timezone", "Timezone")} value={form.timezone} />
|
||||
{onRestart ? (
|
||||
<SettingsRow
|
||||
title={t("settings.rows.restart")}
|
||||
description={
|
||||
requiresRestartPending
|
||||
? tx("settings.status.savedRestartApply", "Saved. Restart when ready.")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onRestart}
|
||||
disabled={isRestarting}
|
||||
className="rounded-full"
|
||||
>
|
||||
{isRestarting ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
{isRestarting ? restartingActionLabel : restartActionLabel}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
) : null}
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
import type {
|
||||
ApplySettingsPayload,
|
||||
MaybeRestartHostEngine,
|
||||
PendingRestartSections,
|
||||
} from "@/components/settings/contracts";
|
||||
import type { AutomationAction } from "@/components/settings/system/AutomationsSettings";
|
||||
import { DEFAULT_CUSTOM_MCP_FORM } from "@/components/settings/system/AppsSettings";
|
||||
import type { SystemSettingsState } from "@/components/settings/system/useSystemSettingsState";
|
||||
import {
|
||||
cancelMcpOAuth,
|
||||
completeMcpOAuth,
|
||||
disableNanobotFeature,
|
||||
enableNanobotFeature,
|
||||
fetchNanobotFeatures,
|
||||
fetchSettings,
|
||||
fetchMcpOAuthStatus,
|
||||
fetchMcpPresets,
|
||||
importMcpConfig,
|
||||
runAutomationAction,
|
||||
runCliAppAction,
|
||||
runMcpPresetAction,
|
||||
saveCustomMcpServer,
|
||||
startMcpOAuth,
|
||||
startApiService,
|
||||
stopApiService,
|
||||
updateAutomation,
|
||||
updateMcpServerTools,
|
||||
} from "@/lib/api";
|
||||
import { notifyCliAppsChanged } from "@/lib/cli-app-events";
|
||||
import { notifyMcpPresetsChanged } from "@/lib/mcp-preset-events";
|
||||
import type { NanobotClient } from "@/lib/nanobot-client";
|
||||
import type {
|
||||
AutomationUpdatePayload,
|
||||
McpOAuthFlowPayload,
|
||||
McpPresetsPayload,
|
||||
NanobotFeatureInfo,
|
||||
SessionAutomationJob,
|
||||
} from "@/lib/types";
|
||||
|
||||
function isExpectedMcpOAuthPendingReloadFailure(
|
||||
payload: McpPresetsPayload,
|
||||
expectedName?: string,
|
||||
): boolean {
|
||||
if (
|
||||
!expectedName
|
||||
|| payload.last_action?.ok === false
|
||||
|| payload.hot_reload?.ok !== false
|
||||
) return false;
|
||||
|
||||
const normalizedName = expectedName.trim().toLowerCase();
|
||||
const failed = payload.hot_reload.failed ?? [];
|
||||
if (
|
||||
!normalizedName
|
||||
|| failed.length !== 1
|
||||
|| failed[0].trim().toLowerCase() !== normalizedName
|
||||
) return false;
|
||||
|
||||
return payload.presets.some((preset) => (
|
||||
preset.name.trim().toLowerCase() === normalizedName
|
||||
&& preset.auth === "oauth"
|
||||
&& preset.status === "authorization_required"
|
||||
));
|
||||
}
|
||||
|
||||
interface SystemSettingsActionsOptions {
|
||||
state: SystemSettingsState;
|
||||
featureCatalog: NanobotFeatureInfo[];
|
||||
client: NanobotClient;
|
||||
token: string;
|
||||
getToken: () => string;
|
||||
t: TFunction;
|
||||
applyPayload: ApplySettingsPayload;
|
||||
maybeRestartHostEngine: MaybeRestartHostEngine;
|
||||
setPendingRestartSections: Dispatch<SetStateAction<PendingRestartSections>>;
|
||||
refreshAutomations: (showLoading?: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
export function createSystemSettingsActions({
|
||||
state,
|
||||
featureCatalog,
|
||||
client,
|
||||
token,
|
||||
getToken,
|
||||
t,
|
||||
applyPayload,
|
||||
maybeRestartHostEngine,
|
||||
setPendingRestartSections,
|
||||
refreshAutomations,
|
||||
}: SystemSettingsActionsOptions) {
|
||||
const {
|
||||
apiServiceAction,
|
||||
customMcpForm,
|
||||
mcpConfigImport,
|
||||
mcpOAuthCallbackUrl,
|
||||
mcpOAuthFlowRef,
|
||||
mcpOAuthNavigatedUrlRef,
|
||||
mcpOAuthPopupRef,
|
||||
nanobotFeatures,
|
||||
setApiService,
|
||||
setApiServiceAction,
|
||||
setApiServiceError,
|
||||
setAutomationAction,
|
||||
setAutomationPendingDelete,
|
||||
setAutomationPendingEdit,
|
||||
setAutomations,
|
||||
setAutomationsError,
|
||||
setCliApps,
|
||||
setCliAppsAction,
|
||||
setCliAppsError,
|
||||
setCliAppsFocusName,
|
||||
setCliAppsMessage,
|
||||
setCustomMcpForm,
|
||||
setMcpConfigImport,
|
||||
setMcpError,
|
||||
setMcpFieldValues,
|
||||
setMcpMessage,
|
||||
setMcpOAuthCallbackError,
|
||||
setMcpOAuthCallbackUrl,
|
||||
setMcpOAuthCompleting,
|
||||
setMcpOAuthFlow,
|
||||
setMcpOAuthPopupBlocked,
|
||||
setMcpPresetAction,
|
||||
setMcpPresets,
|
||||
setNanobotFeatureAction,
|
||||
setNanobotFeatureConfirm,
|
||||
setNanobotFeatures,
|
||||
setNanobotFeaturesError,
|
||||
} = state;
|
||||
|
||||
const installCapabilities = async (names: string[]): Promise<boolean> => {
|
||||
const missing = names.filter(
|
||||
(name) => !featureCatalog.find((feature) => feature.name === name)?.installed,
|
||||
);
|
||||
if (!missing.length) return true;
|
||||
setNanobotFeatureAction(`enable:${names.join("+")}`);
|
||||
setNanobotFeaturesError(null);
|
||||
try {
|
||||
let latest = nanobotFeatures;
|
||||
for (const name of missing) {
|
||||
latest = await enableNanobotFeature(client, name);
|
||||
if (latest.requires_restart) {
|
||||
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
|
||||
}
|
||||
}
|
||||
if (latest) setNanobotFeatures(latest);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setNanobotFeaturesError((err as Error).message);
|
||||
return false;
|
||||
} finally {
|
||||
setNanobotFeatureAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApiServiceAction = async (
|
||||
action: "start" | "stop",
|
||||
values?: { host: string; port: number; timeout: number; apiKey?: string },
|
||||
) => {
|
||||
if (apiServiceAction) return;
|
||||
setApiServiceAction(action);
|
||||
setApiServiceError(null);
|
||||
try {
|
||||
const payload = action === "start"
|
||||
? await startApiService(client, values!)
|
||||
: await stopApiService(client);
|
||||
setApiService(payload);
|
||||
const refreshed = await fetchNanobotFeatures(token);
|
||||
setNanobotFeatures(refreshed);
|
||||
const nextSettings = await fetchSettings(token);
|
||||
applyPayload(nextSettings);
|
||||
} catch (err) {
|
||||
setApiServiceError((err as Error).message);
|
||||
} finally {
|
||||
setApiServiceAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCliAppAction = async (
|
||||
action: "install" | "update" | "uninstall" | "test",
|
||||
name: string,
|
||||
) => {
|
||||
const key = `${action}:${name}`;
|
||||
setCliAppsAction(key);
|
||||
setCliAppsMessage(null);
|
||||
setCliAppsError(null);
|
||||
try {
|
||||
const payload = await runCliAppAction(client, action, name);
|
||||
setCliApps(payload);
|
||||
if (action !== "test") {
|
||||
notifyCliAppsChanged(payload);
|
||||
}
|
||||
setCliAppsMessage(payload.last_action?.message ?? null);
|
||||
setCliAppsFocusName(action === "uninstall" ? null : name);
|
||||
} catch (err) {
|
||||
setCliAppsError((err as Error).message);
|
||||
} finally {
|
||||
setCliAppsAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNanobotFeatureAction = async (
|
||||
action: "enable" | "disable",
|
||||
name: string,
|
||||
confirmed = false,
|
||||
) => {
|
||||
const feature = featureCatalog.find((item) => item.name === name);
|
||||
if (action === "enable" && !confirmed && feature && !feature.installed && feature.install_supported) {
|
||||
setNanobotFeaturesError(null);
|
||||
setNanobotFeatureConfirm(feature);
|
||||
return;
|
||||
}
|
||||
const key = `${action}:${name}`;
|
||||
setNanobotFeatureAction(key);
|
||||
setNanobotFeatureConfirm(null);
|
||||
setNanobotFeaturesError(null);
|
||||
try {
|
||||
const payload = action === "enable"
|
||||
? await enableNanobotFeature(client, name)
|
||||
: await disableNanobotFeature(client, name);
|
||||
setNanobotFeatures(payload);
|
||||
if (payload.requires_restart) {
|
||||
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
|
||||
}
|
||||
} catch (err) {
|
||||
setNanobotFeaturesError((err as Error).message);
|
||||
} finally {
|
||||
setNanobotFeatureAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutomationAction = async (
|
||||
action: AutomationAction,
|
||||
job: SessionAutomationJob,
|
||||
) => {
|
||||
const key = `${action}:${job.id}`;
|
||||
setAutomationAction(key);
|
||||
setAutomationsError(null);
|
||||
try {
|
||||
const payload = await runAutomationAction(client, action, job.id);
|
||||
setAutomations(payload);
|
||||
if (action === "delete") setAutomationPendingDelete(null);
|
||||
if (action === "run") {
|
||||
window.setTimeout(() => void refreshAutomations(false), 1200);
|
||||
window.setTimeout(() => void refreshAutomations(false), 4000);
|
||||
}
|
||||
} catch (err) {
|
||||
setAutomationsError((err as Error).message);
|
||||
} finally {
|
||||
setAutomationAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutomationEdit = async (
|
||||
job: SessionAutomationJob,
|
||||
values: AutomationUpdatePayload,
|
||||
) => {
|
||||
const key = `update:${job.id}`;
|
||||
setAutomationAction(key);
|
||||
setAutomationsError(null);
|
||||
try {
|
||||
const payload = await updateAutomation(client, job.id, values);
|
||||
setAutomations(payload);
|
||||
setAutomationPendingEdit(null);
|
||||
} catch (err) {
|
||||
setAutomationsError((err as Error).message);
|
||||
} finally {
|
||||
setAutomationAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const closeMcpOAuthPopup = () => {
|
||||
const popup = mcpOAuthPopupRef.current;
|
||||
mcpOAuthPopupRef.current = null;
|
||||
mcpOAuthNavigatedUrlRef.current = null;
|
||||
if (!popup) return;
|
||||
try {
|
||||
if (!popup.closed) popup.close();
|
||||
} catch {
|
||||
// The authorization page may have navigated cross-origin before it closed itself.
|
||||
}
|
||||
};
|
||||
|
||||
const openMcpOAuthPopup = (authorizationUrl?: string): Window | null => {
|
||||
let popup: Window | null = null;
|
||||
try {
|
||||
popup = window.open(
|
||||
authorizationUrl ?? "about:blank",
|
||||
"nanobot-mcp-oauth",
|
||||
"popup,width=560,height=720,resizable=yes,scrollbars=yes",
|
||||
);
|
||||
if (popup) {
|
||||
mcpOAuthPopupRef.current = popup;
|
||||
mcpOAuthNavigatedUrlRef.current = authorizationUrl ?? null;
|
||||
if (!authorizationUrl) {
|
||||
try {
|
||||
popup.document.title = t("settings.oauth.signingIn", { defaultValue: "Preparing sign-in…" });
|
||||
popup.document.body.textContent = t("settings.mcp.preparingSignIn", {
|
||||
defaultValue: "Preparing secure sign-in…",
|
||||
});
|
||||
} catch {
|
||||
// about:blank can become unavailable if the window is reused mid-navigation.
|
||||
}
|
||||
}
|
||||
try {
|
||||
popup.opener = null;
|
||||
popup.focus();
|
||||
} catch {
|
||||
// A cross-origin authorization page can restrict window access.
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Browsers can reject popup creation before returning a window handle.
|
||||
}
|
||||
setMcpOAuthPopupBlocked(!popup);
|
||||
return popup;
|
||||
};
|
||||
|
||||
const navigateMcpOAuthPopup = (flow: McpOAuthFlowPayload) => {
|
||||
const authorizationUrl = flow.authorization_url;
|
||||
if (!authorizationUrl) return;
|
||||
const popup = mcpOAuthPopupRef.current;
|
||||
// OAuth pages can use Cross-Origin-Opener-Policy, which severs the
|
||||
// WindowProxy and makes an open tab appear closed. Once navigation was
|
||||
// requested, do not mistake that browser isolation for a blocked popup.
|
||||
if (popup && mcpOAuthNavigatedUrlRef.current === authorizationUrl) return;
|
||||
try {
|
||||
if (popup && !popup.closed) {
|
||||
popup.location.replace(authorizationUrl);
|
||||
mcpOAuthNavigatedUrlRef.current = authorizationUrl;
|
||||
popup.focus();
|
||||
setMcpOAuthPopupBlocked(false);
|
||||
return;
|
||||
}
|
||||
if (popup) return;
|
||||
} catch {
|
||||
// Fall through to the explicit Continue in browser action.
|
||||
}
|
||||
setMcpOAuthPopupBlocked(true);
|
||||
};
|
||||
|
||||
const finishMcpOAuthFlow = async (flow: McpOAuthFlowPayload) => {
|
||||
if (mcpOAuthFlowRef.current?.flow_id !== flow.flow_id) return;
|
||||
closeMcpOAuthPopup();
|
||||
mcpOAuthFlowRef.current = null;
|
||||
setMcpOAuthFlow(null);
|
||||
setMcpPresetAction(null);
|
||||
setMcpOAuthCallbackUrl("");
|
||||
setMcpOAuthCompleting(false);
|
||||
setMcpOAuthCallbackError(null);
|
||||
|
||||
if (flow.status === "connected") {
|
||||
try {
|
||||
const payload = await fetchMcpPresets(getToken());
|
||||
setMcpPresets(payload);
|
||||
notifyMcpPresetsChanged(payload);
|
||||
setMcpMessage(null);
|
||||
setMcpError(null);
|
||||
} catch (err) {
|
||||
setMcpError((err as Error).message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (flow.status === "authorized" && flow.hot_reload) {
|
||||
if (flow.hot_reload.requires_restart) {
|
||||
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
|
||||
}
|
||||
setMcpError(
|
||||
flow.hot_reload.message
|
||||
|| t("settings.mcp.reloadFailed", {
|
||||
defaultValue: "Signed in, but nanobot could not connect the tools. Try restarting nanobot.",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (flow.status === "failed") {
|
||||
setMcpError(
|
||||
flow.error
|
||||
|| t("settings.mcp.oauthFailed", {
|
||||
defaultValue: "Unable to connect. Try signing in again.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const monitorMcpOAuthFlow = async (initial: McpOAuthFlowPayload) => {
|
||||
let current = initial;
|
||||
while (mcpOAuthFlowRef.current?.flow_id === current.flow_id) {
|
||||
navigateMcpOAuthPopup(current);
|
||||
const terminal =
|
||||
current.status === "connected"
|
||||
|| current.status === "failed"
|
||||
|| current.status === "cancelled"
|
||||
|| (current.status === "authorized" && Boolean(current.hot_reload));
|
||||
if (terminal) {
|
||||
await finishMcpOAuthFlow(current);
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 800));
|
||||
if (mcpOAuthFlowRef.current?.flow_id !== current.flow_id) return;
|
||||
try {
|
||||
current = await fetchMcpOAuthStatus(getToken(), current.flow_id);
|
||||
if (mcpOAuthFlowRef.current?.flow_id !== current.flow_id) return;
|
||||
mcpOAuthFlowRef.current = current;
|
||||
setMcpOAuthFlow(current);
|
||||
} catch (err) {
|
||||
if (mcpOAuthFlowRef.current?.flow_id !== current.flow_id) return;
|
||||
closeMcpOAuthPopup();
|
||||
mcpOAuthFlowRef.current = null;
|
||||
setMcpOAuthFlow(null);
|
||||
setMcpPresetAction(null);
|
||||
setMcpOAuthCallbackUrl("");
|
||||
setMcpOAuthCompleting(false);
|
||||
setMcpOAuthCallbackError(null);
|
||||
setMcpError((err as Error).message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMcpOAuthConnect = async (name: string, reset = false) => {
|
||||
openMcpOAuthPopup();
|
||||
const key = `oauth:${name}`;
|
||||
setMcpPresetAction(key);
|
||||
setMcpMessage(null);
|
||||
setMcpError(null);
|
||||
setMcpOAuthCallbackUrl("");
|
||||
setMcpOAuthCompleting(false);
|
||||
setMcpOAuthCallbackError(null);
|
||||
try {
|
||||
const flow = await startMcpOAuth(client, name, reset);
|
||||
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" | "disable" | "remove" | "test" | "reconnect",
|
||||
name: string,
|
||||
values: Record<string, string> = {},
|
||||
) => {
|
||||
const key = `${action}:${name}`;
|
||||
setMcpPresetAction(key);
|
||||
setMcpMessage(null);
|
||||
setMcpError(null);
|
||||
try {
|
||||
const payload = await runMcpPresetAction(client, action, name, values);
|
||||
setMcpPresets(payload);
|
||||
applyMcpActionFeedback(payload, action === "test");
|
||||
if (action !== "test") {
|
||||
notifyMcpPresetsChanged(payload);
|
||||
}
|
||||
if (payload.requires_restart) {
|
||||
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
|
||||
}
|
||||
await maybeRestartHostEngine(payload);
|
||||
if (action === "enable") {
|
||||
setMcpFieldValues((prev) => ({ ...prev, [name]: {} }));
|
||||
}
|
||||
} catch (err) {
|
||||
setMcpError((err as Error).message);
|
||||
} finally {
|
||||
setMcpPresetAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveCustomMcp = async () => {
|
||||
const name = customMcpForm.name.trim();
|
||||
const expectsOAuthAuthorization = (
|
||||
customMcpForm.transport !== "stdio" && customMcpForm.auth === "oauth"
|
||||
);
|
||||
const key = `custom:${name || "new"}`;
|
||||
setMcpPresetAction(key);
|
||||
setMcpMessage(null);
|
||||
setMcpError(null);
|
||||
try {
|
||||
const payload = await saveCustomMcpServer(client, {
|
||||
name,
|
||||
transport: customMcpForm.transport,
|
||||
auth:
|
||||
customMcpForm.transport !== "stdio" && customMcpForm.auth === "oauth"
|
||||
? "oauth"
|
||||
: "",
|
||||
command: customMcpForm.command,
|
||||
args: customMcpForm.args,
|
||||
url: customMcpForm.url,
|
||||
env: customMcpForm.env,
|
||||
headers:
|
||||
customMcpForm.transport !== "stdio" && customMcpForm.auth === "headers"
|
||||
? customMcpForm.headers
|
||||
: "",
|
||||
tool_timeout: customMcpForm.toolTimeout,
|
||||
});
|
||||
setMcpPresets(payload);
|
||||
applyMcpActionFeedback(
|
||||
payload,
|
||||
false,
|
||||
expectsOAuthAuthorization ? name : undefined,
|
||||
);
|
||||
notifyMcpPresetsChanged(payload);
|
||||
if (payload.requires_restart) {
|
||||
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
|
||||
}
|
||||
await maybeRestartHostEngine(payload);
|
||||
setCustomMcpForm((prev) => ({ ...DEFAULT_CUSTOM_MCP_FORM, transport: prev.transport }));
|
||||
} catch (err) {
|
||||
setMcpError((err as Error).message);
|
||||
} finally {
|
||||
setMcpPresetAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportMcpConfig = async () => {
|
||||
setMcpPresetAction("import");
|
||||
setMcpMessage(null);
|
||||
setMcpError(null);
|
||||
try {
|
||||
const payload = await importMcpConfig(client, mcpConfigImport);
|
||||
setMcpPresets(payload);
|
||||
applyMcpActionFeedback(payload);
|
||||
notifyMcpPresetsChanged(payload);
|
||||
if (payload.requires_restart) {
|
||||
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
|
||||
}
|
||||
await maybeRestartHostEngine(payload);
|
||||
setMcpConfigImport("");
|
||||
} catch (err) {
|
||||
setMcpError((err as Error).message);
|
||||
} finally {
|
||||
setMcpPresetAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMcpToolsChange = async (name: string, enabledTools: string[]) => {
|
||||
setMcpPresetAction(`tools:${name}`);
|
||||
setMcpMessage(null);
|
||||
setMcpError(null);
|
||||
try {
|
||||
const payload = await updateMcpServerTools(client, name, enabledTools);
|
||||
setMcpPresets(payload);
|
||||
applyMcpActionFeedback(payload);
|
||||
notifyMcpPresetsChanged(payload);
|
||||
if (payload.requires_restart) {
|
||||
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
|
||||
}
|
||||
await maybeRestartHostEngine(payload);
|
||||
} catch (err) {
|
||||
setMcpError((err as Error).message);
|
||||
} finally {
|
||||
setMcpPresetAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
handleApiServiceAction,
|
||||
handleAutomationAction,
|
||||
handleAutomationEdit,
|
||||
handleCliAppAction,
|
||||
handleImportMcpConfig,
|
||||
handleMcpOAuthCancel,
|
||||
handleMcpOAuthComplete,
|
||||
handleMcpOAuthConnect,
|
||||
handleMcpOAuthOpen,
|
||||
handleMcpPresetAction,
|
||||
handleMcpToolsChange,
|
||||
handleNanobotFeatureAction,
|
||||
handleSaveCustomMcp,
|
||||
installCapabilities,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
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,
|
||||
fetchAppsDiscovery,
|
||||
fetchAutomations,
|
||||
fetchCliApps,
|
||||
fetchMcpPresets,
|
||||
fetchNanobotFeatures,
|
||||
} from "@/lib/api";
|
||||
|
||||
interface SystemSettingsEffectsOptions {
|
||||
state: SystemSettingsState;
|
||||
activeSection: SettingsSectionKey;
|
||||
getToken: () => string;
|
||||
pageVisible: boolean;
|
||||
}
|
||||
|
||||
const MCP_RUNTIME_STATUS_REFRESH_MS = 1_000;
|
||||
|
||||
export function useSystemSettingsEffects({
|
||||
state,
|
||||
activeSection,
|
||||
getToken,
|
||||
pageVisible,
|
||||
}: SystemSettingsEffectsOptions) {
|
||||
const {
|
||||
setApiService,
|
||||
setApiServiceError,
|
||||
setApiServiceLoading,
|
||||
setAppsDiscovery,
|
||||
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 load = () => {
|
||||
fetchAppsDiscovery(getToken())
|
||||
.then((payload) => {
|
||||
if (cancelled) return;
|
||||
setAppsDiscovery(payload);
|
||||
if (payload.refresh_pending && retryCount < 3) {
|
||||
retryCount += 1;
|
||||
retry = window.setTimeout(load, CLI_APPS_REFRESH_RETRY_MS);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
};
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (retry !== null) window.clearTimeout(retry);
|
||||
};
|
||||
}, [activeSection, getToken, setAppsDiscovery]);
|
||||
|
||||
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" || !pageVisible) return;
|
||||
let cancelled = false;
|
||||
let retry: number | null = null;
|
||||
const loadMcpPresets = (showLoading: boolean) => {
|
||||
if (showLoading) setMcpPresetsLoading(true);
|
||||
fetchMcpPresets(getToken())
|
||||
.then((payload) => {
|
||||
if (cancelled) return;
|
||||
setMcpPresets(payload);
|
||||
setMcpError(null);
|
||||
if (payload.presets.some((preset) => preset.runtime_status === "connecting")) {
|
||||
retry = window.setTimeout(() => {
|
||||
retry = null;
|
||||
loadMcpPresets(false);
|
||||
}, MCP_RUNTIME_STATUS_REFRESH_MS);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setMcpError((err as Error).message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled && showLoading) setMcpPresetsLoading(false);
|
||||
});
|
||||
};
|
||||
loadMcpPresets(true);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (retry !== null) window.clearTimeout(retry);
|
||||
};
|
||||
}, [activeSection, getToken, pageVisible]);
|
||||
|
||||
const refreshAutomations = useCallback(
|
||||
async (showLoading = false) => {
|
||||
if (showLoading) setAutomationsLoading(true);
|
||||
try {
|
||||
const payload = await fetchAutomations(getToken());
|
||||
setAutomations(payload);
|
||||
setAutomationsError(null);
|
||||
} catch (err) {
|
||||
setAutomationsError((err as Error).message);
|
||||
} finally {
|
||||
if (showLoading) setAutomationsLoading(false);
|
||||
}
|
||||
},
|
||||
[getToken],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSection !== "automations" || !pageVisible) return;
|
||||
let cancelled = false;
|
||||
let refreshing = false;
|
||||
const refresh = async (showLoading = false) => {
|
||||
if (cancelled || refreshing) return;
|
||||
refreshing = true;
|
||||
if (showLoading) setAutomationsLoading(true);
|
||||
try {
|
||||
const payload = await fetchAutomations(getToken());
|
||||
if (cancelled) return;
|
||||
setAutomations(payload);
|
||||
setAutomationsError(null);
|
||||
} catch (err) {
|
||||
if (!cancelled) setAutomationsError((err as Error).message);
|
||||
} finally {
|
||||
refreshing = false;
|
||||
if (!cancelled && showLoading) setAutomationsLoading(false);
|
||||
}
|
||||
};
|
||||
void refresh(true);
|
||||
const interval = window.setInterval(() => void refresh(false), 5000);
|
||||
const refreshOnFocus = () => void refresh(false);
|
||||
window.addEventListener("focus", refreshOnFocus);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(interval);
|
||||
window.removeEventListener("focus", refreshOnFocus);
|
||||
};
|
||||
}, [activeSection, getToken, pageVisible]);
|
||||
|
||||
return { refreshAutomations };
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
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,
|
||||
AppsDiscoveryPayload,
|
||||
AutomationsPayload,
|
||||
CliAppsPayload,
|
||||
McpOAuthFlowPayload,
|
||||
McpPresetsPayload,
|
||||
NanobotFeatureInfo,
|
||||
NanobotFeaturesPayload,
|
||||
SessionAutomationJob,
|
||||
} from "@/lib/types";
|
||||
|
||||
export function useSystemSettingsState() {
|
||||
const [appsDiscovery, setAppsDiscovery] = useState<AppsDiscoveryPayload | null>(null);
|
||||
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>("discover");
|
||||
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,
|
||||
appsDiscovery,
|
||||
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,
|
||||
setAppsDiscovery,
|
||||
setAppsKindFilter,
|
||||
setAppsQuery,
|
||||
setAutomationAction,
|
||||
setAutomationPendingDelete,
|
||||
setAutomationPendingEdit,
|
||||
setAutomations,
|
||||
setAutomationsError,
|
||||
setAutomationsFilter,
|
||||
setAutomationsLoading,
|
||||
setAutomationsQuery,
|
||||
setAutomationsSort,
|
||||
setChannelsQuery,
|
||||
setCliApps,
|
||||
setCliAppsAction,
|
||||
setCliAppsError,
|
||||
setCliAppsFocusName,
|
||||
setCliAppsLoading,
|
||||
setCliAppsMessage,
|
||||
setCustomMcpForm,
|
||||
setMcpConfigImport,
|
||||
setMcpError,
|
||||
setMcpFieldValues,
|
||||
setMcpMessage,
|
||||
setMcpOAuthCallbackError,
|
||||
setMcpOAuthCallbackUrl,
|
||||
setMcpOAuthCompleting,
|
||||
setMcpOAuthFlow,
|
||||
setMcpOAuthPopupBlocked,
|
||||
setMcpPresetAction,
|
||||
setMcpPresets,
|
||||
setMcpPresetsLoading,
|
||||
setNanobotFeatureAction,
|
||||
setNanobotFeatureConfirm,
|
||||
setNanobotFeatures,
|
||||
setNanobotFeaturesError,
|
||||
setNanobotFeaturesLoading,
|
||||
};
|
||||
}
|
||||
|
||||
export type SystemSettingsState = ReturnType<typeof useSystemSettingsState>;
|
||||
@@ -0,0 +1,614 @@
|
||||
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, appsDiscovery, 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,
|
||||
appsDiscovery,
|
||||
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>;
|
||||
@@ -3,6 +3,7 @@ import { ListTree, Search } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formControlFocusClassName } from "@/components/ui/form-control";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@@ -83,7 +84,8 @@ export function PromptNavigator({
|
||||
placeholder={t("thread.promptNavigator.search")}
|
||||
className={cn(
|
||||
"h-10 w-full rounded-full border border-border bg-background pl-9 pr-3 text-sm",
|
||||
"outline-none transition focus:border-ring focus:ring-2 focus:ring-ring/20",
|
||||
"transition-colors",
|
||||
formControlFocusClassName,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -256,10 +256,7 @@ export function WorkspaceProjectPicker({
|
||||
aria-label={t("workspace.dialog.manual")}
|
||||
aria-invalid={pathError || error ? true : undefined}
|
||||
aria-describedby={pathError || error ? pathErrorId : undefined}
|
||||
className={cn(
|
||||
"h-9 rounded-full border-border/55 bg-background/80 px-3 text-[12.5px]",
|
||||
"focus-visible:ring-1 focus-visible:ring-foreground/10 focus-visible:ring-offset-0",
|
||||
)}
|
||||
className="h-9 rounded-full border-border/55 bg-background/80 px-3 text-[12.5px]"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const formControlFocusClassName =
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50";
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { formControlFocusClassName } from "@/components/ui/form-control";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||
@@ -10,7 +11,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
formControlFocusClassName,
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { formControlFocusClassName } from "@/components/ui/form-control";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||
@@ -9,7 +10,8 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
formControlFocusClassName,
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -241,7 +241,7 @@
|
||||
"activityMode": "Choose how much agent activity chrome to show by default.",
|
||||
"fileEditDisplay": "Choose whether file edit activity opens as line counts or a diff.",
|
||||
"codeWrap": "Keep long code lines readable on smaller screens.",
|
||||
"brandLogos": "Show third-party provider and CLI logos in Settings.",
|
||||
"brandLogos": "Load third-party brand logos from external icon services. Turn this off to use local initials.",
|
||||
"maxResults": "Results returned by each web_search call.",
|
||||
"timeout": "Seconds before a search provider request times out.",
|
||||
"jinaReader": "Use Jina Reader for web_fetch when available.",
|
||||
@@ -354,7 +354,10 @@
|
||||
"enabled": "Enabled",
|
||||
"setup": "Connect",
|
||||
"configure": "Connect",
|
||||
"reconnect": "Reconnect",
|
||||
"connectTitle": "Connect {{name}}",
|
||||
"reconnectTitle": "Reconnect {{name}}",
|
||||
"actionsTitle": "Actions for {{name}}",
|
||||
"connectHint": "Add the key from your account settings.",
|
||||
"saveAndEnable": "Save and enable",
|
||||
"updateSetup": "Update setup",
|
||||
@@ -554,9 +557,6 @@
|
||||
"capabilityOpenAISearch": "OpenAI web search",
|
||||
"capabilityOpenAISearchHelp": "Allow compatible Responses API models to search the web. Search activity appears in chat."
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement."
|
||||
},
|
||||
"image": {
|
||||
"selectProvider": "Select provider",
|
||||
"selectAspect": "Select aspect",
|
||||
@@ -603,6 +603,14 @@
|
||||
},
|
||||
"apps": {
|
||||
"description": "Add tools to nanobot, then @ them in chat.",
|
||||
"discover": "Discover",
|
||||
"installed": "Installed",
|
||||
"allApps": "All apps",
|
||||
"addCustom": "Add custom",
|
||||
"emptyInstalled": "No apps installed yet.",
|
||||
"searchResults": "Search results",
|
||||
"browseAll": "Browse all",
|
||||
"nextFeatured": "Show another",
|
||||
"cliLabel": "App",
|
||||
"mcpLabel": "MCP",
|
||||
"channelLabel": "Channel",
|
||||
@@ -614,7 +622,7 @@
|
||||
"enabledSummary": "{{count}} ready",
|
||||
"caption": "{{cli}} apps · {{mcp}} MCP tools",
|
||||
"searchPlaceholder": "Search tools",
|
||||
"featured": "Tools",
|
||||
"featured": "Featured",
|
||||
"mcpTools": "MCP tools",
|
||||
"loading": "Loading Apps...",
|
||||
"empty": "No tools match your search.",
|
||||
|
||||
@@ -203,7 +203,7 @@
|
||||
"currentModel": "Se usa para nuevas respuestas.",
|
||||
"selectedModelProvider": "Definido por el modelo seleccionado.",
|
||||
"selectedModelValue": "Definido por el modelo seleccionado.",
|
||||
"brandLogos": "Muestra logos de proveedores de terceros y CLI en Ajustes.",
|
||||
"brandLogos": "Carga logotipos de marcas de terceros desde servicios de iconos externos. Desactívalo para usar iniciales locales.",
|
||||
"cliAppsCatalog": "Instala solo adaptadores CLI de aplicaciones que nanobot puede ejecutar localmente; las aplicaciones nativas no se modifican.",
|
||||
"cliAppsFilter": "Busca por aplicación, categoría o capacidad.",
|
||||
"logs": "Abre la carpeta de registros del motor nativo.",
|
||||
@@ -539,7 +539,10 @@
|
||||
"enabled": "Habilitado",
|
||||
"setup": "Conectar",
|
||||
"configure": "Conectar",
|
||||
"reconnect": "Reconectar",
|
||||
"connectTitle": "Conectar {{name}}",
|
||||
"reconnectTitle": "Reconectar {{name}}",
|
||||
"actionsTitle": "Acciones de {{name}}",
|
||||
"connectHint": "Añade la clave desde la configuración de tu cuenta.",
|
||||
"saveAndEnable": "Guardar y habilitar",
|
||||
"updateSetup": "Actualizar configuración",
|
||||
@@ -585,11 +588,16 @@
|
||||
"title": "Observabilidad", "configured": "Las credenciales de trazas están disponibles para nanobot.",
|
||||
"environment": "Configura LANGFUSE_SECRET_KEY y LANGFUSE_PUBLIC_KEY y reinicia nanobot.", "enable": "Habilitar soporte de trazas"
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "Los nombres, logotipos y marcas de productos pertenecen a sus respectivos propietarios. Su uso es solo identificativo y no implica respaldo."
|
||||
},
|
||||
"apps": {
|
||||
"description": "Añade herramientas a nanobot y luego úsalas con @ en el chat.",
|
||||
"discover": "Descubrir",
|
||||
"installed": "Instaladas",
|
||||
"allApps": "Todas las apps",
|
||||
"addCustom": "Añadir personalizada",
|
||||
"emptyInstalled": "Aún no hay apps instaladas.",
|
||||
"searchResults": "Resultados de búsqueda",
|
||||
"browseAll": "Ver todas",
|
||||
"nextFeatured": "Mostrar otras",
|
||||
"cliLabel": "Aplicación",
|
||||
"mcpLabel": "MCP",
|
||||
"channelLabel": "Canal",
|
||||
@@ -601,7 +609,7 @@
|
||||
"enabledSummary": "{{count}} listos",
|
||||
"caption": "{{cli}} aplicaciones · {{mcp}} herramientas MCP",
|
||||
"searchPlaceholder": "Buscar aplicaciones",
|
||||
"featured": "Herramientas",
|
||||
"featured": "Destacadas",
|
||||
"mcpTools": "Herramientas MCP",
|
||||
"loading": "Cargando aplicaciones...",
|
||||
"empty": "Ninguna herramienta coincide con tu búsqueda.",
|
||||
|
||||
@@ -203,7 +203,7 @@
|
||||
"currentModel": "Utilisée pour les nouvelles réponses.",
|
||||
"selectedModelProvider": "Défini par le modèle sélectionné.",
|
||||
"selectedModelValue": "Défini par le modèle sélectionné.",
|
||||
"brandLogos": "Affiche les logos de fournisseurs tiers et CLI dans les Réglages.",
|
||||
"brandLogos": "Charge les logos de marques tierces depuis des services d’icônes externes. Désactivez cette option pour utiliser des initiales locales.",
|
||||
"cliAppsCatalog": "Installe uniquement les adaptateurs CLI d’applications que nanobot peut exécuter localement ; les applications natives restent inchangées.",
|
||||
"cliAppsFilter": "Recherchez par application, catégorie ou capacité.",
|
||||
"logs": "Ouvre le dossier des journaux du moteur natif.",
|
||||
@@ -539,7 +539,10 @@
|
||||
"enabled": "Activé",
|
||||
"setup": "Connecter",
|
||||
"configure": "Connecter",
|
||||
"reconnect": "Reconnecter",
|
||||
"connectTitle": "Connecter {{name}}",
|
||||
"reconnectTitle": "Reconnecter {{name}}",
|
||||
"actionsTitle": "Actions pour {{name}}",
|
||||
"connectHint": "Ajoutez la clé depuis les paramètres de votre compte.",
|
||||
"saveAndEnable": "Enregistrer et activer",
|
||||
"updateSetup": "Mettre à jour la configuration",
|
||||
@@ -584,11 +587,16 @@
|
||||
"title": "Observabilité", "configured": "Les identifiants de traçage sont disponibles pour nanobot.",
|
||||
"environment": "Définissez LANGFUSE_SECRET_KEY et LANGFUSE_PUBLIC_KEY, puis redémarrez nanobot.", "enable": "Activer le traçage"
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "Les noms, logos et marques de produits appartiennent à leurs propriétaires respectifs. Leur utilisation sert uniquement à l'identification et n'implique aucune approbation."
|
||||
},
|
||||
"apps": {
|
||||
"description": "Ajoutez des outils à nanobot, puis utilisez-les avec @ dans le chat.",
|
||||
"discover": "Découvrir",
|
||||
"installed": "Installées",
|
||||
"allApps": "Toutes les apps",
|
||||
"addCustom": "Ajouter une app",
|
||||
"emptyInstalled": "Aucune app installée pour le moment.",
|
||||
"searchResults": "Résultats de recherche",
|
||||
"browseAll": "Tout afficher",
|
||||
"nextFeatured": "Voir d’autres",
|
||||
"cliLabel": "Application",
|
||||
"mcpLabel": "MCP",
|
||||
"channelLabel": "Canal",
|
||||
@@ -600,7 +608,7 @@
|
||||
"enabledSummary": "{{count}} prêts",
|
||||
"caption": "{{cli}} applications · {{mcp}} outils MCP",
|
||||
"searchPlaceholder": "Rechercher des applications",
|
||||
"featured": "Outils",
|
||||
"featured": "À la une",
|
||||
"mcpTools": "Outils MCP",
|
||||
"loading": "Chargement des applications...",
|
||||
"empty": "Aucun outil ne correspond à votre recherche.",
|
||||
|
||||
@@ -203,7 +203,7 @@
|
||||
"currentModel": "Digunakan untuk balasan baru.",
|
||||
"selectedModelProvider": "Ditentukan oleh model yang dipilih.",
|
||||
"selectedModelValue": "Ditentukan oleh model yang dipilih.",
|
||||
"brandLogos": "Tampilkan logo penyedia pihak ketiga dan CLI di Pengaturan.",
|
||||
"brandLogos": "Muat logo merek pihak ketiga dari layanan ikon eksternal. Nonaktifkan untuk menggunakan inisial lokal.",
|
||||
"cliAppsCatalog": "Instal hanya adaptor CLI aplikasi yang dapat dijalankan nanobot secara lokal; aplikasi asli tidak diubah.",
|
||||
"cliAppsFilter": "Cari berdasarkan aplikasi, kategori, atau kemampuan.",
|
||||
"logs": "Buka folder log mesin asli.",
|
||||
@@ -539,7 +539,10 @@
|
||||
"enabled": "Aktif",
|
||||
"setup": "Hubungkan",
|
||||
"configure": "Hubungkan",
|
||||
"reconnect": "Hubungkan kembali",
|
||||
"connectTitle": "Hubungkan {{name}}",
|
||||
"reconnectTitle": "Hubungkan kembali {{name}}",
|
||||
"actionsTitle": "Tindakan untuk {{name}}",
|
||||
"connectHint": "Tambahkan kunci dari pengaturan akun Anda.",
|
||||
"saveAndEnable": "Simpan dan aktifkan",
|
||||
"updateSetup": "Perbarui konfigurasi",
|
||||
@@ -584,11 +587,16 @@
|
||||
"title": "Observabilitas", "configured": "Kredensial tracing tersedia untuk nanobot.",
|
||||
"environment": "Atur LANGFUSE_SECRET_KEY dan LANGFUSE_PUBLIC_KEY, lalu mulai ulang nanobot.", "enable": "Aktifkan dukungan tracing"
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "Nama produk, logo, dan merek adalah milik pemiliknya masing-masing. Penggunaan hanya untuk identifikasi dan tidak menyiratkan dukungan."
|
||||
},
|
||||
"apps": {
|
||||
"description": "Tambahkan alat ke nanobot, lalu gunakan dengan @ di chat.",
|
||||
"discover": "Temukan",
|
||||
"installed": "Terpasang",
|
||||
"allApps": "Semua aplikasi",
|
||||
"addCustom": "Tambah kustom",
|
||||
"emptyInstalled": "Belum ada aplikasi yang terpasang.",
|
||||
"searchResults": "Hasil pencarian",
|
||||
"browseAll": "Lihat semua",
|
||||
"nextFeatured": "Tampilkan lainnya",
|
||||
"cliLabel": "Aplikasi",
|
||||
"mcpLabel": "MCP",
|
||||
"channelLabel": "Kanal",
|
||||
@@ -600,7 +608,7 @@
|
||||
"enabledSummary": "{{count}} siap",
|
||||
"caption": "{{cli}} aplikasi · {{mcp}} alat MCP",
|
||||
"searchPlaceholder": "Cari aplikasi",
|
||||
"featured": "Alat",
|
||||
"featured": "Unggulan",
|
||||
"mcpTools": "Alat MCP",
|
||||
"loading": "Memuat aplikasi...",
|
||||
"empty": "Tidak ada alat yang cocok dengan pencarian Anda.",
|
||||
|
||||
@@ -203,7 +203,7 @@
|
||||
"currentModel": "新しい返信に使用します。",
|
||||
"selectedModelProvider": "選択したモデルによって設定されます。",
|
||||
"selectedModelValue": "選択したモデルによって設定されます。",
|
||||
"brandLogos": "設定で第三者プロバイダーと CLI のロゴを表示します。",
|
||||
"brandLogos": "外部のアイコンサービスからサードパーティのブランドロゴを読み込みます。オフにするとローカルのイニシャルアイコンを使用します。",
|
||||
"cliAppsCatalog": "nanobot がローカルで実行できるアプリ CLI アダプターだけをインストールします。ネイティブアプリは変更しません。",
|
||||
"cliAppsFilter": "アプリ、カテゴリ、機能で検索します。",
|
||||
"logs": "ネイティブエンジンのログフォルダーを開きます。",
|
||||
@@ -539,7 +539,10 @@
|
||||
"enabled": "有効",
|
||||
"setup": "接続",
|
||||
"configure": "接続",
|
||||
"reconnect": "再接続",
|
||||
"connectTitle": "{{name}} に接続",
|
||||
"reconnectTitle": "{{name}} に再接続",
|
||||
"actionsTitle": "{{name}} の操作",
|
||||
"connectHint": "アカウント設定からキーを追加します。",
|
||||
"saveAndEnable": "保存して有効化",
|
||||
"updateSetup": "設定を更新",
|
||||
@@ -584,11 +587,16 @@
|
||||
"title": "可観測性", "configured": "nanobot がトレース認証情報を利用できます。",
|
||||
"environment": "LANGFUSE_SECRET_KEY と LANGFUSE_PUBLIC_KEY を設定して nanobot を再起動してください。", "enable": "トレースサポートを有効化"
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "製品名、ロゴ、ブランドはそれぞれの所有者に帰属します。使用は識別のみを目的とし、承認を意味するものではありません。"
|
||||
},
|
||||
"apps": {
|
||||
"description": "nanobot にツールを追加し、チャットで @ を付けて使用できます。",
|
||||
"discover": "見つける",
|
||||
"installed": "インストール済み",
|
||||
"allApps": "すべてのアプリ",
|
||||
"addCustom": "カスタム追加",
|
||||
"emptyInstalled": "インストール済みのアプリはありません。",
|
||||
"searchResults": "検索結果",
|
||||
"browseAll": "すべて見る",
|
||||
"nextFeatured": "ほかを見る",
|
||||
"cliLabel": "アプリ",
|
||||
"mcpLabel": "MCP",
|
||||
"channelLabel": "チャンネル",
|
||||
@@ -600,7 +608,7 @@
|
||||
"enabledSummary": "{{count}} 件使用可能",
|
||||
"caption": "アプリ {{cli}} 件 · MCP ツール {{mcp}} 件",
|
||||
"searchPlaceholder": "アプリを検索",
|
||||
"featured": "ツール",
|
||||
"featured": "おすすめ",
|
||||
"mcpTools": "MCP ツール",
|
||||
"loading": "アプリを読み込み中...",
|
||||
"empty": "検索条件に一致するツールはありません。",
|
||||
|
||||
@@ -203,7 +203,7 @@
|
||||
"currentModel": "새 응답에 사용됩니다.",
|
||||
"selectedModelProvider": "선택한 모델에 의해 설정됩니다.",
|
||||
"selectedModelValue": "선택한 모델에 의해 설정됩니다.",
|
||||
"brandLogos": "설정에서 타사 제공자와 CLI 로고를 표시합니다.",
|
||||
"brandLogos": "외부 아이콘 서비스에서 타사 브랜드 로고를 불러옵니다. 끄면 로컬 이니셜 아이콘을 사용합니다.",
|
||||
"cliAppsCatalog": "nanobot이 로컬에서 실행할 수 있는 앱 CLI 어댑터만 설치합니다. 네이티브 앱은 변경하지 않습니다.",
|
||||
"cliAppsFilter": "앱, 카테고리 또는 기능으로 검색합니다.",
|
||||
"logs": "네이티브 엔진 로그 폴더를 엽니다.",
|
||||
@@ -539,7 +539,10 @@
|
||||
"enabled": "활성화됨",
|
||||
"setup": "연결",
|
||||
"configure": "연결",
|
||||
"reconnect": "다시 연결",
|
||||
"connectTitle": "{{name}} 연결",
|
||||
"reconnectTitle": "{{name}} 다시 연결",
|
||||
"actionsTitle": "{{name}} 작업",
|
||||
"connectHint": "계정 설정에서 키를 추가하세요.",
|
||||
"saveAndEnable": "저장 후 활성화",
|
||||
"updateSetup": "설정 업데이트",
|
||||
@@ -584,11 +587,16 @@
|
||||
"title": "관측성", "configured": "nanobot이 추적 자격 증명을 사용할 수 있습니다.",
|
||||
"environment": "LANGFUSE_SECRET_KEY와 LANGFUSE_PUBLIC_KEY를 설정한 뒤 nanobot을 다시 시작하세요.", "enable": "추적 지원 활성화"
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "제품 이름, 로고 및 브랜드는 각 소유자의 자산입니다. 사용은 식별 목적일 뿐 보증이나 제휴를 의미하지 않습니다."
|
||||
},
|
||||
"apps": {
|
||||
"description": "nanobot에 도구를 추가한 뒤 채팅에서 @로 사용하세요.",
|
||||
"discover": "둘러보기",
|
||||
"installed": "설치됨",
|
||||
"allApps": "모든 앱",
|
||||
"addCustom": "사용자 지정 추가",
|
||||
"emptyInstalled": "설치된 앱이 없습니다.",
|
||||
"searchResults": "검색 결과",
|
||||
"browseAll": "모두 보기",
|
||||
"nextFeatured": "다른 추천 보기",
|
||||
"cliLabel": "앱",
|
||||
"mcpLabel": "MCP",
|
||||
"channelLabel": "채널",
|
||||
@@ -600,7 +608,7 @@
|
||||
"enabledSummary": "{{count}}개 사용 가능",
|
||||
"caption": "앱 {{cli}}개 · MCP 도구 {{mcp}}개",
|
||||
"searchPlaceholder": "앱 검색",
|
||||
"featured": "도구",
|
||||
"featured": "추천",
|
||||
"mcpTools": "MCP 도구",
|
||||
"loading": "앱을 불러오는 중...",
|
||||
"empty": "검색과 일치하는 도구가 없습니다.",
|
||||
|
||||
@@ -241,7 +241,7 @@
|
||||
"activityMode": "Escolha quanto detalhe de atividade do agente é exibido por padrão.",
|
||||
"fileEditDisplay": "Escolha se a atividade de edição de arquivo é exibida como contagem de linhas ou como diferenças.",
|
||||
"codeWrap": "Mantém linhas longas de código legíveis em telas menores.",
|
||||
"brandLogos": "Mostra logotipos de provedores terceiros e de CLIs em Configurações.",
|
||||
"brandLogos": "Carrega logotipos de marcas de terceiros por serviços externos de ícones. Desative para usar iniciais locais.",
|
||||
"maxResults": "Resultados retornados por cada chamada de web_search.",
|
||||
"timeout": "Segundos antes de uma requisição de busca expirar.",
|
||||
"jinaReader": "Usa o Jina Reader para web_fetch quando disponível.",
|
||||
@@ -354,7 +354,10 @@
|
||||
"enabled": "Habilitado",
|
||||
"setup": "Conectar",
|
||||
"configure": "Conectar",
|
||||
"reconnect": "Reconectar",
|
||||
"connectTitle": "Conectar {{name}}",
|
||||
"reconnectTitle": "Reconectar {{name}}",
|
||||
"actionsTitle": "Ações para {{name}}",
|
||||
"connectHint": "Adicione a chave a partir das configurações da sua conta.",
|
||||
"saveAndEnable": "Salvar e habilitar",
|
||||
"updateSetup": "Atualizar configuração",
|
||||
@@ -554,9 +557,6 @@
|
||||
"capabilityOpenAISearch": "Pesquisa web da OpenAI",
|
||||
"capabilityOpenAISearchHelp": "Permite que modelos compatíveis com a Responses API pesquisem na web. A atividade de pesquisa aparece no chat."
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "Nomes de produtos, logotipos e marcas são propriedades de seus respectivos donos. O uso é apenas para identificação e não implica endosso."
|
||||
},
|
||||
"image": {
|
||||
"selectProvider": "Selecionar provedor",
|
||||
"selectAspect": "Selecionar proporção",
|
||||
@@ -603,6 +603,14 @@
|
||||
},
|
||||
"apps": {
|
||||
"description": "Adicione ferramentas ao nanobot e mencione-as com @ na conversa.",
|
||||
"discover": "Descobrir",
|
||||
"installed": "Instalados",
|
||||
"allApps": "Todos os apps",
|
||||
"addCustom": "Adicionar personalizado",
|
||||
"emptyInstalled": "Nenhum app instalado ainda.",
|
||||
"searchResults": "Resultados da busca",
|
||||
"browseAll": "Ver todos",
|
||||
"nextFeatured": "Mostrar outros",
|
||||
"cliLabel": "Aplicativo",
|
||||
"mcpLabel": "MCP",
|
||||
"channelLabel": "Canal",
|
||||
@@ -614,7 +622,7 @@
|
||||
"enabledSummary": "{{count}} prontos",
|
||||
"caption": "{{cli}} aplicativos · {{mcp}} ferramentas MCP",
|
||||
"searchPlaceholder": "Buscar ferramentas",
|
||||
"featured": "Ferramentas",
|
||||
"featured": "Destaques",
|
||||
"mcpTools": "Ferramentas MCP",
|
||||
"loading": "Carregando aplicativos...",
|
||||
"empty": "Nenhuma ferramenta corresponde à sua busca.",
|
||||
|
||||
@@ -203,7 +203,7 @@
|
||||
"currentModel": "Dùng cho các phản hồi mới.",
|
||||
"selectedModelProvider": "Được đặt bởi mô hình đã chọn.",
|
||||
"selectedModelValue": "Được đặt bởi mô hình đã chọn.",
|
||||
"brandLogos": "Hiển thị logo nhà cung cấp bên thứ ba và CLI trong Cài đặt.",
|
||||
"brandLogos": "Tải logo thương hiệu của bên thứ ba từ dịch vụ biểu tượng bên ngoài. Tắt để dùng chữ cái đại diện cục bộ.",
|
||||
"cliAppsCatalog": "Chỉ cài đặt các bộ chuyển đổi CLI ứng dụng mà nanobot có thể chạy cục bộ; ứng dụng gốc không bị thay đổi.",
|
||||
"cliAppsFilter": "Tìm theo ứng dụng, danh mục hoặc khả năng.",
|
||||
"logs": "Mở thư mục nhật ký của bộ máy gốc.",
|
||||
@@ -539,7 +539,10 @@
|
||||
"enabled": "Đã bật",
|
||||
"setup": "Kết nối",
|
||||
"configure": "Kết nối",
|
||||
"reconnect": "Kết nối lại",
|
||||
"connectTitle": "Kết nối {{name}}",
|
||||
"reconnectTitle": "Kết nối lại {{name}}",
|
||||
"actionsTitle": "Thao tác cho {{name}}",
|
||||
"connectHint": "Thêm khóa từ phần cài đặt tài khoản của bạn.",
|
||||
"saveAndEnable": "Lưu và bật",
|
||||
"updateSetup": "Cập nhật thiết lập",
|
||||
@@ -584,11 +587,16 @@
|
||||
"title": "Khả năng quan sát", "configured": "Thông tin xác thực tracing đã sẵn sàng cho nanobot.",
|
||||
"environment": "Đặt LANGFUSE_SECRET_KEY và LANGFUSE_PUBLIC_KEY rồi khởi động lại nanobot.", "enable": "Bật hỗ trợ tracing"
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "Tên sản phẩm, logo và thương hiệu thuộc về chủ sở hữu tương ứng. Việc sử dụng chỉ nhằm nhận diện và không ngụ ý được xác nhận."
|
||||
},
|
||||
"apps": {
|
||||
"description": "Thêm công cụ vào nanobot, sau đó dùng @ trong cuộc trò chuyện.",
|
||||
"discover": "Khám phá",
|
||||
"installed": "Đã cài đặt",
|
||||
"allApps": "Tất cả ứng dụng",
|
||||
"addCustom": "Thêm tùy chỉnh",
|
||||
"emptyInstalled": "Chưa có ứng dụng nào được cài đặt.",
|
||||
"searchResults": "Kết quả tìm kiếm",
|
||||
"browseAll": "Xem tất cả",
|
||||
"nextFeatured": "Xem nhóm khác",
|
||||
"cliLabel": "Ứng dụng",
|
||||
"mcpLabel": "MCP",
|
||||
"channelLabel": "Kênh",
|
||||
@@ -600,7 +608,7 @@
|
||||
"enabledSummary": "{{count}} sẵn sàng",
|
||||
"caption": "{{cli}} ứng dụng · {{mcp}} công cụ MCP",
|
||||
"searchPlaceholder": "Tìm ứng dụng",
|
||||
"featured": "Công cụ",
|
||||
"featured": "Nổi bật",
|
||||
"mcpTools": "Công cụ MCP",
|
||||
"loading": "Đang tải ứng dụng...",
|
||||
"empty": "Không có công cụ phù hợp với tìm kiếm của bạn.",
|
||||
|
||||
@@ -241,7 +241,7 @@
|
||||
"activityMode": "选择默认显示多少智能体活动详情。",
|
||||
"fileEditDisplay": "选择文件编辑活动默认显示行数还是差异。",
|
||||
"codeWrap": "让长代码行在小屏幕上也易读。",
|
||||
"brandLogos": "在设置中显示第三方提供商和 CLI 图标。",
|
||||
"brandLogos": "从外部图标服务加载第三方品牌 Logo;关闭后使用本地首字母图标。",
|
||||
"maxResults": "每次 web_search 调用返回的结果数。",
|
||||
"timeout": "搜索提供商请求超时前等待的秒数。",
|
||||
"jinaReader": "可用时为 web_fetch 使用 Jina Reader。",
|
||||
@@ -354,7 +354,10 @@
|
||||
"enabled": "已启用",
|
||||
"setup": "连接",
|
||||
"configure": "连接",
|
||||
"reconnect": "重新连接",
|
||||
"connectTitle": "连接 {{name}}",
|
||||
"reconnectTitle": "重新连接 {{name}}",
|
||||
"actionsTitle": "{{name}} 操作",
|
||||
"connectHint": "填入账户中的密钥。",
|
||||
"saveAndEnable": "保存并启用",
|
||||
"updateSetup": "更新配置",
|
||||
@@ -554,9 +557,6 @@
|
||||
"capabilityOpenAISearch": "OpenAI 联网搜索",
|
||||
"capabilityOpenAISearchHelp": "允许兼容 Responses API 的模型搜索网络,并在对话中展示搜索过程。"
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "产品名称、Logo 和品牌归各自所有者所有;此处仅用于识别,不代表背书或合作。"
|
||||
},
|
||||
"image": {
|
||||
"selectProvider": "选择提供商",
|
||||
"selectAspect": "选择比例",
|
||||
@@ -603,6 +603,14 @@
|
||||
},
|
||||
"apps": {
|
||||
"description": "将工具接入 nanobot,然后在对话中通过 @ 调用。",
|
||||
"discover": "发现",
|
||||
"installed": "已安装",
|
||||
"allApps": "全部应用",
|
||||
"addCustom": "自定义接入",
|
||||
"emptyInstalled": "还没有安装应用。",
|
||||
"searchResults": "搜索结果",
|
||||
"browseAll": "浏览全部",
|
||||
"nextFeatured": "换一批",
|
||||
"cliLabel": "应用",
|
||||
"mcpLabel": "MCP",
|
||||
"channelLabel": "渠道",
|
||||
@@ -614,7 +622,7 @@
|
||||
"enabledSummary": "{{count}} 个可用",
|
||||
"caption": "{{cli}} 个应用 · {{mcp}} 个 MCP 工具",
|
||||
"searchPlaceholder": "搜索工具",
|
||||
"featured": "工具",
|
||||
"featured": "精选",
|
||||
"mcpTools": "MCP 工具",
|
||||
"loading": "正在加载应用...",
|
||||
"empty": "没有与搜索条件匹配的工具。",
|
||||
|
||||
@@ -203,7 +203,7 @@
|
||||
"currentModel": "用於新的回覆。",
|
||||
"selectedModelProvider": "由選取的模型決定。",
|
||||
"selectedModelValue": "由選取的模型決定。",
|
||||
"brandLogos": "在設定中顯示第三方供應商與 CLI 圖示。",
|
||||
"brandLogos": "從外部圖示服務載入第三方品牌 Logo;關閉後使用本機首字母圖示。",
|
||||
"cliAppsCatalog": "只安裝 nanobot 可在本機執行的應用程式專用 CLI 轉接器;不會改動原生應用程式。",
|
||||
"cliAppsFilter": "依應用程式、類別或功能搜尋。",
|
||||
"logs": "開啟原生引擎日誌資料夾。",
|
||||
@@ -539,7 +539,10 @@
|
||||
"enabled": "已啟用",
|
||||
"setup": "連線",
|
||||
"configure": "連線",
|
||||
"reconnect": "重新連線",
|
||||
"connectTitle": "連線 {{name}}",
|
||||
"reconnectTitle": "重新連線 {{name}}",
|
||||
"actionsTitle": "{{name}} 操作",
|
||||
"connectHint": "請從帳號設定新增金鑰。",
|
||||
"saveAndEnable": "儲存並啟用",
|
||||
"updateSetup": "更新設定",
|
||||
@@ -584,11 +587,16 @@
|
||||
"title": "可觀測性", "configured": "nanobot 已偵測到追蹤憑證。",
|
||||
"environment": "設定 LANGFUSE_SECRET_KEY 和 LANGFUSE_PUBLIC_KEY 後重新啟動 nanobot。", "enable": "啟用追蹤支援"
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "產品名稱、Logo 與品牌均為各自擁有者的財產。僅供識別之用,不代表任何形式的背書。"
|
||||
},
|
||||
"apps": {
|
||||
"description": "將工具新增至 nanobot,再於聊天中使用 @ 指定工具。",
|
||||
"discover": "探索",
|
||||
"installed": "已安裝",
|
||||
"allApps": "全部應用",
|
||||
"addCustom": "自訂接入",
|
||||
"emptyInstalled": "尚未安裝應用。",
|
||||
"searchResults": "搜尋結果",
|
||||
"browseAll": "瀏覽全部",
|
||||
"nextFeatured": "換一批",
|
||||
"cliLabel": "應用程式",
|
||||
"mcpLabel": "MCP",
|
||||
"channelLabel": "通訊管道",
|
||||
@@ -600,7 +608,7 @@
|
||||
"enabledSummary": "{{count}} 個就緒",
|
||||
"caption": "{{cli}} 個應用程式 · {{mcp}} 個 MCP 工具",
|
||||
"searchPlaceholder": "搜尋工具",
|
||||
"featured": "工具",
|
||||
"featured": "精選",
|
||||
"mcpTools": "MCP 工具",
|
||||
"loading": "正在載入應用程式…",
|
||||
"empty": "沒有符合搜尋條件的工具。",
|
||||
|
||||
+14
-1
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
ApiServicePayload,
|
||||
AppsDiscoveryPayload,
|
||||
AutomationsPayload,
|
||||
AutomationUpdatePayload,
|
||||
ChannelConfigurePayload,
|
||||
@@ -491,6 +492,18 @@ export async function fetchCliApps(
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchAppsDiscovery(
|
||||
token: string,
|
||||
base: string = "",
|
||||
): Promise<AppsDiscoveryPayload> {
|
||||
return request<AppsDiscoveryPayload>(
|
||||
`${base}/api/settings/apps-discovery`,
|
||||
token,
|
||||
undefined,
|
||||
API_READ_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchInstalledCliApps(
|
||||
token: string,
|
||||
base: string = "",
|
||||
@@ -766,7 +779,7 @@ export async function fetchProviderModels(
|
||||
|
||||
export async function runMcpPresetAction(
|
||||
transport: WebUIMutationTransport,
|
||||
action: "enable" | "remove" | "test",
|
||||
action: "enable" | "disable" | "remove" | "test" | "reconnect",
|
||||
name: string,
|
||||
values: Record<string, string> = {},
|
||||
): Promise<McpPresetsPayload> {
|
||||
|
||||
@@ -12,12 +12,15 @@ export interface LocalPreferences {
|
||||
|
||||
export const LOCAL_PREFS_STORAGE_KEY = "nanobot-webui.settings-preferences";
|
||||
export const LOCAL_PREFS_CHANGED_EVENT = "nanobot-webui.local-preferences-changed";
|
||||
export const LOCAL_PREFS_VERSION = 1;
|
||||
|
||||
type StoredLocalPreferences = Partial<LocalPreferences> & { version?: number };
|
||||
|
||||
export const DEFAULT_LOCAL_PREFS: LocalPreferences = {
|
||||
density: "comfortable",
|
||||
activityMode: "auto",
|
||||
codeWrap: true,
|
||||
brandLogos: false,
|
||||
brandLogos: true,
|
||||
fileEditDisplayMode: "summary",
|
||||
};
|
||||
|
||||
@@ -29,12 +32,12 @@ export function readLocalPreferences(): LocalPreferences {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(LOCAL_PREFS_STORAGE_KEY);
|
||||
if (!raw) return DEFAULT_LOCAL_PREFS;
|
||||
const parsed = JSON.parse(raw) as Partial<LocalPreferences>;
|
||||
const parsed = JSON.parse(raw) as StoredLocalPreferences;
|
||||
return {
|
||||
density: parsed.density === "compact" ? "compact" : "comfortable",
|
||||
activityMode: parsed.activityMode === "expanded" ? "expanded" : "auto",
|
||||
codeWrap: parsed.codeWrap !== false,
|
||||
brandLogos: parsed.brandLogos === true,
|
||||
brandLogos: parsed.version === undefined ? true : parsed.brandLogos !== false,
|
||||
fileEditDisplayMode: normalizeFileEditDisplayMode(parsed.fileEditDisplayMode),
|
||||
};
|
||||
} catch {
|
||||
@@ -44,7 +47,10 @@ export function readLocalPreferences(): LocalPreferences {
|
||||
|
||||
export function writeLocalPreferences(preferences: LocalPreferences): void {
|
||||
try {
|
||||
window.localStorage.setItem(LOCAL_PREFS_STORAGE_KEY, JSON.stringify(preferences));
|
||||
window.localStorage.setItem(
|
||||
LOCAL_PREFS_STORAGE_KEY,
|
||||
JSON.stringify({ version: LOCAL_PREFS_VERSION, ...preferences }),
|
||||
);
|
||||
} catch {
|
||||
// Browser-only preferences should never block settings.
|
||||
}
|
||||
|
||||
@@ -9,7 +9,10 @@ export function isMcpPresetsPayload(value: unknown): value is McpPresetsPayload
|
||||
}
|
||||
|
||||
export function installedMcpPresetsFromPayload(payload: McpPresetsPayload): McpPresetInfo[] {
|
||||
return payload.presets.filter((preset) => preset.installed && preset.configured);
|
||||
return payload.presets.filter(
|
||||
(preset) => preset.source !== "agent-plugin"
|
||||
&& (preset.enabled ?? (preset.installed && preset.configured)),
|
||||
);
|
||||
}
|
||||
|
||||
export function notifyMcpPresetsChanged(payload: McpPresetsPayload): void {
|
||||
|
||||
@@ -817,6 +817,13 @@ export interface CliAppsPayload {
|
||||
};
|
||||
}
|
||||
|
||||
export interface AppsDiscoveryPayload {
|
||||
schema_version: number;
|
||||
updated_at: string;
|
||||
featured: string[];
|
||||
refresh_pending?: boolean;
|
||||
}
|
||||
|
||||
export interface NanobotFeatureInfo {
|
||||
name: string;
|
||||
display_name: string;
|
||||
@@ -962,8 +969,10 @@ export interface McpPresetInfo {
|
||||
install_supported: boolean;
|
||||
installed: boolean;
|
||||
configured: boolean;
|
||||
enabled?: boolean;
|
||||
available: boolean;
|
||||
status: "not_installed" | "configured" | "missing_credentials" | "missing_dependency" | "coming_soon" | string;
|
||||
runtime_status?: "connecting" | "connected" | "failed" | string;
|
||||
logo_url?: string | null;
|
||||
brand_color?: string | null;
|
||||
required_fields: McpPresetField[];
|
||||
|
||||
@@ -28,3 +28,16 @@ initializeLoopbackRuntimeHost();
|
||||
|
||||
/* StrictMode disabled: dev double-invokes state updaters; delta accumulation must stay pure — see useNanobotStream. */
|
||||
ReactDOM.createRoot(root).render(<App />);
|
||||
|
||||
if ("serviceWorker" in navigator) {
|
||||
window.addEventListener("load", () => {
|
||||
navigator.serviceWorker
|
||||
.register("/sw.js", {
|
||||
updateViaCache: "none",
|
||||
})
|
||||
.catch(() => {
|
||||
// Service workers are progressive enhancement; registration failures
|
||||
// (unsupported proxies, blocked storage) must not break the app.
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -441,8 +441,12 @@ describe("App layout", () => {
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" }));
|
||||
|
||||
await act(async () => {
|
||||
await import("@/components/settings/SettingsView");
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByRole("navigation", { name: "Settings sections" }),
|
||||
screen.getByRole("navigation", { name: "Settings sections" }),
|
||||
).toBeInTheDocument();
|
||||
expect(container.querySelectorAll("main")).toHaveLength(1);
|
||||
expect(screen.getByRole("heading", { level: 1, name: "Settings" })).toBeInTheDocument();
|
||||
@@ -2685,7 +2689,7 @@ describe("App layout", () => {
|
||||
fireEvent.click(appsButton);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Apps" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("Add tools to nanobot, then @ them in chat.")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Add tools to nanobot, then @ them in chat.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("navigation", { name: "Sidebar navigation" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("navigation", { name: "Settings sections" })).not.toBeInTheDocument();
|
||||
expect(within(sidebar).getByRole("button", { name: "Apps" })).toHaveAttribute(
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
describe("form control focus styles", () => {
|
||||
it.each([
|
||||
["input", <Input aria-label="input" />],
|
||||
["textarea", <Textarea aria-label="textarea" />],
|
||||
])("uses a subdued inset focus ring for the %s", (label, control) => {
|
||||
render(control);
|
||||
|
||||
const element = screen.getByLabelText(label);
|
||||
expect(element).toHaveClass(
|
||||
"focus-visible:ring-2",
|
||||
"focus-visible:ring-inset",
|
||||
"focus-visible:ring-ring/50",
|
||||
);
|
||||
expect(element).not.toHaveClass(
|
||||
"ring-offset-background",
|
||||
"focus-visible:ring-ring",
|
||||
"focus-visible:ring-offset-2",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DEFAULT_LOCAL_PREFS,
|
||||
LOCAL_PREFS_STORAGE_KEY,
|
||||
LOCAL_PREFS_VERSION,
|
||||
readLocalPreferences,
|
||||
writeLocalPreferences,
|
||||
} from "@/lib/local-preferences";
|
||||
|
||||
describe("local preferences", () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it("shows brand logos by default", () => {
|
||||
expect(DEFAULT_LOCAL_PREFS.brandLogos).toBe(true);
|
||||
expect(readLocalPreferences().brandLogos).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves an explicit brand-logo opt-out", () => {
|
||||
localStorage.setItem(LOCAL_PREFS_STORAGE_KEY, JSON.stringify({
|
||||
version: LOCAL_PREFS_VERSION,
|
||||
brandLogos: false,
|
||||
}));
|
||||
|
||||
expect(readLocalPreferences().brandLogos).toBe(false);
|
||||
});
|
||||
|
||||
it("migrates the old auto-persisted opt-out to visible logos", () => {
|
||||
localStorage.setItem(LOCAL_PREFS_STORAGE_KEY, JSON.stringify({ brandLogos: false }));
|
||||
|
||||
expect(readLocalPreferences().brandLogos).toBe(true);
|
||||
});
|
||||
|
||||
it("enables brand logos for legacy preferences without the field", () => {
|
||||
localStorage.setItem(LOCAL_PREFS_STORAGE_KEY, JSON.stringify({ density: "compact" }));
|
||||
|
||||
expect(readLocalPreferences().brandLogos).toBe(true);
|
||||
});
|
||||
|
||||
it("versions newly written preferences", () => {
|
||||
writeLocalPreferences({ ...DEFAULT_LOCAL_PREFS, brandLogos: false });
|
||||
|
||||
expect(JSON.parse(localStorage.getItem(LOCAL_PREFS_STORAGE_KEY) ?? "{}")).toMatchObject({
|
||||
version: LOCAL_PREFS_VERSION,
|
||||
brandLogos: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user