Compare commits
312 Commits
Soulter-patch-4
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c0fb31e7d | |||
| 7aae048405 | |||
| df1e59e01c | |||
| 25f9effcc9 | |||
| 5caf3a4793 | |||
| 458e8e0db8 | |||
| 976398d1f2 | |||
| f5ba1a026a | |||
| dcffb5269a | |||
| 4b7d42c2a3 | |||
| f6321be8c8 | |||
| ebd232ec8e | |||
| 1fd3d4ce0e | |||
| 26d69c96d1 | |||
| 3dcdb8b29c | |||
| 437adead28 | |||
| d5b98b353c | |||
| acbc5150cf | |||
| 85cfd62014 | |||
| 1c7c2ee0cd | |||
| ed47420678 | |||
| 6d687691a2 | |||
| 6db0959bb1 | |||
| 0c71d351ee | |||
| f00ba5adc6 | |||
| a05bfed15d | |||
| a027fb310c | |||
| d3d4e1db7b | |||
| 78b3e12c66 | |||
| c42ac87ee1 | |||
| 4b0d9ae979 | |||
| a1a3db2218 | |||
| 3e278dbd9e | |||
| 7733ccc54a | |||
| 9c7c0ec95a | |||
| 2685528cbd | |||
| 3f24f82486 | |||
| 38f21675d5 | |||
| 3fbd16b211 | |||
| e77500ff69 | |||
| 2c49ac0dcf | |||
| c0e07971b3 | |||
| 7cce05c459 | |||
| 0a16df2837 | |||
| e2365a53b9 | |||
| 7dc142ddf2 | |||
| 65decfbe87 | |||
| 92c31192de | |||
| 8e6c835b85 | |||
| fb2a2a63f2 | |||
| b795f804a7 | |||
| bc3b5e58a4 | |||
| 7e3c32b828 | |||
| ceb32dce9f | |||
| 84e880af5f | |||
| 9909d774ed | |||
| 6b3868b4be | |||
| 11c840953a | |||
| 2bbca887ce | |||
| dd89a4b334 | |||
| a3fa8a5a7c | |||
| aa60467782 | |||
| d936bb0a10 | |||
| 3f863cce7f | |||
| c42bd3150d | |||
| 4c22abd99c | |||
| f08147dc38 | |||
| 11d40ac0c3 | |||
| 64e0183b55 | |||
| 420d82df11 | |||
| d87cf897da | |||
| 2f51916a73 | |||
| b0e10cf479 | |||
| 20efaa5320 | |||
| 3ccd70cd4e | |||
| da520e573a | |||
| 6d055e81e9 | |||
| d41ccb70c5 | |||
| 18a99a25c2 | |||
| 04aee2890a | |||
| c18165909e | |||
| 0b534f65c2 | |||
| c9910d4a66 | |||
| 342b378de1 | |||
| 7579db11be | |||
| b5a40a66fa | |||
| 282ff8d414 | |||
| f3cdb7c006 | |||
| c3afc3d72b | |||
| 0c74bd1aeb | |||
| 070f281dae | |||
| 28a0f372fc | |||
| d7457f38d4 | |||
| 96cafe001d | |||
| 29d100dd83 | |||
| 14f3701c4a | |||
| 1044fc48ca | |||
| 693c2ca818 | |||
| da1565ee81 | |||
| 7d3401fec0 | |||
| fca691b3ca | |||
| ca8f356812 | |||
| b1c486ba98 | |||
| 9363fb824a | |||
| 044b361ac5 | |||
| 06fd2d2428 | |||
| dd6bc1dcdb | |||
| 52d5258b10 | |||
| 91933bbd19 | |||
| f8d075b5d3 | |||
| a4a0a5bb1a | |||
| 86ef758a9a | |||
| 1a03180643 | |||
| 326183a3fd | |||
| 08fc657755 | |||
| 0ff9539599 | |||
| 38f5e077ee | |||
| 89fbd75e7a | |||
| 493662524a | |||
| 1afbb357db | |||
| 8d2140f607 | |||
| 97732987d9 | |||
| a60a40bca3 | |||
| a8ff2b3d9c | |||
| 3a8bfa0873 | |||
| c07fba7add | |||
| 855483c8c2 | |||
| 048c511b18 | |||
| dfc0c34d95 | |||
| a21bb5b234 | |||
| 994d39241e | |||
| e6c1164755 | |||
| 89cc8a1a65 | |||
| c0e4f1e114 | |||
| 7b43448ce4 | |||
| bdac0b65f4 | |||
| cf9ee6f20c | |||
| 01eae72a64 | |||
| bca1476eab | |||
| fbcbde0a4b | |||
| 3914d766db | |||
| b1a119edb4 | |||
| 3dc4bb8e34 | |||
| f5e7ca12f7 | |||
| 7c3cc7b90c | |||
| a5a1ba72fd | |||
| e1d76117b4 | |||
| ad3911a21f | |||
| 3440dcd14b | |||
| e85eef05b8 | |||
| f16edd4fff | |||
| 3e2cb6a2ab | |||
| 25830524f3 | |||
| 304094630c | |||
| 5c3643c54c | |||
| 589cce18af | |||
| e254caf82d | |||
| 438fc105cd | |||
| 7efcd242d6 | |||
| 5d811d3949 | |||
| 8e6aaee10c | |||
| 6da59cfb07 | |||
| eae87e1ec9 | |||
| 894d72e657 | |||
| 42b8293f99 | |||
| 10ceacfbb1 | |||
| 66f5ccd902 | |||
| 3379587223 | |||
| e25a1a42cf | |||
| 21f1fa82f4 | |||
| 0c771e4a77 | |||
| ff4412a627 | |||
| bf430e659a | |||
| ec21cb13d3 | |||
| 1d26b96d90 | |||
| be017c87f4 | |||
| 23fffa95c8 | |||
| 5b303e2e6d | |||
| fc33b3eb68 | |||
| 795aec9578 | |||
| 7d31140c14 | |||
| 654112ca86 | |||
| 5dd30f9a45 | |||
| a53a1ca49b | |||
| 3fd6c4c8a6 | |||
| 5808784f07 | |||
| 537849c1e7 | |||
| 7f3c0fdeb2 | |||
| 8e431e2076 | |||
| 89c11fd683 | |||
| 7cfe2aca99 | |||
| 3a938d2a13 | |||
| 812834bc9f | |||
| 51ff4f6e46 | |||
| 7ac169c5e8 | |||
| 61648ebe3e | |||
| 0610f0db0a | |||
| 8c935981bb | |||
| 3f3b4e4924 | |||
| af581e7f21 | |||
| 9e371ee10b | |||
| 7cf77adbc8 | |||
| 31673ee521 | |||
| ff22030dde | |||
| 101580fd77 | |||
| 418f05f6e4 | |||
| df421e5554 | |||
| ed84074a60 | |||
| bbf61239ad | |||
| 92ee534a2c | |||
| fa4df0b5f3 | |||
| e5ac31efe7 | |||
| 2a7745c767 | |||
| 82e7502f74 | |||
| 866e546b59 | |||
| 6b642d7674 | |||
| 0711ec346f | |||
| bbafb59cb2 | |||
| eaa1fddfa9 | |||
| 1ffa339a2a | |||
| 0dbe32e2dc | |||
| 4e855a17bc | |||
| f2fc724e0f | |||
| 460acf40c0 | |||
| cf29d9390f | |||
| ac44d1fdef | |||
| 66d0f0afd4 | |||
| 2a7b4f6e64 | |||
| 6e1be64aef | |||
| f818ad0758 | |||
| 4abea2bd30 | |||
| 267abfd552 | |||
| b4450eb617 | |||
| daa2efde14 | |||
| d561046ba3 | |||
| fd223bb259 | |||
| 451ad685ae | |||
| 93decaa997 | |||
| 0d1a3ab18b | |||
| 2a6863cf70 | |||
| 76e0d6d71a | |||
| 974bb6b359 | |||
| 2e410fc728 | |||
| 0e2ca0379f | |||
| 9214d48a2d | |||
| 064495698f | |||
| 7c913093b0 | |||
| edf0982ce4 | |||
| 7bf44bd8d2 | |||
| 881b409ebc | |||
| eacfd14218 | |||
| b8ffecf500 | |||
| 74a46464c8 | |||
| e5d85e402b | |||
| 4aa63dbeaf | |||
| ddc268a732 | |||
| ea21d44d60 | |||
| 0f734e19fd | |||
| 6044502968 | |||
| fed11fffa4 | |||
| f79f460b89 | |||
| a6009e2bd8 | |||
| 483048e3dc | |||
| a219a8b70d | |||
| c1de265baf | |||
| 13c8fa3f92 | |||
| 4ff4c5f1bf | |||
| 73e665bef7 | |||
| 4b1bda5f2e | |||
| 18114eafda | |||
| 87cbcc9875 | |||
| 1ebc2070c0 | |||
| e95bd8d3a6 | |||
| d5a3107f8f | |||
| 8d5841b71f | |||
| 8faed949c2 | |||
| e1719efbc8 | |||
| f01c23ad40 | |||
| 847ef0f3f4 | |||
| 48a0b97ac0 | |||
| d21212d0e4 | |||
| c1917ebf4f | |||
| b816045f37 | |||
| 1df1138d04 | |||
| 1962ff2def | |||
| 92a8e40cde | |||
| 3769f145ee | |||
| 18ebeae318 | |||
| 7e246477f0 | |||
| bc3e09f47b | |||
| 707db768ea | |||
| 591803d407 | |||
| b48919246d | |||
| cf9a7235f7 | |||
| d62a6f107b | |||
| 1a539830f8 | |||
| 418913aa53 | |||
| 4b07aa2bc3 | |||
| 64d8daa67d | |||
| 9d44947500 | |||
| 4043a10531 | |||
| 7c8dac2fd5 | |||
| 963122b916 | |||
| aa3b012d60 | |||
| 401dfb9ee2 | |||
| 1d81c52950 | |||
| 40c7cf3901 | |||
| afe292de35 | |||
| d4dcc6430f | |||
| a8cc995633 | |||
| 73251db1da | |||
| d16398a0e8 |
@@ -3,8 +3,8 @@
|
||||
|
||||
### Modifications / 改动点
|
||||
|
||||
<!--请总结你的改动:哪些核心文件被修改了?实现了什么功能?-->
|
||||
<!--Please summarize your changes: What core files were modified? What functionality was implemented?-->
|
||||
<!--请总结你的改动:哪些核心文件被修改了?实现了什么功能?-->
|
||||
|
||||
- [x] This is NOT a breaking change. / 这不是一个破坏性变更。
|
||||
<!-- If your changes is a breaking change, please uncheck the checkbox above -->
|
||||
@@ -21,7 +21,14 @@
|
||||
<!--If merged, your code will serve tens of thousands of users! Please double-check the following items before submitting.-->
|
||||
<!--如果分支被合并,您的代码将服务于数万名用户!在提交前,请核查一下几点内容。-->
|
||||
|
||||
- [ ] 😊 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。/ If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
|
||||
- [ ] 👀 我的更改经过了良好的测试,**并已在上方提供了“验证步骤”和“运行截图”**。/ My changes have been well-tested, **and "Verification Steps" and "Screenshots" have been provided above**.
|
||||
- [ ] 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到了 `requirements.txt` 和 `pyproject.toml` 文件相应位置。/ I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in `requirements.txt` and `pyproject.toml`.
|
||||
- [ ] 😮 我的更改没有引入恶意代码。/ My changes do not introduce malicious code.
|
||||
- [ ] 😊 If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
|
||||
/ 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。
|
||||
|
||||
- [ ] 👀 My changes have been well-tested, **and "Verification Steps" and "Screenshots" have been provided above**.
|
||||
/ 我的更改经过了良好的测试,**并已在上方提供了“验证步骤”和“运行截图”**。
|
||||
|
||||
- [ ] 🤓 I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in `requirements.txt` and `pyproject.toml`.
|
||||
/ 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到 `requirements.txt` 和 `pyproject.toml` 文件相应位置。
|
||||
|
||||
- [ ] 😮 My changes do not introduce malicious code.
|
||||
/ 我的更改没有引入恶意代码。
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest # 运行环境
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: nodejs installation
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "18"
|
||||
- name: npm install
|
||||
run: npm add -D vitepress
|
||||
working-directory: './docs' # working-directory 指定 shell 命令运行目录
|
||||
- name: npm run build
|
||||
run: npm run docs:build
|
||||
working-directory: './docs'
|
||||
- name: scp
|
||||
uses: appleboy/scp-action@v1.0.0
|
||||
with:
|
||||
host: ${{ secrets.HOST_NEKO }}
|
||||
username: ${{ secrets.USERNAME }}
|
||||
password: ${{ secrets.PASSWORDNEKO }}
|
||||
source: 'docs/.vitepress/dist/*'
|
||||
target: '/tmp/'
|
||||
- name: script
|
||||
uses: appleboy/ssh-action@v1.2.5
|
||||
with:
|
||||
host: ${{ secrets.HOST_NEKO }}
|
||||
username: ${{ secrets.USERNAME }}
|
||||
password: ${{ secrets.PASSWORDNEKO }}
|
||||
script: |
|
||||
mkdir -p /root/docker_data/caddy/caddy_data/static_site/abv4/
|
||||
rm -rf /root/docker_data/caddy/caddy_data/static_site/abv4/*
|
||||
mv /tmp/docs/.vitepress/dist/* /root/docker_data/caddy/caddy_data/static_site/abv4/
|
||||
rm -rf /tmp/docs/
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
zip -r dist.zip dist
|
||||
|
||||
- name: Archive production artifacts
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: dist-without-markdown
|
||||
path: |
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: github.event_name == 'push'
|
||||
uses: ncipollo/release-action@v1
|
||||
uses: ncipollo/release-action@v1.21.0
|
||||
with:
|
||||
tag: release-${{ github.sha }}
|
||||
owner: AstrBotDevs
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Deploy Dashboard to GitHub Pages
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *' # Runs daily at midnight UTC
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
# Only allow one concurrent deployment at a time
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: dashboard
|
||||
run: bun install
|
||||
|
||||
- name: Build dashboard
|
||||
working-directory: dashboard
|
||||
run: bun run build
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: dashboard/dist
|
||||
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -64,20 +64,20 @@ jobs:
|
||||
echo "build_date=$build_date" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4.0.0
|
||||
|
||||
- name: Set Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4.0.0
|
||||
|
||||
- name: Log in to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: env.HAS_GHCR_TOKEN == 'true'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ env.GHCR_OWNER }}
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and Push Nightly Image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7.0.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@@ -163,27 +163,27 @@ jobs:
|
||||
cp -r dashboard/dist data/
|
||||
|
||||
- name: Set QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4.0.0
|
||||
|
||||
- name: Set Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4.0.0
|
||||
|
||||
- name: Log in to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: env.HAS_GHCR_TOKEN == 'true'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ env.GHCR_OWNER }}
|
||||
password: ${{ secrets.GHCR_GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and Push Release Image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7.0.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
name: PR Title Check
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, edited, reopened, synchronize]
|
||||
|
||||
jobs:
|
||||
title-format:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Validate PR title
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const title = (context.payload.pull_request.title || "").trim();
|
||||
// allow only:
|
||||
// feat: xxx
|
||||
// feat(scope): xxx
|
||||
const pattern = /^(feat)(\([a-z0-9-]+\))?:\s.+$/i;
|
||||
const isValid = pattern.test(title);
|
||||
const isSameRepo =
|
||||
context.payload.pull_request.head.repo.full_name === context.payload.repository.full_name;
|
||||
|
||||
if (!isValid) {
|
||||
if (isSameRepo) {
|
||||
try {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
body: [
|
||||
"⚠️ PR title format check failed.",
|
||||
"Required formats:",
|
||||
"- `feat: xxx`",
|
||||
"- `feat(scope): xxx`",
|
||||
"Please update your PR title and push again."
|
||||
].join("\n")
|
||||
});
|
||||
} catch (e) {
|
||||
core.warning(`Failed to post PR title comment: ${e.message}`);
|
||||
}
|
||||
} else {
|
||||
core.warning("Fork PR: comment permission is restricted; skip posting review comment.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
core.setFailed("Invalid PR title. Expected format: feat: xxx or feat(scope): xxx.");
|
||||
}
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v4.4.0
|
||||
with:
|
||||
version: 10.28.2
|
||||
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
zip -r "AstrBot-${{ steps.tag.outputs.tag }}-dashboard.zip" dist
|
||||
|
||||
- name: Upload dashboard artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: Dashboard-${{ steps.tag.outputs.tag }}
|
||||
if-no-files-found: error
|
||||
@@ -132,7 +132,7 @@ jobs:
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download dashboard artifact
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: Dashboard-${{ steps.tag.outputs.tag }}
|
||||
path: release-assets
|
||||
@@ -184,7 +184,8 @@ jobs:
|
||||
publish-pypi:
|
||||
name: Publish PyPI
|
||||
runs-on: ubuntu-24.04
|
||||
needs: publish-release
|
||||
needs:
|
||||
- publish-release
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
@@ -192,6 +193,36 @@ jobs:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Resolve tag
|
||||
id: tag
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
elif [ -n "${{ inputs.tag }}" ]; then
|
||||
tag="${{ inputs.tag }}"
|
||||
else
|
||||
tag="$(git describe --tags --abbrev=0)"
|
||||
fi
|
||||
if [ -z "$tag" ]; then
|
||||
echo "Failed to resolve tag." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download dashboard artifact
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: Dashboard-${{ steps.tag.outputs.tag }}
|
||||
path: dashboard-artifact
|
||||
|
||||
- name: Unpack dashboard dist into package tree
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p astrbot/dashboard/dist
|
||||
unzip -q "dashboard-artifact/AstrBot-${{ steps.tag.outputs.tag }}-dashboard.zip" -d dashboard-artifact/unpacked
|
||||
cp -r dashboard-artifact/unpacked/dist/. astrbot/dashboard/dist/
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
@@ -203,6 +234,8 @@ jobs:
|
||||
|
||||
- name: Build package
|
||||
shell: bash
|
||||
# Dashboard assets are already in astrbot/dashboard/dist/;
|
||||
# ASTRBOT_BUILD_DASHBOARD is intentionally unset so the hatch hook skips npm.
|
||||
run: uv build
|
||||
|
||||
- name: Publish to PyPI
|
||||
|
||||
@@ -5,9 +5,9 @@ on:
|
||||
branches:
|
||||
- master
|
||||
paths-ignore:
|
||||
- 'README*.md'
|
||||
- 'changelogs/**'
|
||||
- 'dashboard/**'
|
||||
- "README*.md"
|
||||
- "changelogs/**"
|
||||
- "dashboard/**"
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
name: Run smoke tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
@@ -26,8 +26,8 @@ jobs:
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install UV package manager
|
||||
run: |
|
||||
pip install uv
|
||||
@@ -40,6 +40,9 @@ jobs:
|
||||
- name: Run smoke tests
|
||||
run: |
|
||||
uv run main.py &
|
||||
# uv tool install -e . --force
|
||||
# astrbot init -y
|
||||
# astrbot run --backend-only &
|
||||
APP_PID=$!
|
||||
|
||||
echo "Waiting for application to start..."
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
name: sync wiki
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- '.github/workflows/sync-wiki.yml'
|
||||
- 'docs/scripts/sync_docs_to_wiki.py'
|
||||
- 'docs/tests/test_sync_docs_to_wiki.py'
|
||||
- 'docs/zh/**'
|
||||
- 'docs/en/**'
|
||||
|
||||
concurrency:
|
||||
group: sync-wiki-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Validate manual ref
|
||||
if: github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/master'
|
||||
run: |
|
||||
echo "This workflow only publishes from refs/heads/master. Re-run it from the master branch."
|
||||
exit 1
|
||||
|
||||
- name: Check out docs repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Run sync unit tests
|
||||
working-directory: docs
|
||||
run: python -m unittest discover -s tests -p 'test_sync_docs_to_wiki.py' -v
|
||||
|
||||
- name: Validate internal doc links
|
||||
run: python docs/scripts/sync_docs_to_wiki.py --source-root docs --check-links-only
|
||||
|
||||
- name: Clone AstrBot wiki
|
||||
env:
|
||||
WIKI_TOKEN: ${{ secrets.ASTRBOT_WIKI_TOKEN }}
|
||||
run: |
|
||||
test -n "$WIKI_TOKEN"
|
||||
git clone "https://x-access-token:${WIKI_TOKEN}@github.com/AstrBotDevs/AstrBot.wiki.git" wiki
|
||||
|
||||
- name: Generate wiki pages
|
||||
run: python docs/scripts/sync_docs_to_wiki.py --source-root docs --wiki-root wiki
|
||||
|
||||
- name: Commit and push wiki changes
|
||||
working-directory: wiki
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add .
|
||||
if git diff --cached --quiet; then
|
||||
echo "No wiki changes to push"
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "docs: sync wiki from AstrBot-1/docs"
|
||||
git push
|
||||
@@ -36,6 +36,9 @@ dashboard/dist/
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# Bundled dashboard dist (generated by hatch_build.py during pip wheel build)
|
||||
astrbot/dashboard/dist/
|
||||
|
||||
# Operating System
|
||||
**/.DS_Store
|
||||
.DS_Store
|
||||
@@ -54,3 +57,9 @@ IFLOW.md
|
||||
# genie_tts data
|
||||
CharacterModels/
|
||||
GenieData/
|
||||
.agent/
|
||||
.codex/
|
||||
.opencode/
|
||||
.kilocode/
|
||||
.serena
|
||||
.worktrees/
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.12
|
||||
3.12
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
### Core
|
||||
|
||||
```
|
||||
uv sync
|
||||
uv run main.py
|
||||
uv tool install -e . --force
|
||||
astrbot init
|
||||
astrbot run # start the bot
|
||||
astrbot run --backend-only # start the backend only
|
||||
```
|
||||
|
||||
Exposed an API server on `http://localhost:6185` by default.
|
||||
@@ -13,8 +15,8 @@ Exposed an API server on `http://localhost:6185` by default.
|
||||
|
||||
```
|
||||
cd dashboard
|
||||
pnpm install # First time only. Use npm install -g pnpm if pnpm is not installed.
|
||||
pnpm dev
|
||||
bun install # First time only.
|
||||
bun dev
|
||||
```
|
||||
|
||||
Runs on `http://localhost:3000` by default.
|
||||
@@ -27,6 +29,8 @@ Runs on `http://localhost:3000` by default.
|
||||
4. When committing, ensure to use conventional commits messages, such as `feat: add new agent for data analysis` or `fix: resolve bug in provider manager`.
|
||||
5. Use English for all new comments.
|
||||
6. For path handling, use `pathlib.Path` instead of string paths, and use `astrbot.core.utils.path_utils` to get the AstrBot data and temp directory.
|
||||
7. Use Python 3.12+ type hinting syntax (e.g., `list[str]` over `List[str]`, `int | None` over `Optional[int]`). Avoid using `Any` and ensure comprehensive type annotations are provided.
|
||||
|
||||
|
||||
## PR instructions
|
||||
|
||||
|
||||
@@ -46,6 +46,32 @@ ruff check .
|
||||
|
||||
如果您使用 VSCode,可以安装 `Ruff` 插件。
|
||||
|
||||
##### PR 功能完整性验证(推荐)
|
||||
|
||||
如果您希望在本地做一套接近 CI 的完整验证,可使用:
|
||||
|
||||
```bash
|
||||
make pr-test-neo
|
||||
```
|
||||
|
||||
该命令会执行:
|
||||
- `uv sync --group dev`
|
||||
- `ruff format --check .` 与 `ruff check .`
|
||||
- Neo 相关关键测试
|
||||
- `main.py` 启动 smoke test(检测 `http://localhost:6185`)
|
||||
|
||||
需要全量验证时可使用:
|
||||
|
||||
```bash
|
||||
make pr-test-full
|
||||
```
|
||||
|
||||
如果只想快速重复执行(跳过依赖同步和 dashboard 构建):
|
||||
|
||||
```bash
|
||||
make pr-test-full-fast
|
||||
```
|
||||
|
||||
|
||||
## Contributing Guide
|
||||
|
||||
@@ -88,3 +114,29 @@ We use Ruff as our code formatter and static analysis tool. Before submitting yo
|
||||
ruff format .
|
||||
ruff check .
|
||||
```
|
||||
|
||||
##### PR completeness checks (recommended)
|
||||
|
||||
To run a local validation flow close to CI, use:
|
||||
|
||||
```bash
|
||||
make pr-test-neo
|
||||
```
|
||||
|
||||
This command runs:
|
||||
- `uv sync --group dev`
|
||||
- `ruff format --check .` and `ruff check .`
|
||||
- Neo-related critical tests
|
||||
- a startup smoke test against `http://localhost:6185`
|
||||
|
||||
For full validation, use:
|
||||
|
||||
```bash
|
||||
make pr-test-full
|
||||
```
|
||||
|
||||
For faster repeated runs (skip dependency sync and dashboard build), use:
|
||||
|
||||
```bash
|
||||
make pr-test-full-fast
|
||||
```
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: worktree worktree-add worktree-rm
|
||||
.PHONY: worktree worktree-add worktree-rm pr-test-neo pr-test-full pr-test-full-fast
|
||||
|
||||
WORKTREE_DIR ?= ../astrbot_worktree
|
||||
BRANCH ?= $(word 2,$(MAKECMDGOALS))
|
||||
@@ -27,6 +27,15 @@ endif
|
||||
echo "Worktree $(WORKTREE_DIR)/$(BRANCH) not found."; \
|
||||
fi
|
||||
|
||||
pr-test-neo:
|
||||
./scripts/pr_test_env.sh --profile neo
|
||||
|
||||
pr-test-full:
|
||||
./scripts/pr_test_env.sh --profile full
|
||||
|
||||
pr-test-full-fast:
|
||||
./scripts/pr_test_env.sh --profile full --skip-sync --no-dashboard
|
||||
|
||||
# Swallow extra args (branch/base) so make doesn't treat them as targets
|
||||
%:
|
||||
@true
|
||||
|
||||
@@ -3,13 +3,11 @@
|
||||
<div align="center">
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh.md">中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
||||
|
||||
<br>
|
||||
|
||||
<div>
|
||||
<a href="https://trendshift.io/repositories/12875" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12875" alt="Soulter%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<a href="https://hellogithub.com/repository/AstrBotDevs/AstrBot" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=d127d50cd5e54c5382328acc3bb25483&claim_uid=ZO9by7qCXgSd6Lp&t=2" alt="Featured|HelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
@@ -21,41 +19,44 @@
|
||||
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest">
|
||||
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
|
||||
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
|
||||
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
|
||||
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTk0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
|
||||
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?color=76bad9"/></a>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%20plugins&label=Marketplace&cacheSeconds=3600">
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%20Plugins&label=Marketplace&cacheSeconds=3600">
|
||||
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<a href="https://astrbot.app/">Documentation</a> |
|
||||
<a href="https://astrbot.app/">Home</a> |
|
||||
<a href="https://astrbot.app/">Docs</a> |
|
||||
<a href="https://blog.astrbot.app/">Blog</a> |
|
||||
<a href="https://astrbot.featurebase.app/roadmap">Roadmap</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/issues">Issue Tracker</a>
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/issues">Issues</a>
|
||||
<a href="mailto:community@astrbot.app">Email</a>
|
||||
|
||||
</div>
|
||||
|
||||
AstrBot is an open-source all-in-one Agent chatbot platform that integrates with mainstream instant messaging apps. It provides reliable and scalable conversational AI infrastructure for individuals, developers, and teams. Whether you're building a personal AI companion, intelligent customer service, automation assistant, or enterprise knowledge base, AstrBot enables you to quickly build production-ready AI applications within your IM platform workflows.
|
||||
AstrBot is an open-source, all-in-one Agentic personal and group chat assistant that can be deployed on dozens of mainstream instant messaging platforms such as QQ, Telegram, WeCom, Lark, DingTalk, Slack, and more. It also features a built-in lightweight ChatUI similar to OpenWebUI, creating a reliable and scalable conversational AI infrastructure for individuals, developers, and teams. Whether it's a personal AI companion, smart customer service, automated assistant, or enterprise knowledge base, AstrBot enables you to quickly build AI applications within the workflow of your instant messaging platforms.
|
||||
|
||||

|
||||

|
||||
|
||||
## Key Features
|
||||
|
||||
1. 💯 Free & Open Source.
|
||||
2. ✨ AI LLM Conversations, Multimodal, Agent, MCP, Skills, Knowledge Base, Persona Settings, Auto Context Compression.
|
||||
3. 🤖 Supports integration with Dify, Alibaba Cloud Bailian, Coze, and other agent platforms.
|
||||
4. 🌐 Multi-Platform: QQ, WeChat Work, Feishu, DingTalk, WeChat Official Accounts, Telegram, Slack, and [more](#supported-messaging-platforms).
|
||||
5. 📦 Plugin Extensions with 1000+ plugins available for one-click installation.
|
||||
6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) for isolated, safe execution of code, shell calls, and session-level resource reuse.
|
||||
7. 💻 WebUI Support.
|
||||
8. 🌈 Web ChatUI Support with built-in agent sandbox and web search.
|
||||
9. 🌐 Internationalization (i18n) Support.
|
||||
2. ✨ Large Language Model (LLM) dialogue, Multimodal, Agent, MCP, Skills, Knowledge Base, Persona settings, automatic dialogue compression.
|
||||
3. 🤖 Supports integration with agent platforms such as Dify, Alibaba Bailian, Coze, etc.
|
||||
4. 🌐 Multi-platform support: QQ, WeCom, Lark, DingTalk, WeChat Official Account, Telegram, Slack, and [more](#supported-message-platforms).
|
||||
5. 📦 Plugin extension: 1000+ plugins available for one-click installation.
|
||||
6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html): Isolated environment for safely executing any code, calling Shell commands, and reusing session-level resources.
|
||||
7. 💻 WebUI support.
|
||||
8. 🌈 Web ChatUI support: Built-in proxy sandbox, web search, etc. within ChatUI.
|
||||
9. 🌐 Internationalization (i18n) support.
|
||||
|
||||
<br>
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<th>💙 Role-playing & Emotional Companionship</th>
|
||||
<th>💙 Roleplay & Companionship</th>
|
||||
<th>✨ Proactive Agent</th>
|
||||
<th>🚀 General Agentic Capabilities</th>
|
||||
<th>🧩 1000+ Community Plugins</th>
|
||||
@@ -70,187 +71,191 @@ AstrBot is an open-source all-in-one Agent chatbot platform that integrates with
|
||||
|
||||
## Quick Start
|
||||
|
||||
#### Docker Deployment (Recommended 🥳)
|
||||
### One-Click Deployment
|
||||
|
||||
We recommend deploying AstrBot using Docker or Docker Compose.
|
||||
|
||||
Please refer to the official documentation: [Deploy AstrBot with Docker](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
|
||||
|
||||
#### uv Deployment
|
||||
For users who want to experience AstrBot quickly, are familiar with the command line, and can install the `uv` environment themselves, we recommend using `uv` for one-click deployment ⚡️.
|
||||
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
astrbot
|
||||
astrbot init # Execute this command only for the first time to initialize the environment
|
||||
astrbot run # astrbot run --backend-only starts only the backend service
|
||||
|
||||
# Install development version (more fixes and new features, but less stable; suitable for developers)
|
||||
uv tool install git+https://github.com/AstrBotDevs/AstrBot@dev
|
||||
```
|
||||
|
||||
#### System Package Manager Installation
|
||||
> Requires [uv](https://docs.astral.sh/uv/) installed.
|
||||
|
||||
##### Arch Linux
|
||||
> [!NOTE]
|
||||
> For macOS users: Due to macOS security checks, the first execution of the `astrbot` command may take a longer time (about 10-20 seconds).
|
||||
|
||||
Update `astrbot`:
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
# or use paru
|
||||
paru -S astrbot-git
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
#### Desktop Application (Tauri)
|
||||
### Docker Deployment
|
||||
|
||||
Desktop repository: [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
|
||||
For users familiar with containers who prefer a more stable deployment suitable for production environments, we recommend using Docker / Docker Compose to deploy AstrBot.
|
||||
|
||||
Supports multiple system architectures, direct installation, out-of-the-box experience. Ideal for beginners.
|
||||
Please refer to the official documentation [Deploy AstrBot with Docker](https://astrbot.app/deploy/astrbot/docker.html).
|
||||
|
||||
#### AstrBot Launcher
|
||||
### Deploy on RainYun
|
||||
|
||||
Quick deployment and multi-instance solution. Visit the [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) repository and find the latest release for your system.
|
||||
|
||||
#### BT-Panel Deployment
|
||||
|
||||
AstrBot has partnered with BT-Panel and is now available in their marketplace.
|
||||
|
||||
Please refer to the official documentation: [BT-Panel Deployment](https://astrbot.app/deploy/astrbot/btpanel.html).
|
||||
|
||||
#### 1Panel Deployment
|
||||
|
||||
AstrBot has been officially listed on the 1Panel marketplace.
|
||||
|
||||
Please refer to the official documentation: [1Panel Deployment](https://astrbot.app/deploy/astrbot/1panel.html).
|
||||
|
||||
#### Deploy on RainYun
|
||||
|
||||
For Chinese users:
|
||||
|
||||
AstrBot has been officially listed on RainYun's cloud application platform with one-click deployment.
|
||||
For users who want to deploy AstrBot with one click and do not want to manage servers themselves, we recommend RainYun's one-click cloud deployment service ☁️:
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||
|
||||
#### Deploy on Replit
|
||||
### Desktop Client Deployment
|
||||
|
||||
Community-contributed deployment method.
|
||||
For users who wish to use AstrBot on the desktop with ChatUI as the main interface, we recommend using the AstrBot App.
|
||||
|
||||
Go to [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) to download and install; this method is intended for desktop use and is not recommended for server scenarios.
|
||||
|
||||
### Launcher Deployment
|
||||
|
||||
Also for desktop, users who want quick deployment and isolated environments for multiple instances can use the AstrBot Launcher.
|
||||
|
||||
Go to [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) to download and install.
|
||||
|
||||
### Deploy on Replit
|
||||
|
||||
Replit deployment is maintained by the community, suitable for online demos and lightweight trials.
|
||||
|
||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||
|
||||
#### Windows One-Click Installer
|
||||
### AUR
|
||||
|
||||
Please refer to the official documentation: [Deploy AstrBot with Windows One-Click Installer](https://astrbot.app/deploy/astrbot/windows.html).
|
||||
The AUR method is for Arch Linux users who wish to install AstrBot via the system package manager.
|
||||
|
||||
#### CasaOS Deployment
|
||||
|
||||
Community-contributed deployment method.
|
||||
|
||||
Please refer to the official documentation: [CasaOS Deployment](https://astrbot.app/deploy/astrbot/casaos.html).
|
||||
|
||||
#### Manual Deployment
|
||||
|
||||
First, install uv:
|
||||
Execute the following command in the terminal to install the `astrbot-git` package. You can start using it after installation completes.
|
||||
|
||||
```bash
|
||||
pip install uv
|
||||
yay -S astrbot-git
|
||||
```
|
||||
|
||||
Install AstrBot via Git Clone:
|
||||
**More Deployment Methods**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AstrBotDevs/AstrBot && cd AstrBot
|
||||
uv run main.py
|
||||
```
|
||||
If you need panel-based or highly customized deployment, you can refer to [BT Panel](https://astrbot.app/deploy/astrbot/btpanel.html) (BT Panel App Store), [1Panel](https://astrbot.app/deploy/astrbot/1panel.html) (1Panel App Store), [CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) (NAS / Home Server visual deployment), and [Manual Deployment](https://astrbot.app/deploy/astrbot/cli.html) (Full custom installation based on source code and `uv`).
|
||||
|
||||
Or refer to the official documentation: [Deploy AstrBot from Source](https://astrbot.app/deploy/astrbot/cli.html).
|
||||
## Supported Message Platforms
|
||||
|
||||
## Supported Messaging Platforms
|
||||
|
||||
Connect AstrBot to your favorite chat platform.
|
||||
Connect AstrBot to your favorite chat platforms.
|
||||
|
||||
| Platform | Maintainer |
|
||||
|---------|---------------|
|
||||
| QQ | Official |
|
||||
| OneBot v11 protocol implementation | Official |
|
||||
| Telegram | Official |
|
||||
| WeChat Work Application & WeChat Work Intelligent Bot | Official |
|
||||
| WeChat Customer Service & WeChat Official Accounts | Official |
|
||||
| Feishu (Lark) | Official |
|
||||
| DingTalk | Official |
|
||||
| Slack | Official |
|
||||
| Discord | Official |
|
||||
| LINE | Official |
|
||||
| Satori | Official |
|
||||
| Misskey | Official |
|
||||
| WhatsApp (Coming Soon) | Official |
|
||||
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Community |
|
||||
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | Community |
|
||||
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | Community |
|
||||
| **QQ** | Official |
|
||||
| **OneBot v11** | Official |
|
||||
| **Telegram** | Official |
|
||||
| **WeCom App & Bot** | Official |
|
||||
| **WeChat Customer Service & Official Account** | Official |
|
||||
| **Lark (Feishu)** | Official |
|
||||
| **DingTalk** | Official |
|
||||
| **Slack** | Official |
|
||||
| **Discord** | Official |
|
||||
| **LINE** | Official |
|
||||
| **Satori** | Official |
|
||||
| **Misskey** | Official |
|
||||
| **Whatsapp (Coming Soon)** | Official |
|
||||
| [**Matrix**](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Community |
|
||||
| [**KOOK**](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | Community |
|
||||
| [**VoceChat**](https://github.com/HikariFroya/astrbot_plugin_vocechat) | Community |
|
||||
|
||||
## Supported Model Services
|
||||
## Supported Model Providers
|
||||
|
||||
| Service | Type |
|
||||
| Provider | Type |
|
||||
|---------|---------------|
|
||||
| OpenAI and Compatible Services | LLM Services |
|
||||
| Anthropic | LLM Services |
|
||||
| Google Gemini | LLM Services |
|
||||
| Moonshot AI | LLM Services |
|
||||
| Zhipu AI | LLM Services |
|
||||
| DeepSeek | LLM Services |
|
||||
| Ollama (Self-hosted) | LLM Services |
|
||||
| LM Studio (Self-hosted) | LLM Services |
|
||||
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | LLM Services |
|
||||
| [302.AI](https://share.302.ai/rr1M3l) | LLM Services |
|
||||
| [TokenPony](https://www.tokenpony.cn/3YPyf) | LLM Services |
|
||||
| [SiliconFlow](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) | LLM Services |
|
||||
| [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE) | LLM Services |
|
||||
| ModelScope | LLM Services |
|
||||
| OneAPI | LLM Services |
|
||||
| Dify | LLMOps Platforms |
|
||||
| Alibaba Cloud Bailian Applications | LLMOps Platforms |
|
||||
| Coze | LLMOps Platforms |
|
||||
| OpenAI Whisper | Speech-to-Text Services |
|
||||
| SenseVoice | Speech-to-Text Services |
|
||||
| OpenAI TTS | Text-to-Speech Services |
|
||||
| Gemini TTS | Text-to-Speech Services |
|
||||
| GPT-Sovits-Inference | Text-to-Speech Services |
|
||||
| GPT-Sovits | Text-to-Speech Services |
|
||||
| FishAudio | Text-to-Speech Services |
|
||||
| Edge TTS | Text-to-Speech Services |
|
||||
| Alibaba Cloud Bailian TTS | Text-to-Speech Services |
|
||||
| Azure TTS | Text-to-Speech Services |
|
||||
| Minimax TTS | Text-to-Speech Services |
|
||||
| Volcano Engine TTS | Text-to-Speech Services |
|
||||
| Custom | Any OpenAI API compatible service |
|
||||
| OpenAI | LLM |
|
||||
| Anthropic | LLM |
|
||||
| Google Gemini | LLM |
|
||||
| Moonshot AI | LLM |
|
||||
| Zhipu AI | LLM |
|
||||
| DeepSeek | LLM |
|
||||
| Ollama (Local) | LLM |
|
||||
| LM Studio (Local) | LLM |
|
||||
| [AIHubMix](https://aihubmix.com/?aff=4bfH) | LLM (API Gateway, supports all models) |
|
||||
| [Compshare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | LLM (API Gateway, supports all models) |
|
||||
| [SiliconFlow](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) | LLM (API Gateway, supports all models) |
|
||||
| [PPIO](https://ppio.com/user/register?invited_by=AIOONE) | LLM (API Gateway, supports all models) |
|
||||
| [302.AI](https://share.302.ai/rr1M3l) | LLM (API Gateway, supports all models)|
|
||||
| [TokenPony](https://www.tokenpony.cn/3YPyf) | LLM (API Gateway, supports all models)|
|
||||
| ModelScope | LLM |
|
||||
| OneAPI | LLM |
|
||||
| Dify | LLMOps Platform |
|
||||
| Alibaba Bailian | LLMOps Platform |
|
||||
| Coze | LLMOps Platform |
|
||||
| OpenAI Whisper | Speech-to-Text |
|
||||
| SenseVoice | Speech-to-Text |
|
||||
| OpenAI TTS | Text-to-Speech |
|
||||
| Gemini TTS | Text-to-Speech |
|
||||
| GPT-Sovits-Inference | Text-to-Speech |
|
||||
| GPT-Sovits | Text-to-Speech |
|
||||
| FishAudio | Text-to-Speech |
|
||||
| Edge TTS | Text-to-Speech |
|
||||
| Alibaba Bailian TTS | Text-to-Speech |
|
||||
| Azure TTS | Text-to-Speech |
|
||||
| Minimax TTS | Text-to-Speech |
|
||||
| Volcano Engine TTS | Text-to-Speech |
|
||||
|
||||
## ❤️ Contributing
|
||||
## ❤️ Contribution
|
||||
|
||||
Issues and Pull Requests are always welcome! Feel free to submit your changes to this project :)
|
||||
Welcome any Issues/Pull Requests! Just submit your changes to this project :)
|
||||
|
||||
### How to Contribute
|
||||
|
||||
You can contribute by reviewing issues or helping with pull request reviews. Any issues or PRs are welcome to encourage community participation. Of course, these are just suggestions—you can contribute in any way you like. For adding new features, please discuss through an Issue first.
|
||||
You can contribute by viewing issues or helping to review PRs (Pull Requests). Any issues or PRs are welcome to promote community contribution. Of course, these are just suggestions; you can contribute in any way. For new feature additions, please discuss via Issue first.
|
||||
It is recommended to merge functional PRs into the `dev` branch, which will be merged into the main branch and released as a new version after testing.
|
||||
To reduce conflicts, we suggest:
|
||||
1. Create your working branch based on the `dev` branch, avoid working directly on the `main` branch.
|
||||
2. When submitting a PR, select the `dev` branch as the target.
|
||||
3. Regularly sync the `dev` branch to your local environment; use `git pull` frequently.
|
||||
|
||||
### Development Environment
|
||||
|
||||
AstrBot uses `ruff` for code formatting and linting.
|
||||
AstrBot uses `ruff` for code formatting and checking.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AstrBotDevs/AstrBot
|
||||
pip install pre-commit
|
||||
git switch dev # Switch to dev branch
|
||||
pip install pre-commit # or uv tool install pre-commit
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
## 🌍 Community
|
||||
We recommend using `uv` for local installation and testing:
|
||||
|
||||
```bash
|
||||
uv tool install -e . --force
|
||||
astrbot init
|
||||
astrbot run
|
||||
```
|
||||
|
||||
Frontend Debugging:
|
||||
|
||||
```bash
|
||||
astrbot run --backend-only
|
||||
cd dashboard
|
||||
bun install # or pnpm, etc.
|
||||
bun dev
|
||||
```
|
||||
|
||||
### QQ Groups
|
||||
|
||||
- Group 9: 1076659624 (New)
|
||||
- Group 10: 1078079676 (New)
|
||||
- Group 1: 322154837
|
||||
- Group 3: 630166526
|
||||
- Group 5: 822130018
|
||||
- Group 6: 753075035
|
||||
- Group 7: 743746109
|
||||
- Group 8: 1030353265
|
||||
- Developer Group: 975206796
|
||||
- Developer Group (Casual): 975206796
|
||||
- Developer Group (Official): 1039761811
|
||||
|
||||
### Telegram Group
|
||||
### Discord Channel
|
||||
|
||||
<a href="https://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
|
||||
### Discord Server
|
||||
|
||||
<a href="https://discord.gg/hAVk6tgV36"><img alt="Discord_community" src="https://img.shields.io/badge/Discord-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
- [Discord](https://discord.gg/hAVk6tgV36)
|
||||
|
||||
## ❤️ Special Thanks
|
||||
|
||||
@@ -260,14 +265,24 @@ Special thanks to all Contributors and plugin developers for their contributions
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||
</a>
|
||||
|
||||
Additionally, the birth of this project would not have been possible without the help of the following open-source projects:
|
||||
In addition, the birth of this project cannot be separated from the help of the following open-source projects:
|
||||
|
||||
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - The amazing cat framework
|
||||
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - Great Cat Framework
|
||||
|
||||
Open Source Project Friendly Links:
|
||||
|
||||
- [NoneBot2](https://github.com/nonebot/nonebot2) - Excellent Python Asynchronous ChatBot Framework
|
||||
- [Koishi](https://github.com/koishijs/koishi) - Excellent Node.js ChatBot Framework
|
||||
- [MaiBot](https://github.com/Mai-with-u/MaiBot) - Excellent Anthropomorphic AI ChatBot
|
||||
- [nekro-agent](https://github.com/KroMiose/nekro-agent) - Excellent Agent ChatBot
|
||||
- [LangBot](https://github.com/langbot-app/LangBot) - Excellent Multi-platform AI ChatBot
|
||||
- [ChatLuna](https://github.com/ChatLunaLab/chatluna) - Excellent Multi-platform AI ChatBot Koishi Plugin
|
||||
- [Operit AI](https://github.com/AAswordman/Operit) - Excellent AI Assistant Android APP
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
> [!TIP]
|
||||
> If this project has helped you in your life or work, or if you're interested in its future development, please give the project a Star. It's the driving force behind maintaining this open-source project <3
|
||||
> If this project helps your life/work, or you are concerned about the future development of this project, please Star the project. This is our motivation to maintain this open-source project <3
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -277,9 +292,10 @@ Additionally, the birth of this project would not have been possible without the
|
||||
|
||||
<div align="center">
|
||||
|
||||
_Companionship and capability should never be at odds. What we aim to create is a robot that can understand emotions, provide genuine companionship, and reliably accomplish tasks._
|
||||
_Companionship and capability should never be opposites. We hope to create a robot that can both understand emotions, provide companionship, and reliably complete tasks._
|
||||
|
||||
_私は、高性能ですから!_
|
||||
|
||||
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
-285
@@ -1,285 +0,0 @@
|
||||

|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh.md">中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
||||
|
||||
<br>
|
||||
|
||||
<div>
|
||||
<a href="https://trendshift.io/repositories/12875" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12875" alt="Soulter%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<a href="https://hellogithub.com/repository/AstrBotDevs/AstrBot" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=d127d50cd5e54c5382328acc3bb25483&claim_uid=ZO9by7qCXgSd6Lp&t=2" alt="Featured|HelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<div>
|
||||
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest">
|
||||
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
|
||||
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
|
||||
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
|
||||
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?color=76bad9"/></a>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%20plugins&label=Marketplace&cacheSeconds=3600">
|
||||
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<a href="https://astrbot.app/">Documentation</a> |
|
||||
<a href="https://blog.astrbot.app/">Blog</a> |
|
||||
<a href="https://astrbot.featurebase.app/roadmap">Roadmap</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/issues">Issue Tracker</a>
|
||||
</div>
|
||||
|
||||
AstrBot is an open-source all-in-one Agent chatbot platform that integrates with mainstream instant messaging apps. It provides reliable and scalable conversational AI infrastructure for individuals, developers, and teams. Whether you're building a personal AI companion, intelligent customer service, automation assistant, or enterprise knowledge base, AstrBot enables you to quickly build production-ready AI applications within your IM platform workflows.
|
||||
|
||||

|
||||
|
||||
## Key Features
|
||||
|
||||
1. 💯 Free & Open Source.
|
||||
2. ✨ AI LLM Conversations, Multimodal, Agent, MCP, Skills, Knowledge Base, Persona Settings, Auto Context Compression.
|
||||
3. 🤖 Supports integration with Dify, Alibaba Cloud Bailian, Coze, and other agent platforms.
|
||||
4. 🌐 Multi-Platform: QQ, WeChat Work, Feishu, DingTalk, WeChat Official Accounts, Telegram, Slack, and [more](#supported-messaging-platforms).
|
||||
5. 📦 Plugin Extensions with 1000+ plugins available for one-click installation.
|
||||
6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) for isolated, safe execution of code, shell calls, and session-level resource reuse.
|
||||
7. 💻 WebUI Support.
|
||||
8. 🌈 Web ChatUI Support with built-in agent sandbox and web search.
|
||||
9. 🌐 Internationalization (i18n) Support.
|
||||
|
||||
<br>
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<th>💙 Role-playing & Emotional Companionship</th>
|
||||
<th>✨ Proactive Agent</th>
|
||||
<th>🚀 General Agentic Capabilities</th>
|
||||
<th>🧩 1000+ Community Plugins</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
|
||||
<td align="center"><p align="center"><img width="976" height="1612" alt="c449acd838c41d0915cc08a3824025b1" src="https://github.com/user-attachments/assets/f75368b4-e022-41dc-a9e0-131c3e73e32e" /></p></td>
|
||||
<td align="center"><p align="center"><img width="974" height="1732" alt="image" src="https://github.com/user-attachments/assets/e22a3968-87d7-4708-a7cd-e7f198c7c32e" /></p></td>
|
||||
<td align="center"><p align="center"><img width="976" height="1734" alt="image" src="https://github.com/user-attachments/assets/0952b395-6b4a-432a-8a50-c294b7f89750" /></p></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Quick Start
|
||||
|
||||
#### Docker Deployment (Recommended 🥳)
|
||||
|
||||
We recommend deploying AstrBot using Docker or Docker Compose.
|
||||
|
||||
Please refer to the official documentation: [Deploy AstrBot with Docker](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
|
||||
|
||||
#### uv Deployment
|
||||
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
astrbot
|
||||
```
|
||||
|
||||
#### System Package Manager Installation
|
||||
|
||||
##### Arch Linux
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
# or use paru
|
||||
paru -S astrbot-git
|
||||
```
|
||||
|
||||
#### Desktop Application (Tauri)
|
||||
|
||||
Desktop repository: [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
|
||||
|
||||
Supports multiple system architectures, direct installation, out-of-the-box experience. Ideal for beginners.
|
||||
|
||||
#### AstrBot Launcher
|
||||
|
||||
Quick deployment and multi-instance solution. Visit the [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) repository and find the latest release for your system.
|
||||
|
||||
#### BT-Panel Deployment
|
||||
|
||||
AstrBot has partnered with BT-Panel and is now available in their marketplace.
|
||||
|
||||
Please refer to the official documentation: [BT-Panel Deployment](https://astrbot.app/deploy/astrbot/btpanel.html).
|
||||
|
||||
#### 1Panel Deployment
|
||||
|
||||
AstrBot has been officially listed on the 1Panel marketplace.
|
||||
|
||||
Please refer to the official documentation: [1Panel Deployment](https://astrbot.app/deploy/astrbot/1panel.html).
|
||||
|
||||
#### Deploy on RainYun
|
||||
|
||||
For Chinese users:
|
||||
|
||||
AstrBot has been officially listed on RainYun's cloud application platform with one-click deployment.
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||
|
||||
#### Deploy on Replit
|
||||
|
||||
Community-contributed deployment method.
|
||||
|
||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||
|
||||
#### Windows One-Click Installer
|
||||
|
||||
Please refer to the official documentation: [Deploy AstrBot with Windows One-Click Installer](https://astrbot.app/deploy/astrbot/windows.html).
|
||||
|
||||
#### CasaOS Deployment
|
||||
|
||||
Community-contributed deployment method.
|
||||
|
||||
Please refer to the official documentation: [CasaOS Deployment](https://astrbot.app/deploy/astrbot/casaos.html).
|
||||
|
||||
#### Manual Deployment
|
||||
|
||||
First, install uv:
|
||||
|
||||
```bash
|
||||
pip install uv
|
||||
```
|
||||
|
||||
Install AstrBot via Git Clone:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AstrBotDevs/AstrBot && cd AstrBot
|
||||
uv run main.py
|
||||
```
|
||||
|
||||
Or refer to the official documentation: [Deploy AstrBot from Source](https://astrbot.app/deploy/astrbot/cli.html).
|
||||
|
||||
## Supported Messaging Platforms
|
||||
|
||||
Connect AstrBot to your favorite chat platform.
|
||||
|
||||
| Platform | Maintainer |
|
||||
|---------|---------------|
|
||||
| QQ | Official |
|
||||
| OneBot v11 protocol implementation | Official |
|
||||
| Telegram | Official |
|
||||
| WeChat Work Application & WeChat Work Intelligent Bot | Official |
|
||||
| WeChat Customer Service & WeChat Official Accounts | Official |
|
||||
| Feishu (Lark) | Official |
|
||||
| DingTalk | Official |
|
||||
| Slack | Official |
|
||||
| Discord | Official |
|
||||
| LINE | Official |
|
||||
| Satori | Official |
|
||||
| Misskey | Official |
|
||||
| WhatsApp (Coming Soon) | Official |
|
||||
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Community |
|
||||
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | Community |
|
||||
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | Community |
|
||||
|
||||
## Supported Model Services
|
||||
|
||||
| Service | Type |
|
||||
|---------|---------------|
|
||||
| OpenAI and Compatible Services | LLM Services |
|
||||
| Anthropic | LLM Services |
|
||||
| Google Gemini | LLM Services |
|
||||
| Moonshot AI | LLM Services |
|
||||
| Zhipu AI | LLM Services |
|
||||
| DeepSeek | LLM Services |
|
||||
| Ollama (Self-hosted) | LLM Services |
|
||||
| LM Studio (Self-hosted) | LLM Services |
|
||||
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | LLM Services |
|
||||
| [302.AI](https://share.302.ai/rr1M3l) | LLM Services |
|
||||
| [TokenPony](https://www.tokenpony.cn/3YPyf) | LLM Services |
|
||||
| [SiliconFlow](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) | LLM Services |
|
||||
| [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE) | LLM Services |
|
||||
| ModelScope | LLM Services |
|
||||
| OneAPI | LLM Services |
|
||||
| Dify | LLMOps Platforms |
|
||||
| Alibaba Cloud Bailian Applications | LLMOps Platforms |
|
||||
| Coze | LLMOps Platforms |
|
||||
| OpenAI Whisper | Speech-to-Text Services |
|
||||
| SenseVoice | Speech-to-Text Services |
|
||||
| OpenAI TTS | Text-to-Speech Services |
|
||||
| Gemini TTS | Text-to-Speech Services |
|
||||
| GPT-Sovits-Inference | Text-to-Speech Services |
|
||||
| GPT-Sovits | Text-to-Speech Services |
|
||||
| FishAudio | Text-to-Speech Services |
|
||||
| Edge TTS | Text-to-Speech Services |
|
||||
| Alibaba Cloud Bailian TTS | Text-to-Speech Services |
|
||||
| Azure TTS | Text-to-Speech Services |
|
||||
| Minimax TTS | Text-to-Speech Services |
|
||||
| Volcano Engine TTS | Text-to-Speech Services |
|
||||
|
||||
## ❤️ Contributing
|
||||
|
||||
Issues and Pull Requests are always welcome! Feel free to submit your changes to this project :)
|
||||
|
||||
### How to Contribute
|
||||
|
||||
You can contribute by reviewing issues or helping with pull request reviews. Any issues or PRs are welcome to encourage community participation. Of course, these are just suggestions—you can contribute in any way you like. For adding new features, please discuss through an Issue first.
|
||||
|
||||
### Development Environment
|
||||
|
||||
AstrBot uses `ruff` for code formatting and linting.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AstrBotDevs/AstrBot
|
||||
pip install pre-commit
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
## 🌍 Community
|
||||
|
||||
### QQ Groups
|
||||
|
||||
- Group 1: 322154837
|
||||
- Group 3: 630166526
|
||||
- Group 5: 822130018
|
||||
- Group 6: 753075035
|
||||
- Group 7: 743746109
|
||||
- Group 8: 1030353265
|
||||
- Developer Group: 975206796
|
||||
|
||||
### Telegram Group
|
||||
|
||||
<a href="https://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
|
||||
### Discord Server
|
||||
|
||||
<a href="https://discord.gg/hAVk6tgV36"><img alt="Discord_community" src="https://img.shields.io/badge/Discord-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
|
||||
## ❤️ Special Thanks
|
||||
|
||||
Special thanks to all Contributors and plugin developers for their contributions to AstrBot ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||
</a>
|
||||
|
||||
Additionally, the birth of this project would not have been possible without the help of the following open-source projects:
|
||||
|
||||
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - The amazing cat framework
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
> [!TIP]
|
||||
> If this project has helped you in your life or work, or if you're interested in its future development, please give the project a Star. It's the driving force behind maintaining this open-source project <3
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://star-history.com/#astrbotdevs/astrbot&Date)
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
_Companionship and capability should never be at odds. What we aim to create is a robot that can understand emotions, provide genuine companionship, and reliably accomplish tasks._
|
||||
|
||||
_私は、高性能ですから!_
|
||||
|
||||
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
|
||||
</div>
|
||||
+165
-152
@@ -2,14 +2,12 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh.md">中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">English</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh.md">简体中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
||||
|
||||
<br>
|
||||
|
||||
<div>
|
||||
<a href="https://trendshift.io/repositories/12875" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12875" alt="Soulter%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<a href="https://hellogithub.com/repository/AstrBotDevs/AstrBot" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=d127d50cd5e54c5382328acc3bb25483&claim_uid=ZO9by7qCXgSd6Lp&t=2" alt="Featured|HelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
@@ -21,44 +19,47 @@
|
||||
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest">
|
||||
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
|
||||
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
|
||||
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFZIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
|
||||
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTk4IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
|
||||
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?color=76bad9"/></a>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%20&label=Marketplace&cacheSeconds=3600">
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%20Plugins&label=Marketplace&cacheSeconds=3600">
|
||||
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<a href="https://astrbot.app/">Accueil</a> |
|
||||
<a href="https://astrbot.app/">Documentation</a> |
|
||||
<a href="https://blog.astrbot.app/">Blog</a> |
|
||||
<a href="https://astrbot.featurebase.app/roadmap">Feuille de route</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/issues">Signaler un problème</a>
|
||||
<a href="mailto:community@astrbot.app">Email</a>
|
||||
|
||||
</div>
|
||||
|
||||
AstrBot est une plateforme de chatbot Agent tout-en-un open source qui s'intègre aux principales applications de messagerie instantanée. Elle fournit une infrastructure d'IA conversationnelle fiable et évolutive pour les particuliers, les développeurs et les équipes. Que vous construisiez un compagnon IA personnel, un service client intelligent, un assistant d'automatisation ou une base de connaissances d'entreprise, AstrBot vous permet de créer rapidement des applications d'IA prêtes pour la production dans les flux de travail de votre plateforme de messagerie.
|
||||
AstrBot est un assistant de chat personnel et de groupe Agentic tout-en-un et open-source, qui peut être déployé sur des dizaines de logiciels de messagerie instantanée grand public tels que QQ, Telegram, WeCom (WeChat Entreprise), Lark (Feishu), DingTalk, Slack, etc. Il intègre également une interface de chat légère similaire à OpenWebUI, créant ainsi une infrastructure conversationnelle intelligente fiable et extensible pour les particuliers, les développeurs et les équipes. Qu'il s'agisse d'un compagnon IA personnel, d'un service client intelligent, d'un assistant automatisé ou d'une base de connaissances d'entreprise, AstrBot vous permet de construire rapidement des applications IA au sein du flux de travail de vos plateformes de messagerie instantanée.
|
||||
|
||||

|
||||

|
||||
|
||||
## Fonctionnalités principales
|
||||
## Fonctionnalités Principales
|
||||
|
||||
1. 💯 Gratuit & Open Source.
|
||||
2. ✨ Dialogue avec de grands modèles d'IA, multimodal, Agent, MCP, Skills, Base de connaissances, Paramétrage de personnalité, compression automatique des dialogues.
|
||||
3. 🤖 Prise en charge de l'accès aux plateformes d'Agents telles que Dify, Alibaba Cloud Bailian, Coze, etc.
|
||||
4. 🌐 Multiplateforme : supporte QQ, WeChat Enterprise, Feishu, DingTalk, Comptes officiels WeChat, Telegram, Slack et [plus encore](#plateformes-de-messagerie-prises-en-charge).
|
||||
5. 📦 Extension par plugins, avec plus de 1000 plugins déjà disponibles pour une installation en un clic.
|
||||
6. 🛡️ Environnement isolé [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) : exécution sécurisée de code, appels Shell et réutilisation des ressources au niveau de la session.
|
||||
2. ✨ Dialogue avec de grands modèles d'IA (LLM), multimodal, Agent, MCP, Compétences (Skills), base de connaissances, définition de persona, compression automatique des dialogues.
|
||||
3. 🤖 Prend en charge l'intégration avec des plateformes d'agents comme Dify, Alibaba Bailian, Coze, etc.
|
||||
4. 🌐 Multiplateforme, prend en charge QQ, WeCom, Lark, DingTalk, Compte Officiel WeChat, Telegram, Slack et [plus encore](#plateformes-de-messagerie-prises-en-charge).
|
||||
5. 📦 Extension par plugins, plus de 1000 plugins disponibles pour une installation en un clic.
|
||||
6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) : environnement isolé pour exécuter n'importe quel code, appeler le Shell et réutiliser les ressources au niveau de la session en toute sécurité.
|
||||
7. 💻 Support WebUI.
|
||||
8. 🌈 Support Web ChatUI, avec sandbox d'agent intégrée, recherche web, etc.
|
||||
8. 🌈 Support Web ChatUI, avec sandbox de proxy intégré, recherche web, etc.
|
||||
9. 🌐 Support de l'internationalisation (i18n).
|
||||
|
||||
<br>
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<th>💙 Jeux de rôle & Accompagnement émotionnel</th>
|
||||
<th>✨ Agent proactif</th>
|
||||
<th>🚀 Capacités agentiques générales</th>
|
||||
<th>🧩 1000+ Plugins de communauté</th>
|
||||
<th>💙 Jeu de rôle & Accompagnement émotionnel</th>
|
||||
<th>✨ Agent Proactif</th>
|
||||
<th>🚀 Capacités Agentic Génériques</th>
|
||||
<th>🧩 1000+ Plugins Communautaires</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
|
||||
@@ -68,191 +69,193 @@ AstrBot est une plateforme de chatbot Agent tout-en-un open source qui s'intègr
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Démarrage rapide
|
||||
## Démarrage Rapide
|
||||
|
||||
#### Déploiement Docker (Recommandé 🥳)
|
||||
### Déploiement en un clic
|
||||
|
||||
Nous recommandons de déployer AstrBot en utilisant Docker ou Docker Compose.
|
||||
|
||||
Veuillez consulter la documentation officielle : [Déployer AstrBot avec Docker](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
|
||||
|
||||
#### Déploiement uv
|
||||
Pour les utilisateurs qui souhaitent essayer AstrBot rapidement, qui sont familiers avec la ligne de commande et capables d'installer l'environnement `uv` par eux-mêmes, nous recommandons la méthode de déploiement en un clic avec `uv` ⚡️.
|
||||
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
astrbot
|
||||
astrbot init # Exécutez cette commande uniquement la première fois pour initialiser l'environnement
|
||||
astrbot run # astrbot run --backend-only démarre uniquement le service backend
|
||||
|
||||
# Installer la version de développement (plus de correctifs, nouvelles fonctionnalités, mais moins stable, adapté aux développeurs)
|
||||
uv tool install git+https://github.com/AstrBotDevs/AstrBot@dev
|
||||
```
|
||||
|
||||
#### Application de bureau (Tauri)
|
||||
> Nécessite l'installation de [uv](https://docs.astral.sh/uv/).
|
||||
|
||||
Dépôt de l'application de bureau : [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
|
||||
> [!NOTE]
|
||||
> Pour les utilisateurs de macOS : en raison des contrôles de sécurité de macOS, la première exécution de la commande `astrbot` peut prendre un certain temps (environ 10-20 secondes).
|
||||
|
||||
Prend en charge plusieurs architectures système, installation directe, prête à l'emploi. La solution de déploiement de bureau en un clic la plus adaptée aux débutants. Non recommandée pour les serveurs.
|
||||
Mettre à jour `astrbot` :
|
||||
|
||||
#### Déploiement en un clic avec le lanceur (AstrBot Launcher)
|
||||
```bash
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
Déploiement rapide et solution multi-instances, isolation de l'environnement. Accédez au dépôt [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher), trouvez le package d'installation correspondant à votre système sous la dernière version sur la page Releases.
|
||||
### Déploiement Docker
|
||||
|
||||
#### Déploiement BT-Panel
|
||||
Pour les utilisateurs familiers avec les conteneurs et souhaitant une méthode de déploiement plus stable et adaptée aux environnements de production, nous recommandons d'utiliser Docker / Docker Compose pour déployer AstrBot.
|
||||
|
||||
AstrBot s'est associé à BT-Panel et est maintenant disponible sur leur marketplace.
|
||||
Veuillez vous référer à la documentation officielle [Déployer AstrBot avec Docker](https://astrbot.app/deploy/astrbot/docker.html).
|
||||
|
||||
Veuillez consulter la documentation officielle : [Déploiement BT-Panel](https://astrbot.app/deploy/astrbot/btpanel.html).
|
||||
### Déploiement sur RainYun
|
||||
|
||||
#### Déploiement 1Panel
|
||||
|
||||
AstrBot a été officiellement listé sur le marketplace 1Panel.
|
||||
|
||||
Veuillez consulter la documentation officielle : [Déploiement 1Panel](https://astrbot.app/deploy/astrbot/1panel.html).
|
||||
|
||||
#### Déployer sur RainYun
|
||||
|
||||
For Chinese users:
|
||||
|
||||
AstrBot a été officiellement listé sur la plateforme d'applications cloud de RainYun avec un déploiement en un clic.
|
||||
Pour les utilisateurs souhaitant déployer AstrBot en un clic sans gérer de serveur, nous recommandons le service de déploiement cloud en un clic de RainYun ☁️ :
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||
|
||||
#### Déployer sur Replit
|
||||
### Déploiement Client Bureau
|
||||
|
||||
Méthode de déploiement contribuée par la communauté.
|
||||
Pour les utilisateurs souhaitant utiliser AstrBot sur ordinateur de bureau et utiliser principalement ChatUI comme point d'entrée, nous recommandons l'application AstrBot App.
|
||||
|
||||
Rendez-vous sur [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) pour télécharger et installer ; cette méthode est destinée à un usage bureautique et n'est pas recommandée pour les scénarios serveur.
|
||||
|
||||
### Déploiement Launcher
|
||||
|
||||
Également pour une utilisation sur bureau, pour les utilisateurs souhaitant un déploiement rapide et une isolation de l'environnement pour plusieurs instances, nous recommandons AstrBot Launcher.
|
||||
|
||||
Rendez-vous sur [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) pour télécharger et installer.
|
||||
|
||||
### Déploiement sur Replit
|
||||
|
||||
Le déploiement sur Replit est maintenu par la communauté et convient aux démonstrations en ligne et aux scénarios d'essai légers.
|
||||
|
||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||
|
||||
#### Installateur Windows en un clic
|
||||
### AUR
|
||||
|
||||
Veuillez consulter la documentation officielle : [Déployer AstrBot avec l'installateur Windows en un clic](https://astrbot.app/deploy/astrbot/windows.html).
|
||||
La méthode AUR est destinée aux utilisateurs d'Arch Linux souhaitant installer AstrBot via le gestionnaire de paquets du système.
|
||||
|
||||
#### Déploiement CasaOS
|
||||
|
||||
Méthode de déploiement contribuée par la communauté.
|
||||
|
||||
Veuillez consulter la documentation officielle : [Déploiement CasaOS](https://astrbot.app/deploy/astrbot/casaos.html).
|
||||
|
||||
#### Déploiement manuel
|
||||
|
||||
Tout d'abord, installez uv :
|
||||
|
||||
```bash
|
||||
pip install uv
|
||||
```
|
||||
|
||||
Installez AstrBot via Git Clone :
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AstrBotDevs/AstrBot && cd AstrBot
|
||||
uv run main.py
|
||||
```
|
||||
|
||||
Ou consultez la documentation officielle : [Déployer AstrBot depuis les sources](https://astrbot.app/deploy/astrbot/cli.html).
|
||||
|
||||
#### Installation via le gestionnaire de paquets du système
|
||||
|
||||
##### Arch Linux
|
||||
Exécutez la commande ci-dessous dans le terminal pour installer le paquet `astrbot-git`. Une fois l'installation terminée, vous pouvez le lancer.
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
# ou utiliser paru
|
||||
paru -S astrbot-git
|
||||
```
|
||||
|
||||
## Plateformes de messagerie prises en charge
|
||||
**Plus de méthodes de déploiement**
|
||||
|
||||
Si vous avez besoin d'un déploiement via panneau de contrôle ou hautement personnalisé, vous pouvez consulter [BT Panel](https://astrbot.app/deploy/astrbot/btpanel.html) (installation via le magasin d'applications BT Panel), [1Panel](https://astrbot.app/deploy/astrbot/1panel.html) (installation via le magasin d'applications 1Panel), [CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) (déploiement visuel pour NAS / serveur domestique) et [Déploiement Manuel](https://astrbot.app/deploy/astrbot/cli.html) (installation personnalisée complète basée sur le code source et `uv`).
|
||||
|
||||
## Plateformes de Messagerie Prises en Charge
|
||||
|
||||
Connectez AstrBot à vos plateformes de chat préférées.
|
||||
|
||||
| Plateforme | Maintenance |
|
||||
| Plateforme | Mainteneur |
|
||||
|---------|---------------|
|
||||
| QQ | Officielle |
|
||||
| Implémentation du protocole OneBot v11 | Officielle |
|
||||
| Telegram | Officielle |
|
||||
| Application WeChat Work & Bot intelligent WeChat Work | Officielle |
|
||||
| Service client WeChat & Comptes officiels WeChat | Officielle |
|
||||
| Feishu (Lark) | Officielle |
|
||||
| DingTalk | Officielle |
|
||||
| Slack | Officielle |
|
||||
| Discord | Officielle |
|
||||
| LINE | Officielle |
|
||||
| Satori | Officielle |
|
||||
| Misskey | Officielle |
|
||||
| WhatsApp (Bientôt disponible) | Officielle |
|
||||
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Communauté |
|
||||
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | Communauté |
|
||||
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | Communauté |
|
||||
| **QQ** | Officiel |
|
||||
| **OneBot v11** | Officiel |
|
||||
| **Telegram** | Officiel |
|
||||
| **WeCom (App & Smart Bot)** | Officiel |
|
||||
| **WeChat (Service Client & Compte Officiel)** | Officiel |
|
||||
| **Lark (Feishu)** | Officiel |
|
||||
| **DingTalk** | Officiel |
|
||||
| **Slack** | Officiel |
|
||||
| **Discord** | Officiel |
|
||||
| **LINE** | Officiel |
|
||||
| **Satori** | Officiel |
|
||||
| **Misskey** | Officiel |
|
||||
| **Whatsapp (Bientôt)** | Officiel |
|
||||
| [**Matrix**](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Communauté |
|
||||
| [**KOOK**](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | Communauté |
|
||||
| [**VoceChat**](https://github.com/HikariFroya/astrbot_plugin_vocechat) | Communauté |
|
||||
|
||||
## Services de modèles pris en charge
|
||||
## Fournisseurs de Modèles Pris en Charge
|
||||
|
||||
| Service | Type |
|
||||
| Fournisseur | Type |
|
||||
|---------|---------------|
|
||||
| OpenAI et services compatibles | Services LLM |
|
||||
| Anthropic | Services LLM |
|
||||
| Google Gemini | Services LLM |
|
||||
| Moonshot AI | Services LLM |
|
||||
| Zhipu AI | Services LLM |
|
||||
| DeepSeek | Services LLM |
|
||||
| Ollama (Auto-hébergé) | Services LLM |
|
||||
| LM Studio (Auto-hébergé) | Services LLM |
|
||||
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | Services LLM |
|
||||
| [302.AI](https://share.302.ai/rr1M3l) | Services LLM |
|
||||
| [TokenPony](https://www.tokenpony.cn/3YPyf) | Services LLM |
|
||||
| [SiliconFlow](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) | Services LLM |
|
||||
| [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE) | Services LLM |
|
||||
| ModelScope | Services LLM |
|
||||
| OneAPI | Services LLM |
|
||||
| Dify | Plateformes LLMOps |
|
||||
| Applications Alibaba Cloud Bailian | Plateformes LLMOps |
|
||||
| Coze | Plateformes LLMOps |
|
||||
| OpenAI Whisper | Services de reconnaissance vocale |
|
||||
| SenseVoice | Services de reconnaissance vocale |
|
||||
| OpenAI TTS | Services de synthèse vocale |
|
||||
| Gemini TTS | Services de synthèse vocale |
|
||||
| GPT-Sovits-Inference | Services de synthèse vocale |
|
||||
| GPT-Sovits | Services de synthèse vocale |
|
||||
| FishAudio | Services de synthèse vocale |
|
||||
| Edge TTS | Services de synthèse vocale |
|
||||
| Alibaba Cloud Bailian TTS | Services de synthèse vocale |
|
||||
| Azure TTS | Services de synthèse vocale |
|
||||
| Minimax TTS | Services de synthèse vocale |
|
||||
| Volcano Engine TTS | Services de synthèse vocale |
|
||||
| Personnalisé | Tout service compatible avec l'API OpenAI |
|
||||
| OpenAI | LLM |
|
||||
| Anthropic | LLM |
|
||||
| Google Gemini | LLM |
|
||||
| Moonshot AI | LLM |
|
||||
| Zhipu AI | LLM |
|
||||
| DeepSeek | LLM |
|
||||
| Ollama (Local) | LLM |
|
||||
| LM Studio (Local) | LLM |
|
||||
| [AIHubMix](https://aihubmix.com/?aff=4bfH) | LLM (Passerelle API, supporte tous les modèles) |
|
||||
| [Uyun AI](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | LLM (Passerelle API, supporte tous les modèles) |
|
||||
| [SiliconFlow](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) | LLM (Passerelle API, supporte tous les modèles) |
|
||||
| [PPIO](https://ppio.com/user/register?invited_by=AIOONE) | LLM (Passerelle API, supporte tous les modèles) |
|
||||
| [302.AI](https://share.302.ai/rr1M3l) | LLM (Passerelle API, supporte tous les modèles)|
|
||||
| [TokenPony](https://www.tokenpony.cn/3YPyf) | LLM (Passerelle API, supporte tous les modèles)|
|
||||
| ModelScope | LLM |
|
||||
| OneAPI | LLM |
|
||||
| Dify | Plateforme LLMOps |
|
||||
| Alibaba Bailian | Plateforme LLMOps |
|
||||
| Coze | Plateforme LLMOps |
|
||||
| OpenAI Whisper | Synthèse vocale (Speech-to-Text) |
|
||||
| SenseVoice | Synthèse vocale (Speech-to-Text) |
|
||||
| OpenAI TTS | Synthèse vocale (Text-to-Speech) |
|
||||
| Gemini TTS | Synthèse vocale (Text-to-Speech) |
|
||||
| GPT-Sovits-Inference | Synthèse vocale (Text-to-Speech) |
|
||||
| GPT-Sovits | Synthèse vocale (Text-to-Speech) |
|
||||
| FishAudio | Synthèse vocale (Text-to-Speech) |
|
||||
| Edge TTS | Synthèse vocale (Text-to-Speech) |
|
||||
| Alibaba Bailian TTS | Synthèse vocale (Text-to-Speech) |
|
||||
| Azure TTS | Synthèse vocale (Text-to-Speech) |
|
||||
| Minimax TTS | Synthèse vocale (Text-to-Speech) |
|
||||
| Volcengine TTS | Synthèse vocale (Text-to-Speech) |
|
||||
|
||||
## ❤️ Contribuer
|
||||
## ❤️ Contribution
|
||||
|
||||
Les Issues et Pull Requests sont toujours les bienvenues ! N'hésitez pas à soumettre vos modifications à ce projet :)
|
||||
Les Issues et Pull Requests sont les bienvenus ! Soumettez simplement vos modifications à ce projet :)
|
||||
|
||||
### Comment contribuer
|
||||
### Comment Contribuer
|
||||
|
||||
Vous pouvez contribuer en examinant les issues ou en aidant à la revue des pull requests. Toutes les issues ou PRs sont les bienvenues pour encourager la participation de la communauté. Bien sûr, ce ne sont que des suggestions - vous pouvez contribuer de la manière que vous souhaitez. Pour l'ajout de nouvelles fonctionnalités, veuillez d'abord en discuter via une Issue.
|
||||
Vous pouvez contribuer en examinant les problèmes ou en aidant à réviser les PR (Pull Requests). Tout problème ou PR est le bienvenu pour promouvoir la contribution communautaire. Bien sûr, ce ne sont que des suggestions, vous pouvez contribuer de n'importe quelle manière. Pour l'ajout de nouvelles fonctionnalités, veuillez d'abord en discuter via une Issue.
|
||||
Il est recommandé de fusionner les PR fonctionnels dans la branche `dev`, qui sera fusionnée dans la branche principale et publiée en tant que nouvelle version après test des modifications.
|
||||
Pour réduire les conflits, nous suggérons :
|
||||
1. Créez votre branche de travail basée sur la branche `dev`, évitez de travailler directement sur la branche `main`.
|
||||
2. Lors de la soumission d'une PR, sélectionnez la branche `dev` comme cible.
|
||||
3. Synchronisez régulièrement la branche `dev` en local, utilisez souvent `git pull`.
|
||||
|
||||
### Environnement de développement
|
||||
### Environnement de Développement
|
||||
|
||||
AstrBot utilise `ruff` pour le formatage et le linting du code.
|
||||
AstrBot utilise `ruff` pour le formatage et la vérification du code.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AstrBotDevs/AstrBot
|
||||
pip install pre-commit
|
||||
git switch dev # Basculer vers la branche de développement
|
||||
pip install pre-commit # ou uv tool install pre-commit
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
## 🌍 Communauté
|
||||
Il est recommandé d'utiliser `uv` pour l'installation locale et les tests.
|
||||
```bash
|
||||
uv tool install -e . --force
|
||||
astrbot init
|
||||
astrbot run
|
||||
```
|
||||
Débogage frontend
|
||||
```bash
|
||||
astrbot run --backend-only
|
||||
cd dashboard
|
||||
bun install # ou pnpm, etc.
|
||||
bun dev
|
||||
```
|
||||
|
||||
### Groupes QQ
|
||||
|
||||
- Groupe 9 : 1076659624 (Nouveau)
|
||||
- Groupe 10 : 1078079676 (Nouveau)
|
||||
- Groupe 1 : 322154837
|
||||
- Groupe 3 : 630166526
|
||||
- Groupe 5 : 822130018
|
||||
- Groupe 6 : 753075035
|
||||
- Groupe développeurs : 975206796
|
||||
- Groupe 7 : 743746109
|
||||
- Groupe 8 : 1030353265
|
||||
- Groupe Développeurs (Discussion libre) : 975206796
|
||||
- Groupe Développeurs (Officiel) : 1039761811
|
||||
|
||||
### Groupe Telegram
|
||||
### Canal Discord
|
||||
|
||||
<a href="https://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
- [Discord](https://discord.gg/hAVk6tgV36)
|
||||
|
||||
### Serveur Discord
|
||||
## ❤️ Remerciements Spéciaux
|
||||
|
||||
<a href="https://discord.gg/hAVk6tgV36"><img alt="Discord_community" src="https://img.shields.io/badge/Discord-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
|
||||
## ❤️ Remerciements spéciaux
|
||||
|
||||
Un grand merci à tous les contributeurs et développeurs de plugins pour leurs contributions à AstrBot ❤️
|
||||
Un grand merci à tous les Contributeurs et développeurs de plugins pour leur contribution à AstrBot ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||
@@ -260,12 +263,22 @@ Un grand merci à tous les contributeurs et développeurs de plugins pour leurs
|
||||
|
||||
De plus, la naissance de ce projet n'aurait pas été possible sans l'aide des projets open source suivants :
|
||||
|
||||
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - L'incroyable framework chat
|
||||
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - Le grand framework félin
|
||||
|
||||
## ⭐ Historique des étoiles
|
||||
Liens amicaux vers des projets open source :
|
||||
|
||||
- [NoneBot2](https://github.com/nonebot/nonebot2) - Excellent framework de ChatBot asynchrone en Python
|
||||
- [Koishi](https://github.com/koishijs/koishi) - Excellent framework de ChatBot en Node.js
|
||||
- [MaiBot](https://github.com/Mai-with-u/MaiBot) - Excellent ChatBot IA anthropomorphe
|
||||
- [nekro-agent](https://github.com/KroMiose/nekro-agent) - Excellent ChatBot Agent
|
||||
- [LangBot](https://github.com/langbot-app/LangBot) - Excellent ChatBot IA multiplateforme
|
||||
- [ChatLuna](https://github.com/ChatLunaLab/chatluna) - Excellent plugin Koishi de ChatBot IA multiplateforme
|
||||
- [Operit AI](https://github.com/AAswordman/Operit) - Excellente application Android d'assistant intelligent IA
|
||||
|
||||
## ⭐ Historique des Étoiles
|
||||
|
||||
> [!TIP]
|
||||
> Si ce projet vous a aidé dans votre vie ou votre travail, ou si vous êtes intéressé par son développement futur, veuillez donner une étoile au projet. C'est la force motrice derrière la maintenance de ce projet open source <3
|
||||
> Si ce projet vous a été utile dans votre vie ou votre travail, ou si vous vous intéressez à son développement futur, merci de lui donner une Étoile. C'est notre motivation pour maintenir ce projet open source <3
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -275,10 +288,10 @@ De plus, la naissance de ce projet n'aurait pas été possible sans l'aide des p
|
||||
|
||||
<div align="center">
|
||||
|
||||
_La compagnie et la capacité ne devraient jamais être des opposés. Nous souhaitons créer un robot capable à la fois de comprendre les émotions, d'offrir de la présence, et d'accomplir des tâches de manière fiable._
|
||||
_La compagnie et la compétence ne devraient jamais être opposées. Nous espérons créer un robot capable à la fois de comprendre les émotions, d'offrir de la compagnie et d'accomplir des tâches de manière fiable._
|
||||
|
||||
_私は、高性能ですから!_
|
||||
_私は、高性能ですから!_ (Je suis performant !)
|
||||
|
||||
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
+168
-156
@@ -2,14 +2,12 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh.md">中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">English</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh.md">简体中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
||||
|
||||
<br>
|
||||
|
||||
<div>
|
||||
<a href="https://trendshift.io/repositories/12875" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12875" alt="Soulter%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<a href="https://hellogithub.com/repository/AstrBotDevs/AstrBot" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=d127d50cd5e54c5382328acc3bb25483&claim_uid=ZO9by7qCXgSd6Lp&t=2" alt="Featured|HelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
@@ -21,43 +19,46 @@
|
||||
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest">
|
||||
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
|
||||
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
|
||||
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFZIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0LjYxNTZDNS4zMTUwMiAxNC4zOTk5IDUuNjAxNTYgMTQuMTEzNCA1LjYwMTU2IDEzLjc1OTlWMTEuMDM5OUM1LjYwMTU2IDEwLjY4NjQgNS4zMTUwMiAxMC4zOTk5IDQuOTYxNTYgMTAuMzk5OVoiIGZpbGw9IiNmZmYiLz4KPHBhdGggZD0iTTEzLjc1ODQgMS42MDAxSDExLjAzODRDMTAuNjg1IDEuNjAwMSAxMC4zOTg0IDEuODg2NjQgMTAuMzk4NCAyLjI0MDFWNC45NjAxQzEwLjM5ODQgNS4zMTM1NiAxMC42ODUgNS42MDAxIDExLjAzODQgNS42MDAxSDEzLjc1ODRDMTQuMTExOSA1LjYwMDEgMTQuMzk4NCA1LjMxMzU2IDE0LjM5ODQgNC45NjAxVjIuMjQwMUMxNC4zOTg0IDEuODg2NjQgMTQuMTExOSAxLjYwMDEgMTMuNzU4NCAxLjYwMDFZIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDRMNCAxMlpFIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
|
||||
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
|
||||
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?color=76bad9"/></a>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%20&label=%E3%83%97%E3%83%A9%E3%82%B0%E3%82%A4%E3%83%B3%E3%83%9E%E3%83%BC%E3%82%B1%E3%83%83%E3%83%88&cacheSeconds=3600">
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%E5%80%8B&label=%E3%83%97%E3%83%A9%E3%82%B0%E3%82%A4%E3%83%B3%E3%82%B9%E3%83%88%E3%82%A2&cacheSeconds=3600">
|
||||
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<a href="https://astrbot.app/">ホーム</a> |
|
||||
<a href="https://astrbot.app/">ドキュメント</a> |
|
||||
<a href="https://blog.astrbot.app/">Blog</a> |
|
||||
<a href="https://blog.astrbot.app/">ブログ</a> |
|
||||
<a href="https://astrbot.featurebase.app/roadmap">ロードマップ</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/issues">Issue</a>
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/issues">課題の提出</a>
|
||||
<a href="mailto:community@astrbot.app">Email</a>
|
||||
|
||||
</div>
|
||||
|
||||
AstrBot は、主要なインスタントメッセージングアプリと統合できるオープンソースのオールインワン Agent チャットボットプラットフォームです。個人、開発者、チームに信頼性が高くスケーラブルな会話型 AI インフラストラクチャを提供します。パーソナル AI コンパニオン、インテリジェントカスタマーサービス、オートメーションアシスタント、エンタープライズナレッジベースなど、AstrBot を使用すると、IM プラットフォームのワークフロー内で本番環境対応の AI アプリケーションを迅速に構築できます。
|
||||
AstrBotは、オープンソースのオールインワンAgentic個人およびグループチャットアシスタントです。QQ、Telegram、WeCom(企業微信)、Lark(飛書)、DingTalk(釘釘)、Slackなど、数十種類の主要なインスタントメッセージングソフトウェアに導入できます。さらに、OpenWebUIに似た軽量のChatUIも組み込まれており、個人、開発者、チーム向けに信頼性が高く拡張可能な会話型AIインフラストラクチャを提供します。個人のAIパートナー、インテリジェントなカスタマーサービス、自動化アシスタント、または企業のナレッジベースであっても、AstrBotはインスタントメッセージングプラットフォームのワークフロー内でAIアプリケーションを迅速に構築することを可能にします。
|
||||
|
||||

|
||||

|
||||
|
||||
## 主な機能
|
||||
|
||||
1. 💯 無料 & オープンソース。
|
||||
2. ✨ AI大規模言語モデル対話、マルチモーダル、Agent、MCP、Skills、ナレッジベース、ペルソナ設定、対話の自動圧縮。
|
||||
3. 🤖 Dify、Alibaba Cloud Bailian(百煉)、Coze などのAgentプラットフォームへの接続をサポート。
|
||||
4. 🌐 マルチプラットフォーム:QQ、企業微信(WeCom)、飛書(Lark)、釘釘(DingTalk)、WeChat公式アカウント、Telegram、Slack、[その他](#サポートされているメッセージプラットフォーム)に対応。
|
||||
5. 📦 プラグイン拡張:1000を超える既存プラグインをワンクリックでインストール可能。
|
||||
6. 🛡️ 隔離環境[Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html):コードの安全な実行、Shell呼び出し、セッションレベルのリソース再利用。
|
||||
7. 💻 WebUI 対応。
|
||||
8. 🌈 Web ChatUI 対応:ChatUI内にAgent Sandboxやウェブ検索などを内蔵。
|
||||
9. 🌐 多言語対応(i18n)。
|
||||
2. ✨ AI大規模モデル対話、マルチモーダル、エージェント、MCP、スキル、ナレッジベース、人格設定、対話の自動圧縮。
|
||||
3. 🤖 Dify、Alibaba Bailian(阿里雲百煉)、Cozeなどのエージェントプラットフォームとの連携をサポート。
|
||||
4. 🌐 マルチプラットフォーム対応:QQ、WeCom、Lark、DingTalk、WeChat公式アカウント、Telegram、Slack、その他[多数](#対応メッセージングプラットフォーム)。
|
||||
5. 📦 プラグイン拡張:1000以上のプラグインがワンクリックでインストール可能。
|
||||
6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html):隔離された環境で、あらゆるコードの安全な実行、シェル呼び出し、セッションレベルのリソース再利用が可能。
|
||||
7. 💻 WebUIサポート。
|
||||
8. 🌈 Web ChatUIサポート:ChatUIにはプロキシサンドボックス、Web検索などが組み込まれています。
|
||||
9. 🌐 国際化(i18n)サポート。
|
||||
|
||||
<br>
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<th>💙 ロールプレイ & 感情的な対話</th>
|
||||
<th>✨ プロアクティブ・エージェント (Proactive Agent)</th>
|
||||
<th>🚀 汎用 エージェント的能力</th>
|
||||
<th>💙 ロールプレイ & 感情的な付き添い</th>
|
||||
<th>✨ 能動的エージェント</th>
|
||||
<th>🚀 汎用Agentic能力</th>
|
||||
<th>🧩 1000+ コミュニティプラグイン</th>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -70,203 +71,214 @@ AstrBot は、主要なインスタントメッセージングアプリと統合
|
||||
|
||||
## クイックスタート
|
||||
|
||||
#### Docker デプロイ(推奨 🥳)
|
||||
### ワンクリックデプロイ
|
||||
|
||||
Docker / Docker Compose を使用した AstrBot のデプロイを推奨します。
|
||||
|
||||
公式ドキュメント [Docker を使用した AstrBot のデプロイ](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot) をご参照ください。
|
||||
|
||||
#### uv デプロイ
|
||||
AstrBotをすぐに試してみたい方で、コマンドラインに慣れており、`uv`環境を自分でインストールできる方には、`uv`を使用したワンクリックデプロイをお勧めします⚡️。
|
||||
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
astrbot
|
||||
astrbot init # 初回のみ環境初期化のために実行
|
||||
astrbot run # astrbot run --backend-only バックエンドサービスのみ起動
|
||||
|
||||
# 開発版のインストール(修正や新機能が多いですが、不安定な場合があります。開発者向け)
|
||||
uv tool install git+https://github.com/AstrBotDevs/AstrBot@dev
|
||||
```
|
||||
|
||||
#### デスクトップアプリのデプロイ(Tauri)
|
||||
> [uv](https://docs.astral.sh/uv/)のインストールが必要です。
|
||||
|
||||
デスクトップアプリのリポジトリ [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
|
||||
> [!NOTE]
|
||||
> macOSユーザーの場合:macOSのセキュリティチェックにより、`astrbot`コマンドの初回実行に時間がかかる場合があります(約10〜20秒)。
|
||||
|
||||
マルチシステムアーキテクチャをサポートし、インストールしてすぐに使用可能。初心者や手軽さを求める人に最適なワンクリックデスクトップデプロイソリューションです。サーバー環境での使用は推奨されません。
|
||||
`astrbot`の更新:
|
||||
|
||||
#### ランチャーによるワンクリックデプロイ(AstrBot Launcher)
|
||||
```bash
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
迅速なデプロイとマルチインスタンス対応、環境の隔離が可能。[AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) リポジトリにアクセスし、Releases ページから最新バージョンのシステム対応パッケージをダウンロードしてインストールしてください。
|
||||
### Dockerデプロイ
|
||||
|
||||
#### 宝塔パネルデプロイ
|
||||
コンテナに精通しており、より安定的で本番環境に適したデプロイ方法を好むユーザーには、Docker / Docker Composeを使用したAstrBotのデプロイをお勧めします。
|
||||
|
||||
AstrBot は宝塔パネルと提携し、宝塔パネルに公開されています。
|
||||
公式ドキュメントの[Dockerを使用してAstrBotをデプロイする](https://astrbot.app/deploy/astrbot/docker.html)を参照してください。
|
||||
|
||||
公式ドキュメント [宝塔パネルデプロイ](https://astrbot.app/deploy/astrbot/btpanel.html) をご参照ください。
|
||||
### RainYun(雨云)でのデプロイ
|
||||
|
||||
#### 1Panel デプロイ
|
||||
|
||||
AstrBot は 1Panel 公式により 1Panel パネルに公開されています。
|
||||
|
||||
公式ドキュメント [1Panel デプロイ](https://astrbot.app/deploy/astrbot/1panel.html) をご参照ください。
|
||||
|
||||
#### 雨云でのデプロイ
|
||||
|
||||
For Chinese users:
|
||||
|
||||
AstrBot は雨云公式によりクラウドアプリケーションプラットフォームに公開され、ワンクリックでデプロイ可能です。
|
||||
サーバーを自分で管理せずにAstrBotをワンクリックでデプロイしたいユーザーには、RainYunのワンクリッククラウドデプロイサービスをお勧めします☁️:
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||
|
||||
#### Replit でのデプロイ
|
||||
### デスクトップクライアントデプロイ
|
||||
|
||||
コミュニティ貢献によるデプロイ方法。
|
||||
デスクトップでAstrBotを使用し、主にChatUIを入り口として使用したいユーザーには、AstrBot Appをお勧めします。
|
||||
|
||||
[AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)にアクセスしてダウンロードおよびインストールしてください。この方法はデスクトップ利用向けであり、サーバーシナリオには推奨されません。
|
||||
|
||||
### ランチャーデプロイ
|
||||
|
||||
同じくデスクトップ向けで、迅速にデプロイし、環境を分離して複数起動したいユーザーには、AstrBot Launcherをお勧めします。
|
||||
|
||||
[AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher)にアクセスしてダウンロードおよびインストールしてください。
|
||||
|
||||
### Replitでのデプロイ
|
||||
|
||||
Replitデプロイはコミュニティによって維持されており、オンラインデモや軽量な試用シナリオに適しています。
|
||||
|
||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||
|
||||
#### Windows ワンクリックインストーラーデプロイ
|
||||
### AUR
|
||||
|
||||
公式ドキュメント [Windows ワンクリックインストーラーを使用した AstrBot のデプロイ](https://astrbot.app/deploy/astrbot/windows.html) をご参照ください。
|
||||
AUR方式はArch Linuxユーザー向けで、システムパッケージマネージャーを通じてAstrBotをインストールしたい場合に適しています。
|
||||
|
||||
#### CasaOS デプロイ
|
||||
|
||||
コミュニティ貢献によるデプロイ方法。
|
||||
|
||||
公式ドキュメント [CasaOS デプロイ](https://astrbot.app/deploy/astrbot/casaos.html) をご参照ください。
|
||||
|
||||
#### 手動デプロイ
|
||||
|
||||
まず uv をインストールします:
|
||||
|
||||
```bash
|
||||
pip install uv
|
||||
```
|
||||
|
||||
Git Clone で AstrBot をインストール:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AstrBotDevs/AstrBot && cd AstrBot
|
||||
uv run main.py
|
||||
```
|
||||
|
||||
または、公式ドキュメント [ソースコードから AstrBot をデプロイ](https://astrbot.app/deploy/astrbot/cli.html) をご参照ください。
|
||||
|
||||
#### システムパッケージマネージャーでのインストール
|
||||
|
||||
##### Arch Linux
|
||||
ターミナルで以下のコマンドを実行して`astrbot-git`パッケージをインストールすると、起動して使用できます。
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
# または paru を使用
|
||||
paru -S astrbot-git
|
||||
```
|
||||
|
||||
## サポートされているメッセージプラットフォーム
|
||||
**その他のデプロイ方法**
|
||||
|
||||
AstrBot をよく使うチャットプラットフォームに接続できます。
|
||||
パネル化や高度なカスタマイズデプロイが必要な場合は、[BT Panel(宝塔パネル)](https://astrbot.app/deploy/astrbot/btpanel.html)(BT Panelアプリストアインストール)、[1Panel](https://astrbot.app/deploy/astrbot/1panel.html)(1Panelアプリストアインストール)、[CasaOS](https://astrbot.app/deploy/astrbot/casaos.html)(NAS / ホームサーバーの視覚的デプロイ)、および[手動デプロイ](https://astrbot.app/deploy/astrbot/cli.html)(ソースコードと`uv`に基づく完全なカスタムインストール)を参照してください。
|
||||
|
||||
| プラットフォーム | 保守 |
|
||||
## 対応メッセージングプラットフォーム
|
||||
|
||||
AstrBotを普段使用しているチャットプラットフォームに接続しましょう。
|
||||
|
||||
| プラットフォーム | 管理者 |
|
||||
|---------|---------------|
|
||||
| QQ | 公式 |
|
||||
| OneBot v11 プロトコル実装 | 公式 |
|
||||
| Telegram | 公式 |
|
||||
| WeChat Work アプリケーション & WeChat Work インテリジェントボット | 公式 |
|
||||
| WeChat カスタマーサービス & WeChat 公式アカウント | 公式 |
|
||||
| Feishu (Lark) | 公式 |
|
||||
| DingTalk | 公式 |
|
||||
| Slack | 公式 |
|
||||
| Discord | 公式 |
|
||||
| LINE | 公式 |
|
||||
| Satori | 公式 |
|
||||
| Misskey | 公式 |
|
||||
| WhatsApp (近日対応予定) | 公式 |
|
||||
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | コミュニティ |
|
||||
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | コミュニティ |
|
||||
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | コミュニティ |
|
||||
| **QQ** | 公式管理 |
|
||||
| **OneBot v11** | 公式管理 |
|
||||
| **Telegram** | 公式管理 |
|
||||
| **WeComアプリ & WeComボット** | 公式管理 |
|
||||
| **WeChatカスタマーサービス & WeChat公式アカウント** | 公式管理 |
|
||||
| **Lark (飛書)** | 公式管理 |
|
||||
| **DingTalk (釘釘)** | 公式管理 |
|
||||
| **Slack** | 公式管理 |
|
||||
| **Discord** | 公式管理 |
|
||||
| **LINE** | 公式管理 |
|
||||
| **Satori** | 公式管理 |
|
||||
| **Misskey** | 公式管理 |
|
||||
| **Whatsapp (対応予定)** | 公式管理 |
|
||||
| [**Matrix**](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | コミュニティ管理 |
|
||||
| [**KOOK**](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | コミュニティ管理 |
|
||||
| [**VoceChat**](https://github.com/HikariFroya/astrbot_plugin_vocechat) | コミュニティ管理 |
|
||||
|
||||
## 対応モデルプロバイダー
|
||||
|
||||
## サポートされているモデルサービス
|
||||
|
||||
| サービス | 種類 |
|
||||
| プロバイダー | タイプ |
|
||||
|---------|---------------|
|
||||
| OpenAI および互換サービス | 大規模言語モデルサービス |
|
||||
| Anthropic | 大規模言語モデルサービス |
|
||||
| Google Gemini | 大規模言語モデルサービス |
|
||||
| Moonshot AI | 大規模言語モデルサービス |
|
||||
| 智谱 AI | 大規模言語モデルサービス |
|
||||
| DeepSeek | 大規模言語モデルサービス |
|
||||
| Ollama (セルフホスト) | 大規模言語モデルサービス |
|
||||
| LM Studio (セルフホスト) | 大規模言語モデルサービス |
|
||||
| [優云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | 大規模言語モデルサービス |
|
||||
| [302.AI](https://share.302.ai/rr1M3l) | 大規模言語モデルサービス |
|
||||
| [小馬算力](https://www.tokenpony.cn/3YPyf) | 大規模言語モデルサービス |
|
||||
| [硅基流動](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) | 大規模言語モデルサービス |
|
||||
| [PPIO 派欧云](https://ppio.com/user/register?invited_by=AIOONE) | 大規模言語モデルサービス |
|
||||
| ModelScope | 大規模言語モデルサービス |
|
||||
| OneAPI | 大規模言語モデルサービス |
|
||||
| Dify | LLMOps プラットフォーム |
|
||||
| Alibaba Cloud 百炼アプリケーション | LLMOps プラットフォーム |
|
||||
| Coze | LLMOps プラットフォーム |
|
||||
| OpenAI Whisper | 音声認識サービス |
|
||||
| SenseVoice | 音声認識サービス |
|
||||
| OpenAI TTS | 音声合成サービス |
|
||||
| Gemini TTS | 音声合成サービス |
|
||||
| GPT-Sovits-Inference | 音声合成サービス |
|
||||
| GPT-Sovits | 音声合成サービス |
|
||||
| FishAudio | 音声合成サービス |
|
||||
| Edge TTS | 音声合成サービス |
|
||||
| Alibaba Cloud 百炼 TTS | 音声合成サービス |
|
||||
| Azure TTS | 音声合成サービス |
|
||||
| Minimax TTS | 音声合成サービス |
|
||||
| Volcano Engine TTS | 音声合成サービス |
|
||||
| カスタム | OpenAI API互換の任意のサービス |
|
||||
| OpenAI | LLM |
|
||||
| Anthropic | LLM |
|
||||
| Google Gemini | LLM |
|
||||
| Moonshot AI | LLM |
|
||||
| Zhipu AI (智譜AI) | LLM |
|
||||
| DeepSeek | LLM |
|
||||
| Ollama (ローカル) | LLM |
|
||||
| LM Studio (ローカル) | LLM |
|
||||
| [AIHubMix](https://aihubmix.com/?aff=4bfH) | LLM (APIゲートウェイ, 全モデル対応) |
|
||||
| [Uyun AI (優雲智算)](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | LLM (APIゲートウェイ, 全モデル対応) |
|
||||
| [SiliconFlow (硅基流動)](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) | LLM (APIゲートウェイ, 全モデル対応) |
|
||||
| [PPIO](https://ppio.com/user/register?invited_by=AIOONE) | LLM (APIゲートウェイ, 全モデル対応) |
|
||||
| [302.AI](https://share.302.ai/rr1M3l) | LLM (APIゲートウェイ, 全モデル対応)|
|
||||
| [TokenPony (小馬算力)](https://www.tokenpony.cn/3YPyf) | LLM (APIゲートウェイ, 全モデル対応)|
|
||||
| ModelScope | LLM |
|
||||
| OneAPI | LLM |
|
||||
| Dify | LLMOpsプラットフォーム |
|
||||
| Alibaba Bailian (阿里雲百煉) | LLMOpsプラットフォーム |
|
||||
| Coze | LLMOpsプラットフォーム |
|
||||
| OpenAI Whisper | 音声認識 (STT) |
|
||||
| SenseVoice | 音声認識 (STT) |
|
||||
| OpenAI TTS | 音声合成 (TTS) |
|
||||
| Gemini TTS | 音声合成 (TTS) |
|
||||
| GPT-Sovits-Inference | 音声合成 (TTS) |
|
||||
| GPT-Sovits | 音声合成 (TTS) |
|
||||
| FishAudio | 音声合成 (TTS) |
|
||||
| Edge TTS | 音声合成 (TTS) |
|
||||
| Alibaba Bailian TTS | 音声合成 (TTS) |
|
||||
| Azure TTS | 音声合成 (TTS) |
|
||||
| Minimax TTS | 音声合成 (TTS) |
|
||||
| Volcengine TTS (火山エンジン) | 音声合成 (TTS) |
|
||||
|
||||
## ❤️ コントリビューション
|
||||
## ❤️ 貢献
|
||||
|
||||
Issue や Pull Request は大歓迎です!このプロジェクトに変更を送信してください :)
|
||||
IssueやPull Requestは大歓迎です!変更をこのプロジェクトに送信してください :)
|
||||
|
||||
### コントリビュート方法
|
||||
### 貢献方法
|
||||
|
||||
Issue を確認したり、PR(プルリクエスト)のレビューを手伝うことで貢献できます。どんな Issue や PR への参加も歓迎され、コミュニティ貢献を促進します。もちろん、これらは提案に過ぎず、どんな方法でも貢献できます。新機能の追加については、まず Issue で議論してください。
|
||||
問題の確認やPR(プルリクエスト)のレビューを通じて貢献できます。コミュニティの貢献を促進するために、あらゆる問題やPRへの参加を歓迎します。もちろん、これらは提案に過ぎず、どのような方法で貢献しても構いません。新機能の追加については、まずIssueで議論してください。
|
||||
機能的なPRは`dev`ブランチにマージすることをお勧めします。テスト修正後にメインブランチにマージされ、新しいバージョンとしてリリースされます。
|
||||
コンフリクトを減らすために、以下のことを推奨します:
|
||||
1. 作業ブランチは`dev`ブランチに基づいて作成し、`main`ブランチで直接作業することは避けてください。
|
||||
2. PRを送信する際は、ターゲットブランチとして`dev`ブランチを選択してください。
|
||||
3. 定期的に`dev`ブランチをローカルに同期し、`git pull`を頻繁に使用してください。
|
||||
|
||||
### 開発環境
|
||||
|
||||
AstrBot はコードのフォーマットとチェックに `ruff` を使用しています。
|
||||
AstrBotはコードのフォーマットとチェックに`ruff`を使用しています。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AstrBotDevs/AstrBot
|
||||
pip install pre-commit
|
||||
git switch dev # 開発ブランチに切り替え
|
||||
pip install pre-commit # または uv tool install pre-commit
|
||||
pre-commit install
|
||||
```
|
||||
ローカルでのインストールとテストには`uv`の使用をお勧めします。
|
||||
```bash
|
||||
uv tool install -e . --force
|
||||
astrbot init
|
||||
astrbot run
|
||||
```
|
||||
フロントエンドのデバッグ
|
||||
```bash
|
||||
astrbot run --backend-only
|
||||
cd dashboard
|
||||
bun install # または pnpm など
|
||||
bun dev
|
||||
```
|
||||
|
||||
## 🌍 コミュニティ
|
||||
### QQグループ
|
||||
|
||||
### QQ グループ
|
||||
- 9群: 1076659624 (新)
|
||||
- 10群: 1078079676 (新)
|
||||
- 1群:322154837
|
||||
- 3群:630166526
|
||||
- 5群:822130018
|
||||
- 6群:753075035
|
||||
- 7群:743746109
|
||||
- 8群:1030353265
|
||||
- 開発者群(雑談):975206796
|
||||
- 開発者群(公式):1039761811
|
||||
|
||||
- 1群: 322154837
|
||||
- 3群: 630166526
|
||||
- 5群: 822130018
|
||||
- 6群: 753075035
|
||||
- 開発者群: 975206796
|
||||
### Discordチャンネル
|
||||
|
||||
### Telegram グループ
|
||||
|
||||
<a href="https://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
|
||||
### Discord サーバー
|
||||
|
||||
<a href="https://discord.gg/hAVk6tgV36"><img alt="Discord_community" src="https://img.shields.io/badge/Discord-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
- [Discord](https://discord.gg/hAVk6tgV36)
|
||||
|
||||
## ❤️ Special Thanks
|
||||
|
||||
AstrBot への貢献をしていただいたすべてのコントリビューターとプラグイン開発者に特別な感謝を ❤️
|
||||
AstrBotに貢献してくださったすべてのコントリビューターとプラグイン開発者に感謝します ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||
</a>
|
||||
|
||||
また、このプロジェクトの誕生は以下のオープンソースプロジェクトの助けなしには実現できませんでした:
|
||||
さらに、このプロジェクトの誕生は、以下のオープンソースプロジェクトの助けなしにはあり得ませんでした:
|
||||
|
||||
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - 素晴らしい猫猫フレームワーク
|
||||
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - 偉大な猫フレームワーク
|
||||
|
||||
オープンソースプロジェクトのフレンドリーリンク:
|
||||
|
||||
- [NoneBot2](https://github.com/nonebot/nonebot2) - 優れたPython非同期チャットボットフレームワーク
|
||||
- [Koishi](https://github.com/koishijs/koishi) - 優れたNode.jsチャットボットフレームワーク
|
||||
- [MaiBot](https://github.com/Mai-with-u/MaiBot) - 優れた擬人化AIチャットボット
|
||||
- [nekro-agent](https://github.com/KroMiose/nekro-agent) - 優れたエージェントチャットボット
|
||||
- [LangBot](https://github.com/langbot-app/LangBot) - 優れたマルチプラットフォームAIチャットボット
|
||||
- [ChatLuna](https://github.com/ChatLunaLab/chatluna) - 優れたマルチプラットフォームAIチャットボットKoishiプラグイン
|
||||
- [Operit AI](https://github.com/AAswordman/Operit) - 優れたAIインテリジェントアシスタントAndroidアプリ
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
> [!TIP]
|
||||
> このプロジェクトがあなたの生活や仕事に役立ったり、このプロジェクトの今後の発展に関心がある場合は、プロジェクトに Star をください。これがこのオープンソースプロジェクトを維持する原動力です <3
|
||||
> もしこのプロジェクトがあなたの生活や仕事の助けになったなら、あるいはこのプロジェクトの将来の発展に関心があるなら、プロジェクトにStarを付けてください。これは私たちがこのオープンソースプロジェクトを維持するための原動力となります <3
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -276,10 +288,10 @@ AstrBot への貢献をしていただいたすべてのコントリビュータ
|
||||
|
||||
<div align="center">
|
||||
|
||||
_共感力と能力は決して対立するものではありません。私たちが目指すのは、感情を理解し、心の支えとなるだけでなく、確実に仕事をこなせるロボットの創造です。_
|
||||
_付き添いと能力は決して対立するものであってはなりません。私たちが創造したいのは、感情を理解し、寄り添いながらも、確実に仕事を遂行できるロボットです。_
|
||||
|
||||
_私は、高性能ですから!_
|
||||
|
||||
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
+161
-149
@@ -2,13 +2,11 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh.md">中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">English</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a>
|
||||
|
||||
<br>
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh.md">简体中文</a>
|
||||
|
||||
<div>
|
||||
<a href="https://trendshift.io/repositories/12875" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12875" alt="Soulter%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
@@ -21,44 +19,47 @@
|
||||
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest">
|
||||
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
|
||||
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
|
||||
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFZIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjczODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
|
||||
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
|
||||
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?color=76bad9"/></a>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%20&label=%D0%9C%D0%B0%D1%80%D0%BA%D0%B5%D1%82%D0%BF%D0%BB%D0%B5%D0%B9%D1%81&cacheSeconds=3600">
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%20Plugins&label=%D0%9C%D0%B0%D1%80%D0%BA%D0%B5%D1%82%D0%BF%D0%BB%D0%B5%D0%B9%D1%81&cacheSeconds=3600">
|
||||
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<a href="https://astrbot.app/">Главная</a> |
|
||||
<a href="https://astrbot.app/">Документация</a> |
|
||||
<a href="https://blog.astrbot.app/">Блог</a> |
|
||||
<a href="https://astrbot.featurebase.app/roadmap">Дорожная карта</a> |
|
||||
<a href="https://astrbot.featurebase.app/roadmap">Roadmap</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/issues">Сообщить о проблеме</a>
|
||||
<a href="mailto:community@astrbot.app">Email</a>
|
||||
|
||||
</div>
|
||||
|
||||
AstrBot — это универсальная платформа Agent-чатботов с открытым исходным кодом, которая интегрируется с основными приложениями для обмена мгновенными сообщениями. Она предоставляет надёжную и масштабируемую инфраструктуру разговорного ИИ для частных лиц, разработчиков и команд. Будь то персональный ИИ-компаньон, интеллектуальная служба поддержки, автоматизированный помощник или корпоративная база знаний — AstrBot позволяет быстро создавать готовые к использованию ИИ-приложения в рабочих процессах вашей платформы обмена сообщениями.
|
||||
AstrBot — это универсальный агентский помощник для личных и групповых чатов с открытым исходным кодом. Он может быть развернут в десятках популярных мессенджеров, таких как QQ, Telegram, WeCom (Enterprise WeChat), Lark (Feishu), DingTalk, Slack и других. Кроме того, он имеет встроенный легковесный веб-интерфейс чата (ChatUI), похожий на OpenWebUI, создавая надежную и масштабируемую диалоговую интеллектуальную инфраструктуру для частных лиц, разработчиков и команд. Будь то личный AI-компаньон, интеллектуальная служба поддержки, автоматизированный помощник или корпоративная база знаний, AstrBot позволяет быстро создавать AI-приложения в рабочем процессе ваших платформ обмена мгновенными сообщениями.
|
||||
|
||||

|
||||

|
||||
|
||||
## Основные возможности
|
||||
|
||||
1. 💯 Бесплатно & Открытый исходный код.
|
||||
2. ✨ Диалоги с ИИ-моделями, мультимодальность, Agent, MCP, Skills, База знаний, Настройка личности, автоматическое сжатие диалогов.
|
||||
3. 🤖 Поддержка интеграции с платформами Agents, такими как Dify, Alibaba Cloud Bailian, Coze и др.
|
||||
4. 🌐 Мультиплатформенность: поддержка QQ, WeChat для предприятий, Feishu, DingTalk, публичных аккаунтов WeChat, Telegram, Slack и [других](#Поддерживаемые-платформы-обмена-сообщениями).
|
||||
1. 💯 Бесплатно и с открытым исходным кодом.
|
||||
2. ✨ Поддержка диалога с большими языковыми моделями (LLM), мультимодальность, Агенты, MCP, Навыки (Skills), База знаний, Персонализация, автоматическое сжатие диалога.
|
||||
3. 🤖 Поддержка интеграции с платформами агентов, такими как Dify, Alibaba Bailian, Coze и др.
|
||||
4. 🌐 Мультиплатформенность: поддержка QQ, WeCom, Lark, DingTalk, WeChat Official Account, Telegram, Slack и [других](#поддерживаемые-платформы-сообщений).
|
||||
5. 📦 Расширение плагинами: доступно более 1000 плагинов для установки в один клик.
|
||||
6. 🛡️ Изолированная среда[Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html): безопасное выполнение любого кода, вызов Shell, повторное использование ресурсов на уровне сессии.
|
||||
6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html): Изолированная среда для безопасного выполнения любого кода, вызова Shell и повторного использования ресурсов на уровне сессии.
|
||||
7. 💻 Поддержка WebUI.
|
||||
8. 🌈 Поддержка Web ChatUI: встроенная песочница агента, веб-поиск и др.
|
||||
8. 🌈 Поддержка Web ChatUI: встроенная прокси-песочница, веб-поиск и многое другое внутри ChatUI.
|
||||
9. 🌐 Поддержка интернационализации (i18n).
|
||||
|
||||
<br>
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<th>💙 Ролевые игры & Эмоциональная поддержка</th>
|
||||
<th>✨ Проактивный Агент (Agent)</th>
|
||||
<th>🚀 Универсальные возможности Агента</th>
|
||||
<th>🧩 1000+ плагинов сообщества</th>
|
||||
<th>💙 Ролевые игры и Эмоциональное общение</th>
|
||||
<th>✨ Проактивный Агент</th>
|
||||
<th>🚀 Общие агентские возможности</th>
|
||||
<th>🧩 1000+ Плагинов сообщества</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
|
||||
@@ -70,185 +71,187 @@ AstrBot — это универсальная платформа Agent-чатб
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
#### Развёртывание Docker (Рекомендуется 🥳)
|
||||
### Развертывание в один клик
|
||||
|
||||
Мы рекомендуем развёртывать AstrBot с помощью Docker или Docker Compose.
|
||||
|
||||
См. официальную документацию: [Развёртывание AstrBot с Docker](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
|
||||
|
||||
#### Развёртывание uv
|
||||
Для пользователей, которые хотят быстро протестировать AstrBot, знакомы с командной строкой и могут самостоятельно установить среду `uv`, мы рекомендуем метод развертывания в один клик с помощью `uv` ⚡️.
|
||||
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
astrbot
|
||||
astrbot init # Выполните эту команду только в первый раз для инициализации среды
|
||||
astrbot run # astrbot run --backend-only запускает только бэкенд сервис
|
||||
|
||||
# Установка версии для разработчиков (больше исправлений и новых функций, но менее стабильна; подходит для разработчиков)
|
||||
uv tool install git+https://github.com/AstrBotDevs/AstrBot@dev
|
||||
```
|
||||
|
||||
#### Десктопное приложение (Tauri)
|
||||
> Требуется установленный [uv](https://docs.astral.sh/uv/).
|
||||
|
||||
Репозиторий десктопного приложения: [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
|
||||
> [!NOTE]
|
||||
> Для пользователей macOS: Из-за проверок безопасности macOS первый запуск команды `astrbot` может занять длительное время (около 10-20 секунд).
|
||||
|
||||
Поддерживает различные системные архитектуры, устанавливается напрямую, "из коробки", лучшее настольное решение в один клик для новичков и тех, кто ценит простоту. Не рекомендуется для серверных сценариев.
|
||||
Обновление `astrbot`:
|
||||
|
||||
#### Установка в один клик через лаунчер (AstrBot Launcher)
|
||||
```bash
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
Быстрое развёртывание и поддержка нескольких экземпляров, изоляция среды. Перейдите в репозиторий [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher), найдите последнюю версию на странице Releases и установите соответствующий пакет для вашей системы.
|
||||
### Развертывание через Docker
|
||||
|
||||
#### Развёртывание BT-Panel
|
||||
Для пользователей, знакомых с контейнерами и предпочитающих более стабильный метод развертывания, подходящий для производственных сред, мы рекомендуем использовать Docker / Docker Compose для развертывания AstrBot.
|
||||
|
||||
AstrBot в партнёрстве с BT-Panel теперь доступен на их маркетплейсе.
|
||||
Пожалуйста, обратитесь к официальной документации [Развертывание AstrBot с помощью Docker](https://astrbot.app/deploy/astrbot/docker.html).
|
||||
|
||||
См. официальную документацию: [Развёртывание BT-Panel](https://astrbot.app/deploy/astrbot/btpanel.html).
|
||||
### Развертывание на RainYun
|
||||
|
||||
#### Развёртывание 1Panel
|
||||
|
||||
AstrBot официально размещён на маркетплейсе 1Panel.
|
||||
|
||||
См. официальную документацию: [Развёртывание 1Panel](https://astrbot.app/deploy/astrbot/1panel.html).
|
||||
|
||||
#### Развёртывание на RainYun
|
||||
|
||||
For Chinese users:
|
||||
|
||||
AstrBot официально размещён на облачной платформе приложений RainYun с развёртыванием в один клик.
|
||||
Для пользователей, которые хотят развернуть AstrBot в один клик и не хотят самостоятельно управлять серверами, мы рекомендуем облачный сервис развертывания в один клик от RainYun ☁️:
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||
|
||||
#### Развёртывание на Replit
|
||||
### Развертывание настольного клиента
|
||||
|
||||
Метод развёртывания от сообщества.
|
||||
Для пользователей, желающих использовать AstrBot на рабочем столе и использовать ChatUI в качестве основного интерфейса, мы рекомендуем приложение AstrBot App.
|
||||
|
||||
Перейдите на [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) для загрузки и установки; этот метод предназначен для использования на рабочем столе и не рекомендуется для серверных сценариев.
|
||||
|
||||
### Развертывание через лаунчер
|
||||
|
||||
Также для настольных компьютеров, для пользователей, которым требуется быстрое развертывание и изоляция среды для нескольких экземпляров, мы рекомендуем AstrBot Launcher.
|
||||
|
||||
Перейдите на [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) для загрузки и установки.
|
||||
|
||||
### Развертывание на Replit
|
||||
|
||||
Развертывание на Replit поддерживается сообществом и подходит для онлайн-демонстраций и легких тестовых сценариев.
|
||||
|
||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||
|
||||
#### Установщик Windows в один клик
|
||||
### AUR
|
||||
|
||||
См. официальную документацию: [Развёртывание AstrBot с установщиком Windows в один клик](https://astrbot.app/deploy/astrbot/windows.html).
|
||||
Метод AUR предназначен для пользователей Arch Linux, желающих установить AstrBot через системный менеджер пакетов.
|
||||
|
||||
#### Развёртывание CasaOS
|
||||
|
||||
Метод развёртывания от сообщества.
|
||||
|
||||
См. официальную документацию: [Развёртывание CasaOS](https://astrbot.app/deploy/astrbot/casaos.html).
|
||||
|
||||
#### Ручное развёртывание
|
||||
|
||||
Сначала установите uv:
|
||||
|
||||
```bash
|
||||
pip install uv
|
||||
```
|
||||
|
||||
Установите AstrBot через Git Clone:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AstrBotDevs/AstrBot && cd AstrBot
|
||||
uv run main.py
|
||||
```
|
||||
|
||||
Или см. официальную документацию: [Развёртывание AstrBot из исходного кода](https://astrbot.app/deploy/astrbot/cli.html).
|
||||
|
||||
#### Установка через системный пакетный менеджер
|
||||
|
||||
##### Arch Linux
|
||||
Выполните приведенную ниже команду в терминале, чтобы установить пакет `astrbot-git`. После завершения установки вы сможете запустить его.
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
# или используйте paru
|
||||
paru -S astrbot-git
|
||||
```
|
||||
|
||||
## Поддерживаемые платформы обмена сообщениями
|
||||
**Другие методы развертывания**
|
||||
|
||||
Подключите AstrBot к вашим любимым чат-платформам.
|
||||
Если вам требуется панельное управление или более кастомизированное развертывание, вы можете обратиться к [BT Panel](https://astrbot.app/deploy/astrbot/btpanel.html) (установка через магазин приложений BT Panel), [1Panel](https://astrbot.app/deploy/astrbot/1panel.html) (установка через магазин приложений 1Panel), [CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) (визуальное развертывание для NAS / домашнего сервера) и [Ручное развертывание](https://astrbot.app/deploy/astrbot/cli.html) (полная пользовательская установка на основе исходного кода и `uv`).
|
||||
|
||||
## Поддерживаемые платформы сообщений
|
||||
|
||||
Подключите AstrBot к вашим любимым платформам чата.
|
||||
|
||||
| Платформа | Поддержка |
|
||||
|---------|---------------|
|
||||
| QQ | Официальная |
|
||||
| Реализация протокола OneBot v11 | Официальная |
|
||||
| Telegram | Официальная |
|
||||
| Приложение WeChat Work и интеллектуальный бот WeChat Work | Официальная |
|
||||
| Служба поддержки WeChat и официальные аккаунты WeChat | Официальная |
|
||||
| Feishu (Lark) | Официальная |
|
||||
| DingTalk | Официальная |
|
||||
| Slack | Официальная |
|
||||
| Discord | Официальная |
|
||||
| LINE | Официальная |
|
||||
| Satori | Официальная |
|
||||
| Misskey | Официальная |
|
||||
| WhatsApp (Скоро) | Официальная |
|
||||
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Сообщество |
|
||||
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | Сообщество |
|
||||
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | Сообщество |
|
||||
| **QQ** | Официальная |
|
||||
| **OneBot v11** | Официальная |
|
||||
| **Telegram** | Официальная |
|
||||
| **WeCom (Приложение & Смарт-бот)** | Официальная |
|
||||
| **WeChat (Служба поддержки & Официальный аккаунт)** | Официальная |
|
||||
| **Lark (Feishu)** | Официальная |
|
||||
| **DingTalk** | Официальная |
|
||||
| **Slack** | Официальная |
|
||||
| **Discord** | Официальная |
|
||||
| **LINE** | Официальная |
|
||||
| **Satori** | Официальная |
|
||||
| **Misskey** | Официальная |
|
||||
| **Whatsapp (Скоро)** | Официальная |
|
||||
| [**Matrix**](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Сообщество |
|
||||
| [**KOOK**](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | Сообщество |
|
||||
| [**VoceChat**](https://github.com/HikariFroya/astrbot_plugin_vocechat) | Сообщество |
|
||||
|
||||
## Поддерживаемые сервисы моделей
|
||||
## Поддерживаемые провайдеры моделей
|
||||
|
||||
| Сервис | Тип |
|
||||
| Провайдер | Тип |
|
||||
|---------|---------------|
|
||||
| OpenAI и совместимые сервисы | Сервисы LLM |
|
||||
| Anthropic | Сервисы LLM |
|
||||
| Google Gemini | Сервисы LLM |
|
||||
| Moonshot AI | Сервисы LLM |
|
||||
| Zhipu AI | Сервисы LLM |
|
||||
| DeepSeek | Сервисы LLM |
|
||||
| Ollama (Самостоятельное размещение) | Сервисы LLM |
|
||||
| LM Studio (Самостоятельное размещение) | Сервисы LLM |
|
||||
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | Сервисы LLM |
|
||||
| [302.AI](https://share.302.ai/rr1M3l) | Сервисы LLM |
|
||||
| [TokenPony](https://www.tokenpony.cn/3YPyf) | Сервисы LLM |
|
||||
| [SiliconFlow](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) | Сервисы LLM |
|
||||
| [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE) | Сервисы LLM |
|
||||
| ModelScope | Сервисы LLM |
|
||||
| OneAPI | Сервисы LLM |
|
||||
| Dify | Платформы LLMOps |
|
||||
| Приложения Alibaba Cloud Bailian | Платформы LLMOps |
|
||||
| Coze | Платформы LLMOps |
|
||||
| OpenAI Whisper | Сервисы распознавания речи |
|
||||
| SenseVoice | Сервисы распознавания речи |
|
||||
| OpenAI TTS | Сервисы синтеза речи |
|
||||
| Gemini TTS | Сервисы синтеза речи |
|
||||
| GPT-Sovits-Inference | Сервисы синтеза речи |
|
||||
| GPT-Sovits | Сервисы синтеза речи |
|
||||
| FishAudio | Сервисы синтеза речи |
|
||||
| Edge TTS | Сервисы синтеза речи |
|
||||
| Alibaba Cloud Bailian TTS | Сервисы синтеза речи |
|
||||
| Azure TTS | Сервисы синтеза речи |
|
||||
| Minimax TTS | Сервисы синтеза речи |
|
||||
| Volcano Engine TTS | Сервисы синтеза речи |
|
||||
| Пользовательский | Любой сервис, совместимый с OpenAI API |
|
||||
| OpenAI | LLM |
|
||||
| Anthropic | LLM |
|
||||
| Google Gemini | LLM |
|
||||
| Moonshot AI | LLM |
|
||||
| Zhipu AI | LLM |
|
||||
| DeepSeek | LLM |
|
||||
| Ollama (Локально) | LLM |
|
||||
| LM Studio (Локально) | LLM |
|
||||
| [AIHubMix](https://aihubmix.com/?aff=4bfH) | LLM (API шлюз, поддерживает все модели) |
|
||||
| [Uyun AI](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | LLM (API шлюз, поддерживает все модели) |
|
||||
| [SiliconFlow](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) | LLM (API шлюз, поддерживает все модели) |
|
||||
| [PPIO](https://ppio.com/user/register?invited_by=AIOONE) | LLM (API шлюз, поддерживает все модели) |
|
||||
| [302.AI](https://share.302.ai/rr1M3l) | LLM (API шлюз, поддерживает все модели)|
|
||||
| [TokenPony](https://www.tokenpony.cn/3YPyf) | LLM (API шлюз, поддерживает все модели)|
|
||||
| ModelScope | LLM |
|
||||
| OneAPI | LLM |
|
||||
| Dify | Платформа LLMOps |
|
||||
| Alibaba Bailian | Платформа LLMOps |
|
||||
| Coze | Платформа LLMOps |
|
||||
| OpenAI Whisper | Распознавание речи (STT) |
|
||||
| SenseVoice | Распознавание речи (STT) |
|
||||
| OpenAI TTS | Синтез речи (TTS) |
|
||||
| Gemini TTS | Синтез речи (TTS) |
|
||||
| GPT-Sovits-Inference | Синтез речи (TTS) |
|
||||
| GPT-Sovits | Синтез речи (TTS) |
|
||||
| FishAudio | Синтез речи (TTS) |
|
||||
| Edge TTS | Синтез речи (TTS) |
|
||||
| Alibaba Bailian TTS | Синтез речи (TTS) |
|
||||
| Azure TTS | Синтез речи (TTS) |
|
||||
| Minimax TTS | Синтез речи (TTS) |
|
||||
| Volcengine TTS | Синтез речи (TTS) |
|
||||
|
||||
## ❤️ Вклад в проект
|
||||
|
||||
Issues и Pull Request всегда приветствуются! Не стесняйтесь отправлять свои изменения в этот проект :)
|
||||
Мы приветствуем любые Issues и Pull Requests! Просто отправьте свои изменения в этот проект :)
|
||||
|
||||
### Как внести вклад
|
||||
|
||||
Вы можете внести вклад, просматривая issues или помогая с ревью pull request. Любые issues или PR приветствуются для поощрения участия сообщества. Конечно, это лишь предложения — вы можете вносить вклад любым удобным для вас способом. Для добавления новых функций сначала обсудите это через Issue.
|
||||
Вы можете внести свой вклад, просматривая проблемы (Issues) или помогая проверять PR (Pull Requests). Любая проблема или PR приветствуются для поощрения участия сообщества. Конечно, это всего лишь предложения, вы можете внести свой вклад любым способом. Для добавления новых функций, пожалуйста, сначала обсудите это через Issue.
|
||||
Рекомендуется объединять функциональные PR в ветку `dev`, которая будет объединена с основной веткой (`main`) и выпущена как новая версия после тестирования изменений.
|
||||
Для уменьшения конфликтов мы рекомендуем:
|
||||
1. Создавайте рабочую ветку на основе ветки `dev`, избегайте работы напрямую в ветке `main`.
|
||||
2. При отправке PR выбирайте ветку `dev` в качестве целевой.
|
||||
3. Регулярно синхронизируйте ветку `dev` с локальной средой, чаще используйте `git pull`.
|
||||
|
||||
### Среда разработки
|
||||
|
||||
AstrBot использует `ruff` для форматирования и линтинга кода.
|
||||
AstrBot использует `ruff` для форматирования и проверки кода.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AstrBotDevs/AstrBot
|
||||
pip install pre-commit
|
||||
git switch dev # Переключиться на ветку разработки
|
||||
pip install pre-commit # или uv tool install pre-commit
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
## 🌍 Сообщество
|
||||
Рекомендуется использовать `uv` для локальной установки и тестирования:
|
||||
```bash
|
||||
uv tool install -e . --force
|
||||
astrbot init
|
||||
astrbot run
|
||||
```
|
||||
Отладка фронтенда:
|
||||
```bash
|
||||
astrbot run --backend-only
|
||||
cd dashboard
|
||||
bun install # или pnpm и т.д.
|
||||
bun dev
|
||||
```
|
||||
|
||||
### Группы QQ
|
||||
|
||||
- Группа 9: 1076659624 (Новая)
|
||||
- Группа 10: 1078079676 (Новая)
|
||||
- Группа 1: 322154837
|
||||
- Группа 3: 630166526
|
||||
- Группа 5: 822130018
|
||||
- Группа 6: 753075035
|
||||
- Группа разработчиков: 975206796
|
||||
- Группа 7: 743746109
|
||||
- Группа 8: 1030353265
|
||||
- Группа разработчиков (Неформальное общение): 975206796
|
||||
- Группа разработчиков (Официальная): 1039761811
|
||||
|
||||
### Группа Telegram
|
||||
### Канал Discord
|
||||
|
||||
<a href="https://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
|
||||
### Сервер Discord
|
||||
|
||||
<a href="https://discord.gg/hAVk6tgV36"><img alt="Discord_community" src="https://img.shields.io/badge/Discord-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
- [Discord](https://discord.gg/hAVk6tgV36)
|
||||
|
||||
## ❤️ Особая благодарность
|
||||
|
||||
@@ -258,15 +261,24 @@ pre-commit install
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||
</a>
|
||||
|
||||
Кроме того, рождение этого проекта было бы невозможно без помощи следующих проектов с открытым исходным кодом:
|
||||
Кроме того, рождение этого проекта было бы невозможным без помощи следующих проектов с открытым исходным кодом:
|
||||
|
||||
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - Замечательный кошачий фреймворк
|
||||
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - Великий кошачий фреймворк
|
||||
|
||||
## ⭐ История звёзд
|
||||
Дружественные ссылки на проекты с открытым исходным кодом:
|
||||
|
||||
- [NoneBot2](https://github.com/nonebot/nonebot2) - Отличный асинхронный фреймворк ChatBot на Python
|
||||
- [Koishi](https://github.com/koishijs/koishi) - Отличный фреймворк ChatBot на Node.js
|
||||
- [MaiBot](https://github.com/Mai-with-u/MaiBot) - Отличный антропоморфный AI ChatBot
|
||||
- [nekro-agent](https://github.com/KroMiose/nekro-agent) - Отличный агентский ChatBot
|
||||
- [LangBot](https://github.com/langbot-app/LangBot) - Отличный мультиплатформенный AI ChatBot
|
||||
- [ChatLuna](https://github.com/ChatLunaLab/chatluna) - Отличный плагин мультиплатформенного AI ChatBot для Koishi
|
||||
- [Operit AI](https://github.com/AAswordman/Operit) - Отличное Android-приложение интеллектуального AI-помощника
|
||||
|
||||
## ⭐ История звезд
|
||||
|
||||
> [!TIP]
|
||||
> Если этот проект помог вам в жизни или работе, или если вас интересует его будущее развитие, пожалуйста, поставьте проекту звезду. Это движущая сила поддержки этого проекта с открытым исходным кодом <3
|
||||
|
||||
> Если этот проект помог вам в жизни или работе, или если вы заинтересованы в будущем развитии этого проекта, пожалуйста, поставьте проекту звезду (Star). Это наша мотивация поддерживать этот проект с открытым исходным кодом <3
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -276,10 +288,10 @@ pre-commit install
|
||||
|
||||
<div align="center">
|
||||
|
||||
_Сопровождение и способности никогда не должны быть противоположностями. Мы стремимся создать робота, который сможет как понимать эмоции, оказывать душевную поддержку, так и надёжно выполнять работу._
|
||||
_Компаньонство и способности никогда не должны быть противоположностями. Мы надеемся создать робота, который сможет одновременно понимать эмоции, быть компаньоном и надежно выполнять работу._
|
||||
|
||||
_私は、高性能ですから!_
|
||||
_私は、高性能ですから!_ (Я высокопроизводительный!)
|
||||
|
||||
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
+148
-135
@@ -2,14 +2,12 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh.md">简体中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">English</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh.md">简体中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
||||
|
||||
<br>
|
||||
|
||||
<div>
|
||||
<a href="https://trendshift.io/repositories/12875" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12875" alt="Soulter%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<a href="https://hellogithub.com/repository/AstrBotDevs/AstrBot" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=d127d50cd5e54c5382328acc3bb25483&claim_uid=ZO9by7qCXgSd6Lp&t=2" alt="Featured|HelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
@@ -29,27 +27,30 @@
|
||||
|
||||
<br>
|
||||
|
||||
<a href="https://astrbot.app/">文件</a> |
|
||||
<a href="https://blog.astrbot.app/">Blog</a> |
|
||||
<a href="https://astrbot.app/">首頁</a> |
|
||||
<a href="https://astrbot.app/">文檔</a> |
|
||||
<a href="https://blog.astrbot.app/">博客</a> |
|
||||
<a href="https://astrbot.featurebase.app/roadmap">路線圖</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/issues">問題回報</a>
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/issues">問題提交</a>
|
||||
<a href="mailto:community@astrbot.app">Email</a>
|
||||
|
||||
</div>
|
||||
|
||||
AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主流即時通訊軟體,為個人、開發者和團隊打造可靠、可擴展的對話式智慧基礎設施。無論是個人 AI 夥伴、智慧客服、自動化助手,還是企業知識庫,AstrBot 都能在您的即時通訊軟體平台的工作流程中快速構建生產可用的 AI 應用程式。
|
||||
AstrBot 是一個開源的一站式 Agentic 個人和群聊助手,可在 QQ、Telegram、企業微信、飛書、釘钉、Slack 等數十款主流即時通訊軟件上部署,此外還內置類似 OpenWebUI 的輕量化 ChatUI,為個人、開發者和團隊打造可靠、可擴展的對話式智能基礎設施。無論是個人 AI 夥伴、智能客服、自動化助手,還是企業知識庫,AstrBot 都能在你的即時通訊軟件平台的工作流中快速構建 AI 應用。
|
||||
|
||||

|
||||

|
||||
|
||||
## 主要功能
|
||||
|
||||
1. 💯 免費 & 開源。
|
||||
2. ✨ AI 大模型對話,多模態,Agent,MCP,Skills,知識庫,人格設定,自動壓縮對話。
|
||||
3. 🤖 支援接入 Dify、阿里雲百煉、Coze 等智慧體 (Agent) 平台。
|
||||
4. 🌐 多平台,支援 QQ、企業微信、飛書、釘釘、微信公眾號、Telegram、Slack 以及[更多](#支援的訊息平台)。
|
||||
3. 🤖 支持接入 Dify、阿里雲百煉、Coze 等智能體平台。
|
||||
4. 🌐 多平台,支持 QQ、企業微信、飛書、釘釘、微信公眾號、Telegram、Slack 以及[更多](#支持的消息平台)。
|
||||
5. 📦 插件擴展,已有 1000+ 個插件可一鍵安裝。
|
||||
6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) 隔離化環境,安全地執行任何代碼、調用 Shell、會話級資源複用。
|
||||
7. 💻 WebUI 支援。
|
||||
8. 🌈 Web ChatUI 支援,ChatUI 內置代理沙盒 (Agent Sandbox)、網頁搜尋等。
|
||||
9. 🌐 國際化(i18n)支援。
|
||||
7. 💻 WebUI 支持。
|
||||
8. 🌈 Web ChatUI 支持,ChatUI 內置代理沙盒、網頁搜索等。
|
||||
9. 🌐 國際化(i18n)支持。
|
||||
|
||||
<br>
|
||||
|
||||
@@ -58,7 +59,7 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主
|
||||
<th>💙 角色扮演 & 情感陪伴</th>
|
||||
<th>✨ 主動式 Agent</th>
|
||||
<th>🚀 通用 Agentic 能力</th>
|
||||
<th>🧩 1000+ 社區外掛程式</th>
|
||||
<th>🧩 1000+ 社區插件</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
|
||||
@@ -70,202 +71,214 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主
|
||||
|
||||
## 快速開始
|
||||
|
||||
#### Docker 部署(推薦 🥳)
|
||||
### 一鍵部署
|
||||
|
||||
推薦使用 Docker / Docker Compose 方式部署 AstrBot。
|
||||
|
||||
請參閱官方文件 [使用 Docker 部署 AstrBot](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot)。
|
||||
|
||||
#### uv 部署
|
||||
對於想快速體驗 AstrBot、且熟悉命令行並能夠自行安裝 `uv` 環境的用戶,我們推薦使用 `uv` 一鍵部署方式 ⚡️。
|
||||
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
astrbot
|
||||
astrbot init # 僅首次執行此命令以初始化環境
|
||||
astrbot run # astrbot run --backend-only 僅啟動後端服務
|
||||
|
||||
# 安裝開發版本(更多修復,新功能,但不夠穩定,適合開發者)
|
||||
uv tool install git+https://github.com/AstrBotDevs/AstrBot@dev
|
||||
```
|
||||
|
||||
#### 桌面應用部署(Tauri)
|
||||
> 需要安裝 [uv](https://docs.astral.sh/uv/)。
|
||||
|
||||
桌面應用倉庫 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
|
||||
> [!NOTE]
|
||||
> 對於 macOS 用戶:由於 macOS 安全檢查,首次運行 `astrbot` 命令可能需要較長時間(約 10-20 秒)。
|
||||
|
||||
支援多系統架構,安裝包直接安裝,開箱即用,最適合新手和懶人的一鍵桌面部署方案,不推薦伺服器場景。
|
||||
更新 `astrbot`:
|
||||
|
||||
#### 啟動器一鍵部署(AstrBot Launcher)
|
||||
```bash
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
快速部署和多開方案,實現環境隔離,進入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 倉庫,在 Releases 頁最新版本下找到對應的系統安裝包安裝即可。
|
||||
### Docker 部署
|
||||
|
||||
#### 寶塔面板部署
|
||||
對於熟悉容器、希望獲得更穩定且更適合生產環境部署方式的用戶,我們推薦使用 Docker / Docker Compose 部署 AstrBot。
|
||||
|
||||
AstrBot 與寶塔面板合作,已上架至寶塔面板。
|
||||
請參考官方文檔 [使用 Docker 部署 AstrBot](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot)。
|
||||
|
||||
請參閱官方文件 [寶塔面板部署](https://astrbot.app/deploy/astrbot/btpanel.html)。
|
||||
### 在 雨雲 上部署
|
||||
|
||||
#### 1Panel 部署
|
||||
|
||||
AstrBot 已由 1Panel 官方上架至 1Panel 面板。
|
||||
|
||||
請參閱官方文件 [1Panel 部署](https://astrbot.app/deploy/astrbot/1panel.html)。
|
||||
|
||||
#### 在雨雲上部署
|
||||
|
||||
For Chinese users:
|
||||
|
||||
AstrBot 已由雨雲官方上架至雲端應用程式平台,可一鍵部署。
|
||||
對於希望一鍵部署 AstrBot 且不想自行管理服務器的用戶,我們推薦使用雨雲的一鍵雲部署服務 ☁️:
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||
|
||||
#### 在 Replit 上部署
|
||||
### 桌面客戶端部署
|
||||
|
||||
社群貢獻的部署方式。
|
||||
對於希望在桌面端使用 AstrBot、並以 ChatUI 為主要入口的用戶,我們推薦使用 AstrBot App。
|
||||
|
||||
前往 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) 下載並安裝;該方式面向桌面使用,不推薦服務器場景。
|
||||
|
||||
### 啟動器部署
|
||||
|
||||
同樣在桌面端,希望快速部署並實現環境隔離多開的用戶,我們推薦使用 AstrBot Launcher。
|
||||
|
||||
前往 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 下載並安裝。
|
||||
|
||||
### 在 Replit 上部署
|
||||
|
||||
Replit 部署由社區維護,適合在線演示和輕量試用場景。
|
||||
|
||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||
|
||||
#### Windows 一鍵安裝器部署
|
||||
### AUR
|
||||
|
||||
請參閱官方文件 [使用 Windows 一鍵安裝器部署 AstrBot](https://astrbot.app/deploy/astrbot/windows.html)。
|
||||
AUR 方式面向 Arch Linux 用戶,適合希望通過系統包管理器安裝 AstrBot 的場景。
|
||||
|
||||
#### CasaOS 部署
|
||||
|
||||
社群貢獻的部署方式。
|
||||
|
||||
請參閱官方文件 [CasaOS 部署](https://astrbot.app/deploy/astrbot/casaos.html)。
|
||||
|
||||
#### 手動部署
|
||||
|
||||
首先安裝 uv:
|
||||
|
||||
```bash
|
||||
pip install uv
|
||||
```
|
||||
|
||||
透過 Git Clone 安裝 AstrBot:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AstrBotDevs/AstrBot && cd AstrBot
|
||||
uv run main.py
|
||||
```
|
||||
|
||||
或者請參閱官方文件 [透過原始碼部署 AstrBot](https://astrbot.app/deploy/astrbot/cli.html)。
|
||||
|
||||
#### 系統套件管理員安裝
|
||||
|
||||
##### Arch Linux
|
||||
在終端執行下方命令安裝 `astrbot-git` 包,安裝完成後即可啟動使用。
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
# 或者使用 paru
|
||||
paru -S astrbot-git
|
||||
```
|
||||
|
||||
## 支援的訊息平台
|
||||
**更多部署方式**
|
||||
|
||||
若你需要面板化或更高自定義部署,可參考 [寶塔面板](https://astrbot.app/deploy/astrbot/btpanel.html)(BT Panel 應用商店安裝)、[1Panel](https://astrbot.app/deploy/astrbot/1panel.html)(1Panel 應用商店安裝)、[CasaOS](https://astrbot.app/deploy/astrbot/casaos.html)(NAS / 家庭服務器可視化部署)和 [手動部署](https://astrbot.app/deploy/astrbot/cli.html)(基於源碼與 `uv` 的完整自定義安裝)。
|
||||
|
||||
## 支持的消息平台
|
||||
|
||||
將 AstrBot 連接到你常用的聊天平台。
|
||||
|
||||
| 平台 | 維護方 |
|
||||
|---------|---------------|
|
||||
| QQ | 官方維護 |
|
||||
| OneBot v11 協議實作 | 官方維護 |
|
||||
| Telegram | 官方維護 |
|
||||
| 企微應用 & 企微智慧機器人 | 官方維護 |
|
||||
| 微信客服 & 微信公眾號 | 官方維護 |
|
||||
| 飛書 | 官方維護 |
|
||||
| 釘釘 | 官方維護 |
|
||||
| Slack | 官方維護 |
|
||||
| Discord | 官方維護 |
|
||||
| LINE | 官方維護 |
|
||||
| Satori | 官方維護 |
|
||||
| Misskey | 官方維護 |
|
||||
| Whatsapp(即將支援) | 官方維護 |
|
||||
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | 社群維護 |
|
||||
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | 社群維護 |
|
||||
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | 社群維護 |
|
||||
| **QQ** | 官方維護 |
|
||||
| **OneBot v11** | 官方維護 |
|
||||
| **Telegram** | 官方維護 |
|
||||
| **企微應用 & 企微智能機器人** | 官方維護 |
|
||||
| **微信客服 & 微信公眾號** | 官方維護 |
|
||||
| **飛書** | 官方維護 |
|
||||
| **釘釘** | 官方維護 |
|
||||
| **Slack** | 官方維護 |
|
||||
| **Discord** | 官方維護 |
|
||||
| **LINE** | 官方維護 |
|
||||
| **Satori** | 官方維護 |
|
||||
| **Misskey** | 官方維護 |
|
||||
| **Whatsapp (將支持)** | 官方維護 |
|
||||
| [**Matrix**](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | 社區維護 |
|
||||
| [**KOOK**](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | 社區維護 |
|
||||
| [**VoceChat**](https://github.com/HikariFroya/astrbot_plugin_vocechat) | 社區維護 |
|
||||
|
||||
## 支援的模型服務
|
||||
## 支持的模型提供商
|
||||
|
||||
| 服務 | 類型 |
|
||||
| 提供商 | 類型 |
|
||||
|---------|---------------|
|
||||
| OpenAI 及相容服務 | 大型模型服務 |
|
||||
| Anthropic | 大型模型服務 |
|
||||
| Google Gemini | 大型模型服務 |
|
||||
| Moonshot AI | 大型模型服務 |
|
||||
| 智譜 AI | 大型模型服務 |
|
||||
| DeepSeek | 大型模型服務 |
|
||||
| Ollama(本機部署) | 大型模型服務 |
|
||||
| LM Studio(本機部署) | 大型模型服務 |
|
||||
| [優雲智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | 大型模型服務 |
|
||||
| [302.AI](https://share.302.ai/rr1M3l) | 大型模型服務 |
|
||||
| [小馬算力](https://www.tokenpony.cn/3YPyf) | 大型模型服務 |
|
||||
| [矽基流動](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) | 大型模型服務 |
|
||||
| [PPIO 派歐雲](https://ppio.com/user/register?invited_by=AIOONE) | 大型模型服務 |
|
||||
| ModelScope | 大型模型服務 |
|
||||
| OneAPI | 大型模型服務 |
|
||||
| 自定義 | 任何 OpenAI API 兼容的服務 |
|
||||
| OpenAI | LLM |
|
||||
| Anthropic | LLM |
|
||||
| Google Gemini | LLM |
|
||||
| Moonshot AI | LLM |
|
||||
| 智譜 AI | LLM |
|
||||
| DeepSeek | LLM |
|
||||
| Ollama (本地部署) | LLM |
|
||||
| LM Studio (本地部署) | LLM |
|
||||
| [AIHubMix](https://aihubmix.com/?aff=4bfH) | LLM (API 網關, 支持所有模型) |
|
||||
| [優雲智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | LLM (API 網關, 支持所有模型) |
|
||||
| [硅基流動](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) | LLM (API 網關, 支持所有模型) |
|
||||
| [PPIO 派歐雲](https://ppio.com/user/register?invited_by=AIOONE) | LLM (API 網關, 支持所有模型) |
|
||||
| [302.AI](https://share.302.ai/rr1M3l) | LLM (API 網關, 支持所有模型)|
|
||||
| [小馬算力](https://www.tokenpony.cn/3YPyf) | LLM (API 網關, 支持所有模型)|
|
||||
| ModelScope | LLM |
|
||||
| OneAPI | LLM |
|
||||
| Dify | LLMOps 平台 |
|
||||
| 阿里雲百煉應用 | LLMOps 平台 |
|
||||
| Coze | LLMOps 平台 |
|
||||
| OpenAI Whisper | 語音轉文字服務 |
|
||||
| SenseVoice | 語音轉文字服務 |
|
||||
| OpenAI TTS | 文字轉語音服務 |
|
||||
| Gemini TTS | 文字轉語音服務 |
|
||||
| GPT-Sovits-Inference | 文字轉語音服務 |
|
||||
| GPT-Sovits | 文字轉語音服務 |
|
||||
| FishAudio | 文字轉語音服務 |
|
||||
| Edge TTS | 文字轉語音服務 |
|
||||
| 阿里雲百煉 TTS | 文字轉語音服務 |
|
||||
| Azure TTS | 文字轉語音服務 |
|
||||
| Minimax TTS | 文字轉語音服務 |
|
||||
| 火山引擎 TTS | 文字轉語音服務 |
|
||||
| OpenAI Whisper | 語音轉文本 |
|
||||
| SenseVoice | 語音轉文本 |
|
||||
| OpenAI TTS | 文本轉語音 |
|
||||
| Gemini TTS | 文本轉語音 |
|
||||
| GPT-Sovits-Inference | 文本轉語音 |
|
||||
| GPT-Sovits | 文本轉語音 |
|
||||
| FishAudio | 文本轉語音 |
|
||||
| Edge TTS | 文本轉語音 |
|
||||
| 阿里雲百煉 TTS | 文本轉語音 |
|
||||
| Azure TTS | 文本轉語音 |
|
||||
| Minimax TTS | 文本轉語音 |
|
||||
| 火山引擎 TTS | 文本轉語音 |
|
||||
|
||||
## ❤️ 貢獻
|
||||
|
||||
歡迎任何 Issues/Pull Requests!只需要將您的變更提交到此專案 :)
|
||||
歡迎任何 Issues/Pull Requests!只需要將你的更改提交到此項目 :)
|
||||
|
||||
### 如何貢獻
|
||||
|
||||
您可以透過檢視問題或協助審核 PR(拉取請求)來貢獻。任何問題或 PR 都歡迎參與,以促進社群貢獻。當然,這些只是建議,您可以以任何方式進行貢獻。對於新功能的新增,請先透過 Issue 討論。
|
||||
你可以通過查看問題或幫助審核 PR(拉取請求)來貢獻。任何問題或 PR 都歡迎參與,以促進社區貢獻。當然,這些只是建議,你可以以任何方式進行貢獻。對於新功能的添加,請先通過 Issue 討論。
|
||||
建議將功能性PR合併至dev分支,將在測試修改後合併到主分支並發布新版本。
|
||||
為了減少衝突,建議如下:
|
||||
1. 工作分支最好基於 `dev` 分支創建,避免直接在 `main` 分支上工作。
|
||||
2. 提交 PR 時,選擇 `dev` 分支作為目標分支。
|
||||
3. 定期同步 `dev` 分支到本地,多使用git pull。
|
||||
|
||||
### 開發環境
|
||||
|
||||
AstrBot 使用 `ruff` 進行程式碼格式化和檢查。
|
||||
AstrBot 使用 `ruff` 進行代碼格式化和檢查。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AstrBotDevs/AstrBot
|
||||
pip install pre-commit
|
||||
git switch dev # 切換到開發分支
|
||||
pip install pre-commit # 或者uv tool install pre-commit
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
## 🌍 社群
|
||||
推薦使用uv本地安裝,進行測試
|
||||
```bash
|
||||
uv tool install -e . --force
|
||||
astrbot init
|
||||
astrbot run
|
||||
```
|
||||
調試前端
|
||||
```bash
|
||||
astrbot run --backend-only
|
||||
cd dashboard
|
||||
bun install # 或者pnpm 等
|
||||
bun dev
|
||||
```
|
||||
|
||||
### QQ 群組
|
||||
|
||||
- 9 群: 1076659624 (新)
|
||||
- 10 群: 1078079676 (新)
|
||||
- 1 群:322154837
|
||||
- 3 群:630166526
|
||||
- 5 群:822130018
|
||||
- 6 群:753075035
|
||||
- 開發者群:975206796
|
||||
- 7 群:743746109
|
||||
- 8 群:1030353265
|
||||
- 開發者群(偏閒聊吹水):975206796
|
||||
- 開發者群(正式):1039761811
|
||||
|
||||
### Telegram 群組
|
||||
### Discord 頻道
|
||||
|
||||
<a href="https://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
|
||||
### Discord 群組
|
||||
|
||||
<a href="https://discord.gg/hAVk6tgV36"><img alt="Discord_community" src="https://img.shields.io/badge/Discord-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
- [Discord](https://discord.gg/hAVk6tgV36)
|
||||
|
||||
## ❤️ Special Thanks
|
||||
|
||||
特別感謝所有 Contributors 和外掛開發者對 AstrBot 的貢獻 ❤️
|
||||
特別感謝所有 Contributors 和插件開發者對 AstrBot 的貢獻 ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||
</a>
|
||||
|
||||
此外,本專案的誕生離不開以下開源專案的幫助:
|
||||
此外,本項目的誕生離不開以下開源項目的幫助:
|
||||
|
||||
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - 偉大的貓貓框架
|
||||
|
||||
開源項目友情鏈接:
|
||||
|
||||
- [NoneBot2](https://github.com/nonebot/nonebot2) - 優秀的 Python 異步 ChatBot 框架
|
||||
- [Koishi](https://github.com/koishijs/koishi) - 優秀的 Node.js ChatBot 框架
|
||||
- [MaiBot](https://github.com/Mai-with-u/MaiBot) - 優秀的擬人化 AI ChatBot
|
||||
- [nekro-agent](https://github.com/KroMiose/nekro-agent) - 優秀的 Agent ChatBot
|
||||
- [LangBot](https://github.com/langbot-app/LangBot) - 優秀的多平台 AI ChatBot
|
||||
- [ChatLuna](https://github.com/ChatLunaLab/chatluna) - 優秀的多平台 AI ChatBot Koishi 插件
|
||||
- [Operit AI](https://github.com/AAswordman/Operit) - 優秀的 AI 智能助手 Android APP
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
> [!TIP]
|
||||
> 如果本專案對您的生活 / 工作產生了幫助,或者您關注本專案的未來發展,請給專案 Star,這是我們維護這個開源專案的動力 <3
|
||||
> 如果本項目對您的生活 / 工作產生了幫助,或者您關注本項目的未來發展,請給項目 Star,這是我們維護這個開源項目的動力 <3
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -281,4 +294,4 @@ _私は、高性能ですから!_
|
||||
|
||||
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
+62
-17
@@ -3,8 +3,8 @@
|
||||
<div align="center">
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">English</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
||||
|
||||
@@ -32,9 +32,11 @@
|
||||
<a href="https://blog.astrbot.app/">博客</a> |
|
||||
<a href="https://astrbot.featurebase.app/roadmap">路线图</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/issues">问题提交</a>
|
||||
<a href="mailto:community@astrbot.app">Email</a>
|
||||
|
||||
</div>
|
||||
|
||||
AstrBot 是一个开源的 Agentic 个人与群聊助手,支持在多款即时通讯平台快速构建 AI 应用与自动化工作流。
|
||||
AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、Telegram、企业微信、飞书、钉钉、Slack、等数十款主流即时通讯软件上部署,此外还内置类似 OpenWebUI 的轻量化 ChatUI,为个人、开发者和团队打造可靠、可扩展的对话式智能基础设施。无论是个人 AI 伙伴、智能客服、自动化助手,还是企业知识库,AstrBot 都能在你的即时通讯软件平台的工作流中快速构建 AI 应用。
|
||||
|
||||

|
||||
|
||||
@@ -71,48 +73,71 @@ AstrBot 是一个开源的 Agentic 个人与群聊助手,支持在多款即时
|
||||
|
||||
### 一键部署
|
||||
|
||||
对于想快速体验 AstrBot、且熟悉命令行并能够自行安装 `uv` 环境的用户,我们推荐使用 `uv` 一键部署方式 ⚡️。
|
||||
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
astrbot
|
||||
astrbot init # 仅首次执行此命令以初始化环境
|
||||
astrbot run # astrbot run --backend-only 仅启动后端服务
|
||||
|
||||
# 安装开发版本(更多修复,新功能,但不够稳定,适合开发者)
|
||||
uv tool install git+https://github.com/AstrBotDevs/AstrBot@dev
|
||||
```
|
||||
|
||||
> 需要安装 [uv](https://docs.astral.sh/uv/)。
|
||||
|
||||
> [!NOTE]
|
||||
> 对于 macOS 用户:由于 macOS 安全检查,首次运行 `astrbot` 命令可能需要较长时间(约 10-20 秒)。
|
||||
|
||||
更新 `astrbot`:
|
||||
|
||||
```bash
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
### Docker 部署
|
||||
|
||||
推荐使用 Docker / Docker Compose 方式部署 AstrBot。
|
||||
对于熟悉容器、希望获得更稳定且更适合生产环境部署方式的用户,我们推荐使用 Docker / Docker Compose 部署 AstrBot。
|
||||
|
||||
请参阅官方文档 [使用 Docker 部署 AstrBot](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot) 。
|
||||
请参考官方文档 [使用 Docker 部署 AstrBot](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot)。
|
||||
|
||||
### 在 雨云 上部署
|
||||
|
||||
AstrBot 已由雨云官方上架至云应用平台,可一键部署。
|
||||
对于希望一键部署 AstrBot 且不想自行管理服务器的用户,我们推荐使用雨云的一键云部署服务 ☁️:
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||
|
||||
### 桌面客户端(Tauri)
|
||||
### 桌面客户端部署
|
||||
|
||||
桌面应用仓库 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
|
||||
对于希望在桌面端使用 AstrBot、并以 ChatUI 为主要入口的用户,我们推荐使用 AstrBot App。
|
||||
|
||||
支持多系统架构,安装包直接安装,开箱即用,最适合新手和懒人的一键桌面部署方案,不推荐服务器场景。
|
||||
前往 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) 下载并安装;该方式面向桌面使用,不推荐服务器场景。
|
||||
|
||||
### 启动器一键部署(AstrBot Launcher)
|
||||
### 启动器部署
|
||||
|
||||
快速部署和多开方案,实现环境隔离,进入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 仓库,在 Releases 页最新版本下找到对应的系统安装包安装即可。
|
||||
同样在桌面端,希望快速部署并实现环境隔离多开的用户,我们推荐使用 AstrBot Launcher。
|
||||
|
||||
前往 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 下载并安装。
|
||||
|
||||
### 在 Replit 上部署
|
||||
|
||||
社区贡献的部署方式。
|
||||
Replit 部署由社区维护,适合在线演示和轻量试用场景。
|
||||
|
||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||
|
||||
### AUR
|
||||
|
||||
AUR 方式面向 Arch Linux 用户,适合希望通过系统包管理器安装 AstrBot 的场景。
|
||||
|
||||
在终端执行下方命令安装 `astrbot-git` 包,安装完成后即可启动使用。
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
```
|
||||
|
||||
**更多部署方式**:[宝塔面板](https://astrbot.app/deploy/astrbot/btpanel.html) | [1Panel](https://astrbot.app/deploy/astrbot/1panel.html) | [CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) | [手动部署](https://astrbot.app/deploy/astrbot/cli.html)
|
||||
**更多部署方式**
|
||||
|
||||
若你需要面板化或更高自定义部署,可参考 [宝塔面板](https://astrbot.app/deploy/astrbot/btpanel.html)(BT Panel 应用商店安装)、[1Panel](https://astrbot.app/deploy/astrbot/1panel.html)(1Panel 应用商店安装)、[CasaOS](https://astrbot.app/deploy/astrbot/casaos.html)(NAS / 家庭服务器可视化部署)和 [手动部署](https://astrbot.app/deploy/astrbot/cli.html)(基于源码与 `uv` 的完整自定义安装)。
|
||||
|
||||
## 支持的消息平台
|
||||
|
||||
@@ -181,6 +206,11 @@ yay -S astrbot-git
|
||||
### 如何贡献
|
||||
|
||||
你可以通过查看问题或帮助审核 PR(拉取请求)来贡献。任何问题或 PR 都欢迎参与,以促进社区贡献。当然,这些只是建议,你可以以任何方式进行贡献。对于新功能的添加,请先通过 Issue 讨论。
|
||||
建议将功能性PR合并至dev分支,将在测试修改后合并到主分支并发布新版本。
|
||||
为了减少冲突,建议如下:
|
||||
1. 工作分支最好基于 `dev` 分支创建,避免直接在 `main` 分支上工作。
|
||||
2. 提交 PR 时,选择 `dev` 分支作为目标分支。
|
||||
3. 定期同步 `dev` 分支到本地,多使用git pull。
|
||||
|
||||
### 开发环境
|
||||
|
||||
@@ -188,21 +218,36 @@ AstrBot 使用 `ruff` 进行代码格式化和检查。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AstrBotDevs/AstrBot
|
||||
pip install pre-commit
|
||||
git switch dev # 切换到开发分支
|
||||
pip install pre-commit # 或者uv tool install pre-commit
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
## 🌍 社区
|
||||
推荐使用uv本地安装,进行测试
|
||||
```bash
|
||||
uv tool install -e . --force
|
||||
astrbot init
|
||||
astrbot run
|
||||
```
|
||||
调试前端
|
||||
```bash
|
||||
astrbot run --backend-only
|
||||
cd dashboard
|
||||
bun install # 或者pnpm 等
|
||||
bun dev
|
||||
```
|
||||
|
||||
### QQ 群组
|
||||
|
||||
- 9 群: 1076659624 (新)
|
||||
- 10 群: 1078079676 (新)
|
||||
- 1 群:322154837
|
||||
- 3 群:630166526
|
||||
- 5 群:822130018
|
||||
- 6 群:753075035
|
||||
- 7 群:743746109
|
||||
- 8 群:1030353265
|
||||
- 开发者群:975206796
|
||||
- 开发者群(偏闲聊吹水):975206796
|
||||
- 开发者群(正式):1039761811
|
||||
|
||||
### Discord 频道
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@ import datetime
|
||||
|
||||
from astrbot.api import sp, star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
from astrbot.core.agent.runners.deerflow.constants import (
|
||||
DEERFLOW_PROVIDER_TYPE,
|
||||
DEERFLOW_THREAD_ID_KEY,
|
||||
)
|
||||
from astrbot.core.platform.astr_message_event import MessageSession
|
||||
from astrbot.core.platform.message_type import MessageType
|
||||
from astrbot.core.utils.active_event_registry import active_event_registry
|
||||
@@ -12,6 +16,7 @@ THIRD_PARTY_AGENT_RUNNER_KEY = {
|
||||
"dify": "dify_conversation_id",
|
||||
"coze": "coze_conversation_id",
|
||||
"dashscope": "dashscope_conversation_id",
|
||||
DEERFLOW_PROVIDER_TYPE: DEERFLOW_THREAD_ID_KEY,
|
||||
}
|
||||
THIRD_PARTY_AGENT_RUNNER_STR = ", ".join(THIRD_PARTY_AGENT_RUNNER_KEY.keys())
|
||||
|
||||
|
||||
@@ -1,15 +1,262 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.api import star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
from astrbot.core.provider.entities import ProviderType
|
||||
from astrbot.core.utils.error_redaction import safe_error
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from astrbot.core.provider.provider import Provider
|
||||
|
||||
|
||||
MODEL_LIST_CACHE_TTL_SECONDS_DEFAULT = 30.0
|
||||
MODEL_LOOKUP_MAX_CONCURRENCY_DEFAULT = 4
|
||||
MODEL_LOOKUP_MAX_CONCURRENCY_UPPER_BOUND = 16
|
||||
MODEL_LIST_CACHE_TTL_KEY = "model_list_cache_ttl_seconds"
|
||||
MODEL_LOOKUP_MAX_CONCURRENCY_KEY = "model_lookup_max_concurrency"
|
||||
MODEL_CACHE_MAX_ENTRIES = 512
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ModelLookupConfig:
|
||||
umo: str | None
|
||||
cache_ttl_seconds: float
|
||||
max_concurrency: int
|
||||
|
||||
|
||||
class _ModelCache:
|
||||
def __init__(self) -> None:
|
||||
self._store: dict[tuple[str, str | None], tuple[float, list[str]]] = {}
|
||||
|
||||
def get(self, provider_id: str, umo: str | None, ttl: float) -> list[str] | None:
|
||||
if ttl <= 0:
|
||||
return None
|
||||
entry = self._store.get((provider_id, umo))
|
||||
if not entry:
|
||||
return None
|
||||
timestamp, models = entry
|
||||
if time.monotonic() - timestamp > ttl:
|
||||
self._store.pop((provider_id, umo), None)
|
||||
return None
|
||||
return models
|
||||
|
||||
def set(
|
||||
self, provider_id: str, umo: str | None, models: list[str], ttl: float
|
||||
) -> None:
|
||||
if ttl <= 0:
|
||||
return
|
||||
self._store[(provider_id, umo)] = (time.monotonic(), list(models))
|
||||
self._evict_if_needed()
|
||||
|
||||
def _evict_if_needed(self) -> None:
|
||||
if len(self._store) <= MODEL_CACHE_MAX_ENTRIES:
|
||||
return
|
||||
# Drop oldest entries first when cache grows too large.
|
||||
overflow = len(self._store) - MODEL_CACHE_MAX_ENTRIES
|
||||
for key, _ in sorted(
|
||||
self._store.items(),
|
||||
key=lambda item: item[1][0],
|
||||
)[:overflow]:
|
||||
self._store.pop(key, None)
|
||||
|
||||
def invalidate(
|
||||
self, provider_id: str | None = None, *, umo: str | None = None
|
||||
) -> None:
|
||||
if provider_id is None:
|
||||
self._store.clear()
|
||||
return
|
||||
if umo is not None:
|
||||
self._store.pop((provider_id, umo), None)
|
||||
return
|
||||
stale_keys = [
|
||||
cache_key for cache_key in self._store if cache_key[0] == provider_id
|
||||
]
|
||||
for cache_key in stale_keys:
|
||||
self._store.pop(cache_key, None)
|
||||
|
||||
|
||||
class ProviderCommands:
|
||||
def __init__(self, context: star.Context) -> None:
|
||||
self.context = context
|
||||
self._model_cache = _ModelCache()
|
||||
self._register_provider_change_hook()
|
||||
|
||||
def _register_provider_change_hook(self) -> None:
|
||||
set_change_callback = getattr(
|
||||
self.context.provider_manager,
|
||||
"set_provider_change_callback",
|
||||
None,
|
||||
)
|
||||
if callable(set_change_callback):
|
||||
set_change_callback(self._on_provider_manager_changed)
|
||||
return
|
||||
register_change_hook = getattr(
|
||||
self.context.provider_manager,
|
||||
"register_provider_change_hook",
|
||||
None,
|
||||
)
|
||||
if callable(register_change_hook):
|
||||
register_change_hook(self._on_provider_manager_changed)
|
||||
|
||||
def invalidate_provider_models_cache(
|
||||
self, provider_id: str | None = None, *, umo: str | None = None
|
||||
) -> None:
|
||||
"""Public hook for cache invalidation on external provider config changes."""
|
||||
self._model_cache.invalidate(provider_id, umo=umo)
|
||||
|
||||
def _on_provider_manager_changed(
|
||||
self,
|
||||
provider_id: str,
|
||||
provider_type: ProviderType,
|
||||
umo: str | None,
|
||||
) -> None:
|
||||
if provider_type == ProviderType.CHAT_COMPLETION:
|
||||
self.invalidate_provider_models_cache(provider_id, umo=umo)
|
||||
|
||||
def _get_provider_settings(self, umo: str | None) -> dict:
|
||||
if not umo:
|
||||
return {}
|
||||
try:
|
||||
return self.context.get_config(umo).get("provider_settings", {}) or {}
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"读取 provider_settings 失败,使用默认值: %s",
|
||||
safe_error("", e),
|
||||
)
|
||||
return {}
|
||||
|
||||
def _get_model_cache_ttl(self, umo: str | None) -> float:
|
||||
settings = self._get_provider_settings(umo)
|
||||
raw = settings.get(
|
||||
MODEL_LIST_CACHE_TTL_KEY,
|
||||
MODEL_LIST_CACHE_TTL_SECONDS_DEFAULT,
|
||||
)
|
||||
try:
|
||||
return max(float(raw), 0.0)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"读取 %s 失败,回退默认值 %r: %s",
|
||||
MODEL_LIST_CACHE_TTL_KEY,
|
||||
MODEL_LIST_CACHE_TTL_SECONDS_DEFAULT,
|
||||
safe_error("", e),
|
||||
)
|
||||
return MODEL_LIST_CACHE_TTL_SECONDS_DEFAULT
|
||||
|
||||
def _get_model_lookup_concurrency(self, umo: str | None) -> int:
|
||||
settings = self._get_provider_settings(umo)
|
||||
raw = settings.get(
|
||||
MODEL_LOOKUP_MAX_CONCURRENCY_KEY,
|
||||
MODEL_LOOKUP_MAX_CONCURRENCY_DEFAULT,
|
||||
)
|
||||
try:
|
||||
value = int(raw)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"读取 %s 失败,回退默认值 %r: %s",
|
||||
MODEL_LOOKUP_MAX_CONCURRENCY_KEY,
|
||||
MODEL_LOOKUP_MAX_CONCURRENCY_DEFAULT,
|
||||
safe_error("", e),
|
||||
)
|
||||
value = MODEL_LOOKUP_MAX_CONCURRENCY_DEFAULT
|
||||
return min(max(value, 1), MODEL_LOOKUP_MAX_CONCURRENCY_UPPER_BOUND)
|
||||
|
||||
def _get_model_lookup_config(self, umo: str | None) -> _ModelLookupConfig:
|
||||
return _ModelLookupConfig(
|
||||
umo=umo,
|
||||
cache_ttl_seconds=self._get_model_cache_ttl(umo),
|
||||
max_concurrency=self._get_model_lookup_concurrency(umo),
|
||||
)
|
||||
|
||||
def _resolve_model_name(
|
||||
self,
|
||||
model_name: str,
|
||||
models: Sequence[str],
|
||||
) -> str | None:
|
||||
"""Resolve model name with precedence:
|
||||
exact > case-insensitive > provider-qualified suffix.
|
||||
"""
|
||||
requested = model_name.strip()
|
||||
if not requested:
|
||||
return None
|
||||
|
||||
requested_norm = requested.casefold()
|
||||
|
||||
# exact / case-insensitive match
|
||||
for candidate in models:
|
||||
if candidate == requested or candidate.casefold() == requested_norm:
|
||||
return candidate
|
||||
|
||||
# provider-qualified suffix match:
|
||||
# e.g. candidate `openai/gpt-4o` should match requested `gpt-4o`.
|
||||
for candidate in models:
|
||||
cand_norm = candidate.casefold()
|
||||
if cand_norm.endswith(f"/{requested_norm}") or cand_norm.endswith(
|
||||
f":{requested_norm}"
|
||||
):
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
def _apply_model(
|
||||
self, prov: Provider, model_name: str, *, umo: str | None = None
|
||||
) -> str:
|
||||
prov.set_model(model_name)
|
||||
self.invalidate_provider_models_cache(prov.meta().id, umo=umo)
|
||||
return f"切换模型成功。当前提供商: [{prov.meta().id}] 当前模型: [{prov.get_model()}]"
|
||||
|
||||
async def _get_provider_models(
|
||||
self,
|
||||
provider: Provider,
|
||||
*,
|
||||
config: _ModelLookupConfig,
|
||||
use_cache: bool = True,
|
||||
) -> list[str]:
|
||||
provider_id = provider.meta().id
|
||||
ttl_seconds = config.cache_ttl_seconds
|
||||
umo = config.umo
|
||||
if use_cache:
|
||||
cached = self._model_cache.get(provider_id, umo, ttl_seconds)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
models = list(await provider.get_models())
|
||||
if use_cache:
|
||||
self._model_cache.set(provider_id, umo, models, ttl_seconds)
|
||||
return models
|
||||
|
||||
async def _get_models_or_reply_error(
|
||||
self,
|
||||
message: AstrMessageEvent,
|
||||
prov: Provider,
|
||||
config: _ModelLookupConfig,
|
||||
*,
|
||||
error_prefix: str,
|
||||
disable_t2i: bool = False,
|
||||
warning_log: str | None = None,
|
||||
) -> list[str] | None:
|
||||
try:
|
||||
return await self._get_provider_models(prov, config=config)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
if warning_log is not None:
|
||||
logger.warning(
|
||||
warning_log,
|
||||
prov.meta().id,
|
||||
safe_error("", e),
|
||||
)
|
||||
result = MessageEventResult().message(safe_error(error_prefix, e))
|
||||
if disable_t2i:
|
||||
result = result.use_t2i(False)
|
||||
message.set_result(result)
|
||||
return None
|
||||
|
||||
def _log_reachability_failure(
|
||||
self,
|
||||
@@ -38,12 +285,96 @@ class ProviderCommands:
|
||||
return True, None, None
|
||||
except Exception as e:
|
||||
err_code = "TEST_FAILED"
|
||||
err_reason = str(e)
|
||||
err_reason = safe_error("", e)
|
||||
self._log_reachability_failure(
|
||||
provider, provider_capability_type, err_code, err_reason
|
||||
)
|
||||
return False, err_code, err_reason
|
||||
|
||||
async def _find_provider_for_model(
|
||||
self,
|
||||
model_name: str,
|
||||
*,
|
||||
exclude_provider_id: str | None = None,
|
||||
config: _ModelLookupConfig,
|
||||
use_cache: bool = True,
|
||||
) -> tuple[Provider | None, str | None]:
|
||||
all_providers = []
|
||||
for provider in self.context.get_all_providers():
|
||||
provider_meta = provider.meta()
|
||||
if provider_meta.provider_type != ProviderType.CHAT_COMPLETION:
|
||||
continue
|
||||
if (
|
||||
exclude_provider_id is not None
|
||||
and provider_meta.id == exclude_provider_id
|
||||
):
|
||||
continue
|
||||
all_providers.append(provider)
|
||||
if not all_providers:
|
||||
return None, None
|
||||
|
||||
semaphore = asyncio.Semaphore(config.max_concurrency)
|
||||
|
||||
async def fetch_models(
|
||||
provider: Provider,
|
||||
) -> tuple[Provider, list[str] | None, str | None]:
|
||||
async with semaphore:
|
||||
try:
|
||||
models = await self._get_provider_models(
|
||||
provider,
|
||||
config=config,
|
||||
use_cache=use_cache,
|
||||
)
|
||||
return provider, models, None
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
err = safe_error("", e)
|
||||
logger.debug(
|
||||
"跨提供商查找模型 %s 获取 %s 模型列表失败: %s",
|
||||
model_name,
|
||||
provider.meta().id,
|
||||
err,
|
||||
)
|
||||
return provider, None, err
|
||||
|
||||
results = await asyncio.gather(
|
||||
*(fetch_models(provider) for provider in all_providers)
|
||||
)
|
||||
failed_provider_errors: list[tuple[str, str]] = []
|
||||
for provider, models, err in results:
|
||||
if err is not None:
|
||||
failed_provider_errors.append((provider.meta().id, err))
|
||||
continue
|
||||
if models is None:
|
||||
continue
|
||||
|
||||
matched_model_name = self._resolve_model_name(model_name, models)
|
||||
if matched_model_name is not None:
|
||||
return provider, matched_model_name
|
||||
|
||||
if failed_provider_errors and len(failed_provider_errors) == len(all_providers):
|
||||
failed_ids = ",".join(
|
||||
provider_id for provider_id, _ in failed_provider_errors
|
||||
)
|
||||
logger.error(
|
||||
"跨提供商查找模型 %s 时,所有 %d 个提供商的 get_models() 均失败: %s。请检查配置或网络",
|
||||
model_name,
|
||||
len(all_providers),
|
||||
failed_ids,
|
||||
)
|
||||
elif failed_provider_errors:
|
||||
logger.debug(
|
||||
"跨提供商查找模型 %s 时有 %d 个提供商获取模型失败: %s",
|
||||
model_name,
|
||||
len(failed_provider_errors),
|
||||
",".join(
|
||||
f"{provider_id}({error})"
|
||||
for provider_id, error in failed_provider_errors
|
||||
),
|
||||
)
|
||||
return None, None
|
||||
|
||||
async def provider(
|
||||
self,
|
||||
event: AstrMessageEvent,
|
||||
@@ -92,13 +423,15 @@ class ProviderCommands:
|
||||
id_ = meta.id
|
||||
error_code = None
|
||||
|
||||
if isinstance(reachable, asyncio.CancelledError):
|
||||
raise reachable
|
||||
if isinstance(reachable, Exception):
|
||||
# 异常情况下兜底处理,避免单个 provider 导致列表失败
|
||||
self._log_reachability_failure(
|
||||
p,
|
||||
None,
|
||||
reachable.__class__.__name__,
|
||||
str(reachable),
|
||||
safe_error("", reachable),
|
||||
)
|
||||
reachable_flag = False
|
||||
error_code = reachable.__class__.__name__
|
||||
@@ -224,6 +557,73 @@ class ProviderCommands:
|
||||
else:
|
||||
event.set_result(MessageEventResult().message("无效的参数。"))
|
||||
|
||||
async def _switch_model_by_name(
|
||||
self, message: AstrMessageEvent, model_name: str, prov: Provider
|
||||
) -> None:
|
||||
model_name = model_name.strip()
|
||||
if not model_name:
|
||||
message.set_result(MessageEventResult().message("模型名不能为空。"))
|
||||
return
|
||||
|
||||
umo = message.unified_msg_origin
|
||||
config = self._get_model_lookup_config(umo)
|
||||
curr_provider_id = prov.meta().id
|
||||
|
||||
models = await self._get_models_or_reply_error(
|
||||
message,
|
||||
prov,
|
||||
config,
|
||||
error_prefix="获取当前提供商模型列表失败: ",
|
||||
warning_log="获取当前提供商 %s 模型列表失败,停止跨提供商查找: %s",
|
||||
)
|
||||
if models is None:
|
||||
return
|
||||
|
||||
matched_model_name = self._resolve_model_name(model_name, models)
|
||||
if matched_model_name is not None:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
self._apply_model(prov, matched_model_name, umo=umo)
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
target_prov, matched_target_model_name = await self._find_provider_for_model(
|
||||
model_name,
|
||||
exclude_provider_id=curr_provider_id,
|
||||
config=config,
|
||||
)
|
||||
|
||||
if target_prov is None or matched_target_model_name is None:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
f"模型 [{model_name}] 未在任何已配置的提供商中找到,或所有提供商模型列表获取失败,请检查配置或网络后重试。",
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
target_id = target_prov.meta().id
|
||||
try:
|
||||
await self.context.provider_manager.set_provider(
|
||||
provider_id=target_id,
|
||||
provider_type=ProviderType.CHAT_COMPLETION,
|
||||
umo=umo,
|
||||
)
|
||||
self._apply_model(target_prov, matched_target_model_name, umo=umo)
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
f"检测到模型 [{matched_target_model_name}] 属于提供商 [{target_id}],已自动切换提供商并设置模型。",
|
||||
),
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
safe_error("跨提供商切换并设置模型失败: ", e)
|
||||
),
|
||||
)
|
||||
|
||||
async def model_ls(
|
||||
self,
|
||||
message: AstrMessageEvent,
|
||||
@@ -236,20 +636,17 @@ class ProviderCommands:
|
||||
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"),
|
||||
)
|
||||
return
|
||||
# 定义正则表达式匹配 API 密钥
|
||||
api_key_pattern = re.compile(r"key=[^&'\" ]+")
|
||||
config = self._get_model_lookup_config(message.unified_msg_origin)
|
||||
|
||||
if idx_or_name is None:
|
||||
models = []
|
||||
try:
|
||||
models = await prov.get_models()
|
||||
except BaseException as e:
|
||||
err_msg = api_key_pattern.sub("key=***", str(e))
|
||||
message.set_result(
|
||||
MessageEventResult()
|
||||
.message("获取模型列表失败: " + err_msg)
|
||||
.use_t2i(False),
|
||||
)
|
||||
models = await self._get_models_or_reply_error(
|
||||
message,
|
||||
prov,
|
||||
config,
|
||||
error_prefix="获取模型列表失败: ",
|
||||
disable_t2i=True,
|
||||
)
|
||||
if models is None:
|
||||
return
|
||||
parts = ["下面列出了此模型提供商可用模型:"]
|
||||
for i, model in enumerate(models, 1):
|
||||
@@ -258,40 +655,43 @@ class ProviderCommands:
|
||||
curr_model = prov.get_model() or "无"
|
||||
parts.append(f"\n当前模型: [{curr_model}]")
|
||||
parts.append(
|
||||
"\nTips: 使用 /model <模型名/编号>,即可实时更换模型。如目标模型不存在于上表,请输入模型名。"
|
||||
"\nTips: 使用 /model <模型名/编号> 切换模型。输入模型名时可自动跨提供商查找并切换;跨提供商也可使用 /provider 切换。"
|
||||
)
|
||||
|
||||
ret = "".join(parts)
|
||||
message.set_result(MessageEventResult().message(ret).use_t2i(False))
|
||||
elif isinstance(idx_or_name, int):
|
||||
models = []
|
||||
try:
|
||||
models = await prov.get_models()
|
||||
except BaseException as e:
|
||||
message.set_result(
|
||||
MessageEventResult().message("获取模型列表失败: " + str(e)),
|
||||
)
|
||||
models = await self._get_models_or_reply_error(
|
||||
message,
|
||||
prov,
|
||||
config,
|
||||
error_prefix="获取模型列表失败: ",
|
||||
)
|
||||
if models is None:
|
||||
return
|
||||
if idx_or_name > len(models) or idx_or_name < 1:
|
||||
message.set_result(MessageEventResult().message("模型序号错误。"))
|
||||
else:
|
||||
try:
|
||||
new_model = models[idx_or_name - 1]
|
||||
prov.set_model(new_model)
|
||||
except BaseException as e:
|
||||
message.set_result(
|
||||
MessageEventResult().message("切换模型未知错误: " + str(e)),
|
||||
MessageEventResult().message(
|
||||
self._apply_model(
|
||||
prov,
|
||||
new_model,
|
||||
umo=message.unified_msg_origin,
|
||||
)
|
||||
),
|
||||
)
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
f"切换模型成功。当前提供商: [{prov.meta().id}] 当前模型: [{prov.get_model()}]",
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
safe_error("切换模型未知错误: ", e)
|
||||
),
|
||||
)
|
||||
return
|
||||
else:
|
||||
prov.set_model(idx_or_name)
|
||||
message.set_result(
|
||||
MessageEventResult().message(f"切换模型到 {prov.get_model()}。"),
|
||||
)
|
||||
await self._switch_model_by_name(message, idx_or_name, prov)
|
||||
|
||||
async def key(self, message: AstrMessageEvent, index: int | None = None) -> None:
|
||||
prov = self.context.get_using_provider(message.unified_msg_origin)
|
||||
@@ -322,8 +722,15 @@ class ProviderCommands:
|
||||
try:
|
||||
new_key = keys_data[index - 1]
|
||||
prov.set_key(new_key)
|
||||
except BaseException as e:
|
||||
message.set_result(
|
||||
MessageEventResult().message(f"切换 Key 未知错误: {e!s}"),
|
||||
self.invalidate_provider_models_cache(
|
||||
prov.meta().id,
|
||||
umo=message.unified_msg_origin,
|
||||
)
|
||||
message.set_result(MessageEventResult().message("切换 Key 成功。"))
|
||||
message.set_result(MessageEventResult().message("切换 Key 成功。"))
|
||||
except Exception as e:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
safe_error("切换 Key 未知错误: ", e)
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
@@ -8,7 +8,7 @@ from bs4 import BeautifulSoup
|
||||
from readability import Document
|
||||
|
||||
from astrbot.api import AstrBotConfig, llm_tool, logger, sp, star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult, filter
|
||||
from astrbot.api.event import AstrMessageEvent, filter
|
||||
from astrbot.api.provider import ProviderRequest
|
||||
from astrbot.core.provider.func_tool_manager import FunctionToolManager
|
||||
|
||||
@@ -196,15 +196,6 @@ class Main(star.Star):
|
||||
)
|
||||
return results
|
||||
|
||||
@filter.command("websearch")
|
||||
async def websearch(self, event: AstrMessageEvent, oper: str | None = None) -> None:
|
||||
"""网页搜索指令(已废弃)"""
|
||||
event.set_result(
|
||||
MessageEventResult().message(
|
||||
"此指令已经被废弃,请在 WebUI 中开启或关闭网页搜索功能。",
|
||||
),
|
||||
)
|
||||
|
||||
@llm_tool(name="web_search")
|
||||
async def search_from_search_engine(
|
||||
self,
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
__version__ = "4.18.3"
|
||||
from importlib import metadata
|
||||
|
||||
try:
|
||||
__version__ = metadata.version("AstrBot")
|
||||
except metadata.PackageNotFoundError:
|
||||
__version__ = "unknown"
|
||||
|
||||
+10
-8
@@ -1,11 +1,11 @@
|
||||
"""AstrBot CLI入口"""
|
||||
"""AstrBot CLI entry point"""
|
||||
|
||||
import sys
|
||||
|
||||
import click
|
||||
|
||||
from . import __version__
|
||||
from .commands import conf, init, plug, run
|
||||
from .commands import bk, conf, init, plug, run, uninstall
|
||||
|
||||
logo_tmpl = r"""
|
||||
___ _______.___________..______ .______ ______ .___________.
|
||||
@@ -29,23 +29,23 @@ def cli() -> None:
|
||||
@click.command()
|
||||
@click.argument("command_name", required=False, type=str)
|
||||
def help(command_name: str | None) -> None:
|
||||
"""显示命令的帮助信息
|
||||
"""Display help information for commands
|
||||
|
||||
如果提供了 COMMAND_NAME,则显示该命令的详细帮助信息。
|
||||
否则,显示通用帮助信息。
|
||||
If COMMAND_NAME is provided, display detailed help for that command.
|
||||
Otherwise, display general help information.
|
||||
"""
|
||||
ctx = click.get_current_context()
|
||||
if command_name:
|
||||
# 查找指定命令
|
||||
# Find the specified command
|
||||
command = cli.get_command(ctx, command_name)
|
||||
if command:
|
||||
# 显示特定命令的帮助信息
|
||||
# Display help for the specific command
|
||||
click.echo(command.get_help(ctx))
|
||||
else:
|
||||
click.echo(f"Unknown command: {command_name}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
# 显示通用帮助信息
|
||||
# Display general help information
|
||||
click.echo(cli.get_help(ctx))
|
||||
|
||||
|
||||
@@ -54,6 +54,8 @@ cli.add_command(run)
|
||||
cli.add_command(help)
|
||||
cli.add_command(plug)
|
||||
cli.add_command(conf)
|
||||
cli.add_command(uninstall)
|
||||
cli.add_command(bk)
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from .cmd_bk import bk
|
||||
from .cmd_conf import conf
|
||||
from .cmd_init import init
|
||||
from .cmd_plug import plug
|
||||
from .cmd_run import run
|
||||
from .cmd_uninstall import uninstall
|
||||
|
||||
__all__ = ["conf", "init", "plug", "run"]
|
||||
__all__ = ["conf", "init", "plug", "run", "uninstall", "bk"]
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from astrbot.core import astrbot_config, db_helper
|
||||
from astrbot.core.backup import AstrBotExporter, AstrBotImporter
|
||||
|
||||
# Try importing KnowledgeBaseManager to support KB backup
|
||||
try:
|
||||
from astrbot.core.knowledge.kb_manager import KnowledgeBaseManager
|
||||
except ImportError:
|
||||
try:
|
||||
from astrbot.core.knowledge_base.kb_manager import KnowledgeBaseManager
|
||||
except ImportError:
|
||||
KnowledgeBaseManager = None
|
||||
|
||||
|
||||
async def _get_kb_manager():
|
||||
if KnowledgeBaseManager is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Best effort initialization
|
||||
kb_mgr = KnowledgeBaseManager(astrbot_config, db_helper)
|
||||
# If there are async load methods, we might need to call them
|
||||
if hasattr(kb_mgr, "load_kbs_from_db"):
|
||||
await kb_mgr.load_kbs_from_db()
|
||||
elif hasattr(kb_mgr, "load_all"):
|
||||
await kb_mgr.load_all()
|
||||
return kb_mgr
|
||||
except Exception:
|
||||
# If KB manager fails to load (e.g. missing dependencies), return None
|
||||
# so we can still backup other data
|
||||
return None
|
||||
|
||||
|
||||
@click.group(name="bk")
|
||||
def bk():
|
||||
"""Backup management (Export/Import)"""
|
||||
pass
|
||||
|
||||
|
||||
@bk.command(name="export")
|
||||
@click.option("--output", "-o", help="Output directory", default=None)
|
||||
@click.option(
|
||||
"--gpg-sign", "-S", is_flag=True, help="Sign backup with GPG default private key"
|
||||
)
|
||||
@click.option(
|
||||
"--gpg-encrypt",
|
||||
"-E",
|
||||
help="Encrypt for GPG recipient (Asymmetric)",
|
||||
metavar="RECIPIENT",
|
||||
)
|
||||
@click.option(
|
||||
"--gpg-symmetric", "-C", is_flag=True, help="Encrypt with symmetric cipher (GPG)"
|
||||
)
|
||||
@click.option(
|
||||
"--digest",
|
||||
"-d",
|
||||
type=click.Choice(["md5", "sha1", "sha256", "sha512"]),
|
||||
help="Generate digital digest",
|
||||
)
|
||||
def export_data(
|
||||
output: str | None,
|
||||
gpg_sign: bool,
|
||||
gpg_encrypt: str | None,
|
||||
gpg_symmetric: bool,
|
||||
digest: str | None,
|
||||
):
|
||||
"""Export all AstrBot data to a backup archive.
|
||||
|
||||
If any GPG option (-S, -E, -C) is used, the output file will be processed by GPG
|
||||
and saved with a .gpg extension.
|
||||
|
||||
Examples:
|
||||
|
||||
\b
|
||||
1. Standard Export:
|
||||
astrbot bk export
|
||||
-> Generates a plain .zip file.
|
||||
|
||||
\b
|
||||
2. Signed Backup (Integrity Check):
|
||||
astrbot bk export -S
|
||||
-> Generates a .zip.gpg file containing the backup and your signature.
|
||||
-> NOT ENCRYPTED, but packaged in OpenPGP format.
|
||||
-> Use 'astrbot bk import' or 'gpg --verify' to check integrity.
|
||||
|
||||
\b
|
||||
3. Password Protected (Symmetric Encryption):
|
||||
astrbot bk export -C
|
||||
-> Generates an encrypted .zip.gpg file.
|
||||
-> Prompts for a passphrase.
|
||||
-> Only accessible with the passphrase.
|
||||
|
||||
\b
|
||||
4. Encrypted for Recipient (Asymmetric Encryption):
|
||||
astrbot bk export -E "alice@example.com"
|
||||
-> Generates an encrypted .zip.gpg file for Alice.
|
||||
-> Only Alice's private key can decrypt it.
|
||||
|
||||
\b
|
||||
5. Signed and Encrypted with Digest:
|
||||
astrbot bk export -S -E "bob@example.com" -d sha256
|
||||
-> Signs, encrypts for Bob, and generates a SHA256 checksum file.
|
||||
"""
|
||||
|
||||
# Handle case where -E consumes the next flag (e.g. -E -S)
|
||||
if gpg_encrypt and gpg_encrypt.startswith("-"):
|
||||
consumed_flag = gpg_encrypt
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Warning: Flag '{consumed_flag}' was interpreted as the recipient for -E.",
|
||||
fg="yellow",
|
||||
)
|
||||
)
|
||||
|
||||
# Recover flags
|
||||
if consumed_flag == "-S":
|
||||
gpg_sign = True
|
||||
click.echo("Recovered flag -S (Sign).")
|
||||
elif consumed_flag == "-C":
|
||||
gpg_symmetric = True
|
||||
click.echo("Recovered flag -C (Symmetric).")
|
||||
|
||||
# Prompt for the actual recipient
|
||||
gpg_encrypt = click.prompt("Please enter the GPG recipient (email or key ID)")
|
||||
|
||||
async def _run():
|
||||
if gpg_sign or gpg_encrypt or gpg_symmetric:
|
||||
if not shutil.which("gpg"):
|
||||
raise click.ClickException(
|
||||
"GPG tool not found. Please install GnuPG to use encryption/signing features."
|
||||
)
|
||||
|
||||
kb_mgr = await _get_kb_manager()
|
||||
exporter = AstrBotExporter(db_helper, kb_mgr)
|
||||
|
||||
async def on_progress(stage, current, total, message):
|
||||
click.echo(f"[{stage}] {message}")
|
||||
|
||||
try:
|
||||
path_str = await exporter.export_all(output, progress_callback=on_progress)
|
||||
final_path = Path(path_str)
|
||||
click.echo(
|
||||
click.style(f"\nRaw backup exported to: {final_path}", fg="green")
|
||||
)
|
||||
|
||||
# GPG Operations
|
||||
if gpg_sign or gpg_encrypt or gpg_symmetric:
|
||||
# Construct GPG command
|
||||
# output file usually ends with .gpg
|
||||
gpg_output = final_path.with_name(final_path.name + ".gpg")
|
||||
cmd = ["gpg", "--output", str(gpg_output), "--yes"]
|
||||
|
||||
if gpg_symmetric:
|
||||
if gpg_encrypt:
|
||||
click.echo(
|
||||
click.style(
|
||||
"Warning: Symmetric encryption selected, ignoring asymmetric recipient.",
|
||||
fg="yellow",
|
||||
)
|
||||
)
|
||||
cmd.append("--symmetric")
|
||||
# No --batch to allow interactive passphrase entry on TTY
|
||||
else:
|
||||
# Asymmetric or just Sign
|
||||
# Note: If encrypting, -s adds signature to the encrypted packet.
|
||||
if gpg_encrypt:
|
||||
cmd.extend(["--encrypt", "--recipient", gpg_encrypt])
|
||||
|
||||
if gpg_sign:
|
||||
cmd.append("--sign")
|
||||
|
||||
cmd.append(str(final_path))
|
||||
|
||||
click.echo(f"Running GPG: {' '.join(cmd)}")
|
||||
|
||||
# Replace subprocess.run with asyncio.create_subprocess_exec to avoid blocking the event loop
|
||||
process = await asyncio.create_subprocess_exec(*cmd)
|
||||
await process.wait()
|
||||
|
||||
if process.returncode != 0:
|
||||
raise subprocess.CalledProcessError(process.returncode or 1, cmd)
|
||||
|
||||
# Clean up original file
|
||||
final_path.unlink()
|
||||
final_path = gpg_output
|
||||
click.echo(
|
||||
click.style(f"Processed backup created: {final_path}", fg="green")
|
||||
)
|
||||
|
||||
# Digest Generation
|
||||
if digest:
|
||||
click.echo(f"Calculating {digest} digest...")
|
||||
hash_func = getattr(hashlib, digest)()
|
||||
# Read file in chunks
|
||||
with open(final_path, "rb") as f:
|
||||
while chunk := f.read(8192):
|
||||
hash_func.update(chunk)
|
||||
|
||||
digest_val = hash_func.hexdigest()
|
||||
digest_file = final_path.with_name(final_path.name + f".{digest}")
|
||||
digest_file.write_text(
|
||||
f"{digest_val} *{final_path.name}\n", encoding="utf-8"
|
||||
)
|
||||
click.echo(click.style(f"Digest generated: {digest_file}", fg="green"))
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
click.echo(click.style(f"\nGPG process failed: {e}", fg="red"), err=True)
|
||||
except Exception as e:
|
||||
click.echo(click.style(f"\nExport failed: {e}", fg="red"), err=True)
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
@bk.command(name="import")
|
||||
@click.argument("backup_file")
|
||||
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompts")
|
||||
def import_data_command(backup_file: str, yes: bool):
|
||||
"""Import AstrBot data from a backup archive.
|
||||
|
||||
Automatically handles .zip files and .gpg files (signed or encrypted).
|
||||
If the file is encrypted, you will be prompted for the passphrase.
|
||||
If a digest file (.sha256, .md5, etc.) exists, it will be verified automatically.
|
||||
"""
|
||||
backup_path = Path(backup_file)
|
||||
if not backup_path.exists():
|
||||
raise click.ClickException(f"Backup file not found: {backup_file}")
|
||||
|
||||
# 1. Verify Digest if exists
|
||||
def _verify_digest(file_path: Path) -> bool:
|
||||
supported_digests = ["sha256", "sha512", "md5", "sha1"]
|
||||
digest_verified = True # Default true if no digest file found
|
||||
|
||||
for algo in supported_digests:
|
||||
digest_file = file_path.with_name(f"{file_path.name}.{algo}")
|
||||
if digest_file.exists():
|
||||
click.echo(f"Found digest file: {digest_file.name}")
|
||||
try:
|
||||
# Parse digest file
|
||||
content = digest_file.read_text(encoding="utf-8").strip()
|
||||
# Format: "digest *filename" or "digest filename"
|
||||
# We expect the hash to be the first part
|
||||
if " " in content:
|
||||
expected_digest = content.split()[0].lower()
|
||||
else:
|
||||
expected_digest = content.lower()
|
||||
|
||||
click.echo(f"Verifying {algo} digest...")
|
||||
hash_func = getattr(hashlib, algo)()
|
||||
with open(file_path, "rb") as f:
|
||||
while chunk := f.read(8192):
|
||||
hash_func.update(chunk)
|
||||
|
||||
calculated_digest = hash_func.hexdigest().lower()
|
||||
|
||||
if calculated_digest == expected_digest:
|
||||
click.echo(
|
||||
click.style("Digest verification PASSED.", fg="green")
|
||||
)
|
||||
else:
|
||||
click.echo(
|
||||
click.style(
|
||||
"Digest verification FAILED!", fg="red", bold=True
|
||||
)
|
||||
)
|
||||
click.echo(f" Expected: {expected_digest}")
|
||||
click.echo(f" Actual: {calculated_digest}")
|
||||
digest_verified = False
|
||||
except Exception as e:
|
||||
click.echo(click.style(f"Error checking digest: {e}", fg="red"))
|
||||
digest_verified = False
|
||||
|
||||
return digest_verified
|
||||
|
||||
if not _verify_digest(backup_path):
|
||||
if not yes:
|
||||
if not click.confirm(
|
||||
"Digest verification failed. Abort import?", default=True, abort=True
|
||||
):
|
||||
pass
|
||||
else:
|
||||
click.echo(
|
||||
click.style(
|
||||
"Warning: Digest verification failed. Continuing due to --yes.",
|
||||
fg="yellow",
|
||||
)
|
||||
)
|
||||
|
||||
if not yes:
|
||||
click.confirm(
|
||||
"This will OVERWRITE all current data (DB, Config, Plugins). Continue?",
|
||||
abort=True,
|
||||
default=False,
|
||||
)
|
||||
|
||||
async def _run():
|
||||
zip_path = backup_path
|
||||
is_temp_file = False
|
||||
|
||||
# Handle GPG encrypted files
|
||||
if backup_path.suffix == ".gpg":
|
||||
if not shutil.which("gpg"):
|
||||
raise click.ClickException(
|
||||
"GPG tool not found. Cannot decrypt .gpg file."
|
||||
)
|
||||
|
||||
# Remove .gpg extension for output
|
||||
decrypted_path = backup_path.with_suffix("")
|
||||
# If it doesn't look like a zip after stripping .gpg, maybe append .zip?
|
||||
# But the exporter creates .zip.gpg, so stripping .gpg gives .zip.
|
||||
|
||||
click.echo(f"Processing GPG file {backup_path}...")
|
||||
try:
|
||||
cmd = [
|
||||
"gpg",
|
||||
"--output",
|
||||
str(decrypted_path),
|
||||
"--decrypt", # This handles both decryption and signature verification/extraction
|
||||
str(backup_path),
|
||||
]
|
||||
# Allow interactive passphrase
|
||||
process = await asyncio.create_subprocess_exec(*cmd)
|
||||
await process.wait()
|
||||
|
||||
if process.returncode != 0:
|
||||
raise subprocess.CalledProcessError(process.returncode or 1, cmd)
|
||||
|
||||
zip_path = decrypted_path
|
||||
is_temp_file = True
|
||||
except subprocess.CalledProcessError:
|
||||
click.echo(
|
||||
click.style(
|
||||
"GPG processing failed. Verify signature or decryption key.",
|
||||
fg="red",
|
||||
),
|
||||
err=True,
|
||||
)
|
||||
return
|
||||
|
||||
kb_mgr = await _get_kb_manager()
|
||||
importer = AstrBotImporter(db_helper, kb_mgr)
|
||||
|
||||
async def on_progress(stage, current, total, message):
|
||||
click.echo(f"[{stage}] {message}")
|
||||
|
||||
try:
|
||||
result = await importer.import_all(
|
||||
str(zip_path), progress_callback=on_progress
|
||||
)
|
||||
|
||||
if result.errors:
|
||||
click.echo(
|
||||
click.style("\nImport failed with errors:", fg="red"), err=True
|
||||
)
|
||||
for err in result.errors:
|
||||
click.echo(f" - {err}", err=True)
|
||||
else:
|
||||
click.echo(click.style("\nImport completed successfully!", fg="green"))
|
||||
|
||||
if result.warnings:
|
||||
click.echo(click.style("\nWarnings:", fg="yellow"))
|
||||
for warn in result.warnings:
|
||||
click.echo(f" - {warn}")
|
||||
|
||||
finally:
|
||||
if is_temp_file and zip_path.exists():
|
||||
zip_path.unlink()
|
||||
click.echo(f"Cleaned up temporary file: {zip_path}")
|
||||
|
||||
asyncio.run(_run())
|
||||
@@ -6,61 +6,67 @@ from typing import Any
|
||||
|
||||
import click
|
||||
|
||||
from ..utils import check_astrbot_root, get_astrbot_root
|
||||
from astrbot.core.utils.astrbot_path import astrbot_paths
|
||||
|
||||
from ..utils import check_astrbot_root
|
||||
|
||||
|
||||
def _validate_log_level(value: str) -> str:
|
||||
"""验证日志级别"""
|
||||
"""Validate log level"""
|
||||
value = value.upper()
|
||||
if value not in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
|
||||
raise click.ClickException(
|
||||
"日志级别必须是 DEBUG/INFO/WARNING/ERROR/CRITICAL 之一",
|
||||
"Log level must be one of DEBUG/INFO/WARNING/ERROR/CRITICAL",
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _validate_dashboard_port(value: str) -> int:
|
||||
"""验证 Dashboard 端口"""
|
||||
"""Validate Dashboard port"""
|
||||
try:
|
||||
port = int(value)
|
||||
if port < 1 or port > 65535:
|
||||
raise click.ClickException("端口必须在 1-65535 范围内")
|
||||
raise click.ClickException("Port must be in range 1-65535")
|
||||
return port
|
||||
except ValueError:
|
||||
raise click.ClickException("端口必须是数字")
|
||||
raise click.ClickException("Port must be a number")
|
||||
|
||||
|
||||
def _validate_dashboard_username(value: str) -> str:
|
||||
"""验证 Dashboard 用户名"""
|
||||
"""Validate Dashboard username"""
|
||||
if not value:
|
||||
raise click.ClickException("用户名不能为空")
|
||||
raise click.ClickException("Username cannot be empty")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_dashboard_password(value: str) -> str:
|
||||
"""验证 Dashboard 密码"""
|
||||
"""Validate Dashboard password"""
|
||||
if not value:
|
||||
raise click.ClickException("密码不能为空")
|
||||
raise click.ClickException("Password cannot be empty")
|
||||
return hashlib.md5(value.encode()).hexdigest()
|
||||
|
||||
|
||||
def _validate_timezone(value: str) -> str:
|
||||
"""验证时区"""
|
||||
"""Validate timezone"""
|
||||
try:
|
||||
zoneinfo.ZoneInfo(value)
|
||||
except Exception:
|
||||
raise click.ClickException(f"无效的时区: {value},请使用有效的IANA时区名称")
|
||||
raise click.ClickException(
|
||||
f"Invalid timezone: {value}. Please use a valid IANA timezone name"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _validate_callback_api_base(value: str) -> str:
|
||||
"""验证回调接口基址"""
|
||||
"""Validate callback API base URL"""
|
||||
if not value.startswith("http://") and not value.startswith("https://"):
|
||||
raise click.ClickException("回调接口基址必须以 http:// 或 https:// 开头")
|
||||
raise click.ClickException(
|
||||
"Callback API base must start with http:// or https://"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
# 可通过CLI设置的配置项,配置键到验证器函数的映射
|
||||
# Configuration items settable via CLI, mapping config keys to validator functions
|
||||
CONFIG_VALIDATORS: dict[str, Callable[[str], Any]] = {
|
||||
"timezone": _validate_timezone,
|
||||
"log_level": _validate_log_level,
|
||||
@@ -72,14 +78,14 @@ CONFIG_VALIDATORS: dict[str, Callable[[str], Any]] = {
|
||||
|
||||
|
||||
def _load_config() -> dict[str, Any]:
|
||||
"""加载或初始化配置文件"""
|
||||
root = get_astrbot_root()
|
||||
"""Load or initialize config file"""
|
||||
root = astrbot_paths.root
|
||||
if not check_astrbot_root(root):
|
||||
raise click.ClickException(
|
||||
f"{root}不是有效的 AstrBot 根目录,如需初始化请使用 astrbot init",
|
||||
f"{root} is not a valid AstrBot root directory. Use 'astrbot init' to initialize",
|
||||
)
|
||||
|
||||
config_path = root / "data" / "cmd_config.json"
|
||||
config_path = astrbot_paths.data / "cmd_config.json"
|
||||
if not config_path.exists():
|
||||
from astrbot.core.config.default import DEFAULT_CONFIG
|
||||
|
||||
@@ -91,12 +97,12 @@ def _load_config() -> dict[str, Any]:
|
||||
try:
|
||||
return json.loads(config_path.read_text(encoding="utf-8-sig"))
|
||||
except json.JSONDecodeError as e:
|
||||
raise click.ClickException(f"配置文件解析失败: {e!s}")
|
||||
raise click.ClickException(f"Failed to parse config file: {e!s}")
|
||||
|
||||
|
||||
def _save_config(config: dict[str, Any]) -> None:
|
||||
"""保存配置文件"""
|
||||
config_path = get_astrbot_root() / "data" / "cmd_config.json"
|
||||
"""Save config file"""
|
||||
config_path = astrbot_paths.data / "cmd_config.json"
|
||||
|
||||
config_path.write_text(
|
||||
json.dumps(config, ensure_ascii=False, indent=2),
|
||||
@@ -105,21 +111,21 @@ def _save_config(config: dict[str, Any]) -> None:
|
||||
|
||||
|
||||
def _set_nested_item(obj: dict[str, Any], path: str, value: Any) -> None:
|
||||
"""设置嵌套字典中的值"""
|
||||
"""Set a value in a nested dictionary"""
|
||||
parts = path.split(".")
|
||||
for part in parts[:-1]:
|
||||
if part not in obj:
|
||||
obj[part] = {}
|
||||
elif not isinstance(obj[part], dict):
|
||||
raise click.ClickException(
|
||||
f"配置路径冲突: {'.'.join(parts[: parts.index(part) + 1])} 不是字典",
|
||||
f"Config path conflict: {'.'.join(parts[: parts.index(part) + 1])} is not a dict",
|
||||
)
|
||||
obj = obj[part]
|
||||
obj[parts[-1]] = value
|
||||
|
||||
|
||||
def _get_nested_item(obj: dict[str, Any], path: str) -> Any:
|
||||
"""获取嵌套字典中的值"""
|
||||
"""Get a value from a nested dictionary"""
|
||||
parts = path.split(".")
|
||||
for part in parts:
|
||||
obj = obj[part]
|
||||
@@ -128,21 +134,21 @@ def _get_nested_item(obj: dict[str, Any], path: str) -> Any:
|
||||
|
||||
@click.group(name="conf")
|
||||
def conf() -> None:
|
||||
"""配置管理命令
|
||||
"""Configuration management commands
|
||||
|
||||
支持的配置项:
|
||||
Supported config keys:
|
||||
|
||||
- timezone: 时区设置 (例如: Asia/Shanghai)
|
||||
- timezone: Timezone setting (e.g. Asia/Shanghai)
|
||||
|
||||
- log_level: 日志级别 (DEBUG/INFO/WARNING/ERROR/CRITICAL)
|
||||
- log_level: Log level (DEBUG/INFO/WARNING/ERROR/CRITICAL)
|
||||
|
||||
- dashboard.port: Dashboard 端口
|
||||
- dashboard.port: Dashboard port
|
||||
|
||||
- dashboard.username: Dashboard 用户名
|
||||
- dashboard.username: Dashboard username
|
||||
|
||||
- dashboard.password: Dashboard 密码
|
||||
- dashboard.password: Dashboard password
|
||||
|
||||
- callback_api_base: 回调接口基址
|
||||
- callback_api_base: Callback API base URL
|
||||
"""
|
||||
|
||||
|
||||
@@ -150,9 +156,9 @@ def conf() -> None:
|
||||
@click.argument("key")
|
||||
@click.argument("value")
|
||||
def set_config(key: str, value: str) -> None:
|
||||
"""设置配置项的值"""
|
||||
"""Set the value of a config item"""
|
||||
if key not in CONFIG_VALIDATORS:
|
||||
raise click.ClickException(f"不支持的配置项: {key}")
|
||||
raise click.ClickException(f"Unsupported config key: {key}")
|
||||
|
||||
config = _load_config()
|
||||
|
||||
@@ -162,29 +168,29 @@ def set_config(key: str, value: str) -> None:
|
||||
_set_nested_item(config, key, validated_value)
|
||||
_save_config(config)
|
||||
|
||||
click.echo(f"配置已更新: {key}")
|
||||
click.echo(f"Config updated: {key}")
|
||||
if key == "dashboard.password":
|
||||
click.echo(" 原值: ********")
|
||||
click.echo(" 新值: ********")
|
||||
click.echo(" Old value: ********")
|
||||
click.echo(" New value: ********")
|
||||
else:
|
||||
click.echo(f" 原值: {old_value}")
|
||||
click.echo(f" 新值: {validated_value}")
|
||||
click.echo(f" Old value: {old_value}")
|
||||
click.echo(f" New value: {validated_value}")
|
||||
|
||||
except KeyError:
|
||||
raise click.ClickException(f"未知的配置项: {key}")
|
||||
raise click.ClickException(f"Unknown config key: {key}")
|
||||
except Exception as e:
|
||||
raise click.UsageError(f"设置配置失败: {e!s}")
|
||||
raise click.UsageError(f"Failed to set config: {e!s}")
|
||||
|
||||
|
||||
@conf.command(name="get")
|
||||
@click.argument("key", required=False)
|
||||
def get_config(key: str | None = None) -> None:
|
||||
"""获取配置项的值,不提供key则显示所有可配置项"""
|
||||
"""Get the value of a config item. If no key is provided, show all configurable items"""
|
||||
config = _load_config()
|
||||
|
||||
if key:
|
||||
if key not in CONFIG_VALIDATORS:
|
||||
raise click.ClickException(f"不支持的配置项: {key}")
|
||||
raise click.ClickException(f"Unsupported config key: {key}")
|
||||
|
||||
try:
|
||||
value = _get_nested_item(config, key)
|
||||
@@ -192,11 +198,11 @@ def get_config(key: str | None = None) -> None:
|
||||
value = "********"
|
||||
click.echo(f"{key}: {value}")
|
||||
except KeyError:
|
||||
raise click.ClickException(f"未知的配置项: {key}")
|
||||
raise click.ClickException(f"Unknown config key: {key}")
|
||||
except Exception as e:
|
||||
raise click.UsageError(f"获取配置失败: {e!s}")
|
||||
raise click.UsageError(f"Failed to get config: {e!s}")
|
||||
else:
|
||||
click.echo("当前配置:")
|
||||
click.echo("Current config:")
|
||||
for key in CONFIG_VALIDATORS:
|
||||
try:
|
||||
value = (
|
||||
|
||||
@@ -1,23 +1,47 @@
|
||||
import asyncio
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from filelock import FileLock, Timeout
|
||||
|
||||
from ..utils import check_dashboard, get_astrbot_root
|
||||
from astrbot.core.utils.astrbot_path import astrbot_paths
|
||||
|
||||
from ..utils import check_dashboard
|
||||
|
||||
SYSTEMD_SERVICE = r"""
|
||||
# user service
|
||||
[Unit]
|
||||
Description=AstrBot Service
|
||||
Documentation=https://github.com/AstrBotDevs/AstrBot
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=%h/.local/share/astrbot
|
||||
ExecStart=/usr/bin/sh -c '/usr/bin/astrbot run || { /usr/bin/astrbot init && /usr/bin/astrbot run; }'
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=astrbot-%u
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
"""
|
||||
|
||||
|
||||
async def initialize_astrbot(astrbot_root: Path) -> None:
|
||||
"""执行 AstrBot 初始化逻辑"""
|
||||
async def initialize_astrbot(astrbot_root: Path, *, yes: bool) -> None:
|
||||
"""Execute AstrBot initialization logic"""
|
||||
dot_astrbot = astrbot_root / ".astrbot"
|
||||
|
||||
if not dot_astrbot.exists():
|
||||
click.echo(f"Current Directory: {astrbot_root}")
|
||||
click.echo(
|
||||
"如果你确认这是 Astrbot root directory, 你需要在当前目录下创建一个 .astrbot 文件标记该目录为 AstrBot 的数据目录。",
|
||||
)
|
||||
if click.confirm(
|
||||
f"请检查当前目录是否正确,确认正确请回车: {astrbot_root}",
|
||||
if yes or click.confirm(
|
||||
f"Install AstrBot to this directory? {astrbot_root}",
|
||||
default=True,
|
||||
abort=True,
|
||||
):
|
||||
@@ -33,24 +57,60 @@ async def initialize_astrbot(astrbot_root: Path) -> None:
|
||||
|
||||
for name, path in paths.items():
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
click.echo(f"{'Created' if not path.exists() else 'Directory exists'}: {path}")
|
||||
|
||||
await check_dashboard(astrbot_root / "data")
|
||||
click.echo(
|
||||
f"{'Created' if not path.exists() else f'{name} Directory exists'}: {path}"
|
||||
)
|
||||
if yes or click.confirm(
|
||||
"是否需要集成式 WebUI?(个人电脑推荐,服务器不推荐)",
|
||||
default=True,
|
||||
):
|
||||
await check_dashboard(astrbot_root)
|
||||
else:
|
||||
click.echo("你可以使用在线面版(v4.14.4+),填写后端地址的方式来控制。")
|
||||
|
||||
|
||||
@click.command()
|
||||
def init() -> None:
|
||||
"""初始化 AstrBot"""
|
||||
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompts")
|
||||
def init(yes: bool) -> None:
|
||||
"""Initialize AstrBot"""
|
||||
click.echo("Initializing AstrBot...")
|
||||
astrbot_root = get_astrbot_root()
|
||||
|
||||
# 检查当前系统是否为 Linux 且存在 systemd
|
||||
if platform.system() == "Linux" and shutil.which("systemctl"):
|
||||
if yes or click.confirm(
|
||||
"Detected Linux with systemd. Install AstrBot user service?", default=True
|
||||
):
|
||||
user_config_dir = Path.home() / ".config" / "systemd" / "user"
|
||||
user_config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
service_path = user_config_dir / "astrbot.service"
|
||||
|
||||
service_path.write_text(SYSTEMD_SERVICE)
|
||||
click.echo(f"Created service file at {service_path}")
|
||||
|
||||
try:
|
||||
subprocess.run(["systemctl", "--user", "daemon-reload"], check=True)
|
||||
click.echo("Systemd daemon reloaded.")
|
||||
click.echo("Management commands:")
|
||||
click.echo(" Start: systemctl --user start astrbot")
|
||||
click.echo(" Stop: systemctl --user stop astrbot")
|
||||
click.echo(" Enable: systemctl --user enable astrbot")
|
||||
click.echo(" Log: journalctl --user -u astrbot -f")
|
||||
except subprocess.CalledProcessError as e:
|
||||
click.echo(f"Failed to reload systemd daemon: {e}", err=True)
|
||||
|
||||
astrbot_root = astrbot_paths.root
|
||||
lock_file = astrbot_root / "astrbot.lock"
|
||||
lock = FileLock(lock_file, timeout=5)
|
||||
|
||||
try:
|
||||
with lock.acquire():
|
||||
asyncio.run(initialize_astrbot(astrbot_root))
|
||||
asyncio.run(initialize_astrbot(astrbot_root, yes=yes))
|
||||
click.echo("Done! You can now run 'astrbot run' to start AstrBot")
|
||||
except Timeout:
|
||||
raise click.ClickException("无法获取锁文件,请检查是否有其他实例正在运行")
|
||||
raise click.ClickException(
|
||||
"Cannot acquire lock file. Please check if another instance is running"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise click.ClickException(f"初始化失败: {e!s}")
|
||||
raise click.ClickException(f"Initialization failed: {e!s}")
|
||||
|
||||
@@ -4,11 +4,12 @@ from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from astrbot.core.utils.astrbot_path import astrbot_paths
|
||||
|
||||
from ..utils import (
|
||||
PluginStatus,
|
||||
build_plug_list,
|
||||
check_astrbot_root,
|
||||
get_astrbot_root,
|
||||
get_git_repo,
|
||||
manage_plugin,
|
||||
)
|
||||
@@ -16,23 +17,25 @@ from ..utils import (
|
||||
|
||||
@click.group()
|
||||
def plug() -> None:
|
||||
"""插件管理"""
|
||||
"""Plugin management"""
|
||||
|
||||
|
||||
def _get_data_path() -> Path:
|
||||
base = get_astrbot_root()
|
||||
base = astrbot_paths.root
|
||||
if not check_astrbot_root(base):
|
||||
raise click.ClickException(
|
||||
f"{base}不是有效的 AstrBot 根目录,如需初始化请使用 astrbot init",
|
||||
f"{base} is not a valid AstrBot root directory. Use 'astrbot init' to initialize",
|
||||
)
|
||||
return (base / "data").resolve()
|
||||
return astrbot_paths.data.resolve()
|
||||
|
||||
|
||||
def display_plugins(plugins, title=None, color=None) -> None:
|
||||
if title:
|
||||
click.echo(click.style(title, fg=color, bold=True))
|
||||
|
||||
click.echo(f"{'名称':<20} {'版本':<10} {'状态':<10} {'作者':<15} {'描述':<30}")
|
||||
click.echo(
|
||||
f"{'Name':<20} {'Version':<10} {'Status':<10} {'Author':<15} {'Description':<30}"
|
||||
)
|
||||
click.echo("-" * 85)
|
||||
|
||||
for p in plugins:
|
||||
@@ -46,30 +49,30 @@ def display_plugins(plugins, title=None, color=None) -> None:
|
||||
@plug.command()
|
||||
@click.argument("name")
|
||||
def new(name: str) -> None:
|
||||
"""创建新插件"""
|
||||
"""Create a new plugin"""
|
||||
base_path = _get_data_path()
|
||||
plug_path = base_path / "plugins" / name
|
||||
|
||||
if plug_path.exists():
|
||||
raise click.ClickException(f"插件 {name} 已存在")
|
||||
raise click.ClickException(f"Plugin {name} already exists")
|
||||
|
||||
author = click.prompt("请输入插件作者", type=str)
|
||||
desc = click.prompt("请输入插件描述", type=str)
|
||||
version = click.prompt("请输入插件版本", type=str)
|
||||
author = click.prompt("Enter plugin author", type=str)
|
||||
desc = click.prompt("Enter plugin description", type=str)
|
||||
version = click.prompt("Enter plugin version", type=str)
|
||||
if not re.match(r"^\d+\.\d+(\.\d+)?$", version.lower().lstrip("v")):
|
||||
raise click.ClickException("版本号必须为 x.y 或 x.y.z 格式")
|
||||
repo = click.prompt("请输入插件仓库:", type=str)
|
||||
raise click.ClickException("Version must be in x.y or x.y.z format")
|
||||
repo = click.prompt("Enter plugin repository URL:", type=str)
|
||||
if not repo.startswith("http"):
|
||||
raise click.ClickException("仓库地址必须以 http 开头")
|
||||
raise click.ClickException("Repository URL must start with http")
|
||||
|
||||
click.echo("下载插件模板...")
|
||||
click.echo("Downloading plugin template...")
|
||||
get_git_repo(
|
||||
"https://github.com/Soulter/helloworld",
|
||||
plug_path,
|
||||
)
|
||||
|
||||
click.echo("重写插件信息...")
|
||||
# 重写 metadata.yaml
|
||||
click.echo("Rewriting plugin metadata...")
|
||||
# Rewrite metadata.yaml
|
||||
with open(plug_path / "metadata.yaml", "w", encoding="utf-8") as f:
|
||||
f.write(
|
||||
f"name: {name}\n"
|
||||
@@ -79,11 +82,13 @@ def new(name: str) -> None:
|
||||
f"repo: {repo}\n",
|
||||
)
|
||||
|
||||
# 重写 README.md
|
||||
# Rewrite README.md
|
||||
with open(plug_path / "README.md", "w", encoding="utf-8") as f:
|
||||
f.write(f"# {name}\n\n{desc}\n\n# 支持\n\n[帮助文档](https://astrbot.app)\n")
|
||||
f.write(
|
||||
f"# {name}\n\n{desc}\n\n# Support\n\n[Documentation](https://astrbot.app)\n"
|
||||
)
|
||||
|
||||
# 重写 main.py
|
||||
# Rewrite main.py
|
||||
with open(plug_path / "main.py", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
@@ -95,54 +100,54 @@ def new(name: str) -> None:
|
||||
with open(plug_path / "main.py", "w", encoding="utf-8") as f:
|
||||
f.write(new_content)
|
||||
|
||||
click.echo(f"插件 {name} 创建成功")
|
||||
click.echo(f"Plugin {name} created successfully")
|
||||
|
||||
|
||||
@plug.command()
|
||||
@click.option("--all", "-a", is_flag=True, help="列出未安装的插件")
|
||||
@click.option("--all", "-a", is_flag=True, help="List uninstalled plugins")
|
||||
def list(all: bool) -> None:
|
||||
"""列出插件"""
|
||||
"""List plugins"""
|
||||
base_path = _get_data_path()
|
||||
plugins = build_plug_list(base_path / "plugins")
|
||||
|
||||
# 未发布的插件
|
||||
# Unpublished plugins
|
||||
not_published_plugins = [
|
||||
p for p in plugins if p["status"] == PluginStatus.NOT_PUBLISHED
|
||||
]
|
||||
if not_published_plugins:
|
||||
display_plugins(not_published_plugins, "未发布的插件", "red")
|
||||
display_plugins(not_published_plugins, "Unpublished Plugins", "red")
|
||||
|
||||
# 需要更新的插件
|
||||
# Plugins needing update
|
||||
need_update_plugins = [
|
||||
p for p in plugins if p["status"] == PluginStatus.NEED_UPDATE
|
||||
]
|
||||
if need_update_plugins:
|
||||
display_plugins(need_update_plugins, "需要更新的插件", "yellow")
|
||||
display_plugins(need_update_plugins, "Plugins Needing Update", "yellow")
|
||||
|
||||
# 已安装的插件
|
||||
# Installed plugins
|
||||
installed_plugins = [p for p in plugins if p["status"] == PluginStatus.INSTALLED]
|
||||
if installed_plugins:
|
||||
display_plugins(installed_plugins, "已安装的插件", "green")
|
||||
display_plugins(installed_plugins, "Installed Plugins", "green")
|
||||
|
||||
# 未安装的插件
|
||||
# Uninstalled plugins
|
||||
not_installed_plugins = [
|
||||
p for p in plugins if p["status"] == PluginStatus.NOT_INSTALLED
|
||||
]
|
||||
if not_installed_plugins and all:
|
||||
display_plugins(not_installed_plugins, "未安装的插件", "blue")
|
||||
display_plugins(not_installed_plugins, "Uninstalled Plugins", "blue")
|
||||
|
||||
if (
|
||||
not any([not_published_plugins, need_update_plugins, installed_plugins])
|
||||
and not all
|
||||
):
|
||||
click.echo("未安装任何插件")
|
||||
click.echo("No plugins installed")
|
||||
|
||||
|
||||
@plug.command()
|
||||
@click.argument("name")
|
||||
@click.option("--proxy", help="代理服务器地址")
|
||||
@click.option("--proxy", help="Proxy server address")
|
||||
def install(name: str, proxy: str | None) -> None:
|
||||
"""安装插件"""
|
||||
"""Install a plugin"""
|
||||
base_path = _get_data_path()
|
||||
plug_path = base_path / "plugins"
|
||||
plugins = build_plug_list(base_path / "plugins")
|
||||
@@ -157,7 +162,7 @@ def install(name: str, proxy: str | None) -> None:
|
||||
)
|
||||
|
||||
if not plugin:
|
||||
raise click.ClickException(f"未找到可安装的插件 {name},可能是不存在或已安装")
|
||||
raise click.ClickException(f"Plugin {name} not found or already installed")
|
||||
|
||||
manage_plugin(plugin, plug_path, is_update=False, proxy=proxy)
|
||||
|
||||
@@ -165,30 +170,32 @@ def install(name: str, proxy: str | None) -> None:
|
||||
@plug.command()
|
||||
@click.argument("name")
|
||||
def remove(name: str) -> None:
|
||||
"""卸载插件"""
|
||||
"""Uninstall a plugin"""
|
||||
base_path = _get_data_path()
|
||||
plugins = build_plug_list(base_path / "plugins")
|
||||
plugin = next((p for p in plugins if p["name"] == name), None)
|
||||
|
||||
if not plugin or not plugin.get("local_path"):
|
||||
raise click.ClickException(f"插件 {name} 不存在或未安装")
|
||||
raise click.ClickException(f"Plugin {name} does not exist or is not installed")
|
||||
|
||||
plugin_path = plugin["local_path"]
|
||||
|
||||
click.confirm(f"确定要卸载插件 {name} 吗?", default=False, abort=True)
|
||||
click.confirm(
|
||||
f"Are you sure you want to uninstall plugin {name}?", default=False, abort=True
|
||||
)
|
||||
|
||||
try:
|
||||
shutil.rmtree(plugin_path)
|
||||
click.echo(f"插件 {name} 已卸载")
|
||||
click.echo(f"Plugin {name} has been uninstalled")
|
||||
except Exception as e:
|
||||
raise click.ClickException(f"卸载插件 {name} 失败: {e}")
|
||||
raise click.ClickException(f"Failed to uninstall plugin {name}: {e}")
|
||||
|
||||
|
||||
@plug.command()
|
||||
@click.argument("name", required=False)
|
||||
@click.option("--proxy", help="Github代理地址")
|
||||
@click.option("--proxy", help="GitHub proxy address")
|
||||
def update(name: str, proxy: str | None) -> None:
|
||||
"""更新插件"""
|
||||
"""Update plugins"""
|
||||
base_path = _get_data_path()
|
||||
plug_path = base_path / "plugins"
|
||||
plugins = build_plug_list(base_path / "plugins")
|
||||
@@ -204,7 +211,9 @@ def update(name: str, proxy: str | None) -> None:
|
||||
)
|
||||
|
||||
if not plugin:
|
||||
raise click.ClickException(f"插件 {name} 不需要更新或无法更新")
|
||||
raise click.ClickException(
|
||||
f"Plugin {name} does not need updating or cannot be updated"
|
||||
)
|
||||
|
||||
manage_plugin(plugin, plug_path, is_update=True, proxy=proxy)
|
||||
else:
|
||||
@@ -213,20 +222,20 @@ def update(name: str, proxy: str | None) -> None:
|
||||
]
|
||||
|
||||
if not need_update_plugins:
|
||||
click.echo("没有需要更新的插件")
|
||||
click.echo("No plugins need updating")
|
||||
return
|
||||
|
||||
click.echo(f"发现 {len(need_update_plugins)} 个插件需要更新")
|
||||
click.echo(f"Found {len(need_update_plugins)} plugin(s) needing update")
|
||||
for plugin in need_update_plugins:
|
||||
plugin_name = plugin["name"]
|
||||
click.echo(f"正在更新插件 {plugin_name}...")
|
||||
click.echo(f"Updating plugin {plugin_name}...")
|
||||
manage_plugin(plugin, plug_path, is_update=True, proxy=proxy)
|
||||
|
||||
|
||||
@plug.command()
|
||||
@click.argument("query")
|
||||
def search(query: str) -> None:
|
||||
"""搜索插件"""
|
||||
"""Search for plugins"""
|
||||
base_path = _get_data_path()
|
||||
plugins = build_plug_list(base_path / "plugins")
|
||||
|
||||
@@ -239,7 +248,7 @@ def search(query: str) -> None:
|
||||
]
|
||||
|
||||
if not matched_plugins:
|
||||
click.echo(f"未找到匹配 '{query}' 的插件")
|
||||
click.echo(f"No plugins matching '{query}' found")
|
||||
return
|
||||
|
||||
display_plugins(matched_plugins, f"搜索结果: '{query}'", "cyan")
|
||||
display_plugins(matched_plugins, f"Search results: '{query}'", "cyan")
|
||||
|
||||
@@ -7,15 +7,21 @@ from pathlib import Path
|
||||
import click
|
||||
from filelock import FileLock, Timeout
|
||||
|
||||
from ..utils import check_astrbot_root, check_dashboard, get_astrbot_root
|
||||
from astrbot.core.utils.astrbot_path import astrbot_paths
|
||||
|
||||
from ..utils import check_astrbot_root, check_dashboard
|
||||
|
||||
|
||||
async def run_astrbot(astrbot_root: Path) -> None:
|
||||
"""运行 AstrBot"""
|
||||
"""Run AstrBot"""
|
||||
from astrbot.core import LogBroker, LogManager, db_helper, logger
|
||||
from astrbot.core.initial_loader import InitialLoader
|
||||
|
||||
await check_dashboard(astrbot_root / "data")
|
||||
if (
|
||||
os.environ.get("ASTRBOT_DASHBOARD_ENABLE", os.environ.get("DASHBOARD_ENABLE"))
|
||||
== "True"
|
||||
):
|
||||
await check_dashboard(astrbot_root)
|
||||
|
||||
log_broker = LogBroker()
|
||||
LogManager.set_queue_handler(logger, log_broker)
|
||||
@@ -26,28 +32,49 @@ async def run_astrbot(astrbot_root: Path) -> None:
|
||||
await core_lifecycle.start()
|
||||
|
||||
|
||||
@click.option("--reload", "-r", is_flag=True, help="插件自动重载")
|
||||
@click.option("--port", "-p", help="Astrbot Dashboard端口", required=False, type=str)
|
||||
@click.option("--reload", "-r", is_flag=True, help="Auto-reload plugins")
|
||||
@click.option("--host", "-H", help="AstrBot Dashboard Host", required=False, type=str)
|
||||
@click.option("--port", "-p", help="AstrBot Dashboard port", required=False, type=str)
|
||||
@click.option(
|
||||
"--backend-only",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Disable WebUI, run backend only",
|
||||
)
|
||||
@click.option(
|
||||
"--log-level",
|
||||
help="Log level",
|
||||
required=False,
|
||||
type=str,
|
||||
default="INFO",
|
||||
)
|
||||
@click.command()
|
||||
def run(reload: bool, port: str) -> None:
|
||||
"""运行 AstrBot"""
|
||||
def run(reload: bool, host: str, port: str, backend_only: bool, log_level: str) -> None:
|
||||
"""Run AstrBot"""
|
||||
try:
|
||||
os.environ["ASTRBOT_CLI"] = "1"
|
||||
astrbot_root = get_astrbot_root()
|
||||
astrbot_root = astrbot_paths.root
|
||||
|
||||
if not check_astrbot_root(astrbot_root):
|
||||
raise click.ClickException(
|
||||
f"{astrbot_root}不是有效的 AstrBot 根目录,如需初始化请使用 astrbot init",
|
||||
f"{astrbot_root} is not a valid AstrBot root directory. Use 'astrbot init' to initialize",
|
||||
)
|
||||
|
||||
os.environ["ASTRBOT_ROOT"] = str(astrbot_root)
|
||||
sys.path.insert(0, str(astrbot_root))
|
||||
|
||||
if port:
|
||||
os.environ["DASHBOARD_PORT"] = port
|
||||
if port is not None:
|
||||
os.environ["ASTRBOT_DASHBOARD_PORT"] = port
|
||||
os.environ["DASHBOARD_PORT"] = port # 今后应该移除
|
||||
if host is not None:
|
||||
os.environ["ASTRBOT_DASHBOARD_HOST"] = host
|
||||
os.environ["DASHBOARD_HOST"] = host # 今后应该移除
|
||||
os.environ["ASTRBOT_DASHBOARD_ENABLE"] = str(not backend_only)
|
||||
os.environ["DASHBOARD_ENABLE"] = str(not backend_only) # 今后应该移除
|
||||
os.environ["ASTRBOT_LOG_LEVEL"] = log_level
|
||||
|
||||
if reload:
|
||||
click.echo("启用插件自动重载")
|
||||
click.echo("Plugin auto-reload enabled")
|
||||
os.environ["ASTRBOT_RELOAD"] = "1"
|
||||
|
||||
lock_file = astrbot_root / "astrbot.lock"
|
||||
@@ -55,8 +82,10 @@ def run(reload: bool, port: str) -> None:
|
||||
with lock.acquire():
|
||||
asyncio.run(run_astrbot(astrbot_root))
|
||||
except KeyboardInterrupt:
|
||||
click.echo("AstrBot 已关闭...")
|
||||
click.echo("AstrBot has been shut down.")
|
||||
except Timeout:
|
||||
raise click.ClickException("无法获取锁文件,请检查是否有其他实例正在运行")
|
||||
raise click.ClickException(
|
||||
"Cannot acquire lock file. Please check if another instance is running"
|
||||
)
|
||||
except Exception as e:
|
||||
raise click.ClickException(f"运行时出现错误: {e}\n{traceback.format_exc()}")
|
||||
raise click.ClickException(f"Runtime error: {e}\n{traceback.format_exc()}")
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from astrbot.core.utils.astrbot_path import astrbot_paths
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompts")
|
||||
@click.option(
|
||||
"--keep-data", is_flag=True, help="Keep data directory (config, plugins, etc.)"
|
||||
)
|
||||
def uninstall(yes: bool, keep_data: bool) -> None:
|
||||
"""Uninstall AstrBot systemd service and cleanup data"""
|
||||
|
||||
# 1. Remove Systemd Service
|
||||
if platform.system() == "Linux" and shutil.which("systemctl"):
|
||||
service_path = Path.home() / ".config" / "systemd" / "user" / "astrbot.service"
|
||||
|
||||
if service_path.exists():
|
||||
if yes or click.confirm(
|
||||
"Detected AstrBot systemd service. Stop and remove it?",
|
||||
default=True,
|
||||
):
|
||||
try:
|
||||
click.echo("Stopping AstrBot service...")
|
||||
subprocess.run(
|
||||
["systemctl", "--user", "stop", "astrbot"], check=False
|
||||
)
|
||||
|
||||
click.echo("Disabling AstrBot service...")
|
||||
subprocess.run(
|
||||
["systemctl", "--user", "disable", "astrbot"], check=False
|
||||
)
|
||||
|
||||
click.echo(f"Removing service file: {service_path}")
|
||||
service_path.unlink()
|
||||
|
||||
click.echo("Reloading systemd daemon...")
|
||||
subprocess.run(["systemctl", "--user", "daemon-reload"], check=True)
|
||||
click.echo("Systemd service uninstalled.")
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
click.echo(f"Failed to remove systemd service: {e}", err=True)
|
||||
except Exception as e:
|
||||
click.echo(
|
||||
f"An error occurred during service removal: {e}", err=True
|
||||
)
|
||||
|
||||
# 2. Remove Data
|
||||
if keep_data:
|
||||
click.echo("Skipping data removal as requested.")
|
||||
return
|
||||
|
||||
# Helper paths
|
||||
dot_astrbot = astrbot_paths.root / ".astrbot"
|
||||
lock_file = astrbot_paths.root / "astrbot.lock"
|
||||
data_dir = astrbot_paths.data
|
||||
|
||||
# Check if this looks like an AstrBot root before blowing things up
|
||||
if not dot_astrbot.exists() and not data_dir.exists():
|
||||
click.echo("No AstrBot initialization found in current directory.")
|
||||
return
|
||||
|
||||
if yes or click.confirm(
|
||||
f"Are you sure you want to remove AstrBot data at {astrbot_paths.root}? \n"
|
||||
f"This will delete:\n"
|
||||
f" - {data_dir} (Config, Plugins, Database)\n"
|
||||
f" - {dot_astrbot}\n"
|
||||
f" - {lock_file}",
|
||||
default=False,
|
||||
abort=True,
|
||||
):
|
||||
if data_dir.exists():
|
||||
click.echo(f"Removing directory: {data_dir}")
|
||||
shutil.rmtree(data_dir)
|
||||
|
||||
if dot_astrbot.exists():
|
||||
click.echo(f"Removing file: {dot_astrbot}")
|
||||
dot_astrbot.unlink()
|
||||
|
||||
if lock_file.exists():
|
||||
click.echo(f"Removing file: {lock_file}")
|
||||
lock_file.unlink()
|
||||
|
||||
click.echo("AstrBot data removed successfully.")
|
||||
click.echo("uv: uv tool uninstall astrbot")
|
||||
click.echo("paru/yay: paru -R astrbot")
|
||||
+38
-23
@@ -1,10 +1,17 @@
|
||||
from importlib import resources
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from astrbot.core.utils.astrbot_path import astrbot_paths
|
||||
|
||||
# Static assets bundled inside the installed wheel (built by hatch_build.py).
|
||||
# _BUNDLED_DIST = Path(__file__).parent.parent.parent / "dashboard" / "dist"
|
||||
_BUNDLED_DIST = resources.files("astrbot") / "dashboard" / "dist"
|
||||
|
||||
|
||||
def check_astrbot_root(path: str | Path) -> bool:
|
||||
"""检查路径是否为 AstrBot 根目录"""
|
||||
"""Check if the path is an AstrBot root directory"""
|
||||
if not isinstance(path, Path):
|
||||
path = Path(path)
|
||||
if not path.exists() or not path.is_dir():
|
||||
@@ -15,62 +22,70 @@ def check_astrbot_root(path: str | Path) -> bool:
|
||||
|
||||
|
||||
def get_astrbot_root() -> Path:
|
||||
"""获取Astrbot根目录路径"""
|
||||
return Path.cwd()
|
||||
"""Get the AstrBot root directory path"""
|
||||
return astrbot_paths.root
|
||||
|
||||
|
||||
async def check_dashboard(astrbot_root: Path) -> None:
|
||||
"""检查是否安装了dashboard"""
|
||||
"""Check if the dashboard is installed"""
|
||||
from astrbot.core.config.default import VERSION
|
||||
from astrbot.core.utils.io import download_dashboard, get_dashboard_version
|
||||
|
||||
from .version_comparator import VersionComparator
|
||||
|
||||
# If the wheel ships bundled dashboard assets, no network download is needed.
|
||||
if _BUNDLED_DIST.is_dir():
|
||||
click.echo("Dashboard is bundled with the package – skipping download.")
|
||||
return
|
||||
|
||||
try:
|
||||
dashboard_version = await get_dashboard_version()
|
||||
match dashboard_version:
|
||||
case None:
|
||||
click.echo("未安装管理面板")
|
||||
click.echo("Dashboard is not installed")
|
||||
if click.confirm(
|
||||
"是否安装管理面板?",
|
||||
"Install dashboard?",
|
||||
default=True,
|
||||
abort=True,
|
||||
):
|
||||
click.echo("正在安装管理面板...")
|
||||
await download_dashboard(
|
||||
path="data/dashboard.zip",
|
||||
extract_path=str(astrbot_root),
|
||||
version=f"v{VERSION}",
|
||||
latest=False,
|
||||
)
|
||||
click.echo("管理面板安装完成")
|
||||
click.echo("Installing dashboard...")
|
||||
try:
|
||||
await download_dashboard(
|
||||
path="data/dashboard.zip",
|
||||
extract_path=str(astrbot_root / "data"),
|
||||
version=f"v{VERSION}",
|
||||
latest=False,
|
||||
)
|
||||
click.echo("Dashboard installed successfully")
|
||||
except Exception as e:
|
||||
click.echo(f"Failed to install dashboard: {e}")
|
||||
|
||||
case str():
|
||||
if VersionComparator.compare_version(VERSION, dashboard_version) <= 0:
|
||||
click.echo("管理面板已是最新版本")
|
||||
click.echo("Dashboard is already up to date")
|
||||
return
|
||||
try:
|
||||
version = dashboard_version.split("v")[1]
|
||||
click.echo(f"管理面板版本: {version}")
|
||||
click.echo(f"Dashboard version: {version}")
|
||||
await download_dashboard(
|
||||
path="data/dashboard.zip",
|
||||
extract_path=str(astrbot_root),
|
||||
extract_path=str(astrbot_root / "data"),
|
||||
version=f"v{VERSION}",
|
||||
latest=False,
|
||||
)
|
||||
except Exception as e:
|
||||
click.echo(f"下载管理面板失败: {e}")
|
||||
click.echo(f"Failed to download dashboard: {e}")
|
||||
return
|
||||
except FileNotFoundError:
|
||||
click.echo("初始化管理面板目录...")
|
||||
click.echo("Initializing dashboard directory...")
|
||||
try:
|
||||
await download_dashboard(
|
||||
path=str(astrbot_root / "dashboard.zip"),
|
||||
extract_path=str(astrbot_root),
|
||||
path=str(astrbot_root / "data" / "dashboard.zip"),
|
||||
extract_path=str(astrbot_root / "data"),
|
||||
version=f"v{VERSION}",
|
||||
latest=False,
|
||||
)
|
||||
click.echo("管理面板初始化完成")
|
||||
click.echo("Dashboard initialized successfully")
|
||||
except Exception as e:
|
||||
click.echo(f"下载管理面板失败: {e}")
|
||||
click.echo(f"Failed to download dashboard: {e}")
|
||||
return
|
||||
|
||||
+47
-43
@@ -13,22 +13,22 @@ from .version_comparator import VersionComparator
|
||||
|
||||
|
||||
class PluginStatus(str, Enum):
|
||||
INSTALLED = "已安装"
|
||||
NEED_UPDATE = "需更新"
|
||||
NOT_INSTALLED = "未安装"
|
||||
NOT_PUBLISHED = "未发布"
|
||||
INSTALLED = "installed"
|
||||
NEED_UPDATE = "needs-update"
|
||||
NOT_INSTALLED = "not-installed"
|
||||
NOT_PUBLISHED = "unpublished"
|
||||
|
||||
|
||||
def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None:
|
||||
"""从 Git 仓库下载代码并解压到指定路径"""
|
||||
"""Download code from a Git repository and extract to the specified path"""
|
||||
temp_dir = Path(tempfile.mkdtemp())
|
||||
try:
|
||||
# 解析仓库信息
|
||||
# Parse repository info
|
||||
repo_namespace = url.split("/")[-2:]
|
||||
author = repo_namespace[0]
|
||||
repo = repo_namespace[1]
|
||||
|
||||
# 尝试获取最新的 release
|
||||
# Try to get the latest release
|
||||
release_url = f"https://api.github.com/repos/{author}/{repo}/releases"
|
||||
try:
|
||||
with httpx.Client(
|
||||
@@ -40,21 +40,21 @@ def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None:
|
||||
releases = resp.json()
|
||||
|
||||
if releases:
|
||||
# 使用最新的 release
|
||||
# Use the latest release
|
||||
download_url = releases[0]["zipball_url"]
|
||||
else:
|
||||
# 没有 release,使用默认分支
|
||||
click.echo(f"正在从默认分支下载 {author}/{repo}")
|
||||
# No release found, use default branch
|
||||
click.echo(f"Downloading {author}/{repo} from default branch")
|
||||
download_url = f"https://github.com/{author}/{repo}/archive/refs/heads/master.zip"
|
||||
except Exception as e:
|
||||
click.echo(f"获取 release 信息失败: {e},将直接使用提供的 URL")
|
||||
click.echo(f"Failed to get release info: {e}. Using provided URL directly")
|
||||
download_url = url
|
||||
|
||||
# 应用代理
|
||||
# Apply proxy
|
||||
if proxy:
|
||||
download_url = f"{proxy}/{download_url}"
|
||||
|
||||
# 下载并解压
|
||||
# Download and extract
|
||||
with httpx.Client(
|
||||
proxy=proxy if proxy else None,
|
||||
follow_redirects=True,
|
||||
@@ -65,7 +65,7 @@ def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None:
|
||||
and "archive/refs/heads/master.zip" in download_url
|
||||
):
|
||||
alt_url = download_url.replace("master.zip", "main.zip")
|
||||
click.echo("master 分支不存在,尝试下载 main 分支")
|
||||
click.echo("Branch 'master' not found, trying 'main' branch")
|
||||
resp = client.get(alt_url)
|
||||
resp.raise_for_status()
|
||||
else:
|
||||
@@ -84,13 +84,13 @@ def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None:
|
||||
|
||||
|
||||
def load_yaml_metadata(plugin_dir: Path) -> dict:
|
||||
"""从 metadata.yaml 文件加载插件元数据
|
||||
"""Load plugin metadata from metadata.yaml file
|
||||
|
||||
Args:
|
||||
plugin_dir: 插件目录路径
|
||||
plugin_dir: Plugin directory path
|
||||
|
||||
Returns:
|
||||
dict: 包含元数据的字典,如果读取失败则返回空字典
|
||||
dict: Dictionary containing metadata, or empty dict if loading fails
|
||||
|
||||
"""
|
||||
yaml_path = plugin_dir / "metadata.yaml"
|
||||
@@ -98,33 +98,33 @@ def load_yaml_metadata(plugin_dir: Path) -> dict:
|
||||
try:
|
||||
return yaml.safe_load(yaml_path.read_text(encoding="utf-8")) or {}
|
||||
except Exception as e:
|
||||
click.echo(f"读取 {yaml_path} 失败: {e}", err=True)
|
||||
click.echo(f"Failed to read {yaml_path}: {e}", err=True)
|
||||
return {}
|
||||
|
||||
|
||||
def build_plug_list(plugins_dir: Path) -> list:
|
||||
"""构建插件列表,包含本地和在线插件信息
|
||||
"""Build plugin list containing local and online plugin information
|
||||
|
||||
Args:
|
||||
plugins_dir (Path): 插件目录路径
|
||||
plugins_dir (Path): Plugin directory path
|
||||
|
||||
Returns:
|
||||
list: 包含插件信息的字典列表
|
||||
list: List of dicts containing plugin information
|
||||
|
||||
"""
|
||||
# 获取本地插件信息
|
||||
# Get local plugin info
|
||||
result = []
|
||||
if plugins_dir.exists():
|
||||
for plugin_name in [d.name for d in plugins_dir.glob("*") if d.is_dir()]:
|
||||
plugin_dir = plugins_dir / plugin_name
|
||||
|
||||
# 从 metadata.yaml 加载元数据
|
||||
# Load metadata from metadata.yaml
|
||||
metadata = load_yaml_metadata(plugin_dir)
|
||||
|
||||
if "desc" not in metadata and "description" in metadata:
|
||||
metadata["desc"] = metadata["description"]
|
||||
|
||||
# 如果成功加载元数据,添加到结果列表
|
||||
# If metadata loaded successfully, add to result list
|
||||
if metadata and all(
|
||||
k in metadata for k in ["name", "desc", "version", "author", "repo"]
|
||||
):
|
||||
@@ -140,7 +140,7 @@ def build_plug_list(plugins_dir: Path) -> list:
|
||||
},
|
||||
)
|
||||
|
||||
# 获取在线插件列表
|
||||
# Get online plugin list
|
||||
online_plugins = []
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
@@ -160,13 +160,13 @@ def build_plug_list(plugins_dir: Path) -> list:
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
click.echo(f"获取在线插件列表失败: {e}", err=True)
|
||||
click.echo(f"Failed to get online plugin list: {e}", err=True)
|
||||
|
||||
# 与在线插件比对,更新状态
|
||||
# Compare with online plugins and update status
|
||||
online_plugin_names = {plugin["name"] for plugin in online_plugins}
|
||||
for local_plugin in result:
|
||||
if local_plugin["name"] in online_plugin_names:
|
||||
# 查找对应的在线插件
|
||||
# Find the corresponding online plugin
|
||||
online_plugin = next(
|
||||
p for p in online_plugins if p["name"] == local_plugin["name"]
|
||||
)
|
||||
@@ -179,10 +179,10 @@ def build_plug_list(plugins_dir: Path) -> list:
|
||||
):
|
||||
local_plugin["status"] = PluginStatus.NEED_UPDATE
|
||||
else:
|
||||
# 本地插件未在线上发布
|
||||
# Local plugin is not published online
|
||||
local_plugin["status"] = PluginStatus.NOT_PUBLISHED
|
||||
|
||||
# 添加未安装的在线插件
|
||||
# Add uninstalled online plugins
|
||||
for online_plugin in online_plugins:
|
||||
if not any(plugin["name"] == online_plugin["name"] for plugin in result):
|
||||
result.append(online_plugin)
|
||||
@@ -196,19 +196,19 @@ def manage_plugin(
|
||||
is_update: bool = False,
|
||||
proxy: str | None = None,
|
||||
) -> None:
|
||||
"""安装或更新插件
|
||||
"""Install or update a plugin
|
||||
|
||||
Args:
|
||||
plugin (dict): 插件信息字典
|
||||
plugins_dir (Path): 插件目录
|
||||
is_update (bool, optional): 是否为更新操作. 默认为 False
|
||||
proxy (str, optional): 代理服务器地址
|
||||
plugin (dict): Plugin info dict
|
||||
plugins_dir (Path): Plugins directory
|
||||
is_update (bool, optional): Whether this is an update operation. Defaults to False
|
||||
proxy (str, optional): Proxy server address
|
||||
|
||||
"""
|
||||
plugin_name = plugin["name"]
|
||||
repo_url = plugin["repo"]
|
||||
|
||||
# 如果是更新且有本地路径,直接使用本地路径
|
||||
# If updating and local path exists, use it directly
|
||||
if is_update and plugin.get("local_path"):
|
||||
target_path = Path(plugin["local_path"])
|
||||
else:
|
||||
@@ -216,11 +216,13 @@ def manage_plugin(
|
||||
|
||||
backup_path = Path(f"{target_path}_backup") if is_update else None
|
||||
|
||||
# 检查插件是否存在
|
||||
# Check if plugin exists
|
||||
if is_update and not target_path.exists():
|
||||
raise click.ClickException(f"插件 {plugin_name} 未安装,无法更新")
|
||||
raise click.ClickException(
|
||||
f"Plugin {plugin_name} is not installed and cannot be updated"
|
||||
)
|
||||
|
||||
# 备份现有插件
|
||||
# Backup existing plugin
|
||||
if is_update and backup_path is not None and backup_path.exists():
|
||||
shutil.rmtree(backup_path)
|
||||
if is_update and backup_path is not None:
|
||||
@@ -228,19 +230,21 @@ def manage_plugin(
|
||||
|
||||
try:
|
||||
click.echo(
|
||||
f"正在从 {repo_url} {'更新' if is_update else '下载'}插件 {plugin_name}...",
|
||||
f"{'Updating' if is_update else 'Downloading'} plugin {plugin_name} from {repo_url}...",
|
||||
)
|
||||
get_git_repo(repo_url, target_path, proxy)
|
||||
|
||||
# 更新成功,删除备份
|
||||
# Update succeeded, delete backup
|
||||
if is_update and backup_path is not None and backup_path.exists():
|
||||
shutil.rmtree(backup_path)
|
||||
click.echo(f"插件 {plugin_name} {'更新' if is_update else '安装'}成功")
|
||||
click.echo(
|
||||
f"Plugin {plugin_name} {'updated' if is_update else 'installed'} successfully"
|
||||
)
|
||||
except Exception as e:
|
||||
if target_path.exists():
|
||||
shutil.rmtree(target_path, ignore_errors=True)
|
||||
if is_update and backup_path is not None and backup_path.exists():
|
||||
shutil.move(backup_path, target_path)
|
||||
raise click.ClickException(
|
||||
f"{'更新' if is_update else '安装'}插件 {plugin_name} 时出错: {e}",
|
||||
f"Error {'updating' if is_update else 'installing'} plugin {plugin_name}: {e}",
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""拷贝自 astrbot.core.utils.version_comparator"""
|
||||
"""Copied from astrbot.core.utils.version_comparator"""
|
||||
|
||||
import re
|
||||
|
||||
@@ -6,11 +6,11 @@ import re
|
||||
class VersionComparator:
|
||||
@staticmethod
|
||||
def compare_version(v1: str, v2: str) -> int:
|
||||
"""根据 Semver 语义版本规范来比较版本号的大小。支持不仅局限于 3 个数字的版本号,并处理预发布标签。
|
||||
"""Compare version numbers according to Semver semantics. Supports version numbers with more than 3 digits and handles pre-release tags.
|
||||
|
||||
参考: https://semver.org/lang/zh-CN/
|
||||
Reference: https://semver.org/
|
||||
|
||||
返回 1 表示 v1 > v2,返回 -1 表示 v1 < v2,返回 0 表示 v1 = v2。
|
||||
Returns 1 if v1 > v2, -1 if v1 < v2, 0 if v1 == v2.
|
||||
"""
|
||||
v1 = v1.lower().replace("v", "")
|
||||
v2 = v2.lower().replace("v", "")
|
||||
@@ -24,7 +24,7 @@ class VersionComparator:
|
||||
return [], None
|
||||
major_minor_patch = match.group(1).split(".")
|
||||
prerelease = match.group(2)
|
||||
# buildmetadata = match.group(3) # 构建元数据在比较时忽略
|
||||
# buildmetadata = match.group(3) # Build metadata is ignored in comparison
|
||||
parts = [int(x) for x in major_minor_patch]
|
||||
prerelease = VersionComparator._split_prerelease(prerelease)
|
||||
return parts, prerelease
|
||||
@@ -32,7 +32,7 @@ class VersionComparator:
|
||||
v1_parts, v1_prerelease = split_version(v1)
|
||||
v2_parts, v2_prerelease = split_version(v2)
|
||||
|
||||
# 比较数字部分
|
||||
# Compare numeric parts
|
||||
length = max(len(v1_parts), len(v2_parts))
|
||||
v1_parts.extend([0] * (length - len(v1_parts)))
|
||||
v2_parts.extend([0] * (length - len(v2_parts)))
|
||||
@@ -43,11 +43,11 @@ class VersionComparator:
|
||||
if v1_parts[i] < v2_parts[i]:
|
||||
return -1
|
||||
|
||||
# 比较预发布标签
|
||||
# Compare pre-release tags
|
||||
if v1_prerelease is None and v2_prerelease is not None:
|
||||
return 1 # 没有预发布标签的版本高于有预发布标签的版本
|
||||
return 1 # Version without pre-release tag is higher than one with it
|
||||
if v1_prerelease is not None and v2_prerelease is None:
|
||||
return -1 # 有预发布标签的版本低于没有预发布标签的版本
|
||||
return -1 # Version with pre-release tag is lower than one without it
|
||||
if v1_prerelease is not None and v2_prerelease is not None:
|
||||
len_pre = max(len(v1_prerelease), len(v2_prerelease))
|
||||
for i in range(len_pre):
|
||||
@@ -72,9 +72,9 @@ class VersionComparator:
|
||||
return 1
|
||||
if p1 < p2:
|
||||
return -1
|
||||
return 0 # 预发布标签完全相同
|
||||
return 0 # Pre-release tags are identical
|
||||
|
||||
return 0 # 数字部分和预发布标签都相同
|
||||
return 0 # Both numeric parts and pre-release tags are equal
|
||||
|
||||
@staticmethod
|
||||
def _split_prerelease(prerelease):
|
||||
|
||||
@@ -4,7 +4,21 @@ from astrbot.core.config import AstrBotConfig
|
||||
from astrbot.core.config.default import DB_PATH
|
||||
from astrbot.core.db.sqlite import SQLiteDatabase
|
||||
from astrbot.core.file_token_service import FileTokenService
|
||||
from astrbot.core.utils.pip_installer import PipInstaller
|
||||
from astrbot.core.utils.pip_installer import (
|
||||
DependencyConflictError as DependencyConflictError,
|
||||
)
|
||||
from astrbot.core.utils.pip_installer import (
|
||||
PipInstaller,
|
||||
)
|
||||
from astrbot.core.utils.requirements_utils import (
|
||||
RequirementsPrecheckFailed as RequirementsPrecheckFailed,
|
||||
)
|
||||
from astrbot.core.utils.requirements_utils import (
|
||||
find_missing_requirements as find_missing_requirements,
|
||||
)
|
||||
from astrbot.core.utils.requirements_utils import (
|
||||
find_missing_requirements_or_raise as find_missing_requirements_or_raise,
|
||||
)
|
||||
from astrbot.core.utils.shared_preferences import SharedPreferences
|
||||
from astrbot.core.utils.t2i.renderer import HtmlRenderer
|
||||
|
||||
@@ -14,13 +28,15 @@ from .utils.astrbot_path import get_astrbot_data_path
|
||||
# 初始化数据存储文件夹
|
||||
os.makedirs(get_astrbot_data_path(), exist_ok=True)
|
||||
|
||||
DEMO_MODE = os.getenv("DEMO_MODE", False)
|
||||
DEMO_MODE = os.getenv("DEMO_MODE", "False").strip().lower() in ("true", "1", "t")
|
||||
|
||||
astrbot_config = AstrBotConfig()
|
||||
t2i_base_url = astrbot_config.get("t2i_endpoint", "https://t2i.soulter.top/text2img")
|
||||
html_renderer = HtmlRenderer(t2i_base_url)
|
||||
logger = LogManager.GetLogger(log_name="astrbot")
|
||||
LogManager.configure_logger(logger, astrbot_config)
|
||||
LogManager.configure_logger(
|
||||
logger, astrbot_config, override_level=os.getenv("ASTRBOT_LOG_LEVEL")
|
||||
)
|
||||
LogManager.configure_trace_logger(astrbot_config)
|
||||
db_helper = SQLiteDatabase(DB_PATH)
|
||||
# 简单的偏好设置存储, 这里后续应该存储到数据库中, 一些部分可以存储到配置中
|
||||
|
||||
@@ -15,7 +15,6 @@ class HandoffTool(FunctionTool, Generic[TContext]):
|
||||
tool_description: str | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
|
||||
# Avoid passing duplicate `description` to the FunctionTool dataclass.
|
||||
# Some call sites (e.g. SubAgentOrchestrator) pass `description` via kwargs
|
||||
# to override what the main agent sees, while we also compute a default
|
||||
@@ -62,4 +61,4 @@ class HandoffTool(FunctionTool, Generic[TContext]):
|
||||
|
||||
def default_description(self, agent_name: str | None) -> str:
|
||||
agent_name = agent_name or "another"
|
||||
return f"Delegate tasks to {self.name} agent to handle the request."
|
||||
return f"Delegate tasks to {agent_name} agent to handle the request."
|
||||
|
||||
@@ -144,10 +144,14 @@ class MCPClient:
|
||||
|
||||
cfg = _prepare_config(mcp_server_config.copy())
|
||||
|
||||
def logging_callback(msg: str) -> None:
|
||||
def logging_callback(
|
||||
msg: str | mcp.types.LoggingMessageNotificationParams,
|
||||
) -> None:
|
||||
# Handle MCP service error logs
|
||||
print(f"MCP Server {name} Error: {msg}")
|
||||
self.server_errlogs.append(msg)
|
||||
if isinstance(msg, mcp.types.LoggingMessageNotificationParams):
|
||||
if msg.level in ("warning", "error", "critical", "alert", "emergency"):
|
||||
log_msg = f"[{msg.level.upper()}] {str(msg.data)}"
|
||||
self.server_errlogs.append(log_msg)
|
||||
|
||||
if "url" in cfg:
|
||||
success, error_msg = await _quick_test_mcp_connection(cfg)
|
||||
@@ -214,15 +218,24 @@ class MCPClient:
|
||||
**cfg,
|
||||
)
|
||||
|
||||
def callback(msg: str) -> None:
|
||||
def callback(msg: str | mcp.types.LoggingMessageNotificationParams) -> None:
|
||||
# Handle MCP service error logs
|
||||
self.server_errlogs.append(msg)
|
||||
if isinstance(msg, mcp.types.LoggingMessageNotificationParams):
|
||||
if msg.level in (
|
||||
"warning",
|
||||
"error",
|
||||
"critical",
|
||||
"alert",
|
||||
"emergency",
|
||||
):
|
||||
log_msg = f"[{msg.level.upper()}] {str(msg.data)}"
|
||||
self.server_errlogs.append(log_msg)
|
||||
|
||||
stdio_transport = await self.exit_stack.enter_async_context(
|
||||
mcp.stdio_client(
|
||||
server_params,
|
||||
errlog=LogPipe(
|
||||
level=logging.ERROR,
|
||||
level=logging.INFO,
|
||||
logger=logger,
|
||||
identifier=f"MCPServer-{name}",
|
||||
callback=callback,
|
||||
@@ -374,6 +387,7 @@ class MCPTool(FunctionTool, Generic[TContext]):
|
||||
self.mcp_tool = mcp_tool
|
||||
self.mcp_client = mcp_client
|
||||
self.mcp_server_name = mcp_server_name
|
||||
self.source = "mcp"
|
||||
|
||||
async def call(
|
||||
self, context: ContextWrapper[TContext], **kwargs
|
||||
|
||||
@@ -302,7 +302,7 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
|
||||
|
||||
while True:
|
||||
try:
|
||||
item_type, item_data = await asyncio.get_event_loop().run_in_executor(
|
||||
item_type, item_data = await asyncio.get_running_loop().run_in_executor(
|
||||
None, response_queue.get, True, 1
|
||||
)
|
||||
except queue.Empty:
|
||||
@@ -388,7 +388,7 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
|
||||
|
||||
# 发起请求
|
||||
partial = functools.partial(Application.call, **payload)
|
||||
response = await asyncio.get_event_loop().run_in_executor(None, partial)
|
||||
response = await asyncio.get_running_loop().run_in_executor(None, partial)
|
||||
|
||||
async for resp in self._handle_streaming_response(response, session_id):
|
||||
yield resp
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
DEERFLOW_PROVIDER_TYPE = "deerflow"
|
||||
DEERFLOW_THREAD_ID_KEY = "deerflow_thread_id"
|
||||
DEERFLOW_SESSION_PREFIX = "deerflow-ephemeral"
|
||||
DEERFLOW_AGENT_RUNNER_PROVIDER_ID_KEY = "deerflow_agent_runner_provider_id"
|
||||
@@ -0,0 +1,693 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import typing as T
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from uuid import uuid4
|
||||
|
||||
import astrbot.core.message.components as Comp
|
||||
from astrbot import logger
|
||||
from astrbot.core import sp
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.provider.entities import (
|
||||
LLMResponse,
|
||||
ProviderRequest,
|
||||
)
|
||||
from astrbot.core.utils.config_number import coerce_int_config
|
||||
|
||||
from ...hooks import BaseAgentRunHooks
|
||||
from ...response import AgentResponseData
|
||||
from ...run_context import ContextWrapper, TContext
|
||||
from ..base import AgentResponse, AgentState, BaseAgentRunner
|
||||
from .constants import DEERFLOW_SESSION_PREFIX, DEERFLOW_THREAD_ID_KEY
|
||||
from .deerflow_api_client import DeerFlowAPIClient
|
||||
from .deerflow_content_mapper import (
|
||||
build_chain_from_ai_content,
|
||||
build_user_content,
|
||||
image_component_from_url,
|
||||
)
|
||||
from .deerflow_stream_utils import (
|
||||
build_task_failure_summary,
|
||||
extract_ai_delta_from_event_data,
|
||||
extract_clarification_from_event_data,
|
||||
extract_latest_ai_message,
|
||||
extract_latest_ai_text,
|
||||
extract_latest_clarification_text,
|
||||
extract_messages_from_values_data,
|
||||
extract_task_failures_from_custom_event,
|
||||
get_message_id,
|
||||
)
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override
|
||||
else:
|
||||
from typing_extensions import override
|
||||
|
||||
|
||||
class DeerFlowAgentRunner(BaseAgentRunner[TContext]):
|
||||
"""DeerFlow Agent Runner via LangGraph HTTP API."""
|
||||
|
||||
_MAX_VALUES_HISTORY = 200
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _RunnerConfig:
|
||||
api_base: str
|
||||
api_key: str
|
||||
auth_header: str
|
||||
proxy: str
|
||||
assistant_id: str
|
||||
model_name: str
|
||||
thinking_enabled: bool
|
||||
plan_mode: bool
|
||||
subagent_enabled: bool
|
||||
max_concurrent_subagents: int
|
||||
timeout: int
|
||||
recursion_limit: int
|
||||
|
||||
@dataclass
|
||||
class _StreamState:
|
||||
latest_text: str = ""
|
||||
prev_text_for_streaming: str = ""
|
||||
clarification_text: str = ""
|
||||
task_failures: list[str] = field(default_factory=list)
|
||||
seen_message_ids: set[str] = field(default_factory=set)
|
||||
seen_message_order: deque[str] = field(default_factory=deque)
|
||||
# Fallback tracking for backends that omit message ids in values events.
|
||||
no_id_message_fingerprints: dict[int, str] = field(default_factory=dict)
|
||||
baseline_initialized: bool = False
|
||||
has_values_text: bool = False
|
||||
run_values_messages: list[dict[str, T.Any]] = field(default_factory=list)
|
||||
timed_out: bool = False
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _FinalResult:
|
||||
chain: MessageChain
|
||||
role: str
|
||||
|
||||
def _format_exception(self, err: Exception) -> str:
|
||||
err_type = type(err).__name__
|
||||
detail = str(err).strip()
|
||||
|
||||
if isinstance(err, (asyncio.TimeoutError, TimeoutError)):
|
||||
timeout_text = (
|
||||
f"{self.timeout}s"
|
||||
if isinstance(getattr(self, "timeout", None), (int, float))
|
||||
else "configured timeout"
|
||||
)
|
||||
return (
|
||||
f"{err_type}: request timed out after {timeout_text}. "
|
||||
"Please check DeerFlow service health and backend logs."
|
||||
)
|
||||
|
||||
if detail:
|
||||
if detail.startswith(f"{err_type}:"):
|
||||
return detail
|
||||
return f"{err_type}: {detail}"
|
||||
|
||||
return f"{err_type}: no detailed error message provided."
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Explicit cleanup hook for long-lived workers."""
|
||||
api_client = getattr(self, "api_client", None)
|
||||
if isinstance(api_client, DeerFlowAPIClient) and not api_client.is_closed:
|
||||
try:
|
||||
await api_client.close()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to close DeerFlowAPIClient during runner shutdown: %s",
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def _notify_agent_done_hook(self) -> None:
|
||||
if not self.final_llm_resp:
|
||||
return
|
||||
try:
|
||||
await self.agent_hooks.on_agent_done(self.run_context, self.final_llm_resp)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in on_agent_done hook: {e}", exc_info=True)
|
||||
|
||||
async def _finish_with_result(
|
||||
self, chain: MessageChain, role: str
|
||||
) -> AgentResponse:
|
||||
self.final_llm_resp = LLMResponse(
|
||||
role=role,
|
||||
result_chain=chain,
|
||||
)
|
||||
self._transition_state(AgentState.DONE)
|
||||
await self._notify_agent_done_hook()
|
||||
return AgentResponse(
|
||||
type="llm_result",
|
||||
data=AgentResponseData(chain=chain),
|
||||
)
|
||||
|
||||
async def _finish_with_error(self, err_msg: str) -> AgentResponse:
|
||||
err_text = f"DeerFlow request failed: {err_msg}"
|
||||
err_chain = MessageChain().message(err_text)
|
||||
self.final_llm_resp = LLMResponse(
|
||||
role="err",
|
||||
completion_text=err_text,
|
||||
result_chain=err_chain,
|
||||
)
|
||||
self._transition_state(AgentState.ERROR)
|
||||
await self._notify_agent_done_hook()
|
||||
return AgentResponse(
|
||||
type="err",
|
||||
data=AgentResponseData(
|
||||
chain=err_chain,
|
||||
),
|
||||
)
|
||||
|
||||
def _parse_runner_config(self, provider_config: dict) -> _RunnerConfig:
|
||||
api_base = provider_config.get("deerflow_api_base", "http://127.0.0.1:2026")
|
||||
if not isinstance(api_base, str) or not api_base.startswith(
|
||||
("http://", "https://"),
|
||||
):
|
||||
raise ValueError(
|
||||
"DeerFlow API Base URL format is invalid. It must start with http:// or https://.",
|
||||
)
|
||||
|
||||
proxy = provider_config.get("proxy", "")
|
||||
normalized_proxy = proxy.strip() if isinstance(proxy, str) else ""
|
||||
|
||||
return self._RunnerConfig(
|
||||
api_base=api_base,
|
||||
api_key=provider_config.get("deerflow_api_key", ""),
|
||||
auth_header=provider_config.get("deerflow_auth_header", ""),
|
||||
proxy=normalized_proxy,
|
||||
assistant_id=provider_config.get("deerflow_assistant_id", "lead_agent"),
|
||||
model_name=provider_config.get("deerflow_model_name", ""),
|
||||
thinking_enabled=bool(
|
||||
provider_config.get("deerflow_thinking_enabled", False),
|
||||
),
|
||||
plan_mode=bool(provider_config.get("deerflow_plan_mode", False)),
|
||||
subagent_enabled=bool(
|
||||
provider_config.get("deerflow_subagent_enabled", False),
|
||||
),
|
||||
max_concurrent_subagents=coerce_int_config(
|
||||
provider_config.get("deerflow_max_concurrent_subagents", 3),
|
||||
default=3,
|
||||
min_value=1,
|
||||
field_name="deerflow_max_concurrent_subagents",
|
||||
source="DeerFlow config",
|
||||
),
|
||||
timeout=coerce_int_config(
|
||||
provider_config.get("timeout", 300),
|
||||
default=300,
|
||||
min_value=1,
|
||||
field_name="timeout",
|
||||
source="DeerFlow config",
|
||||
),
|
||||
recursion_limit=coerce_int_config(
|
||||
provider_config.get("deerflow_recursion_limit", 1000),
|
||||
default=1000,
|
||||
min_value=1,
|
||||
field_name="deerflow_recursion_limit",
|
||||
source="DeerFlow config",
|
||||
),
|
||||
)
|
||||
|
||||
async def _load_config_and_client(self, provider_config: dict) -> None:
|
||||
config = self._parse_runner_config(provider_config)
|
||||
|
||||
self.api_base = config.api_base
|
||||
self.api_key = config.api_key
|
||||
self.auth_header = config.auth_header
|
||||
self.proxy = config.proxy
|
||||
self.assistant_id = config.assistant_id
|
||||
self.model_name = config.model_name
|
||||
self.thinking_enabled = config.thinking_enabled
|
||||
self.plan_mode = config.plan_mode
|
||||
self.subagent_enabled = config.subagent_enabled
|
||||
self.max_concurrent_subagents = config.max_concurrent_subagents
|
||||
self.timeout = config.timeout
|
||||
self.recursion_limit = config.recursion_limit
|
||||
|
||||
new_client_signature = (
|
||||
config.api_base,
|
||||
config.api_key,
|
||||
config.auth_header,
|
||||
config.proxy,
|
||||
)
|
||||
old_client = getattr(self, "api_client", None)
|
||||
old_signature = getattr(self, "_api_client_signature", None)
|
||||
|
||||
if (
|
||||
isinstance(old_client, DeerFlowAPIClient)
|
||||
and old_signature == new_client_signature
|
||||
and not old_client.is_closed
|
||||
):
|
||||
self.api_client = old_client
|
||||
return
|
||||
|
||||
if isinstance(old_client, DeerFlowAPIClient):
|
||||
try:
|
||||
await old_client.close()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to close previous DeerFlow API client cleanly: {e}"
|
||||
)
|
||||
|
||||
self.api_client = DeerFlowAPIClient(
|
||||
api_base=config.api_base,
|
||||
api_key=config.api_key,
|
||||
auth_header=config.auth_header,
|
||||
proxy=config.proxy,
|
||||
)
|
||||
self._api_client_signature = new_client_signature
|
||||
|
||||
@override
|
||||
async def reset(
|
||||
self,
|
||||
request: ProviderRequest,
|
||||
run_context: ContextWrapper[TContext],
|
||||
agent_hooks: BaseAgentRunHooks[TContext],
|
||||
provider_config: dict,
|
||||
**kwargs: T.Any,
|
||||
) -> None:
|
||||
self.req = request
|
||||
self.streaming = kwargs.get("streaming", False)
|
||||
self.final_llm_resp = None
|
||||
self._state = AgentState.IDLE
|
||||
self.agent_hooks = agent_hooks
|
||||
self.run_context = run_context
|
||||
|
||||
await self._load_config_and_client(provider_config)
|
||||
|
||||
@override
|
||||
async def step(self):
|
||||
if not self.req:
|
||||
raise ValueError("Request is not set. Please call reset() first.")
|
||||
if self.done():
|
||||
return
|
||||
|
||||
if self._state == AgentState.IDLE:
|
||||
try:
|
||||
await self.agent_hooks.on_agent_begin(self.run_context)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in on_agent_begin hook: {e}", exc_info=True)
|
||||
|
||||
self._transition_state(AgentState.RUNNING)
|
||||
|
||||
try:
|
||||
async for response in self._execute_deerflow_request():
|
||||
yield response
|
||||
except asyncio.CancelledError:
|
||||
# Let caller manage cancellation semantics.
|
||||
raise
|
||||
except Exception as e:
|
||||
err_msg = self._format_exception(e)
|
||||
logger.error(f"DeerFlow request failed: {err_msg}", exc_info=True)
|
||||
yield await self._finish_with_error(err_msg)
|
||||
|
||||
@override
|
||||
async def step_until_done(
|
||||
self, max_step: int = 30
|
||||
) -> T.AsyncGenerator[AgentResponse, None]:
|
||||
if max_step <= 0:
|
||||
raise ValueError("max_step must be greater than 0")
|
||||
|
||||
step_count = 0
|
||||
while not self.done() and step_count < max_step:
|
||||
step_count += 1
|
||||
async for resp in self.step():
|
||||
yield resp
|
||||
|
||||
if not self.done():
|
||||
raise RuntimeError(
|
||||
f"DeerFlow agent reached max_step ({max_step}) without completion."
|
||||
)
|
||||
|
||||
def _extract_new_messages_from_values(
|
||||
self,
|
||||
values_messages: list[T.Any],
|
||||
state: _StreamState,
|
||||
) -> list[dict[str, T.Any]]:
|
||||
new_messages: list[dict[str, T.Any]] = []
|
||||
no_id_indexes_seen: set[int] = set()
|
||||
for idx, msg in enumerate(values_messages):
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
msg_id = get_message_id(msg)
|
||||
if msg_id:
|
||||
if msg_id in state.seen_message_ids:
|
||||
continue
|
||||
self._remember_seen_message_id(state, msg_id)
|
||||
new_messages.append(msg)
|
||||
continue
|
||||
|
||||
no_id_indexes_seen.add(idx)
|
||||
msg_fingerprint = self._fingerprint_message(msg)
|
||||
if state.no_id_message_fingerprints.get(idx) == msg_fingerprint:
|
||||
continue
|
||||
state.no_id_message_fingerprints[idx] = msg_fingerprint
|
||||
new_messages.append(msg)
|
||||
|
||||
# Keep no-id index state aligned with latest values payload shape.
|
||||
for idx in list(state.no_id_message_fingerprints.keys()):
|
||||
if idx not in no_id_indexes_seen:
|
||||
state.no_id_message_fingerprints.pop(idx, None)
|
||||
return new_messages
|
||||
|
||||
def _fingerprint_message(self, message: dict[str, T.Any]) -> str:
|
||||
try:
|
||||
raw = json.dumps(message, sort_keys=True, ensure_ascii=False, default=str)
|
||||
except (TypeError, ValueError):
|
||||
raw = repr(message)
|
||||
return hashlib.sha1(raw.encode("utf-8", errors="ignore")).hexdigest()
|
||||
|
||||
def _remember_seen_message_id(self, state: _StreamState, msg_id: str) -> None:
|
||||
if not msg_id or msg_id in state.seen_message_ids:
|
||||
return
|
||||
|
||||
state.seen_message_ids.add(msg_id)
|
||||
state.seen_message_order.append(msg_id)
|
||||
while len(state.seen_message_order) > self._MAX_VALUES_HISTORY:
|
||||
dropped = state.seen_message_order.popleft()
|
||||
state.seen_message_ids.discard(dropped)
|
||||
|
||||
async def _ensure_thread_id(self, session_id: str) -> str:
|
||||
thread_id = await sp.get_async(
|
||||
scope="umo",
|
||||
scope_id=session_id,
|
||||
key=DEERFLOW_THREAD_ID_KEY,
|
||||
default="",
|
||||
)
|
||||
if thread_id:
|
||||
return thread_id
|
||||
|
||||
thread = await self.api_client.create_thread(timeout=min(30, self.timeout))
|
||||
thread_id = thread.get("thread_id", "")
|
||||
if not thread_id:
|
||||
raise Exception(
|
||||
f"DeerFlow create thread returned invalid payload: {thread}"
|
||||
)
|
||||
|
||||
await sp.put_async(
|
||||
scope="umo",
|
||||
scope_id=session_id,
|
||||
key=DEERFLOW_THREAD_ID_KEY,
|
||||
value=thread_id,
|
||||
)
|
||||
return thread_id
|
||||
|
||||
def _build_messages(
|
||||
self,
|
||||
prompt: str,
|
||||
image_urls: list[str],
|
||||
system_prompt: str | None,
|
||||
) -> list[dict[str, T.Any]]:
|
||||
messages: list[dict[str, T.Any]] = []
|
||||
if system_prompt:
|
||||
messages.append({"role": "system", "content": system_prompt})
|
||||
messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": build_user_content(prompt, image_urls),
|
||||
},
|
||||
)
|
||||
return messages
|
||||
|
||||
def _build_runtime_context(self, thread_id: str) -> dict[str, T.Any]:
|
||||
runtime_context: dict[str, T.Any] = {
|
||||
"thread_id": thread_id,
|
||||
"thinking_enabled": self.thinking_enabled,
|
||||
"is_plan_mode": self.plan_mode,
|
||||
"subagent_enabled": self.subagent_enabled,
|
||||
}
|
||||
if self.subagent_enabled:
|
||||
runtime_context["max_concurrent_subagents"] = self.max_concurrent_subagents
|
||||
if self.model_name:
|
||||
runtime_context["model_name"] = self.model_name
|
||||
return runtime_context
|
||||
|
||||
def _build_payload(
|
||||
self,
|
||||
thread_id: str,
|
||||
prompt: str,
|
||||
image_urls: list[str],
|
||||
system_prompt: str | None,
|
||||
) -> dict[str, T.Any]:
|
||||
return {
|
||||
"assistant_id": self.assistant_id,
|
||||
"input": {
|
||||
"messages": self._build_messages(prompt, image_urls, system_prompt),
|
||||
},
|
||||
"stream_mode": ["values", "messages-tuple", "custom"],
|
||||
# LangGraph 0.6+ prefers context instead of configurable.
|
||||
"context": self._build_runtime_context(thread_id),
|
||||
"config": {
|
||||
"recursion_limit": self.recursion_limit,
|
||||
},
|
||||
}
|
||||
|
||||
def _update_text_and_maybe_stream(
|
||||
self,
|
||||
*,
|
||||
state: _StreamState,
|
||||
new_full_text: str | None = None,
|
||||
delta_text: str | None = None,
|
||||
) -> list[AgentResponse]:
|
||||
if new_full_text:
|
||||
state.latest_text = new_full_text
|
||||
if not self.streaming:
|
||||
return []
|
||||
|
||||
if new_full_text.startswith(state.prev_text_for_streaming):
|
||||
delta = new_full_text[len(state.prev_text_for_streaming) :]
|
||||
else:
|
||||
delta = new_full_text
|
||||
|
||||
if not delta:
|
||||
return []
|
||||
|
||||
state.prev_text_for_streaming = new_full_text
|
||||
return [
|
||||
AgentResponse(
|
||||
type="streaming_delta",
|
||||
data=AgentResponseData(chain=MessageChain().message(delta)),
|
||||
)
|
||||
]
|
||||
|
||||
if delta_text:
|
||||
state.latest_text += delta_text
|
||||
if self.streaming:
|
||||
return [
|
||||
AgentResponse(
|
||||
type="streaming_delta",
|
||||
data=AgentResponseData(
|
||||
chain=MessageChain().message(delta_text)
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
return []
|
||||
|
||||
def _handle_values_event(
|
||||
self,
|
||||
data: T.Any,
|
||||
state: _StreamState,
|
||||
) -> list[AgentResponse]:
|
||||
responses: list[AgentResponse] = []
|
||||
values_messages = extract_messages_from_values_data(data)
|
||||
if not values_messages:
|
||||
return responses
|
||||
|
||||
new_messages: list[dict[str, T.Any]] = []
|
||||
if not state.baseline_initialized:
|
||||
state.baseline_initialized = True
|
||||
for idx, msg in enumerate(values_messages):
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
new_messages.append(msg)
|
||||
msg_id = get_message_id(msg)
|
||||
if msg_id:
|
||||
self._remember_seen_message_id(state, msg_id)
|
||||
continue
|
||||
state.no_id_message_fingerprints[idx] = self._fingerprint_message(msg)
|
||||
else:
|
||||
new_messages = self._extract_new_messages_from_values(
|
||||
values_messages,
|
||||
state,
|
||||
)
|
||||
latest_text = ""
|
||||
if new_messages:
|
||||
state.run_values_messages.extend(new_messages)
|
||||
if len(state.run_values_messages) > self._MAX_VALUES_HISTORY:
|
||||
state.run_values_messages = state.run_values_messages[
|
||||
-self._MAX_VALUES_HISTORY :
|
||||
]
|
||||
latest_text = extract_latest_ai_text(state.run_values_messages)
|
||||
if latest_text:
|
||||
state.has_values_text = True
|
||||
latest_clarification = extract_latest_clarification_text(
|
||||
state.run_values_messages,
|
||||
)
|
||||
if latest_clarification:
|
||||
state.clarification_text = latest_clarification
|
||||
|
||||
responses.extend(
|
||||
self._update_text_and_maybe_stream(
|
||||
state=state,
|
||||
new_full_text=latest_text or None,
|
||||
)
|
||||
)
|
||||
return responses
|
||||
|
||||
def _handle_message_event(
|
||||
self,
|
||||
data: T.Any,
|
||||
state: _StreamState,
|
||||
) -> AgentResponse | None:
|
||||
delta = extract_ai_delta_from_event_data(data)
|
||||
|
||||
responses: list[AgentResponse] = []
|
||||
if delta and not state.has_values_text:
|
||||
responses.extend(
|
||||
self._update_text_and_maybe_stream(
|
||||
state=state,
|
||||
delta_text=delta,
|
||||
)
|
||||
)
|
||||
|
||||
maybe_clarification = extract_clarification_from_event_data(data)
|
||||
if maybe_clarification:
|
||||
state.clarification_text = maybe_clarification
|
||||
return responses[0] if responses else None
|
||||
|
||||
def _build_final_result(self, state: _StreamState) -> _FinalResult:
|
||||
failures_only = False
|
||||
|
||||
if state.clarification_text:
|
||||
final_chain = MessageChain(chain=[Comp.Plain(state.clarification_text)])
|
||||
else:
|
||||
final_chain = MessageChain()
|
||||
latest_ai_message = extract_latest_ai_message(state.run_values_messages)
|
||||
if latest_ai_message:
|
||||
final_chain = build_chain_from_ai_content(
|
||||
latest_ai_message.get("content"),
|
||||
image_component_from_url,
|
||||
)
|
||||
|
||||
if not final_chain.chain and state.latest_text:
|
||||
final_chain = MessageChain(chain=[Comp.Plain(state.latest_text)])
|
||||
|
||||
if not final_chain.chain:
|
||||
failure_text = build_task_failure_summary(state.task_failures)
|
||||
if failure_text:
|
||||
final_chain = MessageChain(chain=[Comp.Plain(failure_text)])
|
||||
failures_only = True
|
||||
|
||||
if not final_chain.chain:
|
||||
logger.warning("DeerFlow returned no text content in stream events.")
|
||||
final_chain = MessageChain(
|
||||
chain=[Comp.Plain("DeerFlow returned an empty response.")],
|
||||
)
|
||||
|
||||
if state.timed_out:
|
||||
timeout_note = (
|
||||
f"DeerFlow stream timed out after {self.timeout}s. "
|
||||
"Returning partial result."
|
||||
)
|
||||
if final_chain.chain and isinstance(final_chain.chain[-1], Comp.Plain):
|
||||
last_text = final_chain.chain[-1].text
|
||||
final_chain.chain[-1].text = (
|
||||
f"{last_text}\n\n{timeout_note}" if last_text else timeout_note
|
||||
)
|
||||
else:
|
||||
final_chain.chain.append(Comp.Plain(timeout_note))
|
||||
|
||||
role = "err" if (state.timed_out or failures_only) else "assistant"
|
||||
return self._FinalResult(chain=final_chain, role=role)
|
||||
|
||||
def _emit_non_plain_components_at_end(
|
||||
self,
|
||||
final_chain: MessageChain,
|
||||
) -> AgentResponse | None:
|
||||
non_plain_components = [
|
||||
component
|
||||
for component in final_chain.chain
|
||||
if not isinstance(component, Comp.Plain)
|
||||
]
|
||||
if not non_plain_components:
|
||||
return None
|
||||
return AgentResponse(
|
||||
type="streaming_delta",
|
||||
data=AgentResponseData(
|
||||
chain=MessageChain(chain=non_plain_components),
|
||||
),
|
||||
)
|
||||
|
||||
async def _execute_deerflow_request(self):
|
||||
prompt = self.req.prompt or ""
|
||||
session_id = self.req.session_id or f"{DEERFLOW_SESSION_PREFIX}-{uuid4()}"
|
||||
image_urls = self.req.image_urls or []
|
||||
system_prompt = self.req.system_prompt
|
||||
|
||||
thread_id = await self._ensure_thread_id(session_id)
|
||||
payload = self._build_payload(
|
||||
thread_id=thread_id,
|
||||
prompt=prompt,
|
||||
image_urls=image_urls,
|
||||
system_prompt=system_prompt,
|
||||
)
|
||||
state = self._StreamState()
|
||||
|
||||
try:
|
||||
async for event in self.api_client.stream_run(
|
||||
thread_id=thread_id,
|
||||
payload=payload,
|
||||
timeout=self.timeout,
|
||||
):
|
||||
event_type = event.get("event")
|
||||
data = event.get("data")
|
||||
|
||||
if event_type == "values":
|
||||
for response in self._handle_values_event(data, state):
|
||||
yield response
|
||||
continue
|
||||
|
||||
if event_type in {"messages-tuple", "messages", "message"}:
|
||||
response = self._handle_message_event(data, state)
|
||||
if response:
|
||||
yield response
|
||||
continue
|
||||
|
||||
if event_type == "custom":
|
||||
state.task_failures.extend(
|
||||
extract_task_failures_from_custom_event(data),
|
||||
)
|
||||
continue
|
||||
|
||||
if event_type == "error":
|
||||
raise Exception(f"DeerFlow stream returned error event: {data}")
|
||||
|
||||
if event_type == "end":
|
||||
break
|
||||
except (asyncio.TimeoutError, TimeoutError):
|
||||
logger.warning(
|
||||
"DeerFlow stream timed out after %ss for thread_id=%s; returning partial result.",
|
||||
self.timeout,
|
||||
thread_id,
|
||||
)
|
||||
state.timed_out = True
|
||||
|
||||
final_result = self._build_final_result(state)
|
||||
|
||||
if self.streaming:
|
||||
extra_response = self._emit_non_plain_components_at_end(final_result.chain)
|
||||
if extra_response:
|
||||
yield extra_response
|
||||
|
||||
yield await self._finish_with_result(final_result.chain, final_result.role)
|
||||
|
||||
@override
|
||||
def done(self) -> bool:
|
||||
"""Check whether the agent has finished or failed."""
|
||||
return self._state in (AgentState.DONE, AgentState.ERROR)
|
||||
|
||||
@override
|
||||
def get_final_llm_resp(self) -> LLMResponse | None:
|
||||
return self.final_llm_resp
|
||||
@@ -0,0 +1,245 @@
|
||||
import codecs
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientResponse, ClientSession, ClientTimeout
|
||||
|
||||
from astrbot.core import logger
|
||||
|
||||
SSE_MAX_BUFFER_CHARS = 1_048_576
|
||||
|
||||
|
||||
def _normalize_sse_newlines(text: str) -> str:
|
||||
"""Normalize CRLF/CR to LF so SSE block splitting works reliably."""
|
||||
return text.replace("\r\n", "\n").replace("\r", "\n")
|
||||
|
||||
|
||||
def _parse_sse_data_lines(data_lines: list[str]) -> Any:
|
||||
raw_data = "\n".join(data_lines)
|
||||
try:
|
||||
return json.loads(raw_data)
|
||||
except json.JSONDecodeError:
|
||||
# Some LangGraph-compatible servers emit multiple JSON fragments
|
||||
# in one SSE event using repeated data lines (e.g. tuple payloads).
|
||||
parsed_lines: list[Any] = []
|
||||
can_parse_all = True
|
||||
for line in data_lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
parsed_lines.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
can_parse_all = False
|
||||
break
|
||||
if can_parse_all and parsed_lines:
|
||||
return parsed_lines[0] if len(parsed_lines) == 1 else parsed_lines
|
||||
return raw_data
|
||||
|
||||
|
||||
def _parse_sse_block(block: str) -> dict[str, Any] | None:
|
||||
if not block.strip():
|
||||
return None
|
||||
|
||||
event_name = "message"
|
||||
data_lines: list[str] = []
|
||||
for line in block.splitlines():
|
||||
if line.startswith("event:"):
|
||||
event_name = line[6:].strip()
|
||||
elif line.startswith("data:"):
|
||||
data_lines.append(line[5:].lstrip())
|
||||
|
||||
if not data_lines:
|
||||
return None
|
||||
return {"event": event_name, "data": _parse_sse_data_lines(data_lines)}
|
||||
|
||||
|
||||
async def _stream_sse(resp: ClientResponse) -> AsyncGenerator[dict[str, Any], None]:
|
||||
"""Parse SSE response blocks into event/data dictionaries."""
|
||||
# Use a forgiving decoder at network boundaries so malformed bytes do not abort stream parsing.
|
||||
decoder = codecs.getincrementaldecoder("utf-8")("replace")
|
||||
buffer = ""
|
||||
|
||||
async for chunk in resp.content.iter_chunked(8192):
|
||||
buffer += _normalize_sse_newlines(decoder.decode(chunk))
|
||||
|
||||
while "\n\n" in buffer:
|
||||
block, buffer = buffer.split("\n\n", 1)
|
||||
parsed = _parse_sse_block(block)
|
||||
if parsed is not None:
|
||||
yield parsed
|
||||
|
||||
if len(buffer) > SSE_MAX_BUFFER_CHARS:
|
||||
logger.warning(
|
||||
"DeerFlow SSE parser buffer exceeded %d chars without delimiter; "
|
||||
"flushing oversized block to prevent unbounded memory growth.",
|
||||
SSE_MAX_BUFFER_CHARS,
|
||||
)
|
||||
parsed = _parse_sse_block(buffer)
|
||||
if parsed is not None:
|
||||
yield parsed
|
||||
buffer = ""
|
||||
|
||||
# flush any remaining buffered text
|
||||
buffer += _normalize_sse_newlines(decoder.decode(b"", final=True))
|
||||
while "\n\n" in buffer:
|
||||
block, buffer = buffer.split("\n\n", 1)
|
||||
parsed = _parse_sse_block(block)
|
||||
if parsed is not None:
|
||||
yield parsed
|
||||
|
||||
if buffer.strip():
|
||||
parsed = _parse_sse_block(buffer)
|
||||
if parsed is not None:
|
||||
yield parsed
|
||||
|
||||
|
||||
class DeerFlowAPIClient:
|
||||
"""HTTP client for DeerFlow LangGraph API.
|
||||
|
||||
Lifecycle is explicitly managed by callers (runner/stage). `__del__` is only a
|
||||
fallback diagnostic and must not be relied on for cleanup.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_base: str = "http://127.0.0.1:2026",
|
||||
api_key: str = "",
|
||||
auth_header: str = "",
|
||||
proxy: str | None = None,
|
||||
) -> None:
|
||||
self.api_base = api_base.rstrip("/")
|
||||
self._session: ClientSession | None = None
|
||||
self._closed = False
|
||||
self.proxy = proxy.strip() if isinstance(proxy, str) else None
|
||||
if self.proxy == "":
|
||||
self.proxy = None
|
||||
self.headers: dict[str, str] = {}
|
||||
if auth_header:
|
||||
self.headers["Authorization"] = auth_header
|
||||
elif api_key:
|
||||
self.headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
def _get_session(self) -> ClientSession:
|
||||
if self._closed:
|
||||
raise RuntimeError("DeerFlowAPIClient is already closed.")
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = ClientSession(trust_env=True)
|
||||
return self._session
|
||||
|
||||
async def __aenter__(self) -> "DeerFlowAPIClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
tb: object | None,
|
||||
) -> None:
|
||||
await self.close()
|
||||
|
||||
async def create_thread(self, timeout: float = 20) -> dict[str, Any]:
|
||||
session = self._get_session()
|
||||
url = f"{self.api_base}/api/langgraph/threads"
|
||||
payload = {"metadata": {}}
|
||||
async with session.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers=self.headers,
|
||||
timeout=timeout,
|
||||
proxy=self.proxy,
|
||||
) as resp:
|
||||
if resp.status not in (200, 201):
|
||||
text = await resp.text()
|
||||
raise Exception(
|
||||
f"DeerFlow create thread failed: {resp.status}. {text}",
|
||||
)
|
||||
return await resp.json()
|
||||
|
||||
async def stream_run(
|
||||
self,
|
||||
thread_id: str,
|
||||
payload: dict[str, Any],
|
||||
timeout: float = 120,
|
||||
) -> AsyncGenerator[dict[str, Any], None]:
|
||||
session = self._get_session()
|
||||
url = f"{self.api_base}/api/langgraph/threads/{thread_id}/runs/stream"
|
||||
input_payload = payload.get("input")
|
||||
message_count = 0
|
||||
if isinstance(input_payload, dict) and isinstance(
|
||||
input_payload.get("messages"), list
|
||||
):
|
||||
message_count = len(input_payload["messages"])
|
||||
# Log only a minimal summary to avoid exposing sensitive user content.
|
||||
logger.debug(
|
||||
"deerflow stream_run payload summary: thread_id=%s, keys=%s, message_count=%d, stream_mode=%s",
|
||||
thread_id,
|
||||
list(payload.keys()),
|
||||
message_count,
|
||||
payload.get("stream_mode"),
|
||||
)
|
||||
# For long-running SSE streams, avoid aiohttp total timeout.
|
||||
# Use socket read timeout so active heartbeats/chunks can keep the stream alive.
|
||||
stream_timeout = ClientTimeout(
|
||||
total=None,
|
||||
connect=min(timeout, 30),
|
||||
sock_connect=min(timeout, 30),
|
||||
sock_read=timeout,
|
||||
)
|
||||
async with session.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers={
|
||||
**self.headers,
|
||||
"Accept": "text/event-stream",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=stream_timeout,
|
||||
proxy=self.proxy,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
text = await resp.text()
|
||||
raise Exception(
|
||||
f"DeerFlow runs/stream request failed: {resp.status}. {text}",
|
||||
)
|
||||
async for event in _stream_sse(resp):
|
||||
yield event
|
||||
|
||||
async def close(self) -> None:
|
||||
session = self._session
|
||||
if session is None:
|
||||
self._closed = True
|
||||
return
|
||||
|
||||
if session.closed:
|
||||
self._session = None
|
||||
self._closed = True
|
||||
return
|
||||
|
||||
try:
|
||||
await session.close()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to close DeerFlowAPIClient session cleanly: %s",
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
finally:
|
||||
# Cleanup is best-effort and should not make teardown paths fail loudly.
|
||||
self._session = None
|
||||
self._closed = True
|
||||
|
||||
def __del__(self) -> None:
|
||||
session = getattr(self, "_session", None)
|
||||
closed = bool(getattr(self, "_closed", False))
|
||||
if closed or session is None or session.closed:
|
||||
return
|
||||
logger.warning(
|
||||
"DeerFlowAPIClient garbage collected with unclosed session; "
|
||||
"explicit close() should be called by runner lifecycle (or `async with`)."
|
||||
)
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool:
|
||||
return self._closed
|
||||
@@ -0,0 +1,190 @@
|
||||
import base64
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import astrbot.core.message.components as Comp
|
||||
from astrbot import logger
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
|
||||
from .deerflow_stream_utils import extract_text
|
||||
|
||||
|
||||
def is_likely_base64_image(value: str) -> bool:
|
||||
if " " in value:
|
||||
return False
|
||||
|
||||
compact = value.replace("\n", "").replace("\r", "")
|
||||
if not compact or len(compact) < 32 or len(compact) % 4 != 0:
|
||||
return False
|
||||
|
||||
base64_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="
|
||||
if any(ch not in base64_chars for ch in compact):
|
||||
return False
|
||||
try:
|
||||
base64.b64decode(compact, validate=True)
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def build_user_content(prompt: str, image_urls: list[str]) -> Any:
|
||||
if not image_urls:
|
||||
return prompt
|
||||
|
||||
content: list[dict[str, Any]] = []
|
||||
skipped_invalid_images = 0
|
||||
any_valid_image = False
|
||||
if prompt:
|
||||
content.append({"type": "text", "text": prompt})
|
||||
|
||||
for image_url in image_urls:
|
||||
url = image_url
|
||||
if not isinstance(url, str):
|
||||
skipped_invalid_images += 1
|
||||
logger.debug(
|
||||
"Skipped DeerFlow image input because value is not a string: %r",
|
||||
type(image_url).__name__,
|
||||
)
|
||||
continue
|
||||
url = url.strip()
|
||||
if not url:
|
||||
skipped_invalid_images += 1
|
||||
logger.debug("Skipped DeerFlow image input because value is empty.")
|
||||
continue
|
||||
if url.startswith(("http://", "https://", "data:")):
|
||||
content.append({"type": "image_url", "image_url": {"url": url}})
|
||||
any_valid_image = True
|
||||
continue
|
||||
if not is_likely_base64_image(url):
|
||||
skipped_invalid_images += 1
|
||||
logger.debug(
|
||||
"Skipped DeerFlow image input because it is neither URL/data URI nor valid base64."
|
||||
)
|
||||
continue
|
||||
compact_base64 = url.replace("\n", "").replace("\r", "")
|
||||
content.append(
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:image/png;base64,{compact_base64}"},
|
||||
},
|
||||
)
|
||||
any_valid_image = True
|
||||
|
||||
if skipped_invalid_images:
|
||||
note_text = (
|
||||
"Note: some images could not be processed and were ignored."
|
||||
if any_valid_image
|
||||
else "Note: none of the provided images could be processed."
|
||||
)
|
||||
content.insert(0, {"type": "text", "text": note_text})
|
||||
if not any_valid_image:
|
||||
logger.warning(
|
||||
"All %d provided DeerFlow image inputs were rejected as invalid or unsupported.",
|
||||
skipped_invalid_images,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"%d DeerFlow image input(s) were rejected as invalid or unsupported.",
|
||||
skipped_invalid_images,
|
||||
)
|
||||
logger.debug(
|
||||
"Skipped %d DeerFlow image inputs that were neither URL/data URI nor valid base64.",
|
||||
skipped_invalid_images,
|
||||
)
|
||||
return content
|
||||
|
||||
|
||||
def image_component_from_url(url: Any) -> Comp.Image | None:
|
||||
if not isinstance(url, str):
|
||||
return None
|
||||
|
||||
normalized = url.strip()
|
||||
if not normalized:
|
||||
return None
|
||||
|
||||
if normalized.startswith(("http://", "https://")):
|
||||
try:
|
||||
return Comp.Image.fromURL(normalized)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if not normalized.startswith("data:"):
|
||||
return None
|
||||
|
||||
header, sep, payload = normalized.partition(",")
|
||||
if not sep:
|
||||
return None
|
||||
if ";base64" not in header.lower():
|
||||
return None
|
||||
|
||||
compact_payload = payload.replace("\n", "").replace("\r", "").strip()
|
||||
if not compact_payload:
|
||||
return None
|
||||
try:
|
||||
base64.b64decode(compact_payload, validate=True)
|
||||
except Exception:
|
||||
return None
|
||||
return Comp.Image.fromBase64(compact_payload)
|
||||
|
||||
|
||||
def append_components_from_content(
|
||||
content: Any,
|
||||
components: list[Comp.BaseMessageComponent],
|
||||
image_resolver: Callable[[Any], Comp.Image | None],
|
||||
) -> None:
|
||||
if isinstance(content, str):
|
||||
if content:
|
||||
components.append(Comp.Plain(content))
|
||||
return
|
||||
|
||||
if isinstance(content, list):
|
||||
for item in content:
|
||||
append_components_from_content(item, components, image_resolver)
|
||||
return
|
||||
|
||||
if not isinstance(content, dict):
|
||||
return
|
||||
|
||||
item_type = str(content.get("type", "")).lower()
|
||||
if item_type == "text" and isinstance(content.get("text"), str):
|
||||
text = content["text"]
|
||||
if text:
|
||||
components.append(Comp.Plain(text))
|
||||
return
|
||||
|
||||
if item_type == "image_url":
|
||||
image_payload = content.get("image_url")
|
||||
image_url: Any = image_payload
|
||||
if isinstance(image_payload, dict):
|
||||
image_url = image_payload.get("url")
|
||||
image_comp = image_resolver(image_url)
|
||||
if image_comp is not None:
|
||||
components.append(image_comp)
|
||||
return
|
||||
|
||||
if "content" in content:
|
||||
append_components_from_content(
|
||||
content.get("content"), components, image_resolver
|
||||
)
|
||||
return
|
||||
|
||||
kwargs = content.get("kwargs")
|
||||
if isinstance(kwargs, dict) and "content" in kwargs:
|
||||
append_components_from_content(
|
||||
kwargs.get("content"), components, image_resolver
|
||||
)
|
||||
|
||||
|
||||
def build_chain_from_ai_content(
|
||||
content: Any,
|
||||
image_resolver: Callable[[Any], Comp.Image | None],
|
||||
) -> MessageChain:
|
||||
components: list[Comp.BaseMessageComponent] = []
|
||||
append_components_from_content(content, components, image_resolver)
|
||||
if components:
|
||||
return MessageChain(chain=components)
|
||||
|
||||
fallback_text = extract_text(content)
|
||||
if fallback_text:
|
||||
return MessageChain(chain=[Comp.Plain(fallback_text)])
|
||||
return MessageChain()
|
||||
@@ -0,0 +1,201 @@
|
||||
import typing as T
|
||||
from collections.abc import Iterable
|
||||
|
||||
|
||||
def extract_text(content: T.Any) -> str:
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, dict):
|
||||
if isinstance(content.get("text"), str):
|
||||
return content["text"]
|
||||
if "content" in content:
|
||||
return extract_text(content.get("content"))
|
||||
if "kwargs" in content and isinstance(content["kwargs"], dict):
|
||||
return extract_text(content["kwargs"].get("content"))
|
||||
if isinstance(content, list):
|
||||
parts: list[str] = []
|
||||
for item in content:
|
||||
if isinstance(item, str):
|
||||
parts.append(item)
|
||||
elif isinstance(item, dict):
|
||||
item_type = item.get("type")
|
||||
if item_type == "text" and isinstance(item.get("text"), str):
|
||||
parts.append(item["text"])
|
||||
elif "content" in item:
|
||||
parts.append(extract_text(item["content"]))
|
||||
return "\n".join([p for p in parts if p]).strip()
|
||||
return str(content) if content is not None else ""
|
||||
|
||||
|
||||
def extract_messages_from_values_data(data: T.Any) -> list[T.Any]:
|
||||
"""Extract messages list from possible values event payload shapes."""
|
||||
candidates: list[T.Any] = []
|
||||
if isinstance(data, dict):
|
||||
candidates.append(data)
|
||||
if isinstance(data.get("values"), dict):
|
||||
candidates.append(data["values"])
|
||||
elif isinstance(data, list):
|
||||
candidates.extend([x for x in data if isinstance(x, dict)])
|
||||
|
||||
for item in candidates:
|
||||
messages = item.get("messages")
|
||||
if isinstance(messages, list):
|
||||
return messages
|
||||
return []
|
||||
|
||||
|
||||
def is_ai_message(message: dict[str, T.Any]) -> bool:
|
||||
role = str(message.get("role", "")).lower()
|
||||
if role in {"assistant", "ai"}:
|
||||
return True
|
||||
|
||||
msg_type = str(message.get("type", "")).lower()
|
||||
if msg_type in {"ai", "assistant", "aimessage", "aimessagechunk"}:
|
||||
return True
|
||||
if "ai" in msg_type and all(
|
||||
token not in msg_type for token in ("human", "tool", "system")
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def extract_latest_ai_text(messages: Iterable[T.Any]) -> str:
|
||||
# Scan backwards to get the latest assistant/ai message text.
|
||||
if isinstance(messages, (list, tuple)):
|
||||
iterable = reversed(messages)
|
||||
else:
|
||||
# Fallback for generic iterables (e.g. generators).
|
||||
iterable = reversed(list(messages))
|
||||
|
||||
for msg in iterable:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if is_ai_message(msg):
|
||||
text = extract_text(msg.get("content"))
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
def extract_latest_ai_message(messages: Iterable[T.Any]) -> dict[str, T.Any] | None:
|
||||
if isinstance(messages, (list, tuple)):
|
||||
iterable = reversed(messages)
|
||||
else:
|
||||
iterable = reversed(list(messages))
|
||||
|
||||
for msg in iterable:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if is_ai_message(msg):
|
||||
return msg
|
||||
return None
|
||||
|
||||
|
||||
def is_clarification_tool_message(message: dict[str, T.Any]) -> bool:
|
||||
msg_type = str(message.get("type", "")).lower()
|
||||
tool_name = str(message.get("name", "")).lower()
|
||||
return msg_type == "tool" and tool_name == "ask_clarification"
|
||||
|
||||
|
||||
def extract_latest_clarification_text(messages: Iterable[T.Any]) -> str:
|
||||
if isinstance(messages, (list, tuple)):
|
||||
iterable = reversed(messages)
|
||||
else:
|
||||
iterable = reversed(list(messages))
|
||||
|
||||
for msg in iterable:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if is_clarification_tool_message(msg):
|
||||
text = extract_text(msg.get("content"))
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
def get_message_id(message: T.Any) -> str:
|
||||
if not isinstance(message, dict):
|
||||
return ""
|
||||
msg_id = message.get("id")
|
||||
return msg_id if isinstance(msg_id, str) else ""
|
||||
|
||||
|
||||
def extract_event_message_obj(data: T.Any) -> dict[str, T.Any] | None:
|
||||
msg_obj = data
|
||||
if isinstance(data, (list, tuple)) and data:
|
||||
msg_obj = data[0]
|
||||
if isinstance(msg_obj, dict) and isinstance(msg_obj.get("data"), dict):
|
||||
# Some servers wrap message body in {"data": {...}}
|
||||
msg_obj = msg_obj["data"]
|
||||
return msg_obj if isinstance(msg_obj, dict) else None
|
||||
|
||||
|
||||
def extract_ai_delta_from_event_data(data: T.Any) -> str:
|
||||
# LangGraph messages-tuple events usually carry either:
|
||||
# - {"type": "ai", "content": "..."}
|
||||
# - [message_obj, metadata]
|
||||
msg_obj = extract_event_message_obj(data)
|
||||
if not msg_obj:
|
||||
return ""
|
||||
if is_ai_message(msg_obj):
|
||||
return extract_text(msg_obj.get("content"))
|
||||
return ""
|
||||
|
||||
|
||||
def extract_clarification_from_event_data(data: T.Any) -> str:
|
||||
msg_obj = extract_event_message_obj(data)
|
||||
if not msg_obj:
|
||||
return ""
|
||||
if is_clarification_tool_message(msg_obj):
|
||||
return extract_text(msg_obj.get("content"))
|
||||
return ""
|
||||
|
||||
|
||||
def _iter_custom_event_items(data: T.Any) -> list[dict[str, T.Any]]:
|
||||
items: list[dict[str, T.Any]] = []
|
||||
if isinstance(data, dict):
|
||||
return [data]
|
||||
if isinstance(data, list):
|
||||
for item in data:
|
||||
if isinstance(item, dict):
|
||||
items.append(item)
|
||||
elif isinstance(item, (list, tuple)):
|
||||
for nested in item:
|
||||
if isinstance(nested, dict):
|
||||
items.append(nested)
|
||||
return items
|
||||
|
||||
|
||||
def extract_task_failures_from_custom_event(data: T.Any) -> list[str]:
|
||||
failures: list[str] = []
|
||||
for item in _iter_custom_event_items(data):
|
||||
event_type = str(item.get("type", "")).lower()
|
||||
if event_type not in {"task_failed", "task_timed_out"}:
|
||||
continue
|
||||
|
||||
task_id = str(item.get("task_id", "")).strip()
|
||||
error_text = extract_text(item.get("error")).strip()
|
||||
if task_id and error_text:
|
||||
failures.append(f"{task_id}: {error_text}")
|
||||
elif error_text:
|
||||
failures.append(error_text)
|
||||
elif task_id:
|
||||
failures.append(f"{task_id}: unknown error")
|
||||
else:
|
||||
failures.append("unknown task failure")
|
||||
return failures
|
||||
|
||||
|
||||
def build_task_failure_summary(failures: list[str]) -> str:
|
||||
if not failures:
|
||||
return ""
|
||||
deduped: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for failure in failures:
|
||||
if failure not in seen:
|
||||
seen.add(failure)
|
||||
deduped.append(failure)
|
||||
if len(deduped) == 1:
|
||||
return f"DeerFlow subtask failed: {deduped[0]}"
|
||||
joined = "\n".join([f"- {item}" for item in deduped[:5]])
|
||||
return f"DeerFlow subtasks failed:\n{joined}"
|
||||
@@ -23,6 +23,9 @@ from astrbot.core.message.components import Json
|
||||
from astrbot.core.message.message_event_result import (
|
||||
MessageChain,
|
||||
)
|
||||
from astrbot.core.persona_error_reply import (
|
||||
extract_persona_custom_error_message_from_event,
|
||||
)
|
||||
from astrbot.core.provider.entities import (
|
||||
LLMResponse,
|
||||
ProviderRequest,
|
||||
@@ -78,6 +81,11 @@ class FollowUpTicket:
|
||||
|
||||
|
||||
class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
def _get_persona_custom_error_message(self) -> str | None:
|
||||
"""Read persona-level custom error message from event extras when available."""
|
||||
event = getattr(self.run_context.context, "event", None)
|
||||
return extract_persona_custom_error_message_from_event(event)
|
||||
|
||||
@override
|
||||
async def reset(
|
||||
self,
|
||||
@@ -463,12 +471,14 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
self.stats.end_time = time.time()
|
||||
self._transition_state(AgentState.ERROR)
|
||||
self._resolve_unconsumed_follow_ups()
|
||||
custom_error_message = self._get_persona_custom_error_message()
|
||||
error_text = custom_error_message or (
|
||||
f"LLM 响应错误: {llm_resp.completion_text or '未知错误'}"
|
||||
)
|
||||
yield AgentResponse(
|
||||
type="err",
|
||||
data=AgentResponseData(
|
||||
chain=MessageChain().message(
|
||||
f"LLM 响应错误: {llm_resp.completion_text or '未知错误'}",
|
||||
),
|
||||
chain=MessageChain().message(error_text),
|
||||
),
|
||||
)
|
||||
return
|
||||
@@ -655,6 +665,31 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
),
|
||||
)
|
||||
|
||||
def _handle_image_content(
|
||||
base64_data: str,
|
||||
mime_type: str,
|
||||
tool_call_id: str,
|
||||
tool_name: str,
|
||||
content_index: int,
|
||||
) -> _HandleFunctionToolsResult:
|
||||
"""Helper to cache image and return result for LLM visibility."""
|
||||
cached_img = tool_image_cache.save_image(
|
||||
base64_data=base64_data,
|
||||
tool_call_id=tool_call_id,
|
||||
tool_name=tool_name,
|
||||
index=content_index,
|
||||
mime_type=mime_type,
|
||||
)
|
||||
_append_tool_call_result(
|
||||
tool_call_id,
|
||||
(
|
||||
f"Image returned and cached at path='{cached_img.file_path}'. "
|
||||
f"Review the image below. Use send_message_to_user to send it to the user if satisfied, "
|
||||
f"with type='image' and path='{cached_img.file_path}'."
|
||||
),
|
||||
)
|
||||
return _HandleFunctionToolsResult.from_cached_image(cached_img)
|
||||
|
||||
# 执行函数调用
|
||||
for func_tool_name, func_tool_args, func_tool_id in zip(
|
||||
llm_response.tools_call_name,
|
||||
@@ -748,69 +783,47 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
if isinstance(resp, CallToolResult):
|
||||
res = resp
|
||||
_final_resp = resp
|
||||
if isinstance(res.content[0], TextContent):
|
||||
_append_tool_call_result(
|
||||
func_tool_id,
|
||||
res.content[0].text,
|
||||
)
|
||||
elif isinstance(res.content[0], ImageContent):
|
||||
# Cache the image instead of sending directly
|
||||
cached_img = tool_image_cache.save_image(
|
||||
base64_data=res.content[0].data,
|
||||
tool_call_id=func_tool_id,
|
||||
tool_name=func_tool_name,
|
||||
index=0,
|
||||
mime_type=res.content[0].mimeType or "image/png",
|
||||
)
|
||||
_append_tool_call_result(
|
||||
func_tool_id,
|
||||
(
|
||||
f"Image returned and cached at path='{cached_img.file_path}'. "
|
||||
f"Review the image below. Use send_message_to_user to send it to the user if satisfied, "
|
||||
f"with type='image' and path='{cached_img.file_path}'."
|
||||
),
|
||||
)
|
||||
# Yield image info for LLM visibility (will be handled in step())
|
||||
yield _HandleFunctionToolsResult.from_cached_image(
|
||||
cached_img
|
||||
)
|
||||
elif isinstance(res.content[0], EmbeddedResource):
|
||||
resource = res.content[0].resource
|
||||
if isinstance(resource, TextResourceContents):
|
||||
# Process all content items in the result
|
||||
for content_index, content in enumerate(res.content):
|
||||
if isinstance(content, TextContent):
|
||||
_append_tool_call_result(
|
||||
func_tool_id,
|
||||
resource.text,
|
||||
content.text,
|
||||
)
|
||||
elif (
|
||||
isinstance(resource, BlobResourceContents)
|
||||
and resource.mimeType
|
||||
and resource.mimeType.startswith("image/")
|
||||
):
|
||||
elif isinstance(content, ImageContent):
|
||||
# Cache the image instead of sending directly
|
||||
cached_img = tool_image_cache.save_image(
|
||||
base64_data=resource.blob,
|
||||
yield _handle_image_content(
|
||||
base64_data=content.data,
|
||||
mime_type=content.mimeType or "image/png",
|
||||
tool_call_id=func_tool_id,
|
||||
tool_name=func_tool_name,
|
||||
index=0,
|
||||
mime_type=resource.mimeType,
|
||||
)
|
||||
_append_tool_call_result(
|
||||
func_tool_id,
|
||||
(
|
||||
f"Image returned and cached at path='{cached_img.file_path}'. "
|
||||
f"Review the image below. Use send_message_to_user to send it to the user if satisfied, "
|
||||
f"with type='image' and path='{cached_img.file_path}'."
|
||||
),
|
||||
)
|
||||
# Yield image info for LLM visibility
|
||||
yield _HandleFunctionToolsResult.from_cached_image(
|
||||
cached_img
|
||||
)
|
||||
else:
|
||||
_append_tool_call_result(
|
||||
func_tool_id,
|
||||
"The tool has returned a data type that is not supported.",
|
||||
content_index=content_index,
|
||||
)
|
||||
elif isinstance(content, EmbeddedResource):
|
||||
resource = content.resource
|
||||
if isinstance(resource, TextResourceContents):
|
||||
_append_tool_call_result(
|
||||
func_tool_id,
|
||||
resource.text,
|
||||
)
|
||||
elif (
|
||||
isinstance(resource, BlobResourceContents)
|
||||
and resource.mimeType
|
||||
and resource.mimeType.startswith("image/")
|
||||
):
|
||||
# Cache the image instead of sending directly
|
||||
yield _handle_image_content(
|
||||
base64_data=resource.blob,
|
||||
mime_type=resource.mimeType,
|
||||
tool_call_id=func_tool_id,
|
||||
tool_name=func_tool_name,
|
||||
content_index=content_index,
|
||||
)
|
||||
else:
|
||||
_append_tool_call_result(
|
||||
func_tool_id,
|
||||
"The tool has returned a data type that is not supported.",
|
||||
)
|
||||
|
||||
elif resp is None:
|
||||
# Tool 直接请求发送消息给用户
|
||||
|
||||
@@ -63,6 +63,11 @@ class FunctionTool(ToolSchema, Generic[TContext]):
|
||||
Declare this tool as a background task. Background tasks return immediately
|
||||
with a task identifier while the real work continues asynchronously.
|
||||
"""
|
||||
source: str = "plugin"
|
||||
"""
|
||||
Origin of this tool: 'plugin' (from star plugins), 'internal' (AstrBot built-in),
|
||||
or 'mcp' (from MCP servers). Used by WebUI for display grouping.
|
||||
"""
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"FuncTool(name={self.name}, parameters={self.parameters}, description={self.description})"
|
||||
@@ -101,6 +106,15 @@ class ToolSet:
|
||||
"""Remove a tool by its name."""
|
||||
self.tools = [tool for tool in self.tools if tool.name != name]
|
||||
|
||||
def normalize(self) -> None:
|
||||
"""Sort tools by name for deterministic serialization.
|
||||
|
||||
This ensures the serialized tool schema sent to the LLM is
|
||||
identical across requests regardless of registration/injection
|
||||
order, enabling LLM provider prefix cache hits.
|
||||
"""
|
||||
self.tools.sort(key=lambda t: t.name)
|
||||
|
||||
def get_tool(self, name: str) -> FunctionTool | None:
|
||||
"""Get a tool by its name."""
|
||||
for tool in self.tools:
|
||||
|
||||
@@ -14,6 +14,9 @@ from astrbot.core.message.message_event_result import (
|
||||
MessageEventResult,
|
||||
ResultContentType,
|
||||
)
|
||||
from astrbot.core.persona_error_reply import (
|
||||
extract_persona_custom_error_message_from_event,
|
||||
)
|
||||
from astrbot.core.provider.entities import LLMResponse
|
||||
from astrbot.core.provider.provider import TTSProvider
|
||||
|
||||
@@ -84,6 +87,21 @@ def _build_tool_result_status_message(
|
||||
return status_msg
|
||||
|
||||
|
||||
def _extract_final_streaming_chain(msg_chain: MessageChain) -> MessageChain | None:
|
||||
if not msg_chain.chain:
|
||||
return None
|
||||
|
||||
final_chain: list[BaseMessageComponent] = []
|
||||
for comp in msg_chain.chain:
|
||||
if isinstance(comp, Plain):
|
||||
continue
|
||||
final_chain.append(comp)
|
||||
|
||||
if not final_chain:
|
||||
return None
|
||||
return MessageChain(chain=final_chain, type=msg_chain.type)
|
||||
|
||||
|
||||
async def run_agent(
|
||||
agent_runner: AgentRunner,
|
||||
max_step: int = 30,
|
||||
@@ -208,6 +226,11 @@ async def run_agent(
|
||||
# display the reasoning content only when configured
|
||||
continue
|
||||
yield resp.data["chain"] # MessageChain
|
||||
elif resp.type == "llm_result":
|
||||
if final_chain := _extract_final_streaming_chain(
|
||||
resp.data["chain"]
|
||||
):
|
||||
yield final_chain
|
||||
if not stop_watcher.done():
|
||||
stop_watcher.cancel()
|
||||
try:
|
||||
@@ -235,7 +258,17 @@ async def run_agent(
|
||||
pass
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
err_msg = f"\n\nAstrBot 请求失败。\n错误类型: {type(e).__name__}\n错误信息: {e!s}\n\n请在平台日志查看和分享错误详情。\n"
|
||||
custom_error_message = extract_persona_custom_error_message_from_event(
|
||||
astr_event
|
||||
)
|
||||
if custom_error_message:
|
||||
err_msg = custom_error_message
|
||||
else:
|
||||
err_msg = (
|
||||
f"Error occurred during AI execution.\n"
|
||||
f"Error Type: {type(e).__name__}\n"
|
||||
f"Error Message: {str(e)}"
|
||||
)
|
||||
|
||||
error_llm_response = LLMResponse(
|
||||
role="err",
|
||||
|
||||
@@ -4,6 +4,8 @@ import json
|
||||
import traceback
|
||||
import typing as T
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Set as AbstractSet
|
||||
|
||||
import mcp
|
||||
|
||||
@@ -15,17 +17,8 @@ from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.agent.tool import FunctionTool, ToolSet
|
||||
from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
from astrbot.core.astr_main_agent_resources import (
|
||||
BACKGROUND_TASK_RESULT_WOKE_SYSTEM_PROMPT,
|
||||
EXECUTE_SHELL_TOOL,
|
||||
FILE_DOWNLOAD_TOOL,
|
||||
FILE_UPLOAD_TOOL,
|
||||
LOCAL_EXECUTE_SHELL_TOOL,
|
||||
LOCAL_PYTHON_TOOL,
|
||||
PYTHON_TOOL,
|
||||
SEND_MESSAGE_TO_USER_TOOL,
|
||||
)
|
||||
from astrbot.core.cron.events import CronMessageEvent
|
||||
from astrbot.core.message.components import Image
|
||||
from astrbot.core.message.message_event_result import (
|
||||
CommandResult,
|
||||
MessageChain,
|
||||
@@ -34,10 +27,92 @@ from astrbot.core.message.message_event_result import (
|
||||
from astrbot.core.platform.message_session import MessageSession
|
||||
from astrbot.core.provider.entites import ProviderRequest
|
||||
from astrbot.core.provider.register import llm_tools
|
||||
from astrbot.core.tools.prompts import (
|
||||
BACKGROUND_TASK_RESULT_WOKE_SYSTEM_PROMPT,
|
||||
BACKGROUND_TASK_WOKE_USER_PROMPT,
|
||||
CONVERSATION_HISTORY_INJECT_PREFIX,
|
||||
)
|
||||
from astrbot.core.tools.send_message import SEND_MESSAGE_TO_USER_TOOL
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
from astrbot.core.utils.history_saver import persist_agent_history
|
||||
from astrbot.core.utils.image_ref_utils import is_supported_image_ref
|
||||
from astrbot.core.utils.string_utils import normalize_and_dedupe_strings
|
||||
|
||||
|
||||
class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
@classmethod
|
||||
def _collect_image_urls_from_args(cls, image_urls_raw: T.Any) -> list[str]:
|
||||
if image_urls_raw is None:
|
||||
return []
|
||||
|
||||
if isinstance(image_urls_raw, str):
|
||||
return [image_urls_raw]
|
||||
|
||||
if isinstance(image_urls_raw, (Sequence, AbstractSet)) and not isinstance(
|
||||
image_urls_raw, (str, bytes, bytearray)
|
||||
):
|
||||
return [item for item in image_urls_raw if isinstance(item, str)]
|
||||
|
||||
logger.debug(
|
||||
"Unsupported image_urls type in handoff tool args: %s",
|
||||
type(image_urls_raw).__name__,
|
||||
)
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
async def _collect_image_urls_from_message(
|
||||
cls, run_context: ContextWrapper[AstrAgentContext]
|
||||
) -> list[str]:
|
||||
urls: list[str] = []
|
||||
event = getattr(run_context.context, "event", None)
|
||||
message_obj = getattr(event, "message_obj", None)
|
||||
message = getattr(message_obj, "message", None)
|
||||
if message:
|
||||
for idx, component in enumerate(message):
|
||||
if not isinstance(component, Image):
|
||||
continue
|
||||
try:
|
||||
path = await component.convert_to_file_path()
|
||||
if path:
|
||||
urls.append(path)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to convert handoff image component at index %d: %s",
|
||||
idx,
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
return urls
|
||||
|
||||
@classmethod
|
||||
async def _collect_handoff_image_urls(
|
||||
cls,
|
||||
run_context: ContextWrapper[AstrAgentContext],
|
||||
image_urls_raw: T.Any,
|
||||
) -> list[str]:
|
||||
candidates: list[str] = []
|
||||
candidates.extend(cls._collect_image_urls_from_args(image_urls_raw))
|
||||
candidates.extend(await cls._collect_image_urls_from_message(run_context))
|
||||
|
||||
normalized = normalize_and_dedupe_strings(candidates)
|
||||
extensionless_local_roots = (get_astrbot_temp_path(),)
|
||||
sanitized = [
|
||||
item
|
||||
for item in normalized
|
||||
if is_supported_image_ref(
|
||||
item,
|
||||
allow_extensionless_existing_local_file=True,
|
||||
extensionless_local_roots=extensionless_local_roots,
|
||||
)
|
||||
]
|
||||
dropped_count = len(normalized) - len(sanitized)
|
||||
if dropped_count > 0:
|
||||
logger.debug(
|
||||
"Dropped %d invalid image_urls entries in handoff image inputs.",
|
||||
dropped_count,
|
||||
)
|
||||
return sanitized
|
||||
|
||||
@classmethod
|
||||
async def execute(cls, tool, run_context, **tool_args):
|
||||
"""执行函数调用。
|
||||
@@ -93,25 +168,90 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
|
||||
return
|
||||
else:
|
||||
# Guard: reject sandbox tools whose capability is unavailable.
|
||||
# Tools are always injected (for schema stability / prefix caching),
|
||||
# but execution is blocked when the sandbox lacks the capability.
|
||||
rejection = cls._check_sandbox_capability(tool, run_context)
|
||||
if rejection is not None:
|
||||
yield rejection
|
||||
return
|
||||
|
||||
async for r in cls._execute_local(tool, run_context, **tool_args):
|
||||
yield r
|
||||
return
|
||||
|
||||
# Browser tool names that require the "browser" sandbox capability.
|
||||
_BROWSER_TOOL_NAMES: frozenset[str] = frozenset(
|
||||
{
|
||||
"astrbot_execute_browser",
|
||||
"astrbot_execute_browser_batch",
|
||||
"astrbot_run_browser_skill",
|
||||
}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _get_runtime_computer_tools(cls, runtime: str) -> dict[str, FunctionTool]:
|
||||
if runtime == "sandbox":
|
||||
return {
|
||||
EXECUTE_SHELL_TOOL.name: EXECUTE_SHELL_TOOL,
|
||||
PYTHON_TOOL.name: PYTHON_TOOL,
|
||||
FILE_UPLOAD_TOOL.name: FILE_UPLOAD_TOOL,
|
||||
FILE_DOWNLOAD_TOOL.name: FILE_DOWNLOAD_TOOL,
|
||||
}
|
||||
if runtime == "local":
|
||||
return {
|
||||
LOCAL_EXECUTE_SHELL_TOOL.name: LOCAL_EXECUTE_SHELL_TOOL,
|
||||
LOCAL_PYTHON_TOOL.name: LOCAL_PYTHON_TOOL,
|
||||
}
|
||||
return {}
|
||||
def _check_sandbox_capability(
|
||||
cls,
|
||||
tool: FunctionTool,
|
||||
run_context: ContextWrapper[AstrAgentContext],
|
||||
) -> mcp.types.CallToolResult | None:
|
||||
"""Return a rejection result if the tool requires a sandbox capability
|
||||
that is not available, or None if the tool may proceed."""
|
||||
if tool.name not in cls._BROWSER_TOOL_NAMES:
|
||||
return None
|
||||
|
||||
from astrbot.core.computer.computer_client import get_sandbox_capabilities
|
||||
|
||||
session_id = run_context.context.event.unified_msg_origin
|
||||
caps = get_sandbox_capabilities(session_id)
|
||||
|
||||
# Sandbox not yet booted — allow through (boot will happen on first
|
||||
# shell/python call; browser tools will fail naturally if truly unavailable).
|
||||
if caps is None:
|
||||
return None
|
||||
|
||||
if "browser" not in caps:
|
||||
msg = (
|
||||
f"Tool '{tool.name}' requires browser capability, but the current "
|
||||
f"sandbox profile does not include it (capabilities: {list(caps)}). "
|
||||
"Please ask the administrator to switch to a sandbox profile with "
|
||||
"browser support, or use shell/python tools instead."
|
||||
)
|
||||
logger.warning(
|
||||
"[ToolExec] capability_rejected tool=%s caps=%s", tool.name, list(caps)
|
||||
)
|
||||
return mcp.types.CallToolResult(
|
||||
content=[mcp.types.TextContent(type="text", text=msg)],
|
||||
isError=True,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _get_runtime_computer_tools(
|
||||
cls,
|
||||
runtime: str,
|
||||
sandbox_cfg: dict | None = None,
|
||||
session_id: str = "",
|
||||
) -> dict[str, FunctionTool]:
|
||||
from astrbot.core.computer.computer_tool_provider import ComputerToolProvider
|
||||
from astrbot.core.tool_provider import ToolProviderContext
|
||||
|
||||
provider = ComputerToolProvider()
|
||||
ctx = ToolProviderContext(
|
||||
computer_use_runtime=runtime,
|
||||
sandbox_cfg=sandbox_cfg,
|
||||
session_id=session_id,
|
||||
)
|
||||
tools = provider.get_tools(ctx)
|
||||
result = {tool.name: tool for tool in tools}
|
||||
logger.info(
|
||||
"[Computer] sandbox_tool_binding target=subagent runtime=%s tools=%d session=%s",
|
||||
runtime,
|
||||
len(result),
|
||||
session_id,
|
||||
)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def _build_handoff_toolset(
|
||||
@@ -124,7 +264,12 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
cfg = ctx.get_config(umo=event.unified_msg_origin)
|
||||
provider_settings = cfg.get("provider_settings", {})
|
||||
runtime = str(provider_settings.get("computer_use_runtime", "local"))
|
||||
runtime_computer_tools = cls._get_runtime_computer_tools(runtime)
|
||||
sandbox_cfg = provider_settings.get("sandbox", {})
|
||||
runtime_computer_tools = cls._get_runtime_computer_tools(
|
||||
runtime,
|
||||
sandbox_cfg=sandbox_cfg,
|
||||
session_id=event.unified_msg_origin,
|
||||
)
|
||||
|
||||
# Keep persona semantics aligned with the main agent: tools=None means
|
||||
# "all tools", including runtime computer-use tools.
|
||||
@@ -161,10 +306,28 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
cls,
|
||||
tool: HandoffTool,
|
||||
run_context: ContextWrapper[AstrAgentContext],
|
||||
**tool_args,
|
||||
*,
|
||||
image_urls_prepared: bool = False,
|
||||
**tool_args: T.Any,
|
||||
):
|
||||
tool_args = dict(tool_args)
|
||||
input_ = tool_args.get("input")
|
||||
image_urls = tool_args.get("image_urls")
|
||||
if image_urls_prepared:
|
||||
prepared_image_urls = tool_args.get("image_urls")
|
||||
if isinstance(prepared_image_urls, list):
|
||||
image_urls = prepared_image_urls
|
||||
else:
|
||||
logger.debug(
|
||||
"Expected prepared handoff image_urls as list[str], got %s.",
|
||||
type(prepared_image_urls).__name__,
|
||||
)
|
||||
image_urls = []
|
||||
else:
|
||||
image_urls = await cls._collect_handoff_image_urls(
|
||||
run_context,
|
||||
tool_args.get("image_urls"),
|
||||
)
|
||||
tool_args["image_urls"] = image_urls
|
||||
|
||||
# Build handoff toolset from registered tools plus runtime computer tools.
|
||||
toolset = cls._build_handoff_toolset(run_context, tool.agent.tools)
|
||||
@@ -194,6 +357,9 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
prov_settings: dict = ctx.get_config(umo=umo).get("provider_settings", {})
|
||||
agent_max_step = int(prov_settings.get("max_agent_step", 30))
|
||||
stream = prov_settings.get("streaming_response", False)
|
||||
llm_resp = await ctx.tool_loop_agent(
|
||||
event=event,
|
||||
chat_provider_id=prov_id,
|
||||
@@ -202,9 +368,8 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
system_prompt=tool.agent.instructions,
|
||||
tools=toolset,
|
||||
contexts=contexts,
|
||||
max_steps=30,
|
||||
run_hooks=tool.agent.run_hooks,
|
||||
stream=ctx.get_config().get("provider_settings", {}).get("stream", False),
|
||||
max_steps=agent_max_step,
|
||||
stream=stream,
|
||||
)
|
||||
yield mcp.types.CallToolResult(
|
||||
content=[mcp.types.TextContent(type="text", text=llm_resp.completion_text)]
|
||||
@@ -247,7 +412,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
type="text",
|
||||
text=(
|
||||
f"Background task dedicated to subagent '{tool.agent.name}' submitted. task_id={task_id}. "
|
||||
f"The subagent '{tool.agent.name}' is working on the task on hehalf you. "
|
||||
f"The subagent '{tool.agent.name}' is working on the task on behalf of you. "
|
||||
f"You will be notified when it finishes."
|
||||
),
|
||||
)
|
||||
@@ -263,8 +428,18 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
) -> None:
|
||||
"""Run the subagent handoff and, on completion, wake the main agent."""
|
||||
result_text = ""
|
||||
tool_args = dict(tool_args)
|
||||
tool_args["image_urls"] = await cls._collect_handoff_image_urls(
|
||||
run_context,
|
||||
tool_args.get("image_urls"),
|
||||
)
|
||||
try:
|
||||
async for r in cls._execute_handoff(tool, run_context, **tool_args):
|
||||
async for r in cls._execute_handoff(
|
||||
tool,
|
||||
run_context,
|
||||
image_urls_prepared=True,
|
||||
**tool_args,
|
||||
):
|
||||
if isinstance(r, mcp.types.CallToolResult):
|
||||
for content in r.content:
|
||||
if isinstance(content, mcp.types.TextContent):
|
||||
@@ -371,11 +546,14 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
message_type=session.message_type,
|
||||
)
|
||||
cron_event.role = event.role
|
||||
from astrbot.core.computer.computer_tool_provider import ComputerToolProvider
|
||||
|
||||
config = MainAgentBuildConfig(
|
||||
tool_call_timeout=3600,
|
||||
streaming_response=ctx.get_config()
|
||||
.get("provider_settings", {})
|
||||
.get("stream", False),
|
||||
tool_providers=[ComputerToolProvider()],
|
||||
)
|
||||
|
||||
req = ProviderRequest()
|
||||
@@ -386,23 +564,13 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
req.contexts = context
|
||||
context_dump = req._print_friendly_context()
|
||||
req.contexts = []
|
||||
req.system_prompt += (
|
||||
"\n\nBellow is you and user previous conversation history:\n"
|
||||
f"{context_dump}"
|
||||
)
|
||||
req.system_prompt += CONVERSATION_HISTORY_INJECT_PREFIX + context_dump
|
||||
|
||||
bg = json.dumps(extras["background_task_result"], ensure_ascii=False)
|
||||
req.system_prompt += BACKGROUND_TASK_RESULT_WOKE_SYSTEM_PROMPT.format(
|
||||
background_task_result=bg
|
||||
)
|
||||
req.prompt = (
|
||||
"Proceed according to your system instructions. "
|
||||
"Output using same language as previous conversation. "
|
||||
"If you need to deliver the result to the user immediately, "
|
||||
"you MUST use `send_message_to_user` tool to send the message directly to the user, "
|
||||
"otherwise the user will not see the result. "
|
||||
"After completing your task, summarize and output your actions and results. "
|
||||
)
|
||||
req.prompt = BACKGROUND_TASK_WOKE_USER_PROMPT
|
||||
if not req.func_tool:
|
||||
req.func_tool = ToolSet()
|
||||
req.func_tool.add_tool(SEND_MESSAGE_TO_USER_TOOL)
|
||||
|
||||
+133
-93
@@ -9,7 +9,7 @@ import zoneinfo
|
||||
from collections.abc import Coroutine
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from astrbot.core import logger
|
||||
from astrbot.core import logger, sp
|
||||
from astrbot.core.agent.handoff import HandoffTool
|
||||
from astrbot.core.agent.mcp_client import MCPTool
|
||||
from astrbot.core.agent.message import TextPart
|
||||
@@ -18,36 +18,36 @@ from astrbot.core.astr_agent_context import AgentContextWrapper, AstrAgentContex
|
||||
from astrbot.core.astr_agent_hooks import MAIN_AGENT_HOOKS
|
||||
from astrbot.core.astr_agent_run_util import AgentRunner
|
||||
from astrbot.core.astr_agent_tool_exec import FunctionToolExecutor
|
||||
from astrbot.core.astr_main_agent_resources import (
|
||||
CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT,
|
||||
EXECUTE_SHELL_TOOL,
|
||||
FILE_DOWNLOAD_TOOL,
|
||||
FILE_UPLOAD_TOOL,
|
||||
KNOWLEDGE_BASE_QUERY_TOOL,
|
||||
LIVE_MODE_SYSTEM_PROMPT,
|
||||
LLM_SAFETY_MODE_SYSTEM_PROMPT,
|
||||
LOCAL_EXECUTE_SHELL_TOOL,
|
||||
LOCAL_PYTHON_TOOL,
|
||||
PYTHON_TOOL,
|
||||
SANDBOX_MODE_PROMPT,
|
||||
SEND_MESSAGE_TO_USER_TOOL,
|
||||
TOOL_CALL_PROMPT,
|
||||
TOOL_CALL_PROMPT_SKILLS_LIKE_MODE,
|
||||
retrieve_knowledge_base,
|
||||
)
|
||||
from astrbot.core.conversation_mgr import Conversation
|
||||
from astrbot.core.message.components import File, Image, Reply
|
||||
from astrbot.core.persona_error_reply import (
|
||||
extract_persona_custom_error_message_from_persona,
|
||||
set_persona_custom_error_message_on_event,
|
||||
)
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from astrbot.core.provider import Provider
|
||||
from astrbot.core.provider.entities import ProviderRequest
|
||||
from astrbot.core.skills.skill_manager import SkillManager, build_skills_prompt
|
||||
from astrbot.core.star.context import Context
|
||||
from astrbot.core.star.star_handler import star_map
|
||||
from astrbot.core.tools.cron_tools import (
|
||||
CREATE_CRON_JOB_TOOL,
|
||||
DELETE_CRON_JOB_TOOL,
|
||||
LIST_CRON_JOBS_TOOL,
|
||||
from astrbot.core.tool_provider import ToolProvider, ToolProviderContext
|
||||
from astrbot.core.tools.kb_query import (
|
||||
KNOWLEDGE_BASE_QUERY_TOOL,
|
||||
retrieve_knowledge_base,
|
||||
)
|
||||
from astrbot.core.tools.prompts import (
|
||||
CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT,
|
||||
COMPUTER_USE_DISABLED_PROMPT,
|
||||
FILE_EXTRACT_CONTEXT_TEMPLATE,
|
||||
IMAGE_CAPTION_DEFAULT_PROMPT,
|
||||
LIVE_MODE_SYSTEM_PROMPT,
|
||||
LLM_SAFETY_MODE_SYSTEM_PROMPT,
|
||||
TOOL_CALL_PROMPT,
|
||||
TOOL_CALL_PROMPT_SKILLS_LIKE_MODE,
|
||||
WEBCHAT_TITLE_GENERATOR_SYSTEM_PROMPT,
|
||||
WEBCHAT_TITLE_GENERATOR_USER_PROMPT,
|
||||
)
|
||||
from astrbot.core.tools.send_message import SEND_MESSAGE_TO_USER_TOOL
|
||||
from astrbot.core.utils.file_extract import extract_file_moonshotai
|
||||
from astrbot.core.utils.llm_metadata import LLM_METADATAS
|
||||
from astrbot.core.utils.quoted_message.settings import (
|
||||
@@ -112,6 +112,9 @@ class MainAgentBuildConfig:
|
||||
computer_use_runtime: str = "local"
|
||||
"""The runtime for agent computer use: none, local, or sandbox."""
|
||||
sandbox_cfg: dict = field(default_factory=dict)
|
||||
tool_providers: list[ToolProvider] = field(default_factory=list)
|
||||
"""Decoupled tool providers injected by the caller.
|
||||
Each provider is queried for tools and system-prompt addons at build time."""
|
||||
add_cron_tools: bool = True
|
||||
"""This will add cron job management tools to the main agent for proactive cron job execution."""
|
||||
provider_settings: dict = field(default_factory=dict)
|
||||
@@ -238,9 +241,9 @@ async def _apply_file_extract(
|
||||
req.contexts.append(
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"File Extract Results of user uploaded files:\n"
|
||||
f"{file_content}\nFile Name: {file_name or 'Unknown'}"
|
||||
"content": FILE_EXTRACT_CONTEXT_TEMPLATE.format(
|
||||
file_content=file_content,
|
||||
file_name=file_name or "Unknown",
|
||||
),
|
||||
},
|
||||
)
|
||||
@@ -256,11 +259,8 @@ def _apply_prompt_prefix(req: ProviderRequest, cfg: dict) -> None:
|
||||
req.prompt = f"{prefix}{req.prompt}"
|
||||
|
||||
|
||||
def _apply_local_env_tools(req: ProviderRequest) -> None:
|
||||
if req.func_tool is None:
|
||||
req.func_tool = ToolSet()
|
||||
req.func_tool.add_tool(LOCAL_EXECUTE_SHELL_TOOL)
|
||||
req.func_tool.add_tool(LOCAL_PYTHON_TOOL)
|
||||
# Computer-use tools are now provided by ComputerToolProvider.
|
||||
# See astrbot.core.computer.computer_tool_provider for details.
|
||||
|
||||
|
||||
async def _ensure_persona_and_skills(
|
||||
@@ -285,6 +285,10 @@ async def _ensure_persona_and_skills(
|
||||
provider_settings=cfg,
|
||||
)
|
||||
|
||||
set_persona_custom_error_message_on_event(
|
||||
event, extract_persona_custom_error_message_from_persona(persona)
|
||||
)
|
||||
|
||||
if persona:
|
||||
# Inject persona system prompt
|
||||
if prompt := persona["prompt"]:
|
||||
@@ -309,11 +313,7 @@ async def _ensure_persona_and_skills(
|
||||
if skills:
|
||||
req.system_prompt += f"\n{build_skills_prompt(skills)}\n"
|
||||
if runtime == "none":
|
||||
req.system_prompt += (
|
||||
"User has not enabled the Computer Use feature. "
|
||||
"You cannot use shell or Python to perform skills. "
|
||||
"If you need to use these capabilities, ask the user to enable Computer Use in the AstrBot WebUI -> Config."
|
||||
)
|
||||
req.system_prompt += COMPUTER_USE_DISABLED_PROMPT
|
||||
tmgr = plugin_context.get_llm_tool_manager()
|
||||
|
||||
# inject toolset in the persona
|
||||
@@ -351,14 +351,9 @@ async def _ensure_persona_and_skills(
|
||||
persona_tools = None
|
||||
pid = a.get("persona_id")
|
||||
if pid:
|
||||
persona_tools = next(
|
||||
(
|
||||
p.get("tools")
|
||||
for p in plugin_context.persona_manager.personas_v3
|
||||
if p["name"] == pid
|
||||
),
|
||||
None,
|
||||
)
|
||||
persona = plugin_context.persona_manager.get_persona_v3_by_id(pid)
|
||||
if persona is not None:
|
||||
persona_tools = persona.get("tools")
|
||||
tools = a.get("tools", [])
|
||||
if persona_tools is not None:
|
||||
tools = persona_tools
|
||||
@@ -428,7 +423,7 @@ async def _request_img_caption(
|
||||
|
||||
img_cap_prompt = cfg.get(
|
||||
"image_caption_prompt",
|
||||
"Please describe the image.",
|
||||
IMAGE_CAPTION_DEFAULT_PROMPT,
|
||||
)
|
||||
logger.debug("Processing image caption with provider: %s", provider_id)
|
||||
llm_resp = await prov.text_chat(
|
||||
@@ -522,7 +517,7 @@ async def _process_quote_message(
|
||||
|
||||
if prov and isinstance(prov, Provider):
|
||||
llm_resp = await prov.text_chat(
|
||||
prompt="Please describe the image content.",
|
||||
prompt=IMAGE_CAPTION_DEFAULT_PROMPT,
|
||||
image_urls=[await image_seg.convert_to_file_path()],
|
||||
)
|
||||
if llm_resp.completion_text:
|
||||
@@ -724,6 +719,38 @@ def _sanitize_context_by_modalities(
|
||||
req.contexts = sanitized_contexts
|
||||
|
||||
|
||||
def _model_outputs_image(provider: Provider, req: ProviderRequest) -> bool:
|
||||
model = req.model or provider.get_model()
|
||||
if not model:
|
||||
return False
|
||||
model_info = LLM_METADATAS.get(model)
|
||||
if not model_info:
|
||||
return False
|
||||
output_modalities = model_info.get("modalities", {}).get("output", [])
|
||||
return "image" in output_modalities
|
||||
|
||||
|
||||
def _should_disable_streaming_for_webchat_output(
|
||||
event: AstrMessageEvent,
|
||||
provider: Provider,
|
||||
req: ProviderRequest,
|
||||
) -> bool:
|
||||
if event.get_platform_name() != "webchat":
|
||||
return False
|
||||
|
||||
provider_cfg = provider.provider_config
|
||||
provider_type = provider_cfg.get("type", "")
|
||||
if provider_type == "googlegenai_chat_completion" and provider_cfg.get(
|
||||
"gm_resp_image_modal", False
|
||||
):
|
||||
return True
|
||||
|
||||
if _model_outputs_image(provider, req):
|
||||
return not bool(provider_cfg.get("supports_streaming_output_modalities", False))
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _plugin_tool_fix(event: AstrMessageEvent, req: ProviderRequest) -> None:
|
||||
"""根据事件中的插件设置,过滤请求中的工具列表。
|
||||
|
||||
@@ -739,9 +766,14 @@ def _plugin_tool_fix(event: AstrMessageEvent, req: ProviderRequest) -> None:
|
||||
continue
|
||||
mp = tool.handler_module_path
|
||||
if not mp:
|
||||
# 没有 plugin 归属信息的工具(如 subagent transfer_to_*)
|
||||
# 不应受到会话插件过滤影响。
|
||||
new_tool_set.add_tool(tool)
|
||||
continue
|
||||
plugin = star_map.get(mp)
|
||||
if not plugin:
|
||||
# 无法解析插件归属时,保守保留工具,避免误过滤。
|
||||
new_tool_set.add_tool(tool)
|
||||
continue
|
||||
if plugin.name in event.plugins_name or plugin.reserved:
|
||||
new_tool_set.add_tool(tool)
|
||||
@@ -760,17 +792,18 @@ async def _handle_webchat(
|
||||
if not user_prompt or not chatui_session_id or not session or session.display_name:
|
||||
return
|
||||
|
||||
llm_resp = await prov.text_chat(
|
||||
system_prompt=(
|
||||
"You are a conversation title generator. "
|
||||
"Generate a concise title in the same language as the user’s input, "
|
||||
"no more than 10 words, capturing only the core topic."
|
||||
"If the input is a greeting, small talk, or has no clear topic, "
|
||||
"(e.g., “hi”, “hello”, “haha”), return <None>. "
|
||||
"Output only the title itself or <None>, with no explanations."
|
||||
),
|
||||
prompt=f"Generate a concise title for the following user query:\n{user_prompt}",
|
||||
)
|
||||
try:
|
||||
llm_resp = await prov.text_chat(
|
||||
system_prompt=WEBCHAT_TITLE_GENERATOR_SYSTEM_PROMPT,
|
||||
prompt=WEBCHAT_TITLE_GENERATOR_USER_PROMPT.format(user_prompt=user_prompt),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"Failed to generate webchat title for session %s: %s",
|
||||
chatui_session_id,
|
||||
e,
|
||||
)
|
||||
return
|
||||
if llm_resp and llm_resp.completion_text:
|
||||
title = llm_resp.completion_text.strip()
|
||||
if not title or "<None>" in title:
|
||||
@@ -786,9 +819,7 @@ async def _handle_webchat(
|
||||
|
||||
def _apply_llm_safety_mode(config: MainAgentBuildConfig, req: ProviderRequest) -> None:
|
||||
if config.safety_mode_strategy == "system_prompt":
|
||||
req.system_prompt = (
|
||||
f"{LLM_SAFETY_MODE_SYSTEM_PROMPT}\n\n{req.system_prompt or ''}"
|
||||
)
|
||||
req.system_prompt = f"{LLM_SAFETY_MODE_SYSTEM_PROMPT}\n\n{req.system_prompt}"
|
||||
else:
|
||||
logger.warning(
|
||||
"Unsupported llm_safety_mode strategy: %s.",
|
||||
@@ -796,32 +827,8 @@ def _apply_llm_safety_mode(config: MainAgentBuildConfig, req: ProviderRequest) -
|
||||
)
|
||||
|
||||
|
||||
def _apply_sandbox_tools(
|
||||
config: MainAgentBuildConfig, req: ProviderRequest, session_id: str
|
||||
) -> None:
|
||||
if req.func_tool is None:
|
||||
req.func_tool = ToolSet()
|
||||
if config.sandbox_cfg.get("booter") == "shipyard":
|
||||
ep = config.sandbox_cfg.get("shipyard_endpoint", "")
|
||||
at = config.sandbox_cfg.get("shipyard_access_token", "")
|
||||
if not ep or not at:
|
||||
logger.error("Shipyard sandbox configuration is incomplete.")
|
||||
return
|
||||
os.environ["SHIPYARD_ENDPOINT"] = ep
|
||||
os.environ["SHIPYARD_ACCESS_TOKEN"] = at
|
||||
req.func_tool.add_tool(EXECUTE_SHELL_TOOL)
|
||||
req.func_tool.add_tool(PYTHON_TOOL)
|
||||
req.func_tool.add_tool(FILE_UPLOAD_TOOL)
|
||||
req.func_tool.add_tool(FILE_DOWNLOAD_TOOL)
|
||||
req.system_prompt += f"\n{SANDBOX_MODE_PROMPT}\n"
|
||||
|
||||
|
||||
def _proactive_cron_job_tools(req: ProviderRequest) -> None:
|
||||
if req.func_tool is None:
|
||||
req.func_tool = ToolSet()
|
||||
req.func_tool.add_tool(CREATE_CRON_JOB_TOOL)
|
||||
req.func_tool.add_tool(DELETE_CRON_JOB_TOOL)
|
||||
req.func_tool.add_tool(LIST_CRON_JOBS_TOOL)
|
||||
# _apply_sandbox_tools has been moved to ComputerToolProvider.
|
||||
# See astrbot.core.computer.computer_tool_provider for details.
|
||||
|
||||
|
||||
def _get_compress_provider(
|
||||
@@ -1048,10 +1055,31 @@ async def build_main_agent(
|
||||
if config.llm_safety_mode:
|
||||
_apply_llm_safety_mode(config, req)
|
||||
|
||||
if config.computer_use_runtime == "sandbox":
|
||||
_apply_sandbox_tools(config, req, req.session_id)
|
||||
elif config.computer_use_runtime == "local":
|
||||
_apply_local_env_tools(req)
|
||||
# Decoupled tool providers — each provider injects its tools and prompt addons
|
||||
if config.tool_providers:
|
||||
_provider_ctx = ToolProviderContext(
|
||||
computer_use_runtime=config.computer_use_runtime,
|
||||
sandbox_cfg=config.sandbox_cfg,
|
||||
session_id=req.session_id or "",
|
||||
)
|
||||
# Respect WebUI tool enable/disable settings.
|
||||
# Internal tools (source='internal') bypass this check — they are
|
||||
# not user-togglable in the WebUI, so legacy entries must not block them.
|
||||
_inactivated: set[str] = set(
|
||||
sp.get("inactivated_llm_tools", [], scope="global", scope_id="global")
|
||||
)
|
||||
for _tp in config.tool_providers:
|
||||
_tp_tools = _tp.get_tools(_provider_ctx)
|
||||
if _tp_tools:
|
||||
if req.func_tool is None:
|
||||
req.func_tool = ToolSet()
|
||||
for _tool in _tp_tools:
|
||||
is_internal = getattr(_tool, "source", "") == "internal"
|
||||
if is_internal or _tool.name not in _inactivated:
|
||||
req.func_tool.add_tool(_tool)
|
||||
_tp_addon = _tp.get_system_prompt_addon(_provider_ctx)
|
||||
if _tp_addon:
|
||||
req.system_prompt = f"{req.system_prompt or ''}{_tp_addon}"
|
||||
|
||||
agent_runner = AgentRunner()
|
||||
astr_agent_ctx = AstrAgentContext(
|
||||
@@ -1059,9 +1087,6 @@ async def build_main_agent(
|
||||
event=event,
|
||||
)
|
||||
|
||||
if config.add_cron_tools:
|
||||
_proactive_cron_job_tools(req)
|
||||
|
||||
if event.platform_meta.support_proactive_message:
|
||||
if req.func_tool is None:
|
||||
req.func_tool = ToolSet()
|
||||
@@ -1078,6 +1103,10 @@ async def build_main_agent(
|
||||
asyncio.create_task(_handle_webchat(event, req, provider))
|
||||
|
||||
if req.func_tool and req.func_tool.tools:
|
||||
# Sort tools by name for deterministic serialization so that
|
||||
# LLM provider prefix caching can match across requests.
|
||||
req.func_tool.normalize()
|
||||
|
||||
tool_prompt = (
|
||||
TOOL_CALL_PROMPT
|
||||
if config.tool_schema_mode == "full"
|
||||
@@ -1089,6 +1118,17 @@ async def build_main_agent(
|
||||
if action_type == "live":
|
||||
req.system_prompt += f"\n{LIVE_MODE_SYSTEM_PROMPT}\n"
|
||||
|
||||
streaming_response = config.streaming_response
|
||||
if streaming_response and _should_disable_streaming_for_webchat_output(
|
||||
event, provider, req
|
||||
):
|
||||
logger.info(
|
||||
"Disable streaming for webchat direct media output. provider=%s model=%s",
|
||||
provider.provider_config.get("id", "unknown"),
|
||||
req.model or provider.get_model(),
|
||||
)
|
||||
streaming_response = False
|
||||
|
||||
reset_coro = agent_runner.reset(
|
||||
provider=provider,
|
||||
request=req,
|
||||
@@ -1098,7 +1138,7 @@ async def build_main_agent(
|
||||
),
|
||||
tool_executor=FunctionToolExecutor(),
|
||||
agent_hooks=MAIN_AGENT_HOOKS,
|
||||
streaming=config.streaming_response,
|
||||
streaming=streaming_response,
|
||||
llm_compress_instruction=config.llm_compress_instruction,
|
||||
llm_compress_keep_recent=config.llm_compress_keep_recent,
|
||||
llm_compress_provider=_get_compress_provider(config, plugin_context),
|
||||
|
||||
@@ -13,11 +13,25 @@ from astrbot.core.agent.tool import FunctionTool, ToolExecResult
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
from astrbot.core.computer.computer_client import get_booter
|
||||
from astrbot.core.computer.tools import (
|
||||
AnnotateExecutionTool,
|
||||
BrowserBatchExecTool,
|
||||
BrowserExecTool,
|
||||
CreateSkillCandidateTool,
|
||||
CreateSkillPayloadTool,
|
||||
EvaluateSkillCandidateTool,
|
||||
ExecuteShellTool,
|
||||
FileDownloadTool,
|
||||
FileUploadTool,
|
||||
GetExecutionHistoryTool,
|
||||
GetSkillPayloadTool,
|
||||
ListSkillCandidatesTool,
|
||||
ListSkillReleasesTool,
|
||||
LocalPythonTool,
|
||||
PromoteSkillCandidateTool,
|
||||
PythonTool,
|
||||
RollbackSkillReleaseTool,
|
||||
RunBrowserSkillTool,
|
||||
SyncSkillReleaseTool,
|
||||
)
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.platform.message_session import MessageSession
|
||||
@@ -174,7 +188,12 @@ class KnowledgeBaseQueryTool(FunctionTool[AstrAgentContext]):
|
||||
@dataclass
|
||||
class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
|
||||
name: str = "send_message_to_user"
|
||||
description: str = "Directly send message to the user. Only use this tool when you need to proactively message the user. Otherwise you can directly output the reply in the conversation."
|
||||
description: str = (
|
||||
"Send message to the user. "
|
||||
"Supports various message types including `plain`, `image`, `record`, `video`, `file`, and `mention_user`. "
|
||||
"Use this tool to send media files (`image`, `record`, `video`, `file`), "
|
||||
"or when you need to proactively message the user(such as cron job). For normal text replies, you can output directly."
|
||||
)
|
||||
|
||||
parameters: dict = Field(
|
||||
default_factory=lambda: {
|
||||
@@ -190,7 +209,7 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Component type. One of: "
|
||||
"plain, image, record, file, mention_user"
|
||||
"plain, image, record, video, file, mention_user. Record is voice message."
|
||||
),
|
||||
},
|
||||
"text": {
|
||||
@@ -306,6 +325,19 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
|
||||
components.append(Comp.Record.fromURL(url=url))
|
||||
else:
|
||||
return f"error: messages[{idx}] must include path or url for record component."
|
||||
elif msg_type == "video":
|
||||
path = msg.get("path")
|
||||
url = msg.get("url")
|
||||
if path:
|
||||
(
|
||||
local_path,
|
||||
file_from_sandbox,
|
||||
) = await self._resolve_path_from_sandbox(context, path)
|
||||
components.append(Comp.Video.fromFileSystem(path=local_path))
|
||||
elif url:
|
||||
components.append(Comp.Video.fromURL(url=url))
|
||||
else:
|
||||
return f"error: messages[{idx}] must include path or url for video component."
|
||||
elif msg_type == "file":
|
||||
path = msg.get("path")
|
||||
url = msg.get("url")
|
||||
@@ -449,6 +481,20 @@ PYTHON_TOOL = PythonTool()
|
||||
LOCAL_PYTHON_TOOL = LocalPythonTool()
|
||||
FILE_UPLOAD_TOOL = FileUploadTool()
|
||||
FILE_DOWNLOAD_TOOL = FileDownloadTool()
|
||||
BROWSER_EXEC_TOOL = BrowserExecTool()
|
||||
BROWSER_BATCH_EXEC_TOOL = BrowserBatchExecTool()
|
||||
RUN_BROWSER_SKILL_TOOL = RunBrowserSkillTool()
|
||||
GET_EXECUTION_HISTORY_TOOL = GetExecutionHistoryTool()
|
||||
ANNOTATE_EXECUTION_TOOL = AnnotateExecutionTool()
|
||||
CREATE_SKILL_PAYLOAD_TOOL = CreateSkillPayloadTool()
|
||||
GET_SKILL_PAYLOAD_TOOL = GetSkillPayloadTool()
|
||||
CREATE_SKILL_CANDIDATE_TOOL = CreateSkillCandidateTool()
|
||||
LIST_SKILL_CANDIDATES_TOOL = ListSkillCandidatesTool()
|
||||
EVALUATE_SKILL_CANDIDATE_TOOL = EvaluateSkillCandidateTool()
|
||||
PROMOTE_SKILL_CANDIDATE_TOOL = PromoteSkillCandidateTool()
|
||||
LIST_SKILL_RELEASES_TOOL = ListSkillReleasesTool()
|
||||
ROLLBACK_SKILL_RELEASE_TOOL = RollbackSkillReleaseTool()
|
||||
SYNC_SKILL_RELEASE_TOOL = SyncSkillReleaseTool()
|
||||
|
||||
# we prevent astrbot from connecting to known malicious hosts
|
||||
# these hosts are base64 encoded
|
||||
|
||||
@@ -12,7 +12,7 @@ import os
|
||||
import shutil
|
||||
import zipfile
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -61,6 +61,69 @@ def _get_major_version(version_str: str) -> str:
|
||||
|
||||
CMD_CONFIG_FILE_PATH = os.path.join(get_astrbot_data_path(), "cmd_config.json")
|
||||
KB_PATH = get_astrbot_knowledge_base_path()
|
||||
DEFAULT_PLATFORM_STATS_INVALID_COUNT_WARN_LIMIT = 5
|
||||
PLATFORM_STATS_INVALID_COUNT_WARN_LIMIT_ENV = (
|
||||
"ASTRBOT_PLATFORM_STATS_INVALID_COUNT_WARN_LIMIT"
|
||||
)
|
||||
|
||||
|
||||
def _load_platform_stats_invalid_count_warn_limit() -> int:
|
||||
raw_value = os.getenv(PLATFORM_STATS_INVALID_COUNT_WARN_LIMIT_ENV)
|
||||
if raw_value is None:
|
||||
return DEFAULT_PLATFORM_STATS_INVALID_COUNT_WARN_LIMIT
|
||||
|
||||
try:
|
||||
value = int(raw_value)
|
||||
if value < 0:
|
||||
raise ValueError("negative")
|
||||
return value
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"Invalid env %s=%r, fallback to default %d",
|
||||
PLATFORM_STATS_INVALID_COUNT_WARN_LIMIT_ENV,
|
||||
raw_value,
|
||||
DEFAULT_PLATFORM_STATS_INVALID_COUNT_WARN_LIMIT,
|
||||
)
|
||||
return DEFAULT_PLATFORM_STATS_INVALID_COUNT_WARN_LIMIT
|
||||
|
||||
|
||||
PLATFORM_STATS_INVALID_COUNT_WARN_LIMIT = (
|
||||
_load_platform_stats_invalid_count_warn_limit()
|
||||
)
|
||||
|
||||
|
||||
class _InvalidCountWarnLimiter:
|
||||
"""Rate-limit warnings for invalid platform_stats count values."""
|
||||
|
||||
def __init__(self, limit: int) -> None:
|
||||
self.limit = limit
|
||||
self._count = 0
|
||||
self._suppression_logged = False
|
||||
|
||||
def warn_invalid_count(self, value: Any, key_for_log: tuple[Any, ...]) -> None:
|
||||
if self.limit > 0:
|
||||
if self._count < self.limit:
|
||||
logger.warning(
|
||||
"platform_stats count 非法,已按 0 处理: value=%r, key=%s",
|
||||
value,
|
||||
key_for_log,
|
||||
)
|
||||
self._count += 1
|
||||
if self._count == self.limit and not self._suppression_logged:
|
||||
logger.warning(
|
||||
"platform_stats 非法 count 告警已达到上限 (%d),后续将抑制",
|
||||
self.limit,
|
||||
)
|
||||
self._suppression_logged = True
|
||||
return
|
||||
|
||||
if not self._suppression_logged:
|
||||
# limit <= 0: emit only one suppression warning.
|
||||
logger.warning(
|
||||
"platform_stats 非法 count 告警已达到上限 (%d),后续将抑制",
|
||||
self.limit,
|
||||
)
|
||||
self._suppression_logged = True
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -138,6 +201,10 @@ class ImportResult:
|
||||
}
|
||||
|
||||
|
||||
class DatabaseClearError(RuntimeError):
|
||||
"""Raised when clearing the main database in replace mode fails."""
|
||||
|
||||
|
||||
class AstrBotImporter:
|
||||
"""AstrBot 数据导入器
|
||||
|
||||
@@ -342,6 +409,9 @@ class AstrBotImporter:
|
||||
|
||||
imported = await self._import_main_database(main_data)
|
||||
result.imported_tables.update(imported)
|
||||
except DatabaseClearError as e:
|
||||
result.add_error(f"清空主数据库失败: {e}")
|
||||
return result
|
||||
except Exception as e:
|
||||
result.add_error(f"导入主数据库失败: {e}")
|
||||
return result
|
||||
@@ -452,7 +522,9 @@ class AstrBotImporter:
|
||||
await session.execute(delete(model_class))
|
||||
logger.debug(f"已清空表 {table_name}")
|
||||
except Exception as e:
|
||||
logger.warning(f"清空表 {table_name} 失败: {e}")
|
||||
raise DatabaseClearError(
|
||||
f"清空表 {table_name} 失败: {e}"
|
||||
) from e
|
||||
|
||||
async def _clear_kb_data(self) -> None:
|
||||
"""清空知识库数据"""
|
||||
@@ -494,9 +566,10 @@ class AstrBotImporter:
|
||||
if not model_class:
|
||||
logger.warning(f"未知的表: {table_name}")
|
||||
continue
|
||||
normalized_rows = self._preprocess_main_table_rows(table_name, rows)
|
||||
|
||||
count = 0
|
||||
for row in rows:
|
||||
for row in normalized_rows:
|
||||
try:
|
||||
# 转换 datetime 字符串为 datetime 对象
|
||||
row = self._convert_datetime_fields(row, model_class)
|
||||
@@ -511,6 +584,118 @@ class AstrBotImporter:
|
||||
|
||||
return imported
|
||||
|
||||
def _preprocess_main_table_rows(
|
||||
self, table_name: str, rows: list[dict[str, Any]]
|
||||
) -> list[dict[str, Any]]:
|
||||
if table_name == "platform_stats":
|
||||
normalized_rows = self._merge_platform_stats_rows(rows)
|
||||
duplicate_count = len(rows) - len(normalized_rows)
|
||||
if duplicate_count > 0:
|
||||
logger.warning(
|
||||
"检测到 %s 重复键 %d 条,已在导入前聚合",
|
||||
table_name,
|
||||
duplicate_count,
|
||||
)
|
||||
return normalized_rows
|
||||
return rows
|
||||
|
||||
def _merge_platform_stats_rows(
|
||||
self, rows: list[dict[str, Any]]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Merge duplicate platform_stats rows by normalized timestamp/platform key.
|
||||
|
||||
Note:
|
||||
- Invalid/empty timestamps are kept as distinct rows to avoid accidental merging.
|
||||
- Non-string platform_id/platform_type are kept as distinct rows.
|
||||
- Invalid count warnings are rate-limited per function invocation.
|
||||
"""
|
||||
merged: dict[tuple[str, str, str], dict[str, Any]] = {}
|
||||
result: list[dict[str, Any]] = []
|
||||
warn_limiter = _InvalidCountWarnLimiter(PLATFORM_STATS_INVALID_COUNT_WARN_LIMIT)
|
||||
|
||||
for row in rows:
|
||||
normalized_row, normalized_timestamp, count = (
|
||||
self._normalize_platform_stats_entry(row, warn_limiter)
|
||||
)
|
||||
platform_id = normalized_row.get("platform_id")
|
||||
platform_type = normalized_row.get("platform_type")
|
||||
|
||||
if (
|
||||
normalized_timestamp is None
|
||||
or not isinstance(platform_id, str)
|
||||
or not isinstance(platform_type, str)
|
||||
):
|
||||
result.append(normalized_row)
|
||||
continue
|
||||
|
||||
merge_key = (normalized_timestamp, platform_id, platform_type)
|
||||
existing = merged.get(merge_key)
|
||||
if existing is None:
|
||||
merged[merge_key] = normalized_row
|
||||
result.append(normalized_row)
|
||||
else:
|
||||
existing["count"] += count
|
||||
|
||||
return result
|
||||
|
||||
def _normalize_platform_stats_entry(
|
||||
self,
|
||||
row: dict[str, Any],
|
||||
warn_limiter: _InvalidCountWarnLimiter,
|
||||
) -> tuple[dict[str, Any], str | None, int]:
|
||||
normalized_row = dict(row)
|
||||
raw_timestamp = normalized_row.get("timestamp")
|
||||
normalized_timestamp = self._normalize_platform_stats_timestamp(raw_timestamp)
|
||||
|
||||
if normalized_timestamp is not None:
|
||||
normalized_row["timestamp"] = normalized_timestamp
|
||||
elif isinstance(raw_timestamp, str):
|
||||
normalized_row["timestamp"] = raw_timestamp.strip()
|
||||
elif raw_timestamp is None:
|
||||
normalized_row["timestamp"] = ""
|
||||
else:
|
||||
normalized_row["timestamp"] = str(raw_timestamp)
|
||||
|
||||
raw_count = normalized_row.get("count", 0)
|
||||
try:
|
||||
count = int(raw_count)
|
||||
except (TypeError, ValueError):
|
||||
key_for_log = (
|
||||
normalized_row.get("timestamp"),
|
||||
repr(normalized_row.get("platform_id")),
|
||||
repr(normalized_row.get("platform_type")),
|
||||
)
|
||||
warn_limiter.warn_invalid_count(raw_count, key_for_log)
|
||||
count = 0
|
||||
|
||||
normalized_row["count"] = count
|
||||
return normalized_row, normalized_timestamp, count
|
||||
|
||||
def _normalize_platform_stats_timestamp(self, value: Any) -> str | None:
|
||||
if isinstance(value, datetime):
|
||||
dt = value
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
dt = dt.astimezone(timezone.utc)
|
||||
return dt.isoformat()
|
||||
if isinstance(value, str):
|
||||
timestamp = value.strip()
|
||||
if not timestamp:
|
||||
return None
|
||||
if timestamp.endswith("Z"):
|
||||
timestamp = f"{timestamp[:-1]}+00:00"
|
||||
try:
|
||||
dt = datetime.fromisoformat(timestamp)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
dt = dt.astimezone(timezone.utc)
|
||||
return dt.isoformat()
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
async def _import_knowledge_bases(
|
||||
self,
|
||||
zf: zipfile.ZipFile,
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..olayer import (
|
||||
BrowserComponent,
|
||||
FileSystemComponent,
|
||||
PythonComponent,
|
||||
ShellComponent,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from astrbot.core.agent.tool import FunctionTool
|
||||
|
||||
|
||||
class ComputerBooter:
|
||||
@@ -11,6 +23,19 @@ class ComputerBooter:
|
||||
@property
|
||||
def shell(self) -> ShellComponent: ...
|
||||
|
||||
@property
|
||||
def capabilities(self) -> tuple[str, ...] | None:
|
||||
"""Sandbox capabilities (e.g. ('python', 'shell', 'filesystem', 'browser')).
|
||||
|
||||
Returns None if the booter doesn't support capability introspection
|
||||
(backward-compatible default). Subclasses override after boot.
|
||||
"""
|
||||
return None
|
||||
|
||||
@property
|
||||
def browser(self) -> BrowserComponent | None:
|
||||
return None
|
||||
|
||||
async def boot(self, session_id: str) -> None: ...
|
||||
|
||||
async def shutdown(self) -> None: ...
|
||||
@@ -29,3 +54,18 @@ class ComputerBooter:
|
||||
async def available(self) -> bool:
|
||||
"""Check if the computer is available."""
|
||||
...
|
||||
|
||||
@classmethod
|
||||
def get_default_tools(cls) -> list[FunctionTool]:
|
||||
"""Conservative full tool list (no instance needed, pre-boot)."""
|
||||
return []
|
||||
|
||||
def get_tools(self) -> list[FunctionTool]:
|
||||
"""Capability-filtered tool list (post-boot).
|
||||
Defaults to get_default_tools()."""
|
||||
return self.__class__.get_default_tools()
|
||||
|
||||
@classmethod
|
||||
def get_system_prompt_parts(cls) -> list[str]:
|
||||
"""Booter-specific system prompt fragments (static text, no instance needed)."""
|
||||
return []
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
"""Manage Bay container lifecycle for zero-config Shipyard Neo integration.
|
||||
|
||||
When no Bay endpoint is configured, AstrBot can automatically start a Bay
|
||||
container using the Docker socket (like BoxliteBooter does for Ship
|
||||
containers).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import tarfile
|
||||
from typing import Any
|
||||
|
||||
import aiodocker
|
||||
import aiohttp
|
||||
|
||||
from astrbot.api import logger
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BAY_IMAGE = "ghcr.io/astrbotdevs/shipyard-neo-bay:latest"
|
||||
BAY_CONTAINER_NAME = "astrbot-bay"
|
||||
BAY_LABEL = "astrbot.bay.managed"
|
||||
BAY_PORT = 8114
|
||||
HEALTH_TIMEOUT_S = 60
|
||||
HEALTH_POLL_INTERVAL_S = 2
|
||||
|
||||
|
||||
class BayContainerManager:
|
||||
"""Start / reuse / stop a Bay container via Docker Engine API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
image: str = BAY_IMAGE,
|
||||
host_port: int = BAY_PORT,
|
||||
) -> None:
|
||||
self._image = image
|
||||
self._host_port = host_port
|
||||
self._docker: aiodocker.Docker | None = None
|
||||
self._container: Any = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def ensure_running(self) -> str:
|
||||
"""Make sure a Bay container is running. Returns the endpoint URL.
|
||||
|
||||
If a container labelled ``astrbot.bay.managed`` already exists
|
||||
and is running, it will be reused. Otherwise a new container is
|
||||
created from *self._image*.
|
||||
"""
|
||||
try:
|
||||
self._docker = aiodocker.Docker()
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
"Failed to connect to Docker daemon. "
|
||||
"Ensure Docker is installed and running, or configure "
|
||||
"an explicit Bay endpoint instead of auto-start mode."
|
||||
) from exc
|
||||
|
||||
# 1. Look for an existing managed container
|
||||
existing = await self._find_managed_container()
|
||||
if existing is not None:
|
||||
state = existing["State"]
|
||||
if state.get("Running"):
|
||||
cid = existing["Id"][:12]
|
||||
logger.info("[BayManager] Reusing existing Bay container: %s", cid)
|
||||
self._container = await self._docker.containers.get(existing["Id"])
|
||||
return f"http://127.0.0.1:{self._host_port}"
|
||||
else:
|
||||
# Container exists but stopped — restart it
|
||||
logger.info("[BayManager] Restarting stopped Bay container")
|
||||
container = await self._docker.containers.get(existing["Id"])
|
||||
await container.start()
|
||||
self._container = container
|
||||
return f"http://127.0.0.1:{self._host_port}"
|
||||
|
||||
# 2. Pull image if needed
|
||||
await self._pull_image_if_needed()
|
||||
|
||||
# 3. Create and start container
|
||||
logger.info(
|
||||
"[BayManager] Starting Bay container: image=%s, port=%d",
|
||||
self._image,
|
||||
self._host_port,
|
||||
)
|
||||
config = {
|
||||
"Image": self._image,
|
||||
"Labels": {BAY_LABEL: "true"},
|
||||
"Env": [
|
||||
"BAY_SERVER__HOST=0.0.0.0",
|
||||
f"BAY_SERVER__PORT={BAY_PORT}",
|
||||
"BAY_DATA_DIR=/app/data",
|
||||
# allow_anonymous=false → auto-provisions API key
|
||||
"BAY_SECURITY__ALLOW_ANONYMOUS=false",
|
||||
],
|
||||
"HostConfig": {
|
||||
"PortBindings": {
|
||||
f"{BAY_PORT}/tcp": [{"HostPort": str(self._host_port)}],
|
||||
},
|
||||
"Binds": [
|
||||
# Bay needs Docker socket to create sandbox containers
|
||||
"/var/run/docker.sock:/var/run/docker.sock",
|
||||
],
|
||||
"RestartPolicy": {"Name": "unless-stopped"},
|
||||
},
|
||||
}
|
||||
self._container = await self._docker.containers.create_or_replace(
|
||||
BAY_CONTAINER_NAME, config
|
||||
)
|
||||
await self._container.start()
|
||||
logger.info("[BayManager] Bay container started: %s", BAY_CONTAINER_NAME)
|
||||
|
||||
return f"http://127.0.0.1:{self._host_port}"
|
||||
|
||||
async def wait_healthy(self, timeout: int = HEALTH_TIMEOUT_S) -> None:
|
||||
"""Block until Bay's ``/health`` endpoint returns 200."""
|
||||
url = f"http://127.0.0.1:{self._host_port}/health"
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + timeout
|
||||
last_error: str = ""
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
while loop.time() < deadline:
|
||||
try:
|
||||
async with session.get(
|
||||
url, timeout=aiohttp.ClientTimeout(total=3)
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
logger.info("[BayManager] Bay is healthy")
|
||||
return
|
||||
last_error = f"HTTP {resp.status}"
|
||||
except Exception as exc:
|
||||
last_error = str(exc)
|
||||
|
||||
await asyncio.sleep(HEALTH_POLL_INTERVAL_S)
|
||||
|
||||
raise TimeoutError(
|
||||
f"Bay did not become healthy within {timeout}s (last error: {last_error})"
|
||||
)
|
||||
|
||||
async def read_credentials(self) -> str:
|
||||
"""Read auto-provisioned API key from Bay container.
|
||||
|
||||
Bay writes ``credentials.json`` to its data directory when
|
||||
``allow_anonymous=false`` and no explicit API key is set.
|
||||
"""
|
||||
if self._container is None:
|
||||
return ""
|
||||
|
||||
try:
|
||||
# Read credentials.json from container filesystem
|
||||
tar_stream = await self._container.get_archive("/app/data/credentials.json")
|
||||
# get_archive returns (tar_data, stat)
|
||||
tar_data = tar_stream
|
||||
|
||||
if isinstance(tar_data, dict):
|
||||
raw = tar_data.get("data", b"")
|
||||
elif isinstance(tar_data, tuple):
|
||||
# (stream, stat_info)
|
||||
raw = b""
|
||||
stream = tar_data[0]
|
||||
if hasattr(stream, "read"):
|
||||
raw = await stream.read()
|
||||
elif isinstance(stream, bytes):
|
||||
raw = stream
|
||||
else:
|
||||
# It might be a chunked response
|
||||
chunks = []
|
||||
async for chunk in stream:
|
||||
chunks.append(chunk)
|
||||
raw = b"".join(chunks)
|
||||
else:
|
||||
raw = tar_data if isinstance(tar_data, bytes) else b""
|
||||
|
||||
if not raw:
|
||||
logger.debug("[BayManager] Empty tar response from container")
|
||||
return ""
|
||||
|
||||
tario = io.BytesIO(raw)
|
||||
with tarfile.open(fileobj=tario) as tar:
|
||||
for member in tar.getmembers():
|
||||
f = tar.extractfile(member)
|
||||
if f:
|
||||
creds = json.loads(f.read().decode("utf-8"))
|
||||
api_key = creds.get("api_key", "")
|
||||
if api_key:
|
||||
masked = (
|
||||
f"{api_key[:8]}..."
|
||||
if len(api_key) >= 10
|
||||
else "redacted"
|
||||
)
|
||||
logger.info(
|
||||
"[BayManager] Auto-discovered Bay API key: %s",
|
||||
masked,
|
||||
)
|
||||
return api_key
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"[BayManager] Failed to read credentials from container: %s", exc
|
||||
)
|
||||
|
||||
return ""
|
||||
|
||||
async def close_client(self) -> None:
|
||||
"""Close the Docker client without stopping the container.
|
||||
|
||||
The Bay container stays running for reuse by future sessions.
|
||||
"""
|
||||
if self._docker is not None:
|
||||
await self._docker.close()
|
||||
self._docker = None
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop and remove the managed Bay container."""
|
||||
if self._container is not None:
|
||||
try:
|
||||
await self._container.stop()
|
||||
await self._container.delete(force=True)
|
||||
logger.info("[BayManager] Bay container stopped and removed")
|
||||
except Exception as exc:
|
||||
logger.debug("[BayManager] Error stopping Bay container: %s", exc)
|
||||
finally:
|
||||
self._container = None
|
||||
|
||||
await self.close_client()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Private helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _find_managed_container(self) -> dict | None:
|
||||
"""Find an existing container with our management label."""
|
||||
assert self._docker is not None
|
||||
containers = await self._docker.containers.list(
|
||||
all=True,
|
||||
filters=json.dumps({"label": [f"{BAY_LABEL}=true"]}),
|
||||
)
|
||||
if containers:
|
||||
# Inspect first match to get full state
|
||||
return await containers[0].show()
|
||||
return None
|
||||
|
||||
async def _pull_image_if_needed(self) -> None:
|
||||
"""Pull the Bay image if it doesn't exist locally."""
|
||||
assert self._docker is not None
|
||||
try:
|
||||
await self._docker.images.inspect(self._image)
|
||||
logger.debug("[BayManager] Image %s already exists", self._image)
|
||||
except aiodocker.exceptions.DockerError:
|
||||
logger.info("[BayManager] Pulling image %s ...", self._image)
|
||||
# Pull with progress logging
|
||||
await self._docker.images.pull(self._image)
|
||||
logger.info("[BayManager] Image %s pulled successfully", self._image)
|
||||
@@ -1,6 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import random
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import aiohttp
|
||||
import boxlite
|
||||
@@ -10,6 +13,9 @@ from shipyard.shell import ShellComponent as ShipyardShellComponent
|
||||
|
||||
from astrbot.api import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from astrbot.core.agent.tool import FunctionTool
|
||||
|
||||
from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
|
||||
from .base import ComputerBooter
|
||||
|
||||
@@ -64,6 +70,10 @@ class MockShipyardSandboxClient:
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.post(url, data=data) as response:
|
||||
if response.status == 200:
|
||||
logger.info(
|
||||
"[Computer] file_upload booter=boxlite remote_path=%s",
|
||||
remote_path,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"message": "File uploaded successfully",
|
||||
@@ -71,6 +81,11 @@ class MockShipyardSandboxClient:
|
||||
}
|
||||
else:
|
||||
error_text = await response.text()
|
||||
logger.warning(
|
||||
"[Computer] file_upload_failed booter=boxlite error=http_status status=%s remote_path=%s",
|
||||
response.status,
|
||||
remote_path,
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Server returned {response.status}: {error_text}",
|
||||
@@ -78,30 +93,39 @@ class MockShipyardSandboxClient:
|
||||
}
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f"Failed to upload file: {e}")
|
||||
logger.error("[Computer] file_upload_failed booter=boxlite error=%s", e)
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Connection error: {str(e)}",
|
||||
"message": "File upload failed",
|
||||
}
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
"[Computer] file_upload_failed booter=boxlite error=timeout remote_path=%s",
|
||||
remote_path,
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"error": "File upload timeout",
|
||||
"message": "File upload failed",
|
||||
}
|
||||
except FileNotFoundError:
|
||||
logger.error(f"File not found: {path}")
|
||||
logger.error(
|
||||
"[Computer] file_upload_failed booter=boxlite error=file_not_found path=%s",
|
||||
path,
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"File not found: {path}",
|
||||
"message": "File upload failed",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error uploading file: {e}")
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"[Computer] file_upload_failed booter=boxlite error=unexpected"
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Internal error: {str(e)}",
|
||||
"error": f"Internal error: {str(exc)}",
|
||||
"message": "File upload failed",
|
||||
}
|
||||
|
||||
@@ -110,24 +134,42 @@ class MockShipyardSandboxClient:
|
||||
loop = 60
|
||||
while loop > 0:
|
||||
try:
|
||||
logger.info(
|
||||
f"Checking health for sandbox {ship_id} on {self.sb_url}..."
|
||||
logger.debug(
|
||||
"[Computer] health_check booter=boxlite ship_id=%s session=%s endpoint=%s attempt=%s healthy=pending",
|
||||
ship_id,
|
||||
session_id,
|
||||
self.sb_url,
|
||||
61 - loop,
|
||||
)
|
||||
url = f"{self.sb_url}/health"
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url) as response:
|
||||
if response.status == 200:
|
||||
logger.info(f"Sandbox {ship_id} is healthy")
|
||||
return
|
||||
logger.debug(
|
||||
"[Computer] health_check booter=boxlite ship_id=%s session=%s endpoint=%s healthy=true",
|
||||
ship_id,
|
||||
session_id,
|
||||
self.sb_url,
|
||||
)
|
||||
return
|
||||
await asyncio.sleep(1)
|
||||
loop -= 1
|
||||
except Exception:
|
||||
await asyncio.sleep(1)
|
||||
loop -= 1
|
||||
logger.warning(
|
||||
"[Computer] health_check_timeout booter=boxlite ship_id=%s session=%s endpoint=%s",
|
||||
ship_id,
|
||||
session_id,
|
||||
self.sb_url,
|
||||
)
|
||||
|
||||
|
||||
class BoxliteBooter(ComputerBooter):
|
||||
async def boot(self, session_id: str) -> None:
|
||||
logger.info(
|
||||
f"Booting(Boxlite) for session: {session_id}, this may take a while..."
|
||||
"[Computer] booter_boot booter=boxlite session=%s status=starting",
|
||||
session_id,
|
||||
)
|
||||
random_port = random.randint(20000, 30000)
|
||||
self.box = boxlite.SimpleBox(
|
||||
@@ -142,7 +184,11 @@ class BoxliteBooter(ComputerBooter):
|
||||
],
|
||||
)
|
||||
await self.box.start()
|
||||
logger.info(f"Boxlite booter started for session: {session_id}")
|
||||
logger.info(
|
||||
"[Computer] booter_boot booter=boxlite session=%s status=ready ship_id=%s",
|
||||
session_id,
|
||||
self.box.id,
|
||||
)
|
||||
self.mocked = MockShipyardSandboxClient(
|
||||
sb_url=f"http://127.0.0.1:{random_port}"
|
||||
)
|
||||
@@ -165,9 +211,15 @@ class BoxliteBooter(ComputerBooter):
|
||||
await self.mocked.wait_healthy(self.box.id, session_id)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
logger.info(f"Shutting down Boxlite booter for ship: {self.box.id}")
|
||||
logger.info(
|
||||
"[Computer] booter_shutdown booter=boxlite ship_id=%s status=starting",
|
||||
self.box.id,
|
||||
)
|
||||
self.box.shutdown()
|
||||
logger.info(f"Boxlite booter for ship: {self.box.id} stopped")
|
||||
logger.info(
|
||||
"[Computer] booter_shutdown booter=boxlite ship_id=%s status=done",
|
||||
self.box.id,
|
||||
)
|
||||
|
||||
@property
|
||||
def fs(self) -> FileSystemComponent:
|
||||
@@ -184,3 +236,24 @@ class BoxliteBooter(ComputerBooter):
|
||||
async def upload_file(self, path: str, file_name: str) -> dict:
|
||||
"""Upload file to sandbox"""
|
||||
return await self.mocked.upload_file(path, file_name)
|
||||
|
||||
@classmethod
|
||||
@functools.cache
|
||||
def _default_tools(cls) -> tuple[FunctionTool, ...]:
|
||||
from astrbot.core.computer.tools import (
|
||||
ExecuteShellTool,
|
||||
FileDownloadTool,
|
||||
FileUploadTool,
|
||||
PythonTool,
|
||||
)
|
||||
|
||||
return (
|
||||
ExecuteShellTool(),
|
||||
PythonTool(),
|
||||
FileUploadTool(),
|
||||
FileDownloadTool(),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_default_tools(cls) -> list[FunctionTool]:
|
||||
return list(cls._default_tools())
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
BOOTER_SHIPYARD = "shipyard"
|
||||
BOOTER_SHIPYARD_NEO = "shipyard_neo"
|
||||
BOOTER_BOXLITE = "boxlite"
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import locale
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -52,6 +53,31 @@ def _ensure_safe_path(path: str) -> str:
|
||||
return abs_path
|
||||
|
||||
|
||||
def _decode_shell_output(output: bytes | None) -> str:
|
||||
if output is None:
|
||||
return ""
|
||||
|
||||
preferred = locale.getpreferredencoding(False) or "utf-8"
|
||||
try:
|
||||
return output.decode("utf-8")
|
||||
except (LookupError, UnicodeDecodeError):
|
||||
pass
|
||||
|
||||
if os.name == "nt":
|
||||
for encoding in ("mbcs", "cp936", "gbk", "gb18030"):
|
||||
try:
|
||||
return output.decode(encoding)
|
||||
except (LookupError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
try:
|
||||
return output.decode(preferred)
|
||||
except (LookupError, UnicodeDecodeError):
|
||||
pass
|
||||
|
||||
return output.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
@dataclass
|
||||
class LocalShellComponent(ShellComponent):
|
||||
async def exec(
|
||||
@@ -72,28 +98,32 @@ class LocalShellComponent(ShellComponent):
|
||||
run_env.update({str(k): str(v) for k, v in env.items()})
|
||||
working_dir = _ensure_safe_path(cwd) if cwd else get_astrbot_root()
|
||||
if background:
|
||||
proc = subprocess.Popen(
|
||||
# `command` is intentionally executed through the current shell so
|
||||
# local computer-use behavior matches existing tool semantics.
|
||||
# Safety relies on `_is_safe_command()` and the allowed-root checks.
|
||||
proc = subprocess.Popen( # noqa: S602 # nosemgrep: python.lang.security.audit.dangerous-subprocess-use-audit
|
||||
command,
|
||||
shell=shell,
|
||||
cwd=working_dir,
|
||||
env=run_env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return {"pid": proc.pid, "stdout": "", "stderr": "", "exit_code": None}
|
||||
result = subprocess.run(
|
||||
# `command` is intentionally executed through the current shell so
|
||||
# local computer-use behavior matches existing tool semantics.
|
||||
# Safety relies on `_is_safe_command()` and the allowed-root checks.
|
||||
result = subprocess.run( # noqa: S602 # nosemgrep: python.lang.security.audit.dangerous-subprocess-use-audit
|
||||
command,
|
||||
shell=shell,
|
||||
cwd=working_dir,
|
||||
env=run_env,
|
||||
timeout=timeout,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return {
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"stdout": _decode_shell_output(result.stdout),
|
||||
"stderr": _decode_shell_output(result.stderr),
|
||||
"exit_code": result.returncode,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from shipyard import ShipyardClient, Spec
|
||||
|
||||
from astrbot.api import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from astrbot.core.agent.tool import FunctionTool
|
||||
|
||||
from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
|
||||
from .base import ComputerBooter
|
||||
|
||||
|
||||
class ShipyardBooter(ComputerBooter):
|
||||
@classmethod
|
||||
@functools.cache
|
||||
def _default_tools(cls) -> tuple[FunctionTool, ...]:
|
||||
from astrbot.core.computer.tools import (
|
||||
ExecuteShellTool,
|
||||
FileDownloadTool,
|
||||
FileUploadTool,
|
||||
PythonTool,
|
||||
)
|
||||
|
||||
return (
|
||||
ExecuteShellTool(),
|
||||
PythonTool(),
|
||||
FileUploadTool(),
|
||||
FileDownloadTool(),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_default_tools(cls) -> list[FunctionTool]:
|
||||
return list(cls._default_tools())
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
endpoint_url: str,
|
||||
@@ -27,11 +56,15 @@ class ShipyardBooter(ComputerBooter):
|
||||
max_session_num=self._session_num,
|
||||
session_id=session_id,
|
||||
)
|
||||
logger.info(f"Got sandbox ship: {ship.id} for session: {session_id}")
|
||||
logger.info(
|
||||
"[Computer] sandbox_created booter=shipyard ship_id=%s session=%s",
|
||||
ship.id,
|
||||
session_id,
|
||||
)
|
||||
self._ship = ship
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
pass
|
||||
logger.info("[Computer] booter_shutdown booter=shipyard status=done")
|
||||
|
||||
@property
|
||||
def fs(self) -> FileSystemComponent:
|
||||
@@ -47,11 +80,22 @@ class ShipyardBooter(ComputerBooter):
|
||||
|
||||
async def upload_file(self, path: str, file_name: str) -> dict:
|
||||
"""Upload file to sandbox"""
|
||||
return await self._ship.upload_file(path, file_name)
|
||||
result = await self._ship.upload_file(path, file_name)
|
||||
logger.info(
|
||||
"[Computer] file_upload booter=shipyard remote_path=%s",
|
||||
file_name,
|
||||
)
|
||||
return result
|
||||
|
||||
async def download_file(self, remote_path: str, local_path: str):
|
||||
"""Download file from sandbox."""
|
||||
return await self._ship.download_file(remote_path, local_path)
|
||||
result = await self._ship.download_file(remote_path, local_path)
|
||||
logger.info(
|
||||
"[Computer] file_download booter=shipyard remote_path=%s local_path=%s",
|
||||
remote_path,
|
||||
local_path,
|
||||
)
|
||||
return result
|
||||
|
||||
async def available(self) -> bool:
|
||||
"""Check if the sandbox is available."""
|
||||
@@ -59,9 +103,21 @@ class ShipyardBooter(ComputerBooter):
|
||||
ship_id = self._ship.id
|
||||
data = await self._sandbox_client.get_ship(ship_id)
|
||||
if not data:
|
||||
logger.debug(
|
||||
"[Computer] health_check booter=shipyard ship_id=%s healthy=false reason=no_data",
|
||||
ship_id,
|
||||
)
|
||||
return False
|
||||
health = bool(data.get("status", 0) == 1)
|
||||
logger.debug(
|
||||
"[Computer] health_check booter=shipyard ship_id=%s healthy=%s",
|
||||
ship_id,
|
||||
health,
|
||||
)
|
||||
return health
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking Shipyard sandbox availability: {e}")
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"[Computer] health_check_failed booter=shipyard ship_id=%s",
|
||||
getattr(getattr(self, "_ship", None), "id", "unknown"),
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,608 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import os
|
||||
import shlex
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from astrbot.api import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from astrbot.core.agent.tool import FunctionTool
|
||||
|
||||
from ..olayer import (
|
||||
BrowserComponent,
|
||||
FileSystemComponent,
|
||||
PythonComponent,
|
||||
ShellComponent,
|
||||
)
|
||||
from .base import ComputerBooter
|
||||
|
||||
|
||||
def _maybe_model_dump(value: Any) -> dict[str, Any]:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if hasattr(value, "model_dump"):
|
||||
dumped = value.model_dump()
|
||||
if isinstance(dumped, dict):
|
||||
return dumped
|
||||
return {}
|
||||
|
||||
|
||||
class NeoPythonComponent(PythonComponent):
|
||||
def __init__(self, sandbox: Any) -> None:
|
||||
self._sandbox = sandbox
|
||||
|
||||
async def exec(
|
||||
self,
|
||||
code: str,
|
||||
kernel_id: str | None = None,
|
||||
timeout: int = 30,
|
||||
silent: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
_ = kernel_id # Bay runtime does not expose kernel_id in current SDK.
|
||||
result = await self._sandbox.python.exec(code, timeout=timeout)
|
||||
payload = _maybe_model_dump(result)
|
||||
|
||||
output_text = payload.get("output", "") or ""
|
||||
error_text = payload.get("error", "") or ""
|
||||
data = payload.get("data") if isinstance(payload.get("data"), dict) else {}
|
||||
rich_output = data.get("output") if isinstance(data.get("output"), dict) else {}
|
||||
if not isinstance(rich_output.get("images"), list):
|
||||
rich_output["images"] = []
|
||||
if "text" not in rich_output:
|
||||
rich_output["text"] = output_text
|
||||
|
||||
if silent:
|
||||
rich_output["text"] = ""
|
||||
|
||||
return {
|
||||
"success": bool(payload.get("success", error_text == "")),
|
||||
"data": {
|
||||
"output": rich_output,
|
||||
"error": error_text,
|
||||
},
|
||||
"execution_id": payload.get("execution_id"),
|
||||
"execution_time_ms": payload.get("execution_time_ms"),
|
||||
"code": payload.get("code"),
|
||||
"output": output_text,
|
||||
"error": error_text,
|
||||
}
|
||||
|
||||
|
||||
class NeoShellComponent(ShellComponent):
|
||||
def __init__(self, sandbox: Any) -> None:
|
||||
self._sandbox = sandbox
|
||||
|
||||
async def exec(
|
||||
self,
|
||||
command: str,
|
||||
cwd: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
timeout: int | None = 30,
|
||||
shell: bool = True,
|
||||
background: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
if not shell:
|
||||
return {
|
||||
"stdout": "",
|
||||
"stderr": "error: only shell mode is supported in shipyard_neo booter.",
|
||||
"exit_code": 2,
|
||||
"success": False,
|
||||
}
|
||||
|
||||
run_command = command
|
||||
if env:
|
||||
env_prefix = " ".join(
|
||||
f"{k}={shlex.quote(str(v))}" for k, v in sorted(env.items())
|
||||
)
|
||||
run_command = f"{env_prefix} {run_command}"
|
||||
|
||||
if background:
|
||||
run_command = f"nohup sh -lc {shlex.quote(run_command)} >/tmp/astrbot_bg.log 2>&1 & echo $!"
|
||||
|
||||
result = await self._sandbox.shell.exec(
|
||||
run_command,
|
||||
timeout=timeout or 30,
|
||||
cwd=cwd,
|
||||
)
|
||||
payload = _maybe_model_dump(result)
|
||||
|
||||
stdout = payload.get("output", "") or ""
|
||||
stderr = payload.get("error", "") or ""
|
||||
exit_code = payload.get("exit_code")
|
||||
if background:
|
||||
pid: int | None = None
|
||||
try:
|
||||
pid = int(stdout.strip().splitlines()[-1])
|
||||
except Exception:
|
||||
pid = None
|
||||
return {
|
||||
"pid": pid,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"exit_code": exit_code,
|
||||
"success": bool(payload.get("success", not stderr)),
|
||||
"execution_id": payload.get("execution_id"),
|
||||
"execution_time_ms": payload.get("execution_time_ms"),
|
||||
"command": payload.get("command"),
|
||||
}
|
||||
|
||||
return {
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"exit_code": exit_code,
|
||||
"success": bool(payload.get("success", not stderr)),
|
||||
"execution_id": payload.get("execution_id"),
|
||||
"execution_time_ms": payload.get("execution_time_ms"),
|
||||
"command": payload.get("command"),
|
||||
}
|
||||
|
||||
|
||||
class NeoFileSystemComponent(FileSystemComponent):
|
||||
def __init__(self, sandbox: Any) -> None:
|
||||
self._sandbox = sandbox
|
||||
|
||||
async def create_file(
|
||||
self,
|
||||
path: str,
|
||||
content: str = "",
|
||||
mode: int = 0o644,
|
||||
) -> dict[str, Any]:
|
||||
_ = mode
|
||||
await self._sandbox.filesystem.write_file(path, content)
|
||||
return {"success": True, "path": path}
|
||||
|
||||
async def read_file(self, path: str, encoding: str = "utf-8") -> dict[str, Any]:
|
||||
_ = encoding
|
||||
content = await self._sandbox.filesystem.read_file(path)
|
||||
return {"success": True, "path": path, "content": content}
|
||||
|
||||
async def write_file(
|
||||
self,
|
||||
path: str,
|
||||
content: str,
|
||||
mode: str = "w",
|
||||
encoding: str = "utf-8",
|
||||
) -> dict[str, Any]:
|
||||
_ = mode
|
||||
_ = encoding
|
||||
await self._sandbox.filesystem.write_file(path, content)
|
||||
return {"success": True, "path": path}
|
||||
|
||||
async def delete_file(self, path: str) -> dict[str, Any]:
|
||||
await self._sandbox.filesystem.delete(path)
|
||||
return {"success": True, "path": path}
|
||||
|
||||
async def list_dir(
|
||||
self,
|
||||
path: str = ".",
|
||||
show_hidden: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
entries = await self._sandbox.filesystem.list_dir(path)
|
||||
data = []
|
||||
for entry in entries:
|
||||
item = _maybe_model_dump(entry)
|
||||
if not show_hidden and str(item.get("name", "")).startswith("."):
|
||||
continue
|
||||
data.append(item)
|
||||
return {"success": True, "path": path, "entries": data}
|
||||
|
||||
|
||||
class NeoBrowserComponent(BrowserComponent):
|
||||
def __init__(self, sandbox: Any) -> None:
|
||||
self._sandbox = sandbox
|
||||
|
||||
async def exec(
|
||||
self,
|
||||
cmd: str,
|
||||
timeout: int = 30,
|
||||
description: str | None = None,
|
||||
tags: str | None = None,
|
||||
learn: bool = False,
|
||||
include_trace: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
result = await self._sandbox.browser.exec(
|
||||
cmd,
|
||||
timeout=timeout,
|
||||
description=description,
|
||||
tags=tags,
|
||||
learn=learn,
|
||||
include_trace=include_trace,
|
||||
)
|
||||
return _maybe_model_dump(result)
|
||||
|
||||
async def exec_batch(
|
||||
self,
|
||||
commands: list[str],
|
||||
timeout: int = 60,
|
||||
stop_on_error: bool = True,
|
||||
description: str | None = None,
|
||||
tags: str | None = None,
|
||||
learn: bool = False,
|
||||
include_trace: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
result = await self._sandbox.browser.exec_batch(
|
||||
commands,
|
||||
timeout=timeout,
|
||||
stop_on_error=stop_on_error,
|
||||
description=description,
|
||||
tags=tags,
|
||||
learn=learn,
|
||||
include_trace=include_trace,
|
||||
)
|
||||
return _maybe_model_dump(result)
|
||||
|
||||
async def run_skill(
|
||||
self,
|
||||
skill_key: str,
|
||||
timeout: int = 60,
|
||||
stop_on_error: bool = True,
|
||||
include_trace: bool = False,
|
||||
description: str | None = None,
|
||||
tags: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
result = await self._sandbox.browser.run_skill(
|
||||
skill_key=skill_key,
|
||||
timeout=timeout,
|
||||
stop_on_error=stop_on_error,
|
||||
include_trace=include_trace,
|
||||
description=description,
|
||||
tags=tags,
|
||||
)
|
||||
return _maybe_model_dump(result)
|
||||
|
||||
|
||||
class ShipyardNeoBooter(ComputerBooter):
|
||||
"""Booter backed by Shipyard Neo (Bay).
|
||||
|
||||
If *endpoint_url* is empty or set to ``"__auto__"``, Bay will be
|
||||
started automatically as a Docker container (like Boxlite does for
|
||||
Ship containers).
|
||||
"""
|
||||
|
||||
AUTO_SENTINEL = "__auto__"
|
||||
DEFAULT_PROFILE = "python-default"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
endpoint_url: str,
|
||||
access_token: str,
|
||||
profile: str = DEFAULT_PROFILE,
|
||||
ttl: int = 3600,
|
||||
) -> None:
|
||||
self._endpoint_url = endpoint_url
|
||||
self._access_token = access_token
|
||||
self._profile = profile
|
||||
self._ttl = ttl
|
||||
self._client: Any = None
|
||||
self._sandbox: Any = None
|
||||
self._bay_manager: Any = None # BayContainerManager when auto-started
|
||||
self._fs: FileSystemComponent | None = None
|
||||
self._python: PythonComponent | None = None
|
||||
self._shell: ShellComponent | None = None
|
||||
self._browser: BrowserComponent | None = None
|
||||
|
||||
@property
|
||||
def bay_client(self) -> Any:
|
||||
return self._client
|
||||
|
||||
@property
|
||||
def sandbox(self) -> Any:
|
||||
return self._sandbox
|
||||
|
||||
@property
|
||||
def capabilities(self) -> tuple[str, ...] | None:
|
||||
"""Sandbox capabilities from the Bay profile.
|
||||
|
||||
Returns an immutable tuple after :meth:`boot`; ``None`` before boot.
|
||||
"""
|
||||
if self._sandbox is None:
|
||||
return None
|
||||
caps = getattr(self._sandbox, "capabilities", None)
|
||||
return tuple(caps) if caps is not None else None
|
||||
|
||||
@property
|
||||
def is_auto_mode(self) -> bool:
|
||||
"""True when Bay should be auto-started."""
|
||||
ep = (self._endpoint_url or "").strip()
|
||||
return not ep or ep == self.AUTO_SENTINEL
|
||||
|
||||
async def boot(self, session_id: str) -> None:
|
||||
_ = session_id
|
||||
|
||||
# --- Auto-start Bay if needed ---
|
||||
if self.is_auto_mode:
|
||||
from .bay_manager import BayContainerManager
|
||||
|
||||
# Clean up previous manager if re-booting
|
||||
if self._bay_manager is not None:
|
||||
await self._bay_manager.close_client()
|
||||
|
||||
logger.info("[Computer] bay_autostart status=starting")
|
||||
self._bay_manager = BayContainerManager()
|
||||
self._endpoint_url = await self._bay_manager.ensure_running()
|
||||
await self._bay_manager.wait_healthy()
|
||||
# Read auto-provisioned credentials
|
||||
if not self._access_token:
|
||||
self._access_token = await self._bay_manager.read_credentials()
|
||||
logger.info(
|
||||
"[Computer] bay_autostart status=ready endpoint=%s",
|
||||
self._endpoint_url,
|
||||
)
|
||||
|
||||
if not self._endpoint_url or not self._access_token:
|
||||
if self._bay_manager is not None:
|
||||
raise ValueError(
|
||||
"Bay container started but credentials could not be read. "
|
||||
"Ensure Bay generated credentials.json, or set access_token manually."
|
||||
)
|
||||
raise ValueError(
|
||||
"Shipyard Neo sandbox configuration is incomplete. "
|
||||
"Set endpoint (default http://127.0.0.1:8114) and access token, "
|
||||
"or ensure Bay's credentials.json is accessible for auto-discovery."
|
||||
)
|
||||
|
||||
from shipyard_neo import BayClient
|
||||
|
||||
self._client = BayClient(
|
||||
endpoint_url=self._endpoint_url,
|
||||
access_token=self._access_token,
|
||||
)
|
||||
await self._client.__aenter__()
|
||||
|
||||
# Resolve profile: user-specified > smart selection > default
|
||||
resolved_profile = await self._resolve_profile(self._client)
|
||||
|
||||
self._sandbox = await self._client.create_sandbox(
|
||||
profile=resolved_profile,
|
||||
ttl=self._ttl,
|
||||
)
|
||||
|
||||
self._fs = NeoFileSystemComponent(self._sandbox)
|
||||
self._python = NeoPythonComponent(self._sandbox)
|
||||
self._shell = NeoShellComponent(self._sandbox)
|
||||
|
||||
caps = self.capabilities or ()
|
||||
self._browser = (
|
||||
NeoBrowserComponent(self._sandbox) if "browser" in caps else None
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[Computer] sandbox_created booter=shipyard_neo sandbox_id=%s profile=%s capabilities=%s auto=%s",
|
||||
self._sandbox.id,
|
||||
resolved_profile,
|
||||
list(caps),
|
||||
bool(self._bay_manager),
|
||||
)
|
||||
|
||||
async def _resolve_profile(self, client: Any) -> str:
|
||||
"""Pick the best profile for this session.
|
||||
|
||||
Resolution order:
|
||||
1. User-specified profile (non-empty, non-default) → use as-is.
|
||||
2. Query ``GET /v1/profiles`` and pick the profile with the most
|
||||
capabilities, preferring profiles that include ``"browser"``.
|
||||
3. Fall back to :attr:`DEFAULT_PROFILE`.
|
||||
|
||||
Auth errors (401/403) are re-raised immediately — they indicate a
|
||||
misconfigured token, and silently falling back would just delay the
|
||||
real failure to ``create_sandbox``.
|
||||
"""
|
||||
# User explicitly set a profile → honour it
|
||||
if self._profile and self._profile != self.DEFAULT_PROFILE:
|
||||
logger.info(
|
||||
"[Computer] profile_selected mode=user profile=%s",
|
||||
self._profile,
|
||||
)
|
||||
return self._profile
|
||||
|
||||
# Query Bay for available profiles
|
||||
from shipyard_neo.errors import ForbiddenError, UnauthorizedError
|
||||
|
||||
try:
|
||||
profile_list = await client.list_profiles()
|
||||
profiles = profile_list.items
|
||||
except (UnauthorizedError, ForbiddenError):
|
||||
raise # auth errors must not be silenced
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[Computer] profile_selection_fallback reason=query_failed fallback=%s error=%s",
|
||||
self.DEFAULT_PROFILE,
|
||||
exc,
|
||||
)
|
||||
return self.DEFAULT_PROFILE
|
||||
|
||||
if not profiles:
|
||||
return self.DEFAULT_PROFILE
|
||||
|
||||
def _score(p: Any) -> tuple[int, int]:
|
||||
"""(has_browser, capability_count) — higher is better."""
|
||||
caps = getattr(p, "capabilities", []) or []
|
||||
return (1 if "browser" in caps else 0, len(caps))
|
||||
|
||||
best = max(profiles, key=_score)
|
||||
chosen = getattr(best, "id", self.DEFAULT_PROFILE)
|
||||
|
||||
if chosen != self.DEFAULT_PROFILE:
|
||||
caps = getattr(best, "capabilities", [])
|
||||
logger.info(
|
||||
"[Computer] profile_selected mode=auto profile=%s capabilities=%s",
|
||||
chosen,
|
||||
caps,
|
||||
)
|
||||
|
||||
return chosen
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
if self._client is not None:
|
||||
sandbox_id = getattr(self._sandbox, "id", "unknown")
|
||||
logger.info(
|
||||
"[Computer] booter_shutdown booter=shipyard_neo sandbox_id=%s status=starting",
|
||||
sandbox_id,
|
||||
)
|
||||
await self._client.__aexit__(None, None, None)
|
||||
self._client = None
|
||||
self._sandbox = None
|
||||
logger.info(
|
||||
"[Computer] booter_shutdown booter=shipyard_neo sandbox_id=%s status=done",
|
||||
sandbox_id,
|
||||
)
|
||||
|
||||
# NOTE: We intentionally do NOT stop the Bay container here.
|
||||
# It stays running for reuse by future sessions. The user can
|
||||
# stop it manually or via ``BayContainerManager.stop()``.
|
||||
if self._bay_manager is not None:
|
||||
await self._bay_manager.close_client()
|
||||
|
||||
@property
|
||||
def fs(self) -> FileSystemComponent:
|
||||
if self._fs is None:
|
||||
raise RuntimeError("ShipyardNeoBooter is not initialized.")
|
||||
return self._fs
|
||||
|
||||
@property
|
||||
def python(self) -> PythonComponent:
|
||||
if self._python is None:
|
||||
raise RuntimeError("ShipyardNeoBooter is not initialized.")
|
||||
return self._python
|
||||
|
||||
@property
|
||||
def shell(self) -> ShellComponent:
|
||||
if self._shell is None:
|
||||
raise RuntimeError("ShipyardNeoBooter is not initialized.")
|
||||
return self._shell
|
||||
|
||||
@property
|
||||
def browser(self) -> BrowserComponent | None:
|
||||
return self._browser
|
||||
|
||||
async def upload_file(self, path: str, file_name: str) -> dict:
|
||||
if self._sandbox is None:
|
||||
raise RuntimeError("ShipyardNeoBooter is not initialized.")
|
||||
with open(path, "rb") as f:
|
||||
content = f.read()
|
||||
remote_path = file_name.lstrip("/")
|
||||
await self._sandbox.filesystem.upload(remote_path, content)
|
||||
logger.info(
|
||||
"[Computer] file_upload booter=shipyard_neo remote_path=%s",
|
||||
remote_path,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"message": "File uploaded successfully",
|
||||
"file_path": remote_path,
|
||||
}
|
||||
|
||||
async def download_file(self, remote_path: str, local_path: str) -> None:
|
||||
if self._sandbox is None:
|
||||
raise RuntimeError("ShipyardNeoBooter is not initialized.")
|
||||
content = await self._sandbox.filesystem.download(remote_path.lstrip("/"))
|
||||
local_dir = os.path.dirname(local_path)
|
||||
if local_dir:
|
||||
os.makedirs(local_dir, exist_ok=True)
|
||||
with open(local_path, "wb") as f:
|
||||
f.write(cast(bytes, content))
|
||||
logger.info(
|
||||
"[Computer] file_download booter=shipyard_neo remote_path=%s local_path=%s",
|
||||
remote_path,
|
||||
local_path,
|
||||
)
|
||||
|
||||
async def available(self) -> bool:
|
||||
if self._sandbox is None:
|
||||
return False
|
||||
try:
|
||||
await self._sandbox.refresh()
|
||||
status = getattr(self._sandbox.status, "value", str(self._sandbox.status))
|
||||
healthy = status not in {"failed", "expired"}
|
||||
logger.debug(
|
||||
"[Computer] health_check booter=shipyard_neo sandbox_id=%s status=%s healthy=%s",
|
||||
getattr(self._sandbox, "id", "unknown"),
|
||||
status,
|
||||
healthy,
|
||||
)
|
||||
return healthy
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"[Computer] health_check_failed booter=shipyard_neo sandbox_id=%s",
|
||||
getattr(self._sandbox, "id", "unknown"),
|
||||
)
|
||||
return False
|
||||
|
||||
# ── Tool / prompt self-description ────────────────────────────
|
||||
|
||||
@classmethod
|
||||
@functools.cache
|
||||
def _base_tools(cls) -> tuple[FunctionTool, ...]:
|
||||
"""4 base + 11 Neo lifecycle = 15 tools (all Neo profiles)."""
|
||||
from astrbot.core.computer.tools import (
|
||||
AnnotateExecutionTool,
|
||||
CreateSkillCandidateTool,
|
||||
CreateSkillPayloadTool,
|
||||
EvaluateSkillCandidateTool,
|
||||
ExecuteShellTool,
|
||||
FileDownloadTool,
|
||||
FileUploadTool,
|
||||
GetExecutionHistoryTool,
|
||||
GetSkillPayloadTool,
|
||||
ListSkillCandidatesTool,
|
||||
ListSkillReleasesTool,
|
||||
PromoteSkillCandidateTool,
|
||||
PythonTool,
|
||||
RollbackSkillReleaseTool,
|
||||
SyncSkillReleaseTool,
|
||||
)
|
||||
|
||||
return (
|
||||
ExecuteShellTool(),
|
||||
PythonTool(),
|
||||
FileUploadTool(),
|
||||
FileDownloadTool(),
|
||||
GetExecutionHistoryTool(),
|
||||
AnnotateExecutionTool(),
|
||||
CreateSkillPayloadTool(),
|
||||
GetSkillPayloadTool(),
|
||||
CreateSkillCandidateTool(),
|
||||
ListSkillCandidatesTool(),
|
||||
EvaluateSkillCandidateTool(),
|
||||
PromoteSkillCandidateTool(),
|
||||
ListSkillReleasesTool(),
|
||||
RollbackSkillReleaseTool(),
|
||||
SyncSkillReleaseTool(),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@functools.cache
|
||||
def _browser_tools(cls) -> tuple[FunctionTool, ...]:
|
||||
from astrbot.core.computer.tools import (
|
||||
BrowserBatchExecTool,
|
||||
BrowserExecTool,
|
||||
RunBrowserSkillTool,
|
||||
)
|
||||
|
||||
return (BrowserExecTool(), BrowserBatchExecTool(), RunBrowserSkillTool())
|
||||
|
||||
@classmethod
|
||||
def get_default_tools(cls) -> list[FunctionTool]:
|
||||
"""Pre-boot: conservative full list (including browser)."""
|
||||
return list(cls._base_tools()) + list(cls._browser_tools())
|
||||
|
||||
def get_tools(self) -> list[FunctionTool]:
|
||||
"""Post-boot: capability-filtered list."""
|
||||
caps = self.capabilities
|
||||
if caps is None:
|
||||
return self.__class__.get_default_tools()
|
||||
tools = list(self._base_tools())
|
||||
if "browser" in caps:
|
||||
tools.extend(self._browser_tools())
|
||||
return tools
|
||||
|
||||
@classmethod
|
||||
def get_system_prompt_parts(cls) -> list[str]:
|
||||
from astrbot.core.computer.prompts import (
|
||||
NEO_FILE_PATH_PROMPT,
|
||||
NEO_SKILL_LIFECYCLE_PROMPT,
|
||||
)
|
||||
|
||||
return [NEO_FILE_PATH_PROMPT, NEO_SKILL_LIFECYCLE_PROMPT]
|
||||
@@ -1,10 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.core.skills.skill_manager import SANDBOX_SKILLS_ROOT
|
||||
from astrbot.core.skills.skill_manager import SANDBOX_SKILLS_ROOT, SkillManager
|
||||
from astrbot.core.star.context import Context
|
||||
from astrbot.core.utils.astrbot_path import (
|
||||
get_astrbot_skills_path,
|
||||
@@ -12,51 +16,426 @@ from astrbot.core.utils.astrbot_path import (
|
||||
)
|
||||
|
||||
from .booters.base import ComputerBooter
|
||||
from .booters.constants import BOOTER_BOXLITE, BOOTER_SHIPYARD, BOOTER_SHIPYARD_NEO
|
||||
from .booters.local import LocalBooter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from astrbot.core.agent.tool import FunctionTool
|
||||
|
||||
session_booter: dict[str, ComputerBooter] = {}
|
||||
local_booter: ComputerBooter | None = None
|
||||
_MANAGED_SKILLS_FILE = ".astrbot_managed_skills.json"
|
||||
|
||||
|
||||
def _list_local_skill_dirs(skills_root: Path) -> list[Path]:
|
||||
skills: list[Path] = []
|
||||
for entry in sorted(skills_root.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
skill_md = entry / "SKILL.md"
|
||||
if skill_md.exists():
|
||||
skills.append(entry)
|
||||
return skills
|
||||
|
||||
|
||||
def _discover_bay_credentials(endpoint: str) -> str:
|
||||
"""Try to auto-discover Bay API key from credentials.json.
|
||||
|
||||
Search order:
|
||||
1. BAY_DATA_DIR env var
|
||||
2. Mono-repo relative path: ../pkgs/bay/ (dev layout)
|
||||
3. Current working directory
|
||||
|
||||
Returns:
|
||||
API key string, or empty string if not found.
|
||||
"""
|
||||
candidates: list[Path] = []
|
||||
|
||||
# 1. BAY_DATA_DIR env var
|
||||
bay_data_dir = os.environ.get("BAY_DATA_DIR")
|
||||
if bay_data_dir:
|
||||
candidates.append(Path(bay_data_dir) / "credentials.json")
|
||||
|
||||
# 2. Mono-repo layout: AstrBot/../pkgs/bay/credentials.json
|
||||
astrbot_root = Path(__file__).resolve().parents[3] # astrbot/core/computer/ → root
|
||||
candidates.append(astrbot_root.parent / "pkgs" / "bay" / "credentials.json")
|
||||
|
||||
# 3. Current working directory
|
||||
candidates.append(Path.cwd() / "credentials.json")
|
||||
|
||||
for cred_path in candidates:
|
||||
if not cred_path.is_file():
|
||||
continue
|
||||
try:
|
||||
data = json.loads(cred_path.read_text())
|
||||
api_key = data.get("api_key", "")
|
||||
if api_key:
|
||||
# Optionally verify endpoint matches
|
||||
cred_endpoint = data.get("endpoint", "")
|
||||
if (
|
||||
cred_endpoint
|
||||
and endpoint
|
||||
and cred_endpoint.rstrip("/") != endpoint.rstrip("/")
|
||||
):
|
||||
logger.warning(
|
||||
"[Computer] bay_credentials_mismatch file_endpoint=%s configured_endpoint=%s action=use_key",
|
||||
cred_endpoint,
|
||||
endpoint,
|
||||
)
|
||||
masked_key = f"{api_key[:4]}..." if len(api_key) >= 6 else "redacted"
|
||||
logger.info(
|
||||
"[Computer] bay_credentials_lookup status=found path=%s key_prefix=%s",
|
||||
cred_path,
|
||||
masked_key,
|
||||
)
|
||||
return api_key
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
logger.debug(
|
||||
"[Computer] bay_credentials_read_failed path=%s error=%s",
|
||||
cred_path,
|
||||
exc,
|
||||
)
|
||||
|
||||
logger.debug("[Computer] bay_credentials_lookup status=not_found")
|
||||
return ""
|
||||
|
||||
|
||||
def _build_python_exec_command(script: str) -> str:
|
||||
return (
|
||||
"if command -v python3 >/dev/null 2>&1; then PYBIN=python3; "
|
||||
"elif command -v python >/dev/null 2>&1; then PYBIN=python; "
|
||||
"else echo 'python not found in sandbox' >&2; exit 127; fi; "
|
||||
"$PYBIN - <<'PY'\n"
|
||||
f"{script}\n"
|
||||
"PY"
|
||||
)
|
||||
|
||||
|
||||
def _build_apply_sync_command() -> str:
|
||||
"""Build shell command for sync stage only.
|
||||
|
||||
This stage mutates sandbox files (managed skill replacement) but does not scan
|
||||
metadata. Keeping it separate allows callers to preserve old behavior while
|
||||
reusing the apply step independently.
|
||||
"""
|
||||
script = f"""
|
||||
import json
|
||||
import shutil
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
root = Path({SANDBOX_SKILLS_ROOT!r})
|
||||
zip_path = root / "skills.zip"
|
||||
tmp_extract = Path(f"{{root}}_tmp_extract")
|
||||
managed_file = root / {_MANAGED_SKILLS_FILE!r}
|
||||
|
||||
|
||||
def remove_tree(path: Path) -> None:
|
||||
if not path.exists():
|
||||
return
|
||||
if path.is_dir():
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
else:
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def load_managed_skills() -> list[str]:
|
||||
if not managed_file.exists():
|
||||
return []
|
||||
try:
|
||||
payload = json.loads(managed_file.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return []
|
||||
if not isinstance(payload, dict):
|
||||
return []
|
||||
items = payload.get("managed_skills", [])
|
||||
if not isinstance(items, list):
|
||||
return []
|
||||
result: list[str] = []
|
||||
for item in items:
|
||||
if isinstance(item, str) and item.strip():
|
||||
result.append(item.strip())
|
||||
return result
|
||||
|
||||
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
for managed_name in load_managed_skills():
|
||||
remove_tree(root / managed_name)
|
||||
|
||||
current_managed: list[str] = []
|
||||
if zip_path.exists():
|
||||
remove_tree(tmp_extract)
|
||||
tmp_extract.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
zf.extractall(tmp_extract)
|
||||
for entry in sorted(tmp_extract.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
target = root / entry.name
|
||||
remove_tree(target)
|
||||
shutil.copytree(entry, target)
|
||||
current_managed.append(entry.name)
|
||||
|
||||
remove_tree(tmp_extract)
|
||||
remove_tree(zip_path)
|
||||
managed_file.write_text(
|
||||
json.dumps({{"managed_skills": current_managed}}, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(json.dumps({{"managed_skills": current_managed}}, ensure_ascii=False))
|
||||
""".strip()
|
||||
return _build_python_exec_command(script)
|
||||
|
||||
|
||||
def _build_scan_command() -> str:
|
||||
"""Build shell command for scan stage only.
|
||||
|
||||
This stage is read-oriented: it scans SKILL.md metadata and returns the
|
||||
historical payload shape consumed by cache update logic.
|
||||
|
||||
The scan resolves the absolute path of the skills root at runtime so
|
||||
that the LLM can reliably ``cat`` skill files regardless of cwd.
|
||||
Only the ``description`` field is extracted from frontmatter.
|
||||
"""
|
||||
script = f"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
root = Path({SANDBOX_SKILLS_ROOT!r})
|
||||
managed_file = root / {_MANAGED_SKILLS_FILE!r}
|
||||
|
||||
# Resolve absolute path at runtime so prompts always have a reliable path
|
||||
root_abs = str(root.resolve())
|
||||
|
||||
|
||||
# NOTE: This parser mirrors skill_manager._parse_frontmatter_description.
|
||||
# Keep the two implementations in sync when changing parsing logic.
|
||||
def parse_description(text: str) -> str:
|
||||
if not text.startswith("---"):
|
||||
return ""
|
||||
lines = text.splitlines()
|
||||
if not lines or lines[0].strip() != "---":
|
||||
return ""
|
||||
end_idx = None
|
||||
for i in range(1, len(lines)):
|
||||
if lines[i].strip() == "---":
|
||||
end_idx = i
|
||||
break
|
||||
if end_idx is None:
|
||||
return ""
|
||||
|
||||
frontmatter = "\n".join(lines[1:end_idx])
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
return ""
|
||||
|
||||
try:
|
||||
payload = yaml.safe_load(frontmatter) or dict()
|
||||
except yaml.YAMLError:
|
||||
return ""
|
||||
if not isinstance(payload, dict):
|
||||
return ""
|
||||
|
||||
description = payload.get("description", "")
|
||||
if not isinstance(description, str):
|
||||
return ""
|
||||
return description.strip()
|
||||
|
||||
|
||||
def load_managed_skills() -> list[str]:
|
||||
if not managed_file.exists():
|
||||
return []
|
||||
try:
|
||||
payload = json.loads(managed_file.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return []
|
||||
if not isinstance(payload, dict):
|
||||
return []
|
||||
items = payload.get("managed_skills", [])
|
||||
if not isinstance(items, list):
|
||||
return []
|
||||
result: list[str] = []
|
||||
for item in items:
|
||||
if isinstance(item, str) and item.strip():
|
||||
result.append(item.strip())
|
||||
return result
|
||||
|
||||
|
||||
def collect_skills() -> list[dict[str, str]]:
|
||||
skills: list[dict[str, str]] = []
|
||||
if not root.exists():
|
||||
return skills
|
||||
for skill_dir in sorted(root.iterdir()):
|
||||
if not skill_dir.is_dir():
|
||||
continue
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
if not skill_md.is_file():
|
||||
continue
|
||||
description = ""
|
||||
try:
|
||||
text = skill_md.read_text(encoding="utf-8")
|
||||
description = parse_description(text)
|
||||
except Exception:
|
||||
description = ""
|
||||
skills.append(
|
||||
{{
|
||||
"name": skill_dir.name,
|
||||
"description": description,
|
||||
"path": f"{{root_abs}}/{{skill_dir.name}}/SKILL.md",
|
||||
}}
|
||||
)
|
||||
return skills
|
||||
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{{
|
||||
"managed_skills": load_managed_skills(),
|
||||
"skills": collect_skills(),
|
||||
}},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
""".strip()
|
||||
return _build_python_exec_command(script)
|
||||
|
||||
|
||||
def _shell_exec_succeeded(result: dict) -> bool:
|
||||
if "success" in result:
|
||||
return bool(result.get("success"))
|
||||
exit_code = result.get("exit_code")
|
||||
return exit_code in (0, None)
|
||||
|
||||
|
||||
def _format_exec_error_detail(result: dict) -> str:
|
||||
"""Format shell execution details for better observability.
|
||||
|
||||
Keep the message compact while still surfacing exit code and stderr/stdout.
|
||||
"""
|
||||
exit_code = result.get("exit_code")
|
||||
stderr = str(result.get("stderr", "") or "").strip()
|
||||
stdout = str(result.get("stdout", "") or "").strip()
|
||||
stderr_text = stderr[:500]
|
||||
stdout_text = stdout[:300]
|
||||
return f"exit_code={exit_code}, stderr={stderr_text!r}, stdout_tail={stdout_text!r}"
|
||||
|
||||
|
||||
def _decode_sync_payload(stdout: str) -> dict | None:
|
||||
text = stdout.strip()
|
||||
if not text:
|
||||
return None
|
||||
candidates = [text]
|
||||
candidates.extend([line.strip() for line in text.splitlines() if line.strip()])
|
||||
for candidate in reversed(candidates):
|
||||
try:
|
||||
payload = json.loads(candidate)
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(payload, dict):
|
||||
return payload
|
||||
return None
|
||||
|
||||
|
||||
def _update_sandbox_skills_cache(payload: dict | None) -> None:
|
||||
if not isinstance(payload, dict):
|
||||
return
|
||||
skills = payload.get("skills", [])
|
||||
if not isinstance(skills, list):
|
||||
return
|
||||
SkillManager().set_sandbox_skills_cache(skills)
|
||||
|
||||
|
||||
async def _apply_skills_to_sandbox(booter: ComputerBooter) -> None:
|
||||
"""Apply local skill bundle to sandbox filesystem only.
|
||||
|
||||
This function is intentionally limited to file mutation. Metadata scanning is
|
||||
executed in a separate phase to keep failure domains clear.
|
||||
"""
|
||||
logger.info("[Computer] sandbox_sync phase=apply status=start")
|
||||
apply_result = await booter.shell.exec(_build_apply_sync_command())
|
||||
if not _shell_exec_succeeded(apply_result):
|
||||
detail = _format_exec_error_detail(apply_result)
|
||||
logger.error(
|
||||
"[Computer] sandbox_sync phase=apply status=failed detail=%s", detail
|
||||
)
|
||||
raise RuntimeError(f"Failed to apply sandbox skill sync strategy: {detail}")
|
||||
logger.info("[Computer] sandbox_sync phase=apply status=done")
|
||||
|
||||
|
||||
async def _scan_sandbox_skills(booter: ComputerBooter) -> dict | None:
|
||||
"""Scan sandbox skills and return normalized payload for cache update."""
|
||||
logger.info("[Computer] sandbox_sync phase=scan status=start")
|
||||
scan_result = await booter.shell.exec(_build_scan_command())
|
||||
if not _shell_exec_succeeded(scan_result):
|
||||
detail = _format_exec_error_detail(scan_result)
|
||||
logger.error(
|
||||
"[Computer] sandbox_sync phase=scan status=failed detail=%s", detail
|
||||
)
|
||||
raise RuntimeError(f"Failed to scan sandbox skills after sync: {detail}")
|
||||
|
||||
payload = _decode_sync_payload(str(scan_result.get("stdout", "") or ""))
|
||||
if payload is None:
|
||||
logger.warning("[Computer] sandbox_sync phase=scan status=empty_payload")
|
||||
else:
|
||||
logger.info("[Computer] sandbox_sync phase=scan status=done")
|
||||
return payload
|
||||
|
||||
|
||||
async def _sync_skills_to_sandbox(booter: ComputerBooter) -> None:
|
||||
skills_root = get_astrbot_skills_path()
|
||||
if not os.path.isdir(skills_root):
|
||||
return
|
||||
if not any(Path(skills_root).iterdir()):
|
||||
return
|
||||
"""Sync local skills to sandbox and refresh cache.
|
||||
|
||||
temp_dir = get_astrbot_temp_path()
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
zip_base = os.path.join(temp_dir, "skills_bundle")
|
||||
zip_path = f"{zip_base}.zip"
|
||||
Backward-compatible orchestrator: keep historical behavior while internally
|
||||
splitting into `apply` and `scan` phases.
|
||||
"""
|
||||
skills_root = Path(get_astrbot_skills_path())
|
||||
if not skills_root.is_dir():
|
||||
return
|
||||
local_skill_dirs = _list_local_skill_dirs(skills_root)
|
||||
|
||||
temp_dir = Path(get_astrbot_temp_path())
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
zip_base = temp_dir / "skills_bundle"
|
||||
zip_path = zip_base.with_suffix(".zip")
|
||||
|
||||
try:
|
||||
if os.path.exists(zip_path):
|
||||
os.remove(zip_path)
|
||||
shutil.make_archive(zip_base, "zip", skills_root)
|
||||
remote_zip = Path(SANDBOX_SKILLS_ROOT) / "skills.zip"
|
||||
logger.info("Uploading skills bundle to sandbox...")
|
||||
await booter.shell.exec(f"mkdir -p {SANDBOX_SKILLS_ROOT}")
|
||||
upload_result = await booter.upload_file(zip_path, str(remote_zip))
|
||||
if not upload_result.get("success", False):
|
||||
raise RuntimeError("Failed to upload skills bundle to sandbox.")
|
||||
# Use -n flag to never overwrite existing files, fallback to Python if unzip unavailable
|
||||
await booter.shell.exec(
|
||||
f"unzip -n {remote_zip} -d {SANDBOX_SKILLS_ROOT} || "
|
||||
f"python3 -c \"import zipfile, os, pathlib; z=zipfile.ZipFile('{remote_zip}'); "
|
||||
f"[z.extract(m, '{SANDBOX_SKILLS_ROOT}') for m in z.namelist() "
|
||||
f"if not os.path.exists(os.path.join('{SANDBOX_SKILLS_ROOT}', m))]\" || "
|
||||
f"python -c \"import zipfile, os, pathlib; z=zipfile.ZipFile('{remote_zip}'); "
|
||||
f"[z.extract(m, '{SANDBOX_SKILLS_ROOT}') for m in z.namelist() "
|
||||
f"if not os.path.exists(os.path.join('{SANDBOX_SKILLS_ROOT}', m))]\"; "
|
||||
f"rm -f {remote_zip}"
|
||||
if local_skill_dirs:
|
||||
if zip_path.exists():
|
||||
zip_path.unlink()
|
||||
shutil.make_archive(str(zip_base), "zip", str(skills_root))
|
||||
remote_zip = Path(SANDBOX_SKILLS_ROOT) / "skills.zip"
|
||||
logger.info("[Computer] sandbox_sync phase=upload status=start")
|
||||
await booter.shell.exec(f"mkdir -p {SANDBOX_SKILLS_ROOT}")
|
||||
upload_result = await booter.upload_file(str(zip_path), str(remote_zip))
|
||||
if not upload_result.get("success", False):
|
||||
logger.error("[Computer] sandbox_sync phase=upload status=failed")
|
||||
raise RuntimeError("Failed to upload skills bundle to sandbox.")
|
||||
logger.info("[Computer] sandbox_sync phase=upload status=done")
|
||||
else:
|
||||
logger.info(
|
||||
"[Computer] sandbox_sync phase=upload status=skipped reason=no_local_skills"
|
||||
)
|
||||
await booter.shell.exec(f"rm -f {SANDBOX_SKILLS_ROOT}/skills.zip")
|
||||
|
||||
# Keep backward-compatible behavior while splitting lifecycle into two
|
||||
# observable phases: apply (filesystem mutation) + scan (metadata read).
|
||||
await _apply_skills_to_sandbox(booter)
|
||||
payload = await _scan_sandbox_skills(booter)
|
||||
_update_sandbox_skills_cache(payload)
|
||||
managed = payload.get("managed_skills", []) if isinstance(payload, dict) else []
|
||||
logger.info(
|
||||
"[Computer] sandbox_sync phase=overall status=done managed=%d",
|
||||
len(managed),
|
||||
)
|
||||
finally:
|
||||
if os.path.exists(zip_path):
|
||||
if zip_path.exists():
|
||||
try:
|
||||
os.remove(zip_path)
|
||||
zip_path.unlink()
|
||||
except Exception:
|
||||
logger.warning(f"Failed to remove temp skills zip: {zip_path}")
|
||||
logger.warning(
|
||||
"[Computer] sandbox_sync phase=cleanup status=failed path=%s",
|
||||
zip_path,
|
||||
)
|
||||
|
||||
|
||||
async def get_booter(
|
||||
@@ -65,8 +444,14 @@ async def get_booter(
|
||||
) -> ComputerBooter:
|
||||
config = context.get_config(umo=session_id)
|
||||
|
||||
runtime = config.get("provider_settings", {}).get("computer_use_runtime", "local")
|
||||
if runtime == "local":
|
||||
return get_local_booter()
|
||||
elif runtime == "none":
|
||||
raise RuntimeError("Sandbox runtime is disabled by configuration.")
|
||||
|
||||
sandbox_cfg = config.get("provider_settings", {}).get("sandbox", {})
|
||||
booter_type = sandbox_cfg.get("booter", "shipyard")
|
||||
booter_type = sandbox_cfg.get("booter", "shipyard_neo")
|
||||
|
||||
if session_id in session_booter:
|
||||
booter = session_booter[session_id]
|
||||
@@ -75,6 +460,11 @@ async def get_booter(
|
||||
session_booter.pop(session_id, None)
|
||||
if session_id not in session_booter:
|
||||
uuid_str = uuid.uuid5(uuid.NAMESPACE_DNS, session_id).hex
|
||||
logger.info(
|
||||
"[Computer] booter_init booter=%s session=%s",
|
||||
booter_type,
|
||||
session_id,
|
||||
)
|
||||
if booter_type == "shipyard":
|
||||
from .booters.shipyard import ShipyardBooter
|
||||
|
||||
@@ -86,6 +476,27 @@ async def get_booter(
|
||||
client = ShipyardBooter(
|
||||
endpoint_url=ep, access_token=token, ttl=ttl, session_num=max_sessions
|
||||
)
|
||||
elif booter_type == "shipyard_neo":
|
||||
from .booters.shipyard_neo import ShipyardNeoBooter
|
||||
|
||||
ep = sandbox_cfg.get("shipyard_neo_endpoint", "")
|
||||
token = sandbox_cfg.get("shipyard_neo_access_token", "")
|
||||
ttl = sandbox_cfg.get("shipyard_neo_ttl", 3600)
|
||||
profile = sandbox_cfg.get("shipyard_neo_profile", "python-default")
|
||||
|
||||
# Auto-discover token from Bay's credentials.json if not configured
|
||||
if not token:
|
||||
token = _discover_bay_credentials(ep)
|
||||
|
||||
logger.info(
|
||||
f"[Computer] Shipyard Neo config: endpoint={ep}, profile={profile}, ttl={ttl}"
|
||||
)
|
||||
client = ShipyardNeoBooter(
|
||||
endpoint_url=ep,
|
||||
access_token=token,
|
||||
profile=profile,
|
||||
ttl=ttl,
|
||||
)
|
||||
elif booter_type == "boxlite":
|
||||
from .booters.boxlite import BoxliteBooter
|
||||
|
||||
@@ -95,17 +506,137 @@ async def get_booter(
|
||||
|
||||
try:
|
||||
await client.boot(uuid_str)
|
||||
logger.info(
|
||||
"[Computer] booter_ready booter=%s session=%s",
|
||||
booter_type,
|
||||
session_id,
|
||||
)
|
||||
await _sync_skills_to_sandbox(client)
|
||||
except Exception as e:
|
||||
logger.error(f"Error booting sandbox for session {session_id}: {e}")
|
||||
raise e
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"[Computer] booter_init_failed booter=%s session=%s",
|
||||
booter_type,
|
||||
session_id,
|
||||
)
|
||||
raise
|
||||
|
||||
session_booter[session_id] = client
|
||||
return session_booter[session_id]
|
||||
|
||||
|
||||
async def sync_skills_to_active_sandboxes() -> None:
|
||||
"""Best-effort skills synchronization for all active sandbox sessions."""
|
||||
logger.info(
|
||||
"[Computer] sandbox_sync scope=active sessions=%d",
|
||||
len(session_booter),
|
||||
)
|
||||
for session_id, booter in list(session_booter.items()):
|
||||
try:
|
||||
if not await booter.available():
|
||||
continue
|
||||
await _sync_skills_to_sandbox(booter)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"[Computer] sandbox_sync_failed session=%s booter=%s",
|
||||
session_id,
|
||||
booter.__class__.__name__,
|
||||
)
|
||||
|
||||
|
||||
def get_local_booter() -> ComputerBooter:
|
||||
global local_booter
|
||||
if local_booter is None:
|
||||
local_booter = LocalBooter()
|
||||
return local_booter
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unified query API — used by ComputerToolProvider and subagent tool exec
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_booter_class(booter_type: str) -> type[ComputerBooter] | None:
|
||||
"""Map booter_type string to class (lazy import)."""
|
||||
if booter_type == BOOTER_SHIPYARD:
|
||||
from .booters.shipyard import ShipyardBooter
|
||||
|
||||
return ShipyardBooter
|
||||
elif booter_type == BOOTER_SHIPYARD_NEO:
|
||||
from .booters.shipyard_neo import ShipyardNeoBooter
|
||||
|
||||
return ShipyardNeoBooter
|
||||
elif booter_type == BOOTER_BOXLITE:
|
||||
from .booters.boxlite import BoxliteBooter
|
||||
|
||||
return BoxliteBooter
|
||||
logger.warning(
|
||||
"[Computer] booter_class_lookup booter=%s found=false",
|
||||
booter_type,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def get_sandbox_tools(session_id: str) -> list[FunctionTool]:
|
||||
"""Return precise tool list from a booted session, or [] if not booted."""
|
||||
booter = session_booter.get(session_id)
|
||||
if booter is None:
|
||||
logger.debug(
|
||||
"[Computer] sandbox_tools source=booted session=%s booter=none tools=0 capabilities=none",
|
||||
session_id,
|
||||
)
|
||||
return []
|
||||
tools = booter.get_tools()
|
||||
caps = getattr(booter, "capabilities", None)
|
||||
logger.debug(
|
||||
"[Computer] sandbox_tools source=booted session=%s booter=%s tools=%d capabilities=%s",
|
||||
session_id,
|
||||
booter.__class__.__name__,
|
||||
len(tools),
|
||||
list(caps) if caps is not None else None,
|
||||
)
|
||||
return tools
|
||||
|
||||
|
||||
def get_sandbox_capabilities(session_id: str) -> tuple[str, ...] | None:
|
||||
"""Return capability tuple from a booted session, or None if unavailable."""
|
||||
booter = session_booter.get(session_id)
|
||||
if booter is None:
|
||||
logger.debug(
|
||||
"[Computer] sandbox_capabilities session=%s booter=none capabilities=none",
|
||||
session_id,
|
||||
)
|
||||
return None
|
||||
caps = getattr(booter, "capabilities", None)
|
||||
logger.debug(
|
||||
"[Computer] sandbox_capabilities session=%s booter=%s capabilities=%s",
|
||||
session_id,
|
||||
booter.__class__.__name__,
|
||||
list(caps) if caps is not None else None,
|
||||
)
|
||||
return caps
|
||||
|
||||
|
||||
def get_default_sandbox_tools(sandbox_cfg: dict) -> list[FunctionTool]:
|
||||
"""Return conservative (pre-boot) tool list based on config. No instance needed."""
|
||||
booter_type = sandbox_cfg.get("booter", BOOTER_SHIPYARD_NEO)
|
||||
cls = _get_booter_class(booter_type)
|
||||
tools = cls.get_default_tools() if cls else []
|
||||
logger.debug(
|
||||
"[Computer] sandbox_tools source=default booter=%s tools=%d capabilities=unknown",
|
||||
booter_type,
|
||||
len(tools),
|
||||
)
|
||||
return tools
|
||||
|
||||
|
||||
def get_sandbox_prompt_parts(sandbox_cfg: dict) -> list[str]:
|
||||
"""Return booter-specific system prompt fragments based on config."""
|
||||
booter_type = sandbox_cfg.get("booter", BOOTER_SHIPYARD_NEO)
|
||||
cls = _get_booter_class(booter_type)
|
||||
prompt_parts = cls.get_system_prompt_parts() if cls else []
|
||||
logger.debug(
|
||||
"[Computer] sandbox_prompts booter=%s parts=%d",
|
||||
booter_type,
|
||||
len(prompt_parts),
|
||||
)
|
||||
return prompt_parts
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
"""ComputerToolProvider — decoupled tool injection for computer-use runtimes.
|
||||
|
||||
Encapsulates all sandbox / local tool injection logic previously hardcoded in
|
||||
``astr_main_agent.py``. The main agent now calls
|
||||
``provider.get_tools(ctx)`` / ``provider.get_system_prompt_addon(ctx)``
|
||||
without knowing about specific tool classes.
|
||||
|
||||
Tool lists are delegated to booter subclasses via ``get_default_tools()``
|
||||
and ``get_tools()`` (see ``booters/base.py``), so adding a new booter type
|
||||
does not require changes here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.core.tool_provider import ToolProviderContext
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from astrbot.core.agent.tool import FunctionTool
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lazy local-mode tool cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_LOCAL_TOOLS_CACHE: list[FunctionTool] | None = None
|
||||
|
||||
|
||||
def _get_local_tools() -> list[FunctionTool]:
|
||||
global _LOCAL_TOOLS_CACHE
|
||||
if _LOCAL_TOOLS_CACHE is None:
|
||||
from astrbot.core.computer.tools import ExecuteShellTool, LocalPythonTool
|
||||
|
||||
_LOCAL_TOOLS_CACHE = [
|
||||
ExecuteShellTool(is_local=True),
|
||||
LocalPythonTool(),
|
||||
]
|
||||
return list(_LOCAL_TOOLS_CACHE)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System-prompt helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SANDBOX_MODE_PROMPT = (
|
||||
"You have access to a sandboxed environment and can execute "
|
||||
"shell commands and Python code securely."
|
||||
)
|
||||
|
||||
|
||||
def _build_local_mode_prompt() -> str:
|
||||
system_name = platform.system() or "Unknown"
|
||||
shell_hint = (
|
||||
"The runtime shell is Windows Command Prompt (cmd.exe). "
|
||||
"Use cmd-compatible commands and do not assume Unix commands like cat/ls/grep are available."
|
||||
if system_name.lower() == "windows"
|
||||
else "The runtime shell is Unix-like. Use POSIX-compatible shell commands."
|
||||
)
|
||||
return (
|
||||
"You have access to the host local environment and can execute shell commands and Python code. "
|
||||
f"Current operating system: {system_name}. "
|
||||
f"{shell_hint}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ComputerToolProvider
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ComputerToolProvider:
|
||||
"""Provides computer-use tools (local / sandbox) based on session context.
|
||||
|
||||
Sandbox tool lists are delegated to booter subclasses so that each booter
|
||||
declares its own capabilities. ``get_tools`` prefers the precise
|
||||
post-boot tool list from a running session; when the sandbox has not yet
|
||||
been booted it falls back to the conservative pre-boot default.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_all_tools() -> list[FunctionTool]:
|
||||
"""Return ALL computer-use tools across all runtimes for registration.
|
||||
|
||||
Creates **fresh instances** separate from the runtime caches so that
|
||||
setting ``active=False`` on them does not affect runtime behaviour.
|
||||
These registration-only instances let the WebUI display and assign
|
||||
tools without injecting them into actual LLM requests.
|
||||
|
||||
At request time, ``get_tools(ctx)`` provides the real, active
|
||||
instances filtered by runtime.
|
||||
"""
|
||||
from astrbot.core.computer.tools import (
|
||||
AnnotateExecutionTool,
|
||||
BrowserBatchExecTool,
|
||||
BrowserExecTool,
|
||||
CreateSkillCandidateTool,
|
||||
CreateSkillPayloadTool,
|
||||
EvaluateSkillCandidateTool,
|
||||
ExecuteShellTool,
|
||||
FileDownloadTool,
|
||||
FileUploadTool,
|
||||
GetExecutionHistoryTool,
|
||||
GetSkillPayloadTool,
|
||||
ListSkillCandidatesTool,
|
||||
ListSkillReleasesTool,
|
||||
LocalPythonTool,
|
||||
PromoteSkillCandidateTool,
|
||||
PythonTool,
|
||||
RollbackSkillReleaseTool,
|
||||
RunBrowserSkillTool,
|
||||
SyncSkillReleaseTool,
|
||||
)
|
||||
|
||||
all_tools: list[FunctionTool] = [
|
||||
ExecuteShellTool(),
|
||||
PythonTool(),
|
||||
FileUploadTool(),
|
||||
FileDownloadTool(),
|
||||
LocalPythonTool(),
|
||||
BrowserExecTool(),
|
||||
BrowserBatchExecTool(),
|
||||
RunBrowserSkillTool(),
|
||||
GetExecutionHistoryTool(),
|
||||
AnnotateExecutionTool(),
|
||||
CreateSkillPayloadTool(),
|
||||
GetSkillPayloadTool(),
|
||||
CreateSkillCandidateTool(),
|
||||
ListSkillCandidatesTool(),
|
||||
EvaluateSkillCandidateTool(),
|
||||
PromoteSkillCandidateTool(),
|
||||
ListSkillReleasesTool(),
|
||||
RollbackSkillReleaseTool(),
|
||||
SyncSkillReleaseTool(),
|
||||
]
|
||||
|
||||
# De-duplicate by name and mark inactive so they are visible
|
||||
# in WebUI but never sent to the LLM via func_list.
|
||||
seen: set[str] = set()
|
||||
result: list[FunctionTool] = []
|
||||
for tool in all_tools:
|
||||
if tool.name not in seen:
|
||||
tool.active = False
|
||||
result.append(tool)
|
||||
seen.add(tool.name)
|
||||
return result
|
||||
|
||||
def get_tools(self, ctx: ToolProviderContext) -> list[FunctionTool]:
|
||||
runtime = ctx.computer_use_runtime
|
||||
if runtime == "none":
|
||||
return []
|
||||
|
||||
if runtime == "local":
|
||||
return _get_local_tools()
|
||||
|
||||
if runtime == "sandbox":
|
||||
return self._sandbox_tools(ctx)
|
||||
|
||||
logger.warning("[ComputerToolProvider] Unknown runtime: %s", runtime)
|
||||
return []
|
||||
|
||||
def get_system_prompt_addon(self, ctx: ToolProviderContext) -> str:
|
||||
runtime = ctx.computer_use_runtime
|
||||
if runtime == "none":
|
||||
return ""
|
||||
|
||||
if runtime == "local":
|
||||
return f"\n{_build_local_mode_prompt()}\n"
|
||||
|
||||
if runtime == "sandbox":
|
||||
return self._sandbox_prompt_addon(ctx)
|
||||
|
||||
return ""
|
||||
|
||||
# -- sandbox helpers ----------------------------------------------------
|
||||
|
||||
def _sandbox_tools(self, ctx: ToolProviderContext) -> list[FunctionTool]:
|
||||
"""Collect tools for sandbox mode.
|
||||
|
||||
Always returns the full (pre-boot default) tool set declared by the
|
||||
booter class, regardless of whether the sandbox is already booted.
|
||||
|
||||
This ensures the tool schema sent to the LLM is stable across the
|
||||
entire conversation lifecycle (pre-boot and post-boot produce the
|
||||
same set), enabling LLM prefix cache hits. Tools whose underlying
|
||||
capability is unavailable at runtime are rejected by the executor
|
||||
with a descriptive error message instead of being omitted from the
|
||||
schema.
|
||||
"""
|
||||
from astrbot.core.computer.computer_client import get_default_sandbox_tools
|
||||
|
||||
booter_type = ctx.sandbox_cfg.get("booter", "shipyard_neo")
|
||||
|
||||
# Validate shipyard (non-neo) config
|
||||
if booter_type == "shipyard":
|
||||
ep = ctx.sandbox_cfg.get("shipyard_endpoint", "")
|
||||
at = ctx.sandbox_cfg.get("shipyard_access_token", "")
|
||||
if not ep or not at:
|
||||
logger.error("Shipyard sandbox configuration is incomplete.")
|
||||
return []
|
||||
|
||||
# Always return the full tool set for schema stability
|
||||
return get_default_sandbox_tools(ctx.sandbox_cfg)
|
||||
|
||||
def _sandbox_prompt_addon(self, ctx: ToolProviderContext) -> str:
|
||||
"""Build system-prompt addon for sandbox mode."""
|
||||
from astrbot.core.computer.computer_client import get_sandbox_prompt_parts
|
||||
|
||||
parts = get_sandbox_prompt_parts(ctx.sandbox_cfg)
|
||||
parts.append(f"\n{SANDBOX_MODE_PROMPT}\n")
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def get_all_tools() -> list[FunctionTool]:
|
||||
"""Module-level entry point for ``FunctionToolManager.register_internal_tools()``.
|
||||
|
||||
Delegates to ``ComputerToolProvider.get_all_tools()`` which collects
|
||||
tools from all runtimes (local, sandbox, browser, neo).
|
||||
"""
|
||||
return ComputerToolProvider.get_all_tools()
|
||||
@@ -1,5 +1,11 @@
|
||||
from .browser import BrowserComponent
|
||||
from .filesystem import FileSystemComponent
|
||||
from .python import PythonComponent
|
||||
from .shell import ShellComponent
|
||||
|
||||
__all__ = ["PythonComponent", "ShellComponent", "FileSystemComponent"]
|
||||
__all__ = [
|
||||
"PythonComponent",
|
||||
"ShellComponent",
|
||||
"FileSystemComponent",
|
||||
"BrowserComponent",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Browser automation component
|
||||
"""
|
||||
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
class BrowserComponent(Protocol):
|
||||
"""Browser operations component"""
|
||||
|
||||
async def exec(
|
||||
self,
|
||||
cmd: str,
|
||||
timeout: int = 30,
|
||||
description: str | None = None,
|
||||
tags: str | None = None,
|
||||
learn: bool = False,
|
||||
include_trace: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute a browser automation command"""
|
||||
...
|
||||
|
||||
async def exec_batch(
|
||||
self,
|
||||
commands: list[str],
|
||||
timeout: int = 60,
|
||||
stop_on_error: bool = True,
|
||||
description: str | None = None,
|
||||
tags: str | None = None,
|
||||
learn: bool = False,
|
||||
include_trace: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute a browser automation command batch"""
|
||||
...
|
||||
|
||||
async def run_skill(
|
||||
self,
|
||||
skill_key: str,
|
||||
timeout: int = 60,
|
||||
stop_on_error: bool = True,
|
||||
include_trace: bool = False,
|
||||
description: str | None = None,
|
||||
tags: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Run a browser skill by skill key"""
|
||||
...
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Booter-specific system prompt fragments.
|
||||
|
||||
Kept separate from ``tools/prompts.py`` (which holds agent-level prompts)
|
||||
so that booter subclasses can import without pulling in unrelated constants.
|
||||
"""
|
||||
|
||||
NEO_FILE_PATH_PROMPT = (
|
||||
"\n[Shipyard Neo File Path Rule]\n"
|
||||
"When using sandbox filesystem tools (upload/download/read/write/list/delete), "
|
||||
"always pass paths relative to the sandbox workspace root. "
|
||||
"Example: use `baidu_homepage.png` instead of `/workspace/baidu_homepage.png`.\n"
|
||||
)
|
||||
|
||||
NEO_SKILL_LIFECYCLE_PROMPT = (
|
||||
"\n[Neo Skill Lifecycle Workflow]\n"
|
||||
"When user asks to create/update a reusable skill in Neo mode, use lifecycle tools instead of directly writing local skill folders.\n"
|
||||
"Preferred sequence:\n"
|
||||
"1) Use `astrbot_create_skill_payload` to store canonical payload content and get `payload_ref`.\n"
|
||||
"2) Use `astrbot_create_skill_candidate` with `skill_key` + `source_execution_ids` (and optional `payload_ref`) to create a candidate.\n"
|
||||
"3) Use `astrbot_promote_skill_candidate` to release: `stage=canary` for trial; `stage=stable` for production.\n"
|
||||
"For stable release, set `sync_to_local=true` to sync `payload.skill_markdown` into local `SKILL.md`.\n"
|
||||
"Do not treat ad-hoc generated files as reusable Neo skills unless they are captured via payload/candidate/release.\n"
|
||||
"To update an existing skill, create a new payload/candidate and promote a new release version; avoid patching old local folders directly.\n"
|
||||
)
|
||||
@@ -1,8 +1,36 @@
|
||||
from .browser import BrowserBatchExecTool, BrowserExecTool, RunBrowserSkillTool
|
||||
from .fs import FileDownloadTool, FileUploadTool
|
||||
from .neo_skills import (
|
||||
AnnotateExecutionTool,
|
||||
CreateSkillCandidateTool,
|
||||
CreateSkillPayloadTool,
|
||||
EvaluateSkillCandidateTool,
|
||||
GetExecutionHistoryTool,
|
||||
GetSkillPayloadTool,
|
||||
ListSkillCandidatesTool,
|
||||
ListSkillReleasesTool,
|
||||
PromoteSkillCandidateTool,
|
||||
RollbackSkillReleaseTool,
|
||||
SyncSkillReleaseTool,
|
||||
)
|
||||
from .python import LocalPythonTool, PythonTool
|
||||
from .shell import ExecuteShellTool
|
||||
|
||||
__all__ = [
|
||||
"BrowserExecTool",
|
||||
"BrowserBatchExecTool",
|
||||
"RunBrowserSkillTool",
|
||||
"GetExecutionHistoryTool",
|
||||
"AnnotateExecutionTool",
|
||||
"CreateSkillPayloadTool",
|
||||
"GetSkillPayloadTool",
|
||||
"CreateSkillCandidateTool",
|
||||
"ListSkillCandidatesTool",
|
||||
"EvaluateSkillCandidateTool",
|
||||
"PromoteSkillCandidateTool",
|
||||
"ListSkillReleasesTool",
|
||||
"RollbackSkillReleaseTool",
|
||||
"SyncSkillReleaseTool",
|
||||
"FileUploadTool",
|
||||
"PythonTool",
|
||||
"LocalPythonTool",
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from astrbot.api import FunctionTool
|
||||
from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.agent.tool import ToolExecResult
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
|
||||
from ..computer_client import get_booter
|
||||
|
||||
|
||||
def _to_json(data: Any) -> str:
|
||||
return json.dumps(data, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def _ensure_admin(context: ContextWrapper[AstrAgentContext]) -> str | None:
|
||||
if context.context.event.role != "admin":
|
||||
return (
|
||||
"error: Permission denied. Browser and skill lifecycle tools are only allowed "
|
||||
"for admin users."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def _get_browser_component(context: ContextWrapper[AstrAgentContext]) -> Any:
|
||||
booter = await get_booter(
|
||||
context.context.context,
|
||||
context.context.event.unified_msg_origin,
|
||||
)
|
||||
browser = getattr(booter, "browser", None)
|
||||
if browser is None:
|
||||
raise RuntimeError(
|
||||
"Current sandbox booter does not support browser capability. "
|
||||
"Please switch to shipyard_neo."
|
||||
)
|
||||
return browser
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrowserExecTool(FunctionTool):
|
||||
name: str = "astrbot_execute_browser"
|
||||
description: str = "Execute one browser automation command in the sandbox."
|
||||
parameters: dict = field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cmd": {"type": "string", "description": "Browser command to execute."},
|
||||
"timeout": {"type": "integer", "default": 30},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Optional execution description.",
|
||||
},
|
||||
"tags": {"type": "string", "description": "Optional tags."},
|
||||
"learn": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to mark execution as learn evidence.",
|
||||
"default": False,
|
||||
},
|
||||
"include_trace": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to include trace_ref in response.",
|
||||
"default": False,
|
||||
},
|
||||
},
|
||||
"required": ["cmd"],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self,
|
||||
context: ContextWrapper[AstrAgentContext],
|
||||
cmd: str,
|
||||
timeout: int = 30,
|
||||
description: str | None = None,
|
||||
tags: str | None = None,
|
||||
learn: bool = False,
|
||||
include_trace: bool = False,
|
||||
) -> ToolExecResult:
|
||||
if err := _ensure_admin(context):
|
||||
return err
|
||||
try:
|
||||
browser = await _get_browser_component(context)
|
||||
result = await browser.exec(
|
||||
cmd=cmd,
|
||||
timeout=timeout,
|
||||
description=description,
|
||||
tags=tags,
|
||||
learn=learn,
|
||||
include_trace=include_trace,
|
||||
)
|
||||
return _to_json(result)
|
||||
except Exception as e:
|
||||
return f"Error executing browser command: {str(e)}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrowserBatchExecTool(FunctionTool):
|
||||
name: str = "astrbot_execute_browser_batch"
|
||||
description: str = "Execute a browser command batch in the sandbox."
|
||||
parameters: dict = field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"commands": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Ordered browser commands.",
|
||||
},
|
||||
"timeout": {"type": "integer", "default": 60},
|
||||
"stop_on_error": {"type": "boolean", "default": True},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Optional execution description.",
|
||||
},
|
||||
"tags": {"type": "string", "description": "Optional tags."},
|
||||
"learn": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to mark execution as learn evidence.",
|
||||
"default": False,
|
||||
},
|
||||
"include_trace": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to include trace_ref in response.",
|
||||
"default": False,
|
||||
},
|
||||
},
|
||||
"required": ["commands"],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self,
|
||||
context: ContextWrapper[AstrAgentContext],
|
||||
commands: list[str],
|
||||
timeout: int = 60,
|
||||
stop_on_error: bool = True,
|
||||
description: str | None = None,
|
||||
tags: str | None = None,
|
||||
learn: bool = False,
|
||||
include_trace: bool = False,
|
||||
) -> ToolExecResult:
|
||||
if err := _ensure_admin(context):
|
||||
return err
|
||||
try:
|
||||
browser = await _get_browser_component(context)
|
||||
result = await browser.exec_batch(
|
||||
commands=commands,
|
||||
timeout=timeout,
|
||||
stop_on_error=stop_on_error,
|
||||
description=description,
|
||||
tags=tags,
|
||||
learn=learn,
|
||||
include_trace=include_trace,
|
||||
)
|
||||
return _to_json(result)
|
||||
except Exception as e:
|
||||
return f"Error executing browser batch command: {str(e)}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunBrowserSkillTool(FunctionTool):
|
||||
name: str = "astrbot_run_browser_skill"
|
||||
description: str = "Run a released browser skill in the sandbox by skill_key."
|
||||
parameters: dict = field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"skill_key": {"type": "string"},
|
||||
"timeout": {"type": "integer", "default": 60},
|
||||
"stop_on_error": {"type": "boolean", "default": True},
|
||||
"include_trace": {"type": "boolean", "default": False},
|
||||
"description": {"type": "string"},
|
||||
"tags": {"type": "string"},
|
||||
},
|
||||
"required": ["skill_key"],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self,
|
||||
context: ContextWrapper[AstrAgentContext],
|
||||
skill_key: str,
|
||||
timeout: int = 60,
|
||||
stop_on_error: bool = True,
|
||||
include_trace: bool = False,
|
||||
description: str | None = None,
|
||||
tags: str | None = None,
|
||||
) -> ToolExecResult:
|
||||
if err := _ensure_admin(context):
|
||||
return err
|
||||
try:
|
||||
browser = await _get_browser_component(context)
|
||||
result = await browser.run_skill(
|
||||
skill_key=skill_key,
|
||||
timeout=timeout,
|
||||
stop_on_error=stop_on_error,
|
||||
include_trace=include_trace,
|
||||
description=description,
|
||||
tags=tags,
|
||||
)
|
||||
return _to_json(result)
|
||||
except Exception as e:
|
||||
return f"Error running browser skill: {str(e)}"
|
||||
@@ -0,0 +1,545 @@
|
||||
import json
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from astrbot.api import FunctionTool
|
||||
from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.agent.tool import ToolExecResult
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
from astrbot.core.skills.neo_skill_sync import NeoSkillSyncManager
|
||||
|
||||
from ..computer_client import get_booter
|
||||
|
||||
|
||||
def _to_jsonable(model_like: Any) -> Any:
|
||||
if isinstance(model_like, dict):
|
||||
return model_like
|
||||
if isinstance(model_like, list):
|
||||
return [_to_jsonable(i) for i in model_like]
|
||||
if hasattr(model_like, "model_dump"):
|
||||
return _to_jsonable(model_like.model_dump())
|
||||
return model_like
|
||||
|
||||
|
||||
def _to_json_text(data: Any) -> str:
|
||||
return json.dumps(_to_jsonable(data), ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def _ensure_admin(context: ContextWrapper[AstrAgentContext]) -> str | None:
|
||||
if context.context.event.role != "admin":
|
||||
return "error: Permission denied. Skill lifecycle tools are only allowed for admin users."
|
||||
return None
|
||||
|
||||
|
||||
async def _get_neo_context(
|
||||
context: ContextWrapper[AstrAgentContext],
|
||||
) -> tuple[Any, Any]:
|
||||
booter = await get_booter(
|
||||
context.context.context,
|
||||
context.context.event.unified_msg_origin,
|
||||
)
|
||||
client = getattr(booter, "bay_client", None)
|
||||
sandbox = getattr(booter, "sandbox", None)
|
||||
if client is None or sandbox is None:
|
||||
raise RuntimeError(
|
||||
"Current sandbox booter does not support Neo skill lifecycle APIs. "
|
||||
"Please switch to shipyard_neo."
|
||||
)
|
||||
return client, sandbox
|
||||
|
||||
|
||||
@dataclass
|
||||
class NeoSkillToolBase(FunctionTool):
|
||||
error_prefix: str = "Error"
|
||||
|
||||
async def _run(
|
||||
self,
|
||||
context: ContextWrapper[AstrAgentContext],
|
||||
neo_call: Callable[[Any, Any], Awaitable[Any]],
|
||||
error_action: str,
|
||||
) -> ToolExecResult:
|
||||
if err := _ensure_admin(context):
|
||||
return err
|
||||
try:
|
||||
client, sandbox = await _get_neo_context(context)
|
||||
result = await neo_call(client, sandbox)
|
||||
return _to_json_text(result)
|
||||
except Exception as e:
|
||||
return f"{self.error_prefix} {error_action}: {str(e)}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class GetExecutionHistoryTool(NeoSkillToolBase):
|
||||
name: str = "astrbot_get_execution_history"
|
||||
description: str = "Get execution history from current sandbox."
|
||||
parameters: dict = field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"exec_type": {"type": "string"},
|
||||
"success_only": {"type": "boolean", "default": False},
|
||||
"limit": {"type": "integer", "default": 100},
|
||||
"offset": {"type": "integer", "default": 0},
|
||||
"tags": {"type": "string"},
|
||||
"has_notes": {"type": "boolean", "default": False},
|
||||
"has_description": {"type": "boolean", "default": False},
|
||||
},
|
||||
"required": [],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self,
|
||||
context: ContextWrapper[AstrAgentContext],
|
||||
exec_type: str | None = None,
|
||||
success_only: bool = False,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
tags: str | None = None,
|
||||
has_notes: bool = False,
|
||||
has_description: bool = False,
|
||||
) -> ToolExecResult:
|
||||
return await self._run(
|
||||
context,
|
||||
lambda _client, sandbox: sandbox.get_execution_history(
|
||||
exec_type=exec_type,
|
||||
success_only=success_only,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
tags=tags,
|
||||
has_notes=has_notes,
|
||||
has_description=has_description,
|
||||
),
|
||||
error_action="getting execution history",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnnotateExecutionTool(NeoSkillToolBase):
|
||||
name: str = "astrbot_annotate_execution"
|
||||
description: str = "Annotate one execution history record."
|
||||
parameters: dict = field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"execution_id": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
"tags": {"type": "string"},
|
||||
"notes": {"type": "string"},
|
||||
},
|
||||
"required": ["execution_id"],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self,
|
||||
context: ContextWrapper[AstrAgentContext],
|
||||
execution_id: str,
|
||||
description: str | None = None,
|
||||
tags: str | None = None,
|
||||
notes: str | None = None,
|
||||
) -> ToolExecResult:
|
||||
return await self._run(
|
||||
context,
|
||||
lambda _client, sandbox: sandbox.annotate_execution(
|
||||
execution_id=execution_id,
|
||||
description=description,
|
||||
tags=tags,
|
||||
notes=notes,
|
||||
),
|
||||
error_action="annotating execution",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CreateSkillPayloadTool(NeoSkillToolBase):
|
||||
name: str = "astrbot_create_skill_payload"
|
||||
description: str = (
|
||||
"Step 1/3 for Neo skill authoring: create immutable payload content and return payload_ref. "
|
||||
"Use this to store skill_markdown and structured metadata; do NOT write local skill folders directly."
|
||||
)
|
||||
parameters: dict = field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"payload": {
|
||||
"anyOf": [
|
||||
{"type": "object"},
|
||||
{"type": "array", "items": {"type": "object"}},
|
||||
],
|
||||
"description": (
|
||||
"Skill payload JSON. Typical schema: {skill_markdown, inputs, outputs, meta}. "
|
||||
"This only stores content and returns payload_ref; it does not create a candidate or release."
|
||||
),
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"description": "Payload kind.",
|
||||
"default": "astrbot_skill_v1",
|
||||
},
|
||||
},
|
||||
"required": ["payload"],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self,
|
||||
context: ContextWrapper[AstrAgentContext],
|
||||
payload: dict[str, Any] | list[Any],
|
||||
kind: str = "astrbot_skill_v1",
|
||||
) -> ToolExecResult:
|
||||
return await self._run(
|
||||
context,
|
||||
lambda client, _sandbox: client.skills.create_payload(
|
||||
payload=payload,
|
||||
kind=kind,
|
||||
),
|
||||
error_action="creating skill payload",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GetSkillPayloadTool(NeoSkillToolBase):
|
||||
name: str = "astrbot_get_skill_payload"
|
||||
description: str = "Get one skill payload by payload_ref."
|
||||
parameters: dict = field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"payload_ref": {"type": "string"},
|
||||
},
|
||||
"required": ["payload_ref"],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self,
|
||||
context: ContextWrapper[AstrAgentContext],
|
||||
payload_ref: str,
|
||||
) -> ToolExecResult:
|
||||
return await self._run(
|
||||
context,
|
||||
lambda client, _sandbox: client.skills.get_payload(payload_ref),
|
||||
error_action="getting skill payload",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CreateSkillCandidateTool(NeoSkillToolBase):
|
||||
name: str = "astrbot_create_skill_candidate"
|
||||
description: str = (
|
||||
"Step 2/3 for Neo skill authoring: create a candidate by binding execution evidence "
|
||||
"(source_execution_ids) with skill identity (skill_key) and optional payload_ref."
|
||||
)
|
||||
parameters: dict = field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"skill_key": {
|
||||
"type": "string",
|
||||
"description": "Stable logical identifier, e.g. image-collage-9grid.",
|
||||
},
|
||||
"source_execution_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Execution evidence IDs captured from sandbox history.",
|
||||
},
|
||||
"scenario_key": {
|
||||
"type": "string",
|
||||
"description": "Optional scenario namespace for grouping candidates.",
|
||||
},
|
||||
"payload_ref": {
|
||||
"type": "string",
|
||||
"description": "Optional payload reference created by astrbot_create_skill_payload.",
|
||||
},
|
||||
},
|
||||
"required": ["skill_key", "source_execution_ids"],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self,
|
||||
context: ContextWrapper[AstrAgentContext],
|
||||
skill_key: str,
|
||||
source_execution_ids: list[str],
|
||||
scenario_key: str | None = None,
|
||||
payload_ref: str | None = None,
|
||||
) -> ToolExecResult:
|
||||
return await self._run(
|
||||
context,
|
||||
lambda client, _sandbox: client.skills.create_candidate(
|
||||
skill_key=skill_key,
|
||||
source_execution_ids=source_execution_ids,
|
||||
scenario_key=scenario_key,
|
||||
payload_ref=payload_ref,
|
||||
),
|
||||
error_action="creating skill candidate",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ListSkillCandidatesTool(NeoSkillToolBase):
|
||||
name: str = "astrbot_list_skill_candidates"
|
||||
description: str = "List skill candidates."
|
||||
parameters: dict = field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {"type": "string"},
|
||||
"skill_key": {"type": "string"},
|
||||
"limit": {"type": "integer", "default": 100},
|
||||
"offset": {"type": "integer", "default": 0},
|
||||
},
|
||||
"required": [],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self,
|
||||
context: ContextWrapper[AstrAgentContext],
|
||||
status: str | None = None,
|
||||
skill_key: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> ToolExecResult:
|
||||
return await self._run(
|
||||
context,
|
||||
lambda client, _sandbox: client.skills.list_candidates(
|
||||
status=status,
|
||||
skill_key=skill_key,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
),
|
||||
error_action="listing skill candidates",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EvaluateSkillCandidateTool(NeoSkillToolBase):
|
||||
name: str = "astrbot_evaluate_skill_candidate"
|
||||
description: str = "Evaluate a skill candidate."
|
||||
parameters: dict = field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"candidate_id": {"type": "string"},
|
||||
"passed": {"type": "boolean"},
|
||||
"score": {"type": "number"},
|
||||
"benchmark_id": {"type": "string"},
|
||||
"report": {"type": "string"},
|
||||
},
|
||||
"required": ["candidate_id", "passed"],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self,
|
||||
context: ContextWrapper[AstrAgentContext],
|
||||
candidate_id: str,
|
||||
passed: bool,
|
||||
score: float | None = None,
|
||||
benchmark_id: str | None = None,
|
||||
report: str | None = None,
|
||||
) -> ToolExecResult:
|
||||
return await self._run(
|
||||
context,
|
||||
lambda client, _sandbox: client.skills.evaluate_candidate(
|
||||
candidate_id,
|
||||
passed=passed,
|
||||
score=score,
|
||||
benchmark_id=benchmark_id,
|
||||
report=report,
|
||||
),
|
||||
error_action="evaluating skill candidate",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PromoteSkillCandidateTool(NeoSkillToolBase):
|
||||
name: str = "astrbot_promote_skill_candidate"
|
||||
description: str = (
|
||||
"Step 3/3 for Neo skill authoring: promote candidate to canary/stable release. "
|
||||
"If stage=stable and sync_to_local=true, payload.skill_markdown is synced to local SKILL.md automatically."
|
||||
)
|
||||
parameters: dict = field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"candidate_id": {"type": "string"},
|
||||
"stage": {
|
||||
"type": "string",
|
||||
"description": "Release stage: canary/stable",
|
||||
"default": "canary",
|
||||
},
|
||||
"sync_to_local": {
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
"Only used with stage=stable. true means sync payload.skill_markdown to local SKILL.md; "
|
||||
"false means release remains Neo-side only."
|
||||
),
|
||||
"default": True,
|
||||
},
|
||||
},
|
||||
"required": ["candidate_id"],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self,
|
||||
context: ContextWrapper[AstrAgentContext],
|
||||
candidate_id: str,
|
||||
stage: str = "canary",
|
||||
sync_to_local: bool = True,
|
||||
) -> ToolExecResult:
|
||||
if err := _ensure_admin(context):
|
||||
return err
|
||||
if stage not in {"canary", "stable"}:
|
||||
return "Error promoting skill candidate: stage must be canary or stable."
|
||||
|
||||
try:
|
||||
client, _sandbox = await _get_neo_context(context)
|
||||
sync_mgr = NeoSkillSyncManager()
|
||||
result = await sync_mgr.promote_with_optional_sync(
|
||||
client,
|
||||
candidate_id=candidate_id,
|
||||
stage=stage,
|
||||
sync_to_local=sync_to_local,
|
||||
)
|
||||
if result.get("sync_error"):
|
||||
rollback_json = result.get("rollback")
|
||||
if rollback_json:
|
||||
return (
|
||||
"Error promoting skill candidate: stable release synced failed; "
|
||||
f"auto rollback succeeded. sync_error={result['sync_error']}; "
|
||||
f"rollback={_to_json_text(rollback_json)}"
|
||||
)
|
||||
return _to_json_text(
|
||||
{
|
||||
"release": result.get("release"),
|
||||
"sync": result.get("sync"),
|
||||
"rollback": result.get("rollback"),
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Error promoting skill candidate: {str(e)}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ListSkillReleasesTool(NeoSkillToolBase):
|
||||
name: str = "astrbot_list_skill_releases"
|
||||
description: str = "List skill releases."
|
||||
parameters: dict = field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"skill_key": {"type": "string"},
|
||||
"active_only": {"type": "boolean", "default": False},
|
||||
"stage": {"type": "string"},
|
||||
"limit": {"type": "integer", "default": 100},
|
||||
"offset": {"type": "integer", "default": 0},
|
||||
},
|
||||
"required": [],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self,
|
||||
context: ContextWrapper[AstrAgentContext],
|
||||
skill_key: str | None = None,
|
||||
active_only: bool = False,
|
||||
stage: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> ToolExecResult:
|
||||
return await self._run(
|
||||
context,
|
||||
lambda client, _sandbox: client.skills.list_releases(
|
||||
skill_key=skill_key,
|
||||
active_only=active_only,
|
||||
stage=stage,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
),
|
||||
error_action="listing skill releases",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RollbackSkillReleaseTool(NeoSkillToolBase):
|
||||
name: str = "astrbot_rollback_skill_release"
|
||||
description: str = "Rollback one skill release."
|
||||
parameters: dict = field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"release_id": {"type": "string"},
|
||||
},
|
||||
"required": ["release_id"],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self,
|
||||
context: ContextWrapper[AstrAgentContext],
|
||||
release_id: str,
|
||||
) -> ToolExecResult:
|
||||
return await self._run(
|
||||
context,
|
||||
lambda client, _sandbox: client.skills.rollback_release(release_id),
|
||||
error_action="rolling back skill release",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncSkillReleaseTool(NeoSkillToolBase):
|
||||
name: str = "astrbot_sync_skill_release"
|
||||
description: str = (
|
||||
"Sync stable Neo release payload to local SKILL.md and update mapping metadata."
|
||||
)
|
||||
parameters: dict = field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"release_id": {"type": "string"},
|
||||
"skill_key": {"type": "string"},
|
||||
"require_stable": {"type": "boolean", "default": True},
|
||||
},
|
||||
"required": [],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self,
|
||||
context: ContextWrapper[AstrAgentContext],
|
||||
release_id: str | None = None,
|
||||
skill_key: str | None = None,
|
||||
require_stable: bool = True,
|
||||
) -> ToolExecResult:
|
||||
return await self._run(
|
||||
context,
|
||||
lambda client, _sandbox: _sync_release_to_dict(
|
||||
client,
|
||||
release_id=release_id,
|
||||
skill_key=skill_key,
|
||||
require_stable=require_stable,
|
||||
),
|
||||
error_action="syncing skill release",
|
||||
)
|
||||
|
||||
|
||||
async def _sync_release_to_dict(
|
||||
client: Any,
|
||||
*,
|
||||
release_id: str | None,
|
||||
skill_key: str | None,
|
||||
require_stable: bool,
|
||||
) -> dict[str, str]:
|
||||
sync_mgr = NeoSkillSyncManager()
|
||||
result = await sync_mgr.sync_release(
|
||||
client,
|
||||
release_id=release_id,
|
||||
skill_key=skill_key,
|
||||
require_stable=require_stable,
|
||||
)
|
||||
return sync_mgr.sync_result_to_dict(result)
|
||||
@@ -1,3 +1,4 @@
|
||||
import platform
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import mcp
|
||||
@@ -10,6 +11,8 @@ from astrbot.core.computer.computer_client import get_booter, get_local_booter
|
||||
from astrbot.core.computer.tools.permissions import check_admin_permission
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
|
||||
_OS_NAME = platform.system()
|
||||
|
||||
param_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -61,7 +64,7 @@ async def handle_result(result: dict, event: AstrMessageEvent) -> ToolExecResult
|
||||
@dataclass
|
||||
class PythonTool(FunctionTool):
|
||||
name: str = "astrbot_execute_ipython"
|
||||
description: str = "Run codes in an IPython shell."
|
||||
description: str = f"Run codes in an IPython shell. Current OS: {_OS_NAME}."
|
||||
parameters: dict = field(default_factory=lambda: param_schema)
|
||||
|
||||
async def call(
|
||||
@@ -83,7 +86,10 @@ class PythonTool(FunctionTool):
|
||||
@dataclass
|
||||
class LocalPythonTool(FunctionTool):
|
||||
name: str = "astrbot_execute_python"
|
||||
description: str = "Execute codes in a Python environment."
|
||||
description: str = (
|
||||
f"Execute codes in a Python environment. Current OS: {_OS_NAME}. "
|
||||
"Use system-compatible commands."
|
||||
)
|
||||
|
||||
parameters: dict = field(default_factory=lambda: param_schema)
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ class ExecuteShellTool(FunctionTool):
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The bash command to execute. Equal to 'cd {working_dir} && {your_command}'.",
|
||||
"description": "The shell command to execute in the current runtime shell (for example, cmd.exe on Windows). Equal to 'cd {working_dir} && {your_command}'.",
|
||||
},
|
||||
"background": {
|
||||
"type": "boolean",
|
||||
@@ -58,7 +58,18 @@ class ExecuteShellTool(FunctionTool):
|
||||
context.context.event.unified_msg_origin,
|
||||
)
|
||||
try:
|
||||
result = await sb.shell.exec(command, background=background, env=env)
|
||||
config = context.context.context.get_config(
|
||||
umo=context.context.event.unified_msg_origin
|
||||
)
|
||||
try:
|
||||
timeout = int(
|
||||
config.get("provider_settings", {}).get("tool_call_timeout", 30)
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
timeout = 30
|
||||
result = await sb.shell.exec(
|
||||
command, background=background, env=env, timeout=timeout
|
||||
)
|
||||
return json.dumps(result)
|
||||
except Exception as e:
|
||||
return f"Error executing command: {str(e)}"
|
||||
|
||||
+331
-55
@@ -1,11 +1,16 @@
|
||||
"""如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。"""
|
||||
|
||||
import os
|
||||
from importlib import metadata
|
||||
from typing import Any, TypedDict
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
VERSION = "4.18.3"
|
||||
try:
|
||||
__version__ = metadata.version("AstrBot")
|
||||
except metadata.PackageNotFoundError:
|
||||
__version__ = "unknown"
|
||||
VERSION = __version__
|
||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
||||
|
||||
WEBHOOK_SUPPORTED_PLATFORMS = [
|
||||
@@ -113,6 +118,7 @@ DEFAULT_CONFIG = {
|
||||
"dify_agent_runner_provider_id": "",
|
||||
"coze_agent_runner_provider_id": "",
|
||||
"dashscope_agent_runner_provider_id": "",
|
||||
"deerflow_agent_runner_provider_id": "",
|
||||
"unsupported_streaming_strategy": "realtime_segmenting",
|
||||
"reachability_check": False,
|
||||
"max_agent_step": 30,
|
||||
@@ -128,14 +134,18 @@ DEFAULT_CONFIG = {
|
||||
"proactive_capability": {
|
||||
"add_cron_tools": True,
|
||||
},
|
||||
"computer_use_runtime": "local",
|
||||
"computer_use_runtime": "none",
|
||||
"computer_use_require_admin": True,
|
||||
"sandbox": {
|
||||
"booter": "shipyard",
|
||||
"booter": "shipyard_neo",
|
||||
"shipyard_endpoint": "",
|
||||
"shipyard_access_token": "",
|
||||
"shipyard_ttl": 3600,
|
||||
"shipyard_max_sessions": 10,
|
||||
"shipyard_neo_endpoint": "",
|
||||
"shipyard_neo_access_token": "",
|
||||
"shipyard_neo_profile": "python-default",
|
||||
"shipyard_neo_ttl": 3600,
|
||||
},
|
||||
},
|
||||
# SubAgent orchestrator mode:
|
||||
@@ -214,6 +224,9 @@ DEFAULT_CONFIG = {
|
||||
"telegram": {
|
||||
"pre_ack_emoji": {"enable": False, "emojis": ["✍️"]},
|
||||
},
|
||||
"discord": {
|
||||
"pre_ack_emoji": {"enable": False, "emojis": ["🤔"]},
|
||||
},
|
||||
},
|
||||
"wake_prefix": ["/"],
|
||||
"log_level": "INFO",
|
||||
@@ -337,14 +350,20 @@ CONFIG_METADATA_2 = {
|
||||
"企业微信智能机器人": {
|
||||
"id": "wecom_ai_bot",
|
||||
"type": "wecom_ai_bot",
|
||||
"hint": "如果发现字段有异常,请重新创建",
|
||||
"enable": True,
|
||||
"wecom_ai_bot_connection_mode": "long_connection", # long_connection, webhook
|
||||
"wecom_ai_bot_name": "",
|
||||
"wecomaibot_ws_bot_id": "",
|
||||
"wecomaibot_ws_secret": "",
|
||||
"wecomaibot_token": "",
|
||||
"wecomaibot_encoding_aes_key": "",
|
||||
"wecomaibot_init_respond_text": "",
|
||||
"wecomaibot_friend_message_welcome_text": "",
|
||||
"wecom_ai_bot_name": "",
|
||||
"msg_push_webhook_url": "",
|
||||
"only_use_webhook_url_to_send": False,
|
||||
"token": "",
|
||||
"encoding_aes_key": "",
|
||||
"wecomaibot_ws_url": "wss://openws.work.weixin.qq.com",
|
||||
"wecomaibot_heartbeat_interval": 30,
|
||||
"unified_webhook_mode": True,
|
||||
"webhook_uuid": "",
|
||||
"callback_server_host": "0.0.0.0",
|
||||
@@ -390,7 +409,6 @@ CONFIG_METADATA_2 = {
|
||||
"discord_token": "",
|
||||
"discord_proxy": "",
|
||||
"discord_command_register": True,
|
||||
"discord_guild_id_for_debug": "",
|
||||
"discord_activity_name": "",
|
||||
},
|
||||
"Misskey": {
|
||||
@@ -445,6 +463,19 @@ CONFIG_METADATA_2 = {
|
||||
"satori_heartbeat_interval": 10,
|
||||
"satori_reconnect_delay": 5,
|
||||
},
|
||||
"kook": {
|
||||
"id": "kook",
|
||||
"type": "kook",
|
||||
"enable": False,
|
||||
"kook_bot_token": "",
|
||||
"kook_reconnect_delay": 1,
|
||||
"kook_max_reconnect_delay": 60,
|
||||
"kook_max_retry_delay": 60,
|
||||
"kook_heartbeat_interval": 30,
|
||||
"kook_heartbeat_timeout": 6,
|
||||
"kook_max_heartbeat_failures": 3,
|
||||
"kook_max_consecutive_failures": 5,
|
||||
},
|
||||
# "WebChat": {
|
||||
# "id": "webchat",
|
||||
# "type": "webchat",
|
||||
@@ -714,6 +745,13 @@ CONFIG_METADATA_2 = {
|
||||
"type": "string",
|
||||
"hint": "请务必填写正确,否则无法使用一些指令。",
|
||||
},
|
||||
"wecom_ai_bot_connection_mode": {
|
||||
"description": "企业微信智能机器人连接模式",
|
||||
"type": "string",
|
||||
"options": ["webhook", "long_connection"],
|
||||
"labels": ["Webhook 回调", "长连接"],
|
||||
"hint": "Webhook 回调模式需要配置 Token/EncodingAESKey。长连接模式需要配置 BotID/Secret。",
|
||||
},
|
||||
"wecomaibot_init_respond_text": {
|
||||
"description": "企业微信智能机器人初始响应文本",
|
||||
"type": "string",
|
||||
@@ -724,6 +762,22 @@ CONFIG_METADATA_2 = {
|
||||
"type": "string",
|
||||
"hint": "当用户当天进入智能机器人单聊会话,回复欢迎语,留空则不回复。",
|
||||
},
|
||||
"wecomaibot_token": {
|
||||
"description": "企业微信智能机器人 Token",
|
||||
"type": "string",
|
||||
"hint": "用于 Webhook 回调模式的身份验证。",
|
||||
"condition": {
|
||||
"wecom_ai_bot_connection_mode": "webhook",
|
||||
},
|
||||
},
|
||||
"wecomaibot_encoding_aes_key": {
|
||||
"description": "企业微信智能机器人 EncodingAESKey",
|
||||
"type": "string",
|
||||
"hint": "用于 Webhook 回调模式的消息加密解密。",
|
||||
"condition": {
|
||||
"wecom_ai_bot_connection_mode": "webhook",
|
||||
},
|
||||
},
|
||||
"msg_push_webhook_url": {
|
||||
"description": "企业微信消息推送 Webhook URL",
|
||||
"type": "string",
|
||||
@@ -734,6 +788,40 @@ CONFIG_METADATA_2 = {
|
||||
"type": "bool",
|
||||
"hint": "启用后,企业微信智能机器人的所有回复都改为通过消息推送 Webhook 发送。消息推送 Webhook 支持更多的消息类型(如图片、文件等)。",
|
||||
},
|
||||
"wecomaibot_ws_bot_id": {
|
||||
"description": "长连接 BotID",
|
||||
"type": "string",
|
||||
"hint": "企业微信智能机器人长连接模式凭证 BotID。",
|
||||
"condition": {
|
||||
"wecom_ai_bot_connection_mode": "long_connection",
|
||||
},
|
||||
},
|
||||
"wecomaibot_ws_secret": {
|
||||
"description": "长连接 Secret",
|
||||
"type": "string",
|
||||
"hint": "企业微信智能机器人长连接模式凭证 Secret。",
|
||||
"condition": {
|
||||
"wecom_ai_bot_connection_mode": "long_connection",
|
||||
},
|
||||
},
|
||||
"wecomaibot_ws_url": {
|
||||
"description": "长连接 WebSocket 地址",
|
||||
"type": "string",
|
||||
"invisible": True,
|
||||
"hint": "默认值为 wss://openws.work.weixin.qq.com,一般无需修改。",
|
||||
"condition": {
|
||||
"wecom_ai_bot_connection_mode": "long_connection",
|
||||
},
|
||||
},
|
||||
"wecomaibot_heartbeat_interval": {
|
||||
"description": "长连接心跳间隔",
|
||||
"type": "int",
|
||||
"invisible": True,
|
||||
"hint": "长连接模式心跳间隔(秒),建议 30 秒。",
|
||||
"condition": {
|
||||
"wecom_ai_bot_connection_mode": "long_connection",
|
||||
},
|
||||
},
|
||||
"lark_bot_name": {
|
||||
"description": "飞书机器人的名字",
|
||||
"type": "string",
|
||||
@@ -750,7 +838,8 @@ CONFIG_METADATA_2 = {
|
||||
"hint": "可选的代理地址:http://ip:port",
|
||||
},
|
||||
"discord_command_register": {
|
||||
"description": "是否自动将插件指令注册为 Discord 斜杠指令",
|
||||
"description": "注册 Discord 指令",
|
||||
"hint": "启用后,自动将插件指令注册为 Discord 斜杠指令",
|
||||
"type": "bool",
|
||||
},
|
||||
"discord_activity_name": {
|
||||
@@ -777,7 +866,7 @@ CONFIG_METADATA_2 = {
|
||||
"unified_webhook_mode": {
|
||||
"description": "统一 Webhook 模式",
|
||||
"type": "bool",
|
||||
"hint": "启用后,将使用 AstrBot 统一 Webhook 入口,无需单独开启端口。回调地址为 /api/platform/webhook/{webhook_uuid}。",
|
||||
"hint": "Webhook 模式下使用 AstrBot 统一 Webhook 入口,无需单独开启端口。回调地址为 /api/platform/webhook/{webhook_uuid}。",
|
||||
},
|
||||
"webhook_uuid": {
|
||||
"invisible": True,
|
||||
@@ -785,6 +874,46 @@ CONFIG_METADATA_2 = {
|
||||
"type": "string",
|
||||
"hint": "统一 Webhook 模式下的唯一标识符,创建平台时自动生成。",
|
||||
},
|
||||
"kook_bot_token": {
|
||||
"description": "机器人 Token",
|
||||
"type": "string",
|
||||
"hint": "必填项。从 KOOK 开发者平台获取的机器人 Token。",
|
||||
},
|
||||
"kook_reconnect_delay": {
|
||||
"description": "重连延迟",
|
||||
"type": "int",
|
||||
"hint": "重连延迟时间(秒),使用指数退避策略。",
|
||||
},
|
||||
"kook_max_reconnect_delay": {
|
||||
"description": "最大重连延迟",
|
||||
"type": "int",
|
||||
"hint": "重连延迟的最大值(秒)。",
|
||||
},
|
||||
"kook_max_retry_delay": {
|
||||
"description": "最大重试延迟",
|
||||
"type": "int",
|
||||
"hint": "重试的最大延迟时间(秒)。",
|
||||
},
|
||||
"kook_heartbeat_interval": {
|
||||
"description": "心跳间隔",
|
||||
"type": "int",
|
||||
"hint": "心跳检测间隔时间(秒)。",
|
||||
},
|
||||
"kook_heartbeat_timeout": {
|
||||
"description": "心跳超时时间",
|
||||
"type": "int",
|
||||
"hint": "心跳检测超时时间(秒)。",
|
||||
},
|
||||
"kook_max_heartbeat_failures": {
|
||||
"description": "最大心跳失败次数",
|
||||
"type": "int",
|
||||
"hint": "允许的最大心跳失败次数,超过后断开连接。",
|
||||
},
|
||||
"kook_max_consecutive_failures": {
|
||||
"description": "最大连续失败次数",
|
||||
"type": "int",
|
||||
"hint": "允许的最大连续失败次数,超过后停止重试。",
|
||||
},
|
||||
},
|
||||
},
|
||||
"platform_settings": {
|
||||
@@ -1002,6 +1131,18 @@ CONFIG_METADATA_2 = {
|
||||
"proxy": "",
|
||||
"custom_headers": {},
|
||||
},
|
||||
"MiniMax": {
|
||||
"id": "minimax",
|
||||
"provider": "minimax",
|
||||
"type": "openai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"api_base": "https://api.minimaxi.com/v1",
|
||||
"timeout": 120,
|
||||
"proxy": "",
|
||||
"custom_headers": {},
|
||||
},
|
||||
"xAI": {
|
||||
"id": "xai",
|
||||
"provider": "xai",
|
||||
@@ -1059,7 +1200,7 @@ CONFIG_METADATA_2 = {
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"timeout": 120,
|
||||
"api_base": "https://openrouter.ai/v1",
|
||||
"api_base": "https://openrouter.ai/api/v1",
|
||||
"proxy": "",
|
||||
"custom_headers": {},
|
||||
},
|
||||
@@ -1252,6 +1393,25 @@ CONFIG_METADATA_2 = {
|
||||
"timeout": 60,
|
||||
"proxy": "",
|
||||
},
|
||||
"DeerFlow": {
|
||||
"id": "deerflow",
|
||||
"provider": "deerflow",
|
||||
"type": "deerflow",
|
||||
"provider_type": "agent_runner",
|
||||
"enable": True,
|
||||
"deerflow_api_base": "http://127.0.0.1:2026",
|
||||
"deerflow_api_key": "",
|
||||
"deerflow_auth_header": "",
|
||||
"deerflow_assistant_id": "lead_agent",
|
||||
"deerflow_model_name": "",
|
||||
"deerflow_thinking_enabled": False,
|
||||
"deerflow_plan_mode": False,
|
||||
"deerflow_subagent_enabled": False,
|
||||
"deerflow_max_concurrent_subagents": 3,
|
||||
"deerflow_recursion_limit": 1000,
|
||||
"timeout": 300,
|
||||
"proxy": "",
|
||||
},
|
||||
"FastGPT": {
|
||||
"id": "fastgpt",
|
||||
"provider": "fastgpt",
|
||||
@@ -2258,6 +2418,55 @@ CONFIG_METADATA_2 = {
|
||||
"type": "string",
|
||||
"hint": "Coze API 的基础 URL 地址,默认为 https://api.coze.cn",
|
||||
},
|
||||
"deerflow_api_base": {
|
||||
"description": "API Base URL",
|
||||
"type": "string",
|
||||
"hint": "DeerFlow API 网关地址,默认为 http://127.0.0.1:2026",
|
||||
},
|
||||
"deerflow_api_key": {
|
||||
"description": "DeerFlow API Key",
|
||||
"type": "string",
|
||||
"hint": "可选。若 DeerFlow 网关配置了 Bearer 鉴权,则在此填写。",
|
||||
},
|
||||
"deerflow_auth_header": {
|
||||
"description": "Authorization Header",
|
||||
"type": "string",
|
||||
"hint": "可选。自定义 Authorization 请求头,优先级高于 DeerFlow API Key。",
|
||||
},
|
||||
"deerflow_assistant_id": {
|
||||
"description": "Assistant ID",
|
||||
"type": "string",
|
||||
"hint": "LangGraph assistant_id,默认为 lead_agent。",
|
||||
},
|
||||
"deerflow_model_name": {
|
||||
"description": "模型名称覆盖",
|
||||
"type": "string",
|
||||
"hint": "可选。覆盖 DeerFlow 默认模型(对应 runtime context 的 model_name)。",
|
||||
},
|
||||
"deerflow_thinking_enabled": {
|
||||
"description": "启用思考模式",
|
||||
"type": "bool",
|
||||
},
|
||||
"deerflow_plan_mode": {
|
||||
"description": "启用计划模式",
|
||||
"type": "bool",
|
||||
"hint": "对应 DeerFlow 的 is_plan_mode。",
|
||||
},
|
||||
"deerflow_subagent_enabled": {
|
||||
"description": "启用子智能体",
|
||||
"type": "bool",
|
||||
"hint": "对应 DeerFlow 的 subagent_enabled。",
|
||||
},
|
||||
"deerflow_max_concurrent_subagents": {
|
||||
"description": "子智能体最大并发数",
|
||||
"type": "int",
|
||||
"hint": "对应 DeerFlow 的 max_concurrent_subagents。仅在启用子智能体时生效,默认 3。",
|
||||
},
|
||||
"deerflow_recursion_limit": {
|
||||
"description": "递归深度上限",
|
||||
"type": "int",
|
||||
"hint": "对应 LangGraph recursion_limit。",
|
||||
},
|
||||
"auto_save_history": {
|
||||
"description": "由 Coze 管理对话记录",
|
||||
"type": "bool",
|
||||
@@ -2335,6 +2544,9 @@ CONFIG_METADATA_2 = {
|
||||
"dashscope_agent_runner_provider_id": {
|
||||
"type": "string",
|
||||
},
|
||||
"deerflow_agent_runner_provider_id": {
|
||||
"type": "string",
|
||||
},
|
||||
"max_agent_step": {
|
||||
"type": "int",
|
||||
},
|
||||
@@ -2543,7 +2755,7 @@ CONFIG_METADATA_3 = {
|
||||
"metadata": {
|
||||
"agent_runner": {
|
||||
"description": "Agent 执行方式",
|
||||
"hint": "选择 AI 对话的执行器,默认为 AstrBot 内置 Agent 执行器,可使用 AstrBot 内的知识库、人格、工具调用功能。如果不打算接入 Dify 或 Coze 等第三方 Agent 执行器,不需要修改此节。",
|
||||
"hint": "选择 AI 对话的执行器,默认为 AstrBot 内置 Agent 执行器,可使用 AstrBot 内的知识库、人格、工具调用功能。如果不打算接入 Dify、Coze、DeerFlow 等第三方 Agent 执行器,不需要修改此节。",
|
||||
"type": "object",
|
||||
"items": {
|
||||
"provider_settings.enable": {
|
||||
@@ -2554,8 +2766,14 @@ CONFIG_METADATA_3 = {
|
||||
"provider_settings.agent_runner_type": {
|
||||
"description": "执行器",
|
||||
"type": "string",
|
||||
"options": ["local", "dify", "coze", "dashscope"],
|
||||
"labels": ["内置 Agent", "Dify", "Coze", "阿里云百炼应用"],
|
||||
"options": ["local", "dify", "coze", "dashscope", "deerflow"],
|
||||
"labels": [
|
||||
"内置 Agent",
|
||||
"Dify",
|
||||
"Coze",
|
||||
"阿里云百炼应用",
|
||||
"DeerFlow",
|
||||
],
|
||||
"condition": {
|
||||
"provider_settings.enable": True,
|
||||
},
|
||||
@@ -2587,6 +2805,15 @@ CONFIG_METADATA_3 = {
|
||||
"provider_settings.enable": True,
|
||||
},
|
||||
},
|
||||
"provider_settings.deerflow_agent_runner_provider_id": {
|
||||
"description": "DeerFlow Agent 执行器提供商 ID",
|
||||
"type": "string",
|
||||
"_special": "select_agent_runner_provider:deerflow",
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "deerflow",
|
||||
"provider_settings.enable": True,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"ai": {
|
||||
@@ -2784,12 +3011,48 @@ CONFIG_METADATA_3 = {
|
||||
"provider_settings.sandbox.booter": {
|
||||
"description": "沙箱环境驱动器",
|
||||
"type": "string",
|
||||
"options": ["shipyard"],
|
||||
"labels": ["Shipyard"],
|
||||
"options": ["shipyard_neo", "shipyard"],
|
||||
"labels": ["Shipyard Neo", "Shipyard"],
|
||||
"condition": {
|
||||
"provider_settings.computer_use_runtime": "sandbox",
|
||||
},
|
||||
},
|
||||
"provider_settings.sandbox.shipyard_neo_endpoint": {
|
||||
"description": "Shipyard Neo API Endpoint",
|
||||
"type": "string",
|
||||
"hint": "Shipyard Neo(Bay) 服务的 API 地址,默认 http://127.0.0.1:8114。",
|
||||
"condition": {
|
||||
"provider_settings.computer_use_runtime": "sandbox",
|
||||
"provider_settings.sandbox.booter": "shipyard_neo",
|
||||
},
|
||||
},
|
||||
"provider_settings.sandbox.shipyard_neo_access_token": {
|
||||
"description": "Shipyard Neo Access Token",
|
||||
"type": "string",
|
||||
"hint": "Bay 的 API Key(sk-bay-...)。留空时自动从 credentials.json 发现。",
|
||||
"condition": {
|
||||
"provider_settings.computer_use_runtime": "sandbox",
|
||||
"provider_settings.sandbox.booter": "shipyard_neo",
|
||||
},
|
||||
},
|
||||
"provider_settings.sandbox.shipyard_neo_profile": {
|
||||
"description": "Shipyard Neo Profile",
|
||||
"type": "string",
|
||||
"hint": "Shipyard Neo 沙箱 profile,如 python-default。",
|
||||
"condition": {
|
||||
"provider_settings.computer_use_runtime": "sandbox",
|
||||
"provider_settings.sandbox.booter": "shipyard_neo",
|
||||
},
|
||||
},
|
||||
"provider_settings.sandbox.shipyard_neo_ttl": {
|
||||
"description": "Shipyard Neo Sandbox TTL",
|
||||
"type": "int",
|
||||
"hint": "Shipyard Neo 沙箱生存时间(秒)。",
|
||||
"condition": {
|
||||
"provider_settings.computer_use_runtime": "sandbox",
|
||||
"provider_settings.sandbox.booter": "shipyard_neo",
|
||||
},
|
||||
},
|
||||
"provider_settings.sandbox.shipyard_endpoint": {
|
||||
"description": "Shipyard API Endpoint",
|
||||
"type": "string",
|
||||
@@ -3025,46 +3288,6 @@ CONFIG_METADATA_3 = {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
},
|
||||
"provider_settings.max_quoted_fallback_images": {
|
||||
"description": "引用图片回退解析上限",
|
||||
"type": "int",
|
||||
"hint": "引用/转发消息回退解析图片时的最大注入数量,超出会截断。",
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
},
|
||||
"provider_settings.quoted_message_parser.max_component_chain_depth": {
|
||||
"description": "引用解析组件链深度",
|
||||
"type": "int",
|
||||
"hint": "解析 Reply 组件链时允许的最大递归深度。",
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
},
|
||||
"provider_settings.quoted_message_parser.max_forward_node_depth": {
|
||||
"description": "引用解析转发节点深度",
|
||||
"type": "int",
|
||||
"hint": "解析合并转发节点时允许的最大递归深度。",
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
},
|
||||
"provider_settings.quoted_message_parser.max_forward_fetch": {
|
||||
"description": "引用解析转发拉取上限",
|
||||
"type": "int",
|
||||
"hint": "递归拉取 get_forward_msg 的最大次数。",
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
},
|
||||
"provider_settings.quoted_message_parser.warn_on_action_failure": {
|
||||
"description": "引用解析 action 失败告警",
|
||||
"type": "bool",
|
||||
"hint": "开启后,get_msg/get_forward_msg 全部尝试失败时输出 warning 日志。",
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
},
|
||||
"provider_settings.max_agent_step": {
|
||||
"description": "工具调用轮数上限",
|
||||
"type": "int",
|
||||
@@ -3108,6 +3331,46 @@ CONFIG_METADATA_3 = {
|
||||
"type": "bool",
|
||||
"hint": "/provider 命令列出模型时是否并发检测连通性。开启后会主动调用模型测试连通性,可能产生额外 token 消耗。",
|
||||
},
|
||||
"provider_settings.max_quoted_fallback_images": {
|
||||
"description": "引用图片回退解析上限",
|
||||
"type": "int",
|
||||
"hint": "引用/转发消息回退解析图片时的最大注入数量,超出会截断。",
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
},
|
||||
"provider_settings.quoted_message_parser.max_component_chain_depth": {
|
||||
"description": "引用解析组件链深度",
|
||||
"type": "int",
|
||||
"hint": "解析 Reply 组件链时允许的最大递归深度。",
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
},
|
||||
"provider_settings.quoted_message_parser.max_forward_node_depth": {
|
||||
"description": "引用解析转发节点深度",
|
||||
"type": "int",
|
||||
"hint": "解析合并转发节点时允许的最大递归深度。",
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
},
|
||||
"provider_settings.quoted_message_parser.max_forward_fetch": {
|
||||
"description": "引用解析转发拉取上限",
|
||||
"type": "int",
|
||||
"hint": "递归拉取 get_forward_msg 的最大次数。",
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
},
|
||||
"provider_settings.quoted_message_parser.warn_on_action_failure": {
|
||||
"description": "引用解析 action 失败告警",
|
||||
"type": "bool",
|
||||
"hint": "开启后,get_msg/get_forward_msg 全部尝试失败时输出 warning 日志。",
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
},
|
||||
},
|
||||
"condition": {
|
||||
"provider_settings.enable": True,
|
||||
@@ -3319,6 +3582,19 @@ CONFIG_METADATA_3 = {
|
||||
"platform_specific.telegram.pre_ack_emoji.enable": True,
|
||||
},
|
||||
},
|
||||
"platform_specific.discord.pre_ack_emoji.enable": {
|
||||
"description": "[Discord] 启用预回应表情",
|
||||
"type": "bool",
|
||||
},
|
||||
"platform_specific.discord.pre_ack_emoji.emojis": {
|
||||
"description": "表情列表(Unicode 或自定义表情名)",
|
||||
"type": "list",
|
||||
"items": {"type": "string"},
|
||||
"hint": "填写 Unicode 表情符号,例如:👍、🤔、⏳",
|
||||
"condition": {
|
||||
"platform_specific.discord.pre_ack_emoji.enable": True,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ from astrbot.core import sp
|
||||
from astrbot.core.agent.message import AssistantMessageSegment, UserMessageSegment
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.db.po import Conversation, ConversationV2
|
||||
from astrbot.core.utils.datetime_utils import to_utc_timestamp
|
||||
|
||||
|
||||
class ConversationManager:
|
||||
@@ -58,8 +59,10 @@ class ConversationManager:
|
||||
|
||||
def _convert_conv_from_v2_to_v1(self, conv_v2: ConversationV2) -> Conversation:
|
||||
"""将 ConversationV2 对象转换为 Conversation 对象"""
|
||||
created_at = int(conv_v2.created_at.timestamp())
|
||||
updated_at = int(conv_v2.updated_at.timestamp())
|
||||
created_ts = to_utc_timestamp(conv_v2.created_at)
|
||||
updated_ts = to_utc_timestamp(conv_v2.updated_at)
|
||||
created_at = int(created_ts) if created_ts is not None else 0
|
||||
updated_at = int(updated_ts) if updated_ts is not None else 0
|
||||
return Conversation(
|
||||
platform_id=conv_v2.platform_id,
|
||||
user_id=conv_v2.user_id,
|
||||
|
||||
@@ -29,9 +29,9 @@ from astrbot.core.pipeline.scheduler import PipelineContext, PipelineScheduler
|
||||
from astrbot.core.platform.manager import PlatformManager
|
||||
from astrbot.core.platform_message_history_mgr import PlatformMessageHistoryManager
|
||||
from astrbot.core.provider.manager import ProviderManager
|
||||
from astrbot.core.star import PluginManager
|
||||
from astrbot.core.star.context import Context
|
||||
from astrbot.core.star.star_handler import EventType, star_handlers_registry, star_map
|
||||
from astrbot.core.star.star_manager import PluginManager
|
||||
from astrbot.core.subagent_orchestrator import SubAgentOrchestrator
|
||||
from astrbot.core.umop_config_router import UmopConfigRouter
|
||||
from astrbot.core.updator import AstrBotUpdator
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""CronToolProvider — provides cron job management tools.
|
||||
|
||||
Follows the same ``ToolProvider`` protocol as ``ComputerToolProvider``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from astrbot.core.agent.tool import FunctionTool
|
||||
from astrbot.core.tool_provider import ToolProvider, ToolProviderContext
|
||||
from astrbot.core.tools.cron_tools import (
|
||||
CREATE_CRON_JOB_TOOL,
|
||||
DELETE_CRON_JOB_TOOL,
|
||||
LIST_CRON_JOBS_TOOL,
|
||||
)
|
||||
|
||||
|
||||
class CronToolProvider(ToolProvider):
|
||||
"""Provides cron-job management tools when enabled."""
|
||||
|
||||
def get_tools(self, ctx: ToolProviderContext) -> list[FunctionTool]:
|
||||
return [CREATE_CRON_JOB_TOOL, DELETE_CRON_JOB_TOOL, LIST_CRON_JOBS_TOOL]
|
||||
|
||||
def get_system_prompt_addon(self, ctx: ToolProviderContext) -> str:
|
||||
return ""
|
||||
@@ -273,10 +273,12 @@ class CronJobManager:
|
||||
_get_session_conv,
|
||||
build_main_agent,
|
||||
)
|
||||
from astrbot.core.astr_main_agent_resources import (
|
||||
from astrbot.core.tools.prompts import (
|
||||
CONVERSATION_HISTORY_INJECT_PREFIX,
|
||||
CRON_TASK_WOKE_USER_PROMPT,
|
||||
PROACTIVE_AGENT_CRON_WOKE_SYSTEM_PROMPT,
|
||||
SEND_MESSAGE_TO_USER_TOOL,
|
||||
)
|
||||
from astrbot.core.tools.send_message import SEND_MESSAGE_TO_USER_TOOL
|
||||
|
||||
try:
|
||||
session = (
|
||||
@@ -307,10 +309,13 @@ class CronJobManager:
|
||||
if cron_payload.get("origin", "tool") == "api":
|
||||
cron_event.role = "admin"
|
||||
|
||||
from astrbot.core.computer.computer_tool_provider import ComputerToolProvider
|
||||
|
||||
config = MainAgentBuildConfig(
|
||||
tool_call_timeout=3600,
|
||||
llm_safety_mode=False,
|
||||
streaming_response=False,
|
||||
tool_providers=[ComputerToolProvider()],
|
||||
)
|
||||
req = ProviderRequest()
|
||||
conv = await _get_session_conv(event=cron_event, plugin_context=self.ctx)
|
||||
@@ -322,21 +327,13 @@ class CronJobManager:
|
||||
context_dump = req._print_friendly_context()
|
||||
req.contexts = []
|
||||
req.system_prompt += (
|
||||
"\n\nBellow is you and user previous conversation history:\n"
|
||||
f"---\n"
|
||||
f"{context_dump}\n"
|
||||
f"---\n"
|
||||
CONVERSATION_HISTORY_INJECT_PREFIX + f"---\n{context_dump}\n---\n"
|
||||
)
|
||||
cron_job_str = json.dumps(extras.get("cron_job", {}), ensure_ascii=False)
|
||||
req.system_prompt += PROACTIVE_AGENT_CRON_WOKE_SYSTEM_PROMPT.format(
|
||||
cron_job=cron_job_str
|
||||
)
|
||||
req.prompt = (
|
||||
"You are now responding to a scheduled task"
|
||||
"Proceed according to your system instructions. "
|
||||
"Output using same language as previous conversation."
|
||||
"After completing your task, summarize and output your actions and results."
|
||||
)
|
||||
req.prompt = CRON_TASK_WOKE_USER_PROMPT
|
||||
if not req.func_tool:
|
||||
req.func_tool = ToolSet()
|
||||
req.func_tool.add_tool(SEND_MESSAGE_TO_USER_TOOL)
|
||||
|
||||
@@ -33,10 +33,18 @@ class BaseDatabase(abc.ABC):
|
||||
DATABASE_URL = ""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# SQLite only supports a single writer at a time. Without a busy
|
||||
# timeout the driver raises "database is locked" instantly when a
|
||||
# second write is attempted. Setting timeout=30 tells SQLite to
|
||||
# wait up to 30 s for the lock, which is enough to ride out brief
|
||||
# write bursts from concurrent agent/metrics/session operations.
|
||||
is_sqlite = "sqlite" in self.DATABASE_URL
|
||||
connect_args = {"timeout": 30} if is_sqlite else {}
|
||||
self.engine = create_async_engine(
|
||||
self.DATABASE_URL,
|
||||
echo=False,
|
||||
future=True,
|
||||
connect_args=connect_args,
|
||||
)
|
||||
self.AsyncSessionLocal = async_sessionmaker(
|
||||
self.engine,
|
||||
@@ -306,6 +314,7 @@ class BaseDatabase(abc.ABC):
|
||||
begin_dialogs: list[str] | None = None,
|
||||
tools: list[str] | None = None,
|
||||
skills: list[str] | None = None,
|
||||
custom_error_message: str | None = None,
|
||||
folder_id: str | None = None,
|
||||
sort_order: int = 0,
|
||||
) -> Persona:
|
||||
@@ -317,6 +326,7 @@ class BaseDatabase(abc.ABC):
|
||||
begin_dialogs: Optional list of initial dialog strings
|
||||
tools: Optional list of tool names (None means all tools, [] means no tools)
|
||||
skills: Optional list of skill names (None means all skills, [] means no skills)
|
||||
custom_error_message: Optional persona-level fallback error message
|
||||
folder_id: Optional folder ID to place the persona in (None means root)
|
||||
sort_order: Sort order within the folder (default 0)
|
||||
"""
|
||||
@@ -340,6 +350,7 @@ class BaseDatabase(abc.ABC):
|
||||
begin_dialogs: list[str] | None = None,
|
||||
tools: list[str] | None = None,
|
||||
skills: list[str] | None = None,
|
||||
custom_error_message: str | None = None,
|
||||
) -> Persona | None:
|
||||
"""Update a persona's system prompt or begin dialogs."""
|
||||
...
|
||||
@@ -644,6 +655,13 @@ class BaseDatabase(abc.ABC):
|
||||
"""Get a Platform session by its ID."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_platform_sessions_by_ids(
|
||||
self, session_ids: list[str]
|
||||
) -> list[PlatformSession]:
|
||||
"""Get platform sessions by IDs."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_platform_sessions_by_creator(
|
||||
self,
|
||||
|
||||
@@ -126,6 +126,8 @@ class Persona(TimestampMixin, SQLModel, table=True):
|
||||
"""None means use ALL tools for default, empty list means no tools, otherwise a list of tool names."""
|
||||
skills: list | None = Field(default=None, sa_type=JSON)
|
||||
"""None means use ALL skills for default, empty list means no skills, otherwise a list of skill names."""
|
||||
custom_error_message: str | None = Field(default=None, sa_type=Text)
|
||||
"""Optional custom error message sent to end users when the agent request fails."""
|
||||
folder_id: str | None = Field(default=None, max_length=36)
|
||||
"""所属文件夹ID,NULL 表示在根目录"""
|
||||
sort_order: int = Field(default=0)
|
||||
@@ -472,6 +474,8 @@ class Personality(TypedDict):
|
||||
"""工具列表。None 表示使用所有工具,空列表表示不使用任何工具"""
|
||||
skills: list[str] | None
|
||||
"""Skills 列表。None 表示使用所有 Skills,空列表表示不使用任何 Skills"""
|
||||
custom_error_message: str | None
|
||||
"""可选的人格自定义报错回复信息。配置后将优先发送给最终用户。"""
|
||||
|
||||
# cache
|
||||
_begin_dialogs_processed: list[dict]
|
||||
|
||||
@@ -32,8 +32,8 @@ from astrbot.core.db.po import (
|
||||
from astrbot.core.db.po import (
|
||||
Stats as DeprecatedStats,
|
||||
)
|
||||
from astrbot.core.sentinels import NOT_GIVEN
|
||||
|
||||
NOT_GIVEN = T.TypeVar("NOT_GIVEN")
|
||||
TxResult = T.TypeVar("TxResult")
|
||||
CRON_FIELD_NOT_SET = object()
|
||||
|
||||
@@ -58,6 +58,7 @@ class SQLiteDatabase(BaseDatabase):
|
||||
# 确保 personas 表有 folder_id、sort_order、skills 列(前向兼容)
|
||||
await self._ensure_persona_folder_columns(conn)
|
||||
await self._ensure_persona_skills_column(conn)
|
||||
await self._ensure_persona_custom_error_message_column(conn)
|
||||
await conn.commit()
|
||||
|
||||
async def _ensure_persona_folder_columns(self, conn) -> None:
|
||||
@@ -92,6 +93,16 @@ class SQLiteDatabase(BaseDatabase):
|
||||
if "skills" not in columns:
|
||||
await conn.execute(text("ALTER TABLE personas ADD COLUMN skills JSON"))
|
||||
|
||||
async def _ensure_persona_custom_error_message_column(self, conn) -> None:
|
||||
"""确保 personas 表有 custom_error_message 列。"""
|
||||
result = await conn.execute(text("PRAGMA table_info(personas)"))
|
||||
columns = {row[1] for row in result.fetchall()}
|
||||
|
||||
if "custom_error_message" not in columns:
|
||||
await conn.execute(
|
||||
text("ALTER TABLE personas ADD COLUMN custom_error_message TEXT")
|
||||
)
|
||||
|
||||
# ====
|
||||
# Platform Statistics
|
||||
# ====
|
||||
@@ -675,6 +686,7 @@ class SQLiteDatabase(BaseDatabase):
|
||||
begin_dialogs=None,
|
||||
tools=None,
|
||||
skills=None,
|
||||
custom_error_message=None,
|
||||
folder_id=None,
|
||||
sort_order=0,
|
||||
):
|
||||
@@ -688,6 +700,7 @@ class SQLiteDatabase(BaseDatabase):
|
||||
begin_dialogs=begin_dialogs or [],
|
||||
tools=tools,
|
||||
skills=skills,
|
||||
custom_error_message=custom_error_message,
|
||||
folder_id=folder_id,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
@@ -719,6 +732,7 @@ class SQLiteDatabase(BaseDatabase):
|
||||
begin_dialogs=None,
|
||||
tools=NOT_GIVEN,
|
||||
skills=NOT_GIVEN,
|
||||
custom_error_message=NOT_GIVEN,
|
||||
):
|
||||
"""Update a persona's system prompt or begin dialogs."""
|
||||
async with self.get_db() as session:
|
||||
@@ -734,6 +748,8 @@ class SQLiteDatabase(BaseDatabase):
|
||||
values["tools"] = tools
|
||||
if skills is not NOT_GIVEN:
|
||||
values["skills"] = skills
|
||||
if custom_error_message is not NOT_GIVEN:
|
||||
values["custom_error_message"] = custom_error_message
|
||||
if not values:
|
||||
return None
|
||||
query = query.values(**values)
|
||||
@@ -1401,6 +1417,21 @@ class SQLiteDatabase(BaseDatabase):
|
||||
result = await session.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_platform_sessions_by_ids(
|
||||
self, session_ids: list[str]
|
||||
) -> list[PlatformSession]:
|
||||
"""Get platform sessions by IDs."""
|
||||
if not session_ids:
|
||||
return []
|
||||
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
query = select(PlatformSession).where(
|
||||
col(PlatformSession.session_id).in_(session_ids)
|
||||
)
|
||||
result = await session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_platform_sessions_by_creator(
|
||||
self,
|
||||
creator: str,
|
||||
|
||||
@@ -38,11 +38,13 @@ class EventBus:
|
||||
while True:
|
||||
event: AstrMessageEvent = await self.event_queue.get()
|
||||
conf_info = self.astrbot_config_mgr.get_conf_info(event.unified_msg_origin)
|
||||
self._print_event(event, conf_info["name"])
|
||||
scheduler = self.pipeline_scheduler_mapping.get(conf_info["id"])
|
||||
conf_id = conf_info["id"]
|
||||
conf_name = conf_info.get("name") or conf_id
|
||||
self._print_event(event, conf_name)
|
||||
scheduler = self.pipeline_scheduler_mapping.get(conf_id)
|
||||
if not scheduler:
|
||||
logger.error(
|
||||
f"PipelineScheduler not found for id: {conf_info['id']}, event ignored."
|
||||
f"PipelineScheduler not found for id: {conf_id}, event ignored."
|
||||
)
|
||||
continue
|
||||
asyncio.create_task(scheduler.execute(event))
|
||||
|
||||
@@ -175,6 +175,10 @@ class LogManager:
|
||||
_trace_sink_id: int | None = None
|
||||
_NOISY_LOGGER_LEVELS: dict[str, int] = {
|
||||
"aiosqlite": logging.WARNING,
|
||||
"filelock": logging.WARNING,
|
||||
"asyncio": logging.WARNING,
|
||||
"tzlocal": logging.WARNING,
|
||||
"apscheduler": logging.WARNING,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -96,10 +96,10 @@ class Plain(BaseMessageComponent):
|
||||
def __init__(self, text: str, convert: bool = True, **_) -> None:
|
||||
super().__init__(text=text, convert=convert, **_)
|
||||
|
||||
def toDict(self):
|
||||
return {"type": "text", "data": {"text": self.text.strip()}}
|
||||
def toDict(self) -> dict:
|
||||
return {"type": "text", "data": {"text": self.text}}
|
||||
|
||||
async def to_dict(self):
|
||||
async def to_dict(self) -> dict:
|
||||
return {"type": "text", "data": {"text": self.text}}
|
||||
|
||||
|
||||
@@ -539,13 +539,36 @@ class Reply(BaseMessageComponent):
|
||||
|
||||
|
||||
class Poke(BaseMessageComponent):
|
||||
type: str = ComponentType.Poke
|
||||
id: int | None = 0
|
||||
qq: int | None = 0
|
||||
type: ComponentType = ComponentType.Poke
|
||||
_type: str | int = "126"
|
||||
id: int | str | None = 0
|
||||
qq: int | str | None = 0 # deprecated: legacy field, kept for compatibility
|
||||
|
||||
def __init__(self, type: str, **_) -> None:
|
||||
type = f"Poke:{type}"
|
||||
super().__init__(type=type, **_)
|
||||
def __init__(self, poke_type: str | int | None = None, **_) -> None:
|
||||
# Backward compatible with old signature: Poke(type="poke", ...)
|
||||
legacy_type = _.pop("type", None)
|
||||
if poke_type is None:
|
||||
poke_type = legacy_type
|
||||
if poke_type in (None, "", "poke", "Poke"):
|
||||
poke_type = "126"
|
||||
super().__init__(_type=str(poke_type), **_)
|
||||
|
||||
def target_id(self) -> str | None:
|
||||
"""Return normalized target id, compatible with old `qq` field."""
|
||||
for value in (self.id, self.qq):
|
||||
if value is None:
|
||||
continue
|
||||
text = str(value).strip()
|
||||
if text and text != "0":
|
||||
return text
|
||||
return None
|
||||
|
||||
def toDict(self):
|
||||
target_id = self.target_id()
|
||||
data = {"type": str(self._type or "126")}
|
||||
if target_id:
|
||||
data["id"] = target_id
|
||||
return {"type": "poke", "data": data}
|
||||
|
||||
|
||||
class Forward(BaseMessageComponent):
|
||||
@@ -676,21 +699,24 @@ class File(BaseMessageComponent):
|
||||
|
||||
if self.url:
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
logger.warning(
|
||||
"不可以在异步上下文中同步等待下载! "
|
||||
"这个警告通常发生于某些逻辑试图通过 <File>.file 获取文件消息段的文件内容。"
|
||||
"请使用 await get_file() 代替直接获取 <File>.file 字段",
|
||||
)
|
||||
return ""
|
||||
# 等待下载完成
|
||||
loop.run_until_complete(self._download_file())
|
||||
# 检查是否有正在运行的 event loop
|
||||
asyncio.get_running_loop()
|
||||
logger.warning(
|
||||
"不可以在异步上下文中同步等待下载! "
|
||||
"这个警告通常发生于某些逻辑试图通过 <File>.file 获取文件消息段的文件内容。"
|
||||
"请使用 await get_file() 代替直接获取 <File>.file 字段",
|
||||
)
|
||||
return ""
|
||||
except RuntimeError:
|
||||
# 没有运行中的 event loop,可以同步执行
|
||||
try:
|
||||
# 使用 asyncio.run 安全地创建和关闭事件循环
|
||||
asyncio.run(self._download_file())
|
||||
except Exception:
|
||||
logger.exception("文件下载失败")
|
||||
|
||||
if self.file_ and os.path.exists(self.file_):
|
||||
return os.path.abspath(self.file_)
|
||||
except Exception as e:
|
||||
logger.error(f"文件下载失败: {e}")
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
@@ -182,6 +182,8 @@ class ResultContentType(enum.Enum):
|
||||
|
||||
LLM_RESULT = enum.auto()
|
||||
"""调用 LLM 产生的结果"""
|
||||
AGENT_RUNNER_ERROR = enum.auto()
|
||||
"""第三方 Agent Runner 返回的错误结果"""
|
||||
GENERAL_RESULT = enum.auto()
|
||||
"""普通的消息结果"""
|
||||
STREAMING_RESULT = enum.auto()
|
||||
@@ -246,6 +248,13 @@ class MessageEventResult(MessageChain):
|
||||
"""是否为 LLM 结果。"""
|
||||
return self.result_content_type == ResultContentType.LLM_RESULT
|
||||
|
||||
def is_model_result(self) -> bool:
|
||||
"""Whether result comes from model execution (including runner errors)."""
|
||||
return self.result_content_type in (
|
||||
ResultContentType.LLM_RESULT,
|
||||
ResultContentType.AGENT_RUNNER_ERROR,
|
||||
)
|
||||
|
||||
|
||||
# 为了兼容旧版代码,保留 CommandResult 的别名
|
||||
CommandResult = MessageEventResult
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
PERSONA_CUSTOM_ERROR_MESSAGE_EXTRA_KEY = "persona_custom_error_message"
|
||||
|
||||
|
||||
def normalize_persona_custom_error_message(value: object) -> str | None:
|
||||
"""Normalize persona custom error reply text."""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
message = value.strip()
|
||||
return message or None
|
||||
|
||||
|
||||
def extract_persona_custom_error_message_from_persona(
|
||||
persona: Mapping[str, Any] | None,
|
||||
) -> str | None:
|
||||
"""Extract normalized custom error reply text from persona mapping."""
|
||||
if persona is None:
|
||||
return None
|
||||
return normalize_persona_custom_error_message(persona.get("custom_error_message"))
|
||||
|
||||
|
||||
def extract_persona_custom_error_message_from_event(event: Any) -> str | None:
|
||||
"""Extract normalized custom error reply text from event extras."""
|
||||
try:
|
||||
if event is None or not hasattr(event, "get_extra"):
|
||||
return None
|
||||
raw_message = event.get_extra(PERSONA_CUSTOM_ERROR_MESSAGE_EXTRA_KEY)
|
||||
return normalize_persona_custom_error_message(raw_message)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def set_persona_custom_error_message_on_event(
|
||||
event: Any, message: object
|
||||
) -> str | None:
|
||||
"""Normalize and store persona custom error reply text into event extras."""
|
||||
normalized = normalize_persona_custom_error_message(message)
|
||||
try:
|
||||
if event is not None and hasattr(event, "set_extra"):
|
||||
event.set_extra(PERSONA_CUSTOM_ERROR_MESSAGE_EXTRA_KEY, normalized)
|
||||
except Exception:
|
||||
pass
|
||||
return normalized
|
||||
|
||||
|
||||
async def resolve_persona_custom_error_message(
|
||||
*,
|
||||
event: Any,
|
||||
persona_manager: Any,
|
||||
provider_settings: dict | None = None,
|
||||
conversation_persona_id: str | None = None,
|
||||
) -> str | None:
|
||||
"""Resolve normalized custom error reply text for the selected persona."""
|
||||
(
|
||||
_persona_id,
|
||||
persona,
|
||||
_force_applied_persona_id,
|
||||
_use_webchat_special_default,
|
||||
) = await persona_manager.resolve_selected_persona(
|
||||
umo=event.unified_msg_origin,
|
||||
conversation_persona_id=conversation_persona_id,
|
||||
platform_name=event.get_platform_name(),
|
||||
provider_settings=provider_settings,
|
||||
)
|
||||
return extract_persona_custom_error_message_from_persona(persona)
|
||||
|
||||
|
||||
async def resolve_event_conversation_persona_id(
|
||||
event: Any, conversation_manager: Any
|
||||
) -> str | None:
|
||||
"""Resolve current conversation persona_id from event and conversation manager."""
|
||||
curr_cid = await conversation_manager.get_curr_conversation_id(
|
||||
event.unified_msg_origin
|
||||
)
|
||||
if not curr_cid:
|
||||
return None
|
||||
conversation = await conversation_manager.get_conversation(
|
||||
event.unified_msg_origin, curr_cid
|
||||
)
|
||||
if not conversation:
|
||||
return None
|
||||
return conversation.persona_id
|
||||
+70
-10
@@ -4,6 +4,7 @@ from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.db.po import Persona, PersonaFolder, Personality
|
||||
from astrbot.core.platform.message_session import MessageSession
|
||||
from astrbot.core.sentinels import NOT_GIVEN
|
||||
|
||||
DEFAULT_PERSONALITY = Personality(
|
||||
prompt="You are a helpful and friendly assistant.",
|
||||
@@ -12,6 +13,7 @@ DEFAULT_PERSONALITY = Personality(
|
||||
mood_imitation_dialogs=[],
|
||||
tools=None,
|
||||
skills=None,
|
||||
custom_error_message=None,
|
||||
_begin_dialogs_processed=[],
|
||||
_mood_imitation_dialogs_processed="",
|
||||
)
|
||||
@@ -42,6 +44,22 @@ class PersonaManager:
|
||||
raise ValueError(f"Persona with ID {persona_id} does not exist.")
|
||||
return persona
|
||||
|
||||
def get_persona_v3_by_id(self, persona_id: str | None) -> Personality | None:
|
||||
"""Resolve a v3 persona object by id.
|
||||
|
||||
- None/empty id returns None.
|
||||
- "default" maps to in-memory DEFAULT_PERSONALITY.
|
||||
- Otherwise search in personas_v3 by persona name.
|
||||
"""
|
||||
if not persona_id:
|
||||
return None
|
||||
if persona_id == "default":
|
||||
return DEFAULT_PERSONALITY
|
||||
return next(
|
||||
(persona for persona in self.personas_v3 if persona["name"] == persona_id),
|
||||
None,
|
||||
)
|
||||
|
||||
async def get_default_persona_v3(
|
||||
self,
|
||||
umo: str | MessageSession | None = None,
|
||||
@@ -52,12 +70,7 @@ class PersonaManager:
|
||||
"default_personality",
|
||||
"default",
|
||||
)
|
||||
if not default_persona_id or default_persona_id == "default":
|
||||
return DEFAULT_PERSONALITY
|
||||
try:
|
||||
return next(p for p in self.personas_v3 if p["name"] == default_persona_id)
|
||||
except Exception:
|
||||
return DEFAULT_PERSONALITY
|
||||
return self.get_persona_v3_by_id(default_persona_id) or DEFAULT_PERSONALITY
|
||||
|
||||
async def resolve_selected_persona(
|
||||
self,
|
||||
@@ -126,19 +139,27 @@ class PersonaManager:
|
||||
persona_id: str,
|
||||
system_prompt: str | None = None,
|
||||
begin_dialogs: list[str] | None = None,
|
||||
tools: list[str] | None = None,
|
||||
skills: list[str] | None = None,
|
||||
tools: list[str] | None | object = NOT_GIVEN,
|
||||
skills: list[str] | None | object = NOT_GIVEN,
|
||||
custom_error_message: str | None | object = NOT_GIVEN,
|
||||
):
|
||||
"""更新指定 persona 的信息。tools 参数为 None 时表示使用所有工具,空列表表示不使用任何工具"""
|
||||
existing_persona = await self.db.get_persona_by_id(persona_id)
|
||||
if not existing_persona:
|
||||
raise ValueError(f"Persona with ID {persona_id} does not exist.")
|
||||
update_kwargs = {}
|
||||
if tools is not NOT_GIVEN:
|
||||
update_kwargs["tools"] = tools
|
||||
if skills is not NOT_GIVEN:
|
||||
update_kwargs["skills"] = skills
|
||||
if custom_error_message is not NOT_GIVEN:
|
||||
update_kwargs["custom_error_message"] = custom_error_message
|
||||
|
||||
persona = await self.db.update_persona(
|
||||
persona_id,
|
||||
system_prompt,
|
||||
begin_dialogs,
|
||||
tools=tools,
|
||||
skills=skills,
|
||||
**update_kwargs,
|
||||
)
|
||||
if persona:
|
||||
for i, p in enumerate(self.personas):
|
||||
@@ -298,6 +319,7 @@ class PersonaManager:
|
||||
begin_dialogs: list[str] | None = None,
|
||||
tools: list[str] | None = None,
|
||||
skills: list[str] | None = None,
|
||||
custom_error_message: str | None = None,
|
||||
folder_id: str | None = None,
|
||||
sort_order: int = 0,
|
||||
) -> Persona:
|
||||
@@ -320,6 +342,7 @@ class PersonaManager:
|
||||
begin_dialogs,
|
||||
tools=tools,
|
||||
skills=skills,
|
||||
custom_error_message=custom_error_message,
|
||||
folder_id=folder_id,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
@@ -327,6 +350,41 @@ class PersonaManager:
|
||||
self.get_v3_persona_data()
|
||||
return new_persona
|
||||
|
||||
async def clone_persona(
|
||||
self,
|
||||
source_persona_id: str,
|
||||
new_persona_id: str,
|
||||
) -> Persona:
|
||||
"""Clone an existing persona with a new ID.
|
||||
|
||||
Args:
|
||||
source_persona_id: Source persona ID to clone from
|
||||
new_persona_id: New persona ID for the clone
|
||||
|
||||
Returns:
|
||||
The newly created persona clone
|
||||
"""
|
||||
source_persona = await self.db.get_persona_by_id(source_persona_id)
|
||||
if not source_persona:
|
||||
raise ValueError(f"Persona with ID {source_persona_id} does not exist.")
|
||||
|
||||
if await self.db.get_persona_by_id(new_persona_id):
|
||||
raise ValueError(f"Persona with ID {new_persona_id} already exists.")
|
||||
|
||||
new_persona = await self.db.insert_persona(
|
||||
new_persona_id,
|
||||
source_persona.system_prompt,
|
||||
source_persona.begin_dialogs,
|
||||
tools=source_persona.tools,
|
||||
skills=source_persona.skills,
|
||||
custom_error_message=source_persona.custom_error_message,
|
||||
folder_id=source_persona.folder_id,
|
||||
sort_order=source_persona.sort_order,
|
||||
)
|
||||
self.personas.append(new_persona)
|
||||
self.get_v3_persona_data()
|
||||
return new_persona
|
||||
|
||||
def get_v3_persona_data(
|
||||
self,
|
||||
) -> tuple[list[dict], list[Personality], Personality]:
|
||||
@@ -346,6 +404,7 @@ class PersonaManager:
|
||||
"mood_imitation_dialogs": [], # deprecated
|
||||
"tools": persona.tools,
|
||||
"skills": persona.skills,
|
||||
"custom_error_message": persona.custom_error_message,
|
||||
}
|
||||
for persona in self.personas
|
||||
]
|
||||
@@ -402,6 +461,7 @@ class PersonaManager:
|
||||
begin_dialogs=selected_default_persona["begin_dialogs"],
|
||||
tools=selected_default_persona["tools"] or None,
|
||||
skills=selected_default_persona["skills"] or None,
|
||||
custom_error_message=selected_default_persona["custom_error_message"],
|
||||
)
|
||||
|
||||
return v3_persona_config, personas_v3, selected_default_persona
|
||||
|
||||
@@ -67,6 +67,18 @@ _LAZY_EXPORTS = {
|
||||
),
|
||||
}
|
||||
|
||||
# Type-checking imports to satisfy static analyzers for __all__ exports
|
||||
if TYPE_CHECKING:
|
||||
from .content_safety_check.stage import ContentSafetyCheckStage
|
||||
from .preprocess_stage.stage import PreProcessStage
|
||||
from .process_stage.stage import ProcessStage
|
||||
from .rate_limit_check.stage import RateLimitStage
|
||||
from .respond.stage import RespondStage
|
||||
from .result_decorate.stage import ResultDecorateStage
|
||||
from .session_status_check.stage import SessionStatusCheckStage
|
||||
from .waking_check.stage import WakingCheckStage
|
||||
from .whitelist_check.stage import WhitelistCheckStage
|
||||
|
||||
__all__ = [
|
||||
"ContentSafetyCheckStage",
|
||||
"EventResultType",
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from astrbot.core.config import AstrBotConfig
|
||||
|
||||
from .context_utils import call_event_hook, call_handler
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from astrbot.core.star import PluginManager
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipelineContext:
|
||||
"""上下文对象,包含管道执行所需的上下文信息"""
|
||||
|
||||
astrbot_config: AstrBotConfig # AstrBot 配置对象
|
||||
plugin_manager: Any # 插件管理器对象
|
||||
plugin_manager: PluginManager # 插件管理器对象
|
||||
astrbot_config_id: str
|
||||
call_handler = call_handler
|
||||
call_event_hook = call_event_hook
|
||||
|
||||
@@ -27,7 +27,7 @@ class PreProcessStage(Stage):
|
||||
) -> None | AsyncGenerator[None, None]:
|
||||
"""在处理事件之前的预处理"""
|
||||
# 平台特异配置:platform_specific.<platform>.pre_ack_emoji
|
||||
supported = {"telegram", "lark"}
|
||||
supported = {"telegram", "lark", "discord"}
|
||||
platform = event.get_platform_name()
|
||||
cfg = (
|
||||
self.config.get("platform_specific", {})
|
||||
|
||||
@@ -19,6 +19,9 @@ from astrbot.core.message.message_event_result import (
|
||||
MessageEventResult,
|
||||
ResultContentType,
|
||||
)
|
||||
from astrbot.core.persona_error_reply import (
|
||||
extract_persona_custom_error_message_from_event,
|
||||
)
|
||||
from astrbot.core.pipeline.stage import Stage
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from astrbot.core.provider.entities import (
|
||||
@@ -110,6 +113,14 @@ class InternalAgentSubStage(Stage):
|
||||
|
||||
self.conv_manager = ctx.plugin_manager.context.conversation_manager
|
||||
|
||||
# Build decoupled tool providers
|
||||
from astrbot.core.computer.computer_tool_provider import ComputerToolProvider
|
||||
from astrbot.core.cron.cron_tool_provider import CronToolProvider
|
||||
|
||||
_tool_providers = [ComputerToolProvider()]
|
||||
if self.add_cron_tools:
|
||||
_tool_providers.append(CronToolProvider())
|
||||
|
||||
self.main_agent_cfg = MainAgentBuildConfig(
|
||||
tool_call_timeout=self.tool_call_timeout,
|
||||
tool_schema_mode=self.tool_schema_mode,
|
||||
@@ -128,6 +139,7 @@ class InternalAgentSubStage(Stage):
|
||||
safety_mode_strategy=self.safety_mode_strategy,
|
||||
computer_use_runtime=self.computer_use_runtime,
|
||||
sandbox_cfg=self.sandbox_cfg,
|
||||
tool_providers=_tool_providers,
|
||||
add_cron_tools=self.add_cron_tools,
|
||||
provider_settings=settings,
|
||||
subagent_orchestrator=conf.get("subagent_orchestrator", {}),
|
||||
@@ -227,6 +239,8 @@ class InternalAgentSubStage(Stage):
|
||||
if reset_coro:
|
||||
await reset_coro
|
||||
|
||||
effective_streaming_response = bool(agent_runner.streaming)
|
||||
|
||||
register_active_runner(event.unified_msg_origin, agent_runner)
|
||||
runner_registered = True
|
||||
action_type = event.get_extra("action_type")
|
||||
@@ -235,7 +249,7 @@ class InternalAgentSubStage(Stage):
|
||||
"astr_agent_prepare",
|
||||
system_prompt=req.system_prompt,
|
||||
tools=req.func_tool.names() if req.func_tool else [],
|
||||
stream=streaming_response,
|
||||
stream=effective_streaming_response,
|
||||
chat_provider={
|
||||
"id": provider.provider_config.get("id", ""),
|
||||
"model": provider.get_model(),
|
||||
@@ -289,7 +303,7 @@ class InternalAgentSubStage(Stage):
|
||||
user_aborted=agent_runner.was_aborted(),
|
||||
)
|
||||
|
||||
elif streaming_response and not stream_to_general:
|
||||
elif effective_streaming_response and not stream_to_general:
|
||||
# 流式响应
|
||||
event.set_result(
|
||||
MessageEventResult()
|
||||
@@ -366,11 +380,13 @@ class InternalAgentSubStage(Stage):
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error occurred while processing agent: {e}")
|
||||
await event.send(
|
||||
MessageChain().message(
|
||||
f"Error occurred while processing agent request: {e}"
|
||||
)
|
||||
custom_error_message = extract_persona_custom_error_message_from_event(
|
||||
event
|
||||
)
|
||||
error_text = custom_error_message or (
|
||||
f"Error occurred while processing agent request: {e}"
|
||||
)
|
||||
await event.send(MessageChain().message(error_text))
|
||||
finally:
|
||||
if follow_up_capture:
|
||||
await finalize_follow_up_capture(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator
|
||||
import inspect
|
||||
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from astrbot.core import astrbot_config, logger
|
||||
@@ -7,6 +8,13 @@ from astrbot.core.agent.runners.coze.coze_agent_runner import CozeAgentRunner
|
||||
from astrbot.core.agent.runners.dashscope.dashscope_agent_runner import (
|
||||
DashscopeAgentRunner,
|
||||
)
|
||||
from astrbot.core.agent.runners.deerflow.constants import (
|
||||
DEERFLOW_AGENT_RUNNER_PROVIDER_ID_KEY,
|
||||
DEERFLOW_PROVIDER_TYPE,
|
||||
)
|
||||
from astrbot.core.agent.runners.deerflow.deerflow_agent_runner import (
|
||||
DeerFlowAgentRunner,
|
||||
)
|
||||
from astrbot.core.agent.runners.dify.dify_agent_runner import DifyAgentRunner
|
||||
from astrbot.core.astr_agent_hooks import MAIN_AGENT_HOOKS
|
||||
from astrbot.core.message.components import Image
|
||||
@@ -15,15 +23,22 @@ from astrbot.core.message.message_event_result import (
|
||||
MessageEventResult,
|
||||
ResultContentType,
|
||||
)
|
||||
from astrbot.core.persona_error_reply import (
|
||||
resolve_event_conversation_persona_id,
|
||||
resolve_persona_custom_error_message,
|
||||
set_persona_custom_error_message_on_event,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from astrbot.core.agent.runners.base import BaseAgentRunner
|
||||
from astrbot.core.provider.entities import LLMResponse
|
||||
from astrbot.core.pipeline.stage import Stage
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from astrbot.core.provider.entities import (
|
||||
ProviderRequest,
|
||||
)
|
||||
from astrbot.core.star.star_handler import EventType
|
||||
from astrbot.core.utils.config_number import coerce_int_config
|
||||
from astrbot.core.utils.metrics import Metric
|
||||
|
||||
from .....astr_agent_context import AgentContextWrapper, AstrAgentContext
|
||||
@@ -33,13 +48,22 @@ AGENT_RUNNER_TYPE_KEY = {
|
||||
"dify": "dify_agent_runner_provider_id",
|
||||
"coze": "coze_agent_runner_provider_id",
|
||||
"dashscope": "dashscope_agent_runner_provider_id",
|
||||
DEERFLOW_PROVIDER_TYPE: DEERFLOW_AGENT_RUNNER_PROVIDER_ID_KEY,
|
||||
}
|
||||
THIRD_PARTY_RUNNER_ERROR_EXTRA_KEY = "_third_party_runner_error"
|
||||
STREAM_CONSUMPTION_CLOSE_TIMEOUT_SEC = 30
|
||||
RUNNER_NO_RESULT_FALLBACK_MESSAGE = "Agent Runner did not return any result."
|
||||
RUNNER_NO_FINAL_RESPONSE_LOG = (
|
||||
"Agent Runner returned no final response, fallback to streamed error/result chain."
|
||||
)
|
||||
RUNNER_NO_RESULT_LOG = "Agent Runner did not return final result."
|
||||
|
||||
|
||||
async def run_third_party_agent(
|
||||
runner: "BaseAgentRunner",
|
||||
stream_to_general: bool = False,
|
||||
) -> AsyncGenerator[MessageChain | None, None]:
|
||||
custom_error_message: str | None = None,
|
||||
) -> AsyncGenerator[tuple[MessageChain, bool], None]:
|
||||
"""
|
||||
运行第三方 agent runner 并转换响应格式
|
||||
类似于 run_agent 函数,但专门处理第三方 agent runner
|
||||
@@ -49,17 +73,92 @@ async def run_third_party_agent(
|
||||
if resp.type == "streaming_delta":
|
||||
if stream_to_general:
|
||||
continue
|
||||
yield resp.data["chain"]
|
||||
yield resp.data["chain"], False
|
||||
elif resp.type == "llm_result":
|
||||
if stream_to_general:
|
||||
yield resp.data["chain"]
|
||||
yield resp.data["chain"], False
|
||||
elif resp.type == "err":
|
||||
yield resp.data["chain"], True
|
||||
except Exception as e:
|
||||
logger.error(f"Third party agent runner error: {e}")
|
||||
err_msg = (
|
||||
f"\nAstrBot 请求失败。\n错误类型: {type(e).__name__}\n"
|
||||
f"错误信息: {e!s}\n\n请在平台日志查看和分享错误详情。\n"
|
||||
)
|
||||
yield MessageChain().message(err_msg)
|
||||
err_msg = custom_error_message
|
||||
if not err_msg:
|
||||
err_msg = (
|
||||
f"Error occurred during AI execution.\n"
|
||||
f"Error Type: {type(e).__name__} (3rd party)\n"
|
||||
f"Error Message: {str(e)}"
|
||||
)
|
||||
yield MessageChain().message(err_msg), True
|
||||
|
||||
|
||||
class _RunnerResultAggregator:
|
||||
def __init__(self) -> None:
|
||||
self.merged_chain: list = []
|
||||
self.has_error = False
|
||||
|
||||
def add_chunk(self, chain: MessageChain, is_error: bool) -> None:
|
||||
self.merged_chain.extend(chain.chain or [])
|
||||
if is_error:
|
||||
self.has_error = True
|
||||
|
||||
def finalize(
|
||||
self,
|
||||
final_resp: "LLMResponse | None",
|
||||
) -> tuple[list, bool]:
|
||||
if not final_resp or not final_resp.result_chain:
|
||||
if self.merged_chain:
|
||||
logger.warning(RUNNER_NO_FINAL_RESPONSE_LOG)
|
||||
return self.merged_chain, self.has_error
|
||||
|
||||
logger.warning(RUNNER_NO_RESULT_LOG)
|
||||
fallback_error_chain = MessageChain().message(
|
||||
RUNNER_NO_RESULT_FALLBACK_MESSAGE,
|
||||
)
|
||||
return fallback_error_chain.chain or [], True
|
||||
|
||||
final_chain = final_resp.result_chain.chain or []
|
||||
is_runner_error = self.has_error or final_resp.role == "err"
|
||||
return final_chain, is_runner_error
|
||||
|
||||
|
||||
def _start_stream_watchdog(
|
||||
*,
|
||||
timeout_sec: int,
|
||||
is_stream_consumed: Callable[[], bool],
|
||||
close_runner_once: Callable[[], Awaitable[None]],
|
||||
) -> asyncio.Task[None]:
|
||||
async def _watchdog() -> None:
|
||||
try:
|
||||
await asyncio.sleep(timeout_sec)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
if not is_stream_consumed():
|
||||
logger.warning(
|
||||
"Third-party runner stream was never consumed in %ss; closing runner to avoid resource leak.",
|
||||
timeout_sec,
|
||||
)
|
||||
try:
|
||||
await close_runner_once()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Exception while closing third-party runner from stream watchdog.",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return asyncio.create_task(_watchdog())
|
||||
|
||||
|
||||
async def _close_runner_if_supported(runner: "BaseAgentRunner") -> None:
|
||||
close_callable = getattr(runner, "close", None)
|
||||
if not callable(close_callable):
|
||||
return
|
||||
|
||||
try:
|
||||
close_result = close_callable()
|
||||
if inspect.isawaitable(close_result):
|
||||
await close_result
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to close third-party runner cleanly: {e}")
|
||||
|
||||
|
||||
class ThirdPartyAgentSubStage(Stage):
|
||||
@@ -76,6 +175,116 @@ class ThirdPartyAgentSubStage(Stage):
|
||||
self.unsupported_streaming_strategy: str = settings[
|
||||
"unsupported_streaming_strategy"
|
||||
]
|
||||
self.stream_consumption_close_timeout_sec: int = coerce_int_config(
|
||||
settings.get(
|
||||
"third_party_stream_consumption_close_timeout_sec",
|
||||
STREAM_CONSUMPTION_CLOSE_TIMEOUT_SEC,
|
||||
),
|
||||
default=STREAM_CONSUMPTION_CLOSE_TIMEOUT_SEC,
|
||||
min_value=1,
|
||||
field_name="third_party_stream_consumption_close_timeout_sec",
|
||||
source="Third-party runner config",
|
||||
)
|
||||
|
||||
async def _resolve_persona_custom_error_message(
|
||||
self, event: AstrMessageEvent
|
||||
) -> str | None:
|
||||
try:
|
||||
conversation_persona_id = await resolve_event_conversation_persona_id(
|
||||
event,
|
||||
self.ctx.plugin_manager.context.conversation_manager,
|
||||
)
|
||||
return await resolve_persona_custom_error_message(
|
||||
event=event,
|
||||
persona_manager=self.ctx.plugin_manager.context.persona_manager,
|
||||
provider_settings=self.conf["provider_settings"],
|
||||
conversation_persona_id=conversation_persona_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to resolve persona custom error message: %s", e)
|
||||
return None
|
||||
|
||||
async def _handle_streaming_response(
|
||||
self,
|
||||
*,
|
||||
runner: "BaseAgentRunner",
|
||||
event: AstrMessageEvent,
|
||||
custom_error_message: str | None,
|
||||
close_runner_once: Callable[[], Awaitable[None]],
|
||||
mark_stream_consumed: Callable[[], None],
|
||||
) -> AsyncGenerator[None, None]:
|
||||
aggregator = _RunnerResultAggregator()
|
||||
|
||||
async def _stream_runner_chain() -> AsyncGenerator[MessageChain, None]:
|
||||
mark_stream_consumed()
|
||||
try:
|
||||
async for chain, is_error in run_third_party_agent(
|
||||
runner,
|
||||
stream_to_general=False,
|
||||
custom_error_message=custom_error_message,
|
||||
):
|
||||
aggregator.add_chunk(chain, is_error)
|
||||
if is_error:
|
||||
event.set_extra(THIRD_PARTY_RUNNER_ERROR_EXTRA_KEY, True)
|
||||
yield chain
|
||||
finally:
|
||||
# Streaming runner cleanup must happen after consumer
|
||||
# finishes iterating to avoid tearing down active streams.
|
||||
await close_runner_once()
|
||||
|
||||
event.set_result(
|
||||
MessageEventResult()
|
||||
.set_result_content_type(ResultContentType.STREAMING_RESULT)
|
||||
.set_async_stream(_stream_runner_chain()),
|
||||
)
|
||||
yield
|
||||
|
||||
if runner.done():
|
||||
final_chain, is_runner_error = aggregator.finalize(
|
||||
runner.get_final_llm_resp()
|
||||
)
|
||||
event.set_extra(THIRD_PARTY_RUNNER_ERROR_EXTRA_KEY, is_runner_error)
|
||||
event.set_result(
|
||||
MessageEventResult(
|
||||
chain=final_chain,
|
||||
result_content_type=ResultContentType.STREAMING_FINISH,
|
||||
),
|
||||
)
|
||||
|
||||
async def _handle_non_streaming_response(
|
||||
self,
|
||||
*,
|
||||
runner: "BaseAgentRunner",
|
||||
event: AstrMessageEvent,
|
||||
stream_to_general: bool,
|
||||
custom_error_message: str | None,
|
||||
) -> AsyncGenerator[None, None]:
|
||||
aggregator = _RunnerResultAggregator()
|
||||
async for chain, is_error in run_third_party_agent(
|
||||
runner,
|
||||
stream_to_general=stream_to_general,
|
||||
custom_error_message=custom_error_message,
|
||||
):
|
||||
aggregator.add_chunk(chain, is_error)
|
||||
if is_error:
|
||||
event.set_extra(THIRD_PARTY_RUNNER_ERROR_EXTRA_KEY, True)
|
||||
yield
|
||||
|
||||
final_chain, is_runner_error = aggregator.finalize(runner.get_final_llm_resp())
|
||||
event.set_extra(THIRD_PARTY_RUNNER_ERROR_EXTRA_KEY, is_runner_error)
|
||||
result_content_type = (
|
||||
ResultContentType.AGENT_RUNNER_ERROR
|
||||
if is_runner_error
|
||||
else ResultContentType.LLM_RESULT
|
||||
)
|
||||
event.set_result(
|
||||
MessageEventResult(
|
||||
chain=final_chain,
|
||||
result_content_type=result_content_type,
|
||||
),
|
||||
)
|
||||
# Second yield keeps scheduler progress consistent after final result update.
|
||||
yield
|
||||
|
||||
async def process(
|
||||
self, event: AstrMessageEvent, provider_wake_prefix: str
|
||||
@@ -112,6 +321,9 @@ class ThirdPartyAgentSubStage(Stage):
|
||||
if not req.prompt and not req.image_urls:
|
||||
return
|
||||
|
||||
custom_error_message = await self._resolve_persona_custom_error_message(event)
|
||||
set_persona_custom_error_message_on_event(event, custom_error_message)
|
||||
|
||||
# call event hook
|
||||
if await call_event_hook(event, EventType.OnLLMRequestEvent, req):
|
||||
return
|
||||
@@ -122,6 +334,8 @@ class ThirdPartyAgentSubStage(Stage):
|
||||
runner = CozeAgentRunner[AstrAgentContext]()
|
||||
elif self.runner_type == "dashscope":
|
||||
runner = DashscopeAgentRunner[AstrAgentContext]()
|
||||
elif self.runner_type == DEERFLOW_PROVIDER_TYPE:
|
||||
runner = DeerFlowAgentRunner[AstrAgentContext]()
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unsupported third party agent runner type: {self.runner_type}",
|
||||
@@ -140,61 +354,68 @@ class ThirdPartyAgentSubStage(Stage):
|
||||
self.unsupported_streaming_strategy == "turn_off"
|
||||
and not event.platform_meta.support_streaming_message
|
||||
)
|
||||
streaming_used = streaming_response and not stream_to_general
|
||||
|
||||
await runner.reset(
|
||||
request=req,
|
||||
run_context=AgentContextWrapper(
|
||||
context=astr_agent_ctx,
|
||||
tool_call_timeout=60,
|
||||
),
|
||||
agent_hooks=MAIN_AGENT_HOOKS,
|
||||
provider_config=self.prov_cfg,
|
||||
streaming=streaming_response,
|
||||
)
|
||||
runner_closed = False
|
||||
stream_consumed = False
|
||||
stream_watchdog_task: asyncio.Task[None] | None = None
|
||||
|
||||
if streaming_response and not stream_to_general:
|
||||
# 流式响应
|
||||
event.set_result(
|
||||
MessageEventResult()
|
||||
.set_result_content_type(ResultContentType.STREAMING_RESULT)
|
||||
.set_async_stream(
|
||||
run_third_party_agent(
|
||||
runner,
|
||||
stream_to_general=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
yield
|
||||
if runner.done():
|
||||
final_resp = runner.get_final_llm_resp()
|
||||
if final_resp and final_resp.result_chain:
|
||||
event.set_result(
|
||||
MessageEventResult(
|
||||
chain=final_resp.result_chain.chain or [],
|
||||
result_content_type=ResultContentType.STREAMING_FINISH,
|
||||
),
|
||||
)
|
||||
else:
|
||||
# 非流式响应或转换为普通响应
|
||||
async for _ in run_third_party_agent(
|
||||
runner,
|
||||
stream_to_general=stream_to_general,
|
||||
):
|
||||
yield
|
||||
|
||||
final_resp = runner.get_final_llm_resp()
|
||||
|
||||
if not final_resp or not final_resp.result_chain:
|
||||
logger.warning("Agent Runner 未返回最终结果。")
|
||||
async def close_runner_once() -> None:
|
||||
nonlocal runner_closed
|
||||
if runner_closed:
|
||||
return
|
||||
runner_closed = True
|
||||
await _close_runner_if_supported(runner)
|
||||
|
||||
event.set_result(
|
||||
MessageEventResult(
|
||||
chain=final_resp.result_chain.chain or [],
|
||||
result_content_type=ResultContentType.LLM_RESULT,
|
||||
def mark_stream_consumed() -> None:
|
||||
nonlocal stream_consumed
|
||||
stream_consumed = True
|
||||
if stream_watchdog_task and not stream_watchdog_task.done():
|
||||
stream_watchdog_task.cancel()
|
||||
|
||||
try:
|
||||
await runner.reset(
|
||||
request=req,
|
||||
run_context=AgentContextWrapper(
|
||||
context=astr_agent_ctx,
|
||||
tool_call_timeout=60,
|
||||
),
|
||||
agent_hooks=MAIN_AGENT_HOOKS,
|
||||
provider_config=self.prov_cfg,
|
||||
streaming=streaming_response,
|
||||
)
|
||||
yield
|
||||
|
||||
if streaming_used:
|
||||
stream_watchdog_task = _start_stream_watchdog(
|
||||
timeout_sec=self.stream_consumption_close_timeout_sec,
|
||||
is_stream_consumed=lambda: stream_consumed,
|
||||
close_runner_once=close_runner_once,
|
||||
)
|
||||
async for _ in self._handle_streaming_response(
|
||||
runner=runner,
|
||||
event=event,
|
||||
custom_error_message=custom_error_message,
|
||||
close_runner_once=close_runner_once,
|
||||
mark_stream_consumed=mark_stream_consumed,
|
||||
):
|
||||
yield
|
||||
else:
|
||||
async for _ in self._handle_non_streaming_response(
|
||||
runner=runner,
|
||||
event=event,
|
||||
stream_to_general=stream_to_general,
|
||||
custom_error_message=custom_error_message,
|
||||
):
|
||||
yield
|
||||
finally:
|
||||
if (
|
||||
stream_watchdog_task
|
||||
and not stream_watchdog_task.done()
|
||||
and (stream_consumed or runner_closed)
|
||||
):
|
||||
stream_watchdog_task.cancel()
|
||||
if not streaming_used:
|
||||
await close_runner_once()
|
||||
|
||||
asyncio.create_task(
|
||||
Metric.upload(
|
||||
|
||||
@@ -28,7 +28,7 @@ class RespondStage(Stage):
|
||||
Comp.At: lambda comp: bool(comp.qq) or bool(comp.name), # @
|
||||
Comp.Image: lambda comp: bool(comp.file), # 图片
|
||||
Comp.Reply: lambda comp: bool(comp.id) and comp.sender_id is not None, # 回复
|
||||
Comp.Poke: lambda comp: comp.id != 0 and comp.qq != 0, # 戳一戳
|
||||
Comp.Poke: lambda comp: comp.target_id() is not None, # 戳一戳
|
||||
Comp.Node: lambda comp: bool(comp.content), # 转发节点
|
||||
Comp.Nodes: lambda comp: bool(comp.nodes), # 多个转发节点
|
||||
Comp.File: lambda comp: bool(comp.file_ or comp.url),
|
||||
@@ -135,7 +135,7 @@ class RespondStage(Stage):
|
||||
|
||||
if (result := event.get_result()) is None:
|
||||
return False
|
||||
if self.only_llm_result and not result.is_llm_result():
|
||||
if self.only_llm_result and not result.is_model_result():
|
||||
return False
|
||||
|
||||
if event.get_platform_name() in [
|
||||
|
||||
@@ -5,7 +5,7 @@ import traceback
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from astrbot.core import file_token_service, html_renderer, logger
|
||||
from astrbot.core.message.components import At, File, Image, Node, Plain, Record, Reply
|
||||
from astrbot.core.message.components import At, Image, Node, Plain, Record, Reply
|
||||
from astrbot.core.message.message_event_result import ResultContentType
|
||||
from astrbot.core.pipeline.content_safety_check.stage import ContentSafetyCheckStage
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
@@ -209,7 +209,7 @@ class ResultDecorateStage(Stage):
|
||||
"dingtalk",
|
||||
]:
|
||||
if (
|
||||
self.only_llm_result and result.is_llm_result()
|
||||
self.only_llm_result and result.is_model_result()
|
||||
) or not self.only_llm_result:
|
||||
new_chain = []
|
||||
for comp in result.chain:
|
||||
@@ -383,8 +383,11 @@ class ResultDecorateStage(Stage):
|
||||
)
|
||||
result.chain = [node]
|
||||
|
||||
has_plain = any(isinstance(item, Plain) for item in result.chain)
|
||||
if has_plain:
|
||||
# at 回复 / 引用回复仅适用于纯文本或图文消息
|
||||
can_decorate = all(
|
||||
isinstance(item, (Plain, Image)) for item in result.chain
|
||||
)
|
||||
if can_decorate:
|
||||
# at 回复
|
||||
if (
|
||||
self.reply_with_mention
|
||||
@@ -399,5 +402,4 @@ class ResultDecorateStage(Stage):
|
||||
|
||||
# 引用回复
|
||||
if self.reply_with_quote:
|
||||
if not any(isinstance(item, File) for item in result.chain):
|
||||
result.chain.insert(0, Reply(id=event.message_obj.message_id))
|
||||
result.chain.insert(0, Reply(id=event.message_obj.message_id))
|
||||
|
||||
@@ -180,6 +180,10 @@ class PlatformManager:
|
||||
from .sources.line.line_adapter import (
|
||||
LinePlatformAdapter, # noqa: F401
|
||||
)
|
||||
case "kook":
|
||||
from .sources.kook.kook_adapter import (
|
||||
KookPlatformAdapter, # noqa: F401
|
||||
)
|
||||
except (ImportError, ModuleNotFoundError) as e:
|
||||
logger.error(
|
||||
f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->平台日志->安装Pip库 中安装依赖库。",
|
||||
|
||||
@@ -4,8 +4,10 @@ from collections.abc import AsyncGenerator
|
||||
|
||||
from aiocqhttp import CQHttp, Event
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.message_components import (
|
||||
At,
|
||||
BaseMessageComponent,
|
||||
File,
|
||||
Image,
|
||||
@@ -70,11 +72,19 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
|
||||
"""解析成 OneBot json 格式"""
|
||||
ret = []
|
||||
for segment in message_chain.chain:
|
||||
if isinstance(segment, Plain):
|
||||
if isinstance(segment, At):
|
||||
# At 组件后插入一个空格,避免与后续文本粘连
|
||||
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
|
||||
ret.append(d)
|
||||
ret.append({"type": "text", "data": {"text": " "}})
|
||||
elif isinstance(segment, Plain):
|
||||
if not segment.text.strip():
|
||||
continue
|
||||
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
|
||||
ret.append(d)
|
||||
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
|
||||
ret.append(d)
|
||||
else:
|
||||
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
|
||||
ret.append(d)
|
||||
return ret
|
||||
|
||||
@classmethod
|
||||
@@ -147,8 +157,29 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
|
||||
payload["user_id"] = session_id
|
||||
await bot.call_action("send_private_forward_msg", **payload)
|
||||
elif isinstance(seg, File):
|
||||
d = await cls._from_segment_to_dict(seg)
|
||||
await cls._dispatch_send(bot, event, is_group, session_id, [d])
|
||||
# 使用 OneBot V11 文件 API 发送文件
|
||||
file_path = seg.file_ or seg.url
|
||||
if not file_path:
|
||||
logger.warning("无法发送文件:文件路径或 URL 为空。")
|
||||
continue
|
||||
|
||||
file_name = seg.name or "file"
|
||||
session_id_int = (
|
||||
int(session_id) if session_id and session_id.isdigit() else None
|
||||
)
|
||||
|
||||
if session_id_int is None:
|
||||
logger.warning(f"无法发送文件:无效的 session_id: {session_id}")
|
||||
continue
|
||||
|
||||
if is_group:
|
||||
await bot.send_group_file(
|
||||
group_id=session_id_int, file=file_path, name=file_name
|
||||
)
|
||||
else:
|
||||
await bot.send_private_file(
|
||||
user_id=session_id_int, file=file_path, name=file_name
|
||||
)
|
||||
else:
|
||||
messages = await cls._parse_onebot_json(MessageChain([seg]))
|
||||
if not messages:
|
||||
|
||||
@@ -191,7 +191,7 @@ class AiocqhttpAdapter(Platform):
|
||||
|
||||
if "sub_type" in event:
|
||||
if event["sub_type"] == "poke" and "target_id" in event:
|
||||
abm.message.append(Poke(qq=str(event["target_id"]), type="poke"))
|
||||
abm.message.append(Poke(id=str(event["target_id"])))
|
||||
|
||||
return abm
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from dingtalk_stream import AckMessage
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.api.event import MessageChain
|
||||
from astrbot.api.message_components import At, Image, Plain, Record, Video
|
||||
from astrbot.api.message_components import At, File, Image, Plain, Record, Video
|
||||
from astrbot.api.platform import (
|
||||
AstrBotMessage,
|
||||
MessageMember,
|
||||
@@ -178,29 +178,110 @@ class DingtalkPlatformAdapter(Platform):
|
||||
abm.session_id = abm.sender.user_id
|
||||
|
||||
message_type: str = cast(str, message.message_type)
|
||||
robot_code = cast(str, message.robot_code or "")
|
||||
raw_content = cast(dict, message.extensions.get("content") or {})
|
||||
if not isinstance(raw_content, dict):
|
||||
raw_content = {}
|
||||
match message_type:
|
||||
case "text":
|
||||
abm.message_str = message.text.content.strip()
|
||||
abm.message.append(Plain(abm.message_str))
|
||||
case "picture":
|
||||
if not robot_code:
|
||||
logger.error("钉钉图片消息解析失败: 回调中缺少 robotCode")
|
||||
await self._remember_sender_binding(message, abm)
|
||||
return abm
|
||||
image_content = cast(
|
||||
dingtalk_stream.ImageContent | None,
|
||||
message.image_content,
|
||||
)
|
||||
download_code = cast(
|
||||
str, (image_content.download_code if image_content else "") or ""
|
||||
)
|
||||
if not download_code:
|
||||
logger.warning("钉钉图片消息缺少 downloadCode,已跳过")
|
||||
else:
|
||||
f_path = await self.download_ding_file(
|
||||
download_code,
|
||||
robot_code,
|
||||
"jpg",
|
||||
)
|
||||
if f_path:
|
||||
abm.message.append(Image.fromFileSystem(f_path))
|
||||
else:
|
||||
logger.warning("钉钉图片消息下载失败,无法解析为图片")
|
||||
case "richText":
|
||||
rtc: dingtalk_stream.RichTextContent = cast(
|
||||
dingtalk_stream.RichTextContent, message.rich_text_content
|
||||
)
|
||||
contents: list[dict] = cast(list[dict], rtc.rich_text_list)
|
||||
plain_parts: list[str] = []
|
||||
for content in contents:
|
||||
plains = ""
|
||||
if "text" in content:
|
||||
plains += content["text"]
|
||||
abm.message.append(Plain(plains))
|
||||
plain_text = cast(str, content.get("text") or "")
|
||||
if plain_text:
|
||||
plain_parts.append(plain_text)
|
||||
abm.message.append(Plain(plain_text))
|
||||
elif "type" in content and content["type"] == "picture":
|
||||
download_code = cast(str, content.get("downloadCode") or "")
|
||||
if not download_code:
|
||||
logger.warning(
|
||||
"钉钉富文本图片消息缺少 downloadCode,已跳过"
|
||||
)
|
||||
continue
|
||||
if not robot_code:
|
||||
logger.error(
|
||||
"钉钉富文本图片消息解析失败: 回调中缺少 robotCode"
|
||||
)
|
||||
continue
|
||||
f_path = await self.download_ding_file(
|
||||
content["downloadCode"],
|
||||
cast(str, message.robot_code),
|
||||
download_code,
|
||||
robot_code,
|
||||
"jpg",
|
||||
)
|
||||
abm.message.append(Image.fromFileSystem(f_path))
|
||||
case "audio":
|
||||
pass
|
||||
if f_path:
|
||||
abm.message.append(Image.fromFileSystem(f_path))
|
||||
abm.message_str = "".join(plain_parts).strip()
|
||||
case "audio" | "voice":
|
||||
download_code = cast(str, raw_content.get("downloadCode") or "")
|
||||
if not download_code:
|
||||
logger.warning("钉钉语音消息缺少 downloadCode,已跳过")
|
||||
elif not robot_code:
|
||||
logger.error("钉钉语音消息解析失败: 回调中缺少 robotCode")
|
||||
else:
|
||||
voice_ext = cast(str, raw_content.get("fileExtension") or "")
|
||||
if not voice_ext:
|
||||
voice_ext = "amr"
|
||||
voice_ext = voice_ext.lstrip(".")
|
||||
f_path = await self.download_ding_file(
|
||||
download_code,
|
||||
robot_code,
|
||||
voice_ext,
|
||||
)
|
||||
if f_path:
|
||||
abm.message.append(Record.fromFileSystem(f_path))
|
||||
case "file":
|
||||
download_code = cast(str, raw_content.get("downloadCode") or "")
|
||||
if not download_code:
|
||||
logger.warning("钉钉文件消息缺少 downloadCode,已跳过")
|
||||
elif not robot_code:
|
||||
logger.error("钉钉文件消息解析失败: 回调中缺少 robotCode")
|
||||
else:
|
||||
file_name = cast(str, raw_content.get("fileName") or "")
|
||||
file_ext = Path(file_name).suffix.lstrip(".") if file_name else ""
|
||||
if not file_ext:
|
||||
file_ext = cast(str, raw_content.get("fileExtension") or "")
|
||||
if not file_ext:
|
||||
file_ext = "file"
|
||||
f_path = await self.download_ding_file(
|
||||
download_code,
|
||||
robot_code,
|
||||
file_ext,
|
||||
)
|
||||
if f_path:
|
||||
if not file_name:
|
||||
file_name = Path(f_path).name
|
||||
abm.message.append(File(name=file_name, file=f_path))
|
||||
|
||||
await self._remember_sender_binding(message, abm)
|
||||
return abm # 别忘了返回转换后的消息对象
|
||||
@@ -270,13 +351,23 @@ class DingtalkPlatformAdapter(Platform):
|
||||
)
|
||||
return ""
|
||||
resp_data = await resp.json()
|
||||
download_url = resp_data["data"]["downloadUrl"]
|
||||
download_url = cast(
|
||||
str,
|
||||
(
|
||||
resp_data.get("downloadUrl")
|
||||
or resp_data.get("data", {}).get("downloadUrl")
|
||||
or ""
|
||||
),
|
||||
)
|
||||
if not download_url:
|
||||
logger.error(f"下载钉钉文件失败: 未找到 downloadUrl, 响应: {resp_data}")
|
||||
return ""
|
||||
await download_file(download_url, str(f_path))
|
||||
return str(f_path)
|
||||
|
||||
async def get_access_token(self) -> str:
|
||||
try:
|
||||
access_token = await asyncio.get_event_loop().run_in_executor(
|
||||
access_token = await asyncio.get_running_loop().run_in_executor(
|
||||
None,
|
||||
self.client_.get_access_token,
|
||||
)
|
||||
@@ -541,6 +632,28 @@ class DingtalkPlatformAdapter(Platform):
|
||||
self._safe_remove_file(cover_path)
|
||||
if converted_video:
|
||||
self._safe_remove_file(video_path)
|
||||
elif isinstance(segment, File):
|
||||
try:
|
||||
file_path = await segment.get_file()
|
||||
if not file_path:
|
||||
logger.warning("钉钉文件发送失败: 无法解析文件路径")
|
||||
continue
|
||||
media_id = await self.upload_media(file_path, "file")
|
||||
if not media_id:
|
||||
continue
|
||||
file_name = segment.name or Path(file_path).name
|
||||
file_type = Path(file_name).suffix.lstrip(".")
|
||||
await send_message(
|
||||
msg_key="sampleFile",
|
||||
msg_param={
|
||||
"mediaId": media_id,
|
||||
"fileName": file_name,
|
||||
"fileType": file_type,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"钉钉文件发送失败: {e}")
|
||||
continue
|
||||
|
||||
async def send_message_chain_to_group(
|
||||
self,
|
||||
@@ -647,7 +760,7 @@ class DingtalkPlatformAdapter(Platform):
|
||||
return
|
||||
logger.error(f"钉钉机器人启动失败: {e}")
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, start_client, loop)
|
||||
|
||||
async def terminate(self) -> None:
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.api.event import MessageChain
|
||||
from astrbot.api.message_components import At, AtAll, Image, Plain
|
||||
from astrbot.api.platform import (
|
||||
AstrBotMessage,
|
||||
MessageMember,
|
||||
MessageType,
|
||||
Platform,
|
||||
PlatformMetadata,
|
||||
register_platform_adapter,
|
||||
)
|
||||
from astrbot.core.message.components import File, Record, Video
|
||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
|
||||
from .kook_client import KookClient
|
||||
from .kook_config import KookConfig
|
||||
from .kook_event import KookEvent
|
||||
from .kook_types import (
|
||||
ContainerModule,
|
||||
FileModule,
|
||||
HeaderModule,
|
||||
ImageGroupModule,
|
||||
KmarkdownElement,
|
||||
KookCardMessageContainer,
|
||||
KookChannelType,
|
||||
KookMessageEventData,
|
||||
KookMessageType,
|
||||
KookModuleType,
|
||||
PlainTextElement,
|
||||
SectionModule,
|
||||
)
|
||||
|
||||
KOOK_AT_SELECTOR_REGEX = re.compile(r"\(met\)([^()]+)\(met\)")
|
||||
|
||||
|
||||
@register_platform_adapter(
|
||||
"kook",
|
||||
"KOOK 适配器",
|
||||
)
|
||||
class KookPlatformAdapter(Platform):
|
||||
def __init__(
|
||||
self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue
|
||||
) -> None:
|
||||
super().__init__(platform_config, event_queue)
|
||||
self.kook_config = KookConfig.from_dict(platform_config)
|
||||
logger.debug(f"[KOOK] 配置: {self.kook_config.pretty_jsons()}")
|
||||
self.settings = platform_settings
|
||||
self.client = KookClient(self.kook_config, self._on_received)
|
||||
self._reconnect_task = None
|
||||
self.running = False
|
||||
self._main_task = None
|
||||
|
||||
async def send_by_session(
|
||||
self, session: MessageSesion, message_chain: MessageChain
|
||||
):
|
||||
inner_message = AstrBotMessage()
|
||||
inner_message.session_id = session.session_id
|
||||
inner_message.type = session.message_type
|
||||
message_event = KookEvent(
|
||||
message_str=message_chain.get_plain_text(),
|
||||
message_obj=inner_message,
|
||||
platform_meta=self.meta(),
|
||||
session_id=session.session_id,
|
||||
client=self.client,
|
||||
)
|
||||
await message_event.send(message_chain)
|
||||
|
||||
def meta(self) -> PlatformMetadata:
|
||||
return PlatformMetadata(
|
||||
name="kook", description="KOOK 适配器", id=self.kook_config.id
|
||||
)
|
||||
|
||||
def _should_ignore_event_by_bot_nickname(self, author_id: str) -> bool:
|
||||
return self.client.bot_id == author_id
|
||||
|
||||
async def _on_received(self, event: KookMessageEventData):
|
||||
logger.debug(
|
||||
f'[KOOK] 收到来自"{event.channel_type.name}"渠道的消息, 消息类型为: {event.type.name}({event.type.value})'
|
||||
)
|
||||
event_type = event.type
|
||||
if event_type in (KookMessageType.KMARKDOWN, KookMessageType.CARD):
|
||||
if self._should_ignore_event_by_bot_nickname(event.author_id):
|
||||
logger.debug("[KOOK] 收到来自机器人自身的消息, 忽略此消息")
|
||||
return
|
||||
try:
|
||||
abm = await self.convert_message(event)
|
||||
await self.handle_msg(abm)
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 消息处理异常: {e}")
|
||||
elif event_type == KookMessageType.SYSTEM:
|
||||
logger.debug(f'[KOOK] 消息为系统通知, 通知类型为: "{event.extra.type}"')
|
||||
logger.debug(f"[KOOK] 原始消息数据: {event.to_json()}")
|
||||
|
||||
async def run(self):
|
||||
"""主运行循环"""
|
||||
self.running = True
|
||||
logger.info("[KOOK] 启动KOOK适配器")
|
||||
|
||||
# 启动主循环
|
||||
self._main_task = asyncio.create_task(self._main_loop())
|
||||
|
||||
try:
|
||||
await self._main_task
|
||||
except asyncio.CancelledError:
|
||||
logger.info("[KOOK] 适配器被取消")
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 适配器运行异常: {e}")
|
||||
finally:
|
||||
self.running = False
|
||||
await self._cleanup()
|
||||
|
||||
async def _main_loop(self):
|
||||
"""主循环,处理连接和重连"""
|
||||
consecutive_failures = 0
|
||||
max_consecutive_failures = self.kook_config.max_consecutive_failures
|
||||
max_retry_delay = self.kook_config.max_retry_delay
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
logger.info("[KOOK] 尝试连接KOOK服务器...")
|
||||
|
||||
# 尝试连接
|
||||
success = await self.client.connect()
|
||||
|
||||
if success:
|
||||
logger.info("[KOOK] 连接成功,开始监听消息")
|
||||
consecutive_failures = 0 # 重置失败计数
|
||||
|
||||
# 等待连接结束(可能是正常关闭或异常)
|
||||
while self.client.running and self.running:
|
||||
try:
|
||||
# 等待 client 内部触发 _stop_event,或者超时 1 秒后重试
|
||||
# 使用 wait_for 配合 timeout 是为了防止极端情况下 self.running 变化没被察觉
|
||||
await asyncio.wait_for(
|
||||
self.client.wait_until_closed(), timeout=1.0
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
# 正常超时,继续下一轮 while 检查
|
||||
continue
|
||||
|
||||
if self.running:
|
||||
logger.warning("[KOOK] 连接断开,准备重连")
|
||||
|
||||
else:
|
||||
consecutive_failures += 1
|
||||
logger.error(
|
||||
f"[KOOK] 连接失败,连续失败次数: {consecutive_failures}"
|
||||
)
|
||||
|
||||
if consecutive_failures >= max_consecutive_failures:
|
||||
logger.error("[KOOK] 连续失败次数过多,停止重连")
|
||||
break
|
||||
|
||||
# 等待一段时间后重试
|
||||
wait_time = min(
|
||||
2**consecutive_failures, max_retry_delay
|
||||
) # 指数退避
|
||||
logger.info(f"[KOOK] 等待 {wait_time} 秒后重试...")
|
||||
await asyncio.sleep(wait_time)
|
||||
|
||||
except Exception as e:
|
||||
consecutive_failures += 1
|
||||
logger.error(f"[KOOK] 主循环异常: {e}")
|
||||
|
||||
if consecutive_failures >= max_consecutive_failures:
|
||||
logger.error("[KOOK] 连续异常次数过多,停止重连")
|
||||
break
|
||||
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def _cleanup(self):
|
||||
"""清理资源"""
|
||||
logger.info("[KOOK] 开始清理资源")
|
||||
|
||||
if self.client:
|
||||
try:
|
||||
await self.client.close()
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 关闭客户端异常: {e}")
|
||||
|
||||
if self._main_task and not self._main_task.done():
|
||||
self._main_task.cancel()
|
||||
try:
|
||||
await self._main_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
logger.info("[KOOK] 资源清理完成")
|
||||
|
||||
def _parse_kmarkdown_text_message(
|
||||
self, data: KookMessageEventData, self_id: str
|
||||
) -> tuple[list, str]:
|
||||
kmarkdown = data.extra.kmarkdown
|
||||
content = data.content or ""
|
||||
if kmarkdown is None:
|
||||
logger.error(
|
||||
f'[KOOK] 无法转换"{KookMessageType.KMARKDOWN.name}"消息, 消息中找不到kmarkdown字段'
|
||||
)
|
||||
logger.error(f"[KOOK] 原始消息内容: {data.to_json()}")
|
||||
return [], ""
|
||||
|
||||
raw_content = kmarkdown.raw_content or content
|
||||
if not isinstance(content, str):
|
||||
content = str(content)
|
||||
if not isinstance(raw_content, str):
|
||||
raw_content = str(raw_content)
|
||||
|
||||
# TODO 后面的pydantic类型替换,以后再来探索吧 :(
|
||||
mention_name_map: dict[str, str] = {}
|
||||
mention_part = kmarkdown.mention_part
|
||||
if isinstance(mention_part, list):
|
||||
for item in mention_part:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
mention_id = item.get("id")
|
||||
if mention_id is None:
|
||||
continue
|
||||
mention_name_map[str(mention_id)] = str(item.get("username", ""))
|
||||
|
||||
components = []
|
||||
cursor = 0
|
||||
for match in KOOK_AT_SELECTOR_REGEX.finditer(content):
|
||||
if match.start() > cursor:
|
||||
plain_text = content[cursor : match.start()]
|
||||
if plain_text:
|
||||
components.append(Plain(text=plain_text))
|
||||
|
||||
mention_target = match.group(1).strip()
|
||||
if mention_target == "all":
|
||||
components.append(AtAll())
|
||||
elif mention_target:
|
||||
components.append(
|
||||
At(
|
||||
qq=mention_target,
|
||||
name=mention_name_map.get(mention_target, ""),
|
||||
)
|
||||
)
|
||||
cursor = match.end()
|
||||
|
||||
if cursor < len(content):
|
||||
tail_text = content[cursor:]
|
||||
if tail_text:
|
||||
components.append(Plain(text=tail_text))
|
||||
|
||||
message_str = raw_content
|
||||
if components:
|
||||
for comp in components:
|
||||
if isinstance(comp, Plain):
|
||||
if not comp.text.strip():
|
||||
continue
|
||||
break
|
||||
if isinstance(comp, At):
|
||||
if str(comp.qq) == str(self_id):
|
||||
message_str = re.sub(
|
||||
r"^@[^\s]+(\s*-\s*[^\s]+)?\s*",
|
||||
"",
|
||||
message_str,
|
||||
count=1,
|
||||
).strip()
|
||||
break
|
||||
if not components:
|
||||
if message_str:
|
||||
components = [Plain(text=message_str)]
|
||||
else:
|
||||
components = []
|
||||
|
||||
return components, message_str
|
||||
|
||||
def _parse_card_message(self, data: KookMessageEventData) -> tuple[list, str]:
|
||||
content = data.content
|
||||
if not isinstance(content, str):
|
||||
content = str(content)
|
||||
|
||||
card_list = KookCardMessageContainer.from_dict(json.loads(content))
|
||||
|
||||
text_parts: list[str] = []
|
||||
images: list[str] = []
|
||||
files: list[tuple[KookModuleType, str, str]] = []
|
||||
|
||||
for card in card_list:
|
||||
for module in card.modules:
|
||||
match module:
|
||||
case SectionModule():
|
||||
if content := self._handle_section_text(module):
|
||||
text_parts.append(content)
|
||||
|
||||
case ContainerModule() | ImageGroupModule():
|
||||
urls = self._handle_image_group(module)
|
||||
images.extend(urls)
|
||||
text_parts.append(" [image]" * len(urls))
|
||||
|
||||
case HeaderModule():
|
||||
text_parts.append(module.text.content)
|
||||
|
||||
case FileModule():
|
||||
files.append((module.type, module.title, module.src))
|
||||
text_parts.append(f" [{module.type.value}]")
|
||||
|
||||
case _:
|
||||
logger.debug(f"[KOOK] 跳过或未处理模块: {module.type}")
|
||||
|
||||
text = "".join(text_parts)
|
||||
message = []
|
||||
|
||||
if text:
|
||||
for search in KOOK_AT_SELECTOR_REGEX.finditer(text):
|
||||
search_text = search.group(1).strip()
|
||||
if search_text == "all":
|
||||
message.append(AtAll())
|
||||
continue
|
||||
message.append(At(qq=search_text))
|
||||
text = text.replace(f"(met){search_text}(met)", "")
|
||||
|
||||
message.append(Plain(text=text))
|
||||
|
||||
for img_url in images:
|
||||
message.append(Image(file=img_url))
|
||||
for file in files:
|
||||
file_type = file[0]
|
||||
file_name = file[1]
|
||||
file_url = file[2]
|
||||
if file_type == KookModuleType.FILE:
|
||||
message.append(File(name=file_name, file=file_url))
|
||||
elif file_type == KookModuleType.VIDEO:
|
||||
message.append(Video(file=file_url))
|
||||
elif file_type == KookModuleType.AUDIO:
|
||||
message.append(Record(file=file_url))
|
||||
else:
|
||||
logger.warning(f"[KOOK] 跳过未知文件类型: {file_type.name}")
|
||||
|
||||
return message, text
|
||||
|
||||
def _handle_section_text(self, module: SectionModule) -> str:
|
||||
"""专门处理 Section 里的文本提取"""
|
||||
if isinstance(module.text, (KmarkdownElement, PlainTextElement)):
|
||||
return module.text.content or ""
|
||||
return ""
|
||||
|
||||
def _handle_image_group(
|
||||
self, module: ContainerModule | ImageGroupModule
|
||||
) -> list[str]:
|
||||
"""专门处理图片组/容器里的合法 URL 提取"""
|
||||
valid_urls = []
|
||||
for el in module.elements:
|
||||
image_src = el.src
|
||||
if not el.src.startswith(("http://", "https://")):
|
||||
logger.warning(f"[KOOK] 屏蔽非http图片url: {image_src}")
|
||||
continue
|
||||
valid_urls.append(el.src)
|
||||
return valid_urls
|
||||
|
||||
async def convert_message(self, data: KookMessageEventData) -> AstrBotMessage:
|
||||
abm = AstrBotMessage()
|
||||
abm.raw_message = data.to_dict()
|
||||
abm.self_id = self.client.bot_id
|
||||
|
||||
channel_type = data.channel_type
|
||||
author_id = data.author_id
|
||||
# channel_type定义: https://developer.kookapp.cn/doc/event/event-introduction
|
||||
match channel_type:
|
||||
case KookChannelType.GROUP:
|
||||
session_id = data.target_id or "unknown"
|
||||
abm.type = MessageType.GROUP_MESSAGE
|
||||
abm.group_id = session_id
|
||||
abm.session_id = session_id
|
||||
case KookChannelType.PERSON:
|
||||
abm.type = MessageType.FRIEND_MESSAGE
|
||||
abm.group_id = ""
|
||||
abm.session_id = data.author_id or "unknown"
|
||||
case KookChannelType.BROADCAST:
|
||||
session_id = data.target_id or "unknown"
|
||||
abm.type = MessageType.OTHER_MESSAGE
|
||||
abm.group_id = session_id
|
||||
abm.session_id = session_id
|
||||
case _:
|
||||
raise ValueError(f"不支持的频道类型: {channel_type}")
|
||||
|
||||
abm.sender = MessageMember(
|
||||
user_id=author_id,
|
||||
nickname=data.extra.author.username if data.extra.author else "unknown",
|
||||
)
|
||||
|
||||
abm.message_id = data.msg_id or "unknown"
|
||||
|
||||
if data.type == KookMessageType.KMARKDOWN:
|
||||
message, message_str = self._parse_kmarkdown_text_message(data, abm.self_id)
|
||||
abm.message = message
|
||||
abm.message_str = message_str
|
||||
elif data.type == KookMessageType.CARD:
|
||||
try:
|
||||
abm.message, abm.message_str = self._parse_card_message(data)
|
||||
except Exception as exp:
|
||||
logger.error(f"[KOOK] 卡片消息解析失败: {exp}")
|
||||
logger.error(f"[KOOK] 原始消息内容: {data.to_json()}")
|
||||
abm.message_str = "[卡片消息解析失败]"
|
||||
abm.message = [Plain(text="[卡片消息解析失败]")]
|
||||
else:
|
||||
logger.warning(f'[KOOK] 不支持的kook消息类型: "{data.type.name}"')
|
||||
abm.message_str = "[不支持的消息类型]"
|
||||
abm.message = [Plain(text="[不支持的消息类型]")]
|
||||
|
||||
return abm
|
||||
|
||||
async def handle_msg(self, message: AstrBotMessage):
|
||||
message_event = KookEvent(
|
||||
message_str=message.message_str,
|
||||
message_obj=message,
|
||||
platform_meta=self.meta(),
|
||||
session_id=message.session_id,
|
||||
client=self.client,
|
||||
)
|
||||
self.commit_event(message_event)
|
||||
@@ -0,0 +1,484 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
import aiohttp
|
||||
import pydantic
|
||||
import websockets
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core.platform.message_type import MessageType
|
||||
|
||||
from .kook_config import KookConfig
|
||||
from .kook_types import (
|
||||
KookApiPaths,
|
||||
KookGatewayIndexResponse,
|
||||
KookHelloEventData,
|
||||
KookMessageSignal,
|
||||
KookMessageType,
|
||||
KookResumeAckEventData,
|
||||
KookUserMeResponse,
|
||||
KookWebsocketEvent,
|
||||
)
|
||||
|
||||
|
||||
class KookClient:
|
||||
def __init__(self, config: KookConfig, event_callback):
|
||||
# 数据字段
|
||||
self.config = config
|
||||
self._bot_id = ""
|
||||
self._bot_username = ""
|
||||
self._bot_nickname = ""
|
||||
|
||||
# 资源字段
|
||||
self._http_client = aiohttp.ClientSession(
|
||||
headers={
|
||||
"Authorization": f"Bot {self.config.token}",
|
||||
}
|
||||
)
|
||||
self.event_callback = event_callback # 回调函数,用于处理接收到的事件
|
||||
self.ws = None
|
||||
self.heartbeat_task = None
|
||||
self._stop_event = asyncio.Event() # 用于通知连接结束
|
||||
|
||||
# 状态/计算字段
|
||||
self.running = False
|
||||
self.session_id = None
|
||||
self.last_sn = 0 # 记录最后处理的消息序号
|
||||
self.last_heartbeat_time = 0
|
||||
self.heartbeat_failed_count = 0
|
||||
|
||||
@property
|
||||
def bot_id(self):
|
||||
return self._bot_id
|
||||
|
||||
@property
|
||||
def bot_nickname(self):
|
||||
return self._bot_nickname
|
||||
|
||||
@property
|
||||
def bot_username(self):
|
||||
return self._bot_username
|
||||
|
||||
async def get_bot_info(self) -> None:
|
||||
"""获取机器人账号信息"""
|
||||
url = KookApiPaths.USER_ME
|
||||
|
||||
try:
|
||||
async with self._http_client.get(url) as resp:
|
||||
if resp.status != 200:
|
||||
logger.error(
|
||||
f"[KOOK] 获取机器人账号信息失败,状态码: {resp.status} , {await resp.text()}"
|
||||
)
|
||||
return
|
||||
try:
|
||||
resp_content = KookUserMeResponse.from_dict(await resp.json())
|
||||
except pydantic.ValidationError as e:
|
||||
logger.error(
|
||||
f"[KOOK] 获取机器人账号信息失败, 响应数据格式错误: \n{e}"
|
||||
)
|
||||
logger.error(f"[KOOK] 响应内容: {await resp.text()}")
|
||||
return
|
||||
|
||||
if not resp_content.success():
|
||||
logger.error(
|
||||
f"[KOOK] 获取机器人账号信息失败: {resp_content.model_dump_json()}"
|
||||
)
|
||||
return
|
||||
|
||||
bot_id: str = resp_content.data.id
|
||||
self._bot_id = bot_id
|
||||
logger.info(f"[KOOK] 获取机器人账号ID成功: {bot_id}")
|
||||
self._bot_nickname = resp_content.data.nickname
|
||||
self._bot_username = resp_content.data.username
|
||||
logger.info(f"[KOOK] 获取机器人名称成功: {self._bot_nickname}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 获取机器人账号信息异常: {e}")
|
||||
|
||||
async def get_gateway_url(self, resume=False, sn=0, session_id=None) -> str | None:
|
||||
"""获取网关连接地址"""
|
||||
url = KookApiPaths.GATEWAY_INDEX
|
||||
|
||||
# 构建连接参数
|
||||
params = {}
|
||||
if resume:
|
||||
params["resume"] = 1
|
||||
params["sn"] = sn
|
||||
if session_id:
|
||||
params["session_id"] = session_id
|
||||
|
||||
try:
|
||||
async with self._http_client.get(url, params=params) as resp:
|
||||
if resp.status != 200:
|
||||
logger.error(f"[KOOK] 获取gateway失败,状态码: {resp.status}")
|
||||
return None
|
||||
|
||||
resp_content = KookGatewayIndexResponse.from_dict(await resp.json())
|
||||
if not resp_content.success():
|
||||
logger.error(f"[KOOK] 获取gateway失败: {resp_content}")
|
||||
return None
|
||||
|
||||
gateway_url: str = resp_content.data.url
|
||||
logger.info(f"[KOOK] 获取gateway成功: {gateway_url.split('?')[0]}")
|
||||
return gateway_url
|
||||
|
||||
except pydantic.ValidationError as e:
|
||||
logger.error(f"[KOOK] 获取gateway失败, 响应数据格式错误: \n{e}")
|
||||
logger.error(f"[KOOK] 原始响应内容: {await resp.text()}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 获取gateway异常: {e}")
|
||||
return None
|
||||
|
||||
async def connect(self, resume=False):
|
||||
"""连接WebSocket"""
|
||||
if self.ws:
|
||||
try:
|
||||
await self.ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.ws = None
|
||||
self._stop_event.clear()
|
||||
try:
|
||||
# 获取gateway地址
|
||||
gateway_url = await self.get_gateway_url(
|
||||
resume=resume, sn=self.last_sn, session_id=self.session_id
|
||||
)
|
||||
await self.get_bot_info()
|
||||
|
||||
if not gateway_url:
|
||||
return False
|
||||
|
||||
# 连接WebSocket
|
||||
self.ws = await websockets.connect(gateway_url)
|
||||
self.running = True
|
||||
logger.info("[KOOK] WebSocket 连接成功")
|
||||
|
||||
# 启动心跳任务
|
||||
if self.heartbeat_task:
|
||||
self.heartbeat_task.cancel()
|
||||
self.heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
||||
|
||||
# 开始监听消息
|
||||
await self.listen()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] WebSocket 连接失败: {e}")
|
||||
if self.ws:
|
||||
try:
|
||||
await self.ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.ws = None
|
||||
return False
|
||||
|
||||
async def listen(self):
|
||||
"""监听WebSocket消息"""
|
||||
try:
|
||||
while self.running:
|
||||
try:
|
||||
if self.ws is None:
|
||||
logger.error("[KOOK] WebSocket 对象丢失,结束监听流程。")
|
||||
break
|
||||
|
||||
msg = await asyncio.wait_for(self.ws.recv(), timeout=10)
|
||||
|
||||
if isinstance(msg, bytes):
|
||||
try:
|
||||
msg = zlib.decompress(msg)
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 解压消息失败: {e}")
|
||||
continue
|
||||
msg = msg.decode("utf-8")
|
||||
|
||||
event = KookWebsocketEvent.from_json(msg)
|
||||
|
||||
# 处理不同类型的信令
|
||||
await self._handle_signal(event)
|
||||
|
||||
except pydantic.ValidationError as e:
|
||||
logger.error(f"[KOOK] 解析WebSocket事件数据格式失败: \n{e}")
|
||||
logger.error(f"[KOOK] 原始响应内容: {msg}")
|
||||
continue
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
# 超时检查,继续循环
|
||||
continue
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
logger.warning("[KOOK] WebSocket连接已关闭")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 消息处理异常: {e}")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] WebSocket 监听异常: {e}")
|
||||
finally:
|
||||
self.running = False
|
||||
self._stop_event.set()
|
||||
|
||||
async def _handle_signal(self, event: KookWebsocketEvent):
|
||||
"""处理不同类型的信令"""
|
||||
data = event.data
|
||||
|
||||
match event.signal:
|
||||
case KookMessageSignal.MESSAGE:
|
||||
if event.sn is not None:
|
||||
self.last_sn = event.sn
|
||||
await self.event_callback(data)
|
||||
|
||||
case KookMessageSignal.HELLO:
|
||||
assert isinstance(data, KookHelloEventData)
|
||||
await self._handle_hello(data)
|
||||
|
||||
case KookMessageSignal.RESUME_ACK:
|
||||
assert isinstance(data, KookResumeAckEventData)
|
||||
await self._handle_resume_ack(data)
|
||||
|
||||
case KookMessageSignal.PONG:
|
||||
await self._handle_pong()
|
||||
|
||||
case KookMessageSignal.RECONNECT:
|
||||
await self._handle_reconnect()
|
||||
|
||||
case _:
|
||||
logger.debug(
|
||||
f"[KOOK] 未处理的信令类型: {event.signal.name}({event.signal.value})"
|
||||
)
|
||||
|
||||
async def _handle_hello(self, data: KookHelloEventData):
|
||||
"""处理HELLO握手"""
|
||||
code = data.code
|
||||
|
||||
if code == 0:
|
||||
self.session_id = data.session_id
|
||||
logger.info(f"[KOOK] 握手成功,session_id: {self.session_id}")
|
||||
# TODO 重置重连延迟
|
||||
# self.reconnect_delay = 1
|
||||
else:
|
||||
logger.error(f"[KOOK] 握手失败,错误码: {code}")
|
||||
if code == 40103: # token过期
|
||||
logger.error("[KOOK] Token已过期,需要重新获取")
|
||||
self.running = False
|
||||
|
||||
async def _handle_pong(self):
|
||||
"""处理PONG心跳响应"""
|
||||
self.last_heartbeat_time = time.time()
|
||||
self.heartbeat_failed_count = 0
|
||||
|
||||
async def _handle_reconnect(self):
|
||||
"""处理重连指令"""
|
||||
logger.warning("[KOOK] 收到重连指令")
|
||||
# 清空本地状态
|
||||
self.last_sn = 0
|
||||
self.session_id = None
|
||||
self.running = False
|
||||
|
||||
async def _handle_resume_ack(self, data: KookResumeAckEventData):
|
||||
"""处理RESUME确认"""
|
||||
self.session_id = data.session_id
|
||||
logger.info(f"[KOOK] Resume成功,session_id: {self.session_id}")
|
||||
|
||||
async def _heartbeat_loop(self):
|
||||
"""心跳循环"""
|
||||
while self.running:
|
||||
try:
|
||||
# 随机化心跳间隔 (±5秒)
|
||||
interval = max(
|
||||
1, self.config.heartbeat_interval + random.randint(-5, 5)
|
||||
)
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
if not self.running:
|
||||
break
|
||||
|
||||
# 发送心跳
|
||||
await self._send_ping()
|
||||
|
||||
# 等待PONG响应
|
||||
await asyncio.sleep(self.config.heartbeat_timeout)
|
||||
|
||||
# 检查是否收到PONG响应
|
||||
if (
|
||||
time.time() - self.last_heartbeat_time
|
||||
> self.config.heartbeat_timeout
|
||||
):
|
||||
self.heartbeat_failed_count += 1
|
||||
logger.warning(
|
||||
f"[KOOK] 心跳超时,失败次数: {self.heartbeat_failed_count}"
|
||||
)
|
||||
|
||||
if (
|
||||
self.heartbeat_failed_count
|
||||
>= self.config.max_heartbeat_failures
|
||||
):
|
||||
logger.error("[KOOK] 心跳失败次数过多,准备重连")
|
||||
self.running = False
|
||||
break
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 心跳异常: {e}")
|
||||
self.heartbeat_failed_count += 1
|
||||
|
||||
async def _send_ping(self):
|
||||
"""发送心跳PING"""
|
||||
if self.ws is None:
|
||||
logger.warning("[KOOK] 尚未连接kook WebSocket服务器, 跳过发送心跳包流程")
|
||||
return
|
||||
try:
|
||||
ping_data = KookWebsocketEvent(
|
||||
signal=KookMessageSignal.PING,
|
||||
data=None,
|
||||
sn=self.last_sn,
|
||||
)
|
||||
await self.ws.send(ping_data.to_json())
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 发送心跳失败: {e}")
|
||||
|
||||
async def send_text(
|
||||
self,
|
||||
target_id: str,
|
||||
content: str,
|
||||
astrbot_message_type: MessageType,
|
||||
kook_message_type: KookMessageType,
|
||||
reply_message_id: str | int = "",
|
||||
):
|
||||
"""发送文本消息
|
||||
消息发送接口文档参见: https://developer.kookapp.cn/doc/http/message#%E5%8F%91%E9%80%81%E9%A2%91%E9%81%93%E8%81%8A%E5%A4%A9%E6%B6%88%E6%81%AF
|
||||
KMarkdown格式参见: https://developer.kookapp.cn/doc/kmarkdown-desc
|
||||
"""
|
||||
url = KookApiPaths.CHANNEL_MESSAGE_CREATE
|
||||
if astrbot_message_type == MessageType.FRIEND_MESSAGE:
|
||||
url = KookApiPaths.DIRECT_MESSAGE_CREATE
|
||||
|
||||
payload = {
|
||||
"target_id": target_id,
|
||||
"content": content,
|
||||
"type": kook_message_type,
|
||||
}
|
||||
if reply_message_id:
|
||||
payload["quote"] = reply_message_id
|
||||
payload["reply_msg_id"] = reply_message_id
|
||||
|
||||
try:
|
||||
async with self._http_client.post(url, json=payload) as resp:
|
||||
if resp.status == 200:
|
||||
result = await resp.json()
|
||||
if result.get("code") != 0:
|
||||
raise RuntimeError(
|
||||
f'发送kook消息类型 "{kook_message_type.name}" 失败: {result}'
|
||||
)
|
||||
# else:
|
||||
# logger.info("[KOOK] 发送消息成功")
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f'发送kook消息类型 "{kook_message_type.name}" HTTP错误: {resp.status} , 响应内容 : {await resp.text()}'
|
||||
)
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'[KOOK] 发送kook消息类型 "{kook_message_type.name}" 异常: {e}'
|
||||
)
|
||||
|
||||
async def upload_asset(self, file_url: str | None) -> str:
|
||||
"""上传文件到kook,获得远端资源url
|
||||
接口定义参见: https://developer.kookapp.cn/doc/http/asset
|
||||
"""
|
||||
if not file_url:
|
||||
return ""
|
||||
|
||||
bytes_data: bytes | None = None
|
||||
filename = "unknown"
|
||||
if file_url.startswith(("http://", "https://")):
|
||||
filename = file_url.split("/")[-1]
|
||||
return file_url
|
||||
|
||||
if file_url.startswith("base64:///"):
|
||||
# b64decode的时候得开头留一个'/'的, 不然会报错
|
||||
b64_str = file_url.removeprefix("base64://")
|
||||
bytes_data = base64.b64decode(b64_str)
|
||||
|
||||
elif file_url.startswith("file://") or os.path.exists(file_url):
|
||||
file_url = file_url.removeprefix("file:///")
|
||||
file_url = file_url.removeprefix("file://")
|
||||
|
||||
try:
|
||||
target_path = Path(file_url).resolve()
|
||||
except Exception as exp:
|
||||
logger.error(f'[KOOK] 获取文件 "{file_url}" 绝对路径失败: "{exp}"')
|
||||
raise FileNotFoundError(
|
||||
f'获取文件 "{file_url}" 绝对路径失败: "{exp}"'
|
||||
) from exp
|
||||
|
||||
if not target_path.is_file():
|
||||
raise FileNotFoundError(f"文件不存在: {target_path.name}")
|
||||
|
||||
filename = target_path.name
|
||||
async with aiofiles.open(target_path, "rb") as f:
|
||||
bytes_data = await f.read()
|
||||
|
||||
else:
|
||||
raise ValueError(f'[KOOK] 不支持的文件资源类型: "{file_url}"')
|
||||
|
||||
data = aiohttp.FormData()
|
||||
data.add_field("file", bytes_data, filename=filename)
|
||||
|
||||
url = KookApiPaths.ASSET_CREATE
|
||||
try:
|
||||
async with self._http_client.post(url, data=data) as resp:
|
||||
if resp.status == 200:
|
||||
result: dict = await resp.json()
|
||||
logger.debug(f"[KOOK] 上传文件响应: {result}")
|
||||
if result.get("code") == 0:
|
||||
logger.info("[KOOK] 上传文件到kook服务器成功")
|
||||
remote_url = result["data"]["url"]
|
||||
logger.debug(f"[KOOK] 文件远端URL: {remote_url}")
|
||||
return remote_url
|
||||
else:
|
||||
raise RuntimeError(f"上传文件到kook服务器失败: {result}")
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"上传文件到kook服务器 HTTP错误: {resp.status} , {await resp.text()}"
|
||||
)
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"上传文件到kook服务器异常: {e}") from e
|
||||
|
||||
async def wait_until_closed(self):
|
||||
"""提供给外部调用的等待方法"""
|
||||
await self._stop_event.wait()
|
||||
|
||||
async def close(self):
|
||||
"""关闭连接"""
|
||||
self.running = False
|
||||
self._stop_event.set()
|
||||
|
||||
if self.heartbeat_task:
|
||||
self.heartbeat_task.cancel()
|
||||
try:
|
||||
await self.heartbeat_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if self.ws:
|
||||
try:
|
||||
await self.ws.close()
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 关闭WebSocket异常: {e}")
|
||||
|
||||
if self._http_client:
|
||||
await self._http_client.close()
|
||||
|
||||
logger.info("[KOOK] 连接已关闭")
|
||||
@@ -0,0 +1,131 @@
|
||||
import json
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class KookConfig:
|
||||
"""KOOK 适配器配置类"""
|
||||
|
||||
# 基础配置
|
||||
token: str
|
||||
enable: bool = False
|
||||
id: str = "kook"
|
||||
|
||||
# 重连配置
|
||||
reconnect_delay: int = 1
|
||||
"""重连延迟基数(秒),指数退避"""
|
||||
max_reconnect_delay: int = 60
|
||||
"""最大重连延迟(秒)"""
|
||||
max_retry_delay: int = 60
|
||||
"""最大重试延迟(秒)"""
|
||||
|
||||
# 心跳配置
|
||||
heartbeat_interval: int = 30
|
||||
"""心跳间隔(秒)"""
|
||||
heartbeat_timeout: int = 6
|
||||
"""心跳超时时间(秒)"""
|
||||
max_heartbeat_failures: int = 3
|
||||
"""最大心跳失败次数"""
|
||||
|
||||
# 失败处理
|
||||
max_consecutive_failures: int = 5
|
||||
"""最大连续失败次数"""
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, config_dict: dict) -> "KookConfig":
|
||||
"""从字典创建配置对象"""
|
||||
return cls(
|
||||
# 适配器id 应该是不能改的
|
||||
# id=config_dict.get("id", "kook"),
|
||||
enable=config_dict.get("enable", False),
|
||||
token=config_dict.get("kook_bot_token", ""),
|
||||
reconnect_delay=config_dict.get(
|
||||
"kook_reconnect_delay",
|
||||
KookConfig.reconnect_delay,
|
||||
),
|
||||
max_reconnect_delay=config_dict.get(
|
||||
"kook_max_reconnect_delay",
|
||||
KookConfig.max_reconnect_delay,
|
||||
),
|
||||
max_retry_delay=config_dict.get(
|
||||
"kook_max_retry_delay",
|
||||
KookConfig.max_retry_delay,
|
||||
),
|
||||
heartbeat_interval=config_dict.get(
|
||||
"kook_heartbeat_interval",
|
||||
KookConfig.heartbeat_interval,
|
||||
),
|
||||
heartbeat_timeout=config_dict.get(
|
||||
"kook_heartbeat_timeout",
|
||||
KookConfig.heartbeat_timeout,
|
||||
),
|
||||
max_heartbeat_failures=config_dict.get(
|
||||
"kook_max_heartbeat_failures",
|
||||
KookConfig.max_heartbeat_failures,
|
||||
),
|
||||
max_consecutive_failures=config_dict.get(
|
||||
"kook_max_consecutive_failures",
|
||||
KookConfig.max_consecutive_failures,
|
||||
),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
def pretty_jsons(self, indent=2) -> str:
|
||||
dict_config = self.to_dict()
|
||||
dict_config["token"] = "*" * len(self.token) if self.token else "MISSING"
|
||||
return json.dumps(dict_config, indent=indent, ensure_ascii=False)
|
||||
|
||||
|
||||
# TODO 没用上的config配置,未来有空会实现这些配置描述的功能?
|
||||
# # 连接配置
|
||||
# CONNECTION_CONFIG = {
|
||||
# # 心跳配置
|
||||
# "heartbeat_interval": 30, # 心跳间隔(秒)
|
||||
# "heartbeat_timeout": 6, # 心跳超时时间(秒)
|
||||
# "max_heartbeat_failures": 3, # 最大心跳失败次数
|
||||
# # 重连配置
|
||||
# "initial_reconnect_delay": 1, # 初始重连延迟(秒)
|
||||
# "max_reconnect_delay": 60, # 最大重连延迟(秒)
|
||||
# "max_consecutive_failures": 5, # 最大连续失败次数
|
||||
# # WebSocket配置
|
||||
# "websocket_timeout": 10, # WebSocket接收超时(秒)
|
||||
# "connection_timeout": 30, # 连接超时(秒)
|
||||
# # 消息处理配置
|
||||
# "enable_compression": True, # 是否启用消息压缩
|
||||
# "max_message_size": 1024 * 1024, # 最大消息大小(字节)
|
||||
# }
|
||||
|
||||
# # 日志配置
|
||||
# LOGGING_CONFIG = {
|
||||
# "level": "INFO", # 日志级别:DEBUG, INFO, WARNING, ERROR
|
||||
# "format": "[KOOK] %(message)s",
|
||||
# "enable_heartbeat_logs": False, # 是否启用心跳日志
|
||||
# "enable_message_logs": False, # 是否启用消息日志
|
||||
# }
|
||||
|
||||
# # 错误处理配置
|
||||
# ERROR_HANDLING_CONFIG = {
|
||||
# "retry_on_network_error": True, # 网络错误时是否重试
|
||||
# "retry_on_token_expired": True, # Token过期时是否重试
|
||||
# "max_retry_attempts": 3, # 最大重试次数
|
||||
# "retry_delay_base": 2, # 重试延迟基数(秒)
|
||||
# }
|
||||
|
||||
# # 性能配置
|
||||
# PERFORMANCE_CONFIG = {
|
||||
# "enable_message_buffering": True, # 是否启用消息缓冲
|
||||
# "buffer_size": 100, # 缓冲区大小
|
||||
# "enable_connection_pooling": True, # 是否启用连接池
|
||||
# "max_concurrent_requests": 10, # 最大并发请求数
|
||||
# }
|
||||
|
||||
# # 安全配置
|
||||
# SECURITY_CONFIG = {
|
||||
# "verify_ssl": True, # 是否验证SSL证书
|
||||
# "enable_rate_limiting": True, # 是否启用速率限制
|
||||
# "rate_limit_requests": 100, # 速率限制请求数
|
||||
# "rate_limit_window": 60, # 速率限制窗口(秒)
|
||||
# }
|
||||
@@ -0,0 +1,210 @@
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import Coroutine
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
|
||||
from astrbot.core.message.components import (
|
||||
At,
|
||||
AtAll,
|
||||
BaseMessageComponent,
|
||||
File,
|
||||
Image,
|
||||
Json,
|
||||
Plain,
|
||||
Record,
|
||||
Reply,
|
||||
Video,
|
||||
)
|
||||
from astrbot.core.platform import MessageType
|
||||
|
||||
from .kook_client import KookClient
|
||||
from .kook_types import (
|
||||
FileModule,
|
||||
KookCardMessage,
|
||||
KookCardMessageContainer,
|
||||
KookMessageType,
|
||||
KookModuleType,
|
||||
OrderMessage,
|
||||
)
|
||||
|
||||
|
||||
class KookEvent(AstrMessageEvent):
|
||||
def __init__(
|
||||
self,
|
||||
message_str: str,
|
||||
message_obj: AstrBotMessage,
|
||||
platform_meta: PlatformMetadata,
|
||||
session_id: str,
|
||||
client: KookClient,
|
||||
):
|
||||
super().__init__(message_str, message_obj, platform_meta, session_id)
|
||||
self.client = client
|
||||
self.channel_id = message_obj.group_id or message_obj.session_id
|
||||
self.astrbot_message_type: MessageType = message_obj.type
|
||||
self._file_message_counter = 0
|
||||
|
||||
def _wrap_message(
|
||||
self, index: int, message_component: BaseMessageComponent
|
||||
) -> Coroutine[Any, Any, OrderMessage]:
|
||||
async def wrap_upload(
|
||||
index: int, message_type: KookMessageType, upload_coro
|
||||
) -> OrderMessage:
|
||||
url = await upload_coro
|
||||
return OrderMessage(index=index, text=url, type=message_type)
|
||||
|
||||
async def handle_plain(
|
||||
index: int,
|
||||
text: str | None,
|
||||
reply_id: str | int = "",
|
||||
type: KookMessageType = KookMessageType.KMARKDOWN,
|
||||
):
|
||||
if not text:
|
||||
text = ""
|
||||
return OrderMessage(
|
||||
index=index,
|
||||
text=text,
|
||||
type=type,
|
||||
reply_id=reply_id,
|
||||
)
|
||||
|
||||
match message_component:
|
||||
case Image():
|
||||
self._file_message_counter += 1
|
||||
return wrap_upload(
|
||||
index,
|
||||
KookMessageType.IMAGE,
|
||||
self.client.upload_asset(message_component.file),
|
||||
)
|
||||
|
||||
case Video():
|
||||
self._file_message_counter += 1
|
||||
return wrap_upload(
|
||||
index,
|
||||
KookMessageType.VIDEO,
|
||||
self.client.upload_asset(message_component.file),
|
||||
)
|
||||
case File():
|
||||
|
||||
async def handle_file(index: int, f_item: File):
|
||||
f_data = await f_item.get_file()
|
||||
url = await self.client.upload_asset(f_data)
|
||||
return OrderMessage(
|
||||
index=index, text=url, type=KookMessageType.FILE
|
||||
)
|
||||
|
||||
self._file_message_counter += 1
|
||||
return handle_file(index, message_component)
|
||||
|
||||
case Record():
|
||||
|
||||
async def handle_audio(index: int, f_item: Record):
|
||||
file_path = await f_item.convert_to_file_path()
|
||||
url = await self.client.upload_asset(file_path)
|
||||
title = f_item.text or Path(file_path).name
|
||||
return OrderMessage(
|
||||
index=index,
|
||||
text=KookCardMessageContainer(
|
||||
[
|
||||
KookCardMessage(
|
||||
modules=[
|
||||
FileModule(
|
||||
type=KookModuleType.AUDIO,
|
||||
title=title,
|
||||
src=url,
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
).to_json(),
|
||||
type=KookMessageType.CARD,
|
||||
)
|
||||
|
||||
return handle_audio(index, message_component)
|
||||
case Plain():
|
||||
return handle_plain(index, message_component.text)
|
||||
case At():
|
||||
return handle_plain(index, f"(met){message_component.qq}(met)")
|
||||
case AtAll():
|
||||
return handle_plain(index, "(met)all(met)")
|
||||
case Reply():
|
||||
return handle_plain(index, "", reply_id=message_component.id)
|
||||
case Json():
|
||||
json_data = message_component.data
|
||||
# kook卡片json外层得是一个列表
|
||||
if isinstance(json_data, dict):
|
||||
json_data = [json_data]
|
||||
return handle_plain(
|
||||
index,
|
||||
# 考虑到kook可能会更改消息结构,为了能让插件开发者
|
||||
# 自行根据kook文档描述填卡片json内容,故不做模型校验
|
||||
# KookCardMessage().model_validate(message_component.data).to_json(),
|
||||
text=json.dumps(json_data),
|
||||
type=KookMessageType.CARD,
|
||||
)
|
||||
case _:
|
||||
raise NotImplementedError(
|
||||
f'kook适配器尚未实现对 "{message_component.type}" 消息类型的支持'
|
||||
)
|
||||
|
||||
async def send(self, message: MessageChain):
|
||||
file_upload_tasks: list[Coroutine[Any, Any, OrderMessage]] = []
|
||||
for index, item in enumerate(message.chain):
|
||||
file_upload_tasks.append(self._wrap_message(index, item))
|
||||
|
||||
if self._file_message_counter > 0:
|
||||
logger.debug("[Kook] 正在向kook服务器上传文件")
|
||||
|
||||
tasks_result = await asyncio.gather(*file_upload_tasks, return_exceptions=True)
|
||||
order_messages: list[OrderMessage] = []
|
||||
|
||||
for index, result in enumerate(tasks_result):
|
||||
if isinstance(result, BaseException):
|
||||
logger.error(f"[Kook] {result}")
|
||||
# 构造一个虚假的 OrderMessage,让用户知道这里本来有张图但坏了
|
||||
# 这样后面的 for 循环就能把它当成普通文本发出去
|
||||
err_node = OrderMessage(
|
||||
index=index,
|
||||
text=str(result),
|
||||
type=KookMessageType.TEXT,
|
||||
)
|
||||
order_messages.append(err_node)
|
||||
else:
|
||||
order_messages.append(result)
|
||||
|
||||
order_messages.sort(key=lambda x: x.index)
|
||||
|
||||
reply_id: str | int = ""
|
||||
errors: list[Exception] = []
|
||||
for item in order_messages:
|
||||
if item.reply_id:
|
||||
reply_id = item.reply_id
|
||||
if not item.text:
|
||||
logger.debug(f'[Kook] 跳过空消息,类型为"{item.type.name}"')
|
||||
continue
|
||||
try:
|
||||
await self.client.send_text(
|
||||
self.channel_id,
|
||||
item.text,
|
||||
self.astrbot_message_type,
|
||||
item.type,
|
||||
reply_id,
|
||||
)
|
||||
except RuntimeError as exp:
|
||||
await self.client.send_text(
|
||||
self.channel_id,
|
||||
str(exp),
|
||||
self.astrbot_message_type,
|
||||
KookMessageType.TEXT,
|
||||
reply_id,
|
||||
)
|
||||
errors.append(exp)
|
||||
|
||||
if errors:
|
||||
err_msg = "\n".join([str(err) for err in errors])
|
||||
logger.error(f"[kook] {err_msg}")
|
||||
|
||||
await super().send(message)
|
||||
@@ -0,0 +1,505 @@
|
||||
import json
|
||||
from enum import IntEnum, StrEnum
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
class KookApiPaths:
|
||||
"""Kook Api 路径"""
|
||||
|
||||
BASE_URL = "https://www.kookapp.cn"
|
||||
API_VERSION_PATH = "/api/v3"
|
||||
|
||||
# 初始化相关
|
||||
USER_ME = f"{BASE_URL}{API_VERSION_PATH}/user/me"
|
||||
GATEWAY_INDEX = f"{BASE_URL}{API_VERSION_PATH}/gateway/index"
|
||||
|
||||
# 消息相关
|
||||
ASSET_CREATE = f"{BASE_URL}{API_VERSION_PATH}/asset/create"
|
||||
## 频道消息
|
||||
CHANNEL_MESSAGE_CREATE = f"{BASE_URL}{API_VERSION_PATH}/message/create"
|
||||
## 私聊消息
|
||||
DIRECT_MESSAGE_CREATE = f"{BASE_URL}{API_VERSION_PATH}/direct-message/create"
|
||||
|
||||
|
||||
class KookMessageType(IntEnum):
|
||||
"""定义参见kook事件结构文档: https://developer.kookapp.cn/doc/event/event-introduction"""
|
||||
|
||||
TEXT = 1
|
||||
IMAGE = 2
|
||||
VIDEO = 3
|
||||
FILE = 4
|
||||
AUDIO = 8
|
||||
KMARKDOWN = 9
|
||||
CARD = 10
|
||||
SYSTEM = 255
|
||||
|
||||
|
||||
class KookModuleType(StrEnum):
|
||||
PLAIN_TEXT = "plain-text"
|
||||
KMARKDOWN = "kmarkdown"
|
||||
IMAGE = "image"
|
||||
BUTTON = "button"
|
||||
HEADER = "header"
|
||||
SECTION = "section"
|
||||
IMAGE_GROUP = "image-group"
|
||||
CONTAINER = "container"
|
||||
ACTION_GROUP = "action-group"
|
||||
CONTEXT = "context"
|
||||
DIVIDER = "divider"
|
||||
FILE = "file"
|
||||
AUDIO = "audio"
|
||||
VIDEO = "video"
|
||||
COUNTDOWN = "countdown"
|
||||
INVITE = "invite"
|
||||
CARD = "card"
|
||||
|
||||
|
||||
ThemeType = Literal[
|
||||
"primary", "success", "danger", "warning", "info", "secondary", "none", "invisible"
|
||||
]
|
||||
"""主题,可选的值为:primary, success, danger, warning, info, secondary, none.默认为 primary,为 none 时不显示侧边框。"""
|
||||
SizeType = Literal["xs", "sm", "md", "lg"]
|
||||
"""大小,可选值为:xs, sm, md, lg, 一般默认为 lg"""
|
||||
|
||||
SectionMode = Literal["left", "right"]
|
||||
CountdownMode = Literal["day", "hour", "second"]
|
||||
|
||||
|
||||
class KookBaseDataClass(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra="allow",
|
||||
arbitrary_types_allowed=True,
|
||||
populate_by_name=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, raw_data: dict):
|
||||
return cls.model_validate(raw_data)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, raw_data: str | bytes | bytearray):
|
||||
return cls.model_validate_json(raw_data)
|
||||
|
||||
def to_dict(
|
||||
self,
|
||||
mode: Literal["json", "python"] | str = "python",
|
||||
by_alias=True,
|
||||
exclude_none=True,
|
||||
exclude_unset=False,
|
||||
) -> dict:
|
||||
return self.model_dump(
|
||||
by_alias=by_alias,
|
||||
exclude_none=exclude_none,
|
||||
mode=mode,
|
||||
exclude_unset=exclude_unset,
|
||||
)
|
||||
|
||||
def to_json(
|
||||
self,
|
||||
indent: int | None = None,
|
||||
ensure_ascii=False,
|
||||
by_alias=True,
|
||||
exclude_none=True,
|
||||
exclude_unset=False,
|
||||
) -> str:
|
||||
return self.model_dump_json(
|
||||
indent=indent,
|
||||
ensure_ascii=ensure_ascii,
|
||||
by_alias=by_alias,
|
||||
exclude_none=exclude_none,
|
||||
exclude_unset=exclude_unset,
|
||||
)
|
||||
|
||||
|
||||
class KookCardModelBase(KookBaseDataClass):
|
||||
"""卡片模块基类"""
|
||||
|
||||
type: str
|
||||
|
||||
|
||||
class PlainTextElement(KookCardModelBase):
|
||||
content: str
|
||||
type: Literal[KookModuleType.PLAIN_TEXT] = KookModuleType.PLAIN_TEXT
|
||||
emoji: bool = True
|
||||
|
||||
|
||||
class KmarkdownElement(KookCardModelBase):
|
||||
content: str
|
||||
type: Literal[KookModuleType.KMARKDOWN] = KookModuleType.KMARKDOWN
|
||||
|
||||
|
||||
class ImageElement(KookCardModelBase):
|
||||
src: str
|
||||
type: Literal[KookModuleType.IMAGE] = KookModuleType.IMAGE
|
||||
alt: str = ""
|
||||
size: SizeType = "lg"
|
||||
circle: bool = False
|
||||
fallbackUrl: str | None = None
|
||||
|
||||
|
||||
class ButtonElement(KookCardModelBase):
|
||||
text: str
|
||||
type: Literal[KookModuleType.BUTTON] = KookModuleType.BUTTON
|
||||
theme: ThemeType = "primary"
|
||||
value: str = ""
|
||||
"""当为 link 时,会跳转到 value 代表的链接;
|
||||
当为 return-val 时,系统会通过系统消息将消息 id,点击用户 id 和 value 发回给发送者,发送者可以根据自己的需求进行处理,消息事件参见button 点击事件。私聊和频道内均可使用按钮点击事件。"""
|
||||
click: Literal["", "link", "return-val"] = ""
|
||||
"""click 代表用户点击的事件,默认为"",代表无任何事件。"""
|
||||
|
||||
|
||||
AnyElement = PlainTextElement | KmarkdownElement | ImageElement | ButtonElement | str
|
||||
|
||||
|
||||
class ParagraphStructure(KookCardModelBase):
|
||||
fields: list[PlainTextElement | KmarkdownElement]
|
||||
type: Literal["paragraph"] = "paragraph"
|
||||
cols: int = 1
|
||||
"""范围是 1-3 , 移动端忽略此参数"""
|
||||
|
||||
|
||||
class HeaderModule(KookCardModelBase):
|
||||
text: PlainTextElement
|
||||
type: Literal[KookModuleType.HEADER] = KookModuleType.HEADER
|
||||
|
||||
|
||||
class SectionModule(KookCardModelBase):
|
||||
text: PlainTextElement | KmarkdownElement | ParagraphStructure
|
||||
type: Literal[KookModuleType.SECTION] = KookModuleType.SECTION
|
||||
mode: SectionMode = "left"
|
||||
accessory: ImageElement | ButtonElement | None = None
|
||||
|
||||
|
||||
class ImageGroupModule(KookCardModelBase):
|
||||
"""1 到多张图片的组合"""
|
||||
|
||||
elements: list[ImageElement]
|
||||
type: Literal[KookModuleType.IMAGE_GROUP] = KookModuleType.IMAGE_GROUP
|
||||
|
||||
|
||||
class ContainerModule(KookCardModelBase):
|
||||
"""1 到多张图片的组合,与图片组模块(ImageGroupModule)不同,图片并不会裁切为正方形。多张图片会纵向排列。"""
|
||||
|
||||
elements: list[ImageElement]
|
||||
type: Literal[KookModuleType.CONTAINER] = KookModuleType.CONTAINER
|
||||
|
||||
|
||||
class ActionGroupModule(KookCardModelBase):
|
||||
"""用来放按钮的模块"""
|
||||
|
||||
elements: list[ButtonElement]
|
||||
type: Literal[KookModuleType.ACTION_GROUP] = KookModuleType.ACTION_GROUP
|
||||
|
||||
|
||||
class ContextModule(KookCardModelBase):
|
||||
elements: list[PlainTextElement | KmarkdownElement | ImageElement]
|
||||
"""最多包含10个元素"""
|
||||
type: Literal[KookModuleType.CONTEXT] = KookModuleType.CONTEXT
|
||||
|
||||
|
||||
class DividerModule(KookCardModelBase):
|
||||
"""展示分割线用的"""
|
||||
|
||||
type: Literal[KookModuleType.DIVIDER] = KookModuleType.DIVIDER
|
||||
|
||||
|
||||
class FileModule(KookCardModelBase):
|
||||
src: str
|
||||
title: str = ""
|
||||
type: Literal[KookModuleType.FILE, KookModuleType.AUDIO, KookModuleType.VIDEO] = (
|
||||
KookModuleType.FILE
|
||||
)
|
||||
cover: str | None = None
|
||||
"""cover 仅音频有效, 是音频的封面图"""
|
||||
|
||||
|
||||
class CountdownModule(KookCardModelBase):
|
||||
"""startTime 和 endTime 为毫秒时间戳,startTime 和 endTime 不能小于服务器当前时间戳。"""
|
||||
|
||||
endTime: int
|
||||
"""毫秒时间戳"""
|
||||
type: Literal[KookModuleType.COUNTDOWN] = KookModuleType.COUNTDOWN
|
||||
startTime: int | None = None
|
||||
"""毫秒时间戳, 仅当mode为second才有这个字段"""
|
||||
mode: CountdownMode = "day"
|
||||
"""mode 主要是倒计时的样式"""
|
||||
|
||||
|
||||
class InviteModule(KookCardModelBase):
|
||||
code: str
|
||||
"""邀请链接或者邀请码"""
|
||||
type: Literal[KookModuleType.INVITE] = KookModuleType.INVITE
|
||||
|
||||
|
||||
# 所有模块的联合类型
|
||||
AnyModule = Annotated[
|
||||
HeaderModule
|
||||
| SectionModule
|
||||
| ImageGroupModule
|
||||
| ContainerModule
|
||||
| ActionGroupModule
|
||||
| ContextModule
|
||||
| DividerModule
|
||||
| FileModule
|
||||
| CountdownModule
|
||||
| InviteModule,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
|
||||
class KookCardMessage(KookBaseDataClass):
|
||||
"""卡片定义文档详见 : https://developer.kookapp.cn/doc/cardmessage
|
||||
此类型不能直接to_json后发送,因为kook要求卡片容器json顶层必须是**列表**
|
||||
若要发送卡片消息,请使用KookCardMessageContainer
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
type: Literal[KookModuleType.CARD] = KookModuleType.CARD
|
||||
theme: ThemeType | None = None
|
||||
size: SizeType | None = None
|
||||
color: str | None = None
|
||||
"""16 进制色值"""
|
||||
modules: list[AnyModule] = Field(default_factory=list)
|
||||
"""单个 card 模块数量不限制,但是一条消息中所有卡片的模块数量之和最多是 50"""
|
||||
|
||||
def add_module(self, module: AnyModule):
|
||||
self.modules.append(module)
|
||||
|
||||
|
||||
class KookCardMessageContainer(list[KookCardMessage]):
|
||||
"""卡片消息容器(列表),此类型可以直接to_json后发送出去"""
|
||||
|
||||
def append(self, object: KookCardMessage) -> None:
|
||||
return super().append(object)
|
||||
|
||||
def to_json(self, indent: int | None = None, ensure_ascii: bool = True) -> str:
|
||||
return json.dumps(
|
||||
[i.to_dict() for i in self], indent=indent, ensure_ascii=ensure_ascii
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, raw_data: list[dict[str, Any]]):
|
||||
return cls(KookCardMessage.from_dict(item) for item in raw_data)
|
||||
|
||||
|
||||
class OrderMessage(BaseModel):
|
||||
index: int
|
||||
text: str
|
||||
type: KookMessageType
|
||||
reply_id: str | int = ""
|
||||
|
||||
|
||||
class KookMessageSignal(IntEnum):
|
||||
"""KOOK WebSocket 信令类型
|
||||
ws文档: https://developer.kookapp.cn/doc/websocket""" # noqa: W291
|
||||
|
||||
MESSAGE = 0
|
||||
"""server->client 消息(s包含聊天和通知消息)"""
|
||||
HELLO = 1
|
||||
"""server->client 客户端连接 ws 时, 服务端返回握手结果"""
|
||||
PING = 2
|
||||
"""client->server 心跳,ping"""
|
||||
PONG = 3
|
||||
"""server->client 心跳,pong"""
|
||||
RESUME = 4
|
||||
"""client->server resume, 恢复会话"""
|
||||
RECONNECT = 5
|
||||
"""server->client reconnect, 要求客户端断开当前连接重新连接"""
|
||||
RESUME_ACK = 6
|
||||
"""server->client resume ack"""
|
||||
|
||||
|
||||
class KookChannelType(StrEnum):
|
||||
GROUP = "GROUP"
|
||||
PERSON = "PERSON"
|
||||
BROADCAST = "BROADCAST"
|
||||
|
||||
|
||||
class KookAuthor(KookBaseDataClass):
|
||||
id: str
|
||||
username: str
|
||||
identify_num: str
|
||||
nickname: str
|
||||
bot: bool
|
||||
online: bool
|
||||
avatar: str | None = None
|
||||
vip_avatar: str | None = None
|
||||
status: int
|
||||
roles: list[int] = Field(default_factory=list)
|
||||
|
||||
|
||||
class KookKMarkdown(KookBaseDataClass):
|
||||
raw_content: str
|
||||
mention_part: list[Any] = Field(default_factory=list)
|
||||
mention_role_part: list[Any] = Field(default_factory=list)
|
||||
|
||||
|
||||
class KookExtra(KookBaseDataClass):
|
||||
type: int | str
|
||||
code: str | None = None
|
||||
body: dict[str, Any] | None = None
|
||||
author: KookAuthor | None = None
|
||||
kmarkdown: KookKMarkdown | None = None
|
||||
last_msg_content: str | None = None
|
||||
mention: list[str] = Field(default_factory=list)
|
||||
mention_all: bool = False
|
||||
mention_here: bool = False
|
||||
|
||||
|
||||
class KookMessageEventData(KookBaseDataClass):
|
||||
signal: Literal[KookMessageSignal.MESSAGE] = Field(
|
||||
KookMessageSignal.MESSAGE, exclude=True
|
||||
)
|
||||
"""only for type hint"""
|
||||
|
||||
channel_type: KookChannelType
|
||||
type: KookMessageType
|
||||
target_id: str
|
||||
author_id: str
|
||||
content: str | dict[str, Any]
|
||||
msg_id: str
|
||||
msg_timestamp: int
|
||||
nonce: str
|
||||
from_type: int
|
||||
extra: KookExtra
|
||||
|
||||
|
||||
class KookHelloEventData(KookBaseDataClass):
|
||||
signal: Literal[KookMessageSignal.HELLO] = Field(
|
||||
KookMessageSignal.HELLO, exclude=True
|
||||
)
|
||||
"""only for type hint"""
|
||||
|
||||
code: int
|
||||
session_id: str
|
||||
|
||||
|
||||
class KookPingEventData(KookBaseDataClass):
|
||||
signal: Literal[KookMessageSignal.PING] = Field(
|
||||
KookMessageSignal.PING, exclude=True
|
||||
)
|
||||
"""only for type hint"""
|
||||
|
||||
|
||||
class KookPongEventData(KookBaseDataClass):
|
||||
signal: Literal[KookMessageSignal.PONG] = Field(
|
||||
KookMessageSignal.PONG, exclude=True
|
||||
)
|
||||
"""only for type hint"""
|
||||
|
||||
|
||||
class KookResumeEventData(KookBaseDataClass):
|
||||
signal: Literal[KookMessageSignal.RESUME] = Field(
|
||||
KookMessageSignal.RESUME, exclude=True
|
||||
)
|
||||
"""only for type hint"""
|
||||
|
||||
|
||||
class KookReconnectEventData(KookBaseDataClass):
|
||||
signal: Literal[KookMessageSignal.RECONNECT] = Field(
|
||||
KookMessageSignal.RECONNECT, exclude=True
|
||||
)
|
||||
"""only for type hint"""
|
||||
|
||||
code: int
|
||||
err: str
|
||||
|
||||
|
||||
class KookResumeAckEventData(KookBaseDataClass):
|
||||
signal: Literal[KookMessageSignal.RESUME_ACK] = Field(
|
||||
KookMessageSignal.RESUME_ACK, exclude=True
|
||||
)
|
||||
"""only for type hint"""
|
||||
|
||||
session_id: str
|
||||
|
||||
|
||||
class KookWebsocketEvent(KookBaseDataClass):
|
||||
"""KOOK WebSocket 原始推送结构"""
|
||||
|
||||
signal: KookMessageSignal = Field(
|
||||
..., validation_alias="s", serialization_alias="s"
|
||||
)
|
||||
"""信令类型"""
|
||||
data: Annotated[
|
||||
KookMessageEventData
|
||||
| KookHelloEventData
|
||||
| KookPingEventData
|
||||
| KookPongEventData
|
||||
| KookResumeEventData
|
||||
| KookReconnectEventData
|
||||
| KookResumeAckEventData
|
||||
| None,
|
||||
Field(discriminator="signal"),
|
||||
] = Field(None, validation_alias="d", serialization_alias="d")
|
||||
"""数据事件主体,对应原字段是'd'"""
|
||||
sn: int | None = None
|
||||
"""消息序号 , 用来确定消息顺序和ws重连时使用
|
||||
详见ws连接流程文档: https://developer.kookapp.cn/doc/websocket#%E8%BF%9E%E6%8E%A5%E6%B5%81%E7%A8%8B""" # noqa: W291
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _inject_signal_into_data(cls, data: Any) -> Any:
|
||||
"""在解析前,把外层的 s 同步到内层的 d 中,供 discriminator 使用"""
|
||||
if isinstance(data, dict):
|
||||
s_value = data.get("s")
|
||||
d_value = data.get("d")
|
||||
if s_value is not None and isinstance(d_value, dict):
|
||||
d_value["signal"] = s_value
|
||||
return data
|
||||
|
||||
|
||||
class KookUserTag(KookBaseDataClass):
|
||||
color: str
|
||||
bg_color: str
|
||||
text: str
|
||||
|
||||
|
||||
class KookApiResponseBase(KookBaseDataClass):
|
||||
code: int
|
||||
message: str
|
||||
data: Any
|
||||
|
||||
def success(self) -> bool:
|
||||
return self.code == 0
|
||||
|
||||
|
||||
class KookUserMeData(KookBaseDataClass):
|
||||
"""USER_ME 接口返回的 'data' 字段主体"""
|
||||
|
||||
id: str
|
||||
username: str
|
||||
identify_num: str
|
||||
nickname: str
|
||||
bot: bool
|
||||
online: bool
|
||||
status: int
|
||||
bot_status: int
|
||||
avatar: str
|
||||
vip_avatar: str | None = None
|
||||
banner: str | None = None
|
||||
roles: list[Any] = Field(default_factory=list)
|
||||
is_vip: bool
|
||||
vip_amp: bool
|
||||
wealth_level: int
|
||||
mobile_verified: bool
|
||||
client_id: str
|
||||
tag_info: KookUserTag | None = None
|
||||
|
||||
|
||||
class KookUserMeResponse(KookApiResponseBase):
|
||||
"""USER_ME 完整响应结构"""
|
||||
|
||||
data: KookUserMeData
|
||||
|
||||
|
||||
class KookGatewayIndexData(KookBaseDataClass):
|
||||
url: str
|
||||
|
||||
|
||||
class KookGatewayIndexResponse(KookApiResponseBase):
|
||||
"""USER_ME 完整响应结构"""
|
||||
|
||||
data: KookGatewayIndexData
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user