mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-07 05:43:55 +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.query_installed_plugins import QueryInstalledPluginsTool
|
||||
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.list_slash_commands import ListSlashCommandsTool
|
||||
from app.agent.tools.impl.query_custom_identifiers import QueryCustomIdentifiersTool
|
||||
@@ -146,6 +150,10 @@ class MoviePilotToolFactory:
|
||||
BrowseWebpageTool,
|
||||
QueryInstalledPluginsTool,
|
||||
QueryPluginCapabilitiesTool,
|
||||
QueryPluginConfigTool,
|
||||
UpdatePluginConfigTool,
|
||||
ReloadPluginTool,
|
||||
QueryPluginDataTool,
|
||||
RunSlashCommandTool,
|
||||
ListSlashCommandsTool,
|
||||
QueryCustomIdentifiersTool,
|
||||
|
||||
73
app/agent/tools/impl/_plugin_tool_utils.py
Normal file
73
app/agent/tools/impl/_plugin_tool_utils.py
Normal file
@@ -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)
|
||||
88
app/agent/tools/impl/query_plugin_config.py
Normal file
88
app/agent/tools/impl/query_plugin_config.py
Normal file
@@ -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,
|
||||
)
|
||||
157
app/agent/tools/impl/query_plugin_data.py
Normal file
157
app/agent/tools/impl/query_plugin_data.py
Normal file
@@ -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,
|
||||
)
|
||||
84
app/agent/tools/impl/reload_plugin.py
Normal file
84
app/agent/tools/impl/reload_plugin.py
Normal file
@@ -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,
|
||||
)
|
||||
153
app/agent/tools/impl/update_plugin_config.py
Normal file
153
app/agent/tools/impl/update_plugin_config.py
Normal file
@@ -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)
|
||||
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:
|
||||
"""
|
||||
删除插件配置
|
||||
|
||||
@@ -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 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):
|
||||
@@ -18,11 +25,27 @@ class PluginData(Base):
|
||||
def get_plugin_data(cls, db: Session, plugin_id: str):
|
||||
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
|
||||
@db_query
|
||||
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()
|
||||
|
||||
@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
|
||||
@db_update
|
||||
def del_plugin_data_by_key(cls, db: Session, plugin_id: str, key: str):
|
||||
@@ -37,3 +60,11 @@ class PluginData(Base):
|
||||
@db_query
|
||||
def get_plugin_data_by_plugin_id(cls, db: Session, plugin_id: str):
|
||||
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:
|
||||
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:
|
||||
"""
|
||||
删除插件数据
|
||||
@@ -61,3 +76,10 @@ class PluginDataOper(DbOper):
|
||||
:param plugin_id: 插件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)
|
||||
|
||||
120
tests/test_agent_plugin_tools.py
Normal file
120
tests/test_agent_plugin_tools.py
Normal file
@@ -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