mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-23 18:12:32 +00:00
feat: add CLI Apps settings MVP
This commit is contained in:
parent
a5a956d9af
commit
e2d00ffc8f
@ -156,9 +156,14 @@ class ContextBuilder:
|
||||
sender_id: str | None = None,
|
||||
session_summary: str | None = None,
|
||||
session_metadata: Mapping[str, Any] | None = None,
|
||||
current_runtime_lines: Sequence[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build the complete message list for an LLM call."""
|
||||
extra = goal_state_runtime_lines(session_metadata)
|
||||
extra = [
|
||||
*goal_state_runtime_lines(session_metadata),
|
||||
]
|
||||
if current_runtime_lines:
|
||||
extra.extend(line for line in current_runtime_lines if line)
|
||||
runtime_ctx = self._build_runtime_context(
|
||||
channel,
|
||||
chat_id,
|
||||
|
||||
@ -28,6 +28,7 @@ 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
|
||||
@ -59,7 +60,6 @@ if TYPE_CHECKING:
|
||||
|
||||
UNIFIED_SESSION_KEY = "unified:default"
|
||||
|
||||
|
||||
class TurnState(Enum):
|
||||
RESTORE = auto()
|
||||
COMPACT = auto()
|
||||
@ -568,7 +568,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 {}
|
||||
extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | cli_app_utils.session_extra(msg.metadata)
|
||||
extra.update(kwargs)
|
||||
text = msg.content if isinstance(msg.content, str) else ""
|
||||
session.add_message("user", text, **extra)
|
||||
@ -593,7 +593,7 @@ class AgentLoop:
|
||||
chat_id=self._runtime_chat_id(msg),
|
||||
sender_id=msg.sender_id,
|
||||
session_summary=pending_summary,
|
||||
session_metadata=session.metadata,
|
||||
session_metadata=session.metadata, current_runtime_lines=cli_app_utils.runtime_lines(msg, self.context.workspace),
|
||||
)
|
||||
|
||||
async def _dispatch_command_inline(
|
||||
@ -1058,7 +1058,7 @@ class AgentLoop:
|
||||
current_role=current_role,
|
||||
sender_id=msg.sender_id,
|
||||
session_summary=pending,
|
||||
session_metadata=session.metadata,
|
||||
session_metadata=session.metadata, current_runtime_lines=cli_app_utils.runtime_lines(msg, self.context.workspace, skip=is_subagent),
|
||||
)
|
||||
t_wall = time.time()
|
||||
final_content, _, all_msgs, stop_reason, _ = await self._run_agent_loop(
|
||||
|
||||
127
nanobot/agent/tools/cli_apps.py
Normal file
127
nanobot/agent/tools/cli_apps.py
Normal file
@ -0,0 +1,127 @@
|
||||
"""Controlled runner for installed CLI Apps."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||
from nanobot.agent.tools.schema import ArraySchema, BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema
|
||||
from nanobot.cli_apps import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
||||
from nanobot.config.schema import Base
|
||||
|
||||
|
||||
class CliAppsToolConfig(Base):
|
||||
"""CLI Apps tool configuration."""
|
||||
|
||||
enable: bool = True
|
||||
install_timeout: int = Field(default=300, ge=1, le=3600)
|
||||
run_timeout: int = Field(default=60, ge=1, le=600)
|
||||
catalog_ttl_seconds: int = Field(default=3600, ge=60, le=86_400)
|
||||
|
||||
|
||||
@tool_parameters(
|
||||
tool_parameters_schema(
|
||||
required=["name"],
|
||||
name=StringSchema("Installed CLI app registry name, for example gimp, safari, or obsidian."),
|
||||
args=ArraySchema(
|
||||
StringSchema("One command-line argument."),
|
||||
description="Arguments to pass to the CLI entry point. Do not include the entry point itself.",
|
||||
nullable=True,
|
||||
),
|
||||
json=BooleanSchema(
|
||||
description="Whether to prepend --json when supported by the CLI.",
|
||||
default=False,
|
||||
nullable=True,
|
||||
),
|
||||
working_dir=StringSchema("Optional working directory for the CLI call.", nullable=True),
|
||||
timeout=IntegerSchema(
|
||||
description="Timeout in seconds for this CLI call.",
|
||||
minimum=1,
|
||||
maximum=600,
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
class CliAppsTool(Tool):
|
||||
"""Run an installed CLI-Anything or public CLI app through a controlled argv subprocess."""
|
||||
|
||||
config_key = "cli_apps"
|
||||
_scopes = {"core", "subagent"}
|
||||
|
||||
@classmethod
|
||||
def config_cls(cls):
|
||||
return CliAppsToolConfig
|
||||
|
||||
@classmethod
|
||||
def enabled(cls, ctx: Any) -> bool:
|
||||
return ctx.config.cli_apps.enable
|
||||
|
||||
@classmethod
|
||||
def create(cls, ctx: Any) -> Tool:
|
||||
cfg = ctx.config.cli_apps
|
||||
return cls(
|
||||
workspace=Path(ctx.workspace),
|
||||
restrict_to_workspace=ctx.config.restrict_to_workspace,
|
||||
runtime=CliAppsRuntimeConfig(
|
||||
install_timeout=cfg.install_timeout,
|
||||
run_timeout=cfg.run_timeout,
|
||||
catalog_ttl_seconds=cfg.catalog_ttl_seconds,
|
||||
),
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
workspace: Path,
|
||||
restrict_to_workspace: bool = False,
|
||||
runtime: CliAppsRuntimeConfig | None = None,
|
||||
) -> None:
|
||||
self.workspace = workspace
|
||||
self.restrict_to_workspace = restrict_to_workspace
|
||||
self.runtime = runtime or CliAppsRuntimeConfig()
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "run_cli_app"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
try:
|
||||
installed = CliAppManager(workspace=self.workspace, runtime=self.runtime).installed_names()
|
||||
except Exception:
|
||||
installed = []
|
||||
installed_note = (
|
||||
f" Installed Settings CLI Apps: {', '.join(installed)}."
|
||||
if installed
|
||||
else " No Settings CLI Apps are currently installed."
|
||||
)
|
||||
return (
|
||||
"Run a CLI App that the user explicitly installed in Settings or attached as @app. "
|
||||
"Do not use this for ordinary system CLIs such as git, gh, python, npm, or brew; "
|
||||
"unknown names are rejected. Execution uses argv, not shell."
|
||||
+ installed_note
|
||||
)
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
name: str,
|
||||
args: list[str] | None = None,
|
||||
json: bool | None = False,
|
||||
working_dir: str | None = None,
|
||||
timeout: int | None = None,
|
||||
) -> str:
|
||||
manager = CliAppManager(workspace=self.workspace, runtime=self.runtime)
|
||||
try:
|
||||
return manager.run(
|
||||
name,
|
||||
args=args or [],
|
||||
json_output=bool(json),
|
||||
working_dir=working_dir,
|
||||
timeout=timeout,
|
||||
restrict_to_workspace=self.restrict_to_workspace,
|
||||
)
|
||||
except CliAppError as exc:
|
||||
return f"Error: {exc.message}"
|
||||
@ -52,6 +52,11 @@ from nanobot.webui.settings_api import (
|
||||
update_provider_settings,
|
||||
update_web_search_settings,
|
||||
)
|
||||
from nanobot.webui.cli_apps_api import (
|
||||
cli_apps_action,
|
||||
cli_apps_payload,
|
||||
normalize_cli_app_mentions,
|
||||
)
|
||||
from nanobot.webui.sidebar_state import (
|
||||
read_webui_sidebar_state,
|
||||
write_webui_sidebar_state,
|
||||
@ -653,6 +658,21 @@ class WebSocketChannel(BaseChannel):
|
||||
if got == "/api/settings/image-generation/update":
|
||||
return self._handle_settings_image_generation_update(request)
|
||||
|
||||
if got == "/api/settings/cli-apps":
|
||||
return self._handle_settings_cli_apps(request)
|
||||
|
||||
if got == "/api/settings/cli-apps/install":
|
||||
return await self._handle_settings_cli_apps_action(request, "install")
|
||||
|
||||
if got == "/api/settings/cli-apps/update":
|
||||
return await self._handle_settings_cli_apps_action(request, "update")
|
||||
|
||||
if got == "/api/settings/cli-apps/uninstall":
|
||||
return await self._handle_settings_cli_apps_action(request, "uninstall")
|
||||
|
||||
if got == "/api/settings/cli-apps/test":
|
||||
return await self._handle_settings_cli_apps_action(request, "test")
|
||||
|
||||
m = re.match(r"^/api/sessions/([^/]+)/messages$", got)
|
||||
if m:
|
||||
return self._handle_session_messages(request, m.group(1))
|
||||
@ -874,6 +894,32 @@ class WebSocketChannel(BaseChannel):
|
||||
return _http_error(e.status, e.message)
|
||||
return _http_json_response(self._with_settings_restart_state(payload, section="image"))
|
||||
|
||||
def _handle_settings_cli_apps(self, request: WsRequest) -> Response:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
try:
|
||||
payload = cli_apps_payload()
|
||||
except Exception:
|
||||
self.logger.exception("failed to load CLI Apps payload")
|
||||
return _http_error(500, "failed to load CLI Apps")
|
||||
return _http_json_response(payload)
|
||||
|
||||
async def _handle_settings_cli_apps_action(self, request: WsRequest, action: str) -> Response:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
query = _parse_query(request.path)
|
||||
try:
|
||||
payload = await asyncio.to_thread(cli_apps_action, action, query)
|
||||
except WebUISettingsError as e:
|
||||
return _http_error(e.status, e.message)
|
||||
except Exception as e:
|
||||
status = getattr(e, "status", 500)
|
||||
message = getattr(e, "message", str(e))
|
||||
if status >= 500:
|
||||
self.logger.exception("CLI Apps action '{}' failed", action)
|
||||
return _http_error(status, message)
|
||||
return _http_json_response(payload)
|
||||
|
||||
@staticmethod
|
||||
def _is_websocket_channel_session_key(key: str) -> bool:
|
||||
"""True when *key* is a ``websocket:…`` session exposed on this HTTP surface."""
|
||||
@ -961,6 +1007,9 @@ class WebSocketChannel(BaseChannel):
|
||||
}
|
||||
if media:
|
||||
user_obj["media_paths"] = list(media)
|
||||
cli_apps = meta.get("cli_apps")
|
||||
if isinstance(cli_apps, list) and cli_apps:
|
||||
user_obj["cli_apps"] = cli_apps
|
||||
self._try_append_webui_transcript(chat_id, user_obj)
|
||||
await super()._handle_message(
|
||||
sender_id,
|
||||
@ -1421,6 +1470,9 @@ class WebSocketChannel(BaseChannel):
|
||||
metadata: dict[str, Any] = {"remote": getattr(connection, "remote_address", None)}
|
||||
if envelope.get("webui") is True:
|
||||
metadata["webui"] = True
|
||||
cli_apps = normalize_cli_app_mentions(envelope.get("cli_apps"))
|
||||
if cli_apps:
|
||||
metadata["cli_apps"] = cli_apps
|
||||
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")
|
||||
|
||||
13
nanobot/cli_apps/__init__.py
Normal file
13
nanobot/cli_apps/__init__.py
Normal file
@ -0,0 +1,13 @@
|
||||
"""CLI Apps integration helpers."""
|
||||
|
||||
from nanobot.cli_apps.service import (
|
||||
CliAppError,
|
||||
CliAppManager,
|
||||
CliAppsRuntimeConfig,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CliAppError",
|
||||
"CliAppManager",
|
||||
"CliAppsRuntimeConfig",
|
||||
]
|
||||
828
nanobot/cli_apps/service.py
Normal file
828
nanobot/cli_apps/service.py
Normal file
@ -0,0 +1,828 @@
|
||||
"""CLI-Anything catalog, install state, and safe CLI execution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from nanobot.config.paths import get_runtime_subdir
|
||||
|
||||
CLI_ANYTHING_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/registry.json"
|
||||
CLI_ANYTHING_PUBLIC_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/public_registry.json"
|
||||
CLI_ANYTHING_RAW_BASE = "https://raw.githubusercontent.com/HKUDS/CLI-Anything/main"
|
||||
CLI_ANYTHING_RAW_SKILLS_BASE = f"{CLI_ANYTHING_RAW_BASE}/skills/"
|
||||
|
||||
_MAX_TOOL_OUTPUT_CHARS = 12_000
|
||||
_SAFE_NAME_RE = re.compile(r"[^a-z0-9_-]+")
|
||||
_MENTION_RE = re.compile(r"(^|[\s([{])@([a-z0-9_-]+)\b", re.IGNORECASE)
|
||||
_SHELL_META_CHARS = ("|", "&&", "||", ";", "$(", "`", ">", "<")
|
||||
|
||||
|
||||
class CliAppError(ValueError):
|
||||
"""User-facing CLI Apps failure."""
|
||||
|
||||
def __init__(self, message: str, *, status: int = 400) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.status = status
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CliAppsRuntimeConfig:
|
||||
"""Runtime knobs for CLI Apps."""
|
||||
|
||||
install_timeout: int = 300
|
||||
run_timeout: int = 60
|
||||
catalog_ttl_seconds: int = 3600
|
||||
|
||||
|
||||
_BRANDS: dict[str, tuple[str, str]] = {
|
||||
"1password-cli": ("1password", "#3B66BC"),
|
||||
"audacity": ("audacity", "#0000CC"),
|
||||
"blender": ("blender", "#E87D0D"),
|
||||
"browser": ("googlechrome", "#4285F4"),
|
||||
"calibre": ("calibre", "#45B29D"),
|
||||
"chromadb": ("chroma", "#FFDE2D"),
|
||||
"comfyui": ("comfyui", "#111827"),
|
||||
"contentful": ("contentful", "#2478CC"),
|
||||
"dify": ("dify", "#155EEF"),
|
||||
"drawio": ("diagramsdotnet", "#F08705"),
|
||||
"elevenlabs": ("elevenlabs", "#000000"),
|
||||
"eth2-quickstart": ("ethereum", "#627EEA"),
|
||||
"firefly-iii": ("fireflyiii", "#CD5029"),
|
||||
"freecad": ("freecad", "#418FDE"),
|
||||
"generate-veo-video": ("googlegemini", "#8E75B2"),
|
||||
"gimp": ("gimp", "#5C5543"),
|
||||
"godot": ("godotengine", "#478CBF"),
|
||||
"hacker-feeds-cli": ("rss", "#FFA500"),
|
||||
"inkscape": ("inkscape", "#000000"),
|
||||
"intelwatch": ("intel", "#0071C5"),
|
||||
"iterm2": ("iterm2", "#000000"),
|
||||
"jimeng": ("bytedance", "#3C8CFF"),
|
||||
"kdenlive": ("kdenlive", "#527EB2"),
|
||||
"krita": ("krita", "#3BABFF"),
|
||||
"libreoffice": ("libreoffice", "#18A303"),
|
||||
"mailchimp": ("mailchimp", "#FFE01B"),
|
||||
"mermaid": ("mermaid", "#FF3670"),
|
||||
"minimax": ("minimax", "#111827"),
|
||||
"musescore": ("musescore", "#1A70B8"),
|
||||
"n8n": ("n8n", "#EA4B71"),
|
||||
"notebooklm": ("googlenotebooklm", "#4285F4"),
|
||||
"obs-studio": ("obsstudio", "#302E31"),
|
||||
"obsidian": ("obsidian", "#7C3AED"),
|
||||
"ollama": ("ollama", "#000000"),
|
||||
"pm2": ("pm2", "#2B037A"),
|
||||
"qgis": ("qgis", "#589632"),
|
||||
"safari": ("safari", "#006CFF"),
|
||||
"sanity": ("sanity", "#F03E2F"),
|
||||
"sentry": ("sentry", "#362D59"),
|
||||
"sketch": ("sketch", "#F7B500"),
|
||||
"shopify": ("shopify", "#7AB55C"),
|
||||
"nsight-graphics": ("nvidia", "#76B900"),
|
||||
"unrealinsights": ("unrealengine", "#0E1128"),
|
||||
"ueatelier": ("unrealengine", "#0E1128"),
|
||||
"ve-twini": ("x", "#000000"),
|
||||
"wecom": ("wechat", "#07C160"),
|
||||
"suno": ("suno", "#000000"),
|
||||
"lldb": ("llvm", "#262D3A"),
|
||||
"android-cli": ("android", "#3DDC84"),
|
||||
"adguardhome": ("adguard", "#68BC71"),
|
||||
"zotero": ("zotero", "#CC2936"),
|
||||
"zoom": ("zoom", "#0B5CFF"),
|
||||
}
|
||||
|
||||
_BRAND_DOMAINS: dict[str, tuple[str, str]] = {
|
||||
"3mf": ("3mf.io", "#00A1DE"),
|
||||
"anygen": ("anygen.com", "#111827"),
|
||||
"clibrowser": ("github.com/allthingssecurity/clibrowser", "#24292F"),
|
||||
"cloudanalyzer": ("github.com/rsasaki0109/CloudAnalyzer", "#2563EB"),
|
||||
"cloudcompare": ("cloudcompare.org", "#4D83C3"),
|
||||
"deployhq": ("deployhq.com", "#00A2D9"),
|
||||
"exa": ("exa.ai", "#111827"),
|
||||
"feishu": ("larksuite.com", "#00A5FF"),
|
||||
"inkstitch": ("inkstitch.org", "#222222"),
|
||||
"macrocli": ("github.com/HKUDS/CLI-Anything/tree/main/macrocli", "#24292F"),
|
||||
"mubu": ("mubu.com", "#16A085"),
|
||||
"nslogger": ("github.com/fpillet/NSLogger", "#24292F"),
|
||||
"novita": ("novita.ai", "#7C3AED"),
|
||||
"openscreen": ("openscreen.com", "#2563EB"),
|
||||
"py4csr": ("github.com/yanmingyu92/py4csr", "#24292F"),
|
||||
"quietshrink": ("github.com/achiya-automation/quietshrink", "#111827"),
|
||||
"renderdoc": ("renderdoc.org", "#2C7DB8"),
|
||||
"rms": ("rms.teltonika-networks.com", "#0054A6"),
|
||||
"sbox": ("sbox.game", "#F59E0B"),
|
||||
"seaclip": ("github.com/SeaClip-Lite/SeaClip", "#0284C7"),
|
||||
"shotcut": ("shotcut.org", "#3B82F6"),
|
||||
"slay-the-spire-ii": ("megacrit.com", "#B91C1C"),
|
||||
"stata": ("stata.com", "#1F4E79"),
|
||||
"unimol-tools": ("github.com/deepmodeling/Uni-Mol", "#4F46E5"),
|
||||
"videocaptioner": ("github.com/WEIFENG2333/VideoCaptioner", "#2563EB"),
|
||||
"wiremock": ("wiremock.org", "#FF6A00"),
|
||||
}
|
||||
|
||||
_BRAND_ALIASES: dict[str, str] = {
|
||||
"1password": "1password-cli",
|
||||
"dify-workflow": "dify",
|
||||
"feishu-lark": "feishu",
|
||||
"lark-cli": "feishu",
|
||||
"minimax-cli": "minimax",
|
||||
"obsidian-cli": "obsidian",
|
||||
"slay-the-spire-2": "slay-the-spire-ii",
|
||||
"slay-the-spire-ii": "slay-the-spire-ii",
|
||||
"unimol-tools": "unimol-tools",
|
||||
"unimol": "unimol-tools",
|
||||
"veo": "generate-veo-video",
|
||||
}
|
||||
|
||||
_BRAND_TRAILING_WORDS = ("cli", "workflow", "workflows", "app", "apps", "tool", "tools")
|
||||
|
||||
|
||||
def _now() -> float:
|
||||
return time.time()
|
||||
|
||||
|
||||
def _safe_skill_name(name: str) -> str:
|
||||
clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-")
|
||||
return f"cli-app-{clean or 'app'}"
|
||||
|
||||
|
||||
def _has_shell_meta(command: str) -> bool:
|
||||
return any(char in command for char in _SHELL_META_CHARS)
|
||||
|
||||
|
||||
def _command_exists(command: str) -> bool:
|
||||
try:
|
||||
parts = shlex.split(command)
|
||||
except ValueError:
|
||||
return False
|
||||
if not parts:
|
||||
return False
|
||||
return shutil.which(parts[0]) is not None
|
||||
|
||||
|
||||
def _is_pip_install_command(command: str) -> bool:
|
||||
try:
|
||||
tokens = shlex.split(command)
|
||||
except ValueError:
|
||||
return False
|
||||
return (
|
||||
len(tokens) >= 3
|
||||
and tokens[:2] == ["pip", "install"]
|
||||
) or (
|
||||
len(tokens) >= 5
|
||||
and tokens[1:4] == ["-m", "pip", "install"]
|
||||
and tokens[0] in {"python", "python3", sys.executable}
|
||||
)
|
||||
|
||||
|
||||
def _pip_uninstall_args_from_command(command: str) -> list[str] | None:
|
||||
if not command or _has_shell_meta(command):
|
||||
return None
|
||||
try:
|
||||
tokens = shlex.split(command)
|
||||
except ValueError:
|
||||
return None
|
||||
if tokens[:2] == ["pip", "uninstall"]:
|
||||
args = tokens[2:]
|
||||
elif (
|
||||
len(tokens) >= 5
|
||||
and tokens[1:4] == ["-m", "pip", "uninstall"]
|
||||
and tokens[0] in {"python", "python3", sys.executable}
|
||||
):
|
||||
args = tokens[4:]
|
||||
else:
|
||||
return None
|
||||
packages = [arg for arg in args if arg not in {"-y", "--yes"}]
|
||||
if not packages or any(arg.startswith("-") for arg in packages):
|
||||
return None
|
||||
return packages
|
||||
|
||||
|
||||
def _brand_key(value: str) -> str:
|
||||
return _SAFE_NAME_RE.sub("-", value.lower()).replace("_", "-").strip("-")
|
||||
|
||||
|
||||
def _brand_candidates(app: dict[str, Any]) -> list[str]:
|
||||
values = [
|
||||
str(app.get("name") or ""),
|
||||
str(app.get("display_name") or ""),
|
||||
str(app.get("entry_point") or "").removeprefix("cli-anything-"),
|
||||
]
|
||||
seen: set[str] = set()
|
||||
candidates: list[str] = []
|
||||
for value in values:
|
||||
key = _brand_key(value)
|
||||
while key and key not in seen:
|
||||
seen.add(key)
|
||||
candidates.append(key)
|
||||
parts = key.split("-")
|
||||
if len(parts) <= 1 or parts[-1] not in _BRAND_TRAILING_WORDS:
|
||||
break
|
||||
key = "-".join(parts[:-1])
|
||||
return candidates
|
||||
|
||||
|
||||
def _brand_payload(app: dict[str, Any]) -> tuple[str | None, str | None]:
|
||||
brand = None
|
||||
domain_brand = None
|
||||
for candidate in _brand_candidates(app):
|
||||
key = _BRAND_ALIASES.get(candidate, candidate)
|
||||
brand = _BRANDS.get(key)
|
||||
if brand:
|
||||
break
|
||||
domain_brand = _BRAND_DOMAINS.get(key)
|
||||
if domain_brand:
|
||||
break
|
||||
if not brand:
|
||||
if not domain_brand:
|
||||
return None, None
|
||||
domain, color = domain_brand
|
||||
return f"https://www.google.com/s2/favicons?domain={domain}&sz=64", color
|
||||
slug, color = brand
|
||||
return f"https://cdn.simpleicons.org/{slug}/{color.lstrip('#')}", color
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any] | None:
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def _write_json(path: Path, data: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = json.dumps(data, indent=2, ensure_ascii=False)
|
||||
tmp_path = path.with_name(f".{path.name}.{os.getpid()}.{int(_now() * 1_000_000)}.tmp")
|
||||
try:
|
||||
tmp_path.write_text(payload, encoding="utf-8")
|
||||
tmp_path.replace(path)
|
||||
finally:
|
||||
if tmp_path.exists():
|
||||
tmp_path.unlink()
|
||||
|
||||
|
||||
def _safe_skill_path(value: str) -> str | None:
|
||||
if not value.startswith("skills/"):
|
||||
return None
|
||||
parts = value.split("/")
|
||||
if any(part in {"", ".", ".."} for part in parts):
|
||||
return None
|
||||
return value if parts[-1] == "SKILL.md" else None
|
||||
|
||||
|
||||
def _skill_content_url(skill_md: str) -> str | None:
|
||||
safe_path = _safe_skill_path(skill_md)
|
||||
if safe_path:
|
||||
return f"{CLI_ANYTHING_RAW_BASE}/{safe_path}"
|
||||
parsed = urlparse(skill_md)
|
||||
if parsed.scheme != "https" or parsed.netloc != "raw.githubusercontent.com":
|
||||
return None
|
||||
if not skill_md.startswith(CLI_ANYTHING_RAW_SKILLS_BASE):
|
||||
return None
|
||||
suffix = skill_md.removeprefix(f"{CLI_ANYTHING_RAW_BASE}/")
|
||||
return skill_md if _safe_skill_path(suffix) else None
|
||||
|
||||
|
||||
def _truncate(text: str, limit: int = _MAX_TOOL_OUTPUT_CHARS) -> str:
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
omitted = len(text) - limit
|
||||
return text[:limit] + f"\n\n... truncated {omitted} characters ..."
|
||||
|
||||
|
||||
class CliAppManager:
|
||||
"""Manage CLI-Anything registry entries and local install state."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
workspace: Path,
|
||||
data_dir: Path | None = None,
|
||||
runtime: CliAppsRuntimeConfig | None = None,
|
||||
) -> None:
|
||||
self.workspace = Path(workspace).expanduser()
|
||||
self.data_dir = Path(data_dir) if data_dir is not None else get_runtime_subdir("cli-apps")
|
||||
self.runtime = runtime or CliAppsRuntimeConfig()
|
||||
|
||||
@property
|
||||
def installed_path(self) -> Path:
|
||||
return self.data_dir / "installed.json"
|
||||
|
||||
def _cache_path(self, source: str) -> Path:
|
||||
return self.data_dir / f"{source}_registry_cache.json"
|
||||
|
||||
def _load_installed(self) -> dict[str, Any]:
|
||||
data = _read_json(self.installed_path) or {}
|
||||
apps = data.get("apps") if isinstance(data.get("apps"), dict) else data
|
||||
return apps if isinstance(apps, dict) else {}
|
||||
|
||||
def _save_installed(self, installed: dict[str, Any]) -> None:
|
||||
_write_json(self.installed_path, {"schema_version": 1, "apps": installed})
|
||||
|
||||
def installed_names(self) -> list[str]:
|
||||
"""Return registry names explicitly installed through CLI Apps."""
|
||||
return sorted(str(name) for name in self._load_installed())
|
||||
|
||||
def _fetch_registry(
|
||||
self,
|
||||
url: str,
|
||||
cache_path: Path,
|
||||
*,
|
||||
force_refresh: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
cached = _read_json(cache_path)
|
||||
if (
|
||||
not force_refresh
|
||||
and cached
|
||||
and _now() - float(cached.get("_cached_at", 0)) < self.runtime.catalog_ttl_seconds
|
||||
):
|
||||
data = cached.get("data")
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
|
||||
try:
|
||||
response = httpx.get(url, timeout=15.0, follow_redirects=True)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("registry response must be an object")
|
||||
except Exception:
|
||||
if cached and isinstance(cached.get("data"), dict):
|
||||
return cached["data"]
|
||||
raise
|
||||
|
||||
_write_json(cache_path, {"_cached_at": _now(), "data": data})
|
||||
return data
|
||||
|
||||
def catalog(self, *, force_refresh: bool = False) -> tuple[list[dict[str, Any]], str | None]:
|
||||
registries = [
|
||||
(
|
||||
"harness",
|
||||
self._fetch_registry(
|
||||
CLI_ANYTHING_REGISTRY_URL,
|
||||
self._cache_path("harness"),
|
||||
force_refresh=force_refresh,
|
||||
),
|
||||
),
|
||||
(
|
||||
"public",
|
||||
self._fetch_registry(
|
||||
CLI_ANYTHING_PUBLIC_REGISTRY_URL,
|
||||
self._cache_path("public"),
|
||||
force_refresh=force_refresh,
|
||||
),
|
||||
),
|
||||
]
|
||||
apps_by_name: dict[str, dict[str, Any]] = {}
|
||||
updated_values: list[str] = []
|
||||
for source, registry in registries:
|
||||
meta = registry.get("meta")
|
||||
if isinstance(meta, dict) and isinstance(meta.get("updated"), str):
|
||||
updated_values.append(meta["updated"])
|
||||
for row in registry.get("clis", []):
|
||||
if not isinstance(row, dict) or not row.get("name"):
|
||||
continue
|
||||
entry = dict(row)
|
||||
entry["_source"] = source
|
||||
key = str(entry["name"]).lower()
|
||||
previous = apps_by_name.get(key)
|
||||
if previous:
|
||||
previous_source = str(previous.get("_source") or source)
|
||||
merged_source = (
|
||||
previous_source if previous_source == source else f"{previous_source}+{source}"
|
||||
)
|
||||
apps_by_name[key] = {**previous, **entry, "_source": merged_source}
|
||||
else:
|
||||
apps_by_name[key] = entry
|
||||
return list(apps_by_name.values()), max(updated_values) if updated_values else None
|
||||
|
||||
def get_app(self, name: str, *, force_refresh: bool = False) -> dict[str, Any]:
|
||||
wanted = name.lower()
|
||||
for app in self.catalog(force_refresh=force_refresh)[0]:
|
||||
if str(app.get("name", "")).lower() == wanted:
|
||||
return app
|
||||
raise CliAppError(f"CLI app '{name}' not found", status=404)
|
||||
|
||||
def mentioned_installed_apps(self, text: str) -> list[dict[str, str]]:
|
||||
"""Return installed CLI Apps referenced as ``@name`` in user text."""
|
||||
if "@" not in text:
|
||||
return []
|
||||
installed = self._load_installed()
|
||||
if not installed:
|
||||
return []
|
||||
installed_by_name = {
|
||||
str(name).lower(): (str(name), data if isinstance(data, dict) else {})
|
||||
for name, data in installed.items()
|
||||
}
|
||||
seen: set[str] = set()
|
||||
mentions: list[dict[str, str]] = []
|
||||
for match in _MENTION_RE.finditer(text):
|
||||
wanted = str(match.group(2)).lower()
|
||||
if wanted in seen or wanted not in installed_by_name:
|
||||
continue
|
||||
installed_name, data = installed_by_name[wanted]
|
||||
seen.add(wanted)
|
||||
entry_point = str(data.get("entry_point") or "")
|
||||
mentions.append(
|
||||
{
|
||||
"name": installed_name,
|
||||
"entry_point": entry_point,
|
||||
"source": str(data.get("source") or ""),
|
||||
"skill": f"skills/{_safe_skill_name(installed_name)}/SKILL.md",
|
||||
"tool": "run_cli_app",
|
||||
}
|
||||
)
|
||||
return mentions
|
||||
|
||||
def _strategy(self, app: dict[str, Any]) -> str:
|
||||
package_manager = str(app.get("package_manager") or "").lower()
|
||||
install_strategy = str(app.get("install_strategy") or "").lower()
|
||||
if package_manager == "bundled" or install_strategy == "bundled":
|
||||
return "bundled"
|
||||
if package_manager in {"npm", "brew", "uv", "pip"}:
|
||||
return package_manager
|
||||
if app.get("npm_package"):
|
||||
return "npm"
|
||||
install_cmd = str(app.get("install_cmd") or "")
|
||||
if _is_pip_install_command(install_cmd):
|
||||
return "pip"
|
||||
return "unsupported"
|
||||
|
||||
def _install_supported(self, app: dict[str, Any]) -> bool:
|
||||
if self._strategy(app) == "unsupported":
|
||||
return False
|
||||
install_cmd = str(app.get("install_cmd") or "")
|
||||
return not _has_shell_meta(install_cmd)
|
||||
|
||||
def _skill_path(self, name: str) -> Path:
|
||||
return self.workspace / "skills" / _safe_skill_name(name) / "SKILL.md"
|
||||
|
||||
def _app_payload(
|
||||
self,
|
||||
app: dict[str, Any],
|
||||
installed: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
name = str(app["name"])
|
||||
entry_point = str(app.get("entry_point") or "")
|
||||
install_supported = self._install_supported(app)
|
||||
is_installed = name in installed
|
||||
available = bool(entry_point and shutil.which(entry_point))
|
||||
if is_installed and available:
|
||||
status = "installed"
|
||||
elif is_installed:
|
||||
status = "missing"
|
||||
elif not install_supported:
|
||||
status = "unsupported"
|
||||
elif available:
|
||||
status = "available"
|
||||
else:
|
||||
status = "not_installed"
|
||||
logo_url, brand_color = _brand_payload(app)
|
||||
return {
|
||||
"name": name,
|
||||
"display_name": app.get("display_name") or name,
|
||||
"category": app.get("category") or "uncategorized",
|
||||
"description": app.get("description") or "",
|
||||
"requires": app.get("requires") or "",
|
||||
"source": app.get("_source") or "harness",
|
||||
"entry_point": entry_point,
|
||||
"install_supported": install_supported,
|
||||
"installed": is_installed,
|
||||
"available": available,
|
||||
"status": status,
|
||||
"logo_url": logo_url,
|
||||
"brand_color": brand_color,
|
||||
"skill_installed": self._skill_path(name).is_file(),
|
||||
}
|
||||
|
||||
def payload(self, *, force_refresh: bool = False) -> dict[str, Any]:
|
||||
apps, updated = self.catalog(force_refresh=force_refresh)
|
||||
installed = self._load_installed()
|
||||
rows = [self._app_payload(app, installed) for app in apps]
|
||||
rows.sort(key=lambda item: (str(item["category"]), str(item["display_name"]).lower()))
|
||||
return {
|
||||
"apps": rows,
|
||||
"installed_count": sum(1 for item in rows if item["installed"]),
|
||||
"catalog_updated_at": updated,
|
||||
}
|
||||
|
||||
def _pip_package_from_install(self, app: dict[str, Any]) -> str | None:
|
||||
install_cmd = str(app.get("install_cmd") or "")
|
||||
try:
|
||||
tokens = shlex.split(install_cmd)
|
||||
except ValueError:
|
||||
return None
|
||||
if tokens[:2] == ["pip", "install"]:
|
||||
args = tokens[2:]
|
||||
elif len(tokens) >= 5 and tokens[1:4] == ["-m", "pip", "install"]:
|
||||
args = tokens[4:]
|
||||
else:
|
||||
return None
|
||||
args = [arg for arg in args if not arg.startswith("-")]
|
||||
if len(args) != 1 or args[0].startswith("git+"):
|
||||
return None
|
||||
return args[0]
|
||||
|
||||
def _pip_install_argv(self, app: dict[str, Any], *, update: bool = False) -> list[str]:
|
||||
install_cmd = str(app.get("install_cmd") or "")
|
||||
if not _is_pip_install_command(install_cmd) or _has_shell_meta(install_cmd):
|
||||
raise CliAppError("unsupported pip install command")
|
||||
tokens = shlex.split(install_cmd)
|
||||
args = tokens[2:] if tokens[:2] == ["pip", "install"] else tokens[4:]
|
||||
prefix = [sys.executable, "-m", "pip", "install"]
|
||||
if update:
|
||||
prefix.extend(["--upgrade", "--force-reinstall"])
|
||||
return prefix + args
|
||||
|
||||
def _pip_uninstall_argv(self, app: dict[str, Any]) -> list[str]:
|
||||
uninstall_cmd = str(app.get("uninstall_cmd") or "")
|
||||
packages = _pip_uninstall_args_from_command(uninstall_cmd)
|
||||
if packages:
|
||||
return [sys.executable, "-m", "pip", "uninstall", "-y", *packages]
|
||||
package = str(app.get("pip_package") or "").strip() or self._pip_package_from_install(app)
|
||||
if not package:
|
||||
entry_point = str(app.get("entry_point") or "").strip()
|
||||
package = entry_point if entry_point.startswith("cli-anything-") else f"cli-anything-{_brand_key(str(app['name']))}"
|
||||
return [sys.executable, "-m", "pip", "uninstall", "-y", package]
|
||||
|
||||
def _npm_argv(self, app: dict[str, Any], action: str) -> list[str]:
|
||||
npm = shutil.which("npm")
|
||||
if not npm:
|
||||
raise CliAppError("npm is not installed")
|
||||
package = str(app.get("npm_package") or "")
|
||||
if not package:
|
||||
raise CliAppError("registry entry has no npm_package")
|
||||
if action == "install":
|
||||
return [npm, "install", "-g", package]
|
||||
if action == "update":
|
||||
return [npm, "install", "-g", package + "@latest"]
|
||||
return [npm, "uninstall", "-g", package]
|
||||
|
||||
def _split_safe_command(self, app: dict[str, Any], key: str, expected: str) -> list[str]:
|
||||
command = str(app.get(key) or "")
|
||||
if not command:
|
||||
raise CliAppError(f"no {key} is defined for {app['name']}")
|
||||
if _has_shell_meta(command):
|
||||
raise CliAppError("script-style install commands are disabled in this MVP")
|
||||
try:
|
||||
argv = shlex.split(command)
|
||||
except ValueError as exc:
|
||||
raise CliAppError(f"invalid command: {exc}") from exc
|
||||
if not argv or argv[0] != expected:
|
||||
raise CliAppError(f"unsupported {expected} command")
|
||||
return argv
|
||||
|
||||
def _argv_for_action(self, app: dict[str, Any], action: str) -> list[str] | None:
|
||||
strategy = self._strategy(app)
|
||||
if strategy == "pip":
|
||||
if action == "install":
|
||||
return self._pip_install_argv(app)
|
||||
if action == "update":
|
||||
return self._pip_install_argv(app, update=True)
|
||||
return self._pip_uninstall_argv(app)
|
||||
if strategy == "npm":
|
||||
return self._npm_argv(app, action)
|
||||
if strategy == "brew":
|
||||
key = {"install": "install_cmd", "update": "update_cmd", "uninstall": "uninstall_cmd"}[action]
|
||||
return self._split_safe_command(app, key, "brew")
|
||||
if strategy == "uv":
|
||||
key = {"install": "install_cmd", "update": "update_cmd", "uninstall": "uninstall_cmd"}[action]
|
||||
return self._split_safe_command(app, key, "uv")
|
||||
if strategy == "bundled":
|
||||
return None
|
||||
raise CliAppError("this CLI app uses an unsupported install strategy")
|
||||
|
||||
def _run_argv(self, argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
argv,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
def _installed_entry(self, app: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"version": app.get("version") or "unknown",
|
||||
"entry_point": app.get("entry_point") or "",
|
||||
"source": app.get("_source") or "harness",
|
||||
"strategy": self._strategy(app),
|
||||
"installed_at": int(_now()),
|
||||
}
|
||||
|
||||
def _fetch_skill_content(self, app: dict[str, Any]) -> str | None:
|
||||
skill_md = str(app.get("skill_md") or "").strip()
|
||||
if not skill_md:
|
||||
return None
|
||||
url = _skill_content_url(skill_md)
|
||||
if not url:
|
||||
return None
|
||||
try:
|
||||
response = httpx.get(url, timeout=15.0, follow_redirects=True)
|
||||
response.raise_for_status()
|
||||
text = response.text
|
||||
except Exception:
|
||||
return None
|
||||
if "SKILL.md" not in url and not text.lstrip().startswith("---"):
|
||||
return None
|
||||
return text if len(text) < 250_000 else None
|
||||
|
||||
def _fallback_skill(self, app: dict[str, Any]) -> str:
|
||||
name = str(app.get("name") or "unknown")
|
||||
display = str(app.get("display_name") or name)
|
||||
entry = str(app.get("entry_point") or f"cli-anything-{name}")
|
||||
description = str(app.get("description") or f"Use {display} from nanobot.")
|
||||
return f"""---
|
||||
name: {_safe_skill_name(name)}
|
||||
description: >-
|
||||
{description}
|
||||
---
|
||||
|
||||
# {display}
|
||||
|
||||
Use this skill when the user asks nanobot to operate {display} through its installed CLI app.
|
||||
|
||||
If the user attached `@{name}` in chat, treat that as the selected app for the current turn.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
{entry} --help
|
||||
{entry} --json --help
|
||||
```
|
||||
|
||||
Prefer machine-readable output when the CLI supports `--json`.
|
||||
"""
|
||||
|
||||
def _with_nanobot_skill_note(self, content: str, app: dict[str, Any]) -> str:
|
||||
marker = "<!-- nanobot-cli-app-note -->"
|
||||
if marker in content:
|
||||
return content
|
||||
name = str(app.get("name") or "unknown")
|
||||
note = f"""{marker}
|
||||
## Nanobot execution
|
||||
|
||||
Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not invoke this CLI through shell unless the user explicitly asks. Prefer this skill when Runtime Context mentions `@{name}` as a CLI App Attachment.
|
||||
"""
|
||||
lines = content.splitlines(keepends=True)
|
||||
if lines and lines[0].strip() == "---":
|
||||
for index, line in enumerate(lines[1:], start=1):
|
||||
if line.strip() == "---":
|
||||
return "".join(lines[: index + 1]) + "\n" + note + "\n" + "".join(lines[index + 1 :])
|
||||
return note + "\n" + content
|
||||
|
||||
def install_skill(self, app: dict[str, Any]) -> Path:
|
||||
path = self._skill_path(str(app["name"]))
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = self._fetch_skill_content(app) or self._fallback_skill(app)
|
||||
content = self._with_nanobot_skill_note(content, app)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
return path
|
||||
|
||||
def remove_skill(self, name: str) -> None:
|
||||
skill_dir = self._skill_path(name).parent
|
||||
if skill_dir.is_dir():
|
||||
shutil.rmtree(skill_dir)
|
||||
|
||||
def _record_installed(self, app: dict[str, Any]) -> None:
|
||||
installed = self._load_installed()
|
||||
installed[str(app["name"])] = self._installed_entry(app)
|
||||
self._save_installed(installed)
|
||||
self.install_skill(app)
|
||||
|
||||
def install(self, name: str) -> dict[str, Any]:
|
||||
app = self.get_app(name)
|
||||
if not self._install_supported(app):
|
||||
raise CliAppError("this CLI app uses an unsupported install strategy")
|
||||
strategy = self._strategy(app)
|
||||
if strategy == "bundled":
|
||||
detect_cmd = str(app.get("detect_cmd") or app.get("entry_point") or "")
|
||||
if detect_cmd and _command_exists(detect_cmd):
|
||||
self._record_installed(app)
|
||||
return self.payload() | {"last_action": {"ok": True, "message": f"CLI for {app['display_name']} is available."}}
|
||||
note = app.get("install_notes") or f"{app['display_name']} is bundled with its parent app."
|
||||
raise CliAppError(str(note))
|
||||
argv = self._argv_for_action(app, "install")
|
||||
assert argv is not None
|
||||
result = self._run_argv(argv, timeout=self.runtime.install_timeout)
|
||||
if result.returncode != 0:
|
||||
raise CliAppError(_truncate(result.stderr or result.stdout or "install failed"), status=500)
|
||||
self._record_installed(app)
|
||||
return self.payload() | {"last_action": {"ok": True, "message": f"Installed CLI for {app['display_name']}."}}
|
||||
|
||||
def update(self, name: str) -> dict[str, Any]:
|
||||
app = self.get_app(name, force_refresh=True)
|
||||
if str(app["name"]) not in self._load_installed():
|
||||
raise CliAppError("CLI app is not installed")
|
||||
if self._strategy(app) == "bundled":
|
||||
self._record_installed(app)
|
||||
return self.payload() | {"last_action": {"ok": True, "message": f"Checked {app['display_name']}."}}
|
||||
argv = self._argv_for_action(app, "update")
|
||||
assert argv is not None
|
||||
result = self._run_argv(argv, timeout=self.runtime.install_timeout)
|
||||
if result.returncode != 0:
|
||||
raise CliAppError(_truncate(result.stderr or result.stdout or "update failed"), status=500)
|
||||
self._record_installed(app)
|
||||
return self.payload() | {"last_action": {"ok": True, "message": f"Updated CLI for {app['display_name']}."}}
|
||||
|
||||
def uninstall(self, name: str) -> dict[str, Any]:
|
||||
app = self.get_app(name)
|
||||
installed = self._load_installed()
|
||||
if str(app["name"]) not in installed:
|
||||
raise CliAppError("CLI app is not installed")
|
||||
if self._strategy(app) != "bundled":
|
||||
argv = self._argv_for_action(app, "uninstall")
|
||||
assert argv is not None
|
||||
result = self._run_argv(argv, timeout=self.runtime.install_timeout)
|
||||
if result.returncode != 0:
|
||||
raise CliAppError(_truncate(result.stderr or result.stdout or "uninstall failed"), status=500)
|
||||
installed.pop(str(app["name"]), None)
|
||||
self._save_installed(installed)
|
||||
self.remove_skill(str(app["name"]))
|
||||
return self.payload() | {"last_action": {"ok": True, "message": f"Uninstalled CLI for {app['display_name']}."}}
|
||||
|
||||
def test(self, name: str) -> dict[str, Any]:
|
||||
app = self.get_app(name)
|
||||
entry = str(app.get("entry_point") or "")
|
||||
resolved = shutil.which(entry)
|
||||
if not entry or not resolved:
|
||||
raise CliAppError(f"{entry or name} is not available on PATH")
|
||||
result = self._run_argv([resolved, "--help"], timeout=min(self.runtime.run_timeout, 30))
|
||||
ok = result.returncode == 0
|
||||
output = _truncate((result.stdout or result.stderr or "").strip(), 3000)
|
||||
return self.payload() | {
|
||||
"last_action": {
|
||||
"ok": ok,
|
||||
"message": f"{entry} --help exited {result.returncode}",
|
||||
"output": output,
|
||||
}
|
||||
}
|
||||
|
||||
def _resolve_cwd(
|
||||
self,
|
||||
working_dir: str | None,
|
||||
*,
|
||||
restrict_to_workspace: bool,
|
||||
) -> Path:
|
||||
cwd = Path(working_dir).expanduser() if working_dir else self.workspace
|
||||
cwd = cwd.resolve(strict=False)
|
||||
workspace = self.workspace.resolve(strict=False)
|
||||
if restrict_to_workspace and cwd != workspace and not cwd.is_relative_to(workspace):
|
||||
raise CliAppError("working_dir is outside the configured workspace")
|
||||
return cwd
|
||||
|
||||
def run(
|
||||
self,
|
||||
name: str,
|
||||
args: list[str] | None = None,
|
||||
*,
|
||||
json_output: bool = False,
|
||||
working_dir: str | None = None,
|
||||
timeout: int | None = None,
|
||||
restrict_to_workspace: bool = False,
|
||||
) -> str:
|
||||
app = self.get_app(name)
|
||||
installed = self._load_installed()
|
||||
if str(app["name"]) not in installed:
|
||||
raise CliAppError(f"CLI app '{name}' is not installed")
|
||||
cwd = self._resolve_cwd(working_dir, restrict_to_workspace=restrict_to_workspace)
|
||||
entry = str(installed[str(app["name"])].get("entry_point") or app.get("entry_point") or "")
|
||||
resolved = shutil.which(entry)
|
||||
if not entry or not resolved:
|
||||
raise CliAppError(f"{entry or name} is not available on PATH")
|
||||
clean_args = [str(arg) for arg in (args or [])]
|
||||
if json_output and "--json" not in clean_args:
|
||||
clean_args = ["--json", *clean_args]
|
||||
effective_timeout = max(1, min(timeout or self.runtime.run_timeout, 600))
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[resolved, *clean_args],
|
||||
cwd=str(cwd),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=effective_timeout,
|
||||
env=os.environ.copy(),
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return f"CLI app '{name}' timed out after {effective_timeout}s"
|
||||
output = [
|
||||
f"CLI app '{name}' exited {result.returncode}.",
|
||||
f"Command: {entry} {' '.join(shlex.quote(arg) for arg in clean_args)}".rstrip(),
|
||||
]
|
||||
if result.stdout:
|
||||
output.append("\nSTDOUT:\n" + result.stdout.rstrip())
|
||||
if result.stderr:
|
||||
output.append("\nSTDERR:\n" + result.stderr.rstrip())
|
||||
return _truncate("\n".join(output))
|
||||
62
nanobot/cli_apps/utils.py
Normal file
62
nanobot/cli_apps/utils.py
Normal file
@ -0,0 +1,62 @@
|
||||
"""CLI Apps helpers shared by the agent loop and settings surfaces."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
"""Return persisted session kwargs for CLI app attachments."""
|
||||
cli_apps = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
|
||||
return {"cli_apps": cli_apps} if isinstance(cli_apps, list) and cli_apps else {}
|
||||
|
||||
|
||||
def runtime_lines(message: Any, workspace: Path, *, skip: bool = False) -> list[str]:
|
||||
"""Return model-visible CLI app annotations for the current turn."""
|
||||
if skip:
|
||||
return []
|
||||
text = message.content if isinstance(getattr(message, "content", None), str) else ""
|
||||
metadata = message.metadata if isinstance(getattr(message, "metadata", None), Mapping) else None
|
||||
return _cli_app_runtime_lines(text, metadata, workspace)
|
||||
|
||||
|
||||
def _cli_app_runtime_lines(
|
||||
text: str,
|
||||
metadata: Mapping[str, Any] | None,
|
||||
workspace: Path,
|
||||
) -> list[str]:
|
||||
structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
|
||||
if isinstance(structured, list):
|
||||
mentions = [
|
||||
item for item in structured
|
||||
if isinstance(item, Mapping) and isinstance(item.get("name"), str)
|
||||
]
|
||||
if mentions:
|
||||
return [
|
||||
"CLI App Attachment: "
|
||||
f"@{str(item['name']).strip().lower()} "
|
||||
f"(installed; tool=run_cli_app; "
|
||||
f"entry_point={str(item.get('entry_point') or 'unknown')}; "
|
||||
f"skill=skills/cli-app-{str(item['name']).strip().lower()}/SKILL.md). "
|
||||
"Read the skill when useful, then run this app with `run_cli_app`; do not bypass it with shell."
|
||||
for item in mentions
|
||||
if str(item.get("name") or "").strip()
|
||||
]
|
||||
if "@" not in text:
|
||||
return []
|
||||
try:
|
||||
from nanobot.cli_apps import CliAppManager
|
||||
|
||||
mentions = CliAppManager(workspace=workspace).mentioned_installed_apps(text)
|
||||
except Exception:
|
||||
return []
|
||||
return [
|
||||
"CLI App Mention: "
|
||||
f"@{item['name']} "
|
||||
f"(installed; tool={item['tool']}; "
|
||||
f"entry_point={item['entry_point'] or 'unknown'}; "
|
||||
f"skill={item['skill']}). "
|
||||
"Read the skill when useful, then run this app with `run_cli_app`; do not bypass it with shell."
|
||||
for item in mentions
|
||||
]
|
||||
@ -11,6 +11,7 @@ from pydantic_settings import BaseSettings
|
||||
from nanobot.cron.types import CronSchedule
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.agent.tools.cli_apps import CliAppsToolConfig
|
||||
from nanobot.agent.tools.image_generation import ImageGenerationToolConfig
|
||||
from nanobot.agent.tools.self import MyToolConfig
|
||||
from nanobot.agent.tools.shell import ExecToolConfig
|
||||
@ -276,6 +277,7 @@ class ToolsConfig(Base):
|
||||
|
||||
web: WebToolsConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.web", "WebToolsConfig"))
|
||||
exec: ExecToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.shell", "ExecToolConfig"))
|
||||
cli_apps: CliAppsToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.cli_apps", "CliAppsToolConfig"))
|
||||
my: MyToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.self", "MyToolConfig"))
|
||||
image_generation: ImageGenerationToolConfig = Field(
|
||||
default_factory=lambda: _lazy_default("nanobot.agent.tools.image_generation", "ImageGenerationToolConfig"),
|
||||
@ -462,6 +464,7 @@ def _resolve_tool_config_refs() -> None:
|
||||
"""
|
||||
import sys
|
||||
|
||||
from nanobot.agent.tools.cli_apps import CliAppsToolConfig
|
||||
from nanobot.agent.tools.image_generation import ImageGenerationToolConfig
|
||||
from nanobot.agent.tools.self import MyToolConfig
|
||||
from nanobot.agent.tools.shell import ExecToolConfig
|
||||
@ -470,6 +473,7 @@ def _resolve_tool_config_refs() -> None:
|
||||
# Re-export into this module's namespace
|
||||
mod = sys.modules[__name__]
|
||||
mod.ExecToolConfig = ExecToolConfig # type: ignore[attr-defined]
|
||||
mod.CliAppsToolConfig = CliAppsToolConfig # type: ignore[attr-defined]
|
||||
mod.WebToolsConfig = WebToolsConfig # type: ignore[attr-defined]
|
||||
mod.WebSearchConfig = WebSearchConfig # type: ignore[attr-defined]
|
||||
mod.WebFetchConfig = WebFetchConfig # type: ignore[attr-defined]
|
||||
|
||||
@ -165,6 +165,23 @@ class Session:
|
||||
image_placeholder_text(p) for p in media if isinstance(p, str) and p
|
||||
)
|
||||
content = f"{content}\n{breadcrumbs}" if content else breadcrumbs
|
||||
cli_apps = message.get("cli_apps")
|
||||
if role == "user" and isinstance(cli_apps, list) and cli_apps and isinstance(content, str):
|
||||
cli_lines: list[str] = []
|
||||
for item in cli_apps[:8]:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = str(item.get("name") or "").strip().lower()
|
||||
if not name:
|
||||
continue
|
||||
entry = str(item.get("entry_point") or "unknown").strip() or "unknown"
|
||||
cli_lines.append(
|
||||
f"[CLI App Attachment: @{name}; tool=run_cli_app; entry_point={entry}; "
|
||||
f"skill=skills/cli-app-{name}/SKILL.md]"
|
||||
)
|
||||
if cli_lines:
|
||||
breadcrumbs = "\n".join(cli_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():
|
||||
|
||||
@ -41,6 +41,13 @@ documents the general tool contract and non-obvious usage patterns.
|
||||
- Use `write_stdin` to poll, provide stdin, close stdin, wait for expected output with `wait_for`, or terminate an existing exec session.
|
||||
- Use `list_exec_sessions` to recover active session IDs after context shifts.
|
||||
|
||||
## CLI App Attachments
|
||||
|
||||
- When Runtime Context lists a `CLI App Attachment` or `CLI App Mention`, treat the `@name` as an app capability the user intentionally attached to the current turn.
|
||||
- If the task may need app-specific behavior, read the listed skill first, then call `run_cli_app` with that `name`.
|
||||
- Do not run an attached CLI app through shell or generic process tools unless the user explicitly asks for that lower-level path.
|
||||
- If the app CLI is missing, lacks local desktop/app/API prerequisites, or cannot complete the requested action, explain that concrete blocker and what was attempted.
|
||||
|
||||
## Web and External Information
|
||||
|
||||
- Use web tools when the user asks for current information, a specific URL, or information likely to have changed.
|
||||
|
||||
93
nanobot/webui/cli_apps_api.py
Normal file
93
nanobot/webui/cli_apps_api.py
Normal file
@ -0,0 +1,93 @@
|
||||
"""CLI Apps helpers for the WebUI HTTP and message surfaces."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from nanobot.cli_apps import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
||||
from nanobot.config.loader import load_config
|
||||
|
||||
QueryParams = dict[str, list[str]]
|
||||
|
||||
_CLI_APP_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$", re.IGNORECASE)
|
||||
_CLI_APP_ATTACHMENT_KEYS = (
|
||||
"name",
|
||||
"display_name",
|
||||
"category",
|
||||
"entry_point",
|
||||
"logo_url",
|
||||
"brand_color",
|
||||
)
|
||||
|
||||
|
||||
def _clip_ws_string(value: Any, limit: int = 240) -> str | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return None
|
||||
return text[:limit]
|
||||
|
||||
|
||||
def normalize_cli_app_mentions(raw: Any) -> list[dict[str, str]]:
|
||||
"""Sanitize structured CLI app mentions sent by the WebUI."""
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
out: list[dict[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
for item in raw[:8]:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = _clip_ws_string(item.get("name"), 64)
|
||||
if not name or _CLI_APP_NAME_RE.match(name) is None:
|
||||
continue
|
||||
key = name.lower()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
row: dict[str, str] = {"name": key}
|
||||
for field in _CLI_APP_ATTACHMENT_KEYS[1:]:
|
||||
value = _clip_ws_string(item.get(field), 512 if field == "logo_url" else 160)
|
||||
if value:
|
||||
row[field] = value
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
def _query_first(query: QueryParams, key: str) -> str | None:
|
||||
values = query.get(key)
|
||||
return values[0] if values else None
|
||||
|
||||
|
||||
def _manager() -> CliAppManager:
|
||||
config = load_config()
|
||||
cli_cfg = config.tools.cli_apps
|
||||
return CliAppManager(
|
||||
workspace=config.workspace_path,
|
||||
runtime=CliAppsRuntimeConfig(
|
||||
install_timeout=cli_cfg.install_timeout,
|
||||
run_timeout=cli_cfg.run_timeout,
|
||||
catalog_ttl_seconds=cli_cfg.catalog_ttl_seconds,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def cli_apps_payload() -> dict[str, Any]:
|
||||
return _manager().payload()
|
||||
|
||||
|
||||
def cli_apps_action(action: str, query: QueryParams) -> dict[str, Any]:
|
||||
name = (_query_first(query, "name") or "").strip()
|
||||
if not name:
|
||||
raise CliAppError("missing CLI app name")
|
||||
manager = _manager()
|
||||
if action == "install":
|
||||
return manager.install(name)
|
||||
if action == "update":
|
||||
return manager.update(name)
|
||||
if action == "uninstall":
|
||||
return manager.uninstall(name)
|
||||
if action == "test":
|
||||
return manager.test(name)
|
||||
raise CliAppError(f"unknown CLI app action '{action}'", status=404)
|
||||
@ -116,6 +116,55 @@ def tool_trace_lines_from_events(events: Any) -> list[str]:
|
||||
return lines
|
||||
|
||||
|
||||
_PHASE_RANK = {"start": 1, "end": 2, "error": 3}
|
||||
|
||||
|
||||
def _normalize_tool_events(events: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(events, list):
|
||||
return []
|
||||
out: list[dict[str, Any]] = []
|
||||
for event in events:
|
||||
if not event or not isinstance(event, dict):
|
||||
continue
|
||||
if event.get("phase") not in {"start", "end", "error"}:
|
||||
continue
|
||||
if not isinstance(event.get("name"), str):
|
||||
fn = event.get("function")
|
||||
if not (isinstance(fn, dict) and isinstance(fn.get("name"), str)):
|
||||
continue
|
||||
out.append(dict(event))
|
||||
return out
|
||||
|
||||
|
||||
def _tool_event_key(event: dict[str, Any]) -> str:
|
||||
call_id = event.get("call_id")
|
||||
if isinstance(call_id, str) and call_id:
|
||||
return f"call:{call_id}"
|
||||
return _format_tool_call_trace(event) or json.dumps(event, sort_keys=True, ensure_ascii=False)
|
||||
|
||||
|
||||
def _merge_tool_events(previous: Any, incoming: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
if not isinstance(previous, list) or not previous:
|
||||
return incoming
|
||||
if not incoming:
|
||||
return [dict(event) for event in previous if isinstance(event, dict)]
|
||||
merged = [dict(event) for event in previous if isinstance(event, dict)]
|
||||
index_by_key = {_tool_event_key(event): idx for idx, event in enumerate(merged)}
|
||||
for event in incoming:
|
||||
key = _tool_event_key(event)
|
||||
existing_index = index_by_key.get(key)
|
||||
if existing_index is None:
|
||||
index_by_key[key] = len(merged)
|
||||
merged.append(event)
|
||||
continue
|
||||
existing = merged[existing_index]
|
||||
incoming_rank = _PHASE_RANK.get(str(event.get("phase")), 0)
|
||||
existing_rank = _PHASE_RANK.get(str(existing.get("phase")), 0)
|
||||
if incoming_rank >= existing_rank:
|
||||
merged[existing_index] = {**existing, **event}
|
||||
return merged
|
||||
|
||||
|
||||
def _merge_unique_tool_trace_lines(
|
||||
previous_traces: list[str],
|
||||
lines: list[str],
|
||||
@ -405,6 +454,9 @@ def replay_transcript_to_ui_messages(
|
||||
row["media"] = media_att
|
||||
if all(m.get("kind") == "image" for m in media_att):
|
||||
row["images"] = [{"url": m.get("url"), "name": m.get("name")} for m in media_att]
|
||||
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)]
|
||||
messages.append(row)
|
||||
continue
|
||||
|
||||
@ -486,6 +538,7 @@ def replay_transcript_to_ui_messages(
|
||||
close_reasoning(messages)
|
||||
continue
|
||||
if kind in ("tool_hint", "progress"):
|
||||
structured_events = _normalize_tool_events(rec.get("tool_events"))
|
||||
structured = tool_trace_lines_from_events(rec.get("tool_events"))
|
||||
text = rec.get("text")
|
||||
trace_lines = structured if structured else ([text] if isinstance(text, str) and text else [])
|
||||
@ -502,7 +555,7 @@ def replay_transcript_to_ui_messages(
|
||||
prev_traces = list(last.get("traces") or [last.get("content")])
|
||||
if structured:
|
||||
merged_traces, added = _merge_unique_tool_trace_lines(prev_traces, structured)
|
||||
if not added:
|
||||
if not added and not structured_events:
|
||||
continue
|
||||
else:
|
||||
merged_traces = prev_traces + trace_lines
|
||||
@ -510,6 +563,9 @@ def replay_transcript_to_ui_messages(
|
||||
**last,
|
||||
"traces": merged_traces,
|
||||
"content": merged_traces[-1],
|
||||
"toolEvents": _merge_tool_events(last.get("toolEvents"), structured_events)
|
||||
if structured_events
|
||||
else last.get("toolEvents"),
|
||||
"activitySegmentId": last.get("activitySegmentId") or segment,
|
||||
}
|
||||
messages[-1] = merged
|
||||
@ -521,6 +577,7 @@ def replay_transcript_to_ui_messages(
|
||||
"kind": "trace",
|
||||
"content": trace_lines[-1],
|
||||
"traces": trace_lines,
|
||||
**({"toolEvents": structured_events} if structured_events else {}),
|
||||
"activitySegmentId": segment,
|
||||
"createdAt": _ts_base + idx,
|
||||
},
|
||||
|
||||
@ -362,6 +362,21 @@ class TestBuildMessages:
|
||||
assert "Other chat goal." not in str(without_goal[-1]["content"])
|
||||
assert "Goal (active):" not in str(without_goal[-1]["content"])
|
||||
|
||||
def test_current_runtime_lines_are_injected(self, tmp_path):
|
||||
builder = _builder(tmp_path)
|
||||
messages = builder.build_messages(
|
||||
[],
|
||||
"please use @zoom tonight",
|
||||
current_runtime_lines=[
|
||||
"CLI App Attachment: @zoom (installed; tool=run_cli_app; entry_point=cli-anything-zoom).",
|
||||
],
|
||||
)
|
||||
user_msg = str(messages[-1]["content"])
|
||||
|
||||
assert "CLI App Attachment: @zoom" in user_msg
|
||||
assert "tool=run_cli_app" in user_msg
|
||||
assert "entry_point=cli-anything-zoom" in user_msg
|
||||
|
||||
def test_consecutive_same_role_merged(self, tmp_path):
|
||||
builder = _builder(tmp_path)
|
||||
history = [{"role": "user", "content": "previous user message"}]
|
||||
|
||||
@ -359,6 +359,31 @@ def test_get_history_synthesizes_breadcrumb_for_image_only_turn():
|
||||
assert history[0] == {"role": "user", "content": "[image: /m/pic.png]"}
|
||||
|
||||
|
||||
def test_get_history_synthesizes_cli_app_attachment_breadcrumb():
|
||||
session = Session(key="test:cli-app")
|
||||
session.messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": "please use @drawio",
|
||||
"cli_apps": [{
|
||||
"name": "drawio",
|
||||
"entry_point": "cli-anything-drawio",
|
||||
}],
|
||||
}
|
||||
)
|
||||
|
||||
history = session.get_history(max_messages=500)
|
||||
|
||||
assert history == [{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"please use @drawio\n"
|
||||
"[CLI App Attachment: @drawio; tool=run_cli_app; "
|
||||
"entry_point=cli-anything-drawio; skill=skills/cli-app-drawio/SKILL.md]"
|
||||
),
|
||||
}]
|
||||
|
||||
|
||||
def test_get_history_ignores_media_kwarg_on_non_user_rows():
|
||||
"""``media`` only ever appears on user entries in practice, but the
|
||||
synthesizer must be defensive: assistants / tools with list content
|
||||
|
||||
@ -105,6 +105,43 @@ async def test_message_without_media_backward_compatible() -> None:
|
||||
assert call.kwargs["media"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_forwards_normalized_cli_app_attachments() -> None:
|
||||
channel = _make_channel()
|
||||
mock_conn = AsyncMock()
|
||||
envelope = {
|
||||
"type": "message",
|
||||
"chat_id": "abc123",
|
||||
"content": "please use @drawio",
|
||||
"webui": True,
|
||||
"cli_apps": [
|
||||
{
|
||||
"name": "DrawIO",
|
||||
"display_name": "Draw.io",
|
||||
"category": "diagram",
|
||||
"entry_point": "cli-anything-drawio",
|
||||
"logo_url": "https://example.invalid/drawio.svg",
|
||||
"brand_color": "#F08705",
|
||||
},
|
||||
{"name": "bad name", "entry_point": "nope"},
|
||||
],
|
||||
}
|
||||
|
||||
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
|
||||
|
||||
channel._handle_message.assert_awaited_once()
|
||||
metadata = channel._handle_message.call_args.kwargs["metadata"]
|
||||
assert metadata["webui"] is True
|
||||
assert metadata["cli_apps"] == [{
|
||||
"name": "drawio",
|
||||
"display_name": "Draw.io",
|
||||
"category": "diagram",
|
||||
"entry_point": "cli-anything-drawio",
|
||||
"logo_url": "https://example.invalid/drawio.svg",
|
||||
"brand_color": "#F08705",
|
||||
}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_with_single_image_forwards_saved_path(tmp_path) -> None:
|
||||
channel = _make_channel()
|
||||
|
||||
@ -140,6 +140,75 @@ async def test_sessions_routes_require_bearer_token(
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cli_apps_routes_require_token_and_return_payload(
|
||||
bus: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"nanobot.channels.websocket.cli_apps_payload",
|
||||
lambda: {
|
||||
"apps": [
|
||||
{
|
||||
"name": "gimp",
|
||||
"display_name": "GIMP",
|
||||
"category": "image",
|
||||
"description": "Image editing",
|
||||
"requires": "Python",
|
||||
"source": "harness",
|
||||
"entry_point": "cli-anything-gimp",
|
||||
"install_supported": True,
|
||||
"installed": False,
|
||||
"available": False,
|
||||
"status": "not_installed",
|
||||
"logo_url": None,
|
||||
"brand_color": None,
|
||||
"skill_installed": False,
|
||||
}
|
||||
],
|
||||
"installed_count": 0,
|
||||
"catalog_updated_at": "2026-04-18",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.channels.websocket.cli_apps_action",
|
||||
lambda action, query: {
|
||||
"apps": [],
|
||||
"installed_count": 1,
|
||||
"catalog_updated_at": "2026-04-18",
|
||||
"last_action": {"ok": True, "message": f"{action}:{query['name'][0]}"},
|
||||
},
|
||||
)
|
||||
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29912)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
deny = await _http_get("http://127.0.0.1:29912/api/settings/cli-apps")
|
||||
assert deny.status_code == 401
|
||||
|
||||
boot = await _http_get("http://127.0.0.1:29912/webui/bootstrap")
|
||||
token = boot.json()["token"]
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
catalog = await _http_get(
|
||||
"http://127.0.0.1:29912/api/settings/cli-apps",
|
||||
headers=auth,
|
||||
)
|
||||
assert catalog.status_code == 200
|
||||
assert catalog.json()["apps"][0]["name"] == "gimp"
|
||||
|
||||
installed = await _http_get(
|
||||
"http://127.0.0.1:29912/api/settings/cli-apps/install?name=gimp",
|
||||
headers=auth,
|
||||
)
|
||||
assert installed.status_code == 200
|
||||
assert installed.json()["last_action"]["message"] == "install:gimp"
|
||||
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
|
||||
|
||||
378
tests/cli_apps/test_service.py
Normal file
378
tests/cli_apps/test_service.py
Normal file
@ -0,0 +1,378 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.cli_apps.service import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
||||
|
||||
|
||||
def _write_cache(path: Path, registry: dict) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps({"_cached_at": time.time(), "data": registry}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _manager(tmp_path: Path) -> CliAppManager:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
return CliAppManager(
|
||||
workspace=workspace,
|
||||
data_dir=tmp_path / "data",
|
||||
runtime=CliAppsRuntimeConfig(catalog_ttl_seconds=3600, install_timeout=5, run_timeout=5),
|
||||
)
|
||||
|
||||
|
||||
def _seed_catalog(manager: CliAppManager) -> None:
|
||||
harness = {
|
||||
"meta": {"updated": "2026-04-16"},
|
||||
"clis": [
|
||||
{
|
||||
"name": "gimp",
|
||||
"display_name": "GIMP",
|
||||
"version": "1.0.0",
|
||||
"description": "Image editing",
|
||||
"category": "image",
|
||||
"requires": "Python 3.10+",
|
||||
"install_cmd": "pip install cli-anything-gimp",
|
||||
"entry_point": "cli-anything-gimp",
|
||||
"skill_md": "skills/cli-anything-gimp/SKILL.md",
|
||||
}
|
||||
],
|
||||
}
|
||||
public = {
|
||||
"meta": {"updated": "2026-04-18"},
|
||||
"clis": [
|
||||
{
|
||||
"name": "gimp",
|
||||
"display_name": "GIMP",
|
||||
"description": "Public duplicate entry",
|
||||
},
|
||||
{
|
||||
"name": "jimeng",
|
||||
"display_name": "Jimeng",
|
||||
"version": "latest",
|
||||
"description": "Script install",
|
||||
"category": "ai",
|
||||
"install_strategy": "script",
|
||||
"install_cmd": "curl -fsSL https://example.invalid/install.sh | bash",
|
||||
"entry_point": "dreamina",
|
||||
},
|
||||
{
|
||||
"name": "feishu",
|
||||
"display_name": "Feishu/Lark CLI",
|
||||
"version": "latest",
|
||||
"description": "Official Lark CLI",
|
||||
"category": "communication",
|
||||
"package_manager": "npm",
|
||||
"npm_package": "@larksuite/cli",
|
||||
"install_cmd": "npm install -g @larksuite/cli",
|
||||
"entry_point": "lark-cli",
|
||||
},
|
||||
{
|
||||
"name": "dify-workflow",
|
||||
"display_name": "Dify Workflow",
|
||||
"version": "latest",
|
||||
"description": "Run Dify workflows",
|
||||
"category": "ai",
|
||||
"install_cmd": "pip install cli-anything-dify-workflow",
|
||||
"entry_point": "cli-anything-dify-workflow",
|
||||
},
|
||||
{
|
||||
"name": "shopify",
|
||||
"display_name": "Shopify CLI",
|
||||
"version": "latest",
|
||||
"description": "Shopify",
|
||||
"category": "web",
|
||||
"package_manager": "npm",
|
||||
"npm_package": "@shopify/cli",
|
||||
"install_cmd": "npm install -g @shopify/cli",
|
||||
"entry_point": "shopify",
|
||||
},
|
||||
{
|
||||
"name": "clibrowser",
|
||||
"display_name": "clibrowser",
|
||||
"version": "latest",
|
||||
"description": "Cargo install",
|
||||
"category": "web",
|
||||
"install_cmd": "cargo install --git https://example.invalid/clibrowser.git",
|
||||
"entry_point": "clibrowser",
|
||||
},
|
||||
{
|
||||
"name": "suno",
|
||||
"display_name": "Suno CLI",
|
||||
"version": "latest",
|
||||
"description": "python3 pip install",
|
||||
"category": "music",
|
||||
"package_manager": "pip",
|
||||
"install_strategy": "command",
|
||||
"install_cmd": "python3 -m pip install git+https://example.invalid/suno-cli.git",
|
||||
"uninstall_cmd": "python3 -m pip uninstall -y suno-cli",
|
||||
"entry_point": "suno",
|
||||
},
|
||||
],
|
||||
}
|
||||
_write_cache(manager._cache_path("harness"), harness)
|
||||
_write_cache(manager._cache_path("public"), public)
|
||||
|
||||
|
||||
def test_payload_merges_catalog_and_marks_unsupported_installs(tmp_path: Path) -> None:
|
||||
manager = _manager(tmp_path)
|
||||
_seed_catalog(manager)
|
||||
|
||||
payload = manager.payload()
|
||||
|
||||
assert payload["catalog_updated_at"] == "2026-04-18"
|
||||
apps = {app["name"]: app for app in payload["apps"]}
|
||||
assert set(apps) == {
|
||||
"clibrowser",
|
||||
"dify-workflow",
|
||||
"feishu",
|
||||
"gimp",
|
||||
"jimeng",
|
||||
"shopify",
|
||||
"suno",
|
||||
}
|
||||
assert apps["gimp"]["install_supported"] is True
|
||||
assert apps["gimp"]["source"] == "harness+public"
|
||||
assert apps["gimp"]["description"] == "Public duplicate entry"
|
||||
assert apps["clibrowser"]["install_supported"] is False
|
||||
assert apps["jimeng"]["install_supported"] is False
|
||||
assert apps["suno"]["install_supported"] is True
|
||||
assert apps["gimp"]["logo_url"]
|
||||
assert apps["dify-workflow"]["logo_url"] == "https://cdn.simpleicons.org/dify/155EEF"
|
||||
assert apps["feishu"]["logo_url"] == (
|
||||
"https://www.google.com/s2/favicons?domain=larksuite.com&sz=64"
|
||||
)
|
||||
assert apps["jimeng"]["logo_url"] == "https://cdn.simpleicons.org/bytedance/3C8CFF"
|
||||
assert apps["clibrowser"]["logo_url"] == (
|
||||
"https://www.google.com/s2/favicons?domain=github.com/allthingssecurity/clibrowser&sz=64"
|
||||
)
|
||||
|
||||
|
||||
def test_install_dispatches_safe_pip_and_installs_skill(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
manager = _manager(tmp_path)
|
||||
_seed_catalog(manager)
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def fake_run(argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
|
||||
calls.append(argv)
|
||||
return subprocess.CompletedProcess(argv, 0, stdout="ok", stderr="")
|
||||
|
||||
monkeypatch.setattr(manager, "_run_argv", fake_run)
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"_fetch_skill_content",
|
||||
lambda app: "---\nname: cli-anything-gimp\ndescription: GIMP\n---\n# GIMP\n",
|
||||
)
|
||||
|
||||
payload = manager.install("gimp")
|
||||
|
||||
assert calls == [[sys.executable, "-m", "pip", "install", "cli-anything-gimp"]]
|
||||
assert payload["last_action"]["ok"] is True
|
||||
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
||||
assert installed["gimp"]["entry_point"] == "cli-anything-gimp"
|
||||
skill = manager.workspace / "skills" / "cli-app-gimp" / "SKILL.md"
|
||||
assert skill.is_file()
|
||||
assert 'run_cli_app` tool with `name="gimp"' in skill.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_installed_state_writes_atomically_without_temp_leftovers(tmp_path: Path) -> None:
|
||||
manager = _manager(tmp_path)
|
||||
|
||||
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
|
||||
manager._save_installed({"zoom": {"entry_point": "cli-anything-zoom"}})
|
||||
|
||||
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
||||
assert set(installed) == {"zoom"}
|
||||
assert not list(manager.installed_path.parent.glob(".installed.json.*.tmp"))
|
||||
|
||||
|
||||
def test_fetch_skill_content_rejects_untrusted_urls(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
manager = _manager(tmp_path)
|
||||
|
||||
def fail_get(*args, **kwargs):
|
||||
raise AssertionError("untrusted skill URL should not be fetched")
|
||||
|
||||
monkeypatch.setattr("nanobot.cli_apps.service.httpx.get", fail_get)
|
||||
|
||||
assert manager._fetch_skill_content({
|
||||
"name": "evil",
|
||||
"skill_md": "https://example.com/SKILL.md",
|
||||
}) is None
|
||||
assert manager._fetch_skill_content({
|
||||
"name": "evil",
|
||||
"skill_md": "skills/../evil/SKILL.md",
|
||||
}) is None
|
||||
|
||||
|
||||
def test_fetch_skill_content_allows_cli_anything_raw_skill_url(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
manager = _manager(tmp_path)
|
||||
seen: list[str] = []
|
||||
|
||||
class Response:
|
||||
text = "---\nname: cli-app-test\ndescription: Test\n---\n# Test\n"
|
||||
|
||||
@staticmethod
|
||||
def raise_for_status() -> None:
|
||||
return None
|
||||
|
||||
def fake_get(url: str, **kwargs):
|
||||
seen.append(url)
|
||||
return Response()
|
||||
|
||||
monkeypatch.setattr("nanobot.cli_apps.service.httpx.get", fake_get)
|
||||
|
||||
content = manager._fetch_skill_content({
|
||||
"name": "gimp",
|
||||
"skill_md": "https://raw.githubusercontent.com/HKUDS/CLI-Anything/main/skills/cli-anything-gimp/SKILL.md",
|
||||
})
|
||||
|
||||
assert content and "# Test" in content
|
||||
assert seen == [
|
||||
"https://raw.githubusercontent.com/HKUDS/CLI-Anything/main/skills/cli-anything-gimp/SKILL.md"
|
||||
]
|
||||
|
||||
|
||||
def test_uninstall_removes_installed_state_and_generated_skill(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
manager = _manager(tmp_path)
|
||||
_seed_catalog(manager)
|
||||
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
|
||||
skill_dir = manager.workspace / "skills" / "cli-app-gimp"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text("# GIMP\n", encoding="utf-8")
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"_run_argv",
|
||||
lambda argv, *, timeout: subprocess.CompletedProcess(argv, 0, stdout="ok", stderr=""),
|
||||
)
|
||||
|
||||
payload = manager.uninstall("gimp")
|
||||
|
||||
assert payload["last_action"]["ok"] is True
|
||||
assert "gimp" not in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
||||
assert not skill_dir.exists()
|
||||
|
||||
|
||||
def test_uninstall_uses_safe_python_m_pip_uninstall_command(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
manager = _manager(tmp_path)
|
||||
_seed_catalog(manager)
|
||||
manager._save_installed({"suno": {"entry_point": "suno"}})
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def fake_run(argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
|
||||
calls.append(argv)
|
||||
return subprocess.CompletedProcess(argv, 0, stdout="ok", stderr="")
|
||||
|
||||
monkeypatch.setattr(manager, "_run_argv", fake_run)
|
||||
|
||||
payload = manager.uninstall("suno")
|
||||
|
||||
assert calls == [[sys.executable, "-m", "pip", "uninstall", "-y", "suno-cli"]]
|
||||
assert payload["last_action"]["ok"] is True
|
||||
|
||||
|
||||
def test_mentioned_installed_apps_only_returns_installed_mentions(tmp_path: Path) -> None:
|
||||
manager = _manager(tmp_path)
|
||||
manager._save_installed(
|
||||
{
|
||||
"gimp": {"entry_point": "cli-anything-gimp", "source": "harness"},
|
||||
"zoom": {"entry_point": "cli-anything-zoom", "source": "public"},
|
||||
}
|
||||
)
|
||||
|
||||
mentions = manager.mentioned_installed_apps("use @zoom and @krita, then @GIMP")
|
||||
|
||||
assert mentions == [
|
||||
{
|
||||
"name": "zoom",
|
||||
"entry_point": "cli-anything-zoom",
|
||||
"source": "public",
|
||||
"skill": "skills/cli-app-zoom/SKILL.md",
|
||||
"tool": "run_cli_app",
|
||||
},
|
||||
{
|
||||
"name": "gimp",
|
||||
"entry_point": "cli-anything-gimp",
|
||||
"source": "harness",
|
||||
"skill": "skills/cli-app-gimp/SKILL.md",
|
||||
"tool": "run_cli_app",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_install_rejects_unknown_and_script_strategy(tmp_path: Path) -> None:
|
||||
manager = _manager(tmp_path)
|
||||
_seed_catalog(manager)
|
||||
|
||||
with pytest.raises(CliAppError, match="not found"):
|
||||
manager.install("missing")
|
||||
|
||||
with pytest.raises(CliAppError, match="unsupported"):
|
||||
manager.install("jimeng")
|
||||
|
||||
|
||||
def test_run_installed_cli_uses_argv_without_shell(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
manager = _manager(tmp_path)
|
||||
_seed_catalog(manager)
|
||||
bin_dir = tmp_path / "bin"
|
||||
bin_dir.mkdir()
|
||||
cli = bin_dir / "cli-anything-gimp"
|
||||
cli.write_text(
|
||||
"#!/usr/bin/env python3\n"
|
||||
"import sys\n"
|
||||
"print('ARGS=' + repr(sys.argv[1:]))\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
cli.chmod(cli.stat().st_mode | stat.S_IEXEC)
|
||||
monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}")
|
||||
manager._save_installed(
|
||||
{
|
||||
"gimp": {
|
||||
"version": "1.0.0",
|
||||
"entry_point": "cli-anything-gimp",
|
||||
"source": "harness",
|
||||
"strategy": "pip",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
result = manager.run("gimp", ["project", "list"], json_output=True)
|
||||
|
||||
assert "CLI app 'gimp' exited 0" in result
|
||||
assert "['--json', 'project', 'list']" in result
|
||||
|
||||
|
||||
def test_run_blocks_working_dir_outside_workspace(tmp_path: Path) -> None:
|
||||
manager = _manager(tmp_path)
|
||||
_seed_catalog(manager)
|
||||
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
|
||||
|
||||
with pytest.raises(CliAppError, match="outside the configured workspace"):
|
||||
manager.run("gimp", working_dir="/etc", restrict_to_workspace=True)
|
||||
121
tests/cli_apps/test_tool.py
Normal file
121
tests/cli_apps/test_tool.py
Normal file
@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from nanobot.agent.tools.cli_apps import CliAppsTool
|
||||
from nanobot.cli_apps.service import CliAppManager, CliAppsRuntimeConfig
|
||||
|
||||
|
||||
def _write_cache(path: Path, registry: dict) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps({"_cached_at": time.time(), "data": registry}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def test_run_cli_app_uses_installed_registry_app(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
data_dir = tmp_path / "data"
|
||||
registry = {
|
||||
"meta": {"updated": "2026-04-16"},
|
||||
"clis": [
|
||||
{
|
||||
"name": "gimp",
|
||||
"display_name": "GIMP",
|
||||
"version": "1.0.0",
|
||||
"description": "Image editing",
|
||||
"category": "image",
|
||||
"install_cmd": "pip install cli-anything-gimp",
|
||||
"entry_point": "cli-anything-gimp",
|
||||
}
|
||||
],
|
||||
}
|
||||
_write_cache(data_dir / "harness_registry_cache.json", registry)
|
||||
_write_cache(data_dir / "public_registry_cache.json", {"meta": {}, "clis": []})
|
||||
CliAppManager(workspace=workspace, data_dir=data_dir)._save_installed(
|
||||
{"gimp": {"entry_point": "cli-anything-gimp"}}
|
||||
)
|
||||
bin_dir = tmp_path / "bin"
|
||||
bin_dir.mkdir()
|
||||
cli = bin_dir / "cli-anything-gimp"
|
||||
cli.write_text(
|
||||
"#!/usr/bin/env python3\n"
|
||||
"import sys\n"
|
||||
"print('tool:' + ' '.join(sys.argv[1:]))\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
cli.chmod(cli.stat().st_mode | stat.S_IEXEC)
|
||||
monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}")
|
||||
monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
|
||||
|
||||
tool = CliAppsTool(
|
||||
workspace=workspace,
|
||||
restrict_to_workspace=True,
|
||||
runtime=CliAppsRuntimeConfig(run_timeout=5),
|
||||
)
|
||||
assert tool.name == "run_cli_app"
|
||||
|
||||
result = asyncio.run(
|
||||
tool.execute(
|
||||
name="gimp",
|
||||
args=["project", "list"],
|
||||
json=True,
|
||||
working_dir=str(workspace),
|
||||
)
|
||||
)
|
||||
|
||||
assert "CLI app 'gimp' exited 0" in result
|
||||
assert "tool:--json project list" in result
|
||||
|
||||
|
||||
def test_run_cli_app_rejects_uninstalled_app(tmp_path: Path, monkeypatch) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
data_dir = tmp_path / "data"
|
||||
registry = {
|
||||
"meta": {"updated": "2026-04-16"},
|
||||
"clis": [
|
||||
{
|
||||
"name": "gimp",
|
||||
"display_name": "GIMP",
|
||||
"version": "1.0.0",
|
||||
"description": "Image editing",
|
||||
"category": "image",
|
||||
"install_cmd": "pip install cli-anything-gimp",
|
||||
"entry_point": "cli-anything-gimp",
|
||||
}
|
||||
],
|
||||
}
|
||||
_write_cache(data_dir / "harness_registry_cache.json", registry)
|
||||
_write_cache(data_dir / "public_registry_cache.json", {"meta": {}, "clis": []})
|
||||
monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
|
||||
tool = CliAppsTool(workspace=workspace, restrict_to_workspace=True)
|
||||
|
||||
result = asyncio.run(tool.execute(name="gimp"))
|
||||
|
||||
assert "not installed" in result
|
||||
|
||||
|
||||
def test_run_cli_app_description_names_only_settings_installed_apps(tmp_path: Path, monkeypatch) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
data_dir = tmp_path / "data"
|
||||
CliAppManager(workspace=workspace, data_dir=data_dir)._save_installed(
|
||||
{"drawio": {"entry_point": "cli-anything-drawio"}}
|
||||
)
|
||||
monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
|
||||
|
||||
tool = CliAppsTool(workspace=workspace)
|
||||
|
||||
assert "Settings CLI Apps: drawio" in tool.description
|
||||
assert "ordinary system CLIs such as git, gh" in tool.description
|
||||
64
tests/cli_apps/test_utils.py
Normal file
64
tests/cli_apps/test_utils.py
Normal file
@ -0,0 +1,64 @@
|
||||
"""Tests for CLI Apps loop helpers."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from nanobot.cli_apps.service import CliAppManager
|
||||
from nanobot.cli_apps.utils import runtime_lines, session_extra
|
||||
|
||||
|
||||
def test_session_extra_returns_cli_apps_only_when_present() -> None:
|
||||
cli_apps = [{"name": "zoom"}]
|
||||
assert session_extra({"cli_apps": cli_apps}) == {"cli_apps": cli_apps}
|
||||
assert session_extra({}) == {}
|
||||
assert session_extra(None) == {}
|
||||
|
||||
|
||||
def test_cli_app_mentions_inject_runtime_metadata(tmp_path, monkeypatch):
|
||||
data_dir = tmp_path / "data"
|
||||
monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
|
||||
manager = CliAppManager(workspace=tmp_path)
|
||||
manager._save_installed(
|
||||
{
|
||||
"zoom": {
|
||||
"entry_point": "cli-anything-zoom",
|
||||
"source": "harness",
|
||||
},
|
||||
"krita": {
|
||||
"entry_point": "cli-anything-krita",
|
||||
"source": "harness",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
lines = runtime_lines(
|
||||
SimpleNamespace(content="please use @zoom tonight; ignore @krita?", metadata={}),
|
||||
tmp_path,
|
||||
)
|
||||
|
||||
joined = "\n".join(lines)
|
||||
assert "CLI App Mention: @zoom" in joined
|
||||
assert "tool=run_cli_app" in joined
|
||||
assert "entry_point=cli-anything-zoom" in joined
|
||||
assert "skill=skills/cli-app-zoom/SKILL.md" in joined
|
||||
|
||||
|
||||
def test_structured_cli_app_attachment_injects_runtime_metadata(tmp_path):
|
||||
lines = runtime_lines(
|
||||
SimpleNamespace(
|
||||
content="please use @zoom tonight",
|
||||
metadata={
|
||||
"cli_apps": [{
|
||||
"name": "zoom",
|
||||
"entry_point": "cli-anything-zoom",
|
||||
"display_name": "Zoom",
|
||||
}],
|
||||
},
|
||||
),
|
||||
tmp_path,
|
||||
)
|
||||
|
||||
joined = "\n".join(lines)
|
||||
assert "CLI App Attachment: @zoom" in joined
|
||||
assert "tool=run_cli_app" in joined
|
||||
assert "entry_point=cli-anything-zoom" in joined
|
||||
assert "skill=skills/cli-app-zoom/SKILL.md" in joined
|
||||
@ -91,6 +91,7 @@ def test_discover_finds_concrete_tools():
|
||||
class_names = {cls.__name__ for cls in discovered}
|
||||
assert "ApplyPatchTool" in class_names
|
||||
assert "ExecTool" in class_names
|
||||
assert "CliAppsTool" in class_names
|
||||
assert "MessageTool" in class_names
|
||||
assert "SpawnTool" in class_names
|
||||
assert "WriteStdinTool" in class_names
|
||||
@ -366,6 +367,7 @@ def test_config_defaults():
|
||||
assert config.tools.my.enable is True
|
||||
assert config.tools.my.allow_set is False
|
||||
assert config.tools.image_generation.enabled is False
|
||||
assert config.tools.cli_apps.enable is True
|
||||
assert config.tools.restrict_to_workspace is False
|
||||
|
||||
|
||||
|
||||
@ -143,6 +143,50 @@ def test_replay_tool_events_dedupes_finish_after_start() -> None:
|
||||
'exec({"cmd": "ls"})',
|
||||
'read_file({"path": "notes.md"})',
|
||||
]
|
||||
assert msgs[0]["toolEvents"][0]["phase"] == "end"
|
||||
assert msgs[0]["toolEvents"][0]["call_id"] == "call-exec"
|
||||
|
||||
|
||||
def test_replay_tool_events_keeps_phase_update_when_trace_is_deduped() -> None:
|
||||
args = {"name": "github", "args": ["repo", "view"], "json": "true"}
|
||||
msgs = replay_transcript_to_ui_messages([
|
||||
{
|
||||
"event": "message",
|
||||
"chat_id": "t-tool",
|
||||
"text": "",
|
||||
"kind": "tool_hint",
|
||||
"tool_events": [
|
||||
{
|
||||
"phase": "start",
|
||||
"call_id": "call-cli",
|
||||
"name": "run_cli_app",
|
||||
"arguments": args,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"event": "message",
|
||||
"chat_id": "t-tool",
|
||||
"text": "",
|
||||
"kind": "progress",
|
||||
"tool_events": [
|
||||
{
|
||||
"phase": "error",
|
||||
"call_id": "call-cli",
|
||||
"name": "run_cli_app",
|
||||
"arguments": args,
|
||||
"error": "Error: CLI app 'github' not found",
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
assert len(msgs) == 1
|
||||
assert msgs[0]["traces"] == [
|
||||
'run_cli_app({"name": "github", "args": ["repo", "view"], "json": "true"})',
|
||||
]
|
||||
assert msgs[0]["toolEvents"][0]["phase"] == "error"
|
||||
assert msgs[0]["toolEvents"][0]["error"] == "Error: CLI app 'github' not found"
|
||||
|
||||
|
||||
def test_replay_file_edit_progress_merges_after_interleaved_activity(tmp_path, monkeypatch) -> None:
|
||||
|
||||
148
webui/src/components/CliAppMentionText.tsx
Normal file
148
webui/src/components/CliAppMentionText.tsx
Normal file
@ -0,0 +1,148 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import type { CliAppInfo } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type CliAppMentionSegment =
|
||||
| { kind: "text"; text: string }
|
||||
| { kind: "cli"; text: string; app: CliAppInfo };
|
||||
|
||||
export function cliAppInitials(app: CliAppInfo): string {
|
||||
const value = app.display_name || app.name;
|
||||
return (
|
||||
value
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase())
|
||||
.join("") || app.name.slice(0, 2).toUpperCase()
|
||||
);
|
||||
}
|
||||
|
||||
export function splitCliAppMentionSegments(
|
||||
value: string,
|
||||
cliApps: CliAppInfo[],
|
||||
): CliAppMentionSegment[] {
|
||||
if (!value || cliApps.length === 0) return value ? [{ kind: "text", text: value }] : [];
|
||||
const appsByName = new Map(
|
||||
cliApps
|
||||
.filter((app) => app.installed)
|
||||
.map((app) => [app.name.toLowerCase(), app]),
|
||||
);
|
||||
if (appsByName.size === 0) return [{ kind: "text", text: value }];
|
||||
|
||||
const segments: CliAppMentionSegment[] = [];
|
||||
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 app = appsByName.get(name.toLowerCase());
|
||||
if (!app) 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) });
|
||||
}
|
||||
segments.push({ kind: "cli", text: value.slice(mentionStart, mentionEnd), app });
|
||||
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,
|
||||
}: {
|
||||
text: string;
|
||||
cliApps: CliAppInfo[];
|
||||
}) {
|
||||
const segments = splitCliAppMentionSegments(text, cliApps);
|
||||
if (!segments.some((segment) => segment.kind === "cli")) return <>{text}</>;
|
||||
return (
|
||||
<>
|
||||
{segments.map((segment, index) => {
|
||||
if (segment.kind === "text") {
|
||||
return <span key={`text-${index}`}>{segment.text}</span>;
|
||||
}
|
||||
return (
|
||||
<CliAppMentionToken
|
||||
key={`cli-${segment.app.name}-${index}`}
|
||||
app={segment.app}
|
||||
label={segment.text}
|
||||
variant="message"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CliAppMentionToken({
|
||||
app,
|
||||
label,
|
||||
variant,
|
||||
isHero = false,
|
||||
}: {
|
||||
app: CliAppInfo;
|
||||
label: string;
|
||||
variant: "composer" | "message";
|
||||
isHero?: boolean;
|
||||
}) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const color = app.brand_color || "hsl(var(--primary))";
|
||||
const mentionName = label.startsWith("@") ? label.slice(1) : label;
|
||||
const showLogo = Boolean(app.logo_url) && !failed;
|
||||
const testIdPrefix = variant === "composer" ? "composer" : "message";
|
||||
|
||||
return (
|
||||
<span
|
||||
data-testid={`${testIdPrefix}-cli-mention-${app.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}-cli-mention-logo-${app.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={app.logo_url ?? ""}
|
||||
alt=""
|
||||
className="h-full w-full object-contain"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{mentionName}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function alphaColor(color: string, percent: number): string {
|
||||
if (/^#[0-9a-f]{6}$/i.test(color)) {
|
||||
const alpha = Math.round((percent / 100) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
return `${color}${alpha}`;
|
||||
}
|
||||
return `color-mix(in srgb, ${color} ${percent}%, transparent)`;
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
@ -8,16 +9,18 @@ import {
|
||||
import { Check, ChevronRight, Copy, FileIcon, ImageIcon, PlaySquare, Sparkles, Wrench } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { CliAppMentionText } from "@/components/CliAppMentionText";
|
||||
import { ImageLightbox } from "@/components/ImageLightbox";
|
||||
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatTurnLatency } from "@/lib/format";
|
||||
import type { UIImage, UIMediaAttachment, UIMessage } from "@/lib/types";
|
||||
import type { CliAppInfo, UICliAppAttachment, 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[];
|
||||
}
|
||||
|
||||
/**
|
||||
@ -32,11 +35,16 @@ interface MessageBubbleProps {
|
||||
export function MessageBubble({
|
||||
message,
|
||||
showAssistantCopyAction = true,
|
||||
cliApps = [],
|
||||
}: MessageBubbleProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyResetRef = useRef<number | null>(null);
|
||||
const baseAnim = "animate-in fade-in-0 slide-in-from-bottom-1 duration-300";
|
||||
const mentionCliApps = useMemo(
|
||||
() => mergeCliMentionApps(cliApps, message.cliApps),
|
||||
[cliApps, message.cliApps],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@ -88,7 +96,7 @@ export function MessageBubble({
|
||||
"text-left text-[16px]/[1.75] whitespace-pre-wrap break-words",
|
||||
)}
|
||||
>
|
||||
{message.content}
|
||||
<CliAppMentionText text={message.content} cliApps={mentionCliApps} />
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
@ -158,6 +166,36 @@ export function MessageBubble({
|
||||
);
|
||||
}
|
||||
|
||||
function mergeCliMentionApps(
|
||||
cliApps: CliAppInfo[],
|
||||
attachments: UICliAppAttachment[] | undefined,
|
||||
): CliAppInfo[] {
|
||||
if (!attachments?.length) return cliApps;
|
||||
const byName = new Map(cliApps.map((app) => [app.name.toLowerCase(), app]));
|
||||
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 || "cli",
|
||||
description: existing?.description || "",
|
||||
requires: existing?.requires || "",
|
||||
source: existing?.source || "attached",
|
||||
entry_point: attachment.entry_point || existing?.entry_point || "",
|
||||
install_supported: existing?.install_supported ?? true,
|
||||
installed: true,
|
||||
available: existing?.available ?? true,
|
||||
status: existing?.status || "installed",
|
||||
logo_url: attachment.logo_url ?? existing?.logo_url ?? null,
|
||||
brand_color: attachment.brand_color ?? existing?.brand_color ?? null,
|
||||
skill_installed: existing?.skill_installed ?? true,
|
||||
});
|
||||
}
|
||||
return Array.from(byName.values());
|
||||
}
|
||||
|
||||
function MessageMedia({
|
||||
media,
|
||||
align,
|
||||
|
||||
@ -32,6 +32,8 @@ import {
|
||||
Loader2,
|
||||
LogOut,
|
||||
Moon,
|
||||
Package,
|
||||
PlayCircle,
|
||||
Orbit,
|
||||
Palette,
|
||||
Pencil,
|
||||
@ -41,6 +43,7 @@ import {
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Triangle,
|
||||
Waves,
|
||||
Zap,
|
||||
@ -59,6 +62,8 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
fetchSettings,
|
||||
fetchCliApps,
|
||||
runCliAppAction,
|
||||
updateImageGenerationSettings,
|
||||
updateProviderSettings,
|
||||
updateSettings,
|
||||
@ -67,6 +72,8 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
import type {
|
||||
CliAppInfo,
|
||||
CliAppsPayload,
|
||||
ImageGenerationSettingsUpdate,
|
||||
SettingsPayload,
|
||||
WebSearchSettingsUpdate,
|
||||
@ -79,6 +86,7 @@ type SettingsSectionKey =
|
||||
| "providers"
|
||||
| "image"
|
||||
| "web"
|
||||
| "cliApps"
|
||||
| "runtime"
|
||||
| "advanced";
|
||||
|
||||
@ -89,6 +97,7 @@ interface LocalPreferences {
|
||||
density: LocalDensity;
|
||||
activityMode: LocalActivityMode;
|
||||
codeWrap: boolean;
|
||||
brandLogos: boolean;
|
||||
}
|
||||
|
||||
interface AgentSettingsDraft {
|
||||
@ -110,6 +119,7 @@ const DEFAULT_LOCAL_PREFS: LocalPreferences = {
|
||||
density: "comfortable",
|
||||
activityMode: "auto",
|
||||
codeWrap: true,
|
||||
brandLogos: true,
|
||||
};
|
||||
|
||||
const LOCAL_UNCONFIGURED_PROVIDER_ORDER = new Map(
|
||||
@ -146,6 +156,7 @@ function readLocalPreferences(): LocalPreferences {
|
||||
density: parsed.density === "compact" ? "compact" : "comfortable",
|
||||
activityMode: parsed.activityMode === "expanded" ? "expanded" : "auto",
|
||||
codeWrap: parsed.codeWrap !== false,
|
||||
brandLogos: parsed.brandLogos !== false,
|
||||
};
|
||||
} catch {
|
||||
return DEFAULT_LOCAL_PREFS;
|
||||
@ -177,8 +188,11 @@ export function SettingsView({
|
||||
const { t } = useTranslation();
|
||||
const { token } = useClient();
|
||||
const [settings, setSettings] = useState<SettingsPayload | null>(null);
|
||||
const [cliApps, setCliApps] = useState<CliAppsPayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [cliAppsLoading, setCliAppsLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [cliAppsAction, setCliAppsAction] = useState<string | null>(null);
|
||||
const [providerSaving, setProviderSaving] = useState<string | null>(null);
|
||||
const [webSearchSaving, setWebSearchSaving] = useState(false);
|
||||
const [imageGenerationSaving, setImageGenerationSaving] = useState(false);
|
||||
@ -186,6 +200,12 @@ export function SettingsView({
|
||||
const [activeSection, setActiveSection] = useState<SettingsSectionKey>("overview");
|
||||
const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
|
||||
const [providerQuery, setProviderQuery] = useState("");
|
||||
const [cliAppsQuery, setCliAppsQuery] = useState("");
|
||||
const [cliAppsCategory, setCliAppsCategory] = useState("all");
|
||||
const [cliAppsInstallFilter, setCliAppsInstallFilter] = useState<"all" | "installed" | "notInstalled">("all");
|
||||
const [cliAppsMessage, setCliAppsMessage] = useState<string | null>(null);
|
||||
const [cliAppsError, setCliAppsError] = useState<string | null>(null);
|
||||
const [cliAppsFocusName, setCliAppsFocusName] = useState<string | null>(null);
|
||||
const [providerForms, setProviderForms] = useState<Record<string, { apiKey: string; apiBase: string }>>({});
|
||||
const [visibleProviderKeys, setVisibleProviderKeys] = useState<Record<string, boolean>>({});
|
||||
const [editingProviderKeys, setEditingProviderKeys] = useState<Record<string, boolean>>({});
|
||||
@ -285,6 +305,27 @@ export function SettingsView({
|
||||
};
|
||||
}, [applyPayload, token]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setCliAppsLoading(true);
|
||||
fetchCliApps(token)
|
||||
.then((payload) => {
|
||||
if (!cancelled) {
|
||||
setCliApps(payload);
|
||||
setCliAppsError(null);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setCliAppsError((err as Error).message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setCliAppsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(LOCAL_PREFS_STORAGE_KEY, JSON.stringify(localPrefs));
|
||||
@ -574,6 +615,26 @@ export function SettingsView({
|
||||
});
|
||||
};
|
||||
|
||||
const handleCliAppAction = async (
|
||||
action: "install" | "update" | "uninstall" | "test",
|
||||
name: string,
|
||||
) => {
|
||||
const key = `${action}:${name}`;
|
||||
setCliAppsAction(key);
|
||||
setCliAppsMessage(null);
|
||||
setCliAppsError(null);
|
||||
try {
|
||||
const payload = await runCliAppAction(token, action, name);
|
||||
setCliApps(payload);
|
||||
setCliAppsMessage(payload.last_action?.message ?? null);
|
||||
setCliAppsFocusName(action === "uninstall" ? null : name);
|
||||
} catch (err) {
|
||||
setCliAppsError((err as Error).message);
|
||||
} finally {
|
||||
setCliAppsAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const renderSection = () => {
|
||||
if (!settings) return null;
|
||||
switch (activeSection) {
|
||||
@ -618,6 +679,7 @@ export function SettingsView({
|
||||
editingProviderKeys={editingProviderKeys}
|
||||
providerSaving={providerSaving}
|
||||
query={providerQuery}
|
||||
showBrandLogos={localPrefs.brandLogos}
|
||||
onQueryChange={setProviderQuery}
|
||||
onToggleProvider={handleToggleProvider}
|
||||
onToggleProviderKey={toggleProviderKeyVisibility}
|
||||
@ -677,6 +739,26 @@ export function SettingsView({
|
||||
requiresRestartPending={pendingRestartSections.web}
|
||||
/>
|
||||
);
|
||||
case "cliApps":
|
||||
return (
|
||||
<CliAppsSettings
|
||||
payload={cliApps}
|
||||
loading={cliAppsLoading}
|
||||
query={cliAppsQuery}
|
||||
category={cliAppsCategory}
|
||||
installFilter={cliAppsInstallFilter}
|
||||
actionKey={cliAppsAction}
|
||||
message={cliAppsMessage}
|
||||
error={cliAppsError}
|
||||
focusName={cliAppsFocusName}
|
||||
showBrandLogos={localPrefs.brandLogos}
|
||||
onQueryChange={setCliAppsQuery}
|
||||
onCategoryChange={setCliAppsCategory}
|
||||
onInstallFilterChange={setCliAppsInstallFilter}
|
||||
onAction={handleCliAppAction}
|
||||
onBackToChat={onBackToChat}
|
||||
/>
|
||||
);
|
||||
case "runtime":
|
||||
return (
|
||||
<RuntimeSettings
|
||||
@ -752,6 +834,7 @@ const SETTINGS_NAV_ITEMS: Array<{ key: SettingsSectionKey; icon: LucideIcon; fal
|
||||
{ key: "providers", icon: KeyRound, fallback: "Providers" },
|
||||
{ key: "image", icon: ImageIcon, fallback: "Image" },
|
||||
{ key: "web", icon: Globe2, fallback: "Web" },
|
||||
{ key: "cliApps", icon: Package, fallback: "CLI Apps" },
|
||||
{ key: "runtime", icon: Server, fallback: "Runtime" },
|
||||
{ key: "advanced", icon: ShieldCheck, fallback: "Advanced" },
|
||||
];
|
||||
@ -1077,6 +1160,16 @@ function AppearanceSettings({
|
||||
label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={tx("settings.rows.brandLogos", "Brand logos")}
|
||||
description={tx("settings.help.brandLogos", "Show third-party provider and CLI logos in Settings.")}
|
||||
>
|
||||
<ToggleButton
|
||||
checked={localPrefs.brandLogos}
|
||||
onChange={(brandLogos) => onChangeLocalPrefs((prev) => ({ ...prev, brandLogos }))}
|
||||
label={localPrefs.brandLogos ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
</div>
|
||||
@ -1211,6 +1304,7 @@ function ProvidersSettings({
|
||||
editingProviderKeys,
|
||||
providerSaving,
|
||||
query,
|
||||
showBrandLogos,
|
||||
onQueryChange,
|
||||
onToggleProvider,
|
||||
onToggleProviderKey,
|
||||
@ -1229,6 +1323,7 @@ function ProvidersSettings({
|
||||
editingProviderKeys: Record<string, boolean>;
|
||||
providerSaving: string | null;
|
||||
query: string;
|
||||
showBrandLogos: boolean;
|
||||
onQueryChange: (query: string) => void;
|
||||
onToggleProvider: (provider: string) => void;
|
||||
onToggleProviderKey: (provider: string) => void;
|
||||
@ -1272,7 +1367,10 @@ function ProvidersSettings({
|
||||
className="flex min-h-[70px] w-full items-center justify-between gap-4 px-4 py-3 text-left transition-colors hover:bg-muted/35 sm:px-5"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-3">
|
||||
<ProviderIcon provider={provider.name} />
|
||||
<ProviderIcon
|
||||
provider={provider.name}
|
||||
showBrandLogos={showBrandLogos}
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-[15px] font-semibold leading-5 text-foreground">
|
||||
{provider.label}
|
||||
@ -1437,6 +1535,7 @@ function ProvidersSettings({
|
||||
>
|
||||
{filteredUnconfigured.map(renderProviderRow)}
|
||||
</ProviderSection>
|
||||
<ThirdPartyBrandNotice />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1831,6 +1930,383 @@ function WebSettings({
|
||||
);
|
||||
}
|
||||
|
||||
function CliAppsSettings({
|
||||
payload,
|
||||
loading,
|
||||
query,
|
||||
category,
|
||||
installFilter,
|
||||
actionKey,
|
||||
message,
|
||||
error,
|
||||
focusName,
|
||||
showBrandLogos,
|
||||
onQueryChange,
|
||||
onCategoryChange,
|
||||
onInstallFilterChange,
|
||||
onAction,
|
||||
onBackToChat,
|
||||
}: {
|
||||
payload: CliAppsPayload | null;
|
||||
loading: boolean;
|
||||
query: string;
|
||||
category: string;
|
||||
installFilter: "all" | "installed" | "notInstalled";
|
||||
actionKey: string | null;
|
||||
message: string | null;
|
||||
error: string | null;
|
||||
focusName: string | null;
|
||||
showBrandLogos: boolean;
|
||||
onQueryChange: (value: string) => void;
|
||||
onCategoryChange: (value: string) => void;
|
||||
onInstallFilterChange: (value: "all" | "installed" | "notInstalled") => void;
|
||||
onAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void;
|
||||
onBackToChat: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const apps = payload?.apps ?? [];
|
||||
const categories = useMemo(
|
||||
() => ["all", ...Array.from(new Set(apps.map((app) => app.category))).sort()],
|
||||
[apps],
|
||||
);
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const filteredApps = apps.filter((app) => {
|
||||
const categoryMatch = category === "all" || app.category === category;
|
||||
if (!categoryMatch) return false;
|
||||
if (installFilter === "installed" && !app.installed) return false;
|
||||
if (installFilter === "notInstalled" && app.installed) return false;
|
||||
if (!normalizedQuery) return true;
|
||||
return (
|
||||
app.display_name.toLowerCase().includes(normalizedQuery) ||
|
||||
app.name.toLowerCase().includes(normalizedQuery) ||
|
||||
app.description.toLowerCase().includes(normalizedQuery) ||
|
||||
app.category.toLowerCase().includes(normalizedQuery)
|
||||
);
|
||||
});
|
||||
const categoryLabel =
|
||||
category === "all"
|
||||
? tx("settings.cliApps.allCategories", "All categories")
|
||||
: category;
|
||||
const installFilterOptions = [
|
||||
{ value: "all", label: tx("settings.cliApps.filterAll", "All") },
|
||||
{ value: "installed", label: tx("settings.cliApps.filterInstalled", "Installed CLIs") },
|
||||
{ value: "notInstalled", label: tx("settings.cliApps.filterNotInstalled", "Not installed") },
|
||||
];
|
||||
const focusedApp = focusName
|
||||
? apps.find((app) => app.name === focusName && app.installed)
|
||||
: null;
|
||||
const visibleStatusMessage = error || (!focusedApp ? message : null);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<section className="space-y-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<SettingsSectionTitle>{tx("settings.sections.cliApps", "CLI Apps")}</SettingsSectionTitle>
|
||||
<p className="mt-1 text-[13px] text-muted-foreground">
|
||||
{tx("settings.cliApps.summary", "{{installed}} of {{total}} CLIs installed")
|
||||
.replace("{{installed}}", String(payload?.installed_count ?? 0))
|
||||
.replace("{{total}}", String(apps.length))}
|
||||
</p>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
value={installFilter}
|
||||
options={installFilterOptions}
|
||||
onChange={(value) => onInstallFilterChange(value as "all" | "installed" | "notInstalled")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" aria-hidden />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
placeholder={tx("settings.cliApps.searchPlaceholder", "Search CLIs")}
|
||||
className="h-10 w-full rounded-full border-border/65 bg-card/80 pl-9 text-[13px] shadow-sm sm:max-w-[320px]"
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-10 justify-between rounded-full bg-card/80 px-4">
|
||||
<span className="max-w-[180px] truncate">{categoryLabel}</span>
|
||||
<ChevronDown className="ml-2 h-3.5 w-3.5" aria-hidden />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="max-h-[320px] overflow-y-auto">
|
||||
{categories.map((item) => (
|
||||
<DropdownMenuItem key={item} onClick={() => onCategoryChange(item)}>
|
||||
{item === "all" ? tx("settings.cliApps.allCategories", "All categories") : item}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{visibleStatusMessage ? (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-[10px] border px-3.5 py-2.5 text-[12.5px]",
|
||||
error
|
||||
? "border-destructive/20 bg-destructive/5 text-destructive"
|
||||
: "border-border/55 bg-muted/35 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{visibleStatusMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{focusedApp ? (
|
||||
<CliAppReadyPanel
|
||||
app={focusedApp}
|
||||
showBrandLogos={showBrandLogos}
|
||||
onBackToChat={onBackToChat}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex h-36 items-center justify-center rounded-[8px] border border-border/45 bg-card/82 text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
|
||||
{tx("settings.cliApps.loading", "Loading CLI Apps...")}
|
||||
</div>
|
||||
) : (
|
||||
<section>
|
||||
<div className="grid gap-2">
|
||||
{filteredApps.map((app) => (
|
||||
<CliAppCard
|
||||
key={app.name}
|
||||
app={app}
|
||||
actionKey={actionKey}
|
||||
showBrandLogos={showBrandLogos}
|
||||
onAction={onAction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!filteredApps.length ? (
|
||||
<div className="rounded-[8px] border border-border/45 bg-card/82 px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
{tx("settings.cliApps.empty", "No CLI Apps match this filter.")}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)}
|
||||
<ThirdPartyBrandNotice />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CliAppReadyPanel({
|
||||
app,
|
||||
showBrandLogos,
|
||||
onBackToChat,
|
||||
}: {
|
||||
app: CliAppInfo;
|
||||
showBrandLogos: boolean;
|
||||
onBackToChat: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const prompt = t("settings.cliApps.readyPrompt", {
|
||||
name: app.name,
|
||||
defaultValue: "Use @{{name}} to inspect what this CLI can do.",
|
||||
});
|
||||
const copyPrompt = () => {
|
||||
if (!navigator.clipboard) return;
|
||||
void navigator.clipboard.writeText(prompt).then(() => {
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1400);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"rounded-[12px] border border-border/55 bg-card/88 px-4 py-3",
|
||||
"shadow-[0_8px_26px_rgba(15,23,42,0.055)]",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<CliAppLogo app={app} showBrandLogos={showBrandLogos} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">
|
||||
{app.display_name}
|
||||
</h3>
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10.5px] font-medium text-muted-foreground">
|
||||
<Check className="h-3 w-3 text-emerald-600 dark:text-emerald-300" aria-hidden />
|
||||
{t("settings.cliApps.readyStatus", { defaultValue: "Ready" })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex min-w-0 flex-wrap items-center gap-1.5 text-[12px] text-muted-foreground">
|
||||
<span className="font-mono">@{app.name}</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span className="truncate font-mono">{app.entry_point || app.name}</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span>{app.category}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={copyPrompt}
|
||||
className="h-8 rounded-full px-3 text-[12px] font-medium text-muted-foreground hover:bg-muted/65 hover:text-foreground"
|
||||
>
|
||||
{copied ? <Check className="mr-1.5 h-3.5 w-3.5" aria-hidden /> : null}
|
||||
{copied
|
||||
? t("settings.cliApps.readyCopied", { defaultValue: "Copied" })
|
||||
: t("settings.cliApps.readyTry", { name: app.name, defaultValue: "Try @{{name}}" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={onBackToChat}
|
||||
className="h-8 rounded-full px-3 text-[12px] font-semibold"
|
||||
>
|
||||
{t("settings.cliApps.openChat", { defaultValue: "Open chat" })}
|
||||
<ChevronRight className="ml-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CliAppCard({
|
||||
app,
|
||||
actionKey,
|
||||
showBrandLogos,
|
||||
onAction,
|
||||
}: {
|
||||
app: CliAppInfo;
|
||||
actionKey: string | null;
|
||||
showBrandLogos: boolean;
|
||||
onAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const installBusy = actionKey === `install:${app.name}`;
|
||||
const updateBusy = actionKey === `update:${app.name}`;
|
||||
const uninstallBusy = actionKey === `uninstall:${app.name}`;
|
||||
const testBusy = actionKey === `test:${app.name}`;
|
||||
const busy = installBusy || updateBusy || uninstallBusy || testBusy;
|
||||
|
||||
return (
|
||||
<article className="flex min-w-0 items-center gap-3 rounded-[8px] border border-border/45 bg-card/82 px-4 py-3 shadow-[0_6px_22px_rgba(15,23,42,0.045)]">
|
||||
<CliAppLogo app={app} showBrandLogos={showBrandLogos} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-baseline gap-2">
|
||||
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">
|
||||
{app.display_name}
|
||||
</h3>
|
||||
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[10.5px] font-medium text-muted-foreground">
|
||||
{app.category}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[12px] text-muted-foreground">
|
||||
{app.entry_point || app.name}
|
||||
</div>
|
||||
<p className="mt-1 truncate text-[12px] leading-5 text-muted-foreground">
|
||||
{app.requires
|
||||
? `${tx("settings.cliApps.requires", "Requires")}: ${app.requires}`
|
||||
: app.description || tx("settings.cliApps.noDescription", "No description available.")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{app.installed ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
className="h-8 rounded-full border-emerald-500/20 bg-emerald-500/10 px-3 text-[12px] font-semibold text-emerald-700 hover:bg-emerald-500/12 dark:text-emerald-300"
|
||||
>
|
||||
{busy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden /> : <Check className="mr-1.5 h-3.5 w-3.5" aria-hidden />}
|
||||
{tx("settings.cliApps.statusInstalled", "CLI installed")}
|
||||
<ChevronDown className="ml-1.5 h-3 w-3" aria-hidden />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem disabled={busy} onClick={() => onAction("test", app.name)}>
|
||||
<PlayCircle className="mr-2 h-3.5 w-3.5" aria-hidden />
|
||||
{tx("settings.cliApps.test", "Test CLI")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={busy} onClick={() => onAction("update", app.name)}>
|
||||
<RotateCcw className="mr-2 h-3.5 w-3.5" aria-hidden />
|
||||
{tx("settings.cliApps.update", "Update CLI")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={busy} onClick={() => onAction("uninstall", app.name)}>
|
||||
<Trash2 className="mr-2 h-3.5 w-3.5" aria-hidden />
|
||||
{tx("settings.cliApps.uninstall", "Uninstall CLI")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : app.install_supported ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => onAction("install", app.name)}
|
||||
className="h-8 rounded-full px-4 text-[12px] font-semibold"
|
||||
>
|
||||
{installBusy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden /> : null}
|
||||
{tx("settings.cliApps.install", "Install CLI")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled
|
||||
className="h-8 rounded-full px-3 text-[12px] font-semibold"
|
||||
>
|
||||
{tx("settings.cliApps.unavailable", "Unavailable")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function CliAppLogo({ app, showBrandLogos }: { app: CliAppInfo; showBrandLogos: boolean }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const bg = app.brand_color || "hsl(var(--muted))";
|
||||
const initials = app.display_name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase())
|
||||
.join("") || app.name.slice(0, 2).toUpperCase();
|
||||
if (showBrandLogos && app.logo_url && !failed) {
|
||||
return (
|
||||
<span
|
||||
className="grid h-11 w-11 shrink-0 place-items-center rounded-[8px] border border-border/45 bg-background"
|
||||
style={{ boxShadow: `inset 0 0 0 1px ${app.brand_color ?? "transparent"}22` }}
|
||||
>
|
||||
<img
|
||||
src={app.logo_url}
|
||||
alt=""
|
||||
className="h-6 w-6 object-contain"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="grid h-11 w-11 shrink-0 place-items-center rounded-[8px] text-[13px] font-semibold text-white"
|
||||
style={{ backgroundColor: bg }}
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RuntimeSettings({
|
||||
form,
|
||||
setForm,
|
||||
@ -2079,6 +2555,18 @@ function ByokEmptyState({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ThirdPartyBrandNotice() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<p className="px-1 text-[11.5px] leading-5 text-muted-foreground/75">
|
||||
{t("settings.legal.thirdPartyBrands", {
|
||||
defaultValue:
|
||||
"Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement.",
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function orderUnconfiguredProviders(
|
||||
providers: SettingsPayload["providers"],
|
||||
): SettingsPayload["providers"] {
|
||||
@ -2126,6 +2614,63 @@ function providerLabel(
|
||||
return providers.find((provider) => provider.name === value)?.label ?? value;
|
||||
}
|
||||
|
||||
interface ProviderBrand {
|
||||
logoUrl: string;
|
||||
color: string;
|
||||
initials: string;
|
||||
}
|
||||
|
||||
function faviconUrl(domain: string): string {
|
||||
return `https://www.google.com/s2/favicons?domain=${domain}&sz=64`;
|
||||
}
|
||||
|
||||
const PROVIDER_BRAND_ALIASES: Record<string, string> = {
|
||||
byteplus_coding_plan: "byteplus",
|
||||
minimax_anthropic: "minimax",
|
||||
openai_codex: "openai",
|
||||
volcengine_coding_plan: "volcengine",
|
||||
};
|
||||
|
||||
const PROVIDER_BRANDS: Record<string, ProviderBrand> = {
|
||||
aihubmix: { logoUrl: faviconUrl("aihubmix.com"), color: "#111827", initials: "AH" },
|
||||
ant_ling: { logoUrl: faviconUrl("ant-ling.com"), color: "#7C3AED", initials: "AL" },
|
||||
anthropic: { logoUrl: faviconUrl("anthropic.com"), color: "#D97757", initials: "A" },
|
||||
atomic_chat: { logoUrl: faviconUrl("atomic.chat"), color: "#111827", initials: "AC" },
|
||||
azure_openai: { logoUrl: faviconUrl("azure.microsoft.com"), color: "#0078D4", initials: "AZ" },
|
||||
bedrock: { logoUrl: faviconUrl("aws.amazon.com"), color: "#FF9900", initials: "AWS" },
|
||||
byteplus: { logoUrl: faviconUrl("byteplus.com"), color: "#325CFF", initials: "BP" },
|
||||
dashscope: { logoUrl: faviconUrl("dashscope.aliyun.com"), color: "#FF6A00", initials: "DS" },
|
||||
deepseek: { logoUrl: faviconUrl("deepseek.com"), color: "#4D6BFE", initials: "DS" },
|
||||
gemini: { logoUrl: faviconUrl("gemini.google.com"), color: "#4285F4", initials: "G" },
|
||||
github_copilot: { logoUrl: faviconUrl("github.com"), color: "#24292F", initials: "GH" },
|
||||
groq: { logoUrl: faviconUrl("groq.com"), color: "#F55036", initials: "GQ" },
|
||||
huggingface: { logoUrl: faviconUrl("huggingface.co"), color: "#FF9D00", initials: "HF" },
|
||||
lm_studio: { logoUrl: faviconUrl("lmstudio.ai"), color: "#111827", initials: "LM" },
|
||||
longcat: { logoUrl: faviconUrl("longcat.chat"), color: "#111827", initials: "LC" },
|
||||
minimax: { logoUrl: faviconUrl("minimax.io"), color: "#111827", initials: "MM" },
|
||||
mistral: { logoUrl: faviconUrl("mistral.ai"), color: "#FA520F", initials: "M" },
|
||||
moonshot: { logoUrl: faviconUrl("moonshot.ai"), color: "#111827", initials: "MS" },
|
||||
novita: { logoUrl: faviconUrl("novita.ai"), color: "#7C3AED", initials: "N" },
|
||||
nvidia: { logoUrl: faviconUrl("nvidia.com"), color: "#76B900", initials: "NV" },
|
||||
ollama: { logoUrl: faviconUrl("ollama.com"), color: "#111827", initials: "O" },
|
||||
openai: { logoUrl: faviconUrl("openai.com"), color: "#111827", initials: "AI" },
|
||||
openrouter: { logoUrl: faviconUrl("openrouter.ai"), color: "#111827", initials: "OR" },
|
||||
ovms: { logoUrl: faviconUrl("openvino.ai"), color: "#0071C5", initials: "OV" },
|
||||
qianfan: { logoUrl: faviconUrl("cloud.baidu.com"), color: "#2932E1", initials: "QF" },
|
||||
siliconflow: { logoUrl: faviconUrl("siliconflow.cn"), color: "#111827", initials: "SF" },
|
||||
skywork: { logoUrl: faviconUrl("skywork.ai"), color: "#5B5BF6", initials: "SW" },
|
||||
stepfun: { logoUrl: faviconUrl("stepfun.com"), color: "#2F6BFF", initials: "SF" },
|
||||
volcengine: { logoUrl: faviconUrl("volcengine.com"), color: "#1664FF", initials: "VE" },
|
||||
vllm: { logoUrl: faviconUrl("vllm.ai"), color: "#2563EB", initials: "VL" },
|
||||
xiaomi_mimo: { logoUrl: faviconUrl("xiaomimimo.com"), color: "#FF6900", initials: "MI" },
|
||||
zhipu: { logoUrl: faviconUrl("bigmodel.cn"), color: "#155EEF", initials: "Z" },
|
||||
};
|
||||
|
||||
function providerBrand(provider: string): ProviderBrand | null {
|
||||
const key = PROVIDER_BRAND_ALIASES[provider] ?? provider;
|
||||
return PROVIDER_BRANDS[key] ?? null;
|
||||
}
|
||||
|
||||
const PROVIDER_ICONS: Record<string, LucideIcon> = {
|
||||
custom: Hexagon,
|
||||
openrouter: Sparkles,
|
||||
@ -2160,8 +2705,44 @@ const PROVIDER_ICONS: Record<string, LucideIcon> = {
|
||||
nvidia: Zap,
|
||||
};
|
||||
|
||||
function ProviderIcon({ provider }: { provider: string }) {
|
||||
function ProviderIcon({
|
||||
provider,
|
||||
showBrandLogos,
|
||||
}: {
|
||||
provider: string;
|
||||
showBrandLogos: boolean;
|
||||
}) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const brand = providerBrand(provider);
|
||||
const Icon = PROVIDER_ICONS[provider] ?? Hexagon;
|
||||
if (showBrandLogos && brand?.logoUrl && !failed) {
|
||||
return (
|
||||
<span
|
||||
data-testid={`provider-logo-${provider}`}
|
||||
className="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-[14px] border border-border/45 bg-background shadow-[inset_0_0_0_1px_rgba(0,0,0,0.025)]"
|
||||
style={{ boxShadow: `inset 0 0 0 1px ${brand.color}22` }}
|
||||
>
|
||||
<img
|
||||
src={brand.logoUrl}
|
||||
alt=""
|
||||
className="h-6 w-6 object-contain"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (showBrandLogos && brand) {
|
||||
return (
|
||||
<span
|
||||
data-testid={`provider-logo-fallback-${provider}`}
|
||||
className="grid h-10 w-10 shrink-0 place-items-center rounded-[14px] text-[11px] font-semibold text-white shadow-[inset_0_0_0_1px_rgba(255,255,255,0.18)]"
|
||||
style={{ backgroundColor: brand.color }}
|
||||
aria-hidden
|
||||
>
|
||||
{brand.initials}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-2xl bg-muted text-foreground/82 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.025)] dark:bg-muted/70">
|
||||
<Icon className="h-5 w-5" strokeWidth={2} aria-hidden />
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { AlertCircle, ChevronRight, Layers } from "lucide-react";
|
||||
import { AlertCircle, ChevronRight, Layers, Terminal } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { cliAppInitials } from "@/components/CliAppMentionText";
|
||||
import { FileReferenceChip } from "@/components/FileReferenceChip";
|
||||
import { ReasoningBubble, StreamingLabelSheen, TraceGroup } from "@/components/MessageBubble";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UIFileEdit, UIMessage } from "@/lib/types";
|
||||
import type { CliAppInfo, ToolProgressEvent, UIFileEdit, UIMessage } from "@/lib/types";
|
||||
|
||||
/** Scrollport height for the Cursor-style “live trace” strip (tailwind spacing). */
|
||||
const CLUSTER_SCROLL_MAX_CLASS = "max-h-52";
|
||||
@ -24,6 +25,7 @@ export function isAgentActivityMember(m: UIMessage): boolean {
|
||||
interface ActivityCounts {
|
||||
reasoningSteps: number;
|
||||
toolCalls: number;
|
||||
cliCount: number;
|
||||
fileCount: number;
|
||||
added: number;
|
||||
deleted: number;
|
||||
@ -32,6 +34,8 @@ interface ActivityCounts {
|
||||
hasFailedFiles: boolean;
|
||||
primaryFilePath?: string;
|
||||
primaryFileTooltipPath?: string;
|
||||
primaryCliName?: string;
|
||||
primaryCliStatus?: CliRunStatus;
|
||||
}
|
||||
|
||||
interface FileEditSummary {
|
||||
@ -47,17 +51,41 @@ interface FileEditSummary {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function countActivity(messages: UIMessage[], fileEdits: FileEditSummary[]): ActivityCounts {
|
||||
interface CliRunSummary {
|
||||
key: string;
|
||||
name: string;
|
||||
args: string[];
|
||||
json: boolean;
|
||||
workingDir?: string;
|
||||
status: CliRunStatus;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
type CliRunStatus = "running" | "done" | "error";
|
||||
|
||||
function countActivity(
|
||||
messages: UIMessage[],
|
||||
fileEdits: FileEditSummary[],
|
||||
cliRuns: CliRunSummary[],
|
||||
): ActivityCounts {
|
||||
let reasoningSteps = 0;
|
||||
let toolCalls = 0;
|
||||
const cliCount = cliRuns.length;
|
||||
const primaryCli = cliRuns[cliRuns.length - 1];
|
||||
const primaryCliName = primaryCli?.name;
|
||||
const primaryCliStatus = primaryCli?.status;
|
||||
for (const m of messages) {
|
||||
if (isReasoningOnlyAssistant(m)) {
|
||||
reasoningSteps += 1;
|
||||
continue;
|
||||
}
|
||||
if (m.kind === "trace") {
|
||||
const lines = m.traces?.length ?? (m.content.trim() ? 1 : 0);
|
||||
toolCalls += lines;
|
||||
const lines = traceLines(m);
|
||||
for (const line of lines) {
|
||||
if (!isCliRunTraceLine(line)) {
|
||||
toolCalls += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let added = 0;
|
||||
@ -89,6 +117,7 @@ function countActivity(messages: UIMessage[], fileEdits: FileEditSummary[]): Act
|
||||
return {
|
||||
reasoningSteps,
|
||||
toolCalls,
|
||||
cliCount,
|
||||
fileCount: fileEdits.length,
|
||||
added,
|
||||
deleted,
|
||||
@ -97,6 +126,8 @@ function countActivity(messages: UIMessage[], fileEdits: FileEditSummary[]): Act
|
||||
hasFailedFiles: fileEdits.length > 0 && failedFileCount === fileEdits.length,
|
||||
primaryFilePath,
|
||||
primaryFileTooltipPath,
|
||||
primaryCliName,
|
||||
primaryCliStatus,
|
||||
};
|
||||
}
|
||||
|
||||
@ -105,6 +136,7 @@ interface AgentActivityClusterProps {
|
||||
/** True while the session turn is still running (drives “Working…” copy + header sheen). */
|
||||
isTurnStreaming: boolean;
|
||||
hasBodyBelow: boolean;
|
||||
cliApps?: CliAppInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
@ -115,15 +147,22 @@ export function AgentActivityCluster({
|
||||
messages,
|
||||
isTurnStreaming,
|
||||
hasBodyBelow,
|
||||
cliApps = [],
|
||||
}: AgentActivityClusterProps) {
|
||||
const { t } = useTranslation();
|
||||
const fileEdits = useMemo(
|
||||
() => summarizeFileEdits(collectFileEdits(messages), isTurnStreaming),
|
||||
[messages, isTurnStreaming],
|
||||
);
|
||||
const cliRuns = useMemo(() => collectCliRuns(messages), [messages]);
|
||||
const cliAppsByName = useMemo(
|
||||
() => new Map(cliApps.map((app) => [app.name.toLowerCase(), app])),
|
||||
[cliApps],
|
||||
);
|
||||
const {
|
||||
reasoningSteps,
|
||||
toolCalls,
|
||||
cliCount,
|
||||
fileCount,
|
||||
added,
|
||||
deleted,
|
||||
@ -132,7 +171,9 @@ export function AgentActivityCluster({
|
||||
hasFailedFiles,
|
||||
primaryFilePath,
|
||||
primaryFileTooltipPath,
|
||||
} = countActivity(messages, fileEdits);
|
||||
primaryCliName,
|
||||
primaryCliStatus,
|
||||
} = countActivity(messages, fileEdits, cliRuns);
|
||||
const hasPendingFileEdit = fileEdits.some((edit) => edit.pending);
|
||||
|
||||
const [userToggledOuter, setUserToggledOuter] = useState(false);
|
||||
@ -148,7 +189,7 @@ export function AgentActivityCluster({
|
||||
const headerBusy = fileCount > 0 ? hasEditingFiles : isTurnStreaming;
|
||||
const singleFilePath = fileCount === 1 ? primaryFilePath : undefined;
|
||||
const singleFileTooltipPath = fileCount === 1 ? primaryFileTooltipPath : undefined;
|
||||
const hasVisibleActivity = reasoningSteps > 0 || toolCalls > 0 || fileCount > 0;
|
||||
const hasVisibleActivity = reasoningSteps > 0 || toolCalls > 0 || cliCount > 0 || fileCount > 0;
|
||||
|
||||
const fileActivitySummary = fileCount > 0
|
||||
? hasPendingFileEdit && !singleFilePath
|
||||
@ -164,8 +205,22 @@ export function AgentActivityCluster({
|
||||
})
|
||||
: "";
|
||||
|
||||
const cliActivitySummary = cliCount > 0
|
||||
? cliCount === 1 && primaryCliName
|
||||
? t(cliActivitySummaryKey(primaryCliStatus, isTurnStreaming), {
|
||||
name: primaryCliName,
|
||||
defaultValue: cliActivitySummaryDefault(primaryCliStatus, isTurnStreaming),
|
||||
})
|
||||
: t(cliActivityManySummaryKey(cliRuns, isTurnStreaming), {
|
||||
count: cliCount,
|
||||
defaultValue: cliActivityManySummaryDefault(cliRuns, isTurnStreaming),
|
||||
})
|
||||
: "";
|
||||
|
||||
const summary = fileCount > 0
|
||||
? fileActivitySummary
|
||||
: cliCount > 0
|
||||
? cliActivitySummary
|
||||
: isTurnStreaming
|
||||
? reasoningSteps > 0
|
||||
? t("message.agentActivityLiveSummary", {
|
||||
@ -254,6 +309,8 @@ export function AgentActivityCluster({
|
||||
|
||||
if (!hasVisibleActivity) return null;
|
||||
|
||||
const HeaderIcon = cliCount > 0 && fileCount === 0 && toolCalls === 0 ? Terminal : Layers;
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", hasBodyBelow && "mb-2")}>
|
||||
<button
|
||||
@ -266,7 +323,7 @@ export function AgentActivityCluster({
|
||||
aria-expanded={outerExpanded}
|
||||
aria-label={summary}
|
||||
>
|
||||
<Layers className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<HeaderIcon className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-left">
|
||||
{singleFilePath ? (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||
@ -337,15 +394,25 @@ export function AgentActivityCluster({
|
||||
);
|
||||
}
|
||||
if (m.kind === "trace") {
|
||||
const hasTraceLines = (m.traces?.length ?? 0) > 0 || m.content.trim().length > 0;
|
||||
return hasTraceLines ? (
|
||||
const normalLines = traceLines(m).filter((line) => !parseCliRunTrace(line));
|
||||
return normalLines.length > 0 ? (
|
||||
<div key={m.id} className="flex flex-col gap-1">
|
||||
<TraceGroup message={m} animClass="" />
|
||||
<TraceGroup
|
||||
message={{
|
||||
...m,
|
||||
traces: normalLines,
|
||||
content: normalLines[normalLines.length - 1],
|
||||
}}
|
||||
animClass=""
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
{cliRuns.length ? (
|
||||
<CliRunGroup runs={cliRuns} active={isTurnStreaming} cliAppsByName={cliAppsByName} />
|
||||
) : null}
|
||||
{fileEdits.length ? <FileEditGroup edits={fileEdits} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
@ -359,6 +426,181 @@ function shortFileName(path: string): string {
|
||||
return path.split(/[\\/]/).pop() || path;
|
||||
}
|
||||
|
||||
function traceLines(message: UIMessage): string[] {
|
||||
if (message.traces?.length) return message.traces;
|
||||
return message.content.trim() ? [message.content] : [];
|
||||
}
|
||||
|
||||
const CLI_RUN_TOOL_NAMES = new Set(["run_cli_app", "cli_anything_run"]);
|
||||
const CLI_RUN_STATUS_RANK: Record<CliRunStatus, number> = { running: 1, done: 2, error: 3 };
|
||||
|
||||
function isCliRunTraceLine(line: string): boolean {
|
||||
return /^(run_cli_app|cli_anything_run)\(/.test(line.trim());
|
||||
}
|
||||
|
||||
function parseCliRunTrace(line: string, status: CliRunStatus = "running"): CliRunSummary | null {
|
||||
const match = /^(run_cli_app|cli_anything_run)\((.*)\)$/.exec(line.trim());
|
||||
if (!match) return null;
|
||||
const argsText = match[2].trim();
|
||||
let argsObject: unknown = {};
|
||||
if (argsText) {
|
||||
try {
|
||||
argsObject = JSON.parse(argsText);
|
||||
} catch {
|
||||
return {
|
||||
key: line,
|
||||
name: "cli",
|
||||
args: [argsText],
|
||||
json: false,
|
||||
status,
|
||||
};
|
||||
}
|
||||
}
|
||||
return cliRunFromArguments(argsObject, { key: line, status });
|
||||
}
|
||||
|
||||
function parseToolEventArguments(event: ToolProgressEvent): unknown {
|
||||
const fnArgs = (event as { function?: { arguments?: unknown } }).function?.arguments;
|
||||
const raw = fnArgs ?? event.arguments;
|
||||
if (typeof raw !== "string") return raw ?? {};
|
||||
if (!raw.trim()) return {};
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return { args: [raw] };
|
||||
}
|
||||
}
|
||||
|
||||
function cliRunStatusFromPhase(phase: unknown): CliRunStatus {
|
||||
if (phase === "error") return "error";
|
||||
if (phase === "end") return "done";
|
||||
return "running";
|
||||
}
|
||||
|
||||
function cliRunError(event: ToolProgressEvent): string | undefined {
|
||||
const error = event.error;
|
||||
if (typeof error === "string") return error;
|
||||
if (error && typeof error === "object") return JSON.stringify(error);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function cliRunFromArguments(
|
||||
argsObject: unknown,
|
||||
options: { key: string; status: CliRunStatus; error?: string },
|
||||
): CliRunSummary {
|
||||
if (!argsObject || typeof argsObject !== "object" || Array.isArray(argsObject)) {
|
||||
return {
|
||||
key: options.key,
|
||||
name: "cli",
|
||||
args: [],
|
||||
json: false,
|
||||
status: options.status,
|
||||
error: options.error,
|
||||
};
|
||||
}
|
||||
const record = argsObject as Record<string, unknown>;
|
||||
const appName = typeof record.name === "string" && record.name.trim()
|
||||
? record.name.trim()
|
||||
: "cli";
|
||||
const rawArgs = Array.isArray(record.args) ? record.args : [];
|
||||
const cliArgs = rawArgs.filter((item): item is string => typeof item === "string");
|
||||
return {
|
||||
key: options.key,
|
||||
name: appName,
|
||||
args: cliArgs,
|
||||
json: record.json === true || record.json === "true",
|
||||
workingDir: typeof record.working_dir === "string" ? record.working_dir : undefined,
|
||||
status: options.status,
|
||||
error: options.error,
|
||||
};
|
||||
}
|
||||
|
||||
function cliRunFromEvent(event: ToolProgressEvent): CliRunSummary | null {
|
||||
const name =
|
||||
typeof (event as { function?: { name?: unknown } }).function?.name === "string"
|
||||
? String((event as { function?: { name?: unknown } }).function?.name)
|
||||
: typeof event.name === "string"
|
||||
? event.name
|
||||
: "";
|
||||
if (!CLI_RUN_TOOL_NAMES.has(name)) return null;
|
||||
const argsObject = parseToolEventArguments(event);
|
||||
const key = event.call_id ? `call:${event.call_id}` : `${name}:${JSON.stringify(argsObject)}`;
|
||||
return cliRunFromArguments(argsObject, {
|
||||
key,
|
||||
status: cliRunStatusFromPhase(event.phase),
|
||||
error: cliRunError(event),
|
||||
});
|
||||
}
|
||||
|
||||
function mergeCliRun(existing: CliRunSummary | undefined, incoming: CliRunSummary): CliRunSummary {
|
||||
if (!existing) return incoming;
|
||||
return CLI_RUN_STATUS_RANK[incoming.status] >= CLI_RUN_STATUS_RANK[existing.status]
|
||||
? { ...existing, ...incoming }
|
||||
: existing;
|
||||
}
|
||||
|
||||
function collectCliRuns(messages: UIMessage[]): CliRunSummary[] {
|
||||
const runsByKey = new Map<string, CliRunSummary>();
|
||||
for (const message of messages) {
|
||||
if (message.kind !== "trace") continue;
|
||||
let hasStructuredCliRun = false;
|
||||
for (const event of message.toolEvents ?? []) {
|
||||
const run = cliRunFromEvent(event);
|
||||
if (!run) continue;
|
||||
hasStructuredCliRun = true;
|
||||
runsByKey.set(run.key, mergeCliRun(runsByKey.get(run.key), run));
|
||||
}
|
||||
if (hasStructuredCliRun) continue;
|
||||
for (const line of traceLines(message)) {
|
||||
const run = parseCliRunTrace(line);
|
||||
if (!run || runsByKey.has(run.key)) continue;
|
||||
runsByKey.set(run.key, run);
|
||||
}
|
||||
}
|
||||
return [...runsByKey.values()];
|
||||
}
|
||||
|
||||
function displayCliArg(arg: string): string {
|
||||
return /\s/.test(arg) ? JSON.stringify(arg) : arg;
|
||||
}
|
||||
|
||||
function formatCliArgs(run: CliRunSummary): string {
|
||||
const args = [...(run.json ? ["--json"] : []), ...run.args].map(displayCliArg);
|
||||
return args.join(" ");
|
||||
}
|
||||
|
||||
function cliActivitySummaryKey(status: CliRunStatus | undefined, active: boolean): string {
|
||||
if (status === "error") return "message.cliActivityFailedOne";
|
||||
return active && status === "running" ? "message.cliActivityRunningOne" : "message.cliActivityRanOne";
|
||||
}
|
||||
|
||||
function cliActivitySummaryDefault(status: CliRunStatus | undefined, active: boolean): string {
|
||||
if (status === "error") return "CLI failed @{{name}}";
|
||||
return `${active && status === "running" ? "Running" : "Ran"} CLI @{{name}}`;
|
||||
}
|
||||
|
||||
function cliActivityManySummaryKey(runs: CliRunSummary[], active: boolean): string {
|
||||
if (runs.some((run) => run.status === "error")) return "message.cliActivityFailedMany";
|
||||
return active && runs.some((run) => run.status === "running")
|
||||
? "message.cliActivityRunningMany"
|
||||
: "message.cliActivityRanMany";
|
||||
}
|
||||
|
||||
function cliActivityManySummaryDefault(runs: CliRunSummary[], active: boolean): string {
|
||||
if (runs.some((run) => run.status === "error")) return "{{count}} CLI failed";
|
||||
return `${active && runs.some((run) => run.status === "running") ? "Running" : "Ran"} {{count}} CLIs`;
|
||||
}
|
||||
|
||||
function cliRunLabelKey(run: CliRunSummary, active: boolean): string {
|
||||
if (run.status === "error") return "message.cliRunFailed";
|
||||
return active && run.status === "running" ? "message.cliRunRunning" : "message.cliRunRan";
|
||||
}
|
||||
|
||||
function cliRunLabelDefault(run: CliRunSummary, active: boolean): string {
|
||||
if (run.status === "error") return "CLI failed";
|
||||
return active && run.status === "running" ? "Running CLI" : "Ran CLI";
|
||||
}
|
||||
|
||||
function fileActivityVerb(editing: boolean, failed: boolean): string {
|
||||
if (failed) return "Failed";
|
||||
return editing ? "Editing" : "Edited";
|
||||
@ -519,6 +761,120 @@ function hasVisibleDiffStats(edit: Pick<FileEditSummary, "added" | "deleted">):
|
||||
return edit.added > 0 || edit.deleted > 0;
|
||||
}
|
||||
|
||||
function CliRunGroup({
|
||||
runs,
|
||||
active,
|
||||
cliAppsByName,
|
||||
}: {
|
||||
runs: CliRunSummary[];
|
||||
active: boolean;
|
||||
cliAppsByName: Map<string, CliAppInfo>;
|
||||
}) {
|
||||
if (runs.length === 0) return null;
|
||||
return (
|
||||
<ul className="space-y-1 border-l border-cyan-500/20 pl-3" data-testid="activity-cli-runs">
|
||||
{runs.map((run) => (
|
||||
<CliRunRow
|
||||
key={run.key}
|
||||
run={run}
|
||||
active={active}
|
||||
app={cliAppsByName.get(run.name.toLowerCase())}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function CliRunRow({ run, active, app }: { run: CliRunSummary; active: boolean; app?: CliAppInfo }) {
|
||||
const { t } = useTranslation();
|
||||
const [logoFailed, setLogoFailed] = useState(false);
|
||||
const args = formatCliArgs(run);
|
||||
const failed = run.status === "error";
|
||||
const rowActive = active && run.status === "running";
|
||||
const color = failed ? "#DC2626" : app?.brand_color || "#0891B2";
|
||||
const logoUrl = app?.logo_url && !logoFailed ? app.logo_url : null;
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
"grid min-w-0 grid-cols-[minmax(0,1fr)] rounded-[10px] border px-2.5 py-2 text-xs",
|
||||
"shadow-[0_6px_18px_rgba(15,23,42,0.045)] transition-colors",
|
||||
)}
|
||||
style={{
|
||||
borderColor: alphaColor(color, rowActive ? 34 : failed ? 28 : 22),
|
||||
backgroundColor: alphaColor(color, rowActive ? 9 : failed ? 7 : 6),
|
||||
}}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
data-testid={`activity-cli-logo-${run.name.toLowerCase()}`}
|
||||
className={cn(
|
||||
"grid h-7 w-7 shrink-0 place-items-center overflow-hidden rounded-[8px] border text-[10px] font-semibold text-white",
|
||||
rowActive && "animate-pulse",
|
||||
)}
|
||||
style={{
|
||||
borderColor: alphaColor(color, 26),
|
||||
backgroundColor: logoUrl ? "hsl(var(--background))" : color,
|
||||
boxShadow: `0 0 0 3px ${alphaColor(color, rowActive ? 10 : 6)}`,
|
||||
}}
|
||||
>
|
||||
{logoUrl ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
className="h-[70%] w-[70%] object-contain"
|
||||
onError={() => setLogoFailed(true)}
|
||||
/>
|
||||
) : app ? (
|
||||
cliAppInitials(app).slice(0, 2)
|
||||
) : (
|
||||
<Terminal className="h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<StreamingLabelSheen active={rowActive} className="shrink-0 text-[12px]">
|
||||
{t(cliRunLabelKey(run, active), {
|
||||
defaultValue: cliRunLabelDefault(run, active),
|
||||
})}
|
||||
</StreamingLabelSheen>
|
||||
<span className="min-w-0 truncate font-mono text-[12px] font-semibold text-foreground/90">
|
||||
@{run.name}
|
||||
</span>
|
||||
{failed ? (
|
||||
<AlertCircle className="h-3 w-3 shrink-0 text-destructive/75" aria-hidden />
|
||||
) : null}
|
||||
</span>
|
||||
{args ? (
|
||||
<span className="mt-0.5 block truncate font-mono text-[11px] leading-relaxed text-muted-foreground/82">
|
||||
{args}
|
||||
</span>
|
||||
) : null}
|
||||
{run.error ? (
|
||||
<span className="mt-0.5 block truncate text-[10.5px] leading-relaxed text-destructive/70">
|
||||
{run.error}
|
||||
</span>
|
||||
) : null}
|
||||
{run.workingDir ? (
|
||||
<span className="mt-0.5 block truncate text-[10.5px] leading-relaxed text-muted-foreground/58">
|
||||
{run.workingDir}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function alphaColor(color: string, percent: number): string {
|
||||
if (/^#[0-9a-f]{6}$/i.test(color)) {
|
||||
const alpha = Math.round((percent / 100) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
return `${color}${alpha}`;
|
||||
}
|
||||
return `color-mix(in srgb, ${color} ${percent}%, transparent)`;
|
||||
}
|
||||
|
||||
function FileEditGroup({ edits }: { edits: FileEditSummary[] }) {
|
||||
if (edits.length === 0) return null;
|
||||
return (
|
||||
|
||||
@ -9,9 +9,16 @@ import {
|
||||
} from "react";
|
||||
|
||||
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
|
||||
import {
|
||||
CliAppMentionToken,
|
||||
cliAppInitials,
|
||||
splitCliAppMentionSegments,
|
||||
type CliAppMentionSegment,
|
||||
} from "@/components/CliAppMentionText";
|
||||
import {
|
||||
Activity,
|
||||
ArrowUp,
|
||||
AtSign,
|
||||
BookOpen,
|
||||
Check,
|
||||
ChevronDown,
|
||||
@ -41,7 +48,7 @@ import {
|
||||
} from "@/hooks/useAttachedImages";
|
||||
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
|
||||
import type { SendImage, SendOptions } from "@/hooks/useNanobotStream";
|
||||
import type { SlashCommand, GoalStateWsPayload } from "@/lib/types";
|
||||
import type { CliAppInfo, GoalStateWsPayload, OutboundCliAppMention, SlashCommand } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** ``<input accept>``: aligned with the server's MIME whitelist. SVG is
|
||||
@ -62,6 +69,7 @@ interface ThreadComposerProps {
|
||||
modelLabel?: string | null;
|
||||
variant?: "thread" | "hero";
|
||||
slashCommands?: SlashCommand[];
|
||||
cliApps?: CliAppInfo[];
|
||||
imageMode?: boolean;
|
||||
onImageModeChange?: (enabled: boolean) => void;
|
||||
onStop?: () => void;
|
||||
@ -98,6 +106,12 @@ interface SlashPaletteLayout {
|
||||
maxHeight: number;
|
||||
}
|
||||
|
||||
interface CliAppMentionQuery {
|
||||
query: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
function slashCommandI18nKey(command: string): string {
|
||||
return command.replace(/^\//, "").replace(/-/g, "_");
|
||||
}
|
||||
@ -167,6 +181,17 @@ function buildGoalMarkdownBody(summary: string, objective: string): string {
|
||||
return o || s;
|
||||
}
|
||||
|
||||
function cliAppMentionPayload(app: CliAppInfo): OutboundCliAppMention {
|
||||
return {
|
||||
name: app.name,
|
||||
display_name: app.display_name,
|
||||
category: app.category,
|
||||
entry_point: app.entry_point,
|
||||
logo_url: app.logo_url ?? null,
|
||||
brand_color: app.brand_color ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function RunElapsedStrip({
|
||||
startedAt,
|
||||
goalState,
|
||||
@ -371,6 +396,7 @@ export function ThreadComposer({
|
||||
modelLabel = null,
|
||||
variant = "thread",
|
||||
slashCommands = [],
|
||||
cliApps = [],
|
||||
imageMode: controlledImageMode,
|
||||
onImageModeChange,
|
||||
onStop,
|
||||
@ -382,6 +408,9 @@ export function ThreadComposer({
|
||||
const [inlineError, setInlineError] = useState<string | null>(null);
|
||||
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false);
|
||||
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0);
|
||||
const [cliAppMenuDismissed, setCliAppMenuDismissed] = useState(false);
|
||||
const [selectedCliAppIndex, setSelectedCliAppIndex] = useState(0);
|
||||
const [cursorPosition, setCursorPosition] = useState(0);
|
||||
const [uncontrolledImageMode, setUncontrolledImageMode] = useState(false);
|
||||
const [imageAspectRatio, setImageAspectRatio] = useState<ImageAspectRatio>("auto");
|
||||
const [aspectMenuOpen, setAspectMenuOpen] = useState(false);
|
||||
@ -491,6 +520,52 @@ export function ThreadComposer({
|
||||
}, [slashCommands, slashQuery, t]);
|
||||
|
||||
const showSlashMenu = filteredSlashCommands.length > 0;
|
||||
const cliAppMention = useMemo<CliAppMentionQuery | null>(() => {
|
||||
if (disabled || cliAppMenuDismissed) return null;
|
||||
const caret = Math.min(Math.max(cursorPosition, 0), value.length);
|
||||
const beforeCaret = value.slice(0, caret);
|
||||
const match = /(?:^|\s)@([a-z0-9_-]*)$/i.exec(beforeCaret);
|
||||
if (!match) return null;
|
||||
const query = match[1].toLowerCase();
|
||||
return {
|
||||
query,
|
||||
start: caret - query.length - 1,
|
||||
end: caret,
|
||||
};
|
||||
}, [cliAppMenuDismissed, cursorPosition, disabled, value]);
|
||||
|
||||
const filteredCliApps = useMemo(() => {
|
||||
if (!cliAppMention) return [];
|
||||
return cliApps
|
||||
.filter((app) => app.installed)
|
||||
.filter((app) => {
|
||||
const haystack = [
|
||||
app.name,
|
||||
app.display_name,
|
||||
app.category,
|
||||
app.description,
|
||||
app.entry_point,
|
||||
].join(" ").toLowerCase();
|
||||
return haystack.includes(cliAppMention.query);
|
||||
})
|
||||
.slice(0, 8);
|
||||
}, [cliAppMention, cliApps]);
|
||||
|
||||
const showCliAppMenu = filteredCliApps.length > 0;
|
||||
const showAnyPalette = showSlashMenu || showCliAppMenu;
|
||||
const mentionSegments = useMemo(
|
||||
() => splitCliAppMentionSegments(value, cliApps),
|
||||
[cliApps, value],
|
||||
);
|
||||
const hasCliMentionDecorations = mentionSegments.some((segment) => segment.kind === "cli");
|
||||
const activeCliMentionApps = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
return mentionSegments.flatMap((segment) => {
|
||||
if (segment.kind !== "cli" || seen.has(segment.app.name)) return [];
|
||||
seen.add(segment.app.name);
|
||||
return [segment.app];
|
||||
});
|
||||
}, [mentionSegments]);
|
||||
const [slashPaletteLayout, setSlashPaletteLayout] = useState<SlashPaletteLayout>({
|
||||
placement: "above",
|
||||
maxHeight: SLASH_PALETTE_MAX_HEIGHT_PX,
|
||||
@ -500,6 +575,10 @@ export function ThreadComposer({
|
||||
setSelectedCommandIndex(0);
|
||||
}, [slashQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedCliAppIndex(0);
|
||||
}, [cliAppMention?.query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCommandIndex >= filteredSlashCommands.length) {
|
||||
setSelectedCommandIndex(0);
|
||||
@ -507,22 +586,29 @@ export function ThreadComposer({
|
||||
}, [filteredSlashCommands.length, selectedCommandIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSlashMenu) return;
|
||||
if (selectedCliAppIndex >= filteredCliApps.length) {
|
||||
setSelectedCliAppIndex(0);
|
||||
}
|
||||
}, [filteredCliApps.length, selectedCliAppIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showAnyPalette) return;
|
||||
|
||||
const dismissOnPointerDown = (event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
if (target instanceof Node && formRef.current?.contains(target)) return;
|
||||
setSlashMenuDismissed(true);
|
||||
setCliAppMenuDismissed(true);
|
||||
};
|
||||
|
||||
document.addEventListener("pointerdown", dismissOnPointerDown, true);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", dismissOnPointerDown, true);
|
||||
};
|
||||
}, [showSlashMenu]);
|
||||
}, [showAnyPalette]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!showSlashMenu) return;
|
||||
if (!showAnyPalette) return;
|
||||
|
||||
const updateLayout = () => {
|
||||
const form = formRef.current;
|
||||
@ -554,7 +640,7 @@ export function ThreadComposer({
|
||||
window.removeEventListener("resize", updateLayout);
|
||||
document.removeEventListener("scroll", updateLayout, true);
|
||||
};
|
||||
}, [filteredSlashCommands.length, showSlashMenu]);
|
||||
}, [filteredCliApps.length, filteredSlashCommands.length, showAnyPalette]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!aspectMenuOpen) return;
|
||||
@ -602,12 +688,36 @@ export function ThreadComposer({
|
||||
(command: SlashCommand) => {
|
||||
setValue(command.argHint ? `${command.command} ` : command.command);
|
||||
setSlashMenuDismissed(true);
|
||||
setCliAppMenuDismissed(false);
|
||||
setInlineError(null);
|
||||
resizeTextarea();
|
||||
},
|
||||
[resizeTextarea],
|
||||
);
|
||||
|
||||
const chooseCliApp = useCallback(
|
||||
(app: CliAppInfo) => {
|
||||
if (!cliAppMention) return;
|
||||
const suffix = value.slice(cliAppMention.end);
|
||||
const mention = `@${app.name}${suffix.startsWith(" ") ? "" : " "}`;
|
||||
const next = `${value.slice(0, cliAppMention.start)}${mention}${suffix}`;
|
||||
const nextCursor = cliAppMention.start + mention.length;
|
||||
setValue(next);
|
||||
setCursorPosition(nextCursor);
|
||||
setCliAppMenuDismissed(true);
|
||||
setSlashMenuDismissed(false);
|
||||
setInlineError(null);
|
||||
resizeTextarea();
|
||||
requestAnimationFrame(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
el.setSelectionRange(nextCursor, nextCursor);
|
||||
});
|
||||
},
|
||||
[cliAppMention, resizeTextarea, value],
|
||||
);
|
||||
|
||||
const submit = useCallback(() => {
|
||||
if (!canSend) return;
|
||||
const trimmed = value.trim();
|
||||
@ -625,14 +735,21 @@ export function ThreadComposer({
|
||||
preview: { url: img.dataUrl, name: img.file.name },
|
||||
}))
|
||||
: undefined;
|
||||
const options: SendOptions | undefined = imageMode
|
||||
? {
|
||||
imageGeneration: {
|
||||
enabled: true,
|
||||
aspect_ratio: imageAspectRatio === "auto" ? null : imageAspectRatio,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
const attachedCliApps = activeCliMentionApps.map(cliAppMentionPayload);
|
||||
const options: SendOptions | undefined =
|
||||
imageMode || attachedCliApps.length > 0
|
||||
? {
|
||||
...(imageMode
|
||||
? {
|
||||
imageGeneration: {
|
||||
enabled: true,
|
||||
aspect_ratio: imageAspectRatio === "auto" ? null : imageAspectRatio,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(attachedCliApps.length > 0 ? { cliApps: attachedCliApps } : {}),
|
||||
}
|
||||
: undefined;
|
||||
onSend(trimmed, payload, options);
|
||||
setValue("");
|
||||
setInlineError(null);
|
||||
@ -640,10 +757,36 @@ export function ThreadComposer({
|
||||
// preview here without affecting the rendered message.
|
||||
clear();
|
||||
setSlashMenuDismissed(false);
|
||||
setCliAppMenuDismissed(false);
|
||||
setCursorPosition(0);
|
||||
resizeTextarea();
|
||||
}, [canSend, clear, imageAspectRatio, imageMode, onSend, readyImages, resizeTextarea, value]);
|
||||
}, [activeCliMentionApps, 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);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSelectedCliAppIndex(
|
||||
(idx) => (idx - 1 + filteredCliApps.length) % filteredCliApps.length,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
|
||||
e.preventDefault();
|
||||
chooseCliApp(filteredCliApps[selectedCliAppIndex]);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setCliAppMenuDismissed(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (showSlashMenu) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
@ -719,6 +862,12 @@ export function ThreadComposer({
|
||||
|
||||
const attachButtonDisabled = disabled || full;
|
||||
const showStopButton = isStreaming && !!onStop;
|
||||
const inputTextClasses = cn(
|
||||
"w-full resize-none bg-transparent",
|
||||
isHero
|
||||
? "min-h-[78px] px-5 pb-2 pt-5 text-[15px] leading-6"
|
||||
: "min-h-[50px] px-4 pb-1.5 pt-3 text-[13.5px] leading-5",
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
@ -743,6 +892,16 @@ export function ThreadComposer({
|
||||
onChoose={chooseSlashCommand}
|
||||
/>
|
||||
) : null}
|
||||
{showCliAppMenu ? (
|
||||
<CliAppMentionPalette
|
||||
apps={filteredCliApps}
|
||||
selectedIndex={selectedCliAppIndex}
|
||||
layout={slashPaletteLayout}
|
||||
isHero={isHero}
|
||||
onHover={setSelectedCliAppIndex}
|
||||
onChoose={chooseCliApp}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"relative mx-auto flex w-full flex-col overflow-visible transition-all duration-200",
|
||||
@ -787,30 +946,42 @@ export function ThreadComposer({
|
||||
{runStartedAt != null || goalState?.active ? (
|
||||
<RunElapsedStrip startedAt={runStartedAt} goalState={goalState} />
|
||||
) : null}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
setSlashMenuDismissed(false);
|
||||
}}
|
||||
onInput={onInput}
|
||||
onKeyDown={onKeyDown}
|
||||
onPaste={onPaste}
|
||||
rows={1}
|
||||
placeholder={resolvedPlaceholder}
|
||||
disabled={disabled}
|
||||
aria-label={t("thread.composer.inputAria")}
|
||||
className={cn(
|
||||
"w-full resize-none bg-transparent",
|
||||
isHero
|
||||
? "min-h-[78px] px-5 pb-2 pt-5 text-[15px] leading-6"
|
||||
: "min-h-[50px] px-4 pb-1.5 pt-3 text-[13.5px] leading-5",
|
||||
"placeholder:text-muted-foreground/70",
|
||||
"focus:outline-none focus-visible:outline-none",
|
||||
"disabled:cursor-not-allowed",
|
||||
)}
|
||||
/>
|
||||
<div className="relative">
|
||||
{hasCliMentionDecorations ? (
|
||||
<ComposerCliMentionOverlay
|
||||
segments={mentionSegments}
|
||||
isHero={isHero}
|
||||
className={inputTextClasses}
|
||||
/>
|
||||
) : null}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
setSlashMenuDismissed(false);
|
||||
setCliAppMenuDismissed(false);
|
||||
setCursorPosition(e.target.selectionStart ?? e.target.value.length);
|
||||
}}
|
||||
onInput={onInput}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyUp={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
|
||||
onSelect={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
|
||||
onClick={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
|
||||
onPaste={onPaste}
|
||||
rows={1}
|
||||
placeholder={resolvedPlaceholder}
|
||||
disabled={disabled}
|
||||
aria-label={t("thread.composer.inputAria")}
|
||||
className={cn(
|
||||
inputTextClasses,
|
||||
"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",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{inlineError ? (
|
||||
<div
|
||||
role="alert"
|
||||
@ -962,6 +1133,40 @@ export function ThreadComposer({
|
||||
);
|
||||
}
|
||||
|
||||
function ComposerCliMentionOverlay({
|
||||
segments,
|
||||
isHero,
|
||||
className,
|
||||
}: {
|
||||
segments: CliAppMentionSegment[];
|
||||
isHero: boolean;
|
||||
className: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
className,
|
||||
"pointer-events-none absolute inset-0 z-0 overflow-hidden whitespace-pre-wrap break-words text-foreground",
|
||||
)}
|
||||
>
|
||||
{segments.map((segment, index) => {
|
||||
if (segment.kind === "text") {
|
||||
return <span key={`text-${index}`}>{segment.text}</span>;
|
||||
}
|
||||
return (
|
||||
<CliAppMentionToken
|
||||
key={`cli-${segment.app.name}-${index}`}
|
||||
app={segment.app}
|
||||
label={segment.text}
|
||||
variant="composer"
|
||||
isHero={isHero}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
interface SlashCommandPaletteProps {
|
||||
commands: SlashCommand[];
|
||||
selectedIndex: number;
|
||||
@ -971,6 +1176,15 @@ interface SlashCommandPaletteProps {
|
||||
onChoose: (command: SlashCommand) => void;
|
||||
}
|
||||
|
||||
interface CliAppMentionPaletteProps {
|
||||
apps: CliAppInfo[];
|
||||
selectedIndex: number;
|
||||
layout: SlashPaletteLayout;
|
||||
isHero: boolean;
|
||||
onHover: (index: number) => void;
|
||||
onChoose: (app: CliAppInfo) => void;
|
||||
}
|
||||
|
||||
function ImageAspectMenu({
|
||||
selected,
|
||||
isHero,
|
||||
@ -1024,6 +1238,121 @@ function ImageAspectMenu({
|
||||
);
|
||||
}
|
||||
|
||||
function CliAppMentionPalette({
|
||||
apps,
|
||||
selectedIndex,
|
||||
layout,
|
||||
isHero,
|
||||
onHover,
|
||||
onChoose,
|
||||
}: CliAppMentionPaletteProps) {
|
||||
const { t } = useTranslation();
|
||||
const listMaxHeight = Math.max(
|
||||
0,
|
||||
layout.maxHeight - SLASH_PALETTE_CHROME_PX,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
role="listbox"
|
||||
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",
|
||||
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)]",
|
||||
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>
|
||||
<div className="overflow-y-auto pr-0.5" style={{ maxHeight: listMaxHeight }}>
|
||||
{apps.map((app, index) => {
|
||||
const selected = index === selectedIndex;
|
||||
return (
|
||||
<button
|
||||
key={app.name}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
onMouseEnter={() => onHover(index)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onChoose(app);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-[13px] px-3 py-2.5 text-left transition-colors",
|
||||
selected
|
||||
? "bg-primary/10 text-foreground"
|
||||
: "text-foreground/86 hover:bg-accent/55",
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-[12px] text-muted-foreground">
|
||||
{app.category}
|
||||
{app.entry_point ? ` · ${app.entry_point}` : ""}
|
||||
</span>
|
||||
</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,
|
||||
selected,
|
||||
}: {
|
||||
app: CliAppInfo;
|
||||
selected: boolean;
|
||||
}) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const color = app.brand_color || "hsl(var(--primary))";
|
||||
if (app.logo_url && !failed) {
|
||||
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",
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={app.logo_url}
|
||||
alt=""
|
||||
className="h-4.5 w-4.5 object-contain"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[8px] text-[10.5px] font-semibold text-white"
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
{cliAppInitials(app)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SlashCommandPalette({
|
||||
commands,
|
||||
selectedIndex,
|
||||
|
||||
@ -6,7 +6,7 @@ import {
|
||||
AgentActivityCluster,
|
||||
isAgentActivityMember,
|
||||
} from "@/components/thread/AgentActivityCluster";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
import type { CliAppInfo, UIMessage } from "@/lib/types";
|
||||
|
||||
interface ThreadMessagesProps {
|
||||
messages: UIMessage[];
|
||||
@ -14,6 +14,7 @@ interface ThreadMessagesProps {
|
||||
isStreaming?: boolean;
|
||||
hiddenMessageCount?: number;
|
||||
onLoadEarlier?: () => void;
|
||||
cliApps?: CliAppInfo[];
|
||||
}
|
||||
|
||||
export type DisplayUnit =
|
||||
@ -164,6 +165,7 @@ export function ThreadMessages({
|
||||
isStreaming = false,
|
||||
hiddenMessageCount = 0,
|
||||
onLoadEarlier,
|
||||
cliApps = [],
|
||||
}: ThreadMessagesProps) {
|
||||
const { t } = useTranslation();
|
||||
const units = useMemo(() => buildDisplayUnits(messages), [messages]);
|
||||
@ -208,6 +210,7 @@ export function ThreadMessages({
|
||||
messages={unit.messages}
|
||||
isTurnStreaming={index === liveActivityClusterIndex}
|
||||
hasBodyBelow={hasBodyBelow}
|
||||
cliApps={cliApps}
|
||||
/>
|
||||
) : (
|
||||
<MessageBubble
|
||||
@ -217,6 +220,7 @@ export function ThreadMessages({
|
||||
? copyFlags[index]
|
||||
: true
|
||||
}
|
||||
cliApps={cliApps}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -19,8 +19,8 @@ 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 { listSlashCommands } from "@/lib/api";
|
||||
import type { ChatSummary, SlashCommand, UIMessage } from "@/lib/types";
|
||||
import { fetchCliApps, listSlashCommands } from "@/lib/api";
|
||||
import type { ChatSummary, CliAppInfo, SlashCommand, UIMessage } from "@/lib/types";
|
||||
import { normalizeLegacyLongTaskMessages } from "@/lib/thread-display-compat";
|
||||
import { scrubSubagentUiMessages } from "@/lib/subagent-channel-display";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
@ -97,6 +97,7 @@ export function ThreadShell({
|
||||
const { client, modelName, token } = useClient();
|
||||
const [booting, setBooting] = useState(false);
|
||||
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
||||
const [cliApps, setCliApps] = useState<CliAppInfo[]>([]);
|
||||
const [heroImageMode, setHeroImageMode] = useState(false);
|
||||
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
|
||||
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
|
||||
@ -247,6 +248,40 @@ export function ThreadShell({
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
const refreshCliApps = useCallback(async () => {
|
||||
try {
|
||||
const payload = await fetchCliApps(token);
|
||||
setCliApps(payload.apps.filter((app) => app.installed));
|
||||
} catch {
|
||||
setCliApps([]);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
const payload = await fetchCliApps(token);
|
||||
if (!cancelled) setCliApps(payload.apps.filter((app) => app.installed));
|
||||
} catch {
|
||||
if (!cancelled) setCliApps([]);
|
||||
}
|
||||
};
|
||||
load();
|
||||
|
||||
const refreshOnFocus = () => {
|
||||
if (document.visibilityState === "hidden") return;
|
||||
void refreshCliApps();
|
||||
};
|
||||
window.addEventListener("focus", refreshOnFocus);
|
||||
document.addEventListener("visibilitychange", refreshOnFocus);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener("focus", refreshOnFocus);
|
||||
document.removeEventListener("visibilitychange", refreshOnFocus);
|
||||
};
|
||||
}, [refreshCliApps, token]);
|
||||
|
||||
const handleWelcomeSend = useCallback(
|
||||
async (content: string, images?: SendImage[], options?: SendOptions) => {
|
||||
if (booting) return;
|
||||
@ -332,6 +367,7 @@ export function ThreadShell({
|
||||
modelLabel={toModelBadgeLabel(modelName)}
|
||||
variant={showHeroComposer ? "hero" : "thread"}
|
||||
slashCommands={slashCommands}
|
||||
cliApps={cliApps}
|
||||
imageMode={showHeroComposer ? heroImageMode : undefined}
|
||||
onImageModeChange={showHeroComposer ? setHeroImageMode : undefined}
|
||||
onStop={stop}
|
||||
@ -351,6 +387,7 @@ export function ThreadShell({
|
||||
modelLabel={toModelBadgeLabel(modelName)}
|
||||
variant="hero"
|
||||
slashCommands={slashCommands}
|
||||
cliApps={cliApps}
|
||||
imageMode={heroImageMode}
|
||||
onImageModeChange={setHeroImageMode}
|
||||
runStartedAt={runStartedAt}
|
||||
@ -391,6 +428,7 @@ export function ThreadShell({
|
||||
scrollToBottomSignal={scrollToBottomSignal}
|
||||
conversationKey={historyKey}
|
||||
showScrollToBottomButton={!!session}
|
||||
cliApps={cliApps}
|
||||
/>
|
||||
</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 { UIMessage } from "@/lib/types";
|
||||
import type { CliAppInfo, UIMessage } from "@/lib/types";
|
||||
|
||||
interface ThreadViewportProps {
|
||||
messages: UIMessage[];
|
||||
@ -24,6 +24,7 @@ interface ThreadViewportProps {
|
||||
scrollToBottomSignal?: number;
|
||||
conversationKey?: string | null;
|
||||
showScrollToBottomButton?: boolean;
|
||||
cliApps?: CliAppInfo[];
|
||||
}
|
||||
|
||||
const NEAR_BOTTOM_PX = 48;
|
||||
@ -53,6 +54,7 @@ export function ThreadViewport({
|
||||
scrollToBottomSignal = 0,
|
||||
conversationKey = null,
|
||||
showScrollToBottomButton = true,
|
||||
cliApps = [],
|
||||
}: ThreadViewportProps) {
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
@ -249,6 +251,7 @@ export function ThreadViewport({
|
||||
isStreaming={isStreaming}
|
||||
hiddenMessageCount={hiddenMessageCount}
|
||||
onLoadEarlier={loadEarlierMessages}
|
||||
cliApps={cliApps}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -216,6 +216,52 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cli-app-linked-sheen {
|
||||
0% {
|
||||
transform: translateX(-140%) skewX(-14deg);
|
||||
opacity: 0;
|
||||
}
|
||||
18% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
72%,
|
||||
100% {
|
||||
transform: translateX(140%) skewX(-14deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.cli-app-linked-chip::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
hsl(var(--foreground) / 0.14) 46%,
|
||||
hsl(var(--background) / 0.7) 50%,
|
||||
hsl(var(--foreground) / 0.12) 54%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: cli-app-linked-sheen 1.25s ease-out 1;
|
||||
}
|
||||
.dark .cli-app-linked-chip::after {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
hsl(var(--foreground) / 0.12) 46%,
|
||||
hsl(var(--background) / 0.5) 50%,
|
||||
hsl(var(--foreground) / 0.1) 54%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cli-app-linked-chip::after {
|
||||
animation: none;
|
||||
content: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Subtle scrollbar that doesn't fight the dark background. */
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
|
||||
@ -2,10 +2,16 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
import { toMediaAttachment } from "@/lib/media";
|
||||
import { mergeUniqueToolTraceLines, toolTraceLinesFromEvents } from "@/lib/tool-traces";
|
||||
import {
|
||||
mergeToolProgressEvents,
|
||||
mergeUniqueToolTraceLines,
|
||||
normalizeToolProgressEvents,
|
||||
toolTraceLinesFromEvents,
|
||||
} from "@/lib/tool-traces";
|
||||
import type { StreamError } from "@/lib/nanobot-client";
|
||||
import type {
|
||||
InboundEvent,
|
||||
OutboundCliAppMention,
|
||||
OutboundImageGeneration,
|
||||
OutboundMedia,
|
||||
GoalStateWsPayload,
|
||||
@ -297,6 +303,7 @@ export interface SendImage {
|
||||
|
||||
export interface SendOptions {
|
||||
imageGeneration?: OutboundImageGeneration;
|
||||
cliApps?: OutboundCliAppMention[];
|
||||
}
|
||||
|
||||
export function useNanobotStream(
|
||||
@ -657,6 +664,7 @@ export function useNanobotStream(
|
||||
// Attach them to the last trace row if it was the last emitted item
|
||||
// so a sequence of calls collapses into one compact trace group.
|
||||
if (ev.kind === "tool_hint" || ev.kind === "progress") {
|
||||
const structuredEvents = normalizeToolProgressEvents(ev.tool_events);
|
||||
const structuredLines = toolTraceLinesFromEvents(ev.tool_events);
|
||||
const lines = structuredLines.length > 0
|
||||
? structuredLines
|
||||
@ -681,13 +689,15 @@ export function useNanobotStream(
|
||||
const mergedLines = structuredLines.length > 0
|
||||
? mergeUniqueToolTraceLines(previousTraces, structuredLines)
|
||||
: null;
|
||||
if (mergedLines && !mergedLines.added) return prev;
|
||||
const merged: UIMessage = {
|
||||
...last,
|
||||
traces: mergedLines ? mergedLines.traces : [...previousTraces, ...lines],
|
||||
content: mergedLines
|
||||
? mergedLines.traces[mergedLines.traces.length - 1]
|
||||
: lines[lines.length - 1],
|
||||
toolEvents: structuredEvents.length
|
||||
? mergeToolProgressEvents(last.toolEvents, structuredEvents)
|
||||
: last.toolEvents,
|
||||
activitySegmentId: last.activitySegmentId ?? segmentId,
|
||||
};
|
||||
return [...prev.slice(0, -1), merged];
|
||||
@ -700,6 +710,7 @@ export function useNanobotStream(
|
||||
kind: "trace",
|
||||
content: lines[lines.length - 1],
|
||||
traces: lines,
|
||||
...(structuredEvents.length ? { toolEvents: structuredEvents } : {}),
|
||||
activitySegmentId: segmentId,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
@ -836,6 +847,7 @@ export function useNanobotStream(
|
||||
content,
|
||||
createdAt: Date.now(),
|
||||
...(previews ? { images: previews } : {}),
|
||||
...(options?.cliApps?.length ? { cliApps: options.cliApps } : {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
@ -80,6 +80,7 @@
|
||||
"providers": "Providers",
|
||||
"image": "Image",
|
||||
"web": "Web",
|
||||
"cliApps": "CLI Apps",
|
||||
"runtime": "Runtime",
|
||||
"advanced": "Advanced"
|
||||
},
|
||||
@ -94,6 +95,7 @@
|
||||
"imageDefaults": "Defaults",
|
||||
"webSearch": "Web search",
|
||||
"webBehavior": "Behavior",
|
||||
"cliApps": "CLI Apps",
|
||||
"identity": "Identity",
|
||||
"safety": "Safety",
|
||||
"capabilities": "Capabilities",
|
||||
@ -115,6 +117,7 @@
|
||||
"density": "Density",
|
||||
"activityMode": "Activity detail",
|
||||
"codeWrap": "Code wrapping",
|
||||
"brandLogos": "Brand logos",
|
||||
"maxResults": "Max results",
|
||||
"timeout": "Timeout",
|
||||
"jinaReader": "Jina reader",
|
||||
@ -141,6 +144,8 @@
|
||||
"ssrfWhitelist": "SSRF whitelist",
|
||||
"mcpServers": "MCP servers",
|
||||
"pathAppend": "PATH append",
|
||||
"cliAppsCatalog": "Catalog",
|
||||
"cliAppsFilter": "Filter",
|
||||
"configurationDocs": "Configuration docs"
|
||||
},
|
||||
"help": {
|
||||
@ -154,6 +159,7 @@
|
||||
"density": "Stored only in this browser.",
|
||||
"activityMode": "Choose how much agent activity chrome to show by default.",
|
||||
"codeWrap": "Keep long code lines readable on smaller screens.",
|
||||
"brandLogos": "Show third-party provider and CLI logos in Settings.",
|
||||
"maxResults": "Results returned by each web_search call.",
|
||||
"timeout": "Seconds before a search provider request times out.",
|
||||
"jinaReader": "Use Jina Reader for web_fetch when available.",
|
||||
@ -168,8 +174,41 @@
|
||||
"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.",
|
||||
"cliAppsCatalog": "Install only the app-specific CLI adapters nanobot can run locally; desktop apps stay untouched.",
|
||||
"cliAppsFilter": "Search by app, category, or capability.",
|
||||
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
|
||||
},
|
||||
"cliApps": {
|
||||
"allCategories": "All categories",
|
||||
"availableCount": "{{count}} apps",
|
||||
"installedCount": "{{count}} CLIs installed",
|
||||
"summary": "{{installed}} of {{total}} CLIs installed",
|
||||
"filterAll": "All",
|
||||
"filterInstalled": "Installed CLIs",
|
||||
"filterNotInstalled": "Not installed",
|
||||
"searchPlaceholder": "Search CLIs",
|
||||
"loading": "Loading CLI Apps...",
|
||||
"empty": "No CLI Apps match this filter.",
|
||||
"statusInstalled": "CLI installed",
|
||||
"statusMissing": "Missing",
|
||||
"statusAvailable": "Available",
|
||||
"statusUnsupported": "Unsupported",
|
||||
"statusNotInstalled": "CLI not installed",
|
||||
"requires": "Requires",
|
||||
"test": "Test CLI",
|
||||
"update": "Update CLI",
|
||||
"uninstall": "Uninstall CLI",
|
||||
"install": "Install CLI",
|
||||
"readyTitle": "@{{name}} is ready",
|
||||
"readyStatus": "Ready",
|
||||
"readyTry": "Try @{{name}}",
|
||||
"readyCopied": "Copied",
|
||||
"readyPrompt": "Use @{{name}} to inspect what this CLI can do.",
|
||||
"openChat": "Open chat",
|
||||
"unsupported": "Unsupported",
|
||||
"unavailable": "Unavailable",
|
||||
"noDescription": "No description available."
|
||||
},
|
||||
"values": {
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
@ -259,6 +298,9 @@
|
||||
"searchPlaceholder": "Search providers",
|
||||
"noMatches": "No providers match this search."
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement."
|
||||
},
|
||||
"image": {
|
||||
"selectProvider": "Select provider",
|
||||
"selectAspect": "Select aspect",
|
||||
@ -462,6 +504,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"mentions": {
|
||||
"ariaLabel": "CLI Apps",
|
||||
"label": "CLI APPS"
|
||||
},
|
||||
"encoding": "Encoding…",
|
||||
"remove": "Remove attachment",
|
||||
"normalizedSizeHint": "{{orig}} → {{current}} (auto)",
|
||||
@ -493,6 +539,15 @@
|
||||
"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",
|
||||
"imageAttachment": "Image attachment",
|
||||
"copyReply": "Copy reply",
|
||||
"copiedReply": "Copied reply",
|
||||
|
||||
@ -68,6 +68,7 @@
|
||||
"providers": "提供商",
|
||||
"image": "图片",
|
||||
"web": "网页",
|
||||
"cliApps": "CLI 应用",
|
||||
"runtime": "运行时",
|
||||
"advanced": "高级"
|
||||
},
|
||||
@ -82,6 +83,7 @@
|
||||
"imageDefaults": "默认值",
|
||||
"webSearch": "网页搜索",
|
||||
"webBehavior": "行为",
|
||||
"cliApps": "CLI 应用",
|
||||
"identity": "身份",
|
||||
"safety": "安全",
|
||||
"capabilities": "能力",
|
||||
@ -103,6 +105,7 @@
|
||||
"density": "密度",
|
||||
"activityMode": "活动细节",
|
||||
"codeWrap": "代码换行",
|
||||
"brandLogos": "品牌 Logo",
|
||||
"maxResults": "最大结果数",
|
||||
"timeout": "超时",
|
||||
"jinaReader": "Jina Reader",
|
||||
@ -129,6 +132,8 @@
|
||||
"ssrfWhitelist": "SSRF 白名单",
|
||||
"mcpServers": "MCP 服务器",
|
||||
"pathAppend": "PATH 追加",
|
||||
"cliAppsCatalog": "目录",
|
||||
"cliAppsFilter": "筛选",
|
||||
"configurationDocs": "配置文档"
|
||||
},
|
||||
"help": {
|
||||
@ -142,6 +147,7 @@
|
||||
"density": "仅保存在当前浏览器。",
|
||||
"activityMode": "选择默认显示多少 agent 活动细节。",
|
||||
"codeWrap": "让较小屏幕上的长代码行更易读。",
|
||||
"brandLogos": "在设置页显示第三方服务商和 CLI 的 Logo。",
|
||||
"maxResults": "每次 web_search 返回的结果数量。",
|
||||
"timeout": "搜索服务商请求超时秒数。",
|
||||
"jinaReader": "可用时为 web_fetch 使用 Jina Reader。",
|
||||
@ -156,8 +162,41 @@
|
||||
"botIcon": "显示在 bot 名称旁的短 emoji 或文本。",
|
||||
"timezone": "运行时上下文和计划任务使用的 IANA 时区。",
|
||||
"toolHintMaxLength": "工具进度提示显示的最大字符数。",
|
||||
"cliAppsCatalog": "只安装 nanobot 在本机调用应用时需要的 CLI 适配层,不触碰应用本体。",
|
||||
"cliAppsFilter": "按应用、分类或能力搜索。",
|
||||
"advancedReadOnly": "高级安全控制在 WebUI 中只读;需要时请谨慎编辑 config.json。"
|
||||
},
|
||||
"cliApps": {
|
||||
"allCategories": "全部分类",
|
||||
"availableCount": "{{count}} 个应用",
|
||||
"installedCount": "已安装 {{count}} 个 CLI",
|
||||
"summary": "CLI 已安装 {{installed}} / {{total}}",
|
||||
"filterAll": "全部",
|
||||
"filterInstalled": "已装 CLI",
|
||||
"filterNotInstalled": "未装 CLI",
|
||||
"searchPlaceholder": "搜索 CLI",
|
||||
"loading": "正在加载 CLI 应用...",
|
||||
"empty": "没有匹配的 CLI 应用。",
|
||||
"statusInstalled": "CLI 已安装",
|
||||
"statusMissing": "缺失",
|
||||
"statusAvailable": "可用",
|
||||
"statusUnsupported": "暂不支持",
|
||||
"statusNotInstalled": "CLI 未安装",
|
||||
"requires": "依赖",
|
||||
"test": "测试 CLI",
|
||||
"update": "更新 CLI",
|
||||
"uninstall": "卸载 CLI",
|
||||
"install": "安装 CLI",
|
||||
"readyTitle": "@{{name}} 已就绪",
|
||||
"readyStatus": "就绪",
|
||||
"readyTry": "试试 @{{name}}",
|
||||
"readyCopied": "已复制",
|
||||
"readyPrompt": "用 @{{name}} 看看这个 CLI 能做什么。",
|
||||
"openChat": "回到对话",
|
||||
"unsupported": "暂不支持",
|
||||
"unavailable": "不可用",
|
||||
"noDescription": "暂无描述。"
|
||||
},
|
||||
"values": {
|
||||
"light": "浅色",
|
||||
"dark": "深色",
|
||||
@ -247,6 +286,9 @@
|
||||
"searchPlaceholder": "搜索服务商",
|
||||
"noMatches": "没有匹配的服务商。"
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "产品名称、Logo 和品牌归各自所有者所有;此处仅用于识别,不代表背书或合作。"
|
||||
},
|
||||
"image": {
|
||||
"selectProvider": "选择服务商",
|
||||
"selectAspect": "选择比例",
|
||||
@ -449,6 +491,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"mentions": {
|
||||
"ariaLabel": "CLI 应用",
|
||||
"label": "CLI 应用"
|
||||
},
|
||||
"encoding": "处理中…",
|
||||
"remove": "移除附件",
|
||||
"normalizedSizeHint": "{{orig}} → {{current}}(已自动压缩)",
|
||||
@ -481,6 +527,15 @@
|
||||
"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 调用失败",
|
||||
"imageAttachment": "图片附件",
|
||||
"copyReply": "复制回复",
|
||||
"copiedReply": "已复制回复",
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type {
|
||||
ChatSummary,
|
||||
CliAppsPayload,
|
||||
ImageGenerationSettingsUpdate,
|
||||
ProviderSettingsUpdate,
|
||||
SettingsPayload,
|
||||
@ -106,6 +107,24 @@ export async function fetchSettings(
|
||||
return request<SettingsPayload>(`${base}/api/settings`, token);
|
||||
}
|
||||
|
||||
export async function fetchCliApps(
|
||||
token: string,
|
||||
base: string = "",
|
||||
): Promise<CliAppsPayload> {
|
||||
return request<CliAppsPayload>(`${base}/api/settings/cli-apps`, token);
|
||||
}
|
||||
|
||||
export async function runCliAppAction(
|
||||
token: string,
|
||||
action: "install" | "update" | "uninstall" | "test",
|
||||
name: string,
|
||||
base: string = "",
|
||||
): Promise<CliAppsPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("name", name);
|
||||
return request<CliAppsPayload>(`${base}/api/settings/cli-apps/${action}?${query}`, token);
|
||||
}
|
||||
|
||||
export async function listSlashCommands(
|
||||
token: string,
|
||||
base: string = "",
|
||||
|
||||
@ -2,6 +2,7 @@ import type {
|
||||
ConnectionStatus,
|
||||
InboundEvent,
|
||||
Outbound,
|
||||
OutboundCliAppMention,
|
||||
OutboundImageGeneration,
|
||||
OutboundMedia,
|
||||
GoalStateWsPayload,
|
||||
@ -304,7 +305,7 @@ export class NanobotClient {
|
||||
chatId: string,
|
||||
content: string,
|
||||
media?: OutboundMedia[],
|
||||
options?: { imageGeneration?: OutboundImageGeneration },
|
||||
options?: { imageGeneration?: OutboundImageGeneration; cliApps?: OutboundCliAppMention[] },
|
||||
): void {
|
||||
this.knownChats.add(chatId);
|
||||
const frame: Outbound = {
|
||||
@ -313,6 +314,7 @@ export class NanobotClient {
|
||||
content,
|
||||
...(media && media.length > 0 ? { media } : {}),
|
||||
...(options?.imageGeneration ? { image_generation: options.imageGeneration } : {}),
|
||||
...(options?.cliApps?.length ? { cli_apps: options.cliApps } : {}),
|
||||
webui: true,
|
||||
};
|
||||
this.queueSend(frame);
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import type { ToolProgressEvent } from "@/lib/types";
|
||||
|
||||
/** Drop duplicate tool_call objects (same id or identical formatted trace). */
|
||||
export function dedupeToolCallsForUi(calls: unknown): unknown[] {
|
||||
if (!Array.isArray(calls) || calls.length === 0) return [];
|
||||
@ -40,15 +42,60 @@ export function formatToolCallTrace(call: unknown): string | null {
|
||||
}
|
||||
|
||||
const VALID_PHASES = new Set(["start", "end", "error"]);
|
||||
const PHASE_RANK: Record<string, number> = { start: 1, end: 2, error: 3 };
|
||||
|
||||
export function toolTraceLinesFromEvents(events: unknown): string[] {
|
||||
export function normalizeToolProgressEvents(events: unknown): ToolProgressEvent[] {
|
||||
if (!Array.isArray(events)) return [];
|
||||
const seen = new Set<string>();
|
||||
const lines: string[] = [];
|
||||
const out: ToolProgressEvent[] = [];
|
||||
for (const event of events) {
|
||||
if (!event || typeof event !== "object") continue;
|
||||
const phase = (event as { phase?: unknown }).phase;
|
||||
const record = event as ToolProgressEvent;
|
||||
const phase = record.phase;
|
||||
if (!(phase && typeof phase === "string" && VALID_PHASES.has(phase))) continue;
|
||||
const name = typeof record.name === "string" ? record.name : "";
|
||||
const functionName =
|
||||
typeof (record as { function?: { name?: unknown } }).function?.name === "string"
|
||||
? String((record as { function?: { name?: unknown } }).function?.name)
|
||||
: "";
|
||||
if (!name && !functionName) continue;
|
||||
out.push(record);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function toolEventKey(event: ToolProgressEvent): string {
|
||||
if (event.call_id) return `call:${event.call_id}`;
|
||||
return formatToolCallTrace(event) ?? JSON.stringify(event);
|
||||
}
|
||||
|
||||
export function mergeToolProgressEvents(
|
||||
previous: ToolProgressEvent[] | undefined,
|
||||
incoming: ToolProgressEvent[],
|
||||
): ToolProgressEvent[] {
|
||||
if (!previous?.length) return incoming;
|
||||
if (!incoming.length) return previous;
|
||||
const next = [...previous];
|
||||
const indexByKey = new Map(next.map((event, index) => [toolEventKey(event), index]));
|
||||
for (const event of incoming) {
|
||||
const key = toolEventKey(event);
|
||||
const existingIndex = indexByKey.get(key);
|
||||
if (existingIndex === undefined) {
|
||||
indexByKey.set(key, next.length);
|
||||
next.push(event);
|
||||
continue;
|
||||
}
|
||||
const existing = next[existingIndex];
|
||||
const incomingRank = PHASE_RANK[String(event.phase)] ?? 0;
|
||||
const existingRank = PHASE_RANK[String(existing.phase)] ?? 0;
|
||||
next[existingIndex] = incomingRank >= existingRank ? { ...existing, ...event } : existing;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function toolTraceLinesFromEvents(events: unknown): string[] {
|
||||
const seen = new Set<string>();
|
||||
const lines: string[] = [];
|
||||
for (const event of normalizeToolProgressEvents(events)) {
|
||||
const callId = (event as { call_id?: unknown }).call_id;
|
||||
if (callId && typeof callId === "string") {
|
||||
if (seen.has(callId)) continue;
|
||||
|
||||
@ -40,6 +40,9 @@ export interface UIMessage {
|
||||
/** For trace rows: each individual hint line, so consecutive hints can
|
||||
* render as a single collapsible group. */
|
||||
traces?: string[];
|
||||
/** Structured tool events behind trace rows. Kept so activity cards can
|
||||
* distinguish running, completed, and failed tool phases. */
|
||||
toolEvents?: ToolProgressEvent[];
|
||||
/** Activity rows: explicit file edits emitted by edit tools. */
|
||||
fileEdits?: UIFileEdit[];
|
||||
/** Activity rows created during the same agent phase share one collapsible block. */
|
||||
@ -48,6 +51,8 @@ export interface UIMessage {
|
||||
images?: UIImage[];
|
||||
/** Signed or local UI-renderable media attachments. */
|
||||
media?: UIMediaAttachment[];
|
||||
/** App-specific CLI adapters explicitly attached to this user turn. */
|
||||
cliApps?: UICliAppAttachment[];
|
||||
/** Assistant turn: accumulated model reasoning / thinking text. Built up
|
||||
* incrementally from ``reasoning_delta`` frames; finalized when
|
||||
* ``reasoning_end`` arrives. */
|
||||
@ -59,6 +64,15 @@ export interface UIMessage {
|
||||
latencyMs?: number;
|
||||
}
|
||||
|
||||
export interface UICliAppAttachment {
|
||||
name: string;
|
||||
display_name?: string;
|
||||
category?: string;
|
||||
entry_point?: string;
|
||||
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;
|
||||
@ -252,6 +266,34 @@ export interface SettingsPayload {
|
||||
restart_required_sections?: Array<"runtime" | "web" | "image">;
|
||||
}
|
||||
|
||||
export interface CliAppInfo {
|
||||
name: string;
|
||||
display_name: string;
|
||||
category: string;
|
||||
description: string;
|
||||
requires: string;
|
||||
source: string;
|
||||
entry_point: string;
|
||||
install_supported: boolean;
|
||||
installed: boolean;
|
||||
available: boolean;
|
||||
status: "installed" | "missing" | "available" | "unsupported" | "not_installed" | string;
|
||||
logo_url?: string | null;
|
||||
brand_color?: string | null;
|
||||
skill_installed: boolean;
|
||||
}
|
||||
|
||||
export interface CliAppsPayload {
|
||||
apps: CliAppInfo[];
|
||||
installed_count: number;
|
||||
catalog_updated_at?: string | null;
|
||||
last_action?: {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
output?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SettingsUpdate {
|
||||
model?: string;
|
||||
provider?: string;
|
||||
@ -394,6 +436,15 @@ export interface OutboundImageGeneration {
|
||||
aspect_ratio?: string | null;
|
||||
}
|
||||
|
||||
export interface OutboundCliAppMention {
|
||||
name: string;
|
||||
display_name?: string;
|
||||
category?: string;
|
||||
entry_point?: string;
|
||||
logo_url?: string | null;
|
||||
brand_color?: string | null;
|
||||
}
|
||||
|
||||
/** Response shape for ``GET .../webui-thread`` (server-built transcript replay). */
|
||||
export interface WebuiThreadPersistedPayload {
|
||||
schemaVersion: number;
|
||||
@ -411,6 +462,7 @@ export type Outbound =
|
||||
content: string;
|
||||
media?: OutboundMedia[];
|
||||
image_generation?: OutboundImageGeneration;
|
||||
cli_apps?: OutboundCliAppMention[];
|
||||
/** Marks messages sent by the embedded WebUI, without changing the
|
||||
* generic websocket protocol for other clients. */
|
||||
webui?: true;
|
||||
|
||||
@ -2,7 +2,24 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
import type { CliAppInfo, UIMessage } from "@/lib/types";
|
||||
|
||||
const BLENDER_CLI_APP: CliAppInfo = {
|
||||
name: "blender",
|
||||
display_name: "Blender",
|
||||
category: "3d",
|
||||
description: "3D creation",
|
||||
requires: "",
|
||||
source: "harness",
|
||||
entry_point: "cli-anything-blender",
|
||||
install_supported: true,
|
||||
installed: true,
|
||||
available: true,
|
||||
status: "installed",
|
||||
logo_url: "https://example.invalid/blender.svg",
|
||||
brand_color: "#E87D0D",
|
||||
skill_installed: true,
|
||||
};
|
||||
|
||||
function activityMessages(extraReasoning = "", extraTool?: UIMessage): UIMessage[] {
|
||||
const rows: UIMessage[] = [
|
||||
@ -271,6 +288,67 @@ describe("AgentActivityCluster", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("renders CLI app runs as dedicated activity rows", () => {
|
||||
const line = 'run_cli_app({"name":"blender","args":["--background","scene.blend"],"json":true})';
|
||||
render(
|
||||
<AgentActivityCluster
|
||||
messages={[{
|
||||
id: "t-cli",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: line,
|
||||
traces: [line],
|
||||
createdAt: 1,
|
||||
}]}
|
||||
isTurnStreaming
|
||||
hasBodyBelow={false}
|
||||
cliApps={[BLENDER_CLI_APP]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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("@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("labels rejected CLI app calls as failed instead of ran", () => {
|
||||
render(
|
||||
<AgentActivityCluster
|
||||
messages={[{
|
||||
id: "t-cli-fail",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: 'run_cli_app({"name":"github","args":["repo","view"],"json":"true"})',
|
||||
traces: ['run_cli_app({"name":"github","args":["repo","view"],"json":"true"})'],
|
||||
toolEvents: [
|
||||
{
|
||||
phase: "error",
|
||||
call_id: "call-github",
|
||||
name: "run_cli_app",
|
||||
arguments: { name: "github", args: ["repo", "view"], json: "true" },
|
||||
error: "Error: CLI app 'github' not found",
|
||||
},
|
||||
],
|
||||
createdAt: 1,
|
||||
}]}
|
||||
isTurnStreaming={false}
|
||||
hasBodyBelow={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /cli failed @github/i }));
|
||||
|
||||
expect(screen.getByTestId("activity-cli-runs")).toHaveTextContent("CLI 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("does not render zero diff counters for completed edits", () => {
|
||||
render(
|
||||
<AgentActivityCluster
|
||||
|
||||
@ -2,10 +2,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
deleteSession,
|
||||
fetchCliApps,
|
||||
fetchSidebarState,
|
||||
fetchWebuiThread,
|
||||
listSessions,
|
||||
listSlashCommands,
|
||||
runCliAppAction,
|
||||
updateSidebarState,
|
||||
updateImageGenerationSettings,
|
||||
updateProviderSettings,
|
||||
@ -116,6 +118,33 @@ describe("webui API helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("reads CLI Apps catalog and serializes actions", async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
apps: [],
|
||||
installed_count: 0,
|
||||
catalog_updated_at: "2026-04-18",
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
await expect(fetchCliApps("tok")).resolves.toMatchObject({ apps: [] });
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/cli-apps",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
|
||||
await runCliAppAction("tok", "install", "gimp");
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/cli-apps/install?name=gimp",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reads and writes persisted sidebar state", async () => {
|
||||
const state = {
|
||||
schema_version: 1,
|
||||
|
||||
@ -727,6 +727,8 @@ describe("App layout", () => {
|
||||
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();
|
||||
fireEvent.click(within(settingsNav).getByRole("button", { name: "Models" }));
|
||||
expect(screen.getByText("AI")).toBeInTheDocument();
|
||||
const modelInput = screen.getByDisplayValue("openai/gpt-4o");
|
||||
@ -739,6 +741,8 @@ describe("App layout", () => {
|
||||
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();
|
||||
expect(screen.getByText(/Product names, logos, and brands/)).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Not configured").length).toBeGreaterThan(0);
|
||||
fireEvent.click(screen.getByText("OpenAI"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Edit" }));
|
||||
|
||||
@ -2,7 +2,42 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { MessageBubble } from "@/components/MessageBubble";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
import type { CliAppInfo, UIMessage } from "@/lib/types";
|
||||
|
||||
const CLI_APPS: CliAppInfo[] = [
|
||||
{
|
||||
name: "zoom",
|
||||
display_name: "Zoom",
|
||||
category: "productivity",
|
||||
description: "Meetings",
|
||||
requires: "",
|
||||
source: "harness",
|
||||
entry_point: "cli-anything-zoom",
|
||||
install_supported: true,
|
||||
installed: true,
|
||||
available: true,
|
||||
status: "installed",
|
||||
logo_url: "https://example.invalid/zoom.svg",
|
||||
brand_color: "#0B5CFF",
|
||||
skill_installed: true,
|
||||
},
|
||||
{
|
||||
name: "krita",
|
||||
display_name: "Krita",
|
||||
category: "image",
|
||||
description: "Painting",
|
||||
requires: "",
|
||||
source: "harness",
|
||||
entry_point: "cli-anything-krita",
|
||||
install_supported: true,
|
||||
installed: false,
|
||||
available: false,
|
||||
status: "not_installed",
|
||||
logo_url: null,
|
||||
brand_color: "#3BABFF",
|
||||
skill_installed: false,
|
||||
},
|
||||
];
|
||||
|
||||
describe("MessageBubble", () => {
|
||||
it("renders user messages as right-aligned pills", () => {
|
||||
@ -22,6 +57,53 @@ describe("MessageBubble", () => {
|
||||
expect(screen.queryByRole("button", { name: "Copy reply" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders installed CLI app mentions inside sent user messages", () => {
|
||||
const message: UIMessage = {
|
||||
id: "u-cli",
|
||||
role: "user",
|
||||
content: "Hi nano, please use @zoom to book a meeting, not @krita",
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
render(<MessageBubble message={message} cliApps={CLI_APPS} />);
|
||||
|
||||
const token = screen.getByTestId("message-cli-mention-zoom");
|
||||
expect(token).toHaveTextContent("@zoom");
|
||||
expect(token.className).not.toContain("rounded");
|
||||
expect(token.className).not.toContain("px-");
|
||||
expect(token.getAttribute("style")).toContain("color: #0B5CFF");
|
||||
expect(token.getAttribute("style")).toContain("text-shadow");
|
||||
expect(screen.getByTestId("message-cli-mention-logo-zoom")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("message-cli-mention-krita")).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/not @krita/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders structured CLI app attachments even without the installed catalog", () => {
|
||||
const message: UIMessage = {
|
||||
id: "u-cli-attached",
|
||||
role: "user",
|
||||
content: "Please use @drawio for the diagram",
|
||||
createdAt: Date.now(),
|
||||
cliApps: [{
|
||||
name: "drawio",
|
||||
display_name: "Draw.io",
|
||||
category: "diagram",
|
||||
entry_point: "cli-anything-drawio",
|
||||
logo_url: "https://example.invalid/drawio.svg",
|
||||
brand_color: "#F08705",
|
||||
}],
|
||||
};
|
||||
|
||||
render(<MessageBubble message={message} cliApps={[]} />);
|
||||
|
||||
const token = screen.getByTestId("message-cli-mention-drawio");
|
||||
expect(token).toHaveTextContent("@drawio");
|
||||
expect(token.className).not.toContain("rounded");
|
||||
expect(token.className).not.toContain("px-");
|
||||
expect(token.getAttribute("style")).toContain("color: #F08705");
|
||||
expect(screen.getByTestId("message-cli-mention-logo-drawio")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("copies completed assistant replies from the action row", async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
|
||||
@ -332,6 +332,49 @@ describe("NanobotClient", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("includes CLI app 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-cli",
|
||||
"@drawio please make this diagram",
|
||||
undefined,
|
||||
{
|
||||
cliApps: [{
|
||||
name: "drawio",
|
||||
display_name: "Draw.io",
|
||||
category: "diagrams",
|
||||
entry_point: "cli-anything-drawio",
|
||||
logo_url: null,
|
||||
brand_color: "#F08705",
|
||||
}],
|
||||
},
|
||||
);
|
||||
|
||||
expect(lastSocket().sent).toContain(
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
chat_id: "chat-cli",
|
||||
content: "@drawio please make this diagram",
|
||||
cli_apps: [{
|
||||
name: "drawio",
|
||||
display_name: "Draw.io",
|
||||
category: "diagrams",
|
||||
entry_point: "cli-anything-drawio",
|
||||
logo_url: null,
|
||||
brand_color: "#F08705",
|
||||
}],
|
||||
webui: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("re-attaches known chats after a reconnect", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
|
||||
@ -2,7 +2,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ThreadComposer } from "@/components/thread/ThreadComposer";
|
||||
import type { SlashCommand } from "@/lib/types";
|
||||
import type { CliAppInfo, SlashCommand } from "@/lib/types";
|
||||
|
||||
const COMMANDS: SlashCommand[] = [
|
||||
{
|
||||
@ -19,6 +19,57 @@ const COMMANDS: SlashCommand[] = [
|
||||
argHint: "[n]",
|
||||
},
|
||||
];
|
||||
|
||||
const CLI_APPS: CliAppInfo[] = [
|
||||
{
|
||||
name: "gimp",
|
||||
display_name: "GIMP",
|
||||
category: "image",
|
||||
description: "Image editing",
|
||||
requires: "",
|
||||
source: "harness",
|
||||
entry_point: "cli-anything-gimp",
|
||||
install_supported: true,
|
||||
installed: true,
|
||||
available: true,
|
||||
status: "installed",
|
||||
logo_url: "https://example.invalid/gimp.svg",
|
||||
brand_color: "#5C5543",
|
||||
skill_installed: true,
|
||||
},
|
||||
{
|
||||
name: "blender",
|
||||
display_name: "Blender",
|
||||
category: "3d",
|
||||
description: "3D creation",
|
||||
requires: "",
|
||||
source: "harness",
|
||||
entry_point: "cli-anything-blender",
|
||||
install_supported: true,
|
||||
installed: true,
|
||||
available: true,
|
||||
status: "installed",
|
||||
logo_url: null,
|
||||
brand_color: "#E87D0D",
|
||||
skill_installed: true,
|
||||
},
|
||||
{
|
||||
name: "krita",
|
||||
display_name: "Krita",
|
||||
category: "image",
|
||||
description: "Painting",
|
||||
requires: "",
|
||||
source: "harness",
|
||||
entry_point: "cli-anything-krita",
|
||||
install_supported: true,
|
||||
installed: false,
|
||||
available: false,
|
||||
status: "not_installed",
|
||||
logo_url: null,
|
||||
brand_color: "#3BABFF",
|
||||
skill_installed: false,
|
||||
},
|
||||
];
|
||||
const ORIGINAL_INNER_HEIGHT = window.innerHeight;
|
||||
|
||||
afterEach(() => {
|
||||
@ -66,7 +117,7 @@ describe("ThreadComposer", () => {
|
||||
const input = screen.getByPlaceholderText("Ask anything...");
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input.className).toContain("min-h-[78px]");
|
||||
expect(input.parentElement?.className).toContain("max-w-[58rem]");
|
||||
expect(input.parentElement?.parentElement?.className).toContain("max-w-[58rem]");
|
||||
});
|
||||
|
||||
it("keeps the thread composer compact while matching the hero style", () => {
|
||||
@ -81,9 +132,9 @@ describe("ThreadComposer", () => {
|
||||
expect(screen.getByText("gpt-4o")).toBeInTheDocument();
|
||||
const input = screen.getByPlaceholderText("Type your message...");
|
||||
expect(input.className).toContain("min-h-[50px]");
|
||||
expect(input.parentElement?.className).toContain("max-w-[49.5rem]");
|
||||
expect(input.parentElement?.className).toContain("rounded-[22px]");
|
||||
expect(input.parentElement?.className).toContain("shadow-[0_12px_30px_rgba(15,23,42,0.07)]");
|
||||
expect(input.parentElement?.parentElement?.className).toContain("max-w-[49.5rem]");
|
||||
expect(input.parentElement?.parentElement?.className).toContain("rounded-[22px]");
|
||||
expect(input.parentElement?.parentElement?.className).toContain("shadow-[0_12px_30px_rgba(15,23,42,0.07)]");
|
||||
expect(screen.getByRole("button", { name: "Attach image" }).className).toContain("bg-card");
|
||||
expect(screen.getByRole("button", { name: "Send message" }).className).toContain("bg-foreground");
|
||||
});
|
||||
@ -163,6 +214,123 @@ describe("ThreadComposer", () => {
|
||||
expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the CLI app mention palette and inserts the selected app", () => {
|
||||
const onSend = vi.fn();
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={onSend}
|
||||
placeholder="Type your message..."
|
||||
cliApps={CLI_APPS}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Message input");
|
||||
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
|
||||
|
||||
const palette = screen.getByRole("listbox", { name: "CLI Apps" });
|
||||
expect(palette).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: /@gimp/i })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
expect(screen.queryByRole("option", { name: /@krita/i })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
expect(screen.getByRole("option", { name: /@blender/i })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
expect(input).toHaveValue("@blender ");
|
||||
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();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith("@blender", undefined, {
|
||||
cliApps: [{
|
||||
name: "blender",
|
||||
display_name: "Blender",
|
||||
category: "3d",
|
||||
entry_point: "cli-anything-blender",
|
||||
logo_url: null,
|
||||
brand_color: "#E87D0D",
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
it("completes a CLI app mention with Tab and adds exactly one trailing space", () => {
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
placeholder="Type your message..."
|
||||
cliApps={CLI_APPS}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Message input");
|
||||
fireEvent.change(input, {
|
||||
target: { value: "use @ble", selectionStart: 8 },
|
||||
});
|
||||
|
||||
fireEvent.keyDown(input, { key: "Tab" });
|
||||
|
||||
expect(input).toHaveValue("use @blender ");
|
||||
expect(screen.getByTestId("composer-cli-mention-blender")).toHaveTextContent("@blender");
|
||||
});
|
||||
|
||||
it("does not duplicate the next word separator when completing a CLI app mention", () => {
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
placeholder="Type your message..."
|
||||
cliApps={CLI_APPS}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Message input");
|
||||
fireEvent.change(input, {
|
||||
target: { value: "use @ble tonight", selectionStart: 8 },
|
||||
});
|
||||
|
||||
fireEvent.keyDown(input, { key: "Tab" });
|
||||
|
||||
expect(input).toHaveValue("use @blender tonight");
|
||||
});
|
||||
|
||||
it("renders a CLI app mention logo inline without moving the text cursor slot", () => {
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
placeholder="Type your message..."
|
||||
cliApps={CLI_APPS}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Message input");
|
||||
fireEvent.change(input, {
|
||||
target: { value: "meeting in @gimp", selectionStart: 16 },
|
||||
});
|
||||
|
||||
expect(input).toHaveValue("meeting in @gimp");
|
||||
const token = screen.getByTestId("composer-cli-mention-gimp");
|
||||
expect(token).toHaveTextContent("@gimp");
|
||||
expect(token.className).not.toContain("font-semibold");
|
||||
expect(token.className).not.toContain("zoom-in");
|
||||
expect(token.className).not.toContain("px-");
|
||||
expect(token.className).not.toContain("mx-");
|
||||
expect(token.getAttribute("style")).toContain("color: #5C5543");
|
||||
expect(token.getAttribute("style")).toContain("text-shadow");
|
||||
expect(screen.queryByTestId("composer-cli-app-tray")).not.toBeInTheDocument();
|
||||
const logo = screen.getByTestId("composer-cli-mention-logo-gimp");
|
||||
expect(logo.className).toContain("top-1/2");
|
||||
expect(logo.className).toContain("left-1/2");
|
||||
expect(logo.className).not.toContain("-top-");
|
||||
});
|
||||
|
||||
it("opens the slash command palette downward when there is more room below", async () => {
|
||||
vi.spyOn(HTMLFormElement.prototype, "getBoundingClientRect").mockReturnValue(
|
||||
rect({ top: 40, bottom: 160, width: 800, height: 120 }),
|
||||
|
||||
@ -356,6 +356,58 @@ describe("useNanobotStream", () => {
|
||||
'exec({"cmd":"ls"})',
|
||||
'read_file({"path":"notes.md"})',
|
||||
]);
|
||||
expect(result.current.messages[0].toolEvents).toMatchObject([
|
||||
{ phase: "end", call_id: "call-exec", name: "exec" },
|
||||
{ phase: "error", call_id: "call-read", name: "read_file", error: "missing" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps phase updates when a tool event trace line is deduped", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-tool-phase", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
const args = { name: "github", args: ["repo", "view"], json: "true" };
|
||||
act(() => {
|
||||
fake.emit("chat-tool-phase", {
|
||||
event: "message",
|
||||
chat_id: "chat-tool-phase",
|
||||
text: "",
|
||||
kind: "tool_hint",
|
||||
tool_events: [{
|
||||
phase: "start",
|
||||
call_id: "call-cli",
|
||||
name: "run_cli_app",
|
||||
arguments: args,
|
||||
}],
|
||||
});
|
||||
fake.emit("chat-tool-phase", {
|
||||
event: "message",
|
||||
chat_id: "chat-tool-phase",
|
||||
text: "",
|
||||
kind: "progress",
|
||||
tool_events: [{
|
||||
phase: "error",
|
||||
call_id: "call-cli",
|
||||
name: "run_cli_app",
|
||||
arguments: args,
|
||||
error: "Error: CLI app 'github' not found",
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages[0].traces).toEqual([
|
||||
'run_cli_app({"name":"github","args":["repo","view"],"json":"true"})',
|
||||
]);
|
||||
expect(result.current.messages[0].toolEvents).toMatchObject([
|
||||
{
|
||||
phase: "error",
|
||||
call_id: "call-cli",
|
||||
name: "run_cli_app",
|
||||
error: "Error: CLI app 'github' not found",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders live file_edit events as their own activity trace", () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user