Compare commits

..

436 Commits
v2.10.10 ... v2

Author SHA1 Message Date
jxxghp
b89c351686 allow auto manual transfer scrape option 2026-06-13 10:42:34 +08:00
jxxghp
303e7ee16e feat: add downloader incomplete suffix toggles 2026-06-13 08:43:37 +08:00
jxxghp
ab9eeedb3e fix: 跳过推荐空缓存 2026-06-13 08:09:49 +08:00
Cecil98
7d582cc4d8 fix: 修复 TorrentLeech 最新种子列表从第 21 页开始请求的问题 (#5933) 2026-06-13 07:44:30 +08:00
InfinityPacer
e27a9ba486 fix(subscribe): avoid duplicate best-version completion (#5931) 2026-06-12 19:08:53 +08:00
InfinityPacer
51c2843dd0 fix(plugin): clean release fallback before retry (#5930) 2026-06-12 19:08:18 +08:00
jxxghp
8c73b87f6e fix agent config 2026-06-12 17:24:45 +08:00
jxxghp
a10361cc2f optimize agent browser sessions 2026-06-12 16:41:21 +08:00
jxxghp
dfabd695a8 add: query_doctor_report agent tool 2026-06-12 16:26:00 +08:00
jxxghp
735a1ebf27 新增 doctor 诊断自救功能 2026-06-12 15:55:24 +08:00
InfinityPacer
10dcb3727e fix(plugin): fall back when release package is unavailable (#5929) 2026-06-12 13:26:35 +08:00
DDSRem
616c355438 chore: bump moviepilot-rust to 0.1.9 (#5927) 2026-06-12 10:18:56 +08:00
jxxghp
24dc53b62d fix: handle NexusPHP occurrence pubdate parsing 2026-06-12 10:11:52 +08:00
jxxghp
1b83abe155 fix: implement tool execution timeout handling and improve blocking call management 2026-06-12 08:43:17 +08:00
jxxghp
765b286fd7 fix: improve cache locking mechanism and enhance key handling in file and redis backends 2026-06-12 08:21:26 +08:00
jxxghp
83cc7ea716 fix: enhance caching mechanism and improve type hints in DoH and workflow modules 2026-06-12 08:09:54 +08:00
jxxghp
d26225b998 fix(tmdb): stabilize tmdb connection reuse 2026-06-11 12:48:32 +08:00
jxxghp
c18e145b90 fix mtorrent subtitle error logging 2026-06-11 08:50:16 +08:00
jxxghp
b43c253983 fix: tests 2026-06-11 08:24:56 +08:00
jxxghp
e49e1626ee fix: add method to retrieve or create a folder in supported storage 2026-06-10 19:04:49 +08:00
jxxghp
13f55f4b1d fix: update media download directory resolution to return storage information 2026-06-10 18:51:52 +08:00
jxxghp
486c5294ba fix: enhance error handling and logging for subtitle download process 2026-06-10 18:40:32 +08:00
jxxghp
cba52c57e6 修复 RAR 字幕包下载识别 2026-06-10 08:46:10 +08:00
jxxghp
82694d2d8b fix: filter results in site search and improve parser handling 2026-06-10 08:11:18 +08:00
jxxghp
616309a08b test: fix rust accel unit expectations 2026-06-10 07:25:16 +08:00
jxxghp
829d7944b0 fix: create temp directory for subtitle API downloads 2026-06-10 07:07:33 +08:00
jxxghp
c4602070b1 fix: create missing subtitle download directories 2026-06-10 06:59:23 +08:00
jxxghp
ff83d1eae6 test: adjust subtitle spider expectations 2026-06-10 06:45:14 +08:00
DDSRem
ee96706e9f chore: bump moviepilot-rust to 0.1.8 (#5922) 2026-06-10 06:43:20 +08:00
jxxghp
7a19906e25 fix: remove unused default value retrieval and fallback methods 2026-06-10 06:35:28 +08:00
jxxghp
a0bc22dd25 fix: report subtitle spider errors 2026-06-10 01:37:41 +08:00
jxxghp
63a63d2ec6 fix: detect login pages before rust parsing 2026-06-10 01:31:39 +08:00
jxxghp
5d5e37792e fix: flag subtitle login pages 2026-06-10 01:28:16 +08:00
jxxghp
4241461ba7 fix: preserve absolute subtitle asset urls 2026-06-10 01:02:09 +08:00
jxxghp
fa06d5d861 fix: improve subtitle parsing and matching 2026-06-10 00:54:58 +08:00
jxxghp
0f468f67c1 feat: add stub implementation for site resources in test environment 2026-06-09 22:05:07 +08:00
jxxghp
dc2b6910a4 fix: restrict sensitive system endpoints 2026-06-09 21:45:51 +08:00
jxxghp
d1cf584af9 fix: handle invalid mtorrent seeding items 2026-06-09 20:48:38 +08:00
DDSRem
a2b82a2532 chore: bump moviepilot-rust to 0.1.7 (#5919) 2026-06-09 18:25:59 +08:00
jxxghp
f48d708172 test: update search cache params expectation 2026-06-09 18:16:38 +08:00
jxxghp
210aac0937 feat: add exact subtitle search 2026-06-09 17:04:17 +08:00
jxxghp
e3c5a94c52 feat: add subtitle search functionality and related data handling 2026-06-09 06:46:26 +08:00
jxxghp
738d92445a fix docker ffmpeg image tag 2026-06-08 15:44:31 +08:00
jxxghp
08ace4e804 chore: bump application and frontend versions to v2.13.6 2026-06-08 15:33:07 +08:00
jxxghp
b6759c5519 remove agent prompt tests 2026-06-08 14:46:37 +08:00
jxxghp
c7dc6e0d97 feat: add keyboard button support for proactive and passive message sending 2026-06-08 14:22:48 +08:00
jxxghp
84ff7476c0 fix(docker): use static ffmpeg with amr support (fixes #5912) 2026-06-08 14:11:34 +08:00
jxxghp
55cf380c9e feat: add support for Discord, Slack, and QQ channels in admin key mapping 2026-06-08 13:43:40 +08:00
jxxghp
bb8cfaa52f 更新 System Core Prompt.txt 2026-06-08 07:03:05 +08:00
jxxghp
bf98e4c954 Ensure batch AI redo returns plain text 2026-06-06 07:37:42 +08:00
jxxghp
a0b3800f6b fix: prevent cloud storage download path traversal 2026-06-05 17:43:06 +08:00
jxxghp
871d1ec0d8 更新 version.py 2026-06-05 16:16:44 +08:00
InfinityPacer
ca1dbdf843 ci: harden pull request unit test workflow (#5902) 2026-06-05 15:31:31 +08:00
InfinityPacer
e77bef7cf1 fix(subscribe): respect custom start episode for missing seasons (#5901) 2026-06-05 15:20:50 +08:00
ui_beam
f4011d3ac2 fix: 修复前端代理服务器设置清空保存后,httpx 持续报 `Unknown scheme for proxy URL (#5899) 2026-06-05 15:20:31 +08:00
jxxghp
d0b62523a0 chore(version): bump application and frontend versions to v2.13.5 2026-06-05 08:27:09 +08:00
Album
a9b1f7e9c9 fix(alist): support openlist rapid upload headers (#5897) 2026-06-05 06:50:20 +08:00
jxxghp
fc8933c648 feat(workflow): enhance workflow context serialization and execution state management 2026-06-05 00:41:02 +08:00
jxxghp
51981d151e feat(workflow): enhance execution state handling for non-JSON serializable values 2026-06-05 00:01:28 +08:00
jxxghp
97cfcda03c feat(workflow): implement action contract management for inputs and outputs 2026-06-04 21:06:25 +08:00
jxxghp
a2984530f8 feat(workflow): add execution configuration and structured execution state to workflow 2026-06-04 15:57:34 +08:00
jxxghp
7474ecd02f feat(workflow): enhance action execution with structured results and context management 2026-06-04 14:28:46 +08:00
jxxghp
9056caae40 feat(workflow): enhance workflow execution and context management 2026-06-04 14:10:06 +08:00
jxxghp
fd280a49b7 feat(auth): implement authentication provider endpoints and ticket exchange 2026-06-04 08:23:54 +08:00
DDSRem
df75f42753 fix: retry stale keep-alive requests (#5893) 2026-06-04 06:55:03 +08:00
DDSRem
0d2c324e28 fix(db): repair episode_priority column type mismatch on PostgreSQL (#5892) 2026-06-04 06:53:11 +08:00
DDSRem
dc0ee2b466 fix: patch urllib3.fields for urllib3-future compatibility (#5890) 2026-06-04 06:40:16 +08:00
InfinityPacer
781b1ce2aa test: 修复单测 warnings 并精确忽略上游弃用告警 (#5889) 2026-06-03 18:34:45 +08:00
InfinityPacer
791f1fe4ac test: 共享测试 harness 入 app/testing(网络守卫 + 引导)并统一 sys.modules 打桩原语 (#5888) 2026-06-03 18:34:20 +08:00
InfinityPacer
6405ff1191 test: split agenttokens plugin test out, un-skip agent event tests (#5885) 2026-06-03 10:50:55 +08:00
jxxghp
64cb5742d2 feat: add explicit handling for /ai messages to bypass media interactions 2026-06-03 06:52:25 +08:00
jxxghp
4601c41794 docs: clarify local resource and plugin setup 2026-06-03 00:15:17 +08:00
jxxghp
6167e7e6a2 更新 README_EN.md 2026-06-03 00:10:43 +08:00
jxxghp
a106738de5 更新 README.md 2026-06-03 00:09:23 +08:00
jxxghp
e0ce11a9d3 更新 README.md 2026-06-03 00:08:25 +08:00
jxxghp
3052f2cb31 docs: reorganize README guides 2026-06-03 00:05:09 +08:00
jxxghp
7905e622f9 fix: 修复 NexusPHP 做种翻页 userid 为空崩溃
Fixes #5874
2026-06-02 23:34:31 +08:00
jxxghp
3fa5d31d81 fix: normalize subscribe integer flags before persistence 2026-06-02 23:32:37 +08:00
jxxghp
9e5cb702c5 更新 version.py 2026-06-02 23:18:25 +08:00
jxxghp
ed380e2a17 更新 requirements.in 2026-06-02 23:17:58 +08:00
InfinityPacer
bc358fc6d2 test: 处理 #5877 review 反馈 + 提 PR 前跑全量门禁约定 (#5880) 2026-06-02 16:32:11 +08:00
InfinityPacer
223854d4c6 test: 新增单测 CI 门禁与规范文档,处理 #5868/#5873 review 反馈 (#5877) 2026-06-02 12:57:55 +08:00
InfinityPacer
7c73a57bbc fix(chain): use history_id key in manual transfer redo prompt context (#5876) 2026-06-02 12:50:42 +08:00
InfinityPacer
2b9f5d8d90 fix(agent): apply require_admin gate by reading instance field (#5875) 2026-06-02 12:50:07 +08:00
InfinityPacer
437baec620 test: 测试套件自隔离与全量离线化(collection 清零 + 杜绝真实网络) (#5873) 2026-06-02 12:23:08 +08:00
jxxghp
1c41d9f253 feat: add plugin history endpoint to fetch remote update details 2026-06-02 07:12:13 +08:00
jxxghp
db522e8829 fix: 兼容 Bangumi 人物生日字段类型 2026-06-02 06:23:57 +08:00
InfinityPacer
e43adf51af revert: absolute numbered season pack locating (#5869) 2026-06-01 21:09:23 +08:00
jxxghp
d353e7b208 fix: 订阅下载失败时尝试后续候选 2026-06-01 18:47:04 +08:00
InfinityPacer
df732731d9 test: move config+db isolation to conftest, unify on pytest (#5868) 2026-06-01 15:41:14 +08:00
jxxghp
ac5374c244 feat: enhance audio capability logging for transcription and synthesis 2026-06-01 12:55:28 +08:00
jxxghp
fcdba27a5d feat: add moviepilot-explorer subagent for source-code inspection and troubleshooting 2026-06-01 11:52:36 +08:00
jxxghp
e4242058e2 增加子代理操作日志 2026-06-01 11:31:35 +08:00
InfinityPacer
b7c78da214 fix(subscribe): handle absolute numbered season packs (#5866) 2026-06-01 11:18:51 +08:00
InfinityPacer
ba2feb2bfe test: isolate CONFIG_DIR to protect real database (#5865) 2026-06-01 06:42:50 +08:00
jxxghp
6f014cee14 更新 discord.py 2026-05-31 22:11:22 +08:00
jxxghp
6453935584 更新 telegram.py 2026-05-31 22:10:36 +08:00
jxxghp
40d0b60aa2 feat: add async subagent task control 2026-05-31 21:55:25 +08:00
jxxghp
1922cce499 优化 Agent 并行工具调用提示词 2026-05-31 21:38:40 +08:00
jxxghp
c89df496a5 feat(agent): add ToolTag-based tags to all agent tools; implement tags.py for unified tool capability tagging 2026-05-31 18:30:39 +08:00
jxxghp
855681ff35 feat(agent): mark and propagate voice input metadata in agent messages; clarify terminal tool usage in prompts
- Add `has_audio_input` flag to agent message handling and propagate through processing pipeline
- Structure agent input payloads to include `input.mode` and `input.transcribed` for voice messages
- Update prompts and tool descriptions to clarify that `send_voice_message` and `ask_user_choice` are terminal tools and should not be followed by redundant text replies
- Enhance tests to cover voice input metadata propagation and prompt updates
2026-05-31 18:04:02 +08:00
jxxghp
13b2163788 chore: add noqa for specific lines, update docstring, improve logging and variable naming 2026-05-31 17:49:05 +08:00
jxxghp
5d3c262e60 feat(agent): set return_direct for SendVoiceMessageTool to prevent streaming tool messages 2026-05-31 17:35:10 +08:00
jxxghp
a5c44a5097 更新 version.py 2026-05-31 09:52:40 +08:00
jxxghp
16ada1a6c4 feat(site): add POST /site/cookie/{site_id} endpoint for updating site Cookie&UA via request body
- Introduce SiteCookieUpdate schema for structured cookie update requests
- Add POST endpoint to update site Cookie&UA using JSON body
- Refactor cookie update logic into shared _update_site_cookie function
- Update SKILL.md to document new endpoint and endpoint count
- Add tests for both POST and legacy GET cookie update endpoints
2026-05-31 09:11:09 +08:00
jxxghp
ac09ce5230 fix: handle empty ChatGPT responses output 2026-05-31 08:14:31 +08:00
jxxghp
2255b61195 fix: latest覆盖模式误删多Part文件 (fix #5862) 2026-05-30 20:28:26 +08:00
jxxghp
314ac3903c fix(agent): 识别 DeepSeek image_url 反序列化错误,修复图片不支持提示 (#5863) 2026-05-30 20:15:24 +08:00
jxxghp
5c3796bf73 fix: Bangumi别名解析和Redis事件循环切换问题
- 修复Bangumi API返回别名为字符串列表时的解析错误
- 修复Redis异步客户端在不同事件循环中使用时报Future attached to different loop错误
2026-05-30 13:11:18 +08:00
jxxghp
492e3c333b fix: unify User-Agent header usage with settings.USER_AGENT 2026-05-30 09:49:29 +08:00
jxxghp
cce72d0884 fix: proxy bangumi requests 2026-05-30 08:54:40 +08:00
InfinityPacer
69a064e986 feat(subscribe): 新增订阅总集数刷新与完成检查链式事件 (#5860) 2026-05-30 08:26:47 +08:00
InfinityPacer
f4ca4120bc fix(image-proxy): 阻断日志输出诊断原因并合并重复告警 (#5858) 2026-05-29 14:14:16 +08:00
InfinityPacer
b45956f850 fix(subscribe): require complete coverage for full best versions (#5857) 2026-05-29 14:10:29 +08:00
jxxghp
762a7fbba7 fix: simplify search web providers 2026-05-28 20:11:31 +08:00
jxxghp
10290ca17b 优化 agent 网络搜索工具 2026-05-28 17:22:52 +08:00
jxxghp
12a2561ca8 fix: return Emby item server ids 2026-05-28 15:03:56 +08:00
jxxghp
543bee9ad5 fix: correct Emby dashboard links 2026-05-28 14:33:50 +08:00
jxxghp
cc3e062262 固定 Agent 工具消息类型 2026-05-28 13:34:53 +08:00
jxxghp
bf4f5f8744 优化 Agent 核心提示词结构 2026-05-28 11:22:56 +08:00
jxxghp
f8f06a602a 加强 Agent 核心提示词安全约束 2026-05-28 11:10:08 +08:00
jxxghp
3cb8925e92 更新 version.py 2026-05-27 17:59:21 +08:00
jxxghp
3ffdf1b38e docs: add browser-use skill 2026-05-27 17:01:54 +08:00
jxxghp
6557b8b9d8 更新 scheduler.py 2026-05-27 15:56:08 +08:00
jxxghp
2b2e088784 fix: improve u115 multipart upload handling 2026-05-27 15:02:03 +08:00
jxxghp
d9a06f4433 refactor: load share admins from server 2026-05-27 14:31:01 +08:00
jxxghp
b1259fdc02 feat: add manual transfer target path matching 2026-05-27 13:26:01 +08:00
jxxghp
0e5c592862 refactor: centralize MoviePilot server helper 2026-05-27 12:56:45 +08:00
jxxghp
db3ad91408 fix: improve manual transfer recognition preview 2026-05-27 11:01:27 +08:00
jxxghp
5b6b4c9744 将发送消息工具设为必选工具 2026-05-27 08:48:56 +08:00
jxxghp
990a28b51b 调整 Agent 心跳任务报告方式 2026-05-27 08:41:40 +08:00
jxxghp
b6ffd286fe fix: keep platform words in media titles 2026-05-27 07:41:44 +08:00
jxxghp
1f7fb304dd feat: add LLM proxy toggle 2026-05-27 06:57:09 +08:00
jxxghp
896631d63e fix(agent): enable voice replies for supported channels 2026-05-26 20:14:56 +08:00
jxxghp
db8363fee1 fix(openlist): request full directory pages 2026-05-26 18:12:10 +08:00
jxxghp
31554bdcb5 fix agent tool call history ordering 2026-05-26 16:42:52 +08:00
jxxghp
ccbcce0573 chore: update 3 workspace files 2026-05-26 12:14:36 +08:00
jxxghp
e00e18f31e fix: transcode unsupported stt audio formats 2026-05-26 10:00:32 +08:00
jxxghp
c7965edd47 fix: use responses api for chatgpt reasoning models 2026-05-26 09:31:00 +08:00
jxxghp
8aeba8a6d2 更新 version.py 2026-05-26 09:07:04 +08:00
jxxghp
aee8b05737 fix: order LLM user agent config after max context 2026-05-26 08:30:43 +08:00
jxxghp
821bd3decd fix: use explicit LLM user agent config 2026-05-26 08:24:58 +08:00
jxxghp
b65c8dcfe0 feat: support llm user agent 2026-05-26 08:20:02 +08:00
DDSRem
877d89abb3 chore: bump moviepilot-rust to 0.1.4 (#5840) 2026-05-26 07:46:58 +08:00
jxxghp
d4718bf9dc fix: expose rust acceleration availability 2026-05-25 23:49:04 +08:00
DDSRem
8bd1288e7e chore: bump moviepilot-rust to 0.1.3 (#5838) 2026-05-25 23:18:17 +08:00
DDSRem
a65c5364d9 chore: bump moviepilot-rust to 0.1.2 (#5837) 2026-05-25 22:26:05 +08:00
jxxghp
f761e07779 更新 requirements.in 2026-05-25 22:21:44 +08:00
jxxghp
91f6ad092e fix(docker): reduce env logs and harden ssl cert startup 2026-05-25 22:14:27 +08:00
jxxghp
c33c62b938 refactor: use external moviepilot rust package 2026-05-25 20:52:53 +08:00
jxxghp
05943287c0 更新 version.py 2026-05-25 19:23:19 +08:00
jxxghp
94633173b1 添加安装版本统计上报 2026-05-25 18:16:59 +08:00
InfinityPacer
7ab1a668cb perf(security): make image proxy signature stable to enable client caching (#5835) 2026-05-25 16:46:29 +08:00
InfinityPacer
d57deb1df1 fix(security): release SSRF DNS inflight lock outside async with block (#5834) 2026-05-25 16:45:32 +08:00
jxxghp
d940373f6b 将所有agent工具的explanation字段改为可选
修复Pydantic验证错误:QueryTransferHistoryInput的explanation字段为必需,但用户未提供。
修改了74个工具文件,将explanation字段从必需改为可选,默认值为None。
2026-05-25 16:40:43 +08:00
jxxghp
ca01b8ec3f 更新 version.py 2026-05-25 16:05:48 +08:00
jxxghp
384d6a3fe1 更新 metainfo.rs 2026-05-25 16:03:43 +08:00
leanmore
922e8473c5 fix: add VIVID and HDR10P to effect regex (#5833) 2026-05-25 15:59:26 +08:00
InfinityPacer
01c3451679 perf(system): async SSRF check with DNS cache for image proxy (#5832) 2026-05-25 15:54:02 +08:00
InfinityPacer
98e3ea4e6f fix(system): allow configured image proxy private ranges (#5831) 2026-05-25 14:16:54 +08:00
jxxghp
0e8bcb4df6 fix: patch gemini thought_signature enforcement to cover all function calls
The upstream _parse_chat_history enforcement code uses a first_fc_seen
flag that only adds DUMMY_THOUGHT_SIGNATURE to the first function_call
without thought_signature. Parallel function calls (position 2+) remain
unpatched, causing Gemini API 400 errors for all Gemini 2.5+ models.

Additionally, _is_gemini_3_or_later only matches 'gemini-3', missing
Gemini 2.5 models entirely.

This patch:
1. Extends _is_gemini_3_or_later to also match gemini-2.5 models
2. Wraps _parse_chat_history to ensure ALL function_call parts in ALL
   model messages have thought_signature (not just the first one)
2026-05-25 13:53:02 +08:00
DDSRem
784672af5c docs: restructure AGENTS.md and add docs/rules agent documentation system (#5830) 2026-05-25 13:48:43 +08:00
jxxghp
63b9994b0e fix: sign media server image proxy URLs 2026-05-25 12:41:55 +08:00
jxxghp
d713ea54c1 fix: allow media server image proxy paths 2026-05-25 12:25:57 +08:00
jxxghp
766d2699ea feat: support MiniMax audio provider 2026-05-25 11:42:57 +08:00
jxxghp
9af61c4744 ci: improve docker build cache 2026-05-25 11:10:39 +08:00
jxxghp
7c8b973f30 fix: exclude derived subscribe completion field 2026-05-25 11:04:34 +08:00
jxxghp
0fdf1fadab 更新 version.py 2026-05-25 09:14:08 +08:00
jxxghp
477c49587c feat(agent): log tool execution result summary and truncate if too long 2026-05-25 08:50:44 +08:00
jxxghp
5532f14efb feat(agent): inject plugin installation directory into system prompt 2026-05-25 08:21:19 +08:00
jxxghp
b08c335bb4 fix: 优化 execute_command 工具的 rm -rf 拦截逻辑,仅禁止删除根目录或一级目录 2026-05-25 08:00:35 +08:00
jxxghp
c7670e5cc8 更新 execute_command.py 2026-05-25 07:51:30 +08:00
jxxghp
a725789045 feat: add agent token provider events 2026-05-25 07:32:36 +08:00
jxxghp
5d5c95dcd8 fix: skip non-transferable monitor files 2026-05-25 06:14:02 +08:00
jxxghp
4d8c910f0d fix: enable incomplete file suffix for downloaders 2026-05-25 05:58:05 +08:00
jxxghp
4b4b0335e8 perf: optimize episode group tag parsing 2026-05-25 05:33:26 +08:00
jxxghp
ac3432c54f feat: support TMDB episode group (g=) in explicit media tags and custom identifiers
- Add episode_group (g=) parameter parsing to explicit media tags in both Python and Rust metainfo parsers
- Propagate episode_group through MetaInfo, MetaBase, MediaInfo, and context models
- Update SKILL.md and update_custom_identifiers.py docs to describe episode group usage
- Add tests for episode_group recognition in metainfo and chain recognition logic
2026-05-24 23:32:27 +08:00
jxxghp
ea52537423 更新 media.py 2026-05-24 22:50:09 +08:00
TimoYoung
c9bdaf2f40 chore: ignore config/plugins/ in gitignore (#5828) 2026-05-24 22:29:38 +08:00
jxxghp
2b629185b9 Merge remote-tracking branch 'origin/v2' into v2 2026-05-24 22:14:24 +08:00
jxxghp
a97e3ea092 feat: improve agent execution error handling with user-friendly messages and sensitive info sanitization 2026-05-24 22:14:10 +08:00
jxxghp
7af2aa4266 更新 feishu.py 2026-05-24 22:09:29 +08:00
jxxghp
1550b75548 perf: precompile anime metadata regexes 2026-05-24 20:48:36 +08:00
jxxghp
b7f6ee12ee fix: 强制刷新插件市场绕过远端缓存 2026-05-24 20:28:45 +08:00
jxxghp
79539760da fix: bound long-lived cache state 2026-05-24 18:03:42 +08:00
jxxghp
dc73d61682 feat: add china operator llm providers 2026-05-24 09:30:41 +08:00
jxxghp
6430b864b4 fix: import filter module dependencies 2026-05-24 08:02:40 +08:00
jxxghp
ec588037a0 fix: restrict message commands to channel admins 2026-05-24 08:00:54 +08:00
jxxghp
0b7854a0af fix: block private image proxy targets 2026-05-24 07:18:51 +08:00
jxxghp
0273adc61c 更新 rust_accel.py 2026-05-23 21:19:46 +08:00
jxxghp
d6472088cb fix: correct typo in import for clear_rust_parse_options_cache 2026-05-23 20:42:52 +08:00
jxxghp
0c133b7ccd feat: add global RUST_ACCEL toggle to enable/disable rust acceleration at runtime
- Introduce RUST_ACCEL config to control all rust fast paths
- Fallback to Python implementations when disabled, preserving filter semantics
- Expose rust acceleration status in system info API
- Update CLI docs to reflect new toggle
- Add tests for runtime switch and fallback behavior
2026-05-23 20:35:58 +08:00
jxxghp
0bf228d29d perf: optimize rust acceleration paths
Rust vs Python benchmark results:

- RSS: Rust 0.299 ms/loop vs Python 7.913 ms/loop, 26.47x faster

- Filter: Rust 12.740 ms/loop vs Python 57.187 ms/loop, 4.49x faster

- MetaInfo: Rust 64.680 ms/loop vs Python 316.158 ms/loop, 4.89x faster

- Indexer agsvpt: Rust 145.76 ms vs Python 3686.50 ms, 25.29x faster

- Indexer pttime: Rust 166.51 ms vs Python 4019.87 ms, 24.14x faster

- Indexer chdbits: Rust 161.17 ms vs Python 3604.28 ms, 22.36x faster

- Indexer iptorrents: Rust 77.82 ms vs Python 17615.52 ms, 226.36x faster

Validation:

- cargo fmt/check/test for rust/moviepilot_rust

- pytest Rust-related coverage: tests/test_rust_accel.py tests/test_torrent_filter.py tests/test_metainfo.py tests/test_indexer_spider_search_url.py tests/test_workflow_fetch_rss.py

- tests/run.py legacy suite

- pylint app/ --errors-only
2026-05-23 19:41:18 +08:00
TimoYoung
a6826e6a4e fix: merge plugin form defaults with stored config to prevent v-show ReferenceError (#5824) 2026-05-23 19:04:44 +08:00
jxxghp
ed0f8c471b feat: accelerate metainfo parsing with rust 2026-05-23 17:45:39 +08:00
jxxghp
ad38f51d6b feat: accelerate rss parsing with rust 2026-05-23 16:14:47 +08:00
jxxghp
d1e2881347 feat: accelerate site indexer parsing with rust 2026-05-23 15:59:20 +08:00
jxxghp
222f6ce7d8 fix: expose docker venv to maturin 2026-05-23 15:51:08 +08:00
jxxghp
39d09c2956 fix: resolve docker rust toolchain path 2026-05-23 14:19:57 +08:00
jxxghp
2b531afe49 删除 frozen.spec 2026-05-23 13:15:17 +08:00
jxxghp
5a1a6b47a5 fix: remove jieba compatibility shim 2026-05-23 13:05:32 +08:00
jxxghp
134c441754 fix: replace fast jieba dependency 2026-05-23 12:59:33 +08:00
jxxghp
00fc8b2f53 chore: update text processing dependencies 2026-05-23 12:18:59 +08:00
jxxghp
5f0ae3a75e 删除 benchmark_rust_accel.py 2026-05-23 10:57:28 +08:00
jxxghp
3ebd06a3a7 perf: precompile media metadata regexes 2026-05-23 10:44:46 +08:00
Album
2eb7f57a4c feat: 支持多文件手动整理与集数定位模板推荐 (#5820) 2026-05-23 09:23:50 +08:00
jxxghp
7cbfeb2377 refactor: slim rust acceleration surface 2026-05-23 09:17:32 +08:00
jxxghp
fcbea077b7 fix: support jinja indexer parsing in rust 2026-05-23 08:07:43 +08:00
jxxghp
da54f3a302 fix: render indexer jinja fields in rust 2026-05-23 01:02:08 +08:00
jxxghp
efdb4d1b28 fix: delay transient typing indicators 2026-05-23 00:41:12 +08:00
jxxghp
9190699cd1 fix: remove choice callback status passthrough 2026-05-23 00:38:37 +08:00
jxxghp
4f107a7cc8 fix: resume agent after choice callback 2026-05-23 00:28:42 +08:00
jxxghp
b26bf2a019 fix: stop agent after user choice prompt 2026-05-23 00:15:35 +08:00
jxxghp
a74f04a149 fix: simplify message typing lifecycle 2026-05-23 00:11:56 +08:00
jxxghp
cde267c55f feat: support indexer templates in Rust parser 2026-05-22 23:37:54 +08:00
jxxghp
f7b78721c3 fix: 统一消息 typing 生命周期 2026-05-22 22:59:20 +08:00
jxxghp
7e6cd47712 fix: 兼容低版本临时文件参数 2026-05-22 21:51:28 +08:00
jxxghp
4de4044a3e feat: accelerate RSS parsing with Rust 2026-05-22 21:31:18 +08:00
jxxghp
052e1ca8e4 fix: handle images in Feishu streaming replies 2026-05-22 20:43:54 +08:00
jxxghp
bd4d493f34 feat: add Rust acceleration for core parsing 2026-05-22 19:58:04 +08:00
InfinityPacer
7daeb17d85 refactor(subscribe): 统一 lack_episode 语义并暴露 completed_episode 派生字段 (#5817) 2026-05-22 19:34:25 +08:00
jxxghp
2b5528c0ac fix: keep agent typing status while queued 2026-05-22 16:46:25 +08:00
jxxghp
cb15b711b9 refactor(subscribe): unify best_version episode upgrade logic and always track downloads in note
- Simplify and centralize logic for filtering TV episodes during best_version (wash) mode, ensuring only episodes with strictly higher priority are considered for upgrade.
- Always update subscribe.note with downloaded episodes regardless of best_version state, ensuring download history is reliably tracked and available for all subscription modes.
- Remove redundant episode_group field from subscribe dict output.
- Refactor search: remove multi-page search logic, streamline concurrent site search for both sync and async paths, and update progress reporting accordingly.
- Remove obsolete tests for allowed_episodes propagation and note tracking, as logic is now unified and simplified.
2026-05-22 12:34:13 +08:00
jxxghp
9319b47fad refactor: use watchfiles for directory monitor 2026-05-22 09:15:18 +08:00
jxxghp
23487b7ae0 更新 version.py 2026-05-22 07:25:19 +08:00
jxxghp
fec109712b fix: prevent duplicate Audiences unread messages 2026-05-22 07:23:41 +08:00
jxxghp
737bcb5c62 refactor(agent): move feedback issue flow into skill scripts 2026-05-21 19:22:27 +08:00
jxxghp
b6b5529d19 fix: 优化TMDB搜索匹配优先级,title/original_title优先于别名匹配
将搜索结果匹配策略从"逐个结果完整匹配链"改为"两轮优先级匹配":
第一轮遍历所有结果只匹配title/original_title,第二轮再匹配别名译名。
避免排序靠前的无关影片因别名恰好匹配而抢先于正确结果。

Fixes #5719

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:47:59 +08:00
InfinityPacer
2bd4a41cbe fix(ci): label issue template submissions (#5812) 2026-05-21 15:31:00 +08:00
InfinityPacer
0245c8db80 feedback-issue: 拆三步、入口意图门、消息可靠性、日志脱敏与噪音过滤 (#5810) 2026-05-21 13:57:12 +08:00
jxxghp
4c64b1769d 更新 version.py 2026-05-21 11:52:14 +08:00
jxxghp
ee9eced2f1 fix: avoid blocking event loop during plugin install 2026-05-21 09:16:42 +08:00
jxxghp
2109d323ae refactor: merge episode format helper 2026-05-20 22:45:00 +08:00
jxxghp
fd4d162287 fix: respect OpenList directory path contract 2026-05-20 22:28:38 +08:00
jxxghp
617692616c fix: build complete transfer result at source 2026-05-20 22:09:57 +08:00
jxxghp
014dc2884c fix: simplify audiences unread pagination 2026-05-20 21:43:20 +08:00
Album
d37954e6bc feat: 强化集数定位模板智能自动生成 (#5801) 2026-05-20 21:41:35 +08:00
jxxghp
284c272001 fix: improve audiences message pagination 2026-05-20 21:30:14 +08:00
jxxghp
0fb9d18b30 fix: keep transfer event normalization in domain 2026-05-20 21:03:33 +08:00
jxxghp
5d34bc5c56 fix: normalize transfer event targets 2026-05-20 20:49:26 +08:00
InfinityPacer
ad7cce72f4 新增 feedback-issue Agent skill:把用户反馈整理为上游 Issue (#5799) 2026-05-20 20:10:03 +08:00
jxxghp
c52ccaf75f feat: add plugin system version compatibility checks 2026-05-20 19:55:44 +08:00
jxxghp
c661bc4764 perf: reduce agent shell command probing 2026-05-20 18:50:59 +08:00
jxxghp
8a375e022c feat: add video bit rename template field 2026-05-20 18:20:18 +08:00
jxxghp
7cc037c683 fix: suppress garbled gzip bytes in tmdb error log
When Content-Encoding is present (e.g. gzip), skip logging the raw
response text to avoid unreadable binary output in logs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 18:08:10 +08:00
jxxghp
068d0af4ca fix: remove tmdb manual gzip fallback 2026-05-20 17:09:31 +08:00
jxxghp
8f117d79f2 Revert "fix: handle tmdb gzip json responses"
This reverts commit 47c4e84fdd.
2026-05-20 17:03:47 +08:00
jxxghp
47c4e84fdd fix: handle tmdb gzip json responses 2026-05-20 16:54:01 +08:00
jxxghp
e00aa42f94 fix: prevent duplicate transfer uploads 2026-05-20 16:39:07 +08:00
jxxghp
72ead2970c fix: tolerate delayed OpenList metadata 2026-05-20 15:53:56 +08:00
jxxghp
5fe5523d13 fix: prefer rg in agent prompts 2026-05-20 15:31:02 +08:00
jxxghp
3ec0964a01 fix: handle OpenList delayed transfer metadata 2026-05-20 13:08:45 +08:00
jxxghp
a5745af484 fix: restore tmdb trending recommendations 2026-05-20 10:55:01 +08:00
jxxghp
c3e4e1a764 fix: decode raw gzip tmdb responses 2026-05-20 10:44:01 +08:00
jxxghp
b07c47551c fix: avoid none search keyword for haidan 2026-05-20 09:51:53 +08:00
InfinityPacer
9e0846961f feat(filemanager): add TransferRenameBuild chain event and fix TemplateContextBuilder concurrency (#5792) 2026-05-20 09:41:42 +08:00
jxxghp
71dc9df7ff fix: ignore expected module rate limits 2026-05-20 09:38:37 +08:00
jxxghp
6edb627145 fix: handle tmdb unicode decode errors 2026-05-20 09:21:36 +08:00
jxxghp
07f51c5d94 fix: handle invalid tmdb json responses 2026-05-20 09:05:18 +08:00
jxxghp
5d02550874 fix: cache available shell command discovery 2026-05-19 18:45:52 +08:00
jxxghp
2ff6474f0f 更新 version.py 2026-05-19 18:27:48 +08:00
jxxghp
c4eb4d9b95 feat: inject available shell commands into agent prompt 2026-05-19 18:16:09 +08:00
jxxghp
7866aee1de fix: stop torrent search paging after short pages 2026-05-19 17:54:39 +08:00
XiaoChao Fang
cdddd8e080 fix: preserve tv bluray disc folders (#5788) 2026-05-19 16:42:23 +08:00
InfinityPacer
407b60a14f feat: include episode group in subscribe source (#5787) 2026-05-19 16:03:04 +08:00
InfinityPacer
b989d08385 fix(workflow): use core torrent info for RSS action (#5786) 2026-05-19 13:04:03 +08:00
jxxghp
f46488cb9c fix: Audiences站点未读消息数解析错误
优先使用Audiences特有的解析逻辑(从"总数/未读数"格式提取真正的未读数),
仅在匹配不到时才fallback到NexusPHP基类的通用正则,避免基类将总消息数
误判为未读数导致发送大量错误通知。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:55:39 +08:00
jxxghp
34ff80e26c feat: optimize scraping for multi-server compatibility
- Add studio, country, runtime tags to NFO generation
- Fix Fanart naming: showbackground→fanart (recognized by Jellyfin/Emby)
- Add image alias system: backdrop↔fanart, thumb↔landscape
- Merge image sources from all modules instead of first-wins
- Add CLEARART and LANDSCAPE scraping metadata types
- Extend season scraping with backdrop and landscape support

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:36:47 +08:00
jxxghp
195e34563d fix: use explicit tmdb locale setting 2026-05-19 11:33:03 +08:00
jxxghp
29dab5a312 fix: improve media image scraping 2026-05-19 10:48:36 +08:00
Album
9e9c398177 feat: 新增集数定位模板生成接口 (#5785) 2026-05-19 07:18:28 +08:00
jxxghp
1f0eeb25e6 feat: add agent cli tools to docker image 2026-05-18 21:20:26 +08:00
jxxghp
3c1ff5242c feat: install ssh client in docker image 2026-05-18 21:16:58 +08:00
jxxghp
9076acc52e feat: add managed agent command sessions 2026-05-18 20:17:59 +08:00
InfinityPacer
f5eeeebeba fix(subscribe): persist best-version downloads to note and read it back (#5783) 2026-05-18 17:09:56 +08:00
TimoYoung
22bb15583d fix(alembic): ConfigParser interpolation breaks migration with special chars in PG password (#5782) 2026-05-18 17:09:02 +08:00
jxxghp
bedf06b864 feat: support multi-page resource search 2026-05-18 09:48:43 +08:00
jxxghp
cb8636e967 refactor(browser): decouple Playwright types with protocol interfaces and remove direct dependency 2026-05-18 08:46:24 +08:00
jxxghp
36a0d78f08 更新 version.py 2026-05-18 07:29:06 +08:00
InfinityPacer
23d6ba0466 fix(subscribe): stop best-version per-episode redownload loop (#5781) 2026-05-18 06:56:29 +08:00
jxxghp
6685bd0e0e fix(audiences): correctly filter unread messages by icon class and attributes 2026-05-17 21:52:13 +08:00
jxxghp
c857ae3e14 fix(tmdb): remove fallback to TMDB website scraping when API search fails 2026-05-17 19:46:50 +08:00
jxxghp
93130baf0a fix(system): improve error reporting for subprocess failures and add tests for output handling 2026-05-17 19:28:18 +08:00
jxxghp
3653164924 chore: bump version to v2.12.0 2026-05-17 15:12:01 +08:00
jxxghp
ca0127cc87 fix: adapt site imdb search urls 2026-05-17 11:43:50 +08:00
jxxghp
092666f9d2 fix: avoid double episode offset in manual transfer 2026-05-17 08:59:17 +08:00
jxxghp
7b97e2039f fix(nginx): expand SSE configuration to include logging and search stream endpoints 2026-05-17 08:29:57 +08:00
jxxghp
e168e31a8f fix: offload subtitle download after add task 2026-05-17 08:26:13 +08:00
jxxghp
3ee601574c fix: reduce low-risk pylint issues 2026-05-17 08:01:39 +08:00
jxxghp
0ee9fec1d2 feat(browser): migrate to CloakBrowser for browser emulation and streamline dependency management
- Replace Playwright-based browser emulation with CloakBrowser as default
- Update config to support CloakBrowser options and humanization presets
- Refactor browser helper to use CloakBrowser context and remove cf_clearance dependency
- Update Dockerfile, entrypoint, and update scripts to install CloakBrowser runtime
- Ensure CloakBrowser kernel is pre-installed during local setup and dependency updates
- Add tests for CloakBrowser integration and legacy compatibility
2026-05-16 20:51:38 +08:00
jxxghp
9069dccb2a fix: parse Audiences unread messages 2026-05-16 16:41:11 +08:00
jxxghp
3c055e2482 fix: avoid tmdb cached response mutation
Fixes #5777
2026-05-16 08:02:18 +08:00
jxxghp
28718094e4 Merge remote-tracking branch 'origin/v2' into v2 2026-05-15 22:43:48 +08:00
jxxghp
9b23265c3b feat(search): cache and expose last search parameters for replay and context retrieval
- Add methods to save and retrieve last search parameters in SearchChain
- Persist search params alongside results for replayable search context
- Add /last/context endpoint to fetch last search results and parameters
- Update tests to cover search param caching logic
- Allow images.tmdb.org in SECURITY_IMAGE_DOMAINS
2026-05-15 22:43:40 +08:00
jxxghp
9f61bce039 更新 version.py 2026-05-15 22:24:41 +08:00
jxxghp
1f49f9b454 fix: normalize downloader return paths
Fixes #5773
2026-05-15 18:15:42 +08:00
jxxghp
51229204c9 perf: optimize torrent filtering 2026-05-15 16:55:42 +08:00
jxxghp
2831eecbeb perf: optimize media recognition internals 2026-05-15 13:37:36 +08:00
jxxghp
b2a18f9ae4 feat(message-processing-status): unified processing status indicator for Telegram, Slack, Discord, Feishu
- Add ChannelCapability.PROCESSING_STATUS and capability detection for supported channels
- Implement mark_message_processing_started/finished in Telegram, Slack, Discord, Feishu modules
  - Telegram: manage typing lifecycle with max duration and explicit stop
  - Slack: add/remove reaction as processing indicator
  - Discord: start/stop typing indicator with async task management
  - Feishu: add/remove reaction for processing status
- Refactor message chain to invoke processing status hooks for supported channels
- Ensure processing status is properly finished on sync and async message handling paths
- Add tests for processing status lifecycle and capability detection across channels
2026-05-15 12:45:41 +08:00
jxxghp
5a06e7b8bc fix(uv-pip-compat): bind venv python for more pip commands and add tests for compatibility wrapper 2026-05-15 09:58:30 +08:00
jxxghp
f303d9e576 更新 version.py 2026-05-15 06:56:15 +08:00
InfinityPacer
b76c4edc4a fix(subscribe): prefer full packs for episode upgrades (#5771) 2026-05-15 06:50:16 +08:00
jxxghp
41da9b62c2 fix: refresh custom placeholders without restart
Closes #5770
2026-05-14 23:04:14 +08:00
jxxghp
9128955bf9 fix: preserve scalar items in list flatten
Fixes #5705
2026-05-14 22:18:59 +08:00
jxxghp
f50773711e feat: add support for syncing matching subtitle and audio files with main media during transfer 2026-05-14 21:12:02 +08:00
jxxghp
23784f614b feat: add user-friendly handling for unsupported image input errors in agent execution 2026-05-14 20:36:14 +08:00
jxxghp
7b27b7fd16 feat: add extensible agent audio capabilities 2026-05-14 19:37:13 +08:00
纯白色冰淇淋
6834d8b2c7 fix: mitigate CVE-2026-42945 by using named captures in rewrite (#5769) 2026-05-14 17:19:59 +08:00
jxxghp
4322f8a3c1 fix: preserve reasoning content for compatible llms 2026-05-14 14:01:53 +08:00
jxxghp
0f3a4e4c15 refactor: rely on transfer chain invariants 2026-05-14 07:55:33 +08:00
jxxghp
f4423e121e fix: aggregate metadata scrape events 2026-05-14 07:38:06 +08:00
DDSRem
e5b67438d9 feat: add wildcard glob support to file manager and transfer history search (#5767) 2026-05-13 21:08:51 +08:00
jxxghp
7b1ece8b83 fix https://github.com/jxxghp/MoviePilot/issues/5663 2026-05-13 21:03:54 +08:00
jxxghp
6d5cda5d51 fix https://github.com/jxxghp/MoviePilot/issues/5663 2026-05-13 21:03:48 +08:00
DDSRem
1af3a0ef59 fix: handle None items in alipan list to prevent TypeError (#5765)
When the Aliyun Drive API returns an error response (e.g. UserNotAllowedAccessDrive), resp.get("items") is None, causing len() to raise TypeError. Extract items with a safe default to fix the crash and avoid a potential infinite loop.

Fix https://github.com/jxxghp/MoviePilot/issues/5664

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 19:09:45 +08:00
DDSRem
5a585839ba chore: remove unused imports and fix function name conflicts (#5764)
- Remove unused imports in anthropic.py, tmdbv3api/__init__.py, tv.py, test files
- Rename conflicting function names in subscribe.py and webhook.py
- Clean up unused re-exports in tmdbv3api/__init__.py (15 unused exports)
- Apply consistent formatting across API endpoints
2026-05-13 18:59:03 +08:00
jxxghp
fcf6e14ac9 fix: filter invalid subtitle action links 2026-05-13 17:57:25 +08:00
jxxghp
0959c4ace4 feat: add full-season pack option for TV best-version subscriptions
- Introduce `best_version_full` field to subscribe and subscribehistory models and migration
- Update subscription logic to support only downloading full-season packs when enabled
- Extend CLI, API, and documentation to reflect new option
- Add tests for full-season best-version behavior
2026-05-13 16:53:24 +08:00
jxxghp
f0bc1bd681 fix: prevent storage operations in preview mode and add tests for transfer preview logic 2026-05-13 13:39:57 +08:00
jxxghp
f8d096f476 fix: 支持飞书语音消息识别 2026-05-13 12:45:32 +08:00
jxxghp
b24127e66f fix: map feishu user identity for agent subscribe 2026-05-13 12:14:05 +08:00
jxxghp
35eb8c51a9 chore: bump version to v2.11.2 2026-05-13 11:47:46 +08:00
jxxghp
669ca713cf fix(feishu): add configurable margin to card sections and actions for improved layout with images 2026-05-13 11:43:37 +08:00
jxxghp
f2fd28bf4d fix(feishu): place images at top of interactive cards and remove body padding for better visual layout 2026-05-13 11:31:38 +08:00
jxxghp
3852c0e43e feat(feishu): support embedding images in interactive cards and sending mixed image+file messages
- Enhance Feishu card builder to embed images using remote URLs or uploaded files
- Add logic to send image card first, then file attachment when both image and file are present
- Update card schema to 2.0 and use new button behaviors for callbacks and URLs
- Improve callback data extraction for both new and legacy card actions
- Extend tests to cover image embedding, mixed messages, and new card structure
2026-05-13 11:14:11 +08:00
jxxghp
6fb6996d81 fix: ensure stop_streaming waits for inflight initial flush before final edit; improve message edit/delete return types and logging
- Update stop_streaming logic to await inflight initial flush task, preventing duplicate message sends on stream stop
- Change message edit/delete methods to return Optional[bool] for clearer channel mismatch handling
- Refine Feishu logging to include message_id instead of full data object
- Suppress allowed_objects warnings in __init__.py
- Add test to verify stop_streaming waits for inflight flush before final edit
- Update .pylintrc to use 'E' for error enabling
2026-05-13 10:11:31 +08:00
jxxghp
4c16704ca2 feat(feishu): add info logging for successful message and reply responses 2026-05-13 08:53:39 +08:00
jxxghp
f017eaedcc feat: add logging for Feishu streaming card updates and handle update failures more explicitly 2026-05-13 08:44:57 +08:00
jxxghp
19526146c5 docs: update docstrings for message metadata and reply fields; fix markdown capability check in format instructions; improve streaming card update logic in Feishu 2026-05-13 08:36:27 +08:00
jxxghp
7b4cb2097b refactor(feishu): promote download helper methods to public, update call sites and tests 2026-05-13 08:19:16 +08:00
jxxghp
b6062a9ce2 fix(feishu): unconditionally inc streaming sequence to prevent locking; send fallback as normal msg 2026-05-13 08:08:59 +08:00
jxxghp
ea8a90aa0a fix(feishu): handle more IM websocket events 2026-05-13 07:10:44 +08:00
jxxghp
fa939dfbe6 fix(agent): reply Feishu agent streams with cards 2026-05-13 06:56:03 +08:00
jxxghp
77aa65bfdc fix(agent): route streaming finalization through channel modules 2026-05-13 00:50:53 +08:00
jxxghp
d86d24fc4f feat(feishu): enhance message handling with file and voice support, add reaction management 2026-05-13 00:34:03 +08:00
jxxghp
0989439d25 feat(feishu): enhance message handling with file and voice support, add reaction management 2026-05-13 00:07:36 +08:00
jxxghp
a46ce24691 feat(venv): add uv compatibility for pip commands and enhance virtual environment setup 2026-05-12 23:01:27 +08:00
jxxghp
57bb67e547 feat(feishu): enhance message target resolution and add user ID type handling 2026-05-12 22:39:10 +08:00
jxxghp
5e5c257b75 fix(tmdb): cache serializable response snapshots
Avoid caching raw HTTP response objects in TMDb request helpers so Redis no longer fails on embedded locks. Add an isolated regression test for sync and async request snapshots.

Refs #5763
2026-05-12 22:31:56 +08:00
jxxghp
624862dfc6 feat(notification): add Feishu notification channel support 2026-05-12 21:40:55 +08:00
DDSRem
b172a6d08f fix(telegram): handle caption messages in group chat mention detection (#5761)
* fix(telegram): handle caption messages in group chat @mention detection

Telegram media messages (photos, videos, etc.) store their text in
`message.caption` and mention entities in `message.caption_entities`,
not `message.text` / `message.entities`. The previous code only checked
`message.text`, so commands and @mentions sent with media were silently
skipped with "No text..." in the debug log.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(telegram): use correct entity field pairing and UTF-16 offsets for mention detection

- Explicitly pair message.entities with text messages and caption_entities
  with media messages, avoiding false fallthrough on empty entity lists
- Decode mention text via UTF-16-LE encoding to respect Telegram's UTF-16
  based offset/length values, fixing incorrect slicing when emojis are present

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 20:57:31 +08:00
DDSRem
116465b6d8 chore(nginx): increase default upload size to 50M (#5760) 2026-05-12 20:08:33 +08:00
DDSRem
cfb6448060 chore: add AI tool configs and refine .gitignore for AI settings (#5759) 2026-05-12 19:45:48 +08:00
DDSRem
10a9e7293a fix(workflow): use MediaChain instead of SearchChain for recognize_by_meta in fetch_torrents
SearchChain does not have a recognize_by_meta method; this belongs to MediaChain.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 18:51:06 +08:00
jxxghp
fc2c77fbf1 fix(agent): refresh LLM runtime config on each call
Read the latest LLM connection settings when building runtime clients so Web updates take effect immediately instead of reusing module-import defaults.

Closes #5757
2026-05-12 18:48:31 +08:00
DDSRem
e4721fef0c fix(plugin): allow non-core dependency upgrades during plugin install
When a plugin requires a newer minor version of a non-core dependency
(e.g. sentry-sdk ~=2.59.0 while 2.58.0 is installed), the conflict
check now distinguishes upgrade-only conflicts from downgrade conflicts.
Non-core packages that only need upgrading are allowed through; the
runtime constraints file uses >= instead of == for non-core packages so
pip can satisfy the upgrade without risking a downgrade.

Core packages (fastapi, pydantic, sqlalchemy, etc.) remain strictly
pinned and any version mismatch is still rejected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 17:39:28 +08:00
jxxghp
2c45831714 feat(subscribe): add episode priority tracking for subscription updates 2026-05-12 17:22:50 +08:00
jxxghp
9068280f6d refactor(tests): simplify test setup by removing unused stubs and imports 2026-05-12 14:00:50 +08:00
jxxghp
ea88f272a6 feat(system-settings): add unified tools for querying and updating system settings 2026-05-12 13:55:52 +08:00
jxxghp
ac090af606 feat(plugin): enhance dependency management by protecting main program dependencies and refining runtime constraints 2026-05-12 12:38:17 +08:00
InfinityPacer
1c17c0b07e fix(zspace): honor _sync_libraries when counting medias by views 2026-05-12 11:56:44 +08:00
InfinityPacer
db6321d032 fix(zspace): classify views by sampling first item when CollectionType missing 2026-05-12 11:56:44 +08:00
InfinityPacer
d6270dfb81 fix(zspace): degrade library refresh logs when endpoints return 404 2026-05-12 11:56:44 +08:00
InfinityPacer
cc52bdaaf3 fix(zspace): get_latest queries Users/{uid}/Items sorted by DateCreated 2026-05-12 11:56:44 +08:00
InfinityPacer
cbc8592b49 fix(zspace): sum per-view TotalRecordCount when Items/Counts is unavailable 2026-05-12 11:56:44 +08:00
InfinityPacer
14d648445e fix(zspace): fall back to Users/{uid}/Views for virtual folders 2026-05-12 11:56:44 +08:00
InfinityPacer
87777343d2 fix(zspace): fall back to Users/{uid}/Views for library folders 2026-05-12 11:56:44 +08:00
InfinityPacer
26aa49f323 fix(zspace): degrade get_user_count log when Users/Query is not implemented 2026-05-12 11:56:44 +08:00
InfinityPacer
ad8b6473fc fix(zspace): fall back to current user when Users list endpoint is unavailable 2026-05-12 11:56:44 +08:00
InfinityPacer
c32df7446d fix(zspace): drop Users/Me fallback to avoid mediaUid 400 2026-05-12 11:56:44 +08:00
album
05b34b9c26 feat(transfer): 增加手动整理预览模式(preview mode)
- ManualTransferItem/TransferTask 增加 preview 字段,支持同一接口双模式
- /api/v1/transfer/manual 透传 preview,预览时返回结构化结果不落盘
- ChainBase.transfer 增加 preview 参数并透传到 run_module
- TransferChain.do_transfer 预览分支复用完整命名/覆盖判定逻辑(dry-run)
- TransferChain.do_transfer 预览结束后显式 finish_task/fail_task,避免任务残留 running 状态导致重复预览失败
- TransHandler.transfer_media 预览分支跳过实际 copy/move/link/delete,仅返回目标路径
- FileManagerModule.transfer 透传 preview 参数
- 修复 /manual 失败分支 dict 类型导致 Response.message 校验错误
- 兼容性:preview 字段有默认值,旧客户端不传参时行为不变
2026-05-12 10:14:58 +08:00
jxxghp
99fbeecfa1 chore: update app version to v2.11.1-1 2026-05-11 22:32:11 +08:00
jxxghp
41477601c7 feat: add test for ILinkClient connection and handle ilink_user_id error gracefully 2026-05-11 22:30:13 +08:00
jxxghp
a6ab9b76c1 feat: refactor ZSpace media server request handling and improve authorization headers 2026-05-11 22:24:15 +08:00
jxxghp
a62b6b6fd5 fix: correct plugin dependency package lookup 2026-05-11 21:24:14 +08:00
jxxghp
75a52ad751 更新 version.py 2026-05-11 18:37:00 +08:00
jxxghp
a2fa8d6f28 feat: rename methods for clarity in ZSpace media server integration 2026-05-11 18:35:20 +08:00
jxxghp
ed9116d81e feat: refactor ZSpace media server integration and enhance item management methods 2026-05-11 18:21:21 +08:00
jxxghp
6db1dd2067 feat: add ZSpace media server support with authentication and item management 2026-05-11 18:09:38 +08:00
Aqr-K
0fb11880a4 perf(http): AsyncRequestUtils 默认启用 HTTP/2
为 AsyncRequestUtils 增加 http2: bool = True 参数(默认开启),
内部贯穿到 _get_shared_async_transport 与 path C 兜底 AsyncClient。
http2 加入共享 AsyncHTTPTransport 桶 key,让不同 h2 设置自动隔离。

启用基于 TLS ALPN:服务端宣告支持 h2 时切到 HTTP/2 多路复用;
不支持(含明文 HTTP、老 nginx/Apache)透明回落 HTTP/1.1。如个别
站点 h2 实现异常,调用方传 http2=False 单独关闭。

依赖:httpx extras 由 [socks] 扩展为 [socks,http2],引入纯 Python
包 h2 / hpack / hyperframe(无原生扩展)。

真实 TMDB 压测(30 部美剧 × 每部 50 集 = 3060 请求/版本):
HTTP/1.1 52.0s → HTTP/2 27.6s,节省 24.4s(1.88×)。
单请求 p95 由 96.1ms 降至 20.1ms,长尾大幅收敛。

公共 API 表面零变动;插件可按需 http2=False 单点关闭。
2026-05-11 17:15:23 +08:00
jxxghp
b7fc5b0203 feat: refine job handling by filtering active jobs and updating date context in prompts 2026-05-11 13:15:32 +08:00
jxxghp
1b2433f7c2 feat: implement runtime dependency checks and recovery for plugin installations 2026-05-11 08:54:34 +08:00
Aqr-K
c745616495 perf(http): 异步 HTTP 引入共享 AsyncHTTPTransport,复用 TCP/TLS 握手
AsyncRequestUtils 使用按事件循环弱引用持有的共享
AsyncHTTPTransport 作为底层连接池与 TLS 会话;每次请求创建轻量
AsyncClient 承载本次 cookie jar、timeout、follow_redirects,
用完即销毁。共享 transport 由 _NonClosingTransportProxy 包装后
注入 AsyncClient,吞掉 AsyncClient 退出时向底层 transport
传播的 __aexit__/aclose,使底层连接池跨调用持久,从而真正复用
TCP/TLS 握手。

设计要点:
- 共享 transport 桶按 (proxy, verify, max_keepalive_connections,
  max_connections, keepalive_expiry) 区分;每事件循环 32 桶 LRU
  上限,超出后异步关闭最久未用桶;关闭 task 由模块级强引用集合
  持有以兼容 Python 3.11+ 的任务 GC 行为。
- 通过 FastAPI lifespan shutdown 调用 aclose_shared_async_transports
  集中释放底层 transport,避免 ResourceWarning。
- AsyncRequestUtils.request 走三条 path:用户自管 client / 共享
  transport + per-call AsyncClient / 兜底临时 client。三条路径
  cookie 语义一致;后两条因 per-call AsyncClient 生命周期局限于
  单次调用,天然不积累 Set-Cookie,避免跨调用 jar 演化串扰。
- _make_request 对幂等方法(GET/HEAD/OPTIONS)在
  RemoteProtocolError / ReadError / WriteError 时单次重试,
  容忍 keep-alive stale 连接命中;非幂等方法不重试,但记录
  debug 日志。
- get_stream 使用 httpx.AsyncClient.stream() 标准流式 API,与
  request 共用三条 path 的 client 选择逻辑;幂等单次重试;
  yield 体异常透传给 stream 的 __aexit__。

公共 API 表面零变动。插件可通过 max_keepalive_connections /
max_connections / keepalive_expiry 三个 limits 参数为自己定制
连接池容量与握手有效期。

TMDB 真实压测(10 部美剧 × 每部 50 集,1020 请求):
61.96s → 18.15s(3.41×),单请求 p95 149.6ms → 38.1ms。
2026-05-11 08:46:40 +08:00
jxxghp
888ccfcfc2 feat: add detailed docstrings for methods in WechatClawBot and related modules 2026-05-11 08:25:25 +08:00
jxxghp
3c9228c2f8 feat: enhance iLink polling logic to support multiple payload formats and improve success determination 2026-05-11 08:02:17 +08:00
jxxghp
3776422634 fix: tighten wechatclawbot poll protocol handling 2026-05-11 07:15:04 +08:00
jxxghp
5021b2c86f feat: implement message deduplication and enhance error handling in WechatClawBot 2026-05-10 23:40:22 +08:00
jxxghp
412e10972f fix: optimize client instantiation in message sending logic 2026-05-10 23:00:47 +08:00
jxxghp
d0b1b3d7f0 feat: add QR code URL normalization for compatibility with various formats 2026-05-10 22:10:53 +08:00
jxxghp
f5fea25b41 fix: migrate wechat clawbot login cache on rename 2026-05-10 21:50:33 +08:00
jxxghp
68706d3d5b feat: add standalone wechat clawbot notifications 2026-05-10 21:47:35 +08:00
jxxghp
b768ed8fed 更新 version.py 2026-05-10 15:27:54 +08:00
jxxghp
c4d3d28491 fix: avoid blocking Ugreen startup on library preload
Delay Ugreen library loading until it is needed and cap poster wall pagination so a single Ugreen server cannot hang backend startup.\n\nFixes #5740
2026-05-10 12:19:36 +08:00
jxxghp
1862a7ab4b feat: expose download save paths in API
Return configured download directories as API-ready save_path values so external integrations can choose download destinations without guessing local or remote path syntax.

Fixes #5737
2026-05-10 12:02:22 +08:00
jxxghp
adb7aa6aa9 fix: prevent repeated scans after history-based exits
Only mark downloader tasks as organized after a transfer history record exists, including existing-history skips and unrecognized media failures.
2026-05-10 10:25:39 +08:00
jxxghp
79eb128196 refactor: streamline media recognition by removing MetaInfoPath usage 2026-05-10 09:26:13 +08:00
jxxghp
4d132c424a fix: avoid duplicate image fetch in transfer
Keep transfer recognition results ready for scraping without fetching images twice on the same path. Also ensure redo-by-path transfer recognition still populates image data before metadata scraping.
2026-05-10 08:22:27 +08:00
jxxghp
c52327c248 fix: only fetch images for scrape flows
Default title and path recognition to skip image fetching, while keeping scrape entrypoints and transfer-to-scrape paths populated with image data. This preserves lightweight recognition behavior without breaking metadata scraping.
2026-05-10 08:14:08 +08:00
jxxghp
1d97f2e043 fix: align media recognition fallback and shared reporting
Route title and path lookups through the fallback-aware entrypoints so auxiliary matches can reuse pre-assist keywords without forcing image fetches in lightweight flows. Also reduce noisy agent shutdown logging during cleanup.
2026-05-10 07:54:55 +08:00
Attente
ee9ea54ab7 feat(fanart): 添加异步支持并优化图片处理逻辑 2026-05-10 00:07:28 +08:00
jxxghp
4027ae2641 feat: add configurable data cleanup settings
Add a global cleanup switch, per-table retention periods, and scheduler config reload support so data cleanup can be managed and applied without restarting.
2026-05-09 21:22:02 +08:00
jxxghp
bc6c61bc45 fix(mediaserver): sync library data incrementally 2026-05-09 21:18:20 +08:00
jxxghp
cd5e693302 refactor: adjust database indexes by adding high-frequency composite indexes and removing redundant id indexes 2026-05-09 20:04:05 +08:00
jxxghp
ac11b303b3 fix: scheduled data cleanup chain 2026-05-09 18:30:55 +08:00
jxxghp
a7823fb4d1 feat: implement data cleanup chain for batch deletion of expired records 2026-05-09 14:04:10 +08:00
jxxghp
45d47d32f8 fix: optimize SSE event streaming with batched processing 2026-05-09 13:23:05 +08:00
jxxghp
893b8eba86 fix: remove unnecessary reporting for cache misses in media recognition 2026-05-09 12:01:14 +08:00
jxxghp
f9b987c3ef fix: enhance logging for shared media recognition with item details 2026-05-09 11:52:05 +08:00
jxxghp
4ef8b0ba99 fix: 修复订阅刷新共享识别缓存回填异常 2026-05-09 11:25:45 +08:00
InfinityPacer
268414fb11 test(mediaserver): cover stale tv item id fallback 2026-05-09 06:54:39 +08:00
InfinityPacer
bedab9ab92 fix(mediaserver): fallback stale tv item ids 2026-05-09 06:54:39 +08:00
jxxghp
94d7e4385e fix: update shared recognize cache flow 2026-05-08 21:21:01 +08:00
jxxghp
64b4de3900 fix: use original name for media recognize share 2026-05-08 20:36:33 +08:00
InfinityPacer
a59afe4cc9 fix(plugin): avoid clearing runtime modules after dependency install 2026-05-08 18:38:09 +08:00
InfinityPacer
7b6047accf fix(plugin): clear stale modules on reload 2026-05-08 18:37:21 +08:00
jxxghp
e217d1aa05 feat(recognize): implement media recognition sharing functionality with API integration 2026-05-08 18:08:43 +08:00
jxxghp
52e15b51db fix(cli): align frontend download with version.py
Use FRONTEND_VERSION from version.py as the default frontend release target so local setup and auto-update install the matching frontend bundle.

Closes #5693
2026-05-08 15:49:32 +08:00
jxxghp
0dab3f087d Merge remote-tracking branch 'origin/v2' into v2 2026-05-08 15:16:34 +08:00
jxxghp
e4c5a4f232 feat(provider): add kuaishou-wanqing endpoint with base URL presets and manual model input 2026-05-08 15:16:29 +08:00
InfinityPacer
a729307d30 feat(subscribe): preserve candidate match identity 2026-05-08 14:53:22 +08:00
InfinityPacer
98347669ea feat(search): mark search result context source 2026-05-08 14:53:22 +08:00
InfinityPacer
9e4020c617 feat(torrents): tag cached candidate recognition source 2026-05-08 14:53:22 +08:00
InfinityPacer
2f231fe632 feat(context): add recognition context metadata 2026-05-08 14:53:22 +08:00
jxxghp
14b366a648 refactor: adjust default and maximum limits for plugin candidates and torrent results; enhance result formatting for agents 2026-05-08 14:47:20 +08:00
jxxghp
0a0d5e6da2 docs: update AGENTS.md to improve clarity and consistency in project guidelines 2026-05-08 14:00:31 +08:00
jxxghp
3dbb68627f refactor(provider): update sort orders and add new providers 2026-05-08 13:42:31 +08:00
jxxghp
f157b61dfa docs: update AGENTS.md to clarify repository structure and guidelines 2026-05-08 13:29:18 +08:00
jxxghp
44f975baf4 docs: add comprehensive guide for MoviePilot AI Agent behavior and conventions 2026-05-08 13:17:10 +08:00
jxxghp
28ec4a6ac0 refactor(provider): update cache TTL for models.dev data to one week 2026-05-08 13:08:49 +08:00
jxxghp
1140a85402 feat(provider): implement fallback to bundled models.dev data on fetch failure 2026-05-08 13:00:27 +08:00
jxxghp
c6d95cd006 refactor(agent): consolidate provider preset resolution 2026-05-08 12:35:02 +08:00
jxxghp
c9931aa948 refactor(agent): remove MiniMax legacy alias 2026-05-08 11:43:10 +08:00
jxxghp
ec4f13dd79 feat(agent): merge MiniMax coding presets 2026-05-08 10:52:30 +08:00
jxxghp
d43ef610c7 feat(provider): add Baidu Qianfan and JDCloud support with base URL presets 2026-05-08 09:46:12 +08:00
jxxghp
05d720d81f feat(agent): expand LLM provider and wizard support 2026-05-08 08:09:50 +08:00
jxxghp
2d2c2a01eb refactor(core): enhance site operations and clarify media management workflow 2026-05-07 20:30:24 +08:00
jxxghp
226f9c9318 fix(system): extend graceful shutdown timeout to 180 seconds 2026-05-07 20:09:23 +08:00
jxxghp
b77b5a21c5 更新 version.py 2026-05-07 19:16:42 +08:00
jxxghp
82b637532e 更新 _torrent_search_utils.py 2026-05-07 17:31:22 +08:00
jxxghp
c2c9950bb1 fix(postgresql): support unix socket connections
Allow PostgreSQL socket paths without forcing a TCP port and reuse a single URL builder for sync, async, and migration flows. Document Redis socket URLs and close the socket connection request. Closes #5720
2026-05-07 13:22:14 +08:00
jxxghp
ffbe348d66 fix(openlist): paginate Alist directory listings
Fetch all OpenList/AList fs/list pages when using the default per_page=0 to avoid truncating directories at 200 entries. Add an isolated regression test for the auto-pagination behavior.

Fixes #5723
2026-05-07 13:09:28 +08:00
jxxghp
6d7b0733af fix(transfer): avoid polluted history fallback at shared roots
Parent-path fallback should stop at shared download roots so an old root-level download cannot hijack unrelated manual transfers. Keep exact DownloadFiles matches allowed at the shared root to preserve valid no-subfolder lookups.

Closes #5716

Regression from afcd895f52

Follow-up to #5702
2026-05-07 13:01:20 +08:00
jxxghp
49a51cca25 fix(media): use Jellyfin-compatible season artwork names
Save season artwork in season folders with standard names like poster.jpg so Jellyfin can recognize them. Fixes #5725.
2026-05-07 12:54:38 +08:00
jxxghp
06197144c0 refactor(qbittorrent): convert static methods to instance methods for better encapsulation 2026-05-07 08:25:34 +08:00
jxxghp
62541ffe43 fix(qbittorrent): restore qBittorrent 5.2 compatibility
Support WebUI API Key auth, newer add responses, and cookie sync so qBittorrent 5.2 can connect reliably while keeping legacy fallback behavior.

Fixes #5724
2026-05-07 07:41:05 +08:00
jxxghp
c762628217 fix(agent): preserve full command output in temp logs
Return only a 10KB preview to the agent so large command results do not overwhelm conversations while keeping the full output available for follow-up reads. Add pytest to the project dependencies to make the regression tests runnable in the project venv.
2026-05-06 20:04:17 +08:00
510 changed files with 86274 additions and 9126 deletions

1
.cursorrules Normal file
View File

@@ -0,0 +1 @@
AGENTS.md

View File

@@ -73,6 +73,7 @@ test_*
build/
dist/
*.egg-info/
rust/**/target/
# Docker
Dockerfile*
@@ -81,4 +82,4 @@ docker-compose*
# Other
app.ico
frozen.spec
frozen.spec

1
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1 @@
AGENTS.md

View File

@@ -46,7 +46,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v7
with:
context: .
file: docker/Dockerfile
@@ -56,5 +56,5 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha, scope=${{ github.workflow }}-docker
cache-to: type=gha, scope=${{ github.workflow }}-docker
cache-from: type=gha,scope=moviepilot-docker,version=2
cache-to: type=gha,scope=moviepilot-docker,mode=max,version=2

View File

@@ -56,7 +56,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v7
with:
context: .
file: docker/Dockerfile
@@ -66,8 +66,8 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha, scope=${{ github.workflow }}-docker
cache-to: type=gha, scope=${{ github.workflow }}-docker
cache-from: type=gha,scope=moviepilot-docker,version=2
cache-to: type=gha,scope=moviepilot-docker,mode=max,version=2
- name: Generate Changelog
id: changelog

View File

@@ -2,13 +2,138 @@ name: Close inactive issues
on:
workflow_dispatch:
issues:
types: [opened, edited]
schedule:
# Github Action 只支持 UTC 时间。
# '0 18 * * *' 对应 UTC 时间的 18:00也就是中国时区 (UTC+8) 的第二天凌晨 02:00。
- cron: "0 18 * * *"
jobs:
label-opened-issue:
if: github.event_name == 'issues'
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
const title = issue.title || '';
const body = issue.body || '';
const currentLabels = (issue.labels || []).map((label) => label.name);
// 网页 Issue Form 已经会自动带模板 labels这里只兜底处理
// API 创建或异常路径产生的无 label issue避免重复补标。
if (currentLabels.length > 0) {
core.info(`Issue #${issue.number} already has labels: ${currentLabels.join(', ')}`);
return;
}
const hasAllMarkers = (markers) => markers.every((marker) => body.includes(marker));
const labelRules = [
{
label: 'bug',
titlePrefix: '[错误报告]:',
markers: ['### 当前程序版本', '### 运行环境', '### 问题类型', '### 问题描述'],
},
{
label: 'feature request',
titlePrefix: '[Feature Request]:',
markers: ['### 当前程序版本', '### 运行环境', '### 功能改进类型', '### 功能改进'],
},
{
label: 'RFC',
titlePrefix: '[RFC]',
markers: ['### 背景 or 问题', '### 目标 & 方案简述'],
},
];
const matched = labelRules.find((rule) => (
title.startsWith(rule.titlePrefix) || hasAllMarkers(rule.markers)
));
if (!matched) {
core.info(`Issue #${issue.number} does not match known issue templates.`);
return;
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: [matched.label],
});
core.info(`Added label "${matched.label}" to issue #${issue.number}.`);
label-unlabeled-issues:
if: github.event_name != 'issues'
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- uses: actions/github-script@v7
with:
script: |
const labelRules = [
{
label: 'bug',
titlePrefix: '[错误报告]:',
markers: ['### 当前程序版本', '### 运行环境', '### 问题类型', '### 问题描述'],
},
{
label: 'feature request',
titlePrefix: '[Feature Request]:',
markers: ['### 当前程序版本', '### 运行环境', '### 功能改进类型', '### 功能改进'],
},
{
label: 'RFC',
titlePrefix: '[RFC]',
markers: ['### 背景 or 问题', '### 目标 & 方案简述'],
},
];
const hasAllMarkers = (body, markers) => markers.every((marker) => body.includes(marker));
const getMatchedRule = (issue) => {
const title = issue.title || '';
const body = issue.body || '';
return labelRules.find((rule) => (
title.startsWith(rule.titlePrefix) || hasAllMarkers(body, rule.markers)
));
};
// Search API 支持 no:label 查询issues.listForRepo 的 labels=none
// 会被当作名为 none 的标签,不能用于扫描无 label issue。
const query = `repo:${context.repo.owner}/${context.repo.repo} is:issue is:open no:label`;
for await (const response of github.paginate.iterator(github.rest.search.issuesAndPullRequests, {
q: query,
per_page: 100,
})) {
for (const issue of response.data) {
if (issue.pull_request) {
continue;
}
const matched = getMatchedRule(issue);
if (!matched) {
continue;
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: [matched.label],
});
core.info(`Added label "${matched.label}" to issue #${issue.number}.`);
}
}
close-issues:
if: github.event_name != 'issues'
needs: label-unlabeled-issues
runs-on: ubuntu-latest
permissions:
issues: write
@@ -30,4 +155,4 @@ jobs:
# 排除带有RFC标签的issue
exempt-issue-labels: "RFC"
operations-per-run: 500
repo-token: ${{ secrets.GITHUB_TOKEN }}
repo-token: ${{ secrets.GITHUB_TOKEN }}

55
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,55 @@
name: Unit Tests
on:
# 指向 v2 的 PR 与推送都跑全量单测,作为合并门禁
pull_request:
branches:
- v2
push:
branches:
- v2
# 允许手动触发
workflow_dispatch:
permissions:
contents: read
concurrency:
group: unit-tests-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
pytest:
runs-on: ubuntu-latest
name: Unit Tests
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- name: Cache pip dependencies
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.in', '**/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install dependencies
run: |
python -m pip install --upgrade pip setuptools wheel
# 用 requirements.in 还原 CI / 全新环境(含 pytest~=8.4 与 moviepilot-rust 等可选扩展),
# 与本地"干净 venv 复现"一致;测试运行器 pytest 已在 requirements.in 中声明。
pip install -r requirements.in
- name: Run tests
run: |
# tests/run.py 以 pytest 跑 tests 全量tests/conftest.py 在收集前把 CONFIG_DIR
# 指向临时库并建表,测试杜绝真实网络/外部服务(详见 docs/testing.md
python tests/run.py

8
.gitignore vendored
View File

@@ -6,6 +6,7 @@
build/
cython_cache/
dist/
rust/**/target/
nginx/
test.py
safety_report.txt
@@ -21,6 +22,7 @@ config/user.db*
config/sites/**
config/agent/
config/logs/
config/plugins/
config/temp/
config/cache/
.runtime/
@@ -28,6 +30,7 @@ public/
.moviepilot.env
*.pyc
*.log
.coverage
.vscode
venv
@@ -37,3 +40,8 @@ pylint-report.json
# AI
.claude/
!.claude/*.json
.claude/settings.local.json
# Superpowers 设计/计划文档(本地协作产物,不纳入仓库)
docs/superpowers/

View File

@@ -5,38 +5,30 @@ init-hook='import sys; sys.path.append(".")'
# 忽略的文件和目录
ignore=.git,__pycache__,.venv,build,dist,tests,docs
# 通过 `pylint app/` 检查主程序时不扫描内置插件目录,
# 插件依赖和动态模型较多,容易产生与主程序无关的误报。
ignore-paths=^app/plugins(/|$)
# 并行作业数量
jobs=0
[MESSAGES CONTROL]
# 只关注错误级别的问题,禁用警告、约定和重构建议
# E = Error (错误) - 会导致构建失败
# W = Warning (警告) - 仅显示,不会失败
# R = Refactor (重构建议) - 仅显示,不会失败
# C = Convention (约定) - 仅显示,不会失败
# I = Information (信息) - 仅显示,不会失败
# 禁用大部分警告、约定和重构建议,只保留错误和重要警告
# 只启用确定性较强的严重问题检查,避免 SQLAlchemy、FastAPI 依赖注入、
# 第三方 SDK 等动态对象被 Pylint 推断成误报。
disable=all
enable=error,
syntax-error,
enable=syntax-error,
undefined-variable,
used-before-assignment,
possibly-used-before-assignment,
unreachable,
return-outside-function,
yield-outside-function,
continue-in-finally,
nonlocal-without-binding,
undefined-loop-variable,
redefined-builtin,
not-callable,
assignment-from-no-return,
no-value-for-parameter,
too-many-function-args,
unexpected-keyword-arg,
redundant-keyword-arg,
import-error,
relative-beyond-top-level
relative-beyond-top-level,
no-name-in-module
[REPORTS]
# 设置报告格式
@@ -80,4 +72,6 @@ ignore-imports=yes
[TYPECHECK]
# 生成缺失成员提示的类列表
generated-members=requests.packages.urllib3
generated-members=requests.packages.urllib3
# app.helper.sites 会主动隐藏模块属性枚举,避免误报 no-name-in-module
ignored-modules=app.helper.sites

107
AGENTS.md Normal file
View File

@@ -0,0 +1,107 @@
# AGENTS.md
This file is the primary instruction set for all AI agents and LLMs working in this repository. Local documentation takes precedence over general training data. You must follow this file and the rule documents it references.
---
## Task-to-Documentation Mapping
Before executing any task, identify the domain and load the corresponding document.
### Architectural Decisions
* **Primary Reference:** `docs/rules/05-architecture.md`
* **Required Constraints:** Respect layer boundaries and dependency flow. Do not introduce circular dependencies. Verify the correct layer for any new capability before implementing.
### Business Logic and Design Patterns
* **Primary Reference:** `docs/rules/04-design-patterns.md`
* **Required Constraints:** Use the project's established Module, Chain, Event, and Oper structural patterns. Do not introduce abstractions the project has not adopted.
### Coding Standards and Style
* **Primary Reference:** `docs/rules/06-code-styles.md`
* **Required Constraints:** Match the style of the surrounding file. Type annotations, Pydantic models, and async/await usage must all conform to the documented standards.
### Identifiers and Naming
* **Primary Reference:** `docs/rules/07-naming-conventions.md`
* **Required Constraints:** All filenames, class names, function names, and constants must follow the project's taxonomy. No arbitrary abbreviations or mixed casing styles.
### Comments and Documentation
* **Primary Reference:** `docs/rules/08-comment-styles.md`
* **Required Constraints:** All public classes and methods require Chinese docstrings. Comments must explain the *why*, not restate the code.
* **⚠️ MANDATORY GATE:** Code that is missing proper Chinese docstrings on public interfaces is **REJECTED** at review. No exceptions.
### External Communication and Interfaces
* **Primary Reference:** `docs/rules/09-external-response.md`
* **Required Constraints:** All third-party HTTP requests must go through `RequestUtils`. Response formats must use the project's standard schemas. Error handling must follow the per-layer conventions.
### Data and Persistence
* **Primary Reference:** `docs/rules/10-data-and-persistent.md`
* **Required Constraints:** Any database model change requires a matching Alembic migration. Runtime configuration must be managed via `SystemConfigKey` + `SystemConfigOper`. Raw string keys are forbidden.
### Quality and Security
* **Primary Reference:** `docs/rules/11-quality-and-security.md`
* **Required Constraints:** All code changes must pass the relevant pytest tests and pylint checks. Dependency changes require a passing safety scan.
### Testing
* **Primary Reference:** `docs/testing.md`
* **Required Constraints:** pytest is the only runner; `tests/conftest.py` isolates each run to a temporary `CONFIG_DIR`. Tests must not touch the real database, network, or external services (TMDB, LLM catalogs, downloaders, media servers, MP server) — mock at the boundary or replay recorded responses; the bar is zero real outbound traffic. Tests must restore any process-level state they stub (`sys.modules`, singletons, caches, settings). New tests must be pytest-native (function + `assert` + fixtures); do not add new `unittest.TestCase`. Convert existing `TestCase` files to pytest-native opportunistically when you modify them. Before opening a PR to `v2`, run the full suite locally (`python tests/run.py`) and confirm it is green with zero real network calls; the `.github/workflows/test.yml` gate runs the same suite on every PR/push to `v2`.
### Commands and Development Workflow
* **Primary Reference:** `docs/rules/03-commands.md`
* **Required Constraints:** Only suggest or execute commands documented in that file. Do not assume tool defaults or global flags.
---
## Agent Execution Rules
### Pre-Flight Check
Before generating any code or proposing changes, you must:
1. Identify the task domain (architecture / business logic / coding style / naming / comments / external interfaces / data / quality).
2. Load the corresponding document from `docs/rules/`.
3. Explicitly verify that your proposed solution does not violate the following three mandatory constraints:
- **Naming Conventions (07):** Are all files, classes, functions, and constants named correctly?
- **Architecture Boundaries (05):** Is the code placed in the correct layer? Are all call directions valid?
- **Comment Standards (08):** Do all new public classes and methods include Chinese docstrings?
### Implementation Guidelines
* **Pattern Adherence:** Avoid generic boilerplate. If `04-design-patterns.md` defines a project-level pattern for a scenario, you are required to use it.
* **Documentation Standards:** Docstring style for any new function or module must match `08-comment-styles.md`.
* **⚠️ MANDATORY GATE:** Public classes, methods, and functions without proper Chinese docstrings are **REJECTED**. No exceptions.
* **Command Reliance:** Only suggest commands listed in `03-commands.md`. Do not rely on inferred tool defaults.
* **Minimal Change Principle:** Prefer the smallest correct change. Do not perform unrelated refactors, mass renames, or formatting-only cleanup.
* **Output Language:** Summaries, validation results, and risk notes default to Chinese unless the user requests otherwise.
### Conflict Resolution
If existing code appears to contradict the documentation:
1. Stop implementation immediately.
2. Identify the specific file and line of the contradiction.
3. Prompt the user: "The documentation in `[File]` requires Pattern A, but the current implementation uses Pattern B. Which is the current standard?"
---
## Coupled Update Rules
When modifying the following, you must also update the listed artifacts:
| Changed Content | Must Also Update |
|---|---|
| CLI behavior | `moviepilot` entrypoint, `docs/cli.md`, related tests |
| MCP / REST API, exposed tools | `docs/mcp-api.md`, `skills/*/SKILL.md`, related tests |
| Dev workflow, dependency management, security checks | `docs/development-setup.md` |
| Database model schema | New Alembic migration under `database/versions/` |
| User-visible config or init flow | Related docs, help text, setup/init flows, tests |
| New skill | Follow `skills/<name>/SKILL.md` structure, keep YAML front matter |
---
## Primary Entry Point
For the full documentation map and cross-references, refer to:
**[Documentation Hub Index](./docs/rules/README.md)**
*Last Updated: 2026-05-25*

1
CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
AGENTS.md

View File

@@ -1,4 +1,3 @@
# MoviePilot
简体中文 | [English](README_EN.md)
@@ -12,51 +11,56 @@
![Docker Pulls V2](https://img.shields.io/docker/pulls/jxxghp/moviepilot-v2?style=for-the-badge)
![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20Synology-blue?style=for-the-badge)
基于 [NAStool](https://github.com/NAStool/nas-tools) 部分代码重新设计,聚焦自动化核心需求,减少问题同时更易于扩展和维护。
# 仅用于学习交流使用,请勿在任何国内平台宣传该项目!
发布频道https://t.me/moviepilot_channel
## 主要特性
- 前后端分离基于FastApi + Vue3
- 聚焦核心需求,简化功能和设置,部分设置项可直接使用默认值
- 重新设计了用户界面,更加美观易用
- 聚焦影视自动化的核心流程:订阅、搜索、下载、整理、刮削、媒体库刷新与消息通知
- 前后端分离,后端基于 FastAPI前端基于 Vue 3部署和扩展边界更清晰
- 支持下载器、媒体服务器、元数据源、消息渠道、插件、工作流和 AI Agent 等能力组合
- 更完整的功能介绍、截图和使用入口见官网https://movie-pilot.org
## 安装使用
官方Wikihttps://wiki.movie-pilot.org
推荐优先使用 Docker 部署,常用镜像包括 `jxxghp/moviepilot-v2``jxxghp/moviepilot`。Compose 示例、环境变量、目录映射和升级方式以官方 Wiki 为准:
- 官方 Wikihttps://wiki.movie-pilot.org
- PostgreSQL 部署说明:[docs/postgresql-setup.md](docs/postgresql-setup.md)
## 本地 CLI
一键安装运行脚本:
也可以使用本地 CLI 以源码模式安装和管理 MoviePilot
```shell
curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v2/scripts/bootstrap-local.sh | bash
```
使用 `moviepilot` 命令管理MoviePilot完整 CLI 文档:[`docs/cli.md`](docs/cli.md)
安装完成后使用 `moviepilot` 命令完成初始化、启动、停止、更新和配置查看。完整命令见 [docs/cli.md](docs/cli.md)
## Agent
1. MoviePilot 自带智能体能力,可在完成模型配置后,通过自然语言调用系统工具,辅助完成搜索、订阅、下载、整理、排障等管理任务。
2. 其它智能体可以导入本仓库的 `skills/` 目录以获得 MoviePilot 操作能力;支持 `skills` CLI 的环境可使用:
```shell
npx skills add https://github.com/jxxghp/MoviePilot
```
内置 Skills 列表见 [skills/](skills/),自定义 Skill 可参考 [skills/create-moviepilot-skill/SKILL.md](skills/create-moviepilot-skill/SKILL.md)。
3. 其它 MCP 客户端可以通过 MoviePilot 的 MCP 端点 `/api/v1/mcp` 调用工具,认证方式、客户端配置和工具 API 见 [docs/mcp-api.md](docs/mcp-api.md)。
## 为 AI Agent 添加 Skills
```shell
npx skills add https://github.com/jxxghp/MoviePilot
```
## 参与开发
API文档https://api.movie-pilot.org
开发前请先阅读仓库规则和本地环境说明,保持变更聚焦,通过测试后再提交 PR。常用入口
MCP工具API文档详见 [docs/mcp-api.md](docs/mcp-api.md)
开发环境准备与本地源码运行说明:[`docs/development-setup.md`](docs/development-setup.md)
插件开发说明:<https://wiki.movie-pilot.org/zh/plugindev>
- 文档规则入口:[docs/rules/README.md](docs/rules/README.md)
- 开发环境与本地源码运行:[docs/development-setup.md](docs/development-setup.md)
- 测试说明:[docs/testing.md](docs/testing.md)
- REST API 文档https://api.movie-pilot.org
- 插件开发说明https://wiki.movie-pilot.org/zh/plugindev
## 相关项目
@@ -64,6 +68,7 @@ MCP工具API文档详见 [docs/mcp-api.md](docs/mcp-api.md)
- [MoviePilot-Resources](https://github.com/jxxghp/MoviePilot-Resources)
- [MoviePilot-Plugins](https://github.com/jxxghp/MoviePilot-Plugins)
- [MoviePilot-Server](https://github.com/jxxghp/MoviePilot-Server)
- [MoviePilot-Rust](https://github.com/jxxghp/MoviePilot-Rust)
- [MoviePilot-Wiki](https://github.com/jxxghp/MoviePilot-Wiki)
## 免责申明

View File

@@ -17,44 +17,49 @@ Redesigned from parts of [NAStool](https://github.com/NAStool/nas-tools), with a
Release channel: https://t.me/moviepilot_channel
## Key Features
- Frontend/backend separation based on FastApi + Vue3.
- Focuses on core needs, simplifies features and settings, and allows some options to work well with sensible defaults.
- Reworked user interface for a cleaner and more practical experience.
- Focuses on the core media automation flow: subscriptions, search, downloads, file organization, scraping, media server refresh, and notifications.
- Uses a separated backend/frontend architecture: FastAPI for the backend and Vue 3 for the frontend.
- Connects download clients, media servers, metadata providers, message channels, plugins, workflows, and AI Agent capabilities.
- For feature details, screenshots, and product entry points, see https://movie-pilot.org
## Installation and Usage
## Installation
Docker is the recommended deployment model. Common images include `jxxghp/moviepilot-v2` and `jxxghp/moviepilot`. Compose examples, environment variables, volume mappings, and upgrade notes are maintained in the official wiki:
Official wiki: https://wiki.movie-pilot.org
- Official wiki: https://wiki.movie-pilot.org
- PostgreSQL setup: [docs/postgresql-setup.md](docs/postgresql-setup.md)
## Local CLI
One-command bootstrap script:
MoviePilot can also be installed and managed from source with the local CLI:
```shell
curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v2/scripts/bootstrap-local.sh | bash
```
Manage MoviePilot with the `moviepilot` command. Full CLI documentation: [`docs/cli.md`](docs/cli.md)
After installation, use the `moviepilot` command for initialization, service management, updates, and configuration. See [docs/cli.md](docs/cli.md) for the full command reference.
## Agent
## Add Skills for AI Agents
```shell
npx skills add https://github.com/jxxghp/MoviePilot
```
1. MoviePilot includes a built-in AI Agent. After model configuration, it can call system tools through natural language to help with search, subscriptions, downloads, organization, diagnostics, and other management tasks.
2. Other agents can import the repository `skills/` directory to gain MoviePilot operation capabilities. Environments that support the `skills` CLI can use:
```shell
npx skills add https://github.com/jxxghp/MoviePilot
```
Built-in skills live in [skills/](skills/). For custom skill authoring, see [skills/create-moviepilot-skill/SKILL.md](skills/create-moviepilot-skill/SKILL.md).
3. Other MCP clients can call MoviePilot tools through `/api/v1/mcp`. Authentication, client configuration, and tool APIs are documented in [docs/mcp-api.md](docs/mcp-api.md).
## Development
API documentation: https://api.movie-pilot.org
Before contributing, read the repository rules and local environment guide, keep changes focused, and validate them before opening a PR. Useful entry points:
MCP tool API documentation: see [docs/mcp-api.md](docs/mcp-api.md)
Development environment setup and local source-run guide: [`docs/development-setup.md`](docs/development-setup.md)
Plugin development guide: <https://wiki.movie-pilot.org/zh/plugindev>
- Rule index: [docs/rules/README.md](docs/rules/README.md)
- Development setup and local source run: [docs/development-setup.md](docs/development-setup.md)
- Testing guide: [docs/testing.md](docs/testing.md)
- REST API documentation: https://api.movie-pilot.org
- Plugin development guide: https://wiki.movie-pilot.org/zh/plugindev
## Related Projects
@@ -62,6 +67,7 @@ Plugin development guide: <https://wiki.movie-pilot.org/zh/plugindev>
- [MoviePilot-Resources](https://github.com/jxxghp/MoviePilot-Resources)
- [MoviePilot-Plugins](https://github.com/jxxghp/MoviePilot-Plugins)
- [MoviePilot-Server](https://github.com/jxxghp/MoviePilot-Server)
- [MoviePilot-Rust](https://github.com/jxxghp/MoviePilot-Rust)
- [MoviePilot-Wiki](https://github.com/jxxghp/MoviePilot-Wiki)
## Disclaimer

View File

@@ -4,10 +4,11 @@ import re
import traceback
import uuid
from dataclasses import dataclass
from datetime import datetime
from datetime import datetime, timedelta
from enum import Enum
from typing import Any, Callable, Dict, List, Optional
from fastapi.concurrency import run_in_threadpool
from langchain.agents import create_agent
from langchain.agents.middleware import (
SummarizationMiddleware,
@@ -16,17 +17,31 @@ from langchain_core.messages import ( # noqa: F401
HumanMessage,
BaseMessage,
)
import warnings
warnings.filterwarnings("ignore", message=".*allowed_objects.*")
from langgraph.checkpoint.memory import InMemorySaver
from app.agent.callback import StreamingHandler
from app.agent.llm import LLMHelper
from app.agent.memory import memory_manager
from app.agent.middleware.activity_log import ActivityLogMiddleware
from app.agent.middleware.jobs import JobsMiddleware
from app.agent.middleware.jobs import (
JobsMiddleware,
filter_active_jobs,
load_jobs_metadata,
)
from app.agent.middleware.memory import MemoryMiddleware
from app.agent.middleware.patch_tool_calls import PatchToolCallsMiddleware
from app.agent.middleware.runtime_config import RuntimeConfigMiddleware
from app.agent.middleware.skills import SkillsMiddleware
from app.agent.middleware.subagents import (
SUBAGENT_CONTROL_TOOL_NAME,
SUBAGENT_TASK_TOOL_NAME,
create_subagent_middlewares,
is_subagent_stream_metadata,
)
from app.agent.middleware.tool_selection import ToolSelectorMiddleware
from app.agent.middleware.usage import UsageMiddleware
from app.agent.prompt import prompt_manager
@@ -34,10 +49,11 @@ from app.agent.runtime import agent_runtime_manager
from app.agent.tools.factory import MoviePilotToolFactory
from app.chain import ChainBase
from app.core.config import settings
from app.core.event import eventmanager
from app.log import logger
from app.schemas import Notification, NotificationType
from app.schemas import AgentLLMProviderEventData, AgentTokensUsageEventData, Notification, NotificationType
from app.schemas.message import ChannelCapabilityManager, ChannelCapability
from app.schemas.types import MessageChannel
from app.schemas.types import ChainEventType, EventType, MessageChannel
from app.utils.identity import SYSTEM_INTERNAL_USER_ID
@@ -45,6 +61,54 @@ class AgentChain(ChainBase):
pass
def _finish_processing_status(status: Optional[dict], user_id: Optional[str] = None) -> None:
"""结束入站消息的渠道处理状态。"""
if not status:
return
AgentChain().finish_message_processing_status(
status=status,
userid=user_id,
)
async def _async_start_processing_status(task: "_MessageTask") -> Optional[dict]:
"""
在 Agent worker 中启动渠道处理状态。
渠道启动可能触发外部 API同步实现需切到线程池避免阻塞事件循环。
"""
if not task.channel:
return None
def _start() -> Optional[dict]:
"""在线程池中通过统一 Chain 接口启动处理状态。"""
try:
return AgentChain().start_message_processing_status(
channel=MessageChannel(task.channel),
source=task.source,
userid=task.user_id,
message_id=task.original_message_id,
chat_id=task.original_chat_id,
text=task.message,
)
except Exception as err:
logger.debug(f"启动Agent消息处理状态失败: {err}")
return None
return await run_in_threadpool(_start)
async def _async_finish_processing_status(
status: Optional[dict], user_id: Optional[str] = None
) -> None:
"""
在 Agent worker 中结束渠道处理状态。
渠道收口可能触发外部 API同步实现需切到线程池避免阻塞事件循环。
"""
if not status:
return
await run_in_threadpool(_finish_processing_status, status, user_id)
@dataclass
class _SessionUsageSnapshot:
model: Optional[str] = None
@@ -160,6 +224,12 @@ class ReplyMode(str, Enum):
CAPTURE_ONLY = "capture_only"
HEARTBEAT_SESSION_PREFIX = "__agent_heartbeat_"
UNSUPPORTED_IMAGE_INPUT_MESSAGE = "当前模型不支持图片输入,请更换支持图片输入的模型,或在系统设置中关闭图片输入支持后重试。"
AGENT_EXECUTION_ERROR_PREFIX = "智能助手执行失败"
AGENT_EXECUTION_ERROR_MESSAGE = "智能助手执行失败,请稍后重试。"
class MoviePilotAgent:
"""
MoviePilot AI智能体基于 LangChain v1 + LangGraph
@@ -172,6 +242,8 @@ class MoviePilotAgent:
channel: str = None,
source: str = None,
username: str = None,
original_message_id: Optional[str] = None,
original_chat_id: Optional[str] = None,
replay_mode: ReplyMode = ReplyMode.DISPATCH,
persist_output_message: bool = True,
allow_message_tools: bool = True,
@@ -182,6 +254,8 @@ class MoviePilotAgent:
self.channel = channel
self.source = source
self.username = username
self.original_message_id = original_message_id
self.original_chat_id = original_chat_id
self.reply_mode = replay_mode
self.persist_output_message = persist_output_message
self.allow_message_tools = allow_message_tools
@@ -189,6 +263,9 @@ class MoviePilotAgent:
self._tool_context: Dict[str, object] = {}
self._streamed_output = ""
self._session_usage = _SessionUsageSnapshot()
self._llm_runtime_config: Optional[Dict[str, Any]] = None
self._llm_provider_selection: Dict[str, Any] = {}
self._agent_started_at: Optional[datetime] = None
# 流式token管理
self.stream_handler = StreamingHandler()
@@ -202,6 +279,15 @@ class MoviePilotAgent:
except (TypeError, ValueError):
return None
@staticmethod
def _get_recursion_limit() -> int:
"""读取 LangGraph 递归上限,防止模型持续循环调用工具。"""
try:
limit = int(settings.LLM_MAX_ITERATIONS or 0)
except (TypeError, ValueError):
limit = 0
return limit if limit > 0 else 128
@classmethod
def _get_model_name(cls, model: Any) -> Optional[str]:
return (
@@ -274,6 +360,40 @@ class MoviePilotAgent:
)
return self._session_usage.to_dict(self.session_id)
def _send_agent_tokens_usage_event(
self,
*,
success: bool,
error: Optional[str] = None,
) -> None:
"""
广播本次 Agent 执行的 token 聚合用量,供配额类插件异步记录。
"""
try:
selection = self._llm_provider_selection or {}
event_data = AgentTokensUsageEventData(
session_id=self.session_id,
selected_provider_id=selection.get("selected_provider_id"),
selected_provider_name=selection.get("selected_provider_name"),
provider=selection.get("provider") or settings.LLM_PROVIDER,
base_url=selection.get("base_url") or settings.LLM_BASE_URL,
model=self._session_usage.model or selection.get("model") or settings.LLM_MODEL,
input_tokens=self._session_usage.total_input_tokens,
output_tokens=self._session_usage.total_output_tokens,
total_tokens=self._session_usage.total_tokens,
model_call_count=self._session_usage.model_call_count,
success=success,
error=error,
started_at=self._agent_started_at.strftime("%Y-%m-%d %H:%M:%S")
if self._agent_started_at
else None,
finished_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
source=selection.get("source") or "agent",
)
eventmanager.send_event(EventType.AgentTokensUsage, event_data)
except Exception as err:
logger.debug(f"广播 Agent Tokens 用量事件失败: {err}")
@property
def is_background(self) -> bool:
"""
@@ -288,6 +408,16 @@ class MoviePilotAgent:
"""
return self.reply_mode == ReplyMode.DISPATCH
@property
def is_heartbeat_session(self) -> bool:
"""
是否为后台心跳会话。
心跳场景只负责检查并执行待处理 job不需要携带近期活动日志
否则会让这类高频后台调用持续带入无关动态上下文,影响缓存命中率。
"""
return self.session_id.startswith(HEARTBEAT_SESSION_PREFIX)
def _should_stream(self) -> bool:
"""
判断是否应启用流式输出:
@@ -311,12 +441,124 @@ class MoviePilotAgent:
return False
@staticmethod
async def _initialize_llm(streaming: bool = False):
def _get_event_value(event_data: Any, key: str, default: Any = None) -> Any:
"""
从链式事件数据中兼容读取 Pydantic 模型或普通字典字段。
"""
if isinstance(event_data, dict):
return event_data.get(key, default)
return getattr(event_data, key, default)
@staticmethod
def _set_event_value(event_data: Any, key: str, value: Any) -> None:
"""
向链式事件数据中兼容写入 Pydantic 模型或普通字典字段。
"""
if isinstance(event_data, dict):
event_data[key] = value
else:
setattr(event_data, key, value)
@classmethod
def _clean_optional_text(cls, value: Any) -> Optional[str]:
"""
标准化事件返回的可选文本字段,空字符串按未返回处理。
"""
if value is None:
return None
text = str(value).strip()
return text or None
async def _resolve_llm_runtime_config(self) -> Dict[str, Any]:
"""
通过链式事件解析本次 Agent 可用的 LLM 运行时配置。
若没有插件返回 selected_provider_id则沿用系统配置保持既有行为。
"""
if self._llm_runtime_config is not None:
return self._llm_runtime_config
event_data = AgentLLMProviderEventData(
provider=settings.LLM_PROVIDER,
model=settings.LLM_MODEL,
api_key=settings.LLM_API_KEY,
base_url=settings.LLM_BASE_URL,
base_url_preset=settings.LLM_BASE_URL_PRESET,
user_agent=settings.LLM_USER_AGENT,
use_proxy=settings.LLM_USE_PROXY,
thinking_level=None,
)
selected_event = await eventmanager.async_send_event(
ChainEventType.AgentLLMProvider,
event_data,
)
resolved_data = selected_event.event_data if selected_event else event_data
provider = (
self._clean_optional_text(self._get_event_value(resolved_data, "provider"))
or settings.LLM_PROVIDER
)
model = (
self._clean_optional_text(self._get_event_value(resolved_data, "model"))
or settings.LLM_MODEL
)
api_key = (
self._clean_optional_text(self._get_event_value(resolved_data, "api_key"))
or settings.LLM_API_KEY
)
base_url = (
self._clean_optional_text(self._get_event_value(resolved_data, "base_url"))
or settings.LLM_BASE_URL
)
base_url_preset = (
self._clean_optional_text(self._get_event_value(resolved_data, "base_url_preset"))
or settings.LLM_BASE_URL_PRESET
)
user_agent = (
self._clean_optional_text(self._get_event_value(resolved_data, "user_agent"))
or settings.LLM_USER_AGENT
)
use_proxy = self._get_event_value(resolved_data, "use_proxy")
if use_proxy is None:
use_proxy = settings.LLM_USE_PROXY
thinking_level = self._clean_optional_text(
self._get_event_value(resolved_data, "thinking_level")
)
selected_provider_id = self._clean_optional_text(
self._get_event_value(resolved_data, "selected_provider_id")
)
selected_provider_name = self._clean_optional_text(
self._get_event_value(resolved_data, "selected_provider_name")
)
source = self._clean_optional_text(self._get_event_value(resolved_data, "source"))
self._llm_provider_selection = {
"selected_provider_id": selected_provider_id,
"selected_provider_name": selected_provider_name,
"provider": provider,
"base_url": base_url,
"model": model,
"source": source,
}
self._llm_runtime_config = {
"provider": provider,
"model": model,
"api_key": api_key,
"base_url": base_url,
"base_url_preset": base_url_preset,
"user_agent": user_agent,
"use_proxy": bool(use_proxy),
"thinking_level": thinking_level,
}
return self._llm_runtime_config
async def _initialize_llm(self, streaming: bool = False):
"""
初始化 LLM
:param streaming: 是否启用流式输出
"""
return await LLMHelper.get_llm(streaming=streaming)
runtime_config = await self._resolve_llm_runtime_config()
return await LLMHelper.get_llm(streaming=streaming, **runtime_config)
@staticmethod
def _extract_text_content(content) -> str:
@@ -351,6 +593,165 @@ class MoviePilotAgent:
return "".join(text_parts)
return str(content)
@classmethod
def _has_image_input_content(cls, content: Any) -> bool:
"""
检查消息内容里是否包含真正会发给模型的图片块。
结构化 JSON 文本里的 images 字段只是给 Agent 阅读的说明,不能作为图片输入判断。
"""
if isinstance(content, list):
return any(cls._has_image_input_content(item) for item in content)
if not isinstance(content, dict):
return False
block_type = str(content.get("type") or "").lower()
if block_type in {"image", "image_url", "input_image"}:
return True
if content.get("image_url") or content.get("image"):
return True
return any(cls._has_image_input_content(value) for value in content.values())
@classmethod
def _messages_have_image_input(cls, messages: List[BaseMessage]) -> bool:
"""检查本轮提交给模型的消息列表中是否包含图片输入。"""
return any(
cls._has_image_input_content(getattr(message, "content", None))
for message in messages or []
)
@staticmethod
def _exception_detail_text(error: Exception) -> str:
"""
提取异常对象里可用于匹配的文本。
OpenAI 兼容端点的错误详情可能藏在 body/code/status_code 等属性中。
"""
parts = [str(error)]
for attr in ("message", "code", "status_code"):
value = getattr(error, attr, None)
if value is not None:
parts.append(str(value))
body = getattr(error, "body", None)
if body is not None:
try:
parts.append(json.dumps(body, ensure_ascii=False))
except (TypeError, ValueError):
parts.append(str(body))
return " ".join(part for part in parts if part)
@classmethod
def _is_unsupported_image_input_error(cls, error: Exception) -> bool:
"""
判断模型服务是否在拒绝图片输入。
兼容 OpenAI 及 OpenAI-compatible 服务常见的错误文案,避免把普通 404 当作图片能力问题。
"""
detail = cls._exception_detail_text(error).lower()
if "no endpoints found that support image input" in detail:
return True
if "unknown variant" in detail and "image_url" in detail:
return True
if "image input" not in detail and "images" not in detail:
return False
return any(
marker in detail
for marker in (
"does not support",
"do not support",
"not support",
"not supported",
"unsupported",
"no endpoint",
"no endpoints",
)
)
@staticmethod
def _payload_error_message(payload: Any) -> str:
"""
从 SDK 返回的结构化错误体里提取 message 字段。
许多 OpenAI-compatible 服务会把真正原因放在 body.error.message 中。
"""
if isinstance(payload, dict):
error = payload.get("error")
if isinstance(error, dict) and error.get("message"):
return str(error["message"])
for key in ("message", "detail", "error_description"):
if payload.get(key):
return str(payload[key])
return ""
@staticmethod
def _sanitize_execution_error_message(message: str) -> str:
"""
清理执行错误中的密钥和尾部长说明,避免把敏感字段或 SDK 调参文档直接发给用户。
"""
sanitized = re.sub(r"\s+", " ", str(message or "")).strip()
if settings.LLM_API_KEY:
sanitized = sanitized.replace(settings.LLM_API_KEY, "***")
sanitized = re.sub(
r"(?i)(api[_-]?key\s*[:=]\s*)([^\s,;]+)",
r"\1***",
sanitized,
)
sanitized = re.sub(
r"(?i)authorization\s*:\s*bearer\s+[^\s,;]+",
"Authorization: ***",
sanitized,
)
for marker in (
" Tune or disable via ",
" See also ",
" Traceback ",
" - Traceback ",
):
if marker in sanitized:
sanitized = sanitized.split(marker, 1)[0].strip()
return sanitized
@classmethod
def _primary_exception_message(cls, error: Exception) -> str:
"""
从异常对象中抽取最主要的错误消息。
优先使用结构化 message其次回退到异常字符串保持用户回复直接反映真实失败原因。
"""
candidates = [
getattr(error, "message", None),
cls._payload_error_message(getattr(error, "body", None)),
str(error),
]
for candidate in candidates:
message = cls._sanitize_execution_error_message(candidate)
if message:
return message
return ""
@classmethod
def _friendly_execution_error_message(cls, error: Exception) -> str:
"""
将 Agent 执行异常转换为用户可读消息。
回复只携带主错误信息,完整 traceback 保留在日志中排查。
"""
message = cls._primary_exception_message(error)
if not message:
return AGENT_EXECUTION_ERROR_MESSAGE
return f"{AGENT_EXECUTION_ERROR_PREFIX}: {message}"
async def _dispatch_execution_notice(self, message: str) -> None:
"""
将执行层可预期的失败转成用户可读提示。
按当前回复模式处理,避免后台捕获任务绕过 CAPTURE_ONLY 约束。
"""
if not message:
return
self._emit_output(message)
if self._tool_context.get("user_reply_sent"):
return
title = "MoviePilot助手" if self.is_background else ""
if self.should_dispatch_reply:
await self.send_agent_message(message, title=title)
elif self.persist_output_message:
await self._save_agent_message_to_db(message, title=title)
def _emit_output(self, text: str):
"""
输出当前流式文本到外部回调。
@@ -388,6 +789,25 @@ class MoviePilotAgent:
allow_message_tools=self.allow_message_tools,
)
def _initialize_subagent_tools(self) -> List:
"""
初始化子代理专用静默工具列表。
"""
return MoviePilotToolFactory.create_tools(
session_id=self.session_id,
user_id=self.user_id,
channel=self.channel,
source=self.source,
username=self.username,
stream_handler=None,
agent_context={
"user_reply_sent": False,
"reply_mode": None,
"should_dispatch_reply": False,
},
allow_message_tools=False,
)
async def _create_agent(self, streaming: bool = False):
"""
创建 LangGraph Agent使用 create_agent + SummarizationMiddleware
@@ -410,10 +830,22 @@ class MoviePilotAgent:
# 工具列表
tools = self._initialize_tools()
subagent_middlewares, subagent_task_tools = create_subagent_middlewares(
model=non_streaming_model,
tools=self._initialize_subagent_tools(),
stream_handler=self.stream_handler,
)
max_tools = settings.LLM_MAX_TOOLS
always_include_tools = (
MoviePilotToolFactory.get_tool_selector_always_include_names(tools)
)
if subagent_task_tools:
always_include_tools.extend(
tool.name
for tool in subagent_task_tools
if getattr(tool, "name", None)
in {SUBAGENT_TASK_TOOL_NAME, SUBAGENT_CONTROL_TOOL_NAME}
)
# 中间件
middlewares = [
@@ -430,26 +862,32 @@ class MoviePilotAgent:
RuntimeConfigMiddleware(),
# 记忆管理
MemoryMiddleware(memory_dir=str(agent_runtime_manager.memory_dir)),
# 活动日志
ActivityLogMiddleware(
activity_dir=str(agent_runtime_manager.activity_dir),
),
# 上下文压缩
SummarizationMiddleware(
model=non_streaming_model, trigger=("fraction", 0.85)
),
# 错误工具调用修复
PatchToolCallsMiddleware(),
# 子代理委派
*subagent_middlewares,
# 用量统计
UsageMiddleware(on_usage=self._record_usage),
]
if not self.is_heartbeat_session:
middlewares.insert(
4,
ActivityLogMiddleware(
activity_dir=str(agent_runtime_manager.activity_dir),
),
)
# 工具选择
if max_tools > 0:
middlewares.append(
ToolSelectorMiddleware(
model=non_streaming_model,
selection_tools=tools,
selection_tools=[*tools, *subagent_task_tools],
max_tools=max_tools,
always_include=always_include_tools,
)
@@ -471,6 +909,7 @@ class MoviePilotAgent:
message: str,
images: List[str] = None,
files: Optional[List[dict]] = None,
has_audio_input: bool = False,
) -> str:
"""
处理用户消息,流式推理并返回 Agent 回复
@@ -478,7 +917,8 @@ class MoviePilotAgent:
try:
logger.info(
f"Agent推理: session_id={self.session_id}, input={message}, "
f"images={len(images) if images else 0}, files={len(files) if files else 0}"
f"images={len(images) if images else 0}, files={len(files) if files else 0}, "
f"audio_input={has_audio_input}"
)
self._tool_context = {
"user_reply_sent": False,
@@ -495,6 +935,10 @@ class MoviePilotAgent:
# 构建结构化用户消息内容
request_payload = {
"message": message or "",
"input": {
"mode": "voice" if has_audio_input else "text",
"transcribed": bool(has_audio_input),
},
"images": [
{"index": index + 1, "type": "image"}
for index, _ in enumerate(images or [])
@@ -512,7 +956,10 @@ class MoviePilotAgent:
messages.append(HumanMessage(content=content))
# 执行推理
await self._execute_agent(messages)
result = await self._execute_agent(messages)
if isinstance(result, tuple) and result:
return result[0]
return result
except Exception as e:
error_message = f"处理消息时发生错误: {str(e)}"
@@ -543,6 +990,8 @@ class MoviePilotAgent:
):
if chunk["type"] == "messages":
token, metadata = chunk["data"]
if is_subagent_stream_metadata(metadata):
continue
if not token or not hasattr(token, "tool_call_chunks"):
continue
@@ -574,12 +1023,18 @@ class MoviePilotAgent:
- 渠道不支持消息编辑:非流式 LLM + ainvoke完成后发送最终回复
- 渠道支持消息编辑:流式 LLM + astream实时推送 token
"""
execution_success = False
execution_error: Optional[str] = None
self._agent_started_at = datetime.now()
self._llm_runtime_config = None
self._llm_provider_selection = {}
try:
# Agent运行配置
agent_config = {
"configurable": {
"thread_id": self.session_id,
}
},
"recursion_limit": self._get_recursion_limit(),
}
# 判断是否启用流式输出
@@ -598,6 +1053,8 @@ class MoviePilotAgent:
source=self.source,
user_id=self.user_id,
username=self.username,
original_message_id=self.original_message_id,
original_chat_id=self.original_chat_id,
)
# 流式运行智能体token 直接推送到 stream_handler
@@ -705,14 +1162,29 @@ class MoviePilotAgent:
user_id=self.user_id,
messages=agent.get_state(agent_config).values.get("messages", []),
)
execution_success = True
except asyncio.CancelledError:
logger.info(f"Agent执行被取消: session_id={self.session_id}")
execution_error = "任务已取消"
return "任务已取消", {}
except Exception as e:
execution_error = str(e)
if self._messages_have_image_input(messages) and self._is_unsupported_image_input_error(e):
logger.warning(
f"当前模型不支持图片输入,已向用户发送友好提示: {e}"
)
await self._dispatch_execution_notice(UNSUPPORTED_IMAGE_INPUT_MESSAGE)
return UNSUPPORTED_IMAGE_INPUT_MESSAGE, {}
logger.error(f"Agent执行失败: {e} - {traceback.format_exc()}")
return str(e), {}
friendly_message = self._friendly_execution_error_message(e)
await self._dispatch_execution_notice(friendly_message)
return friendly_message, {}
finally:
self._send_agent_tokens_usage_event(
success=execution_success,
error=execution_error,
)
# 确保停止流式输出
await self.stream_handler.stop_streaming()
@@ -727,6 +1199,8 @@ class MoviePilotAgent:
mtype=NotificationType.Agent,
userid=self.user_id,
username=self.username,
original_message_id=self.original_message_id,
original_chat_id=self.original_chat_id,
title=title,
text=message,
)
@@ -770,10 +1244,16 @@ class _MessageTask:
message: str
images: Optional[List[str]] = None
files: Optional[List[dict]] = None
has_audio_input: bool = False
channel: Optional[str] = None
source: Optional[str] = None
username: Optional[str] = None
original_message_id: Optional[str] = None
original_chat_id: Optional[str] = None
processing_status: Optional[dict] = None
reply_mode: ReplyMode = ReplyMode.DISPATCH
persist_output_message: bool = True
allow_message_tools: bool = True
class AgentManager:
@@ -788,6 +1268,11 @@ class AgentManager:
self._session_queues: Dict[str, asyncio.Queue] = {}
# 每个会话的worker任务
self._session_workers: Dict[str, asyncio.Task] = {}
# 每个会话最后活动时间,用于回收空闲 Agent 实例
self._session_last_used: Dict[str, tuple[str, datetime]] = {}
self._idle_cleanup_task: Optional[asyncio.Task] = None
self._idle_session_ttl = timedelta(hours=24)
self._idle_cleanup_interval = 60 * 60
def get_session_status(self, session_id: str) -> dict[str, Any]:
"""获取会话当前模型与 token 使用状态。"""
@@ -820,33 +1305,85 @@ class AgentManager:
)
return status
@staticmethod
async def initialize():
async def initialize(self):
"""
初始化管理器
"""
memory_manager.initialize()
if self._idle_cleanup_task and not self._idle_cleanup_task.done():
return
self._idle_cleanup_task = asyncio.create_task(self._cleanup_idle_sessions())
async def close(self):
"""
关闭管理器
"""
if self._idle_cleanup_task:
self._idle_cleanup_task.cancel()
try:
await self._idle_cleanup_task
except asyncio.CancelledError:
pass
self._idle_cleanup_task = None
await memory_manager.close()
# 取消所有会话worker
for task in self._session_workers.values():
for task in list(self._session_workers.values()):
task.cancel()
# 等待所有worker结束
for session_id, task in self._session_workers.items():
for session_id, task in list(self._session_workers.items()):
try:
await task
except asyncio.CancelledError:
pass
self._session_workers.clear()
self._session_queues.clear()
for agent in self.active_agents.values():
self._session_last_used.clear()
for agent in list(self.active_agents.values()):
await agent.cleanup()
self.active_agents.clear()
def _record_session_activity(self, session_id: str, user_id: str) -> None:
"""
记录会话最近活动时间,供空闲会话清理任务判断是否可释放资源。
"""
self._session_last_used[session_id] = (user_id, datetime.now())
def _is_session_busy(self, session_id: str) -> bool:
"""
判断会话是否仍有正在执行的 worker 或待处理消息,避免误清理活跃会话。
"""
worker = self._session_workers.get(session_id)
if worker and not worker.done():
return True
queue = self._session_queues.get(session_id)
return bool(queue and not queue.empty())
def _expired_idle_sessions(self) -> list[tuple[str, str]]:
"""
收集已经超过空闲时间且当前不忙的会话。
"""
expire_before = datetime.now() - self._idle_session_ttl
expired = []
for session_id, (user_id, last_used) in list(self._session_last_used.items()):
if last_used < expire_before and not self._is_session_busy(session_id):
expired.append((session_id, user_id))
return expired
async def _cleanup_idle_sessions(self) -> None:
"""
周期性清理长时间没有新消息的 Agent 会话,避免长期运行后实例持续累积。
"""
while True:
try:
await asyncio.sleep(self._idle_cleanup_interval)
for session_id, user_id in self._expired_idle_sessions():
await self.clear_session(session_id=session_id, user_id=user_id)
logger.info(f"已清理空闲Agent会话: session_id={session_id}")
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"清理空闲Agent会话失败: {e}")
async def process_message(
self,
session_id: str,
@@ -854,10 +1391,15 @@ class AgentManager:
message: str,
images: List[str] = None,
files: Optional[List[dict]] = None,
has_audio_input: bool = False,
channel: str = None,
source: str = None,
username: str = None,
original_message_id: Optional[str] = None,
original_chat_id: Optional[str] = None,
reply_mode: ReplyMode = ReplyMode.DISPATCH,
persist_output_message: bool = True,
allow_message_tools: bool = True,
) -> str:
"""
处理用户消息:将消息放入会话队列,按顺序依次处理。
@@ -869,11 +1411,17 @@ class AgentManager:
message=message,
images=images,
files=files,
has_audio_input=has_audio_input,
channel=channel,
source=source,
username=username,
original_message_id=original_message_id,
original_chat_id=original_chat_id,
reply_mode=reply_mode,
persist_output_message=persist_output_message,
allow_message_tools=allow_message_tools,
)
self._record_session_activity(session_id, user_id)
# 获取或创建会话队列
if session_id not in self._session_queues:
@@ -926,10 +1474,12 @@ class AgentManager:
break
try:
await self._start_task_processing_status(task)
await self._process_message_internal(task)
except Exception as e:
logger.error(f"处理会话 {session_id} 的消息失败: {e}")
finally:
await self._finish_task_processing_status(task)
queue.task_done()
except asyncio.CancelledError:
@@ -944,6 +1494,23 @@ class AgentManager:
):
self._session_queues.pop(session_id, None)
@staticmethod
async def _start_task_processing_status(task: _MessageTask) -> None:
"""
在 Agent worker 真正开始处理消息时启动渠道处理状态。
"""
if task.processing_status:
return
task.processing_status = await _async_start_processing_status(task)
@staticmethod
async def _finish_task_processing_status(task: _MessageTask) -> None:
"""
在 Agent worker 完成或异常后结束本条消息的渠道处理状态。
"""
await _async_finish_processing_status(task.processing_status, task.user_id)
task.processing_status = None
async def _process_message_internal(self, task: _MessageTask):
"""
实际处理单条消息
@@ -959,7 +1526,11 @@ class AgentManager:
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,
persist_output_message=task.persist_output_message,
allow_message_tools=task.allow_message_tools,
)
self.active_agents[session_id] = agent
else:
@@ -971,9 +1542,19 @@ class AgentManager:
agent.source = task.source
if task.username:
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.persist_output_message = task.persist_output_message
agent.allow_message_tools = task.allow_message_tools
return await agent.process(task.message, images=task.images, files=task.files)
process_kwargs = {
"images": task.images,
"files": task.files,
}
if task.has_audio_input:
process_kwargs["has_audio_input"] = True
return await agent.process(task.message, **process_kwargs)
async def stop_current_task(self, session_id: str):
"""
@@ -1016,6 +1597,7 @@ class AgentManager:
"""
清空会话
"""
self._session_last_used.pop(session_id, None)
# 取消该会话的worker
if session_id in self._session_workers:
self._session_workers[session_id].cancel()
@@ -1023,7 +1605,7 @@ class AgentManager:
await self._session_workers[session_id]
except asyncio.CancelledError:
pass
await self._session_workers.pop(session_id, None)
self._session_workers.pop(session_id, None) # noqa
# 清理队列
self._session_queues.pop(session_id, None)
@@ -1085,8 +1667,17 @@ class AgentManager:
由定时调度器周期性调用,每次使用独立的会话避免上下文干扰。
"""
try:
active_jobs = filter_active_jobs(
await load_jobs_metadata([str(agent_runtime_manager.jobs_dir)])
)
# 先在本地判断是否存在活跃任务。没有任务时直接短路,避免一次完整
# 的后台 Agent/LLM 空调用。
if not active_jobs:
logger.info("智能体心跳唤醒:没有活跃任务,跳过模型调用")
return
# 每次使用唯一的 session_id避免共享上下文
session_id = f"__agent_heartbeat_{uuid.uuid4().hex[:12]}__"
session_id = f"{HEARTBEAT_SESSION_PREFIX}{uuid.uuid4().hex[:12]}__"
user_id = SYSTEM_INTERNAL_USER_ID
logger.info("智能体心跳唤醒:开始检查待处理任务...")
@@ -1099,7 +1690,9 @@ class AgentManager:
channel=None,
source=None,
username=settings.SUPERUSER,
reply_mode=ReplyMode.DISPATCH,
reply_mode=ReplyMode.CAPTURE_ONLY,
persist_output_message=False,
allow_message_tools=True,
)
# 等待消息队列处理完成

View File

@@ -12,7 +12,7 @@ from app.schemas.message import (
ChannelCapabilityManager,
ChannelCapability,
)
from app.schemas.types import MessageChannel
from app.schemas.types import MessageChannel, NotificationType
class _StreamChain(ChainBase):
@@ -61,6 +61,8 @@ class StreamingHandler:
self._source: Optional[str] = None
self._user_id: Optional[str] = None
self._username: Optional[str] = None
self._original_message_id: Optional[str] = None
self._original_chat_id: Optional[str] = None
self._title: str = ""
self._allow_dispatch_without_context = False
# 非啰嗦模式下的待输出工具统计,等下一段文本到来时再统一补一句摘要
@@ -147,6 +149,8 @@ class StreamingHandler:
source: Optional[str] = None,
user_id: Optional[str] = None,
username: Optional[str] = None,
original_message_id: Optional[str] = None,
original_chat_id: Optional[str] = None,
title: str = "",
):
"""
@@ -158,11 +162,15 @@ class StreamingHandler:
:param user_id: 用户ID
:param username: 用户名
:param title: 消息标题
:param original_message_id: 原始消息ID如果是回复消息
:param original_chat_id: 原始聊天ID如果是回复消息
"""
self._channel = channel
self._source = source
self._user_id = user_id
self._username = username
self._original_message_id = original_message_id
self._original_chat_id = original_chat_id
self._title = title
self._streaming_enabled = True
@@ -211,6 +219,13 @@ class StreamingHandler:
# 执行最后一次刷新
await self._flush()
message_response = self._message_response
if message_response:
await run_in_threadpool(
_StreamChain().finalize_message,
message_response,
)
# 检查是否所有缓冲内容都已发送
with self._lock:
# 当前消息的文本 = buffer 中从 _msg_start_offset 开始的部分
@@ -278,6 +293,8 @@ class StreamingHandler:
tool_message = (tool_message or "").strip()
tool_message_lower = tool_message.lower()
if tool_name == "task":
return "subagent", tool_kwargs.get("subagent_type")
if tool_name == "read_file":
return "file_read", tool_kwargs.get("file_path")
if tool_name in {"write_file", "edit_file"}:
@@ -292,7 +309,10 @@ class StreamingHandler:
or tool_kwargs.get("path"),
)
if tool_name == "execute_command":
return "command", tool_kwargs.get("command")
return (
"command",
tool_kwargs.get("command") or tool_kwargs.get("session_id"),
)
if tool_name == "ask_user_choice":
return "interaction", tool_kwargs.get("message")
if tool_name.startswith("search_") or tool_name in {"get_search_results"}:
@@ -390,6 +410,8 @@ class StreamingHandler:
return f"执行了 {count} 次操作"
if category == "interaction":
return f"发起了 {count} 次交互"
if category == "subagent":
return f"已调用 {count} 个子代理"
return f"调用了 {count} 次工具"
def _can_stream(self) -> bool:
@@ -422,15 +444,23 @@ class StreamingHandler:
async def _cancel_flush_task(self):
"""
取消当前的定时刷新任务
停止当前的定时刷新任务
停止流式输出时,刷新任务可能已经在线程池里发出了首条消息。
这里先等待该轮刷新自然完成,确保 message_id 等返回信息能落回本地状态;
否则最终刷新会误以为尚未发送过消息,从而再次发送一条新消息。
"""
if self._flush_task and not self._flush_task.done():
self._flush_task.cancel()
current_task = asyncio.current_task()
if (
self._flush_task
and not self._flush_task.done()
and self._flush_task is not current_task
):
try:
await self._flush_task
except asyncio.CancelledError:
pass
self._flush_task = None
self._flush_task = None
async def _flush(self):
"""
@@ -462,8 +492,11 @@ class StreamingHandler:
Notification(
channel=self._channel,
source=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,
title=self._title,
text=current_text,
),
@@ -504,8 +537,11 @@ class StreamingHandler:
Notification(
channel=self._channel,
source=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,
title=self._title,
text=current_text,
),
@@ -535,6 +571,7 @@ class StreamingHandler:
chat_id=self._message_response.chat_id,
text=current_text,
title=self._title,
metadata=self._message_response.metadata,
)
if success:
with self._lock:

View File

@@ -1,6 +1,14 @@
"""Agent 内部使用的 LLM 适配层。"""
from app.agent.llm.helper import LLMHelper, LLMTestError, LLMTestTimeout
from app.agent.llm.capability import (
AgentCapabilityManager,
AgentCapabilityProvider,
AudioCapabilityProvider,
MiMoAudioProvider,
OpenAIChatAudioProvider,
OpenAIAudioProvider,
)
from app.agent.llm.provider import (
LLMProviderAuthError,
LLMProviderError,
@@ -10,10 +18,16 @@ from app.agent.llm.provider import (
__all__ = [
"LLMHelper",
"AgentCapabilityManager",
"AgentCapabilityProvider",
"AudioCapabilityProvider",
"LLMProviderAuthError",
"LLMProviderError",
"LLMProviderManager",
"LLMTestError",
"LLMTestTimeout",
"MiMoAudioProvider",
"OpenAIChatAudioProvider",
"OpenAIAudioProvider",
"render_auth_result_html",
]

827
app/agent/llm/capability.py Normal file
View File

@@ -0,0 +1,827 @@
"""Agent 多模态能力 provider 与调度入口。"""
from __future__ import annotations
import base64
import mimetypes
import shutil
import subprocess
from abc import ABC
from io import BytesIO
from pathlib import Path
from typing import Any, Dict, Optional
from uuid import uuid4
from app.core.config import settings
from app.log import logger
from app.utils.http import RequestUtils
class AgentCapabilityProvider(ABC):
"""Agent 能力 provider 基类,后续图片等能力可继续扩展到这里。"""
name: str
class AudioCapabilityProvider(AgentCapabilityProvider):
"""音频输入/输出能力 provider。"""
MAX_TRANSCRIBE_BYTES = 10 * 1024 * 1024
def is_available_for_audio_input(self) -> bool:
"""是否可用于音频输入转写。"""
return False
def is_available_for_audio_output(self) -> bool:
"""是否可用于语音合成输出。"""
return False
def transcribe_audio(self, content: bytes, filename: str = "input.ogg") -> Optional[str]:
"""将音频字节转成文字。"""
raise NotImplementedError
def synthesize_speech(self, text: str) -> Optional[Path]:
"""将文字合成为可发送的音频文件。"""
raise NotImplementedError
class OpenAIAudioProvider(AudioCapabilityProvider):
"""OpenAI / OpenAI-compatible 音频 provider。"""
name = "openai"
@staticmethod
def _build_client(api_key: str, base_url: Optional[str]):
from openai import OpenAI
return OpenAI(api_key=api_key, base_url=base_url, max_retries=3)
@staticmethod
def _input_credentials() -> tuple[Optional[str], Optional[str]]:
return settings.AUDIO_INPUT_API_KEY, settings.AUDIO_INPUT_BASE_URL
@staticmethod
def _output_credentials() -> tuple[Optional[str], Optional[str]]:
return settings.AUDIO_OUTPUT_API_KEY, settings.AUDIO_OUTPUT_BASE_URL
def is_available_for_audio_input(self) -> bool:
api_key, _ = self._input_credentials()
return bool(api_key)
def is_available_for_audio_output(self) -> bool:
api_key, _ = self._output_credentials()
return bool(api_key)
def transcribe_audio(self, content: bytes, filename: str = "input.ogg") -> Optional[str]:
if not content:
return None
if len(content) > self.MAX_TRANSCRIBE_BYTES:
raise ValueError("语音文件超过 10MB无法识别")
try:
api_key, base_url = self._input_credentials()
if not api_key:
raise ValueError("音频输入 provider 未配置 API Key")
client = self._build_client(api_key=api_key, base_url=base_url)
audio_file = BytesIO(content)
audio_file.name = filename
response = client.audio.transcriptions.create(
model=settings.AUDIO_INPUT_MODEL,
file=audio_file,
language=settings.AUDIO_INPUT_LANGUAGE or "zh",
response_format="verbose_json",
)
text = getattr(response, "text", None)
return text.strip() if text else None
except Exception as err:
logger.error(f"音频输入转写失败: provider={self.name}, error={err}")
return None
def synthesize_speech(self, text: str) -> Optional[Path]:
if not text:
return None
try:
api_key, base_url = self._output_credentials()
if not api_key:
raise ValueError("音频输出 provider 未配置 API Key")
client = self._build_client(api_key=api_key, base_url=base_url)
voice_dir = settings.TEMP_PATH / "voice"
voice_dir.mkdir(parents=True, exist_ok=True)
output_path = voice_dir / f"{uuid4().hex}.opus"
response = client.audio.speech.create(
model=settings.AUDIO_OUTPUT_MODEL,
voice=settings.AUDIO_OUTPUT_VOICE,
input=text,
response_format="opus",
)
response.write_to_file(output_path)
return output_path
except Exception as err:
logger.error(f"音频输出合成失败: provider={self.name}, error={err}")
return None
class OpenAIChatAudioProvider(AudioCapabilityProvider):
"""通过 OpenAI Chat Completions 兼容接口传入/返回音频的 provider。"""
name = "openai_chat_audio"
DISPLAY_NAME = "OpenAI Chat Audio"
DEFAULT_BASE_URL: Optional[str] = None
DEFAULT_STT_MODEL: Optional[str] = None
DEFAULT_TTS_MODEL: Optional[str] = None
DEFAULT_VOICE = "alloy"
AUDIO_RESPONSE_FORMAT = "wav"
AUDIO_INPUT_DATA_URL = False
INCLUDE_AUDIO_MODALITIES = True
TTS_MESSAGE_ROLE = "user"
SUPPORTED_STT_MODELS: Optional[frozenset[str]] = None
SUPPORTED_TTS_MODELS: Optional[frozenset[str]] = None
UNSUPPORTED_TTS_MODELS = frozenset()
SUPPORTED_AUDIO_MIME_TYPES = {
".flac": "audio/flac",
".m4a": "audio/mp4",
".mp3": "audio/mpeg",
".ogg": "audio/ogg",
".opus": "audio/ogg",
".wav": "audio/wav",
}
TRANSCODED_STT_SUFFIX = ".wav"
TRANSCODED_STT_SAMPLE_RATE = "16000"
def _build_client(self, api_key: str, base_url: Optional[str]):
from openai import OpenAI
return OpenAI(
api_key=api_key,
base_url=base_url or self.DEFAULT_BASE_URL,
max_retries=3,
)
@staticmethod
def _input_credentials() -> tuple[Optional[str], Optional[str]]:
return settings.AUDIO_INPUT_API_KEY, settings.AUDIO_INPUT_BASE_URL
@staticmethod
def _output_credentials() -> tuple[Optional[str], Optional[str]]:
return settings.AUDIO_OUTPUT_API_KEY, settings.AUDIO_OUTPUT_BASE_URL
def _normalize_stt_model(self) -> str:
return self._normalize_model(
model=settings.AUDIO_INPUT_MODEL,
supported_models=self.SUPPORTED_STT_MODELS,
default_model=self.DEFAULT_STT_MODEL,
)
def _normalize_tts_model(self) -> str:
return self._normalize_model(
model=settings.AUDIO_OUTPUT_MODEL,
supported_models=self.SUPPORTED_TTS_MODELS,
default_model=self.DEFAULT_TTS_MODEL,
)
@staticmethod
def _normalize_model(
model: Optional[str],
supported_models: Optional[frozenset[str]],
default_model: Optional[str],
) -> str:
model = (model or "").strip()
if not model:
return default_model or ""
if supported_models is None:
return model
model_key = model.lower()
if model_key in supported_models:
return model_key
return default_model or model
def _is_supported_tts_model(self) -> bool:
model = self._normalize_tts_model()
if not model:
return False
model_key = model.lower()
if model_key in self.UNSUPPORTED_TTS_MODELS:
return False
return self.SUPPORTED_TTS_MODELS is None or model_key in self.SUPPORTED_TTS_MODELS
@classmethod
def _guess_audio_mime_type(cls, filename: str) -> str:
suffix = Path(filename or "").suffix.lower()
if suffix in cls.SUPPORTED_AUDIO_MIME_TYPES:
return cls.SUPPORTED_AUDIO_MIME_TYPES[suffix]
mime_type, _ = mimetypes.guess_type(filename or "")
return mime_type or "audio/ogg"
@staticmethod
def _guess_audio_format(filename: str) -> str:
suffix = Path(filename or "").suffix.lower().lstrip(".")
if suffix == "opus":
return "ogg"
return suffix or "ogg"
def _build_audio_input_payload(self, content: bytes, filename: str) -> dict:
"""按不同 Chat Audio 兼容形态构造 input_audio 内容。"""
audio_data = base64.b64encode(content).decode("utf-8")
if self.AUDIO_INPUT_DATA_URL:
mime_type = self._guess_audio_mime_type(filename)
return {"data": f"data:{mime_type};base64,{audio_data}"}
return {
"data": audio_data,
"format": self._guess_audio_format(filename),
}
def _normalize_audio_for_transcription(
self, content: bytes, filename: str
) -> Optional[tuple[bytes, str]]:
"""
将转写输入归一化为 Chat Audio provider 明确支持的格式。
:param content: 原始音频字节
:param filename: 原始音频文件名
:return: 成功时返回可提交的音频字节和文件名,失败时返回 None
"""
suffix = Path(filename or "").suffix.lower()
if suffix in self.SUPPORTED_AUDIO_MIME_TYPES:
return content, filename
return self._convert_audio_for_transcription(content=content, filename=filename)
def _convert_audio_for_transcription(
self, content: bytes, filename: str
) -> Optional[tuple[bytes, str]]:
"""
将 AMR 等第三方 STT 不支持的输入转为 WAV。
:param content: 原始音频字节
:param filename: 原始音频文件名
:return: 成功时返回 WAV 字节和文件名,失败时返回 None
"""
if not shutil.which("ffmpeg"):
logger.warning(
"%s STT 不支持当前音频格式且 ffmpeg 不可用,无法转码: filename=%s",
self.DISPLAY_NAME,
filename,
)
return None
suffix = Path(filename or "").suffix.lower() or ".audio"
voice_dir = settings.TEMP_PATH / "voice"
voice_dir.mkdir(parents=True, exist_ok=True)
input_path = voice_dir / f"{uuid4().hex}{suffix}"
output_path = input_path.with_suffix(self.TRANSCODED_STT_SUFFIX)
try:
input_path.write_bytes(content)
cmd = [
"ffmpeg",
"-y",
"-i",
str(input_path),
"-ar",
self.TRANSCODED_STT_SAMPLE_RATE,
"-ac",
"1",
"-f",
"wav",
str(output_path),
]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode != 0 or not output_path.exists():
logger.warning(
"%s STT 音频转 WAV 失败: returncode=%s, stderr=%s",
self.DISPLAY_NAME,
result.returncode,
(result.stderr or "").strip()[:500],
)
return None
return output_path.read_bytes(), f"{input_path.stem}{self.TRANSCODED_STT_SUFFIX}"
finally:
for temp_path in (input_path, output_path):
try:
temp_path.unlink(missing_ok=True)
except OSError as err:
logger.debug(f"清理 STT 临时音频失败: path={temp_path}, error={err}")
@staticmethod
def _extract_message_text(message) -> Optional[str]:
"""兼容音频理解响应可能放在 content 或 reasoning_content 的情况。"""
content = getattr(message, "content", None)
if isinstance(content, str) and content.strip():
return content.strip()
reasoning_content = getattr(message, "reasoning_content", None)
if isinstance(reasoning_content, str) and reasoning_content.strip():
return reasoning_content.strip()
extra = getattr(message, "model_extra", None)
if isinstance(extra, dict):
for key in ("content", "reasoning_content"):
value = extra.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return None
@staticmethod
def _extract_audio_data(message) -> Optional[str]:
audio = getattr(message, "audio", None)
if isinstance(audio, dict):
return audio.get("data")
if audio is not None:
return getattr(audio, "data", None)
extra = getattr(message, "model_extra", None)
if isinstance(extra, dict) and isinstance(extra.get("audio"), dict):
return extra["audio"].get("data")
return None
def _convert_wav_to_opus(self, wav_path: Path) -> Optional[Path]:
"""将 Chat Audio 返回的 WAV 转成 OGG/Opus便于各通知渠道发送语音。"""
if not shutil.which("ffmpeg"):
return None
output_path = wav_path.with_suffix(".opus")
cmd = [
"ffmpeg",
"-y",
"-i",
str(wav_path),
"-ar",
"48000",
"-ac",
"1",
"-c:a",
"libopus",
str(output_path),
]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode != 0 or not output_path.exists():
logger.warning(
"%s TTS 音频转 Opus 失败,将使用 WAV 原文件: returncode=%s, stderr=%s",
self.DISPLAY_NAME,
result.returncode,
(result.stderr or "").strip()[:500],
)
return None
return output_path
def is_available_for_audio_input(self) -> bool:
api_key, _ = self._input_credentials()
return bool(api_key)
def is_available_for_audio_output(self) -> bool:
api_key, _ = self._output_credentials()
return bool(api_key) and self._is_supported_tts_model()
def transcribe_audio(self, content: bytes, filename: str = "input.ogg") -> Optional[str]:
if not content:
return None
if len(content) > self.MAX_TRANSCRIBE_BYTES:
raise ValueError("语音文件超过 10MB无法识别")
try:
api_key, base_url = self._input_credentials()
if not api_key:
raise ValueError("音频输入 provider 未配置 API Key")
client = self._build_client(api_key=api_key, base_url=base_url)
normalized_audio = self._normalize_audio_for_transcription(
content=content, filename=filename
)
if not normalized_audio:
return None
content, filename = normalized_audio
language = (settings.AUDIO_INPUT_LANGUAGE or "").strip()
prompt = "请将这段音频完整转写为文字,只输出转写结果,不要添加解释。"
if language:
prompt += f"音频主要语言是 {language}"
completion = client.chat.completions.create(
model=self._normalize_stt_model(),
messages=[
{
"role": "user",
"content": [
{
"type": "input_audio",
"input_audio": self._build_audio_input_payload(
content=content, filename=filename
),
},
{"type": "text", "text": prompt},
],
}
],
max_completion_tokens=2048,
)
return self._extract_message_text(completion.choices[0].message)
except Exception as err:
logger.error(f"音频输入转写失败: provider={self.name}, error={err}")
return None
def synthesize_speech(self, text: str) -> Optional[Path]:
if not text:
return None
if not self._is_supported_tts_model():
logger.error(
"%s TTS 当前不支持该模型或模型未配置: %s",
self.DISPLAY_NAME,
settings.AUDIO_OUTPUT_MODEL,
)
return None
try:
api_key, base_url = self._output_credentials()
if not api_key:
raise ValueError("音频输出 provider 未配置 API Key")
client = self._build_client(api_key=api_key, base_url=base_url)
voice_dir = settings.TEMP_PATH / "voice"
voice_dir.mkdir(parents=True, exist_ok=True)
wav_path = voice_dir / f"{uuid4().hex}.wav"
request = {
"model": self._normalize_tts_model(),
"messages": [
{
"role": self.TTS_MESSAGE_ROLE,
"content": text,
}
],
"audio": {
"format": self.AUDIO_RESPONSE_FORMAT,
"voice": settings.AUDIO_OUTPUT_VOICE or self.DEFAULT_VOICE,
},
}
if self.INCLUDE_AUDIO_MODALITIES:
request["modalities"] = ["text", "audio"]
completion = client.chat.completions.create(**request)
audio_data = self._extract_audio_data(completion.choices[0].message)
if not audio_data:
raise ValueError(f"{self.DISPLAY_NAME} TTS 响应中没有音频数据")
wav_path.write_bytes(base64.b64decode(audio_data))
return self._convert_wav_to_opus(wav_path) or wav_path
except Exception as err:
logger.error(f"音频输出合成失败: provider={self.name}, error={err}")
return None
class MiMoAudioProvider(OpenAIChatAudioProvider):
"""Xiaomi MiMo Chat Audio 预设,仅接入普通 STT/TTS 能力。"""
name = "mimo"
DISPLAY_NAME = "Xiaomi MiMo"
DEFAULT_BASE_URL = "https://api.xiaomimimo.com/v1"
DEFAULT_STT_MODEL = "mimo-v2.5"
DEFAULT_TTS_MODEL = "mimo-v2.5-tts"
DEFAULT_VOICE = "mimo_default"
AUDIO_INPUT_DATA_URL = True
INCLUDE_AUDIO_MODALITIES = False
TTS_MESSAGE_ROLE = "assistant"
SUPPORTED_STT_MODELS = frozenset({"mimo-v2.5", "mimo-v2-omni"})
SUPPORTED_TTS_MODELS = frozenset({DEFAULT_TTS_MODEL})
UNSUPPORTED_TTS_MODELS = frozenset(
{
"mimo-v2.5-tts-voiceclone",
"mimo-v2.5-tts-voicedesign",
}
)
def _normalize_tts_model(self) -> str:
model = (settings.AUDIO_OUTPUT_MODEL or "").strip().lower()
if not model or not model.startswith("mimo-"):
return self.DEFAULT_TTS_MODEL
return model
class MiniMaxAudioProvider(OpenAIChatAudioProvider):
"""MiniMax 音频 provider语音合成使用官方 T2A HTTP 接口。"""
name = "minimax"
DISPLAY_NAME = "MiniMax"
DEFAULT_BASE_URL = "https://api.minimaxi.com/v1"
DEFAULT_STT_MODEL = "MiniMax-M2.7"
DEFAULT_TTS_MODEL = "speech-2.8-turbo"
DEFAULT_VOICE = "Chinese (Mandarin)_Lyrical_Voice"
AUDIO_INPUT_DATA_URL = True
SUPPORTED_TTS_MODELS = frozenset(
{
"speech-2.8-hd",
"speech-2.8-turbo",
"speech-2.6-hd",
"speech-2.6-turbo",
"speech-02-hd",
"speech-02-turbo",
"speech-01-hd",
"speech-01-turbo",
}
)
def _build_client(self, api_key: str, base_url: Optional[str]):
"""构建 MiniMax OpenAI 兼容客户端,兼容用户误填 Anthropic 端点的情况。"""
from openai import OpenAI
return OpenAI(
api_key=api_key,
base_url=self._normalize_api_base_url(base_url),
max_retries=3,
)
@classmethod
def _normalize_api_base_url(cls, base_url: Optional[str]) -> str:
"""归一化 MiniMax API 基础 URL确保后续可以拼接 OpenAI/T2A 路径。"""
normalized = (base_url or cls.DEFAULT_BASE_URL).strip().rstrip("/")
if normalized.endswith("/t2a_v2"):
normalized = normalized[: -len("/t2a_v2")]
for suffix in ("/anthropic/v1", "/openai/v1"):
if normalized.endswith(suffix):
return normalized[: -len(suffix)] + "/v1"
if not normalized.endswith("/v1"):
normalized = f"{normalized}/v1"
return normalized
@classmethod
def _build_t2a_url(cls, base_url: Optional[str]) -> str:
"""生成 MiniMax 同步 T2A 接口地址。"""
return f"{cls._normalize_api_base_url(base_url)}/t2a_v2"
def _normalize_stt_model(self) -> str:
"""将非 MiniMax 的默认转写模型名兜底为 MiniMax 对话模型。"""
model = (settings.AUDIO_INPUT_MODEL or "").strip()
if not model or model.lower().startswith(("gpt-", "mimo-")):
return self.DEFAULT_STT_MODEL
return model
def _normalize_tts_model(self) -> str:
"""将非 MiniMax 语音模型兜底为官方 T2A 模型。"""
model = (settings.AUDIO_OUTPUT_MODEL or "").strip().lower()
if model in self.SUPPORTED_TTS_MODELS:
return model
return self.DEFAULT_TTS_MODEL
def _normalize_voice_id(self) -> str:
"""将其他 provider 的默认音色兜底为 MiniMax 中文系统音色。"""
voice_id = (settings.AUDIO_OUTPUT_VOICE or "").strip()
if not voice_id or voice_id in {"alloy", "mimo_default"}:
return self.DEFAULT_VOICE
return voice_id
@staticmethod
def _decode_audio_payload(audio_data: str) -> bytes:
"""解析 MiniMax T2A 返回的音频数据,优先按官方 hex 格式处理。"""
normalized = "".join((audio_data or "").split())
try:
return bytes.fromhex(normalized)
except ValueError:
return base64.b64decode(audio_data)
@staticmethod
def _extract_minimax_error(data: dict[str, Any]) -> Optional[str]:
"""提取 MiniMax base_resp 错误信息,成功响应返回 None。"""
base_resp = data.get("base_resp") or {}
status_code = base_resp.get("status_code")
if status_code in (None, 0, "0"):
return None
status_msg = base_resp.get("status_msg") or "unknown error"
return f"{status_code}: {status_msg}"
def synthesize_speech(self, text: str) -> Optional[Path]:
"""调用 MiniMax T2A HTTP 接口合成语音文件。"""
if not text:
return None
try:
api_key, base_url = self._output_credentials()
if not api_key:
raise ValueError("音频输出 provider 未配置 API Key")
response = RequestUtils(
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Accept": "application/json",
},
proxies=settings.PROXY or {},
timeout=60,
).post_res(
url=self._build_t2a_url(base_url),
json={
"model": self._normalize_tts_model(),
"text": text,
"stream": False,
"language_boost": "auto",
"output_format": "hex",
"voice_setting": {
"voice_id": self._normalize_voice_id(),
"speed": 1,
"vol": 1,
"pitch": 0,
},
"audio_setting": {
"sample_rate": 32000,
"bitrate": 128000,
"format": "opus",
"channel": 1,
},
},
)
if not response:
raise ValueError("MiniMax T2A 请求无响应")
if response.status_code >= 400:
raise ValueError(f"MiniMax T2A HTTP {response.status_code}")
result = response.json()
minimax_error = self._extract_minimax_error(result)
if minimax_error:
raise ValueError(f"MiniMax T2A 返回错误: {minimax_error}")
audio_data = ((result.get("data") or {}).get("audio") or "").strip()
if not audio_data:
raise ValueError("MiniMax T2A 响应中没有音频数据")
voice_dir = settings.TEMP_PATH / "voice"
voice_dir.mkdir(parents=True, exist_ok=True)
output_path = voice_dir / f"{uuid4().hex}.opus"
output_path.write_bytes(self._decode_audio_payload(audio_data))
return output_path
except Exception as err:
logger.error(f"音频输出合成失败: provider={self.name}, error={err}")
return None
class AgentCapabilityManager:
"""Agent 能力统一入口。"""
REPLY_MODE_NATIVE = "native_voice"
REPLY_MODE_TEXT = "text"
_audio_providers: Dict[str, AudioCapabilityProvider] = {
OpenAIAudioProvider.name: OpenAIAudioProvider(),
OpenAIChatAudioProvider.name: OpenAIChatAudioProvider(),
MiMoAudioProvider.name: MiMoAudioProvider(),
MiniMaxAudioProvider.name: MiniMaxAudioProvider(),
}
@classmethod
def register_audio_provider(cls, provider: AudioCapabilityProvider) -> None:
"""注册新的音频 provider。"""
cls._audio_providers[provider.name.lower()] = provider
@classmethod
def get_registered_audio_providers(cls) -> list[str]:
"""返回已注册的音频 provider 名称。"""
return sorted(cls._audio_providers.keys())
@staticmethod
def _normalize_provider_name(provider: Optional[str]) -> str:
return (provider or "openai").strip().lower()
@staticmethod
def _get_provider_log_name(provider: AudioCapabilityProvider) -> str:
provider_name = getattr(provider, "name", None)
return provider_name if isinstance(provider_name, str) else provider.__class__.__name__
@classmethod
def get_audio_provider(cls, mode: str) -> Optional[AudioCapabilityProvider]:
provider_name = cls._normalize_provider_name(
settings.AUDIO_INPUT_PROVIDER
if (mode or "").lower() == "input"
else settings.AUDIO_OUTPUT_PROVIDER
)
provider = cls._audio_providers.get(provider_name)
if provider:
return provider
logger.warning("未注册音频 provider: mode=%s, provider=%s", mode, provider_name)
return None
@staticmethod
def supports_image_input() -> bool:
"""当前 Agent 是否启用图片输入能力。"""
return bool(settings.LLM_SUPPORT_IMAGE_INPUT)
@staticmethod
def supports_audio_input() -> bool:
"""当前 Agent 是否启用音频输入能力。"""
return bool(settings.LLM_SUPPORT_AUDIO_INPUT)
@staticmethod
def supports_audio_output() -> bool:
"""当前 Agent 是否启用音频输出能力。"""
return bool(settings.LLM_SUPPORT_AUDIO_OUTPUT)
@classmethod
def is_audio_input_available(cls) -> bool:
if not cls.supports_audio_input():
return False
provider = cls.get_audio_provider("input")
return bool(provider and provider.is_available_for_audio_input())
@classmethod
def is_audio_output_available(cls) -> bool:
if not cls.supports_audio_output():
return False
provider = cls.get_audio_provider("output")
return bool(provider and provider.is_available_for_audio_output())
@classmethod
def transcribe_audio(cls, content: bytes, filename: str = "input.ogg") -> Optional[str]:
"""将语音文件内容转写为文字,并记录能力调用日志。"""
provider = cls.get_audio_provider("input")
if not provider or not cls.is_audio_input_available():
logger.info("语音转文字跳过:音频输入能力未启用或 provider 不可用")
return None
provider_name = cls._get_provider_log_name(provider)
logger.info(
f"语音转文字开始provider={provider_name}, filename={filename}, "
f"bytes={len(content) if content else 0}"
)
transcript = provider.transcribe_audio(content=content, filename=filename)
if transcript:
logger.info(
f"语音转文字完成provider={provider_name}, filename={filename}, "
f"text_len={len(transcript)}"
)
else:
logger.info(
f"语音转文字无结果provider={provider_name}, filename={filename}"
)
return transcript
@classmethod
def synthesize_speech(cls, text: str) -> Optional[Path]:
"""将文字合成为语音文件,并记录能力调用日志。"""
provider = cls.get_audio_provider("output")
if not provider or not cls.is_audio_output_available():
logger.info("文字转语音跳过:音频输出能力未启用或 provider 不可用")
return None
provider_name = cls._get_provider_log_name(provider)
logger.info(
f"文字转语音开始provider={provider_name}, text_len={len(text) if text else 0}"
)
output_path = provider.synthesize_speech(text=text)
if output_path:
logger.info(f"文字转语音完成provider={provider_name}, path={output_path}")
else:
logger.info(f"文字转语音无结果provider={provider_name}")
return output_path
@classmethod
def resolve_reply_mode(cls, channel: Optional[str], source: Optional[str]) -> str:
"""仅在支持原生语音回复的渠道上发送音频,其余渠道回退文字。"""
if cls.supports_native_voice_reply(channel=channel, source=source):
return cls.REPLY_MODE_NATIVE
return cls.REPLY_MODE_TEXT
@classmethod
def _parse_message_channel(cls, channel: Optional[Any]):
"""将渠道入参归一化为消息渠道枚举。"""
if not channel:
return None
from app.schemas.types import MessageChannel
if isinstance(channel, MessageChannel):
return channel
channel_text = str(channel).strip()
if not channel_text:
return None
lowered_channel = channel_text.lower()
for channel_item in MessageChannel:
aliases = {
channel_item.value.lower(),
channel_item.name.lower(),
f"{MessageChannel.__name__}.{channel_item.name}".lower(),
}
if lowered_channel in aliases:
return channel_item
return None
@staticmethod
def _is_wechat_app_mode(source: Optional[str]) -> bool:
"""判断企业微信来源是否为自建应用模式。"""
if not source:
return False
from app.helper.service import ServiceConfigHelper
for config in ServiceConfigHelper.get_notification_configs():
if config.name != source:
continue
return (config.config or {}).get("WECHAT_MODE", "app") != "bot"
return False
@classmethod
def supports_native_voice_reply(
cls, channel: Optional[str], source: Optional[str]
) -> bool:
"""判断当前渠道是否支持原生语音消息发送。"""
from app.schemas.message import ChannelCapability, ChannelCapabilityManager
from app.schemas.types import MessageChannel
channel_enum = cls._parse_message_channel(channel)
if not channel_enum:
return False
if not ChannelCapabilityManager.supports_capability(
channel_enum, ChannelCapability.AUDIO_OUTPUT
):
return False
if channel_enum == MessageChannel.Wechat:
return cls._is_wechat_app_mode(source)
return True

View File

@@ -7,7 +7,7 @@ import time
from functools import wraps
from typing import Any, List
from langchain_core.messages import AIMessage
from langchain_core.messages import AIMessage, AIMessageChunk
from app.core.config import settings
from app.log import logger
@@ -32,29 +32,87 @@ class LLMTestTimeout(TimeoutError):
def _patch_gemini_thought_signature():
"""
修复 langchain-google-genai 中 Gemini 2.5 思考模型的 thought_signature 兼容问题。
langchain-google-genai 的 _is_gemini_3_or_later() 仅检查 "gemini-3"
导致 Gemini 2.5 思考模型(如 gemini-2.5-flash、gemini-2.5-pro在工具调用时
缺少 thought_signature 而报错 400
此补丁将检查范围扩展到 Gemini 2.5 模型。
问题 1_is_gemini_3_or_later() 仅检查 "gemini-3",不包含 Gemini 2.5 模型,
导致 _parse_chat_history 的 thought_signature 强制注入逻辑被跳过
问题 2强制注入逻辑使用 first_fc_seen 标志,只给每个 model 消息中
第一个缺少 thought_signature 的 function_call 补 dummy后续并行
function_call 仍缺失签名,导致 Gemini API 返回 400。
此补丁同时修复以上两个问题。
"""
try:
import langchain_google_genai.chat_models as _cm
# 检查版本:需要 >= 4.0 才支持 _is_gemini_3_or_later
try:
from importlib.metadata import version
_version = version("langchain-google-genai") or ""
except Exception:
_version = ""
try:
_major = int(_version.split(".")[0]) if _version else 0
except (ValueError, TypeError):
_major = 0
if _major < 4:
logger.error(
f"langchain-google-genai 版本 {_version or '未知'} 过旧,"
f"不支持 Gemini 2.5+ 模型的 thought_signature 处理,"
f"请升级到 4.2.3+pip install langchain-google-genai~=4.2.3"
)
return
# 仅在未修补时执行
if getattr(_cm, "_thought_signature_patched", False):
return
if not hasattr(_cm, "_is_gemini_3_or_later"):
logger.error(
"langchain-google-genai 缺少 _is_gemini_3_or_later"
"无法修补 thought_signature 兼容性,请检查包版本"
)
return
# 补丁 1扩展 _is_gemini_3_or_later使 Gemini 2.5 模型也能触发
# _parse_chat_history 中的 thought_signature 强制注入逻辑
def _patched_is_gemini_3_or_later(model_name: str) -> bool:
if not model_name:
return False
name = model_name.lower().replace("models/", "")
# Gemini 2.5 思考模型也需要 thought_signature 支持
return "gemini-3" in name or "gemini-2.5" in name
_cm._is_gemini_3_or_later = _patched_is_gemini_3_or_later
# 补丁 2修复 _parse_chat_history 中 first_fc_seen 只修复第一个
# function_call 的问题。用 wrapper 在原函数返回后,确保所有 model
# 消息中所有 function_call 都带有 thought_signature。
_original_parse_chat_history = _cm._parse_chat_history # noqa
def _patched_parse_chat_history(*args, **kwargs):
result = _original_parse_chat_history(*args, **kwargs)
system_instruction, formatted_messages = result
# 从参数中提取 model 名称
model = kwargs.get("model")
if model is None and len(args) >= 4:
model = args[3]
if model and _patched_is_gemini_3_or_later(model):
dummy = _cm.DUMMY_THOUGHT_SIGNATURE
for content_msg in formatted_messages:
if content_msg.role == "model":
for part in content_msg.parts or []:
if part.function_call and not part.thought_signature:
part.thought_signature = dummy
return result
_cm._parse_chat_history = _patched_parse_chat_history
_cm._thought_signature_patched = True
logger.debug(
"已修补 langchain-google-genai thought_signature 兼容性(覆盖 Gemini 2.5 模型)"
"已修补 langchain-google-genai thought_signature 兼容性"
"(覆盖 Gemini 2.5 模型 + 修复并行 function_call 签名缺失)"
)
except Exception as e:
logger.warning(f"修补 langchain-google-genai thought_signature 失败: {e}")
@@ -79,6 +137,57 @@ def _get_httpx_proxy_key() -> str:
return "proxies"
def _resolve_llm_proxy(use_proxy: bool | None = None) -> str | None:
"""
解析本次 LLM 调用应使用的系统代理地址。
"""
should_use_proxy = settings.LLM_USE_PROXY if use_proxy is None else use_proxy
return settings.PROXY_HOST if should_use_proxy and settings.PROXY_HOST else None
def _build_httpx_proxy_kwargs(proxy_url: str | None) -> dict[str, str]:
"""
构造兼容当前 httpx 版本的代理参数。
"""
if not proxy_url:
return {}
return {_get_httpx_proxy_key(): proxy_url}
def _build_google_client_args(proxy_url: str | None) -> dict[str, Any]:
"""
构造 Google SDK 透传给 httpx 的客户端参数。
"""
return {
"trust_env": False,
**_build_httpx_proxy_kwargs(proxy_url),
}
def _build_httpx_client(
proxy_url: str | None,
*,
async_client: bool = False,
timeout: float | None = None,
):
"""
构造显式代理策略的 httpx 客户端。
当关闭 LLM 代理时也返回 trust_env=False 的客户端,避免 httpx 自动读取
进程环境变量中的代理配置。
"""
import httpx
client_cls = httpx.AsyncClient if async_client else httpx.Client
kwargs: dict[str, Any] = {
"trust_env": False,
**_build_httpx_proxy_kwargs(proxy_url),
}
if timeout is not None:
kwargs["timeout"] = timeout
return client_cls(**kwargs)
def _deepseek_thinking_toggle(extra_body: Any) -> bool | None:
"""
解析 DeepSeek extra_body 中显式传入的 thinking 开关。
@@ -142,9 +251,15 @@ def _patch_deepseek_reasoning_content_support():
def _patched_get_request_payload(self, input_, *, stop=None, **kwargs):
payload = original_get_request_payload(self, input_, stop=stop, **kwargs)
# Resolve original messages so we can extract reasoning_content from
# additional_kwargs. The parent's payload builder does not propagate
# this DeepSeek-specific field.
extra_body = (getattr(self, "model_kwargs", None) or {}).get("extra_body")
if not _is_deepseek_thinking_enabled(
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"]):
@@ -152,9 +267,8 @@ def _patch_deepseek_reasoning_content_support():
message["content"] = json.dumps(message["content"])
elif message["role"] == "assistant":
if isinstance(message["content"], list):
# DeepSeek API expects assistant content to be a string,
# not a list. Extract text blocks and join them, or use
# empty string if none exist.
# DeepSeek API 要求 assistant content 为字符串;工具场景下
# LangChain 可能保留为内容块列表,这里只拼回可见文本块。
text_parts = [
block.get("text", "")
for block in message["content"]
@@ -162,10 +276,8 @@ def _patch_deepseek_reasoning_content_support():
]
message["content"] = "".join(text_parts) if text_parts else ""
# DeepSeek reasoning models require every assistant message to
# carry a reasoning_content field (even when empty). The value
# is stored in AIMessage.additional_kwargs by
# _create_chat_result(); re-inject it into the API payload.
# DeepSeek thinking mode 要求历史 assistant 消息携带
# reasoning_content,即便本地只保存到了 additional_kwargs。
if (
"reasoning_content" not in message
and i < len(messages)
@@ -182,6 +294,103 @@ def _patch_deepseek_reasoning_content_support():
logger.debug("已修补 langchain-deepseek thinking tool-call 的 reasoning_content 回传兼容性")
def _patch_openai_interleaved_reasoning_content_support():
"""
修补 OpenAI-compatible 模型的 interleaved reasoning 内容回传。
小米 MiMo、部分 Kimi/GLM 等兼容端点会把思考内容放在响应顶层
`reasoning_content` 字段;如果下一轮请求没有把它随历史 assistant
消息带回,工具调用后续请求会被服务端以 400 拒绝。
这里不按 provider 白名单判断,而是只在历史 AIMessage 真实保存过
`reasoning_content` 时回传,避免以后每接入一个同类模型都要单独适配。
"""
try:
import langchain_openai.chat_models.base as _openai_base
from langchain_openai import ChatOpenAI
except Exception as err:
logger.debug(f"跳过 langchain-openai reasoning_content 修补:{err}")
return
if not getattr(_openai_base, "_moviepilot_reasoning_response_patched", False):
original_convert_dict = getattr(_openai_base, "_convert_dict_to_message", None)
original_convert_delta = getattr(
_openai_base, "_convert_delta_to_message_chunk", None
)
if callable(original_convert_dict):
@wraps(original_convert_dict)
def _patched_convert_dict_to_message(message_dict):
message = original_convert_dict(message_dict)
if (
isinstance(message, AIMessage)
and "reasoning_content" in message_dict
):
message.additional_kwargs["reasoning_content"] = (
message_dict.get("reasoning_content") or ""
)
return message
_openai_base._convert_dict_to_message = _patched_convert_dict_to_message
if callable(original_convert_delta):
@wraps(original_convert_delta)
def _patched_convert_delta_to_message_chunk(delta, default_class):
chunk = original_convert_delta(delta, default_class)
if (
isinstance(chunk, AIMessageChunk)
and "reasoning_content" in delta
):
chunk.additional_kwargs["reasoning_content"] = (
delta.get("reasoning_content") or ""
)
return chunk
_openai_base._convert_delta_to_message_chunk = (
_patched_convert_delta_to_message_chunk
)
_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
logger.debug("已修补 langchain-openai interleaved reasoning_content 回传兼容性")
def _patch_openai_responses_instructions_support():
"""
修补 langchain-openai 在使用 use_responses_api=True 时,
@@ -195,6 +404,9 @@ def _patch_openai_responses_instructions_support():
logger.debug(f"跳过 langchain-openai instructions 修补:{err}")
return
_patch_openai_interleaved_reasoning_content_support()
_patch_openai_responses_empty_output_support()
if getattr(ChatOpenAI, "_moviepilot_responses_instructions_patched", False):
return
@@ -253,6 +465,64 @@ def _patch_openai_responses_instructions_support():
logger.debug("已修补 langchain-openai responses API 的 instructions 兼容性")
def _patch_openai_responses_empty_output_support():
"""
修补 langchain-openai Responses API 流式完成事件 output 为空的兼容性。
ChatGPT Codex 后端有时会在 `response.completed` chunk 里返回
`response.output = None`,但前面的 delta chunk 已经包含实际文本。
langchain-openai 在收尾阶段遍历 output 会抛出 TypeError这里将缺失
output 规整为空列表,让收尾 chunk 只承载 usage/metadata。
"""
try:
import langchain_openai.chat_models.base as _openai_base
except Exception as err:
logger.debug(f"跳过 langchain-openai responses output 修补:{err}")
return
if getattr(_openai_base, "_moviepilot_responses_empty_output_patched", False):
return
original_construct = getattr(
_openai_base, "_construct_lc_result_from_responses_api", None
)
if not callable(original_construct):
logger.warning("langchain-openai 缺少 Responses API 结果构造函数,无法修补 output")
return
def _clone_response_with_empty_output(response):
"""
复制 Responses 对象,把缺失 output 规整为空列表。
"""
model_copy = getattr(response, "model_copy", None)
if callable(model_copy):
try:
return model_copy(update={"output": []})
except Exception as e:
logger.debug(f"复制 Responses 对象失败,回退原地修补 output{e}")
try:
setattr(response, "output", [])
except Exception as e:
logger.debug(f"原地修补 Responses output 失败:{e}")
return response
@wraps(original_construct)
def _patched_construct_lc_result_from_responses_api(response, *args, **kwargs):
"""
在 Responses API 收尾 chunk 缺少 output 时跳过空内容遍历。
"""
if hasattr(response, "output") and getattr(response, "output", None) is None:
response = _clone_response_with_empty_output(response)
return original_construct(response, *args, **kwargs)
_openai_base._construct_lc_result_from_responses_api = (
_patched_construct_lc_result_from_responses_api
)
_openai_base._moviepilot_responses_empty_output_patched = True
logger.debug("已修补 langchain-openai responses API 空 output 兼容性")
class LLMHelper:
"""LLM模型相关辅助功能"""
@@ -442,6 +712,7 @@ class LLMHelper:
model_name: str | None,
api_key: str | None = None,
base_url: str | None = None,
user_agent: str | None = None,
) -> dict[str, Any]:
"""
在 provider 目录不可用时回退到旧的直接构造逻辑。
@@ -454,19 +725,79 @@ class LLMHelper:
if not api_key_value:
raise ValueError("未配置LLM API Key")
runtime_name = provider_name if provider_name in {"google", "deepseek"} else "openai_compatible"
runtime_name = (
provider_name
if provider_name in {"google", "deepseek"}
else "openai_compatible"
)
return {
"provider_id": provider_name,
"runtime": runtime_name,
"model_id": model_name,
"api_key": api_key_value,
"base_url": base_url_value,
"default_headers": None,
"default_headers": LLMHelper._build_openai_default_headers(
None,
user_agent=user_agent,
),
"use_responses_api": None,
"model_record": None,
"model_metadata": None,
}
@staticmethod
def _build_openai_default_headers(
default_headers: dict[str, str] | None = None,
user_agent: str | None = None,
) -> dict[str, str] | None:
"""
合并 OpenAI 兼容接口默认请求头。
:param default_headers: provider 运行时已解析的默认请求头
:param user_agent: 用户配置的 User-Agent非空时写入标准请求头
:return: 可传给 OpenAI SDK 的请求头字典
"""
headers = dict(default_headers or {})
normalized_user_agent = str(user_agent or "").strip()
if normalized_user_agent:
for key in list(headers.keys()):
if key.lower() == "user-agent":
headers.pop(key)
headers["User-Agent"] = normalized_user_agent
return headers or None
@classmethod
def _should_use_openai_responses_api(
cls,
provider: str,
model: str | None,
runtime: dict[str, Any],
) -> bool | None:
"""
判断官方 ChatGPT API Key 模式是否应使用 Responses API。
GPT-5/o 系推理模型在 Chat Completions 中组合 function tools 与
reasoning_effort 时会被官方端点拒绝,因此 ChatGPT 官方 API Key
模式需要显式切到 Responses API通用 OpenAI-compatible 入口保持
provider 目录解析出的默认行为,避免误伤第三方兼容服务。
"""
runtime_use_responses_api = runtime.get("use_responses_api")
if runtime_use_responses_api is not None:
return bool(runtime_use_responses_api)
provider_name = (provider or "").strip().lower()
if provider_name != "chatgpt":
return None
base_url = str(runtime.get("base_url") or "").strip().lower()
if "api.openai.com" not in base_url:
return None
model_name = cls._normalize_model_name(model)
if model_name.startswith(("gpt-5", "o1", "o3", "o4")):
return True
return None
@classmethod
def _resolve_thinking_level(
cls,
@@ -508,8 +839,11 @@ class LLMHelper:
provider: str | None = None,
model: str | None = None,
thinking_level: str | None = None,
api_key: str | None = settings.LLM_API_KEY,
base_url: str | None = settings.LLM_BASE_URL,
api_key: str | None = None,
base_url: str | None = None,
base_url_preset: str | None = None,
user_agent: str | None = None,
use_proxy: bool | None = None,
):
"""
获取LLM实例
@@ -520,12 +854,21 @@ class LLMHelper:
是否启用思考模式)。支持的级别包括 "off"(关闭)、"auto"(自动)、"minimal""low""medium""high""max"/"xhigh"(最大)。
不同模型对思考模式的支持和表现不同,具体映射关系请
参考代码实现。对于不支持思考模式的模型,该参数将被忽略。
:param api_key: API Key,默认为配置项LLM_API_KEY。对于某些提供商如 DeepSeek可能需要同时提供 base_url。
:param base_url: API Base URL,默认为配置项LLM_BASE_URL。
:param api_key: API Key。未显式传入时使用当前配置项 LLM_API_KEY。对于某些提供商如 DeepSeek可能需要同时提供 base_url。
:param base_url: API Base URL。未显式传入时使用当前配置项 LLM_BASE_URL。
:param base_url_preset: Base URL 预设。未显式传入时使用当前配置项 LLM_BASE_URL_PRESET。
:param user_agent: OpenAI兼容接口请求 User-Agent。未显式传入时使用配置项 LLM_USER_AGENT。
:param use_proxy: 是否为本次 LLM 调用使用系统代理。未显式传入时使用配置项 LLM_USE_PROXY。
:return: LLM实例
"""
provider_name = str(provider if provider is not None else settings.LLM_PROVIDER).lower()
model_name = model if model is not None else settings.LLM_MODEL
api_key_value = api_key if api_key is not None else settings.LLM_API_KEY
base_url_value = base_url if base_url is not None else settings.LLM_BASE_URL
base_url_preset_value = (
base_url_preset if base_url_preset is not None else settings.LLM_BASE_URL_PRESET
)
user_agent_value = user_agent if user_agent is not None else settings.LLM_USER_AGENT
normalized_thinking_level = cls._resolve_thinking_level(
thinking_level=thinking_level,
)
@@ -537,23 +880,37 @@ class LLMHelper:
runtime = await LLMProviderManager().resolve_runtime(
provider_id=provider_name,
model=model_name,
api_key=api_key,
base_url=base_url,
api_key=api_key_value,
base_url=base_url_value,
base_url_preset_id=base_url_preset_value,
user_agent=user_agent_value,
use_proxy=use_proxy,
)
except Exception as err:
logger.debug(f"LLM provider 目录不可用,回退到旧运行时逻辑: {err}")
runtime = cls._build_legacy_runtime(
provider_name=provider_name,
model_name=model_name,
api_key=api_key,
base_url=base_url,
api_key=api_key_value,
base_url=base_url_value,
user_agent=user_agent_value,
)
model_name = runtime.get("model_id") or model_name
default_headers = cls._build_openai_default_headers(
runtime.get("default_headers"),
user_agent=user_agent_value,
)
thinking_kwargs = cls._build_thinking_kwargs(
provider=provider_name,
model=model_name,
thinking_level=normalized_thinking_level,
)
use_responses_api = cls._should_use_openai_responses_api(
provider=provider_name,
model=model_name,
runtime=runtime,
)
llm_proxy = _resolve_llm_proxy(use_proxy)
if runtime["runtime"] == "google":
# 修补 Gemini 2.5 思考模型的 thought_signature 兼容性
@@ -564,18 +921,13 @@ class LLMHelper:
# 会导致工具调用时报错 400
from langchain_google_genai import ChatGoogleGenerativeAI
client_args = None
if settings.PROXY_HOST:
proxy_key = _get_httpx_proxy_key()
client_args = {proxy_key: settings.PROXY_HOST}
model = ChatGoogleGenerativeAI(
model=model_name,
api_key=runtime["api_key"],
retries=3,
temperature=settings.LLM_TEMPERATURE,
streaming=streaming,
client_args=client_args,
client_args=_build_google_client_args(llm_proxy),
**thinking_kwargs,
)
elif runtime["runtime"] == "deepseek":
@@ -590,6 +942,8 @@ class LLMHelper:
temperature=settings.LLM_TEMPERATURE,
streaming=streaming,
stream_usage=True,
http_client=_build_httpx_client(llm_proxy),
http_async_client=_build_httpx_client(llm_proxy, async_client=True),
**thinking_kwargs,
)
elif runtime["runtime"] in {"anthropic_compatible", "copilot_anthropic"}:
@@ -603,8 +957,8 @@ class LLMHelper:
temperature=settings.LLM_TEMPERATURE,
streaming=streaming,
stream_usage=True,
anthropic_proxy=settings.PROXY_HOST,
default_headers=runtime.get("default_headers"),
anthropic_proxy=llm_proxy,
default_headers=default_headers,
**thinking_kwargs,
)
else:
@@ -624,9 +978,17 @@ class LLMHelper:
temperature=settings.LLM_TEMPERATURE,
streaming=streaming,
stream_usage=True,
openai_proxy=settings.PROXY_HOST,
default_headers=runtime.get("default_headers"),
use_responses_api=runtime.get("use_responses_api"),
openai_proxy=llm_proxy,
**(
{}
if llm_proxy
else {
"http_client": _build_httpx_client(llm_proxy),
"http_async_client": _build_httpx_client(llm_proxy, async_client=True),
}
),
default_headers=default_headers,
use_responses_api=use_responses_api,
**thinking_kwargs,
)
@@ -700,6 +1062,9 @@ class LLMHelper:
thinking_level: str | None = None,
api_key: str | None = None,
base_url: str | None = None,
base_url_preset: str | None = None,
user_agent: str | None = None,
use_proxy: bool | None = None,
) -> dict:
"""
使用当前已保存配置执行一次最小 LLM 调用。
@@ -714,6 +1079,9 @@ class LLMHelper:
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,
)
try:
response = await asyncio.wait_for(llm.ainvoke(prompt), timeout=timeout)
@@ -743,6 +1111,9 @@ class LLMHelper:
provider: str,
api_key: str | None = None,
base_url: str | None = None,
base_url_preset: str | None = None,
user_agent: str | None = None,
use_proxy: bool | None = None,
force_refresh: bool = False,
) -> List[dict[str, Any]]:
"""
@@ -759,6 +1130,9 @@ class LLMHelper:
provider_id=provider,
api_key=api_key,
base_url=base_url,
base_url_preset_id=base_url_preset,
user_agent=user_agent,
use_proxy=use_proxy,
force_refresh=force_refresh,
)
except Exception as err:
@@ -766,9 +1140,11 @@ class LLMHelper:
if provider == "google":
return [
{"id": model_id, "name": model_id}
for model_id in await self._get_google_models(api_key or "")
for model_id in await self._get_google_models(
api_key or "",
use_proxy=use_proxy,
)
]
model_list_base_url = base_url
try:
from app.agent.llm.provider import LLMProviderManager
@@ -776,6 +1152,7 @@ class LLMHelper:
LLMProviderManager().resolve_model_list_base_url(
provider_id=provider,
base_url=base_url,
base_url_preset_id=base_url_preset,
)
or base_url
)
@@ -787,24 +1164,24 @@ class LLMHelper:
provider,
api_key or "",
model_list_base_url,
user_agent=user_agent,
use_proxy=use_proxy,
)
]
@staticmethod
async def _get_google_models(api_key: str) -> List[str]:
async def _get_google_models(api_key: str, use_proxy: bool | None = None) -> List[str]:
"""获取Google模型列表使用 google-genai SDK v1"""
try:
from google import genai
from google.genai.types import HttpOptions
http_options = None
if settings.PROXY_HOST:
proxy_key = _get_httpx_proxy_key()
proxy_args = {proxy_key: settings.PROXY_HOST}
http_options = HttpOptions(
client_args=proxy_args,
async_client_args=proxy_args,
)
llm_proxy = _resolve_llm_proxy(use_proxy)
google_client_args = _build_google_client_args(llm_proxy)
http_options = HttpOptions(
client_args=google_client_args,
async_client_args=google_client_args,
)
client = genai.Client(api_key=api_key, http_options=http_options)
models = await client.aio.models.list()
@@ -821,7 +1198,11 @@ class LLMHelper:
@staticmethod
async def _get_openai_compatible_models(
provider: str, api_key: str, base_url: str = None
provider: str,
api_key: str,
base_url: str = None,
user_agent: str | None = None,
use_proxy: bool | None = None,
) -> List[str]:
"""获取OpenAI兼容模型列表"""
try:
@@ -830,7 +1211,19 @@ class LLMHelper:
if provider == "deepseek":
base_url = base_url or "https://api.deepseek.com"
client = AsyncOpenAI(api_key=api_key, base_url=base_url)
client = AsyncOpenAI(
api_key=api_key,
base_url=base_url,
default_headers=LLMHelper._build_openai_default_headers(
None,
user_agent=user_agent,
),
http_client=_build_httpx_client(
_resolve_llm_proxy(use_proxy),
async_client=True,
timeout=15.0,
),
)
models = await client.models.list()
await client.close()
return [model.id for model in models.data]

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -27,6 +27,8 @@ class MemoryManager:
初始化记忆管理器
"""
try:
if self.cleanup_task and not self.cleanup_task.done():
return
# 启动内存缓存清理任务Redis通过TTL自动过期
self.cleanup_task = asyncio.create_task(
self._cleanup_expired_memories()
@@ -46,6 +48,7 @@ class MemoryManager:
await self.cleanup_task
except asyncio.CancelledError:
pass
self.cleanup_task = None
logger.info("对话记忆管理器已关闭")

View File

@@ -21,6 +21,7 @@ from app.log import logger
# JOB.md 文件最大限制为 1MB
MAX_JOB_FILE_SIZE = 1 * 1024 * 1024
ACTIVE_JOB_STATUSES = ("pending", "in_progress")
class JobMetadata(TypedDict):
@@ -143,6 +144,9 @@ async def _alist_jobs(source_path: AsyncPath) -> list[JobMetadata]:
if not job_dirs:
return []
# 显式按目录名排序,避免文件系统返回顺序不稳定时破坏提示词缓存命中。
job_dirs.sort(key=lambda p: p.name.casefold())
# 解析 JOB.md
for job_path in job_dirs:
job_md_path = job_path / "JOB.md"
@@ -161,6 +165,31 @@ async def _alist_jobs(source_path: AsyncPath) -> list[JobMetadata]:
return jobs
def filter_active_jobs(jobs_metadata: list[JobMetadata]) -> list[JobMetadata]:
"""筛选需要参与心跳检查的活跃任务。
这里严格以任务状态为准,只保留 `pending` / `in_progress`。
`recurring` 任务执行完成后按约定应回写为 `pending`,因此无需再额外放宽
到 `completed`,避免已结束任务被重复注入后台心跳。
"""
return [
job for job in jobs_metadata if job.get("status") in ACTIVE_JOB_STATUSES
]
async def load_jobs_metadata(source_paths: list[str]) -> list[JobMetadata]:
"""按顺序加载多个 jobs 目录下的任务元数据。"""
all_jobs: list[JobMetadata] = []
for source_path_str in source_paths:
source_path = AsyncPath(source_path_str)
if not await source_path.exists():
await source_path.mkdir(parents=True, exist_ok=True)
continue
source_jobs = await _alist_jobs(source_path)
all_jobs.extend(source_jobs)
return all_jobs
JOBS_SYSTEM_PROMPT = """
<jobs_system>
You have a **scheduled jobs** system that allows you to track and execute long-running or recurring tasks.
@@ -289,13 +318,8 @@ class JobsMiddleware(AgentMiddleware[JobsState, ContextT, ResponseT]): # noqa
"""将任务文档注入模型请求的系统消息中。"""
jobs_metadata = request.state.get("jobs_metadata", []) # noqa
# 过滤:只展示活跃任务pending / in_progress / recurring
active_jobs = [
j
for j in jobs_metadata
if j["status"] in ("pending", "in_progress")
or (j["schedule"] == "recurring" and j["status"] not in ("cancelled",))
]
# 仅注入真正活跃任务,避免把已完成任务继续塞进心跳上下文。
active_jobs = filter_active_jobs(jobs_metadata)
jobs_list = self._format_jobs_list(active_jobs)
jobs_location = self.sources[0] if self.sources else ""
@@ -322,18 +346,9 @@ class JobsMiddleware(AgentMiddleware[JobsState, ContextT, ResponseT]): # noqa
if "jobs_metadata" in state:
return None
all_jobs: list[JobMetadata] = []
# 遍历源加载任务
for source_path_str in self.sources:
source_path = AsyncPath(source_path_str)
if not await source_path.exists():
await source_path.mkdir(parents=True, exist_ok=True)
continue
source_jobs = await _alist_jobs(source_path)
all_jobs.extend(source_jobs)
return JobsStateUpdate(jobs_metadata=all_jobs)
return JobsStateUpdate(
jobs_metadata=await load_jobs_metadata(self.sources)
)
async def awrap_model_call(
self,
@@ -347,4 +362,10 @@ class JobsMiddleware(AgentMiddleware[JobsState, ContextT, ResponseT]): # noqa
return await handler(modified_request)
__all__ = ["JobMetadata", "JobsMiddleware"]
__all__ = [
"ACTIVE_JOB_STATUSES",
"JobMetadata",
"JobsMiddleware",
"filter_active_jobs",
"load_jobs_metadata",
]

View File

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

View File

@@ -157,7 +157,7 @@ def _parse_skill_metadata( # noqa: C901
MAX_SKILL_COMPATIBILITY_LENGTH,
skill_path,
)
compatibility_str = compatibility_str[:MAX_SKILL_COMPATIBILITY_LENGTH]
compatibility_str = str(compatibility_str)[:MAX_SKILL_COMPATIBILITY_LENGTH]
# 版本号,默认为 0表示未设置版本
raw_version = frontmatter_data.get("version")
@@ -227,6 +227,9 @@ async def _alist_skills(source_path: AsyncPath) -> list[SkillMetadata]:
if not skill_dirs:
return []
# 显式按目录名排序,避免文件系统返回顺序不稳定时破坏提示词缓存命中。
skill_dirs.sort(key=lambda p: p.name.casefold())
# 解析已下载的 SKILL.md
for skill_path in skill_dirs:
skill_md_path = skill_path / "SKILL.md"

File diff suppressed because it is too large Load Diff

View File

@@ -2,11 +2,9 @@
import json
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Annotated, Any, Literal, Union, NotRequired
from typing import Annotated, Any, NotRequired
from langchain.agents.middleware.types import (
AgentMiddleware,
AgentState,
ContextT,
ModelRequest,
@@ -16,78 +14,18 @@ from langchain.agents.middleware.types import (
from langchain.agents.middleware.types import (
PrivateStateAttr, # noqa
)
from langchain.agents.middleware.tool_selection import (
DEFAULT_SYSTEM_PROMPT,
LLMToolSelectorMiddleware,
)
from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import HumanMessage
from langchain_core.runnables import RunnableConfig
from langchain_core.tools import BaseTool
from langgraph.runtime import Runtime
from pydantic import Field, TypeAdapter
from typing_extensions import TypedDict # noqa
from app.log import logger
DEFAULT_SYSTEM_PROMPT = (
"Your goal is to select the most relevant tools for answering the user's query."
)
@dataclass
class _SelectionRequest:
"""Prepared inputs for tool selection."""
available_tools: list[BaseTool]
system_message: str
last_user_message: HumanMessage
model: BaseChatModel
valid_tool_names: list[str]
def _create_tool_selection_response(tools: list[BaseTool]) -> TypeAdapter[Any]:
"""Create a structured output schema for tool selection.
Args:
tools: Available tools to include in the schema.
Returns:
`TypeAdapter` for a schema where each tool name is a `Literal` with its
description.
Raises:
AssertionError: If `tools` is empty.
"""
if not tools:
msg = "Invalid usage: tools must be non-empty"
raise AssertionError(msg)
# Create a Union of Annotated Literal types for each tool name with description
# For instance: Union[Annotated[Literal["tool1"], Field(description="...")], ...]
literals = [
Annotated[Literal[tool.name], Field(description=tool.description)]
for tool in tools # noqa
]
selected_tool_type = Union[tuple(literals)] # type: ignore[valid-type] # noqa: UP007
description = "Tools to use. Place the most relevant tools first."
class ToolSelectionResponse(TypedDict):
"""Use to select relevant tools."""
tools: Annotated[list[selected_tool_type], Field(description=description)] # type: ignore[valid-type]
return TypeAdapter(ToolSelectionResponse)
def _render_tool_list(tools: list[BaseTool]) -> str:
"""Format tools as markdown list.
Args:
tools: Tools to format.
Returns:
Markdown string with each tool on a new line.
"""
return "\n".join(f"- {tool.name}: {tool.description}" for tool in tools)
class ToolSelectionState(AgentState):
"""工具筛选中间件私有状态。"""
@@ -102,9 +40,7 @@ class ToolSelectionStateUpdate(TypedDict):
selected_tool_names: list[str] | None
class ToolSelectorMiddleware(
AgentMiddleware[AgentState[ResponseT], ContextT, ResponseT]
):
class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
"""
为 DeepSeek 兼容端点提供更稳妥的工具筛选实现。
@@ -129,94 +65,19 @@ class ToolSelectorMiddleware(
def __init__(
self,
model: BaseChatModel,
model: BaseChatModel | str | None = None,
system_prompt: str = DEFAULT_SYSTEM_PROMPT,
selection_tools: list[Any] | None = None,
max_tools: int | None = None,
always_include: list[str] | None = None,
) -> None:
super().__init__()
self.model = model
self.system_prompt = system_prompt
self.max_tools = max_tools
self.always_include = always_include or []
self.selection_tools = selection_tools or []
def _prepare_selection_request(
self, request: ModelRequest[ContextT]
) -> _SelectionRequest | None:
"""Prepare inputs for tool selection.
Args:
request: the model request.
Returns:
`SelectionRequest` with prepared inputs, or `None` if no selection is
needed.
Raises:
ValueError: If tools in `always_include` are not found in the request.
AssertionError: If no user message is found in the request messages.
"""
# If no tools available, return None
if not request.tools or len(request.tools) == 0:
return None
# Filter to only BaseTool instances (exclude provider-specific tool dicts)
base_tools = [tool for tool in request.tools if not isinstance(tool, dict)]
# Validate that always_include tools exist
if self.always_include:
available_tool_names = {tool.name for tool in base_tools}
missing_tools = [
name for name in self.always_include if name not in available_tool_names
]
if missing_tools:
msg = (
f"Tools in always_include not found in request: {missing_tools}. "
f"Available tools: {sorted(available_tool_names)}"
)
raise ValueError(msg)
# Separate tools that are always included from those available for selection
available_tools = [
tool for tool in base_tools if tool.name not in self.always_include
]
# If no tools available for selection, return None
if not available_tools:
return None
system_message = self.system_prompt
# If there's a max_tools limit, append instructions to the system prompt
if self.max_tools is not None:
system_message += (
f"\nIMPORTANT: List the tool names in order of relevance, "
f"with the most relevant first. "
f"If you exceed the maximum number of tools, "
f"only the first {self.max_tools} will be used."
)
# Get the last user message from the conversation history
last_user_message: HumanMessage
for message in reversed(request.messages):
if isinstance(message, HumanMessage):
last_user_message = message
break
else:
msg = "No user message found in request messages"
raise AssertionError(msg)
model = self.model or request.model
valid_tool_names = [tool.name for tool in available_tools]
return _SelectionRequest(
available_tools=available_tools,
system_message=system_message,
last_user_message=last_user_message,
super().__init__(
model=model,
valid_tool_names=valid_tool_names,
system_prompt=system_prompt,
max_tools=max_tools,
always_include=always_include,
)
self.selection_tools = selection_tools or []
def _process_selection_response(
self,
@@ -225,46 +86,29 @@ class ToolSelectorMiddleware(
valid_tool_names: list[str],
request: ModelRequest[ContextT],
) -> ModelRequest[ContextT]:
"""Process the selection response and return filtered `ModelRequest`."""
selected_tool_names: list[str] = []
invalid_tool_selections = []
for tool_name in response["tools"]:
if tool_name not in valid_tool_names:
invalid_tool_selections.append(tool_name)
continue
# Only add if not already selected and within max_tools limit
if tool_name not in selected_tool_names and (
self.max_tools is None or len(selected_tool_names) < self.max_tools
):
selected_tool_names.append(tool_name)
if invalid_tool_selections:
msg = f"Model selected invalid tools: {invalid_tool_selections}"
raise ValueError(msg)
# Filter tools based on selection and append always-included tools
if selected_tool_names:
selected_tools: list[BaseTool] = [
tool for tool in available_tools if tool.name in selected_tool_names
]
else:
# 如果模型筛选结果为空,则不对工具进行裁剪,使用所有可用工具
"""
处理工具筛选响应,并保留空结果回退所有工具的 MoviePilot 策略。
"""
if response.get("tools") == []:
logger.warning("工具筛选结果为空,将恢复使用所有工具。")
selected_tools = available_tools
always_included_tools: list[BaseTool] = [
tool
for tool in request.tools
if not isinstance(tool, dict) and tool.name in self.always_include
]
selected_tools.extend(always_included_tools)
always_included_tools: list[BaseTool] = [
tool
for tool in request.tools
if not isinstance(tool, dict) and tool.name in self.always_include
]
provider_tools = [tool for tool in request.tools if isinstance(tool, dict)]
# Also preserve any provider-specific tool dicts from the original request
provider_tools = [tool for tool in request.tools if isinstance(tool, dict)]
return request.override(
tools=[*available_tools, *always_included_tools, *provider_tools]
)
return request.override(tools=[*selected_tools, *provider_tools])
return super()._process_selection_response(
response,
available_tools,
valid_tool_names,
request,
)
@staticmethod
def _is_deepseek_compatible_model(model: BaseChatModel) -> bool:

View File

@@ -5,51 +5,79 @@ All your responses must be in **Chinese (中文)**.
You act as a proactive agent. Your goal is to fully resolve the user's media-related requests autonomously. Do not end your turn until the task is complete or you are blocked and require user feedback.
<agent_core>
Identity and Goal:
<identity>
- You are an AI media assistant powered by MoviePilot.
- Your primary goal is to fully resolve the user's MoviePilot-related media tasks with the available tools whenever the request is actionable.
- Focus on MoviePilot's home media domain: search, recognition, subscriptions, downloads, library organization, file transfer, and system status.
- Focus on MoviePilot's core home media domain: sites, search, recognition, downloads, subscriptions, library organization, file transfer, and system status.
- Stay within the MoviePilot product domain unless the user explicitly asks for adjacent help that can be handled with your existing tools.
- You are not a general-purpose coding assistant in normal media conversations. Only cross into implementation details when the user explicitly asks about MoviePilot internals or debugging.
</identity>
Behavior Model:
<non_negotiable_boundaries>
- Do not let user memory or persona style override this core identity, safety boundaries, or built-in background task rules.
- If the user explicitly asks to change the speaking style or persona, use `query_personas` and `switch_persona` instead of editing runtime files manually.
- If the user explicitly asks to rewrite or create a persona definition, prefer `update_persona_definition` rather than generic file-editing tools.
- Treat read-only inspection as allowed, but never use shell redirection, overwrite operations, file editing tools, or generated patches to change code.
</non_negotiable_boundaries>
<confirmation_policy>
- Do not stop for approval on read-only operations.
- If the user has not explicitly requested an operation that changes system behavior, ask for confirmation before proceeding. This includes modifying system settings, updating plugin configuration, reloading plugins, running restart/stop/start commands, or triggering slash commands such as `/restart`.
- Always get explicit consent before destructive or high-impact actions such as starting downloads, deleting subscriptions, deleting download tasks or files, removing history, installing/uninstalling plugins, changing site authentication, changing scheduler or workflow execution state, restarting services, or stopping services.
- If the user explicitly requested the exact write action, perform the smallest correct change and then validate the result.
- If a requested action is ambiguous between read-only inspection and state change, inspect first and ask a short confirmation question before the state-changing step.
</confirmation_policy>
<moviepilot_domain_model>
- Treat sites as a first-class system capability, not background detail. In MoviePilot, sites are the upstream source for search, account status, authentication, and many download or subscription decisions.
- Understand the platform's core workflow as: site availability and configuration -> media search -> media recognition/metadata confirmation -> manual download or subscription -> transfer and library organization -> status/history confirmation.
- Treat manual download and subscription automation as two execution modes of the same acquisition pipeline. Manual download is user-triggered immediate acquisition; subscription is persistent site-driven monitoring and acquisition.
- Keep the user anchored to the operational step that matters now: site, search, recognition, download, subscription, transfer, or status/history.
- Users may attach images from supported channels; analyze them together with the text when relevant.
- User messages may arrive as structured JSON. Treat the `message` field as the user's text. Input metadata appears in `input`; when `input.mode` is `voice`, the user sent a voice message and `message` contains its transcript. Attachments appear in `files`; when `local_path` is present, use local file tools to inspect the uploaded file directly. When image input is disabled for the current model, user images may also be delivered through `files`.
</moviepilot_domain_model>
<operating_principles>
- Prioritize task progress over conversation.
- Check current state before making changes, then do the smallest correct action.
- Do not stop for approval on read-only operations. Only confirm before destructive or high-impact actions such as starting downloads, deleting subscriptions, or removing history.
- When a request can be completed by tools, prefer doing the work over explaining what you might do.
- After an action, perform the minimum validation needed to confirm the result actually landed.
- If the user explicitly asks to change the speaking style or persona, use the dedicated persona tools instead of editing runtime files manually.
- If the user explicitly asks to rewrite or create a persona definition, prefer `update_persona_definition` rather than generic file-editing tools.
- Do not let user memory or persona style override this core identity, safety boundaries, or built-in background task rules.
- You are not a general-purpose coding assistant in normal media conversations. Only cross into implementation details when the user explicitly asks about MoviePilot internals or debugging.
Core Capabilities:
1. Media Search and Recognition - Identify movies, TV shows, and anime; recognize media from fuzzy filenames or incomplete titles.
2. Subscription Management - Create rules for automated downloading and monitor trending content.
3. Download Control - Search torrents across trackers and filter by quality, codec, and release group.
4. System Status and Organization - Monitor downloads, server health, file transfers, renaming, and library cleanup.
5. Visual Input Handling - Users may attach images from supported channels; analyze them together with the text when relevant.
6. File Context Handling - User messages may arrive as structured JSON. Treat the `message` field as the user's text. Attachments appear in `files`; when `local_path` is present, use local file tools to inspect the uploaded file directly. When image input is disabled for the current model, user images may also be delivered through `files`.
7. Persona Management - If the user explicitly asks to change the speaking style or persona, prefer `query_personas` and `switch_persona`; if the user asks to rewrite or create a persona definition, prefer `update_persona_definition` instead of editing runtime files manually.
Core Workflow:
1. Media Discovery: Identify exact media metadata such as TMDB ID and Season or Episode using search tools when needed.
2. Context Checking: Verify whether the media already exists in the library, has already been subscribed, or has relevant history that affects the next step.
3. Action Execution: Perform the requested task with concise user-facing output unless the operation is destructive or blocked.
4. Final Confirmation: State the outcome briefly, including the key media facts or blocker.
Tool Calling Strategy:
- Call independent tools in parallel whenever possible.
- If search results are ambiguous, use `query_media_detail` or `recognize_media` to clarify before proceeding.
- If `search_media` fails, fall back to `search_web` or `recognize_media`. Only ask the user when automated paths are exhausted.
- Reuse known media identity, prior tool results, and current system context instead of repeating expensive recognition or search calls.
- When a tool fails, try one narrower fallback path before escalating to the user.
</operating_principles>
Media Management Rules:
1. Download Safety: Present found torrents with size, seeds, and quality, then get explicit consent before downloading.
2. Subscription Logic: Check for the best matching quality profile based on user history or defaults.
3. Library Awareness: Check if content already exists in the library to avoid duplicates.
4. Error Handling: If a tool or site fails, briefly explain what went wrong and suggest an alternative.
5. TV Subscription Rule: When calling `add_subscribe` for a TV show, omitting `season` means subscribe to season 1 only. To subscribe multiple seasons or the full series, call `add_subscribe` separately for each season.
<core_workflow>
1. Site and Context Check: Determine whether site status, site scope, library state, existing subscriptions, or prior download/transfer history can affect the task.
2. Media Identity Resolution: Confirm exact media identity such as TMDB ID, title, year, type, season, or episode using `search_media`, `query_media_detail`, or `recognize_media` as needed.
3. Resource Discovery: Use the appropriate search path for the task. For manual acquisition, search site resources and inspect result quality. For automation, prepare subscription conditions that will search sites continuously.
4. Action Execution: Perform the requested task, typically one of: test/query site, search torrents, add download, add or modify subscription, or transfer and organize files.
5. Final Confirmation: State the outcome briefly, including the key media facts, chosen site or resource scope when relevant, and the next blocker if the task could not be completed.
</core_workflow>
<tool_strategy>
- Use parallel tool calls by default for independent read-only or diagnostic work. In one assistant turn, issue all tool calls that can run without waiting for each other's results, such as checking enabled sites, library existence, recent history, downloader status, and scheduler or configuration state.
- Keep tools sequential only when later arguments depend on earlier output, when a tool mutates state, when confirmation is required, or when concurrent writes could conflict.
- When planning a multi-step investigation, group the first wave of safe state-gathering calls together, then continue with dependent actions after those results return.
- For system startup, Docker, dependency, database, frontend asset, port, safe-mode, or unclear runtime failures, use `query_doctor_report` early to collect the read-only Doctor diagnostic report before falling back to generic command execution.
- Prefer site-aware tool paths when the task is about torrents, subscriptions, or download failures. `query_sites`, `test_site`, and `query_site_userdata` are part of the main operating flow, not edge-case tools.
- If search results are ambiguous, use `query_media_detail` or `recognize_media` to clarify before proceeding.
- For fuzzy torrent names, filenames, or manually provided paths, prefer `recognize_media` before asking the user for a cleaner title.
- If `search_media` fails, fall back to `search_web` or `recognize_media`. Only ask the user when automated paths are exhausted.
- If torrent search yields no useful result, check site scope, site health, and recognition quality before concluding that the resource is unavailable.
- Reuse the latest torrent search cache for `get_search_results` and `add_download` instead of re-running the same search unnecessarily.
- Use `execute_command` only for diagnostics, read-only inspection, or commands the user explicitly asked to run. Its default `action=start` starts a managed background session and returns `session_id`, `status`, `last_seq`, and `output_until_seq`; call the same tool again with `action=read`, `action=wait`, `action=write`, or `action=kill` to poll output, wait in short segments, send stdin, or stop the process.
</tool_strategy>
<media_rules>
1. Site Awareness: When search, download, or subscription behavior depends on sites, prefer checking enabled sites, selected site IDs, priority, or site health before changing user expectations.
2. Download Safety: Present found torrents with size, seeds, and quality, then get explicit consent before downloading.
3. Search vs Recognition: `search_media` is for database lookup, `recognize_media` is for parsing titles or paths, and `search_torrents` is for site resource lookup. Do not confuse these roles.
4. Subscription Logic: Check for the best matching quality profile, filter groups, and site scope based on user history or defaults.
5. Library Awareness: Check if content already exists in the library to avoid duplicates before downloading, subscribing, or transferring.
6. Transfer Awareness: If the user asks about downloaded files landing in the library, include transfer or organization state in the reasoning, not just download completion.
7. Error Handling: If a tool or site fails, briefly explain what went wrong and suggest an alternative or the next best operational step.
8. TV Subscription Rule: When calling `add_subscribe` for a TV show, omitting `season` means subscribe to season 1 only. To subscribe multiple seasons or the full series, call `add_subscribe` separately for each season.
</media_rules>
</agent_core>
<communication_runtime>

View File

@@ -14,7 +14,11 @@ task_types:
- "For 'recurring' jobs, check 'last_run' to determine if it's time to run again."
- "For 'once' jobs with status 'pending', execute them now."
- "After executing each job, update its status, 'last_run' time, and execution log in the JOB.md file."
- "If any job was executed, use the `send_message` tool to send a concise execution report to the user through configured notification channels."
empty_result: "If no jobs were executed, output nothing."
task_rules:
- "After sending the execution report with `send_message`, do not repeat the report in your final response."
- "Your final response for heartbeat must be empty; reporting is handled only through the `send_message` tool."
health_check:
header: "[System Health Check]"
objective: "Verify that the agent execution pipeline is alive."
@@ -120,6 +124,8 @@ task_types:
- "When several records obviously share the same media identity, avoid repeated `recognize_media` or `search_media` calls."
- "Process every selected record exactly once."
- "Keep the final response short and focused on the aggregate outcome."
- "Final response must be plain text only: one concise Chinese sentence or paragraph describing the aggregate result."
- "Do NOT include any title/header, bullet list, numbered list, bold text, code block, table, or other Markdown formatting."
search_recommend:
header: "[System Task - Search Results Recommendation]"
objective: "Analyze the provided search results and select the best matching items based on user preferences."

View File

@@ -1,5 +1,6 @@
"""提示词管理器"""
import shutil
import socket
from dataclasses import dataclass, field
from pathlib import Path
@@ -9,6 +10,7 @@ from typing import Any, Dict, Optional
import yaml
from app.agent.llm.capability import AgentCapabilityManager
from app.core.config import settings
from app.log import logger
from app.schemas import (
@@ -21,6 +23,31 @@ from app.utils.system import SystemUtils
SYSTEM_TASKS_FILE = "System Tasks.yaml"
SYSTEM_TASKS_SCHEMA_VERSION = 2
COMMON_SHELL_COMMANDS = (
# 只探测会明显改变 Agent 执行策略的可选能力。基础命令、语言运行时、
# 包管理器、服务管理器和数据库客户端默认不做启动探测,减少 which 扫描量。
"ssh",
"scp",
"sftp",
"git",
"gh",
"rg",
"fd",
"jq",
"yq",
"curl",
"wget",
"docker",
"docker-compose",
"python",
"python3",
"ffmpeg",
"ffprobe",
"mediainfo",
"rclone",
"aria2c",
"yt-dlp",
)
class PromptConfigError(ValueError):
@@ -64,6 +91,7 @@ class PromptManager:
self.prompts_cache: Dict[str, str] = {}
self._system_tasks_cache: Optional[SystemTasksDefinition] = None
self._system_tasks_signature: Optional[tuple[int, int]] = None
self._available_shell_commands_cache: Optional[list[tuple[str, str]]] = None
def load_prompt(self, prompt_name: str) -> str:
"""
@@ -251,8 +279,7 @@ class PromptManager:
sections.append(self._format_numbered_rules("IMPORTANT", rules))
return "\n\n".join(section for section in sections if section).strip()
@staticmethod
def _get_moviepilot_info() -> str:
def _get_moviepilot_info(self) -> str:
"""
获取MoviePilot系统信息用于注入到系统提示词中
"""
@@ -281,10 +308,15 @@ class PromptManager:
db_info = f"SQLite ({settings.CONFIG_PATH / 'db' / 'moviepilot.db'})"
else:
db_password = settings.DB_POSTGRESQL_PASSWORD or ""
db_info = f"PostgreSQL ({settings.DB_POSTGRESQL_USERNAME}:{db_password}@{settings.DB_POSTGRESQL_HOST}:{settings.DB_POSTGRESQL_PORT}/{settings.DB_POSTGRESQL_DATABASE})"
db_info = (
f"PostgreSQL ({settings.DB_POSTGRESQL_USERNAME}:{db_password}@"
f"{settings.DB_POSTGRESQL_TARGET}/{settings.DB_POSTGRESQL_DATABASE})"
)
# 保留日期用于提供“今天是哪天”的稳定上下文,但不再注入秒级时间,
# 避免每次请求都生成不同的 system prompt影响 provider 侧 cache 命中率。
info_lines = [
f"- 当前时间: {strftime('%Y-%m-%d %H:%M:%S')}",
f"- 当前日期: {strftime('%Y-%m-%d')}",
f"- 运行环境: {SystemUtils.platform} {'docker' if SystemUtils.is_docker() else ''}",
f"- 主机名: {hostname}",
f"- IP地址: {ip_address}",
@@ -297,17 +329,54 @@ class PromptManager:
f"- 配置文件目录: {config_path}",
f"- 日志文件目录: {log_path}",
f"- 系统安装目录: {settings.ROOT_PATH}",
f"- 插件安装目录: {settings.ROOT_PATH / 'app' / 'plugins'}",
]
available_commands = self._get_available_shell_commands()
if available_commands:
info_lines.append("- 可用系统命令(可通过 `execute_command` 调用):")
info_lines.extend(
f" - {command}: {path}" for command, path in available_commands
)
# `rg` 同时覆盖文件枚举和文本检索,且比通用 shell 查找更适合
# Agent 的代码阅读与定位场景;只有在它不可用或不适合时才退回其他工具。
if any(command == "rg" for command, _ in available_commands):
info_lines.append(
"- When searching files or text, prefer `rg` / `rg --files`. Only fall back to other search tools when `rg` is unavailable or unsuitable."
)
return "\n".join(info_lines)
def _get_available_shell_commands(self) -> list[tuple[str, str]]:
"""
探测 PATH 中已经安装的常用命令。
这里只使用 shutil.which 做无副作用查找,不实际执行命令;执行权限、
高风险操作确认和输出限制仍由 execute_command 工具负责。探测结果
在进程内缓存,避免每次组装提示词都重复扫描 PATH。
"""
if self._available_shell_commands_cache is not None:
return self._available_shell_commands_cache
available_commands: list[tuple[str, str]] = []
for command in COMMON_SHELL_COMMANDS:
command_path = shutil.which(command)
if command_path:
available_commands.append((command, command_path))
self._available_shell_commands_cache = available_commands
return available_commands
def clear_available_shell_commands_cache(self) -> None:
"""清理可用系统命令缓存,供测试或运行时手动刷新使用。"""
self._available_shell_commands_cache = None
@staticmethod
def _generate_formatting_instructions(caps: ChannelCapabilities) -> str:
"""
根据渠道能力动态生成格式指令
"""
instructions = []
if ChannelCapability.RICH_TEXT not in caps.capabilities:
if ChannelCapability.MARKDOWN not in caps.capabilities:
instructions.append(
"- Formatting: Use **Plain Text ONLY**. The channel does NOT support Markdown."
)
@@ -322,10 +391,17 @@ class PromptManager:
@staticmethod
def _generate_voice_reply_instructions() -> str:
if not AgentCapabilityManager.supports_audio_output():
return "Audio output is disabled; do not call `send_voice_message`."
return (
"- Voice replies: Use normal text replies by default. "
"Only call `send_voice_message` when the user explicitly asks for a voice reply "
"or spoken playback is clearly better than plain text."
"Use normal text replies by default. Only call `send_voice_message` "
"when the user explicitly asks for a voice reply or spoken playback "
"is clearly better than plain text. `send_voice_message` is a terminal "
"response tool: put the complete user-facing reply in its `message` "
"argument, then stop the turn. Do not also call `send_message`, do not "
"write a final text reply after it, and do not repeat the same content "
"as plain text. If native voice is unavailable, the tool sends the same "
"content as a text fallback and still completes the reply."
)
@staticmethod
@@ -339,9 +415,11 @@ class PromptManager:
):
return (
"- User questions: If you need the user to choose from a few clear options, "
"call `ask_user_choice` to send button options. After the user clicks a button, "
"the selected value will come back as the user's next message. After calling this tool, "
"wait for the user's selection instead of repeating the question in plain text."
"call `ask_user_choice` to send button options. `ask_user_choice` is a terminal "
"interaction tool: put the full question and all options in the tool call, then "
"stop the turn and wait for the user's selection. The selected value will come back "
"as the user's next message. Do not also call `send_message`, do not write a final "
"text reply after it, and do not repeat the question in plain text."
)
return "- User questions: When you truly need user input, ask briefly in plain text."
@@ -423,11 +501,11 @@ class PromptManager:
return text
context = cls._normalize_template_context(template_context)
missing_fields = sorted(field for field in required_fields if field not in context)
missing_fields = sorted(f for f in required_fields if f not in context)
if missing_fields:
raise PromptConfigError(
f"系统任务定义 `{task_type}` 的 `{field_name}` 缺少变量: "
+ ", ".join(f"`{field}`" for field in missing_fields)
+ ", ".join(f"`{f}`" for f in missing_fields)
)
# 这里统一做字符串替换,让 YAML 成为后台任务文案的唯一行为来源。

View File

@@ -4,27 +4,79 @@ import threading
from abc import ABCMeta, abstractmethod
from concurrent.futures import ThreadPoolExecutor
from functools import partial
from typing import Any, Callable, Optional
from typing import Any, Callable, ClassVar, Optional
from langchain_core.tools import BaseTool
from pydantic import PrivateAttr
from app.agent import StreamingHandler
from app.agent.tools.tags import ToolTag
from app.chain import ChainBase
from app.core.config import settings
from app.db.user_oper import UserOper
from app.helper.service import ServiceConfigHelper
from app.log import logger
from app.schemas import Notification
from app.schemas.types import MessageChannel
from app.schemas.types import MessageChannel, NotificationType
class ToolChain(ChainBase):
pass
# 单个工具结果的兜底上限。各工具仍应优先在自身逻辑中分页或摘要化;
# 这里用于拦截遗漏路径,避免超大结果直接进入模型上下文。
DEFAULT_TOOL_RESULT_MAX_CHARS = 64 * 1024
MIN_TOOL_RESULT_PREVIEW_CHARS = 512
def serialize_tool_result_for_agent(result: Any) -> str:
"""将工具返回值稳定转换为 Agent 可消费的字符串。"""
if isinstance(result, str):
return result
if isinstance(result, (int, float)):
return str(result)
try:
return json.dumps(result, ensure_ascii=False, indent=2, default=str)
except Exception as e:
logger.warning(f"工具结果转换为JSON失败: {e}, 使用字符串表示")
return str(result)
def format_tool_result_for_agent(
result: Any,
*,
tool_name: Optional[str] = None,
max_chars: Optional[int] = DEFAULT_TOOL_RESULT_MAX_CHARS,
) -> str:
"""
统一格式化工具结果,并在超长时返回结构化预览。
具体工具可以通过 `result_max_chars` 覆盖上限;传入 None 或 <=0 表示不截断。
"""
formatted_result = serialize_tool_result_for_agent(result)
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)
# 将常见的阻塞调用按能力域拆分到独立线程池,避免外部慢 IO 抢占同一批 worker。
_BLOCKING_BUCKET_LIMITS = {
"command": 4,
"default": 4,
"config": 2,
"db": 4,
@@ -35,6 +87,7 @@ _BLOCKING_BUCKET_LIMITS = {
"site": 4,
"storage": 4,
"subscribe": 2,
"web": 2,
"workflow": 2,
}
_blocking_semaphores = {
@@ -61,11 +114,61 @@ def _get_blocking_executor(bucket: str) -> ThreadPoolExecutor:
return executor
class ToolExecutionTimeoutError(TimeoutError):
"""Agent 工具执行超时异常。"""
def _get_tool_timeout_seconds() -> Optional[float]:
"""读取工具执行超时时间,配置为 0 或负数时表示不限制。"""
try:
timeout = float(settings.LLM_TOOL_TIMEOUT or 0)
except (TypeError, ValueError):
timeout = 0
return timeout if timeout > 0 else None
async def run_agent_blocking(
bucket: str, func: Callable[..., Any], *args: Any, **kwargs: Any
) -> Any:
"""
在受控线程池中运行阻塞型同步代码。
调用方被取消时不会提前释放并发名额,避免底层阻塞调用仍在运行时继续接纳
新任务,把同一类慢 IO 的线程池持续打满。
"""
bucket_name = bucket if bucket in _BLOCKING_BUCKET_LIMITS else "default"
semaphore = _blocking_semaphores[bucket_name]
bound_call = partial(func, *args, **kwargs)
loop = asyncio.get_running_loop()
await semaphore.acquire()
try:
future = _get_blocking_executor(bucket_name).submit(bound_call)
except Exception:
semaphore.release()
raise
def _release_semaphore(_future) -> None:
try:
_future.exception()
except Exception:
pass
try:
loop.call_soon_threadsafe(semaphore.release)
except RuntimeError:
pass
future.add_done_callback(_release_semaphore)
return await asyncio.shield(asyncio.wrap_future(future, loop=loop))
class MoviePilotTool(BaseTool, metaclass=ABCMeta):
"""
MoviePilot专用工具基类LangChain v1 / langchain_core
"""
result_max_chars: ClassVar[Optional[int]] = DEFAULT_TOOL_RESULT_MAX_CHARS
_session_id: str = PrivateAttr()
_user_id: str = PrivateAttr()
_channel: Optional[str] = PrivateAttr(default=None)
@@ -79,7 +182,31 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
super().__init__(**kwargs)
self._session_id = session_id
self._user_id = user_id
self._require_admin = getattr(self.__class__, "require_admin", False)
# require_admin 在各工具子类以 pydantic 字段声明pydantic v2 不在类对象上暴露字段值
# getattr(cls, ...) 取不到必须经实例读取——super().__init__() 已按字段默认填充实例;
# getattr 兜底兼容未声明该字段的工具缺省按非管理员False处理。
self._require_admin = getattr(self, "require_admin", False)
self.tags = self._build_tool_tags()
@staticmethod
def _normalize_tag_values(tags: Optional[Any]) -> set[str]:
"""规范化 LangChain 工具标签。"""
if not tags:
return set()
if isinstance(tags, (str, ToolTag)):
tags = [tags]
normalized_tags = set()
for tag in tags:
if isinstance(tag, ToolTag):
normalized_tags.add(tag.value)
elif tag:
normalized_tags.add(str(tag))
return normalized_tags
def _build_tool_tags(self) -> list[str]:
"""规范化工具实现中显式声明的标签。"""
explicit_tags = self._normalize_tag_values(getattr(self, "tags", None))
return sorted(explicit_tags | {ToolTag.AgentTool.value})
def _run(self, *args: Any, **kwargs: Any) -> Any:
raise NotImplementedError("MoviePilotTool 只支持异步调用,请使用 _arun")
@@ -105,8 +232,8 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
if explanation:
tool_message = explanation
# 发送工具执行过程消息
if self._stream_handler and self._stream_handler.is_streaming:
# 发送工具执行过程消息(流式传输且非最后终结工具时)
if self._stream_handler and self._stream_handler.is_streaming and not self.return_direct:
if settings.AI_AGENT_VERBOSE:
if self._stream_handler.is_auto_flushing:
# 渠道支持编辑:工具消息追加到 buffer由定时刷新推送
@@ -159,22 +286,28 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
# 执行具体工具逻辑
try:
result = await self.run(**kwargs)
logger.debug(f"Tool {self.name} executed with result: {result}")
result = await self.run_with_timeout(**kwargs)
# 记录工具执行结果摘要日志
str_result = serialize_tool_result_for_agent(result)
if len(str_result) > 500:
summary = str_result[:500] + f"...(已截断,总长度: {len(str_result)})"
else:
summary = str_result
logger.info(f"Agent工具 {self.name} 执行完成,结果摘要: {summary}")
except ToolExecutionTimeoutError as e:
error_message = str(e)
logger.warning(error_message)
result = error_message
except Exception as e:
error_message = f"工具执行异常 ({type(e).__name__}): {str(e)}"
logger.error(f"Tool {self.name} execution failed: {e}", exc_info=True)
result = error_message
# 格式化结果
if isinstance(result, str):
formatted_result = result
elif isinstance(result, (int, float)):
formatted_result = str(result)
else:
formatted_result = json.dumps(result, ensure_ascii=False, indent=2)
return formatted_result
return format_tool_result_for_agent(
result, tool_name=self.name, max_chars=self.result_max_chars
)
def get_tool_message(self, **kwargs) -> Optional[str]:
"""
@@ -189,13 +322,26 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
Returns:
str: 友好的提示消息,如果返回 None 或空字符串则使用 explanation
"""
return None
explanation = kwargs.get("explanation")
return str(explanation) if explanation else None
@abstractmethod
async def run(self, **kwargs) -> str:
"""子类实现具体的工具执行逻辑"""
raise NotImplementedError
async def run_with_timeout(self, **kwargs) -> str:
"""按系统配置限制单个工具调用的最长执行时间。"""
timeout = _get_tool_timeout_seconds()
if not timeout:
return await self.run(**kwargs)
try:
return await asyncio.wait_for(self.run(**kwargs), timeout=timeout)
except asyncio.TimeoutError as err:
raise ToolExecutionTimeoutError(
f"工具 {self.name} 执行超时(超过 {timeout:g} 秒),已停止等待结果。"
) from err
@staticmethod
async def run_blocking(
bucket: str, func: Callable[..., Any], *args: Any, **kwargs: Any
@@ -203,15 +349,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
"""
在受控线程池中运行阻塞型同步代码,避免拖住 FastAPI 主事件循环。
"""
bucket_name = bucket if bucket in _BLOCKING_BUCKET_LIMITS else "default"
semaphore = _blocking_semaphores[bucket_name]
bound_call = partial(func, *args, **kwargs)
async with semaphore:
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
_get_blocking_executor(bucket_name), bound_call
)
return await run_agent_blocking(bucket, func, *args, **kwargs)
def set_message_attr(self, channel: str, source: str, username: str):
"""
@@ -231,7 +369,9 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
"""
设置与当前 Agent 共享的上下文。
"""
self._agent_context = agent_context or {}
# 空 dict 也是合法共享上下文;不能用 ``or {}``,否则每个工具会拿到
# 独立的新 dict跨工具状态例如质量门槛拒绝标记无法传播。
self._agent_context = {} if agent_context is None else agent_context
async def _check_permission(self) -> Optional[str]:
"""
@@ -256,6 +396,8 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
MessageChannel.Telegram: "telegram",
MessageChannel.Discord: "discord",
MessageChannel.Wechat: "wechat",
MessageChannel.Feishu: "feishu",
MessageChannel.WechatClawBot: "wechatclawbot",
MessageChannel.Slack: "slack",
MessageChannel.VoceChat: "vocechat",
MessageChannel.SynologyChat: "synologychat",
@@ -275,6 +417,8 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
"telegram": "TELEGRAM_ADMINS",
"discord": "DISCORD_ADMINS",
"wechat": "WECHAT_ADMINS",
"feishu": "FEISHU_ADMINS",
"wechatclawbot": "WECHATCLAWBOT_ADMINS",
"slack": "SLACK_ADMINS",
"vocechat": "VOCECHAT_ADMINS",
"synologychat": "SYNOLOGYCHAT_ADMINS",
@@ -285,6 +429,11 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
"telegram": "TELEGRAM_CHAT_ID",
"vocechat": "VOCECHAT_CHANNEL_ID",
"wechat": "WECHAT_BOT_CHAT_ID",
"feishu": "FEISHU_OPEN_ID",
"wechatclawbot": "WECHATCLAWBOT_DEFAULT_TARGET",
"discord": "DISCORD_CHANNEL_ID",
"slack": "SLACK_CHANNEL",
"qqbot": "QQ_OPENID",
}
admin_key = admin_key_map.get(channel_type)
@@ -344,7 +493,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
async def send_tool_message(
self, message: str, title: str = "", image: Optional[str] = None
):
) -> None:
"""
发送工具消息
"""
@@ -352,6 +501,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
Notification(
channel=self._channel,
source=self._source,
mtype=NotificationType.Agent,
userid=self._user_id,
username=self._username,
title=title,

View File

@@ -74,7 +74,11 @@ from app.agent.tools.impl.uninstall_plugin import UninstallPluginTool
from app.agent.tools.impl.run_slash_command import RunSlashCommandTool
from app.agent.tools.impl.list_slash_commands import ListSlashCommandsTool
from app.agent.tools.impl.query_custom_identifiers import QueryCustomIdentifiersTool
from app.agent.tools.impl.query_doctor_report import QueryDoctorReportTool
from app.agent.tools.impl.update_custom_identifiers import UpdateCustomIdentifiersTool
from app.agent.tools.impl.query_system_settings import QuerySystemSettingsTool
from app.agent.tools.impl.update_system_settings import UpdateSystemSettingsTool
from app.agent.llm.capability import AgentCapabilityManager
from app.core.plugin import PluginManager
from app.log import logger
from app.schemas.message import ChannelCapabilityManager
@@ -88,7 +92,7 @@ class MoviePilotToolFactory:
"""
# 这些通用工具需要始终保留,避免大工具集裁剪后让 Agent 丢失基础的
# 文件系统、命令执行或交互确认能力。AskUserChoiceTool 仅在支持按钮
# 文件系统、命令执行、主动消息发送或交互确认能力。AskUserChoiceTool 仅在支持按钮
# 的渠道中才会实际注入,因此后续会再按已加载工具做一次求交集。
TOOL_SELECTOR_ALWAYS_INCLUDE_NAMES = (
"list_directory",
@@ -96,6 +100,8 @@ class MoviePilotToolFactory:
"read_file",
"edit_file",
"execute_command",
"query_doctor_report",
"send_message",
"ask_user_choice",
)
@@ -216,17 +222,17 @@ class MoviePilotToolFactory:
UninstallPluginTool,
RunSlashCommandTool,
ListSlashCommandsTool,
QueryDoctorReportTool,
QueryCustomIdentifiersTool,
UpdateCustomIdentifiersTool,
QuerySystemSettingsTool,
UpdateSystemSettingsTool,
]
if MoviePilotToolFactory._should_enable_choice_tool(channel):
tool_definitions.append(AskUserChoiceTool)
tool_definitions.extend(
[
SendLocalFileTool,
SendVoiceMessageTool,
]
)
tool_definitions.append(SendLocalFileTool)
if AgentCapabilityManager.supports_audio_output():
tool_definitions.append(SendVoiceMessageTool)
# 创建内置工具
for ToolClass in tool_definitions:
tool = ToolClass(session_id=session_id, user_id=user_id)

View File

@@ -7,6 +7,7 @@ from typing import Any, Optional
from app.core.config import settings
from app.core.plugin import PluginManager
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.server import MoviePilotServerHelper
from app.helper.plugin import PluginHelper
from app.schemas.types import SystemConfigKey
@@ -15,7 +16,8 @@ DEFAULT_PLUGIN_DATA_PREVIEW_CHARS = 12_000
MAX_PLUGIN_DATA_PREVIEW_CHARS = 50_000
PLUGIN_DATA_KEY_PREVIEW_LIMIT = 50
PLUGIN_DATA_TRUNCATION_SUFFIX = "\n...(插件数据内容过长,已截断)"
DEFAULT_PLUGIN_CANDIDATE_LIMIT = 500
DEFAULT_PLUGIN_CANDIDATE_LIMIT = 50
MAX_PLUGIN_CANDIDATE_LIMIT = 200
def get_plugin_snapshot(plugin_id: str) -> Optional[dict[str, Any]]:
@@ -92,6 +94,9 @@ def summarize_plugin(plugin: Any) -> dict[str, Any]:
"plugin_author": getattr(plugin, "plugin_author", None),
"installed": bool(getattr(plugin, "installed", False)),
"has_update": bool(getattr(plugin, "has_update", False)),
"system_version_compatible": getattr(plugin, "system_version_compatible", True) is not False,
"system_version": getattr(plugin, "system_version", None),
"system_version_message": getattr(plugin, "system_version_message", None),
"state": bool(getattr(plugin, "state", False)),
"repo_url": repo_url,
"source": "local_repo" if PluginHelper.is_local_repo_url(repo_url) else "market",
@@ -225,7 +230,7 @@ async def install_plugin_runtime(
refreshed_only = False
if not force and plugin_id in plugin_manager.get_plugin_ids():
refreshed_only = True
await plugin_helper.async_install_reg(pid=plugin_id, repo_url=repo_url)
await MoviePilotServerHelper.async_install_plugin_reg(plugin_id=plugin_id, repo_url=repo_url)
message = "插件已存在,已刷新加载"
else:
if not repo_url:
@@ -237,6 +242,7 @@ async def install_plugin_runtime(
)
if not state:
return False, message, False
await MoviePilotServerHelper.async_install_plugin_reg(plugin_id=plugin_id, repo_url=repo_url)
if plugin_id not in install_plugins:
install_plugins.append(plugin_id)
@@ -244,7 +250,9 @@ async def install_plugin_runtime(
SystemConfigKey.UserInstalledPlugins, install_plugins
)
reload_plugin_runtime(plugin_id)
from app.agent.tools.base import run_agent_blocking
await run_agent_blocking("plugin", reload_plugin_runtime, plugin_id)
return True, message or "插件安装成功", refreshed_only

View File

@@ -0,0 +1,335 @@
"""系统设置工具共用的键解析与分组元数据。"""
from dataclasses import dataclass
from typing import Optional
from app.core.config import Settings
from app.schemas.types import SystemConfigKey
@dataclass(frozen=True)
class SettingSpec:
"""描述一个可被 Agent 读写的系统设置项。"""
key: str
source: str
group: str
label: str
SYSTEMCONFIG_SETTING_METADATA = {
SystemConfigKey.Downloaders.value: {
"group": "downloaders",
"label": "下载器配置",
},
SystemConfigKey.MediaServers.value: {
"group": "media_servers",
"label": "媒体服务器配置",
},
SystemConfigKey.Notifications.value: {
"group": "notifications",
"label": "消息通知配置",
},
SystemConfigKey.NotificationSwitchs.value: {
"group": "notification_switches",
"label": "通知场景开关",
},
SystemConfigKey.Directories.value: {
"group": "directories",
"label": "目录配置",
},
SystemConfigKey.Storages.value: {
"group": "storages",
"label": "存储配置",
},
SystemConfigKey.IndexerSites.value: {
"group": "search_sites",
"label": "搜索站点范围",
},
SystemConfigKey.RssSites.value: {
"group": "subscribe_sites",
"label": "订阅站点范围",
},
SystemConfigKey.UserSiteAuthParams.value: {
"group": "site_auth",
"label": "站点认证参数",
},
SystemConfigKey.AIAgentConfig.value: {
"group": "ai_agent",
"label": "AI 智能体配置",
},
SystemConfigKey.CustomIdentifiers.value: {
"group": "custom_identifiers",
"label": "自定义识别词",
},
SystemConfigKey.EpisodeFormatRuleTable.value: {
"group": "transfer",
"label": "集数定位规则词表",
},
SystemConfigKey.CustomReleaseGroups.value: {
"group": "customization",
"label": "自定义制作组/字幕组",
},
SystemConfigKey.Customization.value: {
"group": "customization",
"label": "自定义占位符",
},
SystemConfigKey.TransferExcludeWords.value: {
"group": "transfer",
"label": "整理屏蔽词",
},
SystemConfigKey.TorrentsPriority.value: {
"group": "filter_rules",
"label": "种子优先级规则",
},
SystemConfigKey.CustomFilterRules.value: {
"group": "filter_rules",
"label": "用户自定义规则",
},
SystemConfigKey.UserFilterRuleGroups.value: {
"group": "filter_rules",
"label": "用户规则组",
},
SystemConfigKey.SearchFilterRuleGroups.value: {
"group": "filter_rules",
"label": "搜索默认过滤规则组",
},
SystemConfigKey.SubscribeFilterRuleGroups.value: {
"group": "filter_rules",
"label": "订阅默认过滤规则组",
},
SystemConfigKey.BestVersionFilterRuleGroups.value: {
"group": "filter_rules",
"label": "洗版默认过滤规则组",
},
SystemConfigKey.SubscribeDefaultParams.value: {
"group": "subscribe_defaults",
"label": "订阅默认参数",
},
SystemConfigKey.DefaultMovieSubscribeConfig.value: {
"group": "subscribe_defaults",
"label": "默认电影订阅规则",
},
SystemConfigKey.DefaultTvSubscribeConfig.value: {
"group": "subscribe_defaults",
"label": "默认电视剧订阅规则",
},
SystemConfigKey.UserInstalledPlugins.value: {
"group": "plugins",
"label": "已安装插件列表",
},
SystemConfigKey.PluginFolders.value: {
"group": "plugins",
"label": "插件文件夹分组配置",
},
SystemConfigKey.PluginInstallReport.value: {
"group": "plugins",
"label": "插件安装统计",
},
SystemConfigKey.NotificationSendTime.value: {
"group": "notifications",
"label": "通知发送时间",
},
SystemConfigKey.NotificationTemplates.value: {
"group": "notifications",
"label": "通知模板",
},
SystemConfigKey.ScrapingSwitchs.value: {
"group": "scraping",
"label": "刮削开关设置",
},
SystemConfigKey.FollowSubscribers.value: {
"group": "subscribe_sites",
"label": "Follow 订阅分享者",
},
}
LIST_ITEM_MATCH_FIELD_DEFAULTS = {
SystemConfigKey.Downloaders.value: "name",
SystemConfigKey.MediaServers.value: "name",
SystemConfigKey.Notifications.value: "name",
SystemConfigKey.NotificationSwitchs.value: "type",
SystemConfigKey.Directories.value: "name",
SystemConfigKey.Storages.value: "name",
}
GROUP_ALIASES = {
"all": "all",
"全部": "all",
"settings": "settings",
"basic": "settings",
"基础设置": "settings",
"基础配置": "settings",
"systemconfig": "systemconfig",
"system_config": "systemconfig",
"系统设置": "systemconfig",
"系统配置": "systemconfig",
"downloaders": "downloaders",
"downloader": "downloaders",
"下载器": "downloaders",
"media_servers": "media_servers",
"mediaservers": "media_servers",
"media-servers": "media_servers",
"媒体服务器": "media_servers",
"notifications": "notifications",
"notification": "notifications",
"消息通知": "notifications",
"通知": "notifications",
"notification_switches": "notification_switches",
"notification_switchs": "notification_switches",
"通知开关": "notification_switches",
"storages": "storages",
"storage": "storages",
"存储": "storages",
"directories": "directories",
"directory": "directories",
"目录": "directories",
"search_sites": "search_sites",
"indexer_sites": "search_sites",
"搜索站点": "search_sites",
"subscribe_sites": "subscribe_sites",
"rss_sites": "subscribe_sites",
"订阅站点": "subscribe_sites",
"site_auth": "site_auth",
"site_auth_params": "site_auth",
"站点认证": "site_auth",
"ai_agent": "ai_agent",
"agent": "ai_agent",
"智能体": "ai_agent",
"custom_identifiers": "custom_identifiers",
"自定义识别词": "custom_identifiers",
"filter_rules": "filter_rules",
"过滤规则": "filter_rules",
"subscribe_defaults": "subscribe_defaults",
"订阅默认": "subscribe_defaults",
"plugins": "plugins",
"插件": "plugins",
"customization": "customization",
"自定义": "customization",
"transfer": "transfer",
"整理": "transfer",
"scraping": "scraping",
"刮削": "scraping",
"misc": "misc",
"其他": "misc",
}
def _normalize_token(value: str) -> str:
return str(value).strip().lower().replace("-", "_")
def _build_specs() -> tuple[dict[str, SettingSpec], dict[str, SettingSpec]]:
core_specs = {
key: SettingSpec(key=key, source="settings", group="settings", label=key)
for key in Settings.model_fields.keys()
}
system_specs = {}
for item in SystemConfigKey:
metadata = SYSTEMCONFIG_SETTING_METADATA.get(item.value, {})
system_specs[item.value] = SettingSpec(
key=item.value,
source="systemconfig",
group=metadata.get("group", "misc"),
label=metadata.get("label", item.value),
)
return core_specs, system_specs
CORE_SETTING_SPECS, SYSTEMCONFIG_SETTING_SPECS = _build_specs()
ALL_SETTING_SPECS = {**CORE_SETTING_SPECS, **SYSTEMCONFIG_SETTING_SPECS}
SETTING_KEY_ALIASES = {}
for key in CORE_SETTING_SPECS:
SETTING_KEY_ALIASES[_normalize_token(key)] = key
for item in SystemConfigKey:
SETTING_KEY_ALIASES[_normalize_token(item.value)] = item.value
SETTING_KEY_ALIASES[_normalize_token(item.name)] = item.value
SINGLE_KEY_GROUP_ALIASES = {
_normalize_token(alias): next(
(
spec.key
for spec in SYSTEMCONFIG_SETTING_SPECS.values()
if spec.group == canonical_group
),
None,
)
for alias, canonical_group in GROUP_ALIASES.items()
if canonical_group not in {"all", "settings", "systemconfig"}
and len(
[
spec.key
for spec in SYSTEMCONFIG_SETTING_SPECS.values()
if spec.group == canonical_group
]
)
== 1
}
def normalize_group(group: Optional[str]) -> str:
if not group:
return "all"
normalized = GROUP_ALIASES.get(_normalize_token(group))
if not normalized:
raise ValueError(
"group 不支持,支持值包括 all/settings/systemconfig 以及"
" downloaders、media_servers、notifications、storages、directories、"
"search_sites、subscribe_sites、site_auth、ai_agent 等分类别名"
)
return normalized
def resolve_setting_spec(setting_key: Optional[str]) -> Optional[SettingSpec]:
"""把精确键名、枚举名或单键分组别名解析为统一的设置定义。"""
if not setting_key:
return None
normalized = _normalize_token(setting_key)
resolved_key = SETTING_KEY_ALIASES.get(normalized) or SINGLE_KEY_GROUP_ALIASES.get(
normalized
)
if not resolved_key:
return None
return ALL_SETTING_SPECS.get(resolved_key)
def list_setting_specs(
group: Optional[str] = "all", keyword: Optional[str] = None
) -> list[SettingSpec]:
"""按分组和关键字筛选可查询的设置项。"""
normalized_group = normalize_group(group)
if normalized_group == "all":
specs = list(ALL_SETTING_SPECS.values())
elif normalized_group == "settings":
specs = list(CORE_SETTING_SPECS.values())
elif normalized_group == "systemconfig":
specs = list(SYSTEMCONFIG_SETTING_SPECS.values())
else:
specs = [
spec
for spec in SYSTEMCONFIG_SETTING_SPECS.values()
if spec.group == normalized_group
]
if keyword:
normalized_keyword = _normalize_token(keyword)
specs = [
spec
for spec in specs
if normalized_keyword in _normalize_token(spec.key)
or normalized_keyword in _normalize_token(spec.group)
or normalized_keyword in _normalize_token(spec.label)
]
return sorted(specs, key=lambda spec: (spec.source, spec.group, spec.key))
def get_default_list_match_field(setting_key: str) -> Optional[str]:
return LIST_ITEM_MATCH_FIELD_DEFAULTS.get(setting_key)

View File

@@ -0,0 +1,630 @@
"""Agent 终端会话管理器。"""
from __future__ import annotations
import asyncio
import errno
import os
import signal
import subprocess
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional
from app.core.config import settings
from app.log import logger
if os.name == "posix":
import fcntl as _fcntl
import pty as _pty
else:
_fcntl = None
_pty = None
TERMINAL_CONCURRENCY_LIMIT = 4
TERMINAL_RETENTION_SECONDS = 30 * 60
TERMINAL_MAX_RETAINED_BYTES = 1024 * 1024
TERMINAL_DEFAULT_READ_BYTES = 10 * 1024
TERMINAL_MAX_READ_BYTES = 64 * 1024
TERMINAL_READ_CHUNK_SIZE = 4096
TERMINAL_PTY_POLL_INTERVAL = 0.05
TERMINAL_WAIT_DEFAULT_MS = 1000
TERMINAL_WAIT_MAX_MS = 60 * 1000
TERMINAL_KILL_GRACE_SECONDS = 3
TERMINAL_FORBIDDEN_KEYWORDS = (
"rm -rf /",
":(){ :|:& };:",
"dd if=/dev/zero",
"mkfs",
"reboot",
"shutdown",
)
@dataclass
class _TerminalChunk:
"""记录终端输出分片,供增量读取时按 seq 过滤。"""
seq: int
stream: str
text: str
byte_size: int
created_at: float
@dataclass
class _TerminalSession:
"""保存一个后台命令会话的进程、输出和状态。"""
session_id: str
command: str
cwd: str
pid: int
use_pty: bool
created_at: float = field(default_factory=time.time)
updated_at: float = field(default_factory=time.time)
status: str = "running"
exit_code: Optional[int] = None
process: Optional[asyncio.subprocess.Process] = None
master_fd: Optional[int] = None
chunks: list[_TerminalChunk] = field(default_factory=list)
next_seq: int = 1
retained_from_seq: int = 1
retained_bytes: int = 0
kill_requested: bool = False
error: Optional[str] = None
reader_tasks: list[asyncio.Task] = field(default_factory=list)
wait_task: Optional[asyncio.Task] = None
def append_output(self, stream: str, data: bytes) -> None:
"""追加输出并按容量上限丢弃最旧分片,避免长任务撑爆内存。"""
if not data:
return
text = data.decode("utf-8", errors="replace")
chunk = _TerminalChunk(
seq=self.next_seq,
stream=stream,
text=text,
byte_size=len(data),
created_at=time.time(),
)
self.next_seq += 1
self.chunks.append(chunk)
self.retained_bytes += chunk.byte_size
self.updated_at = chunk.created_at
self._trim_output()
def _trim_output(self) -> None:
"""移除超出保留上限的旧输出分片。"""
while self.retained_bytes > TERMINAL_MAX_RETAINED_BYTES and self.chunks:
removed = self.chunks.pop(0)
self.retained_bytes -= removed.byte_size
self.retained_from_seq = removed.seq + 1
def mark_finished(self, exit_code: Optional[int]) -> None:
"""标记进程已经结束,并记录退出码。"""
self.exit_code = exit_code
self.status = "killed" if self.kill_requested else "exited"
self.updated_at = time.time()
def mark_error(self, message: str) -> None:
"""标记会话异常,保留错误信息供后续读取。"""
self.error = message
self.status = "error"
self.updated_at = time.time()
def close_pty(self) -> None:
"""关闭父进程持有的 PTY master fd。"""
if self.master_fd is None:
return
try:
os.close(self.master_fd)
except OSError:
pass
self.master_fd = None
class _TerminalSessionManager:
"""管理 Agent 后台终端会话的生命周期。"""
def __init__(self) -> None:
"""初始化会话表和并发保护锁。"""
self._sessions: dict[str, _TerminalSession] = {}
self._lock = asyncio.Lock()
@staticmethod
def _normalize_bool(value: Any, default: bool = True) -> bool:
"""兼容 LLM 或 HTTP 传入的 bool/string/int 布尔值。"""
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() not in {"false", "0", "no", "off"}
return bool(value)
@staticmethod
def _normalize_cwd(cwd: Optional[str]) -> str:
"""解析工作目录,未传入时默认使用 MoviePilot 项目根目录。"""
if not cwd:
return str(settings.ROOT_PATH)
path = Path(cwd).expanduser()
if not path.is_absolute():
path = (settings.ROOT_PATH / path).resolve()
else:
path = path.resolve()
if not path.exists():
raise FileNotFoundError(f"工作目录不存在: {path}")
if not path.is_dir():
raise NotADirectoryError(f"工作目录不是目录: {path}")
return str(path)
@staticmethod
def _build_env(env: Optional[dict[str, Any]]) -> dict[str, str]:
"""合并环境变量,并把值稳定转换为字符串。"""
merged_env = os.environ.copy()
if not env:
return merged_env
for key, value in env.items():
if value is None:
continue
merged_env[str(key)] = str(value)
return merged_env
@staticmethod
def _validate_command(command: str) -> None:
"""拒绝明显危险或空白命令。"""
if not command or not command.strip():
raise ValueError("命令不能为空")
for keyword in TERMINAL_FORBIDDEN_KEYWORDS:
if keyword in command:
raise ValueError(f"命令包含禁止使用的关键字 '{keyword}'")
@staticmethod
def _set_nonblocking(fd: int) -> None:
"""将 PTY master fd 设置为非阻塞,避免后台读取任务卡住事件循环。"""
if _fcntl is None:
raise RuntimeError("当前平台不支持 PTY 非阻塞设置")
flags = _fcntl.fcntl(fd, _fcntl.F_GETFL)
_fcntl.fcntl(fd, _fcntl.F_SETFL, flags | os.O_NONBLOCK)
@staticmethod
def _pipe_subprocess_kwargs() -> dict[str, Any]:
"""生成普通管道模式的子进程参数。"""
kwargs: dict[str, Any] = {
"stdin": asyncio.subprocess.PIPE,
"stdout": asyncio.subprocess.PIPE,
"stderr": asyncio.subprocess.PIPE,
}
if os.name == "posix":
kwargs["start_new_session"] = True
elif os.name == "nt":
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
return kwargs
async def start(
self,
*,
command: str,
cwd: Optional[str] = None,
env: Optional[dict[str, Any]] = None,
use_pty: Any = True,
) -> dict[str, Any]:
"""启动后台命令并立即返回会话 ID。"""
self._validate_command(command)
normalized_cwd = self._normalize_cwd(cwd)
normalized_env = self._build_env(env)
should_use_pty = self._normalize_bool(use_pty, default=True) and os.name == "posix"
async with self._lock:
self._cleanup_finished_sessions_locked()
if self._active_session_count_locked() >= TERMINAL_CONCURRENCY_LIMIT:
raise RuntimeError(
f"后台终端会话数已达到上限 {TERMINAL_CONCURRENCY_LIMIT}"
)
session = (
await self._start_pty_session(command, normalized_cwd, normalized_env)
if should_use_pty
else await self._start_pipe_session(command, normalized_cwd, normalized_env)
)
async with self._lock:
self._sessions[session.session_id] = session
logger.info(
"启动后台终端会话: session_id=%s, pid=%s, use_pty=%s, command=%s",
session.session_id,
session.pid,
session.use_pty,
command,
)
await asyncio.sleep(0)
return self._session_payload(session, output="", output_truncated=False)
async def _start_pty_session(
self, command: str, cwd: str, env: dict[str, str]
) -> _TerminalSession:
"""通过 PTY fork 启动交互式命令会话。"""
if _pty is None:
raise RuntimeError("当前平台不支持 PTY 会话")
pid, master_fd = _pty.fork()
if pid == 0:
os.chdir(cwd)
os.environ.clear()
os.environ.update(env)
shell = os.environ.get("SHELL") or "/bin/sh"
os.execl(shell, shell, "-lc", command)
self._set_nonblocking(master_fd)
session = _TerminalSession(
session_id=f"term_{uuid.uuid4().hex[:12]}",
command=command,
cwd=cwd,
pid=pid,
use_pty=True,
master_fd=master_fd,
)
session.reader_tasks.append(asyncio.create_task(self._read_pty(session)))
session.wait_task = asyncio.create_task(self._wait_pty_process(session))
return session
async def _start_pipe_session(
self, command: str, cwd: str, env: dict[str, str]
) -> _TerminalSession:
"""通过普通 stdin/stdout/stderr 管道启动命令会话。"""
process = await asyncio.create_subprocess_shell(
command,
cwd=cwd,
env=env,
**self._pipe_subprocess_kwargs(),
)
session = _TerminalSession(
session_id=f"term_{uuid.uuid4().hex[:12]}",
command=command,
cwd=cwd,
pid=process.pid or 0,
use_pty=False,
process=process,
)
if process.stdout:
session.reader_tasks.append(
asyncio.create_task(self._read_pipe(session, process.stdout, "stdout"))
)
if process.stderr:
session.reader_tasks.append(
asyncio.create_task(self._read_pipe(session, process.stderr, "stderr"))
)
session.wait_task = asyncio.create_task(self._wait_pipe_process(session))
return session
@staticmethod
async def _read_pty(session: _TerminalSession) -> None:
"""持续从 PTY 读取增量输出。"""
while session.master_fd is not None:
try:
data = os.read(session.master_fd, TERMINAL_READ_CHUNK_SIZE)
except BlockingIOError:
await asyncio.sleep(TERMINAL_PTY_POLL_INTERVAL)
continue
except OSError as err:
if err.errno not in {errno.EIO, errno.EBADF}:
logger.debug("PTY 输出读取异常: session_id=%s, error=%s", session.session_id, err)
break
if not data:
break
session.append_output("pty", data)
@staticmethod
async def _read_pipe(
session: _TerminalSession,
stream: asyncio.StreamReader,
stream_name: str,
) -> None:
"""持续从普通管道读取增量输出。"""
while True:
data = await stream.read(TERMINAL_READ_CHUNK_SIZE)
if not data:
break
session.append_output(stream_name, data)
async def _wait_pty_process(self, session: _TerminalSession) -> None:
"""等待 PTY 子进程结束并完成输出读取任务收尾。"""
try:
_, status = await asyncio.to_thread(os.waitpid, session.pid, 0)
exit_code = os.waitstatus_to_exitcode(status)
session.mark_finished(exit_code)
except ChildProcessError:
session.mark_finished(session.exit_code)
except Exception as err:
session.mark_error(str(err))
logger.warning("等待 PTY 进程失败: session_id=%s, error=%s", session.session_id, err)
finally:
await self._finish_reader_tasks(session)
session.close_pty()
async def _wait_pipe_process(self, session: _TerminalSession) -> None:
"""等待普通管道子进程结束并完成输出读取任务收尾。"""
try:
if not session.process:
session.mark_error("进程对象不存在")
return
exit_code = await session.process.wait()
session.mark_finished(exit_code)
except Exception as err:
session.mark_error(str(err))
logger.warning("等待管道进程失败: session_id=%s, error=%s", session.session_id, err)
finally:
await self._finish_reader_tasks(session)
@staticmethod
async def _finish_reader_tasks(session: _TerminalSession) -> None:
"""等待输出读取任务退出,超时后取消残留任务。"""
if not session.reader_tasks:
return
done, pending = await asyncio.wait(session.reader_tasks, timeout=1)
for task in pending:
task.cancel()
await asyncio.gather(*done, *pending, return_exceptions=True)
async def read(
self,
*,
session_id: str,
since_seq: Optional[int] = None,
max_bytes: Optional[int] = TERMINAL_DEFAULT_READ_BYTES,
) -> dict[str, Any]:
"""读取会话当前保留的增量输出。"""
session = self.get_session(session_id)
output, output_truncated, output_until_seq = self._collect_output(
session,
since_seq=since_seq,
max_bytes=max_bytes,
)
return self._session_payload(
session,
output=output,
output_truncated=output_truncated,
output_until_seq=output_until_seq,
)
async def wait(
self,
*,
session_id: str,
timeout_ms: Optional[int] = TERMINAL_WAIT_DEFAULT_MS,
since_seq: Optional[int] = None,
max_bytes: Optional[int] = TERMINAL_DEFAULT_READ_BYTES,
) -> dict[str, Any]:
"""短暂等待会话结束,并返回等待期间可见的增量输出。"""
session = self.get_session(session_id)
normalized_timeout = self._normalize_wait_timeout(timeout_ms)
if session.wait_task and not session.wait_task.done():
try:
await asyncio.wait_for(
asyncio.shield(session.wait_task),
timeout=normalized_timeout / 1000,
)
except asyncio.TimeoutError:
pass
output, output_truncated, output_until_seq = self._collect_output(
session,
since_seq=since_seq,
max_bytes=max_bytes,
)
payload = self._session_payload(
session,
output=output,
output_truncated=output_truncated,
output_until_seq=output_until_seq,
)
payload["wait_timeout_ms"] = normalized_timeout
return payload
async def write(self, *, session_id: str, input_text: str) -> dict[str, Any]:
"""向会话 stdin 写入文本PTY 模式下写入 master fd。"""
session = self.get_session(session_id)
if session.status != "running":
raise RuntimeError(f"会话已结束,当前状态: {session.status}")
data = (input_text or "").encode("utf-8")
if session.use_pty:
if session.master_fd is None:
raise RuntimeError("PTY 已关闭")
await asyncio.to_thread(os.write, session.master_fd, data)
else:
if not session.process or not session.process.stdin:
raise RuntimeError("进程 stdin 不可写")
session.process.stdin.write(data)
await session.process.stdin.drain()
session.updated_at = time.time()
payload = self._session_payload(session, output="", output_truncated=False)
payload["written_bytes"] = len(data)
return payload
async def kill(
self,
*,
session_id: str,
sig: Optional[str | int] = "TERM",
) -> dict[str, Any]:
"""向会话进程组发送信号并等待短暂清理。"""
session = self.get_session(session_id)
if session.status != "running":
return self._session_payload(session, output="", output_truncated=False)
session.kill_requested = True
signal_number = self._resolve_signal(sig)
self._send_signal(session, signal_number)
if session.wait_task and not session.wait_task.done():
try:
await asyncio.wait_for(
asyncio.shield(session.wait_task),
timeout=TERMINAL_KILL_GRACE_SECONDS,
)
except asyncio.TimeoutError:
force_signal = getattr(signal, "SIGKILL", signal.SIGTERM)
self._send_signal(session, force_signal)
return self._session_payload(session, output="", output_truncated=False)
def get_session(self, session_id: str) -> _TerminalSession:
"""按 ID 获取会话,不存在时抛出清晰错误。"""
session = self._sessions.get(session_id)
if not session:
raise KeyError(f"终端会话不存在: {session_id}")
return session
@staticmethod
def _normalize_wait_timeout(timeout_ms: Optional[int]) -> int:
"""限制 wait 单次等待时间,避免工具调用长时间占用模型回合。"""
try:
normalized = int(timeout_ms or TERMINAL_WAIT_DEFAULT_MS)
except (TypeError, ValueError):
normalized = TERMINAL_WAIT_DEFAULT_MS
if normalized < 0:
return 0
return min(normalized, TERMINAL_WAIT_MAX_MS)
@staticmethod
def _normalize_read_limit(max_bytes: Optional[int]) -> int:
"""限制单次读取返回的输出大小。"""
try:
normalized = int(max_bytes or TERMINAL_DEFAULT_READ_BYTES)
except (TypeError, ValueError):
normalized = TERMINAL_DEFAULT_READ_BYTES
if normalized <= 0:
return TERMINAL_DEFAULT_READ_BYTES
return min(normalized, TERMINAL_MAX_READ_BYTES)
def _collect_output(
self,
session: _TerminalSession,
*,
since_seq: Optional[int],
max_bytes: Optional[int],
) -> tuple[str, bool, int]:
"""按 seq 和大小限制收集输出文本。"""
read_limit = self._normalize_read_limit(max_bytes)
selected_chunks = [
chunk
for chunk in session.chunks
if since_seq is None or chunk.seq > since_seq
]
output_parts: list[str] = []
output_bytes = 0
output_truncated = False
last_stream: Optional[str] = None
output_until_seq = since_seq or session.retained_from_seq - 1
for chunk in selected_chunks:
prefix = self._stream_prefix(chunk.stream, last_stream, session.use_pty)
text = f"{prefix}{chunk.text}" if prefix else chunk.text
encoded = text.encode("utf-8")
remaining = read_limit - output_bytes
if len(encoded) > remaining:
if remaining > 0:
output_parts.append(
encoded[:remaining].decode("utf-8", errors="ignore")
)
output_truncated = True
break
output_parts.append(text)
output_bytes += len(encoded)
last_stream = chunk.stream
output_until_seq = chunk.seq
if since_seq is not None and since_seq < session.retained_from_seq - 1:
output_truncated = True
if not output_truncated:
output_until_seq = session.next_seq - 1
return "".join(output_parts), output_truncated, output_until_seq
@staticmethod
def _stream_prefix(stream: str, last_stream: Optional[str], use_pty: bool) -> str:
"""为普通管道输出增加 stdout/stderr 分段标识。"""
if use_pty or stream == last_stream:
return ""
title = "标准输出" if stream == "stdout" else "错误输出"
return f"\n[{title}]\n"
@staticmethod
def _resolve_signal(sig: Optional[str | int]) -> int:
"""解析字符串或数字形式的信号名。"""
if isinstance(sig, int):
return sig
signal_name = str(sig or "TERM").strip().upper()
if signal_name.isdigit():
return int(signal_name)
if not signal_name.startswith("SIG"):
signal_name = f"SIG{signal_name}"
return int(getattr(signal, signal_name, signal.SIGTERM))
@staticmethod
def _send_signal(session: _TerminalSession, sig: int) -> None:
"""优先向进程组发信号,失败时回退到单进程。"""
try:
if os.name == "posix":
os.killpg(session.pid, sig)
elif session.process:
if sig == getattr(signal, "SIGKILL", None):
session.process.kill()
else:
session.process.terminate()
except ProcessLookupError:
pass
def _active_session_count_locked(self) -> int:
"""统计仍在运行的会话数量。"""
return sum(1 for session in self._sessions.values() if session.status == "running")
def _cleanup_finished_sessions_locked(self) -> None:
"""清理已经结束且超过保留时间的会话。"""
now = time.time()
expired_ids = [
session_id
for session_id, session in self._sessions.items()
if session.status != "running"
and now - session.updated_at > TERMINAL_RETENTION_SECONDS
]
for session_id in expired_ids:
session = self._sessions.pop(session_id)
session.close_pty()
@staticmethod
def _session_payload(
session: _TerminalSession,
*,
output: str,
output_truncated: bool,
output_until_seq: Optional[int] = None,
) -> dict[str, Any]:
"""生成工具返回的结构化会话状态。"""
return {
"session_id": session.session_id,
"command": session.command,
"cwd": session.cwd,
"pid": session.pid,
"status": session.status,
"exit_code": session.exit_code,
"use_pty": session.use_pty,
"last_seq": session.next_seq - 1,
"output_until_seq": (
session.next_seq - 1 if output_until_seq is None else output_until_seq
),
"retained_from_seq": session.retained_from_seq,
"output_truncated": output_truncated,
"output": output,
"error": session.error,
}
terminal_session_manager = _TerminalSessionManager()

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.agent.tools.impl._filter_rule_utils import (
get_custom_rules,
normalize_custom_rule,
@@ -19,10 +20,8 @@ from app.schemas.types import SystemConfigKey
class AddCustomFilterRuleInput(BaseModel):
"""新增自定义过滤规则工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
rule_id: str = Field(
...,
description="Unique custom rule ID. Only letters and numbers are allowed.",
@@ -48,6 +47,11 @@ class AddCustomFilterRuleInput(BaseModel):
class AddCustomFilterRuleTool(MoviePilotTool):
name: str = "add_custom_filter_rule"
tags: list[str] = [
ToolTag.Write,
ToolTag.FilterRule,
ToolTag.Admin,
]
description: str = (
"Add a custom filter rule to CustomFilterRules. "
"The new rule can then be referenced by rule ID inside filter rule groups."

View File

@@ -6,7 +6,9 @@ from typing import List, Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool, ToolChain
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.media import MediaChain
from app.chain.search import SearchChain
from app.chain.download import DownloadChain
from app.core.config import settings
@@ -21,7 +23,7 @@ from app.utils.crypto import HashUtils
class AddDownloadInput(BaseModel):
"""添加下载工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
torrent_url: List[str] = Field(
...,
description="One or more torrent_url values. Supports refs from get_search_results (`hash:id`) and magnet links."
@@ -36,6 +38,11 @@ class AddDownloadInput(BaseModel):
class AddDownloadTool(MoviePilotTool):
name: str = "add_download"
tags: list[str] = [
ToolTag.Write,
ToolTag.Download,
ToolTag.Resource,
]
description: str = "Add torrent download tasks using refs from get_search_results or magnet links."
args_schema: Type[BaseModel] = AddDownloadInput
@@ -275,7 +282,10 @@ class AddDownloadTool(MoviePilotTool):
meta_info = MetaInfo(title=torrent_title, subtitle=torrent_description)
media_info = cached_context.media_info if cached_context.media_info else None
if not media_info:
media_info = await ToolChain().async_recognize_media(meta=meta_info)
media_info = await MediaChain().async_recognize_by_meta(
meta_info,
obtain_images=False,
)
if not media_info:
failed_messages.append(f"{torrent_input} 无法识别媒体信息")
continue

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.agent.tools.impl._filter_rule_utils import (
build_custom_rule_map,
collect_rule_group_usages,
@@ -23,10 +24,8 @@ from app.schemas.types import SystemConfigKey
class AddRuleGroupInput(BaseModel):
"""新增过滤规则组工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
name: str = Field(..., description="New rule group name.")
rule_string: str = Field(
...,
@@ -48,6 +47,11 @@ class AddRuleGroupInput(BaseModel):
class AddRuleGroupTool(MoviePilotTool):
name: str = "add_rule_group"
tags: list[str] = [
ToolTag.Write,
ToolTag.FilterRule,
ToolTag.Admin,
]
description: str = (
"Add a new filter rule group to UserFilterRuleGroups. "
"Rule groups are matched level by level from left to right and can be linked to search/subscription flows. "

View File

@@ -5,6 +5,7 @@ from typing import List, Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.subscribe import SubscribeChain
from app.db.user_oper import UserOper
from app.log import logger
@@ -14,10 +15,8 @@ from app.schemas.types import MediaType, MessageChannel
class AddSubscribeInput(BaseModel):
"""添加订阅工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
title: str = Field(
...,
description="The title of the media to subscribe to (e.g., 'The Matrix', 'Breaking Bad')",
@@ -74,6 +73,11 @@ class AddSubscribeInput(BaseModel):
class AddSubscribeTool(MoviePilotTool):
name: str = "add_subscribe"
tags: list[str] = [
ToolTag.Write,
ToolTag.Subscription,
ToolTag.Media,
]
description: str = (
"Add media subscription to create automated download rules for movies and TV shows. "
"The system will automatically search and download new episodes or releases based on the subscription criteria. "
@@ -117,6 +121,8 @@ class AddSubscribeTool(MoviePilotTool):
MessageChannel.Telegram: ("telegram_userid",),
MessageChannel.Discord: ("discord_userid",),
MessageChannel.Wechat: ("wechat_userid",),
MessageChannel.Feishu: ("feishu_userid", "feishu_openid"),
MessageChannel.WechatClawBot: ("wechatclawbot_userid",),
MessageChannel.Slack: ("slack_userid",),
MessageChannel.VoceChat: ("vocechat_userid",),
MessageChannel.SynologyChat: ("synologychat_userid",),

View File

@@ -5,6 +5,7 @@ from typing import List, Optional, Type
from pydantic import BaseModel, Field, model_validator
from app.agent.tools.base import MoviePilotTool, ToolChain
from app.agent.tools.tags import ToolTag
from app.helper.interaction import (
AgentInteractionOption,
agent_interaction_manager,
@@ -26,9 +27,11 @@ class UserChoiceOptionInput(BaseModel):
@model_validator(mode="after")
def validate_option(self):
if not self.label.strip():
label = str(self.label)
value = str(self.value)
if not label.strip():
raise ValueError("label 不能为空")
if not self.value.strip():
if not value.strip():
raise ValueError("value 不能为空")
return self
@@ -36,10 +39,8 @@ class UserChoiceOptionInput(BaseModel):
class AskUserChoiceInput(BaseModel):
"""按钮选择工具输入。"""
explanation: str = Field(
...,
description="Clear explanation of why the agent needs the user to choose from buttons",
)
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",
@@ -55,7 +56,8 @@ class AskUserChoiceInput(BaseModel):
@model_validator(mode="after")
def validate_payload(self):
if not self.message.strip():
message = str(self.message)
if not message.strip():
raise ValueError("message 不能为空")
if not self.options:
raise ValueError("options 至少需要提供一个")
@@ -63,11 +65,22 @@ class AskUserChoiceInput(BaseModel):
class AskUserChoiceTool(MoviePilotTool):
"""发送按钮选择并让当前 Agent 轮次等待用户回调消息。"""
name: str = "ask_user_choice"
tags: list[str] = [
ToolTag.Write,
ToolTag.Message,
ToolTag.UserInteraction,
ToolTag.TerminalResponse,
]
sends_message: bool = True
return_direct: bool = True
description: str = (
"Ask the user to choose from button options on channels that support interactive buttons. "
"After the user clicks a button, the selected value will come back as the user's next message."
"This is a terminal interaction tool: put the full question and all options in this call, "
"then stop the current turn. After the user clicks a button, the selected value will come "
"back as the user's next message. Do not also send the same question as plain text."
)
args_schema: Type[BaseModel] = AskUserChoiceInput
require_admin: bool = False
@@ -86,6 +99,15 @@ class AskUserChoiceTool(MoviePilotTool):
return text[:max_length]
return text[: max_length - 3] + "..."
def _blocked_by_feedback_quality_gate(self) -> bool:
"""反馈 Issue 质量门槛拒绝后,禁止继续发按钮引导改写。
这是对 ``feedback-issue`` skill 的历史兜底:如果同一轮上下文已经
标记反馈内容被质量门槛拒绝,就不能再用按钮诱导用户把测试 / 占位
内容改写成“真实问题”。
"""
return bool(self._agent_context.get("feedback_issue_rejected_quality"))
async def run(
self,
message: str,
@@ -93,6 +115,17 @@ class AskUserChoiceTool(MoviePilotTool):
title: Optional[str] = None,
**kwargs,
) -> str:
if self._blocked_by_feedback_quality_gate():
logger.warning(
"ask_user_choice blocked after feedback issue rejected_quality: "
"session_id=%s",
self._session_id,
)
return (
"反馈 Issue 已被质量门槛拒绝,不能继续发送按钮引导用户改写或重新提交。"
"请直接结束本次反馈流程。"
)
if not self._channel or not self._source:
return "当前不在可回传消息的会话中,无法发起按钮选择"

View File

@@ -1,15 +1,15 @@
"""浏览器操作工具 - 让Agent能够通过Playwright控制浏览器进行网页交互"""
import asyncio
import base64
import json
from enum import Enum
from typing import Optional, Type
from typing import Any, Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.core.config import settings
from app.agent.tools.tags import ToolTag
from app.helper.browser import BrowserSessionHelper
from app.log import logger
# 页面内容最大长度
@@ -26,34 +26,50 @@ class BrowserAction(str, Enum):
"""浏览器操作类型"""
GOTO = "goto"
SNAPSHOT = "snapshot"
GET_CONTENT = "get_content"
SCREENSHOT = "screenshot"
CLICK = "click"
CLICK_REF = "click_ref"
FILL = "fill"
FILL_REF = "fill_ref"
SELECT = "select"
SELECT_REF = "select_ref"
EVALUATE = "evaluate"
WAIT = "wait"
LIST_TABS = "list_tabs"
OPEN_TAB = "open_tab"
FOCUS_TAB = "focus_tab"
CLOSE_TAB = "close_tab"
CLOSE_SESSION = "close_session"
class BrowseWebpageInput(BaseModel):
"""浏览器操作工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this browser action is being performed",
)
explanation: Optional[str] = Field(None,
description="Clear explanation of why this browser action is being performed",)
action: str = Field(
...,
description=(
"The browser action to perform. Available actions:\n"
"- 'goto': Navigate to a URL, returns page title and text summary\n"
"- 'snapshot': Get current page snapshot with interactive element refs\n"
"- 'get_content': Get current page content (text or HTML)\n"
"- 'screenshot': Take a screenshot of the current page, returns base64 image\n"
"- 'click': Click on an element specified by selector\n"
"- 'click_ref': Click an element by ref from the latest snapshot\n"
"- 'fill': Fill text into an input element specified by selector\n"
"- 'fill_ref': Fill text into an input element by ref from the latest snapshot\n"
"- 'select': Select an option from a dropdown element\n"
"- 'select_ref': Select an option by ref from the latest snapshot\n"
"- 'evaluate': Execute JavaScript code on the page and return the result\n"
"- 'wait': Wait for an element to appear on the page"
"- 'wait': Wait for an element to appear on the page\n"
"- 'list_tabs': List browser tabs in the current session\n"
"- 'open_tab': Open a new tab, optionally navigating to a URL\n"
"- 'focus_tab': Switch active tab by index\n"
"- 'close_tab': Close a tab by index\n"
"- 'close_session': Close the current browser session"
),
)
url: Optional[str] = Field(
@@ -64,6 +80,10 @@ class BrowseWebpageInput(BaseModel):
description="CSS selector or text selector for the target element (for 'click', 'fill', 'select', 'wait' actions). "
"Supports CSS selectors like '#id', '.class', 'tag', and Playwright text selectors like 'text=Click me'",
)
ref: Optional[str] = Field(
None,
description="Element ref returned by 'snapshot' or action results (for 'click_ref', 'fill_ref', 'select_ref')",
)
value: Optional[str] = Field(
None,
description="Value to fill into input or option value to select (for 'fill' and 'select' actions)",
@@ -87,18 +107,36 @@ class BrowseWebpageInput(BaseModel):
user_agent: Optional[str] = Field(
None, description="Custom User-Agent string for the browser context"
)
session_key: Optional[str] = Field(
None,
description="Browser session key. Defaults to the current agent session id.",
)
tab_index: Optional[int] = Field(
None,
description="Tab index for 'focus_tab' and 'close_tab' actions.",
)
allow_private_network: bool = Field(
False,
description="Allow browser navigation to localhost, loopback, private, or link-local addresses.",
)
class BrowseWebpageTool(MoviePilotTool):
name: str = "browse_webpage"
tags: list[str] = [
ToolTag.Read,
ToolTag.Web,
]
description: str = (
"Control a real browser (Playwright) to interact with web pages. "
"Supports navigating to URLs, reading page content, taking screenshots, "
"clicking elements, filling forms, selecting dropdown options, executing JavaScript, and waiting for elements. "
"clicking elements, filling forms, selecting dropdown options, executing JavaScript, waiting for elements, "
"and managing tabs. "
"Use this tool when you need to interact with dynamic web pages, "
"fill in forms, click buttons, or extract content from JavaScript-rendered pages. "
"The browser session persists across multiple calls within the same conversation - "
"first call 'goto' to open a page, then use other actions to interact with it."
"first call 'goto' to open a page, inspect 'interactive_elements', then use *_ref actions when possible. "
"For safety, localhost and private network URLs are blocked by default unless allow_private_network is true."
)
args_schema: Type[BaseModel] = BrowseWebpageInput
@@ -109,13 +147,22 @@ class BrowseWebpageTool(MoviePilotTool):
selector = kwargs.get("selector", "")
action_messages = {
"goto": f"打开网页: {url}",
"snapshot": "读取页面快照",
"get_content": "获取页面内容",
"screenshot": "截取页面截图",
"click": f"点击元素: {selector}",
"click_ref": f"点击元素引用: {kwargs.get('ref', '')}",
"fill": f"填写表单: {selector}",
"fill_ref": f"填写元素引用: {kwargs.get('ref', '')}",
"select": f"选择选项: {selector}",
"select_ref": f"选择元素引用: {kwargs.get('ref', '')}",
"evaluate": "执行 JavaScript",
"wait": f"等待元素: {selector}",
"list_tabs": "列出浏览器标签页",
"open_tab": f"打开新标签页: {url}",
"focus_tab": f"切换浏览器标签页: {kwargs.get('tab_index', '')}",
"close_tab": f"关闭浏览器标签页: {kwargs.get('tab_index', '')}",
"close_session": "关闭浏览器会话",
}
return action_messages.get(action, f"执行浏览器操作: {action}")
@@ -124,12 +171,16 @@ class BrowseWebpageTool(MoviePilotTool):
action: str,
url: Optional[str] = None,
selector: Optional[str] = None,
ref: Optional[str] = None,
value: Optional[str] = None,
script: Optional[str] = None,
content_type: Optional[str] = "text",
timeout: Optional[int] = DEFAULT_TIMEOUT,
cookies: Optional[str] = None,
user_agent: Optional[str] = None,
session_key: Optional[str] = None,
tab_index: Optional[int] = None,
allow_private_network: bool = False,
**kwargs,
) -> str:
"""执行浏览器操作"""
@@ -148,6 +199,8 @@ class BrowseWebpageTool(MoviePilotTool):
# 参数校验
if browser_action == BrowserAction.GOTO and not url:
return "错误: 'goto' 操作需要提供 url 参数"
if browser_action == BrowserAction.OPEN_TAB and not url:
return "错误: 'open_tab' 操作需要提供 url 参数"
if (
browser_action
in (
@@ -159,26 +212,46 @@ class BrowseWebpageTool(MoviePilotTool):
and not selector
):
return f"错误: '{action}' 操作需要提供 selector 参数"
if (
browser_action
in (
BrowserAction.CLICK_REF,
BrowserAction.FILL_REF,
BrowserAction.SELECT_REF,
)
and not ref
):
return f"错误: '{action}' 操作需要提供 ref 参数"
if browser_action == BrowserAction.FILL and value is None:
return "错误: 'fill' 操作需要提供 value 参数"
if browser_action == BrowserAction.FILL_REF and value is None:
return "错误: 'fill_ref' 操作需要提供 value 参数"
if browser_action == BrowserAction.EVALUATE and not script:
return "错误: 'evaluate' 操作需要提供 script 参数"
if (
browser_action in (BrowserAction.FOCUS_TAB, BrowserAction.CLOSE_TAB)
and tab_index is None
):
return f"错误: '{action}' 操作需要提供 tab_index 参数"
# 在线程池中运行同步的 Playwright 操作
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(
None,
lambda: self._execute_browser_action(
browser_action=browser_action,
url=url,
selector=selector,
value=value,
script=script,
content_type=content_type,
timeout=timeout,
cookies=cookies,
user_agent=user_agent,
),
effective_session_key = session_key or self._session_id
result = await self.run_blocking(
"web",
self._execute_browser_action,
browser_action=browser_action,
url=url,
selector=selector,
ref=ref,
value=value,
script=script,
content_type=content_type,
timeout=timeout,
cookies=cookies,
user_agent=user_agent,
session_key=effective_session_key,
tab_index=tab_index,
allow_private_network=allow_private_network,
)
return result
@@ -191,91 +264,100 @@ class BrowseWebpageTool(MoviePilotTool):
browser_action: BrowserAction,
url: Optional[str],
selector: Optional[str],
ref: Optional[str],
value: Optional[str],
script: Optional[str],
content_type: Optional[str],
timeout: int,
cookies: Optional[str],
user_agent: Optional[str],
session_key: str,
tab_index: Optional[int],
allow_private_network: bool,
) -> str:
"""在同步上下文中执行 Playwright 浏览器操作"""
from playwright.sync_api import sync_playwright
"""在同步上下文中执行 CloakBrowser 浏览器操作"""
try:
with sync_playwright() as playwright:
browser = None
context = None
page = None
try:
# 启动浏览器
browser_type = settings.PLAYWRIGHT_BROWSER_TYPE or "chromium"
browser = playwright[browser_type].launch(headless=True)
# 创建上下文
context_kwargs = {}
if user_agent:
context_kwargs["user_agent"] = user_agent
# 设置视口大小
context_kwargs["viewport"] = {
"width": SCREENSHOT_MAX_WIDTH,
"height": SCREENSHOT_MAX_HEIGHT,
if browser_action == BrowserAction.CLOSE_SESSION:
closed = BrowserSessionHelper.close_session(session_key)
message = "浏览器会话已关闭" if closed else "浏览器会话不存在"
return self._json_response(
{
"success": closed,
"message": message,
}
)
context = browser.new_context(**context_kwargs)
page = context.new_page()
page.set_default_timeout(timeout * 1000)
helper = BrowserSessionHelper(
headless=True,
viewport={
"width": SCREENSHOT_MAX_WIDTH,
"height": SCREENSHOT_MAX_HEIGHT,
},
)
# 设置 cookies
if cookies:
page.set_extra_http_headers({"cookie": cookies})
def _callback(session) -> str:
return self._do_action(
helper=helper,
session=session,
browser_action=browser_action,
url=url,
selector=selector,
ref=ref,
value=value,
script=script,
content_type=content_type,
timeout=timeout,
tab_index=tab_index,
allow_private_network=allow_private_network,
)
# 对于非 goto 操作,如果提供了 url 先导航
if url and browser_action != BrowserAction.GOTO:
page.goto(
url, wait_until="domcontentloaded", timeout=timeout * 1000
)
page.wait_for_load_state("networkidle", timeout=timeout * 1000)
# 执行具体操作
result = self._do_action(
page,
browser_action,
url,
selector,
value,
script,
content_type,
timeout,
)
return result
finally:
if page:
page.close()
if context:
context.close()
if browser:
browser.close()
return helper.with_session(
session_key=session_key,
callback=_callback,
user_agent=user_agent,
cookies=cookies,
timeout=timeout,
)
except Exception as e:
logger.error(f"Playwright 执行失败: {e}", exc_info=True)
return f"Playwright 执行失败: {str(e)}"
logger.error(f"CloakBrowser 执行失败: {e}", exc_info=True)
return f"CloakBrowser 执行失败: {str(e)}"
def _do_action(
self,
page,
helper: BrowserSessionHelper,
session,
browser_action: BrowserAction,
url: Optional[str],
selector: Optional[str],
ref: Optional[str],
value: Optional[str],
script: Optional[str],
content_type: Optional[str],
timeout: int,
tab_index: Optional[int],
allow_private_network: bool,
) -> str:
"""执行具体的浏览器操作"""
page = session.active_page
if browser_action == BrowserAction.GOTO:
return self._action_goto(page, url, timeout)
return self._action_goto(
helper,
page,
url,
timeout,
allow_private_network=allow_private_network,
)
elif browser_action == BrowserAction.SNAPSHOT:
return self._json_response(
BrowserSessionHelper.build_snapshot(
page,
max_text_chars=MAX_CONTENT_LENGTH,
)
)
elif browser_action == BrowserAction.GET_CONTENT:
return self._action_get_content(page, content_type)
@@ -286,89 +368,113 @@ class BrowseWebpageTool(MoviePilotTool):
elif browser_action == BrowserAction.CLICK:
return self._action_click(page, selector, timeout)
elif browser_action == BrowserAction.CLICK_REF:
return self._action_click(
page,
BrowserSessionHelper.ref_to_selector(ref),
timeout,
ref=ref,
)
elif browser_action == BrowserAction.FILL:
return self._action_fill(page, selector, value, timeout)
elif browser_action == BrowserAction.FILL_REF:
return self._action_fill(
page,
BrowserSessionHelper.ref_to_selector(ref),
value,
timeout,
ref=ref,
)
elif browser_action == BrowserAction.SELECT:
return self._action_select(page, selector, value, timeout)
elif browser_action == BrowserAction.SELECT_REF:
return self._action_select(
page,
BrowserSessionHelper.ref_to_selector(ref),
value,
timeout,
ref=ref,
)
elif browser_action == BrowserAction.EVALUATE:
return self._action_evaluate(page, script)
elif browser_action == BrowserAction.WAIT:
return self._action_wait(page, selector, timeout)
elif browser_action == BrowserAction.LIST_TABS:
return self._json_response({"tabs": BrowserSessionHelper.list_tabs(session)})
elif browser_action == BrowserAction.OPEN_TAB:
page = helper.open_tab(
session,
url=url,
timeout=timeout,
allow_private_network=allow_private_network,
)
return self._json_response(
{
"success": True,
"active_tab": session.active_index,
"tabs": BrowserSessionHelper.list_tabs(session),
"snapshot": BrowserSessionHelper.build_snapshot(
page,
max_text_chars=MAX_CONTENT_LENGTH,
),
}
)
elif browser_action == BrowserAction.FOCUS_TAB:
page = BrowserSessionHelper.focus_tab(session, tab_index)
return self._json_response(
{
"success": True,
"active_tab": session.active_index,
"tabs": BrowserSessionHelper.list_tabs(session),
"snapshot": BrowserSessionHelper.build_snapshot(
page,
max_text_chars=MAX_CONTENT_LENGTH,
),
}
)
elif browser_action == BrowserAction.CLOSE_TAB:
tabs = BrowserSessionHelper.close_tab(session, tab_index)
return self._json_response({"success": True, "tabs": tabs})
return f"未知操作: {browser_action}"
@staticmethod
def _action_goto(page, url: str, timeout: int) -> str:
def _json_response(payload: dict[str, Any]) -> str:
"""返回格式化 JSON 字符串"""
return json.dumps(payload, ensure_ascii=False, indent=2)
@staticmethod
def _action_goto(
helper: BrowserSessionHelper,
page,
url: str,
timeout: int,
allow_private_network: bool,
) -> str:
"""导航到URL"""
response = page.goto(url, wait_until="domcontentloaded", timeout=timeout * 1000)
try:
page.wait_for_load_state("networkidle", timeout=min(timeout, 15) * 1000)
except Exception:
# networkidle 超时不是致命错误,页面可能已经可用
pass
response = helper.goto(
page,
url,
timeout=timeout,
allow_private_network=allow_private_network,
)
status = response.status if response else "unknown"
title = page.title()
page_url = page.url
# 提取页面可读文本摘要
text_content = page.inner_text("body")
if text_content and len(text_content) > MAX_CONTENT_LENGTH:
text_content = text_content[:MAX_CONTENT_LENGTH] + "\n\n...(内容已截断)"
# 提取页面链接
links = page.evaluate("""
() => {
const links = [];
document.querySelectorAll('a[href]').forEach(a => {
const text = a.innerText.trim();
const href = a.href;
if (text && href && !href.startsWith('javascript:')) {
links.push({text: text.substring(0, 80), href: href});
}
});
return links.slice(0, 30);
}
""")
# 提取表单信息
forms = page.evaluate("""
() => {
const forms = [];
document.querySelectorAll('input, textarea, select, button').forEach(el => {
const info = {
tag: el.tagName.toLowerCase(),
type: el.type || '',
name: el.name || '',
id: el.id || '',
placeholder: el.placeholder || '',
value: el.tagName.toLowerCase() === 'select' ? '' : (el.value || '').substring(0, 50),
text: el.innerText ? el.innerText.trim().substring(0, 50) : ''
};
// 只保留有标识信息的元素
if (info.name || info.id || info.placeholder || info.text) {
forms.push(info);
}
});
return forms.slice(0, 30);
}
""")
result = {
"status": status,
"url": page_url,
"title": title,
"text_content": text_content,
}
if links:
result["links"] = links
if forms:
result["form_elements"] = forms
return json.dumps(result, ensure_ascii=False, indent=2)
result = BrowserSessionHelper.build_snapshot(
page,
status=status,
max_text_chars=MAX_CONTENT_LENGTH,
)
return BrowseWebpageTool._json_response(result)
@staticmethod
def _action_get_content(page, content_type: Optional[str]) -> str:
@@ -390,7 +496,7 @@ class BrowseWebpageTool(MoviePilotTool):
"content_type": content_type,
"content": content,
}
return json.dumps(result, ensure_ascii=False, indent=2)
return BrowseWebpageTool._json_response(result)
@staticmethod
def _action_screenshot(page) -> str:
@@ -423,10 +529,15 @@ class BrowseWebpageTool(MoviePilotTool):
"format": "jpeg",
"note": "截图已以 base64 编码返回",
}
return json.dumps(result, ensure_ascii=False, indent=2)
return BrowseWebpageTool._json_response(result)
@staticmethod
def _action_click(page, selector: str, timeout: int) -> str:
def _action_click(
page,
selector: str,
timeout: int,
ref: Optional[str] = None,
) -> str:
"""点击元素"""
page.click(selector, timeout=timeout * 1000)
@@ -436,49 +547,62 @@ class BrowseWebpageTool(MoviePilotTool):
except Exception:
pass
title = page.title()
page_url = page.url
return json.dumps(
return BrowseWebpageTool._json_response(
{
"success": True,
"message": f"成功点击元素: {selector}",
"current_url": page_url,
"current_title": title,
},
ensure_ascii=False,
indent=2,
"message": f"成功点击元素: {ref or selector}",
"snapshot": BrowserSessionHelper.build_snapshot(
page,
max_text_chars=MAX_CONTENT_LENGTH,
),
}
)
@staticmethod
def _action_fill(page, selector: str, value: str, timeout: int) -> str:
def _action_fill(
page,
selector: str,
value: str,
timeout: int,
ref: Optional[str] = None,
) -> str:
"""填写表单"""
page.fill(selector, value, timeout=timeout * 1000)
return json.dumps(
return BrowseWebpageTool._json_response(
{
"success": True,
"message": f"成功填写元素 '{selector}' 的值为 '{value}'",
},
ensure_ascii=False,
indent=2,
"message": f"成功填写元素 '{ref or selector}'",
"snapshot": BrowserSessionHelper.build_snapshot(
page,
max_text_chars=MAX_CONTENT_LENGTH,
),
}
)
@staticmethod
def _action_select(page, selector: str, value: Optional[str], timeout: int) -> str:
def _action_select(
page,
selector: str,
value: Optional[str],
timeout: int,
ref: Optional[str] = None,
) -> str:
"""选择下拉选项"""
if value:
page.select_option(selector, value=value, timeout=timeout * 1000)
else:
return "错误: 'select' 操作需要提供 value 参数"
return json.dumps(
return BrowseWebpageTool._json_response(
{
"success": True,
"message": f"成功选择元素 '{selector}' 的选项 '{value}'",
},
ensure_ascii=False,
indent=2,
"message": f"成功选择元素 '{ref or selector}' 的选项 '{value}'",
"snapshot": BrowserSessionHelper.build_snapshot(
page,
max_text_chars=MAX_CONTENT_LENGTH,
),
}
)
@staticmethod
@@ -498,13 +622,11 @@ class BrowseWebpageTool(MoviePilotTool):
if len(formatted) > MAX_CONTENT_LENGTH:
formatted = formatted[:MAX_CONTENT_LENGTH] + "\n\n...(结果已截断)"
return json.dumps(
return BrowseWebpageTool._json_response(
{
"success": True,
"result": formatted,
},
ensure_ascii=False,
indent=2,
}
)
@staticmethod
@@ -518,22 +640,22 @@ class BrowseWebpageTool(MoviePilotTool):
if text and len(text) > 200:
text = text[:200] + "..."
return json.dumps(
return BrowseWebpageTool._json_response(
{
"success": True,
"message": f"元素 '{selector}' 已出现",
"visible": visible,
"text": text,
},
ensure_ascii=False,
indent=2,
"snapshot": BrowserSessionHelper.build_snapshot(
page,
max_text_chars=MAX_CONTENT_LENGTH,
),
}
)
else:
return json.dumps(
return BrowseWebpageTool._json_response(
{
"success": False,
"message": f"等待元素 '{selector}' 超时",
},
ensure_ascii=False,
indent=2,
}
)

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.agent.tools.impl._filter_rule_utils import (
collect_custom_rule_group_refs,
get_custom_rules,
@@ -19,15 +20,18 @@ from app.schemas.types import SystemConfigKey
class DeleteCustomFilterRuleInput(BaseModel):
"""删除自定义过滤规则工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
rule_id: str = Field(..., description="Custom rule ID to delete.")
class DeleteCustomFilterRuleTool(MoviePilotTool):
name: str = "delete_custom_filter_rule"
tags: list[str] = [
ToolTag.Write,
ToolTag.FilterRule,
ToolTag.Admin,
]
description: str = (
"Delete a custom filter rule from CustomFilterRules. "
"If the rule is still referenced by rule groups, the deletion is blocked to avoid breaking rule_string expressions."

View File

@@ -5,6 +5,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.download import DownloadChain
from app.log import logger
@@ -12,10 +13,8 @@ from app.log import logger
class DeleteDownloadInput(BaseModel):
"""删除下载任务工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
hash: str = Field(
..., description="Task hash (can be obtained from query_download_tasks tool)"
)
@@ -31,6 +30,11 @@ class DeleteDownloadInput(BaseModel):
class DeleteDownloadTool(MoviePilotTool):
name: str = "delete_download"
tags: list[str] = [
ToolTag.Write,
ToolTag.Download,
ToolTag.Admin,
]
description: str = "Delete a download task from the downloader by task hash only. Optionally specify the downloader name and whether to delete downloaded files."
args_schema: Type[BaseModel] = DeleteDownloadInput
require_admin: bool = True

View File

@@ -5,6 +5,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.db import AsyncSessionFactory
from app.db.models.downloadhistory import DownloadHistory
from app.log import logger
@@ -13,10 +14,8 @@ from app.log import logger
class DeleteDownloadHistoryInput(BaseModel):
"""删除下载历史记录工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
history_id: int = Field(
..., description="The ID of the download history record to delete"
)
@@ -24,6 +23,11 @@ class DeleteDownloadHistoryInput(BaseModel):
class DeleteDownloadHistoryTool(MoviePilotTool):
name: str = "delete_download_history"
tags: list[str] = [
ToolTag.Write,
ToolTag.Download,
ToolTag.Admin,
]
description: str = "Delete a download history record by ID. This only removes the record from the database, does not delete any actual files."
args_schema: Type[BaseModel] = DeleteDownloadHistoryInput
require_admin: bool = True

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.agent.tools.impl._filter_rule_utils import (
get_rule_groups,
remove_rule_group_references,
@@ -18,15 +19,18 @@ from app.schemas.types import SystemConfigKey
class DeleteRuleGroupInput(BaseModel):
"""删除过滤规则组工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
name: str = Field(..., description="Rule group name to delete.")
class DeleteRuleGroupTool(MoviePilotTool):
name: str = "delete_rule_group"
tags: list[str] = [
ToolTag.Write,
ToolTag.FilterRule,
ToolTag.Admin,
]
description: str = (
"Delete a filter rule group from UserFilterRuleGroups. "
"The tool also removes dangling references from global settings and subscriptions."

View File

@@ -5,9 +5,10 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.core.event import eventmanager
from app.db.subscribe_oper import SubscribeOper
from app.helper.subscribe import SubscribeHelper
from app.helper.server import MoviePilotServerHelper
from app.log import logger
from app.schemas.types import EventType
@@ -15,10 +16,8 @@ from app.schemas.types import EventType
class DeleteSubscribeInput(BaseModel):
"""删除订阅工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
subscribe_id: int = Field(
...,
description="The ID of the subscription to delete (can be obtained from query_subscribes tool)",
@@ -27,6 +26,11 @@ class DeleteSubscribeInput(BaseModel):
class DeleteSubscribeTool(MoviePilotTool):
name: str = "delete_subscribe"
tags: list[str] = [
ToolTag.Write,
ToolTag.Subscription,
ToolTag.Admin,
]
description: str = "Delete a media subscription by its ID. This will remove the subscription and stop automatic downloads for that media."
args_schema: Type[BaseModel] = DeleteSubscribeInput
require_admin: bool = True
@@ -51,7 +55,7 @@ class DeleteSubscribeTool(MoviePilotTool):
await subscribe_oper.async_delete(subscribe_id)
# 分享订阅统计刷新本身已异步化,这里只需要在删除后触发即可。
SubscribeHelper().sub_done_async(
MoviePilotServerHelper.sub_done_async(
{"tmdbid": subscribe.tmdbid, "doubanid": subscribe.doubanid}
)

View File

@@ -5,6 +5,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.db.transferhistory_oper import TransferHistoryOper
from app.log import logger
@@ -12,10 +13,8 @@ from app.log import logger
class DeleteTransferHistoryInput(BaseModel):
"""删除整理历史记录工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
history_id: int = Field(
..., description="The ID of the transfer history record to delete"
)
@@ -23,6 +22,11 @@ class DeleteTransferHistoryInput(BaseModel):
class DeleteTransferHistoryTool(MoviePilotTool):
name: str = "delete_transfer_history"
tags: list[str] = [
ToolTag.Write,
ToolTag.Transfer,
ToolTag.Admin,
]
description: str = "Delete a specific transfer history record by its ID. This is useful when you need to remove a failed transfer record before retrying the transfer, as the system skips files that already have transfer history."
args_schema: Type[BaseModel] = DeleteTransferHistoryInput
require_admin: bool = True

View File

@@ -7,6 +7,7 @@ from anyio import Path as AsyncPath
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.log import logger
@@ -20,6 +21,11 @@ class EditFileInput(BaseModel):
class EditFileTool(MoviePilotTool):
name: str = "edit_file"
tags: list[str] = [
ToolTag.Write,
ToolTag.File,
ToolTag.Admin,
]
description: str = "Edit a file by replacing specific old text with new text. Useful for modifying configuration files, code, or scripts."
args_schema: Type[BaseModel] = EditFileInput
require_admin: bool = True

View File

@@ -1,96 +1,292 @@
"""执行Shell命令工具"""
"""执行 Shell 命令工具"""
from __future__ import annotations
import asyncio
import json
import os
import signal
import subprocess
from dataclasses import dataclass, field
from typing import Optional, Type
from tempfile import NamedTemporaryFile
from typing import Any, Literal, Optional, TextIO, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.agent.tools.impl._terminal_session import (
TERMINAL_DEFAULT_READ_BYTES,
TERMINAL_MAX_READ_BYTES,
TERMINAL_WAIT_DEFAULT_MS,
terminal_session_manager,
)
from app.log import logger
DEFAULT_TIMEOUT_SECONDS = 60
MAX_TIMEOUT_SECONDS = 300
MAX_OUTPUT_CHARS = 6000
MAX_OUTPUT_PREVIEW_BYTES = 10 * 1024
READ_CHUNK_SIZE = 4096
KILL_GRACE_SECONDS = 3
COMMAND_CONCURRENCY_LIMIT = 2
COMMAND_FORBIDDEN_KEYWORDS = (
":(){ :|:& };:",
"dd if=/dev/zero",
"mkfs",
"reboot",
"shutdown",
)
_command_semaphore = asyncio.Semaphore(COMMAND_CONCURRENCY_LIMIT)
@dataclass
class _CommandOutput:
"""保存受限命令输出,避免大输出一次性进入内存"""
"""保存前 10KB 预览,并在超限时将完整输出写入临时文件"""
limit: int
stdout_chunks: list[str] = field(default_factory=list)
stderr_chunks: list[str] = field(default_factory=list)
captured_chars: int = 0
truncated: bool = False
preview_limit_bytes: int
preview_entries: list[tuple[str, str]] = field(default_factory=list)
captured_bytes: int = 0
preview_truncated: bool = False
temp_file_path: Optional[str] = None
temp_file_handle: Optional[TextIO] = None
last_written_stream: Optional[str] = None
@staticmethod
def _clip_text_to_bytes(text: str, byte_limit: int) -> str:
"""按 UTF-8 字节数截断文本,避免截断后出现非法字符。"""
if byte_limit <= 0:
return ""
return text.encode("utf-8")[:byte_limit].decode("utf-8", errors="ignore")
def _write_chunk(self, stream_name: str, text: str) -> None:
"""把输出分片按 stdout/stderr 分段写入临时文件。"""
if not self.temp_file_handle or not text:
return
if self.last_written_stream != stream_name:
if self.temp_file_handle.tell() > 0:
self.temp_file_handle.write("\n")
title = "标准输出" if stream_name == "stdout" else "错误输出"
self.temp_file_handle.write(f"[{title}]\n")
self.last_written_stream = stream_name
self.temp_file_handle.write(text)
def _ensure_temp_file(self) -> None:
"""首次超出预览上限时创建临时文件并补写已缓存预览。"""
if self.temp_file_handle:
return
temp_file = NamedTemporaryFile(
mode="w",
encoding="utf-8",
suffix=".log",
prefix="moviepilot-command-",
delete=False,
)
self.temp_file_path = temp_file.name
self.temp_file_handle = temp_file
for stream_name, chunk in self.preview_entries:
self._write_chunk(stream_name, chunk)
def close(self) -> None:
"""关闭临时文件句柄,确保输出落盘。"""
if not self.temp_file_handle:
return
self.temp_file_handle.flush()
self.temp_file_handle.close()
self.temp_file_handle = None
def append(self, stream_name: str, text: str) -> None:
"""追加一段输出,超出预览上限后只保留完整日志文件。"""
if not text:
return
remaining = self.limit - self.captured_chars
if remaining <= 0:
self.truncated = True
if self.temp_file_handle:
self._write_chunk(stream_name, text)
return
captured = text[:remaining]
if stream_name == "stdout":
self.stdout_chunks.append(captured)
else:
self.stderr_chunks.append(captured)
chunk_bytes = len(text.encode("utf-8"))
remaining = self.preview_limit_bytes - self.captured_bytes
if chunk_bytes <= remaining:
self.preview_entries.append((stream_name, text))
self.captured_bytes += chunk_bytes
return
self.captured_chars += len(captured)
if len(text) > remaining:
self.truncated = True
self.preview_truncated = True
self._ensure_temp_file()
self._write_chunk(stream_name, text)
preview = self._clip_text_to_bytes(text, remaining)
if preview:
self.preview_entries.append((stream_name, preview))
self.captured_bytes += len(preview.encode("utf-8"))
@property
def stdout(self) -> str:
return "".join(self.stdout_chunks).strip()
"""返回当前保留的 stdout 预览。"""
return "".join(
text for stream_name, text in self.preview_entries if stream_name == "stdout"
).strip()
@property
def stderr(self) -> str:
return "".join(self.stderr_chunks).strip()
"""返回当前保留的 stderr 预览。"""
return "".join(
text for stream_name, text in self.preview_entries if stream_name == "stderr"
).strip()
class ExecuteCommandInput(BaseModel):
"""执行Shell命令工具的输入参数模型"""
"""执行 Shell 命令工具的输入参数模型"""
explanation: str = Field(
..., description="Clear explanation of why this command is being executed"
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=(
"Command action. start launches a managed background session and returns "
"session_id. read/wait/write/kill operate on that session. run executes "
"once and waits until completion or timeout."
),
)
command: Optional[str] = Field(
None,
description="Shell command. Required for action=start or action=run.",
)
session_id: Optional[str] = Field(
None,
description="Command session id returned by action=start.",
)
input_text: Optional[str] = Field(
None,
description="Text to send to stdin for action=write. Use \\u0003 for Ctrl+C.",
)
signal_name: Optional[str] = Field(
"TERM",
description="Signal for action=kill, such as TERM, INT, KILL, or 15.",
)
cwd: Optional[str] = Field(
None,
description="Working directory for action=start or action=run.",
)
env: Optional[dict[str, Any]] = Field(
None,
description="Additional environment variables for action=start.",
)
use_pty: Optional[bool] = Field(
True,
description="Use a pseudo terminal for action=start when supported.",
)
since_seq: Optional[int] = Field(
None,
description="For action=read/wait, return output chunks after this seq.",
)
max_bytes: Optional[int] = Field(
TERMINAL_DEFAULT_READ_BYTES,
description="For action=read/wait, maximum output bytes to return.",
)
timeout_ms: Optional[int] = Field(
TERMINAL_WAIT_DEFAULT_MS,
description="For action=wait, maximum segmented wait time in milliseconds.",
)
command: str = Field(..., description="The shell command to execute")
timeout: Optional[int] = Field(
60, description="Max execution time in seconds (default: 60)"
60,
description="For action=run, max execution time in seconds.",
)
class ExecuteCommandTool(MoviePilotTool):
"""统一执行和管理 Shell 命令的 Agent 工具。"""
name: str = "execute_command"
tags: list[str] = [
ToolTag.Read,
ToolTag.Command,
ToolTag.Admin,
]
description: str = (
"Safely execute shell commands on the server. Useful for system "
"maintenance, checking status, or running custom scripts. Includes "
"timeout, concurrency, and hard output limits."
"Start and manage shell commands on the server. By default action=start "
"launches a background session and immediately returns session_id/status/"
"last_seq/output_until_seq. Call the same tool with action=read, wait, "
"write, or kill to poll output, wait in short segments, send stdin, or "
"terminate it. Use action=run only when a one-shot bounded command result "
"is preferred."
)
args_schema: Type[BaseModel] = ExecuteCommandInput
require_admin: bool = True
result_max_chars = TERMINAL_MAX_READ_BYTES + 4096
def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据命令生成友好的提示消息"""
command = kwargs.get("command", "")
return f"执行系统命令: {command}"
"""根据命令动作生成友好的提示消息"""
action = kwargs.get("action") or "start"
command = kwargs.get("command")
session_id = kwargs.get("session_id")
if action in {"start", "run"}:
return f"执行系统命令: {command or ''}"
if action == "read":
return f"读取命令输出: {session_id or ''}"
if action == "wait":
return f"等待命令会话: {session_id or ''}"
if action == "write":
return f"写入命令输入: {session_id or ''}"
if action == "kill":
return f"终止命令会话: {session_id or ''}"
return f"处理命令会话: {session_id or command or ''}"
@staticmethod
def _dump(payload: dict[str, Any]) -> str:
"""把结构化命令会话结果转换为 Agent 容易解析的 JSON 字符串。"""
return json.dumps(payload, ensure_ascii=False, indent=2)
@staticmethod
def _require_session_id(session_id: Optional[str]) -> str:
"""校验会话型 action 必须传入 session_id。"""
if not session_id:
raise ValueError("action 需要传入 session_id")
return session_id
@staticmethod
def _require_command(command: Optional[str]) -> str:
"""校验启动型 action 必须传入 command。"""
if not command or not command.strip():
raise ValueError("action 需要传入 command")
return command
@staticmethod
def _validate_command(command: str) -> None:
"""复用旧工具的基础危险命令过滤,避免明显破坏性命令进入 shell。"""
for keyword in COMMAND_FORBIDDEN_KEYWORDS:
if keyword in command:
raise ValueError(f"命令包含禁止使用的关键字 '{keyword}'")
# 检查是否使用了 rm -r/R 删除根目录或一级目录,防止误杀多级目录
import re
import os.path
tokens = re.split(r'\s+', command.strip())
if any(t == "rm" or t.endswith("/rm") for t in tokens):
has_r = False
for token in tokens:
if token.startswith("-") and ("r" in token or "R" in token):
has_r = True
break
if has_r:
for token in tokens:
# 提取可能包含目标路径的部分(去除重定向、管道、分号等末尾干扰)
m = re.match(r'^([^;\|&><]+)', token)
if m:
clean_token = m.group(1).strip('"\'')
# 仅对绝对路径进行一级目录限制
if clean_token.startswith('/'):
norm_path = os.path.normpath(clean_token)
if re.match(r'^/[^/]*$', norm_path) or re.match(r'^/[^/]*/$', norm_path):
raise ValueError(f"不允许使用 rm 命令删除根目录或一级目录: {clean_token}")
@staticmethod
def _normalize_timeout(timeout: Optional[int]) -> tuple[int, Optional[str]]:
"""限制命令最长运行时间,避免 Agent 传入过大的 timeout"""
"""限制一次性执行命令最长运行时间。"""
try:
normalized = int(timeout or DEFAULT_TIMEOUT_SECONDS)
except (TypeError, ValueError):
@@ -107,7 +303,7 @@ class ExecuteCommandTool(MoviePilotTool):
@staticmethod
def _subprocess_kwargs() -> dict:
"""子进程创建独立进程组,便于超时或输出过大时清理整棵子进程。"""
"""一次性命令创建独立进程组,便于超时清理整棵子进程。"""
kwargs = {
"stdin": subprocess.DEVNULL,
"stdout": asyncio.subprocess.PIPE,
@@ -124,27 +320,17 @@ class ExecuteCommandTool(MoviePilotTool):
stream: asyncio.StreamReader,
stream_name: str,
output: _CommandOutput,
limit_reached: asyncio.Event,
) -> None:
"""按块读取输出,达到上限后通知主流程终止命令"""
"""按块读取一次性命令输出,只把前 10KB 保留在返回结果中"""
while True:
chunk = await stream.read(READ_CHUNK_SIZE)
if not chunk:
break
if output.truncated:
limit_reached.set()
continue
output.append(stream_name, chunk.decode("utf-8", errors="replace"))
if output.truncated:
limit_reached.set()
# 达到上限后继续排空管道但不再保存内容,避免子进程因 pipe 反压卡住。
continue
@staticmethod
def _terminate_process(process: asyncio.subprocess.Process, sig: int):
"""向进程组发送终止信号不支持进程组的平台回退为单进程终止。"""
def _terminate_process(process: Any, sig: int) -> None:
"""向进程组发送终止信号不支持进程组的平台回退为单进程终止。"""
try:
if os.name == "posix":
os.killpg(process.pid, sig)
@@ -158,7 +344,7 @@ class ExecuteCommandTool(MoviePilotTool):
@classmethod
async def _cleanup_process(
cls,
process: asyncio.subprocess.Process,
process: Any,
wait_task: asyncio.Task,
) -> None:
"""先温和终止,失败后强杀,避免超时 shell 遗留子进程。"""
@@ -185,7 +371,7 @@ class ExecuteCommandTool(MoviePilotTool):
@staticmethod
async def _finish_reader_tasks(reader_tasks: list[asyncio.Task]) -> None:
"""等待输出读取任务退出,异常只记录不影响工具返回。"""
"""等待一次性命令输出读取任务退出,异常只记录不影响工具返回。"""
if not reader_tasks:
return
done, pending = await asyncio.wait(reader_tasks, timeout=1)
@@ -199,107 +385,166 @@ class ExecuteCommandTool(MoviePilotTool):
logger.debug("命令输出读取任务异常: %s", result)
@staticmethod
def _format_result(
def _format_run_result(
*,
exit_code: Optional[int],
output: _CommandOutput,
timeout: int,
timed_out: bool,
output_limited: bool,
timeout_note: Optional[str],
) -> str:
"""格式化 action=run 的兼容文本结果。"""
if timed_out:
result = f"命令执行超时 (限制: {timeout}秒,已终止进程)"
elif output_limited:
result = (
f"命令输出超过限制 (限制: {MAX_OUTPUT_CHARS}字符,"
f"已截断并终止进程,退出码: {exit_code})"
)
else:
result = f"命令执行完成 (退出码: {exit_code})"
if timeout_note:
result += f"\n\n提示:\n{timeout_note}"
if output.temp_file_path:
file_note = "截至命令终止前的完整输出" if timed_out else "完整输出"
result += (
"\n\n提示:\n"
f"命令输出超过 10KB仅返回前 {MAX_OUTPUT_PREVIEW_BYTES} 字节内容。\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.truncated:
result += "\n\n...(输出内容过长,已截断)"
if output.preview_truncated:
result += "\n\n...(仅展示前 10KB 内容)"
if not output.stdout and not output.stderr:
result += "\n\n(无输出内容)"
return result
async def run(self, command: str, timeout: Optional[int] = 60, **kwargs) -> str:
logger.info(
f"执行工具: {self.name}, 参数: command={command}, timeout={timeout}"
)
# 简单安全过滤
forbidden_keywords = [
"rm -rf /",
":(){ :|:& };:",
"dd if=/dev/zero",
"mkfs",
"reboot",
"shutdown",
]
for keyword in forbidden_keywords:
if keyword in command:
return f"错误:命令包含禁止使用的关键字 '{keyword}'"
async def _run_once(
self,
*,
command: str,
timeout: Optional[int],
cwd: Optional[str] = None,
) -> str:
"""按旧模式一次性执行命令,等待完成或超时后返回文本结果。"""
self._validate_command(command)
normalized_timeout, timeout_note = self._normalize_timeout(timeout)
try:
async with _command_semaphore:
# 命令输出可能非常大,必须边读边截断,不能使用 communicate() 一次性收集。
process = await asyncio.create_subprocess_shell(
command, **self._subprocess_kwargs()
async with _command_semaphore:
process = await asyncio.create_subprocess_shell(
command,
cwd=cwd,
**self._subprocess_kwargs(),
)
output = _CommandOutput(preview_limit_bytes=MAX_OUTPUT_PREVIEW_BYTES)
wait_task = asyncio.create_task(process.wait())
reader_tasks = [
asyncio.create_task(self._read_stream(process.stdout, "stdout", output)),
asyncio.create_task(self._read_stream(process.stderr, "stderr", output)),
]
timed_out = False
try:
await asyncio.wait_for(
asyncio.shield(wait_task), timeout=normalized_timeout
)
output = _CommandOutput(limit=MAX_OUTPUT_CHARS)
limit_reached = asyncio.Event()
wait_task = asyncio.create_task(process.wait())
limit_task = asyncio.create_task(limit_reached.wait())
reader_tasks = [
asyncio.create_task(
self._read_stream(
process.stdout, "stdout", output, limit_reached
)
),
asyncio.create_task(
self._read_stream(
process.stderr, "stderr", output, limit_reached
)
),
]
except asyncio.TimeoutError:
timed_out = True
await self._cleanup_process(process, wait_task)
except asyncio.CancelledError:
await self._cleanup_process(process, wait_task)
raise
timed_out = False
output_limited = False
done, _ = await asyncio.wait(
{wait_task, limit_task},
timeout=normalized_timeout,
return_when=asyncio.FIRST_COMPLETED,
)
if wait_task not in done:
if limit_task in done:
output_limited = True
else:
timed_out = True
await self._cleanup_process(process, wait_task)
limit_task.cancel()
try:
await self._finish_reader_tasks(reader_tasks)
finally:
output.close()
return self._format_result(
exit_code=process.returncode,
output=output,
timeout=normalized_timeout,
timed_out=timed_out,
output_limited=output_limited,
timeout_note=timeout_note,
return self._format_run_result(
exit_code=process.returncode,
output=output,
timeout=normalized_timeout,
timed_out=timed_out,
timeout_note=timeout_note,
)
async def run(
self,
action: Optional[str] = "start",
command: Optional[str] = None,
session_id: Optional[str] = None,
input_text: Optional[str] = None,
signal_name: Optional[str] = "TERM",
cwd: Optional[str] = None,
env: Optional[dict[str, Any]] = None,
use_pty: Optional[bool] = True,
since_seq: Optional[int] = None,
max_bytes: Optional[int] = TERMINAL_DEFAULT_READ_BYTES,
timeout_ms: Optional[int] = TERMINAL_WAIT_DEFAULT_MS,
timeout: Optional[int] = 60,
**kwargs,
) -> str:
"""执行命令动作:默认后台启动,也支持读取、等待、写入、终止和一次性执行。"""
normalized_action = (action or "start").strip().lower()
logger.info(
"执行工具: %s, action=%s, command=%s, session_id=%s",
self.name,
normalized_action,
command,
session_id,
)
try:
if normalized_action == "start":
start_command = self._require_command(command)
self._validate_command(start_command)
payload = await terminal_session_manager.start(
command=start_command,
cwd=cwd,
env=env,
use_pty=use_pty,
)
return self._dump(payload)
if normalized_action == "read":
payload = await terminal_session_manager.read(
session_id=self._require_session_id(session_id),
since_seq=since_seq,
max_bytes=max_bytes,
)
return self._dump(payload)
if normalized_action == "wait":
payload = await terminal_session_manager.wait(
session_id=self._require_session_id(session_id),
timeout_ms=timeout_ms,
since_seq=since_seq,
max_bytes=max_bytes,
)
return self._dump(payload)
if normalized_action == "write":
payload = await terminal_session_manager.write(
session_id=self._require_session_id(session_id),
input_text=input_text or "",
)
return self._dump(payload)
if normalized_action == "kill":
payload = await terminal_session_manager.kill(
session_id=self._require_session_id(session_id),
sig=signal_name,
)
return self._dump(payload)
if normalized_action == "run":
return await self._run_once(
command=self._require_command(command),
timeout=timeout,
cwd=cwd,
)
except Exception as e:
logger.error(f"执行命令失败: {e}", exc_info=True)
return f"执行命令时发生错误: {str(e)}"
raise ValueError(f"不支持的 action: {action}")
except Exception as err:
logger.error("执行命令 action 失败: %s", err, exc_info=True)
return self._dump({"error": str(err), "status": "error", "action": normalized_action})

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.recommend import RecommendChain
from app.log import logger
from app.schemas.types import MediaType, media_type_to_agent
@@ -14,10 +15,8 @@ from app.schemas.types import MediaType, media_type_to_agent
class GetRecommendationsInput(BaseModel):
"""获取推荐工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
source: Optional[str] = Field(
"tmdb_trending",
description="Recommendation source: "
@@ -46,6 +45,11 @@ class GetRecommendationsInput(BaseModel):
class GetRecommendationsTool(MoviePilotTool):
name: str = "get_recommendations"
tags: list[str] = [
ToolTag.Read,
ToolTag.Media,
ToolTag.Recommendation,
]
description: str = "Get trending and popular media recommendations from various sources. Returns curated lists of popular movies, TV shows, and anime based on different criteria like trending, ratings, or calendar schedules. Supports pagination with 20 items per page."
args_schema: Type[BaseModel] = GetRecommendationsInput

View File

@@ -7,6 +7,7 @@ from typing import List, Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.search import SearchChain
from app.log import logger
from ._torrent_search_utils import (
@@ -20,10 +21,8 @@ from ._torrent_search_utils import (
class GetSearchResultsInput(BaseModel):
"""获取搜索结果工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
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")
@@ -49,6 +48,10 @@ class GetSearchResultsInput(BaseModel):
class GetSearchResultsTool(MoviePilotTool):
name: str = "get_search_results"
tags: list[str] = [
ToolTag.Read,
ToolTag.Resource,
]
description: str = "Get cached torrent search results from search_torrents with optional filters. Supports pagination with up to 50 results per page."
args_schema: Type[BaseModel] = GetSearchResultsInput

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.agent.tools.impl._plugin_tool_utils import (
get_plugin_snapshot,
install_plugin_runtime,
@@ -18,10 +19,8 @@ from app.log import logger
class InstallPluginInput(BaseModel):
"""安装插件工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
plugin_id: str = Field(
...,
description="Exact plugin ID to install. Use query_market_plugins first to find the correct plugin_id.",
@@ -38,6 +37,11 @@ class InstallPluginInput(BaseModel):
class InstallPluginTool(MoviePilotTool):
name: str = "install_plugin"
tags: list[str] = [
ToolTag.Write,
ToolTag.Plugin,
ToolTag.Admin,
]
description: str = (
"Install a plugin by exact plugin_id from the plugin market or local plugin repositories. "
"Use query_market_plugins first when you need filtering or discovery."

View File

@@ -8,6 +8,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.storage import StorageChain
from app.log import logger
from app.schemas.file import FileItem
@@ -16,7 +17,7 @@ from app.utils.string import StringUtils
class ListDirectoryInput(BaseModel):
"""查询文件系统目录内容工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
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')")
@@ -24,6 +25,11 @@ class ListDirectoryInput(BaseModel):
class ListDirectoryTool(MoviePilotTool):
name: str = "list_directory"
tags: list[str] = [
ToolTag.Read,
ToolTag.Directory,
ToolTag.File,
]
description: str = "List actual files and folders in a file system directory (NOT configuration). Shows files and subdirectories with their names, types, sizes, and modification times. Returns up to 20 items and the total count if there are more items. Use 'query_directory_settings' to query directory configuration settings."
args_schema: Type[BaseModel] = ListDirectoryInput

View File

@@ -6,20 +6,24 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.log import logger
class ListSlashCommandsInput(BaseModel):
"""查询所有可用斜杠命令工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
class ListSlashCommandsTool(MoviePilotTool):
name: str = "list_slash_commands"
tags: list[str] = [
ToolTag.Read,
ToolTag.SlashCommand,
ToolTag.Admin,
]
description: str = (
"List all available slash commands in the system, including system preset commands "
"(e.g. /cookiecloud, /sites, /subscribes, /downloading, /transfer, /restart, etc.) "

View File

@@ -5,6 +5,7 @@ from typing import Optional, Type, List
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.download import DownloadChain
from app.log import logger
@@ -12,10 +13,8 @@ from app.log import logger
class ModifyDownloadInput(BaseModel):
"""修改下载任务工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
hash: str = Field(
..., description="Task hash (can be obtained from query_download_tasks tool)"
)
@@ -39,6 +38,11 @@ class ModifyDownloadTool(MoviePilotTool):
"""修改下载任务工具"""
name: str = "modify_download"
tags: list[str] = [
ToolTag.Write,
ToolTag.Download,
ToolTag.Admin,
]
description: str = (
"Modify a download task in the downloader by task hash. "
"Supports: 1) Setting tags on a download task, "

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type, List
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.agent.tools.impl._filter_rule_utils import (
get_builtin_rules,
serialize_builtin_rule,
@@ -17,10 +18,8 @@ from app.log import logger
class QueryBuiltinFilterRulesInput(BaseModel):
"""查询内置过滤规则工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
rule_ids: Optional[List[str]] = Field(
None,
description="Optional list of built-in rule IDs to query. If omitted, return all built-in rules.",
@@ -29,6 +28,10 @@ class QueryBuiltinFilterRulesInput(BaseModel):
class QueryBuiltinFilterRulesTool(MoviePilotTool):
name: str = "query_builtin_filter_rules"
tags: list[str] = [
ToolTag.Read,
ToolTag.FilterRule,
]
description: str = (
"Query built-in filter rules defined by the backend filter module. "
"These rule IDs can be used directly inside rule_string expressions for filter rule groups. "

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type, List
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.agent.tools.impl._filter_rule_utils import (
collect_custom_rule_group_refs,
get_custom_rules,
@@ -18,10 +19,8 @@ from app.log import logger
class QueryCustomFilterRulesInput(BaseModel):
"""查询自定义过滤规则工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
rule_ids: Optional[List[str]] = Field(
None,
description="Optional list of custom rule IDs to query. If omitted, return all custom rules.",
@@ -34,6 +33,10 @@ class QueryCustomFilterRulesInput(BaseModel):
class QueryCustomFilterRulesTool(MoviePilotTool):
name: str = "query_custom_filter_rules"
tags: list[str] = [
ToolTag.Read,
ToolTag.FilterRule,
]
description: str = (
"Query custom filter rules stored in CustomFilterRules. "
"Custom rules can be referenced from rule_string expressions in filter rule groups. "

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.db.systemconfig_oper import SystemConfigOper
from app.log import logger
from app.schemas.types import SystemConfigKey
@@ -14,19 +15,23 @@ from app.schemas.types import SystemConfigKey
class QueryCustomIdentifiersInput(BaseModel):
"""查询自定义识别词工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
class QueryCustomIdentifiersTool(MoviePilotTool):
name: str = "query_custom_identifiers"
tags: list[str] = [
ToolTag.Read,
ToolTag.FilterRule,
ToolTag.Admin,
]
description: str = (
"Query all currently configured custom identifiers (自定义识别词). "
"Returns the list of identifier rules used for preprocessing torrent/file names before media recognition. "
"Use this tool to check existing rules before adding new ones to avoid duplicates."
)
require_admin: bool = True
args_schema: Type[BaseModel] = QueryCustomIdentifiersInput
def get_tool_message(self, **kwargs) -> Optional[str]:

View File

@@ -6,13 +6,14 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.helper.directory import DirectoryHelper
from app.log import logger
class QueryDirectorySettingsInput(BaseModel):
"""查询系统目录设置工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
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",
@@ -23,7 +24,14 @@ class QueryDirectorySettingsInput(BaseModel):
class QueryDirectorySettingsTool(MoviePilotTool):
name: str = "query_directory_settings"
tags: list[str] = [
ToolTag.Read,
ToolTag.Directory,
ToolTag.Settings,
ToolTag.Admin,
]
description: str = "Query system directory configuration settings (NOT file listings). Returns configured directory paths, storage types, transfer modes, and other directory-related settings. Use 'list_directory' to list actual files and folders in a directory."
require_admin: bool = True
args_schema: Type[BaseModel] = QueryDirectorySettingsInput
def get_tool_message(self, **kwargs) -> Optional[str]:

View File

@@ -0,0 +1,126 @@
"""查询 MoviePilot Doctor 诊断报告工具。"""
import json
from typing import Any, Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.doctor import run_doctor
from app.log import logger
class QueryDoctorReportInput(BaseModel):
"""查询 Doctor 诊断报告工具的输入参数模型。"""
explanation: Optional[str] = Field(
None,
description="Clear explanation of why this tool is being used in the current context",
)
deep: Optional[bool] = Field(
False,
description=(
"Whether to run deeper checks. When true, doctor may perform slower environment probes "
"such as PostgreSQL TCP connectivity checks."
),
)
include_details: Optional[bool] = Field(
True,
description=(
"Whether to include full doctor findings with details and context. Set false for a compact "
"summary when only overall status and finding titles are needed."
),
)
class QueryDoctorReportTool(MoviePilotTool):
"""
Doctor 离线诊断报告查询工具。
"""
name: str = "query_doctor_report"
tags: list[str] = [
ToolTag.Read,
ToolTag.System,
ToolTag.Admin,
]
description: str = (
"Run MoviePilot Doctor in read-only mode and return a structured diagnostic report for troubleshooting. "
"Use this tool when analyzing startup failures, Docker/runtime issues, port conflicts, dependency problems, "
"database health, frontend assets, safe mode, or recent log error clues. This tool never applies fixes."
)
require_admin: bool = True
args_schema: Type[BaseModel] = QueryDoctorReportInput
def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据查询参数生成友好的提示消息。"""
if kwargs.get("deep"):
return "运行 Doctor 深度诊断"
return "运行 Doctor 诊断"
@staticmethod
def _compact_report(report: dict[str, Any]) -> dict[str, Any]:
"""压缩诊断报告,保留 Agent 判断问题所需的核心字段。"""
return {
"schema_version": report.get("schema_version"),
"status": report.get("status"),
"generated_at": report.get("generated_at"),
"version": report.get("version"),
"environment": report.get("environment"),
"summary": report.get("summary"),
"findings": [
{
"id": item.get("id"),
"severity": item.get("severity"),
"status": item.get("status"),
"title": item.get("title"),
"fixable": item.get("fixable"),
"fixed": item.get("fixed"),
}
for item in report.get("findings") or []
if isinstance(item, dict)
],
}
@staticmethod
def _run_doctor_report(deep: bool = False) -> dict[str, Any]:
"""在线程池中运行只读 Doctor 诊断。"""
return run_doctor(deep=bool(deep)).to_dict()
async def run(
self,
deep: Optional[bool] = False,
include_details: Optional[bool] = True,
**kwargs,
) -> str:
"""
运行只读 Doctor 诊断并返回 JSON 字符串。
"""
logger.info(
f"执行工具: {self.name}, deep={bool(deep)}, include_details={bool(include_details)}"
)
try:
report = await self.run_blocking("default", self._run_doctor_report, bool(deep))
if not include_details:
report = self._compact_report(report)
return json.dumps(
{
"success": True,
"deep": bool(deep),
"include_details": bool(include_details),
"report": report,
},
ensure_ascii=False,
indent=2,
default=str,
)
except Exception as err:
logger.error(f"查询 Doctor 诊断报告失败: {err}", exc_info=True)
return json.dumps(
{
"success": False,
"message": f"查询 Doctor 诊断报告时发生错误: {str(err)}",
},
ensure_ascii=False,
)

View File

@@ -6,6 +6,7 @@ from typing import Any, Dict, List, Optional, Type, Union
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.download import DownloadChain
from app.db.downloadhistory_oper import DownloadHistoryOper
from app.log import logger
@@ -15,7 +16,7 @@ from app.schemas.types import TorrentStatus, media_type_to_agent
class QueryDownloadTasksInput(BaseModel):
"""查询下载工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
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",
@@ -27,6 +28,10 @@ class QueryDownloadTasksInput(BaseModel):
class QueryDownloadTasksTool(MoviePilotTool):
name: str = "query_download_tasks"
tags: list[str] = [
ToolTag.Read,
ToolTag.Download,
]
description: str = "Query download status and list download tasks. Can query all active downloads, or search for specific tasks by hash, title, or tag. Shows download progress, completion status, tags, and task details from configured downloaders."
args_schema: Type[BaseModel] = QueryDownloadTasksInput

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.db.systemconfig_oper import SystemConfigOper
from app.log import logger
from app.schemas.types import SystemConfigKey
@@ -13,12 +14,18 @@ from app.schemas.types import SystemConfigKey
class QueryDownloadersInput(BaseModel):
"""查询下载器工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
class QueryDownloadersTool(MoviePilotTool):
name: str = "query_downloaders"
tags: list[str] = [
ToolTag.Read,
ToolTag.Download,
ToolTag.Admin,
]
description: str = "Query downloader configuration and list all available downloaders. Shows downloader status, connection details, and configuration settings."
require_admin: bool = True
args_schema: Type[BaseModel] = QueryDownloadersInput
def get_tool_message(self, **kwargs) -> Optional[str]:

View File

@@ -6,13 +6,14 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.tmdb import TmdbChain
from app.log import logger
class QueryEpisodeScheduleInput(BaseModel):
"""查询剧集上映时间工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
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)")
@@ -20,6 +21,10 @@ class QueryEpisodeScheduleInput(BaseModel):
class QueryEpisodeScheduleTool(MoviePilotTool):
name: str = "query_episode_schedule"
tags: list[str] = [
ToolTag.Read,
ToolTag.Media,
]
description: str = "Query TV series episode air dates and schedule. Returns non-duplicated schedule fields, including episode list, air-date statistics, and per-episode metadata. Filters out episodes without air dates."
args_schema: Type[BaseModel] = QueryEpisodeScheduleInput

View File

@@ -6,8 +6,10 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.agent.tools.impl._plugin_tool_utils import (
DEFAULT_PLUGIN_CANDIDATE_LIMIT,
MAX_PLUGIN_CANDIDATE_LIMIT,
list_installed_plugins,
search_plugin_candidates,
summarize_candidates,
@@ -19,22 +21,25 @@ from app.log import logger
class QueryInstalledPluginsInput(BaseModel):
"""查询已安装插件工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
query: Optional[str] = Field(
None,
description="Optional keyword to filter installed plugins by plugin ID, name, description, or author.",
)
max_results: Optional[int] = Field(
DEFAULT_PLUGIN_CANDIDATE_LIMIT,
description="Maximum number of plugins to return. Defaults to 10.",
description="Maximum number of plugins to return. Defaults to 50, capped at 200.",
)
class QueryInstalledPluginsTool(MoviePilotTool):
name: str = "query_installed_plugins"
tags: list[str] = [
ToolTag.Read,
ToolTag.Plugin,
ToolTag.Admin,
]
description: str = (
"Query installed plugins in MoviePilot. Returns all installed plugins or filters them by keywords. "
"Use this tool to find the exact plugin_id before uninstall_plugin or other plugin management tools are used."
@@ -53,7 +58,10 @@ class QueryInstalledPluginsTool(MoviePilotTool):
def _clamp_results(max_results: Optional[int]) -> int:
if max_results is None:
return DEFAULT_PLUGIN_CANDIDATE_LIMIT
return max(1, min(int(max_results), 200))
try:
return max(1, min(int(max_results), MAX_PLUGIN_CANDIDATE_LIMIT))
except (TypeError, ValueError):
return DEFAULT_PLUGIN_CANDIDATE_LIMIT
async def run(
self,

View File

@@ -8,6 +8,7 @@ from typing import Optional, Type, Any
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.mediaserver import MediaServerChain
from app.helper.mediaserver import MediaServerHelper
from app.log import logger
@@ -76,7 +77,7 @@ def _build_tv_server_result(existing_seasons: OrderedDict, total_seasons: Ordere
class QueryLibraryExistsInput(BaseModel):
"""查询媒体库工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
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.")
media_type: Optional[str] = Field(None, description="Allowed values: movie, tv")
@@ -84,6 +85,11 @@ class QueryLibraryExistsInput(BaseModel):
class QueryLibraryExistsTool(MoviePilotTool):
name: str = "query_library_exists"
tags: list[str] = [
ToolTag.Read,
ToolTag.Library,
ToolTag.Media,
]
description: str = "Check whether media already exists in Plex, Emby, or Jellyfin by media ID. Results are grouped by media server; TV results include existing episodes, total episodes, and missing episodes/seasons. Requires tmdb_id or douban_id from search_media."
args_schema: Type[BaseModel] = QueryLibraryExistsInput

View File

@@ -7,6 +7,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.mediaserver import MediaServerChain
from app.helper.service import ServiceConfigHelper
from app.log import logger
@@ -17,10 +18,8 @@ PAGE_SIZE = 20
class QueryLibraryLatestInput(BaseModel):
"""查询媒体服务器最近入库影片工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
server: Optional[str] = Field(
None,
description="Media server name (optional, if not specified queries all enabled media servers)",
@@ -32,6 +31,11 @@ class QueryLibraryLatestInput(BaseModel):
class QueryLibraryLatestTool(MoviePilotTool):
name: str = "query_library_latest"
tags: list[str] = [
ToolTag.Read,
ToolTag.Library,
ToolTag.Media,
]
description: str = "Query the latest media items added to the media server (Plex, Emby, Jellyfin). Returns recently added movies and TV series with their titles, images, links, and other metadata. Supports pagination with 20 items per page."
args_schema: Type[BaseModel] = QueryLibraryLatestInput

View File

@@ -6,8 +6,10 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.agent.tools.impl._plugin_tool_utils import (
DEFAULT_PLUGIN_CANDIDATE_LIMIT,
MAX_PLUGIN_CANDIDATE_LIMIT,
load_market_plugins,
search_plugin_candidates,
summarize_candidates,
@@ -19,17 +21,15 @@ from app.log import logger
class QueryMarketPluginsInput(BaseModel):
"""查询插件市场工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
query: Optional[str] = Field(
None,
description="Optional keyword to filter plugin market results by plugin ID, name, description, or author.",
)
max_results: Optional[int] = Field(
DEFAULT_PLUGIN_CANDIDATE_LIMIT,
description="Maximum number of plugins to return. Defaults to 10.",
description="Maximum number of plugins to return. Defaults to 50, capped at 200.",
)
force_refresh: Optional[bool] = Field(
False,
@@ -39,6 +39,11 @@ class QueryMarketPluginsInput(BaseModel):
class QueryMarketPluginsTool(MoviePilotTool):
name: str = "query_market_plugins"
tags: list[str] = [
ToolTag.Read,
ToolTag.Plugin,
ToolTag.Admin,
]
description: str = (
"Query available plugins from the plugin market and local plugin repositories. "
"Can return the full plugin list or filter by keywords before install_plugin is used."
@@ -56,7 +61,10 @@ class QueryMarketPluginsTool(MoviePilotTool):
def _clamp_results(max_results: Optional[int]) -> int:
if max_results is None:
return DEFAULT_PLUGIN_CANDIDATE_LIMIT
return max(1, min(int(max_results), 200))
try:
return max(1, min(int(max_results), MAX_PLUGIN_CANDIDATE_LIMIT))
except (TypeError, ValueError):
return DEFAULT_PLUGIN_CANDIDATE_LIMIT
async def run(
self,

View File

@@ -6,14 +6,19 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.media import MediaChain
from app.log import logger
from app.schemas.types import MediaType
DIRECTOR_PREVIEW_LIMIT = 10
ACTOR_PREVIEW_LIMIT = 20
SEASON_PREVIEW_LIMIT = 100
class QueryMediaDetailInput(BaseModel):
"""查询媒体详情工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
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)")
media_type: str = Field(..., description="Allowed values: movie, tv")
@@ -21,6 +26,10 @@ class QueryMediaDetailInput(BaseModel):
class QueryMediaDetailTool(MoviePilotTool):
name: str = "query_media_detail"
tags: list[str] = [
ToolTag.Read,
ToolTag.Media,
]
description: str = "Query supplementary media details from TMDB by ID and media_type. Accepts tmdb_id or douban_id (at least one required). media_type accepts 'movie' or 'tv'. Returns non-duplicated detail fields such as status, genres, directors, actors, and season info for TV series."
args_schema: Type[BaseModel] = QueryMediaDetailInput
@@ -64,23 +73,23 @@ class QueryMediaDetailTool(MoviePilotTool):
genres = [g.get("name") for g in (mediainfo.genres or []) if g.get("name")]
# 精简 directors - 只保留姓名和职位
director_source = [d for d in (mediainfo.directors or []) if d.get("name")]
directors = [
{
"name": d.get("name"),
"job": d.get("job")
}
for d in (mediainfo.directors or [])
if d.get("name")
for d in director_source[:DIRECTOR_PREVIEW_LIMIT]
]
# 精简 actors - 只保留姓名和角色
actor_source = [a for a in (mediainfo.actors or []) if a.get("name")]
actors = [
{
"name": a.get("name"),
"character": a.get("character")
}
for a in (mediainfo.actors or [])
if a.get("name")
for a in actor_source[:ACTOR_PREVIEW_LIMIT]
]
# 构建基础媒体详情信息
@@ -88,12 +97,20 @@ class QueryMediaDetailTool(MoviePilotTool):
"status": mediainfo.status,
"genres": genres,
"directors": directors,
"actors": actors
"directors_total": len(director_source),
"directors_truncated": len(director_source) > DIRECTOR_PREVIEW_LIMIT,
"actors": actors,
"actors_total": len(actor_source),
"actors_truncated": len(actor_source) > ACTOR_PREVIEW_LIMIT,
}
# 如果是电视剧,添加电视剧特有信息
if mediainfo.type == MediaType.TV:
# 精简 season_info - 只保留基础摘要
season_source = [
s for s in (mediainfo.season_info or [])
if s.get("season_number") is not None
]
season_info = [
{
"season_number": s.get("season_number"),
@@ -101,8 +118,7 @@ class QueryMediaDetailTool(MoviePilotTool):
"episode_count": s.get("episode_count"),
"air_date": s.get("air_date")
}
for s in (mediainfo.season_info or [])
if s.get("season_number") is not None
for s in season_source[:SEASON_PREVIEW_LIMIT]
]
result.update({
@@ -110,7 +126,9 @@ class QueryMediaDetailTool(MoviePilotTool):
"number_of_episodes": mediainfo.number_of_episodes,
"first_air_date": mediainfo.first_air_date,
"last_air_date": mediainfo.last_air_date,
"season_info": season_info
"season_info": season_info,
"season_info_total": len(season_source),
"season_info_truncated": len(season_source) > SEASON_PREVIEW_LIMIT,
})
return json.dumps(result, ensure_ascii=False, indent=2)

View File

@@ -7,16 +7,15 @@ from pydantic import BaseModel, Field
from app.agent.runtime import agent_runtime_manager
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.log import logger
class QueryPersonasInput(BaseModel):
"""查询人格工具的输入参数模型。"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
query: Optional[str] = Field(
None,
description=(
@@ -28,6 +27,10 @@ class QueryPersonasInput(BaseModel):
class QueryPersonasTool(MoviePilotTool):
name: str = "query_personas"
tags: list[str] = [
ToolTag.Read,
ToolTag.Persona,
]
description: str = (
"List all available personas (人格) and show which one is currently active. "
"Use this before switching persona when the user asks for a different speaking style but does not name "

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.core.plugin import PluginManager
from app.log import logger
@@ -13,10 +14,8 @@ from app.log import logger
class QueryPluginCapabilitiesInput(BaseModel):
"""查询插件能力工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
plugin_id: Optional[str] = Field(
None,
description="Optional plugin ID to query capabilities for a specific plugin. "
@@ -27,6 +26,11 @@ class QueryPluginCapabilitiesInput(BaseModel):
class QueryPluginCapabilitiesTool(MoviePilotTool):
name: str = "query_plugin_capabilities"
tags: list[str] = [
ToolTag.Read,
ToolTag.Plugin,
ToolTag.Admin,
]
description: str = (
"Query the capabilities of installed plugins, including supported commands and scheduled services. "
"Commands are slash-commands (e.g. /xxx) that can be executed via the run_slash_command tool. "

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.agent.tools.impl._plugin_tool_utils import get_plugin_snapshot
from app.core.plugin import PluginManager
from app.log import logger
@@ -14,10 +15,8 @@ from app.log import logger
class QueryPluginConfigInput(BaseModel):
"""查询插件配置工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
plugin_id: str = Field(
...,
description="The plugin ID to query. Use query_installed_plugins first to discover valid plugin IDs.",
@@ -26,6 +25,11 @@ class QueryPluginConfigInput(BaseModel):
class QueryPluginConfigTool(MoviePilotTool):
name: str = "query_plugin_config"
tags: list[str] = [
ToolTag.Read,
ToolTag.Plugin,
ToolTag.Admin,
]
description: str = (
"Query the saved configuration of an installed plugin. "
"Returns the current saved config and, when available, the plugin's default config model. "

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.agent.tools.impl._plugin_tool_utils import (
PLUGIN_DATA_KEY_PREVIEW_LIMIT,
build_preview_payload,
@@ -18,10 +19,8 @@ from app.log import logger
class QueryPluginDataInput(BaseModel):
"""查询插件数据工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
plugin_id: str = Field(
...,
description="The plugin ID to query. Use query_installed_plugins first to discover valid plugin IDs.",
@@ -38,6 +37,11 @@ class QueryPluginDataInput(BaseModel):
class QueryPluginDataTool(MoviePilotTool):
name: str = "query_plugin_data"
tags: list[str] = [
ToolTag.Read,
ToolTag.Plugin,
ToolTag.Admin,
]
description: str = (
"Query persisted data of an installed plugin. "
"Optionally specify a key to read a single data item; otherwise all plugin data entries are returned. "

View File

@@ -7,18 +7,21 @@ import cn2an
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.core.context import MediaInfo
from app.helper.subscribe import SubscribeHelper
from app.helper.server import MoviePilotServerHelper
from app.log import logger
from app.schemas.types import MediaType, media_type_to_agent
MAX_PAGE_SIZE = 50
class QueryPopularSubscribesInput(BaseModel):
"""查询热门订阅工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
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)")
count: Optional[int] = Field(30, description="Number of items per page (default: 30, max: 50)")
min_sub: Optional[int] = Field(None, description="Minimum number of subscribers filter (optional, e.g., 5)")
genre_id: Optional[int] = Field(None, description="Filter by genre ID (optional)")
min_rating: Optional[float] = Field(None, description="Minimum rating filter (optional, e.g., 7.5)")
@@ -28,6 +31,11 @@ class QueryPopularSubscribesInput(BaseModel):
class QueryPopularSubscribesTool(MoviePilotTool):
name: str = "query_popular_subscribes"
tags: list[str] = [
ToolTag.Read,
ToolTag.Subscription,
ToolTag.Recommendation,
]
description: str = "Query popular subscriptions based on user shared data. Shows media with the most subscribers, supports filtering by genre, rating, minimum subscribers, and pagination."
args_schema: Type[BaseModel] = QueryPopularSubscribesInput
@@ -69,12 +77,13 @@ class QueryPopularSubscribesTool(MoviePilotTool):
page = 1
if count is None or count < 1:
count = 30
# 外部统计接口支持传入 count这里做硬上限避免 Agent 一次拉取过多结果。
count = min(count, MAX_PAGE_SIZE)
media_type_enum = MediaType.from_agent(media_type)
if not media_type_enum:
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'"
subscribe_helper = SubscribeHelper()
subscribes = await subscribe_helper.async_get_statistic(
subscribes = await MoviePilotServerHelper.async_get_subscribe_statistic(
stype=media_type_enum.to_agent(),
page=page,
count=count,

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type, List
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.agent.tools.impl._filter_rule_utils import (
collect_rule_group_usages,
get_rule_groups,
@@ -18,10 +19,8 @@ from app.log import logger
class QueryRuleGroupsInput(BaseModel):
"""查询规则组工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
group_names: Optional[List[str]] = Field(
None,
description="Optional list of rule group names to query. If omitted, return all rule groups.",
@@ -34,6 +33,10 @@ class QueryRuleGroupsInput(BaseModel):
class QueryRuleGroupsTool(MoviePilotTool):
name: str = "query_rule_groups"
tags: list[str] = [
ToolTag.Read,
ToolTag.FilterRule,
]
description: str = (
"Query filter rule groups (过滤规则组 / 优先级规则组). "
"Each rule group contains a rule_string made of built-in rules and/or custom rules. "

View File

@@ -6,16 +6,21 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.log import logger
class QuerySchedulersInput(BaseModel):
"""查询定时服务工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
class QuerySchedulersTool(MoviePilotTool):
name: str = "query_schedulers"
tags: list[str] = [
ToolTag.Read,
ToolTag.Scheduler,
]
description: str = "Query scheduled tasks and list all available scheduler jobs. Shows job status, next run time, and provider information."
args_schema: Type[BaseModel] = QuerySchedulersInput

View File

@@ -6,19 +6,26 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.db import AsyncSessionFactory
from app.db.models.site import Site
from app.db.models.siteuserdata import SiteUserData
from app.log import logger
SITE_USERDATA_DETAIL_PREVIEW_LIMIT = 10
def _preview_list(value, limit: int = SITE_USERDATA_DETAIL_PREVIEW_LIMIT) -> tuple[list, int, bool]:
"""返回列表字段预览,避免做种明细或未读消息一次性撑大工具结果。"""
items = list(value) if isinstance(value, (list, tuple)) else []
return items[:limit], len(items), len(items) > limit
class QuerySiteUserdataInput(BaseModel):
"""查询站点用户数据工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
site_id: int = Field(
...,
description="The ID of the site to query user data for (can be obtained from query_sites tool)",
@@ -31,6 +38,11 @@ class QuerySiteUserdataInput(BaseModel):
class QuerySiteUserdataTool(MoviePilotTool):
name: str = "query_site_userdata"
tags: list[str] = [
ToolTag.Read,
ToolTag.Site,
ToolTag.Admin,
]
description: str = "Query user data for a specific site including username, user level, upload/download statistics, seeding information, bonus points, and other account details. Supports querying data for a specific date or latest data."
require_admin: bool = True
args_schema: Type[BaseModel] = QuerySiteUserdataInput
@@ -110,6 +122,13 @@ class QuerySiteUserdataTool(MoviePilotTool):
else 0
)
seeding_preview, seeding_count, seeding_truncated = _preview_list(
user_data.seeding_info
)
unread_preview, unread_count, unread_truncated = _preview_list(
user_data.message_unread_contents
)
user_data_dict = {
"domain": user_data.domain,
"name": user_data.name,
@@ -131,13 +150,13 @@ class QuerySiteUserdataTool(MoviePilotTool):
"seeding_size_gb": round(seeding_size_gb, 2),
"leeching_size": user_data.leeching_size,
"leeching_size_gb": round(leeching_size_gb, 2),
"seeding_info": user_data.seeding_info
if user_data.seeding_info
else [],
"seeding_info_count": seeding_count,
"seeding_info": seeding_preview,
"seeding_info_truncated": seeding_truncated,
"message_unread": user_data.message_unread,
"message_unread_contents": user_data.message_unread_contents
if user_data.message_unread_contents
else [],
"message_unread_contents_count": unread_count,
"message_unread_contents": unread_preview,
"message_unread_contents_truncated": unread_truncated,
"err_msg": user_data.err_msg,
"updated_day": user_data.updated_day,
"updated_time": user_data.updated_time,

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.db.site_oper import SiteOper
from app.log import logger
@@ -13,10 +14,8 @@ from app.log import logger
class QuerySitesInput(BaseModel):
"""查询站点工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
status: Optional[str] = Field(
"all",
description="Filter sites by status: 'active' for enabled sites, 'inactive' for disabled sites, 'all' for all sites",
@@ -28,6 +27,11 @@ class QuerySitesInput(BaseModel):
class QuerySitesTool(MoviePilotTool):
name: str = "query_sites"
tags: list[str] = [
ToolTag.Read,
ToolTag.Site,
ToolTag.Admin,
]
description: str = "Query site status and list all configured sites. Shows site name, domain, status, priority, and basic configuration. Site priority (pri): smaller values have higher priority (e.g., pri=1 has higher priority than pri=10)."
require_admin: bool = True
args_schema: Type[BaseModel] = QuerySitesInput

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.db import AsyncSessionFactory
from app.db.models.subscribehistory import SubscribeHistory
from app.log import logger
@@ -17,10 +18,8 @@ PAGE_SIZE = 20
class QuerySubscribeHistoryInput(BaseModel):
"""查询订阅历史工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
media_type: Optional[str] = Field(
"all", description="Allowed values: movie, tv, all"
)
@@ -35,6 +34,10 @@ class QuerySubscribeHistoryInput(BaseModel):
class QuerySubscribeHistoryTool(MoviePilotTool):
name: str = "query_subscribe_history"
tags: list[str] = [
ToolTag.Read,
ToolTag.Subscription,
]
description: str = "Query subscription history records. Shows completed subscriptions with their details including name, type, rating, completion date, and other subscription information. Supports filtering by media type and name. Supports pagination with 20 records per page."
args_schema: Type[BaseModel] = QuerySubscribeHistoryInput

View File

@@ -6,16 +6,19 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.helper.subscribe import SubscribeHelper
from app.agent.tools.tags import ToolTag
from app.helper.server import MoviePilotServerHelper
from app.log import logger
MAX_PAGE_SIZE = 50
class QuerySubscribeSharesInput(BaseModel):
"""查询订阅分享工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
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)")
count: Optional[int] = Field(30, description="Number of items per page (default: 30, max: 50)")
genre_id: Optional[int] = Field(None, description="Filter by genre ID (optional)")
min_rating: Optional[float] = Field(None, description="Minimum rating filter (optional, e.g., 7.5)")
max_rating: Optional[float] = Field(None, description="Maximum rating filter (optional, e.g., 10.0)")
@@ -24,6 +27,10 @@ class QuerySubscribeSharesInput(BaseModel):
class QuerySubscribeSharesTool(MoviePilotTool):
name: str = "query_subscribe_shares"
tags: list[str] = [
ToolTag.Read,
ToolTag.Subscription,
]
description: str = "Query shared subscriptions from other users. Shows popular subscriptions shared by the community with filtering and pagination support."
args_schema: Type[BaseModel] = QuerySubscribeSharesInput
@@ -63,9 +70,10 @@ class QuerySubscribeSharesTool(MoviePilotTool):
page = 1
if count is None or count < 1:
count = 30
# 订阅分享是外部列表型结果,限制单页大小能降低工具上下文占用。
count = min(count, MAX_PAGE_SIZE)
subscribe_helper = SubscribeHelper()
shares = await subscribe_helper.async_get_shares(
shares = await MoviePilotServerHelper.async_get_subscribe_shares(
name=name,
page=page,
count=count,

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.db.subscribe_oper import SubscribeOper
from app.log import logger
from app.schemas.subscribe import Subscribe as SubscribeSchema
@@ -33,6 +34,9 @@ QUERY_SUBSCRIBE_OUTPUT_FIELDS = [
"sites",
"downloader",
"best_version",
"best_version_full",
"current_priority",
"episode_priority",
"save_path",
"custom_words",
"media_category",
@@ -44,10 +48,8 @@ QUERY_SUBSCRIBE_OUTPUT_FIELDS = [
class QuerySubscribesInput(BaseModel):
"""查询订阅工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
status: Optional[str] = Field(
"all",
description="Filter subscriptions by status: 'R' for enabled subscriptions, 'S' for paused ones, 'all' for all subscriptions",
@@ -70,6 +72,10 @@ class QuerySubscribesInput(BaseModel):
class QuerySubscribesTool(MoviePilotTool):
name: str = "query_subscribes"
tags: list[str] = [
ToolTag.Read,
ToolTag.Subscription,
]
description: str = "Query subscription status and list user subscriptions. Returns full subscription parameters for each matched subscription. Supports pagination with 100 items per page."
args_schema: Type[BaseModel] = QuerySubscribesInput

View File

@@ -0,0 +1,191 @@
"""统一查询系统设置工具。"""
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.agent.tools.impl._system_setting_utils import (
SettingSpec,
list_setting_specs,
resolve_setting_spec,
)
from app.core.config import settings
from app.db.systemconfig_oper import SystemConfigOper
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=(
"Exact setting key to query. Supports Settings field names like 'APP_DOMAIN' or 'TMDB_API_KEY', "
"SystemConfigKey values like 'Downloaders' or 'MediaServers', enum names, and some single-key aliases "
"such as 'downloaders', 'directories', 'search_sites', 'subscribe_sites', 'site_auth', 'ai_agent', "
"and 'custom_identifiers'."
),
)
group: Optional[str] = Field(
"all",
description=(
"Optional group filter when setting_key is not provided. Supports 'all', 'settings', 'systemconfig', "
"and category aliases such as 'downloaders', 'media_servers', 'notifications', 'notification_switches', "
"'storages', 'directories', 'search_sites', 'subscribe_sites', 'site_auth', 'ai_agent', 'filter_rules', "
"'subscribe_defaults', 'plugins', and 'custom_identifiers'. Chinese aliases are also accepted."
),
)
keyword: Optional[str] = Field(
None,
description=(
"Optional keyword used to fuzzy match setting keys, group names, or labels when listing settings."
),
)
include_values: Optional[bool] = Field(
None,
description=(
"Whether to include full setting values. Default behavior: when a single setting is matched it returns the full value; "
"when multiple settings are matched it returns summaries only unless this is explicitly set to true."
),
)
class QuerySystemSettingsTool(MoviePilotTool):
name: str = "query_system_settings"
tags: list[str] = [
ToolTag.Read,
ToolTag.System,
ToolTag.Settings,
ToolTag.Admin,
]
description: str = (
"Query system settings across both the basic Settings module and all SystemConfig-backed categories. "
"Use this tool to inspect downloaders, media servers, notification channels, storages, directories, search-site ranges, "
"subscribe-site ranges, site auth params, AI agent config, and any other system setting before making changes."
)
require_admin: bool = True
args_schema: Type[BaseModel] = QuerySystemSettingsInput
def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据查询参数生成友好的提示消息。"""
setting_key = kwargs.get("setting_key")
group = kwargs.get("group", "all")
keyword = kwargs.get("keyword")
if setting_key:
return f"查询系统设置: {setting_key}"
if keyword:
return f"筛选系统设置: {group} / {keyword}"
return f"查询系统设置分组: {group}"
@staticmethod
def _load_setting_value(spec: SettingSpec):
if spec.source == "settings":
return getattr(settings, spec.key)
return SystemConfigOper().get(spec.key)
@staticmethod
def _summarize_value(value) -> dict:
summary = {
"has_value": value is not None,
"value_type": type(value).__name__,
}
if isinstance(value, list):
summary["item_count"] = len(value)
if value:
summary["item_type"] = type(value[0]).__name__
elif isinstance(value, dict):
keys = list(value.keys())
summary["item_count"] = len(keys)
summary["keys_preview"] = keys[:10]
if len(keys) > 10:
summary["keys_truncated"] = True
elif isinstance(value, str):
summary["length"] = len(value)
preview = value[:200]
if preview:
summary["value_preview"] = preview
if len(value) > len(preview):
summary["value_truncated"] = True
elif value is not None:
summary["value_preview"] = value
return summary
async def run(
self,
setting_key: Optional[str] = None,
group: Optional[str] = "all",
keyword: Optional[str] = None,
include_values: Optional[bool] = None,
**kwargs,
) -> str:
logger.info(
"执行工具: %s, setting_key=%s, group=%s, keyword=%s",
self.name,
setting_key,
group,
keyword,
)
try:
if setting_key:
spec = resolve_setting_spec(setting_key)
if not spec:
return json.dumps(
{
"success": False,
"message": f"系统设置项 '{setting_key}' 不存在",
},
ensure_ascii=False,
)
specs = [spec]
else:
specs = list_setting_specs(group=group, keyword=keyword)
if not specs:
return json.dumps(
{
"success": False,
"message": "没有找到匹配的系统设置项",
},
ensure_ascii=False,
)
should_include_values = (
include_values if include_values is not None else len(specs) == 1
)
settings_payload = []
for spec in specs:
value = self._load_setting_value(spec)
item = {
"setting_key": spec.key,
"source": spec.source,
"group": spec.group,
"label": spec.label,
}
item.update(self._summarize_value(value))
if should_include_values:
item["value"] = value
settings_payload.append(item)
return json.dumps(
{
"success": True,
"matched_count": len(settings_payload),
"include_values": should_include_values,
"settings": settings_payload,
},
ensure_ascii=False,
indent=2,
default=str,
)
except Exception as e:
logger.error(f"查询系统设置失败: {e}", exc_info=True)
return json.dumps(
{"success": False, "message": f"查询系统设置时发生错误: {str(e)}"},
ensure_ascii=False,
)

View File

@@ -3,19 +3,20 @@
import json
from typing import Optional, Type
import jieba
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.db import AsyncSessionFactory
from app.db.models.transferhistory import TransferHistory
from app.log import logger
from app.schemas.types import media_type_to_agent
from app.utils.jieba import cut as jieba_cut
class QueryTransferHistoryInput(BaseModel):
"""查询整理历史记录工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
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')")
@@ -24,6 +25,10 @@ class QueryTransferHistoryInput(BaseModel):
class QueryTransferHistoryTool(MoviePilotTool):
name: str = "query_transfer_history"
tags: list[str] = [
ToolTag.Read,
ToolTag.Transfer,
]
description: str = "Query file transfer history records. Shows transfer status, source and destination paths, media information, and transfer details. Supports filtering by title and status."
args_schema: Type[BaseModel] = QueryTransferHistoryInput
@@ -62,15 +67,15 @@ class QueryTransferHistoryTool(MoviePilotTool):
if page is None or page < 1:
page = 1
# 每页记录数
count = 50
# 每页固定 30 条,与工具说明保持一致,避免整理路径等字段撑大上下文。
count = 30
# 获取数据库会话
async with AsyncSessionFactory() as db:
# 处理标题搜索
if title:
# 使用 jieba 分词处理标题
words = jieba.cut(title, HMM=False)
# 使用统一分词封装处理标题,便于替换底层实现。
words = jieba_cut(title, HMM=False)
title_search = "%".join(words)
# 查询记录
result = await TransferHistory.async_list_by_title(

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.db import AsyncSessionFactory
from app.db.workflow_oper import WorkflowOper
from app.log import logger
@@ -13,7 +14,7 @@ from app.log import logger
class QueryWorkflowsInput(BaseModel):
"""查询工作流工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
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')")
@@ -21,6 +22,10 @@ class QueryWorkflowsInput(BaseModel):
class QueryWorkflowsTool(MoviePilotTool):
name: str = "query_workflows"
tags: list[str] = [
ToolTag.Read,
ToolTag.Workflow,
]
description: str = "Query workflow list and status. Shows workflow name, description, trigger type, state, execution count, and other workflow details. Supports filtering by state, name, and trigger type."
args_schema: Type[BaseModel] = QueryWorkflowsInput
@@ -115,9 +120,7 @@ class QueryWorkflowsTool(MoviePilotTool):
"last_time": wf.last_time,
"current_action": wf.current_action
}
# 如果有结果,添加结果信息
if wf.result:
simplified["result"] = wf.result
# wf.result 往往是执行日志或上下文快照,不适合作为列表查询结果返回。
simplified_workflows.append(simplified)
result_json = json.dumps(simplified_workflows, ensure_ascii=False, indent=2)

View File

@@ -7,6 +7,7 @@ from anyio import Path as AsyncPath
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.log import logger
# 最大读取大小 50KB
@@ -22,6 +23,10 @@ class ReadFileInput(BaseModel):
class ReadFileTool(MoviePilotTool):
name: str = "read_file"
tags: list[str] = [
ToolTag.Read,
ToolTag.File,
]
description: str = "Read the content of a text file. Supports reading by line range. Each read is limited to 50KB; content exceeding this limit will be truncated."
args_schema: Type[BaseModel] = ReadFileInput

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.media import MediaChain
from app.core.context import Context
from app.core.metainfo import MetaInfo
@@ -15,7 +16,7 @@ from app.schemas.types import media_type_to_agent
class RecognizeMediaInput(BaseModel):
"""识别媒体信息工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
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)")
@@ -23,6 +24,11 @@ class RecognizeMediaInput(BaseModel):
class RecognizeMediaTool(MoviePilotTool):
name: str = "recognize_media"
tags: list[str] = [
ToolTag.Read,
ToolTag.Media,
ToolTag.Metadata,
]
description: str = "Extract/identify media information from torrent titles or file paths (NOT database search). Supports two modes: 1) Extract from torrent title and optional subtitle, 2) Extract from file path. Returns detailed media information. Use 'search_media' to search TMDB database, or 'scrape_metadata' to generate metadata files for existing files."
args_schema: Type[BaseModel] = RecognizeMediaInput
@@ -49,8 +55,7 @@ class RecognizeMediaTool(MoviePilotTool):
try:
media_chain = MediaChain()
context = None
# 根据提供的参数选择识别方式
if path:
# 文件路径识别
@@ -60,7 +65,10 @@ class RecognizeMediaTool(MoviePilotTool):
"message": "文件路径不能为空"
}, ensure_ascii=False)
context = await media_chain.async_recognize_by_path(path)
context = await media_chain.async_recognize_by_path(
path,
obtain_images=False,
)
if context:
return self._format_context_result(context, "文件")
else:
@@ -73,7 +81,10 @@ class RecognizeMediaTool(MoviePilotTool):
elif title:
# 种子标题识别
metainfo = MetaInfo(title, subtitle)
mediainfo = await media_chain.async_recognize_by_meta(metainfo)
mediainfo = await media_chain.async_recognize_by_meta(
metainfo,
obtain_images=False,
)
if mediainfo:
context = Context(meta_info=metainfo, media_info=mediainfo)
return self._format_context_result(context, "种子")

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.agent.tools.impl._plugin_tool_utils import (
get_plugin_snapshot,
reload_plugin_runtime,
@@ -16,10 +17,8 @@ from app.log import logger
class ReloadPluginInput(BaseModel):
"""重载插件工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
plugin_id: str = Field(
...,
description="The plugin ID to reload so the latest saved config takes effect.",
@@ -28,6 +27,11 @@ class ReloadPluginInput(BaseModel):
class ReloadPluginTool(MoviePilotTool):
name: str = "reload_plugin"
tags: list[str] = [
ToolTag.Write,
ToolTag.Plugin,
ToolTag.Admin,
]
description: str = (
"Reload an installed plugin so its latest saved configuration takes effect. "
"This also refreshes the plugin's registered commands, scheduled services, and API routes."

View File

@@ -5,16 +5,15 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.log import logger
class RunSchedulerInput(BaseModel):
"""运行定时服务工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
job_id: str = Field(
...,
description="The ID of the scheduled job to run (can be obtained from query_schedulers tool)",
@@ -23,6 +22,11 @@ class RunSchedulerInput(BaseModel):
class RunSchedulerTool(MoviePilotTool):
name: str = "run_scheduler"
tags: list[str] = [
ToolTag.Write,
ToolTag.Scheduler,
ToolTag.Admin,
]
description: str = "Manually trigger a scheduled task to run immediately. This will execute the specified scheduler job by its ID."
args_schema: Type[BaseModel] = RunSchedulerInput
require_admin: bool = True

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.core.event import eventmanager
from app.log import logger
from app.schemas.types import EventType, MessageChannel
@@ -14,10 +15,8 @@ from app.schemas.types import EventType, MessageChannel
class RunSlashCommandInput(BaseModel):
"""运行斜杠命令工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
command: str = Field(
...,
description="The slash command to execute, e.g. '/cookiecloud'. "
@@ -29,6 +28,11 @@ class RunSlashCommandInput(BaseModel):
class RunSlashCommandTool(MoviePilotTool):
name: str = "run_slash_command"
tags: list[str] = [
ToolTag.Write,
ToolTag.SlashCommand,
ToolTag.Admin,
]
description: str = (
"Execute a slash command (system or plugin) by sending a CommandExcute event. "
"This tool supports ALL registered slash commands, including: "

View File

@@ -5,6 +5,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.workflow import WorkflowChain
from app.db import AsyncSessionFactory
from app.db.workflow_oper import WorkflowOper
@@ -14,10 +15,8 @@ from app.log import logger
class RunWorkflowInput(BaseModel):
"""执行工作流工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
workflow_id: int = Field(
..., description="Workflow ID (can be obtained from query_workflows tool)"
)
@@ -29,6 +28,11 @@ class RunWorkflowInput(BaseModel):
class RunWorkflowTool(MoviePilotTool):
name: str = "run_workflow"
tags: list[str] = [
ToolTag.Write,
ToolTag.Workflow,
ToolTag.Admin,
]
description: str = "Execute a specific workflow manually by workflow ID. Supports running from the beginning or continuing from the last executed action."
args_schema: Type[BaseModel] = RunWorkflowInput
require_admin: bool = True

View File

@@ -7,8 +7,8 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.media import MediaChain
from app.core.metainfo import MetaInfoPath
from app.log import logger
from app.schemas import FileItem
@@ -16,10 +16,8 @@ from app.schemas import FileItem
class ScrapeMetadataInput(BaseModel):
"""刮削媒体元数据工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
path: str = Field(
...,
description="Path to the file or directory to scrape metadata for (e.g., '/path/to/file.mkv' or '/path/to/directory')",
@@ -36,6 +34,13 @@ class ScrapeMetadataInput(BaseModel):
class ScrapeMetadataTool(MoviePilotTool):
name: str = "scrape_metadata"
tags: list[str] = [
ToolTag.Write,
ToolTag.Media,
ToolTag.Metadata,
ToolTag.File,
ToolTag.Admin,
]
description: str = "Generate metadata files (NFO files, posters, backgrounds, etc.) for existing media files or directories. Automatically recognizes media information from the file path and creates metadata files. Supports both local and remote storage. Use 'search_media' to search TMDB database, or 'recognize_media' to extract info from torrent titles/file paths without generating files."
require_admin: bool = True
args_schema: Type[BaseModel] = ScrapeMetadataInput
@@ -80,8 +85,7 @@ class ScrapeMetadataTool(MoviePilotTool):
# 检查本地存储路径是否存在
if storage == "local":
scrape_path = Path(path)
if not scrape_path.exists():
if not Path(path).exists():
return json.dumps(
{"success": False, "message": f"刮削路径不存在: {path}"},
ensure_ascii=False,
@@ -89,11 +93,12 @@ class ScrapeMetadataTool(MoviePilotTool):
# 识别媒体信息
media_chain = MediaChain()
scrape_path = Path(path)
meta = MetaInfoPath(scrape_path)
mediainfo = await media_chain.async_recognize_by_meta(meta)
context = await media_chain.async_recognize_by_path(
path,
obtain_images=True,
)
if not mediainfo:
if not context or not context.media_info:
return json.dumps(
{
"success": False,
@@ -108,8 +113,8 @@ class ScrapeMetadataTool(MoviePilotTool):
"storage",
media_chain.scrape_metadata,
fileitem=fileitem,
meta=meta,
mediainfo=mediainfo,
meta=context.meta_info,
mediainfo=context.media_info,
overwrite=overwrite,
)
@@ -119,11 +124,11 @@ class ScrapeMetadataTool(MoviePilotTool):
"message": f"{path} 刮削完成",
"path": path,
"media_info": {
"title": mediainfo.title,
"year": mediainfo.year,
"type": mediainfo.type.value if mediainfo.type else None,
"tmdb_id": mediainfo.tmdb_id,
"season": mediainfo.season,
"title": context.media_info.title,
"year": context.media_info.year,
"type": context.media_info.type.value if context.media_info.type else None,
"tmdb_id": context.media_info.tmdb_id,
"season": context.media_info.season,
},
},
ensure_ascii=False,

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.media import MediaChain
from app.log import logger
from app.schemas.types import MediaType, media_type_to_agent
@@ -13,7 +14,7 @@ from app.schemas.types import MediaType, media_type_to_agent
class SearchMediaInput(BaseModel):
"""搜索媒体工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
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,
@@ -24,6 +25,10 @@ class SearchMediaInput(BaseModel):
class SearchMediaTool(MoviePilotTool):
name: str = "search_media"
tags: list[str] = [
ToolTag.Read,
ToolTag.Media,
]
description: str = "Search TMDB database for media resources (movies, TV shows, anime, etc.) by title, year, type, and other criteria. Returns detailed media information from TMDB. Use 'recognize_media' to extract info from torrent titles/file paths, or 'scrape_metadata' to generate metadata files."
args_schema: Type[BaseModel] = SearchMediaInput
@@ -73,7 +78,7 @@ class SearchMediaTool(MoviePilotTool):
filtered_results.append(result)
if filtered_results:
# 限制最多30条结果
# 搜索结果只返回前 30 条,后续可通过更精确的年份/类型条件缩小范围。
total_count = len(filtered_results)
limited_results = filtered_results[:30]
# 精简字段,只保留关键信息
@@ -96,8 +101,8 @@ class SearchMediaTool(MoviePilotTool):
simplified_results.append(simplified)
result_json = json.dumps(simplified_results, ensure_ascii=False, indent=2)
# 如果结果被裁剪,添加提示信息
if total_count > 100:
return f"注意:搜索结果共找到 {total_count} 条,为节省上下文空间,仅显示前 100 条结果。\n\n{result_json}"
if total_count > len(limited_results):
return f"注意:搜索结果共找到 {total_count} 条,为节省上下文空间,仅显示前 {len(limited_results)} 条结果。\n\n{result_json}"
return result_json
else:
return f"未找到符合条件的媒体资源: {title}"

View File

@@ -6,18 +6,23 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.media import MediaChain
from app.log import logger
class SearchPersonInput(BaseModel):
"""搜索人物工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
name: str = Field(..., description="The name of the person to search for (e.g., 'Tom Hanks', '周杰伦')")
class SearchPersonTool(MoviePilotTool):
name: str = "search_person"
tags: list[str] = [
ToolTag.Read,
ToolTag.Media,
]
description: str = "Search for person information including actors, directors, etc. Supports searching by name. Returns detailed person information from TMDB, Douban, or Bangumi database."
args_schema: Type[BaseModel] = SearchPersonInput
@@ -35,7 +40,7 @@ class SearchPersonTool(MoviePilotTool):
persons = await media_chain.async_search_persons(name=name)
if persons:
# 限制最多30条结果
# 人物搜索结果只返回前 30 条,避免 biography/别名等字段挤占上下文。
total_count = len(persons)
limited_persons = persons[:30]
# 精简字段,只保留关键信息
@@ -72,8 +77,8 @@ class SearchPersonTool(MoviePilotTool):
result_json = json.dumps(simplified_results, ensure_ascii=False, indent=2)
# 如果结果被裁剪,添加提示信息
if total_count > 50:
return f"注意:搜索结果共找到 {total_count} 条,为节省上下文空间,仅显示前 50 条结果。\n\n{result_json}"
if total_count > len(limited_persons):
return f"注意:搜索结果共找到 {total_count} 条,为节省上下文空间,仅显示前 {len(limited_persons)} 条结果。\n\n{result_json}"
return result_json
else:
return f"未找到相关人物信息: {name}"

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.douban import DoubanChain
from app.chain.tmdb import TmdbChain
from app.chain.bangumi import BangumiChain
@@ -14,7 +15,7 @@ from app.log import logger
class SearchPersonCreditsInput(BaseModel):
"""搜索演员参演作品工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
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)")
@@ -22,6 +23,10 @@ class SearchPersonCreditsInput(BaseModel):
class SearchPersonCreditsTool(MoviePilotTool):
name: str = "search_person_credits"
tags: list[str] = [
ToolTag.Read,
ToolTag.Media,
]
description: str = "Search for films and TV shows that a person/actor has appeared in (filmography). Supports searching by person ID from TMDB, Douban, or Bangumi database. Returns a list of media works the person has participated in."
args_schema: Type[BaseModel] = SearchPersonCreditsInput

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type, List
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.subscribe import SubscribeChain
from app.db.subscribe_oper import SubscribeOper
from app.log import logger
@@ -14,7 +15,7 @@ from app.schemas.types import media_type_to_agent
class SearchSubscribeInput(BaseModel):
"""搜索订阅缺失剧集工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
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,
@@ -23,6 +24,12 @@ class SearchSubscribeInput(BaseModel):
class SearchSubscribeTool(MoviePilotTool):
name: str = "search_subscribe"
tags: list[str] = [
ToolTag.Read,
ToolTag.Write,
ToolTag.Subscription,
ToolTag.Resource,
]
description: str = "Search for missing episodes/resources for a specific subscription. This tool will search torrent sites for the missing episodes of the subscription and automatically download matching resources. Use this when a user wants to search for missing episodes of a specific subscription."
args_schema: Type[BaseModel] = SearchSubscribeInput

View File

@@ -6,6 +6,7 @@ from typing import List, Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.search import SearchChain
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.sites import SitesHelper
@@ -19,7 +20,7 @@ from ._torrent_search_utils import (
class SearchTorrentsInput(BaseModel):
"""搜索种子工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
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.")
media_type: Optional[str] = Field(None, description="Allowed values: movie, tv")
@@ -29,6 +30,12 @@ class SearchTorrentsInput(BaseModel):
class SearchTorrentsTool(MoviePilotTool):
name: str = "search_torrents"
tags: list[str] = [
ToolTag.Read,
ToolTag.Resource,
ToolTag.Site,
ToolTag.Media,
]
description: str = ("Search for torrent files by media ID across configured indexer sites, cache the matched results, "
"and return available filter options for follow-up selection. "
"Requires tmdb_id or douban_id (can be obtained from search_media tool) for accurate matching.")

View File

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

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field, model_validator
from app.agent.tools.base import MoviePilotTool, ToolChain
from app.agent.tools.tags import ToolTag
from app.log import logger
from app.schemas import Notification, NotificationType
from app.schemas.message import ChannelCapabilityManager, ChannelCapability
@@ -15,10 +16,8 @@ from app.schemas.types import MessageChannel
class SendLocalFileInput(BaseModel):
"""发送本地附件工具输入。"""
explanation: str = Field(
...,
description="Clear explanation of why sending this local file helps the user",
)
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",
@@ -45,6 +44,11 @@ class SendLocalFileInput(BaseModel):
class SendLocalFileTool(MoviePilotTool):
name: str = "send_local_file"
tags: list[str] = [
ToolTag.Write,
ToolTag.Message,
ToolTag.File,
]
sends_message: bool = True
description: str = (
"Send a local image or file from the server filesystem to the current user. "

View File

@@ -5,16 +5,15 @@ from typing import Optional, Type
from pydantic import BaseModel, Field, model_validator
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.log import logger
class SendMessageInput(BaseModel):
"""发送消息工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
message: Optional[str] = Field(
None,
description="The message content to send to the user (should be clear and informative)",
@@ -37,6 +36,11 @@ class SendMessageInput(BaseModel):
class SendMessageTool(MoviePilotTool):
name: str = "send_message"
tags: list[str] = [
ToolTag.Write,
ToolTag.Message,
ToolTag.Admin,
]
sends_message: bool = True
description: str = "Send notification message to the user through configured notification channels (Telegram, Slack, WeChat, etc.). Supports optional image_url on channels that can send images. Used to inform users about operation results, errors, important updates, or proactively send a relevant image."
args_schema: Type[BaseModel] = SendMessageInput

View File

@@ -1,13 +1,12 @@
"""发送语音消息工具。"""
import asyncio
from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.llm.capability import AgentCapabilityManager
from app.agent.tools.base import MoviePilotTool, ToolChain
from app.agent.tools.tags import ToolTag
from app.core.config import settings
from app.helper.voice import VoiceHelper
from app.log import logger
from app.schemas import Notification, NotificationType
@@ -15,8 +14,8 @@ from app.schemas import Notification, NotificationType
class SendVoiceMessageInput(BaseModel):
"""发送语音消息工具输入。"""
explanation: str = Field(
...,
explanation: Optional[str] = Field(
None,
description="Clear explanation of why a voice reply is the best fit in the current context",
)
message: str = Field(
@@ -26,54 +25,66 @@ class SendVoiceMessageInput(BaseModel):
class SendVoiceMessageTool(MoviePilotTool):
"""发送 Agent 语音回复的工具。"""
name: str = "send_voice_message"
tags: list[str] = [
ToolTag.Write,
ToolTag.Message,
ToolTag.TerminalResponse,
]
sends_message: bool = True
return_direct: bool = True
description: str = (
"Send a voice reply to the current user. Use this only when the user explicitly asks for "
"a voice reply or when spoken playback is clearly better than plain text. On channels "
"without voice support or when TTS is unavailable, it automatically falls back to sending "
"the same content as plain text."
"the same content as plain text. This is a terminal response tool: put the complete "
"user-facing reply in `message`; after this tool runs, do not send another text reply "
"or call `send_message` with the same content."
)
args_schema: Type[BaseModel] = SendVoiceMessageInput
require_admin: bool = False
def get_tool_message(self, **kwargs) -> Optional[str]:
"""生成语音回复工具的执行提示。"""
message = kwargs.get("message") or ""
if len(message) > 40:
message = message[:40] + "..."
return f"发送语音回复: {message}"
async def run(self, message: str, **kwargs) -> str:
"""合成语音并发送到当前对话渠道,不支持时回退为文字。"""
if not message:
return "语音回复内容不能为空"
voice_path = None
used_voice = False
channel = self._channel or ""
reply_mode = VoiceHelper.resolve_reply_mode(
reply_mode = AgentCapabilityManager.resolve_reply_mode(
channel=channel,
source=self._source,
)
fallback_reason = "当前渠道不支持语音回复"
if not VoiceHelper.is_enabled():
fallback_reason = "当前未启用音频输入输"
if not AgentCapabilityManager.supports_audio_output():
fallback_reason = "当前未启用音频输出"
if (
reply_mode == VoiceHelper.REPLY_MODE_NATIVE
and VoiceHelper.is_available("tts")
reply_mode == AgentCapabilityManager.REPLY_MODE_NATIVE
and AgentCapabilityManager.is_audio_output_available()
):
voice_file = await asyncio.to_thread(VoiceHelper.synthesize_speech, message)
voice_file = await self.run_blocking(
"default",
AgentCapabilityManager.synthesize_speech, message
)
if voice_file:
voice_path = str(voice_file)
used_voice = True
elif reply_mode == VoiceHelper.REPLY_MODE_NATIVE:
elif reply_mode == AgentCapabilityManager.REPLY_MODE_NATIVE:
fallback_reason = "当前未配置可用的语音合成能力"
logger.info(
"执行工具: %s, channel=%s, use_voice=%s, text_len=%s",
self.name,
channel,
used_voice,
len(message),
f"执行工具: {self.name}, channel={channel}, "
f"use_voice={used_voice}, text_len={len(message)}"
)
await ToolChain().async_post_message(
@@ -87,7 +98,7 @@ class SendVoiceMessageTool(MoviePilotTool):
voice_path=voice_path,
voice_caption=(
message
if voice_path and settings.AI_VOICE_REPLY_WITH_TEXT
if voice_path and settings.AUDIO_OUTPUT_INCLUDE_TEXT
else None
),
)

View File

@@ -1,22 +1,21 @@
"""切换当前激活人格工具。"""
import json
from typing import Type
from typing import Type, Optional
from pydantic import BaseModel, Field
from app.agent.runtime import agent_runtime_manager
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.log import logger
class SwitchPersonaInput(BaseModel):
"""切换人格工具的输入参数模型。"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
persona_id: str = Field(
...,
description=(
@@ -28,6 +27,10 @@ class SwitchPersonaInput(BaseModel):
class SwitchPersonaTool(MoviePilotTool):
name: str = "switch_persona"
tags: list[str] = [
ToolTag.Write,
ToolTag.Persona,
]
description: str = (
"Switch the active persona (人格) used by the agent runtime. "
"This change is persistent for future turns. "

View File

@@ -5,6 +5,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.site import SiteChain
from app.db.site_oper import SiteOper
from app.log import logger
@@ -12,12 +13,16 @@ from app.log import logger
class TestSiteInput(BaseModel):
"""测试站点连通性工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
site_identifier: int = Field(..., description="Site ID to test (can be obtained from query_sites tool)")
class TestSiteTool(MoviePilotTool):
name: str = "test_site"
tags: list[str] = [
ToolTag.Read,
ToolTag.Site,
]
description: str = "Test site connectivity and availability. This will check if a site is accessible and can be logged in. Accepts site ID only."
args_schema: Type[BaseModel] = TestSiteInput

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