mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-26 02:30:20 +08:00
Compare commits
239 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8233e1789 | ||
|
|
83107bf447 | ||
|
|
3a2f90c567 | ||
|
|
4826e3301c | ||
|
|
1855ba81ec | ||
|
|
96ef431efc | ||
|
|
4f2935c85e | ||
|
|
2a49495e27 | ||
|
|
b628bc7209 | ||
|
|
29068a5846 | ||
|
|
51a7120c79 | ||
|
|
476dfef7d9 | ||
|
|
bd5ddd6158 | ||
|
|
a30a48b8f4 | ||
|
|
8e60e5571b | ||
|
|
18c1ec4b82 | ||
|
|
30b932e07e | ||
|
|
54be1143fc | ||
|
|
13f27854fd | ||
|
|
770201c48c | ||
|
|
685f044312 | ||
|
|
8c0afac5d1 | ||
|
|
099ef7d5bf | ||
|
|
f3ac69669c | ||
|
|
eb4ecd990a | ||
|
|
b51971ee7d | ||
|
|
6f6ed998bb | ||
|
|
844407dc41 | ||
|
|
c54605f8ce | ||
|
|
0fbf05d72f | ||
|
|
09bb32f681 | ||
|
|
a37f118576 | ||
|
|
e635bc8e04 | ||
|
|
8245124e82 | ||
|
|
827ed8330c | ||
|
|
136c1baed3 | ||
|
|
992031ef95 | ||
|
|
b16c50b03a | ||
|
|
76803ae7a3 | ||
|
|
56bda11947 | ||
|
|
1b12d7664e | ||
|
|
db9960d9b9 | ||
|
|
2f0c1252da | ||
|
|
36d4434596 | ||
|
|
93e907d032 | ||
|
|
132f27c1c6 | ||
|
|
b231ad415f | ||
|
|
0f183ae08e | ||
|
|
a71d3ea03f | ||
|
|
7f82a9ea4d | ||
|
|
d977e4c48a | ||
|
|
95b6adbeee | ||
|
|
964fee1106 | ||
|
|
656473f3aa | ||
|
|
ab5995a609 | ||
|
|
064e6535d5 | ||
|
|
cab2ac400a | ||
|
|
d14d401c86 | ||
|
|
6c3c5e042d | ||
|
|
f3e5be37fd | ||
|
|
d8f7fa70af | ||
|
|
6916ee0988 | ||
|
|
6fef533527 | ||
|
|
c57985d553 | ||
|
|
ec07379a67 | ||
|
|
2764742b86 | ||
|
|
a0f613fa1e | ||
|
|
73d5c95f4e | ||
|
|
4d30dee74c | ||
|
|
302d8bbf5c | ||
|
|
b646cbb4f6 | ||
|
|
dd73b97095 | ||
|
|
0cb0bac0e1 | ||
|
|
9eb71c744b | ||
|
|
8bf826faa0 | ||
|
|
df4e45c644 | ||
|
|
494f809ef0 | ||
|
|
a4f6e13881 | ||
|
|
36fb82b7aa | ||
|
|
9b1bdb0cb2 | ||
|
|
2a89bfd25c | ||
|
|
d3d1c18316 | ||
|
|
027330f714 | ||
|
|
6fc1c672ad | ||
|
|
d383c9ffd1 | ||
|
|
27e1f634cb | ||
|
|
a9197c434e | ||
|
|
2a8708498c | ||
|
|
098db56a36 | ||
|
|
a8ccb08dd3 | ||
|
|
ff7a238309 | ||
|
|
318cfe68e9 | ||
|
|
0f98cda3b4 | ||
|
|
544ed6d84d | ||
|
|
bb9b6ec5d0 | ||
|
|
7d2a730b0c | ||
|
|
1b6a548dee | ||
|
|
52c5f2900f | ||
|
|
43e89ebf77 | ||
|
|
bc52653ec1 | ||
|
|
4233ebfba6 | ||
|
|
af32c5e9bb | ||
|
|
cd02e55879 | ||
|
|
d49c57b11e | ||
|
|
724b4a59d5 | ||
|
|
109be58abe | ||
|
|
c1d8676b25 | ||
|
|
c1cefa3f40 | ||
|
|
0c53fb86fd | ||
|
|
126279c63b | ||
|
|
d7aa66853a | ||
|
|
d18c2d6f72 | ||
|
|
3e3883a57f | ||
|
|
2ebe7c27c2 | ||
|
|
0cd049bfc2 | ||
|
|
dc773337d3 | ||
|
|
5c649ff1d1 | ||
|
|
3407cc8edd | ||
|
|
f9ea0118d9 | ||
|
|
ad73434e2c | ||
|
|
a6afa0fbc0 | ||
|
|
3306d196b7 | ||
|
|
e44a6f41b5 | ||
|
|
7a7b27858e | ||
|
|
cf8e7438e2 | ||
|
|
647c04956d | ||
|
|
7358b4df14 | ||
|
|
3c74f1bf58 | ||
|
|
8938ae7baa | ||
|
|
1f965f5948 | ||
|
|
8b05decc2d | ||
|
|
1f97870fa9 | ||
|
|
78ddd6093f | ||
|
|
6647565ec4 | ||
|
|
43d1abdec8 | ||
|
|
ce51a20bdb | ||
|
|
6c45ade813 | ||
|
|
90efb204a1 | ||
|
|
495807ef4d | ||
|
|
d483b805d8 | ||
|
|
5c1b303908 | ||
|
|
683e07a102 | ||
|
|
68f18db374 | ||
|
|
99e369aaa4 | ||
|
|
18803c7995 | ||
|
|
6b21abd547 | ||
|
|
f0368e359a | ||
|
|
e02cebe16c | ||
|
|
b395d820d8 | ||
|
|
970f2cf1ca | ||
|
|
c065eddff1 | ||
|
|
33b18f0899 | ||
|
|
d2103f91b8 | ||
|
|
66feacb48d | ||
|
|
7f1cb40421 | ||
|
|
38c3dcc76b | ||
|
|
a9534d2422 | ||
|
|
013f3bc505 | ||
|
|
de9fd75cac | ||
|
|
570ea60096 | ||
|
|
e02650cce9 | ||
|
|
60c7268301 | ||
|
|
e8f6e8647b | ||
|
|
bd53598704 | ||
|
|
4d87bf8d53 | ||
|
|
84eee40e81 | ||
|
|
d2e2435be7 | ||
|
|
ccaeb7662c | ||
|
|
69ed70cc66 | ||
|
|
80d440f6a0 | ||
|
|
bfa2cf5c1f | ||
|
|
5e1bdfe725 | ||
|
|
a516bc1c77 | ||
|
|
e1ba9a2c97 | ||
|
|
4c20639abb | ||
|
|
4ecbc677eb | ||
|
|
846eed6821 | ||
|
|
cff22924b1 | ||
|
|
5c7c1512dd | ||
|
|
d4b6d3f332 | ||
|
|
039558d240 | ||
|
|
93056ed1ff | ||
|
|
d5bac81881 | ||
|
|
a077a08303 | ||
|
|
e78efe3e34 | ||
|
|
e8ae686d4f | ||
|
|
1c60d8ccd7 | ||
|
|
8ad8b5eaad | ||
|
|
af23baec6d | ||
|
|
94b8252fdd | ||
|
|
785f11af0e | ||
|
|
726bc5f2aa | ||
|
|
bae820a11d | ||
|
|
47f6389424 | ||
|
|
6a635ac720 | ||
|
|
d2803bed1e | ||
|
|
8dc1cf53eb | ||
|
|
a93815b18a | ||
|
|
ef36af8a82 | ||
|
|
c87b856ddf | ||
|
|
0f42a0fb8c | ||
|
|
2b031e7e05 | ||
|
|
70831c27b3 | ||
|
|
bef2a81296 | ||
|
|
d0dcf6660f | ||
|
|
4e3eddec10 | ||
|
|
93713ba662 | ||
|
|
0f3e9574ab | ||
|
|
25dbe491fe | ||
|
|
d6db0a86f6 | ||
|
|
6e8bce3d04 | ||
|
|
ed1e31d379 | ||
|
|
3a233014de | ||
|
|
13cb1683ff | ||
|
|
ac9132cba6 | ||
|
|
6f5f1aa457 | ||
|
|
4547edc696 | ||
|
|
8d4412463c | ||
|
|
0189463a09 | ||
|
|
a654686ce7 | ||
|
|
eb9ea1c5c5 | ||
|
|
84b4a7eca2 | ||
|
|
a37ed9aa97 | ||
|
|
b89c351686 | ||
|
|
303e7ee16e | ||
|
|
ab9eeedb3e | ||
|
|
7d582cc4d8 | ||
|
|
e27a9ba486 | ||
|
|
51c2843dd0 | ||
|
|
8c73b87f6e | ||
|
|
a10361cc2f | ||
|
|
dfabd695a8 | ||
|
|
735a1ebf27 | ||
|
|
10dcb3727e | ||
|
|
616c355438 | ||
|
|
24dc53b62d | ||
|
|
1b83abe155 | ||
|
|
765b286fd7 | ||
|
|
83cc7ea716 |
27
.coveragerc
Normal file
27
.coveragerc
Normal file
@@ -0,0 +1,27 @@
|
||||
[run]
|
||||
branch = True
|
||||
source = app
|
||||
omit =
|
||||
app/plugins/*/*
|
||||
app/testing/*
|
||||
app/helper/sites.py
|
||||
|
||||
[report]
|
||||
show_missing = True
|
||||
skip_empty = True
|
||||
precision = 2
|
||||
exclude_lines =
|
||||
pragma: no cover
|
||||
if TYPE_CHECKING:
|
||||
if __name__ == .__main__.:
|
||||
raise NotImplementedError
|
||||
pass
|
||||
|
||||
[html]
|
||||
directory = htmlcov
|
||||
|
||||
[xml]
|
||||
output = coverage.xml
|
||||
|
||||
[json]
|
||||
output = coverage.json
|
||||
16
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
16
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -7,11 +7,13 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
请说明你希望添加的功能。
|
||||
|
||||
站点适配请求请先按 [站点适配采集说明](https://github.com/jxxghp/MoviePilot/blob/v2/docs/site-adapter-capture.md) 生成脱敏 ZIP,并在下方附加。Issue 及附件是公开内容,提交前必须解压预览四个文件。不要上传 Cookie、Authorization、通行密钥、会话字段或任何原始数据。
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: 当前程序版本
|
||||
description: 目前使用的程序版本
|
||||
description: 目前使用的程序版本;仅提供站点采集文件且未安装 MoviePilot 时填写“不适用”
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
@@ -22,6 +24,9 @@ body:
|
||||
options:
|
||||
- Docker
|
||||
- Windows
|
||||
- macOS
|
||||
- Linux
|
||||
- 仅提供站点采集文件
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
@@ -32,6 +37,7 @@ body:
|
||||
options:
|
||||
- 主程序
|
||||
- 插件
|
||||
- 站点适配
|
||||
- 其他
|
||||
validations:
|
||||
required: true
|
||||
@@ -43,6 +49,14 @@ body:
|
||||
placeholder: "功能改进"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: site-adapter-capture
|
||||
attributes:
|
||||
label: 站点适配采集文件
|
||||
description: 站点适配请求必须把采集器生成并人工预览确认过的脱敏 ZIP 拖到这里;Issue 附件公开,严禁附加 Cookie、原始 HTML、HAR 或浏览器网络归档。其他类型请填写“不适用”。
|
||||
placeholder: "将 moviepilot-site-capture-*.zip 拖到这里;非站点适配填写:不适用"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: references
|
||||
attributes:
|
||||
|
||||
536
.github/workflows/pr-agent.yml
vendored
Normal file
536
.github/workflows/pr-agent.yml
vendored
Normal file
@@ -0,0 +1,536 @@
|
||||
name: PR-Agent
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
# PR-Agent 通过 base repo 上下文读取 PR diff 并发布 Review,不 checkout 或执行 PR 分支代码。
|
||||
# pull_request_target 允许 fork PR 使用仓库 secrets,因此 workflow 只运行固定 digest 的 PR-Agent 容器。
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- ready_for_review
|
||||
- review_requested
|
||||
- synchronize
|
||||
issue_comment:
|
||||
# 手动命令只在 PR 评论中有意义;编辑后的命令也可重新触发。
|
||||
types:
|
||||
- created
|
||||
- edited
|
||||
|
||||
permissions:
|
||||
# 读取仓库内容和 PR diff。
|
||||
contents: read
|
||||
# 更新 PR 描述、发布 PR Review 或修改 PR 相关元数据。
|
||||
pull-requests: write
|
||||
# PR 评论在 GitHub API 中属于 issue comments,手动问答需要该权限。
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
pr-agent:
|
||||
if: >-
|
||||
github.event.sender.type != 'Bot' &&
|
||||
(
|
||||
(
|
||||
github.event_name == 'pull_request_target'
|
||||
) ||
|
||||
(
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request != null &&
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'), github.event.comment.author_association) &&
|
||||
(
|
||||
github.event.comment.body == '/describe' ||
|
||||
startsWith(github.event.comment.body, '/describe ') ||
|
||||
github.event.comment.body == '/review' ||
|
||||
startsWith(github.event.comment.body, '/review ') ||
|
||||
github.event.comment.body == '/ask' ||
|
||||
startsWith(github.event.comment.body, '/ask ')
|
||||
)
|
||||
)
|
||||
)
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request_target' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Capture PR context
|
||||
id: pr_context
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pull="$(mktemp)"
|
||||
gh api "repos/${REPO}/pulls/${PR_NUMBER}" > "${pull}"
|
||||
python3 - "${pull}" >> "${GITHUB_OUTPUT}" <<'PY'
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
pull = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||
title = pull.get("title") or ""
|
||||
body = pull.get("body") or ""
|
||||
labels = {item.get("name", "") for item in pull.get("labels") or []}
|
||||
skip_pr_agent = "true" if "skip pr-agent" in labels or re.search(r"^(?:\[Auto\]|Auto)", title) else "false"
|
||||
body = re.sub(
|
||||
r"<!-- pr-agent-summary:start -->.*?<!-- pr-agent-summary:end -->",
|
||||
" ",
|
||||
body,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
text = f"{title}\n{body}"
|
||||
cjk_count = len(re.findall(r"[\u4e00-\u9fff]", text))
|
||||
latin_words = len(re.findall(r"\b[A-Za-z][A-Za-z]{2,}\b", text))
|
||||
if cjk_count >= 4 or latin_words < 8:
|
||||
response_language = "zh-CN"
|
||||
summary_heading = "PR-Agent 摘要"
|
||||
else:
|
||||
response_language = "en-US"
|
||||
summary_heading = "PR-Agent Summary"
|
||||
print(f"head_sha={pull['head']['sha']}")
|
||||
print(f"changed_files={pull.get('changed_files') or 0}")
|
||||
print(f"response_language={response_language}")
|
||||
print(f"summary_heading={summary_heading}")
|
||||
print(f"skip_pr_agent={skip_pr_agent}")
|
||||
PY
|
||||
|
||||
- name: Prepare PR-Agent description markers
|
||||
if: >-
|
||||
steps.pr_context.outputs.skip_pr_agent != 'true' &&
|
||||
(
|
||||
github.event_name == 'pull_request_target' ||
|
||||
github.event.comment.body == '/describe' ||
|
||||
startsWith(github.event.comment.body, '/describe ')
|
||||
)
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
SUMMARY_HEADING: ${{ steps.pr_context.outputs.summary_heading }}
|
||||
CHANGED_FILES: ${{ steps.pr_context.outputs.changed_files }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
payload="$(mktemp)"
|
||||
python3 - "${payload}" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
pull = json.loads(subprocess.check_output(
|
||||
["gh", "api", f"repos/{os.environ['REPO']}/pulls/{os.environ['PR_NUMBER']}"],
|
||||
text=True,
|
||||
))
|
||||
body = pull.get("body") or ""
|
||||
start = "<!-- pr-agent-summary:start -->"
|
||||
end = "<!-- pr-agent-summary:end -->"
|
||||
heading = os.environ.get("SUMMARY_HEADING") or "PR-Agent 摘要"
|
||||
changed_files = int(os.environ.get("CHANGED_FILES") or 0)
|
||||
block = f"## {heading}\n\n{start}\npr_agent:summary\n{end}"
|
||||
start_index = body.find(start)
|
||||
end_index = body.find(end, start_index + len(start)) if start_index >= 0 else -1
|
||||
owned_block = re.compile(
|
||||
r"(?ims)^##\s+(?:PR-Agent\s+摘要|PR-Agent\s+Summary)\s*\n\s*"
|
||||
r"<!-- pr-agent-summary:start -->.*?<!-- pr-agent-summary:end -->\s*"
|
||||
)
|
||||
if changed_files == 0:
|
||||
updated = owned_block.sub("", body)
|
||||
if updated != body:
|
||||
updated = updated.rstrip()
|
||||
elif start_index >= 0 and end_index >= 0:
|
||||
body = re.sub(
|
||||
r"(?im)^##\s+(PR-Agent\s+摘要|PR-Agent\s+Summary)\s*\n\s*(?=<!-- pr-agent-summary:start -->)",
|
||||
f"## {heading}\n\n",
|
||||
body,
|
||||
)
|
||||
start_index = body.find(start)
|
||||
end_index = body.find(end, start_index + len(start))
|
||||
updated = body[:start_index] + f"{start}\npr_agent:summary\n{end}" + body[end_index + len(end):]
|
||||
elif start_index >= 0 or end in body:
|
||||
updated = body
|
||||
elif body.strip():
|
||||
updated = f"{body.rstrip()}\n\n{block}\n"
|
||||
else:
|
||||
updated = f"{block}\n"
|
||||
if updated != body:
|
||||
with open(sys.argv[1], "w", encoding="utf-8") as handle:
|
||||
json.dump({"body": updated}, handle, ensure_ascii=False)
|
||||
PY
|
||||
if [ -s "${payload}" ]; then
|
||||
gh api --method PATCH "repos/${REPO}/pulls/${PR_NUMBER}" --input "${payload}" >/dev/null
|
||||
fi
|
||||
|
||||
- name: Update PR description
|
||||
if: >-
|
||||
steps.pr_context.outputs.skip_pr_agent != 'true' &&
|
||||
steps.pr_context.outputs.changed_files != '0' &&
|
||||
(
|
||||
github.event_name == 'pull_request_target' ||
|
||||
github.event.comment.body == '/describe' ||
|
||||
startsWith(github.event.comment.body, '/describe ')
|
||||
)
|
||||
uses: docker://pragent/pr-agent:0.39.0-github_action@sha256:b253845caa8c7ff5ce8be78f32996647982bdd4890826a962b78eff2e385a825
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
|
||||
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
|
||||
config.model: 'gpt-5.6-terra'
|
||||
config.fallback_models: '["gpt-5.5", "gpt-5.4"]'
|
||||
config.custom_model_max_tokens: '1050000'
|
||||
config.reasoning_effort: 'medium'
|
||||
config.ai_timeout: '900'
|
||||
config.response_language: ${{ steps.pr_context.outputs.response_language }}
|
||||
config.large_patch_policy: 'clip'
|
||||
config.ignore_pr_title: '["^\\[Auto\\]", "^Auto"]'
|
||||
config.ignore_pr_labels: '["skip pr-agent"]'
|
||||
github_action_config.auto_review: 'false'
|
||||
github_action_config.auto_describe: 'true'
|
||||
github_action_config.auto_improve: 'false'
|
||||
github_action_config.pr_actions: '["opened", "reopened", "ready_for_review", "review_requested"]'
|
||||
github_action_config.handle_push_trigger: 'true'
|
||||
github_action_config.push_commands: '["/describe"]'
|
||||
pr_description.generate_ai_title: 'false'
|
||||
pr_description.publish_labels: 'false'
|
||||
pr_description.publish_description_as_comment: 'false'
|
||||
pr_description.publish_description_as_comment_persistent: 'false'
|
||||
pr_description.enable_pr_diagram: 'false'
|
||||
pr_description.enable_pr_type: 'false'
|
||||
pr_description.enable_help_text: 'false'
|
||||
pr_description.enable_help_comment: 'false'
|
||||
pr_description.enable_semantic_files_types: 'false'
|
||||
pr_description.collapsible_file_list: 'adaptive'
|
||||
pr_description.add_original_user_description: 'true'
|
||||
pr_description.use_description_markers: 'true'
|
||||
pr_description.final_update_message: 'false'
|
||||
pr_description.extra_instructions: |
|
||||
Match the configured response language.
|
||||
Summarize the change goal, key implementation details, compatibility impact, tests, and notable risks.
|
||||
Use 2-4 bullets for small pull requests and 4-8 bullets for larger changes.
|
||||
Avoid file lists and local command transcripts.
|
||||
|
||||
- name: Remove unfilled PR-Agent description marker
|
||||
if: >-
|
||||
always() &&
|
||||
steps.pr_context.outputs.skip_pr_agent != 'true' &&
|
||||
(
|
||||
github.event_name == 'pull_request_target' ||
|
||||
github.event.comment.body == '/describe' ||
|
||||
startsWith(github.event.comment.body, '/describe ')
|
||||
)
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
payload="$(mktemp)"
|
||||
python3 - "${payload}" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
pull = json.loads(subprocess.check_output(
|
||||
["gh", "api", f"repos/{os.environ['REPO']}/pulls/{os.environ['PR_NUMBER']}"],
|
||||
text=True,
|
||||
))
|
||||
body = pull.get("body") or ""
|
||||
placeholder = "<!-- pr-agent-summary:start -->\npr_agent:summary\n<!-- pr-agent-summary:end -->"
|
||||
owned_block = re.compile(
|
||||
r"(?ims)^##\s+(?:PR-Agent\s+摘要|PR-Agent\s+Summary)\s*\n\s*"
|
||||
r"<!-- pr-agent-summary:start -->\s*pr_agent:summary\s*<!-- pr-agent-summary:end -->\s*"
|
||||
)
|
||||
if placeholder in body:
|
||||
updated = owned_block.sub("", body).rstrip()
|
||||
if updated != body:
|
||||
with open(sys.argv[1], "w", encoding="utf-8") as handle:
|
||||
json.dump({"body": updated}, handle, ensure_ascii=False)
|
||||
PY
|
||||
if [ -s "${payload}" ]; then
|
||||
gh api --method PATCH "repos/${REPO}/pulls/${PR_NUMBER}" --input "${payload}" >/dev/null
|
||||
fi
|
||||
|
||||
- name: Analyze PR review
|
||||
id: review_analysis
|
||||
if: >-
|
||||
steps.pr_context.outputs.skip_pr_agent != 'true' &&
|
||||
(
|
||||
github.event_name == 'pull_request_target' ||
|
||||
github.event.comment.body == '/review' ||
|
||||
startsWith(github.event.comment.body, '/review ')
|
||||
)
|
||||
uses: docker://pragent/pr-agent:0.39.0-github_action@sha256:b253845caa8c7ff5ce8be78f32996647982bdd4890826a962b78eff2e385a825
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
|
||||
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
|
||||
config.model: ${{ github.event_name == 'issue_comment' && 'gpt-5.6-sol' || 'gpt-5.6-terra' }}
|
||||
config.fallback_models: '["gpt-5.5", "gpt-5.4"]'
|
||||
config.custom_model_max_tokens: '1050000'
|
||||
config.reasoning_effort: 'xhigh'
|
||||
config.ai_timeout: '900'
|
||||
config.response_language: ${{ steps.pr_context.outputs.response_language }}
|
||||
config.large_patch_policy: 'clip'
|
||||
config.ignore_pr_title: '["^\\[Auto\\]", "^Auto"]'
|
||||
config.ignore_pr_labels: '["skip pr-agent"]'
|
||||
config.publish_output: 'false'
|
||||
github_action_config.auto_review: 'true'
|
||||
github_action_config.auto_describe: 'false'
|
||||
github_action_config.auto_improve: 'false'
|
||||
github_action_config.pr_actions: '["opened", "reopened", "ready_for_review", "review_requested"]'
|
||||
github_action_config.handle_push_trigger: 'true'
|
||||
github_action_config.push_commands: '["/review"]'
|
||||
github_action_config.enable_output: 'true'
|
||||
pr_reviewer.num_max_findings: '4'
|
||||
pr_reviewer.require_score_review: 'false'
|
||||
pr_reviewer.require_tests_review: 'false'
|
||||
pr_reviewer.require_security_review: 'false'
|
||||
pr_reviewer.require_estimate_effort_to_review: 'false'
|
||||
pr_reviewer.require_estimate_contribution_time_cost: 'false'
|
||||
pr_reviewer.require_can_be_split_review: 'false'
|
||||
pr_reviewer.require_todo_scan: 'false'
|
||||
pr_reviewer.require_ticket_analysis_review: 'false'
|
||||
pr_reviewer.enable_review_labels_effort: 'false'
|
||||
pr_reviewer.enable_review_labels_security: 'false'
|
||||
pr_reviewer.extra_instructions: |
|
||||
Return key_issues_to_review only for concrete behavior defects introduced by this pull request.
|
||||
Each finding must identify the affected behavior, a reachable trigger, and the existing contract or invariant it violates.
|
||||
Use issue_content to state the smallest correction boundary, not a code patch.
|
||||
Do not report style preferences, comments, refactors, architecture alternatives, speculative races, extra hardening, optional tests, or hypothetical concerns.
|
||||
Return no findings when the evidence is incomplete.
|
||||
|
||||
- name: Publish review comments and summary
|
||||
if: >-
|
||||
steps.pr_context.outputs.skip_pr_agent != 'true' &&
|
||||
steps.review_analysis.outcome == 'success' &&
|
||||
(
|
||||
github.event_name == 'pull_request_target' ||
|
||||
github.event.comment.body == '/review' ||
|
||||
startsWith(github.event.comment.body, '/review ')
|
||||
)
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
REVIEWED_HEAD_SHA: ${{ steps.pr_context.outputs.head_sha }}
|
||||
CHANGED_FILES: ${{ steps.pr_context.outputs.changed_files }}
|
||||
RESPONSE_LANGUAGE: ${{ steps.pr_context.outputs.response_language }}
|
||||
REVIEW_JSON: ${{ steps.review_analysis.outputs.review }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
current_head_sha="$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.head.sha')"
|
||||
if [ "${current_head_sha}" != "${REVIEWED_HEAD_SHA}" ]; then
|
||||
echo "PR head changed during analysis; skip stale review publication."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
review_payload="$(mktemp)"
|
||||
python3 - "${review_payload}" <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from urllib.parse import quote
|
||||
|
||||
review_raw = os.environ.get("REVIEW_JSON") or "{}"
|
||||
review = json.loads(review_raw)
|
||||
if not review_raw.strip() or review == {}:
|
||||
if int(os.environ.get("CHANGED_FILES") or 0):
|
||||
raise SystemExit("Review analysis produced no structured output for a non-empty PR.")
|
||||
review = {}
|
||||
|
||||
repo = os.environ["REPO"]
|
||||
number = os.environ["PR_NUMBER"]
|
||||
head_sha = os.environ["REVIEWED_HEAD_SHA"]
|
||||
language = os.environ.get("RESPONSE_LANGUAGE") or "zh-CN"
|
||||
|
||||
def paged(endpoint):
|
||||
result = json.loads(subprocess.check_output(
|
||||
["gh", "api", "--paginate", "--slurp", endpoint], text=True
|
||||
))
|
||||
if result and all(isinstance(page, list) for page in result):
|
||||
return [item for page in result for item in page]
|
||||
return result
|
||||
|
||||
files = paged(f"repos/{repo}/pulls/{number}/files?per_page=100")
|
||||
comments = paged(f"repos/{repo}/pulls/{number}/comments?per_page=100")
|
||||
reviews = paged(f"repos/{repo}/pulls/{number}/reviews?per_page=100")
|
||||
|
||||
hunk_pattern = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@")
|
||||
changed_lines = {}
|
||||
for file_data in files:
|
||||
path = str(file_data.get("filename") or "")
|
||||
line = None
|
||||
lines = set()
|
||||
for patch_line in (file_data.get("patch") or "").splitlines():
|
||||
hunk = hunk_pattern.match(patch_line)
|
||||
if hunk:
|
||||
line = int(hunk.group(1))
|
||||
continue
|
||||
if line is None or patch_line.startswith("\\"):
|
||||
continue
|
||||
if patch_line.startswith("+") and not patch_line.startswith("+++"):
|
||||
lines.add(line)
|
||||
line += 1
|
||||
elif patch_line.startswith("-") and not patch_line.startswith("---"):
|
||||
continue
|
||||
else:
|
||||
line += 1
|
||||
changed_lines[path] = lines
|
||||
|
||||
def fingerprint(path, line):
|
||||
normalized = "\n".join((path, str(line)))
|
||||
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
current_fingerprints = set()
|
||||
current_locations = set()
|
||||
marker_pattern = re.compile(r"<!-- pr-agent-review:([0-9a-f]{16}) -->")
|
||||
for comment in comments:
|
||||
if comment.get("user", {}).get("login") != "github-actions[bot]":
|
||||
continue
|
||||
if comment.get("line") is None:
|
||||
continue
|
||||
match = marker_pattern.search(str(comment.get("body") or ""))
|
||||
if match:
|
||||
current_fingerprints.add(match.group(1))
|
||||
path = str(comment.get("path") or "")
|
||||
try:
|
||||
line = int(comment.get("line") or 0)
|
||||
except (TypeError, ValueError):
|
||||
line = 0
|
||||
if path and line > 0:
|
||||
current_locations.add((path, line))
|
||||
|
||||
def code_url(path, line):
|
||||
return f"https://github.com/{repo}/blob/{head_sha}/{quote(path, safe='/')}#L{line}"
|
||||
|
||||
issues = review.get("key_issues_to_review") or []
|
||||
findings = []
|
||||
seen = set()
|
||||
for issue in issues:
|
||||
if not isinstance(issue, dict):
|
||||
continue
|
||||
path = str(issue.get("relevant_file") or "").strip()
|
||||
header = str(issue.get("issue_header") or "").strip()
|
||||
content = str(issue.get("issue_content") or "").strip()
|
||||
try:
|
||||
line = int(issue.get("start_line") or 0)
|
||||
except (TypeError, ValueError):
|
||||
line = 0
|
||||
if not path or not header or not content or line < 1:
|
||||
continue
|
||||
finding_key = (path, line, header.lower(), " ".join(content.split()).lower())
|
||||
if finding_key in seen:
|
||||
continue
|
||||
seen.add(finding_key)
|
||||
findings.append({
|
||||
"path": path,
|
||||
"line": line,
|
||||
"header": header,
|
||||
"content": content,
|
||||
"fingerprint": fingerprint(path, line),
|
||||
})
|
||||
|
||||
new_comments = []
|
||||
for finding in findings:
|
||||
if finding["line"] not in changed_lines.get(finding["path"], set()):
|
||||
continue
|
||||
if finding["fingerprint"] in current_fingerprints or (finding["path"], finding["line"]) in current_locations:
|
||||
continue
|
||||
new_comments.append({
|
||||
"path": finding["path"],
|
||||
"line": finding["line"],
|
||||
"side": "RIGHT",
|
||||
"body": "\n".join([
|
||||
f"<!-- pr-agent-review:{finding['fingerprint']} -->",
|
||||
f"**{finding['header']}**",
|
||||
"",
|
||||
finding["content"],
|
||||
]),
|
||||
})
|
||||
|
||||
marker = "<!-- pr-agent-review-summary -->"
|
||||
short_sha = head_sha[:7]
|
||||
commit_url = f"https://github.com/{repo}/commit/{head_sha}"
|
||||
chinese = language == "zh-CN"
|
||||
lines = [marker, "## PR-Agent Code Review", ""]
|
||||
if findings:
|
||||
for finding in findings:
|
||||
location = f"{finding['path']}:{finding['line']}"
|
||||
concise = " ".join(finding["content"].split())[:360]
|
||||
separator = ":" if chinese else ":"
|
||||
lines.append(f"- [{location}]({code_url(finding['path'], finding['line'])}){separator} **{finding['header']}** - {concise}")
|
||||
elif chinese:
|
||||
lines.append("本次变更无需提出审查意见,暂无其他反馈。")
|
||||
else:
|
||||
lines.append("There are no review comments for the current changes. I have no additional feedback to provide.")
|
||||
lines.extend([
|
||||
"",
|
||||
f"审查提交:[{short_sha}]({commit_url})" if chinese else f"Reviewed commit: [{short_sha}]({commit_url})",
|
||||
"",
|
||||
])
|
||||
payload = {
|
||||
"body": "\n".join(lines),
|
||||
"commit_id": head_sha,
|
||||
"event": "COMMENT",
|
||||
}
|
||||
if new_comments:
|
||||
payload["comments"] = new_comments
|
||||
has_matching_summary = not new_comments and any(
|
||||
existing.get("user", {}).get("login") == "github-actions[bot]"
|
||||
and existing.get("commit_id") == head_sha
|
||||
and str(existing.get("body") or "") == payload["body"]
|
||||
for existing in reviews
|
||||
)
|
||||
# 同一提交的手工重审仍会完成分析;完全相同的结果不重复发布 Review。
|
||||
if not has_matching_summary:
|
||||
with open(sys.argv[1], "w", encoding="utf-8") as handle:
|
||||
json.dump(payload, handle, ensure_ascii=False)
|
||||
PY
|
||||
|
||||
latest_head_sha="$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.head.sha')"
|
||||
if [ "${latest_head_sha}" != "${REVIEWED_HEAD_SHA}" ]; then
|
||||
echo "PR head changed while rendering review; skip stale review publication."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -s "${review_payload}" ]; then
|
||||
gh api --method POST "repos/${REPO}/pulls/${PR_NUMBER}/reviews" --input "${review_payload}" >/dev/null
|
||||
fi
|
||||
old_summary_ids="$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments?per_page=100" --jq ".[] | select(.user.login == \"github-actions[bot]\" and ((.body | startswith(\"<!-- pr-agent-update-notification -->\")) or (.body | startswith(\"<!-- pr-agent-code-review-summary -->\")) or (.body | startswith(\"<!-- pr-agent-review-summary -->\")) or (.body | startswith(\"<!-- pr-agent-lab:review -->\")))) | .id")"
|
||||
while IFS= read -r comment_id; do
|
||||
[ -z "${comment_id}" ] && continue
|
||||
gh api --method DELETE "repos/${REPO}/issues/comments/${comment_id}" >/dev/null
|
||||
done <<< "${old_summary_ids}"
|
||||
|
||||
- name: Answer PR question
|
||||
if: >-
|
||||
steps.pr_context.outputs.skip_pr_agent != 'true' &&
|
||||
github.event_name == 'issue_comment' &&
|
||||
(
|
||||
github.event.comment.body == '/ask' ||
|
||||
startsWith(github.event.comment.body, '/ask ')
|
||||
)
|
||||
uses: docker://pragent/pr-agent:0.39.0-github_action@sha256:b253845caa8c7ff5ce8be78f32996647982bdd4890826a962b78eff2e385a825
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
|
||||
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
|
||||
config.model: 'gpt-5.6-terra'
|
||||
config.fallback_models: '["gpt-5.5", "gpt-5.4"]'
|
||||
config.custom_model_max_tokens: '1050000'
|
||||
config.reasoning_effort: 'high'
|
||||
config.ai_timeout: '900'
|
||||
config.response_language: ${{ steps.pr_context.outputs.response_language }}
|
||||
config.large_patch_policy: 'clip'
|
||||
config.ignore_pr_title: '["^\\[Auto\\]", "^Auto"]'
|
||||
config.ignore_pr_labels: '["skip pr-agent"]'
|
||||
github_action_config.auto_review: 'false'
|
||||
github_action_config.auto_describe: 'false'
|
||||
github_action_config.auto_improve: 'false'
|
||||
17
.github/workflows/pylint.yml
vendored
17
.github/workflows/pylint.yml
vendored
@@ -23,24 +23,15 @@ jobs:
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt', '**/requirements.in') }}
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.in', '**/requirements-dev.in', '**/requirements.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip setuptools wheel
|
||||
pip install pylint
|
||||
# 安装项目依赖
|
||||
if [ -f requirements.txt ]; then
|
||||
echo "📦 安装 requirements.txt 中的依赖..."
|
||||
pip install -r requirements.txt
|
||||
elif [ -f requirements.in ]; then
|
||||
echo "📦 安装 requirements.in 中的依赖..."
|
||||
pip install -r requirements.in
|
||||
else
|
||||
echo "⚠️ 未找到依赖文件,仅安装 pylint"
|
||||
fi
|
||||
# Pylint 属于开发/静态检查依赖,统一通过 dev 入口安装。
|
||||
pip install -r requirements-dev.in
|
||||
|
||||
- name: Verify pylint config
|
||||
run: |
|
||||
@@ -88,4 +79,4 @@ jobs:
|
||||
run: |
|
||||
echo "🎉 Pylint 检查完成!"
|
||||
echo "✅ 没有发现语法错误或严重问题"
|
||||
echo "📊 详细报告已保存为构建工件"
|
||||
echo "📊 详细报告已保存为构建工件"
|
||||
|
||||
134
.github/workflows/site-adapter-collector.yml
vendored
Normal file
134
.github/workflows/site-adapter-collector.yml
vendored
Normal file
@@ -0,0 +1,134 @@
|
||||
name: Site Adapter Collector
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: Existing release tag to receive collector assets; leave empty for artifacts only
|
||||
required: false
|
||||
type: string
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.platform_name }} collector
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform_name: Windows
|
||||
platform_id: windows
|
||||
runner: windows-latest
|
||||
source_name: moviepilot-site-collector.exe
|
||||
asset_name: moviepilot-site-collector-windows.exe
|
||||
artifact_name: site-adapter-collector-windows
|
||||
- platform_name: macOS
|
||||
platform_id: macos
|
||||
runner: macos-latest
|
||||
source_name: moviepilot-site-collector
|
||||
asset_name: MoviePilot-Site-Collector-macOS.zip
|
||||
artifact_name: site-adapter-collector-macos
|
||||
- platform_name: Linux
|
||||
platform_id: linux
|
||||
runner: ubuntu-latest
|
||||
source_name: moviepilot-site-collector
|
||||
asset_name: moviepilot-site-collector-linux
|
||||
artifact_name: site-adapter-collector-linux
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: pip
|
||||
cache-dependency-path: scripts/site_adapter_collector_requirements.txt
|
||||
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip setuptools wheel
|
||||
pip install -r scripts/site_adapter_collector_requirements.txt
|
||||
|
||||
- name: Build single-file collector
|
||||
run: |
|
||||
pyinstaller --clean --noconfirm scripts/site_adapter_collector.spec
|
||||
|
||||
- name: Smoke-test collector
|
||||
env:
|
||||
SOURCE_NAME: ${{ matrix.source_name }}
|
||||
run: |
|
||||
python -c "import os, subprocess; from pathlib import Path; subprocess.run([str((Path('dist') / os.environ['SOURCE_NAME']).resolve()), '--help'], check=True)"
|
||||
|
||||
- name: Package macOS double-click archive
|
||||
if: matrix.platform_id == 'macos'
|
||||
shell: bash
|
||||
env:
|
||||
ASSET_NAME: ${{ matrix.asset_name }}
|
||||
SOURCE_NAME: ${{ matrix.source_name }}
|
||||
run: |
|
||||
package_dir="dist/MoviePilot-Collector"
|
||||
mkdir -p "$package_dir"
|
||||
cp "dist/$SOURCE_NAME" "$package_dir/moviepilot-site-collector-macos"
|
||||
cp scripts/start-site-adapter-collector.command "$package_dir/start-site-adapter-collector.command"
|
||||
chmod +x "$package_dir/moviepilot-site-collector-macos"
|
||||
chmod +x "$package_dir/start-site-adapter-collector.command"
|
||||
cd dist
|
||||
COPYFILE_DISABLE=1 zip -q -r -X "$ASSET_NAME" MoviePilot-Collector
|
||||
|
||||
- name: Rename Windows and Linux collector
|
||||
if: matrix.platform_id != 'macos'
|
||||
env:
|
||||
ASSET_NAME: ${{ matrix.asset_name }}
|
||||
SOURCE_NAME: ${{ matrix.source_name }}
|
||||
run: |
|
||||
python -c "import os; from pathlib import Path; (Path('dist') / os.environ['SOURCE_NAME']).replace(Path('dist') / os.environ['ASSET_NAME'])"
|
||||
|
||||
- name: Generate SHA-256 checksum
|
||||
env:
|
||||
ASSET_NAME: ${{ matrix.asset_name }}
|
||||
run: |
|
||||
python -c "import hashlib, os; from pathlib import Path; path = Path('dist') / os.environ['ASSET_NAME']; path.with_name(path.name + '.sha256').write_text(f'{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}\n', encoding='utf-8')"
|
||||
|
||||
- name: Upload collector artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ matrix.artifact_name }}
|
||||
path: |
|
||||
dist/${{ matrix.asset_name }}
|
||||
dist/${{ matrix.asset_name }}.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
publish:
|
||||
name: Upload collectors to release
|
||||
if: github.event_name == 'release' || inputs.release_tag != ''
|
||||
needs:
|
||||
- build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Download collector artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: site-adapter-collector-*
|
||||
path: release-assets
|
||||
merge-multiple: true
|
||||
|
||||
- name: Upload assets to published release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }}
|
||||
run: |
|
||||
gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null
|
||||
gh release upload "$RELEASE_TAG" release-assets/* --clobber --repo "$GITHUB_REPOSITORY"
|
||||
33
.github/workflows/test.yml
vendored
33
.github/workflows/test.yml
vendored
@@ -22,34 +22,49 @@ jobs:
|
||||
pytest:
|
||||
runs-on: ubuntu-latest
|
||||
name: Unit Tests
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.in', '**/requirements.txt') }}
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.in', '**/requirements-dev.in', '**/requirements.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip setuptools wheel
|
||||
# 用 requirements.in 还原 CI / 全新环境(含 pytest~=8.4 与 moviepilot-rust 等可选扩展),
|
||||
# 与本地"干净 venv 复现"一致;测试运行器 pytest 已在 requirements.in 中声明。
|
||||
pip install -r requirements.in
|
||||
# 单测需要开发/测试依赖;运行时入口 requirements.in 不携带测试与构建辅助工具。
|
||||
pip install -r requirements-dev.in
|
||||
|
||||
- name: Run tests
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
# tests/run.py 以 pytest 跑 tests 全量;tests/conftest.py 在收集前把 CONFIG_DIR
|
||||
# 指向临时库并建表,测试杜绝真实网络/外部服务(详见 docs/testing.md)。
|
||||
python tests/run.py
|
||||
# 指向临时库并建表;CI 额外生成覆盖率报告,便于后续补测和回归分析。
|
||||
python -m coverage erase
|
||||
python -m coverage run tests/run.py
|
||||
python -m coverage report
|
||||
python -m coverage json
|
||||
python -m coverage xml
|
||||
|
||||
- name: Upload coverage report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: coverage-report
|
||||
path: |
|
||||
coverage.xml
|
||||
coverage.json
|
||||
retention-days: 7
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -16,7 +16,7 @@ app/helper/*.pyd
|
||||
app/helper/*.bin
|
||||
app/plugins/**
|
||||
!app/plugins/__init__.py
|
||||
config/cookies/**
|
||||
config/cookies/
|
||||
config/app.env
|
||||
config/user.db*
|
||||
config/sites/**
|
||||
@@ -25,13 +25,19 @@ config/logs/
|
||||
config/plugins/
|
||||
config/temp/
|
||||
config/cache/
|
||||
config/.cache/
|
||||
.runtime/
|
||||
public/
|
||||
.moviepilot.env
|
||||
*.pyc
|
||||
*.log
|
||||
.coverage
|
||||
coverage.xml
|
||||
coverage.json
|
||||
htmlcov/
|
||||
.vscode
|
||||
venv
|
||||
moviepilot-site-capture-*.zip
|
||||
|
||||
# Pylint
|
||||
pylint-report.json
|
||||
|
||||
@@ -59,6 +59,7 @@ curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v2/scripts/bootst
|
||||
- 文档规则入口:[docs/rules/README.md](docs/rules/README.md)
|
||||
- 开发环境与本地源码运行:[docs/development-setup.md](docs/development-setup.md)
|
||||
- 测试说明:[docs/testing.md](docs/testing.md)
|
||||
- 新站点适配采集与 Feature Request 提交:[docs/site-adapter-capture.md](docs/site-adapter-capture.md)
|
||||
- REST API 文档:https://api.movie-pilot.org
|
||||
- 插件开发说明:https://wiki.movie-pilot.org/zh/plugindev
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ Before contributing, read the repository rules and local environment guide, keep
|
||||
- Rule index: [docs/rules/README.md](docs/rules/README.md)
|
||||
- Development setup and local source run: [docs/development-setup.md](docs/development-setup.md)
|
||||
- Testing guide: [docs/testing.md](docs/testing.md)
|
||||
- New site adapter capture and Feature Request submission: [docs/site-adapter-capture.md](docs/site-adapter-capture.md)
|
||||
- REST API documentation: https://api.movie-pilot.org
|
||||
- Plugin development guide: https://wiki.movie-pilot.org/zh/plugindev
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import warnings
|
||||
|
||||
|
||||
def _filter_third_party_startup_warnings() -> None:
|
||||
"""
|
||||
过滤第三方库在新版 Python 下产生的已知无害启动警告。
|
||||
"""
|
||||
warnings.filterwarnings(
|
||||
"ignore",
|
||||
message=r"invalid escape sequence '\\&'",
|
||||
category=SyntaxWarning,
|
||||
)
|
||||
|
||||
|
||||
_filter_third_party_startup_warnings()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -261,6 +261,12 @@ class StreamingHandler:
|
||||
tool_message=tool_message,
|
||||
tool_kwargs=tool_kwargs or {},
|
||||
)
|
||||
target_values = []
|
||||
if isinstance(target, (list, tuple, set)):
|
||||
target_values = [item for item in target if item]
|
||||
elif target:
|
||||
target_values = [target]
|
||||
|
||||
with self._lock:
|
||||
bucket = self._pending_tool_stats.setdefault(
|
||||
category,
|
||||
@@ -269,9 +275,30 @@ class StreamingHandler:
|
||||
"targets": set(),
|
||||
},
|
||||
)
|
||||
bucket["count"] += 1
|
||||
if target:
|
||||
bucket["targets"].add(str(target))
|
||||
if category == "subagent" and target_values:
|
||||
bucket["count"] += len(target_values)
|
||||
else:
|
||||
bucket["count"] += 1
|
||||
for target_value in target_values:
|
||||
bucket["targets"].add(str(target_value))
|
||||
|
||||
@staticmethod
|
||||
def _extract_subagent_targets(tool_kwargs: dict[str, Any]) -> list[str]:
|
||||
"""提取子代理工具请求中的目标子代理类型。"""
|
||||
tasks = tool_kwargs.get("tasks")
|
||||
if not isinstance(tasks, list):
|
||||
subagent_type = tool_kwargs.get("subagent_type")
|
||||
return [str(subagent_type)] if subagent_type else []
|
||||
|
||||
targets = []
|
||||
for task in tasks:
|
||||
if isinstance(task, dict):
|
||||
subagent_type = task.get("subagent_type")
|
||||
else:
|
||||
subagent_type = getattr(task, "subagent_type", None)
|
||||
if subagent_type:
|
||||
targets.append(str(subagent_type))
|
||||
return targets
|
||||
|
||||
def flush_pending_tool_summary(self) -> str:
|
||||
"""
|
||||
@@ -288,11 +315,17 @@ class StreamingHandler:
|
||||
tool_name: str,
|
||||
tool_message: Optional[str],
|
||||
tool_kwargs: dict[str, Any],
|
||||
) -> tuple[str, Optional[str]]:
|
||||
) -> tuple[str, Optional[Any]]:
|
||||
tool_name = (tool_name or "").strip().lower()
|
||||
tool_message = (tool_message or "").strip()
|
||||
tool_message_lower = tool_message.lower()
|
||||
|
||||
if tool_name == "skill":
|
||||
return "skill", tool_kwargs.get("name")
|
||||
if tool_name == "query_activity_log":
|
||||
return "activity_log", tool_kwargs.get("keyword") or tool_kwargs.get("date")
|
||||
if tool_name == "subagent_task":
|
||||
return "subagent", StreamingHandler._extract_subagent_targets(tool_kwargs)
|
||||
if tool_name == "task":
|
||||
return "subagent", tool_kwargs.get("subagent_type")
|
||||
if tool_name == "read_file":
|
||||
@@ -369,7 +402,7 @@ class StreamingHandler:
|
||||
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"]:
|
||||
if category in {"file_read", "file_write", "directory", "web_browse", "skill"} and bucket["targets"]:
|
||||
value = len(bucket["targets"])
|
||||
part = self._format_tool_stat(category, value)
|
||||
if part:
|
||||
@@ -406,6 +439,10 @@ class StreamingHandler:
|
||||
return f"执行了 {count} 条命令"
|
||||
if category == "data_query":
|
||||
return f"查询了 {count} 次数据"
|
||||
if category == "skill":
|
||||
return f"查询了 {count} 个技能说明"
|
||||
if category == "activity_log":
|
||||
return f"查询了 {count} 次活动日志"
|
||||
if category == "action":
|
||||
return f"执行了 {count} 次操作"
|
||||
if category == "interaction":
|
||||
@@ -499,6 +536,7 @@ class StreamingHandler:
|
||||
original_chat_id=self._original_chat_id,
|
||||
title=self._title,
|
||||
text=current_text,
|
||||
save_history=False,
|
||||
),
|
||||
)
|
||||
if response and response.success and response.message_id:
|
||||
@@ -544,6 +582,7 @@ class StreamingHandler:
|
||||
original_chat_id=self._original_chat_id,
|
||||
title=self._title,
|
||||
text=current_text,
|
||||
save_history=False,
|
||||
),
|
||||
)
|
||||
if response and response.success and response.message_id:
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
version: 1
|
||||
subagent_id: download-diagnostician
|
||||
label: 下载诊断
|
||||
description: Download and transfer diagnosis subagent for downloaders, download tasks, transfer history, and library status.
|
||||
include_tags:
|
||||
- download
|
||||
- transfer
|
||||
- library
|
||||
- directory
|
||||
- file
|
||||
- media
|
||||
exclude_tags:
|
||||
- write
|
||||
- message
|
||||
- user_interaction
|
||||
---
|
||||
# SUBAGENT
|
||||
|
||||
You specialize in downloaders, download tasks, transfer history, directory settings, and library ingestion state.
|
||||
35
app/agent/defaults/subagents/general-purpose/SUBAGENT.md
Normal file
35
app/agent/defaults/subagents/general-purpose/SUBAGENT.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
version: 1
|
||||
subagent_id: general-purpose
|
||||
label: 通用调查
|
||||
description: General read-only investigation subagent for cross-domain MoviePilot analysis and execution recommendations.
|
||||
include_tags:
|
||||
- media
|
||||
- resource
|
||||
- site
|
||||
- subscription
|
||||
- download
|
||||
- library
|
||||
- transfer
|
||||
- system
|
||||
- settings
|
||||
- plugin
|
||||
- workflow
|
||||
- scheduler
|
||||
- file
|
||||
- directory
|
||||
- web
|
||||
- command
|
||||
- filter_rule
|
||||
- persona
|
||||
- slash_command
|
||||
- recommendation
|
||||
- metadata
|
||||
exclude_tags:
|
||||
- write
|
||||
- message
|
||||
- user_interaction
|
||||
---
|
||||
# SUBAGENT
|
||||
|
||||
You specialize in synthesizing media, site, subscription, download, and system status signals.
|
||||
19
app/agent/defaults/subagents/media-researcher/SUBAGENT.md
Normal file
19
app/agent/defaults/subagents/media-researcher/SUBAGENT.md
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
version: 1
|
||||
subagent_id: media-researcher
|
||||
label: 媒体研究
|
||||
description: Media research subagent for title recognition, people, episodes, metadata, and library existence checks.
|
||||
include_tags:
|
||||
- media
|
||||
- library
|
||||
- recommendation
|
||||
- metadata
|
||||
- web
|
||||
exclude_tags:
|
||||
- write
|
||||
- message
|
||||
- user_interaction
|
||||
---
|
||||
# SUBAGENT
|
||||
|
||||
You specialize in media identity resolution, metadata validation, person credits, and library status analysis.
|
||||
19
app/agent/defaults/subagents/moviepilot-explorer/SUBAGENT.md
Normal file
19
app/agent/defaults/subagents/moviepilot-explorer/SUBAGENT.md
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
version: 1
|
||||
subagent_id: moviepilot-explorer
|
||||
label: 代码探索
|
||||
description: MoviePilot exploration subagent for source-code inspection, configuration structure analysis, logs, and code-level troubleshooting clues.
|
||||
include_tags:
|
||||
- system
|
||||
- settings
|
||||
- file
|
||||
- directory
|
||||
- command
|
||||
exclude_tags:
|
||||
- write
|
||||
- message
|
||||
- user_interaction
|
||||
---
|
||||
# SUBAGENT
|
||||
|
||||
You specialize in MoviePilot source-code structure, local configuration files, directory layout, logs or read-only command output, and code-level root-cause troubleshooting. Prefer reading relevant code paths before judging behavior, and distinguish code/config evidence from runtime system state.
|
||||
18
app/agent/defaults/subagents/resource-searcher/SUBAGENT.md
Normal file
18
app/agent/defaults/subagents/resource-searcher/SUBAGENT.md
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
version: 1
|
||||
subagent_id: resource-searcher
|
||||
label: 资源搜索
|
||||
description: Site and resource search subagent for site checks, torrent search, and resource quality analysis.
|
||||
include_tags:
|
||||
- resource
|
||||
- site
|
||||
- web
|
||||
- media
|
||||
exclude_tags:
|
||||
- write
|
||||
- message
|
||||
- user_interaction
|
||||
---
|
||||
# SUBAGENT
|
||||
|
||||
You specialize in site status, site user data, torrent search results, and resource quality judgment.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
version: 1
|
||||
subagent_id: subscription-analyst
|
||||
label: 订阅分析
|
||||
description: Subscription analysis subagent for subscriptions, history, filter rules, and custom identifiers.
|
||||
include_tags:
|
||||
- subscription
|
||||
- filter_rule
|
||||
- settings
|
||||
- media
|
||||
exclude_tags:
|
||||
- write
|
||||
- message
|
||||
- user_interaction
|
||||
---
|
||||
# SUBAGENT
|
||||
|
||||
You specialize in current subscription state, subscription history, filter rules, and subscription optimization suggestions.
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
version: 1
|
||||
subagent_id: system-diagnostician
|
||||
label: 系统诊断
|
||||
description: System diagnosis subagent for read-only inspection of settings, schedulers, workflows, plugins, directories, and command output.
|
||||
include_tags:
|
||||
- system
|
||||
- settings
|
||||
- plugin
|
||||
- workflow
|
||||
- scheduler
|
||||
- file
|
||||
- directory
|
||||
- web
|
||||
- command
|
||||
- persona
|
||||
- slash_command
|
||||
exclude_tags:
|
||||
- write
|
||||
- message
|
||||
- user_interaction
|
||||
---
|
||||
# SUBAGENT
|
||||
|
||||
You specialize in settings, plugins, scheduled tasks, workflows, directories, and read-only command diagnostics.
|
||||
@@ -691,7 +691,9 @@ class AgentCapabilityManager:
|
||||
@staticmethod
|
||||
def supports_image_input() -> bool:
|
||||
"""当前 Agent 是否启用图片输入能力。"""
|
||||
return bool(settings.LLM_SUPPORT_IMAGE_INPUT)
|
||||
from app.agent.llm.helper import LLMHelper
|
||||
|
||||
return LLMHelper.supports_image_input()
|
||||
|
||||
@staticmethod
|
||||
def supports_audio_input() -> bool:
|
||||
|
||||
@@ -5,7 +5,7 @@ import inspect
|
||||
import json
|
||||
import time
|
||||
from functools import wraps
|
||||
from typing import Any, List
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from langchain_core.messages import AIMessage, AIMessageChunk
|
||||
|
||||
@@ -700,11 +700,85 @@ class LLMHelper:
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def supports_image_input() -> bool:
|
||||
def _metadata_supports_image_input(metadata: Any) -> Optional[bool]:
|
||||
"""从模型元数据中读取图片输入能力,未知时返回 None。"""
|
||||
if not isinstance(metadata, dict):
|
||||
return None
|
||||
|
||||
modalities = metadata.get("modalities") or {}
|
||||
input_modalities = modalities.get("input")
|
||||
if isinstance(input_modalities, str):
|
||||
input_modalities = [input_modalities]
|
||||
if isinstance(input_modalities, list):
|
||||
normalized_modalities = {
|
||||
str(item or "").strip().lower() for item in input_modalities
|
||||
}
|
||||
return "image" in normalized_modalities
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _resolve_catalog_image_input_support(
|
||||
cls,
|
||||
provider: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
base_url_preset: Optional[str] = None,
|
||||
) -> Optional[bool]:
|
||||
"""复用 provider 目录缓存解析当前模型是否支持图片输入。"""
|
||||
provider_name = str(provider if provider is not None else settings.LLM_PROVIDER).strip()
|
||||
model_name = str(model if model is not None else settings.LLM_MODEL).strip()
|
||||
if not provider_name or not model_name:
|
||||
return None
|
||||
|
||||
try:
|
||||
from app.agent.llm.provider import LLMProviderManager
|
||||
|
||||
metadata = LLMProviderManager().resolve_cached_model_metadata(
|
||||
provider_id=provider_name,
|
||||
model_id=model_name,
|
||||
base_url=base_url if base_url is not None else settings.LLM_BASE_URL,
|
||||
base_url_preset_id=(
|
||||
base_url_preset
|
||||
if base_url_preset is not None
|
||||
else settings.LLM_BASE_URL_PRESET
|
||||
),
|
||||
)
|
||||
except Exception as err:
|
||||
logger.debug(f"解析模型图片能力失败: {err}")
|
||||
return None
|
||||
|
||||
return cls._metadata_supports_image_input(metadata)
|
||||
|
||||
@classmethod
|
||||
def supports_image_input(
|
||||
cls,
|
||||
provider: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
base_url_preset: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
判断当前模型是否启用了图片输入能力。
|
||||
|
||||
用户开关为总开关;当内置模型目录明确标注当前模型不支持 image 输入时,
|
||||
即使总开关开启也降级为纯文本,避免文本模型收到 `image_url` 内容块后
|
||||
被兼容端点以 400 拒绝。无参调用保持旧版“只读总开关”语义,
|
||||
未知自定义模型也保持原有开关语义。
|
||||
"""
|
||||
return bool(settings.LLM_SUPPORT_IMAGE_INPUT)
|
||||
if not settings.LLM_SUPPORT_IMAGE_INPUT:
|
||||
return False
|
||||
if provider is None and model is None:
|
||||
return True
|
||||
|
||||
image_support = cls._resolve_catalog_image_input_support(
|
||||
provider=provider,
|
||||
model=model,
|
||||
base_url=base_url,
|
||||
base_url_preset=base_url_preset,
|
||||
)
|
||||
if image_support is not None:
|
||||
return image_support
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _build_legacy_runtime(
|
||||
@@ -798,6 +872,41 @@ class LLMHelper:
|
||||
return True
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _attach_runtime_metadata(model: Any, runtime: dict[str, Any]) -> None:
|
||||
"""
|
||||
将 MoviePilot 已解析出的 provider 运行时信息挂到模型实例上。
|
||||
|
||||
这些字段只供内部中间件识别协议能力,不参与 LangChain 请求序列化。
|
||||
"""
|
||||
runtime_metadata = {
|
||||
"runtime": runtime.get("runtime"),
|
||||
"provider_id": runtime.get("provider_id"),
|
||||
"base_url": runtime.get("base_url"),
|
||||
}
|
||||
|
||||
def _set_metadata_attr(name: str, value: Any) -> None:
|
||||
try:
|
||||
setattr(model, name, value)
|
||||
except Exception:
|
||||
object.__setattr__(model, name, value)
|
||||
|
||||
try:
|
||||
_set_metadata_attr("_moviepilot_llm_runtime", runtime_metadata["runtime"])
|
||||
_set_metadata_attr(
|
||||
"_moviepilot_llm_provider_id",
|
||||
runtime_metadata["provider_id"],
|
||||
)
|
||||
_set_metadata_attr("_moviepilot_llm_base_url", runtime_metadata["base_url"])
|
||||
except Exception as err:
|
||||
logger.debug(f"LLM运行时元数据附加失败: {str(err)}")
|
||||
|
||||
profile = getattr(model, "profile", None)
|
||||
if isinstance(profile, dict):
|
||||
profile["moviepilot_runtime"] = runtime_metadata["runtime"]
|
||||
profile["moviepilot_provider_id"] = runtime_metadata["provider_id"]
|
||||
profile["moviepilot_base_url"] = runtime_metadata["base_url"]
|
||||
|
||||
@classmethod
|
||||
def _resolve_thinking_level(
|
||||
cls,
|
||||
@@ -843,6 +952,7 @@ class LLMHelper:
|
||||
base_url: str | None = None,
|
||||
base_url_preset: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
temperature: Optional[float] = None,
|
||||
use_proxy: bool | None = None,
|
||||
):
|
||||
"""
|
||||
@@ -858,6 +968,7 @@ class LLMHelper:
|
||||
:param base_url: API Base URL。未显式传入时使用当前配置项 LLM_BASE_URL。
|
||||
:param base_url_preset: Base URL 预设。未显式传入时使用当前配置项 LLM_BASE_URL_PRESET。
|
||||
:param user_agent: OpenAI兼容接口请求 User-Agent。未显式传入时使用配置项 LLM_USER_AGENT。
|
||||
:param temperature: LLM 温度参数。未显式传入时使用配置项 LLM_TEMPERATURE。
|
||||
:param use_proxy: 是否为本次 LLM 调用使用系统代理。未显式传入时使用配置项 LLM_USE_PROXY。
|
||||
:return: LLM实例
|
||||
"""
|
||||
@@ -869,6 +980,7 @@ class LLMHelper:
|
||||
base_url_preset if base_url_preset is not None else settings.LLM_BASE_URL_PRESET
|
||||
)
|
||||
user_agent_value = user_agent if user_agent is not None else settings.LLM_USER_AGENT
|
||||
temperature_value = temperature if temperature is not None else settings.LLM_TEMPERATURE
|
||||
normalized_thinking_level = cls._resolve_thinking_level(
|
||||
thinking_level=thinking_level,
|
||||
)
|
||||
@@ -925,7 +1037,7 @@ class LLMHelper:
|
||||
model=model_name,
|
||||
api_key=runtime["api_key"],
|
||||
retries=3,
|
||||
temperature=settings.LLM_TEMPERATURE,
|
||||
temperature=temperature_value,
|
||||
streaming=streaming,
|
||||
client_args=_build_google_client_args(llm_proxy),
|
||||
**thinking_kwargs,
|
||||
@@ -939,7 +1051,7 @@ class LLMHelper:
|
||||
api_key=runtime["api_key"],
|
||||
api_base=runtime["base_url"],
|
||||
max_retries=3,
|
||||
temperature=settings.LLM_TEMPERATURE,
|
||||
temperature=temperature_value,
|
||||
streaming=streaming,
|
||||
stream_usage=True,
|
||||
http_client=_build_httpx_client(llm_proxy),
|
||||
@@ -954,7 +1066,7 @@ class LLMHelper:
|
||||
api_key=runtime["api_key"],
|
||||
base_url=runtime["base_url"],
|
||||
max_retries=3,
|
||||
temperature=settings.LLM_TEMPERATURE,
|
||||
temperature=temperature_value,
|
||||
streaming=streaming,
|
||||
stream_usage=True,
|
||||
anthropic_proxy=llm_proxy,
|
||||
@@ -975,7 +1087,7 @@ class LLMHelper:
|
||||
api_key=runtime["api_key"],
|
||||
max_retries=3,
|
||||
base_url=runtime.get("base_url"),
|
||||
temperature=settings.LLM_TEMPERATURE,
|
||||
temperature=temperature_value,
|
||||
streaming=streaming,
|
||||
stream_usage=True,
|
||||
openai_proxy=llm_proxy,
|
||||
@@ -1011,12 +1123,17 @@ class LLMHelper:
|
||||
"max_input_tokens": int(max_input_tokens),
|
||||
}
|
||||
|
||||
cls._attach_runtime_metadata(model, runtime)
|
||||
return model
|
||||
|
||||
@staticmethod
|
||||
def _extract_text_content(content) -> str:
|
||||
def extract_text_content(content: Any, fallback_to_string: bool = False) -> str:
|
||||
"""
|
||||
从响应内容中提取纯文本,仅保留真实文本块。
|
||||
|
||||
:param content: 模型响应内容,可能是字符串、字典或内容块列表
|
||||
:param fallback_to_string: 未识别为文本内容时是否回退为字符串
|
||||
:return: 提取后的纯文本内容
|
||||
"""
|
||||
if content is None:
|
||||
return ""
|
||||
@@ -1051,7 +1168,7 @@ class LLMHelper:
|
||||
return content.get("text", "")
|
||||
if not content.get("type") and isinstance(content.get("text"), str):
|
||||
return content.get("text", "")
|
||||
return ""
|
||||
return str(content) if fallback_to_string else ""
|
||||
|
||||
@staticmethod
|
||||
async def test_current_settings(
|
||||
@@ -1064,25 +1181,32 @@ class LLMHelper:
|
||||
base_url: str | None = None,
|
||||
base_url_preset: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
temperature: Optional[float] = None,
|
||||
use_proxy: bool | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
使用当前已保存配置执行一次最小 LLM 调用。
|
||||
使用当前配置或显式传入的临时配置执行一次最小 LLM 调用。
|
||||
|
||||
:param temperature: LLM 温度参数。未显式传入时沿用已保存配置。
|
||||
"""
|
||||
provider_name = provider if provider is not None else settings.LLM_PROVIDER
|
||||
model_name = model if model is not None else settings.LLM_MODEL
|
||||
start = time.perf_counter()
|
||||
llm = await LLMHelper.get_llm(
|
||||
streaming=False,
|
||||
provider=provider_name,
|
||||
model=model_name,
|
||||
thinking_level=thinking_level,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
base_url_preset=base_url_preset,
|
||||
user_agent=user_agent,
|
||||
use_proxy=use_proxy,
|
||||
)
|
||||
llm_kwargs = {
|
||||
"streaming": False,
|
||||
"provider": provider_name,
|
||||
"model": model_name,
|
||||
"thinking_level": thinking_level,
|
||||
"api_key": api_key,
|
||||
"base_url": base_url,
|
||||
"base_url_preset": base_url_preset,
|
||||
"user_agent": user_agent,
|
||||
"use_proxy": use_proxy,
|
||||
}
|
||||
if temperature is not None:
|
||||
llm_kwargs["temperature"] = temperature
|
||||
|
||||
llm = await LLMHelper.get_llm(**llm_kwargs)
|
||||
try:
|
||||
response = await asyncio.wait_for(llm.ainvoke(prompt), timeout=timeout)
|
||||
except TimeoutError as err:
|
||||
@@ -1092,7 +1216,7 @@ class LLMHelper:
|
||||
duration_ms = round((time.perf_counter() - start) * 1000)
|
||||
raise LLMTestError(str(err), duration_ms=duration_ms) from err
|
||||
|
||||
reply_text = LLMHelper._extract_text_content(
|
||||
reply_text = LLMHelper.extract_text_content(
|
||||
getattr(response, "content", response)
|
||||
).strip()
|
||||
duration_ms = round((time.perf_counter() - start) * 1000)
|
||||
|
||||
@@ -891,7 +891,7 @@ class LLMProviderManager(metaclass=Singleton):
|
||||
if not self._models_dev_cache_path.exists():
|
||||
payload = None
|
||||
else:
|
||||
payload = json.loads(self._models_dev_cache_path.read_text(encoding="utf-8"))
|
||||
payload = json.loads(self._models_dev_cache_path.read_text(encoding="utf-8", errors="replace"))
|
||||
except Exception as err:
|
||||
logger.warning(f"读取 models.dev provider 缓存失败: {err}")
|
||||
payload = None
|
||||
@@ -1424,7 +1424,7 @@ class LLMProviderManager(metaclass=Singleton):
|
||||
if not self._models_dev_cache_path.exists():
|
||||
return None
|
||||
async with aiofiles.open(
|
||||
self._models_dev_cache_path, mode="r", encoding="utf-8"
|
||||
self._models_dev_cache_path, mode="r", encoding="utf-8", errors="replace"
|
||||
) as stream:
|
||||
return json.loads(await stream.read())
|
||||
except Exception as err:
|
||||
@@ -1437,7 +1437,7 @@ class LLMProviderManager(metaclass=Singleton):
|
||||
if not self._MODELS_DEV_BUNDLED_PATH.exists():
|
||||
return None
|
||||
payload = json.loads(
|
||||
self._MODELS_DEV_BUNDLED_PATH.read_text(encoding="utf-8")
|
||||
self._MODELS_DEV_BUNDLED_PATH.read_text(encoding="utf-8", errors="replace")
|
||||
)
|
||||
except Exception as err:
|
||||
logger.warning(f"读取本地 models.dev 离线文件失败: {err}")
|
||||
@@ -1564,6 +1564,101 @@ class LLMProviderManager(metaclass=Singleton):
|
||||
return models[candidate]
|
||||
return None
|
||||
|
||||
def _cached_models_dev_model(
|
||||
self,
|
||||
provider_id: str,
|
||||
model_id: str,
|
||||
base_url: Optional[str] = None,
|
||||
base_url_preset_id: Optional[str] = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""从已缓存或内置的 models.dev 数据中同步读取模型元数据。"""
|
||||
try:
|
||||
spec = self.get_provider(provider_id)
|
||||
except LLMProviderError:
|
||||
return None
|
||||
|
||||
models_dev_provider_id = self._resolve_provider_models_dev_provider_id(
|
||||
spec,
|
||||
base_url,
|
||||
base_url_preset_id=base_url_preset_id,
|
||||
)
|
||||
if not models_dev_provider_id:
|
||||
return None
|
||||
|
||||
payload = self._cached_models_dev_payload().get(models_dev_provider_id, {}) or {}
|
||||
models = payload.get("models") if isinstance(payload, dict) else None
|
||||
if not isinstance(models, dict):
|
||||
return None
|
||||
|
||||
candidates = [model_id]
|
||||
if model_id.startswith("models/"):
|
||||
candidates.append(model_id.removeprefix("models/"))
|
||||
|
||||
for candidate in candidates:
|
||||
if candidate in models:
|
||||
return models[candidate]
|
||||
return None
|
||||
|
||||
def resolve_cached_model_metadata(
|
||||
self,
|
||||
provider_id: str,
|
||||
model_id: Optional[str],
|
||||
base_url: Optional[str] = None,
|
||||
base_url_preset_id: Optional[str] = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""同步解析缓存中的模型元数据,不触发远端 models.dev 刷新。"""
|
||||
if not model_id:
|
||||
return None
|
||||
metadata = self._cached_models_dev_model(
|
||||
provider_id,
|
||||
model_id,
|
||||
base_url=base_url,
|
||||
base_url_preset_id=base_url_preset_id,
|
||||
)
|
||||
if metadata:
|
||||
return metadata
|
||||
if provider_id == "chatgpt":
|
||||
return self._cached_models_dev_model("openai", model_id)
|
||||
if provider_id == "openai":
|
||||
return (
|
||||
self._cached_models_dev_payload()
|
||||
.get("openai", {})
|
||||
.get("models", {})
|
||||
.get(model_id)
|
||||
)
|
||||
return None
|
||||
|
||||
def _resolve_cached_model_record(
|
||||
self,
|
||||
provider_id: str,
|
||||
model_id: Optional[str],
|
||||
base_url: Optional[str] = None,
|
||||
base_url_preset_id: Optional[str] = None,
|
||||
transport: str = "openai",
|
||||
) -> dict[str, Any] | None:
|
||||
"""从缓存中的模型元数据构造轻量模型记录,不触发远端模型列表刷新。"""
|
||||
if not model_id:
|
||||
return None
|
||||
metadata = self.resolve_cached_model_metadata(
|
||||
provider_id,
|
||||
model_id,
|
||||
base_url=base_url,
|
||||
base_url_preset_id=base_url_preset_id,
|
||||
) or {}
|
||||
if not metadata:
|
||||
return self._normalize_model_record(
|
||||
model_id=model_id,
|
||||
transport=transport,
|
||||
source="configured",
|
||||
)
|
||||
return self._normalize_model_record(
|
||||
model_id=model_id,
|
||||
display_name=metadata.get("name") or model_id,
|
||||
metadata=metadata,
|
||||
transport=transport,
|
||||
source="models.dev-cache",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_model_record(
|
||||
model_id: str,
|
||||
@@ -2040,7 +2135,7 @@ class LLMProviderManager(metaclass=Singleton):
|
||||
try:
|
||||
return jwt.decode(token, options={"verify_signature": False})
|
||||
except Exception as err:
|
||||
print(err)
|
||||
logger.debug(f"解析 JWT token 内容失败: {err}")
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
@@ -2523,40 +2618,29 @@ class LLMProviderManager(metaclass=Singleton):
|
||||
)
|
||||
normalized_api_key = str(api_key or "").strip() or None
|
||||
normalized_base_url = self._sanitize_base_url(base_url)
|
||||
model_record = None
|
||||
if model:
|
||||
try:
|
||||
model_record = next(
|
||||
(
|
||||
item
|
||||
for item in await self.list_models(
|
||||
normalized_provider_id,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
base_url_preset_id=normalized_base_url_preset_id,
|
||||
user_agent=user_agent,
|
||||
use_proxy=use_proxy,
|
||||
)
|
||||
if item["id"] == model
|
||||
),
|
||||
None,
|
||||
)
|
||||
except Exception as err:
|
||||
print(err)
|
||||
model_record = None
|
||||
default_transport = (
|
||||
"anthropic" if resolved_runtime == "anthropic_compatible" else "openai"
|
||||
)
|
||||
model_record = self._resolve_cached_model_record(
|
||||
normalized_provider_id,
|
||||
model,
|
||||
base_url=base_url,
|
||||
base_url_preset_id=normalized_base_url_preset_id,
|
||||
transport=default_transport,
|
||||
)
|
||||
model_metadata = self.resolve_cached_model_metadata(
|
||||
normalized_provider_id,
|
||||
model,
|
||||
base_url=base_url,
|
||||
base_url_preset_id=normalized_base_url_preset_id,
|
||||
)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"provider_id": normalized_provider_id,
|
||||
"runtime": resolved_runtime,
|
||||
"model_id": model,
|
||||
"model_record": model_record,
|
||||
"model_metadata": await self.resolve_model_metadata(
|
||||
normalized_provider_id,
|
||||
model,
|
||||
base_url=base_url,
|
||||
base_url_preset_id=normalized_base_url_preset_id,
|
||||
use_proxy=use_proxy,
|
||||
),
|
||||
"model_metadata": model_metadata,
|
||||
"default_headers": None,
|
||||
"use_responses_api": None,
|
||||
"auth_mode": "api_key",
|
||||
@@ -2567,8 +2651,7 @@ class LLMProviderManager(metaclass=Singleton):
|
||||
try:
|
||||
auth = await self._resolve_chatgpt_oauth()
|
||||
except Exception as err:
|
||||
print(err)
|
||||
pass
|
||||
logger.debug(f"解析 ChatGPT OAuth 鉴权失败,回退 API Key 模式: {err}")
|
||||
|
||||
if auth:
|
||||
headers = {"originator": "moviepilot"}
|
||||
|
||||
600
app/agent/mcp.py
Normal file
600
app/agent/mcp.py
Normal file
@@ -0,0 +1,600 @@
|
||||
"""Agent 外部 MCP 客户端与配置管理。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.log import logger
|
||||
from app.schemas.agent import (
|
||||
AgentMcpServerConfig,
|
||||
AgentMcpServerTestResult,
|
||||
AgentMcpServerToolInfo,
|
||||
)
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.utils.http import AsyncRequestUtils
|
||||
|
||||
MCP_PROTOCOL_VERSION = "2025-11-25"
|
||||
MCP_CLIENT_NAME = "MoviePilot Agent"
|
||||
DEFAULT_MCP_TIMEOUT = 30
|
||||
MCP_TOOL_NAME_PATTERN = re.compile(r"[^a-zA-Z0-9_]+")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentMcpToolSpec:
|
||||
"""已发现的外部 MCP 工具定义。"""
|
||||
|
||||
server: AgentMcpServerConfig
|
||||
name: str
|
||||
agent_tool_name: str
|
||||
description: str
|
||||
input_schema: dict[str, Any]
|
||||
|
||||
|
||||
def _normalize_identifier(value: str, fallback: str = "mcp") -> str:
|
||||
"""把服务器或工具名称转换为 Agent 工具可用的标识片段。"""
|
||||
normalized = MCP_TOOL_NAME_PATTERN.sub("_", str(value or "").strip())
|
||||
normalized = re.sub(r"_+", "_", normalized).strip("_").lower()
|
||||
if not normalized:
|
||||
normalized = fallback
|
||||
if normalized[0].isdigit():
|
||||
normalized = f"{fallback}_{normalized}"
|
||||
return normalized[:64]
|
||||
|
||||
|
||||
def _normalize_timeout(value: Any) -> int:
|
||||
"""规范化 MCP 连接和调用超时时间。"""
|
||||
try:
|
||||
timeout = int(value or DEFAULT_MCP_TIMEOUT)
|
||||
except (TypeError, ValueError):
|
||||
timeout = DEFAULT_MCP_TIMEOUT
|
||||
return min(max(timeout, 1), 600)
|
||||
|
||||
|
||||
def _normalize_string_dict(value: Any) -> dict[str, str]:
|
||||
"""规范化请求头和环境变量字典,移除空键。"""
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
normalized: dict[str, str] = {}
|
||||
for key, item in value.items():
|
||||
normalized_key = str(key or "").strip()
|
||||
if not normalized_key:
|
||||
continue
|
||||
normalized[normalized_key] = str(item or "")
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_input_schema(value: Any) -> dict[str, Any]:
|
||||
"""规范化 MCP 工具参数 Schema,保证至少是 object schema。"""
|
||||
if not isinstance(value, dict):
|
||||
return {"type": "object", "properties": {}, "required": []}
|
||||
schema = dict(value)
|
||||
schema.setdefault("type", "object")
|
||||
schema.setdefault("properties", {})
|
||||
schema.setdefault("required", [])
|
||||
return schema
|
||||
|
||||
|
||||
def _build_agent_tool_name(server: AgentMcpServerConfig, tool_name: str) -> str:
|
||||
"""构造注入 Agent 的外部 MCP 工具名。"""
|
||||
prefix = server.tool_prefix or f"mcp_{server.name or server.id}"
|
||||
normalized_prefix = _normalize_identifier(prefix, fallback="mcp")
|
||||
normalized_tool_name = _normalize_identifier(tool_name, fallback="tool")
|
||||
if normalized_tool_name.startswith(f"{normalized_prefix}_"):
|
||||
return normalized_tool_name
|
||||
return f"{normalized_prefix}_{normalized_tool_name}"[:128]
|
||||
|
||||
|
||||
def _jsonrpc_message(method: str, params: Optional[dict[str, Any]] = None, *, request_id: Optional[str] = None) -> dict:
|
||||
"""构造 JSON-RPC 2.0 消息。"""
|
||||
payload = {"jsonrpc": "2.0", "method": method}
|
||||
if request_id is not None:
|
||||
payload["id"] = request_id
|
||||
if params is not None:
|
||||
payload["params"] = params
|
||||
return payload
|
||||
|
||||
|
||||
def _raise_for_jsonrpc_error(payload: Any) -> None:
|
||||
"""检查 JSON-RPC 响应错误并转换为运行时异常。"""
|
||||
if isinstance(payload, dict) and payload.get("error"):
|
||||
error = payload["error"]
|
||||
if isinstance(error, dict):
|
||||
message = error.get("message") or error
|
||||
else:
|
||||
message = error
|
||||
raise RuntimeError(f"MCP JSON-RPC 错误: {message}")
|
||||
|
||||
|
||||
def _extract_jsonrpc_result(payload: Any, request_id: str) -> Any:
|
||||
"""从 JSON-RPC 响应中提取 result 字段。"""
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError("MCP 响应不是有效 JSON 对象")
|
||||
if payload.get("id") != request_id:
|
||||
raise RuntimeError("MCP 响应 ID 与请求不匹配")
|
||||
_raise_for_jsonrpc_error(payload)
|
||||
return payload.get("result")
|
||||
|
||||
|
||||
async def _iter_sse_events(response) -> Any:
|
||||
"""按 SSE 事件格式迭代响应流。"""
|
||||
event_name = "message"
|
||||
data_lines: list[str] = []
|
||||
async for raw_line in response.aiter_lines():
|
||||
line = raw_line.rstrip("\r")
|
||||
if not line:
|
||||
if data_lines:
|
||||
yield {"event": event_name, "data": "\n".join(data_lines)}
|
||||
event_name = "message"
|
||||
data_lines = []
|
||||
continue
|
||||
if line.startswith(":"):
|
||||
continue
|
||||
field, _, value = line.partition(":")
|
||||
if value.startswith(" "):
|
||||
value = value[1:]
|
||||
if field == "event":
|
||||
event_name = value or "message"
|
||||
elif field == "data":
|
||||
data_lines.append(value)
|
||||
if data_lines:
|
||||
yield {"event": event_name, "data": "\n".join(data_lines)}
|
||||
|
||||
|
||||
def _parse_sse_text_response(text: str, request_id: str) -> Any:
|
||||
"""从非流式 SSE 文本响应中提取匹配请求的 JSON-RPC 结果。"""
|
||||
event_name = "message"
|
||||
data_lines: list[str] = []
|
||||
for raw_line in str(text or "").splitlines():
|
||||
line = raw_line.rstrip("\r")
|
||||
if not line:
|
||||
if data_lines:
|
||||
payload = _load_sse_json_payload(event_name, "\n".join(data_lines))
|
||||
if isinstance(payload, dict) and payload.get("id") == request_id:
|
||||
return _extract_jsonrpc_result(payload, request_id)
|
||||
event_name = "message"
|
||||
data_lines = []
|
||||
continue
|
||||
if line.startswith(":"):
|
||||
continue
|
||||
field, _, value = line.partition(":")
|
||||
if value.startswith(" "):
|
||||
value = value[1:]
|
||||
if field == "event":
|
||||
event_name = value or "message"
|
||||
elif field == "data":
|
||||
data_lines.append(value)
|
||||
if data_lines:
|
||||
payload = _load_sse_json_payload(event_name, "\n".join(data_lines))
|
||||
if isinstance(payload, dict) and payload.get("id") == request_id:
|
||||
return _extract_jsonrpc_result(payload, request_id)
|
||||
raise RuntimeError("MCP SSE 响应中未找到匹配请求")
|
||||
|
||||
|
||||
def _load_sse_json_payload(event_name: str, data: str) -> Optional[dict]:
|
||||
"""解析 SSE data 中的 JSON-RPC 消息。"""
|
||||
if event_name not in {"message", "messages"}:
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(data)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return payload if isinstance(payload, dict) else None
|
||||
|
||||
|
||||
class _StdioMcpSession:
|
||||
"""stdio MCP 会话,按一次操作生命周期启动外部进程。"""
|
||||
|
||||
def __init__(self, server: AgentMcpServerConfig) -> None:
|
||||
self.server = server
|
||||
self.process: Optional[asyncio.subprocess.Process] = None
|
||||
self.stderr_task: Optional[asyncio.Task] = None
|
||||
|
||||
async def __aenter__(self) -> "_StdioMcpSession":
|
||||
"""启动 stdio MCP 子进程。"""
|
||||
if not self.server.command:
|
||||
raise RuntimeError("stdio MCP 服务器缺少启动命令")
|
||||
env = os.environ.copy()
|
||||
env.update(self.server.env or {})
|
||||
self.process = await asyncio.create_subprocess_exec(
|
||||
self.server.command,
|
||||
*(self.server.args or []),
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=env,
|
||||
)
|
||||
self.stderr_task = asyncio.create_task(self._drain_stderr())
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> None:
|
||||
"""结束 stdio MCP 子进程。"""
|
||||
if self.stderr_task:
|
||||
self.stderr_task.cancel()
|
||||
if not self.process:
|
||||
return
|
||||
if self.process.returncode is None:
|
||||
self.process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(self.process.wait(), timeout=2)
|
||||
except asyncio.TimeoutError:
|
||||
self.process.kill()
|
||||
await self.process.wait()
|
||||
|
||||
async def _drain_stderr(self) -> None:
|
||||
"""持续读取子进程 stderr,避免缓冲区阻塞。"""
|
||||
if not self.process or not self.process.stderr:
|
||||
return
|
||||
try:
|
||||
while True:
|
||||
line = await self.process.stderr.readline()
|
||||
if not line:
|
||||
break
|
||||
logger.debug(f"MCP stdio[{self.server.name}] stderr: {line.decode(errors='replace').strip()}")
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
|
||||
async def notify(self, method: str, params: Optional[dict[str, Any]] = None) -> None:
|
||||
"""发送不需要响应的 JSON-RPC 通知。"""
|
||||
await self._write_json(_jsonrpc_message(method, params))
|
||||
|
||||
async def request(self, method: str, params: Optional[dict[str, Any]] = None) -> Any:
|
||||
"""发送 JSON-RPC 请求并等待响应。"""
|
||||
request_id = uuid.uuid4().hex
|
||||
await self._write_json(_jsonrpc_message(method, params, request_id=request_id))
|
||||
while True:
|
||||
payload = await self._read_json()
|
||||
if payload.get("id") == request_id:
|
||||
return _extract_jsonrpc_result(payload, request_id)
|
||||
|
||||
async def _write_json(self, payload: dict) -> None:
|
||||
"""写入一行 JSON-RPC 消息。"""
|
||||
if not self.process or not self.process.stdin:
|
||||
raise RuntimeError("stdio MCP 进程未启动")
|
||||
data = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||
self.process.stdin.write(data.encode("utf-8"))
|
||||
await self.process.stdin.drain()
|
||||
|
||||
async def _read_json(self) -> dict:
|
||||
"""从 stdout 读取一行 JSON-RPC 消息。"""
|
||||
if not self.process or not self.process.stdout:
|
||||
raise RuntimeError("stdio MCP 进程未启动")
|
||||
timeout = _normalize_timeout(self.server.timeout)
|
||||
while True:
|
||||
line = await asyncio.wait_for(self.process.stdout.readline(), timeout=timeout)
|
||||
if not line:
|
||||
raise RuntimeError("stdio MCP 进程已退出")
|
||||
try:
|
||||
payload = json.loads(line.decode("utf-8"))
|
||||
except ValueError:
|
||||
logger.debug(f"忽略非 JSON MCP stdout 行: {line!r}")
|
||||
continue
|
||||
if isinstance(payload, dict):
|
||||
return payload
|
||||
|
||||
|
||||
class _HttpMcpSession:
|
||||
"""Streamable HTTP MCP 会话。"""
|
||||
|
||||
def __init__(self, server: AgentMcpServerConfig) -> None:
|
||||
self.server = server
|
||||
self.session_id: Optional[str] = None
|
||||
|
||||
async def __aenter__(self) -> "_HttpMcpSession":
|
||||
"""进入 HTTP MCP 会话。"""
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> None:
|
||||
"""退出 HTTP MCP 会话。"""
|
||||
return None
|
||||
|
||||
async def notify(self, method: str, params: Optional[dict[str, Any]] = None) -> None:
|
||||
"""发送不需要响应的 JSON-RPC 通知。"""
|
||||
await self._post(_jsonrpc_message(method, params), expect_response=False)
|
||||
|
||||
async def request(self, method: str, params: Optional[dict[str, Any]] = None) -> Any:
|
||||
"""发送 JSON-RPC 请求并等待响应。"""
|
||||
request_id = uuid.uuid4().hex
|
||||
return await self._post(
|
||||
_jsonrpc_message(method, params, request_id=request_id),
|
||||
expect_response=True,
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
async def _post(
|
||||
self,
|
||||
payload: dict,
|
||||
*,
|
||||
expect_response: bool,
|
||||
request_id: Optional[str] = None,
|
||||
) -> Any:
|
||||
"""向 Streamable HTTP MCP 服务发送一条 JSON-RPC 消息。"""
|
||||
if not self.server.url:
|
||||
raise RuntimeError("HTTP MCP 服务器缺少 URL")
|
||||
headers = {
|
||||
"Accept": "application/json, text/event-stream",
|
||||
"Content-Type": "application/json",
|
||||
**(self.server.headers or {}),
|
||||
}
|
||||
if self.session_id:
|
||||
headers["Mcp-Session-Id"] = self.session_id
|
||||
response = await AsyncRequestUtils(
|
||||
headers=headers,
|
||||
timeout=_normalize_timeout(self.server.timeout),
|
||||
content_type="application/json",
|
||||
accept_type="application/json, text/event-stream",
|
||||
http2=False,
|
||||
).post_res(self.server.url, json=payload, raise_exception=True)
|
||||
try:
|
||||
if not response:
|
||||
raise RuntimeError("HTTP MCP 请求无响应")
|
||||
response.raise_for_status()
|
||||
session_id = response.headers.get("Mcp-Session-Id")
|
||||
if session_id:
|
||||
self.session_id = session_id
|
||||
if not expect_response:
|
||||
return None
|
||||
content_type = response.headers.get("content-type", "").lower()
|
||||
if "text/event-stream" in content_type:
|
||||
return _parse_sse_text_response(response.text, request_id or "")
|
||||
data = response.json()
|
||||
return _extract_jsonrpc_result(data, request_id or "")
|
||||
finally:
|
||||
if response is not None:
|
||||
await response.aclose()
|
||||
|
||||
|
||||
class _SseMcpSession:
|
||||
"""旧版 HTTP+SSE MCP 会话。"""
|
||||
|
||||
def __init__(self, server: AgentMcpServerConfig) -> None:
|
||||
self.server = server
|
||||
self.response = None
|
||||
self.endpoint: Optional[str] = None
|
||||
self._stream_manager = None
|
||||
self._event_iterator = None
|
||||
|
||||
async def __aenter__(self) -> "_SseMcpSession":
|
||||
"""打开 SSE 流并读取服务端回传的 POST endpoint。"""
|
||||
if not self.server.url:
|
||||
raise RuntimeError("SSE MCP 服务器缺少 URL")
|
||||
self._stream_manager = AsyncRequestUtils(
|
||||
headers={"Accept": "text/event-stream", **(self.server.headers or {})},
|
||||
timeout=_normalize_timeout(self.server.timeout),
|
||||
accept_type="text/event-stream",
|
||||
http2=False,
|
||||
).get_stream(self.server.url, raise_exception=True)
|
||||
self.response = await self._stream_manager.__aenter__()
|
||||
if not self.response:
|
||||
raise RuntimeError("SSE MCP 连接无响应")
|
||||
self.response.raise_for_status()
|
||||
self._event_iterator = _iter_sse_events(self.response).__aiter__()
|
||||
self.endpoint = await self._read_endpoint()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> None:
|
||||
"""关闭 SSE 流。"""
|
||||
if self._stream_manager:
|
||||
await self._stream_manager.__aexit__(exc_type, exc, tb)
|
||||
|
||||
async def notify(self, method: str, params: Optional[dict[str, Any]] = None) -> None:
|
||||
"""发送不需要响应的 JSON-RPC 通知。"""
|
||||
await self._post(_jsonrpc_message(method, params))
|
||||
|
||||
async def request(self, method: str, params: Optional[dict[str, Any]] = None) -> Any:
|
||||
"""发送 JSON-RPC 请求并等待 SSE 流上的响应。"""
|
||||
request_id = uuid.uuid4().hex
|
||||
await self._post(_jsonrpc_message(method, params, request_id=request_id))
|
||||
timeout = _normalize_timeout(self.server.timeout)
|
||||
while True:
|
||||
event = await asyncio.wait_for(self._event_iterator.__anext__(), timeout=timeout)
|
||||
payload = _load_sse_json_payload(event.get("event", ""), event.get("data", ""))
|
||||
if isinstance(payload, dict) and payload.get("id") == request_id:
|
||||
return _extract_jsonrpc_result(payload, request_id)
|
||||
|
||||
async def _read_endpoint(self) -> str:
|
||||
"""读取 SSE endpoint 事件中的 POST 地址。"""
|
||||
timeout = _normalize_timeout(self.server.timeout)
|
||||
while True:
|
||||
event = await asyncio.wait_for(self._event_iterator.__anext__(), timeout=timeout)
|
||||
if event.get("event") != "endpoint":
|
||||
continue
|
||||
endpoint = str(event.get("data") or "").strip()
|
||||
if not endpoint:
|
||||
continue
|
||||
return urljoin(self.server.url, endpoint)
|
||||
|
||||
async def _post(self, payload: dict) -> None:
|
||||
"""向 SSE 握手返回的 endpoint 发送 JSON-RPC 消息。"""
|
||||
if not self.endpoint:
|
||||
raise RuntimeError("SSE MCP endpoint 未初始化")
|
||||
response = await AsyncRequestUtils(
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
**(self.server.headers or {}),
|
||||
},
|
||||
timeout=_normalize_timeout(self.server.timeout),
|
||||
content_type="application/json",
|
||||
accept_type="application/json",
|
||||
http2=False,
|
||||
).post_res(self.endpoint, json=payload, raise_exception=True)
|
||||
try:
|
||||
if not response:
|
||||
raise RuntimeError("SSE MCP POST 请求无响应")
|
||||
response.raise_for_status()
|
||||
finally:
|
||||
if response is not None:
|
||||
await response.aclose()
|
||||
|
||||
|
||||
async def _open_mcp_session(server: AgentMcpServerConfig):
|
||||
"""根据配置创建对应的 MCP 传输会话。"""
|
||||
transport = "http" if server.transport == "streamable_http" else server.transport
|
||||
if transport == "stdio":
|
||||
return _StdioMcpSession(server)
|
||||
if transport == "sse":
|
||||
return _SseMcpSession(server)
|
||||
if transport == "http":
|
||||
return _HttpMcpSession(server)
|
||||
raise RuntimeError(f"不支持的 MCP 传输协议: {server.transport}")
|
||||
|
||||
|
||||
class AgentMcpManager:
|
||||
"""管理 Agent 外部 MCP 服务器配置、工具发现和工具调用。"""
|
||||
|
||||
def get_servers(self) -> list[AgentMcpServerConfig]:
|
||||
"""读取已保存的外部 MCP 服务器配置。"""
|
||||
raw_servers = SystemConfigOper().get(SystemConfigKey.AIAgentMcpServers) or []
|
||||
if not isinstance(raw_servers, list):
|
||||
return []
|
||||
servers: list[AgentMcpServerConfig] = []
|
||||
for raw_server in raw_servers:
|
||||
try:
|
||||
servers.append(self.normalize_server(raw_server))
|
||||
except Exception as err:
|
||||
logger.warning(f"忽略无效的 Agent MCP 配置: {err}")
|
||||
return servers
|
||||
|
||||
async def save_servers(self, servers: list[AgentMcpServerConfig]) -> bool:
|
||||
"""保存外部 MCP 服务器配置。"""
|
||||
normalized_servers = [self.normalize_server(server).model_dump() for server in servers]
|
||||
return await SystemConfigOper().async_set(
|
||||
SystemConfigKey.AIAgentMcpServers,
|
||||
normalized_servers or None,
|
||||
)
|
||||
|
||||
def normalize_server(self, value: Any) -> AgentMcpServerConfig:
|
||||
"""规范化单个 MCP 服务器配置。"""
|
||||
if isinstance(value, AgentMcpServerConfig):
|
||||
raw_server = value.model_dump()
|
||||
elif isinstance(value, dict):
|
||||
raw_server = dict(value)
|
||||
else:
|
||||
raise ValueError("MCP 服务器配置必须是对象")
|
||||
|
||||
raw_server["id"] = str(raw_server.get("id") or uuid.uuid4().hex[:12]).strip()
|
||||
raw_server["name"] = str(raw_server.get("name") or raw_server["id"]).strip()
|
||||
raw_server["transport"] = str(raw_server.get("transport") or "stdio").strip()
|
||||
raw_server["description"] = str(raw_server.get("description") or "").strip() or None
|
||||
raw_server["command"] = str(raw_server.get("command") or "").strip() or None
|
||||
raw_server["args"] = [str(item) for item in raw_server.get("args") or []]
|
||||
raw_server["env"] = _normalize_string_dict(raw_server.get("env"))
|
||||
raw_server["url"] = str(raw_server.get("url") or "").strip() or None
|
||||
raw_server["headers"] = _normalize_string_dict(raw_server.get("headers"))
|
||||
raw_server["timeout"] = _normalize_timeout(raw_server.get("timeout"))
|
||||
raw_server["tool_prefix"] = str(raw_server.get("tool_prefix") or "").strip() or None
|
||||
raw_server["require_admin"] = bool(raw_server.get("require_admin", True))
|
||||
return AgentMcpServerConfig.model_validate(raw_server)
|
||||
|
||||
def config_signature(self) -> str:
|
||||
"""生成外部 MCP 配置签名,用于 Agent 图缓存失效。"""
|
||||
payload = [server.model_dump() for server in self.get_servers()]
|
||||
raw_text = json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str)
|
||||
return hashlib.sha256(raw_text.encode("utf-8")).hexdigest()
|
||||
|
||||
async def initialize_session(self, session) -> None:
|
||||
"""完成 MCP initialize 和 initialized 通知流程。"""
|
||||
await session.request(
|
||||
"initialize",
|
||||
{
|
||||
"protocolVersion": MCP_PROTOCOL_VERSION,
|
||||
"capabilities": {},
|
||||
"clientInfo": {
|
||||
"name": MCP_CLIENT_NAME,
|
||||
"version": "1.0.0",
|
||||
},
|
||||
},
|
||||
)
|
||||
await session.notify("notifications/initialized")
|
||||
|
||||
async def list_server_tools(self, server: AgentMcpServerConfig) -> list[AgentMcpToolSpec]:
|
||||
"""连接单个 MCP 服务器并读取工具列表。"""
|
||||
normalized_server = self.normalize_server(server)
|
||||
session_manager = await _open_mcp_session(normalized_server)
|
||||
async with session_manager as session:
|
||||
await self.initialize_session(session)
|
||||
result = await session.request("tools/list")
|
||||
tools_payload = result.get("tools", []) if isinstance(result, dict) else []
|
||||
tool_specs: list[AgentMcpToolSpec] = []
|
||||
for item in tools_payload:
|
||||
if not isinstance(item, dict) or not item.get("name"):
|
||||
continue
|
||||
tool_name = str(item["name"])
|
||||
tool_specs.append(
|
||||
AgentMcpToolSpec(
|
||||
server=normalized_server,
|
||||
name=tool_name,
|
||||
agent_tool_name=_build_agent_tool_name(normalized_server, tool_name),
|
||||
description=str(item.get("description") or ""),
|
||||
input_schema=_normalize_input_schema(item.get("inputSchema")),
|
||||
)
|
||||
)
|
||||
return tool_specs
|
||||
|
||||
async def list_enabled_tool_specs(self) -> list[AgentMcpToolSpec]:
|
||||
"""读取所有启用 MCP 服务器暴露的工具定义。"""
|
||||
tool_specs: list[AgentMcpToolSpec] = []
|
||||
seen_names: set[str] = set()
|
||||
for server in self.get_servers():
|
||||
if not server.enabled:
|
||||
continue
|
||||
try:
|
||||
for spec in await self.list_server_tools(server):
|
||||
if spec.agent_tool_name in seen_names:
|
||||
logger.warning(f"跳过重复的 MCP Agent 工具名: {spec.agent_tool_name}")
|
||||
continue
|
||||
tool_specs.append(spec)
|
||||
seen_names.add(spec.agent_tool_name)
|
||||
except Exception as err:
|
||||
logger.warning(f"读取 MCP 服务器 {server.name} 工具失败: {err}")
|
||||
return tool_specs
|
||||
|
||||
async def call_server_tool(
|
||||
self,
|
||||
server: AgentMcpServerConfig,
|
||||
tool_name: str,
|
||||
arguments: Optional[dict[str, Any]] = None,
|
||||
) -> Any:
|
||||
"""调用单个 MCP 服务器上的指定工具。"""
|
||||
normalized_server = self.normalize_server(server)
|
||||
session_manager = await _open_mcp_session(normalized_server)
|
||||
async with session_manager as session:
|
||||
await self.initialize_session(session)
|
||||
return await session.request(
|
||||
"tools/call",
|
||||
{
|
||||
"name": tool_name,
|
||||
"arguments": arguments or {},
|
||||
},
|
||||
)
|
||||
|
||||
async def test_server(self, server: AgentMcpServerConfig) -> AgentMcpServerTestResult:
|
||||
"""测试 MCP 服务器连接并返回工具列表。"""
|
||||
tool_specs = await self.list_server_tools(server)
|
||||
tools = [
|
||||
AgentMcpServerToolInfo(
|
||||
name=spec.name,
|
||||
agent_tool_name=spec.agent_tool_name,
|
||||
description=spec.description,
|
||||
input_schema=spec.input_schema,
|
||||
)
|
||||
for spec in tool_specs
|
||||
]
|
||||
return AgentMcpServerTestResult(
|
||||
success=True,
|
||||
message=f"连接成功,发现 {len(tools)} 个工具",
|
||||
tools=tools,
|
||||
tool_count=len(tools),
|
||||
)
|
||||
|
||||
|
||||
agent_mcp_manager = AgentMcpManager()
|
||||
@@ -4,9 +4,10 @@ import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from langchain_core.messages import BaseMessage
|
||||
from langchain_core.messages import BaseMessage, messages_from_dict, messages_to_dict
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.agentchat_oper import AgentChatOper
|
||||
from app.log import logger
|
||||
from app.schemas.agent import ConversationMemory
|
||||
|
||||
@@ -70,24 +71,43 @@ class MemoryManager:
|
||||
self, session_id: str, user_id: str
|
||||
) -> List[BaseMessage]:
|
||||
"""
|
||||
为Agent获取最近的消息(仅内存缓存)
|
||||
为Agent获取最近的消息。
|
||||
|
||||
如果消息Token数量超过模型最大上下文长度的阀值,会自动进行摘要裁剪
|
||||
优先使用内存缓存,缓存不存在时从数据库恢复上一轮持久化的原始 messages。
|
||||
"""
|
||||
memory = self.get_memory(session_id, user_id)
|
||||
if not memory:
|
||||
if memory:
|
||||
return memory.messages
|
||||
|
||||
try:
|
||||
chat = AgentChatOper().get(session_id=session_id, user_id=user_id)
|
||||
if not chat:
|
||||
chat = AgentChatOper().get(session_id=session_id)
|
||||
except Exception as e:
|
||||
logger.debug(f"读取持久化Agent会话失败: {e}")
|
||||
return []
|
||||
if not chat or not chat.agent_messages:
|
||||
return []
|
||||
|
||||
# 获取所有消息
|
||||
try:
|
||||
messages = messages_from_dict(chat.agent_messages)
|
||||
except Exception as e:
|
||||
logger.debug(f"恢复持久化Agent消息失败: {e}")
|
||||
return []
|
||||
|
||||
memory = ConversationMemory(
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
messages=messages,
|
||||
)
|
||||
self.save_memory(memory)
|
||||
return memory.messages
|
||||
|
||||
def save_agent_messages(
|
||||
self, session_id: str, user_id: str, messages: List[BaseMessage]
|
||||
):
|
||||
"""
|
||||
保存Agent消息(仅内存缓存)
|
||||
|
||||
注意:Redis中的记忆通过TTL机制自动过期,这里只更新内存缓存,Redis会在下次访问时自动过期
|
||||
保存Agent消息到内存缓存与持久化会话表。
|
||||
"""
|
||||
memory = self.get_memory(session_id, user_id)
|
||||
if not memory:
|
||||
@@ -98,6 +118,14 @@ class MemoryManager:
|
||||
|
||||
# 更新内存缓存
|
||||
self.save_memory(memory)
|
||||
try:
|
||||
AgentChatOper().save_agent_messages(
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
messages=messages_to_dict(messages),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"持久化Agent消息失败: {e}")
|
||||
|
||||
def save_memory(self, memory: ConversationMemory):
|
||||
"""
|
||||
|
||||
@@ -3,14 +3,19 @@
|
||||
|
||||
按日期存储在 CONFIG_PATH/agent/activity/YYYY-MM-DD.md 中,
|
||||
每次 Agent 执行完毕后自动调用 LLM 对本轮对话生成简洁的活动摘要,
|
||||
并在每次 Agent 启动时加载近几天的活动日志注入系统提示词。
|
||||
并在每次 Agent 启动时注入轻量索引,完整日志由工具按需查询。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Annotated, Any, NotRequired, TypedDict
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, NotRequired, Optional, TypedDict
|
||||
|
||||
import anyio
|
||||
from anyio import Path as AsyncPath
|
||||
from langchain.agents.middleware.types import (
|
||||
AgentMiddleware,
|
||||
@@ -20,39 +25,302 @@ from langchain.agents.middleware.types import (
|
||||
ModelResponse,
|
||||
PrivateStateAttr, # noqa
|
||||
ResponseT,
|
||||
ToolCallRequest,
|
||||
)
|
||||
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
||||
from langchain_core.tools import StructuredTool
|
||||
from langgraph.runtime import Runtime
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.middleware.utils import append_to_system_message
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.log import logger
|
||||
|
||||
# 活动日志保留天数
|
||||
DEFAULT_RETENTION_DAYS = 7
|
||||
|
||||
# 注入系统提示词时加载的天数
|
||||
# 注入系统提示词时索引的天数
|
||||
PROMPT_LOAD_DAYS = 3
|
||||
|
||||
# 工具默认查询的天数
|
||||
DEFAULT_QUERY_DAYS = 7
|
||||
|
||||
# 工具单次返回的最大条数
|
||||
DEFAULT_QUERY_LIMIT = 20
|
||||
MAX_QUERY_LIMIT = 50
|
||||
|
||||
# 每日日志文件最大大小 (256KB)
|
||||
MAX_LOG_FILE_SIZE = 256 * 1024
|
||||
|
||||
# 提取本轮对话上下文的最大字符数(避免过长的对话消耗太多 token)
|
||||
MAX_CONTEXT_FOR_SUMMARY = 4000
|
||||
|
||||
SUMMARY_SKIP_MARKER = "SKIP"
|
||||
QUERY_ACTIVITY_LOG_TOOL_NAME = "query_activity_log"
|
||||
QUERY_ACTIVITY_LOG_TOOL_DESCRIPTION = (
|
||||
"Query recent MoviePilot Agent activity logs on demand. Use this when the user asks what was done before, "
|
||||
"asks to continue a previous task, or explicitly references recent agent activity. Supports keyword, date, "
|
||||
"recent-day window, limit, and optional regex filters. If a keyword search returns no results, retry with "
|
||||
"a shorter keyword, a larger days window, or no keyword to inspect recent entries."
|
||||
)
|
||||
|
||||
# LLM 总结的提示词
|
||||
SUMMARY_PROMPT = """请根据以下 AI 助手与用户的对话记录,生成一条简洁的活动摘要(中文,一句话,不超过80字)。
|
||||
摘要应包含:用户的需求是什么、助手做了什么、结果如何。
|
||||
只输出摘要内容,不要加任何前缀、标点序号或解释。
|
||||
SUMMARY_PROMPT = """请判断以下 AI 助手与用户的对话是否值得写入 MoviePilot 活动日志。
|
||||
|
||||
如果本轮只是问候、寒暄、感谢、确认、闲聊、没有实际任务、没有工具动作、任务没有推进、纯粹的格式纠正或无意义空转,请只输出:SKIP
|
||||
|
||||
如果值得记录,请输出一条中文单行活动摘要,要求:
|
||||
- 40 到 160 个汉字左右,信息密度高,不要写成泛泛一句话。
|
||||
- 只输出摘要正文,不要标题、编号、Markdown、JSON 或解释。
|
||||
- 尽量包含:用户目标、关键对象(影片/剧集/站点/路径/任务/设置)、助手采取的关键动作或工具、结果状态、失败原因或下一步。
|
||||
- 如果有明确 ID、路径、站点名、任务状态、成功/失败数量,请保留关键值。
|
||||
- 不要记录 API Key、Cookie、Token、密码等敏感信息;如出现请写成“敏感信息已省略”。
|
||||
|
||||
推荐格式示例:
|
||||
用户要求整理 `/downloads/Show`,助手识别为《示例剧》TMDB 12345,并提交 transfer_file 整理,结果成功。
|
||||
用户排查下载失败,助手查询 qBittorrent 任务和站点状态,发现 tracker 超时,建议更换站点或重试。
|
||||
|
||||
对话记录:
|
||||
{conversation}"""
|
||||
|
||||
ACTIVITY_ENTRY_PATTERN = re.compile(r"^-\s+\*\*(?P<time>\d{2}:\d{2})\*\*\s+(?P<summary>.+)$")
|
||||
|
||||
|
||||
class QueryActivityLogInput(BaseModel):
|
||||
"""查询活动日志工具的输入参数模型。"""
|
||||
|
||||
keyword: Optional[str] = Field(
|
||||
None,
|
||||
description=(
|
||||
"Optional plain-text keyword to filter activity summaries. Use short title, path, site, task, "
|
||||
"or status fragments; omit it to inspect latest entries."
|
||||
),
|
||||
)
|
||||
use_regex: Optional[bool] = Field(
|
||||
False,
|
||||
description=(
|
||||
"Whether to treat keyword as a regular expression. Defaults to false; enable only for "
|
||||
"alternative or pattern matching."
|
||||
),
|
||||
)
|
||||
date: Optional[str] = Field(
|
||||
None,
|
||||
description="Optional exact date in YYYY-MM-DD format. If omitted, recent days are searched.",
|
||||
)
|
||||
days: Optional[int] = Field(
|
||||
DEFAULT_QUERY_DAYS,
|
||||
description="Number of recent days to search when date is not specified.",
|
||||
)
|
||||
limit: Optional[int] = Field(
|
||||
DEFAULT_QUERY_LIMIT,
|
||||
description="Maximum number of activity entries to return.",
|
||||
)
|
||||
|
||||
|
||||
def _coerce_query_limit(limit: Optional[int]) -> int:
|
||||
"""规范化活动日志查询条数。"""
|
||||
if limit is None:
|
||||
return DEFAULT_QUERY_LIMIT
|
||||
try:
|
||||
value = int(limit)
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_QUERY_LIMIT
|
||||
return min(max(value, 1), MAX_QUERY_LIMIT)
|
||||
|
||||
|
||||
def _build_log_path(activity_dir: str, date_str: str) -> Path:
|
||||
"""构建指定日期的活动日志路径。"""
|
||||
return Path(activity_dir) / f"{date_str}.md"
|
||||
|
||||
|
||||
def _iter_recent_dates(days: int) -> list[str]:
|
||||
"""返回从今天开始向前的日期字符串列表。"""
|
||||
normalized_days = max(1, int(days or 1))
|
||||
today = datetime.now().date()
|
||||
return [
|
||||
(today - timedelta(days=index)).strftime("%Y-%m-%d")
|
||||
for index in range(normalized_days)
|
||||
]
|
||||
|
||||
|
||||
def _parse_activity_entries(date_str: str, content: str) -> list[dict[str, str]]:
|
||||
"""从单日活动日志 Markdown 中解析活动条目。"""
|
||||
entries: list[dict[str, str]] = []
|
||||
for line in content.splitlines():
|
||||
match = ACTIVITY_ENTRY_PATTERN.match(line.strip())
|
||||
if not match:
|
||||
continue
|
||||
entries.append(
|
||||
{
|
||||
"date": date_str,
|
||||
"time": match.group("time"),
|
||||
"summary": match.group("summary").strip(),
|
||||
}
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def _activity_summary_matches_keyword(
|
||||
summary: str,
|
||||
keyword: str,
|
||||
regex_pattern: Optional[re.Pattern[str]],
|
||||
) -> bool:
|
||||
"""判断活动摘要是否命中普通关键词或正则表达式。"""
|
||||
if regex_pattern:
|
||||
return bool(regex_pattern.search(summary))
|
||||
return keyword.lower() in summary.lower()
|
||||
|
||||
|
||||
def load_activity_log_index(activity_dir: str, days: int = PROMPT_LOAD_DAYS) -> dict[str, str]:
|
||||
"""加载近期活动日志索引,不返回完整日志正文。"""
|
||||
index: dict[str, str] = {}
|
||||
for date_str in _iter_recent_dates(days):
|
||||
log_path = _build_log_path(activity_dir, date_str)
|
||||
if not log_path.is_file():
|
||||
continue
|
||||
try:
|
||||
content = log_path.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception as e:
|
||||
logger.warning(f"读取活动日志索引失败 {log_path}: {e}")
|
||||
continue
|
||||
entry_count = len(_parse_activity_entries(date_str, content))
|
||||
if entry_count:
|
||||
index[date_str] = f"{entry_count} 条活动记录"
|
||||
return index
|
||||
|
||||
|
||||
def query_activity_logs(
|
||||
activity_dir: str,
|
||||
*,
|
||||
keyword: Optional[str] = None,
|
||||
use_regex: bool = False,
|
||||
date: Optional[str] = None,
|
||||
days: int = DEFAULT_QUERY_DAYS,
|
||||
limit: Optional[int] = DEFAULT_QUERY_LIMIT,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
查询活动日志条目。
|
||||
|
||||
:param activity_dir: 活动日志目录
|
||||
:param keyword: 可选关键词,按摘要文本过滤
|
||||
:param use_regex: 是否将关键词按正则表达式匹配
|
||||
:param date: 可选日期,格式为 ``YYYY-MM-DD``
|
||||
:param days: 未指定日期时向前查询的天数
|
||||
:param limit: 返回条数上限
|
||||
:return: 查询结果载荷
|
||||
"""
|
||||
normalized_limit = _coerce_query_limit(limit)
|
||||
normalized_keyword = str(keyword or "").strip()
|
||||
normalized_use_regex = bool(use_regex)
|
||||
regex_pattern: Optional[re.Pattern[str]] = None
|
||||
if normalized_keyword and normalized_use_regex:
|
||||
try:
|
||||
regex_pattern = re.compile(normalized_keyword, re.IGNORECASE)
|
||||
except re.error as err:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"无效的活动日志正则表达式: {err}",
|
||||
"activity_dir": activity_dir,
|
||||
"keyword": normalized_keyword,
|
||||
"use_regex": normalized_use_regex,
|
||||
"date": date,
|
||||
"days": days if not date else None,
|
||||
"searched_dates": [],
|
||||
"total_count": 0,
|
||||
"returned_count": 0,
|
||||
"truncated": False,
|
||||
"entries": [],
|
||||
}
|
||||
date_candidates = [date] if date else _iter_recent_dates(days)
|
||||
entries: list[dict[str, str]] = []
|
||||
searched_dates: list[str] = []
|
||||
|
||||
for date_str in date_candidates:
|
||||
if not date_str:
|
||||
continue
|
||||
searched_dates.append(date_str)
|
||||
log_path = _build_log_path(activity_dir, date_str)
|
||||
if not log_path.is_file():
|
||||
continue
|
||||
try:
|
||||
content = log_path.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception as e:
|
||||
logger.warning(f"读取活动日志失败 {log_path}: {e}")
|
||||
continue
|
||||
for entry in _parse_activity_entries(date_str, content):
|
||||
if normalized_keyword and not _activity_summary_matches_keyword(
|
||||
entry["summary"], normalized_keyword, regex_pattern
|
||||
):
|
||||
continue
|
||||
entries.append(entry)
|
||||
|
||||
entries.sort(key=lambda item: (item["date"], item["time"]), reverse=True)
|
||||
total_count = len(entries)
|
||||
return {
|
||||
"success": True,
|
||||
"activity_dir": activity_dir,
|
||||
"keyword": normalized_keyword or None,
|
||||
"use_regex": normalized_use_regex,
|
||||
"date": date,
|
||||
"days": days if not date else None,
|
||||
"searched_dates": searched_dates,
|
||||
"total_count": total_count,
|
||||
"returned_count": min(total_count, normalized_limit),
|
||||
"truncated": total_count > normalized_limit,
|
||||
"entries": entries[:normalized_limit],
|
||||
}
|
||||
|
||||
|
||||
class _ActivityLogToolProvider:
|
||||
"""活动日志工具的查询实现。"""
|
||||
|
||||
def __init__(self, *, activity_dir: str) -> None:
|
||||
"""初始化活动日志查询目录。"""
|
||||
self._activity_dir = activity_dir
|
||||
|
||||
async def query_activity_log(
|
||||
self,
|
||||
keyword: Optional[str] = None,
|
||||
use_regex: Optional[bool] = False,
|
||||
date: Optional[str] = None,
|
||||
days: Optional[int] = DEFAULT_QUERY_DAYS,
|
||||
limit: Optional[int] = DEFAULT_QUERY_LIMIT,
|
||||
) -> str:
|
||||
"""查询活动日志并返回 JSON 字符串。"""
|
||||
logger.info(
|
||||
"查询活动日志: keyword=%s, use_regex=%s, date=%s, days=%s, limit=%s",
|
||||
keyword,
|
||||
use_regex,
|
||||
date,
|
||||
days,
|
||||
limit,
|
||||
)
|
||||
try:
|
||||
payload = query_activity_logs(
|
||||
self._activity_dir,
|
||||
keyword=keyword,
|
||||
use_regex=bool(use_regex),
|
||||
date=date,
|
||||
days=days or DEFAULT_QUERY_DAYS,
|
||||
limit=limit,
|
||||
)
|
||||
return json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
except Exception as err:
|
||||
logger.error(f"查询活动日志失败: {err}", exc_info=True)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"查询活动日志时发生错误: {str(err)}",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
class ActivityLogState(AgentState):
|
||||
"""ActivityLogMiddleware 的状态模型。"""
|
||||
|
||||
activity_log_contents: NotRequired[Annotated[dict[str, str], PrivateStateAttr]]
|
||||
"""将日期字符串映射到日志内容的字典。标记为私有,不包含在最终代理状态中。"""
|
||||
"""将日期字符串映射到日志索引摘要的字典。标记为私有,不包含在最终代理状态中。"""
|
||||
|
||||
|
||||
class ActivityLogStateUpdate(TypedDict):
|
||||
@@ -61,7 +329,7 @@ class ActivityLogStateUpdate(TypedDict):
|
||||
activity_log_contents: dict[str, str]
|
||||
|
||||
|
||||
def _extract_last_round(messages: list) -> list | None:
|
||||
def _extract_last_round(messages: list) -> Optional[list]:
|
||||
"""从完整消息列表中提取最后一轮交互。
|
||||
|
||||
从最后一条 HumanMessage 到消息末尾即为本轮交互。
|
||||
@@ -148,7 +416,23 @@ def _format_conversation_for_summary(round_messages: list) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def _summarize_with_llm(conversation_text: str) -> str | None:
|
||||
def _should_skip_activity_summary(round_messages: list) -> bool:
|
||||
"""判断本轮交互是否无需生成活动日志。"""
|
||||
if not round_messages:
|
||||
return True
|
||||
|
||||
has_tool_activity = any(
|
||||
isinstance(msg, ToolMessage)
|
||||
or (isinstance(msg, AIMessage) and bool(getattr(msg, "tool_calls", None)))
|
||||
for msg in round_messages
|
||||
)
|
||||
if has_tool_activity:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def _summarize_with_llm(conversation_text: str) -> Optional[str]:
|
||||
"""调用 LLM 对对话文本生成活动摘要。
|
||||
|
||||
参数:
|
||||
@@ -166,50 +450,39 @@ async def _summarize_with_llm(conversation_text: str) -> str | None:
|
||||
summary = response.content.strip()
|
||||
# 清理模型可能输出的前缀(如 "摘要:" "总结:")
|
||||
summary = re.sub(r"^(摘要|总结|活动记录)[::]\s*", "", summary)
|
||||
if summary.strip().upper() == SUMMARY_SKIP_MARKER:
|
||||
return None
|
||||
return summary if summary else None
|
||||
except Exception as e:
|
||||
logger.debug("LLM summarization failed: %s", e)
|
||||
logger.debug(f"LLM 活动摘要生成失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
ACTIVITY_LOG_SYSTEM_PROMPT = """<activity_log>
|
||||
{activity_log}
|
||||
</activity_log>
|
||||
<activity_log_index>
|
||||
{activity_log_index}
|
||||
</activity_log_index>
|
||||
|
||||
<activity_log_guidelines>
|
||||
The above <activity_log> contains a record of your recent interactions with the user, automatically maintained by the system.
|
||||
|
||||
**How to use this information:**
|
||||
- Reference past activities when relevant to provide continuity (e.g., "之前帮你订阅了《XXX》,现在有更新了")
|
||||
- Use activity history to understand ongoing tasks and user patterns
|
||||
- When the user asks "你之前帮我做了什么" or similar questions, refer to this log
|
||||
- Activity logs are automatically recorded after each interaction - you do NOT need to manually update them
|
||||
|
||||
**What is automatically logged:**
|
||||
- Each user interaction: what was asked, which tools were used, and the outcome
|
||||
- Timestamps for all activities
|
||||
- The log is organized by date for easy reference
|
||||
|
||||
**Important:**
|
||||
- Activity logs are READ-ONLY from your perspective - the system manages them automatically
|
||||
- Do not attempt to edit or write to activity log files
|
||||
- For long-term preferences and knowledge, continue to use MEMORY.md
|
||||
- Activity logs are retained for {retention_days} days and then automatically cleaned up
|
||||
The index only shows recent dates and entry counts, not full log contents.
|
||||
Use `query_activity_log` only when the user references previous work, asks to continue a prior task, or recent activity is clearly relevant.
|
||||
Activity logs are read-only and retained for {retention_days} days; use MEMORY.md for durable preferences.
|
||||
</activity_log_guidelines>
|
||||
</activity_log>
|
||||
"""
|
||||
|
||||
|
||||
class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, ResponseT]): # noqa
|
||||
"""自动记录和加载 Agent 活动日志的中间件。
|
||||
"""自动记录 Agent 活动日志并注入轻量索引的中间件。
|
||||
|
||||
- abefore_agent: 加载近几天的活动日志
|
||||
- awrap_model_call: 将活动日志注入系统提示词
|
||||
- abefore_agent: 加载近几天的活动日志索引
|
||||
- awrap_model_call: 将活动日志索引和检索规则注入系统提示词
|
||||
- aafter_agent: 从本次对话中提取摘要并追加到当日日志文件
|
||||
|
||||
参数:
|
||||
activity_dir: 活动日志存储目录路径。
|
||||
retention_days: 日志保留天数(默认 7 天)。
|
||||
prompt_load_days: 注入系统提示词时加载的天数(默认 3 天)。
|
||||
prompt_load_days: 注入系统提示词时索引的天数(默认 3 天)。
|
||||
"""
|
||||
|
||||
state_schema = ActivityLogState
|
||||
@@ -220,20 +493,34 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
|
||||
activity_dir: str,
|
||||
retention_days: int = DEFAULT_RETENTION_DAYS,
|
||||
prompt_load_days: int = PROMPT_LOAD_DAYS,
|
||||
stream_handler: Optional[Any] = None,
|
||||
) -> None:
|
||||
"""初始化活动日志中间件。"""
|
||||
self.activity_dir = activity_dir
|
||||
self.retention_days = retention_days
|
||||
self.prompt_load_days = prompt_load_days
|
||||
self.stream_handler = stream_handler
|
||||
self._background_tasks: set[asyncio.Task[None]] = set()
|
||||
self._tool_provider = _ActivityLogToolProvider(activity_dir=activity_dir)
|
||||
self.tools = [
|
||||
StructuredTool.from_function(
|
||||
coroutine=self._tool_provider.query_activity_log,
|
||||
name=QUERY_ACTIVITY_LOG_TOOL_NAME,
|
||||
description=QUERY_ACTIVITY_LOG_TOOL_DESCRIPTION,
|
||||
args_schema=QueryActivityLogInput,
|
||||
tags=[ToolTag.Read, ToolTag.System],
|
||||
)
|
||||
]
|
||||
|
||||
def _get_log_path(self, date_str: str) -> AsyncPath:
|
||||
"""获取指定日期的日志文件路径。"""
|
||||
return AsyncPath(self.activity_dir) / f"{date_str}.md"
|
||||
|
||||
def _format_activity_log(self, contents: dict[str, str]) -> str:
|
||||
"""格式化活动日志用于系统提示词注入。"""
|
||||
"""格式化活动日志索引用于系统提示词注入。"""
|
||||
if not contents:
|
||||
return ACTIVITY_LOG_SYSTEM_PROMPT.format(
|
||||
activity_log="(暂无活动记录)",
|
||||
activity_log_index="(近期暂无活动日志索引。需要历史上下文时可调用 query_activity_log。)",
|
||||
retention_days=self.retention_days,
|
||||
)
|
||||
|
||||
@@ -247,35 +534,22 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
|
||||
|
||||
if not sections:
|
||||
return ACTIVITY_LOG_SYSTEM_PROMPT.format(
|
||||
activity_log="(暂无活动记录)",
|
||||
activity_log_index="(近期暂无活动日志索引。需要历史上下文时可调用 query_activity_log。)",
|
||||
retention_days=self.retention_days,
|
||||
)
|
||||
|
||||
log_body = "\n\n".join(sections)
|
||||
log_body = "\n".join(sections)
|
||||
return ACTIVITY_LOG_SYSTEM_PROMPT.format(
|
||||
activity_log=log_body,
|
||||
activity_log_index=log_body,
|
||||
retention_days=self.retention_days,
|
||||
)
|
||||
|
||||
async def _load_recent_logs(self) -> dict[str, str]:
|
||||
"""加载近几天的活动日志。"""
|
||||
contents: dict[str, str] = {}
|
||||
today = datetime.now().date()
|
||||
|
||||
for i in range(self.prompt_load_days):
|
||||
date = today - timedelta(days=i)
|
||||
date_str = date.strftime("%Y-%m-%d")
|
||||
log_path = self._get_log_path(date_str)
|
||||
|
||||
if await log_path.exists():
|
||||
try:
|
||||
content = await log_path.read_text(encoding="utf-8")
|
||||
contents[date_str] = content
|
||||
logger.debug("Loaded activity log for %s", date_str)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load activity log %s: %s", date_str, e)
|
||||
|
||||
return contents
|
||||
"""加载近几天的活动日志索引。"""
|
||||
return load_activity_log_index(
|
||||
activity_dir=self.activity_dir,
|
||||
days=self.prompt_load_days,
|
||||
)
|
||||
|
||||
async def _append_activity(self, summary: str) -> None:
|
||||
"""将一条活动记录追加到当日日志文件。"""
|
||||
@@ -303,14 +577,29 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
|
||||
entry = f"- **{now_str}** {summary}\n"
|
||||
try:
|
||||
if await log_path.exists():
|
||||
existing = await log_path.read_text(encoding="utf-8")
|
||||
await log_path.write_text(existing + entry, encoding="utf-8")
|
||||
async with await anyio.open_file(
|
||||
log_path,
|
||||
mode="a",
|
||||
encoding="utf-8",
|
||||
) as stream:
|
||||
await stream.write(entry)
|
||||
else:
|
||||
header = f"# {today_str} 活动日志\n\n"
|
||||
await log_path.write_text(header + entry, encoding="utf-8")
|
||||
logger.debug("Activity logged: %s", summary[:80])
|
||||
try:
|
||||
fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
|
||||
except FileExistsError:
|
||||
async with await anyio.open_file(
|
||||
log_path,
|
||||
mode="a",
|
||||
encoding="utf-8",
|
||||
) as stream:
|
||||
await stream.write(entry)
|
||||
else:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
||||
stream.write(header + entry)
|
||||
logger.debug(f"Activity logged: {summary[:80]}")
|
||||
except Exception as e:
|
||||
logger.warning("Failed to append activity log: %s", e)
|
||||
logger.warning(f"Failed to append activity log: {e}")
|
||||
|
||||
async def _cleanup_old_logs(self) -> None:
|
||||
"""清理超过保留天数的旧日志文件。"""
|
||||
@@ -332,20 +621,54 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
|
||||
file_date = datetime.strptime(match.group(1), "%Y-%m-%d").date()
|
||||
if file_date < cutoff_date:
|
||||
await path.unlink()
|
||||
logger.debug("Cleaned up old activity log: %s", path.name)
|
||||
logger.debug(f"Cleaned up old activity log: {path.name}")
|
||||
except ValueError:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.warning("Failed to cleanup old activity logs: %s", e)
|
||||
logger.warning(f"Failed to cleanup old activity logs: {e}")
|
||||
|
||||
def _schedule_activity_recording(self, messages: list) -> None:
|
||||
"""提交后台活动记录任务,不阻塞当前 Agent 会话结束。"""
|
||||
task = asyncio.create_task(self._record_activity(messages))
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(self._on_activity_recording_done)
|
||||
|
||||
def _on_activity_recording_done(self, task: asyncio.Task[None]) -> None:
|
||||
"""清理已完成的后台任务并记录未捕获异常。"""
|
||||
self._background_tasks.discard(task)
|
||||
try:
|
||||
task.result()
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("活动日志后台记录任务已取消")
|
||||
except Exception as err:
|
||||
logger.warning(f"活动日志后台记录任务失败: {err}")
|
||||
|
||||
async def _record_activity(self, messages: list) -> None:
|
||||
"""在后台生成本轮活动摘要并写入活动日志。"""
|
||||
try:
|
||||
# 提取本轮交互
|
||||
round_messages = _extract_last_round(messages)
|
||||
if not round_messages:
|
||||
return
|
||||
if _should_skip_activity_summary(round_messages):
|
||||
return
|
||||
|
||||
# 格式化对话文本
|
||||
conversation_text = _format_conversation_for_summary(round_messages)
|
||||
if not conversation_text:
|
||||
return
|
||||
|
||||
# 调用 LLM 生成摘要
|
||||
summary = await _summarize_with_llm(conversation_text)
|
||||
if summary:
|
||||
await self._append_activity(summary)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to record activity: {e}")
|
||||
|
||||
async def abefore_agent(
|
||||
self, state: ActivityLogState, runtime: Runtime
|
||||
) -> ActivityLogStateUpdate | None:
|
||||
) -> Optional[ActivityLogStateUpdate]:
|
||||
"""在 Agent 执行前加载近期活动日志。"""
|
||||
# 如果已经加载则跳过
|
||||
if "activity_log_contents" in state:
|
||||
return None
|
||||
|
||||
contents = await self._load_recent_logs()
|
||||
|
||||
# 趁机清理旧日志(低频操作,不影响性能)
|
||||
@@ -374,33 +697,57 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
|
||||
modified_request = self.modify_request(request)
|
||||
return await handler(modified_request)
|
||||
|
||||
async def awrap_tool_call(
|
||||
self,
|
||||
request: ToolCallRequest,
|
||||
handler: Callable[[ToolCallRequest], Awaitable[Any]],
|
||||
) -> Any:
|
||||
"""在活动日志查询工具执行时记录聚合摘要。"""
|
||||
tool = request.tool
|
||||
tool_name = getattr(tool, "name", None)
|
||||
if tool_name != QUERY_ACTIVITY_LOG_TOOL_NAME:
|
||||
return await handler(request)
|
||||
|
||||
tool_call = request.tool_call or {}
|
||||
tool_args = tool_call.get("args") or {}
|
||||
if not isinstance(tool_args, dict):
|
||||
tool_args = {}
|
||||
logger.info(
|
||||
f"开始执行活动日志查询工具: keyword={tool_args.get('keyword') or '-'}, "
|
||||
f"date={tool_args.get('date') or '-'}"
|
||||
)
|
||||
if self.stream_handler and getattr(self.stream_handler, "is_streaming", False):
|
||||
self.stream_handler.record_tool_call(
|
||||
tool_name=QUERY_ACTIVITY_LOG_TOOL_NAME,
|
||||
tool_message=QUERY_ACTIVITY_LOG_TOOL_DESCRIPTION,
|
||||
tool_kwargs=tool_args,
|
||||
)
|
||||
try:
|
||||
result = await handler(request)
|
||||
except Exception as err:
|
||||
logger.error(f"活动日志查询工具执行失败: error={err}")
|
||||
raise
|
||||
logger.info("活动日志查询工具执行完成")
|
||||
return result
|
||||
|
||||
async def aafter_agent(
|
||||
self, state: ActivityLogState, runtime: Runtime
|
||||
) -> dict[str, Any] | None:
|
||||
"""Agent 执行完毕后,调用 LLM 对本轮对话生成摘要并追加到当日活动日志。"""
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""Agent 执行完毕后,异步提交活动日志记录任务。"""
|
||||
try:
|
||||
messages = state.get("messages", [])
|
||||
if not messages:
|
||||
return None
|
||||
|
||||
# 提取本轮交互
|
||||
round_messages = _extract_last_round(messages)
|
||||
if not round_messages:
|
||||
return None
|
||||
|
||||
# 格式化对话文本
|
||||
conversation_text = _format_conversation_for_summary(round_messages)
|
||||
if not conversation_text:
|
||||
return None
|
||||
|
||||
# 调用 LLM 生成摘要
|
||||
summary = await _summarize_with_llm(conversation_text)
|
||||
if summary:
|
||||
await self._append_activity(summary)
|
||||
self._schedule_activity_recording(list(messages))
|
||||
except Exception as e:
|
||||
logger.warning("Failed to record activity: %s", e)
|
||||
logger.warning(f"Failed to record activity: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
__all__ = ["ActivityLogMiddleware"]
|
||||
__all__ = [
|
||||
"ActivityLogMiddleware",
|
||||
"QUERY_ACTIVITY_LOG_TOOL_NAME",
|
||||
"load_activity_log_index",
|
||||
"query_activity_logs",
|
||||
]
|
||||
|
||||
@@ -128,7 +128,7 @@ def _parse_job_metadata(
|
||||
async def _alist_jobs(source_path: AsyncPath) -> list[JobMetadata]:
|
||||
"""异步列出指定路径下的所有任务。
|
||||
|
||||
扫描包含 JOB.md 的目录并解析其元数据。
|
||||
扫描包含 JOB.md 的目录并解析其元数据,遇到非法 UTF-8 字节时以替换字符兜底。
|
||||
"""
|
||||
jobs: list[JobMetadata] = []
|
||||
|
||||
@@ -151,7 +151,10 @@ async def _alist_jobs(source_path: AsyncPath) -> list[JobMetadata]:
|
||||
for job_path in job_dirs:
|
||||
job_md_path = job_path / "JOB.md"
|
||||
|
||||
job_content = await job_md_path.read_text(encoding="utf-8")
|
||||
job_content = await job_md_path.read_text(
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
|
||||
# 解析元数据
|
||||
job_metadata = _parse_job_metadata(
|
||||
@@ -192,7 +195,7 @@ async def load_jobs_metadata(source_paths: list[str]) -> list[JobMetadata]:
|
||||
|
||||
JOBS_SYSTEM_PROMPT = """
|
||||
<jobs_system>
|
||||
You have a **scheduled jobs** system that allows you to track and execute long-running or recurring tasks.
|
||||
You have a scheduled jobs system for user-requested delayed or recurring work.
|
||||
|
||||
**Jobs Location:** `{jobs_location}`
|
||||
|
||||
@@ -200,71 +203,11 @@ You have a **scheduled jobs** system that allows you to track and execute long-r
|
||||
|
||||
{jobs_list}
|
||||
|
||||
**Job File Format:**
|
||||
|
||||
Each job is a directory containing a `JOB.md` file with YAML frontmatter followed by task details:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: 任务名称(简短中文描述)
|
||||
description: 任务的详细描述,说明要做什么
|
||||
schedule: once 或 recurring
|
||||
status: pending / in_progress / completed / cancelled
|
||||
last_run: "YYYY-MM-DD HH:MM"(上次执行时间,可选)
|
||||
---
|
||||
# 任务详情
|
||||
|
||||
## 目标
|
||||
详细描述这个任务要完成的目标。
|
||||
|
||||
## 执行日志
|
||||
记录每次执行的情况和结果。
|
||||
|
||||
- **2024-01-15 10:00** - 执行了XXX操作,结果:成功/失败
|
||||
- **2024-01-16 10:00** - 继续执行XXX...
|
||||
```
|
||||
|
||||
**Job Lifecycle Rules:**
|
||||
|
||||
1. **Creating a Job**: When a user asks you to do something periodically or at a later time:
|
||||
- Create a new directory under the jobs location, directory name is the `job-id` (lowercase, hyphens, 1-64 chars)
|
||||
- Write a `JOB.md` file with proper frontmatter and detailed task description
|
||||
- Set `schedule: once` for one-time tasks, `schedule: recurring` for repeating tasks (e.g., daily sign-in, weekly checks)
|
||||
- Set initial `status: pending`
|
||||
|
||||
2. **Executing a Job**: When you work on a job:
|
||||
- Update `status: in_progress` in the frontmatter
|
||||
- Execute the required actions using your tools
|
||||
- Log the execution result in the "执行日志" section with timestamp
|
||||
- Update `last_run` in frontmatter to current time
|
||||
|
||||
3. **Completing a Job**:
|
||||
- For `schedule: once` tasks: set `status: completed` after successful execution
|
||||
- For `schedule: recurring` tasks: keep `status: pending` after execution, only update `last_run` time. The job stays active for the next scheduled run.
|
||||
- Set `status: cancelled` if the user explicitly asks to cancel/stop a task
|
||||
|
||||
4. **Heartbeat Check**: You will be periodically woken up to check pending jobs. When woken up:
|
||||
- Read the jobs directory to find all active jobs (status: pending or in_progress)
|
||||
- Skip jobs with `status: completed` or `status: cancelled`
|
||||
- For `schedule: recurring` jobs, check `last_run` to determine if it's time to run again
|
||||
- Execute pending jobs and update their status/logs accordingly
|
||||
|
||||
**Important Notes:**
|
||||
- Each job MUST have its own separate directory and JOB.md file to avoid conflicts
|
||||
- Always update the frontmatter fields (status, last_run) when executing a job
|
||||
- Keep execution logs concise but informative
|
||||
- For recurring jobs, maintain a rolling log (keep recent entries, you can summarize/remove old entries to keep the file manageable)
|
||||
- When creating jobs, make the description detailed enough that you can understand and execute the task in future sessions without additional context
|
||||
|
||||
**When to Create Jobs:**
|
||||
- User says "每天帮我..." / "定期..." / "定时..." / "提醒我..." / "以后每次..."
|
||||
- User requests a task that should be done repeatedly
|
||||
- User asks for monitoring or periodic checking of something
|
||||
|
||||
**When NOT to Create Jobs:**
|
||||
- User asks for an immediate one-time action (just do it now)
|
||||
- Simple questions or conversations
|
||||
- Tasks that are already handled by MoviePilot's built-in scheduler services
|
||||
Rules:
|
||||
- Create jobs only when the user asks for delayed, recurring, reminder, or monitoring behavior.
|
||||
- Do not create jobs for immediate one-time work or work already handled by MoviePilot schedulers.
|
||||
- Each job lives in its own directory with a `JOB.md`; read the listed file before executing or updating an active job.
|
||||
- During heartbeat checks, act only on `pending` or `in_progress` jobs, update status/last_run/logs, and leave recurring jobs `pending` after each run.
|
||||
</jobs_system>
|
||||
"""
|
||||
|
||||
@@ -340,12 +283,7 @@ class JobsMiddleware(AgentMiddleware[JobsState, ContextT, ResponseT]): # noqa
|
||||
) -> JobsStateUpdate | None:
|
||||
"""在 Agent 执行前异步加载任务元数据。
|
||||
|
||||
每个会话仅加载一次。若 state 中已有则跳过。
|
||||
"""
|
||||
# 如果 state 中已存在元数据则跳过
|
||||
if "jobs_metadata" in state:
|
||||
return None
|
||||
|
||||
return JobsStateUpdate(
|
||||
jobs_metadata=await load_jobs_metadata(self.sources)
|
||||
)
|
||||
|
||||
@@ -302,7 +302,6 @@ class MemoryMiddleware(AgentMiddleware[MemoryState, ContextT, ResponseT]): # no
|
||||
"""在代理执行前扫描记忆目录并加载所有 .md 文件的内容。
|
||||
|
||||
自动发现目录下所有 `.md` 文件并加载其内容到状态中。
|
||||
如果状态中尚未存在则进行加载。
|
||||
同时检测记忆文件是否为空,设置 memory_empty 标志位,
|
||||
以便在系统提示词中触发初始化引导流程。
|
||||
|
||||
@@ -314,10 +313,6 @@ class MemoryMiddleware(AgentMiddleware[MemoryState, ContextT, ResponseT]): # no
|
||||
返回:
|
||||
填充了 memory_contents 和 memory_empty 的状态更新。
|
||||
"""
|
||||
# 如果已经加载则跳过
|
||||
if "memory_contents" in state:
|
||||
return None
|
||||
|
||||
# 扫描目录下所有 .md 文件
|
||||
md_files = await self._scan_memory_files()
|
||||
|
||||
@@ -335,7 +330,7 @@ class MemoryMiddleware(AgentMiddleware[MemoryState, ContextT, ResponseT]): # no
|
||||
MAX_MEMORY_FILE_SIZE,
|
||||
)
|
||||
continue
|
||||
contents[path] = await file_path.read_text(encoding="utf-8")
|
||||
contents[path] = await file_path.read_text(encoding="utf-8", errors="replace")
|
||||
logger.debug("Loaded memory from: %s", path)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to read memory file %s: %s", path, e)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
from collections.abc import Awaitable, Callable
|
||||
from pathlib import Path
|
||||
from typing import Annotated, List
|
||||
from typing import Annotated, Any, List, Optional
|
||||
from typing import NotRequired, TypedDict
|
||||
|
||||
import yaml # noqa
|
||||
@@ -14,12 +15,16 @@ from langchain.agents.middleware.types import (
|
||||
ModelRequest,
|
||||
ModelResponse,
|
||||
ResponseT,
|
||||
ToolCallRequest,
|
||||
)
|
||||
from langchain.agents.middleware.types import PrivateStateAttr # noqa
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langchain_core.tools import StructuredTool
|
||||
from langgraph.runtime import Runtime
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.middleware.utils import append_to_system_message
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.log import logger
|
||||
|
||||
# 安全提示: SKILL.md 文件最大限制为 10MB,防止 DoS 攻击
|
||||
@@ -84,6 +89,15 @@ class SkillsStateUpdate(TypedDict):
|
||||
"""待合并的 skill 元数据列表。"""
|
||||
|
||||
|
||||
class SkillToolInput(BaseModel):
|
||||
"""Skill 加载工具的输入参数模型。"""
|
||||
|
||||
name: str = Field(
|
||||
...,
|
||||
description="Skill name or id from the available skills list.",
|
||||
)
|
||||
|
||||
|
||||
def _parse_skill_metadata( # noqa: C901
|
||||
content: str,
|
||||
skill_path: str,
|
||||
@@ -234,7 +248,7 @@ async def _alist_skills(source_path: AsyncPath) -> list[SkillMetadata]:
|
||||
for skill_path in skill_dirs:
|
||||
skill_md_path = skill_path / "SKILL.md"
|
||||
|
||||
skill_content = await skill_md_path.read_text(encoding="utf-8")
|
||||
skill_content = await skill_md_path.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
# 解析元数据
|
||||
skill_metadata = _parse_skill_metadata(
|
||||
@@ -248,73 +262,63 @@ async def _alist_skills(source_path: AsyncPath) -> list[SkillMetadata]:
|
||||
return skills
|
||||
|
||||
|
||||
def _list_skills(source_path: Path) -> list[SkillMetadata]:
|
||||
"""同步列出指定路径下的所有技能元数据。"""
|
||||
if not source_path.exists():
|
||||
return []
|
||||
|
||||
skill_dirs = [
|
||||
path
|
||||
for path in source_path.iterdir()
|
||||
if path.is_dir() and (path / "SKILL.md").is_file()
|
||||
]
|
||||
if not skill_dirs:
|
||||
return []
|
||||
|
||||
skill_dirs.sort(key=lambda p: p.name.casefold())
|
||||
|
||||
skills: list[SkillMetadata] = []
|
||||
for skill_path in skill_dirs:
|
||||
skill_md_path = skill_path / "SKILL.md"
|
||||
skill_content = skill_md_path.read_text(encoding="utf-8", errors="replace")
|
||||
skill_metadata = _parse_skill_metadata(
|
||||
content=skill_content,
|
||||
skill_path=str(skill_md_path),
|
||||
skill_id=skill_path.name,
|
||||
)
|
||||
if skill_metadata:
|
||||
skills.append(skill_metadata)
|
||||
return skills
|
||||
|
||||
|
||||
SKILLS_SYSTEM_PROMPT = """
|
||||
<skills_system>
|
||||
You have access to a skills library that provides specialized capabilities and domain knowledge.
|
||||
|
||||
{skills_locations}
|
||||
You have access to a skills library for specialized MoviePilot workflows.
|
||||
|
||||
**Available Skills:**
|
||||
|
||||
{skills_list}
|
||||
|
||||
**How to Use Skills (Progressive Disclosure):**
|
||||
|
||||
Skills follow a **progressive disclosure** pattern - you see their name and description above, but only read full instructions when needed:
|
||||
|
||||
1. **Recognize when a skill applies**: Check if the user's task matches a skill's description
|
||||
2. **Read the skill's full instructions**: Use the path shown in the skill list above
|
||||
3. **Follow the skill's instructions**: SKILL.md contains step-by-step workflows, best practices, and examples
|
||||
4. **Access supporting files**: Skills may include helper scripts, configs, or reference docs - use absolute paths
|
||||
|
||||
**Creating New Skills:**
|
||||
|
||||
When you identify a repetitive complex workflow or specialized task that would benefit from being a skill, you can create one:
|
||||
|
||||
1. **Directory Structure**: Create a new directory in one of the skills locations. The directory name is the `skill-id`.
|
||||
- Path format: `<skills_location>/<skill-id>/SKILL.md`
|
||||
- `skill-id` constraints: 1-64 characters, lowercase letters, numbers, and hyphens only.
|
||||
2. **SKILL.md Format**: Must start with a YAML frontmatter followed by markdown instructions.
|
||||
```markdown
|
||||
---
|
||||
name: Brief tool name (Chinese)
|
||||
description: Detailed functional description and use cases (1-1024 chars)
|
||||
allowed-tools: "tool1 tool2" (optional, space-separated list of recommended tools)
|
||||
compatibility: "Environment requirements" (optional, max 500 chars)
|
||||
---
|
||||
# Skill Instructions
|
||||
Step-by-step workflows, best practices, and examples go here.
|
||||
```
|
||||
3. **Supporting Files**: You can add `.py` scripts, `.yaml` configs, or other files within the same skill directory. Reference them using absolute paths in `SKILL.md`.
|
||||
|
||||
**When to Use Skills:**
|
||||
- User's request matches a skill's domain (e.g., "research X" -> web-research skill)
|
||||
- You need specialized knowledge or structured workflows
|
||||
- A skill provides proven patterns for complex tasks
|
||||
|
||||
**Executing Skill Scripts:**
|
||||
Skills may contain Python scripts or other executable files. Always use absolute paths from the skill list.
|
||||
|
||||
**Example Workflow:**
|
||||
|
||||
User: "Can you research the latest developments in quantum computing?"
|
||||
|
||||
1. Check available skills -> See "web-research" skill with its path
|
||||
2. Read the skill using the path shown
|
||||
3. Follow the skill's research workflow (search -> organize -> synthesize)
|
||||
4. Use any helper scripts with absolute paths
|
||||
|
||||
Remember: Skills make you more capable and consistent. When in doubt, check if a skill exists for the task!
|
||||
When the user's request matches a skill description, call the `skill` tool with that skill name before taking task actions. Follow the loaded SKILL.md instructions, and load referenced supporting files only when needed. Do not create or rewrite skills unless the user explicitly asks for skill authoring.
|
||||
</skills_system>
|
||||
"""
|
||||
|
||||
SKILL_TOOL_NAME = "skill"
|
||||
SKILL_TOOL_DESCRIPTION = """Loads the full instructions for a MoviePilot skill by name or id.
|
||||
|
||||
Available skills:
|
||||
{skills_catalog}
|
||||
|
||||
Call this tool when the user's task matches one of the available skills. The tool returns the SKILL.md content and metadata so you can follow the skill's instructions. Do not use this for simple tasks that do not need a skill.
|
||||
"""
|
||||
|
||||
|
||||
def _extract_version(skill_md: Path) -> int:
|
||||
"""从 SKILL.md 文件中快速提取 version 字段,无法提取时返回 0。"""
|
||||
try:
|
||||
content = skill_md.read_text(encoding="utf-8")
|
||||
content = skill_md.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception as err:
|
||||
print(err)
|
||||
logger.debug(f"读取技能版本失败: {err}")
|
||||
return 0
|
||||
match = re.match(r"^---\s*\n(.*?)\n---\s*\n", content, re.DOTALL)
|
||||
if not match:
|
||||
@@ -402,6 +406,108 @@ def _sync_bundled_skills(bundled_dir: Path, target_dir: Path) -> None:
|
||||
logger.warning("更新内置技能 '%s' 失败: %s", skill_src.name, e)
|
||||
|
||||
|
||||
class _SkillToolProvider:
|
||||
"""Skill 工具的目录扫描和文件读取实现。"""
|
||||
|
||||
def __init__(self, *, sources: list[str]) -> None:
|
||||
"""初始化 Skill 工具数据源。"""
|
||||
self._sources = sources
|
||||
|
||||
@staticmethod
|
||||
def _normalize_name(value: object) -> str:
|
||||
"""标准化技能名称用于匹配。"""
|
||||
return str(value or "").strip().casefold()
|
||||
|
||||
@classmethod
|
||||
def _skill_matches(cls, skill: SkillMetadata, query: str) -> bool:
|
||||
"""判断技能元数据是否匹配用户提供的名称。"""
|
||||
normalized_query = cls._normalize_name(query)
|
||||
candidates = [
|
||||
skill.get("id"),
|
||||
skill.get("name"),
|
||||
]
|
||||
return any(
|
||||
cls._normalize_name(candidate) == normalized_query
|
||||
for candidate in candidates
|
||||
)
|
||||
|
||||
async def _find_skill(self, name: str) -> Optional[SkillMetadata]:
|
||||
"""从中间件配置的 skills 目录中查找指定技能。"""
|
||||
all_skills: dict[str, SkillMetadata] = {}
|
||||
for source_path in self._sources:
|
||||
skill_source_path = AsyncPath(source_path)
|
||||
if not await skill_source_path.exists():
|
||||
continue
|
||||
for skill in await _alist_skills(skill_source_path):
|
||||
all_skills[skill["name"]] = skill
|
||||
|
||||
for skill in all_skills.values():
|
||||
if self._skill_matches(skill, name):
|
||||
return skill
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def _read_skill_content(skill_path: str) -> tuple[str, bool]:
|
||||
"""读取技能文件内容,并在超出上限时返回截断标记。"""
|
||||
path = AsyncPath(skill_path)
|
||||
stat = await path.stat()
|
||||
truncated = stat.st_size > MAX_SKILL_FILE_SIZE
|
||||
async with await path.open("rb") as handle:
|
||||
raw_content = await handle.read(MAX_SKILL_FILE_SIZE)
|
||||
return raw_content.decode("utf-8", errors="replace"), truncated
|
||||
|
||||
async def load_skill(self, name: str) -> str:
|
||||
"""加载指定 Skill 的完整说明并返回 JSON 字符串。"""
|
||||
logger.info(f"加载 Skill: name={name}")
|
||||
try:
|
||||
skill = await self._find_skill(name)
|
||||
if not skill:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"未找到 Skill: {name}",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
content, truncated = await self._read_skill_content(skill["path"])
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"skill": {
|
||||
"id": skill.get("id"),
|
||||
"name": skill.get("name"),
|
||||
"description": skill.get("description"),
|
||||
"path": skill.get("path"),
|
||||
"allowed_tools": skill.get("allowed_tools", []),
|
||||
},
|
||||
"content": content,
|
||||
"truncated": truncated,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
except Exception as err:
|
||||
logger.error(f"加载 Skill 失败: {err}", exc_info=True)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"加载 Skill 时发生错误: {str(err)}",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def _format_skill_tool_catalog(skills: list[SkillMetadata]) -> str:
|
||||
"""渲染 Skill 工具描述中的可用技能目录。"""
|
||||
if not skills:
|
||||
return "(No skills are currently available.)"
|
||||
return "\n".join(
|
||||
f"- {skill['id']}: {skill['name']} - {skill['description']}"
|
||||
for skill in skills
|
||||
)
|
||||
|
||||
|
||||
class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # noqa
|
||||
"""加载并向系统提示词注入 Agent Skill 的中间件。
|
||||
|
||||
@@ -416,6 +522,7 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no
|
||||
*,
|
||||
sources: list[str],
|
||||
bundled_skills_dir: str | None = None,
|
||||
stream_handler: Optional[Any] = None,
|
||||
) -> None:
|
||||
"""初始化 Skill 中间件。
|
||||
|
||||
@@ -426,26 +533,63 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no
|
||||
bundled_skills_dir : str | None
|
||||
项目内置技能目录路径。若提供,在首次加载前会将其中不存在于
|
||||
sources 首个目录的技能自动复制过去。
|
||||
stream_handler : Optional[Any]
|
||||
流式输出处理器,用于记录 skill 工具调用摘要。
|
||||
"""
|
||||
self.sources = sources
|
||||
self.bundled_skills_dir = bundled_skills_dir
|
||||
self.stream_handler = stream_handler
|
||||
self.system_prompt_template = SKILLS_SYSTEM_PROMPT
|
||||
self._skill_provider = _SkillToolProvider(sources=sources)
|
||||
self.tools = [
|
||||
StructuredTool.from_function(
|
||||
coroutine=self._skill_provider.load_skill,
|
||||
name=SKILL_TOOL_NAME,
|
||||
description=SKILL_TOOL_DESCRIPTION.format(
|
||||
skills_catalog=_format_skill_tool_catalog(
|
||||
self._load_skills_metadata()
|
||||
)
|
||||
),
|
||||
args_schema=SkillToolInput,
|
||||
tags=[ToolTag.Read, ToolTag.Skill],
|
||||
)
|
||||
]
|
||||
|
||||
def _format_skills_locations(self) -> str:
|
||||
"""格式化技能位置信息用于系统提示词。"""
|
||||
locations = []
|
||||
def _sync_bundled_skills(self) -> None:
|
||||
"""将项目内置 Skill 同步到首个用户技能目录。"""
|
||||
if not self.bundled_skills_dir or not self.sources:
|
||||
return
|
||||
bundled = Path(self.bundled_skills_dir)
|
||||
target = Path(self.sources[0])
|
||||
try:
|
||||
_sync_bundled_skills(bundled, target)
|
||||
except Exception as e:
|
||||
logger.warning("同步内置技能失败: %s", e)
|
||||
|
||||
for i, source_path in enumerate(self.sources):
|
||||
suffix = " (higher priority)" if i == len(self.sources) - 1 else ""
|
||||
locations.append(f"**MoviePilot Skills**: `{source_path}`{suffix}")
|
||||
def _load_skills_metadata(self) -> list[SkillMetadata]:
|
||||
"""同步加载当前配置目录中的 Skill 元数据。"""
|
||||
self._sync_bundled_skills()
|
||||
all_skills: dict[str, SkillMetadata] = {}
|
||||
for source_path in self.sources:
|
||||
for skill in _list_skills(Path(source_path)):
|
||||
all_skills[skill["name"]] = skill
|
||||
return list(all_skills.values())
|
||||
|
||||
return "\n".join(locations)
|
||||
def _refresh_skill_tool_description(
|
||||
self, skills: list[SkillMetadata]
|
||||
) -> None:
|
||||
"""刷新 skill 工具描述中的可用技能目录。"""
|
||||
if not self.tools:
|
||||
return
|
||||
self.tools[0].description = SKILL_TOOL_DESCRIPTION.format(
|
||||
skills_catalog=_format_skill_tool_catalog(skills)
|
||||
)
|
||||
|
||||
def _format_skills_list(self, skills: list[SkillMetadata]) -> str:
|
||||
@staticmethod
|
||||
def _format_skills_list(skills: list[SkillMetadata]) -> str:
|
||||
"""格式化技能元数据列表用于系统提示词。"""
|
||||
if not skills:
|
||||
paths = [f"{source_path}" for source_path in self.sources]
|
||||
return f"(No skills available yet. You can create skills in {' or '.join(paths)})"
|
||||
return "(No skills available yet.)"
|
||||
|
||||
lines = []
|
||||
for skill in skills:
|
||||
@@ -456,18 +600,15 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no
|
||||
lines.append(desc_line)
|
||||
if skill["allowed_tools"]:
|
||||
lines.append(f" -> Allowed tools: {', '.join(skill['allowed_tools'])}")
|
||||
lines.append(f" -> Read `{skill['path']}` for full instructions")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def modify_request(self, request: ModelRequest[ContextT]) -> ModelRequest[ContextT]:
|
||||
"""将技能文档注入模型请求的系统消息中。"""
|
||||
skills_metadata = request.state.get("skills_metadata", []) # noqa
|
||||
skills_locations = self._format_skills_locations()
|
||||
skills_list = self._format_skills_list(skills_metadata)
|
||||
|
||||
skills_section = self.system_prompt_template.format(
|
||||
skills_locations=skills_locations,
|
||||
skills_list=skills_list,
|
||||
)
|
||||
|
||||
@@ -482,21 +623,9 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no
|
||||
) -> SkillsStateUpdate | None: # ty: ignore[invalid-method-override]
|
||||
"""在 Agent 执行前异步加载技能元数据。
|
||||
|
||||
每个会话仅加载一次。若 state 中已有则跳过。
|
||||
首次加载时,会先将内置技能同步到用户目录(如不存在)。
|
||||
"""
|
||||
# 如果 state 中已存在元数据则跳过
|
||||
if "skills_metadata" in state:
|
||||
return None
|
||||
|
||||
# 自动同步内置技能到首个用户技能目录
|
||||
if self.bundled_skills_dir and self.sources:
|
||||
bundled = Path(self.bundled_skills_dir)
|
||||
target = Path(self.sources[0])
|
||||
try:
|
||||
_sync_bundled_skills(bundled, target)
|
||||
except Exception as e:
|
||||
logger.warning("同步内置技能失败: %s", e)
|
||||
self._sync_bundled_skills()
|
||||
|
||||
all_skills: dict[str, SkillMetadata] = {}
|
||||
|
||||
@@ -511,6 +640,7 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no
|
||||
all_skills[skill["name"]] = skill
|
||||
|
||||
skills = list(all_skills.values())
|
||||
self._refresh_skill_tool_description(skills)
|
||||
return SkillsStateUpdate(skills_metadata=skills)
|
||||
|
||||
async def awrap_model_call(
|
||||
@@ -524,5 +654,37 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no
|
||||
modified_request = self.modify_request(request)
|
||||
return await handler(modified_request)
|
||||
|
||||
async def awrap_tool_call(
|
||||
self,
|
||||
request: ToolCallRequest,
|
||||
handler: Callable[[ToolCallRequest], Awaitable[Any]],
|
||||
) -> Any:
|
||||
"""在 skill 工具执行时记录聚合摘要。"""
|
||||
tool = request.tool
|
||||
tool_name = getattr(tool, "name", None)
|
||||
if tool_name != SKILL_TOOL_NAME:
|
||||
return await handler(request)
|
||||
|
||||
__all__ = ["SkillMetadata", "SkillsMiddleware"]
|
||||
tool_call = request.tool_call or {}
|
||||
tool_args = tool_call.get("args") or {}
|
||||
if not isinstance(tool_args, dict):
|
||||
tool_args = {}
|
||||
logger.info(
|
||||
f"开始执行 Skill 工具: name={tool_args.get('name') or '-'}"
|
||||
)
|
||||
if self.stream_handler and getattr(self.stream_handler, "is_streaming", False):
|
||||
self.stream_handler.record_tool_call(
|
||||
tool_name=SKILL_TOOL_NAME,
|
||||
tool_message="Skill loaded",
|
||||
tool_kwargs=tool_args,
|
||||
)
|
||||
try:
|
||||
result = await handler(request)
|
||||
except Exception as err:
|
||||
logger.error(f"Skill 工具执行失败: error={err}")
|
||||
raise
|
||||
logger.info("Skill 工具执行完成")
|
||||
return result
|
||||
|
||||
|
||||
__all__ = ["SKILL_TOOL_NAME", "SkillMetadata", "SkillsMiddleware"]
|
||||
|
||||
@@ -23,7 +23,9 @@ from langchain_core.messages import AIMessage, HumanMessage
|
||||
from langchain_core.tools import BaseTool, StructuredTool
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.llm import LLMHelper
|
||||
from app.agent.middleware.utils import append_to_system_message
|
||||
from app.agent.runtime import SubAgentDefinition, agent_runtime_manager
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.log import logger
|
||||
|
||||
@@ -38,6 +40,7 @@ SUBAGENT_MAX_ACTIVE_TASKS = 8
|
||||
SUBAGENT_MAX_CONCURRENT_TASKS = 4
|
||||
SUBAGENT_RESULT_MAX_CHARS = 12000
|
||||
SUBAGENT_DESCRIPTION_MAX_CHARS = 500
|
||||
SUBAGENT_PIPELINE_CONTEXT_MAX_CHARS = 12000
|
||||
|
||||
SUBAGENT_PARENT_PROMPT = """<subagents>
|
||||
You may use subagent tools to delegate independent research, retrieval,
|
||||
@@ -50,6 +53,9 @@ Delegation modes:
|
||||
`action=wait`, or `action=cancel` with the returned task IDs.
|
||||
- Use `subagent_task` with `action=run` when you want to launch a bounded
|
||||
batch and wait for the batch in one tool call.
|
||||
- Use `subagent_task` with `action=pipeline` when later subtasks must use
|
||||
previous subagent results. Pipeline steps run sequentially, and each step's
|
||||
result is passed as private context to the next step.
|
||||
|
||||
Rules:
|
||||
- Delegate when a task benefits from focused investigation, such as media identity checks, site/resource search, subscription analysis, download/transfer diagnosis, MoviePilot code/config exploration, or read-only system inspection.
|
||||
@@ -70,7 +76,9 @@ SUBAGENT_CONTROL_DESCRIPTION = (
|
||||
"Use action=start with tasks=[{description, subagent_type}] to launch a batch "
|
||||
"and get task IDs immediately. Use action=status to inspect tasks, action=wait "
|
||||
"to wait for all or any task result, action=cancel to stop running tasks, and "
|
||||
"action=run to launch a bounded batch and wait in one call."
|
||||
"action=run to launch a bounded batch and wait in one call. Use action=pipeline "
|
||||
"to run tasks sequentially while passing each result as private context to the "
|
||||
"next task."
|
||||
)
|
||||
|
||||
SUBAGENT_BASE_PROMPT = """You are a silent subagent working for the MoviePilot main agent.
|
||||
@@ -87,7 +95,7 @@ Requirements:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _SubAgentProfile:
|
||||
"""内置子代理定义。"""
|
||||
"""子代理运行时定义。"""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
@@ -119,9 +127,9 @@ class _SubAgentTaskSpec(BaseModel):
|
||||
class _SubAgentControlInput(BaseModel):
|
||||
"""异步子代理管控工具输入。"""
|
||||
|
||||
action: Literal["start", "status", "wait", "cancel", "run"] = Field(
|
||||
action: Literal["start", "status", "wait", "cancel", "run", "pipeline"] = Field(
|
||||
default="start",
|
||||
description="Task action: start, status, wait, cancel, or run.",
|
||||
description="Task action: start, status, wait, cancel, run, or pipeline.",
|
||||
)
|
||||
description: Optional[str] = Field(
|
||||
default=None,
|
||||
@@ -149,7 +157,10 @@ class _SubAgentControlInput(BaseModel):
|
||||
)
|
||||
timeout_ms: Optional[int] = Field(
|
||||
default=SUBAGENT_DEFAULT_WAIT_TIMEOUT_MS,
|
||||
description="Maximum wait time in milliseconds for action=wait or action=run.",
|
||||
description=(
|
||||
"Maximum wait time in milliseconds for action=wait, action=run, "
|
||||
"or each action=pipeline step."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -186,51 +197,52 @@ def is_subagent_stream_metadata(metadata: Any) -> bool:
|
||||
) == SUBAGENT_STREAM_MARKER_VALUE:
|
||||
return True
|
||||
|
||||
return bool(metadata.get("lc_agent_name") in builtin_subagent_names())
|
||||
return bool(
|
||||
metadata.get("lc_agent_name")
|
||||
in builtin_subagent_names(agent_runtime_manager.current_signature())
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def builtin_subagent_names() -> frozenset[str]:
|
||||
def builtin_subagent_names(
|
||||
runtime_signature: Optional[tuple[tuple[str, int, int], ...]] = None,
|
||||
) -> frozenset[str]:
|
||||
"""返回内置子代理名称集合。"""
|
||||
return frozenset(profile.name for profile in _builtin_subagent_profiles())
|
||||
runtime_signature = runtime_signature or agent_runtime_manager.current_signature()
|
||||
return _cached_builtin_subagent_names(runtime_signature)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _builtin_subagent_profiles() -> tuple[_SubAgentProfile, ...]:
|
||||
"""构建 MoviePilot 默认内置子代理定义。"""
|
||||
default_exclude_tags = frozenset(
|
||||
{
|
||||
ToolTag.Write.value,
|
||||
ToolTag.Message.value,
|
||||
ToolTag.UserInteraction.value,
|
||||
}
|
||||
)
|
||||
general_tags = frozenset(
|
||||
{
|
||||
ToolTag.Media.value,
|
||||
ToolTag.Resource.value,
|
||||
ToolTag.Site.value,
|
||||
ToolTag.Subscription.value,
|
||||
ToolTag.Download.value,
|
||||
ToolTag.Library.value,
|
||||
ToolTag.Transfer.value,
|
||||
ToolTag.System.value,
|
||||
ToolTag.Settings.value,
|
||||
ToolTag.Plugin.value,
|
||||
ToolTag.Workflow.value,
|
||||
ToolTag.Scheduler.value,
|
||||
ToolTag.File.value,
|
||||
ToolTag.Directory.value,
|
||||
ToolTag.Web.value,
|
||||
ToolTag.Command.value,
|
||||
ToolTag.FilterRule.value,
|
||||
ToolTag.Persona.value,
|
||||
ToolTag.SlashCommand.value,
|
||||
ToolTag.Recommendation.value,
|
||||
ToolTag.Metadata.value,
|
||||
}
|
||||
@lru_cache(maxsize=8)
|
||||
def _cached_builtin_subagent_names(
|
||||
runtime_signature: tuple[tuple[str, int, int], ...],
|
||||
) -> frozenset[str]:
|
||||
"""按运行时签名缓存内置子代理名称集合。"""
|
||||
return frozenset(
|
||||
profile.name
|
||||
for profile in _builtin_subagent_profiles(runtime_signature)
|
||||
)
|
||||
|
||||
|
||||
def _builtin_subagent_profiles(
|
||||
runtime_signature: Optional[tuple[tuple[str, int, int], ...]] = None,
|
||||
) -> tuple[_SubAgentProfile, ...]:
|
||||
"""从运行时配置目录加载 MoviePilot 子代理定义。"""
|
||||
runtime_signature = runtime_signature or agent_runtime_manager.current_signature()
|
||||
return _cached_builtin_subagent_profiles(runtime_signature)
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _cached_builtin_subagent_profiles(
|
||||
runtime_signature: tuple[tuple[str, int, int], ...],
|
||||
) -> tuple[_SubAgentProfile, ...]:
|
||||
"""按运行时签名缓存 MoviePilot 子代理定义。"""
|
||||
definitions = agent_runtime_manager.list_subagents()
|
||||
profiles = tuple(
|
||||
_profile_from_runtime_definition(definition)
|
||||
for definition in definitions
|
||||
)
|
||||
if profiles:
|
||||
return profiles
|
||||
logger.warning("未加载到任何子代理定义,使用通用兜底子代理。")
|
||||
return (
|
||||
_SubAgentProfile(
|
||||
name="general-purpose",
|
||||
@@ -239,126 +251,38 @@ def _builtin_subagent_profiles() -> tuple[_SubAgentProfile, ...]:
|
||||
f"{SUBAGENT_BASE_PROMPT}\n"
|
||||
"You specialize in synthesizing media, site, subscription, download, and system status signals."
|
||||
),
|
||||
include_tags=general_tags,
|
||||
exclude_tags=default_exclude_tags,
|
||||
),
|
||||
_SubAgentProfile(
|
||||
name="media-researcher",
|
||||
description="Media research subagent for title recognition, people, episodes, metadata, and library existence checks.",
|
||||
prompt=(
|
||||
f"{SUBAGENT_BASE_PROMPT}\n"
|
||||
"You specialize in media identity resolution, metadata validation, person credits, and library status analysis."
|
||||
),
|
||||
include_tags=frozenset(
|
||||
include_tags=frozenset(tag.value for tag in ToolTag),
|
||||
exclude_tags=frozenset(
|
||||
{
|
||||
ToolTag.Media.value,
|
||||
ToolTag.Library.value,
|
||||
ToolTag.Recommendation.value,
|
||||
ToolTag.Metadata.value,
|
||||
ToolTag.Web.value,
|
||||
ToolTag.Write.value,
|
||||
ToolTag.Message.value,
|
||||
ToolTag.UserInteraction.value,
|
||||
}
|
||||
),
|
||||
exclude_tags=default_exclude_tags,
|
||||
),
|
||||
_SubAgentProfile(
|
||||
name="moviepilot-explorer",
|
||||
description="MoviePilot exploration subagent for source-code inspection, configuration structure analysis, logs, and code-level troubleshooting clues.",
|
||||
prompt=(
|
||||
f"{SUBAGENT_BASE_PROMPT}\n"
|
||||
"You specialize in MoviePilot source-code structure, local configuration files, directory layout, logs or read-only command output, and code-level root-cause troubleshooting. "
|
||||
"Prefer reading relevant code paths before judging behavior, and distinguish code/config evidence from runtime system state."
|
||||
),
|
||||
include_tags=frozenset(
|
||||
{
|
||||
ToolTag.System.value,
|
||||
ToolTag.Settings.value,
|
||||
ToolTag.File.value,
|
||||
ToolTag.Directory.value,
|
||||
ToolTag.Command.value,
|
||||
}
|
||||
),
|
||||
exclude_tags=default_exclude_tags,
|
||||
),
|
||||
_SubAgentProfile(
|
||||
name="resource-searcher",
|
||||
description="Site and resource search subagent for site checks, torrent search, and resource quality analysis.",
|
||||
prompt=(
|
||||
f"{SUBAGENT_BASE_PROMPT}\n"
|
||||
"You specialize in site status, site user data, torrent search results, and resource quality judgment."
|
||||
),
|
||||
include_tags=frozenset(
|
||||
{
|
||||
ToolTag.Resource.value,
|
||||
ToolTag.Site.value,
|
||||
ToolTag.Web.value,
|
||||
ToolTag.Media.value,
|
||||
}
|
||||
),
|
||||
exclude_tags=default_exclude_tags,
|
||||
),
|
||||
_SubAgentProfile(
|
||||
name="subscription-analyst",
|
||||
description="Subscription analysis subagent for subscriptions, history, filter rules, and custom identifiers.",
|
||||
prompt=(
|
||||
f"{SUBAGENT_BASE_PROMPT}\n"
|
||||
"You specialize in current subscription state, subscription history, filter rules, and subscription optimization suggestions."
|
||||
),
|
||||
include_tags=frozenset(
|
||||
{
|
||||
ToolTag.Subscription.value,
|
||||
ToolTag.FilterRule.value,
|
||||
ToolTag.Settings.value,
|
||||
ToolTag.Media.value,
|
||||
}
|
||||
),
|
||||
exclude_tags=default_exclude_tags,
|
||||
),
|
||||
_SubAgentProfile(
|
||||
name="system-diagnostician",
|
||||
description="System diagnosis subagent for read-only inspection of settings, schedulers, workflows, plugins, directories, and command output.",
|
||||
prompt=(
|
||||
f"{SUBAGENT_BASE_PROMPT}\n"
|
||||
"You specialize in settings, plugins, scheduled tasks, workflows, directories, and read-only command diagnostics."
|
||||
),
|
||||
include_tags=frozenset(
|
||||
{
|
||||
ToolTag.System.value,
|
||||
ToolTag.Settings.value,
|
||||
ToolTag.Plugin.value,
|
||||
ToolTag.Workflow.value,
|
||||
ToolTag.Scheduler.value,
|
||||
ToolTag.File.value,
|
||||
ToolTag.Directory.value,
|
||||
ToolTag.Web.value,
|
||||
ToolTag.Command.value,
|
||||
ToolTag.Persona.value,
|
||||
ToolTag.SlashCommand.value,
|
||||
}
|
||||
),
|
||||
exclude_tags=default_exclude_tags,
|
||||
),
|
||||
_SubAgentProfile(
|
||||
name="download-diagnostician",
|
||||
description="Download and transfer diagnosis subagent for downloaders, download tasks, transfer history, and library status.",
|
||||
prompt=(
|
||||
f"{SUBAGENT_BASE_PROMPT}\n"
|
||||
"You specialize in downloaders, download tasks, transfer history, directory settings, and library ingestion state."
|
||||
),
|
||||
include_tags=frozenset(
|
||||
{
|
||||
ToolTag.Download.value,
|
||||
ToolTag.Transfer.value,
|
||||
ToolTag.Library.value,
|
||||
ToolTag.Directory.value,
|
||||
ToolTag.File.value,
|
||||
ToolTag.Media.value,
|
||||
}
|
||||
),
|
||||
exclude_tags=default_exclude_tags,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
builtin_subagent_names.cache_clear = _cached_builtin_subagent_names.cache_clear
|
||||
_builtin_subagent_profiles.cache_clear = _cached_builtin_subagent_profiles.cache_clear
|
||||
|
||||
|
||||
def _profile_from_runtime_definition(
|
||||
definition: SubAgentDefinition,
|
||||
) -> _SubAgentProfile:
|
||||
"""把运行时子代理定义转换为中间件可用的 profile。"""
|
||||
prompt_parts = [SUBAGENT_BASE_PROMPT]
|
||||
if definition.text.strip():
|
||||
prompt_parts.append(definition.text.strip())
|
||||
return _SubAgentProfile(
|
||||
name=definition.subagent_id,
|
||||
description=definition.description,
|
||||
prompt="\n".join(prompt_parts),
|
||||
include_tags=frozenset(definition.include_tags),
|
||||
exclude_tags=frozenset(definition.exclude_tags),
|
||||
)
|
||||
|
||||
|
||||
def _tool_tag_values(tool: BaseTool) -> set[str]:
|
||||
"""读取工具实例上的标签集合。"""
|
||||
tags = getattr(tool, "tags", None) or []
|
||||
@@ -388,34 +312,6 @@ def _format_subagent_catalog(profiles: tuple[_SubAgentProfile, ...]) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _extract_text_content(content: Any) -> str:
|
||||
"""从模型消息内容中提取可读文本。"""
|
||||
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("thought"):
|
||||
continue
|
||||
if block.get("type") in {
|
||||
"thinking",
|
||||
"reasoning_content",
|
||||
"reasoning",
|
||||
"thought",
|
||||
}:
|
||||
continue
|
||||
if isinstance(block.get("text"), str):
|
||||
text_parts.append(block["text"])
|
||||
return "".join(text_parts)
|
||||
return str(content)
|
||||
|
||||
|
||||
def _extract_final_text(result: Any) -> str:
|
||||
"""从子代理执行结果中提取最后一条 AI 文本。"""
|
||||
if isinstance(result, dict):
|
||||
@@ -425,11 +321,11 @@ def _extract_final_text(result: Any) -> str:
|
||||
|
||||
for message in reversed(messages):
|
||||
if isinstance(message, AIMessage) and message.content:
|
||||
text = _extract_text_content(message.content).strip()
|
||||
text = LLMHelper.extract_text_content(message.content).strip()
|
||||
if text:
|
||||
return text
|
||||
|
||||
return _extract_text_content(result).strip()
|
||||
return LLMHelper.extract_text_content(result, fallback_to_string=True).strip()
|
||||
|
||||
|
||||
def _clip_text(text: Any, max_chars: int) -> tuple[str, bool]:
|
||||
@@ -447,6 +343,31 @@ def _format_datetime(value: Optional[datetime]) -> Optional[str]:
|
||||
return value.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def _extract_tool_call_args(request: ToolCallRequest) -> dict[str, Any]:
|
||||
"""提取工具调用参数,并规整为字典。"""
|
||||
tool_call = request.tool_call or {}
|
||||
tool_args = tool_call.get("args") or {}
|
||||
if not isinstance(tool_args, dict):
|
||||
return {}
|
||||
return tool_args
|
||||
|
||||
|
||||
def _record_subagent_tool_call(
|
||||
*,
|
||||
stream_handler: Any,
|
||||
tool_name: str,
|
||||
tool_args: dict[str, Any],
|
||||
) -> None:
|
||||
"""在流式处理器中记录子代理工具调用摘要。"""
|
||||
if not stream_handler or not getattr(stream_handler, "is_streaming", False):
|
||||
return
|
||||
stream_handler.record_tool_call(
|
||||
tool_name=tool_name,
|
||||
tool_message="Subagent invoked",
|
||||
tool_kwargs=tool_args,
|
||||
)
|
||||
|
||||
|
||||
class _SubAgentAgentProvider:
|
||||
"""子代理图懒加载与执行器。"""
|
||||
|
||||
@@ -543,8 +464,10 @@ class MoviePilotSubAgentMiddleware(AgentMiddleware):
|
||||
tools: list[BaseTool],
|
||||
system_prompt: str = SUBAGENT_PARENT_PROMPT,
|
||||
task_description: str = SUBAGENT_TASK_DESCRIPTION,
|
||||
stream_handler: Any = None,
|
||||
) -> None:
|
||||
self.system_prompt = system_prompt
|
||||
self.stream_handler = stream_handler
|
||||
self._provider = _SubAgentAgentProvider(
|
||||
model=model,
|
||||
profiles=profiles,
|
||||
@@ -587,6 +510,35 @@ class MoviePilotSubAgentMiddleware(AgentMiddleware):
|
||||
)
|
||||
return await handler(request.override(system_message=new_system_message))
|
||||
|
||||
async def awrap_tool_call(
|
||||
self,
|
||||
request: ToolCallRequest,
|
||||
handler: Callable[[ToolCallRequest], Awaitable[Any]],
|
||||
) -> Any:
|
||||
"""在 task 子代理工具执行时记录聚合摘要。"""
|
||||
tool = request.tool
|
||||
tool_name = getattr(tool, "name", None)
|
||||
if tool_name != SUBAGENT_TASK_TOOL_NAME:
|
||||
return await handler(request)
|
||||
|
||||
tool_args = _extract_tool_call_args(request)
|
||||
logger.info(
|
||||
f"开始执行子代理工具: tool_name={tool_name}, "
|
||||
f"subagent_type={tool_args.get('subagent_type') or '-'}"
|
||||
)
|
||||
_record_subagent_tool_call(
|
||||
stream_handler=self.stream_handler,
|
||||
tool_name=SUBAGENT_TASK_TOOL_NAME,
|
||||
tool_args=tool_args,
|
||||
)
|
||||
try:
|
||||
result = await handler(request)
|
||||
except Exception as err:
|
||||
logger.error(f"子代理工具执行失败: tool_name={tool_name}, error={err}")
|
||||
raise
|
||||
logger.info(f"子代理工具执行完成: tool_name={tool_name}")
|
||||
return result
|
||||
|
||||
|
||||
class SubAgentTaskControlMiddleware(AgentMiddleware):
|
||||
"""提供异步子代理任务调度工具的中间件。"""
|
||||
@@ -598,8 +550,10 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
|
||||
profiles: tuple[_SubAgentProfile, ...],
|
||||
tools: list[BaseTool],
|
||||
task_description: str = SUBAGENT_CONTROL_DESCRIPTION,
|
||||
stream_handler: Any = None,
|
||||
) -> None:
|
||||
"""初始化异步子代理调度中间件。"""
|
||||
self.stream_handler = stream_handler
|
||||
self._provider = _SubAgentAgentProvider(
|
||||
model=model,
|
||||
profiles=profiles,
|
||||
@@ -858,7 +812,8 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
|
||||
f"pending={len(pending_tasks) - finished_count}"
|
||||
)
|
||||
|
||||
async def _cancel_records(self, records: list[_SubAgentRuntimeTask]) -> None:
|
||||
@staticmethod
|
||||
async def _cancel_records(records: list[_SubAgentRuntimeTask]) -> None:
|
||||
"""取消一组尚未完成的任务。"""
|
||||
cancellable_tasks = [
|
||||
record.task for record in records if not record.task.done()
|
||||
@@ -871,6 +826,156 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
|
||||
await asyncio.gather(*cancellable_tasks, return_exceptions=True)
|
||||
logger.info(f"子代理任务取消完成: tasks={len(cancellable_tasks)}")
|
||||
|
||||
@staticmethod
|
||||
def _pipeline_description(
|
||||
*,
|
||||
description: str,
|
||||
previous_results: list[tuple[_SubAgentRuntimeTask, str]],
|
||||
) -> str:
|
||||
"""追加上游子代理结果,生成当前管道步骤的任务描述。"""
|
||||
normalized_description = description.strip()
|
||||
if not previous_results:
|
||||
return normalized_description
|
||||
|
||||
context_parts = []
|
||||
for step_index, (record, result) in enumerate(previous_results, start=1):
|
||||
clipped_result, result_truncated = _clip_text(
|
||||
result,
|
||||
SUBAGENT_RESULT_MAX_CHARS,
|
||||
)
|
||||
truncated_note = "\n[Result truncated]" if result_truncated else ""
|
||||
context_parts.append(
|
||||
f"Step {step_index} ({record.subagent_type}) result:\n"
|
||||
f"{clipped_result}{truncated_note}"
|
||||
)
|
||||
context_text, context_truncated = _clip_text(
|
||||
"\n\n".join(context_parts),
|
||||
SUBAGENT_PIPELINE_CONTEXT_MAX_CHARS,
|
||||
)
|
||||
truncated_note = "\n[Pipeline context truncated]" if context_truncated else ""
|
||||
return (
|
||||
f"{normalized_description}\n\n"
|
||||
"<pipeline_context>\n"
|
||||
"Previous subagent results are private context for this delegated "
|
||||
"subtask. Use them to complete the current task, but do not expose "
|
||||
"the prior reports verbatim.\n\n"
|
||||
f"{context_text}{truncated_note}\n"
|
||||
"</pipeline_context>"
|
||||
)
|
||||
|
||||
async def _execute_pipeline_task(
|
||||
self,
|
||||
*,
|
||||
record: _SubAgentRuntimeTask,
|
||||
description: str,
|
||||
) -> str:
|
||||
"""执行单个管道步骤,保留原始步骤描述用于状态展示。"""
|
||||
async with self._semaphore:
|
||||
record.started_at = datetime.now()
|
||||
logger.info(
|
||||
f"管道子代理任务开始执行: task_id={record.task_id}, "
|
||||
f"subagent_type={record.subagent_type}"
|
||||
)
|
||||
try:
|
||||
result = await self._provider.run_task(
|
||||
description=description,
|
||||
subagent_type=record.subagent_type,
|
||||
task_id=record.task_id,
|
||||
)
|
||||
logger.info(
|
||||
f"管道子代理任务执行完成: task_id={record.task_id}, "
|
||||
f"subagent_type={record.subagent_type}, result_chars={len(result)}"
|
||||
)
|
||||
return result
|
||||
except asyncio.CancelledError:
|
||||
logger.info(
|
||||
f"管道子代理任务已取消: task_id={record.task_id}, "
|
||||
f"subagent_type={record.subagent_type}"
|
||||
)
|
||||
raise
|
||||
except Exception as err:
|
||||
logger.error(f"管道子代理任务执行失败: task_id={record.task_id}, error={err}")
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _create_pipeline_record(
|
||||
spec: _SubAgentTaskSpec,
|
||||
) -> _SubAgentRuntimeTask:
|
||||
"""创建一个管道步骤记录。"""
|
||||
task_id = f"subagent-{uuid.uuid4().hex[:12]}"
|
||||
return _SubAgentRuntimeTask(
|
||||
task_id=task_id,
|
||||
description=spec.description.strip(),
|
||||
subagent_type=spec.subagent_type or "general-purpose",
|
||||
task=None,
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
|
||||
def _track_pipeline_task(
|
||||
self,
|
||||
record: _SubAgentRuntimeTask,
|
||||
task: asyncio.Task,
|
||||
) -> None:
|
||||
"""登记管道步骤任务,复用统一的状态和异常收口逻辑。"""
|
||||
record.task = task
|
||||
task.add_done_callback(
|
||||
lambda finished_task, finished_task_id=record.task_id: self._mark_task_finished(
|
||||
finished_task_id,
|
||||
finished_task,
|
||||
)
|
||||
)
|
||||
self._tasks[record.task_id] = record
|
||||
|
||||
async def _run_pipeline(
|
||||
self,
|
||||
specs: list[_SubAgentTaskSpec],
|
||||
timeout_ms: Optional[int],
|
||||
) -> tuple[list[_SubAgentRuntimeTask], Optional[str]]:
|
||||
"""按顺序执行管道任务,并把每一步结果传给下一步。"""
|
||||
normalized_timeout_ms = self._normalize_timeout_ms(timeout_ms)
|
||||
if normalized_timeout_ms <= 0:
|
||||
return [], "管道任务需要大于 0 的等待时间。"
|
||||
|
||||
records: list[_SubAgentRuntimeTask] = []
|
||||
previous_results: list[tuple[_SubAgentRuntimeTask, str]] = []
|
||||
timeout = normalized_timeout_ms / 1000
|
||||
for step_index, spec in enumerate(specs, start=1):
|
||||
record = self._create_pipeline_record(spec)
|
||||
records.append(record)
|
||||
pipeline_description = self._pipeline_description(
|
||||
description=record.description,
|
||||
previous_results=previous_results,
|
||||
)
|
||||
task = asyncio.create_task(
|
||||
self._execute_pipeline_task(
|
||||
record=record,
|
||||
description=pipeline_description,
|
||||
),
|
||||
name=record.task_id,
|
||||
)
|
||||
self._track_pipeline_task(record, task)
|
||||
logger.info(
|
||||
f"已启动管道子代理任务: step={step_index}, task_id={record.task_id}, "
|
||||
f"subagent_type={record.subagent_type}"
|
||||
)
|
||||
|
||||
try:
|
||||
result = await asyncio.wait_for(task, timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
error = f"第 {step_index} 个管道子代理任务等待超时。"
|
||||
logger.info(
|
||||
f"{error} task_id={record.task_id}, timeout_ms={normalized_timeout_ms}"
|
||||
)
|
||||
return records, error
|
||||
except Exception as err:
|
||||
error = f"第 {step_index} 个管道子代理任务执行失败: {err}"
|
||||
logger.info(f"{error} task_id={record.task_id}")
|
||||
return records, error
|
||||
|
||||
previous_results.append((record, result))
|
||||
|
||||
return records, None
|
||||
|
||||
async def _control_task(
|
||||
self,
|
||||
action: str = "start",
|
||||
@@ -884,7 +989,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
|
||||
) -> str:
|
||||
"""管理异步子代理任务。"""
|
||||
logger.info(f"收到子代理管控操作: action={action}")
|
||||
if action in {"start", "run"}:
|
||||
if action in {"start", "run", "pipeline"}:
|
||||
specs, error = self._normalize_specs(
|
||||
description=description,
|
||||
subagent_type=subagent_type,
|
||||
@@ -895,6 +1000,20 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
|
||||
return self._json_response({"success": False, "error": error})
|
||||
|
||||
logger.info(f"准备启动子代理任务: action={action}, tasks={len(specs)}")
|
||||
if action == "pipeline":
|
||||
records, pipeline_error = await self._run_pipeline(
|
||||
specs=specs,
|
||||
timeout_ms=timeout_ms,
|
||||
)
|
||||
return self._json_response(
|
||||
{
|
||||
"success": pipeline_error is None,
|
||||
"action": action,
|
||||
"error": pipeline_error,
|
||||
"tasks": [self._task_output(record) for record in records],
|
||||
}
|
||||
)
|
||||
|
||||
records = self._start_tasks(specs)
|
||||
if action == "run":
|
||||
await self._wait_records(
|
||||
@@ -955,99 +1074,39 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
|
||||
if unfinished_records:
|
||||
logger.info(f"Agent 结束,取消未完成子代理任务: tasks={len(unfinished_records)}")
|
||||
await self._cancel_records(unfinished_records)
|
||||
|
||||
|
||||
class SubAgentCallSummaryMiddleware(AgentMiddleware):
|
||||
"""记录子代理调用次数的中间件。"""
|
||||
|
||||
def __init__(self, *, stream_handler: Any = None) -> None:
|
||||
self.stream_handler = stream_handler
|
||||
self.tools = []
|
||||
self._tasks.clear()
|
||||
|
||||
async def awrap_tool_call(
|
||||
self,
|
||||
request: ToolCallRequest,
|
||||
handler: Callable[[ToolCallRequest], Awaitable[Any]],
|
||||
) -> Any:
|
||||
"""在子代理任务工具执行时记录聚合摘要。"""
|
||||
"""在 subagent_task 子代理工具执行时记录聚合摘要。"""
|
||||
tool = request.tool
|
||||
tool_name = getattr(tool, "name", None)
|
||||
is_subagent_tool = tool_name in {
|
||||
SUBAGENT_TASK_TOOL_NAME,
|
||||
SUBAGENT_CONTROL_TOOL_NAME,
|
||||
}
|
||||
if is_subagent_tool:
|
||||
tool_call = request.tool_call or {}
|
||||
tool_args = tool_call.get("args") or {}
|
||||
if not isinstance(tool_args, dict):
|
||||
tool_args = {}
|
||||
logger.info(
|
||||
f"开始执行子代理工具: tool_name={tool_name}, "
|
||||
f"action={tool_args.get('action') or '-'}, "
|
||||
f"subagent_type={tool_args.get('subagent_type') or '-'}"
|
||||
)
|
||||
if (
|
||||
self.stream_handler
|
||||
and getattr(self.stream_handler, "is_streaming", False)
|
||||
):
|
||||
self.stream_handler.record_tool_call(
|
||||
tool_name=tool_name or SUBAGENT_TASK_TOOL_NAME,
|
||||
tool_message="Subagent invoked",
|
||||
tool_kwargs=tool_args,
|
||||
)
|
||||
if tool_name != SUBAGENT_CONTROL_TOOL_NAME:
|
||||
return await handler(request)
|
||||
|
||||
tool_args = _extract_tool_call_args(request)
|
||||
logger.info(
|
||||
f"开始执行子代理工具: tool_name={tool_name}, "
|
||||
f"action={tool_args.get('action') or '-'}, "
|
||||
f"subagent_type={tool_args.get('subagent_type') or '-'}"
|
||||
)
|
||||
_record_subagent_tool_call(
|
||||
stream_handler=self.stream_handler,
|
||||
tool_name=SUBAGENT_CONTROL_TOOL_NAME,
|
||||
tool_args=tool_args,
|
||||
)
|
||||
try:
|
||||
result = await handler(request)
|
||||
except Exception as err:
|
||||
if is_subagent_tool:
|
||||
logger.error(f"子代理工具执行失败: tool_name={tool_name}, error={err}")
|
||||
logger.error(f"子代理工具执行失败: tool_name={tool_name}, error={err}")
|
||||
raise
|
||||
if is_subagent_tool:
|
||||
logger.info(f"子代理工具执行完成: tool_name={tool_name}")
|
||||
logger.info(f"子代理工具执行完成: tool_name={tool_name}")
|
||||
return result
|
||||
|
||||
|
||||
def _deepagents_spec(
|
||||
profiles: tuple[_SubAgentProfile, ...], tools: list[BaseTool]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""将内置定义转换为 Deep Agents 子代理配置。"""
|
||||
specs = []
|
||||
for profile in profiles:
|
||||
specs.append(
|
||||
{
|
||||
"name": profile.name,
|
||||
"description": profile.description,
|
||||
"prompt": profile.prompt,
|
||||
"tools": _select_tools(tools, profile),
|
||||
}
|
||||
)
|
||||
return specs
|
||||
|
||||
|
||||
def _try_create_deepagents_middleware(
|
||||
*,
|
||||
profiles: tuple[_SubAgentProfile, ...],
|
||||
tools: list[BaseTool],
|
||||
model: BaseChatModel,
|
||||
) -> Optional[AgentMiddleware]:
|
||||
"""优先创建 Deep Agents 官方子代理中间件。"""
|
||||
try:
|
||||
from deepagents.backends import StateBackend
|
||||
from deepagents.middleware.subagents import SubAgentMiddleware
|
||||
|
||||
return SubAgentMiddleware(
|
||||
backend=StateBackend(),
|
||||
subagents=_deepagents_spec(profiles, tools),
|
||||
default_model=model,
|
||||
system_prompt=SUBAGENT_PARENT_PROMPT,
|
||||
task_description=SUBAGENT_TASK_DESCRIPTION,
|
||||
)
|
||||
except ImportError:
|
||||
return None
|
||||
except Exception as err:
|
||||
logger.debug(f"Deep Agents 子代理中间件不可用,使用本地实现: {err}")
|
||||
return None
|
||||
|
||||
|
||||
def create_subagent_middlewares(
|
||||
*,
|
||||
model: BaseChatModel,
|
||||
@@ -1055,22 +1114,19 @@ def create_subagent_middlewares(
|
||||
stream_handler: Any = None,
|
||||
) -> tuple[list[AgentMiddleware], list[BaseTool]]:
|
||||
"""创建子代理中间件列表和任务工具列表。"""
|
||||
profiles = _builtin_subagent_profiles()
|
||||
subagent_middleware = _try_create_deepagents_middleware(
|
||||
runtime_signature = agent_runtime_manager.current_signature()
|
||||
profiles = _builtin_subagent_profiles(runtime_signature)
|
||||
subagent_middleware = MoviePilotSubAgentMiddleware(
|
||||
model=model,
|
||||
profiles=profiles,
|
||||
tools=tools,
|
||||
model=model,
|
||||
stream_handler=stream_handler,
|
||||
)
|
||||
if subagent_middleware is None:
|
||||
subagent_middleware = MoviePilotSubAgentMiddleware(
|
||||
model=model,
|
||||
profiles=profiles,
|
||||
tools=tools,
|
||||
)
|
||||
control_middleware = SubAgentTaskControlMiddleware(
|
||||
model=model,
|
||||
profiles=profiles,
|
||||
tools=tools,
|
||||
stream_handler=stream_handler,
|
||||
)
|
||||
|
||||
task_tools = [
|
||||
@@ -1080,7 +1136,6 @@ def create_subagent_middlewares(
|
||||
return [
|
||||
subagent_middleware,
|
||||
control_middleware,
|
||||
SubAgentCallSummaryMiddleware(stream_handler=stream_handler),
|
||||
], task_tools
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""MoviePilot 自定义工具筛选中间件。"""
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
import json
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Annotated, Any, NotRequired
|
||||
@@ -19,13 +20,40 @@ from langchain.agents.middleware.tool_selection import (
|
||||
LLMToolSelectorMiddleware,
|
||||
)
|
||||
from langchain_core.language_models.chat_models import BaseChatModel
|
||||
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langchain_core.tools import BaseTool
|
||||
from langgraph.runtime import Runtime
|
||||
from typing_extensions import TypedDict # noqa
|
||||
|
||||
from app.agent.llm import LLMHelper
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.log import logger
|
||||
|
||||
MIN_SELECTED_TOOL_COUNT = 4
|
||||
RECENT_SELECTION_CONTEXT_MESSAGE_LIMIT = 6
|
||||
RECENT_SELECTION_CONTEXT_MAX_CHARS = 6000
|
||||
RECENT_SELECTION_CONTEXT_TRUNCATION_PREFIX = "..."
|
||||
TOOL_GROUP_EXCLUDED_TAGS = frozenset(
|
||||
{
|
||||
ToolTag.AgentTool.value,
|
||||
ToolTag.Read.value,
|
||||
ToolTag.Write.value,
|
||||
ToolTag.Admin.value,
|
||||
ToolTag.Message.value,
|
||||
ToolTag.UserInteraction.value,
|
||||
ToolTag.TerminalResponse.value,
|
||||
}
|
||||
)
|
||||
|
||||
MOVIEPILOT_TOOL_SELECTION_HINT = """
|
||||
|
||||
MoviePilot tool-chain hints:
|
||||
- Tools with the same capability tag belong to the same functional group.
|
||||
- For multi-step MoviePilot tasks, keep same-tag tools together when relevant.
|
||||
- Prefer selecting likely next-step tools in the same capability group instead of selecting only the first tool.
|
||||
"""
|
||||
|
||||
|
||||
class ToolSelectionState(AgentState):
|
||||
"""工具筛选中间件私有状态。"""
|
||||
@@ -40,19 +68,25 @@ class ToolSelectionStateUpdate(TypedDict):
|
||||
selected_tool_names: list[str] | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ToolSelectionAttempt:
|
||||
"""工具筛选尝试结果,用于统一记录最终日志。"""
|
||||
|
||||
request: ModelRequest
|
||||
selected_tool_names: list[str]
|
||||
status: str
|
||||
detail: str = ""
|
||||
|
||||
|
||||
class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
|
||||
"""
|
||||
为 DeepSeek 兼容端点提供更稳妥的工具筛选实现。
|
||||
使用 provider-neutral JSON 提示执行工具筛选。
|
||||
|
||||
LangChain 默认会通过 `with_structured_output()` 走 OpenAI 的
|
||||
`response_format=json_schema` 路径,但 DeepSeek 官方 OpenAI 兼容端点公开文档
|
||||
仅保证 `json_object` 模式可用。对于 `deepseek-reasoner`,这会在工具筛选阶段
|
||||
提前触发 400,导致 Agent 还没真正开始执行工具就失败。
|
||||
|
||||
因此这里仅在识别到 DeepSeek 模型/端点时,退回到显式 JSON 输出模式:
|
||||
1. 使用 `response_format={"type": "json_object"}`;
|
||||
2. 在提示词中明确约束返回 JSON 结构;
|
||||
3. 手动解析 `{"tools": [...]}`,其余模型继续沿用 LangChain 默认实现。
|
||||
LangChain 默认会通过 `with_structured_output()` 走 provider-specific 的
|
||||
结构化输出能力,不同 OpenAI/Anthropic 兼容端点对 `response_format`、
|
||||
JSON schema 和工具绑定的支持并不一致。工具筛选只是 Agent 执行前的
|
||||
辅助优化,失败时也会恢复使用全部工具,因此这里统一使用文本提示约束
|
||||
模型返回 `{"tools": [...]}` 并手动解析,避免在筛选阶段引入额外兼容分支。
|
||||
|
||||
另外,LangChain 原生工具筛选挂在 `wrap_model_call` 上,会在同一条用户请求
|
||||
的每次“模型回合”前都重新筛选一次工具。对于会多轮调用工具的复杂任务,
|
||||
@@ -73,12 +107,219 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
|
||||
) -> None:
|
||||
super().__init__(
|
||||
model=model,
|
||||
system_prompt=system_prompt,
|
||||
system_prompt=self._append_tool_selection_hint(system_prompt),
|
||||
max_tools=max_tools,
|
||||
always_include=always_include,
|
||||
)
|
||||
self.selection_tools = selection_tools or []
|
||||
|
||||
@classmethod
|
||||
def _render_recent_conversation_context(
|
||||
cls,
|
||||
messages: list[Any],
|
||||
) -> tuple[str, int]:
|
||||
"""渲染最近对话上下文,供工具筛选模型理解多轮追问。"""
|
||||
rendered_messages = []
|
||||
for message in messages:
|
||||
if isinstance(message, HumanMessage):
|
||||
role = "User"
|
||||
elif isinstance(message, AIMessage):
|
||||
role = "Assistant"
|
||||
else:
|
||||
continue
|
||||
|
||||
content = LLMHelper.extract_text_content(message.content).strip()
|
||||
if not content:
|
||||
continue
|
||||
rendered_messages.append(f"{role}: {content}")
|
||||
|
||||
recent_messages = rendered_messages[-RECENT_SELECTION_CONTEXT_MESSAGE_LIMIT:]
|
||||
context = "\n\n".join(recent_messages)
|
||||
if len(context) > RECENT_SELECTION_CONTEXT_MAX_CHARS:
|
||||
context = (
|
||||
f"{RECENT_SELECTION_CONTEXT_TRUNCATION_PREFIX}"
|
||||
f"{context[-RECENT_SELECTION_CONTEXT_MAX_CHARS:]}"
|
||||
)
|
||||
return context, len(recent_messages)
|
||||
|
||||
@classmethod
|
||||
def _build_contextual_user_message(
|
||||
cls,
|
||||
messages: list[Any],
|
||||
last_user_message: HumanMessage,
|
||||
) -> HumanMessage:
|
||||
"""根据最近对话构造工具筛选专用用户消息。"""
|
||||
context, message_count = cls._render_recent_conversation_context(messages)
|
||||
if message_count <= 1:
|
||||
return last_user_message
|
||||
|
||||
return HumanMessage(
|
||||
content=(
|
||||
"Recent conversation context for tool selection:\n"
|
||||
f"{context}\n\n"
|
||||
"Select tools for the latest user instruction. Use prior assistant "
|
||||
"messages and earlier user requests when the latest user message "
|
||||
"depends on previous context."
|
||||
)
|
||||
)
|
||||
|
||||
def _prepare_selection_request(
|
||||
self,
|
||||
request: ModelRequest[ContextT],
|
||||
) -> Any | None:
|
||||
"""准备带最近对话上下文的工具筛选请求。"""
|
||||
selection_request = super()._prepare_selection_request(request)
|
||||
if selection_request is None:
|
||||
return None
|
||||
|
||||
contextual_user_message = self._build_contextual_user_message(
|
||||
messages=request.messages,
|
||||
last_user_message=selection_request.last_user_message,
|
||||
)
|
||||
if contextual_user_message is selection_request.last_user_message:
|
||||
return selection_request
|
||||
return replace(selection_request, last_user_message=contextual_user_message)
|
||||
|
||||
@staticmethod
|
||||
def _append_tool_selection_hint(system_prompt: str) -> str:
|
||||
"""追加 MoviePilot 工具组选择提示,避免复杂链路只选中首个工具。"""
|
||||
if "MoviePilot tool-chain hints:" in system_prompt:
|
||||
return system_prompt
|
||||
return f"{system_prompt.rstrip()}{MOVIEPILOT_TOOL_SELECTION_HINT}"
|
||||
|
||||
def _get_tool_selection_limit(self, valid_tool_names: list[str]) -> int:
|
||||
"""计算补齐筛选结果时允许使用的工具数量上限。"""
|
||||
if self.max_tools:
|
||||
return min(self.max_tools, len(valid_tool_names))
|
||||
return len(valid_tool_names)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_tool_tags(tool: BaseTool) -> list[str]:
|
||||
"""读取工具的业务标签,过滤掉无法表达工具组的通用标签。"""
|
||||
tags = getattr(tool, "tags", None) or []
|
||||
if isinstance(tags, str):
|
||||
tags = [tags]
|
||||
|
||||
normalized_tags = []
|
||||
for tag in tags:
|
||||
tag_value = getattr(tag, "value", tag)
|
||||
if not tag_value:
|
||||
continue
|
||||
tag_name = str(tag_value)
|
||||
if tag_name in TOOL_GROUP_EXCLUDED_TAGS or tag_name in normalized_tags:
|
||||
continue
|
||||
normalized_tags.append(tag_name)
|
||||
return normalized_tags
|
||||
|
||||
@classmethod
|
||||
def _build_tool_groups(
|
||||
cls,
|
||||
available_tools: list[BaseTool],
|
||||
valid_tool_names: list[str],
|
||||
) -> list[tuple[str, list[str]]]:
|
||||
"""根据工具标签构造能力组,保留当前工具列表中的稳定顺序。"""
|
||||
valid_tool_set = set(valid_tool_names)
|
||||
tool_groups: dict[str, list[str]] = {}
|
||||
for tool in available_tools:
|
||||
tool_name = getattr(tool, "name", None)
|
||||
if not tool_name or tool_name not in valid_tool_set:
|
||||
continue
|
||||
for tag in cls._normalize_tool_tags(tool):
|
||||
group_tool_names = tool_groups.setdefault(tag, [])
|
||||
if tool_name not in group_tool_names:
|
||||
group_tool_names.append(tool_name)
|
||||
|
||||
return [
|
||||
(tag, tool_names)
|
||||
for tag, tool_names in tool_groups.items()
|
||||
if len(tool_names) > 1
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def _get_matched_tool_groups(
|
||||
cls,
|
||||
selected_names: list[str],
|
||||
available_tools: list[BaseTool],
|
||||
valid_tool_names: list[str],
|
||||
) -> list[tuple[str, list[str]]]:
|
||||
"""返回已选工具命中的标签能力组。"""
|
||||
groups_by_tag = {
|
||||
tag: tool_names
|
||||
for tag, tool_names in cls._build_tool_groups(
|
||||
available_tools=available_tools,
|
||||
valid_tool_names=valid_tool_names,
|
||||
)
|
||||
}
|
||||
tools_by_name = {
|
||||
tool.name: tool
|
||||
for tool in available_tools
|
||||
if getattr(tool, "name", None)
|
||||
}
|
||||
matched_groups: list[tuple[str, list[str]]] = []
|
||||
seen_tags = set()
|
||||
for tool_name in selected_names:
|
||||
tool = tools_by_name.get(tool_name)
|
||||
if not tool:
|
||||
continue
|
||||
for tag in cls._normalize_tool_tags(tool):
|
||||
if tag in seen_tags or tag not in groups_by_tag:
|
||||
continue
|
||||
matched_groups.append((tag, groups_by_tag[tag]))
|
||||
seen_tags.add(tag)
|
||||
return matched_groups
|
||||
|
||||
def _complete_low_count_selection(
|
||||
self,
|
||||
selected_tool_names: list[str],
|
||||
valid_tool_names: list[str],
|
||||
available_tools: list[BaseTool],
|
||||
) -> list[str]:
|
||||
"""
|
||||
当模型只选出极少工具时,按工具标签补齐同组工具。
|
||||
|
||||
工具标签是工具自身声明的能力归属。这里只补齐已经命中的标签组,
|
||||
不会把所有工具组都展开。
|
||||
"""
|
||||
limit = self._get_tool_selection_limit(valid_tool_names)
|
||||
selected_names = [
|
||||
tool_name
|
||||
for tool_name in selected_tool_names
|
||||
if tool_name in valid_tool_names
|
||||
]
|
||||
selected_set = set(selected_names)
|
||||
valid_tool_set = set(valid_tool_names)
|
||||
completed_names = list(selected_names)
|
||||
matched_groups = self._get_matched_tool_groups(
|
||||
selected_names=selected_names,
|
||||
available_tools=available_tools,
|
||||
valid_tool_names=valid_tool_names,
|
||||
)
|
||||
if not matched_groups:
|
||||
return completed_names[:limit]
|
||||
|
||||
matched_group_tool_names = {
|
||||
tool_name
|
||||
for _, group_tool_names in matched_groups
|
||||
for tool_name in group_tool_names
|
||||
}
|
||||
target_count = min(
|
||||
max(MIN_SELECTED_TOOL_COUNT, len(matched_group_tool_names)),
|
||||
limit,
|
||||
)
|
||||
if len(selected_names) >= target_count:
|
||||
return selected_names[:limit]
|
||||
|
||||
for _, group_tool_names in matched_groups:
|
||||
for tool_name in group_tool_names:
|
||||
if tool_name in selected_set or tool_name not in valid_tool_set:
|
||||
continue
|
||||
completed_names.append(tool_name)
|
||||
selected_set.add(tool_name)
|
||||
if len(completed_names) >= target_count:
|
||||
return completed_names[:limit]
|
||||
|
||||
return completed_names[:limit]
|
||||
|
||||
def _process_selection_response(
|
||||
self,
|
||||
response: dict[str, Any],
|
||||
@@ -87,96 +328,41 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
|
||||
request: ModelRequest[ContextT],
|
||||
) -> ModelRequest[ContextT]:
|
||||
"""
|
||||
处理工具筛选响应,并保留空结果回退所有工具的 MoviePilot 策略。
|
||||
处理工具筛选响应,并在正常空结果时禁用可筛选工具。
|
||||
"""
|
||||
if response.get("tools") == []:
|
||||
logger.warning("工具筛选结果为空,将恢复使用所有工具。")
|
||||
|
||||
always_included_tools: list[BaseTool] = [
|
||||
tool
|
||||
for tool in request.tools
|
||||
if not isinstance(tool, dict) and tool.name in self.always_include
|
||||
]
|
||||
provider_tools = [tool for tool in request.tools if isinstance(tool, dict)]
|
||||
return request.override(tools=[*always_included_tools, *provider_tools])
|
||||
|
||||
return request.override(
|
||||
tools=[*available_tools, *always_included_tools, *provider_tools]
|
||||
)
|
||||
|
||||
return super()._process_selection_response(
|
||||
response["tools"] = self._complete_low_count_selection(
|
||||
selected_tool_names=[
|
||||
tool_name
|
||||
for tool_name in response.get("tools", [])
|
||||
if isinstance(tool_name, str)
|
||||
],
|
||||
valid_tool_names=valid_tool_names,
|
||||
available_tools=available_tools,
|
||||
)
|
||||
modified_request = super()._process_selection_response(
|
||||
response,
|
||||
available_tools,
|
||||
valid_tool_names,
|
||||
request,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_deepseek_compatible_model(model: BaseChatModel) -> bool:
|
||||
"""
|
||||
判断当前模型是否应当走 DeepSeek JSON 兼容分支。
|
||||
|
||||
除了官方 `langchain_deepseek`,用户也可能通过 OpenAI-compatible
|
||||
配置把 DeepSeek 端点接到 `ChatOpenAI`。因此这里同时检查模块名、模型名
|
||||
和 Base URL,避免只靠单一条件漏判。
|
||||
"""
|
||||
module_name = type(model).__module__.lower()
|
||||
model_name = (
|
||||
str(getattr(model, "model_name", "") or getattr(model, "model", ""))
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
base_url = (
|
||||
str(getattr(model, "openai_api_base", "") or getattr(model, "api_base", ""))
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
|
||||
return (
|
||||
"deepseek" in module_name
|
||||
or model_name.startswith("deepseek-")
|
||||
or "api.deepseek.com" in base_url
|
||||
)
|
||||
|
||||
@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 ""
|
||||
return modified_request
|
||||
|
||||
@staticmethod
|
||||
def _parse_json_object(text: str) -> dict[str, Any]:
|
||||
"""
|
||||
解析模型返回的 JSON。
|
||||
|
||||
DeepSeek 在 JSON 模式下通常会返回纯 JSON,但这里仍做一层兜底,
|
||||
兼容模型偶发输出围栏或前后说明文本的情况。
|
||||
不同模型可能偶发输出 Markdown 围栏或前后说明文本,因此这里从
|
||||
响应中提取第一个 JSON 对象作为兜底。
|
||||
"""
|
||||
stripped_text = text.strip()
|
||||
if not stripped_text:
|
||||
@@ -199,23 +385,46 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
|
||||
raise ValueError("工具筛选 JSON 顶层必须是对象")
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _render_tool_list(available_tools: list[Any]) -> str:
|
||||
@classmethod
|
||||
def _render_tool_list(cls, available_tools: list[Any]) -> str:
|
||||
"""把工具名和描述渲染成稳定的文本列表。"""
|
||||
return "\n".join(
|
||||
f"- {tool.name}: {tool.description}" for tool in available_tools
|
||||
lines = []
|
||||
for tool in available_tools:
|
||||
tags = cls._normalize_tool_tags(tool)
|
||||
tag_text = f" [group tags: {', '.join(tags)}]" if tags else ""
|
||||
lines.append(f"- {tool.name}{tag_text}: {tool.description}")
|
||||
return "\n".join(lines)
|
||||
|
||||
@classmethod
|
||||
def _render_tool_groups(cls, available_tools: list[BaseTool]) -> str:
|
||||
"""把当前可用工具按标签渲染成能力组提示。"""
|
||||
valid_tool_names = [
|
||||
tool.name
|
||||
for tool in available_tools
|
||||
if getattr(tool, "name", None)
|
||||
]
|
||||
groups = cls._build_tool_groups(
|
||||
available_tools=available_tools,
|
||||
valid_tool_names=valid_tool_names,
|
||||
)
|
||||
if not groups:
|
||||
return ""
|
||||
rendered_groups = "\n".join(
|
||||
f"- {tag}: {', '.join(tool_names)}"
|
||||
for tag, tool_names in groups
|
||||
)
|
||||
return f"Capability groups from tool tags:\n{rendered_groups}\n\n"
|
||||
|
||||
def _build_deepseek_selection_prompt(self, selection_request: Any) -> str:
|
||||
def _build_json_selection_prompt(self, selection_request: Any) -> str:
|
||||
"""
|
||||
为 DeepSeek 生成显式 JSON 输出提示。
|
||||
生成显式 JSON 输出提示。
|
||||
|
||||
DeepSeek 官方文档要求在 JSON 输出模式下,提示词中必须明确包含 JSON
|
||||
约束,否则兼容端点可能返回空内容或无意义输出。
|
||||
使用纯提示约束可覆盖更多兼容端点,避免在工具筛选阶段依赖某个
|
||||
provider 专属的 `response_format` 或 schema 能力。
|
||||
"""
|
||||
limit_instruction = ""
|
||||
if self.max_tools:
|
||||
limit_instruction = f"- Select up to {self.max_tools} tools. IF NO TOOLS ARE RELEVANT, DO NOT RETURN AN EMPTY ARRAY. SELECT THE MOST APPLICABLE ONES TO ENSURE THE REQUEST IS HANDLED."
|
||||
limit_instruction = f"- Select up to {self.max_tools} tools. Return an empty array if no tools are relevant."
|
||||
|
||||
return (
|
||||
f"{selection_request.system_message}\n\n"
|
||||
@@ -225,18 +434,20 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
|
||||
"- 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"
|
||||
"- Tools sharing the same capability tag are in the same group; include same-group tools together when relevant.\n"
|
||||
f"{limit_instruction}\n"
|
||||
"- Do not add explanations, markdown, or extra keys.\n\n"
|
||||
f"{self._render_tool_groups(selection_request.available_tools)}"
|
||||
"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 模式的工具筛选结果。
|
||||
解析并标准化显式 JSON 模式的工具筛选结果。
|
||||
"""
|
||||
content = getattr(response, "content", response)
|
||||
text = self._extract_text_content(content)
|
||||
text = LLMHelper.extract_text_content(content)
|
||||
logger.debug(f"工具筛选原始响应: {text}")
|
||||
payload = self._parse_json_object(text)
|
||||
|
||||
@@ -250,22 +461,21 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
|
||||
logger.debug(f"工具筛选标准化结果: {normalized_tools}")
|
||||
return {"tools": normalized_tools}
|
||||
|
||||
async def _aselect_tools_with_deepseek(
|
||||
async def _aselect_tools_with_json_prompt(
|
||||
self, selection_request: Any
|
||||
) -> dict[str, list[str]]:
|
||||
"""
|
||||
使用 DeepSeek 兼容的 JSON 输出模式执行异步工具筛选。
|
||||
使用 JSON 提示执行异步工具筛选。
|
||||
|
||||
:param selection_request: LangChain 工具筛选请求
|
||||
:return: 标准化后的工具名列表
|
||||
"""
|
||||
logger.debug("工具筛选走 DeepSeek JSON 兼容分支")
|
||||
structured_model = selection_request.model.bind(
|
||||
response_format={"type": "json_object"}
|
||||
)
|
||||
response = await structured_model.ainvoke(
|
||||
logger.debug("工具筛选走 JSON 提示分支")
|
||||
response = await selection_request.model.ainvoke(
|
||||
[
|
||||
{
|
||||
"role": "system",
|
||||
"content": self._build_deepseek_selection_prompt(selection_request),
|
||||
},
|
||||
SystemMessage(
|
||||
content=self._build_json_selection_prompt(selection_request)
|
||||
),
|
||||
selection_request.last_user_message,
|
||||
]
|
||||
)
|
||||
@@ -276,6 +486,31 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
|
||||
"""从已筛选后的请求中提取最终工具名,保留原有顺序。"""
|
||||
return [tool.name for tool in request.tools if not isinstance(tool, dict)]
|
||||
|
||||
@staticmethod
|
||||
def _count_request_tools(request: ModelRequest) -> int:
|
||||
"""统计当前请求中的 LangChain 工具数量,不包含 provider 原生工具字典。"""
|
||||
return len([tool for tool in request.tools if not isinstance(tool, dict)])
|
||||
|
||||
@classmethod
|
||||
def _log_selection_attempt(cls, attempt: _ToolSelectionAttempt) -> None:
|
||||
"""按工具筛选最终状态记录稳定日志。"""
|
||||
tool_count = cls._count_request_tools(attempt.request)
|
||||
if attempt.status == "selected":
|
||||
selected_text = ", ".join(attempt.selected_tool_names) or "无有效工具"
|
||||
logger.info(f"工具筛选结果: {selected_text}")
|
||||
return
|
||||
if attempt.status == "failed_fallback":
|
||||
logger.warning(
|
||||
f"工具筛选失败,将恢复使用所有工具(共 {tool_count} 个): {attempt.detail}"
|
||||
)
|
||||
return
|
||||
if attempt.status == "skipped":
|
||||
logger.info(f"工具筛选跳过: {attempt.detail}。")
|
||||
return
|
||||
if attempt.status == "reused":
|
||||
selected_text = ", ".join(attempt.selected_tool_names) or "无有效工具"
|
||||
logger.info(f"工具筛选复用已有结果: {selected_text}")
|
||||
|
||||
@staticmethod
|
||||
def _apply_selected_tools(
|
||||
request: ModelRequest[ContextT],
|
||||
@@ -287,9 +522,6 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
|
||||
这里只复用首次筛选出的客户端工具名;provider-specific 的 dict 工具仍然
|
||||
原样保留,避免破坏 LangChain/provider 自身的工具绑定约定。
|
||||
"""
|
||||
if not selected_tool_names:
|
||||
return request
|
||||
|
||||
current_tools_by_name = {
|
||||
tool.name: tool for tool in request.tools if not isinstance(tool, dict)
|
||||
}
|
||||
@@ -310,30 +542,43 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
|
||||
这里单独抽成 helper,便于首次筛选后缓存结果,也便于测试覆盖
|
||||
“首轮筛选,后续复用”的行为。
|
||||
"""
|
||||
return (await self._aselect_request_once_with_status(request)).request
|
||||
|
||||
async def _aselect_request_once_with_status(
|
||||
self, request: ModelRequest[ContextT]
|
||||
) -> _ToolSelectionAttempt:
|
||||
"""
|
||||
执行一次真实工具筛选,并携带最终状态供调用方统一记录日志。
|
||||
"""
|
||||
selection_request = self._prepare_selection_request(request)
|
||||
if selection_request is None:
|
||||
return request
|
||||
return _ToolSelectionAttempt(
|
||||
request=request,
|
||||
selected_tool_names=self._extract_selected_tool_names(request),
|
||||
status="skipped",
|
||||
detail="没有需要筛选的工具",
|
||||
)
|
||||
|
||||
if not self._is_deepseek_compatible_model(selection_request.model):
|
||||
captured_request: ModelRequest[ContextT] = request
|
||||
|
||||
async def _capture_handler(
|
||||
updated_request: ModelRequest[ContextT],
|
||||
) -> ModelRequest[ContextT]:
|
||||
nonlocal captured_request
|
||||
captured_request = updated_request
|
||||
return updated_request
|
||||
|
||||
await super().awrap_model_call(request, _capture_handler)
|
||||
return captured_request
|
||||
|
||||
response = await self._aselect_tools_with_deepseek(selection_request)
|
||||
return self._process_selection_response(
|
||||
response,
|
||||
selection_request.available_tools,
|
||||
selection_request.valid_tool_names,
|
||||
request,
|
||||
)
|
||||
try:
|
||||
response = await self._aselect_tools_with_json_prompt(selection_request)
|
||||
modified_request = self._process_selection_response(
|
||||
response,
|
||||
selection_request.available_tools,
|
||||
selection_request.valid_tool_names,
|
||||
request,
|
||||
)
|
||||
return _ToolSelectionAttempt(
|
||||
request=modified_request,
|
||||
selected_tool_names=self._extract_selected_tool_names(modified_request),
|
||||
status="selected",
|
||||
)
|
||||
except Exception as err:
|
||||
return _ToolSelectionAttempt(
|
||||
request=request,
|
||||
selected_tool_names=self._extract_selected_tool_names(request),
|
||||
status="failed_fallback",
|
||||
detail=str(err),
|
||||
)
|
||||
|
||||
async def abefore_agent( # noqa
|
||||
self,
|
||||
@@ -347,10 +592,22 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
|
||||
这样后续多轮 `model -> tools -> model` 循环都只复用这一次结果,
|
||||
不会为每次模型回合重复追加一笔 selector LLM 开销。
|
||||
"""
|
||||
if "selected_tool_names" in state:
|
||||
return None
|
||||
|
||||
if not self.selection_tools or self.model is None:
|
||||
detail = "没有可筛选工具" if not self.selection_tools else "未配置筛选模型"
|
||||
self._log_selection_attempt(
|
||||
_ToolSelectionAttempt(
|
||||
request=ModelRequest(
|
||||
model=self.model,
|
||||
tools=list(self.selection_tools),
|
||||
messages=state["messages"],
|
||||
state=state,
|
||||
runtime=runtime,
|
||||
),
|
||||
selected_tool_names=[],
|
||||
status="skipped",
|
||||
detail=detail,
|
||||
)
|
||||
)
|
||||
return ToolSelectionStateUpdate(selected_tool_names=None)
|
||||
|
||||
selection_request = ModelRequest(
|
||||
@@ -360,9 +617,10 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
|
||||
state=state,
|
||||
runtime=runtime,
|
||||
)
|
||||
modified_request = await self._aselect_request_once(selection_request)
|
||||
selected_tool_names = self._extract_selected_tool_names(modified_request)
|
||||
return ToolSelectionStateUpdate(selected_tool_names=selected_tool_names or None)
|
||||
attempt = await self._aselect_request_once_with_status(selection_request)
|
||||
self._log_selection_attempt(attempt)
|
||||
selected_tool_names = attempt.selected_tool_names
|
||||
return ToolSelectionStateUpdate(selected_tool_names=selected_tool_names)
|
||||
|
||||
async def awrap_model_call(
|
||||
self,
|
||||
@@ -383,11 +641,13 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
|
||||
and self.selection_tools
|
||||
and self.model is not None
|
||||
):
|
||||
request = await self._aselect_request_once(request)
|
||||
selected_tool_names = self._extract_selected_tool_names(request) or None
|
||||
attempt = await self._aselect_request_once_with_status(request)
|
||||
self._log_selection_attempt(attempt)
|
||||
request = attempt.request
|
||||
selected_tool_names = attempt.selected_tool_names
|
||||
request.state["selected_tool_names"] = selected_tool_names # noqa
|
||||
|
||||
if selected_tool_names:
|
||||
if selected_tool_names is not None:
|
||||
request = self._apply_selected_tools(request, selected_tool_names)
|
||||
|
||||
return await handler(request)
|
||||
|
||||
@@ -58,12 +58,13 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
|
||||
- Use parallel tool calls by default for independent read-only or diagnostic work. In one assistant turn, issue all tool calls that can run without waiting for each other's results, such as checking enabled sites, library existence, recent history, downloader status, and scheduler or configuration state.
|
||||
- Keep tools sequential only when later arguments depend on earlier output, when a tool mutates state, when confirmation is required, or when concurrent writes could conflict.
|
||||
- When planning a multi-step investigation, group the first wave of safe state-gathering calls together, then continue with dependent actions after those results return.
|
||||
- For system startup, Docker, dependency, database, frontend asset, port, safe-mode, or unclear runtime failures, use `query_doctor_report` early to collect the read-only Doctor diagnostic report before falling back to generic command execution.
|
||||
- Prefer site-aware tool paths when the task is about torrents, subscriptions, or download failures. `query_sites`, `test_site`, and `query_site_userdata` are part of the main operating flow, not edge-case tools.
|
||||
- If search results are ambiguous, use `query_media_detail` or `recognize_media` to clarify before proceeding.
|
||||
- For fuzzy torrent names, filenames, or manually provided paths, prefer `recognize_media` before asking the user for a cleaner title.
|
||||
- If `search_media` fails, fall back to `search_web` or `recognize_media`. Only ask the user when automated paths are exhausted.
|
||||
- If torrent search yields no useful result, check site scope, site health, and recognition quality before concluding that the resource is unavailable.
|
||||
- Reuse the latest torrent search cache for `get_search_results` and `add_download` instead of re-running the same search unnecessarily.
|
||||
- Reuse the latest torrent search cache for `get_search_results` and `add_download_tasks` instead of re-running the same search unnecessarily.
|
||||
- Use `execute_command` only for diagnostics, read-only inspection, or commands the user explicitly asked to run. Its default `action=start` starts a managed background session and returns `session_id`, `status`, `last_seq`, and `output_until_seq`; call the same tool again with `action=read`, `action=wait`, `action=write`, or `action=kill` to poll output, wait in short segments, send stdin, or stop the process.
|
||||
</tool_strategy>
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""提示词管理器"""
|
||||
|
||||
import shutil
|
||||
import socket
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from string import Formatter
|
||||
@@ -24,8 +23,6 @@ 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",
|
||||
@@ -91,7 +88,7 @@ class PromptManager:
|
||||
self.prompts_cache: Dict[str, str] = {}
|
||||
self._system_tasks_cache: Optional[SystemTasksDefinition] = None
|
||||
self._system_tasks_signature: Optional[tuple[int, int]] = None
|
||||
self._available_shell_commands_cache: Optional[list[tuple[str, str]]] = None
|
||||
self._available_shell_command_names_cache: Optional[list[str]] = None
|
||||
|
||||
def load_prompt(self, prompt_name: str) -> str:
|
||||
"""
|
||||
@@ -102,7 +99,7 @@ class PromptManager:
|
||||
|
||||
prompt_file = self.prompts_dir / prompt_name
|
||||
try:
|
||||
with open(prompt_file, "r", encoding="utf-8") as f:
|
||||
with open(prompt_file, "r", encoding="utf-8", errors="replace") as f:
|
||||
content = f.read().strip()
|
||||
# 缓存提示词
|
||||
self.prompts_cache[prompt_name] = content
|
||||
@@ -187,7 +184,7 @@ class PromptManager:
|
||||
return self._system_tasks_cache
|
||||
|
||||
try:
|
||||
content = system_tasks_path.read_text(encoding="utf-8")
|
||||
content = system_tasks_path.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception as err: # noqa: BLE001
|
||||
logger.error(f"读取系统任务定义失败: {system_tasks_path}, 错误: {err}")
|
||||
raise PromptConfigError(
|
||||
@@ -281,94 +278,60 @@ class PromptManager:
|
||||
|
||||
def _get_moviepilot_info(self) -> str:
|
||||
"""
|
||||
获取MoviePilot系统信息,用于注入到系统提示词中
|
||||
获取需要常驻注入的最小 MoviePilot 运行信息。
|
||||
"""
|
||||
# 获取主机名和IP地址
|
||||
try:
|
||||
hostname = socket.gethostname()
|
||||
ip_address = socket.gethostbyname(hostname)
|
||||
except Exception: # noqa
|
||||
hostname = "localhost"
|
||||
ip_address = "127.0.0.1"
|
||||
|
||||
# 配置文件和日志文件目录
|
||||
config_path = str(settings.CONFIG_PATH)
|
||||
log_path = str(settings.LOG_PATH)
|
||||
|
||||
# API地址构建
|
||||
api_port = settings.PORT
|
||||
api_path = settings.API_V1_STR
|
||||
|
||||
# API令牌
|
||||
api_token = settings.API_TOKEN or "未设置"
|
||||
|
||||
# 数据库信息
|
||||
db_type = settings.DB_TYPE
|
||||
if db_type == "sqlite":
|
||||
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}@"
|
||||
f"{settings.DB_POSTGRESQL_TARGET}/{settings.DB_POSTGRESQL_DATABASE})"
|
||||
)
|
||||
|
||||
# 保留日期用于提供“今天是哪天”的稳定上下文,但不再注入秒级时间,
|
||||
# 避免每次请求都生成不同的 system prompt,影响 provider 侧 cache 命中率。
|
||||
info_lines = [
|
||||
f"- 当前日期: {strftime('%Y-%m-%d')}",
|
||||
f"- 运行环境: {SystemUtils.platform} {'docker' if SystemUtils.is_docker() else ''}",
|
||||
f"- 主机名: {hostname}",
|
||||
f"- IP地址: {ip_address}",
|
||||
f"- API端口: {api_port}",
|
||||
f"- API路径: {api_path}",
|
||||
f"- API令牌: {api_token}",
|
||||
f"- 外网域名: {settings.APP_DOMAIN or '未设置'}",
|
||||
f"- 数据库类型: {db_type}",
|
||||
f"- 数据库: {db_info}",
|
||||
f"- 配置文件目录: {config_path}",
|
||||
f"- 日志文件目录: {log_path}",
|
||||
f"- 系统安装目录: {settings.ROOT_PATH}",
|
||||
f"- 插件安装目录: {settings.ROOT_PATH / 'app' / 'plugins'}",
|
||||
"- 详细运行状态、数据库、API 和配置值需要时通过 `query_doctor_report`、`query_system_settings` 或 `execute_command` 查询。",
|
||||
]
|
||||
|
||||
available_commands = self._get_available_shell_commands()
|
||||
if available_commands:
|
||||
info_lines.append("- 可用系统命令(可通过 `execute_command` 调用):")
|
||||
path_lines = self._get_runtime_path_lines()
|
||||
if path_lines:
|
||||
info_lines.extend(
|
||||
f" - {command}: {path}" for command, path in available_commands
|
||||
[
|
||||
"- 关键运行路径(必要时可用文件/命令工具读取,避免扫描无关目录):",
|
||||
*path_lines,
|
||||
]
|
||||
)
|
||||
# `rg` 同时覆盖文件枚举和文本检索,且比通用 shell 查找更适合
|
||||
# Agent 的代码阅读与定位场景;只有在它不可用或不适合时才退回其他工具。
|
||||
if any(command == "rg" for command, _ in available_commands):
|
||||
available_commands = self._get_available_shell_command_names()
|
||||
if available_commands:
|
||||
info_lines.append(
|
||||
"- 已安装的常用系统命令(仅列命令名,可通过 `execute_command` 调用): "
|
||||
+ ", ".join(f"`{command}`" for command in available_commands)
|
||||
)
|
||||
if "rg" 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."
|
||||
"- 搜索文件或文本时优先使用 `rg` / `rg --files`,不适合或不可用时再使用其他命令。"
|
||||
)
|
||||
|
||||
return "\n".join(info_lines)
|
||||
|
||||
def _get_available_shell_commands(self) -> list[tuple[str, str]]:
|
||||
"""
|
||||
探测 PATH 中已经安装的常用命令。
|
||||
@staticmethod
|
||||
def _get_runtime_path_lines() -> list[str]:
|
||||
"""返回基础系统提示词需要常驻注入的全局运行路径。"""
|
||||
paths = {
|
||||
"项目根目录": settings.ROOT_PATH,
|
||||
"配置目录": settings.CONFIG_PATH,
|
||||
"临时目录": settings.TEMP_PATH,
|
||||
}
|
||||
return [f" - {label}: `{path}`" for label, path in paths.items()]
|
||||
|
||||
这里只使用 shutil.which 做无副作用查找,不实际执行命令;执行权限、
|
||||
高风险操作确认和输出限制仍由 execute_command 工具负责。探测结果
|
||||
在进程内缓存,避免每次组装提示词都重复扫描 PATH。
|
||||
"""
|
||||
if self._available_shell_commands_cache is not None:
|
||||
return self._available_shell_commands_cache
|
||||
def _get_available_shell_command_names(self) -> list[str]:
|
||||
"""探测 PATH 中可用的常用命令名称,不把绝对路径注入提示词。"""
|
||||
if self._available_shell_command_names_cache is not None:
|
||||
return self._available_shell_command_names_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
|
||||
available_commands = [
|
||||
command for command in COMMON_SHELL_COMMANDS if shutil.which(command)
|
||||
]
|
||||
self._available_shell_command_names_cache = available_commands
|
||||
return available_commands
|
||||
|
||||
def clear_available_shell_commands_cache(self) -> None:
|
||||
"""清理可用系统命令缓存,供测试或运行时手动刷新使用。"""
|
||||
self._available_shell_commands_cache = None
|
||||
def clear_available_shell_command_names_cache(self) -> None:
|
||||
"""清理可用命令名称缓存,供测试或运行时手动刷新使用。"""
|
||||
self._available_shell_command_names_cache = None
|
||||
|
||||
@staticmethod
|
||||
def _generate_formatting_instructions(caps: ChannelCapabilities) -> str:
|
||||
|
||||
87
app/agent/prompt/transfer_redo.py
Normal file
87
app/agent/prompt/transfer_redo.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""整理记录 AI 重新整理提示词构造。"""
|
||||
from typing import Any
|
||||
|
||||
from app.agent.prompt import prompt_manager
|
||||
|
||||
|
||||
def build_manual_redo_template_context(history: Any) -> dict[str, int | str]:
|
||||
"""把整理历史对象映射成 System Tasks 需要的模板变量。"""
|
||||
src_fileitem = history.src_fileitem or {}
|
||||
dest_fileitem = history.dest_fileitem or {}
|
||||
source_path = src_fileitem.get("path") if isinstance(src_fileitem, dict) else ""
|
||||
source_storage = history.src_storage or "local"
|
||||
if history.status and history.mode == "move":
|
||||
dest_path = dest_fileitem.get("path") if isinstance(dest_fileitem, dict) else ""
|
||||
if dest_path:
|
||||
source_path = dest_path
|
||||
source_storage = history.dest_storage or "local"
|
||||
source_path = source_path or history.src or ""
|
||||
season_episode = f"{history.seasons or ''}{history.episodes or ''}".strip()
|
||||
return {
|
||||
"history_id": history.id,
|
||||
"current_status": "success" if history.status else "failed",
|
||||
"recognized_title": history.title or "unknown",
|
||||
"media_type": history.type or "unknown",
|
||||
"category": history.category or "unknown",
|
||||
"year": history.year or "unknown",
|
||||
"season_episode": season_episode or "unknown",
|
||||
"source_path": source_path or "unknown",
|
||||
"source_storage": source_storage,
|
||||
"destination_path": history.dest or "unknown",
|
||||
"destination_storage": history.dest_storage or "unknown",
|
||||
"transfer_mode": history.mode or "unknown",
|
||||
"tmdbid": history.tmdbid or "none",
|
||||
"doubanid": history.doubanid or "none",
|
||||
"error_message": history.errmsg or "none",
|
||||
}
|
||||
|
||||
|
||||
def format_manual_redo_record_context(history: Any) -> str:
|
||||
"""把单条整理记录格式化为批量任务可直接消费的上下文块。"""
|
||||
context = build_manual_redo_template_context(history)
|
||||
return "\n".join(
|
||||
[
|
||||
f"Record #{context['history_id']}:",
|
||||
f"- Current status: {context['current_status']}",
|
||||
f"- Current recognized title: {context['recognized_title']}",
|
||||
f"- Media type: {context['media_type']}",
|
||||
f"- Category: {context['category']}",
|
||||
f"- Year: {context['year']}",
|
||||
f"- Season/Episode: {context['season_episode']}",
|
||||
f"- Source path: {context['source_path']}",
|
||||
f"- Source storage: {context['source_storage']}",
|
||||
f"- Destination path: {context['destination_path']}",
|
||||
f"- Destination storage: {context['destination_storage']}",
|
||||
f"- Transfer mode: {context['transfer_mode']}",
|
||||
f"- Current TMDB ID: {context['tmdbid']}",
|
||||
f"- Current Douban ID: {context['doubanid']}",
|
||||
f"- Error message: {context['error_message']}",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def build_manual_redo_prompt(history: Any) -> str:
|
||||
"""构建手动 AI 整理提示词。"""
|
||||
return prompt_manager.render_system_task_message(
|
||||
"manual_transfer_redo",
|
||||
template_context=build_manual_redo_template_context(history),
|
||||
)
|
||||
|
||||
|
||||
def build_batch_manual_redo_template_context(histories: list[Any]) -> dict[str, int | str]:
|
||||
"""把多条整理历史对象映射成批量 System Tasks 需要的模板变量。"""
|
||||
return {
|
||||
"history_ids_csv": ", ".join(str(history.id) for history in histories),
|
||||
"history_count": len(histories),
|
||||
"records_context": "\n\n".join(
|
||||
format_manual_redo_record_context(history) for history in histories
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def build_batch_manual_redo_prompt(histories: list[Any]) -> str:
|
||||
"""构建批量手动 AI 整理提示词。"""
|
||||
return prompt_manager.render_system_task_message(
|
||||
"batch_manual_transfer_redo",
|
||||
template_context=build_batch_manual_redo_template_context(histories),
|
||||
)
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Optional
|
||||
@@ -22,8 +23,11 @@ JOBS_DIR = "jobs"
|
||||
ACTIVITY_DIR = "activity"
|
||||
PERSONAS_DIR = "personas"
|
||||
PERSONA_FILE = "PERSONA.md"
|
||||
SUBAGENTS_DIR = "subagents"
|
||||
SUBAGENT_FILE = "SUBAGENT.md"
|
||||
CURRENT_PERSONA_SCHEMA_VERSION = 3
|
||||
PERSONA_SCHEMA_VERSION = 1
|
||||
SUBAGENT_SCHEMA_VERSION = 1
|
||||
DEFAULT_PERSONA_ID = "default"
|
||||
PERSONA_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
|
||||
|
||||
@@ -111,6 +115,41 @@ class PersonaDefinition:
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubAgentDefinition:
|
||||
"""单个子代理定义。"""
|
||||
|
||||
subagent_id: str
|
||||
path: Path
|
||||
description: str
|
||||
text: str
|
||||
include_tags: list[str]
|
||||
exclude_tags: list[str]
|
||||
version: int = SUBAGENT_SCHEMA_VERSION
|
||||
label: str = ""
|
||||
|
||||
def summary_line(self) -> str:
|
||||
"""渲染可读的一行子代理摘要。"""
|
||||
parts = [f"`{self.subagent_id}`"]
|
||||
if self.label and self.label != self.subagent_id:
|
||||
parts.append(self.label)
|
||||
if self.description:
|
||||
parts.append(self.description)
|
||||
return " - ".join(parts)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""输出给查询或调试入口的结构化信息。"""
|
||||
return {
|
||||
"subagent_id": self.subagent_id,
|
||||
"label": self.label,
|
||||
"description": self.description,
|
||||
"include_tags": self.include_tags,
|
||||
"exclude_tags": self.exclude_tags,
|
||||
"version": self.version,
|
||||
"path": str(self.path),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentRuntimeConfig:
|
||||
"""一次加载后的根层配置快照。"""
|
||||
@@ -120,6 +159,7 @@ class AgentRuntimeConfig:
|
||||
current_persona_path: Path
|
||||
persona: PersonaDefinition
|
||||
available_personas: list[PersonaDefinition]
|
||||
available_subagents: list[SubAgentDefinition]
|
||||
extra_context_paths: list[Path]
|
||||
extra_contexts: list[tuple[Path, str]]
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
@@ -127,15 +167,11 @@ class AgentRuntimeConfig:
|
||||
|
||||
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>")
|
||||
sections: list[str] = ["<agent_runtime_config>", f"- Active persona: `{self.active_persona}`",
|
||||
f"- Active persona file: `personas/{self.persona.persona_id}/{PERSONA_FILE}`",
|
||||
"- Use `query_personas` before switching persona when the requested speaking style is unclear.",
|
||||
"- Subagent availability is exposed by the subagent task tools; do not rely on this runtime section as a catalog.",
|
||||
"</agent_runtime_config>"]
|
||||
|
||||
if self.warnings:
|
||||
sections.extend(
|
||||
@@ -201,30 +237,40 @@ class AgentRuntimeManager:
|
||||
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.subagents_dir = self.runtime_dir / SUBAGENTS_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
|
||||
self._cached_signature_checked_at = 0.0
|
||||
self._signature_check_interval = 1.0
|
||||
self._layout_ready = False
|
||||
|
||||
def ensure_layout(self) -> None:
|
||||
"""创建目录、同步默认文件,并清理废弃的旧版 runtime 文件。"""
|
||||
with self._cache_lock:
|
||||
if self._layout_ready:
|
||||
return
|
||||
self.agent_root_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.runtime_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.memory_dir.mkdir(parents=True, exist_ok=True)
|
||||
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.subagents_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()
|
||||
with self._cache_lock:
|
||||
self._layout_ready = True
|
||||
|
||||
def load_runtime_config(self) -> AgentRuntimeConfig:
|
||||
"""加载配置。用户目录损坏时自动回退到内置默认配置。"""
|
||||
self.ensure_layout()
|
||||
signature = self._build_signature()
|
||||
signature = self.current_signature()
|
||||
with self._cache_lock:
|
||||
if self._cached_signature == signature and self._cached_config:
|
||||
return self._cached_config
|
||||
@@ -232,7 +278,7 @@ class AgentRuntimeManager:
|
||||
try:
|
||||
config = self._load_from_root(self.runtime_dir)
|
||||
except AgentRuntimeConfigError as err:
|
||||
logger.warning("Agent 根层配置无效,回退到内置默认配置: %s", err)
|
||||
logger.warning(f"Agent 根层配置无效,回退到内置默认配置: {err}")
|
||||
config = self._load_from_root(self.bundled_defaults_dir)
|
||||
config.used_fallback = True
|
||||
config.warnings.insert(
|
||||
@@ -248,6 +294,25 @@ class AgentRuntimeManager:
|
||||
with self._cache_lock:
|
||||
self._cached_signature = None
|
||||
self._cached_config = None
|
||||
self._cached_signature_checked_at = 0.0
|
||||
self._layout_ready = False
|
||||
|
||||
def current_signature(self) -> tuple[tuple[str, int, int], ...]:
|
||||
"""返回当前运行时配置文件签名,供调用方判断缓存是否仍可复用。"""
|
||||
now = time.monotonic()
|
||||
with self._cache_lock:
|
||||
if (
|
||||
self._cached_signature is not None
|
||||
and now - self._cached_signature_checked_at
|
||||
< self._signature_check_interval
|
||||
):
|
||||
return self._cached_signature
|
||||
|
||||
signature = self._build_signature()
|
||||
with self._cache_lock:
|
||||
self._cached_signature = signature
|
||||
self._cached_signature_checked_at = now
|
||||
return signature
|
||||
|
||||
def set_active_persona(self, persona_query: str) -> AgentRuntimeConfig:
|
||||
"""切换当前激活人格,并立即刷新缓存。"""
|
||||
@@ -271,13 +336,17 @@ class AgentRuntimeManager:
|
||||
)
|
||||
current_path.write_text(document, encoding="utf-8")
|
||||
self.invalidate_cache()
|
||||
logger.info("已切换 Agent 人格: %s", persona.persona_id)
|
||||
logger.info(f"已切换 Agent 人格: {persona.persona_id}")
|
||||
return self.load_runtime_config()
|
||||
|
||||
def list_personas(self) -> list[PersonaDefinition]:
|
||||
"""列出当前可用人格。"""
|
||||
return self.load_runtime_config().available_personas
|
||||
|
||||
def list_subagents(self) -> list[SubAgentDefinition]:
|
||||
"""列出当前可用子代理。"""
|
||||
return self.load_runtime_config().available_subagents
|
||||
|
||||
def update_persona_definition(
|
||||
self,
|
||||
persona_query: str,
|
||||
@@ -382,7 +451,7 @@ class AgentRuntimeManager:
|
||||
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("*")):
|
||||
@@ -392,10 +461,42 @@ class AgentRuntimeManager:
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
if target.exists():
|
||||
if self._should_update_bundled_subagent(relative, path, target):
|
||||
shutil.copy2(path, target)
|
||||
logger.info(f"已更新默认 Agent 子代理定义: {target}")
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(path, target)
|
||||
logger.info("已同步默认 Agent 运行时文件: %s", target)
|
||||
logger.info(f"已同步默认 Agent 运行时文件: {target}")
|
||||
|
||||
@classmethod
|
||||
def _should_update_bundled_subagent(
|
||||
cls,
|
||||
relative_path: Path,
|
||||
source_path: Path,
|
||||
target_path: Path,
|
||||
) -> bool:
|
||||
"""判断是否需要用更高版本的内置子代理定义覆盖用户目录副本。"""
|
||||
parts = relative_path.parts
|
||||
if len(parts) < 3 or parts[0] != SUBAGENTS_DIR or relative_path.name != SUBAGENT_FILE:
|
||||
return False
|
||||
|
||||
source_version = cls._read_markdown_version(source_path)
|
||||
target_version = cls._read_markdown_version(target_path)
|
||||
return source_version > target_version
|
||||
|
||||
@staticmethod
|
||||
def _read_markdown_version(path: Path) -> int:
|
||||
"""读取 Markdown frontmatter 中的整数版本,失败时按 0 处理。"""
|
||||
try:
|
||||
document = AgentRuntimeManager._read_markdown(path)
|
||||
except AgentRuntimeConfigError as err:
|
||||
logger.warning(f"读取 Agent 运行时文件版本失败 {path}: {err}")
|
||||
return 0
|
||||
return AgentRuntimeManager._coerce_int_metadata(
|
||||
document.metadata.get("version"),
|
||||
default=0,
|
||||
)
|
||||
|
||||
def _migrate_root_runtime_files(self) -> None:
|
||||
"""兼容早期直接放在 `config/agent` 根目录的 CURRENT_PERSONA。"""
|
||||
@@ -405,7 +506,7 @@ class AgentRuntimeManager:
|
||||
return
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
source.rename(target)
|
||||
logger.info("已迁移旧版 Agent 根配置文件: %s -> %s", source, target)
|
||||
logger.info(f"已迁移旧版 Agent 根配置文件: {source} -> {target}")
|
||||
|
||||
def _remove_obsolete_runtime_files(self) -> None:
|
||||
"""删除不再支持的旧版 Agent 配置文件,避免被误迁移到 memory。"""
|
||||
@@ -414,14 +515,14 @@ class AgentRuntimeManager:
|
||||
if not path.exists() or not path.is_file():
|
||||
continue
|
||||
path.unlink()
|
||||
logger.info("已删除废弃的 Agent 根配置文件: %s", path)
|
||||
logger.info(f"已删除废弃的 Agent 根配置文件: {path}")
|
||||
|
||||
for relative_path in sorted(OBSOLETE_RUNTIME_FILES):
|
||||
path = self.runtime_dir / relative_path
|
||||
if not path.exists() or not path.is_file():
|
||||
continue
|
||||
path.unlink()
|
||||
logger.info("已删除废弃的 Agent 运行时文件: %s", path)
|
||||
logger.info(f"已删除废弃的 Agent 运行时文件: {path}")
|
||||
|
||||
def _migrate_root_memory_files(self) -> None:
|
||||
"""将旧版根目录 memory 文件移入 `config/agent/memory`。"""
|
||||
@@ -432,7 +533,7 @@ class AgentRuntimeManager:
|
||||
if target.exists():
|
||||
continue
|
||||
path.rename(target)
|
||||
logger.info("已迁移旧版 Agent memory 文件: %s -> %s", path, target)
|
||||
logger.info(f"已迁移旧版 Agent memory 文件: {path} -> {target}")
|
||||
|
||||
def _load_from_root(self, root: Path) -> AgentRuntimeConfig:
|
||||
current_persona_path = root / CURRENT_PERSONA_FILE
|
||||
@@ -451,6 +552,7 @@ class AgentRuntimeManager:
|
||||
|
||||
available_personas = self._load_personas(root)
|
||||
persona = self._resolve_persona_definition(active_persona, available_personas)
|
||||
available_subagents = self._load_subagents(root)
|
||||
extra_contexts = [
|
||||
(path, self._read_markdown(path).body)
|
||||
for path in extra_context_paths
|
||||
@@ -468,6 +570,7 @@ class AgentRuntimeManager:
|
||||
current_persona_path=current_persona_path,
|
||||
persona=persona,
|
||||
available_personas=available_personas,
|
||||
available_subagents=available_subagents,
|
||||
extra_context_paths=extra_context_paths,
|
||||
extra_contexts=extra_contexts,
|
||||
warnings=warnings,
|
||||
@@ -513,6 +616,71 @@ class AgentRuntimeManager:
|
||||
raise AgentRuntimeConfigError(f"{personas_root} 中未找到任何人格定义")
|
||||
return personas
|
||||
|
||||
def _load_subagents(self, root: Path) -> list[SubAgentDefinition]:
|
||||
"""扫描并解析所有可用子代理。"""
|
||||
subagents_root = root / SUBAGENTS_DIR
|
||||
if not subagents_root.exists():
|
||||
raise AgentRuntimeConfigError(f"缺少 subagents 目录: {subagents_root}")
|
||||
|
||||
subagents: list[SubAgentDefinition] = []
|
||||
seen_ids: set[str] = set()
|
||||
for subagent_dir in sorted(subagents_root.iterdir()):
|
||||
if not subagent_dir.is_dir():
|
||||
continue
|
||||
subagent_path = subagent_dir / SUBAGENT_FILE
|
||||
if not subagent_path.exists():
|
||||
continue
|
||||
document = self._read_markdown(subagent_path)
|
||||
subagent_id = str(
|
||||
document.metadata.get("subagent_id") or subagent_dir.name
|
||||
).strip()
|
||||
if not subagent_id:
|
||||
raise AgentRuntimeConfigError(f"{subagent_path} 缺少 subagent_id")
|
||||
if not PERSONA_ID_PATTERN.fullmatch(subagent_id):
|
||||
raise AgentRuntimeConfigError(
|
||||
f"{subagent_path} 的 subagent_id 只能使用小写字母、数字、下划线和中划线,且必须以字母或数字开头"
|
||||
)
|
||||
if subagent_id in seen_ids:
|
||||
raise AgentRuntimeConfigError(f"检测到重复的子代理 ID: {subagent_id}")
|
||||
seen_ids.add(subagent_id)
|
||||
|
||||
description = str(document.metadata.get("description") or "").strip()
|
||||
if not description:
|
||||
raise AgentRuntimeConfigError(f"{subagent_path} 缺少 description")
|
||||
include_tags = self._normalize_string_list(
|
||||
document.metadata.get("include_tags"),
|
||||
f"{subagent_path}.include_tags",
|
||||
)
|
||||
if not include_tags:
|
||||
raise AgentRuntimeConfigError(f"{subagent_path} 缺少 include_tags")
|
||||
exclude_tags = self._normalize_string_list(
|
||||
document.metadata.get("exclude_tags"),
|
||||
f"{subagent_path}.exclude_tags",
|
||||
)
|
||||
text = self._normalize_subagent_body(document.body)
|
||||
if not text:
|
||||
raise AgentRuntimeConfigError(f"{subagent_path} 子代理正文不能为空")
|
||||
|
||||
subagents.append(
|
||||
SubAgentDefinition(
|
||||
subagent_id=subagent_id,
|
||||
path=subagent_path,
|
||||
label=str(document.metadata.get("label") or subagent_id).strip(),
|
||||
description=description,
|
||||
text=text,
|
||||
include_tags=include_tags,
|
||||
exclude_tags=exclude_tags,
|
||||
version=self._coerce_int_metadata(
|
||||
document.metadata.get("version"),
|
||||
default=SUBAGENT_SCHEMA_VERSION,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if not subagents:
|
||||
raise AgentRuntimeConfigError(f"{subagents_root} 中未找到任何子代理定义")
|
||||
return subagents
|
||||
|
||||
@staticmethod
|
||||
def _resolve_persona_definition(
|
||||
persona_query: str,
|
||||
@@ -552,7 +720,7 @@ class AgentRuntimeManager:
|
||||
if not path.exists():
|
||||
raise AgentRuntimeConfigError(f"缺少配置文件: {path}")
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
content = path.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception as err: # noqa: BLE001
|
||||
raise AgentRuntimeConfigError(f"读取配置文件失败 {path}: {err}") from err
|
||||
|
||||
@@ -653,6 +821,27 @@ class AgentRuntimeManager:
|
||||
return remainder.strip()
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _normalize_subagent_body(body: Optional[str]) -> str:
|
||||
"""去掉重复的 SUBAGENT 标题,保持正文可安全加载。"""
|
||||
normalized = (body or "").strip()
|
||||
if not normalized:
|
||||
return ""
|
||||
if normalized.startswith("# SUBAGENT"):
|
||||
_, _, remainder = normalized.partition("\n")
|
||||
return remainder.strip()
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _coerce_int_metadata(value: Any, *, default: int = 0) -> int:
|
||||
"""将 frontmatter 中的整数型元数据规范化。"""
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def _validate_runtime_config(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -4,6 +4,7 @@ import threading
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, ClassVar, Optional
|
||||
|
||||
from langchain_core.tools import BaseTool
|
||||
@@ -76,6 +77,7 @@ def format_tool_result_for_agent(
|
||||
|
||||
# 将常见的阻塞调用按能力域拆分到独立线程池,避免外部慢 IO 抢占同一批 worker。
|
||||
_BLOCKING_BUCKET_LIMITS = {
|
||||
"command": 4,
|
||||
"default": 4,
|
||||
"config": 2,
|
||||
"db": 4,
|
||||
@@ -86,6 +88,7 @@ _BLOCKING_BUCKET_LIMITS = {
|
||||
"site": 4,
|
||||
"storage": 4,
|
||||
"subscribe": 2,
|
||||
"web": 2,
|
||||
"workflow": 2,
|
||||
}
|
||||
_blocking_semaphores = {
|
||||
@@ -112,6 +115,64 @@ def _get_blocking_executor(bucket: str) -> ThreadPoolExecutor:
|
||||
return executor
|
||||
|
||||
|
||||
def shutdown_blocking_executors(*, wait: bool = True, cancel_futures: bool = False) -> None:
|
||||
"""关闭 Agent 工具阻塞线程池,释放长期运行进程或测试环境中的 worker。"""
|
||||
with _blocking_executor_lock:
|
||||
executors = list(_blocking_executors.values())
|
||||
_blocking_executors.clear()
|
||||
|
||||
for executor in executors:
|
||||
executor.shutdown(wait=wait, cancel_futures=cancel_futures)
|
||||
|
||||
|
||||
class ToolExecutionTimeoutError(TimeoutError):
|
||||
"""Agent 工具执行超时异常。"""
|
||||
|
||||
|
||||
def _get_tool_timeout_seconds() -> Optional[float]:
|
||||
"""读取工具执行超时时间,配置为 0 或负数时表示不限制。"""
|
||||
try:
|
||||
timeout = float(settings.LLM_TOOL_TIMEOUT or 0)
|
||||
except (TypeError, ValueError):
|
||||
timeout = 0
|
||||
return timeout if timeout > 0 else None
|
||||
|
||||
|
||||
async def run_agent_blocking(
|
||||
bucket: str, func: Callable[..., Any], *args: Any, **kwargs: Any
|
||||
) -> Any:
|
||||
"""
|
||||
在受控线程池中运行阻塞型同步代码。
|
||||
|
||||
调用方被取消时不会提前释放并发名额,避免底层阻塞调用仍在运行时继续接纳
|
||||
新任务,把同一类慢 IO 的线程池持续打满。
|
||||
"""
|
||||
bucket_name = bucket if bucket in _BLOCKING_BUCKET_LIMITS else "default"
|
||||
semaphore = _blocking_semaphores[bucket_name]
|
||||
bound_call = partial(func, *args, **kwargs)
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
await semaphore.acquire()
|
||||
try:
|
||||
future = _get_blocking_executor(bucket_name).submit(bound_call)
|
||||
except Exception:
|
||||
semaphore.release()
|
||||
raise
|
||||
|
||||
def _release_semaphore(_future) -> None:
|
||||
try:
|
||||
_future.exception()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
loop.call_soon_threadsafe(semaphore.release)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
future.add_done_callback(_release_semaphore)
|
||||
return await asyncio.shield(asyncio.wrap_future(future, loop=loop))
|
||||
|
||||
|
||||
class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
"""
|
||||
MoviePilot专用工具基类(LangChain v1 / langchain_core)
|
||||
@@ -177,10 +238,6 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
|
||||
# 获取工具执行提示消息
|
||||
tool_message = self.get_tool_message(**kwargs)
|
||||
if not tool_message:
|
||||
explanation = kwargs.get("explanation")
|
||||
if explanation:
|
||||
tool_message = explanation
|
||||
|
||||
# 发送工具执行过程消息(流式传输且非最后终结工具时)
|
||||
if self._stream_handler and self._stream_handler.is_streaming and not self.return_direct:
|
||||
@@ -236,7 +293,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
|
||||
# 执行具体工具逻辑
|
||||
try:
|
||||
result = await self.run(**kwargs)
|
||||
result = await self.run_with_timeout(**kwargs)
|
||||
|
||||
# 记录工具执行结果摘要日志
|
||||
str_result = serialize_tool_result_for_agent(result)
|
||||
@@ -246,6 +303,10 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
summary = str_result
|
||||
logger.info(f"Agent工具 {self.name} 执行完成,结果摘要: {summary}")
|
||||
|
||||
except ToolExecutionTimeoutError as e:
|
||||
error_message = str(e)
|
||||
logger.warning(error_message)
|
||||
result = error_message
|
||||
except Exception as e:
|
||||
error_message = f"工具执行异常 ({type(e).__name__}): {str(e)}"
|
||||
logger.error(f"Tool {self.name} execution failed: {e}", exc_info=True)
|
||||
@@ -260,22 +321,31 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
获取工具执行时的友好提示消息。
|
||||
|
||||
子类可以重写此方法,根据实际参数生成个性化的提示消息。
|
||||
如果返回 None 或空字符串,将回退使用 explanation 参数。
|
||||
|
||||
Args:
|
||||
**kwargs: 工具的所有参数(包括 explanation)
|
||||
**kwargs: 工具的所有参数
|
||||
|
||||
Returns:
|
||||
str: 友好的提示消息,如果返回 None 或空字符串则使用 explanation
|
||||
str: 友好的提示消息
|
||||
"""
|
||||
explanation = kwargs.get("explanation")
|
||||
return str(explanation) if explanation else None
|
||||
return None
|
||||
|
||||
@abstractmethod
|
||||
async def run(self, **kwargs) -> str:
|
||||
"""子类实现具体的工具执行逻辑"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def run_with_timeout(self, **kwargs) -> str:
|
||||
"""按系统配置限制单个工具调用的最长执行时间。"""
|
||||
timeout = _get_tool_timeout_seconds()
|
||||
if not timeout:
|
||||
return await self.run(**kwargs)
|
||||
try:
|
||||
return await asyncio.wait_for(self.run(**kwargs), timeout=timeout)
|
||||
except asyncio.TimeoutError as err:
|
||||
raise ToolExecutionTimeoutError(
|
||||
f"工具 {self.name} 执行超时(超过 {timeout:g} 秒),已停止等待结果。"
|
||||
) from err
|
||||
|
||||
@staticmethod
|
||||
async def run_blocking(
|
||||
bucket: str, func: Callable[..., Any], *args: Any, **kwargs: Any
|
||||
@@ -283,15 +353,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
"""
|
||||
在受控线程池中运行阻塞型同步代码,避免拖住 FastAPI 主事件循环。
|
||||
"""
|
||||
bucket_name = bucket if bucket in _BLOCKING_BUCKET_LIMITS else "default"
|
||||
semaphore = _blocking_semaphores[bucket_name]
|
||||
bound_call = partial(func, *args, **kwargs)
|
||||
|
||||
async with semaphore:
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(
|
||||
_get_blocking_executor(bucket_name), bound_call
|
||||
)
|
||||
return await run_agent_blocking(bucket, func, *args, **kwargs)
|
||||
|
||||
def set_message_attr(self, channel: str, source: str, username: str):
|
||||
"""
|
||||
@@ -315,6 +377,116 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
# 独立的新 dict,跨工具状态(例如质量门槛拒绝标记)无法传播。
|
||||
self._agent_context = {} if agent_context is None else agent_context
|
||||
|
||||
async def is_admin_user(self) -> bool:
|
||||
"""
|
||||
判断当前工具调用者是否拥有管理员级权限。
|
||||
|
||||
:return: 当前调用者是系统管理员、渠道管理员或显式管理员上下文时返回 True
|
||||
"""
|
||||
if bool(self._agent_context.get("is_admin")):
|
||||
return True
|
||||
|
||||
if not self._channel or not self._source:
|
||||
return False
|
||||
|
||||
return await self._has_channel_admin_permission()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_local_path(path: str) -> Path:
|
||||
"""
|
||||
解析本地路径并展开符号链接。
|
||||
|
||||
:param path: 用户传入的本地文件或目录路径
|
||||
:return: 规范化后的绝对路径
|
||||
"""
|
||||
return Path(path).expanduser().resolve(strict=False)
|
||||
|
||||
@staticmethod
|
||||
def _is_path_relative_to(path: Path, root: Path) -> bool:
|
||||
"""
|
||||
判断路径是否位于指定目录内。
|
||||
|
||||
:param path: 待检查路径
|
||||
:param root: 允许访问的根目录
|
||||
:return: 路径在根目录内或等于根目录时返回 True
|
||||
"""
|
||||
try:
|
||||
path.relative_to(root)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _get_non_admin_local_file_roots(cls) -> list[Path]:
|
||||
"""
|
||||
获取普通用户可访问的本地文件根目录。
|
||||
|
||||
:return: 普通用户允许读写的本地目录列表
|
||||
"""
|
||||
roots = [
|
||||
settings.CONFIG_PATH / "agent",
|
||||
]
|
||||
resolved_roots = []
|
||||
for root in roots:
|
||||
resolved_root = cls._resolve_local_path(str(root))
|
||||
if resolved_root not in resolved_roots:
|
||||
resolved_roots.append(resolved_root)
|
||||
return resolved_roots
|
||||
|
||||
async def _check_local_file_access(
|
||||
self, path: str, operation: str = "访问"
|
||||
) -> tuple[Optional[Path], Optional[str]]:
|
||||
"""
|
||||
检查当前用户是否可访问指定本地路径。
|
||||
|
||||
:param path: 用户传入的本地文件或目录路径
|
||||
:param operation: 当前操作名称,用于生成拒绝提示
|
||||
:return: 解析后的路径和拒绝原因;拒绝原因为空表示允许访问
|
||||
"""
|
||||
if not path:
|
||||
return None, "错误:路径不能为空"
|
||||
|
||||
resolved_path = self._resolve_local_path(path)
|
||||
if await self.is_admin_user():
|
||||
return resolved_path, None
|
||||
|
||||
allowed_roots = self._get_non_admin_local_file_roots()
|
||||
if any(
|
||||
self._is_path_relative_to(resolved_path, root)
|
||||
for root in allowed_roots
|
||||
):
|
||||
return resolved_path, None
|
||||
|
||||
allowed_text = "、".join(str(root) for root in allowed_roots)
|
||||
return (
|
||||
resolved_path,
|
||||
f"抱歉,普通用户只能{operation}Agent配置目录内的文件或目录:{allowed_text}",
|
||||
)
|
||||
|
||||
async def _check_local_storage_access(
|
||||
self,
|
||||
path: str,
|
||||
storage: Optional[str] = "local",
|
||||
operation: str = "访问",
|
||||
) -> tuple[Optional[Path], Optional[str]]:
|
||||
"""
|
||||
检查当前用户是否可访问指定存储路径。
|
||||
|
||||
:param path: 用户传入的文件或目录路径
|
||||
:param storage: 存储类型,普通用户只允许 local
|
||||
:param operation: 当前操作名称,用于生成拒绝提示
|
||||
:return: 本地存储时返回解析后的路径和拒绝原因;远程存储无本地路径
|
||||
"""
|
||||
if (storage or "local") != "local":
|
||||
if await self.is_admin_user():
|
||||
return None, None
|
||||
return (
|
||||
None,
|
||||
f"抱歉,普通用户只能{operation}本地Agent配置目录,不能访问远程存储。",
|
||||
)
|
||||
|
||||
return await self._check_local_file_access(path=path, operation=operation)
|
||||
|
||||
async def _check_permission(self) -> Optional[str]:
|
||||
"""
|
||||
检查用户权限:
|
||||
@@ -327,9 +499,28 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
if not self._require_admin:
|
||||
return None
|
||||
|
||||
if await self.is_admin_user():
|
||||
return None
|
||||
|
||||
if not self._channel or not self._source:
|
||||
return None
|
||||
|
||||
return (
|
||||
"抱歉,您没有执行此工具的权限。"
|
||||
"只有渠道管理员或系统管理员才能执行工具操作。"
|
||||
"如需执行工具,请联系管理员将您的用户ID添加到渠道管理员列表中(设定 -> 通知 -> 对应渠道配置 -> 管理员名单),"
|
||||
"或联系系统管理员为您设置管理员权限。"
|
||||
)
|
||||
|
||||
async def _has_channel_admin_permission(self) -> bool:
|
||||
"""
|
||||
检查当前消息渠道身份是否具备管理员权限。
|
||||
|
||||
:return: 当前渠道用户是渠道管理员、系统管理员或默认接收人时返回 True
|
||||
"""
|
||||
if not self._channel or not self._source:
|
||||
return False
|
||||
|
||||
# 渠道配置来自 SystemConfigOper 内存缓存,可以直接读取;
|
||||
# 只有用户信息需要走异步数据库查询。
|
||||
user_id_str = str(self._user_id) if self._user_id else None
|
||||
@@ -353,7 +544,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
break
|
||||
|
||||
if not channel_type:
|
||||
return None
|
||||
return False
|
||||
|
||||
admin_key_map = {
|
||||
"telegram": "TELEGRAM_ADMINS",
|
||||
@@ -393,7 +584,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
if aid.strip()
|
||||
]
|
||||
if user_id_str and user_id_str in admin_list:
|
||||
return None
|
||||
return True
|
||||
|
||||
user = (
|
||||
await UserOper().async_get_by_name(self._username)
|
||||
@@ -401,14 +592,9 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
else None
|
||||
)
|
||||
if user and user.is_superuser:
|
||||
return None
|
||||
return True
|
||||
|
||||
return (
|
||||
"抱歉,您没有执行此工具的权限。"
|
||||
"只有渠道管理员或系统管理员才能执行工具操作。"
|
||||
"如需执行工具,请联系渠道管理员将您的用户ID添加到渠道管理员列表中,"
|
||||
"或联系系统管理员为您设置权限。"
|
||||
)
|
||||
return False
|
||||
else:
|
||||
user = (
|
||||
await UserOper().async_get_by_name(self._username)
|
||||
@@ -416,22 +602,35 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
else None
|
||||
)
|
||||
if user and user.is_superuser:
|
||||
return None
|
||||
return True
|
||||
|
||||
if user_id_key:
|
||||
config_user_id = config.config.get(user_id_key)
|
||||
if config_user_id and str(config_user_id) == user_id_str:
|
||||
return None
|
||||
return True
|
||||
|
||||
return (
|
||||
"抱歉,您没有执行此工具的权限。"
|
||||
"只有系统管理员才能执行工具操作。"
|
||||
"如需执行工具,请联系系统管理员为您设置权限。"
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"检查权限失败: {e}")
|
||||
|
||||
return None
|
||||
return False
|
||||
|
||||
async def send_notification_message(self, notification: Notification) -> None:
|
||||
"""
|
||||
发送工具通知消息。
|
||||
|
||||
WebAgent 渠道没有后端模块实例,前端流式面板通过 Agent 上下文中的
|
||||
回调直接接收通知;其它渠道继续走统一消息链。
|
||||
"""
|
||||
callback = self._agent_context.get("notification_callback")
|
||||
if (
|
||||
self._channel == MessageChannel.WebAgent.value
|
||||
and callable(callback)
|
||||
):
|
||||
callback(notification)
|
||||
return
|
||||
|
||||
await ToolChain().async_post_message(notification)
|
||||
|
||||
async def send_tool_message(
|
||||
self, message: str, title: str = "", image: Optional[str] = None
|
||||
@@ -439,7 +638,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
"""
|
||||
发送工具消息
|
||||
"""
|
||||
await ToolChain().async_post_message(
|
||||
await self.send_notification_message(
|
||||
Notification(
|
||||
channel=self._channel,
|
||||
source=self._source,
|
||||
@@ -449,5 +648,6 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
title=title,
|
||||
text=message,
|
||||
image=image,
|
||||
save_history=False,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import List, Callable
|
||||
from typing import Callable, List, Optional, Type
|
||||
|
||||
from app.agent.tools.impl.add_download import AddDownloadTool
|
||||
from app.agent.tools.impl.add_download_tasks import AddDownloadTasksTool
|
||||
from app.agent.tools.impl.add_subscribe import AddSubscribeTool
|
||||
from app.agent.tools.impl.update_subscribe import UpdateSubscribeTool
|
||||
from app.agent.tools.impl.search_subscribe import SearchSubscribeTool
|
||||
@@ -37,6 +37,7 @@ from app.agent.tools.impl.query_media_detail import QueryMediaDetailTool
|
||||
from app.agent.tools.impl.search_torrents import SearchTorrentsTool
|
||||
from app.agent.tools.impl.get_search_results import GetSearchResultsTool
|
||||
from app.agent.tools.impl.search_web import SearchWebTool
|
||||
from app.agent.tools.impl.recognize_captcha import RecognizeCaptchaTool
|
||||
from app.agent.tools.impl.send_message import SendMessageTool
|
||||
from app.agent.tools.impl.ask_user_choice import AskUserChoiceTool
|
||||
from app.agent.tools.impl.send_local_file import SendLocalFileTool
|
||||
@@ -49,10 +50,10 @@ 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_tasks import DeleteDownloadTasksTool
|
||||
from app.agent.tools.impl.delete_download_history import DeleteDownloadHistoryTool
|
||||
from app.agent.tools.impl.delete_transfer_history import DeleteTransferHistoryTool
|
||||
from app.agent.tools.impl.modify_download import ModifyDownloadTool
|
||||
from app.agent.tools.impl.update_download_tasks import UpdateDownloadTasksTool
|
||||
from app.agent.tools.impl.query_directory_settings import QueryDirectorySettingsTool
|
||||
from app.agent.tools.impl.list_directory import ListDirectoryTool
|
||||
from app.agent.tools.impl.query_transfer_history import QueryTransferHistoryTool
|
||||
@@ -74,6 +75,7 @@ from app.agent.tools.impl.uninstall_plugin import UninstallPluginTool
|
||||
from app.agent.tools.impl.run_slash_command import RunSlashCommandTool
|
||||
from app.agent.tools.impl.list_slash_commands import ListSlashCommandsTool
|
||||
from app.agent.tools.impl.query_custom_identifiers import QueryCustomIdentifiersTool
|
||||
from app.agent.tools.impl.query_doctor_report import QueryDoctorReportTool
|
||||
from app.agent.tools.impl.update_custom_identifiers import UpdateCustomIdentifiersTool
|
||||
from app.agent.tools.impl.query_system_settings import QuerySystemSettingsTool
|
||||
from app.agent.tools.impl.update_system_settings import UpdateSystemSettingsTool
|
||||
@@ -90,8 +92,87 @@ class MoviePilotToolFactory:
|
||||
MoviePilot工具工厂
|
||||
"""
|
||||
|
||||
BUILTIN_TOOL_CLASSES: tuple[Type[MoviePilotTool], ...] = (
|
||||
SearchMediaTool,
|
||||
SearchPersonTool,
|
||||
SearchPersonCreditsTool,
|
||||
RecognizeMediaTool,
|
||||
ScrapeMetadataTool,
|
||||
QueryEpisodeScheduleTool,
|
||||
QueryMediaDetailTool,
|
||||
AddSubscribeTool,
|
||||
UpdateSubscribeTool,
|
||||
SearchSubscribeTool,
|
||||
SearchTorrentsTool,
|
||||
GetSearchResultsTool,
|
||||
SearchWebTool,
|
||||
RecognizeCaptchaTool,
|
||||
AddDownloadTasksTool,
|
||||
QuerySubscribesTool,
|
||||
QuerySubscribeSharesTool,
|
||||
QueryPopularSubscribesTool,
|
||||
QueryBuiltinFilterRulesTool,
|
||||
QueryCustomFilterRulesTool,
|
||||
QueryRuleGroupsTool,
|
||||
AddCustomFilterRuleTool,
|
||||
UpdateCustomFilterRuleTool,
|
||||
DeleteCustomFilterRuleTool,
|
||||
AddRuleGroupTool,
|
||||
UpdateRuleGroupTool,
|
||||
DeleteRuleGroupTool,
|
||||
QuerySubscribeHistoryTool,
|
||||
DeleteSubscribeTool,
|
||||
QueryDownloadTasksTool,
|
||||
DeleteDownloadTasksTool,
|
||||
DeleteDownloadHistoryTool,
|
||||
DeleteTransferHistoryTool,
|
||||
UpdateDownloadTasksTool,
|
||||
QueryDownloadersTool,
|
||||
QuerySitesTool,
|
||||
UpdateSiteTool,
|
||||
QuerySiteUserdataTool,
|
||||
TestSiteTool,
|
||||
UpdateSiteCookieTool,
|
||||
GetRecommendationsTool,
|
||||
QueryLibraryExistsTool,
|
||||
QueryLibraryLatestTool,
|
||||
QueryDirectorySettingsTool,
|
||||
ListDirectoryTool,
|
||||
QueryTransferHistoryTool,
|
||||
TransferFileTool,
|
||||
SendMessageTool,
|
||||
QuerySchedulersTool,
|
||||
RunSchedulerTool,
|
||||
QueryWorkflowsTool,
|
||||
RunWorkflowTool,
|
||||
QueryPersonasTool,
|
||||
SwitchPersonaTool,
|
||||
UpdatePersonaDefinitionTool,
|
||||
ExecuteCommandTool,
|
||||
EditFileTool,
|
||||
WriteFileTool,
|
||||
ReadFileTool,
|
||||
BrowseWebpageTool,
|
||||
QueryInstalledPluginsTool,
|
||||
QueryMarketPluginsTool,
|
||||
QueryPluginCapabilitiesTool,
|
||||
QueryPluginConfigTool,
|
||||
UpdatePluginConfigTool,
|
||||
ReloadPluginTool,
|
||||
QueryPluginDataTool,
|
||||
InstallPluginTool,
|
||||
UninstallPluginTool,
|
||||
RunSlashCommandTool,
|
||||
ListSlashCommandsTool,
|
||||
QueryDoctorReportTool,
|
||||
QueryCustomIdentifiersTool,
|
||||
UpdateCustomIdentifiersTool,
|
||||
QuerySystemSettingsTool,
|
||||
UpdateSystemSettingsTool,
|
||||
)
|
||||
|
||||
# 这些通用工具需要始终保留,避免大工具集裁剪后让 Agent 丢失基础的
|
||||
# 文件系统、命令执行、主动消息发送或交互确认能力。AskUserChoiceTool 仅在支持按钮
|
||||
# 文件系统、命令执行、历史检索或交互确认能力。AskUserChoiceTool 仅在支持按钮
|
||||
# 的渠道中才会实际注入,因此后续会再按已加载工具做一次求交集。
|
||||
TOOL_SELECTOR_ALWAYS_INCLUDE_NAMES = (
|
||||
"list_directory",
|
||||
@@ -99,12 +180,11 @@ class MoviePilotToolFactory:
|
||||
"read_file",
|
||||
"edit_file",
|
||||
"execute_command",
|
||||
"send_message",
|
||||
"ask_user_choice",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _should_enable_choice_tool(channel: str = None) -> bool:
|
||||
def _should_enable_choice_tool(channel: Optional[str] = None) -> bool:
|
||||
if not channel:
|
||||
return False
|
||||
try:
|
||||
@@ -134,8 +214,24 @@ class MoviePilotToolFactory:
|
||||
if tool_name in available_tool_names
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
def _get_builtin_tool_classes(
|
||||
cls, channel: Optional[str] = None
|
||||
) -> list[Type[MoviePilotTool]]:
|
||||
"""
|
||||
返回当前渠道可用的内置工具类清单。
|
||||
"""
|
||||
tool_definitions = list(cls.BUILTIN_TOOL_CLASSES)
|
||||
if cls._should_enable_choice_tool(channel):
|
||||
tool_definitions.append(AskUserChoiceTool)
|
||||
tool_definitions.append(SendLocalFileTool)
|
||||
if AgentCapabilityManager.supports_audio_output():
|
||||
tool_definitions.append(SendVoiceMessageTool)
|
||||
return tool_definitions
|
||||
|
||||
@classmethod
|
||||
def create_tools(
|
||||
cls,
|
||||
session_id: str,
|
||||
user_id: str,
|
||||
channel: str = None,
|
||||
@@ -149,87 +245,7 @@ class MoviePilotToolFactory:
|
||||
创建MoviePilot工具列表
|
||||
"""
|
||||
tools = []
|
||||
tool_definitions = [
|
||||
SearchMediaTool,
|
||||
SearchPersonTool,
|
||||
SearchPersonCreditsTool,
|
||||
RecognizeMediaTool,
|
||||
ScrapeMetadataTool,
|
||||
QueryEpisodeScheduleTool,
|
||||
QueryMediaDetailTool,
|
||||
AddSubscribeTool,
|
||||
UpdateSubscribeTool,
|
||||
SearchSubscribeTool,
|
||||
SearchTorrentsTool,
|
||||
GetSearchResultsTool,
|
||||
SearchWebTool,
|
||||
AddDownloadTool,
|
||||
QuerySubscribesTool,
|
||||
QuerySubscribeSharesTool,
|
||||
QueryPopularSubscribesTool,
|
||||
QueryBuiltinFilterRulesTool,
|
||||
QueryCustomFilterRulesTool,
|
||||
QueryRuleGroupsTool,
|
||||
AddCustomFilterRuleTool,
|
||||
UpdateCustomFilterRuleTool,
|
||||
DeleteCustomFilterRuleTool,
|
||||
AddRuleGroupTool,
|
||||
UpdateRuleGroupTool,
|
||||
DeleteRuleGroupTool,
|
||||
QuerySubscribeHistoryTool,
|
||||
DeleteSubscribeTool,
|
||||
QueryDownloadTasksTool,
|
||||
DeleteDownloadTool,
|
||||
DeleteDownloadHistoryTool,
|
||||
DeleteTransferHistoryTool,
|
||||
ModifyDownloadTool,
|
||||
QueryDownloadersTool,
|
||||
QuerySitesTool,
|
||||
UpdateSiteTool,
|
||||
QuerySiteUserdataTool,
|
||||
TestSiteTool,
|
||||
UpdateSiteCookieTool,
|
||||
GetRecommendationsTool,
|
||||
QueryLibraryExistsTool,
|
||||
QueryLibraryLatestTool,
|
||||
QueryDirectorySettingsTool,
|
||||
ListDirectoryTool,
|
||||
QueryTransferHistoryTool,
|
||||
TransferFileTool,
|
||||
SendMessageTool,
|
||||
QuerySchedulersTool,
|
||||
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.append(SendLocalFileTool)
|
||||
if AgentCapabilityManager.supports_audio_output():
|
||||
tool_definitions.append(SendVoiceMessageTool)
|
||||
tool_definitions = cls._get_builtin_tool_classes(channel)
|
||||
# 创建内置工具
|
||||
for ToolClass in tool_definitions:
|
||||
tool = ToolClass(session_id=session_id, user_id=user_id)
|
||||
@@ -276,9 +292,9 @@ class MoviePilotToolFactory:
|
||||
|
||||
builtin_tools_count = len(tool_definitions)
|
||||
if plugin_tools_count > 0:
|
||||
logger.info(
|
||||
logger.debug(
|
||||
f"成功创建 {len(tools)} 个MoviePilot工具(内置工具: {builtin_tools_count} 个,插件工具: {plugin_tools_count} 个)"
|
||||
)
|
||||
else:
|
||||
logger.info(f"成功创建 {len(tools)} 个MoviePilot工具")
|
||||
logger.debug(f"成功创建 {len(tools)} 个MoviePilot工具")
|
||||
return tools
|
||||
|
||||
88
app/agent/tools/impl/_command_safety.py
Normal file
88
app/agent/tools/impl/_command_safety.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Agent 命令工具的安全校验逻辑。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os.path
|
||||
import re
|
||||
import shlex
|
||||
|
||||
|
||||
COMMAND_FORBIDDEN_KEYWORDS = (
|
||||
":(){ :|:& };:",
|
||||
"dd if=/dev/zero",
|
||||
"mkfs",
|
||||
"reboot",
|
||||
"shutdown",
|
||||
)
|
||||
|
||||
COMMAND_DANGEROUS_PATTERNS = (
|
||||
re.compile(r"\brm\s+[^;&|]*-[^\s;&|]*[rR][fF]?[^\s;&|]*\s+/(?:\s|$|[;&|])"),
|
||||
re.compile(r"\bdd\s+[^;&|]*(?:of=/dev/(?:sd[a-z]\d*|nvme\d+n\d+p?\d*|disk\d+)|if=/dev/zero)"),
|
||||
re.compile(r"\b(?:mkfs|fdisk|parted|diskutil)\b"),
|
||||
re.compile(r"\b(?:chmod|chown)\s+[^;&|]*-R[^;&|]*\s+/(?:\s|$|[;&|])"),
|
||||
re.compile(r"\b(?:reboot|shutdown|halt|poweroff)\b"),
|
||||
)
|
||||
|
||||
|
||||
def _command_tokens(command: str) -> list[str]:
|
||||
"""尽力解析 shell 命令 token,解析失败时退回空白分割。"""
|
||||
try:
|
||||
return shlex.split(command, posix=True)
|
||||
except ValueError:
|
||||
return re.split(r"\s+", command.strip())
|
||||
|
||||
|
||||
def _contains_recursive_root_delete(command: str) -> bool:
|
||||
"""识别递归删除根目录或一级目录的 rm 命令。"""
|
||||
tokens = _command_tokens(command)
|
||||
if not any(token == "rm" or token.endswith("/rm") for token in tokens):
|
||||
return False
|
||||
has_recursive = any(
|
||||
token.startswith("-") and ("r" in token or "R" in token)
|
||||
for token in tokens
|
||||
)
|
||||
if not has_recursive:
|
||||
return False
|
||||
|
||||
for token in tokens:
|
||||
clean_token = re.match(r"^([^;|&><]+)", token)
|
||||
if not clean_token:
|
||||
continue
|
||||
path_value = clean_token.group(1).strip("\"'")
|
||||
if not path_value.startswith("/"):
|
||||
continue
|
||||
norm_path = os.path.normpath(path_value)
|
||||
if norm_path == "/" or re.match(r"^/[^/]+$", norm_path):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def detect_dangerous_command(command: str) -> str:
|
||||
"""返回危险命令原因,安全时返回空字符串。"""
|
||||
normalized = str(command or "").strip()
|
||||
if not normalized:
|
||||
return "命令不能为空"
|
||||
for keyword in COMMAND_FORBIDDEN_KEYWORDS:
|
||||
if keyword in normalized:
|
||||
return f"命令包含禁止使用的关键字 '{keyword}'"
|
||||
if _contains_recursive_root_delete(normalized):
|
||||
return "命令疑似递归删除根目录或一级目录"
|
||||
for pattern in COMMAND_DANGEROUS_PATTERNS:
|
||||
if pattern.search(normalized):
|
||||
return "命令匹配高危系统操作模式"
|
||||
return ""
|
||||
|
||||
|
||||
def validate_command_safety(command: str, *, confirmed: bool = False) -> None:
|
||||
"""
|
||||
校验 shell 命令安全性。
|
||||
|
||||
:param command: 待执行命令
|
||||
:param confirmed: 是否已经通过显式参数确认高危操作
|
||||
"""
|
||||
reason = detect_dangerous_command(command)
|
||||
if not reason:
|
||||
return
|
||||
if confirmed and reason != "命令不能为空":
|
||||
return
|
||||
raise ValueError(f"{reason}。如确认需要执行,请设置 confirm_dangerous=true")
|
||||
@@ -5,8 +5,7 @@ import re
|
||||
from typing import Any, Dict, Iterable, Optional
|
||||
|
||||
from app.core.event import eventmanager
|
||||
from app.db import AsyncSessionFactory
|
||||
from app.db.models.subscribe import Subscribe
|
||||
from app.db.subscribe_oper import SubscribeOper
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.helper.rule import RuleHelper
|
||||
from app.modules.filter.RuleParser import RuleParser
|
||||
@@ -284,23 +283,22 @@ async def collect_rule_group_usages(
|
||||
continue
|
||||
ensure_usage(name)["used_in_global_best_version"] = True
|
||||
|
||||
async with AsyncSessionFactory() as db:
|
||||
subscribes = await Subscribe.async_list(db)
|
||||
for subscribe in subscribes:
|
||||
filter_groups = subscribe.filter_groups or []
|
||||
for name in filter_groups:
|
||||
if target_names and name not in target_names:
|
||||
continue
|
||||
ensure_usage(name)["subscribes"].append(
|
||||
{
|
||||
"subscribe_id": subscribe.id,
|
||||
"name": subscribe.name,
|
||||
"season": subscribe.season,
|
||||
"type": subscribe.type,
|
||||
"username": subscribe.username,
|
||||
"best_version": bool(subscribe.best_version),
|
||||
}
|
||||
)
|
||||
subscribes = await SubscribeOper().async_list()
|
||||
for subscribe in subscribes:
|
||||
filter_groups = subscribe.filter_groups or []
|
||||
for name in filter_groups:
|
||||
if target_names and name not in target_names:
|
||||
continue
|
||||
ensure_usage(name)["subscribes"].append(
|
||||
{
|
||||
"subscribe_id": subscribe.id,
|
||||
"name": subscribe.name,
|
||||
"season": subscribe.season,
|
||||
"type": subscribe.type,
|
||||
"username": subscribe.username,
|
||||
"best_version": bool(subscribe.best_version),
|
||||
}
|
||||
)
|
||||
|
||||
return usage_map
|
||||
|
||||
@@ -482,22 +480,22 @@ async def rename_rule_group_references(old_name: str, new_name: str) -> dict:
|
||||
await save_system_config(config_key, updated)
|
||||
changed["global_settings"][config_key.value] = updated
|
||||
|
||||
async with AsyncSessionFactory() as db:
|
||||
subscribes = await Subscribe.async_list(db)
|
||||
for subscribe in subscribes:
|
||||
original = subscribe.filter_groups or []
|
||||
updated = replace_group_name_in_list(original, old_name, new_name)
|
||||
if updated == original:
|
||||
continue
|
||||
await subscribe.async_update(db, {"filter_groups": updated})
|
||||
changed["subscribes"].append(
|
||||
{
|
||||
"subscribe_id": subscribe.id,
|
||||
"name": subscribe.name,
|
||||
"season": subscribe.season,
|
||||
"filter_groups": updated,
|
||||
}
|
||||
)
|
||||
subscribe_oper = SubscribeOper()
|
||||
subscribes = await subscribe_oper.async_list()
|
||||
for subscribe in subscribes:
|
||||
original = subscribe.filter_groups or []
|
||||
updated = replace_group_name_in_list(original, old_name, new_name)
|
||||
if updated == original:
|
||||
continue
|
||||
await subscribe_oper.async_update_filter_groups(subscribe.id, updated)
|
||||
changed["subscribes"].append(
|
||||
{
|
||||
"subscribe_id": subscribe.id,
|
||||
"name": subscribe.name,
|
||||
"season": subscribe.season,
|
||||
"filter_groups": updated,
|
||||
}
|
||||
)
|
||||
|
||||
return changed
|
||||
|
||||
@@ -520,21 +518,21 @@ async def remove_rule_group_references(group_name: str) -> dict:
|
||||
await save_system_config(config_key, updated)
|
||||
changed["global_settings"][config_key.value] = updated
|
||||
|
||||
async with AsyncSessionFactory() as db:
|
||||
subscribes = await Subscribe.async_list(db)
|
||||
for subscribe in subscribes:
|
||||
original = subscribe.filter_groups or []
|
||||
updated = [value for value in original if value != group_name]
|
||||
if updated == original:
|
||||
continue
|
||||
await subscribe.async_update(db, {"filter_groups": updated})
|
||||
changed["subscribes"].append(
|
||||
{
|
||||
"subscribe_id": subscribe.id,
|
||||
"name": subscribe.name,
|
||||
"season": subscribe.season,
|
||||
"filter_groups": updated,
|
||||
}
|
||||
)
|
||||
subscribe_oper = SubscribeOper()
|
||||
subscribes = await subscribe_oper.async_list()
|
||||
for subscribe in subscribes:
|
||||
original = subscribe.filter_groups or []
|
||||
updated = [value for value in original if value != group_name]
|
||||
if updated == original:
|
||||
continue
|
||||
await subscribe_oper.async_update_filter_groups(subscribe.id, updated)
|
||||
changed["subscribes"].append(
|
||||
{
|
||||
"subscribe_id": subscribe.id,
|
||||
"name": subscribe.name,
|
||||
"season": subscribe.season,
|
||||
"filter_groups": updated,
|
||||
}
|
||||
)
|
||||
|
||||
return changed
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""插件 Agent 工具共享辅助方法"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import shutil
|
||||
from typing import Any, Optional
|
||||
@@ -104,6 +103,79 @@ def summarize_plugin(plugin: Any) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _merge_plugin_source_metadata(plugin: Any, source_plugin: Any) -> Any:
|
||||
"""
|
||||
将插件市场或本地仓库中的来源元数据合并到已安装插件对象。
|
||||
"""
|
||||
repo_url = getattr(source_plugin, "repo_url", None)
|
||||
if repo_url:
|
||||
setattr(plugin, "repo_url", repo_url)
|
||||
|
||||
for attr in (
|
||||
"has_update",
|
||||
"release",
|
||||
"system_version",
|
||||
"system_version_compatible",
|
||||
"system_version_message",
|
||||
):
|
||||
value = getattr(source_plugin, attr, None)
|
||||
if value is not None:
|
||||
setattr(plugin, attr, value)
|
||||
|
||||
return plugin
|
||||
|
||||
|
||||
def _map_plugins_by_id(plugins: list[Any]) -> dict[str, Any]:
|
||||
"""
|
||||
按插件 ID 建立稳定映射,保留同 ID 首个候选来源。
|
||||
"""
|
||||
plugin_map: dict[str, Any] = {}
|
||||
for plugin in plugins:
|
||||
plugin_id = getattr(plugin, "id", None)
|
||||
if plugin_id and plugin_id not in plugin_map:
|
||||
plugin_map[plugin_id] = plugin
|
||||
return plugin_map
|
||||
|
||||
|
||||
async def enrich_installed_plugin_sources(
|
||||
installed_plugins: list[Any],
|
||||
force_refresh: bool = False,
|
||||
) -> list[Any]:
|
||||
"""
|
||||
为已安装插件补齐安装来源仓库地址。
|
||||
|
||||
本地插件对象只包含运行目录中的静态元数据,通常没有 repo_url。这里按需从
|
||||
本地插件仓库和插件市场补齐来源,保证 Agent 后续安装、升级判断可以拿到仓库地址。
|
||||
"""
|
||||
missing_source_plugins = [
|
||||
plugin for plugin in installed_plugins if not getattr(plugin, "repo_url", None)
|
||||
]
|
||||
if not missing_source_plugins:
|
||||
return installed_plugins
|
||||
|
||||
plugin_manager = PluginManager()
|
||||
local_repo_map = _map_plugins_by_id(plugin_manager.get_local_repo_plugins())
|
||||
for plugin in missing_source_plugins:
|
||||
source_plugin = local_repo_map.get(getattr(plugin, "id", None))
|
||||
if source_plugin:
|
||||
_merge_plugin_source_metadata(plugin, source_plugin)
|
||||
|
||||
missing_source_plugins = [
|
||||
plugin for plugin in installed_plugins if not getattr(plugin, "repo_url", None)
|
||||
]
|
||||
if not missing_source_plugins:
|
||||
return installed_plugins
|
||||
|
||||
market_plugins = await plugin_manager.async_get_online_plugins(force=force_refresh)
|
||||
market_map = _map_plugins_by_id(market_plugins or [])
|
||||
for plugin in missing_source_plugins:
|
||||
source_plugin = market_map.get(getattr(plugin, "id", None))
|
||||
if source_plugin:
|
||||
_merge_plugin_source_metadata(plugin, source_plugin)
|
||||
|
||||
return installed_plugins
|
||||
|
||||
|
||||
async def load_market_plugins(force_refresh: bool = False) -> list[Any]:
|
||||
"""
|
||||
聚合插件市场与本地插件仓库中的候选插件。
|
||||
@@ -251,7 +323,9 @@ async def install_plugin_runtime(
|
||||
SystemConfigKey.UserInstalledPlugins, install_plugins
|
||||
)
|
||||
|
||||
await asyncio.to_thread(reload_plugin_runtime, plugin_id)
|
||||
from app.agent.tools.base import run_agent_blocking
|
||||
|
||||
await run_agent_blocking("plugin", reload_plugin_runtime, plugin_id)
|
||||
return True, message or "插件安装成功", refreshed_only
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""系统设置工具共用的键解析与分组元数据。"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.schemas.types import SystemConfigKey
|
||||
@@ -15,6 +15,7 @@ class SettingSpec:
|
||||
source: str
|
||||
group: str
|
||||
label: str
|
||||
systemconfig_key: Optional[SystemConfigKey] = None
|
||||
|
||||
|
||||
SYSTEMCONFIG_SETTING_METADATA = {
|
||||
@@ -58,6 +59,10 @@ SYSTEMCONFIG_SETTING_METADATA = {
|
||||
"group": "ai_agent",
|
||||
"label": "AI 智能体配置",
|
||||
},
|
||||
SystemConfigKey.AIAgentMcpServers.value: {
|
||||
"group": "ai_agent",
|
||||
"label": "AI 智能体外部 MCP 服务器",
|
||||
},
|
||||
SystemConfigKey.CustomIdentifiers.value: {
|
||||
"group": "custom_identifiers",
|
||||
"label": "自定义识别词",
|
||||
@@ -234,6 +239,7 @@ def _build_specs() -> tuple[dict[str, SettingSpec], dict[str, SettingSpec]]:
|
||||
source="systemconfig",
|
||||
group=metadata.get("group", "misc"),
|
||||
label=metadata.get("label", item.value),
|
||||
systemconfig_key=item,
|
||||
)
|
||||
return core_specs, system_specs
|
||||
|
||||
@@ -333,3 +339,57 @@ def list_setting_specs(
|
||||
|
||||
def get_default_list_match_field(setting_key: str) -> Optional[str]:
|
||||
return LIST_ITEM_MATCH_FIELD_DEFAULTS.get(setting_key)
|
||||
|
||||
|
||||
SECRET_KEYWORDS = (
|
||||
"api_key",
|
||||
"apikey",
|
||||
"token",
|
||||
"secret",
|
||||
"password",
|
||||
"passwd",
|
||||
"cookie",
|
||||
"authorization",
|
||||
"refresh_token",
|
||||
"access_token",
|
||||
)
|
||||
|
||||
|
||||
def is_secret_setting_key(key: str) -> bool:
|
||||
"""判断设置键名是否疑似敏感字段。"""
|
||||
normalized = _normalize_token(key)
|
||||
return any(keyword in normalized for keyword in SECRET_KEYWORDS)
|
||||
|
||||
|
||||
def redact_secret_value(value: Any, *, redact_scalar: bool = False) -> Any:
|
||||
"""递归脱敏配置值中的密钥、Cookie、Token 等敏感字段。"""
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: "***"
|
||||
if is_secret_setting_key(str(key))
|
||||
else redact_secret_value(item, redact_scalar=redact_scalar)
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [
|
||||
redact_secret_value(item, redact_scalar=redact_scalar)
|
||||
for item in value
|
||||
]
|
||||
if isinstance(value, str):
|
||||
return "***" if value and redact_scalar else value
|
||||
return value
|
||||
|
||||
|
||||
def should_redact_setting(spec: SettingSpec, value: Any) -> bool:
|
||||
"""判断某项设置在默认查询响应中是否需要脱敏。"""
|
||||
if is_secret_setting_key(spec.key):
|
||||
return True
|
||||
if isinstance(value, dict):
|
||||
return any(is_secret_setting_key(str(key)) for key in value.keys())
|
||||
if isinstance(value, list):
|
||||
return any(
|
||||
should_redact_setting(spec, item)
|
||||
for item in value
|
||||
if isinstance(item, dict)
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -13,6 +13,7 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.agent.tools.impl._command_safety import validate_command_safety
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
|
||||
@@ -34,14 +35,6 @@ TERMINAL_PTY_POLL_INTERVAL = 0.05
|
||||
TERMINAL_WAIT_DEFAULT_MS = 1000
|
||||
TERMINAL_WAIT_MAX_MS = 60 * 1000
|
||||
TERMINAL_KILL_GRACE_SECONDS = 3
|
||||
TERMINAL_FORBIDDEN_KEYWORDS = (
|
||||
"rm -rf /",
|
||||
":(){ :|:& };:",
|
||||
"dd if=/dev/zero",
|
||||
"mkfs",
|
||||
"reboot",
|
||||
"shutdown",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -176,13 +169,9 @@ class _TerminalSessionManager:
|
||||
return merged_env
|
||||
|
||||
@staticmethod
|
||||
def _validate_command(command: str) -> None:
|
||||
def _validate_command(command: str, *, confirmed: bool = False) -> None:
|
||||
"""拒绝明显危险或空白命令。"""
|
||||
if not command or not command.strip():
|
||||
raise ValueError("命令不能为空")
|
||||
for keyword in TERMINAL_FORBIDDEN_KEYWORDS:
|
||||
if keyword in command:
|
||||
raise ValueError(f"命令包含禁止使用的关键字 '{keyword}'")
|
||||
validate_command_safety(command, confirmed=confirmed)
|
||||
|
||||
@staticmethod
|
||||
def _set_nonblocking(fd: int) -> None:
|
||||
@@ -213,9 +202,10 @@ class _TerminalSessionManager:
|
||||
cwd: Optional[str] = None,
|
||||
env: Optional[dict[str, Any]] = None,
|
||||
use_pty: Any = True,
|
||||
confirm_dangerous: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""启动后台命令并立即返回会话 ID。"""
|
||||
self._validate_command(command)
|
||||
self._validate_command(command, confirmed=confirm_dangerous)
|
||||
normalized_cwd = self._normalize_cwd(cwd)
|
||||
normalized_env = self._build_env(env)
|
||||
should_use_pty = self._normalize_bool(use_pty, default=True) and os.name == "posix"
|
||||
@@ -313,7 +303,10 @@ class _TerminalSessionManager:
|
||||
continue
|
||||
except OSError as err:
|
||||
if err.errno not in {errno.EIO, errno.EBADF}:
|
||||
logger.debug("PTY 输出读取异常: session_id=%s, error=%s", session.session_id, err)
|
||||
logger.debug(
|
||||
f"PTY 输出读取异常: session_id={session.session_id}, "
|
||||
f"error={err}"
|
||||
)
|
||||
break
|
||||
|
||||
if not data:
|
||||
@@ -343,7 +336,9 @@ class _TerminalSessionManager:
|
||||
session.mark_finished(session.exit_code)
|
||||
except Exception as err:
|
||||
session.mark_error(str(err))
|
||||
logger.warning("等待 PTY 进程失败: session_id=%s, error=%s", session.session_id, err)
|
||||
logger.warning(
|
||||
f"等待 PTY 进程失败: session_id={session.session_id}, error={err}"
|
||||
)
|
||||
finally:
|
||||
await self._finish_reader_tasks(session)
|
||||
session.close_pty()
|
||||
@@ -358,7 +353,9 @@ class _TerminalSessionManager:
|
||||
session.mark_finished(exit_code)
|
||||
except Exception as err:
|
||||
session.mark_error(str(err))
|
||||
logger.warning("等待管道进程失败: session_id=%s, error=%s", session.session_id, err)
|
||||
logger.warning(
|
||||
f"等待管道进程失败: session_id={session.session_id}, error={err}"
|
||||
)
|
||||
finally:
|
||||
await self._finish_reader_tasks(session)
|
||||
|
||||
@@ -533,7 +530,7 @@ class _TerminalSessionManager:
|
||||
if len(encoded) > remaining:
|
||||
if remaining > 0:
|
||||
output_parts.append(
|
||||
encoded[:remaining].decode("utf-8", errors="ignore")
|
||||
encoded[:remaining].decode("utf-8", errors="replace")
|
||||
)
|
||||
output_truncated = True
|
||||
break
|
||||
|
||||
@@ -20,8 +20,6 @@ from app.schemas.types import SystemConfigKey
|
||||
class AddCustomFilterRuleInput(BaseModel):
|
||||
"""新增自定义过滤规则工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
rule_id: str = Field(
|
||||
...,
|
||||
description="Unique custom rule ID. Only letters and numbers are allowed.",
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
"""添加下载工具"""
|
||||
"""添加下载任务工具"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Type
|
||||
from typing import List, Optional, Type, Union
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.chain.download import DownloadChain
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.search import SearchChain
|
||||
from app.chain.download import DownloadChain
|
||||
from app.core.config import settings
|
||||
from app.core.context import Context
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.db.site_oper import SiteOper
|
||||
from app.helper.directory import DirectoryHelper
|
||||
from app.helper.directory import DirectoryHelper, validate_download_save_path
|
||||
from app.log import logger
|
||||
from app.schemas import TorrentInfo, FileURI
|
||||
from app.schemas import FileURI, TorrentInfo
|
||||
from app.utils.crypto import HashUtils
|
||||
|
||||
|
||||
class AddDownloadInput(BaseModel):
|
||||
"""添加下载工具的输入参数模型"""
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
class AddDownloadTasksInput(BaseModel):
|
||||
"""添加下载任务工具的输入参数模型"""
|
||||
|
||||
torrent_url: List[str] = Field(
|
||||
...,
|
||||
description="One or more torrent_url values. Supports refs from get_search_results (`hash:id`) and magnet links."
|
||||
@@ -36,15 +36,17 @@ class AddDownloadInput(BaseModel):
|
||||
description="Comma-separated list of labels/tags to assign to the download (optional, e.g., 'movie,hd,bluray')")
|
||||
|
||||
|
||||
class AddDownloadTool(MoviePilotTool):
|
||||
name: str = "add_download"
|
||||
class AddDownloadTasksTool(MoviePilotTool):
|
||||
"""添加下载任务工具"""
|
||||
|
||||
name: str = "add_download_tasks"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.Download,
|
||||
ToolTag.Resource,
|
||||
]
|
||||
description: str = "Add torrent download tasks using refs from get_search_results or magnet links."
|
||||
args_schema: Type[BaseModel] = AddDownloadInput
|
||||
args_schema: Type[BaseModel] = AddDownloadTasksInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""根据下载参数生成友好的提示消息"""
|
||||
@@ -157,16 +159,16 @@ class AddDownloadTool(MoviePilotTool):
|
||||
prefix = "添加种子任务失败:"
|
||||
if normalized_error.startswith(prefix):
|
||||
normalized_error = normalized_error[len(prefix):].lstrip()
|
||||
if AddDownloadTool._is_magnet_link_input(normalized_error):
|
||||
if AddDownloadTasksTool._is_magnet_link_input(normalized_error):
|
||||
normalized_error = ""
|
||||
if normalized_error:
|
||||
return f"{torrent_ref} {normalized_error}"
|
||||
if AddDownloadTool._is_torrent_ref(torrent_ref):
|
||||
if AddDownloadTasksTool._is_torrent_ref(torrent_ref):
|
||||
return torrent_ref
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def _normalize_torrent_urls(cls, torrent_url: Optional[List[str] | str]) -> List[str]:
|
||||
def _normalize_torrent_urls(cls, torrent_url: Optional[Union[List[str], str]]) -> List[str]:
|
||||
"""统一规范 torrent_url 输入,保留所有非空值"""
|
||||
if torrent_url is None:
|
||||
return []
|
||||
@@ -181,8 +183,8 @@ class AddDownloadTool(MoviePilotTool):
|
||||
@staticmethod
|
||||
def _resolve_direct_download_dir(save_path: Optional[str]) -> Optional[Path]:
|
||||
"""解析直接下载使用的目录,优先使用 save_path,其次使用默认下载目录"""
|
||||
if save_path:
|
||||
return Path(save_path)
|
||||
if save_path is not None:
|
||||
return Path(validate_download_save_path(save_path))
|
||||
|
||||
download_dirs = DirectoryHelper().get_download_dirs()
|
||||
if not download_dirs:
|
||||
@@ -223,6 +225,8 @@ class AddDownloadTool(MoviePilotTool):
|
||||
merged_labels: Optional[str],
|
||||
) -> tuple[Optional[str], Optional[str]]:
|
||||
"""同步提交带上下文的下载任务,避免站点下载与下载器调用阻塞事件循环。"""
|
||||
if save_path is not None:
|
||||
save_path = validate_download_save_path(save_path)
|
||||
return DownloadChain().download_single(
|
||||
context=context,
|
||||
downloader=downloader,
|
||||
@@ -234,6 +238,7 @@ class AddDownloadTool(MoviePilotTool):
|
||||
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:
|
||||
"""执行添加下载任务。"""
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, 参数: torrent_url={torrent_url}, downloader={downloader}, save_path={save_path}, labels={labels}")
|
||||
|
||||
@@ -242,6 +247,12 @@ class AddDownloadTool(MoviePilotTool):
|
||||
if not torrent_inputs:
|
||||
return "错误:torrent_url 不能为空。"
|
||||
|
||||
if save_path is not None:
|
||||
try:
|
||||
save_path = validate_download_save_path(save_path)
|
||||
except ValueError as err:
|
||||
return f"参数错误:save_path {str(err)}"
|
||||
|
||||
merged_labels = self._merge_labels_with_system_tag(labels)
|
||||
success_count = 0
|
||||
failed_messages = []
|
||||
@@ -24,8 +24,6 @@ from app.schemas.types import SystemConfigKey
|
||||
class AddRuleGroupInput(BaseModel):
|
||||
"""新增过滤规则组工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
name: str = Field(..., description="New rule group name.")
|
||||
rule_string: str = Field(
|
||||
...,
|
||||
|
||||
@@ -15,8 +15,6 @@ from app.schemas.types import MediaType, MessageChannel
|
||||
class AddSubscribeInput(BaseModel):
|
||||
"""添加订阅工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
title: str = Field(
|
||||
...,
|
||||
description="The title of the media to subscribe to (e.g., 'The Matrix', 'Breaking Bad')",
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import List, Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool, ToolChain
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.helper.interaction import (
|
||||
AgentInteractionOption,
|
||||
@@ -27,6 +27,7 @@ class UserChoiceOptionInput(BaseModel):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_option(self):
|
||||
"""校验按钮选项的文案和值不能为空。"""
|
||||
label = str(self.label)
|
||||
value = str(self.value)
|
||||
if not label.strip():
|
||||
@@ -39,8 +40,6 @@ class UserChoiceOptionInput(BaseModel):
|
||||
class AskUserChoiceInput(BaseModel):
|
||||
"""按钮选择工具输入。"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why the agent needs the user to choose from buttons",)
|
||||
message: str = Field(
|
||||
...,
|
||||
description="Question or prompt shown to the user together with the buttons",
|
||||
@@ -56,6 +55,7 @@ class AskUserChoiceInput(BaseModel):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_payload(self):
|
||||
"""校验按钮选择工具必须提供问题和选项。"""
|
||||
message = str(self.message)
|
||||
if not message.strip():
|
||||
raise ValueError("message 不能为空")
|
||||
@@ -83,9 +83,9 @@ class AskUserChoiceTool(MoviePilotTool):
|
||||
"back as the user's next message. Do not also send the same question as plain text."
|
||||
)
|
||||
args_schema: Type[BaseModel] = AskUserChoiceInput
|
||||
require_admin: bool = False
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""生成工具执行提示文案。"""
|
||||
message = kwargs.get("message", "") or ""
|
||||
if len(message) > 40:
|
||||
message = message[:40] + "..."
|
||||
@@ -93,6 +93,7 @@ class AskUserChoiceTool(MoviePilotTool):
|
||||
|
||||
@staticmethod
|
||||
def _truncate_button_text(text: str, max_length: int) -> str:
|
||||
"""按渠道限制截断按钮文案。"""
|
||||
if max_length <= 0 or len(text) <= max_length:
|
||||
return text
|
||||
if max_length <= 3:
|
||||
@@ -115,6 +116,14 @@ class AskUserChoiceTool(MoviePilotTool):
|
||||
title: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""
|
||||
发送按钮选择消息,并登记待回调的交互上下文。
|
||||
|
||||
:param message: 展示给用户的问题
|
||||
:param options: 可点击的选项列表
|
||||
:param title: 可选标题
|
||||
:return: 工具执行结果描述
|
||||
"""
|
||||
if self._blocked_by_feedback_quality_gate():
|
||||
logger.warning(
|
||||
"ask_user_choice blocked after feedback issue rejected_quality: "
|
||||
@@ -149,7 +158,8 @@ class AskUserChoiceTool(MoviePilotTool):
|
||||
|
||||
choice_options = [
|
||||
AgentInteractionOption(
|
||||
label=option.label.strip(), value=option.value.strip()
|
||||
label=option.label.strip(),
|
||||
value=option.value.strip(),
|
||||
)
|
||||
for option in options
|
||||
]
|
||||
@@ -189,7 +199,7 @@ class AskUserChoiceTool(MoviePilotTool):
|
||||
len(choice_options),
|
||||
)
|
||||
|
||||
await ToolChain().async_post_message(
|
||||
await self.send_notification_message(
|
||||
Notification(
|
||||
channel=channel,
|
||||
source=self._source,
|
||||
@@ -199,6 +209,7 @@ class AskUserChoiceTool(MoviePilotTool):
|
||||
title=title,
|
||||
text=message.strip(),
|
||||
buttons=buttons,
|
||||
save_history=False,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
"""浏览器操作工具 - 让Agent能够通过Playwright控制浏览器进行网页交互"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
from enum import Enum
|
||||
from typing import Optional, Type
|
||||
from typing import Any, Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.core.config import settings
|
||||
from app.helper.browser import BrowserSessionHelper
|
||||
from app.log import logger
|
||||
|
||||
# 页面内容最大长度
|
||||
@@ -27,32 +26,48 @@ class BrowserAction(str, Enum):
|
||||
"""浏览器操作类型"""
|
||||
|
||||
GOTO = "goto"
|
||||
SNAPSHOT = "snapshot"
|
||||
GET_CONTENT = "get_content"
|
||||
SCREENSHOT = "screenshot"
|
||||
CLICK = "click"
|
||||
CLICK_REF = "click_ref"
|
||||
FILL = "fill"
|
||||
FILL_REF = "fill_ref"
|
||||
SELECT = "select"
|
||||
SELECT_REF = "select_ref"
|
||||
EVALUATE = "evaluate"
|
||||
WAIT = "wait"
|
||||
LIST_TABS = "list_tabs"
|
||||
OPEN_TAB = "open_tab"
|
||||
FOCUS_TAB = "focus_tab"
|
||||
CLOSE_TAB = "close_tab"
|
||||
CLOSE_SESSION = "close_session"
|
||||
|
||||
|
||||
class BrowseWebpageInput(BaseModel):
|
||||
"""浏览器操作工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this browser action is being performed",)
|
||||
action: str = Field(
|
||||
...,
|
||||
description=(
|
||||
"The browser action to perform. Available actions:\n"
|
||||
"- 'goto': Navigate to a URL, returns page title and text summary\n"
|
||||
"- 'snapshot': Get current page snapshot with interactive element refs\n"
|
||||
"- 'get_content': Get current page content (text or HTML)\n"
|
||||
"- 'screenshot': Take a screenshot of the current page, returns base64 image\n"
|
||||
"- 'click': Click on an element specified by selector\n"
|
||||
"- 'click_ref': Click an element by ref from the latest snapshot\n"
|
||||
"- 'fill': Fill text into an input element specified by selector\n"
|
||||
"- 'fill_ref': Fill text into an input element by ref from the latest snapshot\n"
|
||||
"- 'select': Select an option from a dropdown element\n"
|
||||
"- 'select_ref': Select an option by ref from the latest snapshot\n"
|
||||
"- 'evaluate': Execute JavaScript code on the page and return the result\n"
|
||||
"- 'wait': Wait for an element to appear on the page"
|
||||
"- 'wait': Wait for an element to appear on the page\n"
|
||||
"- 'list_tabs': List browser tabs in the current session\n"
|
||||
"- 'open_tab': Open a new tab, optionally navigating to a URL\n"
|
||||
"- 'focus_tab': Switch active tab by index\n"
|
||||
"- 'close_tab': Close a tab by index\n"
|
||||
"- 'close_session': Close the current browser session"
|
||||
),
|
||||
)
|
||||
url: Optional[str] = Field(
|
||||
@@ -63,6 +78,10 @@ class BrowseWebpageInput(BaseModel):
|
||||
description="CSS selector or text selector for the target element (for 'click', 'fill', 'select', 'wait' actions). "
|
||||
"Supports CSS selectors like '#id', '.class', 'tag', and Playwright text selectors like 'text=Click me'",
|
||||
)
|
||||
ref: Optional[str] = Field(
|
||||
None,
|
||||
description="Element ref returned by 'snapshot' or action results (for 'click_ref', 'fill_ref', 'select_ref')",
|
||||
)
|
||||
value: Optional[str] = Field(
|
||||
None,
|
||||
description="Value to fill into input or option value to select (for 'fill' and 'select' actions)",
|
||||
@@ -86,6 +105,18 @@ class BrowseWebpageInput(BaseModel):
|
||||
user_agent: Optional[str] = Field(
|
||||
None, description="Custom User-Agent string for the browser context"
|
||||
)
|
||||
session_key: Optional[str] = Field(
|
||||
None,
|
||||
description="Browser session key. Defaults to the current agent session id.",
|
||||
)
|
||||
tab_index: Optional[int] = Field(
|
||||
None,
|
||||
description="Tab index for 'focus_tab' and 'close_tab' actions.",
|
||||
)
|
||||
allow_private_network: bool = Field(
|
||||
False,
|
||||
description="Allow browser navigation to localhost, loopback, private, or link-local addresses.",
|
||||
)
|
||||
|
||||
|
||||
class BrowseWebpageTool(MoviePilotTool):
|
||||
@@ -97,11 +128,13 @@ class BrowseWebpageTool(MoviePilotTool):
|
||||
description: str = (
|
||||
"Control a real browser (Playwright) to interact with web pages. "
|
||||
"Supports navigating to URLs, reading page content, taking screenshots, "
|
||||
"clicking elements, filling forms, selecting dropdown options, executing JavaScript, and waiting for elements. "
|
||||
"clicking elements, filling forms, selecting dropdown options, executing JavaScript, waiting for elements, "
|
||||
"and managing tabs. "
|
||||
"Use this tool when you need to interact with dynamic web pages, "
|
||||
"fill in forms, click buttons, or extract content from JavaScript-rendered pages. "
|
||||
"The browser session persists across multiple calls within the same conversation - "
|
||||
"first call 'goto' to open a page, then use other actions to interact with it."
|
||||
"first call 'goto' to open a page, inspect 'interactive_elements', then use *_ref actions when possible. "
|
||||
"For safety, localhost and private network URLs are blocked by default unless allow_private_network is true."
|
||||
)
|
||||
args_schema: Type[BaseModel] = BrowseWebpageInput
|
||||
|
||||
@@ -112,13 +145,22 @@ class BrowseWebpageTool(MoviePilotTool):
|
||||
selector = kwargs.get("selector", "")
|
||||
action_messages = {
|
||||
"goto": f"打开网页: {url}",
|
||||
"snapshot": "读取页面快照",
|
||||
"get_content": "获取页面内容",
|
||||
"screenshot": "截取页面截图",
|
||||
"click": f"点击元素: {selector}",
|
||||
"click_ref": f"点击元素引用: {kwargs.get('ref', '')}",
|
||||
"fill": f"填写表单: {selector}",
|
||||
"fill_ref": f"填写元素引用: {kwargs.get('ref', '')}",
|
||||
"select": f"选择选项: {selector}",
|
||||
"select_ref": f"选择元素引用: {kwargs.get('ref', '')}",
|
||||
"evaluate": "执行 JavaScript",
|
||||
"wait": f"等待元素: {selector}",
|
||||
"list_tabs": "列出浏览器标签页",
|
||||
"open_tab": f"打开新标签页: {url}",
|
||||
"focus_tab": f"切换浏览器标签页: {kwargs.get('tab_index', '')}",
|
||||
"close_tab": f"关闭浏览器标签页: {kwargs.get('tab_index', '')}",
|
||||
"close_session": "关闭浏览器会话",
|
||||
}
|
||||
return action_messages.get(action, f"执行浏览器操作: {action}")
|
||||
|
||||
@@ -127,12 +169,16 @@ class BrowseWebpageTool(MoviePilotTool):
|
||||
action: str,
|
||||
url: Optional[str] = None,
|
||||
selector: Optional[str] = None,
|
||||
ref: Optional[str] = None,
|
||||
value: Optional[str] = None,
|
||||
script: Optional[str] = None,
|
||||
content_type: Optional[str] = "text",
|
||||
timeout: Optional[int] = DEFAULT_TIMEOUT,
|
||||
cookies: Optional[str] = None,
|
||||
user_agent: Optional[str] = None,
|
||||
session_key: Optional[str] = None,
|
||||
tab_index: Optional[int] = None,
|
||||
allow_private_network: bool = False,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""执行浏览器操作"""
|
||||
@@ -151,6 +197,8 @@ class BrowseWebpageTool(MoviePilotTool):
|
||||
# 参数校验
|
||||
if browser_action == BrowserAction.GOTO and not url:
|
||||
return "错误: 'goto' 操作需要提供 url 参数"
|
||||
if browser_action == BrowserAction.OPEN_TAB and not url:
|
||||
return "错误: 'open_tab' 操作需要提供 url 参数"
|
||||
if (
|
||||
browser_action
|
||||
in (
|
||||
@@ -162,26 +210,51 @@ class BrowseWebpageTool(MoviePilotTool):
|
||||
and not selector
|
||||
):
|
||||
return f"错误: '{action}' 操作需要提供 selector 参数"
|
||||
if (
|
||||
browser_action
|
||||
in (
|
||||
BrowserAction.CLICK_REF,
|
||||
BrowserAction.FILL_REF,
|
||||
BrowserAction.SELECT_REF,
|
||||
)
|
||||
and not ref
|
||||
):
|
||||
return f"错误: '{action}' 操作需要提供 ref 参数"
|
||||
if browser_action == BrowserAction.FILL and value is None:
|
||||
return "错误: 'fill' 操作需要提供 value 参数"
|
||||
if browser_action == BrowserAction.FILL_REF and value is None:
|
||||
return "错误: 'fill_ref' 操作需要提供 value 参数"
|
||||
if browser_action == BrowserAction.EVALUATE and not script:
|
||||
return "错误: 'evaluate' 操作需要提供 script 参数"
|
||||
if (
|
||||
browser_action == BrowserAction.EVALUATE
|
||||
and not await self.is_admin_user()
|
||||
):
|
||||
return "错误: 'evaluate' 操作仅允许管理员使用"
|
||||
if (
|
||||
browser_action in (BrowserAction.FOCUS_TAB, BrowserAction.CLOSE_TAB)
|
||||
and tab_index is None
|
||||
):
|
||||
return f"错误: '{action}' 操作需要提供 tab_index 参数"
|
||||
|
||||
# 在线程池中运行同步的 Playwright 操作
|
||||
loop = asyncio.get_running_loop()
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self._execute_browser_action(
|
||||
browser_action=browser_action,
|
||||
url=url,
|
||||
selector=selector,
|
||||
value=value,
|
||||
script=script,
|
||||
content_type=content_type,
|
||||
timeout=timeout,
|
||||
cookies=cookies,
|
||||
user_agent=user_agent,
|
||||
),
|
||||
effective_session_key = session_key or self._session_id
|
||||
|
||||
result = await self.run_blocking(
|
||||
"web",
|
||||
self._execute_browser_action,
|
||||
browser_action=browser_action,
|
||||
url=url,
|
||||
selector=selector,
|
||||
ref=ref,
|
||||
value=value,
|
||||
script=script,
|
||||
content_type=content_type,
|
||||
timeout=timeout,
|
||||
cookies=cookies,
|
||||
user_agent=user_agent,
|
||||
session_key=effective_session_key,
|
||||
tab_index=tab_index,
|
||||
allow_private_network=allow_private_network,
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -194,65 +267,61 @@ class BrowseWebpageTool(MoviePilotTool):
|
||||
browser_action: BrowserAction,
|
||||
url: Optional[str],
|
||||
selector: Optional[str],
|
||||
ref: Optional[str],
|
||||
value: Optional[str],
|
||||
script: Optional[str],
|
||||
content_type: Optional[str],
|
||||
timeout: int,
|
||||
cookies: Optional[str],
|
||||
user_agent: Optional[str],
|
||||
session_key: str,
|
||||
tab_index: Optional[int],
|
||||
allow_private_network: bool,
|
||||
) -> str:
|
||||
"""在同步上下文中执行 CloakBrowser 浏览器操作"""
|
||||
from cloakbrowser import launch_context
|
||||
|
||||
try:
|
||||
context = None
|
||||
page = None
|
||||
try:
|
||||
context_kwargs = {
|
||||
"viewport": {
|
||||
"width": SCREENSHOT_MAX_WIDTH,
|
||||
"height": SCREENSHOT_MAX_HEIGHT,
|
||||
if browser_action == BrowserAction.CLOSE_SESSION:
|
||||
closed = BrowserSessionHelper.close_session(session_key)
|
||||
message = "浏览器会话已关闭" if closed else "浏览器会话不存在"
|
||||
return self._json_response(
|
||||
{
|
||||
"success": closed,
|
||||
"message": message,
|
||||
}
|
||||
}
|
||||
if user_agent:
|
||||
context_kwargs["user_agent"] = user_agent
|
||||
|
||||
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})
|
||||
helper = BrowserSessionHelper(
|
||||
headless=True,
|
||||
viewport={
|
||||
"width": SCREENSHOT_MAX_WIDTH,
|
||||
"height": SCREENSHOT_MAX_HEIGHT,
|
||||
},
|
||||
)
|
||||
|
||||
# 对于非 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,
|
||||
def _callback(session) -> str:
|
||||
return self._do_action(
|
||||
helper=helper,
|
||||
session=session,
|
||||
browser_action=browser_action,
|
||||
url=url,
|
||||
selector=selector,
|
||||
ref=ref,
|
||||
value=value,
|
||||
script=script,
|
||||
content_type=content_type,
|
||||
timeout=timeout,
|
||||
tab_index=tab_index,
|
||||
allow_private_network=allow_private_network,
|
||||
)
|
||||
return result
|
||||
|
||||
finally:
|
||||
if page:
|
||||
page.close()
|
||||
if context:
|
||||
context.close()
|
||||
return helper.with_session(
|
||||
session_key=session_key,
|
||||
callback=_callback,
|
||||
user_agent=user_agent,
|
||||
cookies=cookies,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"CloakBrowser 执行失败: {e}", exc_info=True)
|
||||
@@ -260,19 +329,38 @@ class BrowseWebpageTool(MoviePilotTool):
|
||||
|
||||
def _do_action(
|
||||
self,
|
||||
page,
|
||||
helper: BrowserSessionHelper,
|
||||
session,
|
||||
browser_action: BrowserAction,
|
||||
url: Optional[str],
|
||||
selector: Optional[str],
|
||||
ref: Optional[str],
|
||||
value: Optional[str],
|
||||
script: Optional[str],
|
||||
content_type: Optional[str],
|
||||
timeout: int,
|
||||
tab_index: Optional[int],
|
||||
allow_private_network: bool,
|
||||
) -> str:
|
||||
"""执行具体的浏览器操作"""
|
||||
page = session.active_page
|
||||
|
||||
if browser_action == BrowserAction.GOTO:
|
||||
return self._action_goto(page, url, timeout)
|
||||
return self._action_goto(
|
||||
helper,
|
||||
page,
|
||||
url,
|
||||
timeout,
|
||||
allow_private_network=allow_private_network,
|
||||
)
|
||||
|
||||
elif browser_action == BrowserAction.SNAPSHOT:
|
||||
return self._json_response(
|
||||
BrowserSessionHelper.build_snapshot(
|
||||
page,
|
||||
max_text_chars=MAX_CONTENT_LENGTH,
|
||||
)
|
||||
)
|
||||
|
||||
elif browser_action == BrowserAction.GET_CONTENT:
|
||||
return self._action_get_content(page, content_type)
|
||||
@@ -283,89 +371,113 @@ class BrowseWebpageTool(MoviePilotTool):
|
||||
elif browser_action == BrowserAction.CLICK:
|
||||
return self._action_click(page, selector, timeout)
|
||||
|
||||
elif browser_action == BrowserAction.CLICK_REF:
|
||||
return self._action_click(
|
||||
page,
|
||||
BrowserSessionHelper.ref_to_selector(ref),
|
||||
timeout,
|
||||
ref=ref,
|
||||
)
|
||||
|
||||
elif browser_action == BrowserAction.FILL:
|
||||
return self._action_fill(page, selector, value, timeout)
|
||||
|
||||
elif browser_action == BrowserAction.FILL_REF:
|
||||
return self._action_fill(
|
||||
page,
|
||||
BrowserSessionHelper.ref_to_selector(ref),
|
||||
value,
|
||||
timeout,
|
||||
ref=ref,
|
||||
)
|
||||
|
||||
elif browser_action == BrowserAction.SELECT:
|
||||
return self._action_select(page, selector, value, timeout)
|
||||
|
||||
elif browser_action == BrowserAction.SELECT_REF:
|
||||
return self._action_select(
|
||||
page,
|
||||
BrowserSessionHelper.ref_to_selector(ref),
|
||||
value,
|
||||
timeout,
|
||||
ref=ref,
|
||||
)
|
||||
|
||||
elif browser_action == BrowserAction.EVALUATE:
|
||||
return self._action_evaluate(page, script)
|
||||
|
||||
elif browser_action == BrowserAction.WAIT:
|
||||
return self._action_wait(page, selector, timeout)
|
||||
|
||||
elif browser_action == BrowserAction.LIST_TABS:
|
||||
return self._json_response({"tabs": BrowserSessionHelper.list_tabs(session)})
|
||||
|
||||
elif browser_action == BrowserAction.OPEN_TAB:
|
||||
page = helper.open_tab(
|
||||
session,
|
||||
url=url,
|
||||
timeout=timeout,
|
||||
allow_private_network=allow_private_network,
|
||||
)
|
||||
return self._json_response(
|
||||
{
|
||||
"success": True,
|
||||
"active_tab": session.active_index,
|
||||
"tabs": BrowserSessionHelper.list_tabs(session),
|
||||
"snapshot": BrowserSessionHelper.build_snapshot(
|
||||
page,
|
||||
max_text_chars=MAX_CONTENT_LENGTH,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
elif browser_action == BrowserAction.FOCUS_TAB:
|
||||
page = BrowserSessionHelper.focus_tab(session, tab_index)
|
||||
return self._json_response(
|
||||
{
|
||||
"success": True,
|
||||
"active_tab": session.active_index,
|
||||
"tabs": BrowserSessionHelper.list_tabs(session),
|
||||
"snapshot": BrowserSessionHelper.build_snapshot(
|
||||
page,
|
||||
max_text_chars=MAX_CONTENT_LENGTH,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
elif browser_action == BrowserAction.CLOSE_TAB:
|
||||
tabs = BrowserSessionHelper.close_tab(session, tab_index)
|
||||
return self._json_response({"success": True, "tabs": tabs})
|
||||
|
||||
return f"未知操作: {browser_action}"
|
||||
|
||||
@staticmethod
|
||||
def _action_goto(page, url: str, timeout: int) -> str:
|
||||
def _json_response(payload: dict[str, Any]) -> str:
|
||||
"""返回格式化 JSON 字符串"""
|
||||
return json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
|
||||
@staticmethod
|
||||
def _action_goto(
|
||||
helper: BrowserSessionHelper,
|
||||
page,
|
||||
url: str,
|
||||
timeout: int,
|
||||
allow_private_network: bool,
|
||||
) -> str:
|
||||
"""导航到URL"""
|
||||
response = page.goto(url, wait_until="domcontentloaded", timeout=timeout * 1000)
|
||||
try:
|
||||
page.wait_for_load_state("networkidle", timeout=min(timeout, 15) * 1000)
|
||||
except Exception:
|
||||
# networkidle 超时不是致命错误,页面可能已经可用
|
||||
pass
|
||||
|
||||
response = helper.goto(
|
||||
page,
|
||||
url,
|
||||
timeout=timeout,
|
||||
allow_private_network=allow_private_network,
|
||||
)
|
||||
status = response.status if response else "unknown"
|
||||
title = page.title()
|
||||
page_url = page.url
|
||||
|
||||
# 提取页面可读文本摘要
|
||||
text_content = page.inner_text("body")
|
||||
if text_content and len(text_content) > MAX_CONTENT_LENGTH:
|
||||
text_content = text_content[:MAX_CONTENT_LENGTH] + "\n\n...(内容已截断)"
|
||||
|
||||
# 提取页面链接
|
||||
links = page.evaluate("""
|
||||
() => {
|
||||
const links = [];
|
||||
document.querySelectorAll('a[href]').forEach(a => {
|
||||
const text = a.innerText.trim();
|
||||
const href = a.href;
|
||||
if (text && href && !href.startsWith('javascript:')) {
|
||||
links.push({text: text.substring(0, 80), href: href});
|
||||
}
|
||||
});
|
||||
return links.slice(0, 30);
|
||||
}
|
||||
""")
|
||||
|
||||
# 提取表单信息
|
||||
forms = page.evaluate("""
|
||||
() => {
|
||||
const forms = [];
|
||||
document.querySelectorAll('input, textarea, select, button').forEach(el => {
|
||||
const info = {
|
||||
tag: el.tagName.toLowerCase(),
|
||||
type: el.type || '',
|
||||
name: el.name || '',
|
||||
id: el.id || '',
|
||||
placeholder: el.placeholder || '',
|
||||
value: el.tagName.toLowerCase() === 'select' ? '' : (el.value || '').substring(0, 50),
|
||||
text: el.innerText ? el.innerText.trim().substring(0, 50) : ''
|
||||
};
|
||||
// 只保留有标识信息的元素
|
||||
if (info.name || info.id || info.placeholder || info.text) {
|
||||
forms.push(info);
|
||||
}
|
||||
});
|
||||
return forms.slice(0, 30);
|
||||
}
|
||||
""")
|
||||
|
||||
result = {
|
||||
"status": status,
|
||||
"url": page_url,
|
||||
"title": title,
|
||||
"text_content": text_content,
|
||||
}
|
||||
if links:
|
||||
result["links"] = links
|
||||
if forms:
|
||||
result["form_elements"] = forms
|
||||
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
result = BrowserSessionHelper.build_snapshot(
|
||||
page,
|
||||
status=status,
|
||||
max_text_chars=MAX_CONTENT_LENGTH,
|
||||
)
|
||||
return BrowseWebpageTool._json_response(result)
|
||||
|
||||
@staticmethod
|
||||
def _action_get_content(page, content_type: Optional[str]) -> str:
|
||||
@@ -387,7 +499,7 @@ class BrowseWebpageTool(MoviePilotTool):
|
||||
"content_type": content_type,
|
||||
"content": content,
|
||||
}
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
return BrowseWebpageTool._json_response(result)
|
||||
|
||||
@staticmethod
|
||||
def _action_screenshot(page) -> str:
|
||||
@@ -420,10 +532,15 @@ class BrowseWebpageTool(MoviePilotTool):
|
||||
"format": "jpeg",
|
||||
"note": "截图已以 base64 编码返回",
|
||||
}
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
return BrowseWebpageTool._json_response(result)
|
||||
|
||||
@staticmethod
|
||||
def _action_click(page, selector: str, timeout: int) -> str:
|
||||
def _action_click(
|
||||
page,
|
||||
selector: str,
|
||||
timeout: int,
|
||||
ref: Optional[str] = None,
|
||||
) -> str:
|
||||
"""点击元素"""
|
||||
page.click(selector, timeout=timeout * 1000)
|
||||
|
||||
@@ -433,49 +550,62 @@ class BrowseWebpageTool(MoviePilotTool):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
title = page.title()
|
||||
page_url = page.url
|
||||
|
||||
return json.dumps(
|
||||
return BrowseWebpageTool._json_response(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"成功点击元素: {selector}",
|
||||
"current_url": page_url,
|
||||
"current_title": title,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
"message": f"成功点击元素: {ref or selector}",
|
||||
"snapshot": BrowserSessionHelper.build_snapshot(
|
||||
page,
|
||||
max_text_chars=MAX_CONTENT_LENGTH,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _action_fill(page, selector: str, value: str, timeout: int) -> str:
|
||||
def _action_fill(
|
||||
page,
|
||||
selector: str,
|
||||
value: str,
|
||||
timeout: int,
|
||||
ref: Optional[str] = None,
|
||||
) -> str:
|
||||
"""填写表单"""
|
||||
page.fill(selector, value, timeout=timeout * 1000)
|
||||
|
||||
return json.dumps(
|
||||
return BrowseWebpageTool._json_response(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"成功填写元素 '{selector}' 的值为 '{value}'",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
"message": f"成功填写元素 '{ref or selector}'",
|
||||
"snapshot": BrowserSessionHelper.build_snapshot(
|
||||
page,
|
||||
max_text_chars=MAX_CONTENT_LENGTH,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _action_select(page, selector: str, value: Optional[str], timeout: int) -> str:
|
||||
def _action_select(
|
||||
page,
|
||||
selector: str,
|
||||
value: Optional[str],
|
||||
timeout: int,
|
||||
ref: Optional[str] = None,
|
||||
) -> str:
|
||||
"""选择下拉选项"""
|
||||
if value:
|
||||
page.select_option(selector, value=value, timeout=timeout * 1000)
|
||||
else:
|
||||
return "错误: 'select' 操作需要提供 value 参数"
|
||||
|
||||
return json.dumps(
|
||||
return BrowseWebpageTool._json_response(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"成功选择元素 '{selector}' 的选项 '{value}'",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
"message": f"成功选择元素 '{ref or selector}' 的选项 '{value}'",
|
||||
"snapshot": BrowserSessionHelper.build_snapshot(
|
||||
page,
|
||||
max_text_chars=MAX_CONTENT_LENGTH,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -495,13 +625,11 @@ class BrowseWebpageTool(MoviePilotTool):
|
||||
if len(formatted) > MAX_CONTENT_LENGTH:
|
||||
formatted = formatted[:MAX_CONTENT_LENGTH] + "\n\n...(结果已截断)"
|
||||
|
||||
return json.dumps(
|
||||
return BrowseWebpageTool._json_response(
|
||||
{
|
||||
"success": True,
|
||||
"result": formatted,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -515,22 +643,22 @@ class BrowseWebpageTool(MoviePilotTool):
|
||||
if text and len(text) > 200:
|
||||
text = text[:200] + "..."
|
||||
|
||||
return json.dumps(
|
||||
return BrowseWebpageTool._json_response(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"元素 '{selector}' 已出现",
|
||||
"visible": visible,
|
||||
"text": text,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
"snapshot": BrowserSessionHelper.build_snapshot(
|
||||
page,
|
||||
max_text_chars=MAX_CONTENT_LENGTH,
|
||||
),
|
||||
}
|
||||
)
|
||||
else:
|
||||
return json.dumps(
|
||||
return BrowseWebpageTool._json_response(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"等待元素 '{selector}' 超时",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -20,8 +20,6 @@ from app.schemas.types import SystemConfigKey
|
||||
class DeleteCustomFilterRuleInput(BaseModel):
|
||||
"""删除自定义过滤规则工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
rule_id: str = Field(..., description="Custom rule ID to delete.")
|
||||
|
||||
|
||||
|
||||
@@ -6,16 +6,13 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.db import AsyncSessionFactory
|
||||
from app.db.models.downloadhistory import DownloadHistory
|
||||
from app.db.downloadhistory_oper import DownloadHistoryOper
|
||||
from app.log import logger
|
||||
|
||||
|
||||
class DeleteDownloadHistoryInput(BaseModel):
|
||||
"""删除下载历史记录工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
history_id: int = Field(
|
||||
..., description="The ID of the download history record to delete"
|
||||
)
|
||||
@@ -40,9 +37,8 @@ class DeleteDownloadHistoryTool(MoviePilotTool):
|
||||
logger.info(f"执行工具: {self.name}, 参数: history_id={history_id}")
|
||||
|
||||
try:
|
||||
async with AsyncSessionFactory() as db:
|
||||
await DownloadHistory.async_delete(db, history_id)
|
||||
return f"下载历史记录 ID: {history_id} 已成功删除"
|
||||
await DownloadHistoryOper().async_delete_history(history_id)
|
||||
return f"下载历史记录 ID: {history_id} 已成功删除"
|
||||
except Exception as e:
|
||||
logger.error(f"删除下载历史记录失败: {e}", exc_info=True)
|
||||
return f"删除下载历史记录时发生错误: {str(e)}"
|
||||
|
||||
@@ -10,11 +10,9 @@ from app.chain.download import DownloadChain
|
||||
from app.log import logger
|
||||
|
||||
|
||||
class DeleteDownloadInput(BaseModel):
|
||||
class DeleteDownloadTasksInput(BaseModel):
|
||||
"""删除下载任务工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
hash: str = Field(
|
||||
..., description="Task hash (can be obtained from query_download_tasks tool)"
|
||||
)
|
||||
@@ -28,15 +26,17 @@ class DeleteDownloadInput(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class DeleteDownloadTool(MoviePilotTool):
|
||||
name: str = "delete_download"
|
||||
class DeleteDownloadTasksTool(MoviePilotTool):
|
||||
"""删除下载任务工具"""
|
||||
|
||||
name: str = "delete_download_tasks"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.Download,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = "Delete a download task from the downloader by task hash only. Optionally specify the downloader name and whether to delete downloaded files."
|
||||
args_schema: Type[BaseModel] = DeleteDownloadInput
|
||||
args_schema: Type[BaseModel] = DeleteDownloadTasksInput
|
||||
require_admin: bool = True
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
@@ -69,6 +69,7 @@ class DeleteDownloadTool(MoviePilotTool):
|
||||
delete_files: Optional[bool] = False,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""执行删除下载任务。"""
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, 参数: hash={hash}, downloader={downloader}, delete_files={delete_files}"
|
||||
)
|
||||
@@ -19,8 +19,6 @@ from app.schemas.types import SystemConfigKey
|
||||
class DeleteRuleGroupInput(BaseModel):
|
||||
"""删除过滤规则组工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
name: str = Field(..., description="Rule group name to delete.")
|
||||
|
||||
|
||||
|
||||
@@ -16,8 +16,6 @@ from app.schemas.types import EventType
|
||||
class DeleteSubscribeInput(BaseModel):
|
||||
"""删除订阅工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
subscribe_id: int = Field(
|
||||
...,
|
||||
description="The ID of the subscription to delete (can be obtained from query_subscribes tool)",
|
||||
|
||||
@@ -6,15 +6,15 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.chain.storage import StorageChain
|
||||
from app.db.transferhistory_oper import TransferHistoryOper
|
||||
from app.log import logger
|
||||
from app.schemas import FileItem
|
||||
|
||||
|
||||
class DeleteTransferHistoryInput(BaseModel):
|
||||
"""删除整理历史记录工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
history_id: int = Field(
|
||||
..., description="The ID of the transfer history record to delete"
|
||||
)
|
||||
@@ -27,7 +27,11 @@ class DeleteTransferHistoryTool(MoviePilotTool):
|
||||
ToolTag.Transfer,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = "Delete a specific transfer history record by its ID. This is useful when you need to remove a failed transfer record before retrying the transfer, as the system skips files that already have transfer history."
|
||||
description: str = (
|
||||
"Delete a specific transfer history record by its ID. For non-successful-move records with an old "
|
||||
"destination file, the tool removes that media-library file before deleting the history record. This is "
|
||||
"useful before retrying or re-organizing because the system skips files that already have transfer history."
|
||||
)
|
||||
args_schema: Type[BaseModel] = DeleteTransferHistoryInput
|
||||
require_admin: bool = True
|
||||
|
||||
@@ -48,10 +52,21 @@ class DeleteTransferHistoryTool(MoviePilotTool):
|
||||
title = history.title or "未知"
|
||||
src = history.src or "未知"
|
||||
status = "成功" if history.status else "失败"
|
||||
deleted_dest = False
|
||||
if history.dest_fileitem and not (history.status and history.mode == "move"):
|
||||
dest_fileitem = FileItem(**history.dest_fileitem)
|
||||
storage_chain = StorageChain()
|
||||
if storage_chain.exists(dest_fileitem):
|
||||
if not storage_chain.delete_media_file(dest_fileitem):
|
||||
return f"错误:旧媒体库文件删除失败,路径={dest_fileitem.path}"
|
||||
deleted_dest = True
|
||||
await transferhis.async_delete(history_id)
|
||||
return (
|
||||
message = (
|
||||
f"已删除整理历史记录:ID={history_id},标题={title},源路径={src},状态={status}"
|
||||
)
|
||||
if deleted_dest:
|
||||
message += ",已删除旧媒体库文件"
|
||||
return message
|
||||
except Exception as e:
|
||||
logger.error(f"删除整理历史记录失败: {e}", exc_info=True)
|
||||
return f"删除整理历史记录时发生错误: {str(e)}"
|
||||
|
||||
@@ -12,7 +12,7 @@ from app.log import logger
|
||||
|
||||
|
||||
class EditFileInput(BaseModel):
|
||||
"""Input parameters for edit file tool"""
|
||||
"""文件编辑工具的输入参数模型。"""
|
||||
|
||||
file_path: str = Field(..., description="The absolute path of the file to edit")
|
||||
old_text: str = Field(..., description="The exact old text to be replaced")
|
||||
@@ -24,11 +24,13 @@ class EditFileTool(MoviePilotTool):
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.File,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = "Edit a file by replacing specific old text with new text. Useful for modifying configuration files, code, or scripts."
|
||||
description: str = (
|
||||
"Edit a local text file by replacing specific old text with new text. "
|
||||
"Non-admin users can only edit files inside the MoviePilot Agent config "
|
||||
"directory."
|
||||
)
|
||||
args_schema: Type[BaseModel] = EditFileInput
|
||||
require_admin: bool = True
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""根据参数生成友好的提示消息"""
|
||||
@@ -40,21 +42,27 @@ class EditFileTool(MoviePilotTool):
|
||||
logger.info(f"执行工具: {self.name}, 参数: file_path={file_path}")
|
||||
|
||||
try:
|
||||
path = AsyncPath(file_path)
|
||||
resolved_path, access_error = await self._check_local_file_access(
|
||||
file_path, operation="编辑"
|
||||
)
|
||||
if access_error:
|
||||
return access_error
|
||||
|
||||
path = AsyncPath(resolved_path)
|
||||
# 校验逻辑:如果要替换特定文本,文件必须存在且包含该文本
|
||||
if not await path.exists():
|
||||
# 如果 old_text 为空,可能用户想直接创建文件,但通常 edit_file 需要匹配旧内容
|
||||
if old_text:
|
||||
return f"错误:文件 {file_path} 不存在,无法进行内容替换。"
|
||||
return f"错误:文件 {resolved_path} 不存在,无法进行内容替换。"
|
||||
|
||||
if await path.exists() and not await path.is_file():
|
||||
return f"错误:{file_path} 不是一个文件"
|
||||
return f"错误:{resolved_path} 不是一个文件"
|
||||
|
||||
if await path.exists():
|
||||
content = await path.read_text(encoding="utf-8")
|
||||
content = await path.read_text(encoding="utf-8", errors="replace")
|
||||
if old_text not in content:
|
||||
logger.warning(f"编辑文件 {file_path} 失败:未找到指定的旧文本块")
|
||||
return f"错误:在文件 {file_path} 中未找到指定的旧文本。请确保包含所有的空格、缩进 and 换行符。"
|
||||
logger.warning(f"编辑文件 {resolved_path} 失败:未找到指定的旧文本块")
|
||||
return f"错误:在文件 {resolved_path} 中未找到指定的旧文本。请确保包含所有的空格、缩进 and 换行符。"
|
||||
occurrences = content.count(old_text)
|
||||
new_content = content.replace(old_text, new_text)
|
||||
else:
|
||||
@@ -68,8 +76,8 @@ class EditFileTool(MoviePilotTool):
|
||||
# 写入文件
|
||||
await path.write_text(new_content, encoding="utf-8")
|
||||
|
||||
logger.info(f"成功编辑文件 {file_path},替换了 {occurrences} 处内容")
|
||||
return f"成功编辑文件 {file_path} (替换了 {occurrences} 处匹配内容)"
|
||||
logger.info(f"成功编辑文件 {resolved_path},替换了 {occurrences} 处内容")
|
||||
return f"成功编辑文件 {resolved_path} (替换了 {occurrences} 处匹配内容)"
|
||||
|
||||
except PermissionError:
|
||||
return f"错误:没有访问/修改 {file_path} 的权限"
|
||||
|
||||
@@ -13,6 +13,7 @@ from typing import Any, Literal, Optional, TextIO, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.impl._command_safety import validate_command_safety
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.agent.tools.impl._terminal_session import (
|
||||
@@ -30,14 +31,6 @@ MAX_OUTPUT_PREVIEW_BYTES = 10 * 1024
|
||||
READ_CHUNK_SIZE = 4096
|
||||
KILL_GRACE_SECONDS = 3
|
||||
COMMAND_CONCURRENCY_LIMIT = 2
|
||||
COMMAND_FORBIDDEN_KEYWORDS = (
|
||||
":(){ :|:& };:",
|
||||
"dd if=/dev/zero",
|
||||
"mkfs",
|
||||
"reboot",
|
||||
"shutdown",
|
||||
)
|
||||
|
||||
_command_semaphore = asyncio.Semaphore(COMMAND_CONCURRENCY_LIMIT)
|
||||
|
||||
|
||||
@@ -58,7 +51,7 @@ class _CommandOutput:
|
||||
"""按 UTF-8 字节数截断文本,避免截断后出现非法字符。"""
|
||||
if byte_limit <= 0:
|
||||
return ""
|
||||
return text.encode("utf-8")[:byte_limit].decode("utf-8", errors="ignore")
|
||||
return text.encode("utf-8")[:byte_limit].decode("utf-8", errors="replace")
|
||||
|
||||
def _write_chunk(self, stream_name: str, text: str) -> None:
|
||||
"""把输出分片按 stdout/stderr 分段写入临时文件。"""
|
||||
@@ -142,7 +135,6 @@ class _CommandOutput:
|
||||
class ExecuteCommandInput(BaseModel):
|
||||
"""执行 Shell 命令工具的输入参数模型。"""
|
||||
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this command action is needed")
|
||||
action: Optional[Literal["start", "read", "wait", "write", "kill", "run"]] = Field(
|
||||
"start",
|
||||
description=(
|
||||
@@ -195,6 +187,13 @@ class ExecuteCommandInput(BaseModel):
|
||||
60,
|
||||
description="For action=run, max execution time in seconds.",
|
||||
)
|
||||
confirm_dangerous: Optional[bool] = Field(
|
||||
False,
|
||||
description=(
|
||||
"Explicit confirmation for high-risk commands such as recursive root deletion, "
|
||||
"disk formatting, shutdown/reboot, or destructive permission changes."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ExecuteCommandTool(MoviePilotTool):
|
||||
@@ -255,34 +254,9 @@ class ExecuteCommandTool(MoviePilotTool):
|
||||
return command
|
||||
|
||||
@staticmethod
|
||||
def _validate_command(command: str) -> None:
|
||||
def _validate_command(command: str, *, confirmed: bool = False) -> None:
|
||||
"""复用旧工具的基础危险命令过滤,避免明显破坏性命令进入 shell。"""
|
||||
for keyword in COMMAND_FORBIDDEN_KEYWORDS:
|
||||
if keyword in command:
|
||||
raise ValueError(f"命令包含禁止使用的关键字 '{keyword}'")
|
||||
|
||||
# 检查是否使用了 rm -r/R 删除根目录或一级目录,防止误杀多级目录
|
||||
import re
|
||||
import os.path
|
||||
tokens = re.split(r'\s+', command.strip())
|
||||
if any(t == "rm" or t.endswith("/rm") for t in tokens):
|
||||
has_r = False
|
||||
for token in tokens:
|
||||
if token.startswith("-") and ("r" in token or "R" in token):
|
||||
has_r = True
|
||||
break
|
||||
|
||||
if has_r:
|
||||
for token in tokens:
|
||||
# 提取可能包含目标路径的部分(去除重定向、管道、分号等末尾干扰)
|
||||
m = re.match(r'^([^;\|&><]+)', token)
|
||||
if m:
|
||||
clean_token = m.group(1).strip('"\'')
|
||||
# 仅对绝对路径进行一级目录限制
|
||||
if clean_token.startswith('/'):
|
||||
norm_path = os.path.normpath(clean_token)
|
||||
if re.match(r'^/[^/]*$', norm_path) or re.match(r'^/[^/]*/$', norm_path):
|
||||
raise ValueError(f"不允许使用 rm 命令删除根目录或一级目录: {clean_token}")
|
||||
validate_command_safety(command, confirmed=confirmed)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_timeout(timeout: Optional[int]) -> tuple[int, Optional[str]]:
|
||||
@@ -367,7 +341,7 @@ class ExecuteCommandTool(MoviePilotTool):
|
||||
asyncio.shield(wait_task), timeout=KILL_GRACE_SECONDS
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("命令进程强制清理超时: pid=%s", process.pid)
|
||||
logger.warning(f"命令进程强制清理超时: pid={process.pid}")
|
||||
|
||||
@staticmethod
|
||||
async def _finish_reader_tasks(reader_tasks: list[asyncio.Task]) -> None:
|
||||
@@ -382,7 +356,7 @@ class ExecuteCommandTool(MoviePilotTool):
|
||||
if isinstance(result, Exception) and not isinstance(
|
||||
result, asyncio.CancelledError
|
||||
):
|
||||
logger.debug("命令输出读取任务异常: %s", result)
|
||||
logger.debug(f"命令输出读取任务异常: {result}")
|
||||
|
||||
@staticmethod
|
||||
def _format_run_result(
|
||||
@@ -425,9 +399,10 @@ class ExecuteCommandTool(MoviePilotTool):
|
||||
command: str,
|
||||
timeout: Optional[int],
|
||||
cwd: Optional[str] = None,
|
||||
confirm_dangerous: bool = False,
|
||||
) -> str:
|
||||
"""按旧模式一次性执行命令,等待完成或超时后返回文本结果。"""
|
||||
self._validate_command(command)
|
||||
self._validate_command(command, confirmed=confirm_dangerous)
|
||||
normalized_timeout, timeout_note = self._normalize_timeout(timeout)
|
||||
|
||||
async with _command_semaphore:
|
||||
@@ -451,6 +426,9 @@ class ExecuteCommandTool(MoviePilotTool):
|
||||
except asyncio.TimeoutError:
|
||||
timed_out = True
|
||||
await self._cleanup_process(process, wait_task)
|
||||
except asyncio.CancelledError:
|
||||
await self._cleanup_process(process, wait_task)
|
||||
raise
|
||||
|
||||
try:
|
||||
await self._finish_reader_tasks(reader_tasks)
|
||||
@@ -479,27 +457,29 @@ class ExecuteCommandTool(MoviePilotTool):
|
||||
max_bytes: Optional[int] = TERMINAL_DEFAULT_READ_BYTES,
|
||||
timeout_ms: Optional[int] = TERMINAL_WAIT_DEFAULT_MS,
|
||||
timeout: Optional[int] = 60,
|
||||
confirm_dangerous: Optional[bool] = False,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""执行命令动作:默认后台启动,也支持读取、等待、写入、终止和一次性执行。"""
|
||||
normalized_action = (action or "start").strip().lower()
|
||||
logger.info(
|
||||
"执行工具: %s, action=%s, command=%s, session_id=%s",
|
||||
self.name,
|
||||
normalized_action,
|
||||
command,
|
||||
session_id,
|
||||
f"执行工具: {self.name}, action={normalized_action}, "
|
||||
f"command={command}, session_id={session_id}"
|
||||
)
|
||||
|
||||
try:
|
||||
if normalized_action == "start":
|
||||
start_command = self._require_command(command)
|
||||
self._validate_command(start_command)
|
||||
self._validate_command(
|
||||
start_command,
|
||||
confirmed=bool(confirm_dangerous),
|
||||
)
|
||||
payload = await terminal_session_manager.start(
|
||||
command=start_command,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
use_pty=use_pty,
|
||||
confirm_dangerous=bool(confirm_dangerous),
|
||||
)
|
||||
return self._dump(payload)
|
||||
|
||||
@@ -539,9 +519,10 @@ class ExecuteCommandTool(MoviePilotTool):
|
||||
command=self._require_command(command),
|
||||
timeout=timeout,
|
||||
cwd=cwd,
|
||||
confirm_dangerous=bool(confirm_dangerous),
|
||||
)
|
||||
|
||||
raise ValueError(f"不支持的 action: {action}")
|
||||
except Exception as err:
|
||||
logger.error("执行命令 action 失败: %s", err, exc_info=True)
|
||||
logger.error(f"执行命令 action 失败: {err}", exc_info=True)
|
||||
return self._dump({"error": str(err), "status": "error", "action": normalized_action})
|
||||
|
||||
@@ -15,8 +15,6 @@ from app.schemas.types import MediaType, media_type_to_agent
|
||||
class GetRecommendationsInput(BaseModel):
|
||||
"""获取推荐工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
source: Optional[str] = Field(
|
||||
"tmdb_trending",
|
||||
description="Recommendation source: "
|
||||
|
||||
@@ -21,8 +21,6 @@ from ._torrent_search_utils import (
|
||||
class GetSearchResultsInput(BaseModel):
|
||||
"""获取搜索结果工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
site: Optional[List[str]] = Field(None, description="Site name filters")
|
||||
season: Optional[List[str]] = Field(None, description="Season or episode filters")
|
||||
free_state: Optional[List[str]] = Field(None, description="Promotion state filters")
|
||||
|
||||
@@ -19,8 +19,6 @@ from app.log import logger
|
||||
class InstallPluginInput(BaseModel):
|
||||
"""安装插件工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
plugin_id: str = Field(
|
||||
...,
|
||||
description="Exact plugin ID to install. Use query_market_plugins first to find the correct plugin_id.",
|
||||
|
||||
@@ -17,7 +17,6 @@ from app.utils.string import StringUtils
|
||||
|
||||
class ListDirectoryInput(BaseModel):
|
||||
"""查询文件系统目录内容工具的输入参数模型"""
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
path: str = Field(..., description="Directory path to list contents (e.g., '/home/user/downloads' or 'C:/Downloads')")
|
||||
storage: Optional[str] = Field("local", description="Storage type (default: 'local' for local file system, can be 'smb', 'alist', etc.)")
|
||||
sort_by: Optional[str] = Field("name", description="Sort order: 'name' for alphabetical sorting, 'time' for modification time sorting (default: 'name')")
|
||||
@@ -116,6 +115,13 @@ class ListDirectoryTool(MoviePilotTool):
|
||||
logger.info(f"执行工具: {self.name}, 参数: path={path}, storage={storage}, sort_by={sort_by}")
|
||||
|
||||
try:
|
||||
resolved_path, access_error = await self._check_local_storage_access(
|
||||
path=path, storage=storage, operation="列出"
|
||||
)
|
||||
if access_error:
|
||||
return access_error
|
||||
if resolved_path:
|
||||
path = str(resolved_path)
|
||||
return await self.run_blocking(
|
||||
"storage", self._list_directory_sync, path, storage, sort_by
|
||||
)
|
||||
|
||||
@@ -13,8 +13,6 @@ from app.log import logger
|
||||
class ListSlashCommandsInput(BaseModel):
|
||||
"""查询所有可用斜杠命令工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
|
||||
|
||||
class ListSlashCommandsTool(MoviePilotTool):
|
||||
|
||||
98
app/agent/tools/impl/mcp.py
Normal file
98
app/agent/tools/impl/mcp.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""外部 MCP 工具适配器。"""
|
||||
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import PrivateAttr
|
||||
|
||||
from app.agent.mcp import AgentMcpToolSpec, agent_mcp_manager
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
|
||||
|
||||
class McpExternalTool(MoviePilotTool):
|
||||
"""将外部 MCP 工具包装为 MoviePilot Agent 工具。"""
|
||||
|
||||
name: str = "mcp_external_tool"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = "Call an external MCP tool configured for MoviePilot Agent."
|
||||
args_schema: dict[str, Any] = {"type": "object", "properties": {}, "required": []}
|
||||
require_admin: bool = True
|
||||
|
||||
_spec: AgentMcpToolSpec = PrivateAttr()
|
||||
|
||||
def __init__(self, spec: AgentMcpToolSpec, session_id: str, user_id: str) -> None:
|
||||
super().__init__(
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
name=spec.agent_tool_name,
|
||||
description=spec.description
|
||||
or f"Call external MCP tool {spec.name} on {spec.server.name}.",
|
||||
args_schema=spec.input_schema,
|
||||
require_admin=spec.server.require_admin,
|
||||
)
|
||||
self._spec = spec
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""根据 MCP 工具信息生成友好的提示消息。"""
|
||||
return f"调用 MCP 工具: {self._spec.server.name}/{self._spec.name}"
|
||||
|
||||
async def run(self, **kwargs) -> str:
|
||||
"""
|
||||
调用外部 MCP 工具。
|
||||
|
||||
:param kwargs: 传递给外部 MCP 工具的参数
|
||||
:return: MCP 工具返回内容
|
||||
"""
|
||||
result = await agent_mcp_manager.call_server_tool(
|
||||
server=self._spec.server,
|
||||
tool_name=self._spec.name,
|
||||
arguments=kwargs,
|
||||
)
|
||||
return self._format_mcp_result(result)
|
||||
|
||||
@staticmethod
|
||||
def _format_mcp_result(result: Any) -> str:
|
||||
"""将 MCP tools/call 返回结构转换为 Agent 可读文本。"""
|
||||
if isinstance(result, dict):
|
||||
content = result.get("content")
|
||||
if isinstance(content, list):
|
||||
parts = []
|
||||
for item in content:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if item.get("type") == "text" and item.get("text") is not None:
|
||||
parts.append(str(item["text"]))
|
||||
elif item:
|
||||
parts.append(json.dumps(item, ensure_ascii=False, default=str))
|
||||
if parts:
|
||||
return "\n".join(parts)
|
||||
if result.get("isError"):
|
||||
return json.dumps(result, ensure_ascii=False, indent=2, default=str)
|
||||
if isinstance(result, str):
|
||||
return result
|
||||
return json.dumps(result, ensure_ascii=False, indent=2, default=str)
|
||||
|
||||
|
||||
async def create_external_mcp_tools(
|
||||
*,
|
||||
session_id: str,
|
||||
user_id: str,
|
||||
channel: Optional[str] = None,
|
||||
source: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
stream_handler=None,
|
||||
agent_context: Optional[dict] = None,
|
||||
) -> list[McpExternalTool]:
|
||||
"""创建当前已启用的外部 MCP Agent 工具列表。"""
|
||||
tools = []
|
||||
for spec in await agent_mcp_manager.list_enabled_tool_specs():
|
||||
tool = McpExternalTool(spec=spec, session_id=session_id, user_id=user_id)
|
||||
tool.set_message_attr(channel=channel, source=source, username=username)
|
||||
tool.set_stream_handler(stream_handler=stream_handler)
|
||||
tool.set_agent_context(agent_context=agent_context)
|
||||
tools.append(tool)
|
||||
return tools
|
||||
@@ -1,143 +0,0 @@
|
||||
"""修改下载任务工具"""
|
||||
|
||||
from typing import Optional, Type, List
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.chain.download import DownloadChain
|
||||
from app.log import logger
|
||||
|
||||
|
||||
class ModifyDownloadInput(BaseModel):
|
||||
"""修改下载任务工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
hash: str = Field(
|
||||
..., description="Task hash (can be obtained from query_download_tasks tool)"
|
||||
)
|
||||
action: Optional[str] = Field(
|
||||
None,
|
||||
description="Action to perform on the task: 'start' to resume downloading, 'stop' to pause downloading. "
|
||||
"If not provided, no start/stop action will be performed.",
|
||||
)
|
||||
tags: Optional[List[str]] = Field(
|
||||
None,
|
||||
description="List of tags to set on the download task. If provided, these tags will be added to the task. "
|
||||
"Example: ['movie', 'hd']",
|
||||
)
|
||||
downloader: Optional[str] = Field(
|
||||
None,
|
||||
description="Name of specific downloader (optional, if not provided will search all downloaders)",
|
||||
)
|
||||
|
||||
|
||||
class ModifyDownloadTool(MoviePilotTool):
|
||||
"""修改下载任务工具"""
|
||||
|
||||
name: str = "modify_download"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.Download,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = (
|
||||
"Modify a download task in the downloader by task hash. "
|
||||
"Supports: 1) Setting tags on a download task, "
|
||||
"2) Starting (resuming) a paused download task, "
|
||||
"3) Stopping (pausing) a downloading task. "
|
||||
"Multiple operations can be performed in a single call."
|
||||
)
|
||||
args_schema: Type[BaseModel] = ModifyDownloadInput
|
||||
require_admin: bool = True
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
hash_value = kwargs.get("hash", "")
|
||||
action = kwargs.get("action")
|
||||
tags = kwargs.get("tags")
|
||||
downloader = kwargs.get("downloader")
|
||||
|
||||
parts = [f"修改下载任务: {hash_value}"]
|
||||
if action == "start":
|
||||
parts.append("操作: 开始下载")
|
||||
elif action == "stop":
|
||||
parts.append("操作: 暂停下载")
|
||||
if tags:
|
||||
parts.append(f"标签: {', '.join(tags)}")
|
||||
if downloader:
|
||||
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,
|
||||
action: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
downloader: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, 参数: hash={hash}, action={action}, tags={tags}, downloader={downloader}"
|
||||
)
|
||||
|
||||
try:
|
||||
# 校验 hash 格式
|
||||
if len(hash) != 40 or not all(c in "0123456789abcdefABCDEF" for c in hash):
|
||||
return "参数错误:hash 格式无效,请先使用 query_download_tasks 工具获取正确的 hash。"
|
||||
|
||||
# 校验参数:至少需要一个操作
|
||||
if not action and not tags:
|
||||
return "参数错误:至少需要指定 action(start/stop)或 tags 中的一个。"
|
||||
|
||||
# 校验 action 参数
|
||||
if action and action not in ("start", "stop"):
|
||||
return f"参数错误:action 只支持 'start'(开始下载)或 'stop'(暂停下载),收到: '{action}'。"
|
||||
|
||||
results = await self.run_blocking(
|
||||
"downloader",
|
||||
self._modify_download_sync,
|
||||
hash,
|
||||
action,
|
||||
tags,
|
||||
downloader,
|
||||
)
|
||||
|
||||
return f"下载任务 {hash}:" + ";".join(results)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"修改下载任务失败: {e}", exc_info=True)
|
||||
return f"修改下载任务时发生错误: {str(e)}"
|
||||
@@ -18,8 +18,6 @@ from app.log import logger
|
||||
class QueryBuiltinFilterRulesInput(BaseModel):
|
||||
"""查询内置过滤规则工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
rule_ids: Optional[List[str]] = Field(
|
||||
None,
|
||||
description="Optional list of built-in rule IDs to query. If omitted, return all built-in rules.",
|
||||
|
||||
@@ -19,8 +19,6 @@ from app.log import logger
|
||||
class QueryCustomFilterRulesInput(BaseModel):
|
||||
"""查询自定义过滤规则工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
rule_ids: Optional[List[str]] = Field(
|
||||
None,
|
||||
description="Optional list of custom rule IDs to query. If omitted, return all custom rules.",
|
||||
|
||||
@@ -15,8 +15,6 @@ from app.schemas.types import SystemConfigKey
|
||||
class QueryCustomIdentifiersInput(BaseModel):
|
||||
"""查询自定义识别词工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
|
||||
|
||||
class QueryCustomIdentifiersTool(MoviePilotTool):
|
||||
|
||||
@@ -13,7 +13,6 @@ from app.log import logger
|
||||
|
||||
class QueryDirectorySettingsInput(BaseModel):
|
||||
"""查询系统目录设置工具的输入参数模型"""
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
directory_type: Optional[str] = Field("all",
|
||||
description="Filter directories by type: 'download' for download directories, 'library' for media library directories, 'all' for all directories")
|
||||
storage_type: Optional[str] = Field("all",
|
||||
|
||||
122
app/agent/tools/impl/query_doctor_report.py
Normal file
122
app/agent/tools/impl/query_doctor_report.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""查询 MoviePilot Doctor 诊断报告工具。"""
|
||||
|
||||
import json
|
||||
from typing import Any, Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.doctor import run_doctor
|
||||
from app.log import logger
|
||||
|
||||
|
||||
class QueryDoctorReportInput(BaseModel):
|
||||
"""查询 Doctor 诊断报告工具的输入参数模型。"""
|
||||
|
||||
deep: Optional[bool] = Field(
|
||||
False,
|
||||
description=(
|
||||
"Whether to run deeper checks. When true, doctor may perform slower environment probes "
|
||||
"such as PostgreSQL TCP connectivity checks."
|
||||
),
|
||||
)
|
||||
include_details: Optional[bool] = Field(
|
||||
True,
|
||||
description=(
|
||||
"Whether to include full doctor findings with details and context. Set false for a compact "
|
||||
"summary when only overall status and finding titles are needed."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class QueryDoctorReportTool(MoviePilotTool):
|
||||
"""
|
||||
Doctor 离线诊断报告查询工具。
|
||||
"""
|
||||
|
||||
name: str = "query_doctor_report"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.System,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = (
|
||||
"Run MoviePilot Doctor in read-only mode and return a structured diagnostic report for troubleshooting. "
|
||||
"Use this tool when analyzing startup failures, Docker/runtime issues, port conflicts, dependency problems, "
|
||||
"database health, frontend assets, safe mode, or recent log error clues. This tool never applies fixes."
|
||||
)
|
||||
require_admin: bool = True
|
||||
args_schema: Type[BaseModel] = QueryDoctorReportInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""根据查询参数生成友好的提示消息。"""
|
||||
if kwargs.get("deep"):
|
||||
return "运行 Doctor 深度诊断"
|
||||
return "运行 Doctor 诊断"
|
||||
|
||||
@staticmethod
|
||||
def _compact_report(report: dict[str, Any]) -> dict[str, Any]:
|
||||
"""压缩诊断报告,保留 Agent 判断问题所需的核心字段。"""
|
||||
return {
|
||||
"schema_version": report.get("schema_version"),
|
||||
"status": report.get("status"),
|
||||
"generated_at": report.get("generated_at"),
|
||||
"version": report.get("version"),
|
||||
"environment": report.get("environment"),
|
||||
"summary": report.get("summary"),
|
||||
"findings": [
|
||||
{
|
||||
"id": item.get("id"),
|
||||
"severity": item.get("severity"),
|
||||
"status": item.get("status"),
|
||||
"title": item.get("title"),
|
||||
"fixable": item.get("fixable"),
|
||||
"fixed": item.get("fixed"),
|
||||
}
|
||||
for item in report.get("findings") or []
|
||||
if isinstance(item, dict)
|
||||
],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _run_doctor_report(deep: bool = False) -> dict[str, Any]:
|
||||
"""在线程池中运行只读 Doctor 诊断。"""
|
||||
return run_doctor(deep=bool(deep)).to_dict()
|
||||
|
||||
async def run(
|
||||
self,
|
||||
deep: Optional[bool] = False,
|
||||
include_details: Optional[bool] = True,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""
|
||||
运行只读 Doctor 诊断并返回 JSON 字符串。
|
||||
"""
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, deep={bool(deep)}, include_details={bool(include_details)}"
|
||||
)
|
||||
try:
|
||||
report = await self.run_blocking("default", self._run_doctor_report, bool(deep))
|
||||
if not include_details:
|
||||
report = self._compact_report(report)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"deep": bool(deep),
|
||||
"include_details": bool(include_details),
|
||||
"report": report,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
default=str,
|
||||
)
|
||||
except Exception as err:
|
||||
logger.error(f"查询 Doctor 诊断报告失败: {err}", exc_info=True)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"查询 Doctor 诊断报告时发生错误: {str(err)}",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
"""查询下载工具"""
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional, Type, Union
|
||||
from typing import Any, Dict, List, Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -10,17 +10,24 @@ from app.agent.tools.tags import ToolTag
|
||||
from app.chain.download import DownloadChain
|
||||
from app.db.downloadhistory_oper import DownloadHistoryOper
|
||||
from app.log import logger
|
||||
from app.schemas import TransferTorrent, DownloadingTorrent
|
||||
from app.schemas.types import TorrentStatus, media_type_to_agent
|
||||
from app.schemas import DownloaderTorrent
|
||||
from app.schemas.types import TorrentQueryStatus, media_type_to_agent
|
||||
|
||||
|
||||
class QueryDownloadTasksInput(BaseModel):
|
||||
"""查询下载工具的输入参数模型"""
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
downloader: Optional[str] = Field(None,
|
||||
description="Name of specific downloader to query (optional, if not provided queries all configured downloaders)")
|
||||
status: Optional[str] = Field("all",
|
||||
description="Filter downloads by status: 'downloading' for active downloads, 'completed' for finished downloads, 'paused' for paused downloads, 'all' for all downloads")
|
||||
include_all_tags: Optional[bool] = Field(
|
||||
False,
|
||||
description="Include tasks without the MoviePilot built-in tag. Default false keeps the normal MoviePilot task scope.",
|
||||
)
|
||||
include_trackers: Optional[bool] = Field(
|
||||
False,
|
||||
description="Include tracker URLs when supported. Hash queries always include trackers.",
|
||||
)
|
||||
hash: Optional[str] = Field(None, description="Query specific download task by hash (optional, if provided will search for this specific task regardless of status)")
|
||||
title: Optional[str] = Field(None, description="Query download tasks by title/name (optional, supports partial match, searches all tasks if provided)")
|
||||
tag: Optional[str] = Field(None, description="Filter download tasks by tag (optional, supports partial match, e.g. 'movie' will match tasks with tag 'movie' or 'movie_2024')")
|
||||
@@ -36,26 +43,45 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
args_schema: Type[BaseModel] = QueryDownloadTasksInput
|
||||
|
||||
@staticmethod
|
||||
def _get_all_torrents(download_chain: DownloadChain, downloader: Optional[str] = None) -> List[Union[TransferTorrent, DownloadingTorrent]]:
|
||||
def _normalize_query_status(status: Optional[str]) -> TorrentQueryStatus:
|
||||
"""
|
||||
归一下载任务查询状态。
|
||||
"""
|
||||
status_value = str(status or "").strip().lower()
|
||||
if not status_value or status_value == TorrentQueryStatus.ALL.value:
|
||||
return TorrentQueryStatus.ALL
|
||||
if status_value in {"completed", "complete", "seeding"}:
|
||||
return TorrentQueryStatus.COMPLETED
|
||||
if status_value in {"paused", "pause"}:
|
||||
return TorrentQueryStatus.PAUSED
|
||||
if status_value == TorrentQueryStatus.DOWNLOADING.value:
|
||||
return TorrentQueryStatus.DOWNLOADING
|
||||
return TorrentQueryStatus.ALL
|
||||
|
||||
@staticmethod
|
||||
def _normalize_include_all_tags(include_all_tags: Any) -> bool:
|
||||
"""
|
||||
归一全部标签查询开关。
|
||||
"""
|
||||
if isinstance(include_all_tags, bool):
|
||||
return include_all_tags
|
||||
if isinstance(include_all_tags, str):
|
||||
return include_all_tags.strip().lower() in {"1", "true", "yes", "on", "是"}
|
||||
return bool(include_all_tags)
|
||||
|
||||
@staticmethod
|
||||
def _get_all_torrents(
|
||||
download_chain: DownloadChain,
|
||||
downloader: Optional[str] = None,
|
||||
include_all_tags: bool = False,
|
||||
) -> List[DownloaderTorrent]:
|
||||
"""
|
||||
查询所有状态的任务(包括下载中和已完成的任务)
|
||||
"""
|
||||
all_torrents = []
|
||||
# 查询下载的任务
|
||||
downloading_torrents = download_chain.list_torrents(
|
||||
downloader=downloader,
|
||||
status=TorrentStatus.DOWNLOADING
|
||||
) or []
|
||||
all_torrents.extend(downloading_torrents)
|
||||
|
||||
# 查询已完成的任务(可转移状态)
|
||||
transfer_torrents = download_chain.list_torrents(
|
||||
return download_chain.list_torrents(
|
||||
downloader=downloader,
|
||||
status=TorrentStatus.TRANSFER
|
||||
include_all_tags=include_all_tags,
|
||||
) or []
|
||||
all_torrents.extend(transfer_torrents)
|
||||
|
||||
return all_torrents
|
||||
|
||||
@staticmethod
|
||||
def _format_progress(progress: Optional[float]) -> Optional[str]:
|
||||
@@ -71,7 +97,7 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
|
||||
@staticmethod
|
||||
def _apply_download_history(
|
||||
torrent: Union[TransferTorrent, DownloadingTorrent], history: Any
|
||||
torrent: DownloaderTorrent, history: Any
|
||||
) -> None:
|
||||
"""将下载历史中的补充信息回填到下载任务结果中。"""
|
||||
if not history:
|
||||
@@ -91,7 +117,7 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
|
||||
@classmethod
|
||||
def _load_history_map(
|
||||
cls, torrents: List[Union[TransferTorrent, DownloadingTorrent]]
|
||||
cls, torrents: List[DownloaderTorrent]
|
||||
) -> Dict[str, Any]:
|
||||
"""批量加载下载历史,避免逐条查询形成 N+1。"""
|
||||
hashes = [torrent.hash for torrent in torrents if getattr(torrent, "hash", None)]
|
||||
@@ -107,15 +133,23 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
hash_value: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
include_all_tags: bool = False,
|
||||
include_trackers: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
同步查询下载器和下载历史,整个链路放在线程池中执行。
|
||||
"""
|
||||
download_chain = DownloadChain()
|
||||
query_status = cls._normalize_query_status(status)
|
||||
include_all_tags = cls._normalize_include_all_tags(include_all_tags)
|
||||
|
||||
if hash_value:
|
||||
torrents = (
|
||||
download_chain.list_torrents(downloader=downloader, hashs=[hash_value])
|
||||
download_chain.list_torrents(
|
||||
downloader=downloader,
|
||||
hashs=[hash_value],
|
||||
include_all_tags=include_all_tags,
|
||||
)
|
||||
or []
|
||||
)
|
||||
if not torrents:
|
||||
@@ -128,7 +162,11 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
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)
|
||||
all_torrents = cls._get_all_torrents(
|
||||
download_chain,
|
||||
downloader,
|
||||
include_all_tags=include_all_tags,
|
||||
)
|
||||
history_map = cls._load_history_map(all_torrents)
|
||||
filtered_downloads = []
|
||||
title_lower = title.lower()
|
||||
@@ -150,7 +188,7 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
if not filtered_downloads:
|
||||
return {"message": f"未找到标题包含 '{title}' 的下载任务"}
|
||||
else:
|
||||
if status == "downloading":
|
||||
if query_status == TorrentQueryStatus.DOWNLOADING and not include_all_tags:
|
||||
downloads = download_chain.downloading(name=downloader) or []
|
||||
filtered_downloads = [
|
||||
dl
|
||||
@@ -158,19 +196,12 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
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)
|
||||
list_status = None if query_status == TorrentQueryStatus.ALL else query_status.value
|
||||
filtered_downloads = download_chain.list_torrents(
|
||||
downloader=downloader,
|
||||
status=list_status,
|
||||
include_all_tags=include_all_tags,
|
||||
) or []
|
||||
|
||||
history_map = cls._load_history_map(filtered_downloads)
|
||||
for torrent in filtered_downloads:
|
||||
@@ -187,6 +218,16 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
if not filtered_downloads:
|
||||
return {"message": "未找到相关下载任务"}
|
||||
|
||||
if hash_value or include_trackers:
|
||||
for torrent in filtered_downloads:
|
||||
if not getattr(torrent, "hash", None):
|
||||
continue
|
||||
tracker_map = download_chain.get_torrent_trackers(
|
||||
hash_string=torrent.hash,
|
||||
downloader=getattr(torrent, "downloader", None) or downloader,
|
||||
) or {}
|
||||
torrent.trackers = tracker_map.get(getattr(torrent, "downloader", None)) or []
|
||||
|
||||
return {"downloads": filtered_downloads}
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
@@ -195,6 +236,9 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
status = kwargs.get("status", "all")
|
||||
hash_value = kwargs.get("hash")
|
||||
title = kwargs.get("title")
|
||||
include_all_tags = self._normalize_include_all_tags(
|
||||
kwargs.get("include_all_tags", False)
|
||||
)
|
||||
|
||||
parts = ["查询下载任务"]
|
||||
|
||||
@@ -213,6 +257,10 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
tag = kwargs.get("tag")
|
||||
if tag:
|
||||
parts.append(f"标签: {tag}")
|
||||
if include_all_tags:
|
||||
parts.append("范围: 全部标签")
|
||||
if kwargs.get("include_trackers"):
|
||||
parts.append("包含Tracker")
|
||||
|
||||
return " | ".join(parts) if len(parts) > 1 else parts[0]
|
||||
|
||||
@@ -220,8 +268,15 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
status: Optional[str] = "all",
|
||||
hash: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
tag: Optional[str] = None, **kwargs) -> str:
|
||||
logger.info(f"执行工具: {self.name}, 参数: downloader={downloader}, status={status}, hash={hash}, title={title}, tag={tag}")
|
||||
tag: Optional[str] = None,
|
||||
include_all_tags: Optional[bool] = False,
|
||||
include_trackers: Optional[bool] = False,
|
||||
**kwargs) -> str:
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, 参数: downloader={downloader}, status={status}, "
|
||||
f"hash={hash}, title={title}, tag={tag}, include_all_tags={include_all_tags}, "
|
||||
f"include_trackers={include_trackers}"
|
||||
)
|
||||
try:
|
||||
payload = await self.run_blocking(
|
||||
"downloader",
|
||||
@@ -231,6 +286,8 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
hash,
|
||||
title,
|
||||
tag,
|
||||
self._normalize_include_all_tags(include_all_tags),
|
||||
self._normalize_include_all_tags(include_trackers),
|
||||
)
|
||||
if payload.get("message"):
|
||||
return payload["message"]
|
||||
@@ -256,6 +313,16 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
"upspeed": getattr(d, "upspeed", None),
|
||||
"dlspeed": getattr(d, "dlspeed", None),
|
||||
"tags": d.tags,
|
||||
"save_path": getattr(d, "save_path", None),
|
||||
"content_path": getattr(d, "content_path", None) or (
|
||||
d.path.as_posix() if getattr(d, "path", None) else None
|
||||
),
|
||||
"category": getattr(d, "category", None),
|
||||
"download_limit": getattr(d, "download_limit", None),
|
||||
"upload_limit": getattr(d, "upload_limit", None),
|
||||
"ratio_limit": getattr(d, "ratio_limit", None),
|
||||
"seeding_time_limit": getattr(d, "seeding_time_limit", None),
|
||||
"trackers": getattr(d, "trackers", None) or [],
|
||||
"left_time": getattr(d, "left_time", None)
|
||||
}
|
||||
# 精简 media 字段
|
||||
|
||||
@@ -14,18 +14,17 @@ from app.schemas.types import SystemConfigKey
|
||||
|
||||
class QueryDownloadersInput(BaseModel):
|
||||
"""查询下载器工具的输入参数模型"""
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
|
||||
|
||||
class QueryDownloadersTool(MoviePilotTool):
|
||||
name: str = "query_downloaders"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Download,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = "Query downloader configuration and list all available downloaders. Shows downloader status, connection details, and configuration settings."
|
||||
require_admin: bool = True
|
||||
description: str = (
|
||||
"Query downloader configuration and list available downloaders. Non-admin users receive "
|
||||
"a safe view with only the fields needed to choose a downloader, without host, account, "
|
||||
"password, token or API key values."
|
||||
)
|
||||
args_schema: Type[BaseModel] = QueryDownloadersInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
@@ -37,11 +36,35 @@ class QueryDownloadersTool(MoviePilotTool):
|
||||
"""从内存配置缓存中读取下载器配置。"""
|
||||
return SystemConfigOper().get(SystemConfigKey.Downloaders)
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_downloaders_config(downloaders_config: list) -> list:
|
||||
"""
|
||||
生成普通用户可见的下载器配置视图。
|
||||
|
||||
:param downloaders_config: 系统下载器完整配置列表
|
||||
:return: 仅包含名称、类型和启用状态的安全配置列表
|
||||
"""
|
||||
safe_fields = ("name", "type", "enabled", "default", "priority")
|
||||
safe_downloaders = []
|
||||
for downloader in downloaders_config:
|
||||
if not isinstance(downloader, dict):
|
||||
continue
|
||||
safe_downloaders.append({
|
||||
key: downloader.get(key)
|
||||
for key in safe_fields
|
||||
if key in downloader
|
||||
})
|
||||
return safe_downloaders
|
||||
|
||||
async def run(self, **kwargs) -> str:
|
||||
logger.info(f"执行工具: {self.name}")
|
||||
try:
|
||||
downloaders_config = self._load_downloaders_config()
|
||||
if downloaders_config:
|
||||
if not await self.is_admin_user():
|
||||
downloaders_config = self._sanitize_downloaders_config(
|
||||
downloaders_config
|
||||
)
|
||||
return json.dumps(downloaders_config, ensure_ascii=False, indent=2)
|
||||
return "未配置下载器。"
|
||||
except Exception as e:
|
||||
|
||||
@@ -13,7 +13,6 @@ from app.log import logger
|
||||
|
||||
class QueryEpisodeScheduleInput(BaseModel):
|
||||
"""查询剧集上映时间工具的输入参数模型"""
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
tmdb_id: int = Field(..., description="TMDB ID of the TV series (can be obtained from search_media tool)")
|
||||
season: int = Field(..., description="Season number to query")
|
||||
episode_group: Optional[str] = Field(None, description="Episode group ID (optional)")
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.agent.tools.tags import ToolTag
|
||||
from app.agent.tools.impl._plugin_tool_utils import (
|
||||
DEFAULT_PLUGIN_CANDIDATE_LIMIT,
|
||||
MAX_PLUGIN_CANDIDATE_LIMIT,
|
||||
enrich_installed_plugin_sources,
|
||||
list_installed_plugins,
|
||||
search_plugin_candidates,
|
||||
summarize_candidates,
|
||||
@@ -21,8 +22,6 @@ from app.log import logger
|
||||
class QueryInstalledPluginsInput(BaseModel):
|
||||
"""查询已安装插件工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
query: Optional[str] = Field(
|
||||
None,
|
||||
description="Optional keyword to filter installed plugins by plugin ID, name, description, or author.",
|
||||
@@ -31,9 +30,15 @@ class QueryInstalledPluginsInput(BaseModel):
|
||||
DEFAULT_PLUGIN_CANDIDATE_LIMIT,
|
||||
description="Maximum number of plugins to return. Defaults to 50, capped at 200.",
|
||||
)
|
||||
force_refresh_market: bool = Field(
|
||||
False,
|
||||
description="Whether to refresh plugin market caches before completing missing repo_url values.",
|
||||
)
|
||||
|
||||
|
||||
class QueryInstalledPluginsTool(MoviePilotTool):
|
||||
"""查询已安装插件并返回 Agent 可消费的摘要信息。"""
|
||||
|
||||
name: str = "query_installed_plugins"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
@@ -67,9 +72,15 @@ class QueryInstalledPluginsTool(MoviePilotTool):
|
||||
self,
|
||||
query: Optional[str] = None,
|
||||
max_results: Optional[int] = DEFAULT_PLUGIN_CANDIDATE_LIMIT,
|
||||
force_refresh_market: bool = False,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
logger.info(f"执行工具: {self.name}, 参数: query={query}")
|
||||
"""
|
||||
查询已安装插件列表,并在可能时补齐插件来源仓库地址。
|
||||
"""
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, 参数: query={query}, force_refresh_market={force_refresh_market}"
|
||||
)
|
||||
try:
|
||||
installed_plugins = list_installed_plugins()
|
||||
if not installed_plugins:
|
||||
@@ -77,6 +88,10 @@ class QueryInstalledPluginsTool(MoviePilotTool):
|
||||
{"success": False, "message": "当前没有已安装的插件"},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
installed_plugins = await enrich_installed_plugin_sources(
|
||||
installed_plugins,
|
||||
force_refresh=force_refresh_market,
|
||||
)
|
||||
|
||||
limit = self._clamp_results(max_results)
|
||||
if query:
|
||||
|
||||
@@ -77,7 +77,6 @@ def _build_tv_server_result(existing_seasons: OrderedDict, total_seasons: Ordere
|
||||
|
||||
class QueryLibraryExistsInput(BaseModel):
|
||||
"""查询媒体库工具的输入参数模型"""
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
tmdb_id: Optional[int] = Field(None, description="TMDB ID (can be obtained from search_media tool). Either tmdb_id or douban_id must be provided.")
|
||||
douban_id: Optional[str] = Field(None, description="Douban ID (can be obtained from search_media tool). Either tmdb_id or douban_id must be provided.")
|
||||
media_type: Optional[str] = Field(None, description="Allowed values: movie, tv")
|
||||
|
||||
@@ -18,8 +18,6 @@ PAGE_SIZE = 20
|
||||
class QueryLibraryLatestInput(BaseModel):
|
||||
"""查询媒体服务器最近入库影片工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
server: Optional[str] = Field(
|
||||
None,
|
||||
description="Media server name (optional, if not specified queries all enabled media servers)",
|
||||
|
||||
@@ -21,8 +21,6 @@ from app.log import logger
|
||||
class QueryMarketPluginsInput(BaseModel):
|
||||
"""查询插件市场工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
query: Optional[str] = Field(
|
||||
None,
|
||||
description="Optional keyword to filter plugin market results by plugin ID, name, description, or author.",
|
||||
|
||||
@@ -18,7 +18,6 @@ SEASON_PREVIEW_LIMIT = 100
|
||||
|
||||
class QueryMediaDetailInput(BaseModel):
|
||||
"""查询媒体详情工具的输入参数模型"""
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
tmdb_id: Optional[int] = Field(None, description="TMDB ID of the media (movie or TV series, can be obtained from search_media tool)")
|
||||
douban_id: Optional[str] = Field(None, description="Douban ID of the media (alternative to tmdb_id)")
|
||||
media_type: str = Field(..., description="Allowed values: movie, tv")
|
||||
|
||||
@@ -14,8 +14,6 @@ from app.log import logger
|
||||
class QueryPersonasInput(BaseModel):
|
||||
"""查询人格工具的输入参数模型。"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
query: Optional[str] = Field(
|
||||
None,
|
||||
description=(
|
||||
|
||||
@@ -14,8 +14,6 @@ from app.log import logger
|
||||
class QueryPluginCapabilitiesInput(BaseModel):
|
||||
"""查询插件能力工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
plugin_id: Optional[str] = Field(
|
||||
None,
|
||||
description="Optional plugin ID to query capabilities for a specific plugin. "
|
||||
|
||||
@@ -15,8 +15,6 @@ from app.log import logger
|
||||
class QueryPluginConfigInput(BaseModel):
|
||||
"""查询插件配置工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
plugin_id: str = Field(
|
||||
...,
|
||||
description="The plugin ID to query. Use query_installed_plugins first to discover valid plugin IDs.",
|
||||
|
||||
@@ -19,8 +19,6 @@ from app.log import logger
|
||||
class QueryPluginDataInput(BaseModel):
|
||||
"""查询插件数据工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
plugin_id: str = Field(
|
||||
...,
|
||||
description="The plugin ID to query. Use query_installed_plugins first to discover valid plugin IDs.",
|
||||
|
||||
@@ -18,7 +18,6 @@ MAX_PAGE_SIZE = 50
|
||||
|
||||
class QueryPopularSubscribesInput(BaseModel):
|
||||
"""查询热门订阅工具的输入参数模型"""
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
media_type: str = Field(..., description="Allowed values: movie, tv")
|
||||
page: Optional[int] = Field(1, description="Page number for pagination (default: 1)")
|
||||
count: Optional[int] = Field(30, description="Number of items per page (default: 30, max: 50)")
|
||||
|
||||
@@ -19,8 +19,6 @@ from app.log import logger
|
||||
class QueryRuleGroupsInput(BaseModel):
|
||||
"""查询规则组工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
group_names: Optional[List[str]] = Field(
|
||||
None,
|
||||
description="Optional list of rule group names to query. If omitted, return all rule groups.",
|
||||
|
||||
@@ -12,9 +12,6 @@ from app.log import logger
|
||||
|
||||
class QuerySchedulersInput(BaseModel):
|
||||
"""查询定时服务工具的输入参数模型"""
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
|
||||
|
||||
class QuerySchedulersTool(MoviePilotTool):
|
||||
name: str = "query_schedulers"
|
||||
tags: list[str] = [
|
||||
|
||||
@@ -7,9 +7,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.db import AsyncSessionFactory
|
||||
from app.db.models.site import Site
|
||||
from app.db.models.siteuserdata import SiteUserData
|
||||
from app.db.site_oper import SiteOper
|
||||
from app.log import logger
|
||||
|
||||
SITE_USERDATA_DETAIL_PREVIEW_LIMIT = 10
|
||||
@@ -24,8 +22,6 @@ def _preview_list(value, limit: int = SITE_USERDATA_DETAIL_PREVIEW_LIMIT) -> tup
|
||||
class QuerySiteUserdataInput(BaseModel):
|
||||
"""查询站点用户数据工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
site_id: int = Field(
|
||||
...,
|
||||
description="The ID of the site to query user data for (can be obtained from query_sites tool)",
|
||||
@@ -66,118 +62,115 @@ class QuerySiteUserdataTool(MoviePilotTool):
|
||||
)
|
||||
|
||||
try:
|
||||
# 获取数据库会话
|
||||
async with AsyncSessionFactory() as db:
|
||||
# 获取站点
|
||||
site = await Site.async_get(db, site_id)
|
||||
if not site:
|
||||
return json.dumps(
|
||||
{"success": False, "message": f"站点不存在: {site_id}"},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
# 获取站点用户数据
|
||||
user_data_list = await SiteUserData.async_get_by_domain(
|
||||
db, domain=site.domain, workdate=workdate
|
||||
site_oper = SiteOper()
|
||||
site = await site_oper.async_get(site_id)
|
||||
if not site:
|
||||
return json.dumps(
|
||||
{"success": False, "message": f"站点不存在: {site_id}"},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
if not user_data_list:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"站点 {site.name} ({site.domain}) 暂无用户数据",
|
||||
"site_id": site_id,
|
||||
"site_name": site.name,
|
||||
"site_domain": site.domain,
|
||||
"workdate": workdate,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
user_data_list = await site_oper.async_get_userdata_by_domain(
|
||||
domain=site.domain, workdate=workdate
|
||||
)
|
||||
|
||||
# 格式化用户数据
|
||||
result = {
|
||||
"success": True,
|
||||
"site_id": site_id,
|
||||
"site_name": site.name,
|
||||
"site_domain": site.domain,
|
||||
"workdate": workdate,
|
||||
"data_count": len(user_data_list),
|
||||
"user_data": [],
|
||||
if not user_data_list:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"站点 {site.name} ({site.domain}) 暂无用户数据",
|
||||
"site_id": site_id,
|
||||
"site_name": site.name,
|
||||
"site_domain": site.domain,
|
||||
"workdate": workdate,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
# 格式化用户数据
|
||||
result = {
|
||||
"success": True,
|
||||
"site_id": site_id,
|
||||
"site_name": site.name,
|
||||
"site_domain": site.domain,
|
||||
"workdate": workdate,
|
||||
"data_count": len(user_data_list),
|
||||
"user_data": [],
|
||||
}
|
||||
|
||||
for user_data in user_data_list:
|
||||
# 格式化上传/下载量(转换为可读格式)
|
||||
upload_gb = user_data.upload / (1024**3) if user_data.upload else 0
|
||||
download_gb = (
|
||||
user_data.download / (1024**3) if user_data.download else 0
|
||||
)
|
||||
seeding_size_gb = (
|
||||
user_data.seeding_size / (1024**3)
|
||||
if user_data.seeding_size
|
||||
else 0
|
||||
)
|
||||
leeching_size_gb = (
|
||||
user_data.leeching_size / (1024**3)
|
||||
if user_data.leeching_size
|
||||
else 0
|
||||
)
|
||||
|
||||
seeding_preview, seeding_count, seeding_truncated = _preview_list(
|
||||
user_data.seeding_info
|
||||
)
|
||||
unread_preview, unread_count, unread_truncated = _preview_list(
|
||||
user_data.message_unread_contents
|
||||
)
|
||||
|
||||
user_data_dict = {
|
||||
"domain": user_data.domain,
|
||||
"name": user_data.name,
|
||||
"username": user_data.username,
|
||||
"userid": user_data.userid,
|
||||
"user_level": user_data.user_level,
|
||||
"join_at": user_data.join_at,
|
||||
"bonus": user_data.bonus,
|
||||
"upload": user_data.upload,
|
||||
"upload_gb": round(upload_gb, 2),
|
||||
"download": user_data.download,
|
||||
"download_gb": round(download_gb, 2),
|
||||
"ratio": round(user_data.ratio, 2) if user_data.ratio else 0,
|
||||
"seeding": int(user_data.seeding) if user_data.seeding else 0,
|
||||
"leeching": int(user_data.leeching)
|
||||
if user_data.leeching
|
||||
else 0,
|
||||
"seeding_size": user_data.seeding_size,
|
||||
"seeding_size_gb": round(seeding_size_gb, 2),
|
||||
"leeching_size": user_data.leeching_size,
|
||||
"leeching_size_gb": round(leeching_size_gb, 2),
|
||||
"seeding_info_count": seeding_count,
|
||||
"seeding_info": seeding_preview,
|
||||
"seeding_info_truncated": seeding_truncated,
|
||||
"message_unread": user_data.message_unread,
|
||||
"message_unread_contents_count": unread_count,
|
||||
"message_unread_contents": unread_preview,
|
||||
"message_unread_contents_truncated": unread_truncated,
|
||||
"err_msg": user_data.err_msg,
|
||||
"updated_day": user_data.updated_day,
|
||||
"updated_time": user_data.updated_time,
|
||||
}
|
||||
result["user_data"].append(user_data_dict)
|
||||
|
||||
for user_data in user_data_list:
|
||||
# 格式化上传/下载量(转换为可读格式)
|
||||
upload_gb = user_data.upload / (1024**3) if user_data.upload else 0
|
||||
download_gb = (
|
||||
user_data.download / (1024**3) if user_data.download else 0
|
||||
)
|
||||
seeding_size_gb = (
|
||||
user_data.seeding_size / (1024**3)
|
||||
if user_data.seeding_size
|
||||
else 0
|
||||
)
|
||||
leeching_size_gb = (
|
||||
user_data.leeching_size / (1024**3)
|
||||
if user_data.leeching_size
|
||||
else 0
|
||||
)
|
||||
# 如果有多条数据,只返回最新的(按更新时间排序)
|
||||
if len(result["user_data"]) > 1:
|
||||
result["user_data"].sort(
|
||||
key=lambda x: (
|
||||
x.get("updated_day", ""),
|
||||
x.get("updated_time", ""),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
result["message"] = (
|
||||
f"找到 {len(result['user_data'])} 条数据,显示最新的一条"
|
||||
)
|
||||
result["user_data"] = [result["user_data"][0]]
|
||||
|
||||
seeding_preview, seeding_count, seeding_truncated = _preview_list(
|
||||
user_data.seeding_info
|
||||
)
|
||||
unread_preview, unread_count, unread_truncated = _preview_list(
|
||||
user_data.message_unread_contents
|
||||
)
|
||||
|
||||
user_data_dict = {
|
||||
"domain": user_data.domain,
|
||||
"name": user_data.name,
|
||||
"username": user_data.username,
|
||||
"userid": user_data.userid,
|
||||
"user_level": user_data.user_level,
|
||||
"join_at": user_data.join_at,
|
||||
"bonus": user_data.bonus,
|
||||
"upload": user_data.upload,
|
||||
"upload_gb": round(upload_gb, 2),
|
||||
"download": user_data.download,
|
||||
"download_gb": round(download_gb, 2),
|
||||
"ratio": round(user_data.ratio, 2) if user_data.ratio else 0,
|
||||
"seeding": int(user_data.seeding) if user_data.seeding else 0,
|
||||
"leeching": int(user_data.leeching)
|
||||
if user_data.leeching
|
||||
else 0,
|
||||
"seeding_size": user_data.seeding_size,
|
||||
"seeding_size_gb": round(seeding_size_gb, 2),
|
||||
"leeching_size": user_data.leeching_size,
|
||||
"leeching_size_gb": round(leeching_size_gb, 2),
|
||||
"seeding_info_count": seeding_count,
|
||||
"seeding_info": seeding_preview,
|
||||
"seeding_info_truncated": seeding_truncated,
|
||||
"message_unread": user_data.message_unread,
|
||||
"message_unread_contents_count": unread_count,
|
||||
"message_unread_contents": unread_preview,
|
||||
"message_unread_contents_truncated": unread_truncated,
|
||||
"err_msg": user_data.err_msg,
|
||||
"updated_day": user_data.updated_day,
|
||||
"updated_time": user_data.updated_time,
|
||||
}
|
||||
result["user_data"].append(user_data_dict)
|
||||
|
||||
# 如果有多条数据,只返回最新的(按更新时间排序)
|
||||
if len(result["user_data"]) > 1:
|
||||
result["user_data"].sort(
|
||||
key=lambda x: (
|
||||
x.get("updated_day", ""),
|
||||
x.get("updated_time", ""),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
result["message"] = (
|
||||
f"找到 {len(result['user_data'])} 条数据,显示最新的一条"
|
||||
)
|
||||
result["user_data"] = [result["user_data"][0]]
|
||||
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
error_message = f"查询站点用户数据失败: {str(e)}"
|
||||
|
||||
@@ -14,8 +14,6 @@ from app.log import logger
|
||||
class QuerySitesInput(BaseModel):
|
||||
"""查询站点工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
status: Optional[str] = Field(
|
||||
"all",
|
||||
description="Filter sites by status: 'active' for enabled sites, 'inactive' for disabled sites, 'all' for all sites",
|
||||
@@ -30,10 +28,12 @@ class QuerySitesTool(MoviePilotTool):
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Site,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = "Query site status and list all configured sites. Shows site name, domain, status, priority, and basic configuration. Site priority (pri): smaller values have higher priority (e.g., pri=1 has higher priority than pri=10)."
|
||||
require_admin: bool = True
|
||||
description: str = (
|
||||
"Query site status and list configured sites. Non-admin users receive a safe view "
|
||||
"that omits sensitive fields: cookie, token, API key and RSS URL. "
|
||||
"Site priority (pri): smaller values have higher priority (e.g., pri=1 has higher priority than pri=10)."
|
||||
)
|
||||
args_schema: Type[BaseModel] = QuerySitesInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
@@ -57,6 +57,7 @@ class QuerySitesTool(MoviePilotTool):
|
||||
) -> str:
|
||||
logger.info(f"执行工具: {self.name}, 参数: status={status}, name={name}")
|
||||
try:
|
||||
is_admin = await self.is_admin_user()
|
||||
site_oper = SiteOper()
|
||||
# 获取所有站点(按优先级排序)
|
||||
sites = await site_oper.async_list()
|
||||
@@ -82,11 +83,25 @@ class QuerySitesTool(MoviePilotTool):
|
||||
"url": s.url,
|
||||
"pri": s.pri,
|
||||
"is_active": s.is_active,
|
||||
"cookie": s.cookie,
|
||||
"downloader": s.downloader,
|
||||
"ua": s.ua,
|
||||
"proxy": s.proxy,
|
||||
"filter": s.filter,
|
||||
"render": s.render,
|
||||
"public": s.public,
|
||||
"note": s.note,
|
||||
"limit_interval": s.limit_interval,
|
||||
"limit_count": s.limit_count,
|
||||
"limit_seconds": s.limit_seconds,
|
||||
"timeout": s.timeout,
|
||||
}
|
||||
if is_admin:
|
||||
simplified.update({
|
||||
"rss": s.rss,
|
||||
"cookie": s.cookie,
|
||||
"apikey": s.apikey,
|
||||
"token": s.token,
|
||||
})
|
||||
simplified_sites.append(simplified)
|
||||
result_json = json.dumps(simplified_sites, ensure_ascii=False, indent=2)
|
||||
return result_json
|
||||
|
||||
@@ -7,8 +7,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.db import AsyncSessionFactory
|
||||
from app.db.models.subscribehistory import SubscribeHistory
|
||||
from app.db.subscribehistory_oper import SubscribeHistoryOper
|
||||
from app.log import logger
|
||||
from app.schemas.types import media_type_to_agent
|
||||
|
||||
@@ -18,8 +17,6 @@ PAGE_SIZE = 20
|
||||
class QuerySubscribeHistoryInput(BaseModel):
|
||||
"""查询订阅历史工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
media_type: Optional[str] = Field(
|
||||
"all", description="Allowed values: movie, tv, all"
|
||||
)
|
||||
@@ -74,88 +71,87 @@ class QuerySubscribeHistoryTool(MoviePilotTool):
|
||||
if media_type not in ["all", "movie", "tv"]:
|
||||
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv', 'all'"
|
||||
|
||||
# 获取数据库会话
|
||||
async with AsyncSessionFactory() as db:
|
||||
if name:
|
||||
# 有名称过滤时,获取足够多的记录在内存中过滤,不分页
|
||||
fetch_count = 500
|
||||
if media_type == "all":
|
||||
movie_history = await SubscribeHistory.async_list_by_type(
|
||||
db, mtype="movie", page=1, count=fetch_count
|
||||
)
|
||||
tv_history = await SubscribeHistory.async_list_by_type(
|
||||
db, mtype="tv", page=1, count=fetch_count
|
||||
)
|
||||
all_history = list(movie_history) + list(tv_history)
|
||||
all_history.sort(key=lambda x: x.date or "", reverse=True)
|
||||
else:
|
||||
all_history = list(
|
||||
await SubscribeHistory.async_list_by_type(
|
||||
db, mtype=media_type, page=1, count=fetch_count
|
||||
)
|
||||
)
|
||||
|
||||
# 按名称过滤
|
||||
name_lower = name.lower()
|
||||
filtered_history = [
|
||||
record
|
||||
for record in all_history
|
||||
if record.name and name_lower in record.name.lower()
|
||||
]
|
||||
|
||||
if not filtered_history:
|
||||
return "未找到相关订阅历史记录"
|
||||
|
||||
# 名称过滤时直接返回所有匹配结果,不分页
|
||||
simplified_records = self._simplify_records(filtered_history)
|
||||
result_json = json.dumps(
|
||||
simplified_records, ensure_ascii=False, indent=2
|
||||
subscribe_history_oper = SubscribeHistoryOper()
|
||||
if name:
|
||||
# 有名称过滤时,获取足够多的记录在内存中过滤,不分页
|
||||
fetch_count = 500
|
||||
if media_type == "all":
|
||||
movie_history = await subscribe_history_oper.async_list_by_type(
|
||||
mtype="movie", page=1, count=fetch_count
|
||||
)
|
||||
return result_json
|
||||
tv_history = await subscribe_history_oper.async_list_by_type(
|
||||
mtype="tv", page=1, count=fetch_count
|
||||
)
|
||||
all_history = list(movie_history) + list(tv_history)
|
||||
all_history.sort(key=lambda x: x.date or "", reverse=True)
|
||||
else:
|
||||
# 无名称过滤时,直接利用数据库分页
|
||||
if media_type == "all":
|
||||
movie_history = await SubscribeHistory.async_list_by_type(
|
||||
db, mtype="movie", page=1, count=page * PAGE_SIZE
|
||||
)
|
||||
tv_history = await SubscribeHistory.async_list_by_type(
|
||||
db, mtype="tv", page=1, count=page * PAGE_SIZE
|
||||
)
|
||||
all_history = list(movie_history) + list(tv_history)
|
||||
all_history.sort(key=lambda x: x.date or "", reverse=True)
|
||||
filtered_history = all_history
|
||||
else:
|
||||
filtered_history = list(
|
||||
await SubscribeHistory.async_list_by_type(
|
||||
db, mtype=media_type, page=1, count=page * PAGE_SIZE
|
||||
)
|
||||
all_history = list(
|
||||
await subscribe_history_oper.async_list_by_type(
|
||||
mtype=media_type, page=1, count=fetch_count
|
||||
)
|
||||
)
|
||||
|
||||
# 按名称过滤
|
||||
name_lower = name.lower()
|
||||
filtered_history = [
|
||||
record
|
||||
for record in all_history
|
||||
if record.name and name_lower in record.name.lower()
|
||||
]
|
||||
|
||||
if not filtered_history:
|
||||
return "未找到相关订阅历史记录"
|
||||
|
||||
# 分页切片
|
||||
total_count = len(filtered_history)
|
||||
start = (page - 1) * PAGE_SIZE
|
||||
end = start + PAGE_SIZE
|
||||
page_records = filtered_history[start:end]
|
||||
|
||||
if not page_records:
|
||||
return f"第 {page} 页没有数据。"
|
||||
|
||||
simplified_records = self._simplify_records(page_records)
|
||||
# 名称过滤时直接返回所有匹配结果,不分页
|
||||
simplified_records = self._simplify_records(filtered_history)
|
||||
result_json = json.dumps(
|
||||
simplified_records, ensure_ascii=False, indent=2
|
||||
)
|
||||
|
||||
has_more = total_count > end
|
||||
payload_msg = f"第 {page} 页,当前页 {len(simplified_records)} 条结果。"
|
||||
if has_more:
|
||||
payload_msg += (
|
||||
f" 可能有更多数据,可使用 page={page + 1} 获取下一页。"
|
||||
return result_json
|
||||
else:
|
||||
# 无名称过滤时,直接利用数据库分页
|
||||
if media_type == "all":
|
||||
movie_history = await subscribe_history_oper.async_list_by_type(
|
||||
mtype="movie", page=1, count=page * PAGE_SIZE
|
||||
)
|
||||
tv_history = await subscribe_history_oper.async_list_by_type(
|
||||
mtype="tv", page=1, count=page * PAGE_SIZE
|
||||
)
|
||||
all_history = list(movie_history) + list(tv_history)
|
||||
all_history.sort(key=lambda x: x.date or "", reverse=True)
|
||||
filtered_history = all_history
|
||||
else:
|
||||
filtered_history = list(
|
||||
await subscribe_history_oper.async_list_by_type(
|
||||
mtype=media_type, page=1, count=page * PAGE_SIZE
|
||||
)
|
||||
)
|
||||
|
||||
return f"{payload_msg}\n\n{result_json}"
|
||||
if not filtered_history:
|
||||
return "未找到相关订阅历史记录"
|
||||
|
||||
# 分页切片
|
||||
total_count = len(filtered_history)
|
||||
start = (page - 1) * PAGE_SIZE
|
||||
end = start + PAGE_SIZE
|
||||
page_records = filtered_history[start:end]
|
||||
|
||||
if not page_records:
|
||||
return f"第 {page} 页没有数据。"
|
||||
|
||||
simplified_records = self._simplify_records(page_records)
|
||||
result_json = json.dumps(
|
||||
simplified_records, ensure_ascii=False, indent=2
|
||||
)
|
||||
|
||||
has_more = total_count > end
|
||||
payload_msg = f"第 {page} 页,当前页 {len(simplified_records)} 条结果。"
|
||||
if has_more:
|
||||
payload_msg += (
|
||||
f" 可能有更多数据,可使用 page={page + 1} 获取下一页。"
|
||||
)
|
||||
|
||||
return f"{payload_msg}\n\n{result_json}"
|
||||
except Exception as e:
|
||||
logger.error(f"查询订阅历史失败: {e}", exc_info=True)
|
||||
return f"查询订阅历史时发生错误: {str(e)}"
|
||||
|
||||
@@ -15,7 +15,6 @@ MAX_PAGE_SIZE = 50
|
||||
|
||||
class QuerySubscribeSharesInput(BaseModel):
|
||||
"""查询订阅分享工具的输入参数模型"""
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
name: Optional[str] = Field(None, description="Filter shares by media name (partial match, optional)")
|
||||
page: Optional[int] = Field(1, description="Page number for pagination (default: 1)")
|
||||
count: Optional[int] = Field(30, description="Number of items per page (default: 30, max: 50)")
|
||||
|
||||
@@ -48,8 +48,6 @@ QUERY_SUBSCRIBE_OUTPUT_FIELDS = [
|
||||
class QuerySubscribesInput(BaseModel):
|
||||
"""查询订阅工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
status: Optional[str] = Field(
|
||||
"all",
|
||||
description="Filter subscriptions by status: 'R' for enabled subscriptions, 'S' for paused ones, 'all' for all subscriptions",
|
||||
|
||||
@@ -9,8 +9,11 @@ from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.agent.tools.impl._system_setting_utils import (
|
||||
SettingSpec,
|
||||
is_secret_setting_key,
|
||||
list_setting_specs,
|
||||
redact_secret_value,
|
||||
resolve_setting_spec,
|
||||
should_redact_setting,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
@@ -20,8 +23,6 @@ from app.log import logger
|
||||
class QuerySystemSettingsInput(BaseModel):
|
||||
"""查询系统设置工具的输入参数模型。"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
setting_key: Optional[str] = Field(
|
||||
None,
|
||||
description=(
|
||||
@@ -53,6 +54,13 @@ class QuerySystemSettingsInput(BaseModel):
|
||||
"when multiple settings are matched it returns summaries only unless this is explicitly set to true."
|
||||
),
|
||||
)
|
||||
show_secrets: Optional[bool] = Field(
|
||||
False,
|
||||
description=(
|
||||
"Whether to return raw secret values such as API keys, tokens, cookies, and passwords. "
|
||||
"Defaults to false; secret-like fields are redacted in returned values and previews."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class QuerySystemSettingsTool(MoviePilotTool):
|
||||
@@ -85,15 +93,18 @@ class QuerySystemSettingsTool(MoviePilotTool):
|
||||
|
||||
@staticmethod
|
||||
def _load_setting_value(spec: SettingSpec):
|
||||
"""读取指定设置项的当前值。"""
|
||||
if spec.source == "settings":
|
||||
return getattr(settings, spec.key)
|
||||
return SystemConfigOper().get(spec.key)
|
||||
return SystemConfigOper().get(spec.systemconfig_key)
|
||||
|
||||
@staticmethod
|
||||
def _summarize_value(value) -> dict:
|
||||
def _summarize_value(value, *, redacted: bool = False) -> dict:
|
||||
"""生成设置值摘要,避免列表和字典默认输出过长。"""
|
||||
summary = {
|
||||
"has_value": value is not None,
|
||||
"value_type": type(value).__name__,
|
||||
"redacted": redacted,
|
||||
}
|
||||
if isinstance(value, list):
|
||||
summary["item_count"] = len(value)
|
||||
@@ -122,14 +133,12 @@ class QuerySystemSettingsTool(MoviePilotTool):
|
||||
group: Optional[str] = "all",
|
||||
keyword: Optional[str] = None,
|
||||
include_values: Optional[bool] = None,
|
||||
show_secrets: Optional[bool] = False,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
logger.info(
|
||||
"执行工具: %s, setting_key=%s, group=%s, keyword=%s",
|
||||
self.name,
|
||||
setting_key,
|
||||
group,
|
||||
keyword,
|
||||
f"执行工具: {self.name}, setting_key={setting_key}, "
|
||||
f"group={group}, keyword={keyword}"
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -158,18 +167,30 @@ class QuerySystemSettingsTool(MoviePilotTool):
|
||||
should_include_values = (
|
||||
include_values if include_values is not None else len(specs) == 1
|
||||
)
|
||||
allow_secret_values = bool(show_secrets) and await self.is_admin_user()
|
||||
settings_payload = []
|
||||
for spec in specs:
|
||||
value = self._load_setting_value(spec)
|
||||
should_redact = (
|
||||
should_redact_setting(spec, value) and not allow_secret_values
|
||||
)
|
||||
response_value = (
|
||||
redact_secret_value(
|
||||
value,
|
||||
redact_scalar=is_secret_setting_key(spec.key),
|
||||
)
|
||||
if should_redact
|
||||
else value
|
||||
)
|
||||
item = {
|
||||
"setting_key": spec.key,
|
||||
"source": spec.source,
|
||||
"group": spec.group,
|
||||
"label": spec.label,
|
||||
}
|
||||
item.update(self._summarize_value(value))
|
||||
item.update(self._summarize_value(response_value, redacted=should_redact))
|
||||
if should_include_values:
|
||||
item["value"] = value
|
||||
item["value"] = response_value
|
||||
settings_payload.append(item)
|
||||
|
||||
return json.dumps(
|
||||
@@ -177,6 +198,7 @@ class QuerySystemSettingsTool(MoviePilotTool):
|
||||
"success": True,
|
||||
"matched_count": len(settings_payload),
|
||||
"include_values": should_include_values,
|
||||
"show_secrets": allow_secret_values,
|
||||
"settings": settings_payload,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
|
||||
@@ -7,8 +7,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.db import AsyncSessionFactory
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
from app.db.transferhistory_oper import TransferHistoryOper
|
||||
from app.log import logger
|
||||
from app.schemas.types import media_type_to_agent
|
||||
from app.utils.jieba import cut as jieba_cut
|
||||
@@ -16,7 +15,6 @@ from app.utils.jieba import cut as jieba_cut
|
||||
|
||||
class QueryTransferHistoryInput(BaseModel):
|
||||
"""查询整理历史记录工具的输入参数模型"""
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
title: Optional[str] = Field(None, description="Search by title (optional, supports partial match)")
|
||||
status: Optional[str] = Field("all",
|
||||
description="Filter by status: 'success' for successful transfers, 'failed' for failed transfers, 'all' for all records (default: 'all')")
|
||||
@@ -70,70 +68,69 @@ class QueryTransferHistoryTool(MoviePilotTool):
|
||||
# 每页固定 30 条,与工具说明保持一致,避免整理路径等字段撑大上下文。
|
||||
count = 30
|
||||
|
||||
# 获取数据库会话
|
||||
async with AsyncSessionFactory() as db:
|
||||
# 处理标题搜索
|
||||
if title:
|
||||
# 使用统一分词封装处理标题,便于替换底层实现。
|
||||
words = jieba_cut(title, HMM=False)
|
||||
title_search = "%".join(words)
|
||||
# 查询记录
|
||||
result = await TransferHistory.async_list_by_title(
|
||||
db, title=title_search, page=page, count=count, status=status_bool
|
||||
)
|
||||
total = await TransferHistory.async_count_by_title(
|
||||
db, title=title_search, status=status_bool
|
||||
)
|
||||
else:
|
||||
# 查询所有记录
|
||||
result = await TransferHistory.async_list_by_page(
|
||||
db, page=page, count=count, status=status_bool
|
||||
)
|
||||
total = await TransferHistory.async_count(db, status=status_bool)
|
||||
transferhis = TransferHistoryOper()
|
||||
# 处理标题搜索
|
||||
if title:
|
||||
# 使用统一分词封装处理标题,便于替换底层实现。
|
||||
words = jieba_cut(title, HMM=False)
|
||||
title_search = "%".join(words)
|
||||
# 查询记录
|
||||
result = await transferhis.async_list_by_title(
|
||||
title=title_search, page=page, count=count, status=status_bool
|
||||
)
|
||||
total = await transferhis.async_count_by_title(
|
||||
title=title_search, status=status_bool
|
||||
)
|
||||
else:
|
||||
# 查询所有记录
|
||||
result = await transferhis.async_list_by_page(
|
||||
page=page, count=count, status=status_bool
|
||||
)
|
||||
total = await transferhis.async_count(status=status_bool)
|
||||
|
||||
if not result:
|
||||
return "未找到相关整理历史记录"
|
||||
if not result:
|
||||
return "未找到相关整理历史记录"
|
||||
|
||||
# 转换为字典格式,只保留关键信息
|
||||
simplified_records = []
|
||||
for record in result:
|
||||
simplified = {
|
||||
"id": record.id,
|
||||
"title": record.title,
|
||||
"year": record.year,
|
||||
"type": media_type_to_agent(record.type),
|
||||
"category": record.category,
|
||||
"seasons": record.seasons,
|
||||
"episodes": record.episodes,
|
||||
"src": record.src,
|
||||
"dest": record.dest,
|
||||
"mode": record.mode,
|
||||
"status": "成功" if record.status else "失败",
|
||||
"date": record.date,
|
||||
"downloader": record.downloader,
|
||||
"download_hash": record.download_hash
|
||||
}
|
||||
# 如果失败,添加错误信息
|
||||
if not record.status and record.errmsg:
|
||||
simplified["errmsg"] = record.errmsg
|
||||
# 添加媒体ID信息(如果有)
|
||||
if record.tmdbid:
|
||||
simplified["tmdbid"] = record.tmdbid
|
||||
if record.imdbid:
|
||||
simplified["imdbid"] = record.imdbid
|
||||
if record.doubanid:
|
||||
simplified["doubanid"] = record.doubanid
|
||||
simplified_records.append(simplified)
|
||||
# 转换为字典格式,只保留关键信息
|
||||
simplified_records = []
|
||||
for record in result:
|
||||
simplified = {
|
||||
"id": record.id,
|
||||
"title": record.title,
|
||||
"year": record.year,
|
||||
"type": media_type_to_agent(record.type),
|
||||
"category": record.category,
|
||||
"seasons": record.seasons,
|
||||
"episodes": record.episodes,
|
||||
"src": record.src,
|
||||
"dest": record.dest,
|
||||
"mode": record.mode,
|
||||
"status": "成功" if record.status else "失败",
|
||||
"date": record.date,
|
||||
"downloader": record.downloader,
|
||||
"download_hash": record.download_hash
|
||||
}
|
||||
# 如果失败,添加错误信息
|
||||
if not record.status and record.errmsg:
|
||||
simplified["errmsg"] = record.errmsg
|
||||
# 添加媒体ID信息(如果有)
|
||||
if record.tmdbid:
|
||||
simplified["tmdbid"] = record.tmdbid
|
||||
if record.imdbid:
|
||||
simplified["imdbid"] = record.imdbid
|
||||
if record.doubanid:
|
||||
simplified["doubanid"] = record.doubanid
|
||||
simplified_records.append(simplified)
|
||||
|
||||
result_json = json.dumps(simplified_records, ensure_ascii=False, indent=2)
|
||||
result_json = json.dumps(simplified_records, ensure_ascii=False, indent=2)
|
||||
|
||||
# 计算总页数
|
||||
total_pages = (total + count - 1) // count if total > 0 else 1
|
||||
# 计算总页数
|
||||
total_pages = (total + count - 1) // count if total > 0 else 1
|
||||
|
||||
# 构建分页信息
|
||||
pagination_info = f"第 {page}/{total_pages} 页,共 {total} 条记录(每页 {count} 条)"
|
||||
# 构建分页信息
|
||||
pagination_info = f"第 {page}/{total_pages} 页,共 {total} 条记录(每页 {count} 条)"
|
||||
|
||||
return f"{pagination_info}\n\n{result_json}"
|
||||
return f"{pagination_info}\n\n{result_json}"
|
||||
except Exception as e:
|
||||
logger.error(f"查询整理历史记录失败: {e}", exc_info=True)
|
||||
return f"查询整理历史记录时发生错误: {str(e)}"
|
||||
|
||||
@@ -7,14 +7,12 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.db import AsyncSessionFactory
|
||||
from app.db.workflow_oper import WorkflowOper
|
||||
from app.log import logger
|
||||
|
||||
|
||||
class QueryWorkflowsInput(BaseModel):
|
||||
"""查询工作流工具的输入参数模型"""
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
state: Optional[str] = Field("all", description="Filter workflows by state: 'W' for waiting, 'R' for running, 'P' for paused, 'S' for success, 'F' for failed, 'all' for all workflows (default: 'all')")
|
||||
name: Optional[str] = Field(None, description="Filter workflows by name (partial match, optional)")
|
||||
trigger_type: Optional[str] = Field("all", description="Filter workflows by trigger type: 'timer' for scheduled, 'event' for event-triggered, 'manual' for manual, 'all' for all types (default: 'all')")
|
||||
@@ -56,75 +54,73 @@ class QueryWorkflowsTool(MoviePilotTool):
|
||||
logger.info(f"执行工具: {self.name}, 参数: state={state}, name={name}, trigger_type={trigger_type}")
|
||||
|
||||
try:
|
||||
# 获取数据库会话
|
||||
async with AsyncSessionFactory() as db:
|
||||
workflow_oper = WorkflowOper(db)
|
||||
workflows = await workflow_oper.async_list()
|
||||
|
||||
# 过滤工作流
|
||||
filtered_workflows = []
|
||||
for wf in workflows:
|
||||
# 按状态过滤
|
||||
if state != "all" and wf.state != state:
|
||||
workflow_oper = WorkflowOper()
|
||||
workflows = await workflow_oper.async_list()
|
||||
|
||||
# 过滤工作流
|
||||
filtered_workflows = []
|
||||
for wf in workflows:
|
||||
# 按状态过滤
|
||||
if state != "all" and wf.state != state:
|
||||
continue
|
||||
|
||||
# 按触发类型过滤
|
||||
if trigger_type != "all":
|
||||
if trigger_type == "timer" and wf.trigger_type not in ["timer", None]:
|
||||
continue
|
||||
|
||||
# 按触发类型过滤
|
||||
if trigger_type != "all":
|
||||
if trigger_type == "timer" and wf.trigger_type not in ["timer", None]:
|
||||
continue
|
||||
elif trigger_type == "event" and wf.trigger_type != "event":
|
||||
continue
|
||||
elif trigger_type == "manual" and wf.trigger_type != "manual":
|
||||
continue
|
||||
|
||||
# 按名称过滤(部分匹配)
|
||||
if name and wf.name and name.lower() not in wf.name.lower():
|
||||
elif trigger_type == "event" and wf.trigger_type != "event":
|
||||
continue
|
||||
|
||||
filtered_workflows.append(wf)
|
||||
|
||||
if not filtered_workflows:
|
||||
return "未找到相关工作流"
|
||||
|
||||
# 转换为字典格式,只保留关键信息
|
||||
simplified_workflows = []
|
||||
for wf in filtered_workflows:
|
||||
# 状态说明
|
||||
state_map = {
|
||||
"W": "等待",
|
||||
"R": "运行中",
|
||||
"P": "暂停",
|
||||
"S": "成功",
|
||||
"F": "失败"
|
||||
}
|
||||
state_desc = state_map.get(wf.state, wf.state)
|
||||
|
||||
# 触发类型说明
|
||||
trigger_type_map = {
|
||||
"timer": "定时触发",
|
||||
"event": "事件触发",
|
||||
"manual": "手动触发"
|
||||
}
|
||||
trigger_type_desc = trigger_type_map.get(wf.trigger_type, wf.trigger_type or "定时触发")
|
||||
|
||||
simplified = {
|
||||
"id": wf.id,
|
||||
"name": wf.name,
|
||||
"description": wf.description,
|
||||
"trigger_type": trigger_type_desc,
|
||||
"state": state_desc,
|
||||
"run_count": wf.run_count,
|
||||
"timer": wf.timer,
|
||||
"event_type": wf.event_type,
|
||||
"add_time": wf.add_time,
|
||||
"last_time": wf.last_time,
|
||||
"current_action": wf.current_action
|
||||
}
|
||||
# wf.result 往往是执行日志或上下文快照,不适合作为列表查询结果返回。
|
||||
simplified_workflows.append(simplified)
|
||||
|
||||
result_json = json.dumps(simplified_workflows, ensure_ascii=False, indent=2)
|
||||
return result_json
|
||||
elif trigger_type == "manual" and wf.trigger_type != "manual":
|
||||
continue
|
||||
|
||||
# 按名称过滤(部分匹配)
|
||||
if name and wf.name and name.lower() not in wf.name.lower():
|
||||
continue
|
||||
|
||||
filtered_workflows.append(wf)
|
||||
|
||||
if not filtered_workflows:
|
||||
return "未找到相关工作流"
|
||||
|
||||
# 转换为字典格式,只保留关键信息
|
||||
simplified_workflows = []
|
||||
for wf in filtered_workflows:
|
||||
# 状态说明
|
||||
state_map = {
|
||||
"W": "等待",
|
||||
"R": "运行中",
|
||||
"P": "暂停",
|
||||
"S": "成功",
|
||||
"F": "失败"
|
||||
}
|
||||
state_desc = state_map.get(wf.state, wf.state)
|
||||
|
||||
# 触发类型说明
|
||||
trigger_type_map = {
|
||||
"timer": "定时触发",
|
||||
"event": "事件触发",
|
||||
"manual": "手动触发"
|
||||
}
|
||||
trigger_type_desc = trigger_type_map.get(wf.trigger_type, wf.trigger_type or "定时触发")
|
||||
|
||||
simplified = {
|
||||
"id": wf.id,
|
||||
"name": wf.name,
|
||||
"description": wf.description,
|
||||
"trigger_type": trigger_type_desc,
|
||||
"state": state_desc,
|
||||
"run_count": wf.run_count,
|
||||
"timer": wf.timer,
|
||||
"event_type": wf.event_type,
|
||||
"add_time": wf.add_time,
|
||||
"last_time": wf.last_time,
|
||||
"current_action": wf.current_action
|
||||
}
|
||||
# wf.result 往往是执行日志或上下文快照,不适合作为列表查询结果返回。
|
||||
simplified_workflows.append(simplified)
|
||||
|
||||
result_json = json.dumps(simplified_workflows, ensure_ascii=False, indent=2)
|
||||
return result_json
|
||||
except Exception as e:
|
||||
logger.error(f"查询工作流失败: {e}", exc_info=True)
|
||||
return f"查询工作流时发生错误: {str(e)}"
|
||||
|
||||
@@ -15,7 +15,7 @@ MAX_READ_SIZE = 50 * 1024
|
||||
|
||||
|
||||
class ReadFileInput(BaseModel):
|
||||
"""Input parameters for read file tool"""
|
||||
"""文件读取工具的输入参数模型。"""
|
||||
file_path: str = Field(..., description="The absolute path of the file to read")
|
||||
start_line: Optional[int] = Field(None, description="The starting line number (1-based, inclusive). If not provided, reading starts from the beginning of the file.")
|
||||
end_line: Optional[int] = Field(None, description="The ending line number (1-based, inclusive). If not provided, reading goes until the end of the file.")
|
||||
@@ -41,15 +41,21 @@ class ReadFileTool(MoviePilotTool):
|
||||
logger.info(f"执行工具: {self.name}, 参数: file_path={file_path}, start_line={start_line}, end_line={end_line}")
|
||||
|
||||
try:
|
||||
path = AsyncPath(file_path)
|
||||
resolved_path, access_error = await self._check_local_file_access(
|
||||
file_path, operation="读取"
|
||||
)
|
||||
if access_error:
|
||||
return access_error
|
||||
|
||||
path = AsyncPath(resolved_path)
|
||||
|
||||
if not await path.exists():
|
||||
return f"错误:文件 {file_path} 不存在"
|
||||
return f"错误:文件 {resolved_path} 不存在"
|
||||
|
||||
if not await path.is_file():
|
||||
return f"错误:{file_path} 不是一个文件"
|
||||
return f"错误:{resolved_path} 不是一个文件"
|
||||
|
||||
content = await path.read_text(encoding="utf-8")
|
||||
content = await path.read_text(encoding="utf-8", errors="replace")
|
||||
truncated = False
|
||||
|
||||
if start_line is not None or end_line is not None:
|
||||
@@ -69,7 +75,7 @@ class ReadFileTool(MoviePilotTool):
|
||||
# 检查大小限制
|
||||
content_bytes = content.encode("utf-8")
|
||||
if len(content_bytes) > MAX_READ_SIZE:
|
||||
content = content_bytes[:MAX_READ_SIZE].decode("utf-8", errors="ignore")
|
||||
content = content_bytes[:MAX_READ_SIZE].decode("utf-8", errors="replace")
|
||||
truncated = True
|
||||
|
||||
if truncated:
|
||||
|
||||
182
app/agent/tools/impl/recognize_captcha.py
Normal file
182
app/agent/tools/impl/recognize_captcha.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""识别图形验证码工具。"""
|
||||
|
||||
import json
|
||||
from typing import Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.helper.browser import BrowserSessionHelper
|
||||
from app.helper.ocr import OcrHelper
|
||||
from app.log import logger
|
||||
|
||||
|
||||
class RecognizeCaptchaInput(BaseModel):
|
||||
"""识别图形验证码工具的输入参数模型。"""
|
||||
|
||||
image_url: str = Field(
|
||||
...,
|
||||
description=(
|
||||
"Captcha image URL obtained from the browser page, usually an img.src value. "
|
||||
"Supports http/https URLs and data:image/...;base64,... URLs."
|
||||
),
|
||||
)
|
||||
cookie: Optional[str] = Field(
|
||||
None,
|
||||
description=(
|
||||
"Optional Cookie header used to download the captcha image when the image URL "
|
||||
"requires the same authenticated browser session."
|
||||
),
|
||||
)
|
||||
user_agent: Optional[str] = Field(
|
||||
None,
|
||||
description="Optional User-Agent used when downloading the captcha image.",
|
||||
)
|
||||
allow_private_network: bool = Field(
|
||||
False,
|
||||
description="Allow captcha image URLs on localhost, loopback, private, or link-local addresses.",
|
||||
)
|
||||
|
||||
|
||||
class RecognizeCaptchaTool(MoviePilotTool):
|
||||
"""
|
||||
图形验证码识别工具,供 Agent 在浏览器自动化登录时读取验证码文本。
|
||||
"""
|
||||
|
||||
name: str = "recognize_captcha"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Web,
|
||||
ToolTag.Site,
|
||||
]
|
||||
description: str = (
|
||||
"Recognize a graphic captcha image and return the captcha text. "
|
||||
"Use this after browser automation extracts a captcha img.src from the page. "
|
||||
"Pass cookie and user_agent when the image URL requires the current browser session. "
|
||||
"Supports http/https image URLs and data:image/...;base64,... URLs. "
|
||||
"For safety, localhost and private network URLs are blocked by default unless "
|
||||
"allow_private_network is true."
|
||||
)
|
||||
args_schema: Type[BaseModel] = RecognizeCaptchaInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""根据验证码图片参数生成友好的提示消息。"""
|
||||
image_url = str(kwargs.get("image_url") or "")
|
||||
if image_url.lower().startswith("data:image/"):
|
||||
return "识别图形验证码: data image"
|
||||
return f"识别图形验证码: {image_url}"
|
||||
|
||||
@staticmethod
|
||||
def _format_image_url_for_log(image_url: str) -> str:
|
||||
"""生成验证码图片地址的安全日志摘要,避免 data URL 图片刷屏。"""
|
||||
clean_url = (image_url or "").strip()
|
||||
if not clean_url:
|
||||
return ""
|
||||
if clean_url.lower().startswith("data:image/"):
|
||||
metadata, separator, data = clean_url.partition(",")
|
||||
if separator:
|
||||
return f"{metadata},<base64:{len(data)} chars>"
|
||||
return f"data:image,<invalid:{len(clean_url)} chars>"
|
||||
if len(clean_url) > 300:
|
||||
return f"{clean_url[:300]}...(已截断,总长度: {len(clean_url)})"
|
||||
return clean_url
|
||||
|
||||
@staticmethod
|
||||
def _recognize_captcha_sync(
|
||||
image_url: str,
|
||||
cookie: Optional[str] = None,
|
||||
user_agent: Optional[str] = None,
|
||||
allow_private_network: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
在线程池中下载并识别验证码图片。
|
||||
|
||||
:param image_url: 验证码图片地址
|
||||
:param cookie: 下载图片时使用的 Cookie
|
||||
:param user_agent: 下载图片时使用的 User-Agent
|
||||
:param allow_private_network: 是否允许访问本机或私网地址
|
||||
:return: 验证码文本,失败时返回空字符串
|
||||
"""
|
||||
clean_url = (image_url or "").strip()
|
||||
if not clean_url:
|
||||
return ""
|
||||
if not clean_url.lower().startswith("data:image/"):
|
||||
BrowserSessionHelper.validate_url(
|
||||
clean_url,
|
||||
allow_private_network=allow_private_network,
|
||||
)
|
||||
return OcrHelper().get_captcha_text(
|
||||
image_url=clean_url,
|
||||
cookie=cookie,
|
||||
ua=user_agent,
|
||||
)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
image_url: str,
|
||||
cookie: Optional[str] = None,
|
||||
user_agent: Optional[str] = None,
|
||||
allow_private_network: bool = False,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""
|
||||
识别指定图片地址中的图形验证码文本。
|
||||
|
||||
:param image_url: 验证码图片地址
|
||||
:param cookie: 下载图片时使用的 Cookie
|
||||
:param user_agent: 下载图片时使用的 User-Agent
|
||||
:param allow_private_network: 是否允许访问本机或私网地址
|
||||
:return: JSON 格式的识别结果
|
||||
"""
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, "
|
||||
f"参数: image_url={self._format_image_url_for_log(image_url)}"
|
||||
)
|
||||
|
||||
try:
|
||||
captcha_text = await self.run_blocking(
|
||||
"web",
|
||||
self._recognize_captcha_sync,
|
||||
image_url,
|
||||
cookie,
|
||||
user_agent,
|
||||
allow_private_network,
|
||||
)
|
||||
if captcha_text:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"captcha_text": captcha_text,
|
||||
"message": "验证码识别成功",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"captcha_text": "",
|
||||
"message": "验证码识别失败或未返回内容",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except ValueError as err:
|
||||
logger.warning(f"验证码图片地址校验失败: {str(err)}")
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"captcha_text": "",
|
||||
"message": str(err),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as err:
|
||||
logger.error(f"识别图形验证码失败: {str(err)}", exc_info=True)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"captcha_text": "",
|
||||
"message": f"识别图形验证码时发生错误: {str(err)}",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
@@ -16,7 +16,6 @@ from app.schemas.types import media_type_to_agent
|
||||
|
||||
class RecognizeMediaInput(BaseModel):
|
||||
"""识别媒体信息工具的输入参数模型"""
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
title: Optional[str] = Field(None, description="The title of the torrent/media to recognize (required for torrent recognition)")
|
||||
subtitle: Optional[str] = Field(None, description="The subtitle or description of the torrent (optional, helps improve recognition accuracy)")
|
||||
path: Optional[str] = Field(None, description="The file path to recognize (required for file recognition, mutually exclusive with title)")
|
||||
|
||||
@@ -17,8 +17,6 @@ from app.log import logger
|
||||
class ReloadPluginInput(BaseModel):
|
||||
"""重载插件工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
plugin_id: str = Field(
|
||||
...,
|
||||
description="The plugin ID to reload so the latest saved config takes effect.",
|
||||
|
||||
@@ -12,8 +12,6 @@ from app.log import logger
|
||||
class RunSchedulerInput(BaseModel):
|
||||
"""运行定时服务工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
job_id: str = Field(
|
||||
...,
|
||||
description="The ID of the scheduled job to run (can be obtained from query_schedulers tool)",
|
||||
|
||||
@@ -15,8 +15,6 @@ from app.schemas.types import EventType, MessageChannel
|
||||
class RunSlashCommandInput(BaseModel):
|
||||
"""运行斜杠命令工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
command: str = Field(
|
||||
...,
|
||||
description="The slash command to execute, e.g. '/cookiecloud'. "
|
||||
|
||||
@@ -7,7 +7,6 @@ from pydantic import BaseModel, Field
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.chain.workflow import WorkflowChain
|
||||
from app.db import AsyncSessionFactory
|
||||
from app.db.workflow_oper import WorkflowOper
|
||||
from app.log import logger
|
||||
|
||||
@@ -15,8 +14,6 @@ from app.log import logger
|
||||
class RunWorkflowInput(BaseModel):
|
||||
"""执行工作流工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
workflow_id: int = Field(
|
||||
..., description="Workflow ID (can be obtained from query_workflows tool)"
|
||||
)
|
||||
@@ -65,26 +62,23 @@ class RunWorkflowTool(MoviePilotTool):
|
||||
)
|
||||
|
||||
try:
|
||||
# 获取数据库会话
|
||||
async with AsyncSessionFactory() as db:
|
||||
workflow_oper = WorkflowOper(db)
|
||||
workflow = await workflow_oper.async_get(workflow_id)
|
||||
workflow = await WorkflowOper().async_get(workflow_id)
|
||||
|
||||
if not workflow:
|
||||
return f"未找到工作流:{workflow_id},请使用 query_workflows 工具查询可用的工作流"
|
||||
if not workflow:
|
||||
return f"未找到工作流:{workflow_id},请使用 query_workflows 工具查询可用的工作流"
|
||||
|
||||
# 工作流执行链路包含大量同步步骤,统一放到 workflow 线程池。
|
||||
state, errmsg = await self.run_blocking(
|
||||
"workflow",
|
||||
self._run_workflow_sync,
|
||||
workflow.id,
|
||||
from_begin,
|
||||
)
|
||||
# 工作流执行链路包含大量同步步骤,统一放到 workflow 线程池。
|
||||
state, errmsg = await self.run_blocking(
|
||||
"workflow",
|
||||
self._run_workflow_sync,
|
||||
workflow.id,
|
||||
from_begin,
|
||||
)
|
||||
|
||||
if not state:
|
||||
return f"执行工作流失败:{workflow.name} (ID: {workflow.id})\n错误原因:{errmsg}"
|
||||
else:
|
||||
return f"工作流执行成功:{workflow.name} (ID: {workflow.id})"
|
||||
if not state:
|
||||
return f"执行工作流失败:{workflow.name} (ID: {workflow.id})\n错误原因:{errmsg}"
|
||||
else:
|
||||
return f"工作流执行成功:{workflow.name} (ID: {workflow.id})"
|
||||
except Exception as e:
|
||||
logger.error(f"执行工作流失败: {e}", exc_info=True)
|
||||
return f"执行工作流时发生错误: {str(e)}"
|
||||
|
||||
@@ -16,8 +16,6 @@ from app.schemas import FileItem
|
||||
class ScrapeMetadataInput(BaseModel):
|
||||
"""刮削媒体元数据工具的输入参数模型"""
|
||||
|
||||
explanation: Optional[str] = Field(None,
|
||||
description="Clear explanation of why this tool is being used in the current context",)
|
||||
path: str = Field(
|
||||
...,
|
||||
description="Path to the file or directory to scrape metadata for (e.g., '/path/to/file.mkv' or '/path/to/directory')",
|
||||
|
||||
@@ -14,7 +14,6 @@ from app.schemas.types import MediaType, media_type_to_agent
|
||||
|
||||
class SearchMediaInput(BaseModel):
|
||||
"""搜索媒体工具的输入参数模型"""
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
title: str = Field(..., description="The title of the media to search for (e.g., 'The Matrix', 'Breaking Bad')")
|
||||
year: Optional[str] = Field(None, description="Release year of the media (optional, helps narrow down results)")
|
||||
media_type: Optional[str] = Field(None,
|
||||
|
||||
@@ -13,7 +13,6 @@ from app.log import logger
|
||||
|
||||
class SearchPersonInput(BaseModel):
|
||||
"""搜索人物工具的输入参数模型"""
|
||||
explanation: Optional[str] = Field(None, description="Clear explanation of why this tool is being used in the current context")
|
||||
name: str = Field(..., description="The name of the person to search for (e.g., 'Tom Hanks', '周杰伦')")
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user