mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-10 14:28:38 +03:00
feat(webui): show packaged agent plugin logos
This commit is contained in:
@@ -2343,6 +2343,10 @@ local WebUI runs it once per plugin version before first enable; remote WebUI cl
|
||||
plugin setup unless remote package installation was explicitly allowed. Agent Plugins v1 does
|
||||
not define a registry, so package distribution remains separate from discovery and execution.
|
||||
|
||||
The optional `extensions.dev.nanobot.logo` field points to a packaged PNG, JPEG, or WebP asset
|
||||
such as `./assets/icon.png`. nanobot only reads contained raster files up to 256 KiB and embeds
|
||||
them locally in the Apps catalog; invalid or missing assets fall back to the plugin initials.
|
||||
|
||||
CLI Apps installed from the WebUI use the same package layout. nanobot installs the CLI through
|
||||
its catalog adapter, then writes and enables a skills-only Agent Plugin under
|
||||
`<workspace>/plugins/`; updates refresh that package and uninstall removes it. The external
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -43,6 +44,13 @@ _MCP_SERVER_FIELDS = {
|
||||
}
|
||||
_SETUP_ENV = {"HOME", "LANG", "LC_ALL", "LOGNAME", "PATH", "SHELL", "TMPDIR", "USER"}
|
||||
_SETUP_TIMEOUT_SECONDS = 600
|
||||
_LOGO_MIME_TYPES = {
|
||||
".jpeg": "image/jpeg",
|
||||
".jpg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".webp": "image/webp",
|
||||
}
|
||||
_MAX_LOGO_BYTES = 256 * 1024
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -66,6 +74,7 @@ class AgentPlugin:
|
||||
display_name: str
|
||||
category: str
|
||||
accent_color: str | None
|
||||
logo: Path | None
|
||||
permissions: tuple[str, ...]
|
||||
install_command: tuple[str, ...]
|
||||
|
||||
@@ -167,6 +176,7 @@ def _load_manifest(plugin_root: Path) -> AgentPlugin | None:
|
||||
display_name=_string(nanobot.get("displayName")) or name,
|
||||
category=_string(nanobot.get("category")) or "Plugin",
|
||||
accent_color=_accent_color(nanobot.get("accentColor")),
|
||||
logo=_plugin_logo(nanobot.get("logo"), plugin_root),
|
||||
permissions=_string_tuple(nanobot.get("permissions")),
|
||||
install_command=_install_command(nanobot.get("installCommand"), plugin_root),
|
||||
)
|
||||
@@ -214,6 +224,7 @@ def agent_plugins_payload(workspace: Path) -> dict[str, Any]:
|
||||
"category": plugin.category,
|
||||
"repository": plugin.repository,
|
||||
"accent_color": plugin.accent_color,
|
||||
"logo_url": _plugin_logo_data_url(plugin.logo),
|
||||
"permissions": list(plugin.permissions),
|
||||
"mcp_servers": mcp_servers,
|
||||
"enabled": enabled,
|
||||
@@ -283,6 +294,46 @@ def _accent_color(value: object) -> str | None:
|
||||
return value if isinstance(value, str) and re.fullmatch(r"#[0-9a-fA-F]{6}", value) else None
|
||||
|
||||
|
||||
def _plugin_logo(value: object, plugin_root: Path) -> Path | None:
|
||||
"""Resolve nanobot's optional packaged logo extension."""
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str) or not value.startswith("./"):
|
||||
logger.warning("Ignoring invalid Agent Plugin logo in '{}'", plugin_root)
|
||||
return None
|
||||
logo = _contained_file(plugin_root / value[2:], plugin_root)
|
||||
if logo is None or logo.suffix.lower() not in _LOGO_MIME_TYPES:
|
||||
logger.warning("Ignoring invalid Agent Plugin logo in '{}'", plugin_root)
|
||||
return None
|
||||
try:
|
||||
if logo.stat().st_size > _MAX_LOGO_BYTES:
|
||||
logger.warning("Ignoring oversized Agent Plugin logo in '{}'", plugin_root)
|
||||
return None
|
||||
except OSError:
|
||||
return None
|
||||
return logo
|
||||
|
||||
|
||||
def _plugin_logo_data_url(path: Path | None) -> str | None:
|
||||
if path is None:
|
||||
return None
|
||||
try:
|
||||
data = path.read_bytes()
|
||||
except OSError:
|
||||
return None
|
||||
suffix = path.suffix.lower()
|
||||
valid = (
|
||||
suffix == ".png" and data.startswith(b"\x89PNG\r\n\x1a\n")
|
||||
or suffix in {".jpg", ".jpeg"} and data.startswith(b"\xff\xd8\xff")
|
||||
or suffix == ".webp" and data.startswith(b"RIFF") and data[8:12] == b"WEBP"
|
||||
)
|
||||
if not valid:
|
||||
logger.warning("Ignoring malformed Agent Plugin logo '{}'", path)
|
||||
return None
|
||||
encoded = base64.b64encode(data).decode("ascii")
|
||||
return f"data:{_LOGO_MIME_TYPES[suffix]};base64,{encoded}"
|
||||
|
||||
|
||||
def _install_command(value: object, plugin_root: Path) -> tuple[str, ...]:
|
||||
"""Validate nanobot's optional, shell-free setup command extension."""
|
||||
if not isinstance(value, list):
|
||||
|
||||
@@ -866,7 +866,7 @@ def _agent_plugin_payload(plugin: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"configured": enabled,
|
||||
"available": enabled,
|
||||
"status": "configured" if enabled else "not_installed",
|
||||
"logo_url": None,
|
||||
"logo_url": plugin.get("logo_url"),
|
||||
"brand_color": plugin.get("accent_color"),
|
||||
"required_fields": [],
|
||||
"connection_summary": ", ".join(server_names),
|
||||
|
||||
@@ -142,6 +142,59 @@ def test_unknown_manifest_fields_and_non_object_extensions_are_ignored(tmp_path:
|
||||
assert [skill.name for skill in discover_agent_plugin_skills(tmp_path)] == ["example"]
|
||||
|
||||
|
||||
def test_agent_plugin_payload_embeds_contained_raster_logo(tmp_path: Path) -> None:
|
||||
plugin = _write_plugin(
|
||||
tmp_path,
|
||||
"demo",
|
||||
manifest={
|
||||
"$schema": AGENT_PLUGIN_SCHEMA,
|
||||
"name": "demo",
|
||||
"extensions": {"dev.nanobot": {"logo": "./assets/icon.png"}},
|
||||
},
|
||||
)
|
||||
assets = plugin / "assets"
|
||||
assets.mkdir()
|
||||
(assets / "icon.png").write_bytes(b"\x89PNG\r\n\x1a\nlogo")
|
||||
executable = plugin / "bin" / "server"
|
||||
executable.parent.mkdir()
|
||||
executable.write_text("#!/bin/sh\n", encoding="utf-8")
|
||||
(plugin / "mcp.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
|
||||
"mcpServers": {"demo": {"type": "stdio", "command": "./bin/server"}},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
logo_url = agent_plugins_payload(tmp_path)["plugins"][0]["logo_url"]
|
||||
|
||||
assert logo_url == "data:image/png;base64,iVBORw0KGgpsb2dv"
|
||||
|
||||
|
||||
def test_agent_plugin_logo_cannot_escape_package(tmp_path: Path) -> None:
|
||||
outside = tmp_path / "outside.png"
|
||||
outside.write_bytes(b"\x89PNG\r\n\x1a\nlogo")
|
||||
plugin = _write_plugin(
|
||||
tmp_path,
|
||||
"demo",
|
||||
manifest={
|
||||
"$schema": AGENT_PLUGIN_SCHEMA,
|
||||
"name": "demo",
|
||||
"extensions": {"dev.nanobot": {"logo": "./assets/icon.png"}},
|
||||
},
|
||||
)
|
||||
assets = plugin / "assets"
|
||||
assets.mkdir()
|
||||
try:
|
||||
(assets / "icon.png").symlink_to(outside)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"file symlink unavailable: {exc}")
|
||||
|
||||
assert agent_plugins.discover_agent_plugins(tmp_path)[0].logo is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("skill_name", "frontmatter"),
|
||||
[
|
||||
|
||||
@@ -33,6 +33,9 @@ def _write_agent_plugin(workspace: Path) -> None:
|
||||
command = root / "bin" / "server"
|
||||
command.parent.mkdir(parents=True, exist_ok=True)
|
||||
command.write_text("#!/bin/sh\n", encoding="utf-8")
|
||||
assets = root / "assets"
|
||||
assets.mkdir()
|
||||
(assets / "icon.png").write_bytes(b"\x89PNG\r\n\x1a\nlogo")
|
||||
(root / "plugin.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
@@ -43,6 +46,7 @@ def _write_agent_plugin(workspace: Path) -> None:
|
||||
"dev.nanobot": {
|
||||
"displayName": "Desktop Control",
|
||||
"accentColor": "#ff7a1a",
|
||||
"logo": "./assets/icon.png",
|
||||
"permissions": ["screen-recording"],
|
||||
}
|
||||
},
|
||||
@@ -109,6 +113,7 @@ def test_agent_plugin_reuses_mcp_catalog_and_runtime_action(
|
||||
row = next(item for item in mcp_presets_payload()["presets"] if item["source"] == "agent-plugin")
|
||||
assert row["name"] == "plugin-desktop"
|
||||
assert row["display_name"] == "Desktop Control"
|
||||
assert row["logo_url"] == "data:image/png;base64,iVBORw0KGgpsb2dv"
|
||||
assert row["installed"] is True
|
||||
assert row["configured"] is False
|
||||
|
||||
|
||||
@@ -8232,6 +8232,7 @@ function McpPresetLogo({ preset, showBrandLogos }: { preset: McpPresetInfo; show
|
||||
const bg = preset.brand_color || "hsl(var(--muted))";
|
||||
const logoUrls = useMemo(() => logoFallbackUrls(preset.logo_url), [preset.logo_url]);
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
|
||||
const packagedLogo = preset.logo_url?.startsWith("data:image/") === true;
|
||||
const initials = preset.display_name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
@@ -8239,7 +8240,7 @@ function McpPresetLogo({ preset, showBrandLogos }: { preset: McpPresetInfo; show
|
||||
.map((part) => part[0]?.toUpperCase())
|
||||
.join("") || preset.name.slice(0, 2).toUpperCase();
|
||||
|
||||
if (showBrandLogos && logoUrl) {
|
||||
if ((showBrandLogos || packagedLogo) && logoUrl) {
|
||||
return (
|
||||
<span
|
||||
className="grid h-11 w-11 shrink-0 place-items-center rounded-[8px] border border-border/45 bg-background"
|
||||
|
||||
Reference in New Issue
Block a user