mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-06-15 23:16:45 +00:00
Compare commits
140 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c60d8ccd7 | ||
|
|
8ad8b5eaad | ||
|
|
af23baec6d | ||
|
|
94b8252fdd | ||
|
|
785f11af0e | ||
|
|
726bc5f2aa | ||
|
|
bae820a11d | ||
|
|
47f6389424 | ||
|
|
6a635ac720 | ||
|
|
d2803bed1e | ||
|
|
8dc1cf53eb | ||
|
|
a93815b18a | ||
|
|
ef36af8a82 | ||
|
|
c87b856ddf | ||
|
|
0f42a0fb8c | ||
|
|
2b031e7e05 | ||
|
|
70831c27b3 | ||
|
|
bef2a81296 | ||
|
|
d0dcf6660f | ||
|
|
4e3eddec10 | ||
|
|
93713ba662 | ||
|
|
0f3e9574ab | ||
|
|
25dbe491fe | ||
|
|
d6db0a86f6 | ||
|
|
6e8bce3d04 | ||
|
|
ed1e31d379 | ||
|
|
3a233014de | ||
|
|
13cb1683ff | ||
|
|
ac9132cba6 | ||
|
|
6f5f1aa457 | ||
|
|
4547edc696 | ||
|
|
8d4412463c | ||
|
|
0189463a09 | ||
|
|
a654686ce7 | ||
|
|
eb9ea1c5c5 | ||
|
|
84b4a7eca2 | ||
|
|
a37ed9aa97 | ||
|
|
b89c351686 | ||
|
|
303e7ee16e | ||
|
|
ab9eeedb3e | ||
|
|
7d582cc4d8 | ||
|
|
e27a9ba486 | ||
|
|
51c2843dd0 | ||
|
|
8c73b87f6e | ||
|
|
a10361cc2f | ||
|
|
dfabd695a8 | ||
|
|
735a1ebf27 | ||
|
|
10dcb3727e | ||
|
|
616c355438 | ||
|
|
24dc53b62d | ||
|
|
1b83abe155 | ||
|
|
765b286fd7 | ||
|
|
83cc7ea716 | ||
|
|
d26225b998 | ||
|
|
c18e145b90 | ||
|
|
b43c253983 | ||
|
|
e49e1626ee | ||
|
|
13f55f4b1d | ||
|
|
486c5294ba | ||
|
|
cba52c57e6 | ||
|
|
82694d2d8b | ||
|
|
616309a08b | ||
|
|
829d7944b0 | ||
|
|
c4602070b1 | ||
|
|
ff83d1eae6 | ||
|
|
ee96706e9f | ||
|
|
7a19906e25 | ||
|
|
a0bc22dd25 | ||
|
|
63a63d2ec6 | ||
|
|
5d5e37792e | ||
|
|
4241461ba7 | ||
|
|
fa06d5d861 | ||
|
|
0f468f67c1 | ||
|
|
dc2b6910a4 | ||
|
|
d1cf584af9 | ||
|
|
a2b82a2532 | ||
|
|
f48d708172 | ||
|
|
210aac0937 | ||
|
|
e3c5a94c52 | ||
|
|
738d92445a | ||
|
|
08ace4e804 | ||
|
|
b6759c5519 | ||
|
|
c7dc6e0d97 | ||
|
|
84ff7476c0 | ||
|
|
55cf380c9e | ||
|
|
bb8cfaa52f | ||
|
|
bf98e4c954 | ||
|
|
a0b3800f6b | ||
|
|
871d1ec0d8 | ||
|
|
ca1dbdf843 | ||
|
|
e77bef7cf1 | ||
|
|
f4011d3ac2 | ||
|
|
d0b62523a0 | ||
|
|
a9b1f7e9c9 | ||
|
|
fc8933c648 | ||
|
|
51981d151e | ||
|
|
97cfcda03c | ||
|
|
a2984530f8 | ||
|
|
7474ecd02f | ||
|
|
9056caae40 | ||
|
|
fd280a49b7 | ||
|
|
df75f42753 | ||
|
|
0d2c324e28 | ||
|
|
dc0ee2b466 | ||
|
|
781b1ce2aa | ||
|
|
791f1fe4ac | ||
|
|
6405ff1191 | ||
|
|
64cb5742d2 | ||
|
|
4601c41794 | ||
|
|
6167e7e6a2 | ||
|
|
a106738de5 | ||
|
|
e0ce11a9d3 | ||
|
|
3052f2cb31 | ||
|
|
7905e622f9 | ||
|
|
3fa5d31d81 | ||
|
|
9e5cb702c5 | ||
|
|
ed380e2a17 | ||
|
|
bc358fc6d2 | ||
|
|
223854d4c6 | ||
|
|
7c73a57bbc | ||
|
|
2b9f5d8d90 | ||
|
|
437baec620 | ||
|
|
1c41d9f253 | ||
|
|
db522e8829 | ||
|
|
e43adf51af | ||
|
|
d353e7b208 | ||
|
|
df732731d9 | ||
|
|
ac5374c244 | ||
|
|
fcdba27a5d | ||
|
|
e4242058e2 | ||
|
|
b7c78da214 | ||
|
|
ba2feb2bfe | ||
|
|
6f014cee14 | ||
|
|
6453935584 | ||
|
|
40d0b60aa2 | ||
|
|
1922cce499 | ||
|
|
c89df496a5 | ||
|
|
855681ff35 | ||
|
|
13b2163788 | ||
|
|
5d3c262e60 |
55
.github/workflows/test.yml
vendored
Normal file
55
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
name: Unit Tests
|
||||
|
||||
on:
|
||||
# 指向 v2 的 PR 与推送都跑全量单测,作为合并门禁
|
||||
pull_request:
|
||||
branches:
|
||||
- v2
|
||||
push:
|
||||
branches:
|
||||
- v2
|
||||
# 允许手动触发
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: unit-tests-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
pytest:
|
||||
runs-on: ubuntu-latest
|
||||
name: Unit Tests
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.in', '**/requirements.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip setuptools wheel
|
||||
# 用 requirements.in 还原 CI / 全新环境(含 pytest~=8.4 与 moviepilot-rust 等可选扩展),
|
||||
# 与本地"干净 venv 复现"一致;测试运行器 pytest 已在 requirements.in 中声明。
|
||||
pip install -r requirements.in
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
# tests/run.py 以 pytest 跑 tests 全量;tests/conftest.py 在收集前把 CONFIG_DIR
|
||||
# 指向临时库并建表,测试杜绝真实网络/外部服务(详见 docs/testing.md)。
|
||||
python tests/run.py
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -30,6 +30,7 @@ public/
|
||||
.moviepilot.env
|
||||
*.pyc
|
||||
*.log
|
||||
.coverage
|
||||
.vscode
|
||||
venv
|
||||
|
||||
@@ -41,3 +42,6 @@ pylint-report.json
|
||||
.claude/
|
||||
!.claude/*.json
|
||||
.claude/settings.local.json
|
||||
|
||||
# Superpowers 设计/计划文档(本地协作产物,不纳入仓库)
|
||||
docs/superpowers/
|
||||
|
||||
@@ -41,6 +41,10 @@ Before executing any task, identify the domain and load the corresponding docume
|
||||
* **Primary Reference:** `docs/rules/11-quality-and-security.md`
|
||||
* **Required Constraints:** All code changes must pass the relevant pytest tests and pylint checks. Dependency changes require a passing safety scan.
|
||||
|
||||
### Testing
|
||||
* **Primary Reference:** `docs/testing.md`
|
||||
* **Required Constraints:** pytest is the only runner; `tests/conftest.py` isolates each run to a temporary `CONFIG_DIR`. Tests must not touch the real database, network, or external services (TMDB, LLM catalogs, downloaders, media servers, MP server) — mock at the boundary or replay recorded responses; the bar is zero real outbound traffic. Tests must restore any process-level state they stub (`sys.modules`, singletons, caches, settings). New tests must be pytest-native (function + `assert` + fixtures); do not add new `unittest.TestCase`. Convert existing `TestCase` files to pytest-native opportunistically when you modify them. Before opening a PR to `v2`, run the full suite locally (`python tests/run.py`) and confirm it is green with zero real network calls; the `.github/workflows/test.yml` gate runs the same suite on every PR/push to `v2`.
|
||||
|
||||
### Commands and Development Workflow
|
||||
* **Primary Reference:** `docs/rules/03-commands.md`
|
||||
* **Required Constraints:** Only suggest or execute commands documented in that file. Do not assume tool defaults or global flags.
|
||||
|
||||
64
README.md
64
README.md
@@ -1,4 +1,3 @@
|
||||
|
||||
# MoviePilot
|
||||
|
||||
简体中文 | [English](README_EN.md)
|
||||
@@ -12,66 +11,56 @@
|
||||

|
||||

|
||||
|
||||
|
||||
基于 [NAStool](https://github.com/NAStool/nas-tools) 部分代码重新设计,聚焦自动化核心需求,减少问题同时更易于扩展和维护。
|
||||
|
||||
# 仅用于学习交流使用,请勿在任何国内平台宣传该项目!
|
||||
|
||||
发布频道:https://t.me/moviepilot_channel
|
||||
|
||||
|
||||
## 主要特性
|
||||
|
||||
- 前后端分离,基于FastApi + Vue3。
|
||||
- 聚焦核心需求,简化功能和设置,部分设置项可直接使用默认值。
|
||||
- 重新设计了用户界面,更加美观易用。
|
||||
|
||||
- 聚焦影视自动化的核心流程:订阅、搜索、下载、整理、刮削、媒体库刷新与消息通知。
|
||||
- 前后端分离,后端基于 FastAPI,前端基于 Vue 3,部署和扩展边界更清晰。
|
||||
- 支持下载器、媒体服务器、元数据源、消息渠道、插件、工作流和 AI Agent 等能力组合。
|
||||
- 更完整的功能介绍、截图和使用入口见官网:https://movie-pilot.org
|
||||
|
||||
## 安装使用
|
||||
|
||||
官方Wiki:https://wiki.movie-pilot.org
|
||||
推荐优先使用 Docker 部署,常用镜像包括 `jxxghp/moviepilot-v2` 和 `jxxghp/moviepilot`。Compose 示例、环境变量、目录映射和升级方式以官方 Wiki 为准:
|
||||
|
||||
- 官方 Wiki:https://wiki.movie-pilot.org
|
||||
- PostgreSQL 部署说明:[docs/postgresql-setup.md](docs/postgresql-setup.md)
|
||||
|
||||
## 本地 CLI
|
||||
|
||||
一键安装运行脚本:
|
||||
也可以使用本地 CLI 以源码模式安装和管理 MoviePilot:
|
||||
|
||||
```shell
|
||||
curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v2/scripts/bootstrap-local.sh | bash
|
||||
```
|
||||
|
||||
使用 `moviepilot` 命令管理MoviePilot,完整 CLI 文档:[`docs/cli.md`](docs/cli.md)
|
||||
安装完成后使用 `moviepilot` 命令完成初始化、启动、停止、更新和配置查看。完整命令见 [docs/cli.md](docs/cli.md)。
|
||||
|
||||
## Agent
|
||||
|
||||
1. MoviePilot 自带智能体能力,可在完成模型配置后,通过自然语言调用系统工具,辅助完成搜索、订阅、下载、整理、排障等管理任务。
|
||||
2. 其它智能体可以导入本仓库的 `skills/` 目录以获得 MoviePilot 操作能力;支持 `skills` CLI 的环境可使用:
|
||||
|
||||
```shell
|
||||
npx skills add https://github.com/jxxghp/MoviePilot
|
||||
```
|
||||
|
||||
内置 Skills 列表见 [skills/](skills/),自定义 Skill 可参考 [skills/create-moviepilot-skill/SKILL.md](skills/create-moviepilot-skill/SKILL.md)。
|
||||
3. 其它 MCP 客户端可以通过 MoviePilot 的 MCP 端点 `/api/v1/mcp` 调用工具,认证方式、客户端配置和工具 API 见 [docs/mcp-api.md](docs/mcp-api.md)。
|
||||
|
||||
## 为 AI Agent 添加 Skills
|
||||
```shell
|
||||
npx skills add https://github.com/jxxghp/MoviePilot
|
||||
```
|
||||
|
||||
## 参与开发
|
||||
|
||||
API文档:https://api.movie-pilot.org
|
||||
开发前请先阅读仓库规则和本地环境说明,保持变更聚焦,通过测试后再提交 PR。常用入口:
|
||||
|
||||
MCP工具API文档:详见 [docs/mcp-api.md](docs/mcp-api.md)
|
||||
|
||||
开发环境准备与本地源码运行说明:[`docs/development-setup.md`](docs/development-setup.md)
|
||||
|
||||
本地开发默认通过 PyPI 依赖安装 Rust 加速扩展;扩展未安装或 `RUST_ACCEL=false` 时会自动使用 Python 实现:
|
||||
|
||||
```shell
|
||||
python -m pip install moviepilot-rust
|
||||
python -c "from app.utils import rust_accel; print(rust_accel.is_available())"
|
||||
```
|
||||
|
||||
如果输出 `True`,说明当前开发环境已经加载 `moviepilot_rust`。Rust 源码和打包发布流程在 [MoviePilot-Rust](https://github.com/jxxghp/MoviePilot-Rust) 仓库维护。
|
||||
|
||||
需要本地评估 Rust 加速效果时,可运行:
|
||||
|
||||
```shell
|
||||
python scripts/benchmark_rust_accel.py --loops 20 --repeat 5
|
||||
```
|
||||
|
||||
插件开发说明:<https://wiki.movie-pilot.org/zh/plugindev>
|
||||
- 文档规则入口:[docs/rules/README.md](docs/rules/README.md)
|
||||
- 开发环境与本地源码运行:[docs/development-setup.md](docs/development-setup.md)
|
||||
- 测试说明:[docs/testing.md](docs/testing.md)
|
||||
- REST API 文档:https://api.movie-pilot.org
|
||||
- 插件开发说明:https://wiki.movie-pilot.org/zh/plugindev
|
||||
|
||||
## 相关项目
|
||||
|
||||
@@ -79,6 +68,7 @@ python scripts/benchmark_rust_accel.py --loops 20 --repeat 5
|
||||
- [MoviePilot-Resources](https://github.com/jxxghp/MoviePilot-Resources)
|
||||
- [MoviePilot-Plugins](https://github.com/jxxghp/MoviePilot-Plugins)
|
||||
- [MoviePilot-Server](https://github.com/jxxghp/MoviePilot-Server)
|
||||
- [MoviePilot-Rust](https://github.com/jxxghp/MoviePilot-Rust)
|
||||
- [MoviePilot-Wiki](https://github.com/jxxghp/MoviePilot-Wiki)
|
||||
|
||||
## 免责申明
|
||||
|
||||
48
README_EN.md
48
README_EN.md
@@ -17,44 +17,49 @@ Redesigned from parts of [NAStool](https://github.com/NAStool/nas-tools), with a
|
||||
|
||||
Release channel: https://t.me/moviepilot_channel
|
||||
|
||||
|
||||
## Key Features
|
||||
|
||||
- Frontend/backend separation based on FastApi + Vue3.
|
||||
- Focuses on core needs, simplifies features and settings, and allows some options to work well with sensible defaults.
|
||||
- Reworked user interface for a cleaner and more practical experience.
|
||||
- Focuses on the core media automation flow: subscriptions, search, downloads, file organization, scraping, media server refresh, and notifications.
|
||||
- Uses a separated backend/frontend architecture: FastAPI for the backend and Vue 3 for the frontend.
|
||||
- Connects download clients, media servers, metadata providers, message channels, plugins, workflows, and AI Agent capabilities.
|
||||
- For feature details, screenshots, and product entry points, see https://movie-pilot.org
|
||||
|
||||
## Installation and Usage
|
||||
|
||||
## Installation
|
||||
Docker is the recommended deployment model. Common images include `jxxghp/moviepilot-v2` and `jxxghp/moviepilot`. Compose examples, environment variables, volume mappings, and upgrade notes are maintained in the official wiki:
|
||||
|
||||
Official wiki: https://wiki.movie-pilot.org
|
||||
- Official wiki: https://wiki.movie-pilot.org
|
||||
- PostgreSQL setup: [docs/postgresql-setup.md](docs/postgresql-setup.md)
|
||||
|
||||
|
||||
## Local CLI
|
||||
|
||||
One-command bootstrap script:
|
||||
MoviePilot can also be installed and managed from source with the local CLI:
|
||||
|
||||
```shell
|
||||
curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v2/scripts/bootstrap-local.sh | bash
|
||||
```
|
||||
|
||||
Manage MoviePilot with the `moviepilot` command. Full CLI documentation: [`docs/cli.md`](docs/cli.md)
|
||||
After installation, use the `moviepilot` command for initialization, service management, updates, and configuration. See [docs/cli.md](docs/cli.md) for the full command reference.
|
||||
|
||||
## Agent
|
||||
|
||||
## Add Skills for AI Agents
|
||||
```shell
|
||||
npx skills add https://github.com/jxxghp/MoviePilot
|
||||
```
|
||||
1. MoviePilot includes a built-in AI Agent. After model configuration, it can call system tools through natural language to help with search, subscriptions, downloads, organization, diagnostics, and other management tasks.
|
||||
2. Other agents can import the repository `skills/` directory to gain MoviePilot operation capabilities. Environments that support the `skills` CLI can use:
|
||||
|
||||
```shell
|
||||
npx skills add https://github.com/jxxghp/MoviePilot
|
||||
```
|
||||
|
||||
Built-in skills live in [skills/](skills/). For custom skill authoring, see [skills/create-moviepilot-skill/SKILL.md](skills/create-moviepilot-skill/SKILL.md).
|
||||
3. Other MCP clients can call MoviePilot tools through `/api/v1/mcp`. Authentication, client configuration, and tool APIs are documented in [docs/mcp-api.md](docs/mcp-api.md).
|
||||
|
||||
## Development
|
||||
|
||||
API documentation: https://api.movie-pilot.org
|
||||
Before contributing, read the repository rules and local environment guide, keep changes focused, and validate them before opening a PR. Useful entry points:
|
||||
|
||||
MCP tool API documentation: see [docs/mcp-api.md](docs/mcp-api.md)
|
||||
|
||||
Development environment setup and local source-run guide: [`docs/development-setup.md`](docs/development-setup.md)
|
||||
|
||||
Plugin development guide: <https://wiki.movie-pilot.org/zh/plugindev>
|
||||
- Rule index: [docs/rules/README.md](docs/rules/README.md)
|
||||
- Development setup and local source run: [docs/development-setup.md](docs/development-setup.md)
|
||||
- Testing guide: [docs/testing.md](docs/testing.md)
|
||||
- REST API documentation: https://api.movie-pilot.org
|
||||
- Plugin development guide: https://wiki.movie-pilot.org/zh/plugindev
|
||||
|
||||
## Related Projects
|
||||
|
||||
@@ -62,6 +67,7 @@ Plugin development guide: <https://wiki.movie-pilot.org/zh/plugindev>
|
||||
- [MoviePilot-Resources](https://github.com/jxxghp/MoviePilot-Resources)
|
||||
- [MoviePilot-Plugins](https://github.com/jxxghp/MoviePilot-Plugins)
|
||||
- [MoviePilot-Server](https://github.com/jxxghp/MoviePilot-Server)
|
||||
- [MoviePilot-Rust](https://github.com/jxxghp/MoviePilot-Rust)
|
||||
- [MoviePilot-Wiki](https://github.com/jxxghp/MoviePilot-Wiki)
|
||||
|
||||
## Disclaimer
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import warnings
|
||||
|
||||
|
||||
def _filter_third_party_startup_warnings() -> None:
|
||||
"""
|
||||
过滤第三方库在新版 Python 下产生的已知无害启动警告。
|
||||
"""
|
||||
warnings.filterwarnings(
|
||||
"ignore",
|
||||
message=r"invalid escape sequence '\\&'",
|
||||
category=SyntaxWarning,
|
||||
)
|
||||
|
||||
|
||||
_filter_third_party_startup_warnings()
|
||||
|
||||
@@ -36,6 +36,12 @@ from app.agent.middleware.memory import MemoryMiddleware
|
||||
from app.agent.middleware.patch_tool_calls import PatchToolCallsMiddleware
|
||||
from app.agent.middleware.runtime_config import RuntimeConfigMiddleware
|
||||
from app.agent.middleware.skills import SkillsMiddleware
|
||||
from app.agent.middleware.subagents import (
|
||||
SUBAGENT_CONTROL_TOOL_NAME,
|
||||
SUBAGENT_TASK_TOOL_NAME,
|
||||
create_subagent_middlewares,
|
||||
is_subagent_stream_metadata,
|
||||
)
|
||||
from app.agent.middleware.tool_selection import ToolSelectorMiddleware
|
||||
from app.agent.middleware.usage import UsageMiddleware
|
||||
from app.agent.prompt import prompt_manager
|
||||
@@ -44,6 +50,7 @@ from app.agent.tools.factory import MoviePilotToolFactory
|
||||
from app.chain import ChainBase
|
||||
from app.core.config import settings
|
||||
from app.core.event import eventmanager
|
||||
from app.db.user_oper import UserOper
|
||||
from app.log import logger
|
||||
from app.schemas import AgentLLMProviderEventData, AgentTokensUsageEventData, Notification, NotificationType
|
||||
from app.schemas.message import ChannelCapabilityManager, ChannelCapability
|
||||
@@ -273,6 +280,15 @@ class MoviePilotAgent:
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _get_recursion_limit() -> int:
|
||||
"""读取 LangGraph 递归上限,防止模型持续循环调用工具。"""
|
||||
try:
|
||||
limit = int(settings.LLM_MAX_ITERATIONS or 0)
|
||||
except (TypeError, ValueError):
|
||||
limit = 0
|
||||
return limit if limit > 0 else 128
|
||||
|
||||
@classmethod
|
||||
def _get_model_name(cls, model: Any) -> Optional[str]:
|
||||
return (
|
||||
@@ -403,6 +419,38 @@ class MoviePilotAgent:
|
||||
"""
|
||||
return self.session_id.startswith(HEARTBEAT_SESSION_PREFIX)
|
||||
|
||||
async def _is_system_admin_context(self) -> bool:
|
||||
"""
|
||||
判断当前 Agent 会话是否应按系统管理员上下文运行工具。
|
||||
"""
|
||||
if self.is_background:
|
||||
return True
|
||||
if self.channel == MessageChannel.Web.value and self.source in {
|
||||
"openai",
|
||||
"openai.responses",
|
||||
"anthropic",
|
||||
}:
|
||||
return True
|
||||
if not self.username:
|
||||
return False
|
||||
try:
|
||||
user = await UserOper().async_get_by_name(self.username)
|
||||
except Exception as e:
|
||||
logger.error(f"检查 Agent 用户管理员身份失败: {e}")
|
||||
return False
|
||||
return bool(user and user.is_superuser)
|
||||
|
||||
async def _build_tool_context(self, should_dispatch_reply: bool) -> Dict[str, object]:
|
||||
"""
|
||||
构造本轮工具共享上下文。
|
||||
"""
|
||||
return {
|
||||
"user_reply_sent": False,
|
||||
"reply_mode": None,
|
||||
"should_dispatch_reply": should_dispatch_reply,
|
||||
"is_admin": await self._is_system_admin_context(),
|
||||
}
|
||||
|
||||
def _should_stream(self) -> bool:
|
||||
"""
|
||||
判断是否应启用流式输出:
|
||||
@@ -774,6 +822,26 @@ class MoviePilotAgent:
|
||||
allow_message_tools=self.allow_message_tools,
|
||||
)
|
||||
|
||||
def _initialize_subagent_tools(self) -> List:
|
||||
"""
|
||||
初始化子代理专用静默工具列表。
|
||||
"""
|
||||
return MoviePilotToolFactory.create_tools(
|
||||
session_id=self.session_id,
|
||||
user_id=self.user_id,
|
||||
channel=self.channel,
|
||||
source=self.source,
|
||||
username=self.username,
|
||||
stream_handler=None,
|
||||
agent_context={
|
||||
"user_reply_sent": False,
|
||||
"reply_mode": None,
|
||||
"should_dispatch_reply": False,
|
||||
"is_admin": bool(self._tool_context.get("is_admin")),
|
||||
},
|
||||
allow_message_tools=False,
|
||||
)
|
||||
|
||||
async def _create_agent(self, streaming: bool = False):
|
||||
"""
|
||||
创建 LangGraph Agent(使用 create_agent + SummarizationMiddleware)
|
||||
@@ -796,10 +864,22 @@ class MoviePilotAgent:
|
||||
|
||||
# 工具列表
|
||||
tools = self._initialize_tools()
|
||||
subagent_middlewares, subagent_task_tools = create_subagent_middlewares(
|
||||
model=non_streaming_model,
|
||||
tools=self._initialize_subagent_tools(),
|
||||
stream_handler=self.stream_handler,
|
||||
)
|
||||
max_tools = settings.LLM_MAX_TOOLS
|
||||
always_include_tools = (
|
||||
MoviePilotToolFactory.get_tool_selector_always_include_names(tools)
|
||||
)
|
||||
if subagent_task_tools:
|
||||
always_include_tools.extend(
|
||||
tool.name
|
||||
for tool in subagent_task_tools
|
||||
if getattr(tool, "name", None)
|
||||
in {SUBAGENT_TASK_TOOL_NAME, SUBAGENT_CONTROL_TOOL_NAME}
|
||||
)
|
||||
|
||||
# 中间件
|
||||
middlewares = [
|
||||
@@ -822,6 +902,8 @@ class MoviePilotAgent:
|
||||
),
|
||||
# 错误工具调用修复
|
||||
PatchToolCallsMiddleware(),
|
||||
# 子代理委派
|
||||
*subagent_middlewares,
|
||||
# 用量统计
|
||||
UsageMiddleware(on_usage=self._record_usage),
|
||||
]
|
||||
@@ -839,7 +921,7 @@ class MoviePilotAgent:
|
||||
middlewares.append(
|
||||
ToolSelectorMiddleware(
|
||||
model=non_streaming_model,
|
||||
selection_tools=tools,
|
||||
selection_tools=[*tools, *subagent_task_tools],
|
||||
max_tools=max_tools,
|
||||
always_include=always_include_tools,
|
||||
)
|
||||
@@ -861,6 +943,7 @@ class MoviePilotAgent:
|
||||
message: str,
|
||||
images: List[str] = None,
|
||||
files: Optional[List[dict]] = None,
|
||||
has_audio_input: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
处理用户消息,流式推理并返回 Agent 回复
|
||||
@@ -868,13 +951,12 @@ class MoviePilotAgent:
|
||||
try:
|
||||
logger.info(
|
||||
f"Agent推理: session_id={self.session_id}, input={message}, "
|
||||
f"images={len(images) if images else 0}, files={len(files) if files else 0}"
|
||||
f"images={len(images) if images else 0}, files={len(files) if files else 0}, "
|
||||
f"audio_input={has_audio_input}"
|
||||
)
|
||||
self._tool_context = await self._build_tool_context(
|
||||
should_dispatch_reply=self.should_dispatch_reply
|
||||
)
|
||||
self._tool_context = {
|
||||
"user_reply_sent": False,
|
||||
"reply_mode": None,
|
||||
"should_dispatch_reply": self.should_dispatch_reply,
|
||||
}
|
||||
self._streamed_output = ""
|
||||
|
||||
# 获取历史消息
|
||||
@@ -885,6 +967,10 @@ class MoviePilotAgent:
|
||||
# 构建结构化用户消息内容
|
||||
request_payload = {
|
||||
"message": message or "",
|
||||
"input": {
|
||||
"mode": "voice" if has_audio_input else "text",
|
||||
"transcribed": bool(has_audio_input),
|
||||
},
|
||||
"images": [
|
||||
{"index": index + 1, "type": "image"}
|
||||
for index, _ in enumerate(images or [])
|
||||
@@ -936,6 +1022,8 @@ class MoviePilotAgent:
|
||||
):
|
||||
if chunk["type"] == "messages":
|
||||
token, metadata = chunk["data"]
|
||||
if is_subagent_stream_metadata(metadata):
|
||||
continue
|
||||
if not token or not hasattr(token, "tool_call_chunks"):
|
||||
continue
|
||||
|
||||
@@ -977,7 +1065,8 @@ class MoviePilotAgent:
|
||||
agent_config = {
|
||||
"configurable": {
|
||||
"thread_id": self.session_id,
|
||||
}
|
||||
},
|
||||
"recursion_limit": self._get_recursion_limit(),
|
||||
}
|
||||
|
||||
# 判断是否启用流式输出
|
||||
@@ -1187,6 +1276,7 @@ class _MessageTask:
|
||||
message: str
|
||||
images: Optional[List[str]] = None
|
||||
files: Optional[List[dict]] = None
|
||||
has_audio_input: bool = False
|
||||
channel: Optional[str] = None
|
||||
source: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
@@ -1333,6 +1423,7 @@ class AgentManager:
|
||||
message: str,
|
||||
images: List[str] = None,
|
||||
files: Optional[List[dict]] = None,
|
||||
has_audio_input: bool = False,
|
||||
channel: str = None,
|
||||
source: str = None,
|
||||
username: str = None,
|
||||
@@ -1352,6 +1443,7 @@ class AgentManager:
|
||||
message=message,
|
||||
images=images,
|
||||
files=files,
|
||||
has_audio_input=has_audio_input,
|
||||
channel=channel,
|
||||
source=source,
|
||||
username=username,
|
||||
@@ -1488,7 +1580,13 @@ class AgentManager:
|
||||
agent.persist_output_message = task.persist_output_message
|
||||
agent.allow_message_tools = task.allow_message_tools
|
||||
|
||||
return await agent.process(task.message, images=task.images, files=task.files)
|
||||
process_kwargs = {
|
||||
"images": task.images,
|
||||
"files": task.files,
|
||||
}
|
||||
if task.has_audio_input:
|
||||
process_kwargs["has_audio_input"] = True
|
||||
return await agent.process(task.message, **process_kwargs)
|
||||
|
||||
async def stop_current_task(self, session_id: str):
|
||||
"""
|
||||
@@ -1539,7 +1637,7 @@ class AgentManager:
|
||||
await self._session_workers[session_id]
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._session_workers.pop(session_id, None)
|
||||
self._session_workers.pop(session_id, None) # noqa
|
||||
|
||||
# 清理队列
|
||||
self._session_queues.pop(session_id, None)
|
||||
|
||||
@@ -293,6 +293,8 @@ class StreamingHandler:
|
||||
tool_message = (tool_message or "").strip()
|
||||
tool_message_lower = tool_message.lower()
|
||||
|
||||
if tool_name == "task":
|
||||
return "subagent", tool_kwargs.get("subagent_type")
|
||||
if tool_name == "read_file":
|
||||
return "file_read", tool_kwargs.get("file_path")
|
||||
if tool_name in {"write_file", "edit_file"}:
|
||||
@@ -408,6 +410,8 @@ class StreamingHandler:
|
||||
return f"执行了 {count} 次操作"
|
||||
if category == "interaction":
|
||||
return f"发起了 {count} 次交互"
|
||||
if category == "subagent":
|
||||
return f"已调用 {count} 个子代理"
|
||||
return f"调用了 {count} 次工具"
|
||||
|
||||
def _can_stream(self) -> bool:
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
version: 1
|
||||
subagent_id: download-diagnostician
|
||||
label: 下载诊断
|
||||
description: Download and transfer diagnosis subagent for downloaders, download tasks, transfer history, and library status.
|
||||
include_tags:
|
||||
- download
|
||||
- transfer
|
||||
- library
|
||||
- directory
|
||||
- file
|
||||
- media
|
||||
exclude_tags:
|
||||
- write
|
||||
- message
|
||||
- user_interaction
|
||||
---
|
||||
# SUBAGENT
|
||||
|
||||
You specialize in downloaders, download tasks, transfer history, directory settings, and library ingestion state.
|
||||
35
app/agent/defaults/subagents/general-purpose/SUBAGENT.md
Normal file
35
app/agent/defaults/subagents/general-purpose/SUBAGENT.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
version: 1
|
||||
subagent_id: general-purpose
|
||||
label: 通用调查
|
||||
description: General read-only investigation subagent for cross-domain MoviePilot analysis and execution recommendations.
|
||||
include_tags:
|
||||
- media
|
||||
- resource
|
||||
- site
|
||||
- subscription
|
||||
- download
|
||||
- library
|
||||
- transfer
|
||||
- system
|
||||
- settings
|
||||
- plugin
|
||||
- workflow
|
||||
- scheduler
|
||||
- file
|
||||
- directory
|
||||
- web
|
||||
- command
|
||||
- filter_rule
|
||||
- persona
|
||||
- slash_command
|
||||
- recommendation
|
||||
- metadata
|
||||
exclude_tags:
|
||||
- write
|
||||
- message
|
||||
- user_interaction
|
||||
---
|
||||
# SUBAGENT
|
||||
|
||||
You specialize in synthesizing media, site, subscription, download, and system status signals.
|
||||
19
app/agent/defaults/subagents/media-researcher/SUBAGENT.md
Normal file
19
app/agent/defaults/subagents/media-researcher/SUBAGENT.md
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
version: 1
|
||||
subagent_id: media-researcher
|
||||
label: 媒体研究
|
||||
description: Media research subagent for title recognition, people, episodes, metadata, and library existence checks.
|
||||
include_tags:
|
||||
- media
|
||||
- library
|
||||
- recommendation
|
||||
- metadata
|
||||
- web
|
||||
exclude_tags:
|
||||
- write
|
||||
- message
|
||||
- user_interaction
|
||||
---
|
||||
# SUBAGENT
|
||||
|
||||
You specialize in media identity resolution, metadata validation, person credits, and library status analysis.
|
||||
19
app/agent/defaults/subagents/moviepilot-explorer/SUBAGENT.md
Normal file
19
app/agent/defaults/subagents/moviepilot-explorer/SUBAGENT.md
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
version: 1
|
||||
subagent_id: moviepilot-explorer
|
||||
label: 代码探索
|
||||
description: MoviePilot exploration subagent for source-code inspection, configuration structure analysis, logs, and code-level troubleshooting clues.
|
||||
include_tags:
|
||||
- system
|
||||
- settings
|
||||
- file
|
||||
- directory
|
||||
- command
|
||||
exclude_tags:
|
||||
- write
|
||||
- message
|
||||
- user_interaction
|
||||
---
|
||||
# SUBAGENT
|
||||
|
||||
You specialize in MoviePilot source-code structure, local configuration files, directory layout, logs or read-only command output, and code-level root-cause troubleshooting. Prefer reading relevant code paths before judging behavior, and distinguish code/config evidence from runtime system state.
|
||||
18
app/agent/defaults/subagents/resource-searcher/SUBAGENT.md
Normal file
18
app/agent/defaults/subagents/resource-searcher/SUBAGENT.md
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
version: 1
|
||||
subagent_id: resource-searcher
|
||||
label: 资源搜索
|
||||
description: Site and resource search subagent for site checks, torrent search, and resource quality analysis.
|
||||
include_tags:
|
||||
- resource
|
||||
- site
|
||||
- web
|
||||
- media
|
||||
exclude_tags:
|
||||
- write
|
||||
- message
|
||||
- user_interaction
|
||||
---
|
||||
# SUBAGENT
|
||||
|
||||
You specialize in site status, site user data, torrent search results, and resource quality judgment.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
version: 1
|
||||
subagent_id: subscription-analyst
|
||||
label: 订阅分析
|
||||
description: Subscription analysis subagent for subscriptions, history, filter rules, and custom identifiers.
|
||||
include_tags:
|
||||
- subscription
|
||||
- filter_rule
|
||||
- settings
|
||||
- media
|
||||
exclude_tags:
|
||||
- write
|
||||
- message
|
||||
- user_interaction
|
||||
---
|
||||
# SUBAGENT
|
||||
|
||||
You specialize in current subscription state, subscription history, filter rules, and subscription optimization suggestions.
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
version: 1
|
||||
subagent_id: system-diagnostician
|
||||
label: 系统诊断
|
||||
description: System diagnosis subagent for read-only inspection of settings, schedulers, workflows, plugins, directories, and command output.
|
||||
include_tags:
|
||||
- system
|
||||
- settings
|
||||
- plugin
|
||||
- workflow
|
||||
- scheduler
|
||||
- file
|
||||
- directory
|
||||
- web
|
||||
- command
|
||||
- persona
|
||||
- slash_command
|
||||
exclude_tags:
|
||||
- write
|
||||
- message
|
||||
- user_interaction
|
||||
---
|
||||
# SUBAGENT
|
||||
|
||||
You specialize in settings, plugins, scheduled tasks, workflows, directories, and read-only command diagnostics.
|
||||
@@ -670,6 +670,11 @@ class AgentCapabilityManager:
|
||||
def _normalize_provider_name(provider: Optional[str]) -> str:
|
||||
return (provider or "openai").strip().lower()
|
||||
|
||||
@staticmethod
|
||||
def _get_provider_log_name(provider: AudioCapabilityProvider) -> str:
|
||||
provider_name = getattr(provider, "name", None)
|
||||
return provider_name if isinstance(provider_name, str) else provider.__class__.__name__
|
||||
|
||||
@classmethod
|
||||
def get_audio_provider(cls, mode: str) -> Optional[AudioCapabilityProvider]:
|
||||
provider_name = cls._normalize_provider_name(
|
||||
@@ -714,17 +719,45 @@ class AgentCapabilityManager:
|
||||
|
||||
@classmethod
|
||||
def transcribe_audio(cls, content: bytes, filename: str = "input.ogg") -> Optional[str]:
|
||||
"""将语音文件内容转写为文字,并记录能力调用日志。"""
|
||||
provider = cls.get_audio_provider("input")
|
||||
if not provider or not cls.is_audio_input_available():
|
||||
logger.info("语音转文字跳过:音频输入能力未启用或 provider 不可用")
|
||||
return None
|
||||
return provider.transcribe_audio(content=content, filename=filename)
|
||||
provider_name = cls._get_provider_log_name(provider)
|
||||
logger.info(
|
||||
f"语音转文字开始:provider={provider_name}, filename={filename}, "
|
||||
f"bytes={len(content) if content else 0}"
|
||||
)
|
||||
transcript = provider.transcribe_audio(content=content, filename=filename)
|
||||
if transcript:
|
||||
logger.info(
|
||||
f"语音转文字完成:provider={provider_name}, filename={filename}, "
|
||||
f"text_len={len(transcript)}"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"语音转文字无结果:provider={provider_name}, filename={filename}"
|
||||
)
|
||||
return transcript
|
||||
|
||||
@classmethod
|
||||
def synthesize_speech(cls, text: str) -> Optional[Path]:
|
||||
"""将文字合成为语音文件,并记录能力调用日志。"""
|
||||
provider = cls.get_audio_provider("output")
|
||||
if not provider or not cls.is_audio_output_available():
|
||||
logger.info("文字转语音跳过:音频输出能力未启用或 provider 不可用")
|
||||
return None
|
||||
return provider.synthesize_speech(text=text)
|
||||
provider_name = cls._get_provider_log_name(provider)
|
||||
logger.info(
|
||||
f"文字转语音开始:provider={provider_name}, text_len={len(text) if text else 0}"
|
||||
)
|
||||
output_path = provider.synthesize_speech(text=text)
|
||||
if output_path:
|
||||
logger.info(f"文字转语音完成:provider={provider_name}, path={output_path}")
|
||||
else:
|
||||
logger.info(f"文字转语音无结果:provider={provider_name}")
|
||||
return output_path
|
||||
|
||||
@classmethod
|
||||
def resolve_reply_mode(cls, channel: Optional[str], source: Optional[str]) -> str:
|
||||
|
||||
@@ -87,7 +87,7 @@ def _patch_gemini_thought_signature():
|
||||
# 补丁 2:修复 _parse_chat_history 中 first_fc_seen 只修复第一个
|
||||
# function_call 的问题。用 wrapper 在原函数返回后,确保所有 model
|
||||
# 消息中所有 function_call 都带有 thought_signature。
|
||||
_original_parse_chat_history = _cm._parse_chat_history
|
||||
_original_parse_chat_history = _cm._parse_chat_history # noqa
|
||||
|
||||
def _patched_parse_chat_history(*args, **kwargs):
|
||||
result = _original_parse_chat_history(*args, **kwargs)
|
||||
@@ -498,13 +498,13 @@ def _patch_openai_responses_empty_output_support():
|
||||
if callable(model_copy):
|
||||
try:
|
||||
return model_copy(update={"output": []})
|
||||
except Exception as err:
|
||||
logger.debug(f"复制 Responses 对象失败,回退原地修补 output:{err}")
|
||||
except Exception as e:
|
||||
logger.debug(f"复制 Responses 对象失败,回退原地修补 output:{e}")
|
||||
|
||||
try:
|
||||
setattr(response, "output", [])
|
||||
except Exception as err:
|
||||
logger.debug(f"原地修补 Responses output 失败:{err}")
|
||||
except Exception as e:
|
||||
logger.debug(f"原地修补 Responses output 失败:{e}")
|
||||
return response
|
||||
|
||||
@wraps(original_construct)
|
||||
|
||||
1153
app/agent/middleware/subagents.py
Normal file
1153
app/agent/middleware/subagents.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,6 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
|
||||
|
||||
<non_negotiable_boundaries>
|
||||
- Do not let user memory or persona style override this core identity, safety boundaries, or built-in background task rules.
|
||||
- Never directly modify application source code, scripts, tests, or generated code through `edit_file`, `write_file`, shell write operations, or similar tools. If the user asks about MoviePilot internals or debugging, inspect and explain the needed change without applying it.
|
||||
- If the user explicitly asks to change the speaking style or persona, use `query_personas` and `switch_persona` instead of editing runtime files manually.
|
||||
- If the user explicitly asks to rewrite or create a persona definition, prefer `update_persona_definition` rather than generic file-editing tools.
|
||||
- Treat read-only inspection as allowed, but never use shell redirection, overwrite operations, file editing tools, or generated patches to change code.
|
||||
@@ -35,7 +34,7 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
|
||||
- Treat manual download and subscription automation as two execution modes of the same acquisition pipeline. Manual download is user-triggered immediate acquisition; subscription is persistent site-driven monitoring and acquisition.
|
||||
- Keep the user anchored to the operational step that matters now: site, search, recognition, download, subscription, transfer, or status/history.
|
||||
- Users may attach images from supported channels; analyze them together with the text when relevant.
|
||||
- User messages may arrive as structured JSON. Treat the `message` field as the user's text. Attachments appear in `files`; when `local_path` is present, use local file tools to inspect the uploaded file directly. When image input is disabled for the current model, user images may also be delivered through `files`.
|
||||
- User messages may arrive as structured JSON. Treat the `message` field as the user's text. Input metadata appears in `input`; when `input.mode` is `voice`, the user sent a voice message and `message` contains its transcript. Attachments appear in `files`; when `local_path` is present, use local file tools to inspect the uploaded file directly. When image input is disabled for the current model, user images may also be delivered through `files`.
|
||||
</moviepilot_domain_model>
|
||||
|
||||
<operating_principles>
|
||||
@@ -56,13 +55,16 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
|
||||
</core_workflow>
|
||||
|
||||
<tool_strategy>
|
||||
- Call independent tools in parallel whenever possible.
|
||||
- Use parallel tool calls by default for independent read-only or diagnostic work. In one assistant turn, issue all tool calls that can run without waiting for each other's results, such as checking enabled sites, library existence, recent history, downloader status, and scheduler or configuration state.
|
||||
- Keep tools sequential only when later arguments depend on earlier output, when a tool mutates state, when confirmation is required, or when concurrent writes could conflict.
|
||||
- When planning a multi-step investigation, group the first wave of safe state-gathering calls together, then continue with dependent actions after those results return.
|
||||
- For system startup, Docker, dependency, database, frontend asset, port, safe-mode, or unclear runtime failures, use `query_doctor_report` early to collect the read-only Doctor diagnostic report before falling back to generic command execution.
|
||||
- Prefer site-aware tool paths when the task is about torrents, subscriptions, or download failures. `query_sites`, `test_site`, and `query_site_userdata` are part of the main operating flow, not edge-case tools.
|
||||
- If search results are ambiguous, use `query_media_detail` or `recognize_media` to clarify before proceeding.
|
||||
- For fuzzy torrent names, filenames, or manually provided paths, prefer `recognize_media` before asking the user for a cleaner title.
|
||||
- If `search_media` fails, fall back to `search_web` or `recognize_media`. Only ask the user when automated paths are exhausted.
|
||||
- If torrent search yields no useful result, check site scope, site health, and recognition quality before concluding that the resource is unavailable.
|
||||
- Reuse the latest torrent search cache for `get_search_results` and `add_download` instead of re-running the same search unnecessarily.
|
||||
- Reuse the latest torrent search cache for `get_search_results` and `add_download_tasks` instead of re-running the same search unnecessarily.
|
||||
- Use `execute_command` only for diagnostics, read-only inspection, or commands the user explicitly asked to run. Its default `action=start` starts a managed background session and returns `session_id`, `status`, `last_seq`, and `output_until_seq`; call the same tool again with `action=read`, `action=wait`, `action=write`, or `action=kill` to poll output, wait in short segments, send stdin, or stop the process.
|
||||
</tool_strategy>
|
||||
|
||||
|
||||
@@ -124,6 +124,8 @@ task_types:
|
||||
- "When several records obviously share the same media identity, avoid repeated `recognize_media` or `search_media` calls."
|
||||
- "Process every selected record exactly once."
|
||||
- "Keep the final response short and focused on the aggregate outcome."
|
||||
- "Final response must be plain text only: one concise Chinese sentence or paragraph describing the aggregate result."
|
||||
- "Do NOT include any title/header, bullet list, numbered list, bold text, code block, table, or other Markdown formatting."
|
||||
search_recommend:
|
||||
header: "[System Task - Search Results Recommendation]"
|
||||
objective: "Analyze the provided search results and select the best matching items based on user preferences."
|
||||
|
||||
@@ -396,7 +396,12 @@ class PromptManager:
|
||||
return (
|
||||
"Use normal text replies by default. Only call `send_voice_message` "
|
||||
"when the user explicitly asks for a voice reply or spoken playback "
|
||||
"is clearly better than plain text."
|
||||
"is clearly better than plain text. `send_voice_message` is a terminal "
|
||||
"response tool: put the complete user-facing reply in its `message` "
|
||||
"argument, then stop the turn. Do not also call `send_message`, do not "
|
||||
"write a final text reply after it, and do not repeat the same content "
|
||||
"as plain text. If native voice is unavailable, the tool sends the same "
|
||||
"content as a text fallback and still completes the reply."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -410,9 +415,11 @@ class PromptManager:
|
||||
):
|
||||
return (
|
||||
"- User questions: If you need the user to choose from a few clear options, "
|
||||
"call `ask_user_choice` to send button options. After the user clicks a button, "
|
||||
"the selected value will come back as the user's next message. After calling this tool, "
|
||||
"wait for the user's selection instead of repeating the question in plain text."
|
||||
"call `ask_user_choice` to send button options. `ask_user_choice` is a terminal "
|
||||
"interaction tool: put the full question and all options in the tool call, then "
|
||||
"stop the turn and wait for the user's selection. The selected value will come back "
|
||||
"as the user's next message. Do not also call `send_message`, do not write a final "
|
||||
"text reply after it, and do not repeat the question in plain text."
|
||||
)
|
||||
return "- User questions: When you truly need user input, ask briefly in plain text."
|
||||
|
||||
|
||||
@@ -22,8 +22,11 @@ JOBS_DIR = "jobs"
|
||||
ACTIVITY_DIR = "activity"
|
||||
PERSONAS_DIR = "personas"
|
||||
PERSONA_FILE = "PERSONA.md"
|
||||
SUBAGENTS_DIR = "subagents"
|
||||
SUBAGENT_FILE = "SUBAGENT.md"
|
||||
CURRENT_PERSONA_SCHEMA_VERSION = 3
|
||||
PERSONA_SCHEMA_VERSION = 1
|
||||
SUBAGENT_SCHEMA_VERSION = 1
|
||||
DEFAULT_PERSONA_ID = "default"
|
||||
PERSONA_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
|
||||
|
||||
@@ -111,6 +114,41 @@ class PersonaDefinition:
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubAgentDefinition:
|
||||
"""单个子代理定义。"""
|
||||
|
||||
subagent_id: str
|
||||
path: Path
|
||||
description: str
|
||||
text: str
|
||||
include_tags: list[str]
|
||||
exclude_tags: list[str]
|
||||
version: int = SUBAGENT_SCHEMA_VERSION
|
||||
label: str = ""
|
||||
|
||||
def summary_line(self) -> str:
|
||||
"""渲染可读的一行子代理摘要。"""
|
||||
parts = [f"`{self.subagent_id}`"]
|
||||
if self.label and self.label != self.subagent_id:
|
||||
parts.append(self.label)
|
||||
if self.description:
|
||||
parts.append(self.description)
|
||||
return " - ".join(parts)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""输出给查询或调试入口的结构化信息。"""
|
||||
return {
|
||||
"subagent_id": self.subagent_id,
|
||||
"label": self.label,
|
||||
"description": self.description,
|
||||
"include_tags": self.include_tags,
|
||||
"exclude_tags": self.exclude_tags,
|
||||
"version": self.version,
|
||||
"path": str(self.path),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentRuntimeConfig:
|
||||
"""一次加载后的根层配置快照。"""
|
||||
@@ -120,6 +158,7 @@ class AgentRuntimeConfig:
|
||||
current_persona_path: Path
|
||||
persona: PersonaDefinition
|
||||
available_personas: list[PersonaDefinition]
|
||||
available_subagents: list[SubAgentDefinition]
|
||||
extra_context_paths: list[Path]
|
||||
extra_contexts: list[tuple[Path, str]]
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
@@ -135,6 +174,12 @@ class AgentRuntimeConfig:
|
||||
if self.available_personas:
|
||||
sections.append("- Available personas:")
|
||||
sections.extend(f" - {persona.summary_line()}" for persona in self.available_personas)
|
||||
if self.available_subagents:
|
||||
sections.append("- Available subagents:")
|
||||
sections.extend(
|
||||
f" - {subagent.summary_line()}"
|
||||
for subagent in self.available_subagents
|
||||
)
|
||||
sections.append("</agent_runtime_config>")
|
||||
|
||||
if self.warnings:
|
||||
@@ -201,6 +246,7 @@ class AgentRuntimeManager:
|
||||
self.skills_dir = self.agent_root_dir / SKILLS_DIR
|
||||
self.jobs_dir = self.agent_root_dir / JOBS_DIR
|
||||
self.activity_dir = self.agent_root_dir / ACTIVITY_DIR
|
||||
self.subagents_dir = self.runtime_dir / SUBAGENTS_DIR
|
||||
self.bundled_defaults_dir = bundled_defaults_dir or (
|
||||
Path(__file__).parent / "defaults"
|
||||
)
|
||||
@@ -216,6 +262,7 @@ class AgentRuntimeManager:
|
||||
self.skills_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.jobs_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.activity_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.subagents_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._migrate_root_runtime_files()
|
||||
self._remove_obsolete_runtime_files()
|
||||
self._sync_bundled_defaults()
|
||||
@@ -278,6 +325,10 @@ class AgentRuntimeManager:
|
||||
"""列出当前可用人格。"""
|
||||
return self.load_runtime_config().available_personas
|
||||
|
||||
def list_subagents(self) -> list[SubAgentDefinition]:
|
||||
"""列出当前可用子代理。"""
|
||||
return self.load_runtime_config().available_subagents
|
||||
|
||||
def update_persona_definition(
|
||||
self,
|
||||
persona_query: str,
|
||||
@@ -382,7 +433,7 @@ class AgentRuntimeManager:
|
||||
return tuple(entries)
|
||||
|
||||
def _sync_bundled_defaults(self) -> None:
|
||||
"""仅复制缺失的默认运行时文件,避免覆盖用户自定义。"""
|
||||
"""同步默认运行时文件,并按版本更新内置子代理定义。"""
|
||||
if not self.bundled_defaults_dir.exists():
|
||||
return
|
||||
for path in sorted(self.bundled_defaults_dir.rglob("*")):
|
||||
@@ -392,11 +443,43 @@ class AgentRuntimeManager:
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
if target.exists():
|
||||
if self._should_update_bundled_subagent(relative, path, target):
|
||||
shutil.copy2(path, target)
|
||||
logger.info(f"已更新默认 Agent 子代理定义: {target}")
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(path, target)
|
||||
logger.info("已同步默认 Agent 运行时文件: %s", target)
|
||||
|
||||
@classmethod
|
||||
def _should_update_bundled_subagent(
|
||||
cls,
|
||||
relative_path: Path,
|
||||
source_path: Path,
|
||||
target_path: Path,
|
||||
) -> bool:
|
||||
"""判断是否需要用更高版本的内置子代理定义覆盖用户目录副本。"""
|
||||
parts = relative_path.parts
|
||||
if len(parts) < 3 or parts[0] != SUBAGENTS_DIR or relative_path.name != SUBAGENT_FILE:
|
||||
return False
|
||||
|
||||
source_version = cls._read_markdown_version(source_path)
|
||||
target_version = cls._read_markdown_version(target_path)
|
||||
return source_version > target_version
|
||||
|
||||
@staticmethod
|
||||
def _read_markdown_version(path: Path) -> int:
|
||||
"""读取 Markdown frontmatter 中的整数版本,失败时按 0 处理。"""
|
||||
try:
|
||||
document = AgentRuntimeManager._read_markdown(path)
|
||||
except AgentRuntimeConfigError as err:
|
||||
logger.warning(f"读取 Agent 运行时文件版本失败 {path}: {err}")
|
||||
return 0
|
||||
return AgentRuntimeManager._coerce_int_metadata(
|
||||
document.metadata.get("version"),
|
||||
default=0,
|
||||
)
|
||||
|
||||
def _migrate_root_runtime_files(self) -> None:
|
||||
"""兼容早期直接放在 `config/agent` 根目录的 CURRENT_PERSONA。"""
|
||||
source = self.agent_root_dir / CURRENT_PERSONA_FILE
|
||||
@@ -451,6 +534,7 @@ class AgentRuntimeManager:
|
||||
|
||||
available_personas = self._load_personas(root)
|
||||
persona = self._resolve_persona_definition(active_persona, available_personas)
|
||||
available_subagents = self._load_subagents(root)
|
||||
extra_contexts = [
|
||||
(path, self._read_markdown(path).body)
|
||||
for path in extra_context_paths
|
||||
@@ -468,6 +552,7 @@ class AgentRuntimeManager:
|
||||
current_persona_path=current_persona_path,
|
||||
persona=persona,
|
||||
available_personas=available_personas,
|
||||
available_subagents=available_subagents,
|
||||
extra_context_paths=extra_context_paths,
|
||||
extra_contexts=extra_contexts,
|
||||
warnings=warnings,
|
||||
@@ -513,6 +598,71 @@ class AgentRuntimeManager:
|
||||
raise AgentRuntimeConfigError(f"{personas_root} 中未找到任何人格定义")
|
||||
return personas
|
||||
|
||||
def _load_subagents(self, root: Path) -> list[SubAgentDefinition]:
|
||||
"""扫描并解析所有可用子代理。"""
|
||||
subagents_root = root / SUBAGENTS_DIR
|
||||
if not subagents_root.exists():
|
||||
raise AgentRuntimeConfigError(f"缺少 subagents 目录: {subagents_root}")
|
||||
|
||||
subagents: list[SubAgentDefinition] = []
|
||||
seen_ids: set[str] = set()
|
||||
for subagent_dir in sorted(subagents_root.iterdir()):
|
||||
if not subagent_dir.is_dir():
|
||||
continue
|
||||
subagent_path = subagent_dir / SUBAGENT_FILE
|
||||
if not subagent_path.exists():
|
||||
continue
|
||||
document = self._read_markdown(subagent_path)
|
||||
subagent_id = str(
|
||||
document.metadata.get("subagent_id") or subagent_dir.name
|
||||
).strip()
|
||||
if not subagent_id:
|
||||
raise AgentRuntimeConfigError(f"{subagent_path} 缺少 subagent_id")
|
||||
if not PERSONA_ID_PATTERN.fullmatch(subagent_id):
|
||||
raise AgentRuntimeConfigError(
|
||||
f"{subagent_path} 的 subagent_id 只能使用小写字母、数字、下划线和中划线,且必须以字母或数字开头"
|
||||
)
|
||||
if subagent_id in seen_ids:
|
||||
raise AgentRuntimeConfigError(f"检测到重复的子代理 ID: {subagent_id}")
|
||||
seen_ids.add(subagent_id)
|
||||
|
||||
description = str(document.metadata.get("description") or "").strip()
|
||||
if not description:
|
||||
raise AgentRuntimeConfigError(f"{subagent_path} 缺少 description")
|
||||
include_tags = self._normalize_string_list(
|
||||
document.metadata.get("include_tags"),
|
||||
f"{subagent_path}.include_tags",
|
||||
)
|
||||
if not include_tags:
|
||||
raise AgentRuntimeConfigError(f"{subagent_path} 缺少 include_tags")
|
||||
exclude_tags = self._normalize_string_list(
|
||||
document.metadata.get("exclude_tags"),
|
||||
f"{subagent_path}.exclude_tags",
|
||||
)
|
||||
text = self._normalize_subagent_body(document.body)
|
||||
if not text:
|
||||
raise AgentRuntimeConfigError(f"{subagent_path} 子代理正文不能为空")
|
||||
|
||||
subagents.append(
|
||||
SubAgentDefinition(
|
||||
subagent_id=subagent_id,
|
||||
path=subagent_path,
|
||||
label=str(document.metadata.get("label") or subagent_id).strip(),
|
||||
description=description,
|
||||
text=text,
|
||||
include_tags=include_tags,
|
||||
exclude_tags=exclude_tags,
|
||||
version=self._coerce_int_metadata(
|
||||
document.metadata.get("version"),
|
||||
default=SUBAGENT_SCHEMA_VERSION,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if not subagents:
|
||||
raise AgentRuntimeConfigError(f"{subagents_root} 中未找到任何子代理定义")
|
||||
return subagents
|
||||
|
||||
@staticmethod
|
||||
def _resolve_persona_definition(
|
||||
persona_query: str,
|
||||
@@ -653,6 +803,27 @@ class AgentRuntimeManager:
|
||||
return remainder.strip()
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _normalize_subagent_body(body: Optional[str]) -> str:
|
||||
"""去掉重复的 SUBAGENT 标题,保持正文可安全加载。"""
|
||||
normalized = (body or "").strip()
|
||||
if not normalized:
|
||||
return ""
|
||||
if normalized.startswith("# SUBAGENT"):
|
||||
_, _, remainder = normalized.partition("\n")
|
||||
return remainder.strip()
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _coerce_int_metadata(value: Any, *, default: int = 0) -> int:
|
||||
"""将 frontmatter 中的整数型元数据规范化。"""
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def _validate_runtime_config(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -4,12 +4,14 @@ import threading
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, ClassVar, Optional
|
||||
|
||||
from langchain_core.tools import BaseTool
|
||||
from pydantic import PrivateAttr
|
||||
|
||||
from app.agent import StreamingHandler
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.chain import ChainBase
|
||||
from app.core.config import settings
|
||||
from app.db.user_oper import UserOper
|
||||
@@ -75,6 +77,7 @@ def format_tool_result_for_agent(
|
||||
|
||||
# 将常见的阻塞调用按能力域拆分到独立线程池,避免外部慢 IO 抢占同一批 worker。
|
||||
_BLOCKING_BUCKET_LIMITS = {
|
||||
"command": 4,
|
||||
"default": 4,
|
||||
"config": 2,
|
||||
"db": 4,
|
||||
@@ -85,6 +88,7 @@ _BLOCKING_BUCKET_LIMITS = {
|
||||
"site": 4,
|
||||
"storage": 4,
|
||||
"subscribe": 2,
|
||||
"web": 2,
|
||||
"workflow": 2,
|
||||
}
|
||||
_blocking_semaphores = {
|
||||
@@ -111,6 +115,54 @@ def _get_blocking_executor(bucket: str) -> ThreadPoolExecutor:
|
||||
return executor
|
||||
|
||||
|
||||
class ToolExecutionTimeoutError(TimeoutError):
|
||||
"""Agent 工具执行超时异常。"""
|
||||
|
||||
|
||||
def _get_tool_timeout_seconds() -> Optional[float]:
|
||||
"""读取工具执行超时时间,配置为 0 或负数时表示不限制。"""
|
||||
try:
|
||||
timeout = float(settings.LLM_TOOL_TIMEOUT or 0)
|
||||
except (TypeError, ValueError):
|
||||
timeout = 0
|
||||
return timeout if timeout > 0 else None
|
||||
|
||||
|
||||
async def run_agent_blocking(
|
||||
bucket: str, func: Callable[..., Any], *args: Any, **kwargs: Any
|
||||
) -> Any:
|
||||
"""
|
||||
在受控线程池中运行阻塞型同步代码。
|
||||
|
||||
调用方被取消时不会提前释放并发名额,避免底层阻塞调用仍在运行时继续接纳
|
||||
新任务,把同一类慢 IO 的线程池持续打满。
|
||||
"""
|
||||
bucket_name = bucket if bucket in _BLOCKING_BUCKET_LIMITS else "default"
|
||||
semaphore = _blocking_semaphores[bucket_name]
|
||||
bound_call = partial(func, *args, **kwargs)
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
await semaphore.acquire()
|
||||
try:
|
||||
future = _get_blocking_executor(bucket_name).submit(bound_call)
|
||||
except Exception:
|
||||
semaphore.release()
|
||||
raise
|
||||
|
||||
def _release_semaphore(_future) -> None:
|
||||
try:
|
||||
_future.exception()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
loop.call_soon_threadsafe(semaphore.release)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
future.add_done_callback(_release_semaphore)
|
||||
return await asyncio.shield(asyncio.wrap_future(future, loop=loop))
|
||||
|
||||
|
||||
class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
"""
|
||||
MoviePilot专用工具基类(LangChain v1 / langchain_core)
|
||||
@@ -131,7 +183,31 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
super().__init__(**kwargs)
|
||||
self._session_id = session_id
|
||||
self._user_id = user_id
|
||||
self._require_admin = getattr(self.__class__, "require_admin", False)
|
||||
# require_admin 在各工具子类以 pydantic 字段声明,pydantic v2 不在类对象上暴露字段值
|
||||
# (getattr(cls, ...) 取不到),必须经实例读取——super().__init__() 已按字段默认填充实例;
|
||||
# getattr 兜底兼容未声明该字段的工具,缺省按非管理员(False)处理。
|
||||
self._require_admin = getattr(self, "require_admin", False)
|
||||
self.tags = self._build_tool_tags()
|
||||
|
||||
@staticmethod
|
||||
def _normalize_tag_values(tags: Optional[Any]) -> set[str]:
|
||||
"""规范化 LangChain 工具标签。"""
|
||||
if not tags:
|
||||
return set()
|
||||
if isinstance(tags, (str, ToolTag)):
|
||||
tags = [tags]
|
||||
normalized_tags = set()
|
||||
for tag in tags:
|
||||
if isinstance(tag, ToolTag):
|
||||
normalized_tags.add(tag.value)
|
||||
elif tag:
|
||||
normalized_tags.add(str(tag))
|
||||
return normalized_tags
|
||||
|
||||
def _build_tool_tags(self) -> list[str]:
|
||||
"""规范化工具实现中显式声明的标签。"""
|
||||
explicit_tags = self._normalize_tag_values(getattr(self, "tags", None))
|
||||
return sorted(explicit_tags | {ToolTag.AgentTool.value})
|
||||
|
||||
def _run(self, *args: Any, **kwargs: Any) -> Any:
|
||||
raise NotImplementedError("MoviePilotTool 只支持异步调用,请使用 _arun")
|
||||
@@ -157,8 +233,8 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
if explanation:
|
||||
tool_message = explanation
|
||||
|
||||
# 发送工具执行过程消息
|
||||
if self._stream_handler and self._stream_handler.is_streaming:
|
||||
# 发送工具执行过程消息(流式传输且非最后终结工具时)
|
||||
if self._stream_handler and self._stream_handler.is_streaming and not self.return_direct:
|
||||
if settings.AI_AGENT_VERBOSE:
|
||||
if self._stream_handler.is_auto_flushing:
|
||||
# 渠道支持编辑:工具消息追加到 buffer,由定时刷新推送
|
||||
@@ -211,7 +287,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
|
||||
# 执行具体工具逻辑
|
||||
try:
|
||||
result = await self.run(**kwargs)
|
||||
result = await self.run_with_timeout(**kwargs)
|
||||
|
||||
# 记录工具执行结果摘要日志
|
||||
str_result = serialize_tool_result_for_agent(result)
|
||||
@@ -221,6 +297,10 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
summary = str_result
|
||||
logger.info(f"Agent工具 {self.name} 执行完成,结果摘要: {summary}")
|
||||
|
||||
except ToolExecutionTimeoutError as e:
|
||||
error_message = str(e)
|
||||
logger.warning(error_message)
|
||||
result = error_message
|
||||
except Exception as e:
|
||||
error_message = f"工具执行异常 ({type(e).__name__}): {str(e)}"
|
||||
logger.error(f"Tool {self.name} execution failed: {e}", exc_info=True)
|
||||
@@ -251,6 +331,18 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
"""子类实现具体的工具执行逻辑"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def run_with_timeout(self, **kwargs) -> str:
|
||||
"""按系统配置限制单个工具调用的最长执行时间。"""
|
||||
timeout = _get_tool_timeout_seconds()
|
||||
if not timeout:
|
||||
return await self.run(**kwargs)
|
||||
try:
|
||||
return await asyncio.wait_for(self.run(**kwargs), timeout=timeout)
|
||||
except asyncio.TimeoutError as err:
|
||||
raise ToolExecutionTimeoutError(
|
||||
f"工具 {self.name} 执行超时(超过 {timeout:g} 秒),已停止等待结果。"
|
||||
) from err
|
||||
|
||||
@staticmethod
|
||||
async def run_blocking(
|
||||
bucket: str, func: Callable[..., Any], *args: Any, **kwargs: Any
|
||||
@@ -258,15 +350,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
"""
|
||||
在受控线程池中运行阻塞型同步代码,避免拖住 FastAPI 主事件循环。
|
||||
"""
|
||||
bucket_name = bucket if bucket in _BLOCKING_BUCKET_LIMITS else "default"
|
||||
semaphore = _blocking_semaphores[bucket_name]
|
||||
bound_call = partial(func, *args, **kwargs)
|
||||
|
||||
async with semaphore:
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(
|
||||
_get_blocking_executor(bucket_name), bound_call
|
||||
)
|
||||
return await run_agent_blocking(bucket, func, *args, **kwargs)
|
||||
|
||||
def set_message_attr(self, channel: str, source: str, username: str):
|
||||
"""
|
||||
@@ -290,6 +374,116 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
# 独立的新 dict,跨工具状态(例如质量门槛拒绝标记)无法传播。
|
||||
self._agent_context = {} if agent_context is None else agent_context
|
||||
|
||||
async def is_admin_user(self) -> bool:
|
||||
"""
|
||||
判断当前工具调用者是否拥有管理员级权限。
|
||||
|
||||
:return: 当前调用者是系统管理员、渠道管理员或显式管理员上下文时返回 True
|
||||
"""
|
||||
if bool(self._agent_context.get("is_admin")):
|
||||
return True
|
||||
|
||||
if not self._channel or not self._source:
|
||||
return False
|
||||
|
||||
return await self._has_channel_admin_permission()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_local_path(path: str) -> Path:
|
||||
"""
|
||||
解析本地路径并展开符号链接。
|
||||
|
||||
:param path: 用户传入的本地文件或目录路径
|
||||
:return: 规范化后的绝对路径
|
||||
"""
|
||||
return Path(path).expanduser().resolve(strict=False)
|
||||
|
||||
@staticmethod
|
||||
def _is_path_relative_to(path: Path, root: Path) -> bool:
|
||||
"""
|
||||
判断路径是否位于指定目录内。
|
||||
|
||||
:param path: 待检查路径
|
||||
:param root: 允许访问的根目录
|
||||
:return: 路径在根目录内或等于根目录时返回 True
|
||||
"""
|
||||
try:
|
||||
path.relative_to(root)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _get_non_admin_local_file_roots(cls) -> list[Path]:
|
||||
"""
|
||||
获取普通用户可访问的本地文件根目录。
|
||||
|
||||
:return: 普通用户允许读写的本地目录列表
|
||||
"""
|
||||
roots = [
|
||||
settings.CONFIG_PATH / "agent"
|
||||
]
|
||||
resolved_roots = []
|
||||
for root in roots:
|
||||
resolved_root = cls._resolve_local_path(str(root))
|
||||
if resolved_root not in resolved_roots:
|
||||
resolved_roots.append(resolved_root)
|
||||
return resolved_roots
|
||||
|
||||
async def _check_local_file_access(
|
||||
self, path: str, operation: str = "访问"
|
||||
) -> tuple[Optional[Path], Optional[str]]:
|
||||
"""
|
||||
检查当前用户是否可访问指定本地路径。
|
||||
|
||||
:param path: 用户传入的本地文件或目录路径
|
||||
:param operation: 当前操作名称,用于生成拒绝提示
|
||||
:return: 解析后的路径和拒绝原因;拒绝原因为空表示允许访问
|
||||
"""
|
||||
if not path:
|
||||
return None, "错误:路径不能为空"
|
||||
|
||||
resolved_path = self._resolve_local_path(path)
|
||||
if await self.is_admin_user():
|
||||
return resolved_path, None
|
||||
|
||||
allowed_roots = self._get_non_admin_local_file_roots()
|
||||
if any(
|
||||
self._is_path_relative_to(resolved_path, root)
|
||||
for root in allowed_roots
|
||||
):
|
||||
return resolved_path, None
|
||||
|
||||
allowed_text = "、".join(str(root) for root in allowed_roots)
|
||||
return (
|
||||
resolved_path,
|
||||
f"抱歉,普通用户只能{operation}配置目录、Agent记忆目录和日志目录内的文件或目录:{allowed_text}",
|
||||
)
|
||||
|
||||
async def _check_local_storage_access(
|
||||
self,
|
||||
path: str,
|
||||
storage: Optional[str] = "local",
|
||||
operation: str = "访问",
|
||||
) -> tuple[Optional[Path], Optional[str]]:
|
||||
"""
|
||||
检查当前用户是否可访问指定存储路径。
|
||||
|
||||
:param path: 用户传入的文件或目录路径
|
||||
:param storage: 存储类型,普通用户只允许 local
|
||||
:param operation: 当前操作名称,用于生成拒绝提示
|
||||
:return: 本地存储时返回解析后的路径和拒绝原因;远程存储无本地路径
|
||||
"""
|
||||
if (storage or "local") != "local":
|
||||
if await self.is_admin_user():
|
||||
return None, None
|
||||
return (
|
||||
None,
|
||||
f"抱歉,普通用户只能{operation}本地配置目录、Agent记忆目录和日志目录,不能访问远程存储。",
|
||||
)
|
||||
|
||||
return await self._check_local_file_access(path=path, operation=operation)
|
||||
|
||||
async def _check_permission(self) -> Optional[str]:
|
||||
"""
|
||||
检查用户权限:
|
||||
@@ -302,9 +496,28 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
if not self._require_admin:
|
||||
return None
|
||||
|
||||
if await self.is_admin_user():
|
||||
return None
|
||||
|
||||
if not self._channel or not self._source:
|
||||
return None
|
||||
|
||||
return (
|
||||
"抱歉,您没有执行此工具的权限。"
|
||||
"只有渠道管理员或系统管理员才能执行工具操作。"
|
||||
"如需执行工具,请联系渠道管理员将您的用户ID添加到渠道管理员列表中,"
|
||||
"或联系系统管理员为您设置权限。"
|
||||
)
|
||||
|
||||
async def _has_channel_admin_permission(self) -> bool:
|
||||
"""
|
||||
检查当前消息渠道身份是否具备管理员权限。
|
||||
|
||||
:return: 当前渠道用户是渠道管理员、系统管理员或默认接收人时返回 True
|
||||
"""
|
||||
if not self._channel or not self._source:
|
||||
return False
|
||||
|
||||
# 渠道配置来自 SystemConfigOper 内存缓存,可以直接读取;
|
||||
# 只有用户信息需要走异步数据库查询。
|
||||
user_id_str = str(self._user_id) if self._user_id else None
|
||||
@@ -328,7 +541,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
break
|
||||
|
||||
if not channel_type:
|
||||
return None
|
||||
return False
|
||||
|
||||
admin_key_map = {
|
||||
"telegram": "TELEGRAM_ADMINS",
|
||||
@@ -348,6 +561,9 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
"wechat": "WECHAT_BOT_CHAT_ID",
|
||||
"feishu": "FEISHU_OPEN_ID",
|
||||
"wechatclawbot": "WECHATCLAWBOT_DEFAULT_TARGET",
|
||||
"discord": "DISCORD_CHANNEL_ID",
|
||||
"slack": "SLACK_CHANNEL",
|
||||
"qqbot": "QQ_OPENID",
|
||||
}
|
||||
|
||||
admin_key = admin_key_map.get(channel_type)
|
||||
@@ -365,7 +581,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
if aid.strip()
|
||||
]
|
||||
if user_id_str and user_id_str in admin_list:
|
||||
return None
|
||||
return True
|
||||
|
||||
user = (
|
||||
await UserOper().async_get_by_name(self._username)
|
||||
@@ -373,14 +589,9 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
else None
|
||||
)
|
||||
if user and user.is_superuser:
|
||||
return None
|
||||
return True
|
||||
|
||||
return (
|
||||
"抱歉,您没有执行此工具的权限。"
|
||||
"只有渠道管理员或系统管理员才能执行工具操作。"
|
||||
"如需执行工具,请联系渠道管理员将您的用户ID添加到渠道管理员列表中,"
|
||||
"或联系系统管理员为您设置权限。"
|
||||
)
|
||||
return False
|
||||
else:
|
||||
user = (
|
||||
await UserOper().async_get_by_name(self._username)
|
||||
@@ -388,22 +599,18 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
else None
|
||||
)
|
||||
if user and user.is_superuser:
|
||||
return None
|
||||
return True
|
||||
|
||||
if user_id_key:
|
||||
config_user_id = config.config.get(user_id_key)
|
||||
if config_user_id and str(config_user_id) == user_id_str:
|
||||
return None
|
||||
return True
|
||||
|
||||
return (
|
||||
"抱歉,您没有执行此工具的权限。"
|
||||
"只有系统管理员才能执行工具操作。"
|
||||
"如需执行工具,请联系系统管理员为您设置权限。"
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"检查权限失败: {e}")
|
||||
|
||||
return None
|
||||
return False
|
||||
|
||||
async def send_tool_message(
|
||||
self, message: str, title: str = "", image: Optional[str] = None
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import List, Callable
|
||||
|
||||
from app.agent.tools.impl.add_download import AddDownloadTool
|
||||
from app.agent.tools.impl.add_download_tasks import AddDownloadTasksTool
|
||||
from app.agent.tools.impl.add_subscribe import AddSubscribeTool
|
||||
from app.agent.tools.impl.update_subscribe import UpdateSubscribeTool
|
||||
from app.agent.tools.impl.search_subscribe import SearchSubscribeTool
|
||||
@@ -37,6 +37,7 @@ from app.agent.tools.impl.query_media_detail import QueryMediaDetailTool
|
||||
from app.agent.tools.impl.search_torrents import SearchTorrentsTool
|
||||
from app.agent.tools.impl.get_search_results import GetSearchResultsTool
|
||||
from app.agent.tools.impl.search_web import SearchWebTool
|
||||
from app.agent.tools.impl.recognize_captcha import RecognizeCaptchaTool
|
||||
from app.agent.tools.impl.send_message import SendMessageTool
|
||||
from app.agent.tools.impl.ask_user_choice import AskUserChoiceTool
|
||||
from app.agent.tools.impl.send_local_file import SendLocalFileTool
|
||||
@@ -49,10 +50,10 @@ from app.agent.tools.impl.query_personas import QueryPersonasTool
|
||||
from app.agent.tools.impl.switch_persona import SwitchPersonaTool
|
||||
from app.agent.tools.impl.update_persona_definition import UpdatePersonaDefinitionTool
|
||||
from app.agent.tools.impl.update_site_cookie import UpdateSiteCookieTool
|
||||
from app.agent.tools.impl.delete_download import DeleteDownloadTool
|
||||
from app.agent.tools.impl.delete_download_tasks import DeleteDownloadTasksTool
|
||||
from app.agent.tools.impl.delete_download_history import DeleteDownloadHistoryTool
|
||||
from app.agent.tools.impl.delete_transfer_history import DeleteTransferHistoryTool
|
||||
from app.agent.tools.impl.modify_download import ModifyDownloadTool
|
||||
from app.agent.tools.impl.update_download_tasks import UpdateDownloadTasksTool
|
||||
from app.agent.tools.impl.query_directory_settings import QueryDirectorySettingsTool
|
||||
from app.agent.tools.impl.list_directory import ListDirectoryTool
|
||||
from app.agent.tools.impl.query_transfer_history import QueryTransferHistoryTool
|
||||
@@ -74,6 +75,7 @@ from app.agent.tools.impl.uninstall_plugin import UninstallPluginTool
|
||||
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
|
||||
from app.agent.tools.impl.query_doctor_report import QueryDoctorReportTool
|
||||
from app.agent.tools.impl.update_custom_identifiers import UpdateCustomIdentifiersTool
|
||||
from app.agent.tools.impl.query_system_settings import QuerySystemSettingsTool
|
||||
from app.agent.tools.impl.update_system_settings import UpdateSystemSettingsTool
|
||||
@@ -99,6 +101,7 @@ class MoviePilotToolFactory:
|
||||
"read_file",
|
||||
"edit_file",
|
||||
"execute_command",
|
||||
"query_doctor_report",
|
||||
"send_message",
|
||||
"ask_user_choice",
|
||||
)
|
||||
@@ -163,7 +166,8 @@ class MoviePilotToolFactory:
|
||||
SearchTorrentsTool,
|
||||
GetSearchResultsTool,
|
||||
SearchWebTool,
|
||||
AddDownloadTool,
|
||||
RecognizeCaptchaTool,
|
||||
AddDownloadTasksTool,
|
||||
QuerySubscribesTool,
|
||||
QuerySubscribeSharesTool,
|
||||
QueryPopularSubscribesTool,
|
||||
@@ -179,10 +183,10 @@ class MoviePilotToolFactory:
|
||||
QuerySubscribeHistoryTool,
|
||||
DeleteSubscribeTool,
|
||||
QueryDownloadTasksTool,
|
||||
DeleteDownloadTool,
|
||||
DeleteDownloadTasksTool,
|
||||
DeleteDownloadHistoryTool,
|
||||
DeleteTransferHistoryTool,
|
||||
ModifyDownloadTool,
|
||||
UpdateDownloadTasksTool,
|
||||
QueryDownloadersTool,
|
||||
QuerySitesTool,
|
||||
UpdateSiteTool,
|
||||
@@ -220,6 +224,7 @@ class MoviePilotToolFactory:
|
||||
UninstallPluginTool,
|
||||
RunSlashCommandTool,
|
||||
ListSlashCommandsTool,
|
||||
QueryDoctorReportTool,
|
||||
QueryCustomIdentifiersTool,
|
||||
UpdateCustomIdentifiersTool,
|
||||
QuerySystemSettingsTool,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""插件 Agent 工具共享辅助方法"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import shutil
|
||||
from typing import Any, Optional
|
||||
@@ -251,7 +250,9 @@ async def install_plugin_runtime(
|
||||
SystemConfigKey.UserInstalledPlugins, install_plugins
|
||||
)
|
||||
|
||||
await asyncio.to_thread(reload_plugin_runtime, plugin_id)
|
||||
from app.agent.tools.base import run_agent_blocking
|
||||
|
||||
await run_agent_blocking("plugin", reload_plugin_runtime, plugin_id)
|
||||
return True, message or "插件安装成功", refreshed_only
|
||||
|
||||
|
||||
|
||||
@@ -302,7 +302,8 @@ class _TerminalSessionManager:
|
||||
session.wait_task = asyncio.create_task(self._wait_pipe_process(session))
|
||||
return session
|
||||
|
||||
async def _read_pty(self, session: _TerminalSession) -> None:
|
||||
@staticmethod
|
||||
async def _read_pty(session: _TerminalSession) -> None:
|
||||
"""持续从 PTY 读取增量输出。"""
|
||||
while session.master_fd is not None:
|
||||
try:
|
||||
@@ -319,9 +320,9 @@ class _TerminalSessionManager:
|
||||
break
|
||||
session.append_output("pty", data)
|
||||
|
||||
@staticmethod
|
||||
async def _read_pipe(
|
||||
self,
|
||||
session: _TerminalSession,
|
||||
session: _TerminalSession,
|
||||
stream: asyncio.StreamReader,
|
||||
stream_name: str,
|
||||
) -> None:
|
||||
@@ -361,7 +362,8 @@ class _TerminalSessionManager:
|
||||
finally:
|
||||
await self._finish_reader_tasks(session)
|
||||
|
||||
async def _finish_reader_tasks(self, session: _TerminalSession) -> None:
|
||||
@staticmethod
|
||||
async def _finish_reader_tasks(session: _TerminalSession) -> None:
|
||||
"""等待输出读取任务退出,超时后取消残留任务。"""
|
||||
if not session.reader_tasks:
|
||||
return
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.agent.tools.impl._filter_rule_utils import (
|
||||
get_custom_rules,
|
||||
normalize_custom_rule,
|
||||
@@ -46,6 +47,11 @@ class AddCustomFilterRuleInput(BaseModel):
|
||||
|
||||
class AddCustomFilterRuleTool(MoviePilotTool):
|
||||
name: str = "add_custom_filter_rule"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.FilterRule,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = (
|
||||
"Add a custom filter rule to CustomFilterRules. "
|
||||
"The new rule can then be referenced by rule ID inside filter rule groups."
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
"""添加下载工具"""
|
||||
"""添加下载任务工具"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Type
|
||||
from typing import List, Optional, Type, Union
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.chain.download import DownloadChain
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.search import SearchChain
|
||||
from app.chain.download import DownloadChain
|
||||
from app.core.config import settings
|
||||
from app.core.context import Context
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.db.site_oper import SiteOper
|
||||
from app.helper.directory import DirectoryHelper
|
||||
from app.log import logger
|
||||
from app.schemas import TorrentInfo, FileURI
|
||||
from app.schemas import FileURI, TorrentInfo
|
||||
from app.utils.crypto import HashUtils
|
||||
|
||||
|
||||
class AddDownloadInput(BaseModel):
|
||||
"""添加下载工具的输入参数模型"""
|
||||
class AddDownloadTasksInput(BaseModel):
|
||||
"""添加下载任务工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
torrent_url: List[str] = Field(
|
||||
...,
|
||||
@@ -35,10 +37,17 @@ class AddDownloadInput(BaseModel):
|
||||
description="Comma-separated list of labels/tags to assign to the download (optional, e.g., 'movie,hd,bluray')")
|
||||
|
||||
|
||||
class AddDownloadTool(MoviePilotTool):
|
||||
name: str = "add_download"
|
||||
class AddDownloadTasksTool(MoviePilotTool):
|
||||
"""添加下载任务工具"""
|
||||
|
||||
name: str = "add_download_tasks"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.Download,
|
||||
ToolTag.Resource,
|
||||
]
|
||||
description: str = "Add torrent download tasks using refs from get_search_results or magnet links."
|
||||
args_schema: Type[BaseModel] = AddDownloadInput
|
||||
args_schema: Type[BaseModel] = AddDownloadTasksInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""根据下载参数生成友好的提示消息"""
|
||||
@@ -151,16 +160,16 @@ class AddDownloadTool(MoviePilotTool):
|
||||
prefix = "添加种子任务失败:"
|
||||
if normalized_error.startswith(prefix):
|
||||
normalized_error = normalized_error[len(prefix):].lstrip()
|
||||
if AddDownloadTool._is_magnet_link_input(normalized_error):
|
||||
if AddDownloadTasksTool._is_magnet_link_input(normalized_error):
|
||||
normalized_error = ""
|
||||
if normalized_error:
|
||||
return f"{torrent_ref} {normalized_error}"
|
||||
if AddDownloadTool._is_torrent_ref(torrent_ref):
|
||||
if AddDownloadTasksTool._is_torrent_ref(torrent_ref):
|
||||
return torrent_ref
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def _normalize_torrent_urls(cls, torrent_url: Optional[List[str] | str]) -> List[str]:
|
||||
def _normalize_torrent_urls(cls, torrent_url: Optional[Union[List[str], str]]) -> List[str]:
|
||||
"""统一规范 torrent_url 输入,保留所有非空值"""
|
||||
if torrent_url is None:
|
||||
return []
|
||||
@@ -228,6 +237,7 @@ class AddDownloadTool(MoviePilotTool):
|
||||
async def run(self, torrent_url: Optional[List[str]] = None,
|
||||
downloader: Optional[str] = None, save_path: Optional[str] = None,
|
||||
labels: Optional[str] = None, **kwargs) -> str:
|
||||
"""执行添加下载任务。"""
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, 参数: torrent_url={torrent_url}, downloader={downloader}, save_path={save_path}, labels={labels}")
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.agent.tools.impl._filter_rule_utils import (
|
||||
build_custom_rule_map,
|
||||
collect_rule_group_usages,
|
||||
@@ -46,6 +47,11 @@ class AddRuleGroupInput(BaseModel):
|
||||
|
||||
class AddRuleGroupTool(MoviePilotTool):
|
||||
name: str = "add_rule_group"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.FilterRule,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = (
|
||||
"Add a new filter rule group to UserFilterRuleGroups. "
|
||||
"Rule groups are matched level by level from left to right and can be linked to search/subscription flows. "
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import List, Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.db.user_oper import UserOper
|
||||
from app.log import logger
|
||||
@@ -72,6 +73,11 @@ class AddSubscribeInput(BaseModel):
|
||||
|
||||
class AddSubscribeTool(MoviePilotTool):
|
||||
name: str = "add_subscribe"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.Subscription,
|
||||
ToolTag.Media,
|
||||
]
|
||||
description: str = (
|
||||
"Add media subscription to create automated download rules for movies and TV shows. "
|
||||
"The system will automatically search and download new episodes or releases based on the subscription criteria. "
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import List, Optional, Type
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool, ToolChain
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.helper.interaction import (
|
||||
AgentInteractionOption,
|
||||
agent_interaction_manager,
|
||||
@@ -67,14 +68,21 @@ class AskUserChoiceTool(MoviePilotTool):
|
||||
"""发送按钮选择并让当前 Agent 轮次等待用户回调消息。"""
|
||||
|
||||
name: str = "ask_user_choice"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.Message,
|
||||
ToolTag.UserInteraction,
|
||||
ToolTag.TerminalResponse,
|
||||
]
|
||||
sends_message: bool = True
|
||||
return_direct: bool = True
|
||||
description: str = (
|
||||
"Ask the user to choose from button options on channels that support interactive buttons. "
|
||||
"After the user clicks a button, the selected value will come back as the user's next message."
|
||||
"This is a terminal interaction tool: put the full question and all options in this call, "
|
||||
"then stop the current turn. After the user clicks a button, the selected value will come "
|
||||
"back as the user's next message. Do not also send the same question as plain text."
|
||||
)
|
||||
args_schema: Type[BaseModel] = AskUserChoiceInput
|
||||
require_admin: bool = False
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
message = kwargs.get("message", "") or ""
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"""浏览器操作工具 - 让Agent能够通过Playwright控制浏览器进行网页交互"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
from enum import Enum
|
||||
from typing import Optional, Type
|
||||
from typing import Any, Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.core.config import settings
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.helper.browser import BrowserSessionHelper
|
||||
from app.log import logger
|
||||
|
||||
# 页面内容最大长度
|
||||
@@ -26,13 +26,22 @@ class BrowserAction(str, Enum):
|
||||
"""浏览器操作类型"""
|
||||
|
||||
GOTO = "goto"
|
||||
SNAPSHOT = "snapshot"
|
||||
GET_CONTENT = "get_content"
|
||||
SCREENSHOT = "screenshot"
|
||||
CLICK = "click"
|
||||
CLICK_REF = "click_ref"
|
||||
FILL = "fill"
|
||||
FILL_REF = "fill_ref"
|
||||
SELECT = "select"
|
||||
SELECT_REF = "select_ref"
|
||||
EVALUATE = "evaluate"
|
||||
WAIT = "wait"
|
||||
LIST_TABS = "list_tabs"
|
||||
OPEN_TAB = "open_tab"
|
||||
FOCUS_TAB = "focus_tab"
|
||||
CLOSE_TAB = "close_tab"
|
||||
CLOSE_SESSION = "close_session"
|
||||
|
||||
|
||||
class BrowseWebpageInput(BaseModel):
|
||||
@@ -45,13 +54,22 @@ class BrowseWebpageInput(BaseModel):
|
||||
description=(
|
||||
"The browser action to perform. Available actions:\n"
|
||||
"- 'goto': Navigate to a URL, returns page title and text summary\n"
|
||||
"- 'snapshot': Get current page snapshot with interactive element refs\n"
|
||||
"- 'get_content': Get current page content (text or HTML)\n"
|
||||
"- 'screenshot': Take a screenshot of the current page, returns base64 image\n"
|
||||
"- 'click': Click on an element specified by selector\n"
|
||||
"- 'click_ref': Click an element by ref from the latest snapshot\n"
|
||||
"- 'fill': Fill text into an input element specified by selector\n"
|
||||
"- 'fill_ref': Fill text into an input element by ref from the latest snapshot\n"
|
||||
"- 'select': Select an option from a dropdown element\n"
|
||||
"- 'select_ref': Select an option by ref from the latest snapshot\n"
|
||||
"- 'evaluate': Execute JavaScript code on the page and return the result\n"
|
||||
"- 'wait': Wait for an element to appear on the page"
|
||||
"- 'wait': Wait for an element to appear on the page\n"
|
||||
"- 'list_tabs': List browser tabs in the current session\n"
|
||||
"- 'open_tab': Open a new tab, optionally navigating to a URL\n"
|
||||
"- 'focus_tab': Switch active tab by index\n"
|
||||
"- 'close_tab': Close a tab by index\n"
|
||||
"- 'close_session': Close the current browser session"
|
||||
),
|
||||
)
|
||||
url: Optional[str] = Field(
|
||||
@@ -62,6 +80,10 @@ class BrowseWebpageInput(BaseModel):
|
||||
description="CSS selector or text selector for the target element (for 'click', 'fill', 'select', 'wait' actions). "
|
||||
"Supports CSS selectors like '#id', '.class', 'tag', and Playwright text selectors like 'text=Click me'",
|
||||
)
|
||||
ref: Optional[str] = Field(
|
||||
None,
|
||||
description="Element ref returned by 'snapshot' or action results (for 'click_ref', 'fill_ref', 'select_ref')",
|
||||
)
|
||||
value: Optional[str] = Field(
|
||||
None,
|
||||
description="Value to fill into input or option value to select (for 'fill' and 'select' actions)",
|
||||
@@ -85,18 +107,36 @@ class BrowseWebpageInput(BaseModel):
|
||||
user_agent: Optional[str] = Field(
|
||||
None, description="Custom User-Agent string for the browser context"
|
||||
)
|
||||
session_key: Optional[str] = Field(
|
||||
None,
|
||||
description="Browser session key. Defaults to the current agent session id.",
|
||||
)
|
||||
tab_index: Optional[int] = Field(
|
||||
None,
|
||||
description="Tab index for 'focus_tab' and 'close_tab' actions.",
|
||||
)
|
||||
allow_private_network: bool = Field(
|
||||
False,
|
||||
description="Allow browser navigation to localhost, loopback, private, or link-local addresses.",
|
||||
)
|
||||
|
||||
|
||||
class BrowseWebpageTool(MoviePilotTool):
|
||||
name: str = "browse_webpage"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Web,
|
||||
]
|
||||
description: str = (
|
||||
"Control a real browser (Playwright) to interact with web pages. "
|
||||
"Supports navigating to URLs, reading page content, taking screenshots, "
|
||||
"clicking elements, filling forms, selecting dropdown options, executing JavaScript, and waiting for elements. "
|
||||
"clicking elements, filling forms, selecting dropdown options, executing JavaScript, waiting for elements, "
|
||||
"and managing tabs. "
|
||||
"Use this tool when you need to interact with dynamic web pages, "
|
||||
"fill in forms, click buttons, or extract content from JavaScript-rendered pages. "
|
||||
"The browser session persists across multiple calls within the same conversation - "
|
||||
"first call 'goto' to open a page, then use other actions to interact with it."
|
||||
"first call 'goto' to open a page, inspect 'interactive_elements', then use *_ref actions when possible. "
|
||||
"For safety, localhost and private network URLs are blocked by default unless allow_private_network is true."
|
||||
)
|
||||
args_schema: Type[BaseModel] = BrowseWebpageInput
|
||||
|
||||
@@ -107,13 +147,22 @@ class BrowseWebpageTool(MoviePilotTool):
|
||||
selector = kwargs.get("selector", "")
|
||||
action_messages = {
|
||||
"goto": f"打开网页: {url}",
|
||||
"snapshot": "读取页面快照",
|
||||
"get_content": "获取页面内容",
|
||||
"screenshot": "截取页面截图",
|
||||
"click": f"点击元素: {selector}",
|
||||
"click_ref": f"点击元素引用: {kwargs.get('ref', '')}",
|
||||
"fill": f"填写表单: {selector}",
|
||||
"fill_ref": f"填写元素引用: {kwargs.get('ref', '')}",
|
||||
"select": f"选择选项: {selector}",
|
||||
"select_ref": f"选择元素引用: {kwargs.get('ref', '')}",
|
||||
"evaluate": "执行 JavaScript",
|
||||
"wait": f"等待元素: {selector}",
|
||||
"list_tabs": "列出浏览器标签页",
|
||||
"open_tab": f"打开新标签页: {url}",
|
||||
"focus_tab": f"切换浏览器标签页: {kwargs.get('tab_index', '')}",
|
||||
"close_tab": f"关闭浏览器标签页: {kwargs.get('tab_index', '')}",
|
||||
"close_session": "关闭浏览器会话",
|
||||
}
|
||||
return action_messages.get(action, f"执行浏览器操作: {action}")
|
||||
|
||||
@@ -122,12 +171,16 @@ class BrowseWebpageTool(MoviePilotTool):
|
||||
action: str,
|
||||
url: Optional[str] = None,
|
||||
selector: Optional[str] = None,
|
||||
ref: Optional[str] = None,
|
||||
value: Optional[str] = None,
|
||||
script: Optional[str] = None,
|
||||
content_type: Optional[str] = "text",
|
||||
timeout: Optional[int] = DEFAULT_TIMEOUT,
|
||||
cookies: Optional[str] = None,
|
||||
user_agent: Optional[str] = None,
|
||||
session_key: Optional[str] = None,
|
||||
tab_index: Optional[int] = None,
|
||||
allow_private_network: bool = False,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""执行浏览器操作"""
|
||||
@@ -146,6 +199,8 @@ class BrowseWebpageTool(MoviePilotTool):
|
||||
# 参数校验
|
||||
if browser_action == BrowserAction.GOTO and not url:
|
||||
return "错误: 'goto' 操作需要提供 url 参数"
|
||||
if browser_action == BrowserAction.OPEN_TAB and not url:
|
||||
return "错误: 'open_tab' 操作需要提供 url 参数"
|
||||
if (
|
||||
browser_action
|
||||
in (
|
||||
@@ -157,26 +212,46 @@ class BrowseWebpageTool(MoviePilotTool):
|
||||
and not selector
|
||||
):
|
||||
return f"错误: '{action}' 操作需要提供 selector 参数"
|
||||
if (
|
||||
browser_action
|
||||
in (
|
||||
BrowserAction.CLICK_REF,
|
||||
BrowserAction.FILL_REF,
|
||||
BrowserAction.SELECT_REF,
|
||||
)
|
||||
and not ref
|
||||
):
|
||||
return f"错误: '{action}' 操作需要提供 ref 参数"
|
||||
if browser_action == BrowserAction.FILL and value is None:
|
||||
return "错误: 'fill' 操作需要提供 value 参数"
|
||||
if browser_action == BrowserAction.FILL_REF and value is None:
|
||||
return "错误: 'fill_ref' 操作需要提供 value 参数"
|
||||
if browser_action == BrowserAction.EVALUATE and not script:
|
||||
return "错误: 'evaluate' 操作需要提供 script 参数"
|
||||
if (
|
||||
browser_action in (BrowserAction.FOCUS_TAB, BrowserAction.CLOSE_TAB)
|
||||
and tab_index is None
|
||||
):
|
||||
return f"错误: '{action}' 操作需要提供 tab_index 参数"
|
||||
|
||||
# 在线程池中运行同步的 Playwright 操作
|
||||
loop = asyncio.get_running_loop()
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self._execute_browser_action(
|
||||
browser_action=browser_action,
|
||||
url=url,
|
||||
selector=selector,
|
||||
value=value,
|
||||
script=script,
|
||||
content_type=content_type,
|
||||
timeout=timeout,
|
||||
cookies=cookies,
|
||||
user_agent=user_agent,
|
||||
),
|
||||
effective_session_key = session_key or self._session_id
|
||||
|
||||
result = await self.run_blocking(
|
||||
"web",
|
||||
self._execute_browser_action,
|
||||
browser_action=browser_action,
|
||||
url=url,
|
||||
selector=selector,
|
||||
ref=ref,
|
||||
value=value,
|
||||
script=script,
|
||||
content_type=content_type,
|
||||
timeout=timeout,
|
||||
cookies=cookies,
|
||||
user_agent=user_agent,
|
||||
session_key=effective_session_key,
|
||||
tab_index=tab_index,
|
||||
allow_private_network=allow_private_network,
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -189,65 +264,61 @@ class BrowseWebpageTool(MoviePilotTool):
|
||||
browser_action: BrowserAction,
|
||||
url: Optional[str],
|
||||
selector: Optional[str],
|
||||
ref: Optional[str],
|
||||
value: Optional[str],
|
||||
script: Optional[str],
|
||||
content_type: Optional[str],
|
||||
timeout: int,
|
||||
cookies: Optional[str],
|
||||
user_agent: Optional[str],
|
||||
session_key: str,
|
||||
tab_index: Optional[int],
|
||||
allow_private_network: bool,
|
||||
) -> str:
|
||||
"""在同步上下文中执行 CloakBrowser 浏览器操作"""
|
||||
from cloakbrowser import launch_context
|
||||
|
||||
try:
|
||||
context = None
|
||||
page = None
|
||||
try:
|
||||
context_kwargs = {
|
||||
"viewport": {
|
||||
"width": SCREENSHOT_MAX_WIDTH,
|
||||
"height": SCREENSHOT_MAX_HEIGHT,
|
||||
if browser_action == BrowserAction.CLOSE_SESSION:
|
||||
closed = BrowserSessionHelper.close_session(session_key)
|
||||
message = "浏览器会话已关闭" if closed else "浏览器会话不存在"
|
||||
return self._json_response(
|
||||
{
|
||||
"success": closed,
|
||||
"message": message,
|
||||
}
|
||||
}
|
||||
if user_agent:
|
||||
context_kwargs["user_agent"] = user_agent
|
||||
|
||||
context = launch_context(
|
||||
headless=True,
|
||||
humanize=settings.CLOAKBROWSER_HUMANIZE,
|
||||
human_preset=settings.CLOAKBROWSER_HUMAN_PRESET,
|
||||
**context_kwargs,
|
||||
)
|
||||
page = context.new_page()
|
||||
page.set_default_timeout(timeout * 1000)
|
||||
|
||||
# 设置 cookies
|
||||
if cookies:
|
||||
page.set_extra_http_headers({"cookie": cookies})
|
||||
helper = BrowserSessionHelper(
|
||||
headless=True,
|
||||
viewport={
|
||||
"width": SCREENSHOT_MAX_WIDTH,
|
||||
"height": SCREENSHOT_MAX_HEIGHT,
|
||||
},
|
||||
)
|
||||
|
||||
# 对于非 goto 操作,如果提供了 url 先导航
|
||||
if url and browser_action != BrowserAction.GOTO:
|
||||
page.goto(url, wait_until="domcontentloaded", timeout=timeout * 1000)
|
||||
page.wait_for_load_state("networkidle", timeout=timeout * 1000)
|
||||
|
||||
# 执行具体操作
|
||||
result = self._do_action(
|
||||
page,
|
||||
browser_action,
|
||||
url,
|
||||
selector,
|
||||
value,
|
||||
script,
|
||||
content_type,
|
||||
timeout,
|
||||
def _callback(session) -> str:
|
||||
return self._do_action(
|
||||
helper=helper,
|
||||
session=session,
|
||||
browser_action=browser_action,
|
||||
url=url,
|
||||
selector=selector,
|
||||
ref=ref,
|
||||
value=value,
|
||||
script=script,
|
||||
content_type=content_type,
|
||||
timeout=timeout,
|
||||
tab_index=tab_index,
|
||||
allow_private_network=allow_private_network,
|
||||
)
|
||||
return result
|
||||
|
||||
finally:
|
||||
if page:
|
||||
page.close()
|
||||
if context:
|
||||
context.close()
|
||||
return helper.with_session(
|
||||
session_key=session_key,
|
||||
callback=_callback,
|
||||
user_agent=user_agent,
|
||||
cookies=cookies,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"CloakBrowser 执行失败: {e}", exc_info=True)
|
||||
@@ -255,19 +326,38 @@ class BrowseWebpageTool(MoviePilotTool):
|
||||
|
||||
def _do_action(
|
||||
self,
|
||||
page,
|
||||
helper: BrowserSessionHelper,
|
||||
session,
|
||||
browser_action: BrowserAction,
|
||||
url: Optional[str],
|
||||
selector: Optional[str],
|
||||
ref: Optional[str],
|
||||
value: Optional[str],
|
||||
script: Optional[str],
|
||||
content_type: Optional[str],
|
||||
timeout: int,
|
||||
tab_index: Optional[int],
|
||||
allow_private_network: bool,
|
||||
) -> str:
|
||||
"""执行具体的浏览器操作"""
|
||||
page = session.active_page
|
||||
|
||||
if browser_action == BrowserAction.GOTO:
|
||||
return self._action_goto(page, url, timeout)
|
||||
return self._action_goto(
|
||||
helper,
|
||||
page,
|
||||
url,
|
||||
timeout,
|
||||
allow_private_network=allow_private_network,
|
||||
)
|
||||
|
||||
elif browser_action == BrowserAction.SNAPSHOT:
|
||||
return self._json_response(
|
||||
BrowserSessionHelper.build_snapshot(
|
||||
page,
|
||||
max_text_chars=MAX_CONTENT_LENGTH,
|
||||
)
|
||||
)
|
||||
|
||||
elif browser_action == BrowserAction.GET_CONTENT:
|
||||
return self._action_get_content(page, content_type)
|
||||
@@ -278,89 +368,113 @@ class BrowseWebpageTool(MoviePilotTool):
|
||||
elif browser_action == BrowserAction.CLICK:
|
||||
return self._action_click(page, selector, timeout)
|
||||
|
||||
elif browser_action == BrowserAction.CLICK_REF:
|
||||
return self._action_click(
|
||||
page,
|
||||
BrowserSessionHelper.ref_to_selector(ref),
|
||||
timeout,
|
||||
ref=ref,
|
||||
)
|
||||
|
||||
elif browser_action == BrowserAction.FILL:
|
||||
return self._action_fill(page, selector, value, timeout)
|
||||
|
||||
elif browser_action == BrowserAction.FILL_REF:
|
||||
return self._action_fill(
|
||||
page,
|
||||
BrowserSessionHelper.ref_to_selector(ref),
|
||||
value,
|
||||
timeout,
|
||||
ref=ref,
|
||||
)
|
||||
|
||||
elif browser_action == BrowserAction.SELECT:
|
||||
return self._action_select(page, selector, value, timeout)
|
||||
|
||||
elif browser_action == BrowserAction.SELECT_REF:
|
||||
return self._action_select(
|
||||
page,
|
||||
BrowserSessionHelper.ref_to_selector(ref),
|
||||
value,
|
||||
timeout,
|
||||
ref=ref,
|
||||
)
|
||||
|
||||
elif browser_action == BrowserAction.EVALUATE:
|
||||
return self._action_evaluate(page, script)
|
||||
|
||||
elif browser_action == BrowserAction.WAIT:
|
||||
return self._action_wait(page, selector, timeout)
|
||||
|
||||
elif browser_action == BrowserAction.LIST_TABS:
|
||||
return self._json_response({"tabs": BrowserSessionHelper.list_tabs(session)})
|
||||
|
||||
elif browser_action == BrowserAction.OPEN_TAB:
|
||||
page = helper.open_tab(
|
||||
session,
|
||||
url=url,
|
||||
timeout=timeout,
|
||||
allow_private_network=allow_private_network,
|
||||
)
|
||||
return self._json_response(
|
||||
{
|
||||
"success": True,
|
||||
"active_tab": session.active_index,
|
||||
"tabs": BrowserSessionHelper.list_tabs(session),
|
||||
"snapshot": BrowserSessionHelper.build_snapshot(
|
||||
page,
|
||||
max_text_chars=MAX_CONTENT_LENGTH,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
elif browser_action == BrowserAction.FOCUS_TAB:
|
||||
page = BrowserSessionHelper.focus_tab(session, tab_index)
|
||||
return self._json_response(
|
||||
{
|
||||
"success": True,
|
||||
"active_tab": session.active_index,
|
||||
"tabs": BrowserSessionHelper.list_tabs(session),
|
||||
"snapshot": BrowserSessionHelper.build_snapshot(
|
||||
page,
|
||||
max_text_chars=MAX_CONTENT_LENGTH,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
elif browser_action == BrowserAction.CLOSE_TAB:
|
||||
tabs = BrowserSessionHelper.close_tab(session, tab_index)
|
||||
return self._json_response({"success": True, "tabs": tabs})
|
||||
|
||||
return f"未知操作: {browser_action}"
|
||||
|
||||
@staticmethod
|
||||
def _action_goto(page, url: str, timeout: int) -> str:
|
||||
def _json_response(payload: dict[str, Any]) -> str:
|
||||
"""返回格式化 JSON 字符串"""
|
||||
return json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
|
||||
@staticmethod
|
||||
def _action_goto(
|
||||
helper: BrowserSessionHelper,
|
||||
page,
|
||||
url: str,
|
||||
timeout: int,
|
||||
allow_private_network: bool,
|
||||
) -> str:
|
||||
"""导航到URL"""
|
||||
response = page.goto(url, wait_until="domcontentloaded", timeout=timeout * 1000)
|
||||
try:
|
||||
page.wait_for_load_state("networkidle", timeout=min(timeout, 15) * 1000)
|
||||
except Exception:
|
||||
# networkidle 超时不是致命错误,页面可能已经可用
|
||||
pass
|
||||
|
||||
response = helper.goto(
|
||||
page,
|
||||
url,
|
||||
timeout=timeout,
|
||||
allow_private_network=allow_private_network,
|
||||
)
|
||||
status = response.status if response else "unknown"
|
||||
title = page.title()
|
||||
page_url = page.url
|
||||
|
||||
# 提取页面可读文本摘要
|
||||
text_content = page.inner_text("body")
|
||||
if text_content and len(text_content) > MAX_CONTENT_LENGTH:
|
||||
text_content = text_content[:MAX_CONTENT_LENGTH] + "\n\n...(内容已截断)"
|
||||
|
||||
# 提取页面链接
|
||||
links = page.evaluate("""
|
||||
() => {
|
||||
const links = [];
|
||||
document.querySelectorAll('a[href]').forEach(a => {
|
||||
const text = a.innerText.trim();
|
||||
const href = a.href;
|
||||
if (text && href && !href.startsWith('javascript:')) {
|
||||
links.push({text: text.substring(0, 80), href: href});
|
||||
}
|
||||
});
|
||||
return links.slice(0, 30);
|
||||
}
|
||||
""")
|
||||
|
||||
# 提取表单信息
|
||||
forms = page.evaluate("""
|
||||
() => {
|
||||
const forms = [];
|
||||
document.querySelectorAll('input, textarea, select, button').forEach(el => {
|
||||
const info = {
|
||||
tag: el.tagName.toLowerCase(),
|
||||
type: el.type || '',
|
||||
name: el.name || '',
|
||||
id: el.id || '',
|
||||
placeholder: el.placeholder || '',
|
||||
value: el.tagName.toLowerCase() === 'select' ? '' : (el.value || '').substring(0, 50),
|
||||
text: el.innerText ? el.innerText.trim().substring(0, 50) : ''
|
||||
};
|
||||
// 只保留有标识信息的元素
|
||||
if (info.name || info.id || info.placeholder || info.text) {
|
||||
forms.push(info);
|
||||
}
|
||||
});
|
||||
return forms.slice(0, 30);
|
||||
}
|
||||
""")
|
||||
|
||||
result = {
|
||||
"status": status,
|
||||
"url": page_url,
|
||||
"title": title,
|
||||
"text_content": text_content,
|
||||
}
|
||||
if links:
|
||||
result["links"] = links
|
||||
if forms:
|
||||
result["form_elements"] = forms
|
||||
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
result = BrowserSessionHelper.build_snapshot(
|
||||
page,
|
||||
status=status,
|
||||
max_text_chars=MAX_CONTENT_LENGTH,
|
||||
)
|
||||
return BrowseWebpageTool._json_response(result)
|
||||
|
||||
@staticmethod
|
||||
def _action_get_content(page, content_type: Optional[str]) -> str:
|
||||
@@ -382,7 +496,7 @@ class BrowseWebpageTool(MoviePilotTool):
|
||||
"content_type": content_type,
|
||||
"content": content,
|
||||
}
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
return BrowseWebpageTool._json_response(result)
|
||||
|
||||
@staticmethod
|
||||
def _action_screenshot(page) -> str:
|
||||
@@ -415,10 +529,15 @@ class BrowseWebpageTool(MoviePilotTool):
|
||||
"format": "jpeg",
|
||||
"note": "截图已以 base64 编码返回",
|
||||
}
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
return BrowseWebpageTool._json_response(result)
|
||||
|
||||
@staticmethod
|
||||
def _action_click(page, selector: str, timeout: int) -> str:
|
||||
def _action_click(
|
||||
page,
|
||||
selector: str,
|
||||
timeout: int,
|
||||
ref: Optional[str] = None,
|
||||
) -> str:
|
||||
"""点击元素"""
|
||||
page.click(selector, timeout=timeout * 1000)
|
||||
|
||||
@@ -428,49 +547,62 @@ class BrowseWebpageTool(MoviePilotTool):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
title = page.title()
|
||||
page_url = page.url
|
||||
|
||||
return json.dumps(
|
||||
return BrowseWebpageTool._json_response(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"成功点击元素: {selector}",
|
||||
"current_url": page_url,
|
||||
"current_title": title,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
"message": f"成功点击元素: {ref or selector}",
|
||||
"snapshot": BrowserSessionHelper.build_snapshot(
|
||||
page,
|
||||
max_text_chars=MAX_CONTENT_LENGTH,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _action_fill(page, selector: str, value: str, timeout: int) -> str:
|
||||
def _action_fill(
|
||||
page,
|
||||
selector: str,
|
||||
value: str,
|
||||
timeout: int,
|
||||
ref: Optional[str] = None,
|
||||
) -> str:
|
||||
"""填写表单"""
|
||||
page.fill(selector, value, timeout=timeout * 1000)
|
||||
|
||||
return json.dumps(
|
||||
return BrowseWebpageTool._json_response(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"成功填写元素 '{selector}' 的值为 '{value}'",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
"message": f"成功填写元素 '{ref or selector}'",
|
||||
"snapshot": BrowserSessionHelper.build_snapshot(
|
||||
page,
|
||||
max_text_chars=MAX_CONTENT_LENGTH,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _action_select(page, selector: str, value: Optional[str], timeout: int) -> str:
|
||||
def _action_select(
|
||||
page,
|
||||
selector: str,
|
||||
value: Optional[str],
|
||||
timeout: int,
|
||||
ref: Optional[str] = None,
|
||||
) -> str:
|
||||
"""选择下拉选项"""
|
||||
if value:
|
||||
page.select_option(selector, value=value, timeout=timeout * 1000)
|
||||
else:
|
||||
return "错误: 'select' 操作需要提供 value 参数"
|
||||
|
||||
return json.dumps(
|
||||
return BrowseWebpageTool._json_response(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"成功选择元素 '{selector}' 的选项 '{value}'",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
"message": f"成功选择元素 '{ref or selector}' 的选项 '{value}'",
|
||||
"snapshot": BrowserSessionHelper.build_snapshot(
|
||||
page,
|
||||
max_text_chars=MAX_CONTENT_LENGTH,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -490,13 +622,11 @@ class BrowseWebpageTool(MoviePilotTool):
|
||||
if len(formatted) > MAX_CONTENT_LENGTH:
|
||||
formatted = formatted[:MAX_CONTENT_LENGTH] + "\n\n...(结果已截断)"
|
||||
|
||||
return json.dumps(
|
||||
return BrowseWebpageTool._json_response(
|
||||
{
|
||||
"success": True,
|
||||
"result": formatted,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -510,22 +640,22 @@ class BrowseWebpageTool(MoviePilotTool):
|
||||
if text and len(text) > 200:
|
||||
text = text[:200] + "..."
|
||||
|
||||
return json.dumps(
|
||||
return BrowseWebpageTool._json_response(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"元素 '{selector}' 已出现",
|
||||
"visible": visible,
|
||||
"text": text,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
"snapshot": BrowserSessionHelper.build_snapshot(
|
||||
page,
|
||||
max_text_chars=MAX_CONTENT_LENGTH,
|
||||
),
|
||||
}
|
||||
)
|
||||
else:
|
||||
return json.dumps(
|
||||
return BrowseWebpageTool._json_response(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"等待元素 '{selector}' 超时",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.agent.tools.impl._filter_rule_utils import (
|
||||
collect_custom_rule_group_refs,
|
||||
get_custom_rules,
|
||||
@@ -26,6 +27,11 @@ class DeleteCustomFilterRuleInput(BaseModel):
|
||||
|
||||
class DeleteCustomFilterRuleTool(MoviePilotTool):
|
||||
name: str = "delete_custom_filter_rule"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.FilterRule,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = (
|
||||
"Delete a custom filter rule from CustomFilterRules. "
|
||||
"If the rule is still referenced by rule groups, the deletion is blocked to avoid breaking rule_string expressions."
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.db import AsyncSessionFactory
|
||||
from app.db.models.downloadhistory import DownloadHistory
|
||||
from app.log import logger
|
||||
@@ -22,6 +23,11 @@ class DeleteDownloadHistoryInput(BaseModel):
|
||||
|
||||
class DeleteDownloadHistoryTool(MoviePilotTool):
|
||||
name: str = "delete_download_history"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.Download,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = "Delete a download history record by ID. This only removes the record from the database, does not delete any actual files."
|
||||
args_schema: Type[BaseModel] = DeleteDownloadHistoryInput
|
||||
require_admin: bool = True
|
||||
|
||||
@@ -5,11 +5,12 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.chain.download import DownloadChain
|
||||
from app.log import logger
|
||||
|
||||
|
||||
class DeleteDownloadInput(BaseModel):
|
||||
class DeleteDownloadTasksInput(BaseModel):
|
||||
"""删除下载任务工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
@@ -27,10 +28,17 @@ class DeleteDownloadInput(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class DeleteDownloadTool(MoviePilotTool):
|
||||
name: str = "delete_download"
|
||||
class DeleteDownloadTasksTool(MoviePilotTool):
|
||||
"""删除下载任务工具"""
|
||||
|
||||
name: str = "delete_download_tasks"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.Download,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = "Delete a download task from the downloader by task hash only. Optionally specify the downloader name and whether to delete downloaded files."
|
||||
args_schema: Type[BaseModel] = DeleteDownloadInput
|
||||
args_schema: Type[BaseModel] = DeleteDownloadTasksInput
|
||||
require_admin: bool = True
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
@@ -63,6 +71,7 @@ class DeleteDownloadTool(MoviePilotTool):
|
||||
delete_files: Optional[bool] = False,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""执行删除下载任务。"""
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, 参数: hash={hash}, downloader={downloader}, delete_files={delete_files}"
|
||||
)
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.agent.tools.impl._filter_rule_utils import (
|
||||
get_rule_groups,
|
||||
remove_rule_group_references,
|
||||
@@ -25,6 +26,11 @@ class DeleteRuleGroupInput(BaseModel):
|
||||
|
||||
class DeleteRuleGroupTool(MoviePilotTool):
|
||||
name: str = "delete_rule_group"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.FilterRule,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = (
|
||||
"Delete a filter rule group from UserFilterRuleGroups. "
|
||||
"The tool also removes dangling references from global settings and subscriptions."
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.core.event import eventmanager
|
||||
from app.db.subscribe_oper import SubscribeOper
|
||||
from app.helper.server import MoviePilotServerHelper
|
||||
@@ -25,6 +26,11 @@ class DeleteSubscribeInput(BaseModel):
|
||||
|
||||
class DeleteSubscribeTool(MoviePilotTool):
|
||||
name: str = "delete_subscribe"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.Subscription,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = "Delete a media subscription by its ID. This will remove the subscription and stop automatic downloads for that media."
|
||||
args_schema: Type[BaseModel] = DeleteSubscribeInput
|
||||
require_admin: bool = True
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.db.transferhistory_oper import TransferHistoryOper
|
||||
from app.log import logger
|
||||
|
||||
@@ -21,6 +22,11 @@ class DeleteTransferHistoryInput(BaseModel):
|
||||
|
||||
class DeleteTransferHistoryTool(MoviePilotTool):
|
||||
name: str = "delete_transfer_history"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.Transfer,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = "Delete a specific transfer history record by its ID. This is useful when you need to remove a failed transfer record before retrying the transfer, as the system skips files that already have transfer history."
|
||||
args_schema: Type[BaseModel] = DeleteTransferHistoryInput
|
||||
require_admin: bool = True
|
||||
|
||||
@@ -7,6 +7,7 @@ from anyio import Path as AsyncPath
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.log import logger
|
||||
|
||||
|
||||
@@ -20,9 +21,16 @@ class EditFileInput(BaseModel):
|
||||
|
||||
class EditFileTool(MoviePilotTool):
|
||||
name: str = "edit_file"
|
||||
description: str = "Edit a file by replacing specific old text with new text. Useful for modifying configuration files, code, or scripts."
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.File,
|
||||
]
|
||||
description: str = (
|
||||
"Edit a local text file by replacing specific old text with new text. "
|
||||
"Non-admin users can only edit files inside the MoviePilot config, "
|
||||
"Agent memory/activity, and log directories."
|
||||
)
|
||||
args_schema: Type[BaseModel] = EditFileInput
|
||||
require_admin: bool = True
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""根据参数生成友好的提示消息"""
|
||||
@@ -34,21 +42,27 @@ class EditFileTool(MoviePilotTool):
|
||||
logger.info(f"执行工具: {self.name}, 参数: file_path={file_path}")
|
||||
|
||||
try:
|
||||
path = AsyncPath(file_path)
|
||||
resolved_path, access_error = await self._check_local_file_access(
|
||||
file_path, operation="编辑"
|
||||
)
|
||||
if access_error:
|
||||
return access_error
|
||||
|
||||
path = AsyncPath(resolved_path)
|
||||
# 校验逻辑:如果要替换特定文本,文件必须存在且包含该文本
|
||||
if not await path.exists():
|
||||
# 如果 old_text 为空,可能用户想直接创建文件,但通常 edit_file 需要匹配旧内容
|
||||
if old_text:
|
||||
return f"错误:文件 {file_path} 不存在,无法进行内容替换。"
|
||||
return f"错误:文件 {resolved_path} 不存在,无法进行内容替换。"
|
||||
|
||||
if await path.exists() and not await path.is_file():
|
||||
return f"错误:{file_path} 不是一个文件"
|
||||
return f"错误:{resolved_path} 不是一个文件"
|
||||
|
||||
if await path.exists():
|
||||
content = await path.read_text(encoding="utf-8")
|
||||
if old_text not in content:
|
||||
logger.warning(f"编辑文件 {file_path} 失败:未找到指定的旧文本块")
|
||||
return f"错误:在文件 {file_path} 中未找到指定的旧文本。请确保包含所有的空格、缩进 and 换行符。"
|
||||
logger.warning(f"编辑文件 {resolved_path} 失败:未找到指定的旧文本块")
|
||||
return f"错误:在文件 {resolved_path} 中未找到指定的旧文本。请确保包含所有的空格、缩进 and 换行符。"
|
||||
occurrences = content.count(old_text)
|
||||
new_content = content.replace(old_text, new_text)
|
||||
else:
|
||||
@@ -62,8 +76,8 @@ class EditFileTool(MoviePilotTool):
|
||||
# 写入文件
|
||||
await path.write_text(new_content, encoding="utf-8")
|
||||
|
||||
logger.info(f"成功编辑文件 {file_path},替换了 {occurrences} 处内容")
|
||||
return f"成功编辑文件 {file_path} (替换了 {occurrences} 处匹配内容)"
|
||||
logger.info(f"成功编辑文件 {resolved_path},替换了 {occurrences} 处内容")
|
||||
return f"成功编辑文件 {resolved_path} (替换了 {occurrences} 处匹配内容)"
|
||||
|
||||
except PermissionError:
|
||||
return f"错误:没有访问/修改 {file_path} 的权限"
|
||||
|
||||
@@ -14,7 +14,8 @@ from typing import Any, Literal, Optional, TextIO, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.impl.terminal_session import (
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.agent.tools.impl._terminal_session import (
|
||||
TERMINAL_DEFAULT_READ_BYTES,
|
||||
TERMINAL_MAX_READ_BYTES,
|
||||
TERMINAL_WAIT_DEFAULT_MS,
|
||||
@@ -200,6 +201,11 @@ class ExecuteCommandTool(MoviePilotTool):
|
||||
"""统一执行和管理 Shell 命令的 Agent 工具。"""
|
||||
|
||||
name: str = "execute_command"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Command,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = (
|
||||
"Start and manage shell commands on the server. By default action=start "
|
||||
"launches a background session and immediately returns session_id/status/"
|
||||
@@ -445,6 +451,9 @@ class ExecuteCommandTool(MoviePilotTool):
|
||||
except asyncio.TimeoutError:
|
||||
timed_out = True
|
||||
await self._cleanup_process(process, wait_task)
|
||||
except asyncio.CancelledError:
|
||||
await self._cleanup_process(process, wait_task)
|
||||
raise
|
||||
|
||||
try:
|
||||
await self._finish_reader_tasks(reader_tasks)
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.chain.recommend import RecommendChain
|
||||
from app.log import logger
|
||||
from app.schemas.types import MediaType, media_type_to_agent
|
||||
@@ -44,6 +45,11 @@ class GetRecommendationsInput(BaseModel):
|
||||
|
||||
class GetRecommendationsTool(MoviePilotTool):
|
||||
name: str = "get_recommendations"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Media,
|
||||
ToolTag.Recommendation,
|
||||
]
|
||||
description: str = "Get trending and popular media recommendations from various sources. Returns curated lists of popular movies, TV shows, and anime based on different criteria like trending, ratings, or calendar schedules. Supports pagination with 20 items per page."
|
||||
args_schema: Type[BaseModel] = GetRecommendationsInput
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import List, Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.chain.search import SearchChain
|
||||
from app.log import logger
|
||||
from ._torrent_search_utils import (
|
||||
@@ -47,6 +48,10 @@ class GetSearchResultsInput(BaseModel):
|
||||
|
||||
class GetSearchResultsTool(MoviePilotTool):
|
||||
name: str = "get_search_results"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Resource,
|
||||
]
|
||||
description: str = "Get cached torrent search results from search_torrents with optional filters. Supports pagination with up to 50 results per page."
|
||||
args_schema: Type[BaseModel] = GetSearchResultsInput
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.agent.tools.impl._plugin_tool_utils import (
|
||||
get_plugin_snapshot,
|
||||
install_plugin_runtime,
|
||||
@@ -36,6 +37,11 @@ class InstallPluginInput(BaseModel):
|
||||
|
||||
class InstallPluginTool(MoviePilotTool):
|
||||
name: str = "install_plugin"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.Plugin,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = (
|
||||
"Install a plugin by exact plugin_id from the plugin market or local plugin repositories. "
|
||||
"Use query_market_plugins first when you need filtering or discovery."
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.chain.storage import StorageChain
|
||||
from app.log import logger
|
||||
from app.schemas.file import FileItem
|
||||
@@ -24,6 +25,11 @@ class ListDirectoryInput(BaseModel):
|
||||
|
||||
class ListDirectoryTool(MoviePilotTool):
|
||||
name: str = "list_directory"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Directory,
|
||||
ToolTag.File,
|
||||
]
|
||||
description: str = "List actual files and folders in a file system directory (NOT configuration). Shows files and subdirectories with their names, types, sizes, and modification times. Returns up to 20 items and the total count if there are more items. Use 'query_directory_settings' to query directory configuration settings."
|
||||
args_schema: Type[BaseModel] = ListDirectoryInput
|
||||
|
||||
@@ -110,6 +116,13 @@ class ListDirectoryTool(MoviePilotTool):
|
||||
logger.info(f"执行工具: {self.name}, 参数: path={path}, storage={storage}, sort_by={sort_by}")
|
||||
|
||||
try:
|
||||
resolved_path, access_error = await self._check_local_storage_access(
|
||||
path=path, storage=storage, operation="列出"
|
||||
)
|
||||
if access_error:
|
||||
return access_error
|
||||
if resolved_path:
|
||||
path = str(resolved_path)
|
||||
return await self.run_blocking(
|
||||
"storage", self._list_directory_sync, path, storage, sort_by
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.log import logger
|
||||
|
||||
|
||||
@@ -18,6 +19,11 @@ class ListSlashCommandsInput(BaseModel):
|
||||
|
||||
class ListSlashCommandsTool(MoviePilotTool):
|
||||
name: str = "list_slash_commands"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.SlashCommand,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = (
|
||||
"List all available slash commands in the system, including system preset commands "
|
||||
"(e.g. /cookiecloud, /sites, /subscribes, /downloading, /transfer, /restart, etc.) "
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
"""修改下载任务工具"""
|
||||
|
||||
from typing import Optional, Type, List
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.chain.download import DownloadChain
|
||||
from app.log import logger
|
||||
|
||||
|
||||
class ModifyDownloadInput(BaseModel):
|
||||
"""修改下载任务工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
hash: str = Field(
|
||||
..., description="Task hash (can be obtained from query_download_tasks tool)"
|
||||
)
|
||||
action: Optional[str] = Field(
|
||||
None,
|
||||
description="Action to perform on the task: 'start' to resume downloading, 'stop' to pause downloading. "
|
||||
"If not provided, no start/stop action will be performed.",
|
||||
)
|
||||
tags: Optional[List[str]] = Field(
|
||||
None,
|
||||
description="List of tags to set on the download task. If provided, these tags will be added to the task. "
|
||||
"Example: ['movie', 'hd']",
|
||||
)
|
||||
downloader: Optional[str] = Field(
|
||||
None,
|
||||
description="Name of specific downloader (optional, if not provided will search all downloaders)",
|
||||
)
|
||||
|
||||
|
||||
class ModifyDownloadTool(MoviePilotTool):
|
||||
"""修改下载任务工具"""
|
||||
|
||||
name: str = "modify_download"
|
||||
description: str = (
|
||||
"Modify a download task in the downloader by task hash. "
|
||||
"Supports: 1) Setting tags on a download task, "
|
||||
"2) Starting (resuming) a paused download task, "
|
||||
"3) Stopping (pausing) a downloading task. "
|
||||
"Multiple operations can be performed in a single call."
|
||||
)
|
||||
args_schema: Type[BaseModel] = ModifyDownloadInput
|
||||
require_admin: bool = True
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
hash_value = kwargs.get("hash", "")
|
||||
action = kwargs.get("action")
|
||||
tags = kwargs.get("tags")
|
||||
downloader = kwargs.get("downloader")
|
||||
|
||||
parts = [f"修改下载任务: {hash_value}"]
|
||||
if action == "start":
|
||||
parts.append("操作: 开始下载")
|
||||
elif action == "stop":
|
||||
parts.append("操作: 暂停下载")
|
||||
if tags:
|
||||
parts.append(f"标签: {', '.join(tags)}")
|
||||
if downloader:
|
||||
parts.append(f"下载器: {downloader}")
|
||||
return " | ".join(parts)
|
||||
|
||||
@staticmethod
|
||||
def _modify_download_sync(
|
||||
hash_value: str,
|
||||
action: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
downloader: Optional[str] = None,
|
||||
) -> List[str]:
|
||||
"""同步修改下载任务状态和标签,避免下载器 SDK 阻塞事件循环。"""
|
||||
download_chain = DownloadChain()
|
||||
results = []
|
||||
|
||||
if tags:
|
||||
tag_result = download_chain.set_torrents_tag(
|
||||
hashs=[hash_value], tags=tags, downloader=downloader
|
||||
)
|
||||
if tag_result:
|
||||
results.append(f"成功设置标签:{', '.join(tags)}")
|
||||
else:
|
||||
results.append("设置标签失败,请检查任务是否存在或下载器是否可用")
|
||||
|
||||
if action:
|
||||
action_result = download_chain.set_downloading(
|
||||
hash_str=hash_value, oper=action, name=downloader
|
||||
)
|
||||
action_desc = "开始" if action == "start" else "暂停"
|
||||
if action_result:
|
||||
results.append(f"成功{action_desc}下载任务")
|
||||
else:
|
||||
results.append(f"{action_desc}下载任务失败,请检查任务是否存在或下载器是否可用")
|
||||
|
||||
return results
|
||||
|
||||
async def run(
|
||||
self,
|
||||
hash: str,
|
||||
action: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
downloader: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, 参数: hash={hash}, action={action}, tags={tags}, downloader={downloader}"
|
||||
)
|
||||
|
||||
try:
|
||||
# 校验 hash 格式
|
||||
if len(hash) != 40 or not all(c in "0123456789abcdefABCDEF" for c in hash):
|
||||
return "参数错误:hash 格式无效,请先使用 query_download_tasks 工具获取正确的 hash。"
|
||||
|
||||
# 校验参数:至少需要一个操作
|
||||
if not action and not tags:
|
||||
return "参数错误:至少需要指定 action(start/stop)或 tags 中的一个。"
|
||||
|
||||
# 校验 action 参数
|
||||
if action and action not in ("start", "stop"):
|
||||
return f"参数错误:action 只支持 'start'(开始下载)或 'stop'(暂停下载),收到: '{action}'。"
|
||||
|
||||
results = await self.run_blocking(
|
||||
"downloader",
|
||||
self._modify_download_sync,
|
||||
hash,
|
||||
action,
|
||||
tags,
|
||||
downloader,
|
||||
)
|
||||
|
||||
return f"下载任务 {hash}:" + ";".join(results)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"修改下载任务失败: {e}", exc_info=True)
|
||||
return f"修改下载任务时发生错误: {str(e)}"
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type, List
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.agent.tools.impl._filter_rule_utils import (
|
||||
get_builtin_rules,
|
||||
serialize_builtin_rule,
|
||||
@@ -27,6 +28,10 @@ class QueryBuiltinFilterRulesInput(BaseModel):
|
||||
|
||||
class QueryBuiltinFilterRulesTool(MoviePilotTool):
|
||||
name: str = "query_builtin_filter_rules"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.FilterRule,
|
||||
]
|
||||
description: str = (
|
||||
"Query built-in filter rules defined by the backend filter module. "
|
||||
"These rule IDs can be used directly inside rule_string expressions for filter rule groups. "
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type, List
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.agent.tools.impl._filter_rule_utils import (
|
||||
collect_custom_rule_group_refs,
|
||||
get_custom_rules,
|
||||
@@ -32,6 +33,10 @@ class QueryCustomFilterRulesInput(BaseModel):
|
||||
|
||||
class QueryCustomFilterRulesTool(MoviePilotTool):
|
||||
name: str = "query_custom_filter_rules"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.FilterRule,
|
||||
]
|
||||
description: str = (
|
||||
"Query custom filter rules stored in CustomFilterRules. "
|
||||
"Custom rules can be referenced from rule_string expressions in filter rule groups. "
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.log import logger
|
||||
from app.schemas.types import SystemConfigKey
|
||||
@@ -20,6 +21,11 @@ class QueryCustomIdentifiersInput(BaseModel):
|
||||
|
||||
class QueryCustomIdentifiersTool(MoviePilotTool):
|
||||
name: str = "query_custom_identifiers"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.FilterRule,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = (
|
||||
"Query all currently configured custom identifiers (自定义识别词). "
|
||||
"Returns the list of identifier rules used for preprocessing torrent/file names before media recognition. "
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.helper.directory import DirectoryHelper
|
||||
from app.log import logger
|
||||
|
||||
@@ -23,6 +24,12 @@ class QueryDirectorySettingsInput(BaseModel):
|
||||
|
||||
class QueryDirectorySettingsTool(MoviePilotTool):
|
||||
name: str = "query_directory_settings"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Directory,
|
||||
ToolTag.Settings,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = "Query system directory configuration settings (NOT file listings). Returns configured directory paths, storage types, transfer modes, and other directory-related settings. Use 'list_directory' to list actual files and folders in a directory."
|
||||
require_admin: bool = True
|
||||
args_schema: Type[BaseModel] = QueryDirectorySettingsInput
|
||||
|
||||
126
app/agent/tools/impl/query_doctor_report.py
Normal file
126
app/agent/tools/impl/query_doctor_report.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""查询 MoviePilot Doctor 诊断报告工具。"""
|
||||
|
||||
import json
|
||||
from typing import Any, Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.doctor import run_doctor
|
||||
from app.log import logger
|
||||
|
||||
|
||||
class QueryDoctorReportInput(BaseModel):
|
||||
"""查询 Doctor 诊断报告工具的输入参数模型。"""
|
||||
|
||||
explanation: Optional[str] = Field(
|
||||
None,
|
||||
description="Clear explanation of why this tool is being used in the current context",
|
||||
)
|
||||
deep: Optional[bool] = Field(
|
||||
False,
|
||||
description=(
|
||||
"Whether to run deeper checks. When true, doctor may perform slower environment probes "
|
||||
"such as PostgreSQL TCP connectivity checks."
|
||||
),
|
||||
)
|
||||
include_details: Optional[bool] = Field(
|
||||
True,
|
||||
description=(
|
||||
"Whether to include full doctor findings with details and context. Set false for a compact "
|
||||
"summary when only overall status and finding titles are needed."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class QueryDoctorReportTool(MoviePilotTool):
|
||||
"""
|
||||
Doctor 离线诊断报告查询工具。
|
||||
"""
|
||||
|
||||
name: str = "query_doctor_report"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.System,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = (
|
||||
"Run MoviePilot Doctor in read-only mode and return a structured diagnostic report for troubleshooting. "
|
||||
"Use this tool when analyzing startup failures, Docker/runtime issues, port conflicts, dependency problems, "
|
||||
"database health, frontend assets, safe mode, or recent log error clues. This tool never applies fixes."
|
||||
)
|
||||
require_admin: bool = True
|
||||
args_schema: Type[BaseModel] = QueryDoctorReportInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""根据查询参数生成友好的提示消息。"""
|
||||
if kwargs.get("deep"):
|
||||
return "运行 Doctor 深度诊断"
|
||||
return "运行 Doctor 诊断"
|
||||
|
||||
@staticmethod
|
||||
def _compact_report(report: dict[str, Any]) -> dict[str, Any]:
|
||||
"""压缩诊断报告,保留 Agent 判断问题所需的核心字段。"""
|
||||
return {
|
||||
"schema_version": report.get("schema_version"),
|
||||
"status": report.get("status"),
|
||||
"generated_at": report.get("generated_at"),
|
||||
"version": report.get("version"),
|
||||
"environment": report.get("environment"),
|
||||
"summary": report.get("summary"),
|
||||
"findings": [
|
||||
{
|
||||
"id": item.get("id"),
|
||||
"severity": item.get("severity"),
|
||||
"status": item.get("status"),
|
||||
"title": item.get("title"),
|
||||
"fixable": item.get("fixable"),
|
||||
"fixed": item.get("fixed"),
|
||||
}
|
||||
for item in report.get("findings") or []
|
||||
if isinstance(item, dict)
|
||||
],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _run_doctor_report(deep: bool = False) -> dict[str, Any]:
|
||||
"""在线程池中运行只读 Doctor 诊断。"""
|
||||
return run_doctor(deep=bool(deep)).to_dict()
|
||||
|
||||
async def run(
|
||||
self,
|
||||
deep: Optional[bool] = False,
|
||||
include_details: Optional[bool] = True,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""
|
||||
运行只读 Doctor 诊断并返回 JSON 字符串。
|
||||
"""
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, deep={bool(deep)}, include_details={bool(include_details)}"
|
||||
)
|
||||
try:
|
||||
report = await self.run_blocking("default", self._run_doctor_report, bool(deep))
|
||||
if not include_details:
|
||||
report = self._compact_report(report)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"deep": bool(deep),
|
||||
"include_details": bool(include_details),
|
||||
"report": report,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
default=str,
|
||||
)
|
||||
except Exception as err:
|
||||
logger.error(f"查询 Doctor 诊断报告失败: {err}", exc_info=True)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"查询 Doctor 诊断报告时发生错误: {str(err)}",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
@@ -1,16 +1,17 @@
|
||||
"""查询下载工具"""
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional, Type, Union
|
||||
from typing import Any, Dict, List, Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.chain.download import DownloadChain
|
||||
from app.db.downloadhistory_oper import DownloadHistoryOper
|
||||
from app.log import logger
|
||||
from app.schemas import TransferTorrent, DownloadingTorrent
|
||||
from app.schemas.types import TorrentStatus, media_type_to_agent
|
||||
from app.schemas import DownloaderTorrent
|
||||
from app.schemas.types import TorrentQueryStatus, media_type_to_agent
|
||||
|
||||
|
||||
class QueryDownloadTasksInput(BaseModel):
|
||||
@@ -20,6 +21,14 @@ class QueryDownloadTasksInput(BaseModel):
|
||||
description="Name of specific downloader to query (optional, if not provided queries all configured downloaders)")
|
||||
status: Optional[str] = Field("all",
|
||||
description="Filter downloads by status: 'downloading' for active downloads, 'completed' for finished downloads, 'paused' for paused downloads, 'all' for all downloads")
|
||||
include_all_tags: Optional[bool] = Field(
|
||||
False,
|
||||
description="Include tasks without the MoviePilot built-in tag. Default false keeps the normal MoviePilot task scope.",
|
||||
)
|
||||
include_trackers: Optional[bool] = Field(
|
||||
False,
|
||||
description="Include tracker URLs when supported. Hash queries always include trackers.",
|
||||
)
|
||||
hash: Optional[str] = Field(None, description="Query specific download task by hash (optional, if provided will search for this specific task regardless of status)")
|
||||
title: Optional[str] = Field(None, description="Query download tasks by title/name (optional, supports partial match, searches all tasks if provided)")
|
||||
tag: Optional[str] = Field(None, description="Filter download tasks by tag (optional, supports partial match, e.g. 'movie' will match tasks with tag 'movie' or 'movie_2024')")
|
||||
@@ -27,30 +36,53 @@ class QueryDownloadTasksInput(BaseModel):
|
||||
|
||||
class QueryDownloadTasksTool(MoviePilotTool):
|
||||
name: str = "query_download_tasks"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Download,
|
||||
]
|
||||
description: str = "Query download status and list download tasks. Can query all active downloads, or search for specific tasks by hash, title, or tag. Shows download progress, completion status, tags, and task details from configured downloaders."
|
||||
args_schema: Type[BaseModel] = QueryDownloadTasksInput
|
||||
|
||||
@staticmethod
|
||||
def _get_all_torrents(download_chain: DownloadChain, downloader: Optional[str] = None) -> List[Union[TransferTorrent, DownloadingTorrent]]:
|
||||
def _normalize_query_status(status: Optional[str]) -> TorrentQueryStatus:
|
||||
"""
|
||||
归一下载任务查询状态。
|
||||
"""
|
||||
status_value = str(status or "").strip().lower()
|
||||
if not status_value or status_value == TorrentQueryStatus.ALL.value:
|
||||
return TorrentQueryStatus.ALL
|
||||
if status_value in {"completed", "complete", "seeding"}:
|
||||
return TorrentQueryStatus.COMPLETED
|
||||
if status_value in {"paused", "pause"}:
|
||||
return TorrentQueryStatus.PAUSED
|
||||
if status_value == TorrentQueryStatus.DOWNLOADING.value:
|
||||
return TorrentQueryStatus.DOWNLOADING
|
||||
return TorrentQueryStatus.ALL
|
||||
|
||||
@staticmethod
|
||||
def _normalize_include_all_tags(include_all_tags: Any) -> bool:
|
||||
"""
|
||||
归一全部标签查询开关。
|
||||
"""
|
||||
if isinstance(include_all_tags, bool):
|
||||
return include_all_tags
|
||||
if isinstance(include_all_tags, str):
|
||||
return include_all_tags.strip().lower() in {"1", "true", "yes", "on", "是"}
|
||||
return bool(include_all_tags)
|
||||
|
||||
@staticmethod
|
||||
def _get_all_torrents(
|
||||
download_chain: DownloadChain,
|
||||
downloader: Optional[str] = None,
|
||||
include_all_tags: bool = False,
|
||||
) -> List[DownloaderTorrent]:
|
||||
"""
|
||||
查询所有状态的任务(包括下载中和已完成的任务)
|
||||
"""
|
||||
all_torrents = []
|
||||
# 查询下载的任务
|
||||
downloading_torrents = download_chain.list_torrents(
|
||||
downloader=downloader,
|
||||
status=TorrentStatus.DOWNLOADING
|
||||
) or []
|
||||
all_torrents.extend(downloading_torrents)
|
||||
|
||||
# 查询已完成的任务(可转移状态)
|
||||
transfer_torrents = download_chain.list_torrents(
|
||||
return download_chain.list_torrents(
|
||||
downloader=downloader,
|
||||
status=TorrentStatus.TRANSFER
|
||||
include_all_tags=include_all_tags,
|
||||
) or []
|
||||
all_torrents.extend(transfer_torrents)
|
||||
|
||||
return all_torrents
|
||||
|
||||
@staticmethod
|
||||
def _format_progress(progress: Optional[float]) -> Optional[str]:
|
||||
@@ -66,7 +98,7 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
|
||||
@staticmethod
|
||||
def _apply_download_history(
|
||||
torrent: Union[TransferTorrent, DownloadingTorrent], history: Any
|
||||
torrent: DownloaderTorrent, history: Any
|
||||
) -> None:
|
||||
"""将下载历史中的补充信息回填到下载任务结果中。"""
|
||||
if not history:
|
||||
@@ -86,7 +118,7 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
|
||||
@classmethod
|
||||
def _load_history_map(
|
||||
cls, torrents: List[Union[TransferTorrent, DownloadingTorrent]]
|
||||
cls, torrents: List[DownloaderTorrent]
|
||||
) -> Dict[str, Any]:
|
||||
"""批量加载下载历史,避免逐条查询形成 N+1。"""
|
||||
hashes = [torrent.hash for torrent in torrents if getattr(torrent, "hash", None)]
|
||||
@@ -102,15 +134,23 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
hash_value: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
include_all_tags: bool = False,
|
||||
include_trackers: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
同步查询下载器和下载历史,整个链路放在线程池中执行。
|
||||
"""
|
||||
download_chain = DownloadChain()
|
||||
query_status = cls._normalize_query_status(status)
|
||||
include_all_tags = cls._normalize_include_all_tags(include_all_tags)
|
||||
|
||||
if hash_value:
|
||||
torrents = (
|
||||
download_chain.list_torrents(downloader=downloader, hashs=[hash_value])
|
||||
download_chain.list_torrents(
|
||||
downloader=downloader,
|
||||
hashs=[hash_value],
|
||||
include_all_tags=include_all_tags,
|
||||
)
|
||||
or []
|
||||
)
|
||||
if not torrents:
|
||||
@@ -123,7 +163,11 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
cls._apply_download_history(torrent, history_map.get(torrent.hash))
|
||||
filtered_downloads = list(torrents)
|
||||
elif title:
|
||||
all_torrents = cls._get_all_torrents(download_chain, downloader)
|
||||
all_torrents = cls._get_all_torrents(
|
||||
download_chain,
|
||||
downloader,
|
||||
include_all_tags=include_all_tags,
|
||||
)
|
||||
history_map = cls._load_history_map(all_torrents)
|
||||
filtered_downloads = []
|
||||
title_lower = title.lower()
|
||||
@@ -145,7 +189,7 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
if not filtered_downloads:
|
||||
return {"message": f"未找到标题包含 '{title}' 的下载任务"}
|
||||
else:
|
||||
if status == "downloading":
|
||||
if query_status == TorrentQueryStatus.DOWNLOADING and not include_all_tags:
|
||||
downloads = download_chain.downloading(name=downloader) or []
|
||||
filtered_downloads = [
|
||||
dl
|
||||
@@ -153,19 +197,12 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
if not downloader or dl.downloader == downloader
|
||||
]
|
||||
else:
|
||||
all_torrents = cls._get_all_torrents(download_chain, downloader)
|
||||
filtered_downloads = []
|
||||
for torrent in all_torrents:
|
||||
if downloader and torrent.downloader != downloader:
|
||||
continue
|
||||
if status == "completed" and torrent.state not in [
|
||||
"seeding",
|
||||
"completed",
|
||||
]:
|
||||
continue
|
||||
if status == "paused" and torrent.state != "paused":
|
||||
continue
|
||||
filtered_downloads.append(torrent)
|
||||
list_status = None if query_status == TorrentQueryStatus.ALL else query_status.value
|
||||
filtered_downloads = download_chain.list_torrents(
|
||||
downloader=downloader,
|
||||
status=list_status,
|
||||
include_all_tags=include_all_tags,
|
||||
) or []
|
||||
|
||||
history_map = cls._load_history_map(filtered_downloads)
|
||||
for torrent in filtered_downloads:
|
||||
@@ -182,6 +219,16 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
if not filtered_downloads:
|
||||
return {"message": "未找到相关下载任务"}
|
||||
|
||||
if hash_value or include_trackers:
|
||||
for torrent in filtered_downloads:
|
||||
if not getattr(torrent, "hash", None):
|
||||
continue
|
||||
tracker_map = download_chain.get_torrent_trackers(
|
||||
hash_string=torrent.hash,
|
||||
downloader=getattr(torrent, "downloader", None) or downloader,
|
||||
) or {}
|
||||
torrent.trackers = tracker_map.get(getattr(torrent, "downloader", None)) or []
|
||||
|
||||
return {"downloads": filtered_downloads}
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
@@ -190,6 +237,9 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
status = kwargs.get("status", "all")
|
||||
hash_value = kwargs.get("hash")
|
||||
title = kwargs.get("title")
|
||||
include_all_tags = self._normalize_include_all_tags(
|
||||
kwargs.get("include_all_tags", False)
|
||||
)
|
||||
|
||||
parts = ["查询下载任务"]
|
||||
|
||||
@@ -208,6 +258,10 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
tag = kwargs.get("tag")
|
||||
if tag:
|
||||
parts.append(f"标签: {tag}")
|
||||
if include_all_tags:
|
||||
parts.append("范围: 全部标签")
|
||||
if kwargs.get("include_trackers"):
|
||||
parts.append("包含Tracker")
|
||||
|
||||
return " | ".join(parts) if len(parts) > 1 else parts[0]
|
||||
|
||||
@@ -215,8 +269,15 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
status: Optional[str] = "all",
|
||||
hash: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
tag: Optional[str] = None, **kwargs) -> str:
|
||||
logger.info(f"执行工具: {self.name}, 参数: downloader={downloader}, status={status}, hash={hash}, title={title}, tag={tag}")
|
||||
tag: Optional[str] = None,
|
||||
include_all_tags: Optional[bool] = False,
|
||||
include_trackers: Optional[bool] = False,
|
||||
**kwargs) -> str:
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, 参数: downloader={downloader}, status={status}, "
|
||||
f"hash={hash}, title={title}, tag={tag}, include_all_tags={include_all_tags}, "
|
||||
f"include_trackers={include_trackers}"
|
||||
)
|
||||
try:
|
||||
payload = await self.run_blocking(
|
||||
"downloader",
|
||||
@@ -226,6 +287,8 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
hash,
|
||||
title,
|
||||
tag,
|
||||
self._normalize_include_all_tags(include_all_tags),
|
||||
self._normalize_include_all_tags(include_trackers),
|
||||
)
|
||||
if payload.get("message"):
|
||||
return payload["message"]
|
||||
@@ -251,6 +314,16 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
"upspeed": getattr(d, "upspeed", None),
|
||||
"dlspeed": getattr(d, "dlspeed", None),
|
||||
"tags": d.tags,
|
||||
"save_path": getattr(d, "save_path", None),
|
||||
"content_path": getattr(d, "content_path", None) or (
|
||||
d.path.as_posix() if getattr(d, "path", None) else None
|
||||
),
|
||||
"category": getattr(d, "category", None),
|
||||
"download_limit": getattr(d, "download_limit", None),
|
||||
"upload_limit": getattr(d, "upload_limit", None),
|
||||
"ratio_limit": getattr(d, "ratio_limit", None),
|
||||
"seeding_time_limit": getattr(d, "seeding_time_limit", None),
|
||||
"trackers": getattr(d, "trackers", None) or [],
|
||||
"left_time": getattr(d, "left_time", None)
|
||||
}
|
||||
# 精简 media 字段
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.log import logger
|
||||
from app.schemas.types import SystemConfigKey
|
||||
@@ -18,8 +19,15 @@ class QueryDownloadersInput(BaseModel):
|
||||
|
||||
class QueryDownloadersTool(MoviePilotTool):
|
||||
name: str = "query_downloaders"
|
||||
description: str = "Query downloader configuration and list all available downloaders. Shows downloader status, connection details, and configuration settings."
|
||||
require_admin: bool = True
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Download,
|
||||
]
|
||||
description: str = (
|
||||
"Query downloader configuration and list available downloaders. Non-admin users receive "
|
||||
"a safe view with only the fields needed to choose a downloader, without host, account, "
|
||||
"password, token or API key values."
|
||||
)
|
||||
args_schema: Type[BaseModel] = QueryDownloadersInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
@@ -31,11 +39,35 @@ class QueryDownloadersTool(MoviePilotTool):
|
||||
"""从内存配置缓存中读取下载器配置。"""
|
||||
return SystemConfigOper().get(SystemConfigKey.Downloaders)
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_downloaders_config(downloaders_config: list) -> list:
|
||||
"""
|
||||
生成普通用户可见的下载器配置视图。
|
||||
|
||||
:param downloaders_config: 系统下载器完整配置列表
|
||||
:return: 仅包含名称、类型和启用状态的安全配置列表
|
||||
"""
|
||||
safe_fields = ("name", "type", "enabled", "default", "priority")
|
||||
safe_downloaders = []
|
||||
for downloader in downloaders_config:
|
||||
if not isinstance(downloader, dict):
|
||||
continue
|
||||
safe_downloaders.append({
|
||||
key: downloader.get(key)
|
||||
for key in safe_fields
|
||||
if key in downloader
|
||||
})
|
||||
return safe_downloaders
|
||||
|
||||
async def run(self, **kwargs) -> str:
|
||||
logger.info(f"执行工具: {self.name}")
|
||||
try:
|
||||
downloaders_config = self._load_downloaders_config()
|
||||
if downloaders_config:
|
||||
if not await self.is_admin_user():
|
||||
downloaders_config = self._sanitize_downloaders_config(
|
||||
downloaders_config
|
||||
)
|
||||
return json.dumps(downloaders_config, ensure_ascii=False, indent=2)
|
||||
return "未配置下载器。"
|
||||
except Exception as e:
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.log import logger
|
||||
|
||||
@@ -20,6 +21,10 @@ class QueryEpisodeScheduleInput(BaseModel):
|
||||
|
||||
class QueryEpisodeScheduleTool(MoviePilotTool):
|
||||
name: str = "query_episode_schedule"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Media,
|
||||
]
|
||||
description: str = "Query TV series episode air dates and schedule. Returns non-duplicated schedule fields, including episode list, air-date statistics, and per-episode metadata. Filters out episodes without air dates."
|
||||
args_schema: Type[BaseModel] = QueryEpisodeScheduleInput
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.agent.tools.impl._plugin_tool_utils import (
|
||||
DEFAULT_PLUGIN_CANDIDATE_LIMIT,
|
||||
MAX_PLUGIN_CANDIDATE_LIMIT,
|
||||
@@ -34,6 +35,11 @@ class QueryInstalledPluginsInput(BaseModel):
|
||||
|
||||
class QueryInstalledPluginsTool(MoviePilotTool):
|
||||
name: str = "query_installed_plugins"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Plugin,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = (
|
||||
"Query installed plugins in MoviePilot. Returns all installed plugins or filters them by keywords. "
|
||||
"Use this tool to find the exact plugin_id before uninstall_plugin or other plugin management tools are used."
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Optional, Type, Any
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.chain.mediaserver import MediaServerChain
|
||||
from app.helper.mediaserver import MediaServerHelper
|
||||
from app.log import logger
|
||||
@@ -84,6 +85,11 @@ class QueryLibraryExistsInput(BaseModel):
|
||||
|
||||
class QueryLibraryExistsTool(MoviePilotTool):
|
||||
name: str = "query_library_exists"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Library,
|
||||
ToolTag.Media,
|
||||
]
|
||||
description: str = "Check whether media already exists in Plex, Emby, or Jellyfin by media ID. Results are grouped by media server; TV results include existing episodes, total episodes, and missing episodes/seasons. Requires tmdb_id or douban_id from search_media."
|
||||
args_schema: Type[BaseModel] = QueryLibraryExistsInput
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.chain.mediaserver import MediaServerChain
|
||||
from app.helper.service import ServiceConfigHelper
|
||||
from app.log import logger
|
||||
@@ -30,6 +31,11 @@ class QueryLibraryLatestInput(BaseModel):
|
||||
|
||||
class QueryLibraryLatestTool(MoviePilotTool):
|
||||
name: str = "query_library_latest"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Library,
|
||||
ToolTag.Media,
|
||||
]
|
||||
description: str = "Query the latest media items added to the media server (Plex, Emby, Jellyfin). Returns recently added movies and TV series with their titles, images, links, and other metadata. Supports pagination with 20 items per page."
|
||||
args_schema: Type[BaseModel] = QueryLibraryLatestInput
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.agent.tools.impl._plugin_tool_utils import (
|
||||
DEFAULT_PLUGIN_CANDIDATE_LIMIT,
|
||||
MAX_PLUGIN_CANDIDATE_LIMIT,
|
||||
@@ -38,6 +39,11 @@ class QueryMarketPluginsInput(BaseModel):
|
||||
|
||||
class QueryMarketPluginsTool(MoviePilotTool):
|
||||
name: str = "query_market_plugins"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Plugin,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = (
|
||||
"Query available plugins from the plugin market and local plugin repositories. "
|
||||
"Can return the full plugin list or filter by keywords before install_plugin is used."
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.chain.media import MediaChain
|
||||
from app.log import logger
|
||||
from app.schemas.types import MediaType
|
||||
@@ -25,6 +26,10 @@ class QueryMediaDetailInput(BaseModel):
|
||||
|
||||
class QueryMediaDetailTool(MoviePilotTool):
|
||||
name: str = "query_media_detail"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Media,
|
||||
]
|
||||
description: str = "Query supplementary media details from TMDB by ID and media_type. Accepts tmdb_id or douban_id (at least one required). media_type accepts 'movie' or 'tv'. Returns non-duplicated detail fields such as status, genres, directors, actors, and season info for TV series."
|
||||
args_schema: Type[BaseModel] = QueryMediaDetailInput
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.runtime import agent_runtime_manager
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.log import logger
|
||||
|
||||
|
||||
@@ -26,6 +27,10 @@ class QueryPersonasInput(BaseModel):
|
||||
|
||||
class QueryPersonasTool(MoviePilotTool):
|
||||
name: str = "query_personas"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Persona,
|
||||
]
|
||||
description: str = (
|
||||
"List all available personas (人格) and show which one is currently active. "
|
||||
"Use this before switching persona when the user asks for a different speaking style but does not name "
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.core.plugin import PluginManager
|
||||
from app.log import logger
|
||||
|
||||
@@ -25,6 +26,11 @@ class QueryPluginCapabilitiesInput(BaseModel):
|
||||
|
||||
class QueryPluginCapabilitiesTool(MoviePilotTool):
|
||||
name: str = "query_plugin_capabilities"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Plugin,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = (
|
||||
"Query the capabilities of installed plugins, including supported commands and scheduled services. "
|
||||
"Commands are slash-commands (e.g. /xxx) that can be executed via the run_slash_command tool. "
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.agent.tools.impl._plugin_tool_utils import get_plugin_snapshot
|
||||
from app.core.plugin import PluginManager
|
||||
from app.log import logger
|
||||
@@ -24,6 +25,11 @@ class QueryPluginConfigInput(BaseModel):
|
||||
|
||||
class QueryPluginConfigTool(MoviePilotTool):
|
||||
name: str = "query_plugin_config"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Plugin,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = (
|
||||
"Query the saved configuration of an installed plugin. "
|
||||
"Returns the current saved config and, when available, the plugin's default config model. "
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.agent.tools.impl._plugin_tool_utils import (
|
||||
PLUGIN_DATA_KEY_PREVIEW_LIMIT,
|
||||
build_preview_payload,
|
||||
@@ -36,6 +37,11 @@ class QueryPluginDataInput(BaseModel):
|
||||
|
||||
class QueryPluginDataTool(MoviePilotTool):
|
||||
name: str = "query_plugin_data"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Plugin,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
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. "
|
||||
|
||||
@@ -7,6 +7,7 @@ import cn2an
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.core.context import MediaInfo
|
||||
from app.helper.server import MoviePilotServerHelper
|
||||
from app.log import logger
|
||||
@@ -30,6 +31,11 @@ class QueryPopularSubscribesInput(BaseModel):
|
||||
|
||||
class QueryPopularSubscribesTool(MoviePilotTool):
|
||||
name: str = "query_popular_subscribes"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Subscription,
|
||||
ToolTag.Recommendation,
|
||||
]
|
||||
description: str = "Query popular subscriptions based on user shared data. Shows media with the most subscribers, supports filtering by genre, rating, minimum subscribers, and pagination."
|
||||
args_schema: Type[BaseModel] = QueryPopularSubscribesInput
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type, List
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.agent.tools.impl._filter_rule_utils import (
|
||||
collect_rule_group_usages,
|
||||
get_rule_groups,
|
||||
@@ -32,6 +33,10 @@ class QueryRuleGroupsInput(BaseModel):
|
||||
|
||||
class QueryRuleGroupsTool(MoviePilotTool):
|
||||
name: str = "query_rule_groups"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.FilterRule,
|
||||
]
|
||||
description: str = (
|
||||
"Query filter rule groups (过滤规则组 / 优先级规则组). "
|
||||
"Each rule group contains a rule_string made of built-in rules and/or custom rules. "
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.log import logger
|
||||
|
||||
|
||||
@@ -16,6 +17,10 @@ class QuerySchedulersInput(BaseModel):
|
||||
|
||||
class QuerySchedulersTool(MoviePilotTool):
|
||||
name: str = "query_schedulers"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Scheduler,
|
||||
]
|
||||
description: str = "Query scheduled tasks and list all available scheduler jobs. Shows job status, next run time, and provider information."
|
||||
args_schema: Type[BaseModel] = QuerySchedulersInput
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.db import AsyncSessionFactory
|
||||
from app.db.models.site import Site
|
||||
from app.db.models.siteuserdata import SiteUserData
|
||||
@@ -37,6 +38,11 @@ class QuerySiteUserdataInput(BaseModel):
|
||||
|
||||
class QuerySiteUserdataTool(MoviePilotTool):
|
||||
name: str = "query_site_userdata"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Site,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = "Query user data for a specific site including username, user level, upload/download statistics, seeding information, bonus points, and other account details. Supports querying data for a specific date or latest data."
|
||||
require_admin: bool = True
|
||||
args_schema: Type[BaseModel] = QuerySiteUserdataInput
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.db.site_oper import SiteOper
|
||||
from app.log import logger
|
||||
|
||||
@@ -26,8 +27,15 @@ class QuerySitesInput(BaseModel):
|
||||
|
||||
class QuerySitesTool(MoviePilotTool):
|
||||
name: str = "query_sites"
|
||||
description: str = "Query site status and list all configured sites. Shows site name, domain, status, priority, and basic configuration. Site priority (pri): smaller values have higher priority (e.g., pri=1 has higher priority than pri=10)."
|
||||
require_admin: bool = True
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Site,
|
||||
]
|
||||
description: str = (
|
||||
"Query site status and list configured sites. Non-admin users receive a safe view "
|
||||
"that omits sensitive fields: cookie, token, API key and RSS URL. "
|
||||
"Site priority (pri): smaller values have higher priority (e.g., pri=1 has higher priority than pri=10)."
|
||||
)
|
||||
args_schema: Type[BaseModel] = QuerySitesInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
@@ -51,6 +59,7 @@ class QuerySitesTool(MoviePilotTool):
|
||||
) -> str:
|
||||
logger.info(f"执行工具: {self.name}, 参数: status={status}, name={name}")
|
||||
try:
|
||||
is_admin = await self.is_admin_user()
|
||||
site_oper = SiteOper()
|
||||
# 获取所有站点(按优先级排序)
|
||||
sites = await site_oper.async_list()
|
||||
@@ -76,11 +85,25 @@ class QuerySitesTool(MoviePilotTool):
|
||||
"url": s.url,
|
||||
"pri": s.pri,
|
||||
"is_active": s.is_active,
|
||||
"cookie": s.cookie,
|
||||
"downloader": s.downloader,
|
||||
"ua": s.ua,
|
||||
"proxy": s.proxy,
|
||||
"filter": s.filter,
|
||||
"render": s.render,
|
||||
"public": s.public,
|
||||
"note": s.note,
|
||||
"limit_interval": s.limit_interval,
|
||||
"limit_count": s.limit_count,
|
||||
"limit_seconds": s.limit_seconds,
|
||||
"timeout": s.timeout,
|
||||
}
|
||||
if is_admin:
|
||||
simplified.update({
|
||||
"rss": s.rss,
|
||||
"cookie": s.cookie,
|
||||
"apikey": s.apikey,
|
||||
"token": s.token,
|
||||
})
|
||||
simplified_sites.append(simplified)
|
||||
result_json = json.dumps(simplified_sites, ensure_ascii=False, indent=2)
|
||||
return result_json
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.db import AsyncSessionFactory
|
||||
from app.db.models.subscribehistory import SubscribeHistory
|
||||
from app.log import logger
|
||||
@@ -33,6 +34,10 @@ class QuerySubscribeHistoryInput(BaseModel):
|
||||
|
||||
class QuerySubscribeHistoryTool(MoviePilotTool):
|
||||
name: str = "query_subscribe_history"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Subscription,
|
||||
]
|
||||
description: str = "Query subscription history records. Shows completed subscriptions with their details including name, type, rating, completion date, and other subscription information. Supports filtering by media type and name. Supports pagination with 20 records per page."
|
||||
args_schema: Type[BaseModel] = QuerySubscribeHistoryInput
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.helper.server import MoviePilotServerHelper
|
||||
from app.log import logger
|
||||
|
||||
@@ -26,6 +27,10 @@ class QuerySubscribeSharesInput(BaseModel):
|
||||
|
||||
class QuerySubscribeSharesTool(MoviePilotTool):
|
||||
name: str = "query_subscribe_shares"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Subscription,
|
||||
]
|
||||
description: str = "Query shared subscriptions from other users. Shows popular subscriptions shared by the community with filtering and pagination support."
|
||||
args_schema: Type[BaseModel] = QuerySubscribeSharesInput
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.db.subscribe_oper import SubscribeOper
|
||||
from app.log import logger
|
||||
from app.schemas.subscribe import Subscribe as SubscribeSchema
|
||||
@@ -71,6 +72,10 @@ class QuerySubscribesInput(BaseModel):
|
||||
|
||||
class QuerySubscribesTool(MoviePilotTool):
|
||||
name: str = "query_subscribes"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Subscription,
|
||||
]
|
||||
description: str = "Query subscription status and list user subscriptions. Returns full subscription parameters for each matched subscription. Supports pagination with 100 items per page."
|
||||
args_schema: Type[BaseModel] = QuerySubscribesInput
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.agent.tools.impl._system_setting_utils import (
|
||||
SettingSpec,
|
||||
list_setting_specs,
|
||||
@@ -56,6 +57,12 @@ class QuerySystemSettingsInput(BaseModel):
|
||||
|
||||
class QuerySystemSettingsTool(MoviePilotTool):
|
||||
name: str = "query_system_settings"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.System,
|
||||
ToolTag.Settings,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = (
|
||||
"Query system settings across both the basic Settings module and all SystemConfig-backed categories. "
|
||||
"Use this tool to inspect downloaders, media servers, notification channels, storages, directories, search-site ranges, "
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.db import AsyncSessionFactory
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
from app.log import logger
|
||||
@@ -24,6 +25,10 @@ class QueryTransferHistoryInput(BaseModel):
|
||||
|
||||
class QueryTransferHistoryTool(MoviePilotTool):
|
||||
name: str = "query_transfer_history"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Transfer,
|
||||
]
|
||||
description: str = "Query file transfer history records. Shows transfer status, source and destination paths, media information, and transfer details. Supports filtering by title and status."
|
||||
args_schema: Type[BaseModel] = QueryTransferHistoryInput
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.db import AsyncSessionFactory
|
||||
from app.db.workflow_oper import WorkflowOper
|
||||
from app.log import logger
|
||||
@@ -21,6 +22,10 @@ class QueryWorkflowsInput(BaseModel):
|
||||
|
||||
class QueryWorkflowsTool(MoviePilotTool):
|
||||
name: str = "query_workflows"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Workflow,
|
||||
]
|
||||
description: str = "Query workflow list and status. Shows workflow name, description, trigger type, state, execution count, and other workflow details. Supports filtering by state, name, and trigger type."
|
||||
args_schema: Type[BaseModel] = QueryWorkflowsInput
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from anyio import Path as AsyncPath
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.log import logger
|
||||
|
||||
# 最大读取大小 50KB
|
||||
@@ -22,6 +23,10 @@ class ReadFileInput(BaseModel):
|
||||
|
||||
class ReadFileTool(MoviePilotTool):
|
||||
name: str = "read_file"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.File,
|
||||
]
|
||||
description: str = "Read the content of a text file. Supports reading by line range. Each read is limited to 50KB; content exceeding this limit will be truncated."
|
||||
args_schema: Type[BaseModel] = ReadFileInput
|
||||
|
||||
@@ -36,13 +41,19 @@ class ReadFileTool(MoviePilotTool):
|
||||
logger.info(f"执行工具: {self.name}, 参数: file_path={file_path}, start_line={start_line}, end_line={end_line}")
|
||||
|
||||
try:
|
||||
path = AsyncPath(file_path)
|
||||
resolved_path, access_error = await self._check_local_file_access(
|
||||
file_path, operation="读取"
|
||||
)
|
||||
if access_error:
|
||||
return access_error
|
||||
|
||||
path = AsyncPath(resolved_path)
|
||||
|
||||
if not await path.exists():
|
||||
return f"错误:文件 {file_path} 不存在"
|
||||
return f"错误:文件 {resolved_path} 不存在"
|
||||
|
||||
if not await path.is_file():
|
||||
return f"错误:{file_path} 不是一个文件"
|
||||
return f"错误:{resolved_path} 不是一个文件"
|
||||
|
||||
content = await path.read_text(encoding="utf-8")
|
||||
truncated = False
|
||||
|
||||
167
app/agent/tools/impl/recognize_captcha.py
Normal file
167
app/agent/tools/impl/recognize_captcha.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""识别图形验证码工具。"""
|
||||
|
||||
import json
|
||||
from typing import Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.helper.browser import BrowserSessionHelper
|
||||
from app.helper.ocr import OcrHelper
|
||||
from app.log import logger
|
||||
|
||||
|
||||
class RecognizeCaptchaInput(BaseModel):
|
||||
"""识别图形验证码工具的输入参数模型。"""
|
||||
|
||||
explanation: Optional[str] = Field(
|
||||
None,
|
||||
description="Clear explanation of why this captcha image needs to be recognized",
|
||||
)
|
||||
image_url: str = Field(
|
||||
...,
|
||||
description=(
|
||||
"Captcha image URL obtained from the browser page, usually an img.src value. "
|
||||
"Supports http/https URLs and data:image/...;base64,... URLs."
|
||||
),
|
||||
)
|
||||
cookie: Optional[str] = Field(
|
||||
None,
|
||||
description=(
|
||||
"Optional Cookie header used to download the captcha image when the image URL "
|
||||
"requires the same authenticated browser session."
|
||||
),
|
||||
)
|
||||
user_agent: Optional[str] = Field(
|
||||
None,
|
||||
description="Optional User-Agent used when downloading the captcha image.",
|
||||
)
|
||||
allow_private_network: bool = Field(
|
||||
False,
|
||||
description="Allow captcha image URLs on localhost, loopback, private, or link-local addresses.",
|
||||
)
|
||||
|
||||
|
||||
class RecognizeCaptchaTool(MoviePilotTool):
|
||||
"""
|
||||
图形验证码识别工具,供 Agent 在浏览器自动化登录时读取验证码文本。
|
||||
"""
|
||||
|
||||
name: str = "recognize_captcha"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Web,
|
||||
]
|
||||
description: str = (
|
||||
"Recognize a graphic captcha image and return the captcha text. "
|
||||
"Use this after browser automation extracts a captcha img.src from the page. "
|
||||
"Pass cookie and user_agent when the image URL requires the current browser session. "
|
||||
"Supports http/https image URLs and data:image/...;base64,... URLs. "
|
||||
"For safety, localhost and private network URLs are blocked by default unless "
|
||||
"allow_private_network is true."
|
||||
)
|
||||
args_schema: Type[BaseModel] = RecognizeCaptchaInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""根据验证码图片参数生成友好的提示消息。"""
|
||||
image_url = str(kwargs.get("image_url") or "")
|
||||
if image_url.lower().startswith("data:image/"):
|
||||
return "识别图形验证码: data image"
|
||||
return f"识别图形验证码: {image_url}"
|
||||
|
||||
@staticmethod
|
||||
def _recognize_captcha_sync(
|
||||
image_url: str,
|
||||
cookie: Optional[str] = None,
|
||||
user_agent: Optional[str] = None,
|
||||
allow_private_network: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
在线程池中下载并识别验证码图片。
|
||||
|
||||
:param image_url: 验证码图片地址
|
||||
:param cookie: 下载图片时使用的 Cookie
|
||||
:param user_agent: 下载图片时使用的 User-Agent
|
||||
:param allow_private_network: 是否允许访问本机或私网地址
|
||||
:return: 验证码文本,失败时返回空字符串
|
||||
"""
|
||||
clean_url = (image_url or "").strip()
|
||||
if not clean_url:
|
||||
return ""
|
||||
if not clean_url.lower().startswith("data:image/"):
|
||||
BrowserSessionHelper.validate_url(
|
||||
clean_url,
|
||||
allow_private_network=allow_private_network,
|
||||
)
|
||||
return OcrHelper().get_captcha_text(
|
||||
image_url=clean_url,
|
||||
cookie=cookie,
|
||||
ua=user_agent,
|
||||
)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
image_url: str,
|
||||
cookie: Optional[str] = None,
|
||||
user_agent: Optional[str] = None,
|
||||
allow_private_network: bool = False,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""
|
||||
识别指定图片地址中的图形验证码文本。
|
||||
|
||||
:param image_url: 验证码图片地址
|
||||
:param cookie: 下载图片时使用的 Cookie
|
||||
:param user_agent: 下载图片时使用的 User-Agent
|
||||
:param allow_private_network: 是否允许访问本机或私网地址
|
||||
:return: JSON 格式的识别结果
|
||||
"""
|
||||
logger.info(f"执行工具: {self.name}, 参数: image_url={image_url}")
|
||||
|
||||
try:
|
||||
captcha_text = await self.run_blocking(
|
||||
"web",
|
||||
self._recognize_captcha_sync,
|
||||
image_url,
|
||||
cookie,
|
||||
user_agent,
|
||||
allow_private_network,
|
||||
)
|
||||
if captcha_text:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"captcha_text": captcha_text,
|
||||
"message": "验证码识别成功",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"captcha_text": "",
|
||||
"message": "验证码识别失败或未返回内容",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except ValueError as err:
|
||||
logger.warning(f"验证码图片地址校验失败: {str(err)}")
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"captcha_text": "",
|
||||
"message": str(err),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as err:
|
||||
logger.error(f"识别图形验证码失败: {str(err)}", exc_info=True)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"captcha_text": "",
|
||||
"message": f"识别图形验证码时发生错误: {str(err)}",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.chain.media import MediaChain
|
||||
from app.core.context import Context
|
||||
from app.core.metainfo import MetaInfo
|
||||
@@ -23,6 +24,11 @@ class RecognizeMediaInput(BaseModel):
|
||||
|
||||
class RecognizeMediaTool(MoviePilotTool):
|
||||
name: str = "recognize_media"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Media,
|
||||
ToolTag.Metadata,
|
||||
]
|
||||
description: str = "Extract/identify media information from torrent titles or file paths (NOT database search). Supports two modes: 1) Extract from torrent title and optional subtitle, 2) Extract from file path. Returns detailed media information. Use 'search_media' to search TMDB database, or 'scrape_metadata' to generate metadata files for existing files."
|
||||
args_schema: Type[BaseModel] = RecognizeMediaInput
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.agent.tools.impl._plugin_tool_utils import (
|
||||
get_plugin_snapshot,
|
||||
reload_plugin_runtime,
|
||||
@@ -26,6 +27,11 @@ class ReloadPluginInput(BaseModel):
|
||||
|
||||
class ReloadPluginTool(MoviePilotTool):
|
||||
name: str = "reload_plugin"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.Plugin,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
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."
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.log import logger
|
||||
|
||||
|
||||
@@ -21,6 +22,11 @@ class RunSchedulerInput(BaseModel):
|
||||
|
||||
class RunSchedulerTool(MoviePilotTool):
|
||||
name: str = "run_scheduler"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.Scheduler,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = "Manually trigger a scheduled task to run immediately. This will execute the specified scheduler job by its ID."
|
||||
args_schema: Type[BaseModel] = RunSchedulerInput
|
||||
require_admin: bool = True
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.core.event import eventmanager
|
||||
from app.log import logger
|
||||
from app.schemas.types import EventType, MessageChannel
|
||||
@@ -27,6 +28,11 @@ class RunSlashCommandInput(BaseModel):
|
||||
|
||||
class RunSlashCommandTool(MoviePilotTool):
|
||||
name: str = "run_slash_command"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.SlashCommand,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = (
|
||||
"Execute a slash command (system or plugin) by sending a CommandExcute event. "
|
||||
"This tool supports ALL registered slash commands, including: "
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.chain.workflow import WorkflowChain
|
||||
from app.db import AsyncSessionFactory
|
||||
from app.db.workflow_oper import WorkflowOper
|
||||
@@ -27,6 +28,11 @@ class RunWorkflowInput(BaseModel):
|
||||
|
||||
class RunWorkflowTool(MoviePilotTool):
|
||||
name: str = "run_workflow"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.Workflow,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = "Execute a specific workflow manually by workflow ID. Supports running from the beginning or continuing from the last executed action."
|
||||
args_schema: Type[BaseModel] = RunWorkflowInput
|
||||
require_admin: bool = True
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.chain.media import MediaChain
|
||||
from app.log import logger
|
||||
from app.schemas import FileItem
|
||||
@@ -33,6 +34,13 @@ class ScrapeMetadataInput(BaseModel):
|
||||
|
||||
class ScrapeMetadataTool(MoviePilotTool):
|
||||
name: str = "scrape_metadata"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.Media,
|
||||
ToolTag.Metadata,
|
||||
ToolTag.File,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = "Generate metadata files (NFO files, posters, backgrounds, etc.) for existing media files or directories. Automatically recognizes media information from the file path and creates metadata files. Supports both local and remote storage. Use 'search_media' to search TMDB database, or 'recognize_media' to extract info from torrent titles/file paths without generating files."
|
||||
require_admin: bool = True
|
||||
args_schema: Type[BaseModel] = ScrapeMetadataInput
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.chain.media import MediaChain
|
||||
from app.log import logger
|
||||
from app.schemas.types import MediaType, media_type_to_agent
|
||||
@@ -24,6 +25,10 @@ class SearchMediaInput(BaseModel):
|
||||
|
||||
class SearchMediaTool(MoviePilotTool):
|
||||
name: str = "search_media"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Media,
|
||||
]
|
||||
description: str = "Search TMDB database for media resources (movies, TV shows, anime, etc.) by title, year, type, and other criteria. Returns detailed media information from TMDB. Use 'recognize_media' to extract info from torrent titles/file paths, or 'scrape_metadata' to generate metadata files."
|
||||
args_schema: Type[BaseModel] = SearchMediaInput
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.chain.media import MediaChain
|
||||
from app.log import logger
|
||||
|
||||
@@ -18,6 +19,10 @@ class SearchPersonInput(BaseModel):
|
||||
|
||||
class SearchPersonTool(MoviePilotTool):
|
||||
name: str = "search_person"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Media,
|
||||
]
|
||||
description: str = "Search for person information including actors, directors, etc. Supports searching by name. Returns detailed person information from TMDB, Douban, or Bangumi database."
|
||||
args_schema: Type[BaseModel] = SearchPersonInput
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.chain.douban import DoubanChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.chain.bangumi import BangumiChain
|
||||
@@ -22,6 +23,10 @@ class SearchPersonCreditsInput(BaseModel):
|
||||
|
||||
class SearchPersonCreditsTool(MoviePilotTool):
|
||||
name: str = "search_person_credits"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Media,
|
||||
]
|
||||
description: str = "Search for films and TV shows that a person/actor has appeared in (filmography). Supports searching by person ID from TMDB, Douban, or Bangumi database. Returns a list of media works the person has participated in."
|
||||
args_schema: Type[BaseModel] = SearchPersonCreditsInput
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type, List
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.db.subscribe_oper import SubscribeOper
|
||||
from app.log import logger
|
||||
@@ -23,6 +24,12 @@ class SearchSubscribeInput(BaseModel):
|
||||
|
||||
class SearchSubscribeTool(MoviePilotTool):
|
||||
name: str = "search_subscribe"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Write,
|
||||
ToolTag.Subscription,
|
||||
ToolTag.Resource,
|
||||
]
|
||||
description: str = "Search for missing episodes/resources for a specific subscription. This tool will search torrent sites for the missing episodes of the subscription and automatically download matching resources. Use this when a user wants to search for missing episodes of a specific subscription."
|
||||
args_schema: Type[BaseModel] = SearchSubscribeInput
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import List, Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.chain.search import SearchChain
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.helper.sites import SitesHelper
|
||||
@@ -29,6 +30,12 @@ class SearchTorrentsInput(BaseModel):
|
||||
|
||||
class SearchTorrentsTool(MoviePilotTool):
|
||||
name: str = "search_torrents"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Resource,
|
||||
ToolTag.Site,
|
||||
ToolTag.Media,
|
||||
]
|
||||
description: str = ("Search for torrent files by media ID across configured indexer sites, cache the matched results, "
|
||||
"and return available filter options for follow-up selection. "
|
||||
"Requires tmdb_id or douban_id (can be obtained from search_media tool) for accurate matching.")
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
@@ -9,6 +8,7 @@ from ddgs import DDGS
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
|
||||
@@ -82,6 +82,10 @@ class SearchWebTool(MoviePilotTool):
|
||||
"""
|
||||
|
||||
name: str = "search_web"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Web,
|
||||
]
|
||||
description: str = (
|
||||
"Search the web for information when you need current information, facts, "
|
||||
"or references. Supports DDGS-backed search engine selection, automatic "
|
||||
@@ -440,8 +444,7 @@ class SearchWebTool(MoviePilotTool):
|
||||
logger.warning(f"搜索引擎搜索进程失败: {err}")
|
||||
return results
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
results = await loop.run_in_executor(None, sync_search)
|
||||
results = await self.run_blocking("web", sync_search)
|
||||
return self._filter_results_by_site(results, site_filter)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool, ToolChain
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.log import logger
|
||||
from app.schemas import Notification, NotificationType
|
||||
from app.schemas.message import ChannelCapabilityManager, ChannelCapability
|
||||
@@ -43,13 +44,18 @@ class SendLocalFileInput(BaseModel):
|
||||
|
||||
class SendLocalFileTool(MoviePilotTool):
|
||||
name: str = "send_local_file"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.Message,
|
||||
ToolTag.File,
|
||||
]
|
||||
sends_message: bool = True
|
||||
description: str = (
|
||||
"Send a local image or file from the server filesystem to the current user. "
|
||||
"Use this when you have generated or identified a local file the user should download."
|
||||
)
|
||||
args_schema: Type[BaseModel] = SendLocalFileInput
|
||||
require_admin: bool = False
|
||||
require_admin: bool = True
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
file_path = kwargs.get("file_path", "")
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.log import logger
|
||||
|
||||
|
||||
@@ -35,6 +36,11 @@ class SendMessageInput(BaseModel):
|
||||
|
||||
class SendMessageTool(MoviePilotTool):
|
||||
name: str = "send_message"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.Message,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
sends_message: bool = True
|
||||
description: str = "Send notification message to the user through configured notification channels (Telegram, Slack, WeChat, etc.). Supports optional image_url on channels that can send images. Used to inform users about operation results, errors, important updates, or proactively send a relevant image."
|
||||
args_schema: Type[BaseModel] = SendMessageInput
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"""发送语音消息工具。"""
|
||||
|
||||
import asyncio
|
||||
from typing import Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.llm.capability import AgentCapabilityManager
|
||||
from app.agent.tools.base import MoviePilotTool, ToolChain
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
from app.schemas import Notification, NotificationType
|
||||
@@ -29,15 +28,22 @@ class SendVoiceMessageTool(MoviePilotTool):
|
||||
"""发送 Agent 语音回复的工具。"""
|
||||
|
||||
name: str = "send_voice_message"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.Message,
|
||||
ToolTag.TerminalResponse,
|
||||
]
|
||||
sends_message: bool = True
|
||||
return_direct: bool = True
|
||||
description: str = (
|
||||
"Send a voice reply to the current user. Use this only when the user explicitly asks for "
|
||||
"a voice reply or when spoken playback is clearly better than plain text. On channels "
|
||||
"without voice support or when TTS is unavailable, it automatically falls back to sending "
|
||||
"the same content as plain text."
|
||||
"the same content as plain text. This is a terminal response tool: put the complete "
|
||||
"user-facing reply in `message`; after this tool runs, do not send another text reply "
|
||||
"or call `send_message` with the same content."
|
||||
)
|
||||
args_schema: Type[BaseModel] = SendVoiceMessageInput
|
||||
require_admin: bool = False
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""生成语音回复工具的执行提示。"""
|
||||
@@ -65,7 +71,8 @@ class SendVoiceMessageTool(MoviePilotTool):
|
||||
reply_mode == AgentCapabilityManager.REPLY_MODE_NATIVE
|
||||
and AgentCapabilityManager.is_audio_output_available()
|
||||
):
|
||||
voice_file = await asyncio.to_thread(
|
||||
voice_file = await self.run_blocking(
|
||||
"default",
|
||||
AgentCapabilityManager.synthesize_speech, message
|
||||
)
|
||||
if voice_file:
|
||||
|
||||
@@ -7,6 +7,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.runtime import agent_runtime_manager
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.log import logger
|
||||
|
||||
|
||||
@@ -26,6 +27,10 @@ class SwitchPersonaInput(BaseModel):
|
||||
|
||||
class SwitchPersonaTool(MoviePilotTool):
|
||||
name: str = "switch_persona"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.Persona,
|
||||
]
|
||||
description: str = (
|
||||
"Switch the active persona (人格) used by the agent runtime. "
|
||||
"This change is persistent for future turns. "
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.chain.site import SiteChain
|
||||
from app.db.site_oper import SiteOper
|
||||
from app.log import logger
|
||||
@@ -18,6 +19,10 @@ class TestSiteInput(BaseModel):
|
||||
|
||||
class TestSiteTool(MoviePilotTool):
|
||||
name: str = "test_site"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Site,
|
||||
]
|
||||
description: str = "Test site connectivity and availability. This will check if a site is accessible and can be logged in. Accepts site ID only."
|
||||
args_schema: Type[BaseModel] = TestSiteInput
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.log import logger
|
||||
from app.schemas import FileItem, MediaType
|
||||
|
||||
@@ -54,6 +55,13 @@ class TransferFileInput(BaseModel):
|
||||
|
||||
class TransferFileTool(MoviePilotTool):
|
||||
name: str = "transfer_file"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.Transfer,
|
||||
ToolTag.Library,
|
||||
ToolTag.File,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = "Transfer/organize a file or directory to the media library. Automatically recognizes media information and organizes files according to configured rules. Supports custom target paths, media identification, and transfer modes."
|
||||
args_schema: Type[BaseModel] = TransferFileInput
|
||||
require_admin: bool = True
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.agent.tools.impl._plugin_tool_utils import (
|
||||
list_installed_plugins,
|
||||
summarize_plugin,
|
||||
@@ -27,6 +28,11 @@ class UninstallPluginInput(BaseModel):
|
||||
|
||||
class UninstallPluginTool(MoviePilotTool):
|
||||
name: str = "uninstall_plugin"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.Plugin,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = (
|
||||
"Uninstall an installed plugin by exact plugin_id. "
|
||||
"Use query_installed_plugins first when you need filtering or discovery."
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.agent.tools.impl._filter_rule_utils import (
|
||||
collect_custom_rule_group_refs,
|
||||
get_custom_rules,
|
||||
@@ -58,6 +59,11 @@ class UpdateCustomFilterRuleInput(BaseModel):
|
||||
|
||||
class UpdateCustomFilterRuleTool(MoviePilotTool):
|
||||
name: str = "update_custom_filter_rule"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.FilterRule,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = (
|
||||
"Update an existing custom filter rule. "
|
||||
"If the rule ID is renamed, all rule groups that reference the old ID are updated automatically."
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import List, Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.log import logger
|
||||
from app.schemas.types import SystemConfigKey
|
||||
@@ -33,6 +34,11 @@ class UpdateCustomIdentifiersInput(BaseModel):
|
||||
|
||||
class UpdateCustomIdentifiersTool(MoviePilotTool):
|
||||
name: str = "update_custom_identifiers"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.FilterRule,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = (
|
||||
"Update the full list of custom identifiers (自定义识别词) used for preprocessing torrent/file names. "
|
||||
"This tool REPLACES all existing identifier rules with the provided list. "
|
||||
|
||||
310
app/agent/tools/impl/update_download_tasks.py
Normal file
310
app/agent/tools/impl/update_download_tasks.py
Normal file
@@ -0,0 +1,310 @@
|
||||
"""更新下载任务工具"""
|
||||
|
||||
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.tags import ToolTag
|
||||
from app.chain.download import DownloadChain
|
||||
from app.log import logger
|
||||
|
||||
|
||||
class UpdateDownloadTasksInput(BaseModel):
|
||||
"""更新下载任务工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(
|
||||
None,
|
||||
description="Clear explanation of why this tool is being used in the current context",
|
||||
)
|
||||
hash: str = Field(
|
||||
..., description="Task hash (can be obtained from query_download_tasks tool)"
|
||||
)
|
||||
action: Optional[str] = Field(
|
||||
None,
|
||||
description="Action to perform on the task: 'start' to resume downloading, 'stop' to pause downloading.",
|
||||
)
|
||||
tags: Optional[List[str]] = Field(
|
||||
None,
|
||||
description="List of tags to add to the download task. Example: ['movie', 'hd']",
|
||||
)
|
||||
downloader: Optional[str] = Field(
|
||||
None,
|
||||
description="Name of specific downloader. If omitted, the tool resolves it from the task hash.",
|
||||
)
|
||||
download_limit: Optional[float] = Field(
|
||||
None,
|
||||
description="Per-task download speed limit in KB/s. Use 0 to disable the limit when supported.",
|
||||
)
|
||||
upload_limit: Optional[float] = Field(
|
||||
None,
|
||||
description="Per-task upload speed limit in KB/s. Use 0 to disable the limit when supported.",
|
||||
)
|
||||
trackers: Optional[List[str]] = Field(
|
||||
None,
|
||||
description="Tracker URL list to add or set, depending on downloader support.",
|
||||
)
|
||||
save_path: Optional[str] = Field(
|
||||
None,
|
||||
description="New save/download directory for the task, when supported.",
|
||||
)
|
||||
category: Optional[str] = Field(
|
||||
None,
|
||||
description="Downloader category to set, when supported.",
|
||||
)
|
||||
ratio_limit: Optional[float] = Field(
|
||||
None,
|
||||
description="Per-task share ratio limit, when supported.",
|
||||
)
|
||||
seeding_time_limit: Optional[int] = Field(
|
||||
None,
|
||||
description="Per-task seeding time limit in minutes, when supported.",
|
||||
)
|
||||
|
||||
|
||||
class UpdateDownloadTasksTool(MoviePilotTool):
|
||||
"""更新下载任务工具"""
|
||||
|
||||
name: str = "update_download_tasks"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.Download,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = (
|
||||
"Update a download task by hash. Supports start/stop, adding tags, per-task "
|
||||
"upload/download speed limits, trackers, save directory, category, share ratio, "
|
||||
"and seeding time where the configured downloader supports them. "
|
||||
"Use query_download_tasks first to get the hash and current downloader."
|
||||
)
|
||||
args_schema: Type[BaseModel] = UpdateDownloadTasksInput
|
||||
require_admin: bool = True
|
||||
|
||||
@staticmethod
|
||||
def _is_valid_hash(hash_value: str) -> bool:
|
||||
"""校验下载任务Hash格式。"""
|
||||
return len(hash_value) == 40 and all(c in "0123456789abcdefABCDEF" for c in hash_value)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_non_empty_list(values: Optional[List[str]]) -> Optional[List[str]]:
|
||||
"""清理字符串列表中的空值。"""
|
||||
if values is None:
|
||||
return None
|
||||
return [str(value).strip() for value in values if str(value).strip()]
|
||||
|
||||
@staticmethod
|
||||
def _has_update_params(**kwargs) -> bool:
|
||||
"""判断是否传入至少一个修改参数。"""
|
||||
return any(value is not None and value != [] for value in kwargs.values())
|
||||
|
||||
@staticmethod
|
||||
def _build_result(operation: str, success: bool, message: str) -> Dict[str, Any]:
|
||||
"""构造单项操作结果。"""
|
||||
return {
|
||||
"operation": operation,
|
||||
"success": success,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _resolve_downloader(
|
||||
cls,
|
||||
download_chain: DownloadChain,
|
||||
hash_value: str,
|
||||
downloader: Optional[str],
|
||||
) -> Optional[str]:
|
||||
"""根据Hash解析下载任务所在下载器。"""
|
||||
if downloader:
|
||||
return downloader
|
||||
torrents = download_chain.list_torrents(
|
||||
hashs=[hash_value],
|
||||
include_all_tags=True,
|
||||
) or []
|
||||
return getattr(torrents[0], "downloader", None) if torrents else None
|
||||
|
||||
@classmethod
|
||||
def _update_download_sync(
|
||||
cls,
|
||||
hash_value: str,
|
||||
action: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
downloader: Optional[str] = None,
|
||||
download_limit: Optional[float] = None,
|
||||
upload_limit: Optional[float] = None,
|
||||
trackers: Optional[List[str]] = None,
|
||||
save_path: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
ratio_limit: Optional[float] = None,
|
||||
seeding_time_limit: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""同步更新下载任务,避免下载器 SDK 阻塞事件循环。"""
|
||||
download_chain = DownloadChain()
|
||||
resolved_downloader = cls._resolve_downloader(
|
||||
download_chain=download_chain,
|
||||
hash_value=hash_value,
|
||||
downloader=downloader,
|
||||
)
|
||||
if not resolved_downloader:
|
||||
return {
|
||||
"hash": hash_value,
|
||||
"downloader": downloader,
|
||||
"results": [
|
||||
cls._build_result("resolve_downloader", False, "未找到下载任务或下载器不可用")
|
||||
],
|
||||
}
|
||||
|
||||
results = []
|
||||
if tags:
|
||||
tag_result = download_chain.set_torrents_tag(
|
||||
hashs=[hash_value], tags=tags, downloader=resolved_downloader
|
||||
)
|
||||
results.append(
|
||||
cls._build_result(
|
||||
"tags",
|
||||
bool(tag_result),
|
||||
f"成功设置标签:{', '.join(tags)}" if tag_result else "设置标签失败",
|
||||
)
|
||||
)
|
||||
|
||||
if action:
|
||||
action_result = download_chain.set_downloading(
|
||||
hash_str=hash_value, oper=action, name=resolved_downloader
|
||||
)
|
||||
action_desc = "开始" if action == "start" else "暂停"
|
||||
results.append(
|
||||
cls._build_result(
|
||||
action,
|
||||
bool(action_result),
|
||||
f"成功{action_desc}下载任务" if action_result else f"{action_desc}下载任务失败",
|
||||
)
|
||||
)
|
||||
|
||||
update_result = {}
|
||||
if cls._has_update_params(
|
||||
download_limit=download_limit,
|
||||
upload_limit=upload_limit,
|
||||
trackers=trackers,
|
||||
save_path=save_path,
|
||||
category=category,
|
||||
ratio_limit=ratio_limit,
|
||||
seeding_time_limit=seeding_time_limit,
|
||||
):
|
||||
update_result = download_chain.update_torrent(
|
||||
hash_string=hash_value,
|
||||
downloader=resolved_downloader,
|
||||
download_limit=download_limit,
|
||||
upload_limit=upload_limit,
|
||||
tracker_list=trackers,
|
||||
save_path=save_path,
|
||||
category=category,
|
||||
ratio_limit=ratio_limit,
|
||||
seeding_time_limit=seeding_time_limit,
|
||||
)
|
||||
operation_messages = {
|
||||
"limits": "限速/做种策略",
|
||||
"trackers": "Tracker",
|
||||
"save_path": "保存目录",
|
||||
"category": "分类",
|
||||
}
|
||||
for operation, success in (update_result or {}).items():
|
||||
label = operation_messages.get(operation, operation)
|
||||
results.append(
|
||||
cls._build_result(
|
||||
operation,
|
||||
bool(success),
|
||||
f"{label}修改成功" if success else f"{label}修改失败或下载器不支持",
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"hash": hash_value,
|
||||
"downloader": resolved_downloader,
|
||||
"results": results,
|
||||
}
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""根据更新参数生成友好的提示消息。"""
|
||||
hash_value = kwargs.get("hash", "")
|
||||
parts = [f"更新下载任务: {hash_value}"]
|
||||
action = kwargs.get("action")
|
||||
if action == "start":
|
||||
parts.append("操作: 开始下载")
|
||||
elif action == "stop":
|
||||
parts.append("操作: 暂停下载")
|
||||
if kwargs.get("tags"):
|
||||
parts.append(f"标签: {', '.join(kwargs.get('tags'))}")
|
||||
if kwargs.get("download_limit") is not None or kwargs.get("upload_limit") is not None:
|
||||
parts.append("限速")
|
||||
if kwargs.get("trackers") is not None:
|
||||
parts.append("Tracker")
|
||||
if kwargs.get("save_path"):
|
||||
parts.append("保存目录")
|
||||
if kwargs.get("category") is not None:
|
||||
parts.append("分类")
|
||||
if kwargs.get("downloader"):
|
||||
parts.append(f"下载器: {kwargs.get('downloader')}")
|
||||
return " | ".join(parts)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
hash: str,
|
||||
action: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
downloader: Optional[str] = None,
|
||||
download_limit: Optional[float] = None,
|
||||
upload_limit: Optional[float] = None,
|
||||
trackers: Optional[List[str]] = None,
|
||||
save_path: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
ratio_limit: Optional[float] = None,
|
||||
seeding_time_limit: Optional[int] = None,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""执行下载任务更新。"""
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, 参数: hash={hash}, action={action}, tags={tags}, "
|
||||
f"downloader={downloader}, download_limit={download_limit}, upload_limit={upload_limit}, "
|
||||
f"trackers={trackers}, save_path={save_path}, category={category}, "
|
||||
f"ratio_limit={ratio_limit}, seeding_time_limit={seeding_time_limit}"
|
||||
)
|
||||
try:
|
||||
if not self._is_valid_hash(hash):
|
||||
return "参数错误:hash 格式无效,请先使用 query_download_tasks 工具获取正确的 hash。"
|
||||
|
||||
tags = self._normalize_non_empty_list(tags)
|
||||
trackers = self._normalize_non_empty_list(trackers)
|
||||
if action and action not in ("start", "stop"):
|
||||
return f"参数错误:action 只支持 'start'(开始下载)或 'stop'(暂停下载),收到: '{action}'。"
|
||||
if not self._has_update_params(
|
||||
action=action,
|
||||
tags=tags,
|
||||
download_limit=download_limit,
|
||||
upload_limit=upload_limit,
|
||||
trackers=trackers,
|
||||
save_path=save_path,
|
||||
category=category,
|
||||
ratio_limit=ratio_limit,
|
||||
seeding_time_limit=seeding_time_limit,
|
||||
):
|
||||
return "参数错误:至少需要指定一个要更新的字段。"
|
||||
|
||||
result = await self.run_blocking(
|
||||
"downloader",
|
||||
self._update_download_sync,
|
||||
hash,
|
||||
action,
|
||||
tags,
|
||||
downloader,
|
||||
download_limit,
|
||||
upload_limit,
|
||||
trackers,
|
||||
save_path,
|
||||
category,
|
||||
ratio_limit,
|
||||
seeding_time_limit,
|
||||
)
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
logger.error(f"更新下载任务失败: {e}", exc_info=True)
|
||||
return f"更新下载任务时发生错误: {str(e)}"
|
||||
@@ -7,6 +7,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.runtime import agent_runtime_manager
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.log import logger
|
||||
|
||||
|
||||
@@ -56,6 +57,11 @@ class UpdatePersonaDefinitionInput(BaseModel):
|
||||
|
||||
class UpdatePersonaDefinitionTool(MoviePilotTool):
|
||||
name: str = "update_persona_definition"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.Persona,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = (
|
||||
"Create or update a runtime persona definition (人格定义) without manually editing PERSONA.md files. "
|
||||
"Use this when the user explicitly asks to modify how a persona is defined, such as changing tone rules, "
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any, Dict, List, Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.agent.tools.impl._plugin_tool_utils import get_plugin_snapshot
|
||||
from app.core.plugin import PluginManager
|
||||
from app.log import logger
|
||||
@@ -42,6 +43,11 @@ class UpdatePluginConfigInput(BaseModel):
|
||||
|
||||
class UpdatePluginConfigTool(MoviePilotTool):
|
||||
name: str = "update_plugin_config"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.Plugin,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
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. "
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user