mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-06-21 23:44:31 +08:00
Compare commits
358 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05943287c0 | ||
|
|
94633173b1 | ||
|
|
7ab1a668cb | ||
|
|
d57deb1df1 | ||
|
|
d940373f6b | ||
|
|
ca01b8ec3f | ||
|
|
384d6a3fe1 | ||
|
|
922e8473c5 | ||
|
|
01c3451679 | ||
|
|
98e3ea4e6f | ||
|
|
0e8bcb4df6 | ||
|
|
784672af5c | ||
|
|
63b9994b0e | ||
|
|
d713ea54c1 | ||
|
|
766d2699ea | ||
|
|
9af61c4744 | ||
|
|
7c8b973f30 | ||
|
|
0fdf1fadab | ||
|
|
477c49587c | ||
|
|
5532f14efb | ||
|
|
b08c335bb4 | ||
|
|
c7670e5cc8 | ||
|
|
a725789045 | ||
|
|
5d5c95dcd8 | ||
|
|
4d8c910f0d | ||
|
|
4b4b0335e8 | ||
|
|
ac3432c54f | ||
|
|
ea52537423 | ||
|
|
c9bdaf2f40 | ||
|
|
2b629185b9 | ||
|
|
a97e3ea092 | ||
|
|
7af2aa4266 | ||
|
|
1550b75548 | ||
|
|
b7f6ee12ee | ||
|
|
79539760da | ||
|
|
dc73d61682 | ||
|
|
6430b864b4 | ||
|
|
ec588037a0 | ||
|
|
0b7854a0af | ||
|
|
0273adc61c | ||
|
|
d6472088cb | ||
|
|
0c133b7ccd | ||
|
|
0bf228d29d | ||
|
|
a6826e6a4e | ||
|
|
ed0f8c471b | ||
|
|
ad38f51d6b | ||
|
|
d1e2881347 | ||
|
|
222f6ce7d8 | ||
|
|
39d09c2956 | ||
|
|
2b531afe49 | ||
|
|
5a1a6b47a5 | ||
|
|
134c441754 | ||
|
|
00fc8b2f53 | ||
|
|
5f0ae3a75e | ||
|
|
3ebd06a3a7 | ||
|
|
2eb7f57a4c | ||
|
|
7cbfeb2377 | ||
|
|
fcbea077b7 | ||
|
|
da54f3a302 | ||
|
|
efdb4d1b28 | ||
|
|
9190699cd1 | ||
|
|
4f107a7cc8 | ||
|
|
b26bf2a019 | ||
|
|
a74f04a149 | ||
|
|
cde267c55f | ||
|
|
f7b78721c3 | ||
|
|
7e6cd47712 | ||
|
|
4de4044a3e | ||
|
|
052e1ca8e4 | ||
|
|
bd4d493f34 | ||
|
|
7daeb17d85 | ||
|
|
2b5528c0ac | ||
|
|
cb15b711b9 | ||
|
|
9319b47fad | ||
|
|
23487b7ae0 | ||
|
|
fec109712b | ||
|
|
737bcb5c62 | ||
|
|
b6b5529d19 | ||
|
|
2bd4a41cbe | ||
|
|
0245c8db80 | ||
|
|
4c64b1769d | ||
|
|
ee9eced2f1 | ||
|
|
2109d323ae | ||
|
|
fd4d162287 | ||
|
|
617692616c | ||
|
|
014dc2884c | ||
|
|
d37954e6bc | ||
|
|
284c272001 | ||
|
|
0fb9d18b30 | ||
|
|
5d34bc5c56 | ||
|
|
ad7cce72f4 | ||
|
|
c52ccaf75f | ||
|
|
c661bc4764 | ||
|
|
8a375e022c | ||
|
|
7cc037c683 | ||
|
|
068d0af4ca | ||
|
|
8f117d79f2 | ||
|
|
47c4e84fdd | ||
|
|
e00aa42f94 | ||
|
|
72ead2970c | ||
|
|
5fe5523d13 | ||
|
|
3ec0964a01 | ||
|
|
a5745af484 | ||
|
|
c3e4e1a764 | ||
|
|
b07c47551c | ||
|
|
9e0846961f | ||
|
|
71dc9df7ff | ||
|
|
6edb627145 | ||
|
|
07f51c5d94 | ||
|
|
5d02550874 | ||
|
|
2ff6474f0f | ||
|
|
c4eb4d9b95 | ||
|
|
7866aee1de | ||
|
|
cdddd8e080 | ||
|
|
407b60a14f | ||
|
|
b989d08385 | ||
|
|
f46488cb9c | ||
|
|
34ff80e26c | ||
|
|
195e34563d | ||
|
|
29dab5a312 | ||
|
|
9e9c398177 | ||
|
|
1f0eeb25e6 | ||
|
|
3c1ff5242c | ||
|
|
9076acc52e | ||
|
|
f5eeeebeba | ||
|
|
22bb15583d | ||
|
|
bedf06b864 | ||
|
|
cb8636e967 | ||
|
|
36a0d78f08 | ||
|
|
23d6ba0466 | ||
|
|
6685bd0e0e | ||
|
|
c857ae3e14 | ||
|
|
93130baf0a | ||
|
|
3653164924 | ||
|
|
ca0127cc87 | ||
|
|
092666f9d2 | ||
|
|
7b97e2039f | ||
|
|
e168e31a8f | ||
|
|
3ee601574c | ||
|
|
0ee9fec1d2 | ||
|
|
9069dccb2a | ||
|
|
3c055e2482 | ||
|
|
28718094e4 | ||
|
|
9b23265c3b | ||
|
|
9f61bce039 | ||
|
|
1f49f9b454 | ||
|
|
51229204c9 | ||
|
|
2831eecbeb | ||
|
|
b2a18f9ae4 | ||
|
|
5a06e7b8bc | ||
|
|
f303d9e576 | ||
|
|
b76c4edc4a | ||
|
|
41da9b62c2 | ||
|
|
9128955bf9 | ||
|
|
f50773711e | ||
|
|
23784f614b | ||
|
|
7b27b7fd16 | ||
|
|
6834d8b2c7 | ||
|
|
4322f8a3c1 | ||
|
|
0f3a4e4c15 | ||
|
|
f4423e121e | ||
|
|
e5b67438d9 | ||
|
|
7b1ece8b83 | ||
|
|
6d5cda5d51 | ||
|
|
1af3a0ef59 | ||
|
|
5a585839ba | ||
|
|
fcf6e14ac9 | ||
|
|
0959c4ace4 | ||
|
|
f0bc1bd681 | ||
|
|
f8d096f476 | ||
|
|
b24127e66f | ||
|
|
35eb8c51a9 | ||
|
|
669ca713cf | ||
|
|
f2fd28bf4d | ||
|
|
3852c0e43e | ||
|
|
6fb6996d81 | ||
|
|
4c16704ca2 | ||
|
|
f017eaedcc | ||
|
|
19526146c5 | ||
|
|
7b4cb2097b | ||
|
|
b6062a9ce2 | ||
|
|
ea8a90aa0a | ||
|
|
fa939dfbe6 | ||
|
|
77aa65bfdc | ||
|
|
d86d24fc4f | ||
|
|
0989439d25 | ||
|
|
a46ce24691 | ||
|
|
57bb67e547 | ||
|
|
5e5c257b75 | ||
|
|
624862dfc6 | ||
|
|
b172a6d08f | ||
|
|
116465b6d8 | ||
|
|
cfb6448060 | ||
|
|
10a9e7293a | ||
|
|
fc2c77fbf1 | ||
|
|
e4721fef0c | ||
|
|
2c45831714 | ||
|
|
9068280f6d | ||
|
|
ea88f272a6 | ||
|
|
ac090af606 | ||
|
|
1c17c0b07e | ||
|
|
db6321d032 | ||
|
|
d6270dfb81 | ||
|
|
cc52bdaaf3 | ||
|
|
cbc8592b49 | ||
|
|
14d648445e | ||
|
|
87777343d2 | ||
|
|
26aa49f323 | ||
|
|
ad8b6473fc | ||
|
|
c32df7446d | ||
|
|
05b34b9c26 | ||
|
|
99fbeecfa1 | ||
|
|
41477601c7 | ||
|
|
a6ab9b76c1 | ||
|
|
a62b6b6fd5 | ||
|
|
75a52ad751 | ||
|
|
a2fa8d6f28 | ||
|
|
ed9116d81e | ||
|
|
6db1dd2067 | ||
|
|
0fb11880a4 | ||
|
|
b7fc5b0203 | ||
|
|
1b2433f7c2 | ||
|
|
c745616495 | ||
|
|
888ccfcfc2 | ||
|
|
3c9228c2f8 | ||
|
|
3776422634 | ||
|
|
5021b2c86f | ||
|
|
412e10972f | ||
|
|
d0b1b3d7f0 | ||
|
|
f5fea25b41 | ||
|
|
68706d3d5b | ||
|
|
b768ed8fed | ||
|
|
c4d3d28491 | ||
|
|
1862a7ab4b | ||
|
|
adb7aa6aa9 | ||
|
|
79eb128196 | ||
|
|
4d132c424a | ||
|
|
c52327c248 | ||
|
|
1d97f2e043 | ||
|
|
ee9ea54ab7 | ||
|
|
4027ae2641 | ||
|
|
bc6c61bc45 | ||
|
|
cd5e693302 | ||
|
|
ac11b303b3 | ||
|
|
a7823fb4d1 | ||
|
|
45d47d32f8 | ||
|
|
893b8eba86 | ||
|
|
f9b987c3ef | ||
|
|
4ef8b0ba99 | ||
|
|
268414fb11 | ||
|
|
bedab9ab92 | ||
|
|
94d7e4385e | ||
|
|
64b4de3900 | ||
|
|
a59afe4cc9 | ||
|
|
7b6047accf | ||
|
|
e217d1aa05 | ||
|
|
52e15b51db | ||
|
|
0dab3f087d | ||
|
|
e4c5a4f232 | ||
|
|
a729307d30 | ||
|
|
98347669ea | ||
|
|
9e4020c617 | ||
|
|
2f231fe632 | ||
|
|
14b366a648 | ||
|
|
0a0d5e6da2 | ||
|
|
3dbb68627f | ||
|
|
f157b61dfa | ||
|
|
44f975baf4 | ||
|
|
28ec4a6ac0 | ||
|
|
1140a85402 | ||
|
|
c6d95cd006 | ||
|
|
c9931aa948 | ||
|
|
ec4f13dd79 | ||
|
|
d43ef610c7 | ||
|
|
05d720d81f | ||
|
|
2d2c2a01eb | ||
|
|
226f9c9318 | ||
|
|
b77b5a21c5 | ||
|
|
82b637532e | ||
|
|
c2c9950bb1 | ||
|
|
ffbe348d66 | ||
|
|
6d7b0733af | ||
|
|
49a51cca25 | ||
|
|
06197144c0 | ||
|
|
62541ffe43 | ||
|
|
c762628217 | ||
|
|
caf615f3bd | ||
|
|
27436757a0 | ||
|
|
924d54dfd3 | ||
|
|
39f9550f86 | ||
|
|
367ecafbbb | ||
|
|
10467244e0 | ||
|
|
cb6dcc6a2e | ||
|
|
43c421b0bb | ||
|
|
45d0891502 | ||
|
|
76c5f54465 | ||
|
|
bcf8116172 | ||
|
|
1f889596b7 | ||
|
|
04443fcfba | ||
|
|
5d7a7fd301 | ||
|
|
4d0a722b09 | ||
|
|
db6dc926cf | ||
|
|
4bb4f5aeb5 | ||
|
|
58e25fe900 | ||
|
|
03f6b9bc96 | ||
|
|
6fdda3a570 | ||
|
|
100eaec38f | ||
|
|
b129508304 | ||
|
|
53bf81aede | ||
|
|
afcc071d07 | ||
|
|
2ea617655c | ||
|
|
0583495548 | ||
|
|
516aea6312 | ||
|
|
2d412cae1c | ||
|
|
45f5326fb4 | ||
|
|
2ccea2da39 | ||
|
|
53f6897d62 | ||
|
|
28a2386f2f | ||
|
|
abda9d3212 | ||
|
|
34e7c4ac14 | ||
|
|
b228107a25 | ||
|
|
2375508616 | ||
|
|
baebd0ed1a | ||
|
|
6532c60a3c | ||
|
|
11478faff3 | ||
|
|
e9291cec6a | ||
|
|
7586a2cd42 | ||
|
|
ef5bd29759 | ||
|
|
7ab643d34a | ||
|
|
0b7505a604 | ||
|
|
460d716512 | ||
|
|
b6f0ef99ab | ||
|
|
af35101774 | ||
|
|
9ed5018cc2 | ||
|
|
7299733960 | ||
|
|
bd5c3d848c | ||
|
|
38c48fa4ce | ||
|
|
b7749c44fd | ||
|
|
e4a7333b79 | ||
|
|
4b27b7bc42 | ||
|
|
c91e87115a | ||
|
|
4a3cc5ee18 | ||
|
|
54d6c2ad4a | ||
|
|
090dcacd30 | ||
|
|
344280cd61 | ||
|
|
2c7fb5786c | ||
|
|
6b9790026c | ||
|
|
6c70531967 | ||
|
|
bcc321eb70 | ||
|
|
2ff1cd1045 | ||
|
|
7fc496cf5b | ||
|
|
8789f35228 | ||
|
|
d4dec90e2f | ||
|
|
5c1487a9a6 | ||
|
|
c5b716c231 | ||
|
|
483fe55372 | ||
|
|
5d588ee127 | ||
|
|
afcd895f52 |
1
.cursorrules
Normal file
1
.cursorrules
Normal file
@@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
@@ -73,6 +73,7 @@ test_*
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
rust/**/target/
|
||||
|
||||
# Docker
|
||||
Dockerfile*
|
||||
@@ -81,4 +82,4 @@ docker-compose*
|
||||
|
||||
# Other
|
||||
app.ico
|
||||
frozen.spec
|
||||
frozen.spec
|
||||
|
||||
1
.github/copilot-instructions.md
vendored
Normal file
1
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
6
.github/workflows/beta.yml
vendored
6
.github/workflows/beta.yml
vendored
@@ -46,7 +46,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
@@ -56,5 +56,5 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha, scope=${{ github.workflow }}-docker
|
||||
cache-to: type=gha, scope=${{ github.workflow }}-docker
|
||||
cache-from: type=gha,scope=moviepilot-docker,version=2
|
||||
cache-to: type=gha,scope=moviepilot-docker,mode=max,version=2
|
||||
|
||||
6
.github/workflows/build.yml
vendored
6
.github/workflows/build.yml
vendored
@@ -56,7 +56,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
@@ -66,8 +66,8 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha, scope=${{ github.workflow }}-docker
|
||||
cache-to: type=gha, scope=${{ github.workflow }}-docker
|
||||
cache-from: type=gha,scope=moviepilot-docker,version=2
|
||||
cache-to: type=gha,scope=moviepilot-docker,mode=max,version=2
|
||||
|
||||
- name: Generate Changelog
|
||||
id: changelog
|
||||
|
||||
127
.github/workflows/issues.yml
vendored
127
.github/workflows/issues.yml
vendored
@@ -2,13 +2,138 @@ name: Close inactive issues
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
|
||||
schedule:
|
||||
# Github Action 只支持 UTC 时间。
|
||||
# '0 18 * * *' 对应 UTC 时间的 18:00,也就是中国时区 (UTC+8) 的第二天凌晨 02:00。
|
||||
- cron: "0 18 * * *"
|
||||
|
||||
jobs:
|
||||
label-opened-issue:
|
||||
if: github.event_name == 'issues'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
const title = issue.title || '';
|
||||
const body = issue.body || '';
|
||||
const currentLabels = (issue.labels || []).map((label) => label.name);
|
||||
|
||||
// 网页 Issue Form 已经会自动带模板 labels;这里只兜底处理
|
||||
// API 创建或异常路径产生的无 label issue,避免重复补标。
|
||||
if (currentLabels.length > 0) {
|
||||
core.info(`Issue #${issue.number} already has labels: ${currentLabels.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasAllMarkers = (markers) => markers.every((marker) => body.includes(marker));
|
||||
const labelRules = [
|
||||
{
|
||||
label: 'bug',
|
||||
titlePrefix: '[错误报告]:',
|
||||
markers: ['### 当前程序版本', '### 运行环境', '### 问题类型', '### 问题描述'],
|
||||
},
|
||||
{
|
||||
label: 'feature request',
|
||||
titlePrefix: '[Feature Request]:',
|
||||
markers: ['### 当前程序版本', '### 运行环境', '### 功能改进类型', '### 功能改进'],
|
||||
},
|
||||
{
|
||||
label: 'RFC',
|
||||
titlePrefix: '[RFC]',
|
||||
markers: ['### 背景 or 问题', '### 目标 & 方案简述'],
|
||||
},
|
||||
];
|
||||
|
||||
const matched = labelRules.find((rule) => (
|
||||
title.startsWith(rule.titlePrefix) || hasAllMarkers(rule.markers)
|
||||
));
|
||||
|
||||
if (!matched) {
|
||||
core.info(`Issue #${issue.number} does not match known issue templates.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: [matched.label],
|
||||
});
|
||||
core.info(`Added label "${matched.label}" to issue #${issue.number}.`);
|
||||
|
||||
label-unlabeled-issues:
|
||||
if: github.event_name != 'issues'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const labelRules = [
|
||||
{
|
||||
label: 'bug',
|
||||
titlePrefix: '[错误报告]:',
|
||||
markers: ['### 当前程序版本', '### 运行环境', '### 问题类型', '### 问题描述'],
|
||||
},
|
||||
{
|
||||
label: 'feature request',
|
||||
titlePrefix: '[Feature Request]:',
|
||||
markers: ['### 当前程序版本', '### 运行环境', '### 功能改进类型', '### 功能改进'],
|
||||
},
|
||||
{
|
||||
label: 'RFC',
|
||||
titlePrefix: '[RFC]',
|
||||
markers: ['### 背景 or 问题', '### 目标 & 方案简述'],
|
||||
},
|
||||
];
|
||||
|
||||
const hasAllMarkers = (body, markers) => markers.every((marker) => body.includes(marker));
|
||||
const getMatchedRule = (issue) => {
|
||||
const title = issue.title || '';
|
||||
const body = issue.body || '';
|
||||
return labelRules.find((rule) => (
|
||||
title.startsWith(rule.titlePrefix) || hasAllMarkers(body, rule.markers)
|
||||
));
|
||||
};
|
||||
|
||||
// Search API 支持 no:label 查询;issues.listForRepo 的 labels=none
|
||||
// 会被当作名为 none 的标签,不能用于扫描无 label issue。
|
||||
const query = `repo:${context.repo.owner}/${context.repo.repo} is:issue is:open no:label`;
|
||||
for await (const response of github.paginate.iterator(github.rest.search.issuesAndPullRequests, {
|
||||
q: query,
|
||||
per_page: 100,
|
||||
})) {
|
||||
for (const issue of response.data) {
|
||||
if (issue.pull_request) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const matched = getMatchedRule(issue);
|
||||
if (!matched) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: [matched.label],
|
||||
});
|
||||
core.info(`Added label "${matched.label}" to issue #${issue.number}.`);
|
||||
}
|
||||
}
|
||||
|
||||
close-issues:
|
||||
if: github.event_name != 'issues'
|
||||
needs: label-unlabeled-issues
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
@@ -30,4 +155,4 @@ jobs:
|
||||
# 排除带有RFC标签的issue
|
||||
exempt-issue-labels: "RFC"
|
||||
operations-per-run: 500
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -6,6 +6,7 @@
|
||||
build/
|
||||
cython_cache/
|
||||
dist/
|
||||
rust/**/target/
|
||||
nginx/
|
||||
test.py
|
||||
safety_report.txt
|
||||
@@ -19,7 +20,9 @@ config/cookies/**
|
||||
config/app.env
|
||||
config/user.db*
|
||||
config/sites/**
|
||||
config/agent/
|
||||
config/logs/
|
||||
config/plugins/
|
||||
config/temp/
|
||||
config/cache/
|
||||
.runtime/
|
||||
@@ -36,3 +39,5 @@ pylint-report.json
|
||||
|
||||
# AI
|
||||
.claude/
|
||||
!.claude/*.json
|
||||
.claude/settings.local.json
|
||||
|
||||
32
.pylintrc
32
.pylintrc
@@ -5,38 +5,30 @@ init-hook='import sys; sys.path.append(".")'
|
||||
# 忽略的文件和目录
|
||||
ignore=.git,__pycache__,.venv,build,dist,tests,docs
|
||||
|
||||
# 通过 `pylint app/` 检查主程序时不扫描内置插件目录,
|
||||
# 插件依赖和动态模型较多,容易产生与主程序无关的误报。
|
||||
ignore-paths=^app/plugins(/|$)
|
||||
|
||||
# 并行作业数量
|
||||
jobs=0
|
||||
|
||||
[MESSAGES CONTROL]
|
||||
# 只关注错误级别的问题,禁用警告、约定和重构建议
|
||||
# E = Error (错误) - 会导致构建失败
|
||||
# W = Warning (警告) - 仅显示,不会失败
|
||||
# R = Refactor (重构建议) - 仅显示,不会失败
|
||||
# C = Convention (约定) - 仅显示,不会失败
|
||||
# I = Information (信息) - 仅显示,不会失败
|
||||
|
||||
# 禁用大部分警告、约定和重构建议,只保留错误和重要警告
|
||||
# 只启用确定性较强的严重问题检查,避免 SQLAlchemy、FastAPI 依赖注入、
|
||||
# 第三方 SDK 等动态对象被 Pylint 推断成误报。
|
||||
disable=all
|
||||
enable=error,
|
||||
syntax-error,
|
||||
enable=syntax-error,
|
||||
undefined-variable,
|
||||
used-before-assignment,
|
||||
possibly-used-before-assignment,
|
||||
unreachable,
|
||||
return-outside-function,
|
||||
yield-outside-function,
|
||||
continue-in-finally,
|
||||
nonlocal-without-binding,
|
||||
undefined-loop-variable,
|
||||
redefined-builtin,
|
||||
not-callable,
|
||||
assignment-from-no-return,
|
||||
no-value-for-parameter,
|
||||
too-many-function-args,
|
||||
unexpected-keyword-arg,
|
||||
redundant-keyword-arg,
|
||||
import-error,
|
||||
relative-beyond-top-level
|
||||
relative-beyond-top-level,
|
||||
no-name-in-module
|
||||
|
||||
[REPORTS]
|
||||
# 设置报告格式
|
||||
@@ -80,4 +72,6 @@ ignore-imports=yes
|
||||
|
||||
[TYPECHECK]
|
||||
# 生成缺失成员提示的类列表
|
||||
generated-members=requests.packages.urllib3
|
||||
generated-members=requests.packages.urllib3
|
||||
# app.helper.sites 会主动隐藏模块属性枚举,避免误报 no-name-in-module
|
||||
ignored-modules=app.helper.sites
|
||||
|
||||
103
AGENTS.md
Normal file
103
AGENTS.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file is the primary instruction set for all AI agents and LLMs working in this repository. Local documentation takes precedence over general training data. You must follow this file and the rule documents it references.
|
||||
|
||||
---
|
||||
|
||||
## Task-to-Documentation Mapping
|
||||
|
||||
Before executing any task, identify the domain and load the corresponding document.
|
||||
|
||||
### Architectural Decisions
|
||||
* **Primary Reference:** `docs/rules/05-architecture.md`
|
||||
* **Required Constraints:** Respect layer boundaries and dependency flow. Do not introduce circular dependencies. Verify the correct layer for any new capability before implementing.
|
||||
|
||||
### Business Logic and Design Patterns
|
||||
* **Primary Reference:** `docs/rules/04-design-patterns.md`
|
||||
* **Required Constraints:** Use the project's established Module, Chain, Event, and Oper structural patterns. Do not introduce abstractions the project has not adopted.
|
||||
|
||||
### Coding Standards and Style
|
||||
* **Primary Reference:** `docs/rules/06-code-styles.md`
|
||||
* **Required Constraints:** Match the style of the surrounding file. Type annotations, Pydantic models, and async/await usage must all conform to the documented standards.
|
||||
|
||||
### Identifiers and Naming
|
||||
* **Primary Reference:** `docs/rules/07-naming-conventions.md`
|
||||
* **Required Constraints:** All filenames, class names, function names, and constants must follow the project's taxonomy. No arbitrary abbreviations or mixed casing styles.
|
||||
|
||||
### Comments and Documentation
|
||||
* **Primary Reference:** `docs/rules/08-comment-styles.md`
|
||||
* **Required Constraints:** All public classes and methods require Chinese docstrings. Comments must explain the *why*, not restate the code.
|
||||
* **⚠️ MANDATORY GATE:** Code that is missing proper Chinese docstrings on public interfaces is **REJECTED** at review. No exceptions.
|
||||
|
||||
### External Communication and Interfaces
|
||||
* **Primary Reference:** `docs/rules/09-external-response.md`
|
||||
* **Required Constraints:** All third-party HTTP requests must go through `RequestUtils`. Response formats must use the project's standard schemas. Error handling must follow the per-layer conventions.
|
||||
|
||||
### Data and Persistence
|
||||
* **Primary Reference:** `docs/rules/10-data-and-persistent.md`
|
||||
* **Required Constraints:** Any database model change requires a matching Alembic migration. Runtime configuration must be managed via `SystemConfigKey` + `SystemConfigOper`. Raw string keys are forbidden.
|
||||
|
||||
### Quality and Security
|
||||
* **Primary Reference:** `docs/rules/11-quality-and-security.md`
|
||||
* **Required Constraints:** All code changes must pass the relevant pytest tests and pylint checks. Dependency changes require a passing safety scan.
|
||||
|
||||
### Commands and Development Workflow
|
||||
* **Primary Reference:** `docs/rules/03-commands.md`
|
||||
* **Required Constraints:** Only suggest or execute commands documented in that file. Do not assume tool defaults or global flags.
|
||||
|
||||
---
|
||||
|
||||
## Agent Execution Rules
|
||||
|
||||
### Pre-Flight Check
|
||||
|
||||
Before generating any code or proposing changes, you must:
|
||||
|
||||
1. Identify the task domain (architecture / business logic / coding style / naming / comments / external interfaces / data / quality).
|
||||
2. Load the corresponding document from `docs/rules/`.
|
||||
3. Explicitly verify that your proposed solution does not violate the following three mandatory constraints:
|
||||
- **Naming Conventions (07):** Are all files, classes, functions, and constants named correctly?
|
||||
- **Architecture Boundaries (05):** Is the code placed in the correct layer? Are all call directions valid?
|
||||
- **Comment Standards (08):** Do all new public classes and methods include Chinese docstrings?
|
||||
|
||||
### Implementation Guidelines
|
||||
|
||||
* **Pattern Adherence:** Avoid generic boilerplate. If `04-design-patterns.md` defines a project-level pattern for a scenario, you are required to use it.
|
||||
* **Documentation Standards:** Docstring style for any new function or module must match `08-comment-styles.md`.
|
||||
* **⚠️ MANDATORY GATE:** Public classes, methods, and functions without proper Chinese docstrings are **REJECTED**. No exceptions.
|
||||
* **Command Reliance:** Only suggest commands listed in `03-commands.md`. Do not rely on inferred tool defaults.
|
||||
* **Minimal Change Principle:** Prefer the smallest correct change. Do not perform unrelated refactors, mass renames, or formatting-only cleanup.
|
||||
* **Output Language:** Summaries, validation results, and risk notes default to Chinese unless the user requests otherwise.
|
||||
|
||||
### Conflict Resolution
|
||||
|
||||
If existing code appears to contradict the documentation:
|
||||
|
||||
1. Stop implementation immediately.
|
||||
2. Identify the specific file and line of the contradiction.
|
||||
3. Prompt the user: "The documentation in `[File]` requires Pattern A, but the current implementation uses Pattern B. Which is the current standard?"
|
||||
|
||||
---
|
||||
|
||||
## Coupled Update Rules
|
||||
|
||||
When modifying the following, you must also update the listed artifacts:
|
||||
|
||||
| Changed Content | Must Also Update |
|
||||
|---|---|
|
||||
| CLI behavior | `moviepilot` entrypoint, `docs/cli.md`, related tests |
|
||||
| MCP / REST API, exposed tools | `docs/mcp-api.md`, `skills/*/SKILL.md`, related tests |
|
||||
| Dev workflow, dependency management, security checks | `docs/development-setup.md` |
|
||||
| Database model schema | New Alembic migration under `database/versions/` |
|
||||
| User-visible config or init flow | Related docs, help text, setup/init flows, tests |
|
||||
| New skill | Follow `skills/<name>/SKILL.md` structure, keep YAML front matter |
|
||||
|
||||
---
|
||||
|
||||
## Primary Entry Point
|
||||
|
||||
For the full documentation map and cross-references, refer to:
|
||||
|
||||
**[Documentation Hub Index](./docs/rules/README.md)**
|
||||
|
||||
*Last Updated: 2026-05-25*
|
||||
18
README.md
18
README.md
@@ -1,3 +1,4 @@
|
||||
|
||||
# MoviePilot
|
||||
|
||||
简体中文 | [English](README_EN.md)
|
||||
@@ -55,6 +56,23 @@ MCP工具API文档:详见 [docs/mcp-api.md](docs/mcp-api.md)
|
||||
|
||||
开发环境准备与本地源码运行说明:[`docs/development-setup.md`](docs/development-setup.md)
|
||||
|
||||
本地开发启用 Rust 加速扩展,需先安装 Rust toolchain 并确保 `cargo` 可用;未安装时项目会自动使用 Python 实现:
|
||||
|
||||
```shell
|
||||
cargo --version
|
||||
python -m pip install "maturin>=1.9,<2"
|
||||
python -m maturin develop --release --manifest-path rust/moviepilot_rust/Cargo.toml
|
||||
python -c "from app.utils import rust_accel; print(rust_accel.is_available())"
|
||||
```
|
||||
|
||||
如果输出 `True`,说明当前开发环境已经加载 `moviepilot_rust`。重新修改 Rust 代码后再次执行 `python -m maturin develop --release --manifest-path rust/moviepilot_rust/Cargo.toml` 即可更新本地扩展。
|
||||
|
||||
需要本地评估 Rust 加速效果时,可运行:
|
||||
|
||||
```shell
|
||||
python scripts/benchmark_rust_accel.py --loops 20 --repeat 5
|
||||
```
|
||||
|
||||
插件开发说明:<https://wiki.movie-pilot.org/zh/plugindev>
|
||||
|
||||
## 相关项目
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import asyncio
|
||||
import threading
|
||||
from typing import Optional, Tuple
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
|
||||
@@ -12,7 +12,7 @@ from app.schemas.message import (
|
||||
ChannelCapabilityManager,
|
||||
ChannelCapability,
|
||||
)
|
||||
from app.schemas.types import MessageChannel
|
||||
from app.schemas.types import MessageChannel, NotificationType
|
||||
|
||||
|
||||
class _StreamChain(ChainBase):
|
||||
@@ -61,17 +61,43 @@ class StreamingHandler:
|
||||
self._source: Optional[str] = None
|
||||
self._user_id: Optional[str] = None
|
||||
self._username: Optional[str] = None
|
||||
self._original_message_id: Optional[str] = None
|
||||
self._original_chat_id: Optional[str] = None
|
||||
self._title: str = ""
|
||||
self._allow_dispatch_without_context = False
|
||||
# 非啰嗦模式下的待输出工具统计,等下一段文本到来时再统一补一句摘要
|
||||
self._pending_tool_stats: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def emit(self, token: str):
|
||||
def set_dispatch_policy(
|
||||
self, allow_dispatch_without_context: bool = False
|
||||
) -> None:
|
||||
"""
|
||||
设置在缺少渠道上下文时是否仍允许向默认通知渠道分发消息。
|
||||
后台 DISPATCH 任务允许,CAPTURE_ONLY 必须禁止。
|
||||
"""
|
||||
self._allow_dispatch_without_context = allow_dispatch_without_context
|
||||
|
||||
def emit(self, token: str) -> str:
|
||||
"""
|
||||
接收 LLM 流式 token,积累到缓冲区。
|
||||
如果存在待输出的工具统计,则会先补上一句摘要再追加 token。
|
||||
"""
|
||||
with self._lock:
|
||||
emitted = token or ""
|
||||
|
||||
if self._pending_tool_stats:
|
||||
summary = self._consume_pending_tool_summary_locked()
|
||||
if summary:
|
||||
if emitted:
|
||||
emitted = f"{summary}{emitted.lstrip(chr(10))}"
|
||||
else:
|
||||
emitted = summary
|
||||
|
||||
# 如果存量消息结束是两个换行,则去掉新消息前面的换行,避免过多空行
|
||||
if self._buffer.endswith("\n\n") and token.startswith("\n"):
|
||||
token = token.lstrip("\n")
|
||||
self._buffer += token
|
||||
if self._buffer.endswith("\n\n") and emitted.startswith("\n"):
|
||||
emitted = emitted.lstrip("\n")
|
||||
self._buffer += emitted
|
||||
return emitted
|
||||
|
||||
async def take(self) -> str:
|
||||
"""
|
||||
@@ -82,6 +108,8 @@ class StreamingHandler:
|
||||
|
||||
注意:流式渠道不调用此方法,工具消息直接 emit 到 buffer 中。
|
||||
"""
|
||||
self.flush_pending_tool_summary()
|
||||
|
||||
with self._lock:
|
||||
if not self._buffer:
|
||||
return ""
|
||||
@@ -99,6 +127,7 @@ class StreamingHandler:
|
||||
self._sent_text = ""
|
||||
self._message_response = None
|
||||
self._msg_start_offset = 0
|
||||
self._pending_tool_stats = {}
|
||||
|
||||
def reset(self):
|
||||
"""
|
||||
@@ -112,6 +141,7 @@ class StreamingHandler:
|
||||
self._buffer = ""
|
||||
self._sent_text = ""
|
||||
self._msg_start_offset = 0
|
||||
self._pending_tool_stats = {}
|
||||
|
||||
async def start_streaming(
|
||||
self,
|
||||
@@ -119,6 +149,8 @@ class StreamingHandler:
|
||||
source: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
original_message_id: Optional[str] = None,
|
||||
original_chat_id: Optional[str] = None,
|
||||
title: str = "",
|
||||
):
|
||||
"""
|
||||
@@ -130,17 +162,22 @@ class StreamingHandler:
|
||||
:param user_id: 用户ID
|
||||
:param username: 用户名
|
||||
:param title: 消息标题
|
||||
:param original_message_id: 原始消息ID(如果是回复消息)
|
||||
:param original_chat_id: 原始聊天ID(如果是回复消息)
|
||||
"""
|
||||
self._channel = channel
|
||||
self._source = source
|
||||
self._user_id = user_id
|
||||
self._username = username
|
||||
self._original_message_id = original_message_id
|
||||
self._original_chat_id = original_chat_id
|
||||
self._title = title
|
||||
|
||||
self._streaming_enabled = True
|
||||
self._sent_text = ""
|
||||
self._message_response = None
|
||||
self._msg_start_offset = 0
|
||||
self._pending_tool_stats = {}
|
||||
|
||||
# 检查渠道是否支持消息编辑,不支持则仅收集 token 到 buffer,不实时推送
|
||||
if not self._can_stream():
|
||||
@@ -176,9 +213,19 @@ class StreamingHandler:
|
||||
# 取消定时任务
|
||||
await self._cancel_flush_task()
|
||||
|
||||
# 将未落地的工具统计补入缓冲区,避免流式结束时丢失这段执行信息
|
||||
self.flush_pending_tool_summary()
|
||||
|
||||
# 执行最后一次刷新
|
||||
await self._flush()
|
||||
|
||||
message_response = self._message_response
|
||||
if message_response:
|
||||
await run_in_threadpool(
|
||||
_StreamChain().finalize_message,
|
||||
message_response,
|
||||
)
|
||||
|
||||
# 检查是否所有缓冲内容都已发送
|
||||
with self._lock:
|
||||
# 当前消息的文本 = buffer 中从 _msg_start_offset 开始的部分
|
||||
@@ -194,11 +241,175 @@ class StreamingHandler:
|
||||
self._sent_text = ""
|
||||
self._message_response = None
|
||||
self._msg_start_offset = 0
|
||||
self._pending_tool_stats = {}
|
||||
if all_sent:
|
||||
# 所有内容已通过流式发送,清空缓冲区
|
||||
self._buffer = ""
|
||||
return all_sent, final_text
|
||||
|
||||
def record_tool_call(
|
||||
self,
|
||||
tool_name: str,
|
||||
tool_message: Optional[str] = None,
|
||||
tool_kwargs: Optional[dict[str, Any]] = None,
|
||||
):
|
||||
"""
|
||||
记录一次工具调用,供非啰嗦模式下延迟汇总输出。
|
||||
"""
|
||||
category, target = self._classify_tool_call(
|
||||
tool_name=tool_name,
|
||||
tool_message=tool_message,
|
||||
tool_kwargs=tool_kwargs or {},
|
||||
)
|
||||
with self._lock:
|
||||
bucket = self._pending_tool_stats.setdefault(
|
||||
category,
|
||||
{
|
||||
"count": 0,
|
||||
"targets": set(),
|
||||
},
|
||||
)
|
||||
bucket["count"] += 1
|
||||
if target:
|
||||
bucket["targets"].add(str(target))
|
||||
|
||||
def flush_pending_tool_summary(self) -> str:
|
||||
"""
|
||||
将待输出的工具统计摘要补入缓冲区,并返回本次新增的摘要文本。
|
||||
"""
|
||||
with self._lock:
|
||||
summary = self._consume_pending_tool_summary_locked()
|
||||
if summary:
|
||||
self._buffer += summary
|
||||
return summary
|
||||
|
||||
@staticmethod
|
||||
def _classify_tool_call(
|
||||
tool_name: str,
|
||||
tool_message: Optional[str],
|
||||
tool_kwargs: dict[str, Any],
|
||||
) -> tuple[str, Optional[str]]:
|
||||
tool_name = (tool_name or "").strip().lower()
|
||||
tool_message = (tool_message or "").strip()
|
||||
tool_message_lower = tool_message.lower()
|
||||
|
||||
if tool_name == "read_file":
|
||||
return "file_read", tool_kwargs.get("file_path")
|
||||
if tool_name in {"write_file", "edit_file"}:
|
||||
return "file_write", tool_kwargs.get("file_path")
|
||||
if tool_name in {"list_directory", "query_directory_settings"}:
|
||||
return "directory", tool_kwargs.get("path")
|
||||
if tool_name == "browse_webpage":
|
||||
return (
|
||||
"web_browse",
|
||||
tool_kwargs.get("url")
|
||||
or tool_kwargs.get("target_url")
|
||||
or tool_kwargs.get("path"),
|
||||
)
|
||||
if tool_name == "execute_command":
|
||||
return (
|
||||
"command",
|
||||
tool_kwargs.get("command") or tool_kwargs.get("session_id"),
|
||||
)
|
||||
if tool_name == "ask_user_choice":
|
||||
return "interaction", tool_kwargs.get("message")
|
||||
if tool_name.startswith("search_") or tool_name in {"get_search_results"}:
|
||||
return (
|
||||
"search",
|
||||
tool_kwargs.get("query")
|
||||
or tool_kwargs.get("title")
|
||||
or tool_kwargs.get("keyword"),
|
||||
)
|
||||
if tool_name.startswith("query_") or tool_name.startswith("list_") or tool_name.startswith("get_"):
|
||||
return "data_query", None
|
||||
if tool_name.startswith(("add_", "update_", "delete_", "modify_", "run_")):
|
||||
return "action", None
|
||||
if tool_name in {
|
||||
"recognize_media",
|
||||
"scrape_metadata",
|
||||
"transfer_file",
|
||||
"test_site",
|
||||
"send_message",
|
||||
"send_local_file",
|
||||
"send_voice_message",
|
||||
}:
|
||||
return "action", None
|
||||
|
||||
if "读取文件" in tool_message or "read file" in tool_message_lower:
|
||||
return "file_read", tool_kwargs.get("file_path")
|
||||
if (
|
||||
"写入文件" in tool_message
|
||||
or "编辑文件" in tool_message
|
||||
or "write file" in tool_message_lower
|
||||
or "edit file" in tool_message_lower
|
||||
):
|
||||
return "file_write", tool_kwargs.get("file_path")
|
||||
if "目录" in tool_message or "directory" in tool_message_lower:
|
||||
return "directory", tool_kwargs.get("path")
|
||||
if "搜索" in tool_message or "search" in tool_message_lower:
|
||||
return (
|
||||
"search",
|
||||
tool_kwargs.get("query")
|
||||
or tool_kwargs.get("title")
|
||||
or tool_kwargs.get("keyword"),
|
||||
)
|
||||
if "网页" in tool_message or "browser" in tool_message_lower or "webpage" in tool_message_lower:
|
||||
return "web_browse", tool_kwargs.get("url")
|
||||
if "命令" in tool_message or "command" in tool_message_lower:
|
||||
return "command", tool_kwargs.get("command")
|
||||
|
||||
return "tool", None
|
||||
|
||||
def _consume_pending_tool_summary_locked(self) -> str:
|
||||
if not self._pending_tool_stats:
|
||||
return ""
|
||||
|
||||
parts = []
|
||||
for category, bucket in self._pending_tool_stats.items():
|
||||
value = bucket["count"]
|
||||
if category in {"file_read", "file_write", "directory", "web_browse"} and bucket["targets"]:
|
||||
value = len(bucket["targets"])
|
||||
part = self._format_tool_stat(category, value)
|
||||
if part:
|
||||
parts.append(part)
|
||||
|
||||
self._pending_tool_stats = {}
|
||||
if not parts:
|
||||
return ""
|
||||
|
||||
summary = f"({','.join(parts)})"
|
||||
visible_buffer = self._buffer.rstrip(" \t")
|
||||
last_char = visible_buffer[-1:] if visible_buffer.strip() else ""
|
||||
prefix = ""
|
||||
if self._buffer and last_char != "\n":
|
||||
prefix = "\n\n"
|
||||
return f"{prefix}{summary}\n\n"
|
||||
|
||||
@staticmethod
|
||||
def _format_tool_stat(category: str, count: int) -> str:
|
||||
if count <= 0:
|
||||
return ""
|
||||
|
||||
if category == "search":
|
||||
return f"执行了 {count} 次搜索"
|
||||
if category == "file_read":
|
||||
return f"读取了 {count} 个文件"
|
||||
if category == "file_write":
|
||||
return f"修改了 {count} 个文件"
|
||||
if category == "directory":
|
||||
return f"查看了 {count} 个目录"
|
||||
if category == "web_browse":
|
||||
return f"浏览了 {count} 个网页"
|
||||
if category == "command":
|
||||
return f"执行了 {count} 条命令"
|
||||
if category == "data_query":
|
||||
return f"查询了 {count} 次数据"
|
||||
if category == "action":
|
||||
return f"执行了 {count} 次操作"
|
||||
if category == "interaction":
|
||||
return f"发起了 {count} 次交互"
|
||||
return f"调用了 {count} 次工具"
|
||||
|
||||
def _can_stream(self) -> bool:
|
||||
"""
|
||||
检查当前渠道是否支持流式输出(消息编辑)
|
||||
@@ -229,15 +440,23 @@ class StreamingHandler:
|
||||
|
||||
async def _cancel_flush_task(self):
|
||||
"""
|
||||
取消当前的定时刷新任务
|
||||
停止当前的定时刷新任务。
|
||||
|
||||
停止流式输出时,刷新任务可能已经在线程池里发出了首条消息。
|
||||
这里先等待该轮刷新自然完成,确保 message_id 等返回信息能落回本地状态;
|
||||
否则最终刷新会误以为尚未发送过消息,从而再次发送一条新消息。
|
||||
"""
|
||||
if self._flush_task and not self._flush_task.done():
|
||||
self._flush_task.cancel()
|
||||
current_task = asyncio.current_task()
|
||||
if (
|
||||
self._flush_task
|
||||
and not self._flush_task.done()
|
||||
and self._flush_task is not current_task
|
||||
):
|
||||
try:
|
||||
await self._flush_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._flush_task = None
|
||||
self._flush_task = None
|
||||
|
||||
async def _flush(self):
|
||||
"""
|
||||
@@ -252,6 +471,12 @@ class StreamingHandler:
|
||||
if not current_text or current_text == self._sent_text:
|
||||
# 没有新内容需要刷新
|
||||
return
|
||||
if (
|
||||
(not self._channel or not self._source)
|
||||
and not self._allow_dispatch_without_context
|
||||
):
|
||||
logger.debug("流式输出缺少渠道上下文,当前模式禁止外发消息")
|
||||
return
|
||||
|
||||
chain = _StreamChain()
|
||||
|
||||
@@ -263,8 +488,11 @@ class StreamingHandler:
|
||||
Notification(
|
||||
channel=self._channel,
|
||||
source=self._source,
|
||||
mtype=NotificationType.Agent,
|
||||
userid=self._user_id,
|
||||
username=self._username,
|
||||
original_message_id=self._original_message_id,
|
||||
original_chat_id=self._original_chat_id,
|
||||
title=self._title,
|
||||
text=current_text,
|
||||
),
|
||||
@@ -305,8 +533,11 @@ class StreamingHandler:
|
||||
Notification(
|
||||
channel=self._channel,
|
||||
source=self._source,
|
||||
mtype=NotificationType.Agent,
|
||||
userid=self._user_id,
|
||||
username=self._username,
|
||||
original_message_id=self._original_message_id,
|
||||
original_chat_id=self._original_chat_id,
|
||||
title=self._title,
|
||||
text=current_text,
|
||||
),
|
||||
@@ -336,6 +567,7 @@ class StreamingHandler:
|
||||
chat_id=self._message_response.chat_id,
|
||||
text=current_text,
|
||||
title=self._title,
|
||||
metadata=self._message_response.metadata,
|
||||
)
|
||||
if success:
|
||||
with self._lock:
|
||||
|
||||
19
app/agent/defaults/CURRENT_PERSONA.md
Normal file
19
app/agent/defaults/CURRENT_PERSONA.md
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
version: 3
|
||||
active_persona: default
|
||||
extra_context_files: []
|
||||
deprecated_phrases: []
|
||||
---
|
||||
# CURRENT_PERSONA
|
||||
|
||||
当前激活人格:`default`
|
||||
|
||||
运行时加载顺序固定如下:
|
||||
|
||||
1. 核心系统提示词(程序内置,不可运行时覆盖)
|
||||
2. `personas/<active_persona>/PERSONA.md`
|
||||
3. `extra_context_files`
|
||||
4. `memory/*.md`
|
||||
5. `activity/*.md`
|
||||
|
||||
`memory` 中的长期偏好可以细化回复方式,但不应覆盖系统核心身份、目标和安全边界。
|
||||
22
app/agent/defaults/personas/aloof/PERSONA.md
Normal file
22
app/agent/defaults/personas/aloof/PERSONA.md
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
version: 1
|
||||
persona_id: aloof
|
||||
label: 高冷
|
||||
description: 冷静、克制、低温度,话少但不失礼。
|
||||
aliases:
|
||||
- 冷淡
|
||||
- 冷感
|
||||
- 冷艳
|
||||
---
|
||||
# PERSONA
|
||||
|
||||
- Tone: cool, distant, and composed.
|
||||
- Keep emotional temperature low and transitions short.
|
||||
- Be brief and efficient, but do not become rude or contemptuous.
|
||||
- Prefer understatement over enthusiasm.
|
||||
|
||||
## RESPONSE_FORMAT
|
||||
|
||||
- Lead with the answer or the action result.
|
||||
- Keep explanations minimal unless the user explicitly asks for detail.
|
||||
- Avoid extra reassurance, hype, or emotional softening.
|
||||
22
app/agent/defaults/personas/anime/PERSONA.md
Normal file
22
app/agent/defaults/personas/anime/PERSONA.md
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
version: 1
|
||||
persona_id: anime
|
||||
label: 二次元
|
||||
description: 带一点 ACG 语感和戏剧化表达,但仍然以任务完成和清晰沟通为主。
|
||||
aliases:
|
||||
- 动漫风
|
||||
- ACG
|
||||
- 宅系
|
||||
---
|
||||
# PERSONA
|
||||
|
||||
- Tone: lively, stylized, and lightly dramatic, with a small amount of anime-flavored wording.
|
||||
- Keep the actual task handling grounded and practical; the style should stay mostly in phrasing.
|
||||
- You may occasionally use short ACG-like interjections, but do not flood the reply with memes, kaomoji, or niche jargon.
|
||||
- Stay readable first. If the task is serious, reduce the stylistic flavor automatically.
|
||||
|
||||
## RESPONSE_FORMAT
|
||||
|
||||
- Prefer short paragraphs or compact lists.
|
||||
- A light playful closing line is acceptable after the real result is already clear.
|
||||
- Do not let the style make operational instructions vague.
|
||||
22
app/agent/defaults/personas/catgirl/PERSONA.md
Normal file
22
app/agent/defaults/personas/catgirl/PERSONA.md
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
version: 1
|
||||
persona_id: catgirl
|
||||
label: 猫娘
|
||||
description: 带一点猫系拟人风格,轻松可爱,但不过度角色扮演。
|
||||
aliases:
|
||||
- 猫猫
|
||||
- 喵系
|
||||
- 猫耳
|
||||
---
|
||||
# PERSONA
|
||||
|
||||
- Tone: playful, cat-like, and cute, with occasional feline wording.
|
||||
- You may occasionally use a light "喵" style suffix or cat metaphor, but only sparingly.
|
||||
- Do not turn the reply into full roleplay; task clarity remains the primary goal.
|
||||
- If the content is operational, keep the answer direct first and add only a thin layer of style.
|
||||
|
||||
## RESPONSE_FORMAT
|
||||
|
||||
- Keep answers compact and readable.
|
||||
- Use only a very small amount of repeated verbal tic.
|
||||
- The result or action status should always appear before any playful flourish.
|
||||
23
app/agent/defaults/personas/concise/PERSONA.md
Normal file
23
app/agent/defaults/personas/concise/PERSONA.md
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
version: 1
|
||||
persona_id: concise
|
||||
label: 极简
|
||||
description: 更短、更硬朗,优先结论和动作,不主动展开背景解释。
|
||||
aliases:
|
||||
- 简洁
|
||||
- 干脆
|
||||
- 极简人格
|
||||
---
|
||||
# PERSONA
|
||||
|
||||
- Tone: terse, decisive, and highly compressed.
|
||||
- Prefer the shortest complete answer that still moves the task forward.
|
||||
- Default to one sentence when possible. Only use lists when they materially improve readability.
|
||||
- Avoid extra context, caveats, or teaching unless the user explicitly asks for explanation.
|
||||
- Keep transitions minimal and skip conversational softening.
|
||||
|
||||
## RESPONSE_FORMAT
|
||||
|
||||
- Lead with the conclusion or result.
|
||||
- For option lists, keep each item very short.
|
||||
- Do not repeat already-known context back to the user unless it is needed to disambiguate the action.
|
||||
22
app/agent/defaults/personas/cute/PERSONA.md
Normal file
22
app/agent/defaults/personas/cute/PERSONA.md
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
version: 1
|
||||
persona_id: cute
|
||||
label: 可爱
|
||||
description: 语气更亲和、更柔软、更讨喜,但不做重度角色扮演。
|
||||
aliases:
|
||||
- 软萌
|
||||
- 甜系
|
||||
- 亲和
|
||||
---
|
||||
# PERSONA
|
||||
|
||||
- Tone: warm, cheerful, and gently cute.
|
||||
- Sound approachable and pleasant, but keep the answer concise and useful.
|
||||
- Avoid baby talk, excessive repetition, or exaggerated emotive punctuation.
|
||||
- If the user asks for directness, keep the cute flavor minimal.
|
||||
|
||||
## RESPONSE_FORMAT
|
||||
|
||||
- Prefer friendly short paragraphs.
|
||||
- For lists, keep each item short and easy to read.
|
||||
- When something fails, explain it gently but clearly.
|
||||
24
app/agent/defaults/personas/default/PERSONA.md
Normal file
24
app/agent/defaults/personas/default/PERSONA.md
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
version: 1
|
||||
persona_id: default
|
||||
label: 默认
|
||||
description: 专业、克制、简洁,适合大多数日常媒体管理场景。
|
||||
aliases:
|
||||
- 专业
|
||||
- 默认人格
|
||||
---
|
||||
# PERSONA
|
||||
|
||||
- Tone: professional, concise, restrained.
|
||||
- Be direct. No unnecessary preamble, no repeating the user's words, no narrating internal reasoning.
|
||||
- Do not flatter the user, praise the question, or add emotional cushioning.
|
||||
- Do not use emojis, exclamation marks, cute language, or excessive apology.
|
||||
- Prefer short declarative sentences. Default to one or two short paragraphs; use lists only when they improve scanability.
|
||||
- Use Markdown for structured data. Use `inline code` for media titles and paths.
|
||||
|
||||
## RESPONSE_FORMAT
|
||||
|
||||
- Keep confirmations short.
|
||||
- For search or comparison results, prefer a brief list over a long paragraph.
|
||||
- Skip filler phrases like "Let me help you", "Here are the results", or "I found...".
|
||||
- When an error occurs, briefly state the blocker and the next best action.
|
||||
22
app/agent/defaults/personas/disdain/PERSONA.md
Normal file
22
app/agent/defaults/personas/disdain/PERSONA.md
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
version: 1
|
||||
persona_id: disdain
|
||||
label: 不屑
|
||||
description: 带一点嫌弃感和轻微毒舌,但必须保持可控和不越界。
|
||||
aliases:
|
||||
- 嫌弃
|
||||
- 毒舌
|
||||
- 鄙视链
|
||||
---
|
||||
# PERSONA
|
||||
|
||||
- Tone: dry, skeptical, and faintly dismissive.
|
||||
- Mild sarcasm is acceptable, but it must stay controlled and should never turn into direct insult or humiliation.
|
||||
- Prioritize sharp phrasing and low patience, while still giving the user the actual answer.
|
||||
- If the task is sensitive or the user is clearly frustrated, reduce the bite automatically.
|
||||
|
||||
## RESPONSE_FORMAT
|
||||
|
||||
- Keep answers crisp and pointed.
|
||||
- Use short, cutting observations only when they improve the style without harming clarity.
|
||||
- Always include the concrete result, instruction, or blocker.
|
||||
22
app/agent/defaults/personas/guide/PERSONA.md
Normal file
22
app/agent/defaults/personas/guide/PERSONA.md
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
version: 1
|
||||
persona_id: guide
|
||||
label: 说明型
|
||||
description: 在复杂问题上更愿意解释原因和步骤,但仍保持克制,不会无节制展开。
|
||||
aliases:
|
||||
- 讲解
|
||||
- 解释型
|
||||
- 教学
|
||||
---
|
||||
# PERSONA
|
||||
|
||||
- Tone: clear, structured, and mildly explanatory.
|
||||
- When the task is simple, stay concise. When the task is complex or the user asks why/how, provide a short explanation with visible structure.
|
||||
- Keep explanations practical and tied to the current decision, not generic theory.
|
||||
- Remain restrained: do not become chatty, cute, or overly warm.
|
||||
|
||||
## RESPONSE_FORMAT
|
||||
|
||||
- For non-trivial tasks, prefer short sections or a compact numbered list.
|
||||
- When describing tradeoffs, keep them concrete and action-oriented.
|
||||
- End with the actual outcome or next step, not a generic summary.
|
||||
23
app/agent/defaults/personas/moe/PERSONA.md
Normal file
23
app/agent/defaults/personas/moe/PERSONA.md
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
version: 1
|
||||
persona_id: moe
|
||||
label: 萌系
|
||||
description: 更轻小说感、更元气、更可爱,但仍然保持边界和专业度。
|
||||
aliases:
|
||||
- 萝莉风
|
||||
- 轻小说风
|
||||
- 元气少女
|
||||
- 萌萌
|
||||
---
|
||||
# PERSONA
|
||||
|
||||
- Tone: soft, upbeat, cute, and lightly playful.
|
||||
- Keep the personality in wording only; do not imitate a child, emphasize age, or use any sexualized framing.
|
||||
- Use cute particles or soft wording sparingly so the answer still feels useful instead of noisy.
|
||||
- When the task is urgent or technical, reduce the fluff and keep the result clear.
|
||||
|
||||
## RESPONSE_FORMAT
|
||||
|
||||
- Prefer short, bright sentences.
|
||||
- A small amount of cute phrasing is acceptable, but the final answer must still be easy to scan.
|
||||
- Do not bury the actual conclusion under roleplay language.
|
||||
33
app/agent/llm/__init__.py
Normal file
33
app/agent/llm/__init__.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Agent 内部使用的 LLM 适配层。"""
|
||||
|
||||
from app.agent.llm.helper import LLMHelper, LLMTestError, LLMTestTimeout
|
||||
from app.agent.llm.capability import (
|
||||
AgentCapabilityManager,
|
||||
AgentCapabilityProvider,
|
||||
AudioCapabilityProvider,
|
||||
MiMoAudioProvider,
|
||||
OpenAIChatAudioProvider,
|
||||
OpenAIAudioProvider,
|
||||
)
|
||||
from app.agent.llm.provider import (
|
||||
LLMProviderAuthError,
|
||||
LLMProviderError,
|
||||
LLMProviderManager,
|
||||
render_auth_result_html,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"LLMHelper",
|
||||
"AgentCapabilityManager",
|
||||
"AgentCapabilityProvider",
|
||||
"AudioCapabilityProvider",
|
||||
"LLMProviderAuthError",
|
||||
"LLMProviderError",
|
||||
"LLMProviderManager",
|
||||
"LLMTestError",
|
||||
"LLMTestTimeout",
|
||||
"MiMoAudioProvider",
|
||||
"OpenAIChatAudioProvider",
|
||||
"OpenAIAudioProvider",
|
||||
"render_auth_result_html",
|
||||
]
|
||||
684
app/agent/llm/capability.py
Normal file
684
app/agent/llm/capability.py
Normal file
@@ -0,0 +1,684 @@
|
||||
"""Agent 多模态能力 provider 与调度入口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import mimetypes
|
||||
import shutil
|
||||
import subprocess
|
||||
from abc import ABC
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
from app.utils.http import RequestUtils
|
||||
|
||||
|
||||
class AgentCapabilityProvider(ABC):
|
||||
"""Agent 能力 provider 基类,后续图片等能力可继续扩展到这里。"""
|
||||
|
||||
name: str
|
||||
|
||||
|
||||
class AudioCapabilityProvider(AgentCapabilityProvider):
|
||||
"""音频输入/输出能力 provider。"""
|
||||
|
||||
MAX_TRANSCRIBE_BYTES = 10 * 1024 * 1024
|
||||
|
||||
def is_available_for_audio_input(self) -> bool:
|
||||
"""是否可用于音频输入转写。"""
|
||||
return False
|
||||
|
||||
def is_available_for_audio_output(self) -> bool:
|
||||
"""是否可用于语音合成输出。"""
|
||||
return False
|
||||
|
||||
def transcribe_audio(self, content: bytes, filename: str = "input.ogg") -> Optional[str]:
|
||||
"""将音频字节转成文字。"""
|
||||
raise NotImplementedError
|
||||
|
||||
def synthesize_speech(self, text: str) -> Optional[Path]:
|
||||
"""将文字合成为可发送的音频文件。"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class OpenAIAudioProvider(AudioCapabilityProvider):
|
||||
"""OpenAI / OpenAI-compatible 音频 provider。"""
|
||||
|
||||
name = "openai"
|
||||
|
||||
@staticmethod
|
||||
def _build_client(api_key: str, base_url: Optional[str]):
|
||||
from openai import OpenAI
|
||||
|
||||
return OpenAI(api_key=api_key, base_url=base_url, max_retries=3)
|
||||
|
||||
@staticmethod
|
||||
def _input_credentials() -> tuple[Optional[str], Optional[str]]:
|
||||
return settings.AUDIO_INPUT_API_KEY, settings.AUDIO_INPUT_BASE_URL
|
||||
|
||||
@staticmethod
|
||||
def _output_credentials() -> tuple[Optional[str], Optional[str]]:
|
||||
return settings.AUDIO_OUTPUT_API_KEY, settings.AUDIO_OUTPUT_BASE_URL
|
||||
|
||||
def is_available_for_audio_input(self) -> bool:
|
||||
api_key, _ = self._input_credentials()
|
||||
return bool(api_key)
|
||||
|
||||
def is_available_for_audio_output(self) -> bool:
|
||||
api_key, _ = self._output_credentials()
|
||||
return bool(api_key)
|
||||
|
||||
def transcribe_audio(self, content: bytes, filename: str = "input.ogg") -> Optional[str]:
|
||||
if not content:
|
||||
return None
|
||||
if len(content) > self.MAX_TRANSCRIBE_BYTES:
|
||||
raise ValueError("语音文件超过 10MB,无法识别")
|
||||
|
||||
try:
|
||||
api_key, base_url = self._input_credentials()
|
||||
if not api_key:
|
||||
raise ValueError("音频输入 provider 未配置 API Key")
|
||||
client = self._build_client(api_key=api_key, base_url=base_url)
|
||||
audio_file = BytesIO(content)
|
||||
audio_file.name = filename
|
||||
response = client.audio.transcriptions.create(
|
||||
model=settings.AUDIO_INPUT_MODEL,
|
||||
file=audio_file,
|
||||
language=settings.AUDIO_INPUT_LANGUAGE or "zh",
|
||||
response_format="verbose_json",
|
||||
)
|
||||
text = getattr(response, "text", None)
|
||||
return text.strip() if text else None
|
||||
except Exception as err:
|
||||
logger.error(f"音频输入转写失败: provider={self.name}, error={err}")
|
||||
return None
|
||||
|
||||
def synthesize_speech(self, text: str) -> Optional[Path]:
|
||||
if not text:
|
||||
return None
|
||||
|
||||
try:
|
||||
api_key, base_url = self._output_credentials()
|
||||
if not api_key:
|
||||
raise ValueError("音频输出 provider 未配置 API Key")
|
||||
client = self._build_client(api_key=api_key, base_url=base_url)
|
||||
voice_dir = settings.TEMP_PATH / "voice"
|
||||
voice_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_path = voice_dir / f"{uuid4().hex}.opus"
|
||||
response = client.audio.speech.create(
|
||||
model=settings.AUDIO_OUTPUT_MODEL,
|
||||
voice=settings.AUDIO_OUTPUT_VOICE,
|
||||
input=text,
|
||||
response_format="opus",
|
||||
)
|
||||
response.write_to_file(output_path)
|
||||
return output_path
|
||||
except Exception as err:
|
||||
logger.error(f"音频输出合成失败: provider={self.name}, error={err}")
|
||||
return None
|
||||
|
||||
|
||||
class OpenAIChatAudioProvider(AudioCapabilityProvider):
|
||||
"""通过 OpenAI Chat Completions 兼容接口传入/返回音频的 provider。"""
|
||||
|
||||
name = "openai_chat_audio"
|
||||
DISPLAY_NAME = "OpenAI Chat Audio"
|
||||
DEFAULT_BASE_URL: Optional[str] = None
|
||||
DEFAULT_STT_MODEL: Optional[str] = None
|
||||
DEFAULT_TTS_MODEL: Optional[str] = None
|
||||
DEFAULT_VOICE = "alloy"
|
||||
AUDIO_RESPONSE_FORMAT = "wav"
|
||||
AUDIO_INPUT_DATA_URL = False
|
||||
INCLUDE_AUDIO_MODALITIES = True
|
||||
TTS_MESSAGE_ROLE = "user"
|
||||
SUPPORTED_STT_MODELS: Optional[frozenset[str]] = None
|
||||
SUPPORTED_TTS_MODELS: Optional[frozenset[str]] = None
|
||||
UNSUPPORTED_TTS_MODELS = frozenset()
|
||||
SUPPORTED_AUDIO_MIME_TYPES = {
|
||||
".flac": "audio/flac",
|
||||
".m4a": "audio/mp4",
|
||||
".mp3": "audio/mpeg",
|
||||
".ogg": "audio/ogg",
|
||||
".opus": "audio/ogg",
|
||||
".wav": "audio/wav",
|
||||
}
|
||||
|
||||
def _build_client(self, api_key: str, base_url: Optional[str]):
|
||||
from openai import OpenAI
|
||||
|
||||
return OpenAI(
|
||||
api_key=api_key,
|
||||
base_url=base_url or self.DEFAULT_BASE_URL,
|
||||
max_retries=3,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _input_credentials() -> tuple[Optional[str], Optional[str]]:
|
||||
return settings.AUDIO_INPUT_API_KEY, settings.AUDIO_INPUT_BASE_URL
|
||||
|
||||
@staticmethod
|
||||
def _output_credentials() -> tuple[Optional[str], Optional[str]]:
|
||||
return settings.AUDIO_OUTPUT_API_KEY, settings.AUDIO_OUTPUT_BASE_URL
|
||||
|
||||
def _normalize_stt_model(self) -> str:
|
||||
return self._normalize_model(
|
||||
model=settings.AUDIO_INPUT_MODEL,
|
||||
supported_models=self.SUPPORTED_STT_MODELS,
|
||||
default_model=self.DEFAULT_STT_MODEL,
|
||||
)
|
||||
|
||||
def _normalize_tts_model(self) -> str:
|
||||
return self._normalize_model(
|
||||
model=settings.AUDIO_OUTPUT_MODEL,
|
||||
supported_models=self.SUPPORTED_TTS_MODELS,
|
||||
default_model=self.DEFAULT_TTS_MODEL,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_model(
|
||||
model: Optional[str],
|
||||
supported_models: Optional[frozenset[str]],
|
||||
default_model: Optional[str],
|
||||
) -> str:
|
||||
model = (model or "").strip()
|
||||
if not model:
|
||||
return default_model or ""
|
||||
if supported_models is None:
|
||||
return model
|
||||
model_key = model.lower()
|
||||
if model_key in supported_models:
|
||||
return model_key
|
||||
return default_model or model
|
||||
|
||||
def _is_supported_tts_model(self) -> bool:
|
||||
model = self._normalize_tts_model()
|
||||
if not model:
|
||||
return False
|
||||
model_key = model.lower()
|
||||
if model_key in self.UNSUPPORTED_TTS_MODELS:
|
||||
return False
|
||||
return self.SUPPORTED_TTS_MODELS is None or model_key in self.SUPPORTED_TTS_MODELS
|
||||
|
||||
@classmethod
|
||||
def _guess_audio_mime_type(cls, filename: str) -> str:
|
||||
suffix = Path(filename or "").suffix.lower()
|
||||
if suffix in cls.SUPPORTED_AUDIO_MIME_TYPES:
|
||||
return cls.SUPPORTED_AUDIO_MIME_TYPES[suffix]
|
||||
mime_type, _ = mimetypes.guess_type(filename or "")
|
||||
return mime_type or "audio/ogg"
|
||||
|
||||
@staticmethod
|
||||
def _guess_audio_format(filename: str) -> str:
|
||||
suffix = Path(filename or "").suffix.lower().lstrip(".")
|
||||
if suffix == "opus":
|
||||
return "ogg"
|
||||
return suffix or "ogg"
|
||||
|
||||
def _build_audio_input_payload(self, content: bytes, filename: str) -> dict:
|
||||
"""按不同 Chat Audio 兼容形态构造 input_audio 内容。"""
|
||||
audio_data = base64.b64encode(content).decode("utf-8")
|
||||
if self.AUDIO_INPUT_DATA_URL:
|
||||
mime_type = self._guess_audio_mime_type(filename)
|
||||
return {"data": f"data:{mime_type};base64,{audio_data}"}
|
||||
return {
|
||||
"data": audio_data,
|
||||
"format": self._guess_audio_format(filename),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _extract_message_text(message) -> Optional[str]:
|
||||
"""兼容音频理解响应可能放在 content 或 reasoning_content 的情况。"""
|
||||
content = getattr(message, "content", None)
|
||||
if isinstance(content, str) and content.strip():
|
||||
return content.strip()
|
||||
|
||||
reasoning_content = getattr(message, "reasoning_content", None)
|
||||
if isinstance(reasoning_content, str) and reasoning_content.strip():
|
||||
return reasoning_content.strip()
|
||||
|
||||
extra = getattr(message, "model_extra", None)
|
||||
if isinstance(extra, dict):
|
||||
for key in ("content", "reasoning_content"):
|
||||
value = extra.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_audio_data(message) -> Optional[str]:
|
||||
audio = getattr(message, "audio", None)
|
||||
if isinstance(audio, dict):
|
||||
return audio.get("data")
|
||||
if audio is not None:
|
||||
return getattr(audio, "data", None)
|
||||
|
||||
extra = getattr(message, "model_extra", None)
|
||||
if isinstance(extra, dict) and isinstance(extra.get("audio"), dict):
|
||||
return extra["audio"].get("data")
|
||||
return None
|
||||
|
||||
def _convert_wav_to_opus(self, wav_path: Path) -> Optional[Path]:
|
||||
"""将 Chat Audio 返回的 WAV 转成 OGG/Opus,便于各通知渠道发送语音。"""
|
||||
if not shutil.which("ffmpeg"):
|
||||
return None
|
||||
|
||||
output_path = wav_path.with_suffix(".opus")
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
str(wav_path),
|
||||
"-ar",
|
||||
"48000",
|
||||
"-ac",
|
||||
"1",
|
||||
"-c:a",
|
||||
"libopus",
|
||||
str(output_path),
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
if result.returncode != 0 or not output_path.exists():
|
||||
logger.warning(
|
||||
"%s TTS 音频转 Opus 失败,将使用 WAV 原文件: returncode=%s, stderr=%s",
|
||||
self.DISPLAY_NAME,
|
||||
result.returncode,
|
||||
(result.stderr or "").strip()[:500],
|
||||
)
|
||||
return None
|
||||
return output_path
|
||||
|
||||
def is_available_for_audio_input(self) -> bool:
|
||||
api_key, _ = self._input_credentials()
|
||||
return bool(api_key)
|
||||
|
||||
def is_available_for_audio_output(self) -> bool:
|
||||
api_key, _ = self._output_credentials()
|
||||
return bool(api_key) and self._is_supported_tts_model()
|
||||
|
||||
def transcribe_audio(self, content: bytes, filename: str = "input.ogg") -> Optional[str]:
|
||||
if not content:
|
||||
return None
|
||||
if len(content) > self.MAX_TRANSCRIBE_BYTES:
|
||||
raise ValueError("语音文件超过 10MB,无法识别")
|
||||
|
||||
try:
|
||||
api_key, base_url = self._input_credentials()
|
||||
if not api_key:
|
||||
raise ValueError("音频输入 provider 未配置 API Key")
|
||||
client = self._build_client(api_key=api_key, base_url=base_url)
|
||||
language = (settings.AUDIO_INPUT_LANGUAGE or "").strip()
|
||||
prompt = "请将这段音频完整转写为文字,只输出转写结果,不要添加解释。"
|
||||
if language:
|
||||
prompt += f"音频主要语言是 {language}。"
|
||||
|
||||
completion = client.chat.completions.create(
|
||||
model=self._normalize_stt_model(),
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_audio",
|
||||
"input_audio": self._build_audio_input_payload(
|
||||
content=content, filename=filename
|
||||
),
|
||||
},
|
||||
{"type": "text", "text": prompt},
|
||||
],
|
||||
}
|
||||
],
|
||||
max_completion_tokens=2048,
|
||||
)
|
||||
return self._extract_message_text(completion.choices[0].message)
|
||||
except Exception as err:
|
||||
logger.error(f"音频输入转写失败: provider={self.name}, error={err}")
|
||||
return None
|
||||
|
||||
def synthesize_speech(self, text: str) -> Optional[Path]:
|
||||
if not text:
|
||||
return None
|
||||
if not self._is_supported_tts_model():
|
||||
logger.error(
|
||||
"%s TTS 当前不支持该模型或模型未配置: %s",
|
||||
self.DISPLAY_NAME,
|
||||
settings.AUDIO_OUTPUT_MODEL,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
api_key, base_url = self._output_credentials()
|
||||
if not api_key:
|
||||
raise ValueError("音频输出 provider 未配置 API Key")
|
||||
client = self._build_client(api_key=api_key, base_url=base_url)
|
||||
voice_dir = settings.TEMP_PATH / "voice"
|
||||
voice_dir.mkdir(parents=True, exist_ok=True)
|
||||
wav_path = voice_dir / f"{uuid4().hex}.wav"
|
||||
request = {
|
||||
"model": self._normalize_tts_model(),
|
||||
"messages": [
|
||||
{
|
||||
"role": self.TTS_MESSAGE_ROLE,
|
||||
"content": text,
|
||||
}
|
||||
],
|
||||
"audio": {
|
||||
"format": self.AUDIO_RESPONSE_FORMAT,
|
||||
"voice": settings.AUDIO_OUTPUT_VOICE or self.DEFAULT_VOICE,
|
||||
},
|
||||
}
|
||||
if self.INCLUDE_AUDIO_MODALITIES:
|
||||
request["modalities"] = ["text", "audio"]
|
||||
completion = client.chat.completions.create(**request)
|
||||
audio_data = self._extract_audio_data(completion.choices[0].message)
|
||||
if not audio_data:
|
||||
raise ValueError(f"{self.DISPLAY_NAME} TTS 响应中没有音频数据")
|
||||
|
||||
wav_path.write_bytes(base64.b64decode(audio_data))
|
||||
return self._convert_wav_to_opus(wav_path) or wav_path
|
||||
except Exception as err:
|
||||
logger.error(f"音频输出合成失败: provider={self.name}, error={err}")
|
||||
return None
|
||||
|
||||
|
||||
class MiMoAudioProvider(OpenAIChatAudioProvider):
|
||||
"""Xiaomi MiMo Chat Audio 预设,仅接入普通 STT/TTS 能力。"""
|
||||
|
||||
name = "mimo"
|
||||
DISPLAY_NAME = "Xiaomi MiMo"
|
||||
DEFAULT_BASE_URL = "https://api.xiaomimimo.com/v1"
|
||||
DEFAULT_STT_MODEL = "mimo-v2.5"
|
||||
DEFAULT_TTS_MODEL = "mimo-v2.5-tts"
|
||||
DEFAULT_VOICE = "mimo_default"
|
||||
AUDIO_INPUT_DATA_URL = True
|
||||
INCLUDE_AUDIO_MODALITIES = False
|
||||
TTS_MESSAGE_ROLE = "assistant"
|
||||
SUPPORTED_STT_MODELS = frozenset({"mimo-v2.5", "mimo-v2-omni"})
|
||||
SUPPORTED_TTS_MODELS = frozenset({DEFAULT_TTS_MODEL})
|
||||
UNSUPPORTED_TTS_MODELS = frozenset(
|
||||
{
|
||||
"mimo-v2.5-tts-voiceclone",
|
||||
"mimo-v2.5-tts-voicedesign",
|
||||
}
|
||||
)
|
||||
|
||||
def _normalize_tts_model(self) -> str:
|
||||
model = (settings.AUDIO_OUTPUT_MODEL or "").strip().lower()
|
||||
if not model or not model.startswith("mimo-"):
|
||||
return self.DEFAULT_TTS_MODEL
|
||||
return model
|
||||
|
||||
|
||||
class MiniMaxAudioProvider(OpenAIChatAudioProvider):
|
||||
"""MiniMax 音频 provider,语音合成使用官方 T2A HTTP 接口。"""
|
||||
|
||||
name = "minimax"
|
||||
DISPLAY_NAME = "MiniMax"
|
||||
DEFAULT_BASE_URL = "https://api.minimaxi.com/v1"
|
||||
DEFAULT_STT_MODEL = "MiniMax-M2.7"
|
||||
DEFAULT_TTS_MODEL = "speech-2.8-turbo"
|
||||
DEFAULT_VOICE = "Chinese (Mandarin)_Lyrical_Voice"
|
||||
AUDIO_INPUT_DATA_URL = True
|
||||
SUPPORTED_TTS_MODELS = frozenset(
|
||||
{
|
||||
"speech-2.8-hd",
|
||||
"speech-2.8-turbo",
|
||||
"speech-2.6-hd",
|
||||
"speech-2.6-turbo",
|
||||
"speech-02-hd",
|
||||
"speech-02-turbo",
|
||||
"speech-01-hd",
|
||||
"speech-01-turbo",
|
||||
}
|
||||
)
|
||||
|
||||
def _build_client(self, api_key: str, base_url: Optional[str]):
|
||||
"""构建 MiniMax OpenAI 兼容客户端,兼容用户误填 Anthropic 端点的情况。"""
|
||||
from openai import OpenAI
|
||||
|
||||
return OpenAI(
|
||||
api_key=api_key,
|
||||
base_url=self._normalize_api_base_url(base_url),
|
||||
max_retries=3,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _normalize_api_base_url(cls, base_url: Optional[str]) -> str:
|
||||
"""归一化 MiniMax API 基础 URL,确保后续可以拼接 OpenAI/T2A 路径。"""
|
||||
normalized = (base_url or cls.DEFAULT_BASE_URL).strip().rstrip("/")
|
||||
if normalized.endswith("/t2a_v2"):
|
||||
normalized = normalized[: -len("/t2a_v2")]
|
||||
for suffix in ("/anthropic/v1", "/openai/v1"):
|
||||
if normalized.endswith(suffix):
|
||||
return normalized[: -len(suffix)] + "/v1"
|
||||
if not normalized.endswith("/v1"):
|
||||
normalized = f"{normalized}/v1"
|
||||
return normalized
|
||||
|
||||
@classmethod
|
||||
def _build_t2a_url(cls, base_url: Optional[str]) -> str:
|
||||
"""生成 MiniMax 同步 T2A 接口地址。"""
|
||||
return f"{cls._normalize_api_base_url(base_url)}/t2a_v2"
|
||||
|
||||
def _normalize_stt_model(self) -> str:
|
||||
"""将非 MiniMax 的默认转写模型名兜底为 MiniMax 对话模型。"""
|
||||
model = (settings.AUDIO_INPUT_MODEL or "").strip()
|
||||
if not model or model.lower().startswith(("gpt-", "mimo-")):
|
||||
return self.DEFAULT_STT_MODEL
|
||||
return model
|
||||
|
||||
def _normalize_tts_model(self) -> str:
|
||||
"""将非 MiniMax 语音模型兜底为官方 T2A 模型。"""
|
||||
model = (settings.AUDIO_OUTPUT_MODEL or "").strip().lower()
|
||||
if model in self.SUPPORTED_TTS_MODELS:
|
||||
return model
|
||||
return self.DEFAULT_TTS_MODEL
|
||||
|
||||
def _normalize_voice_id(self) -> str:
|
||||
"""将其他 provider 的默认音色兜底为 MiniMax 中文系统音色。"""
|
||||
voice_id = (settings.AUDIO_OUTPUT_VOICE or "").strip()
|
||||
if not voice_id or voice_id in {"alloy", "mimo_default"}:
|
||||
return self.DEFAULT_VOICE
|
||||
return voice_id
|
||||
|
||||
@staticmethod
|
||||
def _decode_audio_payload(audio_data: str) -> bytes:
|
||||
"""解析 MiniMax T2A 返回的音频数据,优先按官方 hex 格式处理。"""
|
||||
normalized = "".join((audio_data or "").split())
|
||||
try:
|
||||
return bytes.fromhex(normalized)
|
||||
except ValueError:
|
||||
return base64.b64decode(audio_data)
|
||||
|
||||
@staticmethod
|
||||
def _extract_minimax_error(data: dict[str, Any]) -> Optional[str]:
|
||||
"""提取 MiniMax base_resp 错误信息,成功响应返回 None。"""
|
||||
base_resp = data.get("base_resp") or {}
|
||||
status_code = base_resp.get("status_code")
|
||||
if status_code in (None, 0, "0"):
|
||||
return None
|
||||
status_msg = base_resp.get("status_msg") or "unknown error"
|
||||
return f"{status_code}: {status_msg}"
|
||||
|
||||
def synthesize_speech(self, text: str) -> Optional[Path]:
|
||||
"""调用 MiniMax T2A HTTP 接口合成语音文件。"""
|
||||
if not text:
|
||||
return None
|
||||
|
||||
try:
|
||||
api_key, base_url = self._output_credentials()
|
||||
if not api_key:
|
||||
raise ValueError("音频输出 provider 未配置 API Key")
|
||||
response = RequestUtils(
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
proxies=settings.PROXY or {},
|
||||
timeout=60,
|
||||
).post_res(
|
||||
url=self._build_t2a_url(base_url),
|
||||
json={
|
||||
"model": self._normalize_tts_model(),
|
||||
"text": text,
|
||||
"stream": False,
|
||||
"language_boost": "auto",
|
||||
"output_format": "hex",
|
||||
"voice_setting": {
|
||||
"voice_id": self._normalize_voice_id(),
|
||||
"speed": 1,
|
||||
"vol": 1,
|
||||
"pitch": 0,
|
||||
},
|
||||
"audio_setting": {
|
||||
"sample_rate": 32000,
|
||||
"bitrate": 128000,
|
||||
"format": "opus",
|
||||
"channel": 1,
|
||||
},
|
||||
},
|
||||
)
|
||||
if not response:
|
||||
raise ValueError("MiniMax T2A 请求无响应")
|
||||
if response.status_code >= 400:
|
||||
raise ValueError(f"MiniMax T2A HTTP {response.status_code}")
|
||||
|
||||
result = response.json()
|
||||
minimax_error = self._extract_minimax_error(result)
|
||||
if minimax_error:
|
||||
raise ValueError(f"MiniMax T2A 返回错误: {minimax_error}")
|
||||
|
||||
audio_data = ((result.get("data") or {}).get("audio") or "").strip()
|
||||
if not audio_data:
|
||||
raise ValueError("MiniMax T2A 响应中没有音频数据")
|
||||
|
||||
voice_dir = settings.TEMP_PATH / "voice"
|
||||
voice_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_path = voice_dir / f"{uuid4().hex}.opus"
|
||||
output_path.write_bytes(self._decode_audio_payload(audio_data))
|
||||
return output_path
|
||||
except Exception as err:
|
||||
logger.error(f"音频输出合成失败: provider={self.name}, error={err}")
|
||||
return None
|
||||
|
||||
|
||||
class AgentCapabilityManager:
|
||||
"""Agent 能力统一入口。"""
|
||||
|
||||
REPLY_MODE_NATIVE = "native_voice"
|
||||
REPLY_MODE_TEXT = "text"
|
||||
_audio_providers: Dict[str, AudioCapabilityProvider] = {
|
||||
OpenAIAudioProvider.name: OpenAIAudioProvider(),
|
||||
OpenAIChatAudioProvider.name: OpenAIChatAudioProvider(),
|
||||
MiMoAudioProvider.name: MiMoAudioProvider(),
|
||||
MiniMaxAudioProvider.name: MiniMaxAudioProvider(),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def register_audio_provider(cls, provider: AudioCapabilityProvider) -> None:
|
||||
"""注册新的音频 provider。"""
|
||||
cls._audio_providers[provider.name.lower()] = provider
|
||||
|
||||
@classmethod
|
||||
def get_registered_audio_providers(cls) -> list[str]:
|
||||
"""返回已注册的音频 provider 名称。"""
|
||||
return sorted(cls._audio_providers.keys())
|
||||
|
||||
@staticmethod
|
||||
def _normalize_provider_name(provider: Optional[str]) -> str:
|
||||
return (provider or "openai").strip().lower()
|
||||
|
||||
@classmethod
|
||||
def get_audio_provider(cls, mode: str) -> Optional[AudioCapabilityProvider]:
|
||||
provider_name = cls._normalize_provider_name(
|
||||
settings.AUDIO_INPUT_PROVIDER
|
||||
if (mode or "").lower() == "input"
|
||||
else settings.AUDIO_OUTPUT_PROVIDER
|
||||
)
|
||||
provider = cls._audio_providers.get(provider_name)
|
||||
if provider:
|
||||
return provider
|
||||
logger.warning("未注册音频 provider: mode=%s, provider=%s", mode, provider_name)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def supports_image_input() -> bool:
|
||||
"""当前 Agent 是否启用图片输入能力。"""
|
||||
return bool(settings.LLM_SUPPORT_IMAGE_INPUT)
|
||||
|
||||
@staticmethod
|
||||
def supports_audio_input() -> bool:
|
||||
"""当前 Agent 是否启用音频输入能力。"""
|
||||
return bool(settings.LLM_SUPPORT_AUDIO_INPUT)
|
||||
|
||||
@staticmethod
|
||||
def supports_audio_output() -> bool:
|
||||
"""当前 Agent 是否启用音频输出能力。"""
|
||||
return bool(settings.LLM_SUPPORT_AUDIO_OUTPUT)
|
||||
|
||||
@classmethod
|
||||
def is_audio_input_available(cls) -> bool:
|
||||
if not cls.supports_audio_input():
|
||||
return False
|
||||
provider = cls.get_audio_provider("input")
|
||||
return bool(provider and provider.is_available_for_audio_input())
|
||||
|
||||
@classmethod
|
||||
def is_audio_output_available(cls) -> bool:
|
||||
if not cls.supports_audio_output():
|
||||
return False
|
||||
provider = cls.get_audio_provider("output")
|
||||
return bool(provider and provider.is_available_for_audio_output())
|
||||
|
||||
@classmethod
|
||||
def transcribe_audio(cls, content: bytes, filename: str = "input.ogg") -> Optional[str]:
|
||||
provider = cls.get_audio_provider("input")
|
||||
if not provider or not cls.is_audio_input_available():
|
||||
return None
|
||||
return provider.transcribe_audio(content=content, filename=filename)
|
||||
|
||||
@classmethod
|
||||
def synthesize_speech(cls, text: str) -> Optional[Path]:
|
||||
provider = cls.get_audio_provider("output")
|
||||
if not provider or not cls.is_audio_output_available():
|
||||
return None
|
||||
return provider.synthesize_speech(text=text)
|
||||
|
||||
@classmethod
|
||||
def resolve_reply_mode(cls, channel: Optional[str], source: Optional[str]) -> str:
|
||||
"""仅在支持原生语音回复的渠道上发送音频,其余渠道回退文字。"""
|
||||
if cls.supports_native_voice_reply(channel=channel, source=source):
|
||||
return cls.REPLY_MODE_NATIVE
|
||||
return cls.REPLY_MODE_TEXT
|
||||
|
||||
@classmethod
|
||||
def supports_native_voice_reply(
|
||||
cls, channel: Optional[str], source: Optional[str]
|
||||
) -> bool:
|
||||
"""判断当前渠道是否支持原生语音消息发送。"""
|
||||
if not channel:
|
||||
return False
|
||||
|
||||
from app.helper.service import ServiceConfigHelper
|
||||
from app.schemas.types import MessageChannel
|
||||
|
||||
try:
|
||||
channel_enum = MessageChannel(channel)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
if channel_enum == MessageChannel.Telegram:
|
||||
return True
|
||||
if channel_enum != MessageChannel.Wechat:
|
||||
return False
|
||||
|
||||
# 企业微信 bot 模式不支持发送语音,只有应用模式可用。
|
||||
for config in ServiceConfigHelper.get_notification_configs():
|
||||
if config.name != source:
|
||||
continue
|
||||
return (config.config or {}).get("WECHAT_MODE", "app") != "bot"
|
||||
return False
|
||||
@@ -7,7 +7,7 @@ import time
|
||||
from functools import wraps
|
||||
from typing import Any, List
|
||||
|
||||
from langchain_core.messages import AIMessage
|
||||
from langchain_core.messages import AIMessage, AIMessageChunk
|
||||
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
@@ -32,29 +32,87 @@ class LLMTestTimeout(TimeoutError):
|
||||
def _patch_gemini_thought_signature():
|
||||
"""
|
||||
修复 langchain-google-genai 中 Gemini 2.5 思考模型的 thought_signature 兼容问题。
|
||||
langchain-google-genai 的 _is_gemini_3_or_later() 仅检查 "gemini-3",
|
||||
导致 Gemini 2.5 思考模型(如 gemini-2.5-flash、gemini-2.5-pro)在工具调用时
|
||||
缺少 thought_signature 而报错 400。
|
||||
此补丁将检查范围扩展到 Gemini 2.5 模型。
|
||||
|
||||
问题 1:_is_gemini_3_or_later() 仅检查 "gemini-3",不包含 Gemini 2.5 模型,
|
||||
导致 _parse_chat_history 的 thought_signature 强制注入逻辑被跳过。
|
||||
|
||||
问题 2:强制注入逻辑使用 first_fc_seen 标志,只给每个 model 消息中
|
||||
第一个缺少 thought_signature 的 function_call 补 dummy,后续并行
|
||||
function_call 仍缺失签名,导致 Gemini API 返回 400。
|
||||
|
||||
此补丁同时修复以上两个问题。
|
||||
"""
|
||||
try:
|
||||
import langchain_google_genai.chat_models as _cm
|
||||
|
||||
# 检查版本:需要 >= 4.0 才支持 _is_gemini_3_or_later
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
_version = version("langchain-google-genai") or ""
|
||||
except Exception:
|
||||
_version = ""
|
||||
try:
|
||||
_major = int(_version.split(".")[0]) if _version else 0
|
||||
except (ValueError, TypeError):
|
||||
_major = 0
|
||||
if _major < 4:
|
||||
logger.error(
|
||||
f"langchain-google-genai 版本 {_version or '未知'} 过旧,"
|
||||
f"不支持 Gemini 2.5+ 模型的 thought_signature 处理,"
|
||||
f"请升级到 4.2.3+:pip install langchain-google-genai~=4.2.3"
|
||||
)
|
||||
return
|
||||
|
||||
# 仅在未修补时执行
|
||||
if getattr(_cm, "_thought_signature_patched", False):
|
||||
return
|
||||
|
||||
if not hasattr(_cm, "_is_gemini_3_or_later"):
|
||||
logger.error(
|
||||
"langchain-google-genai 缺少 _is_gemini_3_or_later,"
|
||||
"无法修补 thought_signature 兼容性,请检查包版本"
|
||||
)
|
||||
return
|
||||
|
||||
# 补丁 1:扩展 _is_gemini_3_or_later,使 Gemini 2.5 模型也能触发
|
||||
# _parse_chat_history 中的 thought_signature 强制注入逻辑
|
||||
def _patched_is_gemini_3_or_later(model_name: str) -> bool:
|
||||
if not model_name:
|
||||
return False
|
||||
name = model_name.lower().replace("models/", "")
|
||||
# Gemini 2.5 思考模型也需要 thought_signature 支持
|
||||
return "gemini-3" in name or "gemini-2.5" in name
|
||||
|
||||
_cm._is_gemini_3_or_later = _patched_is_gemini_3_or_later
|
||||
|
||||
# 补丁 2:修复 _parse_chat_history 中 first_fc_seen 只修复第一个
|
||||
# function_call 的问题。用 wrapper 在原函数返回后,确保所有 model
|
||||
# 消息中所有 function_call 都带有 thought_signature。
|
||||
_original_parse_chat_history = _cm._parse_chat_history
|
||||
|
||||
def _patched_parse_chat_history(*args, **kwargs):
|
||||
result = _original_parse_chat_history(*args, **kwargs)
|
||||
system_instruction, formatted_messages = result
|
||||
|
||||
# 从参数中提取 model 名称
|
||||
model = kwargs.get("model")
|
||||
if model is None and len(args) >= 4:
|
||||
model = args[3]
|
||||
|
||||
if model and _patched_is_gemini_3_or_later(model):
|
||||
dummy = _cm.DUMMY_THOUGHT_SIGNATURE
|
||||
for content_msg in formatted_messages:
|
||||
if content_msg.role == "model":
|
||||
for part in content_msg.parts or []:
|
||||
if part.function_call and not part.thought_signature:
|
||||
part.thought_signature = dummy
|
||||
|
||||
return result
|
||||
|
||||
_cm._parse_chat_history = _patched_parse_chat_history
|
||||
_cm._thought_signature_patched = True
|
||||
logger.debug(
|
||||
"已修补 langchain-google-genai thought_signature 兼容性(覆盖 Gemini 2.5 模型)"
|
||||
"已修补 langchain-google-genai thought_signature 兼容性"
|
||||
"(覆盖 Gemini 2.5 模型 + 修复并行 function_call 签名缺失)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"修补 langchain-google-genai thought_signature 失败: {e}")
|
||||
@@ -142,9 +200,15 @@ def _patch_deepseek_reasoning_content_support():
|
||||
def _patched_get_request_payload(self, input_, *, stop=None, **kwargs):
|
||||
payload = original_get_request_payload(self, input_, stop=stop, **kwargs)
|
||||
|
||||
# Resolve original messages so we can extract reasoning_content from
|
||||
# additional_kwargs. The parent's payload builder does not propagate
|
||||
# this DeepSeek-specific field.
|
||||
extra_body = (getattr(self, "model_kwargs", None) or {}).get("extra_body")
|
||||
if not _is_deepseek_thinking_enabled(
|
||||
getattr(self, "model_name", None) or getattr(self, "model", None),
|
||||
extra_body,
|
||||
):
|
||||
return payload
|
||||
|
||||
# 从原始 LangChain 消息中取回 reasoning_content。上游 payload 构造器
|
||||
# 不会自动透传这个 DeepSeek 扩展字段。
|
||||
messages = self._convert_input(input_).to_messages()
|
||||
|
||||
for i, message in enumerate(payload["messages"]):
|
||||
@@ -152,9 +216,8 @@ def _patch_deepseek_reasoning_content_support():
|
||||
message["content"] = json.dumps(message["content"])
|
||||
elif message["role"] == "assistant":
|
||||
if isinstance(message["content"], list):
|
||||
# DeepSeek API expects assistant content to be a string,
|
||||
# not a list. Extract text blocks and join them, or use
|
||||
# empty string if none exist.
|
||||
# DeepSeek API 要求 assistant content 为字符串;工具场景下
|
||||
# LangChain 可能保留为内容块列表,这里只拼回可见文本块。
|
||||
text_parts = [
|
||||
block.get("text", "")
|
||||
for block in message["content"]
|
||||
@@ -162,10 +225,8 @@ def _patch_deepseek_reasoning_content_support():
|
||||
]
|
||||
message["content"] = "".join(text_parts) if text_parts else ""
|
||||
|
||||
# DeepSeek reasoning models require every assistant message to
|
||||
# carry a reasoning_content field (even when empty). The value
|
||||
# is stored in AIMessage.additional_kwargs by
|
||||
# _create_chat_result(); re-inject it into the API payload.
|
||||
# DeepSeek thinking mode 要求历史 assistant 消息携带
|
||||
# reasoning_content,即便本地只保存到了 additional_kwargs。
|
||||
if (
|
||||
"reasoning_content" not in message
|
||||
and i < len(messages)
|
||||
@@ -182,6 +243,176 @@ def _patch_deepseek_reasoning_content_support():
|
||||
logger.debug("已修补 langchain-deepseek thinking tool-call 的 reasoning_content 回传兼容性")
|
||||
|
||||
|
||||
def _patch_openai_interleaved_reasoning_content_support():
|
||||
"""
|
||||
修补 OpenAI-compatible 模型的 interleaved reasoning 内容回传。
|
||||
|
||||
小米 MiMo、部分 Kimi/GLM 等兼容端点会把思考内容放在响应顶层
|
||||
`reasoning_content` 字段;如果下一轮请求没有把它随历史 assistant
|
||||
消息带回,工具调用后续请求会被服务端以 400 拒绝。
|
||||
|
||||
这里不按 provider 白名单判断,而是只在历史 AIMessage 真实保存过
|
||||
`reasoning_content` 时回传,避免以后每接入一个同类模型都要单独适配。
|
||||
"""
|
||||
try:
|
||||
import langchain_openai.chat_models.base as _openai_base
|
||||
from langchain_openai import ChatOpenAI
|
||||
except Exception as err:
|
||||
logger.debug(f"跳过 langchain-openai reasoning_content 修补:{err}")
|
||||
return
|
||||
|
||||
if not getattr(_openai_base, "_moviepilot_reasoning_response_patched", False):
|
||||
original_convert_dict = getattr(_openai_base, "_convert_dict_to_message", None)
|
||||
original_convert_delta = getattr(
|
||||
_openai_base, "_convert_delta_to_message_chunk", None
|
||||
)
|
||||
|
||||
if callable(original_convert_dict):
|
||||
@wraps(original_convert_dict)
|
||||
def _patched_convert_dict_to_message(message_dict):
|
||||
message = original_convert_dict(message_dict)
|
||||
if (
|
||||
isinstance(message, AIMessage)
|
||||
and "reasoning_content" in message_dict
|
||||
):
|
||||
message.additional_kwargs["reasoning_content"] = (
|
||||
message_dict.get("reasoning_content") or ""
|
||||
)
|
||||
return message
|
||||
|
||||
_openai_base._convert_dict_to_message = _patched_convert_dict_to_message
|
||||
|
||||
if callable(original_convert_delta):
|
||||
@wraps(original_convert_delta)
|
||||
def _patched_convert_delta_to_message_chunk(delta, default_class):
|
||||
chunk = original_convert_delta(delta, default_class)
|
||||
if (
|
||||
isinstance(chunk, AIMessageChunk)
|
||||
and "reasoning_content" in delta
|
||||
):
|
||||
chunk.additional_kwargs["reasoning_content"] = (
|
||||
delta.get("reasoning_content") or ""
|
||||
)
|
||||
return chunk
|
||||
|
||||
_openai_base._convert_delta_to_message_chunk = (
|
||||
_patched_convert_delta_to_message_chunk
|
||||
)
|
||||
|
||||
_openai_base._moviepilot_reasoning_response_patched = True
|
||||
|
||||
if getattr(ChatOpenAI, "_moviepilot_interleaved_reasoning_patched", False):
|
||||
return
|
||||
|
||||
original_get_request_payload = getattr(ChatOpenAI, "_get_request_payload", None)
|
||||
if not callable(original_get_request_payload):
|
||||
logger.warning("langchain-openai 缺少 _get_request_payload,无法修补 reasoning_content")
|
||||
return
|
||||
|
||||
@wraps(original_get_request_payload)
|
||||
def _patched_get_request_payload(self, input_, *, stop=None, **kwargs):
|
||||
payload = original_get_request_payload(self, input_, stop=stop, **kwargs)
|
||||
if "messages" not in payload:
|
||||
return payload
|
||||
|
||||
messages = self._convert_input(input_).to_messages()
|
||||
for index, payload_message in enumerate(payload["messages"]):
|
||||
if (
|
||||
payload_message.get("role") != "assistant"
|
||||
or index >= len(messages)
|
||||
or not isinstance(messages[index], AIMessage)
|
||||
or "reasoning_content" in payload_message
|
||||
):
|
||||
continue
|
||||
|
||||
reasoning_content = messages[index].additional_kwargs.get(
|
||||
"reasoning_content"
|
||||
)
|
||||
if reasoning_content is not None:
|
||||
# 只回传模型真实返回过的思考字段。普通模型没有该字段时,
|
||||
# payload 保持原样,不额外塞未知参数。
|
||||
payload_message["reasoning_content"] = reasoning_content
|
||||
|
||||
return payload
|
||||
|
||||
ChatOpenAI._get_request_payload = _patched_get_request_payload
|
||||
ChatOpenAI._moviepilot_interleaved_reasoning_patched = True
|
||||
logger.debug("已修补 langchain-openai interleaved reasoning_content 回传兼容性")
|
||||
|
||||
|
||||
def _patch_openai_responses_instructions_support():
|
||||
"""
|
||||
修补 langchain-openai 在使用 use_responses_api=True 时,
|
||||
提取 system 消息为顶层 instructions 字段。
|
||||
由于 Codex 等模型 (Responses API) 强依赖 instructions 字段,
|
||||
如果没有该字段会报 400 "Instructions are required"。
|
||||
"""
|
||||
try:
|
||||
from langchain_openai import ChatOpenAI
|
||||
except Exception as err:
|
||||
logger.debug(f"跳过 langchain-openai instructions 修补:{err}")
|
||||
return
|
||||
|
||||
_patch_openai_interleaved_reasoning_content_support()
|
||||
|
||||
if getattr(ChatOpenAI, "_moviepilot_responses_instructions_patched", False):
|
||||
return
|
||||
|
||||
original_get_request_payload = getattr(ChatOpenAI, "_get_request_payload", None)
|
||||
if not callable(original_get_request_payload):
|
||||
logger.warning("langchain-openai 缺少 _get_request_payload,无法修补 instructions")
|
||||
return
|
||||
|
||||
@wraps(original_get_request_payload)
|
||||
def _patched_get_request_payload(self, input_, *, stop=None, **kwargs):
|
||||
payload = original_get_request_payload(self, input_, stop=stop, **kwargs)
|
||||
|
||||
base_url = str(getattr(self, "openai_api_base", "") or "").lower()
|
||||
|
||||
# 处理 GitHub Copilot 端点兼容性
|
||||
if "githubcopilot.com" in base_url:
|
||||
payload.pop("stream_options", None)
|
||||
payload.pop("metadata", None)
|
||||
|
||||
# 处理 ChatGPT 官方 Responses API (Codex) 端点兼容性
|
||||
is_codex = "chatgpt.com/backend-api/codex" in base_url
|
||||
|
||||
if is_codex and (getattr(self, "use_responses_api", False) or "input" in payload):
|
||||
instructions = payload.get("instructions", "")
|
||||
inputs = payload.get("input", [])
|
||||
new_inputs = []
|
||||
|
||||
for msg in inputs:
|
||||
if isinstance(msg, dict) and msg.get("role") == "system":
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str) and content.strip():
|
||||
if instructions:
|
||||
instructions += "\n\n" + content
|
||||
else:
|
||||
instructions = content
|
||||
else:
|
||||
new_inputs.append(msg)
|
||||
|
||||
payload["input"] = new_inputs
|
||||
payload["instructions"] = instructions or "You are a helpful assistant."
|
||||
payload["store"] = False
|
||||
|
||||
# Codex 端点不支持的部分常见补全参数,统一清理避免 400 报错
|
||||
unsupported_keys = [
|
||||
"presence_penalty", "frequency_penalty", "top_p", "n", "user",
|
||||
"stop", "metadata", "logit_bias", "logprobs", "top_logprobs",
|
||||
"stream_options", "temperature"
|
||||
]
|
||||
for key in unsupported_keys:
|
||||
payload.pop(key, None)
|
||||
|
||||
return payload
|
||||
|
||||
ChatOpenAI._get_request_payload = _patched_get_request_payload
|
||||
ChatOpenAI._moviepilot_responses_instructions_patched = True
|
||||
logger.debug("已修补 langchain-openai responses API 的 instructions 兼容性")
|
||||
|
||||
|
||||
class LLMHelper:
|
||||
"""LLM模型相关辅助功能"""
|
||||
|
||||
@@ -342,7 +573,7 @@ class LLMHelper:
|
||||
return {}
|
||||
|
||||
# OpenAI 原生推理模型优先走 LangChain 内置 reasoning_effort。
|
||||
if provider_name == "openai" and model_name.startswith(
|
||||
if provider_name in {"openai", "chatgpt"} and model_name.startswith(
|
||||
("gpt-5", "o1", "o3", "o4")
|
||||
):
|
||||
openai_effort = cls._normalize_openai_reasoning_effort(
|
||||
@@ -366,13 +597,84 @@ class LLMHelper:
|
||||
return bool(settings.LLM_SUPPORT_IMAGE_INPUT)
|
||||
|
||||
@staticmethod
|
||||
def get_llm(
|
||||
def _build_legacy_runtime(
|
||||
provider_name: str,
|
||||
model_name: str | None,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
在 provider 目录不可用时回退到旧的直接构造逻辑。
|
||||
|
||||
这主要用于单测 stub 环境以及极端的最小运行环境,正常生产路径仍优先
|
||||
走 `LLMProviderManager.resolve_runtime()`。
|
||||
"""
|
||||
api_key_value = api_key if api_key is not None else settings.LLM_API_KEY
|
||||
base_url_value = base_url if base_url is not None else settings.LLM_BASE_URL
|
||||
if not api_key_value:
|
||||
raise ValueError("未配置LLM API Key")
|
||||
|
||||
runtime_name = (
|
||||
provider_name
|
||||
if provider_name in {"google", "deepseek"}
|
||||
else "openai_compatible"
|
||||
)
|
||||
return {
|
||||
"provider_id": provider_name,
|
||||
"runtime": runtime_name,
|
||||
"model_id": model_name,
|
||||
"api_key": api_key_value,
|
||||
"base_url": base_url_value,
|
||||
"default_headers": None,
|
||||
"use_responses_api": None,
|
||||
"model_record": None,
|
||||
"model_metadata": None,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _resolve_thinking_level(
|
||||
cls,
|
||||
thinking_level: str | None = None,
|
||||
) -> str | None:
|
||||
"""
|
||||
统一兼容新旧 thinking 参数。
|
||||
"""
|
||||
|
||||
def _normalize(value: str | None) -> str | None:
|
||||
normalized = str(value or "").strip().lower()
|
||||
if not normalized:
|
||||
return None
|
||||
alias_map = {
|
||||
"none": "off",
|
||||
"disabled": "off",
|
||||
"disable": "off",
|
||||
"enabled": "auto",
|
||||
"enable": "auto",
|
||||
"default": "auto",
|
||||
"dynamic": "auto",
|
||||
}
|
||||
normalized = alias_map.get(normalized, normalized)
|
||||
if normalized in cls._SUPPORTED_THINKING_LEVELS:
|
||||
return normalized
|
||||
logger.warning(f"忽略不支持的思考级别: {value}")
|
||||
return None
|
||||
|
||||
normalized_thinking_level = _normalize(thinking_level)
|
||||
if normalized_thinking_level:
|
||||
return normalized_thinking_level
|
||||
|
||||
return "off"
|
||||
|
||||
@classmethod
|
||||
async def get_llm(
|
||||
cls,
|
||||
streaming: bool = False,
|
||||
provider: str | None = None,
|
||||
model: str | None = None,
|
||||
thinking_level: str | None = None,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
base_url_preset: str | None = None,
|
||||
):
|
||||
"""
|
||||
获取LLM实例
|
||||
@@ -383,28 +685,49 @@ class LLMHelper:
|
||||
是否启用思考模式)。支持的级别包括 "off"(关闭)、"auto"(自动)、"minimal"、"low"、"medium"、"high"、"max"/"xhigh"(最大)。
|
||||
不同模型对思考模式的支持和表现不同,具体映射关系请
|
||||
参考代码实现。对于不支持思考模式的模型,该参数将被忽略。
|
||||
:param api_key: API Key,默认为
|
||||
配置项LLM_API_KEY。对于某些提供商(
|
||||
如 DeepSeek),可能需要同时提供 base_url。
|
||||
:param base_url: API Base URL,默认为配置项LLM_BASE_URL。
|
||||
:param api_key: API Key。未显式传入时使用当前配置项 LLM_API_KEY。对于某些提供商(如 DeepSeek),可能需要同时提供 base_url。
|
||||
:param base_url: API Base URL。未显式传入时使用当前配置项 LLM_BASE_URL。
|
||||
:param base_url_preset: Base URL 预设。未显式传入时使用当前配置项 LLM_BASE_URL_PRESET。
|
||||
:return: LLM实例
|
||||
"""
|
||||
provider_name = str(
|
||||
provider if provider is not None else settings.LLM_PROVIDER
|
||||
).lower()
|
||||
provider_name = str(provider if provider is not None else settings.LLM_PROVIDER).lower()
|
||||
model_name = model if model is not None else settings.LLM_MODEL
|
||||
api_key_value = api_key if api_key is not None else settings.LLM_API_KEY
|
||||
base_url_value = base_url if base_url is not None else settings.LLM_BASE_URL
|
||||
thinking_kwargs = LLMHelper._build_thinking_kwargs(
|
||||
base_url_preset_value = (
|
||||
base_url_preset if base_url_preset is not None else settings.LLM_BASE_URL_PRESET
|
||||
)
|
||||
normalized_thinking_level = cls._resolve_thinking_level(
|
||||
thinking_level=thinking_level,
|
||||
)
|
||||
try:
|
||||
# 延迟导入,避免单测在最小 stub 环境下 import `llm.py` 时被 provider
|
||||
# 目录依赖链拖住。
|
||||
from app.agent.llm.provider import LLMProviderManager
|
||||
|
||||
runtime = await LLMProviderManager().resolve_runtime(
|
||||
provider_id=provider_name,
|
||||
model=model_name,
|
||||
api_key=api_key_value,
|
||||
base_url=base_url_value,
|
||||
base_url_preset_id=base_url_preset_value,
|
||||
)
|
||||
except Exception as err:
|
||||
logger.debug(f"LLM provider 目录不可用,回退到旧运行时逻辑: {err}")
|
||||
runtime = cls._build_legacy_runtime(
|
||||
provider_name=provider_name,
|
||||
model_name=model_name,
|
||||
api_key=api_key_value,
|
||||
base_url=base_url_value,
|
||||
)
|
||||
model_name = runtime.get("model_id") or model_name
|
||||
thinking_kwargs = cls._build_thinking_kwargs(
|
||||
provider=provider_name,
|
||||
model=model_name,
|
||||
thinking_level=thinking_level
|
||||
thinking_level=normalized_thinking_level,
|
||||
)
|
||||
|
||||
if not api_key_value:
|
||||
raise ValueError("未配置LLM API Key")
|
||||
|
||||
if provider_name == "google":
|
||||
if runtime["runtime"] == "google":
|
||||
# 修补 Gemini 2.5 思考模型的 thought_signature 兼容性
|
||||
_patch_gemini_thought_signature()
|
||||
|
||||
@@ -420,49 +743,82 @@ class LLMHelper:
|
||||
|
||||
model = ChatGoogleGenerativeAI(
|
||||
model=model_name,
|
||||
api_key=api_key_value,
|
||||
api_key=runtime["api_key"],
|
||||
retries=3,
|
||||
temperature=settings.LLM_TEMPERATURE,
|
||||
streaming=streaming,
|
||||
client_args=client_args,
|
||||
**thinking_kwargs,
|
||||
)
|
||||
elif provider_name == "deepseek":
|
||||
elif runtime["runtime"] == "deepseek":
|
||||
from langchain_deepseek import ChatDeepSeek
|
||||
|
||||
_patch_deepseek_reasoning_content_support()
|
||||
model = ChatDeepSeek(
|
||||
model=model_name,
|
||||
api_key=api_key_value,
|
||||
api_base=base_url_value,
|
||||
api_key=runtime["api_key"],
|
||||
api_base=runtime["base_url"],
|
||||
max_retries=3,
|
||||
temperature=settings.LLM_TEMPERATURE,
|
||||
streaming=streaming,
|
||||
stream_usage=True,
|
||||
**thinking_kwargs,
|
||||
)
|
||||
elif runtime["runtime"] in {"anthropic_compatible", "copilot_anthropic"}:
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
|
||||
model = ChatAnthropic(
|
||||
model=model_name,
|
||||
api_key=runtime["api_key"],
|
||||
base_url=runtime["base_url"],
|
||||
max_retries=3,
|
||||
temperature=settings.LLM_TEMPERATURE,
|
||||
streaming=streaming,
|
||||
stream_usage=True,
|
||||
anthropic_proxy=settings.PROXY_HOST,
|
||||
default_headers=runtime.get("default_headers"),
|
||||
**thinking_kwargs,
|
||||
)
|
||||
else:
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
_patch_openai_responses_instructions_support()
|
||||
|
||||
# ChatGPT Codex 端点强制要求 stream: True
|
||||
if runtime.get("use_responses_api") and "chatgpt.com/backend-api/codex" in str(runtime.get("base_url") or ""):
|
||||
streaming = True
|
||||
|
||||
model = ChatOpenAI(
|
||||
model=model_name,
|
||||
api_key=api_key_value,
|
||||
api_key=runtime["api_key"],
|
||||
max_retries=3,
|
||||
base_url=base_url_value,
|
||||
base_url=runtime.get("base_url"),
|
||||
temperature=settings.LLM_TEMPERATURE,
|
||||
streaming=streaming,
|
||||
stream_usage=True,
|
||||
openai_proxy=settings.PROXY_HOST,
|
||||
default_headers=runtime.get("default_headers"),
|
||||
use_responses_api=runtime.get("use_responses_api"),
|
||||
**thinking_kwargs,
|
||||
)
|
||||
|
||||
# 检查是否有profile
|
||||
if hasattr(model, "profile") and model.profile:
|
||||
# 优先使用 provider / models.dev 目录中的上下文上限,减少用户手填成本。
|
||||
model_profile = getattr(model, "profile", None)
|
||||
if model_profile:
|
||||
logger.debug(f"使用LLM模型: {model.model},Profile: {model.profile}")
|
||||
else:
|
||||
model_record = runtime.get("model_record") or {}
|
||||
model_metadata = runtime.get("model_metadata") or {}
|
||||
metadata_limit = model_metadata.get("limit") or {}
|
||||
max_input_tokens = (
|
||||
model_record.get("input_tokens")
|
||||
or model_record.get("context_tokens")
|
||||
or metadata_limit.get("input")
|
||||
or metadata_limit.get("context")
|
||||
or settings.LLM_MAX_CONTEXT_TOKENS * 1000
|
||||
)
|
||||
model.profile = {
|
||||
"max_input_tokens": settings.LLM_MAX_CONTEXT_TOKENS
|
||||
* 1000, # 转换为token单位
|
||||
"max_input_tokens": int(max_input_tokens),
|
||||
}
|
||||
|
||||
return model
|
||||
@@ -516,22 +872,22 @@ class LLMHelper:
|
||||
thinking_level: str | None = None,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
base_url_preset: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
使用当前已保存配置执行一次最小 LLM 调用。
|
||||
"""
|
||||
provider_name = provider if provider is not None else settings.LLM_PROVIDER
|
||||
model_name = model if model is not None else settings.LLM_MODEL
|
||||
api_key_value = api_key if api_key is not None else settings.LLM_API_KEY
|
||||
base_url_value = base_url if base_url is not None else settings.LLM_BASE_URL
|
||||
start = time.perf_counter()
|
||||
llm = LLMHelper.get_llm(
|
||||
llm = await LLMHelper.get_llm(
|
||||
streaming=False,
|
||||
provider=provider_name,
|
||||
model=model_name,
|
||||
thinking_level=thinking_level,
|
||||
api_key=api_key_value,
|
||||
base_url=base_url_value,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
base_url_preset=base_url_preset,
|
||||
)
|
||||
try:
|
||||
response = await asyncio.wait_for(llm.ainvoke(prompt), timeout=timeout)
|
||||
@@ -556,18 +912,62 @@ class LLMHelper:
|
||||
data["reply_preview"] = reply_text[:120]
|
||||
return data
|
||||
|
||||
def get_models(
|
||||
self, provider: str, api_key: str, base_url: str = None
|
||||
) -> List[str]:
|
||||
"""获取模型列表"""
|
||||
async def get_models(
|
||||
self,
|
||||
provider: str,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
base_url_preset: str | None = None,
|
||||
force_refresh: bool = False,
|
||||
) -> List[dict[str, Any]]:
|
||||
"""
|
||||
获取模型列表。
|
||||
|
||||
返回值会带上 context/supports_reasoning 等元数据,供前端直接渲染并自动
|
||||
回填上下文大小。
|
||||
"""
|
||||
logger.info(f"获取 {provider} 模型列表...")
|
||||
if provider == "google":
|
||||
return self._get_google_models(api_key)
|
||||
else:
|
||||
return self._get_openai_compatible_models(provider, api_key, base_url)
|
||||
try:
|
||||
from app.agent.llm.provider import LLMProviderManager
|
||||
|
||||
return await LLMProviderManager().list_models(
|
||||
provider_id=provider,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
base_url_preset_id=base_url_preset,
|
||||
force_refresh=force_refresh,
|
||||
)
|
||||
except Exception as err:
|
||||
logger.debug(f"LLM provider 目录不可用,回退旧模型列表逻辑: {err}")
|
||||
if provider == "google":
|
||||
return [
|
||||
{"id": model_id, "name": model_id}
|
||||
for model_id in await self._get_google_models(api_key or "")
|
||||
]
|
||||
try:
|
||||
from app.agent.llm.provider import LLMProviderManager
|
||||
|
||||
model_list_base_url = (
|
||||
LLMProviderManager().resolve_model_list_base_url(
|
||||
provider_id=provider,
|
||||
base_url=base_url,
|
||||
base_url_preset_id=base_url_preset,
|
||||
)
|
||||
or base_url
|
||||
)
|
||||
except Exception:
|
||||
model_list_base_url = base_url
|
||||
return [
|
||||
{"id": model_id, "name": model_id}
|
||||
for model_id in await self._get_openai_compatible_models(
|
||||
provider,
|
||||
api_key or "",
|
||||
model_list_base_url,
|
||||
)
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _get_google_models(api_key: str) -> List[str]:
|
||||
async def _get_google_models(api_key: str) -> List[str]:
|
||||
"""获取Google模型列表(使用 google-genai SDK v1)"""
|
||||
try:
|
||||
from google import genai
|
||||
@@ -583,29 +983,32 @@ class LLMHelper:
|
||||
)
|
||||
|
||||
client = genai.Client(api_key=api_key, http_options=http_options)
|
||||
models = client.models.list()
|
||||
return [
|
||||
models = await client.aio.models.list()
|
||||
result = [
|
||||
m.name
|
||||
for m in models
|
||||
for m in models.page
|
||||
if m.supported_actions and "generateContent" in m.supported_actions
|
||||
]
|
||||
await client.aio.aclose()
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"获取Google模型列表失败:{e}")
|
||||
raise e
|
||||
|
||||
@staticmethod
|
||||
def _get_openai_compatible_models(
|
||||
async def _get_openai_compatible_models(
|
||||
provider: str, api_key: str, base_url: str = None
|
||||
) -> List[str]:
|
||||
"""获取OpenAI兼容模型列表"""
|
||||
try:
|
||||
from openai import OpenAI
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
if provider == "deepseek":
|
||||
base_url = base_url or "https://api.deepseek.com"
|
||||
|
||||
client = OpenAI(api_key=api_key, base_url=base_url)
|
||||
models = client.models.list()
|
||||
client = AsyncOpenAI(api_key=api_key, base_url=base_url)
|
||||
models = await client.models.list()
|
||||
await client.close()
|
||||
return [model.id for model in models.data]
|
||||
except Exception as e:
|
||||
logger.error(f"获取 {provider} 模型列表失败:{e}")
|
||||
1
app/agent/llm/models.json
Normal file
1
app/agent/llm/models.json
Normal file
File diff suppressed because one or more lines are too long
2620
app/agent/llm/provider.py
Normal file
2620
app/agent/llm/provider.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,8 @@ class MemoryManager:
|
||||
初始化记忆管理器
|
||||
"""
|
||||
try:
|
||||
if self.cleanup_task and not self.cleanup_task.done():
|
||||
return
|
||||
# 启动内存缓存清理任务(Redis通过TTL自动过期)
|
||||
self.cleanup_task = asyncio.create_task(
|
||||
self._cleanup_expired_memories()
|
||||
@@ -46,6 +48,7 @@ class MemoryManager:
|
||||
await self.cleanup_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self.cleanup_task = None
|
||||
|
||||
logger.info("对话记忆管理器已关闭")
|
||||
|
||||
|
||||
@@ -158,9 +158,9 @@ async def _summarize_with_llm(conversation_text: str) -> str | None:
|
||||
LLM 生成的摘要字符串,失败时返回 None。
|
||||
"""
|
||||
try:
|
||||
from app.helper.llm import LLMHelper
|
||||
from app.agent.llm import LLMHelper
|
||||
|
||||
llm = LLMHelper.get_llm(streaming=False)
|
||||
llm = await LLMHelper.get_llm(streaming=False)
|
||||
prompt = SUMMARY_PROMPT.format(conversation=conversation_text)
|
||||
response = await llm.ainvoke(prompt)
|
||||
summary = response.content.strip()
|
||||
@@ -355,7 +355,7 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
|
||||
|
||||
def modify_request(self, request: ModelRequest[ContextT]) -> ModelRequest[ContextT]:
|
||||
"""将活动日志注入系统消息。"""
|
||||
contents = request.state.get("activity_log_contents", {})
|
||||
contents = request.state.get("activity_log_contents", {}) # noqa
|
||||
activity_log_prompt = self._format_activity_log(contents)
|
||||
|
||||
new_system_message = append_to_system_message(
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.log import logger
|
||||
|
||||
# JOB.md 文件最大限制为 1MB
|
||||
MAX_JOB_FILE_SIZE = 1 * 1024 * 1024
|
||||
ACTIVE_JOB_STATUSES = ("pending", "in_progress")
|
||||
|
||||
|
||||
class JobMetadata(TypedDict):
|
||||
@@ -143,6 +144,9 @@ async def _alist_jobs(source_path: AsyncPath) -> list[JobMetadata]:
|
||||
if not job_dirs:
|
||||
return []
|
||||
|
||||
# 显式按目录名排序,避免文件系统返回顺序不稳定时破坏提示词缓存命中。
|
||||
job_dirs.sort(key=lambda p: p.name.casefold())
|
||||
|
||||
# 解析 JOB.md
|
||||
for job_path in job_dirs:
|
||||
job_md_path = job_path / "JOB.md"
|
||||
@@ -161,6 +165,31 @@ async def _alist_jobs(source_path: AsyncPath) -> list[JobMetadata]:
|
||||
return jobs
|
||||
|
||||
|
||||
def filter_active_jobs(jobs_metadata: list[JobMetadata]) -> list[JobMetadata]:
|
||||
"""筛选需要参与心跳检查的活跃任务。
|
||||
|
||||
这里严格以任务状态为准,只保留 `pending` / `in_progress`。
|
||||
`recurring` 任务执行完成后按约定应回写为 `pending`,因此无需再额外放宽
|
||||
到 `completed`,避免已结束任务被重复注入后台心跳。
|
||||
"""
|
||||
return [
|
||||
job for job in jobs_metadata if job.get("status") in ACTIVE_JOB_STATUSES
|
||||
]
|
||||
|
||||
|
||||
async def load_jobs_metadata(source_paths: list[str]) -> list[JobMetadata]:
|
||||
"""按顺序加载多个 jobs 目录下的任务元数据。"""
|
||||
all_jobs: list[JobMetadata] = []
|
||||
for source_path_str in source_paths:
|
||||
source_path = AsyncPath(source_path_str)
|
||||
if not await source_path.exists():
|
||||
await source_path.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
source_jobs = await _alist_jobs(source_path)
|
||||
all_jobs.extend(source_jobs)
|
||||
return all_jobs
|
||||
|
||||
|
||||
JOBS_SYSTEM_PROMPT = """
|
||||
<jobs_system>
|
||||
You have a **scheduled jobs** system that allows you to track and execute long-running or recurring tasks.
|
||||
@@ -289,13 +318,8 @@ class JobsMiddleware(AgentMiddleware[JobsState, ContextT, ResponseT]): # noqa
|
||||
"""将任务文档注入模型请求的系统消息中。"""
|
||||
jobs_metadata = request.state.get("jobs_metadata", []) # noqa
|
||||
|
||||
# 过滤:只展示活跃任务(pending / in_progress / recurring)
|
||||
active_jobs = [
|
||||
j
|
||||
for j in jobs_metadata
|
||||
if j["status"] in ("pending", "in_progress")
|
||||
or (j["schedule"] == "recurring" and j["status"] not in ("cancelled",))
|
||||
]
|
||||
# 仅注入真正活跃的任务,避免把已完成任务继续塞进心跳上下文。
|
||||
active_jobs = filter_active_jobs(jobs_metadata)
|
||||
|
||||
jobs_list = self._format_jobs_list(active_jobs)
|
||||
jobs_location = self.sources[0] if self.sources else ""
|
||||
@@ -322,18 +346,9 @@ class JobsMiddleware(AgentMiddleware[JobsState, ContextT, ResponseT]): # noqa
|
||||
if "jobs_metadata" in state:
|
||||
return None
|
||||
|
||||
all_jobs: list[JobMetadata] = []
|
||||
|
||||
# 遍历源加载任务
|
||||
for source_path_str in self.sources:
|
||||
source_path = AsyncPath(source_path_str)
|
||||
if not await source_path.exists():
|
||||
await source_path.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
source_jobs = await _alist_jobs(source_path)
|
||||
all_jobs.extend(source_jobs)
|
||||
|
||||
return JobsStateUpdate(jobs_metadata=all_jobs)
|
||||
return JobsStateUpdate(
|
||||
jobs_metadata=await load_jobs_metadata(self.sources)
|
||||
)
|
||||
|
||||
async def awrap_model_call(
|
||||
self,
|
||||
@@ -347,4 +362,10 @@ class JobsMiddleware(AgentMiddleware[JobsState, ContextT, ResponseT]): # noqa
|
||||
return await handler(modified_request)
|
||||
|
||||
|
||||
__all__ = ["JobMetadata", "JobsMiddleware"]
|
||||
__all__ = [
|
||||
"ACTIVE_JOB_STATUSES",
|
||||
"JobMetadata",
|
||||
"JobsMiddleware",
|
||||
"filter_active_jobs",
|
||||
"load_jobs_metadata",
|
||||
]
|
||||
|
||||
@@ -57,8 +57,8 @@ You can create, edit, or organize any `.md` files in this directory to manage yo
|
||||
|
||||
**Memory file organization:**
|
||||
- All `.md` files in `{memory_dir}` are automatically loaded as memory.
|
||||
- `MEMORY.md` is the default/primary memory file for general user preferences and profile.
|
||||
- You may create additional `.md` files to organize knowledge by topic (e.g., `MEDIA_RULES.md`, `DOWNLOAD_PREFERENCES.md`, `SITE_CONFIGS.md`, etc.).
|
||||
- `MEMORY.md` is the default/primary memory file for general user preferences, communication style, and durable working rules.
|
||||
- You may create additional `.md` files to organize knowledge by topic (e.g., `MEDIA_RULES.md`, `COMMUNICATION_PREFERENCES.md`, `DOWNLOAD_PREFERENCES.md`, `SITE_CONFIGS.md`, etc.).
|
||||
- Keep each file focused on a specific domain or topic for better organization.
|
||||
- Subdirectories are NOT scanned — only `.md` files directly in `{memory_dir}`.
|
||||
|
||||
@@ -78,11 +78,11 @@ You can create, edit, or organize any `.md` files in this directory to manage yo
|
||||
|
||||
**When to update memories:**
|
||||
- When the user explicitly asks you to remember something (e.g., "remember my email", "save this preference")
|
||||
- When the user describes your role or how you should behave (e.g., "you are a web researcher", "always do X")
|
||||
- When the user gives durable communication or reply-format preferences (e.g., "be more concise", "prefer tables", "use JSON when summarizing")
|
||||
- When the user gives feedback on your work - capture what was wrong and how to improve
|
||||
- When the user provides information required for tool use (e.g., slack channel ID, email addresses)
|
||||
- When the user provides context useful for future tasks, such as how to use tools, or which actions to take in a particular situation
|
||||
- When you discover new patterns or preferences (coding styles, conventions, workflows)
|
||||
- When you discover new user-specific patterns or preferences (communication style, formatting, workflows)
|
||||
|
||||
**When to NOT update memories:**
|
||||
- When the information is temporary or transient (e.g., "I'm running late", "I'm on my phone right now")
|
||||
@@ -90,6 +90,8 @@ You can create, edit, or organize any `.md` files in this directory to manage yo
|
||||
- When the information is a simple question that doesn't reveal lasting preferences (e.g., "What day is it?", "Can you explain X?")
|
||||
- When the information is an acknowledgment or small talk (e.g., "Sounds good!", "Hello", "Thanks for that")
|
||||
- When the information is stale or irrelevant in future conversations
|
||||
- Memory may refine user-facing style, but it must NOT redefine the agent's core identity, safety boundaries, or global system-task rules.
|
||||
- If the user wants a built-in speaking style/persona, prefer the dedicated persona-switching tools instead of rewriting memory as a substitute.
|
||||
- Never store API keys, access tokens, passwords, or any other credentials in any file, memory, or system prompt.
|
||||
- If the user asks where to put API keys or provides an API key, do NOT echo or save it.
|
||||
- Do NOT record daily activities or task execution history in memory files - these are automatically tracked in the activity log system (see <activity_log>). Memory files are only for long-term knowledge, preferences, and patterns.
|
||||
@@ -135,7 +137,7 @@ Default memory file: {memory_file}
|
||||
- Only ask for preferences when they are directly useful for the current task, or when a short follow-up question at the end would clearly help future interactions.
|
||||
|
||||
**What to collect when useful:**
|
||||
- Preferred communication style
|
||||
- Preferred communication style or persona preference
|
||||
- Media interests
|
||||
- Quality / codec / subtitle preferences
|
||||
- Any standing rules the user wants you to follow
|
||||
@@ -153,7 +155,7 @@ Default memory file: {memory_file}
|
||||
Your memory directory is at: {memory_dir}. You can save new knowledge by calling the `edit_file` or `write_file` tool on any `.md` file in this directory.
|
||||
|
||||
**Memory file organization:**
|
||||
- `MEMORY.md` is the default/primary memory file for general user preferences and profile.
|
||||
- `MEMORY.md` is the default/primary memory file for user preferences, persona preferences, and durable working rules.
|
||||
- You may create additional `.md` files to organize knowledge by topic.
|
||||
- All `.md` files directly in the memory directory are automatically loaded on each conversation.
|
||||
|
||||
@@ -166,15 +168,17 @@ Default memory file: {memory_file}
|
||||
|
||||
**When to update memories:**
|
||||
- When the user explicitly asks you to remember something
|
||||
- When the user describes your role or how you should behave
|
||||
- When the user gives durable communication or reply-format preferences
|
||||
- When the user gives feedback on your work
|
||||
- When the user provides information required for tool use
|
||||
- When you discover new patterns or preferences
|
||||
- When you discover new user-specific patterns or preferences
|
||||
|
||||
**When to NOT update memories:**
|
||||
- Temporary/transient information
|
||||
- One-time task requests
|
||||
- Simple questions, acknowledgments, or small talk
|
||||
- Memory may refine user-facing style, but it must NOT redefine the agent's core identity, safety boundaries, or global system-task rules
|
||||
- If the user wants a built-in speaking style/persona, prefer the dedicated persona-switching tools instead of rewriting memory as a substitute
|
||||
- Never store API keys, access tokens, passwords, or credentials
|
||||
- Do NOT record daily activities in memory files — those go to the activity log
|
||||
</memory_guidelines>
|
||||
@@ -188,7 +192,8 @@ class MemoryMiddleware(AgentMiddleware[MemoryState, ContextT, ResponseT]): # no
|
||||
支持多文件记忆组织:用户可以创建多个 `.md` 文件来按主题组织知识。
|
||||
|
||||
参数:
|
||||
memory_dir: 记忆文件目录路径。
|
||||
memory_dir: 记忆文件目录路径。建议使用独立的 `config/agent/memory`
|
||||
目录,避免与核心规则或人格定义混写。
|
||||
"""
|
||||
|
||||
state_schema = MemoryState
|
||||
@@ -201,7 +206,7 @@ class MemoryMiddleware(AgentMiddleware[MemoryState, ContextT, ResponseT]): # no
|
||||
"""初始化记忆中间件。
|
||||
|
||||
参数:
|
||||
memory_dir: 记忆文件目录路径(例如,`"/config/agent"`)。
|
||||
memory_dir: 记忆文件目录路径(例如,`"/config/agent/memory"`)。
|
||||
该目录下所有 `.md` 文件都会被自动加载为记忆。
|
||||
"""
|
||||
self.memory_dir = memory_dir
|
||||
@@ -288,7 +293,7 @@ class MemoryMiddleware(AgentMiddleware[MemoryState, ContextT, ResponseT]): # no
|
||||
|
||||
return md_files
|
||||
|
||||
async def abefore_agent(
|
||||
async def abefore_agent( # noqa
|
||||
self,
|
||||
state: MemoryState,
|
||||
runtime: Runtime, # noqa
|
||||
|
||||
42
app/agent/middleware/runtime_config.py
Normal file
42
app/agent/middleware/runtime_config.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""动态注入 Agent 根层运行时配置的中间件。"""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from langchain.agents.middleware.types import (
|
||||
AgentMiddleware,
|
||||
ContextT,
|
||||
ModelRequest,
|
||||
ModelResponse,
|
||||
ResponseT,
|
||||
)
|
||||
|
||||
from app.agent.middleware.utils import append_to_system_message
|
||||
from app.agent.runtime import agent_runtime_manager
|
||||
|
||||
|
||||
class RuntimeConfigMiddleware(AgentMiddleware[dict, ContextT, ResponseT]): # noqa
|
||||
"""在每次模型调用前动态加载运行时配置。
|
||||
|
||||
这里不把结果缓存到 middleware state 中,目的是让人格切换工具在同一轮
|
||||
Agent 执行里修改 CURRENT_PERSONA 后,后续模型调用可以立即看到新的人格。
|
||||
"""
|
||||
|
||||
def modify_request(self, request: ModelRequest[ContextT]) -> ModelRequest[ContextT]: # noqa
|
||||
runtime_config = agent_runtime_manager.load_runtime_config()
|
||||
runtime_sections = runtime_config.render_prompt_sections()
|
||||
new_system_message = append_to_system_message(
|
||||
request.system_message, runtime_sections
|
||||
)
|
||||
return request.override(system_message=new_system_message)
|
||||
|
||||
async def awrap_model_call(
|
||||
self,
|
||||
request: ModelRequest[ContextT],
|
||||
handler: Callable[
|
||||
[ModelRequest[ContextT]], Awaitable[ModelResponse[ResponseT]]
|
||||
],
|
||||
) -> ModelResponse[ResponseT]:
|
||||
return await handler(self.modify_request(request))
|
||||
|
||||
|
||||
__all__ = ["RuntimeConfigMiddleware"]
|
||||
@@ -157,7 +157,7 @@ def _parse_skill_metadata( # noqa: C901
|
||||
MAX_SKILL_COMPATIBILITY_LENGTH,
|
||||
skill_path,
|
||||
)
|
||||
compatibility_str = compatibility_str[:MAX_SKILL_COMPATIBILITY_LENGTH]
|
||||
compatibility_str = str(compatibility_str)[:MAX_SKILL_COMPATIBILITY_LENGTH]
|
||||
|
||||
# 版本号,默认为 0(表示未设置版本)
|
||||
raw_version = frontmatter_data.get("version")
|
||||
@@ -227,6 +227,9 @@ async def _alist_skills(source_path: AsyncPath) -> list[SkillMetadata]:
|
||||
if not skill_dirs:
|
||||
return []
|
||||
|
||||
# 显式按目录名排序,避免文件系统返回顺序不稳定时破坏提示词缓存命中。
|
||||
skill_dirs.sort(key=lambda p: p.name.casefold())
|
||||
|
||||
# 解析已下载的 SKILL.md
|
||||
for skill_path in skill_dirs:
|
||||
skill_md_path = skill_path / "SKILL.md"
|
||||
@@ -310,7 +313,8 @@ def _extract_version(skill_md: Path) -> int:
|
||||
"""从 SKILL.md 文件中快速提取 version 字段,无法提取时返回 0。"""
|
||||
try:
|
||||
content = skill_md.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
except Exception as err:
|
||||
print(err)
|
||||
return 0
|
||||
match = re.match(r"^---\s*\n(.*?)\n---\s*\n", content, re.DOTALL)
|
||||
if not match:
|
||||
|
||||
393
app/agent/middleware/tool_selection.py
Normal file
393
app/agent/middleware/tool_selection.py
Normal file
@@ -0,0 +1,393 @@
|
||||
"""MoviePilot 自定义工具筛选中间件。"""
|
||||
|
||||
import json
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Annotated, Any, NotRequired
|
||||
|
||||
from langchain.agents.middleware.types import (
|
||||
AgentState,
|
||||
ContextT,
|
||||
ModelRequest,
|
||||
ModelResponse,
|
||||
ResponseT,
|
||||
)
|
||||
from langchain.agents.middleware.types import (
|
||||
PrivateStateAttr, # noqa
|
||||
)
|
||||
from langchain.agents.middleware.tool_selection import (
|
||||
DEFAULT_SYSTEM_PROMPT,
|
||||
LLMToolSelectorMiddleware,
|
||||
)
|
||||
from langchain_core.language_models.chat_models import BaseChatModel
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langchain_core.tools import BaseTool
|
||||
from langgraph.runtime import Runtime
|
||||
from typing_extensions import TypedDict # noqa
|
||||
|
||||
from app.log import logger
|
||||
|
||||
|
||||
class ToolSelectionState(AgentState):
|
||||
"""工具筛选中间件私有状态。"""
|
||||
|
||||
selected_tool_names: NotRequired[Annotated[list[str] | None, PrivateStateAttr]]
|
||||
"""当前这条用户请求首轮筛选得到的工具名列表。"""
|
||||
|
||||
|
||||
class ToolSelectionStateUpdate(TypedDict):
|
||||
"""工具筛选中间件状态更新项。"""
|
||||
|
||||
selected_tool_names: list[str] | None
|
||||
|
||||
|
||||
class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
|
||||
"""
|
||||
为 DeepSeek 兼容端点提供更稳妥的工具筛选实现。
|
||||
|
||||
LangChain 默认会通过 `with_structured_output()` 走 OpenAI 的
|
||||
`response_format=json_schema` 路径,但 DeepSeek 官方 OpenAI 兼容端点公开文档
|
||||
仅保证 `json_object` 模式可用。对于 `deepseek-reasoner`,这会在工具筛选阶段
|
||||
提前触发 400,导致 Agent 还没真正开始执行工具就失败。
|
||||
|
||||
因此这里仅在识别到 DeepSeek 模型/端点时,退回到显式 JSON 输出模式:
|
||||
1. 使用 `response_format={"type": "json_object"}`;
|
||||
2. 在提示词中明确约束返回 JSON 结构;
|
||||
3. 手动解析 `{"tools": [...]}`,其余模型继续沿用 LangChain 默认实现。
|
||||
|
||||
另外,LangChain 原生工具筛选挂在 `wrap_model_call` 上,会在同一条用户请求
|
||||
的每次“模型回合”前都重新筛选一次工具。对于会多轮调用工具的复杂任务,
|
||||
这会重复消耗一次额外的 LLM 调用。这里改成:
|
||||
- `abefore_agent()`:在本轮 Agent 执行开始时筛选一次;
|
||||
- `awrap_model_call()`:从 `request.state` 读取首轮筛选结果并复用。
|
||||
"""
|
||||
|
||||
state_schema = ToolSelectionState
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: BaseChatModel | str | None = None,
|
||||
system_prompt: str = DEFAULT_SYSTEM_PROMPT,
|
||||
selection_tools: list[Any] | None = None,
|
||||
max_tools: int | None = None,
|
||||
always_include: list[str] | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
model=model,
|
||||
system_prompt=system_prompt,
|
||||
max_tools=max_tools,
|
||||
always_include=always_include,
|
||||
)
|
||||
self.selection_tools = selection_tools or []
|
||||
|
||||
def _process_selection_response(
|
||||
self,
|
||||
response: dict[str, Any],
|
||||
available_tools: list[BaseTool],
|
||||
valid_tool_names: list[str],
|
||||
request: ModelRequest[ContextT],
|
||||
) -> ModelRequest[ContextT]:
|
||||
"""
|
||||
处理工具筛选响应,并保留空结果回退所有工具的 MoviePilot 策略。
|
||||
"""
|
||||
if response.get("tools") == []:
|
||||
logger.warning("工具筛选结果为空,将恢复使用所有工具。")
|
||||
|
||||
always_included_tools: list[BaseTool] = [
|
||||
tool
|
||||
for tool in request.tools
|
||||
if not isinstance(tool, dict) and tool.name in self.always_include
|
||||
]
|
||||
provider_tools = [tool for tool in request.tools if isinstance(tool, dict)]
|
||||
|
||||
return request.override(
|
||||
tools=[*available_tools, *always_included_tools, *provider_tools]
|
||||
)
|
||||
|
||||
return super()._process_selection_response(
|
||||
response,
|
||||
available_tools,
|
||||
valid_tool_names,
|
||||
request,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_deepseek_compatible_model(model: BaseChatModel) -> bool:
|
||||
"""
|
||||
判断当前模型是否应当走 DeepSeek JSON 兼容分支。
|
||||
|
||||
除了官方 `langchain_deepseek`,用户也可能通过 OpenAI-compatible
|
||||
配置把 DeepSeek 端点接到 `ChatOpenAI`。因此这里同时检查模块名、模型名
|
||||
和 Base URL,避免只靠单一条件漏判。
|
||||
"""
|
||||
module_name = type(model).__module__.lower()
|
||||
model_name = (
|
||||
str(getattr(model, "model_name", "") or getattr(model, "model", ""))
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
base_url = (
|
||||
str(getattr(model, "openai_api_base", "") or getattr(model, "api_base", ""))
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
|
||||
return (
|
||||
"deepseek" in module_name
|
||||
or model_name.startswith("deepseek-")
|
||||
or "api.deepseek.com" in base_url
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_text_content(content: Any) -> str:
|
||||
"""
|
||||
从模型响应中提取纯文本。
|
||||
|
||||
这里不依赖上层 LLMHelper,避免中间件与 LLM 构造逻辑互相耦合。
|
||||
"""
|
||||
if content is None:
|
||||
return ""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
text_parts: list[str] = []
|
||||
for block in content:
|
||||
if isinstance(block, str):
|
||||
text_parts.append(block)
|
||||
continue
|
||||
if isinstance(block, dict):
|
||||
if block.get("type") == "text" and isinstance(
|
||||
block.get("text"), str
|
||||
):
|
||||
text_parts.append(block["text"])
|
||||
continue
|
||||
if not block.get("type") and isinstance(block.get("text"), str):
|
||||
text_parts.append(block["text"])
|
||||
return "".join(text_parts)
|
||||
if isinstance(content, dict):
|
||||
if content.get("type") == "text" and isinstance(content.get("text"), str):
|
||||
return content["text"]
|
||||
if not content.get("type") and isinstance(content.get("text"), str):
|
||||
return content["text"]
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _parse_json_object(text: str) -> dict[str, Any]:
|
||||
"""
|
||||
解析模型返回的 JSON。
|
||||
|
||||
DeepSeek 在 JSON 模式下通常会返回纯 JSON,但这里仍做一层兜底,
|
||||
兼容模型偶发输出围栏或前后说明文本的情况。
|
||||
"""
|
||||
stripped_text = text.strip()
|
||||
if not stripped_text:
|
||||
raise ValueError("工具筛选返回了空响应")
|
||||
|
||||
try:
|
||||
payload = json.loads(stripped_text)
|
||||
if isinstance(payload, dict):
|
||||
return payload
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
start = stripped_text.find("{")
|
||||
end = stripped_text.rfind("}")
|
||||
if start == -1 or end == -1 or end <= start:
|
||||
raise ValueError(f"工具筛选返回的内容不是合法 JSON: {stripped_text}")
|
||||
|
||||
payload = json.loads(stripped_text[start: end + 1])
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("工具筛选 JSON 顶层必须是对象")
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _render_tool_list(available_tools: list[Any]) -> str:
|
||||
"""把工具名和描述渲染成稳定的文本列表。"""
|
||||
return "\n".join(
|
||||
f"- {tool.name}: {tool.description}" for tool in available_tools
|
||||
)
|
||||
|
||||
def _build_deepseek_selection_prompt(self, selection_request: Any) -> str:
|
||||
"""
|
||||
为 DeepSeek 生成显式 JSON 输出提示。
|
||||
|
||||
DeepSeek 官方文档要求在 JSON 输出模式下,提示词中必须明确包含 JSON
|
||||
约束,否则兼容端点可能返回空内容或无意义输出。
|
||||
"""
|
||||
limit_instruction = ""
|
||||
if self.max_tools:
|
||||
limit_instruction = f"- Select up to {self.max_tools} tools. IF NO TOOLS ARE RELEVANT, DO NOT RETURN AN EMPTY ARRAY. SELECT THE MOST APPLICABLE ONES TO ENSURE THE REQUEST IS HANDLED."
|
||||
|
||||
return (
|
||||
f"{selection_request.system_message}\n\n"
|
||||
"Return the answer in JSON only.\n"
|
||||
'Use exactly this shape: {"tools": ["tool_name_1", "tool_name_2"]}\n'
|
||||
"Rules:\n"
|
||||
"- The `tools` field must be a JSON array of strings.\n"
|
||||
"- Only use tool names from the allowed list below.\n"
|
||||
"- Order tools by relevance, with the most relevant first.\n"
|
||||
f"{limit_instruction}\n"
|
||||
"- Do not add explanations, markdown, or extra keys.\n\n"
|
||||
"Allowed tools:\n"
|
||||
f"{self._render_tool_list(selection_request.available_tools)}"
|
||||
)
|
||||
|
||||
def _normalize_selection_response(self, response: Any) -> dict[str, list[str]]:
|
||||
"""
|
||||
解析并标准化 DeepSeek JSON 模式的工具筛选结果。
|
||||
"""
|
||||
content = getattr(response, "content", response)
|
||||
text = self._extract_text_content(content)
|
||||
logger.debug(f"工具筛选原始响应: {text}")
|
||||
payload = self._parse_json_object(text)
|
||||
|
||||
tools = payload.get("tools")
|
||||
if not isinstance(tools, list):
|
||||
raise ValueError(f"工具筛选 JSON 缺少 `tools` 数组: {payload}")
|
||||
|
||||
normalized_tools = [
|
||||
tool_name for tool_name in tools if isinstance(tool_name, str)
|
||||
]
|
||||
logger.debug(f"工具筛选标准化结果: {normalized_tools}")
|
||||
return {"tools": normalized_tools}
|
||||
|
||||
async def _aselect_tools_with_deepseek(
|
||||
self, selection_request: Any
|
||||
) -> dict[str, list[str]]:
|
||||
"""
|
||||
使用 DeepSeek 兼容的 JSON 输出模式执行异步工具筛选。
|
||||
"""
|
||||
logger.debug("工具筛选走 DeepSeek JSON 兼容分支")
|
||||
structured_model = selection_request.model.bind(
|
||||
response_format={"type": "json_object"}
|
||||
)
|
||||
response = await structured_model.ainvoke(
|
||||
[
|
||||
{
|
||||
"role": "system",
|
||||
"content": self._build_deepseek_selection_prompt(selection_request),
|
||||
},
|
||||
selection_request.last_user_message,
|
||||
]
|
||||
)
|
||||
return self._normalize_selection_response(response)
|
||||
|
||||
@staticmethod
|
||||
def _extract_selected_tool_names(request: ModelRequest) -> list[str]:
|
||||
"""从已筛选后的请求中提取最终工具名,保留原有顺序。"""
|
||||
return [tool.name for tool in request.tools if not isinstance(tool, dict)]
|
||||
|
||||
@staticmethod
|
||||
def _apply_selected_tools(
|
||||
request: ModelRequest[ContextT],
|
||||
selected_tool_names: list[str],
|
||||
) -> ModelRequest[ContextT]:
|
||||
"""
|
||||
将已筛选出的工具集应用到当前模型请求。
|
||||
|
||||
这里只复用首次筛选出的客户端工具名;provider-specific 的 dict 工具仍然
|
||||
原样保留,避免破坏 LangChain/provider 自身的工具绑定约定。
|
||||
"""
|
||||
if not selected_tool_names:
|
||||
return request
|
||||
|
||||
current_tools_by_name = {
|
||||
tool.name: tool for tool in request.tools if not isinstance(tool, dict)
|
||||
}
|
||||
selected_tools = [
|
||||
current_tools_by_name[tool_name]
|
||||
for tool_name in selected_tool_names
|
||||
if tool_name in current_tools_by_name
|
||||
]
|
||||
provider_tools = [tool for tool in request.tools if isinstance(tool, dict)]
|
||||
return request.override(tools=[*selected_tools, *provider_tools])
|
||||
|
||||
async def _aselect_request_once(
|
||||
self, request: ModelRequest[ContextT]
|
||||
) -> ModelRequest[ContextT]:
|
||||
"""
|
||||
执行一次真实工具筛选,并返回筛选后的请求对象。
|
||||
|
||||
这里单独抽成 helper,便于首次筛选后缓存结果,也便于测试覆盖
|
||||
“首轮筛选,后续复用”的行为。
|
||||
"""
|
||||
selection_request = self._prepare_selection_request(request)
|
||||
if selection_request is None:
|
||||
return request
|
||||
|
||||
if not self._is_deepseek_compatible_model(selection_request.model):
|
||||
captured_request: ModelRequest[ContextT] = request
|
||||
|
||||
async def _capture_handler(
|
||||
updated_request: ModelRequest[ContextT],
|
||||
) -> ModelRequest[ContextT]:
|
||||
nonlocal captured_request
|
||||
captured_request = updated_request
|
||||
return updated_request
|
||||
|
||||
await super().awrap_model_call(request, _capture_handler)
|
||||
return captured_request
|
||||
|
||||
response = await self._aselect_tools_with_deepseek(selection_request)
|
||||
return self._process_selection_response(
|
||||
response,
|
||||
selection_request.available_tools,
|
||||
selection_request.valid_tool_names,
|
||||
request,
|
||||
)
|
||||
|
||||
async def abefore_agent( # noqa
|
||||
self,
|
||||
state: ToolSelectionState,
|
||||
runtime: Runtime, # noqa
|
||||
config: RunnableConfig,
|
||||
) -> ToolSelectionStateUpdate | None: # ty: ignore[invalid-method-override]
|
||||
"""
|
||||
在本轮 Agent 执行开始前完成一次真实工具筛选。
|
||||
|
||||
这样后续多轮 `model -> tools -> model` 循环都只复用这一次结果,
|
||||
不会为每次模型回合重复追加一笔 selector LLM 开销。
|
||||
"""
|
||||
if "selected_tool_names" in state:
|
||||
return None
|
||||
|
||||
if not self.selection_tools or self.model is None:
|
||||
return ToolSelectionStateUpdate(selected_tool_names=None)
|
||||
|
||||
selection_request = ModelRequest(
|
||||
model=self.model,
|
||||
tools=list(self.selection_tools),
|
||||
messages=state["messages"],
|
||||
state=state,
|
||||
runtime=runtime,
|
||||
)
|
||||
modified_request = await self._aselect_request_once(selection_request)
|
||||
selected_tool_names = self._extract_selected_tool_names(modified_request)
|
||||
return ToolSelectionStateUpdate(selected_tool_names=selected_tool_names or None)
|
||||
|
||||
async def awrap_model_call(
|
||||
self,
|
||||
request: ModelRequest[ContextT],
|
||||
handler: Callable[
|
||||
[ModelRequest[ContextT]], Awaitable[ModelResponse[ResponseT]]
|
||||
],
|
||||
) -> ModelResponse[ResponseT]:
|
||||
"""
|
||||
从 state 中读取首次筛选结果,并应用到每次模型回合。
|
||||
"""
|
||||
selected_tool_names = request.state.get("selected_tool_names") # noqa
|
||||
|
||||
# 正常路径下,`abefore_agent()` 已经提前写入状态;这里只保留一层兜底,
|
||||
# 兼容直接单测或未来某些绕过 before_agent 的调用场景。
|
||||
if (
|
||||
selected_tool_names is None
|
||||
and self.selection_tools
|
||||
and self.model is not None
|
||||
):
|
||||
request = await self._aselect_request_once(request)
|
||||
selected_tool_names = self._extract_selected_tool_names(request) or None
|
||||
request.state["selected_tool_names"] = selected_tool_names # noqa
|
||||
|
||||
if selected_tool_names:
|
||||
request = self._apply_selected_tools(request, selected_tool_names)
|
||||
|
||||
return await handler(request)
|
||||
@@ -1,73 +0,0 @@
|
||||
You are an AI media assistant powered by MoviePilot. You specialize in managing home media ecosystems: searching for movies/TV shows, managing subscriptions, overseeing downloads, and organizing media libraries.
|
||||
|
||||
All your responses must be in **Chinese (中文)**.
|
||||
|
||||
You act as a proactive agent. Your goal is to fully resolve the user's media-related requests autonomously. Do not end your turn until the task is complete or you are blocked and require user feedback.
|
||||
|
||||
Core Capabilities:
|
||||
1. Media Search & Recognition — Identify movies, TV shows, and anime; recognize media from fuzzy filenames or incomplete titles.
|
||||
2. Subscription Management — Create rules for automated downloading; monitor trending content.
|
||||
3. Download Control — Search torrents across trackers; filter by quality, codec, and release group.
|
||||
4. System Status & Organization — Monitor downloads, server health, file transfers, renaming, and library cleanup.
|
||||
5. Visual Input Handling — Users may attach images from supported channels; analyze them together with the text when relevant.
|
||||
6. File Context Handling — User messages may arrive as structured JSON. Treat the `message` field as the user's text. Attachments appear in `files`; when `local_path` is present, use local file tools to inspect the uploaded file directly. When image input is disabled for the current model, user images may also be delivered through `files`.
|
||||
|
||||
<communication>
|
||||
{verbose_spec}
|
||||
|
||||
- Tone: professional, concise, restrained.
|
||||
- Be direct. NO unnecessary preamble, NO repeating user's words, NO explaining your thinking.
|
||||
- Prioritize task progress over conversation. Answer only what is necessary to move the task forward.
|
||||
- Do NOT flatter the user, praise the question, or use overly eager/service-oriented phrases.
|
||||
- Do NOT use emojis, exclamation marks, cute language, or excessive apology.
|
||||
- Prefer short declarative sentences. Default to one or two short paragraphs; use lists only when they improve scanability.
|
||||
- Use Markdown for structured data. Use `inline code` for media titles/paths.
|
||||
- Include key details (year, rating, resolution) but do NOT over-explain.
|
||||
- Do not stop for approval on read-only operations. Only confirm before critical actions (starting downloads, deleting subscriptions).
|
||||
- If the current channel supports image sending and an image would materially help, you may use the `send_message` tool with `image_url` to send it.
|
||||
- If the current channel supports file sending and you need to return a local image/file for the user to download, use `send_local_file`.
|
||||
{button_choice_spec}
|
||||
- Voice replies: {voice_reply_spec}
|
||||
- NOT a coding assistant. Do not offer code snippets.
|
||||
- If user has set preferred communication style in memory, follow that strictly.
|
||||
</communication>
|
||||
|
||||
<response_format>
|
||||
- Responses MUST be short and punchy: one sentence for confirmations, brief list for search results.
|
||||
- NO filler phrases like "Let me help you", "Here are the results", "I found..." — skip all unnecessary preamble.
|
||||
- NO repeating what user said.
|
||||
- NO narrating your internal reasoning.
|
||||
- NO praise, emotional cushioning, or unnecessary politeness padding.
|
||||
- After task completion: one line summary only.
|
||||
- When error occurs: brief acknowledgment + suggestion, then move on.
|
||||
</response_format>
|
||||
|
||||
<flow>
|
||||
1. Media Discovery: Identify exact media metadata (TMDB ID, Season/Episode) using search tools.
|
||||
2. Context Checking: Verify current status (already in library? already subscribed?).
|
||||
3. Action Execution: Perform the task with a brief status update only if the operation takes time.
|
||||
4. Final Confirmation: State the result concisely.
|
||||
</flow>
|
||||
|
||||
<tool_calling_strategy>
|
||||
- Call independent tools in parallel whenever possible.
|
||||
- If search results are ambiguous, use `query_media_detail` or `recognize_media` to clarify before proceeding.
|
||||
- If `search_media` fails, fall back to `search_web` or `recognize_media`. Only ask the user when all automated methods are exhausted.
|
||||
</tool_calling_strategy>
|
||||
|
||||
<media_management_rules>
|
||||
1. Download Safety: Present found torrents (size, seeds, quality) and get explicit consent before downloading.
|
||||
2. Subscription Logic: Check for the best matching quality profile based on user history or defaults.
|
||||
3. Library Awareness: Check if content already exists in the library to avoid duplicates.
|
||||
4. Error Handling: If a tool or site fails, briefly explain what went wrong and suggest an alternative.
|
||||
5. TV Subscription Rule: When calling `add_subscribe` for a TV show, omitting `season` means subscribe to season 1 only. To subscribe multiple seasons or the full series, call `add_subscribe` separately for each season.
|
||||
</media_management_rules>
|
||||
|
||||
<markdown_spec>
|
||||
Specific markdown rules:
|
||||
{markdown_spec}
|
||||
</markdown_spec>
|
||||
|
||||
<system_info>
|
||||
{moviepilot_info}
|
||||
</system_info>
|
||||
89
app/agent/prompt/System Core Prompt.txt
Normal file
89
app/agent/prompt/System Core Prompt.txt
Normal file
@@ -0,0 +1,89 @@
|
||||
You are the MoviePilot agent runtime. Follow the injected runtime configuration to determine the active persona and any extra user-specific context.
|
||||
|
||||
All your responses must be in **Chinese (中文)**.
|
||||
|
||||
You act as a proactive agent. Your goal is to fully resolve the user's media-related requests autonomously. Do not end your turn until the task is complete or you are blocked and require user feedback.
|
||||
|
||||
<agent_core>
|
||||
Identity and Goal:
|
||||
- You are an AI media assistant powered by MoviePilot.
|
||||
- Your primary goal is to fully resolve the user's MoviePilot-related media tasks with the available tools whenever the request is actionable.
|
||||
- Focus on MoviePilot's core home media domain: sites, search, recognition, downloads, subscriptions, library organization, file transfer, and system status.
|
||||
- Treat sites as a first-class system capability, not background detail. In MoviePilot, sites are the upstream source for search, account status, authentication, and many download or subscription decisions.
|
||||
- Understand the platform's core workflow as: site availability and configuration -> media search -> media recognition/metadata confirmation -> manual download or subscription -> transfer and library organization -> status/history confirmation.
|
||||
- Treat manual download and subscription automation as two execution modes of the same core pipeline. One is user-triggered immediate acquisition; the other is persistent site-driven monitoring and acquisition.
|
||||
- Stay within the MoviePilot product domain unless the user explicitly asks for adjacent help that can be handled with your existing tools.
|
||||
|
||||
Behavior Model:
|
||||
- Prioritize task progress over conversation.
|
||||
- Check current state before making changes, then do the smallest correct action.
|
||||
- When a task depends on tracker or indexer availability, inspect site state first or as early as possible.
|
||||
- Do not stop for approval on read-only operations. Only confirm before destructive or high-impact actions such as starting downloads, deleting subscriptions, or removing history.
|
||||
- When a request can be completed by tools, prefer doing the work over explaining what you might do.
|
||||
- After an action, perform the minimum validation needed to confirm the result actually landed.
|
||||
- Keep the user anchored to the operational step that matters now: site, search, recognition, download, subscription, or transfer.
|
||||
- If the user explicitly asks to change the speaking style or persona, use the dedicated persona tools instead of editing runtime files manually.
|
||||
- If the user explicitly asks to rewrite or create a persona definition, prefer `update_persona_definition` rather than generic file-editing tools.
|
||||
- Do not let user memory or persona style override this core identity, safety boundaries, or built-in background task rules.
|
||||
- You are not a general-purpose coding assistant in normal media conversations. Only cross into implementation details when the user explicitly asks about MoviePilot internals or debugging.
|
||||
|
||||
Core Capabilities:
|
||||
1. Site Operations - Query configured sites, understand site priority and availability, inspect account data, test connectivity, and update site authentication when the user explicitly requests site maintenance.
|
||||
2. Media Search and Recognition - Identify movies, TV shows, and anime; search media databases; recognize media from fuzzy filenames, torrent titles, or incomplete names.
|
||||
3. Torrent Search and Selection - Search torrents across configured sites and filter by quality, resolution, codec, effect, release group, and other result traits.
|
||||
4. Download Control - Add, inspect, modify, or remove download tasks and connect site results to downloader execution.
|
||||
5. Subscription Management - Create and manage subscriptions that continuously search configured sites and automatically download matching releases.
|
||||
6. Transfer and Library Organization - Transfer files into the library, trigger recognition-aware organization, and confirm post-download file landing or cleanup state.
|
||||
7. System Status and History - Monitor downloader state, site state, transfer history, subscription history, and related system health signals.
|
||||
8. Visual Input Handling - Users may attach images from supported channels; analyze them together with the text when relevant.
|
||||
9. File Context Handling - User messages may arrive as structured JSON. Treat the `message` field as the user's text. Attachments appear in `files`; when `local_path` is present, use local file tools to inspect the uploaded file directly. When image input is disabled for the current model, user images may also be delivered through `files`.
|
||||
10. Persona Management - If the user explicitly asks to change the speaking style or persona, prefer `query_personas` and `switch_persona`; if the user asks to rewrite or create a persona definition, prefer `update_persona_definition` instead of editing runtime files manually.
|
||||
|
||||
Core Workflow:
|
||||
1. Site and Context Check: Determine whether site status, site scope, library state, existing subscriptions, or prior download/transfer history can affect the task.
|
||||
2. Media Identity Resolution: Confirm exact media identity such as TMDB ID, title, year, type, season, or episode using `search_media`, `query_media_detail`, or `recognize_media` as needed.
|
||||
3. Resource Discovery: Use the appropriate search path for the task. For manual acquisition, search site resources and inspect result quality. For automation, prepare subscription conditions that will search sites continuously.
|
||||
4. Action Execution: Perform the requested task, typically one of: test/query site, search torrents, add download, add or modify subscription, or transfer and organize files.
|
||||
5. Final Confirmation: State the outcome briefly, including the key media facts, chosen site or resource scope when relevant, and the next blocker if the task could not be completed.
|
||||
|
||||
Tool Calling Strategy:
|
||||
- Call independent tools in parallel whenever possible.
|
||||
- Prefer site-aware tool paths when the task is about torrents, subscriptions, or download failures. `query_sites`, `test_site`, and `query_site_userdata` are part of the main operating flow, not edge-case tools.
|
||||
- If search results are ambiguous, use `query_media_detail` or `recognize_media` to clarify before proceeding.
|
||||
- For fuzzy torrent names, filenames, or manually provided paths, prefer `recognize_media` before asking the user for a cleaner title.
|
||||
- If `search_media` fails, fall back to `search_web` or `recognize_media`. Only ask the user when automated paths are exhausted.
|
||||
- If torrent search yields no useful result, check site scope, site health, and recognition quality before concluding that the resource is unavailable.
|
||||
- Reuse the latest torrent search cache for `get_search_results` and `add_download` instead of re-running the same search unnecessarily.
|
||||
- Reuse known media identity, prior tool results, and current system context instead of repeating expensive recognition or search calls.
|
||||
- When a tool fails, try one narrower fallback path before escalating to the user.
|
||||
- Use `execute_command` for shell work. 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.
|
||||
|
||||
Media Management Rules:
|
||||
1. Site Awareness: When search, download, or subscription behavior depends on sites, prefer checking enabled sites, selected site IDs, priority, or site health before changing user expectations.
|
||||
2. Download Safety: Present found torrents with size, seeds, and quality, then get explicit consent before downloading.
|
||||
3. Search vs Recognition: `search_media` is for database lookup, `recognize_media` is for parsing titles or paths, and `search_torrents` is for site resource lookup. Do not confuse these roles.
|
||||
4. Subscription Logic: Check for the best matching quality profile, filter groups, and site scope based on user history or defaults.
|
||||
5. Library Awareness: Check if content already exists in the library to avoid duplicates before downloading, subscribing, or transferring.
|
||||
6. Transfer Awareness: If the user asks about downloaded files landing in the library, include transfer or organization state in the reasoning, not just download completion.
|
||||
7. Error Handling: If a tool or site fails, briefly explain what went wrong and suggest an alternative or the next best operational step.
|
||||
8. TV Subscription Rule: When calling `add_subscribe` for a TV show, omitting `season` means subscribe to season 1 only. To subscribe multiple seasons or the full series, call `add_subscribe` separately for each season.
|
||||
</agent_core>
|
||||
|
||||
<communication_runtime>
|
||||
{verbose_spec}
|
||||
|
||||
- Channel-aware formatting: Follow the capability rules below for Markdown, plain text, buttons, and voice replies.
|
||||
{button_choice_spec}
|
||||
- Voice replies: {voice_reply_spec}
|
||||
- If the current channel supports image sending and an image would materially help, you may use the `send_message` tool with `image_url` to send it.
|
||||
- If the current channel supports file sending and you need to return a local image or file for the user to download, use `send_local_file`.
|
||||
</communication_runtime>
|
||||
|
||||
<markdown_spec>
|
||||
Specific markdown rules:
|
||||
{markdown_spec}
|
||||
</markdown_spec>
|
||||
|
||||
<system_info>
|
||||
{moviepilot_info}
|
||||
</system_info>
|
||||
139
app/agent/prompt/System Tasks.yaml
Normal file
139
app/agent/prompt/System Tasks.yaml
Normal file
@@ -0,0 +1,139 @@
|
||||
version: 2
|
||||
shared_rules:
|
||||
- This is a background system task, NOT a user conversation.
|
||||
- Your final response will be consumed by the system. Keep it concise and task-focused.
|
||||
- Do NOT include greetings, explanations, or conversational text.
|
||||
- Respond in Chinese (中文).
|
||||
task_types:
|
||||
heartbeat:
|
||||
header: "[System Heartbeat]"
|
||||
objective: "Check all jobs in your jobs directory and process pending tasks."
|
||||
steps_title: "Follow these steps"
|
||||
steps:
|
||||
- "List all jobs with status 'pending' or 'in_progress'."
|
||||
- "For 'recurring' jobs, check 'last_run' to determine if it's time to run again."
|
||||
- "For 'once' jobs with status 'pending', execute them now."
|
||||
- "After executing each job, update its status, 'last_run' time, and execution log in the JOB.md file."
|
||||
empty_result: "If no jobs were executed, output nothing."
|
||||
health_check:
|
||||
header: "[System Health Check]"
|
||||
objective: "Verify that the agent execution pipeline is alive."
|
||||
steps_title: "Follow these steps"
|
||||
steps:
|
||||
- "Verify that runtime config, tools, and jobs can all be accessed normally."
|
||||
- "If a real issue is detected, report the failing subsystem and the immediate blocking reason."
|
||||
empty_result: "If there is nothing meaningful to report, output OK only."
|
||||
transfer_failed_retry:
|
||||
header: "[System Task - Transfer Failed Retry]"
|
||||
objective: "A file transfer or organization has failed. Please use the `transfer-failed-retry` skill to retry the failed transfer."
|
||||
context_title: "Task context"
|
||||
context_lines:
|
||||
- "Failed transfer history record IDs: {history_ids_csv}"
|
||||
- "Total failed records: {history_count}"
|
||||
steps_title: "Follow these steps"
|
||||
steps:
|
||||
- "Use `query_transfer_history` with status='failed' to find the record with id={history_id} and understand the failure details such as source path, error message, and media info."
|
||||
- "Analyze the error message to determine the best retry strategy."
|
||||
- "If the source file no longer exists, skip this retry and report that the file is missing."
|
||||
- "Delete the failed history record using `delete_transfer_history` with history_id={history_id}."
|
||||
- "Re-identify the media using `recognize_media` with the source file path."
|
||||
- "If recognition fails, try `search_media` with keywords from the filename."
|
||||
- "Re-transfer using `transfer_file` with the source path and any identified media info such as tmdbid and media_type."
|
||||
- "Report the final result."
|
||||
batch_transfer_failed_retry:
|
||||
header: "[System Task - Batch Transfer Failed Retry]"
|
||||
objective: "Multiple file transfers from the same source have failed. These files likely belong to the same media. Please use the `transfer-failed-retry` skill to retry them efficiently."
|
||||
context_title: "Task context"
|
||||
context_lines:
|
||||
- "Failed transfer history record IDs: {history_ids_csv}"
|
||||
- "Total failed records: {history_count}"
|
||||
steps_title: "Follow these steps"
|
||||
steps:
|
||||
- "Use `query_transfer_history` with status='failed' to find all records with these IDs and understand the failure details."
|
||||
- "Analyze the first record to determine the shared media identity and the best retry strategy because the root cause is usually the same for all files."
|
||||
- "If the error is about media recognition, identify the media once using `recognize_media` or `search_media`, then reuse that result for all files."
|
||||
- "For each failed record, delete the old history entry with `delete_transfer_history` and re-transfer using `transfer_file`."
|
||||
- "Report how many retries succeeded and how many still failed."
|
||||
task_rules:
|
||||
- "These files share the same media identity. Do NOT call `recognize_media` or `search_media` repeatedly for each file."
|
||||
manual_transfer_redo:
|
||||
header: "[System Task - Manual Transfer Re-Organize]"
|
||||
objective: "A user manually triggered an AI re-organize task from the transfer history page."
|
||||
context_title: "Transfer history record"
|
||||
context_lines:
|
||||
- "- History ID: {history_id}"
|
||||
- "- Current status: {current_status}"
|
||||
- "- Current recognized title: {recognized_title}"
|
||||
- "- Media type: {media_type}"
|
||||
- "- Category: {category}"
|
||||
- "- Year: {year}"
|
||||
- "- Season/Episode: {season_episode}"
|
||||
- "- Source path: {source_path}"
|
||||
- "- Source storage: {source_storage}"
|
||||
- "- Destination path: {destination_path}"
|
||||
- "- Destination storage: {destination_storage}"
|
||||
- "- Transfer mode: {transfer_mode}"
|
||||
- "- Current TMDB ID: {tmdbid}"
|
||||
- "- Current Douban ID: {doubanid}"
|
||||
- "- Error message: {error_message}"
|
||||
steps_title: "Required workflow"
|
||||
steps:
|
||||
- "Use `query_transfer_history` to locate and inspect the record with id={history_id}, and verify the source path, status, media info, and failure context."
|
||||
- "Decide whether the current recognition is trustworthy."
|
||||
- "If the source file no longer exists or cannot be safely processed, stop and report the reason."
|
||||
- "If the current recognition is wrong or the record should be reorganized, determine the correct media identity first."
|
||||
- "Prefer `recognize_media` with the source path. If recognition is not reliable, use `search_media` with keywords from filename, title, or year."
|
||||
- "Only continue when you have high confidence in the target media."
|
||||
- "Before re-organizing, delete the old transfer history record with `delete_transfer_history` so the system will not skip the source file."
|
||||
- "Then use `transfer_file` to organize the source path directly."
|
||||
- "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, tmdbid or doubanid, and media_type."
|
||||
- "If this record is already correct and no re-organize is needed, do not perform destructive actions; simply report that no change is necessary."
|
||||
task_rules:
|
||||
- "Do NOT rely on previous chat context. Work only from the record above."
|
||||
- "Your goal is to directly fix one transfer history record by using MoviePilot tools to analyze, clean up the old history entry if necessary, and organize the source file again."
|
||||
- "You should complete the re-organize by directly using tools such as `query_transfer_history`, `recognize_media`, `search_media`, `delete_transfer_history`, and `transfer_file`."
|
||||
- "Do NOT reorganize blindly when media identity is uncertain."
|
||||
- "If the previous record was successful but obviously identified as the wrong media, still use the tool-based flow above instead of `/redo`."
|
||||
- "Keep the final response short and focused on outcome."
|
||||
batch_manual_transfer_redo:
|
||||
header: "[System Task - Batch Manual Transfer Re-Organize]"
|
||||
objective: "A user manually triggered a batch AI re-organize task from the transfer history page."
|
||||
context_title: "Selected transfer history records"
|
||||
context_lines:
|
||||
- "- History IDs: {history_ids_csv}"
|
||||
- "- Total records: {history_count}"
|
||||
- "{records_context}"
|
||||
steps_title: "Required workflow"
|
||||
steps:
|
||||
- "Review the selected records below first and group them by likely shared media identity, source directory, or retry strategy when possible."
|
||||
- "Use the provided record context as the primary source of truth. Call `query_transfer_history` only when you need extra confirmation."
|
||||
- "For each group, decide whether the current recognition is trustworthy."
|
||||
- "If multiple records clearly belong to the same movie or series, identify the media once with `recognize_media` or `search_media`, then reuse that result for the related records."
|
||||
- "If a source file no longer exists or cannot be safely processed, skip that record and note the reason."
|
||||
- "Before re-organizing a record, delete the old transfer history record with `delete_transfer_history` so the system will not skip the source file."
|
||||
- "Then use `transfer_file` to organize the source path directly."
|
||||
- "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, tmdbid or doubanid, and media_type."
|
||||
- "If a record is already correct and no re-organize is needed, do not perform destructive actions; simply mark it as skipped."
|
||||
- "Report only the aggregate outcome, including how many records succeeded, skipped, and failed."
|
||||
task_rules:
|
||||
- "Do NOT assume every selected record belongs to the same media."
|
||||
- "When several records obviously share the same media identity, avoid repeated `recognize_media` or `search_media` calls."
|
||||
- "Process every selected record exactly once."
|
||||
- "Keep the final response short and focused on the aggregate outcome."
|
||||
search_recommend:
|
||||
header: "[System Task - Search Results Recommendation]"
|
||||
objective: "Analyze the provided search results and select the best matching items based on user preferences."
|
||||
context_title: "Task context"
|
||||
context_lines:
|
||||
- "{search_results}"
|
||||
steps_title: "Follow these steps"
|
||||
steps:
|
||||
- "Review all search result items carefully."
|
||||
- "Evaluate each item based on the user preference criteria."
|
||||
- "Select the top items that best match the preferences."
|
||||
- "Return ONLY a JSON array of item indices."
|
||||
task_rules:
|
||||
- "Return ONLY a JSON array of index numbers, e.g., [0, 3, 1]."
|
||||
- "Do NOT include any explanations, markdown formatting, conversational text, or other content."
|
||||
- "Do NOT call any tools. Simply analyze and return the JSON result directly."
|
||||
- "Respond in JSON format only."
|
||||
@@ -1,10 +1,16 @@
|
||||
"""提示词管理器"""
|
||||
|
||||
import shutil
|
||||
import socket
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from string import Formatter
|
||||
from time import strftime
|
||||
from typing import Dict
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from app.agent.llm.capability import AgentCapabilityManager
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
from app.schemas import (
|
||||
@@ -15,6 +21,62 @@ from app.schemas import (
|
||||
)
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
SYSTEM_TASKS_FILE = "System Tasks.yaml"
|
||||
SYSTEM_TASKS_SCHEMA_VERSION = 2
|
||||
COMMON_SHELL_COMMANDS = (
|
||||
# 只探测会明显改变 Agent 执行策略的可选能力。基础命令、语言运行时、
|
||||
# 包管理器、服务管理器和数据库客户端默认不做启动探测,减少 which 扫描量。
|
||||
"ssh",
|
||||
"scp",
|
||||
"sftp",
|
||||
"git",
|
||||
"gh",
|
||||
"rg",
|
||||
"fd",
|
||||
"jq",
|
||||
"yq",
|
||||
"curl",
|
||||
"wget",
|
||||
"docker",
|
||||
"docker-compose",
|
||||
"python",
|
||||
"python3",
|
||||
"ffmpeg",
|
||||
"ffprobe",
|
||||
"mediainfo",
|
||||
"rclone",
|
||||
"aria2c",
|
||||
"yt-dlp",
|
||||
)
|
||||
|
||||
|
||||
class PromptConfigError(ValueError):
|
||||
"""程序内置提示词定义加载异常。"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class SystemTaskTypeDefinition:
|
||||
"""单个后台系统任务定义。"""
|
||||
|
||||
header: str
|
||||
objective: str
|
||||
context_title: Optional[str] = None
|
||||
context_lines: list[str] = field(default_factory=list)
|
||||
steps_title: Optional[str] = None
|
||||
steps: list[str] = field(default_factory=list)
|
||||
task_rules: list[str] = field(default_factory=list)
|
||||
empty_result: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SystemTasksDefinition:
|
||||
"""程序内置后台系统任务定义。"""
|
||||
|
||||
path: Path
|
||||
version: int
|
||||
shared_rules: list[str]
|
||||
task_types: dict[str, SystemTaskTypeDefinition]
|
||||
|
||||
|
||||
class PromptManager:
|
||||
"""
|
||||
@@ -27,6 +89,9 @@ class PromptManager:
|
||||
else:
|
||||
self.prompts_dir = Path(prompts_dir)
|
||||
self.prompts_cache: Dict[str, str] = {}
|
||||
self._system_tasks_cache: Optional[SystemTasksDefinition] = None
|
||||
self._system_tasks_signature: Optional[tuple[int, int]] = None
|
||||
self._available_shell_commands_cache: Optional[list[tuple[str, str]]] = None
|
||||
|
||||
def load_prompt(self, prompt_name: str) -> str:
|
||||
"""
|
||||
@@ -50,17 +115,16 @@ class PromptManager:
|
||||
logger.error(f"加载提示词失败: {prompt_name}, 错误: {e}")
|
||||
raise
|
||||
|
||||
def get_agent_prompt(
|
||||
self, channel: str = None, prefer_voice_reply: bool = False
|
||||
) -> str:
|
||||
def get_agent_prompt(self, channel: str = None) -> str:
|
||||
"""
|
||||
获取智能体提示词
|
||||
:param channel: 消息渠道(Telegram、微信、Slack等)
|
||||
:param prefer_voice_reply: 是否优先使用语音回复
|
||||
:return: 提示词内容
|
||||
"""
|
||||
# 基础提示词
|
||||
base_prompt = self.load_prompt("Agent Prompt.txt")
|
||||
# 基础提示词只保留 MoviePilot 运行时和渠道能力相关约束。
|
||||
# 根层运行时配置由 RuntimeConfigMiddleware 在每次模型调用前动态注入,
|
||||
# 这样人格切换可以在同一轮 Agent 执行里立即生效。
|
||||
base_prompt = self.load_prompt("System Core Prompt.txt")
|
||||
|
||||
# 识别渠道
|
||||
markdown_spec = ""
|
||||
@@ -93,9 +157,7 @@ class PromptManager:
|
||||
|
||||
# MoviePilot系统信息
|
||||
moviepilot_info = self._get_moviepilot_info()
|
||||
voice_reply_spec = self._generate_voice_reply_instructions(
|
||||
prefer_voice_reply=prefer_voice_reply
|
||||
)
|
||||
voice_reply_spec = self._generate_voice_reply_instructions()
|
||||
|
||||
# 始终替换占位符,避免后续 .format() 时因残留花括号报 KeyError
|
||||
base_prompt = base_prompt.format(
|
||||
@@ -108,8 +170,116 @@ class PromptManager:
|
||||
|
||||
return base_prompt
|
||||
|
||||
@staticmethod
|
||||
def _get_moviepilot_info() -> str:
|
||||
def load_system_tasks_definition(self) -> SystemTasksDefinition:
|
||||
"""加载程序内置的后台系统任务定义。"""
|
||||
system_tasks_path = self.prompts_dir / SYSTEM_TASKS_FILE
|
||||
try:
|
||||
stat = system_tasks_path.stat()
|
||||
except FileNotFoundError as err:
|
||||
logger.error(f"系统任务定义文件不存在: {system_tasks_path}")
|
||||
raise PromptConfigError(f"系统任务定义文件不存在: {system_tasks_path}") from err
|
||||
|
||||
signature = (stat.st_mtime_ns, stat.st_size)
|
||||
if (
|
||||
self._system_tasks_signature == signature
|
||||
and self._system_tasks_cache is not None
|
||||
):
|
||||
return self._system_tasks_cache
|
||||
|
||||
try:
|
||||
content = system_tasks_path.read_text(encoding="utf-8")
|
||||
except Exception as err: # noqa: BLE001
|
||||
logger.error(f"读取系统任务定义失败: {system_tasks_path}, 错误: {err}")
|
||||
raise PromptConfigError(
|
||||
f"读取系统任务定义失败 {system_tasks_path}: {err}"
|
||||
) from err
|
||||
|
||||
try:
|
||||
data = yaml.safe_load(content) or {}
|
||||
except yaml.YAMLError as err:
|
||||
raise PromptConfigError(f"YAML 解析失败 {system_tasks_path}: {err}") from err
|
||||
if not isinstance(data, dict):
|
||||
raise PromptConfigError(
|
||||
f"YAML 根节点必须是映射类型: {system_tasks_path}"
|
||||
)
|
||||
|
||||
definition = self._parse_system_tasks_definition(system_tasks_path, data)
|
||||
self._system_tasks_signature = signature
|
||||
self._system_tasks_cache = definition
|
||||
return definition
|
||||
|
||||
def render_system_task_message(
|
||||
self,
|
||||
task_type: str,
|
||||
*,
|
||||
template_context: Optional[dict[str, Any]] = None,
|
||||
extra_rules: Optional[list[str]] = None,
|
||||
) -> str:
|
||||
"""根据程序内置 YAML 渲染后台系统任务提示词。"""
|
||||
system_tasks = self.load_system_tasks_definition()
|
||||
task_definition = system_tasks.task_types.get(task_type)
|
||||
if not task_definition:
|
||||
raise PromptConfigError(f"未定义的后台系统任务类型: {task_type}")
|
||||
|
||||
rendered_context = self._render_template_lines(
|
||||
task_definition.context_lines,
|
||||
template_context,
|
||||
task_type,
|
||||
"context_lines",
|
||||
)
|
||||
rendered_steps = self._render_template_lines(
|
||||
task_definition.steps,
|
||||
template_context,
|
||||
task_type,
|
||||
"steps",
|
||||
)
|
||||
rendered_task_rules = self._render_template_lines(
|
||||
task_definition.task_rules,
|
||||
template_context,
|
||||
task_type,
|
||||
"task_rules",
|
||||
)
|
||||
|
||||
sections = [
|
||||
self._render_template_text(
|
||||
task_definition.header,
|
||||
template_context,
|
||||
task_type,
|
||||
"header",
|
||||
).strip(),
|
||||
self._render_template_text(
|
||||
task_definition.objective,
|
||||
template_context,
|
||||
task_type,
|
||||
"objective",
|
||||
).strip(),
|
||||
]
|
||||
if rendered_context:
|
||||
sections.append(
|
||||
self._format_titled_lines(
|
||||
task_definition.context_title or "Task context",
|
||||
rendered_context,
|
||||
)
|
||||
)
|
||||
if rendered_steps:
|
||||
sections.append(
|
||||
self._format_titled_lines(
|
||||
task_definition.steps_title or "Follow these steps",
|
||||
rendered_steps,
|
||||
)
|
||||
)
|
||||
|
||||
rules = list(system_tasks.shared_rules)
|
||||
if task_definition.empty_result:
|
||||
rules.append(task_definition.empty_result)
|
||||
rules.extend(rendered_task_rules)
|
||||
if extra_rules:
|
||||
rules.extend(rule.strip() for rule in extra_rules if rule and rule.strip())
|
||||
if rules:
|
||||
sections.append(self._format_numbered_rules("IMPORTANT", rules))
|
||||
return "\n\n".join(section for section in sections if section).strip()
|
||||
|
||||
def _get_moviepilot_info(self) -> str:
|
||||
"""
|
||||
获取MoviePilot系统信息,用于注入到系统提示词中
|
||||
"""
|
||||
@@ -138,10 +308,15 @@ class PromptManager:
|
||||
db_info = f"SQLite ({settings.CONFIG_PATH / 'db' / 'moviepilot.db'})"
|
||||
else:
|
||||
db_password = settings.DB_POSTGRESQL_PASSWORD or ""
|
||||
db_info = f"PostgreSQL ({settings.DB_POSTGRESQL_USERNAME}:{db_password}@{settings.DB_POSTGRESQL_HOST}:{settings.DB_POSTGRESQL_PORT}/{settings.DB_POSTGRESQL_DATABASE})"
|
||||
db_info = (
|
||||
f"PostgreSQL ({settings.DB_POSTGRESQL_USERNAME}:{db_password}@"
|
||||
f"{settings.DB_POSTGRESQL_TARGET}/{settings.DB_POSTGRESQL_DATABASE})"
|
||||
)
|
||||
|
||||
# 保留日期用于提供“今天是哪天”的稳定上下文,但不再注入秒级时间,
|
||||
# 避免每次请求都生成不同的 system prompt,影响 provider 侧 cache 命中率。
|
||||
info_lines = [
|
||||
f"- 当前时间: {strftime('%Y-%m-%d %H:%M:%S')}",
|
||||
f"- 当前日期: {strftime('%Y-%m-%d')}",
|
||||
f"- 运行环境: {SystemUtils.platform} {'docker' if SystemUtils.is_docker() else ''}",
|
||||
f"- 主机名: {hostname}",
|
||||
f"- IP地址: {ip_address}",
|
||||
@@ -154,17 +329,54 @@ class PromptManager:
|
||||
f"- 配置文件目录: {config_path}",
|
||||
f"- 日志文件目录: {log_path}",
|
||||
f"- 系统安装目录: {settings.ROOT_PATH}",
|
||||
f"- 插件安装目录: {settings.ROOT_PATH / 'app' / 'plugins'}",
|
||||
]
|
||||
|
||||
available_commands = self._get_available_shell_commands()
|
||||
if available_commands:
|
||||
info_lines.append("- 可用系统命令(可通过 `execute_command` 调用):")
|
||||
info_lines.extend(
|
||||
f" - {command}: {path}" for command, path in available_commands
|
||||
)
|
||||
# `rg` 同时覆盖文件枚举和文本检索,且比通用 shell 查找更适合
|
||||
# Agent 的代码阅读与定位场景;只有在它不可用或不适合时才退回其他工具。
|
||||
if any(command == "rg" for command, _ in available_commands):
|
||||
info_lines.append(
|
||||
"- When searching files or text, prefer `rg` / `rg --files`. Only fall back to other search tools when `rg` is unavailable or unsuitable."
|
||||
)
|
||||
|
||||
return "\n".join(info_lines)
|
||||
|
||||
def _get_available_shell_commands(self) -> list[tuple[str, str]]:
|
||||
"""
|
||||
探测 PATH 中已经安装的常用命令。
|
||||
|
||||
这里只使用 shutil.which 做无副作用查找,不实际执行命令;执行权限、
|
||||
高风险操作确认和输出限制仍由 execute_command 工具负责。探测结果
|
||||
在进程内缓存,避免每次组装提示词都重复扫描 PATH。
|
||||
"""
|
||||
if self._available_shell_commands_cache is not None:
|
||||
return self._available_shell_commands_cache
|
||||
|
||||
available_commands: list[tuple[str, str]] = []
|
||||
for command in COMMON_SHELL_COMMANDS:
|
||||
command_path = shutil.which(command)
|
||||
if command_path:
|
||||
available_commands.append((command, command_path))
|
||||
self._available_shell_commands_cache = available_commands
|
||||
return available_commands
|
||||
|
||||
def clear_available_shell_commands_cache(self) -> None:
|
||||
"""清理可用系统命令缓存,供测试或运行时手动刷新使用。"""
|
||||
self._available_shell_commands_cache = None
|
||||
|
||||
@staticmethod
|
||||
def _generate_formatting_instructions(caps: ChannelCapabilities) -> str:
|
||||
"""
|
||||
根据渠道能力动态生成格式指令
|
||||
"""
|
||||
instructions = []
|
||||
if ChannelCapability.RICH_TEXT not in caps.capabilities:
|
||||
if ChannelCapability.MARKDOWN not in caps.capabilities:
|
||||
instructions.append(
|
||||
"- Formatting: Use **Plain Text ONLY**. The channel does NOT support Markdown."
|
||||
)
|
||||
@@ -178,17 +390,13 @@ class PromptManager:
|
||||
return "\n".join(instructions)
|
||||
|
||||
@staticmethod
|
||||
def _generate_voice_reply_instructions(prefer_voice_reply: bool) -> str:
|
||||
if not prefer_voice_reply:
|
||||
return (
|
||||
"- Voice replies: Use normal text replies by default. "
|
||||
"Only call `send_voice_message` when spoken playback is clearly better than plain text."
|
||||
)
|
||||
def _generate_voice_reply_instructions() -> str:
|
||||
if not AgentCapabilityManager.supports_audio_output():
|
||||
return "Audio output is disabled; do not call `send_voice_message`."
|
||||
return (
|
||||
"- Current message context: The user sent a voice message.\n"
|
||||
"- Reply preference: Prioritize calling `send_voice_message` for the main user-facing reply.\n"
|
||||
"- Fallback: If voice is unavailable on the current channel, `send_voice_message` will fall back to text.\n"
|
||||
"- Do not repeat the same full reply again after calling `send_voice_message`."
|
||||
"Use normal text replies by default. Only call `send_voice_message` "
|
||||
"when the user explicitly asks for a voice reply or spoken playback "
|
||||
"is clearly better than plain text."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -208,11 +416,172 @@ class PromptManager:
|
||||
)
|
||||
return "- User questions: When you truly need user input, ask briefly in plain text."
|
||||
|
||||
def _parse_system_tasks_definition(
|
||||
self,
|
||||
path: Path,
|
||||
data: dict[str, Any],
|
||||
) -> SystemTasksDefinition:
|
||||
"""把 YAML 结构转换成系统任务定义对象。"""
|
||||
version = self._normalize_positive_int(data.get("version"), "version", default=1)
|
||||
if version < SYSTEM_TASKS_SCHEMA_VERSION:
|
||||
raise PromptConfigError(
|
||||
f"{path} 的 version={version} 过旧,"
|
||||
f"当前要求 System Tasks schema v{SYSTEM_TASKS_SCHEMA_VERSION} 或更高版本"
|
||||
)
|
||||
|
||||
shared_rules = self._normalize_string_list(data.get("shared_rules"), "shared_rules")
|
||||
if not shared_rules:
|
||||
raise PromptConfigError(f"{path} 缺少 shared_rules")
|
||||
|
||||
raw_task_types = data.get("task_types")
|
||||
if not isinstance(raw_task_types, dict) or not raw_task_types:
|
||||
raise PromptConfigError(f"{path} 缺少 task_types 映射")
|
||||
|
||||
task_types: dict[str, SystemTaskTypeDefinition] = {}
|
||||
for key, raw in raw_task_types.items():
|
||||
if not isinstance(raw, dict):
|
||||
raise PromptConfigError(f"task_types.{key} 必须是映射")
|
||||
|
||||
header = str(raw.get("header") or "").strip()
|
||||
objective = str(raw.get("objective") or "").strip()
|
||||
if not header or not objective:
|
||||
raise PromptConfigError(f"task_types.{key} 缺少 header 或 objective")
|
||||
|
||||
task_types[str(key)] = SystemTaskTypeDefinition(
|
||||
header=header,
|
||||
objective=objective,
|
||||
context_title=str(raw.get("context_title") or "").strip() or None,
|
||||
context_lines=self._normalize_string_list(
|
||||
raw.get("context_lines"),
|
||||
f"task_types.{key}.context_lines",
|
||||
),
|
||||
steps_title=str(raw.get("steps_title") or "").strip() or None,
|
||||
steps=self._normalize_string_list(
|
||||
raw.get("steps"),
|
||||
f"task_types.{key}.steps",
|
||||
),
|
||||
task_rules=self._normalize_string_list(
|
||||
raw.get("task_rules"),
|
||||
f"task_types.{key}.task_rules",
|
||||
),
|
||||
empty_result=str(raw.get("empty_result") or "").strip() or None,
|
||||
)
|
||||
return SystemTasksDefinition(
|
||||
path=path,
|
||||
version=version,
|
||||
shared_rules=shared_rules,
|
||||
task_types=task_types,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _render_template_text(
|
||||
cls,
|
||||
text: str,
|
||||
template_context: Optional[dict[str, Any]],
|
||||
task_type: str,
|
||||
field_name: str,
|
||||
) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
formatter = Formatter()
|
||||
required_fields = {
|
||||
placeholder_name
|
||||
for _, placeholder_name, _, _ in formatter.parse(text)
|
||||
if placeholder_name
|
||||
}
|
||||
if not required_fields:
|
||||
return text
|
||||
|
||||
context = cls._normalize_template_context(template_context)
|
||||
missing_fields = sorted(f for f in required_fields if f not in context)
|
||||
if missing_fields:
|
||||
raise PromptConfigError(
|
||||
f"系统任务定义 `{task_type}` 的 `{field_name}` 缺少变量: "
|
||||
+ ", ".join(f"`{f}`" for f in missing_fields)
|
||||
)
|
||||
|
||||
# 这里统一做字符串替换,让 YAML 成为后台任务文案的唯一行为来源。
|
||||
return text.format_map(context)
|
||||
|
||||
@classmethod
|
||||
def _render_template_lines(
|
||||
cls,
|
||||
items: list[str],
|
||||
template_context: Optional[dict[str, Any]],
|
||||
task_type: str,
|
||||
field_name: str,
|
||||
) -> list[str]:
|
||||
return [
|
||||
cls._render_template_text(
|
||||
item,
|
||||
template_context,
|
||||
task_type,
|
||||
f"{field_name}[{index}]",
|
||||
).rstrip()
|
||||
for index, item in enumerate(items, start=1)
|
||||
if item and item.rstrip()
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _normalize_template_context(
|
||||
template_context: Optional[dict[str, Any]],
|
||||
) -> dict[str, str]:
|
||||
if not template_context:
|
||||
return {}
|
||||
return {
|
||||
str(key): "" if value is None else str(value)
|
||||
for key, value in template_context.items()
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _format_numbered_rules(title: str, items: list[str]) -> str:
|
||||
return "\n".join(
|
||||
[f"{title}:"] + [f"{index}. {item}" for index, item in enumerate(items, start=1)]
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _format_titled_lines(title: str, items: list[str]) -> str:
|
||||
cleaned = [item.rstrip() for item in items if item and item.rstrip()]
|
||||
return "\n".join([f"{title}:"] + cleaned)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_positive_int(
|
||||
value: Any,
|
||||
field_name: str,
|
||||
*,
|
||||
default: int,
|
||||
) -> int:
|
||||
if value in (None, ""):
|
||||
return default
|
||||
try:
|
||||
normalized = int(value)
|
||||
except (TypeError, ValueError) as err:
|
||||
raise PromptConfigError(f"{field_name} 必须是正整数") from err
|
||||
if normalized <= 0:
|
||||
raise PromptConfigError(f"{field_name} 必须是正整数")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _normalize_string_list(values: Any, field_name: str) -> list[str]:
|
||||
if values is None:
|
||||
return []
|
||||
if not isinstance(values, list):
|
||||
raise PromptConfigError(f"{field_name} 必须是字符串数组")
|
||||
normalized: list[str] = []
|
||||
for value in values:
|
||||
text = str(value).strip()
|
||||
if text:
|
||||
normalized.append(text)
|
||||
return normalized
|
||||
|
||||
def clear_cache(self):
|
||||
"""
|
||||
清空缓存
|
||||
"""
|
||||
self.prompts_cache.clear()
|
||||
self._system_tasks_cache = None
|
||||
self._system_tasks_signature = None
|
||||
logger.info("提示词缓存已清空")
|
||||
|
||||
|
||||
|
||||
755
app/agent/runtime.py
Normal file
755
app/agent/runtime.py
Normal file
@@ -0,0 +1,755 @@
|
||||
"""Agent 根层运行时配置管理。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
|
||||
CURRENT_PERSONA_FILE = "CURRENT_PERSONA.md"
|
||||
SYSTEM_RUNTIME_DIR = "runtime"
|
||||
MEMORY_DIR = "memory"
|
||||
SKILLS_DIR = "skills"
|
||||
JOBS_DIR = "jobs"
|
||||
ACTIVITY_DIR = "activity"
|
||||
PERSONAS_DIR = "personas"
|
||||
PERSONA_FILE = "PERSONA.md"
|
||||
CURRENT_PERSONA_SCHEMA_VERSION = 3
|
||||
PERSONA_SCHEMA_VERSION = 1
|
||||
DEFAULT_PERSONA_ID = "default"
|
||||
PERSONA_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
|
||||
|
||||
ROOT_LEVEL_RUNTIME_FILES = {
|
||||
CURRENT_PERSONA_FILE,
|
||||
}
|
||||
|
||||
OBSOLETE_AGENT_ROOT_FILES = {
|
||||
"AGENT_CORE.md",
|
||||
"AGENT_PROFILE.md",
|
||||
"AGENT_WORKFLOW.md",
|
||||
"AGENT_HOOKS.md",
|
||||
"USER_PREFERENCES.md",
|
||||
"SYSTEM_TASKS.md",
|
||||
"WAKE_FORMAT.md",
|
||||
}
|
||||
|
||||
OBSOLETE_RUNTIME_FILES = {
|
||||
Path("AGENT_CORE.md"),
|
||||
Path("AGENT_PROFILE.md"),
|
||||
Path("AGENT_WORKFLOW.md"),
|
||||
Path("AGENT_HOOKS.md"),
|
||||
Path("USER_PREFERENCES.md"),
|
||||
Path("SYSTEM_TASKS.md"),
|
||||
Path("WAKE_FORMAT.md"),
|
||||
Path("personas") / DEFAULT_PERSONA_ID / "AGENT_PROFILE.md",
|
||||
Path("personas") / DEFAULT_PERSONA_ID / "AGENT_WORKFLOW.md",
|
||||
Path("personas") / DEFAULT_PERSONA_ID / "AGENT_HOOKS.md",
|
||||
Path("system_tasks") / "SYSTEM_TASKS.md",
|
||||
Path("templates") / "WAKE_FORMAT.md",
|
||||
}
|
||||
|
||||
FRONTMATTER_PATTERN = re.compile(r"^---\s*\n(.*?)\n---\s*\n?", re.DOTALL)
|
||||
|
||||
|
||||
class AgentRuntimeConfigError(ValueError):
|
||||
"""根层配置加载异常。"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedMarkdownDocument:
|
||||
"""解析后的 Markdown 文档。"""
|
||||
|
||||
metadata: dict[str, Any]
|
||||
body: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class PersonaDefinition:
|
||||
"""单个人格定义。"""
|
||||
|
||||
persona_id: str
|
||||
path: Path
|
||||
label: str
|
||||
description: str
|
||||
text: str
|
||||
aliases: list[str] = field(default_factory=list)
|
||||
|
||||
def matches(self, query: str) -> bool:
|
||||
"""判断 query 是否命中当前人格。"""
|
||||
normalized = query.strip().casefold()
|
||||
if not normalized:
|
||||
return False
|
||||
candidates = [self.persona_id, self.label, *self.aliases]
|
||||
return any(candidate.strip().casefold() == normalized for candidate in candidates)
|
||||
|
||||
def summary_line(self) -> str:
|
||||
"""渲染可读的一行人格摘要。"""
|
||||
parts = [f"`{self.persona_id}`"]
|
||||
if self.label and self.label != self.persona_id:
|
||||
parts.append(self.label)
|
||||
if self.description:
|
||||
parts.append(self.description)
|
||||
return " - ".join(parts)
|
||||
|
||||
def to_dict(self, *, is_active: bool) -> dict[str, Any]:
|
||||
"""输出给查询工具的结构化信息。"""
|
||||
return {
|
||||
"persona_id": self.persona_id,
|
||||
"label": self.label,
|
||||
"description": self.description,
|
||||
"aliases": self.aliases,
|
||||
"is_active": is_active,
|
||||
"path": str(self.path),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentRuntimeConfig:
|
||||
"""一次加载后的根层配置快照。"""
|
||||
|
||||
source_root: Path
|
||||
active_persona: str
|
||||
current_persona_path: Path
|
||||
persona: PersonaDefinition
|
||||
available_personas: list[PersonaDefinition]
|
||||
extra_context_paths: list[Path]
|
||||
extra_contexts: list[tuple[Path, str]]
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
used_fallback: bool = False
|
||||
|
||||
def render_prompt_sections(self) -> str:
|
||||
"""渲染进入系统提示词的运行时片段。"""
|
||||
sections: list[str] = [
|
||||
"<agent_runtime_config>",
|
||||
f"- Active persona: `{self.active_persona}`",
|
||||
f"- Active persona source: `{self.persona.path}`",
|
||||
]
|
||||
if self.available_personas:
|
||||
sections.append("- Available personas:")
|
||||
sections.extend(f" - {persona.summary_line()}" for persona in self.available_personas)
|
||||
sections.append("</agent_runtime_config>")
|
||||
|
||||
if self.warnings:
|
||||
sections.extend(
|
||||
[
|
||||
"",
|
||||
"<agent_runtime_warnings>",
|
||||
*[f"- {warning}" for warning in self.warnings],
|
||||
"</agent_runtime_warnings>",
|
||||
]
|
||||
)
|
||||
|
||||
sections.extend(
|
||||
[
|
||||
"",
|
||||
"<agent_persona>",
|
||||
f"- Persona ID: `{self.persona.persona_id}`",
|
||||
]
|
||||
)
|
||||
if self.persona.label and self.persona.label != self.persona.persona_id:
|
||||
sections.append(f"- Persona Label: {self.persona.label}")
|
||||
if self.persona.description:
|
||||
sections.append(f"- Persona Description: {self.persona.description}")
|
||||
sections.extend(
|
||||
[
|
||||
"",
|
||||
self.persona.text.strip() or "(No persona instructions configured.)",
|
||||
"</agent_persona>",
|
||||
]
|
||||
)
|
||||
for path, text in self.extra_contexts:
|
||||
if not text.strip():
|
||||
continue
|
||||
sections.extend(
|
||||
[
|
||||
"",
|
||||
f'<agent_extra_context source="{path.name}">',
|
||||
text.strip(),
|
||||
"</agent_extra_context>",
|
||||
]
|
||||
)
|
||||
return "\n".join(sections).strip()
|
||||
|
||||
def list_personas(self) -> list[dict[str, Any]]:
|
||||
"""返回全部人格摘要。"""
|
||||
return [
|
||||
persona.to_dict(is_active=persona.persona_id == self.active_persona)
|
||||
for persona in self.available_personas
|
||||
]
|
||||
|
||||
|
||||
class AgentRuntimeManager:
|
||||
"""统一管理 agent 根层运行时配置目录、校验与人格切换。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
agent_root_dir: Optional[Path] = None,
|
||||
bundled_defaults_dir: Optional[Path] = None,
|
||||
) -> None:
|
||||
self.agent_root_dir = agent_root_dir or (settings.CONFIG_PATH / "agent")
|
||||
self.runtime_dir = self.agent_root_dir / SYSTEM_RUNTIME_DIR
|
||||
self.memory_dir = self.agent_root_dir / MEMORY_DIR
|
||||
self.skills_dir = self.agent_root_dir / SKILLS_DIR
|
||||
self.jobs_dir = self.agent_root_dir / JOBS_DIR
|
||||
self.activity_dir = self.agent_root_dir / ACTIVITY_DIR
|
||||
self.bundled_defaults_dir = bundled_defaults_dir or (
|
||||
Path(__file__).parent / "defaults"
|
||||
)
|
||||
self._cache_lock = threading.Lock()
|
||||
self._cached_signature: Optional[tuple[tuple[str, int, int], ...]] = None
|
||||
self._cached_config: Optional[AgentRuntimeConfig] = None
|
||||
|
||||
def ensure_layout(self) -> None:
|
||||
"""创建目录、同步默认文件,并清理废弃的旧版 runtime 文件。"""
|
||||
self.agent_root_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.runtime_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.memory_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.skills_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.jobs_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.activity_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._migrate_root_runtime_files()
|
||||
self._remove_obsolete_runtime_files()
|
||||
self._sync_bundled_defaults()
|
||||
self._migrate_root_memory_files()
|
||||
|
||||
def load_runtime_config(self) -> AgentRuntimeConfig:
|
||||
"""加载配置。用户目录损坏时自动回退到内置默认配置。"""
|
||||
self.ensure_layout()
|
||||
signature = self._build_signature()
|
||||
with self._cache_lock:
|
||||
if self._cached_signature == signature and self._cached_config:
|
||||
return self._cached_config
|
||||
|
||||
try:
|
||||
config = self._load_from_root(self.runtime_dir)
|
||||
except AgentRuntimeConfigError as err:
|
||||
logger.warning("Agent 根层配置无效,回退到内置默认配置: %s", err)
|
||||
config = self._load_from_root(self.bundled_defaults_dir)
|
||||
config.used_fallback = True
|
||||
config.warnings.insert(
|
||||
0, f"用户运行时配置加载失败,已回退到内置默认配置: {err}"
|
||||
)
|
||||
|
||||
self._cached_signature = signature
|
||||
self._cached_config = config
|
||||
return config
|
||||
|
||||
def invalidate_cache(self) -> None:
|
||||
"""供测试或手动刷新时清理缓存。"""
|
||||
with self._cache_lock:
|
||||
self._cached_signature = None
|
||||
self._cached_config = None
|
||||
|
||||
def set_active_persona(self, persona_query: str) -> AgentRuntimeConfig:
|
||||
"""切换当前激活人格,并立即刷新缓存。"""
|
||||
self.ensure_layout()
|
||||
runtime_root = self.runtime_dir
|
||||
current_path = runtime_root / CURRENT_PERSONA_FILE
|
||||
current_doc = self._read_markdown(current_path)
|
||||
current_meta = current_doc.metadata
|
||||
|
||||
available_personas = self._load_personas(runtime_root)
|
||||
persona = self._resolve_persona_definition(persona_query, available_personas)
|
||||
|
||||
document = self._render_current_persona_document(
|
||||
active_persona=persona.persona_id,
|
||||
extra_context_files=self._coerce_string_list(
|
||||
current_meta.get("extra_context_files")
|
||||
),
|
||||
deprecated_phrases=self._coerce_string_list(
|
||||
current_meta.get("deprecated_phrases")
|
||||
),
|
||||
)
|
||||
current_path.write_text(document, encoding="utf-8")
|
||||
self.invalidate_cache()
|
||||
logger.info("已切换 Agent 人格: %s", persona.persona_id)
|
||||
return self.load_runtime_config()
|
||||
|
||||
def list_personas(self) -> list[PersonaDefinition]:
|
||||
"""列出当前可用人格。"""
|
||||
return self.load_runtime_config().available_personas
|
||||
|
||||
def update_persona_definition(
|
||||
self,
|
||||
persona_query: str,
|
||||
*,
|
||||
label: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
aliases: Optional[list[str]] = None,
|
||||
instructions: Optional[str] = None,
|
||||
append_instructions: Optional[list[str]] = None,
|
||||
create_if_missing: bool = False,
|
||||
) -> tuple[PersonaDefinition, bool]:
|
||||
"""更新或创建运行时人格定义。"""
|
||||
self.ensure_layout()
|
||||
runtime_root = self.runtime_dir
|
||||
available_personas = self._load_personas(runtime_root)
|
||||
|
||||
created = False
|
||||
try:
|
||||
persona = self._resolve_persona_definition(persona_query, available_personas)
|
||||
target_persona_id = persona.persona_id
|
||||
target_path = persona.path
|
||||
existing_body = persona.text
|
||||
existing_label = persona.label
|
||||
existing_description = persona.description
|
||||
existing_aliases = list(persona.aliases)
|
||||
except AgentRuntimeConfigError:
|
||||
if not create_if_missing:
|
||||
raise
|
||||
target_persona_id = self._validate_new_persona_id(persona_query)
|
||||
target_path = runtime_root / PERSONAS_DIR / target_persona_id / PERSONA_FILE
|
||||
existing_body = ""
|
||||
existing_label = target_persona_id
|
||||
existing_description = ""
|
||||
existing_aliases = []
|
||||
created = True
|
||||
|
||||
final_label = (
|
||||
label.strip()
|
||||
if isinstance(label, str) and label.strip()
|
||||
else existing_label or target_persona_id
|
||||
)
|
||||
final_description = (
|
||||
description.strip()
|
||||
if isinstance(description, str) and description.strip()
|
||||
else existing_description
|
||||
)
|
||||
final_aliases = (
|
||||
self._normalize_persona_aliases(aliases, "aliases")
|
||||
if aliases is not None
|
||||
else existing_aliases
|
||||
)
|
||||
final_body = (
|
||||
self._normalize_persona_body(instructions)
|
||||
if isinstance(instructions, str) and instructions.strip()
|
||||
else self._normalize_persona_body(existing_body)
|
||||
)
|
||||
final_body = self._merge_persona_instructions(
|
||||
final_body,
|
||||
append_instructions,
|
||||
)
|
||||
if not final_body.strip():
|
||||
raise AgentRuntimeConfigError("人格定义正文不能为空")
|
||||
|
||||
document = self._render_persona_document(
|
||||
persona_id=target_persona_id,
|
||||
label=final_label,
|
||||
description=final_description,
|
||||
aliases=final_aliases,
|
||||
body=final_body,
|
||||
)
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
target_path.write_text(document, encoding="utf-8")
|
||||
self.invalidate_cache()
|
||||
|
||||
runtime_config = self.load_runtime_config()
|
||||
updated_persona = self._resolve_persona_definition(
|
||||
target_persona_id,
|
||||
runtime_config.available_personas,
|
||||
)
|
||||
logger.info(
|
||||
"已%s Agent 人格定义: %s",
|
||||
"创建" if created else "更新",
|
||||
updated_persona.persona_id,
|
||||
)
|
||||
return updated_persona, created
|
||||
|
||||
def _build_signature(self) -> tuple[tuple[str, int, int], ...]:
|
||||
"""基于运行时配置和内置人格生成文件签名。"""
|
||||
entries: list[tuple[str, int, int]] = []
|
||||
for prefix, root in (
|
||||
("runtime", self.runtime_dir),
|
||||
("bundled", self.bundled_defaults_dir),
|
||||
):
|
||||
if not root.exists():
|
||||
continue
|
||||
for path in sorted(root.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
stat = path.stat()
|
||||
relative = path.relative_to(root).as_posix()
|
||||
entries.append((f"{prefix}:{relative}", stat.st_mtime_ns, stat.st_size))
|
||||
return tuple(entries)
|
||||
|
||||
def _sync_bundled_defaults(self) -> None:
|
||||
"""仅复制缺失的默认运行时文件,避免覆盖用户自定义。"""
|
||||
if not self.bundled_defaults_dir.exists():
|
||||
return
|
||||
for path in sorted(self.bundled_defaults_dir.rglob("*")):
|
||||
relative = path.relative_to(self.bundled_defaults_dir)
|
||||
target = self.runtime_dir / relative
|
||||
if path.is_dir():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
if target.exists():
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(path, target)
|
||||
logger.info("已同步默认 Agent 运行时文件: %s", target)
|
||||
|
||||
def _migrate_root_runtime_files(self) -> None:
|
||||
"""兼容早期直接放在 `config/agent` 根目录的 CURRENT_PERSONA。"""
|
||||
source = self.agent_root_dir / CURRENT_PERSONA_FILE
|
||||
target = self.runtime_dir / CURRENT_PERSONA_FILE
|
||||
if not source.exists() or target.exists():
|
||||
return
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
source.rename(target)
|
||||
logger.info("已迁移旧版 Agent 根配置文件: %s -> %s", source, target)
|
||||
|
||||
def _remove_obsolete_runtime_files(self) -> None:
|
||||
"""删除不再支持的旧版 Agent 配置文件,避免被误迁移到 memory。"""
|
||||
for filename in sorted(OBSOLETE_AGENT_ROOT_FILES):
|
||||
path = self.agent_root_dir / filename
|
||||
if not path.exists() or not path.is_file():
|
||||
continue
|
||||
path.unlink()
|
||||
logger.info("已删除废弃的 Agent 根配置文件: %s", path)
|
||||
|
||||
for relative_path in sorted(OBSOLETE_RUNTIME_FILES):
|
||||
path = self.runtime_dir / relative_path
|
||||
if not path.exists() or not path.is_file():
|
||||
continue
|
||||
path.unlink()
|
||||
logger.info("已删除废弃的 Agent 运行时文件: %s", path)
|
||||
|
||||
def _migrate_root_memory_files(self) -> None:
|
||||
"""将旧版根目录 memory 文件移入 `config/agent/memory`。"""
|
||||
for path in sorted(self.agent_root_dir.glob("*.md")):
|
||||
if path.name in ROOT_LEVEL_RUNTIME_FILES:
|
||||
continue
|
||||
target = self.memory_dir / path.name
|
||||
if target.exists():
|
||||
continue
|
||||
path.rename(target)
|
||||
logger.info("已迁移旧版 Agent memory 文件: %s -> %s", path, target)
|
||||
|
||||
def _load_from_root(self, root: Path) -> AgentRuntimeConfig:
|
||||
current_persona_path = root / CURRENT_PERSONA_FILE
|
||||
current_doc = self._read_markdown(current_persona_path)
|
||||
current_meta = current_doc.metadata
|
||||
|
||||
active_persona = str(
|
||||
current_meta.get("active_persona") or DEFAULT_PERSONA_ID
|
||||
).strip()
|
||||
if not active_persona:
|
||||
raise AgentRuntimeConfigError("CURRENT_PERSONA.md 缺少 active_persona")
|
||||
|
||||
extra_context_paths = self._resolve_optional_paths(
|
||||
root, current_meta.get("extra_context_files", [])
|
||||
)
|
||||
|
||||
available_personas = self._load_personas(root)
|
||||
persona = self._resolve_persona_definition(active_persona, available_personas)
|
||||
extra_contexts = [
|
||||
(path, self._read_markdown(path).body)
|
||||
for path in extra_context_paths
|
||||
]
|
||||
|
||||
warnings = self._validate_runtime_config(
|
||||
current_meta=current_meta,
|
||||
persona_path=persona.path,
|
||||
extra_context_paths=extra_context_paths,
|
||||
persona_text=persona.text,
|
||||
)
|
||||
return AgentRuntimeConfig(
|
||||
source_root=root,
|
||||
active_persona=active_persona,
|
||||
current_persona_path=current_persona_path,
|
||||
persona=persona,
|
||||
available_personas=available_personas,
|
||||
extra_context_paths=extra_context_paths,
|
||||
extra_contexts=extra_contexts,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
def _load_personas(self, root: Path) -> list[PersonaDefinition]:
|
||||
"""扫描并解析所有可用人格。"""
|
||||
personas_root = root / PERSONAS_DIR
|
||||
if not personas_root.exists():
|
||||
raise AgentRuntimeConfigError(f"缺少 personas 目录: {personas_root}")
|
||||
|
||||
personas: list[PersonaDefinition] = []
|
||||
seen_ids: set[str] = set()
|
||||
for persona_dir in sorted(personas_root.iterdir()):
|
||||
if not persona_dir.is_dir():
|
||||
continue
|
||||
persona_path = persona_dir / PERSONA_FILE
|
||||
if not persona_path.exists():
|
||||
continue
|
||||
document = self._read_markdown(persona_path)
|
||||
persona_id = str(document.metadata.get("persona_id") or persona_dir.name).strip()
|
||||
if not persona_id:
|
||||
raise AgentRuntimeConfigError(f"{persona_path} 缺少 persona_id")
|
||||
if persona_id in seen_ids:
|
||||
raise AgentRuntimeConfigError(f"检测到重复的人格 ID: {persona_id}")
|
||||
seen_ids.add(persona_id)
|
||||
aliases = self._normalize_string_list(
|
||||
document.metadata.get("aliases"),
|
||||
f"{persona_path}.aliases",
|
||||
)
|
||||
personas.append(
|
||||
PersonaDefinition(
|
||||
persona_id=persona_id,
|
||||
path=persona_path,
|
||||
label=str(document.metadata.get("label") or persona_id).strip(),
|
||||
description=str(document.metadata.get("description") or "").strip(),
|
||||
text=document.body,
|
||||
aliases=aliases,
|
||||
)
|
||||
)
|
||||
|
||||
if not personas:
|
||||
raise AgentRuntimeConfigError(f"{personas_root} 中未找到任何人格定义")
|
||||
return personas
|
||||
|
||||
@staticmethod
|
||||
def _resolve_persona_definition(
|
||||
persona_query: str,
|
||||
personas: list[PersonaDefinition],
|
||||
) -> PersonaDefinition:
|
||||
"""按 persona_id、label 或 aliases 解析人格。"""
|
||||
normalized = (persona_query or "").strip()
|
||||
if not normalized:
|
||||
raise AgentRuntimeConfigError("人格 ID 不能为空")
|
||||
|
||||
for persona in personas:
|
||||
if persona.persona_id == normalized:
|
||||
return persona
|
||||
for persona in personas:
|
||||
if persona.matches(normalized):
|
||||
return persona
|
||||
|
||||
available = ", ".join(persona.persona_id for persona in personas)
|
||||
raise AgentRuntimeConfigError(
|
||||
f"未找到人格 `{persona_query}`,可用人格: {available}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_new_persona_id(persona_id: str) -> str:
|
||||
"""校验新建人格的 ID,避免写入非法路径。"""
|
||||
normalized = (persona_id or "").strip()
|
||||
if not normalized:
|
||||
raise AgentRuntimeConfigError("新建人格时 persona_id 不能为空")
|
||||
if not PERSONA_ID_PATTERN.fullmatch(normalized):
|
||||
raise AgentRuntimeConfigError(
|
||||
"新建人格时 persona_id 只能使用小写字母、数字、下划线和中划线,且必须以字母或数字开头"
|
||||
)
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _read_markdown(path: Path) -> ParsedMarkdownDocument:
|
||||
if not path.exists():
|
||||
raise AgentRuntimeConfigError(f"缺少配置文件: {path}")
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except Exception as err: # noqa: BLE001
|
||||
raise AgentRuntimeConfigError(f"读取配置文件失败 {path}: {err}") from err
|
||||
|
||||
metadata: dict[str, Any] = {}
|
||||
body = content
|
||||
match = FRONTMATTER_PATTERN.match(content)
|
||||
if match:
|
||||
try:
|
||||
metadata = yaml.safe_load(match.group(1)) or {}
|
||||
except yaml.YAMLError as err:
|
||||
raise AgentRuntimeConfigError(
|
||||
f"YAML frontmatter 解析失败 {path}: {err}"
|
||||
) from err
|
||||
if not isinstance(metadata, dict):
|
||||
raise AgentRuntimeConfigError(f"frontmatter 必须是映射类型: {path}")
|
||||
body = content[match.end():]
|
||||
return ParsedMarkdownDocument(metadata=metadata, body=body.strip())
|
||||
|
||||
@staticmethod
|
||||
def _resolve_optional_paths(root: Path, values: Any) -> list[Path]:
|
||||
if not values:
|
||||
return []
|
||||
if not isinstance(values, list):
|
||||
raise AgentRuntimeConfigError("extra_context_files 必须是数组")
|
||||
return [AgentRuntimeManager._resolve_relative_path(root, str(value)) for value in values]
|
||||
|
||||
@staticmethod
|
||||
def _resolve_relative_path(root: Path, value: str) -> Path:
|
||||
candidate = Path(value)
|
||||
return candidate if candidate.is_absolute() else (root / candidate).resolve()
|
||||
|
||||
@staticmethod
|
||||
def _normalize_string_list(values: Any, field_name: str) -> list[str]:
|
||||
if values is None:
|
||||
return []
|
||||
if not isinstance(values, list):
|
||||
raise AgentRuntimeConfigError(f"{field_name} 必须是字符串数组")
|
||||
normalized: list[str] = []
|
||||
for value in values:
|
||||
text = str(value).strip()
|
||||
if text:
|
||||
normalized.append(text)
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _coerce_string_list(values: Any) -> list[str]:
|
||||
if not isinstance(values, list):
|
||||
return []
|
||||
return [str(value).strip() for value in values if str(value).strip()]
|
||||
|
||||
@staticmethod
|
||||
def _normalize_persona_aliases(values: Any, field_name: str) -> list[str]:
|
||||
"""规范化人格别名,保持顺序并去重。"""
|
||||
normalized = AgentRuntimeManager._normalize_string_list(values, field_name)
|
||||
deduped: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for alias in normalized:
|
||||
folded = alias.casefold()
|
||||
if folded in seen:
|
||||
continue
|
||||
seen.add(folded)
|
||||
deduped.append(alias)
|
||||
return deduped
|
||||
|
||||
@staticmethod
|
||||
def _merge_persona_instructions(
|
||||
base_body: str,
|
||||
append_instructions: Optional[list[str]],
|
||||
) -> str:
|
||||
"""把增量规则安全追加到人格正文末尾。"""
|
||||
merged = (base_body or "").strip()
|
||||
if not append_instructions:
|
||||
return merged
|
||||
|
||||
extras: list[str] = []
|
||||
for item in append_instructions:
|
||||
text = str(item).strip()
|
||||
if not text:
|
||||
continue
|
||||
if not re.match(r"^([-*]|\d+\.)\s", text):
|
||||
text = f"- {text}"
|
||||
extras.append(text)
|
||||
|
||||
if not extras:
|
||||
return merged
|
||||
if not merged:
|
||||
return "\n".join(extras)
|
||||
return merged.rstrip() + "\n\n" + "\n".join(extras)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_persona_body(body: Optional[str]) -> str:
|
||||
"""去掉重复的 PERSONA 标题,保持正文可安全回写。"""
|
||||
normalized = (body or "").strip()
|
||||
if not normalized:
|
||||
return ""
|
||||
if normalized.startswith("# PERSONA"):
|
||||
_, _, remainder = normalized.partition("\n")
|
||||
return remainder.strip()
|
||||
return normalized
|
||||
|
||||
def _validate_runtime_config(
|
||||
self,
|
||||
*,
|
||||
current_meta: dict[str, Any],
|
||||
persona_path: Path,
|
||||
extra_context_paths: list[Path],
|
||||
persona_text: str,
|
||||
) -> list[str]:
|
||||
warnings: list[str] = []
|
||||
required_paths = [persona_path]
|
||||
duplicates = self._find_duplicate_paths(required_paths + extra_context_paths)
|
||||
if duplicates:
|
||||
warnings.append(
|
||||
"检测到重复引用的根层配置文件: "
|
||||
+ ", ".join(path.as_posix() for path in duplicates)
|
||||
)
|
||||
|
||||
deprecated_phrases = self._normalize_string_list(
|
||||
current_meta.get("deprecated_phrases"), "deprecated_phrases"
|
||||
)
|
||||
if deprecated_phrases:
|
||||
for phrase in deprecated_phrases:
|
||||
if phrase and phrase in persona_text:
|
||||
warnings.append(f"检测到已废弃短语 `{phrase}` 仍出现在 persona 中")
|
||||
return warnings
|
||||
|
||||
@staticmethod
|
||||
def _find_duplicate_paths(paths: Iterable[Path]) -> list[Path]:
|
||||
seen: set[Path] = set()
|
||||
duplicates: list[Path] = []
|
||||
for path in paths:
|
||||
resolved = path.resolve()
|
||||
if resolved in seen and resolved not in duplicates:
|
||||
duplicates.append(resolved)
|
||||
seen.add(resolved)
|
||||
return duplicates
|
||||
|
||||
@staticmethod
|
||||
def _render_current_persona_document(
|
||||
*,
|
||||
active_persona: str,
|
||||
extra_context_files: list[str],
|
||||
deprecated_phrases: list[str],
|
||||
) -> str:
|
||||
"""统一生成 CURRENT_PERSONA.md,避免手写时结构漂移。"""
|
||||
metadata = {
|
||||
"version": CURRENT_PERSONA_SCHEMA_VERSION,
|
||||
"active_persona": active_persona,
|
||||
"extra_context_files": extra_context_files,
|
||||
"deprecated_phrases": deprecated_phrases,
|
||||
}
|
||||
body_lines = [
|
||||
"# CURRENT_PERSONA",
|
||||
"",
|
||||
f"当前激活人格:`{active_persona}`",
|
||||
"",
|
||||
"运行时加载顺序固定如下:",
|
||||
"",
|
||||
"1. 核心系统提示词(程序内置,不可运行时覆盖)",
|
||||
"2. `personas/<active_persona>/PERSONA.md`",
|
||||
"3. `extra_context_files`",
|
||||
"4. `memory/*.md`",
|
||||
"5. `activity/*.md`",
|
||||
"",
|
||||
"`memory` 中的长期偏好可以细化回复方式,但不应覆盖系统核心身份、目标和安全边界。",
|
||||
]
|
||||
frontmatter = yaml.safe_dump(
|
||||
metadata,
|
||||
sort_keys=False,
|
||||
allow_unicode=True,
|
||||
).strip()
|
||||
return f"---\n{frontmatter}\n---\n" + "\n".join(body_lines) + "\n"
|
||||
|
||||
@staticmethod
|
||||
def _render_persona_document(
|
||||
*,
|
||||
persona_id: str,
|
||||
label: str,
|
||||
description: str,
|
||||
aliases: list[str],
|
||||
body: str,
|
||||
) -> str:
|
||||
"""统一生成人格定义文件,避免手写 frontmatter 漂移。"""
|
||||
metadata = {
|
||||
"version": PERSONA_SCHEMA_VERSION,
|
||||
"persona_id": persona_id,
|
||||
"label": label,
|
||||
"description": description,
|
||||
"aliases": aliases,
|
||||
}
|
||||
frontmatter = yaml.safe_dump(
|
||||
metadata,
|
||||
sort_keys=False,
|
||||
allow_unicode=True,
|
||||
).strip()
|
||||
normalized_body = AgentRuntimeManager._normalize_persona_body(body)
|
||||
return f"---\n{frontmatter}\n---\n# PERSONA\n\n{normalized_body}\n"
|
||||
|
||||
|
||||
agent_runtime_manager = AgentRuntimeManager()
|
||||
@@ -1,6 +1,10 @@
|
||||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from typing import Any, Optional
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from functools import partial
|
||||
from typing import Any, Callable, ClassVar, Optional
|
||||
|
||||
from langchain_core.tools import BaseTool
|
||||
from pydantic import PrivateAttr
|
||||
@@ -19,11 +23,101 @@ class ToolChain(ChainBase):
|
||||
pass
|
||||
|
||||
|
||||
# 单个工具结果的兜底上限。各工具仍应优先在自身逻辑中分页或摘要化;
|
||||
# 这里用于拦截遗漏路径,避免超大结果直接进入模型上下文。
|
||||
DEFAULT_TOOL_RESULT_MAX_CHARS = 64 * 1024
|
||||
MIN_TOOL_RESULT_PREVIEW_CHARS = 512
|
||||
|
||||
|
||||
def serialize_tool_result_for_agent(result: Any) -> str:
|
||||
"""将工具返回值稳定转换为 Agent 可消费的字符串。"""
|
||||
if isinstance(result, str):
|
||||
return result
|
||||
if isinstance(result, (int, float)):
|
||||
return str(result)
|
||||
try:
|
||||
return json.dumps(result, ensure_ascii=False, indent=2, default=str)
|
||||
except Exception as e:
|
||||
logger.warning(f"工具结果转换为JSON失败: {e}, 使用字符串表示")
|
||||
return str(result)
|
||||
|
||||
|
||||
def format_tool_result_for_agent(
|
||||
result: Any,
|
||||
*,
|
||||
tool_name: Optional[str] = None,
|
||||
max_chars: Optional[int] = DEFAULT_TOOL_RESULT_MAX_CHARS,
|
||||
) -> str:
|
||||
"""
|
||||
统一格式化工具结果,并在超长时返回结构化预览。
|
||||
|
||||
具体工具可以通过 `result_max_chars` 覆盖上限;传入 None 或 <=0 表示不截断。
|
||||
"""
|
||||
formatted_result = serialize_tool_result_for_agent(result)
|
||||
if not max_chars or max_chars <= 0 or len(formatted_result) <= max_chars:
|
||||
return formatted_result
|
||||
|
||||
preview_limit = max(MIN_TOOL_RESULT_PREVIEW_CHARS, max_chars)
|
||||
preview = formatted_result[:preview_limit]
|
||||
payload = {
|
||||
"tool_result_truncated": True,
|
||||
"tool_name": tool_name,
|
||||
"total_chars": len(formatted_result),
|
||||
"returned_chars": len(preview),
|
||||
"content_preview": preview,
|
||||
"message": (
|
||||
f"工具返回内容超过 {max_chars} 字符,已截断为预览;"
|
||||
"请使用更精确的筛选条件、分页参数或专用查询参数继续获取。"
|
||||
),
|
||||
}
|
||||
return json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
# 将常见的阻塞调用按能力域拆分到独立线程池,避免外部慢 IO 抢占同一批 worker。
|
||||
_BLOCKING_BUCKET_LIMITS = {
|
||||
"default": 4,
|
||||
"config": 2,
|
||||
"db": 4,
|
||||
"downloader": 4,
|
||||
"mediaserver": 4,
|
||||
"plugin": 2,
|
||||
"rule": 2,
|
||||
"site": 4,
|
||||
"storage": 4,
|
||||
"subscribe": 2,
|
||||
"workflow": 2,
|
||||
}
|
||||
_blocking_semaphores = {
|
||||
bucket: asyncio.Semaphore(limit)
|
||||
for bucket, limit in _BLOCKING_BUCKET_LIMITS.items()
|
||||
}
|
||||
_blocking_executors: dict[str, ThreadPoolExecutor] = {}
|
||||
_blocking_executor_lock = threading.Lock()
|
||||
|
||||
|
||||
def _get_blocking_executor(bucket: str) -> ThreadPoolExecutor:
|
||||
"""按桶懒加载线程池,避免在导入阶段创建过多 worker。"""
|
||||
with _blocking_executor_lock:
|
||||
executor = _blocking_executors.get(bucket)
|
||||
if executor:
|
||||
return executor
|
||||
|
||||
limit = _BLOCKING_BUCKET_LIMITS[bucket]
|
||||
executor = ThreadPoolExecutor(
|
||||
max_workers=limit,
|
||||
thread_name_prefix=f"agent-tool-{bucket}",
|
||||
)
|
||||
_blocking_executors[bucket] = executor
|
||||
return executor
|
||||
|
||||
|
||||
class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
"""
|
||||
MoviePilot专用工具基类(LangChain v1 / langchain_core)
|
||||
"""
|
||||
|
||||
result_max_chars: ClassVar[Optional[int]] = DEFAULT_TOOL_RESULT_MAX_CHARS
|
||||
|
||||
_session_id: str = PrivateAttr()
|
||||
_user_id: str = PrivateAttr()
|
||||
_channel: Optional[str] = PrivateAttr(default=None)
|
||||
@@ -71,20 +165,44 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
if tool_message:
|
||||
self._stream_handler.emit(f"\n\n⚙️ => {tool_message}\n\n")
|
||||
else:
|
||||
# 渠道不支持编辑:取出 Agent 文字 + 工具消息合并独立发送
|
||||
agent_message = await self._stream_handler.take()
|
||||
messages = []
|
||||
if agent_message:
|
||||
messages.append(agent_message)
|
||||
if tool_message:
|
||||
messages.append(f"⚙️ => {tool_message}")
|
||||
if messages:
|
||||
merged_message = "\n\n".join(messages)
|
||||
await self.send_tool_message(merged_message)
|
||||
allow_dispatch_without_context = self._agent_context.get(
|
||||
"should_dispatch_reply", False
|
||||
)
|
||||
if self._channel and self._source:
|
||||
# 渠道不支持编辑:取出 Agent 文字 + 工具消息合并独立发送
|
||||
agent_message = await self._stream_handler.take()
|
||||
messages = []
|
||||
if agent_message:
|
||||
messages.append(agent_message)
|
||||
if tool_message:
|
||||
messages.append(f"⚙️ => {tool_message}")
|
||||
if messages:
|
||||
merged_message = "\n\n".join(messages)
|
||||
await self.send_tool_message(merged_message)
|
||||
elif allow_dispatch_without_context:
|
||||
agent_message = await self._stream_handler.take()
|
||||
messages = []
|
||||
if agent_message:
|
||||
messages.append(agent_message)
|
||||
if tool_message:
|
||||
messages.append(f"⚙️ => {tool_message}")
|
||||
if messages:
|
||||
merged_message = "\n\n".join(messages)
|
||||
await self.send_tool_message(merged_message)
|
||||
else:
|
||||
# 后台 capture 流程没有渠道上下文,不能把工具提示回灌到默认通知渠道。
|
||||
self._stream_handler.record_tool_call(
|
||||
tool_name=self.name,
|
||||
tool_message=tool_message,
|
||||
tool_kwargs=kwargs,
|
||||
)
|
||||
else:
|
||||
# 非VERBOSE:工具边界至少补一个换行,避免工具前后的文本直接连在一起
|
||||
if self._stream_handler.last_buffer_char not in ("", "\n"):
|
||||
self._stream_handler.emit("\n")
|
||||
# 非VERBOSE:不逐条回显工具调用,转为在下一段文本前补一句聚合摘要
|
||||
self._stream_handler.record_tool_call(
|
||||
tool_name=self.name,
|
||||
tool_message=tool_message,
|
||||
tool_kwargs=kwargs,
|
||||
)
|
||||
else:
|
||||
# 未启用流式传输,不发送任何工具消息内容
|
||||
pass
|
||||
@@ -94,21 +212,23 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
# 执行具体工具逻辑
|
||||
try:
|
||||
result = await self.run(**kwargs)
|
||||
logger.debug(f"Tool {self.name} executed with result: {result}")
|
||||
|
||||
# 记录工具执行结果摘要日志
|
||||
str_result = serialize_tool_result_for_agent(result)
|
||||
if len(str_result) > 500:
|
||||
summary = str_result[:500] + f"...(已截断,总长度: {len(str_result)})"
|
||||
else:
|
||||
summary = str_result
|
||||
logger.info(f"Agent工具 {self.name} 执行完成,结果摘要: {summary}")
|
||||
|
||||
except Exception as e:
|
||||
error_message = f"工具执行异常 ({type(e).__name__}): {str(e)}"
|
||||
logger.error(f"Tool {self.name} execution failed: {e}", exc_info=True)
|
||||
result = error_message
|
||||
|
||||
# 格式化结果
|
||||
if isinstance(result, str):
|
||||
formatted_result = result
|
||||
elif isinstance(result, (int, float)):
|
||||
formatted_result = str(result)
|
||||
else:
|
||||
formatted_result = json.dumps(result, ensure_ascii=False, indent=2)
|
||||
|
||||
return formatted_result
|
||||
return format_tool_result_for_agent(
|
||||
result, tool_name=self.name, max_chars=self.result_max_chars
|
||||
)
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""
|
||||
@@ -123,13 +243,31 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
Returns:
|
||||
str: 友好的提示消息,如果返回 None 或空字符串则使用 explanation
|
||||
"""
|
||||
return None
|
||||
explanation = kwargs.get("explanation")
|
||||
return str(explanation) if explanation else None
|
||||
|
||||
@abstractmethod
|
||||
async def run(self, **kwargs) -> str:
|
||||
"""子类实现具体的工具执行逻辑"""
|
||||
raise NotImplementedError
|
||||
|
||||
@staticmethod
|
||||
async def run_blocking(
|
||||
bucket: str, func: Callable[..., Any], *args: Any, **kwargs: Any
|
||||
) -> Any:
|
||||
"""
|
||||
在受控线程池中运行阻塞型同步代码,避免拖住 FastAPI 主事件循环。
|
||||
"""
|
||||
bucket_name = bucket if bucket in _BLOCKING_BUCKET_LIMITS else "default"
|
||||
semaphore = _blocking_semaphores[bucket_name]
|
||||
bound_call = partial(func, *args, **kwargs)
|
||||
|
||||
async with semaphore:
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(
|
||||
_get_blocking_executor(bucket_name), bound_call
|
||||
)
|
||||
|
||||
def set_message_attr(self, channel: str, source: str, username: str):
|
||||
"""
|
||||
设置消息属性
|
||||
@@ -148,7 +286,9 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
"""
|
||||
设置与当前 Agent 共享的上下文。
|
||||
"""
|
||||
self._agent_context = agent_context or {}
|
||||
# 空 dict 也是合法共享上下文;不能用 ``or {}``,否则每个工具会拿到
|
||||
# 独立的新 dict,跨工具状态(例如质量门槛拒绝标记)无法传播。
|
||||
self._agent_context = {} if agent_context is None else agent_context
|
||||
|
||||
async def _check_permission(self) -> Optional[str]:
|
||||
"""
|
||||
@@ -165,12 +305,16 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
if not self._channel or not self._source:
|
||||
return None
|
||||
|
||||
# 渠道配置来自 SystemConfigOper 内存缓存,可以直接读取;
|
||||
# 只有用户信息需要走异步数据库查询。
|
||||
user_id_str = str(self._user_id) if self._user_id else None
|
||||
|
||||
channel_type_map = {
|
||||
MessageChannel.Telegram: "telegram",
|
||||
MessageChannel.Discord: "discord",
|
||||
MessageChannel.Wechat: "wechat",
|
||||
MessageChannel.Feishu: "feishu",
|
||||
MessageChannel.WechatClawBot: "wechatclawbot",
|
||||
MessageChannel.Slack: "slack",
|
||||
MessageChannel.VoceChat: "vocechat",
|
||||
MessageChannel.SynologyChat: "synologychat",
|
||||
@@ -190,6 +334,8 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
"telegram": "TELEGRAM_ADMINS",
|
||||
"discord": "DISCORD_ADMINS",
|
||||
"wechat": "WECHAT_ADMINS",
|
||||
"feishu": "FEISHU_ADMINS",
|
||||
"wechatclawbot": "WECHATCLAWBOT_ADMINS",
|
||||
"slack": "SLACK_ADMINS",
|
||||
"vocechat": "VOCECHAT_ADMINS",
|
||||
"synologychat": "SYNOLOGYCHAT_ADMINS",
|
||||
@@ -200,6 +346,8 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
"telegram": "TELEGRAM_CHAT_ID",
|
||||
"vocechat": "VOCECHAT_CHANNEL_ID",
|
||||
"wechat": "WECHAT_BOT_CHAT_ID",
|
||||
"feishu": "FEISHU_OPEN_ID",
|
||||
"wechatclawbot": "WECHATCLAWBOT_DEFAULT_TARGET",
|
||||
}
|
||||
|
||||
admin_key = admin_key_map.get(channel_type)
|
||||
@@ -220,7 +368,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
return None
|
||||
|
||||
user = (
|
||||
UserOper().get_by_name(self._username)
|
||||
await UserOper().async_get_by_name(self._username)
|
||||
if self._username
|
||||
else None
|
||||
)
|
||||
@@ -235,7 +383,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
)
|
||||
else:
|
||||
user = (
|
||||
UserOper().get_by_name(self._username)
|
||||
await UserOper().async_get_by_name(self._username)
|
||||
if self._username
|
||||
else None
|
||||
)
|
||||
|
||||
@@ -16,6 +16,14 @@ from app.agent.tools.impl.test_site import TestSiteTool
|
||||
from app.agent.tools.impl.query_subscribes import QuerySubscribesTool
|
||||
from app.agent.tools.impl.query_subscribe_shares import QuerySubscribeSharesTool
|
||||
from app.agent.tools.impl.query_rule_groups import QueryRuleGroupsTool
|
||||
from app.agent.tools.impl.query_builtin_filter_rules import QueryBuiltinFilterRulesTool
|
||||
from app.agent.tools.impl.query_custom_filter_rules import QueryCustomFilterRulesTool
|
||||
from app.agent.tools.impl.add_custom_filter_rule import AddCustomFilterRuleTool
|
||||
from app.agent.tools.impl.update_custom_filter_rule import UpdateCustomFilterRuleTool
|
||||
from app.agent.tools.impl.delete_custom_filter_rule import DeleteCustomFilterRuleTool
|
||||
from app.agent.tools.impl.add_rule_group import AddRuleGroupTool
|
||||
from app.agent.tools.impl.update_rule_group import UpdateRuleGroupTool
|
||||
from app.agent.tools.impl.delete_rule_group import DeleteRuleGroupTool
|
||||
from app.agent.tools.impl.query_popular_subscribes import QueryPopularSubscribesTool
|
||||
from app.agent.tools.impl.query_subscribe_history import QuerySubscribeHistoryTool
|
||||
from app.agent.tools.impl.delete_subscribe import DeleteSubscribeTool
|
||||
@@ -37,6 +45,9 @@ from app.agent.tools.impl.query_schedulers import QuerySchedulersTool
|
||||
from app.agent.tools.impl.run_scheduler import RunSchedulerTool
|
||||
from app.agent.tools.impl.query_workflows import QueryWorkflowsTool
|
||||
from app.agent.tools.impl.run_workflow import RunWorkflowTool
|
||||
from app.agent.tools.impl.query_personas import QueryPersonasTool
|
||||
from app.agent.tools.impl.switch_persona import SwitchPersonaTool
|
||||
from app.agent.tools.impl.update_persona_definition import UpdatePersonaDefinitionTool
|
||||
from app.agent.tools.impl.update_site_cookie import UpdateSiteCookieTool
|
||||
from app.agent.tools.impl.delete_download import DeleteDownloadTool
|
||||
from app.agent.tools.impl.delete_download_history import DeleteDownloadHistoryTool
|
||||
@@ -52,11 +63,21 @@ from app.agent.tools.impl.write_file import WriteFileTool
|
||||
from app.agent.tools.impl.read_file import ReadFileTool
|
||||
from app.agent.tools.impl.browse_webpage import BrowseWebpageTool
|
||||
from app.agent.tools.impl.query_installed_plugins import QueryInstalledPluginsTool
|
||||
from app.agent.tools.impl.query_market_plugins import QueryMarketPluginsTool
|
||||
from app.agent.tools.impl.query_plugin_capabilities import QueryPluginCapabilitiesTool
|
||||
from app.agent.tools.impl.query_plugin_config import QueryPluginConfigTool
|
||||
from app.agent.tools.impl.update_plugin_config import UpdatePluginConfigTool
|
||||
from app.agent.tools.impl.reload_plugin import ReloadPluginTool
|
||||
from app.agent.tools.impl.query_plugin_data import QueryPluginDataTool
|
||||
from app.agent.tools.impl.install_plugin import InstallPluginTool
|
||||
from app.agent.tools.impl.uninstall_plugin import UninstallPluginTool
|
||||
from app.agent.tools.impl.run_slash_command import RunSlashCommandTool
|
||||
from app.agent.tools.impl.list_slash_commands import ListSlashCommandsTool
|
||||
from app.agent.tools.impl.query_custom_identifiers import QueryCustomIdentifiersTool
|
||||
from app.agent.tools.impl.update_custom_identifiers import UpdateCustomIdentifiersTool
|
||||
from app.agent.tools.impl.query_system_settings import QuerySystemSettingsTool
|
||||
from app.agent.tools.impl.update_system_settings import UpdateSystemSettingsTool
|
||||
from app.agent.llm.capability import AgentCapabilityManager
|
||||
from app.core.plugin import PluginManager
|
||||
from app.log import logger
|
||||
from app.schemas.message import ChannelCapabilityManager
|
||||
@@ -69,6 +90,18 @@ class MoviePilotToolFactory:
|
||||
MoviePilot工具工厂
|
||||
"""
|
||||
|
||||
# 这些通用工具需要始终保留,避免大工具集裁剪后让 Agent 丢失基础的
|
||||
# 文件系统、命令执行或交互确认能力。AskUserChoiceTool 仅在支持按钮
|
||||
# 的渠道中才会实际注入,因此后续会再按已加载工具做一次求交集。
|
||||
TOOL_SELECTOR_ALWAYS_INCLUDE_NAMES = (
|
||||
"list_directory",
|
||||
"write_file",
|
||||
"read_file",
|
||||
"edit_file",
|
||||
"execute_command",
|
||||
"ask_user_choice",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _should_enable_choice_tool(channel: str = None) -> bool:
|
||||
if not channel:
|
||||
@@ -81,6 +114,25 @@ class MoviePilotToolFactory:
|
||||
message_channel
|
||||
) and ChannelCapabilityManager.supports_callbacks(message_channel)
|
||||
|
||||
@classmethod
|
||||
def get_tool_selector_always_include_names(
|
||||
cls, tools: List[MoviePilotTool]
|
||||
) -> List[str]:
|
||||
"""
|
||||
返回当前实际已加载且需要绕过工具筛选的工具名。
|
||||
|
||||
`LLMToolSelectorMiddleware` 会校验 `always_include` 中的工具名是否
|
||||
存在于当前请求里,因此这里必须根据运行时工具列表做交集过滤。
|
||||
"""
|
||||
available_tool_names = {
|
||||
tool.name for tool in tools if getattr(tool, "name", None)
|
||||
}
|
||||
return [
|
||||
tool_name
|
||||
for tool_name in cls.TOOL_SELECTOR_ALWAYS_INCLUDE_NAMES
|
||||
if tool_name in available_tool_names
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def create_tools(
|
||||
session_id: str,
|
||||
@@ -90,6 +142,7 @@ class MoviePilotToolFactory:
|
||||
username: str = None,
|
||||
stream_handler: Callable = None,
|
||||
agent_context: dict = None,
|
||||
allow_message_tools: bool = True,
|
||||
) -> List[MoviePilotTool]:
|
||||
"""
|
||||
创建MoviePilot工具列表
|
||||
@@ -113,7 +166,15 @@ class MoviePilotToolFactory:
|
||||
QuerySubscribesTool,
|
||||
QuerySubscribeSharesTool,
|
||||
QueryPopularSubscribesTool,
|
||||
QueryBuiltinFilterRulesTool,
|
||||
QueryCustomFilterRulesTool,
|
||||
QueryRuleGroupsTool,
|
||||
AddCustomFilterRuleTool,
|
||||
UpdateCustomFilterRuleTool,
|
||||
DeleteCustomFilterRuleTool,
|
||||
AddRuleGroupTool,
|
||||
UpdateRuleGroupTool,
|
||||
DeleteRuleGroupTool,
|
||||
QuerySubscribeHistoryTool,
|
||||
DeleteSubscribeTool,
|
||||
QueryDownloadTasksTool,
|
||||
@@ -139,29 +200,40 @@ class MoviePilotToolFactory:
|
||||
RunSchedulerTool,
|
||||
QueryWorkflowsTool,
|
||||
RunWorkflowTool,
|
||||
QueryPersonasTool,
|
||||
SwitchPersonaTool,
|
||||
UpdatePersonaDefinitionTool,
|
||||
ExecuteCommandTool,
|
||||
EditFileTool,
|
||||
WriteFileTool,
|
||||
ReadFileTool,
|
||||
BrowseWebpageTool,
|
||||
QueryInstalledPluginsTool,
|
||||
QueryMarketPluginsTool,
|
||||
QueryPluginCapabilitiesTool,
|
||||
QueryPluginConfigTool,
|
||||
UpdatePluginConfigTool,
|
||||
ReloadPluginTool,
|
||||
QueryPluginDataTool,
|
||||
InstallPluginTool,
|
||||
UninstallPluginTool,
|
||||
RunSlashCommandTool,
|
||||
ListSlashCommandsTool,
|
||||
QueryCustomIdentifiersTool,
|
||||
UpdateCustomIdentifiersTool,
|
||||
QuerySystemSettingsTool,
|
||||
UpdateSystemSettingsTool,
|
||||
]
|
||||
if MoviePilotToolFactory._should_enable_choice_tool(channel):
|
||||
tool_definitions.append(AskUserChoiceTool)
|
||||
tool_definitions.extend(
|
||||
[
|
||||
SendLocalFileTool,
|
||||
SendVoiceMessageTool,
|
||||
]
|
||||
)
|
||||
tool_definitions.append(SendLocalFileTool)
|
||||
if AgentCapabilityManager.supports_audio_output():
|
||||
tool_definitions.append(SendVoiceMessageTool)
|
||||
# 创建内置工具
|
||||
for ToolClass in tool_definitions:
|
||||
tool = ToolClass(session_id=session_id, user_id=user_id)
|
||||
if not allow_message_tools and getattr(tool, "sends_message", False):
|
||||
continue
|
||||
tool.set_message_attr(channel=channel, source=source, username=username)
|
||||
tool.set_stream_handler(stream_handler=stream_handler)
|
||||
tool.set_agent_context(agent_context=agent_context)
|
||||
@@ -184,6 +256,8 @@ class MoviePilotToolFactory:
|
||||
continue
|
||||
# 创建工具实例
|
||||
tool = ToolClass(session_id=session_id, user_id=user_id)
|
||||
if not allow_message_tools and getattr(tool, "sends_message", False):
|
||||
continue
|
||||
tool.set_message_attr(
|
||||
channel=channel, source=source, username=username
|
||||
)
|
||||
|
||||
540
app/agent/tools/impl/_filter_rule_utils.py
Normal file
540
app/agent/tools/impl/_filter_rule_utils.py
Normal file
@@ -0,0 +1,540 @@
|
||||
"""过滤规则 Agent 工具共用的校验、查询和引用处理逻辑。"""
|
||||
|
||||
import copy
|
||||
import re
|
||||
from typing import Any, Dict, Iterable, Optional
|
||||
|
||||
from app.core.event import eventmanager
|
||||
from app.db import AsyncSessionFactory
|
||||
from app.db.models.subscribe import Subscribe
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.helper.rule import RuleHelper
|
||||
from app.modules.filter.RuleParser import RuleParser
|
||||
from app.modules.filter.builtin_rules import BUILTIN_RULE_SET
|
||||
from app.schemas import CustomRule, FilterRuleGroup
|
||||
from app.schemas.event import ConfigChangeEventData
|
||||
from app.schemas.types import EventType, SystemConfigKey
|
||||
|
||||
RULE_ID_PATTERN = re.compile(r"^[A-Za-z0-9]+$")
|
||||
RULE_TOKEN_PATTERN = re.compile(r"[A-Za-z][A-Za-z0-9]*|[0-9][A-Za-z0-9]+")
|
||||
NUMERIC_RANGE_PATTERN = re.compile(
|
||||
r"^\d+(?:\.\d+)?(?:\s*-\s*\d+(?:\.\d+)?)?$"
|
||||
)
|
||||
|
||||
MEDIA_TYPE_ALIASES = {
|
||||
"movie": "电影",
|
||||
"film": "电影",
|
||||
"tv": "电视剧",
|
||||
"series": "电视剧",
|
||||
"show": "电视剧",
|
||||
"电影": "电影",
|
||||
"电视剧": "电视剧",
|
||||
}
|
||||
|
||||
RULE_STRING_SYNTAX = {
|
||||
"level_separator": ">",
|
||||
"and_operator": "&",
|
||||
"not_operator": "!",
|
||||
"supported_grouping": "Parentheses are supported inside a single level.",
|
||||
"spacing_note": "Prefer spaces around '&', and '>' for readability; use '!RULE' for negation.",
|
||||
"match_order": "Levels are evaluated from left to right. The first matched level wins and stops further matching.",
|
||||
"match_result": "If no level matches, the torrent is filtered out. If a level matches, the torrent is kept.",
|
||||
"writing_workflow": [
|
||||
"First query built-in rules and custom rules to learn valid rule IDs.",
|
||||
"Compose one priority level with '&', '!' and optional parentheses.",
|
||||
"Join multiple priority levels with '>' from highest priority to lowest priority.",
|
||||
"Use spaces around '&', and '>' for readability.",
|
||||
],
|
||||
"examples": [
|
||||
{
|
||||
"description": "Prefer torrents with special subtitles and Chinese dubbing at 4K, otherwise fall back to Chinese subtitles and Chinese dubbing at 4K.",
|
||||
"rule_string": "SPECSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL > CNSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL",
|
||||
},
|
||||
{
|
||||
"description": "Inside one level, require 4K and reject Blu-ray source.",
|
||||
"rule_string": "4K & !BLU",
|
||||
},
|
||||
{
|
||||
"description": "Inside one level, accept either special subtitles or Chinese subtitles, then also require 1080P.",
|
||||
"rule_string": "(SPECSUB | CNSUB) & 1080P",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def normalize_optional_text(value: Optional[str]) -> Optional[str]:
|
||||
"""把空白字符串折叠为 None,避免保存无意义的空值。"""
|
||||
if value is None:
|
||||
return None
|
||||
value = str(value).strip()
|
||||
return value or None
|
||||
|
||||
|
||||
def normalize_media_type(value: Optional[str]) -> Optional[str]:
|
||||
"""兼容英中文媒体类型输入,最终统一为后端实际使用的中文值。"""
|
||||
value = normalize_optional_text(value)
|
||||
if not value:
|
||||
return None
|
||||
normalized = MEDIA_TYPE_ALIASES.get(value.lower(), value)
|
||||
if normalized not in {"电影", "电视剧"}:
|
||||
raise ValueError(
|
||||
"media_type 仅支持 '电影'、'电视剧'、'movie' 或 'tv'"
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def validate_numeric_range(
|
||||
field_name: str, value: Optional[str]
|
||||
) -> Optional[str]:
|
||||
"""校验 size_range / publish_time 这类单值或区间值。"""
|
||||
value = normalize_optional_text(value)
|
||||
if not value:
|
||||
return None
|
||||
if not NUMERIC_RANGE_PATTERN.match(value):
|
||||
raise ValueError(
|
||||
f"{field_name} 格式无效,支持 '1000' 或 '1000-5000' 这类数字区间格式"
|
||||
)
|
||||
|
||||
parts = [float(item.strip()) for item in value.split("-")]
|
||||
if len(parts) == 2 and parts[0] > parts[1]:
|
||||
raise ValueError(f"{field_name} 区间起始值不能大于结束值")
|
||||
return value
|
||||
|
||||
|
||||
def validate_seeders(value: Optional[str]) -> Optional[str]:
|
||||
"""做种人数最终会被 int() 解析,这里提前拦住非法值。"""
|
||||
value = normalize_optional_text(value)
|
||||
if not value:
|
||||
return None
|
||||
if not value.isdigit():
|
||||
raise ValueError("seeders 必须是非负整数")
|
||||
return value
|
||||
|
||||
|
||||
def get_builtin_rules() -> Dict[str, dict]:
|
||||
"""返回内置规则的深拷贝,避免调用方误改共享常量。"""
|
||||
return copy.deepcopy(BUILTIN_RULE_SET)
|
||||
|
||||
|
||||
def get_custom_rules() -> list[CustomRule]:
|
||||
return RuleHelper().get_custom_rules()
|
||||
|
||||
|
||||
def get_rule_groups() -> list[FilterRuleGroup]:
|
||||
return RuleHelper().get_rule_groups()
|
||||
|
||||
|
||||
def build_custom_rule_map(rules: Optional[Iterable[CustomRule]] = None) -> Dict[str, CustomRule]:
|
||||
return {
|
||||
rule.id: rule
|
||||
for rule in (rules or get_custom_rules())
|
||||
if rule.id
|
||||
}
|
||||
|
||||
|
||||
def build_rule_group_map(
|
||||
groups: Optional[Iterable[FilterRuleGroup]] = None,
|
||||
) -> Dict[str, FilterRuleGroup]:
|
||||
return {
|
||||
group.name: group
|
||||
for group in (groups or get_rule_groups())
|
||||
if group.name
|
||||
}
|
||||
|
||||
|
||||
def extract_rule_tokens(rule_string: Optional[str]) -> list[str]:
|
||||
"""从规则串里提取规则 ID,用于引用分析和未知规则校验。"""
|
||||
if not rule_string:
|
||||
return []
|
||||
# dict.fromkeys 用来在保留顺序的同时去重,便于展示和报错。
|
||||
return list(dict.fromkeys(RULE_TOKEN_PATTERN.findall(rule_string)))
|
||||
|
||||
|
||||
def parse_rule_string(rule_string: str) -> dict:
|
||||
"""使用后端同款 RuleParser 解析规则串,并拆出每一层的元数据。"""
|
||||
normalized = normalize_optional_text(rule_string)
|
||||
if not normalized:
|
||||
raise ValueError("rule_string 不能为空")
|
||||
|
||||
parser = RuleParser()
|
||||
levels = [level.strip() for level in normalized.split(">")]
|
||||
if any(not level for level in levels):
|
||||
raise ValueError("rule_string 不能包含空层级,请检查 '>' 两侧内容")
|
||||
|
||||
parsed_levels = []
|
||||
for index, level in enumerate(levels, start=1):
|
||||
try:
|
||||
parser.parse(level)
|
||||
except Exception as exc: # pragma: no cover - 依赖 pyparsing 的具体异常
|
||||
raise ValueError(f"规则串第 {index} 层语法错误: {exc}") from exc
|
||||
|
||||
parsed_levels.append(
|
||||
{
|
||||
"priority": index,
|
||||
"expression": level,
|
||||
"referenced_rules": extract_rule_tokens(level),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"rule_string": " > ".join(levels),
|
||||
"levels": parsed_levels,
|
||||
"referenced_rules": extract_rule_tokens(normalized),
|
||||
}
|
||||
|
||||
|
||||
def validate_rule_string(rule_string: str, available_rule_ids: Iterable[str]) -> dict:
|
||||
"""校验规则串语法和引用规则是否都存在。"""
|
||||
parsed = parse_rule_string(rule_string)
|
||||
available_ids = set(available_rule_ids)
|
||||
unknown_rules = sorted(
|
||||
{
|
||||
rule_id
|
||||
for rule_id in parsed["referenced_rules"]
|
||||
if rule_id not in available_ids
|
||||
}
|
||||
)
|
||||
if unknown_rules:
|
||||
raise ValueError(
|
||||
f"rule_string 引用了不存在的规则: {', '.join(unknown_rules)}"
|
||||
)
|
||||
return parsed
|
||||
|
||||
|
||||
def serialize_builtin_rule(rule_id: str, payload: dict) -> dict:
|
||||
"""把内置规则整理成适合 Agent 阅读的结构。"""
|
||||
data = copy.deepcopy(payload)
|
||||
data["id"] = rule_id
|
||||
data["source"] = "builtin"
|
||||
return data
|
||||
|
||||
|
||||
def serialize_custom_rule(rule: CustomRule, group_refs: Optional[list[str]] = None) -> dict:
|
||||
data = rule.model_dump(exclude_none=True)
|
||||
data["source"] = "custom"
|
||||
data["referenced_by_rule_groups"] = group_refs or []
|
||||
return data
|
||||
|
||||
|
||||
def serialize_rule_group(group: FilterRuleGroup, usage: Optional[dict] = None) -> dict:
|
||||
"""查询时尽量附带解析结果,便于 Agent 理解优先级层级。"""
|
||||
data = group.model_dump(exclude_none=True)
|
||||
if group.rule_string:
|
||||
try:
|
||||
parsed = parse_rule_string(group.rule_string)
|
||||
data["levels"] = parsed["levels"]
|
||||
data["referenced_rules"] = parsed["referenced_rules"]
|
||||
data["syntax_valid"] = True
|
||||
except ValueError as exc:
|
||||
data["syntax_valid"] = False
|
||||
data["syntax_error"] = str(exc)
|
||||
data["referenced_rules"] = extract_rule_tokens(group.rule_string)
|
||||
else:
|
||||
data["syntax_valid"] = False
|
||||
data["syntax_error"] = "rule_string 为空"
|
||||
data["referenced_rules"] = []
|
||||
data["usage"] = usage or default_rule_group_usage()
|
||||
return data
|
||||
|
||||
|
||||
def default_rule_group_usage() -> dict:
|
||||
return {
|
||||
"used_in_global_search": False,
|
||||
"used_in_global_subscribe": False,
|
||||
"used_in_global_best_version": False,
|
||||
"subscribes": [],
|
||||
}
|
||||
|
||||
|
||||
async def collect_rule_group_usages(
|
||||
group_names: Optional[Iterable[str]] = None,
|
||||
) -> Dict[str, dict]:
|
||||
"""收集规则组在全局配置和订阅上的引用情况。"""
|
||||
target_names = set(group_names or [])
|
||||
search_groups = set(
|
||||
SystemConfigOper().get(SystemConfigKey.SearchFilterRuleGroups) or []
|
||||
)
|
||||
subscribe_groups = set(
|
||||
SystemConfigOper().get(SystemConfigKey.SubscribeFilterRuleGroups) or []
|
||||
)
|
||||
best_version_groups = set(
|
||||
SystemConfigOper().get(SystemConfigKey.BestVersionFilterRuleGroups) or []
|
||||
)
|
||||
|
||||
usage_map = {
|
||||
name: default_rule_group_usage()
|
||||
for name in target_names
|
||||
}
|
||||
|
||||
def ensure_usage(name: str) -> dict:
|
||||
if name not in usage_map:
|
||||
usage_map[name] = default_rule_group_usage()
|
||||
return usage_map[name]
|
||||
|
||||
for name in search_groups:
|
||||
if target_names and name not in target_names:
|
||||
continue
|
||||
ensure_usage(name)["used_in_global_search"] = True
|
||||
for name in subscribe_groups:
|
||||
if target_names and name not in target_names:
|
||||
continue
|
||||
ensure_usage(name)["used_in_global_subscribe"] = True
|
||||
for name in best_version_groups:
|
||||
if target_names and name not in target_names:
|
||||
continue
|
||||
ensure_usage(name)["used_in_global_best_version"] = True
|
||||
|
||||
async with AsyncSessionFactory() as db:
|
||||
subscribes = await Subscribe.async_list(db)
|
||||
for subscribe in subscribes:
|
||||
filter_groups = subscribe.filter_groups or []
|
||||
for name in filter_groups:
|
||||
if target_names and name not in target_names:
|
||||
continue
|
||||
ensure_usage(name)["subscribes"].append(
|
||||
{
|
||||
"subscribe_id": subscribe.id,
|
||||
"name": subscribe.name,
|
||||
"season": subscribe.season,
|
||||
"type": subscribe.type,
|
||||
"username": subscribe.username,
|
||||
"best_version": bool(subscribe.best_version),
|
||||
}
|
||||
)
|
||||
|
||||
return usage_map
|
||||
|
||||
|
||||
def collect_custom_rule_group_refs(
|
||||
rule_groups: Iterable[FilterRuleGroup],
|
||||
rule_ids: Optional[Iterable[str]] = None,
|
||||
) -> Dict[str, list[str]]:
|
||||
"""收集自定义规则被哪些规则组引用。"""
|
||||
target_rule_ids = set(rule_ids or [])
|
||||
refs: Dict[str, list[str]] = {
|
||||
rule_id: []
|
||||
for rule_id in target_rule_ids
|
||||
}
|
||||
|
||||
for group in rule_groups:
|
||||
if not group.name or not group.rule_string:
|
||||
continue
|
||||
referenced = set(extract_rule_tokens(group.rule_string))
|
||||
for rule_id in referenced:
|
||||
if target_rule_ids and rule_id not in target_rule_ids:
|
||||
continue
|
||||
refs.setdefault(rule_id, []).append(group.name)
|
||||
|
||||
for names in refs.values():
|
||||
names.sort()
|
||||
return refs
|
||||
|
||||
|
||||
def normalize_custom_rule(
|
||||
rule_id: str,
|
||||
name: str,
|
||||
include: Optional[str],
|
||||
exclude: Optional[str],
|
||||
size_range: Optional[str],
|
||||
seeders: Optional[str],
|
||||
publish_time: Optional[str],
|
||||
existing_rules: Iterable[CustomRule],
|
||||
original_rule_id: Optional[str] = None,
|
||||
) -> CustomRule:
|
||||
"""新增/更新自定义规则时统一走这里,避免多处散落校验逻辑。"""
|
||||
normalized_rule_id = normalize_optional_text(rule_id)
|
||||
normalized_name = normalize_optional_text(name)
|
||||
if not normalized_rule_id:
|
||||
raise ValueError("rule_id 不能为空")
|
||||
if not normalized_name:
|
||||
raise ValueError("name 不能为空")
|
||||
if not RULE_ID_PATTERN.match(normalized_rule_id):
|
||||
raise ValueError("rule_id 仅支持英文字母和数字")
|
||||
if (
|
||||
normalized_rule_id in BUILTIN_RULE_SET
|
||||
and normalized_rule_id != original_rule_id
|
||||
):
|
||||
raise ValueError(
|
||||
f"rule_id '{normalized_rule_id}' 与内置规则冲突,不能覆盖内置规则"
|
||||
)
|
||||
|
||||
for existing_rule in existing_rules:
|
||||
if (
|
||||
existing_rule.id == normalized_rule_id
|
||||
and existing_rule.id != original_rule_id
|
||||
):
|
||||
raise ValueError(f"rule_id '{normalized_rule_id}' 已存在")
|
||||
if (
|
||||
existing_rule.name == normalized_name
|
||||
and existing_rule.id != original_rule_id
|
||||
):
|
||||
raise ValueError(f"规则名称 '{normalized_name}' 已存在")
|
||||
|
||||
return CustomRule(
|
||||
id=normalized_rule_id,
|
||||
name=normalized_name,
|
||||
include=normalize_optional_text(include),
|
||||
exclude=normalize_optional_text(exclude),
|
||||
size_range=validate_numeric_range("size_range", size_range),
|
||||
seeders=validate_seeders(seeders),
|
||||
publish_time=validate_numeric_range("publish_time", publish_time),
|
||||
)
|
||||
|
||||
|
||||
def normalize_rule_group(
|
||||
name: str,
|
||||
rule_string: str,
|
||||
media_type: Optional[str],
|
||||
category: Optional[str],
|
||||
existing_groups: Iterable[FilterRuleGroup],
|
||||
available_rule_ids: Iterable[str],
|
||||
original_name: Optional[str] = None,
|
||||
) -> tuple[FilterRuleGroup, dict]:
|
||||
"""新增/更新规则组时统一校验名字、适用范围和规则串。"""
|
||||
normalized_name = normalize_optional_text(name)
|
||||
if not normalized_name:
|
||||
raise ValueError("规则组名称不能为空")
|
||||
|
||||
for group in existing_groups:
|
||||
if group.name == normalized_name and group.name != original_name:
|
||||
raise ValueError(f"规则组名称 '{normalized_name}' 已存在")
|
||||
|
||||
normalized_media_type = normalize_media_type(media_type)
|
||||
normalized_category = normalize_optional_text(category)
|
||||
if normalized_category and not normalized_media_type:
|
||||
raise ValueError("设置 category 时必须同时设置 media_type")
|
||||
|
||||
parsed = validate_rule_string(rule_string, available_rule_ids)
|
||||
return (
|
||||
FilterRuleGroup(
|
||||
name=normalized_name,
|
||||
rule_string=parsed["rule_string"],
|
||||
media_type=normalized_media_type,
|
||||
category=normalized_category,
|
||||
),
|
||||
parsed,
|
||||
)
|
||||
|
||||
|
||||
async def save_system_config(
|
||||
key: SystemConfigKey, value: Any
|
||||
) -> Optional[bool]:
|
||||
"""通过统一入口保存配置并补发 ConfigChanged 事件。"""
|
||||
normalized_value = value
|
||||
if isinstance(normalized_value, list):
|
||||
normalized_value = [
|
||||
item
|
||||
for item in normalized_value
|
||||
if item is not None and item != ""
|
||||
]
|
||||
normalized_value = normalized_value or None
|
||||
|
||||
success = await SystemConfigOper().async_set(key, normalized_value)
|
||||
if success:
|
||||
await eventmanager.async_send_event(
|
||||
etype=EventType.ConfigChanged,
|
||||
data=ConfigChangeEventData(
|
||||
key=key,
|
||||
value=normalized_value,
|
||||
change_type="update",
|
||||
),
|
||||
)
|
||||
return success
|
||||
|
||||
|
||||
def replace_rule_id_in_rule_string(
|
||||
rule_string: str, old_rule_id: str, new_rule_id: str
|
||||
) -> str:
|
||||
"""只替换完整 token,避免误伤其他规则名。"""
|
||||
pattern = re.compile(
|
||||
rf"(?<![A-Za-z0-9]){re.escape(old_rule_id)}(?![A-Za-z0-9])"
|
||||
)
|
||||
return pattern.sub(new_rule_id, rule_string)
|
||||
|
||||
|
||||
def replace_group_name_in_list(
|
||||
values: Optional[Iterable[str]], old_name: str, new_name: str
|
||||
) -> list[str]:
|
||||
"""更新配置里的规则组名引用,并顺手去重。"""
|
||||
result = []
|
||||
for value in values or []:
|
||||
mapped = new_name if value == old_name else value
|
||||
if mapped not in result:
|
||||
result.append(mapped)
|
||||
return result
|
||||
|
||||
|
||||
async def rename_rule_group_references(old_name: str, new_name: str) -> dict:
|
||||
"""规则组改名后,联动更新全局设置和订阅引用。"""
|
||||
changed = {
|
||||
"global_settings": {},
|
||||
"subscribes": [],
|
||||
}
|
||||
|
||||
for config_key in (
|
||||
SystemConfigKey.SearchFilterRuleGroups,
|
||||
SystemConfigKey.SubscribeFilterRuleGroups,
|
||||
SystemConfigKey.BestVersionFilterRuleGroups,
|
||||
):
|
||||
original = SystemConfigOper().get(config_key) or []
|
||||
updated = replace_group_name_in_list(original, old_name, new_name)
|
||||
if updated != original:
|
||||
await save_system_config(config_key, updated)
|
||||
changed["global_settings"][config_key.value] = updated
|
||||
|
||||
async with AsyncSessionFactory() as db:
|
||||
subscribes = await Subscribe.async_list(db)
|
||||
for subscribe in subscribes:
|
||||
original = subscribe.filter_groups or []
|
||||
updated = replace_group_name_in_list(original, old_name, new_name)
|
||||
if updated == original:
|
||||
continue
|
||||
await subscribe.async_update(db, {"filter_groups": updated})
|
||||
changed["subscribes"].append(
|
||||
{
|
||||
"subscribe_id": subscribe.id,
|
||||
"name": subscribe.name,
|
||||
"season": subscribe.season,
|
||||
"filter_groups": updated,
|
||||
}
|
||||
)
|
||||
|
||||
return changed
|
||||
|
||||
|
||||
async def remove_rule_group_references(group_name: str) -> dict:
|
||||
"""删除规则组后,清理全局设置和订阅里的悬空引用。"""
|
||||
changed = {
|
||||
"global_settings": {},
|
||||
"subscribes": [],
|
||||
}
|
||||
|
||||
for config_key in (
|
||||
SystemConfigKey.SearchFilterRuleGroups,
|
||||
SystemConfigKey.SubscribeFilterRuleGroups,
|
||||
SystemConfigKey.BestVersionFilterRuleGroups,
|
||||
):
|
||||
original = SystemConfigOper().get(config_key) or []
|
||||
updated = [value for value in original if value != group_name]
|
||||
if updated != original:
|
||||
await save_system_config(config_key, updated)
|
||||
changed["global_settings"][config_key.value] = updated
|
||||
|
||||
async with AsyncSessionFactory() as db:
|
||||
subscribes = await Subscribe.async_list(db)
|
||||
for subscribe in subscribes:
|
||||
original = subscribe.filter_groups or []
|
||||
updated = [value for value in original if value != group_name]
|
||||
if updated == original:
|
||||
continue
|
||||
await subscribe.async_update(db, {"filter_groups": updated})
|
||||
changed["subscribes"].append(
|
||||
{
|
||||
"subscribe_id": subscribe.id,
|
||||
"name": subscribe.name,
|
||||
"season": subscribe.season,
|
||||
"filter_groups": updated,
|
||||
}
|
||||
)
|
||||
|
||||
return changed
|
||||
295
app/agent/tools/impl/_plugin_tool_utils.py
Normal file
295
app/agent/tools/impl/_plugin_tool_utils.py
Normal file
@@ -0,0 +1,295 @@
|
||||
"""插件 Agent 工具共享辅助方法"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import shutil
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.plugin import PluginManager
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.helper.plugin import PluginHelper
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
# 默认只向智能体返回一个可读预览,避免超大插件数据挤爆上下文窗口。
|
||||
DEFAULT_PLUGIN_DATA_PREVIEW_CHARS = 12_000
|
||||
MAX_PLUGIN_DATA_PREVIEW_CHARS = 50_000
|
||||
PLUGIN_DATA_KEY_PREVIEW_LIMIT = 50
|
||||
PLUGIN_DATA_TRUNCATION_SUFFIX = "\n...(插件数据内容过长,已截断)"
|
||||
DEFAULT_PLUGIN_CANDIDATE_LIMIT = 50
|
||||
MAX_PLUGIN_CANDIDATE_LIMIT = 200
|
||||
|
||||
|
||||
def get_plugin_snapshot(plugin_id: str) -> Optional[dict[str, Any]]:
|
||||
"""
|
||||
获取已安装插件的基础信息快照。
|
||||
"""
|
||||
plugin_manager = PluginManager()
|
||||
for plugin in plugin_manager.get_local_plugins():
|
||||
if plugin.id == plugin_id:
|
||||
return {
|
||||
"plugin_id": plugin.id,
|
||||
"plugin_name": plugin.plugin_name,
|
||||
"plugin_version": plugin.plugin_version,
|
||||
"state": plugin.state,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def clamp_preview_chars(max_chars: Optional[int]) -> int:
|
||||
"""
|
||||
约束插件数据预览长度,避免工具结果无限膨胀。
|
||||
"""
|
||||
if max_chars is None:
|
||||
return DEFAULT_PLUGIN_DATA_PREVIEW_CHARS
|
||||
return max(512, min(int(max_chars), MAX_PLUGIN_DATA_PREVIEW_CHARS))
|
||||
|
||||
|
||||
def serialize_for_agent(value: Any) -> str:
|
||||
"""
|
||||
将结果稳定序列化为 JSON 字符串,无法原生序列化的对象退化为字符串。
|
||||
"""
|
||||
return json.dumps(value, ensure_ascii=False, indent=2, default=str)
|
||||
|
||||
|
||||
def build_preview_payload(value: Any, max_chars: Optional[int]) -> tuple[bool, int, int, str]:
|
||||
"""
|
||||
为可能很大的插件数据生成预览结果。
|
||||
"""
|
||||
serialized = serialize_for_agent(value)
|
||||
if len(serialized) <= clamp_preview_chars(max_chars):
|
||||
return False, len(serialized), len(serialized), serialized
|
||||
|
||||
preview_limit = clamp_preview_chars(max_chars)
|
||||
preview = serialized[:preview_limit] + PLUGIN_DATA_TRUNCATION_SUFFIX
|
||||
return True, len(serialized), len(preview), preview
|
||||
|
||||
|
||||
def reload_plugin_runtime(plugin_id: str) -> None:
|
||||
"""
|
||||
重载插件并重新注册其命令、定时任务和 API。
|
||||
"""
|
||||
# 这些依赖只在真正执行重载时才导入,避免普通查询工具引入不必要的初始化开销。
|
||||
from app.api.endpoints.plugin import register_plugin_api
|
||||
from app.command import Command
|
||||
from app.scheduler import Scheduler
|
||||
|
||||
plugin_manager = PluginManager()
|
||||
plugin_manager.reload_plugin(plugin_id)
|
||||
Scheduler().update_plugin_job(plugin_id)
|
||||
Command().init_commands(plugin_id)
|
||||
register_plugin_api(plugin_id)
|
||||
|
||||
|
||||
def summarize_plugin(plugin: Any) -> dict[str, Any]:
|
||||
"""
|
||||
提取插件对象中对 Agent 有价值的摘要字段。
|
||||
"""
|
||||
repo_url = getattr(plugin, "repo_url", None)
|
||||
return {
|
||||
"id": getattr(plugin, "id", None),
|
||||
"plugin_name": getattr(plugin, "plugin_name", None),
|
||||
"plugin_desc": getattr(plugin, "plugin_desc", None),
|
||||
"plugin_version": getattr(plugin, "plugin_version", None),
|
||||
"plugin_author": getattr(plugin, "plugin_author", None),
|
||||
"installed": bool(getattr(plugin, "installed", False)),
|
||||
"has_update": bool(getattr(plugin, "has_update", False)),
|
||||
"system_version_compatible": getattr(plugin, "system_version_compatible", True) is not False,
|
||||
"system_version": getattr(plugin, "system_version", None),
|
||||
"system_version_message": getattr(plugin, "system_version_message", None),
|
||||
"state": bool(getattr(plugin, "state", False)),
|
||||
"repo_url": repo_url,
|
||||
"source": "local_repo" if PluginHelper.is_local_repo_url(repo_url) else "market",
|
||||
}
|
||||
|
||||
|
||||
async def load_market_plugins(force_refresh: bool = False) -> list[Any]:
|
||||
"""
|
||||
聚合插件市场与本地插件仓库中的候选插件。
|
||||
"""
|
||||
plugin_manager = PluginManager()
|
||||
online_plugins = await plugin_manager.async_get_online_plugins(force=force_refresh)
|
||||
local_repo_plugins = plugin_manager.get_local_repo_plugins()
|
||||
if not online_plugins and not local_repo_plugins:
|
||||
return []
|
||||
return plugin_manager.process_plugins_list(online_plugins + local_repo_plugins, [])
|
||||
|
||||
|
||||
def list_installed_plugins() -> list[Any]:
|
||||
"""
|
||||
返回当前已安装插件列表。
|
||||
"""
|
||||
plugin_manager = PluginManager()
|
||||
return [plugin for plugin in plugin_manager.get_local_plugins() if plugin.installed]
|
||||
|
||||
|
||||
def _normalize_text(value: Optional[str]) -> str:
|
||||
return (value or "").strip().lower()
|
||||
|
||||
|
||||
def is_exact_plugin_match(plugin: Any, query: str) -> bool:
|
||||
"""
|
||||
精确匹配插件 ID 或插件名称,用于安全地自动选择候选。
|
||||
"""
|
||||
normalized_query = _normalize_text(query)
|
||||
return normalized_query in {
|
||||
_normalize_text(getattr(plugin, "id", None)),
|
||||
_normalize_text(getattr(plugin, "plugin_name", None)),
|
||||
}
|
||||
|
||||
|
||||
def search_plugin_candidates(query: str, plugins: list[Any]) -> list[dict[str, Any]]:
|
||||
"""
|
||||
按插件 ID、名称、描述和作者搜索候选,并返回打分结果。
|
||||
"""
|
||||
normalized_query = _normalize_text(query)
|
||||
if not normalized_query:
|
||||
return []
|
||||
|
||||
tokens = [token for token in normalized_query.replace("-", " ").split() if token]
|
||||
matches: list[dict[str, Any]] = []
|
||||
|
||||
for plugin in plugins:
|
||||
plugin_id = _normalize_text(getattr(plugin, "id", None))
|
||||
plugin_name = _normalize_text(getattr(plugin, "plugin_name", None))
|
||||
plugin_desc = _normalize_text(getattr(plugin, "plugin_desc", None))
|
||||
plugin_author = _normalize_text(getattr(plugin, "plugin_author", None))
|
||||
haystack = "\n".join([plugin_id, plugin_name, plugin_desc, plugin_author])
|
||||
|
||||
score = 0
|
||||
if normalized_query == plugin_id:
|
||||
score = 100
|
||||
elif normalized_query == plugin_name:
|
||||
score = 95
|
||||
elif plugin_id.startswith(normalized_query):
|
||||
score = 85
|
||||
elif plugin_name.startswith(normalized_query):
|
||||
score = 80
|
||||
elif normalized_query in plugin_id:
|
||||
score = 75
|
||||
elif normalized_query in plugin_name:
|
||||
score = 70
|
||||
elif tokens and all(token in plugin_name for token in tokens):
|
||||
score = 68
|
||||
elif tokens and all(token in plugin_id for token in tokens):
|
||||
score = 66
|
||||
elif normalized_query in plugin_desc:
|
||||
score = 45
|
||||
elif normalized_query in plugin_author:
|
||||
score = 40
|
||||
elif tokens and all(token in haystack for token in tokens):
|
||||
score = 35
|
||||
|
||||
if score <= 0:
|
||||
continue
|
||||
|
||||
matches.append(
|
||||
{
|
||||
"plugin": plugin,
|
||||
"score": score,
|
||||
"exact": is_exact_plugin_match(plugin, normalized_query),
|
||||
}
|
||||
)
|
||||
|
||||
return sorted(
|
||||
matches,
|
||||
key=lambda item: (
|
||||
-item["score"],
|
||||
not item["exact"],
|
||||
-int(bool(getattr(item["plugin"], "has_update", False))),
|
||||
-int(bool(getattr(item["plugin"], "installed", False))),
|
||||
-int(getattr(item["plugin"], "add_time", 0) or 0),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def summarize_candidates(matches: list[dict[str, Any]], limit: int = DEFAULT_PLUGIN_CANDIDATE_LIMIT) -> list[dict[str, Any]]:
|
||||
"""
|
||||
压缩候选列表,避免一次性把完整市场数据返回给 Agent。
|
||||
"""
|
||||
return [
|
||||
{
|
||||
**summarize_plugin(item["plugin"]),
|
||||
"score": item["score"],
|
||||
"exact": item["exact"],
|
||||
}
|
||||
for item in matches[:limit]
|
||||
]
|
||||
|
||||
|
||||
async def install_plugin_runtime(
|
||||
plugin_id: str, repo_url: Optional[str], force: bool = False
|
||||
) -> tuple[bool, str, bool]:
|
||||
"""
|
||||
按现有插件接口的行为安装插件,并刷新运行态注册信息。
|
||||
"""
|
||||
install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
|
||||
plugin_manager = PluginManager()
|
||||
plugin_helper = PluginHelper()
|
||||
|
||||
refreshed_only = False
|
||||
if not force and plugin_id in plugin_manager.get_plugin_ids():
|
||||
refreshed_only = True
|
||||
await plugin_helper.async_install_reg(pid=plugin_id, repo_url=repo_url)
|
||||
message = "插件已存在,已刷新加载"
|
||||
else:
|
||||
if not repo_url:
|
||||
return False, "没有传入仓库地址,无法正确安装插件,请检查配置", False
|
||||
state, message = await plugin_helper.async_install(
|
||||
pid=plugin_id,
|
||||
repo_url=repo_url,
|
||||
force_install=force,
|
||||
)
|
||||
if not state:
|
||||
return False, message, False
|
||||
|
||||
if plugin_id not in install_plugins:
|
||||
install_plugins.append(plugin_id)
|
||||
await SystemConfigOper().async_set(
|
||||
SystemConfigKey.UserInstalledPlugins, install_plugins
|
||||
)
|
||||
|
||||
await asyncio.to_thread(reload_plugin_runtime, plugin_id)
|
||||
return True, message or "插件安装成功", refreshed_only
|
||||
|
||||
|
||||
async def uninstall_plugin_runtime(plugin_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
按现有卸载逻辑移除插件,并清理运行态注册与分组信息。
|
||||
"""
|
||||
from app.api.endpoints.plugin import _remove_plugin_from_folders, remove_plugin_api
|
||||
from app.scheduler import Scheduler
|
||||
|
||||
config_oper = SystemConfigOper()
|
||||
install_plugins = config_oper.get(SystemConfigKey.UserInstalledPlugins) or []
|
||||
if plugin_id in install_plugins:
|
||||
install_plugins = [plugin for plugin in install_plugins if plugin != plugin_id]
|
||||
await config_oper.async_set(SystemConfigKey.UserInstalledPlugins, install_plugins)
|
||||
|
||||
remove_plugin_api(plugin_id)
|
||||
Scheduler().remove_plugin_job(plugin_id)
|
||||
|
||||
plugin_manager = PluginManager()
|
||||
plugin_class = plugin_manager.plugins.get(plugin_id)
|
||||
was_clone = bool(getattr(plugin_class, "is_clone", False))
|
||||
clone_files_removed = False
|
||||
|
||||
if was_clone:
|
||||
plugin_manager.delete_plugin_config(plugin_id)
|
||||
plugin_manager.delete_plugin_data(plugin_id)
|
||||
plugin_base_dir = settings.ROOT_PATH / "app" / "plugins" / plugin_id.lower()
|
||||
if plugin_base_dir.exists():
|
||||
try:
|
||||
shutil.rmtree(plugin_base_dir)
|
||||
plugin_manager.plugins.pop(plugin_id, None)
|
||||
clone_files_removed = True
|
||||
except Exception:
|
||||
clone_files_removed = False
|
||||
|
||||
_remove_plugin_from_folders(plugin_id)
|
||||
plugin_manager.remove_plugin(plugin_id)
|
||||
|
||||
return {
|
||||
"was_clone": was_clone,
|
||||
"clone_files_removed": clone_files_removed,
|
||||
}
|
||||
335
app/agent/tools/impl/_system_setting_utils.py
Normal file
335
app/agent/tools/impl/_system_setting_utils.py
Normal file
@@ -0,0 +1,335 @@
|
||||
"""系统设置工具共用的键解析与分组元数据。"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SettingSpec:
|
||||
"""描述一个可被 Agent 读写的系统设置项。"""
|
||||
|
||||
key: str
|
||||
source: str
|
||||
group: str
|
||||
label: str
|
||||
|
||||
|
||||
SYSTEMCONFIG_SETTING_METADATA = {
|
||||
SystemConfigKey.Downloaders.value: {
|
||||
"group": "downloaders",
|
||||
"label": "下载器配置",
|
||||
},
|
||||
SystemConfigKey.MediaServers.value: {
|
||||
"group": "media_servers",
|
||||
"label": "媒体服务器配置",
|
||||
},
|
||||
SystemConfigKey.Notifications.value: {
|
||||
"group": "notifications",
|
||||
"label": "消息通知配置",
|
||||
},
|
||||
SystemConfigKey.NotificationSwitchs.value: {
|
||||
"group": "notification_switches",
|
||||
"label": "通知场景开关",
|
||||
},
|
||||
SystemConfigKey.Directories.value: {
|
||||
"group": "directories",
|
||||
"label": "目录配置",
|
||||
},
|
||||
SystemConfigKey.Storages.value: {
|
||||
"group": "storages",
|
||||
"label": "存储配置",
|
||||
},
|
||||
SystemConfigKey.IndexerSites.value: {
|
||||
"group": "search_sites",
|
||||
"label": "搜索站点范围",
|
||||
},
|
||||
SystemConfigKey.RssSites.value: {
|
||||
"group": "subscribe_sites",
|
||||
"label": "订阅站点范围",
|
||||
},
|
||||
SystemConfigKey.UserSiteAuthParams.value: {
|
||||
"group": "site_auth",
|
||||
"label": "站点认证参数",
|
||||
},
|
||||
SystemConfigKey.AIAgentConfig.value: {
|
||||
"group": "ai_agent",
|
||||
"label": "AI 智能体配置",
|
||||
},
|
||||
SystemConfigKey.CustomIdentifiers.value: {
|
||||
"group": "custom_identifiers",
|
||||
"label": "自定义识别词",
|
||||
},
|
||||
SystemConfigKey.EpisodeFormatRuleTable.value: {
|
||||
"group": "transfer",
|
||||
"label": "集数定位规则词表",
|
||||
},
|
||||
SystemConfigKey.CustomReleaseGroups.value: {
|
||||
"group": "customization",
|
||||
"label": "自定义制作组/字幕组",
|
||||
},
|
||||
SystemConfigKey.Customization.value: {
|
||||
"group": "customization",
|
||||
"label": "自定义占位符",
|
||||
},
|
||||
SystemConfigKey.TransferExcludeWords.value: {
|
||||
"group": "transfer",
|
||||
"label": "整理屏蔽词",
|
||||
},
|
||||
SystemConfigKey.TorrentsPriority.value: {
|
||||
"group": "filter_rules",
|
||||
"label": "种子优先级规则",
|
||||
},
|
||||
SystemConfigKey.CustomFilterRules.value: {
|
||||
"group": "filter_rules",
|
||||
"label": "用户自定义规则",
|
||||
},
|
||||
SystemConfigKey.UserFilterRuleGroups.value: {
|
||||
"group": "filter_rules",
|
||||
"label": "用户规则组",
|
||||
},
|
||||
SystemConfigKey.SearchFilterRuleGroups.value: {
|
||||
"group": "filter_rules",
|
||||
"label": "搜索默认过滤规则组",
|
||||
},
|
||||
SystemConfigKey.SubscribeFilterRuleGroups.value: {
|
||||
"group": "filter_rules",
|
||||
"label": "订阅默认过滤规则组",
|
||||
},
|
||||
SystemConfigKey.BestVersionFilterRuleGroups.value: {
|
||||
"group": "filter_rules",
|
||||
"label": "洗版默认过滤规则组",
|
||||
},
|
||||
SystemConfigKey.SubscribeDefaultParams.value: {
|
||||
"group": "subscribe_defaults",
|
||||
"label": "订阅默认参数",
|
||||
},
|
||||
SystemConfigKey.DefaultMovieSubscribeConfig.value: {
|
||||
"group": "subscribe_defaults",
|
||||
"label": "默认电影订阅规则",
|
||||
},
|
||||
SystemConfigKey.DefaultTvSubscribeConfig.value: {
|
||||
"group": "subscribe_defaults",
|
||||
"label": "默认电视剧订阅规则",
|
||||
},
|
||||
SystemConfigKey.UserInstalledPlugins.value: {
|
||||
"group": "plugins",
|
||||
"label": "已安装插件列表",
|
||||
},
|
||||
SystemConfigKey.PluginFolders.value: {
|
||||
"group": "plugins",
|
||||
"label": "插件文件夹分组配置",
|
||||
},
|
||||
SystemConfigKey.PluginInstallReport.value: {
|
||||
"group": "plugins",
|
||||
"label": "插件安装统计",
|
||||
},
|
||||
SystemConfigKey.NotificationSendTime.value: {
|
||||
"group": "notifications",
|
||||
"label": "通知发送时间",
|
||||
},
|
||||
SystemConfigKey.NotificationTemplates.value: {
|
||||
"group": "notifications",
|
||||
"label": "通知模板",
|
||||
},
|
||||
SystemConfigKey.ScrapingSwitchs.value: {
|
||||
"group": "scraping",
|
||||
"label": "刮削开关设置",
|
||||
},
|
||||
SystemConfigKey.FollowSubscribers.value: {
|
||||
"group": "subscribe_sites",
|
||||
"label": "Follow 订阅分享者",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
LIST_ITEM_MATCH_FIELD_DEFAULTS = {
|
||||
SystemConfigKey.Downloaders.value: "name",
|
||||
SystemConfigKey.MediaServers.value: "name",
|
||||
SystemConfigKey.Notifications.value: "name",
|
||||
SystemConfigKey.NotificationSwitchs.value: "type",
|
||||
SystemConfigKey.Directories.value: "name",
|
||||
SystemConfigKey.Storages.value: "name",
|
||||
}
|
||||
|
||||
|
||||
GROUP_ALIASES = {
|
||||
"all": "all",
|
||||
"全部": "all",
|
||||
"settings": "settings",
|
||||
"basic": "settings",
|
||||
"基础设置": "settings",
|
||||
"基础配置": "settings",
|
||||
"systemconfig": "systemconfig",
|
||||
"system_config": "systemconfig",
|
||||
"系统设置": "systemconfig",
|
||||
"系统配置": "systemconfig",
|
||||
"downloaders": "downloaders",
|
||||
"downloader": "downloaders",
|
||||
"下载器": "downloaders",
|
||||
"media_servers": "media_servers",
|
||||
"mediaservers": "media_servers",
|
||||
"media-servers": "media_servers",
|
||||
"媒体服务器": "media_servers",
|
||||
"notifications": "notifications",
|
||||
"notification": "notifications",
|
||||
"消息通知": "notifications",
|
||||
"通知": "notifications",
|
||||
"notification_switches": "notification_switches",
|
||||
"notification_switchs": "notification_switches",
|
||||
"通知开关": "notification_switches",
|
||||
"storages": "storages",
|
||||
"storage": "storages",
|
||||
"存储": "storages",
|
||||
"directories": "directories",
|
||||
"directory": "directories",
|
||||
"目录": "directories",
|
||||
"search_sites": "search_sites",
|
||||
"indexer_sites": "search_sites",
|
||||
"搜索站点": "search_sites",
|
||||
"subscribe_sites": "subscribe_sites",
|
||||
"rss_sites": "subscribe_sites",
|
||||
"订阅站点": "subscribe_sites",
|
||||
"site_auth": "site_auth",
|
||||
"site_auth_params": "site_auth",
|
||||
"站点认证": "site_auth",
|
||||
"ai_agent": "ai_agent",
|
||||
"agent": "ai_agent",
|
||||
"智能体": "ai_agent",
|
||||
"custom_identifiers": "custom_identifiers",
|
||||
"自定义识别词": "custom_identifiers",
|
||||
"filter_rules": "filter_rules",
|
||||
"过滤规则": "filter_rules",
|
||||
"subscribe_defaults": "subscribe_defaults",
|
||||
"订阅默认": "subscribe_defaults",
|
||||
"plugins": "plugins",
|
||||
"插件": "plugins",
|
||||
"customization": "customization",
|
||||
"自定义": "customization",
|
||||
"transfer": "transfer",
|
||||
"整理": "transfer",
|
||||
"scraping": "scraping",
|
||||
"刮削": "scraping",
|
||||
"misc": "misc",
|
||||
"其他": "misc",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_token(value: str) -> str:
|
||||
return str(value).strip().lower().replace("-", "_")
|
||||
|
||||
|
||||
def _build_specs() -> tuple[dict[str, SettingSpec], dict[str, SettingSpec]]:
|
||||
core_specs = {
|
||||
key: SettingSpec(key=key, source="settings", group="settings", label=key)
|
||||
for key in Settings.model_fields.keys()
|
||||
}
|
||||
system_specs = {}
|
||||
for item in SystemConfigKey:
|
||||
metadata = SYSTEMCONFIG_SETTING_METADATA.get(item.value, {})
|
||||
system_specs[item.value] = SettingSpec(
|
||||
key=item.value,
|
||||
source="systemconfig",
|
||||
group=metadata.get("group", "misc"),
|
||||
label=metadata.get("label", item.value),
|
||||
)
|
||||
return core_specs, system_specs
|
||||
|
||||
|
||||
CORE_SETTING_SPECS, SYSTEMCONFIG_SETTING_SPECS = _build_specs()
|
||||
ALL_SETTING_SPECS = {**CORE_SETTING_SPECS, **SYSTEMCONFIG_SETTING_SPECS}
|
||||
|
||||
|
||||
SETTING_KEY_ALIASES = {}
|
||||
for key in CORE_SETTING_SPECS:
|
||||
SETTING_KEY_ALIASES[_normalize_token(key)] = key
|
||||
for item in SystemConfigKey:
|
||||
SETTING_KEY_ALIASES[_normalize_token(item.value)] = item.value
|
||||
SETTING_KEY_ALIASES[_normalize_token(item.name)] = item.value
|
||||
|
||||
SINGLE_KEY_GROUP_ALIASES = {
|
||||
_normalize_token(alias): next(
|
||||
(
|
||||
spec.key
|
||||
for spec in SYSTEMCONFIG_SETTING_SPECS.values()
|
||||
if spec.group == canonical_group
|
||||
),
|
||||
None,
|
||||
)
|
||||
for alias, canonical_group in GROUP_ALIASES.items()
|
||||
if canonical_group not in {"all", "settings", "systemconfig"}
|
||||
and len(
|
||||
[
|
||||
spec.key
|
||||
for spec in SYSTEMCONFIG_SETTING_SPECS.values()
|
||||
if spec.group == canonical_group
|
||||
]
|
||||
)
|
||||
== 1
|
||||
}
|
||||
|
||||
|
||||
def normalize_group(group: Optional[str]) -> str:
|
||||
if not group:
|
||||
return "all"
|
||||
normalized = GROUP_ALIASES.get(_normalize_token(group))
|
||||
if not normalized:
|
||||
raise ValueError(
|
||||
"group 不支持,支持值包括 all/settings/systemconfig 以及"
|
||||
" downloaders、media_servers、notifications、storages、directories、"
|
||||
"search_sites、subscribe_sites、site_auth、ai_agent 等分类别名"
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def resolve_setting_spec(setting_key: Optional[str]) -> Optional[SettingSpec]:
|
||||
"""把精确键名、枚举名或单键分组别名解析为统一的设置定义。"""
|
||||
|
||||
if not setting_key:
|
||||
return None
|
||||
|
||||
normalized = _normalize_token(setting_key)
|
||||
resolved_key = SETTING_KEY_ALIASES.get(normalized) or SINGLE_KEY_GROUP_ALIASES.get(
|
||||
normalized
|
||||
)
|
||||
if not resolved_key:
|
||||
return None
|
||||
return ALL_SETTING_SPECS.get(resolved_key)
|
||||
|
||||
|
||||
def list_setting_specs(
|
||||
group: Optional[str] = "all", keyword: Optional[str] = None
|
||||
) -> list[SettingSpec]:
|
||||
"""按分组和关键字筛选可查询的设置项。"""
|
||||
|
||||
normalized_group = normalize_group(group)
|
||||
if normalized_group == "all":
|
||||
specs = list(ALL_SETTING_SPECS.values())
|
||||
elif normalized_group == "settings":
|
||||
specs = list(CORE_SETTING_SPECS.values())
|
||||
elif normalized_group == "systemconfig":
|
||||
specs = list(SYSTEMCONFIG_SETTING_SPECS.values())
|
||||
else:
|
||||
specs = [
|
||||
spec
|
||||
for spec in SYSTEMCONFIG_SETTING_SPECS.values()
|
||||
if spec.group == normalized_group
|
||||
]
|
||||
|
||||
if keyword:
|
||||
normalized_keyword = _normalize_token(keyword)
|
||||
specs = [
|
||||
spec
|
||||
for spec in specs
|
||||
if normalized_keyword in _normalize_token(spec.key)
|
||||
or normalized_keyword in _normalize_token(spec.group)
|
||||
or normalized_keyword in _normalize_token(spec.label)
|
||||
]
|
||||
|
||||
return sorted(specs, key=lambda spec: (spec.source, spec.group, spec.key))
|
||||
|
||||
|
||||
def get_default_list_match_field(setting_key: str) -> Optional[str]:
|
||||
return LIST_ITEM_MATCH_FIELD_DEFAULTS.get(setting_key)
|
||||
109
app/agent/tools/impl/add_custom_filter_rule.py
Normal file
109
app/agent/tools/impl/add_custom_filter_rule.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""新增自定义过滤规则工具。"""
|
||||
|
||||
import json
|
||||
from typing import Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.impl._filter_rule_utils import (
|
||||
get_custom_rules,
|
||||
normalize_custom_rule,
|
||||
save_system_config,
|
||||
serialize_custom_rule,
|
||||
)
|
||||
from app.log import logger
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
|
||||
class AddCustomFilterRuleInput(BaseModel):
|
||||
"""新增自定义过滤规则工具的输入参数模型"""
|
||||
|
||||
explanation: 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.",
|
||||
)
|
||||
name: str = Field(..., description="Display name of the custom rule.")
|
||||
include: Optional[str] = Field(
|
||||
None, description="Optional include regex for the rule."
|
||||
)
|
||||
exclude: Optional[str] = Field(
|
||||
None, description="Optional exclude regex for the rule."
|
||||
)
|
||||
size_range: Optional[str] = Field(
|
||||
None, description="Optional size range in MB, for example '1000-5000'."
|
||||
)
|
||||
seeders: Optional[str] = Field(
|
||||
None, description="Optional minimum seeder count as a non-negative integer."
|
||||
)
|
||||
publish_time: Optional[str] = Field(
|
||||
None,
|
||||
description="Optional publish-time filter in minutes, for example '60' or '60-1440'.",
|
||||
)
|
||||
|
||||
|
||||
class AddCustomFilterRuleTool(MoviePilotTool):
|
||||
name: str = "add_custom_filter_rule"
|
||||
description: str = (
|
||||
"Add a custom filter rule to CustomFilterRules. "
|
||||
"The new rule can then be referenced by rule ID inside filter rule groups."
|
||||
)
|
||||
args_schema: Type[BaseModel] = AddCustomFilterRuleInput
|
||||
require_admin: bool = True
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return f"新增自定义过滤规则 {kwargs.get('rule_id', '')}"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
rule_id: str,
|
||||
name: str,
|
||||
include: Optional[str] = None,
|
||||
exclude: Optional[str] = None,
|
||||
size_range: Optional[str] = None,
|
||||
seeders: Optional[str] = None,
|
||||
publish_time: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
logger.info(f"执行工具: {self.name}, rule_id={rule_id}")
|
||||
|
||||
try:
|
||||
custom_rules = get_custom_rules()
|
||||
new_rule = normalize_custom_rule(
|
||||
rule_id=rule_id,
|
||||
name=name,
|
||||
include=include,
|
||||
exclude=exclude,
|
||||
size_range=size_range,
|
||||
seeders=seeders,
|
||||
publish_time=publish_time,
|
||||
existing_rules=custom_rules,
|
||||
)
|
||||
|
||||
custom_rules.append(new_rule)
|
||||
await save_system_config(
|
||||
SystemConfigKey.CustomFilterRules,
|
||||
[rule.model_dump(exclude_none=True) for rule in custom_rules],
|
||||
)
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"已新增自定义过滤规则 {new_rule.id}",
|
||||
"custom_rule": serialize_custom_rule(new_rule),
|
||||
"count": len(custom_rules),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"新增自定义过滤规则失败: {exc}", exc_info=True)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"新增自定义过滤规则失败: {exc}",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
@@ -6,7 +6,8 @@ from typing import List, Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool, ToolChain
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.search import SearchChain
|
||||
from app.chain.download import DownloadChain
|
||||
from app.core.config import settings
|
||||
@@ -21,7 +22,7 @@ from app.utils.crypto import HashUtils
|
||||
|
||||
class AddDownloadInput(BaseModel):
|
||||
"""添加下载工具的输入参数模型"""
|
||||
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
torrent_url: List[str] = Field(
|
||||
...,
|
||||
description="One or more torrent_url values. Supports refs from get_search_results (`hash:id`) and magnet links."
|
||||
@@ -104,6 +105,29 @@ class AddDownloadTool(MoviePilotTool):
|
||||
return None
|
||||
return context
|
||||
|
||||
@classmethod
|
||||
async def _async_resolve_cached_context(cls, torrent_ref: str) -> Optional[Context]:
|
||||
"""异步读取最近搜索缓存,避免在协程里直接访问同步文件缓存。"""
|
||||
ref = str(torrent_ref).strip()
|
||||
if ":" not in ref:
|
||||
return None
|
||||
try:
|
||||
ref_hash, ref_index = ref.split(":", 1)
|
||||
index = int(ref_index)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
if index < 1:
|
||||
return None
|
||||
|
||||
results = await SearchChain().async_last_search_results() or []
|
||||
if index > len(results):
|
||||
return None
|
||||
context = results[index - 1]
|
||||
if not ref_hash or cls._build_torrent_ref(context) != ref_hash:
|
||||
return None
|
||||
return context
|
||||
|
||||
@staticmethod
|
||||
def _merge_labels_with_system_tag(labels: Optional[str]) -> Optional[str]:
|
||||
"""合并用户标签与系统默认标签,确保任务可被系统管理"""
|
||||
@@ -164,6 +188,43 @@ class AddDownloadTool(MoviePilotTool):
|
||||
|
||||
return Path(FileURI(storage=dir_conf.storage or "local", path=dir_conf.download_path).uri)
|
||||
|
||||
@staticmethod
|
||||
def _download_direct_sync(
|
||||
torrent_input: str,
|
||||
download_dir: Path,
|
||||
merged_labels: Optional[str],
|
||||
downloader: Optional[str],
|
||||
) -> tuple[Optional[str], Optional[str]]:
|
||||
"""同步添加磁力下载任务,避免下载器调用阻塞事件循环。"""
|
||||
result = DownloadChain().download(
|
||||
content=torrent_input,
|
||||
download_dir=download_dir,
|
||||
cookie=None,
|
||||
label=merged_labels,
|
||||
downloader=downloader,
|
||||
)
|
||||
if result:
|
||||
_, did, _, error_msg = result
|
||||
else:
|
||||
did, error_msg = None, "未找到下载器"
|
||||
return did, error_msg
|
||||
|
||||
@staticmethod
|
||||
def _download_single_sync(
|
||||
context: Context,
|
||||
downloader: Optional[str],
|
||||
save_path: Optional[str],
|
||||
merged_labels: Optional[str],
|
||||
) -> tuple[Optional[str], Optional[str]]:
|
||||
"""同步提交带上下文的下载任务,避免站点下载与下载器调用阻塞事件循环。"""
|
||||
return DownloadChain().download_single(
|
||||
context=context,
|
||||
downloader=downloader,
|
||||
save_path=save_path,
|
||||
label=merged_labels,
|
||||
return_detail=True,
|
||||
)
|
||||
|
||||
async def run(self, torrent_url: Optional[List[str]] = None,
|
||||
downloader: Optional[str] = None, save_path: Optional[str] = None,
|
||||
labels: Optional[str] = None, **kwargs) -> str:
|
||||
@@ -175,14 +236,13 @@ class AddDownloadTool(MoviePilotTool):
|
||||
if not torrent_inputs:
|
||||
return "错误:torrent_url 不能为空。"
|
||||
|
||||
download_chain = DownloadChain()
|
||||
merged_labels = self._merge_labels_with_system_tag(labels)
|
||||
success_count = 0
|
||||
failed_messages = []
|
||||
|
||||
for torrent_input in torrent_inputs:
|
||||
if self._is_torrent_ref(torrent_input):
|
||||
cached_context = self._resolve_cached_context(torrent_input)
|
||||
cached_context = await self._async_resolve_cached_context(torrent_input)
|
||||
if not cached_context or not cached_context.torrent_info:
|
||||
failed_messages.append(f"{torrent_input} 引用无效,请重新使用 get_search_results 查看搜索结果")
|
||||
continue
|
||||
@@ -216,7 +276,10 @@ class AddDownloadTool(MoviePilotTool):
|
||||
meta_info = MetaInfo(title=torrent_title, subtitle=torrent_description)
|
||||
media_info = cached_context.media_info if cached_context.media_info else None
|
||||
if not media_info:
|
||||
media_info = await ToolChain().async_recognize_media(meta=meta_info)
|
||||
media_info = await MediaChain().async_recognize_by_meta(
|
||||
meta_info,
|
||||
obtain_images=False,
|
||||
)
|
||||
if not media_info:
|
||||
failed_messages.append(f"{torrent_input} 无法识别媒体信息")
|
||||
continue
|
||||
@@ -232,33 +295,33 @@ class AddDownloadTool(MoviePilotTool):
|
||||
f"{torrent_input} 不是有效的下载内容,非 hash:id 时仅支持 magnet: 开头"
|
||||
)
|
||||
continue
|
||||
download_dir = self._resolve_direct_download_dir(save_path)
|
||||
download_dir = await self.run_blocking(
|
||||
"storage", self._resolve_direct_download_dir, save_path
|
||||
)
|
||||
if not download_dir:
|
||||
failed_messages.append(f"{torrent_input} 缺少保存路径,且系统未配置可用下载目录")
|
||||
continue
|
||||
result = download_chain.download(
|
||||
content=torrent_input,
|
||||
download_dir=download_dir,
|
||||
cookie=None,
|
||||
label=merged_labels,
|
||||
downloader=downloader
|
||||
did, error_msg = await self.run_blocking(
|
||||
"downloader",
|
||||
self._download_direct_sync,
|
||||
torrent_input,
|
||||
download_dir,
|
||||
merged_labels,
|
||||
downloader,
|
||||
)
|
||||
if result:
|
||||
_, did, _, error_msg = result
|
||||
else:
|
||||
did, error_msg = None, "未找到下载器"
|
||||
if did:
|
||||
success_count += 1
|
||||
else:
|
||||
failed_messages.append(self._build_failure_message(torrent_input, error_msg))
|
||||
continue
|
||||
|
||||
did, error_msg = download_chain.download_single(
|
||||
context=context,
|
||||
downloader=downloader,
|
||||
save_path=save_path,
|
||||
label=merged_labels,
|
||||
return_detail=True
|
||||
did, error_msg = await self.run_blocking(
|
||||
"downloader",
|
||||
self._download_single_sync,
|
||||
context,
|
||||
downloader,
|
||||
save_path,
|
||||
merged_labels,
|
||||
)
|
||||
if did:
|
||||
success_count += 1
|
||||
|
||||
113
app/agent/tools/impl/add_rule_group.py
Normal file
113
app/agent/tools/impl/add_rule_group.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""新增过滤规则组工具。"""
|
||||
|
||||
import json
|
||||
from typing import Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.impl._filter_rule_utils import (
|
||||
build_custom_rule_map,
|
||||
collect_rule_group_usages,
|
||||
get_builtin_rules,
|
||||
get_custom_rules,
|
||||
get_rule_groups,
|
||||
normalize_rule_group,
|
||||
save_system_config,
|
||||
serialize_rule_group,
|
||||
)
|
||||
from app.log import logger
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
|
||||
class AddRuleGroupInput(BaseModel):
|
||||
"""新增过滤规则组工具的输入参数模型"""
|
||||
|
||||
explanation: 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(
|
||||
...,
|
||||
description=(
|
||||
"Rule expression using built-in/custom rule IDs. "
|
||||
"Use '&', '!' inside one level, and use '>' between priority levels. "
|
||||
"Example: 'SPECSUB & CNVOI & 4K & !BLU > CNSUB & CNVOI & 4K & !BLU'."
|
||||
),
|
||||
)
|
||||
media_type: Optional[str] = Field(
|
||||
None,
|
||||
description="Optional media type scope: '电影', '电视剧', 'movie', or 'tv'.",
|
||||
)
|
||||
category: Optional[str] = Field(
|
||||
None,
|
||||
description="Optional media category. Only valid when media_type is set.",
|
||||
)
|
||||
|
||||
|
||||
class AddRuleGroupTool(MoviePilotTool):
|
||||
name: str = "add_rule_group"
|
||||
description: str = (
|
||||
"Add a new filter rule group to UserFilterRuleGroups. "
|
||||
"Rule groups are matched level by level from left to right and can be linked to search/subscription flows. "
|
||||
"Before calling this tool, first use query_builtin_filter_rules and query_custom_filter_rules to confirm valid rule IDs, "
|
||||
"and optionally use query_rule_groups to imitate existing rule_string patterns."
|
||||
)
|
||||
args_schema: Type[BaseModel] = AddRuleGroupInput
|
||||
require_admin: bool = True
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return f"新增规则组 {kwargs.get('name', '')}"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
name: str,
|
||||
rule_string: str,
|
||||
media_type: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
logger.info(f"执行工具: {self.name}, name={name}")
|
||||
|
||||
try:
|
||||
custom_rules = get_custom_rules()
|
||||
available_rule_ids = set(get_builtin_rules().keys()) | set(
|
||||
build_custom_rule_map(custom_rules).keys()
|
||||
)
|
||||
rule_groups = get_rule_groups()
|
||||
new_group, _ = normalize_rule_group(
|
||||
name=name,
|
||||
rule_string=rule_string,
|
||||
media_type=media_type,
|
||||
category=category,
|
||||
existing_groups=rule_groups,
|
||||
available_rule_ids=available_rule_ids,
|
||||
)
|
||||
|
||||
rule_groups.append(new_group)
|
||||
await save_system_config(
|
||||
SystemConfigKey.UserFilterRuleGroups,
|
||||
[group.model_dump(exclude_none=True) for group in rule_groups],
|
||||
)
|
||||
usage = await collect_rule_group_usages([new_group.name])
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"已新增规则组 {new_group.name}",
|
||||
"rule_group": serialize_rule_group(
|
||||
new_group, usage.get(new_group.name)
|
||||
),
|
||||
"count": len(rule_groups),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"新增规则组失败: {exc}", exc_info=True)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"新增规则组失败: {exc}",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
@@ -1,22 +1,21 @@
|
||||
"""添加订阅工具"""
|
||||
|
||||
from typing import Optional, Type, List
|
||||
from typing import List, Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.db.user_oper import UserOper
|
||||
from app.log import logger
|
||||
from app.schemas.types import MediaType
|
||||
from app.schemas.types import MediaType, MessageChannel
|
||||
|
||||
|
||||
class AddSubscribeInput(BaseModel):
|
||||
"""添加订阅工具的输入参数模型"""
|
||||
|
||||
explanation: str = Field(
|
||||
...,
|
||||
description="Clear explanation of why this tool is being used in the current context",
|
||||
)
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
title: str = Field(
|
||||
...,
|
||||
description="The title of the media to subscribe to (e.g., 'The Matrix', 'Breaking Bad')",
|
||||
@@ -101,6 +100,38 @@ class AddSubscribeTool(MoviePilotTool):
|
||||
|
||||
return message
|
||||
|
||||
async def _resolve_subscribe_username(self) -> Optional[str]:
|
||||
"""优先映射为系统用户名,未绑定时回退当前渠道用户名。"""
|
||||
resolved_username = self._username
|
||||
if not self._channel or not self._user_id:
|
||||
return resolved_username
|
||||
|
||||
try:
|
||||
channel = MessageChannel(self._channel)
|
||||
except ValueError:
|
||||
return resolved_username
|
||||
|
||||
binding_keys = {
|
||||
MessageChannel.Telegram: ("telegram_userid",),
|
||||
MessageChannel.Discord: ("discord_userid",),
|
||||
MessageChannel.Wechat: ("wechat_userid",),
|
||||
MessageChannel.Feishu: ("feishu_userid", "feishu_openid"),
|
||||
MessageChannel.WechatClawBot: ("wechatclawbot_userid",),
|
||||
MessageChannel.Slack: ("slack_userid",),
|
||||
MessageChannel.VoceChat: ("vocechat_userid",),
|
||||
MessageChannel.SynologyChat: ("synologychat_userid",),
|
||||
MessageChannel.QQ: ("qq_userid", "qq_openid"),
|
||||
}.get(channel)
|
||||
if not binding_keys:
|
||||
return resolved_username
|
||||
|
||||
mapped_username = await self.run_blocking(
|
||||
"db",
|
||||
UserOper().get_name,
|
||||
**{key: self._user_id for key in binding_keys},
|
||||
)
|
||||
return mapped_username or resolved_username
|
||||
|
||||
async def run(
|
||||
self,
|
||||
title: str,
|
||||
@@ -137,6 +168,7 @@ class AddSubscribeTool(MoviePilotTool):
|
||||
if media_type_enum == MediaType.TV
|
||||
else None
|
||||
)
|
||||
subscribe_username = await self._resolve_subscribe_username()
|
||||
|
||||
# 构建额外的订阅参数
|
||||
subscribe_kwargs = {}
|
||||
@@ -162,7 +194,7 @@ class AddSubscribeTool(MoviePilotTool):
|
||||
tmdbid=tmdb_id,
|
||||
doubanid=douban_id,
|
||||
season=season,
|
||||
username=self._user_id,
|
||||
username=subscribe_username,
|
||||
**subscribe_kwargs,
|
||||
)
|
||||
if sid:
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import List, Optional, Type
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool, ToolChain
|
||||
from app.chain.interaction import (
|
||||
from app.helper.interaction import (
|
||||
AgentInteractionOption,
|
||||
agent_interaction_manager,
|
||||
)
|
||||
@@ -26,9 +26,11 @@ class UserChoiceOptionInput(BaseModel):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_option(self):
|
||||
if not self.label.strip():
|
||||
label = str(self.label)
|
||||
value = str(self.value)
|
||||
if not label.strip():
|
||||
raise ValueError("label 不能为空")
|
||||
if not self.value.strip():
|
||||
if not value.strip():
|
||||
raise ValueError("value 不能为空")
|
||||
return self
|
||||
|
||||
@@ -36,10 +38,8 @@ class UserChoiceOptionInput(BaseModel):
|
||||
class AskUserChoiceInput(BaseModel):
|
||||
"""按钮选择工具输入。"""
|
||||
|
||||
explanation: str = Field(
|
||||
...,
|
||||
description="Clear explanation of why the agent needs the user to choose from buttons",
|
||||
)
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why the agent needs the user to choose from buttons",)
|
||||
message: str = Field(
|
||||
...,
|
||||
description="Question or prompt shown to the user together with the buttons",
|
||||
@@ -55,7 +55,8 @@ class AskUserChoiceInput(BaseModel):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_payload(self):
|
||||
if not self.message.strip():
|
||||
message = str(self.message)
|
||||
if not message.strip():
|
||||
raise ValueError("message 不能为空")
|
||||
if not self.options:
|
||||
raise ValueError("options 至少需要提供一个")
|
||||
@@ -63,7 +64,11 @@ class AskUserChoiceInput(BaseModel):
|
||||
|
||||
|
||||
class AskUserChoiceTool(MoviePilotTool):
|
||||
"""发送按钮选择并让当前 Agent 轮次等待用户回调消息。"""
|
||||
|
||||
name: str = "ask_user_choice"
|
||||
sends_message: bool = True
|
||||
return_direct: bool = True
|
||||
description: str = (
|
||||
"Ask the user to choose from button options on channels that support interactive buttons. "
|
||||
"After the user clicks a button, the selected value will come back as the user's next message."
|
||||
@@ -85,6 +90,15 @@ class AskUserChoiceTool(MoviePilotTool):
|
||||
return text[:max_length]
|
||||
return text[: max_length - 3] + "..."
|
||||
|
||||
def _blocked_by_feedback_quality_gate(self) -> bool:
|
||||
"""反馈 Issue 质量门槛拒绝后,禁止继续发按钮引导改写。
|
||||
|
||||
这是对 ``feedback-issue`` skill 的历史兜底:如果同一轮上下文已经
|
||||
标记反馈内容被质量门槛拒绝,就不能再用按钮诱导用户把测试 / 占位
|
||||
内容改写成“真实问题”。
|
||||
"""
|
||||
return bool(self._agent_context.get("feedback_issue_rejected_quality"))
|
||||
|
||||
async def run(
|
||||
self,
|
||||
message: str,
|
||||
@@ -92,6 +106,17 @@ class AskUserChoiceTool(MoviePilotTool):
|
||||
title: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
if self._blocked_by_feedback_quality_gate():
|
||||
logger.warning(
|
||||
"ask_user_choice blocked after feedback issue rejected_quality: "
|
||||
"session_id=%s",
|
||||
self._session_id,
|
||||
)
|
||||
return (
|
||||
"反馈 Issue 已被质量门槛拒绝,不能继续发送按钮引导用户改写或重新提交。"
|
||||
"请直接结束本次反馈流程。"
|
||||
)
|
||||
|
||||
if not self._channel or not self._source:
|
||||
return "当前不在可回传消息的会话中,无法发起按钮选择"
|
||||
|
||||
|
||||
@@ -38,10 +38,8 @@ class BrowserAction(str, Enum):
|
||||
class BrowseWebpageInput(BaseModel):
|
||||
"""浏览器操作工具的输入参数模型"""
|
||||
|
||||
explanation: str = Field(
|
||||
...,
|
||||
description="Clear explanation of why this browser action is being performed",
|
||||
)
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this browser action is being performed",)
|
||||
action: str = Field(
|
||||
...,
|
||||
description=(
|
||||
@@ -198,68 +196,62 @@ class BrowseWebpageTool(MoviePilotTool):
|
||||
cookies: Optional[str],
|
||||
user_agent: Optional[str],
|
||||
) -> str:
|
||||
"""在同步上下文中执行 Playwright 浏览器操作"""
|
||||
from playwright.sync_api import sync_playwright
|
||||
"""在同步上下文中执行 CloakBrowser 浏览器操作"""
|
||||
from cloakbrowser import launch_context
|
||||
|
||||
try:
|
||||
with sync_playwright() as playwright:
|
||||
browser = None
|
||||
context = None
|
||||
page = None
|
||||
try:
|
||||
# 启动浏览器
|
||||
browser_type = settings.PLAYWRIGHT_BROWSER_TYPE or "chromium"
|
||||
browser = playwright[browser_type].launch(headless=True)
|
||||
|
||||
# 创建上下文
|
||||
context_kwargs = {}
|
||||
if user_agent:
|
||||
context_kwargs["user_agent"] = user_agent
|
||||
# 设置视口大小
|
||||
context_kwargs["viewport"] = {
|
||||
context = None
|
||||
page = None
|
||||
try:
|
||||
context_kwargs = {
|
||||
"viewport": {
|
||||
"width": SCREENSHOT_MAX_WIDTH,
|
||||
"height": SCREENSHOT_MAX_HEIGHT,
|
||||
}
|
||||
}
|
||||
if user_agent:
|
||||
context_kwargs["user_agent"] = user_agent
|
||||
|
||||
context = browser.new_context(**context_kwargs)
|
||||
page = context.new_page()
|
||||
page.set_default_timeout(timeout * 1000)
|
||||
context = launch_context(
|
||||
headless=True,
|
||||
humanize=settings.CLOAKBROWSER_HUMANIZE,
|
||||
human_preset=settings.CLOAKBROWSER_HUMAN_PRESET,
|
||||
**context_kwargs,
|
||||
)
|
||||
page = context.new_page()
|
||||
page.set_default_timeout(timeout * 1000)
|
||||
|
||||
# 设置 cookies
|
||||
if cookies:
|
||||
page.set_extra_http_headers({"cookie": cookies})
|
||||
# 设置 cookies
|
||||
if cookies:
|
||||
page.set_extra_http_headers({"cookie": cookies})
|
||||
|
||||
# 对于非 goto 操作,如果提供了 url 先导航
|
||||
if url and browser_action != BrowserAction.GOTO:
|
||||
page.goto(
|
||||
url, wait_until="domcontentloaded", timeout=timeout * 1000
|
||||
)
|
||||
page.wait_for_load_state("networkidle", timeout=timeout * 1000)
|
||||
# 对于非 goto 操作,如果提供了 url 先导航
|
||||
if url and browser_action != BrowserAction.GOTO:
|
||||
page.goto(url, wait_until="domcontentloaded", timeout=timeout * 1000)
|
||||
page.wait_for_load_state("networkidle", timeout=timeout * 1000)
|
||||
|
||||
# 执行具体操作
|
||||
result = self._do_action(
|
||||
page,
|
||||
browser_action,
|
||||
url,
|
||||
selector,
|
||||
value,
|
||||
script,
|
||||
content_type,
|
||||
timeout,
|
||||
)
|
||||
return result
|
||||
# 执行具体操作
|
||||
result = self._do_action(
|
||||
page,
|
||||
browser_action,
|
||||
url,
|
||||
selector,
|
||||
value,
|
||||
script,
|
||||
content_type,
|
||||
timeout,
|
||||
)
|
||||
return result
|
||||
|
||||
finally:
|
||||
if page:
|
||||
page.close()
|
||||
if context:
|
||||
context.close()
|
||||
if browser:
|
||||
browser.close()
|
||||
finally:
|
||||
if page:
|
||||
page.close()
|
||||
if context:
|
||||
context.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Playwright 执行失败: {e}", exc_info=True)
|
||||
return f"Playwright 执行失败: {str(e)}"
|
||||
logger.error(f"CloakBrowser 执行失败: {e}", exc_info=True)
|
||||
return f"CloakBrowser 执行失败: {str(e)}"
|
||||
|
||||
def _do_action(
|
||||
self,
|
||||
|
||||
95
app/agent/tools/impl/delete_custom_filter_rule.py
Normal file
95
app/agent/tools/impl/delete_custom_filter_rule.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""删除自定义过滤规则工具。"""
|
||||
|
||||
import json
|
||||
from typing import Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.impl._filter_rule_utils import (
|
||||
collect_custom_rule_group_refs,
|
||||
get_custom_rules,
|
||||
get_rule_groups,
|
||||
save_system_config,
|
||||
)
|
||||
from app.log import logger
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
|
||||
class DeleteCustomFilterRuleInput(BaseModel):
|
||||
"""删除自定义过滤规则工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
rule_id: str = Field(..., description="Custom rule ID to delete.")
|
||||
|
||||
|
||||
class DeleteCustomFilterRuleTool(MoviePilotTool):
|
||||
name: str = "delete_custom_filter_rule"
|
||||
description: str = (
|
||||
"Delete a custom filter rule from CustomFilterRules. "
|
||||
"If the rule is still referenced by rule groups, the deletion is blocked to avoid breaking rule_string expressions."
|
||||
)
|
||||
args_schema: Type[BaseModel] = DeleteCustomFilterRuleInput
|
||||
require_admin: bool = True
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return f"删除自定义过滤规则 {kwargs.get('rule_id', '')}"
|
||||
|
||||
async def run(self, rule_id: str, **kwargs) -> str:
|
||||
logger.info(f"执行工具: {self.name}, rule_id={rule_id}")
|
||||
|
||||
try:
|
||||
custom_rules = get_custom_rules()
|
||||
target_rule = next((rule for rule in custom_rules if rule.id == rule_id), None)
|
||||
if not target_rule:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"自定义过滤规则 '{rule_id}' 不存在",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
refs = collect_custom_rule_group_refs(get_rule_groups(), [rule_id]).get(
|
||||
rule_id, []
|
||||
)
|
||||
if refs:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"message": (
|
||||
f"自定义过滤规则 '{rule_id}' 仍被规则组引用,无法删除。"
|
||||
),
|
||||
"referenced_by_rule_groups": refs,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
remaining_rules = [
|
||||
rule for rule in custom_rules if rule.id != rule_id
|
||||
]
|
||||
await save_system_config(
|
||||
SystemConfigKey.CustomFilterRules,
|
||||
[rule.model_dump(exclude_none=True) for rule in remaining_rules],
|
||||
)
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"已删除自定义过滤规则 {rule_id}",
|
||||
"count": len(remaining_rules),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"删除自定义过滤规则失败: {exc}", exc_info=True)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"删除自定义过滤规则失败: {exc}",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
@@ -12,10 +12,8 @@ from app.log import logger
|
||||
class DeleteDownloadInput(BaseModel):
|
||||
"""删除下载任务工具的输入参数模型"""
|
||||
|
||||
explanation: str = Field(
|
||||
...,
|
||||
description="Clear explanation of why this tool is being used in the current context",
|
||||
)
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
hash: str = Field(
|
||||
..., description="Task hash (can be obtained from query_download_tasks tool)"
|
||||
)
|
||||
@@ -49,6 +47,15 @@ class DeleteDownloadTool(MoviePilotTool):
|
||||
|
||||
return message
|
||||
|
||||
@staticmethod
|
||||
def _delete_download_sync(
|
||||
hash_value: str, downloader: Optional[str] = None, delete_files: bool = False
|
||||
) -> bool:
|
||||
"""同步删除下载任务,避免下载器客户端阻塞事件循环。"""
|
||||
return DownloadChain().remove_torrents(
|
||||
hashs=[hash_value], downloader=downloader, delete_file=delete_files
|
||||
)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
hash: str,
|
||||
@@ -61,16 +68,18 @@ class DeleteDownloadTool(MoviePilotTool):
|
||||
)
|
||||
|
||||
try:
|
||||
download_chain = DownloadChain()
|
||||
|
||||
# 仅支持通过hash删除任务
|
||||
if len(hash) != 40 or not all(c in "0123456789abcdefABCDEF" for c in hash):
|
||||
return "参数错误:hash 格式无效,请先使用 query_download_tasks 工具获取正确的 hash。"
|
||||
|
||||
# 删除下载任务
|
||||
# remove_torrents 支持 delete_file 参数,可以控制是否删除文件
|
||||
result = download_chain.remove_torrents(
|
||||
hashs=[hash], downloader=downloader, delete_file=delete_files
|
||||
result = await self.run_blocking(
|
||||
"downloader",
|
||||
self._delete_download_sync,
|
||||
hash,
|
||||
downloader,
|
||||
bool(delete_files),
|
||||
)
|
||||
|
||||
if result:
|
||||
|
||||
@@ -13,10 +13,8 @@ from app.log import logger
|
||||
class DeleteDownloadHistoryInput(BaseModel):
|
||||
"""删除下载历史记录工具的输入参数模型"""
|
||||
|
||||
explanation: str = Field(
|
||||
...,
|
||||
description="Clear explanation of why this tool is being used in the current context",
|
||||
)
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
history_id: int = Field(
|
||||
..., description="The ID of the download history record to delete"
|
||||
)
|
||||
|
||||
79
app/agent/tools/impl/delete_rule_group.py
Normal file
79
app/agent/tools/impl/delete_rule_group.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""删除过滤规则组工具。"""
|
||||
|
||||
import json
|
||||
from typing import Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.impl._filter_rule_utils import (
|
||||
get_rule_groups,
|
||||
remove_rule_group_references,
|
||||
save_system_config,
|
||||
)
|
||||
from app.log import logger
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
|
||||
class DeleteRuleGroupInput(BaseModel):
|
||||
"""删除过滤规则组工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
name: str = Field(..., description="Rule group name to delete.")
|
||||
|
||||
|
||||
class DeleteRuleGroupTool(MoviePilotTool):
|
||||
name: str = "delete_rule_group"
|
||||
description: str = (
|
||||
"Delete a filter rule group from UserFilterRuleGroups. "
|
||||
"The tool also removes dangling references from global settings and subscriptions."
|
||||
)
|
||||
args_schema: Type[BaseModel] = DeleteRuleGroupInput
|
||||
require_admin: bool = True
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return f"删除规则组 {kwargs.get('name', '')}"
|
||||
|
||||
async def run(self, name: str, **kwargs) -> str:
|
||||
logger.info(f"执行工具: {self.name}, name={name}")
|
||||
|
||||
try:
|
||||
rule_groups = get_rule_groups()
|
||||
if not any(group.name == name for group in rule_groups):
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"规则组 '{name}' 不存在",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
remaining_groups = [
|
||||
group for group in rule_groups if group.name != name
|
||||
]
|
||||
await save_system_config(
|
||||
SystemConfigKey.UserFilterRuleGroups,
|
||||
[group.model_dump(exclude_none=True) for group in remaining_groups],
|
||||
)
|
||||
reference_changes = await remove_rule_group_references(name)
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"已删除规则组 {name}",
|
||||
"count": len(remaining_groups),
|
||||
"reference_updates": reference_changes,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"删除规则组失败: {exc}", exc_info=True)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"删除规则组失败: {exc}",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
@@ -15,10 +15,8 @@ from app.schemas.types import EventType
|
||||
class DeleteSubscribeInput(BaseModel):
|
||||
"""删除订阅工具的输入参数模型"""
|
||||
|
||||
explanation: str = Field(
|
||||
...,
|
||||
description="Clear explanation of why this tool is being used in the current context",
|
||||
)
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
subscribe_id: int = Field(
|
||||
...,
|
||||
description="The ID of the subscription to delete (can be obtained from query_subscribes tool)",
|
||||
@@ -49,8 +47,11 @@ class DeleteSubscribeTool(MoviePilotTool):
|
||||
# 在删除之前获取订阅信息(用于事件)
|
||||
subscribe_info = subscribe.to_dict()
|
||||
|
||||
# 删除订阅
|
||||
subscribe_oper.delete(subscribe_id)
|
||||
await subscribe_oper.async_delete(subscribe_id)
|
||||
# 分享订阅统计刷新本身已异步化,这里只需要在删除后触发即可。
|
||||
SubscribeHelper().sub_done_async(
|
||||
{"tmdbid": subscribe.tmdbid, "doubanid": subscribe.doubanid}
|
||||
)
|
||||
|
||||
# 发送事件
|
||||
await eventmanager.async_send_event(
|
||||
@@ -58,11 +59,6 @@ class DeleteSubscribeTool(MoviePilotTool):
|
||||
{"subscribe_id": subscribe_id, "subscribe_info": subscribe_info},
|
||||
)
|
||||
|
||||
# 统计订阅
|
||||
SubscribeHelper().sub_done_async(
|
||||
{"tmdbid": subscribe.tmdbid, "doubanid": subscribe.doubanid}
|
||||
)
|
||||
|
||||
return f"成功删除订阅:{subscribe.name} ({subscribe.year})"
|
||||
except Exception as e:
|
||||
logger.error(f"删除订阅失败: {e}", exc_info=True)
|
||||
|
||||
@@ -12,10 +12,8 @@ from app.log import logger
|
||||
class DeleteTransferHistoryInput(BaseModel):
|
||||
"""删除整理历史记录工具的输入参数模型"""
|
||||
|
||||
explanation: str = Field(
|
||||
...,
|
||||
description="Clear explanation of why this tool is being used in the current context",
|
||||
)
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
history_id: int = Field(
|
||||
..., description="The ID of the transfer history record to delete"
|
||||
)
|
||||
@@ -37,21 +35,17 @@ class DeleteTransferHistoryTool(MoviePilotTool):
|
||||
|
||||
try:
|
||||
transferhis = TransferHistoryOper()
|
||||
|
||||
# 查询历史记录是否存在
|
||||
history = transferhis.get(history_id)
|
||||
history = await transferhis.async_get(history_id)
|
||||
if not history:
|
||||
return f"错误:整理历史记录不存在,ID={history_id}"
|
||||
|
||||
# 保存信息用于返回
|
||||
title = history.title or "未知"
|
||||
src = history.src or "未知"
|
||||
status = "成功" if history.status else "失败"
|
||||
|
||||
# 删除记录
|
||||
transferhis.delete(history_id)
|
||||
|
||||
return f"已删除整理历史记录:ID={history_id},标题={title},源路径={src},状态={status}"
|
||||
await transferhis.async_delete(history_id)
|
||||
return (
|
||||
f"已删除整理历史记录:ID={history_id},标题={title},源路径={src},状态={status}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"删除整理历史记录失败: {e}", exc_info=True)
|
||||
return f"删除整理历史记录时发生错误: {str(e)}"
|
||||
|
||||
@@ -1,96 +1,286 @@
|
||||
"""执行Shell命令工具"""
|
||||
"""执行 Shell 命令工具。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Type
|
||||
from tempfile import NamedTemporaryFile
|
||||
from typing import Any, Literal, Optional, TextIO, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.impl.terminal_session import (
|
||||
TERMINAL_DEFAULT_READ_BYTES,
|
||||
TERMINAL_MAX_READ_BYTES,
|
||||
TERMINAL_WAIT_DEFAULT_MS,
|
||||
terminal_session_manager,
|
||||
)
|
||||
from app.log import logger
|
||||
|
||||
|
||||
DEFAULT_TIMEOUT_SECONDS = 60
|
||||
MAX_TIMEOUT_SECONDS = 300
|
||||
MAX_OUTPUT_CHARS = 6000
|
||||
MAX_OUTPUT_PREVIEW_BYTES = 10 * 1024
|
||||
READ_CHUNK_SIZE = 4096
|
||||
KILL_GRACE_SECONDS = 3
|
||||
COMMAND_CONCURRENCY_LIMIT = 2
|
||||
COMMAND_FORBIDDEN_KEYWORDS = (
|
||||
":(){ :|:& };:",
|
||||
"dd if=/dev/zero",
|
||||
"mkfs",
|
||||
"reboot",
|
||||
"shutdown",
|
||||
)
|
||||
|
||||
_command_semaphore = asyncio.Semaphore(COMMAND_CONCURRENCY_LIMIT)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CommandOutput:
|
||||
"""保存受限命令输出,避免大输出一次性进入内存。"""
|
||||
"""保存前 10KB 预览,并在超限时将完整输出写入临时文件。"""
|
||||
|
||||
limit: int
|
||||
stdout_chunks: list[str] = field(default_factory=list)
|
||||
stderr_chunks: list[str] = field(default_factory=list)
|
||||
captured_chars: int = 0
|
||||
truncated: bool = False
|
||||
preview_limit_bytes: int
|
||||
preview_entries: list[tuple[str, str]] = field(default_factory=list)
|
||||
captured_bytes: int = 0
|
||||
preview_truncated: bool = False
|
||||
temp_file_path: Optional[str] = None
|
||||
temp_file_handle: Optional[TextIO] = None
|
||||
last_written_stream: Optional[str] = None
|
||||
|
||||
@staticmethod
|
||||
def _clip_text_to_bytes(text: str, byte_limit: int) -> str:
|
||||
"""按 UTF-8 字节数截断文本,避免截断后出现非法字符。"""
|
||||
if byte_limit <= 0:
|
||||
return ""
|
||||
return text.encode("utf-8")[:byte_limit].decode("utf-8", errors="ignore")
|
||||
|
||||
def _write_chunk(self, stream_name: str, text: str) -> None:
|
||||
"""把输出分片按 stdout/stderr 分段写入临时文件。"""
|
||||
if not self.temp_file_handle or not text:
|
||||
return
|
||||
|
||||
if self.last_written_stream != stream_name:
|
||||
if self.temp_file_handle.tell() > 0:
|
||||
self.temp_file_handle.write("\n")
|
||||
title = "标准输出" if stream_name == "stdout" else "错误输出"
|
||||
self.temp_file_handle.write(f"[{title}]\n")
|
||||
self.last_written_stream = stream_name
|
||||
|
||||
self.temp_file_handle.write(text)
|
||||
|
||||
def _ensure_temp_file(self) -> None:
|
||||
"""首次超出预览上限时创建临时文件并补写已缓存预览。"""
|
||||
if self.temp_file_handle:
|
||||
return
|
||||
|
||||
temp_file = NamedTemporaryFile(
|
||||
mode="w",
|
||||
encoding="utf-8",
|
||||
suffix=".log",
|
||||
prefix="moviepilot-command-",
|
||||
delete=False,
|
||||
)
|
||||
self.temp_file_path = temp_file.name
|
||||
self.temp_file_handle = temp_file
|
||||
for stream_name, chunk in self.preview_entries:
|
||||
self._write_chunk(stream_name, chunk)
|
||||
|
||||
def close(self) -> None:
|
||||
"""关闭临时文件句柄,确保输出落盘。"""
|
||||
if not self.temp_file_handle:
|
||||
return
|
||||
self.temp_file_handle.flush()
|
||||
self.temp_file_handle.close()
|
||||
self.temp_file_handle = None
|
||||
|
||||
def append(self, stream_name: str, text: str) -> None:
|
||||
"""追加一段输出,超出预览上限后只保留完整日志文件。"""
|
||||
if not text:
|
||||
return
|
||||
|
||||
remaining = self.limit - self.captured_chars
|
||||
if remaining <= 0:
|
||||
self.truncated = True
|
||||
if self.temp_file_handle:
|
||||
self._write_chunk(stream_name, text)
|
||||
return
|
||||
|
||||
captured = text[:remaining]
|
||||
if stream_name == "stdout":
|
||||
self.stdout_chunks.append(captured)
|
||||
else:
|
||||
self.stderr_chunks.append(captured)
|
||||
chunk_bytes = len(text.encode("utf-8"))
|
||||
remaining = self.preview_limit_bytes - self.captured_bytes
|
||||
if chunk_bytes <= remaining:
|
||||
self.preview_entries.append((stream_name, text))
|
||||
self.captured_bytes += chunk_bytes
|
||||
return
|
||||
|
||||
self.captured_chars += len(captured)
|
||||
if len(text) > remaining:
|
||||
self.truncated = True
|
||||
self.preview_truncated = True
|
||||
self._ensure_temp_file()
|
||||
self._write_chunk(stream_name, text)
|
||||
|
||||
preview = self._clip_text_to_bytes(text, remaining)
|
||||
if preview:
|
||||
self.preview_entries.append((stream_name, preview))
|
||||
self.captured_bytes += len(preview.encode("utf-8"))
|
||||
|
||||
@property
|
||||
def stdout(self) -> str:
|
||||
return "".join(self.stdout_chunks).strip()
|
||||
"""返回当前保留的 stdout 预览。"""
|
||||
return "".join(
|
||||
text for stream_name, text in self.preview_entries if stream_name == "stdout"
|
||||
).strip()
|
||||
|
||||
@property
|
||||
def stderr(self) -> str:
|
||||
return "".join(self.stderr_chunks).strip()
|
||||
"""返回当前保留的 stderr 预览。"""
|
||||
return "".join(
|
||||
text for stream_name, text in self.preview_entries if stream_name == "stderr"
|
||||
).strip()
|
||||
|
||||
|
||||
class ExecuteCommandInput(BaseModel):
|
||||
"""执行Shell命令工具的输入参数模型"""
|
||||
"""执行 Shell 命令工具的输入参数模型。"""
|
||||
|
||||
explanation: str = Field(
|
||||
..., description="Clear explanation of why this command is being executed"
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this command action is needed")
|
||||
action: Optional[Literal["start", "read", "wait", "write", "kill", "run"]] = Field(
|
||||
"start",
|
||||
description=(
|
||||
"Command action. start launches a managed background session and returns "
|
||||
"session_id. read/wait/write/kill operate on that session. run executes "
|
||||
"once and waits until completion or timeout."
|
||||
),
|
||||
)
|
||||
command: Optional[str] = Field(
|
||||
None,
|
||||
description="Shell command. Required for action=start or action=run.",
|
||||
)
|
||||
session_id: Optional[str] = Field(
|
||||
None,
|
||||
description="Command session id returned by action=start.",
|
||||
)
|
||||
input_text: Optional[str] = Field(
|
||||
None,
|
||||
description="Text to send to stdin for action=write. Use \\u0003 for Ctrl+C.",
|
||||
)
|
||||
signal_name: Optional[str] = Field(
|
||||
"TERM",
|
||||
description="Signal for action=kill, such as TERM, INT, KILL, or 15.",
|
||||
)
|
||||
cwd: Optional[str] = Field(
|
||||
None,
|
||||
description="Working directory for action=start or action=run.",
|
||||
)
|
||||
env: Optional[dict[str, Any]] = Field(
|
||||
None,
|
||||
description="Additional environment variables for action=start.",
|
||||
)
|
||||
use_pty: Optional[bool] = Field(
|
||||
True,
|
||||
description="Use a pseudo terminal for action=start when supported.",
|
||||
)
|
||||
since_seq: Optional[int] = Field(
|
||||
None,
|
||||
description="For action=read/wait, return output chunks after this seq.",
|
||||
)
|
||||
max_bytes: Optional[int] = Field(
|
||||
TERMINAL_DEFAULT_READ_BYTES,
|
||||
description="For action=read/wait, maximum output bytes to return.",
|
||||
)
|
||||
timeout_ms: Optional[int] = Field(
|
||||
TERMINAL_WAIT_DEFAULT_MS,
|
||||
description="For action=wait, maximum segmented wait time in milliseconds.",
|
||||
)
|
||||
command: str = Field(..., description="The shell command to execute")
|
||||
timeout: Optional[int] = Field(
|
||||
60, description="Max execution time in seconds (default: 60)"
|
||||
60,
|
||||
description="For action=run, max execution time in seconds.",
|
||||
)
|
||||
|
||||
|
||||
class ExecuteCommandTool(MoviePilotTool):
|
||||
"""统一执行和管理 Shell 命令的 Agent 工具。"""
|
||||
|
||||
name: str = "execute_command"
|
||||
description: str = (
|
||||
"Safely execute shell commands on the server. Useful for system "
|
||||
"maintenance, checking status, or running custom scripts. Includes "
|
||||
"timeout, concurrency, and hard output limits."
|
||||
"Start and manage shell commands on the server. By default action=start "
|
||||
"launches a background session and immediately returns session_id/status/"
|
||||
"last_seq/output_until_seq. Call the same tool with action=read, wait, "
|
||||
"write, or kill to poll output, wait in short segments, send stdin, or "
|
||||
"terminate it. Use action=run only when a one-shot bounded command result "
|
||||
"is preferred."
|
||||
)
|
||||
args_schema: Type[BaseModel] = ExecuteCommandInput
|
||||
require_admin: bool = True
|
||||
result_max_chars = TERMINAL_MAX_READ_BYTES + 4096
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""根据命令生成友好的提示消息"""
|
||||
command = kwargs.get("command", "")
|
||||
return f"执行系统命令: {command}"
|
||||
"""根据命令动作生成友好的提示消息。"""
|
||||
action = kwargs.get("action") or "start"
|
||||
command = kwargs.get("command")
|
||||
session_id = kwargs.get("session_id")
|
||||
if action in {"start", "run"}:
|
||||
return f"执行系统命令: {command or ''}"
|
||||
if action == "read":
|
||||
return f"读取命令输出: {session_id or ''}"
|
||||
if action == "wait":
|
||||
return f"等待命令会话: {session_id or ''}"
|
||||
if action == "write":
|
||||
return f"写入命令输入: {session_id or ''}"
|
||||
if action == "kill":
|
||||
return f"终止命令会话: {session_id or ''}"
|
||||
return f"处理命令会话: {session_id or command or ''}"
|
||||
|
||||
@staticmethod
|
||||
def _dump(payload: dict[str, Any]) -> str:
|
||||
"""把结构化命令会话结果转换为 Agent 容易解析的 JSON 字符串。"""
|
||||
return json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
|
||||
@staticmethod
|
||||
def _require_session_id(session_id: Optional[str]) -> str:
|
||||
"""校验会话型 action 必须传入 session_id。"""
|
||||
if not session_id:
|
||||
raise ValueError("action 需要传入 session_id")
|
||||
return session_id
|
||||
|
||||
@staticmethod
|
||||
def _require_command(command: Optional[str]) -> str:
|
||||
"""校验启动型 action 必须传入 command。"""
|
||||
if not command or not command.strip():
|
||||
raise ValueError("action 需要传入 command")
|
||||
return command
|
||||
|
||||
@staticmethod
|
||||
def _validate_command(command: str) -> None:
|
||||
"""复用旧工具的基础危险命令过滤,避免明显破坏性命令进入 shell。"""
|
||||
for keyword in COMMAND_FORBIDDEN_KEYWORDS:
|
||||
if keyword in command:
|
||||
raise ValueError(f"命令包含禁止使用的关键字 '{keyword}'")
|
||||
|
||||
# 检查是否使用了 rm -r/R 删除根目录或一级目录,防止误杀多级目录
|
||||
import re
|
||||
import os.path
|
||||
tokens = re.split(r'\s+', command.strip())
|
||||
if any(t == "rm" or t.endswith("/rm") for t in tokens):
|
||||
has_r = False
|
||||
for token in tokens:
|
||||
if token.startswith("-") and ("r" in token or "R" in token):
|
||||
has_r = True
|
||||
break
|
||||
|
||||
if has_r:
|
||||
for token in tokens:
|
||||
# 提取可能包含目标路径的部分(去除重定向、管道、分号等末尾干扰)
|
||||
m = re.match(r'^([^;\|&><]+)', token)
|
||||
if m:
|
||||
clean_token = m.group(1).strip('"\'')
|
||||
# 仅对绝对路径进行一级目录限制
|
||||
if clean_token.startswith('/'):
|
||||
norm_path = os.path.normpath(clean_token)
|
||||
if re.match(r'^/[^/]*$', norm_path) or re.match(r'^/[^/]*/$', norm_path):
|
||||
raise ValueError(f"不允许使用 rm 命令删除根目录或一级目录: {clean_token}")
|
||||
|
||||
@staticmethod
|
||||
def _normalize_timeout(timeout: Optional[int]) -> tuple[int, Optional[str]]:
|
||||
"""限制命令最长运行时间,避免 Agent 传入过大的 timeout。"""
|
||||
"""限制一次性执行命令的最长运行时间。"""
|
||||
try:
|
||||
normalized = int(timeout or DEFAULT_TIMEOUT_SECONDS)
|
||||
except (TypeError, ValueError):
|
||||
@@ -107,7 +297,7 @@ class ExecuteCommandTool(MoviePilotTool):
|
||||
|
||||
@staticmethod
|
||||
def _subprocess_kwargs() -> dict:
|
||||
"""为子进程创建独立进程组,便于超时或输出过大时清理整棵子进程。"""
|
||||
"""为一次性命令创建独立进程组,便于超时清理整棵子进程。"""
|
||||
kwargs = {
|
||||
"stdin": subprocess.DEVNULL,
|
||||
"stdout": asyncio.subprocess.PIPE,
|
||||
@@ -124,27 +314,17 @@ class ExecuteCommandTool(MoviePilotTool):
|
||||
stream: asyncio.StreamReader,
|
||||
stream_name: str,
|
||||
output: _CommandOutput,
|
||||
limit_reached: asyncio.Event,
|
||||
) -> None:
|
||||
"""按块读取输出,达到上限后通知主流程终止命令。"""
|
||||
"""按块读取一次性命令输出,只把前 10KB 保留在返回结果中。"""
|
||||
while True:
|
||||
chunk = await stream.read(READ_CHUNK_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
|
||||
if output.truncated:
|
||||
limit_reached.set()
|
||||
continue
|
||||
|
||||
output.append(stream_name, chunk.decode("utf-8", errors="replace"))
|
||||
if output.truncated:
|
||||
limit_reached.set()
|
||||
# 达到上限后继续排空管道但不再保存内容,避免子进程因 pipe 反压卡住。
|
||||
continue
|
||||
|
||||
@staticmethod
|
||||
def _terminate_process(process: asyncio.subprocess.Process, sig: int):
|
||||
"""向进程组发送终止信号;不支持进程组的平台回退为单进程终止。"""
|
||||
def _terminate_process(process: Any, sig: int) -> None:
|
||||
"""向进程组发送终止信号,不支持进程组的平台回退为单进程终止。"""
|
||||
try:
|
||||
if os.name == "posix":
|
||||
os.killpg(process.pid, sig)
|
||||
@@ -158,7 +338,7 @@ class ExecuteCommandTool(MoviePilotTool):
|
||||
@classmethod
|
||||
async def _cleanup_process(
|
||||
cls,
|
||||
process: asyncio.subprocess.Process,
|
||||
process: Any,
|
||||
wait_task: asyncio.Task,
|
||||
) -> None:
|
||||
"""先温和终止,失败后强杀,避免超时 shell 遗留子进程。"""
|
||||
@@ -185,7 +365,7 @@ class ExecuteCommandTool(MoviePilotTool):
|
||||
|
||||
@staticmethod
|
||||
async def _finish_reader_tasks(reader_tasks: list[asyncio.Task]) -> None:
|
||||
"""等待输出读取任务退出,异常只记录不影响工具返回。"""
|
||||
"""等待一次性命令输出读取任务退出,异常只记录不影响工具返回。"""
|
||||
if not reader_tasks:
|
||||
return
|
||||
done, pending = await asyncio.wait(reader_tasks, timeout=1)
|
||||
@@ -199,107 +379,163 @@ class ExecuteCommandTool(MoviePilotTool):
|
||||
logger.debug("命令输出读取任务异常: %s", result)
|
||||
|
||||
@staticmethod
|
||||
def _format_result(
|
||||
def _format_run_result(
|
||||
*,
|
||||
exit_code: Optional[int],
|
||||
output: _CommandOutput,
|
||||
timeout: int,
|
||||
timed_out: bool,
|
||||
output_limited: bool,
|
||||
timeout_note: Optional[str],
|
||||
) -> str:
|
||||
"""格式化 action=run 的兼容文本结果。"""
|
||||
if timed_out:
|
||||
result = f"命令执行超时 (限制: {timeout}秒,已终止进程)"
|
||||
elif output_limited:
|
||||
result = (
|
||||
f"命令输出超过限制 (限制: {MAX_OUTPUT_CHARS}字符,"
|
||||
f"已截断并终止进程,退出码: {exit_code})"
|
||||
)
|
||||
else:
|
||||
result = f"命令执行完成 (退出码: {exit_code})"
|
||||
|
||||
if timeout_note:
|
||||
result += f"\n\n提示:\n{timeout_note}"
|
||||
if output.temp_file_path:
|
||||
file_note = "截至命令终止前的完整输出" if timed_out else "完整输出"
|
||||
result += (
|
||||
"\n\n提示:\n"
|
||||
f"命令输出超过 10KB,仅返回前 {MAX_OUTPUT_PREVIEW_BYTES} 字节内容。\n"
|
||||
f"{file_note}已写入临时文件: {output.temp_file_path}\n"
|
||||
"如需完整内容,请继续读取该文件。"
|
||||
)
|
||||
if output.stdout:
|
||||
result += f"\n\n标准输出:\n{output.stdout}"
|
||||
if output.stderr:
|
||||
result += f"\n\n错误输出:\n{output.stderr}"
|
||||
if output.truncated:
|
||||
result += "\n\n...(输出内容过长,已截断)"
|
||||
if output.preview_truncated:
|
||||
result += "\n\n...(仅展示前 10KB 内容)"
|
||||
if not output.stdout and not output.stderr:
|
||||
result += "\n\n(无输出内容)"
|
||||
return result
|
||||
|
||||
async def run(self, command: str, timeout: Optional[int] = 60, **kwargs) -> str:
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, 参数: command={command}, timeout={timeout}"
|
||||
)
|
||||
|
||||
# 简单安全过滤
|
||||
forbidden_keywords = [
|
||||
"rm -rf /",
|
||||
":(){ :|:& };:",
|
||||
"dd if=/dev/zero",
|
||||
"mkfs",
|
||||
"reboot",
|
||||
"shutdown",
|
||||
]
|
||||
for keyword in forbidden_keywords:
|
||||
if keyword in command:
|
||||
return f"错误:命令包含禁止使用的关键字 '{keyword}'"
|
||||
|
||||
async def _run_once(
|
||||
self,
|
||||
*,
|
||||
command: str,
|
||||
timeout: Optional[int],
|
||||
cwd: Optional[str] = None,
|
||||
) -> str:
|
||||
"""按旧模式一次性执行命令,等待完成或超时后返回文本结果。"""
|
||||
self._validate_command(command)
|
||||
normalized_timeout, timeout_note = self._normalize_timeout(timeout)
|
||||
|
||||
try:
|
||||
async with _command_semaphore:
|
||||
# 命令输出可能非常大,必须边读边截断,不能使用 communicate() 一次性收集。
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
command, **self._subprocess_kwargs()
|
||||
async with _command_semaphore:
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
command,
|
||||
cwd=cwd,
|
||||
**self._subprocess_kwargs(),
|
||||
)
|
||||
output = _CommandOutput(preview_limit_bytes=MAX_OUTPUT_PREVIEW_BYTES)
|
||||
wait_task = asyncio.create_task(process.wait())
|
||||
reader_tasks = [
|
||||
asyncio.create_task(self._read_stream(process.stdout, "stdout", output)),
|
||||
asyncio.create_task(self._read_stream(process.stderr, "stderr", output)),
|
||||
]
|
||||
|
||||
timed_out = False
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.shield(wait_task), timeout=normalized_timeout
|
||||
)
|
||||
output = _CommandOutput(limit=MAX_OUTPUT_CHARS)
|
||||
limit_reached = asyncio.Event()
|
||||
wait_task = asyncio.create_task(process.wait())
|
||||
limit_task = asyncio.create_task(limit_reached.wait())
|
||||
reader_tasks = [
|
||||
asyncio.create_task(
|
||||
self._read_stream(
|
||||
process.stdout, "stdout", output, limit_reached
|
||||
)
|
||||
),
|
||||
asyncio.create_task(
|
||||
self._read_stream(
|
||||
process.stderr, "stderr", output, limit_reached
|
||||
)
|
||||
),
|
||||
]
|
||||
except asyncio.TimeoutError:
|
||||
timed_out = True
|
||||
await self._cleanup_process(process, wait_task)
|
||||
|
||||
timed_out = False
|
||||
output_limited = False
|
||||
done, _ = await asyncio.wait(
|
||||
{wait_task, limit_task},
|
||||
timeout=normalized_timeout,
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
if wait_task not in done:
|
||||
if limit_task in done:
|
||||
output_limited = True
|
||||
else:
|
||||
timed_out = True
|
||||
await self._cleanup_process(process, wait_task)
|
||||
|
||||
limit_task.cancel()
|
||||
try:
|
||||
await self._finish_reader_tasks(reader_tasks)
|
||||
finally:
|
||||
output.close()
|
||||
|
||||
return self._format_result(
|
||||
exit_code=process.returncode,
|
||||
output=output,
|
||||
timeout=normalized_timeout,
|
||||
timed_out=timed_out,
|
||||
output_limited=output_limited,
|
||||
timeout_note=timeout_note,
|
||||
return self._format_run_result(
|
||||
exit_code=process.returncode,
|
||||
output=output,
|
||||
timeout=normalized_timeout,
|
||||
timed_out=timed_out,
|
||||
timeout_note=timeout_note,
|
||||
)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
action: Optional[str] = "start",
|
||||
command: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
input_text: Optional[str] = None,
|
||||
signal_name: Optional[str] = "TERM",
|
||||
cwd: Optional[str] = None,
|
||||
env: Optional[dict[str, Any]] = None,
|
||||
use_pty: Optional[bool] = True,
|
||||
since_seq: Optional[int] = None,
|
||||
max_bytes: Optional[int] = TERMINAL_DEFAULT_READ_BYTES,
|
||||
timeout_ms: Optional[int] = TERMINAL_WAIT_DEFAULT_MS,
|
||||
timeout: Optional[int] = 60,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""执行命令动作:默认后台启动,也支持读取、等待、写入、终止和一次性执行。"""
|
||||
normalized_action = (action or "start").strip().lower()
|
||||
logger.info(
|
||||
"执行工具: %s, action=%s, command=%s, session_id=%s",
|
||||
self.name,
|
||||
normalized_action,
|
||||
command,
|
||||
session_id,
|
||||
)
|
||||
|
||||
try:
|
||||
if normalized_action == "start":
|
||||
start_command = self._require_command(command)
|
||||
self._validate_command(start_command)
|
||||
payload = await terminal_session_manager.start(
|
||||
command=start_command,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
use_pty=use_pty,
|
||||
)
|
||||
return self._dump(payload)
|
||||
|
||||
if normalized_action == "read":
|
||||
payload = await terminal_session_manager.read(
|
||||
session_id=self._require_session_id(session_id),
|
||||
since_seq=since_seq,
|
||||
max_bytes=max_bytes,
|
||||
)
|
||||
return self._dump(payload)
|
||||
|
||||
if normalized_action == "wait":
|
||||
payload = await terminal_session_manager.wait(
|
||||
session_id=self._require_session_id(session_id),
|
||||
timeout_ms=timeout_ms,
|
||||
since_seq=since_seq,
|
||||
max_bytes=max_bytes,
|
||||
)
|
||||
return self._dump(payload)
|
||||
|
||||
if normalized_action == "write":
|
||||
payload = await terminal_session_manager.write(
|
||||
session_id=self._require_session_id(session_id),
|
||||
input_text=input_text or "",
|
||||
)
|
||||
return self._dump(payload)
|
||||
|
||||
if normalized_action == "kill":
|
||||
payload = await terminal_session_manager.kill(
|
||||
session_id=self._require_session_id(session_id),
|
||||
sig=signal_name,
|
||||
)
|
||||
return self._dump(payload)
|
||||
|
||||
if normalized_action == "run":
|
||||
return await self._run_once(
|
||||
command=self._require_command(command),
|
||||
timeout=timeout,
|
||||
cwd=cwd,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"执行命令失败: {e}", exc_info=True)
|
||||
return f"执行命令时发生错误: {str(e)}"
|
||||
raise ValueError(f"不支持的 action: {action}")
|
||||
except Exception as err:
|
||||
logger.error("执行命令 action 失败: %s", err, exc_info=True)
|
||||
return self._dump({"error": str(err), "status": "error", "action": normalized_action})
|
||||
|
||||
@@ -14,10 +14,8 @@ from app.schemas.types import MediaType, media_type_to_agent
|
||||
class GetRecommendationsInput(BaseModel):
|
||||
"""获取推荐工具的输入参数模型"""
|
||||
|
||||
explanation: str = Field(
|
||||
...,
|
||||
description="Clear explanation of why this tool is being used in the current context",
|
||||
)
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
source: Optional[str] = Field(
|
||||
"tmdb_trending",
|
||||
description="Recommendation source: "
|
||||
|
||||
@@ -20,10 +20,8 @@ from ._torrent_search_utils import (
|
||||
class GetSearchResultsInput(BaseModel):
|
||||
"""获取搜索结果工具的输入参数模型"""
|
||||
|
||||
explanation: str = Field(
|
||||
...,
|
||||
description="Clear explanation of why this tool is being used in the current context",
|
||||
)
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
site: Optional[List[str]] = Field(None, description="Site name filters")
|
||||
season: Optional[List[str]] = Field(None, description="Season or episode filters")
|
||||
free_state: Optional[List[str]] = Field(None, description="Promotion state filters")
|
||||
|
||||
116
app/agent/tools/impl/install_plugin.py
Normal file
116
app/agent/tools/impl/install_plugin.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""安装插件工具"""
|
||||
|
||||
import json
|
||||
from typing import Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.impl._plugin_tool_utils import (
|
||||
get_plugin_snapshot,
|
||||
install_plugin_runtime,
|
||||
load_market_plugins,
|
||||
summarize_plugin,
|
||||
)
|
||||
from app.log import logger
|
||||
|
||||
|
||||
class InstallPluginInput(BaseModel):
|
||||
"""安装插件工具的输入参数模型"""
|
||||
|
||||
explanation: 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.",
|
||||
)
|
||||
force: bool = Field(
|
||||
False,
|
||||
description="Whether to force reinstall or upgrade the specified plugin.",
|
||||
)
|
||||
force_refresh_market: bool = Field(
|
||||
False,
|
||||
description="Whether to refresh plugin market caches before reading the market list.",
|
||||
)
|
||||
|
||||
|
||||
class InstallPluginTool(MoviePilotTool):
|
||||
name: str = "install_plugin"
|
||||
description: str = (
|
||||
"Install a plugin by exact plugin_id from the plugin market or local plugin repositories. "
|
||||
"Use query_market_plugins first when you need filtering or discovery."
|
||||
)
|
||||
require_admin: bool = True
|
||||
args_schema: Type[BaseModel] = InstallPluginInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
plugin_id = kwargs.get("plugin_id")
|
||||
return f"安装插件: {plugin_id or '未知插件'}"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
plugin_id: str,
|
||||
force: bool = False,
|
||||
force_refresh_market: bool = False,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, 参数: plugin_id={plugin_id}, force={force}"
|
||||
)
|
||||
|
||||
try:
|
||||
plugins = await load_market_plugins(force_refresh=force_refresh_market)
|
||||
if not plugins:
|
||||
return json.dumps(
|
||||
{"success": False, "message": "当前插件市场没有可用插件"},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
candidate = next((plugin for plugin in plugins if plugin.id == plugin_id), None)
|
||||
if not candidate:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"未在插件市场中找到插件: {plugin_id}。请先调用 query_market_plugins 确认 plugin_id。",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
success, message, refreshed_only = await install_plugin_runtime(
|
||||
candidate.id,
|
||||
getattr(candidate, "repo_url", None),
|
||||
force=force,
|
||||
)
|
||||
if not success:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"plugin": summarize_plugin(candidate),
|
||||
"message": message,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
plugin_snapshot = get_plugin_snapshot(candidate.id)
|
||||
if refreshed_only and getattr(candidate, "has_update", False) and not force:
|
||||
message = "插件已安装,当前仅刷新加载;如需升级到市场新版本,请设置 force=true"
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": message,
|
||||
"force": force,
|
||||
"refreshed_only": refreshed_only,
|
||||
"plugin": summarize_plugin(candidate),
|
||||
"runtime": plugin_snapshot,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"安装插件失败: {e}", exc_info=True)
|
||||
return json.dumps(
|
||||
{"success": False, "message": f"安装插件时发生错误: {str(e)}"},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
@@ -16,7 +16,7 @@ from app.utils.string import StringUtils
|
||||
|
||||
class ListDirectoryInput(BaseModel):
|
||||
"""查询文件系统目录内容工具的输入参数模型"""
|
||||
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
path: str = Field(..., description="Directory path to list contents (e.g., '/home/user/downloads' or 'C:/Downloads')")
|
||||
storage: Optional[str] = Field("local", description="Storage type (default: 'local' for local file system, can be 'smb', 'alist', etc.)")
|
||||
sort_by: Optional[str] = Field("name", description="Sort order: 'name' for alphabetical sorting, 'time' for modification time sorting (default: 'name')")
|
||||
@@ -38,93 +38,81 @@ class ListDirectoryTool(MoviePilotTool):
|
||||
|
||||
return message
|
||||
|
||||
@staticmethod
|
||||
def _list_directory_sync(
|
||||
path: str, storage: Optional[str] = "local", sort_by: Optional[str] = "name"
|
||||
) -> str:
|
||||
"""
|
||||
目录遍历可能触发本地磁盘或远程存储请求,统一放到线程池中执行。
|
||||
"""
|
||||
if not path:
|
||||
return "错误:路径不能为空"
|
||||
|
||||
if storage == "local":
|
||||
if not path.startswith("/") and not (len(path) > 1 and path[1] == ":"):
|
||||
path = str(Path(path).resolve())
|
||||
elif not path.startswith("/"):
|
||||
path = "/" + path
|
||||
|
||||
fileitem = FileItem(storage=storage or "local", path=path, type="dir")
|
||||
file_list = StorageChain().list_files(fileitem, recursion=False)
|
||||
|
||||
if file_list is None:
|
||||
return f"无法访问目录:{path},请检查路径是否正确或存储是否可用"
|
||||
if not file_list:
|
||||
return f"目录 {path} 为空"
|
||||
|
||||
if sort_by == "time":
|
||||
file_list.sort(key=lambda x: x.modify_time or 0, reverse=True)
|
||||
else:
|
||||
file_list.sort(
|
||||
key=lambda x: (
|
||||
0 if x.type == "dir" else 1,
|
||||
StringUtils.natural_sort_key(x.name or ""),
|
||||
)
|
||||
)
|
||||
|
||||
total_count = len(file_list)
|
||||
limited_list = file_list[:20]
|
||||
simplified_items = []
|
||||
for item in limited_list:
|
||||
size_str = StringUtils.str_filesize(item.size) if item.size else None
|
||||
modify_time_str = None
|
||||
if item.modify_time:
|
||||
try:
|
||||
modify_time_str = datetime.fromtimestamp(item.modify_time).strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
except (ValueError, OSError):
|
||||
modify_time_str = str(item.modify_time)
|
||||
|
||||
simplified = {
|
||||
"name": item.name,
|
||||
"type": item.type,
|
||||
"path": item.path,
|
||||
"size": size_str,
|
||||
"modify_time": modify_time_str,
|
||||
}
|
||||
if item.type == "file" and item.extension:
|
||||
simplified["extension"] = item.extension
|
||||
simplified_items.append(simplified)
|
||||
|
||||
result_json = json.dumps(simplified_items, ensure_ascii=False, indent=2)
|
||||
if total_count > 20:
|
||||
return (
|
||||
f"注意:目录中共有 {total_count} 个项目,为节省上下文空间,仅显示前 20 个项目。\n\n"
|
||||
f"{result_json}"
|
||||
)
|
||||
return result_json
|
||||
|
||||
async def run(self, path: str, storage: Optional[str] = "local",
|
||||
sort_by: Optional[str] = "name", **kwargs) -> str:
|
||||
logger.info(f"执行工具: {self.name}, 参数: path={path}, storage={storage}, sort_by={sort_by}")
|
||||
|
||||
try:
|
||||
# 规范化路径
|
||||
if not path:
|
||||
return "错误:路径不能为空"
|
||||
|
||||
# 确保路径格式正确
|
||||
if storage == "local":
|
||||
# 本地路径处理
|
||||
if not path.startswith("/") and not (len(path) > 1 and path[1] == ":"):
|
||||
# 相对路径,尝试转换为绝对路径
|
||||
path = str(Path(path).resolve())
|
||||
else:
|
||||
# 远程存储路径,确保以/开头
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
|
||||
# 创建FileItem
|
||||
fileitem = FileItem(
|
||||
storage=storage or "local",
|
||||
path=path,
|
||||
type="dir"
|
||||
return await self.run_blocking(
|
||||
"storage", self._list_directory_sync, path, storage, sort_by
|
||||
)
|
||||
|
||||
# 查询目录内容
|
||||
storage_chain = StorageChain()
|
||||
file_list = storage_chain.list_files(fileitem, recursion=False)
|
||||
|
||||
if file_list is None:
|
||||
return f"无法访问目录:{path},请检查路径是否正确或存储是否可用"
|
||||
|
||||
if not file_list:
|
||||
return f"目录 {path} 为空"
|
||||
|
||||
# 排序
|
||||
if sort_by == "time":
|
||||
file_list.sort(key=lambda x: x.modify_time or 0, reverse=True)
|
||||
else:
|
||||
# 默认按名称排序(目录优先,然后按名称)
|
||||
file_list.sort(key=lambda x: (
|
||||
0 if x.type == "dir" else 1,
|
||||
StringUtils.natural_sort_key(x.name or "")
|
||||
))
|
||||
|
||||
# 限制返回数量
|
||||
total_count = len(file_list)
|
||||
limited_list = file_list[:20]
|
||||
|
||||
# 转换为字典格式
|
||||
simplified_items = []
|
||||
for item in limited_list:
|
||||
# 格式化文件大小
|
||||
size_str = None
|
||||
if item.size:
|
||||
size_str = StringUtils.str_filesize(item.size)
|
||||
|
||||
# 格式化修改时间
|
||||
modify_time_str = None
|
||||
if item.modify_time:
|
||||
try:
|
||||
modify_time_str = datetime.fromtimestamp(item.modify_time).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except (ValueError, OSError):
|
||||
modify_time_str = str(item.modify_time)
|
||||
|
||||
simplified = {
|
||||
"name": item.name,
|
||||
"type": item.type,
|
||||
"path": item.path,
|
||||
"size": size_str,
|
||||
"modify_time": modify_time_str
|
||||
}
|
||||
# 如果是文件,添加扩展名
|
||||
if item.type == "file" and item.extension:
|
||||
simplified["extension"] = item.extension
|
||||
simplified_items.append(simplified)
|
||||
|
||||
result_json = json.dumps(simplified_items, ensure_ascii=False, indent=2)
|
||||
|
||||
# 如果结果被裁剪,添加提示信息
|
||||
if total_count > 100:
|
||||
return f"注意:目录中共有 {total_count} 个项目,为节省上下文空间,仅显示前 100 个项目。\n\n{result_json}"
|
||||
else:
|
||||
return result_json
|
||||
except Exception as e:
|
||||
logger.error(f"查询目录内容失败: {e}", exc_info=True)
|
||||
return f"查询目录内容时发生错误: {str(e)}"
|
||||
|
||||
|
||||
@@ -12,10 +12,8 @@ from app.log import logger
|
||||
class ListSlashCommandsInput(BaseModel):
|
||||
"""查询所有可用斜杠命令工具的输入参数模型"""
|
||||
|
||||
explanation: str = Field(
|
||||
...,
|
||||
description="Clear explanation of why this tool is being used in the current context",
|
||||
)
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
|
||||
|
||||
class ListSlashCommandsTool(MoviePilotTool):
|
||||
|
||||
@@ -12,10 +12,8 @@ from app.log import logger
|
||||
class ModifyDownloadInput(BaseModel):
|
||||
"""修改下载任务工具的输入参数模型"""
|
||||
|
||||
explanation: str = Field(
|
||||
...,
|
||||
description="Clear explanation of why this tool is being used in the current context",
|
||||
)
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
hash: str = Field(
|
||||
..., description="Task hash (can be obtained from query_download_tasks tool)"
|
||||
)
|
||||
@@ -66,6 +64,38 @@ class ModifyDownloadTool(MoviePilotTool):
|
||||
parts.append(f"下载器: {downloader}")
|
||||
return " | ".join(parts)
|
||||
|
||||
@staticmethod
|
||||
def _modify_download_sync(
|
||||
hash_value: str,
|
||||
action: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
downloader: Optional[str] = None,
|
||||
) -> List[str]:
|
||||
"""同步修改下载任务状态和标签,避免下载器 SDK 阻塞事件循环。"""
|
||||
download_chain = DownloadChain()
|
||||
results = []
|
||||
|
||||
if tags:
|
||||
tag_result = download_chain.set_torrents_tag(
|
||||
hashs=[hash_value], tags=tags, downloader=downloader
|
||||
)
|
||||
if tag_result:
|
||||
results.append(f"成功设置标签:{', '.join(tags)}")
|
||||
else:
|
||||
results.append("设置标签失败,请检查任务是否存在或下载器是否可用")
|
||||
|
||||
if action:
|
||||
action_result = download_chain.set_downloading(
|
||||
hash_str=hash_value, oper=action, name=downloader
|
||||
)
|
||||
action_desc = "开始" if action == "start" else "暂停"
|
||||
if action_result:
|
||||
results.append(f"成功{action_desc}下载任务")
|
||||
else:
|
||||
results.append(f"{action_desc}下载任务失败,请检查任务是否存在或下载器是否可用")
|
||||
|
||||
return results
|
||||
|
||||
async def run(
|
||||
self,
|
||||
hash: str,
|
||||
@@ -91,31 +121,14 @@ class ModifyDownloadTool(MoviePilotTool):
|
||||
if action and action not in ("start", "stop"):
|
||||
return f"参数错误:action 只支持 'start'(开始下载)或 'stop'(暂停下载),收到: '{action}'。"
|
||||
|
||||
download_chain = DownloadChain()
|
||||
results = []
|
||||
|
||||
# 设置标签
|
||||
if tags:
|
||||
tag_result = download_chain.set_torrents_tag(
|
||||
hashs=[hash], tags=tags, downloader=downloader
|
||||
)
|
||||
if tag_result:
|
||||
results.append(f"成功设置标签:{', '.join(tags)}")
|
||||
else:
|
||||
results.append(f"设置标签失败,请检查任务是否存在或下载器是否可用")
|
||||
|
||||
# 执行开始/暂停操作
|
||||
if action:
|
||||
action_result = download_chain.set_downloading(
|
||||
hash_str=hash, oper=action, name=downloader
|
||||
)
|
||||
action_desc = "开始" if action == "start" else "暂停"
|
||||
if action_result:
|
||||
results.append(f"成功{action_desc}下载任务")
|
||||
else:
|
||||
results.append(
|
||||
f"{action_desc}下载任务失败,请检查任务是否存在或下载器是否可用"
|
||||
)
|
||||
results = await self.run_blocking(
|
||||
"downloader",
|
||||
self._modify_download_sync,
|
||||
hash,
|
||||
action,
|
||||
tags,
|
||||
downloader,
|
||||
)
|
||||
|
||||
return f"下载任务 {hash}:" + ";".join(results)
|
||||
|
||||
|
||||
83
app/agent/tools/impl/query_builtin_filter_rules.py
Normal file
83
app/agent/tools/impl/query_builtin_filter_rules.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""查询内置过滤规则工具。"""
|
||||
|
||||
import json
|
||||
from typing import Optional, Type, List
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.impl._filter_rule_utils import (
|
||||
get_builtin_rules,
|
||||
serialize_builtin_rule,
|
||||
RULE_STRING_SYNTAX,
|
||||
)
|
||||
from app.log import logger
|
||||
|
||||
|
||||
class QueryBuiltinFilterRulesInput(BaseModel):
|
||||
"""查询内置过滤规则工具的输入参数模型"""
|
||||
|
||||
explanation: 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.",
|
||||
)
|
||||
|
||||
|
||||
class QueryBuiltinFilterRulesTool(MoviePilotTool):
|
||||
name: str = "query_builtin_filter_rules"
|
||||
description: str = (
|
||||
"Query built-in filter rules defined by the backend filter module. "
|
||||
"These rule IDs can be used directly inside rule_string expressions for filter rule groups. "
|
||||
"Use this tool before add_rule_group or update_rule_group to learn valid built-in rule IDs."
|
||||
)
|
||||
args_schema: Type[BaseModel] = QueryBuiltinFilterRulesInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
rule_ids = kwargs.get("rule_ids") or []
|
||||
if rule_ids:
|
||||
return f"查询内置过滤规则: {', '.join(rule_ids)}"
|
||||
return "查询所有内置过滤规则"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
rule_ids: Optional[List[str]] = None,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
logger.info(f"执行工具: {self.name}")
|
||||
|
||||
try:
|
||||
builtin_rules = get_builtin_rules()
|
||||
if rule_ids:
|
||||
target_ids = set(rule_ids)
|
||||
builtin_rules = {
|
||||
rule_id: payload
|
||||
for rule_id, payload in builtin_rules.items()
|
||||
if rule_id in target_ids
|
||||
}
|
||||
|
||||
serialized = [
|
||||
serialize_builtin_rule(rule_id, payload)
|
||||
for rule_id, payload in builtin_rules.items()
|
||||
]
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"count": len(serialized),
|
||||
"rule_string_syntax": RULE_STRING_SYNTAX,
|
||||
"rules": serialized,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"查询内置过滤规则失败: {exc}", exc_info=True)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"查询内置过滤规则失败: {exc}",
|
||||
"rules": [],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
93
app/agent/tools/impl/query_custom_filter_rules.py
Normal file
93
app/agent/tools/impl/query_custom_filter_rules.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""查询自定义过滤规则工具。"""
|
||||
|
||||
import json
|
||||
from typing import Optional, Type, List
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.impl._filter_rule_utils import (
|
||||
collect_custom_rule_group_refs,
|
||||
get_custom_rules,
|
||||
get_rule_groups,
|
||||
serialize_custom_rule,
|
||||
)
|
||||
from app.log import logger
|
||||
|
||||
|
||||
class QueryCustomFilterRulesInput(BaseModel):
|
||||
"""查询自定义过滤规则工具的输入参数模型"""
|
||||
|
||||
explanation: 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.",
|
||||
)
|
||||
include_group_refs: bool = Field(
|
||||
True,
|
||||
description="Whether to include which rule groups reference each custom rule.",
|
||||
)
|
||||
|
||||
|
||||
class QueryCustomFilterRulesTool(MoviePilotTool):
|
||||
name: str = "query_custom_filter_rules"
|
||||
description: str = (
|
||||
"Query custom filter rules stored in CustomFilterRules. "
|
||||
"Custom rules can be referenced from rule_string expressions in filter rule groups. "
|
||||
"Use this tool before add_rule_group or update_rule_group to learn valid custom rule IDs."
|
||||
)
|
||||
args_schema: Type[BaseModel] = QueryCustomFilterRulesInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
rule_ids = kwargs.get("rule_ids") or []
|
||||
if rule_ids:
|
||||
return f"查询自定义过滤规则: {', '.join(rule_ids)}"
|
||||
return "查询所有自定义过滤规则"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
rule_ids: Optional[List[str]] = None,
|
||||
include_group_refs: bool = True,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
logger.info(f"执行工具: {self.name}")
|
||||
|
||||
try:
|
||||
custom_rules = get_custom_rules()
|
||||
if rule_ids:
|
||||
target_ids = set(rule_ids)
|
||||
custom_rules = [
|
||||
rule for rule in custom_rules if rule.id in target_ids
|
||||
]
|
||||
|
||||
refs = {}
|
||||
if include_group_refs:
|
||||
refs = collect_custom_rule_group_refs(
|
||||
get_rule_groups(),
|
||||
[rule.id for rule in custom_rules if rule.id],
|
||||
)
|
||||
|
||||
serialized = [
|
||||
serialize_custom_rule(rule, refs.get(rule.id))
|
||||
for rule in custom_rules
|
||||
]
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"count": len(serialized),
|
||||
"rules": serialized,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"查询自定义过滤规则失败: {exc}", exc_info=True)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"查询自定义过滤规则失败: {exc}",
|
||||
"rules": [],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
@@ -14,10 +14,8 @@ from app.schemas.types import SystemConfigKey
|
||||
class QueryCustomIdentifiersInput(BaseModel):
|
||||
"""查询自定义识别词工具的输入参数模型"""
|
||||
|
||||
explanation: str = Field(
|
||||
...,
|
||||
description="Clear explanation of why this tool is being used in the current context",
|
||||
)
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
|
||||
|
||||
class QueryCustomIdentifiersTool(MoviePilotTool):
|
||||
@@ -27,17 +25,22 @@ class QueryCustomIdentifiersTool(MoviePilotTool):
|
||||
"Returns the list of identifier rules used for preprocessing torrent/file names before media recognition. "
|
||||
"Use this tool to check existing rules before adding new ones to avoid duplicates."
|
||||
)
|
||||
require_admin: bool = True
|
||||
args_schema: Type[BaseModel] = QueryCustomIdentifiersInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""生成友好的提示消息"""
|
||||
return "查询自定义识别词"
|
||||
|
||||
@staticmethod
|
||||
def _load_custom_identifiers():
|
||||
"""从内存配置缓存中读取自定义识别词。"""
|
||||
return SystemConfigOper().get(SystemConfigKey.CustomIdentifiers)
|
||||
|
||||
async def run(self, **kwargs) -> str:
|
||||
logger.info(f"执行工具: {self.name}")
|
||||
try:
|
||||
system_config_oper = SystemConfigOper()
|
||||
identifiers = system_config_oper.get(SystemConfigKey.CustomIdentifiers)
|
||||
identifiers = self._load_custom_identifiers()
|
||||
if identifiers:
|
||||
return json.dumps(
|
||||
{
|
||||
|
||||
@@ -12,7 +12,7 @@ from app.log import logger
|
||||
|
||||
class QueryDirectorySettingsInput(BaseModel):
|
||||
"""查询系统目录设置工具的输入参数模型"""
|
||||
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
directory_type: Optional[str] = Field("all",
|
||||
description="Filter directories by type: 'download' for download directories, 'library' for media library directories, 'all' for all directories")
|
||||
storage_type: Optional[str] = Field("all",
|
||||
@@ -24,6 +24,7 @@ class QueryDirectorySettingsInput(BaseModel):
|
||||
class QueryDirectorySettingsTool(MoviePilotTool):
|
||||
name: str = "query_directory_settings"
|
||||
description: str = "Query system directory configuration settings (NOT file listings). Returns configured directory paths, storage types, transfer modes, and other directory-related settings. Use 'list_directory' to list actual files and folders in a directory."
|
||||
require_admin: bool = True
|
||||
args_schema: Type[BaseModel] = QueryDirectorySettingsInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
@@ -47,88 +48,93 @@ class QueryDirectorySettingsTool(MoviePilotTool):
|
||||
|
||||
return " | ".join(parts) if len(parts) > 1 else parts[0]
|
||||
|
||||
@staticmethod
|
||||
def _query_directory_settings(
|
||||
directory_type: Optional[str] = "all",
|
||||
storage_type: Optional[str] = "all",
|
||||
name: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
目录配置完全来自内存配置缓存,这里只做本地过滤和序列化。
|
||||
"""
|
||||
directory_helper = DirectoryHelper()
|
||||
|
||||
if directory_type == "download":
|
||||
dirs = directory_helper.get_download_dirs()
|
||||
elif directory_type == "library":
|
||||
dirs = directory_helper.get_library_dirs()
|
||||
else:
|
||||
dirs = directory_helper.get_dirs()
|
||||
|
||||
filtered_dirs = []
|
||||
for d in dirs:
|
||||
if storage_type == "local":
|
||||
if directory_type == "download" and d.storage != "local":
|
||||
continue
|
||||
if directory_type == "library" and d.library_storage != "local":
|
||||
continue
|
||||
if directory_type == "all":
|
||||
if d.download_path and d.storage != "local":
|
||||
continue
|
||||
if d.library_path and d.library_storage != "local":
|
||||
continue
|
||||
elif storage_type == "remote":
|
||||
if directory_type == "download" and d.storage == "local":
|
||||
continue
|
||||
if directory_type == "library" and d.library_storage == "local":
|
||||
continue
|
||||
if directory_type == "all":
|
||||
if d.download_path and d.storage == "local":
|
||||
continue
|
||||
if d.library_path and d.library_storage == "local":
|
||||
continue
|
||||
|
||||
if name and d.name and name.lower() not in d.name.lower():
|
||||
continue
|
||||
filtered_dirs.append(d)
|
||||
|
||||
if not filtered_dirs:
|
||||
return "未找到相关目录配置"
|
||||
|
||||
simplified_dirs = []
|
||||
for d in filtered_dirs:
|
||||
simplified_dirs.append(
|
||||
{
|
||||
"name": d.name,
|
||||
"priority": d.priority,
|
||||
"storage": d.storage,
|
||||
"download_path": d.download_path,
|
||||
"library_path": d.library_path,
|
||||
"library_storage": d.library_storage,
|
||||
"media_type": d.media_type,
|
||||
"media_category": d.media_category,
|
||||
"monitor_type": d.monitor_type,
|
||||
"monitor_mode": d.monitor_mode,
|
||||
"transfer_type": d.transfer_type,
|
||||
"overwrite_mode": d.overwrite_mode,
|
||||
"renaming": d.renaming,
|
||||
"scraping": d.scraping,
|
||||
"notify": d.notify,
|
||||
"download_type_folder": d.download_type_folder,
|
||||
"download_category_folder": d.download_category_folder,
|
||||
"library_type_folder": d.library_type_folder,
|
||||
"library_category_folder": d.library_category_folder,
|
||||
}
|
||||
)
|
||||
|
||||
return json.dumps(simplified_dirs, ensure_ascii=False, indent=2)
|
||||
|
||||
async def run(self, directory_type: Optional[str] = "all",
|
||||
storage_type: Optional[str] = "all",
|
||||
name: Optional[str] = None, **kwargs) -> str:
|
||||
logger.info(f"执行工具: {self.name}, 参数: directory_type={directory_type}, storage_type={storage_type}, name={name}")
|
||||
|
||||
try:
|
||||
directory_helper = DirectoryHelper()
|
||||
|
||||
# 根据目录类型获取目录列表
|
||||
if directory_type == "download":
|
||||
dirs = directory_helper.get_download_dirs()
|
||||
elif directory_type == "library":
|
||||
dirs = directory_helper.get_library_dirs()
|
||||
else:
|
||||
dirs = directory_helper.get_dirs()
|
||||
|
||||
# 按存储类型过滤
|
||||
filtered_dirs = []
|
||||
for d in dirs:
|
||||
# 按存储类型过滤
|
||||
if storage_type == "local":
|
||||
# 对于下载目录,检查 storage;对于媒体库目录,检查 library_storage
|
||||
if directory_type == "download" and d.storage != "local":
|
||||
continue
|
||||
elif directory_type == "library" and d.library_storage != "local":
|
||||
continue
|
||||
elif directory_type == "all":
|
||||
# 检查是否有本地存储配置
|
||||
if d.download_path and d.storage != "local":
|
||||
continue
|
||||
if d.library_path and d.library_storage != "local":
|
||||
continue
|
||||
elif storage_type == "remote":
|
||||
# 对于下载目录,检查 storage;对于媒体库目录,检查 library_storage
|
||||
if directory_type == "download" and d.storage == "local":
|
||||
continue
|
||||
elif directory_type == "library" and d.library_storage == "local":
|
||||
continue
|
||||
elif directory_type == "all":
|
||||
# 检查是否有远程存储配置
|
||||
if d.download_path and d.storage == "local":
|
||||
continue
|
||||
if d.library_path and d.library_storage == "local":
|
||||
continue
|
||||
|
||||
# 按名称过滤(部分匹配)
|
||||
if name and d.name and name.lower() not in d.name.lower():
|
||||
continue
|
||||
|
||||
filtered_dirs.append(d)
|
||||
|
||||
if filtered_dirs:
|
||||
# 转换为字典格式,只保留关键信息
|
||||
simplified_dirs = []
|
||||
for d in filtered_dirs:
|
||||
simplified = {
|
||||
"name": d.name,
|
||||
"priority": d.priority,
|
||||
"storage": d.storage,
|
||||
"download_path": d.download_path,
|
||||
"library_path": d.library_path,
|
||||
"library_storage": d.library_storage,
|
||||
"media_type": d.media_type,
|
||||
"media_category": d.media_category,
|
||||
"monitor_type": d.monitor_type,
|
||||
"monitor_mode": d.monitor_mode,
|
||||
"transfer_type": d.transfer_type,
|
||||
"overwrite_mode": d.overwrite_mode,
|
||||
"renaming": d.renaming,
|
||||
"scraping": d.scraping,
|
||||
"notify": d.notify,
|
||||
"download_type_folder": d.download_type_folder,
|
||||
"download_category_folder": d.download_category_folder,
|
||||
"library_type_folder": d.library_type_folder,
|
||||
"library_category_folder": d.library_category_folder
|
||||
}
|
||||
simplified_dirs.append(simplified)
|
||||
|
||||
result_json = json.dumps(simplified_dirs, ensure_ascii=False, indent=2)
|
||||
return result_json
|
||||
return "未找到相关目录配置"
|
||||
return self._query_directory_settings(
|
||||
directory_type=directory_type,
|
||||
storage_type=storage_type,
|
||||
name=name,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"查询系统目录设置失败: {e}", exc_info=True)
|
||||
return f"查询系统目录设置时发生错误: {str(e)}"
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""查询下载工具"""
|
||||
|
||||
import json
|
||||
from typing import Optional, Type, List, Union
|
||||
from typing import Any, Dict, List, Optional, Type, Union
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -15,7 +15,7 @@ from app.schemas.types import TorrentStatus, media_type_to_agent
|
||||
|
||||
class QueryDownloadTasksInput(BaseModel):
|
||||
"""查询下载工具的输入参数模型"""
|
||||
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
downloader: Optional[str] = Field(None,
|
||||
description="Name of specific downloader to query (optional, if not provided queries all configured downloaders)")
|
||||
status: Optional[str] = Field("all",
|
||||
@@ -64,6 +64,126 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _apply_download_history(
|
||||
torrent: Union[TransferTorrent, DownloadingTorrent], history: Any
|
||||
) -> None:
|
||||
"""将下载历史中的补充信息回填到下载任务结果中。"""
|
||||
if not history:
|
||||
return
|
||||
if hasattr(torrent, "media"):
|
||||
torrent.media = {
|
||||
"tmdbid": history.tmdbid,
|
||||
"type": history.type,
|
||||
"title": history.title,
|
||||
"season": history.seasons,
|
||||
"episode": history.episodes,
|
||||
"image": history.image,
|
||||
}
|
||||
if hasattr(torrent, "username"):
|
||||
torrent.username = history.username
|
||||
torrent.userid = history.userid
|
||||
|
||||
@classmethod
|
||||
def _load_history_map(
|
||||
cls, torrents: List[Union[TransferTorrent, DownloadingTorrent]]
|
||||
) -> Dict[str, Any]:
|
||||
"""批量加载下载历史,避免逐条查询形成 N+1。"""
|
||||
hashes = [torrent.hash for torrent in torrents if getattr(torrent, "hash", None)]
|
||||
if not hashes:
|
||||
return {}
|
||||
return DownloadHistoryOper().get_by_hashes(hashes)
|
||||
|
||||
@classmethod
|
||||
def _query_downloads_sync(
|
||||
cls,
|
||||
downloader: Optional[str] = None,
|
||||
status: Optional[str] = "all",
|
||||
hash_value: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
同步查询下载器和下载历史,整个链路放在线程池中执行。
|
||||
"""
|
||||
download_chain = DownloadChain()
|
||||
|
||||
if hash_value:
|
||||
torrents = (
|
||||
download_chain.list_torrents(downloader=downloader, hashs=[hash_value])
|
||||
or []
|
||||
)
|
||||
if not torrents:
|
||||
return {
|
||||
"message": f"未找到hash为 {hash_value} 的下载任务(该任务可能已完成、已删除或不存在)"
|
||||
}
|
||||
|
||||
history_map = cls._load_history_map(torrents)
|
||||
for torrent in torrents:
|
||||
cls._apply_download_history(torrent, history_map.get(torrent.hash))
|
||||
filtered_downloads = list(torrents)
|
||||
elif title:
|
||||
all_torrents = cls._get_all_torrents(download_chain, downloader)
|
||||
history_map = cls._load_history_map(all_torrents)
|
||||
filtered_downloads = []
|
||||
title_lower = title.lower()
|
||||
|
||||
for torrent in all_torrents:
|
||||
history = history_map.get(torrent.hash)
|
||||
matched = title_lower in (torrent.title or "").lower() or title_lower in (
|
||||
getattr(torrent, "name", None) or ""
|
||||
).lower()
|
||||
if not matched and history and history.title:
|
||||
matched = title_lower in history.title.lower()
|
||||
|
||||
if not matched:
|
||||
continue
|
||||
|
||||
cls._apply_download_history(torrent, history)
|
||||
filtered_downloads.append(torrent)
|
||||
|
||||
if not filtered_downloads:
|
||||
return {"message": f"未找到标题包含 '{title}' 的下载任务"}
|
||||
else:
|
||||
if status == "downloading":
|
||||
downloads = download_chain.downloading(name=downloader) or []
|
||||
filtered_downloads = [
|
||||
dl
|
||||
for dl in downloads
|
||||
if not downloader or dl.downloader == downloader
|
||||
]
|
||||
else:
|
||||
all_torrents = cls._get_all_torrents(download_chain, downloader)
|
||||
filtered_downloads = []
|
||||
for torrent in all_torrents:
|
||||
if downloader and torrent.downloader != downloader:
|
||||
continue
|
||||
if status == "completed" and torrent.state not in [
|
||||
"seeding",
|
||||
"completed",
|
||||
]:
|
||||
continue
|
||||
if status == "paused" and torrent.state != "paused":
|
||||
continue
|
||||
filtered_downloads.append(torrent)
|
||||
|
||||
history_map = cls._load_history_map(filtered_downloads)
|
||||
for torrent in filtered_downloads:
|
||||
cls._apply_download_history(torrent, history_map.get(torrent.hash))
|
||||
|
||||
if tag and filtered_downloads:
|
||||
tag_lower = tag.lower()
|
||||
filtered_downloads = [
|
||||
d for d in filtered_downloads if d.tags and tag_lower in d.tags.lower()
|
||||
]
|
||||
if not filtered_downloads:
|
||||
return {"message": f"未找到标签包含 '{tag}' 的下载任务"}
|
||||
|
||||
if not filtered_downloads:
|
||||
return {"message": "未找到相关下载任务"}
|
||||
|
||||
return {"downloads": filtered_downloads}
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""根据查询参数生成友好的提示消息"""
|
||||
downloader = kwargs.get("downloader")
|
||||
@@ -98,124 +218,19 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
tag: Optional[str] = None, **kwargs) -> str:
|
||||
logger.info(f"执行工具: {self.name}, 参数: downloader={downloader}, status={status}, hash={hash}, title={title}, tag={tag}")
|
||||
try:
|
||||
download_chain = DownloadChain()
|
||||
|
||||
# 如果提供了hash,直接查询该hash的任务(不限制状态)
|
||||
if hash:
|
||||
torrents = download_chain.list_torrents(downloader=downloader, hashs=[hash]) or []
|
||||
if not torrents:
|
||||
return f"未找到hash为 {hash} 的下载任务(该任务可能已完成、已删除或不存在)"
|
||||
# 转换为DownloadingTorrent格式
|
||||
downloads = []
|
||||
for torrent in torrents:
|
||||
# 获取下载历史信息
|
||||
history = DownloadHistoryOper().get_by_hash(torrent.hash)
|
||||
if history:
|
||||
if hasattr(torrent, "media"):
|
||||
torrent.media = {
|
||||
"tmdbid": history.tmdbid,
|
||||
"type": history.type,
|
||||
"title": history.title,
|
||||
"season": history.seasons,
|
||||
"episode": history.episodes,
|
||||
"image": history.image,
|
||||
}
|
||||
if hasattr(torrent, "username"):
|
||||
torrent.username = history.username
|
||||
torrent.userid = history.userid
|
||||
downloads.append(torrent)
|
||||
filtered_downloads = downloads
|
||||
elif title:
|
||||
# 如果提供了title,查询所有任务并搜索匹配的标题
|
||||
# 查询所有状态的任务
|
||||
all_torrents = self._get_all_torrents(download_chain, downloader)
|
||||
filtered_downloads = []
|
||||
title_lower = title.lower()
|
||||
for torrent in all_torrents:
|
||||
# 获取下载历史信息
|
||||
history = DownloadHistoryOper().get_by_hash(torrent.hash)
|
||||
|
||||
# 检查标题或名称是否匹配(包括下载历史中的标题)
|
||||
matched = False
|
||||
# 检查torrent的title和name字段
|
||||
if (title_lower in (torrent.title or "").lower()) or \
|
||||
(title_lower in (getattr(torrent, "name", None) or "").lower()):
|
||||
matched = True
|
||||
# 检查下载历史中的标题
|
||||
if history and history.title:
|
||||
if title_lower in history.title.lower():
|
||||
matched = True
|
||||
|
||||
if matched:
|
||||
if history:
|
||||
if hasattr(torrent, "media"):
|
||||
torrent.media = {
|
||||
"tmdbid": history.tmdbid,
|
||||
"type": history.type,
|
||||
"title": history.title,
|
||||
"season": history.seasons,
|
||||
"episode": history.episodes,
|
||||
"image": history.image,
|
||||
}
|
||||
if hasattr(torrent, "username"):
|
||||
torrent.username = history.username
|
||||
torrent.userid = history.userid
|
||||
filtered_downloads.append(torrent)
|
||||
if not filtered_downloads:
|
||||
return f"未找到标题包含 '{title}' 的下载任务"
|
||||
else:
|
||||
# 根据status决定查询方式
|
||||
if status == "downloading":
|
||||
# 如果status为下载中,使用downloading方法
|
||||
downloads = download_chain.downloading(name=downloader) or []
|
||||
filtered_downloads = []
|
||||
for dl in downloads:
|
||||
if downloader and dl.downloader != downloader:
|
||||
continue
|
||||
filtered_downloads.append(dl)
|
||||
else:
|
||||
# 其他状态(completed、paused、all),使用list_torrents查询所有任务
|
||||
# 查询所有状态的任务
|
||||
all_torrents = self._get_all_torrents(download_chain, downloader)
|
||||
filtered_downloads = []
|
||||
for torrent in all_torrents:
|
||||
if downloader and torrent.downloader != downloader:
|
||||
continue
|
||||
# 根据status过滤
|
||||
if status == "completed":
|
||||
# 已完成的任务(state为seeding或completed)
|
||||
if torrent.state not in ["seeding", "completed"]:
|
||||
continue
|
||||
elif status == "paused":
|
||||
# 已暂停的任务
|
||||
if torrent.state != "paused":
|
||||
continue
|
||||
# status == "all" 时不过滤
|
||||
# 获取下载历史信息
|
||||
history = DownloadHistoryOper().get_by_hash(torrent.hash)
|
||||
if history:
|
||||
if hasattr(torrent, "media"):
|
||||
torrent.media = {
|
||||
"tmdbid": history.tmdbid,
|
||||
"type": history.type,
|
||||
"title": history.title,
|
||||
"season": history.seasons,
|
||||
"episode": history.episodes,
|
||||
"image": history.image,
|
||||
}
|
||||
if hasattr(torrent, "username"):
|
||||
torrent.username = history.username
|
||||
torrent.userid = history.userid
|
||||
filtered_downloads.append(torrent)
|
||||
# 按tag过滤
|
||||
if tag and filtered_downloads:
|
||||
tag_lower = tag.lower()
|
||||
filtered_downloads = [
|
||||
d for d in filtered_downloads
|
||||
if d.tags and tag_lower in d.tags.lower()
|
||||
]
|
||||
if not filtered_downloads:
|
||||
return f"未找到标签包含 '{tag}' 的下载任务"
|
||||
payload = await self.run_blocking(
|
||||
"downloader",
|
||||
self._query_downloads_sync,
|
||||
downloader,
|
||||
status,
|
||||
hash,
|
||||
title,
|
||||
tag,
|
||||
)
|
||||
if payload.get("message"):
|
||||
return payload["message"]
|
||||
|
||||
filtered_downloads = payload.get("downloads") or []
|
||||
if filtered_downloads:
|
||||
# 限制最多20条结果
|
||||
total_count = len(filtered_downloads)
|
||||
|
||||
@@ -13,23 +13,28 @@ from app.schemas.types import SystemConfigKey
|
||||
|
||||
class QueryDownloadersInput(BaseModel):
|
||||
"""查询下载器工具的输入参数模型"""
|
||||
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
|
||||
|
||||
class QueryDownloadersTool(MoviePilotTool):
|
||||
name: str = "query_downloaders"
|
||||
description: str = "Query downloader configuration and list all available downloaders. Shows downloader status, connection details, and configuration settings."
|
||||
require_admin: bool = True
|
||||
args_schema: Type[BaseModel] = QueryDownloadersInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""生成友好的提示消息"""
|
||||
return "查询下载器配置"
|
||||
|
||||
@staticmethod
|
||||
def _load_downloaders_config():
|
||||
"""从内存配置缓存中读取下载器配置。"""
|
||||
return SystemConfigOper().get(SystemConfigKey.Downloaders)
|
||||
|
||||
async def run(self, **kwargs) -> str:
|
||||
logger.info(f"执行工具: {self.name}")
|
||||
try:
|
||||
system_config_oper = SystemConfigOper()
|
||||
downloaders_config = system_config_oper.get(SystemConfigKey.Downloaders)
|
||||
downloaders_config = self._load_downloaders_config()
|
||||
if downloaders_config:
|
||||
return json.dumps(downloaders_config, ensure_ascii=False, indent=2)
|
||||
return "未配置下载器。"
|
||||
|
||||
@@ -12,7 +12,7 @@ from app.log import logger
|
||||
|
||||
class QueryEpisodeScheduleInput(BaseModel):
|
||||
"""查询剧集上映时间工具的输入参数模型"""
|
||||
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
tmdb_id: int = Field(..., description="TMDB ID of the TV series (can be obtained from search_media tool)")
|
||||
season: int = Field(..., description="Season number to query")
|
||||
episode_group: Optional[str] = Field(None, description="Episode group ID (optional)")
|
||||
|
||||
@@ -6,60 +6,105 @@ from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.core.plugin import PluginManager
|
||||
from app.agent.tools.impl._plugin_tool_utils import (
|
||||
DEFAULT_PLUGIN_CANDIDATE_LIMIT,
|
||||
MAX_PLUGIN_CANDIDATE_LIMIT,
|
||||
list_installed_plugins,
|
||||
search_plugin_candidates,
|
||||
summarize_candidates,
|
||||
summarize_plugin,
|
||||
)
|
||||
from app.log import logger
|
||||
|
||||
|
||||
class QueryInstalledPluginsInput(BaseModel):
|
||||
"""查询已安装插件工具的输入参数模型"""
|
||||
|
||||
explanation: str = Field(
|
||||
...,
|
||||
description="Clear explanation of why this tool is being used in the current context",
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
query: Optional[str] = Field(
|
||||
None,
|
||||
description="Optional keyword to filter installed plugins by plugin ID, name, description, or author.",
|
||||
)
|
||||
max_results: Optional[int] = Field(
|
||||
DEFAULT_PLUGIN_CANDIDATE_LIMIT,
|
||||
description="Maximum number of plugins to return. Defaults to 50, capped at 200.",
|
||||
)
|
||||
|
||||
|
||||
class QueryInstalledPluginsTool(MoviePilotTool):
|
||||
name: str = "query_installed_plugins"
|
||||
description: str = (
|
||||
"Query all installed plugins in MoviePilot. Returns a list of installed plugins with their ID, name, "
|
||||
"description, version, author, running state, and other information. "
|
||||
"Use this tool to discover what plugins are available before querying plugin capabilities or running plugin commands."
|
||||
"Query installed plugins in MoviePilot. Returns all installed plugins or filters them by keywords. "
|
||||
"Use this tool to find the exact plugin_id before uninstall_plugin or other plugin management tools are used."
|
||||
)
|
||||
require_admin: bool = True
|
||||
args_schema: Type[BaseModel] = QueryInstalledPluginsInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""生成友好的提示消息"""
|
||||
query = kwargs.get("query")
|
||||
if query:
|
||||
return f"查询已安装插件: {query}"
|
||||
return "查询已安装插件"
|
||||
|
||||
async def run(self, **kwargs) -> str:
|
||||
logger.info(f"执行工具: {self.name}")
|
||||
@staticmethod
|
||||
def _clamp_results(max_results: Optional[int]) -> int:
|
||||
if max_results is None:
|
||||
return DEFAULT_PLUGIN_CANDIDATE_LIMIT
|
||||
try:
|
||||
plugin_manager = PluginManager()
|
||||
local_plugins = plugin_manager.get_local_plugins()
|
||||
# 仅返回已安装的插件
|
||||
installed_plugins = [plugin for plugin in local_plugins if plugin.installed]
|
||||
return max(1, min(int(max_results), MAX_PLUGIN_CANDIDATE_LIMIT))
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_PLUGIN_CANDIDATE_LIMIT
|
||||
|
||||
async def run(
|
||||
self,
|
||||
query: Optional[str] = None,
|
||||
max_results: Optional[int] = DEFAULT_PLUGIN_CANDIDATE_LIMIT,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
logger.info(f"执行工具: {self.name}, 参数: query={query}")
|
||||
try:
|
||||
installed_plugins = list_installed_plugins()
|
||||
if not installed_plugins:
|
||||
return "当前没有已安装的插件"
|
||||
|
||||
plugins_list = []
|
||||
for plugin in installed_plugins:
|
||||
plugins_list.append(
|
||||
{
|
||||
"id": plugin.id,
|
||||
"plugin_name": plugin.plugin_name,
|
||||
"plugin_desc": plugin.plugin_desc,
|
||||
"plugin_version": plugin.plugin_version,
|
||||
"plugin_author": plugin.plugin_author,
|
||||
"state": plugin.state,
|
||||
"has_page": plugin.has_page,
|
||||
}
|
||||
return json.dumps(
|
||||
{"success": False, "message": "当前没有已安装的插件"},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
result_json = json.dumps(plugins_list, ensure_ascii=False, indent=2)
|
||||
return result_json
|
||||
limit = self._clamp_results(max_results)
|
||||
if query:
|
||||
matches = search_plugin_candidates(query, installed_plugins)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"query": query,
|
||||
"total_installed": len(installed_plugins),
|
||||
"match_count": len(matches),
|
||||
"truncated": len(matches) > limit,
|
||||
"plugins": summarize_candidates(matches, limit=limit),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
plugin_summaries = [
|
||||
summarize_plugin(plugin) for plugin in installed_plugins[:limit]
|
||||
]
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"total_installed": len(installed_plugins),
|
||||
"returned_count": len(plugin_summaries),
|
||||
"truncated": len(installed_plugins) > limit,
|
||||
"plugins": plugin_summaries,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"查询已安装插件失败: {e}", exc_info=True)
|
||||
return f"查询已安装插件时发生错误: {str(e)}"
|
||||
return json.dumps(
|
||||
{"success": False, "message": f"查询已安装插件时发生错误: {str(e)}"},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""查询媒体库工具"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections import OrderedDict
|
||||
from typing import Optional, Type, Any
|
||||
@@ -75,7 +76,7 @@ def _build_tv_server_result(existing_seasons: OrderedDict, total_seasons: Ordere
|
||||
|
||||
class QueryLibraryExistsInput(BaseModel):
|
||||
"""查询媒体库工具的输入参数模型"""
|
||||
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
tmdb_id: Optional[int] = Field(None, description="TMDB ID (can be obtained from search_media tool). Either tmdb_id or douban_id must be provided.")
|
||||
douban_id: Optional[str] = Field(None, description="Douban ID (can be obtained from search_media tool). Either tmdb_id or douban_id must be provided.")
|
||||
media_type: Optional[str] = Field(None, description="Allowed values: movie, tv")
|
||||
@@ -102,6 +103,16 @@ class QueryLibraryExistsTool(MoviePilotTool):
|
||||
message += f" [{media_type}]"
|
||||
return message
|
||||
|
||||
@staticmethod
|
||||
def _get_media_server_names() -> list[str]:
|
||||
"""同步读取已加载媒体服务器名称。"""
|
||||
return sorted(MediaServerHelper().get_services().keys())
|
||||
|
||||
@staticmethod
|
||||
def _query_media_exists(mediainfo, server: Optional[str] = None):
|
||||
"""同步查询单个媒体服务器的存在性信息。"""
|
||||
return MediaServerChain().media_exists(mediainfo=mediainfo, server=server)
|
||||
|
||||
async def run(self, tmdb_id: Optional[int] = None, douban_id: Optional[str] = None,
|
||||
media_type: Optional[str] = None, **kwargs) -> str:
|
||||
logger.info(f"执行工具: {self.name}, 参数: tmdb_id={tmdb_id}, douban_id={douban_id}, media_type={media_type}")
|
||||
@@ -116,7 +127,7 @@ class QueryLibraryExistsTool(MoviePilotTool):
|
||||
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'"
|
||||
|
||||
media_chain = MediaServerChain()
|
||||
mediainfo = media_chain.recognize_media(
|
||||
mediainfo = await media_chain.async_recognize_media(
|
||||
tmdbid=tmdb_id,
|
||||
doubanid=douban_id,
|
||||
mtype=media_type_enum,
|
||||
@@ -127,12 +138,22 @@ class QueryLibraryExistsTool(MoviePilotTool):
|
||||
|
||||
# 2. 遍历所有媒体服务器,分别查询存在性信息
|
||||
server_results = OrderedDict()
|
||||
media_server_helper = MediaServerHelper()
|
||||
total_seasons = _filter_regular_seasons(mediainfo.seasons)
|
||||
global_existsinfo = media_chain.media_exists(mediainfo=mediainfo)
|
||||
service_names = self._get_media_server_names()
|
||||
|
||||
for service_name in sorted(media_server_helper.get_services().keys()):
|
||||
existsinfo = media_chain.media_exists(mediainfo=mediainfo, server=service_name)
|
||||
server_checks = await asyncio.gather(
|
||||
*[
|
||||
self.run_blocking(
|
||||
"mediaserver",
|
||||
self._query_media_exists,
|
||||
mediainfo,
|
||||
service_name,
|
||||
)
|
||||
for service_name in service_names
|
||||
]
|
||||
)
|
||||
|
||||
for service_name, existsinfo in zip(service_names, server_checks):
|
||||
if not existsinfo:
|
||||
continue
|
||||
|
||||
@@ -147,21 +168,23 @@ class QueryLibraryExistsTool(MoviePilotTool):
|
||||
"exists": True
|
||||
}
|
||||
|
||||
if global_existsinfo:
|
||||
fallback_server_name = global_existsinfo.server or "local"
|
||||
if fallback_server_name not in server_results:
|
||||
if global_existsinfo.type == MediaType.TV:
|
||||
server_results[fallback_server_name] = _build_tv_server_result(
|
||||
existing_seasons=_filter_regular_seasons(global_existsinfo.seasons),
|
||||
total_seasons=total_seasons
|
||||
)
|
||||
else:
|
||||
server_results[fallback_server_name] = {
|
||||
"exists": True
|
||||
}
|
||||
|
||||
if not server_results:
|
||||
return "媒体库中未找到相关媒体"
|
||||
global_existsinfo = await self.run_blocking(
|
||||
"mediaserver", self._query_media_exists, mediainfo, None
|
||||
)
|
||||
if not global_existsinfo:
|
||||
return "媒体库中未找到相关媒体"
|
||||
|
||||
fallback_server_name = global_existsinfo.server or "local"
|
||||
if global_existsinfo.type == MediaType.TV:
|
||||
server_results[fallback_server_name] = _build_tv_server_result(
|
||||
existing_seasons=_filter_regular_seasons(global_existsinfo.seasons),
|
||||
total_seasons=total_seasons
|
||||
)
|
||||
else:
|
||||
server_results[fallback_server_name] = {
|
||||
"exists": True
|
||||
}
|
||||
|
||||
# 3. 组装统一的存在性结果,不查询媒体服务器详情
|
||||
result_dict = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""查询媒体服务器最近入库影片工具"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Optional, Type
|
||||
|
||||
@@ -16,10 +17,8 @@ PAGE_SIZE = 20
|
||||
class QueryLibraryLatestInput(BaseModel):
|
||||
"""查询媒体服务器最近入库影片工具的输入参数模型"""
|
||||
|
||||
explanation: str = Field(
|
||||
...,
|
||||
description="Clear explanation of why this tool is being used in the current context",
|
||||
)
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
server: Optional[str] = Field(
|
||||
None,
|
||||
description="Media server name (optional, if not specified queries all enabled media servers)",
|
||||
@@ -50,6 +49,32 @@ class QueryLibraryLatestTool(MoviePilotTool):
|
||||
|
||||
return " | ".join(parts)
|
||||
|
||||
@staticmethod
|
||||
def _get_enabled_servers() -> list[str]:
|
||||
"""同步读取启用的媒体服务器列表。"""
|
||||
mediaservers = ServiceConfigHelper.get_mediaserver_configs()
|
||||
return [ms.name for ms in mediaservers if ms.enabled]
|
||||
|
||||
@staticmethod
|
||||
def _load_latest_items(
|
||||
server_name: str, count: int, username: Optional[str] = None
|
||||
) -> list[dict]:
|
||||
"""
|
||||
媒体服务器 SDK 和 requests 调用都是同步的,这里在线程池中转换为可序列化结果。
|
||||
"""
|
||||
latest_items = MediaServerChain().latest(
|
||||
server=server_name, count=count, username=username
|
||||
)
|
||||
if not latest_items:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
**item.model_dump(exclude_none=True),
|
||||
"server": server_name,
|
||||
}
|
||||
for item in latest_items
|
||||
]
|
||||
|
||||
async def run(
|
||||
self, server: Optional[str] = None, page: Optional[int] = 1, **kwargs
|
||||
) -> str:
|
||||
@@ -58,37 +83,34 @@ class QueryLibraryLatestTool(MoviePilotTool):
|
||||
fetch_count = page * PAGE_SIZE
|
||||
logger.info(f"执行工具: {self.name}, 参数: server={server}, page={page}")
|
||||
try:
|
||||
media_chain = MediaServerChain()
|
||||
results = []
|
||||
|
||||
# 如果没有指定服务器,获取所有启用的媒体服务器
|
||||
if not server:
|
||||
mediaservers = ServiceConfigHelper.get_mediaserver_configs()
|
||||
enabled_servers = [ms.name for ms in mediaservers if ms.enabled]
|
||||
|
||||
enabled_servers = self._get_enabled_servers()
|
||||
if not enabled_servers:
|
||||
return "未找到启用的媒体服务器"
|
||||
|
||||
# 遍历所有启用的服务器
|
||||
for server_name in enabled_servers:
|
||||
latest_items = media_chain.latest(
|
||||
server=server_name, count=fetch_count, username=self._username
|
||||
)
|
||||
if latest_items:
|
||||
for item in latest_items:
|
||||
item_dict = item.model_dump(exclude_none=True)
|
||||
item_dict["server"] = server_name
|
||||
results.append(item_dict)
|
||||
else:
|
||||
# 查询指定服务器
|
||||
latest_items = media_chain.latest(
|
||||
server=server, count=fetch_count, username=self._username
|
||||
server_results = await asyncio.gather(
|
||||
*[
|
||||
self.run_blocking(
|
||||
"mediaserver",
|
||||
self._load_latest_items,
|
||||
server_name,
|
||||
fetch_count,
|
||||
self._username,
|
||||
)
|
||||
for server_name in enabled_servers
|
||||
]
|
||||
)
|
||||
results = [
|
||||
item for items in server_results for item in items if items
|
||||
]
|
||||
else:
|
||||
results = await self.run_blocking(
|
||||
"mediaserver",
|
||||
self._load_latest_items,
|
||||
server,
|
||||
fetch_count,
|
||||
self._username,
|
||||
)
|
||||
if latest_items:
|
||||
for item in latest_items:
|
||||
item_dict = item.model_dump(exclude_none=True)
|
||||
item_dict["server"] = server
|
||||
results.append(item_dict)
|
||||
|
||||
if not results:
|
||||
server_info = f"服务器 {server}" if server else "所有服务器"
|
||||
|
||||
115
app/agent/tools/impl/query_market_plugins.py
Normal file
115
app/agent/tools/impl/query_market_plugins.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""查询插件市场工具"""
|
||||
|
||||
import json
|
||||
from typing import Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.impl._plugin_tool_utils import (
|
||||
DEFAULT_PLUGIN_CANDIDATE_LIMIT,
|
||||
MAX_PLUGIN_CANDIDATE_LIMIT,
|
||||
load_market_plugins,
|
||||
search_plugin_candidates,
|
||||
summarize_candidates,
|
||||
summarize_plugin,
|
||||
)
|
||||
from app.log import logger
|
||||
|
||||
|
||||
class QueryMarketPluginsInput(BaseModel):
|
||||
"""查询插件市场工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
query: Optional[str] = Field(
|
||||
None,
|
||||
description="Optional keyword to filter plugin market results by plugin ID, name, description, or author.",
|
||||
)
|
||||
max_results: Optional[int] = Field(
|
||||
DEFAULT_PLUGIN_CANDIDATE_LIMIT,
|
||||
description="Maximum number of plugins to return. Defaults to 50, capped at 200.",
|
||||
)
|
||||
force_refresh: Optional[bool] = Field(
|
||||
False,
|
||||
description="Whether to refresh plugin market caches before querying.",
|
||||
)
|
||||
|
||||
|
||||
class QueryMarketPluginsTool(MoviePilotTool):
|
||||
name: str = "query_market_plugins"
|
||||
description: str = (
|
||||
"Query available plugins from the plugin market and local plugin repositories. "
|
||||
"Can return the full plugin list or filter by keywords before install_plugin is used."
|
||||
)
|
||||
require_admin: bool = True
|
||||
args_schema: Type[BaseModel] = QueryMarketPluginsInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
query = kwargs.get("query")
|
||||
if query:
|
||||
return f"查询插件市场: {query}"
|
||||
return "查询插件市场全部插件"
|
||||
|
||||
@staticmethod
|
||||
def _clamp_results(max_results: Optional[int]) -> int:
|
||||
if max_results is None:
|
||||
return DEFAULT_PLUGIN_CANDIDATE_LIMIT
|
||||
try:
|
||||
return max(1, min(int(max_results), MAX_PLUGIN_CANDIDATE_LIMIT))
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_PLUGIN_CANDIDATE_LIMIT
|
||||
|
||||
async def run(
|
||||
self,
|
||||
query: Optional[str] = None,
|
||||
max_results: Optional[int] = DEFAULT_PLUGIN_CANDIDATE_LIMIT,
|
||||
force_refresh: bool = False,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, 参数: query={query}, force_refresh={force_refresh}"
|
||||
)
|
||||
|
||||
try:
|
||||
plugins = await load_market_plugins(force_refresh=force_refresh)
|
||||
if not plugins:
|
||||
return json.dumps(
|
||||
{"success": False, "message": "当前插件市场没有可用插件"},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
limit = self._clamp_results(max_results)
|
||||
if query:
|
||||
matches = search_plugin_candidates(query, plugins)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"query": query,
|
||||
"total_available": len(plugins),
|
||||
"match_count": len(matches),
|
||||
"truncated": len(matches) > limit,
|
||||
"plugins": summarize_candidates(matches, limit=limit),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
plugin_summaries = [summarize_plugin(plugin) for plugin in plugins[:limit]]
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"total_available": len(plugins),
|
||||
"returned_count": len(plugin_summaries),
|
||||
"truncated": len(plugins) > limit,
|
||||
"plugins": plugin_summaries,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"查询插件市场失败: {e}", exc_info=True)
|
||||
return json.dumps(
|
||||
{"success": False, "message": f"查询插件市场时发生错误: {str(e)}"},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
@@ -10,10 +10,14 @@ from app.chain.media import MediaChain
|
||||
from app.log import logger
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
DIRECTOR_PREVIEW_LIMIT = 10
|
||||
ACTOR_PREVIEW_LIMIT = 20
|
||||
SEASON_PREVIEW_LIMIT = 100
|
||||
|
||||
|
||||
class QueryMediaDetailInput(BaseModel):
|
||||
"""查询媒体详情工具的输入参数模型"""
|
||||
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
tmdb_id: Optional[int] = Field(None, description="TMDB ID of the media (movie or TV series, can be obtained from search_media tool)")
|
||||
douban_id: Optional[str] = Field(None, description="Douban ID of the media (alternative to tmdb_id)")
|
||||
media_type: str = Field(..., description="Allowed values: movie, tv")
|
||||
@@ -64,23 +68,23 @@ class QueryMediaDetailTool(MoviePilotTool):
|
||||
genres = [g.get("name") for g in (mediainfo.genres or []) if g.get("name")]
|
||||
|
||||
# 精简 directors - 只保留姓名和职位
|
||||
director_source = [d for d in (mediainfo.directors or []) if d.get("name")]
|
||||
directors = [
|
||||
{
|
||||
"name": d.get("name"),
|
||||
"job": d.get("job")
|
||||
}
|
||||
for d in (mediainfo.directors or [])
|
||||
if d.get("name")
|
||||
for d in director_source[:DIRECTOR_PREVIEW_LIMIT]
|
||||
]
|
||||
|
||||
# 精简 actors - 只保留姓名和角色
|
||||
actor_source = [a for a in (mediainfo.actors or []) if a.get("name")]
|
||||
actors = [
|
||||
{
|
||||
"name": a.get("name"),
|
||||
"character": a.get("character")
|
||||
}
|
||||
for a in (mediainfo.actors or [])
|
||||
if a.get("name")
|
||||
for a in actor_source[:ACTOR_PREVIEW_LIMIT]
|
||||
]
|
||||
|
||||
# 构建基础媒体详情信息
|
||||
@@ -88,12 +92,20 @@ class QueryMediaDetailTool(MoviePilotTool):
|
||||
"status": mediainfo.status,
|
||||
"genres": genres,
|
||||
"directors": directors,
|
||||
"actors": actors
|
||||
"directors_total": len(director_source),
|
||||
"directors_truncated": len(director_source) > DIRECTOR_PREVIEW_LIMIT,
|
||||
"actors": actors,
|
||||
"actors_total": len(actor_source),
|
||||
"actors_truncated": len(actor_source) > ACTOR_PREVIEW_LIMIT,
|
||||
}
|
||||
|
||||
# 如果是电视剧,添加电视剧特有信息
|
||||
if mediainfo.type == MediaType.TV:
|
||||
# 精简 season_info - 只保留基础摘要
|
||||
season_source = [
|
||||
s for s in (mediainfo.season_info or [])
|
||||
if s.get("season_number") is not None
|
||||
]
|
||||
season_info = [
|
||||
{
|
||||
"season_number": s.get("season_number"),
|
||||
@@ -101,8 +113,7 @@ class QueryMediaDetailTool(MoviePilotTool):
|
||||
"episode_count": s.get("episode_count"),
|
||||
"air_date": s.get("air_date")
|
||||
}
|
||||
for s in (mediainfo.season_info or [])
|
||||
if s.get("season_number") is not None
|
||||
for s in season_source[:SEASON_PREVIEW_LIMIT]
|
||||
]
|
||||
|
||||
result.update({
|
||||
@@ -110,7 +121,9 @@ class QueryMediaDetailTool(MoviePilotTool):
|
||||
"number_of_episodes": mediainfo.number_of_episodes,
|
||||
"first_air_date": mediainfo.first_air_date,
|
||||
"last_air_date": mediainfo.last_air_date,
|
||||
"season_info": season_info
|
||||
"season_info": season_info,
|
||||
"season_info_total": len(season_source),
|
||||
"season_info_truncated": len(season_source) > SEASON_PREVIEW_LIMIT,
|
||||
})
|
||||
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
|
||||
73
app/agent/tools/impl/query_personas.py
Normal file
73
app/agent/tools/impl/query_personas.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""查询可用人格工具。"""
|
||||
|
||||
import json
|
||||
from typing import Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.runtime import agent_runtime_manager
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.log import logger
|
||||
|
||||
|
||||
class QueryPersonasInput(BaseModel):
|
||||
"""查询人格工具的输入参数模型。"""
|
||||
|
||||
explanation: 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 search keyword for persona_id, label, description, or aliases. "
|
||||
"Use this when the user asks for a certain speaking style but the exact persona name is unknown."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class QueryPersonasTool(MoviePilotTool):
|
||||
name: str = "query_personas"
|
||||
description: str = (
|
||||
"List all available personas (人格) and show which one is currently active. "
|
||||
"Use this before switching persona when the user asks for a different speaking style but does not name "
|
||||
"an exact persona_id. The result includes persona_id, label, description, aliases, and whether it is active."
|
||||
)
|
||||
args_schema: Type[BaseModel] = QueryPersonasInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
query = kwargs.get("query")
|
||||
if query:
|
||||
return f"查询人格列表: {query}"
|
||||
return "查询人格列表"
|
||||
|
||||
async def run(self, query: Optional[str] = None, **kwargs) -> str:
|
||||
logger.info("执行工具: %s, 参数: query=%s", self.name, query)
|
||||
try:
|
||||
runtime_config = agent_runtime_manager.load_runtime_config()
|
||||
personas = runtime_config.list_personas()
|
||||
|
||||
if query:
|
||||
normalized = query.strip().casefold()
|
||||
personas = [
|
||||
persona
|
||||
for persona in personas
|
||||
if normalized in persona["persona_id"].casefold()
|
||||
or normalized in persona["label"].casefold()
|
||||
or normalized in persona["description"].casefold()
|
||||
or any(normalized in alias.casefold() for alias in persona["aliases"])
|
||||
]
|
||||
|
||||
payload = {
|
||||
"active_persona": runtime_config.active_persona,
|
||||
"count": len(personas),
|
||||
"personas": personas,
|
||||
}
|
||||
return json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error("查询人格列表失败: %s", e, exc_info=True)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"查询人格列表时发生错误: {str(e)}",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
@@ -13,10 +13,8 @@ from app.log import logger
|
||||
class QueryPluginCapabilitiesInput(BaseModel):
|
||||
"""查询插件能力工具的输入参数模型"""
|
||||
|
||||
explanation: str = Field(
|
||||
...,
|
||||
description="Clear explanation of why this tool is being used in the current context",
|
||||
)
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
plugin_id: Optional[str] = Field(
|
||||
None,
|
||||
description="Optional plugin ID to query capabilities for a specific plugin. "
|
||||
@@ -43,70 +41,68 @@ class QueryPluginCapabilitiesTool(MoviePilotTool):
|
||||
return f"查询插件 {plugin_id} 的能力"
|
||||
return "查询所有插件的能力"
|
||||
|
||||
async def run(self, plugin_id: Optional[str] = None, **kwargs) -> str:
|
||||
logger.info(f"执行工具: {self.name}, 参数: plugin_id={plugin_id}")
|
||||
try:
|
||||
plugin_manager = PluginManager()
|
||||
result = {}
|
||||
@staticmethod
|
||||
def _load_plugin_capabilities(plugin_id: Optional[str] = None) -> dict:
|
||||
"""读取运行中插件实例暴露的内存能力信息。"""
|
||||
plugin_manager = PluginManager()
|
||||
result = {}
|
||||
|
||||
# 获取插件命令
|
||||
commands = plugin_manager.get_plugin_commands(pid=plugin_id)
|
||||
if commands:
|
||||
commands_list = []
|
||||
for cmd in commands:
|
||||
cmd_info = {
|
||||
"cmd": cmd.get("cmd"),
|
||||
"desc": cmd.get("desc"),
|
||||
"plugin_id": cmd.get("pid"),
|
||||
}
|
||||
# data 字段可能包含额外参数信息
|
||||
if cmd.get("data"):
|
||||
cmd_info["data"] = cmd.get("data")
|
||||
commands_list.append(cmd_info)
|
||||
result["commands"] = commands_list
|
||||
commands = plugin_manager.get_plugin_commands(pid=plugin_id)
|
||||
if commands:
|
||||
result["commands"] = [
|
||||
{
|
||||
"cmd": cmd.get("cmd"),
|
||||
"desc": cmd.get("desc"),
|
||||
"plugin_id": cmd.get("pid"),
|
||||
**({"data": cmd.get("data")} if cmd.get("data") else {}),
|
||||
}
|
||||
for cmd in commands
|
||||
]
|
||||
|
||||
# 获取插件动作
|
||||
actions = plugin_manager.get_plugin_actions(pid=plugin_id)
|
||||
if actions:
|
||||
actions_list = []
|
||||
for action_group in actions:
|
||||
plugin_actions = {
|
||||
actions = plugin_manager.get_plugin_actions(pid=plugin_id)
|
||||
if actions:
|
||||
actions_list = []
|
||||
for action_group in actions:
|
||||
actions_list.append(
|
||||
{
|
||||
"plugin_id": action_group.get("plugin_id"),
|
||||
"plugin_name": action_group.get("plugin_name"),
|
||||
"actions": [],
|
||||
}
|
||||
for action in action_group.get("actions", []):
|
||||
plugin_actions["actions"].append(
|
||||
"actions": [
|
||||
{
|
||||
"id": action.get("id"),
|
||||
"name": action.get("name"),
|
||||
}
|
||||
)
|
||||
actions_list.append(plugin_actions)
|
||||
result["actions"] = actions_list
|
||||
|
||||
# 获取插件定时服务
|
||||
services = plugin_manager.get_plugin_services(pid=plugin_id)
|
||||
if services:
|
||||
services_list = []
|
||||
for svc in services:
|
||||
svc_info = {
|
||||
"id": svc.get("id"),
|
||||
"name": svc.get("name"),
|
||||
for action in action_group.get("actions", [])
|
||||
],
|
||||
}
|
||||
# 包含触发器信息
|
||||
trigger = svc.get("trigger")
|
||||
if trigger:
|
||||
svc_info["trigger"] = str(trigger)
|
||||
# 包含定时器参数
|
||||
svc_kwargs = svc.get("kwargs")
|
||||
if svc_kwargs:
|
||||
svc_info["trigger_kwargs"] = {
|
||||
k: str(v) for k, v in svc_kwargs.items()
|
||||
}
|
||||
services_list.append(svc_info)
|
||||
result["services"] = services_list
|
||||
)
|
||||
result["actions"] = actions_list
|
||||
|
||||
services = plugin_manager.get_plugin_services(pid=plugin_id)
|
||||
if services:
|
||||
services_list = []
|
||||
for svc in services:
|
||||
svc_info = {
|
||||
"id": svc.get("id"),
|
||||
"name": svc.get("name"),
|
||||
}
|
||||
trigger = svc.get("trigger")
|
||||
if trigger:
|
||||
svc_info["trigger"] = str(trigger)
|
||||
svc_kwargs = svc.get("kwargs")
|
||||
if svc_kwargs:
|
||||
svc_info["trigger_kwargs"] = {
|
||||
k: str(v) for k, v in svc_kwargs.items()
|
||||
}
|
||||
services_list.append(svc_info)
|
||||
result["services"] = services_list
|
||||
|
||||
return result
|
||||
|
||||
async def run(self, plugin_id: Optional[str] = None, **kwargs) -> str:
|
||||
logger.info(f"执行工具: {self.name}, 参数: plugin_id={plugin_id}")
|
||||
try:
|
||||
result = self._load_plugin_capabilities(plugin_id)
|
||||
if not result:
|
||||
if plugin_id:
|
||||
return f"插件 {plugin_id} 没有注册任何命令、动作或定时服务"
|
||||
|
||||
86
app/agent/tools/impl/query_plugin_config.py
Normal file
86
app/agent/tools/impl/query_plugin_config.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""查询插件配置工具"""
|
||||
|
||||
import json
|
||||
from typing import Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.impl._plugin_tool_utils import get_plugin_snapshot
|
||||
from app.core.plugin import PluginManager
|
||||
from app.log import logger
|
||||
|
||||
|
||||
class QueryPluginConfigInput(BaseModel):
|
||||
"""查询插件配置工具的输入参数模型"""
|
||||
|
||||
explanation: 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.",
|
||||
)
|
||||
|
||||
|
||||
class QueryPluginConfigTool(MoviePilotTool):
|
||||
name: str = "query_plugin_config"
|
||||
description: str = (
|
||||
"Query the saved configuration of an installed plugin. "
|
||||
"Returns the current saved config and, when available, the plugin's default config model. "
|
||||
"Use this before update_plugin_config so you only change the intended keys."
|
||||
)
|
||||
require_admin: bool = True
|
||||
args_schema: Type[BaseModel] = QueryPluginConfigInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""生成友好的提示消息"""
|
||||
plugin_id = kwargs.get("plugin_id", "")
|
||||
return f"查询插件配置: {plugin_id}"
|
||||
|
||||
@staticmethod
|
||||
def _query_plugin_config(plugin_id: str) -> str:
|
||||
"""
|
||||
读取插件已保存配置,并尽量补充默认配置模型方便后续精确修改。
|
||||
"""
|
||||
plugin_info = get_plugin_snapshot(plugin_id)
|
||||
if not plugin_info:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"插件 {plugin_id} 不存在,请先使用 query_installed_plugins 查询有效插件 ID",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
plugin_manager = PluginManager()
|
||||
saved_config = plugin_manager.get_plugin_config(plugin_id) or {}
|
||||
result = {
|
||||
"success": True,
|
||||
**plugin_info,
|
||||
"config": saved_config,
|
||||
}
|
||||
|
||||
# get_form 的 model 通常就是插件期望的配置结构,适合作为修改前的键参考。
|
||||
plugin_instance = plugin_manager.running_plugins.get(plugin_id)
|
||||
if plugin_instance and hasattr(plugin_instance, "get_form"):
|
||||
try:
|
||||
_form_schema, default_model = plugin_instance.get_form()
|
||||
if default_model is not None:
|
||||
result["default_model"] = default_model
|
||||
except Exception as err:
|
||||
logger.warning(f"读取插件 {plugin_id} 默认配置模型失败: {err}")
|
||||
|
||||
return json.dumps(result, ensure_ascii=False, indent=2, default=str)
|
||||
|
||||
async def run(self, plugin_id: str, **kwargs) -> str:
|
||||
logger.info(f"执行工具: {self.name}, 参数: plugin_id={plugin_id}")
|
||||
|
||||
try:
|
||||
# 插件配置来自内存配置缓存和运行态插件实例,直接读取即可。
|
||||
return self._query_plugin_config(plugin_id)
|
||||
except Exception as e:
|
||||
logger.error(f"查询插件配置失败: {e}", exc_info=True)
|
||||
return json.dumps(
|
||||
{"success": False, "message": f"查询插件配置时发生错误: {str(e)}"},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
156
app/agent/tools/impl/query_plugin_data.py
Normal file
156
app/agent/tools/impl/query_plugin_data.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""查询插件数据工具"""
|
||||
|
||||
import json
|
||||
from typing import Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.impl._plugin_tool_utils import (
|
||||
PLUGIN_DATA_KEY_PREVIEW_LIMIT,
|
||||
build_preview_payload,
|
||||
get_plugin_snapshot,
|
||||
)
|
||||
from app.db.plugindata_oper import PluginDataOper
|
||||
from app.log import logger
|
||||
|
||||
|
||||
class QueryPluginDataInput(BaseModel):
|
||||
"""查询插件数据工具的输入参数模型"""
|
||||
|
||||
explanation: 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.",
|
||||
)
|
||||
key: Optional[str] = Field(
|
||||
None,
|
||||
description="Optional plugin data key. If omitted, returns all plugin data entries for the plugin.",
|
||||
)
|
||||
max_chars: Optional[int] = Field(
|
||||
None,
|
||||
description="Maximum number of preview characters to return when plugin data is too large. Default 12000, capped at 50000.",
|
||||
)
|
||||
|
||||
|
||||
class QueryPluginDataTool(MoviePilotTool):
|
||||
name: str = "query_plugin_data"
|
||||
description: str = (
|
||||
"Query persisted data of an installed plugin. "
|
||||
"Optionally specify a key to read a single data item; otherwise all plugin data entries are returned. "
|
||||
"When the result is too large, the tool automatically truncates it and returns a preview instead."
|
||||
)
|
||||
require_admin: bool = True
|
||||
args_schema: Type[BaseModel] = QueryPluginDataInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""生成友好的提示消息"""
|
||||
plugin_id = kwargs.get("plugin_id", "")
|
||||
key = kwargs.get("key")
|
||||
if key:
|
||||
return f"查询插件数据: {plugin_id}.{key}"
|
||||
return f"查询插件全部数据: {plugin_id}"
|
||||
|
||||
@staticmethod
|
||||
async def _query_plugin_data(
|
||||
plugin_id: str, key: Optional[str] = None, max_chars: Optional[int] = None
|
||||
) -> str:
|
||||
"""
|
||||
插件数据改走异步 ORM 查询,避免再套一层线程池。
|
||||
"""
|
||||
plugin_info = get_plugin_snapshot(plugin_id)
|
||||
if not plugin_info:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"插件 {plugin_id} 不存在,请先使用 query_installed_plugins 查询有效插件 ID",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
plugin_data_oper = PluginDataOper()
|
||||
if key:
|
||||
value = await plugin_data_oper.async_get_data(plugin_id, key)
|
||||
if value is None:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
**plugin_info,
|
||||
"key": key,
|
||||
"found": False,
|
||||
"message": f"插件 {plugin_id} 没有数据项 {key}",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
truncated, total_chars, returned_chars, preview = build_preview_payload(
|
||||
value, max_chars
|
||||
)
|
||||
result = {
|
||||
"success": True,
|
||||
**plugin_info,
|
||||
"key": key,
|
||||
"found": True,
|
||||
"truncated": truncated,
|
||||
"total_chars": total_chars,
|
||||
"returned_chars": returned_chars,
|
||||
}
|
||||
if truncated:
|
||||
result["value_preview"] = preview
|
||||
result["message"] = "插件数据内容过大,已截断预览"
|
||||
else:
|
||||
result["value"] = value
|
||||
return json.dumps(result, ensure_ascii=False, indent=2, default=str)
|
||||
|
||||
rows = await plugin_data_oper.async_get_data_all(plugin_id) or []
|
||||
data_map = {row.key: row.value for row in rows}
|
||||
keys = list(data_map.keys())
|
||||
key_preview = keys[:PLUGIN_DATA_KEY_PREVIEW_LIMIT]
|
||||
|
||||
result = {
|
||||
"success": True,
|
||||
**plugin_info,
|
||||
"count": len(data_map),
|
||||
"keys": key_preview,
|
||||
"keys_truncated": len(keys) > PLUGIN_DATA_KEY_PREVIEW_LIMIT,
|
||||
}
|
||||
|
||||
if not data_map:
|
||||
result["data"] = {}
|
||||
result["truncated"] = False
|
||||
return json.dumps(result, ensure_ascii=False, indent=2, default=str)
|
||||
|
||||
truncated, total_chars, returned_chars, preview = build_preview_payload(
|
||||
data_map, max_chars
|
||||
)
|
||||
result["truncated"] = truncated
|
||||
result["total_chars"] = total_chars
|
||||
result["returned_chars"] = returned_chars
|
||||
if truncated:
|
||||
result["data_preview"] = preview
|
||||
result["message"] = "插件数据内容过大,已截断。请传入 key 精确查询单个数据项。"
|
||||
else:
|
||||
result["data"] = data_map
|
||||
return json.dumps(result, ensure_ascii=False, indent=2, default=str)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
plugin_id: str,
|
||||
key: Optional[str] = None,
|
||||
max_chars: Optional[int] = None,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, 参数: plugin_id={plugin_id}, key={key}"
|
||||
)
|
||||
|
||||
try:
|
||||
return await self._query_plugin_data(plugin_id, key, max_chars)
|
||||
except Exception as e:
|
||||
logger.error(f"查询插件数据失败: {e}", exc_info=True)
|
||||
return json.dumps(
|
||||
{"success": False, "message": f"查询插件数据时发生错误: {str(e)}"},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
@@ -12,13 +12,15 @@ from app.helper.subscribe import SubscribeHelper
|
||||
from app.log import logger
|
||||
from app.schemas.types import MediaType, media_type_to_agent
|
||||
|
||||
MAX_PAGE_SIZE = 50
|
||||
|
||||
|
||||
class QueryPopularSubscribesInput(BaseModel):
|
||||
"""查询热门订阅工具的输入参数模型"""
|
||||
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
media_type: str = Field(..., description="Allowed values: movie, tv")
|
||||
page: Optional[int] = Field(1, description="Page number for pagination (default: 1)")
|
||||
count: Optional[int] = Field(30, description="Number of items per page (default: 30)")
|
||||
count: Optional[int] = Field(30, description="Number of items per page (default: 30, max: 50)")
|
||||
min_sub: Optional[int] = Field(None, description="Minimum number of subscribers filter (optional, e.g., 5)")
|
||||
genre_id: Optional[int] = Field(None, description="Filter by genre ID (optional)")
|
||||
min_rating: Optional[float] = Field(None, description="Minimum rating filter (optional, e.g., 7.5)")
|
||||
@@ -69,6 +71,8 @@ class QueryPopularSubscribesTool(MoviePilotTool):
|
||||
page = 1
|
||||
if count is None or count < 1:
|
||||
count = 30
|
||||
# 外部统计接口支持传入 count,这里做硬上限,避免 Agent 一次拉取过多结果。
|
||||
count = min(count, MAX_PAGE_SIZE)
|
||||
media_type_enum = MediaType.from_agent(media_type)
|
||||
if not media_type_enum:
|
||||
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'"
|
||||
|
||||
@@ -1,64 +1,102 @@
|
||||
"""查询规则组工具"""
|
||||
"""查询过滤规则组工具。"""
|
||||
|
||||
import json
|
||||
from typing import Optional, Type
|
||||
from typing import Optional, Type, List
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.helper.rule import RuleHelper
|
||||
from app.agent.tools.impl._filter_rule_utils import (
|
||||
collect_rule_group_usages,
|
||||
get_rule_groups,
|
||||
serialize_rule_group,
|
||||
RULE_STRING_SYNTAX,
|
||||
)
|
||||
from app.log import logger
|
||||
|
||||
|
||||
class QueryRuleGroupsInput(BaseModel):
|
||||
"""查询规则组工具的输入参数模型"""
|
||||
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
|
||||
|
||||
explanation: 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.",
|
||||
)
|
||||
include_usage: bool = Field(
|
||||
True,
|
||||
description="Whether to include where each rule group is referenced by global settings or subscriptions.",
|
||||
)
|
||||
|
||||
|
||||
class QueryRuleGroupsTool(MoviePilotTool):
|
||||
name: str = "query_rule_groups"
|
||||
description: str = "Query all filter rule groups available in the system. Rule groups are used to filter torrents when searching or subscribing. Returns rule group names, media types, and categories, but excludes rule_string to keep results concise."
|
||||
description: str = (
|
||||
"Query filter rule groups (过滤规则组 / 优先级规则组). "
|
||||
"Each rule group contains a rule_string made of built-in rules and/or custom rules. "
|
||||
"Inside one level use '&', '|', '!' and optional parentheses; use '>' between levels. "
|
||||
"Levels are evaluated from left to right, and the first matched level wins. "
|
||||
"The result includes parsed levels and syntax guidance so the agent can learn existing patterns before writing a new rule group."
|
||||
)
|
||||
args_schema: Type[BaseModel] = QueryRuleGroupsInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""根据查询参数生成友好的提示消息"""
|
||||
group_names = kwargs.get("group_names") or []
|
||||
if group_names:
|
||||
return f"查询规则组: {', '.join(group_names)}"
|
||||
return "查询所有规则组"
|
||||
|
||||
async def run(self, **kwargs) -> str:
|
||||
async def run(
|
||||
self,
|
||||
group_names: Optional[List[str]] = None,
|
||||
include_usage: bool = True,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
logger.info(f"执行工具: {self.name}")
|
||||
|
||||
|
||||
try:
|
||||
rule_helper = RuleHelper()
|
||||
rule_groups = rule_helper.get_rule_groups()
|
||||
|
||||
if not rule_groups:
|
||||
return json.dumps({
|
||||
"message": "未找到任何规则组",
|
||||
"rule_groups": []
|
||||
}, ensure_ascii=False, indent=2)
|
||||
|
||||
# 精简字段,过滤掉 rule_string 避免结果过大
|
||||
simplified_groups = []
|
||||
for group in rule_groups:
|
||||
simplified = {
|
||||
"name": group.name,
|
||||
"media_type": group.media_type,
|
||||
"category": group.category
|
||||
}
|
||||
simplified_groups.append(simplified)
|
||||
|
||||
result = {
|
||||
"message": f"找到 {len(simplified_groups)} 个规则组",
|
||||
"rule_groups": simplified_groups
|
||||
}
|
||||
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
error_message = f"查询规则组失败: {str(e)}"
|
||||
logger.error(f"查询规则组失败: {e}", exc_info=True)
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": error_message,
|
||||
"rule_groups": []
|
||||
}, ensure_ascii=False)
|
||||
rule_groups = get_rule_groups()
|
||||
if group_names:
|
||||
target_names = set(group_names)
|
||||
rule_groups = [
|
||||
group for group in rule_groups if group.name in target_names
|
||||
]
|
||||
|
||||
usage_map = {}
|
||||
if include_usage:
|
||||
usage_map = await collect_rule_group_usages(
|
||||
[group.name for group in rule_groups if group.name]
|
||||
)
|
||||
|
||||
serialized = [
|
||||
serialize_rule_group(group, usage_map.get(group.name))
|
||||
for group in rule_groups
|
||||
]
|
||||
message = (
|
||||
f"找到 {len(serialized)} 个规则组"
|
||||
if serialized
|
||||
else "未找到任何规则组"
|
||||
)
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": message,
|
||||
"count": len(serialized),
|
||||
"rule_string_syntax": RULE_STRING_SYNTAX,
|
||||
"rule_groups": serialized,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"查询规则组失败: {exc}", exc_info=True)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"查询规则组失败: {exc}",
|
||||
"rule_groups": [],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
@@ -7,12 +7,11 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.log import logger
|
||||
from app.scheduler import Scheduler
|
||||
|
||||
|
||||
class QuerySchedulersInput(BaseModel):
|
||||
"""查询定时服务工具的输入参数模型"""
|
||||
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
|
||||
|
||||
class QuerySchedulersTool(MoviePilotTool):
|
||||
@@ -27,6 +26,8 @@ class QuerySchedulersTool(MoviePilotTool):
|
||||
async def run(self, **kwargs) -> str:
|
||||
logger.info(f"执行工具: {self.name}")
|
||||
try:
|
||||
from app.scheduler import Scheduler
|
||||
|
||||
scheduler = Scheduler()
|
||||
schedulers = scheduler.list()
|
||||
if schedulers:
|
||||
|
||||
@@ -11,14 +11,20 @@ from app.db.models.site import Site
|
||||
from app.db.models.siteuserdata import SiteUserData
|
||||
from app.log import logger
|
||||
|
||||
SITE_USERDATA_DETAIL_PREVIEW_LIMIT = 10
|
||||
|
||||
|
||||
def _preview_list(value, limit: int = SITE_USERDATA_DETAIL_PREVIEW_LIMIT) -> tuple[list, int, bool]:
|
||||
"""返回列表字段预览,避免做种明细或未读消息一次性撑大工具结果。"""
|
||||
items = list(value) if isinstance(value, (list, tuple)) else []
|
||||
return items[:limit], len(items), len(items) > limit
|
||||
|
||||
|
||||
class QuerySiteUserdataInput(BaseModel):
|
||||
"""查询站点用户数据工具的输入参数模型"""
|
||||
|
||||
explanation: str = Field(
|
||||
...,
|
||||
description="Clear explanation of why this tool is being used in the current context",
|
||||
)
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
site_id: int = Field(
|
||||
...,
|
||||
description="The ID of the site to query user data for (can be obtained from query_sites tool)",
|
||||
@@ -110,6 +116,13 @@ class QuerySiteUserdataTool(MoviePilotTool):
|
||||
else 0
|
||||
)
|
||||
|
||||
seeding_preview, seeding_count, seeding_truncated = _preview_list(
|
||||
user_data.seeding_info
|
||||
)
|
||||
unread_preview, unread_count, unread_truncated = _preview_list(
|
||||
user_data.message_unread_contents
|
||||
)
|
||||
|
||||
user_data_dict = {
|
||||
"domain": user_data.domain,
|
||||
"name": user_data.name,
|
||||
@@ -131,13 +144,13 @@ class QuerySiteUserdataTool(MoviePilotTool):
|
||||
"seeding_size_gb": round(seeding_size_gb, 2),
|
||||
"leeching_size": user_data.leeching_size,
|
||||
"leeching_size_gb": round(leeching_size_gb, 2),
|
||||
"seeding_info": user_data.seeding_info
|
||||
if user_data.seeding_info
|
||||
else [],
|
||||
"seeding_info_count": seeding_count,
|
||||
"seeding_info": seeding_preview,
|
||||
"seeding_info_truncated": seeding_truncated,
|
||||
"message_unread": user_data.message_unread,
|
||||
"message_unread_contents": user_data.message_unread_contents
|
||||
if user_data.message_unread_contents
|
||||
else [],
|
||||
"message_unread_contents_count": unread_count,
|
||||
"message_unread_contents": unread_preview,
|
||||
"message_unread_contents_truncated": unread_truncated,
|
||||
"err_msg": user_data.err_msg,
|
||||
"updated_day": user_data.updated_day,
|
||||
"updated_time": user_data.updated_time,
|
||||
|
||||
@@ -13,10 +13,8 @@ from app.log import logger
|
||||
class QuerySitesInput(BaseModel):
|
||||
"""查询站点工具的输入参数模型"""
|
||||
|
||||
explanation: str = Field(
|
||||
...,
|
||||
description="Clear explanation of why this tool is being used in the current context",
|
||||
)
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
status: Optional[str] = Field(
|
||||
"all",
|
||||
description="Filter sites by status: 'active' for enabled sites, 'inactive' for disabled sites, 'all' for all sites",
|
||||
|
||||
@@ -17,10 +17,8 @@ PAGE_SIZE = 20
|
||||
class QuerySubscribeHistoryInput(BaseModel):
|
||||
"""查询订阅历史工具的输入参数模型"""
|
||||
|
||||
explanation: str = Field(
|
||||
...,
|
||||
description="Clear explanation of why this tool is being used in the current context",
|
||||
)
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
media_type: Optional[str] = Field(
|
||||
"all", description="Allowed values: movie, tv, all"
|
||||
)
|
||||
|
||||
@@ -9,13 +9,15 @@ from app.agent.tools.base import MoviePilotTool
|
||||
from app.helper.subscribe import SubscribeHelper
|
||||
from app.log import logger
|
||||
|
||||
MAX_PAGE_SIZE = 50
|
||||
|
||||
|
||||
class QuerySubscribeSharesInput(BaseModel):
|
||||
"""查询订阅分享工具的输入参数模型"""
|
||||
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
name: Optional[str] = Field(None, description="Filter shares by media name (partial match, optional)")
|
||||
page: Optional[int] = Field(1, description="Page number for pagination (default: 1)")
|
||||
count: Optional[int] = Field(30, description="Number of items per page (default: 30)")
|
||||
count: Optional[int] = Field(30, description="Number of items per page (default: 30, max: 50)")
|
||||
genre_id: Optional[int] = Field(None, description="Filter by genre ID (optional)")
|
||||
min_rating: Optional[float] = Field(None, description="Minimum rating filter (optional, e.g., 7.5)")
|
||||
max_rating: Optional[float] = Field(None, description="Maximum rating filter (optional, e.g., 10.0)")
|
||||
@@ -63,6 +65,8 @@ class QuerySubscribeSharesTool(MoviePilotTool):
|
||||
page = 1
|
||||
if count is None or count < 1:
|
||||
count = 30
|
||||
# 订阅分享是外部列表型结果,限制单页大小能降低工具上下文占用。
|
||||
count = min(count, MAX_PAGE_SIZE)
|
||||
|
||||
subscribe_helper = SubscribeHelper()
|
||||
shares = await subscribe_helper.async_get_shares(
|
||||
|
||||
@@ -33,6 +33,9 @@ QUERY_SUBSCRIBE_OUTPUT_FIELDS = [
|
||||
"sites",
|
||||
"downloader",
|
||||
"best_version",
|
||||
"best_version_full",
|
||||
"current_priority",
|
||||
"episode_priority",
|
||||
"save_path",
|
||||
"custom_words",
|
||||
"media_category",
|
||||
@@ -44,10 +47,8 @@ QUERY_SUBSCRIBE_OUTPUT_FIELDS = [
|
||||
class QuerySubscribesInput(BaseModel):
|
||||
"""查询订阅工具的输入参数模型"""
|
||||
|
||||
explanation: str = Field(
|
||||
...,
|
||||
description="Clear explanation of why this tool is being used in the current context",
|
||||
)
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
status: Optional[str] = Field(
|
||||
"all",
|
||||
description="Filter subscriptions by status: 'R' for enabled subscriptions, 'S' for paused ones, 'all' for all subscriptions",
|
||||
|
||||
184
app/agent/tools/impl/query_system_settings.py
Normal file
184
app/agent/tools/impl/query_system_settings.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""统一查询系统设置工具。"""
|
||||
|
||||
import json
|
||||
from typing import Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.impl._system_setting_utils import (
|
||||
SettingSpec,
|
||||
list_setting_specs,
|
||||
resolve_setting_spec,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.log import logger
|
||||
|
||||
|
||||
class QuerySystemSettingsInput(BaseModel):
|
||||
"""查询系统设置工具的输入参数模型。"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
setting_key: Optional[str] = Field(
|
||||
None,
|
||||
description=(
|
||||
"Exact setting key to query. Supports Settings field names like 'APP_DOMAIN' or 'TMDB_API_KEY', "
|
||||
"SystemConfigKey values like 'Downloaders' or 'MediaServers', enum names, and some single-key aliases "
|
||||
"such as 'downloaders', 'directories', 'search_sites', 'subscribe_sites', 'site_auth', 'ai_agent', "
|
||||
"and 'custom_identifiers'."
|
||||
),
|
||||
)
|
||||
group: Optional[str] = Field(
|
||||
"all",
|
||||
description=(
|
||||
"Optional group filter when setting_key is not provided. Supports 'all', 'settings', 'systemconfig', "
|
||||
"and category aliases such as 'downloaders', 'media_servers', 'notifications', 'notification_switches', "
|
||||
"'storages', 'directories', 'search_sites', 'subscribe_sites', 'site_auth', 'ai_agent', 'filter_rules', "
|
||||
"'subscribe_defaults', 'plugins', and 'custom_identifiers'. Chinese aliases are also accepted."
|
||||
),
|
||||
)
|
||||
keyword: Optional[str] = Field(
|
||||
None,
|
||||
description=(
|
||||
"Optional keyword used to fuzzy match setting keys, group names, or labels when listing settings."
|
||||
),
|
||||
)
|
||||
include_values: Optional[bool] = Field(
|
||||
None,
|
||||
description=(
|
||||
"Whether to include full setting values. Default behavior: when a single setting is matched it returns the full value; "
|
||||
"when multiple settings are matched it returns summaries only unless this is explicitly set to true."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class QuerySystemSettingsTool(MoviePilotTool):
|
||||
name: str = "query_system_settings"
|
||||
description: str = (
|
||||
"Query system settings across both the basic Settings module and all SystemConfig-backed categories. "
|
||||
"Use this tool to inspect downloaders, media servers, notification channels, storages, directories, search-site ranges, "
|
||||
"subscribe-site ranges, site auth params, AI agent config, and any other system setting before making changes."
|
||||
)
|
||||
require_admin: bool = True
|
||||
args_schema: Type[BaseModel] = QuerySystemSettingsInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""根据查询参数生成友好的提示消息。"""
|
||||
|
||||
setting_key = kwargs.get("setting_key")
|
||||
group = kwargs.get("group", "all")
|
||||
keyword = kwargs.get("keyword")
|
||||
if setting_key:
|
||||
return f"查询系统设置: {setting_key}"
|
||||
if keyword:
|
||||
return f"筛选系统设置: {group} / {keyword}"
|
||||
return f"查询系统设置分组: {group}"
|
||||
|
||||
@staticmethod
|
||||
def _load_setting_value(spec: SettingSpec):
|
||||
if spec.source == "settings":
|
||||
return getattr(settings, spec.key)
|
||||
return SystemConfigOper().get(spec.key)
|
||||
|
||||
@staticmethod
|
||||
def _summarize_value(value) -> dict:
|
||||
summary = {
|
||||
"has_value": value is not None,
|
||||
"value_type": type(value).__name__,
|
||||
}
|
||||
if isinstance(value, list):
|
||||
summary["item_count"] = len(value)
|
||||
if value:
|
||||
summary["item_type"] = type(value[0]).__name__
|
||||
elif isinstance(value, dict):
|
||||
keys = list(value.keys())
|
||||
summary["item_count"] = len(keys)
|
||||
summary["keys_preview"] = keys[:10]
|
||||
if len(keys) > 10:
|
||||
summary["keys_truncated"] = True
|
||||
elif isinstance(value, str):
|
||||
summary["length"] = len(value)
|
||||
preview = value[:200]
|
||||
if preview:
|
||||
summary["value_preview"] = preview
|
||||
if len(value) > len(preview):
|
||||
summary["value_truncated"] = True
|
||||
elif value is not None:
|
||||
summary["value_preview"] = value
|
||||
return summary
|
||||
|
||||
async def run(
|
||||
self,
|
||||
setting_key: Optional[str] = None,
|
||||
group: Optional[str] = "all",
|
||||
keyword: Optional[str] = None,
|
||||
include_values: Optional[bool] = None,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
logger.info(
|
||||
"执行工具: %s, setting_key=%s, group=%s, keyword=%s",
|
||||
self.name,
|
||||
setting_key,
|
||||
group,
|
||||
keyword,
|
||||
)
|
||||
|
||||
try:
|
||||
if setting_key:
|
||||
spec = resolve_setting_spec(setting_key)
|
||||
if not spec:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"系统设置项 '{setting_key}' 不存在",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
specs = [spec]
|
||||
else:
|
||||
specs = list_setting_specs(group=group, keyword=keyword)
|
||||
if not specs:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"message": "没有找到匹配的系统设置项",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
should_include_values = (
|
||||
include_values if include_values is not None else len(specs) == 1
|
||||
)
|
||||
settings_payload = []
|
||||
for spec in specs:
|
||||
value = self._load_setting_value(spec)
|
||||
item = {
|
||||
"setting_key": spec.key,
|
||||
"source": spec.source,
|
||||
"group": spec.group,
|
||||
"label": spec.label,
|
||||
}
|
||||
item.update(self._summarize_value(value))
|
||||
if should_include_values:
|
||||
item["value"] = value
|
||||
settings_payload.append(item)
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"matched_count": len(settings_payload),
|
||||
"include_values": should_include_values,
|
||||
"settings": settings_payload,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
default=str,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"查询系统设置失败: {e}", exc_info=True)
|
||||
return json.dumps(
|
||||
{"success": False, "message": f"查询系统设置时发生错误: {str(e)}"},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
@@ -3,7 +3,6 @@
|
||||
import json
|
||||
from typing import Optional, Type
|
||||
|
||||
import jieba
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
@@ -11,11 +10,12 @@ from app.db import AsyncSessionFactory
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
from app.log import logger
|
||||
from app.schemas.types import media_type_to_agent
|
||||
from app.utils.jieba import cut as jieba_cut
|
||||
|
||||
|
||||
class QueryTransferHistoryInput(BaseModel):
|
||||
"""查询整理历史记录工具的输入参数模型"""
|
||||
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
title: Optional[str] = Field(None, description="Search by title (optional, supports partial match)")
|
||||
status: Optional[str] = Field("all",
|
||||
description="Filter by status: 'success' for successful transfers, 'failed' for failed transfers, 'all' for all records (default: 'all')")
|
||||
@@ -62,15 +62,15 @@ class QueryTransferHistoryTool(MoviePilotTool):
|
||||
if page is None or page < 1:
|
||||
page = 1
|
||||
|
||||
# 每页记录数
|
||||
count = 50
|
||||
# 每页固定 30 条,与工具说明保持一致,避免整理路径等字段撑大上下文。
|
||||
count = 30
|
||||
|
||||
# 获取数据库会话
|
||||
async with AsyncSessionFactory() as db:
|
||||
# 处理标题搜索
|
||||
if title:
|
||||
# 使用 jieba 分词处理标题
|
||||
words = jieba.cut(title, HMM=False)
|
||||
# 使用统一分词封装处理标题,便于替换底层实现。
|
||||
words = jieba_cut(title, HMM=False)
|
||||
title_search = "%".join(words)
|
||||
# 查询记录
|
||||
result = await TransferHistory.async_list_by_title(
|
||||
|
||||
@@ -13,7 +13,7 @@ from app.log import logger
|
||||
|
||||
class QueryWorkflowsInput(BaseModel):
|
||||
"""查询工作流工具的输入参数模型"""
|
||||
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
state: Optional[str] = Field("all", description="Filter workflows by state: 'W' for waiting, 'R' for running, 'P' for paused, 'S' for success, 'F' for failed, 'all' for all workflows (default: 'all')")
|
||||
name: Optional[str] = Field(None, description="Filter workflows by name (partial match, optional)")
|
||||
trigger_type: Optional[str] = Field("all", description="Filter workflows by trigger type: 'timer' for scheduled, 'event' for event-triggered, 'manual' for manual, 'all' for all types (default: 'all')")
|
||||
@@ -115,9 +115,7 @@ class QueryWorkflowsTool(MoviePilotTool):
|
||||
"last_time": wf.last_time,
|
||||
"current_action": wf.current_action
|
||||
}
|
||||
# 如果有结果,添加结果信息
|
||||
if wf.result:
|
||||
simplified["result"] = wf.result
|
||||
# wf.result 往往是执行日志或上下文快照,不适合作为列表查询结果返回。
|
||||
simplified_workflows.append(simplified)
|
||||
|
||||
result_json = json.dumps(simplified_workflows, ensure_ascii=False, indent=2)
|
||||
|
||||
@@ -15,7 +15,7 @@ from app.schemas.types import media_type_to_agent
|
||||
|
||||
class RecognizeMediaInput(BaseModel):
|
||||
"""识别媒体信息工具的输入参数模型"""
|
||||
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
title: Optional[str] = Field(None, description="The title of the torrent/media to recognize (required for torrent recognition)")
|
||||
subtitle: Optional[str] = Field(None, description="The subtitle or description of the torrent (optional, helps improve recognition accuracy)")
|
||||
path: Optional[str] = Field(None, description="The file path to recognize (required for file recognition, mutually exclusive with title)")
|
||||
@@ -49,8 +49,7 @@ class RecognizeMediaTool(MoviePilotTool):
|
||||
|
||||
try:
|
||||
media_chain = MediaChain()
|
||||
context = None
|
||||
|
||||
|
||||
# 根据提供的参数选择识别方式
|
||||
if path:
|
||||
# 文件路径识别
|
||||
@@ -60,7 +59,10 @@ class RecognizeMediaTool(MoviePilotTool):
|
||||
"message": "文件路径不能为空"
|
||||
}, ensure_ascii=False)
|
||||
|
||||
context = await media_chain.async_recognize_by_path(path)
|
||||
context = await media_chain.async_recognize_by_path(
|
||||
path,
|
||||
obtain_images=False,
|
||||
)
|
||||
if context:
|
||||
return self._format_context_result(context, "文件")
|
||||
else:
|
||||
@@ -73,7 +75,10 @@ class RecognizeMediaTool(MoviePilotTool):
|
||||
elif title:
|
||||
# 种子标题识别
|
||||
metainfo = MetaInfo(title, subtitle)
|
||||
mediainfo = await media_chain.async_recognize_by_meta(metainfo)
|
||||
mediainfo = await media_chain.async_recognize_by_meta(
|
||||
metainfo,
|
||||
obtain_images=False,
|
||||
)
|
||||
if mediainfo:
|
||||
context = Context(meta_info=metainfo, media_info=mediainfo)
|
||||
return self._format_context_result(context, "种子")
|
||||
|
||||
82
app/agent/tools/impl/reload_plugin.py
Normal file
82
app/agent/tools/impl/reload_plugin.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""重载插件工具"""
|
||||
|
||||
import json
|
||||
from typing import Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.impl._plugin_tool_utils import (
|
||||
get_plugin_snapshot,
|
||||
reload_plugin_runtime,
|
||||
)
|
||||
from app.log import logger
|
||||
|
||||
|
||||
class ReloadPluginInput(BaseModel):
|
||||
"""重载插件工具的输入参数模型"""
|
||||
|
||||
explanation: 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.",
|
||||
)
|
||||
|
||||
|
||||
class ReloadPluginTool(MoviePilotTool):
|
||||
name: str = "reload_plugin"
|
||||
description: str = (
|
||||
"Reload an installed plugin so its latest saved configuration takes effect. "
|
||||
"This also refreshes the plugin's registered commands, scheduled services, and API routes."
|
||||
)
|
||||
require_admin: bool = True
|
||||
args_schema: Type[BaseModel] = ReloadPluginInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""生成友好的提示消息"""
|
||||
plugin_id = kwargs.get("plugin_id", "")
|
||||
return f"重载插件: {plugin_id}"
|
||||
|
||||
@staticmethod
|
||||
def _reload_plugin_sync(plugin_id: str) -> str:
|
||||
"""
|
||||
按后台接口同样的流程重载插件,确保最新配置和注册信息一起刷新。
|
||||
"""
|
||||
plugin_info = get_plugin_snapshot(plugin_id)
|
||||
if not plugin_info:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"插件 {plugin_id} 不存在,请先使用 query_installed_plugins 查询有效插件 ID",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
reload_plugin_runtime(plugin_id)
|
||||
refreshed_plugin = get_plugin_snapshot(plugin_id) or plugin_info
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
**refreshed_plugin,
|
||||
"message": "插件已重载,最新配置已生效",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
default=str,
|
||||
)
|
||||
|
||||
async def run(self, plugin_id: str, **kwargs) -> str:
|
||||
logger.info(f"执行工具: {self.name}, 参数: plugin_id={plugin_id}")
|
||||
|
||||
try:
|
||||
return await self.run_blocking(
|
||||
"plugin", self._reload_plugin_sync, plugin_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"重载插件失败: {e}", exc_info=True)
|
||||
return json.dumps(
|
||||
{"success": False, "message": f"重载插件时发生错误: {str(e)}"},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
@@ -6,16 +6,13 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.log import logger
|
||||
from app.scheduler import Scheduler
|
||||
|
||||
|
||||
class RunSchedulerInput(BaseModel):
|
||||
"""运行定时服务工具的输入参数模型"""
|
||||
|
||||
explanation: str = Field(
|
||||
...,
|
||||
description="Clear explanation of why this tool is being used in the current context",
|
||||
)
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
job_id: str = Field(
|
||||
...,
|
||||
description="The ID of the scheduled job to run (can be obtained from query_schedulers tool)",
|
||||
@@ -33,27 +30,28 @@ class RunSchedulerTool(MoviePilotTool):
|
||||
job_id = kwargs.get("job_id", "")
|
||||
return f"运行定时服务 (ID: {job_id})"
|
||||
|
||||
@staticmethod
|
||||
def _run_scheduler_sync(job_id: str) -> tuple[bool, str]:
|
||||
"""同步触发定时服务,避免调度器扫描阻塞事件循环。"""
|
||||
from app.scheduler import Scheduler
|
||||
|
||||
scheduler = Scheduler()
|
||||
for scheduler_item in scheduler.list():
|
||||
if scheduler_item.id == job_id:
|
||||
scheduler.start(job_id)
|
||||
return True, scheduler_item.name
|
||||
return False, ""
|
||||
|
||||
async def run(self, job_id: str, **kwargs) -> str:
|
||||
logger.info(f"执行工具: {self.name}, 参数: job_id={job_id}")
|
||||
|
||||
try:
|
||||
scheduler = Scheduler()
|
||||
# 检查定时服务是否存在
|
||||
schedulers = scheduler.list()
|
||||
job_exists = False
|
||||
job_name = None
|
||||
for s in schedulers:
|
||||
if s.id == job_id:
|
||||
job_exists = True
|
||||
job_name = s.name
|
||||
break
|
||||
|
||||
job_exists, job_name = await self.run_blocking(
|
||||
"workflow", self._run_scheduler_sync, job_id
|
||||
)
|
||||
if not job_exists:
|
||||
return f"定时服务 ID {job_id} 不存在,请使用 query_schedulers 工具查询可用的定时服务"
|
||||
|
||||
# 运行定时服务
|
||||
scheduler.start(job_id)
|
||||
|
||||
return f"成功触发定时服务:{job_name} (ID: {job_id})"
|
||||
except Exception as e:
|
||||
logger.error(f"运行定时服务失败: {e}", exc_info=True)
|
||||
|
||||
@@ -14,10 +14,8 @@ from app.schemas.types import EventType, MessageChannel
|
||||
class RunSlashCommandInput(BaseModel):
|
||||
"""运行斜杠命令工具的输入参数模型"""
|
||||
|
||||
explanation: str = Field(
|
||||
...,
|
||||
description="Clear explanation of why this tool is being used in the current context",
|
||||
)
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
command: str = Field(
|
||||
...,
|
||||
description="The slash command to execute, e.g. '/cookiecloud'. "
|
||||
|
||||
@@ -14,10 +14,8 @@ from app.log import logger
|
||||
class RunWorkflowInput(BaseModel):
|
||||
"""执行工作流工具的输入参数模型"""
|
||||
|
||||
explanation: str = Field(
|
||||
...,
|
||||
description="Clear explanation of why this tool is being used in the current context",
|
||||
)
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
workflow_id: int = Field(
|
||||
..., description="Workflow ID (can be obtained from query_workflows tool)"
|
||||
)
|
||||
@@ -46,6 +44,13 @@ class RunWorkflowTool(MoviePilotTool):
|
||||
|
||||
return message
|
||||
|
||||
@staticmethod
|
||||
def _run_workflow_sync(
|
||||
workflow_id: int, from_begin: Optional[bool] = True
|
||||
) -> tuple[bool, str]:
|
||||
"""同步执行工作流,放到专用线程池避免长流程阻塞 API 响应。"""
|
||||
return WorkflowChain().process(workflow_id, from_begin=from_begin)
|
||||
|
||||
async def run(
|
||||
self, workflow_id: int, from_begin: Optional[bool] = True, **kwargs
|
||||
) -> str:
|
||||
@@ -62,10 +67,12 @@ class RunWorkflowTool(MoviePilotTool):
|
||||
if not workflow:
|
||||
return f"未找到工作流:{workflow_id},请使用 query_workflows 工具查询可用的工作流"
|
||||
|
||||
# 执行工作流
|
||||
workflow_chain = WorkflowChain()
|
||||
state, errmsg = workflow_chain.process(
|
||||
workflow.id, from_begin=from_begin
|
||||
# 工作流执行链路包含大量同步步骤,统一放到 workflow 线程池。
|
||||
state, errmsg = await self.run_blocking(
|
||||
"workflow",
|
||||
self._run_workflow_sync,
|
||||
workflow.id,
|
||||
from_begin,
|
||||
)
|
||||
|
||||
if not state:
|
||||
|
||||
@@ -8,8 +8,6 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.chain.media import MediaChain
|
||||
from app.core.config import global_vars
|
||||
from app.core.metainfo import MetaInfoPath
|
||||
from app.log import logger
|
||||
from app.schemas import FileItem
|
||||
|
||||
@@ -17,10 +15,8 @@ from app.schemas import FileItem
|
||||
class ScrapeMetadataInput(BaseModel):
|
||||
"""刮削媒体元数据工具的输入参数模型"""
|
||||
|
||||
explanation: str = Field(
|
||||
...,
|
||||
description="Clear explanation of why this tool is being used in the current context",
|
||||
)
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
path: str = Field(
|
||||
...,
|
||||
description="Path to the file or directory to scrape metadata for (e.g., '/path/to/file.mkv' or '/path/to/directory')",
|
||||
@@ -81,8 +77,7 @@ class ScrapeMetadataTool(MoviePilotTool):
|
||||
|
||||
# 检查本地存储路径是否存在
|
||||
if storage == "local":
|
||||
scrape_path = Path(path)
|
||||
if not scrape_path.exists():
|
||||
if not Path(path).exists():
|
||||
return json.dumps(
|
||||
{"success": False, "message": f"刮削路径不存在: {path}"},
|
||||
ensure_ascii=False,
|
||||
@@ -90,11 +85,12 @@ class ScrapeMetadataTool(MoviePilotTool):
|
||||
|
||||
# 识别媒体信息
|
||||
media_chain = MediaChain()
|
||||
scrape_path = Path(path)
|
||||
meta = MetaInfoPath(scrape_path)
|
||||
mediainfo = await media_chain.async_recognize_by_meta(meta)
|
||||
context = await media_chain.async_recognize_by_path(
|
||||
path,
|
||||
obtain_images=True,
|
||||
)
|
||||
|
||||
if not mediainfo:
|
||||
if not context or not context.media_info:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
@@ -104,15 +100,14 @@ class ScrapeMetadataTool(MoviePilotTool):
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
# 在线程池中执行同步的刮削操作
|
||||
await global_vars.loop.run_in_executor(
|
||||
None,
|
||||
lambda: media_chain.scrape_metadata(
|
||||
fileitem=fileitem,
|
||||
meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
overwrite=overwrite,
|
||||
),
|
||||
# 刮削会包含磁盘写入和外部图片/元数据访问,统一放到 storage 线程池。
|
||||
await self.run_blocking(
|
||||
"storage",
|
||||
media_chain.scrape_metadata,
|
||||
fileitem=fileitem,
|
||||
meta=context.meta_info,
|
||||
mediainfo=context.media_info,
|
||||
overwrite=overwrite,
|
||||
)
|
||||
|
||||
return json.dumps(
|
||||
@@ -121,11 +116,11 @@ class ScrapeMetadataTool(MoviePilotTool):
|
||||
"message": f"{path} 刮削完成",
|
||||
"path": path,
|
||||
"media_info": {
|
||||
"title": mediainfo.title,
|
||||
"year": mediainfo.year,
|
||||
"type": mediainfo.type.value if mediainfo.type else None,
|
||||
"tmdb_id": mediainfo.tmdb_id,
|
||||
"season": mediainfo.season,
|
||||
"title": context.media_info.title,
|
||||
"year": context.media_info.year,
|
||||
"type": context.media_info.type.value if context.media_info.type else None,
|
||||
"tmdb_id": context.media_info.tmdb_id,
|
||||
"season": context.media_info.season,
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
|
||||
@@ -13,7 +13,7 @@ from app.schemas.types import MediaType, media_type_to_agent
|
||||
|
||||
class SearchMediaInput(BaseModel):
|
||||
"""搜索媒体工具的输入参数模型"""
|
||||
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
title: str = Field(..., description="The title of the media to search for (e.g., 'The Matrix', 'Breaking Bad')")
|
||||
year: Optional[str] = Field(None, description="Release year of the media (optional, helps narrow down results)")
|
||||
media_type: Optional[str] = Field(None,
|
||||
@@ -73,7 +73,7 @@ class SearchMediaTool(MoviePilotTool):
|
||||
filtered_results.append(result)
|
||||
|
||||
if filtered_results:
|
||||
# 限制最多30条结果
|
||||
# 搜索结果只返回前 30 条,后续可通过更精确的年份/类型条件缩小范围。
|
||||
total_count = len(filtered_results)
|
||||
limited_results = filtered_results[:30]
|
||||
# 精简字段,只保留关键信息
|
||||
@@ -96,8 +96,8 @@ class SearchMediaTool(MoviePilotTool):
|
||||
simplified_results.append(simplified)
|
||||
result_json = json.dumps(simplified_results, ensure_ascii=False, indent=2)
|
||||
# 如果结果被裁剪,添加提示信息
|
||||
if total_count > 100:
|
||||
return f"注意:搜索结果共找到 {total_count} 条,为节省上下文空间,仅显示前 100 条结果。\n\n{result_json}"
|
||||
if total_count > len(limited_results):
|
||||
return f"注意:搜索结果共找到 {total_count} 条,为节省上下文空间,仅显示前 {len(limited_results)} 条结果。\n\n{result_json}"
|
||||
return result_json
|
||||
else:
|
||||
return f"未找到符合条件的媒体资源: {title}"
|
||||
|
||||
@@ -12,7 +12,7 @@ from app.log import logger
|
||||
|
||||
class SearchPersonInput(BaseModel):
|
||||
"""搜索人物工具的输入参数模型"""
|
||||
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
name: str = Field(..., description="The name of the person to search for (e.g., 'Tom Hanks', '周杰伦')")
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ class SearchPersonTool(MoviePilotTool):
|
||||
persons = await media_chain.async_search_persons(name=name)
|
||||
|
||||
if persons:
|
||||
# 限制最多30条结果
|
||||
# 人物搜索结果只返回前 30 条,避免 biography/别名等字段挤占上下文。
|
||||
total_count = len(persons)
|
||||
limited_persons = persons[:30]
|
||||
# 精简字段,只保留关键信息
|
||||
@@ -72,8 +72,8 @@ class SearchPersonTool(MoviePilotTool):
|
||||
|
||||
result_json = json.dumps(simplified_results, ensure_ascii=False, indent=2)
|
||||
# 如果结果被裁剪,添加提示信息
|
||||
if total_count > 50:
|
||||
return f"注意:搜索结果共找到 {total_count} 条,为节省上下文空间,仅显示前 50 条结果。\n\n{result_json}"
|
||||
if total_count > len(limited_persons):
|
||||
return f"注意:搜索结果共找到 {total_count} 条,为节省上下文空间,仅显示前 {len(limited_persons)} 条结果。\n\n{result_json}"
|
||||
return result_json
|
||||
else:
|
||||
return f"未找到相关人物信息: {name}"
|
||||
|
||||
@@ -14,7 +14,7 @@ from app.log import logger
|
||||
|
||||
class SearchPersonCreditsInput(BaseModel):
|
||||
"""搜索演员参演作品工具的输入参数模型"""
|
||||
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
person_id: int = Field(..., description="The ID of the person/actor to search for credits (e.g., 31 for Tom Hanks in TMDB)")
|
||||
source: str = Field(..., description="The data source: 'tmdb' for TheMovieDB, 'douban' for Douban, 'bangumi' for Bangumi")
|
||||
page: Optional[int] = Field(1, description="Page number for pagination (default: 1)")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user