feat(mcp): add preset setup and capability mentions

This commit is contained in:
Xubin Ren 2026-05-24 13:38:37 +08:00
parent 8be258212e
commit 704ac558f6
54 changed files with 8425 additions and 708 deletions

1
.gitignore vendored
View File

@ -98,3 +98,4 @@ tmp/
temp/
*.tmp
exp/
.playwright-mcp/

View File

@ -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."""

View File

@ -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(

View File

@ -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)

View File

@ -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)

View File

@ -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"![{match.group(1)}]({signed_url}{title})"
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")

View File

@ -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

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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"]

View File

@ -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:

View File

@ -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"![{match.group(1)}]({signed_url}{title})"
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

View File

@ -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()

View File

@ -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():

View File

@ -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"

View File

@ -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

View File

@ -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
# ---------------------------------------------------------------------------

View File

@ -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 "<redacted>" 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"}]

View File

@ -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"}]}

View File

@ -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"],
}
)

View File

@ -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<McpPresetInfo, "name" | "display_name">): 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 <span key={`text-${index}`}>{segment.text}</span>;
}
return (
if (segment.kind === "cli") return (
<CliAppMentionToken
key={`cli-${segment.app.name}-${index}`}
app={segment.app}
@ -78,6 +149,14 @@ export function CliAppMentionText({
variant="message"
/>
);
return (
<McpPresetMentionToken
key={`mcp-${segment.preset.name}-${index}`}
preset={segment.preset}
label={segment.text}
variant="message"
/>
);
})}
</>
);
@ -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 (
<span
data-testid={`${testIdPrefix}-cli-mention-${app.name}`}
title={`CLI app: ${app.display_name || app.name}`}
className="relative inline transition-[color,text-shadow] duration-150"
style={{
color,
@ -124,10 +208,69 @@ export function CliAppMentionToken({
)}
>
<img
src={app.logo_url ?? ""}
src={logoUrl ?? ""}
alt=""
className="h-full w-full object-contain"
onError={() => setFailed(true)}
onError={() => setLogoIndex((index) => index + 1)}
/>
</span>
) : null}
</span>
{mentionName}
</span>
);
}
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 (
<span
data-testid={`${testIdPrefix}-mcp-mention-${preset.name}`}
title={`MCP server: ${preset.display_name || preset.name}`}
className="relative inline transition-[color,text-shadow] duration-150"
style={{
color,
textShadow: `0 0 10px ${alphaColor(color, 24)}`,
}}
>
<span
className={cn("relative inline-block", showLogo && "text-transparent")}
style={{ lineHeight: "inherit" }}
>
@
{showLogo ? (
<span
data-testid={`${testIdPrefix}-mcp-mention-logo-${preset.name}`}
className={cn(
"absolute left-1/2 top-1/2 grid place-items-center overflow-hidden rounded-[3px]",
"-translate-x-1/2 -translate-y-1/2",
isHero ? "h-[0.74em] w-[0.74em]" : "h-[0.72em] w-[0.72em]",
)}
>
<img
src={logoUrl ?? ""}
alt=""
className="h-full w-full object-contain"
onError={() => setLogoIndex((index) => index + 1)}
/>
</span>
) : null}

View File

@ -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",
)}
>
<CliAppMentionText text={message.content} cliApps={mentionCliApps} />
<CliAppMentionText
text={message.content}
cliApps={mentionCliApps}
mcpPresets={mentionMcpPresets}
/>
</p>
) : null}
</div>
@ -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,

View File

@ -33,6 +33,7 @@ export function SessionSearchDialog({
}: SessionSearchDialogProps) {
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
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({
<DialogContent
showCloseButton={false}
className={cn(
"max-h-[min(34rem,calc(100vh-2rem))] w-[calc(100vw-2rem)] max-w-[42rem] gap-0 overflow-hidden p-0",
"rounded-2xl border border-border/70 bg-popover/95 text-popover-foreground shadow-2xl backdrop-blur-xl",
"sm:rounded-2xl",
"flex max-h-[min(40rem,calc(100vh-2rem))] w-[calc(100vw-2rem)] max-w-[42rem] flex-col gap-0 overflow-hidden p-0",
"rounded-[22px] border border-border bg-background text-foreground shadow-[0_22px_70px_rgba(0,0,0,0.22)]",
"dark:border-white/14 dark:bg-[#2b2b2b] dark:shadow-[0_26px_90px_rgba(0,0,0,0.44)] sm:rounded-[22px]",
)}
>
<DialogTitle className="sr-only">{t("sidebar.searchAria")}</DialogTitle>
<DialogDescription className="sr-only">
{t("sidebar.searchPlaceholder")}
</DialogDescription>
<div className="flex h-14 items-center gap-3 border-b border-border/60 px-5">
<div className="flex h-[62px] shrink-0 items-center gap-3 border-b border-border px-[18px]">
<Search
className="h-4 w-4 shrink-0 text-muted-foreground"
className="h-[18px] w-[18px] shrink-0 text-muted-foreground"
aria-hidden
/>
<input
@ -128,16 +140,16 @@ export function SessionSearchDialog({
onKeyDown={handleKeyDown}
placeholder={t("sidebar.searchPlaceholder")}
aria-label={t("sidebar.searchAria")}
className="h-full min-w-0 flex-1 bg-transparent text-[15px] font-medium text-foreground outline-none placeholder:text-muted-foreground/75"
className="h-full min-w-0 flex-1 bg-transparent text-[19px] font-normal leading-none text-foreground outline-none placeholder:text-muted-foreground"
/>
<kbd className="hidden h-6 shrink-0 items-center rounded-md border border-border/70 bg-muted/60 px-2 text-[11px] font-medium text-muted-foreground sm:inline-flex">
{shortcutLabel}
</kbd>
</div>
<div className="min-h-0 overflow-y-auto overscroll-contain p-2">
<div
data-testid="session-search-scroll"
className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-2.5 scrollbar-thin scrollbar-track-transparent"
>
<section>
<div className="px-2 pb-1.5 pt-1 text-[12px] font-medium text-muted-foreground/70">
<div className="px-2.5 pb-1.5 pt-1 text-[12px] font-medium text-muted-foreground">
{sectionLabel}
</div>
@ -150,7 +162,7 @@ export function SessionSearchDialog({
{emptyLabel}
</div>
) : (
<ul className="space-y-1">
<ul className="space-y-0.5">
{sessionResults.map((session, index) => {
const title = titleOverrides[session.key]?.trim() ||
session.title?.trim() ||
@ -164,15 +176,18 @@ export function SessionSearchDialog({
return (
<li key={session.key}>
<button
ref={(node) => {
itemRefs.current[index] = node;
}}
type="button"
onClick={() => handleSelect(session.key)}
onMouseEnter={() => setHighlightedIndex(index)}
aria-current={active ? "page" : undefined}
className={cn(
"flex min-h-12 w-full min-w-0 rounded-xl px-3 py-2.5 text-left transition-colors",
"grid min-h-[54px] w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-[11px] px-3 py-2 text-left transition-colors",
highlighted
? "bg-accent text-accent-foreground"
: "text-popover-foreground hover:bg-accent/75 hover:text-accent-foreground",
? "bg-muted text-foreground"
: "text-foreground hover:bg-muted",
)}
>
<span className="min-w-0 flex-1">
@ -181,17 +196,17 @@ export function SessionSearchDialog({
</span>
{showPreview ? (
<span
className={cn(
"block truncate text-[12px] leading-4",
highlighted
? "text-accent-foreground/70"
: "text-muted-foreground",
)}
className="block truncate text-[12px] leading-4 text-muted-foreground"
>
{preview}
</span>
) : null}
</span>
{active ? (
<span className="shrink-0 rounded-full bg-muted-foreground/10 px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
{t("common.current", { defaultValue: "Current" })}
</span>
) : null}
</button>
</li>
);
@ -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";
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -11,14 +11,15 @@ import {
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
import {
CliAppMentionToken,
McpPresetMentionToken,
cliAppInitials,
splitCliAppMentionSegments,
type CliAppMentionSegment,
mcpPresetInitials,
splitCapabilityMentionSegments,
type CapabilityMentionSegment,
} from "@/components/CliAppMentionText";
import {
Activity,
ArrowUp,
AtSign,
BookOpen,
Check,
ChevronDown,
@ -48,7 +49,19 @@ import {
} from "@/hooks/useAttachedImages";
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
import type { SendImage, SendOptions } from "@/hooks/useNanobotStream";
import type { CliAppInfo, GoalStateWsPayload, OutboundCliAppMention, SlashCommand } from "@/lib/types";
import type {
CliAppInfo,
GoalStateWsPayload,
McpPresetInfo,
OutboundCliAppMention,
OutboundMcpPresetMention,
SlashCommand,
} from "@/lib/types";
import {
inferProviderFromModelName,
logoFallbackUrls,
providerBrand,
} from "@/lib/provider-brand";
import { cn } from "@/lib/utils";
/** ``<input accept>``: aligned with the server's MIME whitelist. SVG is
@ -67,9 +80,12 @@ interface ThreadComposerProps {
placeholder?: string;
isStreaming?: boolean;
modelLabel?: string | null;
modelProvider?: string | null;
modelProviderLabel?: string | null;
variant?: "thread" | "hero";
slashCommands?: SlashCommand[];
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
imageMode?: boolean;
onImageModeChange?: (enabled: boolean) => void;
onStop?: () => void;
@ -97,7 +113,7 @@ const IMAGE_ASPECT_RATIOS: ImageAspectRatio[] = ["auto", "1:1", "3:4", "9:16", "
const SLASH_PALETTE_GAP_PX = 8;
const SLASH_PALETTE_MAX_HEIGHT_PX = 288;
const SLASH_PALETTE_MIN_HEIGHT_PX = 144;
const SLASH_PALETTE_CHROME_PX = 64;
const SLASH_PALETTE_CHROME_PX = 40;
type SlashPalettePlacement = "above" | "below";
@ -112,6 +128,10 @@ interface CliAppMentionQuery {
end: number;
}
type MentionCandidate =
| { kind: "cli"; name: string; app: CliAppInfo }
| { kind: "mcp"; name: string; preset: McpPresetInfo };
function slashCommandI18nKey(command: string): string {
return command.replace(/^\//, "").replace(/-/g, "_");
}
@ -192,6 +212,19 @@ function cliAppMentionPayload(app: CliAppInfo): OutboundCliAppMention {
};
}
function mcpPresetMentionPayload(preset: McpPresetInfo): OutboundMcpPresetMention {
return {
name: preset.name,
display_name: preset.display_name,
category: preset.category,
transport: preset.transport,
status: preset.status,
configured: preset.configured,
logo_url: preset.logo_url ?? null,
brand_color: preset.brand_color ?? null,
};
}
function RunElapsedStrip({
startedAt,
goalState,
@ -394,9 +427,12 @@ export function ThreadComposer({
placeholder,
isStreaming = false,
modelLabel = null,
modelProvider = null,
modelProviderLabel = null,
variant = "thread",
slashCommands = [],
cliApps = [],
mcpPresets = [],
imageMode: controlledImageMode,
onImageModeChange,
onStop,
@ -534,9 +570,9 @@ export function ThreadComposer({
};
}, [cliAppMenuDismissed, cursorPosition, disabled, value]);
const filteredCliApps = useMemo(() => {
const filteredMentionCandidates = useMemo<MentionCandidate[]>(() => {
if (!cliAppMention) return [];
return cliApps
const cliCandidates: MentionCandidate[] = cliApps
.filter((app) => app.installed)
.filter((app) => {
const haystack = [
@ -548,16 +584,32 @@ export function ThreadComposer({
].join(" ").toLowerCase();
return haystack.includes(cliAppMention.query);
})
.slice(0, 8);
}, [cliAppMention, cliApps]);
.map((app) => ({ kind: "cli", name: app.name, app }));
const mcpCandidates: MentionCandidate[] = mcpPresets
.filter((preset) => preset.installed && preset.configured)
.filter((preset) => {
const haystack = [
preset.name,
preset.display_name,
preset.category,
preset.description,
preset.transport,
].join(" ").toLowerCase();
return haystack.includes(cliAppMention.query);
})
.map((preset) => ({ kind: "mcp", name: preset.name, preset }));
return [...cliCandidates, ...mcpCandidates].slice(0, 8);
}, [cliAppMention, cliApps, mcpPresets]);
const showCliAppMenu = filteredCliApps.length > 0;
const showCliAppMenu = filteredMentionCandidates.length > 0;
const showAnyPalette = showSlashMenu || showCliAppMenu;
const mentionSegments = useMemo(
() => splitCliAppMentionSegments(value, cliApps),
[cliApps, value],
() => splitCapabilityMentionSegments(value, cliApps, mcpPresets),
[cliApps, mcpPresets, value],
);
const hasMentionDecorations = mentionSegments.some(
(segment) => segment.kind === "cli" || segment.kind === "mcp",
);
const hasCliMentionDecorations = mentionSegments.some((segment) => segment.kind === "cli");
const activeCliMentionApps = useMemo(() => {
const seen = new Set<string>();
return mentionSegments.flatMap((segment) => {
@ -566,6 +618,14 @@ export function ThreadComposer({
return [segment.app];
});
}, [mentionSegments]);
const activeMcpPresetMentions = useMemo(() => {
const seen = new Set<string>();
return mentionSegments.flatMap((segment) => {
if (segment.kind !== "mcp" || seen.has(segment.preset.name)) return [];
seen.add(segment.preset.name);
return [segment.preset];
});
}, [mentionSegments]);
const [slashPaletteLayout, setSlashPaletteLayout] = useState<SlashPaletteLayout>({
placement: "above",
maxHeight: SLASH_PALETTE_MAX_HEIGHT_PX,
@ -586,10 +646,10 @@ export function ThreadComposer({
}, [filteredSlashCommands.length, selectedCommandIndex]);
useEffect(() => {
if (selectedCliAppIndex >= filteredCliApps.length) {
if (selectedCliAppIndex >= filteredMentionCandidates.length) {
setSelectedCliAppIndex(0);
}
}, [filteredCliApps.length, selectedCliAppIndex]);
}, [filteredMentionCandidates.length, selectedCliAppIndex]);
useEffect(() => {
if (!showAnyPalette) return;
@ -640,7 +700,7 @@ export function ThreadComposer({
window.removeEventListener("resize", updateLayout);
document.removeEventListener("scroll", updateLayout, true);
};
}, [filteredCliApps.length, filteredSlashCommands.length, showAnyPalette]);
}, [filteredMentionCandidates.length, filteredSlashCommands.length, showAnyPalette]);
useEffect(() => {
if (!aspectMenuOpen) return;
@ -695,11 +755,11 @@ export function ThreadComposer({
[resizeTextarea],
);
const chooseCliApp = useCallback(
(app: CliAppInfo) => {
const chooseMentionCandidate = useCallback(
(candidate: MentionCandidate) => {
if (!cliAppMention) return;
const suffix = value.slice(cliAppMention.end);
const mention = `@${app.name}${suffix.startsWith(" ") ? "" : " "}`;
const mention = `@${candidate.name}${suffix.startsWith(" ") ? "" : " "}`;
const next = `${value.slice(0, cliAppMention.start)}${mention}${suffix}`;
const nextCursor = cliAppMention.start + mention.length;
setValue(next);
@ -736,8 +796,9 @@ export function ThreadComposer({
}))
: undefined;
const attachedCliApps = activeCliMentionApps.map(cliAppMentionPayload);
const attachedMcpPresets = activeMcpPresetMentions.map(mcpPresetMentionPayload);
const options: SendOptions | undefined =
imageMode || attachedCliApps.length > 0
imageMode || attachedCliApps.length > 0 || attachedMcpPresets.length > 0
? {
...(imageMode
? {
@ -748,6 +809,7 @@ export function ThreadComposer({
}
: {}),
...(attachedCliApps.length > 0 ? { cliApps: attachedCliApps } : {}),
...(attachedMcpPresets.length > 0 ? { mcpPresets: attachedMcpPresets } : {}),
}
: undefined;
onSend(trimmed, payload, options);
@ -760,25 +822,36 @@ export function ThreadComposer({
setCliAppMenuDismissed(false);
setCursorPosition(0);
resizeTextarea();
}, [activeCliMentionApps, canSend, clear, imageAspectRatio, imageMode, onSend, readyImages, resizeTextarea, value]);
}, [
activeCliMentionApps,
activeMcpPresetMentions,
canSend,
clear,
imageAspectRatio,
imageMode,
onSend,
readyImages,
resizeTextarea,
value,
]);
const onKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
if (showCliAppMenu) {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedCliAppIndex((idx) => (idx + 1) % filteredCliApps.length);
setSelectedCliAppIndex((idx) => (idx + 1) % filteredMentionCandidates.length);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedCliAppIndex(
(idx) => (idx - 1 + filteredCliApps.length) % filteredCliApps.length,
(idx) => (idx - 1 + filteredMentionCandidates.length) % filteredMentionCandidates.length,
);
return;
}
if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
e.preventDefault();
chooseCliApp(filteredCliApps[selectedCliAppIndex]);
chooseMentionCandidate(filteredMentionCandidates[selectedCliAppIndex]);
return;
}
if (e.key === "Escape") {
@ -894,12 +967,12 @@ export function ThreadComposer({
) : null}
{showCliAppMenu ? (
<CliAppMentionPalette
apps={filteredCliApps}
candidates={filteredMentionCandidates}
selectedIndex={selectedCliAppIndex}
layout={slashPaletteLayout}
isHero={isHero}
onHover={setSelectedCliAppIndex}
onChoose={chooseCliApp}
onChoose={chooseMentionCandidate}
/>
) : null}
<div
@ -947,7 +1020,7 @@ export function ThreadComposer({
<RunElapsedStrip startedAt={runStartedAt} goalState={goalState} />
) : null}
<div className="relative">
{hasCliMentionDecorations ? (
{hasMentionDecorations ? (
<ComposerCliMentionOverlay
segments={mentionSegments}
isHero={isHero}
@ -978,7 +1051,7 @@ export function ThreadComposer({
"relative z-10 caret-foreground placeholder:text-muted-foreground/70",
"focus:outline-none focus-visible:outline-none",
"disabled:cursor-not-allowed",
hasCliMentionDecorations && "text-transparent selection:bg-primary/20",
hasMentionDecorations && "text-transparent selection:bg-primary/20",
)}
/>
</div>
@ -1019,7 +1092,7 @@ export function ThreadComposer({
"rounded-full text-muted-foreground hover:text-foreground",
isHero
? "h-9 w-9 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card"
: "h-7.5 w-7.5 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card",
: "h-9 w-9 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card",
)}
>
<Plus className={cn(isHero ? "h-5 w-5" : "h-4 w-4")} />
@ -1038,7 +1111,7 @@ export function ThreadComposer({
}}
className={cn(
"rounded-full border border-border/55 px-2.5 font-medium shadow-[0_2px_8px_rgba(15,23,42,0.04)]",
isHero ? "h-9 text-[12px]" : "h-7.5 text-[10.5px]",
"h-9 text-[12px]",
imageMode
? "border-primary/30 bg-primary/10 text-primary hover:bg-primary/12"
: "bg-card text-muted-foreground hover:bg-card hover:text-foreground",
@ -1058,7 +1131,7 @@ export function ThreadComposer({
onClick={() => setAspectMenuOpen((open) => !open)}
className={cn(
"rounded-full border border-border/55 bg-card px-2.5 font-medium text-foreground/80 shadow-[0_2px_8px_rgba(15,23,42,0.04)] hover:bg-card",
isHero ? "h-9 text-[12px]" : "h-7.5 text-[10.5px]",
"h-9 text-[12px]",
)}
>
<span>{t(`thread.composer.imageMode.aspect.${imageAspectRatio.replace(":", "_")}`)}</span>
@ -1078,22 +1151,12 @@ export function ThreadComposer({
) : null}
</div>
{modelLabel ? (
<span
title={modelLabel}
className={cn(
"inline-flex min-w-0 items-center gap-1.5 rounded-full border px-2.5 py-1",
"border-foreground/10 bg-foreground/[0.035] font-medium text-foreground/80",
isHero
? "max-w-[13rem] text-[12px] shadow-[0_2px_8px_rgba(15,23,42,0.04)]"
: "max-w-[10rem] text-[10.5px] shadow-[0_2px_8px_rgba(15,23,42,0.035)]",
)}
>
<span
aria-hidden
className="h-1.5 w-1.5 flex-none rounded-full bg-emerald-500/80"
/>
<span className="truncate">{modelLabel}</span>
</span>
<ComposerModelBadge
label={modelLabel}
provider={modelProvider}
providerLabel={modelProviderLabel}
isHero={isHero}
/>
) : null}
{!isHero ? (
<span className="hidden select-none text-[10.5px] text-muted-foreground/60 sm:inline">
@ -1115,7 +1178,7 @@ export function ThreadComposer({
: isHero
? "border border-foreground bg-foreground text-background shadow-[0_4px_12px_rgba(15,23,42,0.20)] hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80"
: "border border-foreground bg-foreground text-background shadow-[0_3px_10px_rgba(15,23,42,0.18)] hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80",
isHero ? "" : "h-7.5 w-7.5",
"h-9 w-9",
(canSend || showStopButton) && "hover:scale-[1.03] active:scale-95",
)}
>
@ -1133,12 +1196,79 @@ export function ThreadComposer({
);
}
function ComposerModelBadge({
label,
provider,
providerLabel,
isHero,
}: {
label: string;
provider?: string | null;
providerLabel?: string | null;
isHero: boolean;
}) {
const inferredProvider = provider || inferProviderFromModelName(label);
const brand = providerBrand(inferredProvider);
const [logoIndex, setLogoIndex] = useState(0);
const logoUrl = brand?.logoUrls[logoIndex];
const showLogo = !!logoUrl;
const title = providerLabel ? `${label} · ${providerLabel}` : label;
useEffect(() => setLogoIndex(0), [inferredProvider]);
return (
<span
title={title}
className={cn(
"inline-flex min-w-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/82",
"shadow-[0_2px_8px_rgba(15,23,42,0.045)]",
isHero ? "h-9 max-w-[13.5rem] gap-2 px-2.5 text-[12px]" : "h-9 max-w-[12rem] gap-2 px-2.5 text-[12px]",
)}
>
<span
data-testid={inferredProvider ? `composer-model-logo-${inferredProvider}` : "composer-model-logo"}
className={cn(
"grid shrink-0 place-items-center overflow-hidden rounded-full border bg-background",
"h-5 w-5",
)}
style={{
borderColor: brand ? `${brand.color}28` : undefined,
boxShadow: brand ? `inset 0 0 0 1px ${brand.color}18` : undefined,
}}
aria-hidden
>
{showLogo ? (
<img
src={logoUrl}
alt=""
className="h-3.5 w-3.5 object-contain"
onError={() => setLogoIndex((index) => index + 1)}
/>
) : brand ? (
<span
className={cn(
"grid h-full w-full place-items-center rounded-full text-white",
"text-[8px]",
)}
style={{ backgroundColor: brand.color }}
>
{brand.initials.slice(0, 2)}
</span>
) : (
<Sparkles className={cn("text-muted-foreground/65", isHero ? "h-3.5 w-3.5" : "h-3 w-3")} />
)}
</span>
<span className="truncate">{label}</span>
</span>
);
}
function ComposerCliMentionOverlay({
segments,
isHero,
className,
}: {
segments: CliAppMentionSegment[];
segments: CapabilityMentionSegment[];
isHero: boolean;
className: string;
}) {
@ -1154,7 +1284,7 @@ function ComposerCliMentionOverlay({
if (segment.kind === "text") {
return <span key={`text-${index}`}>{segment.text}</span>;
}
return (
if (segment.kind === "cli") return (
<CliAppMentionToken
key={`cli-${segment.app.name}-${index}`}
app={segment.app}
@ -1163,6 +1293,15 @@ function ComposerCliMentionOverlay({
isHero={isHero}
/>
);
return (
<McpPresetMentionToken
key={`mcp-${segment.preset.name}-${index}`}
preset={segment.preset}
label={segment.text}
variant="composer"
isHero={isHero}
/>
);
})}
</div>
);
@ -1177,12 +1316,12 @@ interface SlashCommandPaletteProps {
}
interface CliAppMentionPaletteProps {
apps: CliAppInfo[];
candidates: MentionCandidate[];
selectedIndex: number;
layout: SlashPaletteLayout;
isHero: boolean;
onHover: (index: number) => void;
onChoose: (app: CliAppInfo) => void;
onChoose: (candidate: MentionCandidate) => void;
}
function ImageAspectMenu({
@ -1239,7 +1378,7 @@ function ImageAspectMenu({
}
function CliAppMentionPalette({
apps,
candidates,
selectedIndex,
layout,
isHero,
@ -1257,98 +1396,117 @@ function CliAppMentionPalette({
aria-label={t("thread.composer.mentions.ariaLabel")}
style={{ maxHeight: layout.maxHeight }}
className={cn(
"absolute left-1/2 z-30 w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden rounded-[18px] border",
"absolute left-1/2 z-30 w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden rounded-[22px] border",
layout.placement === "above" ? "bottom-full mb-2" : "top-full mt-2",
"border-border/65 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)]",
"dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]",
"border-border/70 bg-popover p-2 text-popover-foreground shadow-[0_20px_60px_rgba(15,23,42,0.12)]",
"dark:border-white/10 dark:shadow-[0_24px_60px_rgba(0,0,0,0.42)]",
isHero ? "max-w-[58rem]" : "max-w-[49.5rem]",
)}
>
<div className="flex items-center gap-1.5 px-2 pb-1 pt-1 text-[11px] font-medium tracking-[0.08em] text-muted-foreground/70">
<AtSign className="h-3 w-3" aria-hidden />
<span>{t("thread.composer.mentions.label")}</span>
<div className="px-2 pb-1.5 pt-0.5 text-[13px] font-semibold text-muted-foreground/78">
{t("thread.composer.mentions.label")}
</div>
<div className="overflow-y-auto pr-0.5" style={{ maxHeight: listMaxHeight }}>
{apps.map((app, index) => {
<div className="overflow-y-auto" style={{ maxHeight: listMaxHeight }}>
{candidates.map((candidate, index) => {
const selected = index === selectedIndex;
const name = candidate.name;
const displayName = candidate.kind === "cli"
? candidate.app.display_name
: candidate.preset.display_name;
const typeLabel = candidate.kind === "cli"
? t("thread.composer.mentions.cliBadge")
: t("thread.composer.mentions.mcpBadge");
const ariaDescription = candidate.kind === "cli"
? t("thread.composer.mentions.cliDescription", { name })
: t("thread.composer.mentions.mcpDescription", { name });
return (
<button
key={app.name}
key={`${candidate.kind}-${name}`}
type="button"
role="option"
aria-selected={selected}
aria-label={`${displayName} @${name} ${ariaDescription} ${typeLabel}`}
onMouseEnter={() => onHover(index)}
onMouseDown={(e) => {
e.preventDefault();
onChoose(app);
onChoose(candidate);
}}
className={cn(
"flex w-full items-center gap-3 rounded-[13px] px-3 py-2.5 text-left transition-colors",
"flex h-10 w-full items-center gap-2.5 rounded-[13px] px-2.5 text-left transition-colors",
selected
? "bg-primary/10 text-foreground"
: "text-foreground/86 hover:bg-accent/55",
? "bg-foreground/[0.055] text-foreground"
: "text-foreground/90 hover:bg-foreground/[0.04]",
)}
>
<CliAppMentionLogo app={app} selected={selected} />
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-baseline gap-2">
<span className="font-mono text-[13px] font-semibold text-foreground">
@{app.name}
</span>
<span className="truncate text-[13px] font-medium">
{app.display_name}
</span>
<MentionCandidateLogo candidate={candidate} selected={selected} />
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="shrink-0 text-[15px] font-medium tracking-normal text-foreground">
{displayName}
</span>
<span className="mt-0.5 block truncate text-[12px] text-muted-foreground">
{app.category}
{app.entry_point ? ` · ${app.entry_point}` : ""}
<span className="truncate text-[15px] font-normal tracking-normal text-muted-foreground/72">
@{name}
</span>
</span>
<span
className={cn(
"ml-2 shrink-0 rounded-full px-2 py-0.5 text-[11px] font-semibold tracking-normal",
candidate.kind === "cli"
? "bg-orange-500/10 text-orange-600 dark:text-orange-300"
: "bg-sky-500/10 text-sky-600 dark:text-sky-300",
)}
>
{typeLabel}
</span>
</button>
);
})}
</div>
<div className="flex items-center gap-2 px-2 pt-1.5 text-[10.5px] text-muted-foreground/70">
<span>{t("thread.composer.slash.navigateHint")}</span>
<span>{t("thread.composer.slash.selectHint")}</span>
<span>{t("thread.composer.slash.closeHint")}</span>
</div>
</div>
);
}
function CliAppMentionLogo({
app,
function MentionCandidateLogo({
candidate,
selected,
}: {
app: CliAppInfo;
candidate: MentionCandidate;
selected: boolean;
}) {
const [failed, setFailed] = useState(false);
const color = app.brand_color || "hsl(var(--primary))";
if (app.logo_url && !failed) {
const [logoIndex, setLogoIndex] = useState(0);
const color = (candidate.kind === "cli"
? candidate.app.brand_color
: candidate.preset.brand_color) || "hsl(var(--primary))";
const rawLogoUrl = candidate.kind === "cli" ? candidate.app.logo_url : candidate.preset.logo_url;
const logoUrls = useMemo(() => logoFallbackUrls(rawLogoUrl), [rawLogoUrl]);
const logoUrl = logoUrls[logoIndex];
useEffect(() => setLogoIndex(0), [rawLogoUrl]);
if (logoUrl) {
return (
<span
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-[8px] border bg-background",
selected ? "border-primary/25" : "border-border/65",
"flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-[5px]",
selected ? "bg-background/55" : "bg-transparent",
)}
>
<img
src={app.logo_url}
src={logoUrl}
alt=""
className="h-4.5 w-4.5 object-contain"
onError={() => setFailed(true)}
className="h-5 w-5 object-contain"
onError={() => setLogoIndex((index) => index + 1)}
/>
</span>
);
}
return (
<span
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[8px] text-[10.5px] font-semibold text-white"
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-[5px] text-[7.5px] font-semibold text-white"
style={{ backgroundColor: color }}
>
{cliAppInitials(app)}
{candidate.kind === "cli"
? cliAppInitials(candidate.app)
: mcpPresetInitials(candidate.preset)}
</span>
);
}

View File

@ -6,7 +6,7 @@ import {
AgentActivityCluster,
isAgentActivityMember,
} from "@/components/thread/AgentActivityCluster";
import type { CliAppInfo, UIMessage } from "@/lib/types";
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
interface ThreadMessagesProps {
messages: UIMessage[];
@ -15,6 +15,7 @@ interface ThreadMessagesProps {
hiddenMessageCount?: number;
onLoadEarlier?: () => void;
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
}
export type DisplayUnit =
@ -166,6 +167,7 @@ export function ThreadMessages({
hiddenMessageCount = 0,
onLoadEarlier,
cliApps = [],
mcpPresets = [],
}: ThreadMessagesProps) {
const { t } = useTranslation();
const units = useMemo(() => buildDisplayUnits(messages), [messages]);
@ -211,6 +213,7 @@ export function ThreadMessages({
isTurnStreaming={index === liveActivityClusterIndex}
hasBodyBelow={hasBodyBelow}
cliApps={cliApps}
mcpPresets={mcpPresets}
/>
) : (
<MessageBubble
@ -221,6 +224,7 @@ export function ThreadMessages({
: true
}
cliApps={cliApps}
mcpPresets={mcpPresets}
/>
)}
</div>

View File

@ -19,13 +19,19 @@ import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
import { ThreadViewport } from "@/components/thread/ThreadViewport";
import { useNanobotStream, type SendImage, type SendOptions } from "@/hooks/useNanobotStream";
import { useSessionHistory } from "@/hooks/useSessions";
import { fetchCliApps, listSlashCommands } from "@/lib/api";
import { fetchCliApps, fetchMcpPresets, fetchSettings, listSlashCommands } from "@/lib/api";
import {
CLI_APPS_CHANGED_EVENT,
installedCliAppsFromPayload,
isCliAppsPayload,
} from "@/lib/cli-app-events";
import type { ChatSummary, CliAppInfo, SlashCommand, UIMessage } from "@/lib/types";
import {
MCP_PRESETS_CHANGED_EVENT,
installedMcpPresetsFromPayload,
isMcpPresetsPayload,
} from "@/lib/mcp-preset-events";
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
import type { ChatSummary, CliAppInfo, McpPresetInfo, SettingsPayload, SlashCommand, UIMessage } from "@/lib/types";
import { normalizeLegacyLongTaskMessages } from "@/lib/thread-display-compat";
import { scrubSubagentUiMessages } from "@/lib/subagent-channel-display";
import { useClient } from "@/providers/ClientProvider";
@ -55,6 +61,41 @@ function toModelBadgeLabel(modelName: string | null): string | null {
return leaf || trimmed;
}
interface ModelBadgeInfo {
label: string | null;
provider: string | null;
providerLabel: string | null;
}
function activeModelPreset(settings: SettingsPayload | null): SettingsPayload["model_presets"][number] | null {
if (!settings) return null;
const configured = settings.agent.model_preset || "default";
return (
settings.model_presets.find((preset) => preset.name === configured)
?? settings.model_presets.find((preset) => preset.active)
?? null
);
}
function resolvedModelProvider(settings: SettingsPayload | null, modelName: string | null): string | null {
const preset = activeModelPreset(settings);
const rawProvider = preset?.provider || settings?.agent.provider || null;
if (rawProvider === "auto") {
return settings?.agent.resolved_provider || inferProviderFromModelName(modelName) || null;
}
return rawProvider || inferProviderFromModelName(modelName);
}
function toModelBadgeInfo(modelName: string | null, settings: SettingsPayload | null): ModelBadgeInfo {
const label = toModelBadgeLabel(modelName || settings?.agent.model || null);
const provider = resolvedModelProvider(settings, modelName || settings?.agent.model || null);
return {
label,
provider,
providerLabel: provider ? providerDisplayLabel(settings?.providers ?? [], provider) : null,
};
}
const QUICK_ACTION_KEYS = [
{ key: "plan", icon: LayoutGrid, tone: "text-[#f25b8f]" },
{ key: "analyze", icon: BarChart3, tone: "text-[#4f9de8]" },
@ -103,6 +144,8 @@ export function ThreadShell({
const [booting, setBooting] = useState(false);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const [cliApps, setCliApps] = useState<CliAppInfo[]>([]);
const [mcpPresets, setMcpPresets] = useState<McpPresetInfo[]>([]);
const [settings, setSettings] = useState<SettingsPayload | null>(null);
const [heroImageMode, setHeroImageMode] = useState(false);
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
@ -141,6 +184,28 @@ export function ThreadShell({
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
const showHeroComposer = messages.length === 0 && !loading;
const modelBadge = useMemo(
() => toModelBadgeInfo(modelName, settings),
[modelName, settings],
);
const refreshModelSettings = useCallback(async () => {
try {
setSettings(await fetchSettings(token));
} catch {
setSettings(null);
}
}, [token]);
useEffect(() => {
void refreshModelSettings();
}, [refreshModelSettings]);
useEffect(() => {
return client.onRuntimeModelUpdate(() => {
void refreshModelSettings();
});
}, [client, refreshModelSettings]);
useEffect(() => {
if (!chatId || loading) return;
@ -262,6 +327,15 @@ export function ThreadShell({
}
}, [token]);
const refreshMcpPresets = useCallback(async () => {
try {
const payload = await fetchMcpPresets(token);
setMcpPresets(installedMcpPresetsFromPayload(payload));
} catch {
setMcpPresets([]);
}
}, [token]);
useEffect(() => {
let cancelled = false;
const load = async () => {
@ -297,6 +371,41 @@ export function ThreadShell({
};
}, [refreshCliApps, token]);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const payload = await fetchMcpPresets(token);
if (!cancelled) setMcpPresets(installedMcpPresetsFromPayload(payload));
} catch {
if (!cancelled) setMcpPresets([]);
}
};
load();
const refreshOnFocus = () => {
if (document.visibilityState === "hidden") return;
void refreshMcpPresets();
};
window.addEventListener("focus", refreshOnFocus);
document.addEventListener("visibilitychange", refreshOnFocus);
const refreshOnMcpPresetsChanged = (event: Event) => {
const payload = (event as CustomEvent<unknown>).detail;
if (isMcpPresetsPayload(payload)) {
setMcpPresets(installedMcpPresetsFromPayload(payload));
return;
}
void refreshMcpPresets();
};
window.addEventListener(MCP_PRESETS_CHANGED_EVENT, refreshOnMcpPresetsChanged);
return () => {
cancelled = true;
window.removeEventListener("focus", refreshOnFocus);
document.removeEventListener("visibilitychange", refreshOnFocus);
window.removeEventListener(MCP_PRESETS_CHANGED_EVENT, refreshOnMcpPresetsChanged);
};
}, [refreshMcpPresets, token]);
const handleWelcomeSend = useCallback(
async (content: string, images?: SendImage[], options?: SendOptions) => {
if (booting) return;
@ -379,10 +488,13 @@ export function ThreadShell({
? t("thread.composer.placeholderHero")
: t("thread.composer.placeholderThread")
}
modelLabel={toModelBadgeLabel(modelName)}
modelLabel={modelBadge.label}
modelProvider={modelBadge.provider}
modelProviderLabel={modelBadge.providerLabel}
variant={showHeroComposer ? "hero" : "thread"}
slashCommands={slashCommands}
cliApps={cliApps}
mcpPresets={mcpPresets}
imageMode={showHeroComposer ? heroImageMode : undefined}
onImageModeChange={showHeroComposer ? setHeroImageMode : undefined}
onStop={stop}
@ -399,10 +511,13 @@ export function ThreadShell({
? t("thread.composer.placeholderOpening")
: t("thread.composer.placeholderHero")
}
modelLabel={toModelBadgeLabel(modelName)}
modelLabel={modelBadge.label}
modelProvider={modelBadge.provider}
modelProviderLabel={modelBadge.providerLabel}
variant="hero"
slashCommands={slashCommands}
cliApps={cliApps}
mcpPresets={mcpPresets}
imageMode={heroImageMode}
onImageModeChange={setHeroImageMode}
runStartedAt={runStartedAt}
@ -444,6 +559,7 @@ export function ThreadShell({
conversationKey={historyKey}
showScrollToBottomButton={!!session}
cliApps={cliApps}
mcpPresets={mcpPresets}
/>
</section>
);

View File

@ -14,7 +14,7 @@ import { ThreadMessages } from "@/components/thread/ThreadMessages";
import { isAgentActivityMember } from "@/components/thread/AgentActivityCluster";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { CliAppInfo, UIMessage } from "@/lib/types";
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
interface ThreadViewportProps {
messages: UIMessage[];
@ -25,6 +25,7 @@ interface ThreadViewportProps {
conversationKey?: string | null;
showScrollToBottomButton?: boolean;
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
}
const NEAR_BOTTOM_PX = 48;
@ -55,6 +56,7 @@ export function ThreadViewport({
conversationKey = null,
showScrollToBottomButton = true,
cliApps = [],
mcpPresets = [],
}: ThreadViewportProps) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
@ -252,6 +254,7 @@ export function ThreadViewport({
hiddenMessageCount={hiddenMessageCount}
onLoadEarlier={loadEarlierMessages}
cliApps={cliApps}
mcpPresets={mcpPresets}
/>
</div>
</div>

View File

@ -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 } : {}),
},
];
});

View File

@ -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",

View File

@ -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",

View File

@ -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 dactiver la génération dimages."
},
"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 dabord de téléverser ou dindiquer limage, 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 limage",

View File

@ -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",

View File

@ -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": "画像プレビュー",

View File

@ -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": "이미지 미리보기",

View File

@ -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",

View File

@ -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": "已复制回复",

View File

@ -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": "圖片預覽",

View File

@ -2,6 +2,8 @@ import type {
ChatSummary,
CliAppsPayload,
ImageGenerationSettingsUpdate,
McpPresetsPayload,
ModelConfigurationCreate,
ProviderSettingsUpdate,
SettingsPayload,
SettingsUpdate,
@ -39,6 +41,21 @@ async function request<T>(
return (await res.json()) as T;
}
function mcpValuesHeader(values: Record<string, unknown>): HeadersInit | undefined {
const payload: Record<string, unknown> = {};
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<CliAppsPayload>(`${base}/api/settings/cli-apps/${action}?${query}`, token);
}
export async function fetchMcpPresets(
token: string,
base: string = "",
): Promise<McpPresetsPayload> {
return request<McpPresetsPayload>(`${base}/api/settings/mcp-presets`, token);
}
export async function runMcpPresetAction(
token: string,
action: "enable" | "remove" | "test",
name: string,
values: Record<string, string> = {},
base: string = "",
): Promise<McpPresetsPayload> {
const query = new URLSearchParams();
query.set("name", name);
return request<McpPresetsPayload>(
`${base}/api/settings/mcp-presets/${action}?${query}`,
token,
{ headers: mcpValuesHeader(values) },
);
}
export async function saveCustomMcpServer(
token: string,
values: Record<string, string>,
base: string = "",
): Promise<McpPresetsPayload> {
return request<McpPresetsPayload>(
`${base}/api/settings/mcp-presets/custom`,
token,
{ headers: mcpValuesHeader(values) },
);
}
export async function importMcpConfig(
token: string,
config: string,
base: string = "",
): Promise<McpPresetsPayload> {
return request<McpPresetsPayload>(
`${base}/api/settings/mcp-presets/import`,
token,
{ headers: mcpValuesHeader({ config }) },
);
}
export async function updateMcpServerTools(
token: string,
name: string,
enabledTools: string[],
base: string = "",
): Promise<McpPresetsPayload> {
return request<McpPresetsPayload>(
`${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<SettingsPayload>(`${base}/api/settings/update?${query}`, token);
}
export async function createModelConfiguration(
token: string,
configuration: ModelConfigurationCreate,
base: string = "",
): Promise<SettingsPayload> {
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<SettingsPayload>(
`${base}/api/settings/model-configurations/create?${query}`,
token,
);
}
export async function updateProviderSettings(
token: string,
update: ProviderSettingsUpdate,

View File

@ -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<McpPresetsPayload>(MCP_PRESETS_CHANGED_EVENT, {
detail: payload,
}));
}

View File

@ -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);

View File

@ -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<string, string> = {
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<string, string> = {
brave_search: "Brave Search",
byteplus_coding_plan: "BytePlus",
minimaxAnthropic: "MiniMax",
minimax_anthropic: "MiniMax",
openai_codex: "OpenAI",
volcengine_coding_plan: "Volcengine",
};
const PROVIDER_BRANDS: Record<string, ProviderBrand> = {
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;
}

View File

@ -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;

View File

@ -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(
<AgentActivityCluster
messages={[liveReasoning]}
isTurnStreaming
hasBodyBelow
/>,
);
expect(screen.getByTestId("activity-reasoning-marker")).toHaveAttribute("data-state", "thinking");
rerender(
<AgentActivityCluster
messages={[{
...liveReasoning,
reasoningStreaming: false,
isStreaming: false,
}]}
isTurnStreaming={false}
hasBodyBelow
/>,
);
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(
<AgentActivityCluster
messages={[
{
id: "t-search",
role: "tool",
kind: "trace",
content: 'web_search({"query":"nanobot architecture"})',
traces: ['web_search({"query":"nanobot architecture"})'],
createdAt: 1,
},
{
id: "t-cli",
role: "tool",
kind: "trace",
content: cliLine,
traces: [cliLine],
toolEvents: [{
phase: "end",
call_id: "call-blender",
name: "run_cli_app",
arguments: cliArgs,
}],
createdAt: 2,
},
{
id: "t-fetch",
role: "tool",
kind: "trace",
content: 'web_fetch({"url":"https://example.com/diagram"})',
traces: ['web_fetch({"url":"https://example.com/diagram"})'],
createdAt: 3,
},
]}
isTurnStreaming
hasBodyBelow={false}
cliApps={[BLENDER_CLI_APP]}
/>,
);
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(
<AgentActivityCluster
@ -341,14 +448,143 @@ describe("AgentActivityCluster", () => {
/>,
);
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(
<AgentActivityCluster
messages={[{
id: "t-mcp",
role: "tool",
kind: "trace",
content: "mcp_browserbase_browser_navigate()",
traces: ["mcp_browserbase_browser_navigate({\"url\":\"https://example.com\"})"],
toolEvents: [
{
phase: "start",
call_id: "call-browserbase",
name: "mcp_browserbase_browser_navigate",
arguments: { url: "https://example.com" },
},
],
createdAt: 1,
}]}
isTurnStreaming
hasBodyBelow={false}
mcpPresets={[BROWSERBASE_MCP]}
/>,
);
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(
<AgentActivityCluster
messages={[{
id: "t-web-fetch",
role: "tool",
kind: "trace",
content: 'web_fetch({"url":"https://auth0.com/blog/jwt-security-best-practices"})',
traces: ['web_fetch({"url":"https://auth0.com/blog/jwt-security-best-practices"})'],
createdAt: 1,
}]}
isTurnStreaming
hasBodyBelow={false}
/>,
);
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(
<AgentActivityCluster
messages={[{
id: "t-web-fetch-text",
role: "tool",
kind: "trace",
content: "Fetching https://auth0.com/blog/jwt-security-best-practices",
traces: ["Fetching https://auth0.com/blog/jwt-security-best-practices"],
createdAt: 1,
}]}
isTurnStreaming
hasBodyBelow={false}
/>,
);
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(
<AgentActivityCluster
messages={[{
id: "t-web-fetch-local",
role: "tool",
kind: "trace",
content: 'web_fetch({"url":"http://localhost:3000/dashboard"})',
traces: ['web_fetch({"url":"http://localhost:3000/dashboard"})'],
createdAt: 1,
}]}
isTurnStreaming
hasBodyBelow={false}
/>,
);
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(
<AgentActivityCluster
messages={[{
id: "t-shell",
role: "tool",
kind: "trace",
content: line,
traces: [line],
createdAt: 1,
}]}
isTurnStreaming={false}
hasBodyBelow
/>,
);
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(
<AgentActivityCluster
@ -440,7 +676,6 @@ describe("AgentActivityCluster", () => {
);
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();
});

View File

@ -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,

View File

@ -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();

View File

@ -12,7 +12,6 @@ const SETTINGS_NAV_KEYS = [
"overview",
"appearance",
"models",
"providers",
"image",
"web",
"runtime",

View File

@ -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(<MessageBubble message={message} mcpPresets={MCP_PRESETS} />);
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", {

View File

@ -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",

View File

@ -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");
});
});

View File

@ -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(
<SessionSearchDialog
open
sessions={[session(1)]}
activeKey={null}
loading={false}
onOpenChange={() => {}}
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(
<SessionSearchDialog
open
sessions={Array.from({ length: 24 }, (_, index) => 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",
});
});
});

View File

@ -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", () => {
<ThreadComposer
onSend={vi.fn()}
modelLabel="gpt-4o"
modelProvider="openai"
modelProviderLabel="OpenAI"
placeholder="Type your message..."
/>,
);
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(
<ThreadComposer
onSend={onSend}
placeholder="Type your message..."
cliApps={CLI_APPS}
mcpPresets={MCP_PRESETS}
/>,
);
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(
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
cliApps={CLI_APPS}
mcpPresets={MCP_PRESETS}
/>,
);
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(
<ThreadComposer

View File

@ -1010,7 +1010,7 @@ describe("ThreadShell", () => {
));
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();
});
});