feat: Enhance plugin runtime configuration and hook management

- Added `inactive_plugins` field to `RunnerReadyPayload` and `ReloadPluginResultPayload` to track plugins that are not activated due to being disabled or unmet dependencies.
- Introduced `InspectPluginConfigPayload` and `InspectPluginConfigResultPayload` for inspecting plugin configuration metadata.
- Implemented `PluginActivationStatus` enum to better represent plugin activation states.
- Updated `_activate_plugin` method to return activation status and handle inactive plugins accordingly.
- Added hooks for send service to allow modification of messages before and after sending.
- Created new runtime routes for listing hook specifications in the WebUI.
- Refactored plugin configuration handling to utilize runtime inspection for better accuracy and flexibility.
- Enhanced error handling and logging for plugin configuration operations.
This commit is contained in:
DrSmoothl
2026-04-02 21:16:31 +08:00
parent 56f7184c4d
commit 7d0d429640
22 changed files with 2698 additions and 1120 deletions

View File

@@ -6,11 +6,13 @@ from .catalog import router as catalog_router
from .config_routes import router as config_router
from .management import router as management_router
from .progress import get_progress_router, update_progress
from .runtime_routes import router as runtime_router
router = APIRouter(prefix="/plugins", tags=["插件管理"])
router.include_router(catalog_router)
router.include_router(management_router)
router.include_router(config_router)
router.include_router(runtime_router)
set_update_progress_callback(update_progress)

View File

@@ -1,13 +1,13 @@
"""插件配置相关 WebUI 路由。"""
import json
from pathlib import Path
from typing import Any, Dict, Optional, cast
import tomlkit
from fastapi import APIRouter, Cookie, HTTPException
from src.common.logger import get_logger
from src.plugin_runtime.component_query import component_query_service
from src.plugin_runtime.protocol.envelope import InspectPluginConfigResultPayload
from src.webui.utils.toml_utils import save_toml_with_format
from .schemas import UpdatePluginConfigRequest, UpdatePluginRawConfigRequest
@@ -207,6 +207,55 @@ def _build_toml_document(config_data: Dict[str, Any]) -> tomlkit.TOMLDocument:
return tomlkit.parse(tomlkit.dumps(config_data))
def _load_plugin_config_from_disk(plugin_path: Path) -> Dict[str, Any]:
"""从磁盘读取插件配置。
Args:
plugin_path: 插件目录。
Returns:
Dict[str, Any]: 当前配置字典;文件不存在时返回空字典。
"""
config_path = resolve_plugin_file_path(plugin_path, "config.toml")
if not config_path.exists():
return {}
with open(config_path, "r", encoding="utf-8") as file_obj:
loaded_config = tomlkit.load(file_obj).unwrap()
return loaded_config if isinstance(loaded_config, dict) else {}
async def _inspect_plugin_config_via_runtime(
plugin_id: str,
config_data: Optional[Dict[str, Any]] = None,
*,
use_provided_config: bool = False,
) -> InspectPluginConfigResultPayload | None:
"""通过插件运行时解析配置元数据。
Args:
plugin_id: 插件 ID。
config_data: 可选的配置内容。
use_provided_config: 是否优先使用传入配置而不是磁盘配置。
Returns:
InspectPluginConfigResultPayload | None: 运行时可用时返回解析结果,否则返回 ``None``。
Raises:
ValueError: 插件运行时明确拒绝解析请求时抛出。
"""
from src.plugin_runtime.integration import get_plugin_runtime_manager
runtime_manager = get_plugin_runtime_manager()
return await runtime_manager.inspect_plugin_config(
plugin_id,
config_data,
use_provided_config=use_provided_config,
)
async def _validate_plugin_config_via_runtime(plugin_id: str, config_data: Dict[str, Any]) -> Dict[str, Any] | None:
"""通过插件运行时对配置进行校验。
@@ -244,27 +293,24 @@ async def get_plugin_config_schema(plugin_id: str, maibot_session: Optional[str]
logger.info(f"获取插件配置 Schema: {plugin_id}")
try:
registration_schema = component_query_service.get_plugin_config_schema(plugin_id)
if isinstance(registration_schema, dict) and registration_schema:
return {"success": True, "schema": registration_schema}
plugin_path = find_plugin_path_by_id(plugin_id)
if plugin_path is None:
raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}")
schema_json_path = resolve_plugin_file_path(plugin_path, "config_schema.json")
if schema_json_path.exists():
try:
with open(schema_json_path, "r", encoding="utf-8") as file_obj:
return {"success": True, "schema": json.load(file_obj)}
except Exception as e:
logger.warning(f"读取 config_schema.json 失败,回退到自动推断: {e}")
try:
runtime_snapshot = await _inspect_plugin_config_via_runtime(plugin_id)
except ValueError as exc:
logger.warning(f"插件 {plugin_id} 配置 Schema 解析失败,将回退到弱推断: {exc}")
runtime_snapshot = None
current_config: Any = component_query_service.get_plugin_default_config(plugin_id) or {}
config_path = resolve_plugin_file_path(plugin_path, "config.toml")
if config_path.exists():
with open(config_path, "r", encoding="utf-8") as file_obj:
current_config = tomlkit.load(file_obj)
if runtime_snapshot is not None and runtime_snapshot.config_schema:
return {"success": True, "schema": dict(runtime_snapshot.config_schema)}
current_config: Any = (
dict(runtime_snapshot.normalized_config)
if runtime_snapshot is not None
else _load_plugin_config_from_disk(plugin_path)
)
return {"success": True, "schema": _build_schema_from_current_config(plugin_id, current_config)}
except HTTPException:
@@ -375,15 +421,24 @@ async def get_plugin_config(plugin_id: str, maibot_session: Optional[str] = Cook
raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}")
config_path = resolve_plugin_file_path(plugin_path, "config.toml")
try:
runtime_snapshot = await _inspect_plugin_config_via_runtime(plugin_id)
except ValueError as exc:
logger.warning(f"插件 {plugin_id} 配置读取失败,将回退到磁盘内容: {exc}")
runtime_snapshot = None
if runtime_snapshot is not None:
message = "配置文件不存在,已返回默认配置" if not config_path.exists() else ""
return {
"success": True,
"config": dict(runtime_snapshot.normalized_config),
"message": message,
}
if not config_path.exists():
default_config = component_query_service.get_plugin_default_config(plugin_id)
if isinstance(default_config, dict):
return {"success": True, "config": default_config, "message": "配置文件不存在,已返回默认配置"}
return {"success": True, "config": {}, "message": "配置文件不存在"}
with open(config_path, "r", encoding="utf-8") as file_obj:
config = tomlkit.load(file_obj)
return {"success": True, "config": dict(config)}
return {"success": True, "config": _load_plugin_config_from_disk(plugin_path)}
except HTTPException:
raise
except Exception as e:
@@ -412,6 +467,10 @@ async def update_plugin_config(
logger.info(f"更新插件配置: {plugin_id}")
try:
plugin_path = find_plugin_path_by_id(plugin_id)
if plugin_path is None:
raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}")
config_data = request.config or {}
if isinstance(config_data, dict):
config_data = normalize_dotted_keys(config_data)
@@ -419,12 +478,13 @@ async def update_plugin_config(
if isinstance(runtime_validated_config, dict):
config_data = runtime_validated_config
else:
plugin_schema = component_query_service.get_plugin_config_schema(plugin_id)
if isinstance(plugin_schema, dict) and plugin_schema:
_coerce_config_by_plugin_schema(plugin_schema, config_data)
plugin_path = find_plugin_path_by_id(plugin_id)
if plugin_path is None:
raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}")
runtime_snapshot = await _inspect_plugin_config_via_runtime(
plugin_id,
config_data,
use_provided_config=True,
)
if runtime_snapshot is not None and runtime_snapshot.config_schema:
_coerce_config_by_plugin_schema(dict(runtime_snapshot.config_schema), config_data)
config_path = resolve_plugin_file_path(plugin_path, "config.toml")
backup_path = backup_file(config_path, "backup")
@@ -498,17 +558,29 @@ async def toggle_plugin(plugin_id: str, maibot_session: Optional[str] = Cookie(N
raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}")
config_path = resolve_plugin_file_path(plugin_path, "config.toml")
default_config = component_query_service.get_plugin_default_config(plugin_id)
config = _build_toml_document(default_config if isinstance(default_config, dict) else {})
if config_path.exists():
with open(config_path, "r", encoding="utf-8") as file_obj:
config = tomlkit.load(file_obj)
try:
runtime_snapshot = await _inspect_plugin_config_via_runtime(plugin_id)
except ValueError as exc:
logger.warning(f"插件 {plugin_id} 状态切换前配置解析失败,将回退到磁盘内容: {exc}")
runtime_snapshot = None
if "plugin" not in config:
current_config = (
dict(runtime_snapshot.normalized_config)
if runtime_snapshot is not None
else _load_plugin_config_from_disk(plugin_path)
)
config = _build_toml_document(current_config)
plugin_section = config.get("plugin")
if plugin_section is None or not hasattr(plugin_section, "get"):
config["plugin"] = tomlkit.table()
plugin_config = cast(Any, config["plugin"])
current_enabled = bool(plugin_config.get("enabled", True))
current_enabled = (
bool(runtime_snapshot.enabled)
if runtime_snapshot is not None
else bool(plugin_config.get("enabled", True))
)
new_enabled = not current_enabled
plugin_config["enabled"] = new_enabled
save_toml_with_format(config, str(config_path))
@@ -519,7 +591,7 @@ async def toggle_plugin(plugin_id: str, maibot_session: Optional[str] = Cookie(N
"success": True,
"enabled": new_enabled,
"message": f"插件已{status}",
"note": "状态更改将在下次加载插件时生效",
"note": "状态更改将自动热更新到对应插件",
}
except HTTPException:
raise

View File

@@ -0,0 +1,28 @@
"""插件运行时相关 WebUI 路由。"""
from typing import Optional
from fastapi import APIRouter, Cookie
from src.plugin_runtime.component_query import component_query_service
from .schemas import HookSpecListResponse, HookSpecResponse
from .support import require_plugin_token
router = APIRouter()
@router.get("/runtime/hooks", response_model=HookSpecListResponse)
async def list_runtime_hook_specs(maibot_session: Optional[str] = Cookie(None)) -> HookSpecListResponse:
"""返回当前插件运行时公开的 Hook 规格清单。
Args:
maibot_session: 当前 WebUI 会话令牌。
Returns:
HookSpecListResponse: Hook 规格列表响应。
"""
require_plugin_token(maibot_session)
hooks = [HookSpecResponse(**hook_data) for hook_data in component_query_service.list_hook_specs()]
return HookSpecListResponse(success=True, hooks=hooks)

View File

@@ -111,3 +111,19 @@ class UpdatePluginConfigRequest(BaseModel):
class UpdatePluginRawConfigRequest(BaseModel):
config: str = Field(..., description="原始 TOML 配置内容")
class HookSpecResponse(BaseModel):
name: str = Field(..., description="Hook 名称")
description: str = Field("", description="Hook 描述")
parameters_schema: Dict[str, Any] = Field(default_factory=dict, description="Hook 参数模型")
default_timeout_ms: int = Field(..., description="默认超时毫秒数")
allow_blocking: bool = Field(..., description="是否允许 blocking 处理器")
allow_observe: bool = Field(..., description="是否允许 observe 处理器")
allow_abort: bool = Field(..., description="是否允许 abort")
allow_kwargs_mutation: bool = Field(..., description="是否允许修改 kwargs")
class HookSpecListResponse(BaseModel):
success: bool = Field(..., description="是否成功")
hooks: List[HookSpecResponse] = Field(default_factory=list, description="Hook 规格列表")