From e97f64e777925e3f17fb0285fe6ec6702a1aed25 Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Sun, 11 Oct 2026 02:16:52 +0800 Subject: [PATCH] feat(plugin): add avatar to agent pet declarations Co-Authored-By: Claude Opus 5.5 --- app/runtime/extensions/plugin/projection.py | 29 ++++++++++++--------- app/schemas/plugin.py | 4 +++ app/sdk/plugin/base.py | 4 ++- tests/test_plugin_agent_pets.py | 23 +++++++++++++--- 4 files changed, 43 insertions(+), 17 deletions(-) diff --git a/app/runtime/extensions/plugin/projection.py b/app/runtime/extensions/plugin/projection.py index 022619660..050fb1601 100644 --- a/app/runtime/extensions/plugin/projection.py +++ b/app/runtime/extensions/plugin/projection.py @@ -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 与版本缓存键,分身由静态文件接口映射回源插件目录。 diff --git a/app/schemas/plugin.py b/app/schemas/plugin.py index 8cc95f05a..4f676bc97 100644 --- a/app/schemas/plugin.py +++ b/app/schemas/plugin.py @@ -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 模式为空", diff --git a/app/sdk/plugin/base.py b/app/sdk/plugin/base.py index 2466af1b7..74d312d87 100644 --- a/app/sdk/plugin/base.py +++ b/app/sdk/plugin/base.py @@ -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 diff --git a/tests/test_plugin_agent_pets.py b/tests/test_plugin_agent_pets.py index b67eee5d3..0bf14f069 100644 --- a/tests/test_plugin_agent_pets.py +++ b/tests/test_plugin_agent_pets.py @@ -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():