Compare commits

...

228 Commits

Author SHA1 Message Date
github-actions[bot]
5f32b48d8c build(plugin-market): sync default from MoviePilot-Wiki@3aae2b470356 2026-08-07 05:32:11 +00:00
jxxghp
c6bd396794 Improve agent file reading and structured LLM summaries 2026-08-07 13:31:39 +08:00
jxxghp
7985268f10 fix(agent): emit web tool status immediately 2026-08-07 13:01:47 +08:00
jxxghp
865635c59d perf(agent): optimize web SSE streaming 2026-08-07 12:45:36 +08:00
jxxghp
759b9e47eb fix(transfer): expire stale jobs and deduplicate diagnostics 2026-08-07 12:44:04 +08:00
jxxghp
63e492be7c fix(agent): report only meaningful progress milestones 2026-08-07 11:27:36 +08:00
jxxghp
b5eca00ba3 更新 version.py 2026-08-07 07:52:44 +08:00
jxxghp
b80642f56f fix(agent): decouple progress prompt from tool display 2026-08-07 07:17:27 +08:00
jxxghp
6d3161f3cb fix(agent): align progress updates with Codex cadence 2026-08-07 07:01:03 +08:00
jxxghp
ea8d1f8d26 fix(agent): report progress during long tool runs 2026-08-07 06:57:07 +08:00
jxxghp
5654512d41 fix(api): return avatar filename in data 2026-08-06 23:36:34 +08:00
jxxghp
a52e1fdc1c feat(agent): improve prompt cache hit rate 2026-08-06 23:34:29 +08:00
jxxghp
44db45ea28 feat(docker): include sshpass for remote shell access 2026-08-06 22:47:02 +08:00
jxxghp
83409c1439 feat(agent): expand tool output and search pagination 2026-08-06 22:36:05 +08:00
jxxghp
cf6c73d85b fix(agent): persist streamed message order 2026-08-06 22:24:54 +08:00
jxxghp
987c1722d7 feat(api): add unified v2 response layer 2026-08-06 17:32:33 +08:00
秋澪Akimio
57220c93db fix(checks): update regex for proxy string to support socks proxy (#6236) 2026-08-06 15:08:29 +08:00
jxxghp
4abae809c2 fix: preserve parent year in media recognition 2026-08-06 12:45:48 +08:00
jxxghp
7fe7be6d71 feat(plugin): sync default markets from wiki at release 2026-08-06 09:15:30 +08:00
jxxghp
4b1df72a4a refactor(cache): simplify recognition cache persistence 2026-08-06 07:48:43 +08:00
jxxghp
a23ac6c56d feat: 支持模型服务端联网搜索 2026-08-05 19:19:26 +08:00
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
534 changed files with 58376 additions and 6800 deletions

View File

@@ -71,6 +71,7 @@ test_*
# Build artifacts
build/
.build/
dist/
*.egg-info/
rust/**/target/

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:

View File

@@ -2,6 +2,10 @@ name: MoviePilot Builder Beta
on:
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
Docker-build:
runs-on: ubuntu-latest
@@ -16,6 +20,25 @@ jobs:
app_version=$(cat version.py |sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp")
echo "app_version=$app_version" >> $GITHUB_ENV
- name: Checkout Wiki Plugin Market
uses: actions/checkout@v4
with:
repository: jxxghp/MoviePilot-Wiki
ref: main
path: .build/moviepilot-wiki
sparse-checkout: plugin.md
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Generate Plugin Market Default
id: plugin_market
run: |
python3 -m scripts.generate_plugin_market_default \
--wiki-file .build/moviepilot-wiki/plugin.md \
--config-file app/core/config.py
wiki_commit=$(git -C .build/moviepilot-wiki rev-parse HEAD)
echo "wiki_commit=$wiki_commit" >> "$GITHUB_OUTPUT"
- name: Docker Meta
id: meta
uses: docker/metadata-action@v5
@@ -55,6 +78,8 @@ jobs:
linux/arm64/v8
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
labels: |
${{ steps.meta.outputs.labels }}
org.moviepilot.plugin-market-wiki-revision=${{ steps.plugin_market.outputs.wiki_commit }}
cache-from: type=gha,scope=moviepilot-docker,version=2
cache-to: type=gha,scope=moviepilot-docker,mode=max,version=2

View File

@@ -7,6 +7,10 @@ on:
paths:
- 'version.py'
permissions:
contents: write
packages: write
jobs:
Docker-build:
runs-on: ubuntu-latest
@@ -23,6 +27,39 @@ jobs:
run: |
app_version=$(cat version.py |sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp")
echo "app_version=$app_version" >> $GITHUB_ENV
echo "SOURCE_COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV
- name: Checkout Wiki Plugin Market
uses: actions/checkout@v4
with:
repository: jxxghp/MoviePilot-Wiki
ref: main
path: .build/moviepilot-wiki
sparse-checkout: plugin.md
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Generate Plugin Market Default
id: plugin_market
run: |
python3 -m scripts.generate_plugin_market_default \
--wiki-file .build/moviepilot-wiki/plugin.md \
--config-file app/core/config.py
wiki_commit=$(git -C .build/moviepilot-wiki rev-parse HEAD)
echo "wiki_commit=$wiki_commit" >> "$GITHUB_OUTPUT"
- name: Create Release Snapshot
id: release_snapshot
env:
WIKI_COMMIT: ${{ steps.plugin_market.outputs.wiki_commit }}
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add app/core/config.py
if ! git diff --cached --quiet; then
git commit -m "build(plugin-market): sync default from MoviePilot-Wiki@${WIKI_COMMIT:0:12}"
fi
echo "release_commit=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Docker Meta
id: meta
@@ -65,7 +102,10 @@ jobs:
linux/arm64/v8
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
labels: |
${{ steps.meta.outputs.labels }}
org.opencontainers.image.revision=${{ steps.release_snapshot.outputs.release_commit }}
org.moviepilot.plugin-market-wiki-revision=${{ steps.plugin_market.outputs.wiki_commit }}
cache-from: type=gha,scope=moviepilot-docker,version=2
cache-to: type=gha,scope=moviepilot-docker,mode=max,version=2
@@ -78,9 +118,9 @@ jobs:
# 使用 || 作为分隔符,同时获取 commit 消息和作者 GitHub 用户名
if [ -z "$PREVIOUS_TAG" ]; then
COMMITS=$(git log --pretty=format:"%s||%an" HEAD)
COMMITS=$(git log --pretty=format:"%s||%an" "${SOURCE_COMMIT}")
else
COMMITS=$(git log --pretty=format:"%s||%an" ${PREVIOUS_TAG}..HEAD)
COMMITS=$(git log --pretty=format:"%s||%an" "${PREVIOUS_TAG}..${SOURCE_COMMIT}")
fi
# 分类收集 commit 消息(使用关联数组去重)
@@ -188,6 +228,17 @@ jobs:
delete_release: true
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Publish Release Tag
env:
RELEASE_COMMIT: ${{ steps.release_snapshot.outputs.release_commit }}
run: |
tag_name="v${{ env.app_version }}"
if git show-ref --verify --quiet "refs/tags/${tag_name}"; then
git tag -d "$tag_name"
fi
git tag "$tag_name" "$RELEASE_COMMIT"
git push origin "refs/tags/${tag_name}"
- name: Generate Release
uses: softprops/action-gh-release@v2
with:

View File

@@ -1,9 +1,8 @@
name: PR Agent
name: PR-Agent
on:
pull_request_target:
# PR-Agent 通过 base repo 上下文读取 PR diff 并发布 Review,不 checkout 或执行 PR 分支代码。
# pull_request_target 允许 fork PR 使用仓库 secrets因此 workflow 只运行固定 digest 的 PR-Agent 容器。
# Fork 审查需要目标仓库凭据;该 job 仅通过 GitHub API 读取 PR 内容,不 checkout 或执行 PR 分支代码。
types:
- opened
- reopened
@@ -11,24 +10,17 @@ on:
- review_requested
- synchronize
issue_comment:
# 手动命令如 "/review"、"/describe"、"/improve" 和 "/ask ..." 只在 PR 评论中有意义。
# issue_comment 同时覆盖普通 issue因此 job 里还会再判断是否属于 PR。
types:
- created
- edited
permissions:
# 读取仓库内容和 PR diff。
contents: read
# 更新 PR 描述、发布 PR Review 或修改 PR 相关元数据。
pull-requests: write
# PR 评论在 GitHub API 中属于 issue comments手动命令和总结评论需要该权限。
issues: write
jobs:
pr-agent:
name: PR-Agent review and describe
# PR 事件自动处理;评论命令仅允许指定身份在 PR 下触发,避免任意评论消耗模型配额。
if: >-
github.event.sender.type != 'Bot' &&
(
@@ -36,88 +28,25 @@ jobs:
(
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) &&
(
github.event.comment.body == '/review' ||
startsWith(github.event.comment.body, '/review ') ||
github.event.comment.body == '/describe' ||
startsWith(github.event.comment.body, '/describe ') ||
github.event.comment.body == '/improve' ||
startsWith(github.event.comment.body, '/improve ') ||
github.event.comment.body == '/ask' ||
startsWith(github.event.comment.body, '/ask ')
)
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-Agent
id: pragent
# 使用版本号加 digest 固定容器构建,避免 tag 被重推后改变运行内容。
uses: docker://pragent/pr-agent:0.37.0-github_action@sha256:4ec7bac814050a1bc8c96ab2fab6b7b0f65df0049a5ec43f3fee1a0b551c28ca
- name: Run PR Review
uses: docker://ghcr.io/infinitypacer/pr-review-runner:latest
env:
# PR-Agent 使用该 token 读取 PR 元数据并发布评论。
GITHUB_TOKEN: ${{ github.token }}
# 仓库设置中添加的 SecretSettings -> Secrets and variables -> Actions。
# 该 key 只传给 PR-Agent 运行时,不写入仓库。
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
# 仓库设置中添加的 Secret。OpenAI 兼容服务通常需要填写以 "/v1" 结尾的 API 根地址。
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
# 模型、输出语言和大 diff 处理策略。
config.model: "gpt-5.5"
config.fallback_models: '["gpt-5.4"]'
config.reasoning_effort: "xhigh"
config.ai_timeout: "900"
config.response_language: "zh-CN"
config.large_patch_policy: "clip"
config.ignore_pr_title: '["^\\[Auto\\]", "^Auto"]'
config.ignore_pr_labels: '["skip pr-agent"]'
# pull_request_target 事件默认自动执行 /review 和 /describe/improve 保持手动触发。
github_action_config.auto_review: "true"
github_action_config.auto_describe: "true"
github_action_config.auto_improve: "false"
# 允许触发自动工具的 PR 动作。包含 synchronize便于新 commit 推送后刷新结果。
github_action_config.pr_actions: '["opened", "reopened", "ready_for_review", "review_requested", "synchronize"]'
# 保留 action outputs便于后续 workflow 编排或排查。
github_action_config.enable_output: "true"
# /describe 行为控制;与自动触发配置放在同一层,避免使用默认图表和标签策略。
pr_description.generate_ai_title: "false"
pr_description.publish_labels: "false"
pr_description.enable_pr_diagram: "false"
pr_description.collapsible_file_list: "adaptive"
pr_description.add_original_user_description: "true"
# /review 输出策略,聚焦维护者需要处理的风险和缺口。
pr_reviewer.extra_instructions: |
请用中文输出。
优先指出 P0/P1 风险,避免纠结纯格式问题。
重点检查安全、权限、状态一致性、异步/缓存、副作用和测试缺口。
pr_reviewer.num_max_findings: "5"
pr_reviewer.persistent_comment: "true"
pr_reviewer.publish_output_no_suggestions: "true"
pr_reviewer.require_tests_review: "true"
pr_reviewer.require_security_review: "true"
pr_reviewer.require_estimate_effort_to_review: "true"
pr_reviewer.require_can_be_split_review: "true"
pr_reviewer.require_todo_scan: "false"
pr_reviewer.enable_review_labels_effort: "false"
pr_reviewer.enable_review_labels_security: "true"
# /improve 和 /ask 的手动命令策略。
pr_code_suggestions.focus_only_on_problems: "true"
pr_code_suggestions.suggestions_score_threshold: "7"
pr_code_suggestions.commitable_code_suggestions: "false"
pr_questions.use_conversation_history: "true"
# 可选成本和噪音控制:
# github_action_config.auto_improve: "true"
# config.verbosity_level: "1"
# pr_reviewer.num_max_findings: "3"
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

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

1
.gitignore vendored
View File

@@ -37,6 +37,7 @@ 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

View File

@@ -51,11 +51,14 @@ from app.agent.middleware.tool_selection import ToolSelectorMiddleware
from app.agent.middleware.usage import UsageMiddleware
from app.agent.prompt import prompt_manager
from app.agent.runtime import agent_runtime_manager
from app.agent.mcp import agent_mcp_manager
from app.agent.tools.factory import MoviePilotToolFactory
from app.agent.tools.impl.mcp import create_external_mcp_tools
from app.chain import ChainBase
from app.core.config import settings
from app.core.event import eventmanager
from app.db.agentchat_oper import AgentChatOper
from app.db.agenttask_oper import AgentTaskOper
from app.db.user_oper import UserOper
from app.log import logger
from app.schemas import AgentLLMProviderEventData, AgentTokensUsageEventData, Notification, NotificationType
@@ -65,6 +68,8 @@ from app.utils.identity import SYSTEM_INTERNAL_USER_ID
class AgentChain(ChainBase):
"""Agent 业务处理链。"""
pass
@@ -124,9 +129,18 @@ class _SessionUsageSnapshot:
last_output_tokens: int = 0
last_total_tokens: int = 0
last_context_usage_ratio: Optional[float] = None
last_cache_usage_available: bool = False
last_cache_read_input_tokens: int = 0
last_cache_write_input_tokens: int = 0
last_uncached_input_tokens: int = 0
last_cache_hit_ratio: Optional[float] = None
total_input_tokens: int = 0
total_output_tokens: int = 0
total_tokens: int = 0
total_cache_read_input_tokens: int = 0
total_cache_write_input_tokens: int = 0
total_uncached_input_tokens: int = 0
cache_usage_available: bool = False
model_call_count: int = 0
last_updated_at: Optional[datetime] = None
@@ -139,9 +153,23 @@ class _SessionUsageSnapshot:
"last_output_tokens": self.last_output_tokens,
"last_total_tokens": self.last_total_tokens,
"last_context_usage_ratio": self.last_context_usage_ratio,
"last_cache_usage_available": self.last_cache_usage_available,
"last_cache_read_input_tokens": self.last_cache_read_input_tokens,
"last_cache_write_input_tokens": self.last_cache_write_input_tokens,
"last_uncached_input_tokens": self.last_uncached_input_tokens,
"last_cache_hit_ratio": self.last_cache_hit_ratio,
"total_input_tokens": self.total_input_tokens,
"total_output_tokens": self.total_output_tokens,
"total_tokens": self.total_tokens,
"total_cache_read_input_tokens": self.total_cache_read_input_tokens,
"total_cache_write_input_tokens": self.total_cache_write_input_tokens,
"total_uncached_input_tokens": self.total_uncached_input_tokens,
"cache_usage_available": self.cache_usage_available,
"total_cache_hit_ratio": (
self.total_cache_read_input_tokens / self.total_input_tokens
if self.cache_usage_available and self.total_input_tokens
else None
),
"model_call_count": self.model_call_count,
"last_updated_at": self.last_updated_at.strftime("%Y-%m-%d %H:%M:%S")
if self.last_updated_at
@@ -313,13 +341,19 @@ class MoviePilotAgent:
"""
构造可展示的 Agent 会话消息。
"""
normalized_content = content or ""
return {
"id": f"{role}-{uuid.uuid4().hex}",
"role": role,
"content": content or "",
"content": normalized_content,
"createdAt": cls._current_timestamp_ms(),
"status": status,
"tools": [],
"segments": (
[{"type": "text", "content": normalized_content}]
if normalized_content
else []
),
"attachments": attachments or [],
"choices": [],
}
@@ -543,9 +577,33 @@ class MoviePilotAgent:
self._session_usage.last_output_tokens = output_tokens
self._session_usage.last_total_tokens = total_tokens
self._session_usage.last_context_usage_ratio = usage.get("context_usage_ratio")
cache_usage_available = bool(usage.get("cache_usage_available"))
cache_read_input_tokens = self._coerce_int(
usage.get("cache_read_input_tokens")
) or 0
cache_write_input_tokens = self._coerce_int(
usage.get("cache_write_input_tokens")
) or 0
uncached_input_tokens = self._coerce_int(
usage.get("uncached_input_tokens")
)
if uncached_input_tokens is None:
uncached_input_tokens = max(
input_tokens - cache_read_input_tokens - cache_write_input_tokens,
0,
)
self._session_usage.last_cache_usage_available = cache_usage_available
self._session_usage.last_cache_read_input_tokens = cache_read_input_tokens
self._session_usage.last_cache_write_input_tokens = cache_write_input_tokens
self._session_usage.last_uncached_input_tokens = uncached_input_tokens
self._session_usage.last_cache_hit_ratio = usage.get("cache_hit_ratio")
self._session_usage.total_input_tokens += input_tokens
self._session_usage.total_output_tokens += output_tokens
self._session_usage.total_tokens += total_tokens
self._session_usage.total_cache_read_input_tokens += cache_read_input_tokens
self._session_usage.total_cache_write_input_tokens += cache_write_input_tokens
self._session_usage.total_uncached_input_tokens += uncached_input_tokens
self._session_usage.cache_usage_available |= cache_usage_available
def get_session_status(self) -> dict[str, Any]:
if not self._session_usage.model:
@@ -579,6 +637,17 @@ class MoviePilotAgent:
input_tokens=self._session_usage.total_input_tokens,
output_tokens=self._session_usage.total_output_tokens,
total_tokens=self._session_usage.total_tokens,
cache_read_input_tokens=self._session_usage.total_cache_read_input_tokens,
cache_write_input_tokens=self._session_usage.total_cache_write_input_tokens,
uncached_input_tokens=self._session_usage.total_uncached_input_tokens,
cache_hit_ratio=(
self._session_usage.total_cache_read_input_tokens
/ self._session_usage.total_input_tokens
if self._session_usage.cache_usage_available
and self._session_usage.total_input_tokens
else None
),
cache_usage_available=self._session_usage.cache_usage_available,
model_call_count=self._session_usage.model_call_count,
success=success,
error=error,
@@ -710,7 +779,7 @@ class MoviePilotAgent:
"""
通过链式事件解析本次 Agent 可用的 LLM 运行时配置。
若没有插件返回 selected_provider_id则沿用系统配置保持既有行为
插件返回有效配置时沿用系统配置,显式返回的配置优先
"""
if self._llm_runtime_config is not None:
return self._llm_runtime_config
@@ -723,7 +792,9 @@ class MoviePilotAgent:
base_url_preset=settings.LLM_BASE_URL_PRESET,
user_agent=settings.LLM_USER_AGENT,
use_proxy=settings.LLM_USE_PROXY,
thinking_level=None,
thinking_level=settings.LLM_THINKING_LEVEL,
api_protocol=settings.LLM_API_PROTOCOL,
web_search_mode=settings.LLM_WEB_SEARCH_MODE,
)
selected_event = await eventmanager.async_send_event(
ChainEventType.AgentLLMProvider,
@@ -758,9 +829,18 @@ class MoviePilotAgent:
use_proxy = self._get_event_value(resolved_data, "use_proxy")
if use_proxy is None:
use_proxy = settings.LLM_USE_PROXY
thinking_level = self._clean_optional_text(
self._get_event_value(resolved_data, "thinking_level")
thinking_level = (
self._clean_optional_text(
self._get_event_value(resolved_data, "thinking_level")
)
or settings.LLM_THINKING_LEVEL
)
api_protocol = self._clean_optional_text(
self._get_event_value(resolved_data, "api_protocol")
) or settings.LLM_API_PROTOCOL
web_search_mode = self._clean_optional_text(
self._get_event_value(resolved_data, "web_search_mode")
) or settings.LLM_WEB_SEARCH_MODE
selected_provider_id = self._clean_optional_text(
self._get_event_value(resolved_data, "selected_provider_id")
)
@@ -786,6 +866,8 @@ class MoviePilotAgent:
"user_agent": user_agent,
"use_proxy": bool(use_proxy),
"thinking_level": thinking_level,
"api_protocol": api_protocol,
"web_search_mode": web_search_mode,
}
return self._llm_runtime_config
@@ -795,7 +877,17 @@ class MoviePilotAgent:
:param streaming: 是否启用流式输出
"""
runtime_config = await self._resolve_llm_runtime_config()
return await LLMHelper.get_llm(streaming=streaming, **runtime_config)
return await LLMHelper.get_llm(
streaming=streaming,
prompt_cache_key=self._build_prompt_cache_key(),
**runtime_config,
)
def _build_prompt_cache_key(self) -> str:
"""生成不暴露用户标识、且在同一会话内稳定的提示词缓存键。"""
cache_identity = f"{self.user_id or ''}\x00{self.session_id}"
digest = hashlib.sha256(cache_identity.encode("utf-8")).hexdigest()[:32]
return f"moviepilot-agent-{digest}"
@classmethod
def _has_image_input_content(cls, content: Any) -> bool:
@@ -993,6 +1085,13 @@ class MoviePilotAgent:
allow_message_tools=self.allow_message_tools,
)
@staticmethod
def _filter_local_web_search_tools(tools: List, enabled: bool) -> List:
"""按联网搜索策略保留或移除本地 search_web 工具。"""
if enabled:
return tools
return [tool for tool in tools if getattr(tool, "name", None) != "search_web"]
def _refresh_tool_context(self, values: Dict[str, object]) -> None:
"""
刷新本轮工具共享上下文。
@@ -1021,6 +1120,8 @@ class MoviePilotAgent:
runtime_config.get("user_agent"),
bool(runtime_config.get("use_proxy")),
runtime_config.get("thinking_level"),
runtime_config.get("api_protocol"),
runtime_config.get("web_search_mode"),
)
async def _agent_bundle_signature(self, streaming: bool) -> tuple[Any, ...]:
@@ -1037,10 +1138,12 @@ class MoviePilotAgent:
self.has_message_context,
self.is_background,
settings.AI_AGENT_VERBOSE,
settings.LLM_TEMPERATURE,
settings.LLM_MAX_TOOLS,
settings.LLM_MAX_ITERATIONS,
self._public_runtime_config_signature(runtime_config),
agent_runtime_manager.current_signature(),
agent_mcp_manager.config_signature(),
)
def _get_cached_agent(
@@ -1097,6 +1200,39 @@ class MoviePilotAgent:
allow_message_tools=False,
)
async def _initialize_mcp_tools(self) -> List:
"""
初始化外部 MCP 工具列表。
"""
return await create_external_mcp_tools(
session_id=self.session_id,
user_id=self.user_id,
channel=self.channel,
source=self.source,
username=self.username,
stream_handler=self.stream_handler,
agent_context=self._tool_context,
)
async def _initialize_subagent_mcp_tools(self) -> List:
"""
初始化子代理可用的外部 MCP 工具列表。
"""
return await create_external_mcp_tools(
session_id=self.session_id,
user_id=self.user_id,
channel=self.channel,
source=self.source,
username=self.username,
stream_handler=None,
agent_context={
"user_reply_sent": False,
"reply_mode": None,
"should_dispatch_reply": False,
"is_admin": bool(self._tool_context.get("is_admin")),
},
)
async def _create_agent(self, streaming: bool = False):
"""
创建 LangGraph Agent使用 create_agent + SummarizationMiddleware
@@ -1116,6 +1252,8 @@ class MoviePilotAgent:
# LLM 模型(用于 agent 执行)
agent_model = await self._initialize_llm(streaming=streaming)
self._sync_model_profile(agent_model)
server_tools = LLMHelper.get_server_tools(agent_model)
use_local_web_search = LLMHelper.should_use_local_web_search(agent_model)
# 为内部模型调用准备非流式 LLM避免与用户流式回复复用同一实例。
non_streaming_model = (
@@ -1125,7 +1263,11 @@ class MoviePilotAgent:
)
# 工具列表
tools = self._initialize_tools()
tools = self._filter_local_web_search_tools(
self._initialize_tools(),
enabled=use_local_web_search,
)
tools.extend(await self._initialize_mcp_tools())
skills_middleware = SkillsMiddleware(
sources=[str(agent_runtime_manager.skills_dir)],
bundled_skills_dir=str(settings.ROOT_PATH / "skills"),
@@ -1142,9 +1284,15 @@ class MoviePilotAgent:
activity_log_tools = list(
getattr(activity_log_middleware, "tools", []) or []
)
subagent_tools = self._filter_local_web_search_tools(
self._initialize_subagent_tools(),
enabled=use_local_web_search,
)
subagent_tools.extend(await self._initialize_subagent_mcp_tools())
subagent_middlewares, subagent_task_tools = create_subagent_middlewares(
model=non_streaming_model,
tools=self._initialize_subagent_tools(),
tools=subagent_tools,
server_tools=server_tools,
stream_handler=self.stream_handler,
)
max_tools = settings.LLM_MAX_TOOLS
@@ -1219,7 +1367,7 @@ class MoviePilotAgent:
agent = create_agent(
model=agent_model,
tools=[*tools, *skill_tools, *activity_log_tools],
tools=[*tools, *skill_tools, *activity_log_tools, *server_tools],
system_prompt=system_prompt,
middleware=middlewares,
checkpointer=InMemorySaver(),
@@ -1588,22 +1736,24 @@ class MoviePilotAgent:
if not streaming_stopped:
await self.stream_handler.stop_streaming()
async def send_agent_message(self, message: str, title: str = ""):
async def send_agent_message(self, message: str, title: str = "") -> None:
"""
通过原渠道发送消息给用户
发送 Agent 消息;后台任务不绑定原渠道,交由通知链广播。
"""
broadcast = self.is_background
self._save_assistant_display_message_once(message)
await AgentChain().async_post_message(
Notification(
channel=self.channel,
source=self.source,
channel=None if broadcast else self.channel,
source=None if broadcast else self.source,
mtype=NotificationType.Agent,
userid=self.user_id,
username=self.username,
original_message_id=self.original_message_id,
original_chat_id=self.original_chat_id,
userid=None if broadcast else self.user_id,
username=self.username or (settings.SUPERUSER if broadcast else None),
original_message_id=None if broadcast else self.original_message_id,
original_chat_id=None if broadcast else self.original_chat_id,
title=title,
text=message,
save_history=False,
)
)
@@ -1635,6 +1785,10 @@ class _MessageTask:
processing_status: Optional[dict] = None
reply_mode: ReplyMode = ReplyMode.DISPATCH
allow_message_tools: bool = True
output_callback: Optional[Callable[[str], None]] = None
notification_callback: Optional[Callable[[Any], None]] = None
agent_factory: Optional[Callable[..., MoviePilotAgent]] = None
completion_future: Optional[asyncio.Future] = None
class AgentManager:
@@ -1739,6 +1893,12 @@ class AgentManager:
queue = self._session_queues.get(session_id)
return bool(queue and not queue.empty())
def is_session_busy(self, session_id: str) -> bool:
"""
查询会话是否仍有正在执行或排队的任务。
"""
return self._is_session_busy(session_id)
def _expired_idle_sessions(self) -> list[tuple[str, str]]:
"""
收集已经超过空闲时间且当前不忙的会话。
@@ -1780,11 +1940,18 @@ class AgentManager:
original_chat_id: Optional[str] = None,
reply_mode: ReplyMode = ReplyMode.DISPATCH,
allow_message_tools: bool = True,
output_callback: Optional[Callable[[str], None]] = None,
notification_callback: Optional[Callable[[Any], None]] = None,
agent_factory: Optional[Callable[..., MoviePilotAgent]] = None,
wait_for_completion: bool = False,
) -> str:
"""
处理用户消息:将消息放入会话队列,按顺序依次处理。
同一会话的消息排队等待,不同会话之间互不影响。
"""
completion_future = (
asyncio.get_running_loop().create_future() if wait_for_completion else None
)
task = _MessageTask(
session_id=session_id,
user_id=user_id,
@@ -1799,6 +1966,10 @@ class AgentManager:
original_chat_id=original_chat_id,
reply_mode=reply_mode,
allow_message_tools=allow_message_tools,
output_callback=output_callback,
notification_callback=notification_callback,
agent_factory=agent_factory,
completion_future=completion_future,
)
self._record_session_activity(session_id, user_id)
@@ -1831,6 +2002,8 @@ class AgentManager:
self._session_worker(session_id)
)
if completion_future:
return await completion_future
return ""
async def _session_worker(self, session_id: str):
@@ -1854,9 +2027,17 @@ class AgentManager:
try:
await self._start_task_processing_status(task)
await self._process_message_internal(task)
result = await self._process_message_internal(task)
if task.completion_future and not task.completion_future.done():
task.completion_future.set_result(result)
except asyncio.CancelledError as err:
if task.completion_future and not task.completion_future.done():
task.completion_future.set_exception(err)
raise
except Exception as e:
logger.error(f"处理会话 {session_id} 的消息失败: {e}")
if task.completion_future and not task.completion_future.done():
task.completion_future.set_exception(e)
finally:
await self._finish_task_processing_status(task)
queue.task_done()
@@ -1895,35 +2076,55 @@ class AgentManager:
实际处理单条消息
"""
session_id = task.session_id
existing_agent = self.active_agents.get(session_id)
if (
existing_agent
and task.agent_factory
and isinstance(task.agent_factory, type)
and not isinstance(existing_agent, task.agent_factory)
):
await existing_agent.cleanup()
self.active_agents.pop(session_id, None)
if session_id not in self.active_agents:
logger.info(
f"创建新的AI智能体实例session_id: {session_id}, user_id: {task.user_id}"
)
agent = MoviePilotAgent(
session_id=session_id,
user_id=task.user_id,
channel=task.channel,
source=task.source,
username=task.username,
original_message_id=task.original_message_id,
original_chat_id=task.original_chat_id,
replay_mode=task.reply_mode,
allow_message_tools=task.allow_message_tools,
)
agent_factory = task.agent_factory or MoviePilotAgent
agent_kwargs = {
"session_id": session_id,
"user_id": task.user_id,
"channel": task.channel,
"source": task.source,
"username": task.username,
"original_message_id": task.original_message_id,
"original_chat_id": task.original_chat_id,
"replay_mode": task.reply_mode,
"allow_message_tools": task.allow_message_tools,
"output_callback": task.output_callback,
}
if task.notification_callback is not None and task.agent_factory:
agent_kwargs["notification_callback"] = task.notification_callback
agent = agent_factory(**agent_kwargs)
self.active_agents[session_id] = agent
else:
agent = self.active_agents[session_id]
agent.user_id = task.user_id
if task.channel:
agent.channel = task.channel
if task.source:
agent.source = task.source
if task.username:
agent.username = task.username
# 每条队列任务都携带完整消息上下文None 也必须覆盖,避免后台任务
# 复用会话 Agent 时继续沿用上一条入站消息的渠道。
agent.channel = task.channel
agent.source = task.source
agent.username = task.username
agent.original_message_id = task.original_message_id
agent.original_chat_id = task.original_chat_id
agent.reply_mode = task.reply_mode
agent.allow_message_tools = task.allow_message_tools
if hasattr(agent, "set_output_callback"):
agent.set_output_callback(task.output_callback)
else:
agent.output_callback = task.output_callback
if task.notification_callback is not None and hasattr(agent, "set_notification_callback"):
agent.set_notification_callback(task.notification_callback)
process_kwargs = {
"images": task.images,
@@ -2031,6 +2232,78 @@ class AgentManager:
await agent.cleanup()
memory_manager.clear_memory(session_id, user_id)
async def execute_scheduled_task(self, task_id: int) -> tuple[bool, str]:
"""
按持久化上下文唤醒 Agent 执行自主定时任务并向用户回传结果。
:param task_id: Agent 定时任务 ID
:return: 执行是否成功及结果摘要
"""
if not settings.AI_AGENT_ENABLE:
return False, "AI Agent 未启用"
oper = AgentTaskOper()
task = oper.get(task_id)
if not task or not task.enabled:
return False, "Agent 定时任务不存在或已停用"
if not oper.mark_running(task_id):
return False, "Agent 定时任务当前不可执行"
task_message = (
f"定时任务已按计划触发。请立即完成下面的任务,不要只确认收到,"
f"也不要重复创建同一个定时任务。\n\n"
f"任务名称:{task.name}\n"
f"任务内容:{task.content}\n\n"
"完成后请直接向用户发送消息报告本次执行结果;如果无法完成,也需发送消息说明原因。"
)
success = True
result = ""
notification_username = task.username or settings.SUPERUSER
try:
result = await self.process_message(
session_id=task.session_id,
user_id=task.user_id,
message=task_message,
channel=None,
source=None,
username=notification_username,
original_chat_id=None,
reply_mode=ReplyMode.DISPATCH,
allow_message_tools=True,
wait_for_completion=True,
)
result_text = str(result or "").strip()
success = not result_text.startswith(
(AGENT_EXECUTION_ERROR_PREFIX, "处理消息时发生错误")
)
except Exception as err:
success = False
result = f"Agent 定时任务执行失败:{str(err)}"
logger.error(f"Agent 定时任务 {task_id} 执行失败: {str(err)}")
await AgentChain().async_post_message(
Notification(
mtype=NotificationType.Agent,
username=notification_username,
title=f"定时任务执行失败:{task.name}",
text=result,
save_history=False,
)
)
finally:
current_task = oper.get(task_id)
oper.finish(
task_id=task_id,
success=success,
result=str(result or ""),
disable=bool(
current_task
and task.trigger_type == "date"
and current_task.trigger_type == task.trigger_type
and current_task.run_at == task.run_at
),
)
return success, str(result or "任务执行完成")
@staticmethod
def _build_heartbeat_prompt() -> str:
"""使用程序内置 System Tasks 定义构建心跳任务提示词。"""

View File

@@ -536,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:
@@ -581,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

@@ -5,13 +5,17 @@ import inspect
import json
import time
from functools import wraps
from typing import Any, List, Optional
from typing import TYPE_CHECKING, Any, List, Optional
from urllib.parse import urlsplit
from langchain_core.messages import AIMessage, AIMessageChunk
from app.core.config import settings
from app.log import logger
if TYPE_CHECKING:
from app.agent.llm.server_tools import ServerToolResolution
class LLMTestError(RuntimeError):
"""LLM 测试调用异常,附带请求耗时。"""
@@ -224,74 +228,76 @@ def _is_deepseek_thinking_enabled(model_name: str | None, extra_body: Any) -> bo
return False
def _patch_deepseek_reasoning_content_support():
"""
修补 langchain-deepseek 在 tool-call 场景下遗漏 reasoning_content 回传的问题。
DeepSeek thinking mode 要求:若 assistant 历史消息包含 tool_calls
后续请求中必须带回该条消息的顶层 reasoning_content。
某些 langchain-deepseek 版本虽然能从响应中拿到 reasoning_content
但不会在重放消息历史时写回请求载荷,导致 400。
"""
try:
from langchain_deepseek import ChatDeepSeek
except Exception as err:
logger.debug(f"跳过 langchain-deepseek reasoning_content 修补:{err}")
def _patch_interleaved_reasoning_request_support(
model_cls: Any,
*,
patch_marker: str,
thinking_filter: Any = None,
normalize_deepseek_messages: bool = False,
inject_missing_as_empty: bool = False,
) -> None:
"""为兼容模型统一补回工具调用历史中的 reasoning_content。"""
if getattr(model_cls, patch_marker, False):
return
if getattr(ChatDeepSeek, "_moviepilot_reasoning_content_patched", False):
return
original_get_request_payload = getattr(ChatDeepSeek, "_get_request_payload", None)
original_get_request_payload = getattr(model_cls, "_get_request_payload", None)
if not callable(original_get_request_payload):
logger.warning("langchain-deepseek 缺少 _get_request_payload无法修补 reasoning_content")
logger.warning(
f"{model_cls.__name__} 缺少 _get_request_payload无法修补 reasoning_content"
)
return
@wraps(original_get_request_payload)
def _patched_get_request_payload(self, input_, *, stop=None, **kwargs):
payload = original_get_request_payload(self, input_, stop=stop, **kwargs)
if "messages" not in payload:
return payload
extra_body = (getattr(self, "model_kwargs", None) or {}).get("extra_body")
if not _is_deepseek_thinking_enabled(
extra_body = getattr(self, "extra_body", None)
if extra_body is None:
extra_body = (getattr(self, "model_kwargs", None) or {}).get("extra_body")
if thinking_filter is not None and not thinking_filter(
getattr(self, "model_name", None) or getattr(self, "model", None),
extra_body,
):
return payload
# 从原始 LangChain 消息中取回 reasoning_content。上游 payload 构造器
# 不会自动透传这个 DeepSeek 扩展字段。
messages = self._convert_input(input_).to_messages()
for i, message in enumerate(payload["messages"]):
if message["role"] == "tool" and isinstance(message["content"], list):
message["content"] = json.dumps(message["content"])
elif message["role"] == "assistant":
if isinstance(message["content"], list):
# DeepSeek API 要求 assistant content 为字符串;工具场景下
# LangChain 可能保留为内容块列表,这里只拼回可见文本块。
text_parts = [
block.get("text", "")
for block in message["content"]
if isinstance(block, dict) and block.get("type") == "text"
]
message["content"] = "".join(text_parts) if text_parts else ""
# DeepSeek thinking mode 要求历史 assistant 消息携带
# reasoning_content即便本地只保存到了 additional_kwargs。
if (
"reasoning_content" not in message
and i < len(messages)
and isinstance(messages[i], AIMessage)
for index, payload_message in enumerate(payload["messages"]):
if normalize_deepseek_messages:
if payload_message.get("role") == "tool" and isinstance(
payload_message.get("content"), list
):
message["reasoning_content"] = messages[i].additional_kwargs.get(
"reasoning_content", ""
payload_message["content"] = json.dumps(payload_message["content"])
elif payload_message.get("role") == "assistant" and isinstance(
payload_message.get("content"), list
):
payload_message["content"] = "".join(
block.get("text", "")
for block in payload_message["content"]
if isinstance(block, dict) and block.get("type") == "text"
)
if (
payload_message.get("role") != "assistant"
or index >= len(messages)
or not isinstance(messages[index], AIMessage)
or "reasoning_content" in payload_message
):
continue
reasoning_content = messages[index].additional_kwargs.get(
"reasoning_content"
)
if reasoning_content is not None:
payload_message["reasoning_content"] = reasoning_content
elif inject_missing_as_empty:
payload_message["reasoning_content"] = ""
return payload
ChatDeepSeek._get_request_payload = _patched_get_request_payload
ChatDeepSeek._moviepilot_reasoning_content_patched = True
logger.debug("已修补 langchain-deepseek thinking tool-call 的 reasoning_content 回传兼容性")
model_cls._get_request_payload = _patched_get_request_payload
setattr(model_cls, patch_marker, True)
def _patch_openai_interleaved_reasoning_content_support():
@@ -352,42 +358,10 @@ def _patch_openai_interleaved_reasoning_content_support():
_openai_base._moviepilot_reasoning_response_patched = True
if getattr(ChatOpenAI, "_moviepilot_interleaved_reasoning_patched", False):
return
original_get_request_payload = getattr(ChatOpenAI, "_get_request_payload", None)
if not callable(original_get_request_payload):
logger.warning("langchain-openai 缺少 _get_request_payload无法修补 reasoning_content")
return
@wraps(original_get_request_payload)
def _patched_get_request_payload(self, input_, *, stop=None, **kwargs):
payload = original_get_request_payload(self, input_, stop=stop, **kwargs)
if "messages" not in payload:
return payload
messages = self._convert_input(input_).to_messages()
for index, payload_message in enumerate(payload["messages"]):
if (
payload_message.get("role") != "assistant"
or index >= len(messages)
or not isinstance(messages[index], AIMessage)
or "reasoning_content" in payload_message
):
continue
reasoning_content = messages[index].additional_kwargs.get(
"reasoning_content"
)
if reasoning_content is not None:
# 只回传模型真实返回过的思考字段。普通模型没有该字段时,
# payload 保持原样,不额外塞未知参数。
payload_message["reasoning_content"] = reasoning_content
return payload
ChatOpenAI._get_request_payload = _patched_get_request_payload
ChatOpenAI._moviepilot_interleaved_reasoning_patched = True
_patch_interleaved_reasoning_request_support(
ChatOpenAI,
patch_marker="_moviepilot_interleaved_reasoning_patched",
)
logger.debug("已修补 langchain-openai interleaved reasoning_content 回传兼容性")
@@ -840,25 +814,122 @@ class LLMHelper:
headers["User-Agent"] = normalized_user_agent
return headers or None
@staticmethod
def _matches_endpoint_host(base_url: str | None, expected_host: str) -> bool:
"""严格匹配官方 API 主机,避免向兼容端点发送供应商专属参数。"""
try:
return (urlsplit(str(base_url or "")).hostname or "").lower() == expected_host
except ValueError:
return False
@classmethod
def _build_openai_prompt_cache_options(
cls,
*,
provider: str,
base_url: str | None,
use_responses_api: bool | None,
prompt_cache_key: str | None,
default_headers: dict[str, str] | None,
model_kwargs: dict[str, Any],
) -> tuple[dict[str, str] | None, dict[str, Any]]:
"""为 OpenAI 与 xAI 官方端点构造稳定提示词缓存路由参数。"""
cache_key = str(prompt_cache_key or "").strip()
headers = dict(default_headers or {})
kwargs = dict(model_kwargs)
provider_name = str(provider or "").strip().lower()
if not cache_key:
return headers or None, kwargs
is_openai = provider_name in {"chatgpt", "openai"} and cls._matches_endpoint_host(
base_url,
"api.openai.com",
)
is_xai = provider_name == "xai" and cls._matches_endpoint_host(
base_url,
"api.x.ai",
)
if not is_openai and not is_xai:
return headers or None, kwargs
if is_xai and use_responses_api is not True:
headers["x-grok-conv-id"] = cache_key
return headers, kwargs
extra_body = dict(kwargs.get("extra_body") or {})
extra_body["prompt_cache_key"] = cache_key
kwargs["extra_body"] = extra_body
return headers or None, kwargs
@staticmethod
def _with_prompt_cache_control(
model_cls: type,
cache_control: dict[str, str],
) -> type:
"""创建在最终模型绑定阶段保留缓存控制参数的适配类。"""
class PromptCachingModel(model_cls):
"""在 LangChain 工具绑定后仍保留提示词缓存参数的模型适配器。"""
def bind(self, **kwargs: Any) -> Any:
"""绑定调用参数,并补入当前 Provider 的默认缓存控制。"""
kwargs.setdefault("cache_control", dict(cache_control))
return super().bind(**kwargs)
PromptCachingModel.__name__ = f"PromptCaching{model_cls.__name__}"
return PromptCachingModel
@classmethod
def _use_anthropic_prompt_cache(
cls,
*,
provider: str,
runtime: dict[str, Any],
prompt_cache_key: str | None,
) -> bool:
"""判断当前运行时是否为可安全启用缓存的 Anthropic 官方端点。"""
return (
bool(str(prompt_cache_key or "").strip())
and str(provider or "").strip().lower() == "anthropic"
and str(runtime.get("runtime") or "").strip().lower()
== "anthropic_compatible"
and cls._matches_endpoint_host(
runtime.get("base_url"),
"api.anthropic.com",
)
)
@classmethod
def _should_use_openai_responses_api(
cls,
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
@@ -872,6 +943,18 @@ 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:
"""
@@ -907,6 +990,36 @@ class LLMHelper:
profile["moviepilot_provider_id"] = runtime_metadata["provider_id"]
profile["moviepilot_base_url"] = runtime_metadata["base_url"]
@staticmethod
def _attach_server_tool_metadata(
model: Any,
resolution: "ServerToolResolution",
) -> None:
"""把服务端工具解析结果挂到模型实例,供 Agent 组装工具列表。"""
metadata = {
"mode": resolution.mode,
"use_local_web_search": resolution.use_local_web_search,
"server_tools": [dict(tool) for tool in resolution.server_tools],
"available": resolution.available,
"reason": resolution.reason,
}
try:
setattr(model, "_moviepilot_server_tool_metadata", metadata)
except Exception:
object.__setattr__(model, "_moviepilot_server_tool_metadata", metadata)
@staticmethod
def get_server_tools(model: Any) -> list[dict[str, Any]]:
"""读取模型已解析的服务端工具定义。"""
metadata = getattr(model, "_moviepilot_server_tool_metadata", {}) or {}
return [dict(tool) for tool in metadata.get("server_tools", [])]
@staticmethod
def should_use_local_web_search(model: Any) -> bool:
"""判断当前模型是否应保留 MoviePilot 本地联网搜索工具。"""
metadata = getattr(model, "_moviepilot_server_tool_metadata", {}) or {}
return bool(metadata.get("use_local_web_search", True))
@classmethod
def _resolve_thinking_level(
cls,
@@ -952,7 +1065,11 @@ 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,
web_search_mode: str | None = None,
prompt_cache_key: str | None = None,
):
"""
获取LLM实例
@@ -967,7 +1084,16 @@ 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`` 保持原有自动判断。
:param web_search_mode: 联网搜索模式
local/builtin/auto/disabled。未显式传入时使用配置项
``LLM_WEB_SEARCH_MODE``。
:param prompt_cache_key: 同一 Agent 会话内稳定且脱敏的提示词缓存路由键。
:return: LLM实例
"""
provider_name = str(provider if provider is not None else settings.LLM_PROVIDER).lower()
@@ -978,6 +1104,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,
)
@@ -1005,6 +1132,40 @@ class LLMHelper:
user_agent=user_agent_value,
)
model_name = runtime.get("model_id") or model_name
from app.agent.llm.server_tools import (
ServerToolRegistry,
ServerToolUnavailableError,
)
server_tool_resolution = ServerToolRegistry.resolve_web_search(
provider=provider_name,
model=model_name,
mode=(
web_search_mode
if web_search_mode is not None
else getattr(settings, "LLM_WEB_SEARCH_MODE", "local")
),
api_protocol=(
api_protocol
if api_protocol is not None
else settings.LLM_API_PROTOCOL
),
base_url=runtime.get("base_url"),
)
if (
server_tool_resolution.mode == "builtin"
and not server_tool_resolution.available
):
raise ServerToolUnavailableError(
provider=provider_name,
model=str(model_name or ""),
tool_id="web_search",
)
effective_api_protocol = (
server_tool_resolution.required_api_protocol
if server_tool_resolution.required_api_protocol == "responses"
else api_protocol
)
default_headers = cls._build_openai_default_headers(
runtime.get("default_headers"),
user_agent=user_agent_value,
@@ -1018,6 +1179,15 @@ class LLMHelper:
provider=provider_name,
model=model_name,
runtime=runtime,
api_protocol=effective_api_protocol,
)
default_headers, openai_model_kwargs = cls._build_openai_prompt_cache_options(
provider=provider_name,
base_url=runtime.get("base_url"),
use_responses_api=use_responses_api,
prompt_cache_key=prompt_cache_key,
default_headers=default_headers,
model_kwargs=thinking_kwargs,
)
llm_proxy = _resolve_llm_proxy(use_proxy)
@@ -1034,36 +1204,92 @@ 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,
)
elif runtime["runtime"] == "deepseek":
elif (
runtime["runtime"] == "deepseek"
and server_tool_resolution.client_adapter != "openai_responses"
and use_responses_api is not True
):
from langchain_deepseek import ChatDeepSeek
_patch_deepseek_reasoning_content_support()
_patch_interleaved_reasoning_request_support(
ChatDeepSeek,
patch_marker="_moviepilot_reasoning_content_patched",
thinking_filter=lambda model_name, extra_body: (
_is_deepseek_thinking_enabled(model_name, extra_body)
),
normalize_deepseek_messages=True,
inject_missing_as_empty=True,
)
model = ChatDeepSeek(
model=model_name,
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
bedrock_model_cls = ChatBedrockConverse
if (
str(prompt_cache_key or "").strip()
and runtime.get("supports_prompt_cache")
):
bedrock_model_cls = cls._with_prompt_cache_control(
ChatBedrockConverse,
{"type": "default"},
)
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 = bedrock_model_cls(
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
model = ChatAnthropic(
anthropic_model_cls = ChatAnthropic
if cls._use_anthropic_prompt_cache(
provider=provider_name,
runtime=runtime,
prompt_cache_key=prompt_cache_key,
):
anthropic_model_cls = cls._with_prompt_cache_control(
ChatAnthropic,
{"type": "ephemeral"},
)
model = anthropic_model_cls(
model=model_name,
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,
@@ -1084,7 +1310,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,
@@ -1098,13 +1324,18 @@ class LLMHelper:
),
default_headers=default_headers,
use_responses_api=use_responses_api,
**thinking_kwargs,
output_version=("responses/v1" if use_responses_api else None),
**openai_model_kwargs,
)
# 优先使用 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 {}
@@ -1121,6 +1352,7 @@ class LLMHelper:
}
cls._attach_runtime_metadata(model, runtime)
cls._attach_server_tool_metadata(model, server_tool_resolution)
return model
@staticmethod
@@ -1178,25 +1410,38 @@ 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,
web_search_mode: str | None = None,
) -> dict:
"""
使用当前已保存配置执行一次最小 LLM 调用。
使用当前配置或显式传入的临时配置执行一次最小 LLM 调用。
:param temperature: LLM 温度参数。未显式传入时沿用已保存配置。
:param api_protocol: OpenAI 兼容接口 API 协议,未显式传入时沿用已保存配置。
:param web_search_mode: 联网搜索模式,未显式传入时沿用已保存配置。
"""
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,
"web_search_mode": web_search_mode,
}
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:
@@ -1240,7 +1485,7 @@ class LLMHelper:
try:
from app.agent.llm.provider import LLMProviderManager
return await LLMProviderManager().list_models(
models = await LLMProviderManager().list_models(
provider_id=provider,
api_key=api_key,
base_url=base_url,
@@ -1249,16 +1494,25 @@ class LLMHelper:
use_proxy=use_proxy,
force_refresh=force_refresh,
)
return self._attach_server_tool_capabilities(
provider,
models,
base_url=base_url,
)
except Exception as err:
logger.debug(f"LLM provider 目录不可用,回退旧模型列表逻辑: {err}")
if provider == "google":
return [
{"id": model_id, "name": model_id}
for model_id in await self._get_google_models(
api_key or "",
use_proxy=use_proxy,
)
]
return self._attach_server_tool_capabilities(
provider,
[
{"id": model_id, "name": model_id}
for model_id in await self._get_google_models(
api_key or "",
use_proxy=use_proxy,
)
],
base_url=base_url,
)
try:
from app.agent.llm.provider import LLMProviderManager
@@ -1272,16 +1526,40 @@ class LLMHelper:
)
except Exception:
model_list_base_url = base_url
return [
{"id": model_id, "name": model_id}
for model_id in await self._get_openai_compatible_models(
provider,
api_key or "",
model_list_base_url,
user_agent=user_agent,
use_proxy=use_proxy,
)
]
return self._attach_server_tool_capabilities(
provider,
[
{"id": model_id, "name": model_id}
for model_id in await self._get_openai_compatible_models(
provider,
api_key or "",
model_list_base_url,
user_agent=user_agent,
use_proxy=use_proxy,
)
],
base_url=base_url,
)
@staticmethod
def _attach_server_tool_capabilities(
provider: str,
models: List[dict[str, Any]],
base_url: Optional[str] = None,
) -> List[dict[str, Any]]:
"""为模型目录附加通用服务端工具能力元数据。"""
from app.agent.llm.server_tools import ServerToolRegistry
result = []
for item in models:
model_item = dict(item)
model_item["server_tools"] = ServerToolRegistry.list_capabilities(
provider=provider,
model=str(model_item.get("id") or ""),
base_url=base_url,
)
result.append(model_item)
return result
@staticmethod
async def _get_google_models(api_key: str, use_proxy: bool | None = None) -> List[str]:

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",
@@ -1536,6 +1665,20 @@ class LLMProviderManager(metaclass=Singleton):
await self.get_models_dev_data(use_proxy=use_proxy)
).get(models_dev_provider_id, {}) or {}
@staticmethod
def _models_dev_model_candidates(
provider_id: str,
model_id: str,
) -> tuple[str, ...]:
"""生成模型目录查询候选,兼容 Provider 添加的透明模型前缀。"""
candidates = [model_id]
if model_id.startswith("models/"):
candidates.append(model_id.removeprefix("models/"))
if provider_id == "amazon-bedrock" and "." in model_id:
# Cross-region Inference Profile 会增加 us./eu./global. 等前缀。
candidates.append(model_id.split(".", 1)[1])
return tuple(dict.fromkeys(candidates))
async def _models_dev_model(
self,
provider_id: str,
@@ -1555,15 +1698,32 @@ class LLMProviderManager(metaclass=Singleton):
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:
for candidate in self._models_dev_model_candidates(provider_id, model_id):
if candidate in models:
return models[candidate]
return None
@staticmethod
def _metadata_supports_prompt_cache(metadata: Any) -> bool:
"""从统一模型元数据中判断是否声明了提示词缓存能力。"""
if not isinstance(metadata, dict):
return False
explicit_capability = metadata.get("prompt_cache")
if isinstance(explicit_capability, bool):
return explicit_capability
capabilities = metadata.get("capabilities")
if isinstance(capabilities, dict):
explicit_capability = capabilities.get("prompt_cache")
if isinstance(explicit_capability, bool):
return explicit_capability
cost = metadata.get("cost")
return isinstance(cost, dict) and any(
key in cost for key in ("cache_read", "cache_write")
)
def _cached_models_dev_model(
self,
provider_id: str,
@@ -1590,11 +1750,7 @@ class LLMProviderManager(metaclass=Singleton):
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:
for candidate in self._models_dev_model_candidates(provider_id, model_id):
if candidate in models:
return models[candidate]
return None
@@ -1743,6 +1899,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,
@@ -1857,6 +2119,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
@@ -2064,6 +2555,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,
@@ -2641,6 +3139,9 @@ class LLMProviderManager(metaclass=Singleton):
"model_id": model,
"model_record": model_record,
"model_metadata": model_metadata,
"supports_prompt_cache": self._metadata_supports_prompt_cache(
model_metadata
),
"default_headers": None,
"use_responses_api": None,
"auth_mode": "api_key",
@@ -2731,6 +3232,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

View File

@@ -0,0 +1,249 @@
"""LLM 服务端工具能力注册与解析。"""
from dataclasses import dataclass
from fnmatch import fnmatch
from typing import Any, Optional
WEB_SEARCH_MODES = frozenset({"local", "builtin", "auto", "disabled"})
class ServerToolUnavailableError(ValueError):
"""表示用户强制选择了当前模型不可用的服务端工具。"""
def __init__(self, *, provider: str, model: str, tool_id: str) -> None:
"""初始化服务端工具不可用异常。"""
self.provider = provider
self.model = model
self.tool_id = tool_id
super().__init__(
f"当前模型 {provider}/{model} 或接口地址不支持服务端联网搜索,"
"请改用“自动”或“MoviePilot 本地搜索”"
)
@dataclass(frozen=True)
class ServerToolCapability:
"""描述一个模型可用的服务端工具能力。"""
tool_id: str
provider_ids: tuple[str, ...]
model_patterns: tuple[str, ...]
required_api_protocol: str
client_adapter: str
tool_definition: dict[str, Any]
base_url_patterns: tuple[str, ...] = ()
match_without_base_url: bool = True
def matches(self, provider: str, model: str, base_url: Optional[str] = None) -> bool:
"""判断给定 provider/model 是否匹配当前能力。"""
normalized_provider = str(provider or "").strip().lower()
normalized_model = str(model or "").strip().lower().removeprefix("models/")
normalized_base_url = str(base_url or "").strip().lower()
return (
normalized_provider in self.provider_ids
and any(fnmatch(normalized_model, pattern) for pattern in self.model_patterns)
and (
(not normalized_base_url and self.match_without_base_url)
or not self.base_url_patterns
or any(
pattern in normalized_base_url
for pattern in self.base_url_patterns
)
)
)
def serialize(self) -> dict[str, Any]:
"""返回供 API 与前端使用的能力元数据。"""
return {
"id": self.tool_id,
"required_api_protocol": self.required_api_protocol,
"client_adapter": self.client_adapter,
}
@dataclass(frozen=True)
class ServerToolResolution:
"""记录本次联网搜索模式解析后的执行策略。"""
mode: str
use_local_web_search: bool
server_tools: tuple[dict[str, Any], ...] = ()
client_adapter: Optional[str] = None
required_api_protocol: Optional[str] = None
available: bool = False
reason: Optional[str] = None
class ServerToolRegistry:
"""集中注册模型服务端工具,并解析通用执行策略。"""
_CAPABILITIES = (
ServerToolCapability(
tool_id="web_search",
provider_ids=("chatgpt",),
model_patterns=("gpt-5*", "gpt-4.1*", "o4-mini*"),
base_url_patterns=("api.openai.com",),
required_api_protocol="responses",
client_adapter="openai_responses",
tool_definition={"type": "web_search"},
),
ServerToolCapability(
tool_id="web_search",
provider_ids=("openai",),
model_patterns=("gpt-5*", "gpt-4.1*", "o4-mini*"),
base_url_patterns=("api.openai.com",),
required_api_protocol="responses",
client_adapter="openai_responses",
tool_definition={"type": "web_search"},
match_without_base_url=False,
),
ServerToolCapability(
tool_id="web_search",
provider_ids=("anthropic",),
model_patterns=(
"claude-opus-4*",
"claude-sonnet-4*",
"claude-haiku-4*",
"claude-opus-5*",
"claude-sonnet-5*",
"claude-haiku-5*",
"claude-fable-5*",
"claude-mythos-5*",
),
base_url_patterns=("api.anthropic.com",),
required_api_protocol="native",
client_adapter="anthropic_native",
tool_definition={
"type": "web_search_20250305",
"name": "web_search",
},
),
ServerToolCapability(
tool_id="web_search",
provider_ids=("google",),
model_patterns=("gemini-3*", "gemini-2.5*", "gemini-2.0-flash*"),
required_api_protocol="native",
client_adapter="google_native",
tool_definition={"google_search": {}},
),
ServerToolCapability(
tool_id="web_search",
provider_ids=("xai",),
model_patterns=("grok-4.5*",),
base_url_patterns=("api.x.ai",),
required_api_protocol="responses",
client_adapter="openai_responses",
tool_definition={"type": "web_search"},
),
ServerToolCapability(
tool_id="web_search",
provider_ids=("deepseek",),
model_patterns=("deepseek-v4-flash",),
base_url_patterns=("api.deepseek.com",),
required_api_protocol="responses",
client_adapter="openai_responses",
tool_definition={"type": "web_search"},
),
)
@classmethod
def normalize_web_search_mode(cls, mode: Optional[str]) -> str:
"""规范化联网搜索模式,未知值回退为本地搜索。"""
normalized = str(mode or "local").strip().lower()
return normalized if normalized in WEB_SEARCH_MODES else "local"
@classmethod
def get_capability(
cls,
*,
provider: str,
model: str,
base_url: Optional[str] = None,
tool_id: str,
) -> Optional[ServerToolCapability]:
"""查找指定模型的服务端工具能力。"""
return next(
(
capability
for capability in cls._CAPABILITIES
if capability.tool_id == tool_id
and capability.matches(provider, model, base_url)
),
None,
)
@classmethod
def list_capabilities(
cls,
*,
provider: str,
model: str,
base_url: Optional[str] = None,
) -> list[dict[str, Any]]:
"""列出指定模型可用的服务端工具能力。"""
return [
capability.serialize()
for capability in cls._CAPABILITIES
if capability.matches(provider, model, base_url)
]
@classmethod
def resolve_web_search(
cls,
*,
provider: str,
model: str,
mode: Optional[str],
api_protocol: Optional[str],
base_url: Optional[str] = None,
) -> ServerToolResolution:
"""解析联网搜索应使用本地工具还是模型服务端工具。"""
normalized_mode = cls.normalize_web_search_mode(mode)
normalized_protocol = str(api_protocol or "auto").strip().lower()
capability = cls.get_capability(
provider=provider,
model=model,
base_url=base_url,
tool_id="web_search",
)
if normalized_mode == "disabled":
return ServerToolResolution(
mode=normalized_mode,
use_local_web_search=False,
reason="web_search_disabled",
)
if normalized_mode == "local":
return ServerToolResolution(
mode=normalized_mode,
use_local_web_search=True,
reason="local_web_search_selected",
)
if capability is None:
return ServerToolResolution(
mode=normalized_mode,
use_local_web_search=normalized_mode == "auto",
reason="builtin_web_search_unavailable",
)
if (
normalized_mode == "auto"
and normalized_protocol == "chat_completions"
and capability.required_api_protocol == "responses"
):
return ServerToolResolution(
mode=normalized_mode,
use_local_web_search=True,
available=True,
reason="chat_completions_uses_local_fallback",
)
return ServerToolResolution(
mode=normalized_mode,
use_local_web_search=False,
server_tools=(dict(capability.tool_definition),),
client_adapter=capability.client_adapter,
required_api_protocol=capability.required_api_protocol,
available=True,
reason="builtin_web_search_selected",
)

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

@@ -3,9 +3,10 @@
按日期存储在 CONFIG_PATH/agent/activity/YYYY-MM-DD.md 中,
每次 Agent 执行完毕后自动调用 LLM 对本轮对话生成简洁的活动摘要,
并在每次 Agent 启动时注入轻量索引,完整日志由工具按需查询。
系统提示词只注入稳定的检索规则,完整日志由工具按需查询。
"""
import asyncio
import json
import os
import re
@@ -88,10 +89,6 @@ ACTIVITY_ENTRY_PATTERN = re.compile(r"^-\s+\*\*(?P<time>\d{2}:\d{2})\*\*\s+(?P<s
class QueryActivityLogInput(BaseModel):
"""查询活动日志工具的输入参数模型。"""
explanation: Optional[str] = Field(
None,
description="Clear explanation of why this tool is being used in the current context",
)
keyword: Optional[str] = Field(
None,
description=(
@@ -288,17 +285,15 @@ class _ActivityLogToolProvider:
date: Optional[str] = None,
days: Optional[int] = DEFAULT_QUERY_DAYS,
limit: Optional[int] = DEFAULT_QUERY_LIMIT,
explanation: Optional[str] = None,
) -> str:
"""查询活动日志并返回 JSON 字符串。"""
logger.info(
"查询活动日志: keyword=%s, use_regex=%s, date=%s, days=%s, limit=%s, explanation=%s",
"查询活动日志: keyword=%s, use_regex=%s, date=%s, days=%s, limit=%s",
keyword,
use_regex,
date,
days,
limit,
explanation or "-",
)
try:
payload = query_activity_logs(
@@ -452,7 +447,7 @@ async def _summarize_with_llm(conversation_text: str) -> Optional[str]:
llm = await LLMHelper.get_llm(streaming=False)
prompt = SUMMARY_PROMPT.format(conversation=conversation_text)
response = await llm.ainvoke(prompt)
summary = response.content.strip()
summary = LLMHelper.extract_text_content(response.content).strip()
# 清理模型可能输出的前缀(如 "摘要:" "总结:"
summary = re.sub(r"^(摘要|总结|活动记录)[:]\s*", "", summary)
if summary.strip().upper() == SUMMARY_SKIP_MARKER:
@@ -464,12 +459,8 @@ async def _summarize_with_llm(conversation_text: str) -> Optional[str]:
ACTIVITY_LOG_SYSTEM_PROMPT = """<activity_log>
<activity_log_index>
{activity_log_index}
</activity_log_index>
<activity_log_guidelines>
The index only shows recent dates and entry counts, not full log contents.
Activity log contents and indexes are not included in the default context.
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>
@@ -478,10 +469,10 @@ ACTIVITY_LOG_SYSTEM_PROMPT = """<activity_log>
class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, ResponseT]): # noqa
"""自动记录 Agent 活动日志并注入轻量索引的中间件。
"""自动记录 Agent 活动日志并注入稳定检索规则的中间件。
- abefore_agent: 加载近几天的活动日志索引
- awrap_model_call: 将活动日志索引和检索规则注入系统提示词
- awrap_model_call: 将固定的活动日志检索规则注入系统提示词
- aafter_agent: 从本次对话中提取摘要并追加到当日日志文件
参数:
@@ -505,6 +496,7 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
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(
@@ -520,31 +512,9 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
"""获取指定日期的日志文件路径。"""
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_index="(近期暂无活动日志索引。需要历史上下文时可调用 query_activity_log。)",
retention_days=self.retention_days,
)
# 按日期排序(最近的在前)
sorted_dates = sorted(contents.keys(), reverse=True)
sections = []
for date_str in sorted_dates:
content = contents[date_str].strip()
if content:
sections.append(f"### {date_str}\n{content}")
if not sections:
return ACTIVITY_LOG_SYSTEM_PROMPT.format(
activity_log_index="(近期暂无活动日志索引。需要历史上下文时可调用 query_activity_log。)",
retention_days=self.retention_days,
)
log_body = "\n".join(sections)
def _format_activity_log(self, _contents: dict[str, str]) -> str:
"""生成不受活动日志内容变化影响的系统提示词。"""
return ACTIVITY_LOG_SYSTEM_PROMPT.format(
activity_log_index=log_body,
retention_days=self.retention_days,
)
@@ -631,6 +601,44 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
except Exception as 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
) -> Optional[ActivityLogStateUpdate]:
@@ -699,28 +707,12 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
async def aafter_agent(
self, state: ActivityLogState, runtime: Runtime
) -> Optional[dict[str, Any]]:
"""Agent 执行完毕后,调用 LLM 对本轮对话生成摘要并追加到当日活动日志"""
"""Agent 执行完毕后,异步提交活动日志记录任务"""
try:
messages = state.get("messages", [])
if not messages:
return None
# 提取本轮交互
round_messages = _extract_last_round(messages)
if not round_messages:
return None
if _should_skip_activity_summary(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(f"Failed to record activity: {e}")

View File

@@ -204,9 +204,14 @@ You have a scheduled jobs system for user-requested delayed or recurring work.
{jobs_list}
Rules:
- Create jobs only when the user asks for delayed, recurring, reminder, or monitoring behavior.
- Do not create jobs for immediate one-time work or work already handled by MoviePilot schedulers.
- Each job lives in its own directory with a `JOB.md`; read the listed file before executing or updating an active job.
- 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>
"""
@@ -230,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:

View File

@@ -27,8 +27,10 @@ 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 攻击
MAX_SKILL_FILE_SIZE = 10 * 1024 * 1024
# 磁盘读取上限与模型返回上限分离,避免异常大的 Skill 文件撑爆内存或上下文。
MAX_SKILL_FILE_SIZE = 1 * 1024 * 1024
MAX_SKILL_RESULT_CHARS = 64 * 1024
SKILL_CONTENT_TRUNCATION_SUFFIX = "\n...(Skill 内容已截断)"
# Agent Skills 规范约束 (https://agentskills.io/specification)
MAX_SKILL_NAME_LENGTH = 64
@@ -92,10 +94,6 @@ class SkillsStateUpdate(TypedDict):
class SkillToolInput(BaseModel):
"""Skill 加载工具的输入参数模型。"""
explanation: Optional[str] = Field(
None,
description="Clear explanation of why this skill is needed in the current context",
)
name: str = Field(
...,
description="Skill name or id from the available skills list.",
@@ -252,7 +250,17 @@ 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", errors="replace")
stat = await skill_md_path.stat()
if stat.st_size > MAX_SKILL_FILE_SIZE:
logger.warning(
"Skipping %s: file too large (%d bytes)",
skill_md_path,
stat.st_size,
)
continue
skill_content = (await skill_md_path.read_bytes()).decode(
"utf-8", errors="replace"
)
# 解析元数据
skill_metadata = _parse_skill_metadata(
@@ -284,7 +292,16 @@ def _list_skills(source_path: Path) -> list[SkillMetadata]:
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")
if skill_md_path.stat().st_size > MAX_SKILL_FILE_SIZE:
logger.warning(
"Skipping %s: file too large (%d bytes)",
skill_md_path,
skill_md_path.stat().st_size,
)
continue
skill_content = skill_md_path.read_bytes().decode(
"utf-8", errors="replace"
)
skill_metadata = _parse_skill_metadata(
content=skill_content,
skill_path=str(skill_md_path),
@@ -460,9 +477,49 @@ class _SkillToolProvider:
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, explanation: Optional[str] = None) -> str:
@staticmethod
def _serialize_skill_payload(payload: dict[str, Any]) -> str:
"""序列化 Skill 返回值,并严格限制最终进入模型的字符数。"""
serialized = json.dumps(payload, ensure_ascii=False, indent=2)
if len(serialized) <= MAX_SKILL_RESULT_CHARS:
return serialized
original_content = str(payload.get("content") or "")
truncated_payload = dict(payload)
truncated_payload["truncated"] = True
low = 0
high = len(original_content)
best_result = json.dumps(
{
**truncated_payload,
"content": SKILL_CONTENT_TRUNCATION_SUFFIX.strip(),
},
ensure_ascii=False,
indent=2,
)
while low <= high:
middle = (low + high) // 2
candidate = json.dumps(
{
**truncated_payload,
"content": (
original_content[:middle]
+ SKILL_CONTENT_TRUNCATION_SUFFIX
),
},
ensure_ascii=False,
indent=2,
)
if len(candidate) <= MAX_SKILL_RESULT_CHARS:
best_result = candidate
low = middle + 1
else:
high = middle - 1
return best_result
async def load_skill(self, name: str) -> str:
"""加载指定 Skill 的完整说明并返回 JSON 字符串。"""
logger.info(f"加载 Skill: name={name}, explanation={explanation or '-'}")
logger.info(f"加载 Skill: name={name}")
try:
skill = await self._find_skill(name)
if not skill:
@@ -475,7 +532,7 @@ class _SkillToolProvider:
)
content, truncated = await self._read_skill_content(skill["path"])
return json.dumps(
return self._serialize_skill_payload(
{
"success": True,
"skill": {
@@ -487,9 +544,7 @@ class _SkillToolProvider:
},
"content": content,
"truncated": truncated,
},
ensure_ascii=False,
indent=2,
}
)
except Exception as err:
logger.error(f"加载 Skill 失败: {err}", exc_info=True)
@@ -674,8 +729,7 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no
if not isinstance(tool_args, dict):
tool_args = {}
logger.info(
f"开始执行 Skill 工具: name={tool_args.get('name') or '-'}, "
f"explanation={tool_args.get('explanation') or '-'}"
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(

View File

@@ -377,11 +377,13 @@ class _SubAgentAgentProvider:
model: BaseChatModel,
profiles: tuple[_SubAgentProfile, ...],
tools: list[BaseTool],
server_tools: Optional[list[dict[str, Any]]] = None,
) -> None:
"""初始化子代理执行器。"""
self._model = model
self._profiles = {profile.name: profile for profile in profiles}
self._tools = tools
self._server_tools = server_tools or []
self._agents = {}
self._default_agent_name = "general-purpose"
@@ -404,7 +406,7 @@ class _SubAgentAgentProvider:
)
agent = create_agent(
model=self._model,
tools=subagent_tools,
tools=[*subagent_tools, *self._server_tools],
system_prompt=profile.prompt,
name=profile.name,
)
@@ -462,16 +464,19 @@ class MoviePilotSubAgentMiddleware(AgentMiddleware):
model: BaseChatModel,
profiles: tuple[_SubAgentProfile, ...],
tools: list[BaseTool],
server_tools: Optional[list[dict[str, Any]]] = None,
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,
tools=tools,
server_tools=server_tools,
)
self.tools = [
StructuredTool.from_function(
@@ -549,6 +554,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
model: BaseChatModel,
profiles: tuple[_SubAgentProfile, ...],
tools: list[BaseTool],
server_tools: Optional[list[dict[str, Any]]] = None,
task_description: str = SUBAGENT_CONTROL_DESCRIPTION,
stream_handler: Any = None,
) -> None:
@@ -558,6 +564,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
model=model,
profiles=profiles,
tools=tools,
server_tools=server_tools,
)
self._semaphore = asyncio.Semaphore(SUBAGENT_MAX_CONCURRENT_TASKS)
self._tasks: dict[str, _SubAgentRuntimeTask] = {}
@@ -1111,6 +1118,7 @@ def create_subagent_middlewares(
*,
model: BaseChatModel,
tools: list[BaseTool],
server_tools: Optional[list[dict[str, Any]]] = None,
stream_handler: Any = None,
) -> tuple[list[AgentMiddleware], list[BaseTool]]:
"""创建子代理中间件列表和任务工具列表。"""
@@ -1120,12 +1128,14 @@ def create_subagent_middlewares(
model=model,
profiles=profiles,
tools=tools,
server_tools=server_tools or [],
stream_handler=stream_handler,
)
control_middleware = SubAgentTaskControlMiddleware(
model=model,
profiles=profiles,
tools=tools,
server_tools=server_tools or [],
stream_handler=stream_handler,
)

View File

@@ -51,6 +51,18 @@ class UsageMiddleware(AgentMiddleware):
return None
@classmethod
def _first_int(
cls,
candidates: tuple[tuple[Any, tuple[str, ...]], ...],
) -> int | None:
"""按优先级返回首个可用的 usage 整数值。"""
for container, keys in candidates:
value = cls._lookup_int(container, *keys)
if value is not None:
return value
return None
@classmethod
def _extract_model_name(cls, model: Any) -> str | None:
return (
@@ -82,6 +94,131 @@ class UsageMiddleware(AgentMiddleware):
or {}
)
input_token_details = None
if usage_metadata:
getter = getattr(usage_metadata, "get", None)
input_token_details = (
getter("input_token_details")
if callable(getter)
else getattr(usage_metadata, "input_token_details", None)
)
cache_read_tokens = cls._first_int(
(
(
input_token_details,
(
"cache_read",
"cached_tokens",
"cache_read_input_tokens",
"cacheReadInputTokens",
),
),
(
token_usage,
(
"prompt_cache_hit_tokens",
"cache_read_input_tokens",
"cacheReadInputTokens",
),
),
(
response_metadata,
(
"prompt_cache_hit_tokens",
"cache_read_input_tokens",
"cacheReadInputTokens",
"cached_tokens",
),
),
)
)
if cache_read_tokens is None:
cache_read_tokens = cls._first_int(
(
(
token_usage.get("prompt_tokens_details", {}),
("cached_tokens", "cache_read"),
),
(
token_usage.get("input_tokens_details", {}),
("cached_tokens", "cache_read"),
),
)
)
cache_write_tokens = cls._first_int(
(
(
input_token_details,
(
"cache_creation",
"cache_write",
"cache_write_tokens",
"cache_write_input_tokens",
"cacheWriteInputTokens",
),
),
(
token_usage,
(
"cache_creation_input_tokens",
"cache_write_tokens",
"cache_write_input_tokens",
"cacheWriteInputTokens",
),
),
(
response_metadata,
(
"cache_creation_input_tokens",
"cache_write_tokens",
"cache_write_input_tokens",
"cacheWriteInputTokens",
),
),
)
)
if cache_write_tokens is None:
cache_write_tokens = cls._first_int(
(
(
token_usage.get("prompt_tokens_details", {}),
("cache_write_tokens", "cache_creation"),
),
(
token_usage.get("input_tokens_details", {}),
("cache_write_tokens", "cache_creation"),
),
)
)
cache_write_ttl_tokens = sum(
cls._lookup_int(
input_token_details,
ttl_key,
)
or 0
for ttl_key in (
"ephemeral_5m_input_tokens",
"ephemeral_1h_input_tokens",
)
)
if cache_write_ttl_tokens:
cache_write_tokens = cache_write_ttl_tokens
cache_miss_tokens = cls._first_int(
(
(
token_usage,
("prompt_cache_miss_tokens", "cache_miss_input_tokens"),
),
(
response_metadata,
("prompt_cache_miss_tokens", "cache_miss_input_tokens"),
),
)
)
if input_tokens is None:
input_tokens = cls._lookup_int(
token_usage,
@@ -94,6 +231,27 @@ class UsageMiddleware(AgentMiddleware):
"prompt_token_count",
"input_tokens",
)
if input_tokens is None:
bedrock_input_tokens = cls._lookup_int(token_usage, "inputTokens")
if bedrock_input_tokens is not None:
input_tokens = (
bedrock_input_tokens
+ (cache_read_tokens or 0)
+ (cache_write_tokens or 0)
)
if input_tokens is None and any(
value is not None
for value in (
cache_read_tokens,
cache_write_tokens,
cache_miss_tokens,
)
):
input_tokens = (
(cache_read_tokens or 0)
+ (cache_write_tokens or 0)
+ (cache_miss_tokens or 0)
)
if output_tokens is None:
output_tokens = cls._lookup_int(
@@ -113,8 +271,24 @@ class UsageMiddleware(AgentMiddleware):
if total_tokens is None:
total_tokens = cls._lookup_int(response_metadata, "total_token_count")
has_cache_usage = any(
value is not None
for value in (
cache_read_tokens,
cache_write_tokens,
cache_miss_tokens,
)
)
has_usage = any(
value is not None for value in (input_tokens, output_tokens, total_tokens)
value is not None
for value in (
input_tokens,
output_tokens,
total_tokens,
cache_read_tokens,
cache_write_tokens,
cache_miss_tokens,
)
)
resolved_input = input_tokens or 0
resolved_output = output_tokens or 0
@@ -123,12 +297,32 @@ class UsageMiddleware(AgentMiddleware):
if total_tokens is not None
else resolved_input + resolved_output
)
resolved_cache_read = cache_read_tokens or 0
resolved_cache_write = cache_write_tokens or 0
uncached_input_tokens = (
cache_miss_tokens
if cache_miss_tokens is not None
else max(
resolved_input - resolved_cache_read - resolved_cache_write,
0,
)
)
cache_hit_ratio = (
resolved_cache_read / resolved_input
if has_cache_usage and resolved_input
else None
)
return {
"has_usage": has_usage,
"cache_usage_available": has_cache_usage,
"input_tokens": resolved_input,
"output_tokens": resolved_output,
"total_tokens": resolved_total,
"cache_read_input_tokens": resolved_cache_read,
"cache_write_input_tokens": resolved_cache_write,
"uncached_input_tokens": uncached_input_tokens,
"cache_hit_ratio": cache_hit_ratio,
}
async def awrap_model_call(
@@ -157,9 +351,14 @@ class UsageMiddleware(AgentMiddleware):
if ai_message
else {
"has_usage": False,
"cache_usage_available": False,
"input_tokens": 0,
"output_tokens": 0,
"total_tokens": 0,
"cache_read_input_tokens": 0,
"cache_write_input_tokens": 0,
"uncached_input_tokens": 0,
"cache_hit_ratio": None,
}
)
context_window_tokens = self._extract_context_window_tokens(request.model)

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; large searches may be split with narrower globs, paths, or `rg --files` filters. Use `list_directory` to inspect one known directory or a supported remote storage backend; request its `limit`/`offset` page fields when more than the first page is needed, and use `read_file` when the exact local file is known. If `read_file` reports truncation, continue with smaller `start_line` and `end_line` ranges instead of assuming the file ended.
- 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>
@@ -81,7 +85,14 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
</agent_core>
<communication_runtime>
{verbose_spec}
<progress_updates>
- Base progress updates on meaningful changes in understanding or execution, not on elapsed time or the number of tool calls.
- Do not send a progress update merely because work is starting or because one or two tools have finished. Work through a coherent batch of investigation first.
- Send an intermediate update when you have a useful preliminary conclusion, complete or validate a meaningful stage, discover evidence that materially changes the working direction, or encounter a sustained blocker the user should know about.
- Explain the result or new direction with enough context to be useful, including the key evidence and what you will do next. An update may use several sentences when the finding needs explanation; brevity is not a goal by itself.
- Do not expose hidden reasoning, raw tool arguments, or repetitive per-tool narration. Do not repeat an unchanged status.
- Continue working after each update. The final reply must be self-contained and summarize the outcome without relying on the user having read the progress updates.
</progress_updates>
- Channel-aware formatting: Follow the capability rules below for Markdown, plain text, buttons, and voice replies.
{button_choice_spec}

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

@@ -24,6 +24,7 @@ SYSTEM_TASKS_FILE = "System Tasks.yaml"
SYSTEM_TASKS_SCHEMA_VERSION = 2
COMMON_SHELL_COMMANDS = (
"ssh",
"sshpass",
"scp",
"sftp",
"git",
@@ -139,19 +140,6 @@ class PromptManager:
markdown_spec = self._generate_formatting_instructions(caps)
button_choice_spec = self._generate_button_choice_instructions(msg_channel)
# 啰嗦模式
verbose_spec = ""
if not settings.AI_AGENT_VERBOSE:
verbose_spec = (
"\n\n[Important Instruction] STRICTLY ENFORCED: "
"If tools are needed, DO NOT output any conversational text, explanations, progress updates, "
"or acknowledgements before the first tool call or between tool calls. "
"Call tools directly without any transitional phrases. "
"You MUST remain completely silent until all required tools have finished and you have the final result. "
"Only then may you send one final user-facing reply. "
"DO NOT output any intermediate content whatsoever."
)
# MoviePilot系统信息
moviepilot_info = self._get_moviepilot_info()
voice_reply_spec = self._generate_voice_reply_instructions()
@@ -159,7 +147,6 @@ class PromptManager:
# 始终替换占位符,避免后续 .format() 时因残留花括号报 KeyError
base_prompt = base_prompt.format(
markdown_spec=markdown_spec,
verbose_spec=verbose_spec,
moviepilot_info=moviepilot_info,
voice_reply_spec=voice_reply_spec,
button_choice_spec=button_choice_spec,
@@ -315,8 +302,6 @@ class PromptManager:
"项目根目录": settings.ROOT_PATH,
"配置目录": settings.CONFIG_PATH,
"临时目录": settings.TEMP_PATH,
"日志目录": settings.LOG_PATH,
"主日志文件": settings.LOG_PATH / "moviepilot.log",
}
return [f" - {label}: `{path}`" for label, path in paths.items()]

View File

@@ -32,6 +32,10 @@ def build_manual_redo_template_context(history: Any) -> dict[str, int | str]:
"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",
}
@@ -55,6 +59,10 @@ def format_manual_redo_record_context(history: Any) -> str:
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']}",
]
)

View File

@@ -28,7 +28,6 @@ class ToolChain(ChainBase):
# 单个工具结果的兜底上限。各工具仍应优先在自身逻辑中分页或摘要化;
# 这里用于拦截遗漏路径,避免超大结果直接进入模型上下文。
DEFAULT_TOOL_RESULT_MAX_CHARS = 64 * 1024
MIN_TOOL_RESULT_PREVIEW_CHARS = 512
def serialize_tool_result_for_agent(result: Any) -> str:
@@ -59,20 +58,35 @@ def format_tool_result_for_agent(
if not max_chars or max_chars <= 0 or len(formatted_result) <= max_chars:
return formatted_result
preview_limit = max(MIN_TOOL_RESULT_PREVIEW_CHARS, max_chars)
preview = formatted_result[:preview_limit]
payload = {
"tool_result_truncated": True,
"tool_name": tool_name,
"total_chars": len(formatted_result),
"returned_chars": len(preview),
"content_preview": preview,
"message": (
f"工具返回内容超过 {max_chars} 字符,已截断为预览;"
"请使用更精确的筛选条件、分页参数或专用查询参数继续获取。"
),
}
return json.dumps(payload, ensure_ascii=False, indent=2)
def _dump_preview(preview: str) -> str:
"""序列化截断结果,并让 returned_chars 与实际预览保持一致。"""
payload = {
"tool_result_truncated": True,
"tool_name": tool_name,
"total_chars": len(formatted_result),
"returned_chars": len(preview),
"content_preview": preview,
"message": (
f"工具返回内容超过 {max_chars} 字符,已截断为预览;"
"请使用更精确的筛选条件、分页参数或专用查询参数继续获取。"
),
}
return json.dumps(payload, ensure_ascii=False, indent=2)
# JSON 会转义换行、引号和反斜杠,预览本身等于上限时,最终返回值仍可能
# 明显超限。通过二分查找预留包装开销,确保进入模型的最终字符串是硬上限。
low = 0
high = min(len(formatted_result), max_chars)
best_result = _dump_preview("")
while low <= high:
middle = (low + high) // 2
candidate = _dump_preview(formatted_result[:middle])
if len(candidate) <= max_chars:
best_result = candidate
low = middle + 1
else:
high = middle - 1
return best_result
# 将常见的阻塞调用按能力域拆分到独立线程池,避免外部慢 IO 抢占同一批 worker。
@@ -238,10 +252,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:
@@ -325,16 +335,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:
@@ -432,7 +439,6 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
"""
roots = [
settings.CONFIG_PATH / "agent",
settings.LOG_PATH,
]
resolved_roots = []
for root in roots:
@@ -468,7 +474,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(
@@ -490,7 +496,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)
@@ -516,8 +522,8 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
return (
"抱歉,您没有执行此工具的权限。"
"只有渠道管理员或系统管理员才能执行工具操作。"
"如需执行工具,请联系渠道管理员将您的用户ID添加到渠道管理员列表中"
"或联系系统管理员为您设置权限。"
"如需执行工具请联系管理员将您的用户ID添加到渠道管理员列表中(设定 -> 通知 -> 对应渠道配置 -> 管理员名单)"
"或联系系统管理员为您设置管理员权限。"
)
async def _has_channel_admin_permission(self) -> bool:
@@ -628,7 +634,8 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
发送工具通知消息。
WebAgent 渠道没有后端模块实例,前端流式面板通过 Agent 上下文中的
回调直接接收通知;其它渠道继续走统一消息链。
回调直接接收通知;无渠道的后台任务清空渠道侧定位信息后交由消息链广播,
其它渠道继续走统一消息链。
"""
callback = self._agent_context.get("notification_callback")
if (
@@ -638,6 +645,20 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
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(
@@ -656,5 +677,6 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
title=title,
text=message,
image=image,
save_history=False,
)
)

View File

@@ -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
@@ -141,6 +146,11 @@ class MoviePilotToolFactory:
QueryTransferHistoryTool,
TransferFileTool,
SendMessageTool,
CreateAgentTaskTool,
QueryAgentTasksTool,
UpdateAgentTaskTool,
RunAgentTaskTool,
DeleteAgentTaskTool,
QuerySchedulersTool,
RunSchedulerTool,
QueryWorkflowsTool,
@@ -181,6 +191,8 @@ class MoviePilotToolFactory:
"edit_file",
"execute_command",
"ask_user_choice",
"create_agent_task",
"query_agent_tasks",
)
@staticmethod

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

@@ -59,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": "自定义识别词",

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

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

View File

@@ -12,8 +12,8 @@ from app.agent.tools.tags import ToolTag
from app.helper.browser import BrowserSessionHelper
from app.log import logger
# 页面内容最大长度
MAX_CONTENT_LENGTH = 8000
# 页面内容最大长度;保留在全局工具结果兜底上限以内。
MAX_CONTENT_LENGTH = 12_000
# 默认超时时间(秒)
DEFAULT_TIMEOUT = 30
# 截图最大宽度
@@ -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=(

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

@@ -13,8 +13,6 @@ 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"
)

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

@@ -15,8 +15,6 @@ 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"
)

View File

@@ -1,4 +1,4 @@
"""文件编辑工具"""
"""文件精确编辑工具"""
from pathlib import Path
from typing import Optional, Type
@@ -7,6 +7,11 @@ 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
@@ -15,20 +20,46 @@ class EditFileInput(BaseModel):
"""文件编辑工具的输入参数模型。"""
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. "
"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 "
"and log directories."
"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", errors="replace")
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

@@ -7,6 +7,7 @@ import json
import os
import signal
import subprocess
from collections import deque
from dataclasses import dataclass, field
from tempfile import NamedTemporaryFile
from typing import Any, Literal, Optional, TextIO, Type
@@ -27,7 +28,9 @@ from app.log import logger
DEFAULT_TIMEOUT_SECONDS = 60
MAX_TIMEOUT_SECONDS = 300
MAX_OUTPUT_PREVIEW_BYTES = 10 * 1024
MAX_OUTPUT_PREVIEW_BYTES = 32 * 1024
MAX_OUTPUT_HEAD_BYTES = 16 * 1024
MAX_OUTPUT_TAIL_BYTES = 16 * 1024
READ_CHUNK_SIZE = 4096
KILL_GRACE_SECONDS = 3
COMMAND_CONCURRENCY_LIMIT = 2
@@ -36,11 +39,13 @@ _command_semaphore = asyncio.Semaphore(COMMAND_CONCURRENCY_LIMIT)
@dataclass
class _CommandOutput:
"""保存前 10KB 预览,并在超限时将完整输出写入临时文件。"""
"""保存命令头尾预览,并在超限时将完整输出写入临时文件。"""
preview_limit_bytes: int
preview_entries: list[tuple[str, str]] = field(default_factory=list)
tail_entries: deque[tuple[str, str]] = field(default_factory=deque)
captured_bytes: int = 0
tail_bytes: int = 0
preview_truncated: bool = False
temp_file_path: Optional[str] = None
temp_file_handle: Optional[TextIO] = None
@@ -93,10 +98,12 @@ class _CommandOutput:
self.temp_file_handle = None
def append(self, stream_name: str, text: str) -> None:
"""追加一段输出,超出预览上限后保留完整日志文件。"""
"""追加一段输出,超出预览上限后保留头尾预览和完整日志文件。"""
if not text:
return
self._append_tail(stream_name, text)
if self.temp_file_handle:
self._write_chunk(stream_name, text)
return
@@ -117,6 +124,60 @@ class _CommandOutput:
self.preview_entries.append((stream_name, preview))
self.captured_bytes += len(preview.encode("utf-8"))
def _append_tail(self, stream_name: str, text: str) -> None:
"""维护固定字节大小的尾部输出,方便定位测试和构建失败信息。"""
self.tail_entries.append((stream_name, text))
self.tail_bytes += len(text.encode("utf-8"))
while self.tail_bytes > MAX_OUTPUT_TAIL_BYTES and self.tail_entries:
old_stream, old_text = self.tail_entries.popleft()
old_bytes = len(old_text.encode("utf-8"))
overflow = self.tail_bytes - MAX_OUTPUT_TAIL_BYTES
if old_bytes <= overflow:
self.tail_bytes -= old_bytes
continue
kept_text = old_text.encode("utf-8")[overflow:].decode(
"utf-8", errors="ignore"
)
kept_bytes = len(kept_text.encode("utf-8"))
self.tail_bytes -= old_bytes
if kept_text:
self.tail_entries.appendleft((old_stream, kept_text))
self.tail_bytes += kept_bytes
@staticmethod
def _format_entries(entries: list[tuple[str, str]]) -> str:
"""按 stdout/stderr 切换插入可读的输出分段标题。"""
parts: list[str] = []
last_stream: Optional[str] = None
for stream_name, text in entries:
if stream_name != last_stream:
title = "标准输出" if stream_name == "stdout" else "错误输出"
parts.append(f"\n[{title}]\n")
last_stream = stream_name
parts.append(text)
return "".join(parts).strip()
@property
def combined_preview(self) -> str:
"""返回完整输出或头尾组合预览。"""
if not self.preview_truncated:
return self._format_entries(self.preview_entries)
head_entries: list[tuple[str, str]] = []
remaining = MAX_OUTPUT_HEAD_BYTES
for stream_name, text in self.preview_entries:
if remaining <= 0:
break
clipped = self._clip_text_to_bytes(text, remaining)
if clipped:
head_entries.append((stream_name, clipped))
remaining -= len(clipped.encode("utf-8"))
head = self._format_entries(head_entries)
tail = self._format_entries(list(self.tail_entries))
return (
f"{head}\n\n...(中间输出已省略,完整内容在临时文件中)...\n\n{tail}"
).strip()
@property
def stdout(self) -> str:
"""返回当前保留的 stdout 预览。"""
@@ -135,7 +196,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=(
@@ -296,7 +356,7 @@ class ExecuteCommandTool(MoviePilotTool):
stream_name: str,
output: _CommandOutput,
) -> None:
"""按块读取一次性命令输出,只把前 10KB 保留在返回结果中"""
"""按块读取一次性命令输出,保留 32KB 头尾预览"""
while True:
chunk = await stream.read(READ_CHUNK_SIZE)
if not chunk:
@@ -380,17 +440,16 @@ class ExecuteCommandTool(MoviePilotTool):
file_note = "截至命令终止前的完整输出" if timed_out else "完整输出"
result += (
"\n\n提示:\n"
f"命令输出超过 10KB仅返回前 {MAX_OUTPUT_PREVIEW_BYTES} 字节内容。\n"
f"命令输出超过 {MAX_OUTPUT_PREVIEW_BYTES // 1024}KB"
f"仅返回前后各 {MAX_OUTPUT_HEAD_BYTES // 1024}KB 预览。\n"
f"{file_note}已写入临时文件: {output.temp_file_path}\n"
"如需完整内容,请继续读取该文件。"
)
if output.stdout:
result += f"\n\n标准输出:\n{output.stdout}"
if output.stderr:
result += f"\n\n错误输出:\n{output.stderr}"
if output.combined_preview:
result += f"\n\n命令输出预览:\n{output.combined_preview}"
if output.preview_truncated:
result += "\n\n...(仅展示前 10KB 内容)"
if not output.stdout and not output.stderr:
result += "\n\n...(仅展示前后各 16KB 内容)"
if not output.combined_preview:
result += "\n\n(无输出内容)"
return result

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

@@ -15,22 +15,47 @@ from app.schemas.file import FileItem
from app.utils.string import StringUtils
DEFAULT_DIRECTORY_PAGE_SIZE = 50
MAX_DIRECTORY_PAGE_SIZE = 200
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')")
limit: Optional[int] = Field(
DEFAULT_DIRECTORY_PAGE_SIZE,
ge=1,
le=MAX_DIRECTORY_PAGE_SIZE,
description=(
f"Maximum items to return in this page (default: {DEFAULT_DIRECTORY_PAGE_SIZE}, "
f"maximum: {MAX_DIRECTORY_PAGE_SIZE})"
),
)
offset: Optional[int] = Field(
0,
ge=0,
description="Number of sorted directory items to skip before this page",
)
class ListDirectoryTool(MoviePilotTool):
"""分页查询本地或远程存储目录中的文件和子目录。"""
name: str = "list_directory"
tags: list[str] = [
ToolTag.Read,
ToolTag.Directory,
ToolTag.File,
]
description: str = "List actual files and folders in a file system directory (NOT configuration). Shows files and subdirectories with their names, types, sizes, and modification times. Returns up to 20 items and the total count if there are more items. Use 'query_directory_settings' to query directory configuration settings."
description: str = (
"List actual files and folders in a file system directory (NOT configuration). "
"Shows files and subdirectories with their names, types, sizes, and modification "
f"times. Returns a page of up to {DEFAULT_DIRECTORY_PAGE_SIZE} items with total "
f"count and next offset; limit is capped at {MAX_DIRECTORY_PAGE_SIZE}. "
"Use 'query_directory_settings' to query directory configuration settings."
)
args_schema: Type[BaseModel] = ListDirectoryInput
def get_tool_message(self, **kwargs) -> Optional[str]:
@@ -46,10 +71,14 @@ class ListDirectoryTool(MoviePilotTool):
@staticmethod
def _list_directory_sync(
path: str, storage: Optional[str] = "local", sort_by: Optional[str] = "name"
path: str,
storage: Optional[str] = "local",
sort_by: Optional[str] = "name",
limit: Optional[int] = DEFAULT_DIRECTORY_PAGE_SIZE,
offset: Optional[int] = 0,
) -> str:
"""
目录遍历可能触发本地磁盘或远程存储请求,统一放到线程池中执行。
目录遍历可能触发本地磁盘或远程存储请求,统一放到线程池中执行并分页返回
"""
if not path:
return "错误:路径不能为空"
@@ -65,9 +94,6 @@ class ListDirectoryTool(MoviePilotTool):
if file_list is None:
return f"无法访问目录:{path},请检查路径是否正确或存储是否可用"
if not file_list:
return f"目录 {path} 为空"
if sort_by == "time":
file_list.sort(key=lambda x: x.modify_time or 0, reverse=True)
else:
@@ -79,7 +105,14 @@ class ListDirectoryTool(MoviePilotTool):
)
total_count = len(file_list)
limited_list = file_list[:20]
normalized_limit = max(
1,
min(int(limit or DEFAULT_DIRECTORY_PAGE_SIZE), MAX_DIRECTORY_PAGE_SIZE),
)
normalized_offset = max(0, int(offset or 0))
limited_list = file_list[
normalized_offset : normalized_offset + normalized_limit
]
simplified_items = []
for item in limited_list:
size_str = StringUtils.str_filesize(item.size) if item.size else None
@@ -103,16 +136,39 @@ class ListDirectoryTool(MoviePilotTool):
simplified["extension"] = item.extension
simplified_items.append(simplified)
result_json = json.dumps(simplified_items, ensure_ascii=False, indent=2)
if total_count > 20:
return (
f"注意:目录中共有 {total_count} 个项目,为节省上下文空间,仅显示前 20 个项目。\n\n"
f"{result_json}"
)
return result_json
returned_count = len(simplified_items)
has_more = normalized_offset + returned_count < total_count
return json.dumps(
{
"items": simplified_items,
"total_count": total_count,
"returned_count": returned_count,
"limit": normalized_limit,
"offset": normalized_offset,
"has_more": has_more,
"next_offset": (
normalized_offset + returned_count if has_more else None
),
},
ensure_ascii=False,
indent=2,
)
async def run(self, path: str, storage: Optional[str] = "local",
sort_by: Optional[str] = "name", **kwargs) -> str:
sort_by: Optional[str] = "name",
limit: Optional[int] = DEFAULT_DIRECTORY_PAGE_SIZE,
offset: Optional[int] = 0,
**kwargs) -> str:
"""
分页查询指定目录的文件和子目录。
:param path: 要查询的目录路径
:param storage: 存储类型,默认为本地存储
:param sort_by: 排序方式,支持名称或修改时间
:param limit: 当前页最大条数,最高不超过工具上限
:param offset: 当前页起始偏移量
:return: 包含项目列表和分页元数据的 JSON 字符串
"""
logger.info(f"执行工具: {self.name}, 参数: path={path}, storage={storage}, sort_by={sort_by}")
try:
@@ -124,7 +180,13 @@ class ListDirectoryTool(MoviePilotTool):
if resolved_path:
path = str(resolved_path)
return await self.run_blocking(
"storage", self._list_directory_sync, path, storage, sort_by
"storage",
self._list_directory_sync,
path,
storage,
sort_by,
limit,
offset,
)
except Exception as e:
logger.error(f"查询目录内容失败: {e}", exc_info=True)

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

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

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

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

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

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

@@ -23,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=(

View File

@@ -15,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')")
@@ -121,6 +120,14 @@ class QueryTransferHistoryTool(MoviePilotTool):
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)

View File

@@ -13,7 +13,6 @@ 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')")

View File

@@ -1,5 +1,7 @@
"""文件读取工具"""
import hashlib
import json
from pathlib import Path
from typing import Optional, Type
@@ -12,22 +14,40 @@ from app.log import logger
# 最大读取大小 50KB
MAX_READ_SIZE = 50 * 1024
READ_FILE_TRUNCATION_MESSAGE = (
"文件内容超过50KB本次结果已截断。"
"请使用 start_line 和 end_line 参数指定行号范围分段读取。"
)
class ReadFileInput(BaseModel):
"""文件读取工具的输入参数模型。"""
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,
ToolTag.File,
]
description: str = "Read the content of a text file. Supports reading by line range. Each read is limited to 50KB; content exceeding this limit will be truncated."
description: str = (
"Read the content of a text file. Supports reading by line range. Each "
"read is limited to 50KB; when content is truncated, continue with "
"smaller start_line and end_line ranges."
)
args_schema: Type[BaseModel] = ReadFileInput
def get_tool_message(self, **kwargs) -> Optional[str]:
@@ -36,8 +56,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 +82,8 @@ class ReadFileTool(MoviePilotTool):
if not await path.is_file():
return f"错误:{resolved_path} 不是一个文件"
content = await path.read_text(encoding="utf-8", errors="replace")
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:
@@ -78,8 +106,22 @@ class ReadFileTool(MoviePilotTool):
content = content_bytes[:MAX_READ_SIZE].decode("utf-8", errors="replace")
truncated = True
if include_metadata:
payload = {
"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,
}
if truncated:
payload["truncation_message"] = READ_FILE_TRUNCATION_MESSAGE
payload["content"] = content
return json.dumps(payload, ensure_ascii=False, indent=2)
if truncated:
return f"{content}\n\n[警告:文件内容已超过50KB限制以上内容已被截断。请使用 start_line/end_line 参数分段读取。]"
return f"{content}\n\n[警告:{READ_FILE_TRUNCATION_MESSAGE}]"
return content

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=(

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

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

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,
}
# 检查订阅状态

View File

@@ -20,14 +20,18 @@ from ._torrent_search_utils import (
class SearchTorrentsInput(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")
area: Optional[str] = Field(None, description="Search scope: 'title' (default) or 'imdbid'")
sites: Optional[List[int]] = Field(None,
description="Array of specific site IDs to search on (optional, if not provided searches all configured sites)")
class SearchTorrentsTool(MoviePilotTool):
name: str = "search_torrents"
tags: list[str] = [
@@ -36,23 +40,27 @@ class SearchTorrentsTool(MoviePilotTool):
ToolTag.Site,
ToolTag.Media,
]
description: str = ("Search for torrent files by media ID across configured indexer sites, cache the matched results, "
"and return available filter options for follow-up selection. "
"Requires tmdb_id or douban_id (can be obtained from search_media tool) for accurate matching.")
description: str = (
"Search for torrent files by media ID across configured indexer sites, cache the matched results, "
"and return available filter options for follow-up selection. "
"Accepts a TMDB, Douban, Bangumi, AniList, or source-native media ID for accurate matching.")
args_schema: Type[BaseModel] = SearchTorrentsInput
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
@@ -63,13 +71,15 @@ class SearchTorrentsTool(MoviePilotTool):
return SystemConfigOper().get(SystemConfigKey.IndexerSites) or []
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, area: Optional[str] = None,
sites: Optional[List[int]] = None, **kwargs) -> str:
logger.info(
f"执行工具: {self.name}, 参数: tmdb_id={tmdb_id}, douban_id={douban_id}, media_type={media_type}, area={area}, sites={sites}")
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 工具获取媒体信息"
try:
search_chain = SearchChain()
@@ -82,6 +92,10 @@ class SearchTorrentsTool(MoviePilotTool):
filtered_torrents = await search_chain.async_search_by_id(
tmdbid=tmdb_id,
doubanid=douban_id,
bangumiid=bangumi_id,
anilistid=anilist_id,
source=media_source,
mediaid=media_id,
mtype=media_type_enum,
area=area or "title",
sites=sites,
@@ -108,9 +122,9 @@ class SearchTorrentsTool(MoviePilotTool):
}, ensure_ascii=False, indent=2)
return result_json
else:
media_id = f"TMDB={tmdb_id}" if tmdb_id else f"豆瓣={douban_id}"
identity = media_id or tmdb_id or douban_id or bangumi_id or anilist_id
result_json = json.dumps({
"message": f"未找到相关种子资源: {media_id}",
"message": f"未找到相关种子资源: {identity}",
"all_sites": all_sites,
"search_site_ids": search_site_ids,
}, ensure_ascii=False, indent=2)

View File

@@ -48,10 +48,6 @@ class _SearchSiteFilter:
class SearchWebInput(BaseModel):
"""搜索网络内容工具的输入参数模型"""
explanation: Optional[str] = Field(
None,
description="Clear explanation of why this tool is being used in the current context",
)
query: str = Field(
..., description="The search query string to search for on the web"
)

View File

@@ -16,8 +16,6 @@ from app.schemas.types import MessageChannel
class SendLocalFileInput(BaseModel):
"""发送本地附件工具输入。"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why sending this local file helps the user",)
file_path: str = Field(
...,
description="Absolute path to the local image or file to send to the user",
@@ -107,6 +105,7 @@ class SendLocalFileTool(MoviePilotTool):
text=message,
file_path=str(resolved_path),
file_name=file_name or resolved_path.name,
save_history=False,
)
)
return "本地附件已发送"

View File

@@ -14,10 +14,6 @@ from app.schemas.types import NotificationType
class SendMessageInput(BaseModel):
"""发送消息工具的输入参数模型"""
explanation: Optional[str] = Field(
None,
description="Clear explanation of why this tool is being used in the current context",
)
message: Optional[str] = Field(
None,
description="The message content to send to the user (should be clear and informative)",
@@ -104,6 +100,7 @@ class SendMessageTool(MoviePilotTool):
title=title,
text=text,
image=image_url,
save_history=False,
)
)
self._agent_context["user_reply_sent"] = True

View File

@@ -14,10 +14,6 @@ from app.schemas import Notification, NotificationType
class SendVoiceMessageInput(BaseModel):
"""发送语音消息工具输入。"""
explanation: Optional[str] = Field(
None,
description="Clear explanation of why a voice reply is the best fit in the current context",
)
message: str = Field(
...,
description="The spoken content to send back to the user",
@@ -100,6 +96,7 @@ class SendVoiceMessageTool(MoviePilotTool):
if voice_path and settings.AUDIO_OUTPUT_INCLUDE_TEXT
else None
),
save_history=False,
)
)
self._agent_context["user_reply_sent"] = True

View File

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

View File

@@ -13,7 +13,6 @@ from app.log import logger
class TestSiteInput(BaseModel):
"""测试站点连通性工具的输入参数模型"""
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
site_identifier: int = Field(..., description="Site ID to test (can be obtained from query_sites tool)")

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