Compare commits

..

295 Commits

Author SHA1 Message Date
jxxghp
48f4bd5f18 更新 version.py 2026-08-05 07:49:58 +08:00
jxxghp
7a2b003d02 fix(media): restore legacy discover detail fallback 2026-08-05 07:27:44 +08:00
jxxghp
a700ba9379 feat(download): persist poster and backdrop images (#6232) 2026-08-03 21:38:26 +08:00
jxxghp
36dd381260 feat(download): expose source site for active tasks (#6231) 2026-08-03 18:40:08 +08:00
jxxghp
f25ee25bb0 fix(indexer): restore YemaPT search page size 2026-08-03 18:10:43 +08:00
jxxghp
d625196b08 chore(deps): upgrade cloakbrowser to 0.5.3 2026-08-03 13:49:15 +08:00
jxxghp
b1b6a81cef fix(jellyfin): support multiple authentication versions 2026-08-03 11:59:58 +08:00
jxxghp
c6b94d4908 更新 version.py 2026-08-03 09:13:19 +08:00
jxxghp
52ca375f3d fix(history): stabilize download history pagination 2026-08-03 08:45:35 +08:00
jxxghp
d8adb4fbfe fix(u115): handle missing path response 2026-08-03 08:16:45 +08:00
mustangpt
51d2ed1200 feat: support YemaPT Open API (#6227) 2026-08-03 07:01:21 +08:00
thelinyue
702801d0dc fix(subscribe): seerr 端点电影订阅不传季号,避免误判为剧集(S00) (#6226) 2026-08-02 21:06:00 +08:00
jxxghp
3e32eab98f fix(feishu): 媒体列表显示海报 (#6224) 2026-08-02 15:49:32 +08:00
cyt-666
4292678672 fix(emby): fall back to configured user (#6222) 2026-08-02 14:16:57 +08:00
InfinityPacer
7c3f9629bf fix(subscribe): scope duplicate checks by episode group (#6219) 2026-08-01 18:40:29 +08:00
jxxghp
93761fe7e4 优化 Agent 代码编辑工具 (#6218) 2026-08-01 09:02:05 +08:00
jxxghp
593139faac fix(agent): 移除插件源码写入限制 2026-07-31 22:40:50 +08:00
cyt-666
6c89f1eb4b fix: 修复 MCP 动态加载插件工具 (#6214) 2026-07-31 22:31:46 +08:00
jxxghp
2310a3a456 fix(plugin): translate rating validation error 2026-07-31 13:50:17 +08:00
jxxghp
48852350a0 feat(plugin): add centralized rating APIs 2026-07-31 13:41:29 +08:00
jxxghp
a23fce1491 feat: 统计共享媒体识别命中次数 2026-07-30 17:50:16 +08:00
jxxghp
c976741574 feat(transfer): control mounted directory cleanup 2026-07-30 17:45:13 +08:00
jxxghp
04facef64d chore(version): update app and frontend version to v2.15.2 2026-07-30 13:45:29 +08:00
jxxghp
33a97eb2c8 feat: 增加 MoviePilot 选项 (#6212) 2026-07-30 13:33:08 +08:00
Aqr-K
cf80b551f9 fix(monitor): 目录监控自愈、快照语义修正与覆盖保护闭环 (#6210) 2026-07-30 06:53:52 +08:00
jxxghp
e011b20210 add start-local.sh 2026-07-29 16:54:00 +08:00
jxxghp
bdf395f494 feat(trimemedia): 支持飞牛影视访问码
飞牛开启访问码后,需先请求 /c/{访问码} 获得会话凭证,
否则登录接口与图片等资源均返回 404。

- Api 增加 access_code 参数及 verify_access_code(),登录前先校验
- 创建 API 连接时校验访问码,失败则关闭会话
- 图片 Cookies 附带访问码校验凭证,避免代理图片 404

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:09:10 +08:00
jxxghp
68686bc23a fix https://github.com/jxxghp/MoviePilot/issues/6206
fix https://github.com/jxxghp/MoviePilot/issues/6205
2026-07-28 19:01:55 +08:00
Aqr-K
1a528c7803 fix(http): AsyncRequestUtils 代理请求按 host 熔断降级 h2 隧道 (#6208) 2026-07-28 18:54:16 +08:00
jxxghp
6b4a255f26 feat: 重命名 auth_bridge.py 为 auth.py (#6207)
Co-authored-by: traeagent <traeagent@users.noreply.github.com>
2026-07-28 14:56:33 +08:00
InfinityPacer
1a3c1b8b39 fix(system): derive image proxy MIME from content (#6202) 2026-07-27 14:56:46 +08:00
InfinityPacer
8788dae34b revert(login): remove anonymous wallpaper proxy (#6201) 2026-07-27 13:48:52 +08:00
InfinityPacer
bb00814d7a feat(login): 提供无状态签名同源壁纸代理 (#6200) 2026-07-27 12:13:38 +08:00
jxxghp
3d55d44457 fix: ensure media_id is populated in popular subscribes API
- Add media_id field assignment in popular_subscribes endpoint
- Fixes duplicate display issue in frontend by providing complete media identity data

Closes #6199
2026-07-27 09:42:54 +08:00
jxxghp
7a5e565b15 fix(storage): 保留目标目录继承权限 (#6189) 2026-07-26 09:12:35 +08:00
jxxghp
cae28d8c03 fix(transfer): 支持显式重新整理历史记录 2026-07-26 08:59:57 +08:00
jxxghp
3d020c8ceb fix(search): keep large SSE searches connected (#6186) 2026-07-26 08:55:34 +08:00
jxxghp
1b065cc08b fix(agent): apply thinking level and temperature settings 2026-07-26 08:25:00 +08:00
yubanmeiqin9048
a1a6376adc feat: add async plugin data interfaces (#6188) 2026-07-25 14:24:47 +08:00
InfinityPacer
197a09b2a4 fix(dashboard): distinguish empty media results (#6187) 2026-07-25 14:24:25 +08:00
jxxghp
8d099b9581 fix(subscribe): preserve source identity when adding subscriptions 2026-07-25 08:51:30 +08:00
jxxghp
ff9ba79b60 Codex/fix season subscription poster (#6185) 2026-07-25 08:07:08 +08:00
jxxghp
6354a48405 fix(media): fall back to main season poster (#6184) 2026-07-25 07:57:27 +08:00
G0m3e
f6df6cc093 fix(indexer): read HDDolby site message body via NexusPHP web API (#6180) 2026-07-24 12:12:48 +08:00
jxxghp
98e69d1b45 refactor(auth): merge passkey challenge helper (#6179) 2026-07-24 07:05:25 +08:00
InfinityPacer
4b9af5b8c7 fix(auth): secure passkey challenge transactions (#6178) 2026-07-24 06:20:34 +08:00
qqcomeup
059a50f7f8 fix(docker): repair helper resource permissions (#6177) 2026-07-24 06:19:41 +08:00
jxxghp
14fed2d70b 更新 version.py 2026-07-23 17:53:24 +08:00
jxxghp
875984ad39 feat: support torrent description filtering (#6172) 2026-07-23 17:09:45 +08:00
jxxghp
297cd04fbc fix: deduplicate SunnyPT site messages 2026-07-23 14:12:41 +08:00
jxxghp
3dde94be0f feat: support SunnyPT API indexer 2026-07-23 14:04:07 +08:00
jxxghp
98ee939236 feat: allow access to AniList domain in image proxy configuration 2026-07-22 18:10:43 +08:00
zangse
c6611f6210 fix(system): add opt-in Btrfs FSID space deduplication (#6141) 2026-07-22 17:55:17 +08:00
InfinityPacer
503ee90c0c fix(cache): align subscribe share region capacity (#6173) 2026-07-22 17:54:23 +08:00
jxxghp
fb32c59713 feat: add AniList rankings and discovery 2026-07-22 12:53:58 +08:00
jxxghp
a3c90c64ca refactor: remove unused result handling and related test for empty task results 2026-07-22 08:15:19 +08:00
Ekko
de97cb3c0a test: 删除重复的逐键 TTL 缓存测试 (#6161) (#6168) 2026-07-21 21:51:09 +08:00
jxxghp
3b709b7f2e feat: unify media source identity flow (#6129) 2026-07-21 20:20:09 +08:00
jxxghp
6f8b6cfbc9 feat: support explicit media ids for file scraping 2026-07-21 12:57:26 +08:00
jxxghp
e3f80af74f feat: expand metadata sources and media server sync (#6129) 2026-07-21 11:34:18 +08:00
jxxghp
1d708870c9 fix: avoid blocking plugins on pre-existing pip errors 2026-07-21 08:25:03 +08:00
jxxghp
053e1b7562 fix: handle files in newly moved monitor directories 2026-07-21 08:22:46 +08:00
Ekko
4ca3e40507 修复共享内存缓存的逐键 TTL (#6161)
MemoryBackend 原先为每个 region 保存一个 cachetools.TTLCache,导致首次写入的 TTL 成为后续所有 key 的统一生命周期。p115strmhelper 等动态 TTL 调用方因此可能把短期有效的 URL 缓存数天。

改用 TLRUCache 为每次写入独立计算过期时间,同时保留同步与异步 backend 共享 region、缓存值原样返回等既有行为。

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: jxxghp <jxxghp@gmail.com>
2026-07-21 08:10:20 +08:00
jxxghp
318d2ab7d7 feat: 修复单元测试失败 (#6165)
Co-authored-by: traeagent <traeagent@users.noreply.github.com>
2026-07-21 07:41:34 +08:00
Ekko
7c2390908a Fix/memory cache ttl (#6160) 2026-07-21 07:08:21 +08:00
InfinityPacer
5c2b503a74 fix(subscribe): protect runtime facts in public writes (#6157) 2026-07-20 16:44:49 +08:00
jxxghp
44fa202778 fix: map Bangumi movie subjects correctly (#6155) 2026-07-20 16:16:25 +08:00
jxxghp
ed92be08af fix(transfer): support nested download categories (#6156) 2026-07-20 16:14:17 +08:00
jxxghp
9ed0704c5b fix: 重识别时清理旧下载器上下文 2026-07-20 15:35:56 +08:00
jxxghp
e46b4e5ba0 fix: 重新整理时忽略旧下载哈希 (#6154) 2026-07-20 15:31:54 +08:00
jxxghp
87ad7988b2 Merge remote-tracking branch 'origin/v2' into v2 2026-07-20 15:19:49 +08:00
jxxghp
1382975b18 Centralize optional dependency stubs in test bootstrap 2026-07-20 14:53:36 +08:00
InfinityPacer
d9a42c672a fix(subscribe): refine TV wash priority lifecycle (#6153) 2026-07-20 14:02:19 +08:00
jxxghp
b042086efa test(indexer): anonymize TorrentLeech fixture 2026-07-20 13:49:02 +08:00
jxxghp
36fefa14e0 fix(indexer): repair TorrentLeech user data parsing 2026-07-20 13:46:25 +08:00
jxxghp
1332576c3f fix(download): accept legacy remote subscription paths (#6147) 2026-07-20 13:42:27 +08:00
jxxghp
4300af0e9c chore: update version to v2.14.6 2026-07-20 12:58:41 +08:00
jxxghp
405350c774 Preserve plugin diagnostics without degrading overall status 2026-07-20 10:15:15 +08:00
jxxghp
d666134ed2 Route autonomous agent tasks through notification broadcasting 2026-07-20 08:54:32 +08:00
InfinityPacer
5588e37c6d fix: preserve season zero semantics (#6150) 2026-07-20 06:55:23 +08:00
InfinityPacer
2056aa0b2c fix(startup): keep systemd autostart services alive (#6149) 2026-07-20 06:54:09 +08:00
jxxghp
b0ff3ae3c7 Fix Agent task relative-time validation (#6146) 2026-07-19 21:07:27 +08:00
jxxghp
31544629b4 Add autonomous Agent task scheduling 2026-07-19 20:19:06 +08:00
InfinityPacer
142393f2d3 fix(subscribe): reset manual episode tracking (#6144) 2026-07-19 17:36:54 +08:00
Xuanjie Xia
5cf79e0360 feat(site): 模拟登录支持勾选「记住我/保持登录」选项,获取长期会话 (#6138) 2026-07-18 19:24:18 +08:00
Xuanjie Xia
f152a0381d fix(site): 模拟登录适配JS渲染(SPA)登录页 (#6137) 2026-07-18 16:47:24 +08:00
InfinityPacer
428c19b6ba fix(download): restore classification for configured roots (#6135) 2026-07-18 09:35:25 +08:00
jxxghp
8b5524a321 更新 version.py 2026-07-17 14:50:58 +08:00
jxxghp
b972b46747 Merge remote-tracking branch 'origin/v2' into v2 2026-07-17 09:47:01 +08:00
jxxghp
0598fbdd75 fix(media): add support for custom words in media recognition 2026-07-17 09:46:55 +08:00
freeman
572299a45e feat(llm): 新增 Amazon Bedrock 提供商,支持 AK/SK 与 Bedrock API Key 双认证 (#6130) 2026-07-16 19:27:06 +08:00
kuke2733
229824a417 feat(subscribe): merge mediaserver library entries into files info (#6131) 2026-07-16 17:36:56 +08:00
DDSRem
a0ee99aacc chore: bump moviepilot-rust to 0.2.3 (#6128) 2026-07-16 06:31:31 +08:00
InfinityPacer
92918ce380 ci(pr-agent): use shared review runner (#6127) 2026-07-16 06:24:52 +08:00
InfinityPacer
a4335fe753 fix(lifecycle): harden application shutdown (#6125) 2026-07-16 06:24:31 +08:00
jxxghp
107ba37834 更新 version.py 2026-07-15 20:22:27 +08:00
jxxghp
c27678ce06 fix(ugreen): send client id during login 2026-07-15 17:38:31 +08:00
InfinityPacer
7725342a80 fix(scheduler): refresh plugin jobs after reload (#6124) 2026-07-15 17:29:31 +08:00
InfinityPacer
893269f8c1 fix(modules): serialize configuration reload lifecycle (#6122) 2026-07-15 17:28:49 +08:00
InfinityPacer
00d46f3aab docs: clarify docstring punctuation style (#6121) 2026-07-15 16:01:47 +08:00
jxxghp
077241b6ed Merge remote-tracking branch 'origin/v2' into v2 2026-07-15 10:46:28 +08:00
jxxghp
b24a07e388 fix: enhance response data structure in filtering rules with media info 2026-07-15 10:46:21 +08:00
InfinityPacer
f814c271cc refactor(runtime): tighten resource cleanup and test isolation (#6116) 2026-07-14 16:03:29 +08:00
InfinityPacer
e015c67689 chore(db): add driver error diagnostics (#6115) 2026-07-14 12:31:46 +08:00
qqcomeup
98b16bda8d 优化 Docker 启动完成日志 (#6112) 2026-07-14 12:31:07 +08:00
jxxghp
b8233e1789 v2.14.3 2026-07-13 18:46:35 +08:00
InfinityPacer
83107bf447 ci(pr-agent): publish native code reviews (#6110) 2026-07-13 18:41:18 +08:00
jxxghp
3a2f90c567 fix(metainfo): improve regex for episode range recognition with end markers 2026-07-13 18:02:44 +08:00
DDSRem
4826e3301c chore: bump moviepilot-rust to 0.2.2 (#6109) 2026-07-13 18:00:50 +08:00
freeman
1855ba81ec fix(meta): 副标题识别 01-26Fin 等数字范围完结标记集数 (#6105) 2026-07-13 16:50:00 +08:00
jxxghp
96ef431efc feat: 支持豆瓣识别缓存管理 2026-07-13 12:33:56 +08:00
freeman
4f2935c85e fix(transfer): 种子未下载完成时不回写已整理标签 (#6106) 2026-07-13 12:05:01 +08:00
freeman
2a49495e27 fix(jellyfin): 媒体统计改为按用户视图逐库累计 (#5915) (#6104) 2026-07-13 11:51:16 +08:00
jxxghp
b628bc7209 fix: 补齐识别缓存多语言响应 2026-07-13 09:58:11 +08:00
jxxghp
29068a5846 feat: 支持 TheMovieDb 识别缓存管理 2026-07-13 09:48:08 +08:00
jxxghp
51a7120c79 完善 qBittorrent 临时标签清理 (#6093) 2026-07-12 16:51:46 +08:00
jxxghp
476dfef7d9 修复 qBittorrent 重复任务临时标签残留 (#6093) 2026-07-12 16:47:12 +08:00
jxxghp
bd5ddd6158 fix: remove standalone site collector download section from README 2026-07-12 16:38:53 +08:00
jxxghp
a30a48b8f4 fix: allow publishing collector artifacts manually 2026-07-12 16:37:05 +08:00
jxxghp
8e60e5571b fix: support Windows collector console encoding 2026-07-12 16:30:30 +08:00
jxxghp
18c1ec4b82 feat: add standalone site adapter collector 2026-07-12 13:51:07 +08:00
InfinityPacer
30b932e07e fix(subscribe): preserve confirmed episode floor (#6102) 2026-07-12 07:18:29 +08:00
InfinityPacer
54be1143fc feat(plugin): sync federated assets during local development (#6100) 2026-07-11 21:59:53 +08:00
秋澪Akimio
13f27854fd fix: clear Rust parse options cache after updating custom identifiers (#6097) 2026-07-11 18:15:26 +08:00
InfinityPacer
770201c48c fix(plugin): exclude build dependencies from runtime copies (#6096) 2026-07-11 18:15:00 +08:00
Xuanjie Xia
685f044312 fix: 模拟登录时页面跳转导致 page.content() 竞态失败(未知错误) (#6091) 2026-07-10 12:44:36 +08:00
qqcomeup
8c0afac5d1 feat: support prompt-bound plugin input replies (#6087) 2026-07-09 12:52:22 +08:00
qqcomeup
099ef7d5bf fix: avoid blocking plugin release history refresh (#6084) 2026-07-08 12:51:24 +08:00
InfinityPacer
f3ac69669c ci(pr-agent): simplify review workflow (#6082) 2026-07-08 12:49:19 +08:00
jxxghp
eb4ecd990a fix: restore full test suite 2026-07-08 08:54:53 +08:00
jxxghp
b51971ee7d feat: add agent MCP support 2026-07-08 08:44:33 +08:00
InfinityPacer
6f6ed998bb ci(pr-agent): align inline review workflow (#6079) 2026-07-08 07:04:39 +08:00
drdon1234
844407dc41 修复 qBittorrent 已完成但未做种任务识别 (#6076) 2026-07-08 07:01:03 +08:00
jxxghp
c54605f8ce fix: support ugreen token_id login response 2026-07-07 20:15:19 +08:00
qqcomeup
0fbf05d72f fix: handle Telegram urllib3 header formatter compatibility (#6074) 2026-07-07 19:58:49 +08:00
jxxghp
09bb32f681 fix: cool down failed subscription resources 2026-07-07 17:07:13 +08:00
qqcomeup
a37f118576 perf(docker): skip image path chown by default (#6071) 2026-07-07 16:25:43 +08:00
jxxghp
e635bc8e04 chore: bump version to v2.14.2 2026-07-07 12:48:23 +08:00
jxxghp
8245124e82 support scheduler progress i18n (#6069) 2026-07-07 06:04:45 +08:00
qqcomeup
827ed8330c feat: add plugin-scoped text input sessions (#6068) 2026-07-07 05:15:18 +08:00
jxxghp
136c1baed3 fix: keep new subscribe state during guard 2026-07-06 23:10:25 +08:00
jxxghp
992031ef95 Revert "fix: trigger initial subscribe search"
This reverts commit 76803ae7a3.
2026-07-06 19:15:16 +08:00
jxxghp
b16c50b03a feat: add backend i18n response support 2026-07-06 19:13:50 +08:00
jxxghp
76803ae7a3 fix: trigger initial subscribe search 2026-07-06 19:02:01 +08:00
jxxghp
56bda11947 fix python metainfo xxx title parsing 2026-07-06 12:36:06 +08:00
jxxghp
1b12d7664e fix: avoid cnsub matching file size unit 2026-07-06 11:58:03 +08:00
jxxghp
db9960d9b9 refactor: enhance customization and streaming platform handling 2026-07-06 09:33:49 +08:00
InfinityPacer
2f0c1252da feat(subscribe): expose cached site candidates (#6062) 2026-07-06 06:54:19 +08:00
jxxghp
36d4434596 fix: allow manage users to access management APIs 2026-07-05 20:23:04 +08:00
jxxghp
93e907d032 fix: remove openlist directory via fs remove 2026-07-05 17:24:44 +08:00
jxxghp
132f27c1c6 fix: retry qbittorrent files after add 2026-07-05 17:15:07 +08:00
jxxghp
b231ad415f fix title search filter rules 2026-07-05 17:02:19 +08:00
jxxghp
0f183ae08e test: fix download save path fixtures 2026-07-05 14:56:19 +08:00
jxxghp
a71d3ea03f feat: support llm temperature override 2026-07-05 13:04:51 +08:00
InfinityPacer
7f82a9ea4d fix(security): scope subscriptions to owner (#6056) 2026-07-05 09:45:27 +08:00
InfinityPacer
d977e4c48a fix(security): require signed subtitle downloads (#6055) 2026-07-05 09:43:58 +08:00
InfinityPacer
95b6adbeee fix(security): restrict download save paths (#6054) 2026-07-05 09:31:01 +08:00
InfinityPacer
964fee1106 fix(security): protect cookiecloud update uploads (#6053) 2026-07-05 09:28:07 +08:00
InfinityPacer
656473f3aa fix(security): require manage access for workflows (#6052) 2026-07-05 09:27:24 +08:00
InfinityPacer
ab5995a609 fix(security): contain plugin zip extraction (#6051) 2026-07-05 09:26:09 +08:00
InfinityPacer
064e6535d5 fix(security): restrict agent log file access (#6050) 2026-07-05 09:25:25 +08:00
InfinityPacer
cab2ac400a fix(security): rebind resource cookie on user changes (#6049) 2026-07-05 09:24:27 +08:00
InfinityPacer
d14d401c86 docs(security): clarify api token admin contract (#6048) 2026-07-05 09:22:59 +08:00
Album
6c3c5e042d 修复手动整理按集数定位模板过滤 (#6043) 2026-07-03 07:55:59 +08:00
Copilot
f3e5be37fd chore: bump backend and frontend versions (#6042) 2026-07-02 20:50:15 +08:00
jxxghp
d8f7fa70af fix: normalize question marks in title matching 2026-07-02 08:51:16 +08:00
jxxghp
6916ee0988 修复电影合集整理识别错误 2026-07-02 08:06:44 +08:00
LeChristopher Blackwell
6fef533527 fix(security): honor X-Forwarded-Proto when setting resource_token cookie secure flag (#6038) 2026-07-01 22:00:22 +08:00
G0m3e
c57985d553 fix(webpush): add WNS cache policy for Windows Edge push (#6034)
WNS rejects pywebpush default ttl=0 with 400 Bad Request unless X-WNS-Cache-Policy matches TTL; iOS/APNs endpoints are unaffected.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 10:45:24 +08:00
InfinityPacer
ec07379a67 fix(subscribe): record movie completion priority (#6033) 2026-07-01 06:01:32 +08:00
InfinityPacer
2764742b86 fix(plugin): reset stopped plugin config and data (#6031) 2026-06-30 22:37:07 +08:00
jxxghp
a0f613fa1e chore(version): update app and frontend versions to v2.14.0 2026-06-30 20:32:17 +08:00
jxxghp
73d5c95f4e Fix Telegram edit fallback 2026-06-29 22:48:13 +08:00
jxxghp
4d30dee74c feat(mediaserver): add item count to media library details across multiple servers 2026-06-29 21:37:58 +08:00
Pollo3470
302d8bbf5c fix(transfer): 修复订阅自定义识别词在整理时失效 (#6018) 2026-06-29 15:49:02 +08:00
jxxghp
b646cbb4f6 Merge remote-tracking branch 'origin/v2' into v2 2026-06-29 12:55:19 +08:00
jxxghp
dd73b97095 fix(base): improve permission error message for tool access 2026-06-29 12:55:14 +08:00
InfinityPacer
0cb0bac0e1 fix(llm): sanitize model endpoint errors (#6021) 2026-06-29 11:19:38 +08:00
jxxghp
9eb71c744b feat(mediaserver): add item count retrieval for various media servers 2026-06-29 10:45:10 +08:00
jxxghp
8bf826faa0 fix(dashboard): report application memory accurately 2026-06-29 07:37:03 +08:00
jxxghp
df4e45c644 Merge remote-tracking branch 'origin/v2' into v2 2026-06-29 07:08:08 +08:00
jxxghp
494f809ef0 Refine transfer history batch actions 2026-06-29 07:07:33 +08:00
InfinityPacer
a4f6e13881 fix(transfer): expose file meta in intercept event (#6019) 2026-06-29 06:44:40 +08:00
InfinityPacer
36fb82b7aa feat(subscribe): record episode download facts (#6015) 2026-06-29 06:43:51 +08:00
jxxghp
9b1bdb0cb2 feat(dashboard): add system summary endpoint and monthly media statistics 2026-06-28 17:49:09 +08:00
DDSRem
2a89bfd25c chore: bump moviepilot-rust to 0.1.14 (#6016)
Co-authored-by: jxxghp <51039935+jxxghp@users.noreply.github.com>
2026-06-28 17:31:00 +08:00
jxxghp
d3d1c18316 chore: bump application and frontend versions to v2.13.16 2026-06-28 15:51:04 +08:00
jxxghp
027330f714 fix: disable notification history saving for agent messages 2026-06-28 15:25:52 +08:00
jxxghp
6fc1c672ad Merge remote-tracking branch 'origin/v2' into v2 2026-06-28 08:32:12 +08:00
InfinityPacer
d383c9ffd1 feat(subscribe): add modified event payload contract (#6012) 2026-06-28 07:48:25 +08:00
jxxghp
27e1f634cb fix: add total episodes to transfer notifications 2026-06-27 22:45:17 +08:00
jxxghp
a9197c434e Skip download dir selection for single matches 2026-06-27 22:29:55 +08:00
jxxghp
2a8708498c Simplify agent tool schema explanation test 2026-06-27 22:21:45 +08:00
jxxghp
098db56a36 fix: improve web agent stream recovery 2026-06-27 15:09:41 +08:00
jxxghp
a8ccb08dd3 更新 config.py 2026-06-27 08:52:53 +08:00
jxxghp
ff7a238309 Merge remote-tracking branch 'origin/v2' into v2 2026-06-27 07:55:19 +08:00
jxxghp
318cfe68e9 Refactor agent tool inputs and background activity logging 2026-06-27 07:53:09 +08:00
jxxghp
0f98cda3b4 更新 version.py 2026-06-26 19:22:22 +08:00
InfinityPacer
544ed6d84d fix: recognize HDR Vivid resources (#6007) 2026-06-26 14:41:41 +08:00
jxxghp
bb9b6ec5d0 fix: support custom episode offset expressions 2026-06-26 14:15:29 +08:00
jxxghp
7d2a730b0c feat(web-agent): enhance message handling with edit capabilities and button descriptions 2026-06-26 13:44:49 +08:00
jxxghp
1b6a548dee feat(monitor): enhance file handling with transfer history checks 2026-06-26 07:02:59 +08:00
InfinityPacer
52c5f2900f fix(rss): decode XML feeds before parsing (#6004) 2026-06-26 06:37:18 +08:00
InfinityPacer
43e89ebf77 fix: respect explicit subscribe best version settings (#6001)
* fix(subscribe): respect explicit best version settings

* fix(subscribe): isolate delete event failures
2026-06-25 11:31:13 +08:00
xiaoQQya
bc52653ec1 fix: 修复观众加入日期获取问题 (#5999) 2026-06-24 20:47:23 +08:00
jxxghp
4233ebfba6 Persist notification clear markers 2026-06-24 16:58:08 +08:00
jxxghp
af32c5e9bb chore: bump moviepilot-rust to 0.1.11 2026-06-24 14:49:30 +08:00
InfinityPacer
cd02e55879 ci: review PRs with PR-Agent (#5995) 2026-06-24 06:09:55 +08:00
InfinityPacer
d49c57b11e ci: add PR-Agent review workflow (#5994) 2026-06-23 23:21:21 +08:00
InfinityPacer
724b4a59d5 fix(docker): scope package proxy env (#5991)
* Revert "Add NO_PROXY defaults to Docker proxy setup"

This reverts commit 109be58abe.

* fix(docker): scope package proxy env
2026-06-23 20:47:43 +08:00
jxxghp
109be58abe Add NO_PROXY defaults to Docker proxy setup 2026-06-23 20:22:40 +08:00
jxxghp
c1d8676b25 release v2.13.14 2026-06-23 16:34:45 +08:00
jxxghp
c1cefa3f40 Rename package installer helper to package 2026-06-23 13:45:55 +08:00
InfinityPacer
0c53fb86fd feat(deps): add uv-backed package installer (#5987)
* feat(deps): add uv-backed package installer

* feat(deps): support package cache root
2026-06-23 13:36:15 +08:00
jxxghp
126279c63b Handle Audiences new message list previews 2026-06-23 12:54:13 +08:00
jxxghp
d7aa66853a fix: 优化同名字幕附加整理 2026-06-23 12:07:47 +08:00
jxxghp
d18c2d6f72 fix: skip history for interaction exit notices 2026-06-23 12:05:27 +08:00
jxxghp
3e3883a57f Fix history search case sensitivity 2026-06-23 11:16:12 +08:00
jxxghp
2ebe7c27c2 Add repo_url backfill for installed plugin queries 2026-06-23 10:49:12 +08:00
jxxghp
0cd049bfc2 Refactor movie pilot config and test coverage 2026-06-23 10:05:45 +08:00
jxxghp
dc773337d3 fix: bind browser sessions to dedicated worker threads 2026-06-22 22:18:12 +08:00
jxxghp
5c649ff1d1 fix(search): suppress linter warning for SitesHelper import 2026-06-22 21:53:41 +08:00
jxxghp
3407cc8edd Preserve empty tool selections 2026-06-22 21:29:15 +08:00
jxxghp
f9ea0118d9 Handle Telegramify 1.2 compatibility 2026-06-22 20:16:37 +08:00
jxxghp
ad73434e2c fix(agent): stabilize tool selector logging 2026-06-22 19:06:14 +08:00
jxxghp
a6afa0fbc0 fix(agent): log tool selector results 2026-06-22 18:48:04 +08:00
jxxghp
3306d196b7 Refine existing implementation 2026-06-22 18:21:20 +08:00
InfinityPacer
e44a6f41b5 chore: split runtime and development dependencies (#5985) 2026-06-22 17:44:06 +08:00
jxxghp
7a7b27858e Remove redundant Cython Docker install 2026-06-22 14:06:19 +08:00
jxxghp
cf8e7438e2 fix: skip history for agent choice prompts 2026-06-22 07:07:54 +08:00
InfinityPacer
647c04956d fix: preserve subscribe season zero targets (#5983) 2026-06-22 06:36:03 +08:00
jxxghp
7358b4df14 Record streaming summaries for agent tool calls 2026-06-21 21:43:44 +08:00
jxxghp
3c74f1bf58 Add Rust filter trace logging support 2026-06-21 21:25:50 +08:00
jxxghp
8938ae7baa Fix tool reply handling to persist raw agent messages 2026-06-21 20:51:35 +08:00
jxxghp
1f965f5948 Bump application and frontend version to v2.13.13 2026-06-21 20:39:39 +08:00
jxxghp
8b05decc2d Harden agent chat title generation 2026-06-21 20:35:58 +08:00
jxxghp
1f97870fa9 Fix think tag streaming in agent output 2026-06-21 19:58:33 +08:00
jxxghp
78ddd6093f Improve streaming tool summary counts 2026-06-21 18:49:27 +08:00
jxxghp
6647565ec4 Refactor plugin cache handling and update related docs 2026-06-21 18:29:27 +08:00
jxxghp
43d1abdec8 fix: skip agent memory after tool replies 2026-06-21 13:52:36 +08:00
jxxghp
ce51a20bdb refactor: convert _stream_agent_tokens to a static method 2026-06-21 13:43:36 +08:00
jxxghp
6c45ade813 refactor: remove unnecessary user reply check in session history logic 2026-06-21 13:17:00 +08:00
jxxghp
90efb204a1 refactor: expose text content extraction helper 2026-06-21 12:56:23 +08:00
jxxghp
495807ef4d Simplify agent message handling and streaming cleanup 2026-06-21 12:34:11 +08:00
jxxghp
d483b805d8 Normalize Telegram HTML send_message responses 2026-06-21 11:39:53 +08:00
jxxghp
5c1b303908 Add Telegram parse_mode support for send_message 2026-06-21 10:57:26 +08:00
jxxghp
683e07a102 Add Telegram parse mode support 2026-06-21 10:20:30 +08:00
jxxghp
68f18db374 Handle invalid UTF-8 with replacement decoding 2026-06-21 09:56:34 +08:00
jxxghp
99e369aaa4 Refine agent skill boundaries and secret handling 2026-06-21 09:25:44 +08:00
jxxghp
18803c7995 Merge remote-tracking branch 'origin/v2' into v2 2026-06-21 08:18:19 +08:00
InfinityPacer
6b21abd547 add: AnySearch-Skill 2026-06-21 08:17:32 +08:00
InfinityPacer
f0368e359a fix(agent): clean old media file before AI redo (#5980) 2026-06-21 07:39:39 +08:00
InfinityPacer
e02cebe16c fix(tests): stabilize messaging shutdown (#5979) 2026-06-21 07:37:47 +08:00
Album
b395d820d8 feat: 完善集数定位模板识别与自动推荐稳定性 (#5978) 2026-06-21 07:34:57 +08:00
jxxghp
970f2cf1ca fix: clean up feishu websocket shutdown 2026-06-20 15:20:15 +08:00
jxxghp
c065eddff1 fix(nginx): suppress error logs for specific locations in nginx configuration 2026-06-20 14:59:56 +08:00
jxxghp
33b18f0899 fix: parse feishu post messages 2026-06-20 13:04:55 +08:00
jxxghp
d2103f91b8 fix(message): keep auto directory matching for interactive downloads 2026-06-20 12:59:37 +08:00
jxxghp
66feacb48d feat(activity_log): simplify user text matching and enhance logging for captcha tool 2026-06-19 21:25:38 +08:00
jxxghp
7f1cb40421 feat(plugin): implement caching for plugin agent tools registry 2026-06-19 20:50:35 +08:00
jxxghp
38c3dcc76b feat(activity_log): enhance activity logging with query tool and context handling 2026-06-19 20:39:12 +08:00
jxxghp
a9534d2422 feat: add organize files skill 2026-06-19 19:40:23 +08:00
InfinityPacer
013f3bc505 fix(docker): prevent stale service worker caching (#5973) 2026-06-19 18:51:40 +08:00
jxxghp
de9fd75cac v2.13.12 2026-06-19 12:21:49 +08:00
jxxghp
570ea60096 fix(agent): prevent chat history persistence for sessions without a channel 2026-06-19 12:10:39 +08:00
jxxghp
e02650cce9 fix: skip background agent chat persistence 2026-06-19 12:05:47 +08:00
jxxghp
60c7268301 feat(config): add option to hide global AI agent entry point 2026-06-19 09:46:39 +08:00
jxxghp
e8f6e8647b feat(skill): support creating plugin publish repos 2026-06-19 09:26:16 +08:00
jxxghp
bd53598704 feat(skill): add plugin publish workflow 2026-06-19 09:07:27 +08:00
jxxghp
4d87bf8d53 fix(plugin): normalize market labels 2026-06-19 08:59:50 +08:00
InfinityPacer
84eee40e81 fix(plugin): optimize release cache loading (#5966) 2026-06-18 18:52:28 +08:00
jxxghp
d2e2435be7 docs: remove Redis Unix Socket configuration example from postgresql-setup.md 2026-06-18 18:09:03 +08:00
jxxghp
ccaeb7662c feat(redis): add configurable connection pool settings for Redis client 2026-06-18 18:07:37 +08:00
InfinityPacer
69ed70cc66 feat(plugin): support installing release versions (#5964) 2026-06-18 15:47:07 +08:00
jxxghp
80d440f6a0 feat: persist agent chat history 2026-06-18 11:45:50 +08:00
jxxghp
bfa2cf5c1f v2.13.11 2026-06-18 06:41:40 +08:00
jxxghp
5e1bdfe725 fix: exclude notification history flag from event payload 2026-06-17 21:36:59 +08:00
InfinityPacer
a516bc1c77 fix(plugin): allow dev local hot sync across system version gate (#5961) 2026-06-17 21:07:27 +08:00
jxxghp
e1ba9a2c97 feat: conditionally save message history based on save_history flag 2026-06-17 21:00:40 +08:00
DDSRem
4c20639abb chore: bump moviepilot-rust to 0.1.10 (#5960)
Co-authored-by: jxxghp <51039935+jxxghp@users.noreply.github.com>
2026-06-17 19:54:19 +08:00
InfinityPacer
4ecbc677eb feat(subscribe): expose missing target resolver (#5958)
* feat(subscribe): expose missing target resolver

* fix(subscribe): refine missing resolver semantics
2026-06-17 19:54:02 +08:00
jxxghp
846eed6821 feat: add fallback to caption editing when text edit fails in Telegram bot 2026-06-17 19:45:08 +08:00
jxxghp
cff22924b1 feat: add message download path selection 2026-06-17 19:04:41 +08:00
jxxghp
5c7c1512dd fix: preserve empty workflow filter outputs 2026-06-17 16:48:29 +08:00
jxxghp
d4b6d3f332 refactor: remove persist_output_message functionality and related database save logic 2026-06-17 16:14:35 +08:00
jxxghp
039558d240 feat: enhance WebAgent audio handling with format conversion and transcription support 2026-06-17 11:09:16 +08:00
InfinityPacer
93056ed1ff feat(plugin): add pre-reset plugin data event (#5957) 2026-06-17 06:34:15 +08:00
jxxghp
d5bac81881 test: fix agent voice message streaming tests 2026-06-16 23:40:21 +08:00
jxxghp
a077a08303 fix: improve text handling in append_text function and add test for newline preservation 2026-06-16 23:28:11 +08:00
jxxghp
e78efe3e34 feat: implement file upload and callback handling for Web Agent 2026-06-16 22:53:11 +08:00
jxxghp
e8ae686d4f feat: add WebAgent support for streaming and notifications 2026-06-16 19:21:31 +08:00
ch3njun
1c60d8ccd7 修复极影视媒体库同步不全:展开 BoxSet 合集子项 (#5954) 2026-06-16 06:35:58 +08:00
ch3njun
8ad8b5eaad fix(zspace): sync complete media library metadata (#5953) 2026-06-15 22:05:17 +08:00
nazoko
af23baec6d fix(wechat): fix voice upload file handle and Content-Type issues (#5951) 2026-06-15 19:48:11 +08:00
jxxghp
94b8252fdd fix transmission missing limit fields 2026-06-15 18:30:41 +08:00
603 changed files with 77617 additions and 10043 deletions

27
.coveragerc Normal file
View File

@@ -0,0 +1,27 @@
[run]
branch = True
source = app
omit =
app/plugins/*/*
app/testing/*
app/helper/sites.py
[report]
show_missing = True
skip_empty = True
precision = 2
exclude_lines =
pragma: no cover
if TYPE_CHECKING:
if __name__ == .__main__.:
raise NotImplementedError
pass
[html]
directory = htmlcov
[xml]
output = coverage.xml
[json]
output = coverage.json

View File

@@ -7,11 +7,13 @@ body:
attributes:
value: |
请说明你希望添加的功能。
站点适配请求请先按 [站点适配采集说明](https://github.com/jxxghp/MoviePilot/blob/v2/docs/site-adapter-capture.md) 生成脱敏 ZIP并在下方附加。Issue 及附件是公开内容,提交前必须解压预览四个文件。不要上传 Cookie、Authorization、通行密钥、会话字段或任何原始数据。
- type: input
id: version
attributes:
label: 当前程序版本
description: 目前使用的程序版本
description: 目前使用的程序版本;仅提供站点采集文件且未安装 MoviePilot 时填写“不适用”
validations:
required: true
- type: dropdown
@@ -22,6 +24,9 @@ body:
options:
- Docker
- Windows
- macOS
- Linux
- 仅提供站点采集文件
validations:
required: true
- type: dropdown
@@ -32,6 +37,7 @@ body:
options:
- 主程序
- 插件
- 站点适配
- 其他
validations:
required: true
@@ -43,6 +49,14 @@ body:
placeholder: "功能改进"
validations:
required: true
- type: textarea
id: site-adapter-capture
attributes:
label: 站点适配采集文件
description: 站点适配请求必须把采集器生成并人工预览确认过的脱敏 ZIP 拖到这里Issue 附件公开,严禁附加 Cookie、原始 HTML、HAR 或浏览器网络归档。其他类型请填写“不适用”。
placeholder: "将 moviepilot-site-capture-*.zip 拖到这里;非站点适配填写:不适用"
validations:
required: true
- type: textarea
id: references
attributes:

52
.github/workflows/pr-agent.yml vendored Normal file
View File

@@ -0,0 +1,52 @@
name: PR-Agent
on:
pull_request_target:
# Fork 审查需要目标仓库凭据;该 job 仅通过 GitHub API 读取 PR 内容,不 checkout 或执行 PR 分支代码。
types:
- opened
- reopened
- ready_for_review
- review_requested
- synchronize
issue_comment:
types:
- created
- edited
permissions:
contents: read
pull-requests: write
issues: write
jobs:
pr-agent:
if: >-
github.event.sender.type != 'Bot' &&
(
github.event_name == 'pull_request_target' ||
(
github.event_name == 'issue_comment' &&
github.event.issue.pull_request != null &&
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'), github.event.comment.author_association)
)
)
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number }}
cancel-in-progress: ${{ github.event_name == 'pull_request_target' }}
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Run PR Review
uses: docker://ghcr.io/infinitypacer/pr-review-runner:latest
env:
GITHUB_TOKEN: ${{ github.token }}
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
PRR_AUTO_REVIEW_SCOPE: all
PRR_ALLOWED_ASSOCIATIONS: '["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'
PRR_DISABLED_COMMANDS: '["/improve"]'
PRR_SKIP_LABEL: skip pr-agent
PRR_SKIP_TITLE_PATTERN: '^(?:\[Auto\]|Auto)'
config.response_language: zh-CN

View File

@@ -23,24 +23,15 @@ jobs:
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt', '**/requirements.in') }}
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.in', '**/requirements-dev.in', '**/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install dependencies
run: |
python -m pip install --upgrade pip setuptools wheel
pip install pylint
# 安装项目依赖
if [ -f requirements.txt ]; then
echo "📦 安装 requirements.txt 中的依赖..."
pip install -r requirements.txt
elif [ -f requirements.in ]; then
echo "📦 安装 requirements.in 中的依赖..."
pip install -r requirements.in
else
echo "⚠️ 未找到依赖文件,仅安装 pylint"
fi
# Pylint 属于开发/静态检查依赖,统一通过 dev 入口安装。
pip install -r requirements-dev.in
- name: Verify pylint config
run: |
@@ -88,4 +79,4 @@ jobs:
run: |
echo "🎉 Pylint 检查完成!"
echo "✅ 没有发现语法错误或严重问题"
echo "📊 详细报告已保存为构建工件"
echo "📊 详细报告已保存为构建工件"

View File

@@ -0,0 +1,134 @@
name: Site Adapter Collector
on:
workflow_dispatch:
inputs:
release_tag:
description: Existing release tag to receive collector assets; leave empty for artifacts only
required: false
type: string
release:
types:
- published
permissions:
contents: read
jobs:
build:
name: Build ${{ matrix.platform_name }} collector
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- platform_name: Windows
platform_id: windows
runner: windows-latest
source_name: moviepilot-site-collector.exe
asset_name: moviepilot-site-collector-windows.exe
artifact_name: site-adapter-collector-windows
- platform_name: macOS
platform_id: macos
runner: macos-latest
source_name: moviepilot-site-collector
asset_name: MoviePilot-Site-Collector-macOS.zip
artifact_name: site-adapter-collector-macos
- platform_name: Linux
platform_id: linux
runner: ubuntu-latest
source_name: moviepilot-site-collector
asset_name: moviepilot-site-collector-linux
artifact_name: site-adapter-collector-linux
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.12'
cache: pip
cache-dependency-path: scripts/site_adapter_collector_requirements.txt
- name: Install build dependencies
run: |
python -m pip install --upgrade pip setuptools wheel
pip install -r scripts/site_adapter_collector_requirements.txt
- name: Build single-file collector
run: |
pyinstaller --clean --noconfirm scripts/site_adapter_collector.spec
- name: Smoke-test collector
env:
SOURCE_NAME: ${{ matrix.source_name }}
run: |
python -c "import os, subprocess; from pathlib import Path; subprocess.run([str((Path('dist') / os.environ['SOURCE_NAME']).resolve()), '--help'], check=True)"
- name: Package macOS double-click archive
if: matrix.platform_id == 'macos'
shell: bash
env:
ASSET_NAME: ${{ matrix.asset_name }}
SOURCE_NAME: ${{ matrix.source_name }}
run: |
package_dir="dist/MoviePilot-Collector"
mkdir -p "$package_dir"
cp "dist/$SOURCE_NAME" "$package_dir/moviepilot-site-collector-macos"
cp scripts/start-site-adapter-collector.command "$package_dir/start-site-adapter-collector.command"
chmod +x "$package_dir/moviepilot-site-collector-macos"
chmod +x "$package_dir/start-site-adapter-collector.command"
cd dist
COPYFILE_DISABLE=1 zip -q -r -X "$ASSET_NAME" MoviePilot-Collector
- name: Rename Windows and Linux collector
if: matrix.platform_id != 'macos'
env:
ASSET_NAME: ${{ matrix.asset_name }}
SOURCE_NAME: ${{ matrix.source_name }}
run: |
python -c "import os; from pathlib import Path; (Path('dist') / os.environ['SOURCE_NAME']).replace(Path('dist') / os.environ['ASSET_NAME'])"
- name: Generate SHA-256 checksum
env:
ASSET_NAME: ${{ matrix.asset_name }}
run: |
python -c "import hashlib, os; from pathlib import Path; path = Path('dist') / os.environ['ASSET_NAME']; path.with_name(path.name + '.sha256').write_text(f'{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}\n', encoding='utf-8')"
- name: Upload collector artifact
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.artifact_name }}
path: |
dist/${{ matrix.asset_name }}
dist/${{ matrix.asset_name }}.sha256
if-no-files-found: error
retention-days: 3
publish:
name: Upload collectors to release
if: github.event_name == 'release' || inputs.release_tag != ''
needs:
- build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download collector artifacts
uses: actions/download-artifact@v8
with:
pattern: site-adapter-collector-*
path: release-assets
merge-multiple: true
- name: Upload assets to published release
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }}
run: |
gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null
gh release upload "$RELEASE_TAG" release-assets/* --clobber --repo "$GITHUB_REPOSITORY"

View File

@@ -22,34 +22,49 @@ jobs:
pytest:
runs-on: ubuntu-latest
name: Unit Tests
timeout-minutes: 20
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: '3.12'
cache: 'pip'
- name: Cache pip dependencies
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.in', '**/requirements.txt') }}
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.in', '**/requirements-dev.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
# 单测需要开发/测试依赖;运行时入口 requirements.in 不携带测试与构建辅助工具。
pip install -r requirements-dev.in
- name: Run tests
timeout-minutes: 10
run: |
# tests/run.py 以 pytest 跑 tests 全量tests/conftest.py 在收集前把 CONFIG_DIR
# 指向临时库并建表,测试杜绝真实网络/外部服务(详见 docs/testing.md
python tests/run.py
# 指向临时库并建表CI 额外生成覆盖率报告,便于后续补测和回归分析
python -m coverage erase
python -m coverage run tests/run.py
python -m coverage report
python -m coverage json
python -m coverage xml
- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v6
with:
name: coverage-report
path: |
coverage.xml
coverage.json
retention-days: 7

7
.gitignore vendored
View File

@@ -16,7 +16,7 @@ app/helper/*.pyd
app/helper/*.bin
app/plugins/**
!app/plugins/__init__.py
config/cookies/**
config/cookies/
config/app.env
config/user.db*
config/sites/**
@@ -25,14 +25,19 @@ config/logs/
config/plugins/
config/temp/
config/cache/
config/.cache/
.runtime/
public/
.moviepilot.env
*.pyc
*.log
.coverage
coverage.xml
coverage.json
htmlcov/
.vscode
venv
moviepilot-site-capture-*.zip
# Pylint
pylint-report.json

View File

@@ -59,6 +59,7 @@ curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v2/scripts/bootst
- 文档规则入口:[docs/rules/README.md](docs/rules/README.md)
- 开发环境与本地源码运行:[docs/development-setup.md](docs/development-setup.md)
- 测试说明:[docs/testing.md](docs/testing.md)
- 新站点适配采集与 Feature Request 提交:[docs/site-adapter-capture.md](docs/site-adapter-capture.md)
- REST API 文档https://api.movie-pilot.org
- 插件开发说明https://wiki.movie-pilot.org/zh/plugindev

View File

@@ -58,6 +58,7 @@ Before contributing, read the repository rules and local environment guide, keep
- 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)
- New site adapter capture and Feature Request submission: [docs/site-adapter-capture.md](docs/site-adapter-capture.md)
- REST API documentation: https://api.movie-pilot.org
- Plugin development guide: https://wiki.movie-pilot.org/zh/plugindev

File diff suppressed because it is too large Load Diff

View File

@@ -261,6 +261,12 @@ class StreamingHandler:
tool_message=tool_message,
tool_kwargs=tool_kwargs or {},
)
target_values = []
if isinstance(target, (list, tuple, set)):
target_values = [item for item in target if item]
elif target:
target_values = [target]
with self._lock:
bucket = self._pending_tool_stats.setdefault(
category,
@@ -269,9 +275,30 @@ class StreamingHandler:
"targets": set(),
},
)
bucket["count"] += 1
if target:
bucket["targets"].add(str(target))
if category == "subagent" and target_values:
bucket["count"] += len(target_values)
else:
bucket["count"] += 1
for target_value in target_values:
bucket["targets"].add(str(target_value))
@staticmethod
def _extract_subagent_targets(tool_kwargs: dict[str, Any]) -> list[str]:
"""提取子代理工具请求中的目标子代理类型。"""
tasks = tool_kwargs.get("tasks")
if not isinstance(tasks, list):
subagent_type = tool_kwargs.get("subagent_type")
return [str(subagent_type)] if subagent_type else []
targets = []
for task in tasks:
if isinstance(task, dict):
subagent_type = task.get("subagent_type")
else:
subagent_type = getattr(task, "subagent_type", None)
if subagent_type:
targets.append(str(subagent_type))
return targets
def flush_pending_tool_summary(self) -> str:
"""
@@ -288,11 +315,17 @@ class StreamingHandler:
tool_name: str,
tool_message: Optional[str],
tool_kwargs: dict[str, Any],
) -> tuple[str, Optional[str]]:
) -> tuple[str, Optional[Any]]:
tool_name = (tool_name or "").strip().lower()
tool_message = (tool_message or "").strip()
tool_message_lower = tool_message.lower()
if tool_name == "skill":
return "skill", tool_kwargs.get("name")
if tool_name == "query_activity_log":
return "activity_log", tool_kwargs.get("keyword") or tool_kwargs.get("date")
if tool_name == "subagent_task":
return "subagent", StreamingHandler._extract_subagent_targets(tool_kwargs)
if tool_name == "task":
return "subagent", tool_kwargs.get("subagent_type")
if tool_name == "read_file":
@@ -369,7 +402,7 @@ class StreamingHandler:
parts = []
for category, bucket in self._pending_tool_stats.items():
value = bucket["count"]
if category in {"file_read", "file_write", "directory", "web_browse"} and bucket["targets"]:
if category in {"file_read", "file_write", "directory", "web_browse", "skill"} and bucket["targets"]:
value = len(bucket["targets"])
part = self._format_tool_stat(category, value)
if part:
@@ -406,6 +439,10 @@ class StreamingHandler:
return f"执行了 {count} 条命令"
if category == "data_query":
return f"查询了 {count} 次数据"
if category == "skill":
return f"查询了 {count} 个技能说明"
if category == "activity_log":
return f"查询了 {count} 次活动日志"
if category == "action":
return f"执行了 {count} 次操作"
if category == "interaction":
@@ -499,6 +536,7 @@ class StreamingHandler:
original_chat_id=self._original_chat_id,
title=self._title,
text=current_text,
save_history=False,
),
)
if response and response.success and response.message_id:
@@ -544,6 +582,7 @@ class StreamingHandler:
original_chat_id=self._original_chat_id,
title=self._title,
text=current_text,
save_history=False,
),
)
if response and response.success and response.message_id:

View File

@@ -691,7 +691,9 @@ class AgentCapabilityManager:
@staticmethod
def supports_image_input() -> bool:
"""当前 Agent 是否启用图片输入能力。"""
return bool(settings.LLM_SUPPORT_IMAGE_INPUT)
from app.agent.llm.helper import LLMHelper
return LLMHelper.supports_image_input()
@staticmethod
def supports_audio_input() -> bool:

View File

@@ -5,7 +5,7 @@ import inspect
import json
import time
from functools import wraps
from typing import Any, List
from typing import Any, List, Optional
from langchain_core.messages import AIMessage, AIMessageChunk
@@ -700,11 +700,85 @@ class LLMHelper:
return {}
@staticmethod
def supports_image_input() -> bool:
def _metadata_supports_image_input(metadata: Any) -> Optional[bool]:
"""从模型元数据中读取图片输入能力,未知时返回 None。"""
if not isinstance(metadata, dict):
return None
modalities = metadata.get("modalities") or {}
input_modalities = modalities.get("input")
if isinstance(input_modalities, str):
input_modalities = [input_modalities]
if isinstance(input_modalities, list):
normalized_modalities = {
str(item or "").strip().lower() for item in input_modalities
}
return "image" in normalized_modalities
return None
@classmethod
def _resolve_catalog_image_input_support(
cls,
provider: Optional[str] = None,
model: Optional[str] = None,
base_url: Optional[str] = None,
base_url_preset: Optional[str] = None,
) -> Optional[bool]:
"""复用 provider 目录缓存解析当前模型是否支持图片输入。"""
provider_name = str(provider if provider is not None else settings.LLM_PROVIDER).strip()
model_name = str(model if model is not None else settings.LLM_MODEL).strip()
if not provider_name or not model_name:
return None
try:
from app.agent.llm.provider import LLMProviderManager
metadata = LLMProviderManager().resolve_cached_model_metadata(
provider_id=provider_name,
model_id=model_name,
base_url=base_url if base_url is not None else settings.LLM_BASE_URL,
base_url_preset_id=(
base_url_preset
if base_url_preset is not None
else settings.LLM_BASE_URL_PRESET
),
)
except Exception as err:
logger.debug(f"解析模型图片能力失败: {err}")
return None
return cls._metadata_supports_image_input(metadata)
@classmethod
def supports_image_input(
cls,
provider: Optional[str] = None,
model: Optional[str] = None,
base_url: Optional[str] = None,
base_url_preset: Optional[str] = None,
) -> bool:
"""
判断当前模型是否启用了图片输入能力。
用户开关为总开关;当内置模型目录明确标注当前模型不支持 image 输入时,
即使总开关开启也降级为纯文本,避免文本模型收到 `image_url` 内容块后
被兼容端点以 400 拒绝。无参调用保持旧版“只读总开关”语义,
未知自定义模型也保持原有开关语义。
"""
return bool(settings.LLM_SUPPORT_IMAGE_INPUT)
if not settings.LLM_SUPPORT_IMAGE_INPUT:
return False
if provider is None and model is None:
return True
image_support = cls._resolve_catalog_image_input_support(
provider=provider,
model=model,
base_url=base_url,
base_url_preset=base_url_preset,
)
if image_support is not None:
return image_support
return True
@staticmethod
def _build_legacy_runtime(
@@ -772,19 +846,31 @@ class LLMHelper:
provider: str,
model: str | None,
runtime: dict[str, Any],
api_protocol: str | None = None,
) -> bool | None:
"""
判断官方 ChatGPT API Key 模式是否应使用 Responses API。
判断本次 OpenAI 兼容调用是否应使用 Responses API。
GPT-5/o 系推理模型在 Chat Completions 中组合 function tools 与
reasoning_effort 时会被官方端点拒绝,因此 ChatGPT 官方 API Key
模式需要显式切到 Responses API通用 OpenAI-compatible 入口保持
provider 目录解析出的默认行为,避免误伤第三方兼容服务。
优先级:
1. 运行时显式要求ChatGPT Plus/Pro OAuth、Codex 等端点契约),始终保留;
2. 用户通过 ``LLM_API_PROTOCOL`` 显式指定 ``responses`` / ``chat_completions``
3. ``auto``(默认)保持原有 ChatGPT 官方 API Key + GPT-5/o 系推理模型
自动切换逻辑,通用 OpenAI 兼容入口仍走 Chat Completions
避免误伤第三方兼容服务。
:param api_protocol: 显式传入的 API 协议,未传入时读取 ``LLM_API_PROTOCOL``
:return: True/False 强制指定协议None 表示交由 LangChain 默认行为
"""
runtime_use_responses_api = runtime.get("use_responses_api")
if runtime_use_responses_api is not None:
return bool(runtime_use_responses_api)
protocol = cls._normalize_api_protocol(api_protocol)
if protocol == "responses":
return True
if protocol == "chat_completions":
return False
provider_name = (provider or "").strip().lower()
if provider_name != "chatgpt":
return None
@@ -798,6 +884,53 @@ class LLMHelper:
return True
return None
@staticmethod
def _normalize_api_protocol(api_protocol: str | None) -> str:
"""
规范化 API 协议配置,未知值统一回退为 ``auto`` 以保持兼容。
"""
normalized = str(api_protocol or settings.LLM_API_PROTOCOL or "").strip().lower()
if normalized in {"auto", "chat_completions", "responses"}:
return normalized
if normalized:
logger.warning(f"忽略不支持的 LLM_API_PROTOCOL 配置: {api_protocol}")
return "auto"
@staticmethod
def _attach_runtime_metadata(model: Any, runtime: dict[str, Any]) -> None:
"""
将 MoviePilot 已解析出的 provider 运行时信息挂到模型实例上。
这些字段只供内部中间件识别协议能力,不参与 LangChain 请求序列化。
"""
runtime_metadata = {
"runtime": runtime.get("runtime"),
"provider_id": runtime.get("provider_id"),
"base_url": runtime.get("base_url"),
}
def _set_metadata_attr(name: str, value: Any) -> None:
try:
setattr(model, name, value)
except Exception:
object.__setattr__(model, name, value)
try:
_set_metadata_attr("_moviepilot_llm_runtime", runtime_metadata["runtime"])
_set_metadata_attr(
"_moviepilot_llm_provider_id",
runtime_metadata["provider_id"],
)
_set_metadata_attr("_moviepilot_llm_base_url", runtime_metadata["base_url"])
except Exception as err:
logger.debug(f"LLM运行时元数据附加失败: {str(err)}")
profile = getattr(model, "profile", None)
if isinstance(profile, dict):
profile["moviepilot_runtime"] = runtime_metadata["runtime"]
profile["moviepilot_provider_id"] = runtime_metadata["provider_id"]
profile["moviepilot_base_url"] = runtime_metadata["base_url"]
@classmethod
def _resolve_thinking_level(
cls,
@@ -843,7 +976,9 @@ class LLMHelper:
base_url: str | None = None,
base_url_preset: str | None = None,
user_agent: str | None = None,
temperature: Optional[float] = None,
use_proxy: bool | None = None,
api_protocol: str | None = None,
):
"""
获取LLM实例
@@ -858,7 +993,12 @@ class LLMHelper:
: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 temperature: LLM 温度参数。未显式传入时使用配置项 LLM_TEMPERATURE。
:param use_proxy: 是否为本次 LLM 调用使用系统代理。未显式传入时使用配置项 LLM_USE_PROXY。
:param api_protocol: OpenAI 兼容接口 API 协议
auto/chat_completions/responses。未显式传入时使用配置项 LLM_API_PROTOCOL。
仅对 OpenAI 兼容运行时生效;``responses`` 强制走 Responses API
``chat_completions`` 强制走 Chat Completions``auto`` 保持原有自动判断。
:return: LLM实例
"""
provider_name = str(provider if provider is not None else settings.LLM_PROVIDER).lower()
@@ -869,6 +1009,7 @@ class LLMHelper:
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
temperature_value = temperature if temperature is not None else settings.LLM_TEMPERATURE
normalized_thinking_level = cls._resolve_thinking_level(
thinking_level=thinking_level,
)
@@ -909,6 +1050,7 @@ class LLMHelper:
provider=provider_name,
model=model_name,
runtime=runtime,
api_protocol=api_protocol,
)
llm_proxy = _resolve_llm_proxy(use_proxy)
@@ -925,7 +1067,7 @@ class LLMHelper:
model=model_name,
api_key=runtime["api_key"],
retries=3,
temperature=settings.LLM_TEMPERATURE,
temperature=temperature_value,
streaming=streaming,
client_args=_build_google_client_args(llm_proxy),
**thinking_kwargs,
@@ -939,13 +1081,36 @@ class LLMHelper:
api_key=runtime["api_key"],
api_base=runtime["base_url"],
max_retries=3,
temperature=settings.LLM_TEMPERATURE,
temperature=temperature_value,
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"] == "bedrock":
from langchain_aws import ChatBedrockConverse
from app.agent.llm.provider import LLMProviderManager
aws_region = runtime.get("aws_region") or "us-east-1"
aws_auth = runtime.get("aws_auth") or {}
# Bearer 认证需要跳过 SigV4 签名并注入 Authorization 头SigV4 认证
# 直接以 AK/SK 签名;两种方式统一由 provider 管理器构造 boto3 客户端。
bedrock_client = LLMProviderManager().create_bedrock_client(
"bedrock-runtime",
region=aws_region,
credentials=aws_auth,
base_url=runtime.get("base_url"),
use_proxy=use_proxy,
read_timeout=settings.LLM_TOOL_TIMEOUT,
)
model = ChatBedrockConverse(
model_id=model_name,
client=bedrock_client,
temperature=temperature_value,
disable_streaming=not streaming,
)
elif runtime["runtime"] in {"anthropic_compatible", "copilot_anthropic"}:
from langchain_anthropic import ChatAnthropic
@@ -954,7 +1119,7 @@ class LLMHelper:
api_key=runtime["api_key"],
base_url=runtime["base_url"],
max_retries=3,
temperature=settings.LLM_TEMPERATURE,
temperature=temperature_value,
streaming=streaming,
stream_usage=True,
anthropic_proxy=llm_proxy,
@@ -975,7 +1140,7 @@ class LLMHelper:
api_key=runtime["api_key"],
max_retries=3,
base_url=runtime.get("base_url"),
temperature=settings.LLM_TEMPERATURE,
temperature=temperature_value,
streaming=streaming,
stream_usage=True,
openai_proxy=llm_proxy,
@@ -995,7 +1160,11 @@ class LLMHelper:
# 优先使用 provider / models.dev 目录中的上下文上限,减少用户手填成本。
model_profile = getattr(model, "profile", None)
if model_profile:
logger.debug(f"使用LLM模型: {model.model}Profile: {model.profile}")
# ChatBedrockConverse 等模型类没有 model 属性,模型名存放在 model_id。
logged_model_name = getattr(model, "model", None) or getattr(
model, "model_id", model_name
)
logger.debug(f"使用LLM模型: {logged_model_name}Profile: {model_profile}")
else:
model_record = runtime.get("model_record") or {}
model_metadata = runtime.get("model_metadata") or {}
@@ -1011,12 +1180,17 @@ class LLMHelper:
"max_input_tokens": int(max_input_tokens),
}
cls._attach_runtime_metadata(model, runtime)
return model
@staticmethod
def _extract_text_content(content) -> str:
def extract_text_content(content: Any, fallback_to_string: bool = False) -> str:
"""
从响应内容中提取纯文本,仅保留真实文本块。
:param content: 模型响应内容,可能是字符串、字典或内容块列表
:param fallback_to_string: 未识别为文本内容时是否回退为字符串
:return: 提取后的纯文本内容
"""
if content is None:
return ""
@@ -1051,7 +1225,7 @@ class LLMHelper:
return content.get("text", "")
if not content.get("type") and isinstance(content.get("text"), str):
return content.get("text", "")
return ""
return str(content) if fallback_to_string else ""
@staticmethod
async def test_current_settings(
@@ -1064,25 +1238,35 @@ class LLMHelper:
base_url: str | None = None,
base_url_preset: str | None = None,
user_agent: str | None = None,
temperature: Optional[float] = None,
use_proxy: bool | None = None,
api_protocol: str | None = None,
) -> dict:
"""
使用当前已保存配置执行一次最小 LLM 调用。
使用当前配置或显式传入的临时配置执行一次最小 LLM 调用。
:param temperature: LLM 温度参数。未显式传入时沿用已保存配置。
:param api_protocol: OpenAI 兼容接口 API 协议,未显式传入时沿用已保存配置。
"""
provider_name = provider if provider is not None else settings.LLM_PROVIDER
model_name = model if model is not None else settings.LLM_MODEL
start = time.perf_counter()
llm = await LLMHelper.get_llm(
streaming=False,
provider=provider_name,
model=model_name,
thinking_level=thinking_level,
api_key=api_key,
base_url=base_url,
base_url_preset=base_url_preset,
user_agent=user_agent,
use_proxy=use_proxy,
)
llm_kwargs = {
"streaming": False,
"provider": provider_name,
"model": model_name,
"thinking_level": thinking_level,
"api_key": api_key,
"base_url": base_url,
"base_url_preset": base_url_preset,
"user_agent": user_agent,
"use_proxy": use_proxy,
"api_protocol": api_protocol,
}
if temperature is not None:
llm_kwargs["temperature"] = temperature
llm = await LLMHelper.get_llm(**llm_kwargs)
try:
response = await asyncio.wait_for(llm.ainvoke(prompt), timeout=timeout)
except TimeoutError as err:
@@ -1092,7 +1276,7 @@ class LLMHelper:
duration_ms = round((time.perf_counter() - start) * 1000)
raise LLMTestError(str(err), duration_ms=duration_ms) from err
reply_text = LLMHelper._extract_text_content(
reply_text = LLMHelper.extract_text_content(
getattr(response, "content", response)
).strip()
duration_ms = round((time.perf_counter() - start) * 1000)

View File

@@ -7,13 +7,14 @@ import base64
import copy
import hashlib
import json
import re
import secrets
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from urllib.parse import urlencode
from urllib.parse import urlencode, urlsplit
import aiofiles
import httpx
@@ -106,6 +107,90 @@ class LLMProviderManager(metaclass=Singleton):
_MODELS_DEV_BUNDLED_PATH = Path(__file__).with_name("models.json")
_MODELS_DEV_CACHE_TTL = 7 * 24 * 60 * 60
_AUTH_SESSION_DONE_RETENTION = 300
_BEDROCK_DEFAULT_REGION = "us-east-1"
_BEDROCK_API_KEY_PREFIX = "bedrock-api-key-"
_BEDROCK_GPT_OSS_BASE_REGIONS = (
"ap-northeast-1",
"ap-south-1",
"ap-southeast-2",
"eu-central-1",
"eu-north-1",
"eu-west-1",
"eu-west-2",
"sa-east-1",
"us-east-1",
"us-east-2",
"us-west-2",
)
_BEDROCK_GPT_OSS_SAFEGUARD_REGIONS = (
"ap-northeast-1",
"ap-south-1",
"ap-southeast-2",
"eu-west-1",
"eu-west-2",
"sa-east-1",
"us-east-1",
"us-east-2",
"us-west-2",
)
_BEDROCK_ON_DEMAND_MODEL_REGIONS = {
"openai.gpt-oss-120b-1:0": _BEDROCK_GPT_OSS_BASE_REGIONS,
"openai.gpt-oss-20b-1:0": _BEDROCK_GPT_OSS_BASE_REGIONS,
"openai.gpt-oss-safeguard-120b": _BEDROCK_GPT_OSS_SAFEGUARD_REGIONS,
"openai.gpt-oss-safeguard-20b": _BEDROCK_GPT_OSS_SAFEGUARD_REGIONS,
"amazon.nova-lite-v1:0": (
"ap-northeast-1",
"ap-southeast-2",
"eu-west-2",
"us-east-1",
"us-gov-west-1",
),
"amazon.nova-micro-v1:0": (
"ap-southeast-2",
"eu-west-2",
"us-east-1",
"us-gov-west-1",
),
"amazon.nova-pro-v1:0": (
"ap-southeast-2",
"eu-west-2",
"us-east-1",
"us-gov-west-1",
),
"anthropic.claude-3-5-haiku-20241022-v1:0": (
"us-west-2",
),
"anthropic.claude-3-5-sonnet-20240620-v1:0": (
"ap-northeast-1",
"ap-northeast-2",
"ap-southeast-1",
"eu-central-1",
"eu-central-2",
"us-east-1",
"us-gov-west-1",
"us-west-2",
),
"anthropic.claude-3-5-sonnet-20241022-v2:0": (
"ap-southeast-2",
"us-west-2",
),
"anthropic.claude-3-7-sonnet-20250219-v1:0": (
"eu-west-2",
"us-gov-west-1",
),
"anthropic.claude-3-haiku-20240307-v1:0": (
"ap-northeast-1",
"ap-northeast-2",
"ap-south-1",
"ap-southeast-2",
"eu-central-1",
"eu-west-1",
"eu-west-3",
"us-east-1",
"us-gov-west-1",
"us-west-2",
),
}
_CHATGPT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
_CHATGPT_ISSUER = "https://auth.openai.com"
_CHATGPT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
@@ -367,6 +452,50 @@ class LLMProviderManager(metaclass=Singleton):
api_key_hint="填写 Anthropic API Key。",
description="Anthropic Claude 官方端点。",
),
ProviderSpec(
id="amazon-bedrock",
name="Amazon Bedrock",
runtime="bedrock",
models_dev_provider_id="amazon-bedrock",
default_base_url="https://bedrock-runtime.us-east-1.amazonaws.com",
base_url_presets=(
url_preset(
id="bedrock-us-east-1",
label="美东弗吉尼亚北部us-east-1",
value="https://bedrock-runtime.us-east-1.amazonaws.com",
),
url_preset(
id="bedrock-us-west-2",
label="美西俄勒冈us-west-2",
value="https://bedrock-runtime.us-west-2.amazonaws.com",
),
url_preset(
id="bedrock-eu-central-1",
label="欧洲法兰克福eu-central-1",
value="https://bedrock-runtime.eu-central-1.amazonaws.com",
),
url_preset(
id="bedrock-ap-northeast-1",
label="亚太东京ap-northeast-1",
value="https://bedrock-runtime.ap-northeast-1.amazonaws.com",
),
url_preset(
id="bedrock-ap-southeast-1",
label="亚太新加坡ap-southeast-1",
value="https://bedrock-runtime.ap-southeast-1.amazonaws.com",
),
),
base_url_editable=True,
api_key_label="Bedrock API Key / AK:SK",
api_key_hint=(
"支持两种认证方式:填写 Amazon Bedrock API Keybedrock-api-key- 开头,"
"Bearer 认证);或填写 Access Key ID:Secret Access Key可选追加 :Session Token"
"SigV4 认证。Base URL 决定 AWS Region。"
),
model_list_strategy="bedrock",
description="Amazon Bedrock 托管模型服务,支持 Bedrock API Key 与 AK/SK 双认证。",
sort_order=35,
),
ProviderSpec(
id="deepseek",
name="DeepSeek",
@@ -891,7 +1020,7 @@ class LLMProviderManager(metaclass=Singleton):
if not self._models_dev_cache_path.exists():
payload = None
else:
payload = json.loads(self._models_dev_cache_path.read_text(encoding="utf-8"))
payload = json.loads(self._models_dev_cache_path.read_text(encoding="utf-8", errors="replace"))
except Exception as err:
logger.warning(f"读取 models.dev provider 缓存失败: {err}")
payload = None
@@ -1424,7 +1553,7 @@ class LLMProviderManager(metaclass=Singleton):
if not self._models_dev_cache_path.exists():
return None
async with aiofiles.open(
self._models_dev_cache_path, mode="r", encoding="utf-8"
self._models_dev_cache_path, mode="r", encoding="utf-8", errors="replace"
) as stream:
return json.loads(await stream.read())
except Exception as err:
@@ -1437,7 +1566,7 @@ class LLMProviderManager(metaclass=Singleton):
if not self._MODELS_DEV_BUNDLED_PATH.exists():
return None
payload = json.loads(
self._MODELS_DEV_BUNDLED_PATH.read_text(encoding="utf-8")
self._MODELS_DEV_BUNDLED_PATH.read_text(encoding="utf-8", errors="replace")
)
except Exception as err:
logger.warning(f"读取本地 models.dev 离线文件失败: {err}")
@@ -1564,6 +1693,101 @@ class LLMProviderManager(metaclass=Singleton):
return models[candidate]
return None
def _cached_models_dev_model(
self,
provider_id: str,
model_id: str,
base_url: Optional[str] = None,
base_url_preset_id: Optional[str] = None,
) -> dict[str, Any] | None:
"""从已缓存或内置的 models.dev 数据中同步读取模型元数据。"""
try:
spec = self.get_provider(provider_id)
except LLMProviderError:
return None
models_dev_provider_id = self._resolve_provider_models_dev_provider_id(
spec,
base_url,
base_url_preset_id=base_url_preset_id,
)
if not models_dev_provider_id:
return None
payload = self._cached_models_dev_payload().get(models_dev_provider_id, {}) or {}
models = payload.get("models") if isinstance(payload, dict) else None
if not isinstance(models, dict):
return None
candidates = [model_id]
if model_id.startswith("models/"):
candidates.append(model_id.removeprefix("models/"))
for candidate in candidates:
if candidate in models:
return models[candidate]
return None
def resolve_cached_model_metadata(
self,
provider_id: str,
model_id: Optional[str],
base_url: Optional[str] = None,
base_url_preset_id: Optional[str] = None,
) -> dict[str, Any] | None:
"""同步解析缓存中的模型元数据,不触发远端 models.dev 刷新。"""
if not model_id:
return None
metadata = self._cached_models_dev_model(
provider_id,
model_id,
base_url=base_url,
base_url_preset_id=base_url_preset_id,
)
if metadata:
return metadata
if provider_id == "chatgpt":
return self._cached_models_dev_model("openai", model_id)
if provider_id == "openai":
return (
self._cached_models_dev_payload()
.get("openai", {})
.get("models", {})
.get(model_id)
)
return None
def _resolve_cached_model_record(
self,
provider_id: str,
model_id: Optional[str],
base_url: Optional[str] = None,
base_url_preset_id: Optional[str] = None,
transport: str = "openai",
) -> dict[str, Any] | None:
"""从缓存中的模型元数据构造轻量模型记录,不触发远端模型列表刷新。"""
if not model_id:
return None
metadata = self.resolve_cached_model_metadata(
provider_id,
model_id,
base_url=base_url,
base_url_preset_id=base_url_preset_id,
) or {}
if not metadata:
return self._normalize_model_record(
model_id=model_id,
transport=transport,
source="configured",
)
return self._normalize_model_record(
model_id=model_id,
display_name=metadata.get("name") or model_id,
metadata=metadata,
transport=transport,
source="models.dev-cache",
)
@staticmethod
def _normalize_model_record(
model_id: str,
@@ -1648,6 +1872,112 @@ class LLMProviderManager(metaclass=Singleton):
return normalized[:-3]
return normalized
@classmethod
def _extract_bedrock_region(cls, base_url: Optional[str]) -> str:
"""
从 Bedrock 运行时端点 URL 中提取 AWS Region
兼容标准端点、FIPS 端点与 PrivateLinkVPCE端点等主机名形态
从中识别 Region 段。
:param base_url: 形如 https://bedrock-runtime.us-east-1.amazonaws.com 的端点地址
:return: 提取到的 Region无法识别时回退 us-east-1
"""
hostname = urlsplit((base_url or "").strip().lower()).hostname or ""
match = re.search(
r"(?:^|\.)(?:bedrock(?:-runtime)?(?:-fips)?)"
r"\.([a-z0-9-]+-\d+)(?:\.|$)",
hostname,
)
if match:
return match.group(1)
return cls._BEDROCK_DEFAULT_REGION
# Inference Profile 的地理前缀与可用 Region 的对应关系,用于降级目录按
# 当前 Region 过滤掉不可调用的 Profile 条目。
_BEDROCK_GEO_PREFIXES: dict[str, tuple[str, ...]] = {
"us": ("us-east-", "us-west-"),
"eu": ("eu-",),
"apac": ("ap-",),
"au": ("ap-southeast-2", "ap-southeast-4"),
"jp": ("ap-northeast-1", "ap-northeast-3"),
"ca": ("ca-",),
}
_BEDROCK_NON_COMMERCIAL_REGION_PREFIXES = (
"cn-",
"eu-isoe-",
"us-gov-",
"us-iso-",
"us-isob-",
"us-isof-",
)
@classmethod
def _bedrock_model_matches_region(cls, model_id: str, region: str) -> bool:
"""
判断目录中的模型 ID 在指定 Region 是否可调用
models.dev 目录同时收录裸模型 ID直连调用与带地理前缀的
Inference Profile IDus./eu./apac./global. 等)。带前缀的条目只在
对应地理分区和 AWS 分区的 Region 可用global Profile 仅允许商业
AWS 分区。裸 ID 仅在明确记录的 ON_DEMAND Region 可用,未知条目
按不可直连处理。
:param model_id: 目录中的模型 ID
:param region: 当前 Base URL 对应的 AWS Region
:return: 该模型在当前 Region 可调用时返回 True
"""
prefix = model_id.split(".", 1)[0]
if prefix == "global":
return not region.startswith(cls._BEDROCK_NON_COMMERCIAL_REGION_PREFIXES)
region_prefixes = cls._BEDROCK_GEO_PREFIXES.get(prefix)
if region_prefixes is not None:
return (
not region.startswith(cls._BEDROCK_NON_COMMERCIAL_REGION_PREFIXES)
and region.startswith(region_prefixes)
)
on_demand_regions = cls._BEDROCK_ON_DEMAND_MODEL_REGIONS.get(model_id)
return on_demand_regions is not None and region in on_demand_regions
@classmethod
def _parse_bedrock_credentials(cls, api_key: Optional[str]) -> dict[str, Any]:
"""
解析 Bedrock 凭证字符串,识别 Bearer 与 SigV4 两种认证方式
- Bedrock API Keybedrock-api-key- 开头的长期 Key或控制台生成的短期
Token走 Bearer 认证;
- `AccessKeyId:SecretAccessKey` 或 `AccessKeyId:SecretAccessKey:SessionToken`
走 SigV4 认证AWS Access Key ID 均以 "AKIA"/"ASIA" 开头。
:param api_key: 用户在 API Key 输入框填写的凭证内容
:return: 含 auth_scheme 及对应凭证字段的字典
"""
normalized = str(api_key or "").strip()
if not normalized:
raise LLMProviderAuthError(
"Amazon Bedrock 需要填写 Bedrock API Key 或 Access Key ID:Secret Access Key"
)
if not normalized.startswith(cls._BEDROCK_API_KEY_PREFIX):
parts = [part.strip() for part in normalized.split(":")]
if len(parts) in {2, 3} and all(parts):
credentials = {
"auth_scheme": "sigv4",
"access_key_id": parts[0],
"secret_access_key": parts[1],
}
if len(parts) == 3:
credentials["session_token"] = parts[2]
return credentials
if ":" in normalized:
raise LLMProviderAuthError(
"Amazon Bedrock AK/SK 凭证格式不正确,"
"请按 AccessKeyId:SecretAccessKey 或 "
"AccessKeyId:SecretAccessKey:SessionToken 填写"
)
return {"auth_scheme": "bearer", "bearer_token": normalized}
async def _list_models_from_google(
self,
api_key: str,
@@ -1762,6 +2092,235 @@ class LLMProviderManager(metaclass=Singleton):
)
return sorted(results, key=lambda item: item["name"].lower())
def _build_bedrock_boto3_config(
self,
use_proxy: Optional[bool] = None,
) -> Any:
"""
构造 Bedrock boto3 客户端配置,统一超时、重试与代理策略
:param use_proxy: 是否使用系统代理None 时读取 LLM_USE_PROXY 配置
:return: botocore Config 实例
"""
from botocore.config import Config
should_use_proxy = settings.LLM_USE_PROXY if use_proxy is None else use_proxy
proxies = None
if should_use_proxy and settings.PROXY_HOST:
proxies = {"http": settings.PROXY_HOST, "https": settings.PROXY_HOST}
return Config(
connect_timeout=10,
read_timeout=60,
retries={"max_attempts": 3, "mode": "standard"},
proxies=proxies,
)
@staticmethod
def _bedrock_endpoint_url(
service_name: str, base_url: Optional[str]
) -> Optional[str]:
"""
解析应传给 boto3 客户端的自定义端点 URL
标准公有端点交由 boto3 按 Region 自行推导;用户填写 PrivateLink、
FIPS 等非标准端点时才显式透传,保证所选网络路径实际生效。
:param service_name: boto3 服务名bedrock 或 bedrock-runtime
:param base_url: 用户配置的 Base URL
:return: 需要显式指定端点时返回 URL否则返回 None
"""
normalized = (base_url or "").strip().rstrip("/")
if not normalized:
return None
if re.fullmatch(
rf"https://{service_name}\.[a-z0-9-]+\.amazonaws\.com",
normalized,
):
return None
return normalized
def create_bedrock_client(
self,
service_name: str,
region: str,
credentials: dict[str, Any],
base_url: Optional[str] = None,
use_proxy: Optional[bool] = None,
read_timeout: Optional[int] = None,
) -> Any:
"""
按解析后的凭证创建 Bedrock boto3 客户端Bearer 方式注入 Authorization 头
:param service_name: boto3 服务名bedrock 或 bedrock-runtime
:param region: AWS Region
:param credentials: `_parse_bedrock_credentials` 的解析结果
:param base_url: 用户配置的 Base URL非标准端点PrivateLink/FIPS 等)时透传给 boto3
:param use_proxy: 是否使用系统代理
:param read_timeout: 读取超时秒数None 时使用默认值
:return: boto3 客户端实例
"""
import boto3
from botocore import UNSIGNED
config = self._build_bedrock_boto3_config(use_proxy)
if read_timeout:
config = config.merge(type(config)(read_timeout=read_timeout))
endpoint_kwargs: dict[str, Any] = {}
endpoint_url = self._bedrock_endpoint_url(service_name, base_url)
if endpoint_url:
endpoint_kwargs["endpoint_url"] = endpoint_url
if credentials["auth_scheme"] == "sigv4":
return boto3.client(
service_name,
region_name=region,
aws_access_key_id=credentials["access_key_id"],
aws_secret_access_key=credentials["secret_access_key"],
aws_session_token=credentials.get("session_token"),
config=config,
**endpoint_kwargs,
)
# Bearer 认证:以 UNSIGNED 跳过 SigV4 签名,再把 API Key 注入 Authorization 头。
bearer_token = credentials["bearer_token"]
config = config.merge(type(config)(signature_version=UNSIGNED))
client = boto3.client(
service_name,
region_name=region,
aws_access_key_id="unsigned",
aws_secret_access_key="unsigned",
config=config,
**endpoint_kwargs,
)
def _inject_bearer(request: Any, **_kwargs: Any) -> None:
request.headers["Authorization"] = f"Bearer {bearer_token}"
client.meta.events.register(
f"request-created.{service_name}",
_inject_bearer,
)
return client
async def _list_models_from_bedrock_fallback(
self,
region: str,
use_proxy: Optional[bool] = None,
) -> list[dict[str, Any]]:
"""
从 models.dev 目录筛选当前 Region 可调用的 Bedrock 模型
:param region: 当前 Base URL 对应的 AWS Region
:param use_proxy: 是否使用系统代理
:return: 过滤后的标准化模型记录列表
"""
models = await self._list_models_from_models_dev_only(
provider_id="amazon-bedrock",
use_proxy=use_proxy,
)
return [
model
for model in models
if self._bedrock_model_matches_region(model["id"], region)
]
async def _list_models_from_bedrock(
self,
api_key: str,
base_url: Optional[str],
use_proxy: Optional[bool] = None,
) -> list[dict[str, Any]]:
"""
从 Bedrock 控制面拉取模型目录,聚合跨区 Inference Profile 与直连模型
Bedrock 多数新模型仅允许通过 Inference Profileus./eu./apac./global. 前缀)
调用,因此优先列出 Profile再补充支持 ON_DEMAND 直连的基础模型。
:param api_key: 用户填写的凭证内容Bedrock API Key 或 AK/SK
:param base_url: Bedrock 运行时端点,决定 Region
:param use_proxy: 是否使用系统代理
:return: 标准化后的模型记录列表
"""
credentials = self._parse_bedrock_credentials(api_key)
region = self._extract_bedrock_region(base_url)
# runtime VPCE 无法安全推导对应的控制面 VPCEFIPS 端点也不能绕回
# 公有非 FIPS 控制面,因此直接使用本地目录。
if self._bedrock_endpoint_url("bedrock-runtime", base_url):
return await self._list_models_from_bedrock_fallback(region, use_proxy)
client = self.create_bedrock_client(
"bedrock",
region=region,
credentials=credentials,
use_proxy=use_proxy,
)
def _fetch() -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
profiles: list[dict[str, Any]] = []
paginator = client.get_paginator("list_inference_profiles")
for page in paginator.paginate(typeEquals="SYSTEM_DEFINED"):
profiles.extend(page.get("inferenceProfileSummaries") or [])
foundation = client.list_foundation_models(
byOutputModality="TEXT",
byInferenceType="ON_DEMAND",
).get("modelSummaries") or []
return profiles, foundation
try:
profile_summaries, foundation_summaries = await asyncio.to_thread(_fetch)
except Exception as err:
# 部分 Bedrock API Key 的授权范围仅覆盖 bedrock-runtime 推理接口,
# 控制面查询被拒时降级到 models.dev 目录,保证仍能选择模型。
logger.warning(
f"获取 Amazon Bedrock 控制面模型列表失败,降级 models.dev 目录: {err}"
)
return await self._list_models_from_bedrock_fallback(region, use_proxy)
finally:
await asyncio.to_thread(client.close)
results: list[dict[str, Any]] = []
seen_ids: set[str] = set()
def _append_record(model_id: str, display_name: Optional[str]) -> None:
if not model_id or model_id in seen_ids:
return
seen_ids.add(model_id)
# Inference Profile 带区域前缀models.dev 目录按基础模型 ID 收录,
# 去掉首个前缀段再查一次元数据。
metadata = self._cached_models_dev_model("amazon-bedrock", model_id)
if not metadata and "." in model_id:
metadata = self._cached_models_dev_model(
"amazon-bedrock",
model_id.split(".", 1)[1],
)
results.append(
self._normalize_model_record(
model_id=model_id,
display_name=display_name or (metadata or {}).get("name") or model_id,
metadata=metadata or {},
source="provider",
)
)
for profile in profile_summaries:
if (profile.get("status") or "ACTIVE") != "ACTIVE":
continue
_append_record(
str(profile.get("inferenceProfileId") or "").strip(),
profile.get("inferenceProfileName"),
)
# 控制面已按当前 Region 和 ON_DEMAND 筛选,不能复用仅面向
# models.dev 降级目录的静态白名单,否则 AWS 新增模型会被遗漏。
for summary in foundation_summaries:
lifecycle = (summary.get("modelLifecycle") or {}).get("status") or "ACTIVE"
if lifecycle != "ACTIVE":
continue
_append_record(
str(summary.get("modelId") or "").strip(),
summary.get("modelName"),
)
return sorted(results, key=lambda item: item["name"].lower())
@staticmethod
def _copilot_headers(
token: Optional[str] = None, include_auth: bool = True
@@ -1969,6 +2528,13 @@ class LLMProviderManager(metaclass=Singleton):
use_proxy=use_proxy,
)
if resolved_model_list_strategy == "bedrock":
return await self._list_models_from_bedrock(
api_key=runtime["api_key"],
base_url=runtime.get("base_url"),
use_proxy=use_proxy,
)
if resolved_model_list_strategy == "anthropic_compatible":
return await self._list_models_from_models_dev_only(
provider_id=provider_id,
@@ -2040,7 +2606,7 @@ class LLMProviderManager(metaclass=Singleton):
try:
return jwt.decode(token, options={"verify_signature": False})
except Exception as err:
print(err)
logger.debug(f"解析 JWT token 内容失败: {err}")
return {}
@staticmethod
@@ -2523,40 +3089,29 @@ class LLMProviderManager(metaclass=Singleton):
)
normalized_api_key = str(api_key or "").strip() or None
normalized_base_url = self._sanitize_base_url(base_url)
model_record = None
if model:
try:
model_record = next(
(
item
for item in await self.list_models(
normalized_provider_id,
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
),
None,
)
except Exception as err:
print(err)
model_record = None
default_transport = (
"anthropic" if resolved_runtime == "anthropic_compatible" else "openai"
)
model_record = self._resolve_cached_model_record(
normalized_provider_id,
model,
base_url=base_url,
base_url_preset_id=normalized_base_url_preset_id,
transport=default_transport,
)
model_metadata = self.resolve_cached_model_metadata(
normalized_provider_id,
model,
base_url=base_url,
base_url_preset_id=normalized_base_url_preset_id,
)
result: dict[str, Any] = {
"provider_id": normalized_provider_id,
"runtime": resolved_runtime,
"model_id": model,
"model_record": model_record,
"model_metadata": await self.resolve_model_metadata(
normalized_provider_id,
model,
base_url=base_url,
base_url_preset_id=normalized_base_url_preset_id,
use_proxy=use_proxy,
),
"model_metadata": model_metadata,
"default_headers": None,
"use_responses_api": None,
"auth_mode": "api_key",
@@ -2567,8 +3122,7 @@ class LLMProviderManager(metaclass=Singleton):
try:
auth = await self._resolve_chatgpt_oauth()
except Exception as err:
print(err)
pass
logger.debug(f"解析 ChatGPT OAuth 鉴权失败,回退 API Key 模式: {err}")
if auth:
headers = {"originator": "moviepilot"}
@@ -2648,6 +3202,22 @@ class LLMProviderManager(metaclass=Singleton):
)
return result
if resolved_runtime == "bedrock":
effective_base_url = normalized_base_url or self._default_base_url_for_provider(
spec
)
credentials = self._parse_bedrock_credentials(normalized_api_key)
result.update(
{
"api_key": normalized_api_key,
"base_url": effective_base_url,
"aws_region": self._extract_bedrock_region(effective_base_url),
"aws_auth": credentials,
"auth_mode": "api_key",
}
)
return result
if resolved_runtime == "anthropic_compatible":
effective_base_url = normalized_base_url or self._default_base_url_for_provider(
spec

600
app/agent/mcp.py Normal file
View File

@@ -0,0 +1,600 @@
"""Agent 外部 MCP 客户端与配置管理。"""
from __future__ import annotations
import asyncio
import hashlib
import json
import os
import re
import uuid
from dataclasses import dataclass
from typing import Any, Optional
from urllib.parse import urljoin
from app.db.systemconfig_oper import SystemConfigOper
from app.log import logger
from app.schemas.agent import (
AgentMcpServerConfig,
AgentMcpServerTestResult,
AgentMcpServerToolInfo,
)
from app.schemas.types import SystemConfigKey
from app.utils.http import AsyncRequestUtils
MCP_PROTOCOL_VERSION = "2025-11-25"
MCP_CLIENT_NAME = "MoviePilot Agent"
DEFAULT_MCP_TIMEOUT = 30
MCP_TOOL_NAME_PATTERN = re.compile(r"[^a-zA-Z0-9_]+")
@dataclass(frozen=True)
class AgentMcpToolSpec:
"""已发现的外部 MCP 工具定义。"""
server: AgentMcpServerConfig
name: str
agent_tool_name: str
description: str
input_schema: dict[str, Any]
def _normalize_identifier(value: str, fallback: str = "mcp") -> str:
"""把服务器或工具名称转换为 Agent 工具可用的标识片段。"""
normalized = MCP_TOOL_NAME_PATTERN.sub("_", str(value or "").strip())
normalized = re.sub(r"_+", "_", normalized).strip("_").lower()
if not normalized:
normalized = fallback
if normalized[0].isdigit():
normalized = f"{fallback}_{normalized}"
return normalized[:64]
def _normalize_timeout(value: Any) -> int:
"""规范化 MCP 连接和调用超时时间。"""
try:
timeout = int(value or DEFAULT_MCP_TIMEOUT)
except (TypeError, ValueError):
timeout = DEFAULT_MCP_TIMEOUT
return min(max(timeout, 1), 600)
def _normalize_string_dict(value: Any) -> dict[str, str]:
"""规范化请求头和环境变量字典,移除空键。"""
if not isinstance(value, dict):
return {}
normalized: dict[str, str] = {}
for key, item in value.items():
normalized_key = str(key or "").strip()
if not normalized_key:
continue
normalized[normalized_key] = str(item or "")
return normalized
def _normalize_input_schema(value: Any) -> dict[str, Any]:
"""规范化 MCP 工具参数 Schema保证至少是 object schema。"""
if not isinstance(value, dict):
return {"type": "object", "properties": {}, "required": []}
schema = dict(value)
schema.setdefault("type", "object")
schema.setdefault("properties", {})
schema.setdefault("required", [])
return schema
def _build_agent_tool_name(server: AgentMcpServerConfig, tool_name: str) -> str:
"""构造注入 Agent 的外部 MCP 工具名。"""
prefix = server.tool_prefix or f"mcp_{server.name or server.id}"
normalized_prefix = _normalize_identifier(prefix, fallback="mcp")
normalized_tool_name = _normalize_identifier(tool_name, fallback="tool")
if normalized_tool_name.startswith(f"{normalized_prefix}_"):
return normalized_tool_name
return f"{normalized_prefix}_{normalized_tool_name}"[:128]
def _jsonrpc_message(method: str, params: Optional[dict[str, Any]] = None, *, request_id: Optional[str] = None) -> dict:
"""构造 JSON-RPC 2.0 消息。"""
payload = {"jsonrpc": "2.0", "method": method}
if request_id is not None:
payload["id"] = request_id
if params is not None:
payload["params"] = params
return payload
def _raise_for_jsonrpc_error(payload: Any) -> None:
"""检查 JSON-RPC 响应错误并转换为运行时异常。"""
if isinstance(payload, dict) and payload.get("error"):
error = payload["error"]
if isinstance(error, dict):
message = error.get("message") or error
else:
message = error
raise RuntimeError(f"MCP JSON-RPC 错误: {message}")
def _extract_jsonrpc_result(payload: Any, request_id: str) -> Any:
"""从 JSON-RPC 响应中提取 result 字段。"""
if not isinstance(payload, dict):
raise RuntimeError("MCP 响应不是有效 JSON 对象")
if payload.get("id") != request_id:
raise RuntimeError("MCP 响应 ID 与请求不匹配")
_raise_for_jsonrpc_error(payload)
return payload.get("result")
async def _iter_sse_events(response) -> Any:
"""按 SSE 事件格式迭代响应流。"""
event_name = "message"
data_lines: list[str] = []
async for raw_line in response.aiter_lines():
line = raw_line.rstrip("\r")
if not line:
if data_lines:
yield {"event": event_name, "data": "\n".join(data_lines)}
event_name = "message"
data_lines = []
continue
if line.startswith(":"):
continue
field, _, value = line.partition(":")
if value.startswith(" "):
value = value[1:]
if field == "event":
event_name = value or "message"
elif field == "data":
data_lines.append(value)
if data_lines:
yield {"event": event_name, "data": "\n".join(data_lines)}
def _parse_sse_text_response(text: str, request_id: str) -> Any:
"""从非流式 SSE 文本响应中提取匹配请求的 JSON-RPC 结果。"""
event_name = "message"
data_lines: list[str] = []
for raw_line in str(text or "").splitlines():
line = raw_line.rstrip("\r")
if not line:
if data_lines:
payload = _load_sse_json_payload(event_name, "\n".join(data_lines))
if isinstance(payload, dict) and payload.get("id") == request_id:
return _extract_jsonrpc_result(payload, request_id)
event_name = "message"
data_lines = []
continue
if line.startswith(":"):
continue
field, _, value = line.partition(":")
if value.startswith(" "):
value = value[1:]
if field == "event":
event_name = value or "message"
elif field == "data":
data_lines.append(value)
if data_lines:
payload = _load_sse_json_payload(event_name, "\n".join(data_lines))
if isinstance(payload, dict) and payload.get("id") == request_id:
return _extract_jsonrpc_result(payload, request_id)
raise RuntimeError("MCP SSE 响应中未找到匹配请求")
def _load_sse_json_payload(event_name: str, data: str) -> Optional[dict]:
"""解析 SSE data 中的 JSON-RPC 消息。"""
if event_name not in {"message", "messages"}:
return None
try:
payload = json.loads(data)
except (TypeError, ValueError):
return None
return payload if isinstance(payload, dict) else None
class _StdioMcpSession:
"""stdio MCP 会话,按一次操作生命周期启动外部进程。"""
def __init__(self, server: AgentMcpServerConfig) -> None:
self.server = server
self.process: Optional[asyncio.subprocess.Process] = None
self.stderr_task: Optional[asyncio.Task] = None
async def __aenter__(self) -> "_StdioMcpSession":
"""启动 stdio MCP 子进程。"""
if not self.server.command:
raise RuntimeError("stdio MCP 服务器缺少启动命令")
env = os.environ.copy()
env.update(self.server.env or {})
self.process = await asyncio.create_subprocess_exec(
self.server.command,
*(self.server.args or []),
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
)
self.stderr_task = asyncio.create_task(self._drain_stderr())
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
"""结束 stdio MCP 子进程。"""
if self.stderr_task:
self.stderr_task.cancel()
if not self.process:
return
if self.process.returncode is None:
self.process.terminate()
try:
await asyncio.wait_for(self.process.wait(), timeout=2)
except asyncio.TimeoutError:
self.process.kill()
await self.process.wait()
async def _drain_stderr(self) -> None:
"""持续读取子进程 stderr避免缓冲区阻塞。"""
if not self.process or not self.process.stderr:
return
try:
while True:
line = await self.process.stderr.readline()
if not line:
break
logger.debug(f"MCP stdio[{self.server.name}] stderr: {line.decode(errors='replace').strip()}")
except asyncio.CancelledError:
return
async def notify(self, method: str, params: Optional[dict[str, Any]] = None) -> None:
"""发送不需要响应的 JSON-RPC 通知。"""
await self._write_json(_jsonrpc_message(method, params))
async def request(self, method: str, params: Optional[dict[str, Any]] = None) -> Any:
"""发送 JSON-RPC 请求并等待响应。"""
request_id = uuid.uuid4().hex
await self._write_json(_jsonrpc_message(method, params, request_id=request_id))
while True:
payload = await self._read_json()
if payload.get("id") == request_id:
return _extract_jsonrpc_result(payload, request_id)
async def _write_json(self, payload: dict) -> None:
"""写入一行 JSON-RPC 消息。"""
if not self.process or not self.process.stdin:
raise RuntimeError("stdio MCP 进程未启动")
data = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
self.process.stdin.write(data.encode("utf-8"))
await self.process.stdin.drain()
async def _read_json(self) -> dict:
"""从 stdout 读取一行 JSON-RPC 消息。"""
if not self.process or not self.process.stdout:
raise RuntimeError("stdio MCP 进程未启动")
timeout = _normalize_timeout(self.server.timeout)
while True:
line = await asyncio.wait_for(self.process.stdout.readline(), timeout=timeout)
if not line:
raise RuntimeError("stdio MCP 进程已退出")
try:
payload = json.loads(line.decode("utf-8"))
except ValueError:
logger.debug(f"忽略非 JSON MCP stdout 行: {line!r}")
continue
if isinstance(payload, dict):
return payload
class _HttpMcpSession:
"""Streamable HTTP MCP 会话。"""
def __init__(self, server: AgentMcpServerConfig) -> None:
self.server = server
self.session_id: Optional[str] = None
async def __aenter__(self) -> "_HttpMcpSession":
"""进入 HTTP MCP 会话。"""
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
"""退出 HTTP MCP 会话。"""
return None
async def notify(self, method: str, params: Optional[dict[str, Any]] = None) -> None:
"""发送不需要响应的 JSON-RPC 通知。"""
await self._post(_jsonrpc_message(method, params), expect_response=False)
async def request(self, method: str, params: Optional[dict[str, Any]] = None) -> Any:
"""发送 JSON-RPC 请求并等待响应。"""
request_id = uuid.uuid4().hex
return await self._post(
_jsonrpc_message(method, params, request_id=request_id),
expect_response=True,
request_id=request_id,
)
async def _post(
self,
payload: dict,
*,
expect_response: bool,
request_id: Optional[str] = None,
) -> Any:
"""向 Streamable HTTP MCP 服务发送一条 JSON-RPC 消息。"""
if not self.server.url:
raise RuntimeError("HTTP MCP 服务器缺少 URL")
headers = {
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json",
**(self.server.headers or {}),
}
if self.session_id:
headers["Mcp-Session-Id"] = self.session_id
response = await AsyncRequestUtils(
headers=headers,
timeout=_normalize_timeout(self.server.timeout),
content_type="application/json",
accept_type="application/json, text/event-stream",
http2=False,
).post_res(self.server.url, json=payload, raise_exception=True)
try:
if not response:
raise RuntimeError("HTTP MCP 请求无响应")
response.raise_for_status()
session_id = response.headers.get("Mcp-Session-Id")
if session_id:
self.session_id = session_id
if not expect_response:
return None
content_type = response.headers.get("content-type", "").lower()
if "text/event-stream" in content_type:
return _parse_sse_text_response(response.text, request_id or "")
data = response.json()
return _extract_jsonrpc_result(data, request_id or "")
finally:
if response is not None:
await response.aclose()
class _SseMcpSession:
"""旧版 HTTP+SSE MCP 会话。"""
def __init__(self, server: AgentMcpServerConfig) -> None:
self.server = server
self.response = None
self.endpoint: Optional[str] = None
self._stream_manager = None
self._event_iterator = None
async def __aenter__(self) -> "_SseMcpSession":
"""打开 SSE 流并读取服务端回传的 POST endpoint。"""
if not self.server.url:
raise RuntimeError("SSE MCP 服务器缺少 URL")
self._stream_manager = AsyncRequestUtils(
headers={"Accept": "text/event-stream", **(self.server.headers or {})},
timeout=_normalize_timeout(self.server.timeout),
accept_type="text/event-stream",
http2=False,
).get_stream(self.server.url, raise_exception=True)
self.response = await self._stream_manager.__aenter__()
if not self.response:
raise RuntimeError("SSE MCP 连接无响应")
self.response.raise_for_status()
self._event_iterator = _iter_sse_events(self.response).__aiter__()
self.endpoint = await self._read_endpoint()
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
"""关闭 SSE 流。"""
if self._stream_manager:
await self._stream_manager.__aexit__(exc_type, exc, tb)
async def notify(self, method: str, params: Optional[dict[str, Any]] = None) -> None:
"""发送不需要响应的 JSON-RPC 通知。"""
await self._post(_jsonrpc_message(method, params))
async def request(self, method: str, params: Optional[dict[str, Any]] = None) -> Any:
"""发送 JSON-RPC 请求并等待 SSE 流上的响应。"""
request_id = uuid.uuid4().hex
await self._post(_jsonrpc_message(method, params, request_id=request_id))
timeout = _normalize_timeout(self.server.timeout)
while True:
event = await asyncio.wait_for(self._event_iterator.__anext__(), timeout=timeout)
payload = _load_sse_json_payload(event.get("event", ""), event.get("data", ""))
if isinstance(payload, dict) and payload.get("id") == request_id:
return _extract_jsonrpc_result(payload, request_id)
async def _read_endpoint(self) -> str:
"""读取 SSE endpoint 事件中的 POST 地址。"""
timeout = _normalize_timeout(self.server.timeout)
while True:
event = await asyncio.wait_for(self._event_iterator.__anext__(), timeout=timeout)
if event.get("event") != "endpoint":
continue
endpoint = str(event.get("data") or "").strip()
if not endpoint:
continue
return urljoin(self.server.url, endpoint)
async def _post(self, payload: dict) -> None:
"""向 SSE 握手返回的 endpoint 发送 JSON-RPC 消息。"""
if not self.endpoint:
raise RuntimeError("SSE MCP endpoint 未初始化")
response = await AsyncRequestUtils(
headers={
"Accept": "application/json",
"Content-Type": "application/json",
**(self.server.headers or {}),
},
timeout=_normalize_timeout(self.server.timeout),
content_type="application/json",
accept_type="application/json",
http2=False,
).post_res(self.endpoint, json=payload, raise_exception=True)
try:
if not response:
raise RuntimeError("SSE MCP POST 请求无响应")
response.raise_for_status()
finally:
if response is not None:
await response.aclose()
async def _open_mcp_session(server: AgentMcpServerConfig):
"""根据配置创建对应的 MCP 传输会话。"""
transport = "http" if server.transport == "streamable_http" else server.transport
if transport == "stdio":
return _StdioMcpSession(server)
if transport == "sse":
return _SseMcpSession(server)
if transport == "http":
return _HttpMcpSession(server)
raise RuntimeError(f"不支持的 MCP 传输协议: {server.transport}")
class AgentMcpManager:
"""管理 Agent 外部 MCP 服务器配置、工具发现和工具调用。"""
def get_servers(self) -> list[AgentMcpServerConfig]:
"""读取已保存的外部 MCP 服务器配置。"""
raw_servers = SystemConfigOper().get(SystemConfigKey.AIAgentMcpServers) or []
if not isinstance(raw_servers, list):
return []
servers: list[AgentMcpServerConfig] = []
for raw_server in raw_servers:
try:
servers.append(self.normalize_server(raw_server))
except Exception as err:
logger.warning(f"忽略无效的 Agent MCP 配置: {err}")
return servers
async def save_servers(self, servers: list[AgentMcpServerConfig]) -> bool:
"""保存外部 MCP 服务器配置。"""
normalized_servers = [self.normalize_server(server).model_dump() for server in servers]
return await SystemConfigOper().async_set(
SystemConfigKey.AIAgentMcpServers,
normalized_servers or None,
)
def normalize_server(self, value: Any) -> AgentMcpServerConfig:
"""规范化单个 MCP 服务器配置。"""
if isinstance(value, AgentMcpServerConfig):
raw_server = value.model_dump()
elif isinstance(value, dict):
raw_server = dict(value)
else:
raise ValueError("MCP 服务器配置必须是对象")
raw_server["id"] = str(raw_server.get("id") or uuid.uuid4().hex[:12]).strip()
raw_server["name"] = str(raw_server.get("name") or raw_server["id"]).strip()
raw_server["transport"] = str(raw_server.get("transport") or "stdio").strip()
raw_server["description"] = str(raw_server.get("description") or "").strip() or None
raw_server["command"] = str(raw_server.get("command") or "").strip() or None
raw_server["args"] = [str(item) for item in raw_server.get("args") or []]
raw_server["env"] = _normalize_string_dict(raw_server.get("env"))
raw_server["url"] = str(raw_server.get("url") or "").strip() or None
raw_server["headers"] = _normalize_string_dict(raw_server.get("headers"))
raw_server["timeout"] = _normalize_timeout(raw_server.get("timeout"))
raw_server["tool_prefix"] = str(raw_server.get("tool_prefix") or "").strip() or None
raw_server["require_admin"] = bool(raw_server.get("require_admin", True))
return AgentMcpServerConfig.model_validate(raw_server)
def config_signature(self) -> str:
"""生成外部 MCP 配置签名,用于 Agent 图缓存失效。"""
payload = [server.model_dump() for server in self.get_servers()]
raw_text = json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str)
return hashlib.sha256(raw_text.encode("utf-8")).hexdigest()
async def initialize_session(self, session) -> None:
"""完成 MCP initialize 和 initialized 通知流程。"""
await session.request(
"initialize",
{
"protocolVersion": MCP_PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": {
"name": MCP_CLIENT_NAME,
"version": "1.0.0",
},
},
)
await session.notify("notifications/initialized")
async def list_server_tools(self, server: AgentMcpServerConfig) -> list[AgentMcpToolSpec]:
"""连接单个 MCP 服务器并读取工具列表。"""
normalized_server = self.normalize_server(server)
session_manager = await _open_mcp_session(normalized_server)
async with session_manager as session:
await self.initialize_session(session)
result = await session.request("tools/list")
tools_payload = result.get("tools", []) if isinstance(result, dict) else []
tool_specs: list[AgentMcpToolSpec] = []
for item in tools_payload:
if not isinstance(item, dict) or not item.get("name"):
continue
tool_name = str(item["name"])
tool_specs.append(
AgentMcpToolSpec(
server=normalized_server,
name=tool_name,
agent_tool_name=_build_agent_tool_name(normalized_server, tool_name),
description=str(item.get("description") or ""),
input_schema=_normalize_input_schema(item.get("inputSchema")),
)
)
return tool_specs
async def list_enabled_tool_specs(self) -> list[AgentMcpToolSpec]:
"""读取所有启用 MCP 服务器暴露的工具定义。"""
tool_specs: list[AgentMcpToolSpec] = []
seen_names: set[str] = set()
for server in self.get_servers():
if not server.enabled:
continue
try:
for spec in await self.list_server_tools(server):
if spec.agent_tool_name in seen_names:
logger.warning(f"跳过重复的 MCP Agent 工具名: {spec.agent_tool_name}")
continue
tool_specs.append(spec)
seen_names.add(spec.agent_tool_name)
except Exception as err:
logger.warning(f"读取 MCP 服务器 {server.name} 工具失败: {err}")
return tool_specs
async def call_server_tool(
self,
server: AgentMcpServerConfig,
tool_name: str,
arguments: Optional[dict[str, Any]] = None,
) -> Any:
"""调用单个 MCP 服务器上的指定工具。"""
normalized_server = self.normalize_server(server)
session_manager = await _open_mcp_session(normalized_server)
async with session_manager as session:
await self.initialize_session(session)
return await session.request(
"tools/call",
{
"name": tool_name,
"arguments": arguments or {},
},
)
async def test_server(self, server: AgentMcpServerConfig) -> AgentMcpServerTestResult:
"""测试 MCP 服务器连接并返回工具列表。"""
tool_specs = await self.list_server_tools(server)
tools = [
AgentMcpServerToolInfo(
name=spec.name,
agent_tool_name=spec.agent_tool_name,
description=spec.description,
input_schema=spec.input_schema,
)
for spec in tool_specs
]
return AgentMcpServerTestResult(
success=True,
message=f"连接成功,发现 {len(tools)} 个工具",
tools=tools,
tool_count=len(tools),
)
agent_mcp_manager = AgentMcpManager()

View File

@@ -4,9 +4,10 @@ import asyncio
from datetime import datetime
from typing import Dict, List, Optional
from langchain_core.messages import BaseMessage
from langchain_core.messages import BaseMessage, messages_from_dict, messages_to_dict
from app.core.config import settings
from app.db.agentchat_oper import AgentChatOper
from app.log import logger
from app.schemas.agent import ConversationMemory
@@ -70,24 +71,43 @@ class MemoryManager:
self, session_id: str, user_id: str
) -> List[BaseMessage]:
"""
为Agent获取最近的消息(仅内存缓存)
为Agent获取最近的消息
如果消息Token数量超过模型最大上下文长度的阀值会自动进行摘要裁剪
优先使用内存缓存,缓存不存在时从数据库恢复上一轮持久化的原始 messages。
"""
memory = self.get_memory(session_id, user_id)
if not memory:
if memory:
return memory.messages
try:
chat = AgentChatOper().get(session_id=session_id, user_id=user_id)
if not chat:
chat = AgentChatOper().get(session_id=session_id)
except Exception as e:
logger.debug(f"读取持久化Agent会话失败: {e}")
return []
if not chat or not chat.agent_messages:
return []
# 获取所有消息
try:
messages = messages_from_dict(chat.agent_messages)
except Exception as e:
logger.debug(f"恢复持久化Agent消息失败: {e}")
return []
memory = ConversationMemory(
session_id=session_id,
user_id=user_id,
messages=messages,
)
self.save_memory(memory)
return memory.messages
def save_agent_messages(
self, session_id: str, user_id: str, messages: List[BaseMessage]
):
"""
保存Agent消息(仅内存缓存
注意Redis中的记忆通过TTL机制自动过期这里只更新内存缓存Redis会在下次访问时自动过期
保存Agent消息内存缓存与持久化会话表。
"""
memory = self.get_memory(session_id, user_id)
if not memory:
@@ -98,6 +118,14 @@ class MemoryManager:
# 更新内存缓存
self.save_memory(memory)
try:
AgentChatOper().save_agent_messages(
session_id=session_id,
user_id=user_id,
messages=messages_to_dict(messages),
)
except Exception as e:
logger.debug(f"持久化Agent消息失败: {e}")
def save_memory(self, memory: ConversationMemory):
"""

View File

@@ -3,14 +3,19 @@
按日期存储在 CONFIG_PATH/agent/activity/YYYY-MM-DD.md 中,
每次 Agent 执行完毕后自动调用 LLM 对本轮对话生成简洁的活动摘要,
并在每次 Agent 启动时加载近几天的活动日志注入系统提示词
并在每次 Agent 启动时注入轻量索引,完整日志由工具按需查询
"""
import asyncio
import json
import os
import re
from collections.abc import Awaitable, Callable
from datetime import datetime, timedelta
from typing import Annotated, Any, NotRequired, TypedDict
from pathlib import Path
from typing import Annotated, Any, NotRequired, Optional, TypedDict
import anyio
from anyio import Path as AsyncPath
from langchain.agents.middleware.types import (
AgentMiddleware,
@@ -20,39 +25,302 @@ from langchain.agents.middleware.types import (
ModelResponse,
PrivateStateAttr, # noqa
ResponseT,
ToolCallRequest,
)
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langchain_core.tools import StructuredTool
from langgraph.runtime import Runtime
from pydantic import BaseModel, Field
from app.agent.middleware.utils import append_to_system_message
from app.agent.tools.tags import ToolTag
from app.log import logger
# 活动日志保留天数
DEFAULT_RETENTION_DAYS = 7
# 注入系统提示词时加载的天数
# 注入系统提示词时索引的天数
PROMPT_LOAD_DAYS = 3
# 工具默认查询的天数
DEFAULT_QUERY_DAYS = 7
# 工具单次返回的最大条数
DEFAULT_QUERY_LIMIT = 20
MAX_QUERY_LIMIT = 50
# 每日日志文件最大大小 (256KB)
MAX_LOG_FILE_SIZE = 256 * 1024
# 提取本轮对话上下文的最大字符数(避免过长的对话消耗太多 token
MAX_CONTEXT_FOR_SUMMARY = 4000
SUMMARY_SKIP_MARKER = "SKIP"
QUERY_ACTIVITY_LOG_TOOL_NAME = "query_activity_log"
QUERY_ACTIVITY_LOG_TOOL_DESCRIPTION = (
"Query recent MoviePilot Agent activity logs on demand. Use this when the user asks what was done before, "
"asks to continue a previous task, or explicitly references recent agent activity. Supports keyword, date, "
"recent-day window, limit, and optional regex filters. If a keyword search returns no results, retry with "
"a shorter keyword, a larger days window, or no keyword to inspect recent entries."
)
# LLM 总结的提示词
SUMMARY_PROMPT = """根据以下 AI 助手与用户的对话记录生成一条简洁的活动摘要中文一句话不超过80字
摘要应包含:用户的需求是什么、助手做了什么、结果如何。
只输出摘要内容,不要加任何前缀、标点序号或解释。
SUMMARY_PROMPT = """判断以下 AI 助手与用户的对话是否值得写入 MoviePilot 活动日志
如果本轮只是问候、寒暄、感谢、确认、闲聊、没有实际任务、没有工具动作、任务没有推进、纯粹的格式纠正或无意义空转请只输出SKIP
如果值得记录,请输出一条中文单行活动摘要,要求:
- 40 到 160 个汉字左右,信息密度高,不要写成泛泛一句话。
- 只输出摘要正文不要标题、编号、Markdown、JSON 或解释。
- 尽量包含:用户目标、关键对象(影片/剧集/站点/路径/任务/设置)、助手采取的关键动作或工具、结果状态、失败原因或下一步。
- 如果有明确 ID、路径、站点名、任务状态、成功/失败数量,请保留关键值。
- 不要记录 API Key、Cookie、Token、密码等敏感信息如出现请写成“敏感信息已省略”。
推荐格式示例:
用户要求整理 `/downloads/Show`助手识别为《示例剧》TMDB 12345并提交 transfer_file 整理,结果成功。
用户排查下载失败,助手查询 qBittorrent 任务和站点状态,发现 tracker 超时,建议更换站点或重试。
对话记录:
{conversation}"""
ACTIVITY_ENTRY_PATTERN = re.compile(r"^-\s+\*\*(?P<time>\d{2}:\d{2})\*\*\s+(?P<summary>.+)$")
class QueryActivityLogInput(BaseModel):
"""查询活动日志工具的输入参数模型。"""
keyword: Optional[str] = Field(
None,
description=(
"Optional plain-text keyword to filter activity summaries. Use short title, path, site, task, "
"or status fragments; omit it to inspect latest entries."
),
)
use_regex: Optional[bool] = Field(
False,
description=(
"Whether to treat keyword as a regular expression. Defaults to false; enable only for "
"alternative or pattern matching."
),
)
date: Optional[str] = Field(
None,
description="Optional exact date in YYYY-MM-DD format. If omitted, recent days are searched.",
)
days: Optional[int] = Field(
DEFAULT_QUERY_DAYS,
description="Number of recent days to search when date is not specified.",
)
limit: Optional[int] = Field(
DEFAULT_QUERY_LIMIT,
description="Maximum number of activity entries to return.",
)
def _coerce_query_limit(limit: Optional[int]) -> int:
"""规范化活动日志查询条数。"""
if limit is None:
return DEFAULT_QUERY_LIMIT
try:
value = int(limit)
except (TypeError, ValueError):
return DEFAULT_QUERY_LIMIT
return min(max(value, 1), MAX_QUERY_LIMIT)
def _build_log_path(activity_dir: str, date_str: str) -> Path:
"""构建指定日期的活动日志路径。"""
return Path(activity_dir) / f"{date_str}.md"
def _iter_recent_dates(days: int) -> list[str]:
"""返回从今天开始向前的日期字符串列表。"""
normalized_days = max(1, int(days or 1))
today = datetime.now().date()
return [
(today - timedelta(days=index)).strftime("%Y-%m-%d")
for index in range(normalized_days)
]
def _parse_activity_entries(date_str: str, content: str) -> list[dict[str, str]]:
"""从单日活动日志 Markdown 中解析活动条目。"""
entries: list[dict[str, str]] = []
for line in content.splitlines():
match = ACTIVITY_ENTRY_PATTERN.match(line.strip())
if not match:
continue
entries.append(
{
"date": date_str,
"time": match.group("time"),
"summary": match.group("summary").strip(),
}
)
return entries
def _activity_summary_matches_keyword(
summary: str,
keyword: str,
regex_pattern: Optional[re.Pattern[str]],
) -> bool:
"""判断活动摘要是否命中普通关键词或正则表达式。"""
if regex_pattern:
return bool(regex_pattern.search(summary))
return keyword.lower() in summary.lower()
def load_activity_log_index(activity_dir: str, days: int = PROMPT_LOAD_DAYS) -> dict[str, str]:
"""加载近期活动日志索引,不返回完整日志正文。"""
index: dict[str, str] = {}
for date_str in _iter_recent_dates(days):
log_path = _build_log_path(activity_dir, date_str)
if not log_path.is_file():
continue
try:
content = log_path.read_text(encoding="utf-8", errors="replace")
except Exception as e:
logger.warning(f"读取活动日志索引失败 {log_path}: {e}")
continue
entry_count = len(_parse_activity_entries(date_str, content))
if entry_count:
index[date_str] = f"{entry_count} 条活动记录"
return index
def query_activity_logs(
activity_dir: str,
*,
keyword: Optional[str] = None,
use_regex: bool = False,
date: Optional[str] = None,
days: int = DEFAULT_QUERY_DAYS,
limit: Optional[int] = DEFAULT_QUERY_LIMIT,
) -> dict[str, Any]:
"""
查询活动日志条目。
:param activity_dir: 活动日志目录
:param keyword: 可选关键词,按摘要文本过滤
:param use_regex: 是否将关键词按正则表达式匹配
:param date: 可选日期,格式为 ``YYYY-MM-DD``
:param days: 未指定日期时向前查询的天数
:param limit: 返回条数上限
:return: 查询结果载荷
"""
normalized_limit = _coerce_query_limit(limit)
normalized_keyword = str(keyword or "").strip()
normalized_use_regex = bool(use_regex)
regex_pattern: Optional[re.Pattern[str]] = None
if normalized_keyword and normalized_use_regex:
try:
regex_pattern = re.compile(normalized_keyword, re.IGNORECASE)
except re.error as err:
return {
"success": False,
"message": f"无效的活动日志正则表达式: {err}",
"activity_dir": activity_dir,
"keyword": normalized_keyword,
"use_regex": normalized_use_regex,
"date": date,
"days": days if not date else None,
"searched_dates": [],
"total_count": 0,
"returned_count": 0,
"truncated": False,
"entries": [],
}
date_candidates = [date] if date else _iter_recent_dates(days)
entries: list[dict[str, str]] = []
searched_dates: list[str] = []
for date_str in date_candidates:
if not date_str:
continue
searched_dates.append(date_str)
log_path = _build_log_path(activity_dir, date_str)
if not log_path.is_file():
continue
try:
content = log_path.read_text(encoding="utf-8", errors="replace")
except Exception as e:
logger.warning(f"读取活动日志失败 {log_path}: {e}")
continue
for entry in _parse_activity_entries(date_str, content):
if normalized_keyword and not _activity_summary_matches_keyword(
entry["summary"], normalized_keyword, regex_pattern
):
continue
entries.append(entry)
entries.sort(key=lambda item: (item["date"], item["time"]), reverse=True)
total_count = len(entries)
return {
"success": True,
"activity_dir": activity_dir,
"keyword": normalized_keyword or None,
"use_regex": normalized_use_regex,
"date": date,
"days": days if not date else None,
"searched_dates": searched_dates,
"total_count": total_count,
"returned_count": min(total_count, normalized_limit),
"truncated": total_count > normalized_limit,
"entries": entries[:normalized_limit],
}
class _ActivityLogToolProvider:
"""活动日志工具的查询实现。"""
def __init__(self, *, activity_dir: str) -> None:
"""初始化活动日志查询目录。"""
self._activity_dir = activity_dir
async def query_activity_log(
self,
keyword: Optional[str] = None,
use_regex: Optional[bool] = False,
date: Optional[str] = None,
days: Optional[int] = DEFAULT_QUERY_DAYS,
limit: Optional[int] = DEFAULT_QUERY_LIMIT,
) -> str:
"""查询活动日志并返回 JSON 字符串。"""
logger.info(
"查询活动日志: keyword=%s, use_regex=%s, date=%s, days=%s, limit=%s",
keyword,
use_regex,
date,
days,
limit,
)
try:
payload = query_activity_logs(
self._activity_dir,
keyword=keyword,
use_regex=bool(use_regex),
date=date,
days=days or DEFAULT_QUERY_DAYS,
limit=limit,
)
return json.dumps(payload, ensure_ascii=False, indent=2)
except Exception as err:
logger.error(f"查询活动日志失败: {err}", exc_info=True)
return json.dumps(
{
"success": False,
"message": f"查询活动日志时发生错误: {str(err)}",
},
ensure_ascii=False,
)
class ActivityLogState(AgentState):
"""ActivityLogMiddleware 的状态模型。"""
activity_log_contents: NotRequired[Annotated[dict[str, str], PrivateStateAttr]]
"""将日期字符串映射到日志内容的字典。标记为私有,不包含在最终代理状态中。"""
"""将日期字符串映射到日志索引摘要的字典。标记为私有,不包含在最终代理状态中。"""
class ActivityLogStateUpdate(TypedDict):
@@ -61,7 +329,7 @@ class ActivityLogStateUpdate(TypedDict):
activity_log_contents: dict[str, str]
def _extract_last_round(messages: list) -> list | None:
def _extract_last_round(messages: list) -> Optional[list]:
"""从完整消息列表中提取最后一轮交互。
从最后一条 HumanMessage 到消息末尾即为本轮交互。
@@ -148,7 +416,23 @@ def _format_conversation_for_summary(round_messages: list) -> str:
return "\n".join(lines)
async def _summarize_with_llm(conversation_text: str) -> str | None:
def _should_skip_activity_summary(round_messages: list) -> bool:
"""判断本轮交互是否无需生成活动日志。"""
if not round_messages:
return True
has_tool_activity = any(
isinstance(msg, ToolMessage)
or (isinstance(msg, AIMessage) and bool(getattr(msg, "tool_calls", None)))
for msg in round_messages
)
if has_tool_activity:
return False
return True
async def _summarize_with_llm(conversation_text: str) -> Optional[str]:
"""调用 LLM 对对话文本生成活动摘要。
参数:
@@ -166,50 +450,39 @@ async def _summarize_with_llm(conversation_text: str) -> str | None:
summary = response.content.strip()
# 清理模型可能输出的前缀(如 "摘要:" "总结:"
summary = re.sub(r"^(摘要|总结|活动记录)[:]\s*", "", summary)
if summary.strip().upper() == SUMMARY_SKIP_MARKER:
return None
return summary if summary else None
except Exception as e:
logger.debug("LLM summarization failed: %s", e)
logger.debug(f"LLM 活动摘要生成失败: {e}")
return None
ACTIVITY_LOG_SYSTEM_PROMPT = """<activity_log>
{activity_log}
</activity_log>
<activity_log_index>
{activity_log_index}
</activity_log_index>
<activity_log_guidelines>
The above <activity_log> contains a record of your recent interactions with the user, automatically maintained by the system.
**How to use this information:**
- Reference past activities when relevant to provide continuity (e.g., "之前帮你订阅了《XXX》现在有更新了")
- Use activity history to understand ongoing tasks and user patterns
- When the user asks "你之前帮我做了什么" or similar questions, refer to this log
- Activity logs are automatically recorded after each interaction - you do NOT need to manually update them
**What is automatically logged:**
- Each user interaction: what was asked, which tools were used, and the outcome
- Timestamps for all activities
- The log is organized by date for easy reference
**Important:**
- Activity logs are READ-ONLY from your perspective - the system manages them automatically
- Do not attempt to edit or write to activity log files
- For long-term preferences and knowledge, continue to use MEMORY.md
- Activity logs are retained for {retention_days} days and then automatically cleaned up
The index only shows recent dates and entry counts, not full log contents.
Use `query_activity_log` only when the user references previous work, asks to continue a prior task, or recent activity is clearly relevant.
Activity logs are read-only and retained for {retention_days} days; use MEMORY.md for durable preferences.
</activity_log_guidelines>
</activity_log>
"""
class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, ResponseT]): # noqa
"""自动记录和加载 Agent 活动日志的中间件。
"""自动记录 Agent 活动日志并注入轻量索引的中间件。
- abefore_agent: 加载近几天的活动日志
- awrap_model_call: 将活动日志注入系统提示词
- abefore_agent: 加载近几天的活动日志索引
- awrap_model_call: 将活动日志索引和检索规则注入系统提示词
- aafter_agent: 从本次对话中提取摘要并追加到当日日志文件
参数:
activity_dir: 活动日志存储目录路径。
retention_days: 日志保留天数(默认 7 天)。
prompt_load_days: 注入系统提示词时加载的天数(默认 3 天)。
prompt_load_days: 注入系统提示词时索引的天数(默认 3 天)。
"""
state_schema = ActivityLogState
@@ -220,20 +493,34 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
activity_dir: str,
retention_days: int = DEFAULT_RETENTION_DAYS,
prompt_load_days: int = PROMPT_LOAD_DAYS,
stream_handler: Optional[Any] = None,
) -> None:
"""初始化活动日志中间件。"""
self.activity_dir = activity_dir
self.retention_days = retention_days
self.prompt_load_days = prompt_load_days
self.stream_handler = stream_handler
self._background_tasks: set[asyncio.Task[None]] = set()
self._tool_provider = _ActivityLogToolProvider(activity_dir=activity_dir)
self.tools = [
StructuredTool.from_function(
coroutine=self._tool_provider.query_activity_log,
name=QUERY_ACTIVITY_LOG_TOOL_NAME,
description=QUERY_ACTIVITY_LOG_TOOL_DESCRIPTION,
args_schema=QueryActivityLogInput,
tags=[ToolTag.Read, ToolTag.System],
)
]
def _get_log_path(self, date_str: str) -> AsyncPath:
"""获取指定日期的日志文件路径。"""
return AsyncPath(self.activity_dir) / f"{date_str}.md"
def _format_activity_log(self, contents: dict[str, str]) -> str:
"""格式化活动日志用于系统提示词注入。"""
"""格式化活动日志索引用于系统提示词注入。"""
if not contents:
return ACTIVITY_LOG_SYSTEM_PROMPT.format(
activity_log="(暂无活动记录)",
activity_log_index="(近期暂无活动日志索引。需要历史上下文时可调用 query_activity_log。)",
retention_days=self.retention_days,
)
@@ -247,35 +534,22 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
if not sections:
return ACTIVITY_LOG_SYSTEM_PROMPT.format(
activity_log="(暂无活动记录)",
activity_log_index="(近期暂无活动日志索引。需要历史上下文时可调用 query_activity_log。)",
retention_days=self.retention_days,
)
log_body = "\n\n".join(sections)
log_body = "\n".join(sections)
return ACTIVITY_LOG_SYSTEM_PROMPT.format(
activity_log=log_body,
activity_log_index=log_body,
retention_days=self.retention_days,
)
async def _load_recent_logs(self) -> dict[str, str]:
"""加载近几天的活动日志。"""
contents: dict[str, str] = {}
today = datetime.now().date()
for i in range(self.prompt_load_days):
date = today - timedelta(days=i)
date_str = date.strftime("%Y-%m-%d")
log_path = self._get_log_path(date_str)
if await log_path.exists():
try:
content = await log_path.read_text(encoding="utf-8")
contents[date_str] = content
logger.debug("Loaded activity log for %s", date_str)
except Exception as e:
logger.warning("Failed to load activity log %s: %s", date_str, e)
return contents
"""加载近几天的活动日志索引"""
return load_activity_log_index(
activity_dir=self.activity_dir,
days=self.prompt_load_days,
)
async def _append_activity(self, summary: str) -> None:
"""将一条活动记录追加到当日日志文件。"""
@@ -303,14 +577,29 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
entry = f"- **{now_str}** {summary}\n"
try:
if await log_path.exists():
existing = await log_path.read_text(encoding="utf-8")
await log_path.write_text(existing + entry, encoding="utf-8")
async with await anyio.open_file(
log_path,
mode="a",
encoding="utf-8",
) as stream:
await stream.write(entry)
else:
header = f"# {today_str} 活动日志\n\n"
await log_path.write_text(header + entry, encoding="utf-8")
logger.debug("Activity logged: %s", summary[:80])
try:
fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
except FileExistsError:
async with await anyio.open_file(
log_path,
mode="a",
encoding="utf-8",
) as stream:
await stream.write(entry)
else:
with os.fdopen(fd, "w", encoding="utf-8") as stream:
stream.write(header + entry)
logger.debug(f"Activity logged: {summary[:80]}")
except Exception as e:
logger.warning("Failed to append activity log: %s", e)
logger.warning(f"Failed to append activity log: {e}")
async def _cleanup_old_logs(self) -> None:
"""清理超过保留天数的旧日志文件。"""
@@ -332,20 +621,54 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
file_date = datetime.strptime(match.group(1), "%Y-%m-%d").date()
if file_date < cutoff_date:
await path.unlink()
logger.debug("Cleaned up old activity log: %s", path.name)
logger.debug(f"Cleaned up old activity log: {path.name}")
except ValueError:
continue
except Exception as e:
logger.warning("Failed to cleanup old activity logs: %s", e)
logger.warning(f"Failed to cleanup old activity logs: {e}")
def _schedule_activity_recording(self, messages: list) -> None:
"""提交后台活动记录任务,不阻塞当前 Agent 会话结束。"""
task = asyncio.create_task(self._record_activity(messages))
self._background_tasks.add(task)
task.add_done_callback(self._on_activity_recording_done)
def _on_activity_recording_done(self, task: asyncio.Task[None]) -> None:
"""清理已完成的后台任务并记录未捕获异常。"""
self._background_tasks.discard(task)
try:
task.result()
except asyncio.CancelledError:
logger.debug("活动日志后台记录任务已取消")
except Exception as err:
logger.warning(f"活动日志后台记录任务失败: {err}")
async def _record_activity(self, messages: list) -> None:
"""在后台生成本轮活动摘要并写入活动日志。"""
try:
# 提取本轮交互
round_messages = _extract_last_round(messages)
if not round_messages:
return
if _should_skip_activity_summary(round_messages):
return
# 格式化对话文本
conversation_text = _format_conversation_for_summary(round_messages)
if not conversation_text:
return
# 调用 LLM 生成摘要
summary = await _summarize_with_llm(conversation_text)
if summary:
await self._append_activity(summary)
except Exception as e:
logger.warning(f"Failed to record activity: {e}")
async def abefore_agent(
self, state: ActivityLogState, runtime: Runtime
) -> ActivityLogStateUpdate | None:
) -> Optional[ActivityLogStateUpdate]:
"""在 Agent 执行前加载近期活动日志。"""
# 如果已经加载则跳过
if "activity_log_contents" in state:
return None
contents = await self._load_recent_logs()
# 趁机清理旧日志(低频操作,不影响性能)
@@ -374,33 +697,57 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
modified_request = self.modify_request(request)
return await handler(modified_request)
async def awrap_tool_call(
self,
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], Awaitable[Any]],
) -> Any:
"""在活动日志查询工具执行时记录聚合摘要。"""
tool = request.tool
tool_name = getattr(tool, "name", None)
if tool_name != QUERY_ACTIVITY_LOG_TOOL_NAME:
return await handler(request)
tool_call = request.tool_call or {}
tool_args = tool_call.get("args") or {}
if not isinstance(tool_args, dict):
tool_args = {}
logger.info(
f"开始执行活动日志查询工具: keyword={tool_args.get('keyword') or '-'}, "
f"date={tool_args.get('date') or '-'}"
)
if self.stream_handler and getattr(self.stream_handler, "is_streaming", False):
self.stream_handler.record_tool_call(
tool_name=QUERY_ACTIVITY_LOG_TOOL_NAME,
tool_message=QUERY_ACTIVITY_LOG_TOOL_DESCRIPTION,
tool_kwargs=tool_args,
)
try:
result = await handler(request)
except Exception as err:
logger.error(f"活动日志查询工具执行失败: error={err}")
raise
logger.info("活动日志查询工具执行完成")
return result
async def aafter_agent(
self, state: ActivityLogState, runtime: Runtime
) -> dict[str, Any] | None:
"""Agent 执行完毕后,调用 LLM 对本轮对话生成摘要并追加到当日活动日志"""
) -> Optional[dict[str, Any]]:
"""Agent 执行完毕后,异步提交活动日志记录任务"""
try:
messages = state.get("messages", [])
if not messages:
return None
# 提取本轮交互
round_messages = _extract_last_round(messages)
if not round_messages:
return None
# 格式化对话文本
conversation_text = _format_conversation_for_summary(round_messages)
if not conversation_text:
return None
# 调用 LLM 生成摘要
summary = await _summarize_with_llm(conversation_text)
if summary:
await self._append_activity(summary)
self._schedule_activity_recording(list(messages))
except Exception as e:
logger.warning("Failed to record activity: %s", e)
logger.warning(f"Failed to record activity: {e}")
return None
__all__ = ["ActivityLogMiddleware"]
__all__ = [
"ActivityLogMiddleware",
"QUERY_ACTIVITY_LOG_TOOL_NAME",
"load_activity_log_index",
"query_activity_logs",
]

View File

@@ -128,7 +128,7 @@ def _parse_job_metadata(
async def _alist_jobs(source_path: AsyncPath) -> list[JobMetadata]:
"""异步列出指定路径下的所有任务。
扫描包含 JOB.md 的目录并解析其元数据。
扫描包含 JOB.md 的目录并解析其元数据,遇到非法 UTF-8 字节时以替换字符兜底
"""
jobs: list[JobMetadata] = []
@@ -151,7 +151,10 @@ async def _alist_jobs(source_path: AsyncPath) -> list[JobMetadata]:
for job_path in job_dirs:
job_md_path = job_path / "JOB.md"
job_content = await job_md_path.read_text(encoding="utf-8")
job_content = await job_md_path.read_text(
encoding="utf-8",
errors="replace",
)
# 解析元数据
job_metadata = _parse_job_metadata(
@@ -192,7 +195,7 @@ async def load_jobs_metadata(source_paths: list[str]) -> list[JobMetadata]:
JOBS_SYSTEM_PROMPT = """
<jobs_system>
You have a **scheduled jobs** system that allows you to track and execute long-running or recurring tasks.
You have a scheduled jobs system for user-requested delayed or recurring work.
**Jobs Location:** `{jobs_location}`
@@ -200,71 +203,16 @@ You have a **scheduled jobs** system that allows you to track and execute long-r
{jobs_list}
**Job File Format:**
Each job is a directory containing a `JOB.md` file with YAML frontmatter followed by task details:
```markdown
---
name: 任务名称(简短中文描述)
description: 任务的详细描述,说明要做什么
schedule: once 或 recurring
status: pending / in_progress / completed / cancelled
last_run: "YYYY-MM-DD HH:MM"(上次执行时间,可选)
---
# 任务详情
## 目标
详细描述这个任务要完成的目标。
## 执行日志
记录每次执行的情况和结果。
- **2024-01-15 10:00** - 执行了XXX操作结果成功/失败
- **2024-01-16 10:00** - 继续执行XXX...
```
**Job Lifecycle Rules:**
1. **Creating a Job**: When a user asks you to do something periodically or at a later time:
- Create a new directory under the jobs location, directory name is the `job-id` (lowercase, hyphens, 1-64 chars)
- Write a `JOB.md` file with proper frontmatter and detailed task description
- Set `schedule: once` for one-time tasks, `schedule: recurring` for repeating tasks (e.g., daily sign-in, weekly checks)
- Set initial `status: pending`
2. **Executing a Job**: When you work on a job:
- Update `status: in_progress` in the frontmatter
- Execute the required actions using your tools
- Log the execution result in the "执行日志" section with timestamp
- Update `last_run` in frontmatter to current time
3. **Completing a Job**:
- For `schedule: once` tasks: set `status: completed` after successful execution
- For `schedule: recurring` tasks: keep `status: pending` after execution, only update `last_run` time. The job stays active for the next scheduled run.
- Set `status: cancelled` if the user explicitly asks to cancel/stop a task
4. **Heartbeat Check**: You will be periodically woken up to check pending jobs. When woken up:
- Read the jobs directory to find all active jobs (status: pending or in_progress)
- Skip jobs with `status: completed` or `status: cancelled`
- For `schedule: recurring` jobs, check `last_run` to determine if it's time to run again
- Execute pending jobs and update their status/logs accordingly
**Important Notes:**
- Each job MUST have its own separate directory and JOB.md file to avoid conflicts
- Always update the frontmatter fields (status, last_run) when executing a job
- Keep execution logs concise but informative
- For recurring jobs, maintain a rolling log (keep recent entries, you can summarize/remove old entries to keep the file manageable)
- When creating jobs, make the description detailed enough that you can understand and execute the task in future sessions without additional context
**When to Create Jobs:**
- User says "每天帮我..." / "定期..." / "定时..." / "提醒我..." / "以后每次..."
- User requests a task that should be done repeatedly
- User asks for monitoring or periodic checking of something
**When NOT to Create Jobs:**
- User asks for an immediate one-time action (just do it now)
- Simple questions or conversations
- Tasks that are already handled by MoviePilot's built-in scheduler services
Rules:
- For new delayed, recurring, reminder, or monitoring work, use the dedicated
`create_agent_task`, `query_agent_tasks`, `update_agent_task`, `run_agent_task`,
and `delete_agent_task` tools. These tools use integer task IDs. Do not create
or edit JOB.md files for new tasks.
- Use `query_schedulers` and `run_scheduler` only for MoviePilot system, plugin,
or workflow runtime services; never pass their string job IDs to Agent task tools.
- Do not create tasks for immediate one-time work or work already handled by MoviePilot schedulers.
- Entries listed above are legacy JOB.md tasks. Read their files only when a heartbeat asks you to execute them.
- During heartbeat checks, act only on `pending` or `in_progress` jobs, update status/last_run/logs, and leave recurring jobs `pending` after each run.
</jobs_system>
"""
@@ -287,7 +235,7 @@ class JobsMiddleware(AgentMiddleware[JobsState, ContextT, ResponseT]): # noqa
def _format_jobs_list(jobs: list[JobMetadata]) -> str:
"""格式化任务元数据列表用于系统提示词。"""
if not jobs:
return "(No active jobs. You can create jobs when users request periodic or scheduled tasks.)"
return "(No active legacy JOB.md tasks. Use create_agent_task for new scheduled work.)"
lines = []
for job in jobs:
@@ -340,12 +288,7 @@ class JobsMiddleware(AgentMiddleware[JobsState, ContextT, ResponseT]): # noqa
) -> JobsStateUpdate | None:
"""在 Agent 执行前异步加载任务元数据。
每个会话仅加载一次。若 state 中已有则跳过。
"""
# 如果 state 中已存在元数据则跳过
if "jobs_metadata" in state:
return None
return JobsStateUpdate(
jobs_metadata=await load_jobs_metadata(self.sources)
)

View File

@@ -302,7 +302,6 @@ class MemoryMiddleware(AgentMiddleware[MemoryState, ContextT, ResponseT]): # no
"""在代理执行前扫描记忆目录并加载所有 .md 文件的内容。
自动发现目录下所有 `.md` 文件并加载其内容到状态中。
如果状态中尚未存在则进行加载。
同时检测记忆文件是否为空,设置 memory_empty 标志位,
以便在系统提示词中触发初始化引导流程。
@@ -314,10 +313,6 @@ class MemoryMiddleware(AgentMiddleware[MemoryState, ContextT, ResponseT]): # no
返回:
填充了 memory_contents 和 memory_empty 的状态更新。
"""
# 如果已经加载则跳过
if "memory_contents" in state:
return None
# 扫描目录下所有 .md 文件
md_files = await self._scan_memory_files()
@@ -335,7 +330,7 @@ class MemoryMiddleware(AgentMiddleware[MemoryState, ContextT, ResponseT]): # no
MAX_MEMORY_FILE_SIZE,
)
continue
contents[path] = await file_path.read_text(encoding="utf-8")
contents[path] = await file_path.read_text(encoding="utf-8", errors="replace")
logger.debug("Loaded memory from: %s", path)
except Exception as e:
logger.warning("Failed to read memory file %s: %s", path, e)

View File

@@ -1,8 +1,9 @@
import json
import re
import shutil
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Annotated, List
from typing import Annotated, Any, List, Optional
from typing import NotRequired, TypedDict
import yaml # noqa
@@ -14,12 +15,16 @@ from langchain.agents.middleware.types import (
ModelRequest,
ModelResponse,
ResponseT,
ToolCallRequest,
)
from langchain.agents.middleware.types import PrivateStateAttr # noqa
from langchain_core.runnables import RunnableConfig
from langchain_core.tools import StructuredTool
from langgraph.runtime import Runtime
from pydantic import BaseModel, Field
from app.agent.middleware.utils import append_to_system_message
from app.agent.tools.tags import ToolTag
from app.log import logger
# 安全提示: SKILL.md 文件最大限制为 10MB防止 DoS 攻击
@@ -84,6 +89,15 @@ class SkillsStateUpdate(TypedDict):
"""待合并的 skill 元数据列表。"""
class SkillToolInput(BaseModel):
"""Skill 加载工具的输入参数模型。"""
name: str = Field(
...,
description="Skill name or id from the available skills list.",
)
def _parse_skill_metadata( # noqa: C901
content: str,
skill_path: str,
@@ -234,7 +248,7 @@ async def _alist_skills(source_path: AsyncPath) -> list[SkillMetadata]:
for skill_path in skill_dirs:
skill_md_path = skill_path / "SKILL.md"
skill_content = await skill_md_path.read_text(encoding="utf-8")
skill_content = await skill_md_path.read_text(encoding="utf-8", errors="replace")
# 解析元数据
skill_metadata = _parse_skill_metadata(
@@ -248,73 +262,63 @@ async def _alist_skills(source_path: AsyncPath) -> list[SkillMetadata]:
return skills
def _list_skills(source_path: Path) -> list[SkillMetadata]:
"""同步列出指定路径下的所有技能元数据。"""
if not source_path.exists():
return []
skill_dirs = [
path
for path in source_path.iterdir()
if path.is_dir() and (path / "SKILL.md").is_file()
]
if not skill_dirs:
return []
skill_dirs.sort(key=lambda p: p.name.casefold())
skills: list[SkillMetadata] = []
for skill_path in skill_dirs:
skill_md_path = skill_path / "SKILL.md"
skill_content = skill_md_path.read_text(encoding="utf-8", errors="replace")
skill_metadata = _parse_skill_metadata(
content=skill_content,
skill_path=str(skill_md_path),
skill_id=skill_path.name,
)
if skill_metadata:
skills.append(skill_metadata)
return skills
SKILLS_SYSTEM_PROMPT = """
<skills_system>
You have access to a skills library that provides specialized capabilities and domain knowledge.
{skills_locations}
You have access to a skills library for specialized MoviePilot workflows.
**Available Skills:**
{skills_list}
**How to Use Skills (Progressive Disclosure):**
Skills follow a **progressive disclosure** pattern - you see their name and description above, but only read full instructions when needed:
1. **Recognize when a skill applies**: Check if the user's task matches a skill's description
2. **Read the skill's full instructions**: Use the path shown in the skill list above
3. **Follow the skill's instructions**: SKILL.md contains step-by-step workflows, best practices, and examples
4. **Access supporting files**: Skills may include helper scripts, configs, or reference docs - use absolute paths
**Creating New Skills:**
When you identify a repetitive complex workflow or specialized task that would benefit from being a skill, you can create one:
1. **Directory Structure**: Create a new directory in one of the skills locations. The directory name is the `skill-id`.
- Path format: `<skills_location>/<skill-id>/SKILL.md`
- `skill-id` constraints: 1-64 characters, lowercase letters, numbers, and hyphens only.
2. **SKILL.md Format**: Must start with a YAML frontmatter followed by markdown instructions.
```markdown
---
name: Brief tool name (Chinese)
description: Detailed functional description and use cases (1-1024 chars)
allowed-tools: "tool1 tool2" (optional, space-separated list of recommended tools)
compatibility: "Environment requirements" (optional, max 500 chars)
---
# Skill Instructions
Step-by-step workflows, best practices, and examples go here.
```
3. **Supporting Files**: You can add `.py` scripts, `.yaml` configs, or other files within the same skill directory. Reference them using absolute paths in `SKILL.md`.
**When to Use Skills:**
- User's request matches a skill's domain (e.g., "research X" -> web-research skill)
- You need specialized knowledge or structured workflows
- A skill provides proven patterns for complex tasks
**Executing Skill Scripts:**
Skills may contain Python scripts or other executable files. Always use absolute paths from the skill list.
**Example Workflow:**
User: "Can you research the latest developments in quantum computing?"
1. Check available skills -> See "web-research" skill with its path
2. Read the skill using the path shown
3. Follow the skill's research workflow (search -> organize -> synthesize)
4. Use any helper scripts with absolute paths
Remember: Skills make you more capable and consistent. When in doubt, check if a skill exists for the task!
When the user's request matches a skill description, call the `skill` tool with that skill name before taking task actions. Follow the loaded SKILL.md instructions, and load referenced supporting files only when needed. Do not create or rewrite skills unless the user explicitly asks for skill authoring.
</skills_system>
"""
SKILL_TOOL_NAME = "skill"
SKILL_TOOL_DESCRIPTION = """Loads the full instructions for a MoviePilot skill by name or id.
Available skills:
{skills_catalog}
Call this tool when the user's task matches one of the available skills. The tool returns the SKILL.md content and metadata so you can follow the skill's instructions. Do not use this for simple tasks that do not need a skill.
"""
def _extract_version(skill_md: Path) -> int:
"""从 SKILL.md 文件中快速提取 version 字段,无法提取时返回 0。"""
try:
content = skill_md.read_text(encoding="utf-8")
content = skill_md.read_text(encoding="utf-8", errors="replace")
except Exception as err:
print(err)
logger.debug(f"读取技能版本失败: {err}")
return 0
match = re.match(r"^---\s*\n(.*?)\n---\s*\n", content, re.DOTALL)
if not match:
@@ -402,6 +406,108 @@ def _sync_bundled_skills(bundled_dir: Path, target_dir: Path) -> None:
logger.warning("更新内置技能 '%s' 失败: %s", skill_src.name, e)
class _SkillToolProvider:
"""Skill 工具的目录扫描和文件读取实现。"""
def __init__(self, *, sources: list[str]) -> None:
"""初始化 Skill 工具数据源。"""
self._sources = sources
@staticmethod
def _normalize_name(value: object) -> str:
"""标准化技能名称用于匹配。"""
return str(value or "").strip().casefold()
@classmethod
def _skill_matches(cls, skill: SkillMetadata, query: str) -> bool:
"""判断技能元数据是否匹配用户提供的名称。"""
normalized_query = cls._normalize_name(query)
candidates = [
skill.get("id"),
skill.get("name"),
]
return any(
cls._normalize_name(candidate) == normalized_query
for candidate in candidates
)
async def _find_skill(self, name: str) -> Optional[SkillMetadata]:
"""从中间件配置的 skills 目录中查找指定技能。"""
all_skills: dict[str, SkillMetadata] = {}
for source_path in self._sources:
skill_source_path = AsyncPath(source_path)
if not await skill_source_path.exists():
continue
for skill in await _alist_skills(skill_source_path):
all_skills[skill["name"]] = skill
for skill in all_skills.values():
if self._skill_matches(skill, name):
return skill
return None
@staticmethod
async def _read_skill_content(skill_path: str) -> tuple[str, bool]:
"""读取技能文件内容,并在超出上限时返回截断标记。"""
path = AsyncPath(skill_path)
stat = await path.stat()
truncated = stat.st_size > MAX_SKILL_FILE_SIZE
async with await path.open("rb") as handle:
raw_content = await handle.read(MAX_SKILL_FILE_SIZE)
return raw_content.decode("utf-8", errors="replace"), truncated
async def load_skill(self, name: str) -> str:
"""加载指定 Skill 的完整说明并返回 JSON 字符串。"""
logger.info(f"加载 Skill: name={name}")
try:
skill = await self._find_skill(name)
if not skill:
return json.dumps(
{
"success": False,
"message": f"未找到 Skill: {name}",
},
ensure_ascii=False,
)
content, truncated = await self._read_skill_content(skill["path"])
return json.dumps(
{
"success": True,
"skill": {
"id": skill.get("id"),
"name": skill.get("name"),
"description": skill.get("description"),
"path": skill.get("path"),
"allowed_tools": skill.get("allowed_tools", []),
},
"content": content,
"truncated": truncated,
},
ensure_ascii=False,
indent=2,
)
except Exception as err:
logger.error(f"加载 Skill 失败: {err}", exc_info=True)
return json.dumps(
{
"success": False,
"message": f"加载 Skill 时发生错误: {str(err)}",
},
ensure_ascii=False,
)
def _format_skill_tool_catalog(skills: list[SkillMetadata]) -> str:
"""渲染 Skill 工具描述中的可用技能目录。"""
if not skills:
return "(No skills are currently available.)"
return "\n".join(
f"- {skill['id']}: {skill['name']} - {skill['description']}"
for skill in skills
)
class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # noqa
"""加载并向系统提示词注入 Agent Skill 的中间件。
@@ -416,6 +522,7 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no
*,
sources: list[str],
bundled_skills_dir: str | None = None,
stream_handler: Optional[Any] = None,
) -> None:
"""初始化 Skill 中间件。
@@ -426,26 +533,63 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no
bundled_skills_dir : str | None
项目内置技能目录路径。若提供,在首次加载前会将其中不存在于
sources 首个目录的技能自动复制过去。
stream_handler : Optional[Any]
流式输出处理器,用于记录 skill 工具调用摘要。
"""
self.sources = sources
self.bundled_skills_dir = bundled_skills_dir
self.stream_handler = stream_handler
self.system_prompt_template = SKILLS_SYSTEM_PROMPT
self._skill_provider = _SkillToolProvider(sources=sources)
self.tools = [
StructuredTool.from_function(
coroutine=self._skill_provider.load_skill,
name=SKILL_TOOL_NAME,
description=SKILL_TOOL_DESCRIPTION.format(
skills_catalog=_format_skill_tool_catalog(
self._load_skills_metadata()
)
),
args_schema=SkillToolInput,
tags=[ToolTag.Read, ToolTag.Skill],
)
]
def _format_skills_locations(self) -> str:
"""格式化技能位置信息用于系统提示词"""
locations = []
def _sync_bundled_skills(self) -> None:
"""将项目内置 Skill 同步到首个用户技能目录"""
if not self.bundled_skills_dir or not self.sources:
return
bundled = Path(self.bundled_skills_dir)
target = Path(self.sources[0])
try:
_sync_bundled_skills(bundled, target)
except Exception as e:
logger.warning("同步内置技能失败: %s", e)
for i, source_path in enumerate(self.sources):
suffix = " (higher priority)" if i == len(self.sources) - 1 else ""
locations.append(f"**MoviePilot Skills**: `{source_path}`{suffix}")
def _load_skills_metadata(self) -> list[SkillMetadata]:
"""同步加载当前配置目录中的 Skill 元数据。"""
self._sync_bundled_skills()
all_skills: dict[str, SkillMetadata] = {}
for source_path in self.sources:
for skill in _list_skills(Path(source_path)):
all_skills[skill["name"]] = skill
return list(all_skills.values())
return "\n".join(locations)
def _refresh_skill_tool_description(
self, skills: list[SkillMetadata]
) -> None:
"""刷新 skill 工具描述中的可用技能目录。"""
if not self.tools:
return
self.tools[0].description = SKILL_TOOL_DESCRIPTION.format(
skills_catalog=_format_skill_tool_catalog(skills)
)
def _format_skills_list(self, skills: list[SkillMetadata]) -> str:
@staticmethod
def _format_skills_list(skills: list[SkillMetadata]) -> str:
"""格式化技能元数据列表用于系统提示词。"""
if not skills:
paths = [f"{source_path}" for source_path in self.sources]
return f"(No skills available yet. You can create skills in {' or '.join(paths)})"
return "(No skills available yet.)"
lines = []
for skill in skills:
@@ -456,18 +600,15 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no
lines.append(desc_line)
if skill["allowed_tools"]:
lines.append(f" -> Allowed tools: {', '.join(skill['allowed_tools'])}")
lines.append(f" -> Read `{skill['path']}` for full instructions")
return "\n".join(lines)
def modify_request(self, request: ModelRequest[ContextT]) -> ModelRequest[ContextT]:
"""将技能文档注入模型请求的系统消息中。"""
skills_metadata = request.state.get("skills_metadata", []) # noqa
skills_locations = self._format_skills_locations()
skills_list = self._format_skills_list(skills_metadata)
skills_section = self.system_prompt_template.format(
skills_locations=skills_locations,
skills_list=skills_list,
)
@@ -482,21 +623,9 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no
) -> SkillsStateUpdate | None: # ty: ignore[invalid-method-override]
"""在 Agent 执行前异步加载技能元数据。
每个会话仅加载一次。若 state 中已有则跳过。
首次加载时,会先将内置技能同步到用户目录(如不存在)。
"""
# 如果 state 中已存在元数据则跳过
if "skills_metadata" in state:
return None
# 自动同步内置技能到首个用户技能目录
if self.bundled_skills_dir and self.sources:
bundled = Path(self.bundled_skills_dir)
target = Path(self.sources[0])
try:
_sync_bundled_skills(bundled, target)
except Exception as e:
logger.warning("同步内置技能失败: %s", e)
self._sync_bundled_skills()
all_skills: dict[str, SkillMetadata] = {}
@@ -511,6 +640,7 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no
all_skills[skill["name"]] = skill
skills = list(all_skills.values())
self._refresh_skill_tool_description(skills)
return SkillsStateUpdate(skills_metadata=skills)
async def awrap_model_call(
@@ -524,5 +654,37 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no
modified_request = self.modify_request(request)
return await handler(modified_request)
async def awrap_tool_call(
self,
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], Awaitable[Any]],
) -> Any:
"""在 skill 工具执行时记录聚合摘要。"""
tool = request.tool
tool_name = getattr(tool, "name", None)
if tool_name != SKILL_TOOL_NAME:
return await handler(request)
__all__ = ["SkillMetadata", "SkillsMiddleware"]
tool_call = request.tool_call or {}
tool_args = tool_call.get("args") or {}
if not isinstance(tool_args, dict):
tool_args = {}
logger.info(
f"开始执行 Skill 工具: name={tool_args.get('name') or '-'}"
)
if self.stream_handler and getattr(self.stream_handler, "is_streaming", False):
self.stream_handler.record_tool_call(
tool_name=SKILL_TOOL_NAME,
tool_message="Skill loaded",
tool_kwargs=tool_args,
)
try:
result = await handler(request)
except Exception as err:
logger.error(f"Skill 工具执行失败: error={err}")
raise
logger.info("Skill 工具执行完成")
return result
__all__ = ["SKILL_TOOL_NAME", "SkillMetadata", "SkillsMiddleware"]

View File

@@ -23,6 +23,7 @@ from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.tools import BaseTool, StructuredTool
from pydantic import BaseModel, Field
from app.agent.llm import LLMHelper
from app.agent.middleware.utils import append_to_system_message
from app.agent.runtime import SubAgentDefinition, agent_runtime_manager
from app.agent.tools.tags import ToolTag
@@ -196,18 +197,44 @@ def is_subagent_stream_metadata(metadata: Any) -> bool:
) == SUBAGENT_STREAM_MARKER_VALUE:
return True
return bool(metadata.get("lc_agent_name") in builtin_subagent_names())
return bool(
metadata.get("lc_agent_name")
in builtin_subagent_names(agent_runtime_manager.current_signature())
)
@lru_cache(maxsize=1)
def builtin_subagent_names() -> frozenset[str]:
def builtin_subagent_names(
runtime_signature: Optional[tuple[tuple[str, int, int], ...]] = None,
) -> frozenset[str]:
"""返回内置子代理名称集合。"""
return frozenset(profile.name for profile in _builtin_subagent_profiles())
runtime_signature = runtime_signature or agent_runtime_manager.current_signature()
return _cached_builtin_subagent_names(runtime_signature)
@lru_cache(maxsize=1)
def _builtin_subagent_profiles() -> tuple[_SubAgentProfile, ...]:
@lru_cache(maxsize=8)
def _cached_builtin_subagent_names(
runtime_signature: tuple[tuple[str, int, int], ...],
) -> frozenset[str]:
"""按运行时签名缓存内置子代理名称集合。"""
return frozenset(
profile.name
for profile in _builtin_subagent_profiles(runtime_signature)
)
def _builtin_subagent_profiles(
runtime_signature: Optional[tuple[tuple[str, int, int], ...]] = None,
) -> tuple[_SubAgentProfile, ...]:
"""从运行时配置目录加载 MoviePilot 子代理定义。"""
runtime_signature = runtime_signature or agent_runtime_manager.current_signature()
return _cached_builtin_subagent_profiles(runtime_signature)
@lru_cache(maxsize=8)
def _cached_builtin_subagent_profiles(
runtime_signature: tuple[tuple[str, int, int], ...],
) -> tuple[_SubAgentProfile, ...]:
"""按运行时签名缓存 MoviePilot 子代理定义。"""
definitions = agent_runtime_manager.list_subagents()
profiles = tuple(
_profile_from_runtime_definition(definition)
@@ -236,6 +263,10 @@ def _builtin_subagent_profiles() -> tuple[_SubAgentProfile, ...]:
)
builtin_subagent_names.cache_clear = _cached_builtin_subagent_names.cache_clear
_builtin_subagent_profiles.cache_clear = _cached_builtin_subagent_profiles.cache_clear
def _profile_from_runtime_definition(
definition: SubAgentDefinition,
) -> _SubAgentProfile:
@@ -281,34 +312,6 @@ def _format_subagent_catalog(profiles: tuple[_SubAgentProfile, ...]) -> str:
)
def _extract_text_content(content: Any) -> str:
"""从模型消息内容中提取可读文本。"""
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
text_parts: list[str] = []
for block in content:
if isinstance(block, str):
text_parts.append(block)
continue
if isinstance(block, dict):
if block.get("thought"):
continue
if block.get("type") in {
"thinking",
"reasoning_content",
"reasoning",
"thought",
}:
continue
if isinstance(block.get("text"), str):
text_parts.append(block["text"])
return "".join(text_parts)
return str(content)
def _extract_final_text(result: Any) -> str:
"""从子代理执行结果中提取最后一条 AI 文本。"""
if isinstance(result, dict):
@@ -318,11 +321,11 @@ def _extract_final_text(result: Any) -> str:
for message in reversed(messages):
if isinstance(message, AIMessage) and message.content:
text = _extract_text_content(message.content).strip()
text = LLMHelper.extract_text_content(message.content).strip()
if text:
return text
return _extract_text_content(result).strip()
return LLMHelper.extract_text_content(result, fallback_to_string=True).strip()
def _clip_text(text: Any, max_chars: int) -> tuple[str, bool]:
@@ -340,6 +343,31 @@ def _format_datetime(value: Optional[datetime]) -> Optional[str]:
return value.strftime("%Y-%m-%d %H:%M:%S")
def _extract_tool_call_args(request: ToolCallRequest) -> dict[str, Any]:
"""提取工具调用参数,并规整为字典。"""
tool_call = request.tool_call or {}
tool_args = tool_call.get("args") or {}
if not isinstance(tool_args, dict):
return {}
return tool_args
def _record_subagent_tool_call(
*,
stream_handler: Any,
tool_name: str,
tool_args: dict[str, Any],
) -> None:
"""在流式处理器中记录子代理工具调用摘要。"""
if not stream_handler or not getattr(stream_handler, "is_streaming", False):
return
stream_handler.record_tool_call(
tool_name=tool_name,
tool_message="Subagent invoked",
tool_kwargs=tool_args,
)
class _SubAgentAgentProvider:
"""子代理图懒加载与执行器。"""
@@ -436,8 +464,10 @@ class MoviePilotSubAgentMiddleware(AgentMiddleware):
tools: list[BaseTool],
system_prompt: str = SUBAGENT_PARENT_PROMPT,
task_description: str = SUBAGENT_TASK_DESCRIPTION,
stream_handler: Any = None,
) -> None:
self.system_prompt = system_prompt
self.stream_handler = stream_handler
self._provider = _SubAgentAgentProvider(
model=model,
profiles=profiles,
@@ -480,6 +510,35 @@ class MoviePilotSubAgentMiddleware(AgentMiddleware):
)
return await handler(request.override(system_message=new_system_message))
async def awrap_tool_call(
self,
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], Awaitable[Any]],
) -> Any:
"""在 task 子代理工具执行时记录聚合摘要。"""
tool = request.tool
tool_name = getattr(tool, "name", None)
if tool_name != SUBAGENT_TASK_TOOL_NAME:
return await handler(request)
tool_args = _extract_tool_call_args(request)
logger.info(
f"开始执行子代理工具: tool_name={tool_name}, "
f"subagent_type={tool_args.get('subagent_type') or '-'}"
)
_record_subagent_tool_call(
stream_handler=self.stream_handler,
tool_name=SUBAGENT_TASK_TOOL_NAME,
tool_args=tool_args,
)
try:
result = await handler(request)
except Exception as err:
logger.error(f"子代理工具执行失败: tool_name={tool_name}, error={err}")
raise
logger.info(f"子代理工具执行完成: tool_name={tool_name}")
return result
class SubAgentTaskControlMiddleware(AgentMiddleware):
"""提供异步子代理任务调度工具的中间件。"""
@@ -491,8 +550,10 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
profiles: tuple[_SubAgentProfile, ...],
tools: list[BaseTool],
task_description: str = SUBAGENT_CONTROL_DESCRIPTION,
stream_handler: Any = None,
) -> None:
"""初始化异步子代理调度中间件。"""
self.stream_handler = stream_handler
self._provider = _SubAgentAgentProvider(
model=model,
profiles=profiles,
@@ -1013,99 +1074,39 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
if unfinished_records:
logger.info(f"Agent 结束,取消未完成子代理任务: tasks={len(unfinished_records)}")
await self._cancel_records(unfinished_records)
class SubAgentCallSummaryMiddleware(AgentMiddleware):
"""记录子代理调用次数的中间件。"""
def __init__(self, *, stream_handler: Any = None) -> None:
self.stream_handler = stream_handler
self.tools = []
self._tasks.clear()
async def awrap_tool_call(
self,
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], Awaitable[Any]],
) -> Any:
"""在子代理任务工具执行时记录聚合摘要。"""
""" subagent_task 子代理工具执行时记录聚合摘要。"""
tool = request.tool
tool_name = getattr(tool, "name", None)
is_subagent_tool = tool_name in {
SUBAGENT_TASK_TOOL_NAME,
SUBAGENT_CONTROL_TOOL_NAME,
}
if is_subagent_tool:
tool_call = request.tool_call or {}
tool_args = tool_call.get("args") or {}
if not isinstance(tool_args, dict):
tool_args = {}
logger.info(
f"开始执行子代理工具: tool_name={tool_name}, "
f"action={tool_args.get('action') or '-'}, "
f"subagent_type={tool_args.get('subagent_type') or '-'}"
)
if (
self.stream_handler
and getattr(self.stream_handler, "is_streaming", False)
):
self.stream_handler.record_tool_call(
tool_name=tool_name or SUBAGENT_TASK_TOOL_NAME,
tool_message="Subagent invoked",
tool_kwargs=tool_args,
)
if tool_name != SUBAGENT_CONTROL_TOOL_NAME:
return await handler(request)
tool_args = _extract_tool_call_args(request)
logger.info(
f"开始执行子代理工具: tool_name={tool_name}, "
f"action={tool_args.get('action') or '-'}, "
f"subagent_type={tool_args.get('subagent_type') or '-'}"
)
_record_subagent_tool_call(
stream_handler=self.stream_handler,
tool_name=SUBAGENT_CONTROL_TOOL_NAME,
tool_args=tool_args,
)
try:
result = await handler(request)
except Exception as err:
if is_subagent_tool:
logger.error(f"子代理工具执行失败: tool_name={tool_name}, error={err}")
logger.error(f"子代理工具执行失败: tool_name={tool_name}, error={err}")
raise
if is_subagent_tool:
logger.info(f"子代理工具执行完成: tool_name={tool_name}")
logger.info(f"子代理工具执行完成: tool_name={tool_name}")
return result
def _deepagents_spec(
profiles: tuple[_SubAgentProfile, ...], tools: list[BaseTool]
) -> list[dict[str, Any]]:
"""将内置定义转换为 Deep Agents 子代理配置。"""
specs = []
for profile in profiles:
specs.append(
{
"name": profile.name,
"description": profile.description,
"prompt": profile.prompt,
"tools": _select_tools(tools, profile),
}
)
return specs
def _try_create_deepagents_middleware(
*,
profiles: tuple[_SubAgentProfile, ...],
tools: list[BaseTool],
model: BaseChatModel,
) -> Optional[AgentMiddleware]:
"""优先创建 Deep Agents 官方子代理中间件。"""
try:
from deepagents.backends import StateBackend
from deepagents.middleware.subagents import SubAgentMiddleware
return SubAgentMiddleware(
backend=StateBackend(),
subagents=_deepagents_spec(profiles, tools),
default_model=model,
system_prompt=SUBAGENT_PARENT_PROMPT,
task_description=SUBAGENT_TASK_DESCRIPTION,
)
except ImportError:
return None
except Exception as err:
logger.debug(f"Deep Agents 子代理中间件不可用,使用本地实现: {err}")
return None
def create_subagent_middlewares(
*,
model: BaseChatModel,
@@ -1113,24 +1114,19 @@ def create_subagent_middlewares(
stream_handler: Any = None,
) -> tuple[list[AgentMiddleware], list[BaseTool]]:
"""创建子代理中间件列表和任务工具列表。"""
_builtin_subagent_profiles.cache_clear()
builtin_subagent_names.cache_clear()
profiles = _builtin_subagent_profiles()
subagent_middleware = _try_create_deepagents_middleware(
runtime_signature = agent_runtime_manager.current_signature()
profiles = _builtin_subagent_profiles(runtime_signature)
subagent_middleware = MoviePilotSubAgentMiddleware(
model=model,
profiles=profiles,
tools=tools,
model=model,
stream_handler=stream_handler,
)
if subagent_middleware is None:
subagent_middleware = MoviePilotSubAgentMiddleware(
model=model,
profiles=profiles,
tools=tools,
)
control_middleware = SubAgentTaskControlMiddleware(
model=model,
profiles=profiles,
tools=tools,
stream_handler=stream_handler,
)
task_tools = [
@@ -1140,7 +1136,6 @@ def create_subagent_middlewares(
return [
subagent_middleware,
control_middleware,
SubAgentCallSummaryMiddleware(stream_handler=stream_handler),
], task_tools

View File

@@ -1,5 +1,6 @@
"""MoviePilot 自定义工具筛选中间件。"""
from dataclasses import dataclass, replace
import json
from collections.abc import Awaitable, Callable
from typing import Annotated, Any, NotRequired
@@ -19,13 +20,40 @@ from langchain.agents.middleware.tool_selection import (
LLMToolSelectorMiddleware,
)
from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from langchain_core.runnables import RunnableConfig
from langchain_core.tools import BaseTool
from langgraph.runtime import Runtime
from typing_extensions import TypedDict # noqa
from app.agent.llm import LLMHelper
from app.agent.tools.tags import ToolTag
from app.log import logger
MIN_SELECTED_TOOL_COUNT = 4
RECENT_SELECTION_CONTEXT_MESSAGE_LIMIT = 6
RECENT_SELECTION_CONTEXT_MAX_CHARS = 6000
RECENT_SELECTION_CONTEXT_TRUNCATION_PREFIX = "..."
TOOL_GROUP_EXCLUDED_TAGS = frozenset(
{
ToolTag.AgentTool.value,
ToolTag.Read.value,
ToolTag.Write.value,
ToolTag.Admin.value,
ToolTag.Message.value,
ToolTag.UserInteraction.value,
ToolTag.TerminalResponse.value,
}
)
MOVIEPILOT_TOOL_SELECTION_HINT = """
MoviePilot tool-chain hints:
- Tools with the same capability tag belong to the same functional group.
- For multi-step MoviePilot tasks, keep same-tag tools together when relevant.
- Prefer selecting likely next-step tools in the same capability group instead of selecting only the first tool.
"""
class ToolSelectionState(AgentState):
"""工具筛选中间件私有状态。"""
@@ -40,19 +68,25 @@ class ToolSelectionStateUpdate(TypedDict):
selected_tool_names: list[str] | None
@dataclass(frozen=True)
class _ToolSelectionAttempt:
"""工具筛选尝试结果,用于统一记录最终日志。"""
request: ModelRequest
selected_tool_names: list[str]
status: str
detail: str = ""
class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
"""
为 DeepSeek 兼容端点提供更稳妥的工具筛选实现
使用 provider-neutral JSON 提示执行工具筛选。
LangChain 默认会通过 `with_structured_output()` 走 OpenAI
`response_format=json_schema` 路径,但 DeepSeek 官方 OpenAI 兼容端点公开文档
仅保证 `json_object` 模式可用。对于 `deepseek-reasoner`,这会在工具筛选阶段
提前触发 400导致 Agent 还没真正开始执行工具就失败。
因此这里仅在识别到 DeepSeek 模型/端点时,退回到显式 JSON 输出模式:
1. 使用 `response_format={"type": "json_object"}`
2. 在提示词中明确约束返回 JSON 结构;
3. 手动解析 `{"tools": [...]}`,其余模型继续沿用 LangChain 默认实现。
LangChain 默认会通过 `with_structured_output()` 走 provider-specific
结构化输出能力,不同 OpenAI/Anthropic 兼容端点对 `response_format`、
JSON schema 和工具绑定的支持并不一致。工具筛选只是 Agent 执行前的
辅助优化,失败时也会恢复使用全部工具,因此这里统一使用文本提示约束
模型返回 `{"tools": [...]}` 并手动解析,避免在筛选阶段引入额外兼容分支。
另外LangChain 原生工具筛选挂在 `wrap_model_call` 上,会在同一条用户请求
的每次“模型回合”前都重新筛选一次工具。对于会多轮调用工具的复杂任务,
@@ -73,12 +107,219 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
) -> None:
super().__init__(
model=model,
system_prompt=system_prompt,
system_prompt=self._append_tool_selection_hint(system_prompt),
max_tools=max_tools,
always_include=always_include,
)
self.selection_tools = selection_tools or []
@classmethod
def _render_recent_conversation_context(
cls,
messages: list[Any],
) -> tuple[str, int]:
"""渲染最近对话上下文,供工具筛选模型理解多轮追问。"""
rendered_messages = []
for message in messages:
if isinstance(message, HumanMessage):
role = "User"
elif isinstance(message, AIMessage):
role = "Assistant"
else:
continue
content = LLMHelper.extract_text_content(message.content).strip()
if not content:
continue
rendered_messages.append(f"{role}: {content}")
recent_messages = rendered_messages[-RECENT_SELECTION_CONTEXT_MESSAGE_LIMIT:]
context = "\n\n".join(recent_messages)
if len(context) > RECENT_SELECTION_CONTEXT_MAX_CHARS:
context = (
f"{RECENT_SELECTION_CONTEXT_TRUNCATION_PREFIX}"
f"{context[-RECENT_SELECTION_CONTEXT_MAX_CHARS:]}"
)
return context, len(recent_messages)
@classmethod
def _build_contextual_user_message(
cls,
messages: list[Any],
last_user_message: HumanMessage,
) -> HumanMessage:
"""根据最近对话构造工具筛选专用用户消息。"""
context, message_count = cls._render_recent_conversation_context(messages)
if message_count <= 1:
return last_user_message
return HumanMessage(
content=(
"Recent conversation context for tool selection:\n"
f"{context}\n\n"
"Select tools for the latest user instruction. Use prior assistant "
"messages and earlier user requests when the latest user message "
"depends on previous context."
)
)
def _prepare_selection_request(
self,
request: ModelRequest[ContextT],
) -> Any | None:
"""准备带最近对话上下文的工具筛选请求。"""
selection_request = super()._prepare_selection_request(request)
if selection_request is None:
return None
contextual_user_message = self._build_contextual_user_message(
messages=request.messages,
last_user_message=selection_request.last_user_message,
)
if contextual_user_message is selection_request.last_user_message:
return selection_request
return replace(selection_request, last_user_message=contextual_user_message)
@staticmethod
def _append_tool_selection_hint(system_prompt: str) -> str:
"""追加 MoviePilot 工具组选择提示,避免复杂链路只选中首个工具。"""
if "MoviePilot tool-chain hints:" in system_prompt:
return system_prompt
return f"{system_prompt.rstrip()}{MOVIEPILOT_TOOL_SELECTION_HINT}"
def _get_tool_selection_limit(self, valid_tool_names: list[str]) -> int:
"""计算补齐筛选结果时允许使用的工具数量上限。"""
if self.max_tools:
return min(self.max_tools, len(valid_tool_names))
return len(valid_tool_names)
@staticmethod
def _normalize_tool_tags(tool: BaseTool) -> list[str]:
"""读取工具的业务标签,过滤掉无法表达工具组的通用标签。"""
tags = getattr(tool, "tags", None) or []
if isinstance(tags, str):
tags = [tags]
normalized_tags = []
for tag in tags:
tag_value = getattr(tag, "value", tag)
if not tag_value:
continue
tag_name = str(tag_value)
if tag_name in TOOL_GROUP_EXCLUDED_TAGS or tag_name in normalized_tags:
continue
normalized_tags.append(tag_name)
return normalized_tags
@classmethod
def _build_tool_groups(
cls,
available_tools: list[BaseTool],
valid_tool_names: list[str],
) -> list[tuple[str, list[str]]]:
"""根据工具标签构造能力组,保留当前工具列表中的稳定顺序。"""
valid_tool_set = set(valid_tool_names)
tool_groups: dict[str, list[str]] = {}
for tool in available_tools:
tool_name = getattr(tool, "name", None)
if not tool_name or tool_name not in valid_tool_set:
continue
for tag in cls._normalize_tool_tags(tool):
group_tool_names = tool_groups.setdefault(tag, [])
if tool_name not in group_tool_names:
group_tool_names.append(tool_name)
return [
(tag, tool_names)
for tag, tool_names in tool_groups.items()
if len(tool_names) > 1
]
@classmethod
def _get_matched_tool_groups(
cls,
selected_names: list[str],
available_tools: list[BaseTool],
valid_tool_names: list[str],
) -> list[tuple[str, list[str]]]:
"""返回已选工具命中的标签能力组。"""
groups_by_tag = {
tag: tool_names
for tag, tool_names in cls._build_tool_groups(
available_tools=available_tools,
valid_tool_names=valid_tool_names,
)
}
tools_by_name = {
tool.name: tool
for tool in available_tools
if getattr(tool, "name", None)
}
matched_groups: list[tuple[str, list[str]]] = []
seen_tags = set()
for tool_name in selected_names:
tool = tools_by_name.get(tool_name)
if not tool:
continue
for tag in cls._normalize_tool_tags(tool):
if tag in seen_tags or tag not in groups_by_tag:
continue
matched_groups.append((tag, groups_by_tag[tag]))
seen_tags.add(tag)
return matched_groups
def _complete_low_count_selection(
self,
selected_tool_names: list[str],
valid_tool_names: list[str],
available_tools: list[BaseTool],
) -> list[str]:
"""
当模型只选出极少工具时,按工具标签补齐同组工具。
工具标签是工具自身声明的能力归属。这里只补齐已经命中的标签组,
不会把所有工具组都展开。
"""
limit = self._get_tool_selection_limit(valid_tool_names)
selected_names = [
tool_name
for tool_name in selected_tool_names
if tool_name in valid_tool_names
]
selected_set = set(selected_names)
valid_tool_set = set(valid_tool_names)
completed_names = list(selected_names)
matched_groups = self._get_matched_tool_groups(
selected_names=selected_names,
available_tools=available_tools,
valid_tool_names=valid_tool_names,
)
if not matched_groups:
return completed_names[:limit]
matched_group_tool_names = {
tool_name
for _, group_tool_names in matched_groups
for tool_name in group_tool_names
}
target_count = min(
max(MIN_SELECTED_TOOL_COUNT, len(matched_group_tool_names)),
limit,
)
if len(selected_names) >= target_count:
return selected_names[:limit]
for _, group_tool_names in matched_groups:
for tool_name in group_tool_names:
if tool_name in selected_set or tool_name not in valid_tool_set:
continue
completed_names.append(tool_name)
selected_set.add(tool_name)
if len(completed_names) >= target_count:
return completed_names[:limit]
return completed_names[:limit]
def _process_selection_response(
self,
response: dict[str, Any],
@@ -87,96 +328,41 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
request: ModelRequest[ContextT],
) -> ModelRequest[ContextT]:
"""
处理工具筛选响应,并保留空结果回退所有工具的 MoviePilot 策略
处理工具筛选响应,并在正常空结果时禁用可筛选工具
"""
if response.get("tools") == []:
logger.warning("工具筛选结果为空,将恢复使用所有工具。")
always_included_tools: list[BaseTool] = [
tool
for tool in request.tools
if not isinstance(tool, dict) and tool.name in self.always_include
]
provider_tools = [tool for tool in request.tools if isinstance(tool, dict)]
return request.override(tools=[*always_included_tools, *provider_tools])
return request.override(
tools=[*available_tools, *always_included_tools, *provider_tools]
)
return super()._process_selection_response(
response["tools"] = self._complete_low_count_selection(
selected_tool_names=[
tool_name
for tool_name in response.get("tools", [])
if isinstance(tool_name, str)
],
valid_tool_names=valid_tool_names,
available_tools=available_tools,
)
modified_request = super()._process_selection_response(
response,
available_tools,
valid_tool_names,
request,
)
@staticmethod
def _is_deepseek_compatible_model(model: BaseChatModel) -> bool:
"""
判断当前模型是否应当走 DeepSeek JSON 兼容分支。
除了官方 `langchain_deepseek`,用户也可能通过 OpenAI-compatible
配置把 DeepSeek 端点接到 `ChatOpenAI`。因此这里同时检查模块名、模型名
和 Base URL避免只靠单一条件漏判。
"""
module_name = type(model).__module__.lower()
model_name = (
str(getattr(model, "model_name", "") or getattr(model, "model", ""))
.strip()
.lower()
)
base_url = (
str(getattr(model, "openai_api_base", "") or getattr(model, "api_base", ""))
.strip()
.lower()
)
return (
"deepseek" in module_name
or model_name.startswith("deepseek-")
or "api.deepseek.com" in base_url
)
@staticmethod
def _extract_text_content(content: Any) -> str:
"""
从模型响应中提取纯文本。
这里不依赖上层 LLMHelper避免中间件与 LLM 构造逻辑互相耦合。
"""
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
text_parts: list[str] = []
for block in content:
if isinstance(block, str):
text_parts.append(block)
continue
if isinstance(block, dict):
if block.get("type") == "text" and isinstance(
block.get("text"), str
):
text_parts.append(block["text"])
continue
if not block.get("type") and isinstance(block.get("text"), str):
text_parts.append(block["text"])
return "".join(text_parts)
if isinstance(content, dict):
if content.get("type") == "text" and isinstance(content.get("text"), str):
return content["text"]
if not content.get("type") and isinstance(content.get("text"), str):
return content["text"]
return ""
return modified_request
@staticmethod
def _parse_json_object(text: str) -> dict[str, Any]:
"""
解析模型返回的 JSON。
DeepSeek 在 JSON 模式下通常会返回纯 JSON但这里仍做一层兜底
兼容模型偶发输出围栏或前后说明文本的情况
不同模型可能偶发输出 Markdown 围栏或前后说明文本,因此这里从
响应中提取第一个 JSON 对象作为兜底
"""
stripped_text = text.strip()
if not stripped_text:
@@ -199,23 +385,46 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
raise ValueError("工具筛选 JSON 顶层必须是对象")
return payload
@staticmethod
def _render_tool_list(available_tools: list[Any]) -> str:
@classmethod
def _render_tool_list(cls, available_tools: list[Any]) -> str:
"""把工具名和描述渲染成稳定的文本列表。"""
return "\n".join(
f"- {tool.name}: {tool.description}" for tool in available_tools
lines = []
for tool in available_tools:
tags = cls._normalize_tool_tags(tool)
tag_text = f" [group tags: {', '.join(tags)}]" if tags else ""
lines.append(f"- {tool.name}{tag_text}: {tool.description}")
return "\n".join(lines)
@classmethod
def _render_tool_groups(cls, available_tools: list[BaseTool]) -> str:
"""把当前可用工具按标签渲染成能力组提示。"""
valid_tool_names = [
tool.name
for tool in available_tools
if getattr(tool, "name", None)
]
groups = cls._build_tool_groups(
available_tools=available_tools,
valid_tool_names=valid_tool_names,
)
if not groups:
return ""
rendered_groups = "\n".join(
f"- {tag}: {', '.join(tool_names)}"
for tag, tool_names in groups
)
return f"Capability groups from tool tags:\n{rendered_groups}\n\n"
def _build_deepseek_selection_prompt(self, selection_request: Any) -> str:
def _build_json_selection_prompt(self, selection_request: Any) -> str:
"""
为 DeepSeek 生成显式 JSON 输出提示。
生成显式 JSON 输出提示。
DeepSeek 官方文档要求在 JSON 输出模式下,提示词中必须明确包含 JSON
约束,否则兼容端点可能返回空内容或无意义输出
使用纯提示约束可覆盖更多兼容端点,避免在工具筛选阶段依赖某个
provider 专属的 `response_format` 或 schema 能力
"""
limit_instruction = ""
if self.max_tools:
limit_instruction = f"- Select up to {self.max_tools} tools. IF NO TOOLS ARE RELEVANT, DO NOT RETURN AN EMPTY ARRAY. SELECT THE MOST APPLICABLE ONES TO ENSURE THE REQUEST IS HANDLED."
limit_instruction = f"- Select up to {self.max_tools} tools. Return an empty array if no tools are relevant."
return (
f"{selection_request.system_message}\n\n"
@@ -225,18 +434,20 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
"- The `tools` field must be a JSON array of strings.\n"
"- Only use tool names from the allowed list below.\n"
"- Order tools by relevance, with the most relevant first.\n"
"- Tools sharing the same capability tag are in the same group; include same-group tools together when relevant.\n"
f"{limit_instruction}\n"
"- Do not add explanations, markdown, or extra keys.\n\n"
f"{self._render_tool_groups(selection_request.available_tools)}"
"Allowed tools:\n"
f"{self._render_tool_list(selection_request.available_tools)}"
)
def _normalize_selection_response(self, response: Any) -> dict[str, list[str]]:
"""
解析并标准化 DeepSeek JSON 模式的工具筛选结果。
解析并标准化显式 JSON 模式的工具筛选结果。
"""
content = getattr(response, "content", response)
text = self._extract_text_content(content)
text = LLMHelper.extract_text_content(content)
logger.debug(f"工具筛选原始响应: {text}")
payload = self._parse_json_object(text)
@@ -250,22 +461,21 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
logger.debug(f"工具筛选标准化结果: {normalized_tools}")
return {"tools": normalized_tools}
async def _aselect_tools_with_deepseek(
async def _aselect_tools_with_json_prompt(
self, selection_request: Any
) -> dict[str, list[str]]:
"""
使用 DeepSeek 兼容的 JSON 输出模式执行异步工具筛选。
使用 JSON 提示执行异步工具筛选。
:param selection_request: LangChain 工具筛选请求
:return: 标准化后的工具名列表
"""
logger.debug("工具筛选走 DeepSeek JSON 兼容分支")
structured_model = selection_request.model.bind(
response_format={"type": "json_object"}
)
response = await structured_model.ainvoke(
logger.debug("工具筛选走 JSON 提示分支")
response = await selection_request.model.ainvoke(
[
{
"role": "system",
"content": self._build_deepseek_selection_prompt(selection_request),
},
SystemMessage(
content=self._build_json_selection_prompt(selection_request)
),
selection_request.last_user_message,
]
)
@@ -276,6 +486,31 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
"""从已筛选后的请求中提取最终工具名,保留原有顺序。"""
return [tool.name for tool in request.tools if not isinstance(tool, dict)]
@staticmethod
def _count_request_tools(request: ModelRequest) -> int:
"""统计当前请求中的 LangChain 工具数量,不包含 provider 原生工具字典。"""
return len([tool for tool in request.tools if not isinstance(tool, dict)])
@classmethod
def _log_selection_attempt(cls, attempt: _ToolSelectionAttempt) -> None:
"""按工具筛选最终状态记录稳定日志。"""
tool_count = cls._count_request_tools(attempt.request)
if attempt.status == "selected":
selected_text = ", ".join(attempt.selected_tool_names) or "无有效工具"
logger.info(f"工具筛选结果: {selected_text}")
return
if attempt.status == "failed_fallback":
logger.warning(
f"工具筛选失败,将恢复使用所有工具(共 {tool_count} 个): {attempt.detail}"
)
return
if attempt.status == "skipped":
logger.info(f"工具筛选跳过: {attempt.detail}")
return
if attempt.status == "reused":
selected_text = ", ".join(attempt.selected_tool_names) or "无有效工具"
logger.info(f"工具筛选复用已有结果: {selected_text}")
@staticmethod
def _apply_selected_tools(
request: ModelRequest[ContextT],
@@ -287,9 +522,6 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
这里只复用首次筛选出的客户端工具名provider-specific 的 dict 工具仍然
原样保留,避免破坏 LangChain/provider 自身的工具绑定约定。
"""
if not selected_tool_names:
return request
current_tools_by_name = {
tool.name: tool for tool in request.tools if not isinstance(tool, dict)
}
@@ -310,30 +542,43 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
这里单独抽成 helper便于首次筛选后缓存结果也便于测试覆盖
“首轮筛选,后续复用”的行为。
"""
return (await self._aselect_request_once_with_status(request)).request
async def _aselect_request_once_with_status(
self, request: ModelRequest[ContextT]
) -> _ToolSelectionAttempt:
"""
执行一次真实工具筛选,并携带最终状态供调用方统一记录日志。
"""
selection_request = self._prepare_selection_request(request)
if selection_request is None:
return request
return _ToolSelectionAttempt(
request=request,
selected_tool_names=self._extract_selected_tool_names(request),
status="skipped",
detail="没有需要筛选的工具",
)
if not self._is_deepseek_compatible_model(selection_request.model):
captured_request: ModelRequest[ContextT] = request
async def _capture_handler(
updated_request: ModelRequest[ContextT],
) -> ModelRequest[ContextT]:
nonlocal captured_request
captured_request = updated_request
return updated_request
await super().awrap_model_call(request, _capture_handler)
return captured_request
response = await self._aselect_tools_with_deepseek(selection_request)
return self._process_selection_response(
response,
selection_request.available_tools,
selection_request.valid_tool_names,
request,
)
try:
response = await self._aselect_tools_with_json_prompt(selection_request)
modified_request = self._process_selection_response(
response,
selection_request.available_tools,
selection_request.valid_tool_names,
request,
)
return _ToolSelectionAttempt(
request=modified_request,
selected_tool_names=self._extract_selected_tool_names(modified_request),
status="selected",
)
except Exception as err:
return _ToolSelectionAttempt(
request=request,
selected_tool_names=self._extract_selected_tool_names(request),
status="failed_fallback",
detail=str(err),
)
async def abefore_agent( # noqa
self,
@@ -347,10 +592,22 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
这样后续多轮 `model -> tools -> model` 循环都只复用这一次结果,
不会为每次模型回合重复追加一笔 selector LLM 开销。
"""
if "selected_tool_names" in state:
return None
if not self.selection_tools or self.model is None:
detail = "没有可筛选工具" if not self.selection_tools else "未配置筛选模型"
self._log_selection_attempt(
_ToolSelectionAttempt(
request=ModelRequest(
model=self.model,
tools=list(self.selection_tools),
messages=state["messages"],
state=state,
runtime=runtime,
),
selected_tool_names=[],
status="skipped",
detail=detail,
)
)
return ToolSelectionStateUpdate(selected_tool_names=None)
selection_request = ModelRequest(
@@ -360,9 +617,10 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
state=state,
runtime=runtime,
)
modified_request = await self._aselect_request_once(selection_request)
selected_tool_names = self._extract_selected_tool_names(modified_request)
return ToolSelectionStateUpdate(selected_tool_names=selected_tool_names or None)
attempt = await self._aselect_request_once_with_status(selection_request)
self._log_selection_attempt(attempt)
selected_tool_names = attempt.selected_tool_names
return ToolSelectionStateUpdate(selected_tool_names=selected_tool_names)
async def awrap_model_call(
self,
@@ -383,11 +641,13 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
and self.selection_tools
and self.model is not None
):
request = await self._aselect_request_once(request)
selected_tool_names = self._extract_selected_tool_names(request) or None
attempt = await self._aselect_request_once_with_status(request)
self._log_selection_attempt(attempt)
request = attempt.request
selected_tool_names = attempt.selected_tool_names
request.state["selected_tool_names"] = selected_tool_names # noqa
if selected_tool_names:
if selected_tool_names is not None:
request = self._apply_selected_tools(request, selected_tool_names)
return await handler(request)

View File

@@ -17,13 +17,13 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
- 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.
- When the user explicitly asks for delayed, recurring, reminder, or monitoring work, use `create_agent_task` instead of promising to remember it or writing a JOB.md file. Use a `date` trigger with `delay_minutes` for requests such as "in 30 minutes", an exact `date` trigger for other single future runs, and a five-field `cron` trigger for recurring work. Manage existing autonomous tasks with `query_agent_tasks`, `update_agent_task`, `run_agent_task`, and `delete_agent_task`; these tools use integer `task_id` values. Use `query_schedulers` and `run_scheduler` only for MoviePilot system, plugin, or workflow runtime services, whose string `job_id` values must never be passed to autonomous-task tools.
- 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>
@@ -65,7 +65,11 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
- 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_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.
- For administrator code discovery across local files, use `execute_command(action="run")` with `rg` and narrow globs or paths. Use `list_directory` to inspect one known directory or a supported remote storage backend, and use `read_file` when the exact local file is known.
- Read the relevant file before changing it. Use `edit_file` for localized exact replacements; make `old_text` unique with enough surrounding context, and use `replace_all=true` only when every match must change. Use `write_file` for new files; set `overwrite=true` only for an intentional full rewrite, and use `read_file(include_metadata=true)` plus `expected_sha256` when preserving the previously read version matters.
- When implementation depends on a Python or Node.js API, first identify the installed or locked dependency version from environment metadata, requirements, package manifests, lockfiles, local source, and type declarations. Use `rg` against the relevant package directory, `.venv`, or `node_modules` instead of scanning the entire project without bounds. If local evidence is insufficient, use `search_web` and then `browse_webpage` to read the matching version of the official documentation. Do not guess signatures from memory, mix examples from incompatible versions, or install a package only to inspect its API.
- Use structured file tools for source edits because they enforce file access boundaries and conflict checks. Never use shell redirection, inline scripts, or another tool to bypass a file-tool permission denial.
- Use `execute_command` for administrator-only multi-file diagnostics, tests, Git, service operations, SSH, or an exact command the user requested. Use `action=run` for short bounded commands. Use `action=start` for long-running or interactive commands, including SSH; then continue with `read`, `wait`, `write`, or `kill` using the returned `session_id`. Do not start a background session for a short command that can finish within `action=run`.
</tool_strategy>
<media_rules>

View File

@@ -79,6 +79,10 @@ task_types:
- "- Transfer mode: {transfer_mode}"
- "- Current TMDB ID: {tmdbid}"
- "- Current Douban ID: {doubanid}"
- "- Current Bangumi ID: {bangumiid}"
- "- Current AniList ID: {anilistid}"
- "- Current media source: {media_source}"
- "- Current source-native ID: {media_id}"
- "- Error message: {error_message}"
steps_title: "Required workflow"
steps:
@@ -90,7 +94,7 @@ task_types:
- "Only continue when you have high confidence in the target media."
- "Before re-organizing, delete the old transfer history record with `delete_transfer_history` so the system will not skip the source file."
- "Then use `transfer_file` to organize the source path directly."
- "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, tmdbid or doubanid, and media_type."
- "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, all known media IDs, media_source, media_id, and media_type."
- "If this record is already correct and no re-organize is needed, do not perform destructive actions; simply report that no change is necessary."
task_rules:
- "Do NOT rely on previous chat context. Work only from the record above."
@@ -116,7 +120,7 @@ task_types:
- "If a source file no longer exists or cannot be safely processed, skip that record and note the reason."
- "Before re-organizing a record, delete the old transfer history record with `delete_transfer_history` so the system will not skip the source file."
- "Then use `transfer_file` to organize the source path directly."
- "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, tmdbid or doubanid, and media_type."
- "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, all known media IDs, media_source, media_id, and media_type."
- "If a record is already correct and no re-organize is needed, do not perform destructive actions; simply mark it as skipped."
- "Report only the aggregate outcome, including how many records succeeded, skipped, and failed."
task_rules:

View File

@@ -1,7 +1,6 @@
"""提示词管理器"""
import shutil
import socket
from dataclasses import dataclass, field
from pathlib import Path
from string import Formatter
@@ -24,8 +23,6 @@ from app.utils.system import SystemUtils
SYSTEM_TASKS_FILE = "System Tasks.yaml"
SYSTEM_TASKS_SCHEMA_VERSION = 2
COMMON_SHELL_COMMANDS = (
# 只探测会明显改变 Agent 执行策略的可选能力。基础命令、语言运行时、
# 包管理器、服务管理器和数据库客户端默认不做启动探测,减少 which 扫描量。
"ssh",
"scp",
"sftp",
@@ -91,7 +88,7 @@ class PromptManager:
self.prompts_cache: Dict[str, str] = {}
self._system_tasks_cache: Optional[SystemTasksDefinition] = None
self._system_tasks_signature: Optional[tuple[int, int]] = None
self._available_shell_commands_cache: Optional[list[tuple[str, str]]] = None
self._available_shell_command_names_cache: Optional[list[str]] = None
def load_prompt(self, prompt_name: str) -> str:
"""
@@ -102,7 +99,7 @@ class PromptManager:
prompt_file = self.prompts_dir / prompt_name
try:
with open(prompt_file, "r", encoding="utf-8") as f:
with open(prompt_file, "r", encoding="utf-8", errors="replace") as f:
content = f.read().strip()
# 缓存提示词
self.prompts_cache[prompt_name] = content
@@ -187,7 +184,7 @@ class PromptManager:
return self._system_tasks_cache
try:
content = system_tasks_path.read_text(encoding="utf-8")
content = system_tasks_path.read_text(encoding="utf-8", errors="replace")
except Exception as err: # noqa: BLE001
logger.error(f"读取系统任务定义失败: {system_tasks_path}, 错误: {err}")
raise PromptConfigError(
@@ -281,94 +278,60 @@ class PromptManager:
def _get_moviepilot_info(self) -> str:
"""
获取MoviePilot系统信息用于注入到系统提示词中
获取需要常驻注入的最小 MoviePilot 运行信息。
"""
# 获取主机名和IP地址
try:
hostname = socket.gethostname()
ip_address = socket.gethostbyname(hostname)
except Exception: # noqa
hostname = "localhost"
ip_address = "127.0.0.1"
# 配置文件和日志文件目录
config_path = str(settings.CONFIG_PATH)
log_path = str(settings.LOG_PATH)
# API地址构建
api_port = settings.PORT
api_path = settings.API_V1_STR
# API令牌
api_token = settings.API_TOKEN or "未设置"
# 数据库信息
db_type = settings.DB_TYPE
if db_type == "sqlite":
db_info = f"SQLite ({settings.CONFIG_PATH / 'db' / 'moviepilot.db'})"
else:
db_password = settings.DB_POSTGRESQL_PASSWORD or ""
db_info = (
f"PostgreSQL ({settings.DB_POSTGRESQL_USERNAME}:{db_password}@"
f"{settings.DB_POSTGRESQL_TARGET}/{settings.DB_POSTGRESQL_DATABASE})"
)
# 保留日期用于提供“今天是哪天”的稳定上下文,但不再注入秒级时间,
# 避免每次请求都生成不同的 system prompt影响 provider 侧 cache 命中率。
info_lines = [
f"- 当前日期: {strftime('%Y-%m-%d')}",
f"- 运行环境: {SystemUtils.platform} {'docker' if SystemUtils.is_docker() else ''}",
f"- 主机名: {hostname}",
f"- IP地址: {ip_address}",
f"- API端口: {api_port}",
f"- API路径: {api_path}",
f"- API令牌: {api_token}",
f"- 外网域名: {settings.APP_DOMAIN or '未设置'}",
f"- 数据库类型: {db_type}",
f"- 数据库: {db_info}",
f"- 配置文件目录: {config_path}",
f"- 日志文件目录: {log_path}",
f"- 系统安装目录: {settings.ROOT_PATH}",
f"- 插件安装目录: {settings.ROOT_PATH / 'app' / 'plugins'}",
"- 详细运行状态、数据库、API 和配置值需要时通过 `query_doctor_report`、`query_system_settings` 或 `execute_command` 查询。",
]
available_commands = self._get_available_shell_commands()
if available_commands:
info_lines.append("- 可用系统命令(可通过 `execute_command` 调用):")
path_lines = self._get_runtime_path_lines()
if path_lines:
info_lines.extend(
f" - {command}: {path}" for command, path in available_commands
[
"- 关键运行路径(必要时可用文件/命令工具读取,避免扫描无关目录):",
*path_lines,
]
)
# `rg` 同时覆盖文件枚举和文本检索,且比通用 shell 查找更适合
# Agent 的代码阅读与定位场景;只有在它不可用或不适合时才退回其他工具。
if any(command == "rg" for command, _ in available_commands):
available_commands = self._get_available_shell_command_names()
if available_commands:
info_lines.append(
"- 已安装的常用系统命令(仅列命令名,可通过 `execute_command` 调用): "
+ ", ".join(f"`{command}`" for command in available_commands)
)
if "rg" in available_commands:
info_lines.append(
"- When searching files or text, prefer `rg` / `rg --files`. Only fall back to other search tools when `rg` is unavailable or unsuitable."
"- 搜索文件或文本时优先使用 `rg` / `rg --files`,不适合或不可用时再使用其他命令。"
)
return "\n".join(info_lines)
def _get_available_shell_commands(self) -> list[tuple[str, str]]:
"""
探测 PATH 中已经安装的常用命令。
@staticmethod
def _get_runtime_path_lines() -> list[str]:
"""返回基础系统提示词需要常驻注入的全局运行路径。"""
paths = {
"项目根目录": settings.ROOT_PATH,
"配置目录": settings.CONFIG_PATH,
"临时目录": settings.TEMP_PATH,
}
return [f" - {label}: `{path}`" for label, path in paths.items()]
这里只使用 shutil.which 做无副作用查找,不实际执行命令;执行权限、
高风险操作确认和输出限制仍由 execute_command 工具负责。探测结果
在进程内缓存,避免每次组装提示词都重复扫描 PATH。
"""
if self._available_shell_commands_cache is not None:
return self._available_shell_commands_cache
def _get_available_shell_command_names(self) -> list[str]:
"""探测 PATH 中可用的常用命令名称,不把绝对路径注入提示词。"""
if self._available_shell_command_names_cache is not None:
return self._available_shell_command_names_cache
available_commands: list[tuple[str, str]] = []
for command in COMMON_SHELL_COMMANDS:
command_path = shutil.which(command)
if command_path:
available_commands.append((command, command_path))
self._available_shell_commands_cache = available_commands
available_commands = [
command for command in COMMON_SHELL_COMMANDS if shutil.which(command)
]
self._available_shell_command_names_cache = available_commands
return available_commands
def clear_available_shell_commands_cache(self) -> None:
"""清理可用系统命令缓存,供测试或运行时手动刷新使用。"""
self._available_shell_commands_cache = None
def clear_available_shell_command_names_cache(self) -> None:
"""清理可用命令名称缓存,供测试或运行时手动刷新使用。"""
self._available_shell_command_names_cache = None
@staticmethod
def _generate_formatting_instructions(caps: ChannelCapabilities) -> str:

View File

@@ -0,0 +1,95 @@
"""整理记录 AI 重新整理提示词构造。"""
from typing import Any
from app.agent.prompt import prompt_manager
def build_manual_redo_template_context(history: Any) -> dict[str, int | str]:
"""把整理历史对象映射成 System Tasks 需要的模板变量。"""
src_fileitem = history.src_fileitem or {}
dest_fileitem = history.dest_fileitem or {}
source_path = src_fileitem.get("path") if isinstance(src_fileitem, dict) else ""
source_storage = history.src_storage or "local"
if history.status and history.mode == "move":
dest_path = dest_fileitem.get("path") if isinstance(dest_fileitem, dict) else ""
if dest_path:
source_path = dest_path
source_storage = history.dest_storage or "local"
source_path = source_path or history.src or ""
season_episode = f"{history.seasons or ''}{history.episodes or ''}".strip()
return {
"history_id": history.id,
"current_status": "success" if history.status else "failed",
"recognized_title": history.title or "unknown",
"media_type": history.type or "unknown",
"category": history.category or "unknown",
"year": history.year or "unknown",
"season_episode": season_episode or "unknown",
"source_path": source_path or "unknown",
"source_storage": source_storage,
"destination_path": history.dest or "unknown",
"destination_storage": history.dest_storage or "unknown",
"transfer_mode": history.mode or "unknown",
"tmdbid": history.tmdbid or "none",
"doubanid": history.doubanid or "none",
"bangumiid": history.bangumiid or "none",
"anilistid": history.anilistid or "none",
"media_source": history.media_source or "none",
"media_id": history.media_id or "none",
"error_message": history.errmsg or "none",
}
def format_manual_redo_record_context(history: Any) -> str:
"""把单条整理记录格式化为批量任务可直接消费的上下文块。"""
context = build_manual_redo_template_context(history)
return "\n".join(
[
f"Record #{context['history_id']}:",
f"- Current status: {context['current_status']}",
f"- Current recognized title: {context['recognized_title']}",
f"- Media type: {context['media_type']}",
f"- Category: {context['category']}",
f"- Year: {context['year']}",
f"- Season/Episode: {context['season_episode']}",
f"- Source path: {context['source_path']}",
f"- Source storage: {context['source_storage']}",
f"- Destination path: {context['destination_path']}",
f"- Destination storage: {context['destination_storage']}",
f"- Transfer mode: {context['transfer_mode']}",
f"- Current TMDB ID: {context['tmdbid']}",
f"- Current Douban ID: {context['doubanid']}",
f"- Current Bangumi ID: {context['bangumiid']}",
f"- Current AniList ID: {context['anilistid']}",
f"- Current media source: {context['media_source']}",
f"- Current source-native ID: {context['media_id']}",
f"- Error message: {context['error_message']}",
]
)
def build_manual_redo_prompt(history: Any) -> str:
"""构建手动 AI 整理提示词。"""
return prompt_manager.render_system_task_message(
"manual_transfer_redo",
template_context=build_manual_redo_template_context(history),
)
def build_batch_manual_redo_template_context(histories: list[Any]) -> dict[str, int | str]:
"""把多条整理历史对象映射成批量 System Tasks 需要的模板变量。"""
return {
"history_ids_csv": ", ".join(str(history.id) for history in histories),
"history_count": len(histories),
"records_context": "\n\n".join(
format_manual_redo_record_context(history) for history in histories
),
}
def build_batch_manual_redo_prompt(histories: list[Any]) -> str:
"""构建批量手动 AI 整理提示词。"""
return prompt_manager.render_system_task_message(
"batch_manual_transfer_redo",
template_context=build_batch_manual_redo_template_context(histories),
)

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import re
import shutil
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterable, Optional
@@ -166,21 +167,11 @@ class AgentRuntimeConfig:
def render_prompt_sections(self) -> str:
"""渲染进入系统提示词的运行时片段。"""
sections: list[str] = [
"<agent_runtime_config>",
f"- Active persona: `{self.active_persona}`",
f"- Active persona source: `{self.persona.path}`",
]
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>")
sections: list[str] = ["<agent_runtime_config>", f"- Active persona: `{self.active_persona}`",
f"- Active persona file: `personas/{self.persona.persona_id}/{PERSONA_FILE}`",
"- Use `query_personas` before switching persona when the requested speaking style is unclear.",
"- Subagent availability is exposed by the subagent task tools; do not rely on this runtime section as a catalog.",
"</agent_runtime_config>"]
if self.warnings:
sections.extend(
@@ -253,9 +244,15 @@ class AgentRuntimeManager:
self._cache_lock = threading.Lock()
self._cached_signature: Optional[tuple[tuple[str, int, int], ...]] = None
self._cached_config: Optional[AgentRuntimeConfig] = None
self._cached_signature_checked_at = 0.0
self._signature_check_interval = 1.0
self._layout_ready = False
def ensure_layout(self) -> None:
"""创建目录、同步默认文件,并清理废弃的旧版 runtime 文件。"""
with self._cache_lock:
if self._layout_ready:
return
self.agent_root_dir.mkdir(parents=True, exist_ok=True)
self.runtime_dir.mkdir(parents=True, exist_ok=True)
self.memory_dir.mkdir(parents=True, exist_ok=True)
@@ -267,11 +264,13 @@ class AgentRuntimeManager:
self._remove_obsolete_runtime_files()
self._sync_bundled_defaults()
self._migrate_root_memory_files()
with self._cache_lock:
self._layout_ready = True
def load_runtime_config(self) -> AgentRuntimeConfig:
"""加载配置。用户目录损坏时自动回退到内置默认配置。"""
self.ensure_layout()
signature = self._build_signature()
signature = self.current_signature()
with self._cache_lock:
if self._cached_signature == signature and self._cached_config:
return self._cached_config
@@ -279,7 +278,7 @@ class AgentRuntimeManager:
try:
config = self._load_from_root(self.runtime_dir)
except AgentRuntimeConfigError as err:
logger.warning("Agent 根层配置无效,回退到内置默认配置: %s", err)
logger.warning(f"Agent 根层配置无效,回退到内置默认配置: {err}")
config = self._load_from_root(self.bundled_defaults_dir)
config.used_fallback = True
config.warnings.insert(
@@ -295,6 +294,25 @@ class AgentRuntimeManager:
with self._cache_lock:
self._cached_signature = None
self._cached_config = None
self._cached_signature_checked_at = 0.0
self._layout_ready = False
def current_signature(self) -> tuple[tuple[str, int, int], ...]:
"""返回当前运行时配置文件签名,供调用方判断缓存是否仍可复用。"""
now = time.monotonic()
with self._cache_lock:
if (
self._cached_signature is not None
and now - self._cached_signature_checked_at
< self._signature_check_interval
):
return self._cached_signature
signature = self._build_signature()
with self._cache_lock:
self._cached_signature = signature
self._cached_signature_checked_at = now
return signature
def set_active_persona(self, persona_query: str) -> AgentRuntimeConfig:
"""切换当前激活人格,并立即刷新缓存。"""
@@ -318,7 +336,7 @@ class AgentRuntimeManager:
)
current_path.write_text(document, encoding="utf-8")
self.invalidate_cache()
logger.info("已切换 Agent 人格: %s", persona.persona_id)
logger.info(f"已切换 Agent 人格: {persona.persona_id}")
return self.load_runtime_config()
def list_personas(self) -> list[PersonaDefinition]:
@@ -449,7 +467,7 @@ class AgentRuntimeManager:
continue
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, target)
logger.info("已同步默认 Agent 运行时文件: %s", target)
logger.info(f"已同步默认 Agent 运行时文件: {target}")
@classmethod
def _should_update_bundled_subagent(
@@ -488,7 +506,7 @@ class AgentRuntimeManager:
return
target.parent.mkdir(parents=True, exist_ok=True)
source.rename(target)
logger.info("已迁移旧版 Agent 根配置文件: %s -> %s", source, target)
logger.info(f"已迁移旧版 Agent 根配置文件: {source} -> {target}")
def _remove_obsolete_runtime_files(self) -> None:
"""删除不再支持的旧版 Agent 配置文件,避免被误迁移到 memory。"""
@@ -497,14 +515,14 @@ class AgentRuntimeManager:
if not path.exists() or not path.is_file():
continue
path.unlink()
logger.info("已删除废弃的 Agent 根配置文件: %s", path)
logger.info(f"已删除废弃的 Agent 根配置文件: {path}")
for relative_path in sorted(OBSOLETE_RUNTIME_FILES):
path = self.runtime_dir / relative_path
if not path.exists() or not path.is_file():
continue
path.unlink()
logger.info("已删除废弃的 Agent 运行时文件: %s", path)
logger.info(f"已删除废弃的 Agent 运行时文件: {path}")
def _migrate_root_memory_files(self) -> None:
"""将旧版根目录 memory 文件移入 `config/agent/memory`。"""
@@ -515,7 +533,7 @@ class AgentRuntimeManager:
if target.exists():
continue
path.rename(target)
logger.info("已迁移旧版 Agent memory 文件: %s -> %s", path, target)
logger.info(f"已迁移旧版 Agent memory 文件: {path} -> {target}")
def _load_from_root(self, root: Path) -> AgentRuntimeConfig:
current_persona_path = root / CURRENT_PERSONA_FILE
@@ -702,7 +720,7 @@ class AgentRuntimeManager:
if not path.exists():
raise AgentRuntimeConfigError(f"缺少配置文件: {path}")
try:
content = path.read_text(encoding="utf-8")
content = path.read_text(encoding="utf-8", errors="replace")
except Exception as err: # noqa: BLE001
raise AgentRuntimeConfigError(f"读取配置文件失败 {path}: {err}") from err

View File

@@ -115,6 +115,16 @@ def _get_blocking_executor(bucket: str) -> ThreadPoolExecutor:
return executor
def shutdown_blocking_executors(*, wait: bool = True, cancel_futures: bool = False) -> None:
"""关闭 Agent 工具阻塞线程池,释放长期运行进程或测试环境中的 worker。"""
with _blocking_executor_lock:
executors = list(_blocking_executors.values())
_blocking_executors.clear()
for executor in executors:
executor.shutdown(wait=wait, cancel_futures=cancel_futures)
class ToolExecutionTimeoutError(TimeoutError):
"""Agent 工具执行超时异常。"""
@@ -228,10 +238,6 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
# 获取工具执行提示消息
tool_message = self.get_tool_message(**kwargs)
if not tool_message:
explanation = kwargs.get("explanation")
if explanation:
tool_message = explanation
# 发送工具执行过程消息(流式传输且非最后终结工具时)
if self._stream_handler and self._stream_handler.is_streaming and not self.return_direct:
@@ -315,16 +321,13 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
获取工具执行时的友好提示消息。
子类可以重写此方法,根据实际参数生成个性化的提示消息。
如果返回 None 或空字符串,将回退使用 explanation 参数。
Args:
**kwargs: 工具的所有参数(包括 explanation
**kwargs: 工具的所有参数
Returns:
str: 友好的提示消息,如果返回 None 或空字符串则使用 explanation
str: 友好的提示消息
"""
explanation = kwargs.get("explanation")
return str(explanation) if explanation else None
return None
@abstractmethod
async def run(self, **kwargs) -> str:
@@ -421,7 +424,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
:return: 普通用户允许读写的本地目录列表
"""
roots = [
settings.CONFIG_PATH / "agent"
settings.CONFIG_PATH / "agent",
]
resolved_roots = []
for root in roots:
@@ -457,7 +460,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
allowed_text = "".join(str(root) for root in allowed_roots)
return (
resolved_path,
f"抱歉,普通用户只能{operation}配置目录、Agent记忆目录和日志目录内的文件或目录:{allowed_text}",
f"抱歉,普通用户只能{operation}Agent配置目录内的文件或目录:{allowed_text}",
)
async def _check_local_storage_access(
@@ -479,7 +482,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
return None, None
return (
None,
f"抱歉,普通用户只能{operation}本地配置目录、Agent记忆目录和日志目录,不能访问远程存储。",
f"抱歉,普通用户只能{operation}本地Agent配置目录,不能访问远程存储。",
)
return await self._check_local_file_access(path=path, operation=operation)
@@ -505,8 +508,8 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
return (
"抱歉,您没有执行此工具的权限。"
"只有渠道管理员或系统管理员才能执行工具操作。"
"如需执行工具,请联系渠道管理员将您的用户ID添加到渠道管理员列表中"
"或联系系统管理员为您设置权限。"
"如需执行工具请联系管理员将您的用户ID添加到渠道管理员列表中(设定 -> 通知 -> 对应渠道配置 -> 管理员名单)"
"或联系系统管理员为您设置管理员权限。"
)
async def _has_channel_admin_permission(self) -> bool:
@@ -612,13 +615,45 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
return False
async def send_notification_message(self, notification: Notification) -> None:
"""
发送工具通知消息。
WebAgent 渠道没有后端模块实例,前端流式面板通过 Agent 上下文中的
回调直接接收通知;无渠道的后台任务清空渠道侧定位信息后交由消息链广播,
其它渠道继续走统一消息链。
"""
callback = self._agent_context.get("notification_callback")
if (
self._channel == MessageChannel.WebAgent.value
and callable(callback)
):
callback(notification)
return
if not self._channel or not self._source:
notification = notification.model_copy(
update={
"channel": None,
"source": None,
"userid": None,
"username": notification.username
or self._username
or settings.SUPERUSER,
"original_message_id": None,
"original_chat_id": None,
}
)
await ToolChain().async_post_message(notification)
async def send_tool_message(
self, message: str, title: str = "", image: Optional[str] = None
) -> None:
"""
发送工具消息
"""
await ToolChain().async_post_message(
await self.send_notification_message(
Notification(
channel=self._channel,
source=self._source,
@@ -628,5 +663,6 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
title=title,
text=message,
image=image,
save_history=False,
)
)

View File

@@ -1,4 +1,4 @@
from typing import List, Callable
from typing import Callable, List, Optional, Type
from app.agent.tools.impl.add_download_tasks import AddDownloadTasksTool
from app.agent.tools.impl.add_subscribe import AddSubscribeTool
@@ -42,8 +42,13 @@ 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
from app.agent.tools.impl.send_voice_message import SendVoiceMessageTool
from app.agent.tools.impl.create_agent_task import CreateAgentTaskTool
from app.agent.tools.impl.delete_agent_task import DeleteAgentTaskTool
from app.agent.tools.impl.query_agent_tasks import QueryAgentTasksTool
from app.agent.tools.impl.query_schedulers import QuerySchedulersTool
from app.agent.tools.impl.run_agent_task import RunAgentTaskTool
from app.agent.tools.impl.run_scheduler import RunSchedulerTool
from app.agent.tools.impl.update_agent_task import UpdateAgentTaskTool
from app.agent.tools.impl.query_workflows import QueryWorkflowsTool
from app.agent.tools.impl.run_workflow import RunWorkflowTool
from app.agent.tools.impl.query_personas import QueryPersonasTool
@@ -92,8 +97,92 @@ class MoviePilotToolFactory:
MoviePilot工具工厂
"""
BUILTIN_TOOL_CLASSES: tuple[Type[MoviePilotTool], ...] = (
SearchMediaTool,
SearchPersonTool,
SearchPersonCreditsTool,
RecognizeMediaTool,
ScrapeMetadataTool,
QueryEpisodeScheduleTool,
QueryMediaDetailTool,
AddSubscribeTool,
UpdateSubscribeTool,
SearchSubscribeTool,
SearchTorrentsTool,
GetSearchResultsTool,
SearchWebTool,
RecognizeCaptchaTool,
AddDownloadTasksTool,
QuerySubscribesTool,
QuerySubscribeSharesTool,
QueryPopularSubscribesTool,
QueryBuiltinFilterRulesTool,
QueryCustomFilterRulesTool,
QueryRuleGroupsTool,
AddCustomFilterRuleTool,
UpdateCustomFilterRuleTool,
DeleteCustomFilterRuleTool,
AddRuleGroupTool,
UpdateRuleGroupTool,
DeleteRuleGroupTool,
QuerySubscribeHistoryTool,
DeleteSubscribeTool,
QueryDownloadTasksTool,
DeleteDownloadTasksTool,
DeleteDownloadHistoryTool,
DeleteTransferHistoryTool,
UpdateDownloadTasksTool,
QueryDownloadersTool,
QuerySitesTool,
UpdateSiteTool,
QuerySiteUserdataTool,
TestSiteTool,
UpdateSiteCookieTool,
GetRecommendationsTool,
QueryLibraryExistsTool,
QueryLibraryLatestTool,
QueryDirectorySettingsTool,
ListDirectoryTool,
QueryTransferHistoryTool,
TransferFileTool,
SendMessageTool,
CreateAgentTaskTool,
QueryAgentTasksTool,
UpdateAgentTaskTool,
RunAgentTaskTool,
DeleteAgentTaskTool,
QuerySchedulersTool,
RunSchedulerTool,
QueryWorkflowsTool,
RunWorkflowTool,
QueryPersonasTool,
SwitchPersonaTool,
UpdatePersonaDefinitionTool,
ExecuteCommandTool,
EditFileTool,
WriteFileTool,
ReadFileTool,
BrowseWebpageTool,
QueryInstalledPluginsTool,
QueryMarketPluginsTool,
QueryPluginCapabilitiesTool,
QueryPluginConfigTool,
UpdatePluginConfigTool,
ReloadPluginTool,
QueryPluginDataTool,
InstallPluginTool,
UninstallPluginTool,
RunSlashCommandTool,
ListSlashCommandsTool,
QueryDoctorReportTool,
QueryCustomIdentifiersTool,
UpdateCustomIdentifiersTool,
QuerySystemSettingsTool,
UpdateSystemSettingsTool,
)
# 这些通用工具需要始终保留,避免大工具集裁剪后让 Agent 丢失基础的
# 文件系统、命令执行、主动消息发送或交互确认能力。AskUserChoiceTool 仅在支持按钮
# 文件系统、命令执行、历史检索或交互确认能力。AskUserChoiceTool 仅在支持按钮
# 的渠道中才会实际注入,因此后续会再按已加载工具做一次求交集。
TOOL_SELECTOR_ALWAYS_INCLUDE_NAMES = (
"list_directory",
@@ -101,13 +190,13 @@ class MoviePilotToolFactory:
"read_file",
"edit_file",
"execute_command",
"query_doctor_report",
"send_message",
"ask_user_choice",
"create_agent_task",
"query_agent_tasks",
)
@staticmethod
def _should_enable_choice_tool(channel: str = None) -> bool:
def _should_enable_choice_tool(channel: Optional[str] = None) -> bool:
if not channel:
return False
try:
@@ -137,8 +226,24 @@ class MoviePilotToolFactory:
if tool_name in available_tool_names
]
@staticmethod
@classmethod
def _get_builtin_tool_classes(
cls, channel: Optional[str] = None
) -> list[Type[MoviePilotTool]]:
"""
返回当前渠道可用的内置工具类清单。
"""
tool_definitions = list(cls.BUILTIN_TOOL_CLASSES)
if cls._should_enable_choice_tool(channel):
tool_definitions.append(AskUserChoiceTool)
tool_definitions.append(SendLocalFileTool)
if AgentCapabilityManager.supports_audio_output():
tool_definitions.append(SendVoiceMessageTool)
return tool_definitions
@classmethod
def create_tools(
cls,
session_id: str,
user_id: str,
channel: str = None,
@@ -152,89 +257,7 @@ class MoviePilotToolFactory:
创建MoviePilot工具列表
"""
tools = []
tool_definitions = [
SearchMediaTool,
SearchPersonTool,
SearchPersonCreditsTool,
RecognizeMediaTool,
ScrapeMetadataTool,
QueryEpisodeScheduleTool,
QueryMediaDetailTool,
AddSubscribeTool,
UpdateSubscribeTool,
SearchSubscribeTool,
SearchTorrentsTool,
GetSearchResultsTool,
SearchWebTool,
RecognizeCaptchaTool,
AddDownloadTasksTool,
QuerySubscribesTool,
QuerySubscribeSharesTool,
QueryPopularSubscribesTool,
QueryBuiltinFilterRulesTool,
QueryCustomFilterRulesTool,
QueryRuleGroupsTool,
AddCustomFilterRuleTool,
UpdateCustomFilterRuleTool,
DeleteCustomFilterRuleTool,
AddRuleGroupTool,
UpdateRuleGroupTool,
DeleteRuleGroupTool,
QuerySubscribeHistoryTool,
DeleteSubscribeTool,
QueryDownloadTasksTool,
DeleteDownloadTasksTool,
DeleteDownloadHistoryTool,
DeleteTransferHistoryTool,
UpdateDownloadTasksTool,
QueryDownloadersTool,
QuerySitesTool,
UpdateSiteTool,
QuerySiteUserdataTool,
TestSiteTool,
UpdateSiteCookieTool,
GetRecommendationsTool,
QueryLibraryExistsTool,
QueryLibraryLatestTool,
QueryDirectorySettingsTool,
ListDirectoryTool,
QueryTransferHistoryTool,
TransferFileTool,
SendMessageTool,
QuerySchedulersTool,
RunSchedulerTool,
QueryWorkflowsTool,
RunWorkflowTool,
QueryPersonasTool,
SwitchPersonaTool,
UpdatePersonaDefinitionTool,
ExecuteCommandTool,
EditFileTool,
WriteFileTool,
ReadFileTool,
BrowseWebpageTool,
QueryInstalledPluginsTool,
QueryMarketPluginsTool,
QueryPluginCapabilitiesTool,
QueryPluginConfigTool,
UpdatePluginConfigTool,
ReloadPluginTool,
QueryPluginDataTool,
InstallPluginTool,
UninstallPluginTool,
RunSlashCommandTool,
ListSlashCommandsTool,
QueryDoctorReportTool,
QueryCustomIdentifiersTool,
UpdateCustomIdentifiersTool,
QuerySystemSettingsTool,
UpdateSystemSettingsTool,
]
if MoviePilotToolFactory._should_enable_choice_tool(channel):
tool_definitions.append(AskUserChoiceTool)
tool_definitions.append(SendLocalFileTool)
if AgentCapabilityManager.supports_audio_output():
tool_definitions.append(SendVoiceMessageTool)
tool_definitions = cls._get_builtin_tool_classes(channel)
# 创建内置工具
for ToolClass in tool_definitions:
tool = ToolClass(session_id=session_id, user_id=user_id)
@@ -281,9 +304,9 @@ class MoviePilotToolFactory:
builtin_tools_count = len(tool_definitions)
if plugin_tools_count > 0:
logger.info(
logger.debug(
f"成功创建 {len(tools)} 个MoviePilot工具内置工具: {builtin_tools_count} 个,插件工具: {plugin_tools_count} 个)"
)
else:
logger.info(f"成功创建 {len(tools)} 个MoviePilot工具")
logger.debug(f"成功创建 {len(tools)} 个MoviePilot工具")
return tools

View File

@@ -0,0 +1,88 @@
"""Agent 命令工具的安全校验逻辑。"""
from __future__ import annotations
import os.path
import re
import shlex
COMMAND_FORBIDDEN_KEYWORDS = (
":(){ :|:& };:",
"dd if=/dev/zero",
"mkfs",
"reboot",
"shutdown",
)
COMMAND_DANGEROUS_PATTERNS = (
re.compile(r"\brm\s+[^;&|]*-[^\s;&|]*[rR][fF]?[^\s;&|]*\s+/(?:\s|$|[;&|])"),
re.compile(r"\bdd\s+[^;&|]*(?:of=/dev/(?:sd[a-z]\d*|nvme\d+n\d+p?\d*|disk\d+)|if=/dev/zero)"),
re.compile(r"\b(?:mkfs|fdisk|parted|diskutil)\b"),
re.compile(r"\b(?:chmod|chown)\s+[^;&|]*-R[^;&|]*\s+/(?:\s|$|[;&|])"),
re.compile(r"\b(?:reboot|shutdown|halt|poweroff)\b"),
)
def _command_tokens(command: str) -> list[str]:
"""尽力解析 shell 命令 token解析失败时退回空白分割。"""
try:
return shlex.split(command, posix=True)
except ValueError:
return re.split(r"\s+", command.strip())
def _contains_recursive_root_delete(command: str) -> bool:
"""识别递归删除根目录或一级目录的 rm 命令。"""
tokens = _command_tokens(command)
if not any(token == "rm" or token.endswith("/rm") for token in tokens):
return False
has_recursive = any(
token.startswith("-") and ("r" in token or "R" in token)
for token in tokens
)
if not has_recursive:
return False
for token in tokens:
clean_token = re.match(r"^([^;|&><]+)", token)
if not clean_token:
continue
path_value = clean_token.group(1).strip("\"'")
if not path_value.startswith("/"):
continue
norm_path = os.path.normpath(path_value)
if norm_path == "/" or re.match(r"^/[^/]+$", norm_path):
return True
return False
def detect_dangerous_command(command: str) -> str:
"""返回危险命令原因,安全时返回空字符串。"""
normalized = str(command or "").strip()
if not normalized:
return "命令不能为空"
for keyword in COMMAND_FORBIDDEN_KEYWORDS:
if keyword in normalized:
return f"命令包含禁止使用的关键字 '{keyword}'"
if _contains_recursive_root_delete(normalized):
return "命令疑似递归删除根目录或一级目录"
for pattern in COMMAND_DANGEROUS_PATTERNS:
if pattern.search(normalized):
return "命令匹配高危系统操作模式"
return ""
def validate_command_safety(command: str, *, confirmed: bool = False) -> None:
"""
校验 shell 命令安全性。
:param command: 待执行命令
:param confirmed: 是否已经通过显式参数确认高危操作
"""
reason = detect_dangerous_command(command)
if not reason:
return
if confirmed and reason != "命令不能为空":
return
raise ValueError(f"{reason}。如确认需要执行,请设置 confirm_dangerous=true")

View File

@@ -0,0 +1,53 @@
"""Agent 文件写入工具的共享辅助函数。"""
import hashlib
import os
import tempfile
from pathlib import Path
class FileVersionConflictError(RuntimeError):
"""目标文件在准备写入期间发生变化。"""
def calculate_file_sha256(path: Path) -> str:
"""计算文件原始字节的 SHA-256用于检测陈旧写入。"""
digest = hashlib.sha256()
with path.open("rb") as file_handle:
for chunk in iter(lambda: file_handle.read(64 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def atomic_write_text(
path: Path,
content: str,
expected_sha256: str | None = None,
) -> None:
"""校验目标版本后,在同目录写入临时文件并原子替换文本。"""
path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temp_name = tempfile.mkstemp(
dir=path.parent,
prefix=f".{path.name}.",
suffix=".tmp",
)
temp_path = Path(temp_name)
try:
with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as file_handle:
file_handle.write(content)
file_handle.flush()
os.fsync(file_handle.fileno())
if expected_sha256:
if (
not path.is_file()
or calculate_file_sha256(path).casefold()
!= expected_sha256.casefold()
):
raise FileVersionConflictError(str(path))
if path.exists():
os.chmod(temp_path, path.stat().st_mode)
os.replace(temp_path, path)
finally:
if temp_path.exists():
temp_path.unlink()

View File

@@ -5,8 +5,7 @@ import re
from typing import Any, Dict, Iterable, Optional
from app.core.event import eventmanager
from app.db import AsyncSessionFactory
from app.db.models.subscribe import Subscribe
from app.db.subscribe_oper import SubscribeOper
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.rule import RuleHelper
from app.modules.filter.RuleParser import RuleParser
@@ -284,23 +283,22 @@ async def collect_rule_group_usages(
continue
ensure_usage(name)["used_in_global_best_version"] = True
async with AsyncSessionFactory() as db:
subscribes = await Subscribe.async_list(db)
for subscribe in subscribes:
filter_groups = subscribe.filter_groups or []
for name in filter_groups:
if target_names and name not in target_names:
continue
ensure_usage(name)["subscribes"].append(
{
"subscribe_id": subscribe.id,
"name": subscribe.name,
"season": subscribe.season,
"type": subscribe.type,
"username": subscribe.username,
"best_version": bool(subscribe.best_version),
}
)
subscribes = await SubscribeOper().async_list()
for subscribe in subscribes:
filter_groups = subscribe.filter_groups or []
for name in filter_groups:
if target_names and name not in target_names:
continue
ensure_usage(name)["subscribes"].append(
{
"subscribe_id": subscribe.id,
"name": subscribe.name,
"season": subscribe.season,
"type": subscribe.type,
"username": subscribe.username,
"best_version": bool(subscribe.best_version),
}
)
return usage_map
@@ -482,22 +480,22 @@ async def rename_rule_group_references(old_name: str, new_name: str) -> dict:
await save_system_config(config_key, updated)
changed["global_settings"][config_key.value] = updated
async with AsyncSessionFactory() as db:
subscribes = await Subscribe.async_list(db)
for subscribe in subscribes:
original = subscribe.filter_groups or []
updated = replace_group_name_in_list(original, old_name, new_name)
if updated == original:
continue
await subscribe.async_update(db, {"filter_groups": updated})
changed["subscribes"].append(
{
"subscribe_id": subscribe.id,
"name": subscribe.name,
"season": subscribe.season,
"filter_groups": updated,
}
)
subscribe_oper = SubscribeOper()
subscribes = await subscribe_oper.async_list()
for subscribe in subscribes:
original = subscribe.filter_groups or []
updated = replace_group_name_in_list(original, old_name, new_name)
if updated == original:
continue
await subscribe_oper.async_update_filter_groups(subscribe.id, updated)
changed["subscribes"].append(
{
"subscribe_id": subscribe.id,
"name": subscribe.name,
"season": subscribe.season,
"filter_groups": updated,
}
)
return changed
@@ -520,21 +518,21 @@ async def remove_rule_group_references(group_name: str) -> dict:
await save_system_config(config_key, updated)
changed["global_settings"][config_key.value] = updated
async with AsyncSessionFactory() as db:
subscribes = await Subscribe.async_list(db)
for subscribe in subscribes:
original = subscribe.filter_groups or []
updated = [value for value in original if value != group_name]
if updated == original:
continue
await subscribe.async_update(db, {"filter_groups": updated})
changed["subscribes"].append(
{
"subscribe_id": subscribe.id,
"name": subscribe.name,
"season": subscribe.season,
"filter_groups": updated,
}
)
subscribe_oper = SubscribeOper()
subscribes = await subscribe_oper.async_list()
for subscribe in subscribes:
original = subscribe.filter_groups or []
updated = [value for value in original if value != group_name]
if updated == original:
continue
await subscribe_oper.async_update_filter_groups(subscribe.id, updated)
changed["subscribes"].append(
{
"subscribe_id": subscribe.id,
"name": subscribe.name,
"season": subscribe.season,
"filter_groups": updated,
}
)
return changed

View File

@@ -103,6 +103,79 @@ def summarize_plugin(plugin: Any) -> dict[str, Any]:
}
def _merge_plugin_source_metadata(plugin: Any, source_plugin: Any) -> Any:
"""
将插件市场或本地仓库中的来源元数据合并到已安装插件对象。
"""
repo_url = getattr(source_plugin, "repo_url", None)
if repo_url:
setattr(plugin, "repo_url", repo_url)
for attr in (
"has_update",
"release",
"system_version",
"system_version_compatible",
"system_version_message",
):
value = getattr(source_plugin, attr, None)
if value is not None:
setattr(plugin, attr, value)
return plugin
def _map_plugins_by_id(plugins: list[Any]) -> dict[str, Any]:
"""
按插件 ID 建立稳定映射,保留同 ID 首个候选来源。
"""
plugin_map: dict[str, Any] = {}
for plugin in plugins:
plugin_id = getattr(plugin, "id", None)
if plugin_id and plugin_id not in plugin_map:
plugin_map[plugin_id] = plugin
return plugin_map
async def enrich_installed_plugin_sources(
installed_plugins: list[Any],
force_refresh: bool = False,
) -> list[Any]:
"""
为已安装插件补齐安装来源仓库地址。
本地插件对象只包含运行目录中的静态元数据,通常没有 repo_url。这里按需从
本地插件仓库和插件市场补齐来源,保证 Agent 后续安装、升级判断可以拿到仓库地址。
"""
missing_source_plugins = [
plugin for plugin in installed_plugins if not getattr(plugin, "repo_url", None)
]
if not missing_source_plugins:
return installed_plugins
plugin_manager = PluginManager()
local_repo_map = _map_plugins_by_id(plugin_manager.get_local_repo_plugins())
for plugin in missing_source_plugins:
source_plugin = local_repo_map.get(getattr(plugin, "id", None))
if source_plugin:
_merge_plugin_source_metadata(plugin, source_plugin)
missing_source_plugins = [
plugin for plugin in installed_plugins if not getattr(plugin, "repo_url", None)
]
if not missing_source_plugins:
return installed_plugins
market_plugins = await plugin_manager.async_get_online_plugins(force=force_refresh)
market_map = _map_plugins_by_id(market_plugins or [])
for plugin in missing_source_plugins:
source_plugin = market_map.get(getattr(plugin, "id", None))
if source_plugin:
_merge_plugin_source_metadata(plugin, source_plugin)
return installed_plugins
async def load_market_plugins(force_refresh: bool = False) -> list[Any]:
"""
聚合插件市场与本地插件仓库中的候选插件。

View File

@@ -1,7 +1,7 @@
"""系统设置工具共用的键解析与分组元数据。"""
from dataclasses import dataclass
from typing import Optional
from typing import Any, Optional
from app.core.config import Settings
from app.schemas.types import SystemConfigKey
@@ -15,6 +15,7 @@ class SettingSpec:
source: str
group: str
label: str
systemconfig_key: Optional[SystemConfigKey] = None
SYSTEMCONFIG_SETTING_METADATA = {
@@ -58,6 +59,10 @@ SYSTEMCONFIG_SETTING_METADATA = {
"group": "ai_agent",
"label": "AI 智能体配置",
},
SystemConfigKey.AIAgentMcpServers.value: {
"group": "ai_agent",
"label": "AI 智能体外部 MCP 服务器",
},
SystemConfigKey.CustomIdentifiers.value: {
"group": "custom_identifiers",
"label": "自定义识别词",
@@ -234,6 +239,7 @@ def _build_specs() -> tuple[dict[str, SettingSpec], dict[str, SettingSpec]]:
source="systemconfig",
group=metadata.get("group", "misc"),
label=metadata.get("label", item.value),
systemconfig_key=item,
)
return core_specs, system_specs
@@ -333,3 +339,57 @@ def list_setting_specs(
def get_default_list_match_field(setting_key: str) -> Optional[str]:
return LIST_ITEM_MATCH_FIELD_DEFAULTS.get(setting_key)
SECRET_KEYWORDS = (
"api_key",
"apikey",
"token",
"secret",
"password",
"passwd",
"cookie",
"authorization",
"refresh_token",
"access_token",
)
def is_secret_setting_key(key: str) -> bool:
"""判断设置键名是否疑似敏感字段。"""
normalized = _normalize_token(key)
return any(keyword in normalized for keyword in SECRET_KEYWORDS)
def redact_secret_value(value: Any, *, redact_scalar: bool = False) -> Any:
"""递归脱敏配置值中的密钥、Cookie、Token 等敏感字段。"""
if isinstance(value, dict):
return {
key: "***"
if is_secret_setting_key(str(key))
else redact_secret_value(item, redact_scalar=redact_scalar)
for key, item in value.items()
}
if isinstance(value, list):
return [
redact_secret_value(item, redact_scalar=redact_scalar)
for item in value
]
if isinstance(value, str):
return "***" if value and redact_scalar else value
return value
def should_redact_setting(spec: SettingSpec, value: Any) -> bool:
"""判断某项设置在默认查询响应中是否需要脱敏。"""
if is_secret_setting_key(spec.key):
return True
if isinstance(value, dict):
return any(is_secret_setting_key(str(key)) for key in value.keys())
if isinstance(value, list):
return any(
should_redact_setting(spec, item)
for item in value
if isinstance(item, dict)
)
return False

View File

@@ -13,6 +13,7 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional
from app.agent.tools.impl._command_safety import validate_command_safety
from app.core.config import settings
from app.log import logger
@@ -34,14 +35,6 @@ TERMINAL_PTY_POLL_INTERVAL = 0.05
TERMINAL_WAIT_DEFAULT_MS = 1000
TERMINAL_WAIT_MAX_MS = 60 * 1000
TERMINAL_KILL_GRACE_SECONDS = 3
TERMINAL_FORBIDDEN_KEYWORDS = (
"rm -rf /",
":(){ :|:& };:",
"dd if=/dev/zero",
"mkfs",
"reboot",
"shutdown",
)
@dataclass
@@ -176,13 +169,9 @@ class _TerminalSessionManager:
return merged_env
@staticmethod
def _validate_command(command: str) -> None:
def _validate_command(command: str, *, confirmed: bool = False) -> None:
"""拒绝明显危险或空白命令。"""
if not command or not command.strip():
raise ValueError("命令不能为空")
for keyword in TERMINAL_FORBIDDEN_KEYWORDS:
if keyword in command:
raise ValueError(f"命令包含禁止使用的关键字 '{keyword}'")
validate_command_safety(command, confirmed=confirmed)
@staticmethod
def _set_nonblocking(fd: int) -> None:
@@ -213,9 +202,10 @@ class _TerminalSessionManager:
cwd: Optional[str] = None,
env: Optional[dict[str, Any]] = None,
use_pty: Any = True,
confirm_dangerous: bool = False,
) -> dict[str, Any]:
"""启动后台命令并立即返回会话 ID。"""
self._validate_command(command)
self._validate_command(command, confirmed=confirm_dangerous)
normalized_cwd = self._normalize_cwd(cwd)
normalized_env = self._build_env(env)
should_use_pty = self._normalize_bool(use_pty, default=True) and os.name == "posix"
@@ -313,7 +303,10 @@ class _TerminalSessionManager:
continue
except OSError as err:
if err.errno not in {errno.EIO, errno.EBADF}:
logger.debug("PTY 输出读取异常: session_id=%s, error=%s", session.session_id, err)
logger.debug(
f"PTY 输出读取异常: session_id={session.session_id}, "
f"error={err}"
)
break
if not data:
@@ -343,7 +336,9 @@ class _TerminalSessionManager:
session.mark_finished(session.exit_code)
except Exception as err:
session.mark_error(str(err))
logger.warning("等待 PTY 进程失败: session_id=%s, error=%s", session.session_id, err)
logger.warning(
f"等待 PTY 进程失败: session_id={session.session_id}, error={err}"
)
finally:
await self._finish_reader_tasks(session)
session.close_pty()
@@ -358,7 +353,9 @@ class _TerminalSessionManager:
session.mark_finished(exit_code)
except Exception as err:
session.mark_error(str(err))
logger.warning("等待管道进程失败: session_id=%s, error=%s", session.session_id, err)
logger.warning(
f"等待管道进程失败: session_id={session.session_id}, error={err}"
)
finally:
await self._finish_reader_tasks(session)
@@ -533,7 +530,7 @@ class _TerminalSessionManager:
if len(encoded) > remaining:
if remaining > 0:
output_parts.append(
encoded[:remaining].decode("utf-8", errors="ignore")
encoded[:remaining].decode("utf-8", errors="replace")
)
output_truncated = True
break

View File

@@ -127,8 +127,19 @@ def filter_contexts(items: List[Context],
return filtered_items
def simplify_search_result(context: Context, index: int) -> dict:
"""精简单条搜索结果"""
def simplify_search_result(
context: Context,
index: int,
include_description: bool = False,
) -> dict:
"""
精简单条搜索结果
:param context: 搜索结果上下文
:param index: 搜索结果在原始缓存中的序号
:param include_description: 是否返回种子简介
:return: 精简后的搜索结果
"""
simplified = {}
torrent_info = context.torrent_info
meta_info = context.meta_info
@@ -147,6 +158,8 @@ def simplify_search_result(context: Context, index: int) -> dict:
"freedate_diff": torrent_info.freedate_diff,
"pubdate": torrent_info.pubdate,
}
if include_description:
simplified["torrent_info"]["description"] = torrent_info.description
if media_info:
simplified["media_info"] = {

View File

@@ -20,8 +20,6 @@ from app.schemas.types import SystemConfigKey
class AddCustomFilterRuleInput(BaseModel):
"""新增自定义过滤规则工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
rule_id: str = Field(
...,
description="Unique custom rule ID. Only letters and numbers are allowed.",

View File

@@ -15,7 +15,7 @@ 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.helper.directory import DirectoryHelper, validate_download_save_path
from app.log import logger
from app.schemas import FileURI, TorrentInfo
from app.utils.crypto import HashUtils
@@ -24,7 +24,6 @@ from app.utils.crypto import HashUtils
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(
...,
description="One or more torrent_url values. Supports refs from get_search_results (`hash:id`) and magnet links."
@@ -184,8 +183,8 @@ class AddDownloadTasksTool(MoviePilotTool):
@staticmethod
def _resolve_direct_download_dir(save_path: Optional[str]) -> Optional[Path]:
"""解析直接下载使用的目录,优先使用 save_path其次使用默认下载目录"""
if save_path:
return Path(save_path)
if save_path is not None:
return Path(validate_download_save_path(save_path))
download_dirs = DirectoryHelper().get_download_dirs()
if not download_dirs:
@@ -226,6 +225,8 @@ class AddDownloadTasksTool(MoviePilotTool):
merged_labels: Optional[str],
) -> tuple[Optional[str], Optional[str]]:
"""同步提交带上下文的下载任务,避免站点下载与下载器调用阻塞事件循环。"""
if save_path is not None:
save_path = validate_download_save_path(save_path)
return DownloadChain().download_single(
context=context,
downloader=downloader,
@@ -246,6 +247,12 @@ class AddDownloadTasksTool(MoviePilotTool):
if not torrent_inputs:
return "错误torrent_url 不能为空。"
if save_path is not None:
try:
save_path = validate_download_save_path(save_path)
except ValueError as err:
return f"参数错误save_path {str(err)}"
merged_labels = self._merge_labels_with_system_tag(labels)
success_count = 0
failed_messages = []

View File

@@ -24,8 +24,6 @@ from app.schemas.types import SystemConfigKey
class AddRuleGroupInput(BaseModel):
"""新增过滤规则组工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
name: str = Field(..., description="New rule group name.")
rule_string: str = Field(
...,

View File

@@ -15,8 +15,6 @@ from app.schemas.types import MediaType, MessageChannel
class AddSubscribeInput(BaseModel):
"""添加订阅工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
title: str = Field(
...,
description="The title of the media to subscribe to (e.g., 'The Matrix', 'Breaking Bad')",
@@ -41,6 +39,10 @@ class AddSubscribeInput(BaseModel):
None,
description="Douban ID for precise media identification (optional, alternative to tmdb_id)",
)
bangumi_id: Optional[int] = Field(None, description="Bangumi media ID")
anilist_id: Optional[int] = Field(None, description="AniList media ID")
media_source: Optional[str] = Field(None, description="Media metadata source")
media_id: Optional[str] = Field(None, description="Native ID for media_source")
start_episode: Optional[int] = Field(
None,
description="Starting episode number for TV shows (optional, defaults to 1 if not specified)",
@@ -99,7 +101,7 @@ class AddSubscribeTool(MoviePilotTool):
message += f" ({year})"
if media_type:
message += f" [{media_type}]"
if season:
if season is not None:
message += f"{season}"
elif media_type == "tv":
message += " 第1季(默认)"
@@ -146,6 +148,10 @@ class AddSubscribeTool(MoviePilotTool):
season: Optional[int] = None,
tmdb_id: Optional[int] = None,
douban_id: Optional[str] = None,
bangumi_id: Optional[int] = None,
anilist_id: Optional[int] = None,
media_source: Optional[str] = None,
media_id: Optional[str] = None,
start_episode: Optional[int] = None,
total_episode: Optional[int] = None,
quality: Optional[str] = None,
@@ -199,6 +205,10 @@ class AddSubscribeTool(MoviePilotTool):
year=year,
tmdbid=tmdb_id,
doubanid=douban_id,
bangumiid=bangumi_id,
anilistid=anilist_id,
media_source=media_source,
media_id=media_id,
season=season,
username=subscribe_username,
**subscribe_kwargs,

View File

@@ -4,7 +4,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.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.helper.interaction import (
AgentInteractionOption,
@@ -27,6 +27,7 @@ class UserChoiceOptionInput(BaseModel):
@model_validator(mode="after")
def validate_option(self):
"""校验按钮选项的文案和值不能为空。"""
label = str(self.label)
value = str(self.value)
if not label.strip():
@@ -39,8 +40,6 @@ class UserChoiceOptionInput(BaseModel):
class AskUserChoiceInput(BaseModel):
"""按钮选择工具输入。"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why the agent needs the user to choose from buttons",)
message: str = Field(
...,
description="Question or prompt shown to the user together with the buttons",
@@ -56,6 +55,7 @@ class AskUserChoiceInput(BaseModel):
@model_validator(mode="after")
def validate_payload(self):
"""校验按钮选择工具必须提供问题和选项。"""
message = str(self.message)
if not message.strip():
raise ValueError("message 不能为空")
@@ -85,6 +85,7 @@ class AskUserChoiceTool(MoviePilotTool):
args_schema: Type[BaseModel] = AskUserChoiceInput
def get_tool_message(self, **kwargs) -> Optional[str]:
"""生成工具执行提示文案。"""
message = kwargs.get("message", "") or ""
if len(message) > 40:
message = message[:40] + "..."
@@ -92,6 +93,7 @@ class AskUserChoiceTool(MoviePilotTool):
@staticmethod
def _truncate_button_text(text: str, max_length: int) -> str:
"""按渠道限制截断按钮文案。"""
if max_length <= 0 or len(text) <= max_length:
return text
if max_length <= 3:
@@ -114,6 +116,14 @@ class AskUserChoiceTool(MoviePilotTool):
title: Optional[str] = None,
**kwargs,
) -> str:
"""
发送按钮选择消息,并登记待回调的交互上下文。
:param message: 展示给用户的问题
:param options: 可点击的选项列表
:param title: 可选标题
:return: 工具执行结果描述
"""
if self._blocked_by_feedback_quality_gate():
logger.warning(
"ask_user_choice blocked after feedback issue rejected_quality: "
@@ -148,7 +158,8 @@ class AskUserChoiceTool(MoviePilotTool):
choice_options = [
AgentInteractionOption(
label=option.label.strip(), value=option.value.strip()
label=option.label.strip(),
value=option.value.strip(),
)
for option in options
]
@@ -188,7 +199,7 @@ class AskUserChoiceTool(MoviePilotTool):
len(choice_options),
)
await ToolChain().async_post_message(
await self.send_notification_message(
Notification(
channel=channel,
source=self._source,
@@ -198,6 +209,7 @@ class AskUserChoiceTool(MoviePilotTool):
title=title,
text=message.strip(),
buttons=buttons,
save_history=False,
)
)

View File

@@ -47,8 +47,6 @@ class BrowserAction(str, Enum):
class BrowseWebpageInput(BaseModel):
"""浏览器操作工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this browser action is being performed",)
action: str = Field(
...,
description=(
@@ -228,6 +226,11 @@ class BrowseWebpageTool(MoviePilotTool):
return "错误: 'fill_ref' 操作需要提供 value 参数"
if browser_action == BrowserAction.EVALUATE and not script:
return "错误: 'evaluate' 操作需要提供 script 参数"
if (
browser_action == BrowserAction.EVALUATE
and not await self.is_admin_user()
):
return "错误: 'evaluate' 操作仅允许管理员使用"
if (
browser_action in (BrowserAction.FOCUS_TAB, BrowserAction.CLOSE_TAB)
and tab_index is None

View File

@@ -0,0 +1,161 @@
import json
from datetime import datetime, timedelta
from typing import Literal, Optional, Type
import pytz
from pydantic import BaseModel, Field, model_validator
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.core.config import settings
from app.db.agentchat_oper import AgentChatOper
from app.db.agenttask_oper import AgentTaskOper
from app.utils.timer import TimerUtils
class CreateAgentTaskInput(BaseModel):
"""创建 Agent 自主定时任务的输入参数。"""
name: str = Field(
...,
min_length=1,
max_length=100,
description="Short task name shown in task management and execution reports.",
)
content: str = Field(
...,
min_length=1,
max_length=10000,
description="Complete instructions that the agent must execute when the task fires.",
)
trigger_type: Literal["date", "cron"] = Field(
...,
description="Use 'date' for one exact future run or 'cron' for recurring work.",
)
trigger: Optional[str] = Field(
None,
min_length=1,
max_length=200,
description=(
"For date, an ISO 8601 local or timezone-aware time such as "
"2026-07-19 20:30:00; for cron, a standard five-field expression "
"(minute hour day month weekday). The MoviePilot system timezone is used."
),
)
delay_minutes: Optional[int] = Field(
None,
ge=1,
le=525600,
description=(
"For a one-time date task expressed as 'in N minutes', provide this instead "
"of trigger. MoviePilot calculates and persists the exact future run time."
),
)
@model_validator(mode="after")
def validate_trigger(self) -> "CreateAgentTaskInput":
"""校验任务触发配置并统一格式。"""
self.name = self.name.strip()
self.content = self.content.strip()
if not self.name or not self.content:
raise ValueError("name 和 content 不能只包含空白字符")
if self.trigger_type == "date":
if self.delay_minutes is not None:
# LangChain 会在 run() 前后各校验一次,延迟时间在持久化前统一计算。
self.trigger = None
return self
if self.trigger is None:
raise ValueError("date 任务必须提供 trigger 或 delay_minutes")
elif self.trigger is None or self.delay_minutes is not None:
raise ValueError("cron 任务必须提供 trigger且不能提供 delay_minutes")
self.trigger_type, self.trigger = TimerUtils.normalize_schedule_trigger(
trigger_type=self.trigger_type,
trigger_value=self.trigger,
timezone_name=settings.TZ,
require_future=True,
)
return self
class CreateAgentTaskTool(MoviePilotTool):
"""创建可精确唤醒当前 Agent 会话的自主定时任务。"""
name: str = "create_agent_task"
tags: list[str] = [ToolTag.Write, ToolTag.AgentTask, ToolTag.Admin]
description: str = (
"Create a persistent autonomous agent task only when the user explicitly asks "
"for delayed, scheduled, recurring, reminder, or monitoring work. Use trigger_type "
"'date' with delay_minutes for requests such as 'check in 30 minutes', an exact "
"trigger time for other one-time work, and 'cron' for recurring schedules. When "
"fired, MoviePilot wakes the agent in this conversation, executes content, and "
"broadcasts user-facing messages through the configured notification channels."
)
args_schema: Type[BaseModel] = CreateAgentTaskInput
require_admin: bool = True
def get_tool_message(self, **kwargs: object) -> Optional[str]:
"""生成创建定时任务的提示消息。"""
return f"创建自主定时任务:{kwargs.get('name', '')}"
def _create_task(self, payload: CreateAgentTaskInput) -> dict:
"""持久化任务并立即注册到运行时调度器。"""
from app.scheduler import Scheduler
trigger_value = payload.trigger
if payload.trigger_type == "date" and payload.delay_minutes is not None:
timezone = pytz.timezone(settings.TZ)
trigger_value = (
datetime.now(timezone) + timedelta(minutes=payload.delay_minutes)
).isoformat(timespec="seconds")
_, trigger_value = TimerUtils.normalize_schedule_trigger(
trigger_type=payload.trigger_type,
trigger_value=trigger_value,
timezone_name=settings.TZ,
require_future=True,
)
chat = AgentChatOper().get(
session_id=self._session_id,
user_id=self._user_id,
)
task = AgentTaskOper().add(
name=payload.name.strip(),
content=payload.content.strip(),
trigger_type=payload.trigger_type,
cron_expression=trigger_value if payload.trigger_type == "cron" else None,
run_at=trigger_value if payload.trigger_type == "date" else None,
user_id=str(self._user_id),
username=self._username or (chat.username if chat else None),
session_id=str(self._session_id),
channel=self._channel or (chat.channel if chat else None),
source=self._source or (chat.source if chat else None),
original_chat_id=chat.original_chat_id if chat else None,
)
scheduler = Scheduler()
next_run_at = scheduler.update_agent_task_job(task.id)
return AgentTaskOper.to_dict(
task,
next_run_at=next_run_at,
timezone=settings.TZ,
)
async def run(
self,
name: str,
content: str,
trigger_type: str,
trigger: Optional[str] = None,
delay_minutes: Optional[int] = None,
**kwargs: object,
) -> str:
"""创建 Agent 自主定时任务。"""
if not settings.AI_AGENT_ENABLE:
return "AI Agent 未启用,无法创建自主定时任务"
payload = CreateAgentTaskInput(
name=name,
content=content,
trigger_type=trigger_type,
trigger=trigger,
delay_minutes=delay_minutes,
)
task = await self.run_blocking("db", self._create_task, payload)
return json.dumps(task, ensure_ascii=False, indent=2)

View File

@@ -0,0 +1,50 @@
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.agenttask_oper import AgentTaskOper
class DeleteAgentTaskInput(BaseModel):
"""删除 Agent 自主定时任务的输入参数。"""
task_id: int = Field(..., ge=1, description="ID of the task to permanently delete.")
class DeleteAgentTaskTool(MoviePilotTool):
"""永久删除 Agent 自主定时任务。"""
name: str = "delete_agent_task"
tags: list[str] = [ToolTag.Write, ToolTag.AgentTask, ToolTag.Admin]
description: str = (
"Permanently delete an autonomous agent task and remove its runtime schedule. "
"Use update_agent_task with enabled=false when the user only wants to pause it."
)
args_schema: Type[BaseModel] = DeleteAgentTaskInput
require_admin: bool = True
def get_tool_message(self, **kwargs: object) -> Optional[str]:
"""生成删除定时任务的提示消息。"""
return f"删除自主定时任务:{kwargs.get('task_id', '')}"
def _delete_task(self, task_id: int) -> bool:
"""删除当前用户的任务并移除运行时调度。"""
from app.scheduler import Scheduler
deleted = AgentTaskOper().delete(
task_id=task_id,
user_id=str(self._user_id),
)
if deleted:
Scheduler().remove_agent_task_job(task_id)
return deleted
async def run(self, task_id: int, **kwargs: object) -> str:
"""删除 Agent 自主定时任务。"""
payload = DeleteAgentTaskInput(task_id=task_id)
deleted = await self.run_blocking("db", self._delete_task, payload.task_id)
if not deleted:
return f"Agent 定时任务 {task_id} 不存在或不属于当前用户"
return f"Agent 定时任务 {task_id} 已删除"

View File

@@ -20,8 +20,6 @@ from app.schemas.types import SystemConfigKey
class DeleteCustomFilterRuleInput(BaseModel):
"""删除自定义过滤规则工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
rule_id: str = Field(..., description="Custom rule ID to delete.")

View File

@@ -6,16 +6,13 @@ 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.db.downloadhistory_oper import DownloadHistoryOper
from app.log import logger
class DeleteDownloadHistoryInput(BaseModel):
"""删除下载历史记录工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
history_id: int = Field(
..., description="The ID of the download history record to delete"
)
@@ -40,9 +37,8 @@ class DeleteDownloadHistoryTool(MoviePilotTool):
logger.info(f"执行工具: {self.name}, 参数: history_id={history_id}")
try:
async with AsyncSessionFactory() as db:
await DownloadHistory.async_delete(db, history_id)
return f"下载历史记录 ID: {history_id} 已成功删除"
await DownloadHistoryOper().async_delete_history(history_id)
return f"下载历史记录 ID: {history_id} 已成功删除"
except Exception as e:
logger.error(f"删除下载历史记录失败: {e}", exc_info=True)
return f"删除下载历史记录时发生错误: {str(e)}"

View File

@@ -13,8 +13,6 @@ from app.log import logger
class DeleteDownloadTasksInput(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)"
)

View File

@@ -19,8 +19,6 @@ from app.schemas.types import SystemConfigKey
class DeleteRuleGroupInput(BaseModel):
"""删除过滤规则组工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
name: str = Field(..., description="Rule group name to delete.")

View File

@@ -16,8 +16,6 @@ from app.schemas.types import EventType
class DeleteSubscribeInput(BaseModel):
"""删除订阅工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
subscribe_id: int = Field(
...,
description="The ID of the subscription to delete (can be obtained from query_subscribes tool)",
@@ -56,7 +54,15 @@ class DeleteSubscribeTool(MoviePilotTool):
await subscribe_oper.async_delete(subscribe_id)
# 分享订阅统计刷新本身已异步化,这里只需要在删除后触发即可。
MoviePilotServerHelper.sub_done_async(
{"tmdbid": subscribe.tmdbid, "doubanid": subscribe.doubanid}
{
"tmdbid": subscribe.tmdbid,
"doubanid": subscribe.doubanid,
"bangumiid": subscribe.bangumiid,
"anilistid": subscribe.anilistid,
"media_source": subscribe.media_source,
"media_id": subscribe.media_id,
"season": subscribe.season,
}
)
# 发送事件

View File

@@ -6,15 +6,15 @@ 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.db.transferhistory_oper import TransferHistoryOper
from app.log import logger
from app.schemas import FileItem
class DeleteTransferHistoryInput(BaseModel):
"""删除整理历史记录工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
history_id: int = Field(
..., description="The ID of the transfer history record to delete"
)
@@ -27,7 +27,11 @@ class DeleteTransferHistoryTool(MoviePilotTool):
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."
description: str = (
"Delete a specific transfer history record by its ID. For non-successful-move records with an old "
"destination file, the tool removes that media-library file before deleting the history record. This is "
"useful before retrying or re-organizing because the system skips files that already have transfer history."
)
args_schema: Type[BaseModel] = DeleteTransferHistoryInput
require_admin: bool = True
@@ -48,10 +52,21 @@ class DeleteTransferHistoryTool(MoviePilotTool):
title = history.title or "未知"
src = history.src or "未知"
status = "成功" if history.status else "失败"
deleted_dest = False
if history.dest_fileitem and not (history.status and history.mode == "move"):
dest_fileitem = FileItem(**history.dest_fileitem)
storage_chain = StorageChain()
if storage_chain.exists(dest_fileitem):
if not storage_chain.delete_media_file(dest_fileitem):
return f"错误:旧媒体库文件删除失败,路径={dest_fileitem.path}"
deleted_dest = True
await transferhis.async_delete(history_id)
return (
message = (
f"已删除整理历史记录ID={history_id},标题={title},源路径={src},状态={status}"
)
if deleted_dest:
message += ",已删除旧媒体库文件"
return message
except Exception as e:
logger.error(f"删除整理历史记录失败: {e}", exc_info=True)
return f"删除整理历史记录时发生错误: {str(e)}"

View File

@@ -1,4 +1,4 @@
"""文件编辑工具"""
"""文件精确编辑工具"""
from pathlib import Path
from typing import Optional, Type
@@ -7,28 +7,59 @@ from anyio import Path as AsyncPath
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.impl._file_write_utils import (
FileVersionConflictError,
atomic_write_text,
calculate_file_sha256,
)
from app.agent.tools.tags import ToolTag
from app.log import logger
class EditFileInput(BaseModel):
"""Input parameters for edit file tool"""
"""文件编辑工具的输入参数模型。"""
file_path: str = Field(..., description="The absolute path of the file to edit")
old_text: str = Field(..., description="The exact old text to be replaced")
old_text: str = Field(
...,
description=(
"The exact old text to replace. It must be non-empty and uniquely "
"identify one location unless replace_all is true."
),
)
new_text: str = Field(..., description="The new text to replace with")
replace_all: bool = Field(
False,
description=(
"Replace every exact match. Keep false for normal code edits so an "
"ambiguous match fails instead of changing multiple locations."
),
)
expected_sha256: Optional[str] = Field(
None,
pattern=r"^[0-9a-fA-F]{64}$",
description=(
"Optional SHA-256 returned by read_file(include_metadata=true). The "
"edit fails if the file changed after it was read."
),
)
class EditFileTool(MoviePilotTool):
"""使用精确文本匹配安全编辑本地文件。"""
name: str = "edit_file"
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."
"Edit an existing local text file using an exact text match. By default "
"the match must occur exactly once; use replace_all only for intentional "
"bulk replacement. old_text cannot be empty, and new files must be "
"created with write_file. Supports an optional SHA-256 conflict check. "
"Non-admin users can only edit files inside the MoviePilot Agent config "
"directory."
)
args_schema: Type[BaseModel] = EditFileInput
@@ -38,7 +69,16 @@ class EditFileTool(MoviePilotTool):
file_name = Path(file_path).name if file_path else "未知文件"
return f"编辑文件: {file_name}"
async def run(self, file_path: str, old_text: str, new_text: str, **kwargs) -> str:
async def run(
self,
file_path: str,
old_text: str,
new_text: str,
replace_all: bool = False,
expected_sha256: Optional[str] = None,
**kwargs,
) -> str:
"""校验精确匹配和可选文件版本后,以原子方式写入编辑结果。"""
logger.info(f"执行工具: {self.name}, 参数: file_path={file_path}")
try:
@@ -48,37 +88,74 @@ class EditFileTool(MoviePilotTool):
if access_error:
return access_error
path = AsyncPath(resolved_path)
# 校验逻辑:如果要替换特定文本,文件必须存在且包含该文本
if not await path.exists():
# 如果 old_text 为空,可能用户想直接创建文件,但通常 edit_file 需要匹配旧内容
if old_text:
return f"错误:文件 {resolved_path} 不存在,无法进行内容替换。"
if not old_text:
return "错误old_text 不能为空;创建或完整写入文件请使用 write_file。"
if await path.exists() and not await path.is_file():
path = AsyncPath(resolved_path)
if not await path.exists():
return f"错误:文件 {resolved_path} 不存在;创建文件请使用 write_file。"
if not await path.is_file():
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"编辑文件 {resolved_path} 失败:未找到指定的旧文本块")
return f"错误:在文件 {resolved_path} 中未找到指定的旧文本。请确保包含所有的空格、缩进 and 换行符。"
occurrences = content.count(old_text)
new_content = content.replace(old_text, new_text)
else:
# 文件不存在且 old_text 为空的情形(初始化新文件)
new_content = new_text
occurrences = 1
local_path = Path(resolved_path)
current_sha256 = await self.run_blocking(
"default", calculate_file_sha256, local_path
)
if (
expected_sha256
and current_sha256.casefold() != expected_sha256.casefold()
):
return (
f"错误:文件 {resolved_path} 已在读取后发生变化,拒绝覆盖。"
"请重新读取文件并基于最新内容编辑。"
)
# 自动创建父目录
await path.parent.mkdir(parents=True, exist_ok=True)
content = await path.read_text(encoding="utf-8", errors="strict")
occurrences = content.count(old_text)
if occurrences == 0:
logger.warning(f"编辑文件 {resolved_path} 失败:未找到指定的旧文本块")
return (
f"错误:在文件 {resolved_path} 中未找到指定的旧文本。"
"请重新读取文件并确认空格、缩进和换行。"
)
if occurrences > 1 and not replace_all:
return (
f"错误old_text 在文件 {resolved_path} 中匹配到 {occurrences} 处,"
"为避免误改已拒绝编辑。请提供更多上下文使其唯一,或明确设置 "
"replace_all=true。"
)
# 写入文件
await path.write_text(new_content, encoding="utf-8")
replacement_count = occurrences if replace_all else 1
new_content = content.replace(
old_text,
new_text,
-1 if replace_all else 1,
)
await self.run_blocking(
"default",
atomic_write_text,
local_path,
new_content,
current_sha256,
)
new_sha256 = await self.run_blocking(
"default", calculate_file_sha256, local_path
)
logger.info(f"成功编辑文件 {resolved_path},替换了 {occurrences} 处内容")
return f"成功编辑文件 {resolved_path} (替换了 {occurrences}匹配内容)"
logger.info(
f"成功编辑文件 {resolved_path}替换了 {replacement_count} 处内容"
)
return (
f"成功编辑文件 {resolved_path}(替换了 {replacement_count} 处匹配内容,"
f"sha256={new_sha256}"
)
except FileVersionConflictError:
return (
f"错误:文件 {file_path} 在编辑期间发生变化,拒绝覆盖。"
"请重新读取文件并再次编辑。"
)
except PermissionError:
return f"错误:没有访问/修改 {file_path} 的权限"
except UnicodeDecodeError:

View File

@@ -13,6 +13,7 @@ from typing import Any, Literal, Optional, TextIO, Type
from pydantic import BaseModel, Field
from app.agent.tools.impl._command_safety import validate_command_safety
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.agent.tools.impl._terminal_session import (
@@ -30,14 +31,6 @@ MAX_OUTPUT_PREVIEW_BYTES = 10 * 1024
READ_CHUNK_SIZE = 4096
KILL_GRACE_SECONDS = 3
COMMAND_CONCURRENCY_LIMIT = 2
COMMAND_FORBIDDEN_KEYWORDS = (
":(){ :|:& };:",
"dd if=/dev/zero",
"mkfs",
"reboot",
"shutdown",
)
_command_semaphore = asyncio.Semaphore(COMMAND_CONCURRENCY_LIMIT)
@@ -58,7 +51,7 @@ class _CommandOutput:
"""按 UTF-8 字节数截断文本,避免截断后出现非法字符。"""
if byte_limit <= 0:
return ""
return text.encode("utf-8")[:byte_limit].decode("utf-8", errors="ignore")
return text.encode("utf-8")[:byte_limit].decode("utf-8", errors="replace")
def _write_chunk(self, stream_name: str, text: str) -> None:
"""把输出分片按 stdout/stderr 分段写入临时文件。"""
@@ -142,7 +135,6 @@ class _CommandOutput:
class ExecuteCommandInput(BaseModel):
"""执行 Shell 命令工具的输入参数模型。"""
explanation: Optional[str] = Field(None, description="Clear explanation of why this command action is needed")
action: Optional[Literal["start", "read", "wait", "write", "kill", "run"]] = Field(
"start",
description=(
@@ -195,6 +187,13 @@ class ExecuteCommandInput(BaseModel):
60,
description="For action=run, max execution time in seconds.",
)
confirm_dangerous: Optional[bool] = Field(
False,
description=(
"Explicit confirmation for high-risk commands such as recursive root deletion, "
"disk formatting, shutdown/reboot, or destructive permission changes."
),
)
class ExecuteCommandTool(MoviePilotTool):
@@ -255,34 +254,9 @@ class ExecuteCommandTool(MoviePilotTool):
return command
@staticmethod
def _validate_command(command: str) -> None:
def _validate_command(command: str, *, confirmed: bool = False) -> None:
"""复用旧工具的基础危险命令过滤,避免明显破坏性命令进入 shell。"""
for keyword in COMMAND_FORBIDDEN_KEYWORDS:
if keyword in command:
raise ValueError(f"命令包含禁止使用的关键字 '{keyword}'")
# 检查是否使用了 rm -r/R 删除根目录或一级目录,防止误杀多级目录
import re
import os.path
tokens = re.split(r'\s+', command.strip())
if any(t == "rm" or t.endswith("/rm") for t in tokens):
has_r = False
for token in tokens:
if token.startswith("-") and ("r" in token or "R" in token):
has_r = True
break
if has_r:
for token in tokens:
# 提取可能包含目标路径的部分(去除重定向、管道、分号等末尾干扰)
m = re.match(r'^([^;\|&><]+)', token)
if m:
clean_token = m.group(1).strip('"\'')
# 仅对绝对路径进行一级目录限制
if clean_token.startswith('/'):
norm_path = os.path.normpath(clean_token)
if re.match(r'^/[^/]*$', norm_path) or re.match(r'^/[^/]*/$', norm_path):
raise ValueError(f"不允许使用 rm 命令删除根目录或一级目录: {clean_token}")
validate_command_safety(command, confirmed=confirmed)
@staticmethod
def _normalize_timeout(timeout: Optional[int]) -> tuple[int, Optional[str]]:
@@ -367,7 +341,7 @@ class ExecuteCommandTool(MoviePilotTool):
asyncio.shield(wait_task), timeout=KILL_GRACE_SECONDS
)
except asyncio.TimeoutError:
logger.warning("命令进程强制清理超时: pid=%s", process.pid)
logger.warning(f"命令进程强制清理超时: pid={process.pid}")
@staticmethod
async def _finish_reader_tasks(reader_tasks: list[asyncio.Task]) -> None:
@@ -382,7 +356,7 @@ class ExecuteCommandTool(MoviePilotTool):
if isinstance(result, Exception) and not isinstance(
result, asyncio.CancelledError
):
logger.debug("命令输出读取任务异常: %s", result)
logger.debug(f"命令输出读取任务异常: {result}")
@staticmethod
def _format_run_result(
@@ -425,9 +399,10 @@ class ExecuteCommandTool(MoviePilotTool):
command: str,
timeout: Optional[int],
cwd: Optional[str] = None,
confirm_dangerous: bool = False,
) -> str:
"""按旧模式一次性执行命令,等待完成或超时后返回文本结果。"""
self._validate_command(command)
self._validate_command(command, confirmed=confirm_dangerous)
normalized_timeout, timeout_note = self._normalize_timeout(timeout)
async with _command_semaphore:
@@ -482,27 +457,29 @@ class ExecuteCommandTool(MoviePilotTool):
max_bytes: Optional[int] = TERMINAL_DEFAULT_READ_BYTES,
timeout_ms: Optional[int] = TERMINAL_WAIT_DEFAULT_MS,
timeout: Optional[int] = 60,
confirm_dangerous: Optional[bool] = False,
**kwargs,
) -> str:
"""执行命令动作:默认后台启动,也支持读取、等待、写入、终止和一次性执行。"""
normalized_action = (action or "start").strip().lower()
logger.info(
"执行工具: %s, action=%s, command=%s, session_id=%s",
self.name,
normalized_action,
command,
session_id,
f"执行工具: {self.name}, action={normalized_action}, "
f"command={command}, session_id={session_id}"
)
try:
if normalized_action == "start":
start_command = self._require_command(command)
self._validate_command(start_command)
self._validate_command(
start_command,
confirmed=bool(confirm_dangerous),
)
payload = await terminal_session_manager.start(
command=start_command,
cwd=cwd,
env=env,
use_pty=use_pty,
confirm_dangerous=bool(confirm_dangerous),
)
return self._dump(payload)
@@ -542,9 +519,10 @@ class ExecuteCommandTool(MoviePilotTool):
command=self._require_command(command),
timeout=timeout,
cwd=cwd,
confirm_dangerous=bool(confirm_dangerous),
)
raise ValueError(f"不支持的 action: {action}")
except Exception as err:
logger.error("执行命令 action 失败: %s", err, exc_info=True)
logger.error(f"执行命令 action 失败: {err}", exc_info=True)
return self._dump({"error": str(err), "status": "error", "action": normalized_action})

View File

@@ -15,8 +15,6 @@ from app.schemas.types import MediaType, media_type_to_agent
class GetRecommendationsInput(BaseModel):
"""获取推荐工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
source: Optional[str] = Field(
"tmdb_trending",
description="Recommendation source: "
@@ -212,6 +210,10 @@ class GetRecommendationsTool(MoviePilotTool):
"tmdb_id": r.get("tmdb_id"),
"imdb_id": r.get("imdb_id"),
"douban_id": r.get("douban_id"),
"bangumi_id": r.get("bangumi_id"),
"anilist_id": r.get("anilist_id"),
"media_source": r.get("source"),
"media_id": r.get("media_id"),
"vote_average": r.get("vote_average"),
"poster_path": r.get("poster_path"),
"detail_link": r.get("detail_link"),

View File

@@ -21,8 +21,6 @@ from ._torrent_search_utils import (
class GetSearchResultsInput(BaseModel):
"""获取搜索结果工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
site: Optional[List[str]] = Field(None, description="Site name filters")
season: Optional[List[str]] = Field(None, description="Season or episode filters")
free_state: Optional[List[str]] = Field(None, description="Promotion state filters")
@@ -36,6 +34,14 @@ class GetSearchResultsInput(BaseModel):
None,
description="Regular expression pattern to filter torrent titles (e.g., '4K|2160p|UHD', '1080p.*BluRay')",
)
content_pattern: Optional[str] = Field(
None,
description="Regular expression pattern to filter torrent titles, descriptions, and labels (e.g., '特效字幕|国语|DIY')",
)
include_description: Optional[bool] = Field(
False,
description="Whether to include torrent descriptions in returned results",
)
show_filter_options: Optional[bool] = Field(
False,
description="Whether to return only optional filter options for re-checking available conditions",
@@ -47,6 +53,8 @@ class GetSearchResultsInput(BaseModel):
class GetSearchResultsTool(MoviePilotTool):
"""获取并筛选最近一次种子搜索结果"""
name: str = "get_search_results"
tags: list[str] = [
ToolTag.Read,
@@ -56,6 +64,7 @@ class GetSearchResultsTool(MoviePilotTool):
args_schema: Type[BaseModel] = GetSearchResultsInput
def get_tool_message(self, **kwargs) -> Optional[str]:
"""返回工具执行提示"""
return "获取搜索结果"
async def run(
@@ -68,13 +77,33 @@ class GetSearchResultsTool(MoviePilotTool):
resolution: Optional[List[str]] = None,
release_group: Optional[List[str]] = None,
title_pattern: Optional[str] = None,
content_pattern: Optional[str] = None,
include_description: bool = False,
show_filter_options: bool = False,
page: Optional[int] = 1,
**kwargs,
) -> str:
"""
获取并筛选最近一次种子搜索结果
:param site: 站点名称筛选项
:param season: 季集筛选项
:param free_state: 促销状态筛选项
:param video_code: 视频编码筛选项
:param edition: 制作版本筛选项
:param resolution: 分辨率筛选项
:param release_group: 发布组筛选项
:param title_pattern: 仅匹配种子标题的正则表达式
:param content_pattern: 匹配种子标题、简介和标签的正则表达式
:param include_description: 是否在结果中返回种子简介
:param show_filter_options: 是否只返回可用筛选项
:param page: 分页页码
:param kwargs: 工具框架附加参数
:return: JSON 格式的搜索结果或错误提示
"""
page = max(1, page or 1)
logger.info(
f"执行工具: {self.name}, 参数: site={site}, season={season}, free_state={free_state}, video_code={video_code}, edition={edition}, resolution={resolution}, release_group={release_group}, title_pattern={title_pattern}, show_filter_options={show_filter_options}, page={page}"
f"执行工具: {self.name}, 参数: site={site}, season={season}, free_state={free_state}, video_code={video_code}, edition={edition}, resolution={resolution}, release_group={release_group}, title_pattern={title_pattern}, content_pattern={content_pattern}, include_description={include_description}, show_filter_options={show_filter_options}, page={page}"
)
try:
@@ -89,14 +118,22 @@ class GetSearchResultsTool(MoviePilotTool):
}
return json.dumps(payload, ensure_ascii=False, indent=2)
regex_pattern = None
title_regex_pattern = None
if title_pattern:
try:
regex_pattern = re.compile(title_pattern, re.IGNORECASE)
title_regex_pattern = re.compile(title_pattern, re.IGNORECASE)
except re.error as e:
logger.warning(f"正则表达式编译失败: {title_pattern}, 错误: {e}")
return f"正则表达式格式错误: {str(e)}"
content_regex_pattern = None
if content_pattern:
try:
content_regex_pattern = re.compile(content_pattern, re.IGNORECASE)
except re.error as e:
logger.warning(f"正则表达式编译失败: {content_pattern}, 错误: {e}")
return f"正则表达式格式错误: {str(e)}"
filtered_items = filter_contexts(
items=items,
site=site,
@@ -107,14 +144,29 @@ class GetSearchResultsTool(MoviePilotTool):
resolution=resolution,
release_group=release_group,
)
if regex_pattern:
if title_regex_pattern:
filtered_items = [
item
for item in filtered_items
if item.torrent_info
and item.torrent_info.title
and regex_pattern.search(item.torrent_info.title)
and title_regex_pattern.search(item.torrent_info.title)
]
if content_regex_pattern:
content_filtered_items = []
for item in filtered_items:
torrent_info = item.torrent_info
if not torrent_info:
continue
content_values = [torrent_info.title, torrent_info.description]
content_values.extend(torrent_info.labels or [])
if any(
content_regex_pattern.search(str(value))
for value in content_values
if value
):
content_filtered_items.append(item)
filtered_items = content_filtered_items
if not filtered_items:
return "没有符合筛选条件的搜索结果,请调整筛选条件"
@@ -137,7 +189,11 @@ class GetSearchResultsTool(MoviePilotTool):
return f"{page} 页没有数据,共 {total_count} 条结果,共 {(total_count + page_size - 1) // page_size} 页。"
results = [
simplify_search_result(item, index)
simplify_search_result(
item,
index,
include_description=include_description,
)
for item, index in zip(page_items, page_indices)
]
total_pages = (total_count + page_size - 1) // page_size

View File

@@ -19,8 +19,6 @@ from app.log import logger
class InstallPluginInput(BaseModel):
"""安装插件工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
plugin_id: str = Field(
...,
description="Exact plugin ID to install. Use query_market_plugins first to find the correct plugin_id.",

View File

@@ -17,7 +17,6 @@ from app.utils.string import StringUtils
class ListDirectoryInput(BaseModel):
"""查询文件系统目录内容工具的输入参数模型"""
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
path: str = Field(..., description="Directory path to list contents (e.g., '/home/user/downloads' or 'C:/Downloads')")
storage: Optional[str] = Field("local", description="Storage type (default: 'local' for local file system, can be 'smb', 'alist', etc.)")
sort_by: Optional[str] = Field("name", description="Sort order: 'name' for alphabetical sorting, 'time' for modification time sorting (default: 'name')")

View File

@@ -13,8 +13,6 @@ from app.log import logger
class ListSlashCommandsInput(BaseModel):
"""查询所有可用斜杠命令工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
class ListSlashCommandsTool(MoviePilotTool):

View File

@@ -0,0 +1,98 @@
"""外部 MCP 工具适配器。"""
import json
from typing import Any, Optional
from pydantic import PrivateAttr
from app.agent.mcp import AgentMcpToolSpec, agent_mcp_manager
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
class McpExternalTool(MoviePilotTool):
"""将外部 MCP 工具包装为 MoviePilot Agent 工具。"""
name: str = "mcp_external_tool"
tags: list[str] = [
ToolTag.Read,
ToolTag.Admin,
]
description: str = "Call an external MCP tool configured for MoviePilot Agent."
args_schema: dict[str, Any] = {"type": "object", "properties": {}, "required": []}
require_admin: bool = True
_spec: AgentMcpToolSpec = PrivateAttr()
def __init__(self, spec: AgentMcpToolSpec, session_id: str, user_id: str) -> None:
super().__init__(
session_id=session_id,
user_id=user_id,
name=spec.agent_tool_name,
description=spec.description
or f"Call external MCP tool {spec.name} on {spec.server.name}.",
args_schema=spec.input_schema,
require_admin=spec.server.require_admin,
)
self._spec = spec
def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据 MCP 工具信息生成友好的提示消息。"""
return f"调用 MCP 工具: {self._spec.server.name}/{self._spec.name}"
async def run(self, **kwargs) -> str:
"""
调用外部 MCP 工具。
:param kwargs: 传递给外部 MCP 工具的参数
:return: MCP 工具返回内容
"""
result = await agent_mcp_manager.call_server_tool(
server=self._spec.server,
tool_name=self._spec.name,
arguments=kwargs,
)
return self._format_mcp_result(result)
@staticmethod
def _format_mcp_result(result: Any) -> str:
"""将 MCP tools/call 返回结构转换为 Agent 可读文本。"""
if isinstance(result, dict):
content = result.get("content")
if isinstance(content, list):
parts = []
for item in content:
if not isinstance(item, dict):
continue
if item.get("type") == "text" and item.get("text") is not None:
parts.append(str(item["text"]))
elif item:
parts.append(json.dumps(item, ensure_ascii=False, default=str))
if parts:
return "\n".join(parts)
if result.get("isError"):
return json.dumps(result, ensure_ascii=False, indent=2, default=str)
if isinstance(result, str):
return result
return json.dumps(result, ensure_ascii=False, indent=2, default=str)
async def create_external_mcp_tools(
*,
session_id: str,
user_id: str,
channel: Optional[str] = None,
source: Optional[str] = None,
username: Optional[str] = None,
stream_handler=None,
agent_context: Optional[dict] = None,
) -> list[McpExternalTool]:
"""创建当前已启用的外部 MCP Agent 工具列表。"""
tools = []
for spec in await agent_mcp_manager.list_enabled_tool_specs():
tool = McpExternalTool(spec=spec, session_id=session_id, user_id=user_id)
tool.set_message_attr(channel=channel, source=source, username=username)
tool.set_stream_handler(stream_handler=stream_handler)
tool.set_agent_context(agent_context=agent_context)
tools.append(tool)
return tools

View File

@@ -0,0 +1,88 @@
import json
from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.core.config import settings
from app.db.agenttask_oper import AgentTaskOper
class QueryAgentTasksInput(BaseModel):
"""查询 Agent 自主定时任务的输入参数。"""
task_id: Optional[int] = Field(
None,
ge=1,
description="Optional task ID. Omit it to list tasks owned by the current user.",
)
enabled: Optional[bool] = Field(
None,
description="Optional enabled-state filter used when listing tasks.",
)
class QueryAgentTasksTool(MoviePilotTool):
"""查询当前用户创建的 Agent 自主定时任务。"""
name: str = "query_agent_tasks"
tags: list[str] = [ToolTag.Read, ToolTag.AgentTask, ToolTag.Admin]
description: str = (
"Query persistent autonomous agent tasks owned by the current user, including "
"reminders, monitoring tasks, and recurring agent work. Returns the integer "
"task_id, instructions, trigger, enabled state, next run time, and latest result. "
"Do not use this for MoviePilot system, plugin, or workflow scheduler services."
)
args_schema: Type[BaseModel] = QueryAgentTasksInput
require_admin: bool = True
def get_tool_message(self, **kwargs: object) -> Optional[str]:
"""生成查询定时任务的提示消息。"""
task_id = kwargs.get("task_id")
return f"查询自主定时任务:{task_id}" if task_id else "查询自主定时任务"
def _query_tasks(
self,
task_id: Optional[int],
enabled: Optional[bool],
) -> list[dict]:
"""读取当前用户的任务及运行时下一次触发时间。"""
from app.scheduler import Scheduler
oper = AgentTaskOper()
if task_id:
task = oper.get(task_id=task_id, user_id=str(self._user_id))
tasks = [task] if task else []
else:
tasks = oper.list(user_id=str(self._user_id), enabled=enabled)
scheduler = Scheduler()
result = []
for task in tasks:
data = oper.to_dict(
task,
next_run_at=scheduler.get_agent_task_next_run(task.id),
timezone=settings.TZ,
)
result.append(data)
return result
async def run(
self,
task_id: Optional[int] = None,
enabled: Optional[bool] = None,
**kwargs: object,
) -> str:
"""查询 Agent 自主定时任务。"""
payload = QueryAgentTasksInput(task_id=task_id, enabled=enabled)
tasks = await self.run_blocking(
"db",
self._query_tasks,
payload.task_id,
payload.enabled,
)
return json.dumps(
{"total": len(tasks), "tasks": tasks},
ensure_ascii=False,
indent=2,
)

View File

@@ -18,8 +18,6 @@ from app.log import logger
class QueryBuiltinFilterRulesInput(BaseModel):
"""查询内置过滤规则工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
rule_ids: Optional[List[str]] = Field(
None,
description="Optional list of built-in rule IDs to query. If omitted, return all built-in rules.",

View File

@@ -19,8 +19,6 @@ from app.log import logger
class QueryCustomFilterRulesInput(BaseModel):
"""查询自定义过滤规则工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
rule_ids: Optional[List[str]] = Field(
None,
description="Optional list of custom rule IDs to query. If omitted, return all custom rules.",

View File

@@ -15,8 +15,6 @@ from app.schemas.types import SystemConfigKey
class QueryCustomIdentifiersInput(BaseModel):
"""查询自定义识别词工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
class QueryCustomIdentifiersTool(MoviePilotTool):

View File

@@ -13,7 +13,6 @@ from app.log import logger
class QueryDirectorySettingsInput(BaseModel):
"""查询系统目录设置工具的输入参数模型"""
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
directory_type: Optional[str] = Field("all",
description="Filter directories by type: 'download' for download directories, 'library' for media library directories, 'all' for all directories")
storage_type: Optional[str] = Field("all",

View File

@@ -14,10 +14,6 @@ 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=(
@@ -48,7 +44,9 @@ class QueryDoctorReportTool(MoviePilotTool):
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."
"database health, frontend assets, safe mode, or recent log error clues. Plugin-only log findings remain "
"visible with affects_report_status=false and do not downgrade the overall status. This tool never applies "
"fixes."
)
require_admin: bool = True
args_schema: Type[BaseModel] = QueryDoctorReportInput
@@ -77,6 +75,7 @@ class QueryDoctorReportTool(MoviePilotTool):
"title": item.get("title"),
"fixable": item.get("fixable"),
"fixed": item.get("fixed"),
"affects_report_status": item.get("affects_report_status", True),
}
for item in report.get("findings") or []
if isinstance(item, dict)

View File

@@ -16,7 +16,6 @@ from app.schemas.types import TorrentQueryStatus, media_type_to_agent
class QueryDownloadTasksInput(BaseModel):
"""查询下载工具的输入参数模型"""
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
downloader: Optional[str] = Field(None,
description="Name of specific downloader to query (optional, if not provided queries all configured downloaders)")
status: Optional[str] = Field("all",

View File

@@ -14,9 +14,6 @@ from app.schemas.types import SystemConfigKey
class QueryDownloadersInput(BaseModel):
"""查询下载器工具的输入参数模型"""
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
class QueryDownloadersTool(MoviePilotTool):
name: str = "query_downloaders"
tags: list[str] = [

View File

@@ -13,7 +13,6 @@ from app.log import logger
class QueryEpisodeScheduleInput(BaseModel):
"""查询剧集上映时间工具的输入参数模型"""
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
tmdb_id: int = Field(..., description="TMDB ID of the TV series (can be obtained from search_media tool)")
season: int = Field(..., description="Season number to query")
episode_group: Optional[str] = Field(None, description="Episode group ID (optional)")

View File

@@ -10,6 +10,7 @@ from app.agent.tools.tags import ToolTag
from app.agent.tools.impl._plugin_tool_utils import (
DEFAULT_PLUGIN_CANDIDATE_LIMIT,
MAX_PLUGIN_CANDIDATE_LIMIT,
enrich_installed_plugin_sources,
list_installed_plugins,
search_plugin_candidates,
summarize_candidates,
@@ -21,8 +22,6 @@ from app.log import logger
class QueryInstalledPluginsInput(BaseModel):
"""查询已安装插件工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
query: Optional[str] = Field(
None,
description="Optional keyword to filter installed plugins by plugin ID, name, description, or author.",
@@ -31,9 +30,15 @@ class QueryInstalledPluginsInput(BaseModel):
DEFAULT_PLUGIN_CANDIDATE_LIMIT,
description="Maximum number of plugins to return. Defaults to 50, capped at 200.",
)
force_refresh_market: bool = Field(
False,
description="Whether to refresh plugin market caches before completing missing repo_url values.",
)
class QueryInstalledPluginsTool(MoviePilotTool):
"""查询已安装插件并返回 Agent 可消费的摘要信息。"""
name: str = "query_installed_plugins"
tags: list[str] = [
ToolTag.Read,
@@ -67,9 +72,15 @@ class QueryInstalledPluginsTool(MoviePilotTool):
self,
query: Optional[str] = None,
max_results: Optional[int] = DEFAULT_PLUGIN_CANDIDATE_LIMIT,
force_refresh_market: bool = False,
**kwargs,
) -> str:
logger.info(f"执行工具: {self.name}, 参数: query={query}")
"""
查询已安装插件列表,并在可能时补齐插件来源仓库地址。
"""
logger.info(
f"执行工具: {self.name}, 参数: query={query}, force_refresh_market={force_refresh_market}"
)
try:
installed_plugins = list_installed_plugins()
if not installed_plugins:
@@ -77,6 +88,10 @@ class QueryInstalledPluginsTool(MoviePilotTool):
{"success": False, "message": "当前没有已安装的插件"},
ensure_ascii=False,
)
installed_plugins = await enrich_installed_plugin_sources(
installed_plugins,
force_refresh=force_refresh_market,
)
limit = self._clamp_results(max_results)
if query:

View File

@@ -77,9 +77,12 @@ def _build_tv_server_result(existing_seasons: OrderedDict, total_seasons: Ordere
class QueryLibraryExistsInput(BaseModel):
"""查询媒体库工具的输入参数模型"""
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
tmdb_id: Optional[int] = Field(None, description="TMDB ID (can be obtained from search_media tool). Either tmdb_id or douban_id must be provided.")
douban_id: Optional[str] = Field(None, description="Douban ID (can be obtained from search_media tool). Either tmdb_id or douban_id must be provided.")
tmdb_id: Optional[int] = Field(None, description="TMDB media ID")
douban_id: Optional[str] = Field(None, description="Douban media ID")
bangumi_id: Optional[int] = Field(None, description="Bangumi media ID")
anilist_id: Optional[int] = Field(None, description="AniList media ID")
media_source: Optional[str] = Field(None, description="Media metadata source")
media_id: Optional[str] = Field(None, description="Native ID for media_source")
media_type: Optional[str] = Field(None, description="Allowed values: movie, tv")
@@ -90,21 +93,24 @@ class QueryLibraryExistsTool(MoviePilotTool):
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."
description: str = "Check whether media already exists in Plex, Emby, or Jellyfin by a TMDB, Douban, Bangumi, AniList, or source-native media ID. Results are grouped by media server; TV results include existing episodes, total episodes, and missing episodes/seasons."
args_schema: Type[BaseModel] = QueryLibraryExistsInput
def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据查询参数生成友好的提示消息"""
tmdb_id = kwargs.get("tmdb_id")
douban_id = kwargs.get("douban_id")
media_type = kwargs.get("media_type")
if tmdb_id:
message = f"查询媒体库: TMDB={tmdb_id}"
elif douban_id:
message = f"查询媒体库: 豆瓣={douban_id}"
else:
message = "查询媒体库"
identities = (
("TMDB", kwargs.get("tmdb_id")),
("豆瓣", kwargs.get("douban_id")),
("Bangumi", kwargs.get("bangumi_id")),
("AniList", kwargs.get("anilist_id")),
(kwargs.get("media_source") or "媒体源", kwargs.get("media_id")),
)
label, identity = next(
((label, identity) for label, identity in identities if identity is not None),
(None, None),
)
message = f"查询媒体库: {label}={identity}" if label else "查询媒体库"
if media_type:
message += f" [{media_type}]"
return message
@@ -120,11 +126,13 @@ class QueryLibraryExistsTool(MoviePilotTool):
return MediaServerChain().media_exists(mediainfo=mediainfo, server=server)
async def run(self, tmdb_id: Optional[int] = None, douban_id: Optional[str] = None,
bangumi_id: Optional[int] = None, anilist_id: Optional[int] = None,
media_source: Optional[str] = None, media_id: Optional[str] = None,
media_type: Optional[str] = None, **kwargs) -> str:
logger.info(f"执行工具: {self.name}, 参数: tmdb_id={tmdb_id}, douban_id={douban_id}, media_type={media_type}")
try:
if not tmdb_id and not douban_id:
return "参数错误:tmdb_id 和 douban_id 至少需要提供一个,请先使用 search_media 工具获取媒体 ID"
if not any((tmdb_id, douban_id, bangumi_id, anilist_id, media_id)):
return "参数错误:至少需要提供一个媒体 ID,请先使用 search_media 工具获取媒体信息"
media_type_enum = None
if media_type:
@@ -136,11 +144,15 @@ class QueryLibraryExistsTool(MoviePilotTool):
mediainfo = await media_chain.async_recognize_media(
tmdbid=tmdb_id,
doubanid=douban_id,
bangumiid=bangumi_id,
anilistid=anilist_id,
source=media_source,
mediaid=media_id,
mtype=media_type_enum,
)
if not mediainfo:
media_id = f"TMDB={tmdb_id}" if tmdb_id else f"豆瓣={douban_id}"
return f"未识别到媒体信息: {media_id}"
identity = media_id or tmdb_id or douban_id or bangumi_id or anilist_id
return f"未识别到媒体信息: {identity}"
# 2. 遍历所有媒体服务器,分别查询存在性信息
server_results = OrderedDict()

View File

@@ -18,8 +18,6 @@ PAGE_SIZE = 20
class QueryLibraryLatestInput(BaseModel):
"""查询媒体服务器最近入库影片工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
server: Optional[str] = Field(
None,
description="Media server name (optional, if not specified queries all enabled media servers)",

View File

@@ -21,8 +21,6 @@ from app.log import logger
class QueryMarketPluginsInput(BaseModel):
"""查询插件市场工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
query: Optional[str] = Field(
None,
description="Optional keyword to filter plugin market results by plugin ID, name, description, or author.",

View File

@@ -18,9 +18,12 @@ SEASON_PREVIEW_LIMIT = 100
class QueryMediaDetailInput(BaseModel):
"""查询媒体详情工具的输入参数模型"""
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
tmdb_id: Optional[int] = Field(None, description="TMDB ID of the media (movie or TV series, can be obtained from search_media tool)")
douban_id: Optional[str] = Field(None, description="Douban ID of the media (alternative to tmdb_id)")
bangumi_id: Optional[int] = Field(None, description="Bangumi media ID")
anilist_id: Optional[int] = Field(None, description="AniList media ID")
media_source: Optional[str] = Field(None, description="Media metadata source")
media_id: Optional[str] = Field(None, description="Native ID for media_source")
media_type: str = Field(..., description="Allowed values: movie, tv")
@@ -30,24 +33,37 @@ class QueryMediaDetailTool(MoviePilotTool):
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."
description: str = "Query supplementary media details from a metadata source by ID and media_type. Accepts a TMDB, Douban, Bangumi, AniList, or source-native media ID. 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
def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据查询参数生成友好的提示消息"""
tmdb_id = kwargs.get("tmdb_id")
douban_id = kwargs.get("douban_id")
if tmdb_id:
return f"查询媒体详情: TMDB ID {tmdb_id}"
return f"查询媒体详情: 豆瓣 ID {douban_id}"
identities = (
("TMDB", kwargs.get("tmdb_id")),
("豆瓣", kwargs.get("douban_id")),
("Bangumi", kwargs.get("bangumi_id")),
("AniList", kwargs.get("anilist_id")),
)
for label, identity in identities:
if identity is not None:
return f"查询媒体详情: {label} ID {identity}"
return (
f"查询媒体详情: {kwargs.get('media_source') or '媒体源'} "
f"ID {kwargs.get('media_id')}"
)
async def run(self, media_type: str, tmdb_id: Optional[int] = None, douban_id: Optional[str] = None, **kwargs) -> str:
async def run(
self, media_type: str, tmdb_id: Optional[int] = None,
douban_id: Optional[str] = None, bangumi_id: Optional[int] = None,
anilist_id: Optional[int] = None, media_source: Optional[str] = None,
media_id: Optional[str] = None, **kwargs,
) -> str:
logger.info(f"执行工具: {self.name}, 参数: tmdb_id={tmdb_id}, douban_id={douban_id}, media_type={media_type}")
if tmdb_id is None and douban_id is None:
if not any((tmdb_id, douban_id, bangumi_id, anilist_id, media_id)):
return json.dumps({
"success": False,
"message": "必须提供 tmdb_id 或 douban_id 之一"
"message": "必须提供至少一个媒体 ID"
}, ensure_ascii=False)
try:
@@ -60,10 +76,22 @@ class QueryMediaDetailTool(MoviePilotTool):
"message": f"无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'"
}, ensure_ascii=False)
mediainfo = await media_chain.async_recognize_media(tmdbid=tmdb_id, doubanid=douban_id, mtype=media_type_enum)
mediainfo = await media_chain.async_recognize_media(
tmdbid=tmdb_id,
doubanid=douban_id,
bangumiid=bangumi_id,
anilistid=anilist_id,
source=media_source,
mediaid=media_id,
mtype=media_type_enum,
)
if not mediainfo:
id_info = f"TMDB ID {tmdb_id}" if tmdb_id else f"豆瓣 ID {douban_id}"
id_info = (
f"{media_source or '媒体源'} ID {media_id}"
if media_id else
f"媒体 ID {tmdb_id or douban_id or bangumi_id or anilist_id}"
)
return json.dumps({
"success": False,
"message": f"未找到 {id_info} 的媒体信息"
@@ -140,5 +168,9 @@ class QueryMediaDetailTool(MoviePilotTool):
"success": False,
"message": error_message,
"tmdb_id": tmdb_id,
"douban_id": douban_id
"douban_id": douban_id,
"bangumi_id": bangumi_id,
"anilist_id": anilist_id,
"media_source": media_source,
"media_id": media_id,
}, ensure_ascii=False)

View File

@@ -14,8 +14,6 @@ from app.log import logger
class QueryPersonasInput(BaseModel):
"""查询人格工具的输入参数模型。"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
query: Optional[str] = Field(
None,
description=(

View File

@@ -14,8 +14,6 @@ from app.log import logger
class QueryPluginCapabilitiesInput(BaseModel):
"""查询插件能力工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
plugin_id: Optional[str] = Field(
None,
description="Optional plugin ID to query capabilities for a specific plugin. "

View File

@@ -15,8 +15,6 @@ from app.log import logger
class QueryPluginConfigInput(BaseModel):
"""查询插件配置工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
plugin_id: str = Field(
...,
description="The plugin ID to query. Use query_installed_plugins first to discover valid plugin IDs.",

View File

@@ -19,8 +19,6 @@ from app.log import logger
class QueryPluginDataInput(BaseModel):
"""查询插件数据工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
plugin_id: str = Field(
...,
description="The plugin ID to query. Use query_installed_plugins first to discover valid plugin IDs.",

View File

@@ -18,7 +18,6 @@ MAX_PAGE_SIZE = 50
class QueryPopularSubscribesInput(BaseModel):
"""查询热门订阅工具的输入参数模型"""
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
media_type: str = Field(..., description="Allowed values: movie, tv")
page: Optional[int] = Field(1, description="Page number for pagination (default: 1)")
count: Optional[int] = Field(30, description="Number of items per page (default: 30, max: 50)")
@@ -119,7 +118,7 @@ class QueryPopularSubscribesTool(MoviePilotTool):
# 处理标题
title = sub.get("name")
season = sub.get("season")
if season and int(season) > 1 and media.tmdb_id:
if season not in (None, "") and int(season) != 1 and media.tmdb_id:
# 小写数据转大写
season_str = cn2an.an2cn(season, "low")
title = f"{title}{season_str}"
@@ -127,6 +126,8 @@ class QueryPopularSubscribesTool(MoviePilotTool):
media.year = sub.get("year")
media.douban_id = sub.get("doubanid")
media.bangumi_id = sub.get("bangumiid")
media.anilist_id = sub.get("anilistid")
media.source = sub.get("media_source")
media.tvdb_id = sub.get("tvdbid")
media.imdb_id = sub.get("imdbid")
media.season = sub.get("season")
@@ -150,6 +151,9 @@ class QueryPopularSubscribesTool(MoviePilotTool):
"tmdb_id": media_dict.get("tmdb_id"),
"douban_id": media_dict.get("douban_id"),
"bangumi_id": media_dict.get("bangumi_id"),
"anilist_id": media_dict.get("anilist_id"),
"media_source": media_dict.get("source"),
"media_id": media_dict.get("media_id"),
"tvdb_id": media_dict.get("tvdb_id"),
"imdb_id": media_dict.get("imdb_id"),
"season": media_dict.get("season"),

View File

@@ -19,8 +19,6 @@ from app.log import logger
class QueryRuleGroupsInput(BaseModel):
"""查询规则组工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
group_names: Optional[List[str]] = Field(
None,
description="Optional list of rule group names to query. If omitted, return all rule groups.",

View File

@@ -3,7 +3,7 @@
import json
from typing import Optional, Type
from pydantic import BaseModel, Field
from pydantic import BaseModel
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
@@ -11,50 +11,69 @@ from app.log import logger
class QuerySchedulersInput(BaseModel):
"""查询定时服务工具的输入参数模型"""
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
"""查询运行时定时服务的输入参数模型"""
class QuerySchedulersTool(MoviePilotTool):
"""查询系统、插件和工作流注册的运行时定时服务。"""
name: str = "query_schedulers"
tags: list[str] = [
ToolTag.Read,
ToolTag.Scheduler,
ToolTag.Admin,
]
description: str = "Query scheduled tasks and list all available scheduler jobs. Shows job status, next run time, and provider information."
description: str = (
"Query runtime scheduler services registered by MoviePilot system components, "
"plugins, and workflows. It excludes user-created autonomous agent tasks; use "
"query_agent_tasks for reminders, monitoring tasks, and other agent schedules."
)
args_schema: Type[BaseModel] = QuerySchedulersInput
require_admin: bool = True
def get_tool_message(self, **kwargs) -> Optional[str]:
"""生成友好的提示消息"""
return "查询定时服务"
def get_tool_message(self, **kwargs: object) -> Optional[str]:
"""生成查询运行时定时服务的提示消息"""
return "查询系统定时服务"
async def run(self, **kwargs) -> str:
async def run(self, **kwargs: object) -> str:
"""查询非 Agent 自主任务的运行时定时服务。"""
logger.info(f"执行工具: {self.name}")
try:
from app.scheduler import Scheduler
from app.scheduler import AGENT_TASK_JOB_PREFIX, Scheduler
scheduler = Scheduler()
schedulers = scheduler.list()
agent_task_prefix = f"{AGENT_TASK_JOB_PREFIX}-"
schedulers = [
scheduler_item
for scheduler_item in scheduler.list()
if not str(scheduler_item.id or "").startswith(agent_task_prefix)
]
if schedulers:
# 转换为字典列表以便JSON序列化
schedulers_list = []
for s in schedulers:
schedulers_list.append({
"id": s.id,
"name": s.name,
"provider": s.provider,
"status": s.status,
"next_run": s.next_run
})
schedulers_list = [
{
"id": scheduler_item.id,
"name": scheduler_item.name,
"provider": scheduler_item.provider,
"status": scheduler_item.status,
"next_run": scheduler_item.next_run,
}
for scheduler_item in schedulers
]
result_json = json.dumps(schedulers_list, ensure_ascii=False, indent=2)
# 限制最多30条结果
total_count = len(schedulers_list)
if total_count > 30:
limited_schedulers = schedulers_list[:30]
limited_json = json.dumps(limited_schedulers, ensure_ascii=False, indent=2)
return f"注意:查询结果共找到 {total_count} 条,为节省上下文空间,仅显示前 30 条结果。\n\n{limited_json}"
limited_json = json.dumps(
limited_schedulers,
ensure_ascii=False,
indent=2,
)
return (
f"注意:查询结果共找到 {total_count} 条,为节省上下文空间,"
f"仅显示前 30 条结果。\n\n{limited_json}"
)
return result_json
return "未找到定时服务"
return "未找到系统、插件或工作流定时服务"
except Exception as e:
logger.error(f"查询定时服务失败: {e}", exc_info=True)
return f"查询定时服务时发生错误: {str(e)}"

View File

@@ -7,9 +7,7 @@ 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
from app.db.site_oper import SiteOper
from app.log import logger
SITE_USERDATA_DETAIL_PREVIEW_LIMIT = 10
@@ -24,8 +22,6 @@ def _preview_list(value, limit: int = SITE_USERDATA_DETAIL_PREVIEW_LIMIT) -> tup
class QuerySiteUserdataInput(BaseModel):
"""查询站点用户数据工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
site_id: int = Field(
...,
description="The ID of the site to query user data for (can be obtained from query_sites tool)",
@@ -66,118 +62,115 @@ class QuerySiteUserdataTool(MoviePilotTool):
)
try:
# 获取数据库会话
async with AsyncSessionFactory() as db:
# 获取站点
site = await Site.async_get(db, site_id)
if not site:
return json.dumps(
{"success": False, "message": f"站点不存在: {site_id}"},
ensure_ascii=False,
)
# 获取站点用户数据
user_data_list = await SiteUserData.async_get_by_domain(
db, domain=site.domain, workdate=workdate
site_oper = SiteOper()
site = await site_oper.async_get(site_id)
if not site:
return json.dumps(
{"success": False, "message": f"站点不存在: {site_id}"},
ensure_ascii=False,
)
if not user_data_list:
return json.dumps(
{
"success": False,
"message": f"站点 {site.name} ({site.domain}) 暂无用户数据",
"site_id": site_id,
"site_name": site.name,
"site_domain": site.domain,
"workdate": workdate,
},
ensure_ascii=False,
)
user_data_list = await site_oper.async_get_userdata_by_domain(
domain=site.domain, workdate=workdate
)
# 格式化用户数据
result = {
"success": True,
"site_id": site_id,
"site_name": site.name,
"site_domain": site.domain,
"workdate": workdate,
"data_count": len(user_data_list),
"user_data": [],
if not user_data_list:
return json.dumps(
{
"success": False,
"message": f"站点 {site.name} ({site.domain}) 暂无用户数据",
"site_id": site_id,
"site_name": site.name,
"site_domain": site.domain,
"workdate": workdate,
},
ensure_ascii=False,
)
# 格式化用户数据
result = {
"success": True,
"site_id": site_id,
"site_name": site.name,
"site_domain": site.domain,
"workdate": workdate,
"data_count": len(user_data_list),
"user_data": [],
}
for user_data in user_data_list:
# 格式化上传/下载量(转换为可读格式)
upload_gb = user_data.upload / (1024**3) if user_data.upload else 0
download_gb = (
user_data.download / (1024**3) if user_data.download else 0
)
seeding_size_gb = (
user_data.seeding_size / (1024**3)
if user_data.seeding_size
else 0
)
leeching_size_gb = (
user_data.leeching_size / (1024**3)
if user_data.leeching_size
else 0
)
seeding_preview, seeding_count, seeding_truncated = _preview_list(
user_data.seeding_info
)
unread_preview, unread_count, unread_truncated = _preview_list(
user_data.message_unread_contents
)
user_data_dict = {
"domain": user_data.domain,
"name": user_data.name,
"username": user_data.username,
"userid": user_data.userid,
"user_level": user_data.user_level,
"join_at": user_data.join_at,
"bonus": user_data.bonus,
"upload": user_data.upload,
"upload_gb": round(upload_gb, 2),
"download": user_data.download,
"download_gb": round(download_gb, 2),
"ratio": round(user_data.ratio, 2) if user_data.ratio else 0,
"seeding": int(user_data.seeding) if user_data.seeding else 0,
"leeching": int(user_data.leeching)
if user_data.leeching
else 0,
"seeding_size": user_data.seeding_size,
"seeding_size_gb": round(seeding_size_gb, 2),
"leeching_size": user_data.leeching_size,
"leeching_size_gb": round(leeching_size_gb, 2),
"seeding_info_count": seeding_count,
"seeding_info": seeding_preview,
"seeding_info_truncated": seeding_truncated,
"message_unread": user_data.message_unread,
"message_unread_contents_count": unread_count,
"message_unread_contents": unread_preview,
"message_unread_contents_truncated": unread_truncated,
"err_msg": user_data.err_msg,
"updated_day": user_data.updated_day,
"updated_time": user_data.updated_time,
}
result["user_data"].append(user_data_dict)
for user_data in user_data_list:
# 格式化上传/下载量(转换为可读格式)
upload_gb = user_data.upload / (1024**3) if user_data.upload else 0
download_gb = (
user_data.download / (1024**3) if user_data.download else 0
)
seeding_size_gb = (
user_data.seeding_size / (1024**3)
if user_data.seeding_size
else 0
)
leeching_size_gb = (
user_data.leeching_size / (1024**3)
if user_data.leeching_size
else 0
)
# 如果有多条数据,只返回最新的(按更新时间排序)
if len(result["user_data"]) > 1:
result["user_data"].sort(
key=lambda x: (
x.get("updated_day", ""),
x.get("updated_time", ""),
),
reverse=True,
)
result["message"] = (
f"找到 {len(result['user_data'])} 条数据,显示最新的一条"
)
result["user_data"] = [result["user_data"][0]]
seeding_preview, seeding_count, seeding_truncated = _preview_list(
user_data.seeding_info
)
unread_preview, unread_count, unread_truncated = _preview_list(
user_data.message_unread_contents
)
user_data_dict = {
"domain": user_data.domain,
"name": user_data.name,
"username": user_data.username,
"userid": user_data.userid,
"user_level": user_data.user_level,
"join_at": user_data.join_at,
"bonus": user_data.bonus,
"upload": user_data.upload,
"upload_gb": round(upload_gb, 2),
"download": user_data.download,
"download_gb": round(download_gb, 2),
"ratio": round(user_data.ratio, 2) if user_data.ratio else 0,
"seeding": int(user_data.seeding) if user_data.seeding else 0,
"leeching": int(user_data.leeching)
if user_data.leeching
else 0,
"seeding_size": user_data.seeding_size,
"seeding_size_gb": round(seeding_size_gb, 2),
"leeching_size": user_data.leeching_size,
"leeching_size_gb": round(leeching_size_gb, 2),
"seeding_info_count": seeding_count,
"seeding_info": seeding_preview,
"seeding_info_truncated": seeding_truncated,
"message_unread": user_data.message_unread,
"message_unread_contents_count": unread_count,
"message_unread_contents": unread_preview,
"message_unread_contents_truncated": unread_truncated,
"err_msg": user_data.err_msg,
"updated_day": user_data.updated_day,
"updated_time": user_data.updated_time,
}
result["user_data"].append(user_data_dict)
# 如果有多条数据,只返回最新的(按更新时间排序)
if len(result["user_data"]) > 1:
result["user_data"].sort(
key=lambda x: (
x.get("updated_day", ""),
x.get("updated_time", ""),
),
reverse=True,
)
result["message"] = (
f"找到 {len(result['user_data'])} 条数据,显示最新的一条"
)
result["user_data"] = [result["user_data"][0]]
return json.dumps(result, ensure_ascii=False, indent=2)
return json.dumps(result, ensure_ascii=False, indent=2)
except Exception as e:
error_message = f"查询站点用户数据失败: {str(e)}"

View File

@@ -14,8 +14,6 @@ from app.log import logger
class QuerySitesInput(BaseModel):
"""查询站点工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
status: Optional[str] = Field(
"all",
description="Filter sites by status: 'active' for enabled sites, 'inactive' for disabled sites, 'all' for all sites",

View File

@@ -7,8 +7,7 @@ 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.db.subscribehistory_oper import SubscribeHistoryOper
from app.log import logger
from app.schemas.types import media_type_to_agent
@@ -18,8 +17,6 @@ PAGE_SIZE = 20
class QuerySubscribeHistoryInput(BaseModel):
"""查询订阅历史工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
media_type: Optional[str] = Field(
"all", description="Allowed values: movie, tv, all"
)
@@ -74,88 +71,87 @@ class QuerySubscribeHistoryTool(MoviePilotTool):
if media_type not in ["all", "movie", "tv"]:
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv', 'all'"
# 获取数据库会话
async with AsyncSessionFactory() as db:
if name:
# 有名称过滤时,获取足够多的记录在内存中过滤,不分页
fetch_count = 500
if media_type == "all":
movie_history = await SubscribeHistory.async_list_by_type(
db, mtype="movie", page=1, count=fetch_count
)
tv_history = await SubscribeHistory.async_list_by_type(
db, mtype="tv", page=1, count=fetch_count
)
all_history = list(movie_history) + list(tv_history)
all_history.sort(key=lambda x: x.date or "", reverse=True)
else:
all_history = list(
await SubscribeHistory.async_list_by_type(
db, mtype=media_type, page=1, count=fetch_count
)
)
# 按名称过滤
name_lower = name.lower()
filtered_history = [
record
for record in all_history
if record.name and name_lower in record.name.lower()
]
if not filtered_history:
return "未找到相关订阅历史记录"
# 名称过滤时直接返回所有匹配结果,不分页
simplified_records = self._simplify_records(filtered_history)
result_json = json.dumps(
simplified_records, ensure_ascii=False, indent=2
subscribe_history_oper = SubscribeHistoryOper()
if name:
# 有名称过滤时,获取足够多的记录在内存中过滤,不分页
fetch_count = 500
if media_type == "all":
movie_history = await subscribe_history_oper.async_list_by_type(
mtype="movie", page=1, count=fetch_count
)
return result_json
tv_history = await subscribe_history_oper.async_list_by_type(
mtype="tv", page=1, count=fetch_count
)
all_history = list(movie_history) + list(tv_history)
all_history.sort(key=lambda x: x.date or "", reverse=True)
else:
# 无名称过滤时,直接利用数据库分页
if media_type == "all":
movie_history = await SubscribeHistory.async_list_by_type(
db, mtype="movie", page=1, count=page * PAGE_SIZE
)
tv_history = await SubscribeHistory.async_list_by_type(
db, mtype="tv", page=1, count=page * PAGE_SIZE
)
all_history = list(movie_history) + list(tv_history)
all_history.sort(key=lambda x: x.date or "", reverse=True)
filtered_history = all_history
else:
filtered_history = list(
await SubscribeHistory.async_list_by_type(
db, mtype=media_type, page=1, count=page * PAGE_SIZE
)
all_history = list(
await subscribe_history_oper.async_list_by_type(
mtype=media_type, page=1, count=fetch_count
)
)
# 按名称过滤
name_lower = name.lower()
filtered_history = [
record
for record in all_history
if record.name and name_lower in record.name.lower()
]
if not filtered_history:
return "未找到相关订阅历史记录"
# 分页切片
total_count = len(filtered_history)
start = (page - 1) * PAGE_SIZE
end = start + PAGE_SIZE
page_records = filtered_history[start:end]
if not page_records:
return f"{page} 页没有数据。"
simplified_records = self._simplify_records(page_records)
# 名称过滤时直接返回所有匹配结果,不分页
simplified_records = self._simplify_records(filtered_history)
result_json = json.dumps(
simplified_records, ensure_ascii=False, indent=2
)
has_more = total_count > end
payload_msg = f"{page} 页,当前页 {len(simplified_records)} 条结果。"
if has_more:
payload_msg += (
f" 可能有更多数据,可使用 page={page + 1} 获取下一页。"
return result_json
else:
# 无名称过滤时,直接利用数据库分页
if media_type == "all":
movie_history = await subscribe_history_oper.async_list_by_type(
mtype="movie", page=1, count=page * PAGE_SIZE
)
tv_history = await subscribe_history_oper.async_list_by_type(
mtype="tv", page=1, count=page * PAGE_SIZE
)
all_history = list(movie_history) + list(tv_history)
all_history.sort(key=lambda x: x.date or "", reverse=True)
filtered_history = all_history
else:
filtered_history = list(
await subscribe_history_oper.async_list_by_type(
mtype=media_type, page=1, count=page * PAGE_SIZE
)
)
return f"{payload_msg}\n\n{result_json}"
if not filtered_history:
return "未找到相关订阅历史记录"
# 分页切片
total_count = len(filtered_history)
start = (page - 1) * PAGE_SIZE
end = start + PAGE_SIZE
page_records = filtered_history[start:end]
if not page_records:
return f"{page} 页没有数据。"
simplified_records = self._simplify_records(page_records)
result_json = json.dumps(
simplified_records, ensure_ascii=False, indent=2
)
has_more = total_count > end
payload_msg = f"{page} 页,当前页 {len(simplified_records)} 条结果。"
if has_more:
payload_msg += (
f" 可能有更多数据,可使用 page={page + 1} 获取下一页。"
)
return f"{payload_msg}\n\n{result_json}"
except Exception as e:
logger.error(f"查询订阅历史失败: {e}", exc_info=True)
return f"查询订阅历史时发生错误: {str(e)}"
@@ -174,6 +170,9 @@ class QuerySubscribeHistoryTool(MoviePilotTool):
"tmdbid": record.tmdbid,
"doubanid": record.doubanid,
"bangumiid": record.bangumiid,
"anilistid": record.anilistid,
"media_source": record.media_source,
"media_id": record.media_id,
"poster": record.poster,
"vote": record.vote,
"total_episode": record.total_episode,

View File

@@ -15,7 +15,6 @@ MAX_PAGE_SIZE = 50
class QuerySubscribeSharesInput(BaseModel):
"""查询订阅分享工具的输入参数模型"""
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
name: Optional[str] = Field(None, description="Filter shares by media name (partial match, optional)")
page: Optional[int] = Field(1, description="Page number for pagination (default: 1)")
count: Optional[int] = Field(30, description="Number of items per page (default: 30, max: 50)")
@@ -98,6 +97,9 @@ class QuerySubscribeSharesTool(MoviePilotTool):
"tmdbid": share.get("tmdbid"),
"doubanid": share.get("doubanid"),
"bangumiid": share.get("bangumiid"),
"anilistid": share.get("anilistid"),
"media_source": share.get("media_source"),
"media_id": share.get("media_id"),
"poster": share.get("poster"),
"vote": share.get("vote"),
"share_title": share.get("share_title"),

View File

@@ -48,8 +48,6 @@ QUERY_SUBSCRIBE_OUTPUT_FIELDS = [
class QuerySubscribesInput(BaseModel):
"""查询订阅工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
status: Optional[str] = Field(
"all",
description="Filter subscriptions by status: 'R' for enabled subscriptions, 'S' for paused ones, 'all' for all subscriptions",
@@ -65,6 +63,10 @@ class QuerySubscribesInput(BaseModel):
None,
description="Filter by Douban ID to check if a specific media is already subscribed",
)
bangumi_id: Optional[int] = Field(None, description="Filter by Bangumi ID")
anilist_id: Optional[int] = Field(None, description="Filter by AniList ID")
media_source: Optional[str] = Field(None, description="Filter by media source")
media_id: Optional[str] = Field(None, description="Filter by source-native media ID")
page: Optional[int] = Field(
1, description="Page number for pagination (default: 1, 100 items per page)"
)
@@ -106,6 +108,10 @@ class QuerySubscribesTool(MoviePilotTool):
media_type: Optional[str] = "all",
tmdb_id: Optional[int] = None,
douban_id: Optional[str] = None,
bangumi_id: Optional[int] = None,
anilist_id: Optional[int] = None,
media_source: Optional[str] = None,
media_id: Optional[str] = None,
page: Optional[int] = 1,
**kwargs,
) -> str:
@@ -132,6 +138,14 @@ class QuerySubscribesTool(MoviePilotTool):
continue
if douban_id is not None and sub.doubanid != douban_id:
continue
if bangumi_id is not None and sub.bangumiid != bangumi_id:
continue
if anilist_id is not None and sub.anilistid != anilist_id:
continue
if media_source is not None and sub.media_source != media_source:
continue
if media_id is not None and sub.media_id != media_id:
continue
filtered_subscribes.append(sub)
if filtered_subscribes:
total_count = len(filtered_subscribes)

View File

@@ -9,8 +9,11 @@ from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.agent.tools.impl._system_setting_utils import (
SettingSpec,
is_secret_setting_key,
list_setting_specs,
redact_secret_value,
resolve_setting_spec,
should_redact_setting,
)
from app.core.config import settings
from app.db.systemconfig_oper import SystemConfigOper
@@ -20,8 +23,6 @@ from app.log import logger
class QuerySystemSettingsInput(BaseModel):
"""查询系统设置工具的输入参数模型。"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
setting_key: Optional[str] = Field(
None,
description=(
@@ -53,6 +54,13 @@ class QuerySystemSettingsInput(BaseModel):
"when multiple settings are matched it returns summaries only unless this is explicitly set to true."
),
)
show_secrets: Optional[bool] = Field(
False,
description=(
"Whether to return raw secret values such as API keys, tokens, cookies, and passwords. "
"Defaults to false; secret-like fields are redacted in returned values and previews."
),
)
class QuerySystemSettingsTool(MoviePilotTool):
@@ -85,15 +93,18 @@ class QuerySystemSettingsTool(MoviePilotTool):
@staticmethod
def _load_setting_value(spec: SettingSpec):
"""读取指定设置项的当前值。"""
if spec.source == "settings":
return getattr(settings, spec.key)
return SystemConfigOper().get(spec.key)
return SystemConfigOper().get(spec.systemconfig_key)
@staticmethod
def _summarize_value(value) -> dict:
def _summarize_value(value, *, redacted: bool = False) -> dict:
"""生成设置值摘要,避免列表和字典默认输出过长。"""
summary = {
"has_value": value is not None,
"value_type": type(value).__name__,
"redacted": redacted,
}
if isinstance(value, list):
summary["item_count"] = len(value)
@@ -122,14 +133,12 @@ class QuerySystemSettingsTool(MoviePilotTool):
group: Optional[str] = "all",
keyword: Optional[str] = None,
include_values: Optional[bool] = None,
show_secrets: Optional[bool] = False,
**kwargs,
) -> str:
logger.info(
"执行工具: %s, setting_key=%s, group=%s, keyword=%s",
self.name,
setting_key,
group,
keyword,
f"执行工具: {self.name}, setting_key={setting_key}, "
f"group={group}, keyword={keyword}"
)
try:
@@ -158,18 +167,30 @@ class QuerySystemSettingsTool(MoviePilotTool):
should_include_values = (
include_values if include_values is not None else len(specs) == 1
)
allow_secret_values = bool(show_secrets) and await self.is_admin_user()
settings_payload = []
for spec in specs:
value = self._load_setting_value(spec)
should_redact = (
should_redact_setting(spec, value) and not allow_secret_values
)
response_value = (
redact_secret_value(
value,
redact_scalar=is_secret_setting_key(spec.key),
)
if should_redact
else value
)
item = {
"setting_key": spec.key,
"source": spec.source,
"group": spec.group,
"label": spec.label,
}
item.update(self._summarize_value(value))
item.update(self._summarize_value(response_value, redacted=should_redact))
if should_include_values:
item["value"] = value
item["value"] = response_value
settings_payload.append(item)
return json.dumps(
@@ -177,6 +198,7 @@ class QuerySystemSettingsTool(MoviePilotTool):
"success": True,
"matched_count": len(settings_payload),
"include_values": should_include_values,
"show_secrets": allow_secret_values,
"settings": settings_payload,
},
ensure_ascii=False,

View File

@@ -7,8 +7,7 @@ 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.db.transferhistory_oper import TransferHistoryOper
from app.log import logger
from app.schemas.types import media_type_to_agent
from app.utils.jieba import cut as jieba_cut
@@ -16,7 +15,6 @@ from app.utils.jieba import cut as jieba_cut
class QueryTransferHistoryInput(BaseModel):
"""查询整理历史记录工具的输入参数模型"""
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
title: Optional[str] = Field(None, description="Search by title (optional, supports partial match)")
status: Optional[str] = Field("all",
description="Filter by status: 'success' for successful transfers, 'failed' for failed transfers, 'all' for all records (default: 'all')")
@@ -70,70 +68,77 @@ class QueryTransferHistoryTool(MoviePilotTool):
# 每页固定 30 条,与工具说明保持一致,避免整理路径等字段撑大上下文。
count = 30
# 获取数据库会话
async with AsyncSessionFactory() as db:
# 处理标题搜索
if title:
# 使用统一分词封装处理标题,便于替换底层实现。
words = jieba_cut(title, HMM=False)
title_search = "%".join(words)
# 查询记录
result = await TransferHistory.async_list_by_title(
db, title=title_search, page=page, count=count, status=status_bool
)
total = await TransferHistory.async_count_by_title(
db, title=title_search, status=status_bool
)
else:
# 查询所有记录
result = await TransferHistory.async_list_by_page(
db, page=page, count=count, status=status_bool
)
total = await TransferHistory.async_count(db, status=status_bool)
transferhis = TransferHistoryOper()
# 处理标题搜索
if title:
# 使用统一分词封装处理标题,便于替换底层实现。
words = jieba_cut(title, HMM=False)
title_search = "%".join(words)
# 查询记录
result = await transferhis.async_list_by_title(
title=title_search, page=page, count=count, status=status_bool
)
total = await transferhis.async_count_by_title(
title=title_search, status=status_bool
)
else:
# 查询所有记录
result = await transferhis.async_list_by_page(
page=page, count=count, status=status_bool
)
total = await transferhis.async_count(status=status_bool)
if not result:
return "未找到相关整理历史记录"
if not result:
return "未找到相关整理历史记录"
# 转换为字典格式,只保留关键信息
simplified_records = []
for record in result:
simplified = {
"id": record.id,
"title": record.title,
"year": record.year,
"type": media_type_to_agent(record.type),
"category": record.category,
"seasons": record.seasons,
"episodes": record.episodes,
"src": record.src,
"dest": record.dest,
"mode": record.mode,
"status": "成功" if record.status else "失败",
"date": record.date,
"downloader": record.downloader,
"download_hash": record.download_hash
}
# 如果失败,添加错误信息
if not record.status and record.errmsg:
simplified["errmsg"] = record.errmsg
# 添加媒体ID信息如果有
if record.tmdbid:
simplified["tmdbid"] = record.tmdbid
if record.imdbid:
simplified["imdbid"] = record.imdbid
if record.doubanid:
simplified["doubanid"] = record.doubanid
simplified_records.append(simplified)
# 转换为字典格式,只保留关键信息
simplified_records = []
for record in result:
simplified = {
"id": record.id,
"title": record.title,
"year": record.year,
"type": media_type_to_agent(record.type),
"category": record.category,
"seasons": record.seasons,
"episodes": record.episodes,
"src": record.src,
"dest": record.dest,
"mode": record.mode,
"status": "成功" if record.status else "失败",
"date": record.date,
"downloader": record.downloader,
"download_hash": record.download_hash
}
# 如果失败,添加错误信息
if not record.status and record.errmsg:
simplified["errmsg"] = record.errmsg
# 添加媒体ID信息如果有
if record.tmdbid:
simplified["tmdbid"] = record.tmdbid
if record.imdbid:
simplified["imdbid"] = record.imdbid
if record.doubanid:
simplified["doubanid"] = record.doubanid
if record.bangumiid:
simplified["bangumiid"] = record.bangumiid
if record.anilistid:
simplified["anilistid"] = record.anilistid
if record.media_source:
simplified["media_source"] = record.media_source
if record.media_id:
simplified["media_id"] = record.media_id
simplified_records.append(simplified)
result_json = json.dumps(simplified_records, ensure_ascii=False, indent=2)
result_json = json.dumps(simplified_records, ensure_ascii=False, indent=2)
# 计算总页数
total_pages = (total + count - 1) // count if total > 0 else 1
# 计算总页数
total_pages = (total + count - 1) // count if total > 0 else 1
# 构建分页信息
pagination_info = f"{page}/{total_pages} 页,共 {total} 条记录(每页 {count} 条)"
# 构建分页信息
pagination_info = f"{page}/{total_pages} 页,共 {total} 条记录(每页 {count} 条)"
return f"{pagination_info}\n\n{result_json}"
return f"{pagination_info}\n\n{result_json}"
except Exception as e:
logger.error(f"查询整理历史记录失败: {e}", exc_info=True)
return f"查询整理历史记录时发生错误: {str(e)}"

View File

@@ -7,14 +7,12 @@ 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
class QueryWorkflowsInput(BaseModel):
"""查询工作流工具的输入参数模型"""
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
state: Optional[str] = Field("all", description="Filter workflows by state: 'W' for waiting, 'R' for running, 'P' for paused, 'S' for success, 'F' for failed, 'all' for all workflows (default: 'all')")
name: Optional[str] = Field(None, description="Filter workflows by name (partial match, optional)")
trigger_type: Optional[str] = Field("all", description="Filter workflows by trigger type: 'timer' for scheduled, 'event' for event-triggered, 'manual' for manual, 'all' for all types (default: 'all')")
@@ -56,75 +54,73 @@ class QueryWorkflowsTool(MoviePilotTool):
logger.info(f"执行工具: {self.name}, 参数: state={state}, name={name}, trigger_type={trigger_type}")
try:
# 获取数据库会话
async with AsyncSessionFactory() as db:
workflow_oper = WorkflowOper(db)
workflows = await workflow_oper.async_list()
# 过滤工作流
filtered_workflows = []
for wf in workflows:
# 按状态过滤
if state != "all" and wf.state != state:
workflow_oper = WorkflowOper()
workflows = await workflow_oper.async_list()
# 过滤工作流
filtered_workflows = []
for wf in workflows:
# 按状态过滤
if state != "all" and wf.state != state:
continue
# 按触发类型过滤
if trigger_type != "all":
if trigger_type == "timer" and wf.trigger_type not in ["timer", None]:
continue
# 按触发类型过滤
if trigger_type != "all":
if trigger_type == "timer" and wf.trigger_type not in ["timer", None]:
continue
elif trigger_type == "event" and wf.trigger_type != "event":
continue
elif trigger_type == "manual" and wf.trigger_type != "manual":
continue
# 按名称过滤(部分匹配)
if name and wf.name and name.lower() not in wf.name.lower():
elif trigger_type == "event" and wf.trigger_type != "event":
continue
filtered_workflows.append(wf)
if not filtered_workflows:
return "未找到相关工作流"
# 转换为字典格式,只保留关键信息
simplified_workflows = []
for wf in filtered_workflows:
# 状态说明
state_map = {
"W": "等待",
"R": "运行中",
"P": "暂停",
"S": "成功",
"F": "失败"
}
state_desc = state_map.get(wf.state, wf.state)
# 触发类型说明
trigger_type_map = {
"timer": "定时触发",
"event": "事件触发",
"manual": "手动触发"
}
trigger_type_desc = trigger_type_map.get(wf.trigger_type, wf.trigger_type or "定时触发")
simplified = {
"id": wf.id,
"name": wf.name,
"description": wf.description,
"trigger_type": trigger_type_desc,
"state": state_desc,
"run_count": wf.run_count,
"timer": wf.timer,
"event_type": wf.event_type,
"add_time": wf.add_time,
"last_time": wf.last_time,
"current_action": wf.current_action
}
# wf.result 往往是执行日志或上下文快照,不适合作为列表查询结果返回。
simplified_workflows.append(simplified)
result_json = json.dumps(simplified_workflows, ensure_ascii=False, indent=2)
return result_json
elif trigger_type == "manual" and wf.trigger_type != "manual":
continue
# 按名称过滤(部分匹配)
if name and wf.name and name.lower() not in wf.name.lower():
continue
filtered_workflows.append(wf)
if not filtered_workflows:
return "未找到相关工作流"
# 转换为字典格式,只保留关键信息
simplified_workflows = []
for wf in filtered_workflows:
# 状态说明
state_map = {
"W": "等待",
"R": "运行中",
"P": "暂停",
"S": "成功",
"F": "失败"
}
state_desc = state_map.get(wf.state, wf.state)
# 触发类型说明
trigger_type_map = {
"timer": "定时触发",
"event": "事件触发",
"manual": "手动触发"
}
trigger_type_desc = trigger_type_map.get(wf.trigger_type, wf.trigger_type or "定时触发")
simplified = {
"id": wf.id,
"name": wf.name,
"description": wf.description,
"trigger_type": trigger_type_desc,
"state": state_desc,
"run_count": wf.run_count,
"timer": wf.timer,
"event_type": wf.event_type,
"add_time": wf.add_time,
"last_time": wf.last_time,
"current_action": wf.current_action
}
# wf.result 往往是执行日志或上下文快照,不适合作为列表查询结果返回。
simplified_workflows.append(simplified)
result_json = json.dumps(simplified_workflows, ensure_ascii=False, indent=2)
return result_json
except Exception as e:
logger.error(f"查询工作流失败: {e}", exc_info=True)
return f"查询工作流时发生错误: {str(e)}"

View File

@@ -1,5 +1,7 @@
"""文件读取工具"""
import hashlib
import json
from pathlib import Path
from typing import Optional, Type
@@ -15,13 +17,23 @@ MAX_READ_SIZE = 50 * 1024
class ReadFileInput(BaseModel):
"""Input parameters for read file tool"""
"""文件读取工具的输入参数模型。"""
file_path: str = Field(..., description="The absolute path of the file to read")
start_line: Optional[int] = Field(None, description="The starting line number (1-based, inclusive). If not provided, reading starts from the beginning of the file.")
end_line: Optional[int] = Field(None, description="The ending line number (1-based, inclusive). If not provided, reading goes until the end of the file.")
include_metadata: bool = Field(
False,
description=(
"Return structured JSON containing content, size, truncation state, "
"and SHA-256. Use before a guarded full-file overwrite."
),
)
class ReadFileTool(MoviePilotTool):
"""按行范围读取本地文本文件,并可返回文件版本元数据。"""
name: str = "read_file"
tags: list[str] = [
ToolTag.Read,
@@ -36,8 +48,15 @@ class ReadFileTool(MoviePilotTool):
file_name = Path(file_path).name if file_path else "未知文件"
return f"读取文件: {file_name}"
async def run(self, file_path: str, start_line: Optional[int] = None,
end_line: Optional[int] = None, **kwargs) -> str:
async def run(
self,
file_path: str,
start_line: Optional[int] = None,
end_line: Optional[int] = None,
include_metadata: bool = False,
**kwargs,
) -> str:
"""读取指定文本范围,必要时附带完整文件的 SHA-256 元数据。"""
logger.info(f"执行工具: {self.name}, 参数: file_path={file_path}, start_line={start_line}, end_line={end_line}")
try:
@@ -55,7 +74,8 @@ class ReadFileTool(MoviePilotTool):
if not await path.is_file():
return f"错误:{resolved_path} 不是一个文件"
content = await path.read_text(encoding="utf-8")
raw_content = await path.read_bytes()
content = raw_content.decode("utf-8", errors="replace")
truncated = False
if start_line is not None or end_line is not None:
@@ -75,9 +95,24 @@ class ReadFileTool(MoviePilotTool):
# 检查大小限制
content_bytes = content.encode("utf-8")
if len(content_bytes) > MAX_READ_SIZE:
content = content_bytes[:MAX_READ_SIZE].decode("utf-8", errors="ignore")
content = content_bytes[:MAX_READ_SIZE].decode("utf-8", errors="replace")
truncated = True
if include_metadata:
return json.dumps(
{
"file_path": str(resolved_path),
"sha256": hashlib.sha256(raw_content).hexdigest(),
"size_bytes": len(raw_content),
"start_line": start_line,
"end_line": end_line,
"truncated": truncated,
"content": content,
},
ensure_ascii=False,
indent=2,
)
if truncated:
return f"{content}\n\n[警告文件内容已超过50KB限制以上内容已被截断。请使用 start_line/end_line 参数分段读取。]"

View File

@@ -15,10 +15,6 @@ 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=(
@@ -52,6 +48,7 @@ class RecognizeCaptchaTool(MoviePilotTool):
tags: list[str] = [
ToolTag.Read,
ToolTag.Web,
ToolTag.Site,
]
description: str = (
"Recognize a graphic captcha image and return the captcha text. "
@@ -70,6 +67,21 @@ class RecognizeCaptchaTool(MoviePilotTool):
return "识别图形验证码: data image"
return f"识别图形验证码: {image_url}"
@staticmethod
def _format_image_url_for_log(image_url: str) -> str:
"""生成验证码图片地址的安全日志摘要,避免 data URL 图片刷屏。"""
clean_url = (image_url or "").strip()
if not clean_url:
return ""
if clean_url.lower().startswith("data:image/"):
metadata, separator, data = clean_url.partition(",")
if separator:
return f"{metadata},<base64:{len(data)} chars>"
return f"data:image,<invalid:{len(clean_url)} chars>"
if len(clean_url) > 300:
return f"{clean_url[:300]}...(已截断,总长度: {len(clean_url)})"
return clean_url
@staticmethod
def _recognize_captcha_sync(
image_url: str,
@@ -117,7 +129,10 @@ class RecognizeCaptchaTool(MoviePilotTool):
:param allow_private_network: 是否允许访问本机或私网地址
:return: JSON 格式的识别结果
"""
logger.info(f"执行工具: {self.name}, 参数: image_url={image_url}")
logger.info(
f"执行工具: {self.name}, "
f"参数: image_url={self._format_image_url_for_log(image_url)}"
)
try:
captcha_text = await self.run_blocking(

View File

@@ -16,7 +16,6 @@ from app.schemas.types import media_type_to_agent
class RecognizeMediaInput(BaseModel):
"""识别媒体信息工具的输入参数模型"""
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
title: Optional[str] = Field(None, description="The title of the torrent/media to recognize (required for torrent recognition)")
subtitle: Optional[str] = Field(None, description="The subtitle or description of the torrent (optional, helps improve recognition accuracy)")
path: Optional[str] = Field(None, description="The file path to recognize (required for file recognition, mutually exclusive with title)")
@@ -143,6 +142,9 @@ class RecognizeMediaTool(MoviePilotTool):
"imdb_id": media_info.get("imdb_id"),
"douban_id": media_info.get("douban_id"),
"bangumi_id": media_info.get("bangumi_id"),
"anilist_id": media_info.get("anilist_id"),
"media_source": media_info.get("source"),
"media_id": media_info.get("media_id"),
"overview": media_info.get("overview"),
"vote_average": media_info.get("vote_average"),
"poster_path": media_info.get("poster_path"),
@@ -168,7 +170,11 @@ class RecognizeMediaTool(MoviePilotTool):
"season_episode": meta_info.get("season_episode"),
"episode_list": meta_info.get("episode_list"),
"tmdbid": meta_info.get("tmdbid"),
"doubanid": meta_info.get("doubanid")
"doubanid": meta_info.get("doubanid"),
"bangumiid": meta_info.get("bangumiid"),
"anilistid": meta_info.get("anilistid"),
"media_source": meta_info.get("media_source"),
"media_id": meta_info.get("media_id"),
}
return json.dumps(result, ensure_ascii=False, indent=2)

View File

@@ -17,8 +17,6 @@ from app.log import logger
class ReloadPluginInput(BaseModel):
"""重载插件工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
plugin_id: str = Field(
...,
description="The plugin ID to reload so the latest saved config takes effect.",

View File

@@ -0,0 +1,78 @@
"""立即执行 Agent 自主定时任务工具。"""
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.agenttask_oper import AgentTaskOper
class RunAgentTaskInput(BaseModel):
"""立即执行 Agent 自主定时任务的输入参数。"""
task_id: int = Field(
...,
ge=1,
description=(
"Integer autonomous task ID returned by query_agent_tasks. Do not pass a "
"runtime scheduler job_id such as agent-task-12."
),
)
class RunAgentTaskTool(MoviePilotTool):
"""将当前用户的 Agent 自主定时任务提交为立即执行。"""
name: str = "run_agent_task"
tags: list[str] = [ToolTag.Write, ToolTag.AgentTask, ToolTag.Admin]
description: str = (
"Queue an enabled autonomous agent task owned by the current user for immediate "
"execution. Use the integer task_id returned by query_agent_tasks. The task runs "
"after the current agent turn can finish and broadcasts its result through the "
"configured notification channels."
)
args_schema: Type[BaseModel] = RunAgentTaskInput
require_admin: bool = True
def get_tool_message(self, **kwargs: object) -> Optional[str]:
"""生成立即执行 Agent 任务的提示消息。"""
return f"立即执行自主定时任务:{kwargs.get('task_id', '')}"
def _get_task_state(self, task_id: int) -> tuple[str, Optional[str]]:
"""校验任务归属和状态,返回可执行性及任务名称。"""
task = AgentTaskOper().get(
task_id=task_id,
user_id=str(self._user_id),
)
if not task:
return "not_found", None
if not task.enabled:
return "disabled", task.name
if task.last_status == "running":
return "running", task.name
return "ready", task.name
async def run(self, task_id: int, **kwargs: object) -> str:
"""立即执行当前用户拥有且已启用的 Agent 自主定时任务。"""
from app.scheduler import Scheduler
payload = RunAgentTaskInput(task_id=task_id)
status, task_name = await self.run_blocking(
"db",
self._get_task_state,
payload.task_id,
)
if status == "not_found":
return f"Agent 定时任务 {task_id} 不存在或不属于当前用户"
if status == "disabled":
return f"Agent 定时任务 {task_id} 已暂停,请先恢复后再执行"
if status == "running":
return f"Agent 定时任务 {task_id} 正在执行,请勿重复触发"
if not Scheduler().start_agent_task(payload.task_id):
return f"Agent 定时任务 {task_id} 尚未注册到运行时调度器,无法立即执行"
return (
f"Agent 定时任务 {task_id} 已提交立即执行:{task_name}"
"执行完成后将通过已配置的通知渠道广播结果"
)

View File

@@ -12,27 +12,34 @@ from app.log import logger
class RunSchedulerInput(BaseModel):
"""运行定时服务工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
job_id: str = Field(
...,
description="The ID of the scheduled job to run (can be obtained from query_schedulers tool)",
description=(
"Runtime scheduler job ID returned by query_schedulers. Do not pass an "
"autonomous agent task ID or an agent-task-* runtime ID."
),
)
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."
description: str = (
"Manually trigger a MoviePilot system, plugin, or workflow scheduler service by "
"the runtime job_id returned from query_schedulers. This tool does not run "
"user-created autonomous agent tasks; use run_agent_task with an integer task_id."
)
args_schema: Type[BaseModel] = RunSchedulerInput
require_admin: bool = True
def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据运行参数生成友好的提示消息"""
def get_tool_message(self, **kwargs: object) -> Optional[str]:
"""根据运行参数生成友好的提示消息"""
job_id = kwargs.get("job_id", "")
return f"运行定时服务 (ID: {job_id})"
@@ -48,10 +55,18 @@ class RunSchedulerTool(MoviePilotTool):
return True, scheduler_item.name
return False, ""
async def run(self, job_id: str, **kwargs) -> str:
async def run(self, job_id: str, **kwargs: object) -> str:
"""立即运行非 Agent 自主任务的运行时定时服务。"""
logger.info(f"执行工具: {self.name}, 参数: job_id={job_id}")
try:
from app.scheduler import AGENT_TASK_JOB_PREFIX
if job_id.startswith(f"{AGENT_TASK_JOB_PREFIX}-"):
return (
"Agent 自主定时任务不能通过 run_scheduler 运行,"
"请使用 query_agent_tasks 查询整数 task_id 后调用 run_agent_task"
)
job_exists, job_name = await self.run_blocking(
"workflow", self._run_scheduler_sync, job_id
)

View File

@@ -15,8 +15,6 @@ from app.schemas.types import EventType, MessageChannel
class RunSlashCommandInput(BaseModel):
"""运行斜杠命令工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
command: str = Field(
...,
description="The slash command to execute, e.g. '/cookiecloud'. "

View File

@@ -7,7 +7,6 @@ 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
from app.log import logger
@@ -15,8 +14,6 @@ from app.log import logger
class RunWorkflowInput(BaseModel):
"""执行工作流工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
workflow_id: int = Field(
..., description="Workflow ID (can be obtained from query_workflows tool)"
)
@@ -65,26 +62,23 @@ class RunWorkflowTool(MoviePilotTool):
)
try:
# 获取数据库会话
async with AsyncSessionFactory() as db:
workflow_oper = WorkflowOper(db)
workflow = await workflow_oper.async_get(workflow_id)
workflow = await WorkflowOper().async_get(workflow_id)
if not workflow:
return f"未找到工作流:{workflow_id},请使用 query_workflows 工具查询可用的工作流"
if not workflow:
return f"未找到工作流:{workflow_id},请使用 query_workflows 工具查询可用的工作流"
# 工作流执行链路包含大量同步步骤,统一放到 workflow 线程池。
state, errmsg = await self.run_blocking(
"workflow",
self._run_workflow_sync,
workflow.id,
from_begin,
)
# 工作流执行链路包含大量同步步骤,统一放到 workflow 线程池。
state, errmsg = await self.run_blocking(
"workflow",
self._run_workflow_sync,
workflow.id,
from_begin,
)
if not state:
return f"执行工作流失败:{workflow.name} (ID: {workflow.id})\n错误原因:{errmsg}"
else:
return f"工作流执行成功:{workflow.name} (ID: {workflow.id})"
if not state:
return f"执行工作流失败:{workflow.name} (ID: {workflow.id})\n错误原因:{errmsg}"
else:
return f"工作流执行成功:{workflow.name} (ID: {workflow.id})"
except Exception as e:
logger.error(f"执行工作流失败: {e}", exc_info=True)
return f"执行工作流时发生错误: {str(e)}"

View File

@@ -16,8 +16,6 @@ from app.schemas import FileItem
class ScrapeMetadataInput(BaseModel):
"""刮削媒体元数据工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
path: str = Field(
...,
description="Path to the file or directory to scrape metadata for (e.g., '/path/to/file.mkv' or '/path/to/directory')",

View File

@@ -10,11 +10,11 @@ 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
from app.utils.media import resolve_media_identity
class SearchMediaInput(BaseModel):
"""搜索媒体工具的输入参数模型"""
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
title: str = Field(..., description="The title of the media to search for (e.g., 'The Matrix', 'Breaking Bad')")
year: Optional[str] = Field(None, description="Release year of the media (optional, helps narrow down results)")
media_type: Optional[str] = Field(None,
@@ -44,7 +44,7 @@ class SearchMediaTool(MoviePilotTool):
message += f" ({year})"
if media_type:
message += f" [{media_type}]"
if season:
if season is not None:
message += f"{season}"
return message
@@ -84,6 +84,7 @@ class SearchMediaTool(MoviePilotTool):
# 精简字段,只保留关键信息
simplified_results = []
for r in limited_results:
media_source, media_id = resolve_media_identity(media=r)
simplified = {
"title": r.title,
"en_title": r.en_title,
@@ -93,6 +94,10 @@ class SearchMediaTool(MoviePilotTool):
"tmdb_id": r.tmdb_id,
"imdb_id": r.imdb_id,
"douban_id": r.douban_id,
"bangumi_id": r.bangumi_id,
"anilist_id": r.anilist_id,
"media_source": media_source,
"media_id": media_id,
"overview": r.overview[:200] + "..." if r.overview and len(r.overview) > 200 else r.overview,
"vote_average": r.vote_average,
"poster_path": r.poster_path,

View File

@@ -13,7 +13,6 @@ from app.log import logger
class SearchPersonInput(BaseModel):
"""搜索人物工具的输入参数模型"""
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
name: str = Field(..., description="The name of the person to search for (e.g., 'Tom Hanks', '周杰伦')")

View File

@@ -11,11 +11,11 @@ from app.chain.douban import DoubanChain
from app.chain.tmdb import TmdbChain
from app.chain.bangumi import BangumiChain
from app.log import logger
from app.utils.media import resolve_media_identity
class SearchPersonCreditsInput(BaseModel):
"""搜索演员参演作品工具的输入参数模型"""
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
person_id: int = Field(..., description="The ID of the person/actor to search for credits (e.g., 31 for Tom Hanks in TMDB)")
source: str = Field(..., description="The data source: 'tmdb' for TheMovieDB, 'douban' for Douban, 'bangumi' for Bangumi")
page: Optional[int] = Field(1, description="Page number for pagination (default: 1)")
@@ -60,6 +60,7 @@ class SearchPersonCreditsTool(MoviePilotTool):
# 精简字段,只保留关键信息
simplified_results = []
for media in limited_medias:
media_source, media_id = resolve_media_identity(media=media)
simplified = {
"title": media.title,
"en_title": media.en_title,
@@ -69,6 +70,10 @@ class SearchPersonCreditsTool(MoviePilotTool):
"tmdb_id": media.tmdb_id,
"imdb_id": media.imdb_id,
"douban_id": media.douban_id,
"bangumi_id": media.bangumi_id,
"anilist_id": media.anilist_id,
"media_source": media_source,
"media_id": media_id,
"overview": media.overview[:200] + "..." if media.overview and len(media.overview) > 200 else media.overview,
"vote_average": media.vote_average,
"poster_path": media.poster_path,

View File

@@ -15,7 +15,6 @@ from app.schemas.types import media_type_to_agent
class SearchSubscribeInput(BaseModel):
"""搜索订阅缺失剧集工具的输入参数模型"""
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
subscribe_id: int = Field(..., description="The ID of the subscription to search for missing episodes (can be obtained from query_subscribes tool)")
manual: Optional[bool] = Field(False, description="Whether this is a manual search (default: False)")
filter_groups: Optional[List[str]] = Field(None,
@@ -71,7 +70,11 @@ class SearchSubscribeTool(MoviePilotTool):
"total_episode": subscribe.total_episode,
"lack_episode": subscribe.lack_episode,
"tmdbid": subscribe.tmdbid,
"doubanid": subscribe.doubanid
"doubanid": subscribe.doubanid,
"bangumiid": subscribe.bangumiid,
"anilistid": subscribe.anilistid,
"media_source": subscribe.media_source,
"media_id": subscribe.media_id,
}
# 检查订阅状态

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