Compare commits

..

181 Commits
v2.13.1 ... v2

Author SHA1 Message Date
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
jxxghp
a5c44a5097 更新 version.py 2026-05-31 09:52:40 +08:00
jxxghp
16ada1a6c4 feat(site): add POST /site/cookie/{site_id} endpoint for updating site Cookie&UA via request body
- Introduce SiteCookieUpdate schema for structured cookie update requests
- Add POST endpoint to update site Cookie&UA using JSON body
- Refactor cookie update logic into shared _update_site_cookie function
- Update SKILL.md to document new endpoint and endpoint count
- Add tests for both POST and legacy GET cookie update endpoints
2026-05-31 09:11:09 +08:00
jxxghp
ac09ce5230 fix: handle empty ChatGPT responses output 2026-05-31 08:14:31 +08:00
jxxghp
2255b61195 fix: latest覆盖模式误删多Part文件 (fix #5862) 2026-05-30 20:28:26 +08:00
jxxghp
314ac3903c fix(agent): 识别 DeepSeek image_url 反序列化错误,修复图片不支持提示 (#5863) 2026-05-30 20:15:24 +08:00
jxxghp
5c3796bf73 fix: Bangumi别名解析和Redis事件循环切换问题
- 修复Bangumi API返回别名为字符串列表时的解析错误
- 修复Redis异步客户端在不同事件循环中使用时报Future attached to different loop错误
2026-05-30 13:11:18 +08:00
jxxghp
492e3c333b fix: unify User-Agent header usage with settings.USER_AGENT 2026-05-30 09:49:29 +08:00
jxxghp
cce72d0884 fix: proxy bangumi requests 2026-05-30 08:54:40 +08:00
InfinityPacer
69a064e986 feat(subscribe): 新增订阅总集数刷新与完成检查链式事件 (#5860) 2026-05-30 08:26:47 +08:00
InfinityPacer
f4ca4120bc fix(image-proxy): 阻断日志输出诊断原因并合并重复告警 (#5858) 2026-05-29 14:14:16 +08:00
InfinityPacer
b45956f850 fix(subscribe): require complete coverage for full best versions (#5857) 2026-05-29 14:10:29 +08:00
jxxghp
762a7fbba7 fix: simplify search web providers 2026-05-28 20:11:31 +08:00
jxxghp
10290ca17b 优化 agent 网络搜索工具 2026-05-28 17:22:52 +08:00
jxxghp
12a2561ca8 fix: return Emby item server ids 2026-05-28 15:03:56 +08:00
jxxghp
543bee9ad5 fix: correct Emby dashboard links 2026-05-28 14:33:50 +08:00
jxxghp
cc3e062262 固定 Agent 工具消息类型 2026-05-28 13:34:53 +08:00
jxxghp
bf4f5f8744 优化 Agent 核心提示词结构 2026-05-28 11:22:56 +08:00
jxxghp
f8f06a602a 加强 Agent 核心提示词安全约束 2026-05-28 11:10:08 +08:00
jxxghp
3cb8925e92 更新 version.py 2026-05-27 17:59:21 +08:00
jxxghp
3ffdf1b38e docs: add browser-use skill 2026-05-27 17:01:54 +08:00
jxxghp
6557b8b9d8 更新 scheduler.py 2026-05-27 15:56:08 +08:00
jxxghp
2b2e088784 fix: improve u115 multipart upload handling 2026-05-27 15:02:03 +08:00
jxxghp
d9a06f4433 refactor: load share admins from server 2026-05-27 14:31:01 +08:00
jxxghp
b1259fdc02 feat: add manual transfer target path matching 2026-05-27 13:26:01 +08:00
jxxghp
0e5c592862 refactor: centralize MoviePilot server helper 2026-05-27 12:56:45 +08:00
jxxghp
db3ad91408 fix: improve manual transfer recognition preview 2026-05-27 11:01:27 +08:00
jxxghp
5b6b4c9744 将发送消息工具设为必选工具 2026-05-27 08:48:56 +08:00
jxxghp
990a28b51b 调整 Agent 心跳任务报告方式 2026-05-27 08:41:40 +08:00
jxxghp
b6ffd286fe fix: keep platform words in media titles 2026-05-27 07:41:44 +08:00
jxxghp
1f7fb304dd feat: add LLM proxy toggle 2026-05-27 06:57:09 +08:00
jxxghp
896631d63e fix(agent): enable voice replies for supported channels 2026-05-26 20:14:56 +08:00
jxxghp
db8363fee1 fix(openlist): request full directory pages 2026-05-26 18:12:10 +08:00
jxxghp
31554bdcb5 fix agent tool call history ordering 2026-05-26 16:42:52 +08:00
jxxghp
ccbcce0573 chore: update 3 workspace files 2026-05-26 12:14:36 +08:00
jxxghp
e00e18f31e fix: transcode unsupported stt audio formats 2026-05-26 10:00:32 +08:00
jxxghp
c7965edd47 fix: use responses api for chatgpt reasoning models 2026-05-26 09:31:00 +08:00
jxxghp
8aeba8a6d2 更新 version.py 2026-05-26 09:07:04 +08:00
jxxghp
aee8b05737 fix: order LLM user agent config after max context 2026-05-26 08:30:43 +08:00
jxxghp
821bd3decd fix: use explicit LLM user agent config 2026-05-26 08:24:58 +08:00
jxxghp
b65c8dcfe0 feat: support llm user agent 2026-05-26 08:20:02 +08:00
DDSRem
877d89abb3 chore: bump moviepilot-rust to 0.1.4 (#5840) 2026-05-26 07:46:58 +08:00
jxxghp
d4718bf9dc fix: expose rust acceleration availability 2026-05-25 23:49:04 +08:00
DDSRem
8bd1288e7e chore: bump moviepilot-rust to 0.1.3 (#5838) 2026-05-25 23:18:17 +08:00
DDSRem
a65c5364d9 chore: bump moviepilot-rust to 0.1.2 (#5837) 2026-05-25 22:26:05 +08:00
jxxghp
f761e07779 更新 requirements.in 2026-05-25 22:21:44 +08:00
jxxghp
91f6ad092e fix(docker): reduce env logs and harden ssl cert startup 2026-05-25 22:14:27 +08:00
jxxghp
c33c62b938 refactor: use external moviepilot rust package 2026-05-25 20:52:53 +08:00
409 changed files with 45214 additions and 13817 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,68 +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)
本地开发启用 Rust 加速扩展,需先安装 Rust toolchain 并确保 `cargo` 可用;未安装时项目会自动使用 Python 实现:
```shell
cargo --version
python -m pip install "maturin>=1.9,<2"
python -m maturin develop --release --manifest-path rust/moviepilot_rust/Cargo.toml
python -c "from app.utils import rust_accel; print(rust_accel.is_available())"
```
如果输出 `True`,说明当前开发环境已经加载 `moviepilot_rust`。重新修改 Rust 代码后再次执行 `python -m maturin develop --release --manifest-path rust/moviepilot_rust/Cargo.toml` 即可更新本地扩展。
需要本地评估 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
## 相关项目
@@ -81,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:
"""
判断是否应启用流式输出:
@@ -469,6 +517,8 @@ class MoviePilotAgent:
api_key=settings.LLM_API_KEY,
base_url=settings.LLM_BASE_URL,
base_url_preset=settings.LLM_BASE_URL_PRESET,
user_agent=settings.LLM_USER_AGENT,
use_proxy=settings.LLM_USE_PROXY,
thinking_level=None,
)
selected_event = await eventmanager.async_send_event(
@@ -497,6 +547,13 @@ class MoviePilotAgent:
self._clean_optional_text(self._get_event_value(resolved_data, "base_url_preset"))
or settings.LLM_BASE_URL_PRESET
)
user_agent = (
self._clean_optional_text(self._get_event_value(resolved_data, "user_agent"))
or settings.LLM_USER_AGENT
)
use_proxy = self._get_event_value(resolved_data, "use_proxy")
if use_proxy is None:
use_proxy = settings.LLM_USE_PROXY
thinking_level = self._clean_optional_text(
self._get_event_value(resolved_data, "thinking_level")
)
@@ -522,6 +579,8 @@ class MoviePilotAgent:
"api_key": api_key,
"base_url": base_url,
"base_url_preset": base_url_preset,
"user_agent": user_agent,
"use_proxy": bool(use_proxy),
"thinking_level": thinking_level,
}
return self._llm_runtime_config
@@ -621,6 +680,8 @@ class MoviePilotAgent:
detail = cls._exception_detail_text(error).lower()
if "no endpoints found that support image input" in detail:
return True
if "unknown variant" in detail and "image_url" in detail:
return True
if "image input" not in detail and "images" not in detail:
return False
return any(
@@ -761,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
@@ -783,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 = [
@@ -809,6 +902,8 @@ class MoviePilotAgent:
),
# 错误工具调用修复
PatchToolCallsMiddleware(),
# 子代理委派
*subagent_middlewares,
# 用量统计
UsageMiddleware(on_usage=self._record_usage),
]
@@ -826,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,
)
@@ -848,6 +943,7 @@ class MoviePilotAgent:
message: str,
images: List[str] = None,
files: Optional[List[dict]] = None,
has_audio_input: bool = False,
) -> str:
"""
处理用户消息,流式推理并返回 Agent 回复
@@ -855,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 = ""
# 获取历史消息
@@ -872,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 [])
@@ -923,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
@@ -964,7 +1065,8 @@ class MoviePilotAgent:
agent_config = {
"configurable": {
"thread_id": self.session_id,
}
},
"recursion_limit": self._get_recursion_limit(),
}
# 判断是否启用流式输出
@@ -1174,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
@@ -1181,6 +1284,8 @@ class _MessageTask:
original_chat_id: Optional[str] = None
processing_status: Optional[dict] = None
reply_mode: ReplyMode = ReplyMode.DISPATCH
persist_output_message: bool = True
allow_message_tools: bool = True
class AgentManager:
@@ -1318,12 +1423,15 @@ 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,
original_message_id: Optional[str] = None,
original_chat_id: Optional[str] = None,
reply_mode: ReplyMode = ReplyMode.DISPATCH,
persist_output_message: bool = True,
allow_message_tools: bool = True,
) -> str:
"""
处理用户消息:将消息放入会话队列,按顺序依次处理。
@@ -1335,12 +1443,15 @@ class AgentManager:
message=message,
images=images,
files=files,
has_audio_input=has_audio_input,
channel=channel,
source=source,
username=username,
original_message_id=original_message_id,
original_chat_id=original_chat_id,
reply_mode=reply_mode,
persist_output_message=persist_output_message,
allow_message_tools=allow_message_tools,
)
self._record_session_activity(session_id, user_id)
@@ -1450,6 +1561,8 @@ class AgentManager:
original_message_id=task.original_message_id,
original_chat_id=task.original_chat_id,
replay_mode=task.reply_mode,
persist_output_message=task.persist_output_message,
allow_message_tools=task.allow_message_tools,
)
self.active_agents[session_id] = agent
else:
@@ -1464,8 +1577,16 @@ class AgentManager:
agent.original_message_id = task.original_message_id
agent.original_chat_id = task.original_chat_id
agent.reply_mode = task.reply_mode
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):
"""
@@ -1516,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)
@@ -1601,7 +1722,9 @@ class AgentManager:
channel=None,
source=None,
username=settings.SUPERUSER,
reply_mode=ReplyMode.DISPATCH,
reply_mode=ReplyMode.CAPTURE_ONLY,
persist_output_message=False,
allow_message_tools=True,
)
# 等待消息队列处理完成

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

@@ -146,6 +146,8 @@ class OpenAIChatAudioProvider(AudioCapabilityProvider):
".opus": "audio/ogg",
".wav": "audio/wav",
}
TRANSCODED_STT_SUFFIX = ".wav"
TRANSCODED_STT_SAMPLE_RATE = "16000"
def _build_client(self, api_key: str, base_url: Optional[str]):
from openai import OpenAI
@@ -229,6 +231,76 @@ class OpenAIChatAudioProvider(AudioCapabilityProvider):
"format": self._guess_audio_format(filename),
}
def _normalize_audio_for_transcription(
self, content: bytes, filename: str
) -> Optional[tuple[bytes, str]]:
"""
将转写输入归一化为 Chat Audio provider 明确支持的格式。
:param content: 原始音频字节
:param filename: 原始音频文件名
:return: 成功时返回可提交的音频字节和文件名,失败时返回 None
"""
suffix = Path(filename or "").suffix.lower()
if suffix in self.SUPPORTED_AUDIO_MIME_TYPES:
return content, filename
return self._convert_audio_for_transcription(content=content, filename=filename)
def _convert_audio_for_transcription(
self, content: bytes, filename: str
) -> Optional[tuple[bytes, str]]:
"""
将 AMR 等第三方 STT 不支持的输入转为 WAV。
:param content: 原始音频字节
:param filename: 原始音频文件名
:return: 成功时返回 WAV 字节和文件名,失败时返回 None
"""
if not shutil.which("ffmpeg"):
logger.warning(
"%s STT 不支持当前音频格式且 ffmpeg 不可用,无法转码: filename=%s",
self.DISPLAY_NAME,
filename,
)
return None
suffix = Path(filename or "").suffix.lower() or ".audio"
voice_dir = settings.TEMP_PATH / "voice"
voice_dir.mkdir(parents=True, exist_ok=True)
input_path = voice_dir / f"{uuid4().hex}{suffix}"
output_path = input_path.with_suffix(self.TRANSCODED_STT_SUFFIX)
try:
input_path.write_bytes(content)
cmd = [
"ffmpeg",
"-y",
"-i",
str(input_path),
"-ar",
self.TRANSCODED_STT_SAMPLE_RATE,
"-ac",
"1",
"-f",
"wav",
str(output_path),
]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode != 0 or not output_path.exists():
logger.warning(
"%s STT 音频转 WAV 失败: returncode=%s, stderr=%s",
self.DISPLAY_NAME,
result.returncode,
(result.stderr or "").strip()[:500],
)
return None
return output_path.read_bytes(), f"{input_path.stem}{self.TRANSCODED_STT_SUFFIX}"
finally:
for temp_path in (input_path, output_path):
try:
temp_path.unlink(missing_ok=True)
except OSError as err:
logger.debug(f"清理 STT 临时音频失败: path={temp_path}, error={err}")
@staticmethod
def _extract_message_text(message) -> Optional[str]:
"""兼容音频理解响应可能放在 content 或 reasoning_content 的情况。"""
@@ -310,6 +382,12 @@ class OpenAIChatAudioProvider(AudioCapabilityProvider):
if not api_key:
raise ValueError("音频输入 provider 未配置 API Key")
client = self._build_client(api_key=api_key, base_url=base_url)
normalized_audio = self._normalize_audio_for_transcription(
content=content, filename=filename
)
if not normalized_audio:
return None
content, filename = normalized_audio
language = (settings.AUDIO_INPUT_LANGUAGE or "").strip()
prompt = "请将这段音频完整转写为文字,只输出转写结果,不要添加解释。"
if language:
@@ -592,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(
@@ -636,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:
@@ -656,29 +767,61 @@ class AgentCapabilityManager:
return cls.REPLY_MODE_TEXT
@classmethod
def supports_native_voice_reply(
cls, channel: Optional[str], source: Optional[str]
) -> bool:
"""判断当前渠道是否支持原生语音消息发送。"""
def _parse_message_channel(cls, channel: Optional[Any]):
"""将渠道入参归一化为消息渠道枚举。"""
if not channel:
return None
from app.schemas.types import MessageChannel
if isinstance(channel, MessageChannel):
return channel
channel_text = str(channel).strip()
if not channel_text:
return None
lowered_channel = channel_text.lower()
for channel_item in MessageChannel:
aliases = {
channel_item.value.lower(),
channel_item.name.lower(),
f"{MessageChannel.__name__}.{channel_item.name}".lower(),
}
if lowered_channel in aliases:
return channel_item
return None
@staticmethod
def _is_wechat_app_mode(source: Optional[str]) -> bool:
"""判断企业微信来源是否为自建应用模式。"""
if not source:
return False
from app.helper.service import ServiceConfigHelper
from app.schemas.types import MessageChannel
try:
channel_enum = MessageChannel(channel)
except (TypeError, ValueError):
return False
if channel_enum == MessageChannel.Telegram:
return True
if channel_enum != MessageChannel.Wechat:
return False
# 企业微信 bot 模式不支持发送语音,只有应用模式可用。
for config in ServiceConfigHelper.get_notification_configs():
if config.name != source:
continue
return (config.config or {}).get("WECHAT_MODE", "app") != "bot"
return False
@classmethod
def supports_native_voice_reply(
cls, channel: Optional[str], source: Optional[str]
) -> bool:
"""判断当前渠道是否支持原生语音消息发送。"""
from app.schemas.message import ChannelCapability, ChannelCapabilityManager
from app.schemas.types import MessageChannel
channel_enum = cls._parse_message_channel(channel)
if not channel_enum:
return False
if not ChannelCapabilityManager.supports_capability(
channel_enum, ChannelCapability.AUDIO_OUTPUT
):
return False
if channel_enum == MessageChannel.Wechat:
return cls._is_wechat_app_mode(source)
return True

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)
@@ -137,6 +137,57 @@ def _get_httpx_proxy_key() -> str:
return "proxies"
def _resolve_llm_proxy(use_proxy: bool | None = None) -> str | None:
"""
解析本次 LLM 调用应使用的系统代理地址。
"""
should_use_proxy = settings.LLM_USE_PROXY if use_proxy is None else use_proxy
return settings.PROXY_HOST if should_use_proxy and settings.PROXY_HOST else None
def _build_httpx_proxy_kwargs(proxy_url: str | None) -> dict[str, str]:
"""
构造兼容当前 httpx 版本的代理参数。
"""
if not proxy_url:
return {}
return {_get_httpx_proxy_key(): proxy_url}
def _build_google_client_args(proxy_url: str | None) -> dict[str, Any]:
"""
构造 Google SDK 透传给 httpx 的客户端参数。
"""
return {
"trust_env": False,
**_build_httpx_proxy_kwargs(proxy_url),
}
def _build_httpx_client(
proxy_url: str | None,
*,
async_client: bool = False,
timeout: float | None = None,
):
"""
构造显式代理策略的 httpx 客户端。
当关闭 LLM 代理时也返回 trust_env=False 的客户端,避免 httpx 自动读取
进程环境变量中的代理配置。
"""
import httpx
client_cls = httpx.AsyncClient if async_client else httpx.Client
kwargs: dict[str, Any] = {
"trust_env": False,
**_build_httpx_proxy_kwargs(proxy_url),
}
if timeout is not None:
kwargs["timeout"] = timeout
return client_cls(**kwargs)
def _deepseek_thinking_toggle(extra_body: Any) -> bool | None:
"""
解析 DeepSeek extra_body 中显式传入的 thinking 开关。
@@ -354,6 +405,7 @@ def _patch_openai_responses_instructions_support():
return
_patch_openai_interleaved_reasoning_content_support()
_patch_openai_responses_empty_output_support()
if getattr(ChatOpenAI, "_moviepilot_responses_instructions_patched", False):
return
@@ -413,6 +465,64 @@ def _patch_openai_responses_instructions_support():
logger.debug("已修补 langchain-openai responses API 的 instructions 兼容性")
def _patch_openai_responses_empty_output_support():
"""
修补 langchain-openai Responses API 流式完成事件 output 为空的兼容性。
ChatGPT Codex 后端有时会在 `response.completed` chunk 里返回
`response.output = None`,但前面的 delta chunk 已经包含实际文本。
langchain-openai 在收尾阶段遍历 output 会抛出 TypeError这里将缺失
output 规整为空列表,让收尾 chunk 只承载 usage/metadata。
"""
try:
import langchain_openai.chat_models.base as _openai_base
except Exception as err:
logger.debug(f"跳过 langchain-openai responses output 修补:{err}")
return
if getattr(_openai_base, "_moviepilot_responses_empty_output_patched", False):
return
original_construct = getattr(
_openai_base, "_construct_lc_result_from_responses_api", None
)
if not callable(original_construct):
logger.warning("langchain-openai 缺少 Responses API 结果构造函数,无法修补 output")
return
def _clone_response_with_empty_output(response):
"""
复制 Responses 对象,把缺失 output 规整为空列表。
"""
model_copy = getattr(response, "model_copy", None)
if callable(model_copy):
try:
return model_copy(update={"output": []})
except Exception as e:
logger.debug(f"复制 Responses 对象失败,回退原地修补 output{e}")
try:
setattr(response, "output", [])
except Exception as e:
logger.debug(f"原地修补 Responses output 失败:{e}")
return response
@wraps(original_construct)
def _patched_construct_lc_result_from_responses_api(response, *args, **kwargs):
"""
在 Responses API 收尾 chunk 缺少 output 时跳过空内容遍历。
"""
if hasattr(response, "output") and getattr(response, "output", None) is None:
response = _clone_response_with_empty_output(response)
return original_construct(response, *args, **kwargs)
_openai_base._construct_lc_result_from_responses_api = (
_patched_construct_lc_result_from_responses_api
)
_openai_base._moviepilot_responses_empty_output_patched = True
logger.debug("已修补 langchain-openai responses API 空 output 兼容性")
class LLMHelper:
"""LLM模型相关辅助功能"""
@@ -602,6 +712,7 @@ class LLMHelper:
model_name: str | None,
api_key: str | None = None,
base_url: str | None = None,
user_agent: str | None = None,
) -> dict[str, Any]:
"""
在 provider 目录不可用时回退到旧的直接构造逻辑。
@@ -625,12 +736,68 @@ class LLMHelper:
"model_id": model_name,
"api_key": api_key_value,
"base_url": base_url_value,
"default_headers": None,
"default_headers": LLMHelper._build_openai_default_headers(
None,
user_agent=user_agent,
),
"use_responses_api": None,
"model_record": None,
"model_metadata": None,
}
@staticmethod
def _build_openai_default_headers(
default_headers: dict[str, str] | None = None,
user_agent: str | None = None,
) -> dict[str, str] | None:
"""
合并 OpenAI 兼容接口默认请求头。
:param default_headers: provider 运行时已解析的默认请求头
:param user_agent: 用户配置的 User-Agent非空时写入标准请求头
:return: 可传给 OpenAI SDK 的请求头字典
"""
headers = dict(default_headers or {})
normalized_user_agent = str(user_agent or "").strip()
if normalized_user_agent:
for key in list(headers.keys()):
if key.lower() == "user-agent":
headers.pop(key)
headers["User-Agent"] = normalized_user_agent
return headers or None
@classmethod
def _should_use_openai_responses_api(
cls,
provider: str,
model: str | None,
runtime: dict[str, Any],
) -> bool | None:
"""
判断官方 ChatGPT API Key 模式是否应使用 Responses API。
GPT-5/o 系推理模型在 Chat Completions 中组合 function tools 与
reasoning_effort 时会被官方端点拒绝,因此 ChatGPT 官方 API Key
模式需要显式切到 Responses API通用 OpenAI-compatible 入口保持
provider 目录解析出的默认行为,避免误伤第三方兼容服务。
"""
runtime_use_responses_api = runtime.get("use_responses_api")
if runtime_use_responses_api is not None:
return bool(runtime_use_responses_api)
provider_name = (provider or "").strip().lower()
if provider_name != "chatgpt":
return None
base_url = str(runtime.get("base_url") or "").strip().lower()
if "api.openai.com" not in base_url:
return None
model_name = cls._normalize_model_name(model)
if model_name.startswith(("gpt-5", "o1", "o3", "o4")):
return True
return None
@classmethod
def _resolve_thinking_level(
cls,
@@ -675,6 +842,8 @@ class LLMHelper:
api_key: str | None = None,
base_url: str | None = None,
base_url_preset: str | None = None,
user_agent: str | None = None,
use_proxy: bool | None = None,
):
"""
获取LLM实例
@@ -688,6 +857,8 @@ class LLMHelper:
:param api_key: API Key。未显式传入时使用当前配置项 LLM_API_KEY。对于某些提供商如 DeepSeek可能需要同时提供 base_url。
:param base_url: API Base URL。未显式传入时使用当前配置项 LLM_BASE_URL。
:param base_url_preset: Base URL 预设。未显式传入时使用当前配置项 LLM_BASE_URL_PRESET。
:param user_agent: OpenAI兼容接口请求 User-Agent。未显式传入时使用配置项 LLM_USER_AGENT。
:param use_proxy: 是否为本次 LLM 调用使用系统代理。未显式传入时使用配置项 LLM_USE_PROXY。
:return: LLM实例
"""
provider_name = str(provider if provider is not None else settings.LLM_PROVIDER).lower()
@@ -697,6 +868,7 @@ class LLMHelper:
base_url_preset_value = (
base_url_preset if base_url_preset is not None else settings.LLM_BASE_URL_PRESET
)
user_agent_value = user_agent if user_agent is not None else settings.LLM_USER_AGENT
normalized_thinking_level = cls._resolve_thinking_level(
thinking_level=thinking_level,
)
@@ -711,6 +883,8 @@ class LLMHelper:
api_key=api_key_value,
base_url=base_url_value,
base_url_preset_id=base_url_preset_value,
user_agent=user_agent_value,
use_proxy=use_proxy,
)
except Exception as err:
logger.debug(f"LLM provider 目录不可用,回退到旧运行时逻辑: {err}")
@@ -719,13 +893,24 @@ class LLMHelper:
model_name=model_name,
api_key=api_key_value,
base_url=base_url_value,
user_agent=user_agent_value,
)
model_name = runtime.get("model_id") or model_name
default_headers = cls._build_openai_default_headers(
runtime.get("default_headers"),
user_agent=user_agent_value,
)
thinking_kwargs = cls._build_thinking_kwargs(
provider=provider_name,
model=model_name,
thinking_level=normalized_thinking_level,
)
use_responses_api = cls._should_use_openai_responses_api(
provider=provider_name,
model=model_name,
runtime=runtime,
)
llm_proxy = _resolve_llm_proxy(use_proxy)
if runtime["runtime"] == "google":
# 修补 Gemini 2.5 思考模型的 thought_signature 兼容性
@@ -736,18 +921,13 @@ class LLMHelper:
# 会导致工具调用时报错 400
from langchain_google_genai import ChatGoogleGenerativeAI
client_args = None
if settings.PROXY_HOST:
proxy_key = _get_httpx_proxy_key()
client_args = {proxy_key: settings.PROXY_HOST}
model = ChatGoogleGenerativeAI(
model=model_name,
api_key=runtime["api_key"],
retries=3,
temperature=settings.LLM_TEMPERATURE,
streaming=streaming,
client_args=client_args,
client_args=_build_google_client_args(llm_proxy),
**thinking_kwargs,
)
elif runtime["runtime"] == "deepseek":
@@ -762,6 +942,8 @@ class LLMHelper:
temperature=settings.LLM_TEMPERATURE,
streaming=streaming,
stream_usage=True,
http_client=_build_httpx_client(llm_proxy),
http_async_client=_build_httpx_client(llm_proxy, async_client=True),
**thinking_kwargs,
)
elif runtime["runtime"] in {"anthropic_compatible", "copilot_anthropic"}:
@@ -775,8 +957,8 @@ class LLMHelper:
temperature=settings.LLM_TEMPERATURE,
streaming=streaming,
stream_usage=True,
anthropic_proxy=settings.PROXY_HOST,
default_headers=runtime.get("default_headers"),
anthropic_proxy=llm_proxy,
default_headers=default_headers,
**thinking_kwargs,
)
else:
@@ -796,9 +978,17 @@ class LLMHelper:
temperature=settings.LLM_TEMPERATURE,
streaming=streaming,
stream_usage=True,
openai_proxy=settings.PROXY_HOST,
default_headers=runtime.get("default_headers"),
use_responses_api=runtime.get("use_responses_api"),
openai_proxy=llm_proxy,
**(
{}
if llm_proxy
else {
"http_client": _build_httpx_client(llm_proxy),
"http_async_client": _build_httpx_client(llm_proxy, async_client=True),
}
),
default_headers=default_headers,
use_responses_api=use_responses_api,
**thinking_kwargs,
)
@@ -873,6 +1063,8 @@ class LLMHelper:
api_key: str | None = None,
base_url: str | None = None,
base_url_preset: str | None = None,
user_agent: str | None = None,
use_proxy: bool | None = None,
) -> dict:
"""
使用当前已保存配置执行一次最小 LLM 调用。
@@ -888,6 +1080,8 @@ class LLMHelper:
api_key=api_key,
base_url=base_url,
base_url_preset=base_url_preset,
user_agent=user_agent,
use_proxy=use_proxy,
)
try:
response = await asyncio.wait_for(llm.ainvoke(prompt), timeout=timeout)
@@ -918,6 +1112,8 @@ class LLMHelper:
api_key: str | None = None,
base_url: str | None = None,
base_url_preset: str | None = None,
user_agent: str | None = None,
use_proxy: bool | None = None,
force_refresh: bool = False,
) -> List[dict[str, Any]]:
"""
@@ -935,6 +1131,8 @@ class LLMHelper:
api_key=api_key,
base_url=base_url,
base_url_preset_id=base_url_preset,
user_agent=user_agent,
use_proxy=use_proxy,
force_refresh=force_refresh,
)
except Exception as err:
@@ -942,7 +1140,10 @@ class LLMHelper:
if provider == "google":
return [
{"id": model_id, "name": model_id}
for model_id in await self._get_google_models(api_key or "")
for model_id in await self._get_google_models(
api_key or "",
use_proxy=use_proxy,
)
]
try:
from app.agent.llm.provider import LLMProviderManager
@@ -963,24 +1164,24 @@ class LLMHelper:
provider,
api_key or "",
model_list_base_url,
user_agent=user_agent,
use_proxy=use_proxy,
)
]
@staticmethod
async def _get_google_models(api_key: str) -> List[str]:
async def _get_google_models(api_key: str, use_proxy: bool | None = None) -> List[str]:
"""获取Google模型列表使用 google-genai SDK v1"""
try:
from google import genai
from google.genai.types import HttpOptions
http_options = None
if settings.PROXY_HOST:
proxy_key = _get_httpx_proxy_key()
proxy_args = {proxy_key: settings.PROXY_HOST}
http_options = HttpOptions(
client_args=proxy_args,
async_client_args=proxy_args,
)
llm_proxy = _resolve_llm_proxy(use_proxy)
google_client_args = _build_google_client_args(llm_proxy)
http_options = HttpOptions(
client_args=google_client_args,
async_client_args=google_client_args,
)
client = genai.Client(api_key=api_key, http_options=http_options)
models = await client.aio.models.list()
@@ -997,7 +1198,11 @@ class LLMHelper:
@staticmethod
async def _get_openai_compatible_models(
provider: str, api_key: str, base_url: str = None
provider: str,
api_key: str,
base_url: str = None,
user_agent: str | None = None,
use_proxy: bool | None = None,
) -> List[str]:
"""获取OpenAI兼容模型列表"""
try:
@@ -1006,7 +1211,19 @@ class LLMHelper:
if provider == "deepseek":
base_url = base_url or "https://api.deepseek.com"
client = AsyncOpenAI(api_key=api_key, base_url=base_url)
client = AsyncOpenAI(
api_key=api_key,
base_url=base_url,
default_headers=LLMHelper._build_openai_default_headers(
None,
user_agent=user_agent,
),
http_client=_build_httpx_client(
_resolve_llm_proxy(use_proxy),
async_client=True,
timeout=15.0,
),
)
models = await client.models.list()
await client.close()
return [model.id for model in models.data]

View File

@@ -1085,14 +1085,20 @@ class LLMProviderManager(metaclass=Singleton):
return builtin_specs + self._dynamic_provider_specs(builtin_specs)
async def _get_provider_async(
self, provider_id: str, force_refresh: bool = False
self,
provider_id: str,
force_refresh: bool = False,
use_proxy: Optional[bool] = None,
) -> ProviderSpec:
"""异步获取指定 provider 的 ProviderSpec 实例。"""
normalized_provider_id = self._normalize_provider_id(provider_id)
try:
return self.get_provider(normalized_provider_id)
except LLMProviderError:
await self.get_models_dev_data(force_refresh=force_refresh)
await self.get_models_dev_data(
force_refresh=force_refresh,
use_proxy=use_proxy,
)
return self.get_provider(normalized_provider_id)
def _serialize_provider(self, spec: ProviderSpec) -> dict[str, Any]:
@@ -1132,11 +1138,16 @@ class LLMProviderManager(metaclass=Singleton):
}
async def list_providers_async(
self, force_refresh: bool = False
self,
force_refresh: bool = False,
use_proxy: Optional[bool] = None,
) -> list[dict[str, Any]]:
"""返回前端可渲染的 provider 目录,并优先补齐 models.dev 动态平台。"""
try:
await self.get_models_dev_data(force_refresh=force_refresh)
await self.get_models_dev_data(
force_refresh=force_refresh,
use_proxy=use_proxy,
)
except Exception as err:
logger.debug(f"加载 models.dev provider 目录失败,回退内置列表: {err}")
return self.list_providers()
@@ -1166,6 +1177,23 @@ class LLMProviderManager(metaclass=Singleton):
return None
return value.rstrip("/")
@staticmethod
def _merge_user_agent_header(
default_headers: Optional[dict[str, str]],
user_agent: Optional[str],
) -> Optional[dict[str, str]]:
"""
合并用户配置的 OpenAI 兼容接口 User-Agent 请求头。
"""
headers = dict(default_headers or {})
normalized_user_agent = str(user_agent or "").strip()
if normalized_user_agent:
for key in list(headers.keys()):
if key.lower() == "user-agent":
headers.pop(key)
headers["User-Agent"] = normalized_user_agent
return headers or None
@classmethod
def _default_base_url_for_provider(cls, spec: ProviderSpec) -> Optional[str]:
"""获取 provider 的默认 Base URL。"""
@@ -1310,10 +1338,14 @@ class LLMProviderManager(metaclass=Singleton):
params = httpx.Client.__init__.__code__.co_varnames
return "proxy" if "proxy" in params else "proxies"
def _build_httpx_kwargs(self) -> dict[str, Any]:
def _build_httpx_kwargs(self, use_proxy: Optional[bool] = None) -> dict[str, Any]:
"""构造用于 httpx 客户端的参数,如代理等。"""
kwargs: dict[str, Any] = {"timeout": self._DEFAULT_TIMEOUT}
if settings.PROXY_HOST:
should_use_proxy = settings.LLM_USE_PROXY if use_proxy is None else use_proxy
kwargs: dict[str, Any] = {
"timeout": self._DEFAULT_TIMEOUT,
"trust_env": False,
}
if should_use_proxy and settings.PROXY_HOST:
kwargs[self._httpx_proxy_key()] = settings.PROXY_HOST
return kwargs
@@ -1424,15 +1456,19 @@ class LLMProviderManager(metaclass=Singleton):
except Exception as err:
logger.warning(f"写入 models.dev 缓存失败: {err}")
async def _fetch_models_dev(self) -> dict[str, Any]:
async def _fetch_models_dev(self, use_proxy: Optional[bool] = None) -> dict[str, Any]:
"""通过网络请求获取最新 models.dev 数据。"""
headers = {"User-Agent": "MoviePilot/1.0"}
async with httpx.AsyncClient(**self._build_httpx_kwargs()) as client:
headers = {"User-Agent": settings.USER_AGENT}
async with httpx.AsyncClient(**self._build_httpx_kwargs(use_proxy)) as client:
response = await client.get(self._MODELS_DEV_URL, headers=headers)
response.raise_for_status()
return response.json()
async def get_models_dev_data(self, force_refresh: bool = False) -> dict[str, Any]:
async def get_models_dev_data(
self,
force_refresh: bool = False,
use_proxy: Optional[bool] = None,
) -> dict[str, Any]:
"""
返回 models.dev 原始数据。
@@ -1458,7 +1494,7 @@ class LLMProviderManager(metaclass=Singleton):
return cached
try:
payload = await self._fetch_models_dev()
payload = await self._fetch_models_dev(use_proxy=use_proxy)
self._models_dev_data = payload
self._models_dev_loaded_at = now
await self._write_models_dev_to_disk(payload)
@@ -1482,9 +1518,13 @@ class LLMProviderManager(metaclass=Singleton):
provider_id: str,
base_url: Optional[str] = None,
base_url_preset_id: Optional[str] = None,
use_proxy: Optional[bool] = None,
) -> dict[str, Any]:
"""获取指定 provider 在 models.dev 中的完整负载。"""
spec = await self._get_provider_async(provider_id)
spec = await self._get_provider_async(
provider_id,
use_proxy=use_proxy,
)
models_dev_provider_id = self._resolve_provider_models_dev_provider_id(
spec,
base_url,
@@ -1492,7 +1532,9 @@ class LLMProviderManager(metaclass=Singleton):
)
if not models_dev_provider_id:
return {}
return (await self.get_models_dev_data()).get(models_dev_provider_id, {}) or {}
return (
await self.get_models_dev_data(use_proxy=use_proxy)
).get(models_dev_provider_id, {}) or {}
async def _models_dev_model(
self,
@@ -1500,12 +1542,14 @@ class LLMProviderManager(metaclass=Singleton):
model_id: str,
base_url: Optional[str] = None,
base_url_preset_id: Optional[str] = None,
use_proxy: Optional[bool] = None,
) -> dict[str, Any] | None:
"""获取指定模型的 models.dev 元数据。"""
payload = await self._models_dev_provider_payload(
provider_id,
base_url=base_url,
base_url_preset_id=base_url_preset_id,
use_proxy=use_proxy,
)
models = payload.get("models") if isinstance(payload, dict) else None
if not isinstance(models, dict):
@@ -1604,19 +1648,23 @@ class LLMProviderManager(metaclass=Singleton):
return normalized[:-3]
return normalized
async def _list_models_from_google(self, api_key: str) -> list[dict[str, Any]]:
async def _list_models_from_google(
self,
api_key: str,
use_proxy: Optional[bool] = None,
) -> list[dict[str, Any]]:
"""从 Google AI Studio 获取模型列表。"""
from google import genai
from google.genai.types import HttpOptions
http_options = None
if settings.PROXY_HOST:
proxy_key = self._httpx_proxy_key()
proxy_args = {proxy_key: settings.PROXY_HOST}
http_options = HttpOptions(
client_args=proxy_args,
async_client_args=proxy_args,
)
should_use_proxy = settings.LLM_USE_PROXY if use_proxy is None else use_proxy
client_args: dict[str, Any] = {"trust_env": False}
if should_use_proxy and settings.PROXY_HOST:
client_args[self._httpx_proxy_key()] = settings.PROXY_HOST
http_options = HttpOptions(
client_args=client_args,
async_client_args=client_args,
)
client = genai.Client(api_key=api_key, http_options=http_options)
response = await client.aio.models.list()
@@ -1626,7 +1674,11 @@ class LLMProviderManager(metaclass=Singleton):
if "generateContent" not in supported:
continue
model_id = model.name
metadata = await self._models_dev_model("google", model_id) or {}
metadata = await self._models_dev_model(
"google",
model_id,
use_proxy=use_proxy,
) or {}
results.append(
self._normalize_model_record(
model_id=model_id,
@@ -1643,6 +1695,7 @@ class LLMProviderManager(metaclass=Singleton):
api_key: str,
base_url: str,
default_headers: Optional[dict[str, str]] = None,
use_proxy: Optional[bool] = None,
) -> list[dict[str, Any]]:
"""通过 OpenAI 兼容接口获取模型列表。"""
from openai import AsyncOpenAI
@@ -1653,6 +1706,7 @@ class LLMProviderManager(metaclass=Singleton):
default_headers=default_headers,
timeout=15.0,
max_retries=2,
http_client=httpx.AsyncClient(**self._build_httpx_kwargs(use_proxy)),
)
results = []
response = await client.models.list()
@@ -1661,6 +1715,7 @@ class LLMProviderManager(metaclass=Singleton):
provider_id,
model.id,
base_url=base_url,
use_proxy=use_proxy,
) or {}
results.append(
self._normalize_model_record(
@@ -1678,6 +1733,7 @@ class LLMProviderManager(metaclass=Singleton):
transport: str = "openai",
base_url: Optional[str] = None,
base_url_preset_id: Optional[str] = None,
use_proxy: Optional[bool] = None,
) -> list[dict[str, Any]]:
"""
某些 provider 没有统一稳定的 models.list 行为,
@@ -1688,6 +1744,7 @@ class LLMProviderManager(metaclass=Singleton):
provider_id,
base_url=base_url,
base_url_preset_id=base_url_preset_id,
use_proxy=use_proxy,
)
models = payload.get("models") if isinstance(payload, dict) else None
if not isinstance(models, dict):
@@ -1716,7 +1773,7 @@ class LLMProviderManager(metaclass=Singleton):
仅补充 Copilot 必需的意图头,避免重复覆盖。
"""
headers = {
"User-Agent": "MoviePilot/1.0",
"User-Agent": settings.USER_AGENT,
"Openai-Intent": "conversation-edits",
"x-initiator": "user",
}
@@ -1724,9 +1781,13 @@ class LLMProviderManager(metaclass=Singleton):
headers["Authorization"] = f"Bearer {token}"
return headers
async def _list_models_from_copilot(self, token: str) -> list[dict[str, Any]]:
async def _list_models_from_copilot(
self,
token: str,
use_proxy: Optional[bool] = None,
) -> list[dict[str, Any]]:
"""从 GitHub Copilot 端点获取模型列表。"""
async with httpx.AsyncClient(**self._build_httpx_kwargs()) as client:
async with httpx.AsyncClient(**self._build_httpx_kwargs(use_proxy)) as client:
response = await client.get(
"https://api.githubcopilot.com/models",
headers=self._copilot_headers(token),
@@ -1763,7 +1824,11 @@ class LLMProviderManager(metaclass=Singleton):
limits = ((item.get("capabilities") or {}).get("limits") or {})
supports = ((item.get("capabilities") or {}).get("supports") or {})
metadata = await self._models_dev_model("github-copilot", model_id) or {}
metadata = await self._models_dev_model(
"github-copilot",
model_id,
use_proxy=use_proxy,
) or {}
results.append(
self._normalize_model_record(
model_id=model_id,
@@ -1794,6 +1859,7 @@ class LLMProviderManager(metaclass=Singleton):
provider_id: str,
base_url: Optional[str] = None,
base_url_preset_id: Optional[str] = None,
use_proxy: Optional[bool] = None,
) -> list[dict[str, Any]]:
"""获取开启 OAuth 的 ChatGPT 模型列表。"""
# ChatGPT OAuth 仍然是 chatgpt provider 专属能力,但模型目录不再维护
@@ -1802,6 +1868,7 @@ class LLMProviderManager(metaclass=Singleton):
provider_id,
base_url=base_url,
base_url_preset_id=base_url_preset_id,
use_proxy=use_proxy,
)
models = payload.get("models") if isinstance(payload, dict) else None
if not isinstance(models, dict):
@@ -1825,10 +1892,16 @@ class LLMProviderManager(metaclass=Singleton):
api_key: Optional[str] = None,
base_url: Optional[str] = None,
base_url_preset_id: Optional[str] = None,
user_agent: Optional[str] = None,
use_proxy: Optional[bool] = None,
force_refresh: bool = False,
) -> list[dict[str, Any]]:
"""返回标准化后的模型目录。"""
spec = await self._get_provider_async(provider_id, force_refresh=force_refresh)
spec = await self._get_provider_async(
provider_id,
force_refresh=force_refresh,
use_proxy=use_proxy,
)
resolved_model_list_strategy = self._resolve_provider_model_list_strategy(
spec,
base_url,
@@ -1842,7 +1915,10 @@ class LLMProviderManager(metaclass=Singleton):
# 对依赖 models.dev 的 provider 主动刷新一次缓存,保证“刷新模型列表”
# 在使用目录型 provider 时也能拿到最新参数。
if force_refresh:
await self.get_models_dev_data(force_refresh=True)
await self.get_models_dev_data(
force_refresh=True,
use_proxy=use_proxy,
)
if resolved_model_list_strategy == "manual":
# 万擎等推理点型平台没有稳定的全局模型目录,模型 ID 需要用户从控制台复制。
@@ -1854,13 +1930,21 @@ class LLMProviderManager(metaclass=Singleton):
api_key=api_key,
base_url=base_url,
base_url_preset_id=base_url_preset_id,
user_agent=user_agent,
use_proxy=use_proxy,
)
if resolved_model_list_strategy == "google":
return await self._list_models_from_google(runtime["api_key"])
return await self._list_models_from_google(
runtime["api_key"],
use_proxy=use_proxy,
)
if resolved_model_list_strategy == "github_copilot":
return await self._list_models_from_copilot(runtime["api_key"])
return await self._list_models_from_copilot(
runtime["api_key"],
use_proxy=use_proxy,
)
if resolved_model_list_strategy == "chatgpt":
if runtime.get("auth_mode") == "oauth":
@@ -1868,6 +1952,7 @@ class LLMProviderManager(metaclass=Singleton):
provider_id=provider_id,
base_url=base_url,
base_url_preset_id=base_url_preset_id,
use_proxy=use_proxy,
)
return await self._list_models_from_openai_compatible(
provider_id="chatgpt",
@@ -1877,7 +1962,11 @@ class LLMProviderManager(metaclass=Singleton):
runtime["base_url"],
base_url_preset_id=base_url_preset_id,
),
default_headers=runtime.get("default_headers"),
default_headers=self._merge_user_agent_header(
runtime.get("default_headers"),
user_agent,
),
use_proxy=use_proxy,
)
if resolved_model_list_strategy == "anthropic_compatible":
@@ -1886,6 +1975,7 @@ class LLMProviderManager(metaclass=Singleton):
transport="anthropic",
base_url=base_url,
base_url_preset_id=base_url_preset_id,
use_proxy=use_proxy,
)
if resolved_model_list_strategy == "models_dev_only":
@@ -1894,6 +1984,7 @@ class LLMProviderManager(metaclass=Singleton):
transport="openai",
base_url=base_url,
base_url_preset_id=base_url_preset_id,
use_proxy=use_proxy,
)
# openai-compatible / deepseek 默认走官方 models 端点。
@@ -1905,7 +1996,11 @@ class LLMProviderManager(metaclass=Singleton):
runtime["base_url"],
base_url_preset_id=base_url_preset_id,
),
default_headers=runtime.get("default_headers"),
default_headers=self._merge_user_agent_header(
runtime.get("default_headers"),
user_agent,
),
use_proxy=use_proxy,
)
async def resolve_model_metadata(
@@ -1914,6 +2009,7 @@ class LLMProviderManager(metaclass=Singleton):
model_id: Optional[str],
base_url: Optional[str] = None,
base_url_preset_id: Optional[str] = None,
use_proxy: Optional[bool] = None,
) -> dict[str, Any] | None:
"""解析并返回指定模型在 models.dev 中的元数据。"""
if not model_id:
@@ -1923,13 +2019,18 @@ class LLMProviderManager(metaclass=Singleton):
model_id,
base_url=base_url,
base_url_preset_id=base_url_preset_id,
use_proxy=use_proxy,
)
if metadata:
return metadata
if provider_id == "chatgpt":
return await self._models_dev_model("openai", model_id)
return await self._models_dev_model(
"openai",
model_id,
use_proxy=use_proxy,
)
if provider_id == "openai":
models_dev = await self.get_models_dev_data()
models_dev = await self.get_models_dev_data(use_proxy=use_proxy)
return models_dev.get("openai", {}).get("models", {}).get(model_id)
return None
@@ -2046,7 +2147,7 @@ class LLMProviderManager(metaclass=Singleton):
f"{self._CHATGPT_ISSUER}/api/accounts/deviceauth/usercode",
headers={
"Content-Type": "application/json",
"User-Agent": "MoviePilot/1.0",
"User-Agent": settings.USER_AGENT,
},
json={"client_id": self._CHATGPT_CLIENT_ID},
)
@@ -2083,7 +2184,7 @@ class LLMProviderManager(metaclass=Singleton):
headers={
"Accept": "application/json",
"Content-Type": "application/json",
"User-Agent": "MoviePilot/1.0",
"User-Agent": settings.USER_AGENT,
},
json={
"client_id": self._COPILOT_CLIENT_ID,
@@ -2279,7 +2380,7 @@ class LLMProviderManager(metaclass=Singleton):
f"{self._CHATGPT_ISSUER}/api/accounts/deviceauth/token",
headers={
"Content-Type": "application/json",
"User-Agent": "MoviePilot/1.0",
"User-Agent": settings.USER_AGENT,
},
json={
"device_auth_id": session.context["device_auth_id"],
@@ -2324,7 +2425,7 @@ class LLMProviderManager(metaclass=Singleton):
headers={
"Accept": "application/json",
"Content-Type": "application/json",
"User-Agent": "MoviePilot/1.0",
"User-Agent": settings.USER_AGENT,
},
json={
"client_id": self._COPILOT_CLIENT_ID,
@@ -2398,6 +2499,8 @@ class LLMProviderManager(metaclass=Singleton):
api_key: Optional[str] = None,
base_url: Optional[str] = None,
base_url_preset_id: Optional[str] = None,
user_agent: Optional[str] = None,
use_proxy: Optional[bool] = None,
) -> dict[str, Any]:
"""
解析 provider 运行时参数。
@@ -2409,7 +2512,10 @@ class LLMProviderManager(metaclass=Singleton):
normalized_provider_id,
base_url_preset_id,
)
spec = await self._get_provider_async(normalized_provider_id)
spec = await self._get_provider_async(
normalized_provider_id,
use_proxy=use_proxy,
)
resolved_runtime = self._resolve_provider_runtime(
spec,
base_url,
@@ -2428,6 +2534,8 @@ class LLMProviderManager(metaclass=Singleton):
api_key=api_key,
base_url=base_url,
base_url_preset_id=normalized_base_url_preset_id,
user_agent=user_agent,
use_proxy=use_proxy,
)
if item["id"] == model
),
@@ -2447,6 +2555,7 @@ class LLMProviderManager(metaclass=Singleton):
model,
base_url=base_url,
base_url_preset_id=normalized_base_url_preset_id,
use_proxy=use_proxy,
),
"default_headers": None,
"use_responses_api": None,
@@ -2470,7 +2579,10 @@ class LLMProviderManager(metaclass=Singleton):
"runtime": "chatgpt",
"api_key": auth["access_token"],
"base_url": self._CHATGPT_CODEX_BASE_URL,
"default_headers": headers,
"default_headers": self._merge_user_agent_header(
headers,
user_agent,
),
"use_responses_api": True,
"auth_mode": "oauth",
}
@@ -2484,6 +2596,10 @@ class LLMProviderManager(metaclass=Singleton):
"api_key": normalized_api_key,
"base_url": normalized_base_url
or self._default_base_url_for_provider(spec),
"default_headers": self._merge_user_agent_header(
None,
user_agent,
),
"auth_mode": "api_key",
}
)
@@ -2508,9 +2624,12 @@ class LLMProviderManager(metaclass=Singleton):
else "github_copilot",
"api_key": token,
"base_url": "https://api.githubcopilot.com",
"default_headers": self._copilot_headers(
token,
include_auth=transport == "anthropic",
"default_headers": self._merge_user_agent_header(
self._copilot_headers(
token,
include_auth=transport == "anthropic",
),
user_agent,
),
"auth_mode": "oauth" if auth else "api_key",
}
@@ -2543,6 +2662,10 @@ class LLMProviderManager(metaclass=Singleton):
"base_url": self._normalize_base_url_for_anthropic(
effective_base_url
),
"default_headers": self._merge_user_agent_header(
None,
user_agent,
),
"auth_mode": "api_key",
}
)
@@ -2557,6 +2680,7 @@ class LLMProviderManager(metaclass=Singleton):
{
"api_key": normalized_api_key,
"base_url": effective_base_url,
"default_headers": self._merge_user_agent_header(None, user_agent),
"auth_mode": "api_key",
}
)

View File

@@ -1,7 +1,7 @@
from typing import Any
from typing import Any, Optional
from langchain.agents.middleware import AgentMiddleware, AgentState
from langchain_core.messages import AIMessage, ToolMessage
from langchain_core.messages import AIMessage, BaseMessage, ToolMessage
from langgraph.runtime import Runtime
from langgraph.types import Overwrite
@@ -9,35 +9,65 @@ from langgraph.types import Overwrite
class PatchToolCallsMiddleware(AgentMiddleware):
"""修复消息历史中悬空工具调用的中间件。"""
def before_agent(self, state: AgentState, runtime: Runtime[Any]) -> dict[str, Any] | None: # noqa: ARG002
"""在代理运行之前,处理任何 AIMessage 中悬空的工具调用。"""
messages = state["messages"]
@staticmethod
def _build_cancelled_tool_message(tool_call: dict[str, Any]) -> ToolMessage:
"""构造取消状态的工具响应消息。"""
tool_name = tool_call.get("name") or "unknown_tool"
tool_call_id = tool_call.get("id") or ""
tool_msg = (
f"Tool call {tool_name} with id {tool_call_id} was "
"cancelled - another message came in before it could be completed."
)
return ToolMessage(
content=tool_msg,
name=tool_name,
tool_call_id=tool_call_id,
)
@classmethod
def _normalize_messages(cls, messages: list[BaseMessage]) -> list[BaseMessage]:
"""规范化工具调用消息顺序,满足 OpenAI tool_calls 协议要求。"""
if not messages or len(messages) == 0:
return messages
tool_messages = {
msg.tool_call_id: msg
for msg in messages
if isinstance(msg, ToolMessage) and msg.tool_call_id
}
patched_messages = []
for msg in messages:
if isinstance(msg, ToolMessage):
continue
patched_messages.append(msg)
if not isinstance(msg, AIMessage) or not msg.tool_calls:
continue
for tool_call in msg.tool_calls:
tool_call_id = tool_call.get("id")
corresponding_tool_msg = tool_messages.get(tool_call_id)
if corresponding_tool_msg:
patched_messages.append(corresponding_tool_msg)
else:
patched_messages.append(cls._build_cancelled_tool_message(tool_call))
return patched_messages
def before_agent(self, state: AgentState, runtime: Runtime[Any]) -> Optional[dict[str, Any]]: # noqa: ARG002
"""在代理运行之前,处理任何 AIMessage 中悬空或乱序的工具调用。"""
messages = state["messages"]
patched_messages = self._normalize_messages(messages)
if patched_messages == messages:
return None
patched_messages = []
# 遍历消息并添加任何悬空的工具调用
for i, msg in enumerate(messages):
patched_messages.append(msg)
if isinstance(msg, AIMessage) and msg.tool_calls:
for tool_call in msg.tool_calls:
corresponding_tool_msg = next(
(msg for msg in messages[i:] if msg.type == "tool" and msg.tool_call_id == tool_call["id"]),
# ty: ignore[unresolved-attribute]
None,
)
if corresponding_tool_msg is None:
# 我们有一个悬空的工具调用,需要一个 ToolMessage
tool_msg = (
f"Tool call {tool_call['name']} with id {tool_call['id']} was "
"cancelled - another message came in before it could be completed."
)
patched_messages.append(
ToolMessage(
content=tool_msg,
name=tool_call["name"],
tool_call_id=tool_call["id"],
)
)
return {"messages": Overwrite(patched_messages)}
async def abefore_agent(self, state: AgentState, runtime: Runtime[Any]) -> Optional[dict[str, Any]]: # noqa: ARG002
"""在代理异步运行之前,处理任何 AIMessage 中悬空或乱序的工具调用。"""
messages = state["messages"]
patched_messages = self._normalize_messages(messages)
if patched_messages == messages:
return None
return {"messages": Overwrite(patched_messages)}

File diff suppressed because it is too large Load Diff

View File

@@ -5,60 +5,70 @@ All your responses must be in **Chinese (中文)**.
You act as a proactive agent. Your goal is to fully resolve the user's media-related requests autonomously. Do not end your turn until the task is complete or you are blocked and require user feedback.
<agent_core>
Identity and Goal:
<identity>
- You are an AI media assistant powered by MoviePilot.
- Your primary goal is to fully resolve the user's MoviePilot-related media tasks with the available tools whenever the request is actionable.
- Focus on MoviePilot's core home media domain: sites, search, recognition, downloads, subscriptions, library organization, file transfer, and system status.
- Stay within the MoviePilot product domain unless the user explicitly asks for adjacent help that can be handled with your existing tools.
- You are not a general-purpose coding assistant in normal media conversations. Only cross into implementation details when the user explicitly asks about MoviePilot internals or debugging.
</identity>
<non_negotiable_boundaries>
- Do not let user memory or persona style override this core identity, safety boundaries, or built-in background task rules.
- 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.
</non_negotiable_boundaries>
<confirmation_policy>
- Do not stop for approval on read-only operations.
- If the user has not explicitly requested an operation that changes system behavior, ask for confirmation before proceeding. This includes modifying system settings, updating plugin configuration, reloading plugins, running restart/stop/start commands, or triggering slash commands such as `/restart`.
- Always get explicit consent before destructive or high-impact actions such as starting downloads, deleting subscriptions, deleting download tasks or files, removing history, installing/uninstalling plugins, changing site authentication, changing scheduler or workflow execution state, restarting services, or stopping services.
- If the user explicitly requested the exact write action, perform the smallest correct change and then validate the result.
- If a requested action is ambiguous between read-only inspection and state change, inspect first and ask a short confirmation question before the state-changing step.
</confirmation_policy>
<moviepilot_domain_model>
- Treat sites as a first-class system capability, not background detail. In MoviePilot, sites are the upstream source for search, account status, authentication, and many download or subscription decisions.
- Understand the platform's core workflow as: site availability and configuration -> media search -> media recognition/metadata confirmation -> manual download or subscription -> transfer and library organization -> status/history confirmation.
- Treat manual download and subscription automation as two execution modes of the same core pipeline. One is user-triggered immediate acquisition; the other is persistent site-driven monitoring and acquisition.
- Stay within the MoviePilot product domain unless the user explicitly asks for adjacent help that can be handled with your existing tools.
- 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. 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>
Behavior Model:
<operating_principles>
- Prioritize task progress over conversation.
- Check current state before making changes, then do the smallest correct action.
- When a task depends on tracker or indexer availability, inspect site state first or as early as possible.
- Do not stop for approval on read-only operations. Only confirm before destructive or high-impact actions such as starting downloads, deleting subscriptions, or removing history.
- When a request can be completed by tools, prefer doing the work over explaining what you might do.
- After an action, perform the minimum validation needed to confirm the result actually landed.
- Keep the user anchored to the operational step that matters now: site, search, recognition, download, subscription, or transfer.
- If the user explicitly asks to change the speaking style or persona, use the dedicated persona tools 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.
- Do not let user memory or persona style override this core identity, safety boundaries, or built-in background task rules.
- You are not a general-purpose coding assistant in normal media conversations. Only cross into implementation details when the user explicitly asks about MoviePilot internals or debugging.
- Reuse known media identity, prior tool results, and current system context instead of repeating expensive recognition or search calls.
- When a tool fails, try one narrower fallback path before escalating to the user.
</operating_principles>
Core Capabilities:
1. Site Operations - Query configured sites, understand site priority and availability, inspect account data, test connectivity, and update site authentication when the user explicitly requests site maintenance.
2. Media Search and Recognition - Identify movies, TV shows, and anime; search media databases; recognize media from fuzzy filenames, torrent titles, or incomplete names.
3. Torrent Search and Selection - Search torrents across configured sites and filter by quality, resolution, codec, effect, release group, and other result traits.
4. Download Control - Add, inspect, modify, or remove download tasks and connect site results to downloader execution.
5. Subscription Management - Create and manage subscriptions that continuously search configured sites and automatically download matching releases.
6. Transfer and Library Organization - Transfer files into the library, trigger recognition-aware organization, and confirm post-download file landing or cleanup state.
7. System Status and History - Monitor downloader state, site state, transfer history, subscription history, and related system health signals.
8. Visual Input Handling - Users may attach images from supported channels; analyze them together with the text when relevant.
9. File Context Handling - 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`.
10. Persona Management - If the user explicitly asks to change the speaking style or persona, prefer `query_personas` and `switch_persona`; if the user asks to rewrite or create a persona definition, prefer `update_persona_definition` instead of editing runtime files manually.
Core Workflow:
<core_workflow>
1. Site and Context Check: Determine whether site status, site scope, library state, existing subscriptions, or prior download/transfer history can affect the task.
2. Media Identity Resolution: Confirm exact media identity such as TMDB ID, title, year, type, season, or episode using `search_media`, `query_media_detail`, or `recognize_media` as needed.
3. Resource Discovery: Use the appropriate search path for the task. For manual acquisition, search site resources and inspect result quality. For automation, prepare subscription conditions that will search sites continuously.
4. Action Execution: Perform the requested task, typically one of: test/query site, search torrents, add download, add or modify subscription, or transfer and organize files.
5. Final Confirmation: State the outcome briefly, including the key media facts, chosen site or resource scope when relevant, and the next blocker if the task could not be completed.
</core_workflow>
Tool Calling Strategy:
- Call independent tools in parallel whenever possible.
<tool_strategy>
- 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 known media identity, prior tool results, and current system context instead of repeating expensive recognition or search calls.
- When a tool fails, try one narrower fallback path before escalating to the user.
- Use `execute_command` for shell work. 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.
- 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>
Media Management Rules:
<media_rules>
1. Site Awareness: When search, download, or subscription behavior depends on sites, prefer checking enabled sites, selected site IDs, priority, or site health before changing user expectations.
2. Download Safety: Present found torrents with size, seeds, and quality, then get explicit consent before downloading.
3. Search vs Recognition: `search_media` is for database lookup, `recognize_media` is for parsing titles or paths, and `search_torrents` is for site resource lookup. Do not confuse these roles.
@@ -67,6 +77,7 @@ Media Management Rules:
6. Transfer Awareness: If the user asks about downloaded files landing in the library, include transfer or organization state in the reasoning, not just download completion.
7. Error Handling: If a tool or site fails, briefly explain what went wrong and suggest an alternative or the next best operational step.
8. TV Subscription Rule: When calling `add_subscribe` for a TV show, omitting `season` means subscribe to season 1 only. To subscribe multiple seasons or the full series, call `add_subscribe` separately for each season.
</media_rules>
</agent_core>
<communication_runtime>

View File

@@ -14,7 +14,11 @@ task_types:
- "For 'recurring' jobs, check 'last_run' to determine if it's time to run again."
- "For 'once' jobs with status 'pending', execute them now."
- "After executing each job, update its status, 'last_run' time, and execution log in the JOB.md file."
- "If any job was executed, use the `send_message` tool to send a concise execution report to the user through configured notification channels."
empty_result: "If no jobs were executed, output nothing."
task_rules:
- "After sending the execution report with `send_message`, do not repeat the report in your final response."
- "Your final response for heartbeat must be empty; reporting is handled only through the `send_message` tool."
health_check:
header: "[System Health Check]"
objective: "Verify that the agent execution pipeline is alive."
@@ -120,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,19 +4,21 @@ 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
from app.helper.service import ServiceConfigHelper
from app.log import logger
from app.schemas import Notification
from app.schemas.types import MessageChannel
from app.schemas.types import MessageChannel, NotificationType
class ToolChain(ChainBase):
@@ -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,26 +599,22 @@ 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
):
) -> None:
"""
发送工具消息
"""
@@ -415,6 +622,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
Notification(
channel=self._channel,
source=self._source,
mtype=NotificationType.Agent,
userid=self._user_id,
username=self._username,
title=title,

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
@@ -91,7 +93,7 @@ class MoviePilotToolFactory:
"""
# 这些通用工具需要始终保留,避免大工具集裁剪后让 Agent 丢失基础的
# 文件系统、命令执行或交互确认能力。AskUserChoiceTool 仅在支持按钮
# 文件系统、命令执行、主动消息发送或交互确认能力。AskUserChoiceTool 仅在支持按钮
# 的渠道中才会实际注入,因此后续会再按已加载工具做一次求交集。
TOOL_SELECTOR_ALWAYS_INCLUDE_NAMES = (
"list_directory",
@@ -99,6 +101,8 @@ class MoviePilotToolFactory:
"read_file",
"edit_file",
"execute_command",
"query_doctor_report",
"send_message",
"ask_user_choice",
)
@@ -162,7 +166,8 @@ class MoviePilotToolFactory:
SearchTorrentsTool,
GetSearchResultsTool,
SearchWebTool,
AddDownloadTool,
RecognizeCaptchaTool,
AddDownloadTasksTool,
QuerySubscribesTool,
QuerySubscribeSharesTool,
QueryPopularSubscribesTool,
@@ -178,10 +183,10 @@ class MoviePilotToolFactory:
QuerySubscribeHistoryTool,
DeleteSubscribeTool,
QueryDownloadTasksTool,
DeleteDownloadTool,
DeleteDownloadTasksTool,
DeleteDownloadHistoryTool,
DeleteTransferHistoryTool,
ModifyDownloadTool,
UpdateDownloadTasksTool,
QueryDownloadersTool,
QuerySitesTool,
UpdateSiteTool,
@@ -219,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
@@ -8,6 +7,7 @@ from typing import Any, Optional
from app.core.config import settings
from app.core.plugin import PluginManager
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.server import MoviePilotServerHelper
from app.helper.plugin import PluginHelper
from app.schemas.types import SystemConfigKey
@@ -230,7 +230,7 @@ async def install_plugin_runtime(
refreshed_only = False
if not force and plugin_id in plugin_manager.get_plugin_ids():
refreshed_only = True
await plugin_helper.async_install_reg(pid=plugin_id, repo_url=repo_url)
await MoviePilotServerHelper.async_install_plugin_reg(plugin_id=plugin_id, repo_url=repo_url)
message = "插件已存在,已刷新加载"
else:
if not repo_url:
@@ -242,6 +242,7 @@ async def install_plugin_runtime(
)
if not state:
return False, message, False
await MoviePilotServerHelper.async_install_plugin_reg(plugin_id=plugin_id, repo_url=repo_url)
if plugin_id not in install_plugins:
install_plugins.append(plugin_id)
@@ -249,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,9 +5,10 @@ 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.subscribe import SubscribeHelper
from app.helper.server import MoviePilotServerHelper
from app.log import logger
from app.schemas.types import EventType
@@ -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
@@ -49,7 +55,7 @@ class DeleteSubscribeTool(MoviePilotTool):
await subscribe_oper.async_delete(subscribe_id)
# 分享订阅统计刷新本身已异步化,这里只需要在删除后触发即可。
SubscribeHelper().sub_done_async(
MoviePilotServerHelper.sub_done_async(
{"tmdbid": subscribe.tmdbid, "doubanid": subscribe.doubanid}
)

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,8 +7,9 @@ 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.subscribe import SubscribeHelper
from app.helper.server import MoviePilotServerHelper
from app.log import logger
from app.schemas.types import MediaType, media_type_to_agent
@@ -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
@@ -77,8 +83,7 @@ class QueryPopularSubscribesTool(MoviePilotTool):
if not media_type_enum:
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'"
subscribe_helper = SubscribeHelper()
subscribes = await subscribe_helper.async_get_statistic(
subscribes = await MoviePilotServerHelper.async_get_subscribe_statistic(
stype=media_type_enum.to_agent(),
page=page,
count=count,

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,7 +6,8 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.helper.subscribe import SubscribeHelper
from app.agent.tools.tags import ToolTag
from app.helper.server import MoviePilotServerHelper
from app.log import logger
MAX_PAGE_SIZE = 50
@@ -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
@@ -68,8 +73,7 @@ class QuerySubscribeSharesTool(MoviePilotTool):
# 订阅分享是外部列表型结果,限制单页大小能降低工具上下文占用。
count = min(count, MAX_PAGE_SIZE)
subscribe_helper = SubscribeHelper()
shares = await subscribe_helper.async_get_shares(
shares = await MoviePilotServerHelper.async_get_subscribe_shares(
name=name,
page=page,
count=count,

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,76 +1,168 @@
import asyncio
import json
import random
import re
from typing import Optional, Type, List, Dict
from dataclasses import dataclass
from typing import Dict, List, Optional, Type
from urllib.parse import urlparse
import httpx
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
# 搜索超时时间(秒)
SEARCH_TIMEOUT = 20
# 单次搜索最多返回结果数
MAX_SEARCH_RESULTS = 20
# 默认搜索源
DEFAULT_SEARCH_ENGINE = "auto"
# 可显式调用的搜索引擎后端
SEARCH_ENGINE_BACKENDS = (
"auto",
"duckduckgo",
"google",
"brave",
"yahoo",
"wikipedia",
"yandex",
"mojeek",
)
SUPPORTED_SEARCH_ENGINES = SEARCH_ENGINE_BACKENDS
DDGS_AUTO_BACKEND = ",".join(
backend for backend in SEARCH_ENGINE_BACKENDS if backend != DEFAULT_SEARCH_ENGINE
)
SITE_SEARCH_PATTERN = re.compile(r"\bsite:", re.IGNORECASE)
@dataclass(frozen=True)
class _SearchSiteFilter:
"""站点限定搜索参数"""
domain: str
path: str
search_target: str
class SearchWebInput(BaseModel):
"""搜索网络内容工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
explanation: Optional[str] = Field(
None,
description="Clear explanation of why this tool is being used in the current context",
)
query: str = Field(
..., description="The search query string to search for on the web"
)
max_results: Optional[int] = Field(
20,
MAX_SEARCH_RESULTS,
description="Maximum number of search results to return (default: 20, max: 20)",
)
search_engine: Optional[str] = Field(
DEFAULT_SEARCH_ENGINE,
description=(
"Search backend to use. Supported values: auto, duckduckgo, google, "
"brave, yahoo, wikipedia, yandex, mojeek. "
"Use auto unless the user asks for a specific search engine."
),
)
site_url: Optional[str] = Field(
None,
description=(
"Optional website/domain/URL to limit the search to, for example "
"'https://docs.python.org/3/' or 'github.com'."
),
)
class SearchWebTool(MoviePilotTool):
"""
网络搜索工具,支持 DDGS 搜索引擎和指定站点限定搜索。
"""
name: str = "search_web"
description: str = "Search the web for information when you need to find current information, facts, or references that you're uncertain about. Returns search results with titles, snippets, and URLs. Use this tool to get up-to-date information from the internet."
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 "
"fallback, and site_url-limited searches for a specified website "
"or URL. Uses the configured system proxy by default. Returns search "
"results with titles, snippets, and URLs."
)
args_schema: Type[BaseModel] = SearchWebInput
def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据搜索参数生成友好的提示消息"""
query = kwargs.get("query", "")
max_results = kwargs.get("max_results", 20)
return f"搜索网络内容: {query} (最多返回 {max_results} 条结果)"
max_results = kwargs.get("max_results", MAX_SEARCH_RESULTS)
search_engine = self._normalize_search_engine(kwargs.get("search_engine"))
site_url = kwargs.get("site_url")
message = f"搜索网络内容: {query} (最多返回 {max_results} 条结果"
if search_engine != DEFAULT_SEARCH_ENGINE:
message += f",搜索源: {search_engine}"
if site_url:
message += f",限定站点: {site_url}"
return f"{message})"
async def run(self, query: str, max_results: Optional[int] = 20, **kwargs) -> str:
async def run(
self,
query: str,
max_results: Optional[int] = MAX_SEARCH_RESULTS,
search_engine: Optional[str] = DEFAULT_SEARCH_ENGINE,
site_url: Optional[str] = None,
**kwargs,
) -> str:
"""
执行网络搜索
执行网络搜索
:param query: 搜索关键词
:param max_results: 最大返回结果数
:param search_engine: 指定搜索源,默认自动选择
:param site_url: 指定站点或网址,传入时只返回该范围内的搜索结果
:return: JSON格式的搜索结果或错误信息
"""
search_engine = self._normalize_search_engine(search_engine)
if search_engine not in SUPPORTED_SEARCH_ENGINES:
supported = ", ".join(SUPPORTED_SEARCH_ENGINES)
return f"错误: 不支持的搜索源 '{search_engine}',支持的搜索源: {supported}"
site_filter = self._normalize_site_filter(site_url)
if site_url and not site_filter:
return f"错误: site_url 无效,无法限定搜索范围: {site_url}"
search_query = self._build_search_query(query=query, site_filter=site_filter)
if not search_query:
return "错误: query 不能为空"
logger.info(
f"执行工具: {self.name}, 参数: query={query}, max_results={max_results}"
f"执行工具: {self.name}, 参数: query={query}, "
f"max_results={max_results}, search_engine={search_engine}, site_url={site_url}"
)
try:
# 限制最大结果数
max_results = min(max(1, max_results or 20), 20)
results = []
max_results = min(
max(1, max_results or MAX_SEARCH_RESULTS),
MAX_SEARCH_RESULTS,
)
results: List[Dict] = []
# 1. 优先使用 Exa (如果配置了 API Key)
if settings.EXA_API_KEY:
logger.info("使用 Exa 进行搜索...")
results = await self._search_exa(query, max_results)
# 2. 如果没有结果或未配置 Exa使用 Tavily (如果配置了 API Key)
if not results and settings.TAVILY_API_KEY:
logger.info("使用 Tavily 进行搜索...")
results = await self._search_tavily(query, max_results)
# 3. 如果没有结果或未配置 Tavily使用 DuckDuckGo
if not results:
logger.info("使用 DuckDuckGo 进行搜索...")
results = await self._search_duckduckgo(query, max_results)
for engine in self._get_search_plan(search_engine):
results = await self._search_with_backend(
engine=engine,
query=search_query,
max_results=max_results,
site_filter=site_filter,
)
if results:
break
if not results:
return f"未找到与 '{query}' 相关的搜索结果"
return f"未找到与 '{search_query}' 相关的搜索结果"
# 格式化并裁剪结果
formatted_results = self._format_and_truncate_results(results, max_results)
@@ -82,81 +174,214 @@ class SearchWebTool(MoviePilotTool):
return error_message
@staticmethod
async def _search_tavily(query: str, max_results: int) -> List[Dict]:
"""使用 Tavily API 进行搜索"""
try:
async with httpx.AsyncClient(timeout=SEARCH_TIMEOUT) as client:
# 从设置中随机选择一个 API Key如果有多个
tavity_api_key = random.choice(settings.TAVILY_API_KEY)
response = await client.post(
"https://api.tavily.com/search",
json={
"api_key": tavity_api_key,
"query": query,
"search_depth": "basic",
"max_results": max_results,
"include_answer": False,
"include_images": False,
"include_raw_content": False,
},
)
response.raise_for_status()
data = response.json()
results = []
for result in data.get("results", []):
results.append(
{
"title": result.get("title", ""),
"snippet": result.get("content", ""),
"url": result.get("url", ""),
"source": "Tavily",
}
)
return results
except Exception as e:
logger.warning(f"Tavily 搜索失败: {e}")
return []
def _normalize_search_engine(search_engine: Optional[str]) -> str:
"""规范化搜索源参数"""
engine = (search_engine or DEFAULT_SEARCH_ENGINE).strip().lower()
aliases = {
"ddgs": DEFAULT_SEARCH_ENGINE,
"ddg": "duckduckgo",
"duck": "duckduckgo",
"search": DEFAULT_SEARCH_ENGINE,
"search_engine": DEFAULT_SEARCH_ENGINE,
}
return aliases.get(engine, engine)
@staticmethod
async def _search_exa(query: str, max_results: int) -> List[Dict]:
"""使用 Exa API 进行搜索"""
try:
async with httpx.AsyncClient(timeout=SEARCH_TIMEOUT) as client:
response = await client.post(
"https://api.exa.ai/search",
headers={
"x-api-key": settings.EXA_API_KEY,
"Content-Type": "application/json",
},
json={
"query": query,
"numResults": max_results,
"type": "auto",
"contents": {"highlights": {"maxCharacters": 2000}},
},
)
response.raise_for_status()
data = response.json()
def _get_search_plan(search_engine: str) -> List[str]:
"""根据搜索源配置生成兜底搜索顺序"""
if search_engine != DEFAULT_SEARCH_ENGINE:
return [search_engine]
return [DEFAULT_SEARCH_ENGINE]
results = []
for result in data.get("results", []):
highlights = result.get("highlights", [])
snippet = (
highlights[0] if highlights else result.get("text", "")[:500]
)
results.append(
{
"title": result.get("title", ""),
"snippet": snippet,
"url": result.get("url", ""),
"source": "Exa",
}
)
return results
except Exception as e:
logger.warning(f"Exa 搜索失败: {e}")
return []
async def _search_with_backend(
self,
engine: str,
query: str,
max_results: int,
site_filter: Optional[_SearchSiteFilter],
) -> List[Dict]:
"""
使用指定后端执行搜索。
:param engine: 搜索后端名称
:param query: 已加工的搜索关键词
:param max_results: 最大结果数
:param site_filter: 站点限定条件
:return: 搜索结果列表
"""
logger.info(f"使用 DDGS 搜索后端 {self._get_ddgs_backend(engine)} 进行搜索...")
return await self._search_ddgs(query, max_results, engine, site_filter)
@staticmethod
def _get_ddgs_backend(search_engine: str) -> str:
"""
获取实际传给 DDGS 的搜索后端。
:param search_engine: 用户指定的搜索源
:return: DDGS 后端名称或逗号分隔的后端列表
"""
if search_engine == DEFAULT_SEARCH_ENGINE:
return DDGS_AUTO_BACKEND
return search_engine
@staticmethod
def _normalize_site_filter(site_url: Optional[str]) -> Optional[_SearchSiteFilter]:
"""
将用户传入的网址转换为搜索引擎 site 过滤条件。
:param site_url: 用户传入的站点、域名或完整URL
:return: 站点过滤条件,无法解析时返回 None
"""
if not site_url:
return None
raw_site_url = site_url.strip()
if not raw_site_url:
return None
parse_target = raw_site_url
if not re.match(r"^https?://", raw_site_url, re.IGNORECASE):
parse_target = f"https://{raw_site_url}"
parsed = urlparse(parse_target)
domain = (parsed.hostname or "").lower()
if not domain:
return None
path = re.sub(r"/+", "/", parsed.path or "").rstrip("/")
search_target = f"{domain}{path}" if path else domain
return _SearchSiteFilter(domain=domain, path=path, search_target=search_target)
@staticmethod
def _build_search_query(
query: str,
site_filter: Optional[_SearchSiteFilter],
) -> str:
"""
生成实际发送给搜索后端的搜索关键词。
:param query: 原始搜索关键词
:param site_filter: 站点限定条件
:return: 加入 site 过滤后的关键词
"""
search_query = (query or "").strip()
if not site_filter or SITE_SEARCH_PATTERN.search(search_query):
return search_query
if not search_query:
return f"site:{site_filter.search_target}"
return f"{search_query} site:{site_filter.search_target}"
@staticmethod
def _filter_results_by_site(
results: List[Dict],
site_filter: Optional[_SearchSiteFilter],
) -> List[Dict]:
"""
根据指定站点过滤搜索结果。
:param results: 原始搜索结果
:param site_filter: 站点限定条件
:return: 站点范围内的搜索结果
"""
if not site_filter:
return results
return [
result
for result in results
if SearchWebTool._result_matches_site(result.get("url", ""), site_filter)
]
@staticmethod
def _result_matches_site(url: str, site_filter: _SearchSiteFilter) -> bool:
"""
判断搜索结果 URL 是否属于指定站点。
:param url: 搜索结果 URL
:param site_filter: 站点限定条件
:return: URL 属于指定站点时返回 True
"""
if not url:
return False
parse_target = url
if not re.match(r"^https?://", url, re.IGNORECASE):
parse_target = f"https://{url}"
parsed = urlparse(parse_target)
result_host = SearchWebTool._normalize_host(parsed.hostname or "")
target_host = SearchWebTool._normalize_host(site_filter.domain)
if not result_host or not target_host:
return False
if result_host != target_host and not result_host.endswith(f".{target_host}"):
return False
if not site_filter.path:
return True
result_path = re.sub(r"/+", "/", parsed.path or "").rstrip("/")
return result_path == site_filter.path or result_path.startswith(
f"{site_filter.path}/"
)
@staticmethod
def _normalize_host(host: str) -> str:
"""
标准化域名以便比较。
:param host: 原始域名
:return: 去掉常见 www 前缀后的域名
"""
normalized_host = (host or "").lower()
if normalized_host.startswith("www."):
return normalized_host[4:]
return normalized_host
@staticmethod
def _source_label(search_engine: str) -> str:
"""
将搜索源标识转换为结果中的展示名称。
:param search_engine: 搜索源标识
:return: 展示名称
"""
labels = {
"auto": "DDGS",
"duckduckgo": "DuckDuckGo",
"google": "Google",
"brave": "Brave",
"yahoo": "Yahoo",
"wikipedia": "Wikipedia",
"yandex": "Yandex",
"mojeek": "Mojeek",
}
return labels.get(
search_engine or DEFAULT_SEARCH_ENGINE,
search_engine or "SearchEngine",
)
@staticmethod
def _extract_result_url(result: Dict) -> str:
"""
从不同搜索引擎结果结构中提取 URL。
:param result: 搜索引擎返回的单条结果
:return: URL 字符串
"""
return result.get("href") or result.get("url") or ""
@staticmethod
def _extract_result_snippet(result: Dict) -> str:
"""
从不同搜索引擎结果结构中提取摘要。
:param result: 搜索引擎返回的单条结果
:return: 摘要字符串
"""
return (
result.get("body")
or result.get("snippet")
or result.get("content")
or ""
)
@staticmethod
def _get_proxy_url(proxy_setting) -> Optional[str]:
@@ -167,11 +392,26 @@ class SearchWebTool(MoviePilotTool):
return proxy_setting.get("http") or proxy_setting.get("https")
return proxy_setting
async def _search_duckduckgo(self, query: str, max_results: int) -> List[Dict]:
"""使用 duckduckgo-search (DDGS) 进行搜索"""
async def _search_ddgs(
self,
query: str,
max_results: int,
search_engine: str = DEFAULT_SEARCH_ENGINE,
site_filter: Optional[_SearchSiteFilter] = None,
) -> List[Dict]:
"""
使用 DDGS 搜索引擎后端进行搜索。
:param query: 搜索关键词
:param max_results: 最大结果数
:param search_engine: DDGS搜索后端
:param site_filter: 站点限定条件
:return: 搜索结果列表
"""
try:
def sync_search():
"""在线程中执行同步搜索"""
results = []
ddgs_kwargs = {"timeout": SEARCH_TIMEOUT}
proxy_url = self._get_proxy_url(settings.PROXY)
@@ -180,26 +420,35 @@ class SearchWebTool(MoviePilotTool):
try:
with DDGS(**ddgs_kwargs) as ddgs:
ddgs_gen = ddgs.text(query, max_results=max_results)
if ddgs_gen:
for result in ddgs_gen:
ddgs_results = ddgs.text(
query,
max_results=max_results,
backend=self._get_ddgs_backend(search_engine),
)
if ddgs_results:
for result in ddgs_results:
source = (
DEFAULT_SEARCH_ENGINE
if search_engine == DEFAULT_SEARCH_ENGINE
else search_engine
)
results.append(
{
"title": result.get("title", ""),
"snippet": result.get("body", ""),
"url": result.get("href", ""),
"source": "DuckDuckGo",
"snippet": self._extract_result_snippet(result),
"url": self._extract_result_url(result),
"source": self._source_label(source),
}
)
except Exception as err:
logger.warning(f"DuckDuckGo search process failed: {err}")
logger.warning(f"搜索引擎搜索进程失败: {err}")
return results
loop = asyncio.get_running_loop()
return 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:
logger.warning(f"DuckDuckGo 搜索失败: {e}")
logger.warning(f"搜索引擎搜索失败: {e}")
return []
@staticmethod

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
@@ -15,8 +14,10 @@ from app.schemas import Notification, NotificationType
class SendVoiceMessageInput(BaseModel):
"""发送语音消息工具输入。"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why a voice reply is the best fit in the current context",)
explanation: Optional[str] = Field(
None,
description="Clear explanation of why a voice reply is the best fit in the current context",
)
message: str = Field(
...,
description="The spoken content to send back to the user",
@@ -24,24 +25,35 @@ class SendVoiceMessageInput(BaseModel):
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]:
"""生成语音回复工具的执行提示。"""
message = kwargs.get("message") or ""
if len(message) > 40:
message = message[:40] + "..."
return f"发送语音回复: {message}"
async def run(self, message: str, **kwargs) -> str:
"""合成语音并发送到当前对话渠道,不支持时回退为文字。"""
if not message:
return "语音回复内容不能为空"
@@ -59,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:
@@ -69,11 +82,8 @@ class SendVoiceMessageTool(MoviePilotTool):
fallback_reason = "当前未配置可用的语音合成能力"
logger.info(
"执行工具: %s, channel=%s, use_voice=%s, text_len=%s",
self.name,
channel,
used_voice,
len(message),
f"执行工具: {self.name}, channel={channel}, "
f"use_voice={used_voice}, text_len={len(message)}"
)
await ToolChain().async_post_message(

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

Some files were not shown because too many files have changed in this diff Show More