From 704ac558f60b55939fb5c43740b78ae60d789738 Mon Sep 17 00:00:00 2001
From: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
Date: Sun, 24 May 2026 13:38:37 +0800
Subject: [PATCH] feat(mcp): add preset setup and capability mentions
---
.gitignore | 1 +
nanobot/agent/context.py | 30 +
nanobot/agent/loop.py | 32 +-
nanobot/agent/tools/mcp.py | 280 ++-
nanobot/bus/events.py | 7 +-
nanobot/channels/websocket.py | 158 +-
nanobot/config/schema.py | 2 +
nanobot/session/manager.py | 33 +
nanobot/webui/mcp_presets_api.py | 1172 ++++++++++
nanobot/webui/mcp_presets_runtime.py | 5 +
nanobot/webui/settings_api.py | 75 +-
nanobot/webui/transcript.py | 64 +-
tests/agent/test_mcp_connection.py | 176 ++
tests/agent/test_session_manager_history.py | 14 +
tests/channels/test_websocket_channel.py | 29 +-
tests/channels/test_websocket_http_routes.py | 150 ++
tests/tools/test_mcp_tool.py | 35 +-
tests/webui/test_mcp_presets_api.py | 363 +++
tests/webui/test_mcp_presets_runtime.py | 80 +
tests/webui/test_settings_api.py | 67 +
webui/src/components/CliAppMentionText.tsx | 161 +-
webui/src/components/MessageBubble.tsx | 55 +-
webui/src/components/SessionSearchDialog.tsx | 69 +-
.../src/components/settings/SettingsView.tsx | 2033 +++++++++++++++--
.../thread/AgentActivityCluster.tsx | 1121 +++++++--
.../src/components/thread/ThreadComposer.tsx | 350 ++-
.../src/components/thread/ThreadMessages.tsx | 6 +-
webui/src/components/thread/ThreadShell.tsx | 124 +-
.../src/components/thread/ThreadViewport.tsx | 5 +-
webui/src/hooks/useNanobotStream.ts | 3 +
webui/src/i18n/locales/en/common.json | 105 +-
webui/src/i18n/locales/es/common.json | 155 +-
webui/src/i18n/locales/fr/common.json | 155 +-
webui/src/i18n/locales/id/common.json | 155 +-
webui/src/i18n/locales/ja/common.json | 155 +-
webui/src/i18n/locales/ko/common.json | 155 +-
webui/src/i18n/locales/vi/common.json | 155 +-
webui/src/i18n/locales/zh-CN/common.json | 117 +-
webui/src/i18n/locales/zh-TW/common.json | 155 +-
webui/src/lib/api.ts | 93 +
webui/src/lib/mcp-preset-events.ts | 20 +
webui/src/lib/nanobot-client.ts | 8 +-
webui/src/lib/provider-brand.ts | 188 ++
webui/src/lib/types.ts | 95 +
.../src/tests/agent-activity-cluster.test.tsx | 255 ++-
webui/src/tests/api.test.ts | 106 +
webui/src/tests/app-layout.test.tsx | 45 +-
webui/src/tests/i18n.test.tsx | 1 -
webui/src/tests/message-bubble.test.tsx | 42 +-
webui/src/tests/nanobot-client.test.ts | 47 +
webui/src/tests/provider-brand.test.ts | 37 +
.../src/tests/session-search-dialog.test.tsx | 70 +
webui/src/tests/thread-composer.test.tsx | 115 +-
webui/src/tests/thread-shell.test.tsx | 4 +-
54 files changed, 8425 insertions(+), 708 deletions(-)
create mode 100644 nanobot/webui/mcp_presets_api.py
create mode 100644 nanobot/webui/mcp_presets_runtime.py
create mode 100644 tests/webui/test_mcp_presets_api.py
create mode 100644 tests/webui/test_mcp_presets_runtime.py
create mode 100644 tests/webui/test_settings_api.py
create mode 100644 webui/src/lib/mcp-preset-events.ts
create mode 100644 webui/src/lib/provider-brand.ts
create mode 100644 webui/src/tests/provider-brand.test.ts
create mode 100644 webui/src/tests/session-search-dialog.test.tsx
diff --git a/.gitignore b/.gitignore
index 17ebbc972..19b129a26 100644
--- a/.gitignore
+++ b/.gitignore
@@ -98,3 +98,4 @@ tmp/
temp/
*.tmp
exp/
+.playwright-mcp/
diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py
index 4ad2124e7..dac5e1d64 100644
--- a/nanobot/agent/context.py
+++ b/nanobot/agent/context.py
@@ -10,6 +10,10 @@ from typing import Any, Mapping, Sequence
from nanobot.agent.memory import MemoryStore
from nanobot.agent.skills import SkillsLoader
+from nanobot.agent.tools import mcp as mcp_tools
+from nanobot.agent.tools.registry import ToolRegistry
+from nanobot.bus.events import InboundMessage
+from nanobot.cli_apps import utils as cli_app_utils
from nanobot.session.goal_state import goal_state_runtime_lines
from nanobot.utils.helpers import (
current_time_str,
@@ -19,6 +23,32 @@ from nanobot.utils.helpers import (
from nanobot.utils.prompt_templates import render_template
+def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
+ """Return persisted kwargs for turn-attached capabilities."""
+ return cli_app_utils.session_extra(metadata) | mcp_tools.session_extra(metadata)
+
+
+def runtime_lines(state: Any, msg: Any, workspace: Path, *, skip: bool = False) -> list[str]:
+ """Return model-visible runtime annotations for turn-attached capabilities."""
+ return [
+ *cli_app_utils.runtime_lines(msg, workspace, skip=skip),
+ *mcp_tools.runtime_lines(
+ msg,
+ configured_server_names=set(state._mcp_servers),
+ connected_server_names=set(state._mcp_stacks),
+ skip=skip,
+ ),
+ ]
+
+
+async def connect_mcp(state: Any, tools: ToolRegistry) -> None:
+ await mcp_tools.connect_missing_servers(state, tools)
+
+
+async def handle_runtime_control(state: Any, msg: InboundMessage, tools: ToolRegistry) -> bool:
+ return await mcp_tools.handle_runtime_control(state, msg, tools)
+
+
class ContextBuilder:
"""Builds the context (system prompt + messages) for the agent."""
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index b9e459af1..e396212ab 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -14,6 +14,7 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable
from loguru import logger
+from nanobot.agent import context as agent_context
from nanobot.agent import model_presets as preset_helpers
from nanobot.agent.autocompact import AutoCompact
from nanobot.agent.context import ContextBuilder
@@ -28,7 +29,6 @@ from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.self import MyTool
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.queue import MessageBus
-from nanobot.cli_apps import utils as cli_app_utils
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
from nanobot.config.schema import AgentDefaults, ModelPresetConfig
from nanobot.providers.base import LLMProvider
@@ -476,26 +476,8 @@ class AgentLoop:
logger.info("Registered {} tools: {}", len(registered), registered)
async def _connect_mcp(self) -> None:
- """Connect to configured MCP servers (one-time, lazy)."""
- if self._mcp_connected or self._mcp_connecting or not self._mcp_servers:
- return
- self._mcp_connecting = True
- from nanobot.agent.tools.mcp import connect_mcp_servers
-
- try:
- self._mcp_stacks = await connect_mcp_servers(self._mcp_servers, self.tools)
- if self._mcp_stacks:
- self._mcp_connected = True
- else:
- logger.warning("No MCP servers connected successfully (will retry next message)")
- except asyncio.CancelledError:
- logger.warning("MCP connection cancelled (will retry next message)")
- self._mcp_stacks.clear()
- except BaseException as e:
- logger.warning("Failed to connect MCP servers (will retry next message): {}", e)
- self._mcp_stacks.clear()
- finally:
- self._mcp_connecting = False
+ """Connect configured MCP servers."""
+ await agent_context.connect_mcp(self, self.tools)
def _set_tool_context(
self, channel: str, chat_id: str,
@@ -568,7 +550,7 @@ class AgentLoop:
media_paths = [p for p in (msg.media or []) if isinstance(p, str) and p]
has_text = isinstance(msg.content, str) and msg.content.strip()
if has_text or media_paths:
- extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | cli_app_utils.session_extra(msg.metadata)
+ extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | agent_context.session_extra(msg.metadata)
extra.update(kwargs)
text = msg.content if isinstance(msg.content, str) else ""
session.add_message("user", text, **extra)
@@ -593,7 +575,7 @@ class AgentLoop:
chat_id=self._runtime_chat_id(msg),
sender_id=msg.sender_id,
session_summary=pending_summary,
- session_metadata=session.metadata, current_runtime_lines=cli_app_utils.runtime_lines(msg, self.context.workspace),
+ session_metadata=session.metadata, current_runtime_lines=agent_context.runtime_lines(self, msg, self.context.workspace),
)
async def _dispatch_command_inline(
@@ -811,6 +793,8 @@ class AgentLoop:
logger.warning("Error consuming inbound message: {}, continuing...", e)
continue
+ if await agent_context.handle_runtime_control(self, msg, self.tools):
+ continue
raw = msg.content.strip()
if self.commands.is_priority(raw):
await self._dispatch_command_inline(
@@ -1058,7 +1042,7 @@ class AgentLoop:
current_role=current_role,
sender_id=msg.sender_id,
session_summary=pending,
- session_metadata=session.metadata, current_runtime_lines=cli_app_utils.runtime_lines(msg, self.context.workspace, skip=is_subagent),
+ session_metadata=session.metadata, current_runtime_lines=agent_context.runtime_lines(self, msg, self.context.workspace, skip=is_subagent),
)
t_wall = time.time()
final_content, _, all_msgs, stop_reason, _ = await self._run_agent_loop(
diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py
index 73c0850d5..e26a434db 100644
--- a/nanobot/agent/tools/mcp.py
+++ b/nanobot/agent/tools/mcp.py
@@ -6,13 +6,20 @@ import re
import shutil
import urllib.parse
from contextlib import AsyncExitStack, suppress
-from typing import Any
+from typing import Any, Mapping
+from weakref import WeakKeyDictionary
import httpx
from loguru import logger
from nanobot.agent.tools.base import Tool
from nanobot.agent.tools.registry import ToolRegistry
+from nanobot.bus.events import (
+ INBOUND_META_RUNTIME_CONTROL,
+ RUNTIME_CONTROL_ACK,
+ RUNTIME_CONTROL_MCP_RELOAD,
+ InboundMessage,
+)
# Transient connection errors that warrant a single retry.
# These typically happen when an MCP server restarts or a network
@@ -33,6 +40,7 @@ _WINDOWS_SHELL_LAUNCHERS: frozenset[str] = frozenset(("npx", "npm", "pnpm", "yar
# Characters allowed in tool names by model providers (Anthropic, OpenAI, etc.).
# Replace anything outside [a-zA-Z0-9_-] with underscore and collapse runs.
_SANITIZE_RE = re.compile(r"_+")
+_RELOAD_LOCKS: WeakKeyDictionary[Any, asyncio.Lock] = WeakKeyDictionary()
def _sanitize_name(name: str) -> str:
@@ -503,6 +511,7 @@ async def connect_mcp_servers(
command=command,
args=args,
env=env,
+ cwd=cfg.cwd or None,
)
read, write = await server_stack.enter_async_context(stdio_client(params))
elif transport_type == "sse":
@@ -662,3 +671,272 @@ async def connect_mcp_servers(
server_stacks[result[0]] = result[1]
return server_stacks
+
+
+def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
+ """Return persisted session kwargs for MCP preset attachments."""
+ mcp_presets = metadata.get("mcp_presets") if isinstance(metadata, Mapping) else None
+ return {"mcp_presets": mcp_presets} if isinstance(mcp_presets, list) and mcp_presets else {}
+
+
+def runtime_lines(
+ message: Any,
+ *,
+ available_server_names: set[str] | None = None,
+ configured_server_names: set[str] | None = None,
+ connected_server_names: set[str] | None = None,
+ skip: bool = False,
+) -> list[str]:
+ """Return model-visible MCP preset annotations for the current turn."""
+ if skip:
+ return []
+ if configured_server_names is None:
+ configured_server_names = available_server_names
+ if connected_server_names is None:
+ connected_server_names = available_server_names
+ metadata = message.metadata if isinstance(getattr(message, "metadata", None), Mapping) else None
+ structured = metadata.get("mcp_presets") if isinstance(metadata, Mapping) else None
+ if not isinstance(structured, list):
+ return []
+
+ lines: list[str] = []
+ for item in structured[:8]:
+ if not isinstance(item, Mapping):
+ continue
+ raw_name = str(item.get("name") or "").strip().lower()
+ if not raw_name:
+ continue
+ display = str(item.get("display_name") or raw_name).strip() or raw_name
+ transport = str(item.get("transport") or "mcp").strip() or "mcp"
+ prefix = f"mcp_{raw_name}_"
+ if configured_server_names is not None and raw_name not in configured_server_names:
+ lines.append(
+ "MCP Preset Attachment: "
+ f"@{raw_name} ({display}; transport={transport}) is configured in WebUI Settings, "
+ "but this gateway has not loaded the latest MCP settings yet. "
+ f"Tools with prefix `{prefix}` may not be available yet; if they are missing, "
+ "tell the user to restart nanobot."
+ )
+ continue
+ if connected_server_names is not None and raw_name not in connected_server_names:
+ lines.append(
+ "MCP Preset Attachment: "
+ f"@{raw_name} ({display}; transport={transport}) is configured, "
+ "but its MCP connection is not currently live. "
+ f"Tools with prefix `{prefix}` may be unavailable; tell the user to open Settings, "
+ "run the preset test, and restart nanobot only if hot reload is unavailable."
+ )
+ continue
+ lines.append(
+ "MCP Preset Attachment: "
+ f"@{raw_name} ({display}; transport={transport}; tool_prefix={prefix}). "
+ f"Prefer available tools whose names start with `{prefix}` for this request; "
+ "do not substitute shell commands for this MCP integration unless the user asks."
+ )
+ return lines
+
+
+async def connect_missing_servers(state: Any, registry: ToolRegistry) -> None:
+ """Connect configured MCP servers that are not currently live."""
+ missing_servers = {
+ name: cfg for name, cfg in state._mcp_servers.items() if name not in state._mcp_stacks
+ }
+ if state._mcp_connecting or not missing_servers:
+ return
+ state._mcp_connecting = True
+ try:
+ connected = await connect_mcp_servers(missing_servers, registry)
+ state._mcp_stacks.update(connected)
+ state._mcp_connected = bool(state._mcp_stacks)
+ if connected:
+ logger.info("MCP connected servers: {}", sorted(connected))
+ else:
+ logger.warning("No MCP servers connected successfully (will retry next message)")
+ except asyncio.CancelledError:
+ logger.warning("MCP connection cancelled (will retry next message)")
+ state._mcp_connected = bool(state._mcp_stacks)
+ except BaseException as e:
+ logger.warning("Failed to connect MCP servers (will retry next message): {}", e)
+ state._mcp_connected = bool(state._mcp_stacks)
+ finally:
+ state._mcp_connecting = False
+
+
+async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
+ """Reconcile live MCP connections with the current config file."""
+ async with _reload_lock(state):
+ try:
+ from nanobot.config.loader import (load_config,
+ resolve_config_env_vars)
+
+ config = resolve_config_env_vars(load_config())
+ next_servers = dict(config.tools.mcp_servers)
+ except Exception as exc:
+ logger.warning("MCP hot reload could not read config: {}", exc)
+ return {
+ "ok": False,
+ "message": "Could not reload MCP config. Restart nanobot to pick up changes.",
+ "requires_restart": True,
+ "error": str(exc),
+ }
+
+ current_servers = dict(state._mcp_servers)
+ current_names = set(current_servers)
+ next_names = set(next_servers)
+ removed = sorted(current_names - next_names)
+ added = sorted(next_names - current_names)
+ changed = sorted(
+ name
+ for name in current_names & next_names
+ if _server_signature(current_servers[name]) != _server_signature(next_servers[name])
+ )
+
+ tools_removed = 0
+ for name in [*removed, *changed]:
+ tools_removed += _unregister_server_tools(state, registry, name)
+ await _close_server(state, name)
+
+ state._mcp_servers = next_servers
+ retry_missing = sorted(
+ name
+ for name in next_names
+ if name not in state._mcp_stacks and name not in set(added) | set(changed)
+ )
+ to_connect_names = sorted(set(added) | set(changed) | set(retry_missing))
+ to_connect = {name: next_servers[name] for name in to_connect_names}
+ connected: dict[str, AsyncExitStack] = {}
+ if to_connect:
+ connected = await connect_mcp_servers(to_connect, registry)
+ state._mcp_stacks.update(connected)
+
+ state._mcp_connected = bool(state._mcp_stacks)
+ failed = sorted(set(to_connect) - set(connected))
+ unchanged = not removed and not added and not changed and not retry_missing
+ ok = not failed
+ if failed:
+ message = "MCP config reloaded, but some servers did not connect: " + ", ".join(failed)
+ elif unchanged:
+ message = "MCP config is already live."
+ elif retry_missing and not added and not changed and not removed:
+ message = "MCP connections refreshed without restarting nanobot."
+ else:
+ message = "MCP config reloaded without restarting nanobot."
+
+ logger.info(
+ "MCP hot reload: added={} changed={} removed={} retried={} connected={} failed={} tools_removed={}",
+ added,
+ changed,
+ removed,
+ retry_missing,
+ sorted(connected),
+ failed,
+ tools_removed,
+ )
+ return {
+ "ok": ok,
+ "message": message,
+ "added": added,
+ "changed": changed,
+ "removed": removed,
+ "retried": retry_missing,
+ "connected": sorted(state._mcp_stacks),
+ "configured": sorted(state._mcp_servers),
+ "failed": failed,
+ "tools_removed": tools_removed,
+ "requires_restart": False,
+ }
+
+
+async def request_mcp_reload(bus: Any, *, timeout: float = 15.0) -> dict[str, Any]:
+ """Ask the running agent loop to reconcile live MCP connections."""
+ loop = asyncio.get_running_loop()
+ ack: asyncio.Future[dict[str, Any]] = loop.create_future()
+ await bus.publish_inbound(
+ InboundMessage(
+ channel="system",
+ sender_id="webui-settings",
+ chat_id="runtime",
+ content=RUNTIME_CONTROL_MCP_RELOAD,
+ metadata={
+ INBOUND_META_RUNTIME_CONTROL: RUNTIME_CONTROL_MCP_RELOAD,
+ RUNTIME_CONTROL_ACK: ack,
+ },
+ )
+ )
+ try:
+ result = await asyncio.wait_for(ack, timeout=timeout)
+ except asyncio.TimeoutError:
+ return {
+ "ok": False,
+ "message": "MCP hot reload timed out. Restart nanobot to pick up changes.",
+ "requires_restart": True,
+ }
+ return result if isinstance(result, dict) else {
+ "ok": False,
+ "message": "MCP hot reload returned an unexpected response.",
+ "requires_restart": True,
+ }
+
+
+async def handle_runtime_control(state: Any, msg: InboundMessage, registry: ToolRegistry) -> bool:
+ metadata = msg.metadata if isinstance(msg.metadata, dict) else {}
+ control = metadata.get(INBOUND_META_RUNTIME_CONTROL)
+ if control != RUNTIME_CONTROL_MCP_RELOAD:
+ return False
+
+ ack = metadata.get(RUNTIME_CONTROL_ACK)
+ try:
+ result = await reload_servers(state, registry)
+ except Exception as exc:
+ logger.exception("MCP hot reload failed")
+ result = {
+ "ok": False,
+ "message": "MCP hot reload failed. Restart nanobot to pick up changes.",
+ "requires_restart": True,
+ "error": str(exc),
+ }
+ if isinstance(ack, asyncio.Future) and not ack.done():
+ ack.set_result(result)
+ return True
+
+
+def _reload_lock(state: Any) -> asyncio.Lock:
+ try:
+ return _RELOAD_LOCKS[state]
+ except KeyError:
+ lock = asyncio.Lock()
+ _RELOAD_LOCKS[state] = lock
+ return lock
+
+
+def _server_signature(cfg: Any) -> Any:
+ if hasattr(cfg, "model_dump"):
+ return cfg.model_dump(mode="json")
+ return cfg
+
+
+def _tool_prefix(server_name: str) -> str:
+ safe_name = "".join(ch if ch.isalnum() or ch in {"_", "-"} else "_" for ch in server_name)
+ while "__" in safe_name:
+ safe_name = safe_name.replace("__", "_")
+ return f"mcp_{safe_name}_"
+
+
+def _unregister_server_tools(state: Any, registry: ToolRegistry, server_name: str) -> int:
+ prefix = _tool_prefix(server_name)
+ removed = 0
+ for tool_name in list(registry.tool_names):
+ if tool_name.startswith(prefix):
+ registry.unregister(tool_name)
+ removed += 1
+ return removed
+
+
+async def _close_server(state: Any, server_name: str) -> None:
+ stack = state._mcp_stacks.pop(server_name, None)
+ if stack is None:
+ return
+ try:
+ await stack.aclose()
+ except (RuntimeError, BaseExceptionGroup):
+ logger.debug("MCP server '{}' cleanup error (can be ignored)", server_name)
diff --git a/nanobot/bus/events.py b/nanobot/bus/events.py
index 636f9755f..713fe01d3 100644
--- a/nanobot/bus/events.py
+++ b/nanobot/bus/events.py
@@ -9,6 +9,12 @@ from typing import Any
# render it and other channels may ignore unknown keys.
OUTBOUND_META_AGENT_UI = "_agent_ui"
+# Internal-only inbound metadata used by in-process channels to ask the agent
+# loop to update runtime state without going through a user session.
+INBOUND_META_RUNTIME_CONTROL = "_runtime_control"
+RUNTIME_CONTROL_ACK = "_ack"
+RUNTIME_CONTROL_MCP_RELOAD = "mcp_reload"
+
@dataclass
class InboundMessage:
@@ -45,4 +51,3 @@ class OutboundMessage:
media: list[str] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict)
buttons: list[list[str]] = field(default_factory=list)
-
diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py
index 856274090..87b5fbd3a 100644
--- a/nanobot/channels/websocket.py
+++ b/nanobot/channels/websocket.py
@@ -30,6 +30,7 @@ from websockets.exceptions import ConnectionClosed
from websockets.http11 import Request as WsRequest
from websockets.http11 import Response
+from nanobot.agent.tools.mcp import request_mcp_reload
from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
@@ -46,6 +47,7 @@ from nanobot.utils.media_decode import (
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
from nanobot.webui.settings_api import (
WebUISettingsError,
+ create_model_configuration,
settings_payload,
update_agent_settings,
update_image_generation_settings,
@@ -57,12 +59,32 @@ from nanobot.webui.cli_apps_api import (
cli_apps_payload,
normalize_cli_app_mentions,
)
+from nanobot.webui.mcp_presets_api import (
+ mcp_presets_settings_action,
+ normalize_mcp_preset_mentions,
+)
from nanobot.webui.sidebar_state import (
read_webui_sidebar_state,
write_webui_sidebar_state,
)
from nanobot.webui.thread_disk import delete_webui_thread
-from nanobot.webui.transcript import append_transcript_object, build_webui_thread_response
+from nanobot.webui.transcript import (
+ append_transcript_object,
+ build_webui_thread_response,
+ rewrite_local_markdown_images,
+)
+
+_MCP_PRESET_ACTIONS_BY_PATH = {
+ "/api/settings/mcp-presets/enable": "enable",
+ "/api/settings/mcp-presets/remove": "remove",
+ "/api/settings/mcp-presets/test": "test",
+ "/api/settings/mcp-presets/custom": "custom",
+ "/api/settings/mcp-presets/import": "import",
+ "/api/settings/mcp-presets/import-cursor": "import-cursor",
+ "/api/settings/mcp-presets/tools": "tools",
+}
+_MCP_VALUES_HEADER = "X-Nanobot-MCP-Values"
+_MCP_VALUES_HEADER_MAX_BYTES = 64 * 1024
if TYPE_CHECKING:
from nanobot.session.manager import SessionManager
@@ -233,6 +255,34 @@ def _parse_query(path_with_query: str) -> dict[str, list[str]]:
return _parse_request_path(path_with_query)[1]
+def _parse_mcp_settings_query(request: WsRequest) -> dict[str, list[str]]:
+ query = _parse_query(request.path)
+ raw = request.headers.get(_MCP_VALUES_HEADER)
+ if not raw:
+ return query
+ if len(raw.encode("utf-8")) > _MCP_VALUES_HEADER_MAX_BYTES:
+ raise WebUISettingsError("MCP settings payload is too large")
+ try:
+ payload = json.loads(raw)
+ except json.JSONDecodeError as exc:
+ raise WebUISettingsError("invalid MCP settings payload") from exc
+ if not isinstance(payload, dict):
+ raise WebUISettingsError("MCP settings payload must be a JSON object")
+ merged = {key: list(values) for key, values in query.items()}
+ for key, value in payload.items():
+ if not isinstance(key, str) or not key:
+ raise WebUISettingsError("MCP settings payload contains an invalid key")
+ if value is None:
+ continue
+ if isinstance(value, str):
+ text = value.strip()
+ else:
+ text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
+ if text:
+ merged[key] = [text]
+ return merged
+
+
def _query_first(query: dict[str, list[str]], key: str) -> str | None:
"""Return the first value for *key*, or None."""
values = query.get(key)
@@ -425,18 +475,6 @@ _MEDIA_ALLOWED_MIMES: frozenset[str] = frozenset({
"video/webm",
"video/quicktime",
})
-_MARKDOWN_LOCAL_IMAGE_RE = re.compile(
- r"!\[([^\]]*)\]\((<[^>]+>|[^)\s]+)(\s+(?:\"[^\"]*\"|'[^']*'))?\)"
-)
-_INLINE_MARKDOWN_IMAGE_EXTS: frozenset[str] = frozenset({
- ".png",
- ".jpg",
- ".jpeg",
- ".webp",
- ".gif",
-})
-
-
def _issue_route_secret_matches(headers: Any, configured_secret: str) -> bool:
"""Return True if the token-issue HTTP request carries credentials matching ``token_issue_secret``."""
if not configured_secret:
@@ -666,6 +704,9 @@ class WebSocketChannel(BaseChannel):
if got == "/api/settings/update":
return self._handle_settings_update(request)
+ if got == "/api/settings/model-configurations/create":
+ return self._handle_settings_model_configuration_create(request)
+
if got == "/api/settings/provider/update":
return self._handle_settings_provider_update(request)
@@ -690,6 +731,13 @@ class WebSocketChannel(BaseChannel):
if got == "/api/settings/cli-apps/test":
return await self._handle_settings_cli_apps_action(request, "test")
+ if got == "/api/settings/mcp-presets":
+ return await self._handle_settings_mcp_presets(request)
+
+ mcp_action = _MCP_PRESET_ACTIONS_BY_PATH.get(got)
+ if mcp_action is not None:
+ return await self._handle_settings_mcp_presets(request, mcp_action)
+
m = re.match(r"^/api/sessions/([^/]+)/messages$", got)
if m:
return self._handle_session_messages(request, m.group(1))
@@ -881,6 +929,16 @@ class WebSocketChannel(BaseChannel):
self._with_settings_restart_state(payload, section="runtime")
)
+ def _handle_settings_model_configuration_create(self, request: WsRequest) -> Response:
+ if not self._check_api_token(request):
+ return _http_error(401, "Unauthorized")
+ query = _parse_query(request.path)
+ try:
+ payload = create_model_configuration(query)
+ except WebUISettingsError as e:
+ return _http_error(e.status, e.message)
+ return _http_json_response(self._with_settings_restart_state(payload))
+
def _handle_settings_provider_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
@@ -937,6 +995,31 @@ class WebSocketChannel(BaseChannel):
return _http_error(status, message)
return _http_json_response(payload)
+ async def _handle_settings_mcp_presets(
+ self,
+ request: WsRequest,
+ action: str | None = None,
+ ) -> Response:
+ if not self._check_api_token(request):
+ return _http_error(401, "Unauthorized")
+ try:
+ payload = await mcp_presets_settings_action(
+ action,
+ _parse_mcp_settings_query(request),
+ reload_mcp=lambda: request_mcp_reload(self.bus),
+ )
+ except Exception as e:
+ status = getattr(e, "status", 500)
+ message = getattr(e, "message", str(e))
+ if status >= 500:
+ self.logger.exception("MCP preset action '{}' failed", action or "list")
+ return _http_error(status, message)
+ if action is None:
+ return _http_json_response(payload)
+ return _http_json_response(
+ self._with_settings_restart_state(payload, section="runtime")
+ )
+
@staticmethod
def _is_websocket_channel_session_key(key: str) -> bool:
"""True when *key* is a ``websocket:…`` session exposed on this HTTP surface."""
@@ -1028,6 +1111,9 @@ class WebSocketChannel(BaseChannel):
cli_apps = meta.get("cli_apps")
if isinstance(cli_apps, list) and cli_apps:
user_obj["cli_apps"] = cli_apps
+ mcp_presets = meta.get("mcp_presets")
+ if isinstance(mcp_presets, list) and mcp_presets:
+ user_obj["mcp_presets"] = mcp_presets
self._try_append_webui_transcript(chat_id, user_obj)
await super()._handle_message(
sender_id,
@@ -1117,45 +1203,12 @@ class WebSocketChannel(BaseChannel):
return None
return {"url": signed, "name": path.name}
- def _markdown_image_url_for_local_path(self, raw_url: str) -> str | None:
- url = raw_url.strip()
- if url.startswith("<") and url.endswith(">"):
- url = url[1:-1].strip()
- if not url or url.startswith(("/api/media/", "#")):
- return None
- parsed = urlparse(url)
- if parsed.scheme or parsed.netloc:
- return None
- if parsed.query or parsed.fragment:
- return None
- path_text = unquote(url)
- if Path(path_text).suffix.lower() not in _INLINE_MARKDOWN_IMAGE_EXTS:
- return None
- candidate = Path(path_text).expanduser()
- if not candidate.is_absolute():
- candidate = self._workspace_path / candidate
- try:
- resolved = candidate.resolve(strict=False)
- resolved.relative_to(self._workspace_path)
- except (OSError, ValueError):
- return None
- if not resolved.is_file():
- return None
- signed = self._sign_or_stage_media_path(resolved)
- return signed["url"] if signed else None
-
def _rewrite_local_markdown_images(self, text: str) -> str:
- if "![" not in text:
- return text
-
- def replace(match: re.Match[str]) -> str:
- signed_url = self._markdown_image_url_for_local_path(match.group(2))
- if not signed_url:
- return match.group(0)
- title = match.group(3) or ""
- return f""
-
- return _MARKDOWN_LOCAL_IMAGE_RE.sub(replace, text)
+ return rewrite_local_markdown_images(
+ text,
+ workspace_path=self._workspace_path,
+ sign_path=self._sign_or_stage_media_path,
+ )
def _handle_media_fetch(self, sig: str, payload: str) -> Response:
"""Serve a single media file previously signed via
@@ -1531,6 +1584,9 @@ class WebSocketChannel(BaseChannel):
cli_apps = normalize_cli_app_mentions(envelope.get("cli_apps"))
if cli_apps:
metadata["cli_apps"] = cli_apps
+ mcp_presets = normalize_mcp_preset_mentions(envelope.get("mcp_presets"))
+ if mcp_presets:
+ metadata["mcp_presets"] = mcp_presets
image_generation = envelope.get("image_generation")
if isinstance(image_generation, dict) and image_generation.get("enabled") is True:
aspect_ratio = image_generation.get("aspect_ratio")
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index 093e3a7f3..5844faf8c 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -92,6 +92,7 @@ FallbackCandidate = str | InlineFallbackConfig
class ModelPresetConfig(Base):
"""A named set of model + generation parameters for quick switching."""
+ label: str | None = None
model: str
provider: str = "auto"
max_tokens: int = 8192
@@ -254,6 +255,7 @@ class MCPServerConfig(Base):
command: str = "" # Stdio: command to run (e.g. "npx")
args: list[str] = Field(default_factory=list) # Stdio: command arguments
env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars
+ cwd: str = "" # Stdio: working directory for MCP server runtime artifacts
url: str = "" # HTTP/SSE: endpoint URL
headers: dict[str, str] = Field(default_factory=dict) # HTTP/SSE: custom headers
tool_timeout: int = 30 # seconds before a tool call is cancelled
diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py
index 846ec381f..2b41537a9 100644
--- a/nanobot/session/manager.py
+++ b/nanobot/session/manager.py
@@ -27,6 +27,8 @@ _MESSAGE_TIME_PREFIX_RE = re.compile(r"^\[Message Time: [^\]]+\]\n?")
_LOCAL_IMAGE_BREADCRUMB_RE = re.compile(r"^\[image: (?:/|~)[^\]]+\]\s*$")
_TOOL_CALL_ECHO_RE = re.compile(r'^\s*(?:generate_image|message)\([^)]*\)\s*$')
_SESSION_PREVIEW_MAX_CHARS = 120
+_SESSION_LIST_PREVIEW_MAX_RECORDS = 200
+_SESSION_LIST_PREVIEW_MAX_CHARS = 1_000_000
def _sanitize_assistant_replay_text(content: str) -> str:
@@ -182,6 +184,28 @@ class Session:
if cli_lines:
breadcrumbs = "\n".join(cli_lines)
content = f"{content}\n{breadcrumbs}" if content else breadcrumbs
+ mcp_presets = message.get("mcp_presets")
+ if (
+ role == "user"
+ and isinstance(mcp_presets, list)
+ and mcp_presets
+ and isinstance(content, str)
+ ):
+ mcp_lines: list[str] = []
+ for item in mcp_presets[:8]:
+ if not isinstance(item, dict):
+ continue
+ name = str(item.get("name") or "").strip().lower()
+ if not name:
+ continue
+ transport = str(item.get("transport") or "mcp").strip() or "mcp"
+ mcp_lines.append(
+ f"[MCP Preset Attachment: @{name}; tool_prefix=mcp_{name}_; "
+ f"transport={transport}]"
+ )
+ if mcp_lines:
+ breadcrumbs = "\n".join(mcp_lines)
+ content = f"{content}\n{breadcrumbs}" if content else breadcrumbs
if include_timestamps:
content = self._annotate_message_time(message, content)
if role == "assistant" and isinstance(content, str) and not content.strip():
@@ -621,9 +645,18 @@ class SessionManager:
title = metadata.get("title") if isinstance(metadata, dict) else None
preview = ""
fallback_preview = ""
+ scanned_records = 0
+ scanned_chars = 0
for line in f:
if not line.strip():
continue
+ scanned_records += 1
+ scanned_chars += len(line)
+ if (
+ scanned_records > _SESSION_LIST_PREVIEW_MAX_RECORDS
+ or scanned_chars > _SESSION_LIST_PREVIEW_MAX_CHARS
+ ):
+ break
item = json.loads(line)
if item.get("_type") == "metadata":
continue
diff --git a/nanobot/webui/mcp_presets_api.py b/nanobot/webui/mcp_presets_api.py
new file mode 100644
index 000000000..d87747d8a
--- /dev/null
+++ b/nanobot/webui/mcp_presets_api.py
@@ -0,0 +1,1172 @@
+"""MCP preset helpers for the WebUI settings and message surfaces."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import os
+import re
+import shlex
+import shutil
+import urllib.parse
+from collections.abc import Awaitable, Callable
+from contextlib import suppress
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any, Literal, Mapping
+
+from nanobot.agent.tools.registry import ToolRegistry
+from nanobot.config.loader import load_config, resolve_config_env_vars, save_config
+from nanobot.config.paths import get_runtime_subdir
+from nanobot.config.schema import MCPServerConfig
+from nanobot.utils.helpers import ensure_dir
+
+QueryParams = dict[str, list[str]]
+
+_MCP_PRESET_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$", re.IGNORECASE)
+_SECRET_QUERY_RE = re.compile(
+ r"([?&](?:[^=&]*(?:api[_-]?key|token|secret|password|bearer)[^=&]*)=)[^\s]+",
+ re.IGNORECASE,
+)
+_SECRET_ASSIGNMENT_RE = re.compile(
+ r"((?:api[_-]?key|token|secret|password|bearer)(?:[=:]|\s+))[^,\s'\"&]+",
+ re.IGNORECASE,
+)
+_MCP_ATTACHMENT_KEYS = (
+ "name",
+ "display_name",
+ "category",
+ "transport",
+ "logo_url",
+ "brand_color",
+ "status",
+ "configured",
+)
+_MAX_TEST_TOOLS = 16
+_DEFAULT_TEST_TIMEOUT = 20
+_DEFAULT_CUSTOM_TIMEOUT = 30
+_CUSTOM_ACTIONS = {"custom", "import", "import-cursor", "tools"}
+
+McpReload = Callable[[], Awaitable[dict[str, Any]]]
+
+
+class McpPresetError(Exception):
+ """WebUI-facing MCP preset error."""
+
+ def __init__(self, message: str, status: int = 400):
+ super().__init__(message)
+ self.message = message
+ self.status = status
+
+
+@dataclass(frozen=True)
+class McpPresetField:
+ name: str
+ label: str
+ target: tuple[Literal["env", "url_param", "arg", "header"], str]
+ secret: bool = True
+ required: bool = True
+ env_var: str | None = None
+ placeholder: str = ""
+
+
+@dataclass(frozen=True)
+class McpPreset:
+ name: str
+ display_name: str
+ category: str
+ description: str
+ docs_url: str
+ transport: Literal["stdio", "streamableHttp", "sse", "oauth"]
+ install_supported: bool
+ brand_domain: str
+ brand_color: str
+ server: MCPServerConfig | None = None
+ fields: tuple[McpPresetField, ...] = ()
+ requires: str = ""
+ note: str = ""
+
+
+def _favicon_url(domain: str) -> str:
+ return f"https://www.google.com/s2/favicons?domain={domain}&sz=64"
+
+
+MCP_PRESETS: tuple[McpPreset, ...] = (
+ McpPreset(
+ name="browserbase",
+ display_name="Browserbase",
+ category="browser",
+ description="Cloud browser automation through Browserbase's hosted MCP server.",
+ docs_url="https://docs.browserbase.com/integrations/mcp/setup",
+ transport="streamableHttp",
+ install_supported=True,
+ brand_domain="browserbase.com",
+ brand_color="#111827",
+ requires="Browserbase API key",
+ server=MCPServerConfig(
+ type="streamableHttp",
+ url="https://mcp.browserbase.com/mcp",
+ tool_timeout=60,
+ ),
+ fields=(
+ McpPresetField(
+ name="browserbase_api_key",
+ label="Browserbase API key",
+ target=("url_param", "browserbaseApiKey"),
+ env_var="BROWSERBASE_API_KEY",
+ placeholder="bb_live_...",
+ ),
+ ),
+ ),
+ McpPreset(
+ name="playwright",
+ display_name="Playwright",
+ category="browser",
+ description="Local browser inspection and automation with the official Playwright MCP server.",
+ docs_url="https://playwright.dev/docs/getting-started-mcp",
+ transport="stdio",
+ install_supported=True,
+ brand_domain="playwright.dev",
+ brand_color="#2EAD33",
+ requires="Node.js and npx",
+ server=MCPServerConfig(
+ type="stdio",
+ command="npx",
+ args=["-y", "@playwright/mcp@latest"],
+ tool_timeout=60,
+ ),
+ ),
+ McpPreset(
+ name="context7",
+ display_name="Context7",
+ category="docs",
+ description="Fetch current library docs and code examples while the agent works.",
+ docs_url="https://context7.com/docs/resources/all-clients",
+ transport="stdio",
+ install_supported=True,
+ brand_domain="context7.com",
+ brand_color="#111827",
+ requires="Node.js and npx; API key optional",
+ server=MCPServerConfig(
+ type="stdio",
+ command="npx",
+ args=["-y", "@upstash/context7-mcp@latest"],
+ tool_timeout=45,
+ ),
+ fields=(
+ McpPresetField(
+ name="context7_api_key",
+ label="Context7 API key",
+ target=("arg", "--api-key"),
+ env_var="CONTEXT7_API_KEY",
+ placeholder="ctx7_...",
+ required=False,
+ ),
+ ),
+ note="Works without a key for basic public docs; add a key for higher limits or private docs.",
+ ),
+ McpPreset(
+ name="firecrawl",
+ display_name="Firecrawl",
+ category="web",
+ description="Scrape, crawl, search, and extract web pages through Firecrawl's MCP server.",
+ docs_url="https://docs.firecrawl.dev/use-cases/developers-mcp",
+ transport="stdio",
+ install_supported=True,
+ brand_domain="firecrawl.dev",
+ brand_color="#EB5E28",
+ requires="Node.js, npx, and Firecrawl API key",
+ server=MCPServerConfig(
+ type="stdio",
+ command="npx",
+ args=["-y", "firecrawl-mcp"],
+ tool_timeout=60,
+ ),
+ fields=(
+ McpPresetField(
+ name="firecrawl_api_key",
+ label="Firecrawl API key",
+ target=("env", "FIRECRAWL_API_KEY"),
+ env_var="FIRECRAWL_API_KEY",
+ placeholder="fc-...",
+ ),
+ ),
+ ),
+ McpPreset(
+ name="exa",
+ display_name="Exa",
+ category="web",
+ description="Search the web and fetch clean page content through Exa's hosted MCP server.",
+ docs_url="https://exa.ai/mcp",
+ transport="streamableHttp",
+ install_supported=True,
+ brand_domain="exa.ai",
+ brand_color="#101010",
+ requires="Network access",
+ server=MCPServerConfig(
+ type="streamableHttp",
+ url="https://mcp.exa.ai/mcp",
+ tool_timeout=45,
+ ),
+ note="Hosted Exa MCP endpoint currently does not require an API key.",
+ ),
+ McpPreset(
+ name="microsoft-learn",
+ display_name="Microsoft Learn",
+ category="docs",
+ description="Search and fetch official Microsoft Learn documentation through Microsoft's hosted MCP server.",
+ docs_url="https://learn.microsoft.com/en-us/training/support/mcp",
+ transport="streamableHttp",
+ install_supported=True,
+ brand_domain="learn.microsoft.com",
+ brand_color="#0078D4",
+ requires="Network access",
+ server=MCPServerConfig(
+ type="streamableHttp",
+ url="https://learn.microsoft.com/api/mcp",
+ tool_timeout=45,
+ ),
+ note="Public documentation only; no authentication required.",
+ ),
+ McpPreset(
+ name="aws-docs",
+ display_name="AWS Documentation",
+ category="docs",
+ description="Search AWS documentation and service guidance through AWS Labs' documentation MCP server.",
+ docs_url="https://awslabs.github.io/mcp/servers/aws-documentation-mcp-server/",
+ transport="stdio",
+ install_supported=True,
+ brand_domain="aws.amazon.com",
+ brand_color="#FF9900",
+ requires="uvx",
+ server=MCPServerConfig(
+ type="stdio",
+ command="uvx",
+ args=["awslabs.aws-documentation-mcp-server@latest"],
+ env={"FASTMCP_LOG_LEVEL": "ERROR", "AWS_DOCUMENTATION_PARTITION": "aws"},
+ tool_timeout=60,
+ ),
+ ),
+ McpPreset(
+ name="brave-search",
+ display_name="Brave Search",
+ category="web",
+ description="Run web, news, image, video, and local search through Brave Search.",
+ docs_url="https://www.npmjs.com/package/@brave/brave-search-mcp-server",
+ transport="stdio",
+ install_supported=True,
+ brand_domain="brave.com",
+ brand_color="#FB542B",
+ requires="Node.js, npx, and Brave Search API key",
+ server=MCPServerConfig(
+ type="stdio",
+ command="npx",
+ args=["-y", "@brave/brave-search-mcp-server@latest", "--transport", "stdio"],
+ tool_timeout=45,
+ ),
+ fields=(
+ McpPresetField(
+ name="brave_api_key",
+ label="Brave Search API key",
+ target=("env", "BRAVE_API_KEY"),
+ env_var="BRAVE_API_KEY",
+ placeholder="BSA...",
+ ),
+ ),
+ ),
+ McpPreset(
+ name="postman",
+ display_name="Postman",
+ category="api",
+ description="Inspect and manage Postman APIs, collections, and workspaces through the local MCP server.",
+ docs_url="https://learning.postman.com/docs/developer/postman-api/postman-mcp-server/postman-mcp-local-server",
+ transport="stdio",
+ install_supported=True,
+ brand_domain="postman.com",
+ brand_color="#FF6C37",
+ requires="Node.js, npx, and Postman API key",
+ server=MCPServerConfig(
+ type="stdio",
+ command="npx",
+ args=["-y", "@postman/postman-mcp-server@latest", "--full"],
+ tool_timeout=60,
+ ),
+ fields=(
+ McpPresetField(
+ name="postman_api_key",
+ label="Postman API key",
+ target=("env", "POSTMAN_API_KEY"),
+ env_var="POSTMAN_API_KEY",
+ placeholder="PMAK-...",
+ ),
+ ),
+ ),
+ McpPreset(
+ name="figma",
+ display_name="Figma",
+ category="design",
+ description="Read design context from Figma using the official local Dev Mode MCP server.",
+ docs_url="https://help.figma.com/hc/en-us/articles/32132100833559-Guide-to-the-Figma-MCP-server",
+ transport="streamableHttp",
+ install_supported=True,
+ brand_domain="figma.com",
+ brand_color="#F24E1E",
+ requires="Figma desktop app with MCP enabled",
+ server=MCPServerConfig(
+ type="streamableHttp",
+ url="http://127.0.0.1:3845/mcp",
+ tool_timeout=45,
+ ),
+ note="Requires Figma Desktop Dev Mode MCP to be running locally.",
+ ),
+ McpPreset(
+ name="github",
+ display_name="GitHub",
+ category="code",
+ description="Repository, issue, and pull request workflows via GitHub's official MCP server.",
+ docs_url="https://github.com/github/github-mcp-server",
+ transport="stdio",
+ install_supported=True,
+ brand_domain="github.com",
+ brand_color="#24292F",
+ requires="Docker and GitHub token",
+ server=MCPServerConfig(
+ type="stdio",
+ command="docker",
+ args=[
+ "run",
+ "-i",
+ "--rm",
+ "-e",
+ "GITHUB_PERSONAL_ACCESS_TOKEN",
+ "ghcr.io/github/github-mcp-server",
+ ],
+ tool_timeout=60,
+ ),
+ fields=(
+ McpPresetField(
+ name="github_token",
+ label="GitHub token",
+ target=("env", "GITHUB_PERSONAL_ACCESS_TOKEN"),
+ env_var="GITHUB_PERSONAL_ACCESS_TOKEN",
+ placeholder="ghp_...",
+ ),
+ ),
+ ),
+ McpPreset(
+ name="supabase",
+ display_name="Supabase",
+ category="database",
+ description="Inspect and manage Supabase projects through the Supabase MCP server.",
+ docs_url="https://supabase.com/docs/guides/ai-tools/mcp",
+ transport="stdio",
+ install_supported=True,
+ brand_domain="supabase.com",
+ brand_color="#3ECF8E",
+ requires="Node.js, npx, and Supabase access token",
+ server=MCPServerConfig(
+ type="stdio",
+ command="npx",
+ args=["-y", "@supabase/mcp-server-supabase@latest", "--read-only"],
+ tool_timeout=60,
+ ),
+ fields=(
+ McpPresetField(
+ name="supabase_access_token",
+ label="Supabase access token",
+ target=("env", "SUPABASE_ACCESS_TOKEN"),
+ env_var="SUPABASE_ACCESS_TOKEN",
+ placeholder="sbp_...",
+ ),
+ ),
+ note="MVP config starts read-only by default.",
+ ),
+)
+
+
+def _query_first(query: QueryParams, key: str) -> str | None:
+ values = query.get(key)
+ return values[0] if values else None
+
+
+def _query_value(query: QueryParams, key: str) -> str | None:
+ raw = _query_first(query, key)
+ if raw is None:
+ return None
+ value = raw.strip()
+ return value or None
+
+
+def _preset_by_name(name: str) -> McpPreset:
+ if not name or _MCP_PRESET_NAME_RE.match(name) is None:
+ raise McpPresetError("invalid MCP preset name")
+ for preset in MCP_PRESETS:
+ if preset.name == name:
+ return preset
+ raise McpPresetError("unknown MCP preset", status=404)
+
+
+def _preset_by_name_optional(name: str) -> McpPreset | None:
+ try:
+ return _preset_by_name(name)
+ except McpPresetError:
+ return None
+
+
+def _known_preset_names() -> set[str]:
+ return {preset.name for preset in MCP_PRESETS}
+
+
+def _known_mcp_names() -> set[str]:
+ names = _known_preset_names()
+ with suppress(Exception):
+ names.update(load_config().tools.mcp_servers)
+ return names
+
+
+def _clip_ws_string(value: Any, limit: int = 240) -> str | None:
+ if not isinstance(value, str):
+ return None
+ text = value.strip()
+ if not text:
+ return None
+ return text[:limit]
+
+
+def normalize_mcp_preset_mentions(raw: Any) -> list[dict[str, Any]]:
+ """Sanitize structured MCP preset mentions sent by the WebUI."""
+ if not isinstance(raw, list):
+ return []
+ known = _known_mcp_names()
+ out: list[dict[str, Any]] = []
+ seen: set[str] = set()
+ for item in raw[:8]:
+ if not isinstance(item, dict):
+ continue
+ name = _clip_ws_string(item.get("name"), 64)
+ if not name or _MCP_PRESET_NAME_RE.match(name) is None:
+ continue
+ key = name.lower()
+ if key in seen or key not in known:
+ continue
+ seen.add(key)
+ row: dict[str, Any] = {"name": key}
+ for field_name in _MCP_ATTACHMENT_KEYS[1:]:
+ value = item.get(field_name)
+ if isinstance(value, bool):
+ row[field_name] = value
+ continue
+ limit = 512 if field_name == "logo_url" else 160
+ text = _clip_ws_string(value, limit)
+ if text:
+ row[field_name] = text
+ out.append(row)
+ return out
+
+
+def _clone_server(server: MCPServerConfig) -> MCPServerConfig:
+ return MCPServerConfig.model_validate(server.model_dump(mode="json"))
+
+
+def _with_managed_stdio_cwd(name: str, cfg: MCPServerConfig) -> MCPServerConfig:
+ if cfg.command and (cfg.type in (None, "stdio")) and not cfg.cwd:
+ cfg.cwd = str(ensure_dir(get_runtime_subdir("mcp") / name))
+ return cfg
+
+
+def _url_with_param(url: str, key: str, value: str) -> str:
+ parsed = urllib.parse.urlsplit(url)
+ query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
+ query = [(k, v) for k, v in query if k != key]
+ query.append((key, value))
+ return urllib.parse.urlunsplit(
+ (
+ parsed.scheme,
+ parsed.netloc,
+ parsed.path,
+ urllib.parse.urlencode(query),
+ parsed.fragment,
+ )
+ )
+
+
+def _arg_value(args: list[str], flag: str) -> str | None:
+ prefix = f"{flag}="
+ for index, item in enumerate(args):
+ if item == flag and index + 1 < len(args):
+ return args[index + 1]
+ if item.startswith(prefix):
+ return item[len(prefix):]
+ return None
+
+
+def _with_arg_value(args: list[str], flag: str, value: str) -> list[str]:
+ out: list[str] = []
+ skip_next = False
+ prefix = f"{flag}="
+ for item in args:
+ if skip_next:
+ skip_next = False
+ continue
+ if item == flag:
+ skip_next = True
+ continue
+ if item.startswith(prefix):
+ continue
+ out.append(item)
+ out.extend([flag, value])
+ return out
+
+
+def _field_value_from_config(field: McpPresetField, cfg: MCPServerConfig | None) -> str | None:
+ if cfg is None:
+ return None
+ target_kind, target_name = field.target
+ if target_kind == "env":
+ value = cfg.env.get(target_name)
+ return value if value else None
+ if target_kind == "header":
+ value = cfg.headers.get(target_name)
+ return value if value else None
+ if target_kind == "arg":
+ return _arg_value(list(cfg.args), target_name)
+ if target_kind == "url_param" and cfg.url:
+ parsed = urllib.parse.urlsplit(cfg.url)
+ values = urllib.parse.parse_qs(parsed.query).get(target_name)
+ if values:
+ return values[0]
+ return None
+
+
+def _field_configured(field: McpPresetField, cfg: MCPServerConfig | None) -> bool:
+ value = _field_value_from_config(field, cfg)
+ if value:
+ return True
+ return bool(field.env_var and os.environ.get(field.env_var))
+
+
+def _field_payload(field: McpPresetField, cfg: MCPServerConfig | None) -> dict[str, Any]:
+ return {
+ "name": field.name,
+ "label": field.label,
+ "secret": field.secret,
+ "required": field.required,
+ "configured": _field_configured(field, cfg),
+ "placeholder": field.placeholder,
+ "env_var": field.env_var,
+ }
+
+
+def _resolve_field_value(
+ field: McpPresetField,
+ query: QueryParams,
+ existing: MCPServerConfig | None,
+) -> str | None:
+ provided = _query_value(query, field.name)
+ if provided:
+ return provided
+ current = _field_value_from_config(field, existing)
+ if current:
+ return current
+ if field.env_var and os.environ.get(field.env_var):
+ return f"${{{field.env_var}}}"
+ return None
+
+
+def _materialize_server(
+ preset: McpPreset,
+ query: QueryParams,
+ existing: MCPServerConfig | None,
+) -> MCPServerConfig:
+ if preset.server is None or not preset.install_supported:
+ raise McpPresetError(f"{preset.display_name} is not supported yet", status=409)
+
+ cfg = _clone_server(preset.server)
+ for field_spec in preset.fields:
+ value = _resolve_field_value(field_spec, query, existing)
+ if field_spec.required and not value:
+ raise McpPresetError(f"missing {field_spec.label}")
+ if not value:
+ continue
+ target_kind, target_name = field_spec.target
+ if target_kind == "env":
+ cfg.env[target_name] = value
+ elif target_kind == "header":
+ cfg.headers[target_name] = value
+ elif target_kind == "arg":
+ cfg.args = _with_arg_value(list(cfg.args), target_name, value)
+ elif target_kind == "url_param":
+ cfg.url = _url_with_param(cfg.url, target_name, value)
+ return _with_managed_stdio_cwd(preset.name, cfg)
+
+
+def _command_available(command: str) -> bool:
+ if not command:
+ return False
+ if shutil.which(command):
+ return True
+ path = Path(command).expanduser()
+ return path.exists() and path.is_file()
+
+
+def _config_available(cfg: MCPServerConfig | None) -> bool:
+ if cfg is None:
+ return False
+ if cfg.command:
+ return _command_available(cfg.command)
+ if cfg.url:
+ return True
+ return False
+
+
+def _status_for(preset: McpPreset, cfg: MCPServerConfig | None) -> str:
+ if cfg is None:
+ return "not_installed" if preset.install_supported else "coming_soon"
+ if any(field.required and not _field_configured(field, cfg) for field in preset.fields):
+ return "missing_credentials"
+ if cfg.command and not _command_available(cfg.command):
+ return "missing_dependency"
+ return "configured"
+
+
+def _connection_summary(cfg: MCPServerConfig | None) -> str:
+ if cfg is None:
+ return ""
+ if cfg.command:
+ return " ".join([cfg.command, *cfg.args[:2]]).strip()
+ if cfg.url:
+ parsed = urllib.parse.urlsplit(cfg.url)
+ return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", ""))
+ return ""
+
+
+def _tool_allowlist(cfg: MCPServerConfig | None) -> list[str]:
+ if cfg is None:
+ return ["*"]
+ return list(cfg.enabled_tools)
+
+
+def _preset_payload(preset: McpPreset, configured_servers: dict[str, MCPServerConfig]) -> dict[str, Any]:
+ cfg = configured_servers.get(preset.name)
+ status = _status_for(preset, cfg)
+ configured = cfg is not None and status not in {"missing_credentials"}
+ return {
+ "name": preset.name,
+ "display_name": preset.display_name,
+ "category": preset.category,
+ "description": preset.description,
+ "docs_url": preset.docs_url,
+ "transport": preset.transport,
+ "requires": preset.requires,
+ "note": preset.note,
+ "install_supported": preset.install_supported,
+ "installed": cfg is not None,
+ "configured": configured,
+ "available": configured and _config_available(cfg),
+ "status": status,
+ "logo_url": _favicon_url(preset.brand_domain),
+ "brand_color": preset.brand_color,
+ "required_fields": [_field_payload(field, cfg) for field in preset.fields],
+ "connection_summary": _connection_summary(cfg),
+ "enabled_tools": _tool_allowlist(cfg),
+ "source": "preset",
+ }
+
+
+def _custom_payload(
+ name: str,
+ cfg: MCPServerConfig,
+ *,
+ tool_names: list[str] | None = None,
+) -> dict[str, Any]:
+ transport = cfg.type
+ if not transport:
+ transport = "stdio" if cfg.command else ("sse" if cfg.url.rstrip("/").endswith("/sse") else "streamableHttp")
+ status = "missing_dependency" if cfg.command and not _command_available(cfg.command) else "configured"
+ return {
+ "name": name,
+ "display_name": name,
+ "category": "custom",
+ "description": "Custom MCP server from nanobot config.",
+ "docs_url": "",
+ "transport": transport,
+ "requires": "",
+ "note": "",
+ "install_supported": True,
+ "installed": True,
+ "configured": True,
+ "available": _config_available(cfg),
+ "status": status,
+ "logo_url": None,
+ "brand_color": "#64748B",
+ "required_fields": [],
+ "connection_summary": _connection_summary(cfg),
+ "enabled_tools": _tool_allowlist(cfg),
+ "tool_names": tool_names or [],
+ "source": "custom",
+ }
+
+
+def mcp_presets_payload(
+ *,
+ last_action: dict[str, Any] | None = None,
+ tool_preview: Mapping[str, list[str]] | None = None,
+) -> dict[str, Any]:
+ config = load_config()
+ known = _known_preset_names()
+ preset_rows = [
+ _preset_payload(preset, config.tools.mcp_servers)
+ | ({"tool_names": tool_preview.get(preset.name, [])} if tool_preview and preset.name in tool_preview else {})
+ for preset in MCP_PRESETS
+ ]
+ custom_rows = [
+ _custom_payload(name, cfg, tool_names=(tool_preview or {}).get(name))
+ for name, cfg in sorted(config.tools.mcp_servers.items())
+ if name not in known
+ ]
+ payload: dict[str, Any] = {
+ "presets": [*preset_rows, *custom_rows],
+ "installed_count": len(config.tools.mcp_servers),
+ }
+ if last_action is not None:
+ payload["last_action"] = last_action
+ return payload
+
+
+def _display_name_for(name: str, preset: McpPreset | None = None) -> str:
+ return preset.display_name if preset is not None else name
+
+
+def _action_message(action: str, preset: McpPreset, *, ok: bool = True) -> dict[str, Any]:
+ verb = {
+ "enable": "Enabled",
+ "remove": "Removed",
+ "test": "Checked",
+ }.get(action, "Updated")
+ return {
+ "ok": ok,
+ "message": f"{verb} MCP preset for {preset.display_name}.",
+ }
+
+
+def _server_action_message(action: str, name: str, *, ok: bool = True) -> dict[str, Any]:
+ verb = {
+ "custom": "Saved",
+ "import": "Imported",
+ "import-cursor": "Imported",
+ "tools": "Updated tools for",
+ "remove": "Removed",
+ }.get(action, "Updated")
+ return {
+ "ok": ok,
+ "message": f"{verb} MCP server {name}.",
+ }
+
+
+def _scrub_test_error(text: str) -> str:
+ scrubbed = _SECRET_QUERY_RE.sub(r"\1", text.strip())
+ scrubbed = _SECRET_ASSIGNMENT_RE.sub(r"\1", scrubbed)
+ return scrubbed[:400] if scrubbed else "Connection failed."
+
+
+def _checked_at() -> str:
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
+
+
+def _test_timeout(cfg: MCPServerConfig) -> int:
+ raw = cfg.tool_timeout or _DEFAULT_TEST_TIMEOUT
+ return max(5, min(int(raw), _DEFAULT_TEST_TIMEOUT))
+
+
+async def _close_mcp_stacks(stacks: Mapping[str, Any]) -> None:
+ for stack in stacks.values():
+ with suppress(Exception):
+ await stack.aclose()
+
+
+async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]:
+ """Connect to an enabled MCP preset and report its tool surface."""
+ from nanobot.agent.tools.mcp import connect_mcp_servers
+
+ name = (_query_first(query, "name") or "").strip()
+ if not name:
+ raise McpPresetError("missing MCP preset name")
+ if _MCP_PRESET_NAME_RE.match(name) is None:
+ raise McpPresetError("invalid MCP server name")
+ preset = _preset_by_name_optional(name)
+ display_name = _display_name_for(name, preset)
+
+ try:
+ config = resolve_config_env_vars(load_config())
+ except ValueError as exc:
+ return mcp_presets_payload(last_action={
+ "ok": False,
+ "message": _scrub_test_error(str(exc)),
+ "error": _scrub_test_error(str(exc)),
+ "tool_count": 0,
+ "tool_names": [],
+ "checked_at": _checked_at(),
+ })
+
+ cfg = config.tools.mcp_servers.get(name)
+ if cfg is None:
+ raise McpPresetError(f"{display_name} is not enabled", status=404)
+
+ status = _status_for(preset, cfg) if preset is not None else (
+ "missing_dependency" if cfg.command and not _command_available(cfg.command) else "configured"
+ )
+ if status == "missing_credentials":
+ last_action = {
+ "ok": False,
+ "message": f"{display_name} is missing required credentials.",
+ "error": "missing credentials",
+ "tool_count": 0,
+ "tool_names": [],
+ "checked_at": _checked_at(),
+ }
+ return mcp_presets_payload(last_action=last_action)
+
+ if cfg.command and not _command_available(cfg.command):
+ last_action = {
+ "ok": False,
+ "message": f"{display_name} requires '{cfg.command}' on PATH.",
+ "error": "missing dependency",
+ "tool_count": 0,
+ "tool_names": [],
+ "checked_at": _checked_at(),
+ }
+ return mcp_presets_payload(last_action=last_action)
+
+ registry = ToolRegistry()
+ stacks: dict[str, Any] = {}
+ try:
+ stacks = await asyncio.wait_for(
+ connect_mcp_servers({name: cfg}, registry),
+ timeout=_test_timeout(cfg),
+ )
+ tool_prefix = f"mcp_{name}_"
+ tool_names = sorted(name for name in registry.tool_names if name.startswith(tool_prefix))
+ ok = name in stacks
+ if ok:
+ last_action = {
+ "ok": True,
+ "message": (
+ f"{display_name} connected with {len(tool_names)} tools."
+ if tool_names
+ else f"{display_name} connected, but reported no tools."
+ ),
+ "tool_count": len(tool_names),
+ "tool_names": tool_names[:_MAX_TEST_TOOLS],
+ "checked_at": _checked_at(),
+ }
+ else:
+ last_action = {
+ "ok": False,
+ "message": f"{display_name} did not complete an MCP handshake.",
+ "error": "MCP handshake failed",
+ "tool_count": 0,
+ "tool_names": [],
+ "checked_at": _checked_at(),
+ }
+ except asyncio.TimeoutError:
+ last_action = {
+ "ok": False,
+ "message": f"{display_name} test timed out.",
+ "error": "timeout",
+ "tool_count": 0,
+ "tool_names": [],
+ "checked_at": _checked_at(),
+ }
+ except Exception as exc:
+ error = _scrub_test_error(str(exc))
+ last_action = {
+ "ok": False,
+ "message": f"{display_name} could not connect.",
+ "error": error,
+ "tool_count": 0,
+ "tool_names": [],
+ "checked_at": _checked_at(),
+ }
+ finally:
+ await _close_mcp_stacks(stacks)
+
+ preview = {name: last_action.get("tool_names", [])} if last_action.get("tool_names") else None
+ return mcp_presets_payload(last_action=last_action, tool_preview=preview)
+
+
+def _parse_json_value(raw: str | None, *, fallback: Any) -> Any:
+ if raw is None or not raw.strip():
+ return fallback
+ try:
+ return json.loads(raw)
+ except json.JSONDecodeError as exc:
+ raise McpPresetError(f"invalid JSON: {exc.msg}") from exc
+
+
+def _parse_string_list(raw: str | None) -> list[str]:
+ if raw is None or not raw.strip():
+ return []
+ parsed = _parse_json_value(raw, fallback=None)
+ if isinstance(parsed, list) and all(isinstance(item, str) for item in parsed):
+ return [item for item in parsed if item.strip()]
+ if isinstance(parsed, str):
+ return shlex.split(parsed)
+ raise McpPresetError("expected a JSON string array")
+
+
+def _parse_string_map(raw: str | None) -> dict[str, str]:
+ parsed = _parse_json_value(raw, fallback={})
+ if not isinstance(parsed, dict):
+ raise McpPresetError("expected a JSON object")
+ out: dict[str, str] = {}
+ for key, value in parsed.items():
+ if not isinstance(key, str) or not isinstance(value, str):
+ raise McpPresetError("JSON object values must be strings")
+ if key.strip():
+ out[key.strip()] = value
+ return out
+
+
+def _parse_enabled_tools(raw: str | None) -> list[str]:
+ if raw is None or not raw.strip():
+ return ["*"]
+ values = _parse_string_list(raw)
+ if "*" in values:
+ return ["*"]
+ return values
+
+
+def _normalize_transport(value: str | None, *, command: str = "", url: str = "") -> Literal["stdio", "sse", "streamableHttp"]:
+ raw = (value or "").strip()
+ if not raw:
+ if command:
+ return "stdio"
+ if url.rstrip("/").endswith("/sse"):
+ return "sse"
+ return "streamableHttp"
+ aliases = {
+ "stdio": "stdio",
+ "sse": "sse",
+ "streamableHttp": "streamableHttp",
+ "streamable-http": "streamableHttp",
+ "streamable_http": "streamableHttp",
+ "http": "streamableHttp",
+ }
+ normalized = aliases.get(raw)
+ if normalized is None:
+ raise McpPresetError("unsupported MCP transport")
+ return normalized # type: ignore[return-value]
+
+
+def _validated_server_name(name: str) -> str:
+ if not name or _MCP_PRESET_NAME_RE.match(name) is None:
+ raise McpPresetError("invalid MCP server name")
+ return name.strip().lower()
+
+
+def _custom_server_from_query(query: QueryParams) -> tuple[str, MCPServerConfig]:
+ name = _validated_server_name((_query_first(query, "name") or "").strip())
+ command = (_query_first(query, "command") or "").strip()
+ url = (_query_first(query, "url") or "").strip()
+ transport = _normalize_transport(_query_first(query, "transport"), command=command, url=url)
+ if transport == "stdio" and not command:
+ raise McpPresetError("stdio MCP servers require a command")
+ if transport in {"sse", "streamableHttp"} and not url:
+ raise McpPresetError("remote MCP servers require a URL")
+ raw_timeout = (_query_first(query, "tool_timeout") or "").strip()
+ tool_timeout = _DEFAULT_CUSTOM_TIMEOUT
+ if raw_timeout:
+ try:
+ tool_timeout = max(5, min(int(raw_timeout), 600))
+ except ValueError as exc:
+ raise McpPresetError("tool_timeout must be an integer") from exc
+ cfg = MCPServerConfig(
+ type=transport,
+ command=command if transport == "stdio" else "",
+ args=_parse_string_list(_query_first(query, "args")),
+ env=_parse_string_map(_query_first(query, "env")),
+ cwd=(_query_first(query, "cwd") or "").strip() if transport == "stdio" else "",
+ url=url if transport in {"sse", "streamableHttp"} else "",
+ headers=_parse_string_map(_query_first(query, "headers")),
+ tool_timeout=tool_timeout,
+ enabled_tools=_parse_enabled_tools(_query_first(query, "enabled_tools")),
+ )
+ return name, cfg
+
+
+def _mcp_server_config(name: str, raw: Any) -> tuple[str, MCPServerConfig]:
+ server_name = _validated_server_name(name)
+ if not isinstance(raw, Mapping):
+ raise McpPresetError(f"MCP server '{server_name}' must be an object")
+ command = str(raw.get("command") or "").strip()
+ url = str(raw.get("url") or "").strip()
+ transport_value = str(raw.get("type", raw.get("transport", "")) or "")
+ transport = _normalize_transport(transport_value, command=command, url=url)
+ if transport == "stdio" and not command:
+ raise McpPresetError(f"MCP server '{server_name}' stdio transport requires a command")
+ if transport in {"sse", "streamableHttp"} and not url:
+ raise McpPresetError(f"MCP server '{server_name}' remote transport requires a URL")
+ args = raw.get("args") or []
+ env = raw.get("env") or {}
+ headers = raw.get("headers") or {}
+ cwd = str(raw.get("cwd") or "").strip()
+ enabled_tools = raw.get("enabledTools", raw.get("enabled_tools", ["*"]))
+ tool_timeout = raw.get("toolTimeout", raw.get("tool_timeout", _DEFAULT_CUSTOM_TIMEOUT))
+ try:
+ timeout_int = max(5, min(int(tool_timeout), 600))
+ except (TypeError, ValueError):
+ timeout_int = _DEFAULT_CUSTOM_TIMEOUT
+ if not isinstance(args, list) or not all(isinstance(item, str) for item in args):
+ raise McpPresetError(f"MCP server '{server_name}' args must be a string array")
+ if not isinstance(env, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in env.items()):
+ raise McpPresetError(f"MCP server '{server_name}' env must be a string object")
+ if not isinstance(headers, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in headers.items()):
+ raise McpPresetError(f"MCP server '{server_name}' headers must be a string object")
+ if not isinstance(enabled_tools, list) or not all(isinstance(item, str) for item in enabled_tools):
+ enabled_tools = ["*"]
+ return server_name, MCPServerConfig(
+ type=transport,
+ command=command if transport == "stdio" else "",
+ args=args,
+ env=dict(env),
+ cwd=cwd if transport == "stdio" else "",
+ url=url if transport in {"sse", "streamableHttp"} else "",
+ headers=dict(headers),
+ tool_timeout=timeout_int,
+ enabled_tools=list(enabled_tools),
+ )
+
+
+def _import_mcp_servers(raw_json: str | None) -> dict[str, MCPServerConfig]:
+ parsed = _parse_json_value(raw_json, fallback=None)
+ if not isinstance(parsed, Mapping):
+ raise McpPresetError("MCP config must be a JSON object")
+ servers = parsed.get("mcpServers", parsed)
+ if not isinstance(servers, Mapping):
+ raise McpPresetError("MCP config must contain mcpServers")
+ out: dict[str, MCPServerConfig] = {}
+ for name, raw_server in servers.items():
+ if not isinstance(name, str):
+ raise McpPresetError("MCP server names must be strings")
+ server_name, cfg = _mcp_server_config(name, raw_server)
+ out[server_name] = cfg
+ if not out:
+ raise McpPresetError("MCP config contains no servers")
+ return out
+
+
+def custom_mcp_action(action: str, query: QueryParams) -> dict[str, Any]:
+ config = load_config()
+ if action == "custom":
+ name, cfg = _custom_server_from_query(query)
+ config.tools.mcp_servers[name] = cfg
+ save_config(config)
+ payload = mcp_presets_payload(last_action=_server_action_message(action, name))
+ payload["requires_restart"] = True
+ return payload
+
+ if action in {"import", "import-cursor"}:
+ servers = _import_mcp_servers(_query_first(query, "config"))
+ config.tools.mcp_servers.update(servers)
+ save_config(config)
+ payload = mcp_presets_payload(last_action={
+ "ok": True,
+ "message": f"Imported {len(servers)} MCP server(s).",
+ })
+ payload["requires_restart"] = True
+ return payload
+
+ if action == "tools":
+ name = _validated_server_name((_query_first(query, "name") or "").strip())
+ cfg = config.tools.mcp_servers.get(name)
+ if cfg is None:
+ raise McpPresetError("unknown MCP server", status=404)
+ cfg.enabled_tools = _parse_enabled_tools(_query_first(query, "enabled_tools"))
+ config.tools.mcp_servers[name] = cfg
+ save_config(config)
+ payload = mcp_presets_payload(last_action=_server_action_message(action, name))
+ payload["requires_restart"] = True
+ return payload
+
+ raise McpPresetError(f"unknown MCP action '{action}'", status=404)
+
+
+def mcp_presets_action(action: str, query: QueryParams) -> dict[str, Any]:
+ name = (_query_first(query, "name") or "").strip()
+ if not name:
+ raise McpPresetError("missing MCP preset name")
+ preset = _preset_by_name_optional(name)
+
+ config = load_config()
+ existing = config.tools.mcp_servers.get(name)
+
+ if action == "enable":
+ if preset is None:
+ raise McpPresetError("unknown MCP preset", status=404)
+ config.tools.mcp_servers[preset.name] = _materialize_server(preset, query, existing)
+ save_config(config)
+ payload = mcp_presets_payload(last_action=_action_message(action, preset))
+ payload["requires_restart"] = True
+ return payload
+
+ if action == "remove":
+ if preset is None and name not in config.tools.mcp_servers:
+ raise McpPresetError("unknown MCP server", status=404)
+ if name in config.tools.mcp_servers:
+ del config.tools.mcp_servers[name]
+ save_config(config)
+ last_action = (
+ _action_message(action, preset)
+ if preset is not None
+ else _server_action_message(action, name)
+ )
+ payload = mcp_presets_payload(last_action=last_action)
+ payload["requires_restart"] = True
+ return payload
+
+ if action == "test":
+ raise McpPresetError("MCP preset test must run through the async test action", status=500)
+
+ raise McpPresetError(f"unknown MCP preset action '{action}'", status=404)
+
+
+def attach_mcp_hot_reload_result(
+ payload: dict[str, Any],
+ result: dict[str, Any],
+) -> dict[str, Any]:
+ """Merge an agent MCP reload acknowledgement into a WebUI settings payload."""
+ payload = dict(payload)
+ payload["hot_reload"] = result
+ payload["requires_restart"] = bool(result.get("requires_restart"))
+ last_action = dict(payload.get("last_action") or {})
+ base_message = str(last_action.get("message") or "").strip()
+ reload_message = str(result.get("message") or "").strip()
+ if reload_message:
+ last_action["message"] = (
+ f"{base_message} {reload_message}" if base_message else reload_message
+ )
+ if "ok" not in last_action:
+ last_action["ok"] = bool(result.get("ok", False))
+ payload["last_action"] = last_action
+ return payload
+
+
+async def mcp_presets_settings_action(
+ action: str | None,
+ query: QueryParams,
+ *,
+ reload_mcp: McpReload | None = None,
+) -> dict[str, Any]:
+ """Run a WebUI MCP preset action and hot-reload the agent when config changes."""
+ if action is None:
+ return mcp_presets_payload()
+ if action == "test":
+ return await mcp_presets_test_action(query)
+ if action in _CUSTOM_ACTIONS:
+ payload = await asyncio.to_thread(custom_mcp_action, action, query)
+ else:
+ payload = await asyncio.to_thread(mcp_presets_action, action, query)
+ if reload_mcp is not None:
+ payload = attach_mcp_hot_reload_result(payload, await reload_mcp())
+ return payload
diff --git a/nanobot/webui/mcp_presets_runtime.py b/nanobot/webui/mcp_presets_runtime.py
new file mode 100644
index 000000000..1294ccc85
--- /dev/null
+++ b/nanobot/webui/mcp_presets_runtime.py
@@ -0,0 +1,5 @@
+"""Compatibility exports for WebUI-attached MCP preset annotations."""
+
+from nanobot.agent.tools.mcp import runtime_lines, session_extra
+
+__all__ = ["runtime_lines", "session_extra"]
diff --git a/nanobot/webui/settings_api.py b/nanobot/webui/settings_api.py
index 6d43e22c8..7019597a9 100644
--- a/nanobot/webui/settings_api.py
+++ b/nanobot/webui/settings_api.py
@@ -6,10 +6,12 @@ settings payload shape and the allowlisted config mutations exposed to WebUI.
from __future__ import annotations
+import re
from typing import Any
from zoneinfo import ZoneInfo
from nanobot.config.loader import get_config_path, load_config, save_config
+from nanobot.config.schema import ModelPresetConfig
from nanobot.providers.image_generation import (
get_image_gen_provider,
image_gen_provider_names,
@@ -41,6 +43,7 @@ _IMAGE_GENERATION_ASPECT_RATIOS = {
"2:3",
"21:9",
}
+_MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+")
class WebUISettingsError(ValueError):
@@ -100,6 +103,32 @@ def _parse_bool(value: str, field: str) -> bool:
return normalized in {"1", "true", "yes"}
+def _model_configuration_slug(label: str) -> str:
+ normalized = _MODEL_CONFIGURATION_SLUG_RE.sub("-", label.strip().lower())
+ normalized = normalized.strip("-_")
+ if not normalized:
+ raise WebUISettingsError("configuration name is required")
+ if normalized == "default":
+ raise WebUISettingsError("configuration name is reserved")
+ if len(normalized) > 48:
+ normalized = normalized[:48].rstrip("-_")
+ return normalized
+
+
+def _validate_configured_provider(config: Any, provider: str) -> None:
+ if provider == "auto":
+ return
+ spec = find_by_name(provider)
+ if spec is None:
+ raise WebUISettingsError("unknown provider")
+ provider_config = getattr(config.providers, provider, None)
+ if (
+ provider_config is None
+ or not _provider_configured_for_settings(spec, provider_config)
+ ):
+ raise WebUISettingsError("provider is not configured")
+
+
def _image_generation_provider_rows(config: Any) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for name in image_gen_provider_names():
@@ -198,7 +227,7 @@ def settings_payload(*, requires_restart: bool = False) -> dict[str, Any]:
model_presets.append(
{
"name": name,
- "label": name,
+ "label": preset.label or name,
"active": active_preset_name == name,
"is_default": False,
"model": preset.model,
@@ -321,15 +350,7 @@ def update_agent_settings(query: QueryParams) -> dict[str, Any]:
provider = provider.strip()
if not provider:
raise WebUISettingsError("provider is required")
- spec = find_by_name(provider)
- if spec is None:
- raise WebUISettingsError("unknown provider")
- provider_config = getattr(config.providers, provider, None)
- if (
- provider_config is None
- or not _provider_configured_for_settings(spec, provider_config)
- ):
- raise WebUISettingsError("provider is not configured")
+ _validate_configured_provider(config, provider)
if defaults.provider != provider:
defaults.provider = provider
changed = True
@@ -388,6 +409,40 @@ def update_agent_settings(query: QueryParams) -> dict[str, Any]:
return settings_payload(requires_restart=restart_required)
+def create_model_configuration(query: QueryParams) -> dict[str, Any]:
+ label = (_query_first_alias(query, "label", "displayName") or "").strip()
+ raw_name = (_query_first(query, "name") or label).strip()
+ model = (_query_first(query, "model") or "").strip()
+ provider = (_query_first(query, "provider") or "").strip()
+
+ if not label:
+ label = raw_name
+ if not model:
+ raise WebUISettingsError("model is required")
+ if not provider:
+ raise WebUISettingsError("provider is required")
+
+ name = _model_configuration_slug(raw_name or label)
+ config = load_config()
+ if name in config.model_presets:
+ raise WebUISettingsError("configuration already exists", status=409)
+ _validate_configured_provider(config, provider)
+
+ base = config.resolve_default_preset()
+ config.model_presets[name] = ModelPresetConfig(
+ label=label,
+ model=model,
+ provider=provider,
+ max_tokens=base.max_tokens,
+ context_window_tokens=base.context_window_tokens,
+ temperature=base.temperature,
+ reasoning_effort=base.reasoning_effort,
+ )
+ config.agents.defaults.model_preset = name
+ save_config(config)
+ return settings_payload()
+
+
def update_provider_settings(query: QueryParams) -> dict[str, Any]:
provider_name = (_query_first(query, "provider") or "").strip()
if not provider_name:
diff --git a/nanobot/webui/transcript.py b/nanobot/webui/transcript.py
index 90f2e3b09..be525ac93 100644
--- a/nanobot/webui/transcript.py
+++ b/nanobot/webui/transcript.py
@@ -4,10 +4,12 @@ from __future__ import annotations
import json
import os
+import re
import time
import uuid
from pathlib import Path
-from typing import Any, Callable
+from typing import Any, Callable, Mapping
+from urllib.parse import unquote, urlparse
from loguru import logger
@@ -16,6 +18,61 @@ from nanobot.session.manager import SessionManager
WEBUI_TRANSCRIPT_SCHEMA_VERSION = 3
_MAX_TRANSCRIPT_FILE_BYTES = 8 * 1024 * 1024
+_MARKDOWN_LOCAL_IMAGE_RE = re.compile(
+ r"!\[([^\]]*)\]\((<[^>]+>|[^)\s]+)(\s+(?:\"[^\"]*\"|'[^']*'))?\)"
+)
+_INLINE_MARKDOWN_IMAGE_EXTS: frozenset[str] = frozenset({
+ ".png",
+ ".jpg",
+ ".jpeg",
+ ".webp",
+ ".gif",
+})
+
+
+def rewrite_local_markdown_images(
+ text: str,
+ *,
+ workspace_path: Path,
+ sign_path: Callable[[Path], Mapping[str, Any] | None],
+) -> str:
+ """Rewrite markdown image paths inside the workspace to signed WebUI media URLs."""
+ if "![" not in text:
+ return text
+
+ def resolve_url(raw_url: str) -> str | None:
+ url = raw_url.strip()
+ if url.startswith("<") and url.endswith(">"):
+ url = url[1:-1].strip()
+ if not url or url.startswith(("/api/media/", "#")):
+ return None
+ parsed = urlparse(url)
+ if parsed.scheme or parsed.netloc or parsed.query or parsed.fragment:
+ return None
+ path_text = unquote(url)
+ if Path(path_text).suffix.lower() not in _INLINE_MARKDOWN_IMAGE_EXTS:
+ return None
+ candidate = Path(path_text).expanduser()
+ if not candidate.is_absolute():
+ candidate = workspace_path / candidate
+ try:
+ resolved = candidate.resolve(strict=False)
+ resolved.relative_to(workspace_path)
+ except (OSError, ValueError):
+ return None
+ if not resolved.is_file():
+ return None
+ signed = sign_path(resolved)
+ return str(signed.get("url")) if signed and signed.get("url") else None
+
+ def replace(match: re.Match[str]) -> str:
+ signed_url = resolve_url(match.group(2))
+ if not signed_url:
+ return match.group(0)
+ title = match.group(3) or ""
+ return f""
+
+ return _MARKDOWN_LOCAL_IMAGE_RE.sub(replace, text)
def webui_transcript_path(session_key: str) -> Path:
@@ -458,6 +515,11 @@ def replay_transcript_to_ui_messages(
cli_apps = rec.get("cli_apps")
if isinstance(cli_apps, list) and cli_apps:
row["cliApps"] = [dict(app) for app in cli_apps if isinstance(app, dict)]
+ mcp_presets = rec.get("mcp_presets")
+ if isinstance(mcp_presets, list) and mcp_presets:
+ row["mcpPresets"] = [
+ dict(preset) for preset in mcp_presets if isinstance(preset, dict)
+ ]
messages.append(row)
continue
diff --git a/tests/agent/test_mcp_connection.py b/tests/agent/test_mcp_connection.py
index e7d0a7854..18d118c25 100644
--- a/tests/agent/test_mcp_connection.py
+++ b/tests/agent/test_mcp_connection.py
@@ -2,12 +2,39 @@
from __future__ import annotations
+import asyncio
+from contextlib import AsyncExitStack
+from typing import Any
from unittest.mock import MagicMock
import pytest
from nanobot.agent.loop import AgentLoop
+from nanobot.agent.tools import mcp as mcp_runtime
+from nanobot.agent.tools.base import Tool
from nanobot.bus.queue import MessageBus
+from nanobot.config.loader import load_config, save_config
+from nanobot.config.schema import MCPServerConfig
+
+
+class _FakeMcpTool(Tool):
+ def __init__(self, name: str) -> None:
+ self._name = name
+
+ @property
+ def name(self) -> str:
+ return self._name
+
+ @property
+ def description(self) -> str:
+ return "fake MCP tool"
+
+ @property
+ def parameters(self) -> dict[str, Any]:
+ return {"type": "object", "properties": {}}
+
+ async def execute(self, **_kwargs: Any) -> str:
+ return "ok"
def _make_loop(tmp_path, *, mcp_servers: dict | None = None) -> AgentLoop:
@@ -42,3 +69,152 @@ async def test_connect_mcp_retries_when_no_servers_connect(tmp_path, monkeypatch
assert attempts == 2
assert loop._mcp_connected is False
assert loop._mcp_stacks == {}
+
+
+@pytest.mark.asyncio
+async def test_reload_mcp_servers_adds_and_removes_tools_without_restart(
+ tmp_path,
+ monkeypatch: pytest.MonkeyPatch,
+):
+ config_path = tmp_path / "config.json"
+ monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
+ config = load_config()
+ config.tools.mcp_servers["browserbase"] = MCPServerConfig(
+ type="stdio",
+ command="browserbase-mcp",
+ )
+ save_config(config)
+
+ closed: list[str] = []
+
+ async def _mark_closed(name: str) -> None:
+ closed.append(name)
+
+ async def _fake_connect(servers, registry):
+ stacks = {}
+ for name in servers:
+ registry.register(_FakeMcpTool(f"mcp_{name}_navigate"))
+ stack = AsyncExitStack()
+ await stack.__aenter__()
+ stack.push_async_callback(_mark_closed, name)
+ stacks[name] = stack
+ return stacks
+
+ monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
+ loop = _make_loop(tmp_path, mcp_servers={})
+
+ added = await mcp_runtime.reload_servers(loop, loop.tools)
+
+ assert added["ok"] is True
+ assert added["added"] == ["browserbase"]
+ assert loop.tools.has("mcp_browserbase_navigate")
+ assert "browserbase" in loop._mcp_stacks
+
+ config = load_config()
+ del config.tools.mcp_servers["browserbase"]
+ save_config(config)
+
+ removed = await mcp_runtime.reload_servers(loop, loop.tools)
+
+ assert removed["ok"] is True
+ assert removed["removed"] == ["browserbase"]
+ assert not loop.tools.has("mcp_browserbase_navigate")
+ assert "browserbase" not in loop._mcp_stacks
+ assert closed == ["browserbase"]
+
+
+@pytest.mark.asyncio
+async def test_request_mcp_reload_reaches_runtime_control_without_restart(
+ tmp_path,
+ monkeypatch: pytest.MonkeyPatch,
+):
+ config_path = tmp_path / "config.json"
+ monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
+ config = load_config()
+ config.tools.mcp_servers["browserbase"] = MCPServerConfig(
+ type="stdio",
+ command="browserbase-mcp",
+ )
+ save_config(config)
+
+ closed: list[str] = []
+
+ async def _mark_closed(name: str) -> None:
+ closed.append(name)
+
+ async def _fake_connect(servers, registry):
+ stacks = {}
+ for name in servers:
+ registry.register(_FakeMcpTool(f"mcp_{name}_navigate"))
+ stack = AsyncExitStack()
+ await stack.__aenter__()
+ stack.push_async_callback(_mark_closed, name)
+ stacks[name] = stack
+ return stacks
+
+ monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
+ loop = _make_loop(tmp_path, mcp_servers={})
+
+ async def _handle_one_runtime_control() -> None:
+ msg = await loop.bus.consume_inbound()
+ handled = await mcp_runtime.handle_runtime_control(loop, msg, loop.tools)
+ assert handled is True
+
+ consumer = asyncio.create_task(_handle_one_runtime_control())
+ result = await mcp_runtime.request_mcp_reload(loop.bus, timeout=2.0)
+ await consumer
+
+ assert result["ok"] is True
+ assert result["added"] == ["browserbase"]
+ assert result["requires_restart"] is False
+ assert loop.tools.has("mcp_browserbase_navigate")
+
+ config = load_config()
+ del config.tools.mcp_servers["browserbase"]
+ save_config(config)
+
+ consumer = asyncio.create_task(_handle_one_runtime_control())
+ result = await mcp_runtime.request_mcp_reload(loop.bus, timeout=2.0)
+ await consumer
+
+ assert result["ok"] is True
+ assert result["removed"] == ["browserbase"]
+ assert result["requires_restart"] is False
+ assert not loop.tools.has("mcp_browserbase_navigate")
+ assert closed == ["browserbase"]
+
+
+@pytest.mark.asyncio
+async def test_reload_mcp_servers_retries_configured_server_without_live_stack(
+ tmp_path,
+ monkeypatch: pytest.MonkeyPatch,
+):
+ config_path = tmp_path / "config.json"
+ monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
+ config = load_config()
+ config.tools.mcp_servers["browserbase"] = MCPServerConfig(
+ type="stdio",
+ command="browserbase-mcp",
+ )
+ save_config(config)
+
+ async def _fake_connect(servers, registry):
+ stacks = {}
+ for name in servers:
+ registry.register(_FakeMcpTool(f"mcp_{name}_navigate"))
+ stack = AsyncExitStack()
+ await stack.__aenter__()
+ stacks[name] = stack
+ return stacks
+
+ monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
+ loop = _make_loop(tmp_path, mcp_servers={"browserbase": config.tools.mcp_servers["browserbase"]})
+
+ result = await mcp_runtime.reload_servers(loop, loop.tools)
+
+ assert result["ok"] is True
+ assert result["added"] == []
+ assert result["changed"] == []
+ assert result["retried"] == ["browserbase"]
+ assert loop.tools.has("mcp_browserbase_navigate")
+ await loop.close_mcp()
diff --git a/tests/agent/test_session_manager_history.py b/tests/agent/test_session_manager_history.py
index 0a0909de7..8ea2f24a4 100644
--- a/tests/agent/test_session_manager_history.py
+++ b/tests/agent/test_session_manager_history.py
@@ -56,6 +56,20 @@ def test_list_sessions_includes_user_preview(tmp_path):
assert rows[0]["preview"] == "帮我总结一下 OpenAI 的最新硬件计划"
+def test_list_sessions_bounds_preview_scan(tmp_path):
+ manager = SessionManager(tmp_path)
+ session = manager.get_or_create("websocket:chat-long-preview")
+ for index in range(220):
+ session.add_message("assistant", f"assistant trace {index}")
+ session.add_message("user", "this should not force a full sidebar scan")
+ manager.save(session)
+
+ rows = manager.list_sessions()
+
+ assert rows[0]["key"] == "websocket:chat-long-preview"
+ assert rows[0]["preview"] == "assistant trace 0"
+
+
# --- Original regression test (from PR 2075) ---
def test_get_history_drops_orphan_tool_results_when_window_cuts_tool_calls():
diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py
index f40ec4872..03143d540 100644
--- a/tests/channels/test_websocket_channel.py
+++ b/tests/channels/test_websocket_channel.py
@@ -1188,6 +1188,30 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
)
assert bad_preset.status_code == 400
+ created_preset = await _http_get(
+ "http://127.0.0.1:"
+ f"{port}/api/settings/model-configurations/create"
+ "?label=Fast%20writing&provider=openai&model=openai%2Fgpt-4.1-mini",
+ headers={"Authorization": "Bearer tok"},
+ )
+ assert created_preset.status_code == 200
+ created_body = created_preset.json()
+ assert created_body["agent"]["model_preset"] == "fast-writing"
+ assert created_body["agent"]["model"] == "openai/gpt-4.1-mini"
+ created_presets = {
+ preset["name"]: preset for preset in created_body["model_presets"]
+ }
+ assert created_presets["fast-writing"]["label"] == "Fast writing"
+ assert created_presets["fast-writing"]["provider"] == "openai"
+
+ duplicate_preset = await _http_get(
+ "http://127.0.0.1:"
+ f"{port}/api/settings/model-configurations/create"
+ "?label=Fast%20writing&provider=openai&model=openai%2Fgpt-4.1-mini",
+ headers={"Authorization": "Bearer tok"},
+ )
+ assert duplicate_preset.status_code == 409
+
search_updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/web-search/update?provider=searxng"
@@ -1255,7 +1279,10 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
saved = load_config(config_path)
assert saved.agents.defaults.model == "atomic_chat/test"
assert saved.agents.defaults.provider == "atomic_chat"
- assert saved.agents.defaults.model_preset == "deep"
+ assert saved.agents.defaults.model_preset == "fast-writing"
+ assert saved.model_presets["fast-writing"].label == "Fast writing"
+ assert saved.model_presets["fast-writing"].model == "openai/gpt-4.1-mini"
+ assert saved.model_presets["fast-writing"].provider == "openai"
assert saved.agents.defaults.timezone == "Asia/Shanghai"
assert saved.agents.defaults.bot_name == "Nano"
assert saved.agents.defaults.bot_icon == "N"
diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py
index 502b50842..18baf700e 100644
--- a/tests/channels/test_websocket_http_routes.py
+++ b/tests/channels/test_websocket_http_routes.py
@@ -209,6 +209,156 @@ async def test_cli_apps_routes_require_token_and_return_payload(
await server_task
+@pytest.mark.asyncio
+async def test_mcp_presets_routes_require_token_and_return_payload(
+ bus: MagicMock,
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(
+ "nanobot.webui.mcp_presets_api.mcp_presets_payload",
+ lambda: {
+ "presets": [
+ {
+ "name": "browserbase",
+ "display_name": "Browserbase",
+ "category": "browser",
+ "description": "Cloud browser automation",
+ "docs_url": "https://docs.browserbase.com/integrations/mcp/configuration",
+ "transport": "streamableHttp",
+ "requires": "Browserbase API key",
+ "note": "",
+ "install_supported": True,
+ "installed": False,
+ "configured": False,
+ "available": False,
+ "status": "not_installed",
+ "logo_url": None,
+ "brand_color": "#111827",
+ "required_fields": [],
+ "connection_summary": "",
+ }
+ ],
+ "installed_count": 0,
+ },
+ )
+ preset_queries: list[tuple[str, dict[str, list[str]]]] = []
+ custom_queries: list[tuple[str, dict[str, list[str]]]] = []
+
+ def _mcp_preset_action(action: str, query: dict[str, list[str]]) -> dict[str, Any]:
+ preset_queries.append((action, query))
+ return {
+ "presets": [],
+ "installed_count": 1,
+ "requires_restart": action != "test",
+ "last_action": {"ok": True, "message": f"{action}:{query['name'][0]}"},
+ }
+
+ def _custom_action(action: str, query: dict[str, list[str]]) -> dict[str, Any]:
+ custom_queries.append((action, query))
+ return {
+ "presets": [],
+ "installed_count": 1,
+ "requires_restart": True,
+ "last_action": {
+ "ok": True,
+ "message": f"{action}:{query.get('name', ['config'])[0]}",
+ },
+ }
+
+ monkeypatch.setattr(
+ "nanobot.webui.mcp_presets_api.mcp_presets_action",
+ _mcp_preset_action,
+ )
+ monkeypatch.setattr(
+ "nanobot.webui.mcp_presets_api.custom_mcp_action",
+ _custom_action,
+ )
+
+ async def _hot_reload(_bus):
+ return {"ok": True, "message": "MCP config reloaded.", "requires_restart": False}
+
+ monkeypatch.setattr(
+ "nanobot.channels.websocket.request_mcp_reload",
+ _hot_reload,
+ )
+ channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29913)
+ server_task = asyncio.create_task(channel.start())
+ await asyncio.sleep(0.3)
+ try:
+ deny = await _http_get("http://127.0.0.1:29913/api/settings/mcp-presets")
+ assert deny.status_code == 401
+
+ boot = await _http_get("http://127.0.0.1:29913/webui/bootstrap")
+ token = boot.json()["token"]
+ auth = {"Authorization": f"Bearer {token}"}
+
+ catalog = await _http_get(
+ "http://127.0.0.1:29913/api/settings/mcp-presets",
+ headers=auth,
+ )
+ assert catalog.status_code == 200
+ assert catalog.json()["presets"][0]["name"] == "browserbase"
+
+ enabled = await _http_get(
+ "http://127.0.0.1:29913/api/settings/mcp-presets/enable?name=browserbase",
+ headers={
+ **auth,
+ "X-Nanobot-MCP-Values": json.dumps(
+ {"browserbase_api_key": "bb_live_secret"}
+ ),
+ },
+ )
+ assert enabled.status_code == 200
+ assert preset_queries[-1][1]["browserbase_api_key"] == ["bb_live_secret"]
+ body = enabled.json()
+ assert "bb_live_secret" not in enabled.text
+ assert body["last_action"]["message"] == "enable:browserbase MCP config reloaded."
+ assert body["hot_reload"]["ok"] is True
+ assert body["restart_required_sections"] == []
+
+ bad_header = await _http_get(
+ "http://127.0.0.1:29913/api/settings/mcp-presets/enable?name=browserbase",
+ headers={**auth, "X-Nanobot-MCP-Values": "[]"},
+ )
+ assert bad_header.status_code == 400
+
+ custom = await _http_get(
+ "http://127.0.0.1:29913/api/settings/mcp-presets/custom",
+ headers={
+ **auth,
+ "X-Nanobot-MCP-Values": json.dumps(
+ {"name": "docs", "command": "npx"}
+ ),
+ },
+ )
+ assert custom.status_code == 200
+ assert custom_queries[-1][1]["command"] == ["npx"]
+ assert custom.json()["last_action"]["message"] == "custom:docs MCP config reloaded."
+
+ imported = await _http_get(
+ "http://127.0.0.1:29913/api/settings/mcp-presets/import",
+ headers={**auth, "X-Nanobot-MCP-Values": json.dumps({"config": "{}"})},
+ )
+ assert imported.status_code == 200
+ assert imported.json()["last_action"]["message"] == "import:config MCP config reloaded."
+
+ tools = await _http_get(
+ "http://127.0.0.1:29913/api/settings/mcp-presets/tools",
+ headers={
+ **auth,
+ "X-Nanobot-MCP-Values": json.dumps(
+ {"name": "docs", "enabled_tools": []}
+ ),
+ },
+ )
+ assert tools.status_code == 200
+ assert tools.json()["last_action"]["message"] == "tools:docs MCP config reloaded."
+ finally:
+ await channel.stop()
+ await server_task
+
+
@pytest.mark.asyncio
async def test_sessions_list_only_returns_websocket_sessions_by_default(
bus: MagicMock, tmp_path: Path
diff --git a/tests/tools/test_mcp_tool.py b/tests/tools/test_mcp_tool.py
index de39d1a67..68fadce44 100644
--- a/tests/tools/test_mcp_tool.py
+++ b/tests/tools/test_mcp_tool.py
@@ -52,10 +52,17 @@ def _fake_mcp_module(
)
class _FakeStdioServerParameters:
- def __init__(self, command: str, args: list[str], env: dict | None = None) -> None:
+ def __init__(
+ self,
+ command: str,
+ args: list[str],
+ env: dict | None = None,
+ cwd: str | None = None,
+ ) -> None:
self.command = command
self.args = args
self.env = env
+ self.cwd = cwd
class _FakeClientSession:
def __init__(self, _read: object, _write: object) -> None:
@@ -561,6 +568,32 @@ async def test_connect_mcp_servers_wraps_windows_stdio_launchers(
assert captured["env"] is None
+@pytest.mark.asyncio
+async def test_connect_mcp_servers_passes_stdio_cwd(
+ fake_mcp_runtime: dict[str, object | None],
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ fake_mcp_runtime["session"] = _make_fake_session(["demo"])
+ captured: dict[str, object] = {}
+
+ @asynccontextmanager
+ async def _capturing_stdio_client(params: object):
+ captured["cwd"] = params.cwd
+ yield object(), object()
+
+ monkeypatch.setattr(sys.modules["mcp.client.stdio"], "stdio_client", _capturing_stdio_client)
+
+ registry = ToolRegistry()
+ stacks = await connect_mcp_servers(
+ {"test": MCPServerConfig(command="fake", cwd="/tmp/nanobot-mcp-test")},
+ registry,
+ )
+ for stack in stacks.values():
+ await stack.aclose()
+
+ assert captured["cwd"] == "/tmp/nanobot-mcp-test"
+
+
# ---------------------------------------------------------------------------
# MCPResourceWrapper tests
# ---------------------------------------------------------------------------
diff --git a/tests/webui/test_mcp_presets_api.py b/tests/webui/test_mcp_presets_api.py
new file mode 100644
index 000000000..6ddde0b47
--- /dev/null
+++ b/tests/webui/test_mcp_presets_api.py
@@ -0,0 +1,363 @@
+from __future__ import annotations
+
+import asyncio
+
+import pytest
+
+from nanobot.config.loader import load_config
+from nanobot.webui.mcp_presets_api import (
+ McpPresetError,
+ custom_mcp_action,
+ mcp_presets_action,
+ mcp_presets_payload,
+ mcp_presets_test_action,
+ normalize_mcp_preset_mentions,
+)
+
+
+def _use_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr("nanobot.config.loader._current_config_path", tmp_path / "config.json")
+
+
+def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
+ _use_config(tmp_path, monkeypatch)
+
+ payload = mcp_presets_payload()
+ names = {preset["name"] for preset in payload["presets"]}
+
+ assert {
+ "browserbase",
+ "playwright",
+ "github",
+ "figma",
+ "context7",
+ "firecrawl",
+ "exa",
+ "microsoft-learn",
+ "aws-docs",
+ "brave-search",
+ "postman",
+ }.issubset(names)
+ browserbase = next(preset for preset in payload["presets"] if preset["name"] == "browserbase")
+ assert browserbase["installed"] is False
+ assert browserbase["install_supported"] is True
+ assert browserbase["required_fields"][0]["configured"] is False
+ assert "browserbaseApiKey" not in browserbase["connection_summary"]
+
+
+def test_enable_browserbase_writes_scrubbed_config_payload(
+ tmp_path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ _use_config(tmp_path, monkeypatch)
+
+ payload = mcp_presets_action(
+ "enable",
+ {
+ "name": ["browserbase"],
+ "browserbase_api_key": ["bb_live_secret"],
+ },
+ )
+
+ assert payload["requires_restart"] is True
+ assert payload["last_action"]["ok"] is True
+ preset = next(row for row in payload["presets"] if row["name"] == "browserbase")
+ assert preset["installed"] is True
+ assert preset["configured"] is True
+ assert "bb_live_secret" not in str(payload)
+ config = load_config()
+ assert "browserbaseApiKey=bb_live_secret" in config.tools.mcp_servers["browserbase"].url
+
+
+def test_enable_requires_missing_secret(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
+ _use_config(tmp_path, monkeypatch)
+
+ with pytest.raises(McpPresetError) as exc:
+ mcp_presets_action("enable", {"name": ["browserbase"]})
+
+ assert exc.value.status == 400
+ assert "Browserbase API key" in exc.value.message
+
+
+def test_enable_context7_optional_api_key_appends_arg(
+ tmp_path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ _use_config(tmp_path, monkeypatch)
+
+ payload = mcp_presets_action(
+ "enable",
+ {
+ "name": ["context7"],
+ "context7_api_key": ["ctx7_secret"],
+ },
+ )
+
+ assert "ctx7_secret" not in str(payload)
+ row = next(item for item in payload["presets"] if item["name"] == "context7")
+ assert row["configured"] is True
+ config = load_config()
+ assert config.tools.mcp_servers["context7"].args == [
+ "-y",
+ "@upstash/context7-mcp@latest",
+ "--api-key",
+ "ctx7_secret",
+ ]
+
+
+def test_enable_stdio_preset_uses_config_scoped_cwd(
+ tmp_path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ _use_config(tmp_path, monkeypatch)
+
+ mcp_presets_action("enable", {"name": ["playwright"]})
+
+ config = load_config()
+ cwd = config.tools.mcp_servers["playwright"].cwd
+ assert cwd == str(tmp_path / "mcp" / "playwright")
+ assert (tmp_path / "mcp" / "playwright").is_dir()
+
+
+def test_enable_no_auth_remote_presets_write_url(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
+ _use_config(tmp_path, monkeypatch)
+
+ mcp_presets_action("enable", {"name": ["microsoft-learn"]})
+ mcp_presets_action("enable", {"name": ["exa"]})
+
+ config = load_config()
+ assert config.tools.mcp_servers["microsoft-learn"].url == "https://learn.microsoft.com/api/mcp"
+ assert config.tools.mcp_servers["exa"].url == "https://mcp.exa.ai/mcp"
+
+
+def test_enable_firecrawl_writes_scrubbed_env(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
+ _use_config(tmp_path, monkeypatch)
+
+ payload = mcp_presets_action(
+ "enable",
+ {
+ "name": ["firecrawl"],
+ "firecrawl_api_key": ["fc-secret"],
+ },
+ )
+
+ assert "fc-secret" not in str(payload)
+ config = load_config()
+ assert config.tools.mcp_servers["firecrawl"].env["FIRECRAWL_API_KEY"] == "fc-secret"
+
+
+def test_remove_mcp_preset_updates_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
+ _use_config(tmp_path, monkeypatch)
+ mcp_presets_action("enable", {"name": ["playwright"]})
+
+ payload = mcp_presets_action("remove", {"name": ["playwright"]})
+
+ assert payload["requires_restart"] is True
+ config = load_config()
+ assert "playwright" not in config.tools.mcp_servers
+
+
+def test_test_mcp_preset_reports_missing_dependency(
+ tmp_path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ _use_config(tmp_path, monkeypatch)
+ mcp_presets_action("enable", {"name": ["playwright"]})
+ monkeypatch.setattr("nanobot.webui.mcp_presets_api.shutil.which", lambda _command: None)
+
+ payload = asyncio.run(mcp_presets_test_action({"name": ["playwright"]}))
+
+ assert payload["last_action"]["ok"] is False
+ assert "npx" in payload["last_action"]["message"]
+
+
+def test_test_mcp_preset_connects_and_reports_tools(
+ tmp_path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ _use_config(tmp_path, monkeypatch)
+ mcp_presets_action("enable", {"name": ["playwright"]})
+
+ class FakeStack:
+ async def aclose(self) -> None:
+ return None
+
+ async def fake_connect(servers, registry):
+ assert list(servers) == ["playwright"]
+
+ class FakeTool:
+ name = "mcp_playwright_browser_navigate"
+
+ def to_schema(self):
+ return {"name": self.name, "description": "", "parameters": {}}
+
+ registry.register(FakeTool())
+ return {"playwright": FakeStack()}
+
+ monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", fake_connect)
+
+ payload = asyncio.run(mcp_presets_test_action({"name": ["playwright"]}))
+
+ assert payload["last_action"]["ok"] is True
+ assert payload["last_action"]["tool_count"] == 1
+ assert payload["last_action"]["tool_names"] == ["mcp_playwright_browser_navigate"]
+
+
+def test_test_mcp_preset_scrubs_connection_errors(
+ tmp_path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ _use_config(tmp_path, monkeypatch)
+ mcp_presets_action(
+ "enable",
+ {
+ "name": ["browserbase"],
+ "browserbase_api_key": ["bb_live_secret"],
+ },
+ )
+
+ async def fake_connect(_servers, _registry):
+ raise RuntimeError("failed https://mcp.browserbase.com/mcp?browserbaseApiKey=bb_live_secret")
+
+ monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", fake_connect)
+
+ payload = asyncio.run(mcp_presets_test_action({"name": ["browserbase"]}))
+
+ assert payload["last_action"]["ok"] is False
+ assert "bb_live_secret" not in str(payload)
+ assert "" in payload["last_action"]["error"]
+
+
+def test_unlisted_oauth_placeholder_is_not_enabled(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
+ _use_config(tmp_path, monkeypatch)
+
+ with pytest.raises(McpPresetError) as exc:
+ mcp_presets_action("enable", {"name": ["linear"]})
+
+ assert exc.value.status == 404
+
+
+def test_normalize_mcp_preset_mentions_keeps_known_presets_only() -> None:
+ payload = normalize_mcp_preset_mentions([
+ {
+ "name": "browserbase",
+ "display_name": "Browserbase",
+ "transport": "streamableHttp",
+ "configured": True,
+ "logo_url": "https://example.invalid/logo.svg",
+ },
+ {"name": "totally-unknown"},
+ "bad",
+ ])
+
+ assert payload == [{
+ "name": "browserbase",
+ "display_name": "Browserbase",
+ "transport": "streamableHttp",
+ "configured": True,
+ "logo_url": "https://example.invalid/logo.svg",
+ }]
+
+
+def test_custom_mcp_server_writes_config_and_catalog_row(
+ tmp_path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ _use_config(tmp_path, monkeypatch)
+
+ payload = custom_mcp_action(
+ "custom",
+ {
+ "name": ["internal-docs"],
+ "transport": ["stdio"],
+ "command": ["node"],
+ "args": ['["server.js"]'],
+ "env": ['{"DOCS_TOKEN":"docs-secret-value"}'],
+ "tool_timeout": ["45"],
+ },
+ )
+
+ assert payload["requires_restart"] is True
+ row = next(item for item in payload["presets"] if item["name"] == "internal-docs")
+ assert row["source"] == "custom"
+ assert row["transport"] == "stdio"
+ assert row["connection_summary"] == "node server.js"
+ assert "docs-secret-value" not in str(payload)
+ config = load_config()
+ assert config.tools.mcp_servers["internal-docs"].args == ["server.js"]
+ assert config.tools.mcp_servers["internal-docs"].env["DOCS_TOKEN"] == "docs-secret-value"
+
+
+def test_import_mcp_config_and_tool_allowlist(
+ tmp_path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ _use_config(tmp_path, monkeypatch)
+
+ payload = custom_mcp_action(
+ "import",
+ {
+ "config": [
+ (
+ '{"mcpServers":{'
+ '"docs":{"command":"npx","args":["-y","docs-mcp"],"env":{"API_KEY":"config-secret-value"}},'
+ '"remote-docs":{"transport":"sse","url":"https://example.com/sse"}'
+ '}}'
+ )
+ ],
+ },
+ )
+
+ assert payload["last_action"]["message"] == "Imported 2 MCP server(s)."
+ config = load_config()
+ assert config.tools.mcp_servers["docs"].command == "npx"
+ assert config.tools.mcp_servers["docs"].args == ["-y", "docs-mcp"]
+ assert config.tools.mcp_servers["remote-docs"].type == "sse"
+ assert config.tools.mcp_servers["remote-docs"].url == "https://example.com/sse"
+ assert config.tools.mcp_servers["docs"].env["API_KEY"] == "config-secret-value"
+ assert "config-secret-value" not in str(payload)
+
+ payload = custom_mcp_action(
+ "tools",
+ {
+ "name": ["docs"],
+ "enabled_tools": ['["mcp_docs_search"]'],
+ },
+ )
+
+ row = next(item for item in payload["presets"] if item["name"] == "docs")
+ assert row["enabled_tools"] == ["mcp_docs_search"]
+ assert load_config().tools.mcp_servers["docs"].enabled_tools == ["mcp_docs_search"]
+
+ payload = custom_mcp_action(
+ "tools",
+ {
+ "name": ["docs"],
+ "enabled_tools": ["[]"],
+ },
+ )
+
+ row = next(item for item in payload["presets"] if item["name"] == "docs")
+ assert row["enabled_tools"] == []
+ assert load_config().tools.mcp_servers["docs"].enabled_tools == []
+
+
+def test_normalize_mcp_preset_mentions_accepts_configured_custom_server(
+ tmp_path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ _use_config(tmp_path, monkeypatch)
+ custom_mcp_action(
+ "custom",
+ {
+ "name": ["docs"],
+ "transport": ["streamableHttp"],
+ "url": ["https://example.com/mcp"],
+ },
+ )
+
+ payload = normalize_mcp_preset_mentions([
+ {"name": "docs", "display_name": "Docs", "transport": "streamableHttp"},
+ ])
+
+ assert payload == [{"name": "docs", "display_name": "Docs", "transport": "streamableHttp"}]
diff --git a/tests/webui/test_mcp_presets_runtime.py b/tests/webui/test_mcp_presets_runtime.py
new file mode 100644
index 000000000..6abef66f5
--- /dev/null
+++ b/tests/webui/test_mcp_presets_runtime.py
@@ -0,0 +1,80 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+from nanobot.webui import mcp_presets_runtime
+
+
+def test_mcp_preset_runtime_lines_describe_tool_prefix() -> None:
+ msg = SimpleNamespace(
+ content="use @browserbase",
+ metadata={
+ "mcp_presets": [{
+ "name": "browserbase",
+ "display_name": "Browserbase",
+ "transport": "streamableHttp",
+ }],
+ },
+ )
+
+ lines = mcp_presets_runtime.runtime_lines(
+ msg,
+ configured_server_names={"browserbase"},
+ connected_server_names={"browserbase"},
+ )
+
+ assert lines
+ assert "@browserbase" in lines[0]
+ assert "mcp_browserbase_" in lines[0]
+ assert "shell commands" in lines[0]
+
+
+def test_mcp_preset_runtime_lines_warn_when_restart_needed() -> None:
+ msg = SimpleNamespace(
+ content="use @browserbase",
+ metadata={
+ "mcp_presets": [{
+ "name": "browserbase",
+ "display_name": "Browserbase",
+ "transport": "streamableHttp",
+ }],
+ },
+ )
+
+ lines = mcp_presets_runtime.runtime_lines(
+ msg,
+ configured_server_names=set(),
+ connected_server_names=set(),
+ )
+
+ assert lines
+ assert "has not loaded the latest MCP settings" in lines[0]
+
+
+def test_mcp_preset_runtime_lines_warn_when_connection_not_live() -> None:
+ msg = SimpleNamespace(
+ content="use @browserbase",
+ metadata={
+ "mcp_presets": [{
+ "name": "browserbase",
+ "display_name": "Browserbase",
+ "transport": "streamableHttp",
+ }],
+ },
+ )
+
+ lines = mcp_presets_runtime.runtime_lines(
+ msg,
+ configured_server_names={"browserbase"},
+ connected_server_names=set(),
+ )
+
+ assert lines
+ assert "connection is not currently live" in lines[0]
+
+
+def test_mcp_preset_session_extra_only_persists_structured_mentions() -> None:
+ assert mcp_presets_runtime.session_extra({}) == {}
+ assert mcp_presets_runtime.session_extra({
+ "mcp_presets": [{"name": "browserbase"}],
+ }) == {"mcp_presets": [{"name": "browserbase"}]}
diff --git a/tests/webui/test_settings_api.py b/tests/webui/test_settings_api.py
new file mode 100644
index 000000000..d1b6c175e
--- /dev/null
+++ b/tests/webui/test_settings_api.py
@@ -0,0 +1,67 @@
+from __future__ import annotations
+
+import pytest
+
+from nanobot.config.loader import load_config, save_config
+from nanobot.config.schema import Config
+from nanobot.webui.settings_api import WebUISettingsError, create_model_configuration
+
+
+def test_create_model_configuration_writes_label_and_selects(
+ tmp_path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ config_path = tmp_path / "config.json"
+ config = Config()
+ config.agents.defaults.model = "openai/gpt-4o"
+ config.agents.defaults.provider = "openai"
+ config.providers.openai.api_key = "sk-test"
+ save_config(config, config_path)
+ monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
+
+ payload = create_model_configuration(
+ {
+ "label": ["Fast writing"],
+ "provider": ["openai"],
+ "model": ["openai/gpt-4.1-mini"],
+ }
+ )
+
+ assert payload["agent"]["model_preset"] == "fast-writing"
+ assert payload["agent"]["model"] == "openai/gpt-4.1-mini"
+ rows = {row["name"]: row for row in payload["model_presets"]}
+ assert rows["fast-writing"]["label"] == "Fast writing"
+
+ saved = load_config(config_path)
+ assert saved.agents.defaults.model_preset == "fast-writing"
+ assert saved.model_presets["fast-writing"].label == "Fast writing"
+ assert saved.model_presets["fast-writing"].model == "openai/gpt-4.1-mini"
+ assert saved.model_presets["fast-writing"].provider == "openai"
+
+ with pytest.raises(WebUISettingsError) as duplicate:
+ create_model_configuration(
+ {
+ "label": ["Fast writing"],
+ "provider": ["openai"],
+ "model": ["openai/gpt-4.1-mini"],
+ }
+ )
+ assert duplicate.value.status == 409
+
+
+def test_create_model_configuration_rejects_unconfigured_provider(
+ tmp_path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ config_path = tmp_path / "config.json"
+ save_config(Config(), config_path)
+ monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
+
+ with pytest.raises(WebUISettingsError, match="provider is not configured"):
+ create_model_configuration(
+ {
+ "label": ["Deep"],
+ "provider": ["openai"],
+ "model": ["openai/gpt-4.1"],
+ }
+ )
diff --git a/webui/src/components/CliAppMentionText.tsx b/webui/src/components/CliAppMentionText.tsx
index d9adbd877..33a9114f2 100644
--- a/webui/src/components/CliAppMentionText.tsx
+++ b/webui/src/components/CliAppMentionText.tsx
@@ -1,12 +1,17 @@
-import { useState } from "react";
+import { useEffect, useMemo, useState } from "react";
-import type { CliAppInfo } from "@/lib/types";
+import { logoFallbackUrls } from "@/lib/provider-brand";
+import type { CliAppInfo, McpPresetInfo } from "@/lib/types";
import { cn } from "@/lib/utils";
export type CliAppMentionSegment =
| { kind: "text"; text: string }
| { kind: "cli"; text: string; app: CliAppInfo };
+export type CapabilityMentionSegment =
+ | CliAppMentionSegment
+ | { kind: "mcp"; text: string; preset: McpPresetInfo };
+
export function cliAppInitials(app: CliAppInfo): string {
const value = app.display_name || app.name;
return (
@@ -19,6 +24,18 @@ export function cliAppInitials(app: CliAppInfo): string {
);
}
+export function mcpPresetInitials(preset: Pick): string {
+ const value = preset.display_name || preset.name;
+ return (
+ value
+ .split(/\s+/)
+ .filter(Boolean)
+ .slice(0, 2)
+ .map((part) => part[0]?.toUpperCase())
+ .join("") || preset.name.slice(0, 2).toUpperCase()
+ );
+}
+
export function splitCliAppMentionSegments(
value: string,
cliApps: CliAppInfo[],
@@ -55,22 +72,76 @@ export function splitCliAppMentionSegments(
return segments.length ? segments : [{ kind: "text", text: value }];
}
+export function splitCapabilityMentionSegments(
+ value: string,
+ cliApps: CliAppInfo[],
+ mcpPresets: McpPresetInfo[] = [],
+): CapabilityMentionSegment[] {
+ if (!value || (cliApps.length === 0 && mcpPresets.length === 0)) {
+ return value ? [{ kind: "text", text: value }] : [];
+ }
+ const cliAppsByName = new Map(
+ cliApps
+ .filter((app) => app.installed)
+ .map((app) => [app.name.toLowerCase(), app]),
+ );
+ const mcpPresetsByName = new Map(
+ mcpPresets
+ .filter((preset) => preset.installed && preset.configured)
+ .map((preset) => [preset.name.toLowerCase(), preset]),
+ );
+ if (cliAppsByName.size === 0 && mcpPresetsByName.size === 0) {
+ return [{ kind: "text", text: value }];
+ }
+
+ const segments: CapabilityMentionSegment[] = [];
+ const mentionRe = /(^|[\s([{])@([a-z0-9_-]+)\b/gi;
+ let cursor = 0;
+ let match: RegExpExecArray | null;
+ while ((match = mentionRe.exec(value)) !== null) {
+ const prefix = match[1] ?? "";
+ const name = match[2] ?? "";
+ const key = name.toLowerCase();
+ const app = cliAppsByName.get(key);
+ const preset = app ? null : mcpPresetsByName.get(key);
+ if (!app && !preset) continue;
+
+ const mentionStart = match.index + prefix.length;
+ const mentionEnd = mentionStart + name.length + 1;
+ if (mentionStart > cursor) {
+ segments.push({ kind: "text", text: value.slice(cursor, mentionStart) });
+ }
+ if (app) {
+ segments.push({ kind: "cli", text: value.slice(mentionStart, mentionEnd), app });
+ } else if (preset) {
+ segments.push({ kind: "mcp", text: value.slice(mentionStart, mentionEnd), preset });
+ }
+ cursor = mentionEnd;
+ }
+ if (cursor < value.length) {
+ segments.push({ kind: "text", text: value.slice(cursor) });
+ }
+ return segments.length ? segments : [{ kind: "text", text: value }];
+}
+
export function CliAppMentionText({
text,
cliApps,
+ mcpPresets = [],
}: {
text: string;
cliApps: CliAppInfo[];
+ mcpPresets?: McpPresetInfo[];
}) {
- const segments = splitCliAppMentionSegments(text, cliApps);
- if (!segments.some((segment) => segment.kind === "cli")) return <>{text}>;
+ const segments = splitCapabilityMentionSegments(text, cliApps, mcpPresets);
+ if (!segments.some((segment) => segment.kind === "cli" || segment.kind === "mcp")) return <>{text}>;
return (
<>
{segments.map((segment, index) => {
if (segment.kind === "text") {
return {segment.text};
}
- return (
+ if (segment.kind === "cli") return (
);
+ return (
+
+ );
})}
>
);
@@ -94,15 +173,20 @@ export function CliAppMentionToken({
variant: "composer" | "message";
isHero?: boolean;
}) {
- const [failed, setFailed] = useState(false);
+ const [logoIndex, setLogoIndex] = useState(0);
const color = app.brand_color || "hsl(var(--primary))";
const mentionName = label.startsWith("@") ? label.slice(1) : label;
- const showLogo = Boolean(app.logo_url) && !failed;
+ const logoUrls = useMemo(() => logoFallbackUrls(app.logo_url), [app.logo_url]);
+ const logoUrl = logoUrls[logoIndex];
+ const showLogo = Boolean(logoUrl);
const testIdPrefix = variant === "composer" ? "composer" : "message";
+ useEffect(() => setLogoIndex(0), [app.logo_url]);
+
return (
setFailed(true)}
+ onError={() => setLogoIndex((index) => index + 1)}
+ />
+
+ ) : null}
+
+ {mentionName}
+
+ );
+}
+
+export function McpPresetMentionToken({
+ preset,
+ label,
+ variant,
+ isHero = false,
+}: {
+ preset: McpPresetInfo;
+ label: string;
+ variant: "composer" | "message";
+ isHero?: boolean;
+}) {
+ const [logoIndex, setLogoIndex] = useState(0);
+ const color = preset.brand_color || "hsl(var(--primary))";
+ const mentionName = label.startsWith("@") ? label.slice(1) : label;
+ const logoUrls = useMemo(() => logoFallbackUrls(preset.logo_url), [preset.logo_url]);
+ const logoUrl = logoUrls[logoIndex];
+ const showLogo = Boolean(logoUrl);
+ const testIdPrefix = variant === "composer" ? "composer" : "message";
+
+ useEffect(() => setLogoIndex(0), [preset.logo_url]);
+
+ return (
+
+
+ @
+ {showLogo ? (
+
+
setLogoIndex((index) => index + 1)}
/>
) : null}
diff --git a/webui/src/components/MessageBubble.tsx b/webui/src/components/MessageBubble.tsx
index 9868ef31f..157d4e291 100644
--- a/webui/src/components/MessageBubble.tsx
+++ b/webui/src/components/MessageBubble.tsx
@@ -14,13 +14,22 @@ import { ImageLightbox } from "@/components/ImageLightbox";
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
import { cn } from "@/lib/utils";
import { formatTurnLatency } from "@/lib/format";
-import type { CliAppInfo, UICliAppAttachment, UIImage, UIMediaAttachment, UIMessage } from "@/lib/types";
+import type {
+ CliAppInfo,
+ McpPresetInfo,
+ UICliAppAttachment,
+ UIMcpPresetAttachment,
+ UIImage,
+ UIMediaAttachment,
+ UIMessage,
+} from "@/lib/types";
interface MessageBubbleProps {
message: UIMessage;
/** When false, hide the assistant reply copy button (mid-turn text before more agent activity). Default true. */
showAssistantCopyAction?: boolean;
cliApps?: CliAppInfo[];
+ mcpPresets?: McpPresetInfo[];
}
/**
@@ -36,6 +45,7 @@ export function MessageBubble({
message,
showAssistantCopyAction = true,
cliApps = [],
+ mcpPresets = [],
}: MessageBubbleProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
@@ -45,6 +55,10 @@ export function MessageBubble({
() => mergeCliMentionApps(cliApps, message.cliApps),
[cliApps, message.cliApps],
);
+ const mentionMcpPresets = useMemo(
+ () => mergeMcpMentionPresets(mcpPresets, message.mcpPresets),
+ [mcpPresets, message.mcpPresets],
+ );
useEffect(() => {
return () => {
@@ -96,7 +110,11 @@ export function MessageBubble({
"text-left text-[16px]/[1.75] whitespace-pre-wrap break-words",
)}
>
-
+
) : null}
@@ -166,6 +184,39 @@ export function MessageBubble({
);
}
+function mergeMcpMentionPresets(
+ presets: McpPresetInfo[],
+ attachments: UIMcpPresetAttachment[] | undefined,
+): McpPresetInfo[] {
+ if (!attachments?.length) return presets;
+ const byName = new Map(presets.map((preset) => [preset.name.toLowerCase(), preset]));
+ for (const attachment of attachments) {
+ const name = attachment.name?.trim();
+ if (!name) continue;
+ const existing = byName.get(name.toLowerCase());
+ byName.set(name.toLowerCase(), {
+ name,
+ display_name: attachment.display_name || existing?.display_name || name,
+ category: attachment.category || existing?.category || "mcp",
+ description: existing?.description || "",
+ docs_url: existing?.docs_url || "",
+ transport: attachment.transport || existing?.transport || "mcp",
+ requires: existing?.requires || "",
+ note: existing?.note || "",
+ install_supported: existing?.install_supported ?? true,
+ installed: true,
+ configured: attachment.configured ?? existing?.configured ?? true,
+ available: existing?.available ?? true,
+ status: attachment.status || existing?.status || "configured",
+ logo_url: attachment.logo_url ?? existing?.logo_url ?? null,
+ brand_color: attachment.brand_color ?? existing?.brand_color ?? null,
+ required_fields: existing?.required_fields || [],
+ connection_summary: existing?.connection_summary || "",
+ });
+ }
+ return Array.from(byName.values());
+}
+
function mergeCliMentionApps(
cliApps: CliAppInfo[],
attachments: UICliAppAttachment[] | undefined,
diff --git a/webui/src/components/SessionSearchDialog.tsx b/webui/src/components/SessionSearchDialog.tsx
index 1e0f12044..a512f33f5 100644
--- a/webui/src/components/SessionSearchDialog.tsx
+++ b/webui/src/components/SessionSearchDialog.tsx
@@ -33,6 +33,7 @@ export function SessionSearchDialog({
}: SessionSearchDialogProps) {
const { t } = useTranslation();
const inputRef = useRef(null);
+ const itemRefs = useRef>([]);
const [query, setQuery] = useState("");
const [highlightedIndex, setHighlightedIndex] = useState(0);
@@ -46,7 +47,6 @@ export function SessionSearchDialog({
);
}, [normalizedQuery, open, sessions, titleOverrides]);
const itemCount = sessionResults.length;
- const shortcutLabel = useMemo(getSearchShortcutLabel, []);
useEffect(() => {
if (!open) return;
@@ -65,6 +65,18 @@ export function SessionSearchDialog({
);
}, [itemCount]);
+ useEffect(() => {
+ itemRefs.current = itemRefs.current.slice(0, itemCount);
+ }, [itemCount]);
+
+ useEffect(() => {
+ if (!open) return;
+ itemRefs.current[highlightedIndex]?.scrollIntoView({
+ block: "nearest",
+ inline: "nearest",
+ });
+ }, [highlightedIndex, open]);
+
const handleSelect = (key: string) => {
onOpenChange(false);
onSelect(key);
@@ -107,18 +119,18 @@ export function SessionSearchDialog({
{t("sidebar.searchAria")}
{t("sidebar.searchPlaceholder")}
-
+
-
- {shortcutLabel}
-
-
+
-
+
{sectionLabel}
@@ -150,7 +162,7 @@ export function SessionSearchDialog({
{emptyLabel}
) : (
-
+
{sessionResults.map((session, index) => {
const title = titleOverrides[session.key]?.trim() ||
session.title?.trim() ||
@@ -164,15 +176,18 @@ export function SessionSearchDialog({
return (
-
);
@@ -221,13 +236,3 @@ function sessionMatchesTerms(
return terms.every((term) => haystack.includes(term));
}
-
-function getSearchShortcutLabel() {
- if (typeof navigator === "undefined") return "Ctrl K";
- const platform = navigator.platform.toLowerCase();
- const apple =
- platform.includes("mac") ||
- platform.includes("iphone") ||
- platform.includes("ipad");
- return apple ? "⌘K" : "Ctrl K";
-}
diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx
index fa74c36a1..1176fe317 100644
--- a/webui/src/components/settings/SettingsView.tsx
+++ b/webui/src/components/settings/SettingsView.tsx
@@ -27,13 +27,13 @@ import {
Hexagon,
ImageIcon,
Info,
- KeyRound,
Layers,
Loader2,
LogOut,
Moon,
Package,
PlayCircle,
+ Plus,
Orbit,
Palette,
Pencil,
@@ -59,23 +59,46 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
-import { Input } from "@/components/ui/input";
import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ createModelConfiguration,
fetchSettings,
fetchCliApps,
+ fetchMcpPresets,
+ importMcpConfig,
runCliAppAction,
+ runMcpPresetAction,
+ saveCustomMcpServer,
updateImageGenerationSettings,
+ updateMcpServerTools,
updateProviderSettings,
updateSettings,
updateWebSearchSettings,
} from "@/lib/api";
import { notifyCliAppsChanged } from "@/lib/cli-app-events";
+import { notifyMcpPresetsChanged } from "@/lib/mcp-preset-events";
+import {
+ logoFallbackUrls,
+ providerBrand,
+ providerDisplayLabel,
+} from "@/lib/provider-brand";
import { cn } from "@/lib/utils";
import { useClient } from "@/providers/ClientProvider";
import type {
CliAppInfo,
CliAppsPayload,
ImageGenerationSettingsUpdate,
+ McpPresetInfo,
+ McpPresetsPayload,
SettingsPayload,
WebSearchSettingsUpdate,
} from "@/lib/types";
@@ -84,10 +107,10 @@ type SettingsSectionKey =
| "overview"
| "appearance"
| "models"
- | "providers"
| "image"
| "web"
| "cliApps"
+ | "mcp"
| "runtime"
| "advanced";
@@ -111,8 +134,52 @@ interface AgentSettingsDraft {
toolHintMaxLength: number;
}
+interface ModelConfigurationDraft {
+ label: string;
+ provider: string;
+ model: string;
+}
+
type PendingRestartSection = "runtime" | "web" | "image";
type PendingRestartSections = Record;
+type CustomMcpTransport = "stdio" | "streamableHttp" | "sse";
+
+const NANOBOT_ICON_SRC = "/brand/nanobot_icon.png";
+
+const FALLBACK_TIMEZONES = [
+ "UTC",
+ "Asia/Shanghai",
+ "Asia/Hong_Kong",
+ "Asia/Tokyo",
+ "Asia/Seoul",
+ "Asia/Singapore",
+ "Asia/Taipei",
+ "Asia/Dubai",
+ "Asia/Kolkata",
+ "Europe/London",
+ "Europe/Paris",
+ "Europe/Berlin",
+ "Europe/Amsterdam",
+ "America/New_York",
+ "America/Chicago",
+ "America/Denver",
+ "America/Los_Angeles",
+ "America/Toronto",
+ "America/Sao_Paulo",
+ "Australia/Sydney",
+ "Pacific/Auckland",
+];
+
+interface CustomMcpForm {
+ name: string;
+ transport: CustomMcpTransport;
+ command: string;
+ args: string;
+ url: string;
+ env: string;
+ headers: string;
+ toolTimeout: string;
+}
const LOCAL_PREFS_STORAGE_KEY = "nanobot-webui.settings-preferences";
@@ -138,6 +205,17 @@ const EMPTY_PENDING_RESTART_SECTIONS: PendingRestartSections = {
image: false,
};
+const DEFAULT_CUSTOM_MCP_FORM: CustomMcpForm = {
+ name: "",
+ transport: "stdio",
+ command: "",
+ args: "",
+ url: "",
+ env: "",
+ headers: "",
+ toolTimeout: "30",
+};
+
interface SettingsViewProps {
theme: "light" | "dark";
onToggleTheme: () => void;
@@ -190,10 +268,20 @@ export function SettingsView({
const { token } = useClient();
const [settings, setSettings] = useState(null);
const [cliApps, setCliApps] = useState(null);
+ const [mcpPresets, setMcpPresets] = useState(null);
const [loading, setLoading] = useState(true);
const [cliAppsLoading, setCliAppsLoading] = useState(true);
+ const [mcpPresetsLoading, setMcpPresetsLoading] = useState(true);
const [saving, setSaving] = useState(false);
+ const [modelConfigurationOpen, setModelConfigurationOpen] = useState(false);
+ const [modelConfigurationSaving, setModelConfigurationSaving] = useState(false);
+ const [modelConfigurationForm, setModelConfigurationForm] = useState({
+ label: "",
+ provider: "",
+ model: "",
+ });
const [cliAppsAction, setCliAppsAction] = useState(null);
+ const [mcpPresetAction, setMcpPresetAction] = useState(null);
const [providerSaving, setProviderSaving] = useState(null);
const [webSearchSaving, setWebSearchSaving] = useState(false);
const [imageGenerationSaving, setImageGenerationSaving] = useState(false);
@@ -207,6 +295,14 @@ export function SettingsView({
const [cliAppsMessage, setCliAppsMessage] = useState(null);
const [cliAppsError, setCliAppsError] = useState(null);
const [cliAppsFocusName, setCliAppsFocusName] = useState(null);
+ const [mcpQuery, setMcpQuery] = useState("");
+ const [mcpCategory, setMcpCategory] = useState("all");
+ const [mcpInstallFilter, setMcpInstallFilter] = useState<"all" | "installed" | "notInstalled">("all");
+ const [mcpMessage, setMcpMessage] = useState(null);
+ const [mcpError, setMcpError] = useState(null);
+ const [mcpFieldValues, setMcpFieldValues] = useState>>({});
+ const [customMcpForm, setCustomMcpForm] = useState(DEFAULT_CUSTOM_MCP_FORM);
+ const [mcpConfigImport, setMcpConfigImport] = useState("");
const [providerForms, setProviderForms] = useState>({});
const [visibleProviderKeys, setVisibleProviderKeys] = useState>({});
const [editingProviderKeys, setEditingProviderKeys] = useState>({});
@@ -327,6 +423,27 @@ export function SettingsView({
};
}, [token]);
+ useEffect(() => {
+ let cancelled = false;
+ setMcpPresetsLoading(true);
+ fetchMcpPresets(token)
+ .then((payload) => {
+ if (!cancelled) {
+ setMcpPresets(payload);
+ setMcpError(null);
+ }
+ })
+ .catch((err) => {
+ if (!cancelled) setMcpError((err as Error).message);
+ })
+ .finally(() => {
+ if (!cancelled) setMcpPresetsLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [token]);
+
useEffect(() => {
try {
window.localStorage.setItem(LOCAL_PREFS_STORAGE_KEY, JSON.stringify(localPrefs));
@@ -383,6 +500,14 @@ export function SettingsView({
);
}, [imageGenerationForm, settings]);
+ const configuredModelProviderOptions = useMemo(
+ () =>
+ settings?.providers
+ .filter((provider) => provider.configured)
+ .map((provider) => ({ name: provider.name, label: provider.label })) ?? [],
+ [settings],
+ );
+
const hasPendingRestart = useMemo(
() =>
!!settings?.requires_restart ||
@@ -413,6 +538,45 @@ export function SettingsView({
}
};
+ const openModelConfigurationDialog = () => {
+ if (!settings) return;
+ const currentProvider = settings.agent.provider;
+ const provider =
+ configuredModelProviderOptions.find((option) => option.name === currentProvider)?.name ??
+ configuredModelProviderOptions[0]?.name ??
+ "";
+ setModelConfigurationForm({
+ label: "",
+ provider,
+ model: "",
+ });
+ setModelConfigurationOpen(true);
+ };
+
+ const handleCreateModelConfiguration = async () => {
+ if (modelConfigurationSaving) return;
+ const label = modelConfigurationForm.label.trim();
+ const provider = modelConfigurationForm.provider.trim();
+ const model = modelConfigurationForm.model.trim();
+ if (!label || !provider || !model) return;
+ setModelConfigurationSaving(true);
+ try {
+ const payload = await createModelConfiguration(token, {
+ label,
+ provider,
+ model,
+ });
+ applyPayload(payload);
+ onModelNameChange(payload.agent.model || null);
+ setModelConfigurationOpen(false);
+ setError(null);
+ } catch (err) {
+ setError((err as Error).message);
+ } finally {
+ setModelConfigurationSaving(false);
+ }
+ };
+
const saveRuntimeSettings = async () => {
if (!settings || !runtimeDirty || saving) return;
setSaving(true);
@@ -639,6 +803,105 @@ export function SettingsView({
}
};
+ const handleMcpPresetAction = async (
+ action: "enable" | "remove" | "test",
+ name: string,
+ values: Record = {},
+ ) => {
+ const key = `${action}:${name}`;
+ setMcpPresetAction(key);
+ setMcpMessage(null);
+ setMcpError(null);
+ try {
+ const payload = await runMcpPresetAction(token, action, name, values);
+ setMcpPresets(payload);
+ setMcpMessage(payload.last_action?.message ?? null);
+ if (action !== "test") {
+ notifyMcpPresetsChanged(payload);
+ }
+ if (payload.requires_restart) {
+ setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
+ }
+ 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 key = `custom:${name || "new"}`;
+ setMcpPresetAction(key);
+ setMcpMessage(null);
+ setMcpError(null);
+ try {
+ const payload = await saveCustomMcpServer(token, {
+ name,
+ transport: customMcpForm.transport,
+ command: customMcpForm.command,
+ args: customMcpForm.args,
+ url: customMcpForm.url,
+ env: customMcpForm.env,
+ headers: customMcpForm.headers,
+ tool_timeout: customMcpForm.toolTimeout,
+ });
+ setMcpPresets(payload);
+ setMcpMessage(payload.last_action?.message ?? null);
+ notifyMcpPresetsChanged(payload);
+ if (payload.requires_restart) {
+ setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
+ }
+ 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(token, mcpConfigImport);
+ setMcpPresets(payload);
+ setMcpMessage(payload.last_action?.message ?? null);
+ notifyMcpPresetsChanged(payload);
+ if (payload.requires_restart) {
+ setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
+ }
+ 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(token, name, enabledTools);
+ setMcpPresets(payload);
+ setMcpMessage(payload.last_action?.message ?? null);
+ notifyMcpPresetsChanged(payload);
+ if (payload.requires_restart) {
+ setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
+ }
+ } catch (err) {
+ setMcpError((err as Error).message);
+ } finally {
+ setMcpPresetAction(null);
+ }
+ };
+
const renderSection = () => {
if (!settings) return null;
switch (activeSection) {
@@ -649,6 +912,7 @@ export function SettingsView({
requiresRestart={hasPendingRestart}
onRestart={onRestart}
isRestarting={isRestarting}
+ showBrandLogos={localPrefs.brandLogos}
onSelectSection={setActiveSection}
/>
);
@@ -663,47 +927,47 @@ export function SettingsView({
);
case "models":
return (
- setActiveSection("providers")}
- />
- );
- case "providers":
- return (
-
- setProviderForms((prev) => ({
- ...prev,
- [provider]: {
- apiKey: prev[provider]?.apiKey ?? "",
- apiBase: prev[provider]?.apiBase ?? "",
- ...value,
- },
- }))
- }
- onSaveProvider={saveProvider}
- onResetProviderDraft={resetProviderDraft}
- imageProviderRestartPending={pendingRestartSections.image}
- onRestart={onRestart}
- isRestarting={isRestarting}
- />
+
+
+
+ setProviderForms((prev) => ({
+ ...prev,
+ [provider]: {
+ apiKey: prev[provider]?.apiKey ?? "",
+ apiBase: prev[provider]?.apiBase ?? "",
+ ...value,
+ },
+ }))
+ }
+ onSaveProvider={saveProvider}
+ onResetProviderDraft={resetProviderDraft}
+ imageProviderRestartPending={pendingRestartSections.image}
+ onRestart={onRestart}
+ isRestarting={isRestarting}
+ />
+
);
case "image":
return (
@@ -714,7 +978,8 @@ export function SettingsView({
saving={imageGenerationSaving}
onChangeForm={setImageGenerationForm}
onSave={saveImageGenerationSettings}
- onOpenProviders={() => setActiveSection("providers")}
+ onOpenProviders={() => setActiveSection("models")}
+ showBrandLogos={localPrefs.brandLogos}
onRestart={onRestart}
isRestarting={isRestarting}
requiresRestartPending={pendingRestartSections.image}
@@ -738,6 +1003,7 @@ export function SettingsView({
}}
onReset={resetWebSearchDraft}
onSave={saveWebSearch}
+ showBrandLogos={localPrefs.brandLogos}
onRestart={onRestart}
isRestarting={isRestarting}
requiresRestartPending={pendingRestartSections.web}
@@ -763,6 +1029,44 @@ export function SettingsView({
onBackToChat={onBackToChat}
/>
);
+ case "mcp":
+ return (
+ {
+ setMcpFieldValues((prev) => ({
+ ...prev,
+ [presetName]: {
+ ...(prev[presetName] ?? {}),
+ [fieldName]: value,
+ },
+ }));
+ }}
+ onAction={handleMcpPresetAction}
+ onSaveCustom={handleSaveCustomMcp}
+ onImportConfig={handleImportMcpConfig}
+ onToolsChange={handleMcpToolsChange}
+ onRestart={onRestart}
+ isRestarting={isRestarting}
+ />
+ );
case "runtime":
return (
+
+
@@ -835,10 +1150,10 @@ const SETTINGS_NAV_ITEMS: Array<{ key: SettingsSectionKey; icon: LucideIcon; fal
{ key: "overview", icon: Activity, fallback: "Overview" },
{ key: "appearance", icon: Palette, fallback: "Appearance" },
{ key: "models", icon: SlidersHorizontal, fallback: "Models" },
- { key: "providers", icon: KeyRound, fallback: "Providers" },
{ key: "image", icon: ImageIcon, fallback: "Image" },
{ key: "web", icon: Globe2, fallback: "Web" },
{ key: "cliApps", icon: Package, fallback: "CLI Apps" },
+ { key: "mcp", icon: Layers, fallback: "MCP" },
{ key: "runtime", icon: Server, fallback: "Runtime" },
{ key: "advanced", icon: ShieldCheck, fallback: "Advanced" },
];
@@ -924,16 +1239,17 @@ function OverviewSettings({
onRestart,
isRestarting,
onSelectSection,
+ showBrandLogos,
}: {
settings: SettingsPayload;
requiresRestart: boolean;
onRestart?: () => void;
isRestarting?: boolean;
onSelectSection: (section: SettingsSectionKey) => void;
+ showBrandLogos: boolean;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
- const configuredCount = settings.providers.filter((provider) => provider.configured).length;
const activePreset = settings.agent.model_preset || "default";
const activeProvider = settings.agent.resolved_provider ?? settings.agent.provider;
const webStatus = settings.web.enable
@@ -942,7 +1258,7 @@ function OverviewSettings({
const imageStatus = settings.image_generation.enabled
? tx("settings.values.enabled", "Enabled")
: tx("settings.values.disabled", "Disabled");
- const imageCaption = `${providerLabel(settings.image_generation.providers, settings.image_generation.provider)} · ${
+ 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")
@@ -953,9 +1269,7 @@ function OverviewSettings({
-
-
-
+
nanobot
@@ -998,24 +1312,13 @@ function OverviewSettings({
onSelectSection("models")}
/>
- onSelectSection("providers")}
- />
@@ -1024,16 +1327,20 @@ function OverviewSettings({
onSelectSection("web")}
/>
onSelectSection("image")}
/>
@@ -1161,6 +1468,7 @@ function AppearanceSettings({
onChangeLocalPrefs((prev) => ({ ...prev, codeWrap }))}
+ ariaLabel={tx("settings.rows.codeWrap", "Code wrapping")}
label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
@@ -1171,6 +1479,7 @@ function AppearanceSettings({
onChangeLocalPrefs((prev) => ({ ...prev, brandLogos }))}
+ ariaLabel={tx("settings.rows.brandLogos", "Brand logos")}
label={localPrefs.brandLogos ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
@@ -1180,22 +1489,140 @@ function AppearanceSettings({
);
}
+function NewModelConfigurationDialog({
+ open,
+ draft,
+ providers,
+ saving,
+ showProviderLogos,
+ onOpenChange,
+ onChangeDraft,
+ onSave,
+}: {
+ open: boolean;
+ draft: ModelConfigurationDraft;
+ providers: Array<{ name: string; label: string }>;
+ saving: boolean;
+ showProviderLogos: boolean;
+ onOpenChange: (open: boolean) => void;
+ onChangeDraft: Dispatch>;
+ onSave: () => void;
+}) {
+ const { t } = useTranslation();
+ const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
+ const canSave = Boolean(draft.label.trim() && draft.provider.trim() && draft.model.trim());
+
+ return (
+
+ );
+}
+
function ModelsSettings({
form,
setForm,
settings,
dirty,
saving,
+ showBrandLogos,
onSave,
- onOpenProviders,
+ onCreateConfiguration,
}: {
form: AgentSettingsDraft;
setForm: Dispatch>;
settings: SettingsPayload;
dirty: boolean;
saving: boolean;
+ showBrandLogos: boolean;
onSave: () => void;
- onOpenProviders: () => void;
+ onCreateConfiguration: () => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
@@ -1207,45 +1634,24 @@ function ModelsSettings({
const providerValue = providerOptions.some((provider) => provider.name === form.provider)
? form.provider
: "";
- const selectedPreset = settings.model_presets.find((preset) => preset.name === form.modelPreset);
return (
- {tx("settings.sections.presets", "Presets")}
-
- {settings.model_presets.map((preset) => (
-
- ))}
-
-
-
-
- {t("settings.sections.ai")}
- {selectedPreset?.label ?? form.modelPreset}
+ setForm((prev) => ({ ...prev, modelPreset }))}
+ onCreateConfiguration={onCreateConfiguration}
+ />
{form.modelPreset === "default" ? (
<>
@@ -1257,6 +1663,7 @@ function ModelsSettings({
providers={providerOptions}
value={providerValue}
emptyLabel={t("settings.byok.noConfiguredProviders")}
+ showProviderLogos={showBrandLogos}
onChange={(provider) => setForm((prev) => ({ ...prev, provider }))}
/>
@@ -1271,29 +1678,13 @@ function ModelsSettings({
/>
>
- ) : (
-
-
- {selectedPreset?.model ?? settings.agent.model}
-
-
- )}
+ ) : null}
- {configuredProviders.length === 0 ? (
-
-
-
- ) : null}
@@ -1480,7 +1871,7 @@ function ProvidersSettings({
disabled={saving || missingRequiredApiKey || missingOptionalCredential}
className="rounded-full"
>
- {saving ? t("settings.actions.saving") : t("settings.actions.save")}
+ {saving ? t("settings.actions.saving") : tx("settings.providers.saveProvider", "Save provider")}
@@ -1552,6 +1943,7 @@ function ImageGenerationSettings({
onChangeForm,
onSave,
onOpenProviders,
+ showBrandLogos,
onRestart,
isRestarting,
requiresRestartPending,
@@ -1563,6 +1955,7 @@ function ImageGenerationSettings({
onChangeForm: Dispatch
>;
onSave: () => void;
onOpenProviders: () => void;
+ showBrandLogos: boolean;
onRestart?: () => void;
isRestarting?: boolean;
requiresRestartPending: boolean;
@@ -1595,6 +1988,7 @@ function ImageGenerationSettings({
onChangeForm((prev) => ({ ...prev, enabled }))}
+ ariaLabel={tx("settings.rows.imageGeneration", "Image generation")}
label={form.enabled ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
@@ -1606,6 +2000,7 @@ function ImageGenerationSettings({
providers={settings.image_generation.providers}
value={form.provider}
emptyLabel={tx("settings.image.selectProvider", "Select provider")}
+ showProviderLogos={showBrandLogos}
onChange={(provider) => onChangeForm((prev) => ({ ...prev, provider }))}
/>
@@ -1721,6 +2116,7 @@ function WebSettings({
onToggleKeyEditing,
onReset,
onSave,
+ showBrandLogos,
onRestart,
isRestarting,
requiresRestartPending,
@@ -1736,6 +2132,7 @@ function WebSettings({
onToggleKeyEditing: () => void;
onReset: () => void;
onSave: () => void;
+ showBrandLogos: boolean;
onRestart?: () => void;
isRestarting?: boolean;
requiresRestartPending: boolean;
@@ -1781,6 +2178,7 @@ function WebSettings({
providers={settings.web_search.providers}
value={form.provider}
emptyLabel={t("settings.byok.webSearch.selectProvider")}
+ showProviderLogos={showBrandLogos}
onChange={onChangeProvider}
/>
@@ -1904,6 +2302,7 @@ function WebSettings({
onChangeForm((prev) => ({ ...prev, useJinaReader }))}
+ ariaLabel={tx("settings.rows.jinaReader", "Jina reader")}
label={effectiveJinaReader ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
@@ -2100,6 +2499,818 @@ function CliAppsSettings({
);
}
+function McpPresetsSettings({
+ payload,
+ loading,
+ query,
+ category,
+ installFilter,
+ actionKey,
+ message,
+ error,
+ fieldValues,
+ customForm,
+ configImport,
+ showBrandLogos,
+ requiresRestartPending,
+ onQueryChange,
+ onCategoryChange,
+ onInstallFilterChange,
+ onCustomFormChange,
+ onConfigImportChange,
+ onFieldChange,
+ onAction,
+ onSaveCustom,
+ onImportConfig,
+ onToolsChange,
+ onRestart,
+ isRestarting,
+}: {
+ payload: McpPresetsPayload | null;
+ loading: boolean;
+ query: string;
+ category: string;
+ installFilter: "all" | "installed" | "notInstalled";
+ actionKey: string | null;
+ message: string | null;
+ error: string | null;
+ fieldValues: Record>;
+ customForm: CustomMcpForm;
+ configImport: string;
+ showBrandLogos: boolean;
+ requiresRestartPending: boolean;
+ onQueryChange: (value: string) => void;
+ onCategoryChange: (value: string) => void;
+ onInstallFilterChange: (value: "all" | "installed" | "notInstalled") => void;
+ onCustomFormChange: Dispatch>;
+ onConfigImportChange: (value: string) => void;
+ onFieldChange: (presetName: string, fieldName: string, value: string) => void;
+ onAction: (action: "enable" | "remove" | "test", name: string, values?: Record) => void;
+ onSaveCustom: () => void;
+ onImportConfig: () => void;
+ onToolsChange: (name: string, enabledTools: string[]) => void;
+ onRestart?: () => void;
+ isRestarting?: boolean;
+}) {
+ const { t } = useTranslation();
+ const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
+ const presets = payload?.presets ?? [];
+ const categories = useMemo(
+ () => ["all", ...Array.from(new Set(presets.map((preset) => preset.category))).sort()],
+ [presets],
+ );
+ const normalizedQuery = query.trim().toLowerCase();
+ const filteredPresets = presets.filter((preset) => {
+ const categoryMatch = category === "all" || preset.category === category;
+ if (!categoryMatch) return false;
+ if (installFilter === "installed" && !preset.installed) return false;
+ if (installFilter === "notInstalled" && preset.installed) return false;
+ if (!normalizedQuery) return true;
+ return (
+ preset.display_name.toLowerCase().includes(normalizedQuery) ||
+ preset.name.toLowerCase().includes(normalizedQuery) ||
+ preset.description.toLowerCase().includes(normalizedQuery) ||
+ preset.category.toLowerCase().includes(normalizedQuery)
+ );
+ });
+ const installFilterOptions = [
+ { value: "all", label: tx("settings.mcp.filterAll", "All") },
+ { value: "installed", label: tx("settings.mcp.filterInstalled", "Enabled") },
+ { value: "notInstalled", label: tx("settings.mcp.filterNotInstalled", "Not enabled") },
+ ];
+ const categoryLabel = category === "all" ? tx("settings.mcp.allCategories", "All categories") : category;
+ const visibleStatusMessage = error || message;
+ const testToolNames = payload?.last_action?.tool_names ?? [];
+ const testToolCount = payload?.last_action?.tool_count;
+ const showTestDetails = typeof testToolCount === "number" || testToolNames.length > 0 || !!payload?.last_action?.error;
+
+ return (
+
+
+
+
+
{tx("settings.sections.mcp", "MCP")}
+
+ {tx("settings.mcp.summary", "{{installed}} of {{total}} presets enabled")
+ .replace("{{installed}}", String(payload?.installed_count ?? 0))
+ .replace("{{total}}", String(presets.length))}
+
+
+
onInstallFilterChange(value as "all" | "installed" | "notInstalled")}
+ />
+
+
+
+
+
+ onQueryChange(event.target.value)}
+ placeholder={tx("settings.mcp.searchPlaceholder", "Search MCP presets")}
+ className="h-10 w-full rounded-full border-border/65 bg-card/80 pl-9 text-[13px] shadow-sm sm:max-w-[320px]"
+ />
+
+
+
+
+
+
+ {categories.map((item) => (
+ onCategoryChange(item)}>
+ {item === "all" ? tx("settings.mcp.allCategories", "All categories") : item}
+
+ ))}
+
+
+
+
+
+
+
+ {requiresRestartPending ? (
+
+ {tx("settings.mcp.restartRequired", "Restart nanobot to connect updated MCP tools.")}
+ {onRestart ? (
+
+ ) : null}
+
+ ) : null}
+
+ {visibleStatusMessage ? (
+
+ {visibleStatusMessage}
+
+ ) : null}
+
+ {showTestDetails ? (
+
+
+ {typeof testToolCount === "number" ? (
+
+ {tx("settings.mcp.toolsFound", "{{count}} tools").replace("{{count}}", String(testToolCount))}
+
+ ) : null}
+ {payload?.last_action?.checked_at ? (
+ {payload.last_action.checked_at}
+ ) : null}
+
+ {testToolNames.length ? (
+
+ {testToolNames.map((toolName) => (
+
+ {toolName}
+
+ ))}
+
+ ) : null}
+ {payload?.last_action?.error ? (
+
+ {payload.last_action.error}
+
+ ) : null}
+
+ ) : null}
+
+ {loading ? (
+
+
+ {tx("settings.mcp.loading", "Loading MCP presets...")}
+
+ ) : (
+
+
+ {filteredPresets.map((preset) => (
+
+ ))}
+
+ {!filteredPresets.length ? (
+
+ {tx("settings.mcp.empty", "No MCP presets match this filter.")}
+
+ ) : null}
+
+ )}
+
+
+ );
+}
+
+function McpCustomServerPanel({
+ form,
+ configImport,
+ actionKey,
+ onFormChange,
+ onConfigImportChange,
+ onSave,
+ onImportConfig,
+}: {
+ form: CustomMcpForm;
+ configImport: string;
+ actionKey: string | null;
+ onFormChange: Dispatch>;
+ onConfigImportChange: (value: string) => void;
+ onSave: () => void;
+ onImportConfig: () => void;
+}) {
+ const { t } = useTranslation();
+ const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
+ const [activeMode, setActiveMode] = useState<"custom" | "import" | null>(null);
+ const [advancedOpen, setAdvancedOpen] = useState(false);
+ const customBusy = actionKey?.startsWith("custom:") ?? false;
+ const importBusy = actionKey === "import" || actionKey === "import-cursor";
+ const remote = form.transport !== "stdio";
+ const canSave = Boolean(form.name.trim()) && (remote ? Boolean(form.url.trim()) : Boolean(form.command.trim()));
+ const update = (key: K, value: CustomMcpForm[K]) => {
+ onFormChange((prev) => ({ ...prev, [key]: value }));
+ };
+ const transports: Array<{ value: CustomMcpTransport; label: string }> = [
+ { value: "stdio", label: "stdio" },
+ { value: "streamableHttp", label: "HTTP" },
+ { value: "sse", label: "SSE" },
+ ];
+
+ return (
+
+
+
+
+
+
+
+
+ {tx("settings.mcp.moreOptions", "More MCP options")}
+
+
+ {tx("settings.mcp.moreOptionsSubtitle", "Add a custom server or import mcp.json.")}
+
+
+
+
+
+
+
+
+
+ {activeMode === "custom" ? (
+
+
+
+
+
+ {advancedOpen ? (
+
+ {!remote ? (
+
+ ) : (
+
+ )}
+
+
+
+ ) : null}
+
+ ) : null}
+
+ {activeMode === "import" ? (
+
+
+
+
+
+
+ ) : null}
+
+ );
+}
+
+function McpPresetCard({
+ preset,
+ values,
+ actionKey,
+ showBrandLogos,
+ onFieldChange,
+ onAction,
+ onToolsChange,
+}: {
+ preset: McpPresetInfo;
+ values: Record;
+ actionKey: string | null;
+ showBrandLogos: boolean;
+ onFieldChange: (presetName: string, fieldName: string, value: string) => void;
+ onAction: (action: "enable" | "remove" | "test", name: string, values?: Record) => void;
+ onToolsChange: (name: string, enabledTools: string[]) => void;
+}) {
+ const { t } = useTranslation();
+ const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
+ const enableBusy = actionKey === `enable:${preset.name}`;
+ const removeBusy = actionKey === `remove:${preset.name}`;
+ const testBusy = actionKey === `test:${preset.name}`;
+ const toolsBusy = actionKey === `tools:${preset.name}`;
+ const busy = enableBusy || removeBusy || testBusy || toolsBusy;
+ const [setupOpen, setSetupOpen] = useState(false);
+ const missingFields = preset.required_fields.filter((field) => field.required && !field.configured);
+ const hasFields = preset.required_fields.length > 0;
+ const needsSetupInput = missingFields.length > 0;
+ const showSetup = setupOpen && preset.install_supported && hasFields;
+ const readyInstalled = preset.installed && preset.configured;
+ const statusLabel = mcpPresetStatusLabel(preset.status, tx);
+ const canEnable = preset.install_supported && (
+ missingFields.length === 0 || missingFields.every((field) => Boolean(values[field.name]?.trim()))
+ );
+ const toolNames = preset.tool_names ?? [];
+ const enabledTools = preset.enabled_tools ?? ["*"];
+ const allowAllTools = enabledTools.includes("*");
+ const enabledSet = new Set(allowAllTools ? toolNames : enabledTools);
+ const showToolControls = preset.installed && toolNames.length > 0;
+ const setTools = (next: string[]) => onToolsChange(preset.name, next);
+ useEffect(() => {
+ if (preset.configured || !preset.install_supported) setSetupOpen(false);
+ }, [preset.configured, preset.install_supported]);
+ const enableOrOpenSetup = () => {
+ if (needsSetupInput || (preset.installed && !preset.configured && hasFields)) {
+ setSetupOpen(true);
+ return;
+ }
+ onAction("enable", preset.name, values);
+ };
+ const submitSetup = () => {
+ if (!canEnable) return;
+ onAction("enable", preset.name, values);
+ };
+ const toggleTool = (toolName: string) => {
+ const next = new Set(allowAllTools ? toolNames : enabledTools);
+ if (next.has(toolName)) {
+ next.delete(toolName);
+ } else {
+ next.add(toolName);
+ }
+ const nextValues = Array.from(next);
+ setTools(nextValues.length === toolNames.length ? ["*"] : nextValues);
+ };
+
+ return (
+
+
+
+
+
+
+ {preset.display_name}
+
+
+ {preset.category}
+
+
+ {statusLabel}
+
+
+
+ {preset.description}
+
+
+
+ {preset.docs_url ? (
+
+
+
+ ) : null}
+ {readyInstalled ? (
+
+
+
+
+
+ onAction("test", preset.name)}>
+
+ {tx("settings.mcp.test", "Test")}
+
+ onAction("remove", preset.name)}>
+
+ {tx("settings.mcp.remove", "Remove")}
+
+
+
+ ) : preset.installed && !preset.configured ? (
+
+ ) : preset.install_supported ? (
+
+ ) : (
+
+ )}
+
+
+ {showSetup ? (
+
+
+
+
+ {tx("settings.mcp.connectTitle", "Connect {{name}}").replace("{{name}}", preset.display_name)}
+
+
+ {tx("settings.mcp.connectHint", "Add the key from your account settings.")}
+
+
+
+
+
+ {preset.required_fields.map((field) => (
+
+ ))}
+
+
+
+
+
+ ) : null}
+ {showToolControls ? (
+
+
+
+ {toolsBusy ? : }
+ {tx("settings.mcp.toolScope", "Tools")}
+
+
+
+
+
+
+
+ {toolNames.map((toolName) => {
+ const selected = enabledSet.has(toolName);
+ return (
+
+ );
+ })}
+
+
+ ) : preset.installed && !testBusy ? (
+
+ {tx("settings.mcp.testForTools", "Run Test to inspect and choose individual tools.")}
+
+ ) : null}
+
+ );
+}
+
+function mcpPresetStatusLabel(status: string, tx: (key: string, fallback: string) => string): string {
+ switch (status) {
+ case "configured":
+ return tx("settings.mcp.statusConfigured", "Configured");
+ case "missing_credentials":
+ return tx("settings.mcp.statusMissingCredentials", "Needs key");
+ case "missing_dependency":
+ return tx("settings.mcp.statusMissingDependency", "Needs dependency");
+ case "coming_soon":
+ return tx("settings.mcp.statusComingSoon", "Coming soon");
+ default:
+ return tx("settings.mcp.statusNotInstalled", "Not enabled");
+ }
+}
+
+function McpPresetLogo({ preset, showBrandLogos }: { preset: McpPresetInfo; showBrandLogos: boolean }) {
+ const [logoIndex, setLogoIndex] = useState(0);
+ const bg = preset.brand_color || "hsl(var(--muted))";
+ const logoUrls = useMemo(() => logoFallbackUrls(preset.logo_url), [preset.logo_url]);
+ const logoUrl = logoUrls[logoIndex];
+ const initials = preset.display_name
+ .split(/\s+/)
+ .filter(Boolean)
+ .slice(0, 2)
+ .map((part) => part[0]?.toUpperCase())
+ .join("") || preset.name.slice(0, 2).toUpperCase();
+
+ useEffect(() => setLogoIndex(0), [preset.logo_url]);
+
+ if (showBrandLogos && logoUrl) {
+ return (
+
+
setLogoIndex((index) => index + 1)}
+ />
+
+ );
+ }
+ return (
+
+ {initials}
+
+ );
+}
+
function CliAppReadyPanel({
app,
showBrandLogos,
@@ -2278,25 +3489,30 @@ function CliAppCard({
}
function CliAppLogo({ app, showBrandLogos }: { app: CliAppInfo; showBrandLogos: boolean }) {
- const [failed, setFailed] = useState(false);
+ const [logoIndex, setLogoIndex] = useState(0);
const bg = app.brand_color || "hsl(var(--muted))";
+ const logoUrls = useMemo(() => logoFallbackUrls(app.logo_url), [app.logo_url]);
+ const logoUrl = logoUrls[logoIndex];
const initials = app.display_name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("") || app.name.slice(0, 2).toUpperCase();
- if (showBrandLogos && app.logo_url && !failed) {
+
+ useEffect(() => setLogoIndex(0), [app.logo_url]);
+
+ if (showBrandLogos && logoUrl) {
return (
setFailed(true)}
+ onError={() => setLogoIndex((index) => index + 1)}
/>
);
@@ -2354,18 +3570,9 @@ function RuntimeSettings({
/>
- setForm((prev) => ({ ...prev, timezone: event.target.value }))}
- className="h-8 w-[220px] rounded-full text-[13px]"
- />
-
-
- setForm((prev) => ({ ...prev, toolHintMaxLength }))}
+ onChange={(timezone) => setForm((prev) => ({ ...prev, timezone }))}
/>
void;
+}) {
+ const { t } = useTranslation();
+ const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
+ const [query, setQuery] = useState("");
+ const options = useMemo(() => timezoneOptions(value), [value]);
+ const filteredOptions = useMemo(() => filterTimezoneOptions(options, query), [options, query]);
+
+ return (
+ !open && setQuery("")}>
+
+
+
+
+
+
+
+ setQuery(event.target.value)}
+ onKeyDown={(event) => event.stopPropagation()}
+ placeholder={tx("settings.timezone.search", "Search timezone")}
+ className="h-7 border-0 bg-transparent px-0 text-[13px] shadow-none focus-visible:ring-0"
+ />
+
+
+
+ {filteredOptions.length ? (
+ filteredOptions.map((option) => {
+ const selected = option.name === value;
+ return (
+
onChange(option.name)}
+ className={cn(
+ "flex h-9 cursor-default items-center justify-between gap-3 rounded-[12px] px-2.5 text-[13px]",
+ "focus:bg-muted/85 focus:text-foreground",
+ selected && "bg-muted/80 text-foreground focus:bg-muted",
+ )}
+ >
+ {option.name}
+
+
+ {option.offset}
+
+ {selected ? : null}
+
+
+ );
+ })
+ ) : (
+
+ {tx("settings.timezone.empty", "No matching timezones.")}
+
+ )}
+
+
+
+ );
+}
+
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;
@@ -2483,7 +3772,15 @@ function ProviderPicker({
disabled && "text-muted-foreground",
)}
>
- {selectedProvider?.label ?? emptyLabel}
+
+ {selectedProvider && showProviderLogos ? (
+
+ ) : null}
+ {selectedProvider?.label ?? emptyLabel}
+
@@ -2498,12 +3795,20 @@ function ProviderPicker({
key={provider.name}
onSelect={() => onChange(provider.name)}
className={cn(
- "flex cursor-default items-center justify-between gap-2 rounded-[12px] px-3 py-2 text-[13px]",
- "focus:bg-muted focus:text-foreground",
- selected && "bg-primary/10 text-primary focus:bg-primary/12 focus:text-primary",
+ "flex cursor-default items-center justify-between gap-2 rounded-[12px] px-2.5 py-2 text-[13px]",
+ "focus:bg-muted/85 focus:text-foreground",
+ selected && "bg-muted/80 text-foreground focus:bg-muted",
)}
>
- {provider.label}
+
+ {showProviderLogos ? (
+
+ ) : null}
+ {provider.label}
+
{selected ? : null}
);
@@ -2513,6 +3818,61 @@ function ProviderPicker({
);
}
+function ProviderPickerIcon({
+ provider,
+ showBrandLogos,
+}: {
+ provider: string;
+ showBrandLogos: boolean;
+}) {
+ const [logoIndex, setLogoIndex] = useState(0);
+ const brand = providerBrand(provider);
+ const Icon = PROVIDER_ICONS[provider] ?? Sparkles;
+ const logoUrl = brand?.logoUrls[logoIndex];
+
+ useEffect(() => setLogoIndex(0), [provider]);
+
+ if (showBrandLogos && logoUrl) {
+ return (
+
+
setLogoIndex((index) => index + 1)}
+ />
+
+ );
+ }
+
+ if (showBrandLogos && brand) {
+ return (
+
+ {brand.initials}
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
+
function ProviderSection({
title,
count,
@@ -2603,6 +3963,62 @@ function filterProviders(
);
}
+interface TimezoneOption {
+ name: string;
+ offset: string;
+ searchText: string;
+}
+
+function timezoneOptions(current: string): TimezoneOption[] {
+ return timezonesWithCurrent(current).map((name) => {
+ const offset = timezoneOffset(name);
+ return {
+ name,
+ offset,
+ searchText: `${name} ${name.replace(/_/g, " ")} ${offset}`.toLowerCase(),
+ };
+ });
+}
+
+function timezonesWithCurrent(current: string): string[] {
+ const intl = Intl as typeof Intl & {
+ supportedValuesOf?: (key: "timeZone") => string[];
+ };
+ let values: string[] = [];
+ try {
+ values = intl.supportedValuesOf?.("timeZone") ?? [];
+ } catch {
+ values = [];
+ }
+ const deduped = new Set([...FALLBACK_TIMEZONES, ...values, current].filter(Boolean));
+ return Array.from(deduped).sort((left, right) => {
+ if (left === "UTC") return -1;
+ if (right === "UTC") return 1;
+ return left.localeCompare(right);
+ });
+}
+
+function filterTimezoneOptions(options: TimezoneOption[], query: string): TimezoneOption[] {
+ const normalized = query.trim().toLowerCase();
+ if (!normalized) return options;
+ return options.filter((option) => option.searchText.includes(normalized));
+}
+
+function timezoneOffset(timezone: string): string {
+ try {
+ const parts = new Intl.DateTimeFormat("en-US", {
+ timeZone: timezone,
+ timeZoneName: "shortOffset",
+ hour: "2-digit",
+ minute: "2-digit",
+ }).formatToParts(new Date());
+ const value = parts.find((part) => part.type === "timeZoneName")?.value;
+ return value ? value.replace(/^GMT$/, "UTC").replace(/^GMT/, "UTC") : "UTC";
+ } catch {
+ return "Custom timezone";
+ }
+}
+
function optionRowsWithCurrent(
options: Array<{ name: string; label: string }>,
value: string,
@@ -2611,68 +4027,16 @@ function optionRowsWithCurrent(
return [{ name: value, label: value }, ...options];
}
-function providerLabel(
- providers: Array<{ name: string; label: string }>,
- value: string,
+function modelPresetProviderKey(
+ preset: SettingsPayload["model_presets"][number],
+ settings: SettingsPayload,
+ options: { draftProvider?: string } = {},
): string {
- return providers.find((provider) => provider.name === value)?.label ?? value;
-}
-
-interface ProviderBrand {
- logoUrl: string;
- color: string;
- initials: string;
-}
-
-function faviconUrl(domain: string): string {
- return `https://www.google.com/s2/favicons?domain=${domain}&sz=64`;
-}
-
-const PROVIDER_BRAND_ALIASES: Record = {
- byteplus_coding_plan: "byteplus",
- minimax_anthropic: "minimax",
- openai_codex: "openai",
- volcengine_coding_plan: "volcengine",
-};
-
-const PROVIDER_BRANDS: Record = {
- aihubmix: { logoUrl: faviconUrl("aihubmix.com"), color: "#111827", initials: "AH" },
- ant_ling: { logoUrl: faviconUrl("ant-ling.com"), color: "#7C3AED", initials: "AL" },
- anthropic: { logoUrl: faviconUrl("anthropic.com"), color: "#D97757", initials: "A" },
- atomic_chat: { logoUrl: faviconUrl("atomic.chat"), color: "#111827", initials: "AC" },
- azure_openai: { logoUrl: faviconUrl("azure.microsoft.com"), color: "#0078D4", initials: "AZ" },
- bedrock: { logoUrl: faviconUrl("aws.amazon.com"), color: "#FF9900", initials: "AWS" },
- byteplus: { logoUrl: faviconUrl("byteplus.com"), color: "#325CFF", initials: "BP" },
- dashscope: { logoUrl: faviconUrl("dashscope.aliyun.com"), color: "#FF6A00", initials: "DS" },
- deepseek: { logoUrl: faviconUrl("deepseek.com"), color: "#4D6BFE", initials: "DS" },
- gemini: { logoUrl: faviconUrl("gemini.google.com"), color: "#4285F4", initials: "G" },
- github_copilot: { logoUrl: faviconUrl("github.com"), color: "#24292F", initials: "GH" },
- groq: { logoUrl: faviconUrl("groq.com"), color: "#F55036", initials: "GQ" },
- huggingface: { logoUrl: faviconUrl("huggingface.co"), color: "#FF9D00", initials: "HF" },
- lm_studio: { logoUrl: faviconUrl("lmstudio.ai"), color: "#111827", initials: "LM" },
- longcat: { logoUrl: faviconUrl("longcat.chat"), color: "#111827", initials: "LC" },
- minimax: { logoUrl: faviconUrl("minimax.io"), color: "#111827", initials: "MM" },
- mistral: { logoUrl: faviconUrl("mistral.ai"), color: "#FA520F", initials: "M" },
- moonshot: { logoUrl: faviconUrl("moonshot.ai"), color: "#111827", initials: "MS" },
- novita: { logoUrl: faviconUrl("novita.ai"), color: "#7C3AED", initials: "N" },
- nvidia: { logoUrl: faviconUrl("nvidia.com"), color: "#76B900", initials: "NV" },
- ollama: { logoUrl: faviconUrl("ollama.com"), color: "#111827", initials: "O" },
- openai: { logoUrl: faviconUrl("openai.com"), color: "#111827", initials: "AI" },
- openrouter: { logoUrl: faviconUrl("openrouter.ai"), color: "#111827", initials: "OR" },
- ovms: { logoUrl: faviconUrl("openvino.ai"), color: "#0071C5", initials: "OV" },
- qianfan: { logoUrl: faviconUrl("cloud.baidu.com"), color: "#2932E1", initials: "QF" },
- siliconflow: { logoUrl: faviconUrl("siliconflow.cn"), color: "#111827", initials: "SF" },
- skywork: { logoUrl: faviconUrl("skywork.ai"), color: "#5B5BF6", initials: "SW" },
- stepfun: { logoUrl: faviconUrl("stepfun.com"), color: "#2F6BFF", initials: "SF" },
- volcengine: { logoUrl: faviconUrl("volcengine.com"), color: "#1664FF", initials: "VE" },
- vllm: { logoUrl: faviconUrl("vllm.ai"), color: "#2563EB", initials: "VL" },
- xiaomi_mimo: { logoUrl: faviconUrl("xiaomimimo.com"), color: "#FF6900", initials: "MI" },
- zhipu: { logoUrl: faviconUrl("bigmodel.cn"), color: "#155EEF", initials: "Z" },
-};
-
-function providerBrand(provider: string): ProviderBrand | null {
- const key = PROVIDER_BRAND_ALIASES[provider] ?? provider;
- return PROVIDER_BRANDS[key] ?? null;
+ const provider = options.draftProvider ?? preset.provider;
+ if (provider === "auto") {
+ return settings.agent.resolved_provider || settings.agent.provider || preset.provider;
+ }
+ return provider;
}
const PROVIDER_ICONS: Record = {
@@ -2701,6 +4065,14 @@ const PROVIDER_ICONS: Record = {
ant_ling: Sparkles,
azure_openai: Cloud,
bedrock: Database,
+ brave: Search,
+ duckduckgo: Search,
+ exa: Search,
+ jina: Search,
+ kagi: Search,
+ olostep: Search,
+ searxng: Search,
+ tavily: Search,
vllm: Cpu,
ollama: Cpu,
lm_studio: Cpu,
@@ -2716,10 +4088,14 @@ function ProviderIcon({
provider: string;
showBrandLogos: boolean;
}) {
- const [failed, setFailed] = useState(false);
+ const [logoIndex, setLogoIndex] = useState(0);
const brand = providerBrand(provider);
const Icon = PROVIDER_ICONS[provider] ?? Hexagon;
- if (showBrandLogos && brand?.logoUrl && !failed) {
+ const logoUrl = brand?.logoUrls[logoIndex];
+
+ useEffect(() => setLogoIndex(0), [provider]);
+
+ if (showBrandLogos && logoUrl) {
return (
setFailed(true)}
+ onError={() => setLogoIndex((index) => index + 1)}
/>
);
@@ -2754,17 +4130,104 @@ function ProviderIcon({
);
}
+function NanobotBrandLogo({
+ size = "sm",
+ testId,
+}: {
+ size?: "sm" | "lg";
+ testId?: string;
+}) {
+ return (
+
+
+
+ );
+}
+
+function OverviewRowIcon({
+ icon: Icon,
+}: {
+ icon: LucideIcon;
+}) {
+ return (
+
+
+
+ );
+}
+
+function OverviewValueLogo({
+ provider,
+ showBrandLogos,
+}: {
+ provider: string | null | undefined;
+ showBrandLogos: boolean;
+}) {
+ const [logoIndex, setLogoIndex] = useState(0);
+ const brand = provider ? providerBrand(provider) : null;
+ const logoUrl = brand?.logoUrls[logoIndex];
+
+ useEffect(() => setLogoIndex(0), [provider]);
+
+ if (!provider || !showBrandLogos || !brand) return null;
+
+ if (logoUrl) {
+ return (
+
+
setLogoIndex((index) => index + 1)}
+ />
+
+ );
+ }
+
+ return (
+
+ {brand.initials}
+
+ );
+}
+
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 (
@@ -2773,14 +4236,13 @@ function OverviewListRow({
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"
>
-
-
-
+
{title}
{caption}
+
{value}
@@ -2843,6 +4305,143 @@ function ReadOnlyRow({ title, value }: { title: string; value: string }) {
);
}
+function ModelPresetPicker({
+ presets,
+ value,
+ settings,
+ draftModel,
+ draftProvider,
+ showProviderLogos,
+ onChange,
+ onCreateConfiguration,
+}: {
+ presets: SettingsPayload["model_presets"];
+ value: string;
+ settings: SettingsPayload;
+ draftModel: string;
+ draftProvider: string;
+ showProviderLogos: boolean;
+ onChange: (preset: string) => void;
+ onCreateConfiguration: () => void;
+}) {
+ const { t } = useTranslation();
+ const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
+ const selectedPreset = presets.find((preset) => preset.name === value) ?? presets[0] ?? null;
+
+ return (
+
+
+
+
+
+ {presets.map((preset) => {
+ const selected = preset.name === value;
+ return (
+ onChange(preset.name)}
+ className={cn(
+ "flex cursor-default items-center justify-between gap-3 rounded-[12px] px-2.5 py-2 text-[13px]",
+ "focus:bg-muted/85 focus:text-foreground",
+ selected && "bg-muted/80 text-foreground focus:bg-muted",
+ )}
+ >
+
+ {selected ? : null}
+
+ );
+ })}
+
+
+
+
+
+ {tx("settings.models.addConfiguration", "Add configuration")}
+
+
+
+
+ );
+}
+
+function ModelPresetOptionContent({
+ preset,
+ settings,
+ draftModel,
+ draftProvider,
+ showProviderLogos,
+ compact = false,
+}: {
+ preset: SettingsPayload["model_presets"][number];
+ settings: SettingsPayload;
+ draftModel: string;
+ draftProvider: string;
+ showProviderLogos: boolean;
+ compact?: boolean;
+}) {
+ const provider = modelPresetProviderKey(preset, settings, {
+ draftProvider: preset.is_default ? draftProvider : undefined,
+ });
+ const model = preset.is_default ? draftModel : preset.model;
+ const providerName = providerDisplayLabel(settings.providers, provider);
+ return (
+
+
+
+ {model || preset.label}
+
+ {providerName}
+ {preset.label ? ` · ${preset.label}` : ""}
+
+
+
+ );
+}
+
function RestartSettingsFooter({
dirty,
saving,
@@ -3044,24 +4643,38 @@ function SegmentedControl({
function ToggleButton({
checked,
onChange,
+ ariaLabel,
label,
}: {
checked: boolean;
onChange: (checked: boolean) => void;
+ ariaLabel?: string;
label: string;
}) {
return (
);
}
diff --git a/webui/src/components/thread/AgentActivityCluster.tsx b/webui/src/components/thread/AgentActivityCluster.tsx
index 8f201f6ce..3527437a4 100644
--- a/webui/src/components/thread/AgentActivityCluster.tsx
+++ b/webui/src/components/thread/AgentActivityCluster.tsx
@@ -1,12 +1,27 @@
-import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
-import { AlertCircle, ChevronRight, Layers, Terminal } from "lucide-react";
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
+import {
+ AlertCircle,
+ Check,
+ CheckCircle2,
+ ChevronRight,
+ CircleDashed,
+ Layers,
+ Search,
+ Server,
+ Terminal,
+ Wrench,
+ type LucideIcon,
+} from "lucide-react";
import { useTranslation } from "react-i18next";
-import { cliAppInitials } from "@/components/CliAppMentionText";
+import { cliAppInitials, mcpPresetInitials } from "@/components/CliAppMentionText";
import { FileReferenceChip } from "@/components/FileReferenceChip";
-import { ReasoningBubble, StreamingLabelSheen, TraceGroup } from "@/components/MessageBubble";
+import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
+import { StreamingLabelSheen } from "@/components/MessageBubble";
+import { faviconUrls, logoFallbackUrls } from "@/lib/provider-brand";
+import { formatToolCallTrace } from "@/lib/tool-traces";
import { cn } from "@/lib/utils";
-import type { CliAppInfo, ToolProgressEvent, UIFileEdit, UIMessage } from "@/lib/types";
+import type { CliAppInfo, McpPresetInfo, ToolProgressEvent, UIFileEdit, UIMessage } from "@/lib/types";
/** Scrollport height for the Cursor-style “live trace” strip (tailwind spacing). */
const CLUSTER_SCROLL_MAX_CLASS = "max-h-52";
@@ -26,6 +41,7 @@ interface ActivityCounts {
reasoningSteps: number;
toolCalls: number;
cliCount: number;
+ mcpCount: number;
fileCount: number;
added: number;
deleted: number;
@@ -36,6 +52,9 @@ interface ActivityCounts {
primaryFileTooltipPath?: string;
primaryCliName?: string;
primaryCliStatus?: CliRunStatus;
+ primaryMcpName?: string;
+ primaryMcpDisplayName?: string;
+ primaryMcpStatus?: McpRunStatus;
}
interface FileEditSummary {
@@ -62,18 +81,32 @@ interface CliRunSummary {
}
type CliRunStatus = "running" | "done" | "error";
+type McpRunStatus = "running" | "done" | "error";
+
+interface McpRunSummary {
+ key: string;
+ presetName: string;
+ displayName: string;
+ toolName: string;
+ argsPreview: string;
+ status: McpRunStatus;
+ error?: string;
+}
function countActivity(
messages: UIMessage[],
fileEdits: FileEditSummary[],
cliRuns: CliRunSummary[],
+ mcpRuns: McpRunSummary[],
): ActivityCounts {
let reasoningSteps = 0;
let toolCalls = 0;
const cliCount = cliRuns.length;
+ const mcpCount = mcpRuns.length;
const primaryCli = cliRuns[cliRuns.length - 1];
const primaryCliName = primaryCli?.name;
const primaryCliStatus = primaryCli?.status;
+ const primaryMcp = mcpRuns[mcpRuns.length - 1];
for (const m of messages) {
if (isReasoningOnlyAssistant(m)) {
reasoningSteps += 1;
@@ -82,7 +115,7 @@ function countActivity(
if (m.kind === "trace") {
const lines = traceLines(m);
for (const line of lines) {
- if (!isCliRunTraceLine(line)) {
+ if (!isCliRunTraceLine(line) && !isMcpRunTraceLine(line)) {
toolCalls += 1;
}
}
@@ -118,6 +151,7 @@ function countActivity(
reasoningSteps,
toolCalls,
cliCount,
+ mcpCount,
fileCount: fileEdits.length,
added,
deleted,
@@ -128,6 +162,9 @@ function countActivity(
primaryFileTooltipPath,
primaryCliName,
primaryCliStatus,
+ primaryMcpName: primaryMcp?.presetName,
+ primaryMcpDisplayName: primaryMcp?.displayName,
+ primaryMcpStatus: primaryMcp?.status,
};
}
@@ -137,6 +174,7 @@ interface AgentActivityClusterProps {
isTurnStreaming: boolean;
hasBodyBelow: boolean;
cliApps?: CliAppInfo[];
+ mcpPresets?: McpPresetInfo[];
}
/**
@@ -148,6 +186,7 @@ export function AgentActivityCluster({
isTurnStreaming,
hasBodyBelow,
cliApps = [],
+ mcpPresets = [],
}: AgentActivityClusterProps) {
const { t } = useTranslation();
const fileEdits = useMemo(
@@ -155,14 +194,20 @@ export function AgentActivityCluster({
[messages, isTurnStreaming],
);
const cliRuns = useMemo(() => collectCliRuns(messages), [messages]);
+ const mcpRuns = useMemo(() => collectMcpRuns(messages), [messages]);
const cliAppsByName = useMemo(
() => new Map(cliApps.map((app) => [app.name.toLowerCase(), app])),
[cliApps],
);
+ const mcpPresetsByName = useMemo(
+ () => new Map(mcpPresets.map((preset) => [preset.name.toLowerCase(), preset])),
+ [mcpPresets],
+ );
const {
reasoningSteps,
toolCalls,
cliCount,
+ mcpCount,
fileCount,
added,
deleted,
@@ -173,23 +218,35 @@ export function AgentActivityCluster({
primaryFileTooltipPath,
primaryCliName,
primaryCliStatus,
- } = countActivity(messages, fileEdits, cliRuns);
+ primaryMcpDisplayName,
+ primaryMcpStatus,
+ } = countActivity(messages, fileEdits, cliRuns, mcpRuns);
const hasPendingFileEdit = fileEdits.some((edit) => edit.pending);
const [userToggledOuter, setUserToggledOuter] = useState(false);
const [outerOpenLocal, setOuterOpenLocal] = useState(false);
+ const [now, setNow] = useState(() => Date.now());
const activityScrollRef = useRef(null);
const activityContentRef = useRef(null);
const autoFollowActivityRef = useRef(true);
const scrollFrameRef = useRef(null);
- /** Collapsed by default during “Working…” and after the turn; user expands to inspect traces. */
- const outerExpanded = userToggledOuter ? outerOpenLocal : false;
+ /** Live work and the trace directly attached to an answer read like a visible trail. */
+ const outerExpanded = userToggledOuter ? outerOpenLocal : isTurnStreaming || hasBodyBelow;
const hasLiveEditingFiles = isTurnStreaming && hasEditingFiles;
- const headerBusy = fileCount > 0 ? hasEditingFiles : isTurnStreaming;
const singleFilePath = fileCount === 1 ? primaryFilePath : undefined;
const singleFileTooltipPath = fileCount === 1 ? primaryFileTooltipPath : undefined;
- const hasVisibleActivity = reasoningSteps > 0 || toolCalls > 0 || cliCount > 0 || fileCount > 0;
+ const hasVisibleActivity = reasoningSteps > 0 || toolCalls > 0 || cliCount > 0 || mcpCount > 0 || fileCount > 0;
+ const activityDuration = formatActivityDuration(activityDurationMs(messages, isTurnStreaming, now));
+ const thoughtLabel = isTurnStreaming
+ ? t("message.activityThinkingFor", {
+ duration: activityDuration,
+ defaultValue: "Thinking for {{duration}}",
+ })
+ : t("message.activityThoughtFor", {
+ duration: activityDuration,
+ defaultValue: "Thought for {{duration}}",
+ });
const fileActivitySummary = fileCount > 0
? hasPendingFileEdit && !singleFilePath
@@ -217,10 +274,24 @@ export function AgentActivityCluster({
})
: "";
+ const mcpActivitySummary = mcpCount > 0
+ ? mcpCount === 1 && primaryMcpDisplayName
+ ? t(mcpActivitySummaryKey(primaryMcpStatus, isTurnStreaming), {
+ name: primaryMcpDisplayName,
+ defaultValue: mcpActivitySummaryDefault(primaryMcpStatus, isTurnStreaming),
+ })
+ : t(mcpActivityManySummaryKey(mcpRuns, isTurnStreaming), {
+ count: mcpCount,
+ defaultValue: mcpActivityManySummaryDefault(mcpRuns, isTurnStreaming),
+ })
+ : "";
+
const summary = fileCount > 0
? fileActivitySummary
: cliCount > 0
? cliActivitySummary
+ : mcpCount > 0
+ ? mcpActivitySummary
: isTurnStreaming
? reasoningSteps > 0
? t("message.agentActivityLiveSummary", {
@@ -300,6 +371,12 @@ export function AgentActivityCluster({
useEffect(() => cancelActivityScrollFrame, [cancelActivityScrollFrame]);
+ useEffect(() => {
+ if (!isTurnStreaming) return undefined;
+ const interval = window.setInterval(() => setNow(Date.now()), 500);
+ return () => window.clearInterval(interval);
+ }, [isTurnStreaming]);
+
const onActivityScroll = useCallback(() => {
const el = activityScrollRef.current;
if (!el) return;
@@ -309,47 +386,35 @@ export function AgentActivityCluster({
if (!hasVisibleActivity) return null;
- const HeaderIcon = cliCount > 0 && fileCount === 0 && toolCalls === 0 ? Terminal : Layers;
-
return (
diff --git a/webui/src/hooks/useNanobotStream.ts b/webui/src/hooks/useNanobotStream.ts
index 6efb60a4e..ecb028d1b 100644
--- a/webui/src/hooks/useNanobotStream.ts
+++ b/webui/src/hooks/useNanobotStream.ts
@@ -13,6 +13,7 @@ import type {
InboundEvent,
OutboundCliAppMention,
OutboundImageGeneration,
+ OutboundMcpPresetMention,
OutboundMedia,
GoalStateWsPayload,
UIImage,
@@ -313,6 +314,7 @@ export interface SendImage {
export interface SendOptions {
imageGeneration?: OutboundImageGeneration;
cliApps?: OutboundCliAppMention[];
+ mcpPresets?: OutboundMcpPresetMention[];
}
export function useNanobotStream(
@@ -891,6 +893,7 @@ export function useNanobotStream(
createdAt: Date.now(),
...(previews ? { images: previews } : {}),
...(options?.cliApps?.length ? { cliApps: options.cliApps } : {}),
+ ...(options?.mcpPresets?.length ? { mcpPresets: options.mcpPresets } : {}),
},
];
});
diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json
index 04d6885ce..76d4dc030 100644
--- a/webui/src/i18n/locales/en/common.json
+++ b/webui/src/i18n/locales/en/common.json
@@ -81,6 +81,7 @@
"image": "Image",
"web": "Web",
"cliApps": "CLI Apps",
+ "mcp": "MCP",
"runtime": "Runtime",
"advanced": "Advanced"
},
@@ -96,11 +97,20 @@
"webSearch": "Web search",
"webBehavior": "Behavior",
"cliApps": "CLI Apps",
+ "mcp": "MCP",
"identity": "Identity",
"safety": "Safety",
"capabilities": "Capabilities",
"integrations": "Integrations"
},
+ "models": {
+ "selectModel": "Select model",
+ "addConfiguration": "Add configuration",
+ "newConfiguration": "New model configuration",
+ "newConfigurationHelp": "Save a provider and model as a one-click option.",
+ "configurationName": "Name",
+ "configurationNamePlaceholder": "Fast writing"
+ },
"rows": {
"theme": "Theme",
"language": "Language",
@@ -112,6 +122,7 @@
"gateway": "Gateway",
"restartState": "Restart state",
"pendingChanges": "Pending changes",
+ "currentModel": "Current model",
"selectedPreset": "Selected preset",
"presetModel": "Preset model",
"density": "Density",
@@ -154,6 +165,9 @@
"provider": "Select the provider that should serve new model requests.",
"model": "Set the default model name used by nanobot.",
"configPath": "The gateway configuration file currently in use.",
+ "currentModel": "Choose the model nanobot uses for new replies.",
+ "selectedModelProvider": "Set by the selected model.",
+ "selectedModelValue": "Set by the selected model.",
"selectedPreset": "Named presets are read-only here; edit them in config.json.",
"presetModel": "Switch to Default to edit model and provider from the WebUI.",
"density": "Stored only in this browser.",
@@ -178,6 +192,11 @@
"cliAppsFilter": "Search by app, category, or capability.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
},
+ "timezone": {
+ "select": "Select timezone",
+ "search": "Search timezone",
+ "empty": "No matching timezones."
+ },
"cliApps": {
"allCategories": "All categories",
"availableCount": "{{count}} apps",
@@ -209,6 +228,59 @@
"unavailable": "Unavailable",
"noDescription": "No description available."
},
+ "mcp": {
+ "allCategories": "All categories",
+ "summary": "{{installed}} of {{total}} presets enabled",
+ "filterAll": "All",
+ "filterInstalled": "Enabled",
+ "filterNotInstalled": "Not enabled",
+ "searchPlaceholder": "Search MCP presets",
+ "moreOptions": "More MCP options",
+ "moreOptionsSubtitle": "Add a custom server or import mcp.json.",
+ "customTitle": "Custom MCP",
+ "customSubtitle": "Add any stdio, HTTP, or SSE MCP server.",
+ "customAction": "Custom",
+ "importAction": "Import",
+ "serverName": "Server name",
+ "serverUrl": "URL",
+ "transport": "Transport",
+ "command": "Command",
+ "args": "Args JSON",
+ "headers": "Headers JSON",
+ "env": "Env JSON",
+ "timeout": "Tool timeout",
+ "advancedOptions": "Advanced options",
+ "hideAdvanced": "Hide advanced",
+ "saveCustom": "Save MCP",
+ "configImport": "Import mcp.json",
+ "importConfig": "Import",
+ "restartRequired": "Restart nanobot to connect updated MCP tools.",
+ "toolsFound": "{{count}} tools",
+ "loading": "Loading MCP presets...",
+ "empty": "No MCP presets match this filter.",
+ "openDocs": "Open docs",
+ "test": "Test",
+ "remove": "Remove",
+ "enable": "Enable",
+ "enabled": "Enabled",
+ "setup": "Connect",
+ "configure": "Connect",
+ "connectTitle": "Connect {{name}}",
+ "connectHint": "Add the key from your account settings.",
+ "saveAndEnable": "Save and enable",
+ "updateSetup": "Update setup",
+ "configured": "configured",
+ "keepExisting": "Leave blank to keep existing",
+ "statusConfigured": "Configured",
+ "statusMissingCredentials": "Needs key",
+ "statusMissingDependency": "Needs dependency",
+ "statusComingSoon": "Coming soon",
+ "statusNotInstalled": "Not enabled",
+ "toolScope": "Tools",
+ "allTools": "All",
+ "noTools": "None",
+ "testForTools": "Run Test to inspect and choose individual tools."
+ },
"values": {
"light": "Light",
"dark": "Dark",
@@ -296,7 +368,8 @@
},
"providers": {
"searchPlaceholder": "Search providers",
- "noMatches": "No providers match this search."
+ "noMatches": "No providers match this search.",
+ "saveProvider": "Save provider"
},
"legal": {
"thirdPartyBrands": "Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement."
@@ -505,8 +578,14 @@
}
},
"mentions": {
- "ariaLabel": "CLI Apps",
- "label": "CLI APPS"
+ "ariaLabel": "Apps and MCP",
+ "label": "Plugins",
+ "cliGroup": "CLI Apps",
+ "mcpGroup": "MCP servers",
+ "cliBadge": "CLI",
+ "mcpBadge": "MCP",
+ "cliDescription": "Use @{{name}} as a local CLI app",
+ "mcpDescription": "Use @{{name}} as an MCP server"
},
"encoding": "Encoding…",
"remove": "Remove attachment",
@@ -539,15 +618,17 @@
"agentActivityToolsOnly": "{{tools}} tool calls",
"agentActivityLiveSummary": "Working… · {{reasoning}} steps · {{tools}} tool calls",
"agentActivityLiveToolsOnly": "Working… · {{tools}} tool calls",
- "cliActivityRunningOne": "Running CLI @{{name}}",
- "cliActivityRanOne": "Ran CLI @{{name}}",
- "cliActivityFailedOne": "CLI failed @{{name}}",
- "cliActivityRunningMany": "Running {{count}} CLIs",
- "cliActivityRanMany": "Ran {{count}} CLIs",
- "cliActivityFailedMany": "{{count}} CLI failed",
- "cliRunRunning": "Running CLI",
- "cliRunRan": "Ran CLI",
- "cliRunFailed": "CLI failed",
+ "activityThinkingFor": "Thinking for {{duration}}",
+ "activityThoughtFor": "Thought for {{duration}}",
+ "cliActivityRunningOne": "Using @{{name}}",
+ "cliActivityRanOne": "Used @{{name}}",
+ "cliActivityFailedOne": "Failed @{{name}}",
+ "cliActivityRunningMany": "Using {{count}} CLI apps",
+ "cliActivityRanMany": "Used {{count}} CLI apps",
+ "cliActivityFailedMany": "{{count}} CLI apps failed",
+ "cliRunRunning": "Using",
+ "cliRunRan": "Used",
+ "cliRunFailed": "Failed",
"imageAttachment": "Image attachment",
"copyReply": "Copy reply",
"copiedReply": "Copied reply",
diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json
index db66697c6..263e1df8d 100644
--- a/webui/src/i18n/locales/es/common.json
+++ b/webui/src/i18n/locales/es/common.json
@@ -81,7 +81,9 @@
"image": "Image",
"web": "Web",
"runtime": "Runtime",
- "advanced": "Advanced"
+ "advanced": "Advanced",
+ "cliApps": "Apps CLI",
+ "mcp": "MCP"
},
"sections": {
"interface": "Interfaz",
@@ -97,7 +99,9 @@
"identity": "Identity",
"safety": "Safety",
"capabilities": "Capacidades",
- "integrations": "Integrations"
+ "integrations": "Integrations",
+ "cliApps": "Apps CLI",
+ "mcp": "MCP"
},
"rows": {
"theme": "Tema",
@@ -141,7 +145,11 @@
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
- "configurationDocs": "Configuration docs"
+ "configurationDocs": "Configuration docs",
+ "currentModel": "Modelo actual",
+ "brandLogos": "Logotipos de marca",
+ "cliAppsCatalog": "Catálogo de apps CLI",
+ "cliAppsFilter": "Filtro de apps CLI"
},
"help": {
"theme": "Cambia entre apariencia clara y oscura.",
@@ -168,7 +176,13 @@
"botIcon": "Short emoji or text shown beside the bot name.",
"timezone": "IANA timezone used by runtime context and schedules.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
- "advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
+ "advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.",
+ "currentModel": "Elige el modelo que nanobot usará para las próximas respuestas.",
+ "selectedModelProvider": "Lo define el modelo seleccionado.",
+ "selectedModelValue": "Lo define el modelo seleccionado.",
+ "brandLogos": "Los logotipos se cargan desde los dominios de las marcas con una reserva de icono local.",
+ "cliAppsCatalog": "Explora CLIs de apps que nanobot puede ejecutar localmente.",
+ "cliAppsFilter": "Busca por app, categoría o capacidad."
},
"values": {
"light": "Claro",
@@ -185,9 +199,7 @@
"on": "On",
"off": "Off",
"configured": "Configured",
- "notConfigured": "Not configured",
- "restartRequired": "Restart required",
- "liveReload": "Live reload ready"
+ "notConfigured": "Not configured"
},
"status": {
"loading": "Cargando configuración...",
@@ -259,7 +271,8 @@
},
"providers": {
"searchPlaceholder": "Search providers",
- "noMatches": "No providers match this search."
+ "noMatches": "No providers match this search.",
+ "saveProvider": "Guardar proveedor"
},
"image": {
"selectProvider": "Seleccionar proveedor",
@@ -267,6 +280,106 @@
"selectSize": "Seleccionar tamaño",
"configureProvider": "Configurar proveedor",
"missingCredential": "Configura este proveedor antes de activar la generación de imágenes."
+ },
+ "models": {
+ "selectModel": "Seleccionar modelo",
+ "addConfiguration": "Añadir configuración",
+ "newConfiguration": "Nueva configuración de modelo",
+ "newConfigurationHelp": "Guarda un proveedor y un modelo como una opción de un clic.",
+ "configurationName": "Nombre",
+ "configurationNamePlaceholder": "Escritura rápida"
+ },
+ "timezone": {
+ "select": "Seleccionar zona horaria",
+ "search": "Buscar zona horaria",
+ "empty": "No hay zonas horarias coincidentes."
+ },
+ "cliApps": {
+ "allCategories": "Todas las categorías",
+ "availableCount": "{{count}} apps",
+ "installedCount": "{{count}} instaladas",
+ "summary": "{{installed}} de {{total}} CLIs instaladas",
+ "filterAll": "Todas",
+ "filterInstalled": "CLIs instaladas",
+ "filterNotInstalled": "No instaladas",
+ "searchPlaceholder": "Buscar CLIs",
+ "statusInstalled": "Instalada",
+ "statusAvailable": "Disponible",
+ "statusMissing": "Falta dependencia",
+ "statusUnsupported": "No compatible",
+ "statusNotInstalled": "No instalada",
+ "unsupported": "No compatible",
+ "loading": "Cargando apps CLI...",
+ "empty": "Ninguna app CLI coincide con este filtro.",
+ "readyTitle": "@{{name}} está listo",
+ "readyStatus": "Listo",
+ "readyPrompt": "Usa @{{name}} para ver qué puede hacer este CLI.",
+ "readyTry": "Probar @{{name}}",
+ "readyCopied": "Copiado",
+ "openChat": "Abrir chat",
+ "requires": "Requiere",
+ "test": "Probar CLI",
+ "update": "Actualizar CLI",
+ "uninstall": "Desinstalar CLI",
+ "install": "Instalar CLI",
+ "unavailable": "No disponible",
+ "noDescription": "Sin descripción disponible."
+ },
+ "mcp": {
+ "allCategories": "Todas las categorías",
+ "summary": "{{installed}} de {{total}} presets habilitados",
+ "filterAll": "Todos",
+ "filterInstalled": "Habilitados",
+ "filterNotInstalled": "No habilitados",
+ "searchPlaceholder": "Buscar presets MCP",
+ "moreOptions": "Más opciones de MCP",
+ "moreOptionsSubtitle": "Añade un servidor personalizado o importa mcp.json.",
+ "customTitle": "MCP personalizado",
+ "customSubtitle": "Añade cualquier servidor MCP stdio, HTTP o SSE.",
+ "customAction": "Personalizado",
+ "importAction": "Importar",
+ "serverName": "Nombre del servidor",
+ "serverUrl": "URL",
+ "transport": "Transporte",
+ "command": "Comando",
+ "args": "Args JSON",
+ "headers": "Headers JSON",
+ "env": "Env JSON",
+ "timeout": "Tiempo límite de herramienta",
+ "advancedOptions": "Opciones avanzadas",
+ "hideAdvanced": "Ocultar avanzado",
+ "saveCustom": "Guardar MCP",
+ "configImport": "Importar mcp.json",
+ "importConfig": "Importar",
+ "restartRequired": "Reinicia nanobot para conectar las herramientas MCP actualizadas.",
+ "toolsFound": "{{count}} herramientas",
+ "loading": "Cargando presets MCP...",
+ "empty": "Ningún preset MCP coincide con este filtro.",
+ "openDocs": "Abrir docs",
+ "test": "Probar",
+ "remove": "Eliminar",
+ "enable": "Habilitar",
+ "enabled": "Habilitado",
+ "setup": "Conectar",
+ "configure": "Conectar",
+ "connectTitle": "Conectar {{name}}",
+ "connectHint": "Añade la clave desde la configuración de tu cuenta.",
+ "saveAndEnable": "Guardar y habilitar",
+ "updateSetup": "Actualizar configuración",
+ "configured": "configurado",
+ "keepExisting": "Déjalo en blanco para conservar el valor actual",
+ "statusConfigured": "Configurado",
+ "statusMissingCredentials": "Necesita clave",
+ "statusMissingDependency": "Necesita dependencia",
+ "statusComingSoon": "Próximamente",
+ "statusNotInstalled": "No habilitado",
+ "toolScope": "Herramientas",
+ "allTools": "Todas",
+ "noTools": "Ninguna",
+ "testForTools": "Ejecuta Probar para inspeccionar y elegir herramientas individuales."
+ },
+ "legal": {
+ "thirdPartyBrands": "Los nombres, logotipos y marcas de productos pertenecen a sus respectivos propietarios. Su uso es solo identificativo y no implica respaldo."
}
},
"chat": {
@@ -370,8 +483,7 @@
"title": "Editar una imagen",
"prompt": "Ayúdame a editar una imagen. Primero pídeme que suba o indique la imagen, y luego genera el resultado editado."
}
- },
- "description": "Haz preguntas, continúa tu trabajo local o inicia un nuevo hilo."
+ }
},
"header": {
"toggleSidebar": "Mostrar u ocultar la barra lateral",
@@ -475,6 +587,16 @@
"decode_failed": "No se pudo decodificar esta imagen",
"too_large": "Imagen demasiado grande — prueba una más pequeña",
"io": "No se pudo leer este archivo"
+ },
+ "mentions": {
+ "ariaLabel": "Apps y MCP",
+ "label": "Plugins",
+ "cliGroup": "Apps CLI",
+ "mcpGroup": "Servidores MCP",
+ "cliBadge": "CLI",
+ "mcpBadge": "MCP",
+ "cliDescription": "Usar @{{name}} como app CLI local",
+ "mcpDescription": "Usar @{{name}} como servidor MCP"
}
},
"scrollToBottom": "Desplazarse al final",
@@ -499,7 +621,18 @@
"imageAttachment": "Imagen adjunta",
"copyReply": "Copiar respuesta",
"copiedReply": "Respuesta copiada",
- "turnLatencyTitle": "Tiempo de respuesta (extremo a extremo)"
+ "turnLatencyTitle": "Tiempo de respuesta (extremo a extremo)",
+ "activityThinkingFor": "Pensando durante {{duration}}",
+ "activityThoughtFor": "Pensó durante {{duration}}",
+ "cliActivityRunningOne": "Usando @{{name}}",
+ "cliActivityRanOne": "Usó @{{name}}",
+ "cliActivityFailedOne": "Falló @{{name}}",
+ "cliActivityRunningMany": "Usando {{count}} apps CLI",
+ "cliActivityRanMany": "Usó {{count}} apps CLI",
+ "cliActivityFailedMany": "Fallaron {{count}} apps CLI",
+ "cliRunRunning": "Usando",
+ "cliRunRan": "Usado",
+ "cliRunFailed": "Falló"
},
"lightbox": {
"title": "Vista previa de imagen",
diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json
index 3056a38fd..3fa4c205d 100644
--- a/webui/src/i18n/locales/fr/common.json
+++ b/webui/src/i18n/locales/fr/common.json
@@ -81,7 +81,9 @@
"image": "Image",
"web": "Web",
"runtime": "Runtime",
- "advanced": "Advanced"
+ "advanced": "Advanced",
+ "cliApps": "Apps CLI",
+ "mcp": "MCP"
},
"sections": {
"interface": "Interface",
@@ -97,7 +99,9 @@
"identity": "Identity",
"safety": "Safety",
"capabilities": "Capacités",
- "integrations": "Integrations"
+ "integrations": "Integrations",
+ "cliApps": "Apps CLI",
+ "mcp": "MCP"
},
"rows": {
"theme": "Thème",
@@ -141,7 +145,11 @@
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
- "configurationDocs": "Configuration docs"
+ "configurationDocs": "Configuration docs",
+ "currentModel": "Modèle actuel",
+ "brandLogos": "Logos de marque",
+ "cliAppsCatalog": "Catalogue d'apps CLI",
+ "cliAppsFilter": "Filtre des apps CLI"
},
"help": {
"theme": "Basculer entre les apparences claire et sombre.",
@@ -168,7 +176,13 @@
"botIcon": "Short emoji or text shown beside the bot name.",
"timezone": "IANA timezone used by runtime context and schedules.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
- "advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
+ "advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.",
+ "currentModel": "Choisissez le modèle que nanobot utilisera pour les prochaines réponses.",
+ "selectedModelProvider": "Défini par le modèle sélectionné.",
+ "selectedModelValue": "Défini par le modèle sélectionné.",
+ "brandLogos": "Les logos sont chargés depuis les domaines des marques avec une icône locale en secours.",
+ "cliAppsCatalog": "Parcourez les CLIs d'apps que nanobot peut exécuter localement.",
+ "cliAppsFilter": "Recherchez par app, catégorie ou capacité."
},
"values": {
"light": "Clair",
@@ -185,9 +199,7 @@
"on": "On",
"off": "Off",
"configured": "Configured",
- "notConfigured": "Not configured",
- "restartRequired": "Restart required",
- "liveReload": "Live reload ready"
+ "notConfigured": "Not configured"
},
"status": {
"loading": "Chargement des paramètres...",
@@ -259,7 +271,8 @@
},
"providers": {
"searchPlaceholder": "Search providers",
- "noMatches": "No providers match this search."
+ "noMatches": "No providers match this search.",
+ "saveProvider": "Enregistrer le fournisseur"
},
"image": {
"selectProvider": "Sélectionner un fournisseur",
@@ -267,6 +280,106 @@
"selectSize": "Sélectionner une taille",
"configureProvider": "Configurer le fournisseur",
"missingCredential": "Configurez ce fournisseur avant d’activer la génération d’images."
+ },
+ "models": {
+ "selectModel": "Sélectionner un modèle",
+ "addConfiguration": "Ajouter une configuration",
+ "newConfiguration": "Nouvelle configuration de modèle",
+ "newConfigurationHelp": "Enregistrez un fournisseur et un modèle comme option en un clic.",
+ "configurationName": "Nom",
+ "configurationNamePlaceholder": "Rédaction rapide"
+ },
+ "timezone": {
+ "select": "Sélectionner un fuseau horaire",
+ "search": "Rechercher un fuseau horaire",
+ "empty": "Aucun fuseau horaire correspondant."
+ },
+ "cliApps": {
+ "allCategories": "Toutes les catégories",
+ "availableCount": "{{count}} apps",
+ "installedCount": "{{count}} installées",
+ "summary": "{{installed}} CLIs installées sur {{total}}",
+ "filterAll": "Tout",
+ "filterInstalled": "CLIs installées",
+ "filterNotInstalled": "Non installées",
+ "searchPlaceholder": "Rechercher des CLIs",
+ "statusInstalled": "Installée",
+ "statusAvailable": "Disponible",
+ "statusMissing": "Dépendance manquante",
+ "statusUnsupported": "Non compatible",
+ "statusNotInstalled": "Non installée",
+ "unsupported": "Non compatible",
+ "loading": "Chargement des apps CLI...",
+ "empty": "Aucune app CLI ne correspond à ce filtre.",
+ "readyTitle": "@{{name}} est prêt",
+ "readyStatus": "Prêt",
+ "readyPrompt": "Utilisez @{{name}} pour voir ce que ce CLI peut faire.",
+ "readyTry": "Essayer @{{name}}",
+ "readyCopied": "Copié",
+ "openChat": "Ouvrir le chat",
+ "requires": "Requiert",
+ "test": "Tester le CLI",
+ "update": "Mettre à jour le CLI",
+ "uninstall": "Désinstaller le CLI",
+ "install": "Installer le CLI",
+ "unavailable": "Indisponible",
+ "noDescription": "Aucune description disponible."
+ },
+ "mcp": {
+ "allCategories": "Toutes les catégories",
+ "summary": "{{installed}} presets activés sur {{total}}",
+ "filterAll": "Tout",
+ "filterInstalled": "Activés",
+ "filterNotInstalled": "Non activés",
+ "searchPlaceholder": "Rechercher des presets MCP",
+ "moreOptions": "Plus d'options MCP",
+ "moreOptionsSubtitle": "Ajoutez un serveur personnalisé ou importez mcp.json.",
+ "customTitle": "MCP personnalisé",
+ "customSubtitle": "Ajoutez n'importe quel serveur MCP stdio, HTTP ou SSE.",
+ "customAction": "Personnalisé",
+ "importAction": "Importer",
+ "serverName": "Nom du serveur",
+ "serverUrl": "URL",
+ "transport": "Transport",
+ "command": "Commande",
+ "args": "Args JSON",
+ "headers": "Headers JSON",
+ "env": "Env JSON",
+ "timeout": "Délai d'outil",
+ "advancedOptions": "Options avancées",
+ "hideAdvanced": "Masquer les options avancées",
+ "saveCustom": "Enregistrer MCP",
+ "configImport": "Importer mcp.json",
+ "importConfig": "Importer",
+ "restartRequired": "Redémarrez nanobot pour connecter les outils MCP mis à jour.",
+ "toolsFound": "{{count}} outils",
+ "loading": "Chargement des presets MCP...",
+ "empty": "Aucun preset MCP ne correspond à ce filtre.",
+ "openDocs": "Ouvrir la doc",
+ "test": "Tester",
+ "remove": "Supprimer",
+ "enable": "Activer",
+ "enabled": "Activé",
+ "setup": "Connecter",
+ "configure": "Connecter",
+ "connectTitle": "Connecter {{name}}",
+ "connectHint": "Ajoutez la clé depuis les paramètres de votre compte.",
+ "saveAndEnable": "Enregistrer et activer",
+ "updateSetup": "Mettre à jour la configuration",
+ "configured": "configuré",
+ "keepExisting": "Laissez vide pour conserver la valeur actuelle",
+ "statusConfigured": "Configuré",
+ "statusMissingCredentials": "Clé requise",
+ "statusMissingDependency": "Dépendance requise",
+ "statusComingSoon": "Bientôt disponible",
+ "statusNotInstalled": "Non activé",
+ "toolScope": "Outils",
+ "allTools": "Tous",
+ "noTools": "Aucun",
+ "testForTools": "Exécutez Tester pour inspecter et choisir des outils individuels."
+ },
+ "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."
}
},
"chat": {
@@ -370,8 +483,7 @@
"title": "Modifier une image",
"prompt": "Aidez-moi à modifier une image. Demandez-moi d’abord de téléverser ou d’indiquer l’image, puis générez le résultat modifié."
}
- },
- "description": "Posez des questions, poursuivez votre travail local ou démarrez un nouveau fil."
+ }
},
"header": {
"toggleSidebar": "Afficher ou masquer la barre latérale",
@@ -475,6 +587,16 @@
"decode_failed": "Impossible de décoder cette image",
"too_large": "Image trop grande — essayez-en une plus petite",
"io": "Impossible de lire ce fichier"
+ },
+ "mentions": {
+ "ariaLabel": "Apps et MCP",
+ "label": "Plugins",
+ "cliGroup": "Apps CLI",
+ "mcpGroup": "Serveurs MCP",
+ "cliBadge": "CLI",
+ "mcpBadge": "MCP",
+ "cliDescription": "Utiliser @{{name}} comme app CLI locale",
+ "mcpDescription": "Utiliser @{{name}} comme serveur MCP"
}
},
"scrollToBottom": "Faire défiler vers le bas",
@@ -499,7 +621,18 @@
"imageAttachment": "Pièce jointe image",
"copyReply": "Copier la réponse",
"copiedReply": "Réponse copiée",
- "turnLatencyTitle": "Temps de réponse (de bout en bout)"
+ "turnLatencyTitle": "Temps de réponse (de bout en bout)",
+ "activityThinkingFor": "Réflexion pendant {{duration}}",
+ "activityThoughtFor": "Réflexion terminée en {{duration}}",
+ "cliActivityRunningOne": "Utilisation de @{{name}}",
+ "cliActivityRanOne": "@{{name}} utilisé",
+ "cliActivityFailedOne": "Échec de @{{name}}",
+ "cliActivityRunningMany": "Utilisation de {{count}} apps CLI",
+ "cliActivityRanMany": "{{count}} apps CLI utilisées",
+ "cliActivityFailedMany": "Échec de {{count}} apps CLI",
+ "cliRunRunning": "Utilisation",
+ "cliRunRan": "Utilisé",
+ "cliRunFailed": "Échec"
},
"lightbox": {
"title": "Aperçu de l’image",
diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json
index 6de21a9f9..5c5432c7b 100644
--- a/webui/src/i18n/locales/id/common.json
+++ b/webui/src/i18n/locales/id/common.json
@@ -81,7 +81,9 @@
"image": "Image",
"web": "Web",
"runtime": "Runtime",
- "advanced": "Advanced"
+ "advanced": "Advanced",
+ "cliApps": "Aplikasi CLI",
+ "mcp": "MCP"
},
"sections": {
"interface": "Antarmuka",
@@ -97,7 +99,9 @@
"identity": "Identity",
"safety": "Safety",
"capabilities": "Kapabilitas",
- "integrations": "Integrations"
+ "integrations": "Integrations",
+ "cliApps": "Aplikasi CLI",
+ "mcp": "MCP"
},
"rows": {
"theme": "Tema",
@@ -141,7 +145,11 @@
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
- "configurationDocs": "Configuration docs"
+ "configurationDocs": "Configuration docs",
+ "currentModel": "Model saat ini",
+ "brandLogos": "Logo merek",
+ "cliAppsCatalog": "Katalog aplikasi CLI",
+ "cliAppsFilter": "Filter aplikasi CLI"
},
"help": {
"theme": "Beralih antara tampilan terang dan gelap.",
@@ -168,7 +176,13 @@
"botIcon": "Short emoji or text shown beside the bot name.",
"timezone": "IANA timezone used by runtime context and schedules.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
- "advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
+ "advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.",
+ "currentModel": "Pilih model yang digunakan nanobot untuk balasan berikutnya.",
+ "selectedModelProvider": "Ditentukan oleh model yang dipilih.",
+ "selectedModelValue": "Ditentukan oleh model yang dipilih.",
+ "brandLogos": "Logo dimuat dari domain merek dengan ikon lokal sebagai cadangan.",
+ "cliAppsCatalog": "Jelajahi CLI aplikasi yang dapat dijalankan nanobot secara lokal.",
+ "cliAppsFilter": "Cari berdasarkan aplikasi, kategori, atau kemampuan."
},
"values": {
"light": "Terang",
@@ -185,9 +199,7 @@
"on": "On",
"off": "Off",
"configured": "Configured",
- "notConfigured": "Not configured",
- "restartRequired": "Restart required",
- "liveReload": "Live reload ready"
+ "notConfigured": "Not configured"
},
"status": {
"loading": "Memuat pengaturan...",
@@ -259,7 +271,8 @@
},
"providers": {
"searchPlaceholder": "Search providers",
- "noMatches": "No providers match this search."
+ "noMatches": "No providers match this search.",
+ "saveProvider": "Simpan penyedia"
},
"image": {
"selectProvider": "Pilih penyedia",
@@ -267,6 +280,106 @@
"selectSize": "Pilih ukuran",
"configureProvider": "Konfigurasi penyedia",
"missingCredential": "Konfigurasikan penyedia ini sebelum mengaktifkan pembuatan gambar."
+ },
+ "models": {
+ "selectModel": "Pilih model",
+ "addConfiguration": "Tambah konfigurasi",
+ "newConfiguration": "Konfigurasi model baru",
+ "newConfigurationHelp": "Simpan penyedia dan model sebagai opsi sekali klik.",
+ "configurationName": "Nama",
+ "configurationNamePlaceholder": "Penulisan cepat"
+ },
+ "timezone": {
+ "select": "Pilih zona waktu",
+ "search": "Cari zona waktu",
+ "empty": "Tidak ada zona waktu yang cocok."
+ },
+ "cliApps": {
+ "allCategories": "Semua kategori",
+ "availableCount": "{{count}} aplikasi",
+ "installedCount": "{{count}} terpasang",
+ "summary": "{{installed}} dari {{total}} CLI terpasang",
+ "filterAll": "Semua",
+ "filterInstalled": "CLI terpasang",
+ "filterNotInstalled": "Belum terpasang",
+ "searchPlaceholder": "Cari CLI",
+ "statusInstalled": "Terpasang",
+ "statusAvailable": "Tersedia",
+ "statusMissing": "Dependensi hilang",
+ "statusUnsupported": "Tidak didukung",
+ "statusNotInstalled": "Belum terpasang",
+ "unsupported": "Tidak didukung",
+ "loading": "Memuat aplikasi CLI...",
+ "empty": "Tidak ada aplikasi CLI yang cocok dengan filter ini.",
+ "readyTitle": "@{{name}} siap",
+ "readyStatus": "Siap",
+ "readyPrompt": "Gunakan @{{name}} untuk melihat kemampuan CLI ini.",
+ "readyTry": "Coba @{{name}}",
+ "readyCopied": "Disalin",
+ "openChat": "Buka chat",
+ "requires": "Membutuhkan",
+ "test": "Uji CLI",
+ "update": "Perbarui CLI",
+ "uninstall": "Copot CLI",
+ "install": "Pasang CLI",
+ "unavailable": "Tidak tersedia",
+ "noDescription": "Tidak ada deskripsi."
+ },
+ "mcp": {
+ "allCategories": "Semua kategori",
+ "summary": "{{installed}} dari {{total}} preset diaktifkan",
+ "filterAll": "Semua",
+ "filterInstalled": "Aktif",
+ "filterNotInstalled": "Tidak aktif",
+ "searchPlaceholder": "Cari preset MCP",
+ "moreOptions": "Opsi MCP lainnya",
+ "moreOptionsSubtitle": "Tambahkan server khusus atau impor mcp.json.",
+ "customTitle": "MCP khusus",
+ "customSubtitle": "Tambahkan server MCP stdio, HTTP, atau SSE apa pun.",
+ "customAction": "Khusus",
+ "importAction": "Impor",
+ "serverName": "Nama server",
+ "serverUrl": "URL",
+ "transport": "Transport",
+ "command": "Perintah",
+ "args": "Args JSON",
+ "headers": "Headers JSON",
+ "env": "Env JSON",
+ "timeout": "Batas waktu alat",
+ "advancedOptions": "Opsi lanjutan",
+ "hideAdvanced": "Sembunyikan lanjutan",
+ "saveCustom": "Simpan MCP",
+ "configImport": "Impor mcp.json",
+ "importConfig": "Impor",
+ "restartRequired": "Mulai ulang nanobot untuk menyambungkan alat MCP yang diperbarui.",
+ "toolsFound": "{{count}} alat",
+ "loading": "Memuat preset MCP...",
+ "empty": "Tidak ada preset MCP yang cocok dengan filter ini.",
+ "openDocs": "Buka dokumentasi",
+ "test": "Uji",
+ "remove": "Hapus",
+ "enable": "Aktifkan",
+ "enabled": "Aktif",
+ "setup": "Hubungkan",
+ "configure": "Hubungkan",
+ "connectTitle": "Hubungkan {{name}}",
+ "connectHint": "Tambahkan kunci dari pengaturan akun Anda.",
+ "saveAndEnable": "Simpan dan aktifkan",
+ "updateSetup": "Perbarui konfigurasi",
+ "configured": "terkonfigurasi",
+ "keepExisting": "Biarkan kosong untuk mempertahankan nilai saat ini",
+ "statusConfigured": "Terkonfigurasi",
+ "statusMissingCredentials": "Butuh kunci",
+ "statusMissingDependency": "Butuh dependensi",
+ "statusComingSoon": "Segera hadir",
+ "statusNotInstalled": "Tidak aktif",
+ "toolScope": "Alat",
+ "allTools": "Semua",
+ "noTools": "Tidak ada",
+ "testForTools": "Jalankan Uji untuk memeriksa dan memilih alat individual."
+ },
+ "legal": {
+ "thirdPartyBrands": "Nama produk, logo, dan merek adalah milik pemiliknya masing-masing. Penggunaan hanya untuk identifikasi dan tidak menyiratkan dukungan."
}
},
"chat": {
@@ -370,8 +483,7 @@
"title": "Edit gambar",
"prompt": "Bantu saya mengedit gambar. Minta saya mengunggah atau menyebutkan gambar terlebih dahulu, lalu buat hasil editnya."
}
- },
- "description": "Ajukan pertanyaan, lanjutkan pekerjaan lokal, atau mulai thread baru."
+ }
},
"header": {
"toggleSidebar": "Tampilkan atau sembunyikan sidebar",
@@ -475,6 +587,16 @@
"decode_failed": "Tidak dapat mendekode gambar ini",
"too_large": "Gambar terlalu besar — coba yang lebih kecil",
"io": "Tidak dapat membaca file ini"
+ },
+ "mentions": {
+ "ariaLabel": "Aplikasi dan MCP",
+ "label": "Plugin",
+ "cliGroup": "Aplikasi CLI",
+ "mcpGroup": "Server MCP",
+ "cliBadge": "CLI",
+ "mcpBadge": "MCP",
+ "cliDescription": "Gunakan @{{name}} sebagai aplikasi CLI lokal",
+ "mcpDescription": "Gunakan @{{name}} sebagai server MCP"
}
},
"scrollToBottom": "Gulir ke bawah",
@@ -499,7 +621,18 @@
"imageAttachment": "Lampiran gambar",
"copyReply": "Salin balasan",
"copiedReply": "Balasan disalin",
- "turnLatencyTitle": "Waktu respons (ujung ke ujung)"
+ "turnLatencyTitle": "Waktu respons (ujung ke ujung)",
+ "activityThinkingFor": "Berpikir selama {{duration}}",
+ "activityThoughtFor": "Selesai berpikir dalam {{duration}}",
+ "cliActivityRunningOne": "Menggunakan @{{name}}",
+ "cliActivityRanOne": "Menggunakan @{{name}} selesai",
+ "cliActivityFailedOne": "@{{name}} gagal",
+ "cliActivityRunningMany": "Menggunakan {{count}} aplikasi CLI",
+ "cliActivityRanMany": "{{count}} aplikasi CLI digunakan",
+ "cliActivityFailedMany": "{{count}} aplikasi CLI gagal",
+ "cliRunRunning": "Menggunakan",
+ "cliRunRan": "Digunakan",
+ "cliRunFailed": "Gagal"
},
"lightbox": {
"title": "Pratinjau gambar",
diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json
index d78c06714..36091db37 100644
--- a/webui/src/i18n/locales/ja/common.json
+++ b/webui/src/i18n/locales/ja/common.json
@@ -81,7 +81,9 @@
"image": "Image",
"web": "Web",
"runtime": "Runtime",
- "advanced": "Advanced"
+ "advanced": "Advanced",
+ "cliApps": "CLI アプリ",
+ "mcp": "MCP"
},
"sections": {
"interface": "インターフェース",
@@ -97,7 +99,9 @@
"identity": "Identity",
"safety": "Safety",
"capabilities": "機能",
- "integrations": "Integrations"
+ "integrations": "Integrations",
+ "cliApps": "CLI アプリ",
+ "mcp": "MCP"
},
"rows": {
"theme": "テーマ",
@@ -141,7 +145,11 @@
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
- "configurationDocs": "Configuration docs"
+ "configurationDocs": "Configuration docs",
+ "currentModel": "現在のモデル",
+ "brandLogos": "ブランドロゴ",
+ "cliAppsCatalog": "CLI アプリカタログ",
+ "cliAppsFilter": "CLI アプリフィルター"
},
"help": {
"theme": "ライト表示とダーク表示を切り替えます。",
@@ -168,7 +176,13 @@
"botIcon": "Short emoji or text shown beside the bot name.",
"timezone": "IANA timezone used by runtime context and schedules.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
- "advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
+ "advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.",
+ "currentModel": "今後の返信で nanobot が使用するモデルを選択します。",
+ "selectedModelProvider": "選択したモデルによって設定されます。",
+ "selectedModelValue": "選択したモデルによって設定されます。",
+ "brandLogos": "ロゴはブランドのドメインから読み込まれ、ローカルアイコンにフォールバックします。",
+ "cliAppsCatalog": "nanobot がローカルで実行できるアプリ CLI を探します。",
+ "cliAppsFilter": "アプリ、カテゴリ、機能で検索します。"
},
"values": {
"light": "ライト",
@@ -185,9 +199,7 @@
"on": "On",
"off": "Off",
"configured": "Configured",
- "notConfigured": "Not configured",
- "restartRequired": "Restart required",
- "liveReload": "Live reload ready"
+ "notConfigured": "Not configured"
},
"status": {
"loading": "設定を読み込んでいます...",
@@ -259,7 +271,8 @@
},
"providers": {
"searchPlaceholder": "Search providers",
- "noMatches": "No providers match this search."
+ "noMatches": "No providers match this search.",
+ "saveProvider": "プロバイダーを保存"
},
"image": {
"selectProvider": "プロバイダーを選択",
@@ -267,6 +280,106 @@
"selectSize": "サイズを選択",
"configureProvider": "プロバイダーを設定",
"missingCredential": "画像生成を有効にする前に、このプロバイダーを設定してください。"
+ },
+ "models": {
+ "selectModel": "モデルを選択",
+ "addConfiguration": "設定を追加",
+ "newConfiguration": "新しいモデル設定",
+ "newConfigurationHelp": "プロバイダーとモデルをワンクリックの選択肢として保存します。",
+ "configurationName": "名前",
+ "configurationNamePlaceholder": "高速ライティング"
+ },
+ "timezone": {
+ "select": "タイムゾーンを選択",
+ "search": "タイムゾーンを検索",
+ "empty": "一致するタイムゾーンはありません。"
+ },
+ "cliApps": {
+ "allCategories": "すべてのカテゴリ",
+ "availableCount": "{{count}} 個のアプリ",
+ "installedCount": "{{count}} 個インストール済み",
+ "summary": "{{total}} 個中 {{installed}} 個の CLI がインストール済み",
+ "filterAll": "すべて",
+ "filterInstalled": "インストール済み CLI",
+ "filterNotInstalled": "未インストール",
+ "searchPlaceholder": "CLI を検索",
+ "statusInstalled": "インストール済み",
+ "statusAvailable": "利用可能",
+ "statusMissing": "依存関係が不足",
+ "statusUnsupported": "未対応",
+ "statusNotInstalled": "未インストール",
+ "unsupported": "未対応",
+ "loading": "CLI アプリを読み込み中...",
+ "empty": "この条件に一致する CLI アプリはありません。",
+ "readyTitle": "@{{name}} の準備ができました",
+ "readyStatus": "準備完了",
+ "readyPrompt": "@{{name}} を使って、この CLI でできることを確認します。",
+ "readyTry": "@{{name}} を試す",
+ "readyCopied": "コピーしました",
+ "openChat": "チャットを開く",
+ "requires": "必要条件",
+ "test": "CLI をテスト",
+ "update": "CLI を更新",
+ "uninstall": "CLI をアンインストール",
+ "install": "CLI をインストール",
+ "unavailable": "利用不可",
+ "noDescription": "説明はありません。"
+ },
+ "mcp": {
+ "allCategories": "すべてのカテゴリ",
+ "summary": "{{total}} 個中 {{installed}} 個のプリセットが有効",
+ "filterAll": "すべて",
+ "filterInstalled": "有効",
+ "filterNotInstalled": "未有効",
+ "searchPlaceholder": "MCP プリセットを検索",
+ "moreOptions": "その他の MCP オプション",
+ "moreOptionsSubtitle": "カスタムサーバーを追加するか mcp.json をインポートします。",
+ "customTitle": "カスタム MCP",
+ "customSubtitle": "任意の stdio、HTTP、SSE MCP サーバーを追加します。",
+ "customAction": "カスタム",
+ "importAction": "インポート",
+ "serverName": "サーバー名",
+ "serverUrl": "URL",
+ "transport": "トランスポート",
+ "command": "コマンド",
+ "args": "Args JSON",
+ "headers": "Headers JSON",
+ "env": "Env JSON",
+ "timeout": "ツールのタイムアウト",
+ "advancedOptions": "詳細オプション",
+ "hideAdvanced": "詳細を隠す",
+ "saveCustom": "MCP を保存",
+ "configImport": "mcp.json をインポート",
+ "importConfig": "インポート",
+ "restartRequired": "更新された MCP ツールに接続するには nanobot を再起動してください。",
+ "toolsFound": "{{count}} 個のツール",
+ "loading": "MCP プリセットを読み込み中...",
+ "empty": "この条件に一致する MCP プリセットはありません。",
+ "openDocs": "ドキュメントを開く",
+ "test": "テスト",
+ "remove": "削除",
+ "enable": "有効化",
+ "enabled": "有効",
+ "setup": "接続",
+ "configure": "接続",
+ "connectTitle": "{{name}} に接続",
+ "connectHint": "アカウント設定からキーを追加します。",
+ "saveAndEnable": "保存して有効化",
+ "updateSetup": "設定を更新",
+ "configured": "設定済み",
+ "keepExisting": "既存の値を維持するには空欄のままにします",
+ "statusConfigured": "設定済み",
+ "statusMissingCredentials": "キーが必要",
+ "statusMissingDependency": "依存関係が必要",
+ "statusComingSoon": "近日公開",
+ "statusNotInstalled": "未有効",
+ "toolScope": "ツール",
+ "allTools": "すべて",
+ "noTools": "なし",
+ "testForTools": "テストを実行して個別のツールを確認・選択します。"
+ },
+ "legal": {
+ "thirdPartyBrands": "製品名、ロゴ、ブランドはそれぞれの所有者に帰属します。使用は識別のみを目的とし、承認を意味するものではありません。"
}
},
"chat": {
@@ -370,8 +483,7 @@
"title": "画像を編集",
"prompt": "画像編集を手伝ってください。まず編集する画像のアップロードまたは指定を求め、その後に編集後の結果を生成してください。"
}
- },
- "description": "質問したり、ローカル作業を続けたり、新しいスレッドを始めたりできます。"
+ }
},
"header": {
"toggleSidebar": "サイドバーを切り替える",
@@ -475,6 +587,16 @@
"decode_failed": "この画像をデコードできません",
"too_large": "画像が大きすぎます。小さいものを選んでください",
"io": "このファイルを読み込めません"
+ },
+ "mentions": {
+ "ariaLabel": "アプリと MCP",
+ "label": "プラグイン",
+ "cliGroup": "CLI アプリ",
+ "mcpGroup": "MCP サーバー",
+ "cliBadge": "CLI",
+ "mcpBadge": "MCP",
+ "cliDescription": "@{{name}} をローカル CLI アプリとして使用",
+ "mcpDescription": "@{{name}} を MCP サーバーとして使用"
}
},
"scrollToBottom": "一番下へスクロール",
@@ -499,7 +621,18 @@
"imageAttachment": "画像の添付",
"copyReply": "返信をコピー",
"copiedReply": "返信をコピーしました",
- "turnLatencyTitle": "応答時間(全行程)"
+ "turnLatencyTitle": "応答時間(全行程)",
+ "activityThinkingFor": "{{duration}}考えています",
+ "activityThoughtFor": "{{duration}}考えました",
+ "cliActivityRunningOne": "@{{name}} を使用中",
+ "cliActivityRanOne": "@{{name}} を使用しました",
+ "cliActivityFailedOne": "@{{name}} が失敗しました",
+ "cliActivityRunningMany": "{{count}} 個の CLI アプリを使用中",
+ "cliActivityRanMany": "{{count}} 個の CLI アプリを使用しました",
+ "cliActivityFailedMany": "{{count}} 個の CLI アプリが失敗しました",
+ "cliRunRunning": "使用中",
+ "cliRunRan": "使用済み",
+ "cliRunFailed": "失敗"
},
"lightbox": {
"title": "画像プレビュー",
diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json
index 9e37c4d52..76430df9a 100644
--- a/webui/src/i18n/locales/ko/common.json
+++ b/webui/src/i18n/locales/ko/common.json
@@ -81,7 +81,9 @@
"image": "Image",
"web": "Web",
"runtime": "Runtime",
- "advanced": "Advanced"
+ "advanced": "Advanced",
+ "cliApps": "CLI 앱",
+ "mcp": "MCP"
},
"sections": {
"interface": "인터페이스",
@@ -97,7 +99,9 @@
"identity": "Identity",
"safety": "Safety",
"capabilities": "기능",
- "integrations": "Integrations"
+ "integrations": "Integrations",
+ "cliApps": "CLI 앱",
+ "mcp": "MCP"
},
"rows": {
"theme": "테마",
@@ -141,7 +145,11 @@
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
- "configurationDocs": "Configuration docs"
+ "configurationDocs": "Configuration docs",
+ "currentModel": "현재 모델",
+ "brandLogos": "브랜드 로고",
+ "cliAppsCatalog": "CLI 앱 카탈로그",
+ "cliAppsFilter": "CLI 앱 필터"
},
"help": {
"theme": "밝은 모드와 어두운 모드를 전환합니다.",
@@ -168,7 +176,13 @@
"botIcon": "Short emoji or text shown beside the bot name.",
"timezone": "IANA timezone used by runtime context and schedules.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
- "advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
+ "advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.",
+ "currentModel": "nanobot이 새 답변에 사용할 모델을 선택합니다.",
+ "selectedModelProvider": "선택한 모델에서 설정됩니다.",
+ "selectedModelValue": "선택한 모델에서 설정됩니다.",
+ "brandLogos": "로고는 브랜드 도메인에서 불러오며, 실패하면 로컬 아이콘을 사용합니다.",
+ "cliAppsCatalog": "nanobot이 로컬에서 실행할 수 있는 앱 CLI를 살펴봅니다.",
+ "cliAppsFilter": "앱, 카테고리 또는 기능으로 검색합니다."
},
"values": {
"light": "라이트",
@@ -185,9 +199,7 @@
"on": "On",
"off": "Off",
"configured": "Configured",
- "notConfigured": "Not configured",
- "restartRequired": "Restart required",
- "liveReload": "Live reload ready"
+ "notConfigured": "Not configured"
},
"status": {
"loading": "설정을 불러오는 중...",
@@ -259,7 +271,8 @@
},
"providers": {
"searchPlaceholder": "Search providers",
- "noMatches": "No providers match this search."
+ "noMatches": "No providers match this search.",
+ "saveProvider": "제공자 저장"
},
"image": {
"selectProvider": "제공자 선택",
@@ -267,6 +280,106 @@
"selectSize": "크기 선택",
"configureProvider": "제공자 구성",
"missingCredential": "이미지 생성을 활성화하기 전에 이 제공자를 구성하세요."
+ },
+ "models": {
+ "selectModel": "모델 선택",
+ "addConfiguration": "구성 추가",
+ "newConfiguration": "새 모델 구성",
+ "newConfigurationHelp": "제공자와 모델을 한 번에 선택할 수 있는 옵션으로 저장합니다.",
+ "configurationName": "이름",
+ "configurationNamePlaceholder": "빠른 글쓰기"
+ },
+ "timezone": {
+ "select": "시간대 선택",
+ "search": "시간대 검색",
+ "empty": "일치하는 시간대가 없습니다."
+ },
+ "cliApps": {
+ "allCategories": "모든 카테고리",
+ "availableCount": "앱 {{count}}개",
+ "installedCount": "{{count}}개 설치됨",
+ "summary": "CLI {{total}}개 중 {{installed}}개 설치됨",
+ "filterAll": "전체",
+ "filterInstalled": "설치된 CLI",
+ "filterNotInstalled": "미설치",
+ "searchPlaceholder": "CLI 검색",
+ "statusInstalled": "설치됨",
+ "statusAvailable": "사용 가능",
+ "statusMissing": "의존성 필요",
+ "statusUnsupported": "지원 안 함",
+ "statusNotInstalled": "미설치",
+ "unsupported": "지원 안 함",
+ "loading": "CLI 앱 로드 중...",
+ "empty": "이 필터와 일치하는 CLI 앱이 없습니다.",
+ "readyTitle": "@{{name}} 준비됨",
+ "readyStatus": "준비됨",
+ "readyPrompt": "@{{name}}을 사용해 이 CLI가 무엇을 할 수 있는지 확인하세요.",
+ "readyTry": "@{{name}} 사용해 보기",
+ "readyCopied": "복사됨",
+ "openChat": "채팅 열기",
+ "requires": "필요 항목",
+ "test": "CLI 테스트",
+ "update": "CLI 업데이트",
+ "uninstall": "CLI 제거",
+ "install": "CLI 설치",
+ "unavailable": "사용 불가",
+ "noDescription": "설명이 없습니다."
+ },
+ "mcp": {
+ "allCategories": "모든 카테고리",
+ "summary": "프리셋 {{total}}개 중 {{installed}}개 활성화됨",
+ "filterAll": "전체",
+ "filterInstalled": "활성화됨",
+ "filterNotInstalled": "비활성",
+ "searchPlaceholder": "MCP 프리셋 검색",
+ "moreOptions": "추가 MCP 옵션",
+ "moreOptionsSubtitle": "사용자 지정 서버를 추가하거나 mcp.json을 가져옵니다.",
+ "customTitle": "사용자 지정 MCP",
+ "customSubtitle": "stdio, HTTP 또는 SSE MCP 서버를 추가합니다.",
+ "customAction": "사용자 지정",
+ "importAction": "가져오기",
+ "serverName": "서버 이름",
+ "serverUrl": "URL",
+ "transport": "전송 방식",
+ "command": "명령",
+ "args": "Args JSON",
+ "headers": "Headers JSON",
+ "env": "Env JSON",
+ "timeout": "도구 제한 시간",
+ "advancedOptions": "고급 옵션",
+ "hideAdvanced": "고급 숨기기",
+ "saveCustom": "MCP 저장",
+ "configImport": "mcp.json 가져오기",
+ "importConfig": "가져오기",
+ "restartRequired": "업데이트된 MCP 도구를 연결하려면 nanobot을 다시 시작하세요.",
+ "toolsFound": "도구 {{count}}개",
+ "loading": "MCP 프리셋 로드 중...",
+ "empty": "이 필터와 일치하는 MCP 프리셋이 없습니다.",
+ "openDocs": "문서 열기",
+ "test": "테스트",
+ "remove": "제거",
+ "enable": "활성화",
+ "enabled": "활성화됨",
+ "setup": "연결",
+ "configure": "연결",
+ "connectTitle": "{{name}} 연결",
+ "connectHint": "계정 설정에서 키를 추가하세요.",
+ "saveAndEnable": "저장 후 활성화",
+ "updateSetup": "설정 업데이트",
+ "configured": "구성됨",
+ "keepExisting": "기존 값을 유지하려면 비워 두세요",
+ "statusConfigured": "구성됨",
+ "statusMissingCredentials": "키 필요",
+ "statusMissingDependency": "의존성 필요",
+ "statusComingSoon": "곧 제공",
+ "statusNotInstalled": "비활성",
+ "toolScope": "도구",
+ "allTools": "전체",
+ "noTools": "없음",
+ "testForTools": "테스트를 실행해 개별 도구를 확인하고 선택하세요."
+ },
+ "legal": {
+ "thirdPartyBrands": "제품 이름, 로고 및 브랜드는 각 소유자의 자산입니다. 사용은 식별 목적일 뿐 보증이나 제휴를 의미하지 않습니다."
}
},
"chat": {
@@ -370,8 +483,7 @@
"title": "이미지 편집",
"prompt": "이미지 편집을 도와주세요. 먼저 편집할 이미지를 업로드하거나 지정하게 한 뒤, 편집된 결과를 생성해 주세요."
}
- },
- "description": "질문을 하거나, 로컬 작업을 이어가거나, 새 스레드를 시작할 수 있습니다."
+ }
},
"header": {
"toggleSidebar": "사이드바 전환",
@@ -475,6 +587,16 @@
"decode_failed": "이 이미지를 디코딩할 수 없습니다",
"too_large": "이미지가 너무 큽니다. 더 작은 걸로 시도해 주세요",
"io": "이 파일을 읽을 수 없습니다"
+ },
+ "mentions": {
+ "ariaLabel": "앱 및 MCP",
+ "label": "플러그인",
+ "cliGroup": "CLI 앱",
+ "mcpGroup": "MCP 서버",
+ "cliBadge": "CLI",
+ "mcpBadge": "MCP",
+ "cliDescription": "@{{name}}을 로컬 CLI 앱으로 사용",
+ "mcpDescription": "@{{name}}을 MCP 서버로 사용"
}
},
"scrollToBottom": "맨 아래로 스크롤",
@@ -499,7 +621,18 @@
"imageAttachment": "이미지 첨부",
"copyReply": "답변 복사",
"copiedReply": "답변이 복사됨",
- "turnLatencyTitle": "응답 시간(엔드투엔드)"
+ "turnLatencyTitle": "응답 시간(엔드투엔드)",
+ "activityThinkingFor": "{{duration}} 동안 생각 중",
+ "activityThoughtFor": "{{duration}} 동안 생각함",
+ "cliActivityRunningOne": "@{{name}} 사용 중",
+ "cliActivityRanOne": "@{{name}} 사용함",
+ "cliActivityFailedOne": "@{{name}} 실패",
+ "cliActivityRunningMany": "CLI 앱 {{count}}개 사용 중",
+ "cliActivityRanMany": "CLI 앱 {{count}}개 사용함",
+ "cliActivityFailedMany": "CLI 앱 {{count}}개 실패",
+ "cliRunRunning": "사용 중",
+ "cliRunRan": "사용함",
+ "cliRunFailed": "실패"
},
"lightbox": {
"title": "이미지 미리보기",
diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json
index 360da649c..d29068f74 100644
--- a/webui/src/i18n/locales/vi/common.json
+++ b/webui/src/i18n/locales/vi/common.json
@@ -81,7 +81,9 @@
"image": "Image",
"web": "Web",
"runtime": "Runtime",
- "advanced": "Advanced"
+ "advanced": "Advanced",
+ "cliApps": "Ứng dụng CLI",
+ "mcp": "MCP"
},
"sections": {
"interface": "Giao diện",
@@ -97,7 +99,9 @@
"identity": "Identity",
"safety": "Safety",
"capabilities": "Khả năng",
- "integrations": "Integrations"
+ "integrations": "Integrations",
+ "cliApps": "Ứng dụng CLI",
+ "mcp": "MCP"
},
"rows": {
"theme": "Giao diện",
@@ -141,7 +145,11 @@
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
- "configurationDocs": "Configuration docs"
+ "configurationDocs": "Configuration docs",
+ "currentModel": "Mô hình hiện tại",
+ "brandLogos": "Logo thương hiệu",
+ "cliAppsCatalog": "Danh mục ứng dụng CLI",
+ "cliAppsFilter": "Bộ lọc ứng dụng CLI"
},
"help": {
"theme": "Chuyển giữa giao diện sáng và tối.",
@@ -168,7 +176,13 @@
"botIcon": "Short emoji or text shown beside the bot name.",
"timezone": "IANA timezone used by runtime context and schedules.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
- "advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
+ "advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.",
+ "currentModel": "Chọn mô hình nanobot dùng cho các câu trả lời mới.",
+ "selectedModelProvider": "Được đặt bởi mô hình đã chọn.",
+ "selectedModelValue": "Được đặt bởi mô hình đã chọn.",
+ "brandLogos": "Logo được tải từ tên miền thương hiệu, có biểu tượng cục bộ làm dự phòng.",
+ "cliAppsCatalog": "Duyệt các CLI ứng dụng mà nanobot có thể chạy cục bộ.",
+ "cliAppsFilter": "Tìm theo ứng dụng, danh mục hoặc khả năng."
},
"values": {
"light": "Sáng",
@@ -185,9 +199,7 @@
"on": "On",
"off": "Off",
"configured": "Configured",
- "notConfigured": "Not configured",
- "restartRequired": "Restart required",
- "liveReload": "Live reload ready"
+ "notConfigured": "Not configured"
},
"status": {
"loading": "Đang tải cài đặt...",
@@ -259,7 +271,8 @@
},
"providers": {
"searchPlaceholder": "Search providers",
- "noMatches": "No providers match this search."
+ "noMatches": "No providers match this search.",
+ "saveProvider": "Lưu nhà cung cấp"
},
"image": {
"selectProvider": "Chọn nhà cung cấp",
@@ -267,6 +280,106 @@
"selectSize": "Chọn kích thước",
"configureProvider": "Cấu hình nhà cung cấp",
"missingCredential": "Cấu hình nhà cung cấp này trước khi bật tạo ảnh."
+ },
+ "models": {
+ "selectModel": "Chọn mô hình",
+ "addConfiguration": "Thêm cấu hình",
+ "newConfiguration": "Cấu hình mô hình mới",
+ "newConfigurationHelp": "Lưu nhà cung cấp và mô hình thành một lựa chọn một lần nhấp.",
+ "configurationName": "Tên",
+ "configurationNamePlaceholder": "Viết nhanh"
+ },
+ "timezone": {
+ "select": "Chọn múi giờ",
+ "search": "Tìm múi giờ",
+ "empty": "Không có múi giờ phù hợp."
+ },
+ "cliApps": {
+ "allCategories": "Tất cả danh mục",
+ "availableCount": "{{count}} ứng dụng",
+ "installedCount": "Đã cài {{count}}",
+ "summary": "Đã cài {{installed}} / {{total}} CLI",
+ "filterAll": "Tất cả",
+ "filterInstalled": "CLI đã cài",
+ "filterNotInstalled": "Chưa cài",
+ "searchPlaceholder": "Tìm CLI",
+ "statusInstalled": "Đã cài",
+ "statusAvailable": "Có sẵn",
+ "statusMissing": "Thiếu phụ thuộc",
+ "statusUnsupported": "Không hỗ trợ",
+ "statusNotInstalled": "Chưa cài",
+ "unsupported": "Không hỗ trợ",
+ "loading": "Đang tải ứng dụng CLI...",
+ "empty": "Không có ứng dụng CLI nào khớp bộ lọc này.",
+ "readyTitle": "@{{name}} đã sẵn sàng",
+ "readyStatus": "Sẵn sàng",
+ "readyPrompt": "Dùng @{{name}} để xem CLI này làm được gì.",
+ "readyTry": "Thử @{{name}}",
+ "readyCopied": "Đã sao chép",
+ "openChat": "Mở chat",
+ "requires": "Yêu cầu",
+ "test": "Kiểm tra CLI",
+ "update": "Cập nhật CLI",
+ "uninstall": "Gỡ CLI",
+ "install": "Cài CLI",
+ "unavailable": "Không khả dụng",
+ "noDescription": "Không có mô tả."
+ },
+ "mcp": {
+ "allCategories": "Tất cả danh mục",
+ "summary": "Đã bật {{installed}} / {{total}} preset",
+ "filterAll": "Tất cả",
+ "filterInstalled": "Đã bật",
+ "filterNotInstalled": "Chưa bật",
+ "searchPlaceholder": "Tìm preset MCP",
+ "moreOptions": "Tùy chọn MCP khác",
+ "moreOptionsSubtitle": "Thêm máy chủ tùy chỉnh hoặc nhập mcp.json.",
+ "customTitle": "MCP tùy chỉnh",
+ "customSubtitle": "Thêm bất kỳ máy chủ MCP stdio, HTTP hoặc SSE nào.",
+ "customAction": "Tùy chỉnh",
+ "importAction": "Nhập",
+ "serverName": "Tên máy chủ",
+ "serverUrl": "URL",
+ "transport": "Giao thức truyền",
+ "command": "Lệnh",
+ "args": "Args JSON",
+ "headers": "Headers JSON",
+ "env": "Env JSON",
+ "timeout": "Thời gian chờ công cụ",
+ "advancedOptions": "Tùy chọn nâng cao",
+ "hideAdvanced": "Ẩn nâng cao",
+ "saveCustom": "Lưu MCP",
+ "configImport": "Nhập mcp.json",
+ "importConfig": "Nhập",
+ "restartRequired": "Khởi động lại nanobot để kết nối các công cụ MCP đã cập nhật.",
+ "toolsFound": "{{count}} công cụ",
+ "loading": "Đang tải preset MCP...",
+ "empty": "Không có preset MCP nào khớp bộ lọc này.",
+ "openDocs": "Mở tài liệu",
+ "test": "Kiểm tra",
+ "remove": "Xóa",
+ "enable": "Bật",
+ "enabled": "Đã bật",
+ "setup": "Kết nối",
+ "configure": "Kết nối",
+ "connectTitle": "Kết nối {{name}}",
+ "connectHint": "Thêm khóa từ phần cài đặt tài khoản của bạn.",
+ "saveAndEnable": "Lưu và bật",
+ "updateSetup": "Cập nhật thiết lập",
+ "configured": "đã cấu hình",
+ "keepExisting": "Để trống để giữ giá trị hiện tại",
+ "statusConfigured": "Đã cấu hình",
+ "statusMissingCredentials": "Cần khóa",
+ "statusMissingDependency": "Cần phụ thuộc",
+ "statusComingSoon": "Sắp ra mắt",
+ "statusNotInstalled": "Chưa bật",
+ "toolScope": "Công cụ",
+ "allTools": "Tất cả",
+ "noTools": "Không có",
+ "testForTools": "Chạy Kiểm tra để xem và chọn từng công cụ."
+ },
+ "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."
}
},
"chat": {
@@ -370,8 +483,7 @@
"title": "Chỉnh sửa ảnh",
"prompt": "Giúp tôi chỉnh sửa một ảnh. Trước tiên hãy yêu cầu tôi tải lên hoặc chỉ định ảnh, rồi tạo kết quả đã chỉnh sửa."
}
- },
- "description": "Hãy đặt câu hỏi, tiếp tục công việc cục bộ hoặc bắt đầu một luồng mới."
+ }
},
"header": {
"toggleSidebar": "Bật/tắt thanh bên",
@@ -475,6 +587,16 @@
"decode_failed": "Không thể giải mã ảnh này",
"too_large": "Ảnh quá lớn — hãy thử ảnh nhỏ hơn",
"io": "Không thể đọc tệp này"
+ },
+ "mentions": {
+ "ariaLabel": "Ứng dụng và MCP",
+ "label": "Plugin",
+ "cliGroup": "Ứng dụng CLI",
+ "mcpGroup": "Máy chủ MCP",
+ "cliBadge": "CLI",
+ "mcpBadge": "MCP",
+ "cliDescription": "Dùng @{{name}} như ứng dụng CLI cục bộ",
+ "mcpDescription": "Dùng @{{name}} như máy chủ MCP"
}
},
"scrollToBottom": "Cuộn xuống cuối",
@@ -499,7 +621,18 @@
"imageAttachment": "Tệp hình ảnh đính kèm",
"copyReply": "Sao chép trả lời",
"copiedReply": "Đã sao chép trả lời",
- "turnLatencyTitle": "Thời gian phản hồi (end-to-end)"
+ "turnLatencyTitle": "Thời gian phản hồi (end-to-end)",
+ "activityThinkingFor": "Đang suy nghĩ trong {{duration}}",
+ "activityThoughtFor": "Đã suy nghĩ trong {{duration}}",
+ "cliActivityRunningOne": "Đang dùng @{{name}}",
+ "cliActivityRanOne": "Đã dùng @{{name}}",
+ "cliActivityFailedOne": "@{{name}} thất bại",
+ "cliActivityRunningMany": "Đang dùng {{count}} ứng dụng CLI",
+ "cliActivityRanMany": "Đã dùng {{count}} ứng dụng CLI",
+ "cliActivityFailedMany": "{{count}} ứng dụng CLI thất bại",
+ "cliRunRunning": "Đang dùng",
+ "cliRunRan": "Đã dùng",
+ "cliRunFailed": "Thất bại"
},
"lightbox": {
"title": "Xem trước ảnh",
diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json
index 71a1e943b..9e9454f62 100644
--- a/webui/src/i18n/locales/zh-CN/common.json
+++ b/webui/src/i18n/locales/zh-CN/common.json
@@ -9,6 +9,18 @@
"title": "无法连接到 nanobot",
"gatewayHint": "请确认 gateway 已启动(`nanobot gateway`),并且当前页面与 gateway 运行在同一台机器上。"
},
+ "auth": {
+ "title": "需要验证",
+ "hint": "请输入 gateway 配置中的 tokenIssueSecret。",
+ "placeholder": "密码",
+ "submit": "连接",
+ "invalid": "密码无效,请重试。"
+ },
+ "account": {
+ "section": "账户",
+ "logoutHint": "断开此浏览器与 gateway 的连接。",
+ "logout": "退出登录"
+ },
"system": {
"section": "系统",
"restartHint": "重启 nanobot 以应用运行时更改。",
@@ -69,6 +81,7 @@
"image": "图片",
"web": "网页",
"cliApps": "CLI 应用",
+ "mcp": "MCP",
"runtime": "运行时",
"advanced": "高级"
},
@@ -84,11 +97,20 @@
"webSearch": "网页搜索",
"webBehavior": "行为",
"cliApps": "CLI 应用",
+ "mcp": "MCP",
"identity": "身份",
"safety": "安全",
"capabilities": "能力",
"integrations": "集成"
},
+ "models": {
+ "selectModel": "选择模型",
+ "addConfiguration": "添加配置",
+ "newConfiguration": "新建模型配置",
+ "newConfigurationHelp": "把服务商和模型保存为一个可直接切换的选项。",
+ "configurationName": "名称",
+ "configurationNamePlaceholder": "快速写作"
+ },
"rows": {
"theme": "主题",
"language": "语言",
@@ -100,6 +122,7 @@
"gateway": "网关",
"restartState": "重启状态",
"pendingChanges": "待处理更改",
+ "currentModel": "当前模型",
"selectedPreset": "选中的预设",
"presetModel": "预设模型",
"density": "密度",
@@ -142,6 +165,9 @@
"provider": "选择新模型请求使用的服务商。",
"model": "设置 nanobot 默认使用的模型名称。",
"configPath": "当前网关正在使用的配置文件。",
+ "currentModel": "选择 nanobot 接下来回复时使用的模型。",
+ "selectedModelProvider": "由当前模型决定。",
+ "selectedModelValue": "由当前模型决定。",
"selectedPreset": "命名预设在这里只读;需要编辑时请改 config.json。",
"presetModel": "切回 Default 后可在 WebUI 编辑模型和服务商。",
"density": "仅保存在当前浏览器。",
@@ -166,6 +192,11 @@
"cliAppsFilter": "按应用、分类或能力搜索。",
"advancedReadOnly": "高级安全控制在 WebUI 中只读;需要时请谨慎编辑 config.json。"
},
+ "timezone": {
+ "select": "选择时区",
+ "search": "搜索时区",
+ "empty": "没有匹配的时区。"
+ },
"cliApps": {
"allCategories": "全部分类",
"availableCount": "{{count}} 个应用",
@@ -197,6 +228,59 @@
"unavailable": "不可用",
"noDescription": "暂无描述。"
},
+ "mcp": {
+ "allCategories": "全部分类",
+ "summary": "已启用 {{installed}} / {{total}} 个预设",
+ "filterAll": "全部",
+ "filterInstalled": "已启用",
+ "filterNotInstalled": "未启用",
+ "searchPlaceholder": "搜索 MCP 预设",
+ "moreOptions": "更多 MCP 选项",
+ "moreOptionsSubtitle": "添加自定义服务,或导入 mcp.json。",
+ "customTitle": "自定义 MCP",
+ "customSubtitle": "添加任意 stdio、HTTP 或 SSE MCP 服务。",
+ "customAction": "自定义",
+ "importAction": "导入",
+ "serverName": "服务名",
+ "serverUrl": "URL",
+ "transport": "传输方式",
+ "command": "命令",
+ "args": "参数 JSON",
+ "headers": "Headers JSON",
+ "env": "环境变量 JSON",
+ "timeout": "工具超时",
+ "advancedOptions": "高级选项",
+ "hideAdvanced": "收起高级",
+ "saveCustom": "保存 MCP",
+ "configImport": "导入 mcp.json",
+ "importConfig": "导入",
+ "restartRequired": "重启 nanobot 以连接更新后的 MCP 工具。",
+ "toolsFound": "{{count}} 个工具",
+ "loading": "正在加载 MCP 预设...",
+ "empty": "没有匹配的 MCP 预设。",
+ "openDocs": "打开文档",
+ "test": "测试",
+ "remove": "移除",
+ "enable": "启用",
+ "enabled": "已启用",
+ "setup": "连接",
+ "configure": "连接",
+ "connectTitle": "连接 {{name}}",
+ "connectHint": "填入你账户里的 key。",
+ "saveAndEnable": "保存并启用",
+ "updateSetup": "更新配置",
+ "configured": "已配置",
+ "keepExisting": "留空则保留当前值",
+ "statusConfigured": "已配置",
+ "statusMissingCredentials": "需要 key",
+ "statusMissingDependency": "缺少依赖",
+ "statusComingSoon": "暂不支持",
+ "statusNotInstalled": "未启用",
+ "toolScope": "工具",
+ "allTools": "全部",
+ "noTools": "不暴露",
+ "testForTools": "运行测试后,可以查看并选择单个工具。"
+ },
"values": {
"light": "浅色",
"dark": "深色",
@@ -284,7 +368,8 @@
},
"providers": {
"searchPlaceholder": "搜索服务商",
- "noMatches": "没有匹配的服务商。"
+ "noMatches": "没有匹配的服务商。",
+ "saveProvider": "保存服务商"
},
"legal": {
"thirdPartyBrands": "产品名称、Logo 和品牌归各自所有者所有;此处仅用于识别,不代表背书或合作。"
@@ -492,8 +577,14 @@
}
},
"mentions": {
- "ariaLabel": "CLI 应用",
- "label": "CLI 应用"
+ "ariaLabel": "应用和 MCP",
+ "label": "插件",
+ "cliGroup": "CLI 应用",
+ "mcpGroup": "MCP 服务",
+ "cliBadge": "CLI",
+ "mcpBadge": "MCP",
+ "cliDescription": "使用 @{{name}} 调用本地 CLI",
+ "mcpDescription": "使用 @{{name}} 调用 MCP 服务"
},
"encoding": "处理中…",
"remove": "移除附件",
@@ -527,15 +618,17 @@
"agentActivityToolsOnly": "{{tools}} 次工具调用",
"agentActivityLiveSummary": "进行中… · {{reasoning}} 步 · {{tools}} 次工具调用",
"agentActivityLiveToolsOnly": "进行中… · {{tools}} 次工具调用",
- "cliActivityRunningOne": "正在运行 CLI @{{name}}",
- "cliActivityRanOne": "已运行 CLI @{{name}}",
- "cliActivityFailedOne": "CLI 调用失败 @{{name}}",
- "cliActivityRunningMany": "正在运行 {{count}} 个 CLI",
- "cliActivityRanMany": "已运行 {{count}} 个 CLI",
- "cliActivityFailedMany": "{{count}} 个 CLI 调用失败",
- "cliRunRunning": "正在运行 CLI",
- "cliRunRan": "已运行 CLI",
- "cliRunFailed": "CLI 调用失败",
+ "activityThinkingFor": "思考中 {{duration}}",
+ "activityThoughtFor": "思考了 {{duration}}",
+ "cliActivityRunningOne": "正在使用 @{{name}}",
+ "cliActivityRanOne": "已使用 @{{name}}",
+ "cliActivityFailedOne": "使用 @{{name}} 失败",
+ "cliActivityRunningMany": "正在使用 {{count}} 个 CLI 应用",
+ "cliActivityRanMany": "已使用 {{count}} 个 CLI 应用",
+ "cliActivityFailedMany": "{{count}} 个 CLI 应用失败",
+ "cliRunRunning": "正在使用",
+ "cliRunRan": "已使用",
+ "cliRunFailed": "失败",
"imageAttachment": "图片附件",
"copyReply": "复制回复",
"copiedReply": "已复制回复",
diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json
index 704ab3c8a..f4596bfdc 100644
--- a/webui/src/i18n/locales/zh-TW/common.json
+++ b/webui/src/i18n/locales/zh-TW/common.json
@@ -81,7 +81,9 @@
"image": "Image",
"web": "Web",
"runtime": "Runtime",
- "advanced": "Advanced"
+ "advanced": "Advanced",
+ "cliApps": "CLI 應用",
+ "mcp": "MCP"
},
"sections": {
"interface": "介面",
@@ -97,7 +99,9 @@
"identity": "Identity",
"safety": "Safety",
"capabilities": "功能",
- "integrations": "Integrations"
+ "integrations": "Integrations",
+ "cliApps": "CLI 應用",
+ "mcp": "MCP"
},
"rows": {
"theme": "主題",
@@ -141,7 +145,11 @@
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
- "configurationDocs": "Configuration docs"
+ "configurationDocs": "Configuration docs",
+ "currentModel": "目前模型",
+ "brandLogos": "品牌標誌",
+ "cliAppsCatalog": "CLI 應用目錄",
+ "cliAppsFilter": "CLI 應用篩選"
},
"help": {
"theme": "在淺色與深色外觀之間切換。",
@@ -168,7 +176,13 @@
"botIcon": "Short emoji or text shown beside the bot name.",
"timezone": "IANA timezone used by runtime context and schedules.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
- "advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
+ "advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.",
+ "currentModel": "選擇 nanobot 接下來回覆時使用的模型。",
+ "selectedModelProvider": "由目前模型決定。",
+ "selectedModelValue": "由目前模型決定。",
+ "brandLogos": "標誌會從品牌網域載入,並提供本地圖示作為備援。",
+ "cliAppsCatalog": "瀏覽 nanobot 可在本機執行的應用 CLI。",
+ "cliAppsFilter": "按應用、分類或能力搜尋。"
},
"values": {
"light": "淺色",
@@ -185,9 +199,7 @@
"on": "On",
"off": "Off",
"configured": "Configured",
- "notConfigured": "Not configured",
- "restartRequired": "Restart required",
- "liveReload": "Live reload ready"
+ "notConfigured": "Not configured"
},
"status": {
"loading": "正在載入設定...",
@@ -259,7 +271,8 @@
},
"providers": {
"searchPlaceholder": "Search providers",
- "noMatches": "No providers match this search."
+ "noMatches": "No providers match this search.",
+ "saveProvider": "儲存服務商"
},
"image": {
"selectProvider": "選擇服務商",
@@ -267,6 +280,106 @@
"selectSize": "選擇尺寸",
"configureProvider": "設定服務商",
"missingCredential": "啟用圖片生成前,請先設定此服務商。"
+ },
+ "models": {
+ "selectModel": "選擇模型",
+ "addConfiguration": "新增設定",
+ "newConfiguration": "新增模型設定",
+ "newConfigurationHelp": "把服務商和模型儲存為一個可直接切換的選項。",
+ "configurationName": "名稱",
+ "configurationNamePlaceholder": "快速寫作"
+ },
+ "timezone": {
+ "select": "選擇時區",
+ "search": "搜尋時區",
+ "empty": "沒有符合的時區。"
+ },
+ "cliApps": {
+ "allCategories": "全部分類",
+ "availableCount": "{{count}} 個應用",
+ "installedCount": "已安裝 {{count}} 個",
+ "summary": "已安裝 {{installed}} / {{total}} 個 CLI",
+ "filterAll": "全部",
+ "filterInstalled": "已安裝的 CLI",
+ "filterNotInstalled": "未安裝",
+ "searchPlaceholder": "搜尋 CLI",
+ "statusInstalled": "已安裝",
+ "statusAvailable": "可用",
+ "statusMissing": "缺少相依項",
+ "statusUnsupported": "不支援",
+ "statusNotInstalled": "未安裝",
+ "unsupported": "不支援",
+ "loading": "正在載入 CLI 應用...",
+ "empty": "沒有符合此篩選條件的 CLI 應用。",
+ "readyTitle": "@{{name}} 已就緒",
+ "readyStatus": "就緒",
+ "readyPrompt": "使用 @{{name}} 查看這個 CLI 能做什麼。",
+ "readyTry": "試用 @{{name}}",
+ "readyCopied": "已複製",
+ "openChat": "開啟聊天",
+ "requires": "需要",
+ "test": "測試 CLI",
+ "update": "更新 CLI",
+ "uninstall": "解除安裝 CLI",
+ "install": "安裝 CLI",
+ "unavailable": "不可用",
+ "noDescription": "暫無描述。"
+ },
+ "mcp": {
+ "allCategories": "全部分類",
+ "summary": "已啟用 {{installed}} / {{total}} 個預設",
+ "filterAll": "全部",
+ "filterInstalled": "已啟用",
+ "filterNotInstalled": "未啟用",
+ "searchPlaceholder": "搜尋 MCP 預設",
+ "moreOptions": "更多 MCP 選項",
+ "moreOptionsSubtitle": "新增自訂服務,或匯入 mcp.json。",
+ "customTitle": "自訂 MCP",
+ "customSubtitle": "新增任意 stdio、HTTP 或 SSE MCP 服務。",
+ "customAction": "自訂",
+ "importAction": "匯入",
+ "serverName": "服務名稱",
+ "serverUrl": "URL",
+ "transport": "傳輸方式",
+ "command": "指令",
+ "args": "Args JSON",
+ "headers": "Headers JSON",
+ "env": "Env JSON",
+ "timeout": "工具逾時",
+ "advancedOptions": "進階選項",
+ "hideAdvanced": "隱藏進階",
+ "saveCustom": "儲存 MCP",
+ "configImport": "匯入 mcp.json",
+ "importConfig": "匯入",
+ "restartRequired": "重新啟動 nanobot 以連接更新後的 MCP 工具。",
+ "toolsFound": "{{count}} 個工具",
+ "loading": "正在載入 MCP 預設...",
+ "empty": "沒有符合此篩選條件的 MCP 預設。",
+ "openDocs": "開啟文件",
+ "test": "測試",
+ "remove": "移除",
+ "enable": "啟用",
+ "enabled": "已啟用",
+ "setup": "連接",
+ "configure": "連接",
+ "connectTitle": "連接 {{name}}",
+ "connectHint": "從你的帳號設定中加入金鑰。",
+ "saveAndEnable": "儲存並啟用",
+ "updateSetup": "更新設定",
+ "configured": "已設定",
+ "keepExisting": "留空以保留目前值",
+ "statusConfigured": "已設定",
+ "statusMissingCredentials": "需要金鑰",
+ "statusMissingDependency": "需要相依項",
+ "statusComingSoon": "即將推出",
+ "statusNotInstalled": "未啟用",
+ "toolScope": "工具",
+ "allTools": "全部",
+ "noTools": "無",
+ "testForTools": "執行測試以檢查並選擇個別工具。"
+ },
+ "legal": {
+ "thirdPartyBrands": "產品名稱、標誌與品牌均屬於其各自擁有者。使用僅為識別用途,並不代表背書。"
}
},
"chat": {
@@ -370,8 +483,7 @@
"title": "編輯圖片",
"prompt": "幫我編輯一張圖片。先請我上傳或指定要編輯的圖片,然後生成編輯後的結果。"
}
- },
- "description": "你可以提問、延續本地工作,或是開始新的執行緒。"
+ }
},
"header": {
"toggleSidebar": "切換側邊欄",
@@ -475,6 +587,16 @@
"decode_failed": "無法解碼這張圖片",
"too_large": "圖片太大,請換一張小一點的",
"io": "無法讀取這個檔案"
+ },
+ "mentions": {
+ "ariaLabel": "應用和 MCP",
+ "label": "插件",
+ "cliGroup": "CLI 應用",
+ "mcpGroup": "MCP 服務",
+ "cliBadge": "CLI",
+ "mcpBadge": "MCP",
+ "cliDescription": "使用 @{{name}} 呼叫本機 CLI",
+ "mcpDescription": "使用 @{{name}} 呼叫 MCP 服務"
}
},
"scrollToBottom": "捲動到底部",
@@ -499,7 +621,18 @@
"imageAttachment": "圖片附件",
"copyReply": "複製回覆",
"copiedReply": "已複製回覆",
- "turnLatencyTitle": "本輪耗時(端到端)"
+ "turnLatencyTitle": "本輪耗時(端到端)",
+ "activityThinkingFor": "思考中,已 {{duration}}",
+ "activityThoughtFor": "已思考 {{duration}}",
+ "cliActivityRunningOne": "正在使用 @{{name}}",
+ "cliActivityRanOne": "已使用 @{{name}}",
+ "cliActivityFailedOne": "@{{name}} 失敗",
+ "cliActivityRunningMany": "正在使用 {{count}} 個 CLI 應用",
+ "cliActivityRanMany": "已使用 {{count}} 個 CLI 應用",
+ "cliActivityFailedMany": "{{count}} 個 CLI 應用失敗",
+ "cliRunRunning": "使用中",
+ "cliRunRan": "已使用",
+ "cliRunFailed": "失敗"
},
"lightbox": {
"title": "圖片預覽",
diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts
index 5f0998cde..ae074d3ae 100644
--- a/webui/src/lib/api.ts
+++ b/webui/src/lib/api.ts
@@ -2,6 +2,8 @@ import type {
ChatSummary,
CliAppsPayload,
ImageGenerationSettingsUpdate,
+ McpPresetsPayload,
+ ModelConfigurationCreate,
ProviderSettingsUpdate,
SettingsPayload,
SettingsUpdate,
@@ -39,6 +41,21 @@ async function request(
return (await res.json()) as T;
}
+function mcpValuesHeader(values: Record): HeadersInit | undefined {
+ const payload: Record = {};
+ Object.entries(values).forEach(([key, value]) => {
+ if (value === null || value === undefined) return;
+ if (typeof value === "string") {
+ const trimmed = value.trim();
+ if (trimmed) payload[key] = trimmed;
+ return;
+ }
+ payload[key] = value;
+ });
+ if (!Object.keys(payload).length) return undefined;
+ return { "X-Nanobot-MCP-Values": JSON.stringify(payload) };
+}
+
function splitKey(key: string): { channel: string; chatId: string } {
const idx = key.indexOf(":");
if (idx === -1) return { channel: "", chatId: key };
@@ -125,6 +142,66 @@ export async function runCliAppAction(
return request(`${base}/api/settings/cli-apps/${action}?${query}`, token);
}
+export async function fetchMcpPresets(
+ token: string,
+ base: string = "",
+): Promise {
+ return request(`${base}/api/settings/mcp-presets`, token);
+}
+
+export async function runMcpPresetAction(
+ token: string,
+ action: "enable" | "remove" | "test",
+ name: string,
+ values: Record = {},
+ base: string = "",
+): Promise {
+ const query = new URLSearchParams();
+ query.set("name", name);
+ return request(
+ `${base}/api/settings/mcp-presets/${action}?${query}`,
+ token,
+ { headers: mcpValuesHeader(values) },
+ );
+}
+
+export async function saveCustomMcpServer(
+ token: string,
+ values: Record,
+ base: string = "",
+): Promise {
+ return request(
+ `${base}/api/settings/mcp-presets/custom`,
+ token,
+ { headers: mcpValuesHeader(values) },
+ );
+}
+
+export async function importMcpConfig(
+ token: string,
+ config: string,
+ base: string = "",
+): Promise {
+ return request(
+ `${base}/api/settings/mcp-presets/import`,
+ token,
+ { headers: mcpValuesHeader({ config }) },
+ );
+}
+
+export async function updateMcpServerTools(
+ token: string,
+ name: string,
+ enabledTools: string[],
+ base: string = "",
+): Promise {
+ return request(
+ `${base}/api/settings/mcp-presets/tools`,
+ token,
+ { headers: mcpValuesHeader({ name, enabled_tools: enabledTools }) },
+ );
+}
+
export async function listSlashCommands(
token: string,
base: string = "",
@@ -188,6 +265,22 @@ export async function updateSettings(
return request(`${base}/api/settings/update?${query}`, token);
}
+export async function createModelConfiguration(
+ token: string,
+ configuration: ModelConfigurationCreate,
+ base: string = "",
+): Promise {
+ const query = new URLSearchParams();
+ if (configuration.name !== undefined) query.set("name", configuration.name);
+ query.set("label", configuration.label);
+ query.set("provider", configuration.provider);
+ query.set("model", configuration.model);
+ return request(
+ `${base}/api/settings/model-configurations/create?${query}`,
+ token,
+ );
+}
+
export async function updateProviderSettings(
token: string,
update: ProviderSettingsUpdate,
diff --git a/webui/src/lib/mcp-preset-events.ts b/webui/src/lib/mcp-preset-events.ts
new file mode 100644
index 000000000..aa597d37d
--- /dev/null
+++ b/webui/src/lib/mcp-preset-events.ts
@@ -0,0 +1,20 @@
+import type { McpPresetInfo, McpPresetsPayload } from "@/lib/types";
+
+export const MCP_PRESETS_CHANGED_EVENT = "nanobot:mcp-presets-changed";
+
+export function isMcpPresetsPayload(value: unknown): value is McpPresetsPayload {
+ return !!value
+ && typeof value === "object"
+ && Array.isArray((value as { presets?: unknown }).presets);
+}
+
+export function installedMcpPresetsFromPayload(payload: McpPresetsPayload): McpPresetInfo[] {
+ return payload.presets.filter((preset) => preset.installed && preset.configured);
+}
+
+export function notifyMcpPresetsChanged(payload: McpPresetsPayload): void {
+ if (typeof window === "undefined") return;
+ window.dispatchEvent(new CustomEvent(MCP_PRESETS_CHANGED_EVENT, {
+ detail: payload,
+ }));
+}
diff --git a/webui/src/lib/nanobot-client.ts b/webui/src/lib/nanobot-client.ts
index b4d757890..ee225422d 100644
--- a/webui/src/lib/nanobot-client.ts
+++ b/webui/src/lib/nanobot-client.ts
@@ -4,6 +4,7 @@ import type {
Outbound,
OutboundCliAppMention,
OutboundImageGeneration,
+ OutboundMcpPresetMention,
OutboundMedia,
GoalStateWsPayload,
} from "./types";
@@ -305,7 +306,11 @@ export class NanobotClient {
chatId: string,
content: string,
media?: OutboundMedia[],
- options?: { imageGeneration?: OutboundImageGeneration; cliApps?: OutboundCliAppMention[] },
+ options?: {
+ imageGeneration?: OutboundImageGeneration;
+ cliApps?: OutboundCliAppMention[];
+ mcpPresets?: OutboundMcpPresetMention[];
+ },
): void {
this.knownChats.add(chatId);
const frame: Outbound = {
@@ -315,6 +320,7 @@ export class NanobotClient {
...(media && media.length > 0 ? { media } : {}),
...(options?.imageGeneration ? { image_generation: options.imageGeneration } : {}),
...(options?.cliApps?.length ? { cli_apps: options.cliApps } : {}),
+ ...(options?.mcpPresets?.length ? { mcp_presets: options.mcpPresets } : {}),
webui: true,
};
this.queueSend(frame);
diff --git a/webui/src/lib/provider-brand.ts b/webui/src/lib/provider-brand.ts
new file mode 100644
index 000000000..2dbc84776
--- /dev/null
+++ b/webui/src/lib/provider-brand.ts
@@ -0,0 +1,188 @@
+export interface ProviderBrand {
+ logoUrl: string;
+ logoUrls: string[];
+ color: string;
+ initials: string;
+}
+
+function officialFaviconUrl(domain: string): string {
+ return `https://${domain}/favicon.ico`;
+}
+
+function duckDuckGoFaviconUrl(domain: string): string {
+ return `https://icons.duckduckgo.com/ip3/${encodeURIComponent(domain)}.ico`;
+}
+
+function googleFaviconUrl(domain: string): string {
+ return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=64`;
+}
+
+export function faviconUrls(domain: string): string[] {
+ const faviconDomain = faviconDomainFromValue(domain);
+ return [
+ officialFaviconUrl(faviconDomain),
+ duckDuckGoFaviconUrl(faviconDomain),
+ googleFaviconUrl(domain),
+ ];
+}
+
+function brand(
+ domain: string,
+ color: string,
+ initials: string,
+ logoOverrides: string[] = [],
+): ProviderBrand {
+ const logoUrls = [...logoOverrides];
+ faviconUrls(domain).forEach((url) => addUniqueLogoUrl(logoUrls, url));
+ return {
+ logoUrl: logoUrls[0],
+ logoUrls,
+ color,
+ initials,
+ };
+}
+
+function addUniqueLogoUrl(urls: string[], url: string | null | undefined): void {
+ const value = url?.trim();
+ if (value && !urls.includes(value)) urls.push(value);
+}
+
+function domainFromLogoUrl(url: string): string | null {
+ if (url.startsWith("/")) return null;
+ try {
+ const parsed = new URL(url);
+ if (!/^https?:$/.test(parsed.protocol)) return null;
+ const host = parsed.hostname.toLowerCase();
+ if (host === "www.google.com" || host === "google.com") {
+ return parsed.searchParams.get("domain");
+ }
+ if (host === "icons.duckduckgo.com") {
+ const match = parsed.pathname.match(/^\/ip3\/(.+)\.ico$/);
+ return match ? decodeURIComponent(match[1]) : null;
+ }
+ return host.replace(/^www\./, "");
+ } catch {
+ return null;
+ }
+}
+
+function faviconDomainFromValue(value: string): string {
+ const host = value.split("/")[0]?.trim();
+ return host || value;
+}
+
+export function logoFallbackUrls(logoUrl: string | null | undefined): string[] {
+ const value = logoUrl?.trim();
+ if (!value) return [];
+ if (value.startsWith("/")) return [value];
+
+ const urls: string[] = [];
+ const domain = domainFromLogoUrl(value);
+ const isFaviconProxy = /^(https?:\/\/)?(www\.google\.com|google\.com|icons\.duckduckgo\.com)\//i.test(value);
+ if (domain && isFaviconProxy) {
+ addUniqueLogoUrl(urls, value);
+ faviconUrls(domain).forEach((url) => addUniqueLogoUrl(urls, url));
+ return urls;
+ }
+ addUniqueLogoUrl(urls, value);
+ if (domain) faviconUrls(domain).forEach((url) => addUniqueLogoUrl(urls, url));
+ return urls;
+}
+
+export const PROVIDER_BRAND_ALIASES: Record = {
+ brave_search: "brave",
+ byteplus_coding_plan: "byteplus",
+ minimaxAnthropic: "minimax",
+ minimax_anthropic: "minimax",
+ openai_codex: "openai",
+ volcengine_coding_plan: "volcengine",
+};
+
+export const PROVIDER_LABEL_ALIASES: Record = {
+ brave_search: "Brave Search",
+ byteplus_coding_plan: "BytePlus",
+ minimaxAnthropic: "MiniMax",
+ minimax_anthropic: "MiniMax",
+ openai_codex: "OpenAI",
+ volcengine_coding_plan: "Volcengine",
+};
+
+const PROVIDER_BRANDS: Record = {
+ aihubmix: brand("aihubmix.com", "#111827", "AH"),
+ ant_ling: brand("ant-ling.com", "#7C3AED", "AL"),
+ anthropic: brand("anthropic.com", "#D97757", "A"),
+ atomic_chat: brand("atomic.chat", "#111827", "AC"),
+ azure_openai: brand("azure.microsoft.com", "#0078D4", "AZ"),
+ bedrock: brand("aws.amazon.com", "#FF9900", "AWS"),
+ brave: brand("brave.com", "#FB542B", "B"),
+ byteplus: brand("byteplus.com", "#325CFF", "BP"),
+ dashscope: brand("dashscope.aliyun.com", "#FF6A00", "DS"),
+ deepseek: brand("deepseek.com", "#4D6BFE", "DS"),
+ duckduckgo: brand("duckduckgo.com", "#DE5833", "DDG"),
+ exa: brand("exa.ai", "#5B5BF6", "E"),
+ gemini: brand("gemini.google.com", "#4285F4", "G"),
+ github_copilot: brand("github.com", "#24292F", "GH"),
+ groq: brand("groq.com", "#F55036", "GQ"),
+ huggingface: brand("huggingface.co", "#FF9D00", "HF"),
+ jina: brand("jina.ai", "#7C3AED", "J"),
+ kagi: brand("kagi.com", "#FFB319", "K"),
+ lm_studio: brand("lmstudio.ai", "#111827", "LM"),
+ longcat: brand("longcat.chat", "#111827", "LC"),
+ minimax: brand("minimax.io", "#111827", "MM"),
+ mistral: brand("mistral.ai", "#FA520F", "M"),
+ moonshot: brand("moonshot.ai", "#111827", "MS"),
+ novita: brand("novita.ai", "#7C3AED", "N"),
+ olostep: brand("olostep.com", "#111827", "O"),
+ nvidia: brand("nvidia.com", "#76B900", "NV"),
+ ollama: brand("ollama.com", "#111827", "O"),
+ openai: brand("openai.com", "#111827", "AI"),
+ openrouter: brand("openrouter.ai", "#111827", "OR"),
+ ovms: brand("openvino.ai", "#0071C5", "OV"),
+ qianfan: brand("cloud.baidu.com", "#2932E1", "QF"),
+ searxng: brand("searxng.org", "#3050FF", "SX"),
+ siliconflow: brand("siliconflow.cn", "#111827", "SF"),
+ skywork: brand("skywork.ai", "#5B5BF6", "SW"),
+ stepfun: brand("stepfun.com", "#2F6BFF", "SF"),
+ tavily: brand("tavily.com", "#111827", "T"),
+ volcengine: brand("volcengine.com", "#1664FF", "VE"),
+ vllm: brand("vllm.ai", "#2563EB", "VL"),
+ xiaomi_mimo: brand("xiaomimimo.com", "#FF6900", "MI"),
+ zhipu: brand("z.ai", "#155EEF", "Z", [
+ "https://z-cdn.chatglm.cn/z-ai/static/logo.svg",
+ "https://www.google.com/s2/favicons?domain=z.ai&sz=64",
+ ]),
+};
+
+export function providerBrand(provider: string | null | undefined): ProviderBrand | null {
+ if (!provider) return null;
+ const key = PROVIDER_BRAND_ALIASES[provider] ?? provider;
+ return PROVIDER_BRANDS[key] ?? null;
+}
+
+export function providerDisplayLabel(
+ providers: Array<{ name: string; label: string }>,
+ value: string | null | undefined,
+): string {
+ if (!value) return "";
+ return providers.find((provider) => provider.name === value)?.label
+ ?? PROVIDER_LABEL_ALIASES[value]
+ ?? value;
+}
+
+export function inferProviderFromModelName(modelName: string | null | undefined): string | null {
+ const normalized = (modelName ?? "").trim().toLowerCase();
+ if (!normalized) return null;
+ const prefix = normalized.split(/[/:]/)[0];
+ if (providerBrand(prefix)) return prefix;
+ if (/claude|anthropic/.test(normalized)) return "anthropic";
+ if (/gpt-|^o\d|chatgpt|openai/.test(normalized)) return "openai";
+ if (/deepseek/.test(normalized)) return "deepseek";
+ if (/gemini/.test(normalized)) return "gemini";
+ if (/qwen|dashscope/.test(normalized)) return "dashscope";
+ if (/kimi|moonshot/.test(normalized)) return "moonshot";
+ if (/minimax/.test(normalized)) return "minimax";
+ if (/mistral|mixtral/.test(normalized)) return "mistral";
+ if (/skywork|skyclaw/.test(normalized)) return "skywork";
+ if (/ring-/.test(normalized)) return "ant_ling";
+ return null;
+}
diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts
index 05945720f..6e4aff141 100644
--- a/webui/src/lib/types.ts
+++ b/webui/src/lib/types.ts
@@ -53,6 +53,8 @@ export interface UIMessage {
media?: UIMediaAttachment[];
/** App-specific CLI adapters explicitly attached to this user turn. */
cliApps?: UICliAppAttachment[];
+ /** Settings-managed MCP presets explicitly attached to this user turn. */
+ mcpPresets?: UIMcpPresetAttachment[];
/** Assistant turn: accumulated model reasoning / thinking text. Built up
* incrementally from ``reasoning_delta`` frames; finalized when
* ``reasoning_end`` arrives. */
@@ -73,6 +75,17 @@ export interface UICliAppAttachment {
brand_color?: string | null;
}
+export interface UIMcpPresetAttachment {
+ name: string;
+ display_name?: string;
+ category?: string;
+ transport?: string;
+ status?: string;
+ configured?: boolean;
+ logo_url?: string | null;
+ brand_color?: string | null;
+}
+
/** Structured UI blob on ``progress`` WS frames; channels may add more ``kind`` values later. */
export interface AgentUIBlob {
kind: string;
@@ -294,6 +307,69 @@ export interface CliAppsPayload {
};
}
+export interface McpPresetField {
+ name: string;
+ label: string;
+ secret: boolean;
+ required: boolean;
+ configured: boolean;
+ placeholder?: string;
+ env_var?: string | null;
+}
+
+export interface McpPresetInfo {
+ name: string;
+ display_name: string;
+ category: string;
+ description: string;
+ docs_url: string;
+ transport: "stdio" | "streamableHttp" | "sse" | "oauth" | string;
+ requires: string;
+ note: string;
+ install_supported: boolean;
+ installed: boolean;
+ configured: boolean;
+ available: boolean;
+ status: "not_installed" | "configured" | "missing_credentials" | "missing_dependency" | "coming_soon" | string;
+ logo_url?: string | null;
+ brand_color?: string | null;
+ required_fields: McpPresetField[];
+ connection_summary: string;
+ tool_count?: number;
+ tool_names?: string[];
+ checked_at?: string | null;
+ error?: string | null;
+ enabled_tools?: string[];
+ source?: "preset" | "custom" | string;
+}
+
+export interface McpPresetsPayload {
+ presets: McpPresetInfo[];
+ installed_count: number;
+ requires_restart?: boolean;
+ hot_reload?: {
+ ok: boolean;
+ message: string;
+ added?: string[];
+ changed?: string[];
+ removed?: string[];
+ retried?: string[];
+ connected?: string[];
+ configured?: string[];
+ failed?: string[];
+ tools_removed?: number;
+ requires_restart?: boolean;
+ };
+ last_action?: {
+ ok: boolean;
+ message: string;
+ tool_count?: number;
+ tool_names?: string[];
+ checked_at?: string | null;
+ error?: string | null;
+ };
+}
+
export interface SettingsUpdate {
model?: string;
provider?: string;
@@ -304,6 +380,13 @@ export interface SettingsUpdate {
toolHintMaxLength?: number;
}
+export interface ModelConfigurationCreate {
+ name?: string;
+ label: string;
+ provider: string;
+ model: string;
+}
+
export interface ProviderSettingsUpdate {
provider: string;
apiKey?: string;
@@ -446,6 +529,17 @@ export interface OutboundCliAppMention {
brand_color?: string | null;
}
+export interface OutboundMcpPresetMention {
+ name: string;
+ display_name?: string;
+ category?: string;
+ transport?: string;
+ status?: string;
+ configured?: boolean;
+ logo_url?: string | null;
+ brand_color?: string | null;
+}
+
/** Response shape for ``GET .../webui-thread`` (server-built transcript replay). */
export interface WebuiThreadPersistedPayload {
schemaVersion: number;
@@ -464,6 +558,7 @@ export type Outbound =
media?: OutboundMedia[];
image_generation?: OutboundImageGeneration;
cli_apps?: OutboundCliAppMention[];
+ mcp_presets?: OutboundMcpPresetMention[];
/** Marks messages sent by the embedded WebUI, without changing the
* generic websocket protocol for other clients. */
webui?: true;
diff --git a/webui/src/tests/agent-activity-cluster.test.tsx b/webui/src/tests/agent-activity-cluster.test.tsx
index 58989a836..a07c8dd3b 100644
--- a/webui/src/tests/agent-activity-cluster.test.tsx
+++ b/webui/src/tests/agent-activity-cluster.test.tsx
@@ -2,7 +2,7 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { describe, expect, it } from "vitest";
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
-import type { CliAppInfo, UIMessage } from "@/lib/types";
+import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
const BLENDER_CLI_APP: CliAppInfo = {
name: "blender",
@@ -21,6 +21,26 @@ const BLENDER_CLI_APP: CliAppInfo = {
skill_installed: true,
};
+const BROWSERBASE_MCP: McpPresetInfo = {
+ name: "browserbase",
+ display_name: "Browserbase",
+ category: "browser",
+ description: "Cloud browser automation",
+ docs_url: "https://docs.browserbase.com",
+ transport: "streamableHttp",
+ requires: "Browserbase API key",
+ note: "",
+ install_supported: true,
+ installed: true,
+ configured: true,
+ available: true,
+ status: "configured",
+ logo_url: "https://example.invalid/browserbase.svg",
+ brand_color: "#111827",
+ required_fields: [],
+ connection_summary: "https://mcp.browserbase.com/mcp",
+};
+
function activityMessages(extraReasoning = "", extraTool?: UIMessage): UIMessage[] {
const rows: UIMessage[] = [
{
@@ -120,7 +140,6 @@ describe("AgentActivityCluster", () => {
/>,
);
- fireEvent.click(screen.getByRole("button", { name: /working/i }));
const scrollport = screen.getByTestId("agent-activity-scroll");
setScrollGeometry(scrollport, {
scrollHeight: 1000,
@@ -149,7 +168,6 @@ describe("AgentActivityCluster", () => {
/>,
);
- fireEvent.click(screen.getByRole("button", { name: /working/i }));
const scrollport = screen.getByTestId("agent-activity-scroll");
setScrollGeometry(scrollport, {
scrollHeight: 1000,
@@ -201,7 +219,6 @@ describe("AgentActivityCluster", () => {
/>,
);
- fireEvent.click(screen.getByRole("button", { name: /working/i }));
const scrollport = screen.getByTestId("agent-activity-scroll");
setScrollGeometry(scrollport, {
scrollHeight: 1000,
@@ -238,6 +255,44 @@ describe("AgentActivityCluster", () => {
}
});
+ it("turns the live reasoning marker into an animated check when thinking completes", async () => {
+ const liveReasoning: UIMessage = {
+ id: "r-check",
+ role: "assistant",
+ content: "",
+ reasoning: "checking a source",
+ reasoningStreaming: true,
+ isStreaming: true,
+ createdAt: 1,
+ };
+ const { rerender } = render(
+ ,
+ );
+
+ expect(screen.getByTestId("activity-reasoning-marker")).toHaveAttribute("data-state", "thinking");
+
+ rerender(
+ ,
+ );
+
+ const marker = screen.getByTestId("activity-reasoning-marker");
+ expect(marker).toHaveAttribute("data-state", "done");
+ expect(marker.querySelector("svg")).toBeInTheDocument();
+ await waitFor(() => expect(marker).toHaveClass("animate-in"));
+ });
+
it("renders file edit totals and a compact expanded file list", async () => {
const restoreMotion = installReducedMotion();
try {
@@ -306,16 +361,68 @@ describe("AgentActivityCluster", () => {
/>,
);
- fireEvent.click(screen.getByRole("button", { name: /running cli @blender/i }));
-
const cliRuns = screen.getByTestId("activity-cli-runs");
- expect(cliRuns).toHaveTextContent("Running CLI");
+ expect(cliRuns).toHaveTextContent("Using");
expect(cliRuns).toHaveTextContent("@blender");
expect(cliRuns).toHaveTextContent("--json --background scene.blend");
expect(screen.getByTestId("activity-cli-logo-blender")).toBeInTheDocument();
expect(screen.queryByText(/run_cli_app/)).not.toBeInTheDocument();
});
+ it("keeps CLI rows in chronological trace order", () => {
+ const cliArgs = { name: "blender", args: ["project", "new"], json: true };
+ const cliLine = `run_cli_app(${JSON.stringify(cliArgs)})`;
+ render(
+ ,
+ );
+
+ const searchRow = screen.getByText("Searching").closest("li");
+ const cliRow = screen.getByText("@blender").closest("li");
+ const fetchRow = screen.getByText("Reading").closest("li");
+
+ expect(searchRow).not.toBeNull();
+ expect(cliRow).not.toBeNull();
+ expect(fetchRow).not.toBeNull();
+ expect(searchRow!.compareDocumentPosition(cliRow!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ expect(cliRow!.compareDocumentPosition(fetchRow!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ });
+
it("labels rejected CLI app calls as failed instead of ran", () => {
render(
{
/>,
);
- fireEvent.click(screen.getByRole("button", { name: /cli failed @github/i }));
+ fireEvent.click(screen.getByRole("button", { name: /failed @github/i }));
- expect(screen.getByTestId("activity-cli-runs")).toHaveTextContent("CLI failed");
+ expect(screen.getByTestId("activity-cli-runs")).toHaveTextContent("Failed");
expect(screen.getByTestId("activity-cli-runs")).toHaveTextContent("@github");
expect(screen.getByTestId("activity-cli-runs")).toHaveTextContent("Error: CLI app 'github' not found");
expect(screen.queryByText("Ran CLI")).not.toBeInTheDocument();
});
+ it("renders MCP preset tool calls as branded activity rows", () => {
+ render(
+ ,
+ );
+
+ const mcpRuns = screen.getByTestId("activity-mcp-runs");
+ expect(mcpRuns).toHaveTextContent("Using");
+ expect(mcpRuns).toHaveTextContent("Browserbase");
+ expect(mcpRuns).toHaveTextContent("browser_navigate");
+ expect(mcpRuns).toHaveTextContent("url: https://example.com");
+ expect(screen.getByTestId("activity-mcp-logo-browserbase")).toBeInTheDocument();
+ expect(screen.queryByText(/mcp_browserbase_browser_navigate/)).not.toBeInTheDocument();
+ });
+
+ it("renders public web fetch traces with the site favicon", () => {
+ render(
+ ,
+ );
+
+ const favicon = screen.getByTestId("activity-web-favicon-auth0.com");
+ expect(favicon.querySelector("img")?.getAttribute("src")).toContain("auth0.com");
+ expect(screen.getByText("Reading")).toBeInTheDocument();
+ expect(screen.getByText("auth0.com/blog/jwt-security-best-practices")).toBeInTheDocument();
+ });
+
+ it("renders plain-text fetch progress with the site favicon", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId("activity-web-favicon-auth0.com")).toBeInTheDocument();
+ expect(screen.getByText("Reading")).toBeInTheDocument();
+ expect(screen.getByText("auth0.com/blog/jwt-security-best-practices")).toBeInTheDocument();
+ });
+
+ it("does not request favicons for private web fetch targets", () => {
+ render(
+ ,
+ );
+
+ expect(screen.queryByTestId("activity-web-favicon-localhost")).not.toBeInTheDocument();
+ expect(screen.getByText("url: http://localhost:3000/dashboard")).toBeInTheDocument();
+ });
+
+ it("summarizes long shell traces instead of dumping scripts", () => {
+ const command = [
+ "cat << 'EOF' | bash",
+ "SECRET_TOKEN=sk-test",
+ "for id in m1 m2 m3; do",
+ " echo done $id",
+ "done",
+ "EOF",
+ ].join("\n");
+ const line = `exec(${JSON.stringify({ command })})`;
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Shell")).toBeInTheDocument();
+ expect(screen.getByText(/cat << 'EOF' \| bash · script, 6 lines/)).toBeInTheDocument();
+ expect(screen.queryByText(/SECRET_TOKEN/)).not.toBeInTheDocument();
+ expect(screen.queryByText(/for id in/)).not.toBeInTheDocument();
+ expect(screen.queryByText(/^Done$/)).not.toBeInTheDocument();
+ });
+
it("does not render zero diff counters for completed edits", () => {
render(
{
);
expect(screen.getByRole("button", { name: /preparing edit/i })).toBeInTheDocument();
- fireEvent.click(screen.getByRole("button", { name: /preparing edit/i }));
expect(screen.getByText("Preparing file edit…")).toBeInTheDocument();
});
diff --git a/webui/src/tests/api.test.ts b/webui/src/tests/api.test.ts
index 06ce68e9f..9b369418c 100644
--- a/webui/src/tests/api.test.ts
+++ b/webui/src/tests/api.test.ts
@@ -1,15 +1,21 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
+ createModelConfiguration,
deleteSession,
fetchCliApps,
+ fetchMcpPresets,
fetchSidebarState,
fetchWebuiThread,
+ importMcpConfig,
listSessions,
listSlashCommands,
runCliAppAction,
+ runMcpPresetAction,
+ saveCustomMcpServer,
updateSidebarState,
updateImageGenerationSettings,
+ updateMcpServerTools,
updateProviderSettings,
updateSettings,
updateWebSearchSettings,
@@ -68,6 +74,21 @@ describe("webui API helpers", () => {
);
});
+ it("serializes model configuration creation", async () => {
+ await createModelConfiguration("tok", {
+ label: "Fast writing",
+ provider: "openai",
+ model: "openai/gpt-4.1-mini",
+ });
+
+ expect(fetch).toHaveBeenCalledWith(
+ "/api/settings/model-configurations/create?label=Fast+writing&provider=openai&model=openai%2Fgpt-4.1-mini",
+ expect.objectContaining({
+ headers: { Authorization: "Bearer tok" },
+ }),
+ );
+ });
+
it("serializes provider settings updates without returning secrets", async () => {
await updateProviderSettings("tok", {
provider: "openrouter",
@@ -145,6 +166,91 @@ describe("webui API helpers", () => {
);
});
+ it("reads MCP presets and serializes actions", async () => {
+ vi.mocked(fetch).mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ presets: [],
+ installed_count: 0,
+ }),
+ } as Response);
+
+ await expect(fetchMcpPresets("tok")).resolves.toMatchObject({ presets: [] });
+ expect(fetch).toHaveBeenCalledWith(
+ "/api/settings/mcp-presets",
+ expect.objectContaining({
+ headers: { Authorization: "Bearer tok" },
+ }),
+ );
+
+ await runMcpPresetAction("tok", "enable", "browserbase", {
+ browserbase_api_key: "bb_live_test",
+ });
+ expect(fetch).toHaveBeenCalledWith(
+ "/api/settings/mcp-presets/enable?name=browserbase",
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ Authorization: "Bearer tok",
+ "X-Nanobot-MCP-Values": JSON.stringify({
+ browserbase_api_key: "bb_live_test",
+ }),
+ }),
+ }),
+ );
+ });
+
+ it("serializes custom MCP, mcp.json import, and tool allowlist actions", async () => {
+ await saveCustomMcpServer("tok", {
+ name: "docs",
+ transport: "stdio",
+ command: "npx",
+ args: '["-y","docs-mcp"]',
+ env: '{"API_KEY":"secret"}',
+ });
+ expect(fetch).toHaveBeenCalledWith(
+ "/api/settings/mcp-presets/custom",
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ Authorization: "Bearer tok",
+ "X-Nanobot-MCP-Values": JSON.stringify({
+ name: "docs",
+ transport: "stdio",
+ command: "npx",
+ args: '["-y","docs-mcp"]',
+ env: '{"API_KEY":"secret"}',
+ }),
+ }),
+ }),
+ );
+
+ await importMcpConfig("tok", '{"mcpServers":{"docs":{"command":"npx"}}}');
+ expect(fetch).toHaveBeenCalledWith(
+ "/api/settings/mcp-presets/import",
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ Authorization: "Bearer tok",
+ "X-Nanobot-MCP-Values": JSON.stringify({
+ config: '{"mcpServers":{"docs":{"command":"npx"}}}',
+ }),
+ }),
+ }),
+ );
+
+ await updateMcpServerTools("tok", "docs", ["search", "fetch"]);
+ expect(fetch).toHaveBeenCalledWith(
+ "/api/settings/mcp-presets/tools",
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ Authorization: "Bearer tok",
+ "X-Nanobot-MCP-Values": JSON.stringify({
+ name: "docs",
+ enabled_tools: ["search", "fetch"],
+ }),
+ }),
+ }),
+ );
+ });
+
it("reads and writes persisted sidebar state", async () => {
const state = {
schema_version: 1,
diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx
index 4d84c7dee..35d3b6401 100644
--- a/webui/src/tests/app-layout.test.tsx
+++ b/webui/src/tests/app-layout.test.tsx
@@ -713,6 +713,12 @@ describe("App layout", () => {
expect(await screen.findByRole("heading", { name: "Overview" })).toBeInTheDocument();
expect(document.title).toBe("Settings · nanobot");
+ expect(screen.getByTestId("overview-nanobot-logo")).toBeInTheDocument();
+ expect(screen.getByTestId("overview-logo-openai")).toBeInTheDocument();
+ expect(screen.getByTestId("overview-logo-brave")).toBeInTheDocument();
+ expect(screen.getByTestId("overview-logo-openrouter")).toBeInTheDocument();
+ expect(screen.queryByTestId("overview-logo-nanobot-gateway")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("overview-logo-nanobot-workspace")).not.toBeInTheDocument();
expect(screen.queryByRole("navigation", { name: "Sidebar navigation" })).not.toBeInTheDocument();
const settingsNav = screen.getByRole("navigation", { name: "Settings sections" });
expect(settingsNav.className).toContain("overflow-x-auto");
@@ -722,23 +728,41 @@ describe("App layout", () => {
"page",
);
expect(within(settingsNav).getByRole("button", { name: "Models" })).toBeInTheDocument();
- expect(within(settingsNav).getByRole("button", { name: "Providers" })).toBeInTheDocument();
+ expect(within(settingsNav).queryByRole("button", { name: "Providers" })).not.toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Image" })).toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Web" })).toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Advanced" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Sign out" })).toBeInTheDocument();
fireEvent.click(within(settingsNav).getByRole("button", { name: "Appearance" }));
expect(screen.getByText("Brand logos")).toBeInTheDocument();
+ expect(screen.getByRole("switch", { name: "Brand logos" })).toBeInTheDocument();
fireEvent.click(within(settingsNav).getByRole("button", { name: "Models" }));
- expect(screen.getByText("AI")).toBeInTheDocument();
+ expect(screen.queryByText("AI")).not.toBeInTheDocument();
+ expect(screen.getByText("Current model")).toBeInTheDocument();
+ expect(screen.queryByText("Presets")).not.toBeInTheDocument();
+ fireEvent.pointerDown(screen.getByRole("button", { name: /openai\/gpt-4o/ }));
+ fireEvent.click(screen.getByRole("menuitem", { name: "Add configuration" }));
+ const modelDialog = screen.getByRole("dialog", { name: "New model configuration" });
+ expect(within(modelDialog).getByText("Save a provider and model as a one-click option.")).toBeInTheDocument();
+ fireEvent.change(within(modelDialog).getByPlaceholderText("Fast writing"), {
+ target: { value: "Fast writing" },
+ });
+ fireEvent.change(within(modelDialog).getByPlaceholderText("openai/gpt-4.1"), {
+ target: { value: "openai/gpt-4.1-mini" },
+ });
+ expect(within(modelDialog).getByRole("button", { name: /OpenAI/ })).toBeInTheDocument();
+ expect(within(modelDialog).getByRole("button", { name: "Save" })).toBeEnabled();
+ fireEvent.click(within(modelDialog).getByRole("button", { name: "Cancel" }));
const modelInput = screen.getByDisplayValue("openai/gpt-4o");
expect(modelInput).toBeInTheDocument();
+ fireEvent.pointerDown(screen.getByRole("button", { name: /Auto/ }));
+ expect(screen.getAllByTestId("provider-picker-logo-openai").length).toBeGreaterThan(0);
+ fireEvent.click(screen.getByRole("menuitem", { name: /Auto/ }));
fireEvent.change(modelInput, { target: { value: "openai/gpt-4o-mini" } });
expect(screen.getByText("Unsaved changes.").parentElement?.className).toContain(
"text-blue-600",
);
fireEvent.change(modelInput, { target: { value: "openai/gpt-4o" } });
- fireEvent.click(within(settingsNav).getByRole("button", { name: "Providers" }));
expect(screen.getByText("OpenRouter")).toBeInTheDocument();
expect(screen.getByText("Ant Ling")).toBeInTheDocument();
expect(screen.getByTestId("provider-logo-openai")).toBeInTheDocument();
@@ -757,10 +781,11 @@ describe("App layout", () => {
expect(screen.getByDisplayValue("https://api.ant-ling.com/v1")).toBeInTheDocument();
fireEvent.click(screen.getByText("Atomic Chat"));
expect(screen.getByDisplayValue("http://localhost:1337/v1")).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
+ expect(screen.getByRole("button", { name: "Save provider" })).toBeEnabled();
fireEvent.click(within(settingsNav).getByRole("button", { name: "Image" }));
expect(screen.getByRole("heading", { name: "Image" })).toBeInTheDocument();
+ expect(screen.getByRole("switch", { name: "Image generation" })).toBeInTheDocument();
expect(screen.getByText("Provider status")).toBeInTheDocument();
expect(screen.getByDisplayValue("openai/gpt-5.4-image-2")).toBeInTheDocument();
expect(screen.getByText("Save directory")).toBeInTheDocument();
@@ -768,7 +793,9 @@ describe("App layout", () => {
fireEvent.click(within(settingsNav).getByRole("button", { name: "Web" }));
expect(screen.getByText("Search provider")).toBeInTheDocument();
+ expect(screen.getByRole("switch", { name: "Jina reader" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Brave Search/ })).toBeInTheDocument();
+ expect(screen.getByTestId("provider-picker-logo-brave")).toBeInTheDocument();
expect(screen.getByText("BSAo••••ew20")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Edit" }));
fireEvent.change(screen.getByPlaceholderText("Leave blank to keep the current key"), {
@@ -783,7 +810,16 @@ describe("App layout", () => {
fireEvent.click(within(settingsNav).getByRole("button", { name: "Runtime" }));
expect(screen.getByText("Bot name")).toBeInTheDocument();
+ expect(screen.queryByText("Tool hint length")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
+ fireEvent.pointerDown(screen.getByRole("button", { name: "UTC" }));
+ expect(screen.getByPlaceholderText("Search timezone")).toBeInTheDocument();
+ fireEvent.change(screen.getByPlaceholderText("Search timezone"), {
+ target: { value: "Shanghai" },
+ });
+ fireEvent.click(screen.getByRole("menuitem", { name: /Asia\/Shanghai/ }));
+ expect(screen.getByRole("button", { name: "Asia/Shanghai" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
});
it("returns from settings to the blank start page when no session was active", async () => {
@@ -969,6 +1005,7 @@ describe("App layout", () => {
expect(dialog).toHaveClass("origin-center");
expect(dialog.className).not.toContain("translate-x");
expect(dialog.className).not.toContain("translate-y");
+ expect(dialog.querySelector("kbd")).toBeNull();
expect(within(dialog).getByText("Q2 roadmap")).toBeInTheDocument();
expect(within(dialog).getByText("Travel ideas")).toBeInTheDocument();
expect(within(dialog).queryByText("websocket")).not.toBeInTheDocument();
diff --git a/webui/src/tests/i18n.test.tsx b/webui/src/tests/i18n.test.tsx
index e92c42500..07f7d195b 100644
--- a/webui/src/tests/i18n.test.tsx
+++ b/webui/src/tests/i18n.test.tsx
@@ -12,7 +12,6 @@ const SETTINGS_NAV_KEYS = [
"overview",
"appearance",
"models",
- "providers",
"image",
"web",
"runtime",
diff --git a/webui/src/tests/message-bubble.test.tsx b/webui/src/tests/message-bubble.test.tsx
index 217d155f3..f110b58f3 100644
--- a/webui/src/tests/message-bubble.test.tsx
+++ b/webui/src/tests/message-bubble.test.tsx
@@ -2,7 +2,7 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest";
import { MessageBubble } from "@/components/MessageBubble";
-import type { CliAppInfo, UIMessage } from "@/lib/types";
+import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
const CLI_APPS: CliAppInfo[] = [
{
@@ -39,6 +39,28 @@ const CLI_APPS: CliAppInfo[] = [
},
];
+const MCP_PRESETS: McpPresetInfo[] = [
+ {
+ name: "browserbase",
+ display_name: "Browserbase",
+ category: "browser",
+ description: "Cloud browser automation",
+ docs_url: "https://docs.browserbase.com",
+ transport: "streamableHttp",
+ requires: "Browserbase API key",
+ note: "",
+ install_supported: true,
+ installed: true,
+ configured: true,
+ available: true,
+ status: "configured",
+ logo_url: "https://example.invalid/browserbase.svg",
+ brand_color: "#111827",
+ required_fields: [],
+ connection_summary: "https://mcp.browserbase.com/mcp",
+ },
+];
+
describe("MessageBubble", () => {
it("renders user messages as right-aligned pills", () => {
const message: UIMessage = {
@@ -69,6 +91,7 @@ describe("MessageBubble", () => {
const token = screen.getByTestId("message-cli-mention-zoom");
expect(token).toHaveTextContent("@zoom");
+ expect(token).toHaveAttribute("title", "CLI app: Zoom");
expect(token.className).not.toContain("rounded");
expect(token.className).not.toContain("px-");
expect(token.getAttribute("style")).toContain("color: #0B5CFF");
@@ -104,6 +127,23 @@ describe("MessageBubble", () => {
expect(screen.getByTestId("message-cli-mention-logo-drawio")).toBeInTheDocument();
});
+ it("renders MCP preset mentions inside sent user messages", () => {
+ const message: UIMessage = {
+ id: "u-mcp",
+ role: "user",
+ content: "Use @browserbase to inspect the checkout flow",
+ createdAt: Date.now(),
+ };
+
+ render();
+
+ const token = screen.getByTestId("message-mcp-mention-browserbase");
+ expect(token).toHaveTextContent("@browserbase");
+ expect(token).toHaveAttribute("title", "MCP server: Browserbase");
+ expect(token.getAttribute("style")).toContain("color: #111827");
+ expect(screen.getByTestId("message-mcp-mention-logo-browserbase")).toBeInTheDocument();
+ });
+
it("copies completed assistant replies from the action row", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
diff --git a/webui/src/tests/nanobot-client.test.ts b/webui/src/tests/nanobot-client.test.ts
index 131da073b..f51dcf575 100644
--- a/webui/src/tests/nanobot-client.test.ts
+++ b/webui/src/tests/nanobot-client.test.ts
@@ -375,6 +375,53 @@ describe("NanobotClient", () => {
);
});
+ it("includes MCP preset attachments in outbound messages", () => {
+ const client = new NanobotClient({
+ url: "ws://test",
+ reconnect: false,
+ socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
+ });
+ client.connect();
+ lastSocket().fakeOpen();
+
+ client.sendMessage(
+ "chat-mcp",
+ "@browserbase check this page",
+ undefined,
+ {
+ mcpPresets: [{
+ name: "browserbase",
+ display_name: "Browserbase",
+ category: "browser",
+ transport: "streamableHttp",
+ status: "configured",
+ configured: true,
+ logo_url: "https://example.invalid/browserbase.svg",
+ brand_color: "#111827",
+ }],
+ },
+ );
+
+ expect(lastSocket().sent).toContain(
+ JSON.stringify({
+ type: "message",
+ chat_id: "chat-mcp",
+ content: "@browserbase check this page",
+ mcp_presets: [{
+ name: "browserbase",
+ display_name: "Browserbase",
+ category: "browser",
+ transport: "streamableHttp",
+ status: "configured",
+ configured: true,
+ logo_url: "https://example.invalid/browserbase.svg",
+ brand_color: "#111827",
+ }],
+ webui: true,
+ }),
+ );
+ });
+
it("re-attaches known chats after a reconnect", async () => {
const client = new NanobotClient({
url: "ws://test",
diff --git a/webui/src/tests/provider-brand.test.ts b/webui/src/tests/provider-brand.test.ts
new file mode 100644
index 000000000..1f1d4ed74
--- /dev/null
+++ b/webui/src/tests/provider-brand.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from "vitest";
+
+import { faviconUrls, logoFallbackUrls, providerBrand } from "@/lib/provider-brand";
+
+describe("provider brand logos", () => {
+ it("uses multiple favicon sources before falling back to initials", () => {
+ expect(faviconUrls("z.ai")).toEqual([
+ "https://z.ai/favicon.ico",
+ "https://icons.duckduckgo.com/ip3/z.ai.ico",
+ "https://www.google.com/s2/favicons?domain=z.ai&sz=64",
+ ]);
+ });
+
+ it("keeps explicit Google favicon URLs first before trying fallbacks", () => {
+ expect(logoFallbackUrls("https://www.google.com/s2/favicons?domain=browserbase.com&sz=64")).toEqual([
+ "https://www.google.com/s2/favicons?domain=browserbase.com&sz=64",
+ "https://browserbase.com/favicon.ico",
+ "https://icons.duckduckgo.com/ip3/browserbase.com.ico",
+ ]);
+ });
+
+ it("normalizes path-like favicon domains for secondary fallbacks", () => {
+ expect(logoFallbackUrls("https://www.google.com/s2/favicons?domain=github.com/HKUDS/CLI-Anything&sz=64")).toEqual([
+ "https://www.google.com/s2/favicons?domain=github.com/HKUDS/CLI-Anything&sz=64",
+ "https://github.com/favicon.ico",
+ "https://icons.duckduckgo.com/ip3/github.com.ico",
+ "https://www.google.com/s2/favicons?domain=github.com%2FHKUDS%2FCLI-Anything&sz=64",
+ ]);
+ });
+
+ it("keeps Zhipu on the current Z.ai brand domain", () => {
+ expect(providerBrand("zhipu")?.logoUrls[0]).toBe("https://z-cdn.chatglm.cn/z-ai/static/logo.svg");
+ expect(providerBrand("zhipu")?.logoUrls).toContain("https://www.google.com/s2/favicons?domain=z.ai&sz=64");
+ expect(providerBrand("zhipu")?.logoUrls).toContain("https://z.ai/favicon.ico");
+ expect(providerBrand("zhipu")?.initials).toBe("Z");
+ });
+});
diff --git a/webui/src/tests/session-search-dialog.test.tsx b/webui/src/tests/session-search-dialog.test.tsx
new file mode 100644
index 000000000..3d252bf77
--- /dev/null
+++ b/webui/src/tests/session-search-dialog.test.tsx
@@ -0,0 +1,70 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { SessionSearchDialog } from "@/components/SessionSearchDialog";
+import type { ChatSummary } from "@/lib/types";
+
+function session(index: number): ChatSummary {
+ return {
+ key: `websocket:chat-${index}`,
+ channel: "websocket",
+ chatId: `chat-${index}`,
+ createdAt: null,
+ updatedAt: null,
+ title: `Chat ${index}`,
+ preview: `Preview ${index}`,
+ };
+}
+
+describe("SessionSearchDialog", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("uses a solid compact command palette surface", () => {
+ render(
+ {}}
+ onSelect={() => {}}
+ />,
+ );
+
+ const dialog = screen.getByRole("dialog");
+ expect(dialog).toHaveClass("bg-background");
+ expect(dialog.className).not.toContain("bg-popover/");
+ expect(dialog.className).not.toContain("backdrop-blur");
+ expect(screen.getByTestId("session-search-scroll")).toHaveClass("overflow-y-auto");
+ });
+
+ it("keeps keyboard navigation scrollable through long result lists", () => {
+ const scrollIntoView = vi.fn();
+ Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
+ configurable: true,
+ value: scrollIntoView,
+ });
+
+ render(
+ session(index + 1))}
+ activeKey={null}
+ loading={false}
+ onOpenChange={() => {}}
+ onSelect={() => {}}
+ />,
+ );
+
+ const input = screen.getByRole("textbox", { name: "Search" });
+ fireEvent.keyDown(input, { key: "ArrowDown" });
+ fireEvent.keyDown(input, { key: "ArrowDown" });
+
+ expect(scrollIntoView).toHaveBeenCalledWith({
+ block: "nearest",
+ inline: "nearest",
+ });
+ });
+});
diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx
index f4c04456e..250b28308 100644
--- a/webui/src/tests/thread-composer.test.tsx
+++ b/webui/src/tests/thread-composer.test.tsx
@@ -1,8 +1,8 @@
-import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ThreadComposer } from "@/components/thread/ThreadComposer";
-import type { CliAppInfo, SlashCommand } from "@/lib/types";
+import type { CliAppInfo, McpPresetInfo, SlashCommand } from "@/lib/types";
const COMMANDS: SlashCommand[] = [
{
@@ -70,6 +70,47 @@ const CLI_APPS: CliAppInfo[] = [
skill_installed: false,
},
];
+
+const MCP_PRESETS: McpPresetInfo[] = [
+ {
+ name: "browserbase",
+ display_name: "Browserbase",
+ category: "browser",
+ description: "Cloud browser automation",
+ docs_url: "https://docs.browserbase.com",
+ transport: "streamableHttp",
+ requires: "Browserbase API key",
+ note: "",
+ install_supported: true,
+ installed: true,
+ configured: true,
+ available: true,
+ status: "configured",
+ logo_url: "https://example.invalid/browserbase.svg",
+ brand_color: "#111827",
+ required_fields: [],
+ connection_summary: "https://mcp.browserbase.com/mcp",
+ },
+ {
+ name: "figma",
+ display_name: "Figma",
+ category: "design",
+ description: "Design context",
+ docs_url: "https://figma.com",
+ transport: "streamableHttp",
+ requires: "Figma desktop",
+ note: "",
+ install_supported: true,
+ installed: true,
+ configured: false,
+ available: false,
+ status: "missing_credentials",
+ logo_url: null,
+ brand_color: "#F24E1E",
+ required_fields: [],
+ connection_summary: "",
+ },
+];
const ORIGINAL_INNER_HEIGHT = window.innerHeight;
afterEach(() => {
@@ -125,11 +166,14 @@ describe("ThreadComposer", () => {
,
);
expect(screen.getByText("gpt-4o")).toBeInTheDocument();
+ expect(screen.getByTestId("composer-model-logo-openai")).toBeInTheDocument();
const input = screen.getByPlaceholderText("Type your message...");
expect(input.className).toContain("min-h-[50px]");
expect(input.parentElement?.parentElement?.className).toContain("max-w-[49.5rem]");
@@ -227,7 +271,7 @@ describe("ThreadComposer", () => {
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
- const palette = screen.getByRole("listbox", { name: "CLI Apps" });
+ const palette = screen.getByRole("listbox", { name: "Apps and MCP" });
expect(palette).toBeInTheDocument();
expect(screen.getByRole("option", { name: /@gimp/i })).toHaveAttribute(
"aria-selected",
@@ -246,7 +290,7 @@ describe("ThreadComposer", () => {
expect(screen.getByTestId("composer-cli-mention-blender")).toHaveTextContent("@blender");
expect(screen.queryByTestId("composer-cli-app-tray")).not.toBeInTheDocument();
expect(onSend).not.toHaveBeenCalled();
- expect(screen.queryByRole("listbox", { name: "CLI Apps" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("listbox", { name: "Apps and MCP" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
@@ -282,6 +326,69 @@ describe("ThreadComposer", () => {
expect(screen.getByTestId("composer-cli-mention-blender")).toHaveTextContent("@blender");
});
+ it("shows configured MCP presets in the mention palette and submits metadata", () => {
+ const onSend = vi.fn();
+ render(
+ ,
+ );
+
+ const input = screen.getByLabelText("Message input");
+ fireEvent.change(input, {
+ target: { value: "use @bro", selectionStart: 8 },
+ });
+
+ expect(screen.getByRole("option", { name: /@browserbase/i })).toBeInTheDocument();
+ expect(screen.queryByRole("option", { name: /@figma/i })).not.toBeInTheDocument();
+
+ fireEvent.keyDown(input, { key: "Tab" });
+
+ expect(input).toHaveValue("use @browserbase ");
+ expect(screen.getByTestId("composer-mcp-mention-browserbase")).toHaveTextContent("@browserbase");
+
+ fireEvent.click(screen.getByRole("button", { name: "Send message" }));
+
+ expect(onSend).toHaveBeenCalledWith("use @browserbase", undefined, {
+ mcpPresets: [{
+ name: "browserbase",
+ display_name: "Browserbase",
+ category: "browser",
+ transport: "streamableHttp",
+ status: "configured",
+ configured: true,
+ logo_url: "https://example.invalid/browserbase.svg",
+ brand_color: "#111827",
+ }],
+ });
+ });
+
+ it("shows right-side source badges so users can distinguish CLI apps from MCP servers", () => {
+ render(
+ ,
+ );
+
+ const input = screen.getByLabelText("Message input");
+ fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
+
+ expect(screen.queryByText("CLI Apps")).not.toBeInTheDocument();
+ expect(screen.queryByText("MCP servers")).not.toBeInTheDocument();
+ const gimp = screen.getByRole("option", { name: /GIMP @gimp .* CLI/i });
+ const browserbase = screen.getByRole("option", { name: /Browserbase @browserbase .* MCP/i });
+ expect(within(gimp).getByText("CLI")).toBeInTheDocument();
+ expect(within(browserbase).getByText("MCP")).toBeInTheDocument();
+ expect(within(gimp).getByText("@gimp")).toBeInTheDocument();
+ expect(within(browserbase).getByText("@browserbase")).toBeInTheDocument();
+ });
+
it("does not duplicate the next word separator when completing a CLI app mention", () => {
render(
{
));
const input = await screen.findByLabelText("Message input");
- expect(screen.queryByRole("listbox", { name: "CLI Apps" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("listbox", { name: "Apps and MCP" })).not.toBeInTheDocument();
const payload: CliAppsPayload = {
apps: [{
@@ -1038,7 +1038,7 @@ describe("ThreadShell", () => {
});
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
- expect(screen.getByRole("listbox", { name: "CLI Apps" })).toBeInTheDocument();
+ expect(screen.getByRole("listbox", { name: "Apps and MCP" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: /@gimp/i })).toBeInTheDocument();
});
});