feat: 添加插件配置版本管理,优化配置归一化流程

This commit is contained in:
DrSmoothl
2026-04-04 12:38:45 +08:00
parent 81f7754050
commit e44e4245f5
3 changed files with 382 additions and 37 deletions

View File

@@ -26,6 +26,8 @@ from src.webui.routers.plugin.schemas import UpdatePluginConfigRequest
class _DemoConfigPlugin: class _DemoConfigPlugin:
"""用于测试 Runner 配置归一化流程的伪插件。""" """用于测试 Runner 配置归一化流程的伪插件。"""
_config_version: str = "2.0.0"
def __init__(self) -> None: def __init__(self) -> None:
"""初始化测试插件状态。""" """初始化测试插件状态。"""
@@ -43,7 +45,8 @@ class _DemoConfigPlugin:
current_config = dict(config_data or {}) current_config = dict(config_data or {})
plugin_section = dict(current_config.get("plugin", {})) plugin_section = dict(current_config.get("plugin", {}))
changed = "retry_count" not in plugin_section changed = "retry_count" not in plugin_section or "config_version" not in plugin_section
plugin_section.setdefault("config_version", self._config_version)
plugin_section.setdefault("enabled", True) plugin_section.setdefault("enabled", True)
plugin_section.setdefault("retry_count", 3) plugin_section.setdefault("retry_count", 3)
return {"plugin": plugin_section}, changed return {"plugin": plugin_section}, changed
@@ -64,7 +67,7 @@ class _DemoConfigPlugin:
Dict[str, Any]: 默认配置字典。 Dict[str, Any]: 默认配置字典。
""" """
return {"plugin": {"enabled": True, "retry_count": 3}} return {"plugin": {"config_version": self._config_version, "enabled": True, "retry_count": 3}}
def get_webui_config_schema( def get_webui_config_schema(
self, self,
@@ -131,6 +134,7 @@ class _StrictConfigPlugin:
current_config = dict(config_data or {}) current_config = dict(config_data or {})
plugin_section = dict(current_config.get("plugin", {})) plugin_section = dict(current_config.get("plugin", {}))
plugin_section.setdefault("config_version", "2.0.0")
retry_count = int(plugin_section.get("retry_count", 0)) retry_count = int(plugin_section.get("retry_count", 0))
if retry_count < 0: if retry_count < 0:
raise ValueError("重试次数不能小于 0") raise ValueError("重试次数不能小于 0")
@@ -146,6 +150,15 @@ class _StrictConfigPlugin:
del config del config
def get_default_config(self) -> Dict[str, Any]:
"""返回测试插件的默认配置。
Returns:
Dict[str, Any]: 默认配置字典。
"""
return {"plugin": {"config_version": "2.0.0", "enabled": True, "retry_count": 0}}
def test_runner_apply_plugin_config_generates_config_file(tmp_path: Path) -> None: def test_runner_apply_plugin_config_generates_config_file(tmp_path: Path) -> None:
"""Runner 注入配置时应自动补齐并落盘 config.toml。""" """Runner 注入配置时应自动补齐并落盘 config.toml。"""
@@ -158,19 +171,22 @@ def test_runner_apply_plugin_config_generates_config_file(tmp_path: Path) -> Non
) )
meta = SimpleNamespace(plugin_id="demo.plugin", plugin_dir=str(tmp_path), instance=plugin) meta = SimpleNamespace(plugin_id="demo.plugin", plugin_dir=str(tmp_path), instance=plugin)
runner._apply_plugin_config(cast(Any, meta), config_data={"plugin": {"enabled": False}}) runner._apply_plugin_config(
cast(Any, meta),
config_data={"plugin": {"config_version": "2.0.0", "enabled": False}},
)
config_path = tmp_path / "config.toml" config_path = tmp_path / "config.toml"
assert config_path.exists() assert config_path.exists()
assert plugin.received_config == {"plugin": {"enabled": False, "retry_count": 3}} assert plugin.received_config == {"plugin": {"config_version": "2.0.0", "enabled": False, "retry_count": 3}}
with config_path.open("rb") as handle: with config_path.open("rb") as handle:
saved_config = tomllib.load(handle) saved_config = tomllib.load(handle)
assert saved_config == {"plugin": {"enabled": False, "retry_count": 3}} assert saved_config == {"plugin": {"config_version": "2.0.0", "enabled": False, "retry_count": 3}}
def test_runner_apply_plugin_config_preserves_existing_comments(tmp_path: Path) -> None: def test_runner_apply_plugin_config_preserves_existing_comments(tmp_path: Path) -> None:
"""Runner 补齐配置时应尽量保留现有 config.toml 注释。""" """Runner 在版本升级时应尽量保留现有 config.toml 注释。"""
plugin = _DemoConfigPlugin() plugin = _DemoConfigPlugin()
runner = PluginRunner( runner = PluginRunner(
@@ -181,7 +197,7 @@ def test_runner_apply_plugin_config_preserves_existing_comments(tmp_path: Path)
meta = SimpleNamespace(plugin_id="demo.plugin", plugin_dir=str(tmp_path), instance=plugin) meta = SimpleNamespace(plugin_id="demo.plugin", plugin_dir=str(tmp_path), instance=plugin)
config_path = tmp_path / "config.toml" config_path = tmp_path / "config.toml"
config_path.write_text( config_path.write_text(
'# 插件配置头注释\n[plugin]\nenabled = false # 启用开关注释\n', '# 插件配置头注释\n[plugin]\nconfig_version = "1.0.0"\nenabled = false # 启用开关注释\n',
encoding="utf-8", encoding="utf-8",
) )
@@ -193,7 +209,44 @@ def test_runner_apply_plugin_config_preserves_existing_comments(tmp_path: Path)
with config_path.open("rb") as handle: with config_path.open("rb") as handle:
saved_config = tomllib.load(handle) saved_config = tomllib.load(handle)
assert saved_config == {"plugin": {"enabled": False, "retry_count": 3}} assert saved_config == {"plugin": {"config_version": "2.0.0", "enabled": False, "retry_count": 3}}
def test_runner_apply_plugin_config_same_version_does_not_rewrite_file(tmp_path: Path) -> None:
"""Runner 在配置版本未变化时不应仅因补齐默认值而重写文件。"""
plugin = _DemoConfigPlugin()
runner = PluginRunner(
host_address="ipc://unused",
session_token="session-token",
plugin_dirs=[],
)
meta = SimpleNamespace(plugin_id="demo.plugin", plugin_dir=str(tmp_path), instance=plugin)
config_path = tmp_path / "config.toml"
original_config_text = '# 原始注释\n[plugin]\nconfig_version = "2.0.0"\nenabled = false\n'
config_path.write_text(original_config_text, encoding="utf-8")
runner._apply_plugin_config(cast(Any, meta))
assert plugin.received_config == {"plugin": {"config_version": "2.0.0", "enabled": False, "retry_count": 3}}
assert config_path.read_text(encoding="utf-8") == original_config_text
def test_runner_apply_plugin_config_requires_config_version(tmp_path: Path) -> None:
"""Runner 应拒绝缺少配置版本号的插件配置文件。"""
plugin = _DemoConfigPlugin()
runner = PluginRunner(
host_address="ipc://unused",
session_token="session-token",
plugin_dirs=[],
)
meta = SimpleNamespace(plugin_id="demo.plugin", plugin_dir=str(tmp_path), instance=plugin)
config_path = tmp_path / "config.toml"
config_path.write_text("[plugin]\nenabled = true\n", encoding="utf-8")
with pytest.raises(ValueError, match="config_version"):
runner._apply_plugin_config(cast(Any, meta))
def test_component_query_service_returns_plugin_config_schema(monkeypatch: Any) -> None: def test_component_query_service_returns_plugin_config_schema(monkeypatch: Any) -> None:
@@ -246,14 +299,18 @@ async def test_runner_validate_plugin_config_handler_returns_normalized_config(m
message_type=MessageType.REQUEST, message_type=MessageType.REQUEST,
method="plugin.validate_config", method="plugin.validate_config",
plugin_id="demo.plugin", plugin_id="demo.plugin",
payload=ValidatePluginConfigPayload(config_data={"plugin": {"enabled": False}}).model_dump(), payload=ValidatePluginConfigPayload(
config_data={"plugin": {"config_version": "2.0.0", "enabled": False}}
).model_dump(),
) )
response = await runner._handle_validate_plugin_config(envelope) response = await runner._handle_validate_plugin_config(envelope)
assert response.error is None assert response.error is None
assert response.payload["success"] is True assert response.payload["success"] is True
assert response.payload["normalized_config"] == {"plugin": {"enabled": False, "retry_count": 3}} assert response.payload["normalized_config"] == {
"plugin": {"config_version": "2.0.0", "enabled": False, "retry_count": 3}
}
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -308,8 +365,12 @@ async def test_runner_inspect_plugin_config_handler_supports_unloaded_plugin(
assert response.error is None assert response.error is None
assert response.payload["success"] is True assert response.payload["success"] is True
assert response.payload["enabled"] is False assert response.payload["enabled"] is False
assert response.payload["normalized_config"] == {"plugin": {"enabled": False, "retry_count": 3}} assert response.payload["normalized_config"] == {
assert response.payload["default_config"] == {"plugin": {"enabled": True, "retry_count": 3}} "plugin": {"config_version": "2.0.0", "enabled": False, "retry_count": 3}
}
assert response.payload["default_config"] == {
"plugin": {"config_version": "2.0.0", "enabled": True, "retry_count": 3}
}
assert purged_plugins == [("demo.plugin", "/tmp/demo-plugin")] assert purged_plugins == [("demo.plugin", "/tmp/demo-plugin")]
@@ -333,7 +394,9 @@ async def test_runner_validate_plugin_config_handler_returns_error_on_invalid_co
message_type=MessageType.REQUEST, message_type=MessageType.REQUEST,
method="plugin.validate_config", method="plugin.validate_config",
plugin_id="demo.plugin", plugin_id="demo.plugin",
payload=ValidatePluginConfigPayload(config_data={"plugin": {"retry_count": -1}}).model_dump(), payload=ValidatePluginConfigPayload(
config_data={"plugin": {"config_version": "2.0.0", "retry_count": -1}}
).model_dump(),
) )
response = await runner._handle_validate_plugin_config(envelope) response = await runner._handle_validate_plugin_config(envelope)
@@ -363,10 +426,37 @@ async def test_update_plugin_config_prefers_runtime_validation(
""" """
assert plugin_id == "demo.plugin" assert plugin_id == "demo.plugin"
assert config_data == {"plugin": {"enabled": False}} assert config_data == {"plugin": {"config_version": "2.0.0", "enabled": False, "retry_count": 3}}
return {"plugin": {"enabled": False, "retry_count": 3}} return {"plugin": {"config_version": "2.0.0", "enabled": False, "retry_count": 3}}
fake_runtime_manager = SimpleNamespace(validate_plugin_config=_mock_validate_plugin_config) async def _mock_inspect_plugin_config(
plugin_id: str,
config_data: Optional[Dict[str, Any]] = None,
*,
use_provided_config: bool = False,
) -> SimpleNamespace | None:
"""返回运行时配置快照。
Args:
plugin_id: 插件 ID。
config_data: 可选配置。
use_provided_config: 是否使用传入配置。
Returns:
SimpleNamespace | None: 运行时配置快照。
"""
del config_data, use_provided_config
if plugin_id != "demo.plugin":
return None
return SimpleNamespace(
normalized_config={"plugin": {"config_version": "2.0.0", "enabled": True, "retry_count": 3}}
)
fake_runtime_manager = SimpleNamespace(
inspect_plugin_config=_mock_inspect_plugin_config,
validate_plugin_config=_mock_validate_plugin_config,
)
monkeypatch.setattr( monkeypatch.setattr(
"src.webui.routers.plugin.config_routes.require_plugin_token", "src.webui.routers.plugin.config_routes.require_plugin_token",
@@ -390,7 +480,7 @@ async def test_update_plugin_config_prefers_runtime_validation(
assert response["success"] is True assert response["success"] is True
with config_path.open("rb") as handle: with config_path.open("rb") as handle:
saved_config = tomllib.load(handle) saved_config = tomllib.load(handle)
assert saved_config == {"plugin": {"enabled": False, "retry_count": 3}} assert saved_config == {"plugin": {"config_version": "2.0.0", "enabled": False, "retry_count": 3}}
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -432,7 +522,7 @@ async def test_webui_config_endpoints_use_runtime_inspection_for_unloaded_plugin
"sections": {"plugin": {"fields": {}}}, "sections": {"plugin": {"fields": {}}},
"layout": {"type": "auto", "tabs": []}, "layout": {"type": "auto", "tabs": []},
}, },
normalized_config={"plugin": {"enabled": True, "retry_count": 3}}, normalized_config={"plugin": {"config_version": "2.0.0", "enabled": True, "retry_count": 3}},
enabled=True, enabled=True,
) )
@@ -458,6 +548,6 @@ async def test_webui_config_endpoints_use_runtime_inspection_for_unloaded_plugin
assert schema_response["schema"]["plugin_id"] == "demo.plugin" assert schema_response["schema"]["plugin_id"] == "demo.plugin"
assert config_response == { assert config_response == {
"success": True, "success": True,
"config": {"plugin": {"enabled": True, "retry_count": 3}}, "config": {"plugin": {"config_version": "2.0.0", "enabled": True, "retry_count": 3}},
"message": "配置文件不存在,已返回默认配置", "message": "配置文件不存在,已返回默认配置",
} }

View File

@@ -10,6 +10,7 @@
""" """
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Protocol, Set, Tuple, cast from typing import Any, Callable, Dict, List, Optional, Protocol, Set, Tuple, cast
@@ -28,6 +29,7 @@ import tomllib
import tomlkit import tomlkit
from src.common.logger import get_console_handler, get_logger, initialize_logging from src.common.logger import get_console_handler, get_logger, initialize_logging
from src.config.config_utils import compare_versions
from src.plugin_runtime import ( from src.plugin_runtime import (
ENV_BLOCKED_PLUGIN_REASONS, ENV_BLOCKED_PLUGIN_REASONS,
ENV_EXTERNAL_PLUGIN_IDS, ENV_EXTERNAL_PLUGIN_IDS,
@@ -154,6 +156,122 @@ class PluginActivationStatus(str, Enum):
FAILED = "failed" FAILED = "failed"
@dataclass(frozen=True)
class PluginConfigNormalizationResult:
"""描述插件配置归一化结果。"""
normalized_config: Dict[str, Any]
changed: bool
should_persist: bool
class PluginConfigVersionError(ValueError):
"""插件配置版本不合法时抛出的异常。"""
def _deep_copy_plugin_config_value(value: Any) -> Any:
"""递归复制插件配置值。
Args:
value: 待复制的任意配置值。
Returns:
Any: 深复制后的配置值。
"""
if isinstance(value, Mapping):
return _deep_copy_plugin_config_mapping(value)
if isinstance(value, list):
return [_deep_copy_plugin_config_value(item) for item in value]
return value
def _deep_copy_plugin_config_mapping(value: Mapping[str, Any]) -> Dict[str, Any]:
"""递归复制插件配置字典。
Args:
value: 待复制的插件配置映射。
Returns:
Dict[str, Any]: 深复制后的插件配置字典。
"""
return {str(key): _deep_copy_plugin_config_value(item) for key, item in value.items()}
def _overlay_plugin_config_fields(target: Dict[str, Any], source: Mapping[str, Any]) -> None:
"""将旧配置中的已有字段覆盖到新配置骨架中。
Args:
target: 以最新默认配置构造出的目标配置字典。
source: 旧版本配置字典。
"""
for key, source_value in source.items():
if key not in target:
continue
if key == "config_version":
continue
target_value = target[key]
if isinstance(target_value, dict) and isinstance(source_value, Mapping):
_overlay_plugin_config_fields(target_value, source_value)
continue
target[key] = _deep_copy_plugin_config_value(source_value)
def extract_plugin_config_version(config_data: Mapping[str, Any]) -> str:
"""提取插件配置中的版本号。
Args:
config_data: 插件配置字典。
Returns:
str: ``plugin.config_version`` 的规范化字符串值。
Raises:
PluginConfigVersionError: 当缺少 ``[plugin]`` 配置节或 ``config_version``
字段为空时抛出。
"""
plugin_section = config_data.get("plugin")
if not isinstance(plugin_section, Mapping):
raise PluginConfigVersionError(
"插件配置文件缺少 [plugin] 配置节,且必须提供 plugin.config_version 版本号"
)
version_value = plugin_section.get("config_version")
normalized_version = str(version_value or "").strip()
if not normalized_version:
raise PluginConfigVersionError(
"插件配置文件缺少 plugin.config_version 版本号,当前版本策略不再兼容无版本配置"
)
return normalized_version
def rebuild_plugin_config_data(
default_config: Mapping[str, Any],
current_config: Mapping[str, Any],
) -> Dict[str, Any]:
"""基于默认结构重建插件配置。
该方法用于版本升级场景:以最新默认配置为骨架,仅迁移仍然存在的旧字段值,
从而达到“补齐新增字段、移除废弃字段、保留用户已有值”的效果。
Args:
default_config: 最新默认配置内容。
current_config: 旧版本配置内容。
Returns:
Dict[str, Any]: 按最新结构重建后的配置字典。
"""
rebuilt_config = _deep_copy_plugin_config_mapping(default_config)
_overlay_plugin_config_fields(rebuilt_config, current_config)
return rebuilt_config
def _install_shutdown_signal_handlers( def _install_shutdown_signal_handlers(
mark_runner_shutting_down: Callable[[], None], mark_runner_shutting_down: Callable[[], None],
loop: Optional[asyncio.AbstractEventLoop] = None, loop: Optional[asyncio.AbstractEventLoop] = None,
@@ -414,12 +532,19 @@ class PluginRunner:
Dict[str, Any]: 归一化后的当前插件配置。 Dict[str, Any]: 归一化后的当前插件配置。
""" """
instance = meta.instance instance = meta.instance
raw_config = config_data if config_data is not None else self._load_plugin_config(meta.plugin_dir)
plugin_config, should_persist = self._normalize_plugin_config(instance, raw_config)
config_path = Path(meta.plugin_dir) / "config.toml"
default_config = self._get_plugin_default_config(instance) default_config = self._get_plugin_default_config(instance)
raw_config = config_data if config_data is not None else self._load_plugin_config(meta.plugin_dir, meta.plugin_id)
normalization_result = self._normalize_plugin_config(
instance,
raw_config,
default_config=default_config,
suppress_errors=False,
enforce_version=True,
)
plugin_config = normalization_result.normalized_config
config_path = Path(meta.plugin_dir) / "config.toml"
should_initialize_file = not config_path.exists() and bool(default_config) should_initialize_file = not config_path.exists() and bool(default_config)
if should_persist or should_initialize_file: if normalization_result.should_persist or should_initialize_file:
self._save_plugin_config(meta.plugin_dir, plugin_config) self._save_plugin_config(meta.plugin_dir, plugin_config)
if hasattr(instance, "set_plugin_config"): if hasattr(instance, "set_plugin_config"):
try: try:
@@ -433,30 +558,105 @@ class PluginRunner:
instance: object, instance: object,
config_data: Optional[Dict[str, Any]], config_data: Optional[Dict[str, Any]],
*, *,
default_config: Optional[Dict[str, Any]] = None,
suppress_errors: bool = True, suppress_errors: bool = True,
) -> Tuple[Dict[str, Any], bool]: enforce_version: bool = True,
) -> PluginConfigNormalizationResult:
"""对插件配置做统一归一化处理。 """对插件配置做统一归一化处理。
Args: Args:
instance: 插件实例。 instance: 插件实例。
config_data: 原始配置数据。 config_data: 原始配置数据。
default_config: 插件声明的默认配置。
suppress_errors: 是否在归一化失败时吞掉异常并回退原始配置。 suppress_errors: 是否在归一化失败时吞掉异常并回退原始配置。
enforce_version: 是否强制执行 ``plugin.config_version`` 版本检查。
Returns: Returns:
Tuple[Dict[str, Any], bool]: 归一化后的配置,以及是否需要回写文件。 PluginConfigNormalizationResult: 归一化结果、是否发生变更以及是否应写回文件。
""" """
normalized_config = dict(config_data or {}) raw_config = dict(config_data or {})
if not hasattr(instance, "normalize_plugin_config"): latest_default_config = default_config if default_config is not None else self._get_plugin_default_config(instance)
return normalized_config, False config_for_normalize = rebuild_plugin_config_data(raw_config, {})
should_persist = False
try: try:
return cast(_ConfigAwarePlugin, instance).normalize_plugin_config(normalized_config) if latest_default_config:
if enforce_version:
config_for_normalize, should_persist = self._prepare_plugin_config_for_version_update(
raw_config=raw_config,
default_config=latest_default_config,
)
elif not raw_config:
config_for_normalize = rebuild_plugin_config_data(latest_default_config, {})
except Exception as exc:
if not suppress_errors:
raise
logger.warning(f"插件配置版本检查失败,将回退为原始配置: {exc}")
return PluginConfigNormalizationResult(
normalized_config=raw_config,
changed=False,
should_persist=False,
)
if not hasattr(instance, "normalize_plugin_config"):
return PluginConfigNormalizationResult(
normalized_config=config_for_normalize,
changed=config_for_normalize != raw_config,
should_persist=should_persist,
)
try:
normalized_config, normalized_changed = cast(_ConfigAwarePlugin, instance).normalize_plugin_config(
config_for_normalize
)
except Exception as exc: except Exception as exc:
if not suppress_errors: if not suppress_errors:
raise raise
logger.warning(f"插件配置归一化失败,将回退为原始配置: {exc}") logger.warning(f"插件配置归一化失败,将回退为原始配置: {exc}")
return normalized_config, False return PluginConfigNormalizationResult(
normalized_config=raw_config,
changed=False,
should_persist=False,
)
return PluginConfigNormalizationResult(
normalized_config=normalized_config,
changed=normalized_changed or normalized_config != raw_config,
should_persist=should_persist,
)
@staticmethod
def _prepare_plugin_config_for_version_update(
raw_config: Mapping[str, Any],
default_config: Mapping[str, Any],
) -> Tuple[Dict[str, Any], bool]:
"""基于配置版本决定是否需要重建插件配置。
Args:
raw_config: 当前磁盘上的插件配置。
default_config: 插件最新默认配置。
Returns:
Tuple[Dict[str, Any], bool]: 用于后续归一化的配置副本,以及是否需要写回文件。
Raises:
PluginConfigVersionError: 当默认配置或当前配置缺少版本号时抛出。
"""
if not default_config:
return rebuild_plugin_config_data(raw_config, {}), False
latest_version = extract_plugin_config_version(default_config)
if not raw_config:
return rebuild_plugin_config_data(default_config, {}), False
current_version = extract_plugin_config_version(raw_config)
if compare_versions(current_version, latest_version):
logger.info(f"检测到插件配置版本升级: {current_version} -> {latest_version}")
return rebuild_plugin_config_data(default_config, raw_config), True
return rebuild_plugin_config_data(raw_config, {}), False
@staticmethod @staticmethod
def _merge_plugin_config_document(target: Any, source: Any) -> None: def _merge_plugin_config_document(target: Any, source: Any) -> None:
@@ -642,6 +842,7 @@ class PluginRunner:
config_data: Optional[Dict[str, Any]] = None, config_data: Optional[Dict[str, Any]] = None,
use_provided_config: bool = False, use_provided_config: bool = False,
suppress_errors: bool = True, suppress_errors: bool = True,
enforce_version: bool = True,
) -> InspectPluginConfigResultPayload: ) -> InspectPluginConfigResultPayload:
"""解析插件代码定义的配置元数据。 """解析插件代码定义的配置元数据。
@@ -650,6 +851,7 @@ class PluginRunner:
config_data: 可选的配置内容。 config_data: 可选的配置内容。
use_provided_config: 是否优先使用传入的配置内容。 use_provided_config: 是否优先使用传入的配置内容。
suppress_errors: 是否在归一化失败时回退原始配置。 suppress_errors: 是否在归一化失败时回退原始配置。
enforce_version: 是否强制校验 ``plugin.config_version``。
Returns: Returns:
InspectPluginConfigResultPayload: 结构化解析结果。 InspectPluginConfigResultPayload: 结构化解析结果。
@@ -659,14 +861,18 @@ class PluginRunner:
if use_provided_config and config_data is None: if use_provided_config and config_data is None:
raw_config = {} raw_config = {}
normalized_config, changed = self._normalize_plugin_config( default_config = self._get_plugin_default_config(meta.instance)
normalization_result = self._normalize_plugin_config(
meta.instance, meta.instance,
raw_config, raw_config,
default_config=default_config,
suppress_errors=suppress_errors, suppress_errors=suppress_errors,
enforce_version=enforce_version,
) )
default_config = self._get_plugin_default_config(meta.instance) normalized_config = normalization_result.normalized_config
changed = normalization_result.changed
if not normalized_config and not raw_config and default_config: if not normalized_config and not raw_config and default_config:
normalized_config = dict(default_config) normalized_config = rebuild_plugin_config_data(default_config, {})
changed = True changed = True
return InspectPluginConfigResultPayload( return InspectPluginConfigResultPayload(
@@ -946,7 +1152,16 @@ class PluginRunner:
PluginActivationStatus: 插件激活结果。 PluginActivationStatus: 插件激活结果。
""" """
self._inject_context(meta.plugin_id, meta.instance) self._inject_context(meta.plugin_id, meta.instance)
plugin_config = self._apply_plugin_config(meta) try:
plugin_config = self._apply_plugin_config(meta)
except PluginConfigVersionError as exc:
logger.error(f"插件 {meta.plugin_id} 配置版本非法: {exc}")
self._loader.purge_plugin_modules(meta.plugin_id, meta.plugin_dir)
return PluginActivationStatus.FAILED
except Exception as exc:
logger.error(f"插件 {meta.plugin_id} 配置加载失败: {exc}", exc_info=True)
self._loader.purge_plugin_modules(meta.plugin_id, meta.plugin_dir)
return PluginActivationStatus.FAILED
if not self._is_plugin_enabled(plugin_config): if not self._is_plugin_enabled(plugin_config):
logger.info(f"插件 {meta.plugin_id} 已在配置中禁用,跳过激活") logger.info(f"插件 {meta.plugin_id} 已在配置中禁用,跳过激活")
self._loader.purge_plugin_modules(meta.plugin_id, meta.plugin_dir) self._loader.purge_plugin_modules(meta.plugin_id, meta.plugin_dir)
@@ -1604,7 +1819,8 @@ class PluginRunner:
meta, meta,
config_data=payload.config_data, config_data=payload.config_data,
use_provided_config=payload.use_provided_config, use_provided_config=payload.use_provided_config,
suppress_errors=True, suppress_errors=payload.use_provided_config,
enforce_version=not payload.use_provided_config,
) )
except Exception as exc: except Exception as exc:
return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc)) return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc))
@@ -1643,6 +1859,7 @@ class PluginRunner:
config_data=payload.config_data, config_data=payload.config_data,
use_provided_config=True, use_provided_config=True,
suppress_errors=False, suppress_errors=False,
enforce_version=True,
) )
except Exception as exc: except Exception as exc:
return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc)) return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc))

View File

@@ -3,8 +3,8 @@
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional, cast from typing import Any, Dict, Optional, cast
import tomlkit
from fastapi import APIRouter, Cookie, HTTPException from fastapi import APIRouter, Cookie, HTTPException
import tomlkit
from src.common.logger import get_logger from src.common.logger import get_logger
from src.plugin_runtime.protocol.envelope import InspectPluginConfigResultPayload from src.plugin_runtime.protocol.envelope import InspectPluginConfigResultPayload
@@ -13,6 +13,7 @@ from src.webui.utils.toml_utils import save_toml_with_format
from .schemas import UpdatePluginConfigRequest, UpdatePluginRawConfigRequest from .schemas import UpdatePluginConfigRequest, UpdatePluginRawConfigRequest
from .support import ( from .support import (
backup_file, backup_file,
deep_merge,
find_plugin_path_by_id, find_plugin_path_by_id,
get_plugin_config_path, get_plugin_config_path,
normalize_dotted_keys, normalize_dotted_keys,
@@ -26,6 +27,15 @@ router = APIRouter()
def _to_builtin_data(obj: Any) -> Any: def _to_builtin_data(obj: Any) -> Any:
"""将 TOML 对象递归转换为内建 Python 数据。
Args:
obj: 原始对象。
Returns:
Any: 转换后的内建数据结构。
"""
if hasattr(obj, "unwrap"): if hasattr(obj, "unwrap"):
try: try:
obj = obj.unwrap() obj = obj.unwrap()
@@ -39,6 +49,22 @@ def _to_builtin_data(obj: Any) -> Any:
return obj return obj
def _merge_plugin_config_patch(base_config: Dict[str, Any], patch_config: Dict[str, Any]) -> Dict[str, Any]:
"""以现有配置为基线合并本次插件配置改动。
Args:
base_config: 当前完整配置。
patch_config: 本次提交的局部配置改动。
Returns:
Dict[str, Any]: 合并后的完整配置。
"""
merged_config = cast(Dict[str, Any], _to_builtin_data(base_config))
deep_merge(merged_config, patch_config)
return merged_config
def _build_schema_from_current_config(plugin_id: str, current_config: Any) -> Dict[str, Any]: def _build_schema_from_current_config(plugin_id: str, current_config: Any) -> Dict[str, Any]:
"""根据当前配置内容自动推断一个兜底 Schema。 """根据当前配置内容自动推断一个兜底 Schema。
@@ -489,7 +515,19 @@ async def update_plugin_config(
config_data = request.config or {} config_data = request.config or {}
if isinstance(config_data, dict): if isinstance(config_data, dict):
config_data = normalize_dotted_keys(config_data) config_patch = normalize_dotted_keys(config_data)
runtime_snapshot = None
try:
runtime_snapshot = await _inspect_plugin_config_via_runtime(plugin_id)
except ValueError as exc:
logger.warning(f"插件 {plugin_id} 保存前配置检查失败,将回退到磁盘内容: {exc}")
base_config = (
dict(runtime_snapshot.normalized_config)
if runtime_snapshot is not None
else _load_plugin_config_from_disk(plugin_path)
)
config_data = _merge_plugin_config_patch(base_config, config_patch)
runtime_validated_config = await _validate_plugin_config_via_runtime(plugin_id, config_data) runtime_validated_config = await _validate_plugin_config_via_runtime(plugin_id, config_data)
if isinstance(runtime_validated_config, dict): if isinstance(runtime_validated_config, dict):
config_data = runtime_validated_config config_data = runtime_validated_config