mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 15:38:19 +08:00
refactor: 收口 V3 分层架构与插件兼容边界
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
"""插件可见性和特殊密钥权限策略。"""
|
||||
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
class PluginAccessPolicy:
|
||||
"""根据站点认证等级和插件公钥判断插件是否可投影。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
auth_level: Callable[[], int],
|
||||
verify_keys: Callable[..., bool],
|
||||
log: Any,
|
||||
) -> None:
|
||||
"""保存认证等级、密钥校验和日志端口。"""
|
||||
self._auth_level = auth_level
|
||||
self._verify_keys = verify_keys
|
||||
self._logger = log
|
||||
|
||||
@staticmethod
|
||||
def private_key(plugin_id: str) -> Optional[str]:
|
||||
"""按插件 ID 读取特殊密钥认证使用的环境变量。"""
|
||||
try:
|
||||
return os.environ.get(f"PLUGIN_{plugin_id.upper()}_PRIVATE_KEY")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def check(self, plugin: Any, source: Optional[Any] = None) -> bool:
|
||||
"""设置插件认证等级并判断当前环境是否允许该插件。"""
|
||||
if source:
|
||||
if isinstance(source, dict) and "level" in source:
|
||||
plugin.auth_level = source.get("level")
|
||||
elif hasattr(source, "auth_level"):
|
||||
plugin.auth_level = source.auth_level
|
||||
elif not hasattr(plugin, "auth_level"):
|
||||
return True
|
||||
|
||||
level = self._auth_level()
|
||||
if (
|
||||
level > 1
|
||||
and plugin.auth_level == 99
|
||||
and hasattr(plugin, "plugin_public_key")
|
||||
):
|
||||
plugin_id = (
|
||||
getattr(plugin, "id", None)
|
||||
if not isinstance(plugin, type)
|
||||
else plugin.__name__
|
||||
)
|
||||
public_key = plugin.plugin_public_key
|
||||
if public_key and plugin_id:
|
||||
private_key = self.private_key(plugin_id)
|
||||
return self._verify_keys(
|
||||
public_key=public_key,
|
||||
private_key=private_key,
|
||||
)
|
||||
return level >= plugin.auth_level
|
||||
@@ -0,0 +1,204 @@
|
||||
"""插件本地运行态和远程市场目录投影。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.foundation.version import compare_version
|
||||
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
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
|
||||
class PluginCatalogFacade:
|
||||
"""把插件目录应用服务与运行态注册表连接起来。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
classes: Callable[[], Mapping[str, Any]],
|
||||
running: Callable[[], Mapping[str, Any]],
|
||||
storage: Callable[[], PluginStorage],
|
||||
system: Callable[[], PluginSystemServices],
|
||||
market_catalog: Callable[[], Any],
|
||||
market_loader: Callable[..., Any],
|
||||
async_market_loader: Callable[..., Any],
|
||||
map_plugin: Callable[..., Optional[Plugin]],
|
||||
auth_checker: Callable[..., bool],
|
||||
plugin_attr: Callable[[str, str], Any],
|
||||
log: Any,
|
||||
) -> None:
|
||||
"""保存注册表、目录服务和插件外部系统端口。"""
|
||||
self._classes = classes
|
||||
self._running = running
|
||||
self._storage = storage
|
||||
self._system = system
|
||||
self._market_catalog = market_catalog
|
||||
self._market_loader = market_loader
|
||||
self._async_market_loader = async_market_loader
|
||||
self._map_plugin = map_plugin
|
||||
self._auth_checker = auth_checker
|
||||
self._plugin_attr = plugin_attr
|
||||
self._logger = log
|
||||
|
||||
def online(self, force: bool = False) -> list[Plugin]:
|
||||
"""读取所有兼容代际的在线插件目录。"""
|
||||
if not settings.PLUGIN_MARKET:
|
||||
return []
|
||||
markets = [item for item in settings.PLUGIN_MARKET.split(",") if item]
|
||||
result = self._market_catalog().collect(
|
||||
markets=markets,
|
||||
compatible_flags=self._system().compatible_flags(settings.VERSION_FLAG),
|
||||
force=force,
|
||||
loader=self._market_loader,
|
||||
)
|
||||
self._logger.info(f"获取到 {len(result)} 个线上插件")
|
||||
return result
|
||||
|
||||
def local(self) -> list[Plugin]:
|
||||
"""把已加载插件投影为本地插件目录 DTO。"""
|
||||
installed = self._storage().read(SystemConfigKey.UserInstalledPlugins) or []
|
||||
plugins: list[Plugin] = []
|
||||
for plugin_id, plugin_class in self._classes().items():
|
||||
plugin_instance = self._running().get(plugin_id)
|
||||
plugin = Plugin(
|
||||
id=plugin_id,
|
||||
installed=plugin_id in installed,
|
||||
state=self._safe_state(plugin_id, plugin_instance),
|
||||
has_page=supports_plugin_hook(plugin_class, "get_page"),
|
||||
plugin_public_key=getattr(plugin_class, "plugin_public_key", None),
|
||||
plugin_name=getattr(plugin_class, "plugin_name", None),
|
||||
plugin_desc=getattr(plugin_class, "plugin_desc", None),
|
||||
plugin_version=getattr(plugin_class, "plugin_version", None),
|
||||
plugin_icon=getattr(plugin_class, "plugin_icon", None),
|
||||
plugin_author=getattr(plugin_class, "plugin_author", None),
|
||||
author_url=getattr(plugin_class, "author_url", None),
|
||||
plugin_order=getattr(plugin_class, "plugin_order", 0),
|
||||
has_update=False,
|
||||
is_local=True,
|
||||
)
|
||||
if not self._auth_checker(plugin=plugin, source=plugin_class):
|
||||
continue
|
||||
plugins.append(plugin)
|
||||
plugins.sort(key=lambda item: getattr(item, "plugin_order", 0))
|
||||
return plugins
|
||||
|
||||
def local_version(self, plugin_id: str) -> Optional[str]:
|
||||
"""读取指定已安装插件版本,不触发全量目录投影。"""
|
||||
installed = self._storage().read(SystemConfigKey.UserInstalledPlugins) or []
|
||||
if plugin_id not in installed:
|
||||
return None
|
||||
plugin_class = self._classes().get(plugin_id)
|
||||
return getattr(plugin_class, "plugin_version", None)
|
||||
|
||||
def local_repository(self) -> list[Plugin]:
|
||||
"""读取本地插件仓候选并映射为目录 DTO。"""
|
||||
installed = self._storage().read(SystemConfigKey.UserInstalledPlugins) or []
|
||||
candidates = self._system().local_candidates()
|
||||
plugins: list[Plugin] = []
|
||||
for plugin_id, info in candidates.items():
|
||||
package_version = info.get("package_version")
|
||||
plugin = self._map_plugin(
|
||||
pid=plugin_id,
|
||||
plugin_info=info,
|
||||
market=self._system().local_repo_url(
|
||||
plugin_id,
|
||||
info.get("repo_path"),
|
||||
package_version,
|
||||
),
|
||||
installed_apps=installed,
|
||||
add_time=0,
|
||||
package_version=package_version,
|
||||
)
|
||||
if plugin:
|
||||
plugin.is_local = True
|
||||
plugins.append(plugin)
|
||||
plugins.sort(key=lambda item: getattr(item, "plugin_order", 0))
|
||||
self._logger.info(f"获取到 {len(plugins)} 个本地插件")
|
||||
return plugins
|
||||
|
||||
def exists(self, plugin_id: str, version: Optional[str] = None) -> bool:
|
||||
"""判断插件包和已加载版本是否满足安装前置条件。"""
|
||||
if not plugin_id:
|
||||
return False
|
||||
try:
|
||||
package_name = f"app.plugins.{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:
|
||||
return False
|
||||
if version and not compare_version(local_version, ">=", version):
|
||||
self._logger.warning(
|
||||
f"Plugin {plugin_id} version: {local_version} "
|
||||
f"(older than version: {version})"
|
||||
)
|
||||
return False
|
||||
return True
|
||||
except Exception as error:
|
||||
self._logger.debug(f"获取插件是否在本地包中存在失败,{error}")
|
||||
return False
|
||||
|
||||
def get_from_market(
|
||||
self,
|
||||
market: str,
|
||||
package_version: Optional[str] = None,
|
||||
force: bool = False,
|
||||
) -> list[Plugin]:
|
||||
"""读取并映射指定插件市场。"""
|
||||
return self._market_catalog().load(market, package_version, force)
|
||||
|
||||
async def async_online(
|
||||
self,
|
||||
force: bool = False,
|
||||
progress_callback: Optional[Callable[..., None]] = None,
|
||||
) -> list[Plugin]:
|
||||
"""异步读取所有兼容代际的在线插件目录。"""
|
||||
if not settings.PLUGIN_MARKET:
|
||||
if progress_callback:
|
||||
progress_callback(value=100, text="未配置插件市场,跳过刷新")
|
||||
return []
|
||||
markets = [item for item in settings.PLUGIN_MARKET.split(",") if item]
|
||||
result = await self._market_catalog().async_collect(
|
||||
markets=markets,
|
||||
compatible_flags=self._system().compatible_flags(settings.VERSION_FLAG),
|
||||
force=force,
|
||||
loader=self._async_market_loader,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
self._logger.info(f"获取到 {len(result)} 个线上插件")
|
||||
return result
|
||||
|
||||
async def async_get_from_market(
|
||||
self,
|
||||
market: str,
|
||||
package_version: Optional[str] = None,
|
||||
force: bool = False,
|
||||
) -> list[Plugin]:
|
||||
"""异步读取并映射指定插件市场。"""
|
||||
return await self._market_catalog().async_load(
|
||||
market,
|
||||
package_version,
|
||||
force,
|
||||
)
|
||||
|
||||
def merge(self, higher: list[Plugin], base: list[Plugin]) -> list[Plugin]:
|
||||
"""合并不同代际插件目录并保留市场优先级。"""
|
||||
markets = [item for item in settings.PLUGIN_MARKET.split(",") if item]
|
||||
return self._market_catalog().merge(higher, base, markets)
|
||||
|
||||
def _safe_state(self, plugin_id: str, plugin: Any) -> bool:
|
||||
"""读取插件状态,单个插件异常不阻断整个本地目录。"""
|
||||
if not plugin or not hasattr(plugin, "get_state"):
|
||||
return False
|
||||
try:
|
||||
return bool(plugin.get_state())
|
||||
except Exception as error:
|
||||
self._logger.error(f"获取插件 {plugin_id} 状态出错:{error}")
|
||||
return False
|
||||
@@ -0,0 +1,96 @@
|
||||
"""插件分身创建运行时用例。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
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],
|
||||
read_config: Callable[[str], dict],
|
||||
save_config: Callable[[str, dict], bool],
|
||||
reload_plugin: Callable[[str], Any],
|
||||
running_plugin: Callable[[str], Optional[Any]],
|
||||
initialize_plugin: Callable[[str, dict], 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._read_config = read_config
|
||||
self._save_config = save_config
|
||||
self._reload_plugin = reload_plugin
|
||||
self._running_plugin = running_plugin
|
||||
self._initialize_plugin = initialize_plugin
|
||||
self._logger = log
|
||||
|
||||
def clone(
|
||||
self,
|
||||
*,
|
||||
plugin_id: str,
|
||||
suffix: str,
|
||||
name: str,
|
||||
description: str,
|
||||
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:
|
||||
return False, f"原插件 {plugin_id} 不存在"
|
||||
|
||||
clone_id = f"{plugin_id}{suffix.lower()}"
|
||||
if self._plugin_exists(clone_id):
|
||||
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,
|
||||
)
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
self._logger.info(f"插件分身 {clone_id} 创建成功")
|
||||
return True, clone_id
|
||||
except Exception as error: # noqa: BLE001
|
||||
self._logger.error(f"创建插件分身失败:{error}")
|
||||
return False, f"创建插件分身失败:{error}"
|
||||
@@ -6,6 +6,18 @@ from typing import Any
|
||||
from app.foundation.reflection import ObjectUtils
|
||||
|
||||
|
||||
class PluginRuntimeError(Exception):
|
||||
"""插件运行时调用失败的基础异常。"""
|
||||
|
||||
|
||||
class PluginNotFoundError(PluginRuntimeError):
|
||||
"""目标插件未加载。"""
|
||||
|
||||
|
||||
class PluginDashboardError(PluginRuntimeError):
|
||||
"""插件仪表板返回值不符合宿主契约。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PluginHookContract:
|
||||
"""描述宿主识别一个插件钩子时必须保持的运行语义。"""
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""插件依赖检查与安装运行时服务。"""
|
||||
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from app.runtime.extensions.plugin.system import PluginSystemServices
|
||||
|
||||
|
||||
class PluginDependencyService:
|
||||
"""执行缺失插件依赖的发现和安装,不参与插件生命周期。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
system: Callable[[], PluginSystemServices],
|
||||
log: Any,
|
||||
) -> None:
|
||||
"""保存插件系统适配器和日志端口。"""
|
||||
self._system = system
|
||||
self._logger = log
|
||||
|
||||
def install_missing(self) -> list[str]:
|
||||
"""安装当前环境缺失的插件依赖并返回检查到的依赖名。"""
|
||||
installer = self._system().dependency
|
||||
missing = installer.find_missing()
|
||||
if not missing:
|
||||
return missing
|
||||
self._logger.debug(f"检测到缺失的依赖项: {missing}")
|
||||
self._logger.info(f"开始安装缺失的依赖项,共 {len(missing)} 个...")
|
||||
started = time.time()
|
||||
success, _message = installer.install(missing)
|
||||
elapsed = time.time() - started
|
||||
if success:
|
||||
self._logger.info(
|
||||
f"已完成 {len(missing)} 个依赖项安装,总耗时:{elapsed:.2f} 秒"
|
||||
)
|
||||
else:
|
||||
self._logger.warning(
|
||||
f"存在缺失依赖项安装失败,请尝试手动安装,总耗时:{elapsed:.2f} 秒"
|
||||
)
|
||||
return missing
|
||||
@@ -0,0 +1,133 @@
|
||||
"""插件实例生命周期应用能力。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
class PluginLifecycle:
|
||||
"""管理插件发现、初始化、启停和热重载,不持有市场或 HTTP 路由职责。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
classes: dict[str, Any],
|
||||
running: dict[str, Any],
|
||||
load_plugins: Callable[[Optional[str], list[str], Callable[[Any], bool]], list[Any]],
|
||||
installed_plugins: Callable[[], list[str]],
|
||||
plugin_config: Callable[[str], dict],
|
||||
auth_checker: Callable[[Any], bool],
|
||||
clear_modules: Callable[[Optional[str]], Any],
|
||||
clear_tools: Callable[[], None],
|
||||
enable_events: Callable[[Any], None],
|
||||
disable_events: Callable[[Any], None],
|
||||
log: Any,
|
||||
event_sender: Callable[..., Any],
|
||||
) -> None:
|
||||
"""保存注册表、加载器和事件端口。"""
|
||||
self._classes = classes
|
||||
self._running = running
|
||||
self._load_plugins = load_plugins
|
||||
self._installed_plugins = installed_plugins
|
||||
self._plugin_config = plugin_config
|
||||
self._auth_checker = auth_checker
|
||||
self._clear_modules = clear_modules
|
||||
self._clear_tools = clear_tools
|
||||
self._enable_events = enable_events
|
||||
self._disable_events = disable_events
|
||||
self._logger = log
|
||||
self._event_sender = event_sender
|
||||
|
||||
def start(self, plugin_id: Optional[str] = None) -> None:
|
||||
"""加载并初始化指定插件或全部已安装插件。"""
|
||||
installed_plugins = self._installed_plugins()
|
||||
|
||||
def check_module(module: Any) -> bool:
|
||||
"""判断模块是否具备宿主插件最小生命周期钩子。"""
|
||||
return hasattr(module, "init_plugin") and hasattr(module, "plugin_name")
|
||||
|
||||
plugins = self._load_plugins(plugin_id, installed_plugins, check_module)
|
||||
plugins.sort(key=lambda item: getattr(item, "plugin_order", 0))
|
||||
for plugin in plugins:
|
||||
current_id = plugin.__name__
|
||||
if plugin_id and current_id != plugin_id:
|
||||
continue
|
||||
try:
|
||||
if not self._auth_checker(plugin):
|
||||
if current_id in self._classes:
|
||||
self._classes[current_id] = plugin
|
||||
continue
|
||||
self._classes[current_id] = plugin
|
||||
instance = plugin()
|
||||
instance.init_plugin(self._plugin_config(current_id))
|
||||
self._running[current_id] = instance
|
||||
self._logger.info(
|
||||
f"加载插件:{current_id} 版本:{instance.plugin_version}"
|
||||
)
|
||||
if instance.get_state():
|
||||
self._enable_events(plugin)
|
||||
else:
|
||||
self._disable_events(plugin)
|
||||
except Exception as error: # noqa: BLE001
|
||||
self._logger.error(
|
||||
f"加载插件 {current_id} 出错:{error} - {traceback.format_exc()}"
|
||||
)
|
||||
self._clear_tools()
|
||||
|
||||
def initialize(self, plugin_id: str, config: dict) -> None:
|
||||
"""重新应用指定插件配置并刷新事件注册状态。"""
|
||||
plugin = self._running.get(plugin_id)
|
||||
if not plugin:
|
||||
return
|
||||
plugin.init_plugin(config)
|
||||
if plugin.get_state():
|
||||
self._enable_events(type(plugin))
|
||||
else:
|
||||
self._disable_events(type(plugin))
|
||||
self._clear_tools()
|
||||
|
||||
def stop(self, plugin_id: Optional[str] = None) -> None:
|
||||
"""停止指定插件或全部插件,并清理模块缓存。"""
|
||||
if plugin_id:
|
||||
self._logger.info(f"正在停止插件 {plugin_id}...")
|
||||
plugin = self._running.get(plugin_id)
|
||||
plugins = {plugin_id: plugin} if plugin else {}
|
||||
if not plugin:
|
||||
self._logger.debug(f"插件 {plugin_id} 不存在或未加载")
|
||||
else:
|
||||
self._logger.info("正在停止所有插件...")
|
||||
plugins = dict(self._running)
|
||||
|
||||
for current_id, plugin in plugins.items():
|
||||
self._disable_events(type(plugin))
|
||||
self._stop_plugin(plugin)
|
||||
|
||||
if plugin_id:
|
||||
self._classes.pop(plugin_id, None)
|
||||
self._running.pop(plugin_id, None)
|
||||
self._clear_modules(plugin_id)
|
||||
else:
|
||||
self._classes.clear()
|
||||
self._running.clear()
|
||||
self._clear_modules(None)
|
||||
self._clear_tools()
|
||||
self._logger.info("插件停止完成")
|
||||
|
||||
def reload(self, plugin_id: str, reload_event: Any) -> None:
|
||||
"""重启指定插件并广播插件重载事件。"""
|
||||
self.stop(plugin_id)
|
||||
self.start(plugin_id)
|
||||
self._event_sender(reload_event, data={"plugin_id": plugin_id})
|
||||
|
||||
def _stop_plugin(self, plugin: Any) -> None:
|
||||
"""按插件旧 ABI 顺序关闭资源和服务。"""
|
||||
try:
|
||||
if hasattr(plugin, "close"):
|
||||
plugin.close()
|
||||
if hasattr(plugin, "stop_service"):
|
||||
plugin.stop_service()
|
||||
except Exception as error: # noqa: BLE001
|
||||
name = plugin.get_name() if hasattr(plugin, "get_name") else type(plugin).__name__
|
||||
self._logger.warning(f"停止插件 {name} 时发生错误: {error}")
|
||||
@@ -0,0 +1,123 @@
|
||||
"""插件源码发现、导入和模块缓存清理。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
import traceback
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
PluginImportPreparer = Callable[..., None]
|
||||
PluginImportScanner = Callable[..., None]
|
||||
PluginValidator = Callable[[Any], bool]
|
||||
|
||||
|
||||
class PluginLoader:
|
||||
"""只负责从运行目录发现插件类,并维护对应模块缓存。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
plugins_root: Path,
|
||||
import_preparer: PluginImportPreparer,
|
||||
import_scanner: PluginImportScanner,
|
||||
log: Any,
|
||||
) -> None:
|
||||
"""保存插件目录、导入前置能力和日志端口。"""
|
||||
self._plugins_root = plugins_root
|
||||
self._import_preparer = import_preparer
|
||||
self._import_scanner = import_scanner
|
||||
self._logger = log
|
||||
|
||||
def load(
|
||||
self,
|
||||
plugin_id: Optional[str],
|
||||
installed_plugins: list[str],
|
||||
validator: PluginValidator,
|
||||
) -> list[Any]:
|
||||
"""只导入指定插件或已安装插件,并返回通过契约检查的插件类。"""
|
||||
if not self._plugins_root.exists():
|
||||
self._logger.warning(f"插件目录不存在:{self._plugins_root}")
|
||||
return []
|
||||
|
||||
targets = (
|
||||
[plugin_id.lower()]
|
||||
if plugin_id
|
||||
else [item.lower() for item in installed_plugins]
|
||||
)
|
||||
if not targets:
|
||||
self._logger.debug("没有需要加载的插件")
|
||||
return []
|
||||
|
||||
plugins = []
|
||||
loaded_classes = set()
|
||||
for plugin_dir in self._plugins_root.iterdir():
|
||||
if not plugin_dir.is_dir() or plugin_dir.name.startswith("_"):
|
||||
continue
|
||||
if plugin_dir.name not in targets:
|
||||
self._logger.debug(
|
||||
f"跳过插件目录:{plugin_dir.name}(不在加载列表中)"
|
||||
)
|
||||
continue
|
||||
if not (plugin_dir / "__init__.py").exists():
|
||||
self._logger.debug(
|
||||
f"跳过插件目录:{plugin_dir.name}(缺少__init__.py)"
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
module_name = f"app.plugins.{plugin_dir.name}"
|
||||
self._logger.debug(f"正在导入插件模块:{module_name}")
|
||||
self._import_preparer(
|
||||
plugin_id=plugin_dir.name,
|
||||
plugin_dir=plugin_dir,
|
||||
)
|
||||
self._import_scanner(
|
||||
plugin_id=plugin_dir.name,
|
||||
plugin_dir=plugin_dir,
|
||||
)
|
||||
module = importlib.import_module(module_name)
|
||||
for name, candidate in module.__dict__.items():
|
||||
if name.startswith("_") or not isinstance(candidate, type):
|
||||
continue
|
||||
if name in loaded_classes or not validator(candidate):
|
||||
continue
|
||||
loaded_classes.add(name)
|
||||
plugins.append(candidate)
|
||||
self._logger.debug(f"找到符合条件的插件类:{name}")
|
||||
break
|
||||
except Exception as err:
|
||||
self._logger.error(
|
||||
f"加载插件 {plugin_dir.name} 失败:{str(err)} - "
|
||||
f"{traceback.format_exc()}"
|
||||
)
|
||||
return plugins
|
||||
|
||||
def clear_modules(self, plugin_id: Optional[str] = None) -> list[str]:
|
||||
"""清除指定插件或全部插件的 Python 模块缓存。"""
|
||||
prefix = (
|
||||
f"app.plugins.{plugin_id.lower()}"
|
||||
if plugin_id
|
||||
else "app.plugins"
|
||||
)
|
||||
removed = [
|
||||
module_name
|
||||
for module_name in list(sys.modules)
|
||||
if module_name == prefix or module_name.startswith(f"{prefix}.")
|
||||
]
|
||||
for module_name in removed:
|
||||
sys.modules.pop(module_name, None)
|
||||
self._logger.debug(f"已清除插件模块缓存:{module_name}")
|
||||
importlib.invalidate_caches()
|
||||
self._logger.debug("已清除查找器的缓存")
|
||||
if plugin_id:
|
||||
if removed:
|
||||
self._logger.info(
|
||||
f"插件 {plugin_id} 共清除 {len(removed)} 个模块缓存:{removed}"
|
||||
)
|
||||
else:
|
||||
self._logger.debug(f"插件 {plugin_id} 没有找到需要清除的模块缓存")
|
||||
return removed
|
||||
@@ -0,0 +1,116 @@
|
||||
"""插件目录条目的运行态元数据映射。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.runtime.extensions.plugin.contracts import supports_plugin_hook
|
||||
from app.schemas.plugin import Plugin
|
||||
|
||||
|
||||
class PluginMetadataMapper:
|
||||
"""把市场或本地仓条目映射为包含运行态状态的插件 DTO。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
plugin_instance: Callable[[str], Optional[Any]],
|
||||
plugin_class: Callable[[str], Optional[Any]],
|
||||
annotate_system_version: Callable[[dict], dict],
|
||||
is_package_compatible: Callable[[dict, str], bool],
|
||||
auth_checker: Callable[[Plugin, dict], bool],
|
||||
version_compare: Callable[[str, str, str], bool],
|
||||
log: Any,
|
||||
) -> None:
|
||||
"""保存注册表、兼容判断和权限判断端口。"""
|
||||
self._plugin_instance = plugin_instance
|
||||
self._plugin_class = plugin_class
|
||||
self._annotate_system_version = annotate_system_version
|
||||
self._is_package_compatible = is_package_compatible
|
||||
self._auth_checker = auth_checker
|
||||
self._version_compare = version_compare
|
||||
self._logger = log
|
||||
|
||||
def map(
|
||||
self,
|
||||
plugin_id: str,
|
||||
plugin_info: dict,
|
||||
market: str,
|
||||
installed_plugins: list[str],
|
||||
add_time: int,
|
||||
package_version: Optional[str] = None,
|
||||
) -> Optional[Plugin]:
|
||||
"""映射一个插件索引条目,不兼容或无权限时返回空。"""
|
||||
if not isinstance(plugin_info, dict):
|
||||
return None
|
||||
info = self._annotate_system_version(plugin_info.copy())
|
||||
if not self._is_package_compatible(info, package_version or ""):
|
||||
return None
|
||||
|
||||
instance = self._plugin_instance(plugin_id)
|
||||
plugin_class = self._plugin_class(plugin_id)
|
||||
plugin = Plugin(id=plugin_id)
|
||||
plugin.installed = plugin_id in installed_plugins and plugin_class is not None
|
||||
plugin.has_update = False
|
||||
if plugin_class:
|
||||
installed_version = getattr(plugin_class, "plugin_version", None)
|
||||
online_version = info.get("version")
|
||||
if installed_version and online_version:
|
||||
plugin.has_update = self._version_compare(
|
||||
installed_version,
|
||||
"<",
|
||||
online_version,
|
||||
)
|
||||
|
||||
plugin.system_version = info.get("system_version")
|
||||
if info.get("system_version_compatible") is False:
|
||||
plugin.system_version_compatible = False
|
||||
plugin.system_version_message = info.get("system_version_message")
|
||||
|
||||
plugin.state = self._state(plugin_id, instance)
|
||||
plugin.has_page = bool(
|
||||
instance and supports_plugin_hook(instance, "get_page")
|
||||
)
|
||||
if info.get("key"):
|
||||
plugin.plugin_public_key = info["key"]
|
||||
if not self._auth_checker(plugin, info):
|
||||
return None
|
||||
|
||||
plugin.plugin_name = info.get("name")
|
||||
plugin.plugin_desc = info.get("description")
|
||||
plugin.plugin_version = info.get("version")
|
||||
plugin.plugin_icon = info.get("icon")
|
||||
plugin.plugin_label = self.normalize_label(info.get("labels"))
|
||||
plugin.plugin_author = info.get("author")
|
||||
plugin.history = info.get("history") or {}
|
||||
plugin.release = bool(info.get("release"))
|
||||
plugin.repo_url = market
|
||||
plugin.is_local = False
|
||||
plugin.add_time = add_time
|
||||
return plugin
|
||||
|
||||
def _state(self, plugin_id: str, instance: Optional[Any]) -> bool:
|
||||
"""安全读取插件运行状态,插件异常时降级为未启用。"""
|
||||
if not instance or not hasattr(instance, "get_state"):
|
||||
return False
|
||||
try:
|
||||
return bool(instance.get_state())
|
||||
except Exception as error: # noqa: BLE001
|
||||
self._logger.error(f"获取插件 {plugin_id} 状态出错:{error}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def normalize_label(labels: Any) -> Optional[str]:
|
||||
"""兼容市场标签的旧字符串和新列表格式。"""
|
||||
if isinstance(labels, str):
|
||||
label = labels.strip()
|
||||
return label or None
|
||||
if isinstance(labels, list):
|
||||
normalized = [
|
||||
str(item).strip()
|
||||
for item in labels
|
||||
if str(item).strip()
|
||||
]
|
||||
return " ".join(normalized) or None
|
||||
return None
|
||||
@@ -0,0 +1,220 @@
|
||||
"""插件运行目录与本地仓库的文件变化监控。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
FederatedChangeResolver = Callable[[Path], Optional[tuple[str, Optional[dict], bool]]]
|
||||
RuntimePluginResolver = Callable[[Path], Optional[str]]
|
||||
LocalCandidateResolver = Callable[[Path], Optional[dict]]
|
||||
LocalPluginSync = Callable[[str, Optional[dict]], bool]
|
||||
PluginReloader = Callable[[str], Any]
|
||||
WatchFunction = Callable[..., Any]
|
||||
|
||||
|
||||
class PluginMonitorController:
|
||||
"""独立管理插件文件监控线程的启动、停止和重建。"""
|
||||
|
||||
def __init__(self, *, runner: Callable[[], None], log: Any) -> None:
|
||||
"""保存监控循环入口和日志端口,线程状态仅由本组件持有。"""
|
||||
self._runner = runner
|
||||
self._logger = log
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
@property
|
||||
def stop_event(self) -> threading.Event:
|
||||
"""返回供 watchfiles 监听的停止事件。"""
|
||||
return self._stop_event
|
||||
|
||||
def reload(self, enabled: bool) -> None:
|
||||
"""按当前配置停止旧线程,并在启用时创建新线程。"""
|
||||
self.stop()
|
||||
if enabled:
|
||||
self.start()
|
||||
|
||||
def start(self) -> None:
|
||||
"""启动唯一的守护监控线程。"""
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._logger.info("插件文件修改监测已经在运行中...")
|
||||
return
|
||||
self._logger.info("开始监测插件文件修改...")
|
||||
self._stop_event.clear()
|
||||
self._thread = threading.Thread(target=self._runner, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""请求监控线程退出,并在限定时间内等待其清理。"""
|
||||
if not self._thread or not self._thread.is_alive():
|
||||
self._logger.info("未启用插件文件修改监测,无需停止")
|
||||
return
|
||||
self._logger.info("正在停止插件文件修改监测...")
|
||||
self._stop_event.set()
|
||||
self._thread.join(timeout=5)
|
||||
if self._thread.is_alive():
|
||||
self._logger.warning("插件文件修改监测线程在5秒内未能正常停止。")
|
||||
self._thread = None
|
||||
self._logger.info("插件文件修改监测停止完成")
|
||||
|
||||
|
||||
class PluginChangeMonitor:
|
||||
"""把文件变化归并为本地同步和运行态重载动作。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
runtime_root: Path,
|
||||
local_roots: Callable[[], list[Path]],
|
||||
stop_event: Any,
|
||||
recent_sync: dict[str, float],
|
||||
federated_change: FederatedChangeResolver,
|
||||
runtime_plugin: RuntimePluginResolver,
|
||||
local_candidate: LocalCandidateResolver,
|
||||
sync_local: LocalPluginSync,
|
||||
reload_plugin: PluginReloader,
|
||||
watch: WatchFunction,
|
||||
log: Any,
|
||||
) -> None:
|
||||
"""保存监控路径、变化解析器和副作用回调。"""
|
||||
self._runtime_root = runtime_root
|
||||
self._local_roots = local_roots
|
||||
self._stop_event = stop_event
|
||||
self._recent_sync = recent_sync
|
||||
self._federated_change = federated_change
|
||||
self._runtime_plugin = runtime_plugin
|
||||
self._local_candidate = local_candidate
|
||||
self._sync_local = sync_local
|
||||
self._reload_plugin = reload_plugin
|
||||
self._watch = watch
|
||||
self._logger = log
|
||||
|
||||
def run(self) -> None:
|
||||
"""运行 watchfiles 主循环并按批次同步、重载插件。"""
|
||||
plugin_paths = [str(self._runtime_root)]
|
||||
plugin_paths.extend(
|
||||
str(path)
|
||||
for path in self._local_roots()
|
||||
if path.exists() and path.is_dir()
|
||||
)
|
||||
self._logger.info(">>> 监控线程已启动,准备进入watch循环...")
|
||||
for changes in self._watch(
|
||||
*plugin_paths,
|
||||
stop_event=self._stop_event,
|
||||
rust_timeout=1000,
|
||||
yield_on_timeout=True,
|
||||
):
|
||||
if not changes:
|
||||
continue
|
||||
self._process_changes(changes)
|
||||
|
||||
def _process_changes(self, changes: Any) -> None:
|
||||
"""把一批文件事件归并为最多一次同步和一次重载。"""
|
||||
plugins_to_reload = set()
|
||||
local_plugins_to_sync = {}
|
||||
for _change_type, path_str in changes:
|
||||
event_path = Path(path_str)
|
||||
if "__pycache__" in event_path.parts:
|
||||
continue
|
||||
if event_path.name == "requirements.txt":
|
||||
self._handle_requirements_change(event_path)
|
||||
continue
|
||||
|
||||
federated_change = self._federated_change(event_path)
|
||||
if federated_change:
|
||||
plugin_id, candidate, remote_entry_ready = federated_change
|
||||
if candidate and remote_entry_ready:
|
||||
if candidate.get("compatible") is False:
|
||||
self._logger.info(
|
||||
f"检测到本地插件 {plugin_id} 联邦构建产物变化,"
|
||||
f"但跳过同步:{candidate.get('skip_reason')}"
|
||||
)
|
||||
elif plugin_id not in local_plugins_to_sync:
|
||||
local_plugins_to_sync[plugin_id] = (
|
||||
candidate,
|
||||
event_path,
|
||||
False,
|
||||
)
|
||||
continue
|
||||
|
||||
if event_path.suffix != ".py":
|
||||
continue
|
||||
runtime_plugin_id = self._runtime_plugin(event_path)
|
||||
candidate = (
|
||||
self._local_candidate(event_path)
|
||||
if not runtime_plugin_id
|
||||
else None
|
||||
)
|
||||
if runtime_plugin_id:
|
||||
last_sync_time = self._recent_sync.get(runtime_plugin_id)
|
||||
if last_sync_time and time.time() - last_sync_time < 2:
|
||||
continue
|
||||
plugins_to_reload.add(runtime_plugin_id)
|
||||
elif candidate:
|
||||
if candidate.get("compatible") is False:
|
||||
package_version = candidate.get("package_version")
|
||||
source_root = (
|
||||
f"plugins.{package_version}"
|
||||
if package_version
|
||||
else "plugins"
|
||||
)
|
||||
self._logger.info(
|
||||
f"检测到本地插件 {candidate.get('id')} 文件变化,"
|
||||
f"来源:{source_root},文件:{event_path},"
|
||||
f"但跳过同步:{candidate.get('skip_reason')}"
|
||||
)
|
||||
continue
|
||||
local_plugins_to_sync[candidate.get("id")] = (
|
||||
candidate,
|
||||
event_path,
|
||||
True,
|
||||
)
|
||||
|
||||
for plugin_id, (candidate, event_path, should_reload) in (
|
||||
local_plugins_to_sync.items()
|
||||
):
|
||||
package_version = candidate.get("package_version")
|
||||
source_root = (
|
||||
f"plugins.{package_version}" if package_version else "plugins"
|
||||
)
|
||||
change_name = "Python 文件" if should_reload else "联邦构建产物"
|
||||
self._logger.info(
|
||||
f"检测到本地插件 {plugin_id} {change_name}变化,"
|
||||
f"来源:{source_root},文件:{event_path}"
|
||||
)
|
||||
if self._sync_local(plugin_id, candidate) and should_reload:
|
||||
plugins_to_reload.add(plugin_id)
|
||||
|
||||
if not plugins_to_reload:
|
||||
return
|
||||
self._logger.info(
|
||||
f"检测到插件文件变化,准备重载: {list(plugins_to_reload)}"
|
||||
)
|
||||
for plugin_id in plugins_to_reload:
|
||||
try:
|
||||
self._reload_plugin(plugin_id)
|
||||
except Exception as err:
|
||||
self._logger.error(
|
||||
f"插件 {plugin_id} 热重载失败: {err}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def _handle_requirements_change(self, event_path: Path) -> None:
|
||||
"""记录依赖文件变化,但不在监控线程中隐式安装依赖。"""
|
||||
candidate = self._local_candidate(event_path)
|
||||
if not candidate:
|
||||
return
|
||||
if candidate.get("compatible") is False:
|
||||
self._logger.info(
|
||||
f"检测到本地插件 {candidate.get('id')} 依赖文件变化,"
|
||||
f"但跳过处理:{candidate.get('skip_reason')}"
|
||||
)
|
||||
return
|
||||
self._logger.warning(
|
||||
f"检测到本地插件 {candidate.get('id')} 依赖文件变化,"
|
||||
"请重新安装本地插件以安装依赖"
|
||||
)
|
||||
@@ -0,0 +1,146 @@
|
||||
"""插件运行目录、本地仓和联邦产物路径解析。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from collections.abc import Callable, Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.runtime.extensions.plugin.system import PluginSystemServices
|
||||
|
||||
|
||||
class PluginPathResolver:
|
||||
"""把文件事件解析为插件 ID、本地候选和联邦入口状态。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
runtime_root: Path,
|
||||
running: Callable[[], Mapping[str, Any]],
|
||||
system: Callable[[], PluginSystemServices],
|
||||
strict_system_version: Callable[[], bool],
|
||||
log: Any,
|
||||
) -> None:
|
||||
"""保存运行目录和插件市场路径解析端口。"""
|
||||
self._runtime_root = runtime_root.resolve()
|
||||
self._running = running
|
||||
self._system = system
|
||||
self._strict_system_version = strict_system_version
|
||||
self._logger = log
|
||||
|
||||
def federated_change(
|
||||
self,
|
||||
event_path: Path,
|
||||
) -> Optional[tuple[str, Optional[dict], bool]]:
|
||||
"""识别联邦构建产物变化并确认入口文件已完整生成。"""
|
||||
try:
|
||||
event_path = event_path.resolve()
|
||||
candidate = self.local_candidate(event_path)
|
||||
if candidate:
|
||||
plugin_id = candidate.get("id")
|
||||
plugin_dir = Path(candidate.get("path")).resolve()
|
||||
else:
|
||||
if not event_path.is_relative_to(self._runtime_root):
|
||||
return None
|
||||
relative_parts = event_path.relative_to(self._runtime_root).parts
|
||||
if not relative_parts:
|
||||
return None
|
||||
plugin_dir = self._runtime_root / relative_parts[0]
|
||||
plugin_id = next(
|
||||
(
|
||||
item
|
||||
for item in self._running()
|
||||
if item.lower() == relative_parts[0].lower()
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not plugin_id:
|
||||
return None
|
||||
plugin = self._running().get(plugin_id)
|
||||
if not plugin:
|
||||
return None
|
||||
render_mode, dist_path = plugin.get_render_mode()
|
||||
if render_mode != "vue" or not isinstance(dist_path, str) or not dist_path:
|
||||
return None
|
||||
relative_dist_path = Path(dist_path)
|
||||
if (
|
||||
relative_dist_path.is_absolute()
|
||||
or ".." in relative_dist_path.parts
|
||||
or "\\" in dist_path
|
||||
):
|
||||
return None
|
||||
plugin_dir = plugin_dir.resolve()
|
||||
dist_dir = (plugin_dir / relative_dist_path).resolve()
|
||||
if (
|
||||
dist_dir == plugin_dir
|
||||
or not dist_dir.is_relative_to(plugin_dir)
|
||||
or not event_path.is_relative_to(dist_dir)
|
||||
):
|
||||
return None
|
||||
remote_entry = dist_dir / "remoteEntry.js"
|
||||
ready = remote_entry.is_file() and remote_entry.resolve().is_relative_to(
|
||||
plugin_dir
|
||||
)
|
||||
return plugin_id, candidate, ready
|
||||
except Exception as error:
|
||||
self._logger.error(f"识别插件联邦构建产物变化时出错: {error}")
|
||||
return None
|
||||
|
||||
def runtime_plugin(self, event_path: Path) -> Optional[str]:
|
||||
"""从运行目录中的插件 ``__init__.py`` AST 解析插件类名。"""
|
||||
try:
|
||||
event_path = event_path.resolve()
|
||||
if not event_path.is_relative_to(self._runtime_root):
|
||||
return None
|
||||
parts = event_path.relative_to(self._runtime_root).parts
|
||||
if not parts:
|
||||
return None
|
||||
init_file = self._runtime_root / parts[0] / "__init__.py"
|
||||
if not init_file.exists():
|
||||
return None
|
||||
tree = ast.parse(
|
||||
init_file.read_text(encoding="utf-8", errors="replace")
|
||||
)
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.ClassDef):
|
||||
continue
|
||||
if any(
|
||||
isinstance(base, ast.Name) and base.id == "_PluginBase"
|
||||
for base in node.bases
|
||||
):
|
||||
return node.name
|
||||
return None
|
||||
except Exception as error:
|
||||
self._logger.error(f"从路径解析插件 ID 时出错: {error}")
|
||||
return None
|
||||
|
||||
def local_candidate(self, event_path: Path) -> Optional[dict]:
|
||||
"""按 ``plugins``、``plugins.v2``、``plugins.v3`` 目录解析候选。"""
|
||||
try:
|
||||
event_path = event_path.resolve()
|
||||
for repo_path in self._system().local_repo_paths():
|
||||
if not repo_path.exists() or not repo_path.is_dir():
|
||||
continue
|
||||
if not event_path.is_relative_to(repo_path):
|
||||
continue
|
||||
parts = event_path.relative_to(repo_path).parts
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
if parts[0] == "plugins":
|
||||
package_version = ""
|
||||
elif parts[0].startswith("plugins."):
|
||||
package_version = parts[0].split(".", 1)[1]
|
||||
else:
|
||||
continue
|
||||
return self._system().local_candidate(
|
||||
parts[1],
|
||||
package_version=package_version,
|
||||
repo_path=repo_path,
|
||||
strict_compat=False,
|
||||
strict_system_version=self._strict_system_version(),
|
||||
)
|
||||
return None
|
||||
except Exception as error:
|
||||
self._logger.error(f"从本地插件仓路径解析候选时出错: {error}")
|
||||
return None
|
||||
@@ -1,9 +1,15 @@
|
||||
"""插件公开能力投影。"""
|
||||
|
||||
import inspect
|
||||
from typing import Any, Callable, Dict, List, Mapping, Optional
|
||||
|
||||
from app.runtime.extensions.plugin.contracts import supports_plugin_hook
|
||||
from app.runtime.extensions.plugin.contracts import (
|
||||
PluginDashboardError,
|
||||
PluginNotFoundError,
|
||||
supports_plugin_hook,
|
||||
)
|
||||
from app.runtime.log import logger as default_logger
|
||||
from app.schemas.plugin import PluginDashboard
|
||||
|
||||
|
||||
class PluginProjection:
|
||||
@@ -260,3 +266,46 @@ class PluginProjection:
|
||||
f"获取插件[{plugin_id}]仪表盘元数据出错:{str(error)}"
|
||||
)
|
||||
return metadata
|
||||
|
||||
def dashboard(
|
||||
self,
|
||||
plugin_id: str,
|
||||
key: str,
|
||||
user_agent: Optional[str] = None,
|
||||
) -> Optional[PluginDashboard]:
|
||||
"""调用插件仪表板钩子并返回稳定投影,不依赖 HTTP 异常。"""
|
||||
plugin = self._running_plugins.get(plugin_id)
|
||||
if not plugin:
|
||||
raise PluginNotFoundError(f"插件 {plugin_id} 不存在或未加载")
|
||||
try:
|
||||
render_mode, _ = plugin.get_render_mode()
|
||||
method = plugin.get_dashboard
|
||||
count = len(inspect.signature(method).parameters)
|
||||
if count > 1:
|
||||
dashboard = method(key=key, user_agent=user_agent)
|
||||
elif count > 0:
|
||||
dashboard = method(user_agent=user_agent)
|
||||
else:
|
||||
dashboard = method()
|
||||
except Exception as error: # noqa: BLE001
|
||||
self._logger.error(f"插件 {plugin_id} 调用方法 get_dashboard 出错: {error}")
|
||||
raise PluginDashboardError(
|
||||
f"插件 {plugin_id} 调用方法 get_dashboard 出错: {error}"
|
||||
) from error
|
||||
if dashboard is None:
|
||||
return None
|
||||
if not isinstance(dashboard, (tuple, list)) or len(dashboard) != 3:
|
||||
self._logger.error(f"插件 {plugin_id} 返回的仪表盘数据格式错误")
|
||||
raise PluginDashboardError(
|
||||
f"插件 {plugin_id} 返回的仪表盘数据格式错误"
|
||||
)
|
||||
cols, attrs, elements = dashboard
|
||||
return PluginDashboard(
|
||||
id=plugin_id,
|
||||
name=plugin.plugin_name,
|
||||
key=key,
|
||||
render_mode=render_mode,
|
||||
cols=cols or {},
|
||||
attrs=attrs or {},
|
||||
elements=elements,
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ ConfigWriter = Callable[[Any, Any], Any]
|
||||
AsyncConfigWriter = Callable[[Any, Any], Awaitable[Any]]
|
||||
ConfigDeleter = Callable[[Any], bool]
|
||||
PluginDataDeleter = Callable[[str], Any]
|
||||
PluginExists = Callable[[str], bool]
|
||||
|
||||
|
||||
def _empty_read(_key: Any) -> Any:
|
||||
@@ -75,6 +76,69 @@ class PluginStorage:
|
||||
return self._delete_data(plugin_id)
|
||||
|
||||
|
||||
class PluginConfigStore:
|
||||
"""封装插件配置键、存在性和强制删除规则。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
storage: Callable[[], "PluginStorage"],
|
||||
plugin_exists: PluginExists,
|
||||
key_prefix: str = "plugin.%s",
|
||||
) -> None:
|
||||
"""保存持久化端口和运行态插件查询端口。"""
|
||||
self._storage = storage
|
||||
self._plugin_exists = plugin_exists
|
||||
self._key_prefix = key_prefix
|
||||
|
||||
def _key(self, plugin_id: str) -> str:
|
||||
"""构造插件配置在统一配置存储中的键。"""
|
||||
return self._key_prefix % plugin_id
|
||||
|
||||
def read(self, plugin_id: str) -> dict:
|
||||
"""读取配置并过滤历史空键。"""
|
||||
if not self._plugin_exists(plugin_id):
|
||||
return {}
|
||||
config = self._storage().read(self._key(plugin_id))
|
||||
return {
|
||||
key: value
|
||||
for key, value in (config or {}).items()
|
||||
if key
|
||||
}
|
||||
|
||||
def write(self, plugin_id: str, config: dict, force: bool = False) -> bool:
|
||||
"""保存配置,默认拒绝不存在插件的配置写入。"""
|
||||
if not force and not self._plugin_exists(plugin_id):
|
||||
return False
|
||||
self._storage().write(self._key(plugin_id), config)
|
||||
return True
|
||||
|
||||
async def async_write(
|
||||
self,
|
||||
plugin_id: str,
|
||||
config: dict,
|
||||
force: bool = False,
|
||||
) -> bool:
|
||||
"""异步保存配置并保持同步写入的存在性规则。"""
|
||||
if not force and not self._plugin_exists(plugin_id):
|
||||
return False
|
||||
await self._storage().async_write(self._key(plugin_id), config)
|
||||
return True
|
||||
|
||||
def delete(self, plugin_id: str, force: bool = False) -> bool:
|
||||
"""删除配置并保持停止插件后的强制删除能力。"""
|
||||
if not force and not self._plugin_exists(plugin_id):
|
||||
return False
|
||||
return self._storage().delete(self._key(plugin_id))
|
||||
|
||||
def delete_data(self, plugin_id: str, force: bool = False) -> bool:
|
||||
"""删除插件业务数据并保持旧的布尔结果合同。"""
|
||||
if not force and not self._plugin_exists(plugin_id):
|
||||
return False
|
||||
self._storage().delete_data(plugin_id)
|
||||
return True
|
||||
|
||||
|
||||
_plugin_storage = PluginStorage()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""插件市场同步运行时用例。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from app.runtime.extensions.plugin.system import PluginSystemServices
|
||||
|
||||
|
||||
class PluginSyncService:
|
||||
"""根据已安装清单同步缺失或过期插件,不参与插件实例生命周期。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
frozen: Callable[[], bool],
|
||||
installed_plugins: Callable[[], list[str]],
|
||||
online_plugins: Callable[[], list[Any]],
|
||||
local_plugins: Callable[[], list[Any]],
|
||||
merge_plugins: Callable[[list[Any], list[Any], list[Any]], list[Any]],
|
||||
plugin_exists: Callable[[str, Optional[str]], bool],
|
||||
install: Callable[[str, Optional[str], bool], tuple[bool, str]],
|
||||
report: Callable[..., Any],
|
||||
log: Any,
|
||||
) -> None:
|
||||
"""保存目录读取、包安装和持久化报告端口。"""
|
||||
self._frozen = frozen
|
||||
self._installed_plugins = installed_plugins
|
||||
self._online_plugins = online_plugins
|
||||
self._local_plugins = local_plugins
|
||||
self._merge_plugins = merge_plugins
|
||||
self._plugin_exists = plugin_exists
|
||||
self._install = install
|
||||
self._report = report
|
||||
self._logger = log
|
||||
|
||||
def sync(self) -> list[str]:
|
||||
"""并发安装本地缺失或需要更新的已安装插件。"""
|
||||
if self._frozen():
|
||||
return []
|
||||
|
||||
installed = self._installed_plugins()
|
||||
online = self._online_plugins()
|
||||
local = self._local_plugins()
|
||||
candidates = self._merge_plugins(online + local, [], []) if online or local else []
|
||||
targets = [
|
||||
plugin
|
||||
for plugin in candidates
|
||||
if plugin.id in installed
|
||||
and plugin.system_version_compatible is not False
|
||||
and not self._plugin_exists(plugin.id, plugin.plugin_version)
|
||||
]
|
||||
if not targets:
|
||||
return []
|
||||
|
||||
self._logger.info("开始安装第三方插件...")
|
||||
synced: list[str] = []
|
||||
failed: list[str] = []
|
||||
|
||||
def install_one(plugin: Any) -> None:
|
||||
"""安装一个插件并记录结果。"""
|
||||
started = time.time()
|
||||
state, message = self._install(plugin.id, plugin.repo_url, True)
|
||||
elapsed = time.time() - started
|
||||
if state:
|
||||
self._report(plugin_id=plugin.id, repo_url=plugin.repo_url)
|
||||
self._logger.info(
|
||||
f"插件 {plugin.plugin_name} 安装成功,版本:{plugin.plugin_version},"
|
||||
f"耗时:{elapsed:.2f} 秒"
|
||||
)
|
||||
synced.append(plugin.id)
|
||||
else:
|
||||
self._logger.error(
|
||||
f"插件 {plugin.plugin_name} v{plugin.plugin_version} 安装失败:"
|
||||
f"{message},耗时:{elapsed:.2f} 秒"
|
||||
)
|
||||
failed.append(plugin.id)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=5) as executor:
|
||||
futures = {executor.submit(install_one, plugin): plugin for plugin in targets}
|
||||
for future in as_completed(futures):
|
||||
plugin = futures[future]
|
||||
try:
|
||||
future.result()
|
||||
except Exception as error: # noqa: BLE001
|
||||
self._logger.error(
|
||||
f"插件 {plugin.plugin_name} 安装过程中出现异常: {error}"
|
||||
)
|
||||
|
||||
self._logger.info(
|
||||
f"第三方插件安装完成,成功:{len(synced)} 个,失败:{len(failed)} 个"
|
||||
)
|
||||
return synced
|
||||
|
||||
|
||||
class LocalPluginSyncService:
|
||||
"""同步本地插件仓源码到运行目录,并记录热重载抑制窗口。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
installed_plugins: Callable[[], list[str]],
|
||||
candidate: Callable[[str], Optional[dict]],
|
||||
system: Callable[[], PluginSystemServices],
|
||||
recent_sync: dict[str, float],
|
||||
log: Any,
|
||||
) -> None:
|
||||
"""保存本地候选、包同步和运行态监控端口。"""
|
||||
self._installed_plugins = installed_plugins
|
||||
self._candidate = candidate
|
||||
self._system = system
|
||||
self._recent_sync = recent_sync
|
||||
self._logger = log
|
||||
|
||||
def sync(self, plugin_id: str, candidate: Optional[dict] = None) -> bool:
|
||||
"""同步已安装且兼容的本地插件,成功后记录短时事件抑制标记。"""
|
||||
if plugin_id not in self._installed_plugins():
|
||||
self._logger.info(f"本地插件 {plugin_id} 尚未安装,跳过自动同步和热重载")
|
||||
return False
|
||||
candidate = candidate or self._candidate(plugin_id)
|
||||
if not candidate or candidate.get("compatible") is False:
|
||||
if candidate:
|
||||
self._logger.info(
|
||||
f"本地插件 {plugin_id} 不满足同步条件,跳过同步:"
|
||||
f"{candidate.get('skip_reason')}"
|
||||
)
|
||||
return False
|
||||
source_dir = Path(candidate.get("path"))
|
||||
try:
|
||||
if not self._system().package.sync_local(plugin_id, source_dir):
|
||||
return False
|
||||
self._recent_sync[plugin_id] = time.time()
|
||||
self._logger.info(f"已同步本地插件 {plugin_id}:{source_dir}")
|
||||
return True
|
||||
except Exception as error:
|
||||
self._logger.error(f"同步本地插件 {plugin_id} 失败:{error}")
|
||||
return False
|
||||
@@ -0,0 +1,85 @@
|
||||
"""插件 Agent 工具目录缓存。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Any, Mapping, Optional
|
||||
|
||||
from app.runtime.extensions.plugin.contracts import supports_plugin_hook
|
||||
|
||||
|
||||
class PluginToolCatalog:
|
||||
"""按插件运行态版本构建并缓存 Agent 工具声明。"""
|
||||
|
||||
def __init__(self, *, max_attempts: int = 3) -> None:
|
||||
"""创建空目录,并限制状态持续变化时的重试次数。"""
|
||||
self._max_attempts = max_attempts
|
||||
self._cache: dict[str, list[dict[str, Any]]] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._revision = 0
|
||||
|
||||
@property
|
||||
def revision(self) -> int:
|
||||
"""返回当前插件工具目录版本。"""
|
||||
with self._lock:
|
||||
return self._revision
|
||||
|
||||
def clear(self) -> None:
|
||||
"""清空目录缓存并推进版本号。"""
|
||||
with self._lock:
|
||||
self._cache.clear()
|
||||
self._revision += 1
|
||||
|
||||
def get(
|
||||
self,
|
||||
running_plugins: Mapping[str, Any],
|
||||
*,
|
||||
plugin_id: Optional[str] = None,
|
||||
log: Any,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""返回指定插件或全部运行插件的工具声明快照。"""
|
||||
cache_key = plugin_id or "__all__"
|
||||
for _attempt in range(self._max_attempts):
|
||||
with self._lock:
|
||||
cache_revision = self._revision
|
||||
cached = self._cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return self.copy(cached)
|
||||
|
||||
tools_info = []
|
||||
for current_id, plugin in dict(running_plugins).items():
|
||||
if plugin_id and plugin_id != current_id:
|
||||
continue
|
||||
if not supports_plugin_hook(plugin, "get_agent_tools"):
|
||||
continue
|
||||
try:
|
||||
if not plugin.get_state():
|
||||
continue
|
||||
tools = plugin.get_agent_tools()
|
||||
if tools:
|
||||
tools_info.append({
|
||||
"plugin_id": current_id,
|
||||
"plugin_name": plugin.plugin_name,
|
||||
"tools": tools,
|
||||
})
|
||||
except Exception as err:
|
||||
log.error(
|
||||
f"获取插件 {current_id} 智能体工具出错:{str(err)}"
|
||||
)
|
||||
with self._lock:
|
||||
if cache_revision != self._revision:
|
||||
continue
|
||||
self._cache[cache_key] = self.copy(tools_info)
|
||||
return tools_info
|
||||
raise RuntimeError("插件工具注册表持续变化,无法建立当前快照")
|
||||
|
||||
@staticmethod
|
||||
def copy(tools_info: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""复制工具注册信息,避免调用方修改缓存内容。"""
|
||||
return [
|
||||
{
|
||||
**plugin_info,
|
||||
"tools": list(plugin_info.get("tools", [])),
|
||||
}
|
||||
for plugin_info in tools_info
|
||||
]
|
||||
Reference in New Issue
Block a user