mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-13 06:14:02 +00:00
feat(mcp): add preset setup and capability mentions
This commit is contained in:
parent
8be258212e
commit
704ac558f6
1
.gitignore
vendored
1
.gitignore
vendored
@ -98,3 +98,4 @@ tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
exp/
|
||||
.playwright-mcp/
|
||||
|
||||
@ -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."""
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -30,6 +30,7 @@ from websockets.exceptions import ConnectionClosed
|
||||
from websockets.http11 import Request as WsRequest
|
||||
from websockets.http11 import Response
|
||||
|
||||
from nanobot.agent.tools.mcp import request_mcp_reload
|
||||
from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
@ -46,6 +47,7 @@ from nanobot.utils.media_decode import (
|
||||
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
|
||||
from nanobot.webui.settings_api import (
|
||||
WebUISettingsError,
|
||||
create_model_configuration,
|
||||
settings_payload,
|
||||
update_agent_settings,
|
||||
update_image_generation_settings,
|
||||
@ -57,12 +59,32 @@ from nanobot.webui.cli_apps_api import (
|
||||
cli_apps_payload,
|
||||
normalize_cli_app_mentions,
|
||||
)
|
||||
from nanobot.webui.mcp_presets_api import (
|
||||
mcp_presets_settings_action,
|
||||
normalize_mcp_preset_mentions,
|
||||
)
|
||||
from nanobot.webui.sidebar_state import (
|
||||
read_webui_sidebar_state,
|
||||
write_webui_sidebar_state,
|
||||
)
|
||||
from nanobot.webui.thread_disk import delete_webui_thread
|
||||
from nanobot.webui.transcript import append_transcript_object, build_webui_thread_response
|
||||
from nanobot.webui.transcript import (
|
||||
append_transcript_object,
|
||||
build_webui_thread_response,
|
||||
rewrite_local_markdown_images,
|
||||
)
|
||||
|
||||
_MCP_PRESET_ACTIONS_BY_PATH = {
|
||||
"/api/settings/mcp-presets/enable": "enable",
|
||||
"/api/settings/mcp-presets/remove": "remove",
|
||||
"/api/settings/mcp-presets/test": "test",
|
||||
"/api/settings/mcp-presets/custom": "custom",
|
||||
"/api/settings/mcp-presets/import": "import",
|
||||
"/api/settings/mcp-presets/import-cursor": "import-cursor",
|
||||
"/api/settings/mcp-presets/tools": "tools",
|
||||
}
|
||||
_MCP_VALUES_HEADER = "X-Nanobot-MCP-Values"
|
||||
_MCP_VALUES_HEADER_MAX_BYTES = 64 * 1024
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.session.manager import SessionManager
|
||||
@ -233,6 +255,34 @@ def _parse_query(path_with_query: str) -> dict[str, list[str]]:
|
||||
return _parse_request_path(path_with_query)[1]
|
||||
|
||||
|
||||
def _parse_mcp_settings_query(request: WsRequest) -> dict[str, list[str]]:
|
||||
query = _parse_query(request.path)
|
||||
raw = request.headers.get(_MCP_VALUES_HEADER)
|
||||
if not raw:
|
||||
return query
|
||||
if len(raw.encode("utf-8")) > _MCP_VALUES_HEADER_MAX_BYTES:
|
||||
raise WebUISettingsError("MCP settings payload is too large")
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise WebUISettingsError("invalid MCP settings payload") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise WebUISettingsError("MCP settings payload must be a JSON object")
|
||||
merged = {key: list(values) for key, values in query.items()}
|
||||
for key, value in payload.items():
|
||||
if not isinstance(key, str) or not key:
|
||||
raise WebUISettingsError("MCP settings payload contains an invalid key")
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
else:
|
||||
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||
if text:
|
||||
merged[key] = [text]
|
||||
return merged
|
||||
|
||||
|
||||
def _query_first(query: dict[str, list[str]], key: str) -> str | None:
|
||||
"""Return the first value for *key*, or None."""
|
||||
values = query.get(key)
|
||||
@ -425,18 +475,6 @@ _MEDIA_ALLOWED_MIMES: frozenset[str] = frozenset({
|
||||
"video/webm",
|
||||
"video/quicktime",
|
||||
})
|
||||
_MARKDOWN_LOCAL_IMAGE_RE = re.compile(
|
||||
r"!\[([^\]]*)\]\((<[^>]+>|[^)\s]+)(\s+(?:\"[^\"]*\"|'[^']*'))?\)"
|
||||
)
|
||||
_INLINE_MARKDOWN_IMAGE_EXTS: frozenset[str] = frozenset({
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".webp",
|
||||
".gif",
|
||||
})
|
||||
|
||||
|
||||
def _issue_route_secret_matches(headers: Any, configured_secret: str) -> bool:
|
||||
"""Return True if the token-issue HTTP request carries credentials matching ``token_issue_secret``."""
|
||||
if not configured_secret:
|
||||
@ -666,6 +704,9 @@ class WebSocketChannel(BaseChannel):
|
||||
if got == "/api/settings/update":
|
||||
return self._handle_settings_update(request)
|
||||
|
||||
if got == "/api/settings/model-configurations/create":
|
||||
return self._handle_settings_model_configuration_create(request)
|
||||
|
||||
if got == "/api/settings/provider/update":
|
||||
return self._handle_settings_provider_update(request)
|
||||
|
||||
@ -690,6 +731,13 @@ class WebSocketChannel(BaseChannel):
|
||||
if got == "/api/settings/cli-apps/test":
|
||||
return await self._handle_settings_cli_apps_action(request, "test")
|
||||
|
||||
if got == "/api/settings/mcp-presets":
|
||||
return await self._handle_settings_mcp_presets(request)
|
||||
|
||||
mcp_action = _MCP_PRESET_ACTIONS_BY_PATH.get(got)
|
||||
if mcp_action is not None:
|
||||
return await self._handle_settings_mcp_presets(request, mcp_action)
|
||||
|
||||
m = re.match(r"^/api/sessions/([^/]+)/messages$", got)
|
||||
if m:
|
||||
return self._handle_session_messages(request, m.group(1))
|
||||
@ -881,6 +929,16 @@ class WebSocketChannel(BaseChannel):
|
||||
self._with_settings_restart_state(payload, section="runtime")
|
||||
)
|
||||
|
||||
def _handle_settings_model_configuration_create(self, request: WsRequest) -> Response:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
query = _parse_query(request.path)
|
||||
try:
|
||||
payload = create_model_configuration(query)
|
||||
except WebUISettingsError as e:
|
||||
return _http_error(e.status, e.message)
|
||||
return _http_json_response(self._with_settings_restart_state(payload))
|
||||
|
||||
def _handle_settings_provider_update(self, request: WsRequest) -> Response:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
@ -937,6 +995,31 @@ class WebSocketChannel(BaseChannel):
|
||||
return _http_error(status, message)
|
||||
return _http_json_response(payload)
|
||||
|
||||
async def _handle_settings_mcp_presets(
|
||||
self,
|
||||
request: WsRequest,
|
||||
action: str | None = None,
|
||||
) -> Response:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
try:
|
||||
payload = await mcp_presets_settings_action(
|
||||
action,
|
||||
_parse_mcp_settings_query(request),
|
||||
reload_mcp=lambda: request_mcp_reload(self.bus),
|
||||
)
|
||||
except Exception as e:
|
||||
status = getattr(e, "status", 500)
|
||||
message = getattr(e, "message", str(e))
|
||||
if status >= 500:
|
||||
self.logger.exception("MCP preset action '{}' failed", action or "list")
|
||||
return _http_error(status, message)
|
||||
if action is None:
|
||||
return _http_json_response(payload)
|
||||
return _http_json_response(
|
||||
self._with_settings_restart_state(payload, section="runtime")
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_websocket_channel_session_key(key: str) -> bool:
|
||||
"""True when *key* is a ``websocket:…`` session exposed on this HTTP surface."""
|
||||
@ -1028,6 +1111,9 @@ class WebSocketChannel(BaseChannel):
|
||||
cli_apps = meta.get("cli_apps")
|
||||
if isinstance(cli_apps, list) and cli_apps:
|
||||
user_obj["cli_apps"] = cli_apps
|
||||
mcp_presets = meta.get("mcp_presets")
|
||||
if isinstance(mcp_presets, list) and mcp_presets:
|
||||
user_obj["mcp_presets"] = mcp_presets
|
||||
self._try_append_webui_transcript(chat_id, user_obj)
|
||||
await super()._handle_message(
|
||||
sender_id,
|
||||
@ -1117,45 +1203,12 @@ class WebSocketChannel(BaseChannel):
|
||||
return None
|
||||
return {"url": signed, "name": path.name}
|
||||
|
||||
def _markdown_image_url_for_local_path(self, raw_url: str) -> str | None:
|
||||
url = raw_url.strip()
|
||||
if url.startswith("<") and url.endswith(">"):
|
||||
url = url[1:-1].strip()
|
||||
if not url or url.startswith(("/api/media/", "#")):
|
||||
return None
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme or parsed.netloc:
|
||||
return None
|
||||
if parsed.query or parsed.fragment:
|
||||
return None
|
||||
path_text = unquote(url)
|
||||
if Path(path_text).suffix.lower() not in _INLINE_MARKDOWN_IMAGE_EXTS:
|
||||
return None
|
||||
candidate = Path(path_text).expanduser()
|
||||
if not candidate.is_absolute():
|
||||
candidate = self._workspace_path / candidate
|
||||
try:
|
||||
resolved = candidate.resolve(strict=False)
|
||||
resolved.relative_to(self._workspace_path)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if not resolved.is_file():
|
||||
return None
|
||||
signed = self._sign_or_stage_media_path(resolved)
|
||||
return signed["url"] if signed else None
|
||||
|
||||
def _rewrite_local_markdown_images(self, text: str) -> str:
|
||||
if "![" not in text:
|
||||
return text
|
||||
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
signed_url = self._markdown_image_url_for_local_path(match.group(2))
|
||||
if not signed_url:
|
||||
return match.group(0)
|
||||
title = match.group(3) or ""
|
||||
return f""
|
||||
|
||||
return _MARKDOWN_LOCAL_IMAGE_RE.sub(replace, text)
|
||||
return rewrite_local_markdown_images(
|
||||
text,
|
||||
workspace_path=self._workspace_path,
|
||||
sign_path=self._sign_or_stage_media_path,
|
||||
)
|
||||
|
||||
def _handle_media_fetch(self, sig: str, payload: str) -> Response:
|
||||
"""Serve a single media file previously signed via
|
||||
@ -1531,6 +1584,9 @@ class WebSocketChannel(BaseChannel):
|
||||
cli_apps = normalize_cli_app_mentions(envelope.get("cli_apps"))
|
||||
if cli_apps:
|
||||
metadata["cli_apps"] = cli_apps
|
||||
mcp_presets = normalize_mcp_preset_mentions(envelope.get("mcp_presets"))
|
||||
if mcp_presets:
|
||||
metadata["mcp_presets"] = mcp_presets
|
||||
image_generation = envelope.get("image_generation")
|
||||
if isinstance(image_generation, dict) and image_generation.get("enabled") is True:
|
||||
aspect_ratio = image_generation.get("aspect_ratio")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
1172
nanobot/webui/mcp_presets_api.py
Normal file
1172
nanobot/webui/mcp_presets_api.py
Normal file
File diff suppressed because it is too large
Load Diff
5
nanobot/webui/mcp_presets_runtime.py
Normal file
5
nanobot/webui/mcp_presets_runtime.py
Normal 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"]
|
||||
@ -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:
|
||||
|
||||
@ -4,10 +4,12 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from typing import Any, Callable, Mapping
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@ -16,6 +18,61 @@ from nanobot.session.manager import SessionManager
|
||||
|
||||
WEBUI_TRANSCRIPT_SCHEMA_VERSION = 3
|
||||
_MAX_TRANSCRIPT_FILE_BYTES = 8 * 1024 * 1024
|
||||
_MARKDOWN_LOCAL_IMAGE_RE = re.compile(
|
||||
r"!\[([^\]]*)\]\((<[^>]+>|[^)\s]+)(\s+(?:\"[^\"]*\"|'[^']*'))?\)"
|
||||
)
|
||||
_INLINE_MARKDOWN_IMAGE_EXTS: frozenset[str] = frozenset({
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".webp",
|
||||
".gif",
|
||||
})
|
||||
|
||||
|
||||
def rewrite_local_markdown_images(
|
||||
text: str,
|
||||
*,
|
||||
workspace_path: Path,
|
||||
sign_path: Callable[[Path], Mapping[str, Any] | None],
|
||||
) -> str:
|
||||
"""Rewrite markdown image paths inside the workspace to signed WebUI media URLs."""
|
||||
if "![" not in text:
|
||||
return text
|
||||
|
||||
def resolve_url(raw_url: str) -> str | None:
|
||||
url = raw_url.strip()
|
||||
if url.startswith("<") and url.endswith(">"):
|
||||
url = url[1:-1].strip()
|
||||
if not url or url.startswith(("/api/media/", "#")):
|
||||
return None
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme or parsed.netloc or parsed.query or parsed.fragment:
|
||||
return None
|
||||
path_text = unquote(url)
|
||||
if Path(path_text).suffix.lower() not in _INLINE_MARKDOWN_IMAGE_EXTS:
|
||||
return None
|
||||
candidate = Path(path_text).expanduser()
|
||||
if not candidate.is_absolute():
|
||||
candidate = workspace_path / candidate
|
||||
try:
|
||||
resolved = candidate.resolve(strict=False)
|
||||
resolved.relative_to(workspace_path)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if not resolved.is_file():
|
||||
return None
|
||||
signed = sign_path(resolved)
|
||||
return str(signed.get("url")) if signed and signed.get("url") else None
|
||||
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
signed_url = resolve_url(match.group(2))
|
||||
if not signed_url:
|
||||
return match.group(0)
|
||||
title = match.group(3) or ""
|
||||
return f""
|
||||
|
||||
return _MARKDOWN_LOCAL_IMAGE_RE.sub(replace, text)
|
||||
|
||||
|
||||
def webui_transcript_path(session_key: str) -> Path:
|
||||
@ -458,6 +515,11 @@ def replay_transcript_to_ui_messages(
|
||||
cli_apps = rec.get("cli_apps")
|
||||
if isinstance(cli_apps, list) and cli_apps:
|
||||
row["cliApps"] = [dict(app) for app in cli_apps if isinstance(app, dict)]
|
||||
mcp_presets = rec.get("mcp_presets")
|
||||
if isinstance(mcp_presets, list) and mcp_presets:
|
||||
row["mcpPresets"] = [
|
||||
dict(preset) for preset in mcp_presets if isinstance(preset, dict)
|
||||
]
|
||||
messages.append(row)
|
||||
continue
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
363
tests/webui/test_mcp_presets_api.py
Normal file
363
tests/webui/test_mcp_presets_api.py
Normal 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"}]
|
||||
80
tests/webui/test_mcp_presets_runtime.py
Normal file
80
tests/webui/test_mcp_presets_runtime.py
Normal 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"}]}
|
||||
67
tests/webui/test_settings_api.py
Normal file
67
tests/webui/test_settings_api.py
Normal 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"],
|
||||
}
|
||||
)
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 } : {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -81,7 +81,9 @@
|
||||
"image": "Image",
|
||||
"web": "Web",
|
||||
"runtime": "Runtime",
|
||||
"advanced": "Advanced"
|
||||
"advanced": "Advanced",
|
||||
"cliApps": "Apps CLI",
|
||||
"mcp": "MCP"
|
||||
},
|
||||
"sections": {
|
||||
"interface": "Interface",
|
||||
@ -97,7 +99,9 @@
|
||||
"identity": "Identity",
|
||||
"safety": "Safety",
|
||||
"capabilities": "Capacités",
|
||||
"integrations": "Integrations"
|
||||
"integrations": "Integrations",
|
||||
"cliApps": "Apps CLI",
|
||||
"mcp": "MCP"
|
||||
},
|
||||
"rows": {
|
||||
"theme": "Thème",
|
||||
@ -141,7 +145,11 @@
|
||||
"ssrfWhitelist": "SSRF whitelist",
|
||||
"mcpServers": "MCP servers",
|
||||
"pathAppend": "PATH append",
|
||||
"configurationDocs": "Configuration docs"
|
||||
"configurationDocs": "Configuration docs",
|
||||
"currentModel": "Modèle actuel",
|
||||
"brandLogos": "Logos de marque",
|
||||
"cliAppsCatalog": "Catalogue d'apps CLI",
|
||||
"cliAppsFilter": "Filtre des apps CLI"
|
||||
},
|
||||
"help": {
|
||||
"theme": "Basculer entre les apparences claire et sombre.",
|
||||
@ -168,7 +176,13 @@
|
||||
"botIcon": "Short emoji or text shown beside the bot name.",
|
||||
"timezone": "IANA timezone used by runtime context and schedules.",
|
||||
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
|
||||
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
|
||||
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.",
|
||||
"currentModel": "Choisissez le modèle que nanobot utilisera pour les prochaines réponses.",
|
||||
"selectedModelProvider": "Défini par le modèle sélectionné.",
|
||||
"selectedModelValue": "Défini par le modèle sélectionné.",
|
||||
"brandLogos": "Les logos sont chargés depuis les domaines des marques avec une icône locale en secours.",
|
||||
"cliAppsCatalog": "Parcourez les CLIs d'apps que nanobot peut exécuter localement.",
|
||||
"cliAppsFilter": "Recherchez par app, catégorie ou capacité."
|
||||
},
|
||||
"values": {
|
||||
"light": "Clair",
|
||||
@ -185,9 +199,7 @@
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"configured": "Configured",
|
||||
"notConfigured": "Not configured",
|
||||
"restartRequired": "Restart required",
|
||||
"liveReload": "Live reload ready"
|
||||
"notConfigured": "Not configured"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Chargement des paramètres...",
|
||||
@ -259,7 +271,8 @@
|
||||
},
|
||||
"providers": {
|
||||
"searchPlaceholder": "Search providers",
|
||||
"noMatches": "No providers match this search."
|
||||
"noMatches": "No providers match this search.",
|
||||
"saveProvider": "Enregistrer le fournisseur"
|
||||
},
|
||||
"image": {
|
||||
"selectProvider": "Sélectionner un fournisseur",
|
||||
@ -267,6 +280,106 @@
|
||||
"selectSize": "Sélectionner une taille",
|
||||
"configureProvider": "Configurer le fournisseur",
|
||||
"missingCredential": "Configurez ce fournisseur avant d’activer la génération d’images."
|
||||
},
|
||||
"models": {
|
||||
"selectModel": "Sélectionner un modèle",
|
||||
"addConfiguration": "Ajouter une configuration",
|
||||
"newConfiguration": "Nouvelle configuration de modèle",
|
||||
"newConfigurationHelp": "Enregistrez un fournisseur et un modèle comme option en un clic.",
|
||||
"configurationName": "Nom",
|
||||
"configurationNamePlaceholder": "Rédaction rapide"
|
||||
},
|
||||
"timezone": {
|
||||
"select": "Sélectionner un fuseau horaire",
|
||||
"search": "Rechercher un fuseau horaire",
|
||||
"empty": "Aucun fuseau horaire correspondant."
|
||||
},
|
||||
"cliApps": {
|
||||
"allCategories": "Toutes les catégories",
|
||||
"availableCount": "{{count}} apps",
|
||||
"installedCount": "{{count}} installées",
|
||||
"summary": "{{installed}} CLIs installées sur {{total}}",
|
||||
"filterAll": "Tout",
|
||||
"filterInstalled": "CLIs installées",
|
||||
"filterNotInstalled": "Non installées",
|
||||
"searchPlaceholder": "Rechercher des CLIs",
|
||||
"statusInstalled": "Installée",
|
||||
"statusAvailable": "Disponible",
|
||||
"statusMissing": "Dépendance manquante",
|
||||
"statusUnsupported": "Non compatible",
|
||||
"statusNotInstalled": "Non installée",
|
||||
"unsupported": "Non compatible",
|
||||
"loading": "Chargement des apps CLI...",
|
||||
"empty": "Aucune app CLI ne correspond à ce filtre.",
|
||||
"readyTitle": "@{{name}} est prêt",
|
||||
"readyStatus": "Prêt",
|
||||
"readyPrompt": "Utilisez @{{name}} pour voir ce que ce CLI peut faire.",
|
||||
"readyTry": "Essayer @{{name}}",
|
||||
"readyCopied": "Copié",
|
||||
"openChat": "Ouvrir le chat",
|
||||
"requires": "Requiert",
|
||||
"test": "Tester le CLI",
|
||||
"update": "Mettre à jour le CLI",
|
||||
"uninstall": "Désinstaller le CLI",
|
||||
"install": "Installer le CLI",
|
||||
"unavailable": "Indisponible",
|
||||
"noDescription": "Aucune description disponible."
|
||||
},
|
||||
"mcp": {
|
||||
"allCategories": "Toutes les catégories",
|
||||
"summary": "{{installed}} presets activés sur {{total}}",
|
||||
"filterAll": "Tout",
|
||||
"filterInstalled": "Activés",
|
||||
"filterNotInstalled": "Non activés",
|
||||
"searchPlaceholder": "Rechercher des presets MCP",
|
||||
"moreOptions": "Plus d'options MCP",
|
||||
"moreOptionsSubtitle": "Ajoutez un serveur personnalisé ou importez mcp.json.",
|
||||
"customTitle": "MCP personnalisé",
|
||||
"customSubtitle": "Ajoutez n'importe quel serveur MCP stdio, HTTP ou SSE.",
|
||||
"customAction": "Personnalisé",
|
||||
"importAction": "Importer",
|
||||
"serverName": "Nom du serveur",
|
||||
"serverUrl": "URL",
|
||||
"transport": "Transport",
|
||||
"command": "Commande",
|
||||
"args": "Args JSON",
|
||||
"headers": "Headers JSON",
|
||||
"env": "Env JSON",
|
||||
"timeout": "Délai d'outil",
|
||||
"advancedOptions": "Options avancées",
|
||||
"hideAdvanced": "Masquer les options avancées",
|
||||
"saveCustom": "Enregistrer MCP",
|
||||
"configImport": "Importer mcp.json",
|
||||
"importConfig": "Importer",
|
||||
"restartRequired": "Redémarrez nanobot pour connecter les outils MCP mis à jour.",
|
||||
"toolsFound": "{{count}} outils",
|
||||
"loading": "Chargement des presets MCP...",
|
||||
"empty": "Aucun preset MCP ne correspond à ce filtre.",
|
||||
"openDocs": "Ouvrir la doc",
|
||||
"test": "Tester",
|
||||
"remove": "Supprimer",
|
||||
"enable": "Activer",
|
||||
"enabled": "Activé",
|
||||
"setup": "Connecter",
|
||||
"configure": "Connecter",
|
||||
"connectTitle": "Connecter {{name}}",
|
||||
"connectHint": "Ajoutez la clé depuis les paramètres de votre compte.",
|
||||
"saveAndEnable": "Enregistrer et activer",
|
||||
"updateSetup": "Mettre à jour la configuration",
|
||||
"configured": "configuré",
|
||||
"keepExisting": "Laissez vide pour conserver la valeur actuelle",
|
||||
"statusConfigured": "Configuré",
|
||||
"statusMissingCredentials": "Clé requise",
|
||||
"statusMissingDependency": "Dépendance requise",
|
||||
"statusComingSoon": "Bientôt disponible",
|
||||
"statusNotInstalled": "Non activé",
|
||||
"toolScope": "Outils",
|
||||
"allTools": "Tous",
|
||||
"noTools": "Aucun",
|
||||
"testForTools": "Exécutez Tester pour inspecter et choisir des outils individuels."
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "Les noms, logos et marques de produits appartiennent à leurs propriétaires respectifs. Leur utilisation sert uniquement à l'identification et n'implique aucune approbation."
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
@ -370,8 +483,7 @@
|
||||
"title": "Modifier une image",
|
||||
"prompt": "Aidez-moi à modifier une image. Demandez-moi d’abord de téléverser ou d’indiquer l’image, puis générez le résultat modifié."
|
||||
}
|
||||
},
|
||||
"description": "Posez des questions, poursuivez votre travail local ou démarrez un nouveau fil."
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"toggleSidebar": "Afficher ou masquer la barre latérale",
|
||||
@ -475,6 +587,16 @@
|
||||
"decode_failed": "Impossible de décoder cette image",
|
||||
"too_large": "Image trop grande — essayez-en une plus petite",
|
||||
"io": "Impossible de lire ce fichier"
|
||||
},
|
||||
"mentions": {
|
||||
"ariaLabel": "Apps et MCP",
|
||||
"label": "Plugins",
|
||||
"cliGroup": "Apps CLI",
|
||||
"mcpGroup": "Serveurs MCP",
|
||||
"cliBadge": "CLI",
|
||||
"mcpBadge": "MCP",
|
||||
"cliDescription": "Utiliser @{{name}} comme app CLI locale",
|
||||
"mcpDescription": "Utiliser @{{name}} comme serveur MCP"
|
||||
}
|
||||
},
|
||||
"scrollToBottom": "Faire défiler vers le bas",
|
||||
@ -499,7 +621,18 @@
|
||||
"imageAttachment": "Pièce jointe image",
|
||||
"copyReply": "Copier la réponse",
|
||||
"copiedReply": "Réponse copiée",
|
||||
"turnLatencyTitle": "Temps de réponse (de bout en bout)"
|
||||
"turnLatencyTitle": "Temps de réponse (de bout en bout)",
|
||||
"activityThinkingFor": "Réflexion pendant {{duration}}",
|
||||
"activityThoughtFor": "Réflexion terminée en {{duration}}",
|
||||
"cliActivityRunningOne": "Utilisation de @{{name}}",
|
||||
"cliActivityRanOne": "@{{name}} utilisé",
|
||||
"cliActivityFailedOne": "Échec de @{{name}}",
|
||||
"cliActivityRunningMany": "Utilisation de {{count}} apps CLI",
|
||||
"cliActivityRanMany": "{{count}} apps CLI utilisées",
|
||||
"cliActivityFailedMany": "Échec de {{count}} apps CLI",
|
||||
"cliRunRunning": "Utilisation",
|
||||
"cliRunRan": "Utilisé",
|
||||
"cliRunFailed": "Échec"
|
||||
},
|
||||
"lightbox": {
|
||||
"title": "Aperçu de l’image",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "画像プレビュー",
|
||||
|
||||
@ -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": "이미지 미리보기",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "已复制回复",
|
||||
|
||||
@ -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": "圖片預覽",
|
||||
|
||||
@ -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,
|
||||
|
||||
20
webui/src/lib/mcp-preset-events.ts
Normal file
20
webui/src/lib/mcp-preset-events.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
@ -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);
|
||||
|
||||
188
webui/src/lib/provider-brand.ts
Normal file
188
webui/src/lib/provider-brand.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -12,7 +12,6 @@ const SETTINGS_NAV_KEYS = [
|
||||
"overview",
|
||||
"appearance",
|
||||
"models",
|
||||
"providers",
|
||||
"image",
|
||||
"web",
|
||||
"runtime",
|
||||
|
||||
@ -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", {
|
||||
|
||||
@ -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",
|
||||
|
||||
37
webui/src/tests/provider-brand.test.ts
Normal file
37
webui/src/tests/provider-brand.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
70
webui/src/tests/session-search-dialog.test.tsx
Normal file
70
webui/src/tests/session-search-dialog.test.tsx
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user