mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-06-28 03:02:34 +08:00
Add repo_url backfill for installed plugin queries
This commit is contained in:
@@ -103,6 +103,79 @@ def summarize_plugin(plugin: Any) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _merge_plugin_source_metadata(plugin: Any, source_plugin: Any) -> Any:
|
||||
"""
|
||||
将插件市场或本地仓库中的来源元数据合并到已安装插件对象。
|
||||
"""
|
||||
repo_url = getattr(source_plugin, "repo_url", None)
|
||||
if repo_url:
|
||||
setattr(plugin, "repo_url", repo_url)
|
||||
|
||||
for attr in (
|
||||
"has_update",
|
||||
"release",
|
||||
"system_version",
|
||||
"system_version_compatible",
|
||||
"system_version_message",
|
||||
):
|
||||
value = getattr(source_plugin, attr, None)
|
||||
if value is not None:
|
||||
setattr(plugin, attr, value)
|
||||
|
||||
return plugin
|
||||
|
||||
|
||||
def _map_plugins_by_id(plugins: list[Any]) -> dict[str, Any]:
|
||||
"""
|
||||
按插件 ID 建立稳定映射,保留同 ID 首个候选来源。
|
||||
"""
|
||||
plugin_map: dict[str, Any] = {}
|
||||
for plugin in plugins:
|
||||
plugin_id = getattr(plugin, "id", None)
|
||||
if plugin_id and plugin_id not in plugin_map:
|
||||
plugin_map[plugin_id] = plugin
|
||||
return plugin_map
|
||||
|
||||
|
||||
async def enrich_installed_plugin_sources(
|
||||
installed_plugins: list[Any],
|
||||
force_refresh: bool = False,
|
||||
) -> list[Any]:
|
||||
"""
|
||||
为已安装插件补齐安装来源仓库地址。
|
||||
|
||||
本地插件对象只包含运行目录中的静态元数据,通常没有 repo_url。这里按需从
|
||||
本地插件仓库和插件市场补齐来源,保证 Agent 后续安装、升级判断可以拿到仓库地址。
|
||||
"""
|
||||
missing_source_plugins = [
|
||||
plugin for plugin in installed_plugins if not getattr(plugin, "repo_url", None)
|
||||
]
|
||||
if not missing_source_plugins:
|
||||
return installed_plugins
|
||||
|
||||
plugin_manager = PluginManager()
|
||||
local_repo_map = _map_plugins_by_id(plugin_manager.get_local_repo_plugins())
|
||||
for plugin in missing_source_plugins:
|
||||
source_plugin = local_repo_map.get(getattr(plugin, "id", None))
|
||||
if source_plugin:
|
||||
_merge_plugin_source_metadata(plugin, source_plugin)
|
||||
|
||||
missing_source_plugins = [
|
||||
plugin for plugin in installed_plugins if not getattr(plugin, "repo_url", None)
|
||||
]
|
||||
if not missing_source_plugins:
|
||||
return installed_plugins
|
||||
|
||||
market_plugins = await plugin_manager.async_get_online_plugins(force=force_refresh)
|
||||
market_map = _map_plugins_by_id(market_plugins or [])
|
||||
for plugin in missing_source_plugins:
|
||||
source_plugin = market_map.get(getattr(plugin, "id", None))
|
||||
if source_plugin:
|
||||
_merge_plugin_source_metadata(plugin, source_plugin)
|
||||
|
||||
return installed_plugins
|
||||
|
||||
|
||||
async def load_market_plugins(force_refresh: bool = False) -> list[Any]:
|
||||
"""
|
||||
聚合插件市场与本地插件仓库中的候选插件。
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.agent.tools.tags import ToolTag
|
||||
from app.agent.tools.impl._plugin_tool_utils import (
|
||||
DEFAULT_PLUGIN_CANDIDATE_LIMIT,
|
||||
MAX_PLUGIN_CANDIDATE_LIMIT,
|
||||
enrich_installed_plugin_sources,
|
||||
list_installed_plugins,
|
||||
search_plugin_candidates,
|
||||
summarize_candidates,
|
||||
@@ -31,9 +32,15 @@ class QueryInstalledPluginsInput(BaseModel):
|
||||
DEFAULT_PLUGIN_CANDIDATE_LIMIT,
|
||||
description="Maximum number of plugins to return. Defaults to 50, capped at 200.",
|
||||
)
|
||||
force_refresh_market: bool = Field(
|
||||
False,
|
||||
description="Whether to refresh plugin market caches before completing missing repo_url values.",
|
||||
)
|
||||
|
||||
|
||||
class QueryInstalledPluginsTool(MoviePilotTool):
|
||||
"""查询已安装插件并返回 Agent 可消费的摘要信息。"""
|
||||
|
||||
name: str = "query_installed_plugins"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
@@ -67,9 +74,15 @@ class QueryInstalledPluginsTool(MoviePilotTool):
|
||||
self,
|
||||
query: Optional[str] = None,
|
||||
max_results: Optional[int] = DEFAULT_PLUGIN_CANDIDATE_LIMIT,
|
||||
force_refresh_market: bool = False,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
logger.info(f"执行工具: {self.name}, 参数: query={query}")
|
||||
"""
|
||||
查询已安装插件列表,并在可能时补齐插件来源仓库地址。
|
||||
"""
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, 参数: query={query}, force_refresh_market={force_refresh_market}"
|
||||
)
|
||||
try:
|
||||
installed_plugins = list_installed_plugins()
|
||||
if not installed_plugins:
|
||||
@@ -77,6 +90,10 @@ class QueryInstalledPluginsTool(MoviePilotTool):
|
||||
{"success": False, "message": "当前没有已安装的插件"},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
installed_plugins = await enrich_installed_plugin_sources(
|
||||
installed_plugins,
|
||||
force_refresh=force_refresh_market,
|
||||
)
|
||||
|
||||
limit = self._clamp_results(max_results)
|
||||
if query:
|
||||
|
||||
@@ -194,55 +194,6 @@ MoviePilot 也提供普通 REST API 给前端和自动化客户端使用。所
|
||||
}
|
||||
```
|
||||
|
||||
**`search_web` 网络搜索示例**:
|
||||
```json
|
||||
{
|
||||
"tool_name": "search_web",
|
||||
"arguments": {
|
||||
"query": "asyncio TaskGroup",
|
||||
"search_engine": "duckduckgo",
|
||||
"site_url": "https://docs.python.org/3/",
|
||||
"max_results": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`search_engine` 可选,通过 DDGS 支持 `auto`、`duckduckgo`、`google`、`brave`、`yahoo`、`wikipedia`、`yandex`、`mojeek`。`site_url` 可选,用于限定搜索到指定域名或 URL 路径范围。搜索默认使用系统代理配置。
|
||||
|
||||
**`browse_webpage` 浏览器操作示例**:
|
||||
```json
|
||||
{
|
||||
"tool_name": "browse_webpage",
|
||||
"arguments": {
|
||||
"action": "goto",
|
||||
"url": "https://example.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`browse_webpage` 使用持久浏览器会话,默认以当前 Agent 会话作为 `session_key`。`goto`、`snapshot`、`click`、`click_ref`、`fill`、`fill_ref`、`select`、`select_ref`、`wait` 等动作会返回页面快照,快照中的 `interactive_elements[].ref` 可用于后续 `*_ref` 操作。支持 `list_tabs`、`open_tab`、`focus_tab`、`close_tab` 管理标签页,支持 `close_session` 释放会话。出于安全考虑,默认拒绝访问 localhost、环回地址、私网地址和链路本地地址;确需访问可信内网或本机页面时,可显式传入 `allow_private_network: true`。
|
||||
|
||||
**`recognize_captcha` 图形验证码识别示例**:
|
||||
```json
|
||||
{
|
||||
"tool_name": "recognize_captcha",
|
||||
"arguments": {
|
||||
"image_url": "https://example.com/captcha.png",
|
||||
"cookie": "sid=...",
|
||||
"user_agent": "Mozilla/5.0 ..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`recognize_captcha` 用于浏览器自动化登录时识别普通图形验证码。智能体可先通过 `browse_webpage` 的 `evaluate` 动作从页面元素中提取 `img.src`,再把图片地址传给该工具;支持 `http/https` 图片地址和 `data:image/...;base64,...`。当验证码图片依赖当前浏览器会话时,可传入 Cookie 与 User-Agent。出于安全考虑,默认拒绝访问 localhost、环回地址、私网地址和链路本地地址;确需访问可信内网或本机验证码图片时,可显式传入 `allow_private_network: true`。
|
||||
|
||||
**下载任务工具说明**:
|
||||
|
||||
- `add_download_tasks` 用于添加下载任务,支持 `get_search_results` 返回的 `hash:id` 引用和磁力链接,可指定下载器、保存目录和标签。
|
||||
- `query_download_tasks` 用于查询下载任务,支持按下载器、状态、Hash、标题、标签过滤;返回保存目录、内容路径、上传/下载速度、上传/下载限速、分类、分享率、做种时间等下载器可提供的字段。按 `hash` 查询或传入 `include_trackers=true` 时,会尽量返回 Tracker URL 列表。
|
||||
- `update_download_tasks` 用于修改下载任务,统一支持 `start`/`stop`、标签、上传/下载限速、Tracker、保存目录、分类、分享率、做种时间等字段;具体字段是否成功取决于下载器能力,返回结果会按操作项逐条标记成功或失败。
|
||||
- `delete_download_tasks` 用于删除下载任务,按任务 Hash 操作,可指定下载器,并可选择是否同时删除已下载文件。
|
||||
|
||||
### 3. 获取工具详情
|
||||
|
||||
**GET** `/api/v1/mcp/tools/{tool_name}`
|
||||
|
||||
@@ -59,6 +59,9 @@ a local plugin source and installed into the running MoviePilot instance.
|
||||
- Local runtime examples: `app/plugins/<plugin>/__init__.py`
|
||||
- Market/local source candidates: use `query_market_plugins` when the
|
||||
running instance is available.
|
||||
- Installed plugin candidates: use `query_installed_plugins`; its summaries
|
||||
include `repo_url` when the source can be matched from a local plugin
|
||||
repository or plugin market metadata.
|
||||
- For Vue federation examples, prefer current compliant plugins such as
|
||||
`MoviePilot-Plugins/plugins.v2/agenttokens/` and the frontend example
|
||||
`MoviePilot-Frontend/examples/plugin-component/`.
|
||||
|
||||
@@ -5,7 +5,7 @@ description: >-
|
||||
Use this skill ONLY when the user EXPLICITLY requests filing an
|
||||
upstream issue for MoviePilot core, frontend, or an installed plugin,
|
||||
for example "反馈 issue", "提 issue", "报 bug", "给 MP 提 issue",
|
||||
"让上游修一下", "提交错误报告", "提需求", "功能请求",
|
||||
"让上游修一下", "提交错误报告", "提问题", "提需求", "功能请求",
|
||||
or English "file an issue / report a bug / open an upstream issue /
|
||||
feature request".
|
||||
A bare problem report is not enough: diagnose locally first. This
|
||||
@@ -242,9 +242,15 @@ python <skill_dir>/scripts/submit_feedback_issue.py \
|
||||
--username "<current admin username if known>"
|
||||
```
|
||||
|
||||
The script creates the GitHub issue through `GITHUB_TOKEN` when the
|
||||
token is configured and has permission. Otherwise it returns a
|
||||
`prefill_url`. Relay the result:
|
||||
The script automatically imports MoviePilot's `app.core.config.settings`
|
||||
and reads the system-configured `GITHUB_TOKEN` / `settings.GITHUB_HEADERS`
|
||||
from the running MoviePilot environment. Do not ask the user to provide
|
||||
a GitHub token in chat, and never accept or echo a token from the user.
|
||||
When that configured token exists and has permission, the script creates
|
||||
the GitHub issue through the GitHub API. Otherwise it returns a
|
||||
`prefill_url`.
|
||||
|
||||
Relay the result:
|
||||
|
||||
- `success=true`: tell the user the issue was submitted and include
|
||||
`issue_url` if present.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import asyncio
|
||||
import json
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from typing import Optional
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from app.agent.tools.impl.install_plugin import InstallPluginTool
|
||||
from app.agent.tools.impl._plugin_tool_utils import install_plugin_runtime
|
||||
from app.agent.tools.impl.install_plugin import InstallPluginTool
|
||||
from app.agent.tools.impl.query_installed_plugins import QueryInstalledPluginsTool
|
||||
from app.agent.tools.impl.query_market_plugins import QueryMarketPluginsTool
|
||||
from app.agent.tools.impl.query_plugin_config import QueryPluginConfigTool
|
||||
@@ -15,254 +15,361 @@ from app.agent.tools.impl.uninstall_plugin import UninstallPluginTool
|
||||
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 _plugin_snapshot(state: bool = True) -> dict:
|
||||
"""
|
||||
构造插件运行态快照。
|
||||
"""
|
||||
return {
|
||||
"plugin_id": "DemoPlugin",
|
||||
"plugin_name": "Demo Plugin",
|
||||
"plugin_version": "1.0.0",
|
||||
"state": state,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _market_plugin(plugin_id: str, plugin_name: str, installed: bool = False):
|
||||
return SimpleNamespace(
|
||||
id=plugin_id,
|
||||
plugin_name=plugin_name,
|
||||
plugin_desc=f"{plugin_name} description",
|
||||
plugin_version="1.0.0",
|
||||
plugin_author="author",
|
||||
installed=installed,
|
||||
has_update=False,
|
||||
state=installed,
|
||||
repo_url="https://example.com/market",
|
||||
add_time=1,
|
||||
)
|
||||
|
||||
def test_query_market_plugins_filters_candidates(self):
|
||||
tool = QueryMarketPluginsTool(session_id="session-1", user_id="10001")
|
||||
plugins = [
|
||||
self._market_plugin("DemoPlugin", "Demo Plugin"),
|
||||
self._market_plugin("OtherPlugin", "Other Plugin"),
|
||||
]
|
||||
def _market_plugin(
|
||||
plugin_id: str,
|
||||
plugin_name: str,
|
||||
installed: bool = False,
|
||||
repo_url: Optional[str] = "https://example.com/market",
|
||||
) -> SimpleNamespace:
|
||||
"""
|
||||
构造插件市场或已安装插件摘要对象。
|
||||
"""
|
||||
return SimpleNamespace(
|
||||
id=plugin_id,
|
||||
plugin_name=plugin_name,
|
||||
plugin_desc=f"{plugin_name} description",
|
||||
plugin_version="1.0.0",
|
||||
plugin_author="author",
|
||||
installed=installed,
|
||||
has_update=False,
|
||||
state=installed,
|
||||
repo_url=repo_url,
|
||||
add_time=1,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.agent.tools.impl.query_market_plugins.load_market_plugins",
|
||||
new=AsyncMock(return_value=plugins),
|
||||
):
|
||||
result = asyncio.run(tool.run(query="demo"))
|
||||
|
||||
payload = json.loads(result)
|
||||
self.assertTrue(payload["success"])
|
||||
self.assertEqual(payload["match_count"], 1)
|
||||
self.assertEqual(payload["plugins"][0]["id"], "DemoPlugin")
|
||||
def test_query_market_plugins_filters_candidates() -> None:
|
||||
"""
|
||||
查询插件市场时会按关键字返回匹配候选。
|
||||
"""
|
||||
tool = QueryMarketPluginsTool(session_id="session-1", user_id="10001")
|
||||
plugins = [
|
||||
_market_plugin("DemoPlugin", "Demo Plugin"),
|
||||
_market_plugin("OtherPlugin", "Other Plugin"),
|
||||
]
|
||||
|
||||
def test_query_installed_plugins_filters_candidates(self):
|
||||
tool = QueryInstalledPluginsTool(session_id="session-1", user_id="10001")
|
||||
plugins = [
|
||||
self._market_plugin("DemoPlugin", "Demo Plugin", installed=True),
|
||||
self._market_plugin("OtherPlugin", "Other Plugin", installed=True),
|
||||
]
|
||||
with patch(
|
||||
"app.agent.tools.impl.query_market_plugins.load_market_plugins",
|
||||
new=AsyncMock(return_value=plugins),
|
||||
):
|
||||
result = asyncio.run(tool.run(query="demo"))
|
||||
|
||||
with patch(
|
||||
payload = json.loads(result)
|
||||
assert payload["success"]
|
||||
assert payload["match_count"] == 1
|
||||
assert payload["plugins"][0]["id"] == "DemoPlugin"
|
||||
|
||||
|
||||
def test_query_installed_plugins_filters_candidates() -> None:
|
||||
"""
|
||||
查询已安装插件时会按关键字返回匹配候选。
|
||||
"""
|
||||
tool = QueryInstalledPluginsTool(session_id="session-1", user_id="10001")
|
||||
plugins = [
|
||||
_market_plugin("DemoPlugin", "Demo Plugin", installed=True),
|
||||
_market_plugin("OtherPlugin", "Other Plugin", installed=True),
|
||||
]
|
||||
|
||||
with patch(
|
||||
"app.agent.tools.impl.query_installed_plugins.list_installed_plugins",
|
||||
return_value=plugins,
|
||||
):
|
||||
result = asyncio.run(tool.run(query="demo"))
|
||||
|
||||
payload = json.loads(result)
|
||||
assert payload["success"]
|
||||
assert payload["match_count"] == 1
|
||||
assert payload["plugins"][0]["id"] == "DemoPlugin"
|
||||
|
||||
|
||||
def test_query_installed_plugins_fills_missing_repo_url_from_market() -> None:
|
||||
"""
|
||||
已安装插件缺少来源地址时,会从插件市场元数据补齐 repo_url。
|
||||
"""
|
||||
tool = QueryInstalledPluginsTool(session_id="session-1", user_id="10001")
|
||||
installed_plugin = _market_plugin(
|
||||
"DemoPlugin", "Demo Plugin", installed=True, repo_url=None
|
||||
)
|
||||
market_plugin = _market_plugin(
|
||||
"DemoPlugin",
|
||||
"Demo Plugin",
|
||||
installed=True,
|
||||
repo_url="https://github.com/demo/plugins",
|
||||
)
|
||||
plugin_manager = MagicMock()
|
||||
plugin_manager.get_local_repo_plugins.return_value = []
|
||||
plugin_manager.async_get_online_plugins = AsyncMock(return_value=[market_plugin])
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.agent.tools.impl.query_installed_plugins.list_installed_plugins",
|
||||
return_value=plugins,
|
||||
):
|
||||
result = asyncio.run(tool.run(query="demo"))
|
||||
|
||||
payload = json.loads(result)
|
||||
self.assertTrue(payload["success"])
|
||||
self.assertEqual(payload["match_count"], 1)
|
||||
self.assertEqual(payload["plugins"][0]["id"], "DemoPlugin")
|
||||
|
||||
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_install_plugin_installs_market_candidate(self):
|
||||
tool = InstallPluginTool(session_id="session-1", user_id="10001")
|
||||
candidate = self._market_plugin("DemoPlugin", "Demo Plugin")
|
||||
|
||||
with patch(
|
||||
"app.agent.tools.impl.install_plugin.load_market_plugins",
|
||||
new=AsyncMock(return_value=[candidate]),
|
||||
), patch(
|
||||
"app.agent.tools.impl.install_plugin.install_plugin_runtime",
|
||||
new=AsyncMock(return_value=(True, "插件安装完成", False)),
|
||||
) as install_runtime, patch(
|
||||
"app.agent.tools.impl.install_plugin.get_plugin_snapshot",
|
||||
return_value=self._plugin_snapshot(),
|
||||
):
|
||||
result = asyncio.run(tool.run(plugin_id="DemoPlugin"))
|
||||
|
||||
payload = json.loads(result)
|
||||
self.assertTrue(payload["success"])
|
||||
self.assertEqual(payload["plugin"]["id"], "DemoPlugin")
|
||||
install_runtime.assert_awaited_once_with(
|
||||
"DemoPlugin", "https://example.com/market", force=False
|
||||
)
|
||||
|
||||
def test_install_plugin_runtime_reloads_in_threadpool(self):
|
||||
plugin_manager = MagicMock()
|
||||
plugin_manager.get_plugin_ids.return_value = ["DemoPlugin"]
|
||||
plugin_helper = MagicMock()
|
||||
config_oper = MagicMock()
|
||||
config_oper.get.return_value = ["DemoPlugin"]
|
||||
calls = []
|
||||
|
||||
async def fake_run_agent_blocking(bucket, func, *args, **kwargs):
|
||||
calls.append((bucket, func, args, kwargs))
|
||||
return None
|
||||
|
||||
with patch(
|
||||
"app.agent.tools.impl._plugin_tool_utils.SystemConfigOper",
|
||||
return_value=config_oper,
|
||||
), patch(
|
||||
return_value=[installed_plugin],
|
||||
),
|
||||
patch(
|
||||
"app.agent.tools.impl._plugin_tool_utils.PluginManager",
|
||||
return_value=plugin_manager,
|
||||
), patch(
|
||||
),
|
||||
):
|
||||
result = asyncio.run(tool.run(query="demo"))
|
||||
|
||||
payload = json.loads(result)
|
||||
assert payload["success"]
|
||||
assert payload["plugins"][0]["repo_url"] == "https://github.com/demo/plugins"
|
||||
plugin_manager.async_get_online_plugins.assert_awaited_once_with(force=False)
|
||||
|
||||
|
||||
def test_query_plugin_config_returns_saved_config_and_default_model() -> None:
|
||||
"""
|
||||
查询插件配置会返回保存值和默认配置模型。
|
||||
"""
|
||||
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=_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)
|
||||
assert payload["success"]
|
||||
assert payload["config"] == {"enabled": True}
|
||||
assert payload["default_model"] == {"enabled": False, "interval": 10}
|
||||
|
||||
|
||||
def test_update_plugin_config_merges_and_removes_keys_without_reloading() -> None:
|
||||
"""
|
||||
更新插件配置会合并新增键并移除指定旧键。
|
||||
"""
|
||||
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=_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)
|
||||
assert payload["success"]
|
||||
assert payload["config_requires_reload"]
|
||||
assert 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() -> None:
|
||||
"""
|
||||
重载插件工具会调用运行态刷新流程。
|
||||
"""
|
||||
tool = ReloadPluginTool(session_id="session-1", user_id="10001")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.agent.tools.impl.reload_plugin.get_plugin_snapshot",
|
||||
side_effect=[_plugin_snapshot(), _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)
|
||||
assert payload["success"]
|
||||
assert not payload["state"]
|
||||
reload_plugin_runtime.assert_called_once_with("DemoPlugin")
|
||||
|
||||
|
||||
def test_install_plugin_installs_market_candidate() -> None:
|
||||
"""
|
||||
安装插件工具会使用市场候选携带的仓库地址。
|
||||
"""
|
||||
tool = InstallPluginTool(session_id="session-1", user_id="10001")
|
||||
candidate = _market_plugin("DemoPlugin", "Demo Plugin")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.agent.tools.impl.install_plugin.load_market_plugins",
|
||||
new=AsyncMock(return_value=[candidate]),
|
||||
),
|
||||
patch(
|
||||
"app.agent.tools.impl.install_plugin.install_plugin_runtime",
|
||||
new=AsyncMock(return_value=(True, "插件安装完成", False)),
|
||||
) as install_runtime,
|
||||
patch(
|
||||
"app.agent.tools.impl.install_plugin.get_plugin_snapshot",
|
||||
return_value=_plugin_snapshot(),
|
||||
),
|
||||
):
|
||||
result = asyncio.run(tool.run(plugin_id="DemoPlugin"))
|
||||
|
||||
payload = json.loads(result)
|
||||
assert payload["success"]
|
||||
assert payload["plugin"]["id"] == "DemoPlugin"
|
||||
install_runtime.assert_awaited_once_with(
|
||||
"DemoPlugin", "https://example.com/market", force=False
|
||||
)
|
||||
|
||||
|
||||
def test_install_plugin_runtime_reloads_in_threadpool() -> None:
|
||||
"""
|
||||
已存在插件刷新加载时会通过插件线程池执行重载。
|
||||
"""
|
||||
plugin_manager = MagicMock()
|
||||
plugin_manager.get_plugin_ids.return_value = ["DemoPlugin"]
|
||||
plugin_helper = MagicMock()
|
||||
config_oper = MagicMock()
|
||||
config_oper.get.return_value = ["DemoPlugin"]
|
||||
calls = []
|
||||
|
||||
async def fake_run_agent_blocking(bucket, func, *args, **kwargs) -> None:
|
||||
calls.append((bucket, func, args, kwargs))
|
||||
return None
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.agent.tools.impl._plugin_tool_utils.SystemConfigOper",
|
||||
return_value=config_oper,
|
||||
),
|
||||
patch(
|
||||
"app.agent.tools.impl._plugin_tool_utils.PluginManager",
|
||||
return_value=plugin_manager,
|
||||
),
|
||||
patch(
|
||||
"app.agent.tools.impl._plugin_tool_utils.PluginHelper",
|
||||
return_value=plugin_helper,
|
||||
), patch(
|
||||
),
|
||||
patch(
|
||||
"app.agent.tools.impl._plugin_tool_utils.reload_plugin_runtime",
|
||||
) as reload_runtime, patch(
|
||||
) as reload_runtime,
|
||||
patch(
|
||||
"app.agent.tools.impl._plugin_tool_utils.MoviePilotServerHelper.async_install_plugin_reg",
|
||||
AsyncMock(return_value=True),
|
||||
) as install_reg, patch(
|
||||
) as install_reg,
|
||||
patch(
|
||||
"app.agent.tools.base.run_agent_blocking",
|
||||
side_effect=fake_run_agent_blocking,
|
||||
):
|
||||
success, message, refreshed_only = asyncio.run(
|
||||
install_plugin_runtime(
|
||||
"DemoPlugin",
|
||||
"https://example.com/market",
|
||||
force=False,
|
||||
)
|
||||
),
|
||||
):
|
||||
success, message, refreshed_only = asyncio.run(
|
||||
install_plugin_runtime(
|
||||
"DemoPlugin",
|
||||
"https://example.com/market",
|
||||
force=False,
|
||||
)
|
||||
|
||||
self.assertTrue(success)
|
||||
self.assertEqual("插件已存在,已刷新加载", message)
|
||||
self.assertTrue(refreshed_only)
|
||||
install_reg.assert_awaited_once_with(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url="https://example.com/market",
|
||||
)
|
||||
self.assertEqual(1, len(calls))
|
||||
self.assertEqual("plugin", calls[0][0])
|
||||
self.assertEqual(reload_runtime, calls[0][1])
|
||||
self.assertEqual(("DemoPlugin",), calls[0][2])
|
||||
self.assertEqual({}, calls[0][3])
|
||||
|
||||
def test_uninstall_plugin_uninstalls_installed_candidate(self):
|
||||
tool = UninstallPluginTool(session_id="session-1", user_id="10001")
|
||||
installed_plugin = self._market_plugin(
|
||||
"DemoPlugin", "Demo Plugin", installed=True
|
||||
)
|
||||
|
||||
with patch(
|
||||
assert success
|
||||
assert message == "插件已存在,已刷新加载"
|
||||
assert refreshed_only
|
||||
install_reg.assert_awaited_once_with(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url="https://example.com/market",
|
||||
)
|
||||
assert len(calls) == 1
|
||||
assert calls[0][0] == "plugin"
|
||||
assert calls[0][1] == reload_runtime
|
||||
assert calls[0][2] == ("DemoPlugin",)
|
||||
assert calls[0][3] == {}
|
||||
|
||||
|
||||
def test_uninstall_plugin_uninstalls_installed_candidate() -> None:
|
||||
"""
|
||||
卸载插件工具会按已安装候选执行卸载流程。
|
||||
"""
|
||||
tool = UninstallPluginTool(session_id="session-1", user_id="10001")
|
||||
installed_plugin = _market_plugin(
|
||||
"DemoPlugin", "Demo Plugin", installed=True
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.agent.tools.impl.uninstall_plugin.list_installed_plugins",
|
||||
return_value=[installed_plugin],
|
||||
), patch(
|
||||
),
|
||||
patch(
|
||||
"app.agent.tools.impl.uninstall_plugin.uninstall_plugin_runtime",
|
||||
new=AsyncMock(
|
||||
return_value={"was_clone": False, "clone_files_removed": False}
|
||||
),
|
||||
) as uninstall_runtime:
|
||||
result = asyncio.run(tool.run(plugin_id="DemoPlugin"))
|
||||
) as uninstall_runtime,
|
||||
):
|
||||
result = asyncio.run(tool.run(plugin_id="DemoPlugin"))
|
||||
|
||||
payload = json.loads(result)
|
||||
self.assertTrue(payload["success"])
|
||||
self.assertEqual(payload["plugin"]["id"], "DemoPlugin")
|
||||
uninstall_runtime.assert_awaited_once_with("DemoPlugin")
|
||||
payload = json.loads(result)
|
||||
assert payload["success"]
|
||||
assert payload["plugin"]["id"] == "DemoPlugin"
|
||||
uninstall_runtime.assert_awaited_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(
|
||||
def test_query_plugin_data_truncates_large_payload() -> None:
|
||||
"""
|
||||
查询插件数据会截断超长内容并返回预览。
|
||||
"""
|
||||
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(
|
||||
return_value=_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))
|
||||
),
|
||||
):
|
||||
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"])
|
||||
payload = json.loads(result)
|
||||
assert payload["success"]
|
||||
assert payload["truncated"]
|
||||
assert "data_preview" in payload
|
||||
assert "data" not in payload
|
||||
assert "已截断" in payload["data_preview"]
|
||||
|
||||
Reference in New Issue
Block a user