Compare commits

..

159 Commits

Author SHA1 Message Date
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
jxxghp
caf615f3bd feat(system): implement one-shot upgrade mode and enhance upgrade handling 2026-05-05 15:22:33 +08:00
jxxghp
27436757a0 更新 version.py 2026-05-05 12:43:09 +08:00
jxxghp
924d54dfd3 perf(search): 按站点并行过滤搜索结果 2026-05-05 09:01:18 +08:00
jxxghp
39f9550f86 fix(agent): 修复添加订阅时的用户名映射 2026-05-04 21:27:48 +08:00
Attente
367ecafbbb fix(subscribe): 修复订阅电视剧季数判断逻辑 2026-05-04 11:34:13 +08:00
jxxghp
10467244e0 align llm provider registry with opencode endpoints 2026-05-03 09:36:39 +08:00
Yifan
cb6dcc6a2e refactor jellyfin module load logic in unittest 2026-05-02 16:32:18 +08:00
奕凡
43c421b0bb Import call in unittest.mock for additional testing 2026-05-02 16:32:18 +08:00
奕凡
45d0891502 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-02 16:32:18 +08:00
奕凡
76c5f54465 Apply suggestions from code review
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-02 16:32:18 +08:00
奕凡
bcf8116172 handle best_admin_id is None
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-05-02 16:32:18 +08:00
奕凡
1f889596b7 fix f-string usage
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-05-02 16:32:18 +08:00
Yifan
04443fcfba fix(jellyfin): resolve URL string interpolation failure and enhance RBAC fallback resilience
Co-authored-by: Copilot <copilot@github.com>
2026-05-02 16:32:18 +08:00
jxxghp
5d7a7fd301 更新 message.py 2026-05-01 10:02:23 +08:00
jxxghp
4d0a722b09 refactor: reorganize interaction chain 2026-05-01 09:53:04 +08:00
jxxghp
db6dc926cf feat: unify slash command interactions 2026-05-01 08:53:52 +08:00
jxxghp
4bb4f5aeb5 更新 version.py 2026-04-30 21:08:31 +08:00
jxxghp
58e25fe900 删除 test_openai_stream_patch.py 2026-04-30 21:02:58 +08:00
jxxghp
03f6b9bc96 删除 test_openai_responses_patch3.py 2026-04-30 21:02:25 +08:00
jxxghp
6fdda3a570 删除 test_openai_copilot_patch.py 2026-04-30 21:01:49 +08:00
jxxghp
100eaec38f feat: improve tool selection prompt with clearer instructions 2026-04-30 20:33:46 +08:00
jxxghp
b129508304 chore: disable tool selection middleware by setting LLM_MAX_TOOLS to 0 2026-04-30 19:07:07 +08:00
jxxghp
53bf81aede refactor: rename MoviePilotToolSelectorMiddleware to ToolSelectorMiddleware and enhance tool selection logic 2026-04-30 19:05:49 +08:00
jxxghp
afcc071d07 feat: optimize tool selection middleware to cache and reuse tool selection per agent run
- Refactor MoviePilotToolSelectorMiddleware to perform tool selection once per agent execution and cache the result in state, avoiding redundant LLM calls for each model round.
- Add abefore_agent to select tools at the start of agent execution and store selected tool names in state.
- Update awrap_model_call to reuse cached tool selection from state for subsequent model calls.
- Enhance test coverage for tool selection caching and reuse logic.
- Improve error logging in skill version extraction.
2026-04-30 18:29:54 +08:00
jxxghp
2ea617655c refactor: streamline agent initialization and parameter handling for improved clarity and consistency 2026-04-30 18:03:04 +08:00
jxxghp
0583495548 refactor: remove legacy disable_thinking and reasoning_effort parameters from LLM helper and related tests 2026-04-30 17:10:14 +08:00
jxxghp
516aea6312 refactor: rename llm variables for clarity and consistency in agent initialization 2026-04-30 16:41:46 +08:00
jxxghp
2d412cae1c style: improve log formatting for torrent publish time checks in FilterModule 2026-04-30 16:28:28 +08:00
jxxghp
45f5326fb4 fix tool selection middleware 2026-04-30 13:47:43 +08:00
jxxghp
2ccea2da39 chore: update langchain-anthropic, openai, and google-genai dependencies 2026-04-30 13:14:03 +08:00
jxxghp
53f6897d62 feat: ensure essential tools are always included in LLM tool selection and update tests
- Add mechanism to always include core tools (e.g., file operations, command execution) in LLMToolSelectorMiddleware
- Update MoviePilotToolFactory to provide filtered always-include tool names based on loaded tools
- Set default LLM_MAX_TOOLS to 30 in config
- Refactor agent initialization to support always_include parameter
- Enhance tests to cover always_include logic and async agent creation
2026-04-30 13:04:52 +08:00
jxxghp
28a2386f2f feat: add agent tools for querying and managing filter rules and rule groups
- Add tools for querying built-in and custom filter rules, and for adding, updating, and deleting custom rules and rule groups
- Refactor filter module to use shared builtin rule definitions
- Enhance rule group querying to include syntax guidance and usage references
- Add unittests for agent filter rule tools registration and parsing logic
2026-04-30 12:56:38 +08:00
jxxghp
abda9d3212 feat: improve context_tokens_k calculation and update Tencent provider name 2026-04-30 11:41:00 +08:00
jxxghp
34e7c4ac14 feat: enhance openai-compatible provider support and patch responses API instructions handling
- Add compatibility patch for langchain-openai responses API to ensure system messages are extracted as top-level instructions, addressing Codex endpoint requirements.
- Update provider list: add Alibaba, Volcengine, and Tencent TokenHub; adjust SiliconFlow and MiniMax endpoints; refine provider ordering and model list strategies.
- Extend models.dev-only listing logic for providers lacking stable models.list endpoints.
- Increase models.dev cache TTL for improved efficiency.
- Add tests for openai responses API and streaming compatibility patches.
2026-04-30 11:32:55 +08:00
jxxghp
b228107a25 refactor: migrate LLM helper to agent module and add unified LLM API endpoints
- Move LLMHelper and related logic from app.helper.llm to app.agent.llm.helper
- Update all imports to reference new LLMHelper location
- Introduce app/agent/llm/__init__.py for internal LLM adapter exports
- Add llm.py API router with endpoints for model listing, provider auth, and test calls
- Remove legacy LLM endpoints from system.py
- Update requirements for langchain-anthropic and anthropic
- Refactor test_llm_helper_testcall.py for async LLMHelper usage and new import paths
2026-04-30 09:48:50 +08:00
jxxghp
2375508616 Restore background dispatch without channel context 2026-04-30 07:04:59 +08:00
jxxghp
baebd0ed1a Fix background prompt message leakage 2026-04-30 06:58:43 +08:00
jxxghp
6532c60a3c Refine agent background reply handling 2026-04-30 00:25:23 +08:00
jxxghp
11478faff3 separate reply sending from output persistence 2026-04-29 23:56:16 +08:00
jxxghp
e9291cec6a respect output persistence in background agent replies 2026-04-29 23:51:03 +08:00
jxxghp
7586a2cd42 disable agent message tools for ui background tasks 2026-04-29 23:30:59 +08:00
jxxghp
ef5bd29759 move ui background message suppression into agent context 2026-04-29 23:22:37 +08:00
jxxghp
7ab643d34a suppress channel notifications for ui background tasks 2026-04-29 23:13:57 +08:00
jxxghp
0b7505a604 refactor search AI recommendation flow 2026-04-29 22:55:27 +08:00
jxxghp
460d716512 feat: add batch AI re-organize for transfer history and search result recommendation
- Implement batch AI re-organize endpoint for transfer history with progress tracking
- Add batch_manual_transfer_redo system task template and prompt generation
- Refactor agent_manager to support generic background prompt execution
- Add AIRecommendChain for search result recommendation using agent background prompt
- Update search endpoints to use new AIRecommendChain and remove legacy code
- Enhance test cases for batch manual transfer redo
- Minor code cleanup and style fixes
2026-04-29 22:16:04 +08:00
jxxghp
b6f0ef99ab 更新 version.py 2026-04-29 19:02:35 +08:00
jxxghp
af35101774 fix: default to text replies for voice input 2026-04-29 18:54:58 +08:00
jxxghp
9ed5018cc2 refactor: clarify attachment data url handling 2026-04-29 18:51:39 +08:00
jxxghp
7299733960 调整语音文件大小限制,超出 10MB 时禁止识别 2026-04-29 18:41:54 +08:00
jxxghp
bd5c3d848c 修复 _resolve_provider_name 方法递归调用问题,改为静态方法并标准化 provider 名称解析逻辑 2026-04-29 18:41:24 +08:00
jxxghp
38c48fa4ce 优化 OpenAIVoiceProvider 逻辑,简化凭证与 provider 解析方法并调整最大转录文件大小限制 2026-04-29 18:32:12 +08:00
jxxghp
b7749c44fd 重构语音能力配置与逻辑,统一音频输入输出开关并优化语音回复判断 2026-04-29 18:15:34 +08:00
jxxghp
e4a7333b79 调整 LLM_TEMPERATURE 配置参数默认值为 0.3 2026-04-29 16:54:14 +08:00
jxxghp
4b27b7bc42 重构 UgreenCrypto 模块路径至 app.modules.ugreen 并更新相关引用 2026-04-29 16:29:17 +08:00
jxxghp
c91e87115a 调整 System Core Prompt.txt,将核心能力说明移至更显著位置并优化结构 2026-04-29 16:25:16 +08:00
jxxghp
4a3cc5ee18 新增多个人设说明文档并完善测试用例 2026-04-29 16:20:44 +08:00
jxxghp
54d6c2ad4a 更新 __init__.py 2026-04-29 15:27:37 +08:00
jxxghp
090dcacd30 更新 README.md 2026-04-29 14:38:35 +08:00
jxxghp
344280cd61 Refactor agent persona runtime layering 2026-04-29 14:12:47 +08:00
jxxghp
2c7fb5786c 更新 _plugin_tool_utils.py 2026-04-29 09:16:02 +08:00
jxxghp
6b9790026c refine plugin agent tool responsibilities 2026-04-29 08:50:48 +08:00
jxxghp
6c70531967 Refactor _query_plugin_data to static async method 2026-04-29 08:31:38 +08:00
jxxghp
bcc321eb70 add plugin agent management tools 2026-04-29 08:29:04 +08:00
MseeP.ai
2ff1cd1045 Add MseeP.ai badge to README.md 2026-04-29 08:02:47 +08:00
jxxghp
7fc496cf5b 更新 __init__.py 2026-04-29 07:31:52 +08:00
jxxghp
8789f35228 Improve non-verbose agent tool summaries 2026-04-29 07:07:33 +08:00
jxxghp
d4dec90e2f 更新 version.py 2026-04-28 20:49:05 +08:00
jxxghp
5c1487a9a6 Optimize agent tool async blocking paths 2026-04-28 20:36:49 +08:00
jxxghp
c5b716c231 feat: introduce unified agent runtime config and system task prompt framework
- Add structured runtime config files (AGENT_PROFILE.md, AGENT_WORKFLOW.md, AGENT_HOOKS.md, USER_PREFERENCES.md, SYSTEM_TASKS.md, CURRENT_PERSONA.md) for persona, workflow, hooks, and system tasks
- Implement agent_runtime_manager to load, validate, and render runtime config and system task prompts
- Refactor agent initialization to use runtime-managed directories for skills, jobs, memory, and activity logs
- Add AgentHooksMiddleware for structured pre/in/post hooks injection
- Replace hardcoded system task prompts with template-driven rendering from SYSTEM_TASKS.md
- Update tests to cover runtime config loading, migration, and system task prompt rendering
- Update .gitignore to exclude config/agent/
2026-04-28 13:04:28 +08:00
jxxghp
483fe55372 fix: correct Plex notification image lookup
Closes #5700
2026-04-28 09:19:18 +08:00
jxxghp
5d588ee127 fix: correct traditional Chinese subtitle rename detection
Fixes #5703
2026-04-28 09:00:14 +08:00
jxxghp
afcd895f52 fix: backfill transfer download history matching
Fixes #5702
2026-04-28 08:55:40 +08:00
252 changed files with 31133 additions and 5327 deletions

1
.gitignore vendored
View File

@@ -19,6 +19,7 @@ config/cookies/**
config/app.env
config/user.db*
config/sites/**
config/agent/
config/logs/
config/temp/
config/cache/

152
AGENTS.md Normal file
View File

@@ -0,0 +1,152 @@
# MoviePilot AI Agent Guide
This file defines the default behavior for AI agents working in the MoviePilot repository. Unless a deeper directory provides another `AGENTS.md`, these rules apply to the entire repo.
## 1. Project Scope
- This repository contains the MoviePilot backend, CLI, MCP/API, Docker assets, and AI skills.
- The backend is based on FastAPI, with most code under `app/`.
- Frontend source code is not in this repository. The frontend source repository is `MoviePilot-Frontend`.
- This repository also includes the local CLI, database migrations, developer docs, tests, Docker scripts, and AI skills.
## 2. Working Principles
- Read the relevant implementation, tests, and docs before changing code. Do not infer behavior from directory names alone.
- Prefer the smallest correct change. Reuse existing functions, patterns, and naming whenever possible.
- Do not perform unrelated large refactors, mass renames, or formatting-only cleanup.
- Before adding a new abstraction, check whether it is actually reusable. If the logic fits well inside an existing function, class, or flow, keep it there.
- The worktree may contain user changes. Do not revert, overwrite, or reorganize changes you do not fully understand.
- Default to writing conclusions, validation results, and risk notes in Chinese unless the user asks otherwise.
## 3. Key Directories
- `app/api/endpoints/`: HTTP entrypoints. Handles auth, parameters, responses, and simple CRUD.
- `app/chain/`: Business orchestration layer for search, recognition, subscriptions, downloads, messaging flows, and similar use cases.
- `app/modules/`: Dynamically loaded system modules. Encapsulates pluggable downloaders, media servers, message channels, and other backend capabilities.
- `app/helper/`: Reusable low-level helper logic. Not a place for full business orchestration.
- `app/core/config.py`: Environment variables, deployment parameters, and startup-level settings.
- `app/schemas/types.py`: Shared enums and types such as `SystemConfigKey` and module categories.
- `app/db/`: Database models, sessions, and `*_oper.py` data access wrappers.
- `moviepilot`: Local CLI entrypoint and help text.
- `database/versions/`: Alembic migration scripts.
- `docs/`: CLI, MCP/API, and development workflow documentation.
- `skills/`: AI agent skills and related scripts.
- `tests/`: Pytest tests and a few manual test scripts.
- `config/`, `.moviepilot.env`, and `*.db`: Local config or runtime data. Do not modify or commit them unless the user explicitly asks for it.
## 4. Layering And Access Boundaries
### API / Endpoint Layer
- Endpoints should only handle HTTP concerns: auth, parameter parsing, response models, streaming adaptation, and simple input validation.
- Simple list, detail, toggle, settings read/write, and pure CRUD endpoints may directly call `app/db/` or an existing `helper`.
- If the logic coordinates multiple modules, triggers events, touches caches, or combines search, recognition, subscription, or download workflows, move it into `chain`.
- Prefer adding new endpoints to an existing domain file. Create a new endpoint file only when introducing a new top-level resource domain.
- After adding a new endpoint, register it in `app/api/apiv1.py`.
### Chain Layer
- `chain` is the business orchestration layer shared by API, CLI, message interaction, agents, schedulers, and similar entrypoints.
- `chain` is responsible for composing `module`, `helper`, `db`, events, caches, and other stable `chain` capabilities.
- Inside `chain`, prefer calling module capabilities through `run_module()` or `async_run_module()`. Only use `ModuleManager` or similar helpers directly when you truly need to enumerate modules, inspect instances, or run health checks.
- `chain` should focus on use cases and workflows. It should not hold low-level protocol details, HTTP request objects, or page-specific parameter assembly.
- Before adding a new `chain`, ask whether this is a reusable business use case shared by multiple entrypoints, or a flow that coordinates multiple modules or resources. If it is just short logic for one endpoint, do not create a new `chain`.
- `chain` may call other `chain` classes when reusing stable domain logic, but avoid introducing new circular dependencies.
### Module Layer
- `module` is the pluggable capability layer discovered and loaded by `ModuleManager`.
- Put logic in `module` when it represents a new downloader, media server, message channel, recognition backend, filtering backend, file-management backend, or any other capability that needs lifecycle management, priority, configuration switches, or independent testing.
- New modules should follow the existing base-class contract and implement or align with `init_module()`, `init_setting()`, `get_name()`, `get_type()`, `get_subtype()`, `get_priority()`, `test()`, and `stop()`.
- A `module` should focus on one backend or one capability implementation. It should return domain results, not HTTP responses, and should not depend on endpoint auth or FastAPI request objects.
- `chain -> module` is the intended main direction. The repository contains a small number of historical `module -> chain` usages. Do not expand that pattern in new code. If a module needs shared business logic, prefer moving that logic up into `chain` or down into `helper`.
- Do not add direct `module -> module` coupling for new code. Cross-module orchestration should be handled by `chain`.
### Helper Layer
- `helper` is for reusable low-level support logic such as path handling, config aggregation, site index loading, protocol wrappers, rate limiting, cache helpers, and page parsing.
- Add a new `helper` only when the logic is reused in multiple places, or when it is clearly a standalone low-level concern.
- If logic is used only by a single `chain` or a single `module`, prefer keeping it in the original file instead of turning `helper` into a dumping ground.
- If the code needs configuration switches, runtime loading, priorities, independent test entrypoints, or multi-implementation dispatch, it is probably a `module`, not a `helper`.
- `helper` must not become another orchestration layer. Full business workflows still belong in `chain`.
### Preferred Call Directions
- Preferred direction: `endpoint/CLI/agent/command -> chain -> module/helper/db`
- Allowed direction: `chain -> chain`, as long as the reused logic is stable and does not introduce cycles.
- Cautious direction: `endpoint -> db/model/oper/helper`, only for simple queries, simple CRUD, or input normalization.
- Avoid for new code: `module -> chain`, `module -> module`, `helper -> chain`, `helper -> endpoint`.
## 5. Where New Capabilities Should Go
- Scenario: adding a new business workflow such as search, recognition, subscription, download orchestration, or message interaction.
Action: prefer `app/chain/` so API, CLI, agents, and schedulers can share the same orchestration logic.
- Scenario: adding a new downloader, media server, message channel, or other pluggable backend integration.
Action: put it in `app/modules/`. If this introduces a new module category or subtype, also check `app/schemas/types.py` and related schemas.
- Scenario: adding a new public HTTP API.
Action: put it in `app/api/endpoints/`, register it in `app/api/apiv1.py`, and add auth, schemas, docs, and tests. Move complex logic into `chain`.
- Scenario: adding a new low-level utility, parser, config reader, or protocol wrapper.
Action: put it in `app/helper/`, but only if it is not a one-off implementation and not a full business use case.
- Scenario: adding a deployment-level, environment-level, or startup-time config such as ports, paths, proxies, switches, keys, or third-party service addresses.
Action: put it in `ConfigModel` or `Settings` inside `app/core/config.py`.
- Scenario: adding a runtime business config, user-editable rule, or persistent system option.
Action: prefer `SystemConfigKey` plus `SystemConfigOper`. Do not scatter raw string keys.
- Scenario: a config change should automatically reload a long-lived object.
Action: add `CONFIG_WATCH`, `on_config_changed()`, and `get_reload_name()` where appropriate on the related `chain`, `module`, `helper`, or manager class.
- Scenario: adding a few dozen lines of private logic inside one `chain` or `module`.
Action: prefer a private function or private method in the same file. Do not create a new `helper` by default.
## 6. Code And Comment Requirements
- Preserve the existing code style. Do not introduce a new abstraction layer without a clear payoff.
- The repository already uses short docstrings for many public classes and methods. For new public classes and methods, follow the local style of the surrounding file.
- Comments and docstrings should default to Chinese. If the surrounding file is already consistently in English, match the local style.
- Comments should explain why the code is written that way and what non-obvious constraints exist, such as edge cases, compatibility reasons, call ordering, cache or reload semantics, and external system limitations.
- Do not write line-by-line translation comments. Do not comment obvious assignments, branches, or straightforward calls.
- For complex notes, place the comment above the code block instead of using long end-of-line comments.
- When changing code, update or remove stale comments so the documentation stays aligned with the implementation.
- Do not add TODO or FIXME without context. Only keep one if it is genuinely useful and cannot be addressed as part of the current task.
- Do not add noisy comments like "change starts here", "change ends here", or "this is important".
## 7. Dependency And Environment Conventions
- Target Python version is `3.11+`. Current CI uses Python `3.12`.
- The dependency source file is `requirements.in`.
- `requirements.txt` is the lock file generated by `pip-compile requirements.in`. Do not maintain it manually.
- Install dependencies with `pip install -r requirements.txt`.
- When adding or upgrading dependencies:
1. Update `requirements.in`
2. Run `pip-compile requirements.in`
3. Run the relevant tests and security checks
## 8. Coupled Updates
- When fixing a bug, prefer adding a test that reproduces it. When adding a feature, prefer the smallest useful test coverage.
- When changing CLI behavior, also check and update `moviepilot`, `docs/cli.md`, and related tests.
- When changing MCP or REST API behavior, exposed tools, or AI interaction behavior, also check and update `docs/mcp-api.md`, related `skills/*/SKILL.md` files or scripts, and related tests.
- When changing development workflow, dependency management, or security-check procedures, also update `docs/development-setup.md`.
- When changing database structure, add an Alembic migration under `database/versions/`. Do not update models without a migration.
- When changing user-visible config, defaults, or initialization flow, also check related docs, help text, setup or init flows, and tests.
- When adding a new skill, follow the existing `skills/<name>/SKILL.md` structure, keep the YAML front matter, and prefer script paths relative to the `SKILL.md` file.
## 9. Validation Requirements
- Run at least the tests directly related to the change, for example `pytest tests/test_xxx.py`.
- If the change affects common modules, startup flow, CLI, or agent runtime behavior, expand the validation scope.
- After Python code changes, at minimum ensure the change does not introduce new error-level issues in `pylint app/`.
- When changing CLI behavior, validate the relevant help output such as `moviepilot help` or the specific subcommand help.
- When changing dependencies, also run `pip-compile requirements.in` and `safety check -r requirements.txt --policy-file=safety.policy.yml`.
- If the task only changes documentation, explicitly say that tests were not run. Do not claim checks that were not executed.
## 10. Commit And Release Conventions
- Only create a commit when the user explicitly asks for one.
- Prefer Conventional Commits such as `feat: ...`, `fix: ...`, and `docs: ...`.
- This is not just stylistic. The release workflow uses Conventional Commits to categorize changelog entries.
- Do not casually change version numbers, release settings, or Docker release flow unless the task explicitly involves them.
## 11. Output Requirements
- Result summaries should focus on three things: what changed, how it was validated, and what risks remain.
- Do not write vague summaries. Do not describe unexecuted checks as completed.
- If there is compatibility impact, config migration risk, or user-data risk, call it out explicitly.

View File

@@ -1,3 +1,4 @@
# MoviePilot
简体中文 | [English](README_EN.md)

View File

@@ -5,12 +5,12 @@ import traceback
import uuid
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from typing import Any, Callable, Dict, List, Optional
from langchain.agents import create_agent
from langchain.agents.middleware import (
SummarizationMiddleware,
LLMToolSelectorMiddleware,
)
from langchain_core.messages import ( # noqa: F401
HumanMessage,
@@ -19,19 +19,25 @@ from langchain_core.messages import ( # noqa: F401
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.tool_selection import ToolSelectorMiddleware
from app.agent.middleware.usage import UsageMiddleware
from app.agent.prompt import prompt_manager
from app.agent.runtime import agent_runtime_manager
from app.agent.tools.factory import MoviePilotToolFactory
from app.chain import ChainBase
from app.core.config import settings
from app.db.transferhistory_oper import TransferHistoryOper
from app.helper.llm import LLMHelper
from app.log import logger
from app.schemas import Notification, NotificationType
from app.schemas.message import ChannelCapabilityManager, ChannelCapability
@@ -108,7 +114,7 @@ class _ThinkTagStripper:
on_output(self.buffer[:start_idx])
emitted = True
self.in_think_tag = True
self.buffer = self.buffer[start_idx + 7 :]
self.buffer = self.buffer[start_idx + 7:]
else:
# 检查是否以 <think> 的不完整前缀结尾
partial_match = False
@@ -128,7 +134,7 @@ class _ThinkTagStripper:
end_idx = self.buffer.find("</think>")
if end_idx != -1:
self.in_think_tag = False
self.buffer = self.buffer[end_idx + 8 :]
self.buffer = self.buffer[end_idx + 8:]
else:
# 检查是否以 </think> 的不完整前缀结尾
partial_match = False
@@ -149,29 +155,45 @@ class _ThinkTagStripper:
self.buffer = ""
class ReplyMode(str, Enum):
"""
Agent 最终回复处理模式。
"""
DISPATCH = "dispatch"
CAPTURE_ONLY = "capture_only"
HEARTBEAT_SESSION_PREFIX = "__agent_heartbeat_"
class MoviePilotAgent:
"""
MoviePilot AI智能体基于 LangChain v1 + LangGraph
"""
def __init__(
self,
session_id: str,
user_id: str = None,
channel: str = None,
source: str = None,
username: str = None,
self,
session_id: str,
user_id: str = None,
channel: str = None,
source: str = None,
username: str = None,
replay_mode: ReplyMode = ReplyMode.DISPATCH,
persist_output_message: bool = True,
allow_message_tools: bool = True,
output_callback: Optional[Callable[[str], None]] = None,
):
self.session_id = session_id
self.user_id = user_id
self.channel = channel
self.source = source
self.username = username
self.reply_with_voice = False
self.reply_mode = replay_mode
self.persist_output_message = persist_output_message
self.allow_message_tools = allow_message_tools
self.output_callback = output_callback
self._tool_context: Dict[str, object] = {}
self.output_callback: Optional[Callable[[str], None]] = None
self.force_streaming = False
self.suppress_user_reply = False
self._streamed_output = ""
self._session_usage = _SessionUsageSnapshot()
@@ -188,16 +210,16 @@ class MoviePilotAgent:
return None
@classmethod
def _get_model_name(cls, llm: Any) -> Optional[str]:
def _get_model_name(cls, model: Any) -> Optional[str]:
return (
getattr(llm, "model", None)
or getattr(llm, "model_name", None)
or getattr(llm, "model_id", None)
getattr(model, "model", None)
or getattr(model, "model_name", None)
or getattr(model, "model_id", None)
)
@classmethod
def _get_context_window_tokens(cls, llm: Any) -> Optional[int]:
profile = getattr(llm, "profile", None)
def _get_context_window_tokens(cls, model: Any) -> Optional[int]:
profile = getattr(model, "profile", None)
if not profile:
return None
if isinstance(profile, dict):
@@ -209,9 +231,9 @@ class MoviePilotAgent:
or getattr(profile, "input_token_limit", None)
)
def _sync_model_profile(self, llm: Any) -> None:
model_name = self._get_model_name(llm)
context_window_tokens = self._get_context_window_tokens(llm)
def _sync_model_profile(self, model: Any) -> None:
model_name = self._get_model_name(model)
context_window_tokens = self._get_context_window_tokens(model)
if model_name:
self._session_usage.model = model_name
if context_window_tokens:
@@ -264,7 +286,24 @@ class MoviePilotAgent:
"""
是否为后台任务模式(无渠道信息,如定时唤醒)
"""
return not self.channel or not self.source
return (not self.channel or not self.source) and not callable(self.output_callback)
@property
def should_dispatch_reply(self) -> bool:
"""
是否应将最终回复真正发送到消息渠道。
"""
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:
"""
@@ -276,11 +315,7 @@ class MoviePilotAgent:
- 其他情况不启用流式输出
"""
if self.is_background:
return self.force_streaming or callable(self.output_callback)
if self.reply_with_voice:
return False
if self.force_streaming or callable(self.output_callback):
return True
# 啰嗦模式下始终需要流式输出来捕获工具调用前的 Agent 文字
if settings.AI_AGENT_VERBOSE:
return True
@@ -293,12 +328,12 @@ class MoviePilotAgent:
return False
@staticmethod
def _initialize_llm(streaming: bool = False):
async def _initialize_llm(streaming: bool = False):
"""
初始化 LLM
:param streaming: 是否启用流式输出
"""
return LLMHelper.get_llm(streaming=streaming)
return await LLMHelper.get_llm(streaming=streaming)
@staticmethod
def _extract_text_content(content) -> str:
@@ -320,10 +355,10 @@ class MoviePilotAgent:
if block.get("thought"):
continue
if block.get("type") in (
"thinking",
"reasoning_content",
"reasoning",
"thought",
"thinking",
"reasoning_content",
"reasoning",
"thought",
):
continue
if block.get("type") == "text":
@@ -347,6 +382,14 @@ class MoviePilotAgent:
except Exception as e:
logger.debug(f"智能体输出回调失败: {e}")
def _handle_stream_text(self, text: str):
"""
统一处理一段可见流式文本,确保工具统计注入后的内容会同时进入
消息缓冲区和外部流式回调。
"""
emitted_text = self.stream_handler.emit(text)
self._emit_output(emitted_text)
def _initialize_tools(self) -> List:
"""
初始化工具列表
@@ -359,70 +402,82 @@ class MoviePilotAgent:
username=self.username,
stream_handler=self.stream_handler,
agent_context=self._tool_context,
allow_message_tools=self.allow_message_tools,
)
def _create_agent(self, streaming: bool = False):
async def _create_agent(self, streaming: bool = False):
"""
创建 LangGraph Agent使用 create_agent + SummarizationMiddleware
:param streaming: 是否启用流式输出
"""
try:
# 系统提示词
system_prompt = prompt_manager.get_agent_prompt(
channel=self.channel,
prefer_voice_reply=self.reply_with_voice,
)
system_prompt = prompt_manager.get_agent_prompt(channel=self.channel)
# LLM 模型(用于 agent 执行)
llm = self._initialize_llm(streaming=streaming)
self._sync_model_profile(llm)
agent_model = await self._initialize_llm(streaming=streaming)
self._sync_model_profile(agent_model)
# 为中间件内部模型调用准备非流式 LLM避免与用户流式回复复用同一实例。
non_streaming_llm = (
llm if not streaming else self._initialize_llm(streaming=False)
# 为内部模型调用准备非流式 LLM避免与用户流式回复复用同一实例。
non_streaming_model = (
agent_model
if not streaming
else await self._initialize_llm(streaming=False)
)
# 工具列表
tools = self._initialize_tools()
max_tools = settings.LLM_MAX_TOOLS
always_include_tools = (
MoviePilotToolFactory.get_tool_selector_always_include_names(tools)
)
# 中间件
middlewares = [
# Skills
SkillsMiddleware(
sources=[str(settings.CONFIG_PATH / "agent" / "skills")],
sources=[str(agent_runtime_manager.skills_dir)],
bundled_skills_dir=str(settings.ROOT_PATH / "skills"),
),
# Jobs 任务管理
JobsMiddleware(
sources=[str(settings.CONFIG_PATH / "agent" / "jobs")],
sources=[str(agent_runtime_manager.jobs_dir)],
),
# 记忆管理(自动扫描 agent 目录下所有 .md 文件)
MemoryMiddleware(memory_dir=str(settings.CONFIG_PATH / "agent")),
# 活动日志
ActivityLogMiddleware(
activity_dir=str(settings.CONFIG_PATH / "agent" / "activity"),
),
# 用量统计
UsageMiddleware(on_usage=self._record_usage),
# 运行时人格与核心规则
RuntimeConfigMiddleware(),
# 记忆管理
MemoryMiddleware(memory_dir=str(agent_runtime_manager.memory_dir)),
# 上下文压缩
SummarizationMiddleware(
model=non_streaming_llm, trigger=("fraction", 0.85)
model=non_streaming_model, trigger=("fraction", 0.85)
),
# 错误工具调用修复
PatchToolCallsMiddleware(),
# 用量统计
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 settings.LLM_MAX_TOOLS > 0:
if max_tools > 0:
middlewares.append(
LLMToolSelectorMiddleware(
model=non_streaming_llm,
max_tools=settings.LLM_MAX_TOOLS,
ToolSelectorMiddleware(
model=non_streaming_model,
selection_tools=tools,
max_tools=max_tools,
always_include=always_include_tools,
)
)
return create_agent(
model=llm,
model=agent_model,
tools=tools,
system_prompt=system_prompt,
middleware=middlewares,
@@ -433,10 +488,10 @@ class MoviePilotAgent:
raise e
async def process(
self,
message: str,
images: List[str] = None,
files: Optional[List[dict]] = None,
self,
message: str,
images: List[str] = None,
files: Optional[List[dict]] = None,
) -> str:
"""
处理用户消息,流式推理并返回 Agent 回复
@@ -447,9 +502,9 @@ class MoviePilotAgent:
f"images={len(images) if images else 0}, files={len(files) if files else 0}"
)
self._tool_context = {
"incoming_voice": self.reply_with_voice,
"user_reply_sent": False,
"reply_mode": None,
"should_dispatch_reply": self.should_dispatch_reply,
}
self._streamed_output = ""
@@ -483,13 +538,13 @@ class MoviePilotAgent:
except Exception as e:
error_message = f"处理消息时发生错误: {str(e)}"
logger.error(error_message)
if self.suppress_user_reply:
if not self.should_dispatch_reply:
raise
await self.send_agent_message(error_message)
return error_message
async def _stream_agent_tokens(
self, agent, messages: dict, config: dict, on_token: Callable[[str], None]
self, agent, messages: dict, config: dict, on_token: Callable[[str], None]
):
"""
流式运行智能体过滤工具调用token和思考内容将模型生成的内容通过回调输出。
@@ -501,11 +556,11 @@ class MoviePilotAgent:
stripper = _ThinkTagStripper()
async for chunk in agent.astream(
messages,
stream_mode="messages",
config=config,
subgraphs=False,
version="v2",
messages,
stream_mode="messages",
config=config,
subgraphs=False,
version="v2",
):
if chunk["type"] == "messages":
token, metadata = chunk["data"]
@@ -536,7 +591,7 @@ class MoviePilotAgent:
"""
调用 LangGraph Agent 执行推理。
根据运行环境选择不同的执行模式:
- 后台任务模式(无渠道信息):非流式 LLM + ainvoke仅广播最终结果
- 后台任务模式(无渠道信息):非流式 LLM + ainvoke由 reply_mode 决定是发送还是仅捕获
- 渠道不支持消息编辑:非流式 LLM + ainvoke完成后发送最终回复
- 渠道支持消息编辑:流式 LLM + astream实时推送 token
"""
@@ -552,9 +607,12 @@ class MoviePilotAgent:
use_streaming = self._should_stream()
# 创建智能体(根据是否流式传入不同 LLM
agent = self._create_agent(streaming=use_streaming)
agent = await self._create_agent(streaming=use_streaming)
if use_streaming:
self.stream_handler.set_dispatch_policy(
allow_dispatch_without_context=self.should_dispatch_reply
)
# 流式模式:渠道支持消息编辑,启动流式输出实时推送 token
await self.stream_handler.start_streaming(
channel=self.channel,
@@ -568,12 +626,14 @@ class MoviePilotAgent:
agent=agent,
messages={"messages": messages},
config=agent_config,
on_token=lambda token: (
self.stream_handler.emit(token),
self._emit_output(token),
),
on_token=self._handle_stream_text,
)
# 输出流式过程中可能残留的工具调用统计信息
trailing_tool_summary = self.stream_handler.flush_pending_tool_summary()
if trailing_tool_summary:
self._emit_output(trailing_tool_summary)
# 停止流式输出,返回是否已通过流式编辑发送了所有内容及最终文本
(
all_sent_via_stream,
@@ -584,15 +644,31 @@ class MoviePilotAgent:
# 流式输出未能发送全部内容(发送失败等)
# 通过常规方式发送剩余内容
remaining_text = await self.stream_handler.take()
if remaining_text and not self._streamed_output:
self._emit_output(remaining_text)
if remaining_text:
unsent_text = remaining_text
if self._streamed_output and remaining_text.startswith(
self._streamed_output
):
unsent_text = remaining_text[len(self._streamed_output):]
if unsent_text:
self._emit_output(unsent_text)
if (
remaining_text
and not self.suppress_user_reply
and not self._tool_context.get("user_reply_sent")
remaining_text
and self.should_dispatch_reply
and not self._tool_context.get("user_reply_sent")
):
await self.send_agent_message(remaining_text)
elif streamed_text:
elif (
remaining_text
and self.persist_output_message
and not self._tool_context.get("user_reply_sent")
):
title = "MoviePilot助手" if self.is_background else ""
await self._save_agent_message_to_db(
remaining_text,
title=title,
)
elif streamed_text and self.persist_output_message:
# 流式输出已发送全部内容,但未记录到数据库,补充保存消息记录
await self._save_agent_message_to_db(streamed_text)
@@ -624,18 +700,25 @@ class MoviePilotAgent:
self._emit_output(final_text)
if (
final_text
and not self.suppress_user_reply
and not self._tool_context.get("user_reply_sent")
final_text
and self.should_dispatch_reply
and not self._tool_context.get("user_reply_sent")
):
if self.is_background:
# 后台任务仅广播最终回复带标题
# 后台任务发送最终回复时统一带标题
await self.send_agent_message(
final_text, title="MoviePilot助手"
)
else:
# 非流式渠道:发送最终回复
await self.send_agent_message(final_text)
elif (
final_text
and self.persist_output_message
and not self._tool_context.get("user_reply_sent")
):
title = "MoviePilot助手" if self.is_background else ""
await self._save_agent_message_to_db(final_text, title=title)
# 保存消息
memory_manager.save_agent_messages(
@@ -711,7 +794,7 @@ class _MessageTask:
channel: Optional[str] = None
source: Optional[str] = None
username: Optional[str] = None
reply_with_voice: bool = False
reply_mode: ReplyMode = ReplyMode.DISPATCH
class AgentManager:
@@ -720,21 +803,12 @@ class AgentManager:
同一会话的消息按顺序排队处理,不同会话之间互不影响。
"""
# 批量重试整理的等待时间同一批次内的失败记录会合并为一次agent调用
RETRY_TRANSFER_DEBOUNCE_SECONDS = 300
def __init__(self):
self.active_agents: Dict[str, MoviePilotAgent] = {}
# 每个会话的消息队列
self._session_queues: Dict[str, asyncio.Queue] = {}
# 每个会话的worker任务
self._session_workers: Dict[str, asyncio.Task] = {}
# 重试整理的 debounce 缓冲区: group_key -> List[history_id]
self._retry_transfer_buffer: Dict[str, List[int]] = {}
# 重试整理的 debounce 定时器: group_key -> asyncio.TimerHandle
self._retry_transfer_timers: Dict[str, asyncio.TimerHandle] = {}
# 重试整理缓冲区锁
self._retry_transfer_lock = asyncio.Lock()
def get_session_status(self, session_id: str) -> dict[str, Any]:
"""获取会话当前模型与 token 使用状态。"""
@@ -762,8 +836,8 @@ class AgentManager:
queue = self._session_queues.get(session_id)
status["pending_messages"] = queue.qsize() if queue else 0
status["is_processing"] = (
session_id in self._session_workers
and not self._session_workers[session_id].done()
session_id in self._session_workers
and not self._session_workers[session_id].done()
)
return status
@@ -779,11 +853,6 @@ class AgentManager:
关闭管理器
"""
await memory_manager.close()
# 取消所有重试整理的延迟定时器
for timer in self._retry_transfer_timers.values():
timer.cancel()
self._retry_transfer_timers.clear()
self._retry_transfer_buffer.clear()
# 取消所有会话worker
for task in self._session_workers.values():
task.cancel()
@@ -800,16 +869,16 @@ class AgentManager:
self.active_agents.clear()
async def process_message(
self,
session_id: str,
user_id: str,
message: str,
images: List[str] = None,
files: Optional[List[dict]] = None,
channel: str = None,
source: str = None,
username: str = None,
reply_with_voice: bool = False,
self,
session_id: str,
user_id: str,
message: str,
images: List[str] = None,
files: Optional[List[dict]] = None,
channel: str = None,
source: str = None,
username: str = None,
reply_mode: ReplyMode = ReplyMode.DISPATCH,
) -> str:
"""
处理用户消息:将消息放入会话队列,按顺序依次处理。
@@ -824,7 +893,7 @@ class AgentManager:
channel=channel,
source=source,
username=username,
reply_with_voice=reply_with_voice,
reply_mode=reply_mode,
)
# 获取或创建会话队列
@@ -836,8 +905,8 @@ class AgentManager:
# 如果队列中已有等待的消息,通知用户消息已排队
if queue_size > 0 or (
session_id in self._session_workers
and not self._session_workers[session_id].done()
session_id in self._session_workers
and not self._session_workers[session_id].done()
):
logger.info(
f"会话 {session_id} 有任务正在处理,消息已排队等待 "
@@ -849,8 +918,8 @@ class AgentManager:
# 确保该会话有一个worker在运行
if (
session_id not in self._session_workers
or self._session_workers[session_id].done()
session_id not in self._session_workers
or self._session_workers[session_id].done()
):
self._session_workers[session_id] = asyncio.create_task(
self._session_worker(session_id)
@@ -891,8 +960,8 @@ class AgentManager:
self._session_workers.pop(session_id, None) # noqa
# 如果队列为空,清理队列
if (
session_id in self._session_queues
and self._session_queues[session_id].empty()
session_id in self._session_queues
and self._session_queues[session_id].empty()
):
self._session_queues.pop(session_id, None)
@@ -911,6 +980,7 @@ class AgentManager:
channel=task.channel,
source=task.source,
username=task.username,
replay_mode=task.reply_mode,
)
self.active_agents[session_id] = agent
else:
@@ -922,7 +992,7 @@ class AgentManager:
agent.source = task.source
if task.username:
agent.username = task.username
agent.reply_with_voice = task.reply_with_voice
agent.reply_mode = task.reply_mode
return await agent.process(task.message, images=task.images, files=task.files)
@@ -987,33 +1057,70 @@ class AgentManager:
memory_manager.clear_memory(session_id, user_id)
logger.info(f"会话 {session_id} 的记忆已清空")
@staticmethod
async def run_background_prompt(
message: str,
session_prefix: str = "__agent_background",
output_callback: Optional[Callable[[str], None]] = None,
reply_mode: ReplyMode = ReplyMode.CAPTURE_ONLY,
persist_output_message: bool = True,
allow_message_tools: Optional[bool] = None,
) -> None:
"""
以独立后台会话执行一段 prompt。
"""
session_id = f"{session_prefix}_{uuid.uuid4().hex[:8]}__"
user_id = SYSTEM_INTERNAL_USER_ID
if reply_mode == ReplyMode.CAPTURE_ONLY:
allow_message_tools = False
elif allow_message_tools is None:
allow_message_tools = True
agent = MoviePilotAgent(
session_id=session_id,
user_id=user_id,
channel=None,
source=None,
username=settings.SUPERUSER,
replay_mode=reply_mode,
persist_output_message=persist_output_message,
output_callback=output_callback,
allow_message_tools=allow_message_tools,
)
try:
await agent.process(message)
finally:
await agent.cleanup()
memory_manager.clear_memory(session_id, user_id)
@staticmethod
def _build_heartbeat_prompt() -> str:
"""使用程序内置 System Tasks 定义构建心跳任务提示词。"""
return prompt_manager.render_system_task_message("heartbeat")
async def heartbeat_check_jobs(self):
"""
心跳唤醒检查并执行待处理的定时任务Jobs
由定时调度器周期性调用,每次使用独立的会话避免上下文干扰。
"""
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("智能体心跳唤醒:开始检查待处理任务...")
# 英文提示词,便于大模型理解
heartbeat_message = (
"[System Heartbeat] Check all jobs in your jobs directory and process pending tasks:\n"
"1. List all jobs with status 'pending' or 'in_progress'\n"
"2. For 'recurring' jobs, check 'last_run' to determine if it's time to run again\n"
"3. For 'once' jobs with status 'pending', execute them now\n"
"4. After executing each job, update its status, 'last_run' time, and execution log in the JOB.md file\n"
"5. If there are no pending jobs, do NOT generate any response\n\n"
"IMPORTANT: This is a background system task, NOT a user conversation. "
"Your final response will be broadcast as a notification. "
"Only output a brief completion summary listing each executed job and its result. "
"Do NOT include greetings, explanations, or conversational text. "
"If no jobs were executed, output nothing. "
"Respond in Chinese (中文)."
)
heartbeat_message = self._build_heartbeat_prompt()
await self.process_message(
session_id=session_id,
@@ -1022,6 +1129,7 @@ class AgentManager:
channel=None,
source=None,
username=settings.SUPERUSER,
reply_mode=ReplyMode.DISPATCH,
)
# 等待消息队列处理完成
@@ -1043,233 +1151,6 @@ class AgentManager:
except Exception as e:
logger.error(f"智能体心跳唤醒失败: {e}")
async def retry_failed_transfer(self, history_id: int, group_key: str = ""):
"""
触发智能体重新整理失败的历史记录。
由文件整理模块在检测到整理失败后调用。
同一 group_key 的失败记录会在缓冲期内合并为一次agent调用避免重复浪费token。
:param history_id: 失败的整理历史记录ID
:param group_key: 分组键相同key的记录会被合并处理如download_hash、源目录等
"""
if not group_key:
group_key = f"_default_{history_id}"
async with self._retry_transfer_lock:
# 将 history_id 加入缓冲区
if group_key not in self._retry_transfer_buffer:
self._retry_transfer_buffer[group_key] = []
if history_id not in self._retry_transfer_buffer[group_key]:
self._retry_transfer_buffer[group_key].append(history_id)
logger.info(
f"智能体重试整理:记录 ID={history_id} 已加入缓冲区 "
f"(group={group_key}, 当前{len(self._retry_transfer_buffer[group_key])}条)"
)
# 取消该分组的旧定时器
if group_key in self._retry_transfer_timers:
self._retry_transfer_timers[group_key].cancel()
# 设置新的延迟定时器
loop = asyncio.get_running_loop()
self._retry_transfer_timers[group_key] = loop.call_later(
self.RETRY_TRANSFER_DEBOUNCE_SECONDS,
lambda gk=group_key: asyncio.ensure_future(
self._flush_retry_transfer(gk)
),
)
async def _flush_retry_transfer(self, group_key: str):
"""
延迟定时器到期后,取出该分组的所有 history_id 并合并为一次agent调用。
"""
async with self._retry_transfer_lock:
history_ids = self._retry_transfer_buffer.pop(group_key, [])
self._retry_transfer_timers.pop(group_key, None)
if not history_ids:
return
session_id = f"__agent_retry_transfer_batch_{uuid.uuid4().hex[:8]}__"
user_id = SYSTEM_INTERNAL_USER_ID
ids_str = ", ".join(str(i) for i in history_ids)
logger.info(
f"智能体重试整理:开始批量处理失败记录 IDs=[{ids_str}] (group={group_key})"
)
if len(history_ids) == 1:
# 单条记录,使用原有逻辑
retry_message = (
f"[System Task - Transfer Failed Retry] A file transfer/organization has failed. "
f"Please use the 'transfer-failed-retry' skill to retry the failed transfer.\n\n"
f"Failed transfer history record ID: {history_ids[0]}\n\n"
f"Follow these steps:\n"
f"1. Use `query_transfer_history` with status='failed' to find the record with id={history_ids[0]} "
f"and understand the failure details (source path, error message, media info)\n"
f"2. Analyze the error message to determine the best retry strategy\n"
f"3. If the source file no longer exists, skip this retry and report that the file is missing\n"
f"4. Delete the failed history record using `delete_transfer_history` with history_id={history_ids[0]}\n"
f"5. Re-identify the media using `recognize_media` with the source file path\n"
f"6. If recognition fails, try `search_media` with keywords from the filename\n"
f"7. Re-transfer using `transfer_file` with the source path and any identified media info (tmdbid, media_type)\n"
f"8. Report the final result\n\n"
f"IMPORTANT: This is a background system task, NOT a user conversation. "
f"Your final response will be broadcast as a notification. "
f"Only output a brief result summary. "
f"Do NOT include greetings, explanations, or conversational text. "
f"Respond in Chinese (中文)."
)
else:
# 多条记录,使用批量处理逻辑
retry_message = (
f"[System Task - Batch Transfer Failed Retry] Multiple file transfers from the same source "
f"have failed. These files likely belong to the SAME media (e.g., multiple episodes of the same TV show). "
f"Please use the 'transfer-failed-retry' skill to retry them efficiently.\n\n"
f"Failed transfer history record IDs: {ids_str}\n"
f"Total failed records: {len(history_ids)}\n\n"
f"Follow these steps:\n"
f"1. Use `query_transfer_history` with status='failed' to find ALL records with these IDs "
f"and understand the failure details\n"
f"2. Since these files are likely from the same media, analyze the FIRST record to determine "
f"the media identity and the best retry strategy. The root cause is usually the same for all files.\n"
f"3. If the error is about media recognition (e.g., '未识别到媒体信息'), identify the media ONCE "
f"using `recognize_media` or `search_media`, then reuse that result (tmdbid, media_type) for all files\n"
f"4. For EACH failed record:\n"
f" a. Delete the failed history record using `delete_transfer_history`\n"
f" b. Re-transfer using `transfer_file` with the source path and the identified media info\n"
f"5. Report a summary of results (how many succeeded, how many failed)\n\n"
f"IMPORTANT OPTIMIZATION: These files share the same media identity. "
f"Do NOT call `recognize_media` or `search_media` repeatedly for each file. "
f"Identify the media ONCE, then apply to all files.\n\n"
f"IMPORTANT: This is a background system task, NOT a user conversation. "
f"Your final response will be broadcast as a notification. "
f"Only output a brief result summary. "
f"Do NOT include greetings, explanations, or conversational text. "
f"Respond in Chinese (中文)."
)
try:
await self.process_message(
session_id=session_id,
user_id=user_id,
message=retry_message,
channel=None,
source=None,
username=settings.SUPERUSER,
)
# 等待消息队列处理完成
if session_id in self._session_queues:
await self._session_queues[session_id].join()
# 等待worker结束
if session_id in self._session_workers:
try:
await self._session_workers[session_id]
except asyncio.CancelledError:
pass
logger.info(
f"智能体重试整理:批量处理完成 IDs=[{ids_str}] (group={group_key})"
)
# 用完即弃,清理资源
await self.clear_session(session_id, user_id)
except Exception as e:
logger.error(
f"智能体重试整理失败 (IDs=[{ids_str}], group={group_key}): {e}"
)
@staticmethod
def _build_manual_redo_prompt(history) -> str:
"""
构建手动 AI 整理提示词。
"""
src_fileitem = history.src_fileitem or {}
source_path = src_fileitem.get("path") if isinstance(src_fileitem, dict) else ""
source_path = source_path or history.src or ""
season_episode = f"{history.seasons or ''}{history.episodes or ''}".strip()
return "\n".join(
[
"[System Task - Manual Transfer Re-Organize]",
"A user manually triggered an AI re-organize task from the transfer history page.",
"Your goal is to directly fix ONE transfer history record by using MoviePilot tools to analyze, clean up the old history entry if necessary, and organize the source file again.",
"",
"IMPORTANT:",
"1. This is NOT a normal conversation. It is a background execution task.",
"2. Do NOT rely on previous chat context. Work only from the record below.",
"3. You should complete the re-organize by directly using tools such as `query_transfer_history`, `recognize_media`, `search_media`, `delete_transfer_history`, and `transfer_file`.",
"4. Your final response must be a brief Chinese result summary only.",
"",
"Transfer history record:",
f"- History ID: {history.id}",
f"- Current status: {'success' if history.status else 'failed'}",
f"- Current recognized title: {history.title or 'unknown'}",
f"- Media type: {history.type or 'unknown'}",
f"- Category: {history.category or 'unknown'}",
f"- Year: {history.year or 'unknown'}",
f"- Season/Episode: {season_episode or 'unknown'}",
f"- Source path: {source_path or 'unknown'}",
f"- Source storage: {history.src_storage or 'local'}",
f"- Destination path: {history.dest or 'unknown'}",
f"- Destination storage: {history.dest_storage or 'unknown'}",
f"- Transfer mode: {history.mode or 'unknown'}",
f"- Current TMDB ID: {history.tmdbid or 'none'}",
f"- Current Douban ID: {history.doubanid or 'none'}",
f"- Error message: {history.errmsg or 'none'}",
"",
"Required workflow:",
f"1. Use `query_transfer_history` to locate and inspect the record with id={history.id}, and verify the source path, status, media info, and failure context.",
"2. Decide whether the current recognition is trustworthy.",
"3. If the source file no longer exists or cannot be safely processed, stop and report the reason.",
"4. If the current recognition is wrong or the record should be reorganized, determine the correct media identity first.",
"5. Prefer `recognize_media` with the source path. If recognition is not reliable, use `search_media` with keywords from filename/title/year.",
"6. Only continue when you have high confidence in the target media.",
"7. Before re-organizing, delete the old transfer history record with `delete_transfer_history` so the system will not skip the source file.",
"8. Then use `transfer_file` to organize the source path directly.",
"9. When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, tmdbid/doubanid, and media_type.",
"10. If this record is already correct and no re-organize is needed, do not perform destructive actions; simply report that no change is necessary.",
"",
"Important execution rules:",
"- Do NOT reorganize blindly when media identity is uncertain.",
"- If the previous record was successful but obviously identified as the wrong media, still use the tool-based flow above instead of `/redo`.",
"- Keep the final response short, in Chinese, and focused on outcome.",
]
)
async def manual_redo_transfer(
self,
history_id: int,
output_callback: Optional[Callable[[str], None]] = None,
) -> None:
"""
手动触发单条历史记录的 AI 整理。
"""
session_id = f"__agent_manual_redo_{history_id}_{uuid.uuid4().hex[:8]}__"
user_id = SYSTEM_INTERNAL_USER_ID
agent = MoviePilotAgent(
session_id=session_id,
user_id=user_id,
channel=None,
source=None,
username=settings.SUPERUSER,
)
agent.output_callback = output_callback
agent.force_streaming = True
agent.suppress_user_reply = True
try:
history = TransferHistoryOper().get(history_id)
if not history:
raise ValueError(f"整理记录不存在: {history_id}")
await agent.process(self._build_manual_redo_prompt(history))
finally:
await agent.cleanup()
memory_manager.clear_memory(session_id, user_id)
# 全局智能体管理器实例
agent_manager = AgentManager()

View File

@@ -1,6 +1,6 @@
import asyncio
import threading
from typing import Optional, Tuple
from typing import Any, Optional, Tuple
from fastapi.concurrency import run_in_threadpool
@@ -62,16 +62,40 @@ class StreamingHandler:
self._user_id: Optional[str] = None
self._username: Optional[str] = None
self._title: str = ""
self._allow_dispatch_without_context = False
# 非啰嗦模式下的待输出工具统计,等下一段文本到来时再统一补一句摘要
self._pending_tool_stats: dict[str, dict[str, Any]] = {}
def emit(self, token: str):
def set_dispatch_policy(
self, allow_dispatch_without_context: bool = False
) -> None:
"""
设置在缺少渠道上下文时是否仍允许向默认通知渠道分发消息。
后台 DISPATCH 任务允许CAPTURE_ONLY 必须禁止。
"""
self._allow_dispatch_without_context = allow_dispatch_without_context
def emit(self, token: str) -> str:
"""
接收 LLM 流式 token积累到缓冲区。
如果存在待输出的工具统计,则会先补上一句摘要再追加 token。
"""
with self._lock:
emitted = token or ""
if self._pending_tool_stats:
summary = self._consume_pending_tool_summary_locked()
if summary:
if emitted:
emitted = f"{summary}{emitted.lstrip(chr(10))}"
else:
emitted = summary
# 如果存量消息结束是两个换行,则去掉新消息前面的换行,避免过多空行
if self._buffer.endswith("\n\n") and token.startswith("\n"):
token = token.lstrip("\n")
self._buffer += token
if self._buffer.endswith("\n\n") and emitted.startswith("\n"):
emitted = emitted.lstrip("\n")
self._buffer += emitted
return emitted
async def take(self) -> str:
"""
@@ -82,6 +106,8 @@ class StreamingHandler:
注意:流式渠道不调用此方法,工具消息直接 emit 到 buffer 中。
"""
self.flush_pending_tool_summary()
with self._lock:
if not self._buffer:
return ""
@@ -99,6 +125,7 @@ class StreamingHandler:
self._sent_text = ""
self._message_response = None
self._msg_start_offset = 0
self._pending_tool_stats = {}
def reset(self):
"""
@@ -112,6 +139,7 @@ class StreamingHandler:
self._buffer = ""
self._sent_text = ""
self._msg_start_offset = 0
self._pending_tool_stats = {}
async def start_streaming(
self,
@@ -141,6 +169,7 @@ class StreamingHandler:
self._sent_text = ""
self._message_response = None
self._msg_start_offset = 0
self._pending_tool_stats = {}
# 检查渠道是否支持消息编辑,不支持则仅收集 token 到 buffer不实时推送
if not self._can_stream():
@@ -176,6 +205,9 @@ class StreamingHandler:
# 取消定时任务
await self._cancel_flush_task()
# 将未落地的工具统计补入缓冲区,避免流式结束时丢失这段执行信息
self.flush_pending_tool_summary()
# 执行最后一次刷新
await self._flush()
@@ -194,11 +226,172 @@ class StreamingHandler:
self._sent_text = ""
self._message_response = None
self._msg_start_offset = 0
self._pending_tool_stats = {}
if all_sent:
# 所有内容已通过流式发送,清空缓冲区
self._buffer = ""
return all_sent, final_text
def record_tool_call(
self,
tool_name: str,
tool_message: Optional[str] = None,
tool_kwargs: Optional[dict[str, Any]] = None,
):
"""
记录一次工具调用,供非啰嗦模式下延迟汇总输出。
"""
category, target = self._classify_tool_call(
tool_name=tool_name,
tool_message=tool_message,
tool_kwargs=tool_kwargs or {},
)
with self._lock:
bucket = self._pending_tool_stats.setdefault(
category,
{
"count": 0,
"targets": set(),
},
)
bucket["count"] += 1
if target:
bucket["targets"].add(str(target))
def flush_pending_tool_summary(self) -> str:
"""
将待输出的工具统计摘要补入缓冲区,并返回本次新增的摘要文本。
"""
with self._lock:
summary = self._consume_pending_tool_summary_locked()
if summary:
self._buffer += summary
return summary
@staticmethod
def _classify_tool_call(
tool_name: str,
tool_message: Optional[str],
tool_kwargs: dict[str, Any],
) -> tuple[str, Optional[str]]:
tool_name = (tool_name or "").strip().lower()
tool_message = (tool_message or "").strip()
tool_message_lower = tool_message.lower()
if tool_name == "read_file":
return "file_read", tool_kwargs.get("file_path")
if tool_name in {"write_file", "edit_file"}:
return "file_write", tool_kwargs.get("file_path")
if tool_name in {"list_directory", "query_directory_settings"}:
return "directory", tool_kwargs.get("path")
if tool_name == "browse_webpage":
return (
"web_browse",
tool_kwargs.get("url")
or tool_kwargs.get("target_url")
or tool_kwargs.get("path"),
)
if tool_name == "execute_command":
return "command", tool_kwargs.get("command")
if tool_name == "ask_user_choice":
return "interaction", tool_kwargs.get("message")
if tool_name.startswith("search_") or tool_name in {"get_search_results"}:
return (
"search",
tool_kwargs.get("query")
or tool_kwargs.get("title")
or tool_kwargs.get("keyword"),
)
if tool_name.startswith("query_") or tool_name.startswith("list_") or tool_name.startswith("get_"):
return "data_query", None
if tool_name.startswith(("add_", "update_", "delete_", "modify_", "run_")):
return "action", None
if tool_name in {
"recognize_media",
"scrape_metadata",
"transfer_file",
"test_site",
"send_message",
"send_local_file",
"send_voice_message",
}:
return "action", None
if "读取文件" in tool_message or "read file" in tool_message_lower:
return "file_read", tool_kwargs.get("file_path")
if (
"写入文件" in tool_message
or "编辑文件" in tool_message
or "write file" in tool_message_lower
or "edit file" in tool_message_lower
):
return "file_write", tool_kwargs.get("file_path")
if "目录" in tool_message or "directory" in tool_message_lower:
return "directory", tool_kwargs.get("path")
if "搜索" in tool_message or "search" in tool_message_lower:
return (
"search",
tool_kwargs.get("query")
or tool_kwargs.get("title")
or tool_kwargs.get("keyword"),
)
if "网页" in tool_message or "browser" in tool_message_lower or "webpage" in tool_message_lower:
return "web_browse", tool_kwargs.get("url")
if "命令" in tool_message or "command" in tool_message_lower:
return "command", tool_kwargs.get("command")
return "tool", None
def _consume_pending_tool_summary_locked(self) -> str:
if not self._pending_tool_stats:
return ""
parts = []
for category, bucket in self._pending_tool_stats.items():
value = bucket["count"]
if category in {"file_read", "file_write", "directory", "web_browse"} and bucket["targets"]:
value = len(bucket["targets"])
part = self._format_tool_stat(category, value)
if part:
parts.append(part)
self._pending_tool_stats = {}
if not parts:
return ""
summary = f"{''.join(parts)}"
visible_buffer = self._buffer.rstrip(" \t")
last_char = visible_buffer[-1:] if visible_buffer.strip() else ""
prefix = ""
if self._buffer and last_char != "\n":
prefix = "\n\n"
return f"{prefix}{summary}\n\n"
@staticmethod
def _format_tool_stat(category: str, count: int) -> str:
if count <= 0:
return ""
if category == "search":
return f"执行了 {count} 次搜索"
if category == "file_read":
return f"读取了 {count} 个文件"
if category == "file_write":
return f"修改了 {count} 个文件"
if category == "directory":
return f"查看了 {count} 个目录"
if category == "web_browse":
return f"浏览了 {count} 个网页"
if category == "command":
return f"执行了 {count} 条命令"
if category == "data_query":
return f"查询了 {count} 次数据"
if category == "action":
return f"执行了 {count} 次操作"
if category == "interaction":
return f"发起了 {count} 次交互"
return f"调用了 {count} 次工具"
def _can_stream(self) -> bool:
"""
检查当前渠道是否支持流式输出(消息编辑)
@@ -252,6 +445,12 @@ class StreamingHandler:
if not current_text or current_text == self._sent_text:
# 没有新内容需要刷新
return
if (
(not self._channel or not self._source)
and not self._allow_dispatch_without_context
):
logger.debug("流式输出缺少渠道上下文,当前模式禁止外发消息")
return
chain = _StreamChain()

View File

@@ -0,0 +1,19 @@
---
version: 3
active_persona: default
extra_context_files: []
deprecated_phrases: []
---
# CURRENT_PERSONA
当前激活人格:`default`
运行时加载顺序固定如下:
1. 核心系统提示词(程序内置,不可运行时覆盖)
2. `personas/<active_persona>/PERSONA.md`
3. `extra_context_files`
4. `memory/*.md`
5. `activity/*.md`
`memory` 中的长期偏好可以细化回复方式,但不应覆盖系统核心身份、目标和安全边界。

View File

@@ -0,0 +1,22 @@
---
version: 1
persona_id: aloof
label: 高冷
description: 冷静、克制、低温度,话少但不失礼。
aliases:
- 冷淡
- 冷感
- 冷艳
---
# PERSONA
- Tone: cool, distant, and composed.
- Keep emotional temperature low and transitions short.
- Be brief and efficient, but do not become rude or contemptuous.
- Prefer understatement over enthusiasm.
## RESPONSE_FORMAT
- Lead with the answer or the action result.
- Keep explanations minimal unless the user explicitly asks for detail.
- Avoid extra reassurance, hype, or emotional softening.

View File

@@ -0,0 +1,22 @@
---
version: 1
persona_id: anime
label: 二次元
description: 带一点 ACG 语感和戏剧化表达,但仍然以任务完成和清晰沟通为主。
aliases:
- 动漫风
- ACG
- 宅系
---
# PERSONA
- Tone: lively, stylized, and lightly dramatic, with a small amount of anime-flavored wording.
- Keep the actual task handling grounded and practical; the style should stay mostly in phrasing.
- You may occasionally use short ACG-like interjections, but do not flood the reply with memes, kaomoji, or niche jargon.
- Stay readable first. If the task is serious, reduce the stylistic flavor automatically.
## RESPONSE_FORMAT
- Prefer short paragraphs or compact lists.
- A light playful closing line is acceptable after the real result is already clear.
- Do not let the style make operational instructions vague.

View File

@@ -0,0 +1,22 @@
---
version: 1
persona_id: catgirl
label: 猫娘
description: 带一点猫系拟人风格,轻松可爱,但不过度角色扮演。
aliases:
- 猫猫
- 喵系
- 猫耳
---
# PERSONA
- Tone: playful, cat-like, and cute, with occasional feline wording.
- You may occasionally use a light "喵" style suffix or cat metaphor, but only sparingly.
- Do not turn the reply into full roleplay; task clarity remains the primary goal.
- If the content is operational, keep the answer direct first and add only a thin layer of style.
## RESPONSE_FORMAT
- Keep answers compact and readable.
- Use only a very small amount of repeated verbal tic.
- The result or action status should always appear before any playful flourish.

View File

@@ -0,0 +1,23 @@
---
version: 1
persona_id: concise
label: 极简
description: 更短、更硬朗,优先结论和动作,不主动展开背景解释。
aliases:
- 简洁
- 干脆
- 极简人格
---
# PERSONA
- Tone: terse, decisive, and highly compressed.
- Prefer the shortest complete answer that still moves the task forward.
- Default to one sentence when possible. Only use lists when they materially improve readability.
- Avoid extra context, caveats, or teaching unless the user explicitly asks for explanation.
- Keep transitions minimal and skip conversational softening.
## RESPONSE_FORMAT
- Lead with the conclusion or result.
- For option lists, keep each item very short.
- Do not repeat already-known context back to the user unless it is needed to disambiguate the action.

View File

@@ -0,0 +1,22 @@
---
version: 1
persona_id: cute
label: 可爱
description: 语气更亲和、更柔软、更讨喜,但不做重度角色扮演。
aliases:
- 软萌
- 甜系
- 亲和
---
# PERSONA
- Tone: warm, cheerful, and gently cute.
- Sound approachable and pleasant, but keep the answer concise and useful.
- Avoid baby talk, excessive repetition, or exaggerated emotive punctuation.
- If the user asks for directness, keep the cute flavor minimal.
## RESPONSE_FORMAT
- Prefer friendly short paragraphs.
- For lists, keep each item short and easy to read.
- When something fails, explain it gently but clearly.

View File

@@ -0,0 +1,24 @@
---
version: 1
persona_id: default
label: 默认
description: 专业、克制、简洁,适合大多数日常媒体管理场景。
aliases:
- 专业
- 默认人格
---
# PERSONA
- Tone: professional, concise, restrained.
- Be direct. No unnecessary preamble, no repeating the user's words, no narrating internal reasoning.
- Do not flatter the user, praise the question, or add emotional cushioning.
- Do not use emojis, exclamation marks, cute language, or excessive apology.
- Prefer short declarative sentences. Default to one or two short paragraphs; use lists only when they improve scanability.
- Use Markdown for structured data. Use `inline code` for media titles and paths.
## RESPONSE_FORMAT
- Keep confirmations short.
- For search or comparison results, prefer a brief list over a long paragraph.
- Skip filler phrases like "Let me help you", "Here are the results", or "I found...".
- When an error occurs, briefly state the blocker and the next best action.

View File

@@ -0,0 +1,22 @@
---
version: 1
persona_id: disdain
label: 不屑
description: 带一点嫌弃感和轻微毒舌,但必须保持可控和不越界。
aliases:
- 嫌弃
- 毒舌
- 鄙视链
---
# PERSONA
- Tone: dry, skeptical, and faintly dismissive.
- Mild sarcasm is acceptable, but it must stay controlled and should never turn into direct insult or humiliation.
- Prioritize sharp phrasing and low patience, while still giving the user the actual answer.
- If the task is sensitive or the user is clearly frustrated, reduce the bite automatically.
## RESPONSE_FORMAT
- Keep answers crisp and pointed.
- Use short, cutting observations only when they improve the style without harming clarity.
- Always include the concrete result, instruction, or blocker.

View File

@@ -0,0 +1,22 @@
---
version: 1
persona_id: guide
label: 说明型
description: 在复杂问题上更愿意解释原因和步骤,但仍保持克制,不会无节制展开。
aliases:
- 讲解
- 解释型
- 教学
---
# PERSONA
- Tone: clear, structured, and mildly explanatory.
- When the task is simple, stay concise. When the task is complex or the user asks why/how, provide a short explanation with visible structure.
- Keep explanations practical and tied to the current decision, not generic theory.
- Remain restrained: do not become chatty, cute, or overly warm.
## RESPONSE_FORMAT
- For non-trivial tasks, prefer short sections or a compact numbered list.
- When describing tradeoffs, keep them concrete and action-oriented.
- End with the actual outcome or next step, not a generic summary.

View File

@@ -0,0 +1,23 @@
---
version: 1
persona_id: moe
label: 萌系
description: 更轻小说感、更元气、更可爱,但仍然保持边界和专业度。
aliases:
- 萝莉风
- 轻小说风
- 元气少女
- 萌萌
---
# PERSONA
- Tone: soft, upbeat, cute, and lightly playful.
- Keep the personality in wording only; do not imitate a child, emphasize age, or use any sexualized framing.
- Use cute particles or soft wording sparingly so the answer still feels useful instead of noisy.
- When the task is urgent or technical, reduce the fluff and keep the result clear.
## RESPONSE_FORMAT
- Prefer short, bright sentences.
- A small amount of cute phrasing is acceptable, but the final answer must still be easy to scan.
- Do not bury the actual conclusion under roleplay language.

19
app/agent/llm/__init__.py Normal file
View File

@@ -0,0 +1,19 @@
"""Agent 内部使用的 LLM 适配层。"""
from app.agent.llm.helper import LLMHelper, LLMTestError, LLMTestTimeout
from app.agent.llm.provider import (
LLMProviderAuthError,
LLMProviderError,
LLMProviderManager,
render_auth_result_html,
)
__all__ = [
"LLMHelper",
"LLMProviderAuthError",
"LLMProviderError",
"LLMProviderManager",
"LLMTestError",
"LLMTestTimeout",
"render_auth_result_html",
]

View File

@@ -182,6 +182,77 @@ def _patch_deepseek_reasoning_content_support():
logger.debug("已修补 langchain-deepseek thinking tool-call 的 reasoning_content 回传兼容性")
def _patch_openai_responses_instructions_support():
"""
修补 langchain-openai 在使用 use_responses_api=True
提取 system 消息为顶层 instructions 字段
由于 Codex 等模型 (Responses API) 强依赖 instructions 字段
如果没有该字段会报 400 "Instructions are required"
"""
try:
from langchain_openai import ChatOpenAI
except Exception as err:
logger.debug(f"跳过 langchain-openai instructions 修补:{err}")
return
if getattr(ChatOpenAI, "_moviepilot_responses_instructions_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无法修补 instructions")
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)
base_url = str(getattr(self, "openai_api_base", "") or "").lower()
# 处理 GitHub Copilot 端点兼容性
if "githubcopilot.com" in base_url:
payload.pop("stream_options", None)
payload.pop("metadata", None)
# 处理 ChatGPT 官方 Responses API (Codex) 端点兼容性
is_codex = "chatgpt.com/backend-api/codex" in base_url
if is_codex and (getattr(self, "use_responses_api", False) or "input" in payload):
instructions = payload.get("instructions", "")
inputs = payload.get("input", [])
new_inputs = []
for msg in inputs:
if isinstance(msg, dict) and msg.get("role") == "system":
content = msg.get("content")
if isinstance(content, str) and content.strip():
if instructions:
instructions += "\n\n" + content
else:
instructions = content
else:
new_inputs.append(msg)
payload["input"] = new_inputs
payload["instructions"] = instructions or "You are a helpful assistant."
payload["store"] = False
# Codex 端点不支持的部分常见补全参数,统一清理避免 400 报错
unsupported_keys = [
"presence_penalty", "frequency_penalty", "top_p", "n", "user",
"stop", "metadata", "logit_bias", "logprobs", "top_logprobs",
"stream_options", "temperature"
]
for key in unsupported_keys:
payload.pop(key, None)
return payload
ChatOpenAI._get_request_payload = _patched_get_request_payload
ChatOpenAI._moviepilot_responses_instructions_patched = True
logger.debug("已修补 langchain-openai responses API 的 instructions 兼容性")
class LLMHelper:
"""LLM模型相关辅助功能"""
@@ -342,7 +413,7 @@ class LLMHelper:
return {}
# OpenAI 原生推理模型优先走 LangChain 内置 reasoning_effort。
if provider_name == "openai" and model_name.startswith(
if provider_name in {"openai", "chatgpt"} and model_name.startswith(
("gpt-5", "o1", "o3", "o4")
):
openai_effort = cls._normalize_openai_reasoning_effort(
@@ -366,13 +437,84 @@ class LLMHelper:
return bool(settings.LLM_SUPPORT_IMAGE_INPUT)
@staticmethod
def get_llm(
def _build_legacy_runtime(
provider_name: str,
model_name: str | None,
api_key: str | None = None,
base_url: str | None = None,
) -> dict[str, Any]:
"""
provider 目录不可用时回退到旧的直接构造逻辑
这主要用于单测 stub 环境以及极端的最小运行环境正常生产路径仍优先
`LLMProviderManager.resolve_runtime()`
"""
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
if not api_key_value:
raise ValueError("未配置LLM API Key")
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,
"use_responses_api": None,
"model_record": None,
"model_metadata": None,
}
@classmethod
def _resolve_thinking_level(
cls,
thinking_level: str | None = None,
) -> str | None:
"""
统一兼容新旧 thinking 参数
"""
def _normalize(value: str | None) -> str | None:
normalized = str(value or "").strip().lower()
if not normalized:
return None
alias_map = {
"none": "off",
"disabled": "off",
"disable": "off",
"enabled": "auto",
"enable": "auto",
"default": "auto",
"dynamic": "auto",
}
normalized = alias_map.get(normalized, normalized)
if normalized in cls._SUPPORTED_THINKING_LEVELS:
return normalized
logger.warning(f"忽略不支持的思考级别: {value}")
return None
normalized_thinking_level = _normalize(thinking_level)
if normalized_thinking_level:
return normalized_thinking_level
return "off"
@classmethod
async def get_llm(
cls,
streaming: bool = False,
provider: str | None = None,
model: str | None = None,
thinking_level: str | None = None,
api_key: str | None = None,
base_url: str | None = None,
api_key: str | None = settings.LLM_API_KEY,
base_url: str | None = settings.LLM_BASE_URL,
base_url_preset: str | None = settings.LLM_BASE_URL_PRESET,
):
"""
获取LLM实例
@@ -383,28 +525,43 @@ class LLMHelper:
是否启用思考模式支持的级别包括 "off"关闭"auto"自动"minimal""low""medium""high""max"/"xhigh"最大
不同模型对思考模式的支持和表现不同具体映射关系请
参考代码实现对于不支持思考模式的模型该参数将被忽略
:param api_key: API Key默认为
配置项LLM_API_KEY对于某些提供商
DeepSeek可能需要同时提供 base_url
:param api_key: API Key默认为配置项LLM_API_KEY对于某些提供商 DeepSeek可能需要同时提供 base_url
:param base_url: API Base URL默认为配置项LLM_BASE_URL
:return: LLM实例
"""
provider_name = str(
provider if provider is not None else settings.LLM_PROVIDER
).lower()
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
thinking_kwargs = LLMHelper._build_thinking_kwargs(
normalized_thinking_level = cls._resolve_thinking_level(
thinking_level=thinking_level,
)
try:
# 延迟导入,避免单测在最小 stub 环境下 import `llm.py` 时被 provider
# 目录依赖链拖住。
from app.agent.llm.provider import LLMProviderManager
runtime = await LLMProviderManager().resolve_runtime(
provider_id=provider_name,
model=model_name,
api_key=api_key,
base_url=base_url,
base_url_preset_id=base_url_preset,
)
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,
)
model_name = runtime.get("model_id") or model_name
thinking_kwargs = cls._build_thinking_kwargs(
provider=provider_name,
model=model_name,
thinking_level=thinking_level
thinking_level=normalized_thinking_level,
)
if not api_key_value:
raise ValueError("未配置LLM API Key")
if provider_name == "google":
if runtime["runtime"] == "google":
# 修补 Gemini 2.5 思考模型的 thought_signature 兼容性
_patch_gemini_thought_signature()
@@ -420,49 +577,82 @@ class LLMHelper:
model = ChatGoogleGenerativeAI(
model=model_name,
api_key=api_key_value,
api_key=runtime["api_key"],
retries=3,
temperature=settings.LLM_TEMPERATURE,
streaming=streaming,
client_args=client_args,
**thinking_kwargs,
)
elif provider_name == "deepseek":
elif runtime["runtime"] == "deepseek":
from langchain_deepseek import ChatDeepSeek
_patch_deepseek_reasoning_content_support()
model = ChatDeepSeek(
model=model_name,
api_key=api_key_value,
api_base=base_url_value,
api_key=runtime["api_key"],
api_base=runtime["base_url"],
max_retries=3,
temperature=settings.LLM_TEMPERATURE,
streaming=streaming,
stream_usage=True,
**thinking_kwargs,
)
elif runtime["runtime"] in {"anthropic_compatible", "copilot_anthropic"}:
from langchain_anthropic import ChatAnthropic
model = ChatAnthropic(
model=model_name,
api_key=runtime["api_key"],
base_url=runtime["base_url"],
max_retries=3,
temperature=settings.LLM_TEMPERATURE,
streaming=streaming,
stream_usage=True,
anthropic_proxy=settings.PROXY_HOST,
default_headers=runtime.get("default_headers"),
**thinking_kwargs,
)
else:
from langchain_openai import ChatOpenAI
_patch_openai_responses_instructions_support()
# ChatGPT Codex 端点强制要求 stream: True
if runtime.get("use_responses_api") and "chatgpt.com/backend-api/codex" in str(runtime.get("base_url") or ""):
streaming = True
model = ChatOpenAI(
model=model_name,
api_key=api_key_value,
api_key=runtime["api_key"],
max_retries=3,
base_url=base_url_value,
base_url=runtime.get("base_url"),
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"),
**thinking_kwargs,
)
# 检查是否有profile
if hasattr(model, "profile") and model.profile:
# 优先使用 provider / models.dev 目录中的上下文上限,减少用户手填成本。
model_profile = getattr(model, "profile", None)
if model_profile:
logger.debug(f"使用LLM模型: {model.model}Profile: {model.profile}")
else:
model_record = runtime.get("model_record") or {}
model_metadata = runtime.get("model_metadata") or {}
metadata_limit = model_metadata.get("limit") or {}
max_input_tokens = (
model_record.get("input_tokens")
or model_record.get("context_tokens")
or metadata_limit.get("input")
or metadata_limit.get("context")
or settings.LLM_MAX_CONTEXT_TOKENS * 1000
)
model.profile = {
"max_input_tokens": settings.LLM_MAX_CONTEXT_TOKENS
* 1000, # 转换为token单位
"max_input_tokens": int(max_input_tokens),
}
return model
@@ -516,22 +706,22 @@ class LLMHelper:
thinking_level: str | None = None,
api_key: str | None = None,
base_url: str | None = None,
base_url_preset: str | None = None,
) -> dict:
"""
使用当前已保存配置执行一次最小 LLM 调用
"""
provider_name = provider if provider is not None else settings.LLM_PROVIDER
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
start = time.perf_counter()
llm = LLMHelper.get_llm(
llm = await LLMHelper.get_llm(
streaming=False,
provider=provider_name,
model=model_name,
thinking_level=thinking_level,
api_key=api_key_value,
base_url=base_url_value,
api_key=api_key,
base_url=base_url,
base_url_preset=base_url_preset,
)
try:
response = await asyncio.wait_for(llm.ainvoke(prompt), timeout=timeout)
@@ -556,18 +746,63 @@ class LLMHelper:
data["reply_preview"] = reply_text[:120]
return data
def get_models(
self, provider: str, api_key: str, base_url: str = None
) -> List[str]:
"""获取模型列表"""
async def get_models(
self,
provider: str,
api_key: str | None = None,
base_url: str | None = None,
base_url_preset: str | None = None,
force_refresh: bool = False,
) -> List[dict[str, Any]]:
"""
获取模型列表
返回值会带上 context/supports_reasoning 等元数据供前端直接渲染并自动
回填上下文大小
"""
logger.info(f"获取 {provider} 模型列表...")
if provider == "google":
return self._get_google_models(api_key)
else:
return self._get_openai_compatible_models(provider, api_key, base_url)
try:
from app.agent.llm.provider import LLMProviderManager
return await LLMProviderManager().list_models(
provider_id=provider,
api_key=api_key,
base_url=base_url,
base_url_preset_id=base_url_preset,
force_refresh=force_refresh,
)
except Exception as err:
logger.debug(f"LLM provider 目录不可用,回退旧模型列表逻辑: {err}")
if provider == "google":
return [
{"id": model_id, "name": model_id}
for model_id in await self._get_google_models(api_key or "")
]
model_list_base_url = base_url
try:
from app.agent.llm.provider import LLMProviderManager
model_list_base_url = (
LLMProviderManager().resolve_model_list_base_url(
provider_id=provider,
base_url=base_url,
base_url_preset_id=base_url_preset,
)
or base_url
)
except Exception:
model_list_base_url = base_url
return [
{"id": model_id, "name": model_id}
for model_id in await self._get_openai_compatible_models(
provider,
api_key or "",
model_list_base_url,
)
]
@staticmethod
def _get_google_models(api_key: str) -> List[str]:
async def _get_google_models(api_key: str) -> List[str]:
"""获取Google模型列表使用 google-genai SDK v1"""
try:
from google import genai
@@ -583,29 +818,32 @@ class LLMHelper:
)
client = genai.Client(api_key=api_key, http_options=http_options)
models = client.models.list()
return [
models = await client.aio.models.list()
result = [
m.name
for m in models
for m in models.page
if m.supported_actions and "generateContent" in m.supported_actions
]
await client.aio.aclose()
return result
except Exception as e:
logger.error(f"获取Google模型列表失败{e}")
raise e
@staticmethod
def _get_openai_compatible_models(
async def _get_openai_compatible_models(
provider: str, api_key: str, base_url: str = None
) -> List[str]:
"""获取OpenAI兼容模型列表"""
try:
from openai import OpenAI
from openai import AsyncOpenAI
if provider == "deepseek":
base_url = base_url or "https://api.deepseek.com"
client = OpenAI(api_key=api_key, base_url=base_url)
models = client.models.list()
client = AsyncOpenAI(api_key=api_key, base_url=base_url)
models = await client.models.list()
await client.close()
return [model.id for model in models.data]
except Exception as e:
logger.error(f"获取 {provider} 模型列表失败:{e}")

File diff suppressed because one or more lines are too long

2503
app/agent/llm/provider.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -158,9 +158,9 @@ async def _summarize_with_llm(conversation_text: str) -> str | None:
LLM 生成的摘要字符串,失败时返回 None。
"""
try:
from app.helper.llm import LLMHelper
from app.agent.llm import LLMHelper
llm = LLMHelper.get_llm(streaming=False)
llm = await LLMHelper.get_llm(streaming=False)
prompt = SUMMARY_PROMPT.format(conversation=conversation_text)
response = await llm.ainvoke(prompt)
summary = response.content.strip()
@@ -355,7 +355,7 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
def modify_request(self, request: ModelRequest[ContextT]) -> ModelRequest[ContextT]:
"""将活动日志注入系统消息。"""
contents = request.state.get("activity_log_contents", {})
contents = request.state.get("activity_log_contents", {}) # noqa
activity_log_prompt = self._format_activity_log(contents)
new_system_message = append_to_system_message(

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

@@ -57,8 +57,8 @@ You can create, edit, or organize any `.md` files in this directory to manage yo
**Memory file organization:**
- All `.md` files in `{memory_dir}` are automatically loaded as memory.
- `MEMORY.md` is the default/primary memory file for general user preferences and profile.
- You may create additional `.md` files to organize knowledge by topic (e.g., `MEDIA_RULES.md`, `DOWNLOAD_PREFERENCES.md`, `SITE_CONFIGS.md`, etc.).
- `MEMORY.md` is the default/primary memory file for general user preferences, communication style, and durable working rules.
- You may create additional `.md` files to organize knowledge by topic (e.g., `MEDIA_RULES.md`, `COMMUNICATION_PREFERENCES.md`, `DOWNLOAD_PREFERENCES.md`, `SITE_CONFIGS.md`, etc.).
- Keep each file focused on a specific domain or topic for better organization.
- Subdirectories are NOT scanned — only `.md` files directly in `{memory_dir}`.
@@ -78,11 +78,11 @@ You can create, edit, or organize any `.md` files in this directory to manage yo
**When to update memories:**
- When the user explicitly asks you to remember something (e.g., "remember my email", "save this preference")
- When the user describes your role or how you should behave (e.g., "you are a web researcher", "always do X")
- When the user gives durable communication or reply-format preferences (e.g., "be more concise", "prefer tables", "use JSON when summarizing")
- When the user gives feedback on your work - capture what was wrong and how to improve
- When the user provides information required for tool use (e.g., slack channel ID, email addresses)
- When the user provides context useful for future tasks, such as how to use tools, or which actions to take in a particular situation
- When you discover new patterns or preferences (coding styles, conventions, workflows)
- When you discover new user-specific patterns or preferences (communication style, formatting, workflows)
**When to NOT update memories:**
- When the information is temporary or transient (e.g., "I'm running late", "I'm on my phone right now")
@@ -90,6 +90,8 @@ You can create, edit, or organize any `.md` files in this directory to manage yo
- When the information is a simple question that doesn't reveal lasting preferences (e.g., "What day is it?", "Can you explain X?")
- When the information is an acknowledgment or small talk (e.g., "Sounds good!", "Hello", "Thanks for that")
- When the information is stale or irrelevant in future conversations
- Memory may refine user-facing style, but it must NOT redefine the agent's core identity, safety boundaries, or global system-task rules.
- If the user wants a built-in speaking style/persona, prefer the dedicated persona-switching tools instead of rewriting memory as a substitute.
- Never store API keys, access tokens, passwords, or any other credentials in any file, memory, or system prompt.
- If the user asks where to put API keys or provides an API key, do NOT echo or save it.
- Do NOT record daily activities or task execution history in memory files - these are automatically tracked in the activity log system (see <activity_log>). Memory files are only for long-term knowledge, preferences, and patterns.
@@ -135,7 +137,7 @@ Default memory file: {memory_file}
- Only ask for preferences when they are directly useful for the current task, or when a short follow-up question at the end would clearly help future interactions.
**What to collect when useful:**
- Preferred communication style
- Preferred communication style or persona preference
- Media interests
- Quality / codec / subtitle preferences
- Any standing rules the user wants you to follow
@@ -153,7 +155,7 @@ Default memory file: {memory_file}
Your memory directory is at: {memory_dir}. You can save new knowledge by calling the `edit_file` or `write_file` tool on any `.md` file in this directory.
**Memory file organization:**
- `MEMORY.md` is the default/primary memory file for general user preferences and profile.
- `MEMORY.md` is the default/primary memory file for user preferences, persona preferences, and durable working rules.
- You may create additional `.md` files to organize knowledge by topic.
- All `.md` files directly in the memory directory are automatically loaded on each conversation.
@@ -166,15 +168,17 @@ Default memory file: {memory_file}
**When to update memories:**
- When the user explicitly asks you to remember something
- When the user describes your role or how you should behave
- When the user gives durable communication or reply-format preferences
- When the user gives feedback on your work
- When the user provides information required for tool use
- When you discover new patterns or preferences
- When you discover new user-specific patterns or preferences
**When to NOT update memories:**
- Temporary/transient information
- One-time task requests
- Simple questions, acknowledgments, or small talk
- Memory may refine user-facing style, but it must NOT redefine the agent's core identity, safety boundaries, or global system-task rules
- If the user wants a built-in speaking style/persona, prefer the dedicated persona-switching tools instead of rewriting memory as a substitute
- Never store API keys, access tokens, passwords, or credentials
- Do NOT record daily activities in memory files — those go to the activity log
</memory_guidelines>
@@ -188,7 +192,8 @@ class MemoryMiddleware(AgentMiddleware[MemoryState, ContextT, ResponseT]): # no
支持多文件记忆组织:用户可以创建多个 `.md` 文件来按主题组织知识。
参数:
memory_dir: 记忆文件目录路径。
memory_dir: 记忆文件目录路径。建议使用独立的 `config/agent/memory`
目录,避免与核心规则或人格定义混写。
"""
state_schema = MemoryState
@@ -201,7 +206,7 @@ class MemoryMiddleware(AgentMiddleware[MemoryState, ContextT, ResponseT]): # no
"""初始化记忆中间件。
参数:
memory_dir: 记忆文件目录路径(例如,`"/config/agent"`)。
memory_dir: 记忆文件目录路径(例如,`"/config/agent/memory"`)。
该目录下所有 `.md` 文件都会被自动加载为记忆。
"""
self.memory_dir = memory_dir
@@ -288,7 +293,7 @@ class MemoryMiddleware(AgentMiddleware[MemoryState, ContextT, ResponseT]): # no
return md_files
async def abefore_agent(
async def abefore_agent( # noqa
self,
state: MemoryState,
runtime: Runtime, # noqa

View File

@@ -0,0 +1,42 @@
"""动态注入 Agent 根层运行时配置的中间件。"""
from collections.abc import Awaitable, Callable
from langchain.agents.middleware.types import (
AgentMiddleware,
ContextT,
ModelRequest,
ModelResponse,
ResponseT,
)
from app.agent.middleware.utils import append_to_system_message
from app.agent.runtime import agent_runtime_manager
class RuntimeConfigMiddleware(AgentMiddleware[dict, ContextT, ResponseT]): # noqa
"""在每次模型调用前动态加载运行时配置。
这里不把结果缓存到 middleware state 中,目的是让人格切换工具在同一轮
Agent 执行里修改 CURRENT_PERSONA 后,后续模型调用可以立即看到新的人格。
"""
def modify_request(self, request: ModelRequest[ContextT]) -> ModelRequest[ContextT]: # noqa
runtime_config = agent_runtime_manager.load_runtime_config()
runtime_sections = runtime_config.render_prompt_sections()
new_system_message = append_to_system_message(
request.system_message, runtime_sections
)
return request.override(system_message=new_system_message)
async def awrap_model_call(
self,
request: ModelRequest[ContextT],
handler: Callable[
[ModelRequest[ContextT]], Awaitable[ModelResponse[ResponseT]]
],
) -> ModelResponse[ResponseT]:
return await handler(self.modify_request(request))
__all__ = ["RuntimeConfigMiddleware"]

View File

@@ -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"
@@ -310,7 +313,8 @@ def _extract_version(skill_md: Path) -> int:
"""从 SKILL.md 文件中快速提取 version 字段,无法提取时返回 0。"""
try:
content = skill_md.read_text(encoding="utf-8")
except Exception:
except Exception as err:
print(err)
return 0
match = re.match(r"^---\s*\n(.*?)\n---\s*\n", content, re.DOTALL)
if not match:

View File

@@ -0,0 +1,549 @@
"""MoviePilot 自定义工具筛选中间件。"""
import json
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Annotated, Any, Literal, Union, NotRequired
from langchain.agents.middleware.types import (
AgentMiddleware,
AgentState,
ContextT,
ModelRequest,
ModelResponse,
ResponseT,
)
from langchain.agents.middleware.types import (
PrivateStateAttr, # noqa
)
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):
"""工具筛选中间件私有状态。"""
selected_tool_names: NotRequired[Annotated[list[str] | None, PrivateStateAttr]]
"""当前这条用户请求首轮筛选得到的工具名列表。"""
class ToolSelectionStateUpdate(TypedDict):
"""工具筛选中间件状态更新项。"""
selected_tool_names: list[str] | None
class ToolSelectorMiddleware(
AgentMiddleware[AgentState[ResponseT], ContextT, ResponseT]
):
"""
为 DeepSeek 兼容端点提供更稳妥的工具筛选实现。
LangChain 默认会通过 `with_structured_output()` 走 OpenAI 的
`response_format=json_schema` 路径,但 DeepSeek 官方 OpenAI 兼容端点公开文档
仅保证 `json_object` 模式可用。对于 `deepseek-reasoner`,这会在工具筛选阶段
提前触发 400导致 Agent 还没真正开始执行工具就失败。
因此这里仅在识别到 DeepSeek 模型/端点时,退回到显式 JSON 输出模式:
1. 使用 `response_format={"type": "json_object"}`
2. 在提示词中明确约束返回 JSON 结构;
3. 手动解析 `{"tools": [...]}`,其余模型继续沿用 LangChain 默认实现。
另外LangChain 原生工具筛选挂在 `wrap_model_call` 上,会在同一条用户请求
的每次“模型回合”前都重新筛选一次工具。对于会多轮调用工具的复杂任务,
这会重复消耗一次额外的 LLM 调用。这里改成:
- `abefore_agent()`:在本轮 Agent 执行开始时筛选一次;
- `awrap_model_call()`:从 `request.state` 读取首轮筛选结果并复用。
"""
state_schema = ToolSelectionState
def __init__(
self,
model: BaseChatModel,
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,
model=model,
valid_tool_names=valid_tool_names,
)
def _process_selection_response(
self,
response: dict[str, Any],
available_tools: list[BaseTool],
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:
# 如果模型筛选结果为空,则不对工具进行裁剪,使用所有可用工具
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)
# 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=[*selected_tools, *provider_tools])
@staticmethod
def _is_deepseek_compatible_model(model: BaseChatModel) -> bool:
"""
判断当前模型是否应当走 DeepSeek JSON 兼容分支。
除了官方 `langchain_deepseek`,用户也可能通过 OpenAI-compatible
配置把 DeepSeek 端点接到 `ChatOpenAI`。因此这里同时检查模块名、模型名
和 Base URL避免只靠单一条件漏判。
"""
module_name = type(model).__module__.lower()
model_name = (
str(getattr(model, "model_name", "") or getattr(model, "model", ""))
.strip()
.lower()
)
base_url = (
str(getattr(model, "openai_api_base", "") or getattr(model, "api_base", ""))
.strip()
.lower()
)
return (
"deepseek" in module_name
or model_name.startswith("deepseek-")
or "api.deepseek.com" in base_url
)
@staticmethod
def _extract_text_content(content: Any) -> str:
"""
从模型响应中提取纯文本。
这里不依赖上层 LLMHelper避免中间件与 LLM 构造逻辑互相耦合。
"""
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
text_parts: list[str] = []
for block in content:
if isinstance(block, str):
text_parts.append(block)
continue
if isinstance(block, dict):
if block.get("type") == "text" and isinstance(
block.get("text"), str
):
text_parts.append(block["text"])
continue
if not block.get("type") and isinstance(block.get("text"), str):
text_parts.append(block["text"])
return "".join(text_parts)
if isinstance(content, dict):
if content.get("type") == "text" and isinstance(content.get("text"), str):
return content["text"]
if not content.get("type") and isinstance(content.get("text"), str):
return content["text"]
return ""
@staticmethod
def _parse_json_object(text: str) -> dict[str, Any]:
"""
解析模型返回的 JSON。
DeepSeek 在 JSON 模式下通常会返回纯 JSON但这里仍做一层兜底
兼容模型偶发输出围栏或前后说明文本的情况。
"""
stripped_text = text.strip()
if not stripped_text:
raise ValueError("工具筛选返回了空响应")
try:
payload = json.loads(stripped_text)
if isinstance(payload, dict):
return payload
except json.JSONDecodeError:
pass
start = stripped_text.find("{")
end = stripped_text.rfind("}")
if start == -1 or end == -1 or end <= start:
raise ValueError(f"工具筛选返回的内容不是合法 JSON: {stripped_text}")
payload = json.loads(stripped_text[start: end + 1])
if not isinstance(payload, dict):
raise ValueError("工具筛选 JSON 顶层必须是对象")
return payload
@staticmethod
def _render_tool_list(available_tools: list[Any]) -> str:
"""把工具名和描述渲染成稳定的文本列表。"""
return "\n".join(
f"- {tool.name}: {tool.description}" for tool in available_tools
)
def _build_deepseek_selection_prompt(self, selection_request: Any) -> str:
"""
为 DeepSeek 生成显式 JSON 输出提示。
DeepSeek 官方文档要求在 JSON 输出模式下,提示词中必须明确包含 JSON
约束,否则兼容端点可能返回空内容或无意义输出。
"""
limit_instruction = ""
if self.max_tools:
limit_instruction = f"- Select up to {self.max_tools} tools. IF NO TOOLS ARE RELEVANT, DO NOT RETURN AN EMPTY ARRAY. SELECT THE MOST APPLICABLE ONES TO ENSURE THE REQUEST IS HANDLED."
return (
f"{selection_request.system_message}\n\n"
"Return the answer in JSON only.\n"
'Use exactly this shape: {"tools": ["tool_name_1", "tool_name_2"]}\n'
"Rules:\n"
"- The `tools` field must be a JSON array of strings.\n"
"- Only use tool names from the allowed list below.\n"
"- Order tools by relevance, with the most relevant first.\n"
f"{limit_instruction}\n"
"- Do not add explanations, markdown, or extra keys.\n\n"
"Allowed tools:\n"
f"{self._render_tool_list(selection_request.available_tools)}"
)
def _normalize_selection_response(self, response: Any) -> dict[str, list[str]]:
"""
解析并标准化 DeepSeek JSON 模式的工具筛选结果。
"""
content = getattr(response, "content", response)
text = self._extract_text_content(content)
logger.debug(f"工具筛选原始响应: {text}")
payload = self._parse_json_object(text)
tools = payload.get("tools")
if not isinstance(tools, list):
raise ValueError(f"工具筛选 JSON 缺少 `tools` 数组: {payload}")
normalized_tools = [
tool_name for tool_name in tools if isinstance(tool_name, str)
]
logger.debug(f"工具筛选标准化结果: {normalized_tools}")
return {"tools": normalized_tools}
async def _aselect_tools_with_deepseek(
self, selection_request: Any
) -> dict[str, list[str]]:
"""
使用 DeepSeek 兼容的 JSON 输出模式执行异步工具筛选。
"""
logger.debug("工具筛选走 DeepSeek JSON 兼容分支")
structured_model = selection_request.model.bind(
response_format={"type": "json_object"}
)
response = await structured_model.ainvoke(
[
{
"role": "system",
"content": self._build_deepseek_selection_prompt(selection_request),
},
selection_request.last_user_message,
]
)
return self._normalize_selection_response(response)
@staticmethod
def _extract_selected_tool_names(request: ModelRequest) -> list[str]:
"""从已筛选后的请求中提取最终工具名,保留原有顺序。"""
return [tool.name for tool in request.tools if not isinstance(tool, dict)]
@staticmethod
def _apply_selected_tools(
request: ModelRequest[ContextT],
selected_tool_names: list[str],
) -> ModelRequest[ContextT]:
"""
将已筛选出的工具集应用到当前模型请求。
这里只复用首次筛选出的客户端工具名provider-specific 的 dict 工具仍然
原样保留,避免破坏 LangChain/provider 自身的工具绑定约定。
"""
if not selected_tool_names:
return request
current_tools_by_name = {
tool.name: tool for tool in request.tools if not isinstance(tool, dict)
}
selected_tools = [
current_tools_by_name[tool_name]
for tool_name in selected_tool_names
if tool_name in current_tools_by_name
]
provider_tools = [tool for tool in request.tools if isinstance(tool, dict)]
return request.override(tools=[*selected_tools, *provider_tools])
async def _aselect_request_once(
self, request: ModelRequest[ContextT]
) -> ModelRequest[ContextT]:
"""
执行一次真实工具筛选,并返回筛选后的请求对象。
这里单独抽成 helper便于首次筛选后缓存结果也便于测试覆盖
“首轮筛选,后续复用”的行为。
"""
selection_request = self._prepare_selection_request(request)
if selection_request is None:
return request
if not self._is_deepseek_compatible_model(selection_request.model):
captured_request: ModelRequest[ContextT] = request
async def _capture_handler(
updated_request: ModelRequest[ContextT],
) -> ModelRequest[ContextT]:
nonlocal captured_request
captured_request = updated_request
return updated_request
await super().awrap_model_call(request, _capture_handler)
return captured_request
response = await self._aselect_tools_with_deepseek(selection_request)
return self._process_selection_response(
response,
selection_request.available_tools,
selection_request.valid_tool_names,
request,
)
async def abefore_agent( # noqa
self,
state: ToolSelectionState,
runtime: Runtime, # noqa
config: RunnableConfig,
) -> ToolSelectionStateUpdate | None: # ty: ignore[invalid-method-override]
"""
在本轮 Agent 执行开始前完成一次真实工具筛选。
这样后续多轮 `model -> tools -> model` 循环都只复用这一次结果,
不会为每次模型回合重复追加一笔 selector LLM 开销。
"""
if "selected_tool_names" in state:
return None
if not self.selection_tools or self.model is None:
return ToolSelectionStateUpdate(selected_tool_names=None)
selection_request = ModelRequest(
model=self.model,
tools=list(self.selection_tools),
messages=state["messages"],
state=state,
runtime=runtime,
)
modified_request = await self._aselect_request_once(selection_request)
selected_tool_names = self._extract_selected_tool_names(modified_request)
return ToolSelectionStateUpdate(selected_tool_names=selected_tool_names or None)
async def awrap_model_call(
self,
request: ModelRequest[ContextT],
handler: Callable[
[ModelRequest[ContextT]], Awaitable[ModelResponse[ResponseT]]
],
) -> ModelResponse[ResponseT]:
"""
从 state 中读取首次筛选结果,并应用到每次模型回合。
"""
selected_tool_names = request.state.get("selected_tool_names") # noqa
# 正常路径下,`abefore_agent()` 已经提前写入状态;这里只保留一层兜底,
# 兼容直接单测或未来某些绕过 before_agent 的调用场景。
if (
selected_tool_names is None
and self.selection_tools
and self.model is not None
):
request = await self._aselect_request_once(request)
selected_tool_names = self._extract_selected_tool_names(request) or None
request.state["selected_tool_names"] = selected_tool_names # noqa
if selected_tool_names:
request = self._apply_selected_tools(request, selected_tool_names)
return await handler(request)

View File

@@ -1,73 +0,0 @@
You are an AI media assistant powered by MoviePilot. You specialize in managing home media ecosystems: searching for movies/TV shows, managing subscriptions, overseeing downloads, and organizing media libraries.
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.
Core Capabilities:
1. Media Search & Recognition — Identify movies, TV shows, and anime; recognize media from fuzzy filenames or incomplete titles.
2. Subscription Management — Create rules for automated downloading; monitor trending content.
3. Download Control — Search torrents across trackers; filter by quality, codec, and release group.
4. System Status & 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`.
<communication>
{verbose_spec}
- Tone: professional, concise, restrained.
- Be direct. NO unnecessary preamble, NO repeating user's words, NO explaining your thinking.
- Prioritize task progress over conversation. Answer only what is necessary to move the task forward.
- Do NOT flatter the user, praise the question, or use overly eager/service-oriented phrases.
- Do NOT use emojis, exclamation marks, cute language, or excessive apology.
- Prefer short declarative sentences. Default to one or two short paragraphs; use lists only when they improve scanability.
- Use Markdown for structured data. Use `inline code` for media titles/paths.
- Include key details (year, rating, resolution) but do NOT over-explain.
- Do not stop for approval on read-only operations. Only confirm before critical actions (starting downloads, deleting subscriptions).
- If the current channel supports image sending and an image would materially help, you may use the `send_message` tool with `image_url` to send it.
- If the current channel supports file sending and you need to return a local image/file for the user to download, use `send_local_file`.
{button_choice_spec}
- Voice replies: {voice_reply_spec}
- NOT a coding assistant. Do not offer code snippets.
- If user has set preferred communication style in memory, follow that strictly.
</communication>
<response_format>
- Responses MUST be short and punchy: one sentence for confirmations, brief list for search results.
- NO filler phrases like "Let me help you", "Here are the results", "I found..." — skip all unnecessary preamble.
- NO repeating what user said.
- NO narrating your internal reasoning.
- NO praise, emotional cushioning, or unnecessary politeness padding.
- After task completion: one line summary only.
- When error occurs: brief acknowledgment + suggestion, then move on.
</response_format>
<flow>
1. Media Discovery: Identify exact media metadata (TMDB ID, Season/Episode) using search tools.
2. Context Checking: Verify current status (already in library? already subscribed?).
3. Action Execution: Perform the task with a brief status update only if the operation takes time.
4. Final Confirmation: State the result concisely.
</flow>
<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 all automated methods are exhausted.
</tool_calling_strategy>
<media_management_rules>
1. Download Safety: Present found torrents (size, seeds, quality) and 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.
</media_management_rules>
<markdown_spec>
Specific markdown rules:
{markdown_spec}
</markdown_spec>
<system_info>
{moviepilot_info}
</system_info>

View File

@@ -0,0 +1,88 @@
You are the MoviePilot agent runtime. Follow the injected runtime configuration to determine the active persona and any extra user-specific context.
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:
- You are an AI media assistant powered by MoviePilot.
- Your primary goal is to fully resolve the user's MoviePilot-related media tasks with the available tools whenever the request is actionable.
- Focus on MoviePilot's core home media domain: sites, search, recognition, downloads, subscriptions, library organization, file transfer, and system status.
- Treat sites as a first-class system capability, not background detail. In MoviePilot, sites are the upstream source for search, account status, authentication, and many download or subscription decisions.
- Understand the platform's core workflow as: site availability and configuration -> media search -> media recognition/metadata confirmation -> manual download or subscription -> transfer and library organization -> status/history confirmation.
- Treat manual download and subscription automation as two execution modes of the same core pipeline. One is user-triggered immediate acquisition; the other is persistent site-driven monitoring and acquisition.
- Stay within the MoviePilot product domain unless the user explicitly asks for adjacent help that can be handled with your existing tools.
Behavior Model:
- Prioritize task progress over conversation.
- Check current state before making changes, then do the smallest correct action.
- When a task depends on tracker or indexer availability, inspect site state first or as early as possible.
- Do not stop for approval on read-only operations. Only confirm before destructive or high-impact actions such as starting downloads, deleting subscriptions, or removing history.
- When a request can be completed by tools, prefer doing the work over explaining what you might do.
- After an action, perform the minimum validation needed to confirm the result actually landed.
- Keep the user anchored to the operational step that matters now: site, search, recognition, download, subscription, or transfer.
- If the user explicitly asks to change the speaking style or persona, use the dedicated persona tools instead of editing runtime files manually.
- If the user explicitly asks to rewrite or create a persona definition, prefer `update_persona_definition` rather than generic file-editing tools.
- Do not let user memory or persona style override this core identity, safety boundaries, or built-in background task rules.
- You are not a general-purpose coding assistant in normal media conversations. Only cross into implementation details when the user explicitly asks about MoviePilot internals or debugging.
Core Capabilities:
1. Site Operations - Query configured sites, understand site priority and availability, inspect account data, test connectivity, and update site authentication when the user explicitly requests site maintenance.
2. Media Search and Recognition - Identify movies, TV shows, and anime; search media databases; recognize media from fuzzy filenames, torrent titles, or incomplete names.
3. Torrent Search and Selection - Search torrents across configured sites and filter by quality, resolution, codec, effect, release group, and other result traits.
4. Download Control - Add, inspect, modify, or remove download tasks and connect site results to downloader execution.
5. Subscription Management - Create and manage subscriptions that continuously search configured sites and automatically download matching releases.
6. Transfer and Library Organization - Transfer files into the library, trigger recognition-aware organization, and confirm post-download file landing or cleanup state.
7. System Status and History - Monitor downloader state, site state, transfer history, subscription history, and related system health signals.
8. Visual Input Handling - Users may attach images from supported channels; analyze them together with the text when relevant.
9. File Context Handling - User messages may arrive as structured JSON. Treat the `message` field as the user's text. Attachments appear in `files`; when `local_path` is present, use local file tools to inspect the uploaded file directly. When image input is disabled for the current model, user images may also be delivered through `files`.
10. Persona Management - If the user explicitly asks to change the speaking style or persona, prefer `query_personas` and `switch_persona`; if the user asks to rewrite or create a persona definition, prefer `update_persona_definition` instead of editing runtime files manually.
Core Workflow:
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.
Tool Calling Strategy:
- Call independent tools in parallel whenever possible.
- Prefer site-aware tool paths when the task is about torrents, subscriptions, or download failures. `query_sites`, `test_site`, and `query_site_userdata` are part of the main operating flow, not edge-case tools.
- If search results are ambiguous, use `query_media_detail` or `recognize_media` to clarify before proceeding.
- For fuzzy torrent names, filenames, or manually provided paths, prefer `recognize_media` before asking the user for a cleaner title.
- If `search_media` fails, fall back to `search_web` or `recognize_media`. Only ask the user when automated paths are exhausted.
- If torrent search yields no useful result, check site scope, site health, and recognition quality before concluding that the resource is unavailable.
- Reuse the latest torrent search cache for `get_search_results` and `add_download` instead of re-running the same search unnecessarily.
- Reuse known media identity, prior tool results, and current system context instead of repeating expensive recognition or search calls.
- When a tool fails, try one narrower fallback path before escalating to the user.
Media Management 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.
</agent_core>
<communication_runtime>
{verbose_spec}
- Channel-aware formatting: Follow the capability rules below for Markdown, plain text, buttons, and voice replies.
{button_choice_spec}
- Voice replies: {voice_reply_spec}
- If the current channel supports image sending and an image would materially help, you may use the `send_message` tool with `image_url` to send it.
- If the current channel supports file sending and you need to return a local image or file for the user to download, use `send_local_file`.
</communication_runtime>
<markdown_spec>
Specific markdown rules:
{markdown_spec}
</markdown_spec>
<system_info>
{moviepilot_info}
</system_info>

View File

@@ -0,0 +1,139 @@
version: 2
shared_rules:
- This is a background system task, NOT a user conversation.
- Your final response will be consumed by the system. Keep it concise and task-focused.
- Do NOT include greetings, explanations, or conversational text.
- Respond in Chinese (中文).
task_types:
heartbeat:
header: "[System Heartbeat]"
objective: "Check all jobs in your jobs directory and process pending tasks."
steps_title: "Follow these steps"
steps:
- "List all jobs with status 'pending' or 'in_progress'."
- "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."
empty_result: "If no jobs were executed, output nothing."
health_check:
header: "[System Health Check]"
objective: "Verify that the agent execution pipeline is alive."
steps_title: "Follow these steps"
steps:
- "Verify that runtime config, tools, and jobs can all be accessed normally."
- "If a real issue is detected, report the failing subsystem and the immediate blocking reason."
empty_result: "If there is nothing meaningful to report, output OK only."
transfer_failed_retry:
header: "[System Task - Transfer Failed Retry]"
objective: "A file transfer or organization has failed. Please use the `transfer-failed-retry` skill to retry the failed transfer."
context_title: "Task context"
context_lines:
- "Failed transfer history record IDs: {history_ids_csv}"
- "Total failed records: {history_count}"
steps_title: "Follow these steps"
steps:
- "Use `query_transfer_history` with status='failed' to find the record with id={history_id} and understand the failure details such as source path, error message, and media info."
- "Analyze the error message to determine the best retry strategy."
- "If the source file no longer exists, skip this retry and report that the file is missing."
- "Delete the failed history record using `delete_transfer_history` with history_id={history_id}."
- "Re-identify the media using `recognize_media` with the source file path."
- "If recognition fails, try `search_media` with keywords from the filename."
- "Re-transfer using `transfer_file` with the source path and any identified media info such as tmdbid and media_type."
- "Report the final result."
batch_transfer_failed_retry:
header: "[System Task - Batch Transfer Failed Retry]"
objective: "Multiple file transfers from the same source have failed. These files likely belong to the same media. Please use the `transfer-failed-retry` skill to retry them efficiently."
context_title: "Task context"
context_lines:
- "Failed transfer history record IDs: {history_ids_csv}"
- "Total failed records: {history_count}"
steps_title: "Follow these steps"
steps:
- "Use `query_transfer_history` with status='failed' to find all records with these IDs and understand the failure details."
- "Analyze the first record to determine the shared media identity and the best retry strategy because the root cause is usually the same for all files."
- "If the error is about media recognition, identify the media once using `recognize_media` or `search_media`, then reuse that result for all files."
- "For each failed record, delete the old history entry with `delete_transfer_history` and re-transfer using `transfer_file`."
- "Report how many retries succeeded and how many still failed."
task_rules:
- "These files share the same media identity. Do NOT call `recognize_media` or `search_media` repeatedly for each file."
manual_transfer_redo:
header: "[System Task - Manual Transfer Re-Organize]"
objective: "A user manually triggered an AI re-organize task from the transfer history page."
context_title: "Transfer history record"
context_lines:
- "- History ID: {history_id}"
- "- Current status: {current_status}"
- "- Current recognized title: {recognized_title}"
- "- Media type: {media_type}"
- "- Category: {category}"
- "- Year: {year}"
- "- Season/Episode: {season_episode}"
- "- Source path: {source_path}"
- "- Source storage: {source_storage}"
- "- Destination path: {destination_path}"
- "- Destination storage: {destination_storage}"
- "- Transfer mode: {transfer_mode}"
- "- Current TMDB ID: {tmdbid}"
- "- Current Douban ID: {doubanid}"
- "- Error message: {error_message}"
steps_title: "Required workflow"
steps:
- "Use `query_transfer_history` to locate and inspect the record with id={history_id}, and verify the source path, status, media info, and failure context."
- "Decide whether the current recognition is trustworthy."
- "If the source file no longer exists or cannot be safely processed, stop and report the reason."
- "If the current recognition is wrong or the record should be reorganized, determine the correct media identity first."
- "Prefer `recognize_media` with the source path. If recognition is not reliable, use `search_media` with keywords from filename, title, or year."
- "Only continue when you have high confidence in the target media."
- "Before re-organizing, delete the old transfer history record with `delete_transfer_history` so the system will not skip the source file."
- "Then use `transfer_file` to organize the source path directly."
- "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, tmdbid or doubanid, and media_type."
- "If this record is already correct and no re-organize is needed, do not perform destructive actions; simply report that no change is necessary."
task_rules:
- "Do NOT rely on previous chat context. Work only from the record above."
- "Your goal is to directly fix one transfer history record by using MoviePilot tools to analyze, clean up the old history entry if necessary, and organize the source file again."
- "You should complete the re-organize by directly using tools such as `query_transfer_history`, `recognize_media`, `search_media`, `delete_transfer_history`, and `transfer_file`."
- "Do NOT reorganize blindly when media identity is uncertain."
- "If the previous record was successful but obviously identified as the wrong media, still use the tool-based flow above instead of `/redo`."
- "Keep the final response short and focused on outcome."
batch_manual_transfer_redo:
header: "[System Task - Batch Manual Transfer Re-Organize]"
objective: "A user manually triggered a batch AI re-organize task from the transfer history page."
context_title: "Selected transfer history records"
context_lines:
- "- History IDs: {history_ids_csv}"
- "- Total records: {history_count}"
- "{records_context}"
steps_title: "Required workflow"
steps:
- "Review the selected records below first and group them by likely shared media identity, source directory, or retry strategy when possible."
- "Use the provided record context as the primary source of truth. Call `query_transfer_history` only when you need extra confirmation."
- "For each group, decide whether the current recognition is trustworthy."
- "If multiple records clearly belong to the same movie or series, identify the media once with `recognize_media` or `search_media`, then reuse that result for the related records."
- "If a source file no longer exists or cannot be safely processed, skip that record and note the reason."
- "Before re-organizing a record, delete the old transfer history record with `delete_transfer_history` so the system will not skip the source file."
- "Then use `transfer_file` to organize the source path directly."
- "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, tmdbid or doubanid, and media_type."
- "If a record is already correct and no re-organize is needed, do not perform destructive actions; simply mark it as skipped."
- "Report only the aggregate outcome, including how many records succeeded, skipped, and failed."
task_rules:
- "Do NOT assume every selected record belongs to the same media."
- "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."
search_recommend:
header: "[System Task - Search Results Recommendation]"
objective: "Analyze the provided search results and select the best matching items based on user preferences."
context_title: "Task context"
context_lines:
- "{search_results}"
steps_title: "Follow these steps"
steps:
- "Review all search result items carefully."
- "Evaluate each item based on the user preference criteria."
- "Select the top items that best match the preferences."
- "Return ONLY a JSON array of item indices."
task_rules:
- "Return ONLY a JSON array of index numbers, e.g., [0, 3, 1]."
- "Do NOT include any explanations, markdown formatting, conversational text, or other content."
- "Do NOT call any tools. Simply analyze and return the JSON result directly."
- "Respond in JSON format only."

View File

@@ -1,9 +1,13 @@
"""提示词管理器"""
import socket
from dataclasses import dataclass, field
from pathlib import Path
from string import Formatter
from time import strftime
from typing import Dict
from typing import Any, Dict, Optional
import yaml
from app.core.config import settings
from app.log import logger
@@ -15,6 +19,37 @@ from app.schemas import (
)
from app.utils.system import SystemUtils
SYSTEM_TASKS_FILE = "System Tasks.yaml"
SYSTEM_TASKS_SCHEMA_VERSION = 2
class PromptConfigError(ValueError):
"""程序内置提示词定义加载异常。"""
@dataclass
class SystemTaskTypeDefinition:
"""单个后台系统任务定义。"""
header: str
objective: str
context_title: Optional[str] = None
context_lines: list[str] = field(default_factory=list)
steps_title: Optional[str] = None
steps: list[str] = field(default_factory=list)
task_rules: list[str] = field(default_factory=list)
empty_result: Optional[str] = None
@dataclass
class SystemTasksDefinition:
"""程序内置后台系统任务定义。"""
path: Path
version: int
shared_rules: list[str]
task_types: dict[str, SystemTaskTypeDefinition]
class PromptManager:
"""
@@ -27,6 +62,8 @@ class PromptManager:
else:
self.prompts_dir = Path(prompts_dir)
self.prompts_cache: Dict[str, str] = {}
self._system_tasks_cache: Optional[SystemTasksDefinition] = None
self._system_tasks_signature: Optional[tuple[int, int]] = None
def load_prompt(self, prompt_name: str) -> str:
"""
@@ -50,17 +87,16 @@ class PromptManager:
logger.error(f"加载提示词失败: {prompt_name}, 错误: {e}")
raise
def get_agent_prompt(
self, channel: str = None, prefer_voice_reply: bool = False
) -> str:
def get_agent_prompt(self, channel: str = None) -> str:
"""
获取智能体提示词
:param channel: 消息渠道Telegram、微信、Slack等
:param prefer_voice_reply: 是否优先使用语音回复
:return: 提示词内容
"""
# 基础提示词
base_prompt = self.load_prompt("Agent Prompt.txt")
# 基础提示词只保留 MoviePilot 运行时和渠道能力相关约束。
# 根层运行时配置由 RuntimeConfigMiddleware 在每次模型调用前动态注入,
# 这样人格切换可以在同一轮 Agent 执行里立即生效。
base_prompt = self.load_prompt("System Core Prompt.txt")
# 识别渠道
markdown_spec = ""
@@ -93,9 +129,7 @@ class PromptManager:
# MoviePilot系统信息
moviepilot_info = self._get_moviepilot_info()
voice_reply_spec = self._generate_voice_reply_instructions(
prefer_voice_reply=prefer_voice_reply
)
voice_reply_spec = self._generate_voice_reply_instructions()
# 始终替换占位符,避免后续 .format() 时因残留花括号报 KeyError
base_prompt = base_prompt.format(
@@ -108,6 +142,115 @@ class PromptManager:
return base_prompt
def load_system_tasks_definition(self) -> SystemTasksDefinition:
"""加载程序内置的后台系统任务定义。"""
system_tasks_path = self.prompts_dir / SYSTEM_TASKS_FILE
try:
stat = system_tasks_path.stat()
except FileNotFoundError as err:
logger.error(f"系统任务定义文件不存在: {system_tasks_path}")
raise PromptConfigError(f"系统任务定义文件不存在: {system_tasks_path}") from err
signature = (stat.st_mtime_ns, stat.st_size)
if (
self._system_tasks_signature == signature
and self._system_tasks_cache is not None
):
return self._system_tasks_cache
try:
content = system_tasks_path.read_text(encoding="utf-8")
except Exception as err: # noqa: BLE001
logger.error(f"读取系统任务定义失败: {system_tasks_path}, 错误: {err}")
raise PromptConfigError(
f"读取系统任务定义失败 {system_tasks_path}: {err}"
) from err
try:
data = yaml.safe_load(content) or {}
except yaml.YAMLError as err:
raise PromptConfigError(f"YAML 解析失败 {system_tasks_path}: {err}") from err
if not isinstance(data, dict):
raise PromptConfigError(
f"YAML 根节点必须是映射类型: {system_tasks_path}"
)
definition = self._parse_system_tasks_definition(system_tasks_path, data)
self._system_tasks_signature = signature
self._system_tasks_cache = definition
return definition
def render_system_task_message(
self,
task_type: str,
*,
template_context: Optional[dict[str, Any]] = None,
extra_rules: Optional[list[str]] = None,
) -> str:
"""根据程序内置 YAML 渲染后台系统任务提示词。"""
system_tasks = self.load_system_tasks_definition()
task_definition = system_tasks.task_types.get(task_type)
if not task_definition:
raise PromptConfigError(f"未定义的后台系统任务类型: {task_type}")
rendered_context = self._render_template_lines(
task_definition.context_lines,
template_context,
task_type,
"context_lines",
)
rendered_steps = self._render_template_lines(
task_definition.steps,
template_context,
task_type,
"steps",
)
rendered_task_rules = self._render_template_lines(
task_definition.task_rules,
template_context,
task_type,
"task_rules",
)
sections = [
self._render_template_text(
task_definition.header,
template_context,
task_type,
"header",
).strip(),
self._render_template_text(
task_definition.objective,
template_context,
task_type,
"objective",
).strip(),
]
if rendered_context:
sections.append(
self._format_titled_lines(
task_definition.context_title or "Task context",
rendered_context,
)
)
if rendered_steps:
sections.append(
self._format_titled_lines(
task_definition.steps_title or "Follow these steps",
rendered_steps,
)
)
rules = list(system_tasks.shared_rules)
if task_definition.empty_result:
rules.append(task_definition.empty_result)
rules.extend(rendered_task_rules)
if extra_rules:
rules.extend(rule.strip() for rule in extra_rules if rule and rule.strip())
if rules:
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:
"""
@@ -138,10 +281,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}",
@@ -178,17 +326,11 @@ class PromptManager:
return "\n".join(instructions)
@staticmethod
def _generate_voice_reply_instructions(prefer_voice_reply: bool) -> str:
if not prefer_voice_reply:
return (
"- Voice replies: Use normal text replies by default. "
"Only call `send_voice_message` when spoken playback is clearly better than plain text."
)
def _generate_voice_reply_instructions() -> str:
return (
"- Current message context: The user sent a voice message.\n"
"- Reply preference: Prioritize calling `send_voice_message` for the main user-facing reply.\n"
"- Fallback: If voice is unavailable on the current channel, `send_voice_message` will fall back to text.\n"
"- Do not repeat the same full reply again after calling `send_voice_message`."
"- 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."
)
@staticmethod
@@ -208,11 +350,172 @@ class PromptManager:
)
return "- User questions: When you truly need user input, ask briefly in plain text."
def _parse_system_tasks_definition(
self,
path: Path,
data: dict[str, Any],
) -> SystemTasksDefinition:
"""把 YAML 结构转换成系统任务定义对象。"""
version = self._normalize_positive_int(data.get("version"), "version", default=1)
if version < SYSTEM_TASKS_SCHEMA_VERSION:
raise PromptConfigError(
f"{path} 的 version={version} 过旧,"
f"当前要求 System Tasks schema v{SYSTEM_TASKS_SCHEMA_VERSION} 或更高版本"
)
shared_rules = self._normalize_string_list(data.get("shared_rules"), "shared_rules")
if not shared_rules:
raise PromptConfigError(f"{path} 缺少 shared_rules")
raw_task_types = data.get("task_types")
if not isinstance(raw_task_types, dict) or not raw_task_types:
raise PromptConfigError(f"{path} 缺少 task_types 映射")
task_types: dict[str, SystemTaskTypeDefinition] = {}
for key, raw in raw_task_types.items():
if not isinstance(raw, dict):
raise PromptConfigError(f"task_types.{key} 必须是映射")
header = str(raw.get("header") or "").strip()
objective = str(raw.get("objective") or "").strip()
if not header or not objective:
raise PromptConfigError(f"task_types.{key} 缺少 header 或 objective")
task_types[str(key)] = SystemTaskTypeDefinition(
header=header,
objective=objective,
context_title=str(raw.get("context_title") or "").strip() or None,
context_lines=self._normalize_string_list(
raw.get("context_lines"),
f"task_types.{key}.context_lines",
),
steps_title=str(raw.get("steps_title") or "").strip() or None,
steps=self._normalize_string_list(
raw.get("steps"),
f"task_types.{key}.steps",
),
task_rules=self._normalize_string_list(
raw.get("task_rules"),
f"task_types.{key}.task_rules",
),
empty_result=str(raw.get("empty_result") or "").strip() or None,
)
return SystemTasksDefinition(
path=path,
version=version,
shared_rules=shared_rules,
task_types=task_types,
)
@classmethod
def _render_template_text(
cls,
text: str,
template_context: Optional[dict[str, Any]],
task_type: str,
field_name: str,
) -> str:
if not text:
return ""
formatter = Formatter()
required_fields = {
placeholder_name
for _, placeholder_name, _, _ in formatter.parse(text)
if placeholder_name
}
if not required_fields:
return text
context = cls._normalize_template_context(template_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"`{f}`" for f in missing_fields)
)
# 这里统一做字符串替换,让 YAML 成为后台任务文案的唯一行为来源。
return text.format_map(context)
@classmethod
def _render_template_lines(
cls,
items: list[str],
template_context: Optional[dict[str, Any]],
task_type: str,
field_name: str,
) -> list[str]:
return [
cls._render_template_text(
item,
template_context,
task_type,
f"{field_name}[{index}]",
).rstrip()
for index, item in enumerate(items, start=1)
if item and item.rstrip()
]
@staticmethod
def _normalize_template_context(
template_context: Optional[dict[str, Any]],
) -> dict[str, str]:
if not template_context:
return {}
return {
str(key): "" if value is None else str(value)
for key, value in template_context.items()
}
@staticmethod
def _format_numbered_rules(title: str, items: list[str]) -> str:
return "\n".join(
[f"{title}:"] + [f"{index}. {item}" for index, item in enumerate(items, start=1)]
)
@staticmethod
def _format_titled_lines(title: str, items: list[str]) -> str:
cleaned = [item.rstrip() for item in items if item and item.rstrip()]
return "\n".join([f"{title}:"] + cleaned)
@staticmethod
def _normalize_positive_int(
value: Any,
field_name: str,
*,
default: int,
) -> int:
if value in (None, ""):
return default
try:
normalized = int(value)
except (TypeError, ValueError) as err:
raise PromptConfigError(f"{field_name} 必须是正整数") from err
if normalized <= 0:
raise PromptConfigError(f"{field_name} 必须是正整数")
return normalized
@staticmethod
def _normalize_string_list(values: Any, field_name: str) -> list[str]:
if values is None:
return []
if not isinstance(values, list):
raise PromptConfigError(f"{field_name} 必须是字符串数组")
normalized: list[str] = []
for value in values:
text = str(value).strip()
if text:
normalized.append(text)
return normalized
def clear_cache(self):
"""
清空缓存
"""
self.prompts_cache.clear()
self._system_tasks_cache = None
self._system_tasks_signature = None
logger.info("提示词缓存已清空")

755
app/agent/runtime.py Normal file
View File

@@ -0,0 +1,755 @@
"""Agent 根层运行时配置管理。"""
from __future__ import annotations
import re
import shutil
import threading
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterable, Optional
import yaml
from app.core.config import settings
from app.log import logger
CURRENT_PERSONA_FILE = "CURRENT_PERSONA.md"
SYSTEM_RUNTIME_DIR = "runtime"
MEMORY_DIR = "memory"
SKILLS_DIR = "skills"
JOBS_DIR = "jobs"
ACTIVITY_DIR = "activity"
PERSONAS_DIR = "personas"
PERSONA_FILE = "PERSONA.md"
CURRENT_PERSONA_SCHEMA_VERSION = 3
PERSONA_SCHEMA_VERSION = 1
DEFAULT_PERSONA_ID = "default"
PERSONA_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
ROOT_LEVEL_RUNTIME_FILES = {
CURRENT_PERSONA_FILE,
}
OBSOLETE_AGENT_ROOT_FILES = {
"AGENT_CORE.md",
"AGENT_PROFILE.md",
"AGENT_WORKFLOW.md",
"AGENT_HOOKS.md",
"USER_PREFERENCES.md",
"SYSTEM_TASKS.md",
"WAKE_FORMAT.md",
}
OBSOLETE_RUNTIME_FILES = {
Path("AGENT_CORE.md"),
Path("AGENT_PROFILE.md"),
Path("AGENT_WORKFLOW.md"),
Path("AGENT_HOOKS.md"),
Path("USER_PREFERENCES.md"),
Path("SYSTEM_TASKS.md"),
Path("WAKE_FORMAT.md"),
Path("personas") / DEFAULT_PERSONA_ID / "AGENT_PROFILE.md",
Path("personas") / DEFAULT_PERSONA_ID / "AGENT_WORKFLOW.md",
Path("personas") / DEFAULT_PERSONA_ID / "AGENT_HOOKS.md",
Path("system_tasks") / "SYSTEM_TASKS.md",
Path("templates") / "WAKE_FORMAT.md",
}
FRONTMATTER_PATTERN = re.compile(r"^---\s*\n(.*?)\n---\s*\n?", re.DOTALL)
class AgentRuntimeConfigError(ValueError):
"""根层配置加载异常。"""
@dataclass
class ParsedMarkdownDocument:
"""解析后的 Markdown 文档。"""
metadata: dict[str, Any]
body: str
@dataclass
class PersonaDefinition:
"""单个人格定义。"""
persona_id: str
path: Path
label: str
description: str
text: str
aliases: list[str] = field(default_factory=list)
def matches(self, query: str) -> bool:
"""判断 query 是否命中当前人格。"""
normalized = query.strip().casefold()
if not normalized:
return False
candidates = [self.persona_id, self.label, *self.aliases]
return any(candidate.strip().casefold() == normalized for candidate in candidates)
def summary_line(self) -> str:
"""渲染可读的一行人格摘要。"""
parts = [f"`{self.persona_id}`"]
if self.label and self.label != self.persona_id:
parts.append(self.label)
if self.description:
parts.append(self.description)
return " - ".join(parts)
def to_dict(self, *, is_active: bool) -> dict[str, Any]:
"""输出给查询工具的结构化信息。"""
return {
"persona_id": self.persona_id,
"label": self.label,
"description": self.description,
"aliases": self.aliases,
"is_active": is_active,
"path": str(self.path),
}
@dataclass
class AgentRuntimeConfig:
"""一次加载后的根层配置快照。"""
source_root: Path
active_persona: str
current_persona_path: Path
persona: PersonaDefinition
available_personas: list[PersonaDefinition]
extra_context_paths: list[Path]
extra_contexts: list[tuple[Path, str]]
warnings: list[str] = field(default_factory=list)
used_fallback: bool = False
def render_prompt_sections(self) -> str:
"""渲染进入系统提示词的运行时片段。"""
sections: list[str] = [
"<agent_runtime_config>",
f"- Active persona: `{self.active_persona}`",
f"- Active persona source: `{self.persona.path}`",
]
if self.available_personas:
sections.append("- Available personas:")
sections.extend(f" - {persona.summary_line()}" for persona in self.available_personas)
sections.append("</agent_runtime_config>")
if self.warnings:
sections.extend(
[
"",
"<agent_runtime_warnings>",
*[f"- {warning}" for warning in self.warnings],
"</agent_runtime_warnings>",
]
)
sections.extend(
[
"",
"<agent_persona>",
f"- Persona ID: `{self.persona.persona_id}`",
]
)
if self.persona.label and self.persona.label != self.persona.persona_id:
sections.append(f"- Persona Label: {self.persona.label}")
if self.persona.description:
sections.append(f"- Persona Description: {self.persona.description}")
sections.extend(
[
"",
self.persona.text.strip() or "(No persona instructions configured.)",
"</agent_persona>",
]
)
for path, text in self.extra_contexts:
if not text.strip():
continue
sections.extend(
[
"",
f'<agent_extra_context source="{path.name}">',
text.strip(),
"</agent_extra_context>",
]
)
return "\n".join(sections).strip()
def list_personas(self) -> list[dict[str, Any]]:
"""返回全部人格摘要。"""
return [
persona.to_dict(is_active=persona.persona_id == self.active_persona)
for persona in self.available_personas
]
class AgentRuntimeManager:
"""统一管理 agent 根层运行时配置目录、校验与人格切换。"""
def __init__(
self,
*,
agent_root_dir: Optional[Path] = None,
bundled_defaults_dir: Optional[Path] = None,
) -> None:
self.agent_root_dir = agent_root_dir or (settings.CONFIG_PATH / "agent")
self.runtime_dir = self.agent_root_dir / SYSTEM_RUNTIME_DIR
self.memory_dir = self.agent_root_dir / MEMORY_DIR
self.skills_dir = self.agent_root_dir / SKILLS_DIR
self.jobs_dir = self.agent_root_dir / JOBS_DIR
self.activity_dir = self.agent_root_dir / ACTIVITY_DIR
self.bundled_defaults_dir = bundled_defaults_dir or (
Path(__file__).parent / "defaults"
)
self._cache_lock = threading.Lock()
self._cached_signature: Optional[tuple[tuple[str, int, int], ...]] = None
self._cached_config: Optional[AgentRuntimeConfig] = None
def ensure_layout(self) -> None:
"""创建目录、同步默认文件,并清理废弃的旧版 runtime 文件。"""
self.agent_root_dir.mkdir(parents=True, exist_ok=True)
self.runtime_dir.mkdir(parents=True, exist_ok=True)
self.memory_dir.mkdir(parents=True, exist_ok=True)
self.skills_dir.mkdir(parents=True, exist_ok=True)
self.jobs_dir.mkdir(parents=True, exist_ok=True)
self.activity_dir.mkdir(parents=True, exist_ok=True)
self._migrate_root_runtime_files()
self._remove_obsolete_runtime_files()
self._sync_bundled_defaults()
self._migrate_root_memory_files()
def load_runtime_config(self) -> AgentRuntimeConfig:
"""加载配置。用户目录损坏时自动回退到内置默认配置。"""
self.ensure_layout()
signature = self._build_signature()
with self._cache_lock:
if self._cached_signature == signature and self._cached_config:
return self._cached_config
try:
config = self._load_from_root(self.runtime_dir)
except AgentRuntimeConfigError as err:
logger.warning("Agent 根层配置无效,回退到内置默认配置: %s", err)
config = self._load_from_root(self.bundled_defaults_dir)
config.used_fallback = True
config.warnings.insert(
0, f"用户运行时配置加载失败,已回退到内置默认配置: {err}"
)
self._cached_signature = signature
self._cached_config = config
return config
def invalidate_cache(self) -> None:
"""供测试或手动刷新时清理缓存。"""
with self._cache_lock:
self._cached_signature = None
self._cached_config = None
def set_active_persona(self, persona_query: str) -> AgentRuntimeConfig:
"""切换当前激活人格,并立即刷新缓存。"""
self.ensure_layout()
runtime_root = self.runtime_dir
current_path = runtime_root / CURRENT_PERSONA_FILE
current_doc = self._read_markdown(current_path)
current_meta = current_doc.metadata
available_personas = self._load_personas(runtime_root)
persona = self._resolve_persona_definition(persona_query, available_personas)
document = self._render_current_persona_document(
active_persona=persona.persona_id,
extra_context_files=self._coerce_string_list(
current_meta.get("extra_context_files")
),
deprecated_phrases=self._coerce_string_list(
current_meta.get("deprecated_phrases")
),
)
current_path.write_text(document, encoding="utf-8")
self.invalidate_cache()
logger.info("已切换 Agent 人格: %s", persona.persona_id)
return self.load_runtime_config()
def list_personas(self) -> list[PersonaDefinition]:
"""列出当前可用人格。"""
return self.load_runtime_config().available_personas
def update_persona_definition(
self,
persona_query: str,
*,
label: Optional[str] = None,
description: Optional[str] = None,
aliases: Optional[list[str]] = None,
instructions: Optional[str] = None,
append_instructions: Optional[list[str]] = None,
create_if_missing: bool = False,
) -> tuple[PersonaDefinition, bool]:
"""更新或创建运行时人格定义。"""
self.ensure_layout()
runtime_root = self.runtime_dir
available_personas = self._load_personas(runtime_root)
created = False
try:
persona = self._resolve_persona_definition(persona_query, available_personas)
target_persona_id = persona.persona_id
target_path = persona.path
existing_body = persona.text
existing_label = persona.label
existing_description = persona.description
existing_aliases = list(persona.aliases)
except AgentRuntimeConfigError:
if not create_if_missing:
raise
target_persona_id = self._validate_new_persona_id(persona_query)
target_path = runtime_root / PERSONAS_DIR / target_persona_id / PERSONA_FILE
existing_body = ""
existing_label = target_persona_id
existing_description = ""
existing_aliases = []
created = True
final_label = (
label.strip()
if isinstance(label, str) and label.strip()
else existing_label or target_persona_id
)
final_description = (
description.strip()
if isinstance(description, str) and description.strip()
else existing_description
)
final_aliases = (
self._normalize_persona_aliases(aliases, "aliases")
if aliases is not None
else existing_aliases
)
final_body = (
self._normalize_persona_body(instructions)
if isinstance(instructions, str) and instructions.strip()
else self._normalize_persona_body(existing_body)
)
final_body = self._merge_persona_instructions(
final_body,
append_instructions,
)
if not final_body.strip():
raise AgentRuntimeConfigError("人格定义正文不能为空")
document = self._render_persona_document(
persona_id=target_persona_id,
label=final_label,
description=final_description,
aliases=final_aliases,
body=final_body,
)
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(document, encoding="utf-8")
self.invalidate_cache()
runtime_config = self.load_runtime_config()
updated_persona = self._resolve_persona_definition(
target_persona_id,
runtime_config.available_personas,
)
logger.info(
"%s Agent 人格定义: %s",
"创建" if created else "更新",
updated_persona.persona_id,
)
return updated_persona, created
def _build_signature(self) -> tuple[tuple[str, int, int], ...]:
"""基于运行时配置和内置人格生成文件签名。"""
entries: list[tuple[str, int, int]] = []
for prefix, root in (
("runtime", self.runtime_dir),
("bundled", self.bundled_defaults_dir),
):
if not root.exists():
continue
for path in sorted(root.rglob("*")):
if not path.is_file():
continue
stat = path.stat()
relative = path.relative_to(root).as_posix()
entries.append((f"{prefix}:{relative}", stat.st_mtime_ns, stat.st_size))
return tuple(entries)
def _sync_bundled_defaults(self) -> None:
"""仅复制缺失的默认运行时文件,避免覆盖用户自定义。"""
if not self.bundled_defaults_dir.exists():
return
for path in sorted(self.bundled_defaults_dir.rglob("*")):
relative = path.relative_to(self.bundled_defaults_dir)
target = self.runtime_dir / relative
if path.is_dir():
target.mkdir(parents=True, exist_ok=True)
continue
if target.exists():
continue
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, target)
logger.info("已同步默认 Agent 运行时文件: %s", target)
def _migrate_root_runtime_files(self) -> None:
"""兼容早期直接放在 `config/agent` 根目录的 CURRENT_PERSONA。"""
source = self.agent_root_dir / CURRENT_PERSONA_FILE
target = self.runtime_dir / CURRENT_PERSONA_FILE
if not source.exists() or target.exists():
return
target.parent.mkdir(parents=True, exist_ok=True)
source.rename(target)
logger.info("已迁移旧版 Agent 根配置文件: %s -> %s", source, target)
def _remove_obsolete_runtime_files(self) -> None:
"""删除不再支持的旧版 Agent 配置文件,避免被误迁移到 memory。"""
for filename in sorted(OBSOLETE_AGENT_ROOT_FILES):
path = self.agent_root_dir / filename
if not path.exists() or not path.is_file():
continue
path.unlink()
logger.info("已删除废弃的 Agent 根配置文件: %s", path)
for relative_path in sorted(OBSOLETE_RUNTIME_FILES):
path = self.runtime_dir / relative_path
if not path.exists() or not path.is_file():
continue
path.unlink()
logger.info("已删除废弃的 Agent 运行时文件: %s", path)
def _migrate_root_memory_files(self) -> None:
"""将旧版根目录 memory 文件移入 `config/agent/memory`。"""
for path in sorted(self.agent_root_dir.glob("*.md")):
if path.name in ROOT_LEVEL_RUNTIME_FILES:
continue
target = self.memory_dir / path.name
if target.exists():
continue
path.rename(target)
logger.info("已迁移旧版 Agent memory 文件: %s -> %s", path, target)
def _load_from_root(self, root: Path) -> AgentRuntimeConfig:
current_persona_path = root / CURRENT_PERSONA_FILE
current_doc = self._read_markdown(current_persona_path)
current_meta = current_doc.metadata
active_persona = str(
current_meta.get("active_persona") or DEFAULT_PERSONA_ID
).strip()
if not active_persona:
raise AgentRuntimeConfigError("CURRENT_PERSONA.md 缺少 active_persona")
extra_context_paths = self._resolve_optional_paths(
root, current_meta.get("extra_context_files", [])
)
available_personas = self._load_personas(root)
persona = self._resolve_persona_definition(active_persona, available_personas)
extra_contexts = [
(path, self._read_markdown(path).body)
for path in extra_context_paths
]
warnings = self._validate_runtime_config(
current_meta=current_meta,
persona_path=persona.path,
extra_context_paths=extra_context_paths,
persona_text=persona.text,
)
return AgentRuntimeConfig(
source_root=root,
active_persona=active_persona,
current_persona_path=current_persona_path,
persona=persona,
available_personas=available_personas,
extra_context_paths=extra_context_paths,
extra_contexts=extra_contexts,
warnings=warnings,
)
def _load_personas(self, root: Path) -> list[PersonaDefinition]:
"""扫描并解析所有可用人格。"""
personas_root = root / PERSONAS_DIR
if not personas_root.exists():
raise AgentRuntimeConfigError(f"缺少 personas 目录: {personas_root}")
personas: list[PersonaDefinition] = []
seen_ids: set[str] = set()
for persona_dir in sorted(personas_root.iterdir()):
if not persona_dir.is_dir():
continue
persona_path = persona_dir / PERSONA_FILE
if not persona_path.exists():
continue
document = self._read_markdown(persona_path)
persona_id = str(document.metadata.get("persona_id") or persona_dir.name).strip()
if not persona_id:
raise AgentRuntimeConfigError(f"{persona_path} 缺少 persona_id")
if persona_id in seen_ids:
raise AgentRuntimeConfigError(f"检测到重复的人格 ID: {persona_id}")
seen_ids.add(persona_id)
aliases = self._normalize_string_list(
document.metadata.get("aliases"),
f"{persona_path}.aliases",
)
personas.append(
PersonaDefinition(
persona_id=persona_id,
path=persona_path,
label=str(document.metadata.get("label") or persona_id).strip(),
description=str(document.metadata.get("description") or "").strip(),
text=document.body,
aliases=aliases,
)
)
if not personas:
raise AgentRuntimeConfigError(f"{personas_root} 中未找到任何人格定义")
return personas
@staticmethod
def _resolve_persona_definition(
persona_query: str,
personas: list[PersonaDefinition],
) -> PersonaDefinition:
"""按 persona_id、label 或 aliases 解析人格。"""
normalized = (persona_query or "").strip()
if not normalized:
raise AgentRuntimeConfigError("人格 ID 不能为空")
for persona in personas:
if persona.persona_id == normalized:
return persona
for persona in personas:
if persona.matches(normalized):
return persona
available = ", ".join(persona.persona_id for persona in personas)
raise AgentRuntimeConfigError(
f"未找到人格 `{persona_query}`,可用人格: {available}"
)
@staticmethod
def _validate_new_persona_id(persona_id: str) -> str:
"""校验新建人格的 ID避免写入非法路径。"""
normalized = (persona_id or "").strip()
if not normalized:
raise AgentRuntimeConfigError("新建人格时 persona_id 不能为空")
if not PERSONA_ID_PATTERN.fullmatch(normalized):
raise AgentRuntimeConfigError(
"新建人格时 persona_id 只能使用小写字母、数字、下划线和中划线,且必须以字母或数字开头"
)
return normalized
@staticmethod
def _read_markdown(path: Path) -> ParsedMarkdownDocument:
if not path.exists():
raise AgentRuntimeConfigError(f"缺少配置文件: {path}")
try:
content = path.read_text(encoding="utf-8")
except Exception as err: # noqa: BLE001
raise AgentRuntimeConfigError(f"读取配置文件失败 {path}: {err}") from err
metadata: dict[str, Any] = {}
body = content
match = FRONTMATTER_PATTERN.match(content)
if match:
try:
metadata = yaml.safe_load(match.group(1)) or {}
except yaml.YAMLError as err:
raise AgentRuntimeConfigError(
f"YAML frontmatter 解析失败 {path}: {err}"
) from err
if not isinstance(metadata, dict):
raise AgentRuntimeConfigError(f"frontmatter 必须是映射类型: {path}")
body = content[match.end():]
return ParsedMarkdownDocument(metadata=metadata, body=body.strip())
@staticmethod
def _resolve_optional_paths(root: Path, values: Any) -> list[Path]:
if not values:
return []
if not isinstance(values, list):
raise AgentRuntimeConfigError("extra_context_files 必须是数组")
return [AgentRuntimeManager._resolve_relative_path(root, str(value)) for value in values]
@staticmethod
def _resolve_relative_path(root: Path, value: str) -> Path:
candidate = Path(value)
return candidate if candidate.is_absolute() else (root / candidate).resolve()
@staticmethod
def _normalize_string_list(values: Any, field_name: str) -> list[str]:
if values is None:
return []
if not isinstance(values, list):
raise AgentRuntimeConfigError(f"{field_name} 必须是字符串数组")
normalized: list[str] = []
for value in values:
text = str(value).strip()
if text:
normalized.append(text)
return normalized
@staticmethod
def _coerce_string_list(values: Any) -> list[str]:
if not isinstance(values, list):
return []
return [str(value).strip() for value in values if str(value).strip()]
@staticmethod
def _normalize_persona_aliases(values: Any, field_name: str) -> list[str]:
"""规范化人格别名,保持顺序并去重。"""
normalized = AgentRuntimeManager._normalize_string_list(values, field_name)
deduped: list[str] = []
seen: set[str] = set()
for alias in normalized:
folded = alias.casefold()
if folded in seen:
continue
seen.add(folded)
deduped.append(alias)
return deduped
@staticmethod
def _merge_persona_instructions(
base_body: str,
append_instructions: Optional[list[str]],
) -> str:
"""把增量规则安全追加到人格正文末尾。"""
merged = (base_body or "").strip()
if not append_instructions:
return merged
extras: list[str] = []
for item in append_instructions:
text = str(item).strip()
if not text:
continue
if not re.match(r"^([-*]|\d+\.)\s", text):
text = f"- {text}"
extras.append(text)
if not extras:
return merged
if not merged:
return "\n".join(extras)
return merged.rstrip() + "\n\n" + "\n".join(extras)
@staticmethod
def _normalize_persona_body(body: Optional[str]) -> str:
"""去掉重复的 PERSONA 标题,保持正文可安全回写。"""
normalized = (body or "").strip()
if not normalized:
return ""
if normalized.startswith("# PERSONA"):
_, _, remainder = normalized.partition("\n")
return remainder.strip()
return normalized
def _validate_runtime_config(
self,
*,
current_meta: dict[str, Any],
persona_path: Path,
extra_context_paths: list[Path],
persona_text: str,
) -> list[str]:
warnings: list[str] = []
required_paths = [persona_path]
duplicates = self._find_duplicate_paths(required_paths + extra_context_paths)
if duplicates:
warnings.append(
"检测到重复引用的根层配置文件: "
+ ", ".join(path.as_posix() for path in duplicates)
)
deprecated_phrases = self._normalize_string_list(
current_meta.get("deprecated_phrases"), "deprecated_phrases"
)
if deprecated_phrases:
for phrase in deprecated_phrases:
if phrase and phrase in persona_text:
warnings.append(f"检测到已废弃短语 `{phrase}` 仍出现在 persona 中")
return warnings
@staticmethod
def _find_duplicate_paths(paths: Iterable[Path]) -> list[Path]:
seen: set[Path] = set()
duplicates: list[Path] = []
for path in paths:
resolved = path.resolve()
if resolved in seen and resolved not in duplicates:
duplicates.append(resolved)
seen.add(resolved)
return duplicates
@staticmethod
def _render_current_persona_document(
*,
active_persona: str,
extra_context_files: list[str],
deprecated_phrases: list[str],
) -> str:
"""统一生成 CURRENT_PERSONA.md避免手写时结构漂移。"""
metadata = {
"version": CURRENT_PERSONA_SCHEMA_VERSION,
"active_persona": active_persona,
"extra_context_files": extra_context_files,
"deprecated_phrases": deprecated_phrases,
}
body_lines = [
"# CURRENT_PERSONA",
"",
f"当前激活人格:`{active_persona}`",
"",
"运行时加载顺序固定如下:",
"",
"1. 核心系统提示词(程序内置,不可运行时覆盖)",
"2. `personas/<active_persona>/PERSONA.md`",
"3. `extra_context_files`",
"4. `memory/*.md`",
"5. `activity/*.md`",
"",
"`memory` 中的长期偏好可以细化回复方式,但不应覆盖系统核心身份、目标和安全边界。",
]
frontmatter = yaml.safe_dump(
metadata,
sort_keys=False,
allow_unicode=True,
).strip()
return f"---\n{frontmatter}\n---\n" + "\n".join(body_lines) + "\n"
@staticmethod
def _render_persona_document(
*,
persona_id: str,
label: str,
description: str,
aliases: list[str],
body: str,
) -> str:
"""统一生成人格定义文件,避免手写 frontmatter 漂移。"""
metadata = {
"version": PERSONA_SCHEMA_VERSION,
"persona_id": persona_id,
"label": label,
"description": description,
"aliases": aliases,
}
frontmatter = yaml.safe_dump(
metadata,
sort_keys=False,
allow_unicode=True,
).strip()
normalized_body = AgentRuntimeManager._normalize_persona_body(body)
return f"---\n{frontmatter}\n---\n# PERSONA\n\n{normalized_body}\n"
agent_runtime_manager = AgentRuntimeManager()

View File

@@ -1,6 +1,10 @@
import asyncio
import json
import threading
from abc import ABCMeta, abstractmethod
from typing import Any, Optional
from concurrent.futures import ThreadPoolExecutor
from functools import partial
from typing import Any, Callable, ClassVar, Optional
from langchain_core.tools import BaseTool
from pydantic import PrivateAttr
@@ -19,11 +23,101 @@ 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 = {
"default": 4,
"config": 2,
"db": 4,
"downloader": 4,
"mediaserver": 4,
"plugin": 2,
"rule": 2,
"site": 4,
"storage": 4,
"subscribe": 2,
"workflow": 2,
}
_blocking_semaphores = {
bucket: asyncio.Semaphore(limit)
for bucket, limit in _BLOCKING_BUCKET_LIMITS.items()
}
_blocking_executors: dict[str, ThreadPoolExecutor] = {}
_blocking_executor_lock = threading.Lock()
def _get_blocking_executor(bucket: str) -> ThreadPoolExecutor:
"""按桶懒加载线程池,避免在导入阶段创建过多 worker。"""
with _blocking_executor_lock:
executor = _blocking_executors.get(bucket)
if executor:
return executor
limit = _BLOCKING_BUCKET_LIMITS[bucket]
executor = ThreadPoolExecutor(
max_workers=limit,
thread_name_prefix=f"agent-tool-{bucket}",
)
_blocking_executors[bucket] = executor
return executor
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)
@@ -71,20 +165,44 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
if tool_message:
self._stream_handler.emit(f"\n\n⚙️ => {tool_message}\n\n")
else:
# 渠道不支持编辑:取出 Agent 文字 + 工具消息合并独立发送
agent_message = await self._stream_handler.take()
messages = []
if agent_message:
messages.append(agent_message)
if tool_message:
messages.append(f"⚙️ => {tool_message}")
if messages:
merged_message = "\n\n".join(messages)
await self.send_tool_message(merged_message)
allow_dispatch_without_context = self._agent_context.get(
"should_dispatch_reply", False
)
if self._channel and self._source:
# 渠道不支持编辑:取出 Agent 文字 + 工具消息合并独立发送
agent_message = await self._stream_handler.take()
messages = []
if agent_message:
messages.append(agent_message)
if tool_message:
messages.append(f"⚙️ => {tool_message}")
if messages:
merged_message = "\n\n".join(messages)
await self.send_tool_message(merged_message)
elif allow_dispatch_without_context:
agent_message = await self._stream_handler.take()
messages = []
if agent_message:
messages.append(agent_message)
if tool_message:
messages.append(f"⚙️ => {tool_message}")
if messages:
merged_message = "\n\n".join(messages)
await self.send_tool_message(merged_message)
else:
# 后台 capture 流程没有渠道上下文,不能把工具提示回灌到默认通知渠道。
self._stream_handler.record_tool_call(
tool_name=self.name,
tool_message=tool_message,
tool_kwargs=kwargs,
)
else:
# 非VERBOSE工具边界至少补一个换行,避免工具前后的文本直接连在一起
if self._stream_handler.last_buffer_char not in ("", "\n"):
self._stream_handler.emit("\n")
# 非VERBOSE不逐条回显工具调用,转为在下一段文本前补一句聚合摘要
self._stream_handler.record_tool_call(
tool_name=self.name,
tool_message=tool_message,
tool_kwargs=kwargs,
)
else:
# 未启用流式传输,不发送任何工具消息内容
pass
@@ -94,21 +212,16 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
# 执行具体工具逻辑
try:
result = await self.run(**kwargs)
logger.debug(f"Tool {self.name} executed with result: {result}")
result_len = len(str(result)) if result is not None else 0
logger.debug(f"Tool {self.name} executed, raw result length: {result_len}")
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]:
"""
@@ -130,6 +243,23 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
"""子类实现具体的工具执行逻辑"""
raise NotImplementedError
@staticmethod
async def run_blocking(
bucket: str, func: Callable[..., Any], *args: Any, **kwargs: Any
) -> Any:
"""
在受控线程池中运行阻塞型同步代码,避免拖住 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
)
def set_message_attr(self, channel: str, source: str, username: str):
"""
设置消息属性
@@ -165,12 +295,15 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
if not self._channel or not self._source:
return None
# 渠道配置来自 SystemConfigOper 内存缓存,可以直接读取;
# 只有用户信息需要走异步数据库查询。
user_id_str = str(self._user_id) if self._user_id else None
channel_type_map = {
MessageChannel.Telegram: "telegram",
MessageChannel.Discord: "discord",
MessageChannel.Wechat: "wechat",
MessageChannel.WechatClawBot: "wechatclawbot",
MessageChannel.Slack: "slack",
MessageChannel.VoceChat: "vocechat",
MessageChannel.SynologyChat: "synologychat",
@@ -190,6 +323,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
"telegram": "TELEGRAM_ADMINS",
"discord": "DISCORD_ADMINS",
"wechat": "WECHAT_ADMINS",
"wechatclawbot": "WECHATCLAWBOT_ADMINS",
"slack": "SLACK_ADMINS",
"vocechat": "VOCECHAT_ADMINS",
"synologychat": "SYNOLOGYCHAT_ADMINS",
@@ -200,6 +334,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
"telegram": "TELEGRAM_CHAT_ID",
"vocechat": "VOCECHAT_CHANNEL_ID",
"wechat": "WECHAT_BOT_CHAT_ID",
"wechatclawbot": "WECHATCLAWBOT_DEFAULT_TARGET",
}
admin_key = admin_key_map.get(channel_type)
@@ -220,7 +355,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
return None
user = (
UserOper().get_by_name(self._username)
await UserOper().async_get_by_name(self._username)
if self._username
else None
)
@@ -235,7 +370,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
)
else:
user = (
UserOper().get_by_name(self._username)
await UserOper().async_get_by_name(self._username)
if self._username
else None
)

View File

@@ -16,6 +16,14 @@ from app.agent.tools.impl.test_site import TestSiteTool
from app.agent.tools.impl.query_subscribes import QuerySubscribesTool
from app.agent.tools.impl.query_subscribe_shares import QuerySubscribeSharesTool
from app.agent.tools.impl.query_rule_groups import QueryRuleGroupsTool
from app.agent.tools.impl.query_builtin_filter_rules import QueryBuiltinFilterRulesTool
from app.agent.tools.impl.query_custom_filter_rules import QueryCustomFilterRulesTool
from app.agent.tools.impl.add_custom_filter_rule import AddCustomFilterRuleTool
from app.agent.tools.impl.update_custom_filter_rule import UpdateCustomFilterRuleTool
from app.agent.tools.impl.delete_custom_filter_rule import DeleteCustomFilterRuleTool
from app.agent.tools.impl.add_rule_group import AddRuleGroupTool
from app.agent.tools.impl.update_rule_group import UpdateRuleGroupTool
from app.agent.tools.impl.delete_rule_group import DeleteRuleGroupTool
from app.agent.tools.impl.query_popular_subscribes import QueryPopularSubscribesTool
from app.agent.tools.impl.query_subscribe_history import QuerySubscribeHistoryTool
from app.agent.tools.impl.delete_subscribe import DeleteSubscribeTool
@@ -37,6 +45,9 @@ from app.agent.tools.impl.query_schedulers import QuerySchedulersTool
from app.agent.tools.impl.run_scheduler import RunSchedulerTool
from app.agent.tools.impl.query_workflows import QueryWorkflowsTool
from app.agent.tools.impl.run_workflow import RunWorkflowTool
from app.agent.tools.impl.query_personas import QueryPersonasTool
from app.agent.tools.impl.switch_persona import SwitchPersonaTool
from app.agent.tools.impl.update_persona_definition import UpdatePersonaDefinitionTool
from app.agent.tools.impl.update_site_cookie import UpdateSiteCookieTool
from app.agent.tools.impl.delete_download import DeleteDownloadTool
from app.agent.tools.impl.delete_download_history import DeleteDownloadHistoryTool
@@ -52,7 +63,14 @@ from app.agent.tools.impl.write_file import WriteFileTool
from app.agent.tools.impl.read_file import ReadFileTool
from app.agent.tools.impl.browse_webpage import BrowseWebpageTool
from app.agent.tools.impl.query_installed_plugins import QueryInstalledPluginsTool
from app.agent.tools.impl.query_market_plugins import QueryMarketPluginsTool
from app.agent.tools.impl.query_plugin_capabilities import QueryPluginCapabilitiesTool
from app.agent.tools.impl.query_plugin_config import QueryPluginConfigTool
from app.agent.tools.impl.update_plugin_config import UpdatePluginConfigTool
from app.agent.tools.impl.reload_plugin import ReloadPluginTool
from app.agent.tools.impl.query_plugin_data import QueryPluginDataTool
from app.agent.tools.impl.install_plugin import InstallPluginTool
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
@@ -69,6 +87,18 @@ class MoviePilotToolFactory:
MoviePilot工具工厂
"""
# 这些通用工具需要始终保留,避免大工具集裁剪后让 Agent 丢失基础的
# 文件系统、命令执行或交互确认能力。AskUserChoiceTool 仅在支持按钮
# 的渠道中才会实际注入,因此后续会再按已加载工具做一次求交集。
TOOL_SELECTOR_ALWAYS_INCLUDE_NAMES = (
"list_directory",
"write_file",
"read_file",
"edit_file",
"execute_command",
"ask_user_choice",
)
@staticmethod
def _should_enable_choice_tool(channel: str = None) -> bool:
if not channel:
@@ -81,6 +111,25 @@ class MoviePilotToolFactory:
message_channel
) and ChannelCapabilityManager.supports_callbacks(message_channel)
@classmethod
def get_tool_selector_always_include_names(
cls, tools: List[MoviePilotTool]
) -> List[str]:
"""
返回当前实际已加载且需要绕过工具筛选的工具名。
`LLMToolSelectorMiddleware` 会校验 `always_include` 中的工具名是否
存在于当前请求里,因此这里必须根据运行时工具列表做交集过滤。
"""
available_tool_names = {
tool.name for tool in tools if getattr(tool, "name", None)
}
return [
tool_name
for tool_name in cls.TOOL_SELECTOR_ALWAYS_INCLUDE_NAMES
if tool_name in available_tool_names
]
@staticmethod
def create_tools(
session_id: str,
@@ -90,6 +139,7 @@ class MoviePilotToolFactory:
username: str = None,
stream_handler: Callable = None,
agent_context: dict = None,
allow_message_tools: bool = True,
) -> List[MoviePilotTool]:
"""
创建MoviePilot工具列表
@@ -113,7 +163,15 @@ class MoviePilotToolFactory:
QuerySubscribesTool,
QuerySubscribeSharesTool,
QueryPopularSubscribesTool,
QueryBuiltinFilterRulesTool,
QueryCustomFilterRulesTool,
QueryRuleGroupsTool,
AddCustomFilterRuleTool,
UpdateCustomFilterRuleTool,
DeleteCustomFilterRuleTool,
AddRuleGroupTool,
UpdateRuleGroupTool,
DeleteRuleGroupTool,
QuerySubscribeHistoryTool,
DeleteSubscribeTool,
QueryDownloadTasksTool,
@@ -139,13 +197,23 @@ class MoviePilotToolFactory:
RunSchedulerTool,
QueryWorkflowsTool,
RunWorkflowTool,
QueryPersonasTool,
SwitchPersonaTool,
UpdatePersonaDefinitionTool,
ExecuteCommandTool,
EditFileTool,
WriteFileTool,
ReadFileTool,
BrowseWebpageTool,
QueryInstalledPluginsTool,
QueryMarketPluginsTool,
QueryPluginCapabilitiesTool,
QueryPluginConfigTool,
UpdatePluginConfigTool,
ReloadPluginTool,
QueryPluginDataTool,
InstallPluginTool,
UninstallPluginTool,
RunSlashCommandTool,
ListSlashCommandsTool,
QueryCustomIdentifiersTool,
@@ -162,6 +230,8 @@ class MoviePilotToolFactory:
# 创建内置工具
for ToolClass in tool_definitions:
tool = ToolClass(session_id=session_id, user_id=user_id)
if not allow_message_tools and getattr(tool, "sends_message", False):
continue
tool.set_message_attr(channel=channel, source=source, username=username)
tool.set_stream_handler(stream_handler=stream_handler)
tool.set_agent_context(agent_context=agent_context)
@@ -184,6 +254,8 @@ class MoviePilotToolFactory:
continue
# 创建工具实例
tool = ToolClass(session_id=session_id, user_id=user_id)
if not allow_message_tools and getattr(tool, "sends_message", False):
continue
tool.set_message_attr(
channel=channel, source=source, username=username
)

View File

@@ -0,0 +1,540 @@
"""过滤规则 Agent 工具共用的校验、查询和引用处理逻辑。"""
import copy
import re
from typing import Any, Dict, Iterable, Optional
from app.core.event import eventmanager
from app.db import AsyncSessionFactory
from app.db.models.subscribe import Subscribe
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.rule import RuleHelper
from app.modules.filter.RuleParser import RuleParser
from app.modules.filter.builtin_rules import BUILTIN_RULE_SET
from app.schemas import CustomRule, FilterRuleGroup
from app.schemas.event import ConfigChangeEventData
from app.schemas.types import EventType, SystemConfigKey
RULE_ID_PATTERN = re.compile(r"^[A-Za-z0-9]+$")
RULE_TOKEN_PATTERN = re.compile(r"[A-Za-z][A-Za-z0-9]*|[0-9][A-Za-z0-9]+")
NUMERIC_RANGE_PATTERN = re.compile(
r"^\d+(?:\.\d+)?(?:\s*-\s*\d+(?:\.\d+)?)?$"
)
MEDIA_TYPE_ALIASES = {
"movie": "电影",
"film": "电影",
"tv": "电视剧",
"series": "电视剧",
"show": "电视剧",
"电影": "电影",
"电视剧": "电视剧",
}
RULE_STRING_SYNTAX = {
"level_separator": ">",
"and_operator": "&",
"not_operator": "!",
"supported_grouping": "Parentheses are supported inside a single level.",
"spacing_note": "Prefer spaces around '&', and '>' for readability; use '!RULE' for negation.",
"match_order": "Levels are evaluated from left to right. The first matched level wins and stops further matching.",
"match_result": "If no level matches, the torrent is filtered out. If a level matches, the torrent is kept.",
"writing_workflow": [
"First query built-in rules and custom rules to learn valid rule IDs.",
"Compose one priority level with '&', '!' and optional parentheses.",
"Join multiple priority levels with '>' from highest priority to lowest priority.",
"Use spaces around '&', and '>' for readability.",
],
"examples": [
{
"description": "Prefer torrents with special subtitles and Chinese dubbing at 4K, otherwise fall back to Chinese subtitles and Chinese dubbing at 4K.",
"rule_string": "SPECSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL > CNSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL",
},
{
"description": "Inside one level, require 4K and reject Blu-ray source.",
"rule_string": "4K & !BLU",
},
{
"description": "Inside one level, accept either special subtitles or Chinese subtitles, then also require 1080P.",
"rule_string": "(SPECSUB | CNSUB) & 1080P",
},
],
}
def normalize_optional_text(value: Optional[str]) -> Optional[str]:
"""把空白字符串折叠为 None避免保存无意义的空值。"""
if value is None:
return None
value = str(value).strip()
return value or None
def normalize_media_type(value: Optional[str]) -> Optional[str]:
"""兼容英中文媒体类型输入,最终统一为后端实际使用的中文值。"""
value = normalize_optional_text(value)
if not value:
return None
normalized = MEDIA_TYPE_ALIASES.get(value.lower(), value)
if normalized not in {"电影", "电视剧"}:
raise ValueError(
"media_type 仅支持 '电影''电视剧''movie''tv'"
)
return normalized
def validate_numeric_range(
field_name: str, value: Optional[str]
) -> Optional[str]:
"""校验 size_range / publish_time 这类单值或区间值。"""
value = normalize_optional_text(value)
if not value:
return None
if not NUMERIC_RANGE_PATTERN.match(value):
raise ValueError(
f"{field_name} 格式无效,支持 '1000''1000-5000' 这类数字区间格式"
)
parts = [float(item.strip()) for item in value.split("-")]
if len(parts) == 2 and parts[0] > parts[1]:
raise ValueError(f"{field_name} 区间起始值不能大于结束值")
return value
def validate_seeders(value: Optional[str]) -> Optional[str]:
"""做种人数最终会被 int() 解析,这里提前拦住非法值。"""
value = normalize_optional_text(value)
if not value:
return None
if not value.isdigit():
raise ValueError("seeders 必须是非负整数")
return value
def get_builtin_rules() -> Dict[str, dict]:
"""返回内置规则的深拷贝,避免调用方误改共享常量。"""
return copy.deepcopy(BUILTIN_RULE_SET)
def get_custom_rules() -> list[CustomRule]:
return RuleHelper().get_custom_rules()
def get_rule_groups() -> list[FilterRuleGroup]:
return RuleHelper().get_rule_groups()
def build_custom_rule_map(rules: Optional[Iterable[CustomRule]] = None) -> Dict[str, CustomRule]:
return {
rule.id: rule
for rule in (rules or get_custom_rules())
if rule.id
}
def build_rule_group_map(
groups: Optional[Iterable[FilterRuleGroup]] = None,
) -> Dict[str, FilterRuleGroup]:
return {
group.name: group
for group in (groups or get_rule_groups())
if group.name
}
def extract_rule_tokens(rule_string: Optional[str]) -> list[str]:
"""从规则串里提取规则 ID用于引用分析和未知规则校验。"""
if not rule_string:
return []
# dict.fromkeys 用来在保留顺序的同时去重,便于展示和报错。
return list(dict.fromkeys(RULE_TOKEN_PATTERN.findall(rule_string)))
def parse_rule_string(rule_string: str) -> dict:
"""使用后端同款 RuleParser 解析规则串,并拆出每一层的元数据。"""
normalized = normalize_optional_text(rule_string)
if not normalized:
raise ValueError("rule_string 不能为空")
parser = RuleParser()
levels = [level.strip() for level in normalized.split(">")]
if any(not level for level in levels):
raise ValueError("rule_string 不能包含空层级,请检查 '>' 两侧内容")
parsed_levels = []
for index, level in enumerate(levels, start=1):
try:
parser.parse(level)
except Exception as exc: # pragma: no cover - 依赖 pyparsing 的具体异常
raise ValueError(f"规则串第 {index} 层语法错误: {exc}") from exc
parsed_levels.append(
{
"priority": index,
"expression": level,
"referenced_rules": extract_rule_tokens(level),
}
)
return {
"rule_string": " > ".join(levels),
"levels": parsed_levels,
"referenced_rules": extract_rule_tokens(normalized),
}
def validate_rule_string(rule_string: str, available_rule_ids: Iterable[str]) -> dict:
"""校验规则串语法和引用规则是否都存在。"""
parsed = parse_rule_string(rule_string)
available_ids = set(available_rule_ids)
unknown_rules = sorted(
{
rule_id
for rule_id in parsed["referenced_rules"]
if rule_id not in available_ids
}
)
if unknown_rules:
raise ValueError(
f"rule_string 引用了不存在的规则: {', '.join(unknown_rules)}"
)
return parsed
def serialize_builtin_rule(rule_id: str, payload: dict) -> dict:
"""把内置规则整理成适合 Agent 阅读的结构。"""
data = copy.deepcopy(payload)
data["id"] = rule_id
data["source"] = "builtin"
return data
def serialize_custom_rule(rule: CustomRule, group_refs: Optional[list[str]] = None) -> dict:
data = rule.model_dump(exclude_none=True)
data["source"] = "custom"
data["referenced_by_rule_groups"] = group_refs or []
return data
def serialize_rule_group(group: FilterRuleGroup, usage: Optional[dict] = None) -> dict:
"""查询时尽量附带解析结果,便于 Agent 理解优先级层级。"""
data = group.model_dump(exclude_none=True)
if group.rule_string:
try:
parsed = parse_rule_string(group.rule_string)
data["levels"] = parsed["levels"]
data["referenced_rules"] = parsed["referenced_rules"]
data["syntax_valid"] = True
except ValueError as exc:
data["syntax_valid"] = False
data["syntax_error"] = str(exc)
data["referenced_rules"] = extract_rule_tokens(group.rule_string)
else:
data["syntax_valid"] = False
data["syntax_error"] = "rule_string 为空"
data["referenced_rules"] = []
data["usage"] = usage or default_rule_group_usage()
return data
def default_rule_group_usage() -> dict:
return {
"used_in_global_search": False,
"used_in_global_subscribe": False,
"used_in_global_best_version": False,
"subscribes": [],
}
async def collect_rule_group_usages(
group_names: Optional[Iterable[str]] = None,
) -> Dict[str, dict]:
"""收集规则组在全局配置和订阅上的引用情况。"""
target_names = set(group_names or [])
search_groups = set(
SystemConfigOper().get(SystemConfigKey.SearchFilterRuleGroups) or []
)
subscribe_groups = set(
SystemConfigOper().get(SystemConfigKey.SubscribeFilterRuleGroups) or []
)
best_version_groups = set(
SystemConfigOper().get(SystemConfigKey.BestVersionFilterRuleGroups) or []
)
usage_map = {
name: default_rule_group_usage()
for name in target_names
}
def ensure_usage(name: str) -> dict:
if name not in usage_map:
usage_map[name] = default_rule_group_usage()
return usage_map[name]
for name in search_groups:
if target_names and name not in target_names:
continue
ensure_usage(name)["used_in_global_search"] = True
for name in subscribe_groups:
if target_names and name not in target_names:
continue
ensure_usage(name)["used_in_global_subscribe"] = True
for name in best_version_groups:
if target_names and name not in target_names:
continue
ensure_usage(name)["used_in_global_best_version"] = True
async with AsyncSessionFactory() as db:
subscribes = await Subscribe.async_list(db)
for subscribe in subscribes:
filter_groups = subscribe.filter_groups or []
for name in filter_groups:
if target_names and name not in target_names:
continue
ensure_usage(name)["subscribes"].append(
{
"subscribe_id": subscribe.id,
"name": subscribe.name,
"season": subscribe.season,
"type": subscribe.type,
"username": subscribe.username,
"best_version": bool(subscribe.best_version),
}
)
return usage_map
def collect_custom_rule_group_refs(
rule_groups: Iterable[FilterRuleGroup],
rule_ids: Optional[Iterable[str]] = None,
) -> Dict[str, list[str]]:
"""收集自定义规则被哪些规则组引用。"""
target_rule_ids = set(rule_ids or [])
refs: Dict[str, list[str]] = {
rule_id: []
for rule_id in target_rule_ids
}
for group in rule_groups:
if not group.name or not group.rule_string:
continue
referenced = set(extract_rule_tokens(group.rule_string))
for rule_id in referenced:
if target_rule_ids and rule_id not in target_rule_ids:
continue
refs.setdefault(rule_id, []).append(group.name)
for names in refs.values():
names.sort()
return refs
def normalize_custom_rule(
rule_id: str,
name: str,
include: Optional[str],
exclude: Optional[str],
size_range: Optional[str],
seeders: Optional[str],
publish_time: Optional[str],
existing_rules: Iterable[CustomRule],
original_rule_id: Optional[str] = None,
) -> CustomRule:
"""新增/更新自定义规则时统一走这里,避免多处散落校验逻辑。"""
normalized_rule_id = normalize_optional_text(rule_id)
normalized_name = normalize_optional_text(name)
if not normalized_rule_id:
raise ValueError("rule_id 不能为空")
if not normalized_name:
raise ValueError("name 不能为空")
if not RULE_ID_PATTERN.match(normalized_rule_id):
raise ValueError("rule_id 仅支持英文字母和数字")
if (
normalized_rule_id in BUILTIN_RULE_SET
and normalized_rule_id != original_rule_id
):
raise ValueError(
f"rule_id '{normalized_rule_id}' 与内置规则冲突,不能覆盖内置规则"
)
for existing_rule in existing_rules:
if (
existing_rule.id == normalized_rule_id
and existing_rule.id != original_rule_id
):
raise ValueError(f"rule_id '{normalized_rule_id}' 已存在")
if (
existing_rule.name == normalized_name
and existing_rule.id != original_rule_id
):
raise ValueError(f"规则名称 '{normalized_name}' 已存在")
return CustomRule(
id=normalized_rule_id,
name=normalized_name,
include=normalize_optional_text(include),
exclude=normalize_optional_text(exclude),
size_range=validate_numeric_range("size_range", size_range),
seeders=validate_seeders(seeders),
publish_time=validate_numeric_range("publish_time", publish_time),
)
def normalize_rule_group(
name: str,
rule_string: str,
media_type: Optional[str],
category: Optional[str],
existing_groups: Iterable[FilterRuleGroup],
available_rule_ids: Iterable[str],
original_name: Optional[str] = None,
) -> tuple[FilterRuleGroup, dict]:
"""新增/更新规则组时统一校验名字、适用范围和规则串。"""
normalized_name = normalize_optional_text(name)
if not normalized_name:
raise ValueError("规则组名称不能为空")
for group in existing_groups:
if group.name == normalized_name and group.name != original_name:
raise ValueError(f"规则组名称 '{normalized_name}' 已存在")
normalized_media_type = normalize_media_type(media_type)
normalized_category = normalize_optional_text(category)
if normalized_category and not normalized_media_type:
raise ValueError("设置 category 时必须同时设置 media_type")
parsed = validate_rule_string(rule_string, available_rule_ids)
return (
FilterRuleGroup(
name=normalized_name,
rule_string=parsed["rule_string"],
media_type=normalized_media_type,
category=normalized_category,
),
parsed,
)
async def save_system_config(
key: SystemConfigKey, value: Any
) -> Optional[bool]:
"""通过统一入口保存配置并补发 ConfigChanged 事件。"""
normalized_value = value
if isinstance(normalized_value, list):
normalized_value = [
item
for item in normalized_value
if item is not None and item != ""
]
normalized_value = normalized_value or None
success = await SystemConfigOper().async_set(key, normalized_value)
if success:
await eventmanager.async_send_event(
etype=EventType.ConfigChanged,
data=ConfigChangeEventData(
key=key,
value=normalized_value,
change_type="update",
),
)
return success
def replace_rule_id_in_rule_string(
rule_string: str, old_rule_id: str, new_rule_id: str
) -> str:
"""只替换完整 token避免误伤其他规则名。"""
pattern = re.compile(
rf"(?<![A-Za-z0-9]){re.escape(old_rule_id)}(?![A-Za-z0-9])"
)
return pattern.sub(new_rule_id, rule_string)
def replace_group_name_in_list(
values: Optional[Iterable[str]], old_name: str, new_name: str
) -> list[str]:
"""更新配置里的规则组名引用,并顺手去重。"""
result = []
for value in values or []:
mapped = new_name if value == old_name else value
if mapped not in result:
result.append(mapped)
return result
async def rename_rule_group_references(old_name: str, new_name: str) -> dict:
"""规则组改名后,联动更新全局设置和订阅引用。"""
changed = {
"global_settings": {},
"subscribes": [],
}
for config_key in (
SystemConfigKey.SearchFilterRuleGroups,
SystemConfigKey.SubscribeFilterRuleGroups,
SystemConfigKey.BestVersionFilterRuleGroups,
):
original = SystemConfigOper().get(config_key) or []
updated = replace_group_name_in_list(original, old_name, new_name)
if updated != original:
await save_system_config(config_key, updated)
changed["global_settings"][config_key.value] = updated
async with AsyncSessionFactory() as db:
subscribes = await Subscribe.async_list(db)
for subscribe in subscribes:
original = subscribe.filter_groups or []
updated = replace_group_name_in_list(original, old_name, new_name)
if updated == original:
continue
await subscribe.async_update(db, {"filter_groups": updated})
changed["subscribes"].append(
{
"subscribe_id": subscribe.id,
"name": subscribe.name,
"season": subscribe.season,
"filter_groups": updated,
}
)
return changed
async def remove_rule_group_references(group_name: str) -> dict:
"""删除规则组后,清理全局设置和订阅里的悬空引用。"""
changed = {
"global_settings": {},
"subscribes": [],
}
for config_key in (
SystemConfigKey.SearchFilterRuleGroups,
SystemConfigKey.SubscribeFilterRuleGroups,
SystemConfigKey.BestVersionFilterRuleGroups,
):
original = SystemConfigOper().get(config_key) or []
updated = [value for value in original if value != group_name]
if updated != original:
await save_system_config(config_key, updated)
changed["global_settings"][config_key.value] = updated
async with AsyncSessionFactory() as db:
subscribes = await Subscribe.async_list(db)
for subscribe in subscribes:
original = subscribe.filter_groups or []
updated = [value for value in original if value != group_name]
if updated == original:
continue
await subscribe.async_update(db, {"filter_groups": updated})
changed["subscribes"].append(
{
"subscribe_id": subscribe.id,
"name": subscribe.name,
"season": subscribe.season,
"filter_groups": updated,
}
)
return changed

View File

@@ -0,0 +1,291 @@
"""插件 Agent 工具共享辅助方法"""
import json
import shutil
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.plugin import PluginHelper
from app.schemas.types import SystemConfigKey
# 默认只向智能体返回一个可读预览,避免超大插件数据挤爆上下文窗口。
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 = 50
MAX_PLUGIN_CANDIDATE_LIMIT = 200
def get_plugin_snapshot(plugin_id: str) -> Optional[dict[str, Any]]:
"""
获取已安装插件的基础信息快照。
"""
plugin_manager = PluginManager()
for plugin in plugin_manager.get_local_plugins():
if plugin.id == plugin_id:
return {
"plugin_id": plugin.id,
"plugin_name": plugin.plugin_name,
"plugin_version": plugin.plugin_version,
"state": plugin.state,
}
return None
def clamp_preview_chars(max_chars: Optional[int]) -> int:
"""
约束插件数据预览长度,避免工具结果无限膨胀。
"""
if max_chars is None:
return DEFAULT_PLUGIN_DATA_PREVIEW_CHARS
return max(512, min(int(max_chars), MAX_PLUGIN_DATA_PREVIEW_CHARS))
def serialize_for_agent(value: Any) -> str:
"""
将结果稳定序列化为 JSON 字符串,无法原生序列化的对象退化为字符串。
"""
return json.dumps(value, ensure_ascii=False, indent=2, default=str)
def build_preview_payload(value: Any, max_chars: Optional[int]) -> tuple[bool, int, int, str]:
"""
为可能很大的插件数据生成预览结果。
"""
serialized = serialize_for_agent(value)
if len(serialized) <= clamp_preview_chars(max_chars):
return False, len(serialized), len(serialized), serialized
preview_limit = clamp_preview_chars(max_chars)
preview = serialized[:preview_limit] + PLUGIN_DATA_TRUNCATION_SUFFIX
return True, len(serialized), len(preview), preview
def reload_plugin_runtime(plugin_id: str) -> None:
"""
重载插件并重新注册其命令、定时任务和 API。
"""
# 这些依赖只在真正执行重载时才导入,避免普通查询工具引入不必要的初始化开销。
from app.api.endpoints.plugin import register_plugin_api
from app.command import Command
from app.scheduler import Scheduler
plugin_manager = PluginManager()
plugin_manager.reload_plugin(plugin_id)
Scheduler().update_plugin_job(plugin_id)
Command().init_commands(plugin_id)
register_plugin_api(plugin_id)
def summarize_plugin(plugin: Any) -> dict[str, Any]:
"""
提取插件对象中对 Agent 有价值的摘要字段。
"""
repo_url = getattr(plugin, "repo_url", None)
return {
"id": getattr(plugin, "id", None),
"plugin_name": getattr(plugin, "plugin_name", None),
"plugin_desc": getattr(plugin, "plugin_desc", None),
"plugin_version": getattr(plugin, "plugin_version", None),
"plugin_author": getattr(plugin, "plugin_author", None),
"installed": bool(getattr(plugin, "installed", False)),
"has_update": bool(getattr(plugin, "has_update", False)),
"state": bool(getattr(plugin, "state", False)),
"repo_url": repo_url,
"source": "local_repo" if PluginHelper.is_local_repo_url(repo_url) else "market",
}
async def load_market_plugins(force_refresh: bool = False) -> list[Any]:
"""
聚合插件市场与本地插件仓库中的候选插件。
"""
plugin_manager = PluginManager()
online_plugins = await plugin_manager.async_get_online_plugins(force=force_refresh)
local_repo_plugins = plugin_manager.get_local_repo_plugins()
if not online_plugins and not local_repo_plugins:
return []
return plugin_manager.process_plugins_list(online_plugins + local_repo_plugins, [])
def list_installed_plugins() -> list[Any]:
"""
返回当前已安装插件列表。
"""
plugin_manager = PluginManager()
return [plugin for plugin in plugin_manager.get_local_plugins() if plugin.installed]
def _normalize_text(value: Optional[str]) -> str:
return (value or "").strip().lower()
def is_exact_plugin_match(plugin: Any, query: str) -> bool:
"""
精确匹配插件 ID 或插件名称,用于安全地自动选择候选。
"""
normalized_query = _normalize_text(query)
return normalized_query in {
_normalize_text(getattr(plugin, "id", None)),
_normalize_text(getattr(plugin, "plugin_name", None)),
}
def search_plugin_candidates(query: str, plugins: list[Any]) -> list[dict[str, Any]]:
"""
按插件 ID、名称、描述和作者搜索候选并返回打分结果。
"""
normalized_query = _normalize_text(query)
if not normalized_query:
return []
tokens = [token for token in normalized_query.replace("-", " ").split() if token]
matches: list[dict[str, Any]] = []
for plugin in plugins:
plugin_id = _normalize_text(getattr(plugin, "id", None))
plugin_name = _normalize_text(getattr(plugin, "plugin_name", None))
plugin_desc = _normalize_text(getattr(plugin, "plugin_desc", None))
plugin_author = _normalize_text(getattr(plugin, "plugin_author", None))
haystack = "\n".join([plugin_id, plugin_name, plugin_desc, plugin_author])
score = 0
if normalized_query == plugin_id:
score = 100
elif normalized_query == plugin_name:
score = 95
elif plugin_id.startswith(normalized_query):
score = 85
elif plugin_name.startswith(normalized_query):
score = 80
elif normalized_query in plugin_id:
score = 75
elif normalized_query in plugin_name:
score = 70
elif tokens and all(token in plugin_name for token in tokens):
score = 68
elif tokens and all(token in plugin_id for token in tokens):
score = 66
elif normalized_query in plugin_desc:
score = 45
elif normalized_query in plugin_author:
score = 40
elif tokens and all(token in haystack for token in tokens):
score = 35
if score <= 0:
continue
matches.append(
{
"plugin": plugin,
"score": score,
"exact": is_exact_plugin_match(plugin, normalized_query),
}
)
return sorted(
matches,
key=lambda item: (
-item["score"],
not item["exact"],
-int(bool(getattr(item["plugin"], "has_update", False))),
-int(bool(getattr(item["plugin"], "installed", False))),
-int(getattr(item["plugin"], "add_time", 0) or 0),
),
)
def summarize_candidates(matches: list[dict[str, Any]], limit: int = DEFAULT_PLUGIN_CANDIDATE_LIMIT) -> list[dict[str, Any]]:
"""
压缩候选列表,避免一次性把完整市场数据返回给 Agent。
"""
return [
{
**summarize_plugin(item["plugin"]),
"score": item["score"],
"exact": item["exact"],
}
for item in matches[:limit]
]
async def install_plugin_runtime(
plugin_id: str, repo_url: Optional[str], force: bool = False
) -> tuple[bool, str, bool]:
"""
按现有插件接口的行为安装插件,并刷新运行态注册信息。
"""
install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
plugin_manager = PluginManager()
plugin_helper = PluginHelper()
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)
message = "插件已存在,已刷新加载"
else:
if not repo_url:
return False, "没有传入仓库地址,无法正确安装插件,请检查配置", False
state, message = await plugin_helper.async_install(
pid=plugin_id,
repo_url=repo_url,
force_install=force,
)
if not state:
return False, message, False
if plugin_id not in install_plugins:
install_plugins.append(plugin_id)
await SystemConfigOper().async_set(
SystemConfigKey.UserInstalledPlugins, install_plugins
)
reload_plugin_runtime(plugin_id)
return True, message or "插件安装成功", refreshed_only
async def uninstall_plugin_runtime(plugin_id: str) -> dict[str, Any]:
"""
按现有卸载逻辑移除插件,并清理运行态注册与分组信息。
"""
from app.api.endpoints.plugin import _remove_plugin_from_folders, remove_plugin_api
from app.scheduler import Scheduler
config_oper = SystemConfigOper()
install_plugins = config_oper.get(SystemConfigKey.UserInstalledPlugins) or []
if plugin_id in install_plugins:
install_plugins = [plugin for plugin in install_plugins if plugin != plugin_id]
await config_oper.async_set(SystemConfigKey.UserInstalledPlugins, install_plugins)
remove_plugin_api(plugin_id)
Scheduler().remove_plugin_job(plugin_id)
plugin_manager = PluginManager()
plugin_class = plugin_manager.plugins.get(plugin_id)
was_clone = bool(getattr(plugin_class, "is_clone", False))
clone_files_removed = False
if was_clone:
plugin_manager.delete_plugin_config(plugin_id)
plugin_manager.delete_plugin_data(plugin_id)
plugin_base_dir = settings.ROOT_PATH / "app" / "plugins" / plugin_id.lower()
if plugin_base_dir.exists():
try:
shutil.rmtree(plugin_base_dir)
plugin_manager.plugins.pop(plugin_id, None)
clone_files_removed = True
except Exception:
clone_files_removed = False
_remove_plugin_from_folders(plugin_id)
plugin_manager.remove_plugin(plugin_id)
return {
"was_clone": was_clone,
"clone_files_removed": clone_files_removed,
}

View File

@@ -0,0 +1,111 @@
"""新增自定义过滤规则工具。"""
import json
from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.impl._filter_rule_utils import (
get_custom_rules,
normalize_custom_rule,
save_system_config,
serialize_custom_rule,
)
from app.log import logger
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",
)
rule_id: str = Field(
...,
description="Unique custom rule ID. Only letters and numbers are allowed.",
)
name: str = Field(..., description="Display name of the custom rule.")
include: Optional[str] = Field(
None, description="Optional include regex for the rule."
)
exclude: Optional[str] = Field(
None, description="Optional exclude regex for the rule."
)
size_range: Optional[str] = Field(
None, description="Optional size range in MB, for example '1000-5000'."
)
seeders: Optional[str] = Field(
None, description="Optional minimum seeder count as a non-negative integer."
)
publish_time: Optional[str] = Field(
None,
description="Optional publish-time filter in minutes, for example '60' or '60-1440'.",
)
class AddCustomFilterRuleTool(MoviePilotTool):
name: str = "add_custom_filter_rule"
description: str = (
"Add a custom filter rule to CustomFilterRules. "
"The new rule can then be referenced by rule ID inside filter rule groups."
)
args_schema: Type[BaseModel] = AddCustomFilterRuleInput
require_admin: bool = True
def get_tool_message(self, **kwargs) -> Optional[str]:
return f"新增自定义过滤规则 {kwargs.get('rule_id', '')}"
async def run(
self,
rule_id: str,
name: str,
include: Optional[str] = None,
exclude: Optional[str] = None,
size_range: Optional[str] = None,
seeders: Optional[str] = None,
publish_time: Optional[str] = None,
**kwargs,
) -> str:
logger.info(f"执行工具: {self.name}, rule_id={rule_id}")
try:
custom_rules = get_custom_rules()
new_rule = normalize_custom_rule(
rule_id=rule_id,
name=name,
include=include,
exclude=exclude,
size_range=size_range,
seeders=seeders,
publish_time=publish_time,
existing_rules=custom_rules,
)
custom_rules.append(new_rule)
await save_system_config(
SystemConfigKey.CustomFilterRules,
[rule.model_dump(exclude_none=True) for rule in custom_rules],
)
return json.dumps(
{
"success": True,
"message": f"已新增自定义过滤规则 {new_rule.id}",
"custom_rule": serialize_custom_rule(new_rule),
"count": len(custom_rules),
},
ensure_ascii=False,
indent=2,
)
except Exception as exc:
logger.error(f"新增自定义过滤规则失败: {exc}", exc_info=True)
return json.dumps(
{
"success": False,
"message": f"新增自定义过滤规则失败: {exc}",
},
ensure_ascii=False,
)

View File

@@ -6,7 +6,8 @@ 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.chain.media import MediaChain
from app.chain.search import SearchChain
from app.chain.download import DownloadChain
from app.core.config import settings
@@ -104,6 +105,29 @@ class AddDownloadTool(MoviePilotTool):
return None
return context
@classmethod
async def _async_resolve_cached_context(cls, torrent_ref: str) -> Optional[Context]:
"""异步读取最近搜索缓存,避免在协程里直接访问同步文件缓存。"""
ref = str(torrent_ref).strip()
if ":" not in ref:
return None
try:
ref_hash, ref_index = ref.split(":", 1)
index = int(ref_index)
except (TypeError, ValueError):
return None
if index < 1:
return None
results = await SearchChain().async_last_search_results() or []
if index > len(results):
return None
context = results[index - 1]
if not ref_hash or cls._build_torrent_ref(context) != ref_hash:
return None
return context
@staticmethod
def _merge_labels_with_system_tag(labels: Optional[str]) -> Optional[str]:
"""合并用户标签与系统默认标签,确保任务可被系统管理"""
@@ -164,6 +188,43 @@ class AddDownloadTool(MoviePilotTool):
return Path(FileURI(storage=dir_conf.storage or "local", path=dir_conf.download_path).uri)
@staticmethod
def _download_direct_sync(
torrent_input: str,
download_dir: Path,
merged_labels: Optional[str],
downloader: Optional[str],
) -> tuple[Optional[str], Optional[str]]:
"""同步添加磁力下载任务,避免下载器调用阻塞事件循环。"""
result = DownloadChain().download(
content=torrent_input,
download_dir=download_dir,
cookie=None,
label=merged_labels,
downloader=downloader,
)
if result:
_, did, _, error_msg = result
else:
did, error_msg = None, "未找到下载器"
return did, error_msg
@staticmethod
def _download_single_sync(
context: Context,
downloader: Optional[str],
save_path: Optional[str],
merged_labels: Optional[str],
) -> tuple[Optional[str], Optional[str]]:
"""同步提交带上下文的下载任务,避免站点下载与下载器调用阻塞事件循环。"""
return DownloadChain().download_single(
context=context,
downloader=downloader,
save_path=save_path,
label=merged_labels,
return_detail=True,
)
async def run(self, torrent_url: Optional[List[str]] = None,
downloader: Optional[str] = None, save_path: Optional[str] = None,
labels: Optional[str] = None, **kwargs) -> str:
@@ -175,14 +236,13 @@ class AddDownloadTool(MoviePilotTool):
if not torrent_inputs:
return "错误torrent_url 不能为空。"
download_chain = DownloadChain()
merged_labels = self._merge_labels_with_system_tag(labels)
success_count = 0
failed_messages = []
for torrent_input in torrent_inputs:
if self._is_torrent_ref(torrent_input):
cached_context = self._resolve_cached_context(torrent_input)
cached_context = await self._async_resolve_cached_context(torrent_input)
if not cached_context or not cached_context.torrent_info:
failed_messages.append(f"{torrent_input} 引用无效,请重新使用 get_search_results 查看搜索结果")
continue
@@ -216,7 +276,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
@@ -232,33 +295,33 @@ class AddDownloadTool(MoviePilotTool):
f"{torrent_input} 不是有效的下载内容,非 hash:id 时仅支持 magnet: 开头"
)
continue
download_dir = self._resolve_direct_download_dir(save_path)
download_dir = await self.run_blocking(
"storage", self._resolve_direct_download_dir, save_path
)
if not download_dir:
failed_messages.append(f"{torrent_input} 缺少保存路径,且系统未配置可用下载目录")
continue
result = download_chain.download(
content=torrent_input,
download_dir=download_dir,
cookie=None,
label=merged_labels,
downloader=downloader
did, error_msg = await self.run_blocking(
"downloader",
self._download_direct_sync,
torrent_input,
download_dir,
merged_labels,
downloader,
)
if result:
_, did, _, error_msg = result
else:
did, error_msg = None, "未找到下载器"
if did:
success_count += 1
else:
failed_messages.append(self._build_failure_message(torrent_input, error_msg))
continue
did, error_msg = download_chain.download_single(
context=context,
downloader=downloader,
save_path=save_path,
label=merged_labels,
return_detail=True
did, error_msg = await self.run_blocking(
"downloader",
self._download_single_sync,
context,
downloader,
save_path,
merged_labels,
)
if did:
success_count += 1

View File

@@ -0,0 +1,115 @@
"""新增过滤规则组工具。"""
import json
from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.impl._filter_rule_utils import (
build_custom_rule_map,
collect_rule_group_usages,
get_builtin_rules,
get_custom_rules,
get_rule_groups,
normalize_rule_group,
save_system_config,
serialize_rule_group,
)
from app.log import logger
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",
)
name: str = Field(..., description="New rule group name.")
rule_string: str = Field(
...,
description=(
"Rule expression using built-in/custom rule IDs. "
"Use '&', '!' inside one level, and use '>' between priority levels. "
"Example: 'SPECSUB & CNVOI & 4K & !BLU > CNSUB & CNVOI & 4K & !BLU'."
),
)
media_type: Optional[str] = Field(
None,
description="Optional media type scope: '电影', '电视剧', 'movie', or 'tv'.",
)
category: Optional[str] = Field(
None,
description="Optional media category. Only valid when media_type is set.",
)
class AddRuleGroupTool(MoviePilotTool):
name: str = "add_rule_group"
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. "
"Before calling this tool, first use query_builtin_filter_rules and query_custom_filter_rules to confirm valid rule IDs, "
"and optionally use query_rule_groups to imitate existing rule_string patterns."
)
args_schema: Type[BaseModel] = AddRuleGroupInput
require_admin: bool = True
def get_tool_message(self, **kwargs) -> Optional[str]:
return f"新增规则组 {kwargs.get('name', '')}"
async def run(
self,
name: str,
rule_string: str,
media_type: Optional[str] = None,
category: Optional[str] = None,
**kwargs,
) -> str:
logger.info(f"执行工具: {self.name}, name={name}")
try:
custom_rules = get_custom_rules()
available_rule_ids = set(get_builtin_rules().keys()) | set(
build_custom_rule_map(custom_rules).keys()
)
rule_groups = get_rule_groups()
new_group, _ = normalize_rule_group(
name=name,
rule_string=rule_string,
media_type=media_type,
category=category,
existing_groups=rule_groups,
available_rule_ids=available_rule_ids,
)
rule_groups.append(new_group)
await save_system_config(
SystemConfigKey.UserFilterRuleGroups,
[group.model_dump(exclude_none=True) for group in rule_groups],
)
usage = await collect_rule_group_usages([new_group.name])
return json.dumps(
{
"success": True,
"message": f"已新增规则组 {new_group.name}",
"rule_group": serialize_rule_group(
new_group, usage.get(new_group.name)
),
"count": len(rule_groups),
},
ensure_ascii=False,
indent=2,
)
except Exception as exc:
logger.error(f"新增规则组失败: {exc}", exc_info=True)
return json.dumps(
{
"success": False,
"message": f"新增规则组失败: {exc}",
},
ensure_ascii=False,
)

View File

@@ -1,13 +1,14 @@
"""添加订阅工具"""
from typing import Optional, Type, List
from typing import List, Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.chain.subscribe import SubscribeChain
from app.db.user_oper import UserOper
from app.log import logger
from app.schemas.types import MediaType
from app.schemas.types import MediaType, MessageChannel
class AddSubscribeInput(BaseModel):
@@ -101,6 +102,37 @@ class AddSubscribeTool(MoviePilotTool):
return message
async def _resolve_subscribe_username(self) -> Optional[str]:
"""优先映射为系统用户名,未绑定时回退当前渠道用户名。"""
resolved_username = self._username
if not self._channel or not self._user_id:
return resolved_username
try:
channel = MessageChannel(self._channel)
except ValueError:
return resolved_username
binding_keys = {
MessageChannel.Telegram: ("telegram_userid",),
MessageChannel.Discord: ("discord_userid",),
MessageChannel.Wechat: ("wechat_userid",),
MessageChannel.WechatClawBot: ("wechatclawbot_userid",),
MessageChannel.Slack: ("slack_userid",),
MessageChannel.VoceChat: ("vocechat_userid",),
MessageChannel.SynologyChat: ("synologychat_userid",),
MessageChannel.QQ: ("qq_userid", "qq_openid"),
}.get(channel)
if not binding_keys:
return resolved_username
mapped_username = await self.run_blocking(
"db",
UserOper().get_name,
**{key: self._user_id for key in binding_keys},
)
return mapped_username or resolved_username
async def run(
self,
title: str,
@@ -137,6 +169,7 @@ class AddSubscribeTool(MoviePilotTool):
if media_type_enum == MediaType.TV
else None
)
subscribe_username = await self._resolve_subscribe_username()
# 构建额外的订阅参数
subscribe_kwargs = {}
@@ -162,7 +195,7 @@ class AddSubscribeTool(MoviePilotTool):
tmdbid=tmdb_id,
doubanid=douban_id,
season=season,
username=self._user_id,
username=subscribe_username,
**subscribe_kwargs,
)
if sid:

View File

@@ -5,7 +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.chain.interaction import (
from app.helper.interaction import (
AgentInteractionOption,
agent_interaction_manager,
)
@@ -64,6 +64,7 @@ class AskUserChoiceInput(BaseModel):
class AskUserChoiceTool(MoviePilotTool):
name: str = "ask_user_choice"
sends_message: 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."

View File

@@ -0,0 +1,97 @@
"""删除自定义过滤规则工具。"""
import json
from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.impl._filter_rule_utils import (
collect_custom_rule_group_refs,
get_custom_rules,
get_rule_groups,
save_system_config,
)
from app.log import logger
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",
)
rule_id: str = Field(..., description="Custom rule ID to delete.")
class DeleteCustomFilterRuleTool(MoviePilotTool):
name: str = "delete_custom_filter_rule"
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."
)
args_schema: Type[BaseModel] = DeleteCustomFilterRuleInput
require_admin: bool = True
def get_tool_message(self, **kwargs) -> Optional[str]:
return f"删除自定义过滤规则 {kwargs.get('rule_id', '')}"
async def run(self, rule_id: str, **kwargs) -> str:
logger.info(f"执行工具: {self.name}, rule_id={rule_id}")
try:
custom_rules = get_custom_rules()
target_rule = next((rule for rule in custom_rules if rule.id == rule_id), None)
if not target_rule:
return json.dumps(
{
"success": False,
"message": f"自定义过滤规则 '{rule_id}' 不存在",
},
ensure_ascii=False,
)
refs = collect_custom_rule_group_refs(get_rule_groups(), [rule_id]).get(
rule_id, []
)
if refs:
return json.dumps(
{
"success": False,
"message": (
f"自定义过滤规则 '{rule_id}' 仍被规则组引用,无法删除。"
),
"referenced_by_rule_groups": refs,
},
ensure_ascii=False,
indent=2,
)
remaining_rules = [
rule for rule in custom_rules if rule.id != rule_id
]
await save_system_config(
SystemConfigKey.CustomFilterRules,
[rule.model_dump(exclude_none=True) for rule in remaining_rules],
)
return json.dumps(
{
"success": True,
"message": f"已删除自定义过滤规则 {rule_id}",
"count": len(remaining_rules),
},
ensure_ascii=False,
indent=2,
)
except Exception as exc:
logger.error(f"删除自定义过滤规则失败: {exc}", exc_info=True)
return json.dumps(
{
"success": False,
"message": f"删除自定义过滤规则失败: {exc}",
},
ensure_ascii=False,
)

View File

@@ -49,6 +49,15 @@ class DeleteDownloadTool(MoviePilotTool):
return message
@staticmethod
def _delete_download_sync(
hash_value: str, downloader: Optional[str] = None, delete_files: bool = False
) -> bool:
"""同步删除下载任务,避免下载器客户端阻塞事件循环。"""
return DownloadChain().remove_torrents(
hashs=[hash_value], downloader=downloader, delete_file=delete_files
)
async def run(
self,
hash: str,
@@ -61,16 +70,18 @@ class DeleteDownloadTool(MoviePilotTool):
)
try:
download_chain = DownloadChain()
# 仅支持通过hash删除任务
if len(hash) != 40 or not all(c in "0123456789abcdefABCDEF" for c in hash):
return "参数错误hash 格式无效,请先使用 query_download_tasks 工具获取正确的 hash。"
# 删除下载任务
# remove_torrents 支持 delete_file 参数,可以控制是否删除文件
result = download_chain.remove_torrents(
hashs=[hash], downloader=downloader, delete_file=delete_files
result = await self.run_blocking(
"downloader",
self._delete_download_sync,
hash,
downloader,
bool(delete_files),
)
if result:

View File

@@ -0,0 +1,81 @@
"""删除过滤规则组工具。"""
import json
from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.impl._filter_rule_utils import (
get_rule_groups,
remove_rule_group_references,
save_system_config,
)
from app.log import logger
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",
)
name: str = Field(..., description="Rule group name to delete.")
class DeleteRuleGroupTool(MoviePilotTool):
name: str = "delete_rule_group"
description: str = (
"Delete a filter rule group from UserFilterRuleGroups. "
"The tool also removes dangling references from global settings and subscriptions."
)
args_schema: Type[BaseModel] = DeleteRuleGroupInput
require_admin: bool = True
def get_tool_message(self, **kwargs) -> Optional[str]:
return f"删除规则组 {kwargs.get('name', '')}"
async def run(self, name: str, **kwargs) -> str:
logger.info(f"执行工具: {self.name}, name={name}")
try:
rule_groups = get_rule_groups()
if not any(group.name == name for group in rule_groups):
return json.dumps(
{
"success": False,
"message": f"规则组 '{name}' 不存在",
},
ensure_ascii=False,
)
remaining_groups = [
group for group in rule_groups if group.name != name
]
await save_system_config(
SystemConfigKey.UserFilterRuleGroups,
[group.model_dump(exclude_none=True) for group in remaining_groups],
)
reference_changes = await remove_rule_group_references(name)
return json.dumps(
{
"success": True,
"message": f"已删除规则组 {name}",
"count": len(remaining_groups),
"reference_updates": reference_changes,
},
ensure_ascii=False,
indent=2,
)
except Exception as exc:
logger.error(f"删除规则组失败: {exc}", exc_info=True)
return json.dumps(
{
"success": False,
"message": f"删除规则组失败: {exc}",
},
ensure_ascii=False,
)

View File

@@ -49,8 +49,11 @@ class DeleteSubscribeTool(MoviePilotTool):
# 在删除之前获取订阅信息(用于事件)
subscribe_info = subscribe.to_dict()
# 删除订阅
subscribe_oper.delete(subscribe_id)
await subscribe_oper.async_delete(subscribe_id)
# 分享订阅统计刷新本身已异步化,这里只需要在删除后触发即可。
SubscribeHelper().sub_done_async(
{"tmdbid": subscribe.tmdbid, "doubanid": subscribe.doubanid}
)
# 发送事件
await eventmanager.async_send_event(
@@ -58,11 +61,6 @@ class DeleteSubscribeTool(MoviePilotTool):
{"subscribe_id": subscribe_id, "subscribe_info": subscribe_info},
)
# 统计订阅
SubscribeHelper().sub_done_async(
{"tmdbid": subscribe.tmdbid, "doubanid": subscribe.doubanid}
)
return f"成功删除订阅:{subscribe.name} ({subscribe.year})"
except Exception as e:
logger.error(f"删除订阅失败: {e}", exc_info=True)

View File

@@ -37,21 +37,17 @@ class DeleteTransferHistoryTool(MoviePilotTool):
try:
transferhis = TransferHistoryOper()
# 查询历史记录是否存在
history = transferhis.get(history_id)
history = await transferhis.async_get(history_id)
if not history:
return f"错误整理历史记录不存在ID={history_id}"
# 保存信息用于返回
title = history.title or "未知"
src = history.src or "未知"
status = "成功" if history.status else "失败"
# 删除记录
transferhis.delete(history_id)
return f"已删除整理历史记录ID={history_id},标题={title},源路径={src},状态={status}"
await transferhis.async_delete(history_id)
return (
f"已删除整理历史记录ID={history_id},标题={title},源路径={src},状态={status}"
)
except Exception as e:
logger.error(f"删除整理历史记录失败: {e}", exc_info=True)
return f"删除整理历史记录时发生错误: {str(e)}"

View File

@@ -5,7 +5,8 @@ import os
import signal
import subprocess
from dataclasses import dataclass, field
from typing import Optional, Type
from tempfile import NamedTemporaryFile
from typing import Optional, TextIO, Type
from pydantic import BaseModel, Field
@@ -15,7 +16,7 @@ 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
@@ -25,40 +26,93 @@ _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:
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:
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()
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()
return "".join(
text for stream_name, text in self.preview_entries if stream_name == "stderr"
).strip()
class ExecuteCommandInput(BaseModel):
@@ -78,7 +132,7 @@ class ExecuteCommandTool(MoviePilotTool):
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."
"timeout, concurrency, and output preview limits."
)
args_schema: Type[BaseModel] = ExecuteCommandInput
require_admin: bool = True
@@ -107,7 +161,7 @@ class ExecuteCommandTool(MoviePilotTool):
@staticmethod
def _subprocess_kwargs() -> dict:
"""为子进程创建独立进程组,便于超时或输出过大时清理整棵子进程。"""
"""为子进程创建独立进程组,便于超时场景清理整棵子进程。"""
kwargs = {
"stdin": subprocess.DEVNULL,
"stdout": asyncio.subprocess.PIPE,
@@ -124,23 +178,14 @@ 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):
@@ -205,27 +250,33 @@ class ExecuteCommandTool(MoviePilotTool):
output: _CommandOutput,
timeout: int,
timed_out: bool,
output_limited: bool,
timeout_note: Optional[str],
) -> str:
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
@@ -252,51 +303,40 @@ class ExecuteCommandTool(MoviePilotTool):
try:
async with _command_semaphore:
# 命令输出可能非常大,必须边读边截断,不能使用 communicate() 一次性收集。
# 命令输出可能非常大,必须边读边落盘,不能使用 communicate() 一次性收集。
process = await asyncio.create_subprocess_shell(
command, **self._subprocess_kwargs()
)
output = _CommandOutput(limit=MAX_OUTPUT_CHARS)
limit_reached = asyncio.Event()
output = _CommandOutput(preview_limit_bytes=MAX_OUTPUT_PREVIEW_BYTES)
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
)
self._read_stream(process.stdout, "stdout", output)
),
asyncio.create_task(
self._read_stream(
process.stderr, "stderr", output, limit_reached
)
self._read_stream(process.stderr, "stderr", output)
),
]
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
try:
await asyncio.wait_for(
asyncio.shield(wait_task), timeout=normalized_timeout
)
except asyncio.TimeoutError:
timed_out = True
await self._cleanup_process(process, wait_task)
limit_task.cancel()
await self._finish_reader_tasks(reader_tasks)
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,
)

View File

@@ -0,0 +1,118 @@
"""安装插件工具"""
import json
from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.impl._plugin_tool_utils import (
get_plugin_snapshot,
install_plugin_runtime,
load_market_plugins,
summarize_plugin,
)
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",
)
plugin_id: str = Field(
...,
description="Exact plugin ID to install. Use query_market_plugins first to find the correct plugin_id.",
)
force: bool = Field(
False,
description="Whether to force reinstall or upgrade the specified plugin.",
)
force_refresh_market: bool = Field(
False,
description="Whether to refresh plugin market caches before reading the market list.",
)
class InstallPluginTool(MoviePilotTool):
name: str = "install_plugin"
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."
)
require_admin: bool = True
args_schema: Type[BaseModel] = InstallPluginInput
def get_tool_message(self, **kwargs) -> Optional[str]:
plugin_id = kwargs.get("plugin_id")
return f"安装插件: {plugin_id or '未知插件'}"
async def run(
self,
plugin_id: str,
force: bool = False,
force_refresh_market: bool = False,
**kwargs,
) -> str:
logger.info(
f"执行工具: {self.name}, 参数: plugin_id={plugin_id}, force={force}"
)
try:
plugins = await load_market_plugins(force_refresh=force_refresh_market)
if not plugins:
return json.dumps(
{"success": False, "message": "当前插件市场没有可用插件"},
ensure_ascii=False,
)
candidate = next((plugin for plugin in plugins if plugin.id == plugin_id), None)
if not candidate:
return json.dumps(
{
"success": False,
"message": f"未在插件市场中找到插件: {plugin_id}。请先调用 query_market_plugins 确认 plugin_id。",
},
ensure_ascii=False,
)
success, message, refreshed_only = await install_plugin_runtime(
candidate.id,
getattr(candidate, "repo_url", None),
force=force,
)
if not success:
return json.dumps(
{
"success": False,
"plugin": summarize_plugin(candidate),
"message": message,
},
ensure_ascii=False,
indent=2,
)
plugin_snapshot = get_plugin_snapshot(candidate.id)
if refreshed_only and getattr(candidate, "has_update", False) and not force:
message = "插件已安装,当前仅刷新加载;如需升级到市场新版本,请设置 force=true"
return json.dumps(
{
"success": True,
"message": message,
"force": force,
"refreshed_only": refreshed_only,
"plugin": summarize_plugin(candidate),
"runtime": plugin_snapshot,
},
ensure_ascii=False,
indent=2,
)
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

@@ -38,93 +38,81 @@ class ListDirectoryTool(MoviePilotTool):
return message
@staticmethod
def _list_directory_sync(
path: str, storage: Optional[str] = "local", sort_by: Optional[str] = "name"
) -> str:
"""
目录遍历可能触发本地磁盘或远程存储请求,统一放到线程池中执行。
"""
if not path:
return "错误:路径不能为空"
if storage == "local":
if not path.startswith("/") and not (len(path) > 1 and path[1] == ":"):
path = str(Path(path).resolve())
elif not path.startswith("/"):
path = "/" + path
fileitem = FileItem(storage=storage or "local", path=path, type="dir")
file_list = StorageChain().list_files(fileitem, recursion=False)
if file_list is None:
return f"无法访问目录:{path},请检查路径是否正确或存储是否可用"
if not file_list:
return f"目录 {path} 为空"
if sort_by == "time":
file_list.sort(key=lambda x: x.modify_time or 0, reverse=True)
else:
file_list.sort(
key=lambda x: (
0 if x.type == "dir" else 1,
StringUtils.natural_sort_key(x.name or ""),
)
)
total_count = len(file_list)
limited_list = file_list[:20]
simplified_items = []
for item in limited_list:
size_str = StringUtils.str_filesize(item.size) if item.size else None
modify_time_str = None
if item.modify_time:
try:
modify_time_str = datetime.fromtimestamp(item.modify_time).strftime(
"%Y-%m-%d %H:%M:%S"
)
except (ValueError, OSError):
modify_time_str = str(item.modify_time)
simplified = {
"name": item.name,
"type": item.type,
"path": item.path,
"size": size_str,
"modify_time": modify_time_str,
}
if item.type == "file" and item.extension:
simplified["extension"] = item.extension
simplified_items.append(simplified)
result_json = json.dumps(simplified_items, ensure_ascii=False, indent=2)
if total_count > 20:
return (
f"注意:目录中共有 {total_count} 个项目,为节省上下文空间,仅显示前 20 个项目。\n\n"
f"{result_json}"
)
return result_json
async def run(self, path: str, storage: Optional[str] = "local",
sort_by: Optional[str] = "name", **kwargs) -> str:
logger.info(f"执行工具: {self.name}, 参数: path={path}, storage={storage}, sort_by={sort_by}")
try:
# 规范化路径
if not path:
return "错误:路径不能为空"
# 确保路径格式正确
if storage == "local":
# 本地路径处理
if not path.startswith("/") and not (len(path) > 1 and path[1] == ":"):
# 相对路径,尝试转换为绝对路径
path = str(Path(path).resolve())
else:
# 远程存储路径,确保以/开头
if not path.startswith("/"):
path = "/" + path
# 创建FileItem
fileitem = FileItem(
storage=storage or "local",
path=path,
type="dir"
return await self.run_blocking(
"storage", self._list_directory_sync, path, storage, sort_by
)
# 查询目录内容
storage_chain = StorageChain()
file_list = storage_chain.list_files(fileitem, recursion=False)
if file_list is None:
return f"无法访问目录:{path},请检查路径是否正确或存储是否可用"
if not file_list:
return f"目录 {path} 为空"
# 排序
if sort_by == "time":
file_list.sort(key=lambda x: x.modify_time or 0, reverse=True)
else:
# 默认按名称排序(目录优先,然后按名称)
file_list.sort(key=lambda x: (
0 if x.type == "dir" else 1,
StringUtils.natural_sort_key(x.name or "")
))
# 限制返回数量
total_count = len(file_list)
limited_list = file_list[:20]
# 转换为字典格式
simplified_items = []
for item in limited_list:
# 格式化文件大小
size_str = None
if item.size:
size_str = StringUtils.str_filesize(item.size)
# 格式化修改时间
modify_time_str = None
if item.modify_time:
try:
modify_time_str = datetime.fromtimestamp(item.modify_time).strftime("%Y-%m-%d %H:%M:%S")
except (ValueError, OSError):
modify_time_str = str(item.modify_time)
simplified = {
"name": item.name,
"type": item.type,
"path": item.path,
"size": size_str,
"modify_time": modify_time_str
}
# 如果是文件,添加扩展名
if item.type == "file" and item.extension:
simplified["extension"] = item.extension
simplified_items.append(simplified)
result_json = json.dumps(simplified_items, ensure_ascii=False, indent=2)
# 如果结果被裁剪,添加提示信息
if total_count > 100:
return f"注意:目录中共有 {total_count} 个项目,为节省上下文空间,仅显示前 100 个项目。\n\n{result_json}"
else:
return result_json
except Exception as e:
logger.error(f"查询目录内容失败: {e}", exc_info=True)
return f"查询目录内容时发生错误: {str(e)}"

View File

@@ -66,6 +66,38 @@ class ModifyDownloadTool(MoviePilotTool):
parts.append(f"下载器: {downloader}")
return " | ".join(parts)
@staticmethod
def _modify_download_sync(
hash_value: str,
action: Optional[str] = None,
tags: Optional[List[str]] = None,
downloader: Optional[str] = None,
) -> List[str]:
"""同步修改下载任务状态和标签,避免下载器 SDK 阻塞事件循环。"""
download_chain = DownloadChain()
results = []
if tags:
tag_result = download_chain.set_torrents_tag(
hashs=[hash_value], tags=tags, downloader=downloader
)
if tag_result:
results.append(f"成功设置标签:{', '.join(tags)}")
else:
results.append("设置标签失败,请检查任务是否存在或下载器是否可用")
if action:
action_result = download_chain.set_downloading(
hash_str=hash_value, oper=action, name=downloader
)
action_desc = "开始" if action == "start" else "暂停"
if action_result:
results.append(f"成功{action_desc}下载任务")
else:
results.append(f"{action_desc}下载任务失败,请检查任务是否存在或下载器是否可用")
return results
async def run(
self,
hash: str,
@@ -91,31 +123,14 @@ class ModifyDownloadTool(MoviePilotTool):
if action and action not in ("start", "stop"):
return f"参数错误action 只支持 'start'(开始下载)或 'stop'(暂停下载),收到: '{action}'"
download_chain = DownloadChain()
results = []
# 设置标签
if tags:
tag_result = download_chain.set_torrents_tag(
hashs=[hash], tags=tags, downloader=downloader
)
if tag_result:
results.append(f"成功设置标签:{', '.join(tags)}")
else:
results.append(f"设置标签失败,请检查任务是否存在或下载器是否可用")
# 执行开始/暂停操作
if action:
action_result = download_chain.set_downloading(
hash_str=hash, oper=action, name=downloader
)
action_desc = "开始" if action == "start" else "暂停"
if action_result:
results.append(f"成功{action_desc}下载任务")
else:
results.append(
f"{action_desc}下载任务失败,请检查任务是否存在或下载器是否可用"
)
results = await self.run_blocking(
"downloader",
self._modify_download_sync,
hash,
action,
tags,
downloader,
)
return f"下载任务 {hash}" + "".join(results)

View File

@@ -0,0 +1,85 @@
"""查询内置过滤规则工具。"""
import json
from typing import Optional, Type, List
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.impl._filter_rule_utils import (
get_builtin_rules,
serialize_builtin_rule,
RULE_STRING_SYNTAX,
)
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",
)
rule_ids: Optional[List[str]] = Field(
None,
description="Optional list of built-in rule IDs to query. If omitted, return all built-in rules.",
)
class QueryBuiltinFilterRulesTool(MoviePilotTool):
name: str = "query_builtin_filter_rules"
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. "
"Use this tool before add_rule_group or update_rule_group to learn valid built-in rule IDs."
)
args_schema: Type[BaseModel] = QueryBuiltinFilterRulesInput
def get_tool_message(self, **kwargs) -> Optional[str]:
rule_ids = kwargs.get("rule_ids") or []
if rule_ids:
return f"查询内置过滤规则: {', '.join(rule_ids)}"
return "查询所有内置过滤规则"
async def run(
self,
rule_ids: Optional[List[str]] = None,
**kwargs,
) -> str:
logger.info(f"执行工具: {self.name}")
try:
builtin_rules = get_builtin_rules()
if rule_ids:
target_ids = set(rule_ids)
builtin_rules = {
rule_id: payload
for rule_id, payload in builtin_rules.items()
if rule_id in target_ids
}
serialized = [
serialize_builtin_rule(rule_id, payload)
for rule_id, payload in builtin_rules.items()
]
return json.dumps(
{
"success": True,
"count": len(serialized),
"rule_string_syntax": RULE_STRING_SYNTAX,
"rules": serialized,
},
ensure_ascii=False,
indent=2,
)
except Exception as exc:
logger.error(f"查询内置过滤规则失败: {exc}", exc_info=True)
return json.dumps(
{
"success": False,
"message": f"查询内置过滤规则失败: {exc}",
"rules": [],
},
ensure_ascii=False,
)

View File

@@ -0,0 +1,95 @@
"""查询自定义过滤规则工具。"""
import json
from typing import Optional, Type, List
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.impl._filter_rule_utils import (
collect_custom_rule_group_refs,
get_custom_rules,
get_rule_groups,
serialize_custom_rule,
)
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",
)
rule_ids: Optional[List[str]] = Field(
None,
description="Optional list of custom rule IDs to query. If omitted, return all custom rules.",
)
include_group_refs: bool = Field(
True,
description="Whether to include which rule groups reference each custom rule.",
)
class QueryCustomFilterRulesTool(MoviePilotTool):
name: str = "query_custom_filter_rules"
description: str = (
"Query custom filter rules stored in CustomFilterRules. "
"Custom rules can be referenced from rule_string expressions in filter rule groups. "
"Use this tool before add_rule_group or update_rule_group to learn valid custom rule IDs."
)
args_schema: Type[BaseModel] = QueryCustomFilterRulesInput
def get_tool_message(self, **kwargs) -> Optional[str]:
rule_ids = kwargs.get("rule_ids") or []
if rule_ids:
return f"查询自定义过滤规则: {', '.join(rule_ids)}"
return "查询所有自定义过滤规则"
async def run(
self,
rule_ids: Optional[List[str]] = None,
include_group_refs: bool = True,
**kwargs,
) -> str:
logger.info(f"执行工具: {self.name}")
try:
custom_rules = get_custom_rules()
if rule_ids:
target_ids = set(rule_ids)
custom_rules = [
rule for rule in custom_rules if rule.id in target_ids
]
refs = {}
if include_group_refs:
refs = collect_custom_rule_group_refs(
get_rule_groups(),
[rule.id for rule in custom_rules if rule.id],
)
serialized = [
serialize_custom_rule(rule, refs.get(rule.id))
for rule in custom_rules
]
return json.dumps(
{
"success": True,
"count": len(serialized),
"rules": serialized,
},
ensure_ascii=False,
indent=2,
)
except Exception as exc:
logger.error(f"查询自定义过滤规则失败: {exc}", exc_info=True)
return json.dumps(
{
"success": False,
"message": f"查询自定义过滤规则失败: {exc}",
"rules": [],
},
ensure_ascii=False,
)

View File

@@ -33,11 +33,15 @@ class QueryCustomIdentifiersTool(MoviePilotTool):
"""生成友好的提示消息"""
return "查询自定义识别词"
@staticmethod
def _load_custom_identifiers():
"""从内存配置缓存中读取自定义识别词。"""
return SystemConfigOper().get(SystemConfigKey.CustomIdentifiers)
async def run(self, **kwargs) -> str:
logger.info(f"执行工具: {self.name}")
try:
system_config_oper = SystemConfigOper()
identifiers = system_config_oper.get(SystemConfigKey.CustomIdentifiers)
identifiers = self._load_custom_identifiers()
if identifiers:
return json.dumps(
{

View File

@@ -47,88 +47,93 @@ class QueryDirectorySettingsTool(MoviePilotTool):
return " | ".join(parts) if len(parts) > 1 else parts[0]
@staticmethod
def _query_directory_settings(
directory_type: Optional[str] = "all",
storage_type: Optional[str] = "all",
name: Optional[str] = None,
) -> str:
"""
目录配置完全来自内存配置缓存,这里只做本地过滤和序列化。
"""
directory_helper = DirectoryHelper()
if directory_type == "download":
dirs = directory_helper.get_download_dirs()
elif directory_type == "library":
dirs = directory_helper.get_library_dirs()
else:
dirs = directory_helper.get_dirs()
filtered_dirs = []
for d in dirs:
if storage_type == "local":
if directory_type == "download" and d.storage != "local":
continue
if directory_type == "library" and d.library_storage != "local":
continue
if directory_type == "all":
if d.download_path and d.storage != "local":
continue
if d.library_path and d.library_storage != "local":
continue
elif storage_type == "remote":
if directory_type == "download" and d.storage == "local":
continue
if directory_type == "library" and d.library_storage == "local":
continue
if directory_type == "all":
if d.download_path and d.storage == "local":
continue
if d.library_path and d.library_storage == "local":
continue
if name and d.name and name.lower() not in d.name.lower():
continue
filtered_dirs.append(d)
if not filtered_dirs:
return "未找到相关目录配置"
simplified_dirs = []
for d in filtered_dirs:
simplified_dirs.append(
{
"name": d.name,
"priority": d.priority,
"storage": d.storage,
"download_path": d.download_path,
"library_path": d.library_path,
"library_storage": d.library_storage,
"media_type": d.media_type,
"media_category": d.media_category,
"monitor_type": d.monitor_type,
"monitor_mode": d.monitor_mode,
"transfer_type": d.transfer_type,
"overwrite_mode": d.overwrite_mode,
"renaming": d.renaming,
"scraping": d.scraping,
"notify": d.notify,
"download_type_folder": d.download_type_folder,
"download_category_folder": d.download_category_folder,
"library_type_folder": d.library_type_folder,
"library_category_folder": d.library_category_folder,
}
)
return json.dumps(simplified_dirs, ensure_ascii=False, indent=2)
async def run(self, directory_type: Optional[str] = "all",
storage_type: Optional[str] = "all",
name: Optional[str] = None, **kwargs) -> str:
logger.info(f"执行工具: {self.name}, 参数: directory_type={directory_type}, storage_type={storage_type}, name={name}")
try:
directory_helper = DirectoryHelper()
# 根据目录类型获取目录列表
if directory_type == "download":
dirs = directory_helper.get_download_dirs()
elif directory_type == "library":
dirs = directory_helper.get_library_dirs()
else:
dirs = directory_helper.get_dirs()
# 按存储类型过滤
filtered_dirs = []
for d in dirs:
# 按存储类型过滤
if storage_type == "local":
# 对于下载目录,检查 storage对于媒体库目录检查 library_storage
if directory_type == "download" and d.storage != "local":
continue
elif directory_type == "library" and d.library_storage != "local":
continue
elif directory_type == "all":
# 检查是否有本地存储配置
if d.download_path and d.storage != "local":
continue
if d.library_path and d.library_storage != "local":
continue
elif storage_type == "remote":
# 对于下载目录,检查 storage对于媒体库目录检查 library_storage
if directory_type == "download" and d.storage == "local":
continue
elif directory_type == "library" and d.library_storage == "local":
continue
elif directory_type == "all":
# 检查是否有远程存储配置
if d.download_path and d.storage == "local":
continue
if d.library_path and d.library_storage == "local":
continue
# 按名称过滤(部分匹配)
if name and d.name and name.lower() not in d.name.lower():
continue
filtered_dirs.append(d)
if filtered_dirs:
# 转换为字典格式,只保留关键信息
simplified_dirs = []
for d in filtered_dirs:
simplified = {
"name": d.name,
"priority": d.priority,
"storage": d.storage,
"download_path": d.download_path,
"library_path": d.library_path,
"library_storage": d.library_storage,
"media_type": d.media_type,
"media_category": d.media_category,
"monitor_type": d.monitor_type,
"monitor_mode": d.monitor_mode,
"transfer_type": d.transfer_type,
"overwrite_mode": d.overwrite_mode,
"renaming": d.renaming,
"scraping": d.scraping,
"notify": d.notify,
"download_type_folder": d.download_type_folder,
"download_category_folder": d.download_category_folder,
"library_type_folder": d.library_type_folder,
"library_category_folder": d.library_category_folder
}
simplified_dirs.append(simplified)
result_json = json.dumps(simplified_dirs, ensure_ascii=False, indent=2)
return result_json
return "未找到相关目录配置"
return self._query_directory_settings(
directory_type=directory_type,
storage_type=storage_type,
name=name,
)
except Exception as e:
logger.error(f"查询系统目录设置失败: {e}", exc_info=True)
return f"查询系统目录设置时发生错误: {str(e)}"

View File

@@ -1,7 +1,7 @@
"""查询下载工具"""
import json
from typing import Optional, Type, List, Union
from typing import Any, Dict, List, Optional, Type, Union
from pydantic import BaseModel, Field
@@ -64,6 +64,126 @@ class QueryDownloadTasksTool(MoviePilotTool):
except (TypeError, ValueError):
return None
@staticmethod
def _apply_download_history(
torrent: Union[TransferTorrent, DownloadingTorrent], history: Any
) -> None:
"""将下载历史中的补充信息回填到下载任务结果中。"""
if not history:
return
if hasattr(torrent, "media"):
torrent.media = {
"tmdbid": history.tmdbid,
"type": history.type,
"title": history.title,
"season": history.seasons,
"episode": history.episodes,
"image": history.image,
}
if hasattr(torrent, "username"):
torrent.username = history.username
torrent.userid = history.userid
@classmethod
def _load_history_map(
cls, torrents: List[Union[TransferTorrent, DownloadingTorrent]]
) -> Dict[str, Any]:
"""批量加载下载历史,避免逐条查询形成 N+1。"""
hashes = [torrent.hash for torrent in torrents if getattr(torrent, "hash", None)]
if not hashes:
return {}
return DownloadHistoryOper().get_by_hashes(hashes)
@classmethod
def _query_downloads_sync(
cls,
downloader: Optional[str] = None,
status: Optional[str] = "all",
hash_value: Optional[str] = None,
title: Optional[str] = None,
tag: Optional[str] = None,
) -> Dict[str, Any]:
"""
同步查询下载器和下载历史,整个链路放在线程池中执行。
"""
download_chain = DownloadChain()
if hash_value:
torrents = (
download_chain.list_torrents(downloader=downloader, hashs=[hash_value])
or []
)
if not torrents:
return {
"message": f"未找到hash为 {hash_value} 的下载任务(该任务可能已完成、已删除或不存在)"
}
history_map = cls._load_history_map(torrents)
for torrent in torrents:
cls._apply_download_history(torrent, history_map.get(torrent.hash))
filtered_downloads = list(torrents)
elif title:
all_torrents = cls._get_all_torrents(download_chain, downloader)
history_map = cls._load_history_map(all_torrents)
filtered_downloads = []
title_lower = title.lower()
for torrent in all_torrents:
history = history_map.get(torrent.hash)
matched = title_lower in (torrent.title or "").lower() or title_lower in (
getattr(torrent, "name", None) or ""
).lower()
if not matched and history and history.title:
matched = title_lower in history.title.lower()
if not matched:
continue
cls._apply_download_history(torrent, history)
filtered_downloads.append(torrent)
if not filtered_downloads:
return {"message": f"未找到标题包含 '{title}' 的下载任务"}
else:
if status == "downloading":
downloads = download_chain.downloading(name=downloader) or []
filtered_downloads = [
dl
for dl in downloads
if not downloader or dl.downloader == downloader
]
else:
all_torrents = cls._get_all_torrents(download_chain, downloader)
filtered_downloads = []
for torrent in all_torrents:
if downloader and torrent.downloader != downloader:
continue
if status == "completed" and torrent.state not in [
"seeding",
"completed",
]:
continue
if status == "paused" and torrent.state != "paused":
continue
filtered_downloads.append(torrent)
history_map = cls._load_history_map(filtered_downloads)
for torrent in filtered_downloads:
cls._apply_download_history(torrent, history_map.get(torrent.hash))
if tag and filtered_downloads:
tag_lower = tag.lower()
filtered_downloads = [
d for d in filtered_downloads if d.tags and tag_lower in d.tags.lower()
]
if not filtered_downloads:
return {"message": f"未找到标签包含 '{tag}' 的下载任务"}
if not filtered_downloads:
return {"message": "未找到相关下载任务"}
return {"downloads": filtered_downloads}
def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据查询参数生成友好的提示消息"""
downloader = kwargs.get("downloader")
@@ -98,124 +218,19 @@ class QueryDownloadTasksTool(MoviePilotTool):
tag: Optional[str] = None, **kwargs) -> str:
logger.info(f"执行工具: {self.name}, 参数: downloader={downloader}, status={status}, hash={hash}, title={title}, tag={tag}")
try:
download_chain = DownloadChain()
# 如果提供了hash直接查询该hash的任务不限制状态
if hash:
torrents = download_chain.list_torrents(downloader=downloader, hashs=[hash]) or []
if not torrents:
return f"未找到hash为 {hash} 的下载任务(该任务可能已完成、已删除或不存在)"
# 转换为DownloadingTorrent格式
downloads = []
for torrent in torrents:
# 获取下载历史信息
history = DownloadHistoryOper().get_by_hash(torrent.hash)
if history:
if hasattr(torrent, "media"):
torrent.media = {
"tmdbid": history.tmdbid,
"type": history.type,
"title": history.title,
"season": history.seasons,
"episode": history.episodes,
"image": history.image,
}
if hasattr(torrent, "username"):
torrent.username = history.username
torrent.userid = history.userid
downloads.append(torrent)
filtered_downloads = downloads
elif title:
# 如果提供了title查询所有任务并搜索匹配的标题
# 查询所有状态的任务
all_torrents = self._get_all_torrents(download_chain, downloader)
filtered_downloads = []
title_lower = title.lower()
for torrent in all_torrents:
# 获取下载历史信息
history = DownloadHistoryOper().get_by_hash(torrent.hash)
# 检查标题或名称是否匹配(包括下载历史中的标题)
matched = False
# 检查torrent的title和name字段
if (title_lower in (torrent.title or "").lower()) or \
(title_lower in (getattr(torrent, "name", None) or "").lower()):
matched = True
# 检查下载历史中的标题
if history and history.title:
if title_lower in history.title.lower():
matched = True
if matched:
if history:
if hasattr(torrent, "media"):
torrent.media = {
"tmdbid": history.tmdbid,
"type": history.type,
"title": history.title,
"season": history.seasons,
"episode": history.episodes,
"image": history.image,
}
if hasattr(torrent, "username"):
torrent.username = history.username
torrent.userid = history.userid
filtered_downloads.append(torrent)
if not filtered_downloads:
return f"未找到标题包含 '{title}' 的下载任务"
else:
# 根据status决定查询方式
if status == "downloading":
# 如果status为下载中使用downloading方法
downloads = download_chain.downloading(name=downloader) or []
filtered_downloads = []
for dl in downloads:
if downloader and dl.downloader != downloader:
continue
filtered_downloads.append(dl)
else:
# 其他状态completed、paused、all使用list_torrents查询所有任务
# 查询所有状态的任务
all_torrents = self._get_all_torrents(download_chain, downloader)
filtered_downloads = []
for torrent in all_torrents:
if downloader and torrent.downloader != downloader:
continue
# 根据status过滤
if status == "completed":
# 已完成的任务state为seeding或completed
if torrent.state not in ["seeding", "completed"]:
continue
elif status == "paused":
# 已暂停的任务
if torrent.state != "paused":
continue
# status == "all" 时不过滤
# 获取下载历史信息
history = DownloadHistoryOper().get_by_hash(torrent.hash)
if history:
if hasattr(torrent, "media"):
torrent.media = {
"tmdbid": history.tmdbid,
"type": history.type,
"title": history.title,
"season": history.seasons,
"episode": history.episodes,
"image": history.image,
}
if hasattr(torrent, "username"):
torrent.username = history.username
torrent.userid = history.userid
filtered_downloads.append(torrent)
# 按tag过滤
if tag and filtered_downloads:
tag_lower = tag.lower()
filtered_downloads = [
d for d in filtered_downloads
if d.tags and tag_lower in d.tags.lower()
]
if not filtered_downloads:
return f"未找到标签包含 '{tag}' 的下载任务"
payload = await self.run_blocking(
"downloader",
self._query_downloads_sync,
downloader,
status,
hash,
title,
tag,
)
if payload.get("message"):
return payload["message"]
filtered_downloads = payload.get("downloads") or []
if filtered_downloads:
# 限制最多20条结果
total_count = len(filtered_downloads)

View File

@@ -25,11 +25,15 @@ class QueryDownloadersTool(MoviePilotTool):
"""生成友好的提示消息"""
return "查询下载器配置"
@staticmethod
def _load_downloaders_config():
"""从内存配置缓存中读取下载器配置。"""
return SystemConfigOper().get(SystemConfigKey.Downloaders)
async def run(self, **kwargs) -> str:
logger.info(f"执行工具: {self.name}")
try:
system_config_oper = SystemConfigOper()
downloaders_config = system_config_oper.get(SystemConfigKey.Downloaders)
downloaders_config = self._load_downloaders_config()
if downloaders_config:
return json.dumps(downloaders_config, ensure_ascii=False, indent=2)
return "未配置下载器。"

View File

@@ -6,7 +6,14 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.core.plugin import PluginManager
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,
summarize_plugin,
)
from app.log import logger
@@ -17,49 +24,89 @@ class QueryInstalledPluginsInput(BaseModel):
...,
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 50, capped at 200.",
)
class QueryInstalledPluginsTool(MoviePilotTool):
name: str = "query_installed_plugins"
description: str = (
"Query all installed plugins in MoviePilot. Returns a list of installed plugins with their ID, name, "
"description, version, author, running state, and other information. "
"Use this tool to discover what plugins are available before querying plugin capabilities or running plugin commands."
"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."
)
require_admin: bool = True
args_schema: Type[BaseModel] = QueryInstalledPluginsInput
def get_tool_message(self, **kwargs) -> Optional[str]:
"""生成友好的提示消息"""
query = kwargs.get("query")
if query:
return f"查询已安装插件: {query}"
return "查询已安装插件"
async def run(self, **kwargs) -> str:
logger.info(f"执行工具: {self.name}")
@staticmethod
def _clamp_results(max_results: Optional[int]) -> int:
if max_results is None:
return DEFAULT_PLUGIN_CANDIDATE_LIMIT
try:
plugin_manager = PluginManager()
local_plugins = plugin_manager.get_local_plugins()
# 仅返回已安装的插件
installed_plugins = [plugin for plugin in local_plugins if plugin.installed]
return max(1, min(int(max_results), MAX_PLUGIN_CANDIDATE_LIMIT))
except (TypeError, ValueError):
return DEFAULT_PLUGIN_CANDIDATE_LIMIT
async def run(
self,
query: Optional[str] = None,
max_results: Optional[int] = DEFAULT_PLUGIN_CANDIDATE_LIMIT,
**kwargs,
) -> str:
logger.info(f"执行工具: {self.name}, 参数: query={query}")
try:
installed_plugins = list_installed_plugins()
if not installed_plugins:
return "当前没有已安装的插件"
plugins_list = []
for plugin in installed_plugins:
plugins_list.append(
{
"id": plugin.id,
"plugin_name": plugin.plugin_name,
"plugin_desc": plugin.plugin_desc,
"plugin_version": plugin.plugin_version,
"plugin_author": plugin.plugin_author,
"state": plugin.state,
"has_page": plugin.has_page,
}
return json.dumps(
{"success": False, "message": "当前没有已安装的插件"},
ensure_ascii=False,
)
result_json = json.dumps(plugins_list, ensure_ascii=False, indent=2)
return result_json
limit = self._clamp_results(max_results)
if query:
matches = search_plugin_candidates(query, installed_plugins)
return json.dumps(
{
"success": True,
"query": query,
"total_installed": len(installed_plugins),
"match_count": len(matches),
"truncated": len(matches) > limit,
"plugins": summarize_candidates(matches, limit=limit),
},
ensure_ascii=False,
indent=2,
)
plugin_summaries = [
summarize_plugin(plugin) for plugin in installed_plugins[:limit]
]
return json.dumps(
{
"success": True,
"total_installed": len(installed_plugins),
"returned_count": len(plugin_summaries),
"truncated": len(installed_plugins) > limit,
"plugins": plugin_summaries,
},
ensure_ascii=False,
indent=2,
)
except Exception as e:
logger.error(f"查询已安装插件失败: {e}", exc_info=True)
return f"查询已安装插件时发生错误: {str(e)}"
return json.dumps(
{"success": False, "message": f"查询已安装插件时发生错误: {str(e)}"},
ensure_ascii=False,
)

View File

@@ -1,5 +1,6 @@
"""查询媒体库工具"""
import asyncio
import json
from collections import OrderedDict
from typing import Optional, Type, Any
@@ -102,6 +103,16 @@ class QueryLibraryExistsTool(MoviePilotTool):
message += f" [{media_type}]"
return message
@staticmethod
def _get_media_server_names() -> list[str]:
"""同步读取已加载媒体服务器名称。"""
return sorted(MediaServerHelper().get_services().keys())
@staticmethod
def _query_media_exists(mediainfo, server: Optional[str] = None):
"""同步查询单个媒体服务器的存在性信息。"""
return MediaServerChain().media_exists(mediainfo=mediainfo, server=server)
async def run(self, tmdb_id: Optional[int] = None, douban_id: Optional[str] = None,
media_type: Optional[str] = None, **kwargs) -> str:
logger.info(f"执行工具: {self.name}, 参数: tmdb_id={tmdb_id}, douban_id={douban_id}, media_type={media_type}")
@@ -116,7 +127,7 @@ class QueryLibraryExistsTool(MoviePilotTool):
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'"
media_chain = MediaServerChain()
mediainfo = media_chain.recognize_media(
mediainfo = await media_chain.async_recognize_media(
tmdbid=tmdb_id,
doubanid=douban_id,
mtype=media_type_enum,
@@ -127,12 +138,22 @@ class QueryLibraryExistsTool(MoviePilotTool):
# 2. 遍历所有媒体服务器,分别查询存在性信息
server_results = OrderedDict()
media_server_helper = MediaServerHelper()
total_seasons = _filter_regular_seasons(mediainfo.seasons)
global_existsinfo = media_chain.media_exists(mediainfo=mediainfo)
service_names = self._get_media_server_names()
for service_name in sorted(media_server_helper.get_services().keys()):
existsinfo = media_chain.media_exists(mediainfo=mediainfo, server=service_name)
server_checks = await asyncio.gather(
*[
self.run_blocking(
"mediaserver",
self._query_media_exists,
mediainfo,
service_name,
)
for service_name in service_names
]
)
for service_name, existsinfo in zip(service_names, server_checks):
if not existsinfo:
continue
@@ -147,21 +168,23 @@ class QueryLibraryExistsTool(MoviePilotTool):
"exists": True
}
if global_existsinfo:
fallback_server_name = global_existsinfo.server or "local"
if fallback_server_name not in server_results:
if global_existsinfo.type == MediaType.TV:
server_results[fallback_server_name] = _build_tv_server_result(
existing_seasons=_filter_regular_seasons(global_existsinfo.seasons),
total_seasons=total_seasons
)
else:
server_results[fallback_server_name] = {
"exists": True
}
if not server_results:
return "媒体库中未找到相关媒体"
global_existsinfo = await self.run_blocking(
"mediaserver", self._query_media_exists, mediainfo, None
)
if not global_existsinfo:
return "媒体库中未找到相关媒体"
fallback_server_name = global_existsinfo.server or "local"
if global_existsinfo.type == MediaType.TV:
server_results[fallback_server_name] = _build_tv_server_result(
existing_seasons=_filter_regular_seasons(global_existsinfo.seasons),
total_seasons=total_seasons
)
else:
server_results[fallback_server_name] = {
"exists": True
}
# 3. 组装统一的存在性结果,不查询媒体服务器详情
result_dict = {

View File

@@ -1,5 +1,6 @@
"""查询媒体服务器最近入库影片工具"""
import asyncio
import json
from typing import Optional, Type
@@ -50,6 +51,32 @@ class QueryLibraryLatestTool(MoviePilotTool):
return " | ".join(parts)
@staticmethod
def _get_enabled_servers() -> list[str]:
"""同步读取启用的媒体服务器列表。"""
mediaservers = ServiceConfigHelper.get_mediaserver_configs()
return [ms.name for ms in mediaservers if ms.enabled]
@staticmethod
def _load_latest_items(
server_name: str, count: int, username: Optional[str] = None
) -> list[dict]:
"""
媒体服务器 SDK 和 requests 调用都是同步的,这里在线程池中转换为可序列化结果。
"""
latest_items = MediaServerChain().latest(
server=server_name, count=count, username=username
)
if not latest_items:
return []
return [
{
**item.model_dump(exclude_none=True),
"server": server_name,
}
for item in latest_items
]
async def run(
self, server: Optional[str] = None, page: Optional[int] = 1, **kwargs
) -> str:
@@ -58,37 +85,34 @@ class QueryLibraryLatestTool(MoviePilotTool):
fetch_count = page * PAGE_SIZE
logger.info(f"执行工具: {self.name}, 参数: server={server}, page={page}")
try:
media_chain = MediaServerChain()
results = []
# 如果没有指定服务器,获取所有启用的媒体服务器
if not server:
mediaservers = ServiceConfigHelper.get_mediaserver_configs()
enabled_servers = [ms.name for ms in mediaservers if ms.enabled]
enabled_servers = self._get_enabled_servers()
if not enabled_servers:
return "未找到启用的媒体服务器"
# 遍历所有启用的服务器
for server_name in enabled_servers:
latest_items = media_chain.latest(
server=server_name, count=fetch_count, username=self._username
)
if latest_items:
for item in latest_items:
item_dict = item.model_dump(exclude_none=True)
item_dict["server"] = server_name
results.append(item_dict)
else:
# 查询指定服务器
latest_items = media_chain.latest(
server=server, count=fetch_count, username=self._username
server_results = await asyncio.gather(
*[
self.run_blocking(
"mediaserver",
self._load_latest_items,
server_name,
fetch_count,
self._username,
)
for server_name in enabled_servers
]
)
results = [
item for items in server_results for item in items if items
]
else:
results = await self.run_blocking(
"mediaserver",
self._load_latest_items,
server,
fetch_count,
self._username,
)
if latest_items:
for item in latest_items:
item_dict = item.model_dump(exclude_none=True)
item_dict["server"] = server
results.append(item_dict)
if not results:
server_info = f"服务器 {server}" if server else "所有服务器"

View File

@@ -0,0 +1,117 @@
"""查询插件市场工具"""
import json
from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
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,
summarize_plugin,
)
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",
)
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 50, capped at 200.",
)
force_refresh: Optional[bool] = Field(
False,
description="Whether to refresh plugin market caches before querying.",
)
class QueryMarketPluginsTool(MoviePilotTool):
name: str = "query_market_plugins"
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."
)
require_admin: bool = True
args_schema: Type[BaseModel] = QueryMarketPluginsInput
def get_tool_message(self, **kwargs) -> Optional[str]:
query = kwargs.get("query")
if query:
return f"查询插件市场: {query}"
return "查询插件市场全部插件"
@staticmethod
def _clamp_results(max_results: Optional[int]) -> int:
if max_results is None:
return DEFAULT_PLUGIN_CANDIDATE_LIMIT
try:
return max(1, min(int(max_results), MAX_PLUGIN_CANDIDATE_LIMIT))
except (TypeError, ValueError):
return DEFAULT_PLUGIN_CANDIDATE_LIMIT
async def run(
self,
query: Optional[str] = None,
max_results: Optional[int] = DEFAULT_PLUGIN_CANDIDATE_LIMIT,
force_refresh: bool = False,
**kwargs,
) -> str:
logger.info(
f"执行工具: {self.name}, 参数: query={query}, force_refresh={force_refresh}"
)
try:
plugins = await load_market_plugins(force_refresh=force_refresh)
if not plugins:
return json.dumps(
{"success": False, "message": "当前插件市场没有可用插件"},
ensure_ascii=False,
)
limit = self._clamp_results(max_results)
if query:
matches = search_plugin_candidates(query, plugins)
return json.dumps(
{
"success": True,
"query": query,
"total_available": len(plugins),
"match_count": len(matches),
"truncated": len(matches) > limit,
"plugins": summarize_candidates(matches, limit=limit),
},
ensure_ascii=False,
indent=2,
)
plugin_summaries = [summarize_plugin(plugin) for plugin in plugins[:limit]]
return json.dumps(
{
"success": True,
"total_available": len(plugins),
"returned_count": len(plugin_summaries),
"truncated": len(plugins) > limit,
"plugins": plugin_summaries,
},
ensure_ascii=False,
indent=2,
)
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

@@ -10,6 +10,10 @@ 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):
"""查询媒体详情工具的输入参数模型"""
@@ -64,23 +68,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 +92,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 +113,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 +121,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

@@ -0,0 +1,75 @@
"""查询可用人格工具。"""
import json
from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.runtime import agent_runtime_manager
from app.agent.tools.base import MoviePilotTool
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",
)
query: Optional[str] = Field(
None,
description=(
"Optional search keyword for persona_id, label, description, or aliases. "
"Use this when the user asks for a certain speaking style but the exact persona name is unknown."
),
)
class QueryPersonasTool(MoviePilotTool):
name: str = "query_personas"
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 "
"an exact persona_id. The result includes persona_id, label, description, aliases, and whether it is active."
)
args_schema: Type[BaseModel] = QueryPersonasInput
def get_tool_message(self, **kwargs) -> Optional[str]:
query = kwargs.get("query")
if query:
return f"查询人格列表: {query}"
return "查询人格列表"
async def run(self, query: Optional[str] = None, **kwargs) -> str:
logger.info("执行工具: %s, 参数: query=%s", self.name, query)
try:
runtime_config = agent_runtime_manager.load_runtime_config()
personas = runtime_config.list_personas()
if query:
normalized = query.strip().casefold()
personas = [
persona
for persona in personas
if normalized in persona["persona_id"].casefold()
or normalized in persona["label"].casefold()
or normalized in persona["description"].casefold()
or any(normalized in alias.casefold() for alias in persona["aliases"])
]
payload = {
"active_persona": runtime_config.active_persona,
"count": len(personas),
"personas": personas,
}
return json.dumps(payload, ensure_ascii=False, indent=2)
except Exception as e: # noqa: BLE001
logger.error("查询人格列表失败: %s", e, exc_info=True)
return json.dumps(
{
"success": False,
"message": f"查询人格列表时发生错误: {str(e)}",
},
ensure_ascii=False,
)

View File

@@ -43,70 +43,68 @@ class QueryPluginCapabilitiesTool(MoviePilotTool):
return f"查询插件 {plugin_id} 的能力"
return "查询所有插件的能力"
async def run(self, plugin_id: Optional[str] = None, **kwargs) -> str:
logger.info(f"执行工具: {self.name}, 参数: plugin_id={plugin_id}")
try:
plugin_manager = PluginManager()
result = {}
@staticmethod
def _load_plugin_capabilities(plugin_id: Optional[str] = None) -> dict:
"""读取运行中插件实例暴露的内存能力信息。"""
plugin_manager = PluginManager()
result = {}
# 获取插件命令
commands = plugin_manager.get_plugin_commands(pid=plugin_id)
if commands:
commands_list = []
for cmd in commands:
cmd_info = {
"cmd": cmd.get("cmd"),
"desc": cmd.get("desc"),
"plugin_id": cmd.get("pid"),
}
# data 字段可能包含额外参数信息
if cmd.get("data"):
cmd_info["data"] = cmd.get("data")
commands_list.append(cmd_info)
result["commands"] = commands_list
commands = plugin_manager.get_plugin_commands(pid=plugin_id)
if commands:
result["commands"] = [
{
"cmd": cmd.get("cmd"),
"desc": cmd.get("desc"),
"plugin_id": cmd.get("pid"),
**({"data": cmd.get("data")} if cmd.get("data") else {}),
}
for cmd in commands
]
# 获取插件动作
actions = plugin_manager.get_plugin_actions(pid=plugin_id)
if actions:
actions_list = []
for action_group in actions:
plugin_actions = {
actions = plugin_manager.get_plugin_actions(pid=plugin_id)
if actions:
actions_list = []
for action_group in actions:
actions_list.append(
{
"plugin_id": action_group.get("plugin_id"),
"plugin_name": action_group.get("plugin_name"),
"actions": [],
}
for action in action_group.get("actions", []):
plugin_actions["actions"].append(
"actions": [
{
"id": action.get("id"),
"name": action.get("name"),
}
)
actions_list.append(plugin_actions)
result["actions"] = actions_list
# 获取插件定时服务
services = plugin_manager.get_plugin_services(pid=plugin_id)
if services:
services_list = []
for svc in services:
svc_info = {
"id": svc.get("id"),
"name": svc.get("name"),
for action in action_group.get("actions", [])
],
}
# 包含触发器信息
trigger = svc.get("trigger")
if trigger:
svc_info["trigger"] = str(trigger)
# 包含定时器参数
svc_kwargs = svc.get("kwargs")
if svc_kwargs:
svc_info["trigger_kwargs"] = {
k: str(v) for k, v in svc_kwargs.items()
}
services_list.append(svc_info)
result["services"] = services_list
)
result["actions"] = actions_list
services = plugin_manager.get_plugin_services(pid=plugin_id)
if services:
services_list = []
for svc in services:
svc_info = {
"id": svc.get("id"),
"name": svc.get("name"),
}
trigger = svc.get("trigger")
if trigger:
svc_info["trigger"] = str(trigger)
svc_kwargs = svc.get("kwargs")
if svc_kwargs:
svc_info["trigger_kwargs"] = {
k: str(v) for k, v in svc_kwargs.items()
}
services_list.append(svc_info)
result["services"] = services_list
return result
async def run(self, plugin_id: Optional[str] = None, **kwargs) -> str:
logger.info(f"执行工具: {self.name}, 参数: plugin_id={plugin_id}")
try:
result = self._load_plugin_capabilities(plugin_id)
if not result:
if plugin_id:
return f"插件 {plugin_id} 没有注册任何命令、动作或定时服务"

View File

@@ -0,0 +1,88 @@
"""查询插件配置工具"""
import json
from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.impl._plugin_tool_utils import get_plugin_snapshot
from app.core.plugin import PluginManager
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",
)
plugin_id: str = Field(
...,
description="The plugin ID to query. Use query_installed_plugins first to discover valid plugin IDs.",
)
class QueryPluginConfigTool(MoviePilotTool):
name: str = "query_plugin_config"
description: str = (
"Query the saved configuration of an installed plugin. "
"Returns the current saved config and, when available, the plugin's default config model. "
"Use this before update_plugin_config so you only change the intended keys."
)
require_admin: bool = True
args_schema: Type[BaseModel] = QueryPluginConfigInput
def get_tool_message(self, **kwargs) -> Optional[str]:
"""生成友好的提示消息"""
plugin_id = kwargs.get("plugin_id", "")
return f"查询插件配置: {plugin_id}"
@staticmethod
def _query_plugin_config(plugin_id: str) -> str:
"""
读取插件已保存配置,并尽量补充默认配置模型方便后续精确修改。
"""
plugin_info = get_plugin_snapshot(plugin_id)
if not plugin_info:
return json.dumps(
{
"success": False,
"message": f"插件 {plugin_id} 不存在,请先使用 query_installed_plugins 查询有效插件 ID",
},
ensure_ascii=False,
)
plugin_manager = PluginManager()
saved_config = plugin_manager.get_plugin_config(plugin_id) or {}
result = {
"success": True,
**plugin_info,
"config": saved_config,
}
# get_form 的 model 通常就是插件期望的配置结构,适合作为修改前的键参考。
plugin_instance = plugin_manager.running_plugins.get(plugin_id)
if plugin_instance and hasattr(plugin_instance, "get_form"):
try:
_form_schema, default_model = plugin_instance.get_form()
if default_model is not None:
result["default_model"] = default_model
except Exception as err:
logger.warning(f"读取插件 {plugin_id} 默认配置模型失败: {err}")
return json.dumps(result, ensure_ascii=False, indent=2, default=str)
async def run(self, plugin_id: str, **kwargs) -> str:
logger.info(f"执行工具: {self.name}, 参数: plugin_id={plugin_id}")
try:
# 插件配置来自内存配置缓存和运行态插件实例,直接读取即可。
return self._query_plugin_config(plugin_id)
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

@@ -0,0 +1,158 @@
"""查询插件数据工具"""
import json
from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.impl._plugin_tool_utils import (
PLUGIN_DATA_KEY_PREVIEW_LIMIT,
build_preview_payload,
get_plugin_snapshot,
)
from app.db.plugindata_oper import PluginDataOper
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",
)
plugin_id: str = Field(
...,
description="The plugin ID to query. Use query_installed_plugins first to discover valid plugin IDs.",
)
key: Optional[str] = Field(
None,
description="Optional plugin data key. If omitted, returns all plugin data entries for the plugin.",
)
max_chars: Optional[int] = Field(
None,
description="Maximum number of preview characters to return when plugin data is too large. Default 12000, capped at 50000.",
)
class QueryPluginDataTool(MoviePilotTool):
name: str = "query_plugin_data"
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. "
"When the result is too large, the tool automatically truncates it and returns a preview instead."
)
require_admin: bool = True
args_schema: Type[BaseModel] = QueryPluginDataInput
def get_tool_message(self, **kwargs) -> Optional[str]:
"""生成友好的提示消息"""
plugin_id = kwargs.get("plugin_id", "")
key = kwargs.get("key")
if key:
return f"查询插件数据: {plugin_id}.{key}"
return f"查询插件全部数据: {plugin_id}"
@staticmethod
async def _query_plugin_data(
plugin_id: str, key: Optional[str] = None, max_chars: Optional[int] = None
) -> str:
"""
插件数据改走异步 ORM 查询,避免再套一层线程池。
"""
plugin_info = get_plugin_snapshot(plugin_id)
if not plugin_info:
return json.dumps(
{
"success": False,
"message": f"插件 {plugin_id} 不存在,请先使用 query_installed_plugins 查询有效插件 ID",
},
ensure_ascii=False,
)
plugin_data_oper = PluginDataOper()
if key:
value = await plugin_data_oper.async_get_data(plugin_id, key)
if value is None:
return json.dumps(
{
"success": True,
**plugin_info,
"key": key,
"found": False,
"message": f"插件 {plugin_id} 没有数据项 {key}",
},
ensure_ascii=False,
indent=2,
)
truncated, total_chars, returned_chars, preview = build_preview_payload(
value, max_chars
)
result = {
"success": True,
**plugin_info,
"key": key,
"found": True,
"truncated": truncated,
"total_chars": total_chars,
"returned_chars": returned_chars,
}
if truncated:
result["value_preview"] = preview
result["message"] = "插件数据内容过大,已截断预览"
else:
result["value"] = value
return json.dumps(result, ensure_ascii=False, indent=2, default=str)
rows = await plugin_data_oper.async_get_data_all(plugin_id) or []
data_map = {row.key: row.value for row in rows}
keys = list(data_map.keys())
key_preview = keys[:PLUGIN_DATA_KEY_PREVIEW_LIMIT]
result = {
"success": True,
**plugin_info,
"count": len(data_map),
"keys": key_preview,
"keys_truncated": len(keys) > PLUGIN_DATA_KEY_PREVIEW_LIMIT,
}
if not data_map:
result["data"] = {}
result["truncated"] = False
return json.dumps(result, ensure_ascii=False, indent=2, default=str)
truncated, total_chars, returned_chars, preview = build_preview_payload(
data_map, max_chars
)
result["truncated"] = truncated
result["total_chars"] = total_chars
result["returned_chars"] = returned_chars
if truncated:
result["data_preview"] = preview
result["message"] = "插件数据内容过大,已截断。请传入 key 精确查询单个数据项。"
else:
result["data"] = data_map
return json.dumps(result, ensure_ascii=False, indent=2, default=str)
async def run(
self,
plugin_id: str,
key: Optional[str] = None,
max_chars: Optional[int] = None,
**kwargs,
) -> str:
logger.info(
f"执行工具: {self.name}, 参数: plugin_id={plugin_id}, key={key}"
)
try:
return await self._query_plugin_data(plugin_id, key, max_chars)
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

@@ -12,13 +12,15 @@ from app.helper.subscribe import SubscribeHelper
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")
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)")
@@ -69,6 +71,8 @@ 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'"

View File

@@ -1,64 +1,104 @@
"""查询规则组工具"""
"""查询过滤规则组工具"""
import json
from typing import Optional, Type
from typing import Optional, Type, List
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.helper.rule import RuleHelper
from app.agent.tools.impl._filter_rule_utils import (
collect_rule_group_usages,
get_rule_groups,
serialize_rule_group,
RULE_STRING_SYNTAX,
)
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: str = Field(
...,
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.",
)
include_usage: bool = Field(
True,
description="Whether to include where each rule group is referenced by global settings or subscriptions.",
)
class QueryRuleGroupsTool(MoviePilotTool):
name: str = "query_rule_groups"
description: str = "Query all filter rule groups available in the system. Rule groups are used to filter torrents when searching or subscribing. Returns rule group names, media types, and categories, but excludes rule_string to keep results concise."
description: str = (
"Query filter rule groups (过滤规则组 / 优先级规则组). "
"Each rule group contains a rule_string made of built-in rules and/or custom rules. "
"Inside one level use '&', '|', '!' and optional parentheses; use '>' between levels. "
"Levels are evaluated from left to right, and the first matched level wins. "
"The result includes parsed levels and syntax guidance so the agent can learn existing patterns before writing a new rule group."
)
args_schema: Type[BaseModel] = QueryRuleGroupsInput
def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据查询参数生成友好的提示消息"""
group_names = kwargs.get("group_names") or []
if group_names:
return f"查询规则组: {', '.join(group_names)}"
return "查询所有规则组"
async def run(self, **kwargs) -> str:
async def run(
self,
group_names: Optional[List[str]] = None,
include_usage: bool = True,
**kwargs,
) -> str:
logger.info(f"执行工具: {self.name}")
try:
rule_helper = RuleHelper()
rule_groups = rule_helper.get_rule_groups()
if not rule_groups:
return json.dumps({
"message": "未找到任何规则组",
"rule_groups": []
}, ensure_ascii=False, indent=2)
# 精简字段,过滤掉 rule_string 避免结果过大
simplified_groups = []
for group in rule_groups:
simplified = {
"name": group.name,
"media_type": group.media_type,
"category": group.category
}
simplified_groups.append(simplified)
result = {
"message": f"找到 {len(simplified_groups)}规则组",
"rule_groups": simplified_groups
}
return json.dumps(result, ensure_ascii=False, indent=2)
except Exception as e:
error_message = f"查询规则组失败: {str(e)}"
logger.error(f"查询规则组失败: {e}", exc_info=True)
return json.dumps({
"success": False,
"message": error_message,
"rule_groups": []
}, ensure_ascii=False)
rule_groups = get_rule_groups()
if group_names:
target_names = set(group_names)
rule_groups = [
group for group in rule_groups if group.name in target_names
]
usage_map = {}
if include_usage:
usage_map = await collect_rule_group_usages(
[group.name for group in rule_groups if group.name]
)
serialized = [
serialize_rule_group(group, usage_map.get(group.name))
for group in rule_groups
]
message = (
f"找到 {len(serialized)} 个规则组"
if serialized
else "找到任何规则组"
)
return json.dumps(
{
"success": True,
"message": message,
"count": len(serialized),
"rule_string_syntax": RULE_STRING_SYNTAX,
"rule_groups": serialized,
},
ensure_ascii=False,
indent=2,
)
except Exception as exc:
logger.error(f"查询规则组失败: {exc}", exc_info=True)
return json.dumps(
{
"success": False,
"message": f"查询规则组失败: {exc}",
"rule_groups": [],
},
ensure_ascii=False,
)

View File

@@ -7,7 +7,6 @@ from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.log import logger
from app.scheduler import Scheduler
class QuerySchedulersInput(BaseModel):
@@ -27,6 +26,8 @@ class QuerySchedulersTool(MoviePilotTool):
async def run(self, **kwargs) -> str:
logger.info(f"执行工具: {self.name}")
try:
from app.scheduler import Scheduler
scheduler = Scheduler()
schedulers = scheduler.list()
if schedulers:

View File

@@ -11,6 +11,14 @@ 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):
"""查询站点用户数据工具的输入参数模型"""
@@ -110,6 +118,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 +146,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

@@ -9,13 +9,15 @@ from app.agent.tools.base import MoviePilotTool
from app.helper.subscribe import SubscribeHelper
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")
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)")
@@ -63,6 +65,8 @@ 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(

View File

@@ -62,8 +62,8 @@ class QueryTransferHistoryTool(MoviePilotTool):
if page is None or page < 1:
page = 1
# 每页记录数
count = 50
# 每页固定 30 条,与工具说明保持一致,避免整理路径等字段撑大上下文。
count = 30
# 获取数据库会话
async with AsyncSessionFactory() as db:

View File

@@ -115,9 +115,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

@@ -49,8 +49,7 @@ class RecognizeMediaTool(MoviePilotTool):
try:
media_chain = MediaChain()
context = None
# 根据提供的参数选择识别方式
if path:
# 文件路径识别
@@ -60,7 +59,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 +75,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

@@ -0,0 +1,84 @@
"""重载插件工具"""
import json
from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.impl._plugin_tool_utils import (
get_plugin_snapshot,
reload_plugin_runtime,
)
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",
)
plugin_id: str = Field(
...,
description="The plugin ID to reload so the latest saved config takes effect.",
)
class ReloadPluginTool(MoviePilotTool):
name: str = "reload_plugin"
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."
)
require_admin: bool = True
args_schema: Type[BaseModel] = ReloadPluginInput
def get_tool_message(self, **kwargs) -> Optional[str]:
"""生成友好的提示消息"""
plugin_id = kwargs.get("plugin_id", "")
return f"重载插件: {plugin_id}"
@staticmethod
def _reload_plugin_sync(plugin_id: str) -> str:
"""
按后台接口同样的流程重载插件,确保最新配置和注册信息一起刷新。
"""
plugin_info = get_plugin_snapshot(plugin_id)
if not plugin_info:
return json.dumps(
{
"success": False,
"message": f"插件 {plugin_id} 不存在,请先使用 query_installed_plugins 查询有效插件 ID",
},
ensure_ascii=False,
)
reload_plugin_runtime(plugin_id)
refreshed_plugin = get_plugin_snapshot(plugin_id) or plugin_info
return json.dumps(
{
"success": True,
**refreshed_plugin,
"message": "插件已重载,最新配置已生效",
},
ensure_ascii=False,
indent=2,
default=str,
)
async def run(self, plugin_id: str, **kwargs) -> str:
logger.info(f"执行工具: {self.name}, 参数: plugin_id={plugin_id}")
try:
return await self.run_blocking(
"plugin", self._reload_plugin_sync, plugin_id
)
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

@@ -6,7 +6,6 @@ from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.log import logger
from app.scheduler import Scheduler
class RunSchedulerInput(BaseModel):
@@ -33,27 +32,28 @@ class RunSchedulerTool(MoviePilotTool):
job_id = kwargs.get("job_id", "")
return f"运行定时服务 (ID: {job_id})"
@staticmethod
def _run_scheduler_sync(job_id: str) -> tuple[bool, str]:
"""同步触发定时服务,避免调度器扫描阻塞事件循环。"""
from app.scheduler import Scheduler
scheduler = Scheduler()
for scheduler_item in scheduler.list():
if scheduler_item.id == job_id:
scheduler.start(job_id)
return True, scheduler_item.name
return False, ""
async def run(self, job_id: str, **kwargs) -> str:
logger.info(f"执行工具: {self.name}, 参数: job_id={job_id}")
try:
scheduler = Scheduler()
# 检查定时服务是否存在
schedulers = scheduler.list()
job_exists = False
job_name = None
for s in schedulers:
if s.id == job_id:
job_exists = True
job_name = s.name
break
job_exists, job_name = await self.run_blocking(
"workflow", self._run_scheduler_sync, job_id
)
if not job_exists:
return f"定时服务 ID {job_id} 不存在,请使用 query_schedulers 工具查询可用的定时服务"
# 运行定时服务
scheduler.start(job_id)
return f"成功触发定时服务:{job_name} (ID: {job_id})"
except Exception as e:
logger.error(f"运行定时服务失败: {e}", exc_info=True)

View File

@@ -46,6 +46,13 @@ class RunWorkflowTool(MoviePilotTool):
return message
@staticmethod
def _run_workflow_sync(
workflow_id: int, from_begin: Optional[bool] = True
) -> tuple[bool, str]:
"""同步执行工作流,放到专用线程池避免长流程阻塞 API 响应。"""
return WorkflowChain().process(workflow_id, from_begin=from_begin)
async def run(
self, workflow_id: int, from_begin: Optional[bool] = True, **kwargs
) -> str:
@@ -62,10 +69,12 @@ class RunWorkflowTool(MoviePilotTool):
if not workflow:
return f"未找到工作流:{workflow_id},请使用 query_workflows 工具查询可用的工作流"
# 执行工作流
workflow_chain = WorkflowChain()
state, errmsg = workflow_chain.process(
workflow.id, from_begin=from_begin
# 工作流执行链路包含大量同步步骤,统一放到 workflow 线程池。
state, errmsg = await self.run_blocking(
"workflow",
self._run_workflow_sync,
workflow.id,
from_begin,
)
if not state:

View File

@@ -8,8 +8,6 @@ from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.chain.media import MediaChain
from app.core.config import global_vars
from app.core.metainfo import MetaInfoPath
from app.log import logger
from app.schemas import FileItem
@@ -81,8 +79,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,
@@ -90,11 +87,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,
@@ -104,15 +102,14 @@ class ScrapeMetadataTool(MoviePilotTool):
ensure_ascii=False,
)
# 在线程池中执行同步的刮削操作
await global_vars.loop.run_in_executor(
None,
lambda: media_chain.scrape_metadata(
fileitem=fileitem,
meta=meta,
mediainfo=mediainfo,
overwrite=overwrite,
),
# 刮削会包含磁盘写入和外部图片/元数据访问,统一放到 storage 线程池。
await self.run_blocking(
"storage",
media_chain.scrape_metadata,
fileitem=fileitem,
meta=context.meta_info,
mediainfo=context.media_info,
overwrite=overwrite,
)
return json.dumps(
@@ -121,11 +118,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

@@ -73,7 +73,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 +96,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

@@ -35,7 +35,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 +72,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

@@ -7,7 +7,6 @@ from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.chain.subscribe import SubscribeChain
from app.core.config import global_vars
from app.db.subscribe_oper import SubscribeOper
from app.log import logger
from app.schemas.types import media_type_to_agent
@@ -81,19 +80,13 @@ class SearchSubscribeTool(MoviePilotTool):
subscribe_oper.update(subscribe_id, {"filter_groups": filter_groups})
logger.info(f"更新订阅 #{subscribe_id} 的规则组为: {filter_groups}")
# 调用 SubscribeChain 的 search 方法
# search 方法是同步的,需要在异步环境中运行
subscribe_chain = SubscribeChain()
# 在线程池中执行同步的搜索操作
# 当 sid 有值时state 参数会被忽略,直接处理该订阅
await global_vars.loop.run_in_executor(
None,
lambda: subscribe_chain.search(
sid=subscribe_id,
state='R', # 默认状态,当 sid 有值时此参数会被忽略
manual=manual
)
# 订阅搜索会触发大量同步站点访问,统一走 subscribe 线程池。
await self.run_blocking(
"subscribe",
SubscribeChain().search,
sid=subscribe_id,
state="R", # 当 sid 有值时此参数会被忽略
manual=manual,
)
# 重新获取订阅信息以获取更新后的状态

View File

@@ -50,6 +50,11 @@ class SearchTorrentsTool(MoviePilotTool):
message += f" [{media_type}]"
return message
@staticmethod
def _load_configured_sites() -> List[int]:
"""同步读取默认搜索站点列表。"""
return SystemConfigOper().get(SystemConfigKey.IndexerSites) or []
async def run(self, tmdb_id: Optional[int] = None, douban_id: Optional[str] = None,
media_type: Optional[str] = None, area: Optional[str] = None,
sites: Optional[List[int]] = None, **kwargs) -> str:
@@ -83,8 +88,7 @@ class SearchTorrentsTool(MoviePilotTool):
if sites:
search_site_ids = sites
else:
configured_sites = SystemConfigOper().get(SystemConfigKey.IndexerSites)
search_site_ids = configured_sites if configured_sites else []
search_site_ids = self._load_configured_sites()
if filtered_torrents:
await search_chain.async_save_cache(filtered_torrents, SEARCH_RESULT_CACHE_FILE)

View File

@@ -28,7 +28,7 @@ class SearchWebInput(BaseModel):
)
max_results: Optional[int] = Field(
20,
description="Maximum number of search results to return (default: 5, max: 10)",
description="Maximum number of search results to return (default: 20, max: 20)",
)

View File

@@ -45,6 +45,7 @@ class SendLocalFileInput(BaseModel):
class SendLocalFileTool(MoviePilotTool):
name: str = "send_local_file"
sends_message: bool = True
description: str = (
"Send a local image or file from the server filesystem to the current user. "
"Use this when you have generated or identified a local file the user should download."

View File

@@ -37,6 +37,7 @@ class SendMessageInput(BaseModel):
class SendMessageTool(MoviePilotTool):
name: str = "send_message"
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
require_admin: bool = True

View File

@@ -8,10 +8,8 @@ from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool, ToolChain
from app.core.config import settings
from app.helper.voice import VoiceHelper
from app.helper.service import ServiceConfigHelper
from app.log import logger
from app.schemas import Notification, NotificationType
from app.schemas.types import MessageChannel
class SendVoiceMessageInput(BaseModel):
@@ -29,10 +27,12 @@ class SendVoiceMessageInput(BaseModel):
class SendVoiceMessageTool(MoviePilotTool):
name: str = "send_voice_message"
sends_message: bool = True
description: str = (
"Send a voice reply to the current user. Prefer this when the user sent a voice message "
"or when spoken playback is more natural. On channels without voice support or when TTS "
"is unavailable, it automatically falls back to sending the same content as plain text."
"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."
)
args_schema: Type[BaseModel] = SendVoiceMessageInput
require_admin: bool = False
@@ -43,18 +43,6 @@ class SendVoiceMessageTool(MoviePilotTool):
message = message[:40] + "..."
return f"发送语音回复: {message}"
def _supports_real_voice_reply(self) -> bool:
channel = self._channel or ""
if channel == MessageChannel.Telegram.value:
return True
if channel != MessageChannel.Wechat.value:
return False
for config in ServiceConfigHelper.get_notification_configs():
if config.name != self._source:
continue
return (config.config or {}).get("WECHAT_MODE", "app") != "bot"
return False
async def run(self, message: str, **kwargs) -> str:
if not message:
return "语音回复内容不能为空"
@@ -62,11 +50,23 @@ class SendVoiceMessageTool(MoviePilotTool):
voice_path = None
used_voice = False
channel = self._channel or ""
if self._supports_real_voice_reply() and VoiceHelper.is_available("tts"):
reply_mode = VoiceHelper.resolve_reply_mode(
channel=channel,
source=self._source,
)
fallback_reason = "当前渠道不支持语音回复"
if not VoiceHelper.is_enabled():
fallback_reason = "当前未启用音频输入输出"
if (
reply_mode == VoiceHelper.REPLY_MODE_NATIVE
and VoiceHelper.is_available("tts")
):
voice_file = await asyncio.to_thread(VoiceHelper.synthesize_speech, message)
if voice_file:
voice_path = str(voice_file)
used_voice = True
elif reply_mode == VoiceHelper.REPLY_MODE_NATIVE:
fallback_reason = "当前未配置可用的语音合成能力"
logger.info(
"执行工具: %s, channel=%s, use_voice=%s, text_len=%s",
@@ -85,7 +85,11 @@ class SendVoiceMessageTool(MoviePilotTool):
username=self._username,
text=message,
voice_path=voice_path,
voice_caption=message if settings.AI_VOICE_REPLY_WITH_TEXT else None,
voice_caption=(
message
if voice_path and settings.AI_VOICE_REPLY_WITH_TEXT
else None
),
)
)
self._agent_context["user_reply_sent"] = True
@@ -93,4 +97,4 @@ class SendVoiceMessageTool(MoviePilotTool):
if used_voice:
return "语音回复已发送"
return "当前未使用语音通道,已自动回退为文字回复"
return f"{fallback_reason},已自动回退为文字回复"

View File

@@ -0,0 +1,62 @@
"""切换当前激活人格工具。"""
import json
from typing import Type
from pydantic import BaseModel, Field
from app.agent.runtime import agent_runtime_manager
from app.agent.tools.base import MoviePilotTool
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",
)
persona_id: str = Field(
...,
description=(
"The target persona to activate. This can be the exact persona_id, label, or one of the persona aliases. "
"If the exact persona is unclear, call query_personas first."
),
)
class SwitchPersonaTool(MoviePilotTool):
name: str = "switch_persona"
description: str = (
"Switch the active persona (人格) used by the agent runtime. "
"This change is persistent for future turns. "
"Use this when the user explicitly asks to change the speaking style, tone, or response persona. "
"If the user asks for a vague style and you are not sure which persona matches best, call query_personas first."
)
args_schema: Type[BaseModel] = SwitchPersonaInput
def get_tool_message(self, **kwargs) -> str:
persona_id = kwargs.get("persona_id") or "未知人格"
return f"切换人格: {persona_id}"
async def run(self, persona_id: str, **kwargs) -> str:
logger.info("执行工具: %s, 参数: persona_id=%s", self.name, persona_id)
try:
runtime_config = agent_runtime_manager.set_active_persona(persona_id)
payload = {
"success": True,
"active_persona": runtime_config.active_persona,
"persona": runtime_config.persona.to_dict(is_active=True),
"message": f"已切换为人格 `{runtime_config.active_persona}`",
}
return json.dumps(payload, ensure_ascii=False, indent=2)
except Exception as e: # noqa: BLE001
logger.error("切换人格失败: %s", e, exc_info=True)
return json.dumps(
{
"success": False,
"message": f"切换人格时发生错误: {str(e)}",
},
ensure_ascii=False,
)

View File

@@ -26,24 +26,29 @@ class TestSiteTool(MoviePilotTool):
site_identifier = kwargs.get("site_identifier")
return f"测试站点连通性: {site_identifier}"
@staticmethod
def _test_site_sync(site_identifier: int) -> tuple[Optional[str], Optional[str], bool, str]:
"""在同步线程里执行站点联通测试,避免网络请求卡住事件循环。"""
site = SiteOper().get(site_identifier)
if not site:
return None, None, False, f"未找到站点:{site_identifier},请使用 query_sites 工具查询可用的站点"
status, message = SiteChain().test(site.domain)
return site.name, site.domain, status, message
async def run(self, site_identifier: int, **kwargs) -> str:
logger.info(f"执行工具: {self.name}, 参数: site_identifier={site_identifier}")
try:
site_oper = SiteOper()
site_chain = SiteChain()
site = await site_oper.async_get(site_identifier)
if not site:
return f"未找到站点:{site_identifier},请使用 query_sites 工具查询可用的站点"
# 测试站点连通性
status, message = site_chain.test(site.domain)
site_name, site_domain, status, message = await self.run_blocking(
"site", self._test_site_sync, site_identifier
)
if not site_name:
return message
if status:
return f"站点连通性测试成功:{site.name} ({site.domain})\n{message}"
return f"站点连通性测试成功:{site_name} ({site_domain})\n{message}"
else:
return f"站点连通性测试失败:{site.name} ({site.domain})\n{message}"
return f"站点连通性测试失败:{site_name} ({site_domain})\n{message}"
except Exception as e:
logger.error(f"测试站点连通性失败: {e}", exc_info=True)
return f"测试站点连通性时发生错误: {str(e)}"

View File

@@ -6,7 +6,6 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.chain.transfer import TransferChain
from app.log import logger
from app.schemas import FileItem, MediaType
@@ -84,6 +83,75 @@ class TransferFileTool(MoviePilotTool):
return message
@staticmethod
def _transfer_file_sync(
file_path: str,
storage: Optional[str] = "local",
target_path: Optional[str] = None,
target_storage: Optional[str] = None,
media_type: Optional[str] = None,
tmdbid: Optional[int] = None,
doubanid: Optional[str] = None,
season: Optional[int] = None,
transfer_type: Optional[str] = None,
background: Optional[bool] = False,
) -> str:
"""
文件整理链路包含大量同步磁盘与外部服务调用,需要在线程池中运行。
"""
if not file_path:
return "错误:必须提供文件或目录路径"
if storage == "local":
if not file_path.startswith("/") and not (
len(file_path) > 1 and file_path[1] == ":"
):
file_path = str(Path(file_path).resolve())
elif not file_path.startswith("/"):
file_path = "/" + file_path
fileitem = FileItem(
storage=storage or "local",
path=file_path,
type="dir" if file_path.endswith("/") else "file",
)
target_path_obj = Path(target_path) if target_path else None
media_type_enum = None
if media_type:
media_type_enum = MediaType.from_agent(media_type)
if not media_type_enum:
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'"
from app.chain.transfer import TransferChain
state, errormsg = TransferChain().manual_transfer(
fileitem=fileitem,
target_storage=target_storage,
target_path=target_path_obj,
tmdbid=tmdbid,
doubanid=doubanid,
mtype=media_type_enum,
season=season,
transfer_type=transfer_type,
background=background,
)
if state:
if background:
return f"整理任务已提交到后台运行:{file_path}"
return f"整理成功:{file_path}"
if isinstance(errormsg, list):
error_text = f"整理完成,{len(errormsg)} 个文件转移失败"
if errormsg:
error_text += "\n" + "\n".join(str(e) for e in errormsg[:5])
if len(errormsg) > 5:
error_text += f"\n... 还有 {len(errormsg) - 5} 个错误"
else:
error_text = str(errormsg)
return f"整理失败:{error_text}"
async def run(
self,
file_path: str,
@@ -105,73 +173,20 @@ class TransferFileTool(MoviePilotTool):
)
try:
if not file_path:
return "错误:必须提供文件或目录路径"
# 规范化路径
if storage == "local":
# 本地路径处理
if not file_path.startswith("/") and not (
len(file_path) > 1 and file_path[1] == ":"
):
# 相对路径,尝试转换为绝对路径
file_path = str(Path(file_path).resolve())
else:
# 远程存储路径,确保以/开头
if not file_path.startswith("/"):
file_path = "/" + file_path
# 创建FileItem
fileitem = FileItem(
storage=storage or "local",
path=file_path,
type="dir" if file_path.endswith("/") else "file",
return await self.run_blocking(
"storage",
self._transfer_file_sync,
file_path,
storage,
target_path,
target_storage,
media_type,
tmdbid,
doubanid,
season,
transfer_type,
background,
)
# 处理目标路径
target_path_obj = None
if target_path:
target_path_obj = Path(target_path)
# 处理媒体类型
media_type_enum = None
if media_type:
media_type_enum = MediaType.from_agent(media_type)
if not media_type_enum:
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'"
# 调用整理方法
transfer_chain = TransferChain()
state, errormsg = transfer_chain.manual_transfer(
fileitem=fileitem,
target_storage=target_storage,
target_path=target_path_obj,
tmdbid=tmdbid,
doubanid=doubanid,
mtype=media_type_enum,
season=season,
transfer_type=transfer_type,
background=background,
)
if not state:
# 处理错误信息
if isinstance(errormsg, list):
error_text = f"整理完成,{len(errormsg)} 个文件转移失败"
if errormsg:
error_text += f"\n" + "\n".join(
str(e) for e in errormsg[:5]
) # 只显示前5个错误
if len(errormsg) > 5:
error_text += f"\n... 还有 {len(errormsg) - 5} 个错误"
else:
error_text = str(errormsg)
return f"整理失败:{error_text}"
else:
if background:
return f"整理任务已提交到后台运行:{file_path}"
else:
return f"整理成功:{file_path}"
except Exception as e:
logger.error(f"整理文件失败: {e}", exc_info=True)
return f"整理文件时发生错误: {str(e)}"

View File

@@ -0,0 +1,84 @@
"""卸载插件工具"""
import json
from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.impl._plugin_tool_utils import (
list_installed_plugins,
summarize_plugin,
uninstall_plugin_runtime,
)
from app.log import logger
class UninstallPluginInput(BaseModel):
"""卸载插件工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
plugin_id: str = Field(
...,
description="Exact plugin ID to uninstall. Use query_installed_plugins first to find the correct plugin_id.",
)
class UninstallPluginTool(MoviePilotTool):
name: str = "uninstall_plugin"
description: str = (
"Uninstall an installed plugin by exact plugin_id. "
"Use query_installed_plugins first when you need filtering or discovery."
)
require_admin: bool = True
args_schema: Type[BaseModel] = UninstallPluginInput
def get_tool_message(self, **kwargs) -> Optional[str]:
plugin_id = kwargs.get("plugin_id")
return f"卸载插件: {plugin_id or '未知插件'}"
async def run(
self,
plugin_id: str,
**kwargs,
) -> str:
logger.info(f"执行工具: {self.name}, 参数: plugin_id={plugin_id}")
try:
plugins = list_installed_plugins()
if not plugins:
return json.dumps(
{"success": False, "message": "当前没有已安装的插件"},
ensure_ascii=False,
)
candidate = next((plugin for plugin in plugins if plugin.id == plugin_id), None)
if not candidate:
return json.dumps(
{
"success": False,
"message": f"未找到已安装插件: {plugin_id}。请先调用 query_installed_plugins 确认 plugin_id。",
},
ensure_ascii=False,
)
cleanup_result = await uninstall_plugin_runtime(candidate.id)
return json.dumps(
{
"success": True,
"message": f"插件 {candidate.id} 已卸载",
"plugin": summarize_plugin(candidate),
**cleanup_result,
},
ensure_ascii=False,
indent=2,
)
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

@@ -0,0 +1,190 @@
"""更新自定义过滤规则工具。"""
import json
from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.impl._filter_rule_utils import (
collect_custom_rule_group_refs,
get_custom_rules,
get_rule_groups,
normalize_custom_rule,
replace_rule_id_in_rule_string,
save_system_config,
serialize_custom_rule,
)
from app.log import logger
from app.schemas.types import SystemConfigKey
class UpdateCustomFilterRuleInput(BaseModel):
"""更新自定义过滤规则工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
current_rule_id: str = Field(
..., description="Existing custom rule ID to update."
)
new_rule_id: Optional[str] = Field(
None,
description="New rule ID. If omitted, keep the original rule ID.",
)
name: Optional[str] = Field(
None, description="New display name. If omitted, keep the original name."
)
include: Optional[str] = Field(
None,
description="New include regex. Pass an empty string to clear it.",
)
exclude: Optional[str] = Field(
None,
description="New exclude regex. Pass an empty string to clear it.",
)
size_range: Optional[str] = Field(
None,
description="New size range in MB. Pass an empty string to clear it.",
)
seeders: Optional[str] = Field(
None,
description="New minimum seeder count. Pass an empty string to clear it.",
)
publish_time: Optional[str] = Field(
None,
description="New publish-time filter in minutes. Pass an empty string to clear it.",
)
class UpdateCustomFilterRuleTool(MoviePilotTool):
name: str = "update_custom_filter_rule"
description: str = (
"Update an existing custom filter rule. "
"If the rule ID is renamed, all rule groups that reference the old ID are updated automatically."
)
args_schema: Type[BaseModel] = UpdateCustomFilterRuleInput
require_admin: bool = True
def get_tool_message(self, **kwargs) -> Optional[str]:
current_rule_id = kwargs.get("current_rule_id", "")
new_rule_id = kwargs.get("new_rule_id")
if new_rule_id and new_rule_id != current_rule_id:
return f"更新自定义过滤规则 {current_rule_id} -> {new_rule_id}"
return f"更新自定义过滤规则 {current_rule_id}"
async def run(
self,
current_rule_id: str,
new_rule_id: Optional[str] = None,
name: Optional[str] = None,
include: Optional[str] = None,
exclude: Optional[str] = None,
size_range: Optional[str] = None,
seeders: Optional[str] = None,
publish_time: Optional[str] = None,
**kwargs,
) -> str:
logger.info(f"执行工具: {self.name}, current_rule_id={current_rule_id}")
try:
custom_rules = get_custom_rules()
rule_map = {rule.id: rule for rule in custom_rules if rule.id}
current_rule = rule_map.get(current_rule_id)
if not current_rule:
return json.dumps(
{
"success": False,
"message": f"自定义过滤规则 '{current_rule_id}' 不存在",
},
ensure_ascii=False,
)
updated_rule = normalize_custom_rule(
rule_id=new_rule_id or current_rule.id,
name=name if name is not None else current_rule.name,
include=include if include is not None else current_rule.include,
exclude=exclude if exclude is not None else current_rule.exclude,
size_range=(
size_range if size_range is not None else current_rule.size_range
),
seeders=seeders if seeders is not None else current_rule.seeders,
publish_time=(
publish_time
if publish_time is not None
else current_rule.publish_time
),
existing_rules=custom_rules,
original_rule_id=current_rule.id,
)
rule_groups = get_rule_groups()
updated_rule_groups = rule_groups
renamed_group_refs = []
if updated_rule.id != current_rule.id:
updated_rule_groups = []
for group in rule_groups:
if not group.rule_string:
updated_rule_groups.append(group)
continue
new_rule_string = replace_rule_id_in_rule_string(
group.rule_string,
current_rule.id,
updated_rule.id,
)
if new_rule_string == group.rule_string:
updated_rule_groups.append(group)
continue
renamed_group_refs.append(group.name)
updated_rule_groups.append(
group.model_copy(update={"rule_string": new_rule_string})
)
# 先保存规则组引用,再保存规则自身,避免在过滤模块重载时出现新规则 ID 尚未同步的问题。
await save_system_config(
SystemConfigKey.UserFilterRuleGroups,
[
group.model_dump(exclude_none=True)
for group in updated_rule_groups
],
)
final_rules = []
for rule in custom_rules:
if rule.id == current_rule.id:
final_rules.append(updated_rule)
else:
final_rules.append(rule)
await save_system_config(
SystemConfigKey.CustomFilterRules,
[rule.model_dump(exclude_none=True) for rule in final_rules],
)
updated_refs = collect_custom_rule_group_refs(
updated_rule_groups,
[updated_rule.id],
)
return json.dumps(
{
"success": True,
"message": f"已更新自定义过滤规则 {updated_rule.id}",
"custom_rule": serialize_custom_rule(
updated_rule,
updated_refs.get(updated_rule.id),
),
"rule_groups_updated_for_rule_id_rename": renamed_group_refs,
},
ensure_ascii=False,
indent=2,
)
except Exception as exc:
logger.error(f"更新自定义过滤规则失败: {exc}", exc_info=True)
return json.dumps(
{
"success": False,
"message": f"更新自定义过滤规则失败: {exc}",
},
ensure_ascii=False,
)

View File

@@ -0,0 +1,131 @@
"""更新人格定义工具。"""
import json
from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.runtime import agent_runtime_manager
from app.agent.tools.base import MoviePilotTool
from app.log import logger
class UpdatePersonaDefinitionInput(BaseModel):
"""更新人格定义工具的输入参数模型。"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
persona_id: str = Field(
...,
description=(
"Target persona to update. For existing personas this can be persona_id, label, or alias. "
"For new personas, provide the new lowercase persona_id."
),
)
label: Optional[str] = Field(
None,
description="Optional new label shown to users, such as 默认 or 说明型.",
)
description: Optional[str] = Field(
None,
description="Optional short description of the persona's intended style.",
)
aliases: Optional[list[str]] = Field(
None,
description="Optional full replacement list of aliases for this persona.",
)
instructions: Optional[str] = Field(
None,
description=(
"Optional full replacement body for PERSONA.md, excluding YAML frontmatter. "
"Use this when the persona definition should be rewritten completely."
),
)
append_instructions: Optional[list[str]] = Field(
None,
description=(
"Optional extra persona rules to append to the existing PERSONA body. "
"Use this for small adjustments such as '回答更短' or '复杂问题给两步解释'."
),
)
create_if_missing: bool = Field(
False,
description="Whether to create a new runtime persona if the target persona does not already exist.",
)
class UpdatePersonaDefinitionTool(MoviePilotTool):
name: str = "update_persona_definition"
description: str = (
"Create or update a runtime persona definition (人格定义) without manually editing PERSONA.md files. "
"Use this when the user explicitly asks to modify how a persona is defined, such as changing tone rules, "
"rewriting the persona body, adjusting aliases, or creating a new persona."
)
args_schema: Type[BaseModel] = UpdatePersonaDefinitionInput
require_admin: bool = True
def get_tool_message(self, **kwargs) -> str:
persona_id = kwargs.get("persona_id") or "未知人格"
action = "创建/更新人格定义"
return f"{action}: {persona_id}"
async def run(
self,
persona_id: str,
label: Optional[str] = None,
description: Optional[str] = None,
aliases: Optional[list[str]] = None,
instructions: Optional[str] = None,
append_instructions: Optional[list[str]] = None,
create_if_missing: bool = False,
**kwargs,
) -> str:
logger.info("执行工具: %s, 参数: persona_id=%s", self.name, persona_id)
if not any(
value is not None
for value in (label, description, aliases, instructions, append_instructions)
):
return json.dumps(
{
"success": False,
"message": "未提供任何要更新的人格定义字段。",
},
ensure_ascii=False,
)
try:
persona, created = agent_runtime_manager.update_persona_definition(
persona_id,
label=label,
description=description,
aliases=aliases,
instructions=instructions,
append_instructions=append_instructions,
create_if_missing=create_if_missing,
)
runtime_config = agent_runtime_manager.load_runtime_config()
payload = {
"success": True,
"created": created,
"active_persona": runtime_config.active_persona,
"persona": persona.to_dict(
is_active=persona.persona_id == runtime_config.active_persona
),
"message": (
f"已创建人格 `{persona.persona_id}`"
if created
else f"已更新人格 `{persona.persona_id}` 的定义"
),
}
return json.dumps(payload, ensure_ascii=False, indent=2)
except Exception as e: # noqa: BLE001
logger.error("更新人格定义失败: %s", e, exc_info=True)
return json.dumps(
{
"success": False,
"message": f"更新人格定义时发生错误: {str(e)}",
},
ensure_ascii=False,
)

View File

@@ -0,0 +1,153 @@
"""修改插件配置工具"""
import json
from typing import Any, Dict, List, Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.impl._plugin_tool_utils import get_plugin_snapshot
from app.core.plugin import PluginManager
from app.log import logger
class UpdatePluginConfigInput(BaseModel):
"""修改插件配置工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
plugin_id: str = Field(
...,
description="The plugin ID to update. Use query_plugin_config first to inspect the current config.",
)
updates: Optional[Dict[str, Any]] = Field(
None,
description=(
"Config items to save. By default this tool merges these keys into the existing config "
"instead of replacing the whole config."
),
)
remove_keys: Optional[List[str]] = Field(
None,
description="Optional config keys to remove from the saved plugin config.",
)
replace: Optional[bool] = Field(
False,
description=(
"Whether to replace the entire saved config with 'updates'. "
"Default false, which performs a partial merge update."
),
)
class UpdatePluginConfigTool(MoviePilotTool):
name: str = "update_plugin_config"
description: str = (
"Update the saved configuration of an installed plugin. "
"By default this performs a partial merge update and does NOT reload the plugin automatically. "
"Call reload_plugin afterwards to apply the latest saved config to the running plugin."
)
require_admin: bool = True
args_schema: Type[BaseModel] = UpdatePluginConfigInput
def get_tool_message(self, **kwargs) -> Optional[str]:
"""生成友好的提示消息"""
plugin_id = kwargs.get("plugin_id", "")
replace = kwargs.get("replace", False)
action = "覆盖插件配置" if replace else "修改插件配置"
return f"{action}: {plugin_id}"
@staticmethod
async def _update_plugin_config(
plugin_id: str,
updates: Optional[Dict[str, Any]] = None,
remove_keys: Optional[List[str]] = None,
replace: bool = False,
) -> str:
"""
仅异步保存插件配置,不主动生效,让 Agent 可以先批量改完再显式重载插件。
"""
plugin_info = get_plugin_snapshot(plugin_id)
if not plugin_info:
return json.dumps(
{
"success": False,
"message": f"插件 {plugin_id} 不存在,请先使用 query_installed_plugins 查询有效插件 ID",
},
ensure_ascii=False,
)
remove_keys = remove_keys or []
if not replace and not updates and not remove_keys:
return json.dumps(
{"success": False, "message": "没有提供任何需要修改的配置项"},
ensure_ascii=False,
)
plugin_manager = PluginManager()
current_config = dict(plugin_manager.get_plugin_config(plugin_id) or {})
# merge 模式以当前保存值为基准replace 模式则从空配置开始重建。
next_config = {} if replace else dict(current_config)
if updates:
next_config.update(updates)
for key in remove_keys:
next_config.pop(key, None)
changed_keys = sorted(
key
for key in set(current_config.keys()) | set(next_config.keys())
if current_config.get(key) != next_config.get(key)
or (key in current_config) != (key in next_config)
)
if not await plugin_manager.async_save_plugin_config(plugin_id, next_config):
return json.dumps(
{
"success": False,
"message": f"保存插件 {plugin_id} 配置失败",
},
ensure_ascii=False,
)
return json.dumps(
{
"success": True,
**plugin_info,
"message": "插件配置已保存,请调用 reload_plugin 使最新配置生效",
"replace": replace,
"changed_keys": changed_keys,
"removed_keys": remove_keys,
"config_requires_reload": True,
"previous_config": current_config,
"saved_config": next_config,
},
ensure_ascii=False,
indent=2,
default=str,
)
async def run(
self,
plugin_id: str,
updates: Optional[Dict[str, Any]] = None,
remove_keys: Optional[List[str]] = None,
replace: bool = False,
**kwargs,
) -> str:
logger.info(
f"执行工具: {self.name}, 参数: plugin_id={plugin_id}, replace={replace}"
)
try:
return await self._update_plugin_config(
plugin_id, updates, remove_keys, replace
)
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

@@ -0,0 +1,157 @@
"""更新过滤规则组工具。"""
import json
from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.impl._filter_rule_utils import (
build_custom_rule_map,
collect_rule_group_usages,
get_builtin_rules,
get_custom_rules,
get_rule_groups,
normalize_rule_group,
rename_rule_group_references,
save_system_config,
serialize_rule_group,
)
from app.log import logger
from app.schemas.types import SystemConfigKey
class UpdateRuleGroupInput(BaseModel):
"""更新过滤规则组工具的输入参数模型"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
current_name: str = Field(..., description="Existing rule group name to update.")
new_name: Optional[str] = Field(
None,
description="New rule group name. If omitted, keep the original name.",
)
rule_string: Optional[str] = Field(
None,
description=(
"New rule_string. If omitted, keep the original rule_string. "
"Example: 'SPECSUB & CNVOI & 4K & !BLU > CNSUB & CNVOI & 4K & !BLU'."
),
)
media_type: Optional[str] = Field(
None,
description="New media type scope. Pass an empty string to clear it.",
)
category: Optional[str] = Field(
None,
description="New category. Pass an empty string to clear it.",
)
class UpdateRuleGroupTool(MoviePilotTool):
name: str = "update_rule_group"
description: str = (
"Update a filter rule group. "
"If the rule group name changes, its references in global search/subscription settings and per-subscription bindings are updated automatically. "
"Before changing rule_string, first use query_builtin_filter_rules and query_custom_filter_rules to confirm valid rule IDs."
)
args_schema: Type[BaseModel] = UpdateRuleGroupInput
require_admin: bool = True
def get_tool_message(self, **kwargs) -> Optional[str]:
current_name = kwargs.get("current_name", "")
new_name = kwargs.get("new_name")
if new_name and new_name != current_name:
return f"更新规则组 {current_name} -> {new_name}"
return f"更新规则组 {current_name}"
async def run(
self,
current_name: str,
new_name: Optional[str] = None,
rule_string: Optional[str] = None,
media_type: Optional[str] = None,
category: Optional[str] = None,
**kwargs,
) -> str:
logger.info(f"执行工具: {self.name}, current_name={current_name}")
try:
rule_groups = get_rule_groups()
group_map = {group.name: group for group in rule_groups if group.name}
current_group = group_map.get(current_name)
if not current_group:
return json.dumps(
{
"success": False,
"message": f"规则组 '{current_name}' 不存在",
},
ensure_ascii=False,
)
available_rule_ids = set(get_builtin_rules().keys()) | set(
build_custom_rule_map(get_custom_rules()).keys()
)
updated_group, _ = normalize_rule_group(
name=new_name or current_group.name,
rule_string=(
rule_string
if rule_string is not None
else current_group.rule_string
),
media_type=(
media_type
if media_type is not None
else current_group.media_type
),
category=(
category if category is not None else current_group.category
),
existing_groups=rule_groups,
available_rule_ids=available_rule_ids,
original_name=current_group.name,
)
final_groups = []
for group in rule_groups:
if group.name == current_group.name:
final_groups.append(updated_group)
else:
final_groups.append(group)
await save_system_config(
SystemConfigKey.UserFilterRuleGroups,
[group.model_dump(exclude_none=True) for group in final_groups],
)
reference_changes = {}
if updated_group.name != current_group.name:
reference_changes = await rename_rule_group_references(
current_group.name,
updated_group.name,
)
usage = await collect_rule_group_usages([updated_group.name])
return json.dumps(
{
"success": True,
"message": f"已更新规则组 {updated_group.name}",
"rule_group": serialize_rule_group(
updated_group, usage.get(updated_group.name)
),
"reference_updates": reference_changes,
},
ensure_ascii=False,
indent=2,
)
except Exception as exc:
logger.error(f"更新规则组失败: {exc}", exc_info=True)
return json.dumps(
{
"success": False,
"message": f"更新规则组失败: {exc}",
},
ensure_ascii=False,
)

View File

@@ -47,6 +47,28 @@ class UpdateSiteCookieTool(MoviePilotTool):
return message
@staticmethod
def _update_site_cookie_sync(
site_identifier: int,
username: str,
password: str,
two_step_code: Optional[str] = None,
) -> tuple[Optional[str], bool, str]:
"""
在同步线程里执行站点登录和 Cookie 更新,避免网络登录阻塞协程。
"""
site = SiteOper().get(site_identifier)
if not site:
return None, False, f"未找到站点:{site_identifier},请使用 query_sites 工具查询可用的站点"
status, message = SiteChain().update_cookie(
site_info=site,
username=username,
password=password,
two_step_code=two_step_code,
)
return site.name, status, message
async def run(
self,
site_identifier: int,
@@ -60,25 +82,21 @@ class UpdateSiteCookieTool(MoviePilotTool):
)
try:
site_oper = SiteOper()
site_chain = SiteChain()
site = await site_oper.async_get(site_identifier)
if not site:
return f"未找到站点:{site_identifier},请使用 query_sites 工具查询可用的站点"
# 更新站点Cookie和UA
status, message = site_chain.update_cookie(
site_info=site,
username=username,
password=password,
two_step_code=two_step_code,
site_name, status, message = await self.run_blocking(
"site",
self._update_site_cookie_sync,
site_identifier,
username,
password,
two_step_code,
)
if not site_name:
return message
if status:
return f"站点【{site.name}】Cookie和UA更新成功\n{message}"
return f"站点【{site_name}】Cookie和UA更新成功\n{message}"
else:
return f"站点【{site.name}】Cookie和UA更新失败\n错误原因:{message}"
return f"站点【{site_name}】Cookie和UA更新失败\n错误原因:{message}"
except Exception as e:
logger.error(f"更新站点Cookie和UA失败: {e}", exc_info=True)
return f"更新站点Cookie和UA时发生错误: {str(e)}"

View File

@@ -2,6 +2,7 @@ import json
import uuid
from typing import Any, Dict, List, Optional
from app.agent.tools.base import format_tool_result_for_agent
from app.agent.tools.factory import MoviePilotToolFactory
from app.log import logger
@@ -237,22 +238,14 @@ class MoviePilotToolsManager:
# 规范化参数类型
normalized_arguments = self._normalize_arguments(tool_instance, arguments)
# 调用工具的run方法
# 调用工具的run方法。HTTP/MCP 工具调用不会经过 BaseTool._arun
# 因此这里也必须复用同一套返回值格式化和兜底截断逻辑。
result = await tool_instance.run(**normalized_arguments)
# 确保返回字符串
if isinstance(result, str):
formated_result = result
elif isinstance(result, (int, float)):
formated_result = str(result)
else:
try:
formated_result = json.dumps(result, ensure_ascii=False, indent=2)
except Exception as e:
logger.warning(f"结果转换为JSON失败: {e}, 使用字符串表示")
formated_result = str(result)
return formated_result
return format_tool_result_for_agent(
result,
tool_name=tool_name,
max_chars=getattr(tool_instance, "result_max_chars", None),
)
except Exception as e:
logger.error(f"调用工具 {tool_name} 时发生错误: {e}", exc_info=True)
error_msg = json.dumps(

View File

@@ -2,7 +2,7 @@ from fastapi import APIRouter
from app.api.endpoints import login, user, webhook, message, site, subscribe, \
media, douban, search, plugin, tmdb, history, system, download, dashboard, \
transfer, mediaserver, bangumi, storage, discover, recommend, workflow, torrent, mcp, mfa, openai, anthropic
transfer, mediaserver, bangumi, storage, discover, recommend, workflow, torrent, mcp, mfa, openai, anthropic, llm, notification
api_router = APIRouter()
api_router.include_router(login.router, prefix="/login", tags=["login"])
@@ -18,6 +18,8 @@ api_router.include_router(douban.router, prefix="/douban", tags=["douban"])
api_router.include_router(tmdb.router, prefix="/tmdb", tags=["tmdb"])
api_router.include_router(history.router, prefix="/history", tags=["history"])
api_router.include_router(system.router, prefix="/system", tags=["system"])
api_router.include_router(notification.router, prefix="/notification", tags=["notification"])
api_router.include_router(llm.router, prefix="/llm", tags=["llm"])
api_router.include_router(plugin.router, prefix="/plugin", tags=["plugin"])
api_router.include_router(download.router, prefix="/download", tags=["download"])
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"])

View File

@@ -11,6 +11,7 @@ from app.core.security import verify_token
from app.db.models.user import User
from app.db.systemconfig_oper import SystemConfigOper
from app.db.user_oper import get_current_active_user
from app.helper.directory import DirectoryHelper
from app.schemas.types import SystemConfigKey
router = APIRouter()
@@ -76,12 +77,17 @@ def add(
# 元数据
metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)
# 媒体信息
mediainfo = MediaChain().select_recognize_source(
log_name=torrent_in.title,
log_context=torrent_in.title,
native_fn=lambda: MediaChain().recognize_media(meta=metainfo, tmdbid=tmdbid, doubanid=doubanid),
plugin_fn=lambda: MediaChain().recognize_help(title=torrent_in.title, org_meta=metainfo)
)
if tmdbid or doubanid:
mediainfo = MediaChain().recognize_media(
meta=metainfo,
tmdbid=tmdbid,
doubanid=doubanid,
)
else:
mediainfo = MediaChain().recognize_by_meta(
metainfo,
obtain_images=False,
)
if not mediainfo:
return schemas.Response(success=False, message="无法识别媒体信息")
# 种子信息
@@ -135,6 +141,29 @@ async def clients(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
return []
@router.get("/paths", summary="查询可用下载路径", response_model=List[schemas.DownloadDirectory])
def paths(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询可直接用于下载接口 save_path 参数的下载路径
"""
return [
schemas.DownloadDirectory(
name=dir_info.name,
storage=dir_info.storage or "local",
download_path=dir_info.download_path,
save_path=schemas.FileURI(
storage=dir_info.storage or "local",
path=dir_info.download_path,
).uri,
priority=dir_info.priority,
media_type=dir_info.media_type,
media_category=dir_info.media_category,
)
for dir_info in DirectoryHelper().get_download_dirs()
if dir_info.download_path
]
@router.delete("/{hashString}", summary="删除下载任务", response_model=schemas.Response)
def delete(hashString: str, name: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:

View File

@@ -1,14 +1,15 @@
import asyncio
import time
from pathlib import Path
from typing import List, Any, Optional
import jieba
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from pathlib import Path
from app import schemas
from app.agent import ReplyMode, prompt_manager, agent_manager
from app.chain.storage import StorageChain
from app.core.config import settings, global_vars
from app.core.event import eventmanager
@@ -24,13 +25,99 @@ from app.schemas.types import EventType
router = APIRouter()
def _start_ai_redo_task(history_id: int, progress_key: str):
from app.agent import agent_manager
def normalize_history_ids(history_ids: list[int]) -> list[int]:
"""对输入的历史记录 ID 列表进行规范化处理,去除重复项并保持原有顺序。"""
normalized_ids: list[int] = []
for history_id in history_ids:
if history_id not in normalized_ids:
normalized_ids.append(history_id)
return normalized_ids
def build_manual_redo_template_context(history: TransferHistory) -> dict[str, int | str]:
"""仅负责把整理历史对象映射成 System Tasks 需要的模板变量。"""
src_fileitem = history.src_fileitem or {}
source_path = src_fileitem.get("path") if isinstance(src_fileitem, dict) else ""
source_path = source_path or history.src or ""
season_episode = f"{history.seasons or ''}{history.episodes or ''}".strip()
return {
"history_id": history.id,
"current_status": "success" if history.status else "failed",
"recognized_title": history.title or "unknown",
"media_type": history.type or "unknown",
"category": history.category or "unknown",
"year": history.year or "unknown",
"season_episode": season_episode or "unknown",
"source_path": source_path or "unknown",
"source_storage": history.src_storage or "local",
"destination_path": history.dest or "unknown",
"destination_storage": history.dest_storage or "unknown",
"transfer_mode": history.mode or "unknown",
"tmdbid": history.tmdbid or "none",
"doubanid": history.doubanid or "none",
"error_message": history.errmsg or "none",
}
def format_manual_redo_record_context(history: Any) -> str:
"""把单条整理记录格式化为批量任务可直接消费的上下文块。"""
context = build_manual_redo_template_context(history)
return "\n".join(
[
f"Record #{context['history_id']}:",
f"- Current status: {context['current_status']}",
f"- Current recognized title: {context['recognized_title']}",
f"- Media type: {context['media_type']}",
f"- Category: {context['category']}",
f"- Year: {context['year']}",
f"- Season/Episode: {context['season_episode']}",
f"- Source path: {context['source_path']}",
f"- Source storage: {context['source_storage']}",
f"- Destination path: {context['destination_path']}",
f"- Destination storage: {context['destination_storage']}",
f"- Transfer mode: {context['transfer_mode']}",
f"- Current TMDB ID: {context['tmdbid']}",
f"- Current Douban ID: {context['doubanid']}",
f"- Error message: {context['error_message']}",
]
)
def build_manual_redo_prompt(history: Any) -> str:
"""构建手动 AI 整理提示词。"""
return prompt_manager.render_system_task_message(
"manual_transfer_redo",
template_context=build_manual_redo_template_context(history),
)
def build_batch_manual_redo_template_context(
histories: list[Any],
) -> dict[str, int | str]:
"""仅负责把多条整理历史对象映射成批量 System Tasks 需要的模板变量。"""
return {
"history_ids_csv": ", ".join(str(history.id) for history in histories),
"history_count": len(histories),
"records_context": "\n\n".join(
format_manual_redo_record_context(history) for history in histories
),
}
def build_batch_manual_redo_prompt(histories: list[Any]) -> str:
"""构建批量手动 AI 整理提示词。"""
return prompt_manager.render_system_task_message(
"batch_manual_transfer_redo",
template_context=build_batch_manual_redo_template_context(histories),
)
def _start_ai_redo_task(history_id: int, prompt: str, progress_key: str):
"""在后台线程中启动单条 AI 重新整理任务,并通过 ProgressHelper 实时更新进度。"""
progress = ProgressHelper(progress_key)
progress.start()
progress.update(
text=f"智能助正在准备整理记录 #{history_id} ...",
text=f"智能助正在准备整理记录 #{history_id} ...",
data={"history_id": history_id, "success": True},
)
@@ -39,9 +126,13 @@ def _start_ai_redo_task(history_id: int, progress_key: str):
async def runner():
try:
await agent_manager.manual_redo_transfer(
history_id=history_id,
await agent_manager.run_background_prompt(
message=prompt,
session_prefix=f"__agent_manual_redo_{history_id}",
output_callback=update_output,
reply_mode=ReplyMode.CAPTURE_ONLY,
persist_output_message=False,
allow_message_tools=False,
)
progress.update(
text="智能助手整理完成",
@@ -63,6 +154,52 @@ def _start_ai_redo_task(history_id: int, progress_key: str):
asyncio.run_coroutine_threadsafe(runner(), global_vars.loop)
def _start_batch_ai_redo_task(
history_ids: list[int],
prompt: str,
progress_key: str,
):
"""在后台线程中启动批量 AI 重新整理任务,并通过 ProgressHelper 实时更新进度。"""
progress = ProgressHelper(progress_key)
progress.start()
progress.update(
text=f"智能助手正在准备批量整理 {len(history_ids)} 条记录 ...",
data={"history_ids": history_ids, "success": True},
)
def update_output(text: str):
progress.update(text=text, data={"history_ids": history_ids})
async def runner():
try:
await agent_manager.run_background_prompt(
message=prompt,
session_prefix="__agent_manual_redo_batch",
output_callback=update_output,
reply_mode=ReplyMode.CAPTURE_ONLY,
persist_output_message=False,
allow_message_tools=False,
)
progress.update(
text="智能助手批量整理完成",
data={"history_ids": history_ids, "success": True, "completed": True},
)
except Exception as e:
progress.update(
text=f"智能助手批量整理失败:{str(e)}",
data={
"history_ids": history_ids,
"success": False,
"completed": True,
"error": str(e),
},
)
finally:
progress.end()
asyncio.run_coroutine_threadsafe(runner(), global_vars.loop)
@router.get("/download", summary="查询下载历史记录", response_model=List[schemas.DownloadHistory])
async def download_history(page: Optional[int] = 1,
count: Optional[int] = 30,
@@ -159,9 +296,9 @@ def delete_transfer_history(history_in: schemas.TransferHistory,
@router.post("/transfer/{history_id}/ai-redo", summary="智能助手重新整理", response_model=schemas.Response)
def ai_redo_transfer_history(
history_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_active_superuser),
history_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_active_superuser),
) -> Any:
"""
手动触发单条历史记录的 AI 重新整理,并返回进度键。
@@ -173,12 +310,62 @@ def ai_redo_transfer_history(
if not history:
return schemas.Response(success=False, message="整理记录不存在")
prompt = build_manual_redo_prompt(history)
progress_key = f"ai_redo_transfer_{history_id}_{int(time.time() * 1000)}"
_start_ai_redo_task(history_id=history_id, progress_key=progress_key)
_start_ai_redo_task(
history_id=history_id,
prompt=prompt,
progress_key=progress_key,
)
return schemas.Response(success=True, data={"progress_key": progress_key})
@router.post("/transfer/ai-redo", summary="智能助手批量重新整理", response_model=schemas.Response)
def batch_ai_redo_transfer_history(
payload: schemas.BatchTransferHistoryRedoRequest,
db: Session = Depends(get_db),
_: User = Depends(get_current_active_superuser),
) -> Any:
"""
手动触发多条历史记录的 AI 批量重新整理,并返回进度键。
"""
if not settings.AI_AGENT_ENABLE:
return schemas.Response(success=False, message="MoviePilot智能助手未启用")
history_ids = normalize_history_ids(payload.history_ids)
if not history_ids:
return schemas.Response(success=False, message="未提供有效的整理记录")
histories = []
missing_ids = []
for history_id in history_ids:
history = TransferHistory.get(db, history_id)
if not history:
missing_ids.append(history_id)
continue
histories.append(history)
if missing_ids:
return schemas.Response(
success=False,
message="整理记录不存在: " + ", ".join(str(history_id) for history_id in missing_ids),
)
prompt = build_batch_manual_redo_prompt(histories)
progress_key = f"ai_redo_transfer_batch_{int(time.time() * 1000)}"
_start_batch_ai_redo_task(
history_ids=history_ids,
prompt=prompt,
progress_key=progress_key,
)
return schemas.Response(
success=True,
data={"progress_key": progress_key, "history_ids": history_ids},
)
@router.get("/empty/transfer", summary="清空整理记录", response_model=schemas.Response)
async def empty_transfer_history(db: AsyncSession = Depends(get_async_db),
_: User = Depends(get_current_active_superuser_async)) -> Any:

289
app/api/endpoints/llm.py Normal file
View File

@@ -0,0 +1,289 @@
import re
from typing import Annotated, Optional
from fastapi import APIRouter, Body, Depends, Request
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from app import schemas
from app.agent.llm import (
LLMHelper,
LLMProviderManager,
LLMTestTimeout,
render_auth_result_html,
)
from app.core.config import settings
from app.db.models import User
from app.db.user_oper import (
get_current_active_superuser_async,
get_current_active_user_async,
)
from app.log import logger
router = APIRouter()
class LlmTestRequest(BaseModel):
enabled: Optional[bool] = None
provider: Optional[str] = None
model: Optional[str] = None
thinking_level: Optional[str] = None
api_key: Optional[str] = None
base_url: Optional[str] = None
base_url_preset: Optional[str] = None
class LlmProviderAuthStartRequest(BaseModel):
provider: str
method: str
def _sanitize_llm_test_error(message: str, api_key: Optional[str] = None) -> str:
"""
清理错误信息中的敏感字段,避免回显密钥。
"""
if not message:
return "LLM 调用失败"
sanitized = message
if api_key:
sanitized = sanitized.replace(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,
)
return sanitized
@router.get("/models", summary="获取LLM模型列表", response_model=schemas.Response)
async def get_llm_models(
provider: str,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
base_url_preset: Optional[str] = None,
force_refresh: Optional[bool] = False,
_: User = Depends(get_current_active_user_async),
):
"""
获取指定 provider 的模型目录。
"""
try:
provider_manager = LLMProviderManager()
models = await LLMHelper().get_models(
provider=provider,
api_key=api_key,
base_url=base_url,
base_url_preset=base_url_preset,
force_refresh=bool(force_refresh),
)
return schemas.Response(
success=True,
data={
"provider": provider,
"models": models,
"auth_status": provider_manager.get_auth_status(provider),
},
)
except Exception as err:
return schemas.Response(success=False, message=str(err))
@router.get("/providers", summary="获取LLM提供商目录", response_model=schemas.Response)
async def get_llm_providers(
_: User = Depends(get_current_active_user_async),
):
"""
返回前端可直接渲染的 provider 目录。
"""
try:
providers = await LLMProviderManager().list_providers_async()
return schemas.Response(success=True, data=providers)
except Exception as err:
return schemas.Response(success=False, message=str(err))
@router.post(
"/provider-auth/start",
summary="启动LLM提供商授权",
response_model=schemas.Response,
)
async def start_llm_provider_auth(
payload: LlmProviderAuthStartRequest,
request: Request,
_: User = Depends(get_current_active_superuser_async),
):
"""
启动 provider 授权会话。
"""
try:
callback_url = None
if payload.provider == "chatgpt" and payload.method == "browser_oauth":
callback_url = str(
request.url_for("llm_provider_auth_callback", provider_id=payload.provider)
)
result = await LLMProviderManager().start_auth(
payload.provider,
payload.method,
callback_url,
)
return schemas.Response(success=True, data=result)
except Exception as err:
return schemas.Response(success=False, message=str(err))
@router.get(
"/provider-auth/{session_id}",
summary="获取LLM提供商授权会话状态",
response_model=schemas.Response,
)
async def get_llm_provider_auth_session(
session_id: str,
_: User = Depends(get_current_active_superuser_async),
):
"""
查询授权会话状态。
"""
try:
result = LLMProviderManager().get_session_status(session_id)
return schemas.Response(success=True, data=result)
except Exception as err:
return schemas.Response(success=False, message=str(err))
@router.post(
"/provider-auth/{session_id}/poll",
summary="轮询LLM提供商授权会话",
response_model=schemas.Response,
)
async def poll_llm_provider_auth_session(
session_id: str,
_: User = Depends(get_current_active_superuser_async),
):
"""
轮询 device code / OAuth 会话状态。
"""
try:
result = await LLMProviderManager().poll_auth_session(session_id)
return schemas.Response(success=True, data=result)
except Exception as err:
return schemas.Response(success=False, message=str(err))
@router.delete(
"/provider-auth/{provider_id}",
summary="断开LLM提供商授权",
response_model=schemas.Response,
)
async def delete_llm_provider_auth(
provider_id: str,
_: User = Depends(get_current_active_superuser_async),
):
"""
删除已保存的 provider 授权信息。
"""
try:
await LLMProviderManager().clear_auth(provider_id)
return schemas.Response(success=True)
except Exception as err:
return schemas.Response(success=False, message=str(err))
@router.get(
"/provider-auth/callback/{provider_id}",
summary="LLM提供商OAuth回调",
response_class=HTMLResponse,
name="llm_provider_auth_callback",
)
async def llm_provider_auth_callback(
provider_id: str,
code: Optional[str] = None,
state: Optional[str] = None,
error: Optional[str] = None,
error_description: Optional[str] = None,
):
"""
处理需要浏览器回跳的 OAuth provider。
"""
success, message = await LLMProviderManager().handle_chatgpt_callback(
provider_id,
code,
state,
error,
error_description,
)
return HTMLResponse(content=render_auth_result_html(success, message))
@router.post("/test", summary="测试LLM调用", response_model=schemas.Response)
async def llm_test(
payload: Annotated[Optional[LlmTestRequest], Body()] = None,
_: User = Depends(get_current_active_superuser_async),
):
"""
使用传入配置或当前已保存配置执行一次最小 LLM 调用。
"""
payload = payload or LlmTestRequest(
enabled=settings.AI_AGENT_ENABLE,
provider=settings.LLM_PROVIDER,
model=settings.LLM_MODEL,
thinking_level=settings.LLM_THINKING_LEVEL,
api_key=settings.LLM_API_KEY,
base_url=settings.LLM_BASE_URL,
base_url_preset=settings.LLM_BASE_URL_PRESET,
)
if not payload.provider:
return schemas.Response(success=False, message="请配置LLM提供商和模型")
if not payload.model or not payload.model.strip():
return schemas.Response(success=False, message="请先配置 LLM 模型")
data = {
"provider": payload.provider,
"model": payload.model,
}
if not payload.enabled:
return schemas.Response(success=False, message="请先启用智能助手", data=data)
if (
payload.provider not in {"chatgpt", "github-copilot"}
and (not payload.api_key or not payload.api_key.strip())
):
return schemas.Response(
success=False,
message="请先配置 LLM API Key",
data=data,
)
try:
result = await LLMHelper.test_current_settings(
provider=payload.provider,
model=payload.model,
thinking_level=payload.thinking_level,
api_key=payload.api_key,
base_url=payload.base_url,
base_url_preset=payload.base_url_preset,
)
if not result.get("reply_preview"):
return schemas.Response(
success=False,
message="模型响应为空",
data=result,
)
return schemas.Response(success=True, data=result)
except (LLMTestTimeout, TimeoutError) as err:
logger.warning(err)
return schemas.Response(
success=False,
message="LLM 调用超时",
)
except Exception as err:
return schemas.Response(
success=False,
message=_sanitize_llm_test_error(str(err), payload.api_key),
)

View File

@@ -9,7 +9,7 @@ from app.chain.tmdb import TmdbChain
from app.core.config import settings
from app.core.context import Context
from app.core.event import eventmanager
from app.core.metainfo import MetaInfo, MetaInfoPath
from app.core.metainfo import MetaInfo
from app.core.security import verify_token, verify_apitoken
from app.db.models import User
from app.db.user_oper import get_current_active_user, get_current_active_superuser
@@ -121,16 +121,19 @@ def scrape(fileitem: schemas.FileItem,
return schemas.Response(success=False, message="刮削路径无效")
chain = MediaChain()
# 识别媒体信息
scrape_path = Path(fileitem.path)
meta = MetaInfoPath(scrape_path)
mediainfo = chain.recognize_by_meta(meta)
if not mediainfo:
context = chain.recognize_by_path(fileitem.path, obtain_images=True)
if not context or not context.media_info:
return schemas.Response(success=False, message="刮削失败,无法识别媒体信息")
if storage == "local":
if not scrape_path.exists():
if not Path(fileitem.path).exists():
return schemas.Response(success=False, message="刮削路径不存在")
# 手动刮削 (暂时使用同步版本,可以后续优化为异步)
chain.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo, overwrite=True)
chain.scrape_metadata(
fileitem=fileitem,
meta=context.meta_info,
mediainfo=context.media_info,
overwrite=True
)
return schemas.Response(success=True, message=f"{fileitem.path} 刮削完成")
@@ -202,7 +205,11 @@ async def seasons(mediaid: Optional[str] = None,
meta = MetaInfo(title)
if year:
meta.year = year
mediainfo = await MediaChain().async_recognize_media(meta, mtype=MediaType.TV)
meta.type = MediaType.TV
mediainfo = await MediaChain().async_recognize_by_meta(
meta,
obtain_images=False,
)
if mediainfo:
if settings.RECOGNIZE_SOURCE == "themoviedb":
seasons_info = await TmdbChain().async_tmdb_seasons(tmdbid=mediainfo.tmdb_id)
@@ -261,7 +268,10 @@ async def detail(mediaid: str, type_name: str, title: Optional[str] = None, year
meta.year = year
if mtype:
meta.type = mtype
mediainfo = await mediachain.async_recognize_media(meta=meta)
mediainfo = await mediachain.async_recognize_by_meta(
meta,
obtain_images=False,
)
# 识别
if mediainfo:
await mediachain.async_obtain_images(mediainfo)

View File

@@ -0,0 +1,238 @@
from typing import Optional
from fastapi import APIRouter, Depends
from app import schemas
from app.core.module import ModuleManager
from app.db.models import User
from app.db.user_oper import get_current_active_superuser
from app.modules.wechatclawbot.wechatclawbot import WechatClawBot
router = APIRouter()
def _build_wechatclawbot_temp_client(
source: Optional[str] = None,
WECHATCLAWBOT_BASE_URL: Optional[str] = None,
WECHATCLAWBOT_DEFAULT_TARGET: Optional[str] = None,
WECHATCLAWBOT_ADMINS: Optional[str] = None,
WECHATCLAWBOT_POLL_TIMEOUT: Optional[int] = None,
):
"""基于当前表单配置创建一个临时客户端,用于未保存时的扫码状态预览。"""
source_name = str(source or "").strip()
if not source_name:
return None
return WechatClawBot(
name=source_name,
WECHATCLAWBOT_BASE_URL=WECHATCLAWBOT_BASE_URL,
WECHATCLAWBOT_DEFAULT_TARGET=WECHATCLAWBOT_DEFAULT_TARGET,
WECHATCLAWBOT_ADMINS=WECHATCLAWBOT_ADMINS,
WECHATCLAWBOT_POLL_TIMEOUT=WECHATCLAWBOT_POLL_TIMEOUT,
auto_start_polling=False,
)
def _get_wechatclawbot_client(
source: Optional[str] = None,
fallback_source: Optional[str] = None,
WECHATCLAWBOT_BASE_URL: Optional[str] = None,
WECHATCLAWBOT_DEFAULT_TARGET: Optional[str] = None,
WECHATCLAWBOT_ADMINS: Optional[str] = None,
WECHATCLAWBOT_POLL_TIMEOUT: Optional[int] = None,
allow_temporary: bool = False,
):
"""获取已加载的微信 ClawBot 客户端,必要时退回到临时客户端。"""
module = ModuleManager().get_running_module("WechatClawBotModule")
source_name = str(source or "").strip() or None
fallback_name = str(fallback_source or "").strip() or None
if module:
candidate_names = []
for candidate in (fallback_name, source_name):
if candidate and candidate not in candidate_names:
candidate_names.append(candidate)
if candidate_names:
for candidate in candidate_names:
config = module.get_config(candidate)
if not config:
continue
client = module.get_instance(config.name)
if client:
return client, None
else:
client = module.get_instance()
if client:
return client, None
if allow_temporary:
temp_client = _build_wechatclawbot_temp_client(
source=source_name or fallback_name,
WECHATCLAWBOT_BASE_URL=WECHATCLAWBOT_BASE_URL,
WECHATCLAWBOT_DEFAULT_TARGET=WECHATCLAWBOT_DEFAULT_TARGET,
WECHATCLAWBOT_ADMINS=WECHATCLAWBOT_ADMINS,
WECHATCLAWBOT_POLL_TIMEOUT=WECHATCLAWBOT_POLL_TIMEOUT,
)
if temp_client:
return temp_client, None
if source_name:
return None, f"未找到名为 {source_name} 的微信 ClawBot 通知配置"
return None, "微信 ClawBot 通知未启用或配置尚未保存,请先保存并启用当前渠道"
@router.get(
"/wechatclawbot/status",
summary="查询微信 ClawBot 登录状态",
response_model=schemas.Response,
)
def wechatclawbot_status(
source: Optional[str] = None,
fallback_source: Optional[str] = None,
refresh_remote: bool = True,
auto_generate_qrcode: bool = True,
WECHATCLAWBOT_BASE_URL: Optional[str] = None,
WECHATCLAWBOT_DEFAULT_TARGET: Optional[str] = None,
WECHATCLAWBOT_ADMINS: Optional[str] = None,
WECHATCLAWBOT_POLL_TIMEOUT: Optional[int] = None,
_: User = Depends(get_current_active_superuser),
):
"""查询微信 ClawBot 登录状态和二维码。"""
client, errmsg = _get_wechatclawbot_client(
source=source,
fallback_source=fallback_source,
WECHATCLAWBOT_BASE_URL=WECHATCLAWBOT_BASE_URL,
WECHATCLAWBOT_DEFAULT_TARGET=WECHATCLAWBOT_DEFAULT_TARGET,
WECHATCLAWBOT_ADMINS=WECHATCLAWBOT_ADMINS,
WECHATCLAWBOT_POLL_TIMEOUT=WECHATCLAWBOT_POLL_TIMEOUT,
allow_temporary=True,
)
if not client:
return schemas.Response(success=False, message=errmsg)
return schemas.Response(
success=True,
data=client.get_status(
refresh_remote=refresh_remote,
auto_generate_qrcode=auto_generate_qrcode,
),
)
@router.post(
"/wechatclawbot/refresh",
summary="刷新微信 ClawBot 二维码",
response_model=schemas.Response,
)
def refresh_wechatclawbot_qrcode(
source: Optional[str] = None,
fallback_source: Optional[str] = None,
WECHATCLAWBOT_BASE_URL: Optional[str] = None,
WECHATCLAWBOT_DEFAULT_TARGET: Optional[str] = None,
WECHATCLAWBOT_ADMINS: Optional[str] = None,
WECHATCLAWBOT_POLL_TIMEOUT: Optional[int] = None,
_: User = Depends(get_current_active_superuser),
):
"""刷新微信 ClawBot 二维码。"""
client, errmsg = _get_wechatclawbot_client(
source=source,
fallback_source=fallback_source,
WECHATCLAWBOT_BASE_URL=WECHATCLAWBOT_BASE_URL,
WECHATCLAWBOT_DEFAULT_TARGET=WECHATCLAWBOT_DEFAULT_TARGET,
WECHATCLAWBOT_ADMINS=WECHATCLAWBOT_ADMINS,
WECHATCLAWBOT_POLL_TIMEOUT=WECHATCLAWBOT_POLL_TIMEOUT,
allow_temporary=True,
)
if not client:
return schemas.Response(success=False, message=errmsg)
result = client.refresh_qrcode()
return schemas.Response(
success=bool(result.get("success")),
message=result.get("message"),
data=result,
)
@router.post(
"/wechatclawbot/logout",
summary="退出微信 ClawBot 登录",
response_model=schemas.Response,
)
def logout_wechatclawbot(
source: Optional[str] = None,
fallback_source: Optional[str] = None,
WECHATCLAWBOT_BASE_URL: Optional[str] = None,
WECHATCLAWBOT_DEFAULT_TARGET: Optional[str] = None,
WECHATCLAWBOT_ADMINS: Optional[str] = None,
WECHATCLAWBOT_POLL_TIMEOUT: Optional[int] = None,
_: User = Depends(get_current_active_superuser),
):
"""退出微信 ClawBot 登录。"""
client, errmsg = _get_wechatclawbot_client(
source=source,
fallback_source=fallback_source,
WECHATCLAWBOT_BASE_URL=WECHATCLAWBOT_BASE_URL,
WECHATCLAWBOT_DEFAULT_TARGET=WECHATCLAWBOT_DEFAULT_TARGET,
WECHATCLAWBOT_ADMINS=WECHATCLAWBOT_ADMINS,
WECHATCLAWBOT_POLL_TIMEOUT=WECHATCLAWBOT_POLL_TIMEOUT,
allow_temporary=True,
)
if not client:
return schemas.Response(success=False, message=errmsg)
result = client.logout()
return schemas.Response(
success=bool(result.get("success")),
message=result.get("message"),
data=result,
)
@router.get(
"/wechatclawbot/test",
summary="测试微信 ClawBot 连通性",
response_model=schemas.Response,
)
def test_wechatclawbot(
source: Optional[str] = None,
fallback_source: Optional[str] = None,
WECHATCLAWBOT_BASE_URL: Optional[str] = None,
WECHATCLAWBOT_DEFAULT_TARGET: Optional[str] = None,
WECHATCLAWBOT_ADMINS: Optional[str] = None,
WECHATCLAWBOT_POLL_TIMEOUT: Optional[int] = None,
_: User = Depends(get_current_active_superuser),
):
"""测试微信 ClawBot 当前登录态是否可用。"""
client, errmsg = _get_wechatclawbot_client(
source=source,
fallback_source=fallback_source,
WECHATCLAWBOT_BASE_URL=WECHATCLAWBOT_BASE_URL,
WECHATCLAWBOT_DEFAULT_TARGET=WECHATCLAWBOT_DEFAULT_TARGET,
WECHATCLAWBOT_ADMINS=WECHATCLAWBOT_ADMINS,
WECHATCLAWBOT_POLL_TIMEOUT=WECHATCLAWBOT_POLL_TIMEOUT,
allow_temporary=True,
)
if not client:
return schemas.Response(success=False, message=errmsg)
state, message = client.test_connection()
return schemas.Response(success=state, message=message)
@router.post(
"/wechatclawbot/migrate",
summary="迁移微信 ClawBot 登录缓存",
response_model=schemas.Response,
)
def migrate_wechatclawbot_cache(
old_source: str,
new_source: str,
cleanup_old: bool = False,
overwrite: bool = False,
_: User = Depends(get_current_active_superuser),
):
"""在通知名称变更时迁移对应的微信 ClawBot 登录缓存。"""
success, message = WechatClawBot.migrate_cached_state(
old_name=old_source,
new_name=new_source,
cleanup_old=cleanup_old,
overwrite=overwrite,
)
return schemas.Response(success=success, message=message)

View File

@@ -69,9 +69,15 @@ class _OpenAIStreamingHandler(StreamingHandler):
self._event_queue = queue
def emit(self, token: str):
super().emit(token)
if token and self._event_queue is not None:
self._event_queue.put_nowait(token)
emitted = super().emit(token)
if emitted and self._event_queue is not None:
self._event_queue.put_nowait(emitted)
def flush_pending_tool_summary(self) -> str:
emitted = super().flush_pending_tool_summary()
if emitted and self._event_queue is not None:
self._event_queue.put_nowait(emitted)
return emitted
async def start_streaming(
self,

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