Compare commits

..
5 Commits
Author SHA1 Message Date
LinFei83andCursor 6a02e7de21 MCP get_search_results 增加 include_labels 按需返回种子标签 (#6335)
让 Agent 在筛选命中标签时能按需查看 labels,默认不返回以免拉长上下文。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 17:28:38 +08:00
jxxghp 458c08a137 Update version.py 2026-08-10 13:48:39 +08:00
千石 7012e0e305 feat(storages): 新增 AList 存储类型 (#6245) 2026-08-09 14:40:22 +08:00
ngcat 91ce365f78 fix(transhandler): suppress noisy error notification for TV special and extra sample files without episode numbers (#6247) 2026-08-08 15:30:11 +08:00
jxxghp 17be4304c1 ci: register v3 build workflow 2026-08-07 23:33:20 +08:00
1586 changed files with 54401 additions and 288403 deletions
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+5 -28
View File
@@ -10,17 +10,14 @@ LICENSE
# Development files
.pylintrc
**/*.pyc
**/__pycache__/
**/*.pyo
**/*.pyd
*.pyc
__pycache__/
*.pyo
*.pyd
.Python
**/*.so
*.so
.pytest_cache/
.ruff_cache/
.coverage
coverage.json
coverage.xml
htmlcov/
.tox/
.nox/
@@ -30,31 +27,12 @@ htmlcov/
dmypy.json
# Virtual environments
.venv/
.worktrees/
venv/
env/
ENV/
env.bak/
venv.bak/
# Runtime state and locally synchronized payloads
.build/
.agent-work/
.runtime/
.tmp/
.cache/
node_modules/
public/
.moviepilot.env
.env
.env.*
config/*
!config/category.yaml
app/plugins/**
!app/plugins/__init__.py
app/application/site/*.bin
# IDE
.vscode/
.idea/
@@ -92,7 +70,6 @@ test_*
*_test.py
# Build artifacts
.artifacts/
build/
.build/
dist/
+1 -1
View File
@@ -8,7 +8,7 @@ body:
value: |
请说明你希望添加的功能。
站点适配请求请先按 [站点适配采集说明](https://github.com/jxxghp/MoviePilot/blob/v3/docs/site-adapter-capture.md) 生成脱敏 ZIP,并在下方附加。Issue 及附件是公开内容,提交前必须解压预览四个文件。不要上传 Cookie、Authorization、通行密钥、会话字段或任何原始数据。
站点适配请求请先按 [站点适配采集说明](https://github.com/jxxghp/MoviePilot/blob/v2/docs/site-adapter-capture.md) 生成脱敏 ZIP,并在下方附加。Issue 及附件是公开内容,提交前必须解压预览四个文件。不要上传 Cookie、Authorization、通行密钥、会话字段或任何原始数据。
- type: input
id: version
attributes:
@@ -1,69 +0,0 @@
name: Official Plugin Architecture Observation
on:
workflow_dispatch:
schedule:
- cron: '17 3 * * 1'
permissions:
contents: read
concurrency:
group: official-plugin-architecture-observation
cancel-in-progress: true
jobs:
observe:
runs-on: ubuntu-latest
name: Compare latest official plugin contracts
timeout-minutes: 15
steps:
- name: Checkout MoviePilot
uses: actions/checkout@v5
with:
path: MoviePilot
- name: Checkout MoviePilot-Plugins
uses: actions/checkout@v5
with:
repository: jxxghp/MoviePilot-Plugins
ref: main
path: MoviePilot-Plugins
- name: Set up uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version: '0.12.5'
python-version: '3.14'
enable-cache: true
cache-dependency-glob: |
MoviePilot/pyproject.toml
MoviePilot/uv.lock
- name: Install dependencies
working-directory: MoviePilot
run: uv sync --locked
- name: Compare plugin contracts without updating fixtures
id: compare
continue-on-error: true
working-directory: MoviePilot
run: |
uv run --locked --no-sync python scripts/architecture/baseline.py \
--check-plugins \
--plugin-repo ../MoviePilot-Plugins \
--report official-plugin-architecture-report.json
- name: Upload semantic comparison report
if: always()
uses: actions/upload-artifact@v7
with:
name: official-plugin-architecture-report
path: MoviePilot/official-plugin-architecture-report.json
if-no-files-found: warn
retention-days: 3
- name: Fail when plugin contracts changed
if: steps.compare.outcome == 'failure'
run: exit 1
+7 -262
View File
@@ -10,72 +10,15 @@ jobs:
Docker-build:
runs-on: ubuntu-latest
name: Build Docker Image
env:
TRIVY_SKIP_DIRS: /usr/share/java
TRIVY_SKIP_JAVA_DB_UPDATE: "true"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version: '0.12.5'
- name: Verify dependency lock
run: uv lock --check
- name: Audit locked Python dependencies
run: |
uv export --quiet --locked --no-default-groups --group runtime-standard \
--no-emit-project --output-file /tmp/moviepilot-audit-standard.txt
uvx --from pip-audit==2.10.1 pip-audit \
--require-hashes --disable-pip --strict --progress-spinner off \
--requirement /tmp/moviepilot-audit-standard.txt
uv export --quiet --locked --no-default-groups --group runtime-free-threaded \
--no-emit-project --no-hashes \
--output-file /tmp/moviepilot-audit-free-threaded.txt
python3 scripts/normalize_audit_requirements.py \
--lock uv.lock \
--input /tmp/moviepilot-audit-free-threaded.txt \
--output /tmp/moviepilot-audit-free-threaded-normalized.txt
uvx --from pip-audit==2.10.1 pip-audit \
--no-deps --disable-pip --strict --progress-spinner off \
--requirement /tmp/moviepilot-audit-free-threaded-normalized.txt
- name: Release version
id: release_version
run: |
app_version=$(cat version.py |sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp")
frontend_version=$(sed -ne "s/FRONTEND_VERSION\s*=\s*'\([^']*\)'/\1/gp" version.py)
echo "app_version=$app_version" >> $GITHUB_ENV
echo "SOURCE_COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV
echo "frontend_version=$frontend_version" >> "$GITHUB_OUTPUT"
- name: Resolve External Payload Revisions
id: payloads
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FRONTEND_VERSION: ${{ steps.release_version.outputs.frontend_version }}
run: |
plugins_revision=$(git ls-remote https://github.com/jxxghp/MoviePilot-Plugins.git refs/heads/main | awk '{print $1}')
resources_revision=$(git ls-remote https://github.com/jxxghp/MoviePilot-Resources.git refs/heads/main | awk '{print $1}')
frontend_digest=$(gh api "repos/jxxghp/MoviePilot-Frontend/releases/tags/${FRONTEND_VERSION}" \
--jq '.assets[] | select(.name == "dist.zip") | .digest')
[[ "$plugins_revision" =~ ^[0-9a-f]{40}$ ]]
[[ "$resources_revision" =~ ^[0-9a-f]{40}$ ]]
case "$frontend_digest" in
sha256:*) frontend_sha256=${frontend_digest#sha256:} ;;
*) echo "dist.zip 缺少 SHA-256 摘要" >&2; exit 1 ;;
esac
[[ "$frontend_sha256" =~ ^[0-9a-f]{64}$ ]]
echo "plugins_revision=$plugins_revision" >> "$GITHUB_OUTPUT"
echo "resources_revision=$resources_revision" >> "$GITHUB_OUTPUT"
echo "frontend_digest=$frontend_digest" >> "$GITHUB_OUTPUT"
echo "frontend_sha256=$frontend_sha256" >> "$GITHUB_OUTPUT"
- name: Checkout Wiki Plugin Market
uses: actions/checkout@v4
@@ -92,42 +35,17 @@ jobs:
run: |
python3 -m scripts.generate_plugin_market_default \
--wiki-file .build/moviepilot-wiki/plugin.md \
--config-file app/runtime/config.py
--config-file app/core/config.py
wiki_commit=$(git -C .build/moviepilot-wiki rev-parse HEAD)
echo "wiki_commit=$wiki_commit" >> "$GITHUB_OUTPUT"
- name: Download models.dev catalog
id: models_catalog
run: |
temp_file=$(mktemp app/agent/llm/models.json.XXXXXX)
trap 'rm -f "$temp_file"' EXIT
curl --fail --show-error --silent --location --retry 3 \
--connect-timeout 10 --max-time 120 \
"https://models.dev/api.json" -o "$temp_file"
jq -e 'type == "object"' "$temp_file" >/dev/null
# Git keeps only a small placeholder; the beta image receives the current catalog.
chmod 644 "$temp_file"
mv "$temp_file" app/agent/llm/models.json
echo "digest=sha256:$(sha256sum app/agent/llm/models.json | awk '{print $1}')" >> "$GITHUB_OUTPUT"
echo "Downloaded models.dev catalog ($(wc -c < app/agent/llm/models.json) bytes)"
- name: Docker Meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ secrets.DOCKER_USERNAME }}/moviepilot-v3
ghcr.io/${{ github.repository }}-v3
tags: |
type=raw,value=beta
- name: Docker Meta free-threaded
id: meta_ft
uses: docker/metadata-action@v5
with:
images: |
${{ secrets.DOCKER_USERNAME }}/moviepilot-v3t
ghcr.io/${{ github.repository }}-v3t
${{ secrets.DOCKER_USERNAME }}/moviepilot-v2
ghcr.io/${{ github.repository }}
tags: |
type=raw,value=beta
@@ -137,134 +55,6 @@ jobs:
- name: Set Up Buildx
uses: docker/setup-buildx-action@v3
- name: Build standard amd64 candidate
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: docker/Dockerfile
platforms: linux/amd64
load: true
push: false
pull: true
tags: moviepilot-v3-candidate:linux-amd64
build-args: |
MOVIEPILOT_FRONTEND_VERSION=${{ steps.release_version.outputs.frontend_version }}
MOVIEPILOT_FRONTEND_SHA256=${{ steps.payloads.outputs.frontend_sha256 }}
MOVIEPILOT_PLUGINS_REF=${{ steps.payloads.outputs.plugins_revision }}
MOVIEPILOT_RESOURCES_REF=${{ steps.payloads.outputs.resources_revision }}
MOVIEPILOT_PYTHON_VARIANT=standard
cache-from: type=gha,scope=moviepilot-v3-standard-docker-amd64,version=2
cache-to: type=gha,scope=moviepilot-v3-standard-docker-amd64,mode=max,version=2
- name: Scan standard amd64 candidate vulnerabilities
uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0
with:
image-ref: moviepilot-v3-candidate:linux-amd64
version: v0.70.0
cache-dir: ${{ runner.temp }}/trivy
scanners: vuln
vuln-type: os,library
severity: HIGH,CRITICAL
ignore-unfixed: true
trivyignores: .trivyignore.yaml
exit-code: 1
- name: Build standard arm64 candidate
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: docker/Dockerfile
platforms: linux/arm64/v8
load: true
push: false
pull: true
tags: moviepilot-v3-candidate:linux-arm64
build-args: |
MOVIEPILOT_FRONTEND_VERSION=${{ steps.release_version.outputs.frontend_version }}
MOVIEPILOT_FRONTEND_SHA256=${{ steps.payloads.outputs.frontend_sha256 }}
MOVIEPILOT_PLUGINS_REF=${{ steps.payloads.outputs.plugins_revision }}
MOVIEPILOT_RESOURCES_REF=${{ steps.payloads.outputs.resources_revision }}
MOVIEPILOT_PYTHON_VARIANT=standard
cache-from: type=gha,scope=moviepilot-v3-standard-docker-arm64,version=2
cache-to: type=gha,scope=moviepilot-v3-standard-docker-arm64,mode=max,version=2
- name: Scan standard arm64 candidate vulnerabilities
uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0
with:
image-ref: moviepilot-v3-candidate:linux-arm64
version: v0.70.0
cache-dir: ${{ runner.temp }}/trivy
scanners: vuln
vuln-type: os,library
severity: HIGH,CRITICAL
ignore-unfixed: true
trivyignores: .trivyignore.yaml
exit-code: 1
- name: Build free-threaded amd64 candidate
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: docker/Dockerfile
platforms: linux/amd64
load: true
push: false
pull: true
tags: moviepilot-v3t-candidate:linux-amd64
build-args: |
MOVIEPILOT_FRONTEND_VERSION=${{ steps.release_version.outputs.frontend_version }}
MOVIEPILOT_FRONTEND_SHA256=${{ steps.payloads.outputs.frontend_sha256 }}
MOVIEPILOT_PLUGINS_REF=${{ steps.payloads.outputs.plugins_revision }}
MOVIEPILOT_RESOURCES_REF=${{ steps.payloads.outputs.resources_revision }}
MOVIEPILOT_PYTHON_VARIANT=free-threaded
cache-from: type=gha,scope=moviepilot-v3t-docker-amd64,version=2
cache-to: type=gha,scope=moviepilot-v3t-docker-amd64,mode=max,version=2
- name: Scan free-threaded amd64 candidate vulnerabilities
uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0
with:
image-ref: moviepilot-v3t-candidate:linux-amd64
version: v0.70.0
cache-dir: ${{ runner.temp }}/trivy
scanners: vuln
vuln-type: os,library
severity: HIGH,CRITICAL
ignore-unfixed: true
trivyignores: .trivyignore.yaml
exit-code: 1
- name: Build free-threaded arm64 candidate
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: docker/Dockerfile
platforms: linux/arm64/v8
load: true
push: false
pull: true
tags: moviepilot-v3t-candidate:linux-arm64
build-args: |
MOVIEPILOT_FRONTEND_VERSION=${{ steps.release_version.outputs.frontend_version }}
MOVIEPILOT_FRONTEND_SHA256=${{ steps.payloads.outputs.frontend_sha256 }}
MOVIEPILOT_PLUGINS_REF=${{ steps.payloads.outputs.plugins_revision }}
MOVIEPILOT_RESOURCES_REF=${{ steps.payloads.outputs.resources_revision }}
MOVIEPILOT_PYTHON_VARIANT=free-threaded
cache-from: type=gha,scope=moviepilot-v3t-docker-arm64,version=2
cache-to: type=gha,scope=moviepilot-v3t-docker-arm64,mode=max,version=2
- name: Scan free-threaded arm64 candidate vulnerabilities
uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0
with:
image-ref: moviepilot-v3t-candidate:linux-arm64
version: v0.70.0
cache-dir: ${{ runner.temp }}/trivy
scanners: vuln
vuln-type: os,library
severity: HIGH,CRITICAL
ignore-unfixed: true
trivyignores: .trivyignore.yaml
exit-code: 1
- name: Login DockerHub
uses: docker/login-action@v3
with:
@@ -278,8 +68,8 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Publish standard multi-architecture image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
- name: Build Image
uses: docker/build-push-action@v7
with:
context: .
file: docker/Dockerfile
@@ -288,53 +78,8 @@ jobs:
linux/arm64/v8
push: true
tags: ${{ steps.meta.outputs.tags }}
build-args: |
MOVIEPILOT_FRONTEND_VERSION=${{ steps.release_version.outputs.frontend_version }}
MOVIEPILOT_FRONTEND_SHA256=${{ steps.payloads.outputs.frontend_sha256 }}
MOVIEPILOT_PLUGINS_REF=${{ steps.payloads.outputs.plugins_revision }}
MOVIEPILOT_RESOURCES_REF=${{ steps.payloads.outputs.resources_revision }}
MOVIEPILOT_PYTHON_VARIANT=standard
labels: |
${{ steps.meta.outputs.labels }}
org.opencontainers.image.revision=${{ env.SOURCE_COMMIT }}
org.moviepilot.source-revision=${{ env.SOURCE_COMMIT }}
org.moviepilot.frontend-version=${{ steps.release_version.outputs.frontend_version }}
org.moviepilot.frontend-digest=${{ steps.payloads.outputs.frontend_digest }}
org.moviepilot.plugins-revision=${{ steps.payloads.outputs.plugins_revision }}
org.moviepilot.resources-revision=${{ steps.payloads.outputs.resources_revision }}
org.moviepilot.plugin-market-wiki-revision=${{ steps.plugin_market.outputs.wiki_commit }}
org.moviepilot.models-catalog-digest=${{ steps.models_catalog.outputs.digest }}
cache-from: |
type=gha,scope=moviepilot-v3-standard-docker-amd64,version=2
type=gha,scope=moviepilot-v3-standard-docker-arm64,version=2
- name: Publish free-threaded multi-architecture image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: docker/Dockerfile
platforms: |
linux/amd64
linux/arm64/v8
push: true
pull: false
tags: ${{ steps.meta_ft.outputs.tags }}
build-args: |
MOVIEPILOT_FRONTEND_VERSION=${{ steps.release_version.outputs.frontend_version }}
MOVIEPILOT_FRONTEND_SHA256=${{ steps.payloads.outputs.frontend_sha256 }}
MOVIEPILOT_PLUGINS_REF=${{ steps.payloads.outputs.plugins_revision }}
MOVIEPILOT_RESOURCES_REF=${{ steps.payloads.outputs.resources_revision }}
MOVIEPILOT_PYTHON_VARIANT=free-threaded
labels: |
${{ steps.meta_ft.outputs.labels }}
org.opencontainers.image.revision=${{ env.SOURCE_COMMIT }}
org.moviepilot.source-revision=${{ env.SOURCE_COMMIT }}
org.moviepilot.frontend-version=${{ steps.release_version.outputs.frontend_version }}
org.moviepilot.frontend-digest=${{ steps.payloads.outputs.frontend_digest }}
org.moviepilot.plugins-revision=${{ steps.payloads.outputs.plugins_revision }}
org.moviepilot.resources-revision=${{ steps.payloads.outputs.resources_revision }}
org.moviepilot.plugin-market-wiki-revision=${{ steps.plugin_market.outputs.wiki_commit }}
org.moviepilot.models-catalog-digest=${{ steps.models_catalog.outputs.digest }}
cache-from: |
type=gha,scope=moviepilot-v3t-docker-amd64,version=2
type=gha,scope=moviepilot-v3t-docker-arm64,version=2
cache-from: type=gha,scope=moviepilot-docker,version=2
cache-to: type=gha,scope=moviepilot-docker,mode=max,version=2
+6 -524
View File
@@ -1,532 +1,14 @@
name: MoviePilot Builder v3
on:
workflow_dispatch:
push:
branches:
- v3
paths:
- 'version.py'
permissions:
contents: write
packages: write
jobs:
Docker-build:
select-v3:
runs-on: ubuntu-latest
name: Build Docker Image
env:
TRIVY_SKIP_DIRS: /usr/share/java
TRIVY_SKIP_JAVA_DB_UPDATE: "true"
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Set up uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version: '0.12.5'
- name: Verify dependency lock
run: uv lock --check
- name: Audit locked Python dependencies
# GitHub 仅从默认分支登记手动工作流;选择 v3 后会加载 v3 分支的完整构建配置。
- name: Require v3 branch
run: |
uv export --quiet --locked --no-default-groups --group runtime-standard \
--no-emit-project --output-file /tmp/moviepilot-audit-standard.txt
uvx --from pip-audit==2.10.1 pip-audit \
--require-hashes --disable-pip --strict --progress-spinner off \
--requirement /tmp/moviepilot-audit-standard.txt
uv export --quiet --locked --no-default-groups --group runtime-free-threaded \
--no-emit-project --no-hashes \
--output-file /tmp/moviepilot-audit-free-threaded.txt
python3 scripts/normalize_audit_requirements.py \
--lock uv.lock \
--input /tmp/moviepilot-audit-free-threaded.txt \
--output /tmp/moviepilot-audit-free-threaded-normalized.txt
uvx --from pip-audit==2.10.1 pip-audit \
--no-deps --disable-pip --strict --progress-spinner off \
--requirement /tmp/moviepilot-audit-free-threaded-normalized.txt
- name: Release version
id: release_version
run: |
app_version=$(cat version.py |sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp")
frontend_version=$(sed -ne "s/FRONTEND_VERSION\s*=\s*'\([^']*\)'/\1/gp" version.py)
echo "app_version=$app_version" >> $GITHUB_ENV
echo "SOURCE_COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV
echo "frontend_version=$frontend_version" >> "$GITHUB_OUTPUT"
- name: Resolve External Payload Revisions
id: payloads
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FRONTEND_VERSION: ${{ steps.release_version.outputs.frontend_version }}
run: |
plugins_revision=$(git ls-remote https://github.com/jxxghp/MoviePilot-Plugins.git refs/heads/main | awk '{print $1}')
resources_revision=$(git ls-remote https://github.com/jxxghp/MoviePilot-Resources.git refs/heads/main | awk '{print $1}')
frontend_digest=$(gh api "repos/jxxghp/MoviePilot-Frontend/releases/tags/${FRONTEND_VERSION}" \
--jq '.assets[] | select(.name == "dist.zip") | .digest')
[[ "$plugins_revision" =~ ^[0-9a-f]{40}$ ]]
[[ "$resources_revision" =~ ^[0-9a-f]{40}$ ]]
case "$frontend_digest" in
sha256:*) frontend_sha256=${frontend_digest#sha256:} ;;
*) echo "dist.zip 缺少 SHA-256 摘要" >&2; exit 1 ;;
esac
[[ "$frontend_sha256" =~ ^[0-9a-f]{64}$ ]]
echo "plugins_revision=$plugins_revision" >> "$GITHUB_OUTPUT"
echo "resources_revision=$resources_revision" >> "$GITHUB_OUTPUT"
echo "frontend_digest=$frontend_digest" >> "$GITHUB_OUTPUT"
echo "frontend_sha256=$frontend_sha256" >> "$GITHUB_OUTPUT"
- name: Checkout Wiki Plugin Market
uses: actions/checkout@v4
with:
repository: jxxghp/MoviePilot-Wiki
ref: main
path: .build/moviepilot-wiki
sparse-checkout: plugin.md
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Generate Plugin Market Default
id: plugin_market
run: |
python3 -m scripts.generate_plugin_market_default \
--wiki-file .build/moviepilot-wiki/plugin.md \
--config-file app/runtime/config.py
wiki_commit=$(git -C .build/moviepilot-wiki rev-parse HEAD)
echo "wiki_commit=$wiki_commit" >> "$GITHUB_OUTPUT"
- name: Download models.dev catalog
id: models_catalog
run: |
temp_file=$(mktemp app/agent/llm/models.json.XXXXXX)
trap 'rm -f "$temp_file"' EXIT
curl --fail --show-error --silent --location --retry 3 \
--connect-timeout 10 --max-time 120 \
"https://models.dev/api.json" -o "$temp_file"
jq -e 'type == "object"' "$temp_file" >/dev/null
# Git keeps only a small placeholder; the release image receives the current catalog.
chmod 644 "$temp_file"
mv "$temp_file" app/agent/llm/models.json
echo "digest=sha256:$(sha256sum app/agent/llm/models.json | awk '{print $1}')" >> "$GITHUB_OUTPUT"
echo "Downloaded models.dev catalog ($(wc -c < app/agent/llm/models.json) bytes)"
- name: Create Release Snapshot
id: release_snapshot
env:
WIKI_COMMIT: ${{ steps.plugin_market.outputs.wiki_commit }}
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add app/runtime/config.py
if ! git diff --cached --quiet; then
git commit -m "build(plugin-market): sync default from MoviePilot-Wiki@${WIKI_COMMIT:0:12}"
fi
echo "release_commit=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Docker Meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ secrets.DOCKER_USERNAME }}/moviepilot-v3
ghcr.io/${{ github.repository }}-v3
tags: |
type=raw,value=${{ env.app_version }}
- name: Docker Meta free-threaded
id: meta_ft
uses: docker/metadata-action@v5
with:
images: |
${{ secrets.DOCKER_USERNAME }}/moviepilot-v3t
ghcr.io/${{ github.repository }}-v3t
tags: |
type=raw,value=${{ env.app_version }}
- name: Set Up QEMU
uses: docker/setup-qemu-action@v3
- name: Set Up Buildx
uses: docker/setup-buildx-action@v3
- name: Build amd64 candidate
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: docker/Dockerfile
platforms: linux/amd64
load: true
push: false
pull: true
tags: moviepilot-v3-candidate:linux-amd64
build-args: |
MOVIEPILOT_FRONTEND_VERSION=${{ steps.release_version.outputs.frontend_version }}
MOVIEPILOT_FRONTEND_SHA256=${{ steps.payloads.outputs.frontend_sha256 }}
MOVIEPILOT_PLUGINS_REF=${{ steps.payloads.outputs.plugins_revision }}
MOVIEPILOT_RESOURCES_REF=${{ steps.payloads.outputs.resources_revision }}
MOVIEPILOT_PYTHON_VARIANT=standard
cache-from: type=gha,scope=moviepilot-v3-standard-docker-amd64,version=2
cache-to: type=gha,scope=moviepilot-v3-standard-docker-amd64,mode=max,version=2
- name: Scan amd64 candidate vulnerabilities
uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0
with:
image-ref: moviepilot-v3-candidate:linux-amd64
version: v0.70.0
cache-dir: ${{ runner.temp }}/trivy
scanners: vuln
vuln-type: os,library
severity: HIGH,CRITICAL
ignore-unfixed: true
trivyignores: .trivyignore.yaml
exit-code: 1
- name: Build arm64 candidate
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: docker/Dockerfile
platforms: linux/arm64/v8
load: true
push: false
pull: true
tags: moviepilot-v3-candidate:linux-arm64
build-args: |
MOVIEPILOT_FRONTEND_VERSION=${{ steps.release_version.outputs.frontend_version }}
MOVIEPILOT_FRONTEND_SHA256=${{ steps.payloads.outputs.frontend_sha256 }}
MOVIEPILOT_PLUGINS_REF=${{ steps.payloads.outputs.plugins_revision }}
MOVIEPILOT_RESOURCES_REF=${{ steps.payloads.outputs.resources_revision }}
MOVIEPILOT_PYTHON_VARIANT=standard
cache-from: type=gha,scope=moviepilot-v3-standard-docker-arm64,version=2
cache-to: type=gha,scope=moviepilot-v3-standard-docker-arm64,mode=max,version=2
- name: Scan arm64 candidate vulnerabilities
uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0
with:
image-ref: moviepilot-v3-candidate:linux-arm64
version: v0.70.0
cache-dir: ${{ runner.temp }}/trivy
scanners: vuln
vuln-type: os,library
severity: HIGH,CRITICAL
ignore-unfixed: true
trivyignores: .trivyignore.yaml
exit-code: 1
- name: Build free-threaded amd64 candidate
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: docker/Dockerfile
platforms: linux/amd64
load: true
push: false
pull: true
tags: moviepilot-v3t-candidate:linux-amd64
build-args: |
MOVIEPILOT_FRONTEND_VERSION=${{ steps.release_version.outputs.frontend_version }}
MOVIEPILOT_FRONTEND_SHA256=${{ steps.payloads.outputs.frontend_sha256 }}
MOVIEPILOT_PLUGINS_REF=${{ steps.payloads.outputs.plugins_revision }}
MOVIEPILOT_RESOURCES_REF=${{ steps.payloads.outputs.resources_revision }}
MOVIEPILOT_PYTHON_VARIANT=free-threaded
cache-from: type=gha,scope=moviepilot-v3t-docker-amd64,version=2
cache-to: type=gha,scope=moviepilot-v3t-docker-amd64,mode=max,version=2
- name: Scan free-threaded amd64 candidate vulnerabilities
uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0
with:
image-ref: moviepilot-v3t-candidate:linux-amd64
version: v0.70.0
cache-dir: ${{ runner.temp }}/trivy
scanners: vuln
vuln-type: os,library
severity: HIGH,CRITICAL
ignore-unfixed: true
trivyignores: .trivyignore.yaml
exit-code: 1
- name: Build free-threaded arm64 candidate
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: docker/Dockerfile
platforms: linux/arm64/v8
load: true
push: false
pull: true
tags: moviepilot-v3t-candidate:linux-arm64
build-args: |
MOVIEPILOT_FRONTEND_VERSION=${{ steps.release_version.outputs.frontend_version }}
MOVIEPILOT_FRONTEND_SHA256=${{ steps.payloads.outputs.frontend_sha256 }}
MOVIEPILOT_PLUGINS_REF=${{ steps.payloads.outputs.plugins_revision }}
MOVIEPILOT_RESOURCES_REF=${{ steps.payloads.outputs.resources_revision }}
MOVIEPILOT_PYTHON_VARIANT=free-threaded
cache-from: type=gha,scope=moviepilot-v3t-docker-arm64,version=2
cache-to: type=gha,scope=moviepilot-v3t-docker-arm64,mode=max,version=2
- name: Scan free-threaded arm64 candidate vulnerabilities
uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0
with:
image-ref: moviepilot-v3t-candidate:linux-arm64
version: v0.70.0
cache-dir: ${{ runner.temp }}/trivy
scanners: vuln
vuln-type: os,library
severity: HIGH,CRITICAL
ignore-unfixed: true
trivyignores: .trivyignore.yaml
exit-code: 1
- name: Login DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Publish multi-architecture image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: docker/Dockerfile
platforms: |
linux/amd64
linux/arm64/v8
push: true
pull: false
tags: ${{ steps.meta.outputs.tags }}
build-args: |
MOVIEPILOT_FRONTEND_VERSION=${{ steps.release_version.outputs.frontend_version }}
MOVIEPILOT_FRONTEND_SHA256=${{ steps.payloads.outputs.frontend_sha256 }}
MOVIEPILOT_PLUGINS_REF=${{ steps.payloads.outputs.plugins_revision }}
MOVIEPILOT_RESOURCES_REF=${{ steps.payloads.outputs.resources_revision }}
MOVIEPILOT_PYTHON_VARIANT=standard
labels: |
${{ steps.meta.outputs.labels }}
org.opencontainers.image.revision=${{ steps.release_snapshot.outputs.release_commit }}
org.moviepilot.source-revision=${{ env.SOURCE_COMMIT }}
org.moviepilot.release-snapshot-revision=${{ steps.release_snapshot.outputs.release_commit }}
org.moviepilot.frontend-version=${{ steps.release_version.outputs.frontend_version }}
org.moviepilot.frontend-digest=${{ steps.payloads.outputs.frontend_digest }}
org.moviepilot.plugins-revision=${{ steps.payloads.outputs.plugins_revision }}
org.moviepilot.resources-revision=${{ steps.payloads.outputs.resources_revision }}
org.moviepilot.plugin-market-wiki-revision=${{ steps.plugin_market.outputs.wiki_commit }}
org.moviepilot.models-catalog-digest=${{ steps.models_catalog.outputs.digest }}
cache-from: |
type=gha,scope=moviepilot-v3-standard-docker-amd64,version=2
type=gha,scope=moviepilot-v3-standard-docker-arm64,version=2
- name: Publish free-threaded multi-architecture image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: docker/Dockerfile
platforms: |
linux/amd64
linux/arm64/v8
push: true
pull: false
tags: ${{ steps.meta_ft.outputs.tags }}
build-args: |
MOVIEPILOT_FRONTEND_VERSION=${{ steps.release_version.outputs.frontend_version }}
MOVIEPILOT_FRONTEND_SHA256=${{ steps.payloads.outputs.frontend_sha256 }}
MOVIEPILOT_PLUGINS_REF=${{ steps.payloads.outputs.plugins_revision }}
MOVIEPILOT_RESOURCES_REF=${{ steps.payloads.outputs.resources_revision }}
MOVIEPILOT_PYTHON_VARIANT=free-threaded
labels: |
${{ steps.meta_ft.outputs.labels }}
org.opencontainers.image.revision=${{ steps.release_snapshot.outputs.release_commit }}
org.moviepilot.source-revision=${{ env.SOURCE_COMMIT }}
org.moviepilot.release-snapshot-revision=${{ steps.release_snapshot.outputs.release_commit }}
org.moviepilot.frontend-version=${{ steps.release_version.outputs.frontend_version }}
org.moviepilot.frontend-digest=${{ steps.payloads.outputs.frontend_digest }}
org.moviepilot.plugins-revision=${{ steps.payloads.outputs.plugins_revision }}
org.moviepilot.resources-revision=${{ steps.payloads.outputs.resources_revision }}
org.moviepilot.plugin-market-wiki-revision=${{ steps.plugin_market.outputs.wiki_commit }}
org.moviepilot.models-catalog-digest=${{ steps.models_catalog.outputs.digest }}
cache-from: |
type=gha,scope=moviepilot-v3t-docker-amd64,version=2
type=gha,scope=moviepilot-v3t-docker-arm64,version=2
- name: Promote latest image pair
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
run: |
ghcr_repository="${GITHUB_REPOSITORY,,}"
for image in \
"${DOCKER_USERNAME}/moviepilot-v3" \
"ghcr.io/${ghcr_repository}-v3"; do
docker buildx imagetools create \
--tag "${image}:latest" \
"${image}:${app_version}"
done
for image in \
"${DOCKER_USERNAME}/moviepilot-v3t" \
"ghcr.io/${ghcr_repository}-v3t"; do
docker buildx imagetools create \
--tag "${image}:latest" \
"${image}:${app_version}"
done
- name: Generate Changelog
id: changelog
run: |
# 获取上一个 tag(优先 v3.*,回退到任意 v* 版本 tag
PREVIOUS_TAG=$(git tag -l 'v3.*' --sort=-v:refname | grep -v "^v${{ env.app_version }}$" | head -n 1)
if [ -z "$PREVIOUS_TAG" ]; then
PREVIOUS_TAG=$(git tag -l 'v[0-9]*' --sort=-v:refname | grep -v "^v${{ env.app_version }}$" | head -n 1)
fi
echo "Previous tag: $PREVIOUS_TAG"
# 使用 || 作为分隔符,同时获取 commit 消息和作者 GitHub 用户名
if [ -z "$PREVIOUS_TAG" ]; then
# 首次发布且无任何历史版本 tag,限制条数避免打印整库历史撑爆环境变量
COMMITS=$(git log --pretty=format:"%s||%an" -n 300 "${SOURCE_COMMIT}")
else
COMMITS=$(git log --pretty=format:"%s||%an" "${PREVIOUS_TAG}..${SOURCE_COMMIT}")
fi
# 分类收集 commit 消息(使用关联数组去重)
declare -A SEEN
FEATURES=""
FIXES=""
OTHERS=""
while IFS= read -r line; do
# 跳过空行
if [ -z "$line" ]; then
continue
fi
# 分离 commit 消息和作者
msg=$(echo "$line" | sed 's/||[^|]*$//')
author=$(echo "$line" | sed 's/.*||//')
# 跳过 Merge commit 和版本更新 commit
if echo "$msg" | grep -qE "^Merge pull request|^Merge branch|^更新 version"; then
continue
fi
# 按 Conventional Commits 前缀分类
if echo "$msg" | grep -qiE "^feat(\(.+\))?:"; then
desc=$(echo "$msg" | sed -E 's/^feat(\([^)]*\))?:\s*//')
category="FEATURES"
elif echo "$msg" | grep -qiE "^fix(\(.+\))?:"; then
desc=$(echo "$msg" | sed -E 's/^fix(\([^)]*\))?:\s*//')
category="FIXES"
elif echo "$msg" | grep -qiE "^(docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?:"; then
desc=$(echo "$msg" | sed -E 's/^(docs|style|refactor|perf|test|build|ci|chore|revert)(\([^)]*\))?:\s*//')
category="OTHERS"
else
desc="$msg"
category="OTHERS"
fi
# 使用 "分类+描述" 作为去重的 key,跳过重复内容
dedup_key="${category}::${desc}"
if [ -n "${SEEN[$dedup_key]+x}" ]; then
continue
fi
SEEN[$dedup_key]=1
# 添加 by @author 引用
entry="- ${desc} by @${author}"
case "$category" in
FEATURES) FEATURES="${FEATURES}${entry}\n" ;;
FIXES) FIXES="${FIXES}${entry}\n" ;;
OTHERS) OTHERS="${OTHERS}${entry}\n" ;;
esac
done <<< "$COMMITS"
# 组装 changelog
CHANGELOG=""
if [ -n "$FEATURES" ]; then
CHANGELOG="${CHANGELOG}### ✨ 新功能\n\n${FEATURES}\n"
fi
if [ -n "$FIXES" ]; then
CHANGELOG="${CHANGELOG}### 🐛 修复\n\n${FIXES}\n"
fi
if [ -n "$OTHERS" ]; then
CHANGELOG="${CHANGELOG}### 🔧 其他\n\n${OTHERS}\n"
fi
# 添加版本对比链接
if [ -n "$PREVIOUS_TAG" ]; then
CHANGELOG="${CHANGELOG}**完整更新记录**: https://github.com/${{ github.repository }}/compare/${PREVIOUS_TAG}...v${{ env.app_version }}"
fi
# 写入环境变量
echo "CHANGELOG<<EOF" >> $GITHUB_ENV
echo -e "$CHANGELOG" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Get existing release body
id: get_release_body
continue-on-error: true
env:
CHANGELOG: ${{ env.CHANGELOG }}
run: |
release_body=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://api.github.com/repos/${{ github.repository }}/releases/tags/v${{ env.app_version }}" | \
jq -r '.body // ""')
# 如果已有手动编写的 release body,则保留;否则使用自动生成的 changelog
if [ -n "$release_body" ] && [ "$release_body" != "null" ] && [ "$release_body" != "" ]; then
echo "RELEASE_BODY<<EOF" >> $GITHUB_ENV
echo "$release_body" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
else
echo "RELEASE_BODY<<EOF" >> $GITHUB_ENV
echo "$CHANGELOG" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
fi
- name: Delete Release
uses: dev-drprasad/delete-tag-and-release@v1.1
continue-on-error: true
with:
tag_name: v${{ env.app_version }}
delete_release: true
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Publish Release Tag
env:
RELEASE_COMMIT: ${{ steps.release_snapshot.outputs.release_commit }}
run: |
tag_name="v${{ env.app_version }}"
if git show-ref --verify --quiet "refs/tags/${tag_name}"; then
git tag -d "$tag_name"
fi
git tag "$tag_name" "$RELEASE_COMMIT"
git push origin "refs/tags/${tag_name}"
- name: Generate Release
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ env.app_version }}
name: v${{ env.app_version }}
body: ${{ env.RELEASE_BODY }}
draft: false
prerelease: false
make_latest: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
echo "::error::请在 Run workflow 中选择 v3 分支"
exit 1
+252
View File
@@ -0,0 +1,252 @@
name: MoviePilot Builder v2
on:
workflow_dispatch:
push:
branches:
- v2
paths:
- 'version.py'
permissions:
contents: write
packages: write
jobs:
Docker-build:
runs-on: ubuntu-latest
name: Build Docker Image
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Release version
id: release_version
run: |
app_version=$(cat version.py |sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp")
echo "app_version=$app_version" >> $GITHUB_ENV
echo "SOURCE_COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV
- name: Checkout Wiki Plugin Market
uses: actions/checkout@v4
with:
repository: jxxghp/MoviePilot-Wiki
ref: main
path: .build/moviepilot-wiki
sparse-checkout: plugin.md
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Generate Plugin Market Default
id: plugin_market
run: |
python3 -m scripts.generate_plugin_market_default \
--wiki-file .build/moviepilot-wiki/plugin.md \
--config-file app/core/config.py
wiki_commit=$(git -C .build/moviepilot-wiki rev-parse HEAD)
echo "wiki_commit=$wiki_commit" >> "$GITHUB_OUTPUT"
- name: Create Release Snapshot
id: release_snapshot
env:
WIKI_COMMIT: ${{ steps.plugin_market.outputs.wiki_commit }}
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add app/core/config.py
if ! git diff --cached --quiet; then
git commit -m "build(plugin-market): sync default from MoviePilot-Wiki@${WIKI_COMMIT:0:12}"
fi
echo "release_commit=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Docker Meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ secrets.DOCKER_USERNAME }}/moviepilot-v2
${{ secrets.DOCKER_USERNAME }}/moviepilot
ghcr.io/${{ github.repository }}
tags: |
type=raw,value=${{ env.app_version }}
type=raw,value=latest
- name: Set Up QEMU
uses: docker/setup-qemu-action@v3
- name: Set Up Buildx
uses: docker/setup-buildx-action@v3
- name: Login DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Image
uses: docker/build-push-action@v7
with:
context: .
file: docker/Dockerfile
platforms: |
linux/amd64
linux/arm64/v8
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: |
${{ steps.meta.outputs.labels }}
org.opencontainers.image.revision=${{ steps.release_snapshot.outputs.release_commit }}
org.moviepilot.plugin-market-wiki-revision=${{ steps.plugin_market.outputs.wiki_commit }}
cache-from: type=gha,scope=moviepilot-docker,version=2
cache-to: type=gha,scope=moviepilot-docker,mode=max,version=2
- name: Generate Changelog
id: changelog
run: |
# 获取上一个 tag(排除当前版本的 tag)
PREVIOUS_TAG=$(git tag -l 'v*' --sort=-v:refname | grep -v "^v${{ env.app_version }}$" | head -n 1)
echo "Previous tag: $PREVIOUS_TAG"
# 使用 || 作为分隔符,同时获取 commit 消息和作者 GitHub 用户名
if [ -z "$PREVIOUS_TAG" ]; then
COMMITS=$(git log --pretty=format:"%s||%an" "${SOURCE_COMMIT}")
else
COMMITS=$(git log --pretty=format:"%s||%an" "${PREVIOUS_TAG}..${SOURCE_COMMIT}")
fi
# 分类收集 commit 消息(使用关联数组去重)
declare -A SEEN
FEATURES=""
FIXES=""
OTHERS=""
while IFS= read -r line; do
# 跳过空行
if [ -z "$line" ]; then
continue
fi
# 分离 commit 消息和作者
msg=$(echo "$line" | sed 's/||[^|]*$//')
author=$(echo "$line" | sed 's/.*||//')
# 跳过 Merge commit 和版本更新 commit
if echo "$msg" | grep -qE "^Merge pull request|^Merge branch|^更新 version"; then
continue
fi
# 按 Conventional Commits 前缀分类
if echo "$msg" | grep -qiE "^feat(\(.+\))?:"; then
desc=$(echo "$msg" | sed -E 's/^feat(\([^)]*\))?:\s*//')
category="FEATURES"
elif echo "$msg" | grep -qiE "^fix(\(.+\))?:"; then
desc=$(echo "$msg" | sed -E 's/^fix(\([^)]*\))?:\s*//')
category="FIXES"
elif echo "$msg" | grep -qiE "^(docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?:"; then
desc=$(echo "$msg" | sed -E 's/^(docs|style|refactor|perf|test|build|ci|chore|revert)(\([^)]*\))?:\s*//')
category="OTHERS"
else
desc="$msg"
category="OTHERS"
fi
# 使用 "分类+描述" 作为去重的 key,跳过重复内容
dedup_key="${category}::${desc}"
if [ -n "${SEEN[$dedup_key]+x}" ]; then
continue
fi
SEEN[$dedup_key]=1
# 添加 by @author 引用
entry="- ${desc} by @${author}"
case "$category" in
FEATURES) FEATURES="${FEATURES}${entry}\n" ;;
FIXES) FIXES="${FIXES}${entry}\n" ;;
OTHERS) OTHERS="${OTHERS}${entry}\n" ;;
esac
done <<< "$COMMITS"
# 组装 changelog
CHANGELOG=""
if [ -n "$FEATURES" ]; then
CHANGELOG="${CHANGELOG}### ✨ 新功能\n\n${FEATURES}\n"
fi
if [ -n "$FIXES" ]; then
CHANGELOG="${CHANGELOG}### 🐛 修复\n\n${FIXES}\n"
fi
if [ -n "$OTHERS" ]; then
CHANGELOG="${CHANGELOG}### 🔧 其他\n\n${OTHERS}\n"
fi
# 添加版本对比链接
if [ -n "$PREVIOUS_TAG" ]; then
CHANGELOG="${CHANGELOG}**完整更新记录**: https://github.com/${{ github.repository }}/compare/${PREVIOUS_TAG}...v${{ env.app_version }}"
fi
# 写入环境变量
echo "CHANGELOG<<EOF" >> $GITHUB_ENV
echo -e "$CHANGELOG" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Get existing release body
id: get_release_body
continue-on-error: true
run: |
release_body=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://api.github.com/repos/${{ github.repository }}/releases/tags/v${{ env.app_version }}" | \
jq -r '.body // ""')
# 如果已有手动编写的 release body,则保留;否则使用自动生成的 changelog
if [ -n "$release_body" ] && [ "$release_body" != "null" ] && [ "$release_body" != "" ]; then
echo "RELEASE_BODY<<EOF" >> $GITHUB_ENV
echo "$release_body" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
else
echo "RELEASE_BODY<<EOF" >> $GITHUB_ENV
echo "${{ env.CHANGELOG }}" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
fi
- name: Delete Release
uses: dev-drprasad/delete-tag-and-release@v1.1
continue-on-error: true
with:
tag_name: v${{ env.app_version }}
delete_release: true
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Publish Release Tag
env:
RELEASE_COMMIT: ${{ steps.release_snapshot.outputs.release_commit }}
run: |
tag_name="v${{ env.app_version }}"
if git show-ref --verify --quiet "refs/tags/${tag_name}"; then
git tag -d "$tag_name"
fi
git tag "$tag_name" "$RELEASE_COMMIT"
git push origin "refs/tags/${tag_name}"
- name: Generate Release
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ env.app_version }}
name: v${{ env.app_version }}
body: ${{ env.RELEASE_BODY }}
draft: false
prerelease: false
make_latest: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-183
View File
@@ -1,183 +0,0 @@
name: Dependency Compatibility
on:
pull_request:
branches:
- v3
paths:
- 'pyproject.toml'
- 'uv.lock'
- 'app/doctor/dependencies.py'
- 'app/foundation/environment.py'
- 'app/runtime/dependencies.py'
- 'docker/Dockerfile'
- 'docker/**'
- '.github/workflows/dependency-compat.yml'
push:
branches:
- v3
paths:
- 'pyproject.toml'
- 'uv.lock'
- 'app/doctor/dependencies.py'
- 'app/foundation/environment.py'
- 'app/runtime/dependencies.py'
- 'docker/Dockerfile'
- 'docker/**'
- '.github/workflows/dependency-compat.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: dependency-compat-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
install:
name: ${{ matrix.name }} / Python ${{ matrix.python-version }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- name: Linux x64
runner: ubuntu-24.04
python-version: '3.14'
expected-system: Linux
expected-machine: x86_64
- name: Linux ARM64
runner: ubuntu-24.04-arm
python-version: '3.14'
expected-system: Linux
expected-machine: aarch64
- name: macOS Intel
runner: macos-15-intel
python-version: '3.14'
expected-system: Darwin
expected-machine: x86_64
- name: macOS ARM
runner: macos-15
python-version: '3.14'
expected-system: Darwin
expected-machine: arm64
- name: Windows x64
runner: windows-2025
python-version: '3.14'
expected-system: Windows
expected-machine: AMD64
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Set up uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version: '0.12.5'
python-version: ${{ matrix.python-version }}
enable-cache: true
cache-dependency-glob: |
pyproject.toml
uv.lock
- name: Install locked runtime dependencies
run: uv sync --locked --inexact --no-dev --python ${{ matrix.python-version }}
- name: Verify environment and core imports
env:
EXPECTED_SYSTEM: ${{ matrix.expected-system }}
EXPECTED_MACHINE: ${{ matrix.expected-machine }}
run: >-
uv run --locked --no-sync python -c
"import os, platform;
assert platform.system() == os.environ['EXPECTED_SYSTEM'], (platform.system(), os.environ['EXPECTED_SYSTEM']);
assert platform.machine() == os.environ['EXPECTED_MACHINE'], (platform.machine(), os.environ['EXPECTED_MACHINE']);
import alembic, fastapi, pydantic, pydantic_settings, sqlalchemy, starlette, uvicorn"
- name: Verify locked project consistency
run: uv sync --locked --offline --inexact --no-dev --check --python ${{ matrix.python-version }}
docker-dependencies:
name: Docker dependencies / ${{ matrix.platform }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-24.04
platform: linux/amd64
python-variant: standard
cache-scope: linux-amd64-standard
image-tag: moviepilot-dependency-gate:linux-amd64-standard
expected-machine: x86_64
- runner: ubuntu-24.04-arm
platform: linux/arm64
python-variant: standard
cache-scope: linux-arm64-standard
image-tag: moviepilot-dependency-gate:linux-arm64-standard
expected-machine: aarch64
- runner: ubuntu-24.04
platform: linux/amd64
python-variant: free-threaded
cache-scope: linux-amd64-free-threaded
image-tag: moviepilot-dependency-gate:linux-amd64-free-threaded
expected-machine: x86_64
- runner: ubuntu-24.04-arm
platform: linux/arm64
python-variant: free-threaded
cache-scope: linux-arm64-free-threaded
image-tag: moviepilot-dependency-gate:linux-arm64-free-threaded
expected-machine: aarch64
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Build locked dependency stage
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: docker/Dockerfile
target: prepare_venv
platforms: ${{ matrix.platform }}
build-args: |
MOVIEPILOT_PYTHON_VARIANT=${{ matrix.python-variant }}
load: true
push: false
tags: ${{ matrix.image-tag }}
cache-from: type=gha,scope=dependency-compat-${{ matrix.cache-scope }}
cache-to: type=gha,scope=dependency-compat-${{ matrix.cache-scope }},mode=max
- name: Verify dependency image
env:
IMAGE_TAG: ${{ matrix.image-tag }}
EXPECTED_MACHINE: ${{ matrix.expected-machine }}
EXPECTED_VARIANT: ${{ matrix.python-variant }}
run: >-
docker run --rm
-e EXPECTED_MACHINE
-e EXPECTED_VARIANT
"${IMAGE_TAG}"
/opt/venv/bin/python -c
"import os, platform, sys, sysconfig;
assert platform.machine() == os.environ['EXPECTED_MACHINE'], (platform.machine(), os.environ['EXPECTED_MACHINE']);
expected_free_threaded = os.environ['EXPECTED_VARIANT'] == 'free-threaded';
assert (sysconfig.get_config_var('Py_GIL_DISABLED') == 1) is expected_free_threaded;
assert sys._is_gil_enabled() == (not expected_free_threaded);
import alembic, fastapi, moviepilot_rust, pydantic, pydantic_settings, sqlalchemy, starlette, uvicorn;
assert moviepilot_rust.is_available();
assert moviepilot_rust.jieba_cut('中文分词');
assert not expected_free_threaded or callable(moviepilot_rust.zhconv_fast);
assert sys._is_gil_enabled() == (not expected_free_threaded)"
- name: Verify pinned uv version
env:
IMAGE_TAG: ${{ matrix.image-tag }}
run: docker run --rm "${IMAGE_TAG}" uv --version | grep -F 'uv 0.12.5'
+41 -67
View File
@@ -1,21 +1,9 @@
name: Pylint Code Quality Check
on:
pull_request:
branches:
- v3
push:
branches:
- v3
# 允许手动触发
workflow_dispatch:
permissions:
contents: read
concurrency:
group: pylint-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
pylint:
runs-on: ubuntu-latest
@@ -23,22 +11,27 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
fetch-depth: 0
uses: actions/checkout@v4
- name: Set up uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
- name: Set up Python
uses: actions/setup-python@v5
with:
version: '0.12.5'
python-version: '3.14'
enable-cache: true
cache-dependency-glob: |
pyproject.toml
uv.lock
python-version: '3.12'
cache: 'pip'
- name: Cache pip dependencies
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.in', '**/requirements-dev.in', '**/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install dependencies
run: uv sync --locked
run: |
python -m pip install --upgrade pip setuptools wheel
# Pylint 属于开发/静态检查依赖,统一通过 dev 入口安装。
pip install -r requirements-dev.in
- name: Verify pylint config
run: |
@@ -51,51 +44,32 @@ jobs:
echo "❌ 未找到 .pylintrc 配置文件"
exit 1
fi
- name: Collect changed Python files
id: changed
env:
EVENT_NAME: ${{ github.event_name }}
BASE_REF: ${{ github.base_ref }}
BEFORE_SHA: ${{ github.event.before }}
CURRENT_SHA: ${{ github.sha }}
- name: Run pylint
run: |
if [[ "$EVENT_NAME" == "pull_request" ]]; then
git diff --name-only --diff-filter=ACMRT \
"origin/$BASE_REF...HEAD" -- '*.py' > changed-python-files.txt
elif [[ "$EVENT_NAME" == "push" ]] \
&& [[ -n "$BEFORE_SHA" ]] \
&& [[ ! "$BEFORE_SHA" =~ ^0+$ ]] \
&& git cat-file -e "$BEFORE_SHA^{commit}"; then
git diff --name-only --diff-filter=ACMRT \
"$BEFORE_SHA" "$CURRENT_SHA" -- '*.py' > changed-python-files.txt
else
git diff-tree --no-commit-id --name-only --diff-filter=ACMRT \
-r HEAD -- '*.py' > changed-python-files.txt
fi
sort -u -o changed-python-files.txt changed-python-files.txt
if [[ -s changed-python-files.txt ]]; then
echo "has_files=true" >> "$GITHUB_OUTPUT"
sed -n '1,200p' changed-python-files.txt
else
echo "has_files=false" >> "$GITHUB_OUTPUT"
echo "本次没有改动 Python 文件"
fi
# 运行pylint,检查主要的Python文件
echo "🚀 运行 Pylint 错误检查..."
- name: Run pylint on changed Python files
if: steps.changed.outputs.has_files == 'true'
run: |
xargs uv run --locked --no-sync pylint \
--output-format=colorized --reports=yes --score=yes \
< changed-python-files.txt
# 检查主要目录 - 只关注错误,如果有错误则退出
echo "📂 检查 app/ 目录..."
pylint app/ --output-format=colorized --reports=yes --score=yes
- name: Generate full advisory report
if: always()
run: |
uv run --locked --no-sync pylint app/ \
--output-format=json > pylint-report.json || true
# 检查根目录的Python文件
echo "📂 检查根目录 Python 文件..."
for file in $(find . -name "*.py" -not -path "./.*" -not -path "./.venv/*" -not -path "./build/*" -not -path "./dist/*" -not -path "./tests/*" -not -path "./docs/*" -not -path "./__pycache__/*" -maxdepth 1); do
echo "检查文件: $file"
pylint "$file" --output-format=colorized || exit 1
done
# 生成详细报告
echo "📊 生成 Pylint 详细报告..."
pylint app/ --output-format=json > pylint-report.json || true
# 显示评分(仅供参考)
echo "📈 Pylint 评分(仅供参考):"
pylint app/ --score=yes --reports=no | tail -2 || true
- name: Upload pylint report
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
if: always()
with:
name: pylint-report
@@ -104,5 +78,5 @@ jobs:
- name: Summary
run: |
echo "🎉 Pylint 检查完成!"
echo "✅ 改动 Python 文件没有新增语法错误或严重问题"
echo "📊 全仓建议性报告已保存为构建工件"
echo "✅ 没有发现语法错误或严重问题"
echo "📊 详细报告已保存为构建工件"
+6 -9
View File
@@ -49,17 +49,14 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.14'
- name: Set up uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version: '0.12.5'
enable-cache: true
cache-dependency-glob: scripts/site_adapter_collector_requirements.txt
python-version: '3.12'
cache: pip
cache-dependency-path: scripts/site_adapter_collector_requirements.txt
- name: Install build dependencies
run: uv pip install --system --requirement scripts/site_adapter_collector_requirements.txt
run: |
python -m pip install --upgrade pip setuptools wheel
pip install -r scripts/site_adapter_collector_requirements.txt
- name: Build single-file collector
run: |
+29 -115
View File
@@ -1,13 +1,13 @@
name: Unit Tests
on:
# 指向 v3 的 PR 与推送都跑全量单测,作为合并门禁
# 指向 v2 的 PR 与推送都跑全量单测,作为合并门禁
pull_request:
branches:
- v3
- v2
push:
branches:
- v3
- v2
# 允许手动触发
workflow_dispatch:
@@ -19,138 +19,52 @@ concurrency:
cancel-in-progress: true
jobs:
architecture:
runs-on: ubuntu-latest
name: Architecture Contract Gate
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Set up uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version: '0.12.5'
python-version: '3.14'
enable-cache: true
cache-dependency-glob: |
pyproject.toml
uv.lock
- name: Install dependencies
run: uv sync --locked
- name: Check host architecture contracts
run: |
uv run --locked --no-sync pytest \
tests/test_architecture_dependencies.py \
tests/test_architecture_adapter_imports.py \
tests/test_architecture_egress.py \
tests/test_architecture_contract_baseline.py \
tests/test_architecture_baseline_cli.py -q
uv run --locked --no-sync python \
scripts/architecture/baseline.py --check-host
- name: Check governed Python types
run: uv run --locked --no-sync mypy --config-file mypy.ini
- name: Check complexity ratchet
run: uv run --locked --no-sync python scripts/architecture/complexity.py
- name: Check async blocking ratchet
run: uv run --locked --no-sync python scripts/architecture/async_blocking.py
- name: Check background task ownership
run: uv run --locked --no-sync python scripts/architecture/task_ownership.py
- name: Check process runtime service locators
run: uv run --locked --no-sync python scripts/architecture/service_locator.py
- name: Check Ruff diagnostic ratchet
run: uv run --locked --no-sync python scripts/architecture/ruff_ratchet.py
- name: Check mypy error ratchet
run: uv run --locked --no-sync python scripts/architecture/mypy_ratchet.py
- name: Check startup performance contract
run: >-
uv run --locked --no-sync python
scripts/startup/performance.py --check --repeat 3
pytest:
runs-on: ubuntu-latest
name: Unit Tests (${{ matrix.shard }})
name: Unit Tests
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
- shard: '1/4'
- shard: '2/4'
- shard: '3/4'
- shard: '4/4'
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Set up uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
- name: Set up Python
uses: actions/setup-python@v6
with:
version: '0.12.5'
python-version: '3.14'
enable-cache: true
cache-dependency-glob: |
pyproject.toml
uv.lock
python-version: '3.12'
cache: 'pip'
- name: Cache pip dependencies
uses: actions/cache@v5
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.in', '**/requirements-dev.in', '**/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install dependencies
run: uv sync --locked
run: |
python -m pip install --upgrade pip setuptools wheel
# 单测需要开发/测试依赖;运行时入口 requirements.in 不携带测试与构建辅助工具。
pip install -r requirements-dev.in
- name: Run tests
timeout-minutes: 10
run: uv run --locked --no-sync python tests/run.py --shard "${{ matrix.shard }}"
coverage:
runs-on: ubuntu-latest
name: Coverage Report
timeout-minutes: 20
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Set up uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version: '0.12.5'
python-version: '3.14'
enable-cache: true
cache-dependency-glob: |
pyproject.toml
uv.lock
- name: Install dependencies
run: uv sync --locked
- name: Generate coverage reports
timeout-minutes: 10
run: |
uv run --locked --no-sync python -m coverage erase
uv run --locked --no-sync python -m coverage run tests/run.py --serial
uv run --locked --no-sync python -m coverage report
uv run --locked --no-sync python -m coverage json
uv run --locked --no-sync python -m coverage xml
# tests/run.py 以 pytest 跑 tests 全量;tests/conftest.py 在收集前把 CONFIG_DIR
# 指向临时库并建表;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
uses: actions/upload-artifact@v7
if: always()
uses: actions/upload-artifact@v6
with:
name: coverage-report
path: |
coverage.xml
coverage.json
retention-days: 7
- name: Check coverage ratchet
run: uv run --locked --no-sync python scripts/architecture/coverage_ratchet.py
+14 -13
View File
@@ -9,18 +9,23 @@ dist/
rust/**/target/
nginx/
test.py
app/application/site/*.bin
# 站点数据的运行期下载产物。上游 v3 架构重构后落点从 app/application/site 移到了
# app/helper,同目录的 .so/.pyd 由上面的通配兜住,只有 .bin 漏了网
safety_report.txt
app/helper/sites.py
app/helper/*.so
app/helper/*.pyd
app/helper/*.bin
app/plugins/**
!app/plugins/__init__.py
config/*
!config/category.yaml
# 运行期设置持久化目录(settings 写回 app.env 的落点)与本地验证产物
app/config/
.verify_tmp/
.artifacts/
config/cookies/
config/app.env
config/user.db*
config/sites/**
config/agent/
config/logs/
config/plugins/
config/temp/
config/cache/
config/.cache/
.runtime/
public/
.moviepilot.env
@@ -45,7 +50,3 @@ pylint-report.json
# Superpowers 设计/计划文档(本地协作产物,不纳入仓库)
docs/superpowers/
# 保留本地前端构建产物目录,目录内产物不纳入仓库
frontend-dist/*
!frontend-dist/.gitkeep
+2 -2
View File
@@ -73,5 +73,5 @@ ignore-imports=yes
[TYPECHECK]
# 生成缺失成员提示的类列表
generated-members=requests.packages.urllib3
# app.infrastructure.sites 会主动隐藏模块属性枚举,接口由同目录 sites.pyi 声明
ignored-modules=app.infrastructure.sites
# app.helper.sites 会主动隐藏模块属性枚举,避免误报 no-name-in-module
ignored-modules=app.helper.sites
-71
View File
@@ -1,71 +0,0 @@
vulnerabilities:
- id: GHSA-6v7p-g79w-8964
paths:
- Python
purls:
- pkg:pypi/msgpack@1.1.2
expired_at: 2026-11-20
statement: The finding belongs to the base image's system pip and is not imported by MoviePilot.
- id: CVE-2025-47273
paths:
- Python
purls:
- pkg:pypi/setuptools@70.3.0
expired_at: 2026-11-20
statement: The finding belongs to the base image's system pip and is not used for dependency installation.
- id: CVE-2026-33818
paths:
- usr/bin/rclone
purls:
- pkg:golang/stdlib@v1.26.5
expired_at: 2026-11-20
statement: The official rclone binary has no patched release for this embedded Go runtime yet.
- id: CVE-2026-39821
paths:
- usr/bin/rclone
purls:
- pkg:golang/stdlib@v1.26.5
expired_at: 2026-11-20
statement: The official rclone binary has no patched release for this embedded Go runtime yet.
- id: CVE-2026-46600
paths:
- usr/bin/rclone
purls:
- pkg:golang/stdlib@v1.26.5
expired_at: 2026-11-20
statement: The official rclone binary has no patched release for this embedded Go runtime yet.
- id: CVE-2026-56853
paths:
- usr/bin/rclone
purls:
- pkg:golang/stdlib@v1.26.5
expired_at: 2026-11-20
statement: The official rclone binary has no patched release for this embedded Go runtime yet.
- id: CVE-2026-56858
paths:
- usr/bin/rclone
purls:
- pkg:golang/stdlib@v1.26.5
expired_at: 2026-11-20
statement: The official rclone binary has no patched release for this embedded Go runtime yet.
- id: CVE-2026-56859
paths:
- usr/bin/rclone
purls:
- pkg:golang/stdlib@v1.26.5
expired_at: 2026-11-20
statement: The official rclone binary has no patched release for this embedded Go runtime yet.
- id: CVE-2026-56860
paths:
- usr/bin/rclone
purls:
- pkg:golang/stdlib@v1.26.5
expired_at: 2026-11-20
statement: The official rclone binary has no patched release for this embedded Go runtime yet.
- id: CVE-2026-56862
paths:
- usr/bin/rclone
purls:
- pkg:golang/stdlib@v1.26.5
expired_at: 2026-11-20
statement: The official rclone binary has no patched release for this embedded Go runtime yet.
+22 -77
View File
@@ -6,7 +6,7 @@ This file is the primary instruction set for all AI agents and LLMs working in t
## Task-to-Documentation Mapping
For work that changes or reviews repository behavior, identify the domains actually touched and load only the applicable documents. Simple factual checks and unrelated domains do not require preloading rule files.
Before executing any task, identify the domain and load the corresponding document.
### Architectural Decisions
* **Primary Reference:** `docs/rules/05-architecture.md`
@@ -26,11 +26,12 @@ For work that changes or reviews repository behavior, identify the domains actua
### Comments and Documentation
* **Primary Reference:** `docs/rules/08-comment-styles.md`
* **Required Constraints:** Public or cross-module contracts and non-obvious business behavior require concise Chinese docstrings. Small self-evident private helpers and test scaffolding may omit them. Comments must explain the *why*, not restate the code.
* **Required Constraints:** All public classes and methods require Chinese docstrings. Comments must explain the *why*, not restate the code.
* **⚠️ MANDATORY GATE:** Code that is missing proper Chinese docstrings on public interfaces is **REJECTED** at review. No exceptions.
### External Communication and Interfaces
* **Primary Reference:** `docs/rules/09-external-response.md`
* **Required Constraints:** Host-authored ordinary HTTP must go through `RequestUtils`; this rule does not authorize Application/Chain to import the concrete Adapter. Canonical transport, SDK, streaming protocol, contained vendor, diagnostic and control-plane exceptions must match the exact direct-egress policy. Response formats must use the project's standard schemas. Error handling must follow the per-layer conventions.
* **Required Constraints:** All third-party HTTP requests must go through `RequestUtils`. Response formats must use the project's standard schemas. Error handling must follow the per-layer conventions.
### Data and Persistence
* **Primary Reference:** `docs/rules/10-data-and-persistent.md`
@@ -38,79 +39,15 @@ For work that changes or reviews repository behavior, identify the domains actua
### Quality and Security
* **Primary Reference:** `docs/rules/11-quality-and-security.md`
* **Required Constraints:** All code changes must pass the relevant pytest tests and pylint checks. Dependency changes require a current `uv.lock`, locked environment verification, and a passing locked dependency vulnerability audit.
* **Required Constraints:** All code changes must pass the relevant pytest tests and pylint checks. Dependency changes require a passing safety scan.
### Testing
* **Primary Reference:** `docs/testing.md`
* **Required Constraints:** pytest is the only runner; `tests/conftest.py` isolates each run to a temporary `CONFIG_DIR`. Tests must not touch the real database, network, or external services (TMDB, LLM catalogs, downloaders, media servers, MP server) — mock at the boundary or replay recorded responses; the bar is zero real outbound traffic. Tests must restore any process-level state they stub (`sys.modules`, singletons, caches, settings). New tests must be pytest-native (function + `assert` + fixtures); do not add new `unittest.TestCase`. Convert existing `TestCase` files to pytest-native opportunistically when you modify them. Before opening a PR to `v3`, run the affected tests and applicable local checks. Run the full local suite (`uv run --locked --no-sync python tests/run.py`) for dependency or lock changes, shared test infrastructure, database or startup paths, cross-module lifecycle, compatibility layers, broad behavior changes, or an explicit maintainer requirement. The changed path must pass; any unrelated failure must be reported and reproduced against the current `upstream/v3` baseline instead of silently expanding the PR. Documentation-only changes use applicable text and structure checks; the `.github/workflows/test.yml` gate remains the final full-suite check on every PR/push to `v3`.
* **Required Constraints:** pytest is the only runner; `tests/conftest.py` isolates each run to a temporary `CONFIG_DIR`. Tests must not touch the real database, network, or external services (TMDB, LLM catalogs, downloaders, media servers, MP server) — mock at the boundary or replay recorded responses; the bar is zero real outbound traffic. Tests must restore any process-level state they stub (`sys.modules`, singletons, caches, settings). New tests must be pytest-native (function + `assert` + fixtures); do not add new `unittest.TestCase`. Convert existing `TestCase` files to pytest-native opportunistically when you modify them. Before opening a PR to `v2`, run the full suite locally (`python tests/run.py`) and confirm it is green with zero real network calls; the `.github/workflows/test.yml` gate runs the same suite on every PR/push to `v2`.
### Commands and Development Workflow
* **Primary Reference:** `docs/rules/03-commands.md`
* **Required Constraints:** Use that file as the project command reference. Other standard inspection, Git, GitHub, and focused verification commands are allowed when they are necessary, scoped, and consistent with current authorization.
---
## Canonical Package Ownership
The historical `app/core`, `app/helper`, and `app/utils` directories are compatibility-only virtual import roots. Never add physical Python source there and never use those imports from host code. Choose an owner by responsibility, not by whether a function is "shared" or has historically been called a helper.
The legacy roots have no physical directories in the source tree. Current images and update flows write site resources only to `app/application/site/`; plugin imports under `app.helper.*` are resolved exclusively by the exact runtime compatibility manifest.
| Package | Owns | Must Not Own | Representative Files |
|---|---|---|---|
| `app/foundation/` | 无状态、无配置和无 I/O 的底层机制:反射/动态导入、加密、DOM、身份、集合、单例、文本、URL 和版本比较 | `settings`、DB/SystemConfig、网络请求、运行日志、MoviePilot 业务规则、旧导入路径 | `reflection.py`, `crypto.py`, `collections.py`, `text.py`, `url.py` |
| `app/domain/` | Pure MoviePilot business semantics and models for media, recognition, sites, and torrents | Persistence, global settings reads, network/filesystem clients, Rust imports, service discovery, process lifecycle | `context.py`, `media.py`, `metainfo.py`, `scraper.py`, `meta/` |
| `app/runtime/` | 进程级运行机制和策略:配置、事件、完整日志、缓存契约/内存行为、托管资源门面、并发、调度、限流、本地化、GC 和重启状态 | 具体外部产品、业务流程、Redis/文件缓存实现 | `config.py`, `events.py`, `log.py`, `cache.py`, `managed_resources.py`, `thread.py`, `state.py` |
| `app/runtime/extensions/` | 模块、插件、配置化服务和托管资源实现的发现、注册与生命周期适配 | 通用反射机制、插件公开 API、无关业务流程 | `module_manager.py`, `plugin_manager.py`, `managed_resource_adapter.py`, `service_registry.py` |
| `app/adapters/network/` | HTTP、浏览器、DNS、Cloudflare 和 IP 等通用网络技术适配 | RSS/站点业务编排、身份认证策略、命名外部产品流程 | `http.py`, `browser.py`, `doh.py`, `ip.py` |
| `app/adapters/cache/` | Redis 与文件缓存等具体持久化实现 | 缓存协议、装饰器和进程内缓存策略 | `backends.py`, `redis.py` |
| `app/adapters/system/` | 操作系统、文件、进程、标准流、包/资源安装、显示和 Rust 加速适配 | 业务规则、进程重启决策 | `host.py`, `display/`, `stdio.py`, `package.py`, `resource.py`, `rust.py`, `fsproxy.py` |
| `app/adapters/external/` | CookieCloud、插件市场、OCR、IP 归属和 MoviePilot Server 等命名外部生态 | 通用 HTTP/DNS/文件机制或可复用领域语义 | `market.py`, `server.py`, `cookiecloud.py`, `ocr.py`, `location.py`, `wechat_crypt.py` |
| `app/application/` | 聚焦应用服务、用例命令,以及由用例拥有的持久化/技术能力 Port/Protocol | SQLAlchemy、Session、Oper 等具体 DB 实现,具体 Adapter 静态依赖,多领域 Chain 编排、底层通用机制、通用传输协议 | `recognition.py`, `filter.py`, `outbox.py`, `subscription/write.py`, `workflow.py` |
| `app/application/messaging/` | 消息渲染/路由、交互和 Agent 到消息桥接:`ingress.py` 统一渠道回环入口;`interaction.py` 通用交互契约和视图工具;`router.py` 统一交互优先级和回调分发;`site.py`/`subscribe.py`/`skill.py` 对应命令的会话、输入解析和视图;`media.py` 媒体交互状态(业务工作流仍由 `MediaInteractionChain` 执行);`plugin.py` 插件输入接管和插件按钮回调;`agent.py` Agent 选择状态、回调协议和 WebAgent 消息桥接;`message.py` 通知渲染、模板和队列。不作为推荐给插件直接使用的公开 SDK | 认证策略、通用 HTTP、服务发现、仅端点使用的 Web Push 行为 | `ingress.py`, `message.py`, `interaction.py`, `router.py`, `agent.py` |
| `app/application/security/` | 认证、授权、Cookie、Passkey、OTP/二次认证、路径/URL 安全、SSRF 和签名策略 | 通用 URL 解析、进程运行策略、普通业务校验 | `access.py`, `auth.py`, `cookie.py`, `passkey.py`, `otp.py`, `twofactor.py`, `url.py` |
| `app/chain/` | Reusable use-case orchestration across modules, Application services, injected ports, events, and caches; chains reach modules only through `run_module` dispatch on method-name contracts | Transport schemas, backend-specific protocol details, concrete Adapter imports, generic primitives, direct Oper/DB imports, direct imports of module internals (classes, exceptions, constants) | `media.py`, `download.py`, `subscribe.py`, `transfer.py` |
| `app/db/oper/` | 面向表和持久化值的 SQLAlchemy 数据访问;接收调用方 Session,只查询、暂存或 flush | Application 业务规则、隐式事务所有权、外部副作用 | `subscribe.py`, `site.py`, `workflow.py` |
| `app/db/adapters/` | 实现 Application 持久化 Port,创建短生命周期 Session/UoW,并适配 Oper | 用例规则、启动顺序、进程生命周期 | `subscription.py`, `site.py`, `outbox.py`, `workflow.py` |
| `app/startup/` | Composition root: `composition/` 构造并注入跨层依赖,`initializers/` 按领域初始化,`lifecycle/` 编排启动关闭 | Reusable business rules or adapter implementation details | `composition/context.py`, `composition/database.py`, `initializers/modules.py`, `lifecycle/components.py` |
| `app/sdk/` | Deliberately curated stable imports for new plugins | Canonical implementation logic or host-internal dependencies | `browser.py`, `cache.py`, `logging.py`, `media.py`, `network.py`, `services.py` |
| `app/runtime/compat/` | 仅依赖标准库的精确旧导入路由、资源前置扫描和 DEBUG 诊断 | 业务实现、通配猜测、目标模块的提前导入 | `manifest.py`, `imports.py`, `resource_imports.py`, `diagnostics.py` |
容易误分的三个边界必须按实际职责判断:`application/rss.py` 同时承担 Feed/种子语义、站点规则和浏览器回退,不是单纯 HTTP 传输;规范目标是由它拥有所需 Port、startup 注入 network/system Adapter。当前直接导入是 `S2-L6` 临时债务,不是允许的新模式。`application/site/sites.*``user.sites.v3.bin` 共同构成站点目录、认证和索引应用能力,只有下载安装机制留在 `adapters/system/resource.py``foundation/crypto.py` 只提供无状态 RSA/摘要/AES 算法,认证、签名、令牌和二次验证策略仍属于 `application/security/`
### Placement Decision Order
Use these questions in order before creating or moving a module:
1. Is it generic, free of MoviePilot state and I/O? Put it in `foundation`.
2. Is it a pure core MoviePilot rule/model that is independent of a configured service boundary? Put it in `domain`.
3. Is it process-wide runtime policy or a contract used by adapters? Put it in `runtime`.
4. Does it discover or manage modules/plugins/service implementations? Put it in `runtime/extensions`.
5. Does it perform configured network, cache, OS/process, file, package/resource, stdio, or Rust I/O? Put it under the matching `adapters` technical boundary.
6. Does it implement a named external product/ecosystem workflow? Put it in `adapters/external`.
7. Does it own authentication, authorization, signing, SSRF, URL/path safety, OTP, passkeys, or two-factor behavior? Put it in `application/security`.
8. Does it define a use case or the persistence Port required by that use case? Put it in `application`; do not import concrete DB there.
9. Does it implement an Application persistence Port with SQLAlchemy Session/UoW/Oper? Put it in `db/adapters`.
10. Does it coordinate several modules/services/Oper classes for one use case? Put it in `chain`.
11. Is it public to plugins or only preserving an old path? Curate it in `sdk` or map it in `runtime/compat`; do not move implementation there.
### Enforced Split Examples
These decisions are architectural constraints, not naming suggestions:
* Cache contracts, memory backends, decorators, and proxies stay in `app/runtime/cache.py`; Redis and filesystem implementations stay in `app/adapters/cache/backends.py`. Startup registers concrete factories before decorated business modules are imported. Legacy `app.core.cache` resolves to the complete `app.sdk.cache` facade.
* The complete logging runtime stays in `app/runtime/log.py`: policy, console/plugin routing, async rotating file output, and shutdown. `app.runtime.config` supplies the resolved settings and log path. `runtime/log.py` remains a dependency leaf with no `app.*` imports. Plugins use `app.sdk.logging`; legacy `app.log` resolves to that SDK facade.
* Recognition parsing stays pure in `app/domain/meta/` and `app/domain/metainfo.py`. `app/application/recognition.py` consumes injected configuration; `app/startup/initializers/domain.py` injects rules, extension policy, source defaults, TMDB image construction, and the optional Rust accelerator.
* Kodi-style NFO reading and metadata document generation are one domain capability and stay together in `app/domain/scraper.py`; a separate `domain/nfo.py` must not be recreated.
* `app/application/mediaserver.py` is the single media-server service capability module. It owns configured service discovery together with Provider ID normalization and music-library matching, while reusing generic identity rules from `app/domain/media.py`.
* Configured notification-service discovery belongs in `app/application/notification.py`. Web Push subscription and manual-send HTTP behavior stays in `app/api/endpoints/message.py`; it is not a reusable messaging capability module.
* `app/adapters/system/resource.py` detects/downloads/installs resources and returns whether installation occurred. Only `app/startup/initializers/modules.py` may decide to restart the process afterward.
* Process memory/GC policy belongs in `app/runtime/gc.py`; external IP-location APIs belong in `app/adapters/external/location.py`.
* Security implementation filenames use package-context nouns: `app/application/security/url.py` and `app/application/security/twofactor.py`. Historical `app.utils.security` and `app.helper.twofa` remain compatibility mappings only.
Foundation modules do not emit runtime logs. They return documented fallback values or raise according to their public contract; application callers decide whether a failure is operationally relevant and log it from the owning upper layer.
Any ownership move must update canonical host imports, `app/runtime/compat/manifest.py`, curated SDK exports when applicable, `docs/rules/05-architecture.md`, and `tests/test_architecture_dependencies.py`. Run that architecture test before broader tests; it rejects physical legacy sources, forbidden upward dependencies, retired canonical filenames, and import cycles.
* **Required Constraints:** Only suggest or execute commands documented in that file. Do not assume tool defaults or global flags.
---
@@ -118,22 +55,31 @@ Any ownership move must update canonical host imports, `app/runtime/compat/manif
### Pre-Flight Check
Before generating code or proposing changes, identify the domains the task actually touches and load only the corresponding documents from `docs/rules/`. Apply those constraints while designing, implementing, and reviewing the change; do not produce a formal checklist for unrelated domains.
Before generating any code or proposing changes, you must:
Architecture, persistence, security, external protocols, cross-module lifecycle, and public-contract changes require an explicit boundary check before implementation. Local documentation, mechanical maintenance, and narrowly scoped changes use only the rules that materially affect their correctness and reviewability.
1. Identify the task domain (architecture / business logic / coding style / naming / comments / external interfaces / data / quality).
2. Load the corresponding document from `docs/rules/`.
3. Explicitly verify that your proposed solution does not violate the following three mandatory constraints:
- **Naming Conventions (07):** Are all files, classes, functions, and constants named correctly?
- **Architecture Boundaries (05):** Is the code placed in the correct layer? Are all call directions valid?
- **Comment Standards (08):** Do all new public classes and methods include Chinese docstrings?
### Implementation Guidelines
* **Pattern Adherence:** Avoid generic boilerplate. If `04-design-patterns.md` defines a project-level pattern for a scenario, you are required to use it.
* **Documentation Standards:** Docstring style for any new function or module must match `08-comment-styles.md`.
* **Documentation Gate:** Public or cross-module contracts and non-obvious business behavior without useful Chinese documentation are rejected. Do not require comments that merely restate self-evident syntax.
* **Command Reliance:** Prefer commands documented in `03-commands.md`; use other necessary standard commands with explicit, scoped arguments.
* **⚠️ MANDATORY GATE:** Public classes, methods, and functions without proper Chinese docstrings are **REJECTED**. No exceptions.
* **Command Reliance:** Only suggest commands listed in `03-commands.md`. Do not rely on inferred tool defaults.
* **Minimal Change Principle:** Prefer the smallest correct change. Do not perform unrelated refactors, mass renames, or formatting-only cleanup.
* **Output Language:** Summaries, validation results, and risk notes default to Chinese unless the user requests otherwise.
### Conflict Resolution
If existing code appears to contradict the documentation, identify the exact contradiction and decide which current-task gate it affects. Stop and ask only when it blocks acceptance, creates a security or data-safety ambiguity, or cannot be resolved from current source and maintained documentation. Otherwise preserve the evidence, continue unaffected work, and report the discrepancy without silently expanding scope.
If existing code appears to contradict the documentation:
1. Stop implementation immediately.
2. Identify the specific file and line of the contradiction.
3. Prompt the user: "The documentation in `[File]` requires Pattern A, but the current implementation uses Pattern B. Which is the current standard?"
---
@@ -149,7 +95,6 @@ When modifying the following, you must also update the listed artifacts:
| Database model schema | New Alembic migration under `database/versions/` |
| User-visible config or init flow | Related docs, help text, setup/init flows, tests |
| New skill | Follow `skills/<name>/SKILL.md` structure, keep YAML front matter |
| Canonical module ownership or import path | `docs/rules/05-architecture.md`, `app/runtime/compat/manifest.py`, SDK exports when public, architecture/compatibility tests |
---
@@ -159,4 +104,4 @@ For the full documentation map and cross-references, refer to:
**[Documentation Hub Index](./docs/rules/README.md)**
*Last Updated: 2026-08-19*
*Last Updated: 2026-05-25*
+2 -3
View File
@@ -9,7 +9,6 @@
![GitHub issues](https://img.shields.io/github/issues/jxxghp/MoviePilot?style=for-the-badge)
![Docker Pulls](https://img.shields.io/docker/pulls/jxxghp/moviepilot?style=for-the-badge)
![Docker Pulls V2](https://img.shields.io/docker/pulls/jxxghp/moviepilot-v2?style=for-the-badge)
![Docker Pulls V3](https://img.shields.io/docker/pulls/jxxghp/moviepilot-v3?style=for-the-badge)
![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20Synology-blue?style=for-the-badge)
基于 [NAStool](https://github.com/NAStool/nas-tools) 部分代码重新设计,聚焦自动化核心需求,减少问题同时更易于扩展和维护。
@@ -27,7 +26,7 @@
## 安装使用
推荐优先使用 Docker 部署。V3 使用独立镜像 `jxxghp/moviepilot-v3`V2 和旧版镜像保持原命名。Compose 示例、环境变量、目录映射和升级方式以官方 Wiki 为准:
推荐优先使用 Docker 部署,常用镜像包括 `jxxghp/moviepilot-v2``jxxghp/moviepilot`。Compose 示例、环境变量、目录映射和升级方式以官方 Wiki 为准:
- 官方 Wikihttps://wiki.movie-pilot.org
- PostgreSQL 部署说明:[docs/postgresql-setup.md](docs/postgresql-setup.md)
@@ -35,7 +34,7 @@
也可以使用本地 CLI 以源码模式安装和管理 MoviePilot
```shell
curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v3/scripts/bootstrap-local.sh | bash
curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v2/scripts/bootstrap-local.sh | bash
```
安装完成后使用 `moviepilot` 命令完成初始化、启动、停止、更新和配置查看。完整命令见 [docs/cli.md](docs/cli.md)。
+2 -3
View File
@@ -9,7 +9,6 @@
![GitHub issues](https://img.shields.io/github/issues/jxxghp/MoviePilot?style=for-the-badge)
![Docker Pulls](https://img.shields.io/docker/pulls/jxxghp/moviepilot?style=for-the-badge)
![Docker Pulls V2](https://img.shields.io/docker/pulls/jxxghp/moviepilot-v2?style=for-the-badge)
![Docker Pulls V3](https://img.shields.io/docker/pulls/jxxghp/moviepilot-v3?style=for-the-badge)
![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20Synology-blue?style=for-the-badge)
Redesigned from parts of [NAStool](https://github.com/NAStool/nas-tools), with a stronger focus on core automation scenarios while reducing issues and making the project easier to extend and maintain.
@@ -27,7 +26,7 @@ Release channel: https://t.me/moviepilot_channel
## Installation and Usage
Docker is the recommended deployment model. V3 uses the dedicated `jxxghp/moviepilot-v3` image; V2 and legacy images keep their existing names. Compose examples, environment variables, volume mappings, and upgrade notes are maintained in the official wiki:
Docker is the recommended deployment model. Common images include `jxxghp/moviepilot-v2` and `jxxghp/moviepilot`. Compose examples, environment variables, volume mappings, and upgrade notes are maintained in the official wiki:
- Official wiki: https://wiki.movie-pilot.org
- PostgreSQL setup: [docs/postgresql-setup.md](docs/postgresql-setup.md)
@@ -35,7 +34,7 @@ Docker is the recommended deployment model. V3 uses the dedicated `jxxghp/moviep
MoviePilot can also be installed and managed from source with the local CLI:
```shell
curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v3/scripts/bootstrap-local.sh | bash
curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v2/scripts/bootstrap-local.sh | bash
```
After installation, use the `moviepilot` command for initialization, service management, updates, and configuration. See [docs/cli.md](docs/cli.md) for the full command reference.
+1 -10
View File
@@ -1,7 +1,5 @@
import warnings
from app.runtime.compat.imports import install_legacy_import_hook
def _filter_third_party_startup_warnings() -> None:
"""
@@ -9,16 +7,9 @@ def _filter_third_party_startup_warnings() -> None:
"""
warnings.filterwarnings(
"ignore",
message=r"'_UnionGenericAlias' is deprecated and slated for removal in Python 3\.17",
category=DeprecationWarning,
module=r"google\.genai\.types",
)
warnings.filterwarnings(
"ignore",
message=r'"\\&" is an invalid escape sequence\..*',
message=r"invalid escape sequence '\\&'",
category=SyntaxWarning,
)
_filter_third_party_startup_warnings()
install_legacy_import_hook()
-1
View File
@@ -1 +0,0 @@
"""MoviePilot 技术与外部系统适配器。"""
-1
View File
@@ -1 +0,0 @@
"""缓存持久化适配器。"""
-343
View File
@@ -1,343 +0,0 @@
import shutil
import tempfile
from pathlib import Path
from typing import Any, AsyncGenerator, Generator, Optional, Tuple
import aiofiles
import aioshutil
from anyio import Path as AsyncPath
from app.adapters.cache.redis import AsyncRedisHelper, RedisHelper
from app.runtime.cache import (
DEFAULT_CACHE_REGION,
AsyncCacheBackend,
CacheBackend,
configure_cache_factories,
)
from app.runtime.settings import get_runtime_setting
class RedisBackend(CacheBackend):
"""通过同步 Redis 客户端实现缓存后端。"""
def __init__(self, ttl: Optional[int] = None) -> None:
"""初始化 Redis 缓存并保存默认 TTL。"""
self.ttl = ttl
self.redis_helper = RedisHelper()
def set(
self,
key: str,
value: Any,
ttl: Optional[int] = None,
region: Optional[str] = DEFAULT_CACHE_REGION,
**kwargs,
) -> None:
"""写入缓存,非正 TTL 视为立即删除。"""
ttl = self.ttl if ttl is None else ttl
if ttl is not None and ttl <= 0:
self.redis_helper.delete(key, region=region)
return
self.redis_helper.set(key, value, ttl=ttl, region=region, **kwargs)
def exists(
self,
key: str,
region: Optional[str] = DEFAULT_CACHE_REGION,
) -> bool:
"""判断缓存键是否存在。"""
return self.redis_helper.exists(key, region=region)
def get(
self,
key: str,
region: Optional[str] = DEFAULT_CACHE_REGION,
) -> Optional[Any]:
"""读取缓存值,不存在时返回空值。"""
return self.redis_helper.get(key, region=region)
def delete(
self,
key: str,
region: Optional[str] = DEFAULT_CACHE_REGION,
) -> None:
"""删除缓存键。"""
self.redis_helper.delete(key, region=region)
def clear(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> None:
"""清空指定缓存区或全部缓存。"""
self.redis_helper.clear(region=region)
def items(
self,
region: Optional[str] = DEFAULT_CACHE_REGION,
) -> Generator[Tuple[str, Any], None, None]:
"""遍历指定缓存区的键值对。"""
return self.redis_helper.items(region=region)
def close(self) -> None:
"""关闭同步 Redis 连接池。"""
self.redis_helper.close()
@staticmethod
def is_redis() -> bool:
"""标记当前后端为 Redis。"""
return True
class AsyncRedisBackend(AsyncCacheBackend):
"""通过异步 Redis 客户端实现缓存后端。"""
def __init__(self, ttl: Optional[int] = None) -> None:
"""初始化异步 Redis 缓存并保存默认 TTL。"""
self.ttl = ttl
self.redis_helper = AsyncRedisHelper()
async def set(
self,
key: str,
value: Any,
ttl: Optional[int] = None,
region: Optional[str] = DEFAULT_CACHE_REGION,
**kwargs,
) -> None:
"""异步写入缓存,非正 TTL 视为立即删除。"""
ttl = self.ttl if ttl is None else ttl
if ttl is not None and ttl <= 0:
await self.redis_helper.delete(key, region=region)
return
await self.redis_helper.set(key, value, ttl=ttl, region=region, **kwargs)
async def exists(
self,
key: str,
region: Optional[str] = DEFAULT_CACHE_REGION,
) -> bool:
"""异步判断缓存键是否存在。"""
return await self.redis_helper.exists(key, region=region)
async def get(
self,
key: str,
region: Optional[str] = DEFAULT_CACHE_REGION,
) -> Optional[Any]:
"""异步读取缓存值,不存在时返回空值。"""
return await self.redis_helper.get(key, region=region)
async def delete(
self,
key: str,
region: Optional[str] = DEFAULT_CACHE_REGION,
) -> None:
"""异步删除缓存键。"""
await self.redis_helper.delete(key, region=region)
async def clear(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> None:
"""异步清空指定缓存区或全部缓存。"""
await self.redis_helper.clear(region=region)
async def items(
self,
region: Optional[str] = DEFAULT_CACHE_REGION,
) -> AsyncGenerator[Tuple[str, Any], None]:
"""异步遍历指定缓存区的键值对。"""
async for item in self.redis_helper.items(region=region):
yield item
async def close(self) -> None:
"""关闭异步 Redis 连接池。"""
await self.redis_helper.close()
@staticmethod
def is_redis() -> bool:
"""标记当前后端为 Redis。"""
return True
class FileBackend(CacheBackend):
"""通过本地文件系统保存二进制缓存。"""
def __init__(self, base: Path) -> None:
"""初始化缓存根目录。"""
self.base = base
self.base.mkdir(parents=True, exist_ok=True)
def set(
self,
key: str,
value: Any,
region: Optional[str] = DEFAULT_CACHE_REGION,
**_kwargs,
) -> None:
"""原子写入一个二进制缓存文件。"""
cache_path = self.base / region / key
cache_path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
dir=cache_path.parent,
delete=False,
) as tmp_file:
tmp_file.write(value)
temp_path = Path(tmp_file.name)
temp_path.replace(cache_path)
def exists(
self,
key: str,
region: Optional[str] = DEFAULT_CACHE_REGION,
) -> bool:
"""判断缓存文件是否存在。"""
return (self.base / region / key).exists()
def get(
self,
key: str,
region: Optional[str] = DEFAULT_CACHE_REGION,
) -> Optional[Any]:
"""读取二进制缓存文件。"""
cache_path = self.base / region / key
if not cache_path.exists():
return None
with cache_path.open("rb") as file_handle:
return file_handle.read()
def delete(
self,
key: str,
region: Optional[str] = DEFAULT_CACHE_REGION,
) -> None:
"""删除缓存文件或缓存子目录。"""
cache_path = self.base / region / key
if cache_path.is_file():
cache_path.unlink()
elif cache_path.exists():
shutil.rmtree(cache_path, ignore_errors=True)
def clear(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> None:
"""清空指定缓存区或缓存根目录。"""
cache_path = self.base / region if region else self.base
if not cache_path.exists():
return
for item in cache_path.iterdir():
if item.is_file():
item.unlink()
else:
shutil.rmtree(item, ignore_errors=True)
def items(
self,
region: Optional[str] = DEFAULT_CACHE_REGION,
) -> Generator[Tuple[str, Any], None, None]:
"""按相对键遍历指定缓存区中的二进制文件。"""
cache_path = self.base / region
if not cache_path.exists():
return
for item in sorted(cache_path.rglob("*")):
if item.is_file():
with item.open("rb") as file_handle:
yield item.relative_to(cache_path).as_posix(), file_handle.read()
def close(self) -> None:
"""文件缓存没有需要关闭的持久连接。"""
class AsyncFileBackend(AsyncCacheBackend):
"""通过异步文件接口保存二进制缓存。"""
def __init__(self, base: Path) -> None:
"""初始化异步缓存根目录。"""
self.base = base
self.base.mkdir(parents=True, exist_ok=True)
async def set(
self,
key: str,
value: Any,
region: Optional[str] = DEFAULT_CACHE_REGION,
**_kwargs,
) -> None:
"""异步原子写入一个二进制缓存文件。"""
cache_path = AsyncPath(self.base) / region / key
await cache_path.parent.mkdir(parents=True, exist_ok=True)
async with aiofiles.tempfile.NamedTemporaryFile(
dir=cache_path.parent,
delete=False,
) as tmp_file:
await tmp_file.write(value)
temp_path = AsyncPath(tmp_file.name)
await temp_path.replace(cache_path)
async def exists(
self,
key: str,
region: Optional[str] = DEFAULT_CACHE_REGION,
) -> bool:
"""异步判断缓存文件是否存在。"""
return await (AsyncPath(self.base) / region / key).exists()
async def get(
self,
key: str,
region: Optional[str] = DEFAULT_CACHE_REGION,
) -> Optional[Any]:
"""异步读取二进制缓存文件。"""
cache_path = AsyncPath(self.base) / region / key
if not await cache_path.exists():
return None
async with aiofiles.open(cache_path, "rb") as file_handle:
return await file_handle.read()
async def delete(
self,
key: str,
region: Optional[str] = DEFAULT_CACHE_REGION,
) -> None:
"""异步删除缓存文件或缓存子目录。"""
cache_path = AsyncPath(self.base) / region / key
if await cache_path.is_file():
await cache_path.unlink()
elif await cache_path.exists():
await aioshutil.rmtree(cache_path, ignore_errors=True)
async def clear(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> None:
"""异步清空指定缓存区或缓存根目录。"""
cache_path = AsyncPath(self.base) / region if region else AsyncPath(self.base)
if not await cache_path.exists():
return
async for item in cache_path.iterdir():
if await item.is_file():
await item.unlink()
else:
await aioshutil.rmtree(item, ignore_errors=True)
async def items(
self,
region: Optional[str] = DEFAULT_CACHE_REGION,
) -> AsyncGenerator[Tuple[str, Any], None]:
"""异步按相对键遍历指定缓存区中的二进制文件。"""
cache_path = AsyncPath(self.base) / region
if not await cache_path.exists():
return
async for item in cache_path.rglob("*"):
if await item.is_file():
key = Path(str(item)).relative_to(Path(str(cache_path))).as_posix()
async with aiofiles.open(item, "rb") as file_handle:
yield key, await file_handle.read()
async def close(self) -> None:
"""异步文件缓存没有需要关闭的持久连接。"""
def configure_platform_cache() -> None:
"""把配置感知的 Redis 与文件适配器注册到平台缓存工厂。"""
configure_cache_factories(
backend_type_provider=lambda: get_runtime_setting('CACHE_BACKEND_TYPE'),
redis_factory=lambda ttl: RedisBackend(ttl=ttl),
async_redis_factory=lambda ttl: AsyncRedisBackend(ttl=ttl),
file_factory=lambda base: FileBackend(
base=base or get_runtime_setting('TEMP_PATH')
),
async_file_factory=lambda base: AsyncFileBackend(
base=base or get_runtime_setting('TEMP_PATH')
),
file_ttl_provider=lambda: get_runtime_setting('TEMP_FILE_DAYS') * 24 * 3600,
)
-1
View File
@@ -1 +0,0 @@
"""插件市场、CookieCloud、OCR 和远程 MoviePilot 服务集成。"""
-1
View File
@@ -1 +0,0 @@
"""插件市场外部适配器。"""
-124
View File
@@ -1,124 +0,0 @@
"""插件市场查询客户端。"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Optional, cast
from app.adapters.external.market import PluginHelper as _PluginHelper
from app.runtime.cache import async_fresh, fresh
class PluginMarketClient:
"""把插件市场、版本元数据和本地仓库查询隔离为只读客户端。"""
def __init__(self, helper: Optional[_PluginHelper] = None) -> None:
"""复用旧 PluginHelper 实现,保持缓存和弱单例身份不变。"""
self._helper = helper or _PluginHelper()
def get_plugins(
self,
repo_url: str,
package_version: Optional[str] = None,
force: bool = False,
) -> Optional[dict[str, dict[str, Any]]]:
"""同步读取指定仓库和代际的插件索引。"""
with fresh(force):
return self._helper.get_plugins(repo_url, package_version)
async def async_get_plugins(
self,
repo_url: str,
package_version: Optional[str] = None,
force: bool = False,
) -> Optional[dict[str, dict]]:
"""异步读取指定仓库和代际的插件索引。"""
async with async_fresh(force):
return await self._helper.async_get_plugins(repo_url, package_version)
def get_plugin_index_result(
self,
repo_url: str,
package_version: Optional[str] = None,
force: bool = False,
) -> Optional[dict[str, dict]]:
"""读取插件索引的三态结果,供库存读取保留失败事实。"""
with fresh(force):
return cast(
Optional[dict[str, dict[str, Any]]],
self._helper.get_plugin_index_result(repo_url, package_version),
)
async def async_get_plugin_index_result(
self,
repo_url: str,
package_version: Optional[str] = None,
force: bool = False,
) -> Optional[dict[str, dict[str, Any]]]:
"""异步读取插件索引的三态结果,供库存读取保留失败事实。"""
async with async_fresh(force):
return cast(
Optional[dict[str, dict[str, Any]]],
await self._helper.async_get_plugin_index_result(
repo_url,
package_version,
),
)
def get_local_candidates(self) -> dict[str, dict]:
"""返回全部本地插件仓库候选。"""
return self._helper.get_local_plugin_candidates()
def get_local_candidate(
self,
plugin_id: str,
package_version: Optional[str] = None,
repo_path: Optional[Path] = None,
**kwargs: Any,
) -> Optional[dict]:
"""返回指定插件的本地仓库候选。"""
return self._helper.get_local_plugin_candidate(
pid=plugin_id,
package_version=package_version,
repo_path=repo_path,
**kwargs,
)
@staticmethod
def get_local_repo_paths() -> list[Path]:
"""返回配置中有效的本地插件仓库目录。"""
return _PluginHelper.get_local_repo_paths()
@staticmethod
def make_local_repo_url(
plugin_id: str,
repo_path: Optional[object] = None,
package_version: Optional[str] = None,
) -> str:
"""生成兼容旧入口的本地插件来源标识。"""
return _PluginHelper.make_local_repo_url(
plugin_id,
repo_path,
package_version,
)
@staticmethod
def is_local_repo_url(repo_url: Optional[str]) -> bool:
"""判断插件来源是否为本地仓库标识。"""
return _PluginHelper.is_local_repo_url(repo_url)
@staticmethod
def annotate_system_version(plugin_info: dict) -> dict:
"""补充插件所需 MoviePilot 版本兼容状态。"""
return _PluginHelper.annotate_plugin_system_version(plugin_info)
@staticmethod
def is_package_compatible(
plugin_info: dict,
package_version: Optional[str],
) -> bool:
"""判断插件条目是否兼容目标插件包代际。"""
return _PluginHelper.is_package_plugin_compatible(
plugin_info,
package_version,
)
-1
View File
@@ -1 +0,0 @@
"""通用网络与 Web 协议适配器。"""
-1
View File
@@ -1 +0,0 @@
"""运行观测导出器适配器。"""
-49
View File
@@ -1,49 +0,0 @@
"""可选 OpenTelemetry metrics adapter。"""
from __future__ import annotations
import importlib
import os
from typing import Any, Mapping
from app.runtime.observability import MetricKind, MetricSpec, NoopObservationPort, ObservationPort
class OpenTelemetryObservationPort:
"""把内部指标合同映射到可选安装的 OpenTelemetry Metrics API。"""
def __init__(self, meter: Any) -> None:
"""保存 meter,并按名称惰性创建 instrument。"""
self._meter = meter
self._instruments: dict[str, Any] = {}
def record(self, spec: MetricSpec, value: float, labels: Mapping[str, str]) -> None:
"""按合同类型使用 OTel counter、histogram 或 up/down counter。"""
instrument = self._instruments.get(spec.name)
if instrument is None:
instrument = self._create_instrument(spec)
self._instruments[spec.name] = instrument
if spec.kind == MetricKind.HISTOGRAM:
instrument.record(value, attributes=dict(labels))
else:
instrument.add(value, attributes=dict(labels))
def _create_instrument(self, spec: MetricSpec) -> Any:
"""为内部指标类型创建对应 OTel instrument。"""
normalized = spec.name.replace(".", "_")
if spec.kind == MetricKind.HISTOGRAM:
return self._meter.create_histogram(normalized)
if spec.kind == MetricKind.COUNTER:
return self._meter.create_counter(normalized)
return self._meter.create_up_down_counter(normalized)
def build_observation_port() -> ObservationPort:
"""仅在显式启用且 API 可导入时创建 OTel adapter,否则返回 no-op。"""
if os.getenv("MOVIEPILOT_OTEL_METRICS") != "1":
return NoopObservationPort()
try:
metrics = importlib.import_module("opentelemetry.metrics")
except ImportError:
return NoopObservationPort()
return OpenTelemetryObservationPort(metrics.get_meter("moviepilot"))
-1
View File
@@ -1 +0,0 @@
"""操作系统、进程与运行资源适配器。"""
-1
View File
@@ -1 +0,0 @@
"""数据库备份的文件系统与数据库技术适配器命名空间。"""
-260
View File
@@ -1,260 +0,0 @@
"""基于活动 SQLAlchemy 引擎的 SQLite 与 PostgreSQL 备份实现。"""
from __future__ import annotations
import os
import shutil
import sqlite3
import subprocess
from contextlib import closing
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Mapping, Protocol, Sequence
from sqlalchemy.engine import Engine
@dataclass(frozen=True, slots=True)
class DatabaseBackupCheck:
"""数据库适配器返回的基础校验结果。"""
valid: bool
method: str
detail: str | None = None
class ProcessResult(Protocol):
"""数据库命令执行结果的最小合同。"""
returncode: int
stdout: str
stderr: str
class ProcessRunner(Protocol):
"""可替换的数据库命令执行边界。"""
def __call__(
self,
command: Sequence[str],
*,
env: Mapping[str, str],
capture_output: bool,
text: bool,
check: bool,
) -> ProcessResult:
"""执行命令并返回结果。"""
def verify_database_backup(
artifact: Path,
*,
db_type: str,
runner: ProcessRunner = subprocess.run,
tool_resolver: Callable[[str], str | None] = shutil.which,
pg_restore: str = "pg_restore",
) -> DatabaseBackupCheck:
"""在不访问活动数据库的前提下校验一个受管备份文件。"""
if db_type == "sqlite":
method = "PRAGMA integrity_check"
try:
# 正式备份不会再变化,immutable 可避免只读校验创建 WAL 旁路文件。
uri = f"{artifact.resolve().as_uri()}?mode=ro&immutable=1"
with closing(sqlite3.connect(uri, uri=True)) as connection:
rows = connection.execute("PRAGMA integrity_check").fetchall()
except sqlite3.Error as error:
return DatabaseBackupCheck(False, method, str(error))
valid = bool(rows) and all(row[0] == "ok" for row in rows)
detail = None if valid else "; ".join(str(row[0]) for row in rows)
return DatabaseBackupCheck(valid, method, detail)
if db_type == "postgresql":
method = "pg_restore --list"
executable = _require_tool(pg_restore, tool_resolver)
result = runner(
[executable, "--list", str(artifact)],
env=_postgres_environment(),
capture_output=True,
text=True,
check=False,
)
valid = result.returncode == 0 and bool(result.stdout.strip())
detail = None if valid else f"pg_restore 退出码 {result.returncode}"
return DatabaseBackupCheck(valid, method, detail)
raise ValueError(f"不支持的数据库备份类型:{db_type}")
def _require_tool(
executable: str,
tool_resolver: Callable[[str], str | None],
) -> str:
resolved = tool_resolver(executable)
if resolved is None:
raise RuntimeError(
f"未找到 {executable},请安装与服务端同主版本或更高的 "
"PostgreSQL client 并加入 PATH"
)
return resolved
def _postgres_environment(
*,
password: str | None = None,
sslmode: str | None = None,
) -> dict[str, str]:
environment = dict(os.environ)
environment.pop("PGPASSWORD", None)
environment.pop("PGSSLMODE", None)
if password:
environment["PGPASSWORD"] = password
if sslmode:
environment["PGSSLMODE"] = sslmode
return environment
class SQLiteBackupBackend:
"""使用 SQLite 在线备份 API 管理活动文件数据库。"""
db_type = "sqlite"
suffix = ".db"
def __init__(self, engine: Engine) -> None:
self._engine = engine
database = engine.url.database
if not database or database == ":memory:":
raise ValueError("SQLite 内存数据库不支持文件备份")
self._database = Path(database)
def create(self, destination: Path) -> None:
"""从活动引擎指向的 SQLite 文件创建一致快照。"""
source = self._engine.raw_connection()
try:
with closing(sqlite3.connect(destination)) as target:
source.driver_connection.backup(target)
target.commit()
finally:
source.close()
def verify(self, artifact: Path) -> DatabaseBackupCheck:
"""通过 SQLite integrity_check 校验备份内容。"""
return verify_database_backup(artifact, db_type=self.db_type)
def restore(self, artifact: Path) -> None:
"""在 CLI 离线进程中原子替换活动 SQLite 文件。"""
temporary = self._database.with_name(f".{self._database.name}.restore")
self._engine.dispose()
try:
shutil.copy2(artifact, temporary)
temporary.chmod(0o600)
self._database.with_name(f"{self._database.name}-wal").unlink(missing_ok=True)
self._database.with_name(f"{self._database.name}-shm").unlink(missing_ok=True)
os.replace(temporary, self._database)
finally:
temporary.unlink(missing_ok=True)
class PostgreSQLBackupBackend:
"""使用 pg_dump 与 pg_restore 管理活动 PostgreSQL 数据库。"""
db_type = "postgresql"
suffix = ".dump"
def __init__(
self,
engine: Engine,
*,
runner: ProcessRunner = subprocess.run,
tool_resolver: Callable[[str], str | None] = shutil.which,
pg_dump: str = "pg_dump",
pg_restore: str = "pg_restore",
) -> None:
self._engine = engine
self._runner = runner
self._tool_resolver = tool_resolver
self._pg_dump = pg_dump
self._pg_restore = pg_restore
def create(self, destination: Path) -> None:
"""创建 PostgreSQL custom-format 在线备份。"""
command = [
self._require_tool(self._pg_dump),
"--format=custom",
"--no-owner",
"--no-acl",
"--file",
str(destination),
*self._connection_arguments(),
]
result = self._run(command, include_password=True)
if result.returncode != 0:
raise RuntimeError(f"pg_dump 执行失败,退出码 {result.returncode}")
if not destination.is_file() or destination.stat().st_size == 0:
raise RuntimeError("pg_dump 未生成有效的备份文件")
def verify(self, artifact: Path) -> DatabaseBackupCheck:
"""通过 pg_restore 目录读取校验 custom-format 归档。"""
return verify_database_backup(
artifact,
db_type=self.db_type,
runner=self._runner,
tool_resolver=self._tool_resolver,
pg_restore=self._pg_restore,
)
def restore(self, artifact: Path) -> None:
"""在 CLI 离线进程中覆盖当前 PostgreSQL 数据库内容。"""
command = [
self._require_tool(self._pg_restore),
"--clean",
"--if-exists",
"--no-owner",
"--no-acl",
"--single-transaction",
"--exit-on-error",
*self._connection_arguments(),
str(artifact),
]
result = self._run(command, include_password=True)
if result.returncode != 0:
raise RuntimeError(f"pg_restore 执行失败,退出码 {result.returncode}")
def _connection_arguments(self) -> list[str]:
url = self._engine.url
host = str(url.query.get("host") or url.host or "")
port = str(url.query.get("port") or url.port or "")
arguments = [
"--username",
str(url.username or ""),
"--dbname",
str(url.database or ""),
]
if host:
arguments.extend(["--host", host])
if port:
arguments.extend(["--port", port])
return arguments
def _run(self, command: Sequence[str], *, include_password: bool) -> ProcessResult:
return self._runner(
command,
env=self._environment(include_password=include_password),
capture_output=True,
text=True,
check=False,
)
def _require_tool(self, executable: str) -> str:
return _require_tool(executable, self._tool_resolver)
def _environment(self, *, include_password: bool) -> dict[str, str]:
password = (
str(self._engine.url.password)
if include_password and self._engine.url.password
else None
)
sslmode = self._engine.url.query.get("sslmode")
return _postgres_environment(
password=password,
sslmode=str(sslmode) if sslmode else None,
)
-128
View File
@@ -1,128 +0,0 @@
"""数据库备份单文件的受限文件系统操作。"""
from __future__ import annotations
import os
import re
import tempfile
from datetime import datetime
from pathlib import Path
from app.runtime.version import get_app_version
_BACKUP_NAME = re.compile(
r"^(?:moviepilot_(?P<version>v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)_)?"
r"(?P<db_type>sqlite|postgresql)_"
r"(?P<timestamp>\d{8}_\d{6})"
r"(?:_(?P<sequence>\d+))?"
r"(?P<suffix>\.db|\.dump)$"
)
_RELEASE_VERSION = re.compile(r"^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$")
class BackupFiles:
"""把备份文件操作限制在一个私有根目录内。"""
def __init__(self, root: Path) -> None:
self.root = Path(root)
def create_temporary(self, suffix: str) -> Path:
"""在最终目录内创建私有临时文件,保证发布可使用原子替换。"""
self._ensure_root()
descriptor, filename = tempfile.mkstemp(
prefix=".database-",
suffix=f"{suffix}.partial",
dir=self.root,
)
os.close(descriptor)
path = Path(filename)
path.chmod(0o600)
return path
def publish(self, temporary: Path, name: str) -> Path:
"""把已校验临时文件发布为正式备份文件。"""
destination = self._resolve_name(name, require_exists=False)
os.replace(temporary, destination)
destination.chmod(0o600)
return destination
def discard(self, temporary: Path) -> None:
"""清理本次操作拥有的未发布临时文件。"""
path = Path(temporary)
if path.parent == self.root and path.name.startswith(".database-"):
path.unlink(missing_ok=True)
def list(self) -> list[Path]:
"""返回当前根目录内格式合法的正式备份文件。"""
if not self.root.is_dir():
return []
paths = [
path
for path in self.root.iterdir()
if path.is_file() and _BACKUP_NAME.fullmatch(path.name)
]
return sorted(paths, key=lambda path: (self.created_at(path.name), path.name), reverse=True)
def resolve(self, name: str) -> Path:
"""按受限文件名解析一个必须存在的备份文件。"""
return self._resolve_name(name, require_exists=True)
def delete(self, name: str) -> None:
"""删除一个已通过名称约束的备份文件。"""
self.resolve(name).unlink()
def available_name(
self,
*,
db_type: str,
created_at: datetime,
suffix: str,
) -> str:
"""生成包含数据库类型和秒级时间的简短可读文件名。"""
timestamp = created_at.strftime("%Y%m%d_%H%M%S")
version = get_app_version().strip()
if _RELEASE_VERSION.fullmatch(version) is None:
raise ValueError("程序版本号无法用于数据库备份命名")
base = f"moviepilot_{version}_{db_type}_{timestamp}"
candidate = f"{base}{suffix}"
sequence = 1
while (self.root / candidate).exists():
candidate = f"{base}_{sequence}{suffix}"
sequence += 1
if not _BACKUP_NAME.fullmatch(candidate):
raise ValueError("数据库备份文件名无效")
return candidate
@staticmethod
def database_type(name: str) -> str:
"""从受管文件名读取数据库类型。"""
return BackupFiles._match(name).group("db_type")
@staticmethod
def created_at(name: str) -> datetime:
"""从受管文件名读取本地创建时间。"""
return datetime.strptime(
BackupFiles._match(name).group("timestamp"),
"%Y%m%d_%H%M%S",
)
def _ensure_root(self) -> None:
self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
self.root.chmod(0o700)
def _resolve_name(self, name: str, *, require_exists: bool) -> Path:
normalized = str(name).strip()
self._match(normalized)
if Path(normalized).name != normalized:
raise ValueError("数据库备份文件名不能包含路径")
path = self.root / normalized
if require_exists and not path.is_file():
raise FileNotFoundError(normalized)
return path
@staticmethod
def _match(name: str) -> re.Match[str]:
matched = _BACKUP_NAME.fullmatch(str(name))
if matched is None:
raise ValueError("数据库备份文件名无效")
return matched
-53
View File
@@ -1,53 +0,0 @@
"""虚拟显示适配器及旧 DisplayHelper 兼容入口。"""
from __future__ import annotations
from importlib import import_module
from typing import Any
from app.foundation.singleton import Singleton
from app.runtime.log import logger
from app.runtime.managed_resources import (
acquire_managed_resource,
stop_managed_resource,
)
DISPLAY_CAPABILITY_ID = "host.display"
class DisplayHelper(metaclass=Singleton):
"""保留旧构造 API,并把资源所有权委托给 host.display 能力。"""
def __init__(self) -> None:
"""显式构造旧门面时激活虚拟显示,失败保持旧 API 的日志语义。"""
try:
acquire_managed_resource(
DISPLAY_CAPABILITY_ID,
reason="legacy_display_helper",
retry=True,
)
except Exception as error:
logger.error("DisplayHelper init error: %s", error)
def stop(self) -> None:
"""停止已激活的虚拟显示;未配置 Runtime 时保持幂等。"""
stop_managed_resource(
DISPLAY_CAPABILITY_ID,
reason="legacy_display_helper_stop",
)
__all__ = ["DISPLAY_CAPABILITY_ID", "DisplayHelper", "VirtualDisplayResource"]
def __getattr__(name: str) -> Any:
"""按需公开资源实现,普通兼容导入不加载显示后端。"""
if name != "VirtualDisplayResource":
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
value = getattr(
import_module("app.adapters.system.display.resource"),
"VirtualDisplayResource",
)
globals()[name] = value
return value
@@ -1,12 +0,0 @@
schema_version = 1
id = "host.display"
kind = "managed_resource.sync"
entrypoint = "app.adapters.system.display.resource:VirtualDisplayResource"
depends_on = []
[metadata]
name = "Virtual Display"
[activation]
policy = "on_first_use"
watch = []
-45
View File
@@ -1,45 +0,0 @@
"""虚拟显示进程的托管资源实现。"""
from __future__ import annotations
import os
from typing import Any, Optional
from app.adapters.system.host import SystemUtils
from app.runtime.log import logger
class VirtualDisplayResource:
"""按需拥有一个容器内虚拟显示进程。"""
def __init__(self) -> None:
self._display: Optional[Any] = None
@property
def display(self) -> Optional[Any]:
"""返回当前拥有的显示对象;未启动或已停止时为 None。"""
return self._display
def start(self) -> None:
"""仅在容器环境启动虚拟显示,重复启动保持幂等。"""
if self._display is not None or not SystemUtils.is_docker():
return
from pyvirtualdisplay import Display
display = Display(
visible=False,
size=(1024, 768),
extra_args=[os.environ["DISPLAY"]],
)
self._display = display
display.start()
def stop(self) -> None:
"""停止当前资源拥有的显示进程,失败时保留句柄供 Runtime 重试。"""
display = self._display
if display is None:
return
logger.info("正在停止虚拟显示...")
display.stop()
self._display = None
logger.info("虚拟显示已停止")
-483
View File
@@ -1,483 +0,0 @@
"""
本地文件系统操作代理。
FUSE/网络挂载有两种故障形态:crash 型(调用抛错,可捕获、可重试)和 block 型
(调用既不返回错误也不返回结果,永久悬挂)。**Python 无法中断一个已经发出的
系统调用,也无法强杀线程**,所以 block 型故障下阻塞的线程永远无法回收——这正是
整理消费线程停摆、监控自愈路径自冻的根因。
本模块把这些调用放进一个常驻子进程执行。子进程可以被 SIGKILL,因此超时后能真正
回收;对调用方而言,超时表现为一个普通的 OSError 子类(FileSystemTimeout)。
换句话说:**把不可处理的 block 型故障,转换成系统各层已经能正确处理的 crash 型
故障**——退避重启、登记待重试这些既有机制立刻就能接管。
第一版只放行安全的操作:
- 只读(stat/exists/listdir)——强杀不产生任何副作用
- 同存储 rename——内核保证原子性,强杀后要么完全成功要么完全没发生
跨存储的复制+删除不在此列,它需要单独的可恢复语义(临时名 + 完成后 rename)。
"""
import errno as errno_module
import json
import os
import selectors
import shutil
import subprocess
import sys
import threading
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Union
from app.runtime.log import logger
from app.runtime.settings import get_runtime_setting
# worker 脚本路径。用绝对路径直接执行,而不是 -m 或 import:
# 直接执行文件不会触发 app/__init__.py 的导入链,代理启动才是毫秒级的
_WORKER_PATH = Path(__file__).parent / "fsworker.py"
# 单次快操作(stat/listdir/rename/unlink 等)的默认超时秒数
DEFAULT_TIMEOUT = 30
# 长耗时操作(复制)两次进度上报之间的最长间隔秒数。
# worker 每秒上报一次心跳,因此这个阈值判定的是「传输完全没有推进」,
# 而不是「传输很慢」——大文件复制几小时也不会误杀
DEFAULT_STALL_TIMEOUT = 120
# 强杀代理后等待它消失的宽限秒数,不能无限等待
_KILL_GRACE = 5
class FileSystemTimeout(OSError):
"""
文件系统操作在代理中超时未返回,判定挂载无响应。
继承 OSError 是刻意的:整理链、监控 watcher 等各层对 OSError 已有完整的
退避重试与登记逻辑,block 型故障经此转换后可以直接复用它们。
"""
class FileSystemProxy:
"""
常驻子进程文件系统代理。
请求-响应严格串行(一个代理同时只处理一个请求),由锁保证。超时即强杀代理,
下一次请求自动重启一个新的——启动成本是毫秒级,因为 worker 只依赖标准库。
"""
def __init__(self, timeout: Optional[float] = None,
stall_timeout: Optional[float] = None):
"""
:param timeout: 单次快操作的超时秒数,None 表示实时跟随系统设置
:param stall_timeout: 长耗时操作两次进度上报之间的最长间隔秒数,
None 表示实时跟随系统设置
"""
self._timeout_override = timeout
self._stall_timeout_override = stall_timeout
self._process: Optional[subprocess.Popen] = None
self._selector: Optional[selectors.BaseSelector] = None
self._lock = threading.Lock()
# ------------------------------------------------------------------ #
# 对外操作
# ------------------------------------------------------------------ #
def stat(self, path: Path) -> Dict[str, Any]:
"""
读取路径属性。
:param path: 目标路径
:return: {"size", "mtime", "is_dir", "is_file"}
"""
return self._call("stat", path=str(path))
def exists(self, path: Path) -> bool:
"""
判断路径是否存在。
只有 FileNotFoundError 才算「不存在」;其余 OSError(含超时)原样抛出,
避免像 Path.exists() 那样把挂载抖动误判成文件消失。
:param path: 目标路径
:return: 是否存在
"""
try:
self._call("exists", path=str(path))
return True
except FileNotFoundError:
return False
def listdir(self, path: Path) -> List[str]:
"""
列出目录条目名。
:param path: 目标目录
:return: 条目名列表
"""
return self._call("listdir", path=str(path))
def count_entries(self, path: Path, max_check: int = 10000) -> Dict[str, int]:
"""
统计目录规模。整棵树的遍历在子进程内一次完成,超时可整体放弃。
:param path: 目标目录
:param max_check: 文件数上限,超过即提前结束
:return: {"file_count", "dir_count"}
"""
return self._call("count_entries", path=str(path), max_check=max_check)
def rename(self, src: Path, dst: Path) -> bool:
"""
同一存储内重命名/移动。跨存储会抛 OSError(EXDEV),由调用方走原有路径。
:param src: 源路径
:param dst: 目标路径
:return: 是否成功
"""
return self._call("rename", src=str(src), dst=str(dst))
def copy(self, src: Path, dst: Path,
progress_cb: Optional[Callable[[float], None]] = None,
cancel_cb: Optional[Callable[[], bool]] = None,
chunk_size: Optional[int] = None) -> Any:
"""
复制文件内容并保留时间戳,按「进度无推进」判定挂死。
复制大文件可能持续几小时,固定超时无法区分「正常但慢」和「已经挂死」。
worker 每秒上报一次进度作为心跳,这里判定的是**两次上报之间的间隔**:
超过 stall 阈值收不到任何一行,才认定挂载无响应并强杀 worker。
取消检查放在父进程:worker 里读不到 global_vars 的传输取消标记,而父进程
每收到一次进度就能检查一次,要取消直接杀掉 worker 即可,比在子进程里
轮询标记更干净。
:param src: 源文件
:param dst: 目标文件(调用方应传临时名,完成后自行原子替换)
:param progress_cb: 进度回调,入参为百分比
:param cancel_cb: 取消检查回调,返回 True 表示应中止
:param chunk_size: 分块大小
:return: 成功时为 {"copied", "total"},被取消或通信失败时为 False
"""
payload = {"src": str(src), "dst": str(dst)}
if chunk_size:
payload["chunk_size"] = chunk_size
if not self._enabled():
return self._direct_copy(src, dst, progress_cb, cancel_cb, chunk_size)
with self._lock:
try:
return self._request_stream(payload, progress_cb, cancel_cb)
except FileSystemTimeout:
raise
except (BrokenPipeError, ConnectionError, json.JSONDecodeError, ValueError) as err:
logger.error(f"文件系统代理复制通信异常: {src} -> {dst} - {err}")
self._shutdown()
return False
def _request_stream(self, payload: Dict[str, Any],
progress_cb: Optional[Callable[[float], None]],
cancel_cb: Optional[Callable[[], bool]]) -> Any:
"""
发起一次流式请求,逐行消费进度直到终态。
:param payload: 请求参数
:param progress_cb: 进度回调
:param cancel_cb: 取消检查回调
:return: 操作结果
"""
self._ensure_worker()
message = json.dumps({"op": "copy", **payload}) + "\n"
self._process.stdin.write(message.encode("utf-8"))
self._process.stdin.flush()
while True:
response = json.loads(self._read_line(timeout=self._stall_timeout).decode("utf-8"))
progress = response.get("progress")
if progress is not None:
if cancel_cb is not None and cancel_cb():
logger.info(f"复制已取消: {payload.get('src')}")
# 取消就地生效:杀掉 worker 立刻中断传输,不必等它读完整个文件
self._shutdown()
return False
if progress_cb is not None:
total = progress.get("total") or 0
progress_cb(progress.get("copied", 0) / total * 100 if total else 0)
continue
if response.get("ok"):
return response.get("result")
raise OSError(response.get("errno") or 0, response.get("error") or "unknown error")
@staticmethod
def _direct_copy(src: Path, dst: Path,
progress_cb: Optional[Callable[[float], None]],
cancel_cb: Optional[Callable[[], bool]],
chunk_size: Optional[int]) -> bool:
"""
不经代理直接复制,供代理关闭时使用。
"""
info = os.stat(src)
total = info.st_size
copied = 0
with open(src, "rb") as fsrc, open(dst, "wb") as fdst:
while True:
if cancel_cb is not None and cancel_cb():
return False
buf = fsrc.read(chunk_size or 1024 * 1024)
if not buf:
break
fdst.write(buf)
copied += len(buf)
if progress_cb is not None and total:
progress_cb(copied / total * 100)
os.utime(dst, ns=(info.st_atime_ns, info.st_mtime_ns))
return True
def unlink(self, path: Path) -> bool:
"""
删除单个文件。unlink 是原子操作,强杀后没有中间状态。
:param path: 目标文件
:return: 是否成功
"""
return self._call("unlink", path=str(path))
def rmtree(self, path: Path) -> bool:
"""
递归删除目录,容忍部分失败(可重复执行直到成功)。
:param path: 目标目录
:return: 是否成功
"""
return self._call("rmtree", path=str(path))
def close(self):
"""
关闭代理进程。
"""
with self._lock:
self._shutdown()
# ------------------------------------------------------------------ #
# 内部实现
# ------------------------------------------------------------------ #
@property
def _timeout(self) -> float:
"""
单次快操作的超时秒数。
实时读取而不是构造时固定:这三项都暴露在前端设置里,用户改完保存后
必须立刻生效,否则会出现「改了没反应」的困惑。
"""
if self._timeout_override is not None:
return self._timeout_override
return float(get_runtime_setting("FS_PROXY_TIMEOUT", DEFAULT_TIMEOUT))
@property
def _stall_timeout(self) -> float:
"""
长耗时操作两次进度上报之间的最长间隔秒数,同样实时跟随系统设置。
"""
if self._stall_timeout_override is not None:
return self._stall_timeout_override
return float(
get_runtime_setting("FS_PROXY_STALL_TIMEOUT", DEFAULT_STALL_TIMEOUT)
)
@staticmethod
def _enabled() -> bool:
"""
代理是否启用。关闭时退回直接调用,行为与引入代理之前完全一致。
"""
return bool(get_runtime_setting("FS_PROXY_ENABLED", True))
@staticmethod
def _direct(op: str, payload: Dict[str, Any]) -> Any:
"""
不经代理直接执行操作,供代理关闭时使用。
:param op: 操作名
:param payload: 操作参数
:return: 操作结果
"""
if op == "stat":
path = payload["path"]
info = os.stat(path)
return {
"size": info.st_size,
"mtime": info.st_mtime,
"is_dir": os.path.isdir(path),
"is_file": os.path.isfile(path),
}
if op == "exists":
os.stat(payload["path"])
return True
if op == "listdir":
return sorted(os.listdir(payload["path"]))
if op == "count_entries":
file_count = dir_count = 0
for _, dirs, files in os.walk(payload["path"]):
file_count += len(files)
dir_count += len(dirs)
if file_count > (payload.get("max_check") or 10000):
break
return {"file_count": file_count, "dir_count": dir_count}
if op == "rename":
os.rename(payload["src"], payload["dst"])
return True
if op == "unlink":
os.unlink(payload["path"])
return True
if op == "rmtree":
shutil.rmtree(payload["path"], ignore_errors=True)
return True
raise ValueError(f"unknown op: {op}")
def _call(self, op: str, **payload) -> Any:
"""
执行一次代理调用。
:param op: 操作名
:param payload: 操作参数
:return: 操作结果
"""
if not self._enabled():
return self._direct(op, payload)
with self._lock:
try:
return self._request(op, payload)
except FileSystemTimeout:
# 超时说明挂载正在挂死,重试只会再冻一次,直接上报给调用方
raise
except (BrokenPipeError, ConnectionError, json.JSONDecodeError, ValueError) as err:
# 代理进程意外退出或响应损坏,重启后重试一次
logger.debug(f"文件系统代理通信异常,重启后重试: {op} - {err}")
self._shutdown()
return self._request(op, payload)
def _request(self, op: str, payload: Dict[str, Any]) -> Any:
"""
发送请求并等待响应。
:param op: 操作名
:param payload: 操作参数
:return: 操作结果
"""
self._ensure_worker()
message = json.dumps({"op": op, **payload}) + "\n"
self._process.stdin.write(message.encode("utf-8"))
self._process.stdin.flush()
response = json.loads(self._read_line().decode("utf-8"))
if response.get("ok"):
return response.get("result")
# OSError(errno, strerror) 会自动映射到 FileNotFoundError 等具体子类,
# 调用方沿用原有的异常分支即可,无需感知代理的存在
raise OSError(response.get("errno") or 0, response.get("error") or "unknown error")
def _read_line(self, timeout: Optional[float] = None) -> bytes:
"""
读取一行响应,超时即强杀代理。
:param timeout: 本次读取的超时秒数,默认用单次操作超时
:return: 响应行
"""
timeout = self._timeout if timeout is None else timeout
# Windows 的 select() 只能等 socket。subprocess.PIPE 是匿名管道,
# selectors.DefaultSelector.select() 会抛 WinError 10038WSAENOTSOCK),
# 整理链每次 stat 都会直接失败。POSIX 继续用 selectorWindows 改走线程等待。
if sys.platform == "win32":
return self._read_line_windows(timeout)
if not self._selector.select(timeout=timeout):
self._raise_timeout(timeout)
line = self._process.stdout.readline()
if not line:
raise BrokenPipeError("文件系统代理进程已退出")
return line
def _read_line_windows(self, timeout: float) -> bytes:
"""
在辅助线程上读一行,用 join(timeout) 实现可放弃等待。
不能用 select()/WaitForSingleObject:匿名管道在 Windows 上不是
可选择的套接字,也不是可靠的「有数据」同步对象。
:param timeout: 本次读取的超时秒数
:return: 响应行
"""
if self._process is None or self._process.stdout is None:
raise BrokenPipeError("文件系统代理进程已退出")
stdout = self._process.stdout
holder: List[Union[bytes, BaseException]] = []
def _read() -> None:
try:
holder.append(stdout.readline())
except Exception as err: # noqa: BLE001
holder.append(err)
reader = threading.Thread(target=_read, name="fsproxy-stdout", daemon=True)
reader.start()
reader.join(timeout)
if reader.is_alive():
self._raise_timeout(timeout)
if not holder:
raise BrokenPipeError("文件系统代理进程已退出")
line = holder[0]
if isinstance(line, BaseException):
raise line
if not line:
raise BrokenPipeError("文件系统代理进程已退出")
return line
def _raise_timeout(self, timeout: float) -> None:
"""
判定挂载无响应:杀掉代理并抛出可被整理链处理的超时。
:param timeout: 已等待的秒数
"""
logger.error(f"文件系统操作 {timeout} 秒无响应,判定挂载挂死,正在回收代理进程")
self._shutdown()
raise FileSystemTimeout(
errno_module.ETIMEDOUT,
f"文件系统操作超过 {timeout} 秒无响应,挂载可能已无响应"
)
def _ensure_worker(self):
"""
确保代理进程可用,不可用时重新启动。
"""
if self._process is not None and self._process.poll() is None:
return
self._shutdown()
popen_kwargs: Dict[str, Any] = {
"stdin": subprocess.PIPE,
"stdout": subprocess.PIPE,
"stderr": subprocess.DEVNULL,
"bufsize": 0,
}
if sys.platform == "win32":
# NSSM 服务里再拉 python.exe 时避免弹出控制台窗口
popen_kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0)
self._process = subprocess.Popen(
[sys.executable, str(_WORKER_PATH)],
**popen_kwargs,
)
if sys.platform != "win32":
self._selector = selectors.DefaultSelector()
self._selector.register(self._process.stdout, selectors.EVENT_READ)
logger.debug(f"文件系统代理进程已启动: pid={self._process.pid}")
def _shutdown(self):
"""
回收代理进程。冻在挂载上的进程用 SIGKILL,且不无限等待它消失
——否则「可放弃的代理」又变回一次不可放弃的阻塞。
"""
if self._selector is not None:
try:
self._selector.close()
except Exception: # noqa: BLE001
pass
self._selector = None
process, self._process = self._process, None
if process is None:
return
for stream in (process.stdin, process.stdout):
try:
if stream:
stream.close()
except Exception: # noqa: BLE001
pass
if process.poll() is not None:
return
try:
process.kill()
process.wait(timeout=_KILL_GRACE)
except subprocess.TimeoutExpired:
logger.warn(f"文件系统代理进程未能及时退出,交由系统回收: pid={process.pid}")
except Exception as err: # noqa: BLE001
logger.debug(f"回收文件系统代理进程失败: {err}")
# 全局单例:local 存储本身是单例,代理也只需要一个
# 不传超时参数:让它实时跟随系统设置,前端改完保存即刻生效
fsproxy = FileSystemProxy()
-201
View File
@@ -1,201 +0,0 @@
"""
文件系统操作代理 worker。
**本文件不能被 import,只能作为独立脚本执行**fsproxy 用
`subprocess.Popen([sys.executable, <本文件绝对路径>])` 启动)。直接执行文件
路径不会触发 `app/__init__.py` 的导入链,因此这个进程只依赖标准库、启动是
毫秒级的;一旦走 import 就会把整个应用的依赖拉进来,代理被强杀后的重启成本
会高到无法接受。
存在的理由:FUSE/网络挂载进入 block 型故障时,`stat`/`listdir`/`rename` 这类
系统调用既不返回错误也不返回结果,而 Python 没有中断线程的手段——阻塞其上的
线程永远无法回收。放进独立进程后,父进程可以在超时后 SIGKILL 掉它,把
「不可处理的 block」转换成「可处理的 crash」。
协议:stdin/stdout 逐行 JSON。
请求 {"op": "stat", "path": "/mnt/cd2/x.mkv"}
成功 {"ok": true, "result": {...}}
失败 {"ok": false, "errno": 2, "error": "No such file or directory"}
"""
import json
import os
import shutil
import sys
import time
def _stat(payload, _emit):
"""
读取路径的基本属性。
"""
path = payload["path"]
info = os.stat(path)
return {
"size": info.st_size,
"mtime": info.st_mtime,
"is_dir": os.path.isdir(path),
"is_file": os.path.isfile(path),
}
def _exists(payload, _emit):
"""
判断路径是否存在。
用 os.stat 而不是 os.path.exists:后者会把任意 OSError 都归为「不存在」,
挂载抖动会被误判成文件消失。这里让异常原样抛出,由父进程按 errno 区分。
"""
os.stat(payload["path"])
return True
def _listdir(payload, _emit):
"""
列出目录下的条目名。
"""
return sorted(os.listdir(payload["path"]))
def _copy(payload, emit):
"""
分块复制文件内容并周期上报进度。
进度上报同时充当心跳:复制大文件可能持续几小时,父进程无法用固定超时判断
挂死,只能看「两次上报之间隔了多久」。因此这里按固定时间间隔上报,即使
某一秒没读到数据也照常发——一旦挂载卡住,read/write 不返回,上报自然断流,
父进程据此判定并强杀本进程。
只复制内容和时间戳,不复制权限:目标目录的默认权限与继承 ACL 是媒体库的
访问策略,用源文件权限覆盖会清除已继承的 ACL。
"""
src, dst = payload["src"], payload["dst"]
chunk_size = payload.get("chunk_size") or 1024 * 1024
interval = payload.get("progress_interval") or 1.0
info = os.stat(src)
total = info.st_size
copied = 0
# 先发一次 0%:既让心跳立刻开始,也保证父进程在传输开始前就有一次检查
# 取消的机会——否则小文件会在首次定时上报之前就复制完,取消形同虚设
emit({"ok": True, "progress": {"copied": 0, "total": total}})
last_emit = time.monotonic()
with open(src, "rb") as fsrc, open(dst, "wb") as fdst:
while True:
buf = fsrc.read(chunk_size)
if not buf:
break
fdst.write(buf)
copied += len(buf)
now = time.monotonic()
if now - last_emit >= interval:
last_emit = now
emit({"ok": True, "progress": {"copied": copied, "total": total}})
os.utime(dst, ns=(info.st_atime_ns, info.st_mtime_ns))
return {"copied": copied, "total": total}
def _count_entries(payload, _emit):
"""
统计目录下的文件与子目录数量,超过上限即提前结束。
放在子进程里做而不是逐层 listdir 走 IPC:递归遍历一棵大目录树会产生成千
上万次往返,代价不可接受;一次调用在子进程内跑完 os.walk,父进程只需对
这一次调用设超时即可整体放弃。
"""
directory = payload["path"]
max_check = payload.get("max_check") or 10000
file_count = 0
dir_count = 0
for _, dirs, files in os.walk(directory):
file_count += len(files)
dir_count += len(dirs)
if file_count > max_check:
break
return {"file_count": file_count, "dir_count": dir_count}
def _rename(payload, _emit):
"""
同一存储内重命名/移动。
这是第一版唯一放行的写操作:同文件系统内的 rename 由内核保证原子性,
进程被强杀后要么完全成功要么完全没发生,不存在需要清理的中间状态。
跨存储的复制+删除不走这里,它需要单独的可恢复语义。
"""
src, dst = payload["src"], payload["dst"]
if os.stat(src).st_dev != os.stat(os.path.dirname(dst) or ".").st_dev:
raise OSError(18, "Cross-device rename is not handled by the proxy")
os.rename(src, dst)
return True
def _unlink(payload, _emit):
"""
删除单个文件。unlink 是原子操作,强杀后要么删掉了要么没删,没有中间状态。
"""
os.unlink(payload["path"])
return True
def _rmtree(payload, _emit):
"""
递归删除目录。
这一项不是原子的,强杀可能只删掉一部分。放行的理由是:删除被中断的后果
(残留若干文件)远轻于写入被中断(留下叫最终文件名的半成品),而且调用方
本来就以 ignore_errors 容忍部分失败、可以重复执行直到成功。
"""
shutil.rmtree(payload["path"], ignore_errors=True)
return True
_HANDLERS = {
"stat": _stat,
"exists": _exists,
"listdir": _listdir,
"copy": _copy,
"count_entries": _count_entries,
"rename": _rename,
"unlink": _unlink,
"rmtree": _rmtree,
"ping": lambda _payload, _emit: True,
}
def _write(message):
"""
输出一行响应。
"""
sys.stdout.write(json.dumps(message) + "\n")
sys.stdout.flush()
def main():
"""
请求循环:每读一行处理一个请求,直到 stdin 关闭。
一个请求可能对应多行响应:长耗时操作先流式发若干 progress 行,最后发一行
终态(result 或 error)。父进程据此区分「还在推进」和「已经挂死」。
"""
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
payload = json.loads(line)
handler = _HANDLERS.get(payload.get("op"))
if handler is None:
response = {"ok": False, "errno": 0,
"error": f"unknown op: {payload.get('op')}"}
else:
response = {"ok": True, "result": handler(payload, _write)}
except OSError as err:
response = {"ok": False, "errno": err.errno or 0,
"error": err.strerror or str(err)}
except Exception as err: # noqa: BLE001 - worker 不能因任何异常退出
response = {"ok": False, "errno": 0, "error": str(err)}
_write(response)
if __name__ == "__main__":
main()
-1
View File
@@ -1 +0,0 @@
"""插件包和依赖系统适配器。"""
-380
View File
@@ -1,380 +0,0 @@
"""插件 Python 依赖聚合和安装适配器。"""
from __future__ import annotations
import asyncio
import json
from collections.abc import Callable
from dataclasses import dataclass, field
from importlib.metadata import PackageNotFoundError, distribution, distributions
from pathlib import Path
from typing import Any, Optional
from urllib.parse import urlsplit
from packaging.markers import default_environment
from packaging.requirements import Requirement
from packaging.specifiers import InvalidSpecifier, SpecifierSet
from packaging.version import InvalidVersion, Version
from app.adapters.system.plugin.manifest import (
PluginDependencyManifestError,
load_dependency_manifest,
)
from app.runtime.log import logger
from app.runtime.settings import get_runtime_setting
@dataclass
class _RequirementGroup:
"""聚合同一包和安装来源的 extras 与版本约束。"""
name: str # PEP 503 规范化后的包名
url: Optional[str] # direct reference 来源;为空表示从索引安装
extras: set[str] = field(default_factory=set) # 所有插件要求启用的 extras
specifiers: set[str] = field(default_factory=set) # 待求交集的版本约束
class PluginDependencyInstaller:
"""独立负责插件依赖扫描、约束合并和安装。"""
def __init__(
self,
helper: Any = None,
*,
installed_plugins_provider: Optional[Callable[[], list[str]]] = None,
plugin_dir: Optional[Path] = None,
) -> None:
"""保存包安装端口和启动层提供的已安装插件读取器。"""
if helper is None:
from app.adapters.external.market import PluginHelper
helper = PluginHelper()
self._helper = helper
self._installed_plugins_provider = installed_plugins_provider or (lambda: [])
self._plugin_dir = plugin_dir or (
Path(get_runtime_setting('ROOT_PATH')) / "app" / "plugins"
)
@staticmethod
def _standardize(name: str) -> str:
"""按 PEP 503 兼容规则标准化依赖包名。"""
return (name or "").lower().replace("-", "_").replace(".", "_")
@classmethod
def _installed_packages(cls) -> dict[str, Version]:
"""读取当前 Python 环境中可解析版本的已安装包。"""
installed: dict[str, Version] = {}
try:
for distribution in distributions():
name = distribution.metadata.get("Name")
version = distribution.metadata.get("Version") or getattr(
distribution,
"version",
None,
)
if not name or not version:
continue
package_name = cls._standardize(name)
try:
parsed = Version(version)
except InvalidVersion:
logger.debug(
f"无法解析已安装包 '{package_name}' 的版本:{version}"
)
continue
if package_name not in installed or parsed > installed[package_name]:
installed[package_name] = parsed
except Exception as err:
logger.error(f"获取已安装的包时发生错误:{err}")
return installed
@classmethod
def _installed_distribution(cls, package_name: str) -> Any | None:
"""读取一个包的元数据,用于校验 extras 和 direct URL 来源。"""
try:
return distribution(package_name)
except PackageNotFoundError:
return None
def _requirement_satisfied(
self,
requirement: Requirement,
installed: dict[str, Version],
*,
seen: Optional[set[tuple[str, tuple[str, ...], Optional[str]]]] = None,
) -> bool:
"""同时校验版本、extras 及 direct URL,不把同名包误认为同一制品。"""
package_name = self._standardize(requirement.name)
installed_version = installed.get(package_name)
try:
if installed_version is None or not SpecifierSet(
requirement.specifier
).contains(installed_version, prereleases=True):
return False
except InvalidSpecifier as err:
logger.error(f"依赖 {package_name} 约束无效:{err}")
return False
installed_distribution = self._installed_distribution(package_name)
if installed_distribution is None:
return False if requirement.extras or requirement.url else True
if requirement.url and not self._direct_url_matches(
installed_distribution, requirement.url
):
return False
requested_extras = {
self._standardize_extra(extra) for extra in requirement.extras
}
if requested_extras:
provided_extras = {
self._standardize_extra(extra)
for extra in installed_distribution.metadata.get_all(
"Provides-Extra"
)
or []
}
if not requested_extras.issubset(provided_extras):
return False
marker_key = (package_name, tuple(sorted(requested_extras)), requirement.url)
if seen is None:
seen = set()
if marker_key in seen:
return True
seen.add(marker_key)
for raw_dependency in installed_distribution.metadata.get_all(
"Requires-Dist"
) or []:
try:
extra_dependency = Requirement(raw_dependency)
except Exception as err:
logger.debug(
f"无法解析已安装包 {package_name} 的依赖项 '{raw_dependency}'{err}"
)
continue
if not self._marker_matches_for_extras(
extra_dependency, requested_extras
):
continue
if not self._requirement_satisfied(
extra_dependency, installed, seen=seen
):
return False
return True
@classmethod
def _marker_matches_for_extras(
cls, requirement: Requirement, extras: set[str]
) -> bool:
"""判断已安装发行版声明的可选依赖是否属于当前请求的 extra。"""
if requirement.marker is None:
return True
environment = default_environment()
if "extra" in str(requirement.marker):
return any(
requirement.marker.evaluate({**environment, "extra": extra})
for extra in extras
)
return requirement.marker.evaluate(environment)
@staticmethod
def _standardize_extra(name: str) -> str:
"""按 PEP 685 兼容规则标准化 extra 名称。"""
return (name or "").lower().replace("-", "_").replace(".", "_")
@staticmethod
def _direct_url_matches(installed_distribution: Any, required_url: str) -> bool:
"""校验安装发行版记录的 PEP 610 URL 与清单来源一致。"""
try:
payload = installed_distribution.read_text("direct_url.json")
if not payload:
return False
direct_url = json.loads(payload).get("url")
if not isinstance(direct_url, str):
return False
return PluginDependencyInstaller._canonical_direct_url(
required_url
) == PluginDependencyInstaller._canonical_direct_url(direct_url)
except (AttributeError, json.JSONDecodeError, TypeError, ValueError):
return False
@staticmethod
def _canonical_direct_url(value: str) -> tuple[str, str, str, str, str]:
"""规范化来源 URL,同时保留 fragment 中可能存在的校验信息。"""
parsed = urlsplit(value)
netloc = parsed.netloc.rsplit("@", 1)[-1].lower()
return (
parsed.scheme.lower(),
netloc,
parsed.path.rstrip("/"),
parsed.query,
parsed.fragment,
)
@classmethod
def _merge(cls, dependencies: list[Requirement]) -> list[Requirement]:
"""按包和安装来源合并 extras 与约束,保留完整安装目标。"""
groups: dict[tuple[str, Optional[str]], _RequirementGroup] = {}
for requirement in dependencies:
package_name = cls._standardize(requirement.name)
key = (package_name, requirement.url)
group = groups.setdefault(
key,
_RequirementGroup(name=package_name, url=requirement.url),
)
group.extras.update(requirement.extras)
group.specifiers.add(str(requirement.specifier))
merged: list[Requirement] = []
for group in groups.values():
spec_set = SpecifierSet()
for specifier in group.specifiers:
if not specifier:
continue
try:
spec_set &= SpecifierSet(specifier)
except InvalidSpecifier as err:
logger.error(f"发生版本约束冲突:{err}")
target = group.name
if group.extras:
target += f"[{','.join(sorted(group.extras))}]"
if group.url:
target += f" @ {group.url}"
elif spec_set:
target += str(spec_set)
merged.append(Requirement(target))
return merged
def _plugin_manifests(self) -> list[Any]:
"""返回已安装插件当前生效的依赖清单。"""
manifests = []
installed_plugins = {
plugin_id.lower()
for plugin_id in self._installed_plugins_provider() or []
}
try:
plugin_dirs = list(self._plugin_dir.iterdir())
except (FileNotFoundError, OSError):
return []
for plugin_dir in sorted(plugin_dirs, key=lambda item: item.name):
if not plugin_dir.is_dir():
continue
if plugin_dir.name not in installed_plugins:
logger.debug(f"忽略插件 {plugin_dir.name} 的依赖")
continue
manifest = load_dependency_manifest(plugin_dir)
if manifest is None:
continue
manifests.append(manifest)
return manifests
def _plugin_dependencies(self) -> list[Requirement]:
"""扫描已安装插件的生效依赖清单并合并版本约束。"""
dependencies: list[Requirement] = []
for manifest in self._plugin_manifests():
for requirement in manifest.dependencies:
if requirement.marker and not requirement.marker.evaluate():
continue
dependencies.append(requirement)
return self._merge(dependencies)
def find_missing(self) -> list[str]:
"""返回当前插件集合缺失或不满足约束的依赖项。"""
try:
required = self._plugin_dependencies()
installed = self._installed_packages()
missing = []
for requirement in required:
if not self._requirement_satisfied(requirement, installed):
missing.append(str(requirement))
return missing
except PluginDependencyManifestError:
raise
except Exception as err:
logger.error(f"收集所有需要安装或更新的依赖项时发生错误:{err}")
return []
def classify_plugins(self) -> tuple[list[str], list[str], list[str]]:
"""按源码和依赖状态划分已安装插件。"""
ready: list[str] = []
missing_dependencies: list[str] = []
missing_source: list[str] = []
installed_packages = self._installed_packages()
for plugin_id in self._installed_plugins_provider() or []:
plugin_dir = self._plugin_dir / plugin_id.lower()
if not plugin_dir.is_dir():
missing_source.append(plugin_id)
continue
try:
manifest = load_dependency_manifest(plugin_dir)
requirements = [] if manifest is None else [
requirement
for requirement in manifest.dependencies
if not requirement.marker or requirement.marker.evaluate()
]
except PluginDependencyManifestError as error:
logger.error(f"插件 {plugin_id} 依赖清单无效:{error}")
missing_dependencies.append(plugin_id)
continue
if all(
self._requirement_satisfied(requirement, installed_packages)
for requirement in requirements
):
ready.append(plugin_id)
else:
missing_dependencies.append(plugin_id)
return ready, missing_dependencies, missing_source
def _wheels_dirs(self) -> list[Path]:
"""收集已安装插件附带的本地 wheels 目录。"""
result = []
installed_plugins = {
plugin_id.lower()
for plugin_id in self._installed_plugins_provider() or []
}
for plugin_id in installed_plugins:
wheels_dir = self._plugin_dir / plugin_id / "wheels"
if wheels_dir.is_dir():
result.append(wheels_dir)
return list(dict.fromkeys(result))
def install(self, dependencies: list[str]) -> tuple[bool, str]:
"""把已安装插件的原始清单交给一次统一包安装。"""
if not dependencies:
return False, "没有传入需要安装的依赖项"
try:
manifest_paths = [manifest.path for manifest in self._plugin_manifests()]
if not manifest_paths:
return False, "没有找到已安装插件的依赖清单"
return self._helper.install_packages_with_fallback(
manifest_paths,
self._wheels_dirs(),
)
except Exception as err:
logger.error(f"安装依赖项时发生错误:{err}")
return False, f"安装依赖项时发生错误:{err}"
async def async_find_missing(self) -> list[str]:
"""在线程池中扫描缺失依赖,避免阻塞事件循环。"""
return await asyncio.to_thread(self.find_missing)
async def async_install(self, dependencies: list[str]) -> tuple[bool, str]:
"""异步安装依赖,使用可取消的包安装子进程。"""
if not dependencies:
return False, "没有传入需要安装的依赖项"
try:
manifest_paths = [manifest.path for manifest in self._plugin_manifests()]
if not manifest_paths:
return False, "没有找到已安装插件的依赖清单"
return await self._helper.async_install_packages_with_fallback(
manifest_paths,
self._wheels_dirs(),
)
except Exception as err:
logger.error(f"安装依赖项时发生错误:{err}")
return False, f"安装依赖项时发生错误:{err}"
-162
View File
@@ -1,162 +0,0 @@
"""插件 Python 依赖清单的选择和解析。"""
from __future__ import annotations
import tomllib
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from packaging.requirements import Requirement
from app.runtime.log import logger
PYPROJECT_FILENAME = "pyproject.toml"
REQUIREMENTS_FILENAME = "requirements.txt"
DEPENDENCY_MANIFEST_PRIORITY = (
PYPROJECT_FILENAME,
REQUIREMENTS_FILENAME,
)
DEPENDENCY_MANIFEST_FILENAMES = frozenset(
DEPENDENCY_MANIFEST_PRIORITY
)
class PluginDependencyManifestError(ValueError):
"""表示生效的现代依赖清单无法安全消费。"""
@dataclass(frozen=True)
class PluginDependencyManifest:
"""保存插件当前生效的依赖清单及其已解析依赖。"""
path: Path
dependencies: tuple[Requirement, ...]
def select_dependency_manifest(plugin_dir: Path) -> Path | None:
"""按现代清单优先级返回插件当前生效的依赖文件。"""
pyproject_file = plugin_dir / PYPROJECT_FILENAME
if pyproject_file.is_file():
return pyproject_file
requirements_file = plugin_dir / REQUIREMENTS_FILENAME
if requirements_file.is_file():
return requirements_file
return None
def dependency_manifest_status(event_path: Path) -> bool | None:
"""判断文件事件是否改变生效清单,非清单文件返回 None。"""
if event_path.name not in DEPENDENCY_MANIFEST_FILENAMES:
return None
active_manifest = select_dependency_manifest(event_path.parent)
if event_path.is_file():
return active_manifest == event_path
if active_manifest is None:
return True
return DEPENDENCY_MANIFEST_PRIORITY.index(
event_path.name
) < DEPENDENCY_MANIFEST_PRIORITY.index(active_manifest.name)
def load_dependency_manifest(
plugin_dir: Path,
) -> PluginDependencyManifest | None:
"""读取插件当前生效的依赖清单,现代清单无效时拒绝回退。"""
manifest_path = select_dependency_manifest(plugin_dir)
if manifest_path is None:
return None
return load_dependency_file(manifest_path)
def load_dependency_file(path: Path) -> PluginDependencyManifest:
"""读取指定依赖文件,pyproject 严格校验,其余文件保持旧格式兼容。"""
if path.name == PYPROJECT_FILENAME:
dependencies = _load_pyproject_dependencies(path)
else:
dependencies = _load_requirements_dependencies(path)
return PluginDependencyManifest(
path=path,
dependencies=dependencies,
)
def _load_pyproject_dependencies(path: Path) -> tuple[Requirement, ...]:
"""严格读取 PEP 621 ``project.dependencies``。"""
try:
with path.open("rb") as file:
document = tomllib.load(file)
except (OSError, tomllib.TOMLDecodeError) as err:
raise PluginDependencyManifestError(
f"插件依赖清单 {path.name} 无法解析:{err}"
) from err
project = document.get("project")
if not isinstance(project, Mapping):
raise PluginDependencyManifestError(
f"插件依赖清单 {path.name} 缺少 [project] 表"
)
name = project.get("name")
if not isinstance(name, str) or not name.strip():
raise PluginDependencyManifestError(
f"插件依赖清单 {path.name} 的 project.name 必须是非空字符串"
)
dynamic = project.get("dynamic", [])
if not isinstance(dynamic, list) or not all(
isinstance(item, str) for item in dynamic
):
raise PluginDependencyManifestError(
f"插件依赖清单 {path.name} 的 project.dynamic 必须是字符串数组"
)
if "dependencies" in dynamic:
raise PluginDependencyManifestError(
f"插件依赖清单 {path.name} 不支持动态 dependencies"
)
version = project.get("version")
if "version" in dynamic:
if version is not None:
raise PluginDependencyManifestError(
f"插件依赖清单 {path.name} 不能同时静态和动态声明 version"
)
elif not isinstance(version, str) or not version.strip():
raise PluginDependencyManifestError(
f"插件依赖清单 {path.name} 必须声明非空 project.version"
"或将 version 加入 project.dynamic"
)
raw_dependencies = project.get("dependencies", [])
if not isinstance(raw_dependencies, list) or not all(
isinstance(item, str) for item in raw_dependencies
):
raise PluginDependencyManifestError(
f"插件依赖清单 {path.name} 的 project.dependencies 必须是字符串数组"
)
dependencies: list[Requirement] = []
for item in raw_dependencies:
try:
dependencies.append(Requirement(item))
except Exception as err:
raise PluginDependencyManifestError(
f"插件依赖清单 {path.name} 包含无效依赖项 {item!r}{err}"
) from err
return tuple(dependencies)
def _load_requirements_dependencies(path: Path) -> tuple[Requirement, ...]:
"""按旧行为逐行读取 requirements,忽略无法解析的兼容内容。"""
dependencies: list[Requirement] = []
try:
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError as err:
logger.error(f"解析 requirements.txt 时发生错误:{err}")
return ()
for line in lines:
line = line.strip()
if not line or line.startswith("#"):
continue
try:
dependencies.append(Requirement(line))
except Exception as err:
logger.debug(f"无法解析依赖项 '{line}'{err}")
return tuple(dependencies)
-740
View File
@@ -1,740 +0,0 @@
"""插件包文件安装、快照恢复和分身处理适配器。"""
from __future__ import annotations
import hashlib
import re
import shutil
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional, cast
from app.adapters.external.market import PluginHelper as _PluginHelper
from app.adapters.system.host import SystemUtils
from app.runtime.execution import (
run_in_threadpool_to_completion as _await_thread_operation,
)
from app.runtime.log import logger
from app.runtime.settings import get_runtime_setting
@dataclass(frozen=True, slots=True)
class PluginPackageCheckpoint:
"""记录运行目录快照及待提升的容器恢复备份。"""
plugin_id: str
plugin_dir: Path
persistent_backup_dir: Path
backup_staging_dir: Path | None
backup_previous_dir: Path | None
transaction_dir: Path
plugin_existed: bool
persistent_backup_existed: bool
@property
def existed(self) -> bool:
"""保留旧调用方读取运行目录存在状态的兼容属性。"""
return self.plugin_existed
@property
def rollback_marker(self) -> Path:
"""返回文件补偿完成标记,供 PREPARED 重放保持幂等。"""
return self.transaction_dir / ".rollback-complete"
class PluginPackageManager:
"""隔离插件包安装、本地同步、分身改写和文件补偿能力。"""
_COPY_IGNORE = ("__pycache__", "*.pyc", ".DS_Store", "node_modules")
def __init__(self, helper: Optional[_PluginHelper] = None) -> None:
"""保存市场下载实现;文件事务由本适配器独立负责。"""
self._helper = helper or _PluginHelper()
@staticmethod
def __plugin_dir(plugin_id: str) -> Path:
"""解析插件运行目录并拒绝越出宿主插件根目录的标识。"""
plugins_root = (
Path(get_runtime_setting('ROOT_PATH')) / "app" / "plugins"
).resolve()
plugin_dir = (plugins_root / plugin_id.lower()).resolve()
if plugin_dir == plugins_root or not plugin_dir.is_relative_to(plugins_root):
raise ValueError(f"非法插件ID{plugin_id}")
return plugin_dir
def checkpoint(
self,
plugin_id: str,
transaction_id: Optional[str] = None,
) -> PluginPackageCheckpoint:
"""在包变更前保存运行目录;持久事务使用配置目录承载恢复材料。"""
plugin_dir = self.__plugin_dir(plugin_id)
durable = transaction_id is not None
persistent_backup_dir = (
Path(get_runtime_setting('CONFIG_PATH'))
/ "plugins_backup"
/ plugin_id.lower()
).resolve()
backup_staging_dir = (
persistent_backup_dir.parent
/ f".{plugin_id.lower()}.staging-{transaction_id}"
if durable and SystemUtils.is_docker()
else None
)
backup_previous_dir = (
persistent_backup_dir.parent
/ f".{plugin_id.lower()}.previous-{transaction_id}"
if durable and SystemUtils.is_docker()
else None
)
transaction_root = (
Path(get_runtime_setting('CONFIG_PATH'))
if durable
else Path(get_runtime_setting('TEMP_PATH'))
)
transaction_dir = (
transaction_root
/ "plugin_transactions"
/ (transaction_id or f"{plugin_id.lower()}-{uuid.uuid4().hex}")
)
plugin_existed = plugin_dir.exists()
persistent_backup_existed = persistent_backup_dir.exists()
try:
transaction_dir.mkdir(parents=True, exist_ok=False)
if plugin_existed:
shutil.copytree(plugin_dir, transaction_dir / "package")
except Exception:
shutil.rmtree(transaction_dir, ignore_errors=True)
raise
return PluginPackageCheckpoint(
plugin_id=plugin_id,
plugin_dir=plugin_dir,
persistent_backup_dir=persistent_backup_dir,
backup_staging_dir=backup_staging_dir,
backup_previous_dir=backup_previous_dir,
transaction_dir=transaction_dir,
plugin_existed=plugin_existed,
persistent_backup_existed=persistent_backup_existed,
)
def restore_checkpoint(
self,
*,
plugin_id: str,
transaction_id: str,
plugin_existed: bool,
persistent_backup_existed: bool,
) -> PluginPackageCheckpoint:
"""按受控根目录和事务 ID 重建崩溃回放所需的文件引用。"""
plugin_dir = self.__plugin_dir(plugin_id)
persistent_backup_dir = (
Path(get_runtime_setting('CONFIG_PATH'))
/ "plugins_backup"
/ plugin_id.lower()
).resolve()
durable_backup = SystemUtils.is_docker()
return PluginPackageCheckpoint(
plugin_id=plugin_id,
plugin_dir=plugin_dir,
persistent_backup_dir=persistent_backup_dir,
backup_staging_dir=(
persistent_backup_dir.parent
/ f".{plugin_id.lower()}.staging-{transaction_id}"
if durable_backup
else None
),
backup_previous_dir=(
persistent_backup_dir.parent
/ f".{plugin_id.lower()}.previous-{transaction_id}"
if durable_backup
else None
),
transaction_dir=(
Path(get_runtime_setting('CONFIG_PATH'))
/ "plugin_transactions"
/ transaction_id
),
plugin_existed=plugin_existed,
persistent_backup_existed=persistent_backup_existed,
)
async def async_checkpoint(
self,
plugin_id: str,
transaction_id: Optional[str] = None,
) -> PluginPackageCheckpoint:
"""在线程池中创建插件包文件快照。"""
return cast(
PluginPackageCheckpoint,
await _await_thread_operation(
self.checkpoint,
plugin_id,
transaction_id,
),
)
@staticmethod
def commit(checkpoint: PluginPackageCheckpoint) -> None:
"""清理已完成事务的运行目录快照和残余替换材料。"""
if checkpoint.backup_staging_dir and checkpoint.backup_staging_dir.exists():
raise RuntimeError("持久备份尚未提升,不能清理插件安装事务")
if checkpoint.backup_previous_dir and checkpoint.backup_previous_dir.exists():
raise RuntimeError("旧持久备份尚未清理,不能结束插件安装事务")
if checkpoint.transaction_dir.exists():
shutil.rmtree(checkpoint.transaction_dir, ignore_errors=False)
async def async_commit(self, checkpoint: PluginPackageCheckpoint) -> None:
"""在线程池中清理已提交的插件包快照。"""
await _await_thread_operation(self.commit, checkpoint)
@staticmethod
def rollback(checkpoint: PluginPackageCheckpoint) -> None:
"""兼容旧调用方,恢复运行目录和持久备份后清理恢复材料。"""
PluginPackageManager.restore(checkpoint)
PluginPackageManager.cleanup(checkpoint)
@staticmethod
def restore(checkpoint: PluginPackageCheckpoint) -> None:
"""恢复运行目录和提交前持久备份,并保留快照直到 journal 删除。"""
if checkpoint.rollback_marker.is_file():
return
PluginPackageManager.__restore_tree(
target=checkpoint.plugin_dir,
snapshot=checkpoint.transaction_dir / "package",
existed=checkpoint.plugin_existed,
label=f"插件 {checkpoint.plugin_id} 运行目录",
)
PluginPackageManager.__rollback_persistent_backup(checkpoint)
if checkpoint.backup_staging_dir and checkpoint.backup_staging_dir.exists():
shutil.rmtree(checkpoint.backup_staging_dir, ignore_errors=False)
checkpoint.transaction_dir.mkdir(parents=True, exist_ok=True)
checkpoint.rollback_marker.touch(exist_ok=True)
@staticmethod
def cleanup(checkpoint: PluginPackageCheckpoint) -> None:
"""在 journal 已删除后清理恢复材料;重复调用保持幂等。"""
if checkpoint.transaction_dir.exists():
shutil.rmtree(checkpoint.transaction_dir, ignore_errors=False)
async def async_rollback(self, checkpoint: PluginPackageCheckpoint) -> None:
"""在线程池中恢复插件包文件快照。"""
await _await_thread_operation(self.rollback, checkpoint)
async def async_restore(self, checkpoint: PluginPackageCheckpoint) -> None:
"""在线程池恢复插件状态,并保留 journal 仍需引用的材料。"""
await _await_thread_operation(self.restore, checkpoint)
async def async_cleanup(self, checkpoint: PluginPackageCheckpoint) -> None:
"""在线程池清理已失去 journal 所有权的恢复材料。"""
await _await_thread_operation(self.cleanup, checkpoint)
@staticmethod
def __rollback_persistent_backup(
checkpoint: PluginPackageCheckpoint,
) -> None:
"""把已激活但尚未提交的持久备份恢复到事务前状态。"""
previous = checkpoint.backup_previous_dir
staging = checkpoint.backup_staging_dir
if previous is None or staging is None:
return
target = checkpoint.persistent_backup_dir
if previous.exists():
discarded = target.parent / f".{target.name}.discard-{uuid.uuid4().hex}"
try:
if target.exists():
target.replace(discarded)
previous.replace(target)
if discarded.exists():
shutil.rmtree(discarded, ignore_errors=False)
except Exception:
if not target.exists() and discarded.exists():
discarded.replace(target)
raise
finally:
if target.exists() and discarded.exists():
shutil.rmtree(discarded, ignore_errors=True)
return
if staging.exists():
return
if checkpoint.persistent_backup_existed:
if target.exists():
return
raise FileNotFoundError(
f"插件 {checkpoint.plugin_id} 的旧持久备份恢复材料不存在"
)
if target.exists():
shutil.rmtree(target, ignore_errors=False)
@staticmethod
def __restore_tree(
*,
target: Path,
snapshot: Path,
existed: bool,
label: str,
) -> None:
"""用同级 staging 替换目录,失败时保留替换前的当前目录。"""
if existed and not snapshot.is_dir():
raise FileNotFoundError(f"{label}补偿快照不存在:{snapshot}")
target.parent.mkdir(parents=True, exist_ok=True)
staging = target.parent / f".{target.name}.restore-{uuid.uuid4().hex}"
previous = target.parent / f".{target.name}.previous-{uuid.uuid4().hex}"
try:
if existed:
shutil.copytree(snapshot, staging)
if target.exists():
target.replace(previous)
if existed:
staging.replace(target)
if previous.exists():
shutil.rmtree(previous)
except Exception:
if not target.exists() and previous.exists():
previous.replace(target)
raise
finally:
if staging.exists():
shutil.rmtree(staging, ignore_errors=True)
if target.exists() and previous.exists():
shutil.rmtree(previous, ignore_errors=True)
@classmethod
def stage_persistent_backup(cls, checkpoint: PluginPackageCheckpoint) -> None:
"""把新载荷复制到持久配置目录的独立 staging,不覆盖现有备份。"""
staging = checkpoint.backup_staging_dir
if staging is None:
return
if not checkpoint.plugin_dir.is_dir():
raise FileNotFoundError(
f"插件 {checkpoint.plugin_id} 运行目录不存在"
)
staging.parent.mkdir(parents=True, exist_ok=True)
if staging.exists():
shutil.rmtree(staging, ignore_errors=False)
shutil.copytree(
checkpoint.plugin_dir,
staging,
ignore=shutil.ignore_patterns(*cls._COPY_IGNORE),
)
async def async_stage_persistent_backup(
self,
checkpoint: PluginPackageCheckpoint,
) -> None:
"""在线程池准备新载荷的容器恢复备份。"""
await _await_thread_operation(self.stage_persistent_backup, checkpoint)
@staticmethod
def activate_persistent_backup(checkpoint: PluginPackageCheckpoint) -> None:
"""在数据库提交前激活新备份,并保留上一份备份供失败补偿。"""
staging = checkpoint.backup_staging_dir
previous = checkpoint.backup_previous_dir
if staging is None or previous is None:
return
target = checkpoint.persistent_backup_dir
target.parent.mkdir(parents=True, exist_ok=True)
if staging.exists():
if target.exists() and not previous.exists():
target.replace(previous)
if not target.exists():
staging.replace(target)
elif not target.exists():
raise FileNotFoundError(
f"插件 {checkpoint.plugin_id} 的持久备份 staging 不存在"
)
async def async_activate_persistent_backup(
self,
checkpoint: PluginPackageCheckpoint,
) -> None:
"""在线程池激活新持久备份,同时保留失败补偿材料。"""
await _await_thread_operation(self.activate_persistent_backup, checkpoint)
@staticmethod
def finalize_persistent_backup(checkpoint: PluginPackageCheckpoint) -> None:
"""数据库提交后清理上一份持久备份;重复调用保持幂等。"""
staging = checkpoint.backup_staging_dir
previous = checkpoint.backup_previous_dir
if staging is None or previous is None:
return
if staging.exists():
raise RuntimeError("新持久备份尚未激活")
if not checkpoint.persistent_backup_dir.is_dir():
raise FileNotFoundError(
f"插件 {checkpoint.plugin_id} 的已提交持久备份不存在"
)
if previous.exists():
shutil.rmtree(previous, ignore_errors=False)
async def async_finalize_persistent_backup(
self,
checkpoint: PluginPackageCheckpoint,
) -> None:
"""在线程池清理数据库提交后的旧持久备份。"""
await _await_thread_operation(self.finalize_persistent_backup, checkpoint)
def payload_receipt(self, plugin_id: str) -> str:
"""按稳定相对路径和文件内容计算已安装载荷收据。"""
plugin_dir = self.__plugin_dir(plugin_id)
if not plugin_dir.is_dir():
raise FileNotFoundError(f"插件 {plugin_id} 运行目录不存在")
return self.__tree_receipt(plugin_dir)
@classmethod
def persistent_backup_receipt(
cls,
checkpoint: PluginPackageCheckpoint,
) -> str:
"""计算已提升持久备份的内容收据,供崩溃回放确认终态。"""
if not checkpoint.persistent_backup_dir.is_dir():
raise FileNotFoundError(
f"插件 {checkpoint.plugin_id} 持久备份不存在"
)
return cls.__tree_receipt(checkpoint.persistent_backup_dir)
@classmethod
def __tree_receipt(cls, root: Path) -> str:
"""对插件目录使用稳定路径和文件内容生成审计收据。"""
digest = hashlib.sha256()
for path in sorted(
root.rglob("*"),
key=lambda item: item.relative_to(root).as_posix(),
):
relative = path.relative_to(root).as_posix()
if cls.__ignored_receipt_path(path, root):
continue
encoded_path = relative.encode("utf-8")
digest.update(len(encoded_path).to_bytes(4, "big"))
digest.update(encoded_path)
if path.is_symlink():
digest.update(b"L")
target = path.readlink().as_posix().encode("utf-8")
digest.update(len(target).to_bytes(4, "big"))
digest.update(target)
elif path.is_dir():
digest.update(b"D")
elif path.is_file():
digest.update(b"F")
with path.open("rb") as file_handle:
for chunk in iter(lambda: file_handle.read(1024 * 1024), b""):
digest.update(chunk)
return f"sha256:{digest.hexdigest()}"
async def async_payload_receipt(self, plugin_id: str) -> str:
"""在线程池计算插件载荷收据。"""
return cast(
str,
await _await_thread_operation(self.payload_receipt, plugin_id),
)
async def async_committed_payload_receipt(
self,
checkpoint: PluginPackageCheckpoint,
) -> str:
"""读取数据库已提交载荷在当前部署模式下的恢复事实。"""
if checkpoint.backup_staging_dir is not None:
return cast(
str,
await _await_thread_operation(
self.persistent_backup_receipt,
checkpoint,
),
)
return await self.async_payload_receipt(checkpoint.plugin_id)
@classmethod
def __ignored_receipt_path(cls, path: Path, root: Path) -> bool:
"""排除不会进入运行载荷和持久备份的派生文件。"""
relative_parts = path.relative_to(root).parts
return any(
part in {"__pycache__", "node_modules", ".DS_Store"}
or part.endswith(".pyc")
for part in relative_parts
)
def install(
self,
plugin_id: str,
repo_url: str,
package_version: Optional[str] = None,
release_version: Optional[str] = None,
force_install: bool = False,
) -> tuple[bool, str]:
"""同步安装插件包,下载过程继续复用既有市场兼容策略。"""
return cast(
tuple[bool, str],
cast(Any, self._helper)._PluginHelper__install_package(
pid=plugin_id,
repo_url=repo_url,
package_version=package_version,
release_version=release_version,
force_install=force_install,
),
)
async def async_install(
self,
plugin_id: str,
repo_url: str,
package_version: Optional[str] = None,
release_version: Optional[str] = None,
force_install: bool = False,
) -> tuple[bool, str]:
"""异步安装插件包,下载过程继续复用既有市场兼容策略。"""
return cast(
tuple[bool, str],
await cast(Any, self._helper)._PluginHelper__async_install_package(
pid=plugin_id,
repo_url=repo_url,
package_version=package_version,
release_version=release_version,
force_install=force_install,
),
)
def sync_local(self, plugin_id: str, source_dir: Path) -> bool:
"""用本地仓库内容原子替换运行副本,失败时恢复原目录。"""
source_dir = source_dir.resolve()
plugin_dir = self.__plugin_dir(plugin_id)
if source_dir == plugin_dir:
return True
checkpoint = self.checkpoint(plugin_id)
try:
if plugin_dir.exists():
shutil.rmtree(plugin_dir)
shutil.copytree(
source_dir,
plugin_dir,
ignore=shutil.ignore_patterns(*self._COPY_IGNORE),
)
self.commit(checkpoint)
return True
except Exception as err:
logger.error(f"同步本地插件 {plugin_id} 失败:{err}")
try:
self.rollback(checkpoint)
except Exception as rollback_err:
logger.error(
f"恢复本地插件 {plugin_id} 原目录失败:{rollback_err}",
exc_info=True,
)
return False
def clone(
self,
*,
plugin_id: str,
clone_id: str,
original_class_name: str,
suffix: str,
name: str,
description: str,
version: Optional[str] = None,
icon: Optional[str] = None,
) -> tuple[bool, str]:
"""复制并改写插件分身文件,任一步失败都删除不完整目标。"""
original_dir = self.__plugin_dir(plugin_id)
clone_dir = self.__plugin_dir(clone_id)
if not original_dir.is_dir():
return False, f"原插件目录 {original_dir} 不存在"
if clone_dir.exists():
return False, f"分身插件 {clone_id} 已存在"
checkpoint = self.checkpoint(clone_id)
try:
shutil.copytree(original_dir, clone_dir)
success, message = self._modify_plugin_files(
plugin_dir=clone_dir,
original_class_name=original_class_name,
suffix=suffix,
name=name,
description=description,
version=version,
icon=icon,
)
if not success:
self.rollback(checkpoint)
return False, message
self.commit(checkpoint)
logger.info(f"已复制插件目录:{original_dir} -> {clone_dir}")
return True, "文件修改成功"
except Exception as err:
try:
self.rollback(checkpoint)
except Exception as rollback_err:
logger.error(
f"清理插件分身 {clone_id} 失败:{rollback_err}",
exc_info=True,
)
return False, f"创建插件分身文件失败:{err}"
def _modify_plugin_files(
self,
*,
plugin_dir: Path,
original_class_name: str,
suffix: str,
name: str,
description: str,
version: Optional[str],
icon: Optional[str],
) -> tuple[bool, str]:
"""改写分身的 Python 元数据和联邦前端资源。"""
clone_class_name = f"{original_class_name}{suffix}"
init_file = plugin_dir / "__init__.py"
if init_file.exists():
success, message = self._modify_python_file(
file_path=init_file,
original_class_name=original_class_name,
clone_class_name=clone_class_name,
name=name,
description=description,
version=version,
icon=icon,
)
if not success:
return False, message
dist_dir = plugin_dir / "dist"
if dist_dir.exists():
success, message = self._modify_federation_files(
dist_dir=dist_dir,
original_class_name=original_class_name,
clone_class_name=clone_class_name,
)
if not success:
return False, message
return True, "文件修改成功"
@staticmethod
def _modify_python_file(
*,
file_path: Path,
original_class_name: str,
clone_class_name: str,
name: str,
description: str,
version: Optional[str],
icon: Optional[str],
) -> tuple[bool, str]:
"""改写插件主类名称、展示元数据和独立配置前缀。"""
try:
content = file_path.read_text(encoding="utf-8", errors="replace")
content = content.replace(
f"class {original_class_name}",
f"class {clone_class_name}",
)
if name:
content = re.sub(
r'plugin_name\s*=\s*["\'][^"\']*["\']',
f'plugin_name = "{name}"',
content,
)
if description:
content = re.sub(
r'plugin_desc\s*=\s*["\'][^"\']*["\']',
f'plugin_desc = "{description}"',
content,
)
content = re.sub(
r'plugin_config_prefix\s*=\s*["\'][^"\']*["\']',
f'plugin_config_prefix = "{clone_class_name.lower()}_"',
content,
)
if version:
content = re.sub(
r'plugin_version\s*=\s*["\'][^"\']*["\']',
f'plugin_version = "{version}"',
content,
)
if icon and icon.strip():
content = re.sub(
r'plugin_icon\s*=\s*["\'][^"\']*["\']',
f'plugin_icon = "{icon}"',
content,
)
if "def init_plugin(self" in content:
init_index = content.index("def init_plugin(self")
content = (
content[:init_index]
+ "is_clone = True\n\n "
+ content[init_index:]
)
file_path.write_text(content, encoding="utf-8")
return True, "Python文件修改成功"
except Exception as err:
logger.error(f"修改Python文件失败:{err}")
return False, f"修改Python文件失败:{err}"
def _modify_federation_files(
self,
*,
dist_dir: Path,
original_class_name: str,
clone_class_name: str,
) -> tuple[bool, str]:
"""改写联邦构建产物中的插件类名和样式命名空间。"""
try:
for file_path in dist_dir.rglob("*"):
if not file_path.is_file() or file_path.suffix not in {".js", ".css"}:
continue
try:
content = file_path.read_text(encoding="utf-8", errors="replace")
if file_path.suffix == ".js":
content = content.replace(original_class_name, clone_class_name)
content = content.replace(
f'"{original_class_name}"',
f'"{clone_class_name}"',
)
content = content.replace(
f"'{original_class_name}'",
f"'{clone_class_name}'",
)
content = content.replace(
f"css__{original_class_name}__",
f"css__{clone_class_name}__",
)
content = content.replace(
original_class_name.lower(),
clone_class_name.lower(),
)
file_path.write_text(content, encoding="utf-8")
except Exception as err:
logger.warning(f"修改联邦插件文件 {file_path} 失败:{err}")
self._rename_federation_assets(
dist_dir,
original_class_name,
clone_class_name,
)
return True, "联邦插件文件修改完成"
except Exception as err:
logger.error(f"修改联邦插件文件失败:{err}")
return False, f"修改联邦插件文件失败:{err}"
@staticmethod
def _rename_federation_assets(
dist_dir: Path,
original_class_name: str,
clone_class_name: str,
) -> None:
"""重命名包含原类名的顶层联邦资源,避免分身资源冲突。"""
try:
for file_path in dist_dir.glob("*"):
if not file_path.is_file():
continue
if original_class_name.lower() not in file_path.name.lower():
continue
new_name = file_path.name.replace(
original_class_name.lower(),
clone_class_name.lower(),
)
new_path = file_path.parent / new_name
if not new_path.exists():
file_path.rename(new_path)
except Exception as err:
logger.warning(f"重命名联邦插件资源文件失败:{err}")
-481
View File
@@ -1,481 +0,0 @@
"""MoviePilot Release 后台检查、下载与待安装状态管理。"""
from __future__ import annotations
import hashlib
import json
import re
import stat
import subprocess
import threading
import zipfile
from datetime import datetime, timezone
from pathlib import Path, PurePosixPath
from typing import Any, Optional
from app.adapters.network.http import RequestUtils
from app.foundation.singleton import SingletonClass
from app.foundation.version import compare_version
from app.runtime.version import get_app_version
from app.foundation.environment import is_docker
from app.runtime.log import logger
from app.runtime.settings import get_runtime_setting
from app.runtime.thread import ThreadHelper
from app.schemas.system import SystemUpdateStatus
class SystemUpdateManager(metaclass=SingletonClass):
"""持久化更新状态,并保证同一时刻只有一个下载任务。"""
_BACKEND_RELEASES_API = "https://api.github.com/repos/jxxghp/MoviePilot/releases"
_FRONTEND_RELEASE_API = (
"https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases/tags/{tag}"
)
_BACKEND_ARCHIVE_URL = (
"https://github.com/jxxghp/MoviePilot/archive/refs/tags/{tag}.zip"
)
_VERSION_PATTERN = re.compile(r"^v3\.\d+\.\d+(?:[-.](?:alpha|beta|rc)\d*)?$", re.I)
_STABLE_VERSION_PATTERN = re.compile(r"^v3\.\d+\.\d+$", re.I)
def __init__(self) -> None:
self._lock = threading.RLock()
self._download_active = False
@property
def _root(self) -> Path:
return Path(get_runtime_setting('TEMP_PATH')) / "moviepilot-update"
@property
def _state_file(self) -> Path:
return self._root / "state.json"
@property
def _install_file(self) -> Path:
return self._root / "install.json"
@property
def _backend_archive(self) -> Path:
return self._root / "backend.zip"
@property
def _frontend_archive(self) -> Path:
return self._root / "frontend.zip"
@staticmethod
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _default_state(self) -> dict[str, Any]:
return SystemUpdateStatus(current_version=get_app_version()).model_dump()
def _read_state(self) -> dict[str, Any]:
try:
payload = json.loads(self._state_file.read_text(encoding="utf-8"))
if isinstance(payload, dict):
return {
**self._default_state(),
**payload,
"current_version": get_app_version(),
}
except (OSError, json.JSONDecodeError):
pass
return self._default_state()
def _write_state(self, **changes: Any) -> dict[str, Any]:
with self._lock:
state = self._read_state()
state.update(changes)
state["current_version"] = get_app_version()
state["progress"] = self._progress(
state.get("downloaded_bytes", 0), state.get("total_bytes", 0)
)
validated = SystemUpdateStatus.model_validate(state).model_dump()
self._root.mkdir(parents=True, exist_ok=True)
temporary = self._state_file.with_suffix(".tmp")
temporary.write_text(
json.dumps(validated, ensure_ascii=False, indent=2), encoding="utf-8"
)
temporary.replace(self._state_file)
return validated
@staticmethod
def _progress(downloaded: Any, total: Any) -> int:
try:
downloaded_value = max(0, int(downloaded))
total_value = max(0, int(total))
except (TypeError, ValueError):
return 0
if total_value <= 0:
return 0
return min(100, int(downloaded_value * 100 / total_value))
def get_status(self) -> SystemUpdateStatus:
"""返回状态快照,并在更新完成后的新进程中清理安装终态。"""
with self._lock:
state = self._read_state()
target = str(state.get("version") or "")
if state.get("state") == "downloading" and not self._download_active:
state = self._write_state(
state="failed",
error="更新包下载因服务重启而中断,请重试",
can_update=True,
can_install=False,
)
if state.get("state") == "installing" and target == get_app_version():
self._install_file.unlink(missing_ok=True)
state = self._write_state(
state="idle",
version=None,
frontend_version=None,
release_name=None,
release_notes=None,
published_at=None,
downloaded_bytes=0,
total_bytes=0,
error=None,
can_update=False,
can_install=False,
)
return SystemUpdateStatus.model_validate(state)
def check(self) -> SystemUpdateStatus:
"""查询 GitHub 稳定版 v3 Release,并保留正在下载或待安装状态。"""
current = self.get_status()
if current.state in {"downloading", "ready", "installing"}:
return current
try:
response = self._request().get_res(self._BACKEND_RELEASES_API)
if response is None or response.status_code != 200:
raise RuntimeError("GitHub Release 请求失败")
releases = response.json()
release = next(
(
item
for item in releases
if isinstance(item, dict)
and not item.get("draft")
and not item.get("prerelease")
and self._STABLE_VERSION_PATTERN.fullmatch(
str(item.get("tag_name") or "")
)
),
None,
)
if not release:
raise RuntimeError("未找到可用的 v3 稳定版本")
version = str(release["tag_name"])
has_update = compare_version(version, "gt", get_app_version()) is True
return SystemUpdateStatus.model_validate(
self._write_state(
state="available" if has_update else "idle",
version=version if has_update else None,
frontend_version=None,
release_name=str(release.get("name") or version) if has_update else None,
release_notes=str(release.get("body") or "") if has_update else None,
published_at=release.get("published_at") if has_update else None,
checked_at=self._now(),
downloaded_bytes=0,
total_bytes=0,
error=None,
can_update=has_update,
can_install=False,
)
)
except Exception as error: # 定时检查失败不应打扰用户,下载失败才进入可见错误态
logger.warning(f"检查 MoviePilot 更新失败: {error}")
return SystemUpdateStatus.model_validate(
self._write_state(
state="idle",
checked_at=self._now(),
error=str(error),
can_update=False,
can_install=False,
)
)
def start_download(self) -> SystemUpdateStatus:
"""启动唯一后台下载线程,并立即返回下载中状态。"""
with self._lock:
state = self.get_status()
if state.state == "ready":
return state
if state.state == "downloading" and self._download_active:
return state
if state.state != "available" or not state.version:
state = self.check()
if state.state != "available" or not state.version:
return state
self._backend_archive.unlink(missing_ok=True)
self._frontend_archive.unlink(missing_ok=True)
self._write_state(
state="downloading",
downloaded_bytes=0,
total_bytes=0,
error=None,
can_update=False,
can_install=False,
)
self._download_active = True
try:
ThreadHelper().submit(self._download_update, state.version)
except RuntimeError as error:
self._download_active = False
return SystemUpdateStatus.model_validate(
self._write_state(
state="failed",
error=f"无法启动更新包下载:{error}",
can_update=True,
can_install=False,
)
)
return self.get_status()
def request_install(self) -> tuple[bool, str]:
"""校验待安装文件并写入启动阶段消费的安装意图。"""
with self._lock:
state = self.get_status()
if state.state != "ready" or not state.version:
return False, "更新包尚未下载完成"
try:
backend_sha256 = self._sha256(self._backend_archive)
frontend_sha256 = self._sha256(self._frontend_archive)
prepared = self._read_prepared_manifest()
if backend_sha256 != prepared.get("backend_sha256"):
raise RuntimeError("后端更新包校验失败")
if frontend_sha256 != prepared.get("frontend_sha256"):
raise RuntimeError("前端更新包校验失败")
self._install_file.write_text(
json.dumps(prepared, ensure_ascii=False, indent=2), encoding="utf-8"
)
self._write_state(state="installing", can_install=False, error=None)
return True, "更新包已就绪,正在重启安装"
except (OSError, RuntimeError, json.JSONDecodeError) as error:
self._write_state(state="failed", error=str(error), can_install=False)
return False, str(error)
def cancel_install(self, reason: str) -> None:
"""重启请求失败时撤销安装意图,避免下次普通启动意外安装。"""
with self._lock:
self._install_file.unlink(missing_ok=True)
self._write_state(
state="ready",
error=reason,
can_update=False,
can_install=True,
)
def _request(self) -> RequestUtils:
return RequestUtils(
proxies=get_runtime_setting('PROXY'),
headers=get_runtime_setting('GITHUB_HEADERS'),
timeout=60,
)
def _download_update(self, version: str) -> None:
try:
downloaded = 0
downloaded, backend_total = self._download_file(
self._proxied(self._BACKEND_ARCHIVE_URL.format(tag=version)),
self._backend_archive,
downloaded,
0,
)
frontend_version = self._validate_backend_archive(version)
frontend_release = self._fetch_frontend_release(frontend_version)
frontend_asset = next(
(
item
for item in frontend_release.get("assets") or []
if item.get("name") == "dist.zip" and item.get("browser_download_url")
),
None,
)
if not frontend_asset:
raise RuntimeError(f"前端 {frontend_version} 缺少 dist.zip 发布资产")
frontend_total = int(frontend_asset.get("size") or 0)
total = backend_total + frontend_total
self._write_state(
frontend_version=frontend_version,
downloaded_bytes=downloaded,
total_bytes=total,
)
downloaded, _ = self._download_file(
self._proxied(str(frontend_asset["browser_download_url"])),
self._frontend_archive,
downloaded,
total,
)
self._validate_frontend_archive(frontend_version)
expected_digest = str(frontend_asset.get("digest") or "")
frontend_sha256 = self._sha256(self._frontend_archive)
if expected_digest.startswith("sha256:") and frontend_sha256 != expected_digest.removeprefix("sha256:"):
raise RuntimeError("前端更新包与 GitHub Release 摘要不一致")
if not is_docker():
self._prepare_local_backend_ref(version)
prepared = {
"version": version,
"frontend_version": frontend_version,
"backend_archive": str(self._backend_archive),
"frontend_archive": str(self._frontend_archive),
"backend_sha256": self._sha256(self._backend_archive),
"frontend_sha256": frontend_sha256,
"prepared_at": self._now(),
}
(self._root / "prepared.json").write_text(
json.dumps(prepared, ensure_ascii=False, indent=2), encoding="utf-8"
)
self._write_state(
state="ready",
downloaded_bytes=downloaded,
total_bytes=max(total, downloaded),
error=None,
can_update=False,
can_install=True,
)
logger.info(f"MoviePilot {version} 更新包已下载完成,等待用户确认重启")
except Exception as error: # 后台线程必须把所有失败沉淀为可查询状态
logger.error(f"下载 MoviePilot 更新包失败: {error}")
self._write_state(
state="failed", error=str(error), can_update=True, can_install=False
)
finally:
with self._lock:
self._download_active = False
def _download_file(
self, url: str, destination: Path, downloaded_before: int, total_hint: int
) -> tuple[int, int]:
temporary = destination.with_suffix(".part")
temporary.unlink(missing_ok=True)
with self._request().get_stream(url) as response:
if response is None or response.status_code != 200:
raise RuntimeError(f"下载更新包失败:HTTP {getattr(response, 'status_code', '无响应')}")
content_length = int(response.headers.get("content-length") or 0)
total = total_hint or content_length
current = downloaded_before
with temporary.open("wb") as output:
for chunk in response.iter_content(chunk_size=256 * 1024):
if not chunk:
continue
output.write(chunk)
current += len(chunk)
self._write_state(downloaded_bytes=current, total_bytes=total)
temporary.replace(destination)
return current, content_length
def _fetch_frontend_release(self, version: str) -> dict[str, Any]:
response = self._request().get_res(
self._FRONTEND_RELEASE_API.format(tag=version)
)
if response is None or response.status_code != 200:
raise RuntimeError(f"无法获取前端 {version} Release")
payload = response.json()
if not isinstance(payload, dict):
raise RuntimeError("前端 Release 返回格式异常")
return payload
def _validate_backend_archive(self, version: str) -> str:
with zipfile.ZipFile(self._backend_archive) as archive:
self._validate_zip_members(archive)
version_name = next(
(name for name in archive.namelist() if name.count("/") == 1 and name.endswith("/version.py")),
None,
)
if not version_name:
raise RuntimeError("后端更新包缺少 version.py")
version_source = archive.read(version_name).decode("utf-8")
app_match = re.search(r"^APP_VERSION\s*=\s*['\"]([^'\"]+)", version_source, re.M)
frontend_match = re.search(r"^FRONTEND_VERSION\s*=\s*['\"]([^'\"]+)", version_source, re.M)
if not app_match or app_match.group(1) != version:
raise RuntimeError("后端更新包版本与目标 Release 不一致")
if not frontend_match or not self._VERSION_PATTERN.fullmatch(frontend_match.group(1)):
raise RuntimeError("后端更新包声明的前端版本无效")
required = ("pyproject.toml", "uv.lock")
names = archive.namelist()
if any(not any(name.endswith(f"/{item}") for name in names) for item in required):
raise RuntimeError("后端更新包缺少依赖锁定文件")
return frontend_match.group(1)
def _validate_frontend_archive(self, version: str) -> None:
with zipfile.ZipFile(self._frontend_archive) as archive:
self._validate_zip_members(archive)
names = set(archive.namelist())
if "dist/index.html" not in names or "dist/version.txt" not in names:
raise RuntimeError("前端更新包结构无效")
archived_version = archive.read("dist/version.txt").decode("utf-8").strip()
if archived_version != version:
raise RuntimeError("前端更新包版本与后端声明不一致")
@staticmethod
def _validate_zip_members(archive: zipfile.ZipFile) -> None:
for item in archive.infolist():
path = PurePosixPath(item.filename)
file_type = (item.external_attr >> 16) & 0o170000
if (
path.is_absolute()
or ".." in path.parts
or file_type == stat.S_IFLNK
):
raise RuntimeError("更新包包含不安全路径")
def _read_prepared_manifest(self) -> dict[str, Any]:
payload = json.loads((self._root / "prepared.json").read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise RuntimeError("更新包清单格式无效")
return payload
@staticmethod
def _prepare_local_backend_ref(version: str) -> None:
"""本地 CLI 在下载阶段获取标签,使重启后的代码切换不再联网。"""
root = Path(__file__).resolve().parents[3]
if not (root / ".git").is_dir():
raise RuntimeError("本地安装目录不是 Git 仓库,无法准备 Release 更新")
try:
worktree = subprocess.run(
["git", "status", "--porcelain", "--untracked-files=no"],
cwd=root,
check=True,
capture_output=True,
text=True,
timeout=30,
)
if worktree.stdout.strip():
raise RuntimeError("本地源码存在未提交改动,无法准备 Release 更新")
subprocess.run(
["git", "fetch", "--no-tags", "origin", "tag", version],
cwd=root,
check=True,
capture_output=True,
text=True,
timeout=180,
)
subprocess.run(
["git", "rev-parse", "--verify", f"{version}^{{commit}}"],
cwd=root,
check=True,
capture_output=True,
text=True,
timeout=30,
)
except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as error:
raise RuntimeError(f"无法准备本地 Release 标签 {version}") from error
@staticmethod
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as file_handle:
for chunk in iter(lambda: file_handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
@staticmethod
def _proxied(url: str) -> str:
proxy = str(get_runtime_setting('GITHUB_PROXY') or "").strip()
return f"{proxy}{url}" if proxy else url
system_update_manager = SystemUpdateManager()
-1
View File
@@ -1 +0,0 @@
"""Web 框架适配器。"""
-44
View File
@@ -1,44 +0,0 @@
"""HTTP 请求关联 ID 的 ASGI 适配器。"""
from __future__ import annotations
from typing import Any
from app.runtime.correlation import (
CORRELATION_ID_HEADER,
correlation_scope,
normalize_correlation_id,
)
class CorrelationIdMiddleware:
"""验证入口 ID、绑定请求上下文并把同一 ID 写回响应。"""
def __init__(self, app: Any) -> None:
"""保存下游 ASGI 应用。"""
self._app = app
async def __call__(self, scope: dict, receive: Any, send: Any) -> None:
"""只治理 HTTP scope,并让绑定覆盖完整流式响应生命周期。"""
if scope.get("type") != "http":
await self._app(scope, receive, send)
return
raw_headers = dict(scope.get("headers") or [])
candidate = raw_headers.get(CORRELATION_ID_HEADER.lower().encode("ascii"))
correlation_id = normalize_correlation_id(
candidate.decode("ascii", errors="ignore") if candidate else None
)
scope.setdefault("state", {})["request_id"] = correlation_id
async def send_with_correlation(message: dict) -> None:
"""在响应开始帧中覆盖为当前请求的安全关联 ID。"""
if message.get("type") == "http.response.start":
headers = list(message.get("headers") or [])
header_name = CORRELATION_ID_HEADER.lower().encode("ascii")
headers = [item for item in headers if item[0].lower() != header_name]
headers.append((header_name, correlation_id.encode("ascii")))
message["headers"] = headers
await send(message)
with correlation_scope(correlation_id):
await self._app(scope, receive, send_with_correlation)
-45
View File
@@ -1,45 +0,0 @@
"""面向编排器的最小公开健康探针。"""
from fastapi import FastAPI, Request, status
from fastapi.responses import JSONResponse
from fastapi.routing import APIRoute
from app.runtime.health import get_application_health
async def liveness() -> JSONResponse:
"""确认进程和当前事件循环能够处理请求,不访问任何外部依赖。"""
return JSONResponse(content={"status": "alive"})
async def readiness(request: Request) -> JSONResponse:
"""仅公开可否接流量,不泄露数据库、插件或启动异常细节。"""
ready = get_application_health(request.app).is_ready
return JSONResponse(
status_code=(
status.HTTP_200_OK
if ready
else status.HTTP_503_SERVICE_UNAVAILABLE
),
content={"status": "ready" if ready else "not_ready"},
)
def install_health_routes(app: FastAPI) -> None:
"""把公开探针装到 API 版本前缀之外,供容器和反向代理使用。"""
app.router.add_api_route(
"/health/live",
liveness,
methods=["GET"],
response_class=JSONResponse,
include_in_schema=False,
route_class_override=APIRoute,
)
app.router.add_api_route(
"/health/ready",
readiness,
methods=["GET"],
response_class=JSONResponse,
include_in_schema=False,
route_class_override=APIRoute,
)
-58
View File
@@ -1,58 +0,0 @@
"""HTTP route/status/latency 指标 ASGI 适配器。"""
from __future__ import annotations
import time
from typing import Any
from app.runtime.observability import record_metric
from starlette.routing import Match
class HttpMetricsMiddleware:
"""按路由模板、方法和状态码记录低基数 HTTP 时延。"""
def __init__(self, app: Any) -> None:
"""保存下游 ASGI 应用。"""
self._app = app
async def __call__(self, scope: dict, receive: Any, send: Any) -> None:
"""只治理 HTTP scope,并在响应开始后读取路由模板。"""
if scope.get("type") != "http":
await self._app(scope, receive, send)
return
started_at = time.perf_counter()
status = "500"
async def send_with_metrics(message: dict) -> None:
"""捕获响应状态并原样转发 ASGI 消息。"""
nonlocal status
if message.get("type") == "http.response.start":
status = str(message.get("status", 500))
await send(message)
try:
await self._app(scope, receive, send_with_metrics)
finally:
route_path = self._resolve_route_template(scope)
record_metric(
"http.server.duration",
time.perf_counter() - started_at,
route=route_path,
method=str(scope.get("method", "UNKNOWN")),
status=status,
)
def _resolve_route_template(self, scope: dict) -> str:
"""遍历 ASGI wrapper 找到匹配路由模板,绝不回退到具体请求 path。"""
route = scope.get("route")
if getattr(route, "path", None):
return route.path
candidate = self._app
while candidate is not None:
for registered_route in getattr(candidate, "routes", ()):
match, _ = registered_route.matches(scope)
if match == Match.FULL:
return getattr(registered_route, "path", "unmatched")
candidate = getattr(candidate, "app", None)
return "unmatched"
-1
View File
@@ -1 +0,0 @@
"""插件 Web 适配器。"""
-178
View File
@@ -1,178 +0,0 @@
"""FastAPI 动态插件路由适配器。"""
import asyncio
from concurrent.futures import Future, TimeoutError as FutureTimeoutError
from threading import Lock
from typing import Any, Callable, Optional
from fastapi import Depends, FastAPI
from fastapi.routing import APIRoute
class FastAPIDynamicRouteRegistry:
"""在 FastAPI 上注册插件自由响应路由,并维护 OpenAPI 缓存。"""
_dispatch_admission_timeout = 5.0
def __init__(
self,
app: FastAPI,
plugin_ids: Callable[[], list[str]],
plugin_apis: Callable[[str], list[dict]],
verify_token: Callable[..., Any],
verify_apikey: Callable[..., Any],
prefix: str,
protected_routes: set[str],
log: Any,
event_loop: Callable[[], asyncio.AbstractEventLoop | None] | None = None,
) -> None:
"""注入应用、插件投影、认证依赖和日志端口。"""
self._app = app
self._plugin_ids = plugin_ids
self._plugin_apis = plugin_apis
self._verify_token = verify_token
self._verify_apikey = verify_apikey
self._prefix = prefix
self._protected_routes = protected_routes
self._logger = log
self._event_loop = event_loop
def update(self, plugin_id: Optional[str], action: str) -> None:
"""在主事件循环中按插件生命周期新增或移除动态路由。"""
if self._event_loop is None:
self._update(plugin_id, action)
return
target_loop = self._event_loop()
if (
target_loop is None
or not target_loop.is_running()
or target_loop.is_closed()
):
raise RuntimeError("主事件循环未运行,无法更新插件动态路由")
try:
current_loop = asyncio.get_running_loop()
except RuntimeError:
current_loop = None
if current_loop is target_loop:
self._update(plugin_id, action)
return
completed: Future[None] = Future()
dispatch_lock = Lock()
dispatch_started = False
dispatch_abandoned = False
def apply_update() -> None:
"""在目标 loop 的单个回调中完成路由表与 OpenAPI 投影切换。"""
nonlocal dispatch_started
with dispatch_lock:
if dispatch_abandoned:
return
dispatch_started = True
try:
self._update(plugin_id, action)
except BaseException as error:
completed.set_exception(error)
else:
completed.set_result(None)
target_loop.call_soon_threadsafe(apply_update)
try:
completed.result(timeout=self._dispatch_admission_timeout)
except FutureTimeoutError as error:
with dispatch_lock:
if not dispatch_started:
dispatch_abandoned = True
raise RuntimeError(
"主事件循环未及时接收插件动态路由更新"
) from error
# 回调一旦开始便不可撤销,等待确定终态以免失败回滚后发生迟到写入。
completed.result()
def _update(self, plugin_id: Optional[str], action: str) -> None:
"""执行不可中断的路由表与 OpenAPI 投影更新。"""
if action not in {"add", "remove"}:
raise ValueError("Action must be 'add' or 'remove'")
modified = False
existing_paths = {
path: route
for route in self._app.routes
if (path := self._route_path(route)) is not None
}
plugin_ids = [plugin_id] if plugin_id else self._plugin_ids()
for current_id in plugin_ids:
if self.remove(current_id):
modified = True
if action != "add":
continue
for source_api in self._plugin_apis(current_id):
api = dict(source_api)
api["dependencies"] = list(source_api.get("dependencies") or ())
api_path = f"{self._prefix}{api.get('path', '')}"
try:
api["path"] = api_path
allow_anonymous = api.pop("allow_anonymous", False)
auth_mode = api.pop("auth", "apikey")
dependencies = api.setdefault("dependencies", [])
if not allow_anonymous:
if (
auth_mode == "bear"
and Depends(self._verify_token) not in dependencies
):
dependencies.append(Depends(self._verify_token))
elif Depends(self._verify_apikey) not in dependencies:
dependencies.append(Depends(self._verify_apikey))
# 插件 API 自行决定响应结构,不使用宿主统一 Response 路由。
api.setdefault("route_class_override", APIRoute)
self._app.router.add_api_route(**api, tags=["plugin"])
modified = True
self._logger.debug(f"Added plugin route: {api_path}")
except Exception as error:
self._logger.error(
f"Error adding plugin route {api_path}: {str(error)}"
)
if modified:
self.clean(existing_paths)
self._app.openapi_schema = None
self._app.setup()
def remove(self, plugin_id: str) -> bool:
"""移除指定插件前缀下的全部动态路由。"""
if not plugin_id:
return False
prefix = f"{self._prefix}/{plugin_id}/"
routes = [
route for route in self._app.routes
if (path := self._route_path(route)) is not None
and path.startswith(prefix)
]
removed = False
for route in routes:
try:
self._app.routes.remove(route)
removed = True
self._logger.debug(f"Removed plugin route: {self._route_path(route)}")
except Exception as error:
self._logger.error(
f"Error removing plugin route {self._route_path(route)}: {str(error)}"
)
return removed
@staticmethod
def _route_path(route: Any) -> Optional[str]:
"""返回公开路由路径,跳过 FastAPI 内部的无路径 include 包装器。"""
path = getattr(route, "path", None)
return path if isinstance(path, str) else None
def clean(self, existing_paths: dict) -> None:
"""清理 FastAPI 重建时可能重复的受保护文档路由。"""
for protected_route in self._protected_routes:
try:
existing_route = existing_paths.get(protected_route)
if existing_route:
self._app.routes.remove(existing_route)
except Exception as error:
self._logger.error(
f"Error removing protected route {protected_route}: {str(error)}"
)
-1
View File
@@ -1 +0,0 @@
"""Web 传输层认证适配器。"""
-274
View File
@@ -1,274 +0,0 @@
"""把应用安全能力适配为 FastAPI 认证依赖和 Cookie 行为。"""
import datetime
from datetime import timedelta
from typing import Annotated, Any, Callable, Optional
import jwt
from fastapi import HTTPException, Request, Response, Security, status
from fastapi.security import (
APIKeyCookie,
APIKeyHeader,
APIKeyQuery,
HTTPBearer,
OAuth2PasswordBearer,
)
from app.runtime.cache import cached
from app.runtime.log import logger
from app.runtime.settings import get_runtime_setting
from app.schemas.token import TokenPayload
SuperuserTokenPayloadProvider = Callable[[], TokenPayload]
TokenEncoder = Callable[..., str]
TokenDecoder = Callable[[str | None, str], TokenPayload]
_superuser_token_payload_provider: Optional[SuperuserTokenPayloadProvider] = None
_token_encoder: Optional[TokenEncoder] = None
_token_decoder: Optional[TokenDecoder] = None
JWT_ALGORITHM = "HS256"
oauth2_scheme_manual_error = OAuth2PasswordBearer(
auto_error=False,
tokenUrl=f"{get_runtime_setting('API_V1_STR')}/login/access-token",
)
resource_token_cookie = APIKeyCookie(
name=get_runtime_setting('PROJECT_NAME'),
auto_error=False,
scheme_name="resource_token_cookie",
)
api_token_query = APIKeyQuery(
name="token",
auto_error=False,
scheme_name="api_token_query",
)
api_key_header = APIKeyHeader(
name="X-API-KEY",
auto_error=False,
scheme_name="api_key_header",
)
api_key_query = APIKeyQuery(
name="apikey",
auto_error=False,
scheme_name="api_key_query",
)
openai_bearer_scheme = HTTPBearer(auto_error=False)
anthropic_api_key_header = APIKeyHeader(
name="x-api-key",
auto_error=False,
scheme_name="anthropic_api_key_header",
)
def set_superuser_token_payload_provider(
provider: SuperuserTokenPayloadProvider,
) -> None:
"""由启动组合根注入 API 密钥认证使用的超级用户载荷来源。"""
global _superuser_token_payload_provider
_superuser_token_payload_provider = provider
def configure_token_codec(
encoder: TokenEncoder,
decoder: TokenDecoder,
) -> None:
"""由组合根注入框架无关的令牌编码与解码能力。"""
global _token_encoder, _token_decoder
_token_encoder = encoder
_token_decoder = decoder
def _encode_token(**claims: Any) -> str:
"""使用已注入编码器创建令牌,未装配时给出明确错误。"""
if _token_encoder is None:
raise RuntimeError("Web 认证令牌编码器尚未配置")
return _token_encoder(**claims)
def _decode_token(token: str | None, purpose: str) -> TokenPayload:
"""使用已注入解码器验证令牌,未装配时给出明确错误。"""
if _token_decoder is None:
raise RuntimeError("Web 认证令牌解码器尚未配置")
return _token_decoder(token, purpose)
def _get_api_token(
token_query: Annotated[str | None, Security(api_token_query)] = None,
) -> str | None:
"""从 URL 查询参数读取兼容 API Token。"""
return token_query
def _get_api_key(
key_query: Annotated[str | None, Security(api_key_query)] = None,
key_header: Annotated[str | None, Security(api_key_header)] = None,
) -> str | None:
"""优先从请求头、其次从查询参数读取兼容 API Key。"""
return key_header or key_query
@cached(maxsize=1, ttl=600)
def _create_superuser_token_payload() -> TokenPayload:
"""使用组合根提供器创建 API 密钥调用的超级用户载荷。"""
if not _superuser_token_payload_provider:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="认证服务尚未初始化",
)
try:
return _superuser_token_payload_provider()
except PermissionError as error:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(error) or "用户权限不足",
) from error
def set_or_refresh_resource_token_cookie(
request: Request,
response: Response,
payload: TokenPayload,
) -> None:
"""复用匹配的资源令牌,或为当前身份写入新的安全 Cookie。"""
project_name = get_runtime_setting('PROJECT_NAME')
resource_token = request.cookies.get(project_name)
if resource_token:
try:
decoded = jwt.decode(
resource_token,
get_runtime_setting('RESOURCE_SECRET_KEY'),
algorithms=[JWT_ALGORITHM],
)
exp = decoded.get("exp")
if exp:
remaining_time = datetime.datetime.fromtimestamp(
exp,
tz=datetime.UTC,
) - datetime.datetime.now(datetime.UTC)
if remaining_time < timedelta(
seconds=(
get_runtime_setting(
"RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS"
)
/ 3
)
):
raise jwt.ExpiredSignatureError
expected_claims = {
"sub": str(payload.sub),
"username": payload.username,
"super_user": payload.super_user,
"level": payload.level,
"purpose": "resource",
}
if any(
decoded.get(claim) != value
for claim, value in expected_claims.items()
):
raise jwt.InvalidTokenError("资源令牌身份或权限上下文不匹配")
except jwt.PyJWTError:
logger.debug("Token error occurred. refreshing token")
except Exception as error:
logger.debug(
f"Unexpected error occurred while decoding token: {error}"
)
else:
return
resource_token = _encode_token(
userid=payload.sub,
username=payload.username or "",
super_user=payload.super_user,
expires_delta=timedelta(
seconds=get_runtime_setting('RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS')
),
level=payload.level,
purpose="resource",
)
is_https = (
request.url.scheme == "https"
or request.headers.get("x-forwarded-proto", "").lower() == "https"
)
response.set_cookie(
key=project_name,
value=resource_token,
httponly=True,
secure=is_https,
samesite="lax",
)
def _decode_or_http_error(
token: str | None,
purpose: str,
) -> TokenPayload:
"""把应用层令牌校验错误转换为 HTTP 403。"""
try:
return _decode_token(token, purpose)
except ValueError as error:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(error),
) from error
def verify_token(
request: Request,
response: Response,
jwt_token: Annotated[
str | None,
Security(oauth2_scheme_manual_error),
],
api_key: Annotated[str | None, Security(_get_api_key)],
api_token: Annotated[str | None, Security(_get_api_token)],
) -> TokenPayload:
"""验证 JWT、API Key 或 API Token,并维护资源 Cookie。"""
if jwt_token:
payload = _decode_or_http_error(jwt_token, "authentication")
set_or_refresh_resource_token_cookie(request, response, payload)
return payload
if api_key:
verify_apikey(api_key)
return _create_superuser_token_payload()
if api_token:
verify_apitoken(api_token)
return _create_superuser_token_payload()
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
def verify_resource_token(
resource_token: Annotated[
str | None,
Security(resource_token_cookie),
],
) -> TokenPayload:
"""验证 Cookie 中携带的资源访问令牌。"""
return _decode_or_http_error(resource_token, "resource")
def _verify_key(key: str | None, expected_key: str, key_type: str) -> str:
"""校验受信第三方集成使用的固定 API 凭据。"""
if not key or key != expected_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"{key_type} 校验不通过",
)
return key
def verify_apitoken(
token: Annotated[str | None, Security(_get_api_token)],
) -> str:
"""校验 URL 查询参数中的兼容 API Token。"""
return _verify_key(token, get_runtime_setting('API_TOKEN'), "token")
def verify_apikey(
apikey: Annotated[str | None, Security(_get_api_key)],
) -> str:
"""校验请求头或查询参数中的兼容 API Key。"""
return _verify_key(apikey, get_runtime_setting('API_TOKEN'), "apikey")
+2361 -14
View File
File diff suppressed because it is too large Load Diff
+23 -109
View File
@@ -1,34 +1,24 @@
import asyncio
import re
import threading
from typing import Any, Optional, Tuple
from app.runtime.execution import run_in_threadpool
from app.agent.policy.sanitizer import sanitize_for_host
from fastapi.concurrency import run_in_threadpool
from app.chain import ChainBase
from app.runtime.log import logger
from app.schemas.message import Message, MessageResponse
from app.schemas.notification import ChannelCapabilityManager, ChannelCapability
from app.schemas.types import NotificationChannel, MessageType
from app.log import logger
from app.schemas import Notification
from app.schemas.message import (
MessageResponse,
ChannelCapabilityManager,
ChannelCapability,
)
from app.schemas.types import MessageChannel, NotificationType
class _StreamChain(ChainBase):
pass
_PATCH_FILE_HEADER_PATTERN = re.compile(
r"\*\*\* (?:Add|Update|Delete) File:\s*(\S+)"
)
def _extract_first_patch_path(patch: Optional[str]) -> Optional[str]:
"""从补丁文本中提取首个文件路径,作为流式消息展示目标。"""
if not patch:
return None
match = _PATCH_FILE_HEADER_PATTERN.search(patch)
return match.group(1) if match else None
class StreamingHandler:
"""
流式Token缓冲管理器
@@ -58,7 +48,6 @@ class StreamingHandler:
# 流式输出相关状态
self._streaming_enabled = False
self._flush_task: Optional[asyncio.Task] = None
self._streaming_lifecycle_lock = asyncio.Lock()
# 当前消息的发送信息(用于编辑消息)
self._message_response: Optional[MessageResponse] = None
# 已发送给用户的文本(用于追踪增量)
@@ -78,8 +67,6 @@ class StreamingHandler:
self._allow_dispatch_without_context = False
# 非啰嗦模式下的待输出工具统计,等下一段文本到来时再统一补一句摘要
self._pending_tool_stats: dict[str, dict[str, Any]] = {}
# 本轮已写入缓冲区的工具摘要行,供 Telegram 富文本渲染时做区分样式
self._tool_summaries: set[str] = set()
def set_dispatch_policy(
self, allow_dispatch_without_context: bool = False
@@ -141,7 +128,6 @@ class StreamingHandler:
self._message_response = None
self._msg_start_offset = 0
self._pending_tool_stats = {}
self._tool_summaries = set()
def reset(self):
"""
@@ -156,7 +142,6 @@ class StreamingHandler:
self._sent_text = ""
self._msg_start_offset = 0
self._pending_tool_stats = {}
self._tool_summaries = set()
async def start_streaming(
self,
@@ -167,28 +152,6 @@ class StreamingHandler:
original_message_id: Optional[str] = None,
original_chat_id: Optional[str] = None,
title: str = "",
):
"""串行启动流式输出,禁止新一轮覆盖尚未结束的刷新 owner。"""
async with self._streaming_lifecycle_lock:
await self._start_streaming(
channel=channel,
source=source,
user_id=user_id,
username=username,
original_message_id=original_message_id,
original_chat_id=original_chat_id,
title=title,
)
async def _start_streaming(
self,
channel: Optional[str] = None,
source: Optional[str] = None,
user_id: Optional[str] = None,
username: Optional[str] = None,
original_message_id: Optional[str] = None,
original_chat_id: Optional[str] = None,
title: str = "",
):
"""
启动流式输出。
@@ -202,10 +165,6 @@ class StreamingHandler:
:param original_message_id: 原始消息ID(如果是回复消息)
:param original_chat_id: 原始聊天ID(如果是回复消息)
"""
if self._flush_task is not None:
self._streaming_enabled = False
await self._cancel_flush_task()
self._channel = channel
self._source = source
self._user_id = user_id
@@ -219,7 +178,6 @@ class StreamingHandler:
self._message_response = None
self._msg_start_offset = 0
self._pending_tool_stats = {}
self._tool_summaries = set()
# 检查渠道是否支持消息编辑,不支持则仅收集 token 到 buffer,不实时推送
if not self._can_stream():
@@ -228,7 +186,7 @@ class StreamingHandler:
# 从渠道能力中获取单条消息最大长度
try:
channel_enum = NotificationChannel(self._channel)
channel_enum = MessageChannel(self._channel)
self._max_message_length = ChannelCapabilityManager.get_max_message_length(
channel_enum
)
@@ -240,11 +198,6 @@ class StreamingHandler:
logger.debug("流式输出已启动")
async def stop_streaming(self) -> Tuple[bool, str]:
"""串行停止流式输出,并等待本轮刷新与最终消息收口。"""
async with self._streaming_lifecycle_lock:
return await self._stop_streaming()
async def _stop_streaming(self) -> Tuple[bool, str]:
"""
停止流式输出。执行最后一次刷新确保所有内容都已发送。
:return: (all_sent, final_text)
@@ -289,7 +242,6 @@ class StreamingHandler:
self._message_response = None
self._msg_start_offset = 0
self._pending_tool_stats = {}
self._tool_summaries = set()
if all_sent:
# 所有内容已通过流式发送,清空缓冲区
self._buffer = ""
@@ -304,14 +256,10 @@ class StreamingHandler:
"""
记录一次工具调用,供非啰嗦模式下延迟汇总输出。
"""
recorded_message = sanitize_for_host(tool_message) if tool_message else tool_message
recorded_args = sanitize_for_host(tool_kwargs or {})
if not isinstance(recorded_args, dict):
recorded_args = {}
category, target = self._classify_tool_call(
tool_name=tool_name,
tool_message=recorded_message,
tool_kwargs=recorded_args,
tool_message=tool_message,
tool_kwargs=tool_kwargs or {},
)
target_values = []
if isinstance(target, (list, tuple, set)):
@@ -384,8 +332,6 @@ class StreamingHandler:
return "file_read", tool_kwargs.get("file_path")
if tool_name in {"write_file", "edit_file"}:
return "file_write", tool_kwargs.get("file_path")
if tool_name == "apply_patch":
return "file_write", _extract_first_patch_path(tool_kwargs.get("patch"))
if tool_name in {"list_directory", "query_directory_settings"}:
return "directory", tool_kwargs.get("path")
if tool_name == "browse_webpage":
@@ -467,14 +413,11 @@ class StreamingHandler:
return ""
summary = f"{''.join(parts)}"
self._tool_summaries.add(summary)
# 摘要前始终保证一个空行,让工具执行信息与正文分属不同段落,
# 避免 Markdown 富文本把单个换行折叠成同一段落内的软换行
visible_buffer = self._buffer.rstrip(" \t")
trailing_newlines = len(visible_buffer) - len(visible_buffer.rstrip("\n"))
last_char = visible_buffer[-1:] if visible_buffer.strip() else ""
prefix = ""
if visible_buffer.strip():
prefix = "\n" * max(2 - trailing_newlines, 0)
if self._buffer and last_char != "\n":
prefix = "\n\n"
return f"{prefix}{summary}\n\n"
@staticmethod
@@ -515,35 +458,13 @@ class StreamingHandler:
if not self._channel:
return False
try:
channel_enum = NotificationChannel(self._channel)
channel_enum = MessageChannel(self._channel)
return ChannelCapabilityManager.supports_capability(
channel_enum, ChannelCapability.MESSAGE_EDITING
)
except (ValueError, KeyError):
return False
def _get_rich_message(self, text: str) -> Optional[str]:
"""
为 Telegram 流式消息返回 Rich Markdown,其他渠道继续使用原有格式。
"""
if self._channel != NotificationChannel.Telegram.value:
return None
return self._quote_tool_summary_lines(text)
def _quote_tool_summary_lines(self, text: str) -> str:
"""
将缓冲区中的工具摘要整行转换为 Markdown 引用块。
富文本会把普通段落间的空行折叠成紧凑排版,引用块作为独立 block 类型
渲染,保证工具执行信息在 Telegram 上始终与正文有可辨识的视觉分隔。
"""
if not self._tool_summaries or not text:
return text
return "\n".join(
f"> {line}" if line in self._tool_summaries else line
for line in text.split("\n")
)
async def _flush_loop(self):
"""
定时刷新循环,定期将缓冲区内容发送/编辑到用户
@@ -605,17 +526,16 @@ class StreamingHandler:
# 第一次发送:发送新消息并获取 message_id
response = await run_in_threadpool(
chain.send_direct_message,
Message(
Notification(
channel=self._channel,
source=self._source,
mtype=MessageType.Agent,
mtype=NotificationType.Agent,
userid=self._user_id,
username=self._username,
original_message_id=self._original_message_id,
original_chat_id=self._original_chat_id,
title=self._title,
text=current_text,
rich_message=self._get_rich_message(current_text),
save_history=False,
),
)
@@ -652,17 +572,16 @@ class StreamingHandler:
if current_text:
response = await run_in_threadpool(
chain.send_direct_message,
Message(
Notification(
channel=self._channel,
source=self._source,
mtype=MessageType.Agent,
mtype=NotificationType.Agent,
userid=self._user_id,
username=self._username,
original_message_id=self._original_message_id,
original_chat_id=self._original_chat_id,
title=self._title,
text=current_text,
rich_message=self._get_rich_message(current_text),
save_history=False,
),
)
@@ -679,15 +598,10 @@ class StreamingHandler:
else:
# 后续更新:编辑已有消息
try:
channel_enum = NotificationChannel(self._channel)
channel_enum = MessageChannel(self._channel)
except (ValueError, KeyError):
return
metadata = dict(self._message_response.metadata or {})
rich_message = self._get_rich_message(current_text)
if rich_message:
# 通用编辑接口不增加渠道专属参数,通过元数据交给 Telegram 模块消费。
metadata["telegram_rich_message"] = rich_message
success = await run_in_threadpool(
chain.edit_message,
channel=channel_enum,
@@ -696,7 +610,7 @@ class StreamingHandler:
chat_id=self._message_response.chat_id,
text=current_text,
title=self._title,
metadata=metadata,
metadata=self._message_response.metadata,
)
if success:
with self._lock:
-8
View File
@@ -1,8 +0,0 @@
"""Agent Capability 声明与通用入口适配器。"""
AGENT_ENTRYPOINT_KIND = "agent_entrypoint"
AGENT_SERVICE_KIND = "agent_service"
AGENT_MANAGER_CAPABILITY_ID = "agent.manager"
AGENT_SERVICE_CAPABILITY_ID = "agent.service"
MOVIEPILOT_AGENT_TYPE_CAPABILITY_ID = "agent.moviepilot_type"
TOOL_FACTORY_CAPABILITY_ID = "agent.tool_factory"
-221
View File
@@ -1,221 +0,0 @@
"""Agent canonical entrypoint 的 Capability Runtime 适配器。"""
from __future__ import annotations
import asyncio
import importlib
import inspect
from pathlib import Path
from typing import Any, Iterable, Mapping
from app.agent.capabilities import AGENT_ENTRYPOINT_KIND, AGENT_SERVICE_KIND
from app.runtime.capabilities.errors import CapabilityAdapterContractError
from app.runtime.capabilities.model import (
ActivationPolicy,
AdapterExecutionMode,
CapabilitySpec,
SelectorSchema,
)
from app.runtime.capabilities.registry import CapabilityRegistry
from app.runtime.settings import get_runtime_setting, has_runtime_setting
_DEFAULT_CAPABILITY_ROOT = Path(__file__).resolve().parent
_SETTING_SELECTOR = "setting_truthy"
def _validate_setting_selector(config: Mapping[str, Any]) -> None:
"""限制 selector 只能读取已声明的应用设置。"""
key = config["key"]
if not isinstance(key, str) or not key or not has_runtime_setting(key):
raise ValueError(f"未知应用设置:{key!r}")
AGENT_SELECTOR_SCHEMAS = {
_SETTING_SELECTOR: SelectorSchema(
required_fields=frozenset({"key"}),
validator=_validate_setting_selector,
)
}
def _load_entrypoint(spec: CapabilitySpec) -> Any:
"""按 manifest 解析 canonical 符号,不创建额外业务对象。"""
module_name, symbol_name = spec.entrypoint.split(":", maxsplit=1)
module = importlib.import_module(module_name)
try:
return getattr(module, symbol_name)
except AttributeError as error:
raise CapabilityAdapterContractError(
f"{spec.entrypoint} 未公开 Agent entrypoint"
) from error
def _lifecycle_method(spec: CapabilitySpec, candidate: Any, name: str) -> Any:
"""读取 Agent Service 必需的异步生命周期方法。"""
callback = getattr(candidate, name, None)
if not callable(callback):
raise CapabilityAdapterContractError(
f"{spec.entrypoint} 的 Agent Service 缺少 {name}()"
)
return callback
class AgentEntrypointAdapter:
"""把 canonical Python 符号作为无资源副作用的同步能力发布。"""
execution_mode = AdapterExecutionMode.SYNC
@staticmethod
def materialize(spec: CapabilitySpec) -> Any:
"""按 manifest entrypoint 导入 canonical 符号。"""
return _load_entrypoint(spec)
@staticmethod
def create(
_spec: CapabilitySpec,
implementation: Any,
_generation: int,
_previous: Any = None,
) -> Any:
"""发布 canonical 符号本身,不创建第二份业务对象。"""
return implementation
@staticmethod
def start(
_spec: CapabilitySpec,
_candidate: Any,
_generation: int,
) -> None:
"""entrypoint 不拥有业务资源,初始化由独立 service 能力负责。"""
@staticmethod
def stop(
_spec: CapabilitySpec,
_instance: Any,
_generation: int,
) -> None:
"""撤销入口可见性;业务资源由独立 service 能力关闭。"""
@staticmethod
def cleanup(
_spec: CapabilitySpec,
_candidate: Any,
_generation: int,
_error: BaseException,
) -> None:
"""entrypoint 启动无副作用,因此失败候选无需额外释放。"""
class AgentServiceAdapter:
"""把具备 initialize/close 的 canonical 对象接入异步资源生命周期。"""
execution_mode = AdapterExecutionMode.ASYNC
@staticmethod
async def materialize(spec: CapabilitySpec) -> Any:
"""在线程中导入 canonical service,避免阻塞应用事件循环。"""
return await asyncio.to_thread(_load_entrypoint, spec)
@staticmethod
async def create(
_spec: CapabilitySpec,
implementation: Any,
_generation: int,
_previous: Any = None,
) -> Any:
"""复用 canonical service,不复制其内部队列和后台任务所有权。"""
return implementation
@staticmethod
async def start(
spec: CapabilitySpec,
candidate: Any,
_generation: int,
) -> None:
"""等待 service 在当前应用事件循环完成初始化。"""
result = _lifecycle_method(spec, candidate, "initialize")()
if not inspect.isawaitable(result):
raise CapabilityAdapterContractError(
f"{spec.entrypoint}.initialize() 必须返回 awaitable"
)
await result
@staticmethod
async def stop(
spec: CapabilitySpec,
instance: Any,
_generation: int,
) -> None:
"""等待 service 停止后台任务并释放其资源。"""
result = _lifecycle_method(spec, instance, "close")()
if not inspect.isawaitable(result):
raise CapabilityAdapterContractError(
f"{spec.entrypoint}.close() 必须返回 awaitable"
)
converged = await result
if converged is False:
raise CapabilityAdapterContractError(
f"{spec.entrypoint}.close() 返回未收敛,保留 service owner"
)
@staticmethod
async def cleanup(
spec: CapabilitySpec,
candidate: Any,
generation: int,
_error: BaseException,
) -> None:
"""初始化失败或关闭竞态时按相同 close 合同释放部分资源。"""
await AgentServiceAdapter.stop(spec, candidate, generation)
def _validate_registry(registry: CapabilityRegistry) -> None:
"""固定 entrypoint 物化轴与 service 资源轴的声明合同。"""
for spec in registry.list_specs():
if set(spec.metadata) != {"name"}:
raise ValueError(f"{spec.source}: Agent Capability metadata 只能包含 name")
if spec.kind == AGENT_ENTRYPOINT_KIND:
if spec.activation is not ActivationPolicy.ON_FIRST_USE:
raise ValueError(
f"{spec.source}: Agent entrypoint 必须使用 on_first_use"
)
if spec.selector is not None or spec.watch:
raise ValueError(
f"{spec.source}: Agent entrypoint 不接受 selector 或 watch"
)
continue
if spec.activation is not ActivationPolicy.WHEN_CONFIGURED:
raise ValueError(f"{spec.source}: Agent Service 必须使用 when_configured")
selector = spec.selector
if selector is None or selector.kind != _SETTING_SELECTOR:
raise ValueError(f"{spec.source}: Agent Service 必须声明 setting_truthy")
selector_key = str(selector.config["key"])
if spec.watch != (selector_key,):
raise ValueError(
f"{spec.source}: Agent Service watch 必须只包含 selector key"
)
def build_agent_capability_registry(
roots: Iterable[Path | str] | None = None,
) -> CapabilityRegistry:
"""发现 data-only Agent manifests,不导入编排器、Provider 或工具实现。"""
registry = CapabilityRegistry.discover(
tuple(roots) if roots is not None else (_DEFAULT_CAPABILITY_ROOT,),
kinds={AGENT_ENTRYPOINT_KIND, AGENT_SERVICE_KIND},
selector_schemas=AGENT_SELECTOR_SCHEMAS,
)
_validate_registry(registry)
return registry
def should_run_agent_service(spec: CapabilitySpec) -> bool:
"""依据 manifest selector 判断 service 是否应拥有运行实例。"""
selector = spec.selector
if (
spec.kind != AGENT_SERVICE_KIND
or selector is None
or selector.kind != _SETTING_SELECTOR
):
raise ValueError(f"{spec.source}: 不是可协调的 Agent Service 声明")
return bool(get_runtime_setting(selector.config["key"]))
@@ -1,12 +0,0 @@
schema_version = 1
id = "agent.manager"
kind = "agent_entrypoint"
entrypoint = "app.agent.orchestrator:agent_manager"
depends_on = []
[metadata]
name = "Agent Manager"
[activation]
policy = "on_first_use"
watch = []
@@ -1,12 +0,0 @@
schema_version = 1
id = "agent.moviepilot_type"
kind = "agent_entrypoint"
entrypoint = "app.agent.orchestrator:MoviePilotAgent"
depends_on = []
[metadata]
name = "MoviePilot Agent Type"
[activation]
policy = "on_first_use"
watch = []
@@ -1,16 +0,0 @@
schema_version = 1
id = "agent.service"
kind = "agent_service"
entrypoint = "app.agent.orchestrator:agent_manager"
depends_on = []
[metadata]
name = "Agent Service"
[activation]
policy = "when_configured"
watch = ["AI_AGENT_ENABLE"]
[activation.selector]
kind = "setting_truthy"
key = "AI_AGENT_ENABLE"
@@ -1,12 +0,0 @@
schema_version = 1
id = "agent.tool_factory"
kind = "agent_entrypoint"
entrypoint = "app.agent.tools.factory:MoviePilotToolFactory"
depends_on = []
[metadata]
name = "Agent Tool Factory"
[activation]
policy = "on_first_use"
watch = []
-35
View File
@@ -1,35 +0,0 @@
"""Agent 轻量公共合同,不触发模型、工具或编排运行时加载。"""
import uuid
from datetime import datetime
from typing import Any, Optional
from app.schemas.types import ReplyMode
def build_display_message(
role: str,
content: str = "",
attachments: Optional[list[dict]] = None,
status: str = "done",
) -> dict[str, Any]:
"""构造前后端共享的 Agent 会话展示消息。"""
normalized_content = content or ""
return {
"id": f"{role}-{uuid.uuid4().hex}",
"role": role,
"content": normalized_content,
"createdAt": int(datetime.now().timestamp() * 1000),
"status": status,
"tools": [],
"segments": (
[{"type": "text", "content": normalized_content}]
if normalized_content
else []
),
"attachments": attachments or [],
"choices": [],
}
__all__ = ["ReplyMode", "build_display_message"]
+16 -35
View File
@@ -1,39 +1,20 @@
"""Agent 内部使用的 LLM 适配层,公开对象按需解析"""
"""Agent 内部使用的 LLM 适配层。"""
from importlib import import_module
from typing import Any
_EXPORT_MODULES = {
"LLMHelper": "app.agent.llm.helper",
"LLMTestError": "app.agent.llm.helper",
"LLMTestTimeout": "app.agent.llm.helper",
"AgentCapabilityManager": "app.agent.llm.capability",
"AgentCapabilityProvider": "app.agent.llm.capability",
"AudioCapabilityProvider": "app.agent.llm.capability",
"MiMoAudioProvider": "app.agent.llm.capability",
"OpenAIChatAudioProvider": "app.agent.llm.capability",
"OpenAIAudioProvider": "app.agent.llm.capability",
"LLMProviderAuthError": "app.agent.llm.provider",
"LLMProviderError": "app.agent.llm.provider",
"LLMProviderManager": "app.agent.llm.provider",
"render_auth_result_html": "app.agent.llm.provider",
}
def __getattr__(name: str) -> Any:
"""首次访问公开对象时只加载其所属适配模块。"""
module_name = _EXPORT_MODULES.get(name)
if module_name is None:
raise AttributeError(f"module 'app.agent.llm' has no attribute {name!r}")
value = getattr(import_module(module_name), name)
globals()[name] = value
return value
def __dir__() -> list[str]:
"""让延迟公开对象继续支持交互式发现。"""
return sorted(set(globals()) | set(_EXPORT_MODULES))
from app.agent.llm.helper import LLMHelper, LLMTestError, LLMTestTimeout
from app.agent.llm.capability import (
AgentCapabilityManager,
AgentCapabilityProvider,
AudioCapabilityProvider,
MiMoAudioProvider,
OpenAIChatAudioProvider,
OpenAIAudioProvider,
)
from app.agent.llm.provider import (
LLMProviderAuthError,
LLMProviderError,
LLMProviderManager,
render_auth_result_html,
)
__all__ = [
"LLMHelper",
+39 -39
View File
@@ -12,11 +12,9 @@ from pathlib import Path
from typing import Any, Dict, Optional
from uuid import uuid4
from app.application.notification import get_notification_configs
from app.runtime.settings import get_runtime_setting
from app.runtime.log import logger
from app.adapters.network.http import RequestUtils
from app.core.config import settings
from app.log import logger
from app.utils.http import RequestUtils
class AgentCapabilityProvider(ABC):
@@ -60,11 +58,11 @@ class OpenAIAudioProvider(AudioCapabilityProvider):
@staticmethod
def _input_credentials() -> tuple[Optional[str], Optional[str]]:
return get_runtime_setting('AUDIO_INPUT_API_KEY'), get_runtime_setting('AUDIO_INPUT_BASE_URL')
return settings.AUDIO_INPUT_API_KEY, settings.AUDIO_INPUT_BASE_URL
@staticmethod
def _output_credentials() -> tuple[Optional[str], Optional[str]]:
return get_runtime_setting('AUDIO_OUTPUT_API_KEY'), get_runtime_setting('AUDIO_OUTPUT_BASE_URL')
return settings.AUDIO_OUTPUT_API_KEY, settings.AUDIO_OUTPUT_BASE_URL
def is_available_for_audio_input(self) -> bool:
api_key, _ = self._input_credentials()
@@ -88,9 +86,9 @@ class OpenAIAudioProvider(AudioCapabilityProvider):
audio_file = BytesIO(content)
audio_file.name = filename
response = client.audio.transcriptions.create(
model=get_runtime_setting('AUDIO_INPUT_MODEL'),
model=settings.AUDIO_INPUT_MODEL,
file=audio_file,
language=get_runtime_setting('AUDIO_INPUT_LANGUAGE') or "zh",
language=settings.AUDIO_INPUT_LANGUAGE or "zh",
response_format="verbose_json",
)
text = getattr(response, "text", None)
@@ -108,12 +106,12 @@ class OpenAIAudioProvider(AudioCapabilityProvider):
if not api_key:
raise ValueError("音频输出 provider 未配置 API Key")
client = self._build_client(api_key=api_key, base_url=base_url)
voice_dir = get_runtime_setting('TEMP_PATH') / "voice"
voice_dir = settings.TEMP_PATH / "voice"
voice_dir.mkdir(parents=True, exist_ok=True)
output_path = voice_dir / f"{uuid4().hex}.opus"
response = client.audio.speech.create(
model=get_runtime_setting('AUDIO_OUTPUT_MODEL'),
voice=get_runtime_setting('AUDIO_OUTPUT_VOICE'),
model=settings.AUDIO_OUTPUT_MODEL,
voice=settings.AUDIO_OUTPUT_VOICE,
input=text,
response_format="opus",
)
@@ -162,22 +160,22 @@ class OpenAIChatAudioProvider(AudioCapabilityProvider):
@staticmethod
def _input_credentials() -> tuple[Optional[str], Optional[str]]:
return get_runtime_setting('AUDIO_INPUT_API_KEY'), get_runtime_setting('AUDIO_INPUT_BASE_URL')
return settings.AUDIO_INPUT_API_KEY, settings.AUDIO_INPUT_BASE_URL
@staticmethod
def _output_credentials() -> tuple[Optional[str], Optional[str]]:
return get_runtime_setting('AUDIO_OUTPUT_API_KEY'), get_runtime_setting('AUDIO_OUTPUT_BASE_URL')
return settings.AUDIO_OUTPUT_API_KEY, settings.AUDIO_OUTPUT_BASE_URL
def _normalize_stt_model(self) -> str:
return self._normalize_model(
model=get_runtime_setting('AUDIO_INPUT_MODEL'),
model=settings.AUDIO_INPUT_MODEL,
supported_models=self.SUPPORTED_STT_MODELS,
default_model=self.DEFAULT_STT_MODEL,
)
def _normalize_tts_model(self) -> str:
return self._normalize_model(
model=get_runtime_setting('AUDIO_OUTPUT_MODEL'),
model=settings.AUDIO_OUTPUT_MODEL,
supported_models=self.SUPPORTED_TTS_MODELS,
default_model=self.DEFAULT_TTS_MODEL,
)
@@ -267,7 +265,7 @@ class OpenAIChatAudioProvider(AudioCapabilityProvider):
return None
suffix = Path(filename or "").suffix.lower() or ".audio"
voice_dir = get_runtime_setting('TEMP_PATH') / "voice"
voice_dir = settings.TEMP_PATH / "voice"
voice_dir.mkdir(parents=True, exist_ok=True)
input_path = voice_dir / f"{uuid4().hex}{suffix}"
output_path = input_path.with_suffix(self.TRANSCODED_STT_SUFFIX)
@@ -390,7 +388,7 @@ class OpenAIChatAudioProvider(AudioCapabilityProvider):
if not normalized_audio:
return None
content, filename = normalized_audio
language = (get_runtime_setting('AUDIO_INPUT_LANGUAGE') or "").strip()
language = (settings.AUDIO_INPUT_LANGUAGE or "").strip()
prompt = "请将这段音频完整转写为文字,只输出转写结果,不要添加解释。"
if language:
prompt += f"音频主要语言是 {language}"
@@ -425,7 +423,7 @@ class OpenAIChatAudioProvider(AudioCapabilityProvider):
logger.error(
"%s TTS 当前不支持该模型或模型未配置: %s",
self.DISPLAY_NAME,
get_runtime_setting('AUDIO_OUTPUT_MODEL'),
settings.AUDIO_OUTPUT_MODEL,
)
return None
@@ -434,7 +432,7 @@ class OpenAIChatAudioProvider(AudioCapabilityProvider):
if not api_key:
raise ValueError("音频输出 provider 未配置 API Key")
client = self._build_client(api_key=api_key, base_url=base_url)
voice_dir = get_runtime_setting('TEMP_PATH') / "voice"
voice_dir = settings.TEMP_PATH / "voice"
voice_dir.mkdir(parents=True, exist_ok=True)
wav_path = voice_dir / f"{uuid4().hex}.wav"
request = {
@@ -447,7 +445,7 @@ class OpenAIChatAudioProvider(AudioCapabilityProvider):
],
"audio": {
"format": self.AUDIO_RESPONSE_FORMAT,
"voice": get_runtime_setting('AUDIO_OUTPUT_VOICE') or self.DEFAULT_VOICE,
"voice": settings.AUDIO_OUTPUT_VOICE or self.DEFAULT_VOICE,
},
}
if self.INCLUDE_AUDIO_MODALITIES:
@@ -486,7 +484,7 @@ class MiMoAudioProvider(OpenAIChatAudioProvider):
)
def _normalize_tts_model(self) -> str:
model = (get_runtime_setting('AUDIO_OUTPUT_MODEL') or "").strip().lower()
model = (settings.AUDIO_OUTPUT_MODEL or "").strip().lower()
if not model or not model.startswith("mimo-"):
return self.DEFAULT_TTS_MODEL
return model
@@ -545,21 +543,21 @@ class MiniMaxAudioProvider(OpenAIChatAudioProvider):
def _normalize_stt_model(self) -> str:
"""将非 MiniMax 的默认转写模型名兜底为 MiniMax 对话模型。"""
model = (get_runtime_setting('AUDIO_INPUT_MODEL') or "").strip()
model = (settings.AUDIO_INPUT_MODEL or "").strip()
if not model or model.lower().startswith(("gpt-", "mimo-")):
return self.DEFAULT_STT_MODEL
return model
def _normalize_tts_model(self) -> str:
"""将非 MiniMax 语音模型兜底为官方 T2A 模型。"""
model = (get_runtime_setting('AUDIO_OUTPUT_MODEL') or "").strip().lower()
model = (settings.AUDIO_OUTPUT_MODEL or "").strip().lower()
if model in self.SUPPORTED_TTS_MODELS:
return model
return self.DEFAULT_TTS_MODEL
def _normalize_voice_id(self) -> str:
"""将其他 provider 的默认音色兜底为 MiniMax 中文系统音色。"""
voice_id = (get_runtime_setting('AUDIO_OUTPUT_VOICE') or "").strip()
voice_id = (settings.AUDIO_OUTPUT_VOICE or "").strip()
if not voice_id or voice_id in {"alloy", "mimo_default"}:
return self.DEFAULT_VOICE
return voice_id
@@ -598,7 +596,7 @@ class MiniMaxAudioProvider(OpenAIChatAudioProvider):
"Content-Type": "application/json",
"Accept": "application/json",
},
proxies=get_runtime_setting('PROXY') or {},
proxies=settings.PROXY or {},
timeout=60,
).post_res(
url=self._build_t2a_url(base_url),
@@ -636,7 +634,7 @@ class MiniMaxAudioProvider(OpenAIChatAudioProvider):
if not audio_data:
raise ValueError("MiniMax T2A 响应中没有音频数据")
voice_dir = get_runtime_setting('TEMP_PATH') / "voice"
voice_dir = settings.TEMP_PATH / "voice"
voice_dir.mkdir(parents=True, exist_ok=True)
output_path = voice_dir / f"{uuid4().hex}.opus"
output_path.write_bytes(self._decode_audio_payload(audio_data))
@@ -680,9 +678,9 @@ class AgentCapabilityManager:
@classmethod
def get_audio_provider(cls, mode: str) -> Optional[AudioCapabilityProvider]:
provider_name = cls._normalize_provider_name(
get_runtime_setting('AUDIO_INPUT_PROVIDER')
settings.AUDIO_INPUT_PROVIDER
if (mode or "").lower() == "input"
else get_runtime_setting('AUDIO_OUTPUT_PROVIDER')
else settings.AUDIO_OUTPUT_PROVIDER
)
provider = cls._audio_providers.get(provider_name)
if provider:
@@ -700,12 +698,12 @@ class AgentCapabilityManager:
@staticmethod
def supports_audio_input() -> bool:
"""当前 Agent 是否启用音频输入能力。"""
return bool(get_runtime_setting('LLM_SUPPORT_AUDIO_INPUT'))
return bool(settings.LLM_SUPPORT_AUDIO_INPUT)
@staticmethod
def supports_audio_output() -> bool:
"""当前 Agent 是否启用音频输出能力。"""
return bool(get_runtime_setting('LLM_SUPPORT_AUDIO_OUTPUT'))
return bool(settings.LLM_SUPPORT_AUDIO_OUTPUT)
@classmethod
def is_audio_input_available(cls) -> bool:
@@ -776,20 +774,20 @@ class AgentCapabilityManager:
if not channel:
return None
from app.schemas.types import NotificationChannel
from app.schemas.types import MessageChannel
if isinstance(channel, NotificationChannel):
if isinstance(channel, MessageChannel):
return channel
channel_text = str(channel).strip()
if not channel_text:
return None
lowered_channel = channel_text.lower()
for channel_item in NotificationChannel:
for channel_item in MessageChannel:
aliases = {
channel_item.value.lower(),
channel_item.name.lower(),
f"{NotificationChannel.__name__}.{channel_item.name}".lower(),
f"{MessageChannel.__name__}.{channel_item.name}".lower(),
}
if lowered_channel in aliases:
return channel_item
@@ -801,7 +799,9 @@ class AgentCapabilityManager:
if not source:
return False
for config in get_notification_configs(include_disabled=True):
from app.helper.service import ServiceConfigHelper
for config in ServiceConfigHelper.get_notification_configs():
if config.name != source:
continue
return (config.config or {}).get("WECHAT_MODE", "app") != "bot"
@@ -812,8 +812,8 @@ class AgentCapabilityManager:
cls, channel: Optional[str], source: Optional[str]
) -> bool:
"""判断当前渠道是否支持原生语音消息发送。"""
from app.schemas.notification import ChannelCapability, ChannelCapabilityManager
from app.schemas.types import NotificationChannel
from app.schemas.message import ChannelCapability, ChannelCapabilityManager
from app.schemas.types import MessageChannel
channel_enum = cls._parse_message_channel(channel)
if not channel_enum:
@@ -824,6 +824,6 @@ class AgentCapabilityManager:
):
return False
if channel_enum == NotificationChannel.Wechat:
if channel_enum == MessageChannel.Wechat:
return cls._is_wechat_app_mode(source)
return True
-69
View File
@@ -1,69 +0,0 @@
"""LLM helper 与 provider 实现之间的运行时端口。"""
from collections.abc import Callable
from typing import Any, Protocol
class LLMProviderRuntimePort(Protocol):
"""声明 LLM helper 与管理 API 共用的 provider 运行时能力。"""
def resolve_cached_model_metadata(self, **kwargs: Any) -> dict[str, Any] | None:
"""从本地目录缓存解析模型元数据。"""
...
async def resolve_runtime(self, **kwargs: Any) -> dict[str, Any]:
"""解析创建模型客户端所需的统一运行时参数。"""
...
def create_bedrock_client(self, *args: Any, **kwargs: Any) -> Any:
"""创建带统一认证和网络配置的 Bedrock 客户端。"""
...
async def list_models(self, **kwargs: Any) -> list[dict[str, Any]]:
"""返回 provider 可用的模型目录。"""
...
def resolve_model_list_base_url(self, **kwargs: Any) -> str | None:
"""解析兼容接口用于查询模型列表的基础地址。"""
...
async def provider_manage(
self,
provider: str,
action: str,
**params: Any,
) -> dict[str, Any]:
"""执行与具体提供商无关的统一管理动作。"""
...
async def handle_chatgpt_callback(
self,
provider_id: str,
code: str | None,
state: str | None,
error: str | None,
error_description: str | None,
) -> tuple[bool, str]:
"""完成 ChatGPT OAuth 回调并返回公开结果。"""
...
LLMProviderRuntimeFactory = Callable[[], LLMProviderRuntimePort]
_provider_runtime_factory: LLMProviderRuntimeFactory | None = None
def register_llm_provider_runtime(
factory: LLMProviderRuntimeFactory | None,
) -> LLMProviderRuntimeFactory | None:
"""注册 provider 运行时工厂,并返回先前工厂供隔离测试恢复。"""
global _provider_runtime_factory
previous = _provider_runtime_factory
_provider_runtime_factory = factory
return previous
def resolve_llm_provider_runtime() -> LLMProviderRuntimePort:
"""解析已组装的 provider 运行时,未注册时给出明确边界错误。"""
if _provider_runtime_factory is None:
raise RuntimeError("LLM provider 运行时尚未由启动层完成组装")
return _provider_runtime_factory()
+64 -128
View File
@@ -10,9 +10,8 @@ from urllib.parse import urlsplit
from langchain_core.messages import AIMessage, AIMessageChunk
from app.agent.llm.gateway import resolve_llm_provider_runtime
from app.runtime.log import logger
from app.runtime.settings import get_runtime_setting
from app.core.config import settings
from app.log import logger
if TYPE_CHECKING:
from app.agent.llm.server_tools import ServerToolResolution
@@ -64,7 +63,7 @@ def _patch_gemini_thought_signature():
logger.error(
f"langchain-google-genai 版本 {_version or '未知'} 过旧,"
f"不支持 Gemini 2.5+ 模型的 thought_signature 处理,"
f"恢复 MoviePilot 锁定依赖或更新主程序"
f"升级到 4.2.3+pip install langchain-google-genai~=4.2.3"
)
return
@@ -146,8 +145,8 @@ def _resolve_llm_proxy(use_proxy: bool | None = None) -> str | None:
"""
解析本次 LLM 调用应使用的系统代理地址。
"""
should_use_proxy = get_runtime_setting('LLM_USE_PROXY') if use_proxy is None else use_proxy
return get_runtime_setting('PROXY_HOST') if should_use_proxy and get_runtime_setting('PROXY_HOST') else None
should_use_proxy = settings.LLM_USE_PROXY if use_proxy is None else use_proxy
return settings.PROXY_HOST if should_use_proxy and settings.PROXY_HOST else None
def _build_httpx_proxy_kwargs(proxy_url: str | None) -> dict[str, str]:
@@ -501,94 +500,6 @@ def _patch_openai_responses_empty_output_support():
class LLMHelper:
"""LLM模型相关辅助功能"""
_DEFAULT_MAX_INPUT_TOKENS = 256_000
@staticmethod
def _positive_token_limit(value: Any) -> int | None:
"""只接受可直接作为模型窗口上限的正整数。"""
return value if type(value) is int and value > 0 else None
@classmethod
def _source_input_limit(cls, source: dict[str, Any]) -> int | None:
"""合并同一事实源的 input/context 上限,采用更严格的约束。"""
candidates = [
cls._positive_token_limit(source.get("input_tokens")),
cls._positive_token_limit(source.get("context_tokens")),
]
valid = [candidate for candidate in candidates if candidate is not None]
return min(valid) if valid else None
@classmethod
def _normalize_model_profile(
cls,
model_profile: Any,
runtime: dict[str, Any],
) -> dict[str, Any]:
"""把当前端点的窗口事实合并到 LangChain model profile。"""
profile = dict(model_profile) if isinstance(model_profile, dict) else {}
model_record = runtime.get("model_record") or {}
model_metadata = runtime.get("model_metadata") or {}
metadata_limit = model_metadata.get("limit") or {}
metadata_source = {
"input_tokens": metadata_limit.get("input"),
"context_tokens": metadata_limit.get("context"),
}
record_input = cls._source_input_limit(model_record)
metadata_input = cls._source_input_limit(metadata_source)
profile_input = cls._positive_token_limit(profile.get("max_input_tokens"))
configured_k = cls._positive_token_limit(get_runtime_setting('LLM_MAX_CONTEXT_TOKENS'))
configured_input = configured_k * 1000 if configured_k else None
endpoint_matched = runtime.get("model_profile_endpoint_matched") is True
if endpoint_matched:
max_input_tokens = next(
(
candidate
for candidate in (
record_input,
metadata_input,
profile_input,
configured_input,
)
if candidate is not None
),
cls._DEFAULT_MAX_INPUT_TOKENS,
)
else:
constraints = [
candidate
for candidate in (
record_input,
metadata_input,
profile_input,
configured_input,
cls._DEFAULT_MAX_INPUT_TOKENS,
)
if candidate is not None
]
max_input_tokens = min(constraints)
profile["max_input_tokens"] = max_input_tokens
record_output = (
cls._positive_token_limit(model_record.get("output_tokens"))
if endpoint_matched
else None
)
metadata_output = (
cls._positive_token_limit(metadata_limit.get("output"))
if endpoint_matched
else None
)
profile_output = cls._positive_token_limit(profile.get("max_output_tokens"))
max_output_tokens = record_output or metadata_output or profile_output
if max_output_tokens is not None:
profile["max_output_tokens"] = max_output_tokens
else:
profile.pop("max_output_tokens", None)
return profile
_SUPPORTED_THINKING_LEVELS = frozenset(
{"off", "auto", "minimal", "low", "medium", "high", "max", "xhigh"}
)
@@ -788,20 +699,22 @@ class LLMHelper:
base_url_preset: Optional[str] = None,
) -> Optional[bool]:
"""复用 provider 目录缓存解析当前模型是否支持图片输入。"""
provider_name = str(provider if provider is not None else get_runtime_setting('LLM_PROVIDER')).strip()
model_name = str(model if model is not None else get_runtime_setting('LLM_MODEL')).strip()
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:
metadata = resolve_llm_provider_runtime().resolve_cached_model_metadata(
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 get_runtime_setting('LLM_BASE_URL'),
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 get_runtime_setting('LLM_BASE_URL_PRESET')
else settings.LLM_BASE_URL_PRESET
),
)
except Exception as err:
@@ -826,7 +739,7 @@ class LLMHelper:
被兼容端点以 400 拒绝。无参调用保持旧版“只读总开关”语义,
未知自定义模型也保持原有开关语义。
"""
if not get_runtime_setting('LLM_SUPPORT_IMAGE_INPUT'):
if not settings.LLM_SUPPORT_IMAGE_INPUT:
return False
if provider is None and model is None:
return True
@@ -855,8 +768,8 @@ class LLMHelper:
这主要用于单测 stub 环境以及极端的最小运行环境,正常生产路径仍优先
走 `LLMProviderManager.resolve_runtime()`。
"""
api_key_value = api_key if api_key is not None else get_runtime_setting('LLM_API_KEY')
base_url_value = base_url if base_url is not None else get_runtime_setting('LLM_BASE_URL')
api_key_value = api_key if api_key is not None else settings.LLM_API_KEY
base_url_value = base_url if base_url is not None else settings.LLM_BASE_URL
if not api_key_value:
raise ValueError("未配置LLM API Key")
@@ -1035,7 +948,7 @@ class LLMHelper:
"""
规范化 API 协议配置,未知值统一回退为 ``auto`` 以保持兼容。
"""
normalized = str(api_protocol or get_runtime_setting('LLM_API_PROTOCOL') or "").strip().lower()
normalized = str(api_protocol or settings.LLM_API_PROTOCOL or "").strip().lower()
if normalized in {"auto", "chat_completions", "responses"}:
return normalized
if normalized:
@@ -1183,20 +1096,24 @@ class LLMHelper:
:param prompt_cache_key: 同一 Agent 会话内稳定且脱敏的提示词缓存路由键。
:return: LLM实例
"""
provider_name = str(provider if provider is not None else get_runtime_setting('LLM_PROVIDER')).lower()
model_name = model if model is not None else get_runtime_setting('LLM_MODEL')
api_key_value = api_key if api_key is not None else get_runtime_setting('LLM_API_KEY')
base_url_value = base_url if base_url is not None else get_runtime_setting('LLM_BASE_URL')
provider_name = str(provider if provider is not None else settings.LLM_PROVIDER).lower()
model_name = model if model is not None else settings.LLM_MODEL
api_key_value = api_key if api_key is not None else settings.LLM_API_KEY
base_url_value = base_url if base_url is not None else settings.LLM_BASE_URL
base_url_preset_value = (
base_url_preset if base_url_preset is not None else get_runtime_setting('LLM_BASE_URL_PRESET')
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 get_runtime_setting('LLM_USER_AGENT')
temperature_value = temperature if temperature is not None else get_runtime_setting('LLM_TEMPERATURE')
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,
)
try:
runtime = await resolve_llm_provider_runtime().resolve_runtime(
# 延迟导入,避免单测在最小 stub 环境下 import `llm.py` 时被 provider
# 目录依赖链拖住。
from app.agent.llm.provider import LLMProviderManager
runtime = await LLMProviderManager().resolve_runtime(
provider_id=provider_name,
model=model_name,
api_key=api_key_value,
@@ -1226,12 +1143,12 @@ class LLMHelper:
mode=(
web_search_mode
if web_search_mode is not None
else get_runtime_setting("LLM_WEB_SEARCH_MODE", "local")
else getattr(settings, "LLM_WEB_SEARCH_MODE", "local")
),
api_protocol=(
api_protocol
if api_protocol is not None
else get_runtime_setting('LLM_API_PROTOCOL')
else settings.LLM_API_PROTOCOL
),
base_url=runtime.get("base_url"),
)
@@ -1323,6 +1240,8 @@ class LLMHelper:
elif runtime["runtime"] == "bedrock":
from langchain_aws import ChatBedrockConverse
from app.agent.llm.provider import LLMProviderManager
bedrock_model_cls = ChatBedrockConverse
if (
str(prompt_cache_key or "").strip()
@@ -1337,13 +1256,13 @@ class LLMHelper:
aws_auth = runtime.get("aws_auth") or {}
# Bearer 认证需要跳过 SigV4 签名并注入 Authorization 头,SigV4 认证
# 直接以 AK/SK 签名;两种方式统一由 provider 管理器构造 boto3 客户端。
bedrock_client = resolve_llm_provider_runtime().create_bedrock_client(
bedrock_client = LLMProviderManager().create_bedrock_client(
"bedrock-runtime",
region=aws_region,
credentials=aws_auth,
base_url=runtime.get("base_url"),
use_proxy=use_proxy,
read_timeout=get_runtime_setting('LLM_TOOL_TIMEOUT'),
read_timeout=settings.LLM_TOOL_TIMEOUT,
)
model = bedrock_model_cls(
model_id=model_name,
@@ -1409,15 +1328,28 @@ class LLMHelper:
**openai_model_kwargs,
)
model.profile = cls._normalize_model_profile(
model_profile=getattr(model, "profile", None),
runtime=runtime,
)
# ChatBedrockConverse 等模型类没有 model 属性,模型名存放在 model_id。
logged_model_name = getattr(model, "model", None) or getattr(
model, "model_id", model_name
)
logger.debug(f"使用LLM模型: {logged_model_name}Profile: {model.profile}")
# 优先使用 provider / models.dev 目录中的上下文上限,减少用户手填成本。
model_profile = getattr(model, "profile", None)
if model_profile:
# ChatBedrockConverse 等模型类没有 model 属性,模型名存放在 model_id。
logged_model_name = getattr(model, "model", None) or getattr(
model, "model_id", model_name
)
logger.debug(f"使用LLM模型: {logged_model_name}Profile: {model_profile}")
else:
model_record = runtime.get("model_record") or {}
model_metadata = runtime.get("model_metadata") or {}
metadata_limit = model_metadata.get("limit") or {}
max_input_tokens = (
model_record.get("input_tokens")
or model_record.get("context_tokens")
or metadata_limit.get("input")
or metadata_limit.get("context")
or settings.LLM_MAX_CONTEXT_TOKENS * 1000
)
model.profile = {
"max_input_tokens": int(max_input_tokens),
}
cls._attach_runtime_metadata(model, runtime)
cls._attach_server_tool_metadata(model, server_tool_resolution)
@@ -1490,8 +1422,8 @@ class LLMHelper:
:param api_protocol: OpenAI 兼容接口 API 协议,未显式传入时沿用已保存配置。
:param web_search_mode: 联网搜索模式,未显式传入时沿用已保存配置。
"""
provider_name = provider if provider is not None else get_runtime_setting('LLM_PROVIDER')
model_name = model if model is not None else get_runtime_setting('LLM_MODEL')
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_kwargs = {
"streaming": False,
@@ -1551,7 +1483,9 @@ class LLMHelper:
"""
logger.info(f"获取 {provider} 模型列表...")
try:
models = await resolve_llm_provider_runtime().list_models(
from app.agent.llm.provider import LLMProviderManager
models = await LLMProviderManager().list_models(
provider_id=provider,
api_key=api_key,
base_url=base_url,
@@ -1580,8 +1514,10 @@ class LLMHelper:
base_url=base_url,
)
try:
from app.agent.llm.provider import LLMProviderManager
model_list_base_url = (
resolve_llm_provider_runtime().resolve_model_list_base_url(
LLMProviderManager().resolve_model_list_base_url(
provider_id=provider,
base_url=base_url,
base_url_preset_id=base_url_preset,
File diff suppressed because one or more lines are too long
+25 -209
View File
@@ -20,12 +20,11 @@ import aiofiles
import httpx
import jwt
from app.runtime.settings import get_runtime_setting
from app.application.configuration import get_configured_system_config
from app.runtime.log import logger
from app.schemas.types import LlmProviderAction, SystemConfigKey
from app.foundation.singleton import Singleton
from app.core.config import settings
from app.db.systemconfig_oper import SystemConfigOper
from app.log import logger
from app.schemas.types import SystemConfigKey
from app.utils.singleton import Singleton
class LLMProviderError(RuntimeError):
@@ -267,7 +266,7 @@ class LLMProviderManager(metaclass=Singleton):
self._models_dev_data: dict[str, Any] | None = None
self._models_dev_loaded_at: float = 0
self._models_dev_cache_path = (
Path(get_runtime_setting('TEMP_PATH')) / "llm_provider_models_dev_cache.json"
Path(settings.TEMP_PATH) / "llm_provider_models_dev_cache.json"
)
def _cleanup_auth_sessions_locked(self, now: Optional[float] = None) -> None:
@@ -418,7 +417,7 @@ class LLMProviderManager(metaclass=Singleton):
base_url_editable=True,
requires_base_url=True,
supports_api_key=True,
api_key_hint="填写 OpenAI-compatible 服务的 API Key;如服务未启用鉴权,可填写任意占位值",
api_key_hint="通用 OpenAI-compatible 兜底入口,需要手动填写 Base URL",
description="通用 OpenAI-compatible 模型服务。",
sort_order=1,
),
@@ -1448,33 +1447,6 @@ class LLMProviderManager(metaclass=Singleton):
return spec.models_dev_provider_id
@classmethod
def _is_model_profile_endpoint_matched(
cls,
spec: ProviderSpec,
base_url: Optional[str],
base_url_preset_id: Optional[str] = None,
) -> bool:
"""判断模型目录上限是否与当前 provider 端点具有明确对应关系。"""
if spec.id == "openai":
return False
preset = cls._resolve_provider_preset(spec, base_url, base_url_preset_id)
if preset:
effective_base_url = (
cls._sanitize_base_url(base_url)
or cls._default_base_url_for_provider(spec)
)
preset_base_url = cls._sanitize_base_url(preset.value)
return effective_base_url == preset_base_url
default_base_url = cls._default_base_url_for_provider(spec)
effective_base_url = cls._sanitize_base_url(base_url)
if not effective_base_url and not default_base_url:
return bool(spec.models_dev_provider_id)
effective_base_url = effective_base_url or default_base_url
return bool(default_base_url and effective_base_url == default_base_url)
def resolve_model_list_base_url(
self,
provider_id: str,
@@ -1497,19 +1469,19 @@ class LLMProviderManager(metaclass=Singleton):
def _build_httpx_kwargs(self, use_proxy: Optional[bool] = None) -> dict[str, Any]:
"""构造用于 httpx 客户端的参数,如代理等。"""
should_use_proxy = get_runtime_setting('LLM_USE_PROXY') if use_proxy is None else use_proxy
should_use_proxy = settings.LLM_USE_PROXY if use_proxy is None else use_proxy
kwargs: dict[str, Any] = {
"timeout": self._DEFAULT_TIMEOUT,
"trust_env": False,
}
if should_use_proxy and get_runtime_setting('PROXY_HOST'):
kwargs[self._httpx_proxy_key()] = get_runtime_setting('PROXY_HOST')
if should_use_proxy and settings.PROXY_HOST:
kwargs[self._httpx_proxy_key()] = settings.PROXY_HOST
return kwargs
@staticmethod
def _read_agent_config() -> dict[str, Any]:
"""读取 AI Agent 配置信息。"""
config = get_configured_system_config().get(SystemConfigKey.AIAgentConfig)
config = SystemConfigOper().get(SystemConfigKey.AIAgentConfig)
if isinstance(config, dict):
return config
return {}
@@ -1519,10 +1491,10 @@ class LLMProviderManager(metaclass=Singleton):
"""
使用异步持久化写回 provider 鉴权配置。
`get_configured_system_config().get()` 读取的是内存缓存,这里保留同步调用;
`SystemConfigOper().get()` 读取的是内存缓存,这里保留同步调用;
但写入需要落库,因此统一走 `async_set()`。
"""
await get_configured_system_config().async_set(
await SystemConfigOper().async_set(
SystemConfigKey.AIAgentConfig,
copy.deepcopy(value) or None,
)
@@ -1615,7 +1587,7 @@ class LLMProviderManager(metaclass=Singleton):
async def _fetch_models_dev(self, use_proxy: Optional[bool] = None) -> dict[str, Any]:
"""通过网络请求获取最新 models.dev 数据。"""
headers = {"User-Agent": get_runtime_setting('USER_AGENT')}
headers = {"User-Agent": settings.USER_AGENT}
async with httpx.AsyncClient(**self._build_httpx_kwargs(use_proxy)) as client:
response = await client.get(self._MODELS_DEV_URL, headers=headers)
response.raise_for_status()
@@ -2042,10 +2014,10 @@ class LLMProviderManager(metaclass=Singleton):
from google import genai
from google.genai.types import HttpOptions
should_use_proxy = get_runtime_setting('LLM_USE_PROXY') if use_proxy is None else use_proxy
should_use_proxy = settings.LLM_USE_PROXY if use_proxy is None else use_proxy
client_args: dict[str, Any] = {"trust_env": False}
if should_use_proxy and get_runtime_setting('PROXY_HOST'):
client_args[self._httpx_proxy_key()] = get_runtime_setting('PROXY_HOST')
if should_use_proxy and settings.PROXY_HOST:
client_args[self._httpx_proxy_key()] = settings.PROXY_HOST
http_options = HttpOptions(
client_args=client_args,
async_client_args=client_args,
@@ -2159,10 +2131,10 @@ class LLMProviderManager(metaclass=Singleton):
"""
from botocore.config import Config
should_use_proxy = get_runtime_setting('LLM_USE_PROXY') if use_proxy is None else use_proxy
should_use_proxy = settings.LLM_USE_PROXY if use_proxy is None else use_proxy
proxies = None
if should_use_proxy and get_runtime_setting('PROXY_HOST'):
proxies = {"http": get_runtime_setting('PROXY_HOST'), "https": get_runtime_setting('PROXY_HOST')}
if should_use_proxy and settings.PROXY_HOST:
proxies = {"http": settings.PROXY_HOST, "https": settings.PROXY_HOST}
return Config(
connect_timeout=10,
read_timeout=60,
@@ -2387,7 +2359,7 @@ class LLMProviderManager(metaclass=Singleton):
仅补充 Copilot 必需的意图头,避免重复覆盖。
"""
headers = {
"User-Agent": get_runtime_setting('USER_AGENT'),
"User-Agent": settings.USER_AGENT,
"Openai-Intent": "conversation-edits",
"x-initiator": "user",
}
@@ -2768,7 +2740,7 @@ class LLMProviderManager(metaclass=Singleton):
f"{self._CHATGPT_ISSUER}/api/accounts/deviceauth/usercode",
headers={
"Content-Type": "application/json",
"User-Agent": get_runtime_setting('USER_AGENT'),
"User-Agent": settings.USER_AGENT,
},
json={"client_id": self._CHATGPT_CLIENT_ID},
)
@@ -2805,7 +2777,7 @@ class LLMProviderManager(metaclass=Singleton):
headers={
"Accept": "application/json",
"Content-Type": "application/json",
"User-Agent": get_runtime_setting('USER_AGENT'),
"User-Agent": settings.USER_AGENT,
},
json={
"client_id": self._COPILOT_CLIENT_ID,
@@ -2960,155 +2932,6 @@ class LLMProviderManager(metaclass=Singleton):
self._mark_session_error(session, str(err))
return self.get_session_status(session_id)
async def provider_manage(self, provider: str, action: str, **params: Any) -> Dict[str, Any]:
"""
LLM 提供商统一管理入口。
按公共动作词汇表分发,统一返回 {"success", "message", "data"}
临时配置默认值填充、密钥脱敏与错误归因改写均封闭在此,
上层透传时无需感知任何提供商特色。
"""
normalized = action.value if isinstance(action, LlmProviderAction) else str(action)
try:
if normalized == LlmProviderAction.LIST_PROVIDERS.value:
return {"success": True, "message": "", "data": await self.list_providers_async()}
if normalized == LlmProviderAction.LIST_MODELS.value:
return await self._manage_list_models(provider, **params)
if normalized == LlmProviderAction.START_AUTH.value:
data = await self.start_auth(
provider, str(params.get("method") or ""), params.get("callback_url")
)
return {"success": True, "message": "", "data": data}
if normalized == LlmProviderAction.AUTH_STATUS.value:
data = self.get_session_status(str(params.get("session_id") or ""))
return {"success": True, "message": "", "data": data}
if normalized == LlmProviderAction.POLL_AUTH.value:
data = await self.poll_auth_session(str(params.get("session_id") or ""))
return {"success": True, "message": "", "data": data}
if normalized == LlmProviderAction.DISCONNECT.value:
await self.clear_auth(provider)
return {"success": True, "message": "", "data": None}
if normalized == LlmProviderAction.TEST.value:
return await self._manage_test(provider, **params)
return {"success": False, "message": f"不支持的管理动作:{normalized}", "data": None}
except Exception as err:
return {"success": False, "message": self._sanitize_error(str(err)), "data": None}
async def _manage_list_models(self, provider: str, **params: Any) -> Dict[str, Any]:
"""管理动作:查询模型目录,附带授权状态摘要。"""
from app.agent.llm.helper import LLMHelper
api_key = params.get("api_key")
try:
models = await LLMHelper().get_models(
provider=provider,
api_key=api_key,
base_url=params.get("base_url"),
base_url_preset=params.get("base_url_preset"),
user_agent=params.get("user_agent"),
use_proxy=params.get("use_proxy"),
force_refresh=bool(params.get("force_refresh", False)),
)
except Exception as err:
return {"success": False, "message": self._sanitize_error(str(err), api_key), "data": None}
return {
"success": True,
"message": "",
"data": {
"provider": provider,
"models": models,
"auth_status": self.get_auth_status(provider),
},
}
def _requires_api_key(self, provider_id: str) -> bool:
"""判断测试调用是否必须 API Key:支持 OAuth 授权或已有保存凭据的提供商可豁免。"""
try:
spec = self.get_provider(provider_id)
except Exception:
return True
if self.get_saved_auth(provider_id):
return False
return not spec.oauth_methods
async def _manage_test(self, provider: str, **params: Any) -> Dict[str, Any]:
"""管理动作:使用传入配置或当前已保存配置执行一次最小 LLM 调用。"""
from app.agent.llm.helper import LLMHelper, LLMTestTimeout
provider_name = provider or get_runtime_setting('LLM_PROVIDER')
model = params.get("model") if params.get("model") is not None else get_runtime_setting('LLM_MODEL')
enabled = params.get("enabled")
enabled = bool(enabled) if enabled is not None else bool(get_runtime_setting('AI_AGENT_ENABLE'))
api_key = params.get("api_key") if params.get("api_key") is not None else get_runtime_setting('LLM_API_KEY')
data = {"provider": provider_name, "model": model}
if not provider_name:
return {"success": False, "message": "请配置LLM提供商和模型", "data": None}
if not model or not model.strip():
return {"success": False, "message": "请先配置 LLM 模型", "data": None}
if not enabled:
return {"success": False, "message": "请先启用智能助手", "data": data}
if self._requires_api_key(provider_name) and (not api_key or not api_key.strip()):
return {"success": False, "message": "请先配置 LLM API Key", "data": data}
test_kwargs: Dict[str, Any] = {
"provider": provider_name,
"model": model,
"thinking_level": params.get("thinking_level"),
"api_key": api_key,
"base_url": params.get("base_url"),
"base_url_preset": params.get("base_url_preset"),
"user_agent": params.get("user_agent"),
"use_proxy": params.get("use_proxy"),
"api_protocol": params.get("api_protocol"),
"web_search_mode": params.get("web_search_mode"),
}
if params.get("temperature") is not None:
test_kwargs["temperature"] = params.get("temperature")
try:
result = await LLMHelper.test_current_settings(**test_kwargs)
except (LLMTestTimeout, TimeoutError) as err:
logger.warning(err)
return {"success": False, "message": "LLM 调用超时", "data": None}
except Exception as err:
return {"success": False, "message": self._sanitize_error(str(err), api_key), "data": None}
if not result.get("reply_preview"):
return {"success": False, "message": "模型响应为空", "data": result}
return {"success": True, "message": "", "data": result}
@staticmethod
def _sanitize_error(message: str, api_key: Optional[str] = None) -> str:
"""清理错误信息中的敏感字段,并把 SDK 内部解析错误改写为可定位的基础地址提示。"""
if not message:
return "LLM 没有返回任何内容"
sanitized = message
if api_key:
sanitized = sanitized.replace(api_key, "***")
sanitized = re.sub(
r"(?i)(api[_-]?key\s*[:=]\s*)([^\s,;]+)",
r"\1***",
sanitized,
)
sanitized = re.sub(
r"(?i)authorization\s*:\s*bearer\s+[^\s,;]+",
"Authorization: ***",
sanitized,
)
normalized_message = sanitized.lower().replace("_", "").replace(" ", "")
if "str" in normalized_message and (
"modeldump" in normalized_message
or "setprivateattributes" in normalized_message
):
return (
"服务返回内容不是兼容的模型响应,请检查基础地址是否填写为 "
"API Base URL,如果服务要求 /v1 等版本路径,请包含在基础地址中,"
"不要填写网页地址或完整的 chat/completions 路径"
)
return sanitized
async def _exchange_chatgpt_code_for_tokens(
self, code: str, redirect_uri: str, code_verifier: str
) -> dict[str, Any]:
@@ -3150,7 +2973,7 @@ class LLMProviderManager(metaclass=Singleton):
f"{self._CHATGPT_ISSUER}/api/accounts/deviceauth/token",
headers={
"Content-Type": "application/json",
"User-Agent": get_runtime_setting('USER_AGENT'),
"User-Agent": settings.USER_AGENT,
},
json={
"device_auth_id": session.context["device_auth_id"],
@@ -3195,7 +3018,7 @@ class LLMProviderManager(metaclass=Singleton):
headers={
"Accept": "application/json",
"Content-Type": "application/json",
"User-Agent": get_runtime_setting('USER_AGENT'),
"User-Agent": settings.USER_AGENT,
},
json={
"client_id": self._COPILOT_CLIENT_ID,
@@ -3316,13 +3139,6 @@ class LLMProviderManager(metaclass=Singleton):
"model_id": model,
"model_record": model_record,
"model_metadata": model_metadata,
"model_profile_endpoint_matched": (
self._is_model_profile_endpoint_matched(
spec,
base_url,
base_url_preset_id=normalized_base_url_preset_id,
)
),
"supports_prompt_cache": self._metadata_supports_prompt_cache(
model_metadata
),
+13 -11
View File
@@ -12,15 +12,15 @@ from dataclasses import dataclass
from typing import Any, Optional
from urllib.parse import urljoin
from app.application.configuration import get_configured_system_config
from app.runtime.log import logger
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.adapters.network.http import AsyncRequestUtils
from app.utils.http import AsyncRequestUtils
MCP_PROTOCOL_VERSION = "2025-11-25"
MCP_CLIENT_NAME = "MoviePilot Agent"
@@ -217,11 +217,8 @@ class _StdioMcpSession:
async def __aexit__(self, exc_type, exc, tb) -> None:
"""结束 stdio MCP 子进程。"""
stderr_task = self.stderr_task
self.stderr_task = None
if stderr_task:
stderr_task.cancel()
await asyncio.gather(stderr_task, return_exceptions=True)
if self.stderr_task:
self.stderr_task.cancel()
if not self.process:
return
if self.process.returncode is None:
@@ -456,7 +453,7 @@ class AgentMcpManager:
def get_servers(self) -> list[AgentMcpServerConfig]:
"""读取已保存的外部 MCP 服务器配置。"""
raw_servers = get_configured_system_config().get(SystemConfigKey.AIAgentMcpServers) or []
raw_servers = SystemConfigOper().get(SystemConfigKey.AIAgentMcpServers) or []
if not isinstance(raw_servers, list):
return []
servers: list[AgentMcpServerConfig] = []
@@ -470,7 +467,7 @@ class AgentMcpManager:
async def save_servers(self, servers: list[AgentMcpServerConfig]) -> bool:
"""保存外部 MCP 服务器配置。"""
normalized_servers = [self.normalize_server(server).model_dump() for server in servers]
return await get_configured_system_config().async_set(
return await SystemConfigOper().async_set(
SystemConfigKey.AIAgentMcpServers,
normalized_servers or None,
)
@@ -544,14 +541,19 @@ class AgentMcpManager:
return tool_specs
async def list_enabled_tool_specs(self) -> list[AgentMcpToolSpec]:
"""读取所有启用 MCP 服务器暴露的工具定义并保留同名冲突"""
"""读取所有启用 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
+7 -68
View File
@@ -6,13 +6,9 @@ from typing import Dict, List, Optional
from langchain_core.messages import BaseMessage, messages_from_dict, messages_to_dict
from app.application.agentdata import get_agent_chat_port
from app.application.messaging.chat import (
get_configured_agent_chat_persistence,
get_configured_agent_chat_service,
)
from app.runtime.log import logger
from app.runtime.settings import get_runtime_setting
from app.core.config import settings
from app.db.agentchat_oper import AgentChatOper
from app.log import logger
from app.schemas.agent import ConversationMemory
@@ -84,45 +80,9 @@ class MemoryManager:
return memory.messages
try:
chat = get_agent_chat_port().get(session_id=session_id, user_id=user_id)
chat = AgentChatOper().get(session_id=session_id, user_id=user_id)
if not chat:
chat = get_agent_chat_port().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
async def async_get_agent_messages(
self, session_id: str, user_id: str
) -> List[BaseMessage]:
"""异步恢复 Agent 消息,查询与会话应用服务保持同一异步端口。"""
memory = self.get_memory(session_id, user_id)
if memory:
return memory.messages
try:
service = get_configured_agent_chat_service()
chat = await service.get(
session_id=session_id,
user_id=user_id,
)
if not chat:
chat = await service.get(session_id=session_id)
chat = AgentChatOper().get(session_id=session_id)
except Exception as e:
logger.debug(f"读取持久化Agent会话失败: {e}")
return []
@@ -159,28 +119,7 @@ class MemoryManager:
# 更新内存缓存
self.save_memory(memory)
try:
get_agent_chat_port().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}")
async def async_save_agent_messages(
self, session_id: str, user_id: str, messages: List[BaseMessage]
) -> None:
"""异步保存 Agent 消息,持久化写入经有界数据库 worker 承接。"""
memory = self.get_memory(session_id, user_id)
if not memory:
memory = ConversationMemory(session_id=session_id, user_id=user_id)
memory.messages = messages
memory.updated_at = datetime.now()
self.save_memory(memory)
try:
persistence = get_configured_agent_chat_persistence()
await persistence.async_save_agent_messages(
AgentChatOper().save_agent_messages(
session_id=session_id,
user_id=user_id,
messages=messages_to_dict(messages),
@@ -226,7 +165,7 @@ class MemoryManager:
for cache_key, memory in self.memory_cache.items():
if (
current_time - memory.updated_at
).days > get_runtime_setting('LLM_MEMORY_RETENTION_DAYS'):
).days > settings.LLM_MEMORY_RETENTION_DAYS:
expired_sessions.append(cache_key)
# 只清理内存缓存,不删除Redis中的键(Redis会自动过期)
+31 -65
View File
@@ -33,14 +33,8 @@ from langgraph.runtime import Runtime
from pydantic import BaseModel, Field
from app.agent.middleware.utils import append_to_system_message
from app.agent.policy.sanitizer import (
sanitize_for_host,
summarize_error,
summarize_result,
)
from app.agent.tools.tags import ToolTag
from app.runtime.log import logger
from app.runtime.tasks import TaskRegistry, get_task_registry
from app.log import logger
# 活动日志保留天数
DEFAULT_RETENTION_DAYS = 7
@@ -92,17 +86,6 @@ SUMMARY_PROMPT = """请判断以下 AI 助手与用户的对话是否值得写
ACTIVITY_ENTRY_PATTERN = re.compile(r"^-\s+\*\*(?P<time>\d{2}:\d{2})\*\*\s+(?P<summary>.+)$")
def _write_activity_log_exclusive(path: Path, content: str) -> bool:
"""同步独占创建日志文件;调用方必须在线程池中执行本函数。"""
try:
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
except FileExistsError:
return False
with os.fdopen(fd, "w", encoding="utf-8") as stream:
stream.write(content)
return True
class QueryActivityLogInput(BaseModel):
"""查询活动日志工具的输入参数模型。"""
@@ -198,9 +181,7 @@ def load_activity_log_index(activity_dir: str, days: int = PROMPT_LOAD_DAYS) ->
try:
content = log_path.read_text(encoding="utf-8", errors="replace")
except Exception as e:
logger.warning(
f"读取活动日志索引失败 {log_path}: {summarize_error(e)}"
)
logger.warning(f"读取活动日志索引失败 {log_path}: {e}")
continue
entry_count = len(_parse_activity_entries(date_str, content))
if entry_count:
@@ -264,7 +245,7 @@ def query_activity_logs(
try:
content = log_path.read_text(encoding="utf-8", errors="replace")
except Exception as e:
logger.warning(f"读取活动日志失败 {log_path}: {summarize_error(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(
@@ -306,16 +287,14 @@ class _ActivityLogToolProvider:
limit: Optional[int] = DEFAULT_QUERY_LIMIT,
) -> str:
"""查询活动日志并返回 JSON 字符串。"""
logged_args = sanitize_for_host(
{
"keyword": keyword,
"use_regex": use_regex,
"date": date,
"days": days,
"limit": limit,
}
logger.info(
"查询活动日志: keyword=%s, use_regex=%s, date=%s, days=%s, limit=%s",
keyword,
use_regex,
date,
days,
limit,
)
logger.info(f"查询活动日志: args={logged_args}")
try:
payload = query_activity_logs(
self._activity_dir,
@@ -327,12 +306,11 @@ class _ActivityLogToolProvider:
)
return json.dumps(payload, ensure_ascii=False, indent=2)
except Exception as err:
error_summary = summarize_error(err)
logger.error(f"查询活动日志失败: {error_summary}")
logger.error(f"查询活动日志失败: {err}", exc_info=True)
return json.dumps(
{
"success": False,
"message": f"查询活动日志时发生错误: {error_summary}",
"message": f"查询活动日志时发生错误: {str(err)}",
},
ensure_ascii=False,
)
@@ -464,7 +442,7 @@ async def _summarize_with_llm(conversation_text: str) -> Optional[str]:
LLM 生成的摘要字符串失败时返回 None
"""
try:
from app.agent.llm.helper import LLMHelper
from app.agent.llm import LLMHelper
llm = await LLMHelper.get_llm(streaming=False)
prompt = SUMMARY_PROMPT.format(conversation=conversation_text)
@@ -476,7 +454,7 @@ async def _summarize_with_llm(conversation_text: str) -> Optional[str]:
return None
return summary if summary else None
except Exception as e:
logger.debug(f"LLM 活动摘要生成失败: {summarize_error(e)}")
logger.debug(f"LLM 活动摘要生成失败: {e}")
return None
@@ -512,14 +490,12 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
retention_days: int = DEFAULT_RETENTION_DAYS,
prompt_load_days: int = PROMPT_LOAD_DAYS,
stream_handler: Optional[Any] = None,
task_registry: Optional[TaskRegistry] = None,
) -> None:
"""初始化活动日志中间件,并绑定宿主后台任务 owner"""
"""初始化活动日志中间件。"""
self.activity_dir = activity_dir
self.retention_days = retention_days
self.prompt_load_days = prompt_load_days
self.stream_handler = stream_handler
self._task_registry = task_registry or get_task_registry()
self._background_tasks: set[asyncio.Task[None]] = set()
self._tool_provider = _ActivityLogToolProvider(activity_dir=activity_dir)
self.tools = [
@@ -583,21 +559,21 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
await stream.write(entry)
else:
header = f"# {today_str} 活动日志\n\n"
created = await anyio.to_thread.run_sync(
_write_activity_log_exclusive,
Path(log_path),
header + entry,
)
if not created:
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)
logger.debug(f"Activity logged: {summarize_result(summary, max_chars=80)}")
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(f"Failed to append activity log: {summarize_error(e)}")
logger.warning(f"Failed to append activity log: {e}")
async def _cleanup_old_logs(self) -> None:
"""清理超过保留天数的旧日志文件。"""
@@ -623,16 +599,11 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
except ValueError:
continue
except Exception as e:
logger.warning(
f"Failed to cleanup old activity logs: {summarize_error(e)}"
)
logger.warning(f"Failed to cleanup old activity logs: {e}")
def _schedule_activity_recording(self, messages: list) -> None:
"""提交后台活动记录任务,不阻塞当前 Agent 会话结束。"""
task = self._task_registry.create(
self._record_activity(messages),
owner="agent.activity_log.record",
)
task = asyncio.create_task(self._record_activity(messages))
self._background_tasks.add(task)
task.add_done_callback(self._on_activity_recording_done)
@@ -644,7 +615,7 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
except asyncio.CancelledError:
logger.debug("活动日志后台记录任务已取消")
except Exception as err:
logger.warning(f"活动日志后台记录任务失败: {summarize_error(err)}")
logger.warning(f"活动日志后台记录任务失败: {err}")
async def _record_activity(self, messages: list) -> None:
"""在后台生成本轮活动摘要并写入活动日志。"""
@@ -666,7 +637,7 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
if summary:
await self._append_activity(summary)
except Exception as e:
logger.warning(f"Failed to record activity: {summarize_error(e)}")
logger.warning(f"Failed to record activity: {e}")
async def abefore_agent(
self, state: ActivityLogState, runtime: Runtime
@@ -715,12 +686,9 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
tool_args = tool_call.get("args") or {}
if not isinstance(tool_args, dict):
tool_args = {}
logged_args = sanitize_for_host(tool_args)
if not isinstance(logged_args, dict):
logged_args = {}
logger.info(
f"开始执行活动日志查询工具: keyword={logged_args.get('keyword') or '-'}, "
f"date={logged_args.get('date') or '-'}"
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(
@@ -731,9 +699,7 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
try:
result = await handler(request)
except Exception as err:
logger.error(
f"活动日志查询工具执行失败: error={summarize_error(err)}"
)
logger.error(f"活动日志查询工具执行失败: error={err}")
raise
logger.info("活动日志查询工具执行完成")
return result
@@ -748,7 +714,7 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
return None
self._schedule_activity_recording(list(messages))
except Exception as e:
logger.warning(f"Failed to record activity: {summarize_error(e)}")
logger.warning(f"Failed to record activity: {e}")
return None
+1 -1
View File
@@ -17,7 +17,7 @@ from langchain_core.runnables import RunnableConfig
from langgraph.runtime import Runtime
from app.agent.middleware.utils import append_to_system_message
from app.runtime.log import logger
from app.log import logger
# JOB.md 文件最大限制为 1MB
MAX_JOB_FILE_SIZE = 1 * 1024 * 1024
+1 -1
View File
@@ -15,7 +15,7 @@ from langchain_core.runnables import RunnableConfig
from langgraph.runtime import Runtime
from app.agent.middleware.utils import append_to_system_message
from app.runtime.log import logger
from app.log import logger
# 记忆文件最大限制为 100KB,防止单文件过大导致上下文溢出
MAX_MEMORY_FILE_SIZE = 100 * 1024
-195
View File
@@ -1,195 +0,0 @@
"""LangChain 工具调用的 MoviePilot 宿主策略中间件。"""
import asyncio
from collections.abc import Awaitable, Callable
from typing import Any
from langchain.agents.middleware import AgentMiddleware, ToolCallRequest, hook_config
from langchain_core.messages import AIMessage, ToolMessage
from app.agent.policy.contracts import (
ToolOrigin,
ToolPolicyContext,
)
from app.agent.policy.orchestrator import (
DEFAULT_TOOL_POLICY_ORCHESTRATOR,
AgentToolPolicyOrchestrator,
call_policy_hook,
)
from app.agent.tools.catalog import ToolCatalogSnapshot
from app.agent.tools.impl.query_system_settings import QuerySystemSettingsTool
POLICY_DENIED_MESSAGE = "当前宿主策略不允许执行该工具。"
POLICY_UNAVAILABLE_MESSAGE = "宿主策略暂时不可用,未执行该工具。"
TOOL_TIMEOUT_MESSAGE = (
"工具执行超时,已停止等待结果;"
"若工具包含外部写操作,操作可能仍在继续,请先确认实际状态再重试。"
)
class AgentPolicyMiddleware(AgentMiddleware):
"""观测进入本地 ToolNode 的 client-side 工具调用和结果。
模型供应商原生 server tools 在供应商侧执行不经过本地 middleware
因而不具备这里生成的 start/finish/fail 回执
"""
def __init__(
self,
*,
context: ToolPolicyContext,
orchestrator: AgentToolPolicyOrchestrator = DEFAULT_TOOL_POLICY_ORCHESTRATOR,
catalog: ToolCatalogSnapshot | None = None,
tools: list[Any] | None = None,
) -> None:
"""绑定宿主可信上下文和共享策略编排器。"""
self.context = context
self.orchestrator = orchestrator
self.catalog = catalog
self._tools = {
tool.name: tool
for tool in (tools or [])
if getattr(tool, "name", None)
}
@hook_config(can_jump_to=["end"])
async def aafter_model(self, state: dict[str, Any], runtime: Any) -> Any:
"""在 ToolNode 前暂停需要用户确认的敏感设置读取。"""
messages = state.get("messages") or []
if not messages or not isinstance(messages[-1], AIMessage):
return None
tool_calls = messages[-1].tool_calls or []
sensitive_call = None
sensitive_tool = None
for tool_call in tool_calls:
arguments = tool_call.get("args")
tool = self._tools.get(tool_call.get("name"))
if (
isinstance(tool, QuerySystemSettingsTool)
and isinstance(arguments, dict)
and arguments.get("show_secrets") is True
):
sensitive_call = tool_call
sensitive_tool = tool
break
if sensitive_call is None or sensitive_tool is None:
return None
confirmation_handler = (
self.context.agent_context.get("secret_confirmation_handler")
if self.context.origin is ToolOrigin.AGENT_INTERACTIVE
else None
)
if not callable(confirmation_handler):
confirmation_message = "当前入口不支持敏感设置确认,未执行任何工具。"
else:
confirmation_message = await confirmation_handler(
sensitive_tool,
sensitive_call.get("args") or {},
)
paused_messages = [
ToolMessage(
content=(
"本轮工具调用已暂停,未执行任何操作;"
"请等待用户确认或取消敏感设置读取。"
),
tool_call_id=str(tool_call.get("id") or ""),
name=str(tool_call.get("name") or "unknown"),
)
for tool_call in tool_calls
]
paused_messages.append(AIMessage(content=confirmation_message))
return {"messages": paused_messages, "jump_to": "end"}
async def awrap_tool_call(
self,
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], Awaitable[Any]],
) -> Any:
"""在 handler 外层生成 shadow 决策和 secret-safe 回执摘要。"""
tool_call = request.tool_call or {}
arguments = tool_call.get("args") or {}
if not isinstance(arguments, dict):
arguments = {}
try:
_, result = await self.execute_tool_call(
tool=request.tool,
arguments=arguments,
invocation_id=tool_call.get("id"),
handler=lambda: handler(request),
enforce_decision=False,
)
except TimeoutError:
tool_name = str(getattr(request.tool, "name", None) or "unknown")
return ToolMessage(
content=TOOL_TIMEOUT_MESSAGE,
tool_call_id=str(tool_call.get("id") or ""),
name=tool_name,
status="error",
)
# 普通 ToolNode 保持 shadow 观测;已确认调用使用默认的强制决策语义。
return result
async def execute_tool_call(
self,
*,
tool: Any,
arguments: dict[str, Any],
handler: Callable[[], Awaitable[Any]],
invocation_id: str | None = None,
enforce_decision: bool = True,
) -> tuple[bool, Any]:
"""执行一次本地工具调用,并复用 ToolNode 的策略生命周期。"""
observation = call_policy_hook(
"start",
self.orchestrator.start,
context=self.context,
tool=tool,
arguments=arguments,
invocation_id=invocation_id,
)
if enforce_decision and observation is None:
return False, POLICY_UNAVAILABLE_MESSAGE
if (
enforce_decision
and observation.decision.allowed is False
):
return False, POLICY_DENIED_MESSAGE
try:
result = await handler()
except asyncio.CancelledError as error:
if observation is not None:
call_policy_hook(
"cancel",
self.orchestrator.fail,
observation,
error,
)
raise
except Exception as error:
if observation is not None:
call_policy_hook(
"fail",
self.orchestrator.fail,
observation,
error,
)
raise
if observation is not None:
call_policy_hook(
"finish",
self.orchestrator.finish,
observation,
result,
)
return True, result
__all__ = [
"AgentPolicyMiddleware",
"POLICY_DENIED_MESSAGE",
"POLICY_UNAVAILABLE_MESSAGE",
]
+175 -31
View File
@@ -24,19 +24,58 @@ from langgraph.runtime import Runtime
from pydantic import BaseModel, Field
from app.agent.middleware.utils import append_to_system_message
from app.agent.policy.sanitizer import sanitize_for_host, summarize_error
from app.agent.tools.tags import ToolTag
from app.agent.skills.metadata import (
MAX_SKILL_FILE_SIZE,
SkillMetadata,
parse_skill_metadata,
)
from app.runtime.log import logger
from app.log import logger
# 模型返回上限独立于领域层的磁盘读取上限,避免异常内容撑爆上下文。
# 磁盘读取上限与模型返回上限分离,避免异常大的 Skill 文件撑爆内存或上下文。
MAX_SKILL_FILE_SIZE = 1 * 1024 * 1024
MAX_SKILL_RESULT_CHARS = 64 * 1024
SKILL_CONTENT_TRUNCATION_SUFFIX = "\n...(Skill 内容已截断)"
# Agent Skills 规范约束 (https://agentskills.io/specification)
MAX_SKILL_NAME_LENGTH = 64
MAX_SKILL_DESCRIPTION_LENGTH = 1024
MAX_SKILL_COMPATIBILITY_LENGTH = 500
class SkillMetadata(TypedDict):
"""Skill 元数据,符合 Agent Skills 规范。"""
path: str
"""SKILL.md 文件路径。"""
id: str
"""Skill 标识符。
约束: 1-64 字符仅限小写字母/数字/连字符不能以连字符开头或结尾无连续连字符需与父目录名一致
"""
name: str
"""Skill 名称。
约束: Skill中文描述
"""
version: int
"""Skill 版本号。
用于内置技能的版本管理同步时比较版本号决定是否覆盖用户目录中的旧版本
"""
description: str
"""Skill 功能描述。
约束: 1-1024 字符应说明功能及适用场景
"""
license: str | None
"""许可证信息。"""
compatibility: str | None
"""环境依赖或兼容性要求 (最多 500 字符)。"""
metadata: dict[str, str]
"""附加元数据。"""
allowed_tools: list[str]
"""(实验性) Skill 建议使用的工具列表。"""
class SkillsState(AgentState):
"""skills 中间件状态。"""
@@ -61,6 +100,123 @@ class SkillToolInput(BaseModel):
)
def _parse_skill_metadata( # noqa: C901
content: str,
skill_path: str,
skill_id: str,
) -> SkillMetadata | None:
"""从 SKILL.md 内容中解析 YAML 前言并验证元数据。"""
if len(content) > MAX_SKILL_FILE_SIZE:
logger.warning(
"Skipping %s: content too large (%d bytes)", skill_path, len(content)
)
return None
# 匹配 --- 分隔的 YAML 前言
frontmatter_pattern = r"^---\s*\n(.*?)\n---\s*\n"
match = re.match(frontmatter_pattern, content, re.DOTALL)
if not match:
logger.warning("Skipping %s: no valid YAML frontmatter found", skill_path)
return None
frontmatter_str = match.group(1)
# 解析 YAML
try:
frontmatter_data = yaml.safe_load(frontmatter_str)
except yaml.YAMLError as e:
logger.warning("Invalid YAML in %s: %s", skill_path, e)
return None
if not isinstance(frontmatter_data, dict):
logger.warning("Skipping %s: frontmatter is not a mapping", skill_path)
return None
# SKill名称和描述
name = str(frontmatter_data.get("name", "")).strip()
description = str(frontmatter_data.get("description", "")).strip()
if not name or not description:
logger.warning(
"Skipping %s: missing required 'name' or 'description'", skill_path
)
return None
description_str = description
if len(description_str) > MAX_SKILL_DESCRIPTION_LENGTH:
logger.warning(
"Description exceeds %d characters in %s, truncating",
MAX_SKILL_DESCRIPTION_LENGTH,
skill_path,
)
description_str = description_str[:MAX_SKILL_DESCRIPTION_LENGTH]
# 可选的工具列表,支持空格或逗号分隔
raw_tools = frontmatter_data.get("allowed-tools")
if isinstance(raw_tools, str):
allowed_tools = [
t.strip(",") # 兼容 Claude Code 风格的逗号分隔
for t in raw_tools.split()
if t.strip(",")
]
else:
if raw_tools is not None:
logger.warning(
"Ignoring non-string 'allowed-tools' in %s (got %s)",
skill_path,
type(raw_tools).__name__,
)
allowed_tools = []
# 能力或环境兼容性说明,最多 500 字符
compatibility_str = str(frontmatter_data.get("compatibility", "")).strip() or None
if compatibility_str and len(compatibility_str) > MAX_SKILL_COMPATIBILITY_LENGTH:
logger.warning(
"Compatibility exceeds %d characters in %s, truncating",
MAX_SKILL_COMPATIBILITY_LENGTH,
skill_path,
)
compatibility_str = str(compatibility_str)[:MAX_SKILL_COMPATIBILITY_LENGTH]
# 版本号,默认为 0(表示未设置版本)
raw_version = frontmatter_data.get("version")
version = 0
if raw_version is not None:
try:
version = int(raw_version)
except (ValueError, TypeError):
logger.warning(
"Invalid 'version' in %s (got %r), defaulting to 0",
skill_path,
raw_version,
)
return SkillMetadata(
id=skill_id,
name=name,
version=version,
description=description_str,
path=skill_path,
metadata=_validate_metadata(frontmatter_data.get("metadata", {}), skill_path),
license=str(frontmatter_data.get("license", "")).strip() or None,
compatibility=compatibility_str,
allowed_tools=allowed_tools,
)
def _validate_metadata(
raw: object,
skill_path: str,
) -> dict[str, str]:
"""验证并规范化 YAML 前言中的元数据字段,确保为 dict[str, str] 类型。"""
if not isinstance(raw, dict):
if raw:
logger.warning(
"Ignoring non-dict metadata in %s (got %s)",
skill_path,
type(raw).__name__,
)
return {}
return {str(k): str(v) for k, v in raw.items()}
def _format_skill_annotations(skill: SkillMetadata) -> str:
"""构建许可证和兼容性说明字符串。"""
parts: list[str] = []
@@ -107,7 +263,7 @@ async def _alist_skills(source_path: AsyncPath) -> list[SkillMetadata]:
)
# 解析元数据
skill_metadata = parse_skill_metadata(
skill_metadata = _parse_skill_metadata(
content=skill_content,
skill_path=str(skill_md_path),
skill_id=skill_path.name,
@@ -146,7 +302,7 @@ def _list_skills(source_path: Path) -> list[SkillMetadata]:
skill_content = skill_md_path.read_bytes().decode(
"utf-8", errors="replace"
)
skill_metadata = parse_skill_metadata(
skill_metadata = _parse_skill_metadata(
content=skill_content,
skill_path=str(skill_md_path),
skill_id=skill_path.name,
@@ -183,7 +339,7 @@ def _extract_version(skill_md: Path) -> int:
try:
content = skill_md.read_text(encoding="utf-8", errors="replace")
except Exception as err:
logger.debug(f"读取技能版本失败: {summarize_error(err)}")
logger.debug(f"读取技能版本失败: {err}")
return 0
match = re.match(r"^---\s*\n(.*?)\n---\s*\n", content, re.DOTALL)
if not match:
@@ -241,11 +397,7 @@ def _sync_bundled_skills(bundled_dir: Path, target_dir: Path) -> None:
"已自动复制内置技能 '%s' -> '%s'", skill_src.name, skill_dst
)
except Exception as e:
logger.warning(
"复制内置技能 '%s' 失败: %s",
sanitize_for_host(skill_src.name),
summarize_error(e),
)
logger.warning("复制内置技能 '%s' 失败: %s", skill_src.name, e)
continue
# 目标已存在,比较版本号
@@ -272,11 +424,7 @@ def _sync_bundled_skills(bundled_dir: Path, target_dir: Path) -> None:
bundled_version,
)
except Exception as e:
logger.warning(
"更新内置技能 '%s' 失败: %s",
sanitize_for_host(skill_src.name),
summarize_error(e),
)
logger.warning("更新内置技能 '%s' 失败: %s", skill_src.name, e)
class _SkillToolProvider:
@@ -371,7 +519,7 @@ class _SkillToolProvider:
async def load_skill(self, name: str) -> str:
"""加载指定 Skill 的完整说明并返回 JSON 字符串。"""
logger.info(f"加载 Skill: name={sanitize_for_host(name)}")
logger.info(f"加载 Skill: name={name}")
try:
skill = await self._find_skill(name)
if not skill:
@@ -399,12 +547,11 @@ class _SkillToolProvider:
}
)
except Exception as err:
error_summary = summarize_error(err)
logger.error(f"加载 Skill 失败: {error_summary}")
logger.error(f"加载 Skill 失败: {err}", exc_info=True)
return json.dumps(
{
"success": False,
"message": f"加载 Skill 时发生错误: {error_summary}",
"message": f"加载 Skill 时发生错误: {str(err)}",
},
ensure_ascii=False,
)
@@ -476,7 +623,7 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no
try:
_sync_bundled_skills(bundled, target)
except Exception as e:
logger.warning(f"同步内置技能失败: {summarize_error(e)}")
logger.warning("同步内置技能失败: %s", e)
def _load_skills_metadata(self) -> list[SkillMetadata]:
"""同步加载当前配置目录中的 Skill 元数据。"""
@@ -581,11 +728,8 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no
tool_args = tool_call.get("args") or {}
if not isinstance(tool_args, dict):
tool_args = {}
logged_args = sanitize_for_host(tool_args)
if not isinstance(logged_args, dict):
logged_args = {}
logger.info(
f"开始执行 Skill 工具: name={logged_args.get('name') or '-'}"
f"开始执行 Skill 工具: name={tool_args.get('name') or '-'}"
)
if self.stream_handler and getattr(self.stream_handler, "is_streaming", False):
self.stream_handler.record_tool_call(
@@ -596,7 +740,7 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no
try:
result = await handler(request)
except Exception as err:
logger.error(f"Skill 工具执行失败: error={summarize_error(err)}")
logger.error(f"Skill 工具执行失败: error={err}")
raise
logger.info("Skill 工具执行完成")
return result
+38 -196
View File
@@ -23,23 +23,11 @@ from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.tools import BaseTool, StructuredTool
from pydantic import BaseModel, Field
from app.agent.llm.helper import LLMHelper
from app.agent.middleware.policy import AgentPolicyMiddleware
from app.agent.llm import LLMHelper
from app.agent.middleware.utils import append_to_system_message
from app.agent.policy.contracts import (
AuthSource,
PrincipalType,
ToolOrigin,
ToolPolicyContext,
)
from app.agent.policy.sanitizer import (
sanitize_for_host,
summarize_error,
)
from app.agent.runtime import SubAgentDefinition, agent_runtime_manager
from app.agent.tools.tags import ToolTag
from app.agent.tools.catalog import ToolCatalogSnapshot
from app.runtime.log import logger
from app.log import logger
SUBAGENT_TASK_TOOL_NAME = "task"
@@ -48,7 +36,6 @@ SUBAGENT_STREAM_MARKER_KEY = "ls_agent_type"
SUBAGENT_STREAM_MARKER_VALUE = "subagent"
SUBAGENT_DEFAULT_WAIT_TIMEOUT_MS = 60000
SUBAGENT_MAX_WAIT_TIMEOUT_MS = 300000
SUBAGENT_CANCEL_GRACE_SECONDS = 5.0
SUBAGENT_MAX_ACTIVE_TASKS = 8
SUBAGENT_MAX_CONCURRENT_TASKS = 4
SUBAGENT_RESULT_MAX_CHARS = 12000
@@ -106,36 +93,6 @@ Requirements:
"""
def _default_subagent_policy_context(tools: list[BaseTool]) -> ToolPolicyContext:
"""从已注入工具继承会话归属,确保独立构造的子图也经过宿主策略。"""
for tool in tools:
session_id = getattr(tool, "_session_id", None)
user_id = getattr(tool, "_user_id", None)
if not session_id and not user_id:
continue
agent_context = getattr(tool, "_agent_context", None)
if not isinstance(agent_context, dict):
agent_context = {}
return ToolPolicyContext(
session_id=str(session_id or "subagent"),
user_id=str(user_id or "subagent"),
origin=ToolOrigin.SUBAGENT,
principal_type=PrincipalType.SUBAGENT,
auth_source=AuthSource.INTERNAL,
agent_context=agent_context,
channel=getattr(tool, "_channel", None),
source=getattr(tool, "_source", None),
)
return ToolPolicyContext(
session_id="subagent",
user_id="subagent",
origin=ToolOrigin.SUBAGENT,
principal_type=PrincipalType.SUBAGENT,
auth_source=AuthSource.INTERNAL,
agent_context={},
)
@dataclass(frozen=True)
class _SubAgentProfile:
"""子代理运行时定义。"""
@@ -421,16 +378,12 @@ class _SubAgentAgentProvider:
profiles: tuple[_SubAgentProfile, ...],
tools: list[BaseTool],
server_tools: Optional[list[dict[str, Any]]] = None,
policy_context: Optional[ToolPolicyContext] = None,
catalog: Optional[ToolCatalogSnapshot] = None,
) -> None:
"""初始化子代理执行器。"""
self._model = model
self._profiles = {profile.name: profile for profile in profiles}
self._tools = tools
self._server_tools = server_tools or []
self._policy_context = policy_context or _default_subagent_policy_context(tools)
self._catalog = catalog
self._agents = {}
self._default_agent_name = "general-purpose"
@@ -448,9 +401,6 @@ class _SubAgentAgentProvider:
return profile.name, cached_agent
subagent_tools = _select_tools(self._tools, profile)
subagent_catalog = (
self._catalog.select(subagent_tools) if self._catalog is not None else None
)
logger.info(
f"创建子代理图: subagent_type={profile.name}, tools={len(subagent_tools)}"
)
@@ -459,12 +409,6 @@ class _SubAgentAgentProvider:
tools=[*subagent_tools, *self._server_tools],
system_prompt=profile.prompt,
name=profile.name,
middleware=[
AgentPolicyMiddleware(
context=self._policy_context,
catalog=subagent_catalog,
)
],
)
self._agents[profile.name] = agent
return profile.name, agent
@@ -500,7 +444,7 @@ class _SubAgentAgentProvider:
except Exception as err:
logger.error(
f"子代理调用失败: subagent_type={agent_name}, "
f"task_id={log_task_id}, error={summarize_error(err)}"
f"task_id={log_task_id}, error={err}"
)
raise
final_text = _extract_final_text(result)
@@ -524,8 +468,6 @@ class MoviePilotSubAgentMiddleware(AgentMiddleware):
system_prompt: str = SUBAGENT_PARENT_PROMPT,
task_description: str = SUBAGENT_TASK_DESCRIPTION,
stream_handler: Any = None,
policy_context: Optional[ToolPolicyContext] = None,
catalog: Optional[ToolCatalogSnapshot] = None,
) -> None:
"""初始化同步子代理中间件。"""
self.system_prompt = system_prompt
@@ -535,8 +477,6 @@ class MoviePilotSubAgentMiddleware(AgentMiddleware):
profiles=profiles,
tools=tools,
server_tools=server_tools,
policy_context=policy_context,
catalog=catalog,
)
self.tools = [
StructuredTool.from_function(
@@ -587,12 +527,9 @@ class MoviePilotSubAgentMiddleware(AgentMiddleware):
return await handler(request)
tool_args = _extract_tool_call_args(request)
logged_args = sanitize_for_host(tool_args)
if not isinstance(logged_args, dict):
logged_args = {}
logger.info(
f"开始执行子代理工具: tool_name={tool_name}, "
f"subagent_type={logged_args.get('subagent_type') or '-'}"
f"subagent_type={tool_args.get('subagent_type') or '-'}"
)
_record_subagent_tool_call(
stream_handler=self.stream_handler,
@@ -602,10 +539,7 @@ class MoviePilotSubAgentMiddleware(AgentMiddleware):
try:
result = await handler(request)
except Exception as err:
logger.error(
f"子代理工具执行失败: tool_name={tool_name}, "
f"error={summarize_error(err)}"
)
logger.error(f"子代理工具执行失败: tool_name={tool_name}, error={err}")
raise
logger.info(f"子代理工具执行完成: tool_name={tool_name}")
return result
@@ -623,8 +557,6 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
server_tools: Optional[list[dict[str, Any]]] = None,
task_description: str = SUBAGENT_CONTROL_DESCRIPTION,
stream_handler: Any = None,
policy_context: Optional[ToolPolicyContext] = None,
catalog: Optional[ToolCatalogSnapshot] = None,
) -> None:
"""初始化异步子代理调度中间件。"""
self.stream_handler = stream_handler
@@ -633,13 +565,9 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
profiles=profiles,
tools=tools,
server_tools=server_tools,
policy_context=policy_context,
catalog=catalog,
)
self._semaphore = asyncio.Semaphore(SUBAGENT_MAX_CONCURRENT_TASKS)
self._tasks: dict[str, _SubAgentRuntimeTask] = {}
self._accepting_tasks = True
self._close_cancel_requested: set[asyncio.Task] = set()
self.tools = [
StructuredTool.from_function(
coroutine=self._control_task,
@@ -700,7 +628,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
error = record.task.exception()
if error:
payload["error"] = summarize_error(error)
payload["error"] = str(error)
return payload
result, result_truncated = _clip_text(
@@ -805,17 +733,11 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
)
raise
except Exception as err:
logger.error(
f"子代理任务执行失败: task_id={record.task_id}, "
f"error={summarize_error(err)}"
)
logger.error(f"子代理任务执行失败: task_id={record.task_id}, error={err}")
raise
def _mark_task_finished(self, task_id: str, task: asyncio.Task) -> None:
"""记录任务完成时间并取出异常避免未读取告警。"""
cancel_requested = getattr(self, "_close_cancel_requested", None)
if cancel_requested is not None:
cancel_requested.discard(task)
record = self._tasks.get(task_id)
if record:
record.finished_at = datetime.now()
@@ -898,70 +820,18 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
)
@staticmethod
async def _cancel_records(
records: list[_SubAgentRuntimeTask],
*,
cancel_requested: Optional[set[asyncio.Task]] = None,
) -> list[_SubAgentRuntimeTask]:
"""取消一组任务,并返回等待上限内仍未收敛的记录。"""
async def _cancel_records(records: list[_SubAgentRuntimeTask]) -> None:
"""取消一组尚未完成的任务。"""
cancellable_tasks = [
record.task for record in records if not record.task.done()
]
if cancellable_tasks:
logger.info(f"开始取消子代理任务: tasks={len(cancellable_tasks)}")
for task in cancellable_tasks:
if cancel_requested is not None and task in cancel_requested:
continue
task.cancel()
if cancel_requested is not None:
cancel_requested.add(task)
if not cancellable_tasks:
return []
done, pending = await asyncio.wait(
cancellable_tasks,
timeout=SUBAGENT_CANCEL_GRACE_SECONDS,
)
if done:
await asyncio.gather(*done, return_exceptions=True)
if pending:
logger.warning(
f"子代理任务取消等待超时: pending={len(pending)}, "
f"timeout={SUBAGENT_CANCEL_GRACE_SECONDS}s"
)
else:
if cancellable_tasks:
await asyncio.gather(*cancellable_tasks, return_exceptions=True)
logger.info(f"子代理任务取消完成: tasks={len(cancellable_tasks)}")
return [record for record in records if record.task in pending]
def seal(self) -> None:
"""封住新的 detached 子代理提交,既有任务继续由记录表持有。"""
self._accepting_tasks = False
async def close(self) -> bool:
"""有限等待 detached 子代理;超时保留记录并返回 False。"""
self.seal()
if not hasattr(self, "_close_cancel_requested"):
self._close_cancel_requested = set()
unfinished_records = [
record for record in self._tasks.values() if not record.task.done()
]
if unfinished_records:
logger.info(
f"关闭子代理任务控制器,取消未完成任务: tasks={len(unfinished_records)}"
)
pending_records = await self._cancel_records(
unfinished_records,
cancel_requested=self._close_cancel_requested,
)
if pending_records:
self._tasks = {
record.task_id: record
for record in pending_records
}
return False
self._tasks.clear()
self._close_cancel_requested.clear()
return True
@staticmethod
def _pipeline_description(
@@ -1031,10 +901,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
)
raise
except Exception as err:
logger.error(
f"管道子代理任务执行失败: task_id={record.task_id}, "
f"error={summarize_error(err)}"
)
logger.error(f"管道子代理任务执行失败: task_id={record.task_id}, error={err}")
raise
@staticmethod
@@ -1080,8 +947,6 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
previous_results: list[tuple[_SubAgentRuntimeTask, str]] = []
timeout = normalized_timeout_ms / 1000
for step_index, spec in enumerate(specs, start=1):
if not self._accepting_tasks:
return records, "子代理任务控制器正在关闭,不能再启动新任务。"
record = self._create_pipeline_record(spec)
records.append(record)
pipeline_description = self._pipeline_description(
@@ -1101,24 +966,17 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
f"subagent_type={record.subagent_type}"
)
done, pending = await asyncio.wait({task}, timeout=timeout)
if pending:
task.cancel()
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
try:
result = next(iter(done)).result()
except Exception as err:
error = (
f"{step_index} 个管道子代理任务执行失败: "
f"{summarize_error(err)}"
)
logger.info(
f"{error} task_id={record.task_id}"
)
error = f"{step_index} 个管道子代理任务执行失败: {err}"
logger.info(f"{error} task_id={record.task_id}")
return records, error
previous_results.append((record, result))
@@ -1139,19 +997,13 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
"""管理异步子代理任务。"""
logger.info(f"收到子代理管控操作: action={action}")
if action in {"start", "run", "pipeline"}:
if not self._accepting_tasks:
error = "子代理任务控制器正在关闭,不能再启动新任务。"
return self._json_response({"success": False, "error": error})
specs, error = self._normalize_specs(
description=description,
subagent_type=subagent_type,
tasks=tasks,
)
if error:
logger.info(
f"子代理管控操作未启动任务: action={action}, "
f"error={sanitize_for_host(error)}"
)
logger.info(f"子代理管控操作未启动任务: action={action}, error={error}")
return self._json_response({"success": False, "error": error})
logger.info(f"准备启动子代理任务: action={action}, tasks={len(specs)}")
@@ -1192,7 +1044,6 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
active_only=action in {"wait", "cancel"} and not task_ids and not task_id,
)
cancellation_pending: list[_SubAgentRuntimeTask] = []
if action == "wait":
logger.info(
f"准备等待子代理任务: selected={len(records)}, missing={len(missing_ids)}"
@@ -1206,28 +1057,31 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
logger.info(
f"准备取消子代理任务: selected={len(records)}, missing={len(missing_ids)}"
)
cancellation_pending = await self._cancel_records(records)
await self._cancel_records(records)
elif action == "status":
logger.info(
f"查询子代理任务状态: selected={len(records)}, missing={len(missing_ids)}"
)
response = {
"success": not cancellation_pending,
"action": action,
"wait_mode": wait_mode if action == "wait" else None,
"missing_task_ids": missing_ids,
"tasks": [self._task_output(record) for record in records],
}
if action == "cancel":
response["cancel_pending_task_ids"] = [
record.task_id for record in cancellation_pending
]
return self._json_response(response)
return self._json_response(
{
"success": True,
"action": action,
"wait_mode": wait_mode if action == "wait" else None,
"missing_task_ids": missing_ids,
"tasks": [self._task_output(record) for record in records],
}
)
async def aafter_agent(self, state: Any, runtime: Any) -> None:
"""Agent 结束时取消未完成的子代理任务,避免后台泄漏。"""
await self.close()
unfinished_records = [
record for record in self._tasks.values() if not record.task.done()
]
if unfinished_records:
logger.info(f"Agent 结束,取消未完成子代理任务: tasks={len(unfinished_records)}")
await self._cancel_records(unfinished_records)
self._tasks.clear()
async def awrap_tool_call(
self,
@@ -1241,13 +1095,10 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
return await handler(request)
tool_args = _extract_tool_call_args(request)
logged_args = sanitize_for_host(tool_args)
if not isinstance(logged_args, dict):
logged_args = {}
logger.info(
f"开始执行子代理工具: tool_name={tool_name}, "
f"action={logged_args.get('action') or '-'}, "
f"subagent_type={logged_args.get('subagent_type') or '-'}"
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,
@@ -1257,10 +1108,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
try:
result = await handler(request)
except Exception as err:
logger.error(
f"子代理工具执行失败: tool_name={tool_name}, "
f"error={summarize_error(err)}"
)
logger.error(f"子代理工具执行失败: tool_name={tool_name}, error={err}")
raise
logger.info(f"子代理工具执行完成: tool_name={tool_name}")
return result
@@ -1272,8 +1120,6 @@ def create_subagent_middlewares(
tools: list[BaseTool],
server_tools: Optional[list[dict[str, Any]]] = None,
stream_handler: Any = None,
policy_context: Optional[ToolPolicyContext] = None,
catalog: Optional[ToolCatalogSnapshot] = None,
) -> tuple[list[AgentMiddleware], list[BaseTool]]:
"""创建子代理中间件列表和任务工具列表。"""
runtime_signature = agent_runtime_manager.current_signature()
@@ -1284,8 +1130,6 @@ def create_subagent_middlewares(
tools=tools,
server_tools=server_tools or [],
stream_handler=stream_handler,
policy_context=policy_context,
catalog=catalog,
)
control_middleware = SubAgentTaskControlMiddleware(
model=model,
@@ -1293,8 +1137,6 @@ def create_subagent_middlewares(
tools=tools,
server_tools=server_tools or [],
stream_handler=stream_handler,
policy_context=policy_context,
catalog=catalog,
)
task_tools = [
-554
View File
@@ -1,554 +0,0 @@
"""Agent 会话上下文压缩中间件。"""
from collections.abc import Awaitable, Callable
from importlib import import_module
from typing import Any
from langchain.agents.middleware.summarization import (
DEFAULT_SUMMARY_PROMPT,
ContextSize,
SummarizationMiddleware,
TokenCounter,
TriggerClause,
)
from langchain.agents.middleware.types import (
AgentMiddleware,
ExtendedModelResponse,
ModelRequest,
ModelResponse,
)
from langchain.chat_models import BaseChatModel
from langchain_core.messages import AnyMessage, HumanMessage, RemoveMessage, ToolMessage
from langchain_core.messages.utils import count_tokens_approximately, get_buffer_string
from langgraph.graph.message import REMOVE_ALL_MESSAGES
from langgraph.types import Command
from app.agent.middleware.usage import UsageMiddleware
from app.runtime.log import logger
try:
_internal_call_metadata = import_module(
"langchain.agents.middleware.internal_call_transformer"
).internal_call_metadata
except ImportError:
def _internal_call_metadata() -> dict[str, Any]:
"""旧版 LangChain 没有内部模型调用的流式过滤标记。"""
return {}
class ContextSummarizationError(RuntimeError):
"""摘要不可用且原有会话上下文未被替换。"""
class ContextPreservingSummarizationMiddleware(SummarizationMiddleware):
"""摘要失败时中止状态更新,避免永久丢失既有会话上下文。"""
_ERROR_MESSAGE = "会话上下文压缩失败,原有上下文已保留,请稍后重试"
_UNSUMMARIZABLE_MESSAGE = (
"会话历史中存在无法压缩的超长内容,原有上下文已保留,"
"请新建或清空会话后继续"
)
# LangChain 默认按 4000 token 裁剪待摘要消息,单条超长工具结果或用户输入
# (超长无换行文本、非文本多模态块)会被整体丢弃,直接触发"无法压缩"报错。
# 调大该上限可容纳更大单条消息,减少误报;16k 相对常见模型窗口仍然安全,
# 摘要模型与主模型同窗口,过大会抬高摘要成本并挤占主模型预算。
_DEFAULT_TRIM_TOKENS_TO_SUMMARIZE = 16000
def __init__(
self,
model: str | BaseChatModel,
*,
trigger: (
ContextSize | TriggerClause | list[ContextSize | TriggerClause] | None
) = None,
keep: ContextSize = ("messages", 20),
token_counter: TokenCounter = count_tokens_approximately,
summary_prompt: str = DEFAULT_SUMMARY_PROMPT,
trim_tokens_to_summarize: int | None = _DEFAULT_TRIM_TOKENS_TO_SUMMARIZE,
**deprecated_kwargs: Any,
) -> None:
"""按 MoviePilot 的默认压缩策略构建摘要中间件。"""
super().__init__(
model=model,
trigger=trigger,
keep=keep,
token_counter=token_counter,
summary_prompt=summary_prompt,
trim_tokens_to_summarize=trim_tokens_to_summarize,
**deprecated_kwargs,
)
@classmethod
def _require_valid_summary(cls, summary: str) -> str:
"""拒绝无法继续承载会话上下文的空摘要。"""
if not summary:
raise ContextSummarizationError(cls._ERROR_MESSAGE)
return summary
def _prepare_summary_input(
self, messages_to_summarize: list[AnyMessage]
) -> str:
"""复用 LangChain 裁剪策略生成摘要模型输入。"""
trimmed_messages = self._trim_messages_for_summary(messages_to_summarize)
if not trimmed_messages:
raise ContextSummarizationError(self._UNSUMMARIZABLE_MESSAGE)
return get_buffer_string(trimmed_messages, format="xml")
def _create_summary(self, messages_to_summarize: list[AnyMessage]) -> str:
"""同步摘要失败时保持原图状态。"""
formatted_messages = self._prepare_summary_input(messages_to_summarize)
summary_model = getattr(self, "_summary_model", self.model)
try:
response = summary_model.invoke(
self.summary_prompt.format(messages=formatted_messages).rstrip(),
config={
"metadata": {
"lc_source": "summarization",
**_internal_call_metadata(),
}
},
)
except Exception as err:
raise ContextSummarizationError(self._ERROR_MESSAGE) from err
return self._require_valid_summary(response.text.strip())
async def _acreate_summary(self, messages_to_summarize: list[AnyMessage]) -> str:
"""异步摘要失败时保持原图状态。"""
formatted_messages = self._prepare_summary_input(messages_to_summarize)
summary_model = getattr(self, "_summary_model", self.model)
try:
response = await summary_model.ainvoke(
self.summary_prompt.format(messages=formatted_messages).rstrip(),
config={
"metadata": {
"lc_source": "summarization",
**_internal_call_metadata(),
}
},
)
except Exception as err:
raise ContextSummarizationError(self._ERROR_MESSAGE) from err
return self._require_valid_summary(response.text.strip())
def partition_for_token_limit(
self,
messages: list[AnyMessage],
token_limit: int,
*,
force: bool = False,
minimum_cutoff: int = 1,
strict_token_limit: bool = False,
) -> tuple[list[AnyMessage], list[AnyMessage]] | None:
"""按 token 上限拆分历史,并保持 LangChain 的工具调用事务边界。"""
self._ensure_message_ids(messages)
if self.token_counter(messages) <= token_limit:
return (
self._minimum_safe_partition(
messages,
minimum_cutoff=minimum_cutoff,
token_limit=token_limit if strict_token_limit else None,
)
if force
else None
)
left, right = 0, len(messages)
cutoff_candidate = len(messages)
while left < right:
midpoint = (left + right) // 2
if self._partial_token_counter(messages[midpoint:]) <= token_limit:
cutoff_candidate = midpoint
right = midpoint
else:
left = midpoint + 1
if cutoff_candidate >= len(messages):
cutoff_candidate = len(messages)
cutoff_index = self._find_safe_cutoff_point(messages, cutoff_candidate)
if (
cutoff_index <= 0
or cutoff_index >= len(messages)
or cutoff_index < minimum_cutoff
or not self._contains_unsummarized_message(messages[:cutoff_index])
or (
strict_token_limit
and self._partial_token_counter(messages[cutoff_index:]) > token_limit
)
):
if cutoff_candidate >= len(messages):
if strict_token_limit:
return None
return self._latest_safe_partition(
messages,
minimum_cutoff=minimum_cutoff,
)
return self._minimum_safe_partition(
messages,
minimum_cutoff=max(minimum_cutoff, cutoff_candidate),
token_limit=token_limit if strict_token_limit else None,
)
return self._partition_messages(messages, cutoff_index)
def partition_for_retention(
self, messages: list[AnyMessage]
) -> tuple[list[AnyMessage], list[AnyMessage]] | None:
"""按摘要器既有触发和保留策略拆分历史。"""
self._ensure_message_ids(messages)
total_tokens = self.token_counter(messages)
if not self._should_summarize(messages, total_tokens):
return None
cutoff_index = self._determine_cutoff_index(messages)
if cutoff_index <= 0:
return None
return self._partition_messages(messages, cutoff_index)
def _minimum_safe_partition(
self,
messages: list[AnyMessage],
*,
minimum_cutoff: int = 1,
token_limit: int | None = None,
) -> tuple[list[AnyMessage], list[AnyMessage]] | None:
"""至少摘要一段旧历史,同时保留最新完整消息事务。"""
for candidate in range(max(1, minimum_cutoff), len(messages)):
cutoff_index = self._find_safe_cutoff_point(messages, candidate)
if (
minimum_cutoff <= cutoff_index < len(messages)
and self._contains_unsummarized_message(messages[:cutoff_index])
and (
token_limit is None
or self._partial_token_counter(messages[cutoff_index:])
<= token_limit
)
):
return self._partition_messages(messages, cutoff_index)
return None
def _latest_safe_partition(
self,
messages: list[AnyMessage],
*,
minimum_cutoff: int,
) -> tuple[list[AnyMessage], list[AnyMessage]] | None:
"""保留无法满足软预算时的最新完整消息事务。"""
for candidate in range(len(messages) - 1, minimum_cutoff - 1, -1):
cutoff_index = self._find_safe_cutoff_point(messages, candidate)
if (
minimum_cutoff <= cutoff_index < len(messages)
and self._contains_unsummarized_message(messages[:cutoff_index])
):
return self._partition_messages(messages, cutoff_index)
return None
@staticmethod
def _contains_unsummarized_message(messages: list[AnyMessage]) -> bool:
"""确认待摘要段包含可推进上下文的原始消息。"""
return any(
message.additional_kwargs.get("lc_source") != "summarization"
for message in messages
)
def build_summary_messages(self, summary: str) -> list[AnyMessage]:
"""将摘要转换为 LangChain 约定的可识别历史消息。"""
return self._build_new_messages(summary)
def ensure_message_ids(self, messages: list[AnyMessage]) -> None:
"""为压缩后消息补齐 LangGraph reducer 所需的稳定 ID。"""
self._ensure_message_ids(messages)
def create_summary(self, messages_to_summarize: list[AnyMessage]) -> str:
"""通过 MoviePilot 的失败保护合同生成同步摘要。"""
return self._create_summary(messages_to_summarize)
async def acreate_summary(self, messages_to_summarize: list[AnyMessage]) -> str:
"""通过 MoviePilot 的失败保护合同生成异步摘要。"""
return await self._acreate_summary(messages_to_summarize)
class FinalRequestCompactionMiddleware(AgentMiddleware):
"""按最终模型请求预算压缩历史,并在模型成功后原子提交新状态。"""
_COMPACTION_ANCHOR_KEY = "moviepilot_compaction_anchor_id"
_UNCOMPRESSIBLE_REQUEST = (
"最终模型请求压缩后仍超出上下文窗口,原有上下文已保留,"
"请减少启用工具或切换更大上下文模型"
)
def __init__(
self,
*,
summarizer: ContextPreservingSummarizationMiddleware,
trigger_fraction: float = 0.85,
keep_fraction: float = 0.10,
) -> None:
self.summarizer = summarizer
self.trigger_fraction = trigger_fraction
self.keep_fraction = keep_fraction
def _should_compact(self, budget: dict[str, Any]) -> bool:
"""以最终请求实际模型窗口判断是否需要压缩。"""
estimated_tokens = budget.get("estimated_input_tokens")
context_window = budget.get("context_window_tokens")
return (
isinstance(estimated_tokens, int)
and isinstance(context_window, int)
and estimated_tokens >= context_window * self.trigger_fraction
)
def _compaction_partition(
self, request: ModelRequest
) -> tuple[list[AnyMessage], list[AnyMessage]] | None:
"""最终输入达到阈值时,拆分需要摘要和需要原样保留的消息。"""
messages = list(request.messages)
try:
budget = UsageMiddleware.estimate_request(request)
except Exception as error:
logger.debug(
"最终模型请求预算评估失败,继续原请求: error_type=%s",
type(error).__name__,
)
return None
context_window = budget.get("context_window_tokens")
if self._should_skip_after_current_turn_compaction(messages, budget):
return None
if not self._should_compact(budget) or not isinstance(context_window, int):
return None
try:
partition = self.summarizer.partition_for_retention(messages)
if partition is None:
partition = self.summarizer.partition_for_token_limit(
messages,
max(1, int(context_window * self.keep_fraction)),
force=budget["estimated_input_tokens"] > context_window,
)
except Exception as error:
logger.debug(
"最终请求历史拆分失败,继续原请求: error_type=%s",
type(error).__name__,
)
if budget["estimated_input_tokens"] > context_window:
raise ContextSummarizationError(
self._UNCOMPRESSIBLE_REQUEST
) from error
return None
if partition is None:
if budget["estimated_input_tokens"] > context_window:
raise ContextSummarizationError(self._UNCOMPRESSIBLE_REQUEST)
return None
messages_to_summarize, preserved_messages = partition
if all(
message.additional_kwargs.get("lc_source") == "summarization"
for message in messages_to_summarize
):
if budget["estimated_input_tokens"] > context_window:
raise ContextSummarizationError(self._UNCOMPRESSIBLE_REQUEST)
return None
return messages_to_summarize, preserved_messages
@classmethod
def _should_skip_after_current_turn_compaction(
cls, messages: list[AnyMessage], budget: dict[str, Any]
) -> bool:
"""同轮只在新工具结果已使请求超窗时再次压缩。"""
for message in reversed(messages):
anchor_id = message.additional_kwargs.get(cls._COMPACTION_ANCHOR_KEY)
if not isinstance(anchor_id, str):
continue
anchor_index = next(
(
index
for index, candidate in enumerate(messages)
if candidate.id == anchor_id
),
None,
)
if anchor_index is None:
return False
messages_after_anchor = messages[anchor_index + 1 :]
if any(
isinstance(candidate, HumanMessage)
for candidate in messages_after_anchor
):
return False
if any(isinstance(candidate, ToolMessage) for candidate in messages_after_anchor):
estimated_tokens = budget.get("estimated_input_tokens")
context_window = budget.get("context_window_tokens")
return not (
isinstance(estimated_tokens, int)
and isinstance(context_window, int)
and estimated_tokens > context_window
)
return True
return False
def _build_compacted_messages(
self, summary: str, preserved_messages: list[AnyMessage]
) -> list[AnyMessage]:
"""构造摘要与近期历史,并记录本轮压缩输入边界。"""
summary_messages = self.summarizer.build_summary_messages(summary)
if summary_messages:
self.summarizer.ensure_message_ids(summary_messages)
anchor_id = (
preserved_messages[-1].id
if preserved_messages
else summary_messages[0].id
)
first_summary = summary_messages[0]
summary_messages[0] = first_summary.model_copy(
update={
"additional_kwargs": {
**first_summary.additional_kwargs,
self._COMPACTION_ANCHOR_KEY: anchor_id,
}
}
)
return [*summary_messages, *preserved_messages]
def _validate_or_repartition(
self,
request: ModelRequest,
messages_to_summarize: list[AnyMessage],
preserved_messages: list[AnyMessage],
summary: str,
) -> tuple[list[AnyMessage], tuple[list[AnyMessage], list[AnyMessage]] | None]:
"""复核压缩后的最终预算,并计算一次更小的近期历史分区。"""
compacted_messages = self._build_compacted_messages(summary, preserved_messages)
compacted_budget = UsageMiddleware.estimate_request(
request.override(messages=compacted_messages)
)
context_window = compacted_budget.get("context_window_tokens")
estimated_tokens = compacted_budget.get("estimated_input_tokens")
if not isinstance(context_window, int) or not isinstance(estimated_tokens, int):
return compacted_messages, None
if estimated_tokens <= context_window:
return compacted_messages, None
summary_messages = self.summarizer.build_summary_messages(summary)
fixed_summary_budget = UsageMiddleware.estimate_request(
request.override(messages=summary_messages)
)
available_recent_tokens = (
context_window - fixed_summary_budget["estimated_input_tokens"]
)
if available_recent_tokens <= 0:
raise ContextSummarizationError(self._UNCOMPRESSIBLE_REQUEST)
repartition = self.summarizer.partition_for_token_limit(
list(request.messages),
available_recent_tokens,
force=True,
minimum_cutoff=len(messages_to_summarize) + 1,
strict_token_limit=True,
)
if (
repartition is None
or len(repartition[0]) <= len(messages_to_summarize)
):
raise ContextSummarizationError(self._UNCOMPRESSIBLE_REQUEST)
return compacted_messages, repartition
def _require_within_window(
self, request: ModelRequest, compacted_messages: list[AnyMessage]
) -> None:
"""禁止把已知仍超过主模型窗口的请求发送给 provider。"""
budget = UsageMiddleware.estimate_request(
request.override(messages=compacted_messages)
)
estimated_tokens = budget.get("estimated_input_tokens")
context_window = budget.get("context_window_tokens")
if (
isinstance(estimated_tokens, int)
and isinstance(context_window, int)
and estimated_tokens > context_window
):
raise ContextSummarizationError(self._UNCOMPRESSIBLE_REQUEST)
def _prepare_messages(self, request: ModelRequest) -> list[AnyMessage] | None:
"""同步生成摘要与需要原样保留的近期消息。"""
partition = self._compaction_partition(request)
if partition is None:
return None
messages_to_summarize, preserved_messages = partition
summary = self.summarizer.create_summary(messages_to_summarize)
compacted_messages, repartition = self._validate_or_repartition(
request,
messages_to_summarize,
preserved_messages,
summary,
)
if repartition is not None:
messages_to_summarize, preserved_messages = repartition
summary = self.summarizer.create_summary(messages_to_summarize)
compacted_messages = self._build_compacted_messages(
summary, preserved_messages
)
self._require_within_window(request, compacted_messages)
return compacted_messages
async def _aprepare_messages(
self, request: ModelRequest
) -> list[AnyMessage] | None:
"""异步生成摘要与需要原样保留的近期消息。"""
partition = self._compaction_partition(request)
if partition is None:
return None
messages_to_summarize, preserved_messages = partition
summary = await self.summarizer.acreate_summary(messages_to_summarize)
compacted_messages, repartition = self._validate_or_repartition(
request,
messages_to_summarize,
preserved_messages,
summary,
)
if repartition is not None:
messages_to_summarize, preserved_messages = repartition
summary = await self.summarizer.acreate_summary(messages_to_summarize)
compacted_messages = self._build_compacted_messages(
summary, preserved_messages
)
self._require_within_window(request, compacted_messages)
return compacted_messages
@staticmethod
def _with_state_update(
response: ModelResponse, compacted_messages: list[AnyMessage]
) -> ExtendedModelResponse:
"""主模型成功后一次性替换历史,同时保留本次模型结果。"""
return ExtendedModelResponse(
model_response=response,
command=Command(
update={
"messages": [
RemoveMessage(id=REMOVE_ALL_MESSAGES),
*compacted_messages,
*response.result,
]
}
),
)
def wrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelResponse | ExtendedModelResponse:
"""同步压缩最终请求;模型失败时不提交摘要状态。"""
compacted_messages = self._prepare_messages(request)
if compacted_messages is None:
return handler(request)
response = handler(request.override(messages=compacted_messages))
return self._with_state_update(response, compacted_messages)
async def awrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
) -> ModelResponse | ExtendedModelResponse:
"""异步压缩最终请求;模型失败时不提交摘要状态。"""
compacted_messages = await self._aprepare_messages(request)
if compacted_messages is None:
return await handler(request)
response = await handler(request.override(messages=compacted_messages))
return self._with_state_update(response, compacted_messages)
+2 -2
View File
@@ -26,9 +26,9 @@ from langchain_core.tools import BaseTool
from langgraph.runtime import Runtime
from typing_extensions import TypedDict # noqa
from app.agent.llm.helper import LLMHelper
from app.agent.llm import LLMHelper
from app.agent.tools.tags import ToolTag
from app.runtime.log import logger
from app.log import logger
MIN_SELECTED_TOOL_COUNT = 4
RECENT_SELECTION_CONTEXT_MESSAGE_LIMIT = 6
+10 -230
View File
@@ -9,25 +9,19 @@ from langchain.agents.middleware.types import (
ResponseT,
)
from langchain_core.messages import AIMessage
from langchain_core.messages.utils import count_tokens_approximately
from app.runtime.log import logger
from app.log import logger
class UsageMiddleware(AgentMiddleware):
"""观察最终模型请求预算,并记录模型返回的真实 usage"""
"""记录模型调用 usage 信息并回传给外部会话"""
def __init__(
self,
*,
on_usage: Callable[[dict[str, Any]], None] | None = None,
on_request_budget: Callable[[dict[str, Any]], None] | None = None,
next_request_sequence: Callable[[], int] | None = None,
) -> None:
self.on_usage = on_usage
self.on_request_budget = on_request_budget
self.next_request_sequence = next_request_sequence
self._request_sequence = 0
@staticmethod
def _coerce_int(value: Any) -> int | None:
@@ -38,37 +32,6 @@ class UsageMiddleware(AgentMiddleware):
except (TypeError, ValueError):
return None
@staticmethod
def _coerce_positive_int(value: Any) -> int | None:
"""仅接受模型 profile 和请求设置声明的非 bool 正整数。"""
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
return None
return value
@classmethod
def _lookup_positive_int(cls, container: Any, *keys: str) -> int | None:
"""按字段优先级读取 token 上限,拒绝隐式数值转换。"""
if not container:
return None
getter = getattr(container, "get", None)
if callable(getter):
for key in keys:
value = getter(key)
if value is not None:
normalized = cls._coerce_positive_int(value)
if normalized is not None:
return normalized
for key in keys:
value = getattr(container, key, None)
if value is not None:
normalized = cls._coerce_positive_int(value)
if normalized is not None:
return normalized
return None
@classmethod
def _lookup_int(cls, container: Any, *keys: str) -> int | None:
if not container:
@@ -102,158 +65,18 @@ class UsageMiddleware(AgentMiddleware):
@classmethod
def _extract_model_name(cls, model: Any) -> str | None:
for field in ("model", "model_name", "model_id"):
try:
value = getattr(model, field, None)
except Exception:
continue
if value:
return value
return None
return (
getattr(model, "model", None)
or getattr(model, "model_name", None)
or getattr(model, "model_id", None)
)
@classmethod
def _extract_context_window_tokens(cls, model: Any) -> int | None:
try:
profile = getattr(model, "profile", None)
except Exception:
return None
profile = getattr(model, "profile", None)
if not profile:
return None
try:
return cls._lookup_positive_int(
profile, "max_input_tokens", "input_token_limit"
)
except Exception:
return None
@classmethod
def _extract_model_max_output_tokens(cls, model: Any) -> int | None:
"""读取模型输出能力上限;该值不代表单次请求已经预留的输出空间。"""
try:
profile = getattr(model, "profile", None)
except Exception:
return None
if not profile:
return None
try:
return cls._lookup_positive_int(
profile, "max_output_tokens", "output_token_limit"
)
except Exception:
return None
def _next_request_sequence(self) -> int | None:
"""优先使用会话级序号,使图重建后的请求仍保持单调顺序。"""
if callable(self.next_request_sequence):
try:
return self.next_request_sequence()
except Exception as error:
logger.debug(
"分配会话级模型请求序号失败: error_type=%s",
type(error).__name__,
)
# 无法证明顺序的请求仍可累计 usage,但不能参与最近请求快照竞争。
return None
self._request_sequence += 1
return self._request_sequence
@classmethod
def _extract_configured_output_limit_tokens(
cls, request: ModelRequest
) -> int | None:
"""读取最终请求显式配置的单次输出上限。"""
model_settings = request.model_settings or {}
value = cls._lookup_positive_int(
model_settings,
"max_completion_tokens",
"max_tokens",
"max_output_tokens",
)
return value
@staticmethod
def _count_multimodal_blocks(messages: list[Any]) -> tuple[int, int]:
"""统计图片和未知多模态块,不保留块内容或资源地址。"""
image_count = 0
unknown_count = 0
for message in messages:
content = getattr(message, "content", None)
if not isinstance(content, list):
continue
for block in content:
if isinstance(block, str):
continue
if not isinstance(block, dict):
unknown_count += 1
continue
block_type = block.get("type")
if block_type in {"image", "image_url"}:
image_count += 1
elif block_type != "text":
unknown_count += 1
return image_count, unknown_count
@classmethod
def estimate_request(cls, request: ModelRequest) -> dict[str, Any]:
"""估算最终模型输入组成,仅返回可安全暴露的聚合数字。"""
messages = list(request.messages or [])
system_messages = [request.system_message] if request.system_message else []
tools = list(request.tools or [])
message_tokens = count_tokens_approximately(
messages,
use_usage_metadata_scaling=False,
)
system_tokens = count_tokens_approximately(
system_messages,
use_usage_metadata_scaling=False,
)
tool_tokens = count_tokens_approximately(
[],
tools=tools,
use_usage_metadata_scaling=False,
)
estimated_input_tokens = message_tokens + system_tokens + tool_tokens
context_window_tokens = cls._extract_context_window_tokens(request.model)
model_max_output_tokens = cls._extract_model_max_output_tokens(request.model)
configured_output_limit_tokens = cls._extract_configured_output_limit_tokens(
request
)
image_count, unknown_multimodal_count = cls._count_multimodal_blocks(
[*system_messages, *messages]
)
estimated_input_ratio = (
estimated_input_tokens / context_window_tokens
if context_window_tokens
else None
)
return {
"has_estimate": True,
"model": cls._extract_model_name(request.model),
"message_count": len(messages),
"tool_count": len(tools),
"image_count": image_count,
"unknown_multimodal_count": unknown_multimodal_count,
"message_tokens": message_tokens,
"system_tokens": system_tokens,
"tool_tokens": tool_tokens,
# 该成本已经包含在 message_tokens 中,只单独暴露组成,不能再次汇总。
"multimodal_tokens": image_count * 85,
"estimated_input_tokens": estimated_input_tokens,
"context_window_tokens": context_window_tokens,
"estimated_remaining_input_tokens": (
context_window_tokens - estimated_input_tokens
if context_window_tokens
else None
),
"estimated_input_ratio": estimated_input_ratio,
"estimated_over_input_limit": (
estimated_input_tokens > context_window_tokens
if context_window_tokens
else None
),
"model_max_output_tokens": model_max_output_tokens,
"configured_output_limit_tokens": configured_output_limit_tokens,
}
return cls._lookup_int(profile, "max_input_tokens", "input_token_limit")
@classmethod
def _extract_usage(cls, ai_message: AIMessage) -> dict[str, Any]:
@@ -467,7 +290,6 @@ class UsageMiddleware(AgentMiddleware):
cache_miss_tokens,
)
)
input_usage_available = input_tokens is not None
resolved_input = input_tokens or 0
resolved_output = output_tokens or 0
resolved_total = (
@@ -493,7 +315,6 @@ class UsageMiddleware(AgentMiddleware):
return {
"has_usage": has_usage,
"input_usage_available": input_usage_available,
"cache_usage_available": has_cache_usage,
"input_tokens": resolved_input,
"output_tokens": resolved_output,
@@ -511,39 +332,6 @@ class UsageMiddleware(AgentMiddleware):
[ModelRequest[ContextT]], Awaitable[ModelResponse[ResponseT]]
],
) -> ModelResponse[ResponseT]:
request_sequence = self._next_request_sequence()
request_budget = None
try:
request_budget = {
"request_sequence": request_sequence,
**self.estimate_request(request),
}
except Exception as error:
logger.debug(
"估算最终模型请求预算失败: error_type=%s",
type(error).__name__,
)
request_budget = {
"request_sequence": request_sequence,
"has_estimate": False,
"model": self._extract_model_name(request.model),
"context_window_tokens": self._extract_context_window_tokens(
request.model
),
}
if callable(self.on_request_budget):
request_budget_recorded = False
try:
self.on_request_budget(request_budget)
request_budget_recorded = True
except Exception as error:
logger.debug(
"记录最终模型请求预算失败: error_type=%s",
type(error).__name__,
)
else:
request_budget_recorded = False
response = await handler(request)
if not callable(self.on_usage):
@@ -563,7 +351,6 @@ class UsageMiddleware(AgentMiddleware):
if ai_message
else {
"has_usage": False,
"input_usage_available": False,
"cache_usage_available": False,
"input_tokens": 0,
"output_tokens": 0,
@@ -576,18 +363,11 @@ class UsageMiddleware(AgentMiddleware):
)
context_window_tokens = self._extract_context_window_tokens(request.model)
context_usage_ratio = None
if context_window_tokens and usage["input_usage_available"]:
if context_window_tokens and usage["has_usage"]:
context_usage_ratio = usage["input_tokens"] / context_window_tokens
self.on_usage(
{
"request_sequence": request_sequence,
"request_budget_recorded": request_budget_recorded,
"estimated_input_tokens": (
request_budget.get("estimated_input_tokens")
if request_budget
else None
),
"model": self._extract_model_name(request.model),
"context_window_tokens": context_window_tokens,
"context_usage_ratio": context_usage_ratio,
File diff suppressed because it is too large Load Diff
-55
View File
@@ -1,55 +0,0 @@
"""MoviePilot Agent 宿主策略公共内部入口。"""
from importlib import import_module
from typing import Any
_EXPORT_MODULES = {
"ActionEffect": "app.agent.policy.contracts",
"ActionPolicy": "app.agent.policy.contracts",
"AuthSource": "app.agent.policy.contracts",
"ConfirmationMode": "app.agent.policy.contracts",
"ExecutionOutcome": "app.agent.policy.contracts",
"ExecutionReceipt": "app.agent.policy.contracts",
"MigrationState": "app.agent.policy.contracts",
"PolicyDecision": "app.agent.policy.contracts",
"PolicyObservation": "app.agent.policy.contracts",
"PolicyPrincipal": "app.agent.policy.contracts",
"PrincipalRole": "app.agent.policy.contracts",
"PrincipalType": "app.agent.policy.contracts",
"RecoveryMode": "app.agent.policy.contracts",
"ResultSensitivity": "app.agent.policy.contracts",
"ToolInvocation": "app.agent.policy.contracts",
"ToolOrigin": "app.agent.policy.contracts",
"ToolPolicyContext": "app.agent.policy.contracts",
"ToolRevision": "app.agent.policy.contracts",
"AgentToolPolicyOrchestrator": "app.agent.policy.orchestrator",
"DEFAULT_TOOL_POLICY_ORCHESTRATOR": "app.agent.policy.orchestrator",
"call_policy_hook": "app.agent.policy.orchestrator",
"DEFAULT_TOOL_POLICY_REGISTRY": "app.agent.policy.registry",
"ToolPolicyRegistry": "app.agent.policy.registry",
"REDACTED_VALUE": "app.agent.policy.sanitizer",
"sanitize_for_host": "app.agent.policy.sanitizer",
"stable_type_name": "app.agent.policy.sanitizer",
"summarize_error": "app.agent.policy.sanitizer",
"summarize_input": "app.agent.policy.sanitizer",
"summarize_result": "app.agent.policy.sanitizer",
}
def __getattr__(name: str) -> Any:
"""首次访问公开策略对象时只加载其所属模块。"""
module_name = _EXPORT_MODULES.get(name)
if module_name is None:
raise AttributeError(f"module 'app.agent.policy' has no attribute {name!r}")
value = getattr(import_module(module_name), name)
globals()[name] = value
return value
def __dir__() -> list[str]:
"""让惰性公开对象继续支持交互式发现。"""
return sorted(set(globals()) | set(_EXPORT_MODULES))
__all__ = list(_EXPORT_MODULES)
-259
View File
@@ -1,259 +0,0 @@
"""MoviePilot Agent 宿主策略的内部契约。"""
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Mapping, MutableMapping, Optional
class ToolOrigin(str, Enum):
"""工具调用的宿主可信入口。"""
AGENT_INTERACTIVE = "agent_interactive"
AGENT_API = "agent_api"
OPERATOR_DIRECT = "operator_direct"
BACKGROUND = "background"
SUBAGENT = "subagent"
class PrincipalType(str, Enum):
"""调用主体类型,用于区分人、管理员集成和内部运行时。"""
HUMAN = "human"
SYSTEM_ADMIN_INTEGRATION = "system_admin_integration"
SCOPED_AGENT = "scoped_agent"
BACKGROUND = "background"
SUBAGENT = "subagent"
class AuthSource(str, Enum):
"""主体身份的宿主认证来源。"""
CHANNEL = "channel"
WEB_SESSION = "web_session"
API_TOKEN = "api_token"
INTERNAL = "internal"
AGENT_TOKEN = "agent_token"
class PrincipalRole(str, Enum):
"""策略授权使用的角色层级。"""
USER = "user"
CHANNEL_ADMIN = "channel_admin"
SYSTEM_ADMIN = "system_admin"
SYSTEM_INTERNAL = "system_internal"
class ActionEffect(str, Enum):
"""工具调用的实际副作用类别。"""
SAFE_READ = "safe_read"
SENSITIVE_READ = "sensitive_read"
REVERSIBLE_WRITE = "reversible_write"
DESTRUCTIVE_WRITE = "destructive_write"
EXTERNAL_SIDE_EFFECT = "external_side_effect"
ARBITRARY_EXECUTION = "arbitrary_execution"
UNKNOWN = "unknown"
class ConfirmationMode(str, Enum):
"""动作在完成授权后所需的确认方式。"""
NONE = "none"
REQUIRED = "required"
UNSUPPORTED = "unsupported"
class RecoveryMode(str, Enum):
"""动作可提供的执行恢复保证。"""
NONE = "none"
TRANSACTION = "transaction"
BEFORE_STATE = "before_state"
RECOVERABLE_DELETE = "recoverable_delete"
IDEMPOTENT = "idempotent"
RECONCILE = "reconcile"
MANUAL_ONLY = "manual_only"
class ResultSensitivity(str, Enum):
"""工具结果进入模型、记忆和日志时的敏感等级。"""
NORMAL = "normal"
PRIVATE = "private"
SECRET = "secret"
UNKNOWN = "unknown"
class MigrationState(str, Enum):
"""工具策略当前采用宿主强制还是兼容观测。"""
ENFORCED = "enforced"
LEGACY_SHADOW = "legacy_shadow"
class ExecutionOutcome(str, Enum):
"""工具 handler 观测终态;成功不代表业务授权或副作用已完成。"""
SUCCEEDED = "succeeded"
FAILED = "failed"
@dataclass(frozen=True)
class PolicyPrincipal:
"""由可信入口建立、不可由工具参数覆盖的调用主体。"""
principal_id: str
principal_type: PrincipalType
auth_source: AuthSource
role: PrincipalRole
scopes: tuple[str, ...] = ()
@dataclass(frozen=True)
class ToolInvocation:
"""一次进入宿主策略层的规范化工具调用。"""
invocation_id: str
tool_name: str
arguments: Mapping[str, Any]
principal: PolicyPrincipal
session_id: str
origin: ToolOrigin
channel: Optional[str] = None
source: Optional[str] = None
@dataclass(frozen=True)
class ToolRevision:
"""记录目录项绑定的工具实现、工厂和插件目录版本。"""
implementation: str
factory: str
plugin: str
@dataclass(frozen=True)
class ActionPolicy:
"""参数级动作策略及其兼容迁移状态。"""
effect: ActionEffect
required_role: PrincipalRole
confirmation: ConfirmationMode
recovery: RecoveryMode
result_sensitivity: ResultSensitivity
migration_state: MigrationState
policy_version: str = "p1-g1-v1"
interactive_allowed: bool = True
machine_allowed: bool = True
background_allowed: bool = True
subagent_allowed: bool = True
@dataclass(frozen=True)
class PolicyDecision:
"""宿主策略层决定;shadow allow 仅表示新策略不拦截,旧门禁仍是授权事实源。"""
allowed: bool
confirmation_required: bool
shadow: bool
reason_code: str
@dataclass(frozen=True)
class PolicyObservation:
"""调用开始时生成、供完成或失败回执复用的观测对象。"""
invocation: ToolInvocation
policy: ActionPolicy
decision: PolicyDecision
input_summary: str
started_at: float
@dataclass(frozen=True)
class ExecutionReceipt:
"""工具策略生命周期生成的非持久化脱敏回执。"""
invocation_id: str
tool_name: str
origin: ToolOrigin
decision: PolicyDecision
outcome: ExecutionOutcome
input_summary: str
result_summary: Optional[str] = None
error_summary: Optional[str] = None
duration_ms: int = 0
external_may_continue: bool = False # 中断后外部操作仍可能继续,不能视为已停止。
needs_reconcile: bool = False # 调用方需查询外部实际状态后再决定补偿或重试。
@dataclass(frozen=True)
class ToolPolicyContext:
"""宿主入口上下文;管理员状态引用会随缓存图的每轮执行刷新。"""
session_id: str
user_id: str
origin: ToolOrigin
principal_type: PrincipalType
auth_source: AuthSource
agent_context: MutableMapping[str, Any] = field(repr=False, compare=False)
channel: Optional[str] = None
source: Optional[str] = None
@property
def principal(self) -> PolicyPrincipal:
"""根据当前宿主上下文生成本次调用主体。"""
if self.principal_type in {PrincipalType.BACKGROUND, PrincipalType.SUBAGENT}:
default_role = PrincipalRole.SYSTEM_INTERNAL
else:
default_role = PrincipalRole.USER
role = (
PrincipalRole.SYSTEM_ADMIN
if bool(self.agent_context.get("is_admin"))
else default_role
)
raw_scopes = self.agent_context.get("policy_scopes") or ()
scopes = tuple(str(scope) for scope in raw_scopes if scope)
return PolicyPrincipal(
principal_id=str(self.user_id or self.principal_type.value),
principal_type=self.principal_type,
auth_source=self.auth_source,
role=role,
scopes=scopes,
)
def for_subagent(self) -> "ToolPolicyContext":
"""保留用户与会话归属,并切换为子代理可信来源。"""
return ToolPolicyContext(
session_id=self.session_id,
user_id=self.user_id,
origin=ToolOrigin.SUBAGENT,
principal_type=PrincipalType.SUBAGENT,
auth_source=AuthSource.INTERNAL,
agent_context=self.agent_context,
channel=self.channel,
source=self.source,
)
__all__ = [
"ActionEffect",
"ActionPolicy",
"AuthSource",
"ConfirmationMode",
"ExecutionOutcome",
"ExecutionReceipt",
"MigrationState",
"PolicyDecision",
"PolicyObservation",
"PolicyPrincipal",
"PrincipalRole",
"PrincipalType",
"RecoveryMode",
"ResultSensitivity",
"ToolInvocation",
"ToolOrigin",
"ToolPolicyContext",
"ToolRevision",
]
-229
View File
@@ -1,229 +0,0 @@
"""Agent 工具策略观测、脱敏回执与共享执行边界。"""
import asyncio
import time
import uuid
from collections.abc import Callable
from typing import Any, Mapping, Optional, TypeVar
from langchain_core.messages import ToolMessage
from pydantic import ValidationError
from app.agent.policy.contracts import (
ActionEffect,
ConfirmationMode,
ExecutionOutcome,
ExecutionReceipt,
MigrationState,
PolicyDecision,
PolicyObservation,
RecoveryMode,
ToolInvocation,
ToolPolicyContext,
)
from app.agent.policy.registry import DEFAULT_TOOL_POLICY_REGISTRY, ToolPolicyRegistry
from app.agent.policy.sanitizer import (
stable_type_name,
summarize_error,
summarize_input,
summarize_result,
)
from app.runtime.log import logger
_HookResult = TypeVar("_HookResult")
def call_policy_hook(
phase: str,
hook: Callable[..., _HookResult],
*args: Any,
**kwargs: Any,
) -> Optional[_HookResult]:
"""以 fail-open 方式调用兼容观测 hook,故障只记录稳定类型。"""
try:
return hook(*args, **kwargs)
except Exception as error:
try:
logger.warning(
f"Agent工具策略观测失败: phase={phase}, "
f"error_type={stable_type_name(error)}"
)
except Exception:
pass
return None
def _normalize_policy_arguments(tool: Any, arguments: Mapping[str, Any]) -> dict[str, Any]:
"""为策略生成 Pydantic 规范化副本,不改变真实执行参数。"""
raw_arguments = dict(arguments or {})
args_schema = getattr(tool, "args_schema", None)
if not args_schema:
return raw_arguments
try:
validated = args_schema.model_validate(raw_arguments)
return validated.model_dump(mode="json")
except (AttributeError, TypeError, ValueError, ValidationError):
# 实际 handler 仍负责既有参数错误语义;策略观测按原始值保守处理。
return raw_arguments
def _result_payload(result: Any) -> Any:
"""从 LangChain 工具消息中提取模型可见结果供脱敏摘要使用。"""
if isinstance(result, ToolMessage):
return result.content
return result
class AgentToolPolicyOrchestrator:
"""让 Agent middleware 与 direct manager 复用同一策略生命周期。"""
def __init__(self, registry: ToolPolicyRegistry = DEFAULT_TOOL_POLICY_REGISTRY) -> None:
"""绑定工具策略解析表。"""
self.registry = registry
def start(
self,
*,
context: ToolPolicyContext,
tool: Any,
arguments: Mapping[str, Any],
invocation_id: Optional[str] = None,
) -> PolicyObservation:
"""解析调用策略,并创建不影响现有 allow 行为的观测对象。"""
tool_name = str(getattr(tool, "name", None) or "unknown_tool")
normalized_arguments = _normalize_policy_arguments(tool, arguments)
policy = self.registry.resolve(
tool_name=tool_name,
arguments=normalized_arguments,
requires_admin=bool(getattr(tool, "_require_admin", False)),
)
if policy.migration_state is MigrationState.LEGACY_SHADOW:
decision = PolicyDecision(
allowed=True,
confirmation_required=False,
shadow=True,
reason_code="legacy_shadow_allow",
)
elif policy.confirmation is ConfirmationMode.REQUIRED:
# 通用编排器保持 shadow;支持的 Agent 入口会在 ToolNode 前独立完成确认。
decision = PolicyDecision(
allowed=True,
confirmation_required=False,
shadow=True,
reason_code="confirmation_policy_shadow_allow",
)
else:
decision = PolicyDecision(
allowed=True,
confirmation_required=False,
shadow=False,
reason_code="safe_read_allow",
)
invocation = ToolInvocation(
invocation_id=invocation_id or uuid.uuid4().hex,
tool_name=tool_name,
arguments=normalized_arguments,
principal=context.principal,
session_id=context.session_id,
origin=context.origin,
channel=context.channel,
source=context.source,
)
input_summary = summarize_input(normalized_arguments)
observation = PolicyObservation(
invocation=invocation,
policy=policy,
decision=decision,
input_summary=input_summary,
started_at=time.monotonic(),
)
logger.debug(
f"Agent工具策略: tool={tool_name}, origin={context.origin.value}, "
f"decision={decision.reason_code}, input={input_summary}"
)
return observation
@staticmethod
def finish(observation: PolicyObservation, result: Any) -> ExecutionReceipt:
"""生成成功回执 envelope,并只记录脱敏结果摘要。"""
result_summary = summarize_result(_result_payload(result))
receipt = ExecutionReceipt(
invocation_id=observation.invocation.invocation_id,
tool_name=observation.invocation.tool_name,
origin=observation.invocation.origin,
decision=observation.decision,
outcome=ExecutionOutcome.SUCCEEDED,
input_summary=observation.input_summary,
result_summary=result_summary,
duration_ms=max(
0,
int((time.monotonic() - observation.started_at) * 1000),
),
)
logger.info(
f"Agent工具执行完成: tool={receipt.tool_name}, "
f"origin={receipt.origin.value}, shadow={receipt.decision.shadow}, "
f"duration_ms={receipt.duration_ms}, result={result_summary}"
)
return receipt
@staticmethod
def _uncertain_external_state(
observation: PolicyObservation,
error: BaseException,
) -> tuple[bool, bool]:
"""标记取消或超时后无法确认的写操作终态。"""
interrupted = isinstance(error, (asyncio.CancelledError, TimeoutError))
read_only = observation.policy.effect in {
ActionEffect.SAFE_READ,
ActionEffect.SENSITIVE_READ,
}
if not interrupted or read_only:
return False, False
needs_reconcile = observation.policy.recovery not in {
RecoveryMode.TRANSACTION,
RecoveryMode.IDEMPOTENT,
}
return True, needs_reconcile
@staticmethod
def fail(observation: PolicyObservation, error: BaseException) -> ExecutionReceipt:
"""生成失败回执 envelope,不把异常中的凭据写入日志。"""
error_summary = summarize_error(error)
external_may_continue, needs_reconcile = (
AgentToolPolicyOrchestrator._uncertain_external_state(observation, error)
)
receipt = ExecutionReceipt(
invocation_id=observation.invocation.invocation_id,
tool_name=observation.invocation.tool_name,
origin=observation.invocation.origin,
decision=observation.decision,
outcome=ExecutionOutcome.FAILED,
input_summary=observation.input_summary,
error_summary=error_summary,
duration_ms=max(
0,
int((time.monotonic() - observation.started_at) * 1000),
),
external_may_continue=external_may_continue,
needs_reconcile=needs_reconcile,
)
logger.error(
f"Agent工具执行失败: tool={receipt.tool_name}, "
f"origin={receipt.origin.value}, shadow={receipt.decision.shadow}, "
f"duration_ms={receipt.duration_ms}, error={error_summary}, "
f"external_may_continue={receipt.external_may_continue}, "
f"needs_reconcile={receipt.needs_reconcile}"
)
return receipt
DEFAULT_TOOL_POLICY_ORCHESTRATOR = AgentToolPolicyOrchestrator()
__all__ = [
"AgentToolPolicyOrchestrator",
"DEFAULT_TOOL_POLICY_ORCHESTRATOR",
"call_policy_hook",
]
-201
View File
@@ -1,201 +0,0 @@
"""工具策略例外与参数级策略解析。"""
from typing import Any, Mapping
from app.agent.policy.contracts import (
ActionEffect,
ActionPolicy,
ConfirmationMode,
MigrationState,
PrincipalRole,
RecoveryMode,
ResultSensitivity,
)
# 这些非管理员读取在运行时解析为强制 SAFE_READ;管理员门禁仍沿用原有授权事实源。
SAFE_READ_TOOL_NAMES = frozenset(
{
"list_slash_commands",
"query_installed_plugins",
"query_personas",
"query_schedulers",
"query_workflows",
}
)
# 该清单只校验固定工具 inventory;未命中的固定或动态工具同样默认 LEGACY_SHADOW。
BUILTIN_LEGACY_SHADOW_INVENTORY = frozenset(
{
"add_custom_filter_rule",
"add_download_tasks",
"add_rule_group",
"add_subscribe",
"apply_patch",
"ask_user_choice",
"browse_webpage",
"create_agent_task",
"delete_agent_task",
"delete_custom_filter_rule",
"delete_download_history",
"delete_download_tasks",
"delete_rule_group",
"delete_subscribe",
"delete_transfer_history",
"edit_file",
"execute_command",
"get_recommendations",
"get_search_results",
"install_plugin",
"list_directory",
"query_agent_tasks",
"query_builtin_filter_rules",
"query_custom_filter_rules",
"query_custom_identifiers",
"query_directory_settings",
"query_doctor_report",
"query_download_tasks",
"query_downloaders",
"query_episode_schedule",
"query_library_exists",
"query_library_latest",
"query_market_plugins",
"query_media_detail",
"query_plugin_capabilities",
"query_plugin_config",
"query_plugin_data",
"query_popular_subscribes",
"query_rule_groups",
"query_site_userdata",
"query_sites",
"query_subscribe_history",
"query_subscribe_shares",
"query_subscribes",
"query_system_settings",
"query_transfer_history",
"read_file",
"recognize_captcha",
"recognize_media",
"reload_plugin",
"run_agent_task",
"run_scheduler",
"run_slash_command",
"run_workflow",
"scrape_metadata",
"search_media",
"search_person",
"search_person_credits",
"search_subscribe",
"search_torrents",
"search_web",
"send_local_file",
"send_message",
"send_voice_message",
"switch_persona",
"test_site",
"transfer_file",
"uninstall_plugin",
"update_agent_task",
"update_custom_filter_rule",
"update_custom_identifiers",
"update_download_tasks",
"update_persona_definition",
"update_plugin_config",
"update_rule_group",
"update_site",
"update_site_cookie",
"update_subscribe",
"update_system_settings",
"write_file",
}
)
class ToolPolicyRegistry:
"""解析固定和动态工具的宿主策略。"""
def __init__(
self,
*,
safe_read_tool_names: frozenset[str] = SAFE_READ_TOOL_NAMES,
builtin_legacy_shadow_inventory: frozenset[str] = (
BUILTIN_LEGACY_SHADOW_INVENTORY
),
) -> None:
"""建立 SAFE_READ 例外与固定工具 inventory。"""
overlap = safe_read_tool_names & builtin_legacy_shadow_inventory
if overlap:
raise ValueError(f"工具策略 inventory 存在重复项: {sorted(overlap)}")
self._safe_read_tool_names = safe_read_tool_names
self._builtin_legacy_shadow_inventory = builtin_legacy_shadow_inventory
@property
def builtin_tool_inventory(self) -> set[str]:
"""返回用于测试校验的固定工具 inventory。"""
return set(
self._safe_read_tool_names | self._builtin_legacy_shadow_inventory
)
def resolve(
self,
*,
tool_name: str,
arguments: Mapping[str, Any],
requires_admin: bool,
) -> ActionPolicy:
"""根据工具名和宿主权限声明解析当前参数级策略。"""
required_role = (
PrincipalRole.SYSTEM_ADMIN if requires_admin else PrincipalRole.USER
)
if (
tool_name == "query_system_settings"
and arguments.get("show_secrets") is True
):
return ActionPolicy(
effect=ActionEffect.SENSITIVE_READ,
required_role=PrincipalRole.SYSTEM_ADMIN,
confirmation=ConfirmationMode.REQUIRED,
recovery=RecoveryMode.NONE,
result_sensitivity=ResultSensitivity.SECRET,
migration_state=MigrationState.ENFORCED,
policy_version="p1-g2a2-v1",
machine_allowed=True,
background_allowed=False,
subagent_allowed=False,
)
if tool_name in self._safe_read_tool_names:
return ActionPolicy(
effect=ActionEffect.SAFE_READ,
required_role=required_role,
confirmation=ConfirmationMode.NONE,
recovery=RecoveryMode.NONE,
result_sensitivity=ResultSensitivity.NORMAL,
# 角色门禁仍由既有授权事实源判断,管理员读取保持兼容观测。
migration_state=(
MigrationState.LEGACY_SHADOW
if requires_admin
else MigrationState.ENFORCED
),
)
# 除明确例外外,固定和动态工具都保持现有能力,但不得被视为安全读取。
return ActionPolicy(
effect=ActionEffect.UNKNOWN,
required_role=required_role,
confirmation=ConfirmationMode.REQUIRED,
recovery=RecoveryMode.MANUAL_ONLY,
result_sensitivity=ResultSensitivity.UNKNOWN,
migration_state=MigrationState.LEGACY_SHADOW,
)
DEFAULT_TOOL_POLICY_REGISTRY = ToolPolicyRegistry()
__all__ = [
"BUILTIN_LEGACY_SHADOW_INVENTORY",
"DEFAULT_TOOL_POLICY_REGISTRY",
"SAFE_READ_TOOL_NAMES",
"ToolPolicyRegistry",
]
File diff suppressed because it is too large Load Diff
-69
View File
@@ -1,69 +0,0 @@
"""Agent 设置工具与宿主回执共用的敏感字段身份判定。"""
import re
from typing import Any
_MAX_FIELD_NAME_CHARS = 256
_ACRONYM_BOUNDARY_PATTERN = re.compile(r"(?<=[A-Z])(?=[A-Z][a-z])")
_CAMEL_CASE_BOUNDARY_PATTERN = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
_SECRET_FIELD_NAMES = frozenset(
{
"access_token",
"api_key",
"apikey",
"api_token",
"auth_header",
"authorization",
"client_secret",
"cookie",
"passkey",
"passwd",
"password",
"private_key",
"pwd",
"refresh_token",
"secret",
"secret_access_key",
"secret_key",
"token",
}
)
_SECRET_FIELD_ENDINGS = tuple(f"_{name}" for name in _SECRET_FIELD_NAMES)
_SECRET_SETTING_NAMES = frozenset(
{
# CookieCloud 的用户 key 没有类型后缀,但与密码共同构成端到端加密凭据。
"cookiecloud_key",
}
)
_SECRET_SETTING_ENDINGS = (
"_encrypt_key",
)
def _normalize_field_name(value: Any) -> str:
"""将短字段名规范化为 snake_case,非字符串不参与身份推导。"""
if type(value) is not str:
return ""
text = value.strip()
if len(text) > _MAX_FIELD_NAME_CHARS:
text = text[-_MAX_FIELD_NAME_CHARS:]
text = _ACRONYM_BOUNDARY_PATTERN.sub("_", text)
text = _CAMEL_CASE_BOUNDARY_PATTERN.sub("_", text)
return re.sub(r"[^a-z0-9]+", "_", text.lower()).strip("_")
def is_secret_setting_key(key: Any) -> bool:
"""按完整字段或类型后缀识别凭据,避免误伤 token 统计与过期配置。"""
normalized = _normalize_field_name(key)
if not normalized:
return False
return (
normalized in _SECRET_FIELD_NAMES
or normalized in _SECRET_SETTING_NAMES
or normalized.endswith(_SECRET_FIELD_ENDINGS)
or normalized.endswith(_SECRET_SETTING_ENDINGS)
)
__all__ = ["is_secret_setting_key"]
+3 -12
View File
@@ -21,7 +21,6 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
<confirmation_policy>
- Do not stop for approval on read-only operations.
- Raw secret reads are protected operations rather than ordinary read-only queries. When a user explicitly asks for a raw credential or another unredacted sensitive setting, call `query_system_settings` with `show_secrets=true`; do not refuse the request solely because the value is sensitive. The host verifies administrator authority, obtains any required confirmation, and delivers the result through a protected channel. Never expose or repeat the secret in an ordinary assistant response, tool narration, or follow-up model context.
- If the user has not explicitly requested an operation that changes system behavior, ask for confirmation before proceeding. This includes modifying system settings, updating plugin configuration, reloading plugins, running restart/stop/start commands, or triggering slash commands such as `/restart`.
- Always get explicit consent before destructive or high-impact actions such as starting downloads, deleting subscriptions, deleting download tasks or files, removing history, installing/uninstalling plugins, changing site authentication, changing scheduler or workflow execution state, restarting services, or stopping services.
- When the user explicitly asks for delayed, recurring, reminder, or monitoring work, use `create_agent_task` instead of promising to remember it or writing a JOB.md file. Use a `date` trigger with `delay_minutes` for requests such as "in 30 minutes", an exact `date` trigger for other single future runs, and a five-field `cron` trigger for recurring work. Manage existing autonomous tasks with `query_agent_tasks`, `update_agent_task`, `run_agent_task`, and `delete_agent_task`; these tools use integer `task_id` values. Use `query_schedulers` and `run_scheduler` only for MoviePilot system, plugin, or workflow runtime services, whose string `job_id` values must never be passed to autonomous-task tools.
@@ -31,7 +30,7 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
<moviepilot_domain_model>
- Treat sites as a first-class system capability, not background detail. In MoviePilot, sites are the upstream source for search, account status, authentication, and many download or subscription decisions.
- Understand the platform's core workflow as: site availability and configuration -> media search -> media recognition/metadata confirmation -> manual download or subscription -> transfer and library organization -> metadata scraping (including configured music lyrics) -> status/history confirmation.
- Understand the platform's core workflow as: site availability and configuration -> media search -> media recognition/metadata confirmation -> manual download or subscription -> transfer and library organization -> status/history confirmation.
- Treat manual download and subscription automation as two execution modes of the same acquisition pipeline. Manual download is user-triggered immediate acquisition; subscription is persistent site-driven monitoring and acquisition.
- Keep the user anchored to the operational step that matters now: site, search, recognition, download, subscription, transfer, or status/history.
- Users may attach images from supported channels; analyze them together with the text when relevant.
@@ -49,7 +48,7 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
<core_workflow>
1. Site and Context Check: Determine whether site status, site scope, library state, existing subscriptions, or prior download/transfer history can affect the task.
2. Media Identity Resolution: Confirm an exact source-native identity and pass it only as the fixed `media_source` enum plus `media_id`. Video and music share this pair; music also uses `media_type=music` and `music_type=recording|album|artist`. Use `search_media`, `query_media_detail`, or `recognize_media` as needed.
2. Media Identity Resolution: Confirm exact media identity such as TMDB ID, title, year, type, season, or episode using `search_media`, `query_media_detail`, or `recognize_media` as needed.
3. Resource Discovery: Use the appropriate search path for the task. For manual acquisition, search site resources and inspect result quality. For automation, prepare subscription conditions that will search sites continuously.
4. Action Execution: Perform the requested task, typically one of: test/query site, search torrents, add download, add or modify subscription, or transfer and organize files.
5. Final Confirmation: State the outcome briefly, including the key media facts, chosen site or resource scope when relevant, and the next blocker if the task could not be completed.
@@ -67,7 +66,7 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
- If torrent search yields no useful result, check site scope, site health, and recognition quality before concluding that the resource is unavailable.
- Reuse the latest torrent search cache for `get_search_results` and `add_download_tasks` instead of re-running the same search unnecessarily.
- For administrator code discovery across local files, use `execute_command(action="run")` with `rg` and narrow globs or paths; large searches may be split with narrower globs, paths, or `rg --files` filters. Use `list_directory` to inspect one known directory or a supported remote storage backend; request its `limit`/`offset` page fields when more than the first page is needed, and use `read_file` when the exact local file is known. If `read_file` reports truncation, continue with smaller `start_line` and `end_line` ranges instead of assuming the file ended.
- Read the relevant file before changing it, then pick the editing tool by scope. Use `apply_patch` when one logical change spans multiple files, adds new files, or deletes files: submit a single patch wrapped in `*** Begin Patch` / `*** End Patch` with `*** Add File:`, `*** Update File:`, and `*** Delete File:` sections; every context and removed line must match the current content exactly, and the whole patch is validated before any file is written. Use `edit_file` for a single localized exact replacement within one already-read file; make `old_text` unique with enough surrounding context, and use `replace_all=true` only when every match must change. Use `write_file` for one standalone new file; set `overwrite=true` only for an intentional full rewrite, and use `read_file(include_metadata=true)` plus `expected_sha256` when preserving the previously read version matters.
- Read the relevant file before changing it. Use `edit_file` for localized exact replacements; make `old_text` unique with enough surrounding context, and use `replace_all=true` only when every match must change. Use `write_file` for new files; set `overwrite=true` only for an intentional full rewrite, and use `read_file(include_metadata=true)` plus `expected_sha256` when preserving the previously read version matters.
- When implementation depends on a Python or Node.js API, first identify the installed or locked dependency version from environment metadata, requirements, package manifests, lockfiles, local source, and type declarations. Use `rg` against the relevant package directory, `.venv`, or `node_modules` instead of scanning the entire project without bounds. If local evidence is insufficient, use `search_web` and then `browse_webpage` to read the matching version of the official documentation. Do not guess signatures from memory, mix examples from incompatible versions, or install a package only to inspect its API.
- Use structured file tools for source edits because they enforce file access boundaries and conflict checks. Never use shell redirection, inline scripts, or another tool to bypass a file-tool permission denial.
- Use `execute_command` for administrator-only multi-file diagnostics, tests, Git, service operations, SSH, or an exact command the user requested. Use `action=run` for short bounded commands. Use `action=start` for long-running or interactive commands, including SSH; then continue with `read`, `wait`, `write`, or `kill` using the returned `session_id`. Do not start a background session for a short command that can finish within `action=run`.
@@ -82,13 +81,6 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
6. Transfer Awareness: If the user asks about downloaded files landing in the library, include transfer or organization state in the reasoning, not just download completion.
7. Error Handling: If a tool or site fails, briefly explain what went wrong and suggest an alternative or the next best operational step.
8. TV Subscription Rule: When calling `add_subscribe` for a TV show, omitting `season` means subscribe to season 1 only. To subscribe multiple seasons or the full series, call `add_subscribe` separately for each season.
9. Music Entity Rule: A recording is one track, an album is a collection of tracks, and an artist is browse-only. Never subscribe, search torrents for, download, transfer, or library-check an artist entity.
10. Music Identity Rule: Reuse the exact `media_source`, `media_id`, and `music_type` returned by music search/detail. Do not substitute a same-name track, album, or artist, and do not send TV-only season/episode arguments for music.
11. Recording Rule: A recording subscription or download targets one track. Use the recording ID and organize/scrape one audio file unless the user explicitly selected a different recording.
12. Album Rule: An album subscription or download targets the complete album, similar to a TV season pack. Use the album ID, prefer one album resource/directory, require the resource file list to cover the expected `total_tracks`, and do not mark the subscription complete or report the album as present when only some tracks exist.
13. Music Library Rule: Before a music download or subscription, use `query_library_exists` with the exact recording/album identity when duplicate risk matters. For albums, a negative result can mean either absent or incomplete and should remain eligible for acquisition.
14. Music Organization Rule: Organize one recording as one audio file with `music_type=recording`. Organize a complete album by passing its album directory once with `media_type=music`, `music_type=album`, and the album source-native identity; do not repeatedly identify every track as an unrelated media item.
15. Music Scraping Rule: `scrape_metadata(media_type="music")` applies configured audio-tag, cover, and lyrics policies. For an album directory, pass the album identity when known; without one, allow per-file tag recognition. Report the actual lyrics saved/existing/missing counts returned by the tool.
</media_rules>
</agent_core>
@@ -105,7 +97,6 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
- Channel-aware formatting: Follow the capability rules below for Markdown, plain text, buttons, and voice replies.
{button_choice_spec}
- Voice replies: {voice_reply_spec}
{rich_message_spec}
- If the current channel supports image sending and an image would materially help, you may use the `send_message` tool with `image_url` to send it.
- If the current channel supports file sending and you need to return a local image or file for the user to download, use `send_local_file`.
</communication_runtime>
+14 -12
View File
@@ -40,13 +40,13 @@ task_types:
- "Analyze the error message to determine the best retry strategy."
- "If the source file no longer exists, skip this retry and report that the file is missing."
- "Delete the failed history record using `delete_transfer_history` with history_id={history_id}."
- "Re-identify the media using `recognize_media` with the source file path. For audio files, set media_type='music' and preserve artist/title/album context."
- "If recognition fails, try `search_media` with keywords from the filename. For music, distinguish recording, album, and browse-only artist results."
- "Re-transfer using `transfer_file` with the source path and exact identity fields. Reuse media_source + media_id for every media type, plus media_type + music_type for music."
- "Re-identify the media using `recognize_media` with the source file path."
- "If recognition fails, try `search_media` with keywords from the filename."
- "Re-transfer using `transfer_file` with the source path and any identified media info such as tmdbid and media_type."
- "Report the final result."
batch_transfer_failed_retry:
header: "[System Task - Batch Transfer Failed Retry]"
objective: "Multiple file transfers have failed. Group only records that share a trustworthy movie, series, recording, or album identity, then use the `transfer-failed-retry` skill to retry them efficiently."
objective: "Multiple file transfers from the same source have failed. These files likely belong to the same media. Please use the `transfer-failed-retry` skill to retry them efficiently."
context_title: "Task context"
context_lines:
- "Failed transfer history record IDs: {history_ids_csv}"
@@ -54,12 +54,12 @@ task_types:
steps_title: "Follow these steps"
steps:
- "Use `query_transfer_history` with status='failed' to find all records with these IDs and understand the failure details."
- "Group records by exact media identity and source directory before retrying. Do not assume all selected files belong to one media."
- "If the error is about media recognition, identify each group once using `recognize_media` or `search_media`, then reuse that result inside the group. Album tracks should normally be retried from the shared album directory with the album identity."
- "Analyze the first record to determine the shared media identity and the best retry strategy because the root cause is usually the same for all files."
- "If the error is about media recognition, identify the media once using `recognize_media` or `search_media`, then reuse that result for all files."
- "For each failed record, delete the old history entry with `delete_transfer_history` and re-transfer using `transfer_file`."
- "Report how many retries succeeded and how many still failed."
task_rules:
- "Within one verified group, do NOT call `recognize_media` or `search_media` repeatedly for each file. A music recording is one track; a music album is one multi-track directory; an artist is never a transfer target."
- "These files share the same media identity. Do NOT call `recognize_media` or `search_media` repeatedly for each file."
manual_transfer_redo:
header: "[System Task - Manual Transfer Re-Organize]"
objective: "A user manually triggered an AI re-organize task from the transfer history page."
@@ -77,10 +77,12 @@ task_types:
- "- Destination path: {destination_path}"
- "- Destination storage: {destination_storage}"
- "- Transfer mode: {transfer_mode}"
- "- Current TMDB ID: {tmdbid}"
- "- Current Douban ID: {doubanid}"
- "- Current Bangumi ID: {bangumiid}"
- "- Current AniList ID: {anilistid}"
- "- Current media source: {media_source}"
- "- Current source-native ID: {media_id}"
- "- Music entity type: {music_type}"
- "- Expected album tracks: {total_tracks}"
- "- Error message: {error_message}"
steps_title: "Required workflow"
steps:
@@ -92,7 +94,7 @@ task_types:
- "Only continue when you have high confidence in the target media."
- "Before re-organizing, delete the old transfer history record with `delete_transfer_history` so the system will not skip the source file."
- "Then use `transfer_file` to organize the source path directly."
- "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, media_source, media_id, media_type, and music_type. For an album, retry the album directory once with the album identity when the records share that directory."
- "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, all known media IDs, media_source, media_id, and media_type."
- "If this record is already correct and no re-organize is needed, do not perform destructive actions; simply report that no change is necessary."
task_rules:
- "Do NOT rely on previous chat context. Work only from the record above."
@@ -114,11 +116,11 @@ task_types:
- "Review the selected records below first and group them by likely shared media identity, source directory, or retry strategy when possible."
- "Use the provided record context as the primary source of truth. Call `query_transfer_history` only when you need extra confirmation."
- "For each group, decide whether the current recognition is trustworthy."
- "If multiple records clearly belong to the same movie, series, or music album, identify the media once with `recognize_media` or `search_media`, then reuse that result for the related records. A recording remains a single-track target, and an artist is browse-only."
- "If multiple records clearly belong to the same movie or series, identify the media once with `recognize_media` or `search_media`, then reuse that result for the related records."
- "If a source file no longer exists or cannot be safely processed, skip that record and note the reason."
- "Before re-organizing a record, delete the old transfer history record with `delete_transfer_history` so the system will not skip the source file."
- "Then use `transfer_file` to organize the source path directly."
- "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, media_source, media_id, media_type, and music_type. Prefer one directory transfer for a verified complete album instead of treating each track as an unrelated media item."
- "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, all known media IDs, media_source, media_id, and media_type."
- "If a record is already correct and no re-organize is needed, do not perform destructive actions; simply mark it as skipped."
- "Report only the aggregate outcome, including how many records succeeded, skipped, and failed."
task_rules:
+14 -31
View File
@@ -10,14 +10,15 @@ from typing import Any, Dict, Optional
import yaml
from app.agent.llm.capability import AgentCapabilityManager
from app.runtime.settings import get_runtime_setting
from app.runtime.log import logger
from app.schemas.notification import ChannelCapability
from app.schemas.notification import ChannelCapabilities
from app.schemas.notification import NotificationChannel
from app.schemas.notification import ChannelCapabilityManager
from app.adapters.system.host import SystemUtils
from app.core.config import settings
from app.log import logger
from app.schemas import (
ChannelCapability,
ChannelCapabilities,
MessageChannel,
ChannelCapabilityManager,
)
from app.utils.system import SystemUtils
SYSTEM_TASKS_FILE = "System Tasks.yaml"
SYSTEM_TASKS_SCHEMA_VERSION = 2
@@ -127,7 +128,7 @@ class PromptManager:
markdown_spec = ""
msg_channel = (
next(
(c for c in NotificationChannel if c.value.lower() == channel.lower()), None
(c for c in MessageChannel if c.value.lower() == channel.lower()), None
)
if channel
else None
@@ -138,7 +139,6 @@ class PromptManager:
if caps:
markdown_spec = self._generate_formatting_instructions(caps)
button_choice_spec = self._generate_button_choice_instructions(msg_channel)
rich_message_spec = self._generate_rich_message_instructions(msg_channel)
# MoviePilot系统信息
moviepilot_info = self._get_moviepilot_info()
@@ -150,7 +150,6 @@ class PromptManager:
moviepilot_info=moviepilot_info,
voice_reply_spec=voice_reply_spec,
button_choice_spec=button_choice_spec,
rich_message_spec=rich_message_spec,
)
return base_prompt
@@ -300,9 +299,9 @@ class PromptManager:
def _get_runtime_path_lines() -> list[str]:
"""返回基础系统提示词需要常驻注入的全局运行路径。"""
paths = {
"项目根目录": get_runtime_setting('ROOT_PATH'),
"配置目录": get_runtime_setting('CONFIG_PATH'),
"临时目录": get_runtime_setting('TEMP_PATH'),
"项目根目录": settings.ROOT_PATH,
"配置目录": settings.CONFIG_PATH,
"临时目录": settings.TEMP_PATH,
}
return [f" - {label}: `{path}`" for label, path in paths.items()]
@@ -355,25 +354,9 @@ class PromptManager:
"content as a text fallback and still completes the reply."
)
@staticmethod
def _generate_rich_message_instructions(
channel: NotificationChannel = None,
) -> str:
"""根据渠道生成 Telegram Rich Message 回复提示。"""
if channel != NotificationChannel.Telegram:
return ""
return (
"- Telegram final replies: Prefer the `send_message` tool with its "
"`rich_message` argument. Put the complete reply in that argument using "
"GitHub-style Markdown; headings, lists, tables, blockquotes, code blocks, "
"and links are converted to Telegram Rich Message blocks. Do not also set "
"`message`, `title`, or `image_url` for the same reply. Use a normal plain "
"reply only when the response is very short and has no useful structure."
)
@staticmethod
def _generate_button_choice_instructions(
channel: NotificationChannel = None,
channel: MessageChannel = None,
) -> str:
if (
channel
+8 -9
View File
@@ -17,7 +17,6 @@ def build_manual_redo_template_context(history: Any) -> dict[str, int | str]:
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()
is_music = str(history.type or "") in {"music", "音乐"}
return {
"history_id": history.id,
"current_status": "success" if history.status else "failed",
@@ -31,14 +30,12 @@ def build_manual_redo_template_context(history: Any) -> dict[str, int | str]:
"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",
"bangumiid": history.bangumiid or "none",
"anilistid": history.anilistid or "none",
"media_source": history.media_source or "none",
"media_id": history.media_id or "none",
"music_type": getattr(history, "music_type", None) or (
"unknown" if is_music else "not_applicable"
),
"total_tracks": getattr(history, "total_tracks", None) or (
"unknown" if is_music else "not_applicable"
),
"error_message": history.errmsg or "none",
}
@@ -60,10 +57,12 @@ def format_manual_redo_record_context(history: Any) -> str:
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"- Current Bangumi ID: {context['bangumiid']}",
f"- Current AniList ID: {context['anilistid']}",
f"- Current media source: {context['media_source']}",
f"- Current source-native ID: {context['media_id']}",
f"- Music entity type: {context['music_type']}",
f"- Expected album tracks: {context['total_tracks']}",
f"- Error message: {context['error_message']}",
]
)
+3 -14
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import re
import importlib
import shutil
import threading
import time
@@ -13,8 +12,8 @@ from typing import Any, Iterable, Optional
import yaml
from app.application.configuration import get_runtime_settings
from app.runtime.log import logger
from app.core.config import settings
from app.log import logger
CURRENT_PERSONA_FILE = "CURRENT_PERSONA.md"
SYSTEM_RUNTIME_DIR = "runtime"
@@ -32,16 +31,6 @@ SUBAGENT_SCHEMA_VERSION = 1
DEFAULT_PERSONA_ID = "default"
PERSONA_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
def _default_agent_root_dir() -> Path:
"""从组合根设置服务取得 Agent 目录,导入早期保留旧设置回退。"""
try:
config_path = get_runtime_settings().get("CONFIG_PATH")
except RuntimeError:
legacy_settings = importlib.import_module("app.runtime.config").settings
config_path = legacy_settings.CONFIG_PATH
return Path(config_path) / "agent"
ROOT_LEVEL_RUNTIME_FILES = {
CURRENT_PERSONA_FILE,
}
@@ -242,7 +231,7 @@ class AgentRuntimeManager:
agent_root_dir: Optional[Path] = None,
bundled_defaults_dir: Optional[Path] = None,
) -> None:
self.agent_root_dir = agent_root_dir or _default_agent_root_dir()
self.agent_root_dir = agent_root_dir or (settings.CONFIG_PATH / "agent")
self.runtime_dir = self.agent_root_dir / SYSTEM_RUNTIME_DIR
self.memory_dir = self.agent_root_dir / MEMORY_DIR
self.skills_dir = self.agent_root_dir / SKILLS_DIR
-150
View File
@@ -1,150 +0,0 @@
"""Agent 重量级 canonical 对象的轻量首用入口。"""
from __future__ import annotations
import sys
import threading
from typing import Any
from app.agent.capabilities import (
AGENT_ENTRYPOINT_KIND,
AGENT_MANAGER_CAPABILITY_ID,
AGENT_SERVICE_CAPABILITY_ID,
AGENT_SERVICE_KIND,
MOVIEPILOT_AGENT_TYPE_CAPABILITY_ID,
TOOL_FACTORY_CAPABILITY_ID,
)
from app.agent.capabilities.adapter import (
AgentEntrypointAdapter,
AgentServiceAdapter,
build_agent_capability_registry,
should_run_agent_service,
)
from app.runtime.capabilities.model import CapabilityMaterializationState
from app.runtime.capabilities.runtime import CapabilityRuntime
_runtime_lock = threading.RLock()
_agent_runtime: CapabilityRuntime | None = None
def _build_agent_runtime() -> CapabilityRuntime:
"""装配 Agent Runtime;构建阶段只解析 manifests。"""
return CapabilityRuntime(
build_agent_capability_registry(),
adapters={
AGENT_ENTRYPOINT_KIND: AgentEntrypointAdapter(),
AGENT_SERVICE_KIND: AgentServiceAdapter(),
},
)
def _ensure_runtime() -> CapabilityRuntime:
"""返回进程唯一 Runtime,同进程关闭后不重新创建。"""
global _agent_runtime
with _runtime_lock:
if _agent_runtime is None:
_agent_runtime = _build_agent_runtime()
return _agent_runtime
def _materialize_entrypoint(capability_id: str) -> Any:
"""通过通用 Runtime 完成并发 single-flight 物化,不声明资源运行态。"""
return _ensure_runtime().materialize(
capability_id,
reason="agent_entrypoint_first_use",
)
def get_agent_manager() -> Any:
"""返回 canonical Agent Manager;关闭门禁生效后稳定拒绝首用。"""
return _materialize_entrypoint(AGENT_MANAGER_CAPABILITY_ID)
async def reconcile_agent_service(
*,
reason: str,
changed_keys: set[str] | None = None,
retry: bool = False,
) -> Any | None:
"""按 manifest watch/selector 协调唯一 Agent Service 生命周期。"""
runtime = _ensure_runtime()
spec = runtime.get_spec(AGENT_SERVICE_CAPABILITY_ID)
if spec is None:
raise RuntimeError("缺少 agent.service capability")
if changed_keys is not None and not changed_keys.intersection(spec.watch):
return runtime.get_running(AGENT_SERVICE_CAPABILITY_ID)
if not should_run_agent_service(spec):
# stop_async 会等待并发首启后再撤销实例;未物化能力则保持零导入。
await runtime.stop_async(
AGENT_SERVICE_CAPABILITY_ID,
reason=reason,
)
return None
return await runtime.activate_async(
AGENT_SERVICE_CAPABILITY_ID,
reason=reason,
retry=retry,
)
async def activate_agent_service(*, retry: bool = False) -> Any | None:
"""执行启动期协调;selector 未启用时保持 service 未物化。"""
return await reconcile_agent_service(
reason="agent_service_startup_reconcile",
retry=retry,
)
def get_running_agent_manager() -> Any | None:
"""只读返回 RUNNING Agent Service;未构建 Runtime 时不触发声明发现。"""
with _runtime_lock:
runtime = _agent_runtime
if runtime is None:
return None
return runtime.get_running(AGENT_SERVICE_CAPABILITY_ID)
def get_moviepilot_agent_type() -> type:
"""返回 canonical MoviePilotAgent 类型。"""
agent_type = _materialize_entrypoint(MOVIEPILOT_AGENT_TYPE_CAPABILITY_ID)
if not isinstance(agent_type, type):
raise TypeError("MoviePilot Agent entrypoint 必须是类型")
return agent_type
def get_tool_factory() -> type:
"""返回 canonical 工具工厂类型。"""
factory_type = _materialize_entrypoint(TOOL_FACTORY_CAPABILITY_ID)
if not isinstance(factory_type, type):
raise TypeError("Agent Tool Factory entrypoint 必须是类型")
return factory_type
def is_tool_factory_materialized() -> bool:
"""只读判断工具工厂是否已解析;未建 Runtime 时不触发发现或导入。"""
with _runtime_lock:
runtime = _agent_runtime
if runtime is None:
return False
return (
runtime.snapshot(TOOL_FACTORY_CAPABILITY_ID).materialization
is CapabilityMaterializationState.RESOLVED
)
async def close_materialized_terminal_sessions() -> None:
"""关闭已物化的终端会话管理器,不触发新的 Agent 工具导入。"""
module = sys.modules.get("app.agent.tools.impl._terminal_session")
manager = getattr(module, "terminal_session_manager", None) if module else None
close = getattr(manager, "close", None)
if callable(close):
await close()
async def begin_agent_shutdown() -> bool:
"""不可逆关闭首用闸门,并返回全部 Agent 能力是否真实收敛。"""
runtime = _ensure_runtime()
return await runtime.shutdown_async(
reason="application_shutdown",
)
-1
View File
@@ -1 +0,0 @@
"""Agent Skill 元数据、市场和本地生命周期能力。"""
-132
View File
@@ -1,132 +0,0 @@
import logging
import re
from typing import TypedDict
import yaml
logger = logging.getLogger(__name__)
# 磁盘读取上限属于 Skill 文档格式约束,市场扫描和 Agent 加载必须共用。
MAX_SKILL_FILE_SIZE = 1 * 1024 * 1024
MAX_SKILL_NAME_LENGTH = 64
MAX_SKILL_DESCRIPTION_LENGTH = 1024
MAX_SKILL_COMPATIBILITY_LENGTH = 500
class SkillMetadata(TypedDict):
"""描述符合 Agent Skills 规范的已校验元数据。"""
path: str
id: str
name: str
version: int
description: str
license: str | None
compatibility: str | None
metadata: dict[str, str]
allowed_tools: list[str]
def _validate_metadata(raw: object, skill_path: str) -> dict[str, str]:
"""将 YAML metadata 字段规范化为字符串键值映射。"""
if not isinstance(raw, dict):
if raw:
logger.warning(
"Ignoring non-dict metadata in %s (got %s)",
skill_path,
type(raw).__name__,
)
return {}
return {str(key): str(value) for key, value in raw.items()}
def parse_skill_metadata( # noqa: C901
content: str,
skill_path: str,
skill_id: str,
) -> SkillMetadata | None:
"""解析并校验一个 SKILL.md 的 YAML 前言。"""
if len(content) > MAX_SKILL_FILE_SIZE:
logger.warning(
"Skipping %s: content too large (%d bytes)", skill_path, len(content)
)
return None
match = re.match(r"^---\s*\n(.*?)\n---\s*\n", content, re.DOTALL)
if not match:
logger.warning("Skipping %s: no valid YAML frontmatter found", skill_path)
return None
try:
frontmatter_data = yaml.safe_load(match.group(1))
except yaml.YAMLError as err:
logger.warning("Invalid YAML in %s: %s", skill_path, err)
return None
if not isinstance(frontmatter_data, dict):
logger.warning("Skipping %s: frontmatter is not a mapping", skill_path)
return None
name = str(frontmatter_data.get("name", "")).strip()
description = str(frontmatter_data.get("description", "")).strip()
if not name or not description:
logger.warning(
"Skipping %s: missing required 'name' or 'description'", skill_path
)
return None
if len(description) > MAX_SKILL_DESCRIPTION_LENGTH:
logger.warning(
"Description exceeds %d characters in %s, truncating",
MAX_SKILL_DESCRIPTION_LENGTH,
skill_path,
)
description = description[:MAX_SKILL_DESCRIPTION_LENGTH]
raw_tools = frontmatter_data.get("allowed-tools")
if isinstance(raw_tools, str):
allowed_tools = [
tool.strip(",")
for tool in raw_tools.split()
if tool.strip(",")
]
else:
if raw_tools is not None:
logger.warning(
"Ignoring non-string 'allowed-tools' in %s (got %s)",
skill_path,
type(raw_tools).__name__,
)
allowed_tools = []
compatibility = str(frontmatter_data.get("compatibility", "")).strip() or None
if compatibility and len(compatibility) > MAX_SKILL_COMPATIBILITY_LENGTH:
logger.warning(
"Compatibility exceeds %d characters in %s, truncating",
MAX_SKILL_COMPATIBILITY_LENGTH,
skill_path,
)
compatibility = compatibility[:MAX_SKILL_COMPATIBILITY_LENGTH]
raw_version = frontmatter_data.get("version")
version = 0
if raw_version is not None:
try:
version = int(raw_version)
except (ValueError, TypeError):
logger.warning(
"Invalid 'version' in %s (got %r), defaulting to 0",
skill_path,
raw_version,
)
return SkillMetadata(
id=skill_id,
name=name,
version=version,
description=description,
path=skill_path,
metadata=_validate_metadata(frontmatter_data.get("metadata", {}), skill_path),
license=str(frontmatter_data.get("license", "")).strip() or None,
compatibility=compatibility,
allowed_tools=allowed_tools,
)
+152 -273
View File
@@ -1,79 +1,24 @@
import asyncio
import inspect
import json
import threading
from abc import ABCMeta, abstractmethod
from concurrent.futures import Future as ConcurrentFuture, ThreadPoolExecutor
from contextvars import Context, copy_context
from concurrent.futures import ThreadPoolExecutor
from functools import partial
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Optional, Protocol
from typing import Any, Callable, ClassVar, Optional
from langchain_core.tools import BaseTool
from pydantic import PrivateAttr
from app.agent.policy.sanitizer import (
summarize_error,
summarize_input,
summarize_result,
)
from app.agent import StreamingHandler
from app.agent.tools.tags import ToolTag
from app.chain import ChainBase
from app.runtime.settings import get_runtime_setting
from app.application.messaging.agent import matches_channel_admin
from app.application.notification import get_notification_configs
from app.runtime.log import logger
from app.schemas.message import Message
from app.schemas.types import NotificationChannel, MessageType
if TYPE_CHECKING:
from app.agent.callback import StreamingHandler as _StreamingHandlerProtocol
else:
class _StreamingHandlerProtocol(Protocol):
"""工具执行仅依赖的流式缓冲合同。"""
@property
def is_streaming(self) -> bool:
"""是否正在收集流式输出。"""
...
@property
def is_auto_flushing(self) -> bool:
"""是否由渠道编辑能力自动刷新缓冲。"""
...
@property
def last_buffer_char(self) -> str:
"""返回缓冲区最后一个字符。"""
...
def emit(self, token: str) -> str:
"""追加流式文本并返回实际追加内容。"""
...
async def take(self) -> str:
"""取出并清空当前缓冲内容。"""
...
def record_tool_call(
self,
tool_name: str,
tool_message: Optional[str] = None,
tool_kwargs: Optional[dict[str, Any]] = None,
) -> None:
"""记录一次待汇总的工具调用。"""
...
def __getattr__(name: str) -> Any:
"""显式访问历史 StreamingHandler 符号时返回 canonical 实现。"""
if name == "StreamingHandler":
from app.agent.callback import StreamingHandler
return StreamingHandler
raise AttributeError(f"module 'app.agent.tools.base' has no attribute {name!r}")
from app.core.config import settings
from app.db.user_oper import UserOper
from app.helper.service import ServiceConfigHelper
from app.log import logger
from app.schemas import Notification
from app.schemas.types import MessageChannel, NotificationType
class ToolChain(ChainBase):
@@ -94,9 +39,7 @@ def serialize_tool_result_for_agent(result: Any) -> str:
try:
return json.dumps(result, ensure_ascii=False, indent=2, default=str)
except Exception as e:
logger.warning(
f"工具结果转换为JSON失败: {summarize_error(e)}, 使用字符串表示"
)
logger.warning(f"工具结果转换为JSON失败: {e}, 使用字符串表示")
return str(result)
@@ -167,130 +110,33 @@ _blocking_semaphores = {
for bucket, limit in _BLOCKING_BUCKET_LIMITS.items()
}
_blocking_executors: dict[str, ThreadPoolExecutor] = {}
_blocking_retiring_executors: set[ThreadPoolExecutor] = set()
_blocking_futures: dict[ConcurrentFuture[Any], ThreadPoolExecutor] = {}
_blocking_executor_lock = threading.RLock()
_blocking_executor_accepting = True
_blocking_executor_lock = threading.Lock()
def _discard_blocking_future(future: ConcurrentFuture[Any]) -> None:
"""在同步调用到达终态后撤销 Future 与 retiring executor owner。"""
def _get_blocking_executor(bucket: str) -> ThreadPoolExecutor:
"""按桶懒加载线程池,避免在导入阶段创建过多 worker。"""
with _blocking_executor_lock:
executor = _blocking_futures.pop(future, None)
if executor is None or executor not in _blocking_retiring_executors:
return
if executor not in _blocking_futures.values():
_blocking_retiring_executors.discard(executor)
def _submit_blocking_call(
bucket: str,
bound_call: Callable[[], Any],
) -> ConcurrentFuture[Any]:
"""在提交门禁内原子取得 executor、提交调用并登记 Future owner。"""
context = copy_context()
with _blocking_executor_lock:
if not _blocking_executor_accepting:
raise RuntimeError("Agent 工具阻塞执行器正在关闭,不能再提交新任务")
executor = _blocking_executors.get(bucket)
if executor is None:
limit = _BLOCKING_BUCKET_LIMITS[bucket]
executor = ThreadPoolExecutor(
max_workers=limit,
thread_name_prefix=f"agent-tool-{bucket}",
)
_blocking_executors[bucket] = executor
# 长期 worker 保持空底层上下文,每个任务只在自己的调用快照内运行。
future = Context().run(executor.submit, context.run, bound_call)
_blocking_futures[future] = executor
future.add_done_callback(_discard_blocking_future)
return future
if executor:
return executor
limit = _BLOCKING_BUCKET_LIMITS[bucket]
executor = ThreadPoolExecutor(
max_workers=limit,
thread_name_prefix=f"agent-tool-{bucket}",
)
_blocking_executors[bucket] = executor
return executor
def _retire_blocking_executors(*, cancel_futures: bool) -> tuple[ThreadPoolExecutor, ...]:
"""撤销活动 executor 的提交资格,并保留其运行 Future 对应的 owner。"""
def shutdown_blocking_executors(*, wait: bool = True, cancel_futures: bool = False) -> None:
"""关闭 Agent 工具阻塞线程池,释放长期运行进程或测试环境中的 worker。"""
with _blocking_executor_lock:
executors = tuple(_blocking_executors.values())
executors = list(_blocking_executors.values())
_blocking_executors.clear()
_blocking_retiring_executors.update(executors)
for executor in executors:
executor.shutdown(wait=False, cancel_futures=cancel_futures)
with _blocking_executor_lock:
owned_executors = set(_blocking_futures.values())
_blocking_retiring_executors.intersection_update(owned_executors)
return executors
def begin_blocking_executor_shutdown(*, cancel_futures: bool = True) -> None:
"""原子封住新阻塞工具提交,并请求取消尚未开始的同步调用。"""
global _blocking_executor_accepting
with _blocking_executor_lock:
_blocking_executor_accepting = False
_retire_blocking_executors(cancel_futures=cancel_futures)
def reopen_blocking_executors() -> bool:
"""仅在旧 Future 和 executor 全部收敛后重新开放测试生命周期。"""
global _blocking_executor_accepting
with _blocking_executor_lock:
if _blocking_futures or _blocking_retiring_executors:
return False
_blocking_executor_accepting = True
return True
async def close_blocking_executors(
*,
timeout_seconds: float,
cancel_futures: bool = True,
) -> bool:
"""有限等待全部阻塞工具 Future,超时保留 Future 与 executor owner。"""
begin_blocking_executor_shutdown(cancel_futures=cancel_futures)
with _blocking_executor_lock:
futures = tuple(_blocking_futures)
wrapped_futures = tuple(asyncio.wrap_future(future) for future in futures)
if wrapped_futures:
done, pending = await asyncio.wait(
wrapped_futures,
timeout=max(0.0, timeout_seconds),
)
if done:
await asyncio.gather(*done, return_exceptions=True)
for pending_future in pending:
pending_future.add_done_callback(
lambda completed: completed.exception()
if not completed.cancelled()
else None
)
with _blocking_executor_lock:
unfinished = tuple(
future for future in _blocking_futures if not future.done()
)
retiring_count = len(_blocking_retiring_executors)
if unfinished:
logger.error(
"Agent 阻塞工具未在 %.1f 秒内收敛:futures=%dexecutors=%d",
max(0.0, timeout_seconds),
len(unfinished),
retiring_count,
)
return False
return True
def shutdown_blocking_executors(
*,
wait: bool = True,
cancel_futures: bool = False,
) -> bool:
"""同步清理测试 owner;非等待模式下保留尚未收敛的 executor 句柄。"""
executors = _retire_blocking_executors(cancel_futures=cancel_futures)
for executor in executors:
if wait:
executor.shutdown(wait=True, cancel_futures=cancel_futures)
with _blocking_executor_lock:
return not _blocking_futures and not _blocking_retiring_executors
executor.shutdown(wait=wait, cancel_futures=cancel_futures)
class ToolExecutionTimeoutError(TimeoutError):
@@ -300,7 +146,7 @@ class ToolExecutionTimeoutError(TimeoutError):
def _get_tool_timeout_seconds() -> Optional[float]:
"""读取工具执行超时时间,配置为 0 或负数时表示不限制。"""
try:
timeout = float(get_runtime_setting('LLM_TOOL_TIMEOUT') or 0)
timeout = float(settings.LLM_TOOL_TIMEOUT or 0)
except (TypeError, ValueError):
timeout = 0
return timeout if timeout > 0 else None
@@ -322,7 +168,7 @@ async def run_agent_blocking(
await semaphore.acquire()
try:
future = _submit_blocking_call(bucket_name, bound_call)
future = _get_blocking_executor(bucket_name).submit(bound_call)
except Exception:
semaphore.release()
raise
@@ -353,7 +199,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
_channel: Optional[str] = PrivateAttr(default=None)
_source: Optional[str] = PrivateAttr(default=None)
_username: Optional[str] = PrivateAttr(default=None)
_stream_handler: Optional[_StreamingHandlerProtocol] = PrivateAttr(default=None)
_stream_handler: Optional[StreamingHandler] = PrivateAttr(default=None)
_require_admin: bool = PrivateAttr(default=False)
_agent_context: dict = PrivateAttr(default_factory=dict)
@@ -402,10 +248,6 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
permission_result = await self._check_permission()
if permission_result:
# 工具被权限门禁拦截时,模型在调用前可能已输出一段引导文本,且这里
# 不会产生工具消息或统计摘要;补一个换行分隔符,避免随后的失败说明
# 与引导文本直接连在一起。
self._ensure_tool_boundary_separator()
return permission_result
# 获取工具执行提示消息
@@ -413,7 +255,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
# 发送工具执行过程消息(流式传输且非最后终结工具时)
if self._stream_handler and self._stream_handler.is_streaming and not self.return_direct:
if get_runtime_setting('AI_AGENT_VERBOSE'):
if settings.AI_AGENT_VERBOSE:
if self._stream_handler.is_auto_flushing:
# 渠道支持编辑:工具消息追加到 buffer,由定时刷新推送
if tool_message:
@@ -461,26 +303,27 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
# 未启用流式传输,不发送任何工具消息内容
pass
logger.debug(
f"Executing tool {self.name} with input summary: {summarize_input(kwargs)}"
)
logger.debug(f"Executing tool {self.name} with args: {kwargs}")
# 执行具体工具逻辑
try:
result = await self.run_with_timeout(**kwargs)
logger.info(
f"Agent工具 {self.name} 执行完成,"
f"结果摘要: {summarize_result(result)}"
)
# 记录工具执行结果摘要日志
str_result = serialize_tool_result_for_agent(result)
if len(str_result) > 500:
summary = str_result[:500] + f"...(已截断,总长度: {len(str_result)})"
else:
summary = str_result
logger.info(f"Agent工具 {self.name} 执行完成,结果摘要: {summary}")
except ToolExecutionTimeoutError as e:
error_message = summarize_error(e)
error_message = str(e)
logger.warning(error_message)
raise
result = error_message
except Exception as e:
error_message = f"工具执行异常: {summarize_error(e)}"
logger.error(f"Tool {self.name} execution failed: {summarize_error(e)}")
error_message = f"工具执行异常 ({type(e).__name__}): {str(e)}"
logger.error(f"Tool {self.name} execution failed: {e}", exc_info=True)
result = error_message
return format_tool_result_for_agent(
@@ -515,7 +358,6 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
except asyncio.TimeoutError as err:
raise ToolExecutionTimeoutError(
f"工具 {self.name} 执行超时(超过 {timeout:g} 秒),已停止等待结果。"
"若工具包含外部写操作,操作可能仍在继续,请先确认实际状态再重试。"
) from err
@staticmethod
@@ -535,9 +377,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
self._source = source
self._username = username
def set_stream_handler(
self, stream_handler: Optional[_StreamingHandlerProtocol]
) -> None:
def set_stream_handler(self, stream_handler: StreamingHandler):
"""
设置回调处理器
"""
@@ -551,29 +391,14 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
# 独立的新 dict,跨工具状态(例如质量门槛拒绝标记)无法传播。
self._agent_context = {} if agent_context is None else agent_context
def _ensure_tool_boundary_separator(self) -> None:
"""
在流式缓冲中为工具边界补一个换行分隔符
工具被权限门禁等前置检查拦截时不产生工具消息或统计摘要若模型在调用前
已输出文本后续内容会直接粘在前文后面这里保证缓冲以换行结尾让工具
前后的内容分行展示缓冲为空或已以换行结尾时无需处理
"""
if (
self._stream_handler
and self._stream_handler.is_streaming
and self._stream_handler.last_buffer_char not in ("", "\n")
):
self._stream_handler.emit("\n")
async def is_admin_user(self) -> bool:
"""
判断当前工具调用者是否拥有管理员级权限
:return: 当前调用者是系统管理员渠道管理员或显式管理员上下文时返回 True
"""
if "is_admin" in self._agent_context:
return self._agent_context.get("is_admin") is True
if bool(self._agent_context.get("is_admin")):
return True
if not self._channel or not self._source:
return False
@@ -613,7 +438,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
:return: 普通用户允许读写的本地目录列表
"""
roots = [
get_runtime_setting('CONFIG_PATH') / "agent",
settings.CONFIG_PATH / "agent",
]
resolved_roots = []
for root in roots:
@@ -678,10 +503,12 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
async def _check_permission(self) -> Optional[str]:
"""
检查管理员工具权限
Agent 共享上下文中的显式管理员事实优先没有该事实的旧调用才按渠道
管理员名单回查并保留无消息渠道内部调用的兼容行为
检查用户权限
1. 首先检查工具是否需要管理员权限
2. 如果需要管理员权限则检查用户是否是渠道管理员
3. 如果渠道没有设置管理员名单则检查用户是否是系统管理员
4. 如果都不是系统管理员检查用户ID是否等于渠道配置的用户ID
5. 如果都不是返回权限拒绝消息
"""
if not self._require_admin:
return None
@@ -689,9 +516,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
if await self.is_admin_user():
return None
if "is_admin" not in self._agent_context and (
not self._channel or not self._source
):
if not self._channel or not self._source:
return None
return (
@@ -705,75 +530,136 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
"""
检查当前消息渠道身份是否具备管理员权限
:return: 当前渠道稳定用户 ID 位于显式管理员名单或等于渠道主ID时返回 True
: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
try:
channel = NotificationChannel(self._channel)
except ValueError:
channel_type_map = {
MessageChannel.Telegram: "telegram",
MessageChannel.Discord: "discord",
MessageChannel.Wechat: "wechat",
MessageChannel.Feishu: "feishu",
MessageChannel.WechatClawBot: "wechatclawbot",
MessageChannel.Slack: "slack",
MessageChannel.VoceChat: "vocechat",
MessageChannel.SynologyChat: "synologychat",
MessageChannel.QQ: "qqbot",
}
channel_type = None
for key, value in channel_type_map.items():
if self._channel == key.value:
channel_type = value
break
if not channel_type:
return False
admin_key_map = {
"telegram": "TELEGRAM_ADMINS",
"discord": "DISCORD_ADMINS",
"wechat": "WECHAT_ADMINS",
"feishu": "FEISHU_ADMINS",
"wechatclawbot": "WECHATCLAWBOT_ADMINS",
"slack": "SLACK_ADMINS",
"vocechat": "VOCECHAT_ADMINS",
"synologychat": "SYNOLOGYCHAT_ADMINS",
"qqbot": "QQBOT_ADMINS",
}
user_id_key_map = {
"telegram": "TELEGRAM_CHAT_ID",
"vocechat": "VOCECHAT_CHANNEL_ID",
"wechat": "WECHAT_BOT_CHAT_ID",
"feishu": "FEISHU_OPEN_ID",
"wechatclawbot": "WECHATCLAWBOT_DEFAULT_TARGET",
"discord": "DISCORD_CHANNEL_ID",
"slack": "SLACK_CHANNEL",
"qqbot": "QQ_OPENID",
}
admin_key = admin_key_map.get(channel_type)
user_id_key = user_id_key_map.get(channel_type)
try:
configs = get_notification_configs(include_disabled=True)
configs = ServiceConfigHelper.get_notification_configs()
for config in configs:
if config.name == self._source and config.config:
return matches_channel_admin(
channel,
config.config,
user_id_str,
)
channel_admins = config.config.get(admin_key) if admin_key else None
if channel_admins:
admin_list = [
aid.strip()
for aid in str(channel_admins).split(",")
if aid.strip()
]
if user_id_str and user_id_str in admin_list:
return True
user = (
await UserOper().async_get_by_name(self._username)
if self._username
else None
)
if user and user.is_superuser:
return True
return False
else:
user = (
await UserOper().async_get_by_name(self._username)
if self._username
else None
)
if user and user.is_superuser:
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 True
return False
except Exception as e:
logger.error(f"检查权限失败: {summarize_error(e)}")
logger.error(f"检查权限失败: {e}")
return False
async def send_message(self, message: Message) -> None:
async def send_notification_message(self, notification: Notification) -> None:
"""
发送工具消息
发送工具通知消息
WebAgent 渠道没有后端模块实例前端流式面板通过 Agent 上下文中的
回调直接接收消息无渠道的后台任务清空渠道侧定位信息后交由消息链广播
回调直接接收通知无渠道的后台任务清空渠道侧定位信息后交由消息链广播
其它渠道继续走统一消息链
"""
callback = self._agent_context.get("message_callback")
callback = self._agent_context.get("notification_callback")
if (
self._channel == NotificationChannel.WebAgent.value
self._channel == MessageChannel.WebAgent.value
and callable(callback)
):
callback_result = callback(message)
if inspect.isawaitable(callback_result):
await callback_result
callback(notification)
return
if not self._channel or not self._source:
message = message.model_copy(
notification = notification.model_copy(
update={
"channel": None,
"source": None,
"userid": None,
"username": message.username
"username": notification.username
or self._username
or get_runtime_setting('SUPERUSER'),
or settings.SUPERUSER,
"original_message_id": None,
"original_chat_id": None,
}
)
elif not message.original_chat_id:
# 工具回调消息默认回填当前会话的原会话 ID,
# 保证群聊 @ 机器人时按钮选择、消息发送等交互消息回复到原群,而不是私聊窗口。
original_chat_id = str(
self._agent_context.get("original_chat_id") or ""
).strip() or None
if original_chat_id:
message = message.model_copy(
update={"original_chat_id": original_chat_id}
)
await ToolChain().async_post_message(message)
await ToolChain().async_post_message(notification)
async def send_tool_message(
self, message: str, title: str = "", image: Optional[str] = None
@@ -781,11 +667,11 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
"""
发送工具消息
"""
await self.send_message(
Message(
await self.send_notification_message(
Notification(
channel=self._channel,
source=self._source,
mtype=MessageType.Agent,
mtype=NotificationType.Agent,
userid=self._user_id,
username=self._username,
title=title,
@@ -794,10 +680,3 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
save_history=False,
)
)
# 普通导入保持 callback 冷态;显式导入或历史星号导入仍解析真实类。
__all__ = sorted(
{name for name in globals() if not name.startswith("_")}
| {"StreamingHandler"}
)
-189
View File
@@ -1,189 +0,0 @@
"""Agent 本地工具目录的不可变快照与严格解析。"""
import hashlib
import json
from dataclasses import dataclass, field
from types import MappingProxyType
from typing import Any, Mapping, Optional
from pydantic import BaseModel
from app.agent.policy.contracts import ToolRevision
class ToolCatalogError(RuntimeError):
"""工具目录无法建立可信当前视图时的稳定失败。"""
class ToolIdentityAmbiguousError(ToolCatalogError):
"""同一工具名对应多个实现,无法进行严格解析。"""
def _stable_json(value: Any) -> str:
"""生成工具身份摘要使用的稳定 JSON。"""
return json.dumps(
value,
ensure_ascii=False,
allow_nan=False,
sort_keys=True,
separators=(",", ":"),
)
def _schema_digest(tool: Any) -> str:
"""计算工具当前 Pydantic 参数契约摘要。"""
args_schema = getattr(tool, "args_schema", None)
if isinstance(args_schema, type) and issubclass(args_schema, BaseModel):
schema = args_schema.model_json_schema()
elif isinstance(args_schema, Mapping):
schema = dict(args_schema)
else:
schema = {"type": "object", "properties": {}}
return hashlib.sha256(_stable_json(schema).encode("utf-8")).hexdigest()
def _implementation_identity(tool: Any) -> str:
"""返回不依赖对象地址、可区分动态绑定的工具实现身份。"""
tool_class = type(tool)
implementation = f"{tool_class.__module__}.{tool_class.__qualname__}"
binding = str(getattr(tool, "_agent_tool_binding", "") or "")
return f"{implementation}:{binding}" if binding else implementation
@dataclass(frozen=True)
class ToolCatalogEntry:
"""绑定一次目录构造中精确工具实例的身份记录。"""
name: str
source: str
identity: str
description_digest: str
schema_digest: str
revision: ToolRevision
tool: Any = field(repr=False, compare=False)
@dataclass(frozen=True)
class ToolCatalogSnapshot:
"""图构造、缓存签名和身份碰撞审计共享的本地工具事实源。"""
entries: tuple[ToolCatalogEntry, ...]
plugin_revision: int
factory_revision: str
_by_name: Mapping[str, tuple[ToolCatalogEntry, ...]] = field(
init=False,
repr=False,
compare=False,
)
def __post_init__(self) -> None:
"""建立只读名称索引并保留所有冲突项。"""
by_name: dict[str, list[ToolCatalogEntry]] = {}
for entry in self.entries:
by_name.setdefault(entry.name, []).append(entry)
object.__setattr__(
self,
"_by_name",
MappingProxyType(
{name: tuple(matches) for name, matches in by_name.items()}
),
)
@classmethod
def from_tools(
cls,
tools: list[Any],
*,
plugin_revision: int,
factory_revision: str,
) -> "ToolCatalogSnapshot":
"""从已完成上下文注入的精确实例建立不可变目录。"""
entries = []
for tool in tools:
name = str(getattr(tool, "name", "") or "")
if not name:
raise ToolCatalogError("工具缺少稳定名称")
source = str(getattr(tool, "_agent_tool_source", "builtin"))
schema_digest = _schema_digest(tool)
description_digest = hashlib.sha256(
str(getattr(tool, "description", "") or "").encode("utf-8")
).hexdigest()
implementation = _implementation_identity(tool)
revision = ToolRevision(
implementation=implementation,
factory=factory_revision,
plugin=str(plugin_revision),
)
entries.append(
ToolCatalogEntry(
name=name,
source=source,
identity=f"{source}:{implementation}:{schema_digest}",
description_digest=description_digest,
schema_digest=schema_digest,
revision=revision,
tool=tool,
)
)
return cls(
entries=tuple(entries),
plugin_revision=plugin_revision,
factory_revision=factory_revision,
)
@property
def tools(self) -> list[Any]:
"""按目录顺序返回精确工具实例。"""
return [entry.tool for entry in self.entries]
@property
def collisions(self) -> Mapping[str, tuple[ToolCatalogEntry, ...]]:
"""返回所有同名工具,不按注册顺序隐式选胜者。"""
return MappingProxyType(
{name: entries for name, entries in self._by_name.items() if len(entries) > 1}
)
@property
def signature(self) -> tuple[Any, ...]:
"""返回可参与 Agent 图缓存的完整目录签名。"""
return (
self.factory_revision,
self.plugin_revision,
tuple(
(
entry.name,
entry.identity,
entry.description_digest,
entry.schema_digest,
)
for entry in self.entries
),
)
def resolve_unique(self, name: str) -> Optional[ToolCatalogEntry]:
"""严格解析当前唯一实现;重名时拒绝继承 first/last-wins。"""
entries = self._by_name.get(name, ())
if len(entries) > 1:
raise ToolIdentityAmbiguousError("TOOL_IDENTITY_AMBIGUOUS")
return entries[0] if entries else None
def select(self, tools: list[Any]) -> "ToolCatalogSnapshot":
"""为子图保留所选工具名的全部候选身份与 revision 语义。"""
selected_names = {
str(getattr(tool, "name", "") or "") for tool in tools
}
return ToolCatalogSnapshot(
entries=tuple(
entry for entry in self.entries if entry.name in selected_names
),
plugin_revision=self.plugin_revision,
factory_revision=self.factory_revision,
)
__all__ = [
"ToolCatalogEntry",
"ToolCatalogError",
"ToolCatalogSnapshot",
"ToolIdentityAmbiguousError",
]
+6 -68
View File
@@ -1,5 +1,3 @@
import hashlib
from typing import Callable, List, Optional, Type
from app.agent.tools.impl.add_download_tasks import AddDownloadTasksTool
@@ -66,7 +64,6 @@ from app.agent.tools.impl.list_directory import ListDirectoryTool
from app.agent.tools.impl.query_transfer_history import QueryTransferHistoryTool
from app.agent.tools.impl.transfer_file import TransferFileTool
from app.agent.tools.impl.execute_command import ExecuteCommandTool
from app.agent.tools.impl.apply_patch import ApplyPatchTool
from app.agent.tools.impl.edit_file import EditFileTool
from app.agent.tools.impl.write_file import WriteFileTool
from app.agent.tools.impl.read_file import ReadFileTool
@@ -88,24 +85,11 @@ from app.agent.tools.impl.update_custom_identifiers import UpdateCustomIdentifie
from app.agent.tools.impl.query_system_settings import QuerySystemSettingsTool
from app.agent.tools.impl.update_system_settings import UpdateSystemSettingsTool
from app.agent.llm.capability import AgentCapabilityManager
from app.application.plugin.runtime import get_plugin_manager
from app.runtime.log import logger
from app.schemas.notification import ChannelCapabilityManager
from app.schemas.types import NotificationChannel
from app.core.plugin import PluginManager
from app.log import logger
from app.schemas.message import ChannelCapabilityManager
from app.schemas.types import MessageChannel
from .base import MoviePilotTool
from .catalog import ToolCatalogError, ToolCatalogSnapshot
def _get_plugin_agent_tools() -> list[dict]:
"""读取当前插件工具投影,隔离 Agent 工具工厂与 Runtime 管理器。"""
try:
return get_plugin_manager().get_plugin_agent_tools()
except RuntimeError as error:
# 纯工具目录探针可以在启动组合根之前运行;此时只跳过可选插件工具,
# 不隐式创建 PluginManager,避免冷导入重新引入 Runtime 定位器。
if "尚未由启动组合根装配" not in str(error):
raise
return []
class MoviePilotToolFactory:
@@ -176,7 +160,6 @@ class MoviePilotToolFactory:
UpdatePersonaDefinitionTool,
ExecuteCommandTool,
EditFileTool,
ApplyPatchTool,
WriteFileTool,
ReadFileTool,
BrowseWebpageTool,
@@ -206,30 +189,18 @@ class MoviePilotToolFactory:
"write_file",
"read_file",
"edit_file",
"apply_patch",
"execute_command",
"ask_user_choice",
"create_agent_task",
"query_agent_tasks",
)
CATALOG_BUILD_MAX_ATTEMPTS = 3
@classmethod
def catalog_factory_revision(cls) -> str:
"""返回当前内置工具工厂定义的稳定摘要。"""
identities = (
f"{tool_class.__module__}.{tool_class.__qualname__}"
for tool_class in cls.BUILTIN_TOOL_CLASSES
)
return hashlib.sha256("\n".join(identities).encode("utf-8")).hexdigest()
@staticmethod
def _should_enable_choice_tool(channel: Optional[str] = None) -> bool:
if not channel:
return False
try:
message_channel = NotificationChannel(channel)
message_channel = MessageChannel(channel)
except ValueError:
return False
return ChannelCapabilityManager.supports_buttons(
@@ -295,12 +266,11 @@ class MoviePilotToolFactory:
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)
object.__setattr__(tool, "_agent_tool_source", "builtin")
tools.append(tool)
# 加载插件提供的工具
plugin_tools_count = 0
plugin_tools_info = _get_plugin_agent_tools()
plugin_tools_info = PluginManager().get_plugin_agent_tools()
for plugin_info in plugin_tools_info:
plugin_id = plugin_info.get("plugin_id")
plugin_name = plugin_info.get("plugin_name")
@@ -322,11 +292,6 @@ class MoviePilotToolFactory:
)
tool.set_stream_handler(stream_handler=stream_handler)
tool.set_agent_context(agent_context=agent_context)
object.__setattr__(
tool,
"_agent_tool_source",
f"plugin:{plugin_id or 'unknown'}",
)
tools.append(tool)
plugin_tools_count += 1
logger.debug(
@@ -345,30 +310,3 @@ class MoviePilotToolFactory:
else:
logger.debug(f"成功创建 {len(tools)} 个MoviePilot工具")
return tools
@classmethod
def create_catalog(cls, **tool_kwargs) -> ToolCatalogSnapshot:
"""在插件目录稳定窗口内构造一份完整本地工具快照。"""
try:
plugin_manager = get_plugin_manager()
except RuntimeError as error:
# 没有启动上下文时仍允许构造内置工具目录;插件工具会在正式启动
# 后由组合根提供的 Runtime 中重新物化。
if "尚未由启动组合根装配" not in str(error):
raise
return ToolCatalogSnapshot.from_tools(
cls.create_tools(**tool_kwargs),
plugin_revision=0,
factory_revision=cls.catalog_factory_revision(),
)
for _attempt in range(cls.CATALOG_BUILD_MAX_ATTEMPTS):
before_revision = plugin_manager.get_plugin_agent_tools_revision()
tools = cls.create_tools(**tool_kwargs)
after_revision = plugin_manager.get_plugin_agent_tools_revision()
if before_revision == after_revision:
return ToolCatalogSnapshot.from_tools(
tools,
plugin_revision=after_revision,
factory_revision=cls.catalog_factory_revision(),
)
raise ToolCatalogError("插件工具目录持续变化,无法建立当前快照")
+84 -79
View File
@@ -2,20 +2,16 @@
import copy
import re
from typing import Any, Callable, Dict, Iterable, Optional
from typing import Any, Dict, Iterable, Optional
from app.application.agentdata import get_agent_subscribe_port
from app.application.configuration import get_configured_system_config
from app.application.rules import (
BUILTIN_RULE_SET,
RuleHelper,
RuleParser,
replace_group_name_in_list,
)
from app.runtime.events import eventmanager
from app.core.event import eventmanager
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
from app.modules.filter.builtin_rules import BUILTIN_RULE_SET
from app.schemas import CustomRule, FilterRuleGroup
from app.schemas.event import ConfigChangeEventData
from app.schemas.rule import CustomRule
from app.schemas.system import FilterRuleGroup
from app.schemas.types import EventType, SystemConfigKey
RULE_ID_PATTERN = re.compile(r"^[A-Za-z0-9]+$")
@@ -30,10 +26,8 @@ MEDIA_TYPE_ALIASES = {
"tv": "电视剧",
"series": "电视剧",
"show": "电视剧",
"music": "音乐",
"电影": "电影",
"电视剧": "电视剧",
"音乐": "音乐",
}
RULE_STRING_SYNTAX = {
@@ -81,9 +75,9 @@ def normalize_media_type(value: Optional[str]) -> Optional[str]:
if not value:
return None
normalized = MEDIA_TYPE_ALIASES.get(value.lower(), value)
if normalized not in {"电影", "电视剧", "音乐"}:
if normalized not in {"电影", "电视剧"}:
raise ValueError(
"media_type 仅支持 '电影''电视剧''音乐''movie''tv''music'"
"media_type 仅支持 '电影''电视剧''movie''tv'"
)
return normalized
@@ -257,13 +251,13 @@ async def collect_rule_group_usages(
"""收集规则组在全局配置和订阅上的引用情况。"""
target_names = set(group_names or [])
search_groups = set(
get_configured_system_config().get(SystemConfigKey.SearchFilterRuleGroups) or []
SystemConfigOper().get(SystemConfigKey.SearchFilterRuleGroups) or []
)
subscribe_groups = set(
get_configured_system_config().get(SystemConfigKey.SubscribeFilterRuleGroups) or []
SystemConfigOper().get(SystemConfigKey.SubscribeFilterRuleGroups) or []
)
best_version_groups = set(
get_configured_system_config().get(SystemConfigKey.BestVersionFilterRuleGroups) or []
SystemConfigOper().get(SystemConfigKey.BestVersionFilterRuleGroups) or []
)
usage_map = {
@@ -289,7 +283,7 @@ async def collect_rule_group_usages(
continue
ensure_usage(name)["used_in_global_best_version"] = True
subscribes = await get_agent_subscribe_port().async_list()
subscribes = await SubscribeOper().async_list()
for subscribe in subscribes:
filter_groups = subscribe.filter_groups or []
for name in filter_groups:
@@ -433,70 +427,67 @@ async def save_system_config(
]
normalized_value = normalized_value or None
success = await get_configured_system_config().async_set(key, normalized_value)
success = await SystemConfigOper().async_set(key, normalized_value)
if success:
await _publish_rule_config_changed(key, normalized_value)
await eventmanager.async_send_event(
etype=EventType.ConfigChanged,
data=ConfigChangeEventData(
key=key,
value=normalized_value,
change_type="update",
),
)
return success
async def _publish_rule_config_changed(
key: SystemConfigKey,
value: Any,
) -> None:
"""广播一项已经提交的规则配置变更。"""
await eventmanager.async_send_event(
etype=EventType.ConfigChanged,
data=ConfigChangeEventData(
key=key,
value=value,
change_type="update",
),
def replace_rule_id_in_rule_string(
rule_string: str, old_rule_id: str, new_rule_id: str
) -> str:
"""只替换完整 token,避免误伤其他规则名。"""
pattern = re.compile(
rf"(?<![A-Za-z0-9]){re.escape(old_rule_id)}(?![A-Za-z0-9])"
)
return pattern.sub(new_rule_id, rule_string)
async def _rewrite_rule_group_references(
map_names: Callable[[Iterable[str]], list[str]],
) -> dict:
"""按名称映射器更新全局、默认订阅配置和已有订阅引用"""
def replace_group_name_in_list(
values: Optional[Iterable[str]], old_name: str, new_name: str
) -> list[str]:
"""更新配置里的规则组名引用,并顺手去重"""
result = []
for value in values or []:
mapped = new_name if value == old_name else value
if mapped not in result:
result.append(mapped)
return result
async def rename_rule_group_references(old_name: str, new_name: str) -> dict:
"""规则组改名后,联动更新全局设置和订阅引用。"""
changed = {
"global_settings": {},
"subscribes": [],
}
system_config = get_configured_system_config()
for config_key in (
SystemConfigKey.SearchFilterRuleGroups,
SystemConfigKey.SubscribeFilterRuleGroups,
SystemConfigKey.BestVersionFilterRuleGroups,
):
original = system_config.get(config_key) or []
updated = map_names(original)
original = SystemConfigOper().get(config_key) or []
updated = replace_group_name_in_list(original, old_name, new_name)
if updated != original:
await save_system_config(config_key, updated)
changed["global_settings"][config_key.value] = updated
for config_key in (
SystemConfigKey.DefaultMovieSubscribeConfig,
SystemConfigKey.DefaultTvSubscribeConfig,
SystemConfigKey.DefaultMusicSubscribeConfig,
):
original = system_config.get(config_key) or {}
original_groups = original.get("filter_groups") or []
updated_groups = map_names(original_groups)
if updated_groups == original_groups:
continue
updated = copy.deepcopy(original)
updated["filter_groups"] = updated_groups
await save_system_config(config_key, updated)
changed["global_settings"][config_key.value] = updated
subscribe_port = get_agent_subscribe_port()
subscribes = await subscribe_port.async_list()
subscribe_oper = SubscribeOper()
subscribes = await subscribe_oper.async_list()
for subscribe in subscribes:
original = subscribe.filter_groups or []
updated = map_names(original)
updated = replace_group_name_in_list(original, old_name, new_name)
if updated == original:
continue
await subscribe_port.async_update_filter_groups(subscribe.id, updated)
await subscribe_oper.async_update_filter_groups(subscribe.id, updated)
changed["subscribes"].append(
{
"subscribe_id": subscribe.id,
@@ -509,25 +500,39 @@ async def _rewrite_rule_group_references(
return changed
async def rename_rule_group_references(old_name: str, new_name: str) -> dict:
"""规则组改名后,联动更新全部配置和已有订阅引用。"""
return await _rewrite_rule_group_references(
lambda values: replace_group_name_in_list(values, old_name, new_name)
)
async def remove_rule_group_references(group_name: str) -> dict:
"""删除规则组后,清理全部配置和已有订阅的悬空引用。"""
return await _rewrite_rule_group_references(
lambda values: [value for value in values or [] if value != group_name]
)
"""删除规则组后,清理全局设置和订阅的悬空引用。"""
changed = {
"global_settings": {},
"subscribes": [],
}
for config_key in (
SystemConfigKey.SearchFilterRuleGroups,
SystemConfigKey.SubscribeFilterRuleGroups,
SystemConfigKey.BestVersionFilterRuleGroups,
):
original = SystemConfigOper().get(config_key) or []
updated = [value for value in original if value != group_name]
if updated != original:
await save_system_config(config_key, updated)
changed["global_settings"][config_key.value] = updated
def replace_rule_id_in_rule_string(
rule_string: str, old_rule_id: str, new_rule_id: str
) -> str:
"""只替换完整 token,避免误伤其他规则名。"""
pattern = re.compile(
rf"(?<![A-Za-z0-9]){re.escape(old_rule_id)}(?![A-Za-z0-9])"
)
return pattern.sub(new_rule_id, rule_string)
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
-87
View File
@@ -1,87 +0,0 @@
"""Agent 音乐工具共享的实体校验与结果精简函数。"""
from typing import Any
from app.domain.context import (
MusicAlbumInfo,
MusicArtistInfo,
MusicInfo,
)
from app.schemas.types import media_type_to_agent
MUSIC_TRACK_PREVIEW_LIMIT = 100
MUSIC_RELEASE_PREVIEW_LIMIT = 20
def simplify_music_info(info: MusicInfo) -> dict[str, Any]:
"""精简音乐列表项,同时保留订阅和下载所需的稳定身份。"""
payload = {
"title": info.title,
"year": info.year,
"type": media_type_to_agent(info.type),
"music_type": info.music_type,
"artists": list(info.artists or []),
"artist_ids": list(info.artist_ids or []),
"artist": info.artist,
"album": info.album,
"album_id": info.album_id,
"album_type": info.album_type,
"release_date": info.release_date,
"disc_number": info.disc_number,
"track_number": info.track_number,
"total_tracks": info.total_tracks,
"duration": info.duration,
"isrc": info.isrc,
"version": info.version,
"genres": list(info.genres or []),
"category": info.category,
"listen_count": info.listen_count,
"media_source": info.media_source,
"media_id": info.media_id,
"poster_path": info.poster_path,
"detail_link": info.detail_link,
"overview": info.overview,
}
return {key: value for key, value in payload.items() if value not in (None, "", [])}
def simplify_music_album(
info: MusicAlbumInfo,
*,
track_limit: int = MUSIC_TRACK_PREVIEW_LIMIT,
) -> dict[str, Any]:
"""精简专辑详情并限制曲目和发行版本预览,避免撑大 Agent 上下文。"""
normalized_track_limit = max(1, min(track_limit, MUSIC_TRACK_PREVIEW_LIMIT))
tracks = list(info.tracks or [])
releases = list(info.releases or [])
payload = simplify_music_info(info.to_music_info())
payload.update({
"release_date": info.release_date,
"secondary_types": list(info.secondary_types or []),
"tags": list(info.tags or []),
"rating": info.rating,
"rating_votes": info.rating_votes,
"tracks": [
simplify_music_info(track)
for track in tracks[:normalized_track_limit]
],
"tracks_total": len(tracks),
"tracks_truncated": len(tracks) > normalized_track_limit,
"releases": [release.to_dict() for release in releases[:MUSIC_RELEASE_PREVIEW_LIMIT]],
"releases_total": len(releases),
"releases_truncated": len(releases) > MUSIC_RELEASE_PREVIEW_LIMIT,
})
return payload
def simplify_music_artist(info: MusicArtistInfo) -> dict[str, Any]:
"""精简艺术家详情,明确其仅用于浏览而非订阅或下载。"""
payload = info.to_dict()
payload.pop("raw_data", None)
payload.pop("mediaid_prefix", None)
payload["type"] = media_type_to_agent(info.type)
payload["media_source"] = info.media_source
payload["media_id"] = info.media_id
payload["subscribable"] = False
return {key: value for key, value in payload.items() if value not in (None, "", [])}
+77 -121
View File
@@ -2,18 +2,15 @@
import json
import shutil
from pathlib import Path
from typing import Any, Optional
from app.adapters.external.market import PluginHelper
from app.application.configuration import get_configured_system_config
from app.application.plugin.gateway import get_plugin_install_service
from app.application.plugin.runtime import get_plugin_manager
from app.runtime.settings import get_runtime_setting
from app.schemas.plugin import PluginRuntimeStatus
from app.core.config import settings
from app.core.plugin import PluginManager
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.server import MoviePilotServerHelper
from app.helper.plugin import PluginHelper
from app.schemas.types import SystemConfigKey
# 默认只向智能体返回一个可读预览,避免超大插件数据挤爆上下文窗口。
DEFAULT_PLUGIN_DATA_PREVIEW_CHARS = 12_000
MAX_PLUGIN_DATA_PREVIEW_CHARS = 50_000
@@ -23,22 +20,11 @@ DEFAULT_PLUGIN_CANDIDATE_LIMIT = 50
MAX_PLUGIN_CANDIDATE_LIMIT = 200
def _remove_plugin_directory(path: Path) -> bool:
"""删除插件目录并返回是否完成,供受控线程执行。"""
if not path.exists():
return False
try:
shutil.rmtree(path)
except Exception:
return False
return True
def get_plugin_snapshot(plugin_id: str) -> Optional[dict[str, Any]]:
"""
获取已安装插件的基础信息快照
"""
plugin_manager = get_plugin_manager()
plugin_manager = PluginManager()
for plugin in plugin_manager.get_local_plugins():
if plugin.id == plugin_id:
return {
@@ -79,27 +65,22 @@ def build_preview_payload(value: Any, max_chars: Optional[int]) -> tuple[bool, i
return True, len(serialized), len(preview), preview
def refresh_plugin_registrations(plugin_id: str) -> None:
"""重新注册插件的定时任务、命令和动态 API 路由。"""
def reload_plugin_runtime(plugin_id: str) -> None:
"""
重载插件并重新注册其命令定时任务和 API
"""
# 这些依赖只在真正执行重载时才导入,避免普通查询工具引入不必要的初始化开销。
from app.application.plugin.routes import register_plugin_api
from app.application.commands import init_commands
from app.application.scheduling import update_plugin_job
from app.api.endpoints.plugin import register_plugin_api
from app.command import Command
from app.scheduler import Scheduler
update_plugin_job(plugin_id)
init_commands(plugin_id)
plugin_manager = PluginManager()
plugin_manager.reload_plugin(plugin_id)
Scheduler().update_plugin_job(plugin_id)
Command().init_commands(plugin_id)
register_plugin_api(plugin_id)
def reload_plugin_runtime(plugin_id: str) -> PluginRuntimeStatus:
"""重载插件实例并重新注册其命令、定时任务和 API。"""
plugin_manager = get_plugin_manager()
with plugin_manager.mutation(f"重载插件 {plugin_id}"):
runtime_status = plugin_manager.reload_plugin(plugin_id)
refresh_plugin_registrations(plugin_id)
return runtime_status
def summarize_plugin(plugin: Any) -> dict[str, Any]:
"""
提取插件对象中对 Agent 有价值的摘要字段
@@ -172,7 +153,7 @@ async def enrich_installed_plugin_sources(
if not missing_source_plugins:
return installed_plugins
plugin_manager = get_plugin_manager()
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))
@@ -199,7 +180,7 @@ async def load_market_plugins(force_refresh: bool = False) -> list[Any]:
"""
聚合插件市场与本地插件仓库中的候选插件
"""
plugin_manager = get_plugin_manager()
plugin_manager = PluginManager()
online_plugins = await plugin_manager.async_get_online_plugins(force=force_refresh)
local_repo_plugins = plugin_manager.get_local_repo_plugins()
if not online_plugins and not local_repo_plugins:
@@ -211,7 +192,7 @@ def list_installed_plugins() -> list[Any]:
"""
返回当前已安装插件列表
"""
plugin_manager = get_plugin_manager()
plugin_manager = PluginManager()
return [plugin for plugin in plugin_manager.get_local_plugins() if plugin.installed]
@@ -310,106 +291,81 @@ def summarize_candidates(matches: list[dict[str, Any]], limit: int = DEFAULT_PLU
async def install_plugin_runtime(
plugin_id: str,
repo_url: Optional[str],
force: bool = False,
*,
explicit_source: bool = False,
plugin_id: str, repo_url: Optional[str], force: bool = False
) -> tuple[bool, str, bool]:
"""
按现有插件接口的行为安装插件并刷新运行态注册信息
"""
result = await get_plugin_install_service().install(
plugin_id=plugin_id,
repo_url=repo_url or None,
force=force,
explicit_source=explicit_source,
)
return result.success, result.message, result.refreshed_only
install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
plugin_manager = PluginManager()
plugin_helper = PluginHelper()
refreshed_only = False
if not force and plugin_id in plugin_manager.get_plugin_ids():
refreshed_only = True
await MoviePilotServerHelper.async_install_plugin_reg(plugin_id=plugin_id, repo_url=repo_url)
message = "插件已存在,已刷新加载"
else:
if not repo_url:
return False, "没有传入仓库地址,无法正确安装插件,请检查配置", False
state, message = await plugin_helper.async_install(
pid=plugin_id,
repo_url=repo_url,
force_install=force,
)
if not state:
return False, message, False
await MoviePilotServerHelper.async_install_plugin_reg(plugin_id=plugin_id, repo_url=repo_url)
async def inspect_plugin_sources(
plugin_id: str,
*,
force: bool = False,
) -> dict[str, Any]:
"""返回 Agent 可展示的脱敏来源候选与当前准入状态。"""
inspection = await get_plugin_install_service().inspect_source(
plugin_id=plugin_id,
force=force,
)
candidates = [
candidate.public_dict()
for candidate in inspection.online_candidates
]
if inspection.local_candidate is not None:
candidates.append(inspection.local_candidate.public_dict())
return {
"selection_status": inspection.selection.status.value,
"selection_reason": inspection.selection.reason,
"inventory_complete": inspection.inventory_complete,
"candidates": candidates,
}
if plugin_id not in install_plugins:
install_plugins.append(plugin_id)
await SystemConfigOper().async_set(
SystemConfigKey.UserInstalledPlugins, install_plugins
)
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
async def uninstall_plugin_runtime(plugin_id: str) -> dict[str, Any]:
"""
按现有卸载逻辑移除插件并清理运行态注册与分组信息
"""
from app.application.plugin.folders import remove_plugin_from_folders
from app.application.plugin.routes import remove_plugin_api
from app.application.scheduling import remove_plugin_job
from app.agent.tools.base import run_agent_blocking
from app.api.endpoints.plugin import _remove_plugin_from_folders, remove_plugin_api
from app.scheduler import Scheduler
plugin_manager = get_plugin_manager()
with plugin_manager.mutation(f"卸载插件 {plugin_id}"):
virtual_instance = plugin_manager.get_plugin_instance(plugin_id)
source_instances = plugin_manager.get_plugin_source_instances(plugin_id)
if not virtual_instance and source_instances:
instance_ids = "".join(item.instance_id for item in source_instances)
raise ValueError(f"请先卸载该插件的分身:{instance_ids}")
config_oper = SystemConfigOper()
install_plugins = config_oper.get(SystemConfigKey.UserInstalledPlugins) or []
if plugin_id in install_plugins:
install_plugins = [plugin for plugin in install_plugins if plugin != plugin_id]
await config_oper.async_set(SystemConfigKey.UserInstalledPlugins, install_plugins)
config_oper = get_configured_system_config()
install_plugins = config_oper.get(SystemConfigKey.UserInstalledPlugins) or []
if plugin_id in install_plugins:
install_plugins = [
plugin for plugin in install_plugins if plugin != plugin_id
]
await config_oper.async_set(
SystemConfigKey.UserInstalledPlugins,
install_plugins,
)
remove_plugin_api(plugin_id)
Scheduler().remove_plugin_job(plugin_id)
remove_plugin_api(plugin_id)
remove_plugin_job(plugin_id)
plugin_manager = PluginManager()
plugin_class = plugin_manager.plugins.get(plugin_id)
was_clone = bool(getattr(plugin_class, "is_clone", False))
clone_files_removed = False
plugin_class = plugin_manager.plugins.get(plugin_id)
was_clone = bool(getattr(plugin_class, "is_clone", False))
clone_files_removed = False
if virtual_instance:
plugin_manager.delete_plugin_config(plugin_id, force=True)
plugin_manager.delete_plugin_data(plugin_id, force=True)
plugin_manager.delete_plugin_instance(plugin_id)
elif was_clone:
plugin_manager.delete_plugin_config(plugin_id)
plugin_manager.delete_plugin_data(plugin_id)
plugin_base_dir = get_runtime_setting('ROOT_PATH') / "app" / "plugins" / plugin_id.lower()
if was_clone:
plugin_manager.delete_plugin_config(plugin_id)
plugin_manager.delete_plugin_data(plugin_id)
plugin_base_dir = settings.ROOT_PATH / "app" / "plugins" / plugin_id.lower()
if plugin_base_dir.exists():
try:
clone_files_removed = await run_agent_blocking(
"plugin",
_remove_plugin_directory,
plugin_base_dir,
)
if clone_files_removed:
plugin_manager.plugins.pop(plugin_id, None)
shutil.rmtree(plugin_base_dir)
plugin_manager.plugins.pop(plugin_id, None)
clone_files_removed = True
except Exception:
clone_files_removed = False
remove_plugin_from_folders(plugin_id)
plugin_manager.remove_plugin(plugin_id)
_remove_plugin_from_folders(plugin_id)
plugin_manager.remove_plugin(plugin_id)
return {
"was_clone": was_clone,
"clone_files_removed": clone_files_removed,
}
return {
"was_clone": was_clone,
"clone_files_removed": clone_files_removed,
}
+25 -33
View File
@@ -3,8 +3,7 @@
from dataclasses import dataclass
from typing import Any, Optional
from app.agent.policy.secret_fields import is_secret_setting_key
from app.runtime.config import Settings
from app.core.config import Settings
from app.schemas.types import SystemConfigKey
@@ -120,10 +119,6 @@ SYSTEMCONFIG_SETTING_METADATA = {
"group": "subscribe_defaults",
"label": "默认电视剧订阅规则",
},
SystemConfigKey.DefaultMusicSubscribeConfig.value: {
"group": "subscribe_defaults",
"label": "默认音乐订阅规则",
},
SystemConfigKey.UserInstalledPlugins.value: {
"group": "plugins",
"label": "已安装插件列表",
@@ -227,36 +222,13 @@ GROUP_ALIASES = {
}
# 这些前缀共同组成可启动、推理和扩展 AI Agent 的同一业务配置域。
AI_AGENT_CORE_SETTING_PREFIXES = (
"AI_AGENT_",
"LLM_",
"AUDIO_INPUT_",
"AUDIO_OUTPUT_",
"AI_RECOMMEND_",
)
def _normalize_token(value: str) -> str:
return str(value).strip().lower().replace("-", "_")
def _resolve_core_setting_group(key: str) -> str:
"""根据基础设置的业务归属返回 Agent 可查询的分类。"""
if key.startswith(AI_AGENT_CORE_SETTING_PREFIXES):
return "ai_agent"
return "settings"
def _build_specs() -> tuple[dict[str, SettingSpec], dict[str, SettingSpec]]:
core_specs = {
key: SettingSpec(
key=key,
source="settings",
group=_resolve_core_setting_group(key),
label=key,
)
key: SettingSpec(key=key, source="settings", group="settings", label=key)
for key in Settings.model_fields.keys()
}
system_specs = {}
@@ -287,7 +259,7 @@ SINGLE_KEY_GROUP_ALIASES = {
_normalize_token(alias): next(
(
spec.key
for spec in ALL_SETTING_SPECS.values()
for spec in SYSTEMCONFIG_SETTING_SPECS.values()
if spec.group == canonical_group
),
None,
@@ -297,7 +269,7 @@ SINGLE_KEY_GROUP_ALIASES = {
and len(
[
spec.key
for spec in ALL_SETTING_SPECS.values()
for spec in SYSTEMCONFIG_SETTING_SPECS.values()
if spec.group == canonical_group
]
)
@@ -348,7 +320,7 @@ def list_setting_specs(
else:
specs = [
spec
for spec in ALL_SETTING_SPECS.values()
for spec in SYSTEMCONFIG_SETTING_SPECS.values()
if spec.group == normalized_group
]
@@ -369,6 +341,26 @@ 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):
+12 -116
View File
@@ -14,8 +14,8 @@ from pathlib import Path
from typing import Any, Optional
from app.agent.tools.impl._command_safety import validate_command_safety
from app.runtime.log import logger
from app.runtime.settings import get_runtime_setting
from app.core.config import settings
from app.log import logger
if os.name == "posix":
import fcntl as _fcntl
@@ -128,11 +128,6 @@ class _TerminalSessionManager:
"""初始化会话表和并发保护锁。"""
self._sessions: dict[str, _TerminalSession] = {}
self._lock = asyncio.Lock()
self._close_lock = asyncio.Lock()
self._closed = False
self._starting = 0
self._starts_idle = asyncio.Event()
self._starts_idle.set()
@staticmethod
def _normalize_bool(value: Any, default: bool = True) -> bool:
@@ -149,10 +144,10 @@ class _TerminalSessionManager:
def _normalize_cwd(cwd: Optional[str]) -> str:
"""解析工作目录,未传入时默认使用 MoviePilot 项目根目录。"""
if not cwd:
return str(get_runtime_setting('ROOT_PATH'))
return str(settings.ROOT_PATH)
path = Path(cwd).expanduser()
if not path.is_absolute():
path = (get_runtime_setting('ROOT_PATH') / path).resolve()
path = (settings.ROOT_PATH / path).resolve()
else:
path = path.resolve()
if not path.exists():
@@ -216,55 +211,20 @@ class _TerminalSessionManager:
should_use_pty = self._normalize_bool(use_pty, default=True) and os.name == "posix"
async with self._lock:
if self._closed:
raise RuntimeError("终端会话管理器已关闭")
self._cleanup_finished_sessions_locked()
if (
self._active_session_count_locked() + self._starting
>= TERMINAL_CONCURRENCY_LIMIT
):
if self._active_session_count_locked() >= TERMINAL_CONCURRENCY_LIMIT:
raise RuntimeError(
f"后台终端会话数已达到上限 {TERMINAL_CONCURRENCY_LIMIT}"
)
self._starting += 1
self._starts_idle.clear()
session: Optional[_TerminalSession] = None
reject_session = False
session_registered = False
session_released = False
try:
session = (
await self._start_pty_session(command, normalized_cwd, normalized_env)
if should_use_pty
else await self._start_pipe_session(
command, normalized_cwd, normalized_env
)
)
session = (
await self._start_pty_session(command, normalized_cwd, normalized_env)
if should_use_pty
else await self._start_pipe_session(command, normalized_cwd, normalized_env)
)
async with self._lock:
reject_session = self._closed
if not reject_session:
self._sessions[session.session_id] = session
session_registered = True
if reject_session:
await self._terminate_session(session)
session_released = True
raise RuntimeError("终端会话管理器已关闭")
except BaseException:
if session is not None and not session_registered and not session_released:
cleanup_task = asyncio.create_task(self._terminate_session(session))
try:
await asyncio.shield(cleanup_task)
except asyncio.CancelledError:
await cleanup_task
raise
finally:
async with self._lock:
self._starting -= 1
if self._starting == 0:
self._starts_idle.set()
async with self._lock:
self._sessions[session.session_id] = session
logger.info(
"启动后台终端会话: session_id=%s, pid=%s, use_pty=%s, command=%s",
@@ -513,62 +473,6 @@ class _TerminalSessionManager:
return self._session_payload(session, output="", output_truncated=False)
async def close(self) -> None:
"""停止所有后台终端会话并释放 PTY、读取任务和会话记录。"""
async with self._close_lock:
async with self._lock:
self._closed = True
await self._starts_idle.wait()
async with self._lock:
sessions = list(self._sessions.values())
await asyncio.gather(
*(self._terminate_session(session) for session in sessions),
return_exceptions=True,
)
async with self._lock:
for session in sessions:
session.close_pty()
self._sessions.clear()
async def _terminate_session(self, session: _TerminalSession) -> None:
"""以有限等待停止进程,并在必要时升级为 SIGKILL。"""
if session.status == "running":
session.kill_requested = True
self._send_signal(session, signal.SIGTERM)
wait_task = session.wait_task
if wait_task and not wait_task.done():
try:
await asyncio.wait_for(
asyncio.shield(wait_task),
timeout=TERMINAL_KILL_GRACE_SECONDS,
)
except asyncio.TimeoutError:
force_signal = getattr(signal, "SIGKILL", signal.SIGTERM)
self._send_signal(session, force_signal)
try:
await asyncio.wait_for(
asyncio.shield(wait_task),
timeout=TERMINAL_KILL_GRACE_SECONDS,
)
except asyncio.TimeoutError:
logger.error(
"终端会话关闭超时: session_id=%s, pid=%s",
session.session_id,
session.pid,
)
for task in session.reader_tasks:
if not task.done():
task.cancel()
if session.reader_tasks:
await asyncio.gather(*session.reader_tasks, return_exceptions=True)
session.close_pty()
def get_session(self, session_id: str) -> _TerminalSession:
"""按 ID 获取会话,不存在时抛出清晰错误。"""
session = self._sessions.get(session_id)
@@ -721,11 +625,3 @@ class _TerminalSessionManager:
terminal_session_manager = _TerminalSessionManager()
def get_terminal_session_manager() -> _TerminalSessionManager:
"""返回当前进程的终端会话管理器,避免复用已完成关停的实例。"""
global terminal_session_manager
if terminal_session_manager._closed:
terminal_session_manager = _TerminalSessionManager()
return terminal_session_manager
+25 -43
View File
@@ -3,11 +3,9 @@
import re
from typing import List, Optional
from app.domain.context import Context
from app.schemas.types import MediaType, media_type_to_agent
from app.foundation.crypto import HashUtils
from app.foundation import size as size_tools
from ._music_utils import simplify_music_info
from app.core.context import Context
from app.utils.crypto import HashUtils
from app.utils.string import StringUtils
SEARCH_RESULT_CACHE_FILE = "__search_result__"
TORRENT_RESULT_LIMIT = 50
@@ -152,7 +150,7 @@ def simplify_search_result(
if torrent_info:
simplified["torrent_info"] = {
"title": torrent_info.title,
"size": size_tools.format_size(torrent_info.size),
"size": StringUtils.format_size(torrent_info.size),
"seeders": torrent_info.seeders,
"peers": torrent_info.peers,
"site_name": torrent_info.site_name,
@@ -168,44 +166,28 @@ def simplify_search_result(
simplified["torrent_info"]["labels"] = torrent_info.labels or []
if media_info:
if getattr(media_info, "type", None) == MediaType.MUSIC:
simplified["media_info"] = simplify_music_info(media_info)
else:
simplified["media_info"] = {
"title": getattr(media_info, "title", None),
"en_title": getattr(media_info, "en_title", None),
"year": getattr(media_info, "year", None),
"type": media_type_to_agent(getattr(media_info, "type", None)),
"season": getattr(media_info, "season", None),
"tmdb_id": getattr(media_info, "tmdb_id", None),
}
simplified["media_info"] = {
"title": media_info.title,
"en_title": media_info.en_title,
"year": media_info.year,
"type": media_info.type.value if media_info.type else None,
"season": media_info.season,
"tmdb_id": media_info.tmdb_id,
}
if meta_info:
if getattr(meta_info, "type", None) == MediaType.MUSIC:
simplified["meta_info"] = {
key: getattr(meta_info, key, None)
for key in (
"title", "artists", "album", "album_artist", "year",
"disc_number", "track_number", "total_tracks", "version",
"audio_format", "bit_depth", "sample_rate", "bitrate",
"duration", "isrc", "media_source", "media_id",
)
if getattr(meta_info, key, None) not in (None, "", [])
}
simplified["meta_info"]["type"] = "music"
else:
simplified["meta_info"] = {
"name": getattr(meta_info, "name", None),
"cn_name": getattr(meta_info, "cn_name", None),
"en_name": getattr(meta_info, "en_name", None),
"year": getattr(meta_info, "year", None),
"type": media_type_to_agent(getattr(meta_info, "type", None)),
"begin_season": getattr(meta_info, "begin_season", None),
"season_episode": getattr(meta_info, "season_episode", None),
"resource_team": getattr(meta_info, "resource_team", None),
"video_encode": getattr(meta_info, "video_encode", None),
"edition": getattr(meta_info, "edition", None),
"resource_pix": getattr(meta_info, "resource_pix", None),
}
simplified["meta_info"] = {
"name": meta_info.name,
"cn_name": meta_info.cn_name,
"en_name": meta_info.en_name,
"year": meta_info.year,
"type": meta_info.type.value if meta_info.type else None,
"begin_season": meta_info.begin_season,
"season_episode": meta_info.season_episode,
"resource_team": meta_info.resource_team,
"video_encode": meta_info.video_encode,
"edition": meta_info.edition,
"resource_pix": meta_info.resource_pix,
}
return simplified
@@ -13,7 +13,7 @@ from app.agent.tools.impl._filter_rule_utils import (
save_system_config,
serialize_custom_rule,
)
from app.runtime.log import logger
from app.log import logger
from app.schemas.types import SystemConfigKey

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