feat(plugin): add avatar to agent pet declarations

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
InfinityPacer
2026-10-11 02:16:52 +08:00
committed by InfinityPacer
parent 25aaf3a32b
commit e97f64e777
4 changed files with 43 additions and 17 deletions

View File

@@ -19,7 +19,7 @@ from app.schemas.plugin import PluginDashboard
_AGENT_PET_KEY_PATTERN = re.compile(r"^[a-z0-9_-]{1,32}$")
_AGENT_PET_MODES = frozenset({"stage", "renderer"})
_AGENT_PET_BUBBLES = frozenset({"host", "self"})
# 预览图允许原样透传的绝对地址前缀,其余一律视为相对联邦产物目录的路径
# 预览图与头像允许原样透传的绝对地址前缀,其余一律视为相对联邦产物目录的路径
_AGENT_PET_ABSOLUTE_PREVIEW_PREFIXES = ("http://", "https://", "data:")
@@ -383,11 +383,12 @@ class PluginProjection:
)
continue
seen.add(item["key"])
preview = item.pop("preview")
item["preview_url"] = (
self._agent_pet_preview_url(plugin_id, plugin, dist_path, preview)
if preview else None
)
for field in ("preview", "avatar"):
image = item.pop(field)
item[f"{field}_url"] = (
self._agent_pet_image_url(plugin_id, plugin, dist_path, image)
if image else None
)
items.append({**base, **item})
return items
@@ -414,7 +415,8 @@ class PluginProjection:
api_version = raw.get("api_version", 1)
if isinstance(api_version, bool) or not isinstance(api_version, int) or api_version < 1:
raise _InvalidAgentPet(f"{key} 的 api_version 必须是正整数:{api_version!r}")
preview = PluginProjection._agent_pet_preview(key, raw.get("preview"))
preview = PluginProjection._agent_pet_image(key, "preview", raw.get("preview"))
avatar = PluginProjection._agent_pet_image(key, "avatar", raw.get("avatar"))
bubbles = None
random_actions = None
if mode == "stage":
@@ -433,6 +435,7 @@ class PluginProjection:
"component": component.strip().removeprefix("./"),
"api_version": api_version,
"preview": preview,
"avatar": avatar,
"bubbles": bubbles,
"random_actions": random_actions,
}
@@ -449,12 +452,12 @@ class PluginProjection:
return list(dict.fromkeys(value))
@staticmethod
def _agent_pet_preview(key: str, value: Any) -> Optional[str]:
"""校验预览图声明:绝对地址仅允许 http(s) 与 data,相对路径不得越出联邦产物目录。"""
def _agent_pet_image(key: str, field: str, value: Any) -> Optional[str]:
"""校验预览图或头像声明:绝对地址仅允许 http(s) 与 data,相对路径不得越出联邦产物目录。"""
if value is None or value == "":
return None
if not isinstance(value, str):
raise _InvalidAgentPet(f"{key} 的 preview 必须是字符串")
raise _InvalidAgentPet(f"{key} 的 {field} 必须是字符串")
preview = value.strip()
if preview.lower().startswith(_AGENT_PET_ABSOLUTE_PREVIEW_PREFIXES):
return preview
@@ -464,17 +467,17 @@ class PluginProjection:
or "\\" in relative
or any(segment in ("", ".", "..") for segment in relative.split("/"))
):
raise _InvalidAgentPet(f"{key} 的 preview 路径无效:{value!r}")
raise _InvalidAgentPet(f"{key} 的 {field} 路径无效:{value!r}")
return relative
def _agent_pet_preview_url(
def _agent_pet_image_url(
self,
plugin_id: str,
plugin: Any,
dist_path: str,
preview: str,
) -> str:
"""把已校验的预览图解析为可访问 URL。
"""把已校验的预览图或头像解析为可访问 URL。
绝对地址原样透传;相对路径解析到 remoteEntry 所在目录,与 remoteEntry 共用
插件静态文件接口、实例 ID 与版本缓存键,分身由静态文件接口映射回源插件目录。

View File

@@ -567,6 +567,10 @@ class PluginAgentPet(BaseModel): # type: ignore[misc]
default=None,
description="预览图 URL:相对路径已解析为与 remoteEntry 同源的插件静态文件地址,或原样的 http(s)/data URL",
)
avatar_url: Optional[str] = Field(
default=None,
description="方形头像 URL,用于 Agent 面板头部与消息头像;解析规则同 preview_url,未声明为空",
)
bubbles: Optional[Literal["host", "self"]] = Field(
default=None,
description="气泡归属,仅 stage 模式:host(宿主按锚点绘制)/ self(插件自绘);renderer 模式为空",

View File

@@ -214,6 +214,7 @@ class _PluginBase(metaclass=ABCMeta):
- component:联邦暴露名,默认 ``AgentPet``(即 ``./AgentPet``)
- api_version:形象契约版本,默认 1;前端忽略不认识的版本
- preview:预览图,相对插件联邦产物目录的路径,或 ``http(s)://``、``data:`` URL
- avatar:方形头像,用于 Agent 面板头部与消息头像,路径规则同 preview
- bubbles:仅 stage 模式,``host``(插件上报锚点、宿主画气泡,默认)或 ``self``
- random_actions:仅 renderer 模式,宿主随机动作的候选名单
@@ -224,7 +225,8 @@ class _PluginBase(metaclass=ABCMeta):
"key": "chibi",
"name": "Q 版看板娘",
"mode": "stage",
"preview": "preview.png"
"preview": "preview.png",
"avatar": "avatar.png"
}]
"""
pass

View File

@@ -69,11 +69,17 @@ def _projection(plugins, log=None):
def test_agent_pets_projects_defaults_and_preview_url_next_to_remote_entry():
"""合法声明补齐默认值,相对预览图解析到与 remoteEntry 相同的静态目录并带版本。"""
"""合法声明补齐默认值,相对预览图与头像解析到与 remoteEntry 相同的静态目录并带版本,缺省为空。"""
plugin = _Plugin(
plugin_version="1.0.0",
get_agent_pets=lambda: [
{"key": "chibi", "name": "看板娘", "mode": "stage", "preview": "./img/preview.png"},
{
"key": "chibi",
"name": "看板娘",
"mode": "stage",
"preview": "./img/preview.png",
"avatar": "img/avatar.png",
},
{
"key": "sprite_1",
"name": " 精灵 ",
@@ -81,6 +87,7 @@ def test_agent_pets_projects_defaults_and_preview_url_next_to_remote_entry():
"component": "./SpritePet",
"api_version": 2,
"preview": "https://example.com/p.png",
"avatar": "data:image/png;base64,AAAA",
"random_actions": ["wave", "jump", "wave"],
},
{"key": "plain", "name": "无预览"},
@@ -104,6 +111,7 @@ def test_agent_pets_projects_defaults_and_preview_url_next_to_remote_entry():
"component": "AgentPet",
"api_version": 1,
"preview_url": "/plugin/file/petdemo/dist/assets/img/preview.png?v=1.0.0",
"avatar_url": "/plugin/file/petdemo/dist/assets/img/avatar.png?v=1.0.0",
"bubbles": "host",
"random_actions": None,
},
@@ -118,6 +126,7 @@ def test_agent_pets_projects_defaults_and_preview_url_next_to_remote_entry():
"component": "SpritePet",
"api_version": 2,
"preview_url": "https://example.com/p.png",
"avatar_url": "data:image/png;base64,AAAA",
"bubbles": None,
"random_actions": ["wave", "jump"],
},
@@ -132,6 +141,7 @@ def test_agent_pets_projects_defaults_and_preview_url_next_to_remote_entry():
"component": "AgentPet",
"api_version": 1,
"preview_url": None,
"avatar_url": None,
"bubbles": None,
"random_actions": None,
},
@@ -168,6 +178,10 @@ def test_agent_pets_skips_disabled_non_vue_and_unimplemented_plugins():
{"key": "trav", "name": "越界", "preview": "../secret.png"},
{"key": "abs", "name": "根路径", "preview": "/etc/passwd"},
{"key": "js", "name": "脚本", "preview": "javascript:alert(1)"},
{"key": "avatar_trav", "name": "头像越界", "avatar": "img/../../secret.png"},
{"key": "avatar_abs", "name": "头像根路径", "avatar": "/etc/passwd"},
{"key": "avatar_ftp", "name": "头像协议", "avatar": "ftp://example.com/a.png"},
{"key": "avatar_type", "name": "头像类型", "avatar": 1},
{"key": "acts", "name": "动作", "random_actions": "wave"},
"not-a-dict",
],
@@ -208,7 +222,9 @@ def test_agent_pets_instance_keeps_instance_id_and_source_identity():
"""插件分身沿用实例 ID 作为 plugin_id 与预览 URL 路径,source_plugin_id 指向源插件。"""
plugin = _Plugin(
plugin_source_id="PetDemo",
get_agent_pets=lambda: [{"key": "pet", "name": "形象", "preview": "preview.png"}],
get_agent_pets=lambda: [
{"key": "pet", "name": "形象", "preview": "preview.png", "avatar": "avatar.png"}
],
)
pet = _projection({"PetDemoWork": plugin}).agent_pets()[0]
@@ -216,6 +232,7 @@ def test_agent_pets_instance_keeps_instance_id_and_source_identity():
assert pet["plugin_id"] == "PetDemoWork"
assert pet["source_plugin_id"] == "PetDemo"
assert pet["preview_url"] == "/plugin/file/petdemowork/dist/assets/preview.png"
assert pet["avatar_url"] == "/plugin/file/petdemowork/dist/assets/avatar.png"
def test_agent_pets_isolates_hook_failure_and_non_list_result():