mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-07 16:36:53 +08:00
add plugin agent management tools
This commit is contained in:
@@ -53,6 +53,10 @@ from app.agent.tools.impl.read_file import ReadFileTool
|
|||||||
from app.agent.tools.impl.browse_webpage import BrowseWebpageTool
|
from app.agent.tools.impl.browse_webpage import BrowseWebpageTool
|
||||||
from app.agent.tools.impl.query_installed_plugins import QueryInstalledPluginsTool
|
from app.agent.tools.impl.query_installed_plugins import QueryInstalledPluginsTool
|
||||||
from app.agent.tools.impl.query_plugin_capabilities import QueryPluginCapabilitiesTool
|
from app.agent.tools.impl.query_plugin_capabilities import QueryPluginCapabilitiesTool
|
||||||
|
from app.agent.tools.impl.query_plugin_config import QueryPluginConfigTool
|
||||||
|
from app.agent.tools.impl.update_plugin_config import UpdatePluginConfigTool
|
||||||
|
from app.agent.tools.impl.reload_plugin import ReloadPluginTool
|
||||||
|
from app.agent.tools.impl.query_plugin_data import QueryPluginDataTool
|
||||||
from app.agent.tools.impl.run_slash_command import RunSlashCommandTool
|
from app.agent.tools.impl.run_slash_command import RunSlashCommandTool
|
||||||
from app.agent.tools.impl.list_slash_commands import ListSlashCommandsTool
|
from app.agent.tools.impl.list_slash_commands import ListSlashCommandsTool
|
||||||
from app.agent.tools.impl.query_custom_identifiers import QueryCustomIdentifiersTool
|
from app.agent.tools.impl.query_custom_identifiers import QueryCustomIdentifiersTool
|
||||||
@@ -146,6 +150,10 @@ class MoviePilotToolFactory:
|
|||||||
BrowseWebpageTool,
|
BrowseWebpageTool,
|
||||||
QueryInstalledPluginsTool,
|
QueryInstalledPluginsTool,
|
||||||
QueryPluginCapabilitiesTool,
|
QueryPluginCapabilitiesTool,
|
||||||
|
QueryPluginConfigTool,
|
||||||
|
UpdatePluginConfigTool,
|
||||||
|
ReloadPluginTool,
|
||||||
|
QueryPluginDataTool,
|
||||||
RunSlashCommandTool,
|
RunSlashCommandTool,
|
||||||
ListSlashCommandsTool,
|
ListSlashCommandsTool,
|
||||||
QueryCustomIdentifiersTool,
|
QueryCustomIdentifiersTool,
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""插件 Agent 工具共享辅助方法"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from app.core.plugin import PluginManager
|
||||||
|
|
||||||
|
# 默认只向智能体返回一个可读预览,避免超大插件数据挤爆上下文窗口。
|
||||||
|
DEFAULT_PLUGIN_DATA_PREVIEW_CHARS = 12_000
|
||||||
|
MAX_PLUGIN_DATA_PREVIEW_CHARS = 50_000
|
||||||
|
PLUGIN_DATA_KEY_PREVIEW_LIMIT = 50
|
||||||
|
PLUGIN_DATA_TRUNCATION_SUFFIX = "\n...(插件数据内容过长,已截断)"
|
||||||
|
|
||||||
|
|
||||||
|
def get_plugin_snapshot(plugin_id: str) -> Optional[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
获取已安装插件的基础信息快照。
|
||||||
|
"""
|
||||||
|
plugin_manager = PluginManager()
|
||||||
|
for plugin in plugin_manager.get_local_plugins():
|
||||||
|
if plugin.id == plugin_id:
|
||||||
|
return {
|
||||||
|
"plugin_id": plugin.id,
|
||||||
|
"plugin_name": plugin.plugin_name,
|
||||||
|
"plugin_version": plugin.plugin_version,
|
||||||
|
"state": plugin.state,
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def clamp_preview_chars(max_chars: Optional[int]) -> int:
|
||||||
|
"""
|
||||||
|
约束插件数据预览长度,避免工具结果无限膨胀。
|
||||||
|
"""
|
||||||
|
if max_chars is None:
|
||||||
|
return DEFAULT_PLUGIN_DATA_PREVIEW_CHARS
|
||||||
|
return max(512, min(int(max_chars), MAX_PLUGIN_DATA_PREVIEW_CHARS))
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_for_agent(value: Any) -> str:
|
||||||
|
"""
|
||||||
|
将结果稳定序列化为 JSON 字符串,无法原生序列化的对象退化为字符串。
|
||||||
|
"""
|
||||||
|
return json.dumps(value, ensure_ascii=False, indent=2, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def build_preview_payload(value: Any, max_chars: Optional[int]) -> tuple[bool, int, int, str]:
|
||||||
|
"""
|
||||||
|
为可能很大的插件数据生成预览结果。
|
||||||
|
"""
|
||||||
|
serialized = serialize_for_agent(value)
|
||||||
|
if len(serialized) <= clamp_preview_chars(max_chars):
|
||||||
|
return False, len(serialized), len(serialized), serialized
|
||||||
|
|
||||||
|
preview_limit = clamp_preview_chars(max_chars)
|
||||||
|
preview = serialized[:preview_limit] + PLUGIN_DATA_TRUNCATION_SUFFIX
|
||||||
|
return True, len(serialized), len(preview), preview
|
||||||
|
|
||||||
|
|
||||||
|
def reload_plugin_runtime(plugin_id: str) -> None:
|
||||||
|
"""
|
||||||
|
重载插件并重新注册其命令、定时任务和 API。
|
||||||
|
"""
|
||||||
|
# 这些依赖只在真正执行重载时才导入,避免普通查询工具引入不必要的初始化开销。
|
||||||
|
from app.api.endpoints.plugin import register_plugin_api
|
||||||
|
from app.command import Command
|
||||||
|
from app.scheduler import Scheduler
|
||||||
|
|
||||||
|
plugin_manager = PluginManager()
|
||||||
|
plugin_manager.reload_plugin(plugin_id)
|
||||||
|
Scheduler().update_plugin_job(plugin_id)
|
||||||
|
Command().init_commands(plugin_id)
|
||||||
|
register_plugin_api(plugin_id)
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""查询插件配置工具"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Optional, Type
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.agent.tools.base import MoviePilotTool
|
||||||
|
from app.agent.tools.impl._plugin_tool_utils import get_plugin_snapshot
|
||||||
|
from app.core.plugin import PluginManager
|
||||||
|
from app.log import logger
|
||||||
|
|
||||||
|
|
||||||
|
class QueryPluginConfigInput(BaseModel):
|
||||||
|
"""查询插件配置工具的输入参数模型"""
|
||||||
|
|
||||||
|
explanation: str = Field(
|
||||||
|
...,
|
||||||
|
description="Clear explanation of why this tool is being used in the current context",
|
||||||
|
)
|
||||||
|
plugin_id: str = Field(
|
||||||
|
...,
|
||||||
|
description="The plugin ID to query. Use query_installed_plugins first to discover valid plugin IDs.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class QueryPluginConfigTool(MoviePilotTool):
|
||||||
|
name: str = "query_plugin_config"
|
||||||
|
description: str = (
|
||||||
|
"Query the saved configuration of an installed plugin. "
|
||||||
|
"Returns the current saved config and, when available, the plugin's default config model. "
|
||||||
|
"Use this before update_plugin_config so you only change the intended keys."
|
||||||
|
)
|
||||||
|
require_admin: bool = True
|
||||||
|
args_schema: Type[BaseModel] = QueryPluginConfigInput
|
||||||
|
|
||||||
|
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||||
|
"""生成友好的提示消息"""
|
||||||
|
plugin_id = kwargs.get("plugin_id", "")
|
||||||
|
return f"查询插件配置: {plugin_id}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _query_plugin_config(plugin_id: str) -> str:
|
||||||
|
"""
|
||||||
|
读取插件已保存配置,并尽量补充默认配置模型方便后续精确修改。
|
||||||
|
"""
|
||||||
|
plugin_info = get_plugin_snapshot(plugin_id)
|
||||||
|
if not plugin_info:
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"message": f"插件 {plugin_id} 不存在,请先使用 query_installed_plugins 查询有效插件 ID",
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
plugin_manager = PluginManager()
|
||||||
|
saved_config = plugin_manager.get_plugin_config(plugin_id) or {}
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
**plugin_info,
|
||||||
|
"config": saved_config,
|
||||||
|
}
|
||||||
|
|
||||||
|
# get_form 的 model 通常就是插件期望的配置结构,适合作为修改前的键参考。
|
||||||
|
plugin_instance = plugin_manager.running_plugins.get(plugin_id)
|
||||||
|
if plugin_instance and hasattr(plugin_instance, "get_form"):
|
||||||
|
try:
|
||||||
|
_form_schema, default_model = plugin_instance.get_form()
|
||||||
|
if default_model is not None:
|
||||||
|
result["default_model"] = default_model
|
||||||
|
except Exception as err:
|
||||||
|
logger.warning(f"读取插件 {plugin_id} 默认配置模型失败: {err}")
|
||||||
|
|
||||||
|
return json.dumps(result, ensure_ascii=False, indent=2, default=str)
|
||||||
|
|
||||||
|
async def run(self, plugin_id: str, **kwargs) -> str:
|
||||||
|
logger.info(f"执行工具: {self.name}, 参数: plugin_id={plugin_id}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 插件配置来自内存配置缓存和运行态插件实例,直接读取即可。
|
||||||
|
return self._query_plugin_config(plugin_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"查询插件配置失败: {e}", exc_info=True)
|
||||||
|
return json.dumps(
|
||||||
|
{"success": False, "message": f"查询插件配置时发生错误: {str(e)}"},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""查询插件数据工具"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Optional, Type
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.agent.tools.base import MoviePilotTool
|
||||||
|
from app.agent.tools.impl._plugin_tool_utils import (
|
||||||
|
PLUGIN_DATA_KEY_PREVIEW_LIMIT,
|
||||||
|
build_preview_payload,
|
||||||
|
get_plugin_snapshot,
|
||||||
|
)
|
||||||
|
from app.db.plugindata_oper import PluginDataOper
|
||||||
|
from app.log import logger
|
||||||
|
|
||||||
|
|
||||||
|
class QueryPluginDataInput(BaseModel):
|
||||||
|
"""查询插件数据工具的输入参数模型"""
|
||||||
|
|
||||||
|
explanation: str = Field(
|
||||||
|
...,
|
||||||
|
description="Clear explanation of why this tool is being used in the current context",
|
||||||
|
)
|
||||||
|
plugin_id: str = Field(
|
||||||
|
...,
|
||||||
|
description="The plugin ID to query. Use query_installed_plugins first to discover valid plugin IDs.",
|
||||||
|
)
|
||||||
|
key: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="Optional plugin data key. If omitted, returns all plugin data entries for the plugin.",
|
||||||
|
)
|
||||||
|
max_chars: Optional[int] = Field(
|
||||||
|
None,
|
||||||
|
description="Maximum number of preview characters to return when plugin data is too large. Default 12000, capped at 50000.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class QueryPluginDataTool(MoviePilotTool):
|
||||||
|
name: str = "query_plugin_data"
|
||||||
|
description: str = (
|
||||||
|
"Query persisted data of an installed plugin. "
|
||||||
|
"Optionally specify a key to read a single data item; otherwise all plugin data entries are returned. "
|
||||||
|
"When the result is too large, the tool automatically truncates it and returns a preview instead."
|
||||||
|
)
|
||||||
|
require_admin: bool = True
|
||||||
|
args_schema: Type[BaseModel] = QueryPluginDataInput
|
||||||
|
|
||||||
|
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||||
|
"""生成友好的提示消息"""
|
||||||
|
plugin_id = kwargs.get("plugin_id", "")
|
||||||
|
key = kwargs.get("key")
|
||||||
|
if key:
|
||||||
|
return f"查询插件数据: {plugin_id}.{key}"
|
||||||
|
return f"查询插件全部数据: {plugin_id}"
|
||||||
|
|
||||||
|
async def _query_plugin_data(
|
||||||
|
self, plugin_id: str, key: Optional[str] = None, max_chars: Optional[int] = None
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
插件数据改走异步 ORM 查询,避免再套一层线程池。
|
||||||
|
"""
|
||||||
|
plugin_info = get_plugin_snapshot(plugin_id)
|
||||||
|
if not plugin_info:
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"message": f"插件 {plugin_id} 不存在,请先使用 query_installed_plugins 查询有效插件 ID",
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
plugin_data_oper = PluginDataOper()
|
||||||
|
if key:
|
||||||
|
value = await plugin_data_oper.async_get_data(plugin_id, key)
|
||||||
|
if value is None:
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
**plugin_info,
|
||||||
|
"key": key,
|
||||||
|
"found": False,
|
||||||
|
"message": f"插件 {plugin_id} 没有数据项 {key}",
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
truncated, total_chars, returned_chars, preview = build_preview_payload(
|
||||||
|
value, max_chars
|
||||||
|
)
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
**plugin_info,
|
||||||
|
"key": key,
|
||||||
|
"found": True,
|
||||||
|
"truncated": truncated,
|
||||||
|
"total_chars": total_chars,
|
||||||
|
"returned_chars": returned_chars,
|
||||||
|
}
|
||||||
|
if truncated:
|
||||||
|
result["value_preview"] = preview
|
||||||
|
result["message"] = "插件数据内容过大,已截断预览"
|
||||||
|
else:
|
||||||
|
result["value"] = value
|
||||||
|
return json.dumps(result, ensure_ascii=False, indent=2, default=str)
|
||||||
|
|
||||||
|
rows = await plugin_data_oper.async_get_data_all(plugin_id) or []
|
||||||
|
data_map = {row.key: row.value for row in rows}
|
||||||
|
keys = list(data_map.keys())
|
||||||
|
key_preview = keys[:PLUGIN_DATA_KEY_PREVIEW_LIMIT]
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
**plugin_info,
|
||||||
|
"count": len(data_map),
|
||||||
|
"keys": key_preview,
|
||||||
|
"keys_truncated": len(keys) > PLUGIN_DATA_KEY_PREVIEW_LIMIT,
|
||||||
|
}
|
||||||
|
|
||||||
|
if not data_map:
|
||||||
|
result["data"] = {}
|
||||||
|
result["truncated"] = False
|
||||||
|
return json.dumps(result, ensure_ascii=False, indent=2, default=str)
|
||||||
|
|
||||||
|
truncated, total_chars, returned_chars, preview = build_preview_payload(
|
||||||
|
data_map, max_chars
|
||||||
|
)
|
||||||
|
result["truncated"] = truncated
|
||||||
|
result["total_chars"] = total_chars
|
||||||
|
result["returned_chars"] = returned_chars
|
||||||
|
if truncated:
|
||||||
|
result["data_preview"] = preview
|
||||||
|
result["message"] = "插件数据内容过大,已截断。请传入 key 精确查询单个数据项。"
|
||||||
|
else:
|
||||||
|
result["data"] = data_map
|
||||||
|
return json.dumps(result, ensure_ascii=False, indent=2, default=str)
|
||||||
|
|
||||||
|
async def run(
|
||||||
|
self,
|
||||||
|
plugin_id: str,
|
||||||
|
key: Optional[str] = None,
|
||||||
|
max_chars: Optional[int] = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> str:
|
||||||
|
logger.info(
|
||||||
|
f"执行工具: {self.name}, 参数: plugin_id={plugin_id}, key={key}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await self._query_plugin_data(plugin_id, key, max_chars)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"查询插件数据失败: {e}", exc_info=True)
|
||||||
|
return json.dumps(
|
||||||
|
{"success": False, "message": f"查询插件数据时发生错误: {str(e)}"},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""重载插件工具"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Optional, Type
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.agent.tools.base import MoviePilotTool
|
||||||
|
from app.agent.tools.impl._plugin_tool_utils import (
|
||||||
|
get_plugin_snapshot,
|
||||||
|
reload_plugin_runtime,
|
||||||
|
)
|
||||||
|
from app.log import logger
|
||||||
|
|
||||||
|
|
||||||
|
class ReloadPluginInput(BaseModel):
|
||||||
|
"""重载插件工具的输入参数模型"""
|
||||||
|
|
||||||
|
explanation: str = Field(
|
||||||
|
...,
|
||||||
|
description="Clear explanation of why this tool is being used in the current context",
|
||||||
|
)
|
||||||
|
plugin_id: str = Field(
|
||||||
|
...,
|
||||||
|
description="The plugin ID to reload so the latest saved config takes effect.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ReloadPluginTool(MoviePilotTool):
|
||||||
|
name: str = "reload_plugin"
|
||||||
|
description: str = (
|
||||||
|
"Reload an installed plugin so its latest saved configuration takes effect. "
|
||||||
|
"This also refreshes the plugin's registered commands, scheduled services, and API routes."
|
||||||
|
)
|
||||||
|
require_admin: bool = True
|
||||||
|
args_schema: Type[BaseModel] = ReloadPluginInput
|
||||||
|
|
||||||
|
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||||
|
"""生成友好的提示消息"""
|
||||||
|
plugin_id = kwargs.get("plugin_id", "")
|
||||||
|
return f"重载插件: {plugin_id}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _reload_plugin_sync(plugin_id: str) -> str:
|
||||||
|
"""
|
||||||
|
按后台接口同样的流程重载插件,确保最新配置和注册信息一起刷新。
|
||||||
|
"""
|
||||||
|
plugin_info = get_plugin_snapshot(plugin_id)
|
||||||
|
if not plugin_info:
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"message": f"插件 {plugin_id} 不存在,请先使用 query_installed_plugins 查询有效插件 ID",
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
reload_plugin_runtime(plugin_id)
|
||||||
|
refreshed_plugin = get_plugin_snapshot(plugin_id) or plugin_info
|
||||||
|
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
**refreshed_plugin,
|
||||||
|
"message": "插件已重载,最新配置已生效",
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
default=str,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run(self, plugin_id: str, **kwargs) -> str:
|
||||||
|
logger.info(f"执行工具: {self.name}, 参数: plugin_id={plugin_id}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await self.run_blocking(
|
||||||
|
"plugin", self._reload_plugin_sync, plugin_id
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"重载插件失败: {e}", exc_info=True)
|
||||||
|
return json.dumps(
|
||||||
|
{"success": False, "message": f"重载插件时发生错误: {str(e)}"},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""修改插件配置工具"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any, Dict, List, Optional, Type
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.agent.tools.base import MoviePilotTool
|
||||||
|
from app.agent.tools.impl._plugin_tool_utils import get_plugin_snapshot
|
||||||
|
from app.core.plugin import PluginManager
|
||||||
|
from app.log import logger
|
||||||
|
|
||||||
|
|
||||||
|
class UpdatePluginConfigInput(BaseModel):
|
||||||
|
"""修改插件配置工具的输入参数模型"""
|
||||||
|
|
||||||
|
explanation: str = Field(
|
||||||
|
...,
|
||||||
|
description="Clear explanation of why this tool is being used in the current context",
|
||||||
|
)
|
||||||
|
plugin_id: str = Field(
|
||||||
|
...,
|
||||||
|
description="The plugin ID to update. Use query_plugin_config first to inspect the current config.",
|
||||||
|
)
|
||||||
|
updates: Optional[Dict[str, Any]] = Field(
|
||||||
|
None,
|
||||||
|
description=(
|
||||||
|
"Config items to save. By default this tool merges these keys into the existing config "
|
||||||
|
"instead of replacing the whole config."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
remove_keys: Optional[List[str]] = Field(
|
||||||
|
None,
|
||||||
|
description="Optional config keys to remove from the saved plugin config.",
|
||||||
|
)
|
||||||
|
replace: Optional[bool] = Field(
|
||||||
|
False,
|
||||||
|
description=(
|
||||||
|
"Whether to replace the entire saved config with 'updates'. "
|
||||||
|
"Default false, which performs a partial merge update."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdatePluginConfigTool(MoviePilotTool):
|
||||||
|
name: str = "update_plugin_config"
|
||||||
|
description: str = (
|
||||||
|
"Update the saved configuration of an installed plugin. "
|
||||||
|
"By default this performs a partial merge update and does NOT reload the plugin automatically. "
|
||||||
|
"Call reload_plugin afterwards to apply the latest saved config to the running plugin."
|
||||||
|
)
|
||||||
|
require_admin: bool = True
|
||||||
|
args_schema: Type[BaseModel] = UpdatePluginConfigInput
|
||||||
|
|
||||||
|
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||||
|
"""生成友好的提示消息"""
|
||||||
|
plugin_id = kwargs.get("plugin_id", "")
|
||||||
|
replace = kwargs.get("replace", False)
|
||||||
|
action = "覆盖插件配置" if replace else "修改插件配置"
|
||||||
|
return f"{action}: {plugin_id}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _update_plugin_config(
|
||||||
|
plugin_id: str,
|
||||||
|
updates: Optional[Dict[str, Any]] = None,
|
||||||
|
remove_keys: Optional[List[str]] = None,
|
||||||
|
replace: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
仅异步保存插件配置,不主动生效,让 Agent 可以先批量改完再显式重载插件。
|
||||||
|
"""
|
||||||
|
plugin_info = get_plugin_snapshot(plugin_id)
|
||||||
|
if not plugin_info:
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"message": f"插件 {plugin_id} 不存在,请先使用 query_installed_plugins 查询有效插件 ID",
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
remove_keys = remove_keys or []
|
||||||
|
if not replace and not updates and not remove_keys:
|
||||||
|
return json.dumps(
|
||||||
|
{"success": False, "message": "没有提供任何需要修改的配置项"},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
plugin_manager = PluginManager()
|
||||||
|
current_config = dict(plugin_manager.get_plugin_config(plugin_id) or {})
|
||||||
|
|
||||||
|
# merge 模式以当前保存值为基准,replace 模式则从空配置开始重建。
|
||||||
|
next_config = {} if replace else dict(current_config)
|
||||||
|
if updates:
|
||||||
|
next_config.update(updates)
|
||||||
|
for key in remove_keys:
|
||||||
|
next_config.pop(key, None)
|
||||||
|
|
||||||
|
changed_keys = sorted(
|
||||||
|
key
|
||||||
|
for key in set(current_config.keys()) | set(next_config.keys())
|
||||||
|
if current_config.get(key) != next_config.get(key)
|
||||||
|
or (key in current_config) != (key in next_config)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not await plugin_manager.async_save_plugin_config(plugin_id, next_config):
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"message": f"保存插件 {plugin_id} 配置失败",
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
**plugin_info,
|
||||||
|
"message": "插件配置已保存,请调用 reload_plugin 使最新配置生效",
|
||||||
|
"replace": replace,
|
||||||
|
"changed_keys": changed_keys,
|
||||||
|
"removed_keys": remove_keys,
|
||||||
|
"config_requires_reload": True,
|
||||||
|
"previous_config": current_config,
|
||||||
|
"saved_config": next_config,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
default=str,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run(
|
||||||
|
self,
|
||||||
|
plugin_id: str,
|
||||||
|
updates: Optional[Dict[str, Any]] = None,
|
||||||
|
remove_keys: Optional[List[str]] = None,
|
||||||
|
replace: bool = False,
|
||||||
|
**kwargs,
|
||||||
|
) -> str:
|
||||||
|
logger.info(
|
||||||
|
f"执行工具: {self.name}, 参数: plugin_id={plugin_id}, replace={replace}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await self._update_plugin_config(
|
||||||
|
plugin_id, updates, remove_keys, replace
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"修改插件配置失败: {e}", exc_info=True)
|
||||||
|
return json.dumps(
|
||||||
|
{"success": False, "message": f"修改插件配置时发生错误: {str(e)}"},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
@@ -686,6 +686,20 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
|||||||
SystemConfigOper().set(self._config_key % pid, conf)
|
SystemConfigOper().set(self._config_key % pid, conf)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
async def async_save_plugin_config(
|
||||||
|
self, pid: str, conf: dict, force: bool = False
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
异步保存插件配置。
|
||||||
|
:param pid: 插件ID
|
||||||
|
:param conf: 配置
|
||||||
|
:param force: 强制保存
|
||||||
|
"""
|
||||||
|
if not force and not self._plugins.get(pid):
|
||||||
|
return False
|
||||||
|
await SystemConfigOper().async_set(self._config_key % pid, conf)
|
||||||
|
return True
|
||||||
|
|
||||||
def delete_plugin_config(self, pid: str) -> bool:
|
def delete_plugin_config(self, pid: str) -> bool:
|
||||||
"""
|
"""
|
||||||
删除插件配置
|
删除插件配置
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
from sqlalchemy import Column, String, JSON
|
from sqlalchemy import Column, String, JSON, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db import db_query, db_update, get_id_column, Base
|
from app.db import (
|
||||||
|
db_query,
|
||||||
|
db_update,
|
||||||
|
async_db_query,
|
||||||
|
get_id_column,
|
||||||
|
Base,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PluginData(Base):
|
class PluginData(Base):
|
||||||
@@ -18,11 +25,27 @@ class PluginData(Base):
|
|||||||
def get_plugin_data(cls, db: Session, plugin_id: str):
|
def get_plugin_data(cls, db: Session, plugin_id: str):
|
||||||
return db.query(cls).filter(cls.plugin_id == plugin_id).all()
|
return db.query(cls).filter(cls.plugin_id == plugin_id).all()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@async_db_query
|
||||||
|
async def async_get_plugin_data(cls, db: AsyncSession, plugin_id: str):
|
||||||
|
result = await db.execute(select(cls).where(cls.plugin_id == plugin_id))
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@db_query
|
@db_query
|
||||||
def get_plugin_data_by_key(cls, db: Session, plugin_id: str, key: str):
|
def get_plugin_data_by_key(cls, db: Session, plugin_id: str, key: str):
|
||||||
return db.query(cls).filter(cls.plugin_id == plugin_id, cls.key == key).first()
|
return db.query(cls).filter(cls.plugin_id == plugin_id, cls.key == key).first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@async_db_query
|
||||||
|
async def async_get_plugin_data_by_key(
|
||||||
|
cls, db: AsyncSession, plugin_id: str, key: str
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(cls).where(cls.plugin_id == plugin_id, cls.key == key)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@db_update
|
@db_update
|
||||||
def del_plugin_data_by_key(cls, db: Session, plugin_id: str, key: str):
|
def del_plugin_data_by_key(cls, db: Session, plugin_id: str, key: str):
|
||||||
@@ -37,3 +60,11 @@ class PluginData(Base):
|
|||||||
@db_query
|
@db_query
|
||||||
def get_plugin_data_by_plugin_id(cls, db: Session, plugin_id: str):
|
def get_plugin_data_by_plugin_id(cls, db: Session, plugin_id: str):
|
||||||
return db.query(cls).filter(cls.plugin_id == plugin_id).all()
|
return db.query(cls).filter(cls.plugin_id == plugin_id).all()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@async_db_query
|
||||||
|
async def async_get_plugin_data_by_plugin_id(
|
||||||
|
cls, db: AsyncSession, plugin_id: str
|
||||||
|
):
|
||||||
|
result = await db.execute(select(cls).where(cls.plugin_id == plugin_id))
|
||||||
|
return result.scalars().all()
|
||||||
|
|||||||
@@ -38,6 +38,21 @@ class PluginDataOper(DbOper):
|
|||||||
else:
|
else:
|
||||||
return PluginData.get_plugin_data(self._db, plugin_id)
|
return PluginData.get_plugin_data(self._db, plugin_id)
|
||||||
|
|
||||||
|
async def async_get_data(self, plugin_id: str, key: Optional[str] = None) -> Any:
|
||||||
|
"""
|
||||||
|
异步获取插件数据。
|
||||||
|
:param plugin_id: 插件id
|
||||||
|
:param key: 数据key
|
||||||
|
"""
|
||||||
|
if key:
|
||||||
|
data = await PluginData.async_get_plugin_data_by_key(
|
||||||
|
self._db, plugin_id, key
|
||||||
|
)
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
return data.value
|
||||||
|
return await PluginData.async_get_plugin_data(self._db, plugin_id)
|
||||||
|
|
||||||
def del_data(self, plugin_id: str, key: Optional[str] = None) -> Any:
|
def del_data(self, plugin_id: str, key: Optional[str] = None) -> Any:
|
||||||
"""
|
"""
|
||||||
删除插件数据
|
删除插件数据
|
||||||
@@ -61,3 +76,10 @@ class PluginDataOper(DbOper):
|
|||||||
:param plugin_id: 插件id
|
:param plugin_id: 插件id
|
||||||
"""
|
"""
|
||||||
return PluginData.get_plugin_data_by_plugin_id(self._db, plugin_id)
|
return PluginData.get_plugin_data_by_plugin_id(self._db, plugin_id)
|
||||||
|
|
||||||
|
async def async_get_data_all(self, plugin_id: str) -> Any:
|
||||||
|
"""
|
||||||
|
异步获取插件所有数据。
|
||||||
|
:param plugin_id: 插件id
|
||||||
|
"""
|
||||||
|
return await PluginData.async_get_plugin_data_by_plugin_id(self._db, plugin_id)
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
from app.agent.tools.impl.query_plugin_config import QueryPluginConfigTool
|
||||||
|
from app.agent.tools.impl.query_plugin_data import QueryPluginDataTool
|
||||||
|
from app.agent.tools.impl.reload_plugin import ReloadPluginTool
|
||||||
|
from app.agent.tools.impl.update_plugin_config import UpdatePluginConfigTool
|
||||||
|
|
||||||
|
|
||||||
|
class TestAgentPluginTools(unittest.TestCase):
|
||||||
|
@staticmethod
|
||||||
|
def _plugin_snapshot(state: bool = True) -> dict:
|
||||||
|
return {
|
||||||
|
"plugin_id": "DemoPlugin",
|
||||||
|
"plugin_name": "Demo Plugin",
|
||||||
|
"plugin_version": "1.0.0",
|
||||||
|
"state": state,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_query_plugin_config_returns_saved_config_and_default_model(self):
|
||||||
|
tool = QueryPluginConfigTool(session_id="session-1", user_id="10001")
|
||||||
|
plugin_manager = MagicMock()
|
||||||
|
plugin_manager.get_plugin_config.return_value = {"enabled": True}
|
||||||
|
plugin_instance = MagicMock()
|
||||||
|
plugin_instance.get_form.return_value = (None, {"enabled": False, "interval": 10})
|
||||||
|
plugin_manager.running_plugins = {"DemoPlugin": plugin_instance}
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.agent.tools.impl.query_plugin_config.get_plugin_snapshot",
|
||||||
|
return_value=self._plugin_snapshot(),
|
||||||
|
), patch(
|
||||||
|
"app.agent.tools.impl.query_plugin_config.PluginManager",
|
||||||
|
return_value=plugin_manager,
|
||||||
|
):
|
||||||
|
result = asyncio.run(tool.run(plugin_id="DemoPlugin"))
|
||||||
|
|
||||||
|
payload = json.loads(result)
|
||||||
|
self.assertTrue(payload["success"])
|
||||||
|
self.assertEqual(payload["config"], {"enabled": True})
|
||||||
|
self.assertEqual(payload["default_model"], {"enabled": False, "interval": 10})
|
||||||
|
|
||||||
|
def test_update_plugin_config_merges_and_removes_keys_without_reloading(self):
|
||||||
|
tool = UpdatePluginConfigTool(session_id="session-1", user_id="10001")
|
||||||
|
plugin_manager = MagicMock()
|
||||||
|
plugin_manager.get_plugin_config.return_value = {
|
||||||
|
"enabled": False,
|
||||||
|
"interval": 30,
|
||||||
|
"token": "legacy-token",
|
||||||
|
}
|
||||||
|
plugin_manager.async_save_plugin_config = AsyncMock(return_value=True)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.agent.tools.impl.update_plugin_config.get_plugin_snapshot",
|
||||||
|
return_value=self._plugin_snapshot(),
|
||||||
|
), patch(
|
||||||
|
"app.agent.tools.impl.update_plugin_config.PluginManager",
|
||||||
|
return_value=plugin_manager,
|
||||||
|
):
|
||||||
|
result = asyncio.run(
|
||||||
|
tool.run(
|
||||||
|
plugin_id="DemoPlugin",
|
||||||
|
updates={"enabled": True},
|
||||||
|
remove_keys=["token"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = json.loads(result)
|
||||||
|
self.assertTrue(payload["success"])
|
||||||
|
self.assertTrue(payload["config_requires_reload"])
|
||||||
|
self.assertEqual(payload["saved_config"], {"enabled": True, "interval": 30})
|
||||||
|
plugin_manager.async_save_plugin_config.assert_awaited_once_with(
|
||||||
|
"DemoPlugin",
|
||||||
|
{"enabled": True, "interval": 30},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_reload_plugin_triggers_runtime_refresh(self):
|
||||||
|
tool = ReloadPluginTool(session_id="session-1", user_id="10001")
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.agent.tools.impl.reload_plugin.get_plugin_snapshot",
|
||||||
|
side_effect=[self._plugin_snapshot(), self._plugin_snapshot(state=False)],
|
||||||
|
), patch(
|
||||||
|
"app.agent.tools.impl.reload_plugin.reload_plugin_runtime"
|
||||||
|
) as reload_plugin_runtime:
|
||||||
|
result = asyncio.run(tool.run(plugin_id="DemoPlugin"))
|
||||||
|
|
||||||
|
payload = json.loads(result)
|
||||||
|
self.assertTrue(payload["success"])
|
||||||
|
self.assertFalse(payload["state"])
|
||||||
|
reload_plugin_runtime.assert_called_once_with("DemoPlugin")
|
||||||
|
|
||||||
|
def test_query_plugin_data_truncates_large_payload(self):
|
||||||
|
tool = QueryPluginDataTool(session_id="session-1", user_id="10001")
|
||||||
|
plugin_data_oper = MagicMock()
|
||||||
|
plugin_data_oper.async_get_data_all = AsyncMock(return_value=[
|
||||||
|
SimpleNamespace(key="payload", value={"text": "x" * 5000})
|
||||||
|
])
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.agent.tools.impl.query_plugin_data.get_plugin_snapshot",
|
||||||
|
return_value=self._plugin_snapshot(),
|
||||||
|
), patch(
|
||||||
|
"app.agent.tools.impl.query_plugin_data.PluginDataOper",
|
||||||
|
return_value=plugin_data_oper,
|
||||||
|
):
|
||||||
|
result = asyncio.run(tool.run(plugin_id="DemoPlugin", max_chars=200))
|
||||||
|
|
||||||
|
payload = json.loads(result)
|
||||||
|
self.assertTrue(payload["success"])
|
||||||
|
self.assertTrue(payload["truncated"])
|
||||||
|
self.assertIn("data_preview", payload)
|
||||||
|
self.assertNotIn("data", payload)
|
||||||
|
self.assertIn("已截断", payload["data_preview"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user