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**.
|
3. Configure a provider and model in **Settings → Models**.
|
||||||
4. Send `Hello!` before configuring anything else.
|
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
|
## 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) |
|
| Choose a hosted, OAuth, company, or local model | [Provider Cookbook](./provider-cookbook.md) |
|
||||||
| Add model fallbacks | [Configure Model Fallback](./guides/configure-model-fallback.md) |
|
| Add model fallbacks | [Configure Model Fallback](./guides/configure-model-fallback.md) |
|
||||||
| Enable web search | [Configure Web Search](./guides/configure-web-search.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) |
|
| Add an MCP tool server | [Configure MCP Tools](./guides/configure-mcp-tools.md) |
|
||||||
| Generate images | [Image Generation](./image-generation.md) |
|
| Generate images | [Image Generation](./image-generation.md) |
|
||||||
| Schedule work or create a local trigger | [Automations](./automations.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 |
|
| 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) |
|
| Channel | Export a `ChannelPlugin` descriptor, keep its runtime and optional setup surfaces in one package, and follow [`channel-package-guide.md`](./channel-package-guide.md) |
|
||||||
| Tool | Implement a tool under `agent/tools/` or expose a plugin entry point |
|
| Tool | Implement a tool under `agent/tools/` or expose a plugin entry point |
|
||||||
| MCP | Add `tools.mcpServers` config |
|
| Agent Plugin | Add a v1 package under `<workspace>/plugins/` and enable it from Apps |
|
||||||
| Skill | Add workspace skill files under `<workspace>/skills/` or built-in skills under `nanobot/skills/` |
|
| 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.
|
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.
|
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 and Safety
|
||||||
|
|
||||||
Tools are discovered automatically from built-in modules and plugin entry points. Common tool groups include:
|
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`.
|
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
|
```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. |
|
| `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 Hint Max Length
|
||||||
|
|
||||||
Tool hints are the short progress messages shown when the agent calls tools (e.g. `$ cd …/project && npm test`). By default, these are truncated at 40 characters, which can make long commands hard to read.
|
Tool hints are the short progress messages shown when the agent calls tools (e.g. `$ cd …/project && npm test`). By default, these are truncated at 40 characters, which can make long commands hard to read.
|
||||||
|
|||||||
@@ -100,6 +100,29 @@ Gateway-style setup for model IDs served through OpenRouter.
|
|||||||
|
|
||||||
Use the model ID exactly as OpenRouter lists it.
|
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 Gateway
|
||||||
|
|
||||||
Eden AI exposes an OpenAI-compatible chat-completions endpoint at
|
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
|
## Apps
|
||||||
|
|
||||||
Open Apps from the sidebar to manage tools that nanobot can attach to a chat
|
Open Apps from the sidebar to review and manage installable capabilities. The
|
||||||
turn. The default **Ready** view shows only tools that can be used immediately:
|
default **Ready** view shows only capabilities that can be used immediately:
|
||||||
|
|
||||||
- **Apps** are local command-line adapters that nanobot runs on your machine.
|
- **Agent Plugins** are local packages that can bundle skills, MCP servers, or
|
||||||
Installing an adapter does not modify the native desktop or web app it
|
both. A package under `<workspace>/plugins/` is installed but remains inactive
|
||||||
connects to.
|
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
|
- **MCP** lists Model Context Protocol servers. Presets provide known
|
||||||
configurations, and the **Add MCP server** panel accepts stdio, HTTP, and SSE
|
configurations, and the **Add MCP server** panel accepts stdio, HTTP, and SSE
|
||||||
servers. Custom HTTP/SSE servers can use no authentication, OAuth, or request
|
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
|
included in nanobot and activate automatically when a file is attached. The
|
||||||
equivalent CLI for optional integrations remains `nanobot plugins`. See
|
equivalent CLI for optional integrations remains `nanobot plugins`. See
|
||||||
[`cli-reference.md`](./cli-reference.md#optional-features).
|
[`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
|
Some MCP presets connect to hosted keyless endpoints. For example, the Firecrawl
|
||||||
preset uses Firecrawl's hosted MCP endpoint for search, scrape, crawl, and
|
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
|
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.
|
provider; mention `@parallel-search` when a turn should use it.
|
||||||
|
|
||||||
After an App or MCP server is available, mention it from the composer with `@`
|
After a CLI App or MCP server is available, mention it from the composer with
|
||||||
to attach that tool to the next message.
|
`@` to attach that tool to the next message. Plugin-provided skills participate
|
||||||
|
in normal skill discovery and can be invoked with `$skill-name`.
|
||||||
|
|
||||||
## Skills
|
## Skills
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ async def connect_mcp(state: Any, tools: ToolRegistry) -> None:
|
|||||||
await mcp_tools.connect_missing_servers(state, tools)
|
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:
|
async def close_mcp(state: Any) -> None:
|
||||||
await mcp_tools.close_mcp_servers(state)
|
await mcp_tools.close_mcp_servers(state)
|
||||||
|
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ from nanobot.utils.runtime import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nanobot.agent.tools.mcp import MCPConnection
|
from nanobot.agent.tools.mcp import MCPConnection, MCPRuntimeStatus
|
||||||
from nanobot.config.schema import (
|
from nanobot.config.schema import (
|
||||||
ChannelsConfig,
|
ChannelsConfig,
|
||||||
Config,
|
Config,
|
||||||
@@ -401,6 +401,7 @@ class AgentLoop:
|
|||||||
self._running = False
|
self._running = False
|
||||||
self._mcp_servers = mcp_servers or {}
|
self._mcp_servers = mcp_servers or {}
|
||||||
self._mcp_stacks: dict[str, MCPConnection] = {}
|
self._mcp_stacks: dict[str, MCPConnection] = {}
|
||||||
|
self._mcp_runtime_statuses: dict[str, MCPRuntimeStatus] = {}
|
||||||
self._mcp_connecting = False
|
self._mcp_connecting = False
|
||||||
self._runtime_context_providers: list[RuntimeContextProvider] = []
|
self._runtime_context_providers: list[RuntimeContextProvider] = []
|
||||||
self._active_tasks: dict[str, set[asyncio.Task[Any]]] = {}
|
self._active_tasks: dict[str, set[asyncio.Task[Any]]] = {}
|
||||||
@@ -485,6 +486,8 @@ class AgentLoop:
|
|||||||
config,
|
config,
|
||||||
provider_snapshot_loader,
|
provider_snapshot_loader,
|
||||||
)
|
)
|
||||||
|
from nanobot.agent.plugins import agent_plugin_mcp_servers
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
bus=bus,
|
bus=bus,
|
||||||
provider=provider,
|
provider=provider,
|
||||||
@@ -499,7 +502,7 @@ class AgentLoop:
|
|||||||
provider_retry_mode=defaults.provider_retry_mode,
|
provider_retry_mode=defaults.provider_retry_mode,
|
||||||
tool_hint_max_length=defaults.tool_hint_max_length,
|
tool_hint_max_length=defaults.tool_hint_max_length,
|
||||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
restrict_to_workspace=config.tools.restrict_to_workspace,
|
||||||
mcp_servers=config.tools.mcp_servers,
|
mcp_servers=agent_plugin_mcp_servers(config.workspace_path, config.tools.mcp_servers),
|
||||||
channels_config=config.channels,
|
channels_config=config.channels,
|
||||||
timezone=defaults.timezone,
|
timezone=defaults.timezone,
|
||||||
unified_session=defaults.unified_session,
|
unified_session=defaults.unified_session,
|
||||||
@@ -644,6 +647,10 @@ class AgentLoop:
|
|||||||
"""Connect configured MCP servers."""
|
"""Connect configured MCP servers."""
|
||||||
await agent_context.connect_mcp(self, self.tools)
|
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(
|
def register_runtime_context_provider(
|
||||||
self,
|
self,
|
||||||
provider: RuntimeContextProvider,
|
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?",
|
r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?",
|
||||||
re.DOTALL,
|
re.DOTALL,
|
||||||
)
|
)
|
||||||
|
_SKILL_NAME = re.compile(r"^(?!.*--)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
|
||||||
_SKILL_REFERENCE = re.compile(r"(?<![\w$])\$([A-Za-z0-9_-]+)")
|
_SKILL_REFERENCE = re.compile(r"(?<![\w$])\$([A-Za-z0-9_-]+)")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_skill_metadata(content: str) -> dict[str, object] | None:
|
||||||
|
"""Parse a skill document's YAML frontmatter."""
|
||||||
|
if not (match := _STRIP_SKILL_FRONTMATTER.match(content)):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = yaml.safe_load(match.group(1))
|
||||||
|
except yaml.YAMLError:
|
||||||
|
return None
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
return None
|
||||||
|
return {str(key): value for key, value in cast(dict[object, object], parsed).items()}
|
||||||
|
|
||||||
|
|
||||||
|
def valid_skill_metadata(metadata: dict[str, object], name: str) -> bool:
|
||||||
|
"""Return whether metadata satisfies the Agent Skills identity contract."""
|
||||||
|
description = metadata.get("description")
|
||||||
|
return (
|
||||||
|
metadata.get("name") == name
|
||||||
|
and len(name) <= 64
|
||||||
|
and _SKILL_NAME.fullmatch(name) is not None
|
||||||
|
and isinstance(description, str)
|
||||||
|
and 1 <= len(description.strip()) <= 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SkillsLoader:
|
class SkillsLoader:
|
||||||
"""
|
"""
|
||||||
Loader for agent skills.
|
Loader for agent skills.
|
||||||
@@ -34,6 +60,15 @@ class SkillsLoader:
|
|||||||
self.builtin_skills = builtin_skills_dir or BUILTIN_SKILLS_DIR
|
self.builtin_skills = builtin_skills_dir or BUILTIN_SKILLS_DIR
|
||||||
self.disabled_skills = disabled_skills or set()
|
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]]:
|
def _skill_entries_from_dir(self, base: Path, source: str, *, skip_names: set[str] | None = None) -> list[dict[str, str]]:
|
||||||
if not base.exists():
|
if not base.exists():
|
||||||
return []
|
return []
|
||||||
@@ -60,15 +95,33 @@ class SkillsLoader:
|
|||||||
Returns:
|
Returns:
|
||||||
List of skill info dicts with 'name', 'path', 'source'.
|
List of skill info dicts with 'name', 'path', 'source'.
|
||||||
"""
|
"""
|
||||||
|
from nanobot.agent.plugins import enabled_agent_plugin_skills
|
||||||
|
|
||||||
|
plugin_skills = enabled_agent_plugin_skills(self.workspace)
|
||||||
skills = self._skill_entries_from_dir(self.workspace_skills, "workspace")
|
skills = self._skill_entries_from_dir(self.workspace_skills, "workspace")
|
||||||
workspace_names = {entry["name"] for entry in skills}
|
seen_names = {entry["name"] for entry in skills}
|
||||||
|
for name, path in plugin_skills:
|
||||||
|
if name in seen_names:
|
||||||
|
continue
|
||||||
|
skills.append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"path": str(path),
|
||||||
|
"source": "plugin",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
seen_names.add(name)
|
||||||
if self.builtin_skills and self.builtin_skills.exists():
|
if self.builtin_skills and self.builtin_skills.exists():
|
||||||
skills.extend(
|
skills.extend(
|
||||||
self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=workspace_names)
|
self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=seen_names)
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.disabled_skills:
|
if self.disabled_skills:
|
||||||
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:
|
if filter_unavailable:
|
||||||
return [skill for skill in skills if self._check_requirements(self._get_skill_meta(skill["name"]))]
|
return [skill for skill in skills if self._check_requirements(self._get_skill_meta(skill["name"]))]
|
||||||
@@ -84,14 +137,11 @@ class SkillsLoader:
|
|||||||
Returns:
|
Returns:
|
||||||
Skill content or None if not found.
|
Skill content or None if not found.
|
||||||
"""
|
"""
|
||||||
roots = [self.workspace_skills]
|
skills = self.list_skills(filter_unavailable=False)
|
||||||
if self.builtin_skills:
|
available = {skill["name"] for skill in skills}
|
||||||
roots.append(self.builtin_skills)
|
resolved = name if name in available else self._skill_aliases().get(name, name)
|
||||||
for root in roots:
|
entry = next((skill for skill in skills if skill["name"] == resolved), None)
|
||||||
path = root / name / "SKILL.md"
|
return Path(entry["path"]).read_text(encoding="utf-8") if entry else None
|
||||||
if path.exists():
|
|
||||||
return path.read_text(encoding="utf-8")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def load_skills_for_context(self, skill_names: list[str]) -> str:
|
def load_skills_for_context(self, skill_names: list[str]) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -118,9 +168,11 @@ class SkillsLoader:
|
|||||||
entry["name"]
|
entry["name"]
|
||||||
for entry in self.list_skills(filter_unavailable=True)
|
for entry in self.list_skills(filter_unavailable=True)
|
||||||
}
|
}
|
||||||
|
aliases = self._skill_aliases()
|
||||||
invoked: list[str] = []
|
invoked: list[str] = []
|
||||||
for match in _SKILL_REFERENCE.finditer(text):
|
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:
|
if name in available and name not in invoked:
|
||||||
invoked.append(name)
|
invoked.append(name)
|
||||||
return invoked
|
return invoked
|
||||||
@@ -145,6 +197,7 @@ class SkillsLoader:
|
|||||||
sections: list[str] = []
|
sections: list[str] = []
|
||||||
groups = (
|
groups = (
|
||||||
("Workspace skills", "workspace", self.workspace_skills),
|
("Workspace skills", "workspace", self.workspace_skills),
|
||||||
|
("Agent Plugin skills", "plugin", self.workspace / "plugins"),
|
||||||
("Built-in skills", "builtin", self.builtin_skills),
|
("Built-in skills", "builtin", self.builtin_skills),
|
||||||
)
|
)
|
||||||
for label, source, root in groups:
|
for label, source, root in groups:
|
||||||
@@ -278,21 +331,4 @@ class SkillsLoader:
|
|||||||
Returns:
|
Returns:
|
||||||
Metadata dict or None.
|
Metadata dict or None.
|
||||||
"""
|
"""
|
||||||
content = self.load_skill(name)
|
return parse_skill_metadata(self.load_skill(name) or "")
|
||||||
if not content or not content.startswith("---"):
|
|
||||||
return None
|
|
||||||
match = _STRIP_SKILL_FRONTMATTER.match(content)
|
|
||||||
if not match:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
parsed = yaml.safe_load(match.group(1))
|
|
||||||
except yaml.YAMLError:
|
|
||||||
return None
|
|
||||||
if not isinstance(parsed, dict):
|
|
||||||
return None
|
|
||||||
# yaml.safe_load returns native types (int, bool, list, etc.);
|
|
||||||
# keep values as-is so downstream consumers get correct types.
|
|
||||||
metadata: dict[str, object] = {}
|
|
||||||
for key, value in cast(dict[object, object], parsed).items():
|
|
||||||
metadata[str(key)] = value
|
|
||||||
return metadata
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Base class for agent tools."""
|
"""Base class for agent tools."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
import typing
|
import typing
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
@@ -67,6 +68,8 @@ class Schema(ABC):
|
|||||||
return [f"{label} should be number"]
|
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]):
|
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}"]
|
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] = []
|
errors: list[str] = []
|
||||||
if "enum" in schema and val not in schema["enum"]:
|
if "enum" in schema and val not in schema["enum"]:
|
||||||
|
|||||||
@@ -148,9 +148,19 @@ class _FsTool(Tool):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _resolve_read(self, path: str) -> Path:
|
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(
|
return self._resolve_with_extra(
|
||||||
path,
|
path,
|
||||||
self._extra_read_allowed_dirs,
|
[*self._extra_read_allowed_dirs, *plugin_skill_dirs],
|
||||||
self._extra_read_allowed_files,
|
self._extra_read_allowed_files,
|
||||||
include_media_dir=True,
|
include_media_dir=True,
|
||||||
extra_files_require_allowed_root=True,
|
extra_files_require_allowed_root=True,
|
||||||
|
|||||||
+101
-3
@@ -9,7 +9,7 @@ import shutil
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||||
from contextlib import AsyncExitStack, suppress
|
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
|
from weakref import WeakKeyDictionary
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -62,6 +62,10 @@ _WINDOWS_SHELL_LAUNCHERS: frozenset[str] = frozenset(("npx", "npm", "pnpm", "yar
|
|||||||
_SANITIZE_RE = re.compile(r"_+")
|
_SANITIZE_RE = re.compile(r"_+")
|
||||||
_RELOAD_LOCKS: WeakKeyDictionary[Any, asyncio.Lock] = WeakKeyDictionary()
|
_RELOAD_LOCKS: WeakKeyDictionary[Any, asyncio.Lock] = WeakKeyDictionary()
|
||||||
_ReconnectCallback = Callable[[str, str, Tool], Awaitable[Tool | None]]
|
_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):
|
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 {}
|
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:
|
async def connect_missing_servers(state: Any, registry: ToolRegistry) -> None:
|
||||||
"""Connect configured MCP servers that are not currently live."""
|
"""Connect configured MCP servers that are not currently live."""
|
||||||
async with _reload_lock(state):
|
async with _reload_lock(state):
|
||||||
if getattr(state, "_mcp_closing", False):
|
if getattr(state, "_mcp_closing", False):
|
||||||
return
|
return
|
||||||
missing_servers = {
|
configured_missing = {
|
||||||
name: cfg for name, cfg in state._mcp_servers.items() if name not in state._mcp_stacks
|
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:
|
if state._mcp_connecting or not missing_servers:
|
||||||
return
|
return
|
||||||
state._mcp_connecting = True
|
state._mcp_connecting = True
|
||||||
|
_set_runtime_status(state, missing_servers, "connecting")
|
||||||
try:
|
try:
|
||||||
connected = await connect_mcp_servers(missing_servers, registry)
|
connected = await connect_mcp_servers(missing_servers, registry)
|
||||||
if getattr(state, "_mcp_closing", False):
|
if getattr(state, "_mcp_closing", False):
|
||||||
@@ -1315,6 +1394,7 @@ async def connect_missing_servers(state: Any, registry: ToolRegistry) -> None:
|
|||||||
await connection.aclose()
|
await connection.aclose()
|
||||||
return
|
return
|
||||||
state._mcp_stacks.update(connected)
|
state._mcp_stacks.update(connected)
|
||||||
|
_record_connection_result(state, missing_servers, connected)
|
||||||
_attach_reconnect_handlers(state, registry, connected)
|
_attach_reconnect_handlers(state, registry, connected)
|
||||||
if connected:
|
if connected:
|
||||||
logger.info("MCP connected servers: {}", sorted(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:
|
except asyncio.CancelledError:
|
||||||
if task_is_cancelling():
|
if task_is_cancelling():
|
||||||
raise
|
raise
|
||||||
|
_set_runtime_status(state, missing_servers, "failed")
|
||||||
logger.warning("MCP connection cancelled (will retry next message)")
|
logger.warning("MCP connection cancelled (will retry next message)")
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
|
_set_runtime_status(state, missing_servers, "failed")
|
||||||
logger.warning("Failed to connect MCP servers (will retry next message): {}", e)
|
logger.warning("Failed to connect MCP servers (will retry next message): {}", e)
|
||||||
finally:
|
finally:
|
||||||
state._mcp_connecting = False
|
state._mcp_connecting = False
|
||||||
@@ -1340,10 +1422,14 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
|||||||
"requires_restart": True,
|
"requires_restart": True,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
|
from nanobot.agent.plugins import agent_plugin_mcp_servers
|
||||||
from nanobot.config.loader import load_config, resolve_config_env_vars
|
from nanobot.config.loader import load_config, resolve_config_env_vars
|
||||||
|
|
||||||
config = resolve_config_env_vars(load_config())
|
config = resolve_config_env_vars(load_config())
|
||||||
next_servers = dict(config.tools.mcp_servers)
|
next_servers = agent_plugin_mcp_servers(
|
||||||
|
config.workspace_path,
|
||||||
|
config.tools.mcp_servers,
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("MCP hot reload could not read config: {}", exc)
|
logger.warning("MCP hot reload could not read config: {}", exc)
|
||||||
return {
|
return {
|
||||||
@@ -1376,6 +1462,11 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
|||||||
tools_removed += _unregister_server_tools(registry, name)
|
tools_removed += _unregister_server_tools(registry, name)
|
||||||
await _close_server(state, 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
|
state._mcp_servers = next_servers
|
||||||
retry_missing = sorted(
|
retry_missing = sorted(
|
||||||
name
|
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}
|
to_connect = {name: next_servers[name] for name in to_connect_names}
|
||||||
connected: dict[str, MCPConnection] = {}
|
connected: dict[str, MCPConnection] = {}
|
||||||
if to_connect:
|
if to_connect:
|
||||||
|
_set_runtime_status(state, to_connect, "connecting")
|
||||||
connected = await connect_mcp_servers(to_connect, registry)
|
connected = await connect_mcp_servers(to_connect, registry)
|
||||||
if getattr(state, "_mcp_closing", False):
|
if getattr(state, "_mcp_closing", False):
|
||||||
for connection in connected.values():
|
for connection in connected.values():
|
||||||
@@ -1400,6 +1492,7 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
|||||||
"requires_restart": True,
|
"requires_restart": True,
|
||||||
}
|
}
|
||||||
state._mcp_stacks.update(connected)
|
state._mcp_stacks.update(connected)
|
||||||
|
_record_connection_result(state, to_connect, connected)
|
||||||
_attach_reconnect_handlers(state, registry, connected)
|
_attach_reconnect_handlers(state, registry, connected)
|
||||||
|
|
||||||
failed = sorted(set(to_connect) - set(connected))
|
failed = sorted(set(to_connect) - set(connected))
|
||||||
@@ -1558,12 +1651,14 @@ async def _refresh_terminated_server(
|
|||||||
_unregister_server_tools(registry, server_name)
|
_unregister_server_tools(registry, server_name)
|
||||||
await _close_server(state, server_name)
|
await _close_server(state, server_name)
|
||||||
|
|
||||||
|
_set_runtime_status(state, {server_name}, "connecting")
|
||||||
connected = await connect_mcp_servers({server_name: cfg}, registry)
|
connected = await connect_mcp_servers({server_name: cfg}, registry)
|
||||||
if getattr(state, "_mcp_closing", False):
|
if getattr(state, "_mcp_closing", False):
|
||||||
for connection in connected.values():
|
for connection in connected.values():
|
||||||
await connection.aclose()
|
await connection.aclose()
|
||||||
return None
|
return None
|
||||||
state._mcp_stacks.update(connected)
|
state._mcp_stacks.update(connected)
|
||||||
|
_record_connection_result(state, {server_name}, connected)
|
||||||
_attach_reconnect_handlers(state, registry, connected)
|
_attach_reconnect_handlers(state, registry, connected)
|
||||||
if server_name not in connected:
|
if server_name not in connected:
|
||||||
logger.warning("MCP server '{}' reconnect failed after session termination", server_name)
|
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):
|
async with _reload_lock(state):
|
||||||
connections = list(state._mcp_stacks.items())
|
connections = list(state._mcp_stacks.items())
|
||||||
state._mcp_stacks.clear()
|
state._mcp_stacks.clear()
|
||||||
|
statuses = _runtime_status_store(state)
|
||||||
|
if statuses is not None:
|
||||||
|
statuses.clear()
|
||||||
for name, connection in connections:
|
for name, connection in connections:
|
||||||
try:
|
try:
|
||||||
await connection.aclose()
|
await connection.aclose()
|
||||||
|
|||||||
+131
-23
@@ -20,6 +20,7 @@ from urllib.parse import urlparse
|
|||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from nanobot.agent.skills import parse_skill_metadata, valid_skill_metadata
|
||||||
from nanobot.apps.protocol import app_manifest, compact_dict
|
from nanobot.apps.protocol import app_manifest, compact_dict
|
||||||
from nanobot.config.paths import get_runtime_subdir
|
from nanobot.config.paths import get_runtime_subdir
|
||||||
from nanobot.security.workspace_policy import is_path_within
|
from nanobot.security.workspace_policy import is_path_within
|
||||||
@@ -27,6 +28,7 @@ from nanobot.security.workspace_policy import is_path_within
|
|||||||
CLI_ANYTHING_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/registry.json"
|
CLI_ANYTHING_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/registry.json"
|
||||||
CLI_ANYTHING_PUBLIC_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/public_registry.json"
|
CLI_ANYTHING_PUBLIC_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/public_registry.json"
|
||||||
CLI_ANYTHING_RAW_BASE = "https://raw.githubusercontent.com/HKUDS/CLI-Anything/main"
|
CLI_ANYTHING_RAW_BASE = "https://raw.githubusercontent.com/HKUDS/CLI-Anything/main"
|
||||||
|
AGENT_PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"
|
||||||
NANOBOT_EXTENSION_REGISTRY_URL = "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main/registry.json"
|
NANOBOT_EXTENSION_REGISTRY_URL = "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main/registry.json"
|
||||||
NANOBOT_EXTENSION_RAW_BASE = "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main"
|
NANOBOT_EXTENSION_RAW_BASE = "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main"
|
||||||
_CATALOG_SOURCES = (
|
_CATALOG_SOURCES = (
|
||||||
@@ -102,7 +104,6 @@ _BRANDS: dict[str, tuple[str, str]] = {
|
|||||||
"audacity": ("audacity", "#0000CC"),
|
"audacity": ("audacity", "#0000CC"),
|
||||||
"blender": ("blender", "#E87D0D"),
|
"blender": ("blender", "#E87D0D"),
|
||||||
"browser": ("googlechrome", "#4285F4"),
|
"browser": ("googlechrome", "#4285F4"),
|
||||||
"calibre": ("calibre", "#45B29D"),
|
|
||||||
"chromadb": ("chroma", "#FFDE2D"),
|
"chromadb": ("chroma", "#FFDE2D"),
|
||||||
"comfyui": ("comfyui", "#111827"),
|
"comfyui": ("comfyui", "#111827"),
|
||||||
"contentful": ("contentful", "#2478CC"),
|
"contentful": ("contentful", "#2478CC"),
|
||||||
@@ -156,6 +157,7 @@ _BRANDS: dict[str, tuple[str, str]] = {
|
|||||||
_BRAND_DOMAINS: dict[str, tuple[str, str]] = {
|
_BRAND_DOMAINS: dict[str, tuple[str, str]] = {
|
||||||
"3mf": ("3mf.io", "#00A1DE"),
|
"3mf": ("3mf.io", "#00A1DE"),
|
||||||
"anygen": ("anygen.io", "#111827"),
|
"anygen": ("anygen.io", "#111827"),
|
||||||
|
"calibre": ("calibre-ebook.com", "#45B29D"),
|
||||||
"clibrowser": ("github.com/allthingssecurity/clibrowser", "#24292F"),
|
"clibrowser": ("github.com/allthingssecurity/clibrowser", "#24292F"),
|
||||||
"cloudanalyzer": ("github.com/rsasaki0109/CloudAnalyzer", "#2563EB"),
|
"cloudanalyzer": ("github.com/rsasaki0109/CloudAnalyzer", "#2563EB"),
|
||||||
"cloudcompare": ("cloudcompare.org", "#4D83C3"),
|
"cloudcompare": ("cloudcompare.org", "#4D83C3"),
|
||||||
@@ -199,6 +201,13 @@ _BRAND_ALIASES: dict[str, str] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_BRAND_TRAILING_WORDS = ("cli", "workflow", "workflows", "app", "apps", "tool", "tools")
|
_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:
|
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
|
return cast(dict[str, Any], value) if isinstance(value, dict) else None
|
||||||
|
|
||||||
|
|
||||||
def _safe_skill_name(name: str) -> str:
|
def _skill_name(name: str, *, legacy: bool = False) -> str:
|
||||||
clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-")
|
clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-")
|
||||||
|
if not legacy:
|
||||||
|
clean = clean.replace("_", "-")
|
||||||
return f"cli-app-{clean or 'app'}"
|
return f"cli-app-{clean or 'app'}"
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin_skill_relative_path(name: str) -> str:
|
||||||
|
skill_name = _skill_name(name)
|
||||||
|
return f"plugins/{skill_name}/skills/{skill_name}/SKILL.md"
|
||||||
|
|
||||||
|
|
||||||
|
def cli_app_skill_relative_path(workspace: Path, name: str) -> str:
|
||||||
|
"""Return a CLI App's skill path, including the legacy location."""
|
||||||
|
canonical = _plugin_skill_relative_path(name)
|
||||||
|
legacy = f"skills/{_skill_name(name, legacy=True)}/SKILL.md"
|
||||||
|
if not (workspace / canonical).is_file() and (workspace / legacy).is_file():
|
||||||
|
return legacy
|
||||||
|
return canonical
|
||||||
|
|
||||||
|
|
||||||
def _has_shell_meta(command: str) -> bool:
|
def _has_shell_meta(command: str) -> bool:
|
||||||
return any(char in command for char in _SHELL_META_CHARS)
|
return any(char in command for char in _SHELL_META_CHARS)
|
||||||
|
|
||||||
@@ -315,11 +340,25 @@ def _brand_candidates(app: dict[str, Any]) -> list[str]:
|
|||||||
return candidates
|
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]:
|
def _brand_payload(app: dict[str, Any]) -> tuple[str | None, str | None]:
|
||||||
declared_logo = str(app.get("logo_url") or "").strip()
|
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://", "/")):
|
if declared_logo.startswith(("https://", "/")):
|
||||||
declared_color = str(app.get("brand_color") or "").strip()
|
return declared_logo, declared_color
|
||||||
return declared_logo, declared_color or None
|
|
||||||
|
|
||||||
brand = None
|
brand = None
|
||||||
domain_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)
|
domain_brand = _BRAND_DOMAINS.get(key)
|
||||||
if domain_brand:
|
if domain_brand:
|
||||||
break
|
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 brand:
|
||||||
if not domain_brand:
|
if not domain_brand:
|
||||||
return None, None
|
return None, None
|
||||||
domain, color = domain_brand
|
domain, color = domain_brand
|
||||||
return f"https://www.google.com/s2/favicons?domain={domain}&sz=64", color
|
return f"https://www.google.com/s2/favicons?domain={domain}&sz=64", color
|
||||||
slug, color = brand
|
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:
|
def _read_json(path: Path) -> dict[str, Any] | None:
|
||||||
@@ -442,6 +489,16 @@ class CliAppManager:
|
|||||||
"""Return registry names explicitly installed through CLI Apps."""
|
"""Return registry names explicitly installed through CLI Apps."""
|
||||||
return sorted(str(name) for name in self._load_installed())
|
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(
|
def _fetch_registry(
|
||||||
self,
|
self,
|
||||||
url: str,
|
url: str,
|
||||||
@@ -613,7 +670,7 @@ class CliAppManager:
|
|||||||
"name": installed_name,
|
"name": installed_name,
|
||||||
"entry_point": entry_point,
|
"entry_point": entry_point,
|
||||||
"source": str(data.get("source") or ""),
|
"source": str(data.get("source") or ""),
|
||||||
"skill": f"skills/{_safe_skill_name(installed_name)}/SKILL.md",
|
"skill": cli_app_skill_relative_path(self.workspace, installed_name),
|
||||||
"tool": "run_cli_app",
|
"tool": "run_cli_app",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -639,9 +696,6 @@ class CliAppManager:
|
|||||||
install_cmd = str(app.get("install_cmd") or "")
|
install_cmd = str(app.get("install_cmd") or "")
|
||||||
return not _has_shell_meta(install_cmd)
|
return not _has_shell_meta(install_cmd)
|
||||||
|
|
||||||
def _skill_path(self, name: str) -> Path:
|
|
||||||
return self.workspace / "skills" / _safe_skill_name(name) / "SKILL.md"
|
|
||||||
|
|
||||||
def _app_payload(
|
def _app_payload(
|
||||||
self,
|
self,
|
||||||
app: dict[str, Any],
|
app: dict[str, Any],
|
||||||
@@ -677,7 +731,7 @@ class CliAppManager:
|
|||||||
"status": status,
|
"status": status,
|
||||||
"logo_url": logo_url,
|
"logo_url": logo_url,
|
||||||
"brand_color": brand_color,
|
"brand_color": brand_color,
|
||||||
"skill_installed": self._skill_path(name).is_file(),
|
"skill_installed": (self.workspace / cli_app_skill_relative_path(self.workspace, name)).is_file(),
|
||||||
"manifest": self._manifest_payload(app, logo_url=logo_url, brand_color=brand_color),
|
"manifest": self._manifest_payload(app, logo_url=logo_url, brand_color=brand_color),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -713,7 +767,8 @@ class CliAppManager:
|
|||||||
name = str(app["name"])
|
name = str(app["name"])
|
||||||
entry_point = str(app.get("entry_point") or "")
|
entry_point = str(app.get("entry_point") or "")
|
||||||
strategy = self._strategy(app)
|
strategy = self._strategy(app)
|
||||||
skill_path = f"skills/{_safe_skill_name(name)}/SKILL.md"
|
skill_path = _plugin_skill_relative_path(name)
|
||||||
|
plugin_path = f"plugins/{_skill_name(name)}"
|
||||||
capabilities = [
|
capabilities = [
|
||||||
compact_dict({
|
compact_dict({
|
||||||
"type": "cli",
|
"type": "cli",
|
||||||
@@ -726,13 +781,13 @@ class CliAppManager:
|
|||||||
install = compact_dict({
|
install = compact_dict({
|
||||||
"supported": install_supported,
|
"supported": install_supported,
|
||||||
"strategy": strategy,
|
"strategy": strategy,
|
||||||
"managed_paths": [skill_path],
|
"managed_paths": [plugin_path],
|
||||||
"verification": ["entry_point_available"] if entry_point else [],
|
"verification": ["entry_point_available"] if entry_point else [],
|
||||||
})
|
})
|
||||||
remove = compact_dict({
|
remove = compact_dict({
|
||||||
"supported": strategy != "unsupported",
|
"supported": strategy != "unsupported",
|
||||||
"strategy": strategy,
|
"strategy": strategy,
|
||||||
"managed_paths": [skill_path],
|
"managed_paths": [plugin_path],
|
||||||
"verification": (
|
"verification": (
|
||||||
["package_manager_ok", "entry_point_absent", "managed_paths_absent"]
|
["package_manager_ok", "entry_point_absent", "managed_paths_absent"]
|
||||||
if strategy not in {"bundled", "unsupported"}
|
if strategy not in {"bundled", "unsupported"}
|
||||||
@@ -964,6 +1019,35 @@ class CliAppManager:
|
|||||||
return None
|
return None
|
||||||
raise CliAppError("this CLI app uses an unsupported install strategy")
|
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]:
|
def _run_argv(self, argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
|
||||||
command = subprocess.list2cmdline(argv)
|
command = subprocess.list2cmdline(argv)
|
||||||
logger.info("CLI Apps: running {}", command)
|
logger.info("CLI Apps: running {}", command)
|
||||||
@@ -1032,11 +1116,10 @@ class CliAppManager:
|
|||||||
name = str(app.get("name") or "unknown")
|
name = str(app.get("name") or "unknown")
|
||||||
display = str(app.get("display_name") or name)
|
display = str(app.get("display_name") or name)
|
||||||
entry = str(app.get("entry_point") or f"cli-anything-{name}")
|
entry = str(app.get("entry_point") or f"cli-anything-{name}")
|
||||||
description = _catalog_description(app) or f"Use {display} from nanobot."
|
description = (_catalog_description(app) or f"Use {display} from nanobot.")[:1024]
|
||||||
return f"""---
|
return f"""---
|
||||||
name: {_safe_skill_name(name)}
|
name: {_skill_name(name)}
|
||||||
description: >-
|
description: {json.dumps(description, ensure_ascii=False)}
|
||||||
{description}
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# {display}
|
# {display}
|
||||||
@@ -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:
|
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 -->"
|
marker = "<!-- nanobot-cli-app-note -->"
|
||||||
if marker in content:
|
if marker in content:
|
||||||
return content
|
return content
|
||||||
name = str(app.get("name") or "unknown")
|
|
||||||
note = f"""{marker}
|
note = f"""{marker}
|
||||||
## Nanobot execution
|
## 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
|
return note + "\n" + content
|
||||||
|
|
||||||
def install_skill(self, app: dict[str, Any]) -> Path:
|
def install_skill(self, app: dict[str, Any]) -> Path:
|
||||||
path = self._skill_path(str(app["name"]))
|
name = str(app["name"])
|
||||||
|
path = self.workspace / _plugin_skill_relative_path(name)
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
content = self._fetch_skill_content(app) or self._fallback_skill(app)
|
content = self._fetch_skill_content(app) or self._fallback_skill(app)
|
||||||
content = self._with_nanobot_skill_note(content, app)
|
content = self._with_nanobot_skill_note(content, app)
|
||||||
path.write_text(content, encoding="utf-8")
|
path.write_text(content, encoding="utf-8")
|
||||||
|
plugin_root = path.parents[2]
|
||||||
|
manifest = compact_dict({
|
||||||
|
"$schema": AGENT_PLUGIN_SCHEMA,
|
||||||
|
"name": _skill_name(str(app["name"])),
|
||||||
|
"version": str(app.get("version") or ""),
|
||||||
|
"description": _catalog_description(app),
|
||||||
|
})
|
||||||
|
_write_json(plugin_root / "plugin.json", manifest)
|
||||||
|
legacy_dir = self.workspace / "skills" / _skill_name(str(app["name"]), legacy=True)
|
||||||
|
if legacy_dir.is_dir():
|
||||||
|
shutil.rmtree(legacy_dir)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
def remove_skill(self, name: str) -> None:
|
def remove_skill(self, name: str) -> None:
|
||||||
skill_dir = self._skill_path(name).parent
|
plugin_root = (self.workspace / _plugin_skill_relative_path(name)).parents[2]
|
||||||
if skill_dir.is_dir():
|
if plugin_root.is_dir():
|
||||||
shutil.rmtree(skill_dir)
|
shutil.rmtree(plugin_root)
|
||||||
|
legacy_dir = self.workspace / "skills" / _skill_name(name, legacy=True)
|
||||||
|
if legacy_dir.is_dir():
|
||||||
|
shutil.rmtree(legacy_dir)
|
||||||
|
|
||||||
def _record_installed(self, app: dict[str, Any]) -> dict[str, Any]:
|
def _record_installed(self, app: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
from nanobot.agent.plugins import set_agent_plugin_enabled
|
||||||
|
|
||||||
installed = self._load_installed()
|
installed = self._load_installed()
|
||||||
entry = self._installed_entry(app)
|
entry = self._installed_entry(app)
|
||||||
installed[str(app["name"])] = entry
|
installed[str(app["name"])] = entry
|
||||||
self._save_installed(installed)
|
self._save_installed(installed)
|
||||||
self.install_skill(app)
|
self.install_skill(app)
|
||||||
|
set_agent_plugin_enabled(self.workspace, _skill_name(str(app["name"])), True)
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
def install(self, name: str) -> dict[str, Any]:
|
def install(self, name: str) -> dict[str, Any]:
|
||||||
@@ -1381,7 +1489,7 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
|
|||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
errors="replace",
|
errors="replace",
|
||||||
timeout=effective_timeout,
|
timeout=effective_timeout,
|
||||||
env=os.environ.copy(),
|
env=self._subprocess_env(),
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return f"CLI app '{name}' timed out after {effective_timeout}s"
|
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."""
|
"""Return CLI App annotations from an immutable request snapshot."""
|
||||||
structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
|
structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
|
||||||
if isinstance(structured, list):
|
if isinstance(structured, list):
|
||||||
|
from nanobot.apps.cli.service import cli_app_skill_relative_path
|
||||||
|
|
||||||
structured_items = cast(list[Any], structured)
|
structured_items = cast(list[Any], structured)
|
||||||
mentions = [
|
mentions = [
|
||||||
cast(Mapping[str, Any], item) for item in structured_items
|
cast(Mapping[str, Any], item) for item in structured_items
|
||||||
@@ -32,7 +34,7 @@ def runtime_lines_for_request(
|
|||||||
f"@{str(item['name']).strip().lower()} "
|
f"@{str(item['name']).strip().lower()} "
|
||||||
f"(installed; tool=run_cli_app; "
|
f"(installed; tool=run_cli_app; "
|
||||||
f"entry_point={str(item.get('entry_point') or 'unknown')}; "
|
f"entry_point={str(item.get('entry_point') or 'unknown')}; "
|
||||||
f"skill=skills/cli-app-{str(item['name']).strip().lower()}/SKILL.md). "
|
f"skill={cli_app_skill_relative_path(workspace, str(item['name']))}). "
|
||||||
"Read the skill when useful, then run this app with `run_cli_app`; do not bypass it with shell."
|
"Read the skill when useful, then run this app with `run_cli_app`; do not bypass it with shell."
|
||||||
for item in mentions
|
for item in mentions
|
||||||
if str(item.get("name") or "").strip()
|
if str(item.get("name") or "").strip()
|
||||||
|
|||||||
@@ -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 asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import inspect
|
import inspect
|
||||||
from collections.abc import Callable, Iterable
|
from collections.abc import Callable, Iterable, Mapping
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
@@ -100,6 +100,7 @@ class ChannelManager:
|
|||||||
webui_static_dist: bool = True,
|
webui_static_dist: bool = True,
|
||||||
webui_runtime_surface: str = "browser",
|
webui_runtime_surface: str = "browser",
|
||||||
webui_runtime_capabilities: dict[str, Any] | None = None,
|
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,
|
webui_skill_state_action: Callable[[set[str]], None] | None = None,
|
||||||
config_path: Path | None = None,
|
config_path: Path | None = None,
|
||||||
):
|
):
|
||||||
@@ -119,6 +120,7 @@ class ChannelManager:
|
|||||||
self._webui_static_dist = webui_static_dist
|
self._webui_static_dist = webui_static_dist
|
||||||
self._webui_runtime_surface = webui_runtime_surface
|
self._webui_runtime_surface = webui_runtime_surface
|
||||||
self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {})
|
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._webui_skill_state_action = webui_skill_state_action
|
||||||
self.channels: dict[str, BaseChannel] = {}
|
self.channels: dict[str, BaseChannel] = {}
|
||||||
self._channel_owners: dict[str, str] = {}
|
self._channel_owners: dict[str, str] = {}
|
||||||
@@ -187,6 +189,7 @@ class ChannelManager:
|
|||||||
local_trigger_pending_ids=self._webui_local_trigger_pending_ids,
|
local_trigger_pending_ids=self._webui_local_trigger_pending_ids,
|
||||||
channel_feature_action=self.apply_channel_feature_action,
|
channel_feature_action=self.apply_channel_feature_action,
|
||||||
channel_runtime_status=self.get_status,
|
channel_runtime_status=self.get_status,
|
||||||
|
mcp_runtime_status=self._webui_mcp_runtime_status,
|
||||||
skill_state_action=self._webui_skill_state_action,
|
skill_state_action=self._webui_skill_state_action,
|
||||||
logger=logger,
|
logger=logger,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -968,6 +968,11 @@ class MatrixChannel(BaseChannel):
|
|||||||
meta["thread_reply_to_event_id"] = reply_to
|
meta["thread_reply_to_event_id"] = reply_to
|
||||||
return meta
|
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
|
@staticmethod
|
||||||
def _build_thread_relates_to(metadata: dict[str, Any] | None) -> dict[str, Any] | None:
|
def _build_thread_relates_to(metadata: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||||
if not metadata:
|
if not metadata:
|
||||||
@@ -1171,6 +1176,7 @@ class MatrixChannel(BaseChannel):
|
|||||||
await self._handle_message(
|
await self._handle_message(
|
||||||
sender_id=event.sender, chat_id=room.room_id,
|
sender_id=event.sender, chat_id=room.room_id,
|
||||||
content=event.body, metadata=self._base_metadata(room, event),
|
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),
|
is_dm=self._is_direct_room(room),
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -1209,6 +1215,7 @@ class MatrixChannel(BaseChannel):
|
|||||||
content="\n".join(parts),
|
content="\n".join(parts),
|
||||||
media=[attachment["path"]] if attachment else [],
|
media=[attachment["path"]] if attachment else [],
|
||||||
metadata=meta,
|
metadata=meta,
|
||||||
|
session_key=self._thread_session_key(room.room_id, event),
|
||||||
is_dm=self._is_direct_room(room),
|
is_dm=self._is_direct_room(room),
|
||||||
)
|
)
|
||||||
except Exception:
|
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_root_event_id"] == "$root1"
|
||||||
assert metadata["thread_reply_to_event_id"] == "$reply1"
|
assert metadata["thread_reply_to_event_id"] == "$reply1"
|
||||||
assert metadata["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
|
@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_root_event_id"] == "$root1"
|
||||||
assert metadata["thread_reply_to_event_id"] == "$event1"
|
assert metadata["thread_reply_to_event_id"] == "$event1"
|
||||||
assert metadata["event_id"] == "$event1"
|
assert metadata["event_id"] == "$event1"
|
||||||
|
assert handled[0]["session_key"] == "matrix:!room:matrix.org:thread:$root1"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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(
|
async def _webui_mutate(
|
||||||
client: Any,
|
client: Any,
|
||||||
action: str,
|
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)
|
channel = _ch(bus, port=port)
|
||||||
|
|
||||||
server_task = asyncio.create_task(channel.start())
|
server_task = asyncio.create_task(channel.start())
|
||||||
await asyncio.sleep(0.3)
|
|
||||||
|
|
||||||
try:
|
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_raw = await client.recv()
|
||||||
ready = json.loads(ready_raw)
|
ready = json.loads(ready_raw)
|
||||||
assert ready["event"] == "ready"
|
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["hot_reload"]["ok"] is True
|
||||||
assert body["restart_required_sections"] == []
|
assert body["restart_required_sections"] == []
|
||||||
|
|
||||||
|
disabled = await _webui_mutate(
|
||||||
|
channel,
|
||||||
|
"settings.mcp.disable",
|
||||||
|
{"name": "browserbase"},
|
||||||
|
)
|
||||||
|
assert disabled.status_code == 200
|
||||||
|
assert preset_queries[-1][0] == "disable"
|
||||||
|
|
||||||
custom = await _webui_mutate(
|
custom = await _webui_mutate(
|
||||||
channel,
|
channel,
|
||||||
"settings.mcp.custom",
|
"settings.mcp.custom",
|
||||||
|
|||||||
@@ -668,6 +668,7 @@ def _run_gateway(
|
|||||||
webui_static_dist=webui_static_dist,
|
webui_static_dist=webui_static_dist,
|
||||||
webui_runtime_surface=webui_runtime_surface,
|
webui_runtime_surface=webui_runtime_surface,
|
||||||
webui_runtime_capabilities=webui_runtime_capabilities,
|
webui_runtime_capabilities=webui_runtime_capabilities,
|
||||||
|
webui_mcp_runtime_status=agent.mcp_runtime_status,
|
||||||
webui_skill_state_action=_webui_skill_state_action,
|
webui_skill_state_action=_webui_skill_state_action,
|
||||||
config_path=Path(config_path),
|
config_path=Path(config_path),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -447,6 +447,28 @@ def _merge_unique_list(base: object, override: object) -> object:
|
|||||||
return result
|
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(
|
def _merge_responses_extra_body(
|
||||||
body: dict[str, Any],
|
body: dict[str, Any],
|
||||||
extra_body: dict[str, Any],
|
extra_body: dict[str, Any],
|
||||||
@@ -501,9 +523,6 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
self._proxy = proxy or None
|
self._proxy = proxy or None
|
||||||
self._native_compaction_available = True
|
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
|
effective_base = api_base or (spec.default_api_base if spec else None) or None
|
||||||
self._effective_base = effective_base
|
self._effective_base = effective_base
|
||||||
self._default_headers = {"x-session-affinity": uuid.uuid4().hex}
|
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")
|
raise RuntimeError("OpenAI client initialization did not produce a client")
|
||||||
return self._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
|
@classmethod
|
||||||
def _apply_cache_control(
|
def _apply_cache_control(
|
||||||
cls,
|
cls,
|
||||||
@@ -968,14 +973,11 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
if msg.get("role") == "assistant" and "reasoning_content" not in msg:
|
if msg.get("role") == "assistant" and "reasoning_content" not in msg:
|
||||||
msg["reasoning_content"] = ""
|
msg["reasoning_content"] = ""
|
||||||
|
|
||||||
# Merge user-configured extra_body last so it can override or
|
# Merge user-configured extra_body last so ordinary fields can override
|
||||||
# extend provider-specific defaults (e.g. chat_template_kwargs,
|
# provider defaults. Keep configured tools at the top level: the SDK
|
||||||
# guided_json, repetition_penalty). Uses recursive merge so
|
# otherwise lets extra_body.tools replace nanobot's generated functions.
|
||||||
# nested dicts like {"chat_template_kwargs": {"enable_thinking": false}}
|
|
||||||
# do not clobber sibling keys already set by thinking-style logic.
|
|
||||||
if self._extra_body:
|
if self._extra_body:
|
||||||
existing = kwargs.get("extra_body", {})
|
kwargs = _merge_chat_extra_body(kwargs, self._extra_body)
|
||||||
kwargs["extra_body"] = _deep_merge(existing, self._extra_body)
|
|
||||||
|
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
|
|||||||
@@ -11,23 +11,39 @@ Two free services, no API keys needed.
|
|||||||
|
|
||||||
## wttr.in (primary)
|
## 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
|
```bash
|
||||||
curl -s "wttr.in/London?format=3"
|
curl -s "https://wttr.in/London?format=3"
|
||||||
# Output: London: ⛅️ +8°C
|
# Output: London: ⛅️ +8°C
|
||||||
```
|
```
|
||||||
|
|
||||||
Compact format:
|
Custom current conditions format:
|
||||||
```bash
|
```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
|
# 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:
|
Full forecast:
|
||||||
```bash
|
```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
|
Format codes: `%c` condition · `%t` temp · `%h` humidity · `%w` wind · `%l` location · `%m` moon
|
||||||
|
|
||||||
Tips:
|
Tips:
|
||||||
@@ -35,7 +51,8 @@ Tips:
|
|||||||
- Airport codes: `wttr.in/JFK`
|
- Airport codes: `wttr.in/JFK`
|
||||||
- Units: `?m` (metric) `?u` (USCS)
|
- Units: `?m` (metric) `?u` (USCS)
|
||||||
- Today only: `?1` · Current only: `?0`
|
- 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)
|
## Open-Meteo (fallback, JSON)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Callable
|
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,
|
local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
|
||||||
channel_feature_action: Callable[..., Any] | None = None,
|
channel_feature_action: Callable[..., Any] | None = None,
|
||||||
channel_runtime_status: Callable[[], dict[str, 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,
|
skill_state_action: Callable[[set[str]], None] | None = None,
|
||||||
logger: Any = default_logger,
|
logger: Any = default_logger,
|
||||||
) -> GatewayServices:
|
) -> GatewayServices:
|
||||||
@@ -116,6 +118,7 @@ def build_gateway_services(
|
|||||||
local_trigger_pending_ids=local_trigger_pending_ids,
|
local_trigger_pending_ids=local_trigger_pending_ids,
|
||||||
channel_feature_action=channel_feature_action,
|
channel_feature_action=channel_feature_action,
|
||||||
channel_runtime_status=channel_runtime_status,
|
channel_runtime_status=channel_runtime_status,
|
||||||
|
mcp_runtime_status=mcp_runtime_status,
|
||||||
skill_state_action=skill_state_action,
|
skill_state_action=skill_state_action,
|
||||||
log=logger,
|
log=logger,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ from datetime import datetime, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Literal, Mapping, cast
|
from typing import TYPE_CHECKING, Any, Literal, Mapping, cast
|
||||||
|
|
||||||
|
from nanobot.agent.plugins import (
|
||||||
|
AgentPlugin,
|
||||||
|
discover_agent_plugins,
|
||||||
|
set_agent_plugin_enabled,
|
||||||
|
)
|
||||||
from nanobot.agent.tools.mcp_oauth import (
|
from nanobot.agent.tools.mcp_oauth import (
|
||||||
delete_mcp_oauth_credentials,
|
delete_mcp_oauth_credentials,
|
||||||
mcp_oauth_has_credentials,
|
mcp_oauth_has_credentials,
|
||||||
@@ -55,8 +60,10 @@ _MAX_TEST_TOOLS = 16
|
|||||||
_DEFAULT_TEST_TIMEOUT = 20
|
_DEFAULT_TEST_TIMEOUT = 20
|
||||||
_DEFAULT_CUSTOM_TIMEOUT = 30
|
_DEFAULT_CUSTOM_TIMEOUT = 30
|
||||||
_CUSTOM_ACTIONS = {"custom", "import", "import-cursor", "tools"}
|
_CUSTOM_ACTIONS = {"custom", "import", "import-cursor", "tools"}
|
||||||
|
_MCP_RUNTIME_STATUSES = {"connecting", "connected", "failed"}
|
||||||
|
|
||||||
McpReload = Callable[[], Awaitable[dict[str, Any]]]
|
McpReload = Callable[[], Awaitable[dict[str, Any]]]
|
||||||
|
McpRuntimeStatus = Callable[[], Mapping[str, str]]
|
||||||
|
|
||||||
|
|
||||||
class McpPresetError(Exception):
|
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(
|
def mcp_presets_payload(
|
||||||
*,
|
*,
|
||||||
last_action: dict[str, Any] | None = None,
|
last_action: dict[str, Any] | None = None,
|
||||||
tool_preview: Mapping[str, list[str]] | None = None,
|
tool_preview: Mapping[str, list[str]] | None = None,
|
||||||
|
runtime_status: Mapping[str, str] | None = None,
|
||||||
config_path: Path | None = None,
|
config_path: Path | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
config = load_config(config_path) if config_path is not None else load_config()
|
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())
|
for name, cfg in sorted(config.tools.mcp_servers.items())
|
||||||
if name not in known
|
if name not in known
|
||||||
]
|
]
|
||||||
|
existing_names = {str(row["name"]) for row in (*preset_rows, *custom_rows)}
|
||||||
|
plugin_rows = [
|
||||||
|
_agent_plugin_payload(plugin)
|
||||||
|
for plugin in discover_agent_plugins(config.workspace_path)
|
||||||
|
if f"plugin-{plugin.name}" not in existing_names
|
||||||
|
]
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"presets": [*preset_rows, *custom_rows],
|
"presets": [*preset_rows, *custom_rows, *plugin_rows],
|
||||||
"installed_count": len(config.tools.mcp_servers),
|
"installed_count": len(config.tools.mcp_servers)
|
||||||
|
+ sum(int(row["enabled"]) for row in plugin_rows),
|
||||||
}
|
}
|
||||||
if last_action is not None:
|
if last_action is not None:
|
||||||
payload["last_action"] = last_action
|
payload["last_action"] = last_action
|
||||||
|
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
|
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:
|
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",
|
"import-cursor": "Imported",
|
||||||
"tools": "Updated tools for",
|
"tools": "Updated tools for",
|
||||||
"remove": "Removed",
|
"remove": "Removed",
|
||||||
|
"reconnect": "Retried connection for",
|
||||||
}.get(action, "Updated")
|
}.get(action, "Updated")
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"ok": ok,
|
"ok": ok,
|
||||||
@@ -982,6 +1053,24 @@ def _server_action_message(action: str, name: str, *, ok: bool = True) -> dict[s
|
|||||||
return payload
|
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:
|
def _scrub_test_error(text: str) -> str:
|
||||||
scrubbed = _SECRET_QUERY_RE.sub(r"\1<redacted>", text.strip())
|
scrubbed = _SECRET_QUERY_RE.sub(r"\1<redacted>", text.strip())
|
||||||
scrubbed = _SECRET_ASSIGNMENT_RE.sub(r"\1<redacted>", scrubbed)
|
scrubbed = _SECRET_ASSIGNMENT_RE.sub(r"\1<redacted>", scrubbed)
|
||||||
@@ -1536,15 +1625,52 @@ async def mcp_presets_settings_action(
|
|||||||
query: QueryParams,
|
query: QueryParams,
|
||||||
*,
|
*,
|
||||||
reload_mcp: McpReload | None = None,
|
reload_mcp: McpReload | None = None,
|
||||||
|
mcp_runtime_status: McpRuntimeStatus | None = None,
|
||||||
config: WebUISettingsConfig | None = None,
|
config: WebUISettingsConfig | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Run a WebUI MCP preset action and hot-reload the agent when config changes."""
|
"""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
|
config_path = config.path if config is not None else None
|
||||||
if action is None:
|
if action is None:
|
||||||
return mcp_presets_payload(config_path=config_path)
|
return mcp_presets_payload(
|
||||||
|
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":
|
if action == "test":
|
||||||
return await mcp_presets_test_action(query, config_path=config_path)
|
payload = await mcp_presets_test_action(query, config_path=config_path)
|
||||||
if config is not None:
|
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
|
operation = custom_mcp_action if action in _CUSTOM_ACTIONS else mcp_presets_action
|
||||||
payload = await asyncio.to_thread(
|
payload = await asyncio.to_thread(
|
||||||
config.run_serialized,
|
config.run_serialized,
|
||||||
@@ -1556,4 +1682,7 @@ async def mcp_presets_settings_action(
|
|||||||
payload = await asyncio.to_thread(mcp_presets_action, action, query)
|
payload = await asyncio.to_thread(mcp_presets_action, action, query)
|
||||||
if reload_mcp is not None:
|
if reload_mcp is not None:
|
||||||
payload = attach_mcp_hot_reload_result(payload, await reload_mcp())
|
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 asyncio
|
||||||
import html
|
import html
|
||||||
import json
|
import json
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable, Mapping
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
from websockets.http11 import Request as WsRequest
|
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 import request_mcp_reload
|
||||||
from nanobot.agent.tools.mcp_oauth import MCP_OAUTH_CALLBACK_PATH
|
from nanobot.agent.tools.mcp_oauth import MCP_OAUTH_CALLBACK_PATH
|
||||||
from nanobot.api.runtime import ApiRuntime, api_runtime_paths
|
from nanobot.api.runtime import ApiRuntime, api_runtime_paths
|
||||||
|
from nanobot.apps.discovery import discovery_payload
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.registry import load_channel_plugin
|
from nanobot.channels.registry import load_channel_plugin
|
||||||
from nanobot.channels.validation import validate_channel_config
|
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 = {
|
_MCP_PRESET_ACTIONS_BY_PATH = {
|
||||||
"/api/settings/mcp-presets/enable": "enable",
|
"/api/settings/mcp-presets/enable": "enable",
|
||||||
|
"/api/settings/mcp-presets/disable": "disable",
|
||||||
"/api/settings/mcp-presets/remove": "remove",
|
"/api/settings/mcp-presets/remove": "remove",
|
||||||
"/api/settings/mcp-presets/test": "test",
|
"/api/settings/mcp-presets/test": "test",
|
||||||
|
"/api/settings/mcp-presets/reconnect": "reconnect",
|
||||||
"/api/settings/mcp-presets/custom": "custom",
|
"/api/settings/mcp-presets/custom": "custom",
|
||||||
"/api/settings/mcp-presets/import": "import",
|
"/api/settings/mcp-presets/import": "import",
|
||||||
"/api/settings/mcp-presets/import-cursor": "import-cursor",
|
"/api/settings/mcp-presets/import-cursor": "import-cursor",
|
||||||
@@ -125,6 +128,7 @@ _CAPABILITY_ROUTES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_SYSTEM_ROUTES = {
|
_SYSTEM_ROUTES = {
|
||||||
|
"/api/settings/apps-discovery": "apps-discovery",
|
||||||
"/api/settings/cli-apps": "cli-list",
|
"/api/settings/cli-apps": "cli-list",
|
||||||
"/api/settings/cli-apps/install": "cli-install",
|
"/api/settings/cli-apps/install": "cli-install",
|
||||||
"/api/settings/cli-apps/update": "cli-update",
|
"/api/settings/cli-apps/update": "cli-update",
|
||||||
@@ -224,6 +228,7 @@ class WebUISettingsRouter:
|
|||||||
runtime_capabilities: dict[str, Any],
|
runtime_capabilities: dict[str, Any],
|
||||||
channel_feature_action: Callable[..., Any] | None = None,
|
channel_feature_action: Callable[..., Any] | None = None,
|
||||||
channel_runtime_status: Callable[[], dict[str, 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,
|
mcp_oauth_redirect_uri: Callable[[WsRequest], str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
@@ -237,6 +242,7 @@ class WebUISettingsRouter:
|
|||||||
self._runtime_capabilities = runtime_capabilities
|
self._runtime_capabilities = runtime_capabilities
|
||||||
self._channel_feature_action = channel_feature_action
|
self._channel_feature_action = channel_feature_action
|
||||||
self._channel_runtime_status = channel_runtime_status
|
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_redirect_uri = mcp_oauth_redirect_uri
|
||||||
self._mcp_oauth = McpOAuthManager()
|
self._mcp_oauth = McpOAuthManager()
|
||||||
self._restart_sections: set[str] = set()
|
self._restart_sections: set[str] = set()
|
||||||
@@ -457,6 +463,7 @@ class WebUISettingsRouter:
|
|||||||
|
|
||||||
def _system_operations(self) -> system_domain.SystemSettingsOperations:
|
def _system_operations(self) -> system_domain.SystemSettingsOperations:
|
||||||
return system_domain.SystemSettingsOperations(
|
return system_domain.SystemSettingsOperations(
|
||||||
|
apps_discovery_payload=discovery_payload,
|
||||||
cli_apps_payload=cli_apps_payload,
|
cli_apps_payload=cli_apps_payload,
|
||||||
cli_apps_action=cli_apps_action,
|
cli_apps_action=cli_apps_action,
|
||||||
nanobot_features_payload=nanobot_features_payload,
|
nanobot_features_payload=nanobot_features_payload,
|
||||||
@@ -469,6 +476,7 @@ class WebUISettingsRouter:
|
|||||||
deny_code=deny_code,
|
deny_code=deny_code,
|
||||||
mcp_presets_action=mcp_presets_settings_action,
|
mcp_presets_action=mcp_presets_settings_action,
|
||||||
reload_mcp=lambda: request_mcp_reload(self.bus),
|
reload_mcp=lambda: request_mcp_reload(self.bus),
|
||||||
|
mcp_runtime_status=self._mcp_runtime_status,
|
||||||
check_for_update=check_for_update,
|
check_for_update=check_for_update,
|
||||||
channel_feature_action=self._channel_feature_action,
|
channel_feature_action=self._channel_feature_action,
|
||||||
channel_runtime_status=self._channel_runtime_status,
|
channel_runtime_status=self._channel_runtime_status,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import asyncio
|
|||||||
import inspect
|
import inspect
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from collections.abc import Callable, Iterable
|
from collections.abc import Callable, Iterable, Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, TypedDict, cast
|
from typing import TYPE_CHECKING, Any, TypedDict, cast
|
||||||
@@ -43,6 +43,7 @@ SettingsOperation = Callable[..., Any]
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class SystemSettingsOperations:
|
class SystemSettingsOperations:
|
||||||
|
apps_discovery_payload: SettingsOperation
|
||||||
cli_apps_payload: SettingsOperation
|
cli_apps_payload: SettingsOperation
|
||||||
cli_apps_action: SettingsOperation
|
cli_apps_action: SettingsOperation
|
||||||
nanobot_features_payload: SettingsOperation
|
nanobot_features_payload: SettingsOperation
|
||||||
@@ -55,6 +56,7 @@ class SystemSettingsOperations:
|
|||||||
deny_code: SettingsOperation
|
deny_code: SettingsOperation
|
||||||
mcp_presets_action: SettingsOperation
|
mcp_presets_action: SettingsOperation
|
||||||
reload_mcp: SettingsOperation
|
reload_mcp: SettingsOperation
|
||||||
|
mcp_runtime_status: Callable[[], Mapping[str, str]] | None
|
||||||
check_for_update: SettingsOperation
|
check_for_update: SettingsOperation
|
||||||
channel_feature_action: SettingsOperation | None = None
|
channel_feature_action: SettingsOperation | None = None
|
||||||
channel_runtime_status: Callable[[], dict[str, Any]] | None = None
|
channel_runtime_status: Callable[[], dict[str, Any]] | None = None
|
||||||
@@ -371,6 +373,8 @@ class SystemSettingsHandler:
|
|||||||
channel_name: str | None = None,
|
channel_name: str | None = None,
|
||||||
connect_action: str | None = None,
|
connect_action: str | None = None,
|
||||||
) -> SettingsRouteResult:
|
) -> SettingsRouteResult:
|
||||||
|
if action == "apps-discovery":
|
||||||
|
return await self._apps_discovery(operations)
|
||||||
if action == "cli-list":
|
if action == "cli-list":
|
||||||
return await self._cli_apps(request, operations)
|
return await self._cli_apps(request, operations)
|
||||||
if action.startswith("cli-"):
|
if action.startswith("cli-"):
|
||||||
@@ -418,6 +422,15 @@ class SystemSettingsHandler:
|
|||||||
return await self._version_check(operations)
|
return await self._version_check(operations)
|
||||||
return SettingsRouteResult.failure(404, "unknown settings action")
|
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(
|
async def _cli_apps(
|
||||||
self,
|
self,
|
||||||
request: SettingsRequest,
|
request: SettingsRequest,
|
||||||
@@ -928,6 +941,7 @@ class SystemSettingsHandler:
|
|||||||
action,
|
action,
|
||||||
request.query,
|
request.query,
|
||||||
reload_mcp=operations.reload_mcp,
|
reload_mcp=operations.reload_mcp,
|
||||||
|
mcp_runtime_status=operations.mcp_runtime_status,
|
||||||
config=self.settings.config,
|
config=self.settings.config,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import json
|
|||||||
import mimetypes
|
import mimetypes
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable, Mapping
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
from urllib.parse import quote, unquote, urlsplit, urlunsplit
|
from urllib.parse import quote, unquote, urlsplit, urlunsplit
|
||||||
@@ -160,8 +160,10 @@ _WEBUI_MUTATION_PATHS = {
|
|||||||
"settings.pairing.approve": "/api/settings/pairing/approve",
|
"settings.pairing.approve": "/api/settings/pairing/approve",
|
||||||
"settings.pairing.deny": "/api/settings/pairing/deny",
|
"settings.pairing.deny": "/api/settings/pairing/deny",
|
||||||
"settings.mcp.enable": "/api/settings/mcp-presets/enable",
|
"settings.mcp.enable": "/api/settings/mcp-presets/enable",
|
||||||
|
"settings.mcp.disable": "/api/settings/mcp-presets/disable",
|
||||||
"settings.mcp.remove": "/api/settings/mcp-presets/remove",
|
"settings.mcp.remove": "/api/settings/mcp-presets/remove",
|
||||||
"settings.mcp.test": "/api/settings/mcp-presets/test",
|
"settings.mcp.test": "/api/settings/mcp-presets/test",
|
||||||
|
"settings.mcp.reconnect": "/api/settings/mcp-presets/reconnect",
|
||||||
"settings.mcp.custom": "/api/settings/mcp-presets/custom",
|
"settings.mcp.custom": "/api/settings/mcp-presets/custom",
|
||||||
"settings.mcp.import": "/api/settings/mcp-presets/import",
|
"settings.mcp.import": "/api/settings/mcp-presets/import",
|
||||||
"settings.mcp.import_cursor": "/api/settings/mcp-presets/import-cursor",
|
"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,
|
local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
|
||||||
channel_feature_action: Callable[..., Any] | None = None,
|
channel_feature_action: Callable[..., Any] | None = None,
|
||||||
channel_runtime_status: Callable[[], dict[str, 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,
|
skill_state_action: Callable[[set[str]], None] | None = None,
|
||||||
log: Any = logger,
|
log: Any = logger,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -347,6 +350,7 @@ class GatewayHTTPHandler:
|
|||||||
runtime_capabilities=self._capabilities,
|
runtime_capabilities=self._capabilities,
|
||||||
channel_feature_action=channel_feature_action,
|
channel_feature_action=channel_feature_action,
|
||||||
channel_runtime_status=channel_runtime_status,
|
channel_runtime_status=channel_runtime_status,
|
||||||
|
mcp_runtime_status=mcp_runtime_status,
|
||||||
mcp_oauth_redirect_uri=self._mcp_oauth_redirect_uri,
|
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 contextlib import AsyncExitStack
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
import pytest
|
import pytest
|
||||||
@@ -155,6 +155,31 @@ async def test_connect_mcp_retries_when_no_servers_connect(tmp_path, monkeypatch
|
|||||||
|
|
||||||
assert attempts == 2
|
assert attempts == 2
|
||||||
assert loop._mcp_stacks == {}
|
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
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -266,6 +266,7 @@ def test_disabled_skills_excluded_from_list(tmp_path: Path) -> None:
|
|||||||
assert len(entries) == 1
|
assert len(entries) == 1
|
||||||
assert entries[0]["name"] == "beta"
|
assert entries[0]["name"] == "beta"
|
||||||
assert entries[0]["path"] == str(beta_path)
|
assert entries[0]["path"] == str(beta_path)
|
||||||
|
assert loader.load_skill("alpha") is None
|
||||||
|
|
||||||
|
|
||||||
def test_disabled_skills_empty_set_no_effect(tmp_path: Path) -> None:
|
def test_disabled_skills_empty_set_no_effect(tmp_path: Path) -> None:
|
||||||
|
|||||||
@@ -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()
|
tools = ToolRegistry()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def mcp_runtime_status() -> dict[str, str]:
|
||||||
|
return {}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def pending_cron_job_ids_for_session(_session_key: str) -> set[str]:
|
def pending_cron_job_ids_for_session(_session_key: str) -> set[str]:
|
||||||
return set()
|
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
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.agent import plugins as agent_plugins
|
||||||
|
from nanobot.agent.skills import SkillsLoader
|
||||||
from nanobot.apps.cli.service import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
from nanobot.apps.cli.service import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _isolate_plugin_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(agent_plugins, "get_config_path", lambda: tmp_path / "config/config.json")
|
||||||
|
|
||||||
|
|
||||||
def _write_cache(path: Path, registry: dict) -> None:
|
def _write_cache(path: Path, registry: dict) -> None:
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
path.write_text(
|
path.write_text(
|
||||||
@@ -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"
|
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:
|
def test_payload_resolves_obsidian_agent_cli_brand(tmp_path: Path) -> None:
|
||||||
manager = _manager(tmp_path)
|
manager = _manager(tmp_path)
|
||||||
_write_cache(
|
_write_cache(
|
||||||
@@ -391,6 +468,9 @@ def test_install_dispatches_safe_pip_and_installs_skill(
|
|||||||
"_fetch_skill_content",
|
"_fetch_skill_content",
|
||||||
lambda app: "---\nname: cli-anything-gimp\ndescription: GIMP\n---\n# GIMP\n",
|
lambda app: "---\nname: cli-anything-gimp\ndescription: GIMP\n---\n# GIMP\n",
|
||||||
)
|
)
|
||||||
|
legacy = manager.workspace / "skills" / "cli-app-gimp" / "SKILL.md"
|
||||||
|
legacy.parent.mkdir(parents=True)
|
||||||
|
legacy.write_text("legacy", encoding="utf-8")
|
||||||
|
|
||||||
payload = manager.install("gimp")
|
payload = manager.install("gimp")
|
||||||
|
|
||||||
@@ -400,9 +480,15 @@ def test_install_dispatches_safe_pip_and_installs_skill(
|
|||||||
assert "state_recorded" in payload["last_action"]["verification"]
|
assert "state_recorded" in payload["last_action"]["verification"]
|
||||||
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
||||||
assert installed["gimp"]["entry_point"] == "cli-anything-gimp"
|
assert installed["gimp"]["entry_point"] == "cli-anything-gimp"
|
||||||
skill = manager.workspace / "skills" / "cli-app-gimp" / "SKILL.md"
|
plugin = manager.workspace / "plugins" / "cli-app-gimp"
|
||||||
|
skill = plugin / "skills" / "cli-app-gimp" / "SKILL.md"
|
||||||
assert skill.is_file()
|
assert skill.is_file()
|
||||||
|
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 '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(
|
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"]
|
assert "entry_point_available" in payload["last_action"]["verification"]
|
||||||
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
||||||
assert installed["feishu"]["entry_point_path"] == str(resolved)
|
assert installed["feishu"]["entry_point_path"] == str(resolved)
|
||||||
skill = manager.workspace / "skills" / "cli-app-feishu" / "SKILL.md"
|
skill = manager.workspace / "plugins/cli-app-feishu/skills/cli-app-feishu/SKILL.md"
|
||||||
assert skill.is_file()
|
assert skill.is_file()
|
||||||
assert 'run_cli_app` tool with `name="feishu"' in skill.read_text(encoding="utf-8")
|
assert 'run_cli_app` tool with `name="feishu"' in skill.read_text(encoding="utf-8")
|
||||||
|
|
||||||
@@ -704,7 +790,8 @@ def test_uninstall_removes_installed_state_and_generated_skill(
|
|||||||
manager = _manager(tmp_path)
|
manager = _manager(tmp_path)
|
||||||
_seed_catalog(manager)
|
_seed_catalog(manager)
|
||||||
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
|
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
|
||||||
skill_dir = manager.workspace / "skills" / "cli-app-gimp"
|
plugin_dir = manager.workspace / "plugins" / "cli-app-gimp"
|
||||||
|
skill_dir = plugin_dir / "skills" / "cli-app-gimp"
|
||||||
skill_dir.mkdir(parents=True)
|
skill_dir.mkdir(parents=True)
|
||||||
(skill_dir / "SKILL.md").write_text("# GIMP\n", encoding="utf-8")
|
(skill_dir / "SKILL.md").write_text("# GIMP\n", encoding="utf-8")
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
@@ -717,7 +804,7 @@ def test_uninstall_removes_installed_state_and_generated_skill(
|
|||||||
|
|
||||||
assert payload["last_action"]["ok"] is True
|
assert payload["last_action"]["ok"] is True
|
||||||
assert "gimp" not in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
assert "gimp" not in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
||||||
assert not skill_dir.exists()
|
assert not plugin_dir.exists()
|
||||||
|
|
||||||
|
|
||||||
def test_uninstall_uses_safe_python_m_pip_uninstall_command(
|
def test_uninstall_uses_safe_python_m_pip_uninstall_command(
|
||||||
@@ -845,19 +932,62 @@ def test_mentioned_installed_apps_only_returns_installed_mentions(tmp_path: Path
|
|||||||
"name": "zoom",
|
"name": "zoom",
|
||||||
"entry_point": "cli-anything-zoom",
|
"entry_point": "cli-anything-zoom",
|
||||||
"source": "public",
|
"source": "public",
|
||||||
"skill": "skills/cli-app-zoom/SKILL.md",
|
"skill": "plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md",
|
||||||
"tool": "run_cli_app",
|
"tool": "run_cli_app",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "gimp",
|
"name": "gimp",
|
||||||
"entry_point": "cli-anything-gimp",
|
"entry_point": "cli-anything-gimp",
|
||||||
"source": "harness",
|
"source": "harness",
|
||||||
"skill": "skills/cli-app-gimp/SKILL.md",
|
"skill": "plugins/cli-app-gimp/skills/cli-app-gimp/SKILL.md",
|
||||||
"tool": "run_cli_app",
|
"tool": "run_cli_app",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_skill_cleans_legacy_underscored_name(tmp_path: Path) -> None:
|
||||||
|
manager = _manager(tmp_path)
|
||||||
|
legacy = manager.workspace / "skills" / "cli-app-unimol_tools" / "SKILL.md"
|
||||||
|
legacy.parent.mkdir(parents=True)
|
||||||
|
legacy.write_text("# Legacy Uni-Mol\n", encoding="utf-8")
|
||||||
|
|
||||||
|
manager.remove_skill("unimol_tools")
|
||||||
|
|
||||||
|
assert not legacy.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_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:
|
def test_install_rejects_unknown_and_script_strategy(tmp_path: Path) -> None:
|
||||||
manager = _manager(tmp_path)
|
manager = _manager(tmp_path)
|
||||||
_seed_catalog(manager)
|
_seed_catalog(manager)
|
||||||
|
|||||||
@@ -38,24 +38,26 @@ def test_cli_app_mentions_inject_runtime_metadata(tmp_path, monkeypatch):
|
|||||||
assert "CLI App Mention: @zoom" in joined
|
assert "CLI App Mention: @zoom" in joined
|
||||||
assert "tool=run_cli_app" in joined
|
assert "tool=run_cli_app" in joined
|
||||||
assert "entry_point=cli-anything-zoom" in joined
|
assert "entry_point=cli-anything-zoom" in joined
|
||||||
assert "skill=skills/cli-app-zoom/SKILL.md" in joined
|
assert "skill=plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md" in joined
|
||||||
|
|
||||||
|
|
||||||
def test_structured_cli_app_attachment_injects_runtime_metadata(tmp_path):
|
def test_structured_cli_app_attachment_uses_existing_legacy_skill(tmp_path):
|
||||||
|
legacy = tmp_path / "skills" / "cli-app-unimol_tools" / "SKILL.md"
|
||||||
|
legacy.parent.mkdir(parents=True)
|
||||||
|
legacy.write_text("# Legacy Uni-Mol\n", encoding="utf-8")
|
||||||
lines = runtime_lines_for_request(
|
lines = runtime_lines_for_request(
|
||||||
"please use @zoom tonight",
|
"please use @unimol_tools",
|
||||||
{
|
{
|
||||||
"cli_apps": [{
|
"cli_apps": [{
|
||||||
"name": "zoom",
|
"name": "unimol_tools",
|
||||||
"entry_point": "cli-anything-zoom",
|
"entry_point": "cli-anything-unimol-tools",
|
||||||
"display_name": "Zoom",
|
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
tmp_path,
|
tmp_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
joined = "\n".join(lines)
|
joined = "\n".join(lines)
|
||||||
assert "CLI App Attachment: @zoom" in joined
|
assert "CLI App Attachment: @unimol_tools" in joined
|
||||||
assert "tool=run_cli_app" in joined
|
assert "tool=run_cli_app" in joined
|
||||||
assert "entry_point=cli-anything-zoom" in joined
|
assert "entry_point=cli-anything-unimol-tools" in joined
|
||||||
assert "skill=skills/cli-app-zoom/SKILL.md" in joined
|
assert "skill=skills/cli-app-unimol_tools/SKILL.md" in joined
|
||||||
|
|||||||
@@ -117,6 +117,30 @@ class TestBuildKwargsExtraBody:
|
|||||||
"chat_template_kwargs": {"enable_thinking": False},
|
"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:
|
def test_extra_body_merges_with_thinking(self) -> None:
|
||||||
"""Config extra_body should merge with (and override) thinking params."""
|
"""Config extra_body should merge with (and override) thinking params."""
|
||||||
from nanobot.providers.registry import ProviderSpec
|
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
|
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:
|
def test_wrapper_preserves_non_nullable_unions() -> None:
|
||||||
tool_def = SimpleNamespace(
|
tool_def = SimpleNamespace(
|
||||||
name="demo",
|
name="demo",
|
||||||
|
|||||||
@@ -598,6 +598,24 @@ def test_cast_params_invalid_string_to_number() -> None:
|
|||||||
assert result["rate"] == "not_a_number"
|
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:
|
def test_validate_params_bool_not_accepted_as_number() -> None:
|
||||||
"""Booleans should not pass number validation."""
|
"""Booleans should not pass number validation."""
|
||||||
tool = CastTestTool(
|
tool = CastTestTool(
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
|
from functools import partial
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from mcp.shared.auth import OAuthToken
|
from mcp.shared.auth import OAuthToken
|
||||||
|
|
||||||
|
from nanobot.agent.plugins import AGENT_PLUGIN_MCP_SCHEMA, AGENT_PLUGIN_SCHEMA
|
||||||
from nanobot.agent.tools.mcp_oauth import MCPOAuthStorage, mcp_oauth_has_credentials
|
from nanobot.agent.tools.mcp_oauth import MCPOAuthStorage, mcp_oauth_has_credentials
|
||||||
from nanobot.config.loader import load_config
|
from nanobot.config.loader import load_config
|
||||||
from nanobot.webui.mcp_presets_api import (
|
from nanobot.webui.mcp_presets_api import (
|
||||||
@@ -12,13 +16,48 @@ from nanobot.webui.mcp_presets_api import (
|
|||||||
custom_mcp_action,
|
custom_mcp_action,
|
||||||
mcp_presets_action,
|
mcp_presets_action,
|
||||||
mcp_presets_payload,
|
mcp_presets_payload,
|
||||||
|
mcp_presets_settings_action,
|
||||||
mcp_presets_test_action,
|
mcp_presets_test_action,
|
||||||
normalize_mcp_preset_mentions,
|
normalize_mcp_preset_mentions,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _use_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
def _use_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", tmp_path / "config.json")
|
config_path = tmp_path / "config.json"
|
||||||
|
config_path.write_text(
|
||||||
|
json.dumps({"agents": {"defaults": {"workspace": str(tmp_path / "workspace")}}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_agent_plugin(workspace: Path) -> None:
|
||||||
|
root = workspace / "plugins" / "desktop"
|
||||||
|
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:
|
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"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_oauth_preset_is_one_click_configured_after_token_storage(
|
async def test_oauth_preset_is_one_click_configured_after_token_storage(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
@@ -87,10 +172,72 @@ async def test_oauth_preset_is_one_click_configured_after_token_storage(
|
|||||||
assert row["configured"] is True
|
assert row["configured"] is True
|
||||||
assert row["status"] == "configured"
|
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"]})
|
mcp_presets_action("remove", {"name": ["xmind"]})
|
||||||
assert await MCPOAuthStorage("xmind", cfg.url).get_tokens() is None
|
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(
|
def test_enable_browserbase_writes_scrubbed_config_payload(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from collections.abc import Callable, Mapping
|
||||||
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import ANY, AsyncMock, MagicMock
|
from unittest.mock import ANY, AsyncMock, MagicMock
|
||||||
from urllib.parse import parse_qs, urlsplit
|
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.config.loader import get_config_path
|
||||||
from nanobot.webui.http_utils import http_json_response
|
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_routes import WebUISettingsRouter
|
||||||
from nanobot.webui.settings_services import WebUISettingsServices
|
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(
|
return WebUISettingsRouter(
|
||||||
settings=WebUISettingsServices.create(get_config_path()),
|
settings=WebUISettingsServices.create(config_path or get_config_path()),
|
||||||
bus=SimpleNamespace(),
|
bus=SimpleNamespace(),
|
||||||
logger=SimpleNamespace(exception=lambda *_args: None),
|
logger=SimpleNamespace(exception=lambda *_args: None),
|
||||||
check_api_token=lambda _request: authorized,
|
check_api_token=lambda _request: authorized,
|
||||||
@@ -28,6 +36,7 @@ def _router(*, authorized: bool = True) -> WebUISettingsRouter:
|
|||||||
),
|
),
|
||||||
runtime_surface="browser",
|
runtime_surface="browser",
|
||||||
runtime_capabilities={},
|
runtime_capabilities={},
|
||||||
|
mcp_runtime_status=mcp_runtime_status,
|
||||||
mcp_oauth_redirect_uri=lambda _request: "https://gateway.example/auth/mcp/callback",
|
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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_mcp_oauth_start_uses_gateway_callback_and_requires_api_auth(monkeypatch) -> None:
|
async def test_mcp_oauth_start_uses_gateway_callback_and_requires_api_auth(monkeypatch) -> None:
|
||||||
config = SimpleNamespace(
|
config = SimpleNamespace(
|
||||||
|
|||||||
+17
-1
@@ -2,7 +2,10 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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="color-scheme" content="light dark" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
@@ -11,9 +14,14 @@
|
|||||||
/>
|
/>
|
||||||
<meta name="theme-color" content="#fafaf9" media="(prefers-color-scheme: light)" />
|
<meta name="theme-color" content="#fafaf9" media="(prefers-color-scheme: light)" />
|
||||||
<meta name="theme-color" content="#161618" media="(prefers-color-scheme: dark)" />
|
<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="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="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="apple-touch-icon" sizes="180x180" href="/brand/nanobot_apple_touch.png" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<style>
|
<style>
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
@@ -21,6 +29,14 @@
|
|||||||
height: 100%;
|
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 {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: #ffffff;
|
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}
|
disabled={submitting}
|
||||||
aria-invalid={validationError ? true : undefined}
|
aria-invalid={validationError ? true : undefined}
|
||||||
aria-describedby={validationError ? "webui-auth-error" : 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
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<Button
|
<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 { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { formControlFocusClassName } from "@/components/ui/form-control";
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
@@ -83,7 +84,8 @@ export function PromptNavigator({
|
|||||||
placeholder={t("thread.promptNavigator.search")}
|
placeholder={t("thread.promptNavigator.search")}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-10 w-full rounded-full border border-border bg-background pl-9 pr-3 text-sm",
|
"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>
|
</div>
|
||||||
|
|||||||
@@ -256,10 +256,7 @@ export function WorkspaceProjectPicker({
|
|||||||
aria-label={t("workspace.dialog.manual")}
|
aria-label={t("workspace.dialog.manual")}
|
||||||
aria-invalid={pathError || error ? true : undefined}
|
aria-invalid={pathError || error ? true : undefined}
|
||||||
aria-describedby={pathError || error ? pathErrorId : undefined}
|
aria-describedby={pathError || error ? pathErrorId : undefined}
|
||||||
className={cn(
|
className="h-9 rounded-full border-border/55 bg-background/80 px-3 text-[12.5px]"
|
||||||
"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",
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
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 * as React from "react";
|
||||||
|
|
||||||
|
import { formControlFocusClassName } from "@/components/ui/form-control";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||||
@@ -10,7 +11,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { formControlFocusClassName } from "@/components/ui/form-control";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
|
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||||
@@ -9,7 +10,8 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -241,7 +241,7 @@
|
|||||||
"activityMode": "Choose how much agent activity chrome to show by default.",
|
"activityMode": "Choose how much agent activity chrome to show by default.",
|
||||||
"fileEditDisplay": "Choose whether file edit activity opens as line counts or a diff.",
|
"fileEditDisplay": "Choose whether file edit activity opens as line counts or a diff.",
|
||||||
"codeWrap": "Keep long code lines readable on smaller screens.",
|
"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.",
|
"maxResults": "Results returned by each web_search call.",
|
||||||
"timeout": "Seconds before a search provider request times out.",
|
"timeout": "Seconds before a search provider request times out.",
|
||||||
"jinaReader": "Use Jina Reader for web_fetch when available.",
|
"jinaReader": "Use Jina Reader for web_fetch when available.",
|
||||||
@@ -354,7 +354,10 @@
|
|||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
"setup": "Connect",
|
"setup": "Connect",
|
||||||
"configure": "Connect",
|
"configure": "Connect",
|
||||||
|
"reconnect": "Reconnect",
|
||||||
"connectTitle": "Connect {{name}}",
|
"connectTitle": "Connect {{name}}",
|
||||||
|
"reconnectTitle": "Reconnect {{name}}",
|
||||||
|
"actionsTitle": "Actions for {{name}}",
|
||||||
"connectHint": "Add the key from your account settings.",
|
"connectHint": "Add the key from your account settings.",
|
||||||
"saveAndEnable": "Save and enable",
|
"saveAndEnable": "Save and enable",
|
||||||
"updateSetup": "Update setup",
|
"updateSetup": "Update setup",
|
||||||
@@ -554,9 +557,6 @@
|
|||||||
"capabilityOpenAISearch": "OpenAI web search",
|
"capabilityOpenAISearch": "OpenAI web search",
|
||||||
"capabilityOpenAISearchHelp": "Allow compatible Responses API models to search the web. Search activity appears in chat."
|
"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": {
|
"image": {
|
||||||
"selectProvider": "Select provider",
|
"selectProvider": "Select provider",
|
||||||
"selectAspect": "Select aspect",
|
"selectAspect": "Select aspect",
|
||||||
@@ -603,6 +603,14 @@
|
|||||||
},
|
},
|
||||||
"apps": {
|
"apps": {
|
||||||
"description": "Add tools to nanobot, then @ them in chat.",
|
"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",
|
"cliLabel": "App",
|
||||||
"mcpLabel": "MCP",
|
"mcpLabel": "MCP",
|
||||||
"channelLabel": "Channel",
|
"channelLabel": "Channel",
|
||||||
@@ -614,7 +622,7 @@
|
|||||||
"enabledSummary": "{{count}} ready",
|
"enabledSummary": "{{count}} ready",
|
||||||
"caption": "{{cli}} apps · {{mcp}} MCP tools",
|
"caption": "{{cli}} apps · {{mcp}} MCP tools",
|
||||||
"searchPlaceholder": "Search tools",
|
"searchPlaceholder": "Search tools",
|
||||||
"featured": "Tools",
|
"featured": "Featured",
|
||||||
"mcpTools": "MCP tools",
|
"mcpTools": "MCP tools",
|
||||||
"loading": "Loading Apps...",
|
"loading": "Loading Apps...",
|
||||||
"empty": "No tools match your search.",
|
"empty": "No tools match your search.",
|
||||||
|
|||||||
@@ -203,7 +203,7 @@
|
|||||||
"currentModel": "Se usa para nuevas respuestas.",
|
"currentModel": "Se usa para nuevas respuestas.",
|
||||||
"selectedModelProvider": "Definido por el modelo seleccionado.",
|
"selectedModelProvider": "Definido por el modelo seleccionado.",
|
||||||
"selectedModelValue": "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.",
|
"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.",
|
"cliAppsFilter": "Busca por aplicación, categoría o capacidad.",
|
||||||
"logs": "Abre la carpeta de registros del motor nativo.",
|
"logs": "Abre la carpeta de registros del motor nativo.",
|
||||||
@@ -539,7 +539,10 @@
|
|||||||
"enabled": "Habilitado",
|
"enabled": "Habilitado",
|
||||||
"setup": "Conectar",
|
"setup": "Conectar",
|
||||||
"configure": "Conectar",
|
"configure": "Conectar",
|
||||||
|
"reconnect": "Reconectar",
|
||||||
"connectTitle": "Conectar {{name}}",
|
"connectTitle": "Conectar {{name}}",
|
||||||
|
"reconnectTitle": "Reconectar {{name}}",
|
||||||
|
"actionsTitle": "Acciones de {{name}}",
|
||||||
"connectHint": "Añade la clave desde la configuración de tu cuenta.",
|
"connectHint": "Añade la clave desde la configuración de tu cuenta.",
|
||||||
"saveAndEnable": "Guardar y habilitar",
|
"saveAndEnable": "Guardar y habilitar",
|
||||||
"updateSetup": "Actualizar configuración",
|
"updateSetup": "Actualizar configuración",
|
||||||
@@ -585,11 +588,16 @@
|
|||||||
"title": "Observabilidad", "configured": "Las credenciales de trazas están disponibles para nanobot.",
|
"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"
|
"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": {
|
"apps": {
|
||||||
"description": "Añade herramientas a nanobot y luego úsalas con @ en el chat.",
|
"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",
|
"cliLabel": "Aplicación",
|
||||||
"mcpLabel": "MCP",
|
"mcpLabel": "MCP",
|
||||||
"channelLabel": "Canal",
|
"channelLabel": "Canal",
|
||||||
@@ -601,7 +609,7 @@
|
|||||||
"enabledSummary": "{{count}} listos",
|
"enabledSummary": "{{count}} listos",
|
||||||
"caption": "{{cli}} aplicaciones · {{mcp}} herramientas MCP",
|
"caption": "{{cli}} aplicaciones · {{mcp}} herramientas MCP",
|
||||||
"searchPlaceholder": "Buscar aplicaciones",
|
"searchPlaceholder": "Buscar aplicaciones",
|
||||||
"featured": "Herramientas",
|
"featured": "Destacadas",
|
||||||
"mcpTools": "Herramientas MCP",
|
"mcpTools": "Herramientas MCP",
|
||||||
"loading": "Cargando aplicaciones...",
|
"loading": "Cargando aplicaciones...",
|
||||||
"empty": "Ninguna herramienta coincide con tu búsqueda.",
|
"empty": "Ninguna herramienta coincide con tu búsqueda.",
|
||||||
|
|||||||
@@ -203,7 +203,7 @@
|
|||||||
"currentModel": "Utilisée pour les nouvelles réponses.",
|
"currentModel": "Utilisée pour les nouvelles réponses.",
|
||||||
"selectedModelProvider": "Défini par le modèle sélectionné.",
|
"selectedModelProvider": "Défini par le modèle sélectionné.",
|
||||||
"selectedModelValue": "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.",
|
"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é.",
|
"cliAppsFilter": "Recherchez par application, catégorie ou capacité.",
|
||||||
"logs": "Ouvre le dossier des journaux du moteur natif.",
|
"logs": "Ouvre le dossier des journaux du moteur natif.",
|
||||||
@@ -539,7 +539,10 @@
|
|||||||
"enabled": "Activé",
|
"enabled": "Activé",
|
||||||
"setup": "Connecter",
|
"setup": "Connecter",
|
||||||
"configure": "Connecter",
|
"configure": "Connecter",
|
||||||
|
"reconnect": "Reconnecter",
|
||||||
"connectTitle": "Connecter {{name}}",
|
"connectTitle": "Connecter {{name}}",
|
||||||
|
"reconnectTitle": "Reconnecter {{name}}",
|
||||||
|
"actionsTitle": "Actions pour {{name}}",
|
||||||
"connectHint": "Ajoutez la clé depuis les paramètres de votre compte.",
|
"connectHint": "Ajoutez la clé depuis les paramètres de votre compte.",
|
||||||
"saveAndEnable": "Enregistrer et activer",
|
"saveAndEnable": "Enregistrer et activer",
|
||||||
"updateSetup": "Mettre à jour la configuration",
|
"updateSetup": "Mettre à jour la configuration",
|
||||||
@@ -584,11 +587,16 @@
|
|||||||
"title": "Observabilité", "configured": "Les identifiants de traçage sont disponibles pour nanobot.",
|
"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"
|
"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": {
|
"apps": {
|
||||||
"description": "Ajoutez des outils à nanobot, puis utilisez-les avec @ dans le chat.",
|
"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",
|
"cliLabel": "Application",
|
||||||
"mcpLabel": "MCP",
|
"mcpLabel": "MCP",
|
||||||
"channelLabel": "Canal",
|
"channelLabel": "Canal",
|
||||||
@@ -600,7 +608,7 @@
|
|||||||
"enabledSummary": "{{count}} prêts",
|
"enabledSummary": "{{count}} prêts",
|
||||||
"caption": "{{cli}} applications · {{mcp}} outils MCP",
|
"caption": "{{cli}} applications · {{mcp}} outils MCP",
|
||||||
"searchPlaceholder": "Rechercher des applications",
|
"searchPlaceholder": "Rechercher des applications",
|
||||||
"featured": "Outils",
|
"featured": "À la une",
|
||||||
"mcpTools": "Outils MCP",
|
"mcpTools": "Outils MCP",
|
||||||
"loading": "Chargement des applications...",
|
"loading": "Chargement des applications...",
|
||||||
"empty": "Aucun outil ne correspond à votre recherche.",
|
"empty": "Aucun outil ne correspond à votre recherche.",
|
||||||
|
|||||||
@@ -203,7 +203,7 @@
|
|||||||
"currentModel": "Digunakan untuk balasan baru.",
|
"currentModel": "Digunakan untuk balasan baru.",
|
||||||
"selectedModelProvider": "Ditentukan oleh model yang dipilih.",
|
"selectedModelProvider": "Ditentukan oleh model yang dipilih.",
|
||||||
"selectedModelValue": "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.",
|
"cliAppsCatalog": "Instal hanya adaptor CLI aplikasi yang dapat dijalankan nanobot secara lokal; aplikasi asli tidak diubah.",
|
||||||
"cliAppsFilter": "Cari berdasarkan aplikasi, kategori, atau kemampuan.",
|
"cliAppsFilter": "Cari berdasarkan aplikasi, kategori, atau kemampuan.",
|
||||||
"logs": "Buka folder log mesin asli.",
|
"logs": "Buka folder log mesin asli.",
|
||||||
@@ -539,7 +539,10 @@
|
|||||||
"enabled": "Aktif",
|
"enabled": "Aktif",
|
||||||
"setup": "Hubungkan",
|
"setup": "Hubungkan",
|
||||||
"configure": "Hubungkan",
|
"configure": "Hubungkan",
|
||||||
|
"reconnect": "Hubungkan kembali",
|
||||||
"connectTitle": "Hubungkan {{name}}",
|
"connectTitle": "Hubungkan {{name}}",
|
||||||
|
"reconnectTitle": "Hubungkan kembali {{name}}",
|
||||||
|
"actionsTitle": "Tindakan untuk {{name}}",
|
||||||
"connectHint": "Tambahkan kunci dari pengaturan akun Anda.",
|
"connectHint": "Tambahkan kunci dari pengaturan akun Anda.",
|
||||||
"saveAndEnable": "Simpan dan aktifkan",
|
"saveAndEnable": "Simpan dan aktifkan",
|
||||||
"updateSetup": "Perbarui konfigurasi",
|
"updateSetup": "Perbarui konfigurasi",
|
||||||
@@ -584,11 +587,16 @@
|
|||||||
"title": "Observabilitas", "configured": "Kredensial tracing tersedia untuk nanobot.",
|
"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"
|
"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": {
|
"apps": {
|
||||||
"description": "Tambahkan alat ke nanobot, lalu gunakan dengan @ di chat.",
|
"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",
|
"cliLabel": "Aplikasi",
|
||||||
"mcpLabel": "MCP",
|
"mcpLabel": "MCP",
|
||||||
"channelLabel": "Kanal",
|
"channelLabel": "Kanal",
|
||||||
@@ -600,7 +608,7 @@
|
|||||||
"enabledSummary": "{{count}} siap",
|
"enabledSummary": "{{count}} siap",
|
||||||
"caption": "{{cli}} aplikasi · {{mcp}} alat MCP",
|
"caption": "{{cli}} aplikasi · {{mcp}} alat MCP",
|
||||||
"searchPlaceholder": "Cari aplikasi",
|
"searchPlaceholder": "Cari aplikasi",
|
||||||
"featured": "Alat",
|
"featured": "Unggulan",
|
||||||
"mcpTools": "Alat MCP",
|
"mcpTools": "Alat MCP",
|
||||||
"loading": "Memuat aplikasi...",
|
"loading": "Memuat aplikasi...",
|
||||||
"empty": "Tidak ada alat yang cocok dengan pencarian Anda.",
|
"empty": "Tidak ada alat yang cocok dengan pencarian Anda.",
|
||||||
|
|||||||
@@ -203,7 +203,7 @@
|
|||||||
"currentModel": "新しい返信に使用します。",
|
"currentModel": "新しい返信に使用します。",
|
||||||
"selectedModelProvider": "選択したモデルによって設定されます。",
|
"selectedModelProvider": "選択したモデルによって設定されます。",
|
||||||
"selectedModelValue": "選択したモデルによって設定されます。",
|
"selectedModelValue": "選択したモデルによって設定されます。",
|
||||||
"brandLogos": "設定で第三者プロバイダーと CLI のロゴを表示します。",
|
"brandLogos": "外部のアイコンサービスからサードパーティのブランドロゴを読み込みます。オフにするとローカルのイニシャルアイコンを使用します。",
|
||||||
"cliAppsCatalog": "nanobot がローカルで実行できるアプリ CLI アダプターだけをインストールします。ネイティブアプリは変更しません。",
|
"cliAppsCatalog": "nanobot がローカルで実行できるアプリ CLI アダプターだけをインストールします。ネイティブアプリは変更しません。",
|
||||||
"cliAppsFilter": "アプリ、カテゴリ、機能で検索します。",
|
"cliAppsFilter": "アプリ、カテゴリ、機能で検索します。",
|
||||||
"logs": "ネイティブエンジンのログフォルダーを開きます。",
|
"logs": "ネイティブエンジンのログフォルダーを開きます。",
|
||||||
@@ -539,7 +539,10 @@
|
|||||||
"enabled": "有効",
|
"enabled": "有効",
|
||||||
"setup": "接続",
|
"setup": "接続",
|
||||||
"configure": "接続",
|
"configure": "接続",
|
||||||
|
"reconnect": "再接続",
|
||||||
"connectTitle": "{{name}} に接続",
|
"connectTitle": "{{name}} に接続",
|
||||||
|
"reconnectTitle": "{{name}} に再接続",
|
||||||
|
"actionsTitle": "{{name}} の操作",
|
||||||
"connectHint": "アカウント設定からキーを追加します。",
|
"connectHint": "アカウント設定からキーを追加します。",
|
||||||
"saveAndEnable": "保存して有効化",
|
"saveAndEnable": "保存して有効化",
|
||||||
"updateSetup": "設定を更新",
|
"updateSetup": "設定を更新",
|
||||||
@@ -584,11 +587,16 @@
|
|||||||
"title": "可観測性", "configured": "nanobot がトレース認証情報を利用できます。",
|
"title": "可観測性", "configured": "nanobot がトレース認証情報を利用できます。",
|
||||||
"environment": "LANGFUSE_SECRET_KEY と LANGFUSE_PUBLIC_KEY を設定して nanobot を再起動してください。", "enable": "トレースサポートを有効化"
|
"environment": "LANGFUSE_SECRET_KEY と LANGFUSE_PUBLIC_KEY を設定して nanobot を再起動してください。", "enable": "トレースサポートを有効化"
|
||||||
},
|
},
|
||||||
"legal": {
|
|
||||||
"thirdPartyBrands": "製品名、ロゴ、ブランドはそれぞれの所有者に帰属します。使用は識別のみを目的とし、承認を意味するものではありません。"
|
|
||||||
},
|
|
||||||
"apps": {
|
"apps": {
|
||||||
"description": "nanobot にツールを追加し、チャットで @ を付けて使用できます。",
|
"description": "nanobot にツールを追加し、チャットで @ を付けて使用できます。",
|
||||||
|
"discover": "見つける",
|
||||||
|
"installed": "インストール済み",
|
||||||
|
"allApps": "すべてのアプリ",
|
||||||
|
"addCustom": "カスタム追加",
|
||||||
|
"emptyInstalled": "インストール済みのアプリはありません。",
|
||||||
|
"searchResults": "検索結果",
|
||||||
|
"browseAll": "すべて見る",
|
||||||
|
"nextFeatured": "ほかを見る",
|
||||||
"cliLabel": "アプリ",
|
"cliLabel": "アプリ",
|
||||||
"mcpLabel": "MCP",
|
"mcpLabel": "MCP",
|
||||||
"channelLabel": "チャンネル",
|
"channelLabel": "チャンネル",
|
||||||
@@ -600,7 +608,7 @@
|
|||||||
"enabledSummary": "{{count}} 件使用可能",
|
"enabledSummary": "{{count}} 件使用可能",
|
||||||
"caption": "アプリ {{cli}} 件 · MCP ツール {{mcp}} 件",
|
"caption": "アプリ {{cli}} 件 · MCP ツール {{mcp}} 件",
|
||||||
"searchPlaceholder": "アプリを検索",
|
"searchPlaceholder": "アプリを検索",
|
||||||
"featured": "ツール",
|
"featured": "おすすめ",
|
||||||
"mcpTools": "MCP ツール",
|
"mcpTools": "MCP ツール",
|
||||||
"loading": "アプリを読み込み中...",
|
"loading": "アプリを読み込み中...",
|
||||||
"empty": "検索条件に一致するツールはありません。",
|
"empty": "検索条件に一致するツールはありません。",
|
||||||
|
|||||||
@@ -203,7 +203,7 @@
|
|||||||
"currentModel": "새 응답에 사용됩니다.",
|
"currentModel": "새 응답에 사용됩니다.",
|
||||||
"selectedModelProvider": "선택한 모델에 의해 설정됩니다.",
|
"selectedModelProvider": "선택한 모델에 의해 설정됩니다.",
|
||||||
"selectedModelValue": "선택한 모델에 의해 설정됩니다.",
|
"selectedModelValue": "선택한 모델에 의해 설정됩니다.",
|
||||||
"brandLogos": "설정에서 타사 제공자와 CLI 로고를 표시합니다.",
|
"brandLogos": "외부 아이콘 서비스에서 타사 브랜드 로고를 불러옵니다. 끄면 로컬 이니셜 아이콘을 사용합니다.",
|
||||||
"cliAppsCatalog": "nanobot이 로컬에서 실행할 수 있는 앱 CLI 어댑터만 설치합니다. 네이티브 앱은 변경하지 않습니다.",
|
"cliAppsCatalog": "nanobot이 로컬에서 실행할 수 있는 앱 CLI 어댑터만 설치합니다. 네이티브 앱은 변경하지 않습니다.",
|
||||||
"cliAppsFilter": "앱, 카테고리 또는 기능으로 검색합니다.",
|
"cliAppsFilter": "앱, 카테고리 또는 기능으로 검색합니다.",
|
||||||
"logs": "네이티브 엔진 로그 폴더를 엽니다.",
|
"logs": "네이티브 엔진 로그 폴더를 엽니다.",
|
||||||
@@ -539,7 +539,10 @@
|
|||||||
"enabled": "활성화됨",
|
"enabled": "활성화됨",
|
||||||
"setup": "연결",
|
"setup": "연결",
|
||||||
"configure": "연결",
|
"configure": "연결",
|
||||||
|
"reconnect": "다시 연결",
|
||||||
"connectTitle": "{{name}} 연결",
|
"connectTitle": "{{name}} 연결",
|
||||||
|
"reconnectTitle": "{{name}} 다시 연결",
|
||||||
|
"actionsTitle": "{{name}} 작업",
|
||||||
"connectHint": "계정 설정에서 키를 추가하세요.",
|
"connectHint": "계정 설정에서 키를 추가하세요.",
|
||||||
"saveAndEnable": "저장 후 활성화",
|
"saveAndEnable": "저장 후 활성화",
|
||||||
"updateSetup": "설정 업데이트",
|
"updateSetup": "설정 업데이트",
|
||||||
@@ -584,11 +587,16 @@
|
|||||||
"title": "관측성", "configured": "nanobot이 추적 자격 증명을 사용할 수 있습니다.",
|
"title": "관측성", "configured": "nanobot이 추적 자격 증명을 사용할 수 있습니다.",
|
||||||
"environment": "LANGFUSE_SECRET_KEY와 LANGFUSE_PUBLIC_KEY를 설정한 뒤 nanobot을 다시 시작하세요.", "enable": "추적 지원 활성화"
|
"environment": "LANGFUSE_SECRET_KEY와 LANGFUSE_PUBLIC_KEY를 설정한 뒤 nanobot을 다시 시작하세요.", "enable": "추적 지원 활성화"
|
||||||
},
|
},
|
||||||
"legal": {
|
|
||||||
"thirdPartyBrands": "제품 이름, 로고 및 브랜드는 각 소유자의 자산입니다. 사용은 식별 목적일 뿐 보증이나 제휴를 의미하지 않습니다."
|
|
||||||
},
|
|
||||||
"apps": {
|
"apps": {
|
||||||
"description": "nanobot에 도구를 추가한 뒤 채팅에서 @로 사용하세요.",
|
"description": "nanobot에 도구를 추가한 뒤 채팅에서 @로 사용하세요.",
|
||||||
|
"discover": "둘러보기",
|
||||||
|
"installed": "설치됨",
|
||||||
|
"allApps": "모든 앱",
|
||||||
|
"addCustom": "사용자 지정 추가",
|
||||||
|
"emptyInstalled": "설치된 앱이 없습니다.",
|
||||||
|
"searchResults": "검색 결과",
|
||||||
|
"browseAll": "모두 보기",
|
||||||
|
"nextFeatured": "다른 추천 보기",
|
||||||
"cliLabel": "앱",
|
"cliLabel": "앱",
|
||||||
"mcpLabel": "MCP",
|
"mcpLabel": "MCP",
|
||||||
"channelLabel": "채널",
|
"channelLabel": "채널",
|
||||||
@@ -600,7 +608,7 @@
|
|||||||
"enabledSummary": "{{count}}개 사용 가능",
|
"enabledSummary": "{{count}}개 사용 가능",
|
||||||
"caption": "앱 {{cli}}개 · MCP 도구 {{mcp}}개",
|
"caption": "앱 {{cli}}개 · MCP 도구 {{mcp}}개",
|
||||||
"searchPlaceholder": "앱 검색",
|
"searchPlaceholder": "앱 검색",
|
||||||
"featured": "도구",
|
"featured": "추천",
|
||||||
"mcpTools": "MCP 도구",
|
"mcpTools": "MCP 도구",
|
||||||
"loading": "앱을 불러오는 중...",
|
"loading": "앱을 불러오는 중...",
|
||||||
"empty": "검색과 일치하는 도구가 없습니다.",
|
"empty": "검색과 일치하는 도구가 없습니다.",
|
||||||
|
|||||||
@@ -241,7 +241,7 @@
|
|||||||
"activityMode": "Escolha quanto detalhe de atividade do agente é exibido por padrão.",
|
"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.",
|
"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.",
|
"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.",
|
"maxResults": "Resultados retornados por cada chamada de web_search.",
|
||||||
"timeout": "Segundos antes de uma requisição de busca expirar.",
|
"timeout": "Segundos antes de uma requisição de busca expirar.",
|
||||||
"jinaReader": "Usa o Jina Reader para web_fetch quando disponível.",
|
"jinaReader": "Usa o Jina Reader para web_fetch quando disponível.",
|
||||||
@@ -354,7 +354,10 @@
|
|||||||
"enabled": "Habilitado",
|
"enabled": "Habilitado",
|
||||||
"setup": "Conectar",
|
"setup": "Conectar",
|
||||||
"configure": "Conectar",
|
"configure": "Conectar",
|
||||||
|
"reconnect": "Reconectar",
|
||||||
"connectTitle": "Conectar {{name}}",
|
"connectTitle": "Conectar {{name}}",
|
||||||
|
"reconnectTitle": "Reconectar {{name}}",
|
||||||
|
"actionsTitle": "Ações para {{name}}",
|
||||||
"connectHint": "Adicione a chave a partir das configurações da sua conta.",
|
"connectHint": "Adicione a chave a partir das configurações da sua conta.",
|
||||||
"saveAndEnable": "Salvar e habilitar",
|
"saveAndEnable": "Salvar e habilitar",
|
||||||
"updateSetup": "Atualizar configuração",
|
"updateSetup": "Atualizar configuração",
|
||||||
@@ -554,9 +557,6 @@
|
|||||||
"capabilityOpenAISearch": "Pesquisa web da OpenAI",
|
"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."
|
"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": {
|
"image": {
|
||||||
"selectProvider": "Selecionar provedor",
|
"selectProvider": "Selecionar provedor",
|
||||||
"selectAspect": "Selecionar proporção",
|
"selectAspect": "Selecionar proporção",
|
||||||
@@ -603,6 +603,14 @@
|
|||||||
},
|
},
|
||||||
"apps": {
|
"apps": {
|
||||||
"description": "Adicione ferramentas ao nanobot e mencione-as com @ na conversa.",
|
"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",
|
"cliLabel": "Aplicativo",
|
||||||
"mcpLabel": "MCP",
|
"mcpLabel": "MCP",
|
||||||
"channelLabel": "Canal",
|
"channelLabel": "Canal",
|
||||||
@@ -614,7 +622,7 @@
|
|||||||
"enabledSummary": "{{count}} prontos",
|
"enabledSummary": "{{count}} prontos",
|
||||||
"caption": "{{cli}} aplicativos · {{mcp}} ferramentas MCP",
|
"caption": "{{cli}} aplicativos · {{mcp}} ferramentas MCP",
|
||||||
"searchPlaceholder": "Buscar ferramentas",
|
"searchPlaceholder": "Buscar ferramentas",
|
||||||
"featured": "Ferramentas",
|
"featured": "Destaques",
|
||||||
"mcpTools": "Ferramentas MCP",
|
"mcpTools": "Ferramentas MCP",
|
||||||
"loading": "Carregando aplicativos...",
|
"loading": "Carregando aplicativos...",
|
||||||
"empty": "Nenhuma ferramenta corresponde à sua busca.",
|
"empty": "Nenhuma ferramenta corresponde à sua busca.",
|
||||||
|
|||||||
@@ -203,7 +203,7 @@
|
|||||||
"currentModel": "Dùng cho các phản hồi mới.",
|
"currentModel": "Dùng cho các phản hồi mới.",
|
||||||
"selectedModelProvider": "Được đặt bởi mô hình đã chọn.",
|
"selectedModelProvider": "Được đặt bởi mô hình đã chọn.",
|
||||||
"selectedModelValue": "Đượ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.",
|
"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.",
|
"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.",
|
"logs": "Mở thư mục nhật ký của bộ máy gốc.",
|
||||||
@@ -539,7 +539,10 @@
|
|||||||
"enabled": "Đã bật",
|
"enabled": "Đã bật",
|
||||||
"setup": "Kết nối",
|
"setup": "Kết nối",
|
||||||
"configure": "Kết nối",
|
"configure": "Kết nối",
|
||||||
|
"reconnect": "Kết nối lại",
|
||||||
"connectTitle": "Kết nối {{name}}",
|
"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.",
|
"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",
|
"saveAndEnable": "Lưu và bật",
|
||||||
"updateSetup": "Cập nhật thiết lập",
|
"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.",
|
"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"
|
"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": {
|
"apps": {
|
||||||
"description": "Thêm công cụ vào nanobot, sau đó dùng @ trong cuộc trò chuyện.",
|
"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",
|
"cliLabel": "Ứng dụng",
|
||||||
"mcpLabel": "MCP",
|
"mcpLabel": "MCP",
|
||||||
"channelLabel": "Kênh",
|
"channelLabel": "Kênh",
|
||||||
@@ -600,7 +608,7 @@
|
|||||||
"enabledSummary": "{{count}} sẵn sàng",
|
"enabledSummary": "{{count}} sẵn sàng",
|
||||||
"caption": "{{cli}} ứng dụng · {{mcp}} công cụ MCP",
|
"caption": "{{cli}} ứng dụng · {{mcp}} công cụ MCP",
|
||||||
"searchPlaceholder": "Tìm ứng dụng",
|
"searchPlaceholder": "Tìm ứng dụng",
|
||||||
"featured": "Công cụ",
|
"featured": "Nổi bật",
|
||||||
"mcpTools": "Công cụ MCP",
|
"mcpTools": "Công cụ MCP",
|
||||||
"loading": "Đang tải ứng dụng...",
|
"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.",
|
"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": "选择默认显示多少智能体活动详情。",
|
"activityMode": "选择默认显示多少智能体活动详情。",
|
||||||
"fileEditDisplay": "选择文件编辑活动默认显示行数还是差异。",
|
"fileEditDisplay": "选择文件编辑活动默认显示行数还是差异。",
|
||||||
"codeWrap": "让长代码行在小屏幕上也易读。",
|
"codeWrap": "让长代码行在小屏幕上也易读。",
|
||||||
"brandLogos": "在设置中显示第三方提供商和 CLI 图标。",
|
"brandLogos": "从外部图标服务加载第三方品牌 Logo;关闭后使用本地首字母图标。",
|
||||||
"maxResults": "每次 web_search 调用返回的结果数。",
|
"maxResults": "每次 web_search 调用返回的结果数。",
|
||||||
"timeout": "搜索提供商请求超时前等待的秒数。",
|
"timeout": "搜索提供商请求超时前等待的秒数。",
|
||||||
"jinaReader": "可用时为 web_fetch 使用 Jina Reader。",
|
"jinaReader": "可用时为 web_fetch 使用 Jina Reader。",
|
||||||
@@ -354,7 +354,10 @@
|
|||||||
"enabled": "已启用",
|
"enabled": "已启用",
|
||||||
"setup": "连接",
|
"setup": "连接",
|
||||||
"configure": "连接",
|
"configure": "连接",
|
||||||
|
"reconnect": "重新连接",
|
||||||
"connectTitle": "连接 {{name}}",
|
"connectTitle": "连接 {{name}}",
|
||||||
|
"reconnectTitle": "重新连接 {{name}}",
|
||||||
|
"actionsTitle": "{{name}} 操作",
|
||||||
"connectHint": "填入账户中的密钥。",
|
"connectHint": "填入账户中的密钥。",
|
||||||
"saveAndEnable": "保存并启用",
|
"saveAndEnable": "保存并启用",
|
||||||
"updateSetup": "更新配置",
|
"updateSetup": "更新配置",
|
||||||
@@ -554,9 +557,6 @@
|
|||||||
"capabilityOpenAISearch": "OpenAI 联网搜索",
|
"capabilityOpenAISearch": "OpenAI 联网搜索",
|
||||||
"capabilityOpenAISearchHelp": "允许兼容 Responses API 的模型搜索网络,并在对话中展示搜索过程。"
|
"capabilityOpenAISearchHelp": "允许兼容 Responses API 的模型搜索网络,并在对话中展示搜索过程。"
|
||||||
},
|
},
|
||||||
"legal": {
|
|
||||||
"thirdPartyBrands": "产品名称、Logo 和品牌归各自所有者所有;此处仅用于识别,不代表背书或合作。"
|
|
||||||
},
|
|
||||||
"image": {
|
"image": {
|
||||||
"selectProvider": "选择提供商",
|
"selectProvider": "选择提供商",
|
||||||
"selectAspect": "选择比例",
|
"selectAspect": "选择比例",
|
||||||
@@ -603,6 +603,14 @@
|
|||||||
},
|
},
|
||||||
"apps": {
|
"apps": {
|
||||||
"description": "将工具接入 nanobot,然后在对话中通过 @ 调用。",
|
"description": "将工具接入 nanobot,然后在对话中通过 @ 调用。",
|
||||||
|
"discover": "发现",
|
||||||
|
"installed": "已安装",
|
||||||
|
"allApps": "全部应用",
|
||||||
|
"addCustom": "自定义接入",
|
||||||
|
"emptyInstalled": "还没有安装应用。",
|
||||||
|
"searchResults": "搜索结果",
|
||||||
|
"browseAll": "浏览全部",
|
||||||
|
"nextFeatured": "换一批",
|
||||||
"cliLabel": "应用",
|
"cliLabel": "应用",
|
||||||
"mcpLabel": "MCP",
|
"mcpLabel": "MCP",
|
||||||
"channelLabel": "渠道",
|
"channelLabel": "渠道",
|
||||||
@@ -614,7 +622,7 @@
|
|||||||
"enabledSummary": "{{count}} 个可用",
|
"enabledSummary": "{{count}} 个可用",
|
||||||
"caption": "{{cli}} 个应用 · {{mcp}} 个 MCP 工具",
|
"caption": "{{cli}} 个应用 · {{mcp}} 个 MCP 工具",
|
||||||
"searchPlaceholder": "搜索工具",
|
"searchPlaceholder": "搜索工具",
|
||||||
"featured": "工具",
|
"featured": "精选",
|
||||||
"mcpTools": "MCP 工具",
|
"mcpTools": "MCP 工具",
|
||||||
"loading": "正在加载应用...",
|
"loading": "正在加载应用...",
|
||||||
"empty": "没有与搜索条件匹配的工具。",
|
"empty": "没有与搜索条件匹配的工具。",
|
||||||
|
|||||||
@@ -203,7 +203,7 @@
|
|||||||
"currentModel": "用於新的回覆。",
|
"currentModel": "用於新的回覆。",
|
||||||
"selectedModelProvider": "由選取的模型決定。",
|
"selectedModelProvider": "由選取的模型決定。",
|
||||||
"selectedModelValue": "由選取的模型決定。",
|
"selectedModelValue": "由選取的模型決定。",
|
||||||
"brandLogos": "在設定中顯示第三方供應商與 CLI 圖示。",
|
"brandLogos": "從外部圖示服務載入第三方品牌 Logo;關閉後使用本機首字母圖示。",
|
||||||
"cliAppsCatalog": "只安裝 nanobot 可在本機執行的應用程式專用 CLI 轉接器;不會改動原生應用程式。",
|
"cliAppsCatalog": "只安裝 nanobot 可在本機執行的應用程式專用 CLI 轉接器;不會改動原生應用程式。",
|
||||||
"cliAppsFilter": "依應用程式、類別或功能搜尋。",
|
"cliAppsFilter": "依應用程式、類別或功能搜尋。",
|
||||||
"logs": "開啟原生引擎日誌資料夾。",
|
"logs": "開啟原生引擎日誌資料夾。",
|
||||||
@@ -539,7 +539,10 @@
|
|||||||
"enabled": "已啟用",
|
"enabled": "已啟用",
|
||||||
"setup": "連線",
|
"setup": "連線",
|
||||||
"configure": "連線",
|
"configure": "連線",
|
||||||
|
"reconnect": "重新連線",
|
||||||
"connectTitle": "連線 {{name}}",
|
"connectTitle": "連線 {{name}}",
|
||||||
|
"reconnectTitle": "重新連線 {{name}}",
|
||||||
|
"actionsTitle": "{{name}} 操作",
|
||||||
"connectHint": "請從帳號設定新增金鑰。",
|
"connectHint": "請從帳號設定新增金鑰。",
|
||||||
"saveAndEnable": "儲存並啟用",
|
"saveAndEnable": "儲存並啟用",
|
||||||
"updateSetup": "更新設定",
|
"updateSetup": "更新設定",
|
||||||
@@ -584,11 +587,16 @@
|
|||||||
"title": "可觀測性", "configured": "nanobot 已偵測到追蹤憑證。",
|
"title": "可觀測性", "configured": "nanobot 已偵測到追蹤憑證。",
|
||||||
"environment": "設定 LANGFUSE_SECRET_KEY 和 LANGFUSE_PUBLIC_KEY 後重新啟動 nanobot。", "enable": "啟用追蹤支援"
|
"environment": "設定 LANGFUSE_SECRET_KEY 和 LANGFUSE_PUBLIC_KEY 後重新啟動 nanobot。", "enable": "啟用追蹤支援"
|
||||||
},
|
},
|
||||||
"legal": {
|
|
||||||
"thirdPartyBrands": "產品名稱、Logo 與品牌均為各自擁有者的財產。僅供識別之用,不代表任何形式的背書。"
|
|
||||||
},
|
|
||||||
"apps": {
|
"apps": {
|
||||||
"description": "將工具新增至 nanobot,再於聊天中使用 @ 指定工具。",
|
"description": "將工具新增至 nanobot,再於聊天中使用 @ 指定工具。",
|
||||||
|
"discover": "探索",
|
||||||
|
"installed": "已安裝",
|
||||||
|
"allApps": "全部應用",
|
||||||
|
"addCustom": "自訂接入",
|
||||||
|
"emptyInstalled": "尚未安裝應用。",
|
||||||
|
"searchResults": "搜尋結果",
|
||||||
|
"browseAll": "瀏覽全部",
|
||||||
|
"nextFeatured": "換一批",
|
||||||
"cliLabel": "應用程式",
|
"cliLabel": "應用程式",
|
||||||
"mcpLabel": "MCP",
|
"mcpLabel": "MCP",
|
||||||
"channelLabel": "通訊管道",
|
"channelLabel": "通訊管道",
|
||||||
@@ -600,7 +608,7 @@
|
|||||||
"enabledSummary": "{{count}} 個就緒",
|
"enabledSummary": "{{count}} 個就緒",
|
||||||
"caption": "{{cli}} 個應用程式 · {{mcp}} 個 MCP 工具",
|
"caption": "{{cli}} 個應用程式 · {{mcp}} 個 MCP 工具",
|
||||||
"searchPlaceholder": "搜尋工具",
|
"searchPlaceholder": "搜尋工具",
|
||||||
"featured": "工具",
|
"featured": "精選",
|
||||||
"mcpTools": "MCP 工具",
|
"mcpTools": "MCP 工具",
|
||||||
"loading": "正在載入應用程式…",
|
"loading": "正在載入應用程式…",
|
||||||
"empty": "沒有符合搜尋條件的工具。",
|
"empty": "沒有符合搜尋條件的工具。",
|
||||||
|
|||||||
+14
-1
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
ApiServicePayload,
|
ApiServicePayload,
|
||||||
|
AppsDiscoveryPayload,
|
||||||
AutomationsPayload,
|
AutomationsPayload,
|
||||||
AutomationUpdatePayload,
|
AutomationUpdatePayload,
|
||||||
ChannelConfigurePayload,
|
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(
|
export async function fetchInstalledCliApps(
|
||||||
token: string,
|
token: string,
|
||||||
base: string = "",
|
base: string = "",
|
||||||
@@ -766,7 +779,7 @@ export async function fetchProviderModels(
|
|||||||
|
|
||||||
export async function runMcpPresetAction(
|
export async function runMcpPresetAction(
|
||||||
transport: WebUIMutationTransport,
|
transport: WebUIMutationTransport,
|
||||||
action: "enable" | "remove" | "test",
|
action: "enable" | "disable" | "remove" | "test" | "reconnect",
|
||||||
name: string,
|
name: string,
|
||||||
values: Record<string, string> = {},
|
values: Record<string, string> = {},
|
||||||
): Promise<McpPresetsPayload> {
|
): Promise<McpPresetsPayload> {
|
||||||
|
|||||||
@@ -12,12 +12,15 @@ export interface LocalPreferences {
|
|||||||
|
|
||||||
export const LOCAL_PREFS_STORAGE_KEY = "nanobot-webui.settings-preferences";
|
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_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 = {
|
export const DEFAULT_LOCAL_PREFS: LocalPreferences = {
|
||||||
density: "comfortable",
|
density: "comfortable",
|
||||||
activityMode: "auto",
|
activityMode: "auto",
|
||||||
codeWrap: true,
|
codeWrap: true,
|
||||||
brandLogos: false,
|
brandLogos: true,
|
||||||
fileEditDisplayMode: "summary",
|
fileEditDisplayMode: "summary",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -29,12 +32,12 @@ export function readLocalPreferences(): LocalPreferences {
|
|||||||
try {
|
try {
|
||||||
const raw = window.localStorage.getItem(LOCAL_PREFS_STORAGE_KEY);
|
const raw = window.localStorage.getItem(LOCAL_PREFS_STORAGE_KEY);
|
||||||
if (!raw) return DEFAULT_LOCAL_PREFS;
|
if (!raw) return DEFAULT_LOCAL_PREFS;
|
||||||
const parsed = JSON.parse(raw) as Partial<LocalPreferences>;
|
const parsed = JSON.parse(raw) as StoredLocalPreferences;
|
||||||
return {
|
return {
|
||||||
density: parsed.density === "compact" ? "compact" : "comfortable",
|
density: parsed.density === "compact" ? "compact" : "comfortable",
|
||||||
activityMode: parsed.activityMode === "expanded" ? "expanded" : "auto",
|
activityMode: parsed.activityMode === "expanded" ? "expanded" : "auto",
|
||||||
codeWrap: parsed.codeWrap !== false,
|
codeWrap: parsed.codeWrap !== false,
|
||||||
brandLogos: parsed.brandLogos === true,
|
brandLogos: parsed.version === undefined ? true : parsed.brandLogos !== false,
|
||||||
fileEditDisplayMode: normalizeFileEditDisplayMode(parsed.fileEditDisplayMode),
|
fileEditDisplayMode: normalizeFileEditDisplayMode(parsed.fileEditDisplayMode),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
@@ -44,7 +47,10 @@ export function readLocalPreferences(): LocalPreferences {
|
|||||||
|
|
||||||
export function writeLocalPreferences(preferences: LocalPreferences): void {
|
export function writeLocalPreferences(preferences: LocalPreferences): void {
|
||||||
try {
|
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 {
|
} catch {
|
||||||
// Browser-only preferences should never block settings.
|
// 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[] {
|
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 {
|
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 {
|
export interface NanobotFeatureInfo {
|
||||||
name: string;
|
name: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
@@ -962,8 +969,10 @@ export interface McpPresetInfo {
|
|||||||
install_supported: boolean;
|
install_supported: boolean;
|
||||||
installed: boolean;
|
installed: boolean;
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
|
enabled?: boolean;
|
||||||
available: boolean;
|
available: boolean;
|
||||||
status: "not_installed" | "configured" | "missing_credentials" | "missing_dependency" | "coming_soon" | string;
|
status: "not_installed" | "configured" | "missing_credentials" | "missing_dependency" | "coming_soon" | string;
|
||||||
|
runtime_status?: "connecting" | "connected" | "failed" | string;
|
||||||
logo_url?: string | null;
|
logo_url?: string | null;
|
||||||
brand_color?: string | null;
|
brand_color?: string | null;
|
||||||
required_fields: McpPresetField[];
|
required_fields: McpPresetField[];
|
||||||
|
|||||||
@@ -28,3 +28,16 @@ initializeLoopbackRuntimeHost();
|
|||||||
|
|
||||||
/* StrictMode disabled: dev double-invokes state updaters; delta accumulation must stay pure — see useNanobotStream. */
|
/* StrictMode disabled: dev double-invokes state updaters; delta accumulation must stay pure — see useNanobotStream. */
|
||||||
ReactDOM.createRoot(root).render(<App />);
|
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" });
|
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" }));
|
fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" }));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await import("@/components/settings/SettingsView");
|
||||||
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole("navigation", { name: "Settings sections" }),
|
screen.getByRole("navigation", { name: "Settings sections" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(container.querySelectorAll("main")).toHaveLength(1);
|
expect(container.querySelectorAll("main")).toHaveLength(1);
|
||||||
expect(screen.getByRole("heading", { level: 1, name: "Settings" })).toBeInTheDocument();
|
expect(screen.getByRole("heading", { level: 1, name: "Settings" })).toBeInTheDocument();
|
||||||
@@ -2685,7 +2689,7 @@ describe("App layout", () => {
|
|||||||
fireEvent.click(appsButton);
|
fireEvent.click(appsButton);
|
||||||
|
|
||||||
expect(await screen.findByRole("heading", { name: "Apps" })).toBeInTheDocument();
|
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.getByRole("navigation", { name: "Sidebar navigation" })).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("navigation", { name: "Settings sections" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("navigation", { name: "Settings sections" })).not.toBeInTheDocument();
|
||||||
expect(within(sidebar).getByRole("button", { name: "Apps" })).toHaveAttribute(
|
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