mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-27 11:11:07 +08:00
Compare commits
228 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f32b48d8c | ||
|
|
c6bd396794 | ||
|
|
7985268f10 | ||
|
|
865635c59d | ||
|
|
759b9e47eb | ||
|
|
63e492be7c | ||
|
|
b5eca00ba3 | ||
|
|
b80642f56f | ||
|
|
6d3161f3cb | ||
|
|
ea8d1f8d26 | ||
|
|
5654512d41 | ||
|
|
a52e1fdc1c | ||
|
|
44db45ea28 | ||
|
|
83409c1439 | ||
|
|
cf6c73d85b | ||
|
|
987c1722d7 | ||
|
|
57220c93db | ||
|
|
4abae809c2 | ||
|
|
7fe7be6d71 | ||
|
|
4b1df72a4a | ||
|
|
a23ac6c56d | ||
|
|
48f4bd5f18 | ||
|
|
7a2b003d02 | ||
|
|
a700ba9379 | ||
|
|
36dd381260 | ||
|
|
f25ee25bb0 | ||
|
|
d625196b08 | ||
|
|
b1b6a81cef | ||
|
|
c6b94d4908 | ||
|
|
52ca375f3d | ||
|
|
d8adb4fbfe | ||
|
|
51d2ed1200 | ||
|
|
702801d0dc | ||
|
|
3e32eab98f | ||
|
|
4292678672 | ||
|
|
7c3f9629bf | ||
|
|
93761fe7e4 | ||
|
|
593139faac | ||
|
|
6c89f1eb4b | ||
|
|
2310a3a456 | ||
|
|
48852350a0 | ||
|
|
a23fce1491 | ||
|
|
c976741574 | ||
|
|
04facef64d | ||
|
|
33a97eb2c8 | ||
|
|
cf80b551f9 | ||
|
|
e011b20210 | ||
|
|
bdf395f494 | ||
|
|
68686bc23a | ||
|
|
1a528c7803 | ||
|
|
6b4a255f26 | ||
|
|
1a3c1b8b39 | ||
|
|
8788dae34b | ||
|
|
bb00814d7a | ||
|
|
3d55d44457 | ||
|
|
7a5e565b15 | ||
|
|
cae28d8c03 | ||
|
|
3d020c8ceb | ||
|
|
1b065cc08b | ||
|
|
a1a6376adc | ||
|
|
197a09b2a4 | ||
|
|
8d099b9581 | ||
|
|
ff9ba79b60 | ||
|
|
6354a48405 | ||
|
|
f6df6cc093 | ||
|
|
98e69d1b45 | ||
|
|
4b9af5b8c7 | ||
|
|
059a50f7f8 | ||
|
|
14fed2d70b | ||
|
|
875984ad39 | ||
|
|
297cd04fbc | ||
|
|
3dde94be0f | ||
|
|
98ee939236 | ||
|
|
c6611f6210 | ||
|
|
503ee90c0c | ||
|
|
fb32c59713 | ||
|
|
a3c90c64ca | ||
|
|
de97cb3c0a | ||
|
|
3b709b7f2e | ||
|
|
6f8b6cfbc9 | ||
|
|
e3f80af74f | ||
|
|
1d708870c9 | ||
|
|
053e1b7562 | ||
|
|
4ca3e40507 | ||
|
|
318d2ab7d7 | ||
|
|
7c2390908a | ||
|
|
5c2b503a74 | ||
|
|
44fa202778 | ||
|
|
ed92be08af | ||
|
|
9ed0704c5b | ||
|
|
e46b4e5ba0 | ||
|
|
87ad7988b2 | ||
|
|
1382975b18 | ||
|
|
d9a42c672a | ||
|
|
b042086efa | ||
|
|
36fefa14e0 | ||
|
|
1332576c3f | ||
|
|
4300af0e9c | ||
|
|
405350c774 | ||
|
|
d666134ed2 | ||
|
|
5588e37c6d | ||
|
|
2056aa0b2c | ||
|
|
b0ff3ae3c7 | ||
|
|
31544629b4 | ||
|
|
142393f2d3 | ||
|
|
5cf79e0360 | ||
|
|
f152a0381d | ||
|
|
428c19b6ba | ||
|
|
8b5524a321 | ||
|
|
b972b46747 | ||
|
|
0598fbdd75 | ||
|
|
572299a45e | ||
|
|
229824a417 | ||
|
|
a0ee99aacc | ||
|
|
92918ce380 | ||
|
|
a4335fe753 | ||
|
|
107ba37834 | ||
|
|
c27678ce06 | ||
|
|
7725342a80 | ||
|
|
893269f8c1 | ||
|
|
00d46f3aab | ||
|
|
077241b6ed | ||
|
|
b24a07e388 | ||
|
|
f814c271cc | ||
|
|
e015c67689 | ||
|
|
98b16bda8d | ||
|
|
b8233e1789 | ||
|
|
83107bf447 | ||
|
|
3a2f90c567 | ||
|
|
4826e3301c | ||
|
|
1855ba81ec | ||
|
|
96ef431efc | ||
|
|
4f2935c85e | ||
|
|
2a49495e27 | ||
|
|
b628bc7209 | ||
|
|
29068a5846 | ||
|
|
51a7120c79 | ||
|
|
476dfef7d9 | ||
|
|
bd5ddd6158 | ||
|
|
a30a48b8f4 | ||
|
|
8e60e5571b | ||
|
|
18c1ec4b82 | ||
|
|
30b932e07e | ||
|
|
54be1143fc | ||
|
|
13f27854fd | ||
|
|
770201c48c | ||
|
|
685f044312 | ||
|
|
8c0afac5d1 | ||
|
|
099ef7d5bf | ||
|
|
f3ac69669c | ||
|
|
eb4ecd990a | ||
|
|
b51971ee7d | ||
|
|
6f6ed998bb | ||
|
|
844407dc41 | ||
|
|
c54605f8ce | ||
|
|
0fbf05d72f | ||
|
|
09bb32f681 | ||
|
|
a37f118576 | ||
|
|
e635bc8e04 | ||
|
|
8245124e82 | ||
|
|
827ed8330c | ||
|
|
136c1baed3 | ||
|
|
992031ef95 | ||
|
|
b16c50b03a | ||
|
|
76803ae7a3 | ||
|
|
56bda11947 | ||
|
|
1b12d7664e | ||
|
|
db9960d9b9 | ||
|
|
2f0c1252da | ||
|
|
36d4434596 | ||
|
|
93e907d032 | ||
|
|
132f27c1c6 | ||
|
|
b231ad415f | ||
|
|
0f183ae08e | ||
|
|
a71d3ea03f | ||
|
|
7f82a9ea4d | ||
|
|
d977e4c48a | ||
|
|
95b6adbeee | ||
|
|
964fee1106 | ||
|
|
656473f3aa | ||
|
|
ab5995a609 | ||
|
|
064e6535d5 | ||
|
|
cab2ac400a | ||
|
|
d14d401c86 | ||
|
|
6c3c5e042d | ||
|
|
f3e5be37fd | ||
|
|
d8f7fa70af | ||
|
|
6916ee0988 | ||
|
|
6fef533527 | ||
|
|
c57985d553 | ||
|
|
ec07379a67 | ||
|
|
2764742b86 | ||
|
|
a0f613fa1e | ||
|
|
73d5c95f4e | ||
|
|
4d30dee74c | ||
|
|
302d8bbf5c | ||
|
|
b646cbb4f6 | ||
|
|
dd73b97095 | ||
|
|
0cb0bac0e1 | ||
|
|
9eb71c744b | ||
|
|
8bf826faa0 | ||
|
|
df4e45c644 | ||
|
|
494f809ef0 | ||
|
|
a4f6e13881 | ||
|
|
36fb82b7aa | ||
|
|
9b1bdb0cb2 | ||
|
|
2a89bfd25c | ||
|
|
d3d1c18316 | ||
|
|
027330f714 | ||
|
|
6fc1c672ad | ||
|
|
d383c9ffd1 | ||
|
|
27e1f634cb | ||
|
|
a9197c434e | ||
|
|
2a8708498c | ||
|
|
098db56a36 | ||
|
|
a8ccb08dd3 | ||
|
|
ff7a238309 | ||
|
|
318cfe68e9 | ||
|
|
0f98cda3b4 | ||
|
|
544ed6d84d | ||
|
|
bb9b6ec5d0 | ||
|
|
7d2a730b0c | ||
|
|
1b6a548dee | ||
|
|
52c5f2900f | ||
|
|
43e89ebf77 | ||
|
|
bc52653ec1 | ||
|
|
4233ebfba6 | ||
|
|
af32c5e9bb |
@@ -71,6 +71,7 @@ test_*
|
||||
|
||||
# Build artifacts
|
||||
build/
|
||||
.build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
rust/**/target/
|
||||
|
||||
16
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
16
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -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:
|
||||
|
||||
27
.github/workflows/beta.yml
vendored
27
.github/workflows/beta.yml
vendored
@@ -2,6 +2,10 @@ name: MoviePilot Builder Beta
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
Docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -16,6 +20,25 @@ jobs:
|
||||
app_version=$(cat version.py |sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp")
|
||||
echo "app_version=$app_version" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout Wiki Plugin Market
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: jxxghp/MoviePilot-Wiki
|
||||
ref: main
|
||||
path: .build/moviepilot-wiki
|
||||
sparse-checkout: plugin.md
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
|
||||
- name: Generate Plugin Market Default
|
||||
id: plugin_market
|
||||
run: |
|
||||
python3 -m scripts.generate_plugin_market_default \
|
||||
--wiki-file .build/moviepilot-wiki/plugin.md \
|
||||
--config-file app/core/config.py
|
||||
wiki_commit=$(git -C .build/moviepilot-wiki rev-parse HEAD)
|
||||
echo "wiki_commit=$wiki_commit" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Docker Meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
@@ -55,6 +78,8 @@ jobs:
|
||||
linux/arm64/v8
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
labels: |
|
||||
${{ steps.meta.outputs.labels }}
|
||||
org.moviepilot.plugin-market-wiki-revision=${{ steps.plugin_market.outputs.wiki_commit }}
|
||||
cache-from: type=gha,scope=moviepilot-docker,version=2
|
||||
cache-to: type=gha,scope=moviepilot-docker,mode=max,version=2
|
||||
|
||||
57
.github/workflows/build.yml
vendored
57
.github/workflows/build.yml
vendored
@@ -7,6 +7,10 @@ on:
|
||||
paths:
|
||||
- 'version.py'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
Docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -23,6 +27,39 @@ jobs:
|
||||
run: |
|
||||
app_version=$(cat version.py |sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp")
|
||||
echo "app_version=$app_version" >> $GITHUB_ENV
|
||||
echo "SOURCE_COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout Wiki Plugin Market
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: jxxghp/MoviePilot-Wiki
|
||||
ref: main
|
||||
path: .build/moviepilot-wiki
|
||||
sparse-checkout: plugin.md
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
|
||||
- name: Generate Plugin Market Default
|
||||
id: plugin_market
|
||||
run: |
|
||||
python3 -m scripts.generate_plugin_market_default \
|
||||
--wiki-file .build/moviepilot-wiki/plugin.md \
|
||||
--config-file app/core/config.py
|
||||
wiki_commit=$(git -C .build/moviepilot-wiki rev-parse HEAD)
|
||||
echo "wiki_commit=$wiki_commit" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create Release Snapshot
|
||||
id: release_snapshot
|
||||
env:
|
||||
WIKI_COMMIT: ${{ steps.plugin_market.outputs.wiki_commit }}
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add app/core/config.py
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "build(plugin-market): sync default from MoviePilot-Wiki@${WIKI_COMMIT:0:12}"
|
||||
fi
|
||||
echo "release_commit=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Docker Meta
|
||||
id: meta
|
||||
@@ -65,7 +102,10 @@ jobs:
|
||||
linux/arm64/v8
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
labels: |
|
||||
${{ steps.meta.outputs.labels }}
|
||||
org.opencontainers.image.revision=${{ steps.release_snapshot.outputs.release_commit }}
|
||||
org.moviepilot.plugin-market-wiki-revision=${{ steps.plugin_market.outputs.wiki_commit }}
|
||||
cache-from: type=gha,scope=moviepilot-docker,version=2
|
||||
cache-to: type=gha,scope=moviepilot-docker,mode=max,version=2
|
||||
|
||||
@@ -78,9 +118,9 @@ jobs:
|
||||
|
||||
# 使用 || 作为分隔符,同时获取 commit 消息和作者 GitHub 用户名
|
||||
if [ -z "$PREVIOUS_TAG" ]; then
|
||||
COMMITS=$(git log --pretty=format:"%s||%an" HEAD)
|
||||
COMMITS=$(git log --pretty=format:"%s||%an" "${SOURCE_COMMIT}")
|
||||
else
|
||||
COMMITS=$(git log --pretty=format:"%s||%an" ${PREVIOUS_TAG}..HEAD)
|
||||
COMMITS=$(git log --pretty=format:"%s||%an" "${PREVIOUS_TAG}..${SOURCE_COMMIT}")
|
||||
fi
|
||||
|
||||
# 分类收集 commit 消息(使用关联数组去重)
|
||||
@@ -188,6 +228,17 @@ jobs:
|
||||
delete_release: true
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Publish Release Tag
|
||||
env:
|
||||
RELEASE_COMMIT: ${{ steps.release_snapshot.outputs.release_commit }}
|
||||
run: |
|
||||
tag_name="v${{ env.app_version }}"
|
||||
if git show-ref --verify --quiet "refs/tags/${tag_name}"; then
|
||||
git tag -d "$tag_name"
|
||||
fi
|
||||
git tag "$tag_name" "$RELEASE_COMMIT"
|
||||
git push origin "refs/tags/${tag_name}"
|
||||
|
||||
- name: Generate Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
|
||||
101
.github/workflows/pr-agent.yml
vendored
101
.github/workflows/pr-agent.yml
vendored
@@ -1,9 +1,8 @@
|
||||
name: PR Agent
|
||||
name: PR-Agent
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
# PR-Agent 通过 base repo 上下文读取 PR diff 并发布 Review,不 checkout 或执行 PR 分支代码。
|
||||
# pull_request_target 允许 fork PR 使用仓库 secrets,因此 workflow 只运行固定 digest 的 PR-Agent 容器。
|
||||
# Fork 审查需要目标仓库凭据;该 job 仅通过 GitHub API 读取 PR 内容,不 checkout 或执行 PR 分支代码。
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
@@ -11,24 +10,17 @@ on:
|
||||
- review_requested
|
||||
- synchronize
|
||||
issue_comment:
|
||||
# 手动命令如 "/review"、"/describe"、"/improve" 和 "/ask ..." 只在 PR 评论中有意义。
|
||||
# issue_comment 同时覆盖普通 issue,因此 job 里还会再判断是否属于 PR。
|
||||
types:
|
||||
- created
|
||||
- edited
|
||||
|
||||
permissions:
|
||||
# 读取仓库内容和 PR diff。
|
||||
contents: read
|
||||
# 更新 PR 描述、发布 PR Review 或修改 PR 相关元数据。
|
||||
pull-requests: write
|
||||
# PR 评论在 GitHub API 中属于 issue comments,手动命令和总结评论需要该权限。
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
pr-agent:
|
||||
name: PR-Agent review and describe
|
||||
# PR 事件自动处理;评论命令仅允许指定身份在 PR 下触发,避免任意评论消耗模型配额。
|
||||
if: >-
|
||||
github.event.sender.type != 'Bot' &&
|
||||
(
|
||||
@@ -36,88 +28,25 @@ jobs:
|
||||
(
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request != null &&
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'), github.event.comment.author_association) &&
|
||||
(
|
||||
github.event.comment.body == '/review' ||
|
||||
startsWith(github.event.comment.body, '/review ') ||
|
||||
github.event.comment.body == '/describe' ||
|
||||
startsWith(github.event.comment.body, '/describe ') ||
|
||||
github.event.comment.body == '/improve' ||
|
||||
startsWith(github.event.comment.body, '/improve ') ||
|
||||
github.event.comment.body == '/ask' ||
|
||||
startsWith(github.event.comment.body, '/ask ')
|
||||
)
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'), github.event.comment.author_association)
|
||||
)
|
||||
)
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request_target' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Run PR-Agent
|
||||
id: pragent
|
||||
# 使用版本号加 digest 固定容器构建,避免 tag 被重推后改变运行内容。
|
||||
uses: docker://pragent/pr-agent:0.37.0-github_action@sha256:4ec7bac814050a1bc8c96ab2fab6b7b0f65df0049a5ec43f3fee1a0b551c28ca
|
||||
- name: Run PR Review
|
||||
uses: docker://ghcr.io/infinitypacer/pr-review-runner:latest
|
||||
env:
|
||||
# PR-Agent 使用该 token 读取 PR 元数据并发布评论。
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
# 仓库设置中添加的 Secret:Settings -> Secrets and variables -> Actions。
|
||||
# 该 key 只传给 PR-Agent 运行时,不写入仓库。
|
||||
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
|
||||
|
||||
# 仓库设置中添加的 Secret。OpenAI 兼容服务通常需要填写以 "/v1" 结尾的 API 根地址。
|
||||
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
|
||||
|
||||
# 模型、输出语言和大 diff 处理策略。
|
||||
config.model: "gpt-5.5"
|
||||
config.fallback_models: '["gpt-5.4"]'
|
||||
config.reasoning_effort: "xhigh"
|
||||
config.ai_timeout: "900"
|
||||
config.response_language: "zh-CN"
|
||||
config.large_patch_policy: "clip"
|
||||
config.ignore_pr_title: '["^\\[Auto\\]", "^Auto"]'
|
||||
config.ignore_pr_labels: '["skip pr-agent"]'
|
||||
|
||||
# pull_request_target 事件默认自动执行 /review 和 /describe;/improve 保持手动触发。
|
||||
github_action_config.auto_review: "true"
|
||||
github_action_config.auto_describe: "true"
|
||||
github_action_config.auto_improve: "false"
|
||||
|
||||
# 允许触发自动工具的 PR 动作。包含 synchronize,便于新 commit 推送后刷新结果。
|
||||
github_action_config.pr_actions: '["opened", "reopened", "ready_for_review", "review_requested", "synchronize"]'
|
||||
|
||||
# 保留 action outputs,便于后续 workflow 编排或排查。
|
||||
github_action_config.enable_output: "true"
|
||||
|
||||
# /describe 行为控制;与自动触发配置放在同一层,避免使用默认图表和标签策略。
|
||||
pr_description.generate_ai_title: "false"
|
||||
pr_description.publish_labels: "false"
|
||||
pr_description.enable_pr_diagram: "false"
|
||||
pr_description.collapsible_file_list: "adaptive"
|
||||
pr_description.add_original_user_description: "true"
|
||||
|
||||
# /review 输出策略,聚焦维护者需要处理的风险和缺口。
|
||||
pr_reviewer.extra_instructions: |
|
||||
请用中文输出。
|
||||
优先指出 P0/P1 风险,避免纠结纯格式问题。
|
||||
重点检查安全、权限、状态一致性、异步/缓存、副作用和测试缺口。
|
||||
pr_reviewer.num_max_findings: "5"
|
||||
pr_reviewer.persistent_comment: "true"
|
||||
pr_reviewer.publish_output_no_suggestions: "true"
|
||||
pr_reviewer.require_tests_review: "true"
|
||||
pr_reviewer.require_security_review: "true"
|
||||
pr_reviewer.require_estimate_effort_to_review: "true"
|
||||
pr_reviewer.require_can_be_split_review: "true"
|
||||
pr_reviewer.require_todo_scan: "false"
|
||||
pr_reviewer.enable_review_labels_effort: "false"
|
||||
pr_reviewer.enable_review_labels_security: "true"
|
||||
|
||||
# /improve 和 /ask 的手动命令策略。
|
||||
pr_code_suggestions.focus_only_on_problems: "true"
|
||||
pr_code_suggestions.suggestions_score_threshold: "7"
|
||||
pr_code_suggestions.commitable_code_suggestions: "false"
|
||||
pr_questions.use_conversation_history: "true"
|
||||
|
||||
# 可选成本和噪音控制:
|
||||
# github_action_config.auto_improve: "true"
|
||||
# config.verbosity_level: "1"
|
||||
# pr_reviewer.num_max_findings: "3"
|
||||
PRR_AUTO_REVIEW_SCOPE: all
|
||||
PRR_ALLOWED_ASSOCIATIONS: '["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'
|
||||
PRR_DISABLED_COMMANDS: '["/improve"]'
|
||||
PRR_SKIP_LABEL: skip pr-agent
|
||||
PRR_SKIP_TITLE_PATTERN: '^(?:\[Auto\]|Auto)'
|
||||
config.response_language: zh-CN
|
||||
|
||||
134
.github/workflows/site-adapter-collector.yml
vendored
Normal file
134
.github/workflows/site-adapter-collector.yml
vendored
Normal file
@@ -0,0 +1,134 @@
|
||||
name: Site Adapter Collector
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: Existing release tag to receive collector assets; leave empty for artifacts only
|
||||
required: false
|
||||
type: string
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.platform_name }} collector
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform_name: Windows
|
||||
platform_id: windows
|
||||
runner: windows-latest
|
||||
source_name: moviepilot-site-collector.exe
|
||||
asset_name: moviepilot-site-collector-windows.exe
|
||||
artifact_name: site-adapter-collector-windows
|
||||
- platform_name: macOS
|
||||
platform_id: macos
|
||||
runner: macos-latest
|
||||
source_name: moviepilot-site-collector
|
||||
asset_name: MoviePilot-Site-Collector-macOS.zip
|
||||
artifact_name: site-adapter-collector-macos
|
||||
- platform_name: Linux
|
||||
platform_id: linux
|
||||
runner: ubuntu-latest
|
||||
source_name: moviepilot-site-collector
|
||||
asset_name: moviepilot-site-collector-linux
|
||||
artifact_name: site-adapter-collector-linux
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: pip
|
||||
cache-dependency-path: scripts/site_adapter_collector_requirements.txt
|
||||
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip setuptools wheel
|
||||
pip install -r scripts/site_adapter_collector_requirements.txt
|
||||
|
||||
- name: Build single-file collector
|
||||
run: |
|
||||
pyinstaller --clean --noconfirm scripts/site_adapter_collector.spec
|
||||
|
||||
- name: Smoke-test collector
|
||||
env:
|
||||
SOURCE_NAME: ${{ matrix.source_name }}
|
||||
run: |
|
||||
python -c "import os, subprocess; from pathlib import Path; subprocess.run([str((Path('dist') / os.environ['SOURCE_NAME']).resolve()), '--help'], check=True)"
|
||||
|
||||
- name: Package macOS double-click archive
|
||||
if: matrix.platform_id == 'macos'
|
||||
shell: bash
|
||||
env:
|
||||
ASSET_NAME: ${{ matrix.asset_name }}
|
||||
SOURCE_NAME: ${{ matrix.source_name }}
|
||||
run: |
|
||||
package_dir="dist/MoviePilot-Collector"
|
||||
mkdir -p "$package_dir"
|
||||
cp "dist/$SOURCE_NAME" "$package_dir/moviepilot-site-collector-macos"
|
||||
cp scripts/start-site-adapter-collector.command "$package_dir/start-site-adapter-collector.command"
|
||||
chmod +x "$package_dir/moviepilot-site-collector-macos"
|
||||
chmod +x "$package_dir/start-site-adapter-collector.command"
|
||||
cd dist
|
||||
COPYFILE_DISABLE=1 zip -q -r -X "$ASSET_NAME" MoviePilot-Collector
|
||||
|
||||
- name: Rename Windows and Linux collector
|
||||
if: matrix.platform_id != 'macos'
|
||||
env:
|
||||
ASSET_NAME: ${{ matrix.asset_name }}
|
||||
SOURCE_NAME: ${{ matrix.source_name }}
|
||||
run: |
|
||||
python -c "import os; from pathlib import Path; (Path('dist') / os.environ['SOURCE_NAME']).replace(Path('dist') / os.environ['ASSET_NAME'])"
|
||||
|
||||
- name: Generate SHA-256 checksum
|
||||
env:
|
||||
ASSET_NAME: ${{ matrix.asset_name }}
|
||||
run: |
|
||||
python -c "import hashlib, os; from pathlib import Path; path = Path('dist') / os.environ['ASSET_NAME']; path.with_name(path.name + '.sha256').write_text(f'{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}\n', encoding='utf-8')"
|
||||
|
||||
- name: Upload collector artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ matrix.artifact_name }}
|
||||
path: |
|
||||
dist/${{ matrix.asset_name }}
|
||||
dist/${{ matrix.asset_name }}.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
publish:
|
||||
name: Upload collectors to release
|
||||
if: github.event_name == 'release' || inputs.release_tag != ''
|
||||
needs:
|
||||
- build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Download collector artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: site-adapter-collector-*
|
||||
path: release-assets
|
||||
merge-multiple: true
|
||||
|
||||
- name: Upload assets to published release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }}
|
||||
run: |
|
||||
gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null
|
||||
gh release upload "$RELEASE_TAG" release-assets/* --clobber --repo "$GITHUB_REPOSITORY"
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -37,6 +37,7 @@ coverage.json
|
||||
htmlcov/
|
||||
.vscode
|
||||
venv
|
||||
moviepilot-site-capture-*.zip
|
||||
|
||||
# Pylint
|
||||
pylint-report.json
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -51,11 +51,14 @@ from app.agent.middleware.tool_selection import ToolSelectorMiddleware
|
||||
from app.agent.middleware.usage import UsageMiddleware
|
||||
from app.agent.prompt import prompt_manager
|
||||
from app.agent.runtime import agent_runtime_manager
|
||||
from app.agent.mcp import agent_mcp_manager
|
||||
from app.agent.tools.factory import MoviePilotToolFactory
|
||||
from app.agent.tools.impl.mcp import create_external_mcp_tools
|
||||
from app.chain import ChainBase
|
||||
from app.core.config import settings
|
||||
from app.core.event import eventmanager
|
||||
from app.db.agentchat_oper import AgentChatOper
|
||||
from app.db.agenttask_oper import AgentTaskOper
|
||||
from app.db.user_oper import UserOper
|
||||
from app.log import logger
|
||||
from app.schemas import AgentLLMProviderEventData, AgentTokensUsageEventData, Notification, NotificationType
|
||||
@@ -65,6 +68,8 @@ from app.utils.identity import SYSTEM_INTERNAL_USER_ID
|
||||
|
||||
|
||||
class AgentChain(ChainBase):
|
||||
"""Agent 业务处理链。"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@@ -124,9 +129,18 @@ class _SessionUsageSnapshot:
|
||||
last_output_tokens: int = 0
|
||||
last_total_tokens: int = 0
|
||||
last_context_usage_ratio: Optional[float] = None
|
||||
last_cache_usage_available: bool = False
|
||||
last_cache_read_input_tokens: int = 0
|
||||
last_cache_write_input_tokens: int = 0
|
||||
last_uncached_input_tokens: int = 0
|
||||
last_cache_hit_ratio: Optional[float] = None
|
||||
total_input_tokens: int = 0
|
||||
total_output_tokens: int = 0
|
||||
total_tokens: int = 0
|
||||
total_cache_read_input_tokens: int = 0
|
||||
total_cache_write_input_tokens: int = 0
|
||||
total_uncached_input_tokens: int = 0
|
||||
cache_usage_available: bool = False
|
||||
model_call_count: int = 0
|
||||
last_updated_at: Optional[datetime] = None
|
||||
|
||||
@@ -139,9 +153,23 @@ class _SessionUsageSnapshot:
|
||||
"last_output_tokens": self.last_output_tokens,
|
||||
"last_total_tokens": self.last_total_tokens,
|
||||
"last_context_usage_ratio": self.last_context_usage_ratio,
|
||||
"last_cache_usage_available": self.last_cache_usage_available,
|
||||
"last_cache_read_input_tokens": self.last_cache_read_input_tokens,
|
||||
"last_cache_write_input_tokens": self.last_cache_write_input_tokens,
|
||||
"last_uncached_input_tokens": self.last_uncached_input_tokens,
|
||||
"last_cache_hit_ratio": self.last_cache_hit_ratio,
|
||||
"total_input_tokens": self.total_input_tokens,
|
||||
"total_output_tokens": self.total_output_tokens,
|
||||
"total_tokens": self.total_tokens,
|
||||
"total_cache_read_input_tokens": self.total_cache_read_input_tokens,
|
||||
"total_cache_write_input_tokens": self.total_cache_write_input_tokens,
|
||||
"total_uncached_input_tokens": self.total_uncached_input_tokens,
|
||||
"cache_usage_available": self.cache_usage_available,
|
||||
"total_cache_hit_ratio": (
|
||||
self.total_cache_read_input_tokens / self.total_input_tokens
|
||||
if self.cache_usage_available and self.total_input_tokens
|
||||
else None
|
||||
),
|
||||
"model_call_count": self.model_call_count,
|
||||
"last_updated_at": self.last_updated_at.strftime("%Y-%m-%d %H:%M:%S")
|
||||
if self.last_updated_at
|
||||
@@ -313,13 +341,19 @@ class MoviePilotAgent:
|
||||
"""
|
||||
构造可展示的 Agent 会话消息。
|
||||
"""
|
||||
normalized_content = content or ""
|
||||
return {
|
||||
"id": f"{role}-{uuid.uuid4().hex}",
|
||||
"role": role,
|
||||
"content": content or "",
|
||||
"content": normalized_content,
|
||||
"createdAt": cls._current_timestamp_ms(),
|
||||
"status": status,
|
||||
"tools": [],
|
||||
"segments": (
|
||||
[{"type": "text", "content": normalized_content}]
|
||||
if normalized_content
|
||||
else []
|
||||
),
|
||||
"attachments": attachments or [],
|
||||
"choices": [],
|
||||
}
|
||||
@@ -543,9 +577,33 @@ class MoviePilotAgent:
|
||||
self._session_usage.last_output_tokens = output_tokens
|
||||
self._session_usage.last_total_tokens = total_tokens
|
||||
self._session_usage.last_context_usage_ratio = usage.get("context_usage_ratio")
|
||||
cache_usage_available = bool(usage.get("cache_usage_available"))
|
||||
cache_read_input_tokens = self._coerce_int(
|
||||
usage.get("cache_read_input_tokens")
|
||||
) or 0
|
||||
cache_write_input_tokens = self._coerce_int(
|
||||
usage.get("cache_write_input_tokens")
|
||||
) or 0
|
||||
uncached_input_tokens = self._coerce_int(
|
||||
usage.get("uncached_input_tokens")
|
||||
)
|
||||
if uncached_input_tokens is None:
|
||||
uncached_input_tokens = max(
|
||||
input_tokens - cache_read_input_tokens - cache_write_input_tokens,
|
||||
0,
|
||||
)
|
||||
self._session_usage.last_cache_usage_available = cache_usage_available
|
||||
self._session_usage.last_cache_read_input_tokens = cache_read_input_tokens
|
||||
self._session_usage.last_cache_write_input_tokens = cache_write_input_tokens
|
||||
self._session_usage.last_uncached_input_tokens = uncached_input_tokens
|
||||
self._session_usage.last_cache_hit_ratio = usage.get("cache_hit_ratio")
|
||||
self._session_usage.total_input_tokens += input_tokens
|
||||
self._session_usage.total_output_tokens += output_tokens
|
||||
self._session_usage.total_tokens += total_tokens
|
||||
self._session_usage.total_cache_read_input_tokens += cache_read_input_tokens
|
||||
self._session_usage.total_cache_write_input_tokens += cache_write_input_tokens
|
||||
self._session_usage.total_uncached_input_tokens += uncached_input_tokens
|
||||
self._session_usage.cache_usage_available |= cache_usage_available
|
||||
|
||||
def get_session_status(self) -> dict[str, Any]:
|
||||
if not self._session_usage.model:
|
||||
@@ -579,6 +637,17 @@ class MoviePilotAgent:
|
||||
input_tokens=self._session_usage.total_input_tokens,
|
||||
output_tokens=self._session_usage.total_output_tokens,
|
||||
total_tokens=self._session_usage.total_tokens,
|
||||
cache_read_input_tokens=self._session_usage.total_cache_read_input_tokens,
|
||||
cache_write_input_tokens=self._session_usage.total_cache_write_input_tokens,
|
||||
uncached_input_tokens=self._session_usage.total_uncached_input_tokens,
|
||||
cache_hit_ratio=(
|
||||
self._session_usage.total_cache_read_input_tokens
|
||||
/ self._session_usage.total_input_tokens
|
||||
if self._session_usage.cache_usage_available
|
||||
and self._session_usage.total_input_tokens
|
||||
else None
|
||||
),
|
||||
cache_usage_available=self._session_usage.cache_usage_available,
|
||||
model_call_count=self._session_usage.model_call_count,
|
||||
success=success,
|
||||
error=error,
|
||||
@@ -710,7 +779,7 @@ class MoviePilotAgent:
|
||||
"""
|
||||
通过链式事件解析本次 Agent 可用的 LLM 运行时配置。
|
||||
|
||||
若没有插件返回 selected_provider_id,则沿用系统配置,保持既有行为。
|
||||
插件未返回有效配置时沿用系统配置,显式返回的配置优先。
|
||||
"""
|
||||
if self._llm_runtime_config is not None:
|
||||
return self._llm_runtime_config
|
||||
@@ -723,7 +792,9 @@ class MoviePilotAgent:
|
||||
base_url_preset=settings.LLM_BASE_URL_PRESET,
|
||||
user_agent=settings.LLM_USER_AGENT,
|
||||
use_proxy=settings.LLM_USE_PROXY,
|
||||
thinking_level=None,
|
||||
thinking_level=settings.LLM_THINKING_LEVEL,
|
||||
api_protocol=settings.LLM_API_PROTOCOL,
|
||||
web_search_mode=settings.LLM_WEB_SEARCH_MODE,
|
||||
)
|
||||
selected_event = await eventmanager.async_send_event(
|
||||
ChainEventType.AgentLLMProvider,
|
||||
@@ -758,9 +829,18 @@ class MoviePilotAgent:
|
||||
use_proxy = self._get_event_value(resolved_data, "use_proxy")
|
||||
if use_proxy is None:
|
||||
use_proxy = settings.LLM_USE_PROXY
|
||||
thinking_level = self._clean_optional_text(
|
||||
self._get_event_value(resolved_data, "thinking_level")
|
||||
thinking_level = (
|
||||
self._clean_optional_text(
|
||||
self._get_event_value(resolved_data, "thinking_level")
|
||||
)
|
||||
or settings.LLM_THINKING_LEVEL
|
||||
)
|
||||
api_protocol = self._clean_optional_text(
|
||||
self._get_event_value(resolved_data, "api_protocol")
|
||||
) or settings.LLM_API_PROTOCOL
|
||||
web_search_mode = self._clean_optional_text(
|
||||
self._get_event_value(resolved_data, "web_search_mode")
|
||||
) or settings.LLM_WEB_SEARCH_MODE
|
||||
selected_provider_id = self._clean_optional_text(
|
||||
self._get_event_value(resolved_data, "selected_provider_id")
|
||||
)
|
||||
@@ -786,6 +866,8 @@ class MoviePilotAgent:
|
||||
"user_agent": user_agent,
|
||||
"use_proxy": bool(use_proxy),
|
||||
"thinking_level": thinking_level,
|
||||
"api_protocol": api_protocol,
|
||||
"web_search_mode": web_search_mode,
|
||||
}
|
||||
return self._llm_runtime_config
|
||||
|
||||
@@ -795,7 +877,17 @@ class MoviePilotAgent:
|
||||
:param streaming: 是否启用流式输出
|
||||
"""
|
||||
runtime_config = await self._resolve_llm_runtime_config()
|
||||
return await LLMHelper.get_llm(streaming=streaming, **runtime_config)
|
||||
return await LLMHelper.get_llm(
|
||||
streaming=streaming,
|
||||
prompt_cache_key=self._build_prompt_cache_key(),
|
||||
**runtime_config,
|
||||
)
|
||||
|
||||
def _build_prompt_cache_key(self) -> str:
|
||||
"""生成不暴露用户标识、且在同一会话内稳定的提示词缓存键。"""
|
||||
cache_identity = f"{self.user_id or ''}\x00{self.session_id}"
|
||||
digest = hashlib.sha256(cache_identity.encode("utf-8")).hexdigest()[:32]
|
||||
return f"moviepilot-agent-{digest}"
|
||||
|
||||
@classmethod
|
||||
def _has_image_input_content(cls, content: Any) -> bool:
|
||||
@@ -993,6 +1085,13 @@ class MoviePilotAgent:
|
||||
allow_message_tools=self.allow_message_tools,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _filter_local_web_search_tools(tools: List, enabled: bool) -> List:
|
||||
"""按联网搜索策略保留或移除本地 search_web 工具。"""
|
||||
if enabled:
|
||||
return tools
|
||||
return [tool for tool in tools if getattr(tool, "name", None) != "search_web"]
|
||||
|
||||
def _refresh_tool_context(self, values: Dict[str, object]) -> None:
|
||||
"""
|
||||
刷新本轮工具共享上下文。
|
||||
@@ -1021,6 +1120,8 @@ class MoviePilotAgent:
|
||||
runtime_config.get("user_agent"),
|
||||
bool(runtime_config.get("use_proxy")),
|
||||
runtime_config.get("thinking_level"),
|
||||
runtime_config.get("api_protocol"),
|
||||
runtime_config.get("web_search_mode"),
|
||||
)
|
||||
|
||||
async def _agent_bundle_signature(self, streaming: bool) -> tuple[Any, ...]:
|
||||
@@ -1037,10 +1138,12 @@ class MoviePilotAgent:
|
||||
self.has_message_context,
|
||||
self.is_background,
|
||||
settings.AI_AGENT_VERBOSE,
|
||||
settings.LLM_TEMPERATURE,
|
||||
settings.LLM_MAX_TOOLS,
|
||||
settings.LLM_MAX_ITERATIONS,
|
||||
self._public_runtime_config_signature(runtime_config),
|
||||
agent_runtime_manager.current_signature(),
|
||||
agent_mcp_manager.config_signature(),
|
||||
)
|
||||
|
||||
def _get_cached_agent(
|
||||
@@ -1097,6 +1200,39 @@ class MoviePilotAgent:
|
||||
allow_message_tools=False,
|
||||
)
|
||||
|
||||
async def _initialize_mcp_tools(self) -> List:
|
||||
"""
|
||||
初始化外部 MCP 工具列表。
|
||||
"""
|
||||
return await create_external_mcp_tools(
|
||||
session_id=self.session_id,
|
||||
user_id=self.user_id,
|
||||
channel=self.channel,
|
||||
source=self.source,
|
||||
username=self.username,
|
||||
stream_handler=self.stream_handler,
|
||||
agent_context=self._tool_context,
|
||||
)
|
||||
|
||||
async def _initialize_subagent_mcp_tools(self) -> List:
|
||||
"""
|
||||
初始化子代理可用的外部 MCP 工具列表。
|
||||
"""
|
||||
return await create_external_mcp_tools(
|
||||
session_id=self.session_id,
|
||||
user_id=self.user_id,
|
||||
channel=self.channel,
|
||||
source=self.source,
|
||||
username=self.username,
|
||||
stream_handler=None,
|
||||
agent_context={
|
||||
"user_reply_sent": False,
|
||||
"reply_mode": None,
|
||||
"should_dispatch_reply": False,
|
||||
"is_admin": bool(self._tool_context.get("is_admin")),
|
||||
},
|
||||
)
|
||||
|
||||
async def _create_agent(self, streaming: bool = False):
|
||||
"""
|
||||
创建 LangGraph Agent(使用 create_agent + SummarizationMiddleware)
|
||||
@@ -1116,6 +1252,8 @@ class MoviePilotAgent:
|
||||
# LLM 模型(用于 agent 执行)
|
||||
agent_model = await self._initialize_llm(streaming=streaming)
|
||||
self._sync_model_profile(agent_model)
|
||||
server_tools = LLMHelper.get_server_tools(agent_model)
|
||||
use_local_web_search = LLMHelper.should_use_local_web_search(agent_model)
|
||||
|
||||
# 为内部模型调用准备非流式 LLM,避免与用户流式回复复用同一实例。
|
||||
non_streaming_model = (
|
||||
@@ -1125,7 +1263,11 @@ class MoviePilotAgent:
|
||||
)
|
||||
|
||||
# 工具列表
|
||||
tools = self._initialize_tools()
|
||||
tools = self._filter_local_web_search_tools(
|
||||
self._initialize_tools(),
|
||||
enabled=use_local_web_search,
|
||||
)
|
||||
tools.extend(await self._initialize_mcp_tools())
|
||||
skills_middleware = SkillsMiddleware(
|
||||
sources=[str(agent_runtime_manager.skills_dir)],
|
||||
bundled_skills_dir=str(settings.ROOT_PATH / "skills"),
|
||||
@@ -1142,9 +1284,15 @@ class MoviePilotAgent:
|
||||
activity_log_tools = list(
|
||||
getattr(activity_log_middleware, "tools", []) or []
|
||||
)
|
||||
subagent_tools = self._filter_local_web_search_tools(
|
||||
self._initialize_subagent_tools(),
|
||||
enabled=use_local_web_search,
|
||||
)
|
||||
subagent_tools.extend(await self._initialize_subagent_mcp_tools())
|
||||
subagent_middlewares, subagent_task_tools = create_subagent_middlewares(
|
||||
model=non_streaming_model,
|
||||
tools=self._initialize_subagent_tools(),
|
||||
tools=subagent_tools,
|
||||
server_tools=server_tools,
|
||||
stream_handler=self.stream_handler,
|
||||
)
|
||||
max_tools = settings.LLM_MAX_TOOLS
|
||||
@@ -1219,7 +1367,7 @@ class MoviePilotAgent:
|
||||
|
||||
agent = create_agent(
|
||||
model=agent_model,
|
||||
tools=[*tools, *skill_tools, *activity_log_tools],
|
||||
tools=[*tools, *skill_tools, *activity_log_tools, *server_tools],
|
||||
system_prompt=system_prompt,
|
||||
middleware=middlewares,
|
||||
checkpointer=InMemorySaver(),
|
||||
@@ -1588,22 +1736,24 @@ class MoviePilotAgent:
|
||||
if not streaming_stopped:
|
||||
await self.stream_handler.stop_streaming()
|
||||
|
||||
async def send_agent_message(self, message: str, title: str = ""):
|
||||
async def send_agent_message(self, message: str, title: str = "") -> None:
|
||||
"""
|
||||
通过原渠道发送消息给用户
|
||||
发送 Agent 消息;后台任务不绑定原渠道,交由通知链广播。
|
||||
"""
|
||||
broadcast = self.is_background
|
||||
self._save_assistant_display_message_once(message)
|
||||
await AgentChain().async_post_message(
|
||||
Notification(
|
||||
channel=self.channel,
|
||||
source=self.source,
|
||||
channel=None if broadcast else self.channel,
|
||||
source=None if broadcast else self.source,
|
||||
mtype=NotificationType.Agent,
|
||||
userid=self.user_id,
|
||||
username=self.username,
|
||||
original_message_id=self.original_message_id,
|
||||
original_chat_id=self.original_chat_id,
|
||||
userid=None if broadcast else self.user_id,
|
||||
username=self.username or (settings.SUPERUSER if broadcast else None),
|
||||
original_message_id=None if broadcast else self.original_message_id,
|
||||
original_chat_id=None if broadcast else self.original_chat_id,
|
||||
title=title,
|
||||
text=message,
|
||||
save_history=False,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1635,6 +1785,10 @@ class _MessageTask:
|
||||
processing_status: Optional[dict] = None
|
||||
reply_mode: ReplyMode = ReplyMode.DISPATCH
|
||||
allow_message_tools: bool = True
|
||||
output_callback: Optional[Callable[[str], None]] = None
|
||||
notification_callback: Optional[Callable[[Any], None]] = None
|
||||
agent_factory: Optional[Callable[..., MoviePilotAgent]] = None
|
||||
completion_future: Optional[asyncio.Future] = None
|
||||
|
||||
|
||||
class AgentManager:
|
||||
@@ -1739,6 +1893,12 @@ class AgentManager:
|
||||
queue = self._session_queues.get(session_id)
|
||||
return bool(queue and not queue.empty())
|
||||
|
||||
def is_session_busy(self, session_id: str) -> bool:
|
||||
"""
|
||||
查询会话是否仍有正在执行或排队的任务。
|
||||
"""
|
||||
return self._is_session_busy(session_id)
|
||||
|
||||
def _expired_idle_sessions(self) -> list[tuple[str, str]]:
|
||||
"""
|
||||
收集已经超过空闲时间且当前不忙的会话。
|
||||
@@ -1780,11 +1940,18 @@ class AgentManager:
|
||||
original_chat_id: Optional[str] = None,
|
||||
reply_mode: ReplyMode = ReplyMode.DISPATCH,
|
||||
allow_message_tools: bool = True,
|
||||
output_callback: Optional[Callable[[str], None]] = None,
|
||||
notification_callback: Optional[Callable[[Any], None]] = None,
|
||||
agent_factory: Optional[Callable[..., MoviePilotAgent]] = None,
|
||||
wait_for_completion: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
处理用户消息:将消息放入会话队列,按顺序依次处理。
|
||||
同一会话的消息排队等待,不同会话之间互不影响。
|
||||
"""
|
||||
completion_future = (
|
||||
asyncio.get_running_loop().create_future() if wait_for_completion else None
|
||||
)
|
||||
task = _MessageTask(
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
@@ -1799,6 +1966,10 @@ class AgentManager:
|
||||
original_chat_id=original_chat_id,
|
||||
reply_mode=reply_mode,
|
||||
allow_message_tools=allow_message_tools,
|
||||
output_callback=output_callback,
|
||||
notification_callback=notification_callback,
|
||||
agent_factory=agent_factory,
|
||||
completion_future=completion_future,
|
||||
)
|
||||
self._record_session_activity(session_id, user_id)
|
||||
|
||||
@@ -1831,6 +2002,8 @@ class AgentManager:
|
||||
self._session_worker(session_id)
|
||||
)
|
||||
|
||||
if completion_future:
|
||||
return await completion_future
|
||||
return ""
|
||||
|
||||
async def _session_worker(self, session_id: str):
|
||||
@@ -1854,9 +2027,17 @@ class AgentManager:
|
||||
|
||||
try:
|
||||
await self._start_task_processing_status(task)
|
||||
await self._process_message_internal(task)
|
||||
result = await self._process_message_internal(task)
|
||||
if task.completion_future and not task.completion_future.done():
|
||||
task.completion_future.set_result(result)
|
||||
except asyncio.CancelledError as err:
|
||||
if task.completion_future and not task.completion_future.done():
|
||||
task.completion_future.set_exception(err)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"处理会话 {session_id} 的消息失败: {e}")
|
||||
if task.completion_future and not task.completion_future.done():
|
||||
task.completion_future.set_exception(e)
|
||||
finally:
|
||||
await self._finish_task_processing_status(task)
|
||||
queue.task_done()
|
||||
@@ -1895,35 +2076,55 @@ class AgentManager:
|
||||
实际处理单条消息
|
||||
"""
|
||||
session_id = task.session_id
|
||||
existing_agent = self.active_agents.get(session_id)
|
||||
if (
|
||||
existing_agent
|
||||
and task.agent_factory
|
||||
and isinstance(task.agent_factory, type)
|
||||
and not isinstance(existing_agent, task.agent_factory)
|
||||
):
|
||||
await existing_agent.cleanup()
|
||||
self.active_agents.pop(session_id, None)
|
||||
|
||||
if session_id not in self.active_agents:
|
||||
logger.info(
|
||||
f"创建新的AI智能体实例,session_id: {session_id}, user_id: {task.user_id}"
|
||||
)
|
||||
agent = MoviePilotAgent(
|
||||
session_id=session_id,
|
||||
user_id=task.user_id,
|
||||
channel=task.channel,
|
||||
source=task.source,
|
||||
username=task.username,
|
||||
original_message_id=task.original_message_id,
|
||||
original_chat_id=task.original_chat_id,
|
||||
replay_mode=task.reply_mode,
|
||||
allow_message_tools=task.allow_message_tools,
|
||||
)
|
||||
agent_factory = task.agent_factory or MoviePilotAgent
|
||||
agent_kwargs = {
|
||||
"session_id": session_id,
|
||||
"user_id": task.user_id,
|
||||
"channel": task.channel,
|
||||
"source": task.source,
|
||||
"username": task.username,
|
||||
"original_message_id": task.original_message_id,
|
||||
"original_chat_id": task.original_chat_id,
|
||||
"replay_mode": task.reply_mode,
|
||||
"allow_message_tools": task.allow_message_tools,
|
||||
"output_callback": task.output_callback,
|
||||
}
|
||||
if task.notification_callback is not None and task.agent_factory:
|
||||
agent_kwargs["notification_callback"] = task.notification_callback
|
||||
agent = agent_factory(**agent_kwargs)
|
||||
self.active_agents[session_id] = agent
|
||||
else:
|
||||
agent = self.active_agents[session_id]
|
||||
agent.user_id = task.user_id
|
||||
if task.channel:
|
||||
agent.channel = task.channel
|
||||
if task.source:
|
||||
agent.source = task.source
|
||||
if task.username:
|
||||
agent.username = task.username
|
||||
# 每条队列任务都携带完整消息上下文,None 也必须覆盖,避免后台任务
|
||||
# 复用会话 Agent 时继续沿用上一条入站消息的渠道。
|
||||
agent.channel = task.channel
|
||||
agent.source = task.source
|
||||
agent.username = task.username
|
||||
agent.original_message_id = task.original_message_id
|
||||
agent.original_chat_id = task.original_chat_id
|
||||
agent.reply_mode = task.reply_mode
|
||||
agent.allow_message_tools = task.allow_message_tools
|
||||
if hasattr(agent, "set_output_callback"):
|
||||
agent.set_output_callback(task.output_callback)
|
||||
else:
|
||||
agent.output_callback = task.output_callback
|
||||
if task.notification_callback is not None and hasattr(agent, "set_notification_callback"):
|
||||
agent.set_notification_callback(task.notification_callback)
|
||||
|
||||
process_kwargs = {
|
||||
"images": task.images,
|
||||
@@ -2031,6 +2232,78 @@ class AgentManager:
|
||||
await agent.cleanup()
|
||||
memory_manager.clear_memory(session_id, user_id)
|
||||
|
||||
async def execute_scheduled_task(self, task_id: int) -> tuple[bool, str]:
|
||||
"""
|
||||
按持久化上下文唤醒 Agent 执行自主定时任务并向用户回传结果。
|
||||
|
||||
:param task_id: Agent 定时任务 ID
|
||||
:return: 执行是否成功及结果摘要
|
||||
"""
|
||||
if not settings.AI_AGENT_ENABLE:
|
||||
return False, "AI Agent 未启用"
|
||||
oper = AgentTaskOper()
|
||||
task = oper.get(task_id)
|
||||
if not task or not task.enabled:
|
||||
return False, "Agent 定时任务不存在或已停用"
|
||||
if not oper.mark_running(task_id):
|
||||
return False, "Agent 定时任务当前不可执行"
|
||||
|
||||
task_message = (
|
||||
f"定时任务已按计划触发。请立即完成下面的任务,不要只确认收到,"
|
||||
f"也不要重复创建同一个定时任务。\n\n"
|
||||
f"任务名称:{task.name}\n"
|
||||
f"任务内容:{task.content}\n\n"
|
||||
"完成后请直接向用户发送消息报告本次执行结果;如果无法完成,也需发送消息说明原因。"
|
||||
)
|
||||
success = True
|
||||
result = ""
|
||||
notification_username = task.username or settings.SUPERUSER
|
||||
try:
|
||||
result = await self.process_message(
|
||||
session_id=task.session_id,
|
||||
user_id=task.user_id,
|
||||
message=task_message,
|
||||
channel=None,
|
||||
source=None,
|
||||
username=notification_username,
|
||||
original_chat_id=None,
|
||||
reply_mode=ReplyMode.DISPATCH,
|
||||
allow_message_tools=True,
|
||||
wait_for_completion=True,
|
||||
)
|
||||
result_text = str(result or "").strip()
|
||||
success = not result_text.startswith(
|
||||
(AGENT_EXECUTION_ERROR_PREFIX, "处理消息时发生错误")
|
||||
)
|
||||
except Exception as err:
|
||||
success = False
|
||||
result = f"Agent 定时任务执行失败:{str(err)}"
|
||||
logger.error(f"Agent 定时任务 {task_id} 执行失败: {str(err)}")
|
||||
await AgentChain().async_post_message(
|
||||
Notification(
|
||||
mtype=NotificationType.Agent,
|
||||
username=notification_username,
|
||||
title=f"定时任务执行失败:{task.name}",
|
||||
text=result,
|
||||
save_history=False,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
current_task = oper.get(task_id)
|
||||
oper.finish(
|
||||
task_id=task_id,
|
||||
success=success,
|
||||
result=str(result or ""),
|
||||
disable=bool(
|
||||
current_task
|
||||
and task.trigger_type == "date"
|
||||
and current_task.trigger_type == task.trigger_type
|
||||
and current_task.run_at == task.run_at
|
||||
),
|
||||
)
|
||||
|
||||
return success, str(result or "任务执行完成")
|
||||
|
||||
@staticmethod
|
||||
def _build_heartbeat_prompt() -> str:
|
||||
"""使用程序内置 System Tasks 定义构建心跳任务提示词。"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -5,13 +5,17 @@ import inspect
|
||||
import json
|
||||
import time
|
||||
from functools import wraps
|
||||
from typing import Any, List, Optional
|
||||
from typing import TYPE_CHECKING, Any, List, Optional
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from langchain_core.messages import AIMessage, AIMessageChunk
|
||||
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.agent.llm.server_tools import ServerToolResolution
|
||||
|
||||
|
||||
class LLMTestError(RuntimeError):
|
||||
"""LLM 测试调用异常,附带请求耗时。"""
|
||||
@@ -224,74 +228,76 @@ def _is_deepseek_thinking_enabled(model_name: str | None, extra_body: Any) -> bo
|
||||
return False
|
||||
|
||||
|
||||
def _patch_deepseek_reasoning_content_support():
|
||||
"""
|
||||
修补 langchain-deepseek 在 tool-call 场景下遗漏 reasoning_content 回传的问题。
|
||||
|
||||
DeepSeek thinking mode 要求:若 assistant 历史消息包含 tool_calls,
|
||||
后续请求中必须带回该条消息的顶层 reasoning_content。
|
||||
某些 langchain-deepseek 版本虽然能从响应中拿到 reasoning_content,
|
||||
但不会在重放消息历史时写回请求载荷,导致 400。
|
||||
"""
|
||||
try:
|
||||
from langchain_deepseek import ChatDeepSeek
|
||||
except Exception as err:
|
||||
logger.debug(f"跳过 langchain-deepseek reasoning_content 修补:{err}")
|
||||
def _patch_interleaved_reasoning_request_support(
|
||||
model_cls: Any,
|
||||
*,
|
||||
patch_marker: str,
|
||||
thinking_filter: Any = None,
|
||||
normalize_deepseek_messages: bool = False,
|
||||
inject_missing_as_empty: bool = False,
|
||||
) -> None:
|
||||
"""为兼容模型统一补回工具调用历史中的 reasoning_content。"""
|
||||
if getattr(model_cls, patch_marker, False):
|
||||
return
|
||||
|
||||
if getattr(ChatDeepSeek, "_moviepilot_reasoning_content_patched", False):
|
||||
return
|
||||
|
||||
original_get_request_payload = getattr(ChatDeepSeek, "_get_request_payload", None)
|
||||
original_get_request_payload = getattr(model_cls, "_get_request_payload", None)
|
||||
if not callable(original_get_request_payload):
|
||||
logger.warning("langchain-deepseek 缺少 _get_request_payload,无法修补 reasoning_content")
|
||||
logger.warning(
|
||||
f"{model_cls.__name__} 缺少 _get_request_payload,无法修补 reasoning_content"
|
||||
)
|
||||
return
|
||||
|
||||
@wraps(original_get_request_payload)
|
||||
def _patched_get_request_payload(self, input_, *, stop=None, **kwargs):
|
||||
payload = original_get_request_payload(self, input_, stop=stop, **kwargs)
|
||||
if "messages" not in payload:
|
||||
return payload
|
||||
|
||||
extra_body = (getattr(self, "model_kwargs", None) or {}).get("extra_body")
|
||||
if not _is_deepseek_thinking_enabled(
|
||||
extra_body = getattr(self, "extra_body", None)
|
||||
if extra_body is None:
|
||||
extra_body = (getattr(self, "model_kwargs", None) or {}).get("extra_body")
|
||||
if thinking_filter is not None and not thinking_filter(
|
||||
getattr(self, "model_name", None) or getattr(self, "model", None),
|
||||
extra_body,
|
||||
):
|
||||
return payload
|
||||
|
||||
# 从原始 LangChain 消息中取回 reasoning_content。上游 payload 构造器
|
||||
# 不会自动透传这个 DeepSeek 扩展字段。
|
||||
messages = self._convert_input(input_).to_messages()
|
||||
|
||||
for i, message in enumerate(payload["messages"]):
|
||||
if message["role"] == "tool" and isinstance(message["content"], list):
|
||||
message["content"] = json.dumps(message["content"])
|
||||
elif message["role"] == "assistant":
|
||||
if isinstance(message["content"], list):
|
||||
# DeepSeek API 要求 assistant content 为字符串;工具场景下
|
||||
# LangChain 可能保留为内容块列表,这里只拼回可见文本块。
|
||||
text_parts = [
|
||||
block.get("text", "")
|
||||
for block in message["content"]
|
||||
if isinstance(block, dict) and block.get("type") == "text"
|
||||
]
|
||||
message["content"] = "".join(text_parts) if text_parts else ""
|
||||
|
||||
# DeepSeek thinking mode 要求历史 assistant 消息携带
|
||||
# reasoning_content,即便本地只保存到了 additional_kwargs。
|
||||
if (
|
||||
"reasoning_content" not in message
|
||||
and i < len(messages)
|
||||
and isinstance(messages[i], AIMessage)
|
||||
for index, payload_message in enumerate(payload["messages"]):
|
||||
if normalize_deepseek_messages:
|
||||
if payload_message.get("role") == "tool" and isinstance(
|
||||
payload_message.get("content"), list
|
||||
):
|
||||
message["reasoning_content"] = messages[i].additional_kwargs.get(
|
||||
"reasoning_content", ""
|
||||
payload_message["content"] = json.dumps(payload_message["content"])
|
||||
elif payload_message.get("role") == "assistant" and isinstance(
|
||||
payload_message.get("content"), list
|
||||
):
|
||||
payload_message["content"] = "".join(
|
||||
block.get("text", "")
|
||||
for block in payload_message["content"]
|
||||
if isinstance(block, dict) and block.get("type") == "text"
|
||||
)
|
||||
|
||||
if (
|
||||
payload_message.get("role") != "assistant"
|
||||
or index >= len(messages)
|
||||
or not isinstance(messages[index], AIMessage)
|
||||
or "reasoning_content" in payload_message
|
||||
):
|
||||
continue
|
||||
|
||||
reasoning_content = messages[index].additional_kwargs.get(
|
||||
"reasoning_content"
|
||||
)
|
||||
if reasoning_content is not None:
|
||||
payload_message["reasoning_content"] = reasoning_content
|
||||
elif inject_missing_as_empty:
|
||||
payload_message["reasoning_content"] = ""
|
||||
|
||||
return payload
|
||||
|
||||
ChatDeepSeek._get_request_payload = _patched_get_request_payload
|
||||
ChatDeepSeek._moviepilot_reasoning_content_patched = True
|
||||
logger.debug("已修补 langchain-deepseek thinking tool-call 的 reasoning_content 回传兼容性")
|
||||
model_cls._get_request_payload = _patched_get_request_payload
|
||||
setattr(model_cls, patch_marker, True)
|
||||
|
||||
|
||||
def _patch_openai_interleaved_reasoning_content_support():
|
||||
@@ -352,42 +358,10 @@ def _patch_openai_interleaved_reasoning_content_support():
|
||||
|
||||
_openai_base._moviepilot_reasoning_response_patched = True
|
||||
|
||||
if getattr(ChatOpenAI, "_moviepilot_interleaved_reasoning_patched", False):
|
||||
return
|
||||
|
||||
original_get_request_payload = getattr(ChatOpenAI, "_get_request_payload", None)
|
||||
if not callable(original_get_request_payload):
|
||||
logger.warning("langchain-openai 缺少 _get_request_payload,无法修补 reasoning_content")
|
||||
return
|
||||
|
||||
@wraps(original_get_request_payload)
|
||||
def _patched_get_request_payload(self, input_, *, stop=None, **kwargs):
|
||||
payload = original_get_request_payload(self, input_, stop=stop, **kwargs)
|
||||
if "messages" not in payload:
|
||||
return payload
|
||||
|
||||
messages = self._convert_input(input_).to_messages()
|
||||
for index, payload_message in enumerate(payload["messages"]):
|
||||
if (
|
||||
payload_message.get("role") != "assistant"
|
||||
or index >= len(messages)
|
||||
or not isinstance(messages[index], AIMessage)
|
||||
or "reasoning_content" in payload_message
|
||||
):
|
||||
continue
|
||||
|
||||
reasoning_content = messages[index].additional_kwargs.get(
|
||||
"reasoning_content"
|
||||
)
|
||||
if reasoning_content is not None:
|
||||
# 只回传模型真实返回过的思考字段。普通模型没有该字段时,
|
||||
# payload 保持原样,不额外塞未知参数。
|
||||
payload_message["reasoning_content"] = reasoning_content
|
||||
|
||||
return payload
|
||||
|
||||
ChatOpenAI._get_request_payload = _patched_get_request_payload
|
||||
ChatOpenAI._moviepilot_interleaved_reasoning_patched = True
|
||||
_patch_interleaved_reasoning_request_support(
|
||||
ChatOpenAI,
|
||||
patch_marker="_moviepilot_interleaved_reasoning_patched",
|
||||
)
|
||||
logger.debug("已修补 langchain-openai interleaved reasoning_content 回传兼容性")
|
||||
|
||||
|
||||
@@ -840,25 +814,122 @@ class LLMHelper:
|
||||
headers["User-Agent"] = normalized_user_agent
|
||||
return headers or None
|
||||
|
||||
@staticmethod
|
||||
def _matches_endpoint_host(base_url: str | None, expected_host: str) -> bool:
|
||||
"""严格匹配官方 API 主机,避免向兼容端点发送供应商专属参数。"""
|
||||
try:
|
||||
return (urlsplit(str(base_url or "")).hostname or "").lower() == expected_host
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _build_openai_prompt_cache_options(
|
||||
cls,
|
||||
*,
|
||||
provider: str,
|
||||
base_url: str | None,
|
||||
use_responses_api: bool | None,
|
||||
prompt_cache_key: str | None,
|
||||
default_headers: dict[str, str] | None,
|
||||
model_kwargs: dict[str, Any],
|
||||
) -> tuple[dict[str, str] | None, dict[str, Any]]:
|
||||
"""为 OpenAI 与 xAI 官方端点构造稳定提示词缓存路由参数。"""
|
||||
cache_key = str(prompt_cache_key or "").strip()
|
||||
headers = dict(default_headers or {})
|
||||
kwargs = dict(model_kwargs)
|
||||
provider_name = str(provider or "").strip().lower()
|
||||
if not cache_key:
|
||||
return headers or None, kwargs
|
||||
|
||||
is_openai = provider_name in {"chatgpt", "openai"} and cls._matches_endpoint_host(
|
||||
base_url,
|
||||
"api.openai.com",
|
||||
)
|
||||
is_xai = provider_name == "xai" and cls._matches_endpoint_host(
|
||||
base_url,
|
||||
"api.x.ai",
|
||||
)
|
||||
if not is_openai and not is_xai:
|
||||
return headers or None, kwargs
|
||||
|
||||
if is_xai and use_responses_api is not True:
|
||||
headers["x-grok-conv-id"] = cache_key
|
||||
return headers, kwargs
|
||||
|
||||
extra_body = dict(kwargs.get("extra_body") or {})
|
||||
extra_body["prompt_cache_key"] = cache_key
|
||||
kwargs["extra_body"] = extra_body
|
||||
return headers or None, kwargs
|
||||
|
||||
@staticmethod
|
||||
def _with_prompt_cache_control(
|
||||
model_cls: type,
|
||||
cache_control: dict[str, str],
|
||||
) -> type:
|
||||
"""创建在最终模型绑定阶段保留缓存控制参数的适配类。"""
|
||||
|
||||
class PromptCachingModel(model_cls):
|
||||
"""在 LangChain 工具绑定后仍保留提示词缓存参数的模型适配器。"""
|
||||
|
||||
def bind(self, **kwargs: Any) -> Any:
|
||||
"""绑定调用参数,并补入当前 Provider 的默认缓存控制。"""
|
||||
kwargs.setdefault("cache_control", dict(cache_control))
|
||||
return super().bind(**kwargs)
|
||||
|
||||
PromptCachingModel.__name__ = f"PromptCaching{model_cls.__name__}"
|
||||
return PromptCachingModel
|
||||
|
||||
@classmethod
|
||||
def _use_anthropic_prompt_cache(
|
||||
cls,
|
||||
*,
|
||||
provider: str,
|
||||
runtime: dict[str, Any],
|
||||
prompt_cache_key: str | None,
|
||||
) -> bool:
|
||||
"""判断当前运行时是否为可安全启用缓存的 Anthropic 官方端点。"""
|
||||
return (
|
||||
bool(str(prompt_cache_key or "").strip())
|
||||
and str(provider or "").strip().lower() == "anthropic"
|
||||
and str(runtime.get("runtime") or "").strip().lower()
|
||||
== "anthropic_compatible"
|
||||
and cls._matches_endpoint_host(
|
||||
runtime.get("base_url"),
|
||||
"api.anthropic.com",
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _should_use_openai_responses_api(
|
||||
cls,
|
||||
provider: str,
|
||||
model: str | None,
|
||||
runtime: dict[str, Any],
|
||||
api_protocol: str | None = None,
|
||||
) -> bool | None:
|
||||
"""
|
||||
判断官方 ChatGPT API Key 模式是否应使用 Responses API。
|
||||
判断本次 OpenAI 兼容调用是否应使用 Responses API。
|
||||
|
||||
GPT-5/o 系推理模型在 Chat Completions 中组合 function tools 与
|
||||
reasoning_effort 时会被官方端点拒绝,因此 ChatGPT 官方 API Key
|
||||
模式需要显式切到 Responses API;通用 OpenAI-compatible 入口保持
|
||||
provider 目录解析出的默认行为,避免误伤第三方兼容服务。
|
||||
优先级:
|
||||
1. 运行时显式要求(ChatGPT Plus/Pro OAuth、Codex 等端点契约),始终保留;
|
||||
2. 用户通过 ``LLM_API_PROTOCOL`` 显式指定 ``responses`` / ``chat_completions``;
|
||||
3. ``auto``(默认)保持原有 ChatGPT 官方 API Key + GPT-5/o 系推理模型
|
||||
自动切换逻辑,通用 OpenAI 兼容入口仍走 Chat Completions,
|
||||
避免误伤第三方兼容服务。
|
||||
|
||||
:param api_protocol: 显式传入的 API 协议,未传入时读取 ``LLM_API_PROTOCOL``
|
||||
:return: True/False 强制指定协议;None 表示交由 LangChain 默认行为
|
||||
"""
|
||||
runtime_use_responses_api = runtime.get("use_responses_api")
|
||||
if runtime_use_responses_api is not None:
|
||||
return bool(runtime_use_responses_api)
|
||||
|
||||
protocol = cls._normalize_api_protocol(api_protocol)
|
||||
if protocol == "responses":
|
||||
return True
|
||||
if protocol == "chat_completions":
|
||||
return False
|
||||
|
||||
provider_name = (provider or "").strip().lower()
|
||||
if provider_name != "chatgpt":
|
||||
return None
|
||||
@@ -872,6 +943,18 @@ class LLMHelper:
|
||||
return True
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_api_protocol(api_protocol: str | None) -> str:
|
||||
"""
|
||||
规范化 API 协议配置,未知值统一回退为 ``auto`` 以保持兼容。
|
||||
"""
|
||||
normalized = str(api_protocol or settings.LLM_API_PROTOCOL or "").strip().lower()
|
||||
if normalized in {"auto", "chat_completions", "responses"}:
|
||||
return normalized
|
||||
if normalized:
|
||||
logger.warning(f"忽略不支持的 LLM_API_PROTOCOL 配置: {api_protocol}")
|
||||
return "auto"
|
||||
|
||||
@staticmethod
|
||||
def _attach_runtime_metadata(model: Any, runtime: dict[str, Any]) -> None:
|
||||
"""
|
||||
@@ -907,6 +990,36 @@ class LLMHelper:
|
||||
profile["moviepilot_provider_id"] = runtime_metadata["provider_id"]
|
||||
profile["moviepilot_base_url"] = runtime_metadata["base_url"]
|
||||
|
||||
@staticmethod
|
||||
def _attach_server_tool_metadata(
|
||||
model: Any,
|
||||
resolution: "ServerToolResolution",
|
||||
) -> None:
|
||||
"""把服务端工具解析结果挂到模型实例,供 Agent 组装工具列表。"""
|
||||
metadata = {
|
||||
"mode": resolution.mode,
|
||||
"use_local_web_search": resolution.use_local_web_search,
|
||||
"server_tools": [dict(tool) for tool in resolution.server_tools],
|
||||
"available": resolution.available,
|
||||
"reason": resolution.reason,
|
||||
}
|
||||
try:
|
||||
setattr(model, "_moviepilot_server_tool_metadata", metadata)
|
||||
except Exception:
|
||||
object.__setattr__(model, "_moviepilot_server_tool_metadata", metadata)
|
||||
|
||||
@staticmethod
|
||||
def get_server_tools(model: Any) -> list[dict[str, Any]]:
|
||||
"""读取模型已解析的服务端工具定义。"""
|
||||
metadata = getattr(model, "_moviepilot_server_tool_metadata", {}) or {}
|
||||
return [dict(tool) for tool in metadata.get("server_tools", [])]
|
||||
|
||||
@staticmethod
|
||||
def should_use_local_web_search(model: Any) -> bool:
|
||||
"""判断当前模型是否应保留 MoviePilot 本地联网搜索工具。"""
|
||||
metadata = getattr(model, "_moviepilot_server_tool_metadata", {}) or {}
|
||||
return bool(metadata.get("use_local_web_search", True))
|
||||
|
||||
@classmethod
|
||||
def _resolve_thinking_level(
|
||||
cls,
|
||||
@@ -952,7 +1065,11 @@ class LLMHelper:
|
||||
base_url: str | None = None,
|
||||
base_url_preset: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
temperature: Optional[float] = None,
|
||||
use_proxy: bool | None = None,
|
||||
api_protocol: str | None = None,
|
||||
web_search_mode: str | None = None,
|
||||
prompt_cache_key: str | None = None,
|
||||
):
|
||||
"""
|
||||
获取LLM实例
|
||||
@@ -967,7 +1084,16 @@ class LLMHelper:
|
||||
:param base_url: API Base URL。未显式传入时使用当前配置项 LLM_BASE_URL。
|
||||
:param base_url_preset: Base URL 预设。未显式传入时使用当前配置项 LLM_BASE_URL_PRESET。
|
||||
:param user_agent: OpenAI兼容接口请求 User-Agent。未显式传入时使用配置项 LLM_USER_AGENT。
|
||||
:param temperature: LLM 温度参数。未显式传入时使用配置项 LLM_TEMPERATURE。
|
||||
:param use_proxy: 是否为本次 LLM 调用使用系统代理。未显式传入时使用配置项 LLM_USE_PROXY。
|
||||
:param api_protocol: OpenAI 兼容接口 API 协议
|
||||
(auto/chat_completions/responses)。未显式传入时使用配置项 LLM_API_PROTOCOL。
|
||||
仅对 OpenAI 兼容运行时生效;``responses`` 强制走 Responses API,
|
||||
``chat_completions`` 强制走 Chat Completions,``auto`` 保持原有自动判断。
|
||||
:param web_search_mode: 联网搜索模式
|
||||
(local/builtin/auto/disabled)。未显式传入时使用配置项
|
||||
``LLM_WEB_SEARCH_MODE``。
|
||||
:param prompt_cache_key: 同一 Agent 会话内稳定且脱敏的提示词缓存路由键。
|
||||
:return: LLM实例
|
||||
"""
|
||||
provider_name = str(provider if provider is not None else settings.LLM_PROVIDER).lower()
|
||||
@@ -978,6 +1104,7 @@ class LLMHelper:
|
||||
base_url_preset if base_url_preset is not None else settings.LLM_BASE_URL_PRESET
|
||||
)
|
||||
user_agent_value = user_agent if user_agent is not None else settings.LLM_USER_AGENT
|
||||
temperature_value = temperature if temperature is not None else settings.LLM_TEMPERATURE
|
||||
normalized_thinking_level = cls._resolve_thinking_level(
|
||||
thinking_level=thinking_level,
|
||||
)
|
||||
@@ -1005,6 +1132,40 @@ class LLMHelper:
|
||||
user_agent=user_agent_value,
|
||||
)
|
||||
model_name = runtime.get("model_id") or model_name
|
||||
from app.agent.llm.server_tools import (
|
||||
ServerToolRegistry,
|
||||
ServerToolUnavailableError,
|
||||
)
|
||||
|
||||
server_tool_resolution = ServerToolRegistry.resolve_web_search(
|
||||
provider=provider_name,
|
||||
model=model_name,
|
||||
mode=(
|
||||
web_search_mode
|
||||
if web_search_mode is not None
|
||||
else getattr(settings, "LLM_WEB_SEARCH_MODE", "local")
|
||||
),
|
||||
api_protocol=(
|
||||
api_protocol
|
||||
if api_protocol is not None
|
||||
else settings.LLM_API_PROTOCOL
|
||||
),
|
||||
base_url=runtime.get("base_url"),
|
||||
)
|
||||
if (
|
||||
server_tool_resolution.mode == "builtin"
|
||||
and not server_tool_resolution.available
|
||||
):
|
||||
raise ServerToolUnavailableError(
|
||||
provider=provider_name,
|
||||
model=str(model_name or ""),
|
||||
tool_id="web_search",
|
||||
)
|
||||
effective_api_protocol = (
|
||||
server_tool_resolution.required_api_protocol
|
||||
if server_tool_resolution.required_api_protocol == "responses"
|
||||
else api_protocol
|
||||
)
|
||||
default_headers = cls._build_openai_default_headers(
|
||||
runtime.get("default_headers"),
|
||||
user_agent=user_agent_value,
|
||||
@@ -1018,6 +1179,15 @@ class LLMHelper:
|
||||
provider=provider_name,
|
||||
model=model_name,
|
||||
runtime=runtime,
|
||||
api_protocol=effective_api_protocol,
|
||||
)
|
||||
default_headers, openai_model_kwargs = cls._build_openai_prompt_cache_options(
|
||||
provider=provider_name,
|
||||
base_url=runtime.get("base_url"),
|
||||
use_responses_api=use_responses_api,
|
||||
prompt_cache_key=prompt_cache_key,
|
||||
default_headers=default_headers,
|
||||
model_kwargs=thinking_kwargs,
|
||||
)
|
||||
llm_proxy = _resolve_llm_proxy(use_proxy)
|
||||
|
||||
@@ -1034,36 +1204,92 @@ class LLMHelper:
|
||||
model=model_name,
|
||||
api_key=runtime["api_key"],
|
||||
retries=3,
|
||||
temperature=settings.LLM_TEMPERATURE,
|
||||
temperature=temperature_value,
|
||||
streaming=streaming,
|
||||
client_args=_build_google_client_args(llm_proxy),
|
||||
**thinking_kwargs,
|
||||
)
|
||||
elif runtime["runtime"] == "deepseek":
|
||||
elif (
|
||||
runtime["runtime"] == "deepseek"
|
||||
and server_tool_resolution.client_adapter != "openai_responses"
|
||||
and use_responses_api is not True
|
||||
):
|
||||
from langchain_deepseek import ChatDeepSeek
|
||||
|
||||
_patch_deepseek_reasoning_content_support()
|
||||
_patch_interleaved_reasoning_request_support(
|
||||
ChatDeepSeek,
|
||||
patch_marker="_moviepilot_reasoning_content_patched",
|
||||
thinking_filter=lambda model_name, extra_body: (
|
||||
_is_deepseek_thinking_enabled(model_name, extra_body)
|
||||
),
|
||||
normalize_deepseek_messages=True,
|
||||
inject_missing_as_empty=True,
|
||||
)
|
||||
model = ChatDeepSeek(
|
||||
model=model_name,
|
||||
api_key=runtime["api_key"],
|
||||
api_base=runtime["base_url"],
|
||||
max_retries=3,
|
||||
temperature=settings.LLM_TEMPERATURE,
|
||||
temperature=temperature_value,
|
||||
streaming=streaming,
|
||||
stream_usage=True,
|
||||
http_client=_build_httpx_client(llm_proxy),
|
||||
http_async_client=_build_httpx_client(llm_proxy, async_client=True),
|
||||
**thinking_kwargs,
|
||||
)
|
||||
elif runtime["runtime"] == "bedrock":
|
||||
from langchain_aws import ChatBedrockConverse
|
||||
|
||||
from app.agent.llm.provider import LLMProviderManager
|
||||
|
||||
bedrock_model_cls = ChatBedrockConverse
|
||||
if (
|
||||
str(prompt_cache_key or "").strip()
|
||||
and runtime.get("supports_prompt_cache")
|
||||
):
|
||||
bedrock_model_cls = cls._with_prompt_cache_control(
|
||||
ChatBedrockConverse,
|
||||
{"type": "default"},
|
||||
)
|
||||
|
||||
aws_region = runtime.get("aws_region") or "us-east-1"
|
||||
aws_auth = runtime.get("aws_auth") or {}
|
||||
# Bearer 认证需要跳过 SigV4 签名并注入 Authorization 头,SigV4 认证
|
||||
# 直接以 AK/SK 签名;两种方式统一由 provider 管理器构造 boto3 客户端。
|
||||
bedrock_client = LLMProviderManager().create_bedrock_client(
|
||||
"bedrock-runtime",
|
||||
region=aws_region,
|
||||
credentials=aws_auth,
|
||||
base_url=runtime.get("base_url"),
|
||||
use_proxy=use_proxy,
|
||||
read_timeout=settings.LLM_TOOL_TIMEOUT,
|
||||
)
|
||||
model = bedrock_model_cls(
|
||||
model_id=model_name,
|
||||
client=bedrock_client,
|
||||
temperature=temperature_value,
|
||||
disable_streaming=not streaming,
|
||||
)
|
||||
elif runtime["runtime"] in {"anthropic_compatible", "copilot_anthropic"}:
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
|
||||
model = ChatAnthropic(
|
||||
anthropic_model_cls = ChatAnthropic
|
||||
if cls._use_anthropic_prompt_cache(
|
||||
provider=provider_name,
|
||||
runtime=runtime,
|
||||
prompt_cache_key=prompt_cache_key,
|
||||
):
|
||||
anthropic_model_cls = cls._with_prompt_cache_control(
|
||||
ChatAnthropic,
|
||||
{"type": "ephemeral"},
|
||||
)
|
||||
|
||||
model = anthropic_model_cls(
|
||||
model=model_name,
|
||||
api_key=runtime["api_key"],
|
||||
base_url=runtime["base_url"],
|
||||
max_retries=3,
|
||||
temperature=settings.LLM_TEMPERATURE,
|
||||
temperature=temperature_value,
|
||||
streaming=streaming,
|
||||
stream_usage=True,
|
||||
anthropic_proxy=llm_proxy,
|
||||
@@ -1084,7 +1310,7 @@ class LLMHelper:
|
||||
api_key=runtime["api_key"],
|
||||
max_retries=3,
|
||||
base_url=runtime.get("base_url"),
|
||||
temperature=settings.LLM_TEMPERATURE,
|
||||
temperature=temperature_value,
|
||||
streaming=streaming,
|
||||
stream_usage=True,
|
||||
openai_proxy=llm_proxy,
|
||||
@@ -1098,13 +1324,18 @@ class LLMHelper:
|
||||
),
|
||||
default_headers=default_headers,
|
||||
use_responses_api=use_responses_api,
|
||||
**thinking_kwargs,
|
||||
output_version=("responses/v1" if use_responses_api else None),
|
||||
**openai_model_kwargs,
|
||||
)
|
||||
|
||||
# 优先使用 provider / models.dev 目录中的上下文上限,减少用户手填成本。
|
||||
model_profile = getattr(model, "profile", None)
|
||||
if model_profile:
|
||||
logger.debug(f"使用LLM模型: {model.model},Profile: {model.profile}")
|
||||
# ChatBedrockConverse 等模型类没有 model 属性,模型名存放在 model_id。
|
||||
logged_model_name = getattr(model, "model", None) or getattr(
|
||||
model, "model_id", model_name
|
||||
)
|
||||
logger.debug(f"使用LLM模型: {logged_model_name},Profile: {model_profile}")
|
||||
else:
|
||||
model_record = runtime.get("model_record") or {}
|
||||
model_metadata = runtime.get("model_metadata") or {}
|
||||
@@ -1121,6 +1352,7 @@ class LLMHelper:
|
||||
}
|
||||
|
||||
cls._attach_runtime_metadata(model, runtime)
|
||||
cls._attach_server_tool_metadata(model, server_tool_resolution)
|
||||
return model
|
||||
|
||||
@staticmethod
|
||||
@@ -1178,25 +1410,38 @@ class LLMHelper:
|
||||
base_url: str | None = None,
|
||||
base_url_preset: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
temperature: Optional[float] = None,
|
||||
use_proxy: bool | None = None,
|
||||
api_protocol: str | None = None,
|
||||
web_search_mode: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
使用当前已保存配置执行一次最小 LLM 调用。
|
||||
使用当前配置或显式传入的临时配置执行一次最小 LLM 调用。
|
||||
|
||||
:param temperature: LLM 温度参数。未显式传入时沿用已保存配置。
|
||||
:param api_protocol: OpenAI 兼容接口 API 协议,未显式传入时沿用已保存配置。
|
||||
:param web_search_mode: 联网搜索模式,未显式传入时沿用已保存配置。
|
||||
"""
|
||||
provider_name = provider if provider is not None else settings.LLM_PROVIDER
|
||||
model_name = model if model is not None else settings.LLM_MODEL
|
||||
start = time.perf_counter()
|
||||
llm = await LLMHelper.get_llm(
|
||||
streaming=False,
|
||||
provider=provider_name,
|
||||
model=model_name,
|
||||
thinking_level=thinking_level,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
base_url_preset=base_url_preset,
|
||||
user_agent=user_agent,
|
||||
use_proxy=use_proxy,
|
||||
)
|
||||
llm_kwargs = {
|
||||
"streaming": False,
|
||||
"provider": provider_name,
|
||||
"model": model_name,
|
||||
"thinking_level": thinking_level,
|
||||
"api_key": api_key,
|
||||
"base_url": base_url,
|
||||
"base_url_preset": base_url_preset,
|
||||
"user_agent": user_agent,
|
||||
"use_proxy": use_proxy,
|
||||
"api_protocol": api_protocol,
|
||||
"web_search_mode": web_search_mode,
|
||||
}
|
||||
if temperature is not None:
|
||||
llm_kwargs["temperature"] = temperature
|
||||
|
||||
llm = await LLMHelper.get_llm(**llm_kwargs)
|
||||
try:
|
||||
response = await asyncio.wait_for(llm.ainvoke(prompt), timeout=timeout)
|
||||
except TimeoutError as err:
|
||||
@@ -1240,7 +1485,7 @@ class LLMHelper:
|
||||
try:
|
||||
from app.agent.llm.provider import LLMProviderManager
|
||||
|
||||
return await LLMProviderManager().list_models(
|
||||
models = await LLMProviderManager().list_models(
|
||||
provider_id=provider,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
@@ -1249,16 +1494,25 @@ class LLMHelper:
|
||||
use_proxy=use_proxy,
|
||||
force_refresh=force_refresh,
|
||||
)
|
||||
return self._attach_server_tool_capabilities(
|
||||
provider,
|
||||
models,
|
||||
base_url=base_url,
|
||||
)
|
||||
except Exception as err:
|
||||
logger.debug(f"LLM provider 目录不可用,回退旧模型列表逻辑: {err}")
|
||||
if provider == "google":
|
||||
return [
|
||||
{"id": model_id, "name": model_id}
|
||||
for model_id in await self._get_google_models(
|
||||
api_key or "",
|
||||
use_proxy=use_proxy,
|
||||
)
|
||||
]
|
||||
return self._attach_server_tool_capabilities(
|
||||
provider,
|
||||
[
|
||||
{"id": model_id, "name": model_id}
|
||||
for model_id in await self._get_google_models(
|
||||
api_key or "",
|
||||
use_proxy=use_proxy,
|
||||
)
|
||||
],
|
||||
base_url=base_url,
|
||||
)
|
||||
try:
|
||||
from app.agent.llm.provider import LLMProviderManager
|
||||
|
||||
@@ -1272,16 +1526,40 @@ class LLMHelper:
|
||||
)
|
||||
except Exception:
|
||||
model_list_base_url = base_url
|
||||
return [
|
||||
{"id": model_id, "name": model_id}
|
||||
for model_id in await self._get_openai_compatible_models(
|
||||
provider,
|
||||
api_key or "",
|
||||
model_list_base_url,
|
||||
user_agent=user_agent,
|
||||
use_proxy=use_proxy,
|
||||
)
|
||||
]
|
||||
return self._attach_server_tool_capabilities(
|
||||
provider,
|
||||
[
|
||||
{"id": model_id, "name": model_id}
|
||||
for model_id in await self._get_openai_compatible_models(
|
||||
provider,
|
||||
api_key or "",
|
||||
model_list_base_url,
|
||||
user_agent=user_agent,
|
||||
use_proxy=use_proxy,
|
||||
)
|
||||
],
|
||||
base_url=base_url,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _attach_server_tool_capabilities(
|
||||
provider: str,
|
||||
models: List[dict[str, Any]],
|
||||
base_url: Optional[str] = None,
|
||||
) -> List[dict[str, Any]]:
|
||||
"""为模型目录附加通用服务端工具能力元数据。"""
|
||||
from app.agent.llm.server_tools import ServerToolRegistry
|
||||
|
||||
result = []
|
||||
for item in models:
|
||||
model_item = dict(item)
|
||||
model_item["server_tools"] = ServerToolRegistry.list_capabilities(
|
||||
provider=provider,
|
||||
model=str(model_item.get("id") or ""),
|
||||
base_url=base_url,
|
||||
)
|
||||
result.append(model_item)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
async def _get_google_models(api_key: str, use_proxy: bool | None = None) -> List[str]:
|
||||
|
||||
@@ -7,13 +7,14 @@ import base64
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from urllib.parse import urlencode
|
||||
from urllib.parse import urlencode, urlsplit
|
||||
|
||||
import aiofiles
|
||||
import httpx
|
||||
@@ -106,6 +107,90 @@ class LLMProviderManager(metaclass=Singleton):
|
||||
_MODELS_DEV_BUNDLED_PATH = Path(__file__).with_name("models.json")
|
||||
_MODELS_DEV_CACHE_TTL = 7 * 24 * 60 * 60
|
||||
_AUTH_SESSION_DONE_RETENTION = 300
|
||||
_BEDROCK_DEFAULT_REGION = "us-east-1"
|
||||
_BEDROCK_API_KEY_PREFIX = "bedrock-api-key-"
|
||||
_BEDROCK_GPT_OSS_BASE_REGIONS = (
|
||||
"ap-northeast-1",
|
||||
"ap-south-1",
|
||||
"ap-southeast-2",
|
||||
"eu-central-1",
|
||||
"eu-north-1",
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"sa-east-1",
|
||||
"us-east-1",
|
||||
"us-east-2",
|
||||
"us-west-2",
|
||||
)
|
||||
_BEDROCK_GPT_OSS_SAFEGUARD_REGIONS = (
|
||||
"ap-northeast-1",
|
||||
"ap-south-1",
|
||||
"ap-southeast-2",
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"sa-east-1",
|
||||
"us-east-1",
|
||||
"us-east-2",
|
||||
"us-west-2",
|
||||
)
|
||||
_BEDROCK_ON_DEMAND_MODEL_REGIONS = {
|
||||
"openai.gpt-oss-120b-1:0": _BEDROCK_GPT_OSS_BASE_REGIONS,
|
||||
"openai.gpt-oss-20b-1:0": _BEDROCK_GPT_OSS_BASE_REGIONS,
|
||||
"openai.gpt-oss-safeguard-120b": _BEDROCK_GPT_OSS_SAFEGUARD_REGIONS,
|
||||
"openai.gpt-oss-safeguard-20b": _BEDROCK_GPT_OSS_SAFEGUARD_REGIONS,
|
||||
"amazon.nova-lite-v1:0": (
|
||||
"ap-northeast-1",
|
||||
"ap-southeast-2",
|
||||
"eu-west-2",
|
||||
"us-east-1",
|
||||
"us-gov-west-1",
|
||||
),
|
||||
"amazon.nova-micro-v1:0": (
|
||||
"ap-southeast-2",
|
||||
"eu-west-2",
|
||||
"us-east-1",
|
||||
"us-gov-west-1",
|
||||
),
|
||||
"amazon.nova-pro-v1:0": (
|
||||
"ap-southeast-2",
|
||||
"eu-west-2",
|
||||
"us-east-1",
|
||||
"us-gov-west-1",
|
||||
),
|
||||
"anthropic.claude-3-5-haiku-20241022-v1:0": (
|
||||
"us-west-2",
|
||||
),
|
||||
"anthropic.claude-3-5-sonnet-20240620-v1:0": (
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-2",
|
||||
"ap-southeast-1",
|
||||
"eu-central-1",
|
||||
"eu-central-2",
|
||||
"us-east-1",
|
||||
"us-gov-west-1",
|
||||
"us-west-2",
|
||||
),
|
||||
"anthropic.claude-3-5-sonnet-20241022-v2:0": (
|
||||
"ap-southeast-2",
|
||||
"us-west-2",
|
||||
),
|
||||
"anthropic.claude-3-7-sonnet-20250219-v1:0": (
|
||||
"eu-west-2",
|
||||
"us-gov-west-1",
|
||||
),
|
||||
"anthropic.claude-3-haiku-20240307-v1:0": (
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-2",
|
||||
"ap-south-1",
|
||||
"ap-southeast-2",
|
||||
"eu-central-1",
|
||||
"eu-west-1",
|
||||
"eu-west-3",
|
||||
"us-east-1",
|
||||
"us-gov-west-1",
|
||||
"us-west-2",
|
||||
),
|
||||
}
|
||||
_CHATGPT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||
_CHATGPT_ISSUER = "https://auth.openai.com"
|
||||
_CHATGPT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
|
||||
@@ -367,6 +452,50 @@ class LLMProviderManager(metaclass=Singleton):
|
||||
api_key_hint="填写 Anthropic API Key。",
|
||||
description="Anthropic Claude 官方端点。",
|
||||
),
|
||||
ProviderSpec(
|
||||
id="amazon-bedrock",
|
||||
name="Amazon Bedrock",
|
||||
runtime="bedrock",
|
||||
models_dev_provider_id="amazon-bedrock",
|
||||
default_base_url="https://bedrock-runtime.us-east-1.amazonaws.com",
|
||||
base_url_presets=(
|
||||
url_preset(
|
||||
id="bedrock-us-east-1",
|
||||
label="美东(弗吉尼亚北部)us-east-1",
|
||||
value="https://bedrock-runtime.us-east-1.amazonaws.com",
|
||||
),
|
||||
url_preset(
|
||||
id="bedrock-us-west-2",
|
||||
label="美西(俄勒冈)us-west-2",
|
||||
value="https://bedrock-runtime.us-west-2.amazonaws.com",
|
||||
),
|
||||
url_preset(
|
||||
id="bedrock-eu-central-1",
|
||||
label="欧洲(法兰克福)eu-central-1",
|
||||
value="https://bedrock-runtime.eu-central-1.amazonaws.com",
|
||||
),
|
||||
url_preset(
|
||||
id="bedrock-ap-northeast-1",
|
||||
label="亚太(东京)ap-northeast-1",
|
||||
value="https://bedrock-runtime.ap-northeast-1.amazonaws.com",
|
||||
),
|
||||
url_preset(
|
||||
id="bedrock-ap-southeast-1",
|
||||
label="亚太(新加坡)ap-southeast-1",
|
||||
value="https://bedrock-runtime.ap-southeast-1.amazonaws.com",
|
||||
),
|
||||
),
|
||||
base_url_editable=True,
|
||||
api_key_label="Bedrock API Key / AK:SK",
|
||||
api_key_hint=(
|
||||
"支持两种认证方式:填写 Amazon Bedrock API Key(bedrock-api-key- 开头,"
|
||||
"Bearer 认证);或填写 Access Key ID:Secret Access Key(可选追加 :Session Token,"
|
||||
"SigV4 认证)。Base URL 决定 AWS Region。"
|
||||
),
|
||||
model_list_strategy="bedrock",
|
||||
description="Amazon Bedrock 托管模型服务,支持 Bedrock API Key 与 AK/SK 双认证。",
|
||||
sort_order=35,
|
||||
),
|
||||
ProviderSpec(
|
||||
id="deepseek",
|
||||
name="DeepSeek",
|
||||
@@ -1536,6 +1665,20 @@ class LLMProviderManager(metaclass=Singleton):
|
||||
await self.get_models_dev_data(use_proxy=use_proxy)
|
||||
).get(models_dev_provider_id, {}) or {}
|
||||
|
||||
@staticmethod
|
||||
def _models_dev_model_candidates(
|
||||
provider_id: str,
|
||||
model_id: str,
|
||||
) -> tuple[str, ...]:
|
||||
"""生成模型目录查询候选,兼容 Provider 添加的透明模型前缀。"""
|
||||
candidates = [model_id]
|
||||
if model_id.startswith("models/"):
|
||||
candidates.append(model_id.removeprefix("models/"))
|
||||
if provider_id == "amazon-bedrock" and "." in model_id:
|
||||
# Cross-region Inference Profile 会增加 us./eu./global. 等前缀。
|
||||
candidates.append(model_id.split(".", 1)[1])
|
||||
return tuple(dict.fromkeys(candidates))
|
||||
|
||||
async def _models_dev_model(
|
||||
self,
|
||||
provider_id: str,
|
||||
@@ -1555,15 +1698,32 @@ class LLMProviderManager(metaclass=Singleton):
|
||||
if not isinstance(models, dict):
|
||||
return None
|
||||
|
||||
candidates = [model_id]
|
||||
if model_id.startswith("models/"):
|
||||
candidates.append(model_id.removeprefix("models/"))
|
||||
|
||||
for candidate in candidates:
|
||||
for candidate in self._models_dev_model_candidates(provider_id, model_id):
|
||||
if candidate in models:
|
||||
return models[candidate]
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _metadata_supports_prompt_cache(metadata: Any) -> bool:
|
||||
"""从统一模型元数据中判断是否声明了提示词缓存能力。"""
|
||||
if not isinstance(metadata, dict):
|
||||
return False
|
||||
|
||||
explicit_capability = metadata.get("prompt_cache")
|
||||
if isinstance(explicit_capability, bool):
|
||||
return explicit_capability
|
||||
|
||||
capabilities = metadata.get("capabilities")
|
||||
if isinstance(capabilities, dict):
|
||||
explicit_capability = capabilities.get("prompt_cache")
|
||||
if isinstance(explicit_capability, bool):
|
||||
return explicit_capability
|
||||
|
||||
cost = metadata.get("cost")
|
||||
return isinstance(cost, dict) and any(
|
||||
key in cost for key in ("cache_read", "cache_write")
|
||||
)
|
||||
|
||||
def _cached_models_dev_model(
|
||||
self,
|
||||
provider_id: str,
|
||||
@@ -1590,11 +1750,7 @@ class LLMProviderManager(metaclass=Singleton):
|
||||
if not isinstance(models, dict):
|
||||
return None
|
||||
|
||||
candidates = [model_id]
|
||||
if model_id.startswith("models/"):
|
||||
candidates.append(model_id.removeprefix("models/"))
|
||||
|
||||
for candidate in candidates:
|
||||
for candidate in self._models_dev_model_candidates(provider_id, model_id):
|
||||
if candidate in models:
|
||||
return models[candidate]
|
||||
return None
|
||||
@@ -1743,6 +1899,112 @@ class LLMProviderManager(metaclass=Singleton):
|
||||
return normalized[:-3]
|
||||
return normalized
|
||||
|
||||
@classmethod
|
||||
def _extract_bedrock_region(cls, base_url: Optional[str]) -> str:
|
||||
"""
|
||||
从 Bedrock 运行时端点 URL 中提取 AWS Region
|
||||
|
||||
兼容标准端点、FIPS 端点与 PrivateLink(VPCE)端点等主机名形态,
|
||||
从中识别 Region 段。
|
||||
|
||||
:param base_url: 形如 https://bedrock-runtime.us-east-1.amazonaws.com 的端点地址
|
||||
:return: 提取到的 Region,无法识别时回退 us-east-1
|
||||
"""
|
||||
hostname = urlsplit((base_url or "").strip().lower()).hostname or ""
|
||||
match = re.search(
|
||||
r"(?:^|\.)(?:bedrock(?:-runtime)?(?:-fips)?)"
|
||||
r"\.([a-z0-9-]+-\d+)(?:\.|$)",
|
||||
hostname,
|
||||
)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return cls._BEDROCK_DEFAULT_REGION
|
||||
|
||||
# Inference Profile 的地理前缀与可用 Region 的对应关系,用于降级目录按
|
||||
# 当前 Region 过滤掉不可调用的 Profile 条目。
|
||||
_BEDROCK_GEO_PREFIXES: dict[str, tuple[str, ...]] = {
|
||||
"us": ("us-east-", "us-west-"),
|
||||
"eu": ("eu-",),
|
||||
"apac": ("ap-",),
|
||||
"au": ("ap-southeast-2", "ap-southeast-4"),
|
||||
"jp": ("ap-northeast-1", "ap-northeast-3"),
|
||||
"ca": ("ca-",),
|
||||
}
|
||||
_BEDROCK_NON_COMMERCIAL_REGION_PREFIXES = (
|
||||
"cn-",
|
||||
"eu-isoe-",
|
||||
"us-gov-",
|
||||
"us-iso-",
|
||||
"us-isob-",
|
||||
"us-isof-",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _bedrock_model_matches_region(cls, model_id: str, region: str) -> bool:
|
||||
"""
|
||||
判断目录中的模型 ID 在指定 Region 是否可调用
|
||||
|
||||
models.dev 目录同时收录裸模型 ID(直连调用)与带地理前缀的
|
||||
Inference Profile ID(us./eu./apac./global. 等)。带前缀的条目只在
|
||||
对应地理分区和 AWS 分区的 Region 可用;global Profile 仅允许商业
|
||||
AWS 分区。裸 ID 仅在明确记录的 ON_DEMAND Region 可用,未知条目
|
||||
按不可直连处理。
|
||||
|
||||
:param model_id: 目录中的模型 ID
|
||||
:param region: 当前 Base URL 对应的 AWS Region
|
||||
:return: 该模型在当前 Region 可调用时返回 True
|
||||
"""
|
||||
prefix = model_id.split(".", 1)[0]
|
||||
if prefix == "global":
|
||||
return not region.startswith(cls._BEDROCK_NON_COMMERCIAL_REGION_PREFIXES)
|
||||
region_prefixes = cls._BEDROCK_GEO_PREFIXES.get(prefix)
|
||||
if region_prefixes is not None:
|
||||
return (
|
||||
not region.startswith(cls._BEDROCK_NON_COMMERCIAL_REGION_PREFIXES)
|
||||
and region.startswith(region_prefixes)
|
||||
)
|
||||
on_demand_regions = cls._BEDROCK_ON_DEMAND_MODEL_REGIONS.get(model_id)
|
||||
return on_demand_regions is not None and region in on_demand_regions
|
||||
|
||||
@classmethod
|
||||
def _parse_bedrock_credentials(cls, api_key: Optional[str]) -> dict[str, Any]:
|
||||
"""
|
||||
解析 Bedrock 凭证字符串,识别 Bearer 与 SigV4 两种认证方式
|
||||
|
||||
- Bedrock API Key(bedrock-api-key- 开头的长期 Key,或控制台生成的短期
|
||||
Token)走 Bearer 认证;
|
||||
- `AccessKeyId:SecretAccessKey` 或 `AccessKeyId:SecretAccessKey:SessionToken`
|
||||
走 SigV4 认证,AWS Access Key ID 均以 "AKIA"/"ASIA" 开头。
|
||||
|
||||
:param api_key: 用户在 API Key 输入框填写的凭证内容
|
||||
:return: 含 auth_scheme 及对应凭证字段的字典
|
||||
"""
|
||||
normalized = str(api_key or "").strip()
|
||||
if not normalized:
|
||||
raise LLMProviderAuthError(
|
||||
"Amazon Bedrock 需要填写 Bedrock API Key 或 Access Key ID:Secret Access Key"
|
||||
)
|
||||
|
||||
if not normalized.startswith(cls._BEDROCK_API_KEY_PREFIX):
|
||||
parts = [part.strip() for part in normalized.split(":")]
|
||||
if len(parts) in {2, 3} and all(parts):
|
||||
credentials = {
|
||||
"auth_scheme": "sigv4",
|
||||
"access_key_id": parts[0],
|
||||
"secret_access_key": parts[1],
|
||||
}
|
||||
if len(parts) == 3:
|
||||
credentials["session_token"] = parts[2]
|
||||
return credentials
|
||||
if ":" in normalized:
|
||||
raise LLMProviderAuthError(
|
||||
"Amazon Bedrock AK/SK 凭证格式不正确,"
|
||||
"请按 AccessKeyId:SecretAccessKey 或 "
|
||||
"AccessKeyId:SecretAccessKey:SessionToken 填写"
|
||||
)
|
||||
|
||||
return {"auth_scheme": "bearer", "bearer_token": normalized}
|
||||
|
||||
async def _list_models_from_google(
|
||||
self,
|
||||
api_key: str,
|
||||
@@ -1857,6 +2119,235 @@ class LLMProviderManager(metaclass=Singleton):
|
||||
)
|
||||
return sorted(results, key=lambda item: item["name"].lower())
|
||||
|
||||
def _build_bedrock_boto3_config(
|
||||
self,
|
||||
use_proxy: Optional[bool] = None,
|
||||
) -> Any:
|
||||
"""
|
||||
构造 Bedrock boto3 客户端配置,统一超时、重试与代理策略
|
||||
|
||||
:param use_proxy: 是否使用系统代理,None 时读取 LLM_USE_PROXY 配置
|
||||
:return: botocore Config 实例
|
||||
"""
|
||||
from botocore.config import Config
|
||||
|
||||
should_use_proxy = settings.LLM_USE_PROXY if use_proxy is None else use_proxy
|
||||
proxies = None
|
||||
if should_use_proxy and settings.PROXY_HOST:
|
||||
proxies = {"http": settings.PROXY_HOST, "https": settings.PROXY_HOST}
|
||||
return Config(
|
||||
connect_timeout=10,
|
||||
read_timeout=60,
|
||||
retries={"max_attempts": 3, "mode": "standard"},
|
||||
proxies=proxies,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _bedrock_endpoint_url(
|
||||
service_name: str, base_url: Optional[str]
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
解析应传给 boto3 客户端的自定义端点 URL
|
||||
|
||||
标准公有端点交由 boto3 按 Region 自行推导;用户填写 PrivateLink、
|
||||
FIPS 等非标准端点时才显式透传,保证所选网络路径实际生效。
|
||||
|
||||
:param service_name: boto3 服务名(bedrock 或 bedrock-runtime)
|
||||
:param base_url: 用户配置的 Base URL
|
||||
:return: 需要显式指定端点时返回 URL,否则返回 None
|
||||
"""
|
||||
normalized = (base_url or "").strip().rstrip("/")
|
||||
if not normalized:
|
||||
return None
|
||||
if re.fullmatch(
|
||||
rf"https://{service_name}\.[a-z0-9-]+\.amazonaws\.com",
|
||||
normalized,
|
||||
):
|
||||
return None
|
||||
return normalized
|
||||
|
||||
def create_bedrock_client(
|
||||
self,
|
||||
service_name: str,
|
||||
region: str,
|
||||
credentials: dict[str, Any],
|
||||
base_url: Optional[str] = None,
|
||||
use_proxy: Optional[bool] = None,
|
||||
read_timeout: Optional[int] = None,
|
||||
) -> Any:
|
||||
"""
|
||||
按解析后的凭证创建 Bedrock boto3 客户端,Bearer 方式注入 Authorization 头
|
||||
|
||||
:param service_name: boto3 服务名(bedrock 或 bedrock-runtime)
|
||||
:param region: AWS Region
|
||||
:param credentials: `_parse_bedrock_credentials` 的解析结果
|
||||
:param base_url: 用户配置的 Base URL,非标准端点(PrivateLink/FIPS 等)时透传给 boto3
|
||||
:param use_proxy: 是否使用系统代理
|
||||
:param read_timeout: 读取超时秒数,None 时使用默认值
|
||||
:return: boto3 客户端实例
|
||||
"""
|
||||
import boto3
|
||||
from botocore import UNSIGNED
|
||||
|
||||
config = self._build_bedrock_boto3_config(use_proxy)
|
||||
if read_timeout:
|
||||
config = config.merge(type(config)(read_timeout=read_timeout))
|
||||
endpoint_kwargs: dict[str, Any] = {}
|
||||
endpoint_url = self._bedrock_endpoint_url(service_name, base_url)
|
||||
if endpoint_url:
|
||||
endpoint_kwargs["endpoint_url"] = endpoint_url
|
||||
|
||||
if credentials["auth_scheme"] == "sigv4":
|
||||
return boto3.client(
|
||||
service_name,
|
||||
region_name=region,
|
||||
aws_access_key_id=credentials["access_key_id"],
|
||||
aws_secret_access_key=credentials["secret_access_key"],
|
||||
aws_session_token=credentials.get("session_token"),
|
||||
config=config,
|
||||
**endpoint_kwargs,
|
||||
)
|
||||
|
||||
# Bearer 认证:以 UNSIGNED 跳过 SigV4 签名,再把 API Key 注入 Authorization 头。
|
||||
bearer_token = credentials["bearer_token"]
|
||||
config = config.merge(type(config)(signature_version=UNSIGNED))
|
||||
client = boto3.client(
|
||||
service_name,
|
||||
region_name=region,
|
||||
aws_access_key_id="unsigned",
|
||||
aws_secret_access_key="unsigned",
|
||||
config=config,
|
||||
**endpoint_kwargs,
|
||||
)
|
||||
|
||||
def _inject_bearer(request: Any, **_kwargs: Any) -> None:
|
||||
request.headers["Authorization"] = f"Bearer {bearer_token}"
|
||||
|
||||
client.meta.events.register(
|
||||
f"request-created.{service_name}",
|
||||
_inject_bearer,
|
||||
)
|
||||
return client
|
||||
|
||||
async def _list_models_from_bedrock_fallback(
|
||||
self,
|
||||
region: str,
|
||||
use_proxy: Optional[bool] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
从 models.dev 目录筛选当前 Region 可调用的 Bedrock 模型
|
||||
|
||||
:param region: 当前 Base URL 对应的 AWS Region
|
||||
:param use_proxy: 是否使用系统代理
|
||||
:return: 过滤后的标准化模型记录列表
|
||||
"""
|
||||
models = await self._list_models_from_models_dev_only(
|
||||
provider_id="amazon-bedrock",
|
||||
use_proxy=use_proxy,
|
||||
)
|
||||
return [
|
||||
model
|
||||
for model in models
|
||||
if self._bedrock_model_matches_region(model["id"], region)
|
||||
]
|
||||
|
||||
async def _list_models_from_bedrock(
|
||||
self,
|
||||
api_key: str,
|
||||
base_url: Optional[str],
|
||||
use_proxy: Optional[bool] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
从 Bedrock 控制面拉取模型目录,聚合跨区 Inference Profile 与直连模型
|
||||
|
||||
Bedrock 多数新模型仅允许通过 Inference Profile(us./eu./apac./global. 前缀)
|
||||
调用,因此优先列出 Profile,再补充支持 ON_DEMAND 直连的基础模型。
|
||||
|
||||
:param api_key: 用户填写的凭证内容(Bedrock API Key 或 AK/SK)
|
||||
:param base_url: Bedrock 运行时端点,决定 Region
|
||||
:param use_proxy: 是否使用系统代理
|
||||
:return: 标准化后的模型记录列表
|
||||
"""
|
||||
credentials = self._parse_bedrock_credentials(api_key)
|
||||
region = self._extract_bedrock_region(base_url)
|
||||
# runtime VPCE 无法安全推导对应的控制面 VPCE;FIPS 端点也不能绕回
|
||||
# 公有非 FIPS 控制面,因此直接使用本地目录。
|
||||
if self._bedrock_endpoint_url("bedrock-runtime", base_url):
|
||||
return await self._list_models_from_bedrock_fallback(region, use_proxy)
|
||||
client = self.create_bedrock_client(
|
||||
"bedrock",
|
||||
region=region,
|
||||
credentials=credentials,
|
||||
use_proxy=use_proxy,
|
||||
)
|
||||
|
||||
def _fetch() -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
profiles: list[dict[str, Any]] = []
|
||||
paginator = client.get_paginator("list_inference_profiles")
|
||||
for page in paginator.paginate(typeEquals="SYSTEM_DEFINED"):
|
||||
profiles.extend(page.get("inferenceProfileSummaries") or [])
|
||||
foundation = client.list_foundation_models(
|
||||
byOutputModality="TEXT",
|
||||
byInferenceType="ON_DEMAND",
|
||||
).get("modelSummaries") or []
|
||||
return profiles, foundation
|
||||
|
||||
try:
|
||||
profile_summaries, foundation_summaries = await asyncio.to_thread(_fetch)
|
||||
except Exception as err:
|
||||
# 部分 Bedrock API Key 的授权范围仅覆盖 bedrock-runtime 推理接口,
|
||||
# 控制面查询被拒时降级到 models.dev 目录,保证仍能选择模型。
|
||||
logger.warning(
|
||||
f"获取 Amazon Bedrock 控制面模型列表失败,降级 models.dev 目录: {err}"
|
||||
)
|
||||
return await self._list_models_from_bedrock_fallback(region, use_proxy)
|
||||
finally:
|
||||
await asyncio.to_thread(client.close)
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
seen_ids: set[str] = set()
|
||||
|
||||
def _append_record(model_id: str, display_name: Optional[str]) -> None:
|
||||
if not model_id or model_id in seen_ids:
|
||||
return
|
||||
seen_ids.add(model_id)
|
||||
# Inference Profile 带区域前缀,models.dev 目录按基础模型 ID 收录,
|
||||
# 去掉首个前缀段再查一次元数据。
|
||||
metadata = self._cached_models_dev_model("amazon-bedrock", model_id)
|
||||
if not metadata and "." in model_id:
|
||||
metadata = self._cached_models_dev_model(
|
||||
"amazon-bedrock",
|
||||
model_id.split(".", 1)[1],
|
||||
)
|
||||
results.append(
|
||||
self._normalize_model_record(
|
||||
model_id=model_id,
|
||||
display_name=display_name or (metadata or {}).get("name") or model_id,
|
||||
metadata=metadata or {},
|
||||
source="provider",
|
||||
)
|
||||
)
|
||||
|
||||
for profile in profile_summaries:
|
||||
if (profile.get("status") or "ACTIVE") != "ACTIVE":
|
||||
continue
|
||||
_append_record(
|
||||
str(profile.get("inferenceProfileId") or "").strip(),
|
||||
profile.get("inferenceProfileName"),
|
||||
)
|
||||
# 控制面已按当前 Region 和 ON_DEMAND 筛选,不能复用仅面向
|
||||
# models.dev 降级目录的静态白名单,否则 AWS 新增模型会被遗漏。
|
||||
for summary in foundation_summaries:
|
||||
lifecycle = (summary.get("modelLifecycle") or {}).get("status") or "ACTIVE"
|
||||
if lifecycle != "ACTIVE":
|
||||
continue
|
||||
_append_record(
|
||||
str(summary.get("modelId") or "").strip(),
|
||||
summary.get("modelName"),
|
||||
)
|
||||
|
||||
return sorted(results, key=lambda item: item["name"].lower())
|
||||
|
||||
@staticmethod
|
||||
def _copilot_headers(
|
||||
token: Optional[str] = None, include_auth: bool = True
|
||||
@@ -2064,6 +2555,13 @@ class LLMProviderManager(metaclass=Singleton):
|
||||
use_proxy=use_proxy,
|
||||
)
|
||||
|
||||
if resolved_model_list_strategy == "bedrock":
|
||||
return await self._list_models_from_bedrock(
|
||||
api_key=runtime["api_key"],
|
||||
base_url=runtime.get("base_url"),
|
||||
use_proxy=use_proxy,
|
||||
)
|
||||
|
||||
if resolved_model_list_strategy == "anthropic_compatible":
|
||||
return await self._list_models_from_models_dev_only(
|
||||
provider_id=provider_id,
|
||||
@@ -2641,6 +3139,9 @@ class LLMProviderManager(metaclass=Singleton):
|
||||
"model_id": model,
|
||||
"model_record": model_record,
|
||||
"model_metadata": model_metadata,
|
||||
"supports_prompt_cache": self._metadata_supports_prompt_cache(
|
||||
model_metadata
|
||||
),
|
||||
"default_headers": None,
|
||||
"use_responses_api": None,
|
||||
"auth_mode": "api_key",
|
||||
@@ -2731,6 +3232,22 @@ class LLMProviderManager(metaclass=Singleton):
|
||||
)
|
||||
return result
|
||||
|
||||
if resolved_runtime == "bedrock":
|
||||
effective_base_url = normalized_base_url or self._default_base_url_for_provider(
|
||||
spec
|
||||
)
|
||||
credentials = self._parse_bedrock_credentials(normalized_api_key)
|
||||
result.update(
|
||||
{
|
||||
"api_key": normalized_api_key,
|
||||
"base_url": effective_base_url,
|
||||
"aws_region": self._extract_bedrock_region(effective_base_url),
|
||||
"aws_auth": credentials,
|
||||
"auth_mode": "api_key",
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
if resolved_runtime == "anthropic_compatible":
|
||||
effective_base_url = normalized_base_url or self._default_base_url_for_provider(
|
||||
spec
|
||||
|
||||
249
app/agent/llm/server_tools.py
Normal file
249
app/agent/llm/server_tools.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""LLM 服务端工具能力注册与解析。"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from fnmatch import fnmatch
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
WEB_SEARCH_MODES = frozenset({"local", "builtin", "auto", "disabled"})
|
||||
|
||||
|
||||
class ServerToolUnavailableError(ValueError):
|
||||
"""表示用户强制选择了当前模型不可用的服务端工具。"""
|
||||
|
||||
def __init__(self, *, provider: str, model: str, tool_id: str) -> None:
|
||||
"""初始化服务端工具不可用异常。"""
|
||||
self.provider = provider
|
||||
self.model = model
|
||||
self.tool_id = tool_id
|
||||
super().__init__(
|
||||
f"当前模型 {provider}/{model} 或接口地址不支持服务端联网搜索,"
|
||||
"请改用“自动”或“MoviePilot 本地搜索”"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServerToolCapability:
|
||||
"""描述一个模型可用的服务端工具能力。"""
|
||||
|
||||
tool_id: str
|
||||
provider_ids: tuple[str, ...]
|
||||
model_patterns: tuple[str, ...]
|
||||
required_api_protocol: str
|
||||
client_adapter: str
|
||||
tool_definition: dict[str, Any]
|
||||
base_url_patterns: tuple[str, ...] = ()
|
||||
match_without_base_url: bool = True
|
||||
|
||||
def matches(self, provider: str, model: str, base_url: Optional[str] = None) -> bool:
|
||||
"""判断给定 provider/model 是否匹配当前能力。"""
|
||||
normalized_provider = str(provider or "").strip().lower()
|
||||
normalized_model = str(model or "").strip().lower().removeprefix("models/")
|
||||
normalized_base_url = str(base_url or "").strip().lower()
|
||||
return (
|
||||
normalized_provider in self.provider_ids
|
||||
and any(fnmatch(normalized_model, pattern) for pattern in self.model_patterns)
|
||||
and (
|
||||
(not normalized_base_url and self.match_without_base_url)
|
||||
or not self.base_url_patterns
|
||||
or any(
|
||||
pattern in normalized_base_url
|
||||
for pattern in self.base_url_patterns
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def serialize(self) -> dict[str, Any]:
|
||||
"""返回供 API 与前端使用的能力元数据。"""
|
||||
return {
|
||||
"id": self.tool_id,
|
||||
"required_api_protocol": self.required_api_protocol,
|
||||
"client_adapter": self.client_adapter,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServerToolResolution:
|
||||
"""记录本次联网搜索模式解析后的执行策略。"""
|
||||
|
||||
mode: str
|
||||
use_local_web_search: bool
|
||||
server_tools: tuple[dict[str, Any], ...] = ()
|
||||
client_adapter: Optional[str] = None
|
||||
required_api_protocol: Optional[str] = None
|
||||
available: bool = False
|
||||
reason: Optional[str] = None
|
||||
|
||||
|
||||
class ServerToolRegistry:
|
||||
"""集中注册模型服务端工具,并解析通用执行策略。"""
|
||||
|
||||
_CAPABILITIES = (
|
||||
ServerToolCapability(
|
||||
tool_id="web_search",
|
||||
provider_ids=("chatgpt",),
|
||||
model_patterns=("gpt-5*", "gpt-4.1*", "o4-mini*"),
|
||||
base_url_patterns=("api.openai.com",),
|
||||
required_api_protocol="responses",
|
||||
client_adapter="openai_responses",
|
||||
tool_definition={"type": "web_search"},
|
||||
),
|
||||
ServerToolCapability(
|
||||
tool_id="web_search",
|
||||
provider_ids=("openai",),
|
||||
model_patterns=("gpt-5*", "gpt-4.1*", "o4-mini*"),
|
||||
base_url_patterns=("api.openai.com",),
|
||||
required_api_protocol="responses",
|
||||
client_adapter="openai_responses",
|
||||
tool_definition={"type": "web_search"},
|
||||
match_without_base_url=False,
|
||||
),
|
||||
ServerToolCapability(
|
||||
tool_id="web_search",
|
||||
provider_ids=("anthropic",),
|
||||
model_patterns=(
|
||||
"claude-opus-4*",
|
||||
"claude-sonnet-4*",
|
||||
"claude-haiku-4*",
|
||||
"claude-opus-5*",
|
||||
"claude-sonnet-5*",
|
||||
"claude-haiku-5*",
|
||||
"claude-fable-5*",
|
||||
"claude-mythos-5*",
|
||||
),
|
||||
base_url_patterns=("api.anthropic.com",),
|
||||
required_api_protocol="native",
|
||||
client_adapter="anthropic_native",
|
||||
tool_definition={
|
||||
"type": "web_search_20250305",
|
||||
"name": "web_search",
|
||||
},
|
||||
),
|
||||
ServerToolCapability(
|
||||
tool_id="web_search",
|
||||
provider_ids=("google",),
|
||||
model_patterns=("gemini-3*", "gemini-2.5*", "gemini-2.0-flash*"),
|
||||
required_api_protocol="native",
|
||||
client_adapter="google_native",
|
||||
tool_definition={"google_search": {}},
|
||||
),
|
||||
ServerToolCapability(
|
||||
tool_id="web_search",
|
||||
provider_ids=("xai",),
|
||||
model_patterns=("grok-4.5*",),
|
||||
base_url_patterns=("api.x.ai",),
|
||||
required_api_protocol="responses",
|
||||
client_adapter="openai_responses",
|
||||
tool_definition={"type": "web_search"},
|
||||
),
|
||||
ServerToolCapability(
|
||||
tool_id="web_search",
|
||||
provider_ids=("deepseek",),
|
||||
model_patterns=("deepseek-v4-flash",),
|
||||
base_url_patterns=("api.deepseek.com",),
|
||||
required_api_protocol="responses",
|
||||
client_adapter="openai_responses",
|
||||
tool_definition={"type": "web_search"},
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def normalize_web_search_mode(cls, mode: Optional[str]) -> str:
|
||||
"""规范化联网搜索模式,未知值回退为本地搜索。"""
|
||||
normalized = str(mode or "local").strip().lower()
|
||||
return normalized if normalized in WEB_SEARCH_MODES else "local"
|
||||
|
||||
@classmethod
|
||||
def get_capability(
|
||||
cls,
|
||||
*,
|
||||
provider: str,
|
||||
model: str,
|
||||
base_url: Optional[str] = None,
|
||||
tool_id: str,
|
||||
) -> Optional[ServerToolCapability]:
|
||||
"""查找指定模型的服务端工具能力。"""
|
||||
return next(
|
||||
(
|
||||
capability
|
||||
for capability in cls._CAPABILITIES
|
||||
if capability.tool_id == tool_id
|
||||
and capability.matches(provider, model, base_url)
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def list_capabilities(
|
||||
cls,
|
||||
*,
|
||||
provider: str,
|
||||
model: str,
|
||||
base_url: Optional[str] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""列出指定模型可用的服务端工具能力。"""
|
||||
return [
|
||||
capability.serialize()
|
||||
for capability in cls._CAPABILITIES
|
||||
if capability.matches(provider, model, base_url)
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def resolve_web_search(
|
||||
cls,
|
||||
*,
|
||||
provider: str,
|
||||
model: str,
|
||||
mode: Optional[str],
|
||||
api_protocol: Optional[str],
|
||||
base_url: Optional[str] = None,
|
||||
) -> ServerToolResolution:
|
||||
"""解析联网搜索应使用本地工具还是模型服务端工具。"""
|
||||
normalized_mode = cls.normalize_web_search_mode(mode)
|
||||
normalized_protocol = str(api_protocol or "auto").strip().lower()
|
||||
capability = cls.get_capability(
|
||||
provider=provider,
|
||||
model=model,
|
||||
base_url=base_url,
|
||||
tool_id="web_search",
|
||||
)
|
||||
|
||||
if normalized_mode == "disabled":
|
||||
return ServerToolResolution(
|
||||
mode=normalized_mode,
|
||||
use_local_web_search=False,
|
||||
reason="web_search_disabled",
|
||||
)
|
||||
if normalized_mode == "local":
|
||||
return ServerToolResolution(
|
||||
mode=normalized_mode,
|
||||
use_local_web_search=True,
|
||||
reason="local_web_search_selected",
|
||||
)
|
||||
if capability is None:
|
||||
return ServerToolResolution(
|
||||
mode=normalized_mode,
|
||||
use_local_web_search=normalized_mode == "auto",
|
||||
reason="builtin_web_search_unavailable",
|
||||
)
|
||||
if (
|
||||
normalized_mode == "auto"
|
||||
and normalized_protocol == "chat_completions"
|
||||
and capability.required_api_protocol == "responses"
|
||||
):
|
||||
return ServerToolResolution(
|
||||
mode=normalized_mode,
|
||||
use_local_web_search=True,
|
||||
available=True,
|
||||
reason="chat_completions_uses_local_fallback",
|
||||
)
|
||||
|
||||
return ServerToolResolution(
|
||||
mode=normalized_mode,
|
||||
use_local_web_search=False,
|
||||
server_tools=(dict(capability.tool_definition),),
|
||||
client_adapter=capability.client_adapter,
|
||||
required_api_protocol=capability.required_api_protocol,
|
||||
available=True,
|
||||
reason="builtin_web_search_selected",
|
||||
)
|
||||
600
app/agent/mcp.py
Normal file
600
app/agent/mcp.py
Normal 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()
|
||||
@@ -3,9 +3,10 @@
|
||||
|
||||
按日期存储在 CONFIG_PATH/agent/activity/YYYY-MM-DD.md 中,
|
||||
每次 Agent 执行完毕后自动调用 LLM 对本轮对话生成简洁的活动摘要,
|
||||
并在每次 Agent 启动时注入轻量索引,完整日志由工具按需查询。
|
||||
系统提示词只注入稳定的检索规则,完整日志由工具按需查询。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -88,10 +89,6 @@ ACTIVITY_ENTRY_PATTERN = re.compile(r"^-\s+\*\*(?P<time>\d{2}:\d{2})\*\*\s+(?P<s
|
||||
class QueryActivityLogInput(BaseModel):
|
||||
"""查询活动日志工具的输入参数模型。"""
|
||||
|
||||
explanation: Optional[str] = Field(
|
||||
None,
|
||||
description="Clear explanation of why this tool is being used in the current context",
|
||||
)
|
||||
keyword: Optional[str] = Field(
|
||||
None,
|
||||
description=(
|
||||
@@ -288,17 +285,15 @@ class _ActivityLogToolProvider:
|
||||
date: Optional[str] = None,
|
||||
days: Optional[int] = DEFAULT_QUERY_DAYS,
|
||||
limit: Optional[int] = DEFAULT_QUERY_LIMIT,
|
||||
explanation: Optional[str] = None,
|
||||
) -> str:
|
||||
"""查询活动日志并返回 JSON 字符串。"""
|
||||
logger.info(
|
||||
"查询活动日志: keyword=%s, use_regex=%s, date=%s, days=%s, limit=%s, explanation=%s",
|
||||
"查询活动日志: keyword=%s, use_regex=%s, date=%s, days=%s, limit=%s",
|
||||
keyword,
|
||||
use_regex,
|
||||
date,
|
||||
days,
|
||||
limit,
|
||||
explanation or "-",
|
||||
)
|
||||
try:
|
||||
payload = query_activity_logs(
|
||||
@@ -452,7 +447,7 @@ async def _summarize_with_llm(conversation_text: str) -> Optional[str]:
|
||||
llm = await LLMHelper.get_llm(streaming=False)
|
||||
prompt = SUMMARY_PROMPT.format(conversation=conversation_text)
|
||||
response = await llm.ainvoke(prompt)
|
||||
summary = response.content.strip()
|
||||
summary = LLMHelper.extract_text_content(response.content).strip()
|
||||
# 清理模型可能输出的前缀(如 "摘要:" "总结:")
|
||||
summary = re.sub(r"^(摘要|总结|活动记录)[::]\s*", "", summary)
|
||||
if summary.strip().upper() == SUMMARY_SKIP_MARKER:
|
||||
@@ -464,12 +459,8 @@ async def _summarize_with_llm(conversation_text: str) -> Optional[str]:
|
||||
|
||||
|
||||
ACTIVITY_LOG_SYSTEM_PROMPT = """<activity_log>
|
||||
<activity_log_index>
|
||||
{activity_log_index}
|
||||
</activity_log_index>
|
||||
|
||||
<activity_log_guidelines>
|
||||
The index only shows recent dates and entry counts, not full log contents.
|
||||
Activity log contents and indexes are not included in the default context.
|
||||
Use `query_activity_log` only when the user references previous work, asks to continue a prior task, or recent activity is clearly relevant.
|
||||
Activity logs are read-only and retained for {retention_days} days; use MEMORY.md for durable preferences.
|
||||
</activity_log_guidelines>
|
||||
@@ -478,10 +469,10 @@ ACTIVITY_LOG_SYSTEM_PROMPT = """<activity_log>
|
||||
|
||||
|
||||
class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, ResponseT]): # noqa
|
||||
"""自动记录 Agent 活动日志并注入轻量索引的中间件。
|
||||
"""自动记录 Agent 活动日志并注入稳定检索规则的中间件。
|
||||
|
||||
- abefore_agent: 加载近几天的活动日志索引
|
||||
- awrap_model_call: 将活动日志索引和检索规则注入系统提示词
|
||||
- awrap_model_call: 将固定的活动日志检索规则注入系统提示词
|
||||
- aafter_agent: 从本次对话中提取摘要并追加到当日日志文件
|
||||
|
||||
参数:
|
||||
@@ -505,6 +496,7 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
|
||||
self.retention_days = retention_days
|
||||
self.prompt_load_days = prompt_load_days
|
||||
self.stream_handler = stream_handler
|
||||
self._background_tasks: set[asyncio.Task[None]] = set()
|
||||
self._tool_provider = _ActivityLogToolProvider(activity_dir=activity_dir)
|
||||
self.tools = [
|
||||
StructuredTool.from_function(
|
||||
@@ -520,31 +512,9 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
|
||||
"""获取指定日期的日志文件路径。"""
|
||||
return AsyncPath(self.activity_dir) / f"{date_str}.md"
|
||||
|
||||
def _format_activity_log(self, contents: dict[str, str]) -> str:
|
||||
"""格式化活动日志索引用于系统提示词注入。"""
|
||||
if not contents:
|
||||
return ACTIVITY_LOG_SYSTEM_PROMPT.format(
|
||||
activity_log_index="(近期暂无活动日志索引。需要历史上下文时可调用 query_activity_log。)",
|
||||
retention_days=self.retention_days,
|
||||
)
|
||||
|
||||
# 按日期排序(最近的在前)
|
||||
sorted_dates = sorted(contents.keys(), reverse=True)
|
||||
sections = []
|
||||
for date_str in sorted_dates:
|
||||
content = contents[date_str].strip()
|
||||
if content:
|
||||
sections.append(f"### {date_str}\n{content}")
|
||||
|
||||
if not sections:
|
||||
return ACTIVITY_LOG_SYSTEM_PROMPT.format(
|
||||
activity_log_index="(近期暂无活动日志索引。需要历史上下文时可调用 query_activity_log。)",
|
||||
retention_days=self.retention_days,
|
||||
)
|
||||
|
||||
log_body = "\n".join(sections)
|
||||
def _format_activity_log(self, _contents: dict[str, str]) -> str:
|
||||
"""生成不受活动日志内容变化影响的系统提示词。"""
|
||||
return ACTIVITY_LOG_SYSTEM_PROMPT.format(
|
||||
activity_log_index=log_body,
|
||||
retention_days=self.retention_days,
|
||||
)
|
||||
|
||||
@@ -631,6 +601,44 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup old activity logs: {e}")
|
||||
|
||||
def _schedule_activity_recording(self, messages: list) -> None:
|
||||
"""提交后台活动记录任务,不阻塞当前 Agent 会话结束。"""
|
||||
task = asyncio.create_task(self._record_activity(messages))
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(self._on_activity_recording_done)
|
||||
|
||||
def _on_activity_recording_done(self, task: asyncio.Task[None]) -> None:
|
||||
"""清理已完成的后台任务并记录未捕获异常。"""
|
||||
self._background_tasks.discard(task)
|
||||
try:
|
||||
task.result()
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("活动日志后台记录任务已取消")
|
||||
except Exception as err:
|
||||
logger.warning(f"活动日志后台记录任务失败: {err}")
|
||||
|
||||
async def _record_activity(self, messages: list) -> None:
|
||||
"""在后台生成本轮活动摘要并写入活动日志。"""
|
||||
try:
|
||||
# 提取本轮交互
|
||||
round_messages = _extract_last_round(messages)
|
||||
if not round_messages:
|
||||
return
|
||||
if _should_skip_activity_summary(round_messages):
|
||||
return
|
||||
|
||||
# 格式化对话文本
|
||||
conversation_text = _format_conversation_for_summary(round_messages)
|
||||
if not conversation_text:
|
||||
return
|
||||
|
||||
# 调用 LLM 生成摘要
|
||||
summary = await _summarize_with_llm(conversation_text)
|
||||
if summary:
|
||||
await self._append_activity(summary)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to record activity: {e}")
|
||||
|
||||
async def abefore_agent(
|
||||
self, state: ActivityLogState, runtime: Runtime
|
||||
) -> Optional[ActivityLogStateUpdate]:
|
||||
@@ -699,28 +707,12 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
|
||||
async def aafter_agent(
|
||||
self, state: ActivityLogState, runtime: Runtime
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""Agent 执行完毕后,调用 LLM 对本轮对话生成摘要并追加到当日活动日志。"""
|
||||
"""Agent 执行完毕后,异步提交活动日志记录任务。"""
|
||||
try:
|
||||
messages = state.get("messages", [])
|
||||
if not messages:
|
||||
return None
|
||||
|
||||
# 提取本轮交互
|
||||
round_messages = _extract_last_round(messages)
|
||||
if not round_messages:
|
||||
return None
|
||||
if _should_skip_activity_summary(round_messages):
|
||||
return None
|
||||
|
||||
# 格式化对话文本
|
||||
conversation_text = _format_conversation_for_summary(round_messages)
|
||||
if not conversation_text:
|
||||
return None
|
||||
|
||||
# 调用 LLM 生成摘要
|
||||
summary = await _summarize_with_llm(conversation_text)
|
||||
if summary:
|
||||
await self._append_activity(summary)
|
||||
self._schedule_activity_recording(list(messages))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to record activity: {e}")
|
||||
|
||||
|
||||
@@ -204,9 +204,14 @@ You have a scheduled jobs system for user-requested delayed or recurring work.
|
||||
{jobs_list}
|
||||
|
||||
Rules:
|
||||
- Create jobs only when the user asks for delayed, recurring, reminder, or monitoring behavior.
|
||||
- Do not create jobs for immediate one-time work or work already handled by MoviePilot schedulers.
|
||||
- Each job lives in its own directory with a `JOB.md`; read the listed file before executing or updating an active job.
|
||||
- For new delayed, recurring, reminder, or monitoring work, use the dedicated
|
||||
`create_agent_task`, `query_agent_tasks`, `update_agent_task`, `run_agent_task`,
|
||||
and `delete_agent_task` tools. These tools use integer task IDs. Do not create
|
||||
or edit JOB.md files for new tasks.
|
||||
- Use `query_schedulers` and `run_scheduler` only for MoviePilot system, plugin,
|
||||
or workflow runtime services; never pass their string job IDs to Agent task tools.
|
||||
- Do not create tasks for immediate one-time work or work already handled by MoviePilot schedulers.
|
||||
- Entries listed above are legacy JOB.md tasks. Read their files only when a heartbeat asks you to execute them.
|
||||
- During heartbeat checks, act only on `pending` or `in_progress` jobs, update status/last_run/logs, and leave recurring jobs `pending` after each run.
|
||||
</jobs_system>
|
||||
"""
|
||||
@@ -230,7 +235,7 @@ class JobsMiddleware(AgentMiddleware[JobsState, ContextT, ResponseT]): # noqa
|
||||
def _format_jobs_list(jobs: list[JobMetadata]) -> str:
|
||||
"""格式化任务元数据列表用于系统提示词。"""
|
||||
if not jobs:
|
||||
return "(No active jobs. You can create jobs when users request periodic or scheduled tasks.)"
|
||||
return "(No active legacy JOB.md tasks. Use create_agent_task for new scheduled work.)"
|
||||
|
||||
lines = []
|
||||
for job in jobs:
|
||||
|
||||
@@ -27,8 +27,10 @@ from app.agent.middleware.utils import append_to_system_message
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.log import logger
|
||||
|
||||
# 安全提示: SKILL.md 文件最大限制为 10MB,防止 DoS 攻击
|
||||
MAX_SKILL_FILE_SIZE = 10 * 1024 * 1024
|
||||
# 磁盘读取上限与模型返回上限分离,避免异常大的 Skill 文件撑爆内存或上下文。
|
||||
MAX_SKILL_FILE_SIZE = 1 * 1024 * 1024
|
||||
MAX_SKILL_RESULT_CHARS = 64 * 1024
|
||||
SKILL_CONTENT_TRUNCATION_SUFFIX = "\n...(Skill 内容已截断)"
|
||||
|
||||
# Agent Skills 规范约束 (https://agentskills.io/specification)
|
||||
MAX_SKILL_NAME_LENGTH = 64
|
||||
@@ -92,10 +94,6 @@ class SkillsStateUpdate(TypedDict):
|
||||
class SkillToolInput(BaseModel):
|
||||
"""Skill 加载工具的输入参数模型。"""
|
||||
|
||||
explanation: Optional[str] = Field(
|
||||
None,
|
||||
description="Clear explanation of why this skill is needed in the current context",
|
||||
)
|
||||
name: str = Field(
|
||||
...,
|
||||
description="Skill name or id from the available skills list.",
|
||||
@@ -252,7 +250,17 @@ async def _alist_skills(source_path: AsyncPath) -> list[SkillMetadata]:
|
||||
for skill_path in skill_dirs:
|
||||
skill_md_path = skill_path / "SKILL.md"
|
||||
|
||||
skill_content = await skill_md_path.read_text(encoding="utf-8", errors="replace")
|
||||
stat = await skill_md_path.stat()
|
||||
if stat.st_size > MAX_SKILL_FILE_SIZE:
|
||||
logger.warning(
|
||||
"Skipping %s: file too large (%d bytes)",
|
||||
skill_md_path,
|
||||
stat.st_size,
|
||||
)
|
||||
continue
|
||||
skill_content = (await skill_md_path.read_bytes()).decode(
|
||||
"utf-8", errors="replace"
|
||||
)
|
||||
|
||||
# 解析元数据
|
||||
skill_metadata = _parse_skill_metadata(
|
||||
@@ -284,7 +292,16 @@ def _list_skills(source_path: Path) -> list[SkillMetadata]:
|
||||
skills: list[SkillMetadata] = []
|
||||
for skill_path in skill_dirs:
|
||||
skill_md_path = skill_path / "SKILL.md"
|
||||
skill_content = skill_md_path.read_text(encoding="utf-8", errors="replace")
|
||||
if skill_md_path.stat().st_size > MAX_SKILL_FILE_SIZE:
|
||||
logger.warning(
|
||||
"Skipping %s: file too large (%d bytes)",
|
||||
skill_md_path,
|
||||
skill_md_path.stat().st_size,
|
||||
)
|
||||
continue
|
||||
skill_content = skill_md_path.read_bytes().decode(
|
||||
"utf-8", errors="replace"
|
||||
)
|
||||
skill_metadata = _parse_skill_metadata(
|
||||
content=skill_content,
|
||||
skill_path=str(skill_md_path),
|
||||
@@ -460,9 +477,49 @@ class _SkillToolProvider:
|
||||
raw_content = await handle.read(MAX_SKILL_FILE_SIZE)
|
||||
return raw_content.decode("utf-8", errors="replace"), truncated
|
||||
|
||||
async def load_skill(self, name: str, explanation: Optional[str] = None) -> str:
|
||||
@staticmethod
|
||||
def _serialize_skill_payload(payload: dict[str, Any]) -> str:
|
||||
"""序列化 Skill 返回值,并严格限制最终进入模型的字符数。"""
|
||||
serialized = json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
if len(serialized) <= MAX_SKILL_RESULT_CHARS:
|
||||
return serialized
|
||||
|
||||
original_content = str(payload.get("content") or "")
|
||||
truncated_payload = dict(payload)
|
||||
truncated_payload["truncated"] = True
|
||||
low = 0
|
||||
high = len(original_content)
|
||||
best_result = json.dumps(
|
||||
{
|
||||
**truncated_payload,
|
||||
"content": SKILL_CONTENT_TRUNCATION_SUFFIX.strip(),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
while low <= high:
|
||||
middle = (low + high) // 2
|
||||
candidate = json.dumps(
|
||||
{
|
||||
**truncated_payload,
|
||||
"content": (
|
||||
original_content[:middle]
|
||||
+ SKILL_CONTENT_TRUNCATION_SUFFIX
|
||||
),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
if len(candidate) <= MAX_SKILL_RESULT_CHARS:
|
||||
best_result = candidate
|
||||
low = middle + 1
|
||||
else:
|
||||
high = middle - 1
|
||||
return best_result
|
||||
|
||||
async def load_skill(self, name: str) -> str:
|
||||
"""加载指定 Skill 的完整说明并返回 JSON 字符串。"""
|
||||
logger.info(f"加载 Skill: name={name}, explanation={explanation or '-'}")
|
||||
logger.info(f"加载 Skill: name={name}")
|
||||
try:
|
||||
skill = await self._find_skill(name)
|
||||
if not skill:
|
||||
@@ -475,7 +532,7 @@ class _SkillToolProvider:
|
||||
)
|
||||
|
||||
content, truncated = await self._read_skill_content(skill["path"])
|
||||
return json.dumps(
|
||||
return self._serialize_skill_payload(
|
||||
{
|
||||
"success": True,
|
||||
"skill": {
|
||||
@@ -487,9 +544,7 @@ class _SkillToolProvider:
|
||||
},
|
||||
"content": content,
|
||||
"truncated": truncated,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
}
|
||||
)
|
||||
except Exception as err:
|
||||
logger.error(f"加载 Skill 失败: {err}", exc_info=True)
|
||||
@@ -674,8 +729,7 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no
|
||||
if not isinstance(tool_args, dict):
|
||||
tool_args = {}
|
||||
logger.info(
|
||||
f"开始执行 Skill 工具: name={tool_args.get('name') or '-'}, "
|
||||
f"explanation={tool_args.get('explanation') or '-'}"
|
||||
f"开始执行 Skill 工具: name={tool_args.get('name') or '-'}"
|
||||
)
|
||||
if self.stream_handler and getattr(self.stream_handler, "is_streaming", False):
|
||||
self.stream_handler.record_tool_call(
|
||||
|
||||
@@ -377,11 +377,13 @@ class _SubAgentAgentProvider:
|
||||
model: BaseChatModel,
|
||||
profiles: tuple[_SubAgentProfile, ...],
|
||||
tools: list[BaseTool],
|
||||
server_tools: Optional[list[dict[str, Any]]] = None,
|
||||
) -> None:
|
||||
"""初始化子代理执行器。"""
|
||||
self._model = model
|
||||
self._profiles = {profile.name: profile for profile in profiles}
|
||||
self._tools = tools
|
||||
self._server_tools = server_tools or []
|
||||
self._agents = {}
|
||||
self._default_agent_name = "general-purpose"
|
||||
|
||||
@@ -404,7 +406,7 @@ class _SubAgentAgentProvider:
|
||||
)
|
||||
agent = create_agent(
|
||||
model=self._model,
|
||||
tools=subagent_tools,
|
||||
tools=[*subagent_tools, *self._server_tools],
|
||||
system_prompt=profile.prompt,
|
||||
name=profile.name,
|
||||
)
|
||||
@@ -462,16 +464,19 @@ class MoviePilotSubAgentMiddleware(AgentMiddleware):
|
||||
model: BaseChatModel,
|
||||
profiles: tuple[_SubAgentProfile, ...],
|
||||
tools: list[BaseTool],
|
||||
server_tools: Optional[list[dict[str, Any]]] = None,
|
||||
system_prompt: str = SUBAGENT_PARENT_PROMPT,
|
||||
task_description: str = SUBAGENT_TASK_DESCRIPTION,
|
||||
stream_handler: Any = None,
|
||||
) -> None:
|
||||
"""初始化同步子代理中间件。"""
|
||||
self.system_prompt = system_prompt
|
||||
self.stream_handler = stream_handler
|
||||
self._provider = _SubAgentAgentProvider(
|
||||
model=model,
|
||||
profiles=profiles,
|
||||
tools=tools,
|
||||
server_tools=server_tools,
|
||||
)
|
||||
self.tools = [
|
||||
StructuredTool.from_function(
|
||||
@@ -549,6 +554,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
|
||||
model: BaseChatModel,
|
||||
profiles: tuple[_SubAgentProfile, ...],
|
||||
tools: list[BaseTool],
|
||||
server_tools: Optional[list[dict[str, Any]]] = None,
|
||||
task_description: str = SUBAGENT_CONTROL_DESCRIPTION,
|
||||
stream_handler: Any = None,
|
||||
) -> None:
|
||||
@@ -558,6 +564,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
|
||||
model=model,
|
||||
profiles=profiles,
|
||||
tools=tools,
|
||||
server_tools=server_tools,
|
||||
)
|
||||
self._semaphore = asyncio.Semaphore(SUBAGENT_MAX_CONCURRENT_TASKS)
|
||||
self._tasks: dict[str, _SubAgentRuntimeTask] = {}
|
||||
@@ -1111,6 +1118,7 @@ def create_subagent_middlewares(
|
||||
*,
|
||||
model: BaseChatModel,
|
||||
tools: list[BaseTool],
|
||||
server_tools: Optional[list[dict[str, Any]]] = None,
|
||||
stream_handler: Any = None,
|
||||
) -> tuple[list[AgentMiddleware], list[BaseTool]]:
|
||||
"""创建子代理中间件列表和任务工具列表。"""
|
||||
@@ -1120,12 +1128,14 @@ def create_subagent_middlewares(
|
||||
model=model,
|
||||
profiles=profiles,
|
||||
tools=tools,
|
||||
server_tools=server_tools or [],
|
||||
stream_handler=stream_handler,
|
||||
)
|
||||
control_middleware = SubAgentTaskControlMiddleware(
|
||||
model=model,
|
||||
profiles=profiles,
|
||||
tools=tools,
|
||||
server_tools=server_tools or [],
|
||||
stream_handler=stream_handler,
|
||||
)
|
||||
|
||||
|
||||
@@ -51,6 +51,18 @@ class UsageMiddleware(AgentMiddleware):
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _first_int(
|
||||
cls,
|
||||
candidates: tuple[tuple[Any, tuple[str, ...]], ...],
|
||||
) -> int | None:
|
||||
"""按优先级返回首个可用的 usage 整数值。"""
|
||||
for container, keys in candidates:
|
||||
value = cls._lookup_int(container, *keys)
|
||||
if value is not None:
|
||||
return value
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _extract_model_name(cls, model: Any) -> str | None:
|
||||
return (
|
||||
@@ -82,6 +94,131 @@ class UsageMiddleware(AgentMiddleware):
|
||||
or {}
|
||||
)
|
||||
|
||||
input_token_details = None
|
||||
if usage_metadata:
|
||||
getter = getattr(usage_metadata, "get", None)
|
||||
input_token_details = (
|
||||
getter("input_token_details")
|
||||
if callable(getter)
|
||||
else getattr(usage_metadata, "input_token_details", None)
|
||||
)
|
||||
|
||||
cache_read_tokens = cls._first_int(
|
||||
(
|
||||
(
|
||||
input_token_details,
|
||||
(
|
||||
"cache_read",
|
||||
"cached_tokens",
|
||||
"cache_read_input_tokens",
|
||||
"cacheReadInputTokens",
|
||||
),
|
||||
),
|
||||
(
|
||||
token_usage,
|
||||
(
|
||||
"prompt_cache_hit_tokens",
|
||||
"cache_read_input_tokens",
|
||||
"cacheReadInputTokens",
|
||||
),
|
||||
),
|
||||
(
|
||||
response_metadata,
|
||||
(
|
||||
"prompt_cache_hit_tokens",
|
||||
"cache_read_input_tokens",
|
||||
"cacheReadInputTokens",
|
||||
"cached_tokens",
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
if cache_read_tokens is None:
|
||||
cache_read_tokens = cls._first_int(
|
||||
(
|
||||
(
|
||||
token_usage.get("prompt_tokens_details", {}),
|
||||
("cached_tokens", "cache_read"),
|
||||
),
|
||||
(
|
||||
token_usage.get("input_tokens_details", {}),
|
||||
("cached_tokens", "cache_read"),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
cache_write_tokens = cls._first_int(
|
||||
(
|
||||
(
|
||||
input_token_details,
|
||||
(
|
||||
"cache_creation",
|
||||
"cache_write",
|
||||
"cache_write_tokens",
|
||||
"cache_write_input_tokens",
|
||||
"cacheWriteInputTokens",
|
||||
),
|
||||
),
|
||||
(
|
||||
token_usage,
|
||||
(
|
||||
"cache_creation_input_tokens",
|
||||
"cache_write_tokens",
|
||||
"cache_write_input_tokens",
|
||||
"cacheWriteInputTokens",
|
||||
),
|
||||
),
|
||||
(
|
||||
response_metadata,
|
||||
(
|
||||
"cache_creation_input_tokens",
|
||||
"cache_write_tokens",
|
||||
"cache_write_input_tokens",
|
||||
"cacheWriteInputTokens",
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
if cache_write_tokens is None:
|
||||
cache_write_tokens = cls._first_int(
|
||||
(
|
||||
(
|
||||
token_usage.get("prompt_tokens_details", {}),
|
||||
("cache_write_tokens", "cache_creation"),
|
||||
),
|
||||
(
|
||||
token_usage.get("input_tokens_details", {}),
|
||||
("cache_write_tokens", "cache_creation"),
|
||||
),
|
||||
)
|
||||
)
|
||||
cache_write_ttl_tokens = sum(
|
||||
cls._lookup_int(
|
||||
input_token_details,
|
||||
ttl_key,
|
||||
)
|
||||
or 0
|
||||
for ttl_key in (
|
||||
"ephemeral_5m_input_tokens",
|
||||
"ephemeral_1h_input_tokens",
|
||||
)
|
||||
)
|
||||
if cache_write_ttl_tokens:
|
||||
cache_write_tokens = cache_write_ttl_tokens
|
||||
|
||||
cache_miss_tokens = cls._first_int(
|
||||
(
|
||||
(
|
||||
token_usage,
|
||||
("prompt_cache_miss_tokens", "cache_miss_input_tokens"),
|
||||
),
|
||||
(
|
||||
response_metadata,
|
||||
("prompt_cache_miss_tokens", "cache_miss_input_tokens"),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if input_tokens is None:
|
||||
input_tokens = cls._lookup_int(
|
||||
token_usage,
|
||||
@@ -94,6 +231,27 @@ class UsageMiddleware(AgentMiddleware):
|
||||
"prompt_token_count",
|
||||
"input_tokens",
|
||||
)
|
||||
if input_tokens is None:
|
||||
bedrock_input_tokens = cls._lookup_int(token_usage, "inputTokens")
|
||||
if bedrock_input_tokens is not None:
|
||||
input_tokens = (
|
||||
bedrock_input_tokens
|
||||
+ (cache_read_tokens or 0)
|
||||
+ (cache_write_tokens or 0)
|
||||
)
|
||||
if input_tokens is None and any(
|
||||
value is not None
|
||||
for value in (
|
||||
cache_read_tokens,
|
||||
cache_write_tokens,
|
||||
cache_miss_tokens,
|
||||
)
|
||||
):
|
||||
input_tokens = (
|
||||
(cache_read_tokens or 0)
|
||||
+ (cache_write_tokens or 0)
|
||||
+ (cache_miss_tokens or 0)
|
||||
)
|
||||
|
||||
if output_tokens is None:
|
||||
output_tokens = cls._lookup_int(
|
||||
@@ -113,8 +271,24 @@ class UsageMiddleware(AgentMiddleware):
|
||||
if total_tokens is None:
|
||||
total_tokens = cls._lookup_int(response_metadata, "total_token_count")
|
||||
|
||||
has_cache_usage = any(
|
||||
value is not None
|
||||
for value in (
|
||||
cache_read_tokens,
|
||||
cache_write_tokens,
|
||||
cache_miss_tokens,
|
||||
)
|
||||
)
|
||||
has_usage = any(
|
||||
value is not None for value in (input_tokens, output_tokens, total_tokens)
|
||||
value is not None
|
||||
for value in (
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
total_tokens,
|
||||
cache_read_tokens,
|
||||
cache_write_tokens,
|
||||
cache_miss_tokens,
|
||||
)
|
||||
)
|
||||
resolved_input = input_tokens or 0
|
||||
resolved_output = output_tokens or 0
|
||||
@@ -123,12 +297,32 @@ class UsageMiddleware(AgentMiddleware):
|
||||
if total_tokens is not None
|
||||
else resolved_input + resolved_output
|
||||
)
|
||||
resolved_cache_read = cache_read_tokens or 0
|
||||
resolved_cache_write = cache_write_tokens or 0
|
||||
uncached_input_tokens = (
|
||||
cache_miss_tokens
|
||||
if cache_miss_tokens is not None
|
||||
else max(
|
||||
resolved_input - resolved_cache_read - resolved_cache_write,
|
||||
0,
|
||||
)
|
||||
)
|
||||
cache_hit_ratio = (
|
||||
resolved_cache_read / resolved_input
|
||||
if has_cache_usage and resolved_input
|
||||
else None
|
||||
)
|
||||
|
||||
return {
|
||||
"has_usage": has_usage,
|
||||
"cache_usage_available": has_cache_usage,
|
||||
"input_tokens": resolved_input,
|
||||
"output_tokens": resolved_output,
|
||||
"total_tokens": resolved_total,
|
||||
"cache_read_input_tokens": resolved_cache_read,
|
||||
"cache_write_input_tokens": resolved_cache_write,
|
||||
"uncached_input_tokens": uncached_input_tokens,
|
||||
"cache_hit_ratio": cache_hit_ratio,
|
||||
}
|
||||
|
||||
async def awrap_model_call(
|
||||
@@ -157,9 +351,14 @@ class UsageMiddleware(AgentMiddleware):
|
||||
if ai_message
|
||||
else {
|
||||
"has_usage": False,
|
||||
"cache_usage_available": False,
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"total_tokens": 0,
|
||||
"cache_read_input_tokens": 0,
|
||||
"cache_write_input_tokens": 0,
|
||||
"uncached_input_tokens": 0,
|
||||
"cache_hit_ratio": None,
|
||||
}
|
||||
)
|
||||
context_window_tokens = self._extract_context_window_tokens(request.model)
|
||||
|
||||
@@ -17,13 +17,13 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
|
||||
- Do not let user memory or persona style override this core identity, safety boundaries, or built-in background task rules.
|
||||
- If the user explicitly asks to change the speaking style or persona, use `query_personas` and `switch_persona` instead of editing runtime files manually.
|
||||
- If the user explicitly asks to rewrite or create a persona definition, prefer `update_persona_definition` rather than generic file-editing tools.
|
||||
- Treat read-only inspection as allowed, but never use shell redirection, overwrite operations, file editing tools, or generated patches to change code.
|
||||
</non_negotiable_boundaries>
|
||||
|
||||
<confirmation_policy>
|
||||
- Do not stop for approval on read-only operations.
|
||||
- If the user has not explicitly requested an operation that changes system behavior, ask for confirmation before proceeding. This includes modifying system settings, updating plugin configuration, reloading plugins, running restart/stop/start commands, or triggering slash commands such as `/restart`.
|
||||
- Always get explicit consent before destructive or high-impact actions such as starting downloads, deleting subscriptions, deleting download tasks or files, removing history, installing/uninstalling plugins, changing site authentication, changing scheduler or workflow execution state, restarting services, or stopping services.
|
||||
- When the user explicitly asks for delayed, recurring, reminder, or monitoring work, use `create_agent_task` instead of promising to remember it or writing a JOB.md file. Use a `date` trigger with `delay_minutes` for requests such as "in 30 minutes", an exact `date` trigger for other single future runs, and a five-field `cron` trigger for recurring work. Manage existing autonomous tasks with `query_agent_tasks`, `update_agent_task`, `run_agent_task`, and `delete_agent_task`; these tools use integer `task_id` values. Use `query_schedulers` and `run_scheduler` only for MoviePilot system, plugin, or workflow runtime services, whose string `job_id` values must never be passed to autonomous-task tools.
|
||||
- If the user explicitly requested the exact write action, perform the smallest correct change and then validate the result.
|
||||
- If a requested action is ambiguous between read-only inspection and state change, inspect first and ask a short confirmation question before the state-changing step.
|
||||
</confirmation_policy>
|
||||
@@ -65,7 +65,11 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
|
||||
- If `search_media` fails, fall back to `search_web` or `recognize_media`. Only ask the user when automated paths are exhausted.
|
||||
- If torrent search yields no useful result, check site scope, site health, and recognition quality before concluding that the resource is unavailable.
|
||||
- Reuse the latest torrent search cache for `get_search_results` and `add_download_tasks` instead of re-running the same search unnecessarily.
|
||||
- Use `execute_command` only for diagnostics, read-only inspection, or commands the user explicitly asked to run. Its default `action=start` starts a managed background session and returns `session_id`, `status`, `last_seq`, and `output_until_seq`; call the same tool again with `action=read`, `action=wait`, `action=write`, or `action=kill` to poll output, wait in short segments, send stdin, or stop the process.
|
||||
- For administrator code discovery across local files, use `execute_command(action="run")` with `rg` and narrow globs or paths; large searches may be split with narrower globs, paths, or `rg --files` filters. Use `list_directory` to inspect one known directory or a supported remote storage backend; request its `limit`/`offset` page fields when more than the first page is needed, and use `read_file` when the exact local file is known. If `read_file` reports truncation, continue with smaller `start_line` and `end_line` ranges instead of assuming the file ended.
|
||||
- Read the relevant file before changing it. Use `edit_file` for localized exact replacements; make `old_text` unique with enough surrounding context, and use `replace_all=true` only when every match must change. Use `write_file` for new files; set `overwrite=true` only for an intentional full rewrite, and use `read_file(include_metadata=true)` plus `expected_sha256` when preserving the previously read version matters.
|
||||
- When implementation depends on a Python or Node.js API, first identify the installed or locked dependency version from environment metadata, requirements, package manifests, lockfiles, local source, and type declarations. Use `rg` against the relevant package directory, `.venv`, or `node_modules` instead of scanning the entire project without bounds. If local evidence is insufficient, use `search_web` and then `browse_webpage` to read the matching version of the official documentation. Do not guess signatures from memory, mix examples from incompatible versions, or install a package only to inspect its API.
|
||||
- Use structured file tools for source edits because they enforce file access boundaries and conflict checks. Never use shell redirection, inline scripts, or another tool to bypass a file-tool permission denial.
|
||||
- Use `execute_command` for administrator-only multi-file diagnostics, tests, Git, service operations, SSH, or an exact command the user requested. Use `action=run` for short bounded commands. Use `action=start` for long-running or interactive commands, including SSH; then continue with `read`, `wait`, `write`, or `kill` using the returned `session_id`. Do not start a background session for a short command that can finish within `action=run`.
|
||||
</tool_strategy>
|
||||
|
||||
<media_rules>
|
||||
@@ -81,7 +85,14 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
|
||||
</agent_core>
|
||||
|
||||
<communication_runtime>
|
||||
{verbose_spec}
|
||||
<progress_updates>
|
||||
- Base progress updates on meaningful changes in understanding or execution, not on elapsed time or the number of tool calls.
|
||||
- Do not send a progress update merely because work is starting or because one or two tools have finished. Work through a coherent batch of investigation first.
|
||||
- Send an intermediate update when you have a useful preliminary conclusion, complete or validate a meaningful stage, discover evidence that materially changes the working direction, or encounter a sustained blocker the user should know about.
|
||||
- Explain the result or new direction with enough context to be useful, including the key evidence and what you will do next. An update may use several sentences when the finding needs explanation; brevity is not a goal by itself.
|
||||
- Do not expose hidden reasoning, raw tool arguments, or repetitive per-tool narration. Do not repeat an unchanged status.
|
||||
- Continue working after each update. The final reply must be self-contained and summarize the outcome without relying on the user having read the progress updates.
|
||||
</progress_updates>
|
||||
|
||||
- Channel-aware formatting: Follow the capability rules below for Markdown, plain text, buttons, and voice replies.
|
||||
{button_choice_spec}
|
||||
|
||||
@@ -79,6 +79,10 @@ task_types:
|
||||
- "- Transfer mode: {transfer_mode}"
|
||||
- "- Current TMDB ID: {tmdbid}"
|
||||
- "- Current Douban ID: {doubanid}"
|
||||
- "- Current Bangumi ID: {bangumiid}"
|
||||
- "- Current AniList ID: {anilistid}"
|
||||
- "- Current media source: {media_source}"
|
||||
- "- Current source-native ID: {media_id}"
|
||||
- "- Error message: {error_message}"
|
||||
steps_title: "Required workflow"
|
||||
steps:
|
||||
@@ -90,7 +94,7 @@ task_types:
|
||||
- "Only continue when you have high confidence in the target media."
|
||||
- "Before re-organizing, delete the old transfer history record with `delete_transfer_history` so the system will not skip the source file."
|
||||
- "Then use `transfer_file` to organize the source path directly."
|
||||
- "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, tmdbid or doubanid, and media_type."
|
||||
- "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, all known media IDs, media_source, media_id, and media_type."
|
||||
- "If this record is already correct and no re-organize is needed, do not perform destructive actions; simply report that no change is necessary."
|
||||
task_rules:
|
||||
- "Do NOT rely on previous chat context. Work only from the record above."
|
||||
@@ -116,7 +120,7 @@ task_types:
|
||||
- "If a source file no longer exists or cannot be safely processed, skip that record and note the reason."
|
||||
- "Before re-organizing a record, delete the old transfer history record with `delete_transfer_history` so the system will not skip the source file."
|
||||
- "Then use `transfer_file` to organize the source path directly."
|
||||
- "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, tmdbid or doubanid, and media_type."
|
||||
- "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, all known media IDs, media_source, media_id, and media_type."
|
||||
- "If a record is already correct and no re-organize is needed, do not perform destructive actions; simply mark it as skipped."
|
||||
- "Report only the aggregate outcome, including how many records succeeded, skipped, and failed."
|
||||
task_rules:
|
||||
|
||||
@@ -24,6 +24,7 @@ SYSTEM_TASKS_FILE = "System Tasks.yaml"
|
||||
SYSTEM_TASKS_SCHEMA_VERSION = 2
|
||||
COMMON_SHELL_COMMANDS = (
|
||||
"ssh",
|
||||
"sshpass",
|
||||
"scp",
|
||||
"sftp",
|
||||
"git",
|
||||
@@ -139,19 +140,6 @@ class PromptManager:
|
||||
markdown_spec = self._generate_formatting_instructions(caps)
|
||||
button_choice_spec = self._generate_button_choice_instructions(msg_channel)
|
||||
|
||||
# 啰嗦模式
|
||||
verbose_spec = ""
|
||||
if not settings.AI_AGENT_VERBOSE:
|
||||
verbose_spec = (
|
||||
"\n\n[Important Instruction] STRICTLY ENFORCED: "
|
||||
"If tools are needed, DO NOT output any conversational text, explanations, progress updates, "
|
||||
"or acknowledgements before the first tool call or between tool calls. "
|
||||
"Call tools directly without any transitional phrases. "
|
||||
"You MUST remain completely silent until all required tools have finished and you have the final result. "
|
||||
"Only then may you send one final user-facing reply. "
|
||||
"DO NOT output any intermediate content whatsoever."
|
||||
)
|
||||
|
||||
# MoviePilot系统信息
|
||||
moviepilot_info = self._get_moviepilot_info()
|
||||
voice_reply_spec = self._generate_voice_reply_instructions()
|
||||
@@ -159,7 +147,6 @@ class PromptManager:
|
||||
# 始终替换占位符,避免后续 .format() 时因残留花括号报 KeyError
|
||||
base_prompt = base_prompt.format(
|
||||
markdown_spec=markdown_spec,
|
||||
verbose_spec=verbose_spec,
|
||||
moviepilot_info=moviepilot_info,
|
||||
voice_reply_spec=voice_reply_spec,
|
||||
button_choice_spec=button_choice_spec,
|
||||
@@ -315,8 +302,6 @@ class PromptManager:
|
||||
"项目根目录": settings.ROOT_PATH,
|
||||
"配置目录": settings.CONFIG_PATH,
|
||||
"临时目录": settings.TEMP_PATH,
|
||||
"日志目录": settings.LOG_PATH,
|
||||
"主日志文件": settings.LOG_PATH / "moviepilot.log",
|
||||
}
|
||||
return [f" - {label}: `{path}`" for label, path in paths.items()]
|
||||
|
||||
|
||||
@@ -32,6 +32,10 @@ def build_manual_redo_template_context(history: Any) -> dict[str, int | str]:
|
||||
"transfer_mode": history.mode or "unknown",
|
||||
"tmdbid": history.tmdbid or "none",
|
||||
"doubanid": history.doubanid or "none",
|
||||
"bangumiid": history.bangumiid or "none",
|
||||
"anilistid": history.anilistid or "none",
|
||||
"media_source": history.media_source or "none",
|
||||
"media_id": history.media_id or "none",
|
||||
"error_message": history.errmsg or "none",
|
||||
}
|
||||
|
||||
@@ -55,6 +59,10 @@ def format_manual_redo_record_context(history: Any) -> str:
|
||||
f"- Transfer mode: {context['transfer_mode']}",
|
||||
f"- Current TMDB ID: {context['tmdbid']}",
|
||||
f"- Current Douban ID: {context['doubanid']}",
|
||||
f"- Current Bangumi ID: {context['bangumiid']}",
|
||||
f"- Current AniList ID: {context['anilistid']}",
|
||||
f"- Current media source: {context['media_source']}",
|
||||
f"- Current source-native ID: {context['media_id']}",
|
||||
f"- Error message: {context['error_message']}",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -28,7 +28,6 @@ class ToolChain(ChainBase):
|
||||
# 单个工具结果的兜底上限。各工具仍应优先在自身逻辑中分页或摘要化;
|
||||
# 这里用于拦截遗漏路径,避免超大结果直接进入模型上下文。
|
||||
DEFAULT_TOOL_RESULT_MAX_CHARS = 64 * 1024
|
||||
MIN_TOOL_RESULT_PREVIEW_CHARS = 512
|
||||
|
||||
|
||||
def serialize_tool_result_for_agent(result: Any) -> str:
|
||||
@@ -59,20 +58,35 @@ def format_tool_result_for_agent(
|
||||
if not max_chars or max_chars <= 0 or len(formatted_result) <= max_chars:
|
||||
return formatted_result
|
||||
|
||||
preview_limit = max(MIN_TOOL_RESULT_PREVIEW_CHARS, max_chars)
|
||||
preview = formatted_result[:preview_limit]
|
||||
payload = {
|
||||
"tool_result_truncated": True,
|
||||
"tool_name": tool_name,
|
||||
"total_chars": len(formatted_result),
|
||||
"returned_chars": len(preview),
|
||||
"content_preview": preview,
|
||||
"message": (
|
||||
f"工具返回内容超过 {max_chars} 字符,已截断为预览;"
|
||||
"请使用更精确的筛选条件、分页参数或专用查询参数继续获取。"
|
||||
),
|
||||
}
|
||||
return json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
def _dump_preview(preview: str) -> str:
|
||||
"""序列化截断结果,并让 returned_chars 与实际预览保持一致。"""
|
||||
payload = {
|
||||
"tool_result_truncated": True,
|
||||
"tool_name": tool_name,
|
||||
"total_chars": len(formatted_result),
|
||||
"returned_chars": len(preview),
|
||||
"content_preview": preview,
|
||||
"message": (
|
||||
f"工具返回内容超过 {max_chars} 字符,已截断为预览;"
|
||||
"请使用更精确的筛选条件、分页参数或专用查询参数继续获取。"
|
||||
),
|
||||
}
|
||||
return json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
|
||||
# JSON 会转义换行、引号和反斜杠,预览本身等于上限时,最终返回值仍可能
|
||||
# 明显超限。通过二分查找预留包装开销,确保进入模型的最终字符串是硬上限。
|
||||
low = 0
|
||||
high = min(len(formatted_result), max_chars)
|
||||
best_result = _dump_preview("")
|
||||
while low <= high:
|
||||
middle = (low + high) // 2
|
||||
candidate = _dump_preview(formatted_result[:middle])
|
||||
if len(candidate) <= max_chars:
|
||||
best_result = candidate
|
||||
low = middle + 1
|
||||
else:
|
||||
high = middle - 1
|
||||
return best_result
|
||||
|
||||
|
||||
# 将常见的阻塞调用按能力域拆分到独立线程池,避免外部慢 IO 抢占同一批 worker。
|
||||
@@ -238,10 +252,6 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
|
||||
# 获取工具执行提示消息
|
||||
tool_message = self.get_tool_message(**kwargs)
|
||||
if not tool_message:
|
||||
explanation = kwargs.get("explanation")
|
||||
if explanation:
|
||||
tool_message = explanation
|
||||
|
||||
# 发送工具执行过程消息(流式传输且非最后终结工具时)
|
||||
if self._stream_handler and self._stream_handler.is_streaming and not self.return_direct:
|
||||
@@ -325,16 +335,13 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
获取工具执行时的友好提示消息。
|
||||
|
||||
子类可以重写此方法,根据实际参数生成个性化的提示消息。
|
||||
如果返回 None 或空字符串,将回退使用 explanation 参数。
|
||||
|
||||
Args:
|
||||
**kwargs: 工具的所有参数(包括 explanation)
|
||||
**kwargs: 工具的所有参数
|
||||
|
||||
Returns:
|
||||
str: 友好的提示消息,如果返回 None 或空字符串则使用 explanation
|
||||
str: 友好的提示消息
|
||||
"""
|
||||
explanation = kwargs.get("explanation")
|
||||
return str(explanation) if explanation else None
|
||||
return None
|
||||
|
||||
@abstractmethod
|
||||
async def run(self, **kwargs) -> str:
|
||||
@@ -432,7 +439,6 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
"""
|
||||
roots = [
|
||||
settings.CONFIG_PATH / "agent",
|
||||
settings.LOG_PATH,
|
||||
]
|
||||
resolved_roots = []
|
||||
for root in roots:
|
||||
@@ -468,7 +474,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
allowed_text = "、".join(str(root) for root in allowed_roots)
|
||||
return (
|
||||
resolved_path,
|
||||
f"抱歉,普通用户只能{operation}Agent配置目录和日志目录内的文件或目录:{allowed_text}",
|
||||
f"抱歉,普通用户只能{operation}Agent配置目录内的文件或目录:{allowed_text}",
|
||||
)
|
||||
|
||||
async def _check_local_storage_access(
|
||||
@@ -490,7 +496,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
return None, None
|
||||
return (
|
||||
None,
|
||||
f"抱歉,普通用户只能{operation}本地配置目录、Agent记忆目录和日志目录,不能访问远程存储。",
|
||||
f"抱歉,普通用户只能{operation}本地Agent配置目录,不能访问远程存储。",
|
||||
)
|
||||
|
||||
return await self._check_local_file_access(path=path, operation=operation)
|
||||
@@ -516,8 +522,8 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
return (
|
||||
"抱歉,您没有执行此工具的权限。"
|
||||
"只有渠道管理员或系统管理员才能执行工具操作。"
|
||||
"如需执行工具,请联系渠道管理员将您的用户ID添加到渠道管理员列表中,"
|
||||
"或联系系统管理员为您设置权限。"
|
||||
"如需执行工具,请联系管理员将您的用户ID添加到渠道管理员列表中(设定 -> 通知 -> 对应渠道配置 -> 管理员名单),"
|
||||
"或联系系统管理员为您设置管理员权限。"
|
||||
)
|
||||
|
||||
async def _has_channel_admin_permission(self) -> bool:
|
||||
@@ -628,7 +634,8 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
发送工具通知消息。
|
||||
|
||||
WebAgent 渠道没有后端模块实例,前端流式面板通过 Agent 上下文中的
|
||||
回调直接接收通知;其它渠道继续走统一消息链。
|
||||
回调直接接收通知;无渠道的后台任务清空渠道侧定位信息后交由消息链广播,
|
||||
其它渠道继续走统一消息链。
|
||||
"""
|
||||
callback = self._agent_context.get("notification_callback")
|
||||
if (
|
||||
@@ -638,6 +645,20 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
callback(notification)
|
||||
return
|
||||
|
||||
if not self._channel or not self._source:
|
||||
notification = notification.model_copy(
|
||||
update={
|
||||
"channel": None,
|
||||
"source": None,
|
||||
"userid": None,
|
||||
"username": notification.username
|
||||
or self._username
|
||||
or settings.SUPERUSER,
|
||||
"original_message_id": None,
|
||||
"original_chat_id": None,
|
||||
}
|
||||
)
|
||||
|
||||
await ToolChain().async_post_message(notification)
|
||||
|
||||
async def send_tool_message(
|
||||
@@ -656,5 +677,6 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
title=title,
|
||||
text=message,
|
||||
image=image,
|
||||
save_history=False,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -42,8 +42,13 @@ from app.agent.tools.impl.send_message import SendMessageTool
|
||||
from app.agent.tools.impl.ask_user_choice import AskUserChoiceTool
|
||||
from app.agent.tools.impl.send_local_file import SendLocalFileTool
|
||||
from app.agent.tools.impl.send_voice_message import SendVoiceMessageTool
|
||||
from app.agent.tools.impl.create_agent_task import CreateAgentTaskTool
|
||||
from app.agent.tools.impl.delete_agent_task import DeleteAgentTaskTool
|
||||
from app.agent.tools.impl.query_agent_tasks import QueryAgentTasksTool
|
||||
from app.agent.tools.impl.query_schedulers import QuerySchedulersTool
|
||||
from app.agent.tools.impl.run_agent_task import RunAgentTaskTool
|
||||
from app.agent.tools.impl.run_scheduler import RunSchedulerTool
|
||||
from app.agent.tools.impl.update_agent_task import UpdateAgentTaskTool
|
||||
from app.agent.tools.impl.query_workflows import QueryWorkflowsTool
|
||||
from app.agent.tools.impl.run_workflow import RunWorkflowTool
|
||||
from app.agent.tools.impl.query_personas import QueryPersonasTool
|
||||
@@ -141,6 +146,11 @@ class MoviePilotToolFactory:
|
||||
QueryTransferHistoryTool,
|
||||
TransferFileTool,
|
||||
SendMessageTool,
|
||||
CreateAgentTaskTool,
|
||||
QueryAgentTasksTool,
|
||||
UpdateAgentTaskTool,
|
||||
RunAgentTaskTool,
|
||||
DeleteAgentTaskTool,
|
||||
QuerySchedulersTool,
|
||||
RunSchedulerTool,
|
||||
QueryWorkflowsTool,
|
||||
@@ -181,6 +191,8 @@ class MoviePilotToolFactory:
|
||||
"edit_file",
|
||||
"execute_command",
|
||||
"ask_user_choice",
|
||||
"create_agent_task",
|
||||
"query_agent_tasks",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
53
app/agent/tools/impl/_file_write_utils.py
Normal file
53
app/agent/tools/impl/_file_write_utils.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Agent 文件写入工具的共享辅助函数。"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class FileVersionConflictError(RuntimeError):
|
||||
"""目标文件在准备写入期间发生变化。"""
|
||||
|
||||
|
||||
def calculate_file_sha256(path: Path) -> str:
|
||||
"""计算文件原始字节的 SHA-256,用于检测陈旧写入。"""
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as file_handle:
|
||||
for chunk in iter(lambda: file_handle.read(64 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def atomic_write_text(
|
||||
path: Path,
|
||||
content: str,
|
||||
expected_sha256: str | None = None,
|
||||
) -> None:
|
||||
"""校验目标版本后,在同目录写入临时文件并原子替换文本。"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor, temp_name = tempfile.mkstemp(
|
||||
dir=path.parent,
|
||||
prefix=f".{path.name}.",
|
||||
suffix=".tmp",
|
||||
)
|
||||
temp_path = Path(temp_name)
|
||||
try:
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as file_handle:
|
||||
file_handle.write(content)
|
||||
file_handle.flush()
|
||||
os.fsync(file_handle.fileno())
|
||||
|
||||
if expected_sha256:
|
||||
if (
|
||||
not path.is_file()
|
||||
or calculate_file_sha256(path).casefold()
|
||||
!= expected_sha256.casefold()
|
||||
):
|
||||
raise FileVersionConflictError(str(path))
|
||||
if path.exists():
|
||||
os.chmod(temp_path, path.stat().st_mode)
|
||||
os.replace(temp_path, path)
|
||||
finally:
|
||||
if temp_path.exists():
|
||||
temp_path.unlink()
|
||||
@@ -59,6 +59,10 @@ SYSTEMCONFIG_SETTING_METADATA = {
|
||||
"group": "ai_agent",
|
||||
"label": "AI 智能体配置",
|
||||
},
|
||||
SystemConfigKey.AIAgentMcpServers.value: {
|
||||
"group": "ai_agent",
|
||||
"label": "AI 智能体外部 MCP 服务器",
|
||||
},
|
||||
SystemConfigKey.CustomIdentifiers.value: {
|
||||
"group": "custom_identifiers",
|
||||
"label": "自定义识别词",
|
||||
|
||||
@@ -127,8 +127,19 @@ def filter_contexts(items: List[Context],
|
||||
return filtered_items
|
||||
|
||||
|
||||
def simplify_search_result(context: Context, index: int) -> dict:
|
||||
"""精简单条搜索结果"""
|
||||
def simplify_search_result(
|
||||
context: Context,
|
||||
index: int,
|
||||
include_description: bool = False,
|
||||
) -> dict:
|
||||
"""
|
||||
精简单条搜索结果
|
||||
|
||||
:param context: 搜索结果上下文
|
||||
:param index: 搜索结果在原始缓存中的序号
|
||||
:param include_description: 是否返回种子简介
|
||||
:return: 精简后的搜索结果
|
||||
"""
|
||||
simplified = {}
|
||||
torrent_info = context.torrent_info
|
||||
meta_info = context.meta_info
|
||||
@@ -147,6 +158,8 @@ def simplify_search_result(context: Context, index: int) -> dict:
|
||||
"freedate_diff": torrent_info.freedate_diff,
|
||||
"pubdate": torrent_info.pubdate,
|
||||
}
|
||||
if include_description:
|
||||
simplified["torrent_info"]["description"] = torrent_info.description
|
||||
|
||||
if media_info:
|
||||
simplified["media_info"] = {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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(
|
||||
...,
|
||||
|
||||
@@ -15,8 +15,6 @@ from app.schemas.types import MediaType, MessageChannel
|
||||
class AddSubscribeInput(BaseModel):
|
||||
"""添加订阅工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
title: str = Field(
|
||||
...,
|
||||
description="The title of the media to subscribe to (e.g., 'The Matrix', 'Breaking Bad')",
|
||||
@@ -41,6 +39,10 @@ class AddSubscribeInput(BaseModel):
|
||||
None,
|
||||
description="Douban ID for precise media identification (optional, alternative to tmdb_id)",
|
||||
)
|
||||
bangumi_id: Optional[int] = Field(None, description="Bangumi media ID")
|
||||
anilist_id: Optional[int] = Field(None, description="AniList media ID")
|
||||
media_source: Optional[str] = Field(None, description="Media metadata source")
|
||||
media_id: Optional[str] = Field(None, description="Native ID for media_source")
|
||||
start_episode: Optional[int] = Field(
|
||||
None,
|
||||
description="Starting episode number for TV shows (optional, defaults to 1 if not specified)",
|
||||
@@ -99,7 +101,7 @@ class AddSubscribeTool(MoviePilotTool):
|
||||
message += f" ({year})"
|
||||
if media_type:
|
||||
message += f" [{media_type}]"
|
||||
if season:
|
||||
if season is not None:
|
||||
message += f" 第{season}季"
|
||||
elif media_type == "tv":
|
||||
message += " 第1季(默认)"
|
||||
@@ -146,6 +148,10 @@ class AddSubscribeTool(MoviePilotTool):
|
||||
season: Optional[int] = None,
|
||||
tmdb_id: Optional[int] = None,
|
||||
douban_id: Optional[str] = None,
|
||||
bangumi_id: Optional[int] = None,
|
||||
anilist_id: Optional[int] = None,
|
||||
media_source: Optional[str] = None,
|
||||
media_id: Optional[str] = None,
|
||||
start_episode: Optional[int] = None,
|
||||
total_episode: Optional[int] = None,
|
||||
quality: Optional[str] = None,
|
||||
@@ -199,6 +205,10 @@ class AddSubscribeTool(MoviePilotTool):
|
||||
year=year,
|
||||
tmdbid=tmdb_id,
|
||||
doubanid=douban_id,
|
||||
bangumiid=bangumi_id,
|
||||
anilistid=anilist_id,
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
season=season,
|
||||
username=subscribe_username,
|
||||
**subscribe_kwargs,
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -12,8 +12,8 @@ from app.agent.tools.tags import ToolTag
|
||||
from app.helper.browser import BrowserSessionHelper
|
||||
from app.log import logger
|
||||
|
||||
# 页面内容最大长度
|
||||
MAX_CONTENT_LENGTH = 8000
|
||||
# 页面内容最大长度;保留在全局工具结果兜底上限以内。
|
||||
MAX_CONTENT_LENGTH = 12_000
|
||||
# 默认超时时间(秒)
|
||||
DEFAULT_TIMEOUT = 30
|
||||
# 截图最大宽度
|
||||
@@ -47,8 +47,6 @@ class BrowserAction(str, Enum):
|
||||
class BrowseWebpageInput(BaseModel):
|
||||
"""浏览器操作工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this browser action is being performed",)
|
||||
action: str = Field(
|
||||
...,
|
||||
description=(
|
||||
|
||||
161
app/agent/tools/impl/create_agent_task.py
Normal file
161
app/agent/tools/impl/create_agent_task.py
Normal file
@@ -0,0 +1,161 @@
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Literal, Optional, Type
|
||||
|
||||
import pytz
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.core.config import settings
|
||||
from app.db.agentchat_oper import AgentChatOper
|
||||
from app.db.agenttask_oper import AgentTaskOper
|
||||
from app.utils.timer import TimerUtils
|
||||
|
||||
|
||||
class CreateAgentTaskInput(BaseModel):
|
||||
"""创建 Agent 自主定时任务的输入参数。"""
|
||||
|
||||
name: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=100,
|
||||
description="Short task name shown in task management and execution reports.",
|
||||
)
|
||||
content: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=10000,
|
||||
description="Complete instructions that the agent must execute when the task fires.",
|
||||
)
|
||||
trigger_type: Literal["date", "cron"] = Field(
|
||||
...,
|
||||
description="Use 'date' for one exact future run or 'cron' for recurring work.",
|
||||
)
|
||||
trigger: Optional[str] = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=200,
|
||||
description=(
|
||||
"For date, an ISO 8601 local or timezone-aware time such as "
|
||||
"2026-07-19 20:30:00; for cron, a standard five-field expression "
|
||||
"(minute hour day month weekday). The MoviePilot system timezone is used."
|
||||
),
|
||||
)
|
||||
delay_minutes: Optional[int] = Field(
|
||||
None,
|
||||
ge=1,
|
||||
le=525600,
|
||||
description=(
|
||||
"For a one-time date task expressed as 'in N minutes', provide this instead "
|
||||
"of trigger. MoviePilot calculates and persists the exact future run time."
|
||||
),
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_trigger(self) -> "CreateAgentTaskInput":
|
||||
"""校验任务触发配置并统一格式。"""
|
||||
self.name = self.name.strip()
|
||||
self.content = self.content.strip()
|
||||
if not self.name or not self.content:
|
||||
raise ValueError("name 和 content 不能只包含空白字符")
|
||||
if self.trigger_type == "date":
|
||||
if self.delay_minutes is not None:
|
||||
# LangChain 会在 run() 前后各校验一次,延迟时间在持久化前统一计算。
|
||||
self.trigger = None
|
||||
return self
|
||||
if self.trigger is None:
|
||||
raise ValueError("date 任务必须提供 trigger 或 delay_minutes")
|
||||
elif self.trigger is None or self.delay_minutes is not None:
|
||||
raise ValueError("cron 任务必须提供 trigger,且不能提供 delay_minutes")
|
||||
self.trigger_type, self.trigger = TimerUtils.normalize_schedule_trigger(
|
||||
trigger_type=self.trigger_type,
|
||||
trigger_value=self.trigger,
|
||||
timezone_name=settings.TZ,
|
||||
require_future=True,
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class CreateAgentTaskTool(MoviePilotTool):
|
||||
"""创建可精确唤醒当前 Agent 会话的自主定时任务。"""
|
||||
|
||||
name: str = "create_agent_task"
|
||||
tags: list[str] = [ToolTag.Write, ToolTag.AgentTask, ToolTag.Admin]
|
||||
description: str = (
|
||||
"Create a persistent autonomous agent task only when the user explicitly asks "
|
||||
"for delayed, scheduled, recurring, reminder, or monitoring work. Use trigger_type "
|
||||
"'date' with delay_minutes for requests such as 'check in 30 minutes', an exact "
|
||||
"trigger time for other one-time work, and 'cron' for recurring schedules. When "
|
||||
"fired, MoviePilot wakes the agent in this conversation, executes content, and "
|
||||
"broadcasts user-facing messages through the configured notification channels."
|
||||
)
|
||||
args_schema: Type[BaseModel] = CreateAgentTaskInput
|
||||
require_admin: bool = True
|
||||
|
||||
def get_tool_message(self, **kwargs: object) -> Optional[str]:
|
||||
"""生成创建定时任务的提示消息。"""
|
||||
return f"创建自主定时任务:{kwargs.get('name', '')}"
|
||||
|
||||
def _create_task(self, payload: CreateAgentTaskInput) -> dict:
|
||||
"""持久化任务并立即注册到运行时调度器。"""
|
||||
from app.scheduler import Scheduler
|
||||
|
||||
trigger_value = payload.trigger
|
||||
if payload.trigger_type == "date" and payload.delay_minutes is not None:
|
||||
timezone = pytz.timezone(settings.TZ)
|
||||
trigger_value = (
|
||||
datetime.now(timezone) + timedelta(minutes=payload.delay_minutes)
|
||||
).isoformat(timespec="seconds")
|
||||
_, trigger_value = TimerUtils.normalize_schedule_trigger(
|
||||
trigger_type=payload.trigger_type,
|
||||
trigger_value=trigger_value,
|
||||
timezone_name=settings.TZ,
|
||||
require_future=True,
|
||||
)
|
||||
chat = AgentChatOper().get(
|
||||
session_id=self._session_id,
|
||||
user_id=self._user_id,
|
||||
)
|
||||
task = AgentTaskOper().add(
|
||||
name=payload.name.strip(),
|
||||
content=payload.content.strip(),
|
||||
trigger_type=payload.trigger_type,
|
||||
cron_expression=trigger_value if payload.trigger_type == "cron" else None,
|
||||
run_at=trigger_value if payload.trigger_type == "date" else None,
|
||||
user_id=str(self._user_id),
|
||||
username=self._username or (chat.username if chat else None),
|
||||
session_id=str(self._session_id),
|
||||
channel=self._channel or (chat.channel if chat else None),
|
||||
source=self._source or (chat.source if chat else None),
|
||||
original_chat_id=chat.original_chat_id if chat else None,
|
||||
)
|
||||
scheduler = Scheduler()
|
||||
next_run_at = scheduler.update_agent_task_job(task.id)
|
||||
return AgentTaskOper.to_dict(
|
||||
task,
|
||||
next_run_at=next_run_at,
|
||||
timezone=settings.TZ,
|
||||
)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
name: str,
|
||||
content: str,
|
||||
trigger_type: str,
|
||||
trigger: Optional[str] = None,
|
||||
delay_minutes: Optional[int] = None,
|
||||
**kwargs: object,
|
||||
) -> str:
|
||||
"""创建 Agent 自主定时任务。"""
|
||||
if not settings.AI_AGENT_ENABLE:
|
||||
return "AI Agent 未启用,无法创建自主定时任务"
|
||||
payload = CreateAgentTaskInput(
|
||||
name=name,
|
||||
content=content,
|
||||
trigger_type=trigger_type,
|
||||
trigger=trigger,
|
||||
delay_minutes=delay_minutes,
|
||||
)
|
||||
task = await self.run_blocking("db", self._create_task, payload)
|
||||
return json.dumps(task, ensure_ascii=False, indent=2)
|
||||
50
app/agent/tools/impl/delete_agent_task.py
Normal file
50
app/agent/tools/impl/delete_agent_task.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from typing import Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.db.agenttask_oper import AgentTaskOper
|
||||
|
||||
|
||||
class DeleteAgentTaskInput(BaseModel):
|
||||
"""删除 Agent 自主定时任务的输入参数。"""
|
||||
|
||||
task_id: int = Field(..., ge=1, description="ID of the task to permanently delete.")
|
||||
|
||||
|
||||
class DeleteAgentTaskTool(MoviePilotTool):
|
||||
"""永久删除 Agent 自主定时任务。"""
|
||||
|
||||
name: str = "delete_agent_task"
|
||||
tags: list[str] = [ToolTag.Write, ToolTag.AgentTask, ToolTag.Admin]
|
||||
description: str = (
|
||||
"Permanently delete an autonomous agent task and remove its runtime schedule. "
|
||||
"Use update_agent_task with enabled=false when the user only wants to pause it."
|
||||
)
|
||||
args_schema: Type[BaseModel] = DeleteAgentTaskInput
|
||||
require_admin: bool = True
|
||||
|
||||
def get_tool_message(self, **kwargs: object) -> Optional[str]:
|
||||
"""生成删除定时任务的提示消息。"""
|
||||
return f"删除自主定时任务:{kwargs.get('task_id', '')}"
|
||||
|
||||
def _delete_task(self, task_id: int) -> bool:
|
||||
"""删除当前用户的任务并移除运行时调度。"""
|
||||
from app.scheduler import Scheduler
|
||||
|
||||
deleted = AgentTaskOper().delete(
|
||||
task_id=task_id,
|
||||
user_id=str(self._user_id),
|
||||
)
|
||||
if deleted:
|
||||
Scheduler().remove_agent_task_job(task_id)
|
||||
return deleted
|
||||
|
||||
async def run(self, task_id: int, **kwargs: object) -> str:
|
||||
"""删除 Agent 自主定时任务。"""
|
||||
payload = DeleteAgentTaskInput(task_id=task_id)
|
||||
deleted = await self.run_blocking("db", self._delete_task, payload.task_id)
|
||||
if not deleted:
|
||||
return f"Agent 定时任务 {task_id} 不存在或不属于当前用户"
|
||||
return f"Agent 定时任务 {task_id} 已删除"
|
||||
@@ -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.")
|
||||
|
||||
|
||||
|
||||
@@ -13,8 +13,6 @@ from app.log import logger
|
||||
class DeleteDownloadHistoryInput(BaseModel):
|
||||
"""删除下载历史记录工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
history_id: int = Field(
|
||||
..., description="The ID of the download history record to delete"
|
||||
)
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
|
||||
@@ -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.")
|
||||
|
||||
|
||||
|
||||
@@ -16,8 +16,6 @@ from app.schemas.types import EventType
|
||||
class DeleteSubscribeInput(BaseModel):
|
||||
"""删除订阅工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
subscribe_id: int = Field(
|
||||
...,
|
||||
description="The ID of the subscription to delete (can be obtained from query_subscribes tool)",
|
||||
@@ -56,7 +54,15 @@ class DeleteSubscribeTool(MoviePilotTool):
|
||||
await subscribe_oper.async_delete(subscribe_id)
|
||||
# 分享订阅统计刷新本身已异步化,这里只需要在删除后触发即可。
|
||||
MoviePilotServerHelper.sub_done_async(
|
||||
{"tmdbid": subscribe.tmdbid, "doubanid": subscribe.doubanid}
|
||||
{
|
||||
"tmdbid": subscribe.tmdbid,
|
||||
"doubanid": subscribe.doubanid,
|
||||
"bangumiid": subscribe.bangumiid,
|
||||
"anilistid": subscribe.anilistid,
|
||||
"media_source": subscribe.media_source,
|
||||
"media_id": subscribe.media_id,
|
||||
"season": subscribe.season,
|
||||
}
|
||||
)
|
||||
|
||||
# 发送事件
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""文件编辑工具"""
|
||||
"""文件精确编辑工具。"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional, Type
|
||||
@@ -7,6 +7,11 @@ from anyio import Path as AsyncPath
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.impl._file_write_utils import (
|
||||
FileVersionConflictError,
|
||||
atomic_write_text,
|
||||
calculate_file_sha256,
|
||||
)
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.log import logger
|
||||
|
||||
@@ -15,20 +20,46 @@ class EditFileInput(BaseModel):
|
||||
"""文件编辑工具的输入参数模型。"""
|
||||
|
||||
file_path: str = Field(..., description="The absolute path of the file to edit")
|
||||
old_text: str = Field(..., description="The exact old text to be replaced")
|
||||
old_text: str = Field(
|
||||
...,
|
||||
description=(
|
||||
"The exact old text to replace. It must be non-empty and uniquely "
|
||||
"identify one location unless replace_all is true."
|
||||
),
|
||||
)
|
||||
new_text: str = Field(..., description="The new text to replace with")
|
||||
replace_all: bool = Field(
|
||||
False,
|
||||
description=(
|
||||
"Replace every exact match. Keep false for normal code edits so an "
|
||||
"ambiguous match fails instead of changing multiple locations."
|
||||
),
|
||||
)
|
||||
expected_sha256: Optional[str] = Field(
|
||||
None,
|
||||
pattern=r"^[0-9a-fA-F]{64}$",
|
||||
description=(
|
||||
"Optional SHA-256 returned by read_file(include_metadata=true). The "
|
||||
"edit fails if the file changed after it was read."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class EditFileTool(MoviePilotTool):
|
||||
"""使用精确文本匹配安全编辑本地文件。"""
|
||||
|
||||
name: str = "edit_file"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.File,
|
||||
]
|
||||
description: str = (
|
||||
"Edit a local text file by replacing specific old text with new text. "
|
||||
"Edit an existing local text file using an exact text match. By default "
|
||||
"the match must occur exactly once; use replace_all only for intentional "
|
||||
"bulk replacement. old_text cannot be empty, and new files must be "
|
||||
"created with write_file. Supports an optional SHA-256 conflict check. "
|
||||
"Non-admin users can only edit files inside the MoviePilot Agent config "
|
||||
"and log directories."
|
||||
"directory."
|
||||
)
|
||||
args_schema: Type[BaseModel] = EditFileInput
|
||||
|
||||
@@ -38,7 +69,16 @@ class EditFileTool(MoviePilotTool):
|
||||
file_name = Path(file_path).name if file_path else "未知文件"
|
||||
return f"编辑文件: {file_name}"
|
||||
|
||||
async def run(self, file_path: str, old_text: str, new_text: str, **kwargs) -> str:
|
||||
async def run(
|
||||
self,
|
||||
file_path: str,
|
||||
old_text: str,
|
||||
new_text: str,
|
||||
replace_all: bool = False,
|
||||
expected_sha256: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""校验精确匹配和可选文件版本后,以原子方式写入编辑结果。"""
|
||||
logger.info(f"执行工具: {self.name}, 参数: file_path={file_path}")
|
||||
|
||||
try:
|
||||
@@ -48,37 +88,74 @@ class EditFileTool(MoviePilotTool):
|
||||
if access_error:
|
||||
return access_error
|
||||
|
||||
path = AsyncPath(resolved_path)
|
||||
# 校验逻辑:如果要替换特定文本,文件必须存在且包含该文本
|
||||
if not await path.exists():
|
||||
# 如果 old_text 为空,可能用户想直接创建文件,但通常 edit_file 需要匹配旧内容
|
||||
if old_text:
|
||||
return f"错误:文件 {resolved_path} 不存在,无法进行内容替换。"
|
||||
if not old_text:
|
||||
return "错误:old_text 不能为空;创建或完整写入文件请使用 write_file。"
|
||||
|
||||
if await path.exists() and not await path.is_file():
|
||||
path = AsyncPath(resolved_path)
|
||||
if not await path.exists():
|
||||
return f"错误:文件 {resolved_path} 不存在;创建文件请使用 write_file。"
|
||||
|
||||
if not await path.is_file():
|
||||
return f"错误:{resolved_path} 不是一个文件"
|
||||
|
||||
if await path.exists():
|
||||
content = await path.read_text(encoding="utf-8", errors="replace")
|
||||
if old_text not in content:
|
||||
logger.warning(f"编辑文件 {resolved_path} 失败:未找到指定的旧文本块")
|
||||
return f"错误:在文件 {resolved_path} 中未找到指定的旧文本。请确保包含所有的空格、缩进 and 换行符。"
|
||||
occurrences = content.count(old_text)
|
||||
new_content = content.replace(old_text, new_text)
|
||||
else:
|
||||
# 文件不存在且 old_text 为空的情形(初始化新文件)
|
||||
new_content = new_text
|
||||
occurrences = 1
|
||||
local_path = Path(resolved_path)
|
||||
current_sha256 = await self.run_blocking(
|
||||
"default", calculate_file_sha256, local_path
|
||||
)
|
||||
if (
|
||||
expected_sha256
|
||||
and current_sha256.casefold() != expected_sha256.casefold()
|
||||
):
|
||||
return (
|
||||
f"错误:文件 {resolved_path} 已在读取后发生变化,拒绝覆盖。"
|
||||
"请重新读取文件并基于最新内容编辑。"
|
||||
)
|
||||
|
||||
# 自动创建父目录
|
||||
await path.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = await path.read_text(encoding="utf-8", errors="strict")
|
||||
occurrences = content.count(old_text)
|
||||
if occurrences == 0:
|
||||
logger.warning(f"编辑文件 {resolved_path} 失败:未找到指定的旧文本块")
|
||||
return (
|
||||
f"错误:在文件 {resolved_path} 中未找到指定的旧文本。"
|
||||
"请重新读取文件并确认空格、缩进和换行。"
|
||||
)
|
||||
if occurrences > 1 and not replace_all:
|
||||
return (
|
||||
f"错误:old_text 在文件 {resolved_path} 中匹配到 {occurrences} 处,"
|
||||
"为避免误改已拒绝编辑。请提供更多上下文使其唯一,或明确设置 "
|
||||
"replace_all=true。"
|
||||
)
|
||||
|
||||
# 写入文件
|
||||
await path.write_text(new_content, encoding="utf-8")
|
||||
replacement_count = occurrences if replace_all else 1
|
||||
new_content = content.replace(
|
||||
old_text,
|
||||
new_text,
|
||||
-1 if replace_all else 1,
|
||||
)
|
||||
await self.run_blocking(
|
||||
"default",
|
||||
atomic_write_text,
|
||||
local_path,
|
||||
new_content,
|
||||
current_sha256,
|
||||
)
|
||||
new_sha256 = await self.run_blocking(
|
||||
"default", calculate_file_sha256, local_path
|
||||
)
|
||||
|
||||
logger.info(f"成功编辑文件 {resolved_path},替换了 {occurrences} 处内容")
|
||||
return f"成功编辑文件 {resolved_path} (替换了 {occurrences} 处匹配内容)"
|
||||
logger.info(
|
||||
f"成功编辑文件 {resolved_path},替换了 {replacement_count} 处内容"
|
||||
)
|
||||
return (
|
||||
f"成功编辑文件 {resolved_path}(替换了 {replacement_count} 处匹配内容,"
|
||||
f"sha256={new_sha256})"
|
||||
)
|
||||
|
||||
except FileVersionConflictError:
|
||||
return (
|
||||
f"错误:文件 {file_path} 在编辑期间发生变化,拒绝覆盖。"
|
||||
"请重新读取文件并再次编辑。"
|
||||
)
|
||||
except PermissionError:
|
||||
return f"错误:没有访问/修改 {file_path} 的权限"
|
||||
except UnicodeDecodeError:
|
||||
|
||||
@@ -7,6 +7,7 @@ import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from tempfile import NamedTemporaryFile
|
||||
from typing import Any, Literal, Optional, TextIO, Type
|
||||
@@ -27,7 +28,9 @@ from app.log import logger
|
||||
|
||||
DEFAULT_TIMEOUT_SECONDS = 60
|
||||
MAX_TIMEOUT_SECONDS = 300
|
||||
MAX_OUTPUT_PREVIEW_BYTES = 10 * 1024
|
||||
MAX_OUTPUT_PREVIEW_BYTES = 32 * 1024
|
||||
MAX_OUTPUT_HEAD_BYTES = 16 * 1024
|
||||
MAX_OUTPUT_TAIL_BYTES = 16 * 1024
|
||||
READ_CHUNK_SIZE = 4096
|
||||
KILL_GRACE_SECONDS = 3
|
||||
COMMAND_CONCURRENCY_LIMIT = 2
|
||||
@@ -36,11 +39,13 @@ _command_semaphore = asyncio.Semaphore(COMMAND_CONCURRENCY_LIMIT)
|
||||
|
||||
@dataclass
|
||||
class _CommandOutput:
|
||||
"""保存前 10KB 预览,并在超限时将完整输出写入临时文件。"""
|
||||
"""保存命令头尾预览,并在超限时将完整输出写入临时文件。"""
|
||||
|
||||
preview_limit_bytes: int
|
||||
preview_entries: list[tuple[str, str]] = field(default_factory=list)
|
||||
tail_entries: deque[tuple[str, str]] = field(default_factory=deque)
|
||||
captured_bytes: int = 0
|
||||
tail_bytes: int = 0
|
||||
preview_truncated: bool = False
|
||||
temp_file_path: Optional[str] = None
|
||||
temp_file_handle: Optional[TextIO] = None
|
||||
@@ -93,10 +98,12 @@ class _CommandOutput:
|
||||
self.temp_file_handle = None
|
||||
|
||||
def append(self, stream_name: str, text: str) -> None:
|
||||
"""追加一段输出,超出预览上限后只保留完整日志文件。"""
|
||||
"""追加一段输出,超出预览上限后保留头尾预览和完整日志文件。"""
|
||||
if not text:
|
||||
return
|
||||
|
||||
self._append_tail(stream_name, text)
|
||||
|
||||
if self.temp_file_handle:
|
||||
self._write_chunk(stream_name, text)
|
||||
return
|
||||
@@ -117,6 +124,60 @@ class _CommandOutput:
|
||||
self.preview_entries.append((stream_name, preview))
|
||||
self.captured_bytes += len(preview.encode("utf-8"))
|
||||
|
||||
def _append_tail(self, stream_name: str, text: str) -> None:
|
||||
"""维护固定字节大小的尾部输出,方便定位测试和构建失败信息。"""
|
||||
self.tail_entries.append((stream_name, text))
|
||||
self.tail_bytes += len(text.encode("utf-8"))
|
||||
while self.tail_bytes > MAX_OUTPUT_TAIL_BYTES and self.tail_entries:
|
||||
old_stream, old_text = self.tail_entries.popleft()
|
||||
old_bytes = len(old_text.encode("utf-8"))
|
||||
overflow = self.tail_bytes - MAX_OUTPUT_TAIL_BYTES
|
||||
if old_bytes <= overflow:
|
||||
self.tail_bytes -= old_bytes
|
||||
continue
|
||||
kept_text = old_text.encode("utf-8")[overflow:].decode(
|
||||
"utf-8", errors="ignore"
|
||||
)
|
||||
kept_bytes = len(kept_text.encode("utf-8"))
|
||||
self.tail_bytes -= old_bytes
|
||||
if kept_text:
|
||||
self.tail_entries.appendleft((old_stream, kept_text))
|
||||
self.tail_bytes += kept_bytes
|
||||
|
||||
@staticmethod
|
||||
def _format_entries(entries: list[tuple[str, str]]) -> str:
|
||||
"""按 stdout/stderr 切换插入可读的输出分段标题。"""
|
||||
parts: list[str] = []
|
||||
last_stream: Optional[str] = None
|
||||
for stream_name, text in entries:
|
||||
if stream_name != last_stream:
|
||||
title = "标准输出" if stream_name == "stdout" else "错误输出"
|
||||
parts.append(f"\n[{title}]\n")
|
||||
last_stream = stream_name
|
||||
parts.append(text)
|
||||
return "".join(parts).strip()
|
||||
|
||||
@property
|
||||
def combined_preview(self) -> str:
|
||||
"""返回完整输出或头尾组合预览。"""
|
||||
if not self.preview_truncated:
|
||||
return self._format_entries(self.preview_entries)
|
||||
|
||||
head_entries: list[tuple[str, str]] = []
|
||||
remaining = MAX_OUTPUT_HEAD_BYTES
|
||||
for stream_name, text in self.preview_entries:
|
||||
if remaining <= 0:
|
||||
break
|
||||
clipped = self._clip_text_to_bytes(text, remaining)
|
||||
if clipped:
|
||||
head_entries.append((stream_name, clipped))
|
||||
remaining -= len(clipped.encode("utf-8"))
|
||||
head = self._format_entries(head_entries)
|
||||
tail = self._format_entries(list(self.tail_entries))
|
||||
return (
|
||||
f"{head}\n\n...(中间输出已省略,完整内容在临时文件中)...\n\n{tail}"
|
||||
).strip()
|
||||
|
||||
@property
|
||||
def stdout(self) -> str:
|
||||
"""返回当前保留的 stdout 预览。"""
|
||||
@@ -135,7 +196,6 @@ class _CommandOutput:
|
||||
class ExecuteCommandInput(BaseModel):
|
||||
"""执行 Shell 命令工具的输入参数模型。"""
|
||||
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this command action is needed")
|
||||
action: Optional[Literal["start", "read", "wait", "write", "kill", "run"]] = Field(
|
||||
"start",
|
||||
description=(
|
||||
@@ -296,7 +356,7 @@ class ExecuteCommandTool(MoviePilotTool):
|
||||
stream_name: str,
|
||||
output: _CommandOutput,
|
||||
) -> None:
|
||||
"""按块读取一次性命令输出,只把前 10KB 保留在返回结果中。"""
|
||||
"""按块读取一次性命令输出,保留 32KB 头尾预览。"""
|
||||
while True:
|
||||
chunk = await stream.read(READ_CHUNK_SIZE)
|
||||
if not chunk:
|
||||
@@ -380,17 +440,16 @@ class ExecuteCommandTool(MoviePilotTool):
|
||||
file_note = "截至命令终止前的完整输出" if timed_out else "完整输出"
|
||||
result += (
|
||||
"\n\n提示:\n"
|
||||
f"命令输出超过 10KB,仅返回前 {MAX_OUTPUT_PREVIEW_BYTES} 字节内容。\n"
|
||||
f"命令输出超过 {MAX_OUTPUT_PREVIEW_BYTES // 1024}KB,"
|
||||
f"仅返回前后各 {MAX_OUTPUT_HEAD_BYTES // 1024}KB 预览。\n"
|
||||
f"{file_note}已写入临时文件: {output.temp_file_path}\n"
|
||||
"如需完整内容,请继续读取该文件。"
|
||||
)
|
||||
if output.stdout:
|
||||
result += f"\n\n标准输出:\n{output.stdout}"
|
||||
if output.stderr:
|
||||
result += f"\n\n错误输出:\n{output.stderr}"
|
||||
if output.combined_preview:
|
||||
result += f"\n\n命令输出预览:\n{output.combined_preview}"
|
||||
if output.preview_truncated:
|
||||
result += "\n\n...(仅展示前 10KB 内容)"
|
||||
if not output.stdout and not output.stderr:
|
||||
result += "\n\n...(仅展示前后各 16KB 内容)"
|
||||
if not output.combined_preview:
|
||||
result += "\n\n(无输出内容)"
|
||||
return result
|
||||
|
||||
|
||||
@@ -15,8 +15,6 @@ from app.schemas.types import MediaType, media_type_to_agent
|
||||
class GetRecommendationsInput(BaseModel):
|
||||
"""获取推荐工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
source: Optional[str] = Field(
|
||||
"tmdb_trending",
|
||||
description="Recommendation source: "
|
||||
@@ -212,6 +210,10 @@ class GetRecommendationsTool(MoviePilotTool):
|
||||
"tmdb_id": r.get("tmdb_id"),
|
||||
"imdb_id": r.get("imdb_id"),
|
||||
"douban_id": r.get("douban_id"),
|
||||
"bangumi_id": r.get("bangumi_id"),
|
||||
"anilist_id": r.get("anilist_id"),
|
||||
"media_source": r.get("source"),
|
||||
"media_id": r.get("media_id"),
|
||||
"vote_average": r.get("vote_average"),
|
||||
"poster_path": r.get("poster_path"),
|
||||
"detail_link": r.get("detail_link"),
|
||||
|
||||
@@ -21,8 +21,6 @@ from ._torrent_search_utils import (
|
||||
class GetSearchResultsInput(BaseModel):
|
||||
"""获取搜索结果工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
site: Optional[List[str]] = Field(None, description="Site name filters")
|
||||
season: Optional[List[str]] = Field(None, description="Season or episode filters")
|
||||
free_state: Optional[List[str]] = Field(None, description="Promotion state filters")
|
||||
@@ -36,6 +34,14 @@ class GetSearchResultsInput(BaseModel):
|
||||
None,
|
||||
description="Regular expression pattern to filter torrent titles (e.g., '4K|2160p|UHD', '1080p.*BluRay')",
|
||||
)
|
||||
content_pattern: Optional[str] = Field(
|
||||
None,
|
||||
description="Regular expression pattern to filter torrent titles, descriptions, and labels (e.g., '特效字幕|国语|DIY')",
|
||||
)
|
||||
include_description: Optional[bool] = Field(
|
||||
False,
|
||||
description="Whether to include torrent descriptions in returned results",
|
||||
)
|
||||
show_filter_options: Optional[bool] = Field(
|
||||
False,
|
||||
description="Whether to return only optional filter options for re-checking available conditions",
|
||||
@@ -47,6 +53,8 @@ class GetSearchResultsInput(BaseModel):
|
||||
|
||||
|
||||
class GetSearchResultsTool(MoviePilotTool):
|
||||
"""获取并筛选最近一次种子搜索结果"""
|
||||
|
||||
name: str = "get_search_results"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
@@ -56,6 +64,7 @@ class GetSearchResultsTool(MoviePilotTool):
|
||||
args_schema: Type[BaseModel] = GetSearchResultsInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""返回工具执行提示"""
|
||||
return "获取搜索结果"
|
||||
|
||||
async def run(
|
||||
@@ -68,13 +77,33 @@ class GetSearchResultsTool(MoviePilotTool):
|
||||
resolution: Optional[List[str]] = None,
|
||||
release_group: Optional[List[str]] = None,
|
||||
title_pattern: Optional[str] = None,
|
||||
content_pattern: Optional[str] = None,
|
||||
include_description: bool = False,
|
||||
show_filter_options: bool = False,
|
||||
page: Optional[int] = 1,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""
|
||||
获取并筛选最近一次种子搜索结果
|
||||
|
||||
:param site: 站点名称筛选项
|
||||
:param season: 季集筛选项
|
||||
:param free_state: 促销状态筛选项
|
||||
:param video_code: 视频编码筛选项
|
||||
:param edition: 制作版本筛选项
|
||||
:param resolution: 分辨率筛选项
|
||||
:param release_group: 发布组筛选项
|
||||
:param title_pattern: 仅匹配种子标题的正则表达式
|
||||
:param content_pattern: 匹配种子标题、简介和标签的正则表达式
|
||||
:param include_description: 是否在结果中返回种子简介
|
||||
:param show_filter_options: 是否只返回可用筛选项
|
||||
:param page: 分页页码
|
||||
:param kwargs: 工具框架附加参数
|
||||
:return: JSON 格式的搜索结果或错误提示
|
||||
"""
|
||||
page = max(1, page or 1)
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, 参数: site={site}, season={season}, free_state={free_state}, video_code={video_code}, edition={edition}, resolution={resolution}, release_group={release_group}, title_pattern={title_pattern}, show_filter_options={show_filter_options}, page={page}"
|
||||
f"执行工具: {self.name}, 参数: site={site}, season={season}, free_state={free_state}, video_code={video_code}, edition={edition}, resolution={resolution}, release_group={release_group}, title_pattern={title_pattern}, content_pattern={content_pattern}, include_description={include_description}, show_filter_options={show_filter_options}, page={page}"
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -89,14 +118,22 @@ class GetSearchResultsTool(MoviePilotTool):
|
||||
}
|
||||
return json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
|
||||
regex_pattern = None
|
||||
title_regex_pattern = None
|
||||
if title_pattern:
|
||||
try:
|
||||
regex_pattern = re.compile(title_pattern, re.IGNORECASE)
|
||||
title_regex_pattern = re.compile(title_pattern, re.IGNORECASE)
|
||||
except re.error as e:
|
||||
logger.warning(f"正则表达式编译失败: {title_pattern}, 错误: {e}")
|
||||
return f"正则表达式格式错误: {str(e)}"
|
||||
|
||||
content_regex_pattern = None
|
||||
if content_pattern:
|
||||
try:
|
||||
content_regex_pattern = re.compile(content_pattern, re.IGNORECASE)
|
||||
except re.error as e:
|
||||
logger.warning(f"正则表达式编译失败: {content_pattern}, 错误: {e}")
|
||||
return f"正则表达式格式错误: {str(e)}"
|
||||
|
||||
filtered_items = filter_contexts(
|
||||
items=items,
|
||||
site=site,
|
||||
@@ -107,14 +144,29 @@ class GetSearchResultsTool(MoviePilotTool):
|
||||
resolution=resolution,
|
||||
release_group=release_group,
|
||||
)
|
||||
if regex_pattern:
|
||||
if title_regex_pattern:
|
||||
filtered_items = [
|
||||
item
|
||||
for item in filtered_items
|
||||
if item.torrent_info
|
||||
and item.torrent_info.title
|
||||
and regex_pattern.search(item.torrent_info.title)
|
||||
and title_regex_pattern.search(item.torrent_info.title)
|
||||
]
|
||||
if content_regex_pattern:
|
||||
content_filtered_items = []
|
||||
for item in filtered_items:
|
||||
torrent_info = item.torrent_info
|
||||
if not torrent_info:
|
||||
continue
|
||||
content_values = [torrent_info.title, torrent_info.description]
|
||||
content_values.extend(torrent_info.labels or [])
|
||||
if any(
|
||||
content_regex_pattern.search(str(value))
|
||||
for value in content_values
|
||||
if value
|
||||
):
|
||||
content_filtered_items.append(item)
|
||||
filtered_items = content_filtered_items
|
||||
if not filtered_items:
|
||||
return "没有符合筛选条件的搜索结果,请调整筛选条件"
|
||||
|
||||
@@ -137,7 +189,11 @@ class GetSearchResultsTool(MoviePilotTool):
|
||||
return f"第 {page} 页没有数据,共 {total_count} 条结果,共 {(total_count + page_size - 1) // page_size} 页。"
|
||||
|
||||
results = [
|
||||
simplify_search_result(item, index)
|
||||
simplify_search_result(
|
||||
item,
|
||||
index,
|
||||
include_description=include_description,
|
||||
)
|
||||
for item, index in zip(page_items, page_indices)
|
||||
]
|
||||
total_pages = (total_count + page_size - 1) // page_size
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -15,22 +15,47 @@ from app.schemas.file import FileItem
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
DEFAULT_DIRECTORY_PAGE_SIZE = 50
|
||||
MAX_DIRECTORY_PAGE_SIZE = 200
|
||||
|
||||
|
||||
class ListDirectoryInput(BaseModel):
|
||||
"""查询文件系统目录内容工具的输入参数模型"""
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
path: str = Field(..., description="Directory path to list contents (e.g., '/home/user/downloads' or 'C:/Downloads')")
|
||||
storage: Optional[str] = Field("local", description="Storage type (default: 'local' for local file system, can be 'smb', 'alist', etc.)")
|
||||
sort_by: Optional[str] = Field("name", description="Sort order: 'name' for alphabetical sorting, 'time' for modification time sorting (default: 'name')")
|
||||
limit: Optional[int] = Field(
|
||||
DEFAULT_DIRECTORY_PAGE_SIZE,
|
||||
ge=1,
|
||||
le=MAX_DIRECTORY_PAGE_SIZE,
|
||||
description=(
|
||||
f"Maximum items to return in this page (default: {DEFAULT_DIRECTORY_PAGE_SIZE}, "
|
||||
f"maximum: {MAX_DIRECTORY_PAGE_SIZE})"
|
||||
),
|
||||
)
|
||||
offset: Optional[int] = Field(
|
||||
0,
|
||||
ge=0,
|
||||
description="Number of sorted directory items to skip before this page",
|
||||
)
|
||||
|
||||
|
||||
class ListDirectoryTool(MoviePilotTool):
|
||||
"""分页查询本地或远程存储目录中的文件和子目录。"""
|
||||
|
||||
name: str = "list_directory"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Directory,
|
||||
ToolTag.File,
|
||||
]
|
||||
description: str = "List actual files and folders in a file system directory (NOT configuration). Shows files and subdirectories with their names, types, sizes, and modification times. Returns up to 20 items and the total count if there are more items. Use 'query_directory_settings' to query directory configuration settings."
|
||||
description: str = (
|
||||
"List actual files and folders in a file system directory (NOT configuration). "
|
||||
"Shows files and subdirectories with their names, types, sizes, and modification "
|
||||
f"times. Returns a page of up to {DEFAULT_DIRECTORY_PAGE_SIZE} items with total "
|
||||
f"count and next offset; limit is capped at {MAX_DIRECTORY_PAGE_SIZE}. "
|
||||
"Use 'query_directory_settings' to query directory configuration settings."
|
||||
)
|
||||
args_schema: Type[BaseModel] = ListDirectoryInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
@@ -46,10 +71,14 @@ class ListDirectoryTool(MoviePilotTool):
|
||||
|
||||
@staticmethod
|
||||
def _list_directory_sync(
|
||||
path: str, storage: Optional[str] = "local", sort_by: Optional[str] = "name"
|
||||
path: str,
|
||||
storage: Optional[str] = "local",
|
||||
sort_by: Optional[str] = "name",
|
||||
limit: Optional[int] = DEFAULT_DIRECTORY_PAGE_SIZE,
|
||||
offset: Optional[int] = 0,
|
||||
) -> str:
|
||||
"""
|
||||
目录遍历可能触发本地磁盘或远程存储请求,统一放到线程池中执行。
|
||||
目录遍历可能触发本地磁盘或远程存储请求,统一放到线程池中执行并分页返回。
|
||||
"""
|
||||
if not path:
|
||||
return "错误:路径不能为空"
|
||||
@@ -65,9 +94,6 @@ class ListDirectoryTool(MoviePilotTool):
|
||||
|
||||
if file_list is None:
|
||||
return f"无法访问目录:{path},请检查路径是否正确或存储是否可用"
|
||||
if not file_list:
|
||||
return f"目录 {path} 为空"
|
||||
|
||||
if sort_by == "time":
|
||||
file_list.sort(key=lambda x: x.modify_time or 0, reverse=True)
|
||||
else:
|
||||
@@ -79,7 +105,14 @@ class ListDirectoryTool(MoviePilotTool):
|
||||
)
|
||||
|
||||
total_count = len(file_list)
|
||||
limited_list = file_list[:20]
|
||||
normalized_limit = max(
|
||||
1,
|
||||
min(int(limit or DEFAULT_DIRECTORY_PAGE_SIZE), MAX_DIRECTORY_PAGE_SIZE),
|
||||
)
|
||||
normalized_offset = max(0, int(offset or 0))
|
||||
limited_list = file_list[
|
||||
normalized_offset : normalized_offset + normalized_limit
|
||||
]
|
||||
simplified_items = []
|
||||
for item in limited_list:
|
||||
size_str = StringUtils.str_filesize(item.size) if item.size else None
|
||||
@@ -103,16 +136,39 @@ class ListDirectoryTool(MoviePilotTool):
|
||||
simplified["extension"] = item.extension
|
||||
simplified_items.append(simplified)
|
||||
|
||||
result_json = json.dumps(simplified_items, ensure_ascii=False, indent=2)
|
||||
if total_count > 20:
|
||||
return (
|
||||
f"注意:目录中共有 {total_count} 个项目,为节省上下文空间,仅显示前 20 个项目。\n\n"
|
||||
f"{result_json}"
|
||||
)
|
||||
return result_json
|
||||
returned_count = len(simplified_items)
|
||||
has_more = normalized_offset + returned_count < total_count
|
||||
return json.dumps(
|
||||
{
|
||||
"items": simplified_items,
|
||||
"total_count": total_count,
|
||||
"returned_count": returned_count,
|
||||
"limit": normalized_limit,
|
||||
"offset": normalized_offset,
|
||||
"has_more": has_more,
|
||||
"next_offset": (
|
||||
normalized_offset + returned_count if has_more else None
|
||||
),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
async def run(self, path: str, storage: Optional[str] = "local",
|
||||
sort_by: Optional[str] = "name", **kwargs) -> str:
|
||||
sort_by: Optional[str] = "name",
|
||||
limit: Optional[int] = DEFAULT_DIRECTORY_PAGE_SIZE,
|
||||
offset: Optional[int] = 0,
|
||||
**kwargs) -> str:
|
||||
"""
|
||||
分页查询指定目录的文件和子目录。
|
||||
|
||||
:param path: 要查询的目录路径
|
||||
:param storage: 存储类型,默认为本地存储
|
||||
:param sort_by: 排序方式,支持名称或修改时间
|
||||
:param limit: 当前页最大条数,最高不超过工具上限
|
||||
:param offset: 当前页起始偏移量
|
||||
:return: 包含项目列表和分页元数据的 JSON 字符串
|
||||
"""
|
||||
logger.info(f"执行工具: {self.name}, 参数: path={path}, storage={storage}, sort_by={sort_by}")
|
||||
|
||||
try:
|
||||
@@ -124,7 +180,13 @@ class ListDirectoryTool(MoviePilotTool):
|
||||
if resolved_path:
|
||||
path = str(resolved_path)
|
||||
return await self.run_blocking(
|
||||
"storage", self._list_directory_sync, path, storage, sort_by
|
||||
"storage",
|
||||
self._list_directory_sync,
|
||||
path,
|
||||
storage,
|
||||
sort_by,
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"查询目录内容失败: {e}", exc_info=True)
|
||||
|
||||
@@ -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):
|
||||
|
||||
98
app/agent/tools/impl/mcp.py
Normal file
98
app/agent/tools/impl/mcp.py
Normal 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
|
||||
88
app/agent/tools/impl/query_agent_tasks.py
Normal file
88
app/agent/tools/impl/query_agent_tasks.py
Normal file
@@ -0,0 +1,88 @@
|
||||
import json
|
||||
from typing import Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.core.config import settings
|
||||
from app.db.agenttask_oper import AgentTaskOper
|
||||
|
||||
|
||||
class QueryAgentTasksInput(BaseModel):
|
||||
"""查询 Agent 自主定时任务的输入参数。"""
|
||||
|
||||
task_id: Optional[int] = Field(
|
||||
None,
|
||||
ge=1,
|
||||
description="Optional task ID. Omit it to list tasks owned by the current user.",
|
||||
)
|
||||
enabled: Optional[bool] = Field(
|
||||
None,
|
||||
description="Optional enabled-state filter used when listing tasks.",
|
||||
)
|
||||
|
||||
|
||||
class QueryAgentTasksTool(MoviePilotTool):
|
||||
"""查询当前用户创建的 Agent 自主定时任务。"""
|
||||
|
||||
name: str = "query_agent_tasks"
|
||||
tags: list[str] = [ToolTag.Read, ToolTag.AgentTask, ToolTag.Admin]
|
||||
description: str = (
|
||||
"Query persistent autonomous agent tasks owned by the current user, including "
|
||||
"reminders, monitoring tasks, and recurring agent work. Returns the integer "
|
||||
"task_id, instructions, trigger, enabled state, next run time, and latest result. "
|
||||
"Do not use this for MoviePilot system, plugin, or workflow scheduler services."
|
||||
)
|
||||
args_schema: Type[BaseModel] = QueryAgentTasksInput
|
||||
require_admin: bool = True
|
||||
|
||||
def get_tool_message(self, **kwargs: object) -> Optional[str]:
|
||||
"""生成查询定时任务的提示消息。"""
|
||||
task_id = kwargs.get("task_id")
|
||||
return f"查询自主定时任务:{task_id}" if task_id else "查询自主定时任务"
|
||||
|
||||
def _query_tasks(
|
||||
self,
|
||||
task_id: Optional[int],
|
||||
enabled: Optional[bool],
|
||||
) -> list[dict]:
|
||||
"""读取当前用户的任务及运行时下一次触发时间。"""
|
||||
from app.scheduler import Scheduler
|
||||
|
||||
oper = AgentTaskOper()
|
||||
if task_id:
|
||||
task = oper.get(task_id=task_id, user_id=str(self._user_id))
|
||||
tasks = [task] if task else []
|
||||
else:
|
||||
tasks = oper.list(user_id=str(self._user_id), enabled=enabled)
|
||||
scheduler = Scheduler()
|
||||
result = []
|
||||
for task in tasks:
|
||||
data = oper.to_dict(
|
||||
task,
|
||||
next_run_at=scheduler.get_agent_task_next_run(task.id),
|
||||
timezone=settings.TZ,
|
||||
)
|
||||
result.append(data)
|
||||
return result
|
||||
|
||||
async def run(
|
||||
self,
|
||||
task_id: Optional[int] = None,
|
||||
enabled: Optional[bool] = None,
|
||||
**kwargs: object,
|
||||
) -> str:
|
||||
"""查询 Agent 自主定时任务。"""
|
||||
payload = QueryAgentTasksInput(task_id=task_id, enabled=enabled)
|
||||
tasks = await self.run_blocking(
|
||||
"db",
|
||||
self._query_tasks,
|
||||
payload.task_id,
|
||||
payload.enabled,
|
||||
)
|
||||
return json.dumps(
|
||||
{"total": len(tasks), "tasks": tasks},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -14,10 +14,6 @@ from app.log import logger
|
||||
class QueryDoctorReportInput(BaseModel):
|
||||
"""查询 Doctor 诊断报告工具的输入参数模型。"""
|
||||
|
||||
explanation: Optional[str] = Field(
|
||||
None,
|
||||
description="Clear explanation of why this tool is being used in the current context",
|
||||
)
|
||||
deep: Optional[bool] = Field(
|
||||
False,
|
||||
description=(
|
||||
@@ -48,7 +44,9 @@ class QueryDoctorReportTool(MoviePilotTool):
|
||||
description: str = (
|
||||
"Run MoviePilot Doctor in read-only mode and return a structured diagnostic report for troubleshooting. "
|
||||
"Use this tool when analyzing startup failures, Docker/runtime issues, port conflicts, dependency problems, "
|
||||
"database health, frontend assets, safe mode, or recent log error clues. This tool never applies fixes."
|
||||
"database health, frontend assets, safe mode, or recent log error clues. Plugin-only log findings remain "
|
||||
"visible with affects_report_status=false and do not downgrade the overall status. This tool never applies "
|
||||
"fixes."
|
||||
)
|
||||
require_admin: bool = True
|
||||
args_schema: Type[BaseModel] = QueryDoctorReportInput
|
||||
@@ -77,6 +75,7 @@ class QueryDoctorReportTool(MoviePilotTool):
|
||||
"title": item.get("title"),
|
||||
"fixable": item.get("fixable"),
|
||||
"fixed": item.get("fixed"),
|
||||
"affects_report_status": item.get("affects_report_status", True),
|
||||
}
|
||||
for item in report.get("findings") or []
|
||||
if isinstance(item, dict)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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] = [
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -22,8 +22,6 @@ from app.log import logger
|
||||
class QueryInstalledPluginsInput(BaseModel):
|
||||
"""查询已安装插件工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
query: Optional[str] = Field(
|
||||
None,
|
||||
description="Optional keyword to filter installed plugins by plugin ID, name, description, or author.",
|
||||
|
||||
@@ -77,9 +77,12 @@ def _build_tv_server_result(existing_seasons: OrderedDict, total_seasons: Ordere
|
||||
|
||||
class QueryLibraryExistsInput(BaseModel):
|
||||
"""查询媒体库工具的输入参数模型"""
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
tmdb_id: Optional[int] = Field(None, description="TMDB ID (can be obtained from search_media tool). Either tmdb_id or douban_id must be provided.")
|
||||
douban_id: Optional[str] = Field(None, description="Douban ID (can be obtained from search_media tool). Either tmdb_id or douban_id must be provided.")
|
||||
tmdb_id: Optional[int] = Field(None, description="TMDB media ID")
|
||||
douban_id: Optional[str] = Field(None, description="Douban media ID")
|
||||
bangumi_id: Optional[int] = Field(None, description="Bangumi media ID")
|
||||
anilist_id: Optional[int] = Field(None, description="AniList media ID")
|
||||
media_source: Optional[str] = Field(None, description="Media metadata source")
|
||||
media_id: Optional[str] = Field(None, description="Native ID for media_source")
|
||||
media_type: Optional[str] = Field(None, description="Allowed values: movie, tv")
|
||||
|
||||
|
||||
@@ -90,21 +93,24 @@ class QueryLibraryExistsTool(MoviePilotTool):
|
||||
ToolTag.Library,
|
||||
ToolTag.Media,
|
||||
]
|
||||
description: str = "Check whether media already exists in Plex, Emby, or Jellyfin by media ID. Results are grouped by media server; TV results include existing episodes, total episodes, and missing episodes/seasons. Requires tmdb_id or douban_id from search_media."
|
||||
description: str = "Check whether media already exists in Plex, Emby, or Jellyfin by a TMDB, Douban, Bangumi, AniList, or source-native media ID. Results are grouped by media server; TV results include existing episodes, total episodes, and missing episodes/seasons."
|
||||
args_schema: Type[BaseModel] = QueryLibraryExistsInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""根据查询参数生成友好的提示消息"""
|
||||
tmdb_id = kwargs.get("tmdb_id")
|
||||
douban_id = kwargs.get("douban_id")
|
||||
media_type = kwargs.get("media_type")
|
||||
|
||||
if tmdb_id:
|
||||
message = f"查询媒体库: TMDB={tmdb_id}"
|
||||
elif douban_id:
|
||||
message = f"查询媒体库: 豆瓣={douban_id}"
|
||||
else:
|
||||
message = "查询媒体库"
|
||||
identities = (
|
||||
("TMDB", kwargs.get("tmdb_id")),
|
||||
("豆瓣", kwargs.get("douban_id")),
|
||||
("Bangumi", kwargs.get("bangumi_id")),
|
||||
("AniList", kwargs.get("anilist_id")),
|
||||
(kwargs.get("media_source") or "媒体源", kwargs.get("media_id")),
|
||||
)
|
||||
label, identity = next(
|
||||
((label, identity) for label, identity in identities if identity is not None),
|
||||
(None, None),
|
||||
)
|
||||
message = f"查询媒体库: {label}={identity}" if label else "查询媒体库"
|
||||
if media_type:
|
||||
message += f" [{media_type}]"
|
||||
return message
|
||||
@@ -120,11 +126,13 @@ class QueryLibraryExistsTool(MoviePilotTool):
|
||||
return MediaServerChain().media_exists(mediainfo=mediainfo, server=server)
|
||||
|
||||
async def run(self, tmdb_id: Optional[int] = None, douban_id: Optional[str] = None,
|
||||
bangumi_id: Optional[int] = None, anilist_id: Optional[int] = None,
|
||||
media_source: Optional[str] = None, media_id: Optional[str] = None,
|
||||
media_type: Optional[str] = None, **kwargs) -> str:
|
||||
logger.info(f"执行工具: {self.name}, 参数: tmdb_id={tmdb_id}, douban_id={douban_id}, media_type={media_type}")
|
||||
try:
|
||||
if not tmdb_id and not douban_id:
|
||||
return "参数错误:tmdb_id 和 douban_id 至少需要提供一个,请先使用 search_media 工具获取媒体 ID。"
|
||||
if not any((tmdb_id, douban_id, bangumi_id, anilist_id, media_id)):
|
||||
return "参数错误:至少需要提供一个媒体 ID,请先使用 search_media 工具获取媒体信息。"
|
||||
|
||||
media_type_enum = None
|
||||
if media_type:
|
||||
@@ -136,11 +144,15 @@ class QueryLibraryExistsTool(MoviePilotTool):
|
||||
mediainfo = await media_chain.async_recognize_media(
|
||||
tmdbid=tmdb_id,
|
||||
doubanid=douban_id,
|
||||
bangumiid=bangumi_id,
|
||||
anilistid=anilist_id,
|
||||
source=media_source,
|
||||
mediaid=media_id,
|
||||
mtype=media_type_enum,
|
||||
)
|
||||
if not mediainfo:
|
||||
media_id = f"TMDB={tmdb_id}" if tmdb_id else f"豆瓣={douban_id}"
|
||||
return f"未识别到媒体信息: {media_id}"
|
||||
identity = media_id or tmdb_id or douban_id or bangumi_id or anilist_id
|
||||
return f"未识别到媒体信息: {identity}"
|
||||
|
||||
# 2. 遍历所有媒体服务器,分别查询存在性信息
|
||||
server_results = OrderedDict()
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -18,9 +18,12 @@ SEASON_PREVIEW_LIMIT = 100
|
||||
|
||||
class QueryMediaDetailInput(BaseModel):
|
||||
"""查询媒体详情工具的输入参数模型"""
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
tmdb_id: Optional[int] = Field(None, description="TMDB ID of the media (movie or TV series, can be obtained from search_media tool)")
|
||||
douban_id: Optional[str] = Field(None, description="Douban ID of the media (alternative to tmdb_id)")
|
||||
bangumi_id: Optional[int] = Field(None, description="Bangumi media ID")
|
||||
anilist_id: Optional[int] = Field(None, description="AniList media ID")
|
||||
media_source: Optional[str] = Field(None, description="Media metadata source")
|
||||
media_id: Optional[str] = Field(None, description="Native ID for media_source")
|
||||
media_type: str = Field(..., description="Allowed values: movie, tv")
|
||||
|
||||
|
||||
@@ -30,24 +33,37 @@ class QueryMediaDetailTool(MoviePilotTool):
|
||||
ToolTag.Read,
|
||||
ToolTag.Media,
|
||||
]
|
||||
description: str = "Query supplementary media details from TMDB by ID and media_type. Accepts tmdb_id or douban_id (at least one required). media_type accepts 'movie' or 'tv'. Returns non-duplicated detail fields such as status, genres, directors, actors, and season info for TV series."
|
||||
description: str = "Query supplementary media details from a metadata source by ID and media_type. Accepts a TMDB, Douban, Bangumi, AniList, or source-native media ID. media_type accepts 'movie' or 'tv'. Returns non-duplicated detail fields such as status, genres, directors, actors, and season info for TV series."
|
||||
args_schema: Type[BaseModel] = QueryMediaDetailInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""根据查询参数生成友好的提示消息"""
|
||||
tmdb_id = kwargs.get("tmdb_id")
|
||||
douban_id = kwargs.get("douban_id")
|
||||
if tmdb_id:
|
||||
return f"查询媒体详情: TMDB ID {tmdb_id}"
|
||||
return f"查询媒体详情: 豆瓣 ID {douban_id}"
|
||||
identities = (
|
||||
("TMDB", kwargs.get("tmdb_id")),
|
||||
("豆瓣", kwargs.get("douban_id")),
|
||||
("Bangumi", kwargs.get("bangumi_id")),
|
||||
("AniList", kwargs.get("anilist_id")),
|
||||
)
|
||||
for label, identity in identities:
|
||||
if identity is not None:
|
||||
return f"查询媒体详情: {label} ID {identity}"
|
||||
return (
|
||||
f"查询媒体详情: {kwargs.get('media_source') or '媒体源'} "
|
||||
f"ID {kwargs.get('media_id')}"
|
||||
)
|
||||
|
||||
async def run(self, media_type: str, tmdb_id: Optional[int] = None, douban_id: Optional[str] = None, **kwargs) -> str:
|
||||
async def run(
|
||||
self, media_type: str, tmdb_id: Optional[int] = None,
|
||||
douban_id: Optional[str] = None, bangumi_id: Optional[int] = None,
|
||||
anilist_id: Optional[int] = None, media_source: Optional[str] = None,
|
||||
media_id: Optional[str] = None, **kwargs,
|
||||
) -> str:
|
||||
logger.info(f"执行工具: {self.name}, 参数: tmdb_id={tmdb_id}, douban_id={douban_id}, media_type={media_type}")
|
||||
|
||||
if tmdb_id is None and douban_id is None:
|
||||
if not any((tmdb_id, douban_id, bangumi_id, anilist_id, media_id)):
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": "必须提供 tmdb_id 或 douban_id 之一"
|
||||
"message": "必须提供至少一个媒体 ID"
|
||||
}, ensure_ascii=False)
|
||||
|
||||
try:
|
||||
@@ -60,10 +76,22 @@ class QueryMediaDetailTool(MoviePilotTool):
|
||||
"message": f"无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'"
|
||||
}, ensure_ascii=False)
|
||||
|
||||
mediainfo = await media_chain.async_recognize_media(tmdbid=tmdb_id, doubanid=douban_id, mtype=media_type_enum)
|
||||
mediainfo = await media_chain.async_recognize_media(
|
||||
tmdbid=tmdb_id,
|
||||
doubanid=douban_id,
|
||||
bangumiid=bangumi_id,
|
||||
anilistid=anilist_id,
|
||||
source=media_source,
|
||||
mediaid=media_id,
|
||||
mtype=media_type_enum,
|
||||
)
|
||||
|
||||
if not mediainfo:
|
||||
id_info = f"TMDB ID {tmdb_id}" if tmdb_id else f"豆瓣 ID {douban_id}"
|
||||
id_info = (
|
||||
f"{media_source or '媒体源'} ID {media_id}"
|
||||
if media_id else
|
||||
f"媒体 ID {tmdb_id or douban_id or bangumi_id or anilist_id}"
|
||||
)
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"未找到 {id_info} 的媒体信息"
|
||||
@@ -140,5 +168,9 @@ class QueryMediaDetailTool(MoviePilotTool):
|
||||
"success": False,
|
||||
"message": error_message,
|
||||
"tmdb_id": tmdb_id,
|
||||
"douban_id": douban_id
|
||||
"douban_id": douban_id,
|
||||
"bangumi_id": bangumi_id,
|
||||
"anilist_id": anilist_id,
|
||||
"media_source": media_source,
|
||||
"media_id": media_id,
|
||||
}, ensure_ascii=False)
|
||||
|
||||
@@ -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=(
|
||||
|
||||
@@ -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. "
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -18,7 +18,6 @@ MAX_PAGE_SIZE = 50
|
||||
|
||||
class QueryPopularSubscribesInput(BaseModel):
|
||||
"""查询热门订阅工具的输入参数模型"""
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
media_type: str = Field(..., description="Allowed values: movie, tv")
|
||||
page: Optional[int] = Field(1, description="Page number for pagination (default: 1)")
|
||||
count: Optional[int] = Field(30, description="Number of items per page (default: 30, max: 50)")
|
||||
@@ -119,7 +118,7 @@ class QueryPopularSubscribesTool(MoviePilotTool):
|
||||
# 处理标题
|
||||
title = sub.get("name")
|
||||
season = sub.get("season")
|
||||
if season and int(season) > 1 and media.tmdb_id:
|
||||
if season not in (None, "") and int(season) != 1 and media.tmdb_id:
|
||||
# 小写数据转大写
|
||||
season_str = cn2an.an2cn(season, "low")
|
||||
title = f"{title} 第{season_str}季"
|
||||
@@ -127,6 +126,8 @@ class QueryPopularSubscribesTool(MoviePilotTool):
|
||||
media.year = sub.get("year")
|
||||
media.douban_id = sub.get("doubanid")
|
||||
media.bangumi_id = sub.get("bangumiid")
|
||||
media.anilist_id = sub.get("anilistid")
|
||||
media.source = sub.get("media_source")
|
||||
media.tvdb_id = sub.get("tvdbid")
|
||||
media.imdb_id = sub.get("imdbid")
|
||||
media.season = sub.get("season")
|
||||
@@ -150,6 +151,9 @@ class QueryPopularSubscribesTool(MoviePilotTool):
|
||||
"tmdb_id": media_dict.get("tmdb_id"),
|
||||
"douban_id": media_dict.get("douban_id"),
|
||||
"bangumi_id": media_dict.get("bangumi_id"),
|
||||
"anilist_id": media_dict.get("anilist_id"),
|
||||
"media_source": media_dict.get("source"),
|
||||
"media_id": media_dict.get("media_id"),
|
||||
"tvdb_id": media_dict.get("tvdb_id"),
|
||||
"imdb_id": media_dict.get("imdb_id"),
|
||||
"season": media_dict.get("season"),
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import json
|
||||
from typing import Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
@@ -11,50 +11,69 @@ from app.log import logger
|
||||
|
||||
|
||||
class QuerySchedulersInput(BaseModel):
|
||||
"""查询定时服务工具的输入参数模型"""
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
"""查询运行时定时服务的输入参数模型。"""
|
||||
|
||||
|
||||
class QuerySchedulersTool(MoviePilotTool):
|
||||
"""查询系统、插件和工作流注册的运行时定时服务。"""
|
||||
|
||||
name: str = "query_schedulers"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Scheduler,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = "Query scheduled tasks and list all available scheduler jobs. Shows job status, next run time, and provider information."
|
||||
description: str = (
|
||||
"Query runtime scheduler services registered by MoviePilot system components, "
|
||||
"plugins, and workflows. It excludes user-created autonomous agent tasks; use "
|
||||
"query_agent_tasks for reminders, monitoring tasks, and other agent schedules."
|
||||
)
|
||||
args_schema: Type[BaseModel] = QuerySchedulersInput
|
||||
require_admin: bool = True
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""生成友好的提示消息"""
|
||||
return "查询定时服务"
|
||||
def get_tool_message(self, **kwargs: object) -> Optional[str]:
|
||||
"""生成查询运行时定时服务的提示消息。"""
|
||||
return "查询系统定时服务"
|
||||
|
||||
async def run(self, **kwargs) -> str:
|
||||
async def run(self, **kwargs: object) -> str:
|
||||
"""查询非 Agent 自主任务的运行时定时服务。"""
|
||||
logger.info(f"执行工具: {self.name}")
|
||||
try:
|
||||
from app.scheduler import Scheduler
|
||||
from app.scheduler import AGENT_TASK_JOB_PREFIX, Scheduler
|
||||
|
||||
scheduler = Scheduler()
|
||||
schedulers = scheduler.list()
|
||||
agent_task_prefix = f"{AGENT_TASK_JOB_PREFIX}-"
|
||||
schedulers = [
|
||||
scheduler_item
|
||||
for scheduler_item in scheduler.list()
|
||||
if not str(scheduler_item.id or "").startswith(agent_task_prefix)
|
||||
]
|
||||
if schedulers:
|
||||
# 转换为字典列表以便JSON序列化
|
||||
schedulers_list = []
|
||||
for s in schedulers:
|
||||
schedulers_list.append({
|
||||
"id": s.id,
|
||||
"name": s.name,
|
||||
"provider": s.provider,
|
||||
"status": s.status,
|
||||
"next_run": s.next_run
|
||||
})
|
||||
schedulers_list = [
|
||||
{
|
||||
"id": scheduler_item.id,
|
||||
"name": scheduler_item.name,
|
||||
"provider": scheduler_item.provider,
|
||||
"status": scheduler_item.status,
|
||||
"next_run": scheduler_item.next_run,
|
||||
}
|
||||
for scheduler_item in schedulers
|
||||
]
|
||||
result_json = json.dumps(schedulers_list, ensure_ascii=False, indent=2)
|
||||
# 限制最多30条结果
|
||||
total_count = len(schedulers_list)
|
||||
if total_count > 30:
|
||||
limited_schedulers = schedulers_list[:30]
|
||||
limited_json = json.dumps(limited_schedulers, ensure_ascii=False, indent=2)
|
||||
return f"注意:查询结果共找到 {total_count} 条,为节省上下文空间,仅显示前 30 条结果。\n\n{limited_json}"
|
||||
limited_json = json.dumps(
|
||||
limited_schedulers,
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
return (
|
||||
f"注意:查询结果共找到 {total_count} 条,为节省上下文空间,"
|
||||
f"仅显示前 30 条结果。\n\n{limited_json}"
|
||||
)
|
||||
return result_json
|
||||
return "未找到定时服务"
|
||||
return "未找到系统、插件或工作流定时服务"
|
||||
except Exception as e:
|
||||
logger.error(f"查询定时服务失败: {e}", exc_info=True)
|
||||
return f"查询定时服务时发生错误: {str(e)}"
|
||||
|
||||
@@ -22,8 +22,6 @@ def _preview_list(value, limit: int = SITE_USERDATA_DETAIL_PREVIEW_LIMIT) -> tup
|
||||
class QuerySiteUserdataInput(BaseModel):
|
||||
"""查询站点用户数据工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
site_id: int = Field(
|
||||
...,
|
||||
description="The ID of the site to query user data for (can be obtained from query_sites tool)",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -17,8 +17,6 @@ PAGE_SIZE = 20
|
||||
class QuerySubscribeHistoryInput(BaseModel):
|
||||
"""查询订阅历史工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
media_type: Optional[str] = Field(
|
||||
"all", description="Allowed values: movie, tv, all"
|
||||
)
|
||||
@@ -172,6 +170,9 @@ class QuerySubscribeHistoryTool(MoviePilotTool):
|
||||
"tmdbid": record.tmdbid,
|
||||
"doubanid": record.doubanid,
|
||||
"bangumiid": record.bangumiid,
|
||||
"anilistid": record.anilistid,
|
||||
"media_source": record.media_source,
|
||||
"media_id": record.media_id,
|
||||
"poster": record.poster,
|
||||
"vote": record.vote,
|
||||
"total_episode": record.total_episode,
|
||||
|
||||
@@ -15,7 +15,6 @@ MAX_PAGE_SIZE = 50
|
||||
|
||||
class QuerySubscribeSharesInput(BaseModel):
|
||||
"""查询订阅分享工具的输入参数模型"""
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
name: Optional[str] = Field(None, description="Filter shares by media name (partial match, optional)")
|
||||
page: Optional[int] = Field(1, description="Page number for pagination (default: 1)")
|
||||
count: Optional[int] = Field(30, description="Number of items per page (default: 30, max: 50)")
|
||||
@@ -98,6 +97,9 @@ class QuerySubscribeSharesTool(MoviePilotTool):
|
||||
"tmdbid": share.get("tmdbid"),
|
||||
"doubanid": share.get("doubanid"),
|
||||
"bangumiid": share.get("bangumiid"),
|
||||
"anilistid": share.get("anilistid"),
|
||||
"media_source": share.get("media_source"),
|
||||
"media_id": share.get("media_id"),
|
||||
"poster": share.get("poster"),
|
||||
"vote": share.get("vote"),
|
||||
"share_title": share.get("share_title"),
|
||||
|
||||
@@ -48,8 +48,6 @@ QUERY_SUBSCRIBE_OUTPUT_FIELDS = [
|
||||
class QuerySubscribesInput(BaseModel):
|
||||
"""查询订阅工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
status: Optional[str] = Field(
|
||||
"all",
|
||||
description="Filter subscriptions by status: 'R' for enabled subscriptions, 'S' for paused ones, 'all' for all subscriptions",
|
||||
@@ -65,6 +63,10 @@ class QuerySubscribesInput(BaseModel):
|
||||
None,
|
||||
description="Filter by Douban ID to check if a specific media is already subscribed",
|
||||
)
|
||||
bangumi_id: Optional[int] = Field(None, description="Filter by Bangumi ID")
|
||||
anilist_id: Optional[int] = Field(None, description="Filter by AniList ID")
|
||||
media_source: Optional[str] = Field(None, description="Filter by media source")
|
||||
media_id: Optional[str] = Field(None, description="Filter by source-native media ID")
|
||||
page: Optional[int] = Field(
|
||||
1, description="Page number for pagination (default: 1, 100 items per page)"
|
||||
)
|
||||
@@ -106,6 +108,10 @@ class QuerySubscribesTool(MoviePilotTool):
|
||||
media_type: Optional[str] = "all",
|
||||
tmdb_id: Optional[int] = None,
|
||||
douban_id: Optional[str] = None,
|
||||
bangumi_id: Optional[int] = None,
|
||||
anilist_id: Optional[int] = None,
|
||||
media_source: Optional[str] = None,
|
||||
media_id: Optional[str] = None,
|
||||
page: Optional[int] = 1,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
@@ -132,6 +138,14 @@ class QuerySubscribesTool(MoviePilotTool):
|
||||
continue
|
||||
if douban_id is not None and sub.doubanid != douban_id:
|
||||
continue
|
||||
if bangumi_id is not None and sub.bangumiid != bangumi_id:
|
||||
continue
|
||||
if anilist_id is not None and sub.anilistid != anilist_id:
|
||||
continue
|
||||
if media_source is not None and sub.media_source != media_source:
|
||||
continue
|
||||
if media_id is not None and sub.media_id != media_id:
|
||||
continue
|
||||
filtered_subscribes.append(sub)
|
||||
if filtered_subscribes:
|
||||
total_count = len(filtered_subscribes)
|
||||
|
||||
@@ -23,8 +23,6 @@ from app.log import logger
|
||||
class QuerySystemSettingsInput(BaseModel):
|
||||
"""查询系统设置工具的输入参数模型。"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
setting_key: Optional[str] = Field(
|
||||
None,
|
||||
description=(
|
||||
|
||||
@@ -15,7 +15,6 @@ from app.utils.jieba import cut as jieba_cut
|
||||
|
||||
class QueryTransferHistoryInput(BaseModel):
|
||||
"""查询整理历史记录工具的输入参数模型"""
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
title: Optional[str] = Field(None, description="Search by title (optional, supports partial match)")
|
||||
status: Optional[str] = Field("all",
|
||||
description="Filter by status: 'success' for successful transfers, 'failed' for failed transfers, 'all' for all records (default: 'all')")
|
||||
@@ -121,6 +120,14 @@ class QueryTransferHistoryTool(MoviePilotTool):
|
||||
simplified["imdbid"] = record.imdbid
|
||||
if record.doubanid:
|
||||
simplified["doubanid"] = record.doubanid
|
||||
if record.bangumiid:
|
||||
simplified["bangumiid"] = record.bangumiid
|
||||
if record.anilistid:
|
||||
simplified["anilistid"] = record.anilistid
|
||||
if record.media_source:
|
||||
simplified["media_source"] = record.media_source
|
||||
if record.media_id:
|
||||
simplified["media_id"] = record.media_id
|
||||
simplified_records.append(simplified)
|
||||
|
||||
result_json = json.dumps(simplified_records, ensure_ascii=False, indent=2)
|
||||
|
||||
@@ -13,7 +13,6 @@ from app.log import logger
|
||||
|
||||
class QueryWorkflowsInput(BaseModel):
|
||||
"""查询工作流工具的输入参数模型"""
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
state: Optional[str] = Field("all", description="Filter workflows by state: 'W' for waiting, 'R' for running, 'P' for paused, 'S' for success, 'F' for failed, 'all' for all workflows (default: 'all')")
|
||||
name: Optional[str] = Field(None, description="Filter workflows by name (partial match, optional)")
|
||||
trigger_type: Optional[str] = Field("all", description="Filter workflows by trigger type: 'timer' for scheduled, 'event' for event-triggered, 'manual' for manual, 'all' for all types (default: 'all')")
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""文件读取工具"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional, Type
|
||||
|
||||
@@ -12,22 +14,40 @@ from app.log import logger
|
||||
|
||||
# 最大读取大小 50KB
|
||||
MAX_READ_SIZE = 50 * 1024
|
||||
READ_FILE_TRUNCATION_MESSAGE = (
|
||||
"文件内容超过50KB,本次结果已截断。"
|
||||
"请使用 start_line 和 end_line 参数指定行号范围分段读取。"
|
||||
)
|
||||
|
||||
|
||||
class ReadFileInput(BaseModel):
|
||||
"""文件读取工具的输入参数模型。"""
|
||||
|
||||
file_path: str = Field(..., description="The absolute path of the file to read")
|
||||
start_line: Optional[int] = Field(None, description="The starting line number (1-based, inclusive). If not provided, reading starts from the beginning of the file.")
|
||||
end_line: Optional[int] = Field(None, description="The ending line number (1-based, inclusive). If not provided, reading goes until the end of the file.")
|
||||
include_metadata: bool = Field(
|
||||
False,
|
||||
description=(
|
||||
"Return structured JSON containing content, size, truncation state, "
|
||||
"and SHA-256. Use before a guarded full-file overwrite."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ReadFileTool(MoviePilotTool):
|
||||
"""按行范围读取本地文本文件,并可返回文件版本元数据。"""
|
||||
|
||||
name: str = "read_file"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.File,
|
||||
]
|
||||
description: str = "Read the content of a text file. Supports reading by line range. Each read is limited to 50KB; content exceeding this limit will be truncated."
|
||||
description: str = (
|
||||
"Read the content of a text file. Supports reading by line range. Each "
|
||||
"read is limited to 50KB; when content is truncated, continue with "
|
||||
"smaller start_line and end_line ranges."
|
||||
)
|
||||
args_schema: Type[BaseModel] = ReadFileInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
@@ -36,8 +56,15 @@ class ReadFileTool(MoviePilotTool):
|
||||
file_name = Path(file_path).name if file_path else "未知文件"
|
||||
return f"读取文件: {file_name}"
|
||||
|
||||
async def run(self, file_path: str, start_line: Optional[int] = None,
|
||||
end_line: Optional[int] = None, **kwargs) -> str:
|
||||
async def run(
|
||||
self,
|
||||
file_path: str,
|
||||
start_line: Optional[int] = None,
|
||||
end_line: Optional[int] = None,
|
||||
include_metadata: bool = False,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""读取指定文本范围,必要时附带完整文件的 SHA-256 元数据。"""
|
||||
logger.info(f"执行工具: {self.name}, 参数: file_path={file_path}, start_line={start_line}, end_line={end_line}")
|
||||
|
||||
try:
|
||||
@@ -55,7 +82,8 @@ class ReadFileTool(MoviePilotTool):
|
||||
if not await path.is_file():
|
||||
return f"错误:{resolved_path} 不是一个文件"
|
||||
|
||||
content = await path.read_text(encoding="utf-8", errors="replace")
|
||||
raw_content = await path.read_bytes()
|
||||
content = raw_content.decode("utf-8", errors="replace")
|
||||
truncated = False
|
||||
|
||||
if start_line is not None or end_line is not None:
|
||||
@@ -78,8 +106,22 @@ class ReadFileTool(MoviePilotTool):
|
||||
content = content_bytes[:MAX_READ_SIZE].decode("utf-8", errors="replace")
|
||||
truncated = True
|
||||
|
||||
if include_metadata:
|
||||
payload = {
|
||||
"file_path": str(resolved_path),
|
||||
"sha256": hashlib.sha256(raw_content).hexdigest(),
|
||||
"size_bytes": len(raw_content),
|
||||
"start_line": start_line,
|
||||
"end_line": end_line,
|
||||
"truncated": truncated,
|
||||
}
|
||||
if truncated:
|
||||
payload["truncation_message"] = READ_FILE_TRUNCATION_MESSAGE
|
||||
payload["content"] = content
|
||||
return json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
|
||||
if truncated:
|
||||
return f"{content}\n\n[警告:文件内容已超过50KB限制,以上内容已被截断。请使用 start_line/end_line 参数分段读取。]"
|
||||
return f"{content}\n\n[警告:{READ_FILE_TRUNCATION_MESSAGE}]"
|
||||
|
||||
return content
|
||||
|
||||
|
||||
@@ -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=(
|
||||
|
||||
@@ -16,7 +16,6 @@ from app.schemas.types import media_type_to_agent
|
||||
|
||||
class RecognizeMediaInput(BaseModel):
|
||||
"""识别媒体信息工具的输入参数模型"""
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
title: Optional[str] = Field(None, description="The title of the torrent/media to recognize (required for torrent recognition)")
|
||||
subtitle: Optional[str] = Field(None, description="The subtitle or description of the torrent (optional, helps improve recognition accuracy)")
|
||||
path: Optional[str] = Field(None, description="The file path to recognize (required for file recognition, mutually exclusive with title)")
|
||||
@@ -143,6 +142,9 @@ class RecognizeMediaTool(MoviePilotTool):
|
||||
"imdb_id": media_info.get("imdb_id"),
|
||||
"douban_id": media_info.get("douban_id"),
|
||||
"bangumi_id": media_info.get("bangumi_id"),
|
||||
"anilist_id": media_info.get("anilist_id"),
|
||||
"media_source": media_info.get("source"),
|
||||
"media_id": media_info.get("media_id"),
|
||||
"overview": media_info.get("overview"),
|
||||
"vote_average": media_info.get("vote_average"),
|
||||
"poster_path": media_info.get("poster_path"),
|
||||
@@ -168,7 +170,11 @@ class RecognizeMediaTool(MoviePilotTool):
|
||||
"season_episode": meta_info.get("season_episode"),
|
||||
"episode_list": meta_info.get("episode_list"),
|
||||
"tmdbid": meta_info.get("tmdbid"),
|
||||
"doubanid": meta_info.get("doubanid")
|
||||
"doubanid": meta_info.get("doubanid"),
|
||||
"bangumiid": meta_info.get("bangumiid"),
|
||||
"anilistid": meta_info.get("anilistid"),
|
||||
"media_source": meta_info.get("media_source"),
|
||||
"media_id": meta_info.get("media_id"),
|
||||
}
|
||||
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
|
||||
@@ -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.",
|
||||
|
||||
78
app/agent/tools/impl/run_agent_task.py
Normal file
78
app/agent/tools/impl/run_agent_task.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""立即执行 Agent 自主定时任务工具。"""
|
||||
|
||||
from typing import Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.db.agenttask_oper import AgentTaskOper
|
||||
|
||||
|
||||
class RunAgentTaskInput(BaseModel):
|
||||
"""立即执行 Agent 自主定时任务的输入参数。"""
|
||||
|
||||
task_id: int = Field(
|
||||
...,
|
||||
ge=1,
|
||||
description=(
|
||||
"Integer autonomous task ID returned by query_agent_tasks. Do not pass a "
|
||||
"runtime scheduler job_id such as agent-task-12."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class RunAgentTaskTool(MoviePilotTool):
|
||||
"""将当前用户的 Agent 自主定时任务提交为立即执行。"""
|
||||
|
||||
name: str = "run_agent_task"
|
||||
tags: list[str] = [ToolTag.Write, ToolTag.AgentTask, ToolTag.Admin]
|
||||
description: str = (
|
||||
"Queue an enabled autonomous agent task owned by the current user for immediate "
|
||||
"execution. Use the integer task_id returned by query_agent_tasks. The task runs "
|
||||
"after the current agent turn can finish and broadcasts its result through the "
|
||||
"configured notification channels."
|
||||
)
|
||||
args_schema: Type[BaseModel] = RunAgentTaskInput
|
||||
require_admin: bool = True
|
||||
|
||||
def get_tool_message(self, **kwargs: object) -> Optional[str]:
|
||||
"""生成立即执行 Agent 任务的提示消息。"""
|
||||
return f"立即执行自主定时任务:{kwargs.get('task_id', '')}"
|
||||
|
||||
def _get_task_state(self, task_id: int) -> tuple[str, Optional[str]]:
|
||||
"""校验任务归属和状态,返回可执行性及任务名称。"""
|
||||
task = AgentTaskOper().get(
|
||||
task_id=task_id,
|
||||
user_id=str(self._user_id),
|
||||
)
|
||||
if not task:
|
||||
return "not_found", None
|
||||
if not task.enabled:
|
||||
return "disabled", task.name
|
||||
if task.last_status == "running":
|
||||
return "running", task.name
|
||||
return "ready", task.name
|
||||
|
||||
async def run(self, task_id: int, **kwargs: object) -> str:
|
||||
"""立即执行当前用户拥有且已启用的 Agent 自主定时任务。"""
|
||||
from app.scheduler import Scheduler
|
||||
|
||||
payload = RunAgentTaskInput(task_id=task_id)
|
||||
status, task_name = await self.run_blocking(
|
||||
"db",
|
||||
self._get_task_state,
|
||||
payload.task_id,
|
||||
)
|
||||
if status == "not_found":
|
||||
return f"Agent 定时任务 {task_id} 不存在或不属于当前用户"
|
||||
if status == "disabled":
|
||||
return f"Agent 定时任务 {task_id} 已暂停,请先恢复后再执行"
|
||||
if status == "running":
|
||||
return f"Agent 定时任务 {task_id} 正在执行,请勿重复触发"
|
||||
if not Scheduler().start_agent_task(payload.task_id):
|
||||
return f"Agent 定时任务 {task_id} 尚未注册到运行时调度器,无法立即执行"
|
||||
return (
|
||||
f"Agent 定时任务 {task_id} 已提交立即执行:{task_name}。"
|
||||
"执行完成后将通过已配置的通知渠道广播结果"
|
||||
)
|
||||
@@ -12,27 +12,34 @@ from app.log import logger
|
||||
class RunSchedulerInput(BaseModel):
|
||||
"""运行定时服务工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
job_id: str = Field(
|
||||
...,
|
||||
description="The ID of the scheduled job to run (can be obtained from query_schedulers tool)",
|
||||
description=(
|
||||
"Runtime scheduler job ID returned by query_schedulers. Do not pass an "
|
||||
"autonomous agent task ID or an agent-task-* runtime ID."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class RunSchedulerTool(MoviePilotTool):
|
||||
"""立即运行系统、插件或工作流注册的定时服务。"""
|
||||
|
||||
name: str = "run_scheduler"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.Scheduler,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = "Manually trigger a scheduled task to run immediately. This will execute the specified scheduler job by its ID."
|
||||
description: str = (
|
||||
"Manually trigger a MoviePilot system, plugin, or workflow scheduler service by "
|
||||
"the runtime job_id returned from query_schedulers. This tool does not run "
|
||||
"user-created autonomous agent tasks; use run_agent_task with an integer task_id."
|
||||
)
|
||||
args_schema: Type[BaseModel] = RunSchedulerInput
|
||||
require_admin: bool = True
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""根据运行参数生成友好的提示消息"""
|
||||
def get_tool_message(self, **kwargs: object) -> Optional[str]:
|
||||
"""根据运行参数生成友好的提示消息。"""
|
||||
job_id = kwargs.get("job_id", "")
|
||||
return f"运行定时服务 (ID: {job_id})"
|
||||
|
||||
@@ -48,10 +55,18 @@ class RunSchedulerTool(MoviePilotTool):
|
||||
return True, scheduler_item.name
|
||||
return False, ""
|
||||
|
||||
async def run(self, job_id: str, **kwargs) -> str:
|
||||
async def run(self, job_id: str, **kwargs: object) -> str:
|
||||
"""立即运行非 Agent 自主任务的运行时定时服务。"""
|
||||
logger.info(f"执行工具: {self.name}, 参数: job_id={job_id}")
|
||||
|
||||
try:
|
||||
from app.scheduler import AGENT_TASK_JOB_PREFIX
|
||||
|
||||
if job_id.startswith(f"{AGENT_TASK_JOB_PREFIX}-"):
|
||||
return (
|
||||
"Agent 自主定时任务不能通过 run_scheduler 运行,"
|
||||
"请使用 query_agent_tasks 查询整数 task_id 后调用 run_agent_task"
|
||||
)
|
||||
job_exists, job_name = await self.run_blocking(
|
||||
"workflow", self._run_scheduler_sync, job_id
|
||||
)
|
||||
|
||||
@@ -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'. "
|
||||
|
||||
@@ -14,8 +14,6 @@ from app.log import logger
|
||||
class RunWorkflowInput(BaseModel):
|
||||
"""执行工作流工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
workflow_id: int = Field(
|
||||
..., description="Workflow ID (can be obtained from query_workflows tool)"
|
||||
)
|
||||
|
||||
@@ -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')",
|
||||
|
||||
@@ -10,11 +10,11 @@ from app.agent.tools.tags import ToolTag
|
||||
from app.chain.media import MediaChain
|
||||
from app.log import logger
|
||||
from app.schemas.types import MediaType, media_type_to_agent
|
||||
from app.utils.media import resolve_media_identity
|
||||
|
||||
|
||||
class SearchMediaInput(BaseModel):
|
||||
"""搜索媒体工具的输入参数模型"""
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
title: str = Field(..., description="The title of the media to search for (e.g., 'The Matrix', 'Breaking Bad')")
|
||||
year: Optional[str] = Field(None, description="Release year of the media (optional, helps narrow down results)")
|
||||
media_type: Optional[str] = Field(None,
|
||||
@@ -44,7 +44,7 @@ class SearchMediaTool(MoviePilotTool):
|
||||
message += f" ({year})"
|
||||
if media_type:
|
||||
message += f" [{media_type}]"
|
||||
if season:
|
||||
if season is not None:
|
||||
message += f" 第{season}季"
|
||||
|
||||
return message
|
||||
@@ -84,6 +84,7 @@ class SearchMediaTool(MoviePilotTool):
|
||||
# 精简字段,只保留关键信息
|
||||
simplified_results = []
|
||||
for r in limited_results:
|
||||
media_source, media_id = resolve_media_identity(media=r)
|
||||
simplified = {
|
||||
"title": r.title,
|
||||
"en_title": r.en_title,
|
||||
@@ -93,6 +94,10 @@ class SearchMediaTool(MoviePilotTool):
|
||||
"tmdb_id": r.tmdb_id,
|
||||
"imdb_id": r.imdb_id,
|
||||
"douban_id": r.douban_id,
|
||||
"bangumi_id": r.bangumi_id,
|
||||
"anilist_id": r.anilist_id,
|
||||
"media_source": media_source,
|
||||
"media_id": media_id,
|
||||
"overview": r.overview[:200] + "..." if r.overview and len(r.overview) > 200 else r.overview,
|
||||
"vote_average": r.vote_average,
|
||||
"poster_path": r.poster_path,
|
||||
|
||||
@@ -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', '周杰伦')")
|
||||
|
||||
|
||||
|
||||
@@ -11,11 +11,11 @@ from app.chain.douban import DoubanChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.chain.bangumi import BangumiChain
|
||||
from app.log import logger
|
||||
from app.utils.media import resolve_media_identity
|
||||
|
||||
|
||||
class SearchPersonCreditsInput(BaseModel):
|
||||
"""搜索演员参演作品工具的输入参数模型"""
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
person_id: int = Field(..., description="The ID of the person/actor to search for credits (e.g., 31 for Tom Hanks in TMDB)")
|
||||
source: str = Field(..., description="The data source: 'tmdb' for TheMovieDB, 'douban' for Douban, 'bangumi' for Bangumi")
|
||||
page: Optional[int] = Field(1, description="Page number for pagination (default: 1)")
|
||||
@@ -60,6 +60,7 @@ class SearchPersonCreditsTool(MoviePilotTool):
|
||||
# 精简字段,只保留关键信息
|
||||
simplified_results = []
|
||||
for media in limited_medias:
|
||||
media_source, media_id = resolve_media_identity(media=media)
|
||||
simplified = {
|
||||
"title": media.title,
|
||||
"en_title": media.en_title,
|
||||
@@ -69,6 +70,10 @@ class SearchPersonCreditsTool(MoviePilotTool):
|
||||
"tmdb_id": media.tmdb_id,
|
||||
"imdb_id": media.imdb_id,
|
||||
"douban_id": media.douban_id,
|
||||
"bangumi_id": media.bangumi_id,
|
||||
"anilist_id": media.anilist_id,
|
||||
"media_source": media_source,
|
||||
"media_id": media_id,
|
||||
"overview": media.overview[:200] + "..." if media.overview and len(media.overview) > 200 else media.overview,
|
||||
"vote_average": media.vote_average,
|
||||
"poster_path": media.poster_path,
|
||||
|
||||
@@ -15,7 +15,6 @@ from app.schemas.types import media_type_to_agent
|
||||
|
||||
class SearchSubscribeInput(BaseModel):
|
||||
"""搜索订阅缺失剧集工具的输入参数模型"""
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
subscribe_id: int = Field(..., description="The ID of the subscription to search for missing episodes (can be obtained from query_subscribes tool)")
|
||||
manual: Optional[bool] = Field(False, description="Whether this is a manual search (default: False)")
|
||||
filter_groups: Optional[List[str]] = Field(None,
|
||||
@@ -71,7 +70,11 @@ class SearchSubscribeTool(MoviePilotTool):
|
||||
"total_episode": subscribe.total_episode,
|
||||
"lack_episode": subscribe.lack_episode,
|
||||
"tmdbid": subscribe.tmdbid,
|
||||
"doubanid": subscribe.doubanid
|
||||
"doubanid": subscribe.doubanid,
|
||||
"bangumiid": subscribe.bangumiid,
|
||||
"anilistid": subscribe.anilistid,
|
||||
"media_source": subscribe.media_source,
|
||||
"media_id": subscribe.media_id,
|
||||
}
|
||||
|
||||
# 检查订阅状态
|
||||
|
||||
@@ -20,14 +20,18 @@ from ._torrent_search_utils import (
|
||||
|
||||
class SearchTorrentsInput(BaseModel):
|
||||
"""搜索种子工具的输入参数模型"""
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
tmdb_id: Optional[int] = Field(None, description="TMDB ID (can be obtained from search_media tool). Either tmdb_id or douban_id must be provided.")
|
||||
douban_id: Optional[str] = Field(None, description="Douban ID (can be obtained from search_media tool). Either tmdb_id or douban_id must be provided.")
|
||||
tmdb_id: Optional[int] = Field(None, description="TMDB media ID")
|
||||
douban_id: Optional[str] = Field(None, description="Douban media ID")
|
||||
bangumi_id: Optional[int] = Field(None, description="Bangumi media ID")
|
||||
anilist_id: Optional[int] = Field(None, description="AniList media ID")
|
||||
media_source: Optional[str] = Field(None, description="Media metadata source")
|
||||
media_id: Optional[str] = Field(None, description="Native ID for media_source")
|
||||
media_type: Optional[str] = Field(None, description="Allowed values: movie, tv")
|
||||
area: Optional[str] = Field(None, description="Search scope: 'title' (default) or 'imdbid'")
|
||||
sites: Optional[List[int]] = Field(None,
|
||||
description="Array of specific site IDs to search on (optional, if not provided searches all configured sites)")
|
||||
|
||||
|
||||
class SearchTorrentsTool(MoviePilotTool):
|
||||
name: str = "search_torrents"
|
||||
tags: list[str] = [
|
||||
@@ -36,23 +40,27 @@ class SearchTorrentsTool(MoviePilotTool):
|
||||
ToolTag.Site,
|
||||
ToolTag.Media,
|
||||
]
|
||||
description: str = ("Search for torrent files by media ID across configured indexer sites, cache the matched results, "
|
||||
"and return available filter options for follow-up selection. "
|
||||
"Requires tmdb_id or douban_id (can be obtained from search_media tool) for accurate matching.")
|
||||
description: str = (
|
||||
"Search for torrent files by media ID across configured indexer sites, cache the matched results, "
|
||||
"and return available filter options for follow-up selection. "
|
||||
"Accepts a TMDB, Douban, Bangumi, AniList, or source-native media ID for accurate matching.")
|
||||
args_schema: Type[BaseModel] = SearchTorrentsInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""根据搜索参数生成友好的提示消息"""
|
||||
tmdb_id = kwargs.get("tmdb_id")
|
||||
douban_id = kwargs.get("douban_id")
|
||||
media_type = kwargs.get("media_type")
|
||||
|
||||
if tmdb_id:
|
||||
message = f"搜索种子: TMDB={tmdb_id}"
|
||||
elif douban_id:
|
||||
message = f"搜索种子: 豆瓣={douban_id}"
|
||||
else:
|
||||
message = "搜索种子"
|
||||
identities = (
|
||||
("TMDB", kwargs.get("tmdb_id")),
|
||||
("豆瓣", kwargs.get("douban_id")),
|
||||
("Bangumi", kwargs.get("bangumi_id")),
|
||||
("AniList", kwargs.get("anilist_id")),
|
||||
(kwargs.get("media_source") or "媒体源", kwargs.get("media_id")),
|
||||
)
|
||||
label, identity = next(
|
||||
((label, identity) for label, identity in identities if identity is not None),
|
||||
(None, None),
|
||||
)
|
||||
message = f"搜索种子: {label}={identity}" if label else "搜索种子"
|
||||
if media_type:
|
||||
message += f" [{media_type}]"
|
||||
return message
|
||||
@@ -63,13 +71,15 @@ class SearchTorrentsTool(MoviePilotTool):
|
||||
return SystemConfigOper().get(SystemConfigKey.IndexerSites) or []
|
||||
|
||||
async def run(self, tmdb_id: Optional[int] = None, douban_id: Optional[str] = None,
|
||||
bangumi_id: Optional[int] = None, anilist_id: Optional[int] = None,
|
||||
media_source: Optional[str] = None, media_id: Optional[str] = None,
|
||||
media_type: Optional[str] = None, area: Optional[str] = None,
|
||||
sites: Optional[List[int]] = None, **kwargs) -> str:
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, 参数: tmdb_id={tmdb_id}, douban_id={douban_id}, media_type={media_type}, area={area}, sites={sites}")
|
||||
|
||||
if not tmdb_id and not douban_id:
|
||||
return "参数错误:tmdb_id 和 douban_id 至少需要提供一个,请先使用 search_media 工具获取媒体 ID。"
|
||||
if not any((tmdb_id, douban_id, bangumi_id, anilist_id, media_id)):
|
||||
return "参数错误:至少需要提供一个媒体 ID,请先使用 search_media 工具获取媒体信息。"
|
||||
|
||||
try:
|
||||
search_chain = SearchChain()
|
||||
@@ -82,6 +92,10 @@ class SearchTorrentsTool(MoviePilotTool):
|
||||
filtered_torrents = await search_chain.async_search_by_id(
|
||||
tmdbid=tmdb_id,
|
||||
doubanid=douban_id,
|
||||
bangumiid=bangumi_id,
|
||||
anilistid=anilist_id,
|
||||
source=media_source,
|
||||
mediaid=media_id,
|
||||
mtype=media_type_enum,
|
||||
area=area or "title",
|
||||
sites=sites,
|
||||
@@ -108,9 +122,9 @@ class SearchTorrentsTool(MoviePilotTool):
|
||||
}, ensure_ascii=False, indent=2)
|
||||
return result_json
|
||||
else:
|
||||
media_id = f"TMDB={tmdb_id}" if tmdb_id else f"豆瓣={douban_id}"
|
||||
identity = media_id or tmdb_id or douban_id or bangumi_id or anilist_id
|
||||
result_json = json.dumps({
|
||||
"message": f"未找到相关种子资源: {media_id}",
|
||||
"message": f"未找到相关种子资源: {identity}",
|
||||
"all_sites": all_sites,
|
||||
"search_site_ids": search_site_ids,
|
||||
}, ensure_ascii=False, indent=2)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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 "本地附件已发送"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=(
|
||||
|
||||
@@ -13,7 +13,6 @@ from app.log import logger
|
||||
|
||||
class TestSiteInput(BaseModel):
|
||||
"""测试站点连通性工具的输入参数模型"""
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
site_identifier: int = Field(..., description="Site ID to test (can be obtained from query_sites tool)")
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user