Compare commits

..

140 Commits
v2.13.3 ... v2

Author SHA1 Message Date
ch3njun
1c60d8ccd7 修复极影视媒体库同步不全:展开 BoxSet 合集子项 (#5954) 2026-06-16 06:35:58 +08:00
ch3njun
8ad8b5eaad fix(zspace): sync complete media library metadata (#5953) 2026-06-15 22:05:17 +08:00
nazoko
af23baec6d fix(wechat): fix voice upload file handle and Content-Type issues (#5951) 2026-06-15 19:48:11 +08:00
jxxghp
94b8252fdd fix transmission missing limit fields 2026-06-15 18:30:41 +08:00
InfinityPacer
785f11af0e fix: offload log zip generation (#5948) 2026-06-15 16:05:14 +08:00
jxxghp
726bc5f2aa chore: bump application and frontend version to v2.13.10 2026-06-15 15:55:46 +08:00
jxxghp
bae820a11d feat: unify download task tool names 2026-06-15 14:28:18 +08:00
jxxghp
47f6389424 test: update downloader path mapping expectations 2026-06-15 14:04:58 +08:00
jxxghp
6a635ac720 feat: enhance agent download task controls 2026-06-15 13:51:35 +08:00
jxxghp
d2803bed1e Improve feedback issue routing and labels 2026-06-15 12:47:52 +08:00
jxxghp
8dc1cf53eb feat: restrict non-admin users from accessing the local file sending tool 2026-06-15 09:46:06 +08:00
jxxghp
a93815b18a refactor: simplify directory list for agent configuration 2026-06-15 09:19:14 +08:00
jxxghp
ef36af8a82 feat: enhance user permissions handling for admin and non-admin contexts 2026-06-15 09:16:46 +08:00
jxxghp
c87b856ddf 支持 Slack 和 Discord 自动注册命令 2026-06-15 08:03:29 +08:00
jxxghp
0f42a0fb8c 支持全局 AI 下绕过传统搜索 2026-06-15 07:50:45 +08:00
jxxghp
2b031e7e05 fix: 兼容 transmission-rpc v7 文件列表接口 2026-06-14 23:53:26 +08:00
jxxghp
70831c27b3 fix: 支持标准代理环境变量 2026-06-14 21:50:38 +08:00
jxxghp
bef2a81296 fix downloader task status queries 2026-06-14 18:23:18 +08:00
jxxghp
d0dcf6660f chore: update application and frontend version to v2.13.9 2026-06-14 16:28:34 +08:00
jxxghp
4e3eddec10 feat: add captcha recognition agent tool 2026-06-14 16:24:04 +08:00
jxxghp
93713ba662 feat: sync plugin markets from wiki 2026-06-14 12:57:40 +08:00
jxxghp
0f3e9574ab Configure subagent profiles from runtime files 2026-06-14 10:27:26 +08:00
nazoko
25dbe491fe fix(jellyfin): 修复播放通知封面缺失问题 (#5938) 2026-06-14 06:24:42 +08:00
jxxghp
d6db0a86f6 fix llm test message 2026-06-13 23:20:15 +08:00
jxxghp
6e8bce3d04 fix: 优化LLM测试基础地址错误提示 2026-06-13 23:02:21 +08:00
jxxghp
ed1e31d379 fix: 兼容插件仪表盘空返回 2026-06-13 22:54:35 +08:00
jxxghp
3a233014de docs: update moviepilot plugin creation skill 2026-06-13 22:39:18 +08:00
InfinityPacer
13cb1683ff fix: restrict log access and add zip download (#5936) 2026-06-13 20:20:10 +08:00
jxxghp
ac9132cba6 fix bcrypt 2026-06-13 20:16:04 +08:00
jxxghp
6f5f1aa457 chore: downgrade bcrypt version to 4.3.0 in requirements.in 2026-06-13 19:19:01 +08:00
jxxghp
4547edc696 chore: bump application and frontend versions to v2.13.8 2026-06-13 18:43:29 +08:00
jxxghp
8d4412463c fix: change logging level to debug for plugin backup messages 2026-06-13 18:38:06 +08:00
jxxghp
0189463a09 feat: update allowed-tools and enhance plugin creation instructions in SKILL.md 2026-06-13 18:30:13 +08:00
jxxghp
a654686ce7 feat: add skill documentation for creating and managing MoviePilot plugins 2026-06-13 18:24:19 +08:00
jxxghp
eb9ea1c5c5 chore: update package versions in requirements.in 2026-06-13 17:36:45 +08:00
jxxghp
84b4a7eca2 fix: change logging level to debug for plugin restoration and add tests for warning filters 2026-06-13 17:15:35 +08:00
jxxghp
a37ed9aa97 feat: add backend health check for unmanaged MoviePilot processes 2026-06-13 17:12:29 +08:00
jxxghp
b89c351686 allow auto manual transfer scrape option 2026-06-13 10:42:34 +08:00
jxxghp
303e7ee16e feat: add downloader incomplete suffix toggles 2026-06-13 08:43:37 +08:00
jxxghp
ab9eeedb3e fix: 跳过推荐空缓存 2026-06-13 08:09:49 +08:00
Cecil98
7d582cc4d8 fix: 修复 TorrentLeech 最新种子列表从第 21 页开始请求的问题 (#5933) 2026-06-13 07:44:30 +08:00
InfinityPacer
e27a9ba486 fix(subscribe): avoid duplicate best-version completion (#5931) 2026-06-12 19:08:53 +08:00
InfinityPacer
51c2843dd0 fix(plugin): clean release fallback before retry (#5930) 2026-06-12 19:08:18 +08:00
jxxghp
8c73b87f6e fix agent config 2026-06-12 17:24:45 +08:00
jxxghp
a10361cc2f optimize agent browser sessions 2026-06-12 16:41:21 +08:00
jxxghp
dfabd695a8 add: query_doctor_report agent tool 2026-06-12 16:26:00 +08:00
jxxghp
735a1ebf27 新增 doctor 诊断自救功能 2026-06-12 15:55:24 +08:00
InfinityPacer
10dcb3727e fix(plugin): fall back when release package is unavailable (#5929) 2026-06-12 13:26:35 +08:00
DDSRem
616c355438 chore: bump moviepilot-rust to 0.1.9 (#5927) 2026-06-12 10:18:56 +08:00
jxxghp
24dc53b62d fix: handle NexusPHP occurrence pubdate parsing 2026-06-12 10:11:52 +08:00
jxxghp
1b83abe155 fix: implement tool execution timeout handling and improve blocking call management 2026-06-12 08:43:17 +08:00
jxxghp
765b286fd7 fix: improve cache locking mechanism and enhance key handling in file and redis backends 2026-06-12 08:21:26 +08:00
jxxghp
83cc7ea716 fix: enhance caching mechanism and improve type hints in DoH and workflow modules 2026-06-12 08:09:54 +08:00
jxxghp
d26225b998 fix(tmdb): stabilize tmdb connection reuse 2026-06-11 12:48:32 +08:00
jxxghp
c18e145b90 fix mtorrent subtitle error logging 2026-06-11 08:50:16 +08:00
jxxghp
b43c253983 fix: tests 2026-06-11 08:24:56 +08:00
jxxghp
e49e1626ee fix: add method to retrieve or create a folder in supported storage 2026-06-10 19:04:49 +08:00
jxxghp
13f55f4b1d fix: update media download directory resolution to return storage information 2026-06-10 18:51:52 +08:00
jxxghp
486c5294ba fix: enhance error handling and logging for subtitle download process 2026-06-10 18:40:32 +08:00
jxxghp
cba52c57e6 修复 RAR 字幕包下载识别 2026-06-10 08:46:10 +08:00
jxxghp
82694d2d8b fix: filter results in site search and improve parser handling 2026-06-10 08:11:18 +08:00
jxxghp
616309a08b test: fix rust accel unit expectations 2026-06-10 07:25:16 +08:00
jxxghp
829d7944b0 fix: create temp directory for subtitle API downloads 2026-06-10 07:07:33 +08:00
jxxghp
c4602070b1 fix: create missing subtitle download directories 2026-06-10 06:59:23 +08:00
jxxghp
ff83d1eae6 test: adjust subtitle spider expectations 2026-06-10 06:45:14 +08:00
DDSRem
ee96706e9f chore: bump moviepilot-rust to 0.1.8 (#5922) 2026-06-10 06:43:20 +08:00
jxxghp
7a19906e25 fix: remove unused default value retrieval and fallback methods 2026-06-10 06:35:28 +08:00
jxxghp
a0bc22dd25 fix: report subtitle spider errors 2026-06-10 01:37:41 +08:00
jxxghp
63a63d2ec6 fix: detect login pages before rust parsing 2026-06-10 01:31:39 +08:00
jxxghp
5d5e37792e fix: flag subtitle login pages 2026-06-10 01:28:16 +08:00
jxxghp
4241461ba7 fix: preserve absolute subtitle asset urls 2026-06-10 01:02:09 +08:00
jxxghp
fa06d5d861 fix: improve subtitle parsing and matching 2026-06-10 00:54:58 +08:00
jxxghp
0f468f67c1 feat: add stub implementation for site resources in test environment 2026-06-09 22:05:07 +08:00
jxxghp
dc2b6910a4 fix: restrict sensitive system endpoints 2026-06-09 21:45:51 +08:00
jxxghp
d1cf584af9 fix: handle invalid mtorrent seeding items 2026-06-09 20:48:38 +08:00
DDSRem
a2b82a2532 chore: bump moviepilot-rust to 0.1.7 (#5919) 2026-06-09 18:25:59 +08:00
jxxghp
f48d708172 test: update search cache params expectation 2026-06-09 18:16:38 +08:00
jxxghp
210aac0937 feat: add exact subtitle search 2026-06-09 17:04:17 +08:00
jxxghp
e3c5a94c52 feat: add subtitle search functionality and related data handling 2026-06-09 06:46:26 +08:00
jxxghp
738d92445a fix docker ffmpeg image tag 2026-06-08 15:44:31 +08:00
jxxghp
08ace4e804 chore: bump application and frontend versions to v2.13.6 2026-06-08 15:33:07 +08:00
jxxghp
b6759c5519 remove agent prompt tests 2026-06-08 14:46:37 +08:00
jxxghp
c7dc6e0d97 feat: add keyboard button support for proactive and passive message sending 2026-06-08 14:22:48 +08:00
jxxghp
84ff7476c0 fix(docker): use static ffmpeg with amr support (fixes #5912) 2026-06-08 14:11:34 +08:00
jxxghp
55cf380c9e feat: add support for Discord, Slack, and QQ channels in admin key mapping 2026-06-08 13:43:40 +08:00
jxxghp
bb8cfaa52f 更新 System Core Prompt.txt 2026-06-08 07:03:05 +08:00
jxxghp
bf98e4c954 Ensure batch AI redo returns plain text 2026-06-06 07:37:42 +08:00
jxxghp
a0b3800f6b fix: prevent cloud storage download path traversal 2026-06-05 17:43:06 +08:00
jxxghp
871d1ec0d8 更新 version.py 2026-06-05 16:16:44 +08:00
InfinityPacer
ca1dbdf843 ci: harden pull request unit test workflow (#5902) 2026-06-05 15:31:31 +08:00
InfinityPacer
e77bef7cf1 fix(subscribe): respect custom start episode for missing seasons (#5901) 2026-06-05 15:20:50 +08:00
ui_beam
f4011d3ac2 fix: 修复前端代理服务器设置清空保存后,httpx 持续报 `Unknown scheme for proxy URL (#5899) 2026-06-05 15:20:31 +08:00
jxxghp
d0b62523a0 chore(version): bump application and frontend versions to v2.13.5 2026-06-05 08:27:09 +08:00
Album
a9b1f7e9c9 fix(alist): support openlist rapid upload headers (#5897) 2026-06-05 06:50:20 +08:00
jxxghp
fc8933c648 feat(workflow): enhance workflow context serialization and execution state management 2026-06-05 00:41:02 +08:00
jxxghp
51981d151e feat(workflow): enhance execution state handling for non-JSON serializable values 2026-06-05 00:01:28 +08:00
jxxghp
97cfcda03c feat(workflow): implement action contract management for inputs and outputs 2026-06-04 21:06:25 +08:00
jxxghp
a2984530f8 feat(workflow): add execution configuration and structured execution state to workflow 2026-06-04 15:57:34 +08:00
jxxghp
7474ecd02f feat(workflow): enhance action execution with structured results and context management 2026-06-04 14:28:46 +08:00
jxxghp
9056caae40 feat(workflow): enhance workflow execution and context management 2026-06-04 14:10:06 +08:00
jxxghp
fd280a49b7 feat(auth): implement authentication provider endpoints and ticket exchange 2026-06-04 08:23:54 +08:00
DDSRem
df75f42753 fix: retry stale keep-alive requests (#5893) 2026-06-04 06:55:03 +08:00
DDSRem
0d2c324e28 fix(db): repair episode_priority column type mismatch on PostgreSQL (#5892) 2026-06-04 06:53:11 +08:00
DDSRem
dc0ee2b466 fix: patch urllib3.fields for urllib3-future compatibility (#5890) 2026-06-04 06:40:16 +08:00
InfinityPacer
781b1ce2aa test: 修复单测 warnings 并精确忽略上游弃用告警 (#5889) 2026-06-03 18:34:45 +08:00
InfinityPacer
791f1fe4ac test: 共享测试 harness 入 app/testing(网络守卫 + 引导)并统一 sys.modules 打桩原语 (#5888) 2026-06-03 18:34:20 +08:00
InfinityPacer
6405ff1191 test: split agenttokens plugin test out, un-skip agent event tests (#5885) 2026-06-03 10:50:55 +08:00
jxxghp
64cb5742d2 feat: add explicit handling for /ai messages to bypass media interactions 2026-06-03 06:52:25 +08:00
jxxghp
4601c41794 docs: clarify local resource and plugin setup 2026-06-03 00:15:17 +08:00
jxxghp
6167e7e6a2 更新 README_EN.md 2026-06-03 00:10:43 +08:00
jxxghp
a106738de5 更新 README.md 2026-06-03 00:09:23 +08:00
jxxghp
e0ce11a9d3 更新 README.md 2026-06-03 00:08:25 +08:00
jxxghp
3052f2cb31 docs: reorganize README guides 2026-06-03 00:05:09 +08:00
jxxghp
7905e622f9 fix: 修复 NexusPHP 做种翻页 userid 为空崩溃
Fixes #5874
2026-06-02 23:34:31 +08:00
jxxghp
3fa5d31d81 fix: normalize subscribe integer flags before persistence 2026-06-02 23:32:37 +08:00
jxxghp
9e5cb702c5 更新 version.py 2026-06-02 23:18:25 +08:00
jxxghp
ed380e2a17 更新 requirements.in 2026-06-02 23:17:58 +08:00
InfinityPacer
bc358fc6d2 test: 处理 #5877 review 反馈 + 提 PR 前跑全量门禁约定 (#5880) 2026-06-02 16:32:11 +08:00
InfinityPacer
223854d4c6 test: 新增单测 CI 门禁与规范文档,处理 #5868/#5873 review 反馈 (#5877) 2026-06-02 12:57:55 +08:00
InfinityPacer
7c73a57bbc fix(chain): use history_id key in manual transfer redo prompt context (#5876) 2026-06-02 12:50:42 +08:00
InfinityPacer
2b9f5d8d90 fix(agent): apply require_admin gate by reading instance field (#5875) 2026-06-02 12:50:07 +08:00
InfinityPacer
437baec620 test: 测试套件自隔离与全量离线化(collection 清零 + 杜绝真实网络) (#5873) 2026-06-02 12:23:08 +08:00
jxxghp
1c41d9f253 feat: add plugin history endpoint to fetch remote update details 2026-06-02 07:12:13 +08:00
jxxghp
db522e8829 fix: 兼容 Bangumi 人物生日字段类型 2026-06-02 06:23:57 +08:00
InfinityPacer
e43adf51af revert: absolute numbered season pack locating (#5869) 2026-06-01 21:09:23 +08:00
jxxghp
d353e7b208 fix: 订阅下载失败时尝试后续候选 2026-06-01 18:47:04 +08:00
InfinityPacer
df732731d9 test: move config+db isolation to conftest, unify on pytest (#5868) 2026-06-01 15:41:14 +08:00
jxxghp
ac5374c244 feat: enhance audio capability logging for transcription and synthesis 2026-06-01 12:55:28 +08:00
jxxghp
fcdba27a5d feat: add moviepilot-explorer subagent for source-code inspection and troubleshooting 2026-06-01 11:52:36 +08:00
jxxghp
e4242058e2 增加子代理操作日志 2026-06-01 11:31:35 +08:00
InfinityPacer
b7c78da214 fix(subscribe): handle absolute numbered season packs (#5866) 2026-06-01 11:18:51 +08:00
InfinityPacer
ba2feb2bfe test: isolate CONFIG_DIR to protect real database (#5865) 2026-06-01 06:42:50 +08:00
jxxghp
6f014cee14 更新 discord.py 2026-05-31 22:11:22 +08:00
jxxghp
6453935584 更新 telegram.py 2026-05-31 22:10:36 +08:00
jxxghp
40d0b60aa2 feat: add async subagent task control 2026-05-31 21:55:25 +08:00
jxxghp
1922cce499 优化 Agent 并行工具调用提示词 2026-05-31 21:38:40 +08:00
jxxghp
c89df496a5 feat(agent): add ToolTag-based tags to all agent tools; implement tags.py for unified tool capability tagging 2026-05-31 18:30:39 +08:00
jxxghp
855681ff35 feat(agent): mark and propagate voice input metadata in agent messages; clarify terminal tool usage in prompts
- Add `has_audio_input` flag to agent message handling and propagate through processing pipeline
- Structure agent input payloads to include `input.mode` and `input.transcribed` for voice messages
- Update prompts and tool descriptions to clarify that `send_voice_message` and `ask_user_choice` are terminal tools and should not be followed by redundant text replies
- Enhance tests to cover voice input metadata propagation and prompt updates
2026-05-31 18:04:02 +08:00
jxxghp
13b2163788 chore: add noqa for specific lines, update docstring, improve logging and variable naming 2026-05-31 17:49:05 +08:00
jxxghp
5d3c262e60 feat(agent): set return_direct for SendVoiceMessageTool to prevent streaming tool messages 2026-05-31 17:35:10 +08:00
367 changed files with 38724 additions and 4223 deletions

55
.github/workflows/test.yml vendored Normal file
View 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
View File

@@ -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/

View File

@@ -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.

View File

@@ -1,4 +1,3 @@
# MoviePilot
简体中文 | [English](README_EN.md)
@@ -12,66 +11,56 @@
![Docker Pulls V2](https://img.shields.io/docker/pulls/jxxghp/moviepilot-v2?style=for-the-badge)
![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20Synology-blue?style=for-the-badge)
基于 [NAStool](https://github.com/NAStool/nas-tools) 部分代码重新设计,聚焦自动化核心需求,减少问题同时更易于扩展和维护。
# 仅用于学习交流使用,请勿在任何国内平台宣传该项目!
发布频道https://t.me/moviepilot_channel
## 主要特性
- 前后端分离基于FastApi + Vue3
- 聚焦核心需求,简化功能和设置,部分设置项可直接使用默认值
- 重新设计了用户界面,更加美观易用
- 聚焦影视自动化的核心流程:订阅、搜索、下载、整理、刮削、媒体库刷新与消息通知
- 前后端分离,后端基于 FastAPI前端基于 Vue 3部署和扩展边界更清晰
- 支持下载器、媒体服务器、元数据源、消息渠道、插件、工作流和 AI Agent 等能力组合
- 更完整的功能介绍、截图和使用入口见官网https://movie-pilot.org
## 安装使用
官方Wikihttps://wiki.movie-pilot.org
推荐优先使用 Docker 部署,常用镜像包括 `jxxghp/moviepilot-v2``jxxghp/moviepilot`。Compose 示例、环境变量、目录映射和升级方式以官方 Wiki 为准:
- 官方 Wikihttps://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)
## 免责申明

View File

@@ -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

View File

@@ -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()

View File

@@ -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)

View File

@@ -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:

View File

@@ -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.

View 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.

View 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.

View 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.

View 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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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:

View File

@@ -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)

File diff suppressed because it is too large Load Diff

View File

@@ -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>

View File

@@ -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."

View File

@@ -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."

View File

@@ -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,
*,

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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."

View File

@@ -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}")

View File

@@ -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. "

View File

@@ -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. "

View File

@@ -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 ""

View File

@@ -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,
}
)

View File

@@ -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."

View File

@@ -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

View File

@@ -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}"
)

View File

@@ -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."

View File

@@ -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

View File

@@ -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

View File

@@ -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} 的权限"

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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."

View File

@@ -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
)

View File

@@ -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.) "

View File

@@ -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 "参数错误:至少需要指定 actionstart/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)}"

View File

@@ -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. "

View File

@@ -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. "

View File

@@ -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. "

View File

@@ -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

View 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,
)

View File

@@ -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 字段

View File

@@ -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:

View File

@@ -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

View File

@@ -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."

View File

@@ -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

View File

@@ -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

View File

@@ -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."

View File

@@ -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

View 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 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 "

View File

@@ -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. "

View File

@@ -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. "

View File

@@ -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. "

View File

@@ -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

View File

@@ -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. "

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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, "

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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,
)

View File

@@ -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

View File

@@ -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."

View File

@@ -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

View File

@@ -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: "

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.")

View File

@@ -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:

View File

@@ -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", "")

View File

@@ -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

View File

@@ -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:

View 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. "

View File

@@ -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

View File

@@ -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

View File

@@ -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."

View File

@@ -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."

View File

@@ -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. "

View 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)}"

View 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
@@ -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, "

View File

@@ -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