mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-10 22:38:40 +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
|
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.
|
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
|
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
|
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
|
`<workspace>/plugins/`; updates refresh that package and uninstall removes it. The external
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -43,6 +44,13 @@ _MCP_SERVER_FIELDS = {
|
|||||||
}
|
}
|
||||||
_SETUP_ENV = {"HOME", "LANG", "LC_ALL", "LOGNAME", "PATH", "SHELL", "TMPDIR", "USER"}
|
_SETUP_ENV = {"HOME", "LANG", "LC_ALL", "LOGNAME", "PATH", "SHELL", "TMPDIR", "USER"}
|
||||||
_SETUP_TIMEOUT_SECONDS = 600
|
_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)
|
@dataclass(frozen=True)
|
||||||
@@ -66,6 +74,7 @@ class AgentPlugin:
|
|||||||
display_name: str
|
display_name: str
|
||||||
category: str
|
category: str
|
||||||
accent_color: str | None
|
accent_color: str | None
|
||||||
|
logo: Path | None
|
||||||
permissions: tuple[str, ...]
|
permissions: tuple[str, ...]
|
||||||
install_command: 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,
|
display_name=_string(nanobot.get("displayName")) or name,
|
||||||
category=_string(nanobot.get("category")) or "Plugin",
|
category=_string(nanobot.get("category")) or "Plugin",
|
||||||
accent_color=_accent_color(nanobot.get("accentColor")),
|
accent_color=_accent_color(nanobot.get("accentColor")),
|
||||||
|
logo=_plugin_logo(nanobot.get("logo"), plugin_root),
|
||||||
permissions=_string_tuple(nanobot.get("permissions")),
|
permissions=_string_tuple(nanobot.get("permissions")),
|
||||||
install_command=_install_command(nanobot.get("installCommand"), plugin_root),
|
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,
|
"category": plugin.category,
|
||||||
"repository": plugin.repository,
|
"repository": plugin.repository,
|
||||||
"accent_color": plugin.accent_color,
|
"accent_color": plugin.accent_color,
|
||||||
|
"logo_url": _plugin_logo_data_url(plugin.logo),
|
||||||
"permissions": list(plugin.permissions),
|
"permissions": list(plugin.permissions),
|
||||||
"mcp_servers": mcp_servers,
|
"mcp_servers": mcp_servers,
|
||||||
"enabled": enabled,
|
"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
|
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, ...]:
|
def _install_command(value: object, plugin_root: Path) -> tuple[str, ...]:
|
||||||
"""Validate nanobot's optional, shell-free setup command extension."""
|
"""Validate nanobot's optional, shell-free setup command extension."""
|
||||||
if not isinstance(value, list):
|
if not isinstance(value, list):
|
||||||
|
|||||||
@@ -866,7 +866,7 @@ def _agent_plugin_payload(plugin: Mapping[str, Any]) -> dict[str, Any]:
|
|||||||
"configured": enabled,
|
"configured": enabled,
|
||||||
"available": enabled,
|
"available": enabled,
|
||||||
"status": "configured" if enabled else "not_installed",
|
"status": "configured" if enabled else "not_installed",
|
||||||
"logo_url": None,
|
"logo_url": plugin.get("logo_url"),
|
||||||
"brand_color": plugin.get("accent_color"),
|
"brand_color": plugin.get("accent_color"),
|
||||||
"required_fields": [],
|
"required_fields": [],
|
||||||
"connection_summary": ", ".join(server_names),
|
"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"]
|
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(
|
@pytest.mark.parametrize(
|
||||||
("skill_name", "frontmatter"),
|
("skill_name", "frontmatter"),
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ def _write_agent_plugin(workspace: Path) -> None:
|
|||||||
command = root / "bin" / "server"
|
command = root / "bin" / "server"
|
||||||
command.parent.mkdir(parents=True, exist_ok=True)
|
command.parent.mkdir(parents=True, exist_ok=True)
|
||||||
command.write_text("#!/bin/sh\n", encoding="utf-8")
|
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(
|
(root / "plugin.json").write_text(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
@@ -43,6 +46,7 @@ def _write_agent_plugin(workspace: Path) -> None:
|
|||||||
"dev.nanobot": {
|
"dev.nanobot": {
|
||||||
"displayName": "Desktop Control",
|
"displayName": "Desktop Control",
|
||||||
"accentColor": "#ff7a1a",
|
"accentColor": "#ff7a1a",
|
||||||
|
"logo": "./assets/icon.png",
|
||||||
"permissions": ["screen-recording"],
|
"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")
|
row = next(item for item in mcp_presets_payload()["presets"] if item["source"] == "agent-plugin")
|
||||||
assert row["name"] == "plugin-desktop"
|
assert row["name"] == "plugin-desktop"
|
||||||
assert row["display_name"] == "Desktop Control"
|
assert row["display_name"] == "Desktop Control"
|
||||||
|
assert row["logo_url"] == "data:image/png;base64,iVBORw0KGgpsb2dv"
|
||||||
assert row["installed"] is True
|
assert row["installed"] is True
|
||||||
assert row["configured"] is False
|
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 bg = preset.brand_color || "hsl(var(--muted))";
|
||||||
const logoUrls = useMemo(() => logoFallbackUrls(preset.logo_url), [preset.logo_url]);
|
const logoUrls = useMemo(() => logoFallbackUrls(preset.logo_url), [preset.logo_url]);
|
||||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
|
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
|
||||||
|
const packagedLogo = preset.logo_url?.startsWith("data:image/") === true;
|
||||||
const initials = preset.display_name
|
const initials = preset.display_name
|
||||||
.split(/\s+/)
|
.split(/\s+/)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -8239,7 +8240,7 @@ function McpPresetLogo({ preset, showBrandLogos }: { preset: McpPresetInfo; show
|
|||||||
.map((part) => part[0]?.toUpperCase())
|
.map((part) => part[0]?.toUpperCase())
|
||||||
.join("") || preset.name.slice(0, 2).toUpperCase();
|
.join("") || preset.name.slice(0, 2).toUpperCase();
|
||||||
|
|
||||||
if (showBrandLogos && logoUrl) {
|
if ((showBrandLogos || packagedLogo) && logoUrl) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="grid h-11 w-11 shrink-0 place-items-center rounded-[8px] border border-border/45 bg-background"
|
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