Compare commits

...

137 Commits

Author SHA1 Message Date
DDSRem
a0ee99aacc chore: bump moviepilot-rust to 0.2.3 (#6128) 2026-07-16 06:31:31 +08:00
InfinityPacer
92918ce380 ci(pr-agent): use shared review runner (#6127) 2026-07-16 06:24:52 +08:00
InfinityPacer
a4335fe753 fix(lifecycle): harden application shutdown (#6125) 2026-07-16 06:24:31 +08:00
jxxghp
107ba37834 更新 version.py 2026-07-15 20:22:27 +08:00
jxxghp
c27678ce06 fix(ugreen): send client id during login 2026-07-15 17:38:31 +08:00
InfinityPacer
7725342a80 fix(scheduler): refresh plugin jobs after reload (#6124) 2026-07-15 17:29:31 +08:00
InfinityPacer
893269f8c1 fix(modules): serialize configuration reload lifecycle (#6122) 2026-07-15 17:28:49 +08:00
InfinityPacer
00d46f3aab docs: clarify docstring punctuation style (#6121) 2026-07-15 16:01:47 +08:00
jxxghp
077241b6ed Merge remote-tracking branch 'origin/v2' into v2 2026-07-15 10:46:28 +08:00
jxxghp
b24a07e388 fix: enhance response data structure in filtering rules with media info 2026-07-15 10:46:21 +08:00
InfinityPacer
f814c271cc refactor(runtime): tighten resource cleanup and test isolation (#6116) 2026-07-14 16:03:29 +08:00
InfinityPacer
e015c67689 chore(db): add driver error diagnostics (#6115) 2026-07-14 12:31:46 +08:00
qqcomeup
98b16bda8d 优化 Docker 启动完成日志 (#6112) 2026-07-14 12:31:07 +08:00
jxxghp
b8233e1789 v2.14.3 2026-07-13 18:46:35 +08:00
InfinityPacer
83107bf447 ci(pr-agent): publish native code reviews (#6110) 2026-07-13 18:41:18 +08:00
jxxghp
3a2f90c567 fix(metainfo): improve regex for episode range recognition with end markers 2026-07-13 18:02:44 +08:00
DDSRem
4826e3301c chore: bump moviepilot-rust to 0.2.2 (#6109) 2026-07-13 18:00:50 +08:00
freeman
1855ba81ec fix(meta): 副标题识别 01-26Fin 等数字范围完结标记集数 (#6105) 2026-07-13 16:50:00 +08:00
jxxghp
96ef431efc feat: 支持豆瓣识别缓存管理 2026-07-13 12:33:56 +08:00
freeman
4f2935c85e fix(transfer): 种子未下载完成时不回写已整理标签 (#6106) 2026-07-13 12:05:01 +08:00
freeman
2a49495e27 fix(jellyfin): 媒体统计改为按用户视图逐库累计 (#5915) (#6104) 2026-07-13 11:51:16 +08:00
jxxghp
b628bc7209 fix: 补齐识别缓存多语言响应 2026-07-13 09:58:11 +08:00
jxxghp
29068a5846 feat: 支持 TheMovieDb 识别缓存管理 2026-07-13 09:48:08 +08:00
jxxghp
51a7120c79 完善 qBittorrent 临时标签清理 (#6093) 2026-07-12 16:51:46 +08:00
jxxghp
476dfef7d9 修复 qBittorrent 重复任务临时标签残留 (#6093) 2026-07-12 16:47:12 +08:00
jxxghp
bd5ddd6158 fix: remove standalone site collector download section from README 2026-07-12 16:38:53 +08:00
jxxghp
a30a48b8f4 fix: allow publishing collector artifacts manually 2026-07-12 16:37:05 +08:00
jxxghp
8e60e5571b fix: support Windows collector console encoding 2026-07-12 16:30:30 +08:00
jxxghp
18c1ec4b82 feat: add standalone site adapter collector 2026-07-12 13:51:07 +08:00
InfinityPacer
30b932e07e fix(subscribe): preserve confirmed episode floor (#6102) 2026-07-12 07:18:29 +08:00
InfinityPacer
54be1143fc feat(plugin): sync federated assets during local development (#6100) 2026-07-11 21:59:53 +08:00
秋澪Akimio
13f27854fd fix: clear Rust parse options cache after updating custom identifiers (#6097) 2026-07-11 18:15:26 +08:00
InfinityPacer
770201c48c fix(plugin): exclude build dependencies from runtime copies (#6096) 2026-07-11 18:15:00 +08:00
Xuanjie Xia
685f044312 fix: 模拟登录时页面跳转导致 page.content() 竞态失败(未知错误) (#6091) 2026-07-10 12:44:36 +08:00
qqcomeup
8c0afac5d1 feat: support prompt-bound plugin input replies (#6087) 2026-07-09 12:52:22 +08:00
qqcomeup
099ef7d5bf fix: avoid blocking plugin release history refresh (#6084) 2026-07-08 12:51:24 +08:00
InfinityPacer
f3ac69669c ci(pr-agent): simplify review workflow (#6082) 2026-07-08 12:49:19 +08:00
jxxghp
eb4ecd990a fix: restore full test suite 2026-07-08 08:54:53 +08:00
jxxghp
b51971ee7d feat: add agent MCP support 2026-07-08 08:44:33 +08:00
InfinityPacer
6f6ed998bb ci(pr-agent): align inline review workflow (#6079) 2026-07-08 07:04:39 +08:00
drdon1234
844407dc41 修复 qBittorrent 已完成但未做种任务识别 (#6076) 2026-07-08 07:01:03 +08:00
jxxghp
c54605f8ce fix: support ugreen token_id login response 2026-07-07 20:15:19 +08:00
qqcomeup
0fbf05d72f fix: handle Telegram urllib3 header formatter compatibility (#6074) 2026-07-07 19:58:49 +08:00
jxxghp
09bb32f681 fix: cool down failed subscription resources 2026-07-07 17:07:13 +08:00
qqcomeup
a37f118576 perf(docker): skip image path chown by default (#6071) 2026-07-07 16:25:43 +08:00
jxxghp
e635bc8e04 chore: bump version to v2.14.2 2026-07-07 12:48:23 +08:00
jxxghp
8245124e82 support scheduler progress i18n (#6069) 2026-07-07 06:04:45 +08:00
qqcomeup
827ed8330c feat: add plugin-scoped text input sessions (#6068) 2026-07-07 05:15:18 +08:00
jxxghp
136c1baed3 fix: keep new subscribe state during guard 2026-07-06 23:10:25 +08:00
jxxghp
992031ef95 Revert "fix: trigger initial subscribe search"
This reverts commit 76803ae7a3.
2026-07-06 19:15:16 +08:00
jxxghp
b16c50b03a feat: add backend i18n response support 2026-07-06 19:13:50 +08:00
jxxghp
76803ae7a3 fix: trigger initial subscribe search 2026-07-06 19:02:01 +08:00
jxxghp
56bda11947 fix python metainfo xxx title parsing 2026-07-06 12:36:06 +08:00
jxxghp
1b12d7664e fix: avoid cnsub matching file size unit 2026-07-06 11:58:03 +08:00
jxxghp
db9960d9b9 refactor: enhance customization and streaming platform handling 2026-07-06 09:33:49 +08:00
InfinityPacer
2f0c1252da feat(subscribe): expose cached site candidates (#6062) 2026-07-06 06:54:19 +08:00
jxxghp
36d4434596 fix: allow manage users to access management APIs 2026-07-05 20:23:04 +08:00
jxxghp
93e907d032 fix: remove openlist directory via fs remove 2026-07-05 17:24:44 +08:00
jxxghp
132f27c1c6 fix: retry qbittorrent files after add 2026-07-05 17:15:07 +08:00
jxxghp
b231ad415f fix title search filter rules 2026-07-05 17:02:19 +08:00
jxxghp
0f183ae08e test: fix download save path fixtures 2026-07-05 14:56:19 +08:00
jxxghp
a71d3ea03f feat: support llm temperature override 2026-07-05 13:04:51 +08:00
InfinityPacer
7f82a9ea4d fix(security): scope subscriptions to owner (#6056) 2026-07-05 09:45:27 +08:00
InfinityPacer
d977e4c48a fix(security): require signed subtitle downloads (#6055) 2026-07-05 09:43:58 +08:00
InfinityPacer
95b6adbeee fix(security): restrict download save paths (#6054) 2026-07-05 09:31:01 +08:00
InfinityPacer
964fee1106 fix(security): protect cookiecloud update uploads (#6053) 2026-07-05 09:28:07 +08:00
InfinityPacer
656473f3aa fix(security): require manage access for workflows (#6052) 2026-07-05 09:27:24 +08:00
InfinityPacer
ab5995a609 fix(security): contain plugin zip extraction (#6051) 2026-07-05 09:26:09 +08:00
InfinityPacer
064e6535d5 fix(security): restrict agent log file access (#6050) 2026-07-05 09:25:25 +08:00
InfinityPacer
cab2ac400a fix(security): rebind resource cookie on user changes (#6049) 2026-07-05 09:24:27 +08:00
InfinityPacer
d14d401c86 docs(security): clarify api token admin contract (#6048) 2026-07-05 09:22:59 +08:00
Album
6c3c5e042d 修复手动整理按集数定位模板过滤 (#6043) 2026-07-03 07:55:59 +08:00
Copilot
f3e5be37fd chore: bump backend and frontend versions (#6042) 2026-07-02 20:50:15 +08:00
jxxghp
d8f7fa70af fix: normalize question marks in title matching 2026-07-02 08:51:16 +08:00
jxxghp
6916ee0988 修复电影合集整理识别错误 2026-07-02 08:06:44 +08:00
LeChristopher Blackwell
6fef533527 fix(security): honor X-Forwarded-Proto when setting resource_token cookie secure flag (#6038) 2026-07-01 22:00:22 +08:00
G0m3e
c57985d553 fix(webpush): add WNS cache policy for Windows Edge push (#6034)
WNS rejects pywebpush default ttl=0 with 400 Bad Request unless X-WNS-Cache-Policy matches TTL; iOS/APNs endpoints are unaffected.

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

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

This reverts commit 109be58abe.

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

* feat(deps): support package cache root
2026-06-23 13:36:15 +08:00
jxxghp
126279c63b Handle Audiences new message list previews 2026-06-23 12:54:13 +08:00
jxxghp
d7aa66853a fix: 优化同名字幕附加整理 2026-06-23 12:07:47 +08:00
jxxghp
d18c2d6f72 fix: skip history for interaction exit notices 2026-06-23 12:05:27 +08:00
jxxghp
3e3883a57f Fix history search case sensitivity 2026-06-23 11:16:12 +08:00
jxxghp
2ebe7c27c2 Add repo_url backfill for installed plugin queries 2026-06-23 10:49:12 +08:00
jxxghp
0cd049bfc2 Refactor movie pilot config and test coverage 2026-06-23 10:05:45 +08:00
jxxghp
dc773337d3 fix: bind browser sessions to dedicated worker threads 2026-06-22 22:18:12 +08:00
jxxghp
5c649ff1d1 fix(search): suppress linter warning for SitesHelper import 2026-06-22 21:53:41 +08:00
jxxghp
3407cc8edd Preserve empty tool selections 2026-06-22 21:29:15 +08:00
jxxghp
f9ea0118d9 Handle Telegramify 1.2 compatibility 2026-06-22 20:16:37 +08:00
jxxghp
ad73434e2c fix(agent): stabilize tool selector logging 2026-06-22 19:06:14 +08:00
jxxghp
a6afa0fbc0 fix(agent): log tool selector results 2026-06-22 18:48:04 +08:00
jxxghp
3306d196b7 Refine existing implementation 2026-06-22 18:21:20 +08:00
InfinityPacer
e44a6f41b5 chore: split runtime and development dependencies (#5985) 2026-06-22 17:44:06 +08:00
jxxghp
7a7b27858e Remove redundant Cython Docker install 2026-06-22 14:06:19 +08:00
378 changed files with 34481 additions and 4283 deletions

View File

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

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

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

View File

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

View File

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

View File

@@ -38,16 +38,15 @@ jobs:
uses: actions/cache@v5
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.in', '**/requirements.txt') }}
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.in', '**/requirements-dev.in', '**/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install dependencies
run: |
python -m pip install --upgrade pip setuptools wheel
# 用 requirements.in 还原 CI / 全新环境(含 pytest 与 moviepilot-rust 等可选扩展),
# 与本地"干净 venv 复现"一致;测试运行器 pytest 已在 requirements.in 中声明。
pip install -r requirements.in
# 单测需要开发/测试依赖;运行时入口 requirements.in 不携带测试与构建辅助工具。
pip install -r requirements-dev.in
- name: Run tests
timeout-minutes: 10

4
.gitignore vendored
View File

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

View File

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

View File

@@ -58,6 +58,7 @@ Before contributing, read the repository rules and local environment guide, keep
- Rule index: [docs/rules/README.md](docs/rules/README.md)
- Development setup and local source run: [docs/development-setup.md](docs/development-setup.md)
- Testing guide: [docs/testing.md](docs/testing.md)
- New site adapter capture and Feature Request submission: [docs/site-adapter-capture.md](docs/site-adapter-capture.md)
- REST API documentation: https://api.movie-pilot.org
- Plugin development guide: https://wiki.movie-pilot.org/zh/plugindev

View File

@@ -1,4 +1,5 @@
import asyncio
import hashlib
import json
import re
import traceback
@@ -50,7 +51,9 @@ from app.agent.middleware.tool_selection import ToolSelectorMiddleware
from app.agent.middleware.usage import UsageMiddleware
from app.agent.prompt import prompt_manager
from app.agent.runtime import agent_runtime_manager
from app.agent.mcp import agent_mcp_manager
from app.agent.tools.factory import MoviePilotToolFactory
from app.agent.tools.impl.mcp import create_external_mcp_tools
from app.chain import ChainBase
from app.core.config import settings
from app.core.event import eventmanager
@@ -148,6 +151,16 @@ class _SessionUsageSnapshot:
}
@dataclass
class _CompiledAgentBundle:
"""会话内可复用的 Agent 图及其构造签名。"""
signature: tuple[Any, ...]
agent: Any
streaming: bool
created_at: datetime
class _ThinkTagStripper:
"""
流式剥离 <think>...</think> 标签的辅助类。
@@ -280,6 +293,8 @@ class MoviePilotAgent:
self._llm_runtime_config: Optional[Dict[str, Any]] = None
self._llm_provider_selection: Dict[str, Any] = {}
self._agent_started_at: Optional[datetime] = None
self._compiled_agent_bundle: Optional[_CompiledAgentBundle] = None
self._last_agent_cache_hit = False
# 流式token管理
self.stream_handler = StreamingHandler()
@@ -838,6 +853,8 @@ class MoviePilotAgent:
detail = cls._exception_detail_text(error).lower()
if "no endpoints found that support image input" in detail:
return True
if "not a vlm" in detail or "text-only prompts" in detail:
return True
if "unknown variant" in detail and "image_url" in detail:
return True
if "image input" not in detail and "images" not in detail:
@@ -978,6 +995,91 @@ class MoviePilotAgent:
allow_message_tools=self.allow_message_tools,
)
def _refresh_tool_context(self, values: Dict[str, object]) -> None:
"""
刷新本轮工具共享上下文。
工具对象可能随会话内 Agent 图缓存被复用,因此这里保留 dict 对象本身,
只替换其中内容,确保缓存工具看到的是最新权限与回复状态。
"""
self._tool_context.clear()
self._tool_context.update(values)
@staticmethod
def _public_runtime_config_signature(runtime_config: Dict[str, Any]) -> tuple:
"""生成不包含密钥明文的 LLM 运行时签名。"""
api_key = runtime_config.get("api_key") or ""
api_key_digest = (
hashlib.sha256(str(api_key).encode("utf-8")).hexdigest()[:12]
if api_key
else ""
)
return (
runtime_config.get("provider"),
runtime_config.get("model"),
api_key_digest,
runtime_config.get("base_url"),
runtime_config.get("base_url_preset"),
runtime_config.get("user_agent"),
bool(runtime_config.get("use_proxy")),
runtime_config.get("thinking_level"),
)
async def _agent_bundle_signature(self, streaming: bool) -> tuple[Any, ...]:
"""构造会话内 Agent 图缓存签名。"""
runtime_config = await self._resolve_llm_runtime_config()
return (
streaming,
self.channel,
self.source,
self.user_id,
self.username,
self.allow_message_tools,
bool(self._tool_context.get("is_admin")),
self.has_message_context,
self.is_background,
settings.AI_AGENT_VERBOSE,
settings.LLM_MAX_TOOLS,
settings.LLM_MAX_ITERATIONS,
self._public_runtime_config_signature(runtime_config),
agent_runtime_manager.current_signature(),
agent_mcp_manager.config_signature(),
)
def _get_cached_agent(
self, signature: tuple[Any, ...], streaming: bool
) -> Optional[Any]:
"""按签名读取当前会话已编译的 Agent 图。"""
bundle = self._compiled_agent_bundle
if (
bundle
and bundle.streaming == streaming
and bundle.signature == signature
):
return bundle.agent
return None
def _cache_agent(
self,
*,
signature: tuple[Any, ...],
agent: Any,
streaming: bool,
) -> Any:
"""保存当前会话可复用的 Agent 图。"""
self._compiled_agent_bundle = _CompiledAgentBundle(
signature=signature,
agent=agent,
streaming=streaming,
created_at=datetime.now(),
)
return agent
@staticmethod
def _latest_turn_messages(messages: List[BaseMessage]) -> List[BaseMessage]:
"""从完整历史中提取本轮新增用户消息。"""
return [messages[-1]] if messages else []
def _initialize_subagent_tools(self) -> List:
"""
初始化子代理专用静默工具列表。
@@ -998,12 +1100,52 @@ class MoviePilotAgent:
allow_message_tools=False,
)
async def _initialize_mcp_tools(self) -> List:
"""
初始化外部 MCP 工具列表。
"""
return await create_external_mcp_tools(
session_id=self.session_id,
user_id=self.user_id,
channel=self.channel,
source=self.source,
username=self.username,
stream_handler=self.stream_handler,
agent_context=self._tool_context,
)
async def _initialize_subagent_mcp_tools(self) -> List:
"""
初始化子代理可用的外部 MCP 工具列表。
"""
return await create_external_mcp_tools(
session_id=self.session_id,
user_id=self.user_id,
channel=self.channel,
source=self.source,
username=self.username,
stream_handler=None,
agent_context={
"user_reply_sent": False,
"reply_mode": None,
"should_dispatch_reply": False,
"is_admin": bool(self._tool_context.get("is_admin")),
},
)
async def _create_agent(self, streaming: bool = False):
"""
创建 LangGraph Agent使用 create_agent + SummarizationMiddleware
:param streaming: 是否启用流式输出
"""
try:
bundle_signature = await self._agent_bundle_signature(streaming)
cached_agent = self._get_cached_agent(bundle_signature, streaming)
self._last_agent_cache_hit = bool(cached_agent)
if cached_agent:
logger.debug(f"复用会话内 Agent 图: session_id={self.session_id}")
return cached_agent
# 系统提示词
system_prompt = prompt_manager.get_agent_prompt(channel=self.channel)
@@ -1020,6 +1162,7 @@ class MoviePilotAgent:
# 工具列表
tools = self._initialize_tools()
tools.extend(await self._initialize_mcp_tools())
skills_middleware = SkillsMiddleware(
sources=[str(agent_runtime_manager.skills_dir)],
bundled_skills_dir=str(settings.ROOT_PATH / "skills"),
@@ -1036,9 +1179,11 @@ class MoviePilotAgent:
activity_log_tools = list(
getattr(activity_log_middleware, "tools", []) or []
)
subagent_tools = self._initialize_subagent_tools()
subagent_tools.extend(await self._initialize_subagent_mcp_tools())
subagent_middlewares, subagent_task_tools = create_subagent_middlewares(
model=non_streaming_model,
tools=self._initialize_subagent_tools(),
tools=subagent_tools,
stream_handler=self.stream_handler,
)
max_tools = settings.LLM_MAX_TOOLS
@@ -1111,13 +1256,18 @@ class MoviePilotAgent:
)
)
return create_agent(
agent = create_agent(
model=agent_model,
tools=[*tools, *skill_tools, *activity_log_tools],
system_prompt=system_prompt,
middleware=middlewares,
checkpointer=InMemorySaver(),
)
return self._cache_agent(
signature=bundle_signature,
agent=agent,
streaming=streaming,
)
except Exception as e:
logger.error(f"创建 Agent 失败: {e}")
raise e
@@ -1135,12 +1285,15 @@ class MoviePilotAgent:
user_display_saved = False
try:
logger.info(
f"Agent推理: session_id={self.session_id}, input={message}, "
f"Agent推理: session_id={self.session_id}, "
f"input_chars={len(message or '')}, "
f"images={len(images) if images else 0}, files={len(files) if files else 0}, "
f"audio_input={has_audio_input}"
)
self._tool_context = await self._build_tool_context(
should_dispatch_reply=self.should_dispatch_reply
self._refresh_tool_context(
await self._build_tool_context(
should_dispatch_reply=self.should_dispatch_reply
)
)
self._streamed_output = ""
@@ -1328,6 +1481,11 @@ class MoviePilotAgent:
# 创建智能体(根据是否流式传入不同 LLM
agent = await self._create_agent(streaming=use_streaming)
input_messages = (
self._latest_turn_messages(messages)
if self._last_agent_cache_hit
else messages
)
if use_streaming:
self.stream_handler.set_dispatch_policy(
@@ -1346,7 +1504,7 @@ class MoviePilotAgent:
# 流式运行智能体token 直接推送到 stream_handler
await self._stream_agent_tokens(
agent=agent,
messages={"messages": messages},
messages={"messages": input_messages},
config=agent_config,
on_token=self._handle_stream_text,
)
@@ -1385,7 +1543,7 @@ class MoviePilotAgent:
else:
# 非流式模式:后台任务或渠道不支持消息编辑
await agent.ainvoke(
{"messages": messages},
{"messages": input_messages},
config=agent_config,
)
@@ -1444,9 +1602,11 @@ class MoviePilotAgent:
except asyncio.CancelledError:
logger.info(f"Agent执行被取消: session_id={self.session_id}")
self._compiled_agent_bundle = None
execution_error = "任务已取消"
return "任务已取消", {}
except Exception as e:
self._compiled_agent_bundle = None
execution_error = str(e)
if self._messages_have_image_input(messages) and self._is_unsupported_image_input_error(e):
logger.warning(
@@ -1483,6 +1643,7 @@ class MoviePilotAgent:
original_chat_id=self.original_chat_id,
title=title,
text=message,
save_history=False,
)
)
@@ -1490,6 +1651,7 @@ class MoviePilotAgent:
"""
清理智能体资源
"""
self._compiled_agent_bundle = None
logger.info(f"MoviePilot智能体已清理: session_id={self.session_id}")
@@ -1513,6 +1675,10 @@ class _MessageTask:
processing_status: Optional[dict] = None
reply_mode: ReplyMode = ReplyMode.DISPATCH
allow_message_tools: bool = True
output_callback: Optional[Callable[[str], None]] = None
notification_callback: Optional[Callable[[Any], None]] = None
agent_factory: Optional[Callable[..., MoviePilotAgent]] = None
completion_future: Optional[asyncio.Future] = None
class AgentManager:
@@ -1617,6 +1783,12 @@ class AgentManager:
queue = self._session_queues.get(session_id)
return bool(queue and not queue.empty())
def is_session_busy(self, session_id: str) -> bool:
"""
查询会话是否仍有正在执行或排队的任务。
"""
return self._is_session_busy(session_id)
def _expired_idle_sessions(self) -> list[tuple[str, str]]:
"""
收集已经超过空闲时间且当前不忙的会话。
@@ -1658,11 +1830,18 @@ class AgentManager:
original_chat_id: Optional[str] = None,
reply_mode: ReplyMode = ReplyMode.DISPATCH,
allow_message_tools: bool = True,
output_callback: Optional[Callable[[str], None]] = None,
notification_callback: Optional[Callable[[Any], None]] = None,
agent_factory: Optional[Callable[..., MoviePilotAgent]] = None,
wait_for_completion: bool = False,
) -> str:
"""
处理用户消息:将消息放入会话队列,按顺序依次处理。
同一会话的消息排队等待,不同会话之间互不影响。
"""
completion_future = (
asyncio.get_running_loop().create_future() if wait_for_completion else None
)
task = _MessageTask(
session_id=session_id,
user_id=user_id,
@@ -1677,6 +1856,10 @@ class AgentManager:
original_chat_id=original_chat_id,
reply_mode=reply_mode,
allow_message_tools=allow_message_tools,
output_callback=output_callback,
notification_callback=notification_callback,
agent_factory=agent_factory,
completion_future=completion_future,
)
self._record_session_activity(session_id, user_id)
@@ -1709,6 +1892,8 @@ class AgentManager:
self._session_worker(session_id)
)
if completion_future:
return await completion_future
return ""
async def _session_worker(self, session_id: str):
@@ -1732,9 +1917,17 @@ class AgentManager:
try:
await self._start_task_processing_status(task)
await self._process_message_internal(task)
result = await self._process_message_internal(task)
if task.completion_future and not task.completion_future.done():
task.completion_future.set_result(result)
except asyncio.CancelledError as err:
if task.completion_future and not task.completion_future.done():
task.completion_future.set_exception(err)
raise
except Exception as e:
logger.error(f"处理会话 {session_id} 的消息失败: {e}")
if task.completion_future and not task.completion_future.done():
task.completion_future.set_exception(e)
finally:
await self._finish_task_processing_status(task)
queue.task_done()
@@ -1773,21 +1966,36 @@ class AgentManager:
实际处理单条消息
"""
session_id = task.session_id
existing_agent = self.active_agents.get(session_id)
if (
existing_agent
and task.agent_factory
and isinstance(task.agent_factory, type)
and not isinstance(existing_agent, task.agent_factory)
):
await existing_agent.cleanup()
self.active_agents.pop(session_id, None)
if session_id not in self.active_agents:
logger.info(
f"创建新的AI智能体实例session_id: {session_id}, user_id: {task.user_id}"
)
agent = MoviePilotAgent(
session_id=session_id,
user_id=task.user_id,
channel=task.channel,
source=task.source,
username=task.username,
original_message_id=task.original_message_id,
original_chat_id=task.original_chat_id,
replay_mode=task.reply_mode,
allow_message_tools=task.allow_message_tools,
)
agent_factory = task.agent_factory or MoviePilotAgent
agent_kwargs = {
"session_id": session_id,
"user_id": task.user_id,
"channel": task.channel,
"source": task.source,
"username": task.username,
"original_message_id": task.original_message_id,
"original_chat_id": task.original_chat_id,
"replay_mode": task.reply_mode,
"allow_message_tools": task.allow_message_tools,
"output_callback": task.output_callback,
}
if task.notification_callback is not None and task.agent_factory:
agent_kwargs["notification_callback"] = task.notification_callback
agent = agent_factory(**agent_kwargs)
self.active_agents[session_id] = agent
else:
agent = self.active_agents[session_id]
@@ -1802,6 +2010,12 @@ class AgentManager:
agent.original_chat_id = task.original_chat_id
agent.reply_mode = task.reply_mode
agent.allow_message_tools = task.allow_message_tools
if hasattr(agent, "set_output_callback"):
agent.set_output_callback(task.output_callback)
else:
agent.output_callback = task.output_callback
if task.notification_callback is not None and hasattr(agent, "set_notification_callback"):
agent.set_notification_callback(task.notification_callback)
process_kwargs = {
"images": task.images,

View File

@@ -536,6 +536,7 @@ class StreamingHandler:
original_chat_id=self._original_chat_id,
title=self._title,
text=current_text,
save_history=False,
),
)
if response and response.success and response.message_id:
@@ -581,6 +582,7 @@ class StreamingHandler:
original_chat_id=self._original_chat_id,
title=self._title,
text=current_text,
save_history=False,
),
)
if response and response.success and response.message_id:

View File

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

View File

@@ -5,7 +5,7 @@ import inspect
import json
import time
from functools import wraps
from typing import Any, List
from typing import Any, List, Optional
from langchain_core.messages import AIMessage, AIMessageChunk
@@ -700,11 +700,85 @@ class LLMHelper:
return {}
@staticmethod
def supports_image_input() -> bool:
def _metadata_supports_image_input(metadata: Any) -> Optional[bool]:
"""从模型元数据中读取图片输入能力,未知时返回 None。"""
if not isinstance(metadata, dict):
return None
modalities = metadata.get("modalities") or {}
input_modalities = modalities.get("input")
if isinstance(input_modalities, str):
input_modalities = [input_modalities]
if isinstance(input_modalities, list):
normalized_modalities = {
str(item or "").strip().lower() for item in input_modalities
}
return "image" in normalized_modalities
return None
@classmethod
def _resolve_catalog_image_input_support(
cls,
provider: Optional[str] = None,
model: Optional[str] = None,
base_url: Optional[str] = None,
base_url_preset: Optional[str] = None,
) -> Optional[bool]:
"""复用 provider 目录缓存解析当前模型是否支持图片输入。"""
provider_name = str(provider if provider is not None else settings.LLM_PROVIDER).strip()
model_name = str(model if model is not None else settings.LLM_MODEL).strip()
if not provider_name or not model_name:
return None
try:
from app.agent.llm.provider import LLMProviderManager
metadata = LLMProviderManager().resolve_cached_model_metadata(
provider_id=provider_name,
model_id=model_name,
base_url=base_url if base_url is not None else settings.LLM_BASE_URL,
base_url_preset_id=(
base_url_preset
if base_url_preset is not None
else settings.LLM_BASE_URL_PRESET
),
)
except Exception as err:
logger.debug(f"解析模型图片能力失败: {err}")
return None
return cls._metadata_supports_image_input(metadata)
@classmethod
def supports_image_input(
cls,
provider: Optional[str] = None,
model: Optional[str] = None,
base_url: Optional[str] = None,
base_url_preset: Optional[str] = None,
) -> bool:
"""
判断当前模型是否启用了图片输入能力。
用户开关为总开关;当内置模型目录明确标注当前模型不支持 image 输入时,
即使总开关开启也降级为纯文本,避免文本模型收到 `image_url` 内容块后
被兼容端点以 400 拒绝。无参调用保持旧版“只读总开关”语义,
未知自定义模型也保持原有开关语义。
"""
return bool(settings.LLM_SUPPORT_IMAGE_INPUT)
if not settings.LLM_SUPPORT_IMAGE_INPUT:
return False
if provider is None and model is None:
return True
image_support = cls._resolve_catalog_image_input_support(
provider=provider,
model=model,
base_url=base_url,
base_url_preset=base_url_preset,
)
if image_support is not None:
return image_support
return True
@staticmethod
def _build_legacy_runtime(
@@ -798,6 +872,41 @@ class LLMHelper:
return True
return None
@staticmethod
def _attach_runtime_metadata(model: Any, runtime: dict[str, Any]) -> None:
"""
将 MoviePilot 已解析出的 provider 运行时信息挂到模型实例上。
这些字段只供内部中间件识别协议能力,不参与 LangChain 请求序列化。
"""
runtime_metadata = {
"runtime": runtime.get("runtime"),
"provider_id": runtime.get("provider_id"),
"base_url": runtime.get("base_url"),
}
def _set_metadata_attr(name: str, value: Any) -> None:
try:
setattr(model, name, value)
except Exception:
object.__setattr__(model, name, value)
try:
_set_metadata_attr("_moviepilot_llm_runtime", runtime_metadata["runtime"])
_set_metadata_attr(
"_moviepilot_llm_provider_id",
runtime_metadata["provider_id"],
)
_set_metadata_attr("_moviepilot_llm_base_url", runtime_metadata["base_url"])
except Exception as err:
logger.debug(f"LLM运行时元数据附加失败: {str(err)}")
profile = getattr(model, "profile", None)
if isinstance(profile, dict):
profile["moviepilot_runtime"] = runtime_metadata["runtime"]
profile["moviepilot_provider_id"] = runtime_metadata["provider_id"]
profile["moviepilot_base_url"] = runtime_metadata["base_url"]
@classmethod
def _resolve_thinking_level(
cls,
@@ -843,6 +952,7 @@ class LLMHelper:
base_url: str | None = None,
base_url_preset: str | None = None,
user_agent: str | None = None,
temperature: Optional[float] = None,
use_proxy: bool | None = None,
):
"""
@@ -858,6 +968,7 @@ class LLMHelper:
:param base_url: API Base URL。未显式传入时使用当前配置项 LLM_BASE_URL。
:param base_url_preset: Base URL 预设。未显式传入时使用当前配置项 LLM_BASE_URL_PRESET。
:param user_agent: OpenAI兼容接口请求 User-Agent。未显式传入时使用配置项 LLM_USER_AGENT。
:param temperature: LLM 温度参数。未显式传入时使用配置项 LLM_TEMPERATURE。
:param use_proxy: 是否为本次 LLM 调用使用系统代理。未显式传入时使用配置项 LLM_USE_PROXY。
:return: LLM实例
"""
@@ -869,6 +980,7 @@ class LLMHelper:
base_url_preset if base_url_preset is not None else settings.LLM_BASE_URL_PRESET
)
user_agent_value = user_agent if user_agent is not None else settings.LLM_USER_AGENT
temperature_value = temperature if temperature is not None else settings.LLM_TEMPERATURE
normalized_thinking_level = cls._resolve_thinking_level(
thinking_level=thinking_level,
)
@@ -925,7 +1037,7 @@ class LLMHelper:
model=model_name,
api_key=runtime["api_key"],
retries=3,
temperature=settings.LLM_TEMPERATURE,
temperature=temperature_value,
streaming=streaming,
client_args=_build_google_client_args(llm_proxy),
**thinking_kwargs,
@@ -939,7 +1051,7 @@ class LLMHelper:
api_key=runtime["api_key"],
api_base=runtime["base_url"],
max_retries=3,
temperature=settings.LLM_TEMPERATURE,
temperature=temperature_value,
streaming=streaming,
stream_usage=True,
http_client=_build_httpx_client(llm_proxy),
@@ -954,7 +1066,7 @@ class LLMHelper:
api_key=runtime["api_key"],
base_url=runtime["base_url"],
max_retries=3,
temperature=settings.LLM_TEMPERATURE,
temperature=temperature_value,
streaming=streaming,
stream_usage=True,
anthropic_proxy=llm_proxy,
@@ -975,7 +1087,7 @@ class LLMHelper:
api_key=runtime["api_key"],
max_retries=3,
base_url=runtime.get("base_url"),
temperature=settings.LLM_TEMPERATURE,
temperature=temperature_value,
streaming=streaming,
stream_usage=True,
openai_proxy=llm_proxy,
@@ -1011,6 +1123,7 @@ class LLMHelper:
"max_input_tokens": int(max_input_tokens),
}
cls._attach_runtime_metadata(model, runtime)
return model
@staticmethod
@@ -1068,25 +1181,32 @@ class LLMHelper:
base_url: str | None = None,
base_url_preset: str | None = None,
user_agent: str | None = None,
temperature: Optional[float] = None,
use_proxy: bool | None = None,
) -> dict:
"""
使用当前已保存配置执行一次最小 LLM 调用。
使用当前配置或显式传入的临时配置执行一次最小 LLM 调用。
:param temperature: 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
start = time.perf_counter()
llm = await LLMHelper.get_llm(
streaming=False,
provider=provider_name,
model=model_name,
thinking_level=thinking_level,
api_key=api_key,
base_url=base_url,
base_url_preset=base_url_preset,
user_agent=user_agent,
use_proxy=use_proxy,
)
llm_kwargs = {
"streaming": False,
"provider": provider_name,
"model": model_name,
"thinking_level": thinking_level,
"api_key": api_key,
"base_url": base_url,
"base_url_preset": base_url_preset,
"user_agent": user_agent,
"use_proxy": use_proxy,
}
if temperature is not None:
llm_kwargs["temperature"] = temperature
llm = await LLMHelper.get_llm(**llm_kwargs)
try:
response = await asyncio.wait_for(llm.ainvoke(prompt), timeout=timeout)
except TimeoutError as err:

View File

@@ -1564,6 +1564,101 @@ class LLMProviderManager(metaclass=Singleton):
return models[candidate]
return None
def _cached_models_dev_model(
self,
provider_id: str,
model_id: str,
base_url: Optional[str] = None,
base_url_preset_id: Optional[str] = None,
) -> dict[str, Any] | None:
"""从已缓存或内置的 models.dev 数据中同步读取模型元数据。"""
try:
spec = self.get_provider(provider_id)
except LLMProviderError:
return None
models_dev_provider_id = self._resolve_provider_models_dev_provider_id(
spec,
base_url,
base_url_preset_id=base_url_preset_id,
)
if not models_dev_provider_id:
return None
payload = self._cached_models_dev_payload().get(models_dev_provider_id, {}) or {}
models = payload.get("models") if isinstance(payload, dict) else None
if not isinstance(models, dict):
return None
candidates = [model_id]
if model_id.startswith("models/"):
candidates.append(model_id.removeprefix("models/"))
for candidate in candidates:
if candidate in models:
return models[candidate]
return None
def resolve_cached_model_metadata(
self,
provider_id: str,
model_id: Optional[str],
base_url: Optional[str] = None,
base_url_preset_id: Optional[str] = None,
) -> dict[str, Any] | None:
"""同步解析缓存中的模型元数据,不触发远端 models.dev 刷新。"""
if not model_id:
return None
metadata = self._cached_models_dev_model(
provider_id,
model_id,
base_url=base_url,
base_url_preset_id=base_url_preset_id,
)
if metadata:
return metadata
if provider_id == "chatgpt":
return self._cached_models_dev_model("openai", model_id)
if provider_id == "openai":
return (
self._cached_models_dev_payload()
.get("openai", {})
.get("models", {})
.get(model_id)
)
return None
def _resolve_cached_model_record(
self,
provider_id: str,
model_id: Optional[str],
base_url: Optional[str] = None,
base_url_preset_id: Optional[str] = None,
transport: str = "openai",
) -> dict[str, Any] | None:
"""从缓存中的模型元数据构造轻量模型记录,不触发远端模型列表刷新。"""
if not model_id:
return None
metadata = self.resolve_cached_model_metadata(
provider_id,
model_id,
base_url=base_url,
base_url_preset_id=base_url_preset_id,
) or {}
if not metadata:
return self._normalize_model_record(
model_id=model_id,
transport=transport,
source="configured",
)
return self._normalize_model_record(
model_id=model_id,
display_name=metadata.get("name") or model_id,
metadata=metadata,
transport=transport,
source="models.dev-cache",
)
@staticmethod
def _normalize_model_record(
model_id: str,
@@ -2040,7 +2135,7 @@ class LLMProviderManager(metaclass=Singleton):
try:
return jwt.decode(token, options={"verify_signature": False})
except Exception as err:
print(err)
logger.debug(f"解析 JWT token 内容失败: {err}")
return {}
@staticmethod
@@ -2523,40 +2618,29 @@ class LLMProviderManager(metaclass=Singleton):
)
normalized_api_key = str(api_key or "").strip() or None
normalized_base_url = self._sanitize_base_url(base_url)
model_record = None
if model:
try:
model_record = next(
(
item
for item in await self.list_models(
normalized_provider_id,
api_key=api_key,
base_url=base_url,
base_url_preset_id=normalized_base_url_preset_id,
user_agent=user_agent,
use_proxy=use_proxy,
)
if item["id"] == model
),
None,
)
except Exception as err:
print(err)
model_record = None
default_transport = (
"anthropic" if resolved_runtime == "anthropic_compatible" else "openai"
)
model_record = self._resolve_cached_model_record(
normalized_provider_id,
model,
base_url=base_url,
base_url_preset_id=normalized_base_url_preset_id,
transport=default_transport,
)
model_metadata = self.resolve_cached_model_metadata(
normalized_provider_id,
model,
base_url=base_url,
base_url_preset_id=normalized_base_url_preset_id,
)
result: dict[str, Any] = {
"provider_id": normalized_provider_id,
"runtime": resolved_runtime,
"model_id": model,
"model_record": model_record,
"model_metadata": await self.resolve_model_metadata(
normalized_provider_id,
model,
base_url=base_url,
base_url_preset_id=normalized_base_url_preset_id,
use_proxy=use_proxy,
),
"model_metadata": model_metadata,
"default_headers": None,
"use_responses_api": None,
"auth_mode": "api_key",
@@ -2567,8 +2651,7 @@ class LLMProviderManager(metaclass=Singleton):
try:
auth = await self._resolve_chatgpt_oauth()
except Exception as err:
print(err)
pass
logger.debug(f"解析 ChatGPT OAuth 鉴权失败,回退 API Key 模式: {err}")
if auth:
headers = {"originator": "moviepilot"}

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

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

View File

@@ -6,13 +6,16 @@
并在每次 Agent 启动时注入轻量索引,完整日志由工具按需查询。
"""
import asyncio
import json
import os
import re
from collections.abc import Awaitable, Callable
from datetime import datetime, timedelta
from pathlib import Path
from typing import Annotated, Any, NotRequired, Optional, TypedDict
import anyio
from anyio import Path as AsyncPath
from langchain.agents.middleware.types import (
AgentMiddleware,
@@ -86,10 +89,6 @@ ACTIVITY_ENTRY_PATTERN = re.compile(r"^-\s+\*\*(?P<time>\d{2}:\d{2})\*\*\s+(?P<s
class QueryActivityLogInput(BaseModel):
"""查询活动日志工具的输入参数模型。"""
explanation: Optional[str] = Field(
None,
description="Clear explanation of why this tool is being used in the current context",
)
keyword: Optional[str] = Field(
None,
description=(
@@ -286,17 +285,15 @@ class _ActivityLogToolProvider:
date: Optional[str] = None,
days: Optional[int] = DEFAULT_QUERY_DAYS,
limit: Optional[int] = DEFAULT_QUERY_LIMIT,
explanation: Optional[str] = None,
) -> str:
"""查询活动日志并返回 JSON 字符串。"""
logger.info(
"查询活动日志: keyword=%s, use_regex=%s, date=%s, days=%s, limit=%s, explanation=%s",
"查询活动日志: keyword=%s, use_regex=%s, date=%s, days=%s, limit=%s",
keyword,
use_regex,
date,
days,
limit,
explanation or "-",
)
try:
payload = query_activity_logs(
@@ -503,6 +500,7 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
self.retention_days = retention_days
self.prompt_load_days = prompt_load_days
self.stream_handler = stream_handler
self._background_tasks: set[asyncio.Task[None]] = set()
self._tool_provider = _ActivityLogToolProvider(activity_dir=activity_dir)
self.tools = [
StructuredTool.from_function(
@@ -579,14 +577,29 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
entry = f"- **{now_str}** {summary}\n"
try:
if await log_path.exists():
existing = await log_path.read_text(encoding="utf-8", errors="replace")
await log_path.write_text(existing + entry, encoding="utf-8")
async with await anyio.open_file(
log_path,
mode="a",
encoding="utf-8",
) as stream:
await stream.write(entry)
else:
header = f"# {today_str} 活动日志\n\n"
await log_path.write_text(header + entry, encoding="utf-8")
logger.debug("Activity logged: %s", summary[:80])
try:
fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
except FileExistsError:
async with await anyio.open_file(
log_path,
mode="a",
encoding="utf-8",
) as stream:
await stream.write(entry)
else:
with os.fdopen(fd, "w", encoding="utf-8") as stream:
stream.write(header + entry)
logger.debug(f"Activity logged: {summary[:80]}")
except Exception as e:
logger.warning("Failed to append activity log: %s", e)
logger.warning(f"Failed to append activity log: {e}")
async def _cleanup_old_logs(self) -> None:
"""清理超过保留天数的旧日志文件。"""
@@ -608,20 +621,54 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
file_date = datetime.strptime(match.group(1), "%Y-%m-%d").date()
if file_date < cutoff_date:
await path.unlink()
logger.debug("Cleaned up old activity log: %s", path.name)
logger.debug(f"Cleaned up old activity log: {path.name}")
except ValueError:
continue
except Exception as e:
logger.warning("Failed to cleanup old activity logs: %s", e)
logger.warning(f"Failed to cleanup old activity logs: {e}")
def _schedule_activity_recording(self, messages: list) -> None:
"""提交后台活动记录任务,不阻塞当前 Agent 会话结束。"""
task = asyncio.create_task(self._record_activity(messages))
self._background_tasks.add(task)
task.add_done_callback(self._on_activity_recording_done)
def _on_activity_recording_done(self, task: asyncio.Task[None]) -> None:
"""清理已完成的后台任务并记录未捕获异常。"""
self._background_tasks.discard(task)
try:
task.result()
except asyncio.CancelledError:
logger.debug("活动日志后台记录任务已取消")
except Exception as err:
logger.warning(f"活动日志后台记录任务失败: {err}")
async def _record_activity(self, messages: list) -> None:
"""在后台生成本轮活动摘要并写入活动日志。"""
try:
# 提取本轮交互
round_messages = _extract_last_round(messages)
if not round_messages:
return
if _should_skip_activity_summary(round_messages):
return
# 格式化对话文本
conversation_text = _format_conversation_for_summary(round_messages)
if not conversation_text:
return
# 调用 LLM 生成摘要
summary = await _summarize_with_llm(conversation_text)
if summary:
await self._append_activity(summary)
except Exception as e:
logger.warning(f"Failed to record activity: {e}")
async def abefore_agent(
self, state: ActivityLogState, runtime: Runtime
) -> Optional[ActivityLogStateUpdate]:
"""在 Agent 执行前加载近期活动日志。"""
# 如果已经加载则跳过
if "activity_log_contents" in state:
return None
contents = await self._load_recent_logs()
# 趁机清理旧日志(低频操作,不影响性能)
@@ -686,30 +733,14 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
async def aafter_agent(
self, state: ActivityLogState, runtime: Runtime
) -> Optional[dict[str, Any]]:
"""Agent 执行完毕后,调用 LLM 对本轮对话生成摘要并追加到当日活动日志"""
"""Agent 执行完毕后,异步提交活动日志记录任务"""
try:
messages = state.get("messages", [])
if not messages:
return None
# 提取本轮交互
round_messages = _extract_last_round(messages)
if not round_messages:
return None
if _should_skip_activity_summary(round_messages):
return None
# 格式化对话文本
conversation_text = _format_conversation_for_summary(round_messages)
if not conversation_text:
return None
# 调用 LLM 生成摘要
summary = await _summarize_with_llm(conversation_text)
if summary:
await self._append_activity(summary)
self._schedule_activity_recording(list(messages))
except Exception as e:
logger.warning("Failed to record activity: %s", e)
logger.warning(f"Failed to record activity: {e}")
return None

View File

@@ -283,12 +283,7 @@ class JobsMiddleware(AgentMiddleware[JobsState, ContextT, ResponseT]): # noqa
) -> JobsStateUpdate | None:
"""在 Agent 执行前异步加载任务元数据。
每个会话仅加载一次。若 state 中已有则跳过。
"""
# 如果 state 中已存在元数据则跳过
if "jobs_metadata" in state:
return None
return JobsStateUpdate(
jobs_metadata=await load_jobs_metadata(self.sources)
)

View File

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

View File

@@ -92,10 +92,6 @@ class SkillsStateUpdate(TypedDict):
class SkillToolInput(BaseModel):
"""Skill 加载工具的输入参数模型。"""
explanation: Optional[str] = Field(
None,
description="Clear explanation of why this skill is needed in the current context",
)
name: str = Field(
...,
description="Skill name or id from the available skills list.",
@@ -322,7 +318,7 @@ def _extract_version(skill_md: Path) -> int:
try:
content = skill_md.read_text(encoding="utf-8", errors="replace")
except Exception as err:
print(err)
logger.debug(f"读取技能版本失败: {err}")
return 0
match = re.match(r"^---\s*\n(.*?)\n---\s*\n", content, re.DOTALL)
if not match:
@@ -460,9 +456,9 @@ class _SkillToolProvider:
raw_content = await handle.read(MAX_SKILL_FILE_SIZE)
return raw_content.decode("utf-8", errors="replace"), truncated
async def load_skill(self, name: str, explanation: Optional[str] = None) -> str:
async def load_skill(self, name: str) -> str:
"""加载指定 Skill 的完整说明并返回 JSON 字符串。"""
logger.info(f"加载 Skill: name={name}, explanation={explanation or '-'}")
logger.info(f"加载 Skill: name={name}")
try:
skill = await self._find_skill(name)
if not skill:
@@ -627,13 +623,8 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no
) -> SkillsStateUpdate | None: # ty: ignore[invalid-method-override]
"""在 Agent 执行前异步加载技能元数据。
每个会话仅加载一次。若 state 中已有则跳过。
首次加载时,会先将内置技能同步到用户目录(如不存在)。
"""
# 如果 state 中已存在元数据则跳过
if "skills_metadata" in state:
return None
self._sync_bundled_skills()
all_skills: dict[str, SkillMetadata] = {}
@@ -679,8 +670,7 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no
if not isinstance(tool_args, dict):
tool_args = {}
logger.info(
f"开始执行 Skill 工具: name={tool_args.get('name') or '-'}, "
f"explanation={tool_args.get('explanation') or '-'}"
f"开始执行 Skill 工具: name={tool_args.get('name') or '-'}"
)
if self.stream_handler and getattr(self.stream_handler, "is_streaming", False):
self.stream_handler.record_tool_call(

View File

@@ -197,18 +197,44 @@ def is_subagent_stream_metadata(metadata: Any) -> bool:
) == SUBAGENT_STREAM_MARKER_VALUE:
return True
return bool(metadata.get("lc_agent_name") in builtin_subagent_names())
return bool(
metadata.get("lc_agent_name")
in builtin_subagent_names(agent_runtime_manager.current_signature())
)
@lru_cache(maxsize=1)
def builtin_subagent_names() -> frozenset[str]:
def builtin_subagent_names(
runtime_signature: Optional[tuple[tuple[str, int, int], ...]] = None,
) -> frozenset[str]:
"""返回内置子代理名称集合。"""
return frozenset(profile.name for profile in _builtin_subagent_profiles())
runtime_signature = runtime_signature or agent_runtime_manager.current_signature()
return _cached_builtin_subagent_names(runtime_signature)
@lru_cache(maxsize=1)
def _builtin_subagent_profiles() -> tuple[_SubAgentProfile, ...]:
@lru_cache(maxsize=8)
def _cached_builtin_subagent_names(
runtime_signature: tuple[tuple[str, int, int], ...],
) -> frozenset[str]:
"""按运行时签名缓存内置子代理名称集合。"""
return frozenset(
profile.name
for profile in _builtin_subagent_profiles(runtime_signature)
)
def _builtin_subagent_profiles(
runtime_signature: Optional[tuple[tuple[str, int, int], ...]] = None,
) -> tuple[_SubAgentProfile, ...]:
"""从运行时配置目录加载 MoviePilot 子代理定义。"""
runtime_signature = runtime_signature or agent_runtime_manager.current_signature()
return _cached_builtin_subagent_profiles(runtime_signature)
@lru_cache(maxsize=8)
def _cached_builtin_subagent_profiles(
runtime_signature: tuple[tuple[str, int, int], ...],
) -> tuple[_SubAgentProfile, ...]:
"""按运行时签名缓存 MoviePilot 子代理定义。"""
definitions = agent_runtime_manager.list_subagents()
profiles = tuple(
_profile_from_runtime_definition(definition)
@@ -237,6 +263,10 @@ def _builtin_subagent_profiles() -> tuple[_SubAgentProfile, ...]:
)
builtin_subagent_names.cache_clear = _cached_builtin_subagent_names.cache_clear
_builtin_subagent_profiles.cache_clear = _cached_builtin_subagent_profiles.cache_clear
def _profile_from_runtime_definition(
definition: SubAgentDefinition,
) -> _SubAgentProfile:
@@ -1044,6 +1074,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
if unfinished_records:
logger.info(f"Agent 结束,取消未完成子代理任务: tasks={len(unfinished_records)}")
await self._cancel_records(unfinished_records)
self._tasks.clear()
async def awrap_tool_call(
self,
@@ -1083,9 +1114,8 @@ def create_subagent_middlewares(
stream_handler: Any = None,
) -> tuple[list[AgentMiddleware], list[BaseTool]]:
"""创建子代理中间件列表和任务工具列表。"""
_builtin_subagent_profiles.cache_clear()
builtin_subagent_names.cache_clear()
profiles = _builtin_subagent_profiles()
runtime_signature = agent_runtime_manager.current_signature()
profiles = _builtin_subagent_profiles(runtime_signature)
subagent_middleware = MoviePilotSubAgentMiddleware(
model=model,
profiles=profiles,

View File

@@ -1,6 +1,6 @@
"""MoviePilot 自定义工具筛选中间件。"""
from dataclasses import replace
from dataclasses import dataclass, replace
import json
from collections.abc import Awaitable, Callable
from typing import Annotated, Any, NotRequired
@@ -20,7 +20,7 @@ from langchain.agents.middleware.tool_selection import (
LLMToolSelectorMiddleware,
)
from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from langchain_core.runnables import RunnableConfig
from langchain_core.tools import BaseTool
from langgraph.runtime import Runtime
@@ -68,19 +68,25 @@ class ToolSelectionStateUpdate(TypedDict):
selected_tool_names: list[str] | None
@dataclass(frozen=True)
class _ToolSelectionAttempt:
"""工具筛选尝试结果,用于统一记录最终日志。"""
request: ModelRequest
selected_tool_names: list[str]
status: str
detail: str = ""
class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
"""
为 DeepSeek 兼容端点提供更稳妥的工具筛选实现
使用 provider-neutral JSON 提示执行工具筛选。
LangChain 默认会通过 `with_structured_output()` 走 OpenAI
`response_format=json_schema` 路径,但 DeepSeek 官方 OpenAI 兼容端点公开文档
仅保证 `json_object` 模式可用。对于 `deepseek-reasoner`,这会在工具筛选阶段
提前触发 400导致 Agent 还没真正开始执行工具就失败。
因此这里仅在识别到 DeepSeek 模型/端点时,退回到显式 JSON 输出模式:
1. 使用 `response_format={"type": "json_object"}`
2. 在提示词中明确约束返回 JSON 结构;
3. 手动解析 `{"tools": [...]}`,其余模型继续沿用 LangChain 默认实现。
LangChain 默认会通过 `with_structured_output()` 走 provider-specific
结构化输出能力,不同 OpenAI/Anthropic 兼容端点对 `response_format`、
JSON schema 和工具绑定的支持并不一致。工具筛选只是 Agent 执行前的
辅助优化,失败时也会恢复使用全部工具,因此这里统一使用文本提示约束
模型返回 `{"tools": [...]}` 并手动解析,避免在筛选阶段引入额外兼容分支。
另外LangChain 原生工具筛选挂在 `wrap_model_call` 上,会在同一条用户请求
的每次“模型回合”前都重新筛选一次工具。对于会多轮调用工具的复杂任务,
@@ -322,21 +328,16 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
request: ModelRequest[ContextT],
) -> ModelRequest[ContextT]:
"""
处理工具筛选响应,并保留空结果回退所有工具的 MoviePilot 策略
处理工具筛选响应,并在正常空结果时禁用可筛选工具
"""
if response.get("tools") == []:
logger.warning("工具筛选结果为空,将恢复使用所有工具。")
always_included_tools: list[BaseTool] = [
tool
for tool in request.tools
if not isinstance(tool, dict) and tool.name in self.always_include
]
provider_tools = [tool for tool in request.tools if isinstance(tool, dict)]
return request.override(
tools=[*available_tools, *always_included_tools, *provider_tools]
)
return request.override(tools=[*always_included_tools, *provider_tools])
response["tools"] = self._complete_low_count_selection(
selected_tool_names=[
@@ -347,47 +348,21 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
valid_tool_names=valid_tool_names,
available_tools=available_tools,
)
return super()._process_selection_response(
modified_request = super()._process_selection_response(
response,
available_tools,
valid_tool_names,
request,
)
@staticmethod
def _is_deepseek_compatible_model(model: BaseChatModel) -> bool:
"""
判断当前模型是否应当走 DeepSeek JSON 兼容分支。
除了官方 `langchain_deepseek`,用户也可能通过 OpenAI-compatible
配置把 DeepSeek 端点接到 `ChatOpenAI`。因此这里同时检查模块名、模型名
和 Base URL避免只靠单一条件漏判。
"""
module_name = type(model).__module__.lower()
model_name = (
str(getattr(model, "model_name", "") or getattr(model, "model", ""))
.strip()
.lower()
)
base_url = (
str(getattr(model, "openai_api_base", "") or getattr(model, "api_base", ""))
.strip()
.lower()
)
return (
"deepseek" in module_name
or model_name.startswith("deepseek-")
or "api.deepseek.com" in base_url
)
return modified_request
@staticmethod
def _parse_json_object(text: str) -> dict[str, Any]:
"""
解析模型返回的 JSON。
DeepSeek 在 JSON 模式下通常会返回纯 JSON但这里仍做一层兜底
兼容模型偶发输出围栏或前后说明文本的情况
不同模型可能偶发输出 Markdown 围栏或前后说明文本,因此这里从
响应中提取第一个 JSON 对象作为兜底
"""
stripped_text = text.strip()
if not stripped_text:
@@ -440,16 +415,16 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
)
return f"Capability groups from tool tags:\n{rendered_groups}\n\n"
def _build_deepseek_selection_prompt(self, selection_request: Any) -> str:
def _build_json_selection_prompt(self, selection_request: Any) -> str:
"""
为 DeepSeek 生成显式 JSON 输出提示。
生成显式 JSON 输出提示。
DeepSeek 官方文档要求在 JSON 输出模式下,提示词中必须明确包含 JSON
约束,否则兼容端点可能返回空内容或无意义输出
使用纯提示约束可覆盖更多兼容端点,避免在工具筛选阶段依赖某个
provider 专属的 `response_format` 或 schema 能力
"""
limit_instruction = ""
if self.max_tools:
limit_instruction = f"- Select up to {self.max_tools} tools. IF NO TOOLS ARE RELEVANT, DO NOT RETURN AN EMPTY ARRAY. SELECT THE MOST APPLICABLE ONES TO ENSURE THE REQUEST IS HANDLED."
limit_instruction = f"- Select up to {self.max_tools} tools. Return an empty array if no tools are relevant."
return (
f"{selection_request.system_message}\n\n"
@@ -469,7 +444,7 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
def _normalize_selection_response(self, response: Any) -> dict[str, list[str]]:
"""
解析并标准化 DeepSeek JSON 模式的工具筛选结果。
解析并标准化显式 JSON 模式的工具筛选结果。
"""
content = getattr(response, "content", response)
text = LLMHelper.extract_text_content(content)
@@ -486,22 +461,21 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
logger.debug(f"工具筛选标准化结果: {normalized_tools}")
return {"tools": normalized_tools}
async def _aselect_tools_with_deepseek(
async def _aselect_tools_with_json_prompt(
self, selection_request: Any
) -> dict[str, list[str]]:
"""
使用 DeepSeek 兼容的 JSON 输出模式执行异步工具筛选。
使用 JSON 提示执行异步工具筛选。
:param selection_request: LangChain 工具筛选请求
:return: 标准化后的工具名列表
"""
logger.debug("工具筛选走 DeepSeek JSON 兼容分支")
structured_model = selection_request.model.bind(
response_format={"type": "json_object"}
)
response = await structured_model.ainvoke(
logger.debug("工具筛选走 JSON 提示分支")
response = await selection_request.model.ainvoke(
[
{
"role": "system",
"content": self._build_deepseek_selection_prompt(selection_request),
},
SystemMessage(
content=self._build_json_selection_prompt(selection_request)
),
selection_request.last_user_message,
]
)
@@ -512,6 +486,31 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
"""从已筛选后的请求中提取最终工具名,保留原有顺序。"""
return [tool.name for tool in request.tools if not isinstance(tool, dict)]
@staticmethod
def _count_request_tools(request: ModelRequest) -> int:
"""统计当前请求中的 LangChain 工具数量,不包含 provider 原生工具字典。"""
return len([tool for tool in request.tools if not isinstance(tool, dict)])
@classmethod
def _log_selection_attempt(cls, attempt: _ToolSelectionAttempt) -> None:
"""按工具筛选最终状态记录稳定日志。"""
tool_count = cls._count_request_tools(attempt.request)
if attempt.status == "selected":
selected_text = ", ".join(attempt.selected_tool_names) or "无有效工具"
logger.info(f"工具筛选结果: {selected_text}")
return
if attempt.status == "failed_fallback":
logger.warning(
f"工具筛选失败,将恢复使用所有工具(共 {tool_count} 个): {attempt.detail}"
)
return
if attempt.status == "skipped":
logger.info(f"工具筛选跳过: {attempt.detail}")
return
if attempt.status == "reused":
selected_text = ", ".join(attempt.selected_tool_names) or "无有效工具"
logger.info(f"工具筛选复用已有结果: {selected_text}")
@staticmethod
def _apply_selected_tools(
request: ModelRequest[ContextT],
@@ -523,9 +522,6 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
这里只复用首次筛选出的客户端工具名provider-specific 的 dict 工具仍然
原样保留,避免破坏 LangChain/provider 自身的工具绑定约定。
"""
if not selected_tool_names:
return request
current_tools_by_name = {
tool.name: tool for tool in request.tools if not isinstance(tool, dict)
}
@@ -546,30 +542,43 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
这里单独抽成 helper便于首次筛选后缓存结果也便于测试覆盖
“首轮筛选,后续复用”的行为。
"""
return (await self._aselect_request_once_with_status(request)).request
async def _aselect_request_once_with_status(
self, request: ModelRequest[ContextT]
) -> _ToolSelectionAttempt:
"""
执行一次真实工具筛选,并携带最终状态供调用方统一记录日志。
"""
selection_request = self._prepare_selection_request(request)
if selection_request is None:
return request
return _ToolSelectionAttempt(
request=request,
selected_tool_names=self._extract_selected_tool_names(request),
status="skipped",
detail="没有需要筛选的工具",
)
if not self._is_deepseek_compatible_model(selection_request.model):
captured_request: ModelRequest[ContextT] = request
async def _capture_handler(
updated_request: ModelRequest[ContextT],
) -> ModelRequest[ContextT]:
nonlocal captured_request
captured_request = updated_request
return updated_request
await super().awrap_model_call(request, _capture_handler)
return captured_request
response = await self._aselect_tools_with_deepseek(selection_request)
return self._process_selection_response(
response,
selection_request.available_tools,
selection_request.valid_tool_names,
request,
)
try:
response = await self._aselect_tools_with_json_prompt(selection_request)
modified_request = self._process_selection_response(
response,
selection_request.available_tools,
selection_request.valid_tool_names,
request,
)
return _ToolSelectionAttempt(
request=modified_request,
selected_tool_names=self._extract_selected_tool_names(modified_request),
status="selected",
)
except Exception as err:
return _ToolSelectionAttempt(
request=request,
selected_tool_names=self._extract_selected_tool_names(request),
status="failed_fallback",
detail=str(err),
)
async def abefore_agent( # noqa
self,
@@ -583,10 +592,22 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
这样后续多轮 `model -> tools -> model` 循环都只复用这一次结果,
不会为每次模型回合重复追加一笔 selector LLM 开销。
"""
if "selected_tool_names" in state:
return None
if not self.selection_tools or self.model is None:
detail = "没有可筛选工具" if not self.selection_tools else "未配置筛选模型"
self._log_selection_attempt(
_ToolSelectionAttempt(
request=ModelRequest(
model=self.model,
tools=list(self.selection_tools),
messages=state["messages"],
state=state,
runtime=runtime,
),
selected_tool_names=[],
status="skipped",
detail=detail,
)
)
return ToolSelectionStateUpdate(selected_tool_names=None)
selection_request = ModelRequest(
@@ -596,9 +617,10 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
state=state,
runtime=runtime,
)
modified_request = await self._aselect_request_once(selection_request)
selected_tool_names = self._extract_selected_tool_names(modified_request)
return ToolSelectionStateUpdate(selected_tool_names=selected_tool_names or None)
attempt = await self._aselect_request_once_with_status(selection_request)
self._log_selection_attempt(attempt)
selected_tool_names = attempt.selected_tool_names
return ToolSelectionStateUpdate(selected_tool_names=selected_tool_names)
async def awrap_model_call(
self,
@@ -619,11 +641,13 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
and self.selection_tools
and self.model is not None
):
request = await self._aselect_request_once(request)
selected_tool_names = self._extract_selected_tool_names(request) or None
attempt = await self._aselect_request_once_with_status(request)
self._log_selection_attempt(attempt)
request = attempt.request
selected_tool_names = attempt.selected_tool_names
request.state["selected_tool_names"] = selected_tool_names # noqa
if selected_tool_names:
if selected_tool_names is not None:
request = self._apply_selected_tools(request, selected_tool_names)
return await handler(request)

View File

@@ -315,8 +315,6 @@ class PromptManager:
"项目根目录": settings.ROOT_PATH,
"配置目录": settings.CONFIG_PATH,
"临时目录": settings.TEMP_PATH,
"日志目录": settings.LOG_PATH,
"主日志文件": settings.LOG_PATH / "moviepilot.log",
}
return [f" - {label}: `{path}`" for label, path in paths.items()]

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import re
import shutil
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterable, Optional
@@ -243,9 +244,15 @@ class AgentRuntimeManager:
self._cache_lock = threading.Lock()
self._cached_signature: Optional[tuple[tuple[str, int, int], ...]] = None
self._cached_config: Optional[AgentRuntimeConfig] = None
self._cached_signature_checked_at = 0.0
self._signature_check_interval = 1.0
self._layout_ready = False
def ensure_layout(self) -> None:
"""创建目录、同步默认文件,并清理废弃的旧版 runtime 文件。"""
with self._cache_lock:
if self._layout_ready:
return
self.agent_root_dir.mkdir(parents=True, exist_ok=True)
self.runtime_dir.mkdir(parents=True, exist_ok=True)
self.memory_dir.mkdir(parents=True, exist_ok=True)
@@ -257,11 +264,13 @@ class AgentRuntimeManager:
self._remove_obsolete_runtime_files()
self._sync_bundled_defaults()
self._migrate_root_memory_files()
with self._cache_lock:
self._layout_ready = True
def load_runtime_config(self) -> AgentRuntimeConfig:
"""加载配置。用户目录损坏时自动回退到内置默认配置。"""
self.ensure_layout()
signature = self._build_signature()
signature = self.current_signature()
with self._cache_lock:
if self._cached_signature == signature and self._cached_config:
return self._cached_config
@@ -269,7 +278,7 @@ class AgentRuntimeManager:
try:
config = self._load_from_root(self.runtime_dir)
except AgentRuntimeConfigError as err:
logger.warning("Agent 根层配置无效,回退到内置默认配置: %s", err)
logger.warning(f"Agent 根层配置无效,回退到内置默认配置: {err}")
config = self._load_from_root(self.bundled_defaults_dir)
config.used_fallback = True
config.warnings.insert(
@@ -285,6 +294,25 @@ class AgentRuntimeManager:
with self._cache_lock:
self._cached_signature = None
self._cached_config = None
self._cached_signature_checked_at = 0.0
self._layout_ready = False
def current_signature(self) -> tuple[tuple[str, int, int], ...]:
"""返回当前运行时配置文件签名,供调用方判断缓存是否仍可复用。"""
now = time.monotonic()
with self._cache_lock:
if (
self._cached_signature is not None
and now - self._cached_signature_checked_at
< self._signature_check_interval
):
return self._cached_signature
signature = self._build_signature()
with self._cache_lock:
self._cached_signature = signature
self._cached_signature_checked_at = now
return signature
def set_active_persona(self, persona_query: str) -> AgentRuntimeConfig:
"""切换当前激活人格,并立即刷新缓存。"""
@@ -308,7 +336,7 @@ class AgentRuntimeManager:
)
current_path.write_text(document, encoding="utf-8")
self.invalidate_cache()
logger.info("已切换 Agent 人格: %s", persona.persona_id)
logger.info(f"已切换 Agent 人格: {persona.persona_id}")
return self.load_runtime_config()
def list_personas(self) -> list[PersonaDefinition]:
@@ -439,7 +467,7 @@ class AgentRuntimeManager:
continue
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, target)
logger.info("已同步默认 Agent 运行时文件: %s", target)
logger.info(f"已同步默认 Agent 运行时文件: {target}")
@classmethod
def _should_update_bundled_subagent(
@@ -478,7 +506,7 @@ class AgentRuntimeManager:
return
target.parent.mkdir(parents=True, exist_ok=True)
source.rename(target)
logger.info("已迁移旧版 Agent 根配置文件: %s -> %s", source, target)
logger.info(f"已迁移旧版 Agent 根配置文件: {source} -> {target}")
def _remove_obsolete_runtime_files(self) -> None:
"""删除不再支持的旧版 Agent 配置文件,避免被误迁移到 memory。"""
@@ -487,14 +515,14 @@ class AgentRuntimeManager:
if not path.exists() or not path.is_file():
continue
path.unlink()
logger.info("已删除废弃的 Agent 根配置文件: %s", path)
logger.info(f"已删除废弃的 Agent 根配置文件: {path}")
for relative_path in sorted(OBSOLETE_RUNTIME_FILES):
path = self.runtime_dir / relative_path
if not path.exists() or not path.is_file():
continue
path.unlink()
logger.info("已删除废弃的 Agent 运行时文件: %s", path)
logger.info(f"已删除废弃的 Agent 运行时文件: {path}")
def _migrate_root_memory_files(self) -> None:
"""将旧版根目录 memory 文件移入 `config/agent/memory`。"""
@@ -505,7 +533,7 @@ class AgentRuntimeManager:
if target.exists():
continue
path.rename(target)
logger.info("已迁移旧版 Agent memory 文件: %s -> %s", path, target)
logger.info(f"已迁移旧版 Agent memory 文件: {path} -> {target}")
def _load_from_root(self, root: Path) -> AgentRuntimeConfig:
current_persona_path = root / CURRENT_PERSONA_FILE

View File

@@ -238,10 +238,6 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
# 获取工具执行提示消息
tool_message = self.get_tool_message(**kwargs)
if not tool_message:
explanation = kwargs.get("explanation")
if explanation:
tool_message = explanation
# 发送工具执行过程消息(流式传输且非最后终结工具时)
if self._stream_handler and self._stream_handler.is_streaming and not self.return_direct:
@@ -325,16 +321,13 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
获取工具执行时的友好提示消息。
子类可以重写此方法,根据实际参数生成个性化的提示消息。
如果返回 None 或空字符串,将回退使用 explanation 参数。
Args:
**kwargs: 工具的所有参数(包括 explanation
**kwargs: 工具的所有参数
Returns:
str: 友好的提示消息,如果返回 None 或空字符串则使用 explanation
str: 友好的提示消息
"""
explanation = kwargs.get("explanation")
return str(explanation) if explanation else None
return None
@abstractmethod
async def run(self, **kwargs) -> str:
@@ -431,7 +424,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
:return: 普通用户允许读写的本地目录列表
"""
roots = [
settings.CONFIG_PATH / "agent"
settings.CONFIG_PATH / "agent",
]
resolved_roots = []
for root in roots:
@@ -467,7 +460,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
allowed_text = "".join(str(root) for root in allowed_roots)
return (
resolved_path,
f"抱歉,普通用户只能{operation}配置目录、Agent记忆目录和日志目录内的文件或目录:{allowed_text}",
f"抱歉,普通用户只能{operation}Agent配置目录内的文件或目录:{allowed_text}",
)
async def _check_local_storage_access(
@@ -489,7 +482,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
return None, None
return (
None,
f"抱歉,普通用户只能{operation}本地配置目录、Agent记忆目录和日志目录,不能访问远程存储。",
f"抱歉,普通用户只能{operation}本地Agent配置目录,不能访问远程存储。",
)
return await self._check_local_file_access(path=path, operation=operation)
@@ -515,8 +508,8 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
return (
"抱歉,您没有执行此工具的权限。"
"只有渠道管理员或系统管理员才能执行工具操作。"
"如需执行工具,请联系渠道管理员将您的用户ID添加到渠道管理员列表中"
"或联系系统管理员为您设置权限。"
"如需执行工具请联系管理员将您的用户ID添加到渠道管理员列表中(设定 -> 通知 -> 对应渠道配置 -> 管理员名单)"
"或联系系统管理员为您设置管理员权限。"
)
async def _has_channel_admin_permission(self) -> bool:
@@ -655,5 +648,6 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
title=title,
text=message,
image=image,
save_history=False,
)
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ from app.core.config import settings
from app.core.context import Context
from app.core.metainfo import MetaInfo
from app.db.site_oper import SiteOper
from app.helper.directory import DirectoryHelper
from app.helper.directory import DirectoryHelper, validate_download_save_path
from app.log import logger
from app.schemas import FileURI, TorrentInfo
from app.utils.crypto import HashUtils
@@ -24,7 +24,6 @@ from app.utils.crypto import HashUtils
class AddDownloadTasksInput(BaseModel):
"""添加下载任务工具的输入参数模型"""
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
torrent_url: List[str] = Field(
...,
description="One or more torrent_url values. Supports refs from get_search_results (`hash:id`) and magnet links."
@@ -184,8 +183,8 @@ class AddDownloadTasksTool(MoviePilotTool):
@staticmethod
def _resolve_direct_download_dir(save_path: Optional[str]) -> Optional[Path]:
"""解析直接下载使用的目录,优先使用 save_path其次使用默认下载目录"""
if save_path:
return Path(save_path)
if save_path is not None:
return Path(validate_download_save_path(save_path))
download_dirs = DirectoryHelper().get_download_dirs()
if not download_dirs:
@@ -226,6 +225,8 @@ class AddDownloadTasksTool(MoviePilotTool):
merged_labels: Optional[str],
) -> tuple[Optional[str], Optional[str]]:
"""同步提交带上下文的下载任务,避免站点下载与下载器调用阻塞事件循环。"""
if save_path is not None:
save_path = validate_download_save_path(save_path)
return DownloadChain().download_single(
context=context,
downloader=downloader,
@@ -246,6 +247,12 @@ class AddDownloadTasksTool(MoviePilotTool):
if not torrent_inputs:
return "错误torrent_url 不能为空。"
if save_path is not None:
try:
save_path = validate_download_save_path(save_path)
except ValueError as err:
return f"参数错误save_path {str(err)}"
merged_labels = self._merge_labels_with_system_tag(labels)
success_count = 0
failed_messages = []

View File

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

View File

@@ -15,8 +15,6 @@ from app.schemas.types import MediaType, MessageChannel
class AddSubscribeInput(BaseModel):
"""添加订阅工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
title: str = Field(
...,
description="The title of the media to subscribe to (e.g., 'The Matrix', 'Breaking Bad')",

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,8 +13,6 @@ from app.log import logger
class DeleteDownloadTasksInput(BaseModel):
"""删除下载任务工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
hash: str = Field(
..., description="Task hash (can be obtained from query_download_tasks tool)"
)

View File

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

View File

@@ -16,8 +16,6 @@ from app.schemas.types import EventType
class DeleteSubscribeInput(BaseModel):
"""删除订阅工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
subscribe_id: int = Field(
...,
description="The ID of the subscription to delete (can be obtained from query_subscribes tool)",

View File

@@ -15,8 +15,6 @@ from app.schemas import FileItem
class DeleteTransferHistoryInput(BaseModel):
"""删除整理历史记录工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
history_id: int = Field(
..., description="The ID of the transfer history record to delete"
)

View File

@@ -12,7 +12,7 @@ from app.log import logger
class EditFileInput(BaseModel):
"""Input parameters for edit file tool"""
"""文件编辑工具的输入参数模型。"""
file_path: str = Field(..., description="The absolute path of the file to edit")
old_text: str = Field(..., description="The exact old text to be replaced")
@@ -27,8 +27,8 @@ class EditFileTool(MoviePilotTool):
]
description: str = (
"Edit a local text file by replacing specific old text with new text. "
"Non-admin users can only edit files inside the MoviePilot config, "
"Agent memory/activity, and log directories."
"Non-admin users can only edit files inside the MoviePilot Agent config "
"directory."
)
args_schema: Type[BaseModel] = EditFileInput

View File

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

View File

@@ -15,8 +15,6 @@ from app.schemas.types import MediaType, media_type_to_agent
class GetRecommendationsInput(BaseModel):
"""获取推荐工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
source: Optional[str] = Field(
"tmdb_trending",
description="Recommendation source: "

View File

@@ -21,8 +21,6 @@ from ._torrent_search_utils import (
class GetSearchResultsInput(BaseModel):
"""获取搜索结果工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
site: Optional[List[str]] = Field(None, description="Site name filters")
season: Optional[List[str]] = Field(None, description="Season or episode filters")
free_state: Optional[List[str]] = Field(None, description="Promotion state filters")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,10 +14,6 @@ from app.log import logger
class QueryDoctorReportInput(BaseModel):
"""查询 Doctor 诊断报告工具的输入参数模型。"""
explanation: Optional[str] = Field(
None,
description="Clear explanation of why this tool is being used in the current context",
)
deep: Optional[bool] = Field(
False,
description=(

View File

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

View File

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

View File

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

View File

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

View File

@@ -77,7 +77,6 @@ def _build_tv_server_result(existing_seasons: OrderedDict, total_seasons: Ordere
class QueryLibraryExistsInput(BaseModel):
"""查询媒体库工具的输入参数模型"""
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
tmdb_id: Optional[int] = Field(None, description="TMDB ID (can be obtained from search_media tool). Either tmdb_id or douban_id must be provided.")
douban_id: Optional[str] = Field(None, description="Douban ID (can be obtained from search_media tool). Either tmdb_id or douban_id must be provided.")
media_type: Optional[str] = Field(None, description="Allowed values: movie, tv")

View File

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

View File

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

View File

@@ -18,7 +18,6 @@ SEASON_PREVIEW_LIMIT = 100
class QueryMediaDetailInput(BaseModel):
"""查询媒体详情工具的输入参数模型"""
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
tmdb_id: Optional[int] = Field(None, description="TMDB ID of the media (movie or TV series, can be obtained from search_media tool)")
douban_id: Optional[str] = Field(None, description="Douban ID of the media (alternative to tmdb_id)")
media_type: str = Field(..., description="Allowed values: movie, tv")

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,7 +18,6 @@ MAX_PAGE_SIZE = 50
class QueryPopularSubscribesInput(BaseModel):
"""查询热门订阅工具的输入参数模型"""
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
media_type: str = Field(..., description="Allowed values: movie, tv")
page: Optional[int] = Field(1, description="Page number for pagination (default: 1)")
count: Optional[int] = Field(30, description="Number of items per page (default: 30, max: 50)")

View File

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

View File

@@ -12,9 +12,6 @@ from app.log import logger
class QuerySchedulersInput(BaseModel):
"""查询定时服务工具的输入参数模型"""
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
class QuerySchedulersTool(MoviePilotTool):
name: str = "query_schedulers"
tags: list[str] = [

View File

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

View File

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

View File

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

View File

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

View File

@@ -48,8 +48,6 @@ QUERY_SUBSCRIBE_OUTPUT_FIELDS = [
class QuerySubscribesInput(BaseModel):
"""查询订阅工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
status: Optional[str] = Field(
"all",
description="Filter subscriptions by status: 'R' for enabled subscriptions, 'S' for paused ones, 'all' for all subscriptions",

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ MAX_READ_SIZE = 50 * 1024
class ReadFileInput(BaseModel):
"""Input parameters for read file tool"""
"""文件读取工具的输入参数模型。"""
file_path: str = Field(..., description="The absolute path of the file to read")
start_line: Optional[int] = Field(None, description="The starting line number (1-based, inclusive). If not provided, reading starts from the beginning of the file.")
end_line: Optional[int] = Field(None, description="The ending line number (1-based, inclusive). If not provided, reading goes until the end of the file.")

View File

@@ -15,10 +15,6 @@ from app.log import logger
class RecognizeCaptchaInput(BaseModel):
"""识别图形验证码工具的输入参数模型。"""
explanation: Optional[str] = Field(
None,
description="Clear explanation of why this captcha image needs to be recognized",
)
image_url: str = Field(
...,
description=(

View File

@@ -16,7 +16,6 @@ from app.schemas.types import media_type_to_agent
class RecognizeMediaInput(BaseModel):
"""识别媒体信息工具的输入参数模型"""
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
title: Optional[str] = Field(None, description="The title of the torrent/media to recognize (required for torrent recognition)")
subtitle: Optional[str] = Field(None, description="The subtitle or description of the torrent (optional, helps improve recognition accuracy)")
path: Optional[str] = Field(None, description="The file path to recognize (required for file recognition, mutually exclusive with title)")

View File

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

View File

@@ -12,8 +12,6 @@ from app.log import logger
class RunSchedulerInput(BaseModel):
"""运行定时服务工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
job_id: str = Field(
...,
description="The ID of the scheduled job to run (can be obtained from query_schedulers tool)",

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,6 @@ from app.schemas.types import MediaType, media_type_to_agent
class SearchMediaInput(BaseModel):
"""搜索媒体工具的输入参数模型"""
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
title: str = Field(..., description="The title of the media to search for (e.g., 'The Matrix', 'Breaking Bad')")
year: Optional[str] = Field(None, description="Release year of the media (optional, helps narrow down results)")
media_type: Optional[str] = Field(None,

View File

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

View File

@@ -15,7 +15,6 @@ from app.log import logger
class SearchPersonCreditsInput(BaseModel):
"""搜索演员参演作品工具的输入参数模型"""
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
person_id: int = Field(..., description="The ID of the person/actor to search for credits (e.g., 31 for Tom Hanks in TMDB)")
source: str = Field(..., description="The data source: 'tmdb' for TheMovieDB, 'douban' for Douban, 'bangumi' for Bangumi")
page: Optional[int] = Field(1, description="Page number for pagination (default: 1)")

View File

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

View File

@@ -9,7 +9,7 @@ from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.search import SearchChain
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.sites import SitesHelper
from app.helper.sites import SitesHelper # noqa
from app.log import logger
from app.schemas.types import MediaType, SystemConfigKey
from ._torrent_search_utils import (
@@ -20,7 +20,6 @@ from ._torrent_search_utils import (
class SearchTorrentsInput(BaseModel):
"""搜索种子工具的输入参数模型"""
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
tmdb_id: Optional[int] = Field(None, description="TMDB ID (can be obtained from search_media tool). Either tmdb_id or douban_id must be provided.")
douban_id: Optional[str] = Field(None, description="Douban ID (can be obtained from search_media tool). Either tmdb_id or douban_id must be provided.")
media_type: Optional[str] = Field(None, description="Allowed values: movie, tv")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,8 +14,6 @@ from app.schemas import FileItem, MediaType
class TransferFileInput(BaseModel):
"""整理文件或目录工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
file_path: str = Field(
...,
description="Path to the file or directory to transfer (e.g., '/path/to/file.mkv' or '/path/to/directory')",

View File

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

View File

@@ -23,8 +23,6 @@ from app.schemas.types import SystemConfigKey
class UpdateCustomFilterRuleInput(BaseModel):
"""更新自定义过滤规则工具的输入参数模型"""
explanation: Optional[str] = Field(None,
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."
)

View File

@@ -7,6 +7,7 @@ from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.core.metainfo import clear_rust_parse_options_cache
from app.db.systemconfig_oper import SystemConfigOper
from app.log import logger
from app.schemas.types import SystemConfigKey
@@ -15,8 +16,6 @@ from app.schemas.types import SystemConfigKey
class UpdateCustomIdentifiersInput(BaseModel):
"""更新自定义识别词工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
identifiers: List[str] = Field(
...,
description=(
@@ -87,6 +86,7 @@ class UpdateCustomIdentifiersTool(MoviePilotTool):
SystemConfigKey.CustomIdentifiers, value
)
if success:
clear_rust_parse_options_cache()
return json.dumps(
{
"success": True,

View File

@@ -8,16 +8,13 @@ from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.download import DownloadChain
from app.helper.directory import validate_download_save_path
from app.log import logger
class UpdateDownloadTasksInput(BaseModel):
"""更新下载任务工具的输入参数模型"""
explanation: Optional[str] = Field(
None,
description="Clear explanation of why this tool is being used in the current context",
)
hash: str = Field(
..., description="Task hash (can be obtained from query_download_tasks tool)"
)
@@ -154,6 +151,18 @@ class UpdateDownloadTasksTool(MoviePilotTool):
],
}
if save_path is not None:
try:
save_path = validate_download_save_path(save_path)
except ValueError:
return {
"hash": hash_value,
"downloader": resolved_downloader,
"results": [
cls._build_result("save_path", False, "保存目录不在允许的下载目录范围内")
],
}
results = []
if tags:
tag_result = download_chain.set_torrents_tag(

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