refactor: 收口 V3 分层架构与插件兼容边界

This commit is contained in:
jxxghp
2026-08-18 13:22:02 +08:00
parent cca99bd421
commit 8472bcff43
274 changed files with 10730 additions and 6130 deletions
+10 -4
View File
@@ -435,10 +435,10 @@ MODULE_ALIASES: Dict[str, ModuleAlias] = {
owner="runtime",
),
"app.core.security": ModuleAlias(
target="app.application.security.access",
replacement="app.application.security.access",
target="app.sdk.security",
replacement="app.sdk.security",
introduced="v3.0.0",
owner="application",
owner="sdk",
),
"app.helper.agent": ModuleAlias(
target="app.application.messaging.agent", replacement="app.application.messaging.agent",
@@ -569,10 +569,16 @@ MODULE_ALIASES: Dict[str, ModuleAlias] = {
introduced="v3.0.0", owner="adapters",
),
"app.helper.service": ModuleAlias(
target="app.runtime.extensions.service_registry",
target="app.sdk.services",
replacement="app.sdk.services",
introduced="v3.0.0", owner="runtime",
),
"app.runtime.extensions.service_registry": ModuleAlias(
target="app.sdk.services",
replacement="app.sdk.services",
introduced="v3.0.0",
owner="sdk",
),
"app.helper.sites": ModuleAlias(
target="app.application.site.sites", replacement="app.sdk.network",
introduced="v3.0.0", owner="application",
+18 -9
View File
@@ -5,6 +5,7 @@ import os
import platform
import re
import secrets
import shutil
import sys
import threading
from asyncio import AbstractEventLoop
@@ -25,7 +26,12 @@ from app.runtime.log import (
NonBlockingFileHandler,
)
from app.schemas.types import MediaType
from app.adapters.system.host import SystemUtils
from app.foundation.environment import (
cpu_arch,
get_env_path,
is_docker,
is_frozen,
)
from app.foundation.url import UrlUtils
from version import APP_VERSION
@@ -729,7 +735,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
model_config = SettingsConfigDict(
case_sensitive=True,
env_file=SystemUtils.get_env_path(),
env_file=get_env_path(),
env_file_encoding="utf-8",
)
@@ -741,10 +747,10 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
if not path.exists():
path.mkdir(parents=True, exist_ok=True)
# 如果是二进制程序,确保配置文件存在
if SystemUtils.is_frozen():
if is_frozen():
app_env_path = self.CONFIG_PATH / "app.env"
if not app_env_path.exists():
SystemUtils.copy(self.INNER_CONFIG_PATH / "app.env", app_env_path)
shutil.copy2(self.INNER_CONFIG_PATH / "app.env", app_env_path)
@staticmethod
def validate_api_token(value: Any, original_value: Any) -> Tuple[Any, bool]:
@@ -928,7 +934,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
# 当值为 None 时,从 env 文件中删除该键,恢复为默认值
if converted_value is None:
unset_key(
dotenv_path=SystemUtils.get_env_path(),
dotenv_path=get_env_path(),
key_to_unset=field_name,
)
logger.info(f"配置项 '{field_name}' 已清空,从 'app.env' 中移除")
@@ -940,7 +946,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
value_to_write = str(converted_value)
set_key(
dotenv_path=SystemUtils.get_env_path(),
dotenv_path=get_env_path(),
key_to_set=field_name,
value_to_set=value_to_write,
quote_mode="always",
@@ -1013,7 +1019,10 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
"""
全局用户代理字符串
"""
return f"{self.PROJECT_NAME}/{APP_VERSION[1:]} ({platform.system()} {platform.release()}; {SystemUtils.cpu_arch()})"
return (
f"{self.PROJECT_NAME}/{APP_VERSION[1:]} "
f"({platform.system()} {platform.release()}; {cpu_arch()})"
)
@property
def NORMAL_USER_AGENT(self) -> str:
@@ -1032,9 +1041,9 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
"""按显式配置、容器和冻结运行环境确定配置目录。"""
if self.CONFIG_DIR:
return Path(self.CONFIG_DIR)
elif SystemUtils.is_docker():
elif is_docker():
return Path("/config")
elif SystemUtils.is_frozen():
elif is_frozen():
return Path(sys.executable).parent / "config"
return self.ROOT_PATH / "config"
+1 -2
View File
@@ -8,10 +8,9 @@ import time
from collections.abc import Callable
from typing import Any
from fastapi.concurrency import run_in_threadpool
from app.runtime.event.binding import EventBindingResolver
from app.runtime.event.registry import EventRegistry
from app.runtime.execution import run_in_threadpool
from app.runtime.log import logger
from app.schemas.types import EventType
+13 -1
View File
@@ -1,10 +1,22 @@
import asyncio
import inspect
import time
from functools import wraps
from functools import partial, wraps
from typing import Any, Callable
from app.schemas.exception import ImmediateException
from anyio.to_thread import run_sync
async def run_in_threadpool(
func: Callable[..., Any],
*args: Any,
**kwargs: Any,
) -> Any:
"""在线程中执行同步函数,保持 FastAPI 旧帮助函数的参数语义。"""
if kwargs:
func = partial(func, **kwargs)
return await run_sync(func, *args)
def retry(ExceptionToCheck: Any,
+1 -2
View File
@@ -6,9 +6,8 @@ import inspect
from collections.abc import Callable, Mapping
from typing import Any, Protocol
from fastapi.concurrency import run_in_threadpool
from app.foundation.reflection import ObjectUtils
from app.runtime.execution import run_in_threadpool
from app.runtime.log import logger
from app.runtime.extensions.module.contracts import get_module_method_contract
from app.schemas.exception import RateLimitExceededException
+59
View File
@@ -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
+204
View File
@@ -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
+96
View File
@@ -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
+133
View File
@@ -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}")
+123
View File
@@ -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
+116
View File
@@ -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
+220
View File
@@ -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')} 依赖文件变化,"
"请重新安装本地插件以安装依赖"
)
+146
View File
@@ -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
+50 -1
View File
@@ -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,
)
+64
View File
@@ -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()
+140
View File
@@ -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
+85
View File
@@ -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
]
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -22,10 +22,12 @@ def _empty_service_config(_config_key: SystemConfigKey) -> Any:
_service_config_reader: ServiceConfigReader = _empty_service_config
def configure_service_config_reader(reader: ServiceConfigReader) -> None:
"""由启动组合根注入服务配置读取能力。"""
def configure_service_config_reader(reader: ServiceConfigReader) -> ServiceConfigReader:
"""注入服务配置读取能力,并返回先前 reader 供隔离环境恢复"""
global _service_config_reader
previous = _service_config_reader
_service_config_reader = reader
return previous
class ServiceConfigHelper:
-109
View File
@@ -1,109 +0,0 @@
from typing import Dict, List, Optional, Type, TypeVar, Generic, Iterator
from app.runtime.extensions.module_manager import ModuleManager
from app.runtime.extensions.service_config import ServiceConfigHelper
from app.schemas.system import ServiceInfo
from app.schemas.types import SystemConfigKey, ModuleType
TConf = TypeVar("TConf")
__all__ = [
"ServiceBaseHelper",
"ServiceConfigHelper",
]
class ServiceBaseHelper(Generic[TConf]):
"""
通用服务帮助类抽象获取配置和服务实例的通用逻辑
"""
def __init__(self, config_key: SystemConfigKey, conf_type: Type[TConf], module_type: ModuleType):
"""绑定服务配置类型与对应的运行模块类型。"""
self.modulemanager = ModuleManager()
self.config_key = config_key
self.conf_type = conf_type
self.module_type = module_type
def get_configs(self, include_disabled: bool = False) -> Dict[str, TConf]:
"""
获取配置列表
:param include_disabled: 是否包含禁用的配置默认 False仅返回启用的配置
:return: 配置字典
"""
configs: List[TConf] = ServiceConfigHelper.get_configs(self.config_key, self.conf_type)
return {
config.name: config
for config in configs
if (config.name and config.type and config.enabled) or include_disabled
} if configs else {}
def get_config(self, name: str) -> Optional[TConf]:
"""
获取指定名称配置
"""
if not name:
return None
configs = self.get_configs()
return configs.get(name)
def iterate_module_instances(self) -> Iterator[ServiceInfo]:
"""
迭代所有模块的实例及其对应的配置返回 ServiceInfo 实例
"""
configs = self.get_configs()
for module in self.modulemanager.get_running_type_modules(self.module_type):
if not module:
continue
module_instances = module.get_instances()
if not isinstance(module_instances, dict):
continue
for name, instance in module_instances.items():
if not instance:
continue
config = configs.get(name)
service_info = ServiceInfo(
name=name,
instance=instance,
module=module,
type=config.type if config else None,
config=config
)
yield service_info
def get_services(self, type_filter: Optional[str] = None, name_filters: Optional[List[str]] = None) \
-> Dict[str, ServiceInfo]:
"""
获取服务信息列表并根据类型和名称列表进行过滤
:param type_filter: 需要过滤的服务类型
:param name_filters: 需要过滤的服务名称列表
:return: 过滤后的服务信息字典
"""
name_filters_set = set(name_filters) if name_filters else None
return {
service_info.name: service_info
for service_info in self.iterate_module_instances()
if service_info.config and (
type_filter is None or service_info.type == type_filter
) and (
name_filters_set is None or service_info.name in name_filters_set)
}
def get_service(self, name: str, type_filter: Optional[str] = None) -> Optional[ServiceInfo]:
"""
获取指定名称的服务信息并根据类型过滤
:param name: 服务名称
:param type_filter: 需要过滤的服务类型
:return: 对应的服务信息若不存在或类型不匹配则返回 None
"""
if not name:
return None
for service_info in self.iterate_module_instances():
if service_info.name == name:
if service_info.config and (type_filter is None or service_info.type == type_filter):
return service_info
return None
+5 -5
View File
@@ -14,7 +14,7 @@ import psutil
from app.runtime.config import settings
from app.runtime.log import logger
from app.runtime.reload import ConfigReloadMixin
from app.adapters.system.host import SystemUtils
from app.foundation.environment import is_docker
class SystemHelper(ConfigReloadMixin):
@@ -50,7 +50,7 @@ class SystemHelper(ConfigReloadMixin):
"""
判断是否可以内部重启
"""
return SystemUtils.is_docker() or SystemHelper._is_local_cli_managed()
return is_docker() or SystemHelper._is_local_cli_managed()
@staticmethod
def _load_runtime_file(path: Path) -> Optional[dict]:
@@ -292,7 +292,7 @@ class SystemHelper(ConfigReloadMixin):
"""
执行Docker重启操作
"""
if not SystemUtils.is_docker():
if not is_docker():
if not SystemHelper._is_local_cli_managed():
return False, "当前实例不是由 moviepilot CLI 启动,无法执行内建重启!"
try:
@@ -396,7 +396,7 @@ class SystemHelper(ConfigReloadMixin):
设置系统已修改标志
"""
try:
if SystemUtils.is_docker():
if is_docker():
Path(self.__system_flag_file).touch(exist_ok=True)
except Exception as e:
print(f"设置系统修改标志失败: {str(e)}")
@@ -406,6 +406,6 @@ class SystemHelper(ConfigReloadMixin):
检查系统是否已被重置
:return: 如果系统已重置返回 True否则返回 False
"""
if SystemUtils.is_docker():
if is_docker():
return not Path(self.__system_flag_file).exists()
return False