feat(plugin): add virtual plugin instances

This commit is contained in:
jxxghp
2026-08-22 09:40:38 +08:00
parent 4061868d25
commit 7b9499fc5b
21 changed files with 891 additions and 114 deletions
+3 -2
View File
@@ -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:
+38 -5
View File
@@ -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,
+44 -37
View File
@@ -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}"
)
+165
View File
@@ -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.<source> 的绝对导入。
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 = (
+16 -3
View File
@@ -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
),
)
+73
View File
@@ -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()