diff --git a/app/agent/tools/impl/_plugin_tool_utils.py b/app/agent/tools/impl/_plugin_tool_utils.py index 13e331c5c..f0e752cd6 100644 --- a/app/agent/tools/impl/_plugin_tool_utils.py +++ b/app/agent/tools/impl/_plugin_tool_utils.py @@ -336,20 +336,26 @@ async def install_plugin_runtime( return None async def reload_runtime(target_id: str) -> object: - """通过 Agent 阻塞任务适配器重建插件实例。""" + """通过 Agent 阻塞任务适配器重载源插件及其虚拟实例。""" return await run_agent_blocking( "plugin", - plugin_manager.reload_plugin, + plugin_manager.reload_plugin_tree, target_id, ) async def refresh_registrations(target_id: str) -> object: - """通过 Agent 阻塞任务适配器刷新服务、命令和动态路由。""" - return await run_agent_blocking( - "plugin", - refresh_plugin_registrations, - target_id, - ) + """通过 Agent 阻塞任务适配器刷新源插件及其虚拟实例注册。""" + result = None + reload_targets = list( + plugin_manager.get_plugin_reload_targets(target_id) + ) or [target_id] + for reload_target in reload_targets: + result = await run_agent_blocking( + "plugin", + refresh_plugin_registrations, + reload_target, + ) + return result with plugin_manager.suppress_plugin_monitor(plugin_id): result = await PluginInstallCommand( @@ -387,6 +393,13 @@ async def uninstall_plugin_runtime(plugin_id: str) -> dict[str, Any]: from app.application.plugin.routes import remove_plugin_api from app.application.scheduling import remove_plugin_job + plugin_manager = get_plugin_manager() + virtual_instance = plugin_manager.get_plugin_instance(plugin_id) + source_instances = plugin_manager.get_plugin_source_instances(plugin_id) + if not virtual_instance and source_instances: + instance_ids = "、".join(item.instance_id for item in source_instances) + raise ValueError(f"请先卸载该插件的分身:{instance_ids}") + config_oper = SystemConfigOper() install_plugins = config_oper.get(SystemConfigKey.UserInstalledPlugins) or [] if plugin_id in install_plugins: @@ -396,12 +409,15 @@ async def uninstall_plugin_runtime(plugin_id: str) -> dict[str, Any]: remove_plugin_api(plugin_id) remove_plugin_job(plugin_id) - plugin_manager = get_plugin_manager() plugin_class = plugin_manager.plugins.get(plugin_id) was_clone = bool(getattr(plugin_class, "is_clone", False)) clone_files_removed = False - if was_clone: + if virtual_instance: + plugin_manager.delete_plugin_config(plugin_id, force=True) + plugin_manager.delete_plugin_data(plugin_id, force=True) + plugin_manager.delete_plugin_instance(plugin_id) + elif was_clone: plugin_manager.delete_plugin_config(plugin_id) plugin_manager.delete_plugin_data(plugin_id) plugin_base_dir = settings.ROOT_PATH / "app" / "plugins" / plugin_id.lower() diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py index f0af8c86f..3de933d65 100644 --- a/app/api/endpoints/plugin.py +++ b/app/api/endpoints/plugin.py @@ -13,6 +13,7 @@ from starlette.responses import StreamingResponse from app.schemas.common import JsonObject as _SchemaJsonObject from app.schemas.plugin import Plugin as _SchemaPlugin from app.schemas.plugin import PluginDashboard as _SchemaPluginDashboard +from app.schemas.plugin import PluginCloneRequest as _SchemaPluginCloneRequest from app.schemas.plugin import PluginDashboardMetaItem as _SchemaPluginDashboardMetaItem from app.schemas.plugin import PluginFoldersData as _SchemaPluginFoldersData from app.schemas.plugin import PluginRating as _SchemaPluginRating @@ -558,12 +559,13 @@ async def install( ) async def reload_runtime(target_id: str) -> object: - """在线程池中重建插件实例并广播重载事件。""" - return await run_in_threadpool(PluginManager().reload_plugin, target_id) + """在线程池中重建源插件及其全部虚拟实例。""" + return await run_in_threadpool(PluginManager().reload_plugin_tree, target_id) async def refresh_registrations(target_id: str) -> object: - """在线程池中刷新插件服务、命令和动态路由。""" - return await run_in_threadpool(register_plugin, target_id) + """在线程池中刷新源插件及其虚拟实例的全部宿主注册。""" + for reload_target in plugin_manager.get_plugin_reload_targets(target_id): + await run_in_threadpool(register_plugin, reload_target) command = PluginInstallCommand( installed_plugins_reader=lambda: get_configured_system_config().get( @@ -779,8 +781,12 @@ async def plugin_static_file( ) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden") + source_plugin_id = PluginManager().get_plugin_source_id(plugin_id) plugin_base_dir = ( - AsyncPath(settings.ROOT_PATH) / "app" / "plugins" / plugin_id.lower() + AsyncPath(settings.ROOT_PATH) + / "app" + / "plugins" + / source_plugin_id.lower() ) plugin_file_path = plugin_base_dir / filepath.lstrip("/") @@ -940,7 +946,9 @@ async def update_folder_plugins( "/clone/{plugin_id}", summary="创建插件分身", response_model=_SchemaResponse[None] ) def clone_plugin( - plugin_id: str, clone_data: dict, _: ApiPrincipal = Depends(get_current_active_superuser) + plugin_id: str, + clone_data: _SchemaPluginCloneRequest, + _: ApiPrincipal = Depends(get_current_active_superuser), ) -> Any: """ 创建插件分身 @@ -948,16 +956,16 @@ def clone_plugin( try: success, message = PluginManager().clone_plugin( plugin_id=plugin_id, - suffix=clone_data.get("suffix", ""), - name=clone_data.get("name", ""), - description=clone_data.get("description", ""), - version=clone_data.get("version", ""), - icon=clone_data.get("icon", ""), + suffix=clone_data.suffix, + name=clone_data.name, + description=clone_data.description, + version=clone_data.version, + icon=clone_data.icon, ) if success: - # 注册插件服务 - reload_plugin(message) + # 分身服务已完成运行态加载,此处只补齐宿主注册。 + register_plugin(message) # 将分身插件添加到原插件所在的文件夹中 _add_clone_to_plugin_folder(plugin_id, message) return _SchemaResponse(success=True, message="插件分身创建成功") @@ -1003,6 +1011,15 @@ def uninstall_plugin( """ 卸载插件 """ + plugin_manager = PluginManager() + virtual_instance = plugin_manager.get_plugin_instance(plugin_id) + source_instances = plugin_manager.get_plugin_source_instances(plugin_id) + if not virtual_instance and source_instances: + instance_ids = "、".join(item.instance_id for item in source_instances) + return _SchemaResponse( + success=False, + message=f"请先卸载该插件的分身:{instance_ids}", + ) config_oper = get_configured_system_config() # 删除已安装信息 install_plugins = config_oper.get(SystemConfigKey.UserInstalledPlugins) or [] @@ -1016,9 +1033,12 @@ def uninstall_plugin( # 移除插件服务 remove_plugin_job(plugin_id) # 判断是否为分身 - plugin_manager = PluginManager() plugin_class = plugin_manager.plugins.get(plugin_id) - if getattr(plugin_class, "is_clone", False): + if virtual_instance: + plugin_manager.delete_plugin_config(plugin_id, force=True) + plugin_manager.delete_plugin_data(plugin_id, force=True) + plugin_manager.delete_plugin_instance(plugin_id) + elif getattr(plugin_class, "is_clone", False): # 如果是分身插件,则删除分身数据和配置 plugin_manager.delete_plugin_config(plugin_id) plugin_manager.delete_plugin_data(plugin_id) diff --git a/app/locales/en-US.json b/app/locales/en-US.json index fba7d9ad5..e2c96552f 100644 --- a/app/locales/en-US.json +++ b/app/locales/en-US.json @@ -839,6 +839,10 @@ "source": "创建插件分身失败:{reason}", "target": "Failed to create plugin clone: {reason}" }, + { + "source": "请先卸载该插件的分身:{instances}", + "target": "Uninstall this plugin's instances first: {instances}" + }, { "source": "{domain} 站点己存在", "target": "Site {domain} already exists" diff --git a/app/locales/zh-TW.json b/app/locales/zh-TW.json index 6f961d235..4789ea1b3 100644 --- a/app/locales/zh-TW.json +++ b/app/locales/zh-TW.json @@ -835,6 +835,10 @@ "source": "创建插件分身失败:{reason}", "target": "建立插件分身失敗:{reason}" }, + { + "source": "请先卸载该插件的分身:{instances}", + "target": "請先解除安裝該插件的分身:{instances}" + }, { "source": "{domain} 站点己存在", "target": "{domain} 站點已存在" diff --git a/app/runtime/extensions/plugin/access.py b/app/runtime/extensions/plugin/access.py index a34106ae6..9d1d7fa8b 100644 --- a/app/runtime/extensions/plugin/access.py +++ b/app/runtime/extensions/plugin/access.py @@ -45,9 +45,10 @@ class PluginAccessPolicy: and hasattr(plugin, "plugin_public_key") ): plugin_id = ( - getattr(plugin, "id", None) + getattr(plugin, "plugin_source_id", None) + or getattr(plugin, "id", None) if not isinstance(plugin, type) - else plugin.__name__ + else getattr(plugin, "plugin_source_id", None) or plugin.__name__ ) public_key = plugin.plugin_public_key if public_key and plugin_id: diff --git a/app/runtime/extensions/plugin/catalog.py b/app/runtime/extensions/plugin/catalog.py index af42fd6b4..b52542f29 100644 --- a/app/runtime/extensions/plugin/catalog.py +++ b/app/runtime/extensions/plugin/catalog.py @@ -11,7 +11,7 @@ from app.runtime.config import settings from app.runtime.extensions.plugin.contracts import supports_plugin_hook from app.runtime.extensions.plugin.storage import PluginStorage from app.runtime.extensions.plugin.system import PluginSystemServices -from app.schemas.plugin import Plugin, PluginRuntimeStatus +from app.schemas.plugin import Plugin, PluginInstance, PluginRuntimeStatus from app.schemas.types import SystemConfigKey @@ -31,6 +31,8 @@ class PluginCatalogFacade: map_plugin: Callable[..., Optional[Plugin]], auth_checker: Callable[..., bool], plugin_attr: Callable[[str, str], Any], + plugin_instance: Callable[[str], Optional[PluginInstance]], + plugin_instances: Callable[[], dict[str, PluginInstance]], runtime_status: Callable[[str], Optional[PluginRuntimeStatus]], log: Any, ) -> None: @@ -45,6 +47,8 @@ class PluginCatalogFacade: self._map_plugin = map_plugin self._auth_checker = auth_checker self._plugin_attr = plugin_attr + self._plugin_instance = plugin_instance + self._plugin_instances = plugin_instances self._runtime_status = runtime_status self._logger = log @@ -64,10 +68,11 @@ class PluginCatalogFacade: def local(self) -> list[Plugin]: """把已加载插件投影为本地插件目录 DTO。""" - installed = self._storage().read(SystemConfigKey.UserInstalledPlugins) or [] + installed = self._installed_ids() plugins: list[Plugin] = [] for plugin_id, plugin_class in self._classes().items(): plugin_instance = self._running().get(plugin_id) + instance = self._plugin_instance(plugin_id) plugin = Plugin( id=plugin_id, installed=plugin_id in installed, @@ -84,6 +89,9 @@ class PluginCatalogFacade: plugin_order=getattr(plugin_class, "plugin_order", 0), has_update=False, is_local=True, + source_plugin_id=getattr(plugin_class, "plugin_source_id", None), + is_instance=instance is not None, + instance_mode=instance.mode if instance else None, ) if not self._auth_checker(plugin=plugin, source=plugin_class): continue @@ -93,7 +101,7 @@ class PluginCatalogFacade: def installed(self) -> list[Plugin]: """按安装清单投影插件,未加载项目仍返回可观察占位卡片。""" - installed_ids = self._storage().read(SystemConfigKey.UserInstalledPlugins) or [] + installed_ids = self._installed_ids() local_by_id = { plugin.id: plugin for plugin in self.local() @@ -105,6 +113,7 @@ class PluginCatalogFacade: if plugin: result.append(plugin) continue + instance = self._plugin_instance(plugin_id) result.append(Plugin( id=plugin_id, plugin_name=plugin_id, @@ -112,6 +121,11 @@ class PluginCatalogFacade: state=False, runtime_status=self._runtime_status(plugin_id), is_local=True, + source_plugin_id=( + instance.source_plugin_id if instance else None + ), + is_instance=instance is not None, + instance_mode=instance.mode if instance else None, )) # 展示顺序由持久化安装清单保留,避免后台恢复或占位卡片出现后改变用户看到的位置。 # 前端可用用户级 PluginOrder 覆盖,plugin_order 只用于运行期插件发现顺序。 @@ -119,7 +133,7 @@ class PluginCatalogFacade: def local_version(self, plugin_id: str) -> Optional[str]: """读取指定已安装插件版本,不触发全量目录投影。""" - installed = self._storage().read(SystemConfigKey.UserInstalledPlugins) or [] + installed = self._installed_ids() if plugin_id not in installed: return None plugin_class = self._classes().get(plugin_id) @@ -156,11 +170,20 @@ class PluginCatalogFacade: if not plugin_id: return False try: - package_name = f"app.plugins.{plugin_id.lower()}" + instance = self._plugin_instance(plugin_id) + source_plugin_id = ( + instance.source_plugin_id if instance else plugin_id + ) + package_name = f"app.plugins.{source_plugin_id.lower()}" spec = importlib.util.find_spec(package_name) if spec is None or spec.origin is None: return False local_version = self._plugin_attr(plugin_id, "plugin_version") + if not local_version and instance: + local_version = self._plugin_attr( + instance.source_plugin_id, + "plugin_version", + ) if not local_version: return False if version and not compare_version(local_version, ">=", version): @@ -174,6 +197,16 @@ class PluginCatalogFacade: self._logger.debug(f"获取插件是否在本地包中存在失败,{error}") return False + def _installed_ids(self) -> list[str]: + """合并物理安装清单和虚拟实例清单并保持各自持久化顺序。""" + installed = list( + self._storage().read(SystemConfigKey.UserInstalledPlugins) or [] + ) + for instance_id in self._plugin_instances(): + if instance_id not in installed: + installed.append(instance_id) + return installed + def get_from_market( self, market: str, diff --git a/app/runtime/extensions/plugin/clone.py b/app/runtime/extensions/plugin/clone.py index 06a9d2887..cdbe0cd94 100644 --- a/app/runtime/extensions/plugin/clone.py +++ b/app/runtime/extensions/plugin/clone.py @@ -5,36 +5,38 @@ from __future__ import annotations from collections.abc import Callable from typing import Any, Optional +from app.schemas.plugin import PluginInstance, PluginRuntimeStatus + class PluginCloneService: - """协调插件包复制、安装清单、配置复制和运行态刷新。""" + """创建共享源码的虚拟插件实例并协调失败回滚。""" def __init__( self, *, plugin_class: Callable[[str], Optional[Any]], plugin_exists: Callable[[str], bool], - package_clone: Callable[..., tuple[bool, str]], - installed_plugins: Callable[[], list[str]], - save_installed_plugins: Callable[[list[str]], Any], + source_plugin_id: Callable[[str], str], + save_instance: Callable[[PluginInstance], Any], + delete_instance: Callable[[str], bool], read_config: Callable[[str], dict], save_config: Callable[[str, dict], bool], + delete_config: Callable[[str], bool], reload_plugin: Callable[[str], Any], - running_plugin: Callable[[str], Optional[Any]], - initialize_plugin: Callable[[str, dict], Any], + remove_plugin: Callable[[str], Any], log: Any, ) -> None: - """保存包、持久化和运行态端口。""" + """保存实例描述、持久化和运行态端口。""" self._plugin_class = plugin_class self._plugin_exists = plugin_exists - self._package_clone = package_clone - self._installed_plugins = installed_plugins - self._save_installed_plugins = save_installed_plugins + self._source_plugin_id = source_plugin_id + self._save_instance = save_instance + self._delete_instance = delete_instance self._read_config = read_config self._save_config = save_config + self._delete_config = delete_config self._reload_plugin = reload_plugin - self._running_plugin = running_plugin - self._initialize_plugin = initialize_plugin + self._remove_plugin = remove_plugin self._logger = log def clone( @@ -47,11 +49,10 @@ class PluginCloneService: version: Optional[str] = None, icon: Optional[str] = None, ) -> tuple[bool, str]: - """创建插件分身并保持原有默认禁用配置语义。""" + """创建虚拟分身,复制隔离配置并保持默认禁用语义。""" if not plugin_id or not suffix: return False, "插件ID和分身后缀不能为空" - original_class = self._plugin_class(plugin_id) - if original_class is None: + if self._plugin_class(plugin_id) is None: return False, f"原插件 {plugin_id} 不存在" clone_id = f"{plugin_id}{suffix.lower()}" @@ -59,38 +60,44 @@ class PluginCloneService: return False, f"分身插件 {clone_id} 已存在" try: - success, message = self._package_clone( - plugin_id=plugin_id, - clone_id=clone_id, - original_class_name=original_class.__name__, - suffix=suffix.lower(), - name=name, - description=description, - version=version, - icon=icon, + instance = PluginInstance( + instance_id=clone_id, + source_plugin_id=self._source_plugin_id(plugin_id), + plugin_name=name or None, + plugin_desc=description or None, + plugin_icon=icon or None, ) - if not success: - return False, message - - installed = list(self._installed_plugins()) - if clone_id not in installed: - installed.append(clone_id) - self._save_installed_plugins(installed) + self._save_instance(instance) original_config = self._read_config(plugin_id) if original_config: clone_config = dict(original_config) clone_config["enable"] = False clone_config["enabled"] = False - self._save_config(clone_id, clone_config) + if not self._save_config(clone_id, clone_config): + raise RuntimeError("虚拟实例配置保存失败") - self._reload_plugin(clone_id) - clone_instance = self._running_plugin(clone_id) - clone_config = self._read_config(clone_id) - if clone_instance and clone_config: - self._initialize_plugin(clone_id, clone_config) + status = self._reload_plugin(clone_id) + if status is PluginRuntimeStatus.LOAD_FAILED: + raise RuntimeError("虚拟实例加载失败") self._logger.info(f"插件分身 {clone_id} 创建成功") return True, clone_id except Exception as error: # noqa: BLE001 + self._rollback(clone_id) self._logger.error(f"创建插件分身失败:{error}") return False, f"创建插件分身失败:{error}" + + def _rollback(self, clone_id: str) -> None: + """逐项清理失败实例,单个清理错误不得阻断其余回滚。""" + rollback_steps = ( + ("运行态", self._remove_plugin), + ("实例描述", self._delete_instance), + ("配置", self._delete_config), + ) + for label, rollback in rollback_steps: + try: + rollback(clone_id) + except Exception as rollback_error: # noqa: BLE001 + self._logger.warning( + f"回滚插件分身 {clone_id} 的{label}失败:{rollback_error}" + ) diff --git a/app/runtime/extensions/plugin/loader.py b/app/runtime/extensions/plugin/loader.py index f553fa652..d7da0dce9 100644 --- a/app/runtime/extensions/plugin/loader.py +++ b/app/runtime/extensions/plugin/loader.py @@ -3,12 +3,16 @@ from __future__ import annotations import importlib +import importlib.util import sys +import threading import traceback from collections.abc import Callable from pathlib import Path from typing import Any, Optional +from app.schemas.plugin import PluginInstance + PluginImportPreparer = Callable[..., None] PluginImportScanner = Callable[..., None] @@ -18,6 +22,8 @@ PluginValidator = Callable[[Any], bool] class PluginLoader: """只负责从运行目录发现插件类,并维护对应模块缓存。""" + _instance_import_lock = threading.RLock() + def __init__( self, *, @@ -96,6 +102,165 @@ class PluginLoader: ) return plugins + def load_instance( + self, + instance: PluginInstance, + validator: PluginValidator, + ) -> list[Any]: + """在实例专属模块命名空间中重新执行源插件代码并返回适配类。""" + source_dir = self._plugins_root / instance.source_plugin_id.lower() + source_file = source_dir / "__init__.py" + if not source_file.exists(): + self._logger.warning( + f"虚拟插件实例 {instance.instance_id} 的源码不存在:{source_dir}" + ) + return [] + + module_name = f"app.plugins.{instance.instance_id.lower()}" + self.clear_modules(instance.instance_id) + try: + self._import_preparer( + plugin_id=instance.source_plugin_id.lower(), + plugin_dir=source_dir, + ) + self._import_scanner( + plugin_id=instance.source_plugin_id.lower(), + plugin_dir=source_dir, + ) + spec = importlib.util.spec_from_file_location( + module_name, + source_file, + submodule_search_locations=[str(source_dir)], + ) + if spec is None or spec.loader is None: + raise ImportError(f"无法创建模块规格:{module_name}") + module = importlib.util.module_from_spec(spec) + self._execute_instance_module( + module=module, + module_name=module_name, + source_module_name=( + f"app.plugins.{instance.source_plugin_id.lower()}" + ), + loader=spec.loader, + ) + for name, candidate in module.__dict__.items(): + if name.startswith("_") or not isinstance(candidate, type): + continue + if not validator(candidate): + continue + self._adapt_instance_class(candidate, instance) + self._logger.debug( + f"从 {instance.source_plugin_id} 加载虚拟插件实例:{instance.instance_id}" + ) + return [candidate] + except Exception as error: # noqa: BLE001 + self.clear_modules(instance.instance_id) + self._logger.error( + f"加载虚拟插件实例 {instance.instance_id} 失败:{error} - " + f"{traceback.format_exc()}" + ) + return [] + + def _execute_instance_module( + self, + *, + module: Any, + module_name: str, + source_module_name: str, + loader: Any, + ) -> None: + """执行实例模块,并把旧式自身绝对导入迁移到实例命名空间。""" + source_prefix = f"{source_module_name}." + parent_module = sys.modules.get("app.plugins") + source_attribute = source_module_name.rsplit(".", 1)[-1] + missing = object() + with self._instance_import_lock: + source_snapshot = { + name: loaded_module + for name, loaded_module in list(sys.modules.items()) + if name == source_module_name or name.startswith(source_prefix) + } + parent_snapshot = ( + getattr(parent_module, source_attribute, missing) + if parent_module + else missing + ) + for name in source_snapshot: + sys.modules.pop(name, None) + sys.modules[module_name] = module + # 兼容旧插件在包内仍写 app.plugins. 的绝对导入。 + sys.modules[source_module_name] = module + if parent_module: + setattr(parent_module, source_attribute, module) + captured: dict[str, Any] = {} + try: + loader.exec_module(module) + captured = { + name: loaded_module + for name, loaded_module in list(sys.modules.items()) + if name == source_module_name or name.startswith(source_prefix) + } + finally: + for name in list(sys.modules): + if name == source_module_name or name.startswith(source_prefix): + sys.modules.pop(name, None) + sys.modules.update(source_snapshot) + if parent_module: + if parent_snapshot is missing: + try: + delattr(parent_module, source_attribute) + except AttributeError: + pass + else: + setattr(parent_module, source_attribute, parent_snapshot) + + for source_name, loaded_module in captured.items(): + suffix = source_name[len(source_module_name):] + instance_name = f"{module_name}{suffix}" + sys.modules[instance_name] = loaded_module + self._retarget_module_identity( + loaded_module, + source_name, + instance_name, + ) + + @staticmethod + def _retarget_module_identity( + module: Any, + source_name: str, + instance_name: str, + ) -> None: + """修正被旧绝对路径加载对象的模块身份,避免事件与诊断键冲突。""" + if getattr(module, "__name__", None) == source_name: + module.__name__ = instance_name + package_name = getattr(module, "__package__", None) + if isinstance(package_name, str) and package_name.startswith(source_name): + module.__package__ = instance_name + package_name[len(source_name):] + spec = getattr(module, "__spec__", None) + if spec and getattr(spec, "name", None) == source_name: + spec.name = instance_name + for value in vars(module).values(): + if getattr(value, "__module__", None) == source_name: + try: + value.__module__ = instance_name + except (AttributeError, TypeError): + continue + + @staticmethod + def _adapt_instance_class(candidate: Any, instance: PluginInstance) -> None: + """只改运行身份与展示元数据,不改源码、限定名和联邦产物。""" + candidate.__name__ = instance.instance_id + candidate.plugin_instance_id = instance.instance_id + candidate.plugin_source_id = instance.source_plugin_id + candidate.is_clone = True + candidate.plugin_config_prefix = f"{instance.instance_id.lower()}_" + if instance.plugin_name: + candidate.plugin_name = instance.plugin_name + if instance.plugin_desc: + candidate.plugin_desc = instance.plugin_desc + if instance.plugin_icon: + candidate.plugin_icon = instance.plugin_icon + def clear_modules(self, plugin_id: Optional[str] = None) -> list[str]: """清除指定插件或全部插件的 Python 模块缓存。""" prefix = ( diff --git a/app/runtime/extensions/plugin/projection.py b/app/runtime/extensions/plugin/projection.py index 26b9a21dc..0885ca8d5 100644 --- a/app/runtime/extensions/plugin/projection.py +++ b/app/runtime/extensions/plugin/projection.py @@ -150,11 +150,15 @@ class PluginProjection: continue if not self._remote_entry_factory: raise RuntimeError("插件联邦入口生成器尚未配置") - remotes.append({ + remote = { "id": plugin_id, "url": self._remote_entry_factory(plugin_id, dist_path), "name": plugin.plugin_name, - }) + } + source_plugin_id = getattr(plugin, "plugin_source_id", None) + if source_plugin_id: + remote["source_plugin_id"] = source_plugin_id + remotes.append(remote) return remotes def auth_providers(self) -> List[Dict[str, Any]]: @@ -189,11 +193,15 @@ class PluginProjection: if not self._remote_entry_factory: raise RuntimeError("插件联邦入口生成器尚未配置") provider.setdefault("component", "AuthPage") - provider["remote"] = { + remote = { "id": plugin_id, "url": self._remote_entry_factory(plugin_id, dist_path), "name": plugin.plugin_name, } + source_plugin_id = getattr(plugin, "plugin_source_id", None) + if source_plugin_id: + remote["source_plugin_id"] = source_plugin_id + provider["remote"] = remote providers.append(provider) return providers @@ -335,4 +343,9 @@ class PluginProjection: cols=cols or {}, attrs=attrs or {}, elements=elements, + source_plugin_id=getattr(plugin, "plugin_source_id", None), + is_instance=bool(getattr(plugin, "plugin_source_id", None)), + instance_mode=( + "virtual" if getattr(plugin, "plugin_source_id", None) else None + ), ) diff --git a/app/runtime/extensions/plugin/storage.py b/app/runtime/extensions/plugin/storage.py index c91844d11..c93579520 100644 --- a/app/runtime/extensions/plugin/storage.py +++ b/app/runtime/extensions/plugin/storage.py @@ -5,6 +5,11 @@ from __future__ import annotations from collections.abc import Awaitable, Callable from typing import Any +from pydantic import ValidationError + +from app.schemas.plugin import PluginInstance +from app.schemas.types import SystemConfigKey + ConfigReader = Callable[[Any], Any] ConfigWriter = Callable[[Any, Any], Any] @@ -139,6 +144,74 @@ class PluginConfigStore: return True +class PluginInstanceStore: + """管理虚拟插件实例描述,并隔离兼容清单与新实例清单。""" + + def __init__(self, *, storage: Callable[[], "PluginStorage"]) -> None: + """保存延迟解析的持久化端口,便于启动组合根后装配。""" + self._storage = storage + + def all(self) -> dict[str, PluginInstance]: + """读取全部有效实例,忽略损坏项以免阻断存量插件启动。""" + raw_instances = self._storage().read(SystemConfigKey.PluginInstances) or {} + if isinstance(raw_instances, list): + entries = { + item.get("instance_id"): item + for item in raw_instances + if isinstance(item, dict) and item.get("instance_id") + } + elif isinstance(raw_instances, dict): + entries = raw_instances + else: + return {} + + instances: dict[str, PluginInstance] = {} + for instance_id, raw_instance in entries.items(): + try: + payload = dict(raw_instance) if isinstance(raw_instance, dict) else {} + payload.setdefault("instance_id", instance_id) + instance = PluginInstance.model_validate(payload) + instances[instance.instance_id] = instance + except (TypeError, ValidationError): + continue + return instances + + def get(self, instance_id: str) -> PluginInstance | None: + """读取指定实例描述。""" + return self.all().get(instance_id) + + def save(self, instance: PluginInstance) -> None: + """新增或更新实例描述,并以实例 ID 作为稳定持久化键。""" + instances = self.all() + instances[instance.instance_id] = instance + self._write(instances) + + def delete(self, instance_id: str) -> bool: + """删除指定实例描述,返回删除前是否存在。""" + instances = self.all() + removed = instances.pop(instance_id, None) + if removed is None: + return False + self._write(instances) + return True + + def for_source(self, source_plugin_id: str) -> list[PluginInstance]: + """按持久化顺序返回引用同一源码插件的全部实例。""" + return [ + instance + for instance in self.all().values() + if instance.source_plugin_id == source_plugin_id + ] + + def _write(self, instances: dict[str, PluginInstance]) -> None: + """把模型映射序列化为普通字典,避免存储层依赖 Pydantic。""" + payload = { + instance_id: instance.model_dump(mode="json") + for instance_id, instance in instances.items() + } + self._storage().write(SystemConfigKey.PluginInstances, payload) + + _plugin_storage = PluginStorage() diff --git a/app/runtime/extensions/plugin_manager.py b/app/runtime/extensions/plugin_manager.py index aba00117c..c989fd2c2 100644 --- a/app/runtime/extensions/plugin_manager.py +++ b/app/runtime/extensions/plugin_manager.py @@ -9,7 +9,7 @@ from watchfiles import watch from app.schemas.plugin import Plugin as _SchemaPlugin from app.schemas.plugin import PluginDashboard as _SchemaPluginDashboard -from app.schemas.plugin import PluginRuntimeStatus +from app.schemas.plugin import PluginInstance, PluginRuntimeStatus from app.foundation.crypto import RSAUtils from app.foundation.singleton import Singleton from app.foundation.version import compare_version @@ -42,7 +42,7 @@ from app.runtime.extensions.plugin.dependency import ( PluginDependencyInstallResult, PluginDependencyService, ) -from app.runtime.extensions.plugin.storage import PluginConfigStore +from app.runtime.extensions.plugin.storage import PluginConfigStore, PluginInstanceStore from app.schemas.types import EventType, SystemConfigKey LegacyDiagnosticsConfigurator = Callable[..., None] @@ -131,6 +131,9 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): # 旧属性继续引用注册表拥有的可变字典,保持插件和测试的访问身份。 self._plugins = self._plugin_registry.classes self._running_plugins = self._plugin_registry.running + self._plugin_instance_store = PluginInstanceStore( + storage=lambda: get_plugin_storage(), + ) # 配置Key self._config_key: str = "plugin.%s" self._plugin_config_store = PluginConfigStore( @@ -158,6 +161,8 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): map_plugin=lambda **kwargs: self._process_plugin_info(**kwargs), auth_checker=lambda **kwargs: self.__set_and_check_auth_level(**kwargs), plugin_attr=lambda pid, attr: self.get_plugin_attr(pid, attr), + plugin_instance=self.get_plugin_instance, + plugin_instances=self.get_plugin_instances, runtime_status=self._plugin_registry.runtime_status, log=logger, ) @@ -201,7 +206,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): self._plugin_lifecycle = PluginLifecycle( classes=self._plugins, running=self._running_plugins, - load_plugins=lambda pid, installed, check: self._load_selective_plugins( + load_plugins=lambda pid, installed, check: self._load_runtime_plugins( pid, installed, check, @@ -262,23 +267,21 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): self._plugin_clone = PluginCloneService( plugin_class=self._plugin_registry.plugin_class, plugin_exists=lambda plugin_id: self.is_plugin_exists(plugin_id), - package_clone=lambda **kwargs: get_plugin_system().package.clone(**kwargs), - installed_plugins=lambda: get_plugin_storage().read( - SystemConfigKey.UserInstalledPlugins - ) or [], - save_installed_plugins=lambda plugins: get_plugin_storage().write( - SystemConfigKey.UserInstalledPlugins, - plugins, - ), + source_plugin_id=self.get_plugin_source_id, + save_instance=self._plugin_instance_store.save, + delete_instance=self._plugin_instance_store.delete, read_config=self.get_plugin_config, save_config=lambda plugin_id, config: self.save_plugin_config( plugin_id, config, force=True, ), + delete_config=lambda plugin_id: self.delete_plugin_config( + plugin_id, + force=True, + ), reload_plugin=self.reload_plugin, - running_plugin=self._plugin_registry.instance, - initialize_plugin=self.init_plugin, + remove_plugin=self.remove_plugin, log=logger, ) # 事件总线只通过通用解析器访问运行中的插件实例。 @@ -372,6 +375,30 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): log=logger, ).load(pid, installed_plugins, check_module_func) + def _load_runtime_plugins( + self, + pid: Optional[str], + installed_plugins: List[str], + check_module_func: Callable[[Any], bool], + ) -> List[Any]: + """加载物理插件或虚拟实例,并保留旧选择性加载方法的合同。""" + if pid: + instance = self._plugin_instance_store.get(pid) + if instance: + return self._plugin_loader.load_instance(instance, check_module_func) + return self._plugin_loader.load(pid, installed_plugins, check_module_func) + + plugins = self._plugin_loader.load( + None, + installed_plugins, + check_module_func, + ) + for instance in self._plugin_instance_store.all().values(): + plugins.extend( + self._plugin_loader.load_instance(instance, check_module_func) + ) + return plugins + @property def running_plugins(self) -> Dict[str, Any]: """ @@ -435,7 +462,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): monitor_suppressed=self.is_plugin_monitor_suppressed, local_candidate=self._get_local_plugin_candidate_from_path, sync_local=self._sync_local_plugin_if_installed, - reload_plugin=self.reload_plugin, + reload_plugin=self.reload_plugin_tree, dependency_manifest_status=( get_plugin_system().dependency_manifest_status ), @@ -512,6 +539,27 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): """ return self._plugin_lifecycle.reload(plugin_id, EventType.PluginReload) + def reload_plugin_tree(self, plugin_id: str) -> PluginRuntimeStatus: + """重载源码插件,并同步刷新所有引用该源码的虚拟实例。""" + source_plugin_id = self.get_plugin_source_id(plugin_id) + status = self.reload_plugin(source_plugin_id) + for instance in self._plugin_instance_store.for_source(source_plugin_id): + self.reload_plugin(instance.instance_id) + return status + + def get_plugin_reload_targets(self, plugin_id: str) -> List[str]: + """返回源码更新后需要刷新注册信息的源插件及其实例 ID。""" + source_plugin_id = self.get_plugin_source_id(plugin_id) + return [ + source_plugin_id, + *( + instance.instance_id + for instance in self._plugin_instance_store.for_source( + source_plugin_id + ) + ), + ] + @staticmethod def _clear_plugin_modules(plugin_id: Optional[str] = None): """ @@ -551,13 +599,29 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): log=logger, ).install_missing_with_status() - @staticmethod - def classify_plugins() -> PluginDependencyClassification: - """按源码和依赖是否就绪划分已安装插件。""" - return PluginDependencyService( + def classify_plugins(self) -> PluginDependencyClassification: + """按源码依赖状态分类物理插件,并把结果映射到虚拟实例。""" + source_classification = PluginDependencyService( system=get_plugin_system, log=logger, ).classify_plugins() + ready = list(source_classification.ready) + missing_dependencies = list(source_classification.missing_dependencies) + missing_source = list(source_classification.missing_source) + source_ready = set(source_classification.ready) + source_pending = set(source_classification.missing_dependencies) + for instance in self._plugin_instance_store.all().values(): + if instance.source_plugin_id in source_ready: + ready.append(instance.instance_id) + elif instance.source_plugin_id in source_pending: + missing_dependencies.append(instance.instance_id) + else: + missing_source.append(instance.instance_id) + return PluginDependencyClassification( + ready=tuple(ready), + missing_dependencies=tuple(missing_dependencies), + missing_source=tuple(missing_source), + ) def apply_plugin_dependency_classification( self, @@ -610,6 +674,27 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): """ return self._plugin_config_store.read(pid) + def get_plugin_instances(self) -> dict[str, PluginInstance]: + """返回全部有效虚拟插件实例描述。""" + return self._plugin_instance_store.all() + + def get_plugin_instance(self, plugin_id: str) -> Optional[PluginInstance]: + """返回指定虚拟插件实例描述,物理插件返回空。""" + return self._plugin_instance_store.get(plugin_id) + + def get_plugin_source_id(self, plugin_id: str) -> str: + """解析插件运行身份对应的源码身份,普通插件保持原值。""" + instance = self._plugin_instance_store.get(plugin_id) + return instance.source_plugin_id if instance else plugin_id + + def get_plugin_source_instances(self, plugin_id: str) -> List[PluginInstance]: + """返回直接引用指定源码插件的虚拟实例。""" + return self._plugin_instance_store.for_source(plugin_id) + + def delete_plugin_instance(self, plugin_id: str) -> bool: + """删除虚拟实例描述;调用方仍负责停止实例和清理业务数据。""" + return self._plugin_instance_store.delete(plugin_id) + def save_plugin_config(self, pid: str, conf: dict, force: bool = False) -> bool: """ 保存插件配置 diff --git a/app/schemas/exports.py b/app/schemas/exports.py index 4d5f2a65a..3202eaf92 100644 --- a/app/schemas/exports.py +++ b/app/schemas/exports.py @@ -246,11 +246,13 @@ SCHEMA_EXPORTS = { 'Path': ('app.schemas.transfer', 'Path'), 'PerformanceSnapshot': ('app.schemas.monitoring', 'PerformanceSnapshot'), 'Plugin': ('app.schemas.plugin', 'Plugin'), + 'PluginCloneRequest': ('app.schemas.plugin', 'PluginCloneRequest'), 'PluginDashboard': ('app.schemas.plugin', 'PluginDashboard'), 'PluginDashboardMetaItem': ('app.schemas.plugin', 'PluginDashboardMetaItem'), 'PluginDataResetEventData': ('app.schemas.event', 'PluginDataResetEventData'), 'PluginFolderConfigData': ('app.schemas.plugin', 'PluginFolderConfigData'), 'PluginFoldersData': ('app.schemas.plugin', 'PluginFoldersData'), + 'PluginInstance': ('app.schemas.plugin', 'PluginInstance'), 'PluginMarketSyncData': ('app.schemas.system', 'PluginMarketSyncData'), 'PluginMarketSyncRequest': ('app.schemas.system', 'PluginMarketSyncRequest'), 'PluginMemoryInfo': ('app.schemas.plugin', 'PluginMemoryInfo'), @@ -423,7 +425,7 @@ SCHEMA_CONFLICTS = { 'FilterRuleGroup': ['app.schemas.rule', 'app.schemas.system'], 'JsonData': ['app.schemas.agent', 'app.schemas.common', 'app.schemas.context', 'app.schemas.dashboard', 'app.schemas.event', 'app.schemas.history', 'app.schemas.mediaserver', 'app.schemas.message', 'app.schemas.mfa', 'app.schemas.music', 'app.schemas.plugin', 'app.schemas.search', 'app.schemas.openai', 'app.schemas.servcookie', 'app.schemas.site', 'app.schemas.user', 'app.schemas.workflow', 'app.schemas.mcp'], 'List': ['app.schemas.agent', 'app.schemas.context', 'app.schemas.event', 'app.schemas.history', 'app.schemas.mediaserver', 'app.schemas.message', 'app.schemas.monitoring', 'app.schemas.plugin', 'app.schemas.openai', 'app.schemas.subscribe', 'app.schemas.transfer', 'app.schemas.workflow'], - 'Literal': ['app.schemas.agent', 'app.schemas.music', 'app.schemas.search', 'app.schemas.servcookie', 'app.schemas.mcp'], + 'Literal': ['app.schemas.agent', 'app.schemas.music', 'app.schemas.plugin', 'app.schemas.search', 'app.schemas.servcookie', 'app.schemas.mcp'], 'LocaleHelper': ['app.schemas.dashboard', 'app.schemas.response'], 'MediaInfo': ['app.schemas.context', 'app.schemas.system', 'app.schemas.transfer', 'app.schemas.workflow'], 'MediaSource': ['app.schemas.cache', 'app.schemas.context', 'app.schemas.event', 'app.schemas.history', 'app.schemas.mediaserver', 'app.schemas.music', 'app.schemas.subscribe', 'app.schemas.transfer'], @@ -448,6 +450,6 @@ SCHEMA_CONFLICTS = { 'UserPermissions': ['app.schemas.token', 'app.schemas.user'], 'dataclass': ['app.schemas.notification', 'app.schemas.system'], 'datetime': ['app.schemas.agent', 'app.schemas.monitoring'], - 'field_validator': ['app.schemas.event', 'app.schemas.message', 'app.schemas.music', 'app.schemas.response', 'app.schemas.subscribe', 'app.schemas.system'], + 'field_validator': ['app.schemas.event', 'app.schemas.message', 'app.schemas.music', 'app.schemas.plugin', 'app.schemas.response', 'app.schemas.subscribe', 'app.schemas.system'], 'model_validator': ['app.schemas.dashboard', 'app.schemas.event', 'app.schemas.mediaserver', 'app.schemas.subscribe'], } diff --git a/app/schemas/plugin.py b/app/schemas/plugin.py index 72b67ae6a..55b9f2c0a 100644 --- a/app/schemas/plugin.py +++ b/app/schemas/plugin.py @@ -1,7 +1,7 @@ from enum import Enum as _Enum -from typing import Optional, List, Dict, Union +from typing import Literal, Optional, List, Dict, Union -from pydantic import BaseModel, Field, RootModel +from pydantic import BaseModel, Field, RootModel, field_validator from app.schemas.common import JsonData @@ -17,6 +17,27 @@ class PluginRuntimeStatus(str, _Enum): LOAD_FAILED = "load_failed" +class PluginInstance(BaseModel): + """持久化一个共享源码插件的独立运行实例。""" + + instance_id: str = Field(description="运行实例 ID,也是配置、数据和路由命名空间") + source_plugin_id: str = Field(description="提供代码与前端资源的源插件 ID") + plugin_name: Optional[str] = Field(default=None, description="实例展示名称") + plugin_desc: Optional[str] = Field(default=None, description="实例展示描述") + plugin_icon: Optional[str] = Field(default=None, description="实例展示图标") + mode: Literal["virtual"] = Field(default="virtual", description="实例实现模式") + + @field_validator("instance_id", "source_plugin_id") + @classmethod + def validate_plugin_id(cls, value: str) -> str: + """限制实例标识为可安全用作 Python 类名和路由段的格式。""" + if not value or not value[0].isalpha() or not value.isalnum(): + raise ValueError("插件 ID 必须以字母开头且只能包含字母和数字") + if len(value) > 128: + raise ValueError("插件 ID 长度不能超过 128 个字符") + return value + + class Plugin(BaseModel): """ 插件信息 @@ -72,6 +93,12 @@ class Plugin(BaseModel): add_time: Optional[int] = 0 # 插件公钥 plugin_public_key: Optional[str] = None + # 共享代码与前端资源的源插件 ID;普通插件为空 + source_plugin_id: Optional[str] = None + # 是否为共享源码的虚拟实例 + is_instance: Optional[bool] = False + # 实例实现模式;存量物理分身为空 + instance_mode: Optional[str] = None class PluginRuntimeSummary(BaseModel): @@ -83,6 +110,24 @@ class PluginRuntimeSummary(BaseModel): failed_count: int = Field(description="加载失败或被策略阻止的插件数量") +class PluginCloneRequest(BaseModel): + """创建虚拟插件分身的请求参数。""" + + suffix: str = Field( + min_length=1, + max_length=20, + pattern=r"^[A-Za-z0-9]+$", + description="追加到当前插件 ID 后的 ASCII 字母或数字后缀", + ) + name: str = Field(default="", description="分身展示名称") + description: str = Field(default="", description="分身展示描述") + icon: Optional[str] = Field(default=None, description="分身展示图标") + version: Optional[str] = Field( + default=None, + description="兼容旧客户端保留,虚拟分身始终跟随源插件版本", + ) + + class PluginDashboard(Plugin): """ 插件仪表盘 @@ -164,6 +209,7 @@ class PluginRemoteInfo(BaseModel): id: str url: str name: str + source_plugin_id: Optional[str] = None class PluginReleaseItem(BaseModel): diff --git a/app/schemas/types.py b/app/schemas/types.py index 40e826f5e..8b76c9b7b 100644 --- a/app/schemas/types.py +++ b/app/schemas/types.py @@ -392,6 +392,8 @@ class SystemConfigKey(Enum): UserCustomCSS = "UserCustomCSS" # 用户已安装的插件 UserInstalledPlugins = "UserInstalledPlugins" + # 共享源码插件的虚拟运行实例 + PluginInstances = "PluginInstances" # 插件文件夹分组配置 PluginFolders = "PluginFolders" # 默认电影订阅规则 diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index e12ee58bd..77f0c5ead 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -13,8 +13,8 @@ "runtime_to_db": [], "workflow_to_db": [] }, - "edge_count": 6306, - "edge_sha256": "7ca2da728599965ef604010fdf8e8e03d2939c68f3f45eba5b93aff63c2838a4", + "edge_count": 6313, + "edge_sha256": "25292ce04e33206f713eb24cff2397931124fcb22f55ab0d78f94314581161d4", "edges": [ "app -> app.runtime", "app -> app.runtime.compat", @@ -5482,6 +5482,8 @@ "app.runtime.extensions.plugin.catalog -> app.schemas", "app.runtime.extensions.plugin.catalog -> app.schemas.plugin", "app.runtime.extensions.plugin.catalog -> app.schemas.types", + "app.runtime.extensions.plugin.clone -> app.schemas", + "app.runtime.extensions.plugin.clone -> app.schemas.plugin", "app.runtime.extensions.plugin.contracts -> app.foundation", "app.runtime.extensions.plugin.contracts -> app.foundation.reflection", "app.runtime.extensions.plugin.dependency -> app.runtime", @@ -5490,6 +5492,8 @@ "app.runtime.extensions.plugin.dependency -> app.runtime.extensions.plugin.system", "app.runtime.extensions.plugin.lifecycle -> app.schemas", "app.runtime.extensions.plugin.lifecycle -> app.schemas.plugin", + "app.runtime.extensions.plugin.loader -> app.schemas", + "app.runtime.extensions.plugin.loader -> app.schemas.plugin", "app.runtime.extensions.plugin.metadata -> app.runtime", "app.runtime.extensions.plugin.metadata -> app.runtime.extensions", "app.runtime.extensions.plugin.metadata -> app.runtime.extensions.plugin", @@ -5509,6 +5513,9 @@ "app.runtime.extensions.plugin.projection -> app.schemas.plugin", "app.runtime.extensions.plugin.registry -> app.schemas", "app.runtime.extensions.plugin.registry -> app.schemas.plugin", + "app.runtime.extensions.plugin.storage -> app.schemas", + "app.runtime.extensions.plugin.storage -> app.schemas.plugin", + "app.runtime.extensions.plugin.storage -> app.schemas.types", "app.runtime.extensions.plugin.sync -> app.runtime", "app.runtime.extensions.plugin.sync -> app.runtime.extensions", "app.runtime.extensions.plugin.sync -> app.runtime.extensions.plugin", diff --git a/tests/fixtures/architecture/official-plugin-baseline.json b/tests/fixtures/architecture/official-plugin-baseline.json index 739ed77a7..e39a80c8a 100644 --- a/tests/fixtures/architecture/official-plugin-baseline.json +++ b/tests/fixtures/architecture/official-plugin-baseline.json @@ -2695,7 +2695,7 @@ ] }, "get_api": { - "file_count": 83, + "file_count": 84, "files": [ "plugins.v2/agentresourceofficer/__init__.py", "plugins.v2/agenttokens/__init__.py", @@ -2775,6 +2775,7 @@ "plugins.v3/imdbsource/__init__.py", "plugins.v3/libraryscraper/__init__.py", "plugins.v3/maoyanrank/__init__.py", + "plugins.v3/mediainforenameenhancer/__init__.py", "plugins.v3/mediaservermsg/__init__.py", "plugins.v3/neodbsync/__init__.py", "plugins.v3/personmeta/__init__.py", @@ -2787,7 +2788,7 @@ "files": [] }, "get_command": { - "file_count": 81, + "file_count": 82, "files": [ "plugins.v2/agentresourceofficer/__init__.py", "plugins.v2/agenttokens/__init__.py", @@ -2865,6 +2866,7 @@ "plugins.v3/imdbsource/__init__.py", "plugins.v3/libraryscraper/__init__.py", "plugins.v3/maoyanrank/__init__.py", + "plugins.v3/mediainforenameenhancer/__init__.py", "plugins.v3/mediaservermsg/__init__.py", "plugins.v3/neodbsync/__init__.py", "plugins.v3/personmeta/__init__.py", @@ -2888,7 +2890,7 @@ ] }, "get_form": { - "file_count": 83, + "file_count": 84, "files": [ "plugins.v2/agentresourceofficer/__init__.py", "plugins.v2/agenttokens/__init__.py", @@ -2968,6 +2970,7 @@ "plugins.v3/imdbsource/__init__.py", "plugins.v3/libraryscraper/__init__.py", "plugins.v3/maoyanrank/__init__.py", + "plugins.v3/mediainforenameenhancer/__init__.py", "plugins.v3/mediaservermsg/__init__.py", "plugins.v3/neodbsync/__init__.py", "plugins.v3/personmeta/__init__.py", @@ -2986,7 +2989,7 @@ ] }, "get_page": { - "file_count": 83, + "file_count": 84, "files": [ "plugins.v2/agentresourceofficer/__init__.py", "plugins.v2/agenttokens/__init__.py", @@ -3066,6 +3069,7 @@ "plugins.v3/imdbsource/__init__.py", "plugins.v3/libraryscraper/__init__.py", "plugins.v3/maoyanrank/__init__.py", + "plugins.v3/mediainforenameenhancer/__init__.py", "plugins.v3/mediaservermsg/__init__.py", "plugins.v3/neodbsync/__init__.py", "plugins.v3/personmeta/__init__.py", @@ -3148,7 +3152,7 @@ "files": [] }, "get_state": { - "file_count": 85, + "file_count": 86, "files": [ "plugins.v2/agentresourceofficer/__init__.py", "plugins.v2/agenttokens/__init__.py", @@ -3230,6 +3234,7 @@ "plugins.v3/imdbsource/__init__.py", "plugins.v3/libraryscraper/__init__.py", "plugins.v3/maoyanrank/__init__.py", + "plugins.v3/mediainforenameenhancer/__init__.py", "plugins.v3/mediaservermsg/__init__.py", "plugins.v3/neodbsync/__init__.py", "plugins.v3/personmeta/__init__.py", @@ -3238,7 +3243,7 @@ ] }, "init_plugin": { - "file_count": 83, + "file_count": 84, "files": [ "plugins.v2/agentresourceofficer/__init__.py", "plugins.v2/agenttokens/__init__.py", @@ -3318,6 +3323,7 @@ "plugins.v3/imdbsource/__init__.py", "plugins.v3/libraryscraper/__init__.py", "plugins.v3/maoyanrank/__init__.py", + "plugins.v3/mediainforenameenhancer/__init__.py", "plugins.v3/mediaservermsg/__init__.py", "plugins.v3/neodbsync/__init__.py", "plugins.v3/personmeta/__init__.py", @@ -3326,7 +3332,7 @@ ] }, "stop_service": { - "file_count": 83, + "file_count": 84, "files": [ "plugins.v2/agentresourceofficer/__init__.py", "plugins.v2/agenttokens/__init__.py", @@ -3406,6 +3412,7 @@ "plugins.v3/imdbsource/__init__.py", "plugins.v3/libraryscraper/__init__.py", "plugins.v3/maoyanrank/__init__.py", + "plugins.v3/mediainforenameenhancer/__init__.py", "plugins.v3/mediaservermsg/__init__.py", "plugins.v3/neodbsync/__init__.py", "plugins.v3/personmeta/__init__.py", @@ -4293,7 +4300,7 @@ ] }, "app.plugins": { - "file_count": 83, + "file_count": 84, "files": [ "plugins.v2/agentresourceofficer/__init__.py", "plugins.v2/agenttokens/__init__.py", @@ -4373,6 +4380,7 @@ "plugins.v3/imdbsource/__init__.py", "plugins.v3/libraryscraper/__init__.py", "plugins.v3/maoyanrank/__init__.py", + "plugins.v3/mediainforenameenhancer/__init__.py", "plugins.v3/mediaservermsg/__init__.py", "plugins.v3/neodbsync/__init__.py", "plugins.v3/personmeta/__init__.py", @@ -4541,7 +4549,7 @@ ] }, "app.schemas.types": { - "file_count": 66, + "file_count": 67, "files": [ "plugins.v2/agentresourceofficer/__init__.py", "plugins.v2/agenttokens/__init__.py", @@ -4604,6 +4612,7 @@ "plugins.v3/historytov2/__init__.py", "plugins.v3/imdbsource/__init__.py", "plugins.v3/imdbsource/imdbhelper.py", + "plugins.v3/mediainforenameenhancer/__init__.py", "plugins.v3/mediaservermsg/__init__.py", "plugins.v3/neodbsync/__init__.py", "plugins.v3/personmeta/__init__.py", @@ -4655,7 +4664,7 @@ ] }, "app.sdk.events": { - "file_count": 11, + "file_count": 12, "files": [ "plugins.v3/autoclean/__init__.py", "plugins.v3/bangumicoll/__init__.py", @@ -4664,6 +4673,7 @@ "plugins.v3/downloadsitetag/__init__.py", "plugins.v3/episodegroupmeta/__init__.py", "plugins.v3/imdbsource/__init__.py", + "plugins.v3/mediainforenameenhancer/__init__.py", "plugins.v3/mediaservermsg/__init__.py", "plugins.v3/neodbsync/__init__.py", "plugins.v3/personmeta/__init__.py", @@ -4671,7 +4681,7 @@ ] }, "app.sdk.logging": { - "file_count": 23, + "file_count": 24, "files": [ "plugins.v3/agentresourceofficer/__init__.py", "plugins.v3/agentresourceofficer/feishu_channel.py", @@ -4691,6 +4701,7 @@ "plugins.v3/imdbsource/officialapi.py", "plugins.v3/libraryscraper/__init__.py", "plugins.v3/maoyanrank/__init__.py", + "plugins.v3/mediainforenameenhancer/__init__.py", "plugins.v3/mediaservermsg/__init__.py", "plugins.v3/neodbsync/__init__.py", "plugins.v3/personmeta/__init__.py", @@ -4936,9 +4947,9 @@ } }, "provenance": { - "head": "217c8d25ffe6ff0b3f6352c4278fd6896def442e", - "python_file_count": 237, - "source_sha256": "c3796565ff3fd49a8ff3f1e1373d9afab44fd7b98371e44f662c32739fe53d15" + "head": "fd0363711dbba96e31a27ad56b2e87c3524096a4", + "python_file_count": 238, + "source_sha256": "f07ff2e8c95080cbbf6d229376464e61dc74061c1a8d371b5ea2cf00148413de" }, "schema_version": 3, "scope": { diff --git a/tests/test_agent_plugin_tools.py b/tests/test_agent_plugin_tools.py index 595c66c53..9f94f4747 100644 --- a/tests/test_agent_plugin_tools.py +++ b/tests/test_agent_plugin_tools.py @@ -334,7 +334,7 @@ def test_install_plugin_runtime_reloads_in_threadpool() -> None: ) assert len(calls) == 2 assert calls[0][0] == "plugin" - assert calls[0][1] == plugin_manager.reload_plugin + assert calls[0][1] == plugin_manager.reload_plugin_tree assert calls[0][2] == ("DemoPlugin",) assert calls[0][3] == {} assert calls[1][0] == "plugin" diff --git a/tests/test_plugin_catalog_runtime.py b/tests/test_plugin_catalog_runtime.py index 99fa7bbad..329ea4f48 100644 --- a/tests/test_plugin_catalog_runtime.py +++ b/tests/test_plugin_catalog_runtime.py @@ -37,6 +37,8 @@ def test_installed_catalog_keeps_plugins_that_are_not_loaded(): map_plugin=lambda **_kwargs: None, auth_checker=lambda **_kwargs: True, plugin_attr=lambda _plugin_id, _attr: None, + plugin_instance=lambda _plugin_id: None, + plugin_instances=lambda: {}, runtime_status=statuses.get, log=SimpleNamespace(error=lambda *_args: None, info=lambda *_args: None), ) diff --git a/tests/test_plugin_endpoint.py b/tests/test_plugin_endpoint.py index fdb879aef..0bf499db3 100644 --- a/tests/test_plugin_endpoint.py +++ b/tests/test_plugin_endpoint.py @@ -8,13 +8,15 @@ from app.api.endpoints.plugin import plugin_releases from app.api.endpoints.plugin import reset_plugin from app.api.endpoints.plugin import reload_plugin from app.api.endpoints.plugin import runtime_status +from app.api.endpoints.plugin import plugin_static_file +from app.api.endpoints.plugin import uninstall_plugin from app.api.endpoints.system import sync_plugin_market_from_wiki from app.application.plugin.config import PluginConfigCommand from app.runtime.config import settings from app.runtime.extensions.plugin_manager import PluginManager from app.schemas.event import PluginDataResetEventData -from app.schemas.plugin import PluginRuntimeStatus -from app.schemas.types import ChainEventType +from app.schemas.plugin import PluginInstance, PluginRuntimeStatus +from app.schemas.types import ChainEventType, SystemConfigKey from app.foundation.singleton import Singleton @@ -518,11 +520,72 @@ def test_delete_plugin_config_can_force_delete_after_plugin_is_stopped(): storage.delete.return_value = True with patch("app.runtime.extensions.plugin_manager.get_plugin_storage", return_value=storage): assert manager.delete_plugin_config("DemoPlugin", force=True) is True - storage.delete.assert_called_once_with("plugin.DemoPlugin") Singleton._instances.pop((PluginManager, (), frozenset()), None) +def test_virtual_instance_static_file_reads_from_source_directory(tmp_path, monkeypatch): + """实例 URL 保持独立,但静态内容直接读取共享的源插件目录。""" + source_file = tmp_path / "app/plugins/demoplugin/dist/remoteEntry.js" + source_file.parent.mkdir(parents=True) + source_file.write_text("export default 'shared'", encoding="utf-8") + plugin_manager = MagicMock() + plugin_manager.get_plugin_source_id.return_value = "DemoPlugin" + monkeypatch.setattr(plugin_endpoint, "PluginManager", lambda: plugin_manager) + monkeypatch.setattr( + plugin_endpoint, + "settings", + MagicMock(ROOT_PATH=tmp_path), + ) + + response = asyncio.run( + plugin_static_file("DemoPluginwork", "dist/remoteEntry.js", None) + ) + + async def read_body() -> bytes: + """读取流式响应的全部测试内容。""" + return b"".join([chunk async for chunk in response.body_iterator]) + + assert asyncio.run(read_body()) == b"export default 'shared'" + assert response.media_type == "application/javascript" + plugin_manager.get_plugin_source_id.assert_called_once_with("DemoPluginwork") + + +def test_uninstall_virtual_instance_never_removes_source_package(monkeypatch): + """卸载虚拟实例只清理实例状态,不触碰源插件安装清单或目录。""" + plugin_manager = MagicMock() + plugin_manager.get_plugin_instance.return_value = PluginInstance( + instance_id="DemoPluginwork", + source_plugin_id="DemoPlugin", + ) + plugin_manager.get_plugin_source_instances.return_value = [] + config = MagicMock() + config.get.return_value = ["DemoPlugin"] + monkeypatch.setattr(plugin_endpoint, "PluginManager", lambda: plugin_manager) + monkeypatch.setattr(plugin_endpoint, "get_configured_system_config", lambda: config) + monkeypatch.setattr(plugin_endpoint, "remove_plugin_api", MagicMock()) + monkeypatch.setattr(plugin_endpoint, "remove_plugin_job", MagicMock()) + monkeypatch.setattr(plugin_endpoint, "remove_plugin_from_folders", MagicMock()) + + result = uninstall_plugin("DemoPluginwork", None) + + assert result.success is True + config.set.assert_called_once_with( + SystemConfigKey.UserInstalledPlugins, + ["DemoPlugin"], + ) + plugin_manager.delete_plugin_config.assert_called_once_with( + "DemoPluginwork", + force=True, + ) + plugin_manager.delete_plugin_data.assert_called_once_with( + "DemoPluginwork", + force=True, + ) + plugin_manager.delete_plugin_instance.assert_called_once_with("DemoPluginwork") + plugin_manager.remove_plugin.assert_called_once_with("DemoPluginwork") + + def test_delete_plugin_data_can_force_delete_after_plugin_is_stopped(): """ 重置入口会先停止插件;插件数据删除不能依赖运行态注册仍存在。 diff --git a/tests/test_plugin_projection.py b/tests/test_plugin_projection.py index 80ea6d3b6..14af1eefd 100644 --- a/tests/test_plugin_projection.py +++ b/tests/test_plugin_projection.py @@ -172,6 +172,27 @@ def test_projection_builds_federation_and_auth_provider_entries(): }] +def test_projection_exposes_source_identity_for_virtual_frontend_instance(): + """虚拟实例的联邦入口保留实例 URL,并补充共享源码身份。""" + plugin = _Plugin( + plugin_source_id="Demo", + get_render_mode=lambda: ("vue", "dist/assets"), + get_auth_providers=lambda: [{"id": "demo-login"}], + ) + projection = PluginProjection( + {"DemoWork": plugin}, + remote_entry_factory=lambda plugin_id, path: f"/{plugin_id}/{path}", + ) + + assert projection.remotes()[0] == { + "id": "DemoWork", + "url": "/DemoWork/dist/assets", + "name": "测试插件", + "source_plugin_id": "Demo", + } + assert projection.auth_providers()[0]["remote"]["source_plugin_id"] == "Demo" + + def test_projection_normalizes_sidebar_and_dashboard_metadata(): """侧栏和仪表板元数据在投影层完成校验、排序与兼容默认值。""" plugin = _Plugin( diff --git a/tests/test_plugin_virtual_instances.py b/tests/test_plugin_virtual_instances.py new file mode 100644 index 000000000..5ae5f6299 --- /dev/null +++ b/tests/test_plugin_virtual_instances.py @@ -0,0 +1,202 @@ +"""虚拟插件实例的持久化、加载和创建行为测试。""" + +import sys +from types import ModuleType, SimpleNamespace + +from app.runtime.extensions.plugin.clone import PluginCloneService +from app.runtime.extensions.plugin.loader import PluginLoader +from app.runtime.extensions.plugin.storage import PluginInstanceStore, PluginStorage +from app.schemas.plugin import PluginInstance, PluginRuntimeStatus +from app.schemas.types import SystemConfigKey + + +def _logger() -> SimpleNamespace: + """提供加载器测试所需的最小日志对象。""" + return SimpleNamespace( + debug=lambda *_args: None, + info=lambda *_args: None, + warning=lambda *_args: None, + error=lambda *_args: None, + ) + + +def test_instance_store_keeps_virtual_instances_out_of_installed_list(): + """虚拟实例使用独立配置键,不改写存量物理插件安装清单。""" + values = {SystemConfigKey.UserInstalledPlugins: ["DemoPlugin"]} + storage = PluginStorage( + read=values.get, + write=lambda key, value: values.__setitem__(key, value), + ) + store = PluginInstanceStore(storage=lambda: storage) + + instance = PluginInstance( + instance_id="DemoPluginWork", + source_plugin_id="DemoPlugin", + plugin_name="工作实例", + ) + store.save(instance) + + assert store.get("DemoPluginWork") == instance + assert store.for_source("DemoPlugin") == [instance] + assert values[SystemConfigKey.UserInstalledPlugins] == ["DemoPlugin"] + assert store.delete("DemoPluginWork") is True + assert store.all() == {} + + +def test_loader_executes_each_instance_in_an_isolated_module_namespace( + tmp_path, + monkeypatch, +): + """两个实例共享磁盘源码,但模块全局状态、类身份和相对导入互相隔离。""" + source_dir = tmp_path / "demoplugin" + source_dir.mkdir() + (source_dir / "state.py").write_text("state = []\n", encoding="utf-8") + (source_dir / "__init__.py").write_text( + "from app.plugins.demoplugin.state import state\n" + "class DemoPlugin:\n" + " plugin_name = 'Demo'\n" + " plugin_desc = 'Source'\n" + " plugin_icon = 'source.svg'\n" + " plugin_config_prefix = 'demo_'\n" + " def init_plugin(self, _config):\n" + " state.append(self.__class__.__name__)\n", + encoding="utf-8", + ) + loader = PluginLoader( + plugins_root=tmp_path, + import_preparer=lambda **_kwargs: None, + import_scanner=lambda **_kwargs: None, + log=_logger(), + ) + validator = lambda candidate: hasattr(candidate, "init_plugin") + import app.plugins as plugin_package + + source_module = ModuleType("app.plugins.demoplugin") + source_module.__path__ = [str(source_dir)] + monkeypatch.setitem(sys.modules, "app.plugins.demoplugin", source_module) + monkeypatch.setattr(plugin_package, "demoplugin", source_module, raising=False) + + work_class = loader.load_instance( + PluginInstance( + instance_id="DemoPluginWork", + source_plugin_id="DemoPlugin", + plugin_name="工作实例", + ), + validator, + )[0] + home_class = loader.load_instance( + PluginInstance( + instance_id="DemoPluginHome", + source_plugin_id="DemoPlugin", + plugin_desc="家庭实例", + ), + validator, + )[0] + work_class().init_plugin({}) + home_class().init_plugin({}) + + assert work_class.__name__ == "DemoPluginWork" + assert home_class.__name__ == "DemoPluginHome" + assert work_class.__qualname__ == "DemoPlugin" + assert work_class.__module__ == "app.plugins.demopluginwork" + assert home_class.__module__ == "app.plugins.demopluginhome" + assert work_class.plugin_source_id == "DemoPlugin" + assert work_class.plugin_name == "工作实例" + assert work_class.plugin_config_prefix == "demopluginwork_" + assert work_class.__dict__["init_plugin"].__globals__["state"] == [ + "DemoPluginWork" + ] + assert home_class.__dict__["init_plugin"].__globals__["state"] == [ + "DemoPluginHome" + ] + assert sys.modules["app.plugins.demoplugin"] is source_module + assert plugin_package.demoplugin is source_module + + +def test_clone_service_persists_descriptor_without_copying_source_package(): + """创建分身只写实例描述和隔离配置,并始终跟随源插件版本。""" + instances: dict[str, PluginInstance] = {} + configs = {"DemoPlugin": {"enable": True, "token": "secret"}} + reloaded: list[str] = [] + removed: list[str] = [] + + class DemoPlugin: + """提供创建用例需要的最小源插件类。""" + + service = PluginCloneService( + plugin_class=lambda plugin_id: DemoPlugin if plugin_id == "DemoPlugin" else None, + plugin_exists=lambda plugin_id: plugin_id in instances, + source_plugin_id=lambda plugin_id: plugin_id, + save_instance=lambda instance: instances.__setitem__( + instance.instance_id, + instance, + ), + delete_instance=lambda plugin_id: instances.pop(plugin_id, None) is not None, + read_config=lambda plugin_id: configs.get(plugin_id, {}), + save_config=lambda plugin_id, config: not configs.__setitem__(plugin_id, config), + delete_config=lambda plugin_id: configs.pop(plugin_id, None) is not None, + reload_plugin=lambda plugin_id: ( + reloaded.append(plugin_id) or PluginRuntimeStatus.ACTIVE + ), + remove_plugin=removed.append, + log=_logger(), + ) + + success, clone_id = service.clone( + plugin_id="DemoPlugin", + suffix="Work", + name="工作实例", + description="独立配置", + version="9.9.9", + ) + + assert success is True + assert clone_id == "DemoPluginwork" + assert instances[clone_id].source_plugin_id == "DemoPlugin" + assert configs[clone_id] == { + "enable": False, + "enabled": False, + "token": "secret", + } + assert reloaded == [clone_id] + assert removed == [] + + +def test_clone_service_rolls_back_descriptor_and_config_after_load_failure(): + """实例首次加载失败时不留下不可见描述或孤立配置。""" + instances: dict[str, PluginInstance] = {} + configs = {"DemoPlugin": {"enabled": True}} + removed: list[str] = [] + + class DemoPlugin: + """提供失败回滚用例需要的最小源插件类。""" + + service = PluginCloneService( + plugin_class=lambda _plugin_id: DemoPlugin, + plugin_exists=lambda plugin_id: plugin_id in instances, + source_plugin_id=lambda plugin_id: plugin_id, + save_instance=lambda instance: instances.__setitem__( + instance.instance_id, + instance, + ), + delete_instance=lambda plugin_id: instances.pop(plugin_id, None) is not None, + read_config=lambda plugin_id: configs.get(plugin_id, {}), + save_config=lambda plugin_id, config: not configs.__setitem__(plugin_id, config), + delete_config=lambda plugin_id: configs.pop(plugin_id, None) is not None, + reload_plugin=lambda _plugin_id: PluginRuntimeStatus.LOAD_FAILED, + remove_plugin=removed.append, + log=_logger(), + ) + + success, message = service.clone( + plugin_id="DemoPlugin", + suffix="Broken", + name="失败实例", + description="", + ) + + assert success is False + assert "加载失败" in message + assert instances == {} + assert configs == {"DemoPlugin": {"enabled": True}} + assert removed == ["DemoPluginbroken"]