mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-02 02:30:16 +08:00
Compare commits
1 Commits
dev
...
perf/tenac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
572689b416 |
@@ -16,11 +16,9 @@ venv*/
|
||||
ENV/
|
||||
.conda/
|
||||
dashboard/
|
||||
!astrbot/dashboard/
|
||||
!astrbot/dashboard/dist/
|
||||
!astrbot/dashboard/dist/**
|
||||
data/
|
||||
changelogs/
|
||||
tests/
|
||||
.ruff_cache/
|
||||
.astrbot
|
||||
astrbot.lock
|
||||
astrbot.lock
|
||||
184
.env.example
184
.env.example
@@ -1,184 +0,0 @@
|
||||
# ==========================================
|
||||
# AstrBot Instance Configuration: ${INSTANCE_NAME}
|
||||
# AstrBot 实例配置文件:${INSTANCE_NAME}
|
||||
# ==========================================
|
||||
# 将此文件复制为 .env 并根据需要修改。
|
||||
# Copy this file to .env and modify as needed.
|
||||
# 注意:在此处设置的变量将覆盖默认配置。
|
||||
# Note: Variables set here override application defaults.
|
||||
|
||||
# ------------------------------------------
|
||||
# 实例标识 / Instance Identity
|
||||
# ------------------------------------------
|
||||
|
||||
# 实例名称(用于日志和服务名)
|
||||
# Instance name (used in logs/service names)
|
||||
INSTANCE_NAME="${INSTANCE_NAME}"
|
||||
|
||||
# ------------------------------------------
|
||||
# 核心配置 / Core Configuration
|
||||
# ------------------------------------------
|
||||
|
||||
# AstrBot 根目录路径
|
||||
# AstrBot root directory path
|
||||
# 默认 Default: 当前工作目录,桌面客户端为 ~/.astrbot,服务器为 /var/lib/astrbot/<instance>/
|
||||
# 示例 Example: /var/lib/astrbot/mybot
|
||||
ASTRBOT_ROOT="${ASTRBOT_ROOT}"
|
||||
|
||||
# 日志等级
|
||||
# Log level
|
||||
# 可选值 Values: DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||
# 默认 Default: INFO
|
||||
# ASTRBOT_LOG_LEVEL=INFO
|
||||
|
||||
# 启用插件热重载(开发时有用)
|
||||
# Enable plugin hot reload (useful for development)
|
||||
# 可选值 Values: 0 (禁用 disabled), 1 (启用 enabled)
|
||||
# 默认 Default: 0
|
||||
# ASTRBOT_RELOAD=0
|
||||
|
||||
# 禁用匿名使用统计
|
||||
# Disable anonymous usage statistics
|
||||
# 可选值 Values: 0 (启用统计 enabled), 1 (禁用统计 disabled)
|
||||
# 默认 Default: 0
|
||||
ASTRBOT_DISABLE_METRICS=0
|
||||
|
||||
# 覆盖 Python 可执行文件路径(用于本地代码执行功能)
|
||||
# Override Python executable path (for local code execution)
|
||||
# 示例 Example: /usr/bin/python3, /home/user/.pyenv/shims/python
|
||||
# PYTHON=/usr/bin/python3
|
||||
|
||||
# 启用演示模式(可能限制部分功能)
|
||||
# Enable demo mode (may restrict certain features)
|
||||
# 可选值 Values: True, False
|
||||
# 默认 Default: False
|
||||
# DEMO_MODE=False
|
||||
|
||||
# 启用测试模式(影响日志和部分行为)
|
||||
# Enable testing mode (affects logging and behavior)
|
||||
# 可选值 Values: True, False
|
||||
# 默认 Default: False
|
||||
# TESTING=False
|
||||
|
||||
# 标记:是否通过桌面客户端执行(主要用于内部)
|
||||
# Flag: running via desktop client (internal use)
|
||||
# 可选值 Values: 0, 1
|
||||
# ASTRBOT_DESKTOP_CLIENT=0
|
||||
|
||||
# 标记:是否通过 systemd 服务执行
|
||||
# Flag: running via systemd service
|
||||
# 可选值 Values: 0, 1
|
||||
ASTRBOT_SYSTEMD=1
|
||||
|
||||
# ------------------------------------------
|
||||
# 管理面板配置 / Dashboard Configuration
|
||||
# ------------------------------------------
|
||||
|
||||
# 启用或禁用 WebUI 管理面板
|
||||
# Enable or disable WebUI dashboard
|
||||
# 可选值 Values: True, False
|
||||
# 默认 Default: True
|
||||
ASTRBOT_DASHBOARD_ENABLE=True
|
||||
|
||||
# 允许跨域请求的来源域名(多个用逗号分隔,允许所有则用 *)
|
||||
# Allowed CORS origins for WebUI dashboard (comma-separated, or * for all)
|
||||
# 示例 Example: https://dash.astrbot.men
|
||||
# 默认 Default: *
|
||||
# ASTRBOT_CORS_ALLOW_ORIGIN="*"
|
||||
|
||||
# ------------------------------------------
|
||||
# 国际化配置 / Internationalization Configuration
|
||||
# ------------------------------------------
|
||||
|
||||
# CLI 界面语言
|
||||
# CLI interface language
|
||||
# 可选值 Values: zh (中文), en (英文)
|
||||
# 默认 Default: zh (跟随系统 locale / follows system locale)
|
||||
# ASTRBOT_CLI_LANG=zh
|
||||
|
||||
# ------------------------------------------
|
||||
# 网络配置 / Network Configuration
|
||||
# ------------------------------------------
|
||||
|
||||
# API 绑定主机
|
||||
# API bind host
|
||||
# 示例 Example: 0.0.0.0 (所有接口 all interfaces), 127.0.0.1 (仅本地 localhost only)
|
||||
ASTRBOT_HOST="${ASTRBOT_HOST}"
|
||||
|
||||
# API 绑定端口
|
||||
# API bind port
|
||||
# 示例 Example: 3000, 6185, 8080
|
||||
ASTRBOT_PORT="${ASTRBOT_PORT}"
|
||||
|
||||
# 是否为 API 启用 SSL/TLS
|
||||
# Enable SSL/TLS for API
|
||||
# 可选值 Values: true, false
|
||||
# 默认 Default: false
|
||||
ASTRBOT_SSL_ENABLE=false
|
||||
|
||||
# SSL 证书路径(PEM 格式)
|
||||
# SSL certificate path (PEM format)
|
||||
# 示例 Example: /etc/astrbot/certs/myinstance/fullchain.pem
|
||||
ASTRBOT_SSL_CERT=""
|
||||
|
||||
# SSL 私钥路径(PEM 格式)
|
||||
# SSL private key path (PEM format)
|
||||
# 示例 Example: /etc/astrbot/certs/myinstance/privkey.pem
|
||||
ASTRBOT_SSL_KEY=""
|
||||
|
||||
# SSL CA 证书链路径(可选,用于客户端验证)
|
||||
# SSL CA certificates bundle (optional, for client verification)
|
||||
# 示例 Example: /etc/ssl/certs/ca-certificates.crt
|
||||
ASTRBOT_SSL_CA_CERTS=""
|
||||
|
||||
# ------------------------------------------
|
||||
# 代理配置 / Proxy Configuration
|
||||
# ------------------------------------------
|
||||
|
||||
# HTTP 代理地址
|
||||
# HTTP proxy URL
|
||||
# 示例 Example: http://127.0.0.1:7890, socks5://127.0.0.1:1080
|
||||
# http_proxy=
|
||||
|
||||
# HTTPS 代理地址
|
||||
# HTTPS proxy URL
|
||||
# 示例 Example: http://127.0.0.1:7890, socks5://127.0.0.1:1080
|
||||
# https_proxy=
|
||||
|
||||
# 不走代理的主机列表(逗号分隔)
|
||||
# Hosts to bypass proxy (comma-separated)
|
||||
# 示例 Example: localhost,127.0.0.1,192.168.0.0/16,.local
|
||||
# no_proxy=localhost,127.0.0.1
|
||||
|
||||
# ------------------------------------------
|
||||
# 第三方集成 / Third-party Integrations
|
||||
# ------------------------------------------
|
||||
|
||||
# 阿里云 DashScope API 密钥(用于 Rerank 服务)
|
||||
# Alibaba DashScope API Key (for Rerank service)
|
||||
# 获取地址 Get from: https://dashscope.console.aliyun.com/
|
||||
# 示例 Example: sk-xxxxxxxxxxxx
|
||||
# DASHSCOPE_API_KEY=
|
||||
|
||||
# Coze 集成
|
||||
# Coze integration
|
||||
# 获取地址 Get from: https://www.coze.com/
|
||||
# COZE_API_KEY=
|
||||
# COZE_BOT_ID=
|
||||
|
||||
# 计算机控制相关的数据目录(用于截图/文件存储)
|
||||
# Computer control data directory (for screenshots/file storage)
|
||||
# 示例 Example: /var/lib/astrbot/bay_data
|
||||
# BAY_DATA_DIR=
|
||||
|
||||
# ------------------------------------------
|
||||
# 平台特定配置 / Platform-specific Configuration
|
||||
# ------------------------------------------
|
||||
|
||||
# QQ 官方机器人测试模式开关
|
||||
# QQ official bot test mode
|
||||
# 可选值 Values: on, off
|
||||
# 默认 Default: off
|
||||
# TEST_MODE=off
|
||||
|
||||
# End of template / 模板结束
|
||||
26
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
26
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
@@ -1,40 +1,42 @@
|
||||
|
||||
name: '🎉 Feature Request / 功能建议'
|
||||
name: '🎉 功能建议'
|
||||
title: "[Feature]"
|
||||
description: Submit a suggestion to help us improve. / 提交建议帮助我们改进。
|
||||
description: 提交建议帮助我们改进。
|
||||
labels: [ "enhancement" ]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for taking the time to suggest a new feature! Please explain your idea clearly and accurately. / 感谢您抽出时间提出新功能建议,请准确解释您的想法。
|
||||
感谢您抽出时间提出新功能建议,请准确解释您的想法。
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description / 描述
|
||||
description: Please describe the feature you want to be added in detail. / 请详细描述您希望添加的功能。
|
||||
label: 描述
|
||||
description: 简短描述您的功能建议。
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Use Case / 使用场景
|
||||
description: Please describe the use case for this feature. / 请描述这个功能的使用场景。
|
||||
label: 使用场景
|
||||
description: 你想要发生什么?
|
||||
placeholder: >
|
||||
一个清晰且具体的描述这个功能的使用场景。
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Willing to Submit PR? / 是否愿意提交PR?
|
||||
label: 你愿意提交PR吗?
|
||||
description: >
|
||||
This is not required, but if you are willing to submit a PR to implement this feature, it would be greatly appreciated! / 这不是必需的,但如果您愿意提交 PR 来实现这个功能,我们将不胜感激!
|
||||
这不是必须的,但我们欢迎您的贡献。
|
||||
options:
|
||||
- label: Yes, I am willing to submit a PR. / 是的,我愿意提交 PR。
|
||||
- label: 是的, 我愿意提交PR!
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Code of Conduct
|
||||
options:
|
||||
- label: >
|
||||
I have read and agree to abide by the project's [Code of Conduct](https://docs.github.com/zh/site-policy/github-terms/github-community-code-of-conduct). /
|
||||
我已阅读并同意遵守该项目的 [行为准则](https://docs.github.com/zh/site-policy/github-terms/github-community-code-of-conduct)。
|
||||
required: true
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "Thank you for filling out our form!"
|
||||
value: "感谢您填写我们的表单!"
|
||||
17
.github/PULL_REQUEST_TEMPLATE.md
vendored
17
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -3,8 +3,8 @@
|
||||
|
||||
### Modifications / 改动点
|
||||
|
||||
<!--Please summarize your changes: What core files were modified? What functionality was implemented?-->
|
||||
<!--请总结你的改动:哪些核心文件被修改了?实现了什么功能?-->
|
||||
<!--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,14 +21,7 @@
|
||||
<!--If merged, your code will serve tens of thousands of users! Please double-check the following items before submitting.-->
|
||||
<!--如果分支被合并,您的代码将服务于数万名用户!在提交前,请核查一下几点内容。-->
|
||||
|
||||
- [ ] 😊 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.
|
||||
/ 我的更改没有引入恶意代码。
|
||||
- [ ] 😊 如果 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.
|
||||
|
||||
49
.github/workflows/build-docs.yml
vendored
49
.github/workflows/build-docs.yml
vendored
@@ -1,49 +0,0 @@
|
||||
name: Build and Deploy AstrBot Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest # 运行环境
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v7
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
with:
|
||||
version: 10.28.2
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24.13.0"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: docs/pnpm-lock.yaml
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
working-directory: './docs'
|
||||
- name: Build docs
|
||||
run: pnpm 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/
|
||||
4
.github/workflows/code-format.yml
vendored
4
.github/workflows/code-format.yml
vendored
@@ -12,12 +12,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Install UV
|
||||
run: pip install uv
|
||||
|
||||
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
@@ -56,7 +56,7 @@ jobs:
|
||||
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
|
||||
7
.github/workflows/coverage_test.yml
vendored
7
.github/workflows/coverage_test.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -37,10 +37,9 @@ jobs:
|
||||
mkdir -p data/temp
|
||||
export TESTING=true
|
||||
export ZHIPU_API_KEY=${{ secrets.OPENAI_API_KEY }}
|
||||
pytest --cov=astrbot -v -o log_cli=true -o log_level=DEBUG
|
||||
pytest --cov=. -v -o log_cli=true -o log_level=DEBUG
|
||||
|
||||
- name: Upload results to Codecov
|
||||
if: github.repository == 'AstrBotDevs/AstrBot'
|
||||
uses: codecov/codecov-action@v7
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
23
.github/workflows/dashboard_ci.yml
vendored
23
.github/workflows/dashboard_ci.yml
vendored
@@ -8,29 +8,22 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: github.repository == 'AstrBotDevs/AstrBot'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
with:
|
||||
version: 10.28.2
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.13.0'
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: dashboard/pnpm-lock.yaml
|
||||
|
||||
- name: Install and Build
|
||||
working-directory: dashboard
|
||||
- name: npm install, build
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm lint:check
|
||||
cd dashboard
|
||||
npm install pnpm -g
|
||||
pnpm install
|
||||
pnpm i --save-dev @types/markdown-it
|
||||
pnpm run build
|
||||
|
||||
- name: Inject Commit SHA
|
||||
@@ -43,7 +36,7 @@ jobs:
|
||||
zip -r dist.zip dist
|
||||
|
||||
- name: Archive production artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: dist-without-markdown
|
||||
path: |
|
||||
@@ -52,7 +45,7 @@ jobs:
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: github.event_name == 'push'
|
||||
uses: ncipollo/release-action@v1.21.0
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
tag: release-${{ github.sha }}
|
||||
owner: AstrBotDevs
|
||||
|
||||
48
.github/workflows/docker-image.yml
vendored
48
.github/workflows/docker-image.yml
vendored
@@ -11,7 +11,7 @@ on:
|
||||
|
||||
jobs:
|
||||
build-nightly-image:
|
||||
if: github.repository == 'AstrBotDevs/AstrBot' && github.event_name == 'schedule'
|
||||
if: github.event_name == 'schedule'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-tag: true
|
||||
@@ -46,21 +46,14 @@ jobs:
|
||||
|
||||
- name: Build Dashboard
|
||||
run: |
|
||||
dashboard_version=$(python3 - <<'PY'
|
||||
import tomllib
|
||||
with open("pyproject.toml", "rb") as f:
|
||||
print("v" + tomllib.load(f)["project"]["version"])
|
||||
PY
|
||||
)
|
||||
cd dashboard
|
||||
npm install
|
||||
npm run build
|
||||
mkdir -p dist/assets
|
||||
echo "$dashboard_version" > dist/assets/version
|
||||
echo $(git rev-parse HEAD) > dist/assets/version
|
||||
cd ..
|
||||
mkdir -p astrbot/dashboard
|
||||
rm -rf astrbot/dashboard/dist
|
||||
cp -r dashboard/dist astrbot/dashboard/dist
|
||||
mkdir -p data
|
||||
cp -r dashboard/dist data/
|
||||
|
||||
- name: Determine test image tags
|
||||
id: test-meta
|
||||
@@ -71,20 +64,20 @@ jobs:
|
||||
echo "build_date=$build_date" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set QEMU
|
||||
uses: docker/setup-qemu-action@v4.1.0
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4.1.0
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to DockerHub
|
||||
uses: docker/login-action@v4.2.0
|
||||
uses: docker/login-action@v3
|
||||
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@v4.2.0
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ env.GHCR_OWNER }}
|
||||
@@ -105,7 +98,7 @@ jobs:
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and Push Nightly Image
|
||||
uses: docker/build-push-action@v7.2.0
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@@ -116,7 +109,7 @@ jobs:
|
||||
run: echo "Test Docker image has been built and pushed successfully"
|
||||
|
||||
build-release-image:
|
||||
if: github.repository == 'AstrBotDevs/AstrBot' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')))
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v'))
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
@@ -125,7 +118,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-tag: true
|
||||
@@ -164,34 +157,33 @@ jobs:
|
||||
npm install
|
||||
npm run build
|
||||
mkdir -p dist/assets
|
||||
echo "${{ steps.release-meta.outputs.version }}" > dist/assets/version
|
||||
echo $(git rev-parse HEAD) > dist/assets/version
|
||||
cd ..
|
||||
mkdir -p astrbot/dashboard
|
||||
rm -rf astrbot/dashboard/dist
|
||||
cp -r dashboard/dist astrbot/dashboard/dist
|
||||
mkdir -p data
|
||||
cp -r dashboard/dist data/
|
||||
|
||||
- name: Set QEMU
|
||||
uses: docker/setup-qemu-action@v4.1.0
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4.1.0
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to DockerHub
|
||||
uses: docker/login-action@v4.2.0
|
||||
uses: docker/login-action@v3
|
||||
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@v4.2.0
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ env.GHCR_OWNER }}
|
||||
password: ${{ secrets.GHCR_GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and Push Release Image
|
||||
uses: docker/build-push-action@v7.2.0
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
251
.github/workflows/release.yml
vendored
251
.github/workflows/release.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Release AstrBot
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -20,7 +20,6 @@ permissions:
|
||||
jobs:
|
||||
build-dashboard:
|
||||
name: Build Dashboard
|
||||
if: github.repository == 'AstrBotDevs/AstrBot'
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
@@ -28,7 +27,7 @@ jobs:
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
@@ -51,48 +50,38 @@ jobs:
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.28.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24.13.0"
|
||||
node-version: '24.13.0'
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: dashboard/pnpm-lock.yaml
|
||||
|
||||
- name: Build dashboard dist
|
||||
shell: bash
|
||||
working-directory: dashboard
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm run build
|
||||
echo "${{ steps.tag.outputs.tag }}" > dist/assets/version
|
||||
pnpm --dir dashboard install --frozen-lockfile
|
||||
pnpm --dir dashboard run build
|
||||
echo "${{ steps.tag.outputs.tag }}" > dashboard/dist/assets/version
|
||||
cd dashboard
|
||||
zip -r "AstrBot-${{ steps.tag.outputs.tag }}-dashboard.zip" dist
|
||||
|
||||
- name: Build core package
|
||||
shell: bash
|
||||
run: |
|
||||
git archive \
|
||||
--format=zip \
|
||||
--prefix="AstrBot-${{ steps.tag.outputs.tag }}/" \
|
||||
--output="AstrBot-${{ steps.tag.outputs.tag }}-core.zip" \
|
||||
HEAD
|
||||
|
||||
- name: Upload dashboard artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: Dashboard-${{ steps.tag.outputs.tag }}
|
||||
if-no-files-found: error
|
||||
path: dashboard/AstrBot-${{ steps.tag.outputs.tag }}-dashboard.zip
|
||||
|
||||
- name: Upload release packages to Cloudflare R2
|
||||
- name: Upload dashboard package to Cloudflare R2
|
||||
if: ${{ env.R2_ACCOUNT_ID != '' && env.R2_ACCESS_KEY_ID != '' && env.R2_SECRET_ACCESS_KEY != '' }}
|
||||
env:
|
||||
R2_BUCKET_NAME: "astrbot"
|
||||
DASHBOARD_LATEST_OBJECT_NAME: "astrbot-webui-latest.zip"
|
||||
CORE_LATEST_OBJECT_NAME: "astrbot-core-latest.zip"
|
||||
R2_OBJECT_NAME: "astrbot-webui-latest.zip"
|
||||
VERSION_TAG: ${{ steps.tag.outputs.tag }}
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -108,27 +97,178 @@ jobs:
|
||||
endpoint = https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com
|
||||
EOF
|
||||
|
||||
cp "dashboard/AstrBot-${VERSION_TAG}-dashboard.zip" "dashboard/${DASHBOARD_LATEST_OBJECT_NAME}"
|
||||
rclone copy "dashboard/${DASHBOARD_LATEST_OBJECT_NAME}" "r2:${R2_BUCKET_NAME}" --progress
|
||||
cp "dashboard/AstrBot-${VERSION_TAG}-dashboard.zip" "dashboard/${R2_OBJECT_NAME}"
|
||||
rclone copy "dashboard/${R2_OBJECT_NAME}" "r2:${R2_BUCKET_NAME}" --progress
|
||||
cp "dashboard/AstrBot-${VERSION_TAG}-dashboard.zip" "dashboard/astrbot-webui-${VERSION_TAG}.zip"
|
||||
rclone copy "dashboard/astrbot-webui-${VERSION_TAG}.zip" "r2:${R2_BUCKET_NAME}" --progress
|
||||
|
||||
cp "AstrBot-${VERSION_TAG}-core.zip" "${CORE_LATEST_OBJECT_NAME}"
|
||||
rclone copy "${CORE_LATEST_OBJECT_NAME}" "r2:${R2_BUCKET_NAME}" --progress
|
||||
cp "AstrBot-${VERSION_TAG}-core.zip" "astrbot-core-${VERSION_TAG}.zip"
|
||||
rclone copy "astrbot-core-${VERSION_TAG}.zip" "r2:${R2_BUCKET_NAME}" --progress
|
||||
rclone copyto "AstrBot-${VERSION_TAG}-core.zip" "r2:${R2_BUCKET_NAME}/astrbot-core/${VERSION_TAG}/source.zip" --progress
|
||||
rclone copyto "AstrBot-${VERSION_TAG}-core.zip" "r2:${R2_BUCKET_NAME}/download/astrbot-core/${VERSION_TAG}/source.zip" --progress
|
||||
build-desktop:
|
||||
name: Build ${{ matrix.name }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: linux-x64
|
||||
runner: ubuntu-24.04
|
||||
os: linux
|
||||
arch: amd64
|
||||
- name: linux-arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
os: linux
|
||||
arch: arm64
|
||||
- name: windows-x64
|
||||
runner: windows-2022
|
||||
os: win
|
||||
arch: amd64
|
||||
- name: windows-arm64
|
||||
runner: windows-11-arm
|
||||
os: win
|
||||
arch: arm64
|
||||
- name: macos-x64
|
||||
runner: macos-15-intel
|
||||
os: mac
|
||||
arch: amd64
|
||||
- name: macos-arm64
|
||||
runner: macos-15
|
||||
os: mac
|
||||
arch: arm64
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
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: Setup uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.28.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.13.0'
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: |
|
||||
dashboard/pnpm-lock.yaml
|
||||
desktop/pnpm-lock.yaml
|
||||
|
||||
- name: Prepare OpenSSL for Windows ARM64
|
||||
if: ${{ matrix.os == 'win' && matrix.arch == 'arm64' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
git clone https://github.com/microsoft/vcpkg.git C:\vcpkg
|
||||
& C:\vcpkg\bootstrap-vcpkg.bat -disableMetrics
|
||||
& C:\vcpkg\vcpkg.exe install openssl:arm64-windows
|
||||
|
||||
"VCPKG_ROOT=C:\vcpkg" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"VCPKGRS_TRIPLET=arm64-windows" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"OPENSSL_DIR=C:\vcpkg\installed\arm64-windows" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"OPENSSL_ROOT_DIR=C:\vcpkg\installed\arm64-windows" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"OPENSSL_LIB_DIR=C:\vcpkg\installed\arm64-windows\lib" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"OPENSSL_INCLUDE_DIR=C:\vcpkg\installed\arm64-windows\include" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
uv sync
|
||||
pnpm --dir dashboard install --frozen-lockfile
|
||||
pnpm --dir desktop install --frozen-lockfile
|
||||
|
||||
- name: Build desktop package
|
||||
shell: bash
|
||||
run: |
|
||||
pnpm --dir dashboard run build
|
||||
pnpm --dir desktop run build:webui
|
||||
pnpm --dir desktop run build:backend
|
||||
pnpm --dir desktop run sync:version
|
||||
pnpm --dir desktop exec electron-builder --publish never
|
||||
|
||||
- name: Normalize artifact names
|
||||
shell: bash
|
||||
env:
|
||||
NAME_PREFIX: AstrBot-${{ steps.tag.outputs.tag }}-${{ matrix.arch }}-${{ matrix.os }}
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
out_dir="desktop/dist/release"
|
||||
mkdir -p "$out_dir"
|
||||
files=(
|
||||
desktop/dist/*.AppImage
|
||||
desktop/dist/*.dmg
|
||||
desktop/dist/*.zip
|
||||
desktop/dist/*.exe
|
||||
)
|
||||
if [ ${#files[@]} -eq 0 ]; then
|
||||
echo "No desktop artifacts found to rename." >&2
|
||||
exit 1
|
||||
fi
|
||||
for src in "${files[@]}"; do
|
||||
file="$(basename "$src")"
|
||||
case "$file" in
|
||||
*.AppImage)
|
||||
dest="$out_dir/${NAME_PREFIX}.AppImage"
|
||||
;;
|
||||
*.dmg)
|
||||
dest="$out_dir/${NAME_PREFIX}.dmg"
|
||||
;;
|
||||
*.exe)
|
||||
dest="$out_dir/${NAME_PREFIX}.exe"
|
||||
;;
|
||||
*.zip)
|
||||
dest="$out_dir/${NAME_PREFIX}.zip"
|
||||
;;
|
||||
*)
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
cp "$src" "$dest"
|
||||
done
|
||||
ls -la "$out_dir"
|
||||
|
||||
- name: Upload desktop artifacts
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: AstrBot-${{ steps.tag.outputs.tag }}-${{ matrix.arch }}-${{ matrix.os }}
|
||||
if-no-files-found: error
|
||||
path: desktop/dist/release/*
|
||||
|
||||
publish-release:
|
||||
name: Publish GitHub Release
|
||||
if: github.repository == 'AstrBotDevs/AstrBot'
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- build-dashboard
|
||||
- build-desktop
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
@@ -151,11 +291,18 @@ jobs:
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download dashboard artifact
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: Dashboard-${{ steps.tag.outputs.tag }}
|
||||
path: release-assets
|
||||
|
||||
- name: Download desktop artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: AstrBot-${{ steps.tag.outputs.tag }}-*
|
||||
path: release-assets
|
||||
merge-multiple: true
|
||||
|
||||
- name: Resolve release notes
|
||||
id: notes
|
||||
shell: bash
|
||||
@@ -201,47 +348,15 @@ jobs:
|
||||
|
||||
publish-pypi:
|
||||
name: Publish PyPI
|
||||
if: github.repository == 'AstrBotDevs/AstrBot'
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- publish-release
|
||||
needs: publish-release
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
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:
|
||||
@@ -253,8 +368,6 @@ 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
|
||||
|
||||
17
.github/workflows/smoke_test.yml
vendored
17
.github/workflows/smoke_test.yml
vendored
@@ -5,9 +5,9 @@ on:
|
||||
branches:
|
||||
- master
|
||||
paths-ignore:
|
||||
- "README*.md"
|
||||
- "changelogs/**"
|
||||
- "dashboard/**"
|
||||
- 'README*.md'
|
||||
- 'changelogs/**'
|
||||
- 'dashboard/**'
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -16,18 +16,18 @@ jobs:
|
||||
name: Run smoke tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- 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,9 +40,6 @@ 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..."
|
||||
|
||||
1
.github/workflows/stale.yml
vendored
1
.github/workflows/stale.yml
vendored
@@ -18,7 +18,6 @@ on:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
if: github.repository == 'AstrBotDevs/AstrBot'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
69
.github/workflows/sync-wiki.yml
vendored
69
.github/workflows/sync-wiki.yml
vendored
@@ -1,69 +0,0 @@
|
||||
name: Sync AstrBot Docs to GitHub 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:
|
||||
if: github.repository == 'AstrBotDevs/AstrBot'
|
||||
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@v7
|
||||
|
||||
- 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
|
||||
37
.github/workflows/unit_tests.yml
vendored
37
.github/workflows/unit_tests.yml
vendored
@@ -1,37 +0,0 @@
|
||||
name: Unit Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths-ignore:
|
||||
- 'README*.md'
|
||||
- 'changelogs/**'
|
||||
- 'dashboard/**'
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
unit-tests:
|
||||
name: Run pytest suite
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install uv
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
chmod +x scripts/run_pytests_ci.sh
|
||||
bash ./scripts/run_pytests_ci.sh ./tests
|
||||
50
.gitignore
vendored
50
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
# Python related
|
||||
__pycache__
|
||||
.mypy_cache
|
||||
.venv*
|
||||
.conda/
|
||||
uv.lock
|
||||
@@ -32,12 +33,16 @@ tests/astrbot_plugin_openai
|
||||
dashboard/node_modules/
|
||||
dashboard/dist/
|
||||
.pnpm-store/
|
||||
desktop/node_modules/
|
||||
desktop/dist/
|
||||
desktop/out/
|
||||
desktop/resources/backend/astrbot-backend*
|
||||
desktop/resources/backend/*.exe
|
||||
desktop/resources/webui/*
|
||||
desktop/resources/.pyinstaller/
|
||||
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
|
||||
@@ -50,46 +55,9 @@ astrbot.lock
|
||||
chroma
|
||||
venv/*
|
||||
pytest.ini
|
||||
AGENTS.md
|
||||
IFLOW.md
|
||||
CLAUDE.md
|
||||
|
||||
# genie_tts data
|
||||
CharacterModels/
|
||||
GenieData/
|
||||
.agent/
|
||||
.codex/
|
||||
.claude/
|
||||
.opencode/
|
||||
.kilocode/
|
||||
.serena
|
||||
.worktrees/
|
||||
|
||||
.astrbot_sdk_testing/
|
||||
.env
|
||||
dashboard/warker.js
|
||||
dashboard/bun.lock
|
||||
.pua/
|
||||
|
||||
# Rust build artifacts
|
||||
rust/target/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
*.whl
|
||||
# 拓展模块
|
||||
*.so
|
||||
*.dll
|
||||
|
||||
# MDI font subset (generated by dashboard/scripts/subset-mdi-font.mjs)
|
||||
dashboard/src/assets/mdi-subset/*.woff
|
||||
dashboard/src/assets/mdi-subset/*.woff2
|
||||
.planning
|
||||
*cache
|
||||
node_modules
|
||||
|
||||
*pinokio*
|
||||
dashboard/pnpm-lock.yaml
|
||||
.obsidian
|
||||
dashboard/.codex
|
||||
.codex
|
||||
.zed/settings.json
|
||||
|
||||
@@ -6,20 +6,20 @@ ci:
|
||||
autoupdate_schedule: weekly
|
||||
autoupdate_commit_msg: ":balloon: pre-commit autoupdate"
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.15.7
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff-check
|
||||
types_or: [python, pyi]
|
||||
args: [--fix]
|
||||
# Run the formatter.
|
||||
- id: ruff-format
|
||||
types_or: [python, pyi]
|
||||
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v3.21.2
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py312-plus]
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.14.1
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff-check
|
||||
types_or: [ python, pyi ]
|
||||
args: [ --fix ]
|
||||
# Run the formatter.
|
||||
- id: ruff-format
|
||||
types_or: [ python, pyi ]
|
||||
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v3.21.0
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py310-plus]
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.12
|
||||
3.12
|
||||
249
AGENTS.md
249
AGENTS.md
@@ -3,10 +3,8 @@
|
||||
### Core
|
||||
|
||||
```
|
||||
uv tool install -e . --force
|
||||
astrbot init
|
||||
astrbot run # start the bot
|
||||
astrbot run --backend-only # start the backend only
|
||||
uv sync
|
||||
uv run main.py
|
||||
```
|
||||
|
||||
Exposed an API server on `http://localhost:6185` by default.
|
||||
@@ -15,243 +13,22 @@ Exposed an API server on `http://localhost:6185` by default.
|
||||
|
||||
```
|
||||
cd dashboard
|
||||
bun install # First time only.
|
||||
bun dev
|
||||
pnpm install # First time only. Use npm install -g pnpm if pnpm is not installed.
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Runs on `http://localhost:3000` by default.
|
||||
|
||||
## Pre-commit setup
|
||||
|
||||
AstrBot uses [pre-commit](https://pre-commit.com/) hooks to automatically format and lint Python code before each commit. The hooks run `ruff check`, `ruff format`, and `pyupgrade` (see [`.pre-commit-config.yaml`](.pre-commit-config.yaml) for details).
|
||||
|
||||
To set it up:
|
||||
|
||||
```bash
|
||||
pip install pre-commit
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
After installation, the hooks will run automatically on `git commit`. You can also run them manually at any time:
|
||||
|
||||
```bash
|
||||
ruff format .
|
||||
ruff check .
|
||||
```
|
||||
|
||||
> **Note:** If you use VSCode, install the `Ruff` extension for real-time formatting and linting in the editor.
|
||||
|
||||
## Dev environment tips
|
||||
|
||||
- **Main entry**: `astrbot/__main__.py` or via CLI `astrbot run`
|
||||
- **CLI commands**: `astrbot/cli/commands/`
|
||||
- **Core modules**: `astrbot/core/`
|
||||
- **Platform adapters**: `astrbot/core/platform/sources/`
|
||||
- **Star plugins**: `astrbot/builtin_stars/`
|
||||
- **Dashboard**: `dashboard/` (Vue.js frontend)
|
||||
1. When modifying the WebUI, be sure to maintain componentization and clean code. Avoid duplicate code.
|
||||
2. Do not add any report files such as xxx_SUMMARY.md.
|
||||
3. After finishing, use `ruff format .` and `ruff check .` to format and check the code.
|
||||
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.
|
||||
|
||||
## File Organization
|
||||
## PR instructions
|
||||
|
||||
```
|
||||
astrbot/
|
||||
├── __main__.py # Main entry point
|
||||
├── __init__.py # Package init, exports
|
||||
├── cli/ # CLI commands
|
||||
│ └── commands/ # Individual command modules
|
||||
├── core/ # Core functionality
|
||||
│ ├── agent/ # Agent execution
|
||||
│ ├── platform/ # Platform adapters
|
||||
│ ├── pipeline/ # Message processing
|
||||
│ ├── star/ # Plugin system
|
||||
│ └── config/ # Configuration
|
||||
├── builtin_stars/ # Built-in plugins
|
||||
├── dashboard/ # Vue.js frontend
|
||||
└── utils/ # Utilities
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
- `astrbot/core/` - Core bot functionality
|
||||
- `astrbot/core/platform/` - Platform adapter system
|
||||
- `astrbot/core/agent/` - Agent execution logic
|
||||
- `astrbot/core/star/` - Plugin/Star handler system
|
||||
- `astrbot/core/pipeline/` - Message processing pipeline
|
||||
- `astrbot/cli/` - Command-line interface
|
||||
|
||||
### Important Utilities
|
||||
|
||||
```python
|
||||
from astrbot.core.utils.astrbot_path import (
|
||||
get_astrbot_root, # AstrBot root directory
|
||||
get_astrbot_data_path, # Data directory
|
||||
get_astrbot_config_path, # Config directory
|
||||
get_astrbot_plugin_path, # Plugin directory
|
||||
get_astrbot_temp_path, # Temp directory
|
||||
get_astrbot_skills_path, # Skills directory
|
||||
)
|
||||
```
|
||||
|
||||
### Platform Adapters
|
||||
|
||||
Platform adapters are in `astrbot/core/platform/sources/`:
|
||||
- Each adapter extends base platform classes
|
||||
- Use `@register_platform_adapter` decorator
|
||||
- Events flow through `commit_event()` to message queue
|
||||
|
||||
### Star (Plugin) System
|
||||
|
||||
Stars are plugins in `astrbot/builtin_stars/`:
|
||||
- Extend `Star` base class
|
||||
- Use decorators for command handlers: `@star.on_command`, `@star.on_message`, etc.
|
||||
- Access via `context` object
|
||||
|
||||
## Code Style
|
||||
|
||||
1. **Type hints required** - Use Python 3.12+ syntax:
|
||||
- `list[str]` not `List[str]`
|
||||
- `int | None` not `Optional[int]`
|
||||
- Avoid `Any` when possible. Use proper `TypedDict`, `dataclass`, or `Protocol` instead.
|
||||
- When encountering dict access issues (e.g., `msg.get("key")` where type inference is wrong), define a `TypedDict` with `total=False` to explicitly declare allowed keys.
|
||||
|
||||
Good example:
|
||||
```python
|
||||
class MessageComponent(TypedDict, total=False):
|
||||
type: str
|
||||
text: str
|
||||
path: str
|
||||
```
|
||||
|
||||
Bad example (avoid):
|
||||
```python
|
||||
msg: Any = something
|
||||
msg = cast(dict, msg)
|
||||
```
|
||||
|
||||
2. **Path handling** - Always use `pathlib.Path`:
|
||||
```python
|
||||
from pathlib import Path
|
||||
# Use astrbot.core.utils.path_utils for data/temp directories
|
||||
from astrbot.core.utils.path_utils import get_astrbot_data_path
|
||||
```
|
||||
|
||||
3. **Formatting** - Run before committing:
|
||||
```bash
|
||||
ruff format .
|
||||
ruff check .
|
||||
```
|
||||
|
||||
4. **Comments** - Use English for all comments and docstrings
|
||||
|
||||
5. **Imports** - Use absolute imports via `astrbot.` prefix
|
||||
|
||||
### Environment Variables
|
||||
|
||||
When adding new environment variables:
|
||||
|
||||
1. Use `ASTRBOT_` prefix: `ASTRBOT_ENABLE_FEATURE`
|
||||
2. Add to `.env.example` with description
|
||||
3. Update `astrbot/cli/commands/cmd_run.py`:
|
||||
- Add to module docstring under "Environment Variables Used in Project"
|
||||
- Add to `keys_to_print` list for debug output
|
||||
|
||||
## Testing
|
||||
|
||||
1. Tests go in `tests/` directory
|
||||
2. Use `pytest` with `pytest-asyncio`
|
||||
3. Run: `uv sync --group dev && uv run pytest --cov=astrbot tests/`
|
||||
4. Test files: `test_*.py` or `*_test.py`
|
||||
|
||||
### Code Quality Scoring Test
|
||||
|
||||
The project enforces a **code quality score** via `tests/test_code_quality_typing.py`. All agents must treat this as a hard constraint when modifying code.
|
||||
|
||||
**Run the test:**
|
||||
```bash
|
||||
uv run pytest tests/test_code_quality_typing.py -v
|
||||
```
|
||||
|
||||
**Scoring rules (target: 100/100, threshold for PASS: 80/100):**
|
||||
|
||||
| Pattern | Cost |
|
||||
|---------|------|
|
||||
| `cast(Any, ...)` | -1 pt each |
|
||||
| `# type: ignore` | -0.5 pt each |
|
||||
| **BAD** `# type: ignore[...]` (unresolved-import, class-alias, no-name-module, attr-defined, etc.) | **-3 pt each** |
|
||||
| `bare except:` (no exception type) | -0.5 pt each |
|
||||
| Duplicate code block (5+ identical lines, ≥2 occurrences) | -2 pt each |
|
||||
|
||||
**Why bad type: ignore is heavily penalized:**
|
||||
- `# type: ignore[unresolved-import]` — hides missing module/stub issues
|
||||
- `# type: ignore[class-alias]` — hides improper type alias patterns
|
||||
- `# type: ignore[attr-defined]` — hides missing attribute errors
|
||||
- These are **workarounds, not fixes** — they paper over real type errors
|
||||
|
||||
**Scoring formula:**
|
||||
```
|
||||
score = max(0, 100 - cast_any - type_ignore*0.5 - bad_type_ignore*3 - bare_except*0.5 - dup_blocks*2)
|
||||
```
|
||||
|
||||
**Agent rules when modifying code:**
|
||||
1. **Do not add** `# type: ignore[unresolved-import]` or `# type: ignore[class-alias]` — fix the underlying issue instead
|
||||
2. **Do not use** `cast(Any, ...)` to suppress type errors — use proper type annotations
|
||||
3. **Do not add** bare `except:` clauses — use `except SomeSpecificException:`
|
||||
4. **Do not copy-paste** 5+ line blocks — extract to a shared helper function
|
||||
5. Before committing, run the scoring test and ensure score ≥ 80
|
||||
|
||||
## Git Conventions
|
||||
|
||||
### Commit Messages
|
||||
|
||||
Use conventional commits:
|
||||
```
|
||||
feat: add new feature
|
||||
fix: resolve bug
|
||||
docs: update documentation
|
||||
refactor: restructure code
|
||||
test: add tests
|
||||
chore: maintenance tasks
|
||||
```
|
||||
|
||||
### PR Guidelines
|
||||
|
||||
1. Title: conventional commit format
|
||||
2. Description: English
|
||||
3. Target branch: `dev`
|
||||
4. Keep changes focused and atomic
|
||||
|
||||
## Project-Specific Guidelines
|
||||
|
||||
1. **No report files** - Do not add `xxx_SUMMARY.md` or similar
|
||||
2. **Componentization** - Maintain clean code, avoid duplication in WebUI
|
||||
3. **Backward compatibility** - When deprecating, add warnings
|
||||
4. **CLI help** - Run `astrbot help --all` to see all commands
|
||||
5. When modifying frontend/dashboard code, use the project's custom request module `@/utils/request` for HTTP calls
|
||||
6. For fetch or SSE URLs, use `resolveApiUrl('/api/your-path')` so the configured `VITE_API_BASE` and dev proxy rules are respected
|
||||
7. Do not import the plain `axios` package directly in dashboard source files
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Adding a new platform adapter
|
||||
1. Create adapter in `astrbot/core/platform/sources/`
|
||||
2. Extend `Platform` base class
|
||||
3. Use `@register_platform_adapter` decorator
|
||||
4. Implement required methods: `run()`, `convert_message()`, `meta()`
|
||||
|
||||
### Adding a new command
|
||||
1. Add to appropriate module in `cli/commands/`
|
||||
2. Register with `@click.command()`
|
||||
3. Update `astrbot/cli/__main__.py` to add command
|
||||
|
||||
### Adding a new Star handler
|
||||
1. Create in `astrbot/builtin_stars/` or as plugin
|
||||
2. Extend `Star` class
|
||||
3. Use decorators: `@star.on_command()`, `@star.on_schedule()`, etc.
|
||||
|
||||
## Release versions
|
||||
|
||||
1. Replace current version name to specific version name.
|
||||
2. Write changelog in `changelogs/`, you can refer to the full commit messages between the latest tag to the latest commit.
|
||||
3. Make and push a commit into master branch with message format like: `chore: bump version to 4.25.0`
|
||||
4. Create a tag and push the tag. For example: `git tag v4.25.0 && git push origin v4.25.0`
|
||||
1. Title format: use conventional commit messages
|
||||
2. Use English to write PR title and descriptions.
|
||||
|
||||
@@ -46,32 +46,6 @@ 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
|
||||
|
||||
@@ -114,29 +88,3 @@ 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
|
||||
```
|
||||
|
||||
@@ -12,11 +12,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
bash \
|
||||
ffmpeg \
|
||||
libavcodec-extra \
|
||||
curl \
|
||||
gnupg \
|
||||
git \
|
||||
ripgrep \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& apt-get clean \
|
||||
|
||||
@@ -11,6 +11,4 @@ As of now, AstrBot has **no commercial services of any kind**, and the official
|
||||
|
||||
If anyone asks you to pay while using AstrBot, **you are likely being scammed**. Please request a refund immediately and report it to us by email.
|
||||
|
||||
📊 Please read the [End User License Agreement](https://github.com/AstrBotDevs/AstrBot/blob/master/EULA.md) carefully before using this project. By installing, you agree to all its contents.
|
||||
|
||||
📮 Official email: [community@astrbot.app](mailto:community@astrbot.app)
|
||||
|
||||
@@ -11,6 +11,4 @@ AstrBot 是受 AGPLv3 开源协议保护的**免费开源软件项目**,您可
|
||||
|
||||
如果您在使用 AstrBot 的过程中被要求付费,**表明您已经遭遇诈骗行为**。请立即向相关方申请退款,并及时通过邮件向我们反馈。
|
||||
|
||||
📊 在使用本项目之前,请仔细阅读 [最终用户许可协议](https://github.com/AstrBotDevs/AstrBot/blob/master/EULA.md)。安装即表示您已阅读并同意其中的全部内容。
|
||||
|
||||
📮 官方邮箱:[community@astrbot.app](mailto:community@astrbot.app)
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
## Добро пожаловать в AstrBot
|
||||
|
||||
🌟 Спасибо, что используете AstrBot!
|
||||
|
||||
AstrBot — это Agentic AI-ассистент для личных и групповых чатов с поддержкой множества IM-платформ и широким набором встроенных функций. Надеемся, что он сделает ваше общение эффективным и приятным. ❤️
|
||||
|
||||
Важное уведомление:
|
||||
|
||||
AstrBot — это **бесплатный проект с открытым исходным кодом**, защищённый лицензией AGPLv3. Полный исходный код и связанные ресурсы доступны на [**официальном сайте**](https://astrbot.app) и [**GitHub**](https://github.com/astrbotdevs/astrbot).
|
||||
На данный момент AstrBot **не предоставляет никаких коммерческих услуг**, и официальная команда **никогда не будет взимать плату с пользователей** под каким-либо названием.
|
||||
|
||||
Если кто-то просит вас заплатить при использовании AstrBot, **вас, скорее всего, пытаются обмануть**. Немедленно запросите возврат средств и сообщите нам по электронной почте.
|
||||
|
||||
📊 Пожалуйста, внимательно прочитайте [Лицензионное соглашение](https://github.com/AstrBotDevs/AstrBot/blob/master/EULA.md) перед использованием. Устанавливая программу, вы соглашаетесь со всеми его условиями.
|
||||
|
||||
📮 Официальная почта: [community@astrbot.app](mailto:community@astrbot.app)
|
||||
11
Makefile
11
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: worktree worktree-add worktree-rm pr-test-neo pr-test-full pr-test-full-fast
|
||||
.PHONY: worktree worktree-add worktree-rm
|
||||
|
||||
WORKTREE_DIR ?= ../astrbot_worktree
|
||||
BRANCH ?= $(word 2,$(MAKECMDGOALS))
|
||||
@@ -27,15 +27,6 @@ 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
|
||||
|
||||
321
README.md
321
README.md
@@ -1,18 +1,15 @@
|
||||

|
||||
|
||||

|
||||
|
||||
<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_zh-TW.md">繁體中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_en.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> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
||||
|
||||
<br>
|
||||
|
||||
<div>
|
||||
<a href="https://trendshift.io/repositories/21369" target="_blank"><img src="https://trendshift.io/api/badge/repositories/21369" alt="AstrBotDevs%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<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>
|
||||
|
||||
@@ -24,43 +21,42 @@
|
||||
<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://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%E4%B8%AA&label=%E6%8F%92%E4%BB%B6%E5%B8%82%E5%9C%BA&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/">文档</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="mailto:community@astrbot.app">Email Support</a>
|
||||
<a href="https://astrbot.featurebase.app/roadmap">路线图</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/issues">问题提交</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 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、Telegram、企业微信、飞书、钉钉、Slack、等数十款主流即时通讯软件上部署,此外还内置类似 OpenWebUI 的轻量化 ChatUI,为个人、开发者和团队打造可靠、可扩展的对话式智能基础设施。无论是个人 AI 伙伴、智能客服、自动化助手,还是企业知识库,AstrBot 都能在你的即时通讯软件平台的工作流中快速构建 AI 应用。
|
||||
|
||||

|
||||

|
||||
|
||||
## 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.
|
||||
1. 💯 免费 & 开源。
|
||||
2. ✨ AI 大模型对话,多模态,Agent,MCP,Skills,知识库,人格设定,自动压缩对话。
|
||||
3. 🤖 支持接入 Dify、阿里云百炼、Coze 等智能体平台。
|
||||
4. 🌐 多平台,支持 QQ、企业微信、飞书、钉钉、微信公众号、Telegram、Slack 以及[更多](#支持的消息平台)。
|
||||
5. 📦 插件扩展,已有近 800 个插件可一键安装。
|
||||
6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) 隔离化环境,安全地执行任何代码、调用 Shell、会话级资源复用。
|
||||
7. 💻 WebUI 支持。
|
||||
8. 🌈 Web ChatUI 支持,ChatUI 内置代理沙盒、网页搜索等。
|
||||
9. 🌐 国际化(i18n)支持。
|
||||
|
||||
<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>
|
||||
<th>💙 角色扮演 & 情感陪伴</th>
|
||||
<th>✨ 主动式 Agent</th>
|
||||
<th>🚀 通用 Agentic 能力</th>
|
||||
<th>🧩 900+ 社区插件</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,158 +66,168 @@ AstrBot is an open-source all-in-one Agent chatbot platform that integrates with
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Quick Start
|
||||
## 快速开始
|
||||
|
||||
### One-Click Deployment
|
||||
#### Docker 部署(推荐 🥳)
|
||||
|
||||
For users who want to quickly experience AstrBot, are familiar with command-line usage, and can install a `uv` environment on their own, we recommend the `uv` one-click deployment method ⚡️:
|
||||
推荐使用 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 部署
|
||||
|
||||
```bash
|
||||
uv tool install astrbot --python 3.12
|
||||
astrbot init # Only execute this command for the first time to initialize the environment
|
||||
astrbot run
|
||||
uv tool install astrbot
|
||||
astrbot
|
||||
```
|
||||
|
||||
> Requires [uv](https://docs.astral.sh/uv/) to be installed.
|
||||
> AstrBot requires Python 3.12 or later. The `--python 3.12` option ensures that `uv` creates the tool environment with Python 3.12.
|
||||
#### 启动器一键部署(AstrBot Launcher)
|
||||
|
||||
> [!NOTE]
|
||||
> For macOS users: due to macOS security checks, the first run of the `astrbot` command may take longer (about 10-20s).
|
||||
进入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 仓库,在 Releases 页最新版本下找到对应的系统安装包安装即可。
|
||||
|
||||
Update `astrbot`:
|
||||
#### 宝塔面板部署
|
||||
|
||||
```bash
|
||||
uv tool upgrade astrbot --python 3.12
|
||||
```
|
||||
AstrBot 与宝塔面板合作,已上架至宝塔面板。
|
||||
|
||||
> [!WARNING]
|
||||
> AstrBot deployed via `uv` **does not support upgrading through the WebUI**. To update, please run the command above from the command line.
|
||||
请参阅官方文档 [宝塔面板部署](https://astrbot.app/deploy/astrbot/btpanel.html) 。
|
||||
|
||||
### Docker Deployment
|
||||
#### 1Panel 部署
|
||||
|
||||
For users familiar with containers and looking for a more stable, production-ready deployment method, we recommend deploying AstrBot with Docker / Docker Compose.
|
||||
AstrBot 已由 1Panel 官方上架至 1Panel 面板。
|
||||
|
||||
Please refer to the official documentation: [Deploy AstrBot with Docker](https://docs.astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
|
||||
请参阅官方文档 [1Panel 部署](https://astrbot.app/deploy/astrbot/1panel.html) 。
|
||||
|
||||
### Deploy on RainYun
|
||||
#### 在 雨云 上部署
|
||||
|
||||
For users who want one-click deployment and do not want to manage servers themselves, we recommend RainYun's one-click cloud deployment service ☁️:
|
||||
AstrBot 已由雨云官方上架至云应用平台,可一键部署。
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||
|
||||
### Desktop Application Deployment
|
||||
#### 在 Replit 上部署
|
||||
|
||||
For users who want to use AstrBot on desktop and mainly use ChatUI, we recommend AstrBot App.
|
||||
|
||||
Visit [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) to download and install; this method is designed for desktop usage and is not recommended for server scenarios.
|
||||
|
||||
### Launcher Deployment
|
||||
|
||||
For desktop users who also want fast deployment and isolated multi-instance usage, we recommend AstrBot Launcher.
|
||||
|
||||
Visit [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) to download and install.
|
||||
|
||||
### Deploy on Replit
|
||||
|
||||
Replit deployment is maintained by the community and is suitable for online demos and lightweight trials.
|
||||
社区贡献的部署方式。
|
||||
|
||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||
|
||||
### AUR
|
||||
#### Windows 一键安装器部署
|
||||
|
||||
AUR deployment targets Arch Linux users who prefer installing AstrBot through the system package workflow.
|
||||
请参阅官方文档 [使用 Windows 一键安装器部署 AstrBot](https://astrbot.app/deploy/astrbot/windows.html) 。
|
||||
|
||||
Run the command below to install `astrbot-git`, then start AstrBot in your local environment.
|
||||
#### 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
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
# 或者使用 paru
|
||||
paru -S astrbot-git
|
||||
```
|
||||
|
||||
**More deployment methods**
|
||||
#### 桌面端 Electron 打包
|
||||
|
||||
If you need panel-based management or deeper customization, see [BT-Panel Deployment](https://docs.astrbot.app/deploy/astrbot/btpanel.html) for BT Panel app-store setup, [1Panel Deployment](https://docs.astrbot.app/deploy/astrbot/1panel.html) for 1Panel app-market deployment, [CasaOS Deployment](https://docs.astrbot.app/deploy/astrbot/casaos.html) for NAS/home-server visual deployment, and [Manual Deployment](https://docs.astrbot.app/deploy/astrbot/cli.html) for fully custom source-based installation with `uv`.
|
||||
桌面端(Electron 打包,`pnpm` 工作流)构建流程请参阅:[`desktop/README.md`](desktop/README.md)。
|
||||
|
||||
## Supported Messaging Platforms
|
||||
## 支持的消息平台
|
||||
|
||||
Connect AstrBot to your favorite chat platform.
|
||||
**官方维护**
|
||||
|
||||
| Platform | Maintainer |
|
||||
|---------|---------------|
|
||||
| QQ | Official |
|
||||
| OneBot v11 protocol implementation | Official |
|
||||
| Telegram | Official |
|
||||
| Wecom & Wecom AI Bot | Official |
|
||||
| WeChat Official Accounts | Official |
|
||||
| Feishu (Lark) | Official |
|
||||
| DingTalk | Official |
|
||||
| Slack | Official |
|
||||
| Discord | Official |
|
||||
| LINE | Official |
|
||||
| Satori | Official |
|
||||
| KOOK | Official |
|
||||
| Misskey | Official |
|
||||
| Mattermost | Official |
|
||||
| WhatsApp (Coming Soon) | Official |
|
||||
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Community |
|
||||
| [Rocket.Chat](https://github.com/NET-Homeless/astrbot_plugin_rocket_chat_adapter) | Community |
|
||||
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | Community |
|
||||
- QQ (官方平台 & OneBot)
|
||||
- Telegram
|
||||
- 企微应用 & 企微智能机器人
|
||||
- 微信客服 & 微信公众号
|
||||
- 飞书
|
||||
- 钉钉
|
||||
- Slack
|
||||
- Discord
|
||||
- Satori
|
||||
- Misskey
|
||||
- Whatsapp (将支持)
|
||||
- LINE (将支持)
|
||||
|
||||
## 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 |
|
||||
| [AIHubMix](https://aihubmix.com/?aff=4bfH) | LLM Services (API Gateway, supports all models) |
|
||||
| [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 |
|
||||
| Xiaomi MiMo Omni | 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 |
|
||||
| Xiaomi MiMo TTS | Text-to-Speech Services |
|
||||
| Volcano Engine TTS | Text-to-Speech Services |
|
||||
- [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)
|
||||
|
||||
## ❤️ Sponsors
|
||||
## 支持的模型服务
|
||||
|
||||
<p align="center">
|
||||
<img alt="sponsors" src="https://sponsors.astrbot.app/?v=1">
|
||||
</p>
|
||||
**大模型服务**
|
||||
|
||||
- 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
|
||||
|
||||
## ❤️ Contributing
|
||||
**LLMOps 平台**
|
||||
|
||||
Issues and Pull Requests are always welcome! Feel free to submit your changes to this project :)
|
||||
- Dify
|
||||
- 阿里云百炼应用
|
||||
- Coze
|
||||
|
||||
### 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.
|
||||
- OpenAI Whisper
|
||||
- SenseVoice
|
||||
|
||||
### Development Environment
|
||||
**文本转语音服务**
|
||||
|
||||
AstrBot uses `ruff` for code formatting and linting.
|
||||
- OpenAI TTS
|
||||
- Gemini TTS
|
||||
- GPT-Sovits-Inference
|
||||
- GPT-Sovits
|
||||
- FishAudio
|
||||
- Edge TTS
|
||||
- 阿里云百炼 TTS
|
||||
- Azure TTS
|
||||
- Minimax TTS
|
||||
- 火山引擎 TTS
|
||||
|
||||
## ❤️ 贡献
|
||||
|
||||
欢迎任何 Issues/Pull Requests!只需要将你的更改提交到此项目 :)
|
||||
|
||||
### 如何贡献
|
||||
|
||||
你可以通过查看问题或帮助审核 PR(拉取请求)来贡献。任何问题或 PR 都欢迎参与,以促进社区贡献。当然,这些只是建议,你可以以任何方式进行贡献。对于新功能的添加,请先通过 Issue 讨论。
|
||||
|
||||
### 开发环境
|
||||
|
||||
AstrBot 使用 `ruff` 进行代码格式化和检查。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AstrBotDevs/AstrBot
|
||||
@@ -229,48 +235,42 @@ pip install pre-commit
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
## 🌍 社区
|
||||
|
||||
## 🌍 Community
|
||||
### QQ 群组
|
||||
|
||||
### QQ Groups
|
||||
- 1 群:322154837
|
||||
- 3 群:630166526
|
||||
- 5 群:822130018
|
||||
- 6 群:753075035
|
||||
- 7 群:743746109
|
||||
- 8 群:1030353265
|
||||
- 开发者群:975206796
|
||||
|
||||
- Group 1: 322154837 (Full)
|
||||
- Group 3: 630166526 (Full)
|
||||
- Group 4: 1077826412 (Full)
|
||||
- Group 5: 822130018 (Full)
|
||||
- Group 6: 753075035 (Full)
|
||||
- Group 7: 743746109 (Full)
|
||||
- Group 8: 1030353265 (Full)
|
||||
- Group 9: 1076659624 (Full)
|
||||
- Group 10: 1078079676 (Full)
|
||||
- Group 11: 704659519 (Full)
|
||||
- Group 12: 916228568 (Full)
|
||||
- Group 13: 1092185289
|
||||
- Group 14: 1103419483
|
||||
### Telegram 群组
|
||||
|
||||
- Developer Group(Chit-chat): 975206796
|
||||
- Developer Group(Formal): 1039761811
|
||||
<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
|
||||
### 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>
|
||||
|
||||
## ❤️ Special Thanks
|
||||
|
||||
Special thanks to all Contributors and plugin developers for their contributions to AstrBot ❤️
|
||||
特别感谢所有 Contributors 和插件开发者对 AstrBot 的贡献 ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=300&columns=15" />
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
||||
</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
|
||||
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - 伟大的猫猫框架
|
||||
|
||||
## ⭐ 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
|
||||
> 如果本项目对您的生活 / 工作产生了帮助,或者您关注本项目的未来发展,请给项目 Star,这是我们维护这个开源项目的动力 <3
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -280,9 +280,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._
|
||||
_陪伴与能力从来不应该是对立面。我们希望创造的是一个既能理解情绪、给予陪伴,也能可靠完成工作的机器人。_
|
||||
|
||||
_私は、高性能ですから!_
|
||||
|
||||
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
|
||||
|
||||
</div>
|
||||
|
||||
296
README_en.md
Normal file
296
README_en.md
Normal file
@@ -0,0 +1,296 @@
|
||||

|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.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 nearly 800 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>🧩 900+ 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
|
||||
```
|
||||
|
||||
#### 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
|
||||
|
||||
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).
|
||||
|
||||
#### System Package Manager Installation
|
||||
|
||||
##### Arch Linux
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
# or use paru
|
||||
paru -S astrbot-git
|
||||
```
|
||||
|
||||
#### Desktop Electron Build
|
||||
|
||||
For desktop build steps (Electron packaging, `pnpm` workflow), see [`desktop/README.md`](desktop/README.md).
|
||||
|
||||
## Supported Messaging Platforms
|
||||
|
||||
**Officially Maintained**
|
||||
|
||||
- QQ (Official Platform & OneBot)
|
||||
- Telegram
|
||||
- WeChat Work Application & WeChat Work Intelligent Bot
|
||||
- WeChat Customer Service & WeChat Official Accounts
|
||||
- Feishu (Lark)
|
||||
- DingTalk
|
||||
- Slack
|
||||
- Discord
|
||||
- Satori
|
||||
- Misskey
|
||||
- WhatsApp (Coming Soon)
|
||||
- LINE (Coming Soon)
|
||||
|
||||
**Community Maintained**
|
||||
|
||||
- [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)
|
||||
|
||||
## Supported Model Services
|
||||
|
||||
**LLM Services**
|
||||
|
||||
- OpenAI and Compatible Services
|
||||
- Anthropic
|
||||
- Google Gemini
|
||||
- Moonshot AI
|
||||
- Zhipu AI
|
||||
- DeepSeek
|
||||
- Ollama (Self-hosted)
|
||||
- LM Studio (Self-hosted)
|
||||
- [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74)
|
||||
- [302.AI](https://share.302.ai/rr1M3l)
|
||||
- [TokenPony](https://www.tokenpony.cn/3YPyf)
|
||||
- [SiliconFlow](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot)
|
||||
- [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE)
|
||||
- ModelScope
|
||||
- OneAPI
|
||||
|
||||
**LLMOps Platforms**
|
||||
|
||||
- Dify
|
||||
- Alibaba Cloud Bailian Applications
|
||||
- Coze
|
||||
|
||||
**Speech-to-Text Services**
|
||||
|
||||
- OpenAI Whisper
|
||||
- SenseVoice
|
||||
|
||||
**Text-to-Speech Services**
|
||||
|
||||
- OpenAI TTS
|
||||
- Gemini TTS
|
||||
- GPT-Sovits-Inference
|
||||
- GPT-Sovits
|
||||
- FishAudio
|
||||
- Edge TTS
|
||||
- Alibaba Cloud Bailian TTS
|
||||
- Azure TTS
|
||||
- Minimax TTS
|
||||
- Volcano Engine TTS
|
||||
|
||||
## ❤️ 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" />
|
||||
</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>
|
||||
284
README_fr.md
284
README_fr.md
@@ -2,16 +2,16 @@
|
||||
|
||||
<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.md">中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_en.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_ru.md">Русский</a>
|
||||
|
||||
<br>
|
||||
|
||||
<div>
|
||||
<a href="https://trendshift.io/repositories/21369" target="_blank"><img src="https://trendshift.io/api/badge/repositories/21369" alt="AstrBotDevs%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<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>
|
||||
|
||||
@@ -21,9 +21,9 @@
|
||||
<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%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=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=%E4%B8%AA&label=%E6%8F%92%E4%BB%B6%E5%B8%82%E5%9C%BA&cacheSeconds=3600">
|
||||
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
|
||||
</div>
|
||||
|
||||
@@ -33,12 +33,11 @@
|
||||
<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 Support</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.
|
||||
|
||||

|
||||
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" />
|
||||
|
||||
## Fonctionnalités principales
|
||||
|
||||
@@ -46,7 +45,7 @@ AstrBot est une plateforme de chatbot Agent tout-en-un open source qui s'intègr
|
||||
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.
|
||||
5. 📦 Extension par plugins, avec près de 800 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.
|
||||
7. 💻 Support WebUI.
|
||||
8. 🌈 Support Web ChatUI, avec sandbox d'agent intégrée, recherche web, etc.
|
||||
@@ -59,7 +58,7 @@ AstrBot est une plateforme de chatbot Agent tout-en-un open source qui s'intègr
|
||||
<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>🧩 900+ Plugins de communauté</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>
|
||||
@@ -71,137 +70,156 @@ AstrBot est une plateforme de chatbot Agent tout-en-un open source qui s'intègr
|
||||
|
||||
## Démarrage rapide
|
||||
|
||||
### Déploiement en un clic
|
||||
#### Déploiement Docker (Recommandé 🥳)
|
||||
|
||||
Pour les utilisateurs qui veulent découvrir AstrBot rapidement, qui sont familiers avec la ligne de commande et peuvent installer eux-mêmes l'environnement `uv`, nous recommandons la méthode de déploiement en un clic avec `uv` ⚡️ :
|
||||
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
|
||||
|
||||
```bash
|
||||
uv tool install astrbot --python 3.12
|
||||
astrbot init # Exécutez cette commande uniquement la première fois pour initialiser l'environnement
|
||||
astrbot run
|
||||
uv tool install astrbot
|
||||
astrbot
|
||||
```
|
||||
|
||||
> [uv](https://docs.astral.sh/uv/) doit être installé.
|
||||
> AstrBot nécessite Python 3.12 ou une version plus récente. L'option `--python 3.12` garantit que `uv` crée l'environnement tool avec Python 3.12.
|
||||
#### Installation via le gestionnaire de paquets du système
|
||||
|
||||
> [!NOTE]
|
||||
> Pour les utilisateurs macOS : en raison des vérifications de sécurité de macOS, la première exécution de la commande `astrbot` peut prendre plus de temps (environ 10-20s).
|
||||
|
||||
Mettre à jour `astrbot` :
|
||||
|
||||
```bash
|
||||
uv tool upgrade astrbot --python 3.12
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> AstrBot déployé via `uv` **ne prend pas en charge la mise à jour via le WebUI**. Pour mettre à jour, exécutez la commande ci-dessus depuis le terminal.
|
||||
|
||||
### Déploiement Docker
|
||||
|
||||
Pour les utilisateurs familiers avec les conteneurs et qui souhaitent une méthode plus stable et adaptée à la production, nous recommandons de déployer AstrBot avec Docker / Docker Compose.
|
||||
|
||||
Veuillez consulter la documentation officielle [Déployer AstrBot avec Docker](https://docs.astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
|
||||
|
||||
### Déployer sur RainYun
|
||||
|
||||
Pour les utilisateurs qui souhaitent déployer AstrBot en un clic sans gérer le serveur eux-mêmes, nous recommandons le service de déploiement cloud en un clic de RainYun ☁️ :
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||
|
||||
### Déploiement de l'application de bureau
|
||||
|
||||
Pour les utilisateurs qui veulent utiliser AstrBot sur desktop et passer principalement par ChatUI, nous recommandons AstrBot App.
|
||||
|
||||
Accédez à [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) pour télécharger et installer l'application ; cette méthode est conçue pour un usage desktop et n'est pas recommandée pour les scénarios serveur.
|
||||
|
||||
### Déploiement avec le lanceur
|
||||
|
||||
Également sur desktop, pour les utilisateurs qui souhaitent un déploiement rapide avec isolation d'environnement et multi-instances, nous recommandons AstrBot Launcher.
|
||||
|
||||
Accédez à [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) pour télécharger et installer.
|
||||
|
||||
### Déployer sur Replit
|
||||
|
||||
Le déploiement sur Replit est maintenu par la communauté et convient aux démonstrations en ligne et aux essais légers.
|
||||
|
||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||
|
||||
### AUR
|
||||
|
||||
Le mode AUR s'adresse aux utilisateurs Arch Linux qui préfèrent installer AstrBot via le gestionnaire de paquets système.
|
||||
|
||||
Exécutez la commande ci-dessous pour installer `astrbot-git`, puis lancez AstrBot localement.
|
||||
##### Arch Linux
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
# ou utiliser paru
|
||||
paru -S astrbot-git
|
||||
```
|
||||
|
||||
**Autres méthodes de déploiement**
|
||||
#### Déploiement BT-Panel
|
||||
|
||||
Si vous avez besoin d'une gestion par panneau ou d'une personnalisation plus poussée, consultez [Déploiement BT-Panel](https://docs.astrbot.app/deploy/astrbot/btpanel.html) pour une installation via BT Panel, [Déploiement 1Panel](https://docs.astrbot.app/deploy/astrbot/1panel.html) pour le marketplace 1Panel, [Déploiement CasaOS](https://docs.astrbot.app/deploy/astrbot/casaos.html) pour un déploiement visuel sur NAS/serveur domestique, et [Déploiement manuel](https://docs.astrbot.app/deploy/astrbot/cli.html) pour une installation complète depuis les sources avec `uv`.
|
||||
AstrBot s'est associé à BT-Panel et est maintenant disponible sur leur marketplace.
|
||||
|
||||
Veuillez consulter la documentation officielle : [Déploiement BT-Panel](https://astrbot.app/deploy/astrbot/btpanel.html).
|
||||
|
||||
#### 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
|
||||
|
||||
AstrBot a été officiellement listé sur la plateforme d'applications cloud de RainYun avec un déploiement en un clic.
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||
|
||||
#### Déployer sur Replit
|
||||
|
||||
Méthode de déploiement contribuée par la communauté.
|
||||
|
||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||
|
||||
#### Installateur Windows en un clic
|
||||
|
||||
Veuillez consulter la documentation officielle : [Déployer AstrBot avec l'installateur Windows en un clic](https://astrbot.app/deploy/astrbot/windows.html).
|
||||
|
||||
#### 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).
|
||||
|
||||
#### Установка через системный пакетный менеджер
|
||||
|
||||
##### Arch Linux
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
# или используйте paru
|
||||
paru -S astrbot-git
|
||||
```
|
||||
|
||||
## Plateformes de messagerie prises en charge
|
||||
|
||||
Connectez AstrBot à vos plateformes de chat préférées.
|
||||
**Maintenues officiellement**
|
||||
|
||||
| Plateforme | Maintenance |
|
||||
|---------|---------------|
|
||||
| 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 |
|
||||
| KOOK | Officielle |
|
||||
| Misskey | Officielle |
|
||||
| Mattermost | Officielle |
|
||||
| WhatsApp (Bientôt disponible) | Officielle |
|
||||
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Communauté |
|
||||
| [Rocket.Chat](https://github.com/NET-Homeless/astrbot_plugin_rocket_chat_adapter) | Communauté |
|
||||
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | Communauté |
|
||||
- QQ (Plateforme officielle & OneBot)
|
||||
- Telegram
|
||||
- Application WeChat Work & Bot intelligent WeChat Work
|
||||
- Service client WeChat & Comptes officiels WeChat
|
||||
- Feishu (Lark)
|
||||
- DingTalk
|
||||
- Slack
|
||||
- Discord
|
||||
- Satori
|
||||
- Misskey
|
||||
- WhatsApp (Bientôt disponible)
|
||||
- LINE (Bientôt disponible)
|
||||
|
||||
**Maintenues par la communauté**
|
||||
|
||||
- [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)
|
||||
|
||||
## Services de modèles pris en charge
|
||||
|
||||
| Service | 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 |
|
||||
| [AIHubMix](https://aihubmix.com/?aff=4bfH) | Services LLM (Passerelle API, prend en charge tous les modèles) |
|
||||
| [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 |
|
||||
| Xiaomi MiMo Omni | 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 |
|
||||
| Xiaomi MiMo TTS | Services de synthèse vocale |
|
||||
| Volcano Engine TTS | Services de synthèse vocale |
|
||||
**Services LLM**
|
||||
|
||||
- OpenAI et services compatibles
|
||||
- Anthropic
|
||||
- Google Gemini
|
||||
- Moonshot AI
|
||||
- Zhipu AI
|
||||
- DeepSeek
|
||||
- Ollama (Auto-hébergé)
|
||||
- LM Studio (Auto-hébergé)
|
||||
- [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74)
|
||||
- [302.AI](https://share.302.ai/rr1M3l)
|
||||
- [TokenPony](https://www.tokenpony.cn/3YPyf)
|
||||
- [SiliconFlow](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot)
|
||||
- [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE)
|
||||
- ModelScope
|
||||
- OneAPI
|
||||
|
||||
**Plateformes LLMOps**
|
||||
|
||||
- Dify
|
||||
- Applications Alibaba Cloud Bailian
|
||||
- Coze
|
||||
|
||||
**Services de reconnaissance vocale**
|
||||
|
||||
- OpenAI Whisper
|
||||
- SenseVoice
|
||||
|
||||
**Services de synthèse vocale**
|
||||
|
||||
- OpenAI TTS
|
||||
- Gemini TTS
|
||||
- GPT-Sovits-Inference
|
||||
- GPT-Sovits
|
||||
- FishAudio
|
||||
- Edge TTS
|
||||
- Alibaba Cloud Bailian TTS
|
||||
- Azure TTS
|
||||
- Minimax TTS
|
||||
- Volcano Engine TTS
|
||||
|
||||
## ❤️ Contribuer
|
||||
|
||||
@@ -225,19 +243,15 @@ pre-commit install
|
||||
|
||||
### Groupes QQ
|
||||
|
||||
- Groupe 12 : 916228568 (nouveau)
|
||||
- Groupe 9 : 1076659624 (complet)
|
||||
- Groupe 10 : 1078079676 (complet)
|
||||
- Groupe 11 : 704659519 (complet)
|
||||
- Groupe 1 : 322154837 (complet)
|
||||
- Groupe 3 : 630166526 (complet)
|
||||
- Groupe 4 : 1077826412 (complet)
|
||||
- Groupe 5 : 822130018 (complet)
|
||||
- Groupe 6 : 753075035 (complet)
|
||||
- Groupe 7 : 743746109 (complet)
|
||||
- Groupe 8 : 1030353265 (complet)
|
||||
- Groupe 1 : 322154837
|
||||
- Groupe 3 : 630166526
|
||||
- Groupe 5 : 822130018
|
||||
- Groupe 6 : 753075035
|
||||
- Groupe développeurs : 975206796
|
||||
- Groupe développeurs (officiel) : 1039761811
|
||||
|
||||
### Groupe 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>
|
||||
|
||||
### Serveur Discord
|
||||
|
||||
@@ -248,7 +262,7 @@ pre-commit install
|
||||
Un grand merci à tous les contributeurs et développeurs de plugins pour leurs contributions à AstrBot ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=300&columns=15" />
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
||||
</a>
|
||||
|
||||
De plus, la naissance de ce projet n'aurait pas été possible sans l'aide des projets open source suivants :
|
||||
|
||||
282
README_ja.md
282
README_ja.md
@@ -2,8 +2,8 @@
|
||||
|
||||
<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.md">中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_en.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_fr.md">Français</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
||||
@@ -11,7 +11,7 @@
|
||||
<br>
|
||||
|
||||
<div>
|
||||
<a href="https://trendshift.io/repositories/21369" target="_blank"><img src="https://trendshift.io/api/badge/repositories/21369" alt="AstrBotDevs%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<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>
|
||||
|
||||
@@ -21,9 +21,9 @@
|
||||
<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=%E4%B8%AA&label=%E6%8F%92%E4%BB%B6%E5%B8%82%E5%9C%BA&cacheSeconds=3600">
|
||||
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
|
||||
</div>
|
||||
|
||||
@@ -33,12 +33,11 @@
|
||||
<a href="https://blog.astrbot.app/">Blog</a> |
|
||||
<a href="https://astrbot.featurebase.app/roadmap">ロードマップ</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/issues">Issue</a>
|
||||
<a href="mailto:community@astrbot.app">Email Support</a>
|
||||
</div>
|
||||
|
||||
AstrBot は、主要なインスタントメッセージングアプリと統合できるオープンソースのオールインワン Agent チャットボットプラットフォームです。個人、開発者、チームに信頼性が高くスケーラブルな会話型 AI インフラストラクチャを提供します。パーソナル AI コンパニオン、インテリジェントカスタマーサービス、オートメーションアシスタント、エンタープライズナレッジベースなど、AstrBot を使用すると、IM プラットフォームのワークフロー内で本番環境対応の AI アプリケーションを迅速に構築できます。
|
||||
|
||||

|
||||
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" />
|
||||
|
||||
## 主な機能
|
||||
|
||||
@@ -46,7 +45,7 @@ AstrBot は、主要なインスタントメッセージングアプリと統合
|
||||
2. ✨ AI大規模言語モデル対話、マルチモーダル、Agent、MCP、Skills、ナレッジベース、ペルソナ設定、対話の自動圧縮。
|
||||
3. 🤖 Dify、Alibaba Cloud Bailian(百煉)、Coze などのAgentプラットフォームへの接続をサポート。
|
||||
4. 🌐 マルチプラットフォーム:QQ、企業微信(WeCom)、飛書(Lark)、釘釘(DingTalk)、WeChat公式アカウント、Telegram、Slack、[その他](#サポートされているメッセージプラットフォーム)に対応。
|
||||
5. 📦 プラグイン拡張:1000を超える既存プラグインをワンクリックでインストール可能。
|
||||
5. 📦 プラグイン拡張:800近い既存プラグインをワンクリックでインストール可能。
|
||||
6. 🛡️ 隔離環境[Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html):コードの安全な実行、Shell呼び出し、セッションレベルのリソース再利用。
|
||||
7. 💻 WebUI 対応。
|
||||
8. 🌈 Web ChatUI 対応:ChatUI内にAgent Sandboxやウェブ検索などを内蔵。
|
||||
@@ -59,7 +58,7 @@ AstrBot は、主要なインスタントメッセージングアプリと統合
|
||||
<th>💙 ロールプレイ & 感情的な対話</th>
|
||||
<th>✨ プロアクティブ・エージェント (Proactive Agent)</th>
|
||||
<th>🚀 汎用 エージェント的能力</th>
|
||||
<th>🧩 1000+ コミュニティプラグイン</th>
|
||||
<th>🧩 900+ コミュニティプラグイン</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>
|
||||
@@ -71,138 +70,157 @@ AstrBot は、主要なインスタントメッセージングアプリと統合
|
||||
|
||||
## クイックスタート
|
||||
|
||||
### ワンクリックデプロイ
|
||||
#### Docker デプロイ(推奨 🥳)
|
||||
|
||||
AstrBot を素早く試したいユーザーで、コマンドラインに慣れており `uv` 環境を自分でインストールできる場合は、`uv` のワンクリックデプロイをおすすめします ⚡️:
|
||||
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 デプロイ
|
||||
|
||||
```bash
|
||||
uv tool install astrbot --python 3.12
|
||||
astrbot init # 初回のみ実行して環境を初期化します
|
||||
astrbot run
|
||||
uv tool install astrbot
|
||||
astrbot
|
||||
```
|
||||
|
||||
> [uv](https://docs.astral.sh/uv/) のインストールが必要です。
|
||||
> AstrBot には Python 3.12 以降が必要です。`--python 3.12` を指定すると、`uv` は Python 3.12 で tool 環境を作成します。
|
||||
#### システムパッケージマネージャーでのインストール
|
||||
|
||||
> [!NOTE]
|
||||
> macOS ユーザーの場合:macOS のセキュリティチェックにより、`astrbot` コマンドの初回実行に時間がかかる場合があります(約 10〜20 秒)。
|
||||
|
||||
`astrbot` の更新:
|
||||
|
||||
```bash
|
||||
uv tool upgrade astrbot --python 3.12
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> `uv` 経由でデプロイした AstrBot は、**WebUI からのバージョンアップグレードに対応していません**。更新するには、上記のコマンドをコマンドラインで実行してください。
|
||||
|
||||
### Docker デプロイ
|
||||
|
||||
コンテナ運用に慣れており、より安定した本番向けのデプロイ方法を求めるユーザーには、Docker / Docker Compose での AstrBot デプロイをおすすめします。
|
||||
|
||||
公式ドキュメント [Docker を使用した AstrBot のデプロイ](https://docs.astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot) をご参照ください。
|
||||
|
||||
### 雨云でのデプロイ
|
||||
|
||||
AstrBot をワンクリックでデプロイしたく、サーバーを自分で管理したくないユーザーには、雨云のワンクリッククラウドデプロイサービスをおすすめします ☁️:
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||
|
||||
### デスクトップアプリのデプロイ
|
||||
|
||||
デスクトップで 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)
|
||||
|
||||
### AUR
|
||||
|
||||
AUR 方式は Arch Linux ユーザー向けで、システムのパッケージ運用に合わせて AstrBot を導入したい場合に適しています。
|
||||
|
||||
次のコマンドで `astrbot-git` をインストールし、ローカル環境で AstrBot を起動してください。
|
||||
##### Arch Linux
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
# または paru を使用
|
||||
paru -S astrbot-git
|
||||
```
|
||||
|
||||
**その他のデプロイ方法**
|
||||
#### 宝塔パネルデプロイ
|
||||
|
||||
パネル操作での導入やより高度なカスタマイズが必要な場合は、[宝塔パネルデプロイ](https://docs.astrbot.app/deploy/astrbot/btpanel.html)(BT Panel 経由の導入)、[1Panel デプロイ](https://docs.astrbot.app/deploy/astrbot/1panel.html)(1Panel アプリマーケット経由)、[CasaOS デプロイ](https://docs.astrbot.app/deploy/astrbot/casaos.html)(NAS / ホームサーバー向け可視化導入)、[手動デプロイ](https://docs.astrbot.app/deploy/astrbot/cli.html)(`uv` とソースベースのフルカスタム導入)を参照してください。
|
||||
AstrBot は宝塔パネルと提携し、宝塔パネルに公開されています。
|
||||
|
||||
公式ドキュメント [宝塔パネルデプロイ](https://astrbot.app/deploy/astrbot/btpanel.html) をご参照ください。
|
||||
|
||||
#### 1Panel デプロイ
|
||||
|
||||
AstrBot は 1Panel 公式により 1Panel パネルに公開されています。
|
||||
|
||||
公式ドキュメント [1Panel デプロイ](https://astrbot.app/deploy/astrbot/1panel.html) をご参照ください。
|
||||
|
||||
#### 雨云でのデプロイ
|
||||
|
||||
AstrBot は雨云公式によりクラウドアプリケーションプラットフォームに公開され、ワンクリックでデプロイ可能です。
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||
|
||||
#### Replit でのデプロイ
|
||||
|
||||
コミュニティ貢献によるデプロイ方法。
|
||||
|
||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||
|
||||
#### Windows ワンクリックインストーラーデプロイ
|
||||
|
||||
公式ドキュメント [Windows ワンクリックインストーラーを使用した AstrBot のデプロイ](https://astrbot.app/deploy/astrbot/windows.html) をご参照ください。
|
||||
|
||||
#### 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
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
# или используйте paru
|
||||
paru -S astrbot-git
|
||||
```
|
||||
|
||||
## サポートされているメッセージプラットフォーム
|
||||
|
||||
AstrBot をよく使うチャットプラットフォームに接続できます。
|
||||
**公式メンテナンス**
|
||||
|
||||
| プラットフォーム | 保守 |
|
||||
|---------|---------------|
|
||||
| QQ | 公式 |
|
||||
| OneBot v11 プロトコル実装 | 公式 |
|
||||
| Telegram | 公式 |
|
||||
| WeChat Work アプリケーション & WeChat Work インテリジェントボット | 公式 |
|
||||
| WeChat カスタマーサービス & WeChat 公式アカウント | 公式 |
|
||||
| Feishu (Lark) | 公式 |
|
||||
| DingTalk | 公式 |
|
||||
| Slack | 公式 |
|
||||
| Discord | 公式 |
|
||||
| LINE | 公式 |
|
||||
| Satori | 公式 |
|
||||
| KOOK | 公式 |
|
||||
| Misskey | 公式 |
|
||||
| Mattermost | 公式 |
|
||||
| WhatsApp (近日対応予定) | 公式 |
|
||||
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | コミュニティ |
|
||||
| [Rocket.Chat](https://github.com/NET-Homeless/astrbot_plugin_rocket_chat_adapter) | コミュニティ |
|
||||
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | コミュニティ |
|
||||
- QQ (公式プラットフォーム & OneBot)
|
||||
- Telegram
|
||||
- WeChat Work アプリケーション & WeChat Work インテリジェントボット
|
||||
- WeChat カスタマーサービス & WeChat 公式アカウント
|
||||
- Feishu (Lark)
|
||||
- DingTalk
|
||||
- Slack
|
||||
- Discord
|
||||
- Satori
|
||||
- Misskey
|
||||
- WhatsApp (近日対応予定)
|
||||
- LINE (近日対応予定)
|
||||
|
||||
**コミュニティメンテナンス**
|
||||
|
||||
- [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 (セルフホスト) | 大規模言語モデルサービス |
|
||||
| [AIHubMix](https://aihubmix.com/?aff=4bfH) | 大規模言語モデルサービス(APIゲートウェイ、全モデル対応) |
|
||||
| [優云智算](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 | 音声認識サービス |
|
||||
| Xiaomi MiMo Omni | 音声認識サービス |
|
||||
| OpenAI TTS | 音声合成サービス |
|
||||
| Gemini TTS | 音声合成サービス |
|
||||
| GPT-Sovits-Inference | 音声合成サービス |
|
||||
| GPT-Sovits | 音声合成サービス |
|
||||
| FishAudio | 音声合成サービス |
|
||||
| Edge TTS | 音声合成サービス |
|
||||
| Alibaba Cloud 百炼 TTS | 音声合成サービス |
|
||||
| Azure TTS | 音声合成サービス |
|
||||
| Minimax TTS | 音声合成サービス |
|
||||
| Xiaomi MiMo TTS | 音声合成サービス |
|
||||
| Volcano Engine TTS | 音声合成サービス |
|
||||
**大規模言語モデルサービス**
|
||||
|
||||
- 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
|
||||
|
||||
**LLMOps プラットフォーム**
|
||||
|
||||
- Dify
|
||||
- Alibaba Cloud 百炼アプリケーション
|
||||
- Coze
|
||||
|
||||
**音声認識サービス**
|
||||
|
||||
- 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
|
||||
|
||||
## ❤️ コントリビューション
|
||||
|
||||
@@ -226,19 +244,15 @@ pre-commit install
|
||||
|
||||
### QQ グループ
|
||||
|
||||
- 12群: 916228568 (新)
|
||||
- 9群: 1076659624 (満員)
|
||||
- 10群: 1078079676 (満員)
|
||||
- 11群: 704659519 (満員)
|
||||
- 1群: 322154837 (満員)
|
||||
- 3群: 630166526 (満員)
|
||||
- 4群: 1077826412 (満員)
|
||||
- 5群: 822130018 (満員)
|
||||
- 6群: 753075035 (満員)
|
||||
- 7群: 743746109 (満員)
|
||||
- 8群: 1030353265 (満員)
|
||||
- 1群: 322154837
|
||||
- 3群: 630166526
|
||||
- 5群: 822130018
|
||||
- 6群: 753075035
|
||||
- 開発者群: 975206796
|
||||
- 開発者群(正式): 1039761811
|
||||
|
||||
### 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 サーバー
|
||||
|
||||
@@ -249,7 +263,7 @@ pre-commit install
|
||||
AstrBot への貢献をしていただいたすべてのコントリビューターとプラグイン開発者に特別な感謝を ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=300&columns=15" />
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
||||
</a>
|
||||
|
||||
また、このプロジェクトの誕生は以下のオープンソースプロジェクトの助けなしには実現できませんでした:
|
||||
|
||||
252
README_ru.md
252
README_ru.md
@@ -2,16 +2,16 @@
|
||||
|
||||
<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.md">中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_en.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>
|
||||
|
||||
<div>
|
||||
<a href="https://trendshift.io/repositories/21369" target="_blank"><img src="https://trendshift.io/api/badge/repositories/21369" alt="AstrBotDevs%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<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>
|
||||
|
||||
@@ -21,9 +21,9 @@
|
||||
<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=%E4%B8%AA&label=%E6%8F%92%E4%BB%B6%E5%B8%82%E5%9C%BA&cacheSeconds=3600">
|
||||
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
|
||||
</div>
|
||||
|
||||
@@ -33,12 +33,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 Support</a>
|
||||
</div>
|
||||
|
||||
AstrBot — это универсальная платформа Agent-чатботов с открытым исходным кодом, которая интегрируется с основными приложениями для обмена мгновенными сообщениями. Она предоставляет надёжную и масштабируемую инфраструктуру разговорного ИИ для частных лиц, разработчиков и команд. Будь то персональный ИИ-компаньон, интеллектуальная служба поддержки, автоматизированный помощник или корпоративная база знаний — AstrBot позволяет быстро создавать готовые к использованию ИИ-приложения в рабочих процессах вашей платформы обмена сообщениями.
|
||||
|
||||

|
||||
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" />
|
||||
|
||||
## Основные возможности
|
||||
|
||||
@@ -46,7 +45,7 @@ AstrBot — это универсальная платформа Agent-чатб
|
||||
2. ✨ Диалоги с ИИ-моделями, мультимодальность, Agent, MCP, Skills, База знаний, Настройка личности, автоматическое сжатие диалогов.
|
||||
3. 🤖 Поддержка интеграции с платформами Agents, такими как Dify, Alibaba Cloud Bailian, Coze и др.
|
||||
4. 🌐 Мультиплатформенность: поддержка QQ, WeChat для предприятий, Feishu, DingTalk, публичных аккаунтов WeChat, Telegram, Slack и [других](#Поддерживаемые-платформы-обмена-сообщениями).
|
||||
5. 📦 Расширение плагинами: доступно более 1000 плагинов для установки в один клик.
|
||||
5. 📦 Расширение плагинами: доступно почти 800 плагинов для установки в один клик.
|
||||
6. 🛡️ Изолированная среда[Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html): безопасное выполнение любого кода, вызов Shell, повторное использование ресурсов на уровне сессии.
|
||||
7. 💻 Поддержка WebUI.
|
||||
8. 🌈 Поддержка Web ChatUI: встроенная песочница агента, веб-поиск и др.
|
||||
@@ -57,9 +56,9 @@ AstrBot — это универсальная платформа Agent-чатб
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<th>💙 Ролевые игры & Эмоциональная поддержка</th>
|
||||
<th>✨ Проактивный Агент (Agent)</th>
|
||||
<th>🚀 Универсальные возможности Агента</th>
|
||||
<th>🧩 1000+ плагинов сообщества</th>
|
||||
<th>✨ Проактивный Агент(Agent)</th>
|
||||
<th>🚀 Универсальные Агентные возможности</th>
|
||||
<th>🧩 Универсальные Агентные (Agentic) возможности</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>
|
||||
@@ -71,137 +70,146 @@ AstrBot — это универсальная платформа Agent-чатб
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
### Развёртывание в один клик
|
||||
#### Развёртывание Docker (Рекомендуется 🥳)
|
||||
|
||||
Для пользователей, которые хотят быстро попробовать AstrBot, знакомы с командной строкой и могут самостоятельно установить окружение `uv`, мы рекомендуем использовать развёртывание в один клик через `uv` ⚡️:
|
||||
Мы рекомендуем развёртывать 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
|
||||
|
||||
```bash
|
||||
uv tool install astrbot --python 3.12
|
||||
astrbot init # Выполните эту команду только при первом запуске для инициализации окружения
|
||||
astrbot run
|
||||
uv tool install astrbot
|
||||
astrbot
|
||||
```
|
||||
|
||||
> Требуется установленный [uv](https://docs.astral.sh/uv/).
|
||||
> Для AstrBot требуется Python 3.12 или новее. Параметр `--python 3.12` гарантирует, что `uv` создаст tool-окружение с Python 3.12.
|
||||
#### Развёртывание BT-Panel
|
||||
|
||||
> [!NOTE]
|
||||
> Для пользователей macOS: из-за проверок безопасности macOS первый запуск команды `astrbot` может занять больше времени (около 10-20 секунд).
|
||||
AstrBot в партнёрстве с BT-Panel теперь доступен на их маркетплейсе.
|
||||
|
||||
Обновить `astrbot`:
|
||||
См. официальную документацию: [Развёртывание BT-Panel](https://astrbot.app/deploy/astrbot/btpanel.html).
|
||||
|
||||
```bash
|
||||
uv tool upgrade astrbot --python 3.12
|
||||
```
|
||||
#### Развёртывание 1Panel
|
||||
|
||||
> [!WARNING]
|
||||
> AstrBot, развёрнутый через `uv`, **не поддерживает обновление через WebUI**. Для обновления выполните указанную выше команду из командной строки.
|
||||
AstrBot официально размещён на маркетплейсе 1Panel.
|
||||
|
||||
### Развёртывание Docker
|
||||
См. официальную документацию: [Развёртывание 1Panel](https://astrbot.app/deploy/astrbot/1panel.html).
|
||||
|
||||
Для пользователей, знакомых с контейнерами и которым нужен более стабильный и подходящий для production способ, мы рекомендуем разворачивать AstrBot через Docker / Docker Compose.
|
||||
#### Развёртывание на RainYun
|
||||
|
||||
См. официальную документацию [Развёртывание AstrBot с Docker](https://docs.astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
|
||||
|
||||
### Развёртывание на RainYun
|
||||
|
||||
Для пользователей, которые хотят развернуть 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)
|
||||
|
||||
### AUR
|
||||
#### Установщик Windows в один клик
|
||||
|
||||
AUR-вариант предназначен для пользователей Arch Linux, которым удобна установка через системный менеджер пакетов.
|
||||
См. официальную документацию: [Развёртывание AstrBot с установщиком Windows в один клик](https://astrbot.app/deploy/astrbot/windows.html).
|
||||
|
||||
Выполните команду ниже для установки `astrbot-git`, затем запустите 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
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
# или используйте paru
|
||||
paru -S astrbot-git
|
||||
```
|
||||
|
||||
**Другие способы развёртывания**
|
||||
|
||||
Если вам нужна панельная установка или более глубокая кастомизация, смотрите [Развёртывание BT-Panel](https://docs.astrbot.app/deploy/astrbot/btpanel.html) (установка через BT Panel), [Развёртывание 1Panel](https://docs.astrbot.app/deploy/astrbot/1panel.html) (развёртывание через маркетплейс 1Panel), [Развёртывание CasaOS](https://docs.astrbot.app/deploy/astrbot/casaos.html) (визуальный вариант для NAS и домашних серверов) и [Ручное развёртывание](https://docs.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 | Официальная |
|
||||
| KOOK | Официальная |
|
||||
| Misskey | Официальная |
|
||||
| Mattermost | Официальная |
|
||||
| WhatsApp (Скоро) | Официальная |
|
||||
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Сообщество |
|
||||
| [Rocket.Chat](https://github.com/NET-Homeless/astrbot_plugin_rocket_chat_adapter) | Сообщество |
|
||||
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | Сообщество |
|
||||
- QQ (Официальная платформа и OneBot)
|
||||
- Telegram
|
||||
- Приложение WeChat Work и интеллектуальный бот WeChat Work
|
||||
- Служба поддержки WeChat и официальные аккаунты WeChat
|
||||
- Feishu (Lark)
|
||||
- DingTalk
|
||||
- Slack
|
||||
- Discord
|
||||
- Satori
|
||||
- Misskey
|
||||
- WhatsApp (Скоро)
|
||||
- LINE (Скоро)
|
||||
|
||||
**Поддерживаемые сообществом**
|
||||
|
||||
- [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 |
|
||||
| [AIHubMix](https://aihubmix.com/?aff=4bfH) | Сервисы LLM (API-шлюз, поддерживает все модели) |
|
||||
| [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 | Сервисы распознавания речи |
|
||||
| Xiaomi MiMo Omni | Сервисы распознавания речи |
|
||||
| OpenAI TTS | Сервисы синтеза речи |
|
||||
| Gemini TTS | Сервисы синтеза речи |
|
||||
| GPT-Sovits-Inference | Сервисы синтеза речи |
|
||||
| GPT-Sovits | Сервисы синтеза речи |
|
||||
| FishAudio | Сервисы синтеза речи |
|
||||
| Edge TTS | Сервисы синтеза речи |
|
||||
| Alibaba Cloud Bailian TTS | Сервисы синтеза речи |
|
||||
| Azure TTS | Сервисы синтеза речи |
|
||||
| Minimax TTS | Сервисы синтеза речи |
|
||||
| Xiaomi MiMo TTS | Сервисы синтеза речи |
|
||||
| Volcano Engine TTS | Сервисы синтеза речи |
|
||||
**Сервисы LLM**
|
||||
|
||||
- OpenAI и совместимые сервисы
|
||||
- Anthropic
|
||||
- Google Gemini
|
||||
- Moonshot AI
|
||||
- Zhipu AI
|
||||
- DeepSeek
|
||||
- Ollama (Самостоятельное размещение)
|
||||
- LM Studio (Самостоятельное размещение)
|
||||
- [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74)
|
||||
- [302.AI](https://share.302.ai/rr1M3l)
|
||||
- [TokenPony](https://www.tokenpony.cn/3YPyf)
|
||||
- [SiliconFlow](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot)
|
||||
- [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE)
|
||||
- ModelScope
|
||||
- OneAPI
|
||||
|
||||
**Платформы LLMOps**
|
||||
|
||||
- Dify
|
||||
- Приложения Alibaba Cloud Bailian
|
||||
- Coze
|
||||
|
||||
**Сервисы распознавания речи**
|
||||
|
||||
- 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
|
||||
|
||||
## ❤️ Вклад в проект
|
||||
|
||||
@@ -225,19 +233,15 @@ pre-commit install
|
||||
|
||||
### Группы QQ
|
||||
|
||||
- Группа 12: 916228568 (новая)
|
||||
- Группа 9: 1076659624 (полная)
|
||||
- Группа 10: 1078079676 (полная)
|
||||
- Группа 11: 704659519 (полная)
|
||||
- Группа 1: 322154837 (полная)
|
||||
- Группа 3: 630166526 (полная)
|
||||
- Группа 4: 1077826412 (полная)
|
||||
- Группа 5: 822130018 (полная)
|
||||
- Группа 6: 753075035 (полная)
|
||||
- Группа 7: 743746109 (полная)
|
||||
- Группа 8: 1030353265 (полная)
|
||||
- Группа 1: 322154837
|
||||
- Группа 3: 630166526
|
||||
- Группа 5: 822130018
|
||||
- Группа 6: 753075035
|
||||
- Группа разработчиков: 975206796
|
||||
- Группа разработчиков (официальная): 1039761811
|
||||
|
||||
### Группа 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
|
||||
|
||||
@@ -248,7 +252,7 @@ pre-commit install
|
||||
Особая благодарность всем контрибьюторам и разработчикам плагинов за их вклад в AstrBot ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=300&columns=15" />
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
||||
</a>
|
||||
|
||||
Кроме того, рождение этого проекта было бы невозможно без помощи следующих проектов с открытым исходным кодом:
|
||||
|
||||
246
README_zh-TW.md
246
README_zh-TW.md
@@ -2,8 +2,8 @@
|
||||
|
||||
<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.md">简体中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_en.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_fr.md">Français</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
||||
@@ -11,7 +11,7 @@
|
||||
<br>
|
||||
|
||||
<div>
|
||||
<a href="https://trendshift.io/repositories/21369" target="_blank"><img src="https://trendshift.io/api/badge/repositories/21369" alt="AstrBotDevs%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<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>
|
||||
|
||||
@@ -32,13 +32,12 @@
|
||||
<a href="https://astrbot.app/">文件</a> |
|
||||
<a href="https://blog.astrbot.app/">Blog</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>
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/issues">問題回報</a>
|
||||
</div>
|
||||
|
||||
AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主流即時通訊軟體,為個人、開發者和團隊打造可靠、可擴展的對話式智慧基礎設施。無論是個人 AI 夥伴、智慧客服、自動化助手,還是企業知識庫,AstrBot 都能在您的即時通訊軟體平台的工作流程中快速構建生產可用的 AI 應用程式。
|
||||
|
||||

|
||||
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" />
|
||||
|
||||
## 主要功能
|
||||
|
||||
@@ -46,7 +45,7 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主
|
||||
2. ✨ AI 大模型對話,多模態,Agent,MCP,Skills,知識庫,人格設定,自動壓縮對話。
|
||||
3. 🤖 支援接入 Dify、阿里雲百煉、Coze 等智慧體 (Agent) 平台。
|
||||
4. 🌐 多平台,支援 QQ、企業微信、飛書、釘釘、微信公眾號、Telegram、Slack 以及[更多](#支援的訊息平台)。
|
||||
5. 📦 插件擴展,已有 1000+ 個插件可一鍵安裝。
|
||||
5. 📦 插件擴展,已有近 800 個插件可一鍵安裝。
|
||||
6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) 隔離化環境,安全地執行任何代碼、調用 Shell、會話級資源複用。
|
||||
7. 💻 WebUI 支援。
|
||||
8. 🌈 Web ChatUI 支援,ChatUI 內置代理沙盒 (Agent Sandbox)、網頁搜尋等。
|
||||
@@ -59,7 +58,7 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主
|
||||
<th>💙 角色扮演 & 情感陪伴</th>
|
||||
<th>✨ 主動式 Agent</th>
|
||||
<th>🚀 通用 Agentic 能力</th>
|
||||
<th>🧩 1000+ 社區外掛程式</th>
|
||||
<th>🧩 900+ 社區外掛程式</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>
|
||||
@@ -71,137 +70,146 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主
|
||||
|
||||
## 快速開始
|
||||
|
||||
### 一鍵部署
|
||||
#### Docker 部署(推薦 🥳)
|
||||
|
||||
對於想快速體驗 AstrBot、且熟悉命令列並能自行安裝 `uv` 環境的使用者,我們推薦使用 `uv` 一鍵部署方式 ⚡️。
|
||||
推薦使用 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 部署
|
||||
|
||||
```bash
|
||||
uv tool install astrbot --python 3.12
|
||||
astrbot init # 僅首次執行此命令以初始化環境
|
||||
astrbot run
|
||||
uv tool install astrbot
|
||||
astrbot
|
||||
```
|
||||
|
||||
> 需要安裝 [uv](https://docs.astral.sh/uv/)。
|
||||
> AstrBot 需要 Python 3.12 或更高版本。`--python 3.12` 會確保 `uv` 使用 Python 3.12 建立 tool 環境。
|
||||
#### 寶塔面板部署
|
||||
|
||||
> [!NOTE]
|
||||
> 對於 macOS 使用者:由於 macOS 安全性檢查,首次執行 `astrbot` 指令可能需要較長時間(約 10-20 秒)。
|
||||
AstrBot 與寶塔面板合作,已上架至寶塔面板。
|
||||
|
||||
更新 `astrbot`:
|
||||
請參閱官方文件 [寶塔面板部署](https://astrbot.app/deploy/astrbot/btpanel.html)。
|
||||
|
||||
```bash
|
||||
uv tool upgrade astrbot --python 3.12
|
||||
```
|
||||
#### 1Panel 部署
|
||||
|
||||
> [!WARNING]
|
||||
> 透過 `uv` 部署的 AstrBot **不支援在 WebUI 中進行版本升級**。如需更新,請透過命令列執行上述命令。
|
||||
AstrBot 已由 1Panel 官方上架至 1Panel 面板。
|
||||
|
||||
### Docker 部署
|
||||
請參閱官方文件 [1Panel 部署](https://astrbot.app/deploy/astrbot/1panel.html)。
|
||||
|
||||
對於熟悉容器、希望獲得更穩定且更適合正式環境部署方式的使用者,我們推薦使用 Docker / Docker Compose 部署 AstrBot。
|
||||
#### 在雨雲上部署
|
||||
|
||||
請參考官方文件 [使用 Docker 部署 AstrBot](https://docs.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)
|
||||
|
||||
### 桌面客戶端部署
|
||||
#### 在 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)
|
||||
|
||||
### AUR
|
||||
#### Windows 一鍵安裝器部署
|
||||
|
||||
AUR 方式面向 Arch Linux 使用者,適合希望透過系統套件管理器安裝 AstrBot 的場景。
|
||||
請參閱官方文件 [使用 Windows 一鍵安裝器部署 AstrBot](https://astrbot.app/deploy/astrbot/windows.html)。
|
||||
|
||||
在終端執行下方命令安裝 `astrbot-git` 套件,安裝完成後即可啟動使用。
|
||||
#### 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
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
# 或者使用 paru
|
||||
paru -S astrbot-git
|
||||
```
|
||||
|
||||
**更多部署方式**
|
||||
|
||||
若你需要面板化或更高自訂程度的部署,可參考 [寶塔面板](https://docs.astrbot.app/deploy/astrbot/btpanel.html)(BT Panel 應用商店安裝)、[1Panel](https://docs.astrbot.app/deploy/astrbot/1panel.html)(1Panel 應用商店安裝)、[CasaOS](https://docs.astrbot.app/deploy/astrbot/casaos.html)(NAS / 家用伺服器可視化部署)與 [手動部署](https://docs.astrbot.app/deploy/astrbot/cli.html)(基於原始碼與 `uv` 的完整自訂安裝)。
|
||||
|
||||
## 支援的訊息平台
|
||||
|
||||
將 AstrBot 連接到你常用的聊天平台。
|
||||
**官方維護**
|
||||
|
||||
| 平台 | 維護方 |
|
||||
|---------|---------------|
|
||||
| QQ | 官方維護 |
|
||||
| OneBot v11 協議實作 | 官方維護 |
|
||||
| Telegram | 官方維護 |
|
||||
| 企微應用 & 企微智慧機器人 | 官方維護 |
|
||||
| 微信客服 & 微信公眾號 | 官方維護 |
|
||||
| 飛書 | 官方維護 |
|
||||
| 釘釘 | 官方維護 |
|
||||
| Slack | 官方維護 |
|
||||
| Discord | 官方維護 |
|
||||
| LINE | 官方維護 |
|
||||
| Satori | 官方維護 |
|
||||
| KOOK | 官方維護 |
|
||||
| Misskey | 官方維護 |
|
||||
| Mattermost | 官方維護 |
|
||||
| WhatsApp(即將支援) | 官方維護 |
|
||||
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | 社群維護 |
|
||||
| [Rocket.Chat](https://github.com/NET-Homeless/astrbot_plugin_rocket_chat_adapter) | 社群維護 |
|
||||
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | 社群維護 |
|
||||
- QQ(官方平台 & OneBot)
|
||||
- Telegram
|
||||
- 企微應用 & 企微智慧機器人
|
||||
- 微信客服 & 微信公眾號
|
||||
- 飛書
|
||||
- 釘釘
|
||||
- Slack
|
||||
- Discord
|
||||
- Satori
|
||||
- Misskey
|
||||
- Whatsapp(即將支援)
|
||||
- LINE(即將支援)
|
||||
|
||||
**社群維護**
|
||||
|
||||
- [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(本機部署) | 大型模型服務 |
|
||||
| [AIHubMix](https://aihubmix.com/?aff=4bfH) | 大型模型服務(API 閘道,支援所有模型) |
|
||||
| [優雲智算](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 平台 |
|
||||
| 阿里雲百煉應用 | LLMOps 平台 |
|
||||
| Coze | LLMOps 平台 |
|
||||
| OpenAI Whisper | 語音轉文字服務 |
|
||||
| SenseVoice | 語音轉文字服務 |
|
||||
| Xiaomi MiMo Omni | 語音轉文字服務 |
|
||||
| OpenAI TTS | 文字轉語音服務 |
|
||||
| Gemini TTS | 文字轉語音服務 |
|
||||
| GPT-Sovits-Inference | 文字轉語音服務 |
|
||||
| GPT-Sovits | 文字轉語音服務 |
|
||||
| FishAudio | 文字轉語音服務 |
|
||||
| Edge TTS | 文字轉語音服務 |
|
||||
| 阿里雲百煉 TTS | 文字轉語音服務 |
|
||||
| Azure TTS | 文字轉語音服務 |
|
||||
| Minimax TTS | 文字轉語音服務 |
|
||||
| Xiaomi MiMo TTS | 文字轉語音服務 |
|
||||
| 火山引擎 TTS | 文字轉語音服務 |
|
||||
**大型模型服務**
|
||||
|
||||
- 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
|
||||
|
||||
**LLMOps 平台**
|
||||
|
||||
- Dify
|
||||
- 阿里雲百煉應用
|
||||
- Coze
|
||||
|
||||
**語音轉文字服務**
|
||||
|
||||
- OpenAI Whisper
|
||||
- SenseVoice
|
||||
|
||||
**文字轉語音服務**
|
||||
|
||||
- OpenAI TTS
|
||||
- Gemini TTS
|
||||
- GPT-Sovits-Inference
|
||||
- GPT-Sovits
|
||||
- FishAudio
|
||||
- Edge TTS
|
||||
- 阿里雲百煉 TTS
|
||||
- Azure TTS
|
||||
- Minimax TTS
|
||||
- 火山引擎 TTS
|
||||
|
||||
## ❤️ 貢獻
|
||||
|
||||
@@ -225,19 +233,15 @@ pre-commit install
|
||||
|
||||
### QQ 群組
|
||||
|
||||
- 12 群:916228568 (新)
|
||||
- 9 群:1076659624 (人滿)
|
||||
- 10 群:1078079676 (人滿)
|
||||
- 11 群:704659519 (人滿)
|
||||
- 1 群:322154837 (人滿)
|
||||
- 3 群:630166526 (人滿)
|
||||
- 4 群:1077826412 (人滿)
|
||||
- 5 群:822130018 (人滿)
|
||||
- 6 群:753075035 (人滿)
|
||||
- 7 群:743746109 (人滿)
|
||||
- 8 群:1030353265 (人滿)
|
||||
- 開發者群(闲聊吹水):975206796
|
||||
- 開發者群(正式):1039761811
|
||||
- 1 群:322154837
|
||||
- 3 群:630166526
|
||||
- 5 群:822130018
|
||||
- 6 群:753075035
|
||||
- 開發者群:975206796
|
||||
|
||||
### 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 群組
|
||||
|
||||
@@ -248,7 +252,7 @@ pre-commit install
|
||||
特別感謝所有 Contributors 和外掛開發者對 AstrBot 的貢獻 ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=300&columns=15" />
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
||||
</a>
|
||||
|
||||
此外,本專案的誕生離不開以下開源專案的幫助:
|
||||
|
||||
319
README_zh.md
319
README_zh.md
@@ -1,319 +0,0 @@
|
||||

|
||||
|
||||
<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_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>
|
||||
|
||||
<div>
|
||||
<a href="https://trendshift.io/repositories/21369" target="_blank"><img src="https://trendshift.io/api/badge/repositories/21369" alt="AstrBotDevs%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=%E4%B8%AA&label=%E6%8F%92%E4%BB%B6%E5%B8%82%E5%9C%BA&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://github.com/AstrBotDevs/AstrBot/issues">问题提交</a> |
|
||||
<a href="mailto:community@astrbot.app">Email</a>
|
||||
|
||||
</div>
|
||||
|
||||
AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、Telegram、企业微信、飞书、钉钉、Slack 等数十款主流即时通讯软件上部署,此外还内置类似 OpenWebUI 的轻量化 ChatUI,为个人、开发者和团队打造可靠、可扩展的对话式智能基础设施。无论是个人 AI 伙伴、智能客服、自动化助手,还是企业知识库,AstrBot 都能在你的即时通讯软件平台的工作流中快速构建 AI 应用。
|
||||
|
||||

|
||||
|
||||
## 主要功能
|
||||
|
||||
1. 💯 免费 & 开源。
|
||||
2. ✨ AI 大模型对话,多模态,Agent,MCP,Skills,知识库,人格设定,自动压缩对话。
|
||||
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 内置代理沙盒、网页搜索等。
|
||||
9. 🌐 国际化(i18n)支持。
|
||||
|
||||
<br>
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<th>💙 角色扮演 & 情感陪伴</th>
|
||||
<th>✨ 主动式 Agent</th>
|
||||
<th>🚀 通用 Agentic 能力</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>
|
||||
<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>
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 一键部署
|
||||
|
||||
对于想快速体验 AstrBot、且熟悉命令行并能够自行安装 `uv` 环境的用户,我们推荐使用 `uv` 一键部署方式 ⚡️。
|
||||
|
||||
```bash
|
||||
uv tool install astrbot --python 3.12
|
||||
astrbot init # 仅首次执行此命令以初始化环境
|
||||
astrbot run # astrbot run --backend-only 仅启动后端服务
|
||||
|
||||
# 安装开发版本(更多修复,新功能,但不够稳定,适合开发者)
|
||||
uv tool install git+https://github.com/AstrBotDevs/AstrBot@dev
|
||||
```
|
||||
|
||||
> 需要安装 [uv](https://docs.astral.sh/uv/)。
|
||||
> AstrBot 需要 Python 3.12 或更高版本。`--python 3.12` 会确保 `uv` 使用 Python 3.12 创建 tool 环境。
|
||||
|
||||
> [!NOTE]
|
||||
> 对于 macOS 用户:由于 macOS 安全检查,首次运行 `astrbot` 命令可能需要较长时间(约 10-20 秒)。
|
||||
|
||||
更新 `astrbot`:
|
||||
|
||||
```bash
|
||||
uv tool upgrade astrbot --python 3.12
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> 通过 `uv` 部署的 AstrBot **不支持在 WebUI 中进行版本升级**。如需更新,请通过命令行执行上述命令。
|
||||
|
||||
### Docker 部署
|
||||
|
||||
对于熟悉容器、希望获得更稳定且更适合生产环境部署方式的用户,我们推荐使用 Docker / Docker Compose 部署 AstrBot。
|
||||
|
||||
请参考官方文档 [使用 Docker 部署 AstrBot](https://docs.astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot)。
|
||||
|
||||
### 在 雨云 上部署
|
||||
|
||||
对于希望一键部署 AstrBot 且不想自行管理服务器的用户,我们推荐使用雨云的一键云部署服务 ☁️:
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||
|
||||
### 桌面客户端部署
|
||||
|
||||
对于希望在桌面端使用 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)
|
||||
|
||||
### AUR
|
||||
|
||||
AUR 方式面向 Arch Linux 用户,适合希望通过系统包管理器安装 AstrBot 的场景。
|
||||
|
||||
在终端执行下方命令安装 `astrbot-git` 包,安装完成后即可启动使用。
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
```
|
||||
|
||||
**更多部署方式**
|
||||
|
||||
若你需要面板化或更高自定义部署,可参考 [宝塔面板](https://docs.astrbot.app/deploy/astrbot/btpanel.html)(BT Panel 应用商店安装)、[1Panel](https://docs.astrbot.app/deploy/astrbot/1panel.html)(1Panel 应用商店安装)、[CasaOS](https://docs.astrbot.app/deploy/astrbot/casaos.html)(NAS / 家庭服务器可视化部署)和 [手动部署](https://docs.astrbot.app/deploy/astrbot/cli.html)(基于源码与 `uv` 的完整自定义安装)。
|
||||
|
||||
## 支持的消息平台
|
||||
|
||||
将 AstrBot 连接到你常用的聊天平台。
|
||||
|
||||
| 平台 | 维护方 |
|
||||
|---------|---------------|
|
||||
| **QQ** | 官方维护 |
|
||||
| **OneBot v11** | 官方维护 |
|
||||
| **Telegram** | 官方维护 |
|
||||
| **企微应用 & 企微智能机器人** | 官方维护 |
|
||||
| **微信客服 & 微信公众号** | 官方维护 |
|
||||
| **飞书** | 官方维护 |
|
||||
| **钉钉** | 官方维护 |
|
||||
| **Slack** | 官方维护 |
|
||||
| **Discord** | 官方维护 |
|
||||
| **LINE** | 官方维护 |
|
||||
| **Satori** | 官方维护 |
|
||||
| **KOOK** | 官方维护 |
|
||||
| **Misskey** | 官方维护 |
|
||||
| **Mattermost** | 官方维护 |
|
||||
| **WhatsApp(将支持)** | 官方维护 |
|
||||
| [**Matrix**](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | 社区维护 |
|
||||
| [**Rocket.Chat**](https://github.com/NET-Homeless/astrbot_plugin_rocket_chat_adapter) | 社区维护 |
|
||||
| [**VoceChat**](https://github.com/HikariFroya/astrbot_plugin_vocechat) | 社区维护 |
|
||||
|
||||
## 支持的模型提供商
|
||||
|
||||
| 提供商 | 类型 |
|
||||
|---------|---------------|
|
||||
| 自定义 | 任何 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 | 语音转文本 |
|
||||
| Xiaomi MiMo Omni | 语音转文本 |
|
||||
| OpenAI TTS | 文本转语音 |
|
||||
| Gemini TTS | 文本转语音 |
|
||||
| GPT-Sovits-Inference | 文本转语音 |
|
||||
| GPT-Sovits | 文本转语音 |
|
||||
| FishAudio | 文本转语音 |
|
||||
| Edge TTS | 文本转语音 |
|
||||
| 阿里云百炼 TTS | 文本转语音 |
|
||||
| Azure TTS | 文本转语音 |
|
||||
| Minimax TTS | 文本转语音 |
|
||||
| Xiaomi MiMo TTS | 文本转语音 |
|
||||
| 火山引擎 TTS | 文本转语音 |
|
||||
|
||||
## ❤️ Sponsors
|
||||
|
||||
<p align="center">
|
||||
<img alt="sponsors" src="https://sponsors.astrbot.app/?v=1">
|
||||
</p>
|
||||
|
||||
|
||||
## ❤️ 贡献
|
||||
|
||||
欢迎任何 Issues/Pull Requests!只需要将你的更改提交到此项目 :)
|
||||
|
||||
### 如何贡献
|
||||
|
||||
你可以通过查看问题或帮助审核 PR(拉取请求)来贡献。任何问题或 PR 都欢迎参与,以促进社区贡献。当然,这些只是建议,你可以以任何方式进行贡献。对于新功能的添加,请先通过 Issue 讨论。
|
||||
建议将功能性PR合并至dev分支,将在测试修改后合并到主分支并发布新版本。
|
||||
为了减少冲突,建议如下:
|
||||
1. 工作分支最好基于 `dev` 分支创建,避免直接在 `main` 分支上工作。
|
||||
2. 提交 PR 时,选择 `dev` 分支作为目标分支。
|
||||
3. 定期同步 `dev` 分支到本地,多使用git pull。
|
||||
|
||||
### 开发环境
|
||||
|
||||
AstrBot 使用 `ruff` 进行代码格式化和检查。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AstrBotDevs/AstrBot
|
||||
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 群组
|
||||
|
||||
- 1 群:322154837 (人满)
|
||||
- 3 群:630166526 (人满)
|
||||
- 4 群:1077826412 (人满)
|
||||
- 5 群:822130018 (人满)
|
||||
- 6 群:753075035 (人满)
|
||||
- 7 群:743746109 (人满)
|
||||
- 8 群:1030353265 (人满)
|
||||
- 9 群:1076659624 (人满)
|
||||
- 10 群:1078079676 (人满)
|
||||
- 11 群:704659519 (人满)
|
||||
- 12 群:916228568 (人满)
|
||||
- 13 群:1092185289
|
||||
- 14 群:1103419483
|
||||
|
||||
|
||||
- 开发者群(偏闲聊吹水):975206796
|
||||
- 开发者群(正式):1039761811
|
||||
|
||||
### Discord 频道
|
||||
|
||||
- [Discord](https://discord.gg/hAVk6tgV36)
|
||||
|
||||
## ❤️ Special Thanks
|
||||
|
||||
特别感谢所有 Contributors 和插件开发者对 AstrBot 的贡献 ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=300&columns=15" />
|
||||
</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
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://star-history.com/#astrbotdevs/astrbot&Date)
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
_陪伴与能力从来不应该是对立面。我们希望创造的是一个既能理解情绪、给予陪伴,也能可靠完成工作的机器人。_
|
||||
|
||||
_私は、高性能ですから!_
|
||||
|
||||
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
|
||||
|
||||
</div>
|
||||
@@ -1,30 +1,3 @@
|
||||
from __future__ import annotations
|
||||
from .core.log import LogManager
|
||||
|
||||
from importlib.metadata import PackageNotFoundError
|
||||
from importlib.metadata import version as _pkg_version
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
try:
|
||||
__version__ = _pkg_version("astrbot")
|
||||
except PackageNotFoundError:
|
||||
__version__ = "4.26.1"
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .core import logger as logger
|
||||
|
||||
__all__ = ["logger"]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name == "cli":
|
||||
from astrbot.cli.__main__ import cli
|
||||
|
||||
return cli()
|
||||
|
||||
if name == "logger":
|
||||
from .core import logger
|
||||
|
||||
return logger
|
||||
|
||||
raise AttributeError(name)
|
||||
logger = LogManager.GetLogger(log_name="astrbot")
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
import mimetypes
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import anyio
|
||||
|
||||
from astrbot.core import LogBroker, LogManager, db_helper, logger
|
||||
from astrbot.core.config.default import VERSION
|
||||
from astrbot.core.initial_loader import InitialLoader
|
||||
from astrbot.core.utils.astrbot_path import (
|
||||
get_astrbot_config_path,
|
||||
get_astrbot_data_path,
|
||||
get_astrbot_knowledge_base_path,
|
||||
get_astrbot_plugin_path,
|
||||
get_astrbot_root,
|
||||
get_astrbot_site_packages_path,
|
||||
get_astrbot_skills_path,
|
||||
get_astrbot_temp_path,
|
||||
)
|
||||
from astrbot.core.utils.io import (
|
||||
download_dashboard,
|
||||
get_dashboard_version,
|
||||
)
|
||||
|
||||
# 将父目录添加到 sys.path
|
||||
sys.path.append(Path(__file__).parent.as_posix())
|
||||
|
||||
logo_tmpl = r"""
|
||||
___ _______.___________..______ .______ ______ .___________.
|
||||
/ \ / | || _ \ | _ \ / __ \ | |
|
||||
/ ^ \ | (----`---| |----`| |_) | | |_) | | | | | `---| |----`
|
||||
/ /_\ \ \ \ | | | / | _ < | | | | | |
|
||||
/ _____ \ .----) | | | | |\ \----.| |_) | | `--' | | |
|
||||
/__/ \__\ |_______/ |__| | _| `._____||______/ \______/ |__|
|
||||
|
||||
"""
|
||||
|
||||
|
||||
def check_env() -> None:
|
||||
# Python version check: require 3.12 or 3.13
|
||||
if not (sys.version_info.major == 3 and sys.version_info.minor in (12, 13)):
|
||||
sys.exit(1)
|
||||
|
||||
astrbot_root = get_astrbot_root()
|
||||
if astrbot_root not in sys.path:
|
||||
sys.path.insert(0, astrbot_root)
|
||||
|
||||
site_packages_path = get_astrbot_site_packages_path()
|
||||
if site_packages_path not in sys.path:
|
||||
sys.path.insert(0, site_packages_path)
|
||||
|
||||
os.makedirs(get_astrbot_config_path(), exist_ok=True)
|
||||
os.makedirs(get_astrbot_plugin_path(), exist_ok=True)
|
||||
os.makedirs(get_astrbot_temp_path(), exist_ok=True)
|
||||
os.makedirs(get_astrbot_knowledge_base_path(), exist_ok=True)
|
||||
os.makedirs(get_astrbot_skills_path(), exist_ok=True)
|
||||
os.makedirs(site_packages_path, exist_ok=True)
|
||||
|
||||
# 针对问题 #181 的临时解决方案
|
||||
mimetypes.add_type("text/javascript", ".js")
|
||||
mimetypes.add_type("text/javascript", ".mjs")
|
||||
mimetypes.add_type("application/json", ".json")
|
||||
|
||||
|
||||
async def check_dashboard_files(webui_dir: str | None = None):
|
||||
"""下载管理面板文件"""
|
||||
# 指定webui目录
|
||||
if webui_dir:
|
||||
if await anyio.Path(webui_dir).exists():
|
||||
logger.info(f"使用指定的 WebUI 目录: {webui_dir}")
|
||||
return webui_dir
|
||||
logger.warning(f"指定的 WebUI 目录 {webui_dir} 不存在,将使用默认逻辑。")
|
||||
|
||||
data_dist_path = os.path.join(get_astrbot_data_path(), "dist")
|
||||
if await anyio.Path(data_dist_path).exists():
|
||||
v = await get_dashboard_version()
|
||||
if v is not None:
|
||||
# 存在文件
|
||||
if v == f"v{VERSION}":
|
||||
logger.info("WebUI 版本已是最新。")
|
||||
else:
|
||||
logger.warning(
|
||||
f"检测到 WebUI 版本 ({v}) 与当前 AstrBot 版本 (v{VERSION}) 不符。",
|
||||
)
|
||||
return data_dist_path
|
||||
|
||||
logger.info(
|
||||
"开始下载管理面板文件...高峰期(晚上)可能导致较慢的速度。如多次下载失败,请前往 https://github.com/AstrBotDevs/AstrBot/releases/latest 下载 dist.zip,并将其中的 dist 文件夹解压至 data 目录下。",
|
||||
)
|
||||
|
||||
try:
|
||||
await download_dashboard(version=f"v{VERSION}", latest=False)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"下载指定版本(v{VERSION})的管理面板文件失败: {e},尝试下载最新版本。",
|
||||
)
|
||||
try:
|
||||
await download_dashboard(latest=True)
|
||||
except Exception as e:
|
||||
logger.critical(f"下载管理面板文件失败: {e}。")
|
||||
return None
|
||||
|
||||
logger.info("管理面板下载完成。")
|
||||
return data_dist_path
|
||||
|
||||
|
||||
async def main_async(webui_dir_arg: str | None, log_broker: LogBroker) -> None:
|
||||
"""主异步入口"""
|
||||
# 检查仪表板文件
|
||||
webui_dir = await check_dashboard_files(webui_dir_arg)
|
||||
if webui_dir is None:
|
||||
logger.warning(
|
||||
"管理面板文件检查失败,WebUI 功能将不可用。"
|
||||
"请检查网络连接或手动指定 --webui-dir 参数。",
|
||||
)
|
||||
|
||||
db = db_helper
|
||||
|
||||
# 打印 logo
|
||||
logger.info(logo_tmpl)
|
||||
|
||||
core_lifecycle = InitialLoader(db, log_broker)
|
||||
core_lifecycle.webui_dir = webui_dir
|
||||
await core_lifecycle.start()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="AstrBot")
|
||||
parser.add_argument(
|
||||
"--webui-dir",
|
||||
type=str,
|
||||
help="指定 WebUI 静态文件目录路径",
|
||||
default=None,
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
check_env()
|
||||
|
||||
# 启动日志代理
|
||||
log_broker = LogBroker()
|
||||
LogManager.set_queue_handler(logger, log_broker)
|
||||
|
||||
# 只使用一次 asyncio.run()
|
||||
asyncio.run(main_async(args.webui_dir, log_broker))
|
||||
@@ -1,4 +1,3 @@
|
||||
# ruff: noqa: F401, F403, F811, I001
|
||||
from astrbot.core.config.astrbot_config import AstrBotConfig
|
||||
from astrbot import logger
|
||||
from astrbot.core import html_renderer
|
||||
@@ -30,7 +29,7 @@ from astrbot.core.star.filter.platform_adapter_type import (
|
||||
PlatformAdapterType,
|
||||
)
|
||||
from astrbot.core.star.register import (
|
||||
register_star as register, # 注册插件(Star)
|
||||
register_star as register, # 注册插件(Star)
|
||||
)
|
||||
from astrbot.core.star import Context, Star
|
||||
from astrbot.core.star.config import *
|
||||
@@ -52,4 +51,4 @@ from astrbot.core.platform import (
|
||||
|
||||
from astrbot.core.platform.register import register_platform_adapter
|
||||
|
||||
from .message_components import *
|
||||
from .message_components import *
|
||||
@@ -14,8 +14,6 @@ from astrbot.core.star.register import register_command_group as command_group
|
||||
from astrbot.core.star.register import register_custom_filter as custom_filter
|
||||
from astrbot.core.star.register import register_event_message_type as event_message_type
|
||||
from astrbot.core.star.register import register_llm_tool as llm_tool
|
||||
from astrbot.core.star.register import register_on_agent_begin as on_agent_begin
|
||||
from astrbot.core.star.register import register_on_agent_done as on_agent_done
|
||||
from astrbot.core.star.register import register_on_astrbot_loaded as on_astrbot_loaded
|
||||
from astrbot.core.star.register import (
|
||||
register_on_decorating_result as on_decorating_result,
|
||||
@@ -26,9 +24,6 @@ from astrbot.core.star.register import (
|
||||
register_on_llm_tool_respond as on_llm_tool_respond,
|
||||
)
|
||||
from astrbot.core.star.register import register_on_platform_loaded as on_platform_loaded
|
||||
from astrbot.core.star.register import register_on_plugin_error as on_plugin_error
|
||||
from astrbot.core.star.register import register_on_plugin_loaded as on_plugin_loaded
|
||||
from astrbot.core.star.register import register_on_plugin_unloaded as on_plugin_unloaded
|
||||
from astrbot.core.star.register import register_on_using_llm_tool as on_using_llm_tool
|
||||
from astrbot.core.star.register import (
|
||||
register_on_waiting_llm_request as on_waiting_llm_request,
|
||||
@@ -53,20 +48,15 @@ __all__ = [
|
||||
"custom_filter",
|
||||
"event_message_type",
|
||||
"llm_tool",
|
||||
"on_agent_begin",
|
||||
"on_agent_done",
|
||||
"on_astrbot_loaded",
|
||||
"on_decorating_result",
|
||||
"on_llm_request",
|
||||
"on_llm_response",
|
||||
"on_llm_tool_respond",
|
||||
"on_platform_loaded",
|
||||
"on_plugin_error",
|
||||
"on_plugin_loaded",
|
||||
"on_plugin_unloaded",
|
||||
"on_using_llm_tool",
|
||||
"on_waiting_llm_request",
|
||||
"permission_type",
|
||||
"platform_adapter_type",
|
||||
"regex",
|
||||
"on_using_llm_tool",
|
||||
"on_llm_tool_respond",
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from astrbot.core.star import Context, Star, StarTools
|
||||
from astrbot.core.star.config import *
|
||||
from astrbot.core.star.register import (
|
||||
register_star as register, # 注册插件(Star)
|
||||
register_star as register, # 注册插件(Star)
|
||||
)
|
||||
|
||||
__all__ = ["Context", "Star", "StarTools", "register"]
|
||||
|
||||
@@ -1,453 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
from collections.abc import Callable, KeysView
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any, Generic, TypeVar, overload
|
||||
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from starlette.datastructures import Headers
|
||||
from starlette.datastructures import UploadFile as StarletteUploadFile
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
ValueT = TypeVar("ValueT")
|
||||
DefaultT = TypeVar("DefaultT")
|
||||
ConvertedT = TypeVar("ConvertedT")
|
||||
|
||||
|
||||
class PluginMultiDict(Generic[ValueT]):
|
||||
"""Dictionary-like request values that preserves duplicate keys."""
|
||||
|
||||
def __init__(self, pairs: list[tuple[str, ValueT]]) -> None:
|
||||
self._pairs = pairs
|
||||
|
||||
@overload
|
||||
def get(self, key: str) -> ValueT | None: ...
|
||||
|
||||
@overload
|
||||
def get(self, key: str, default: DefaultT) -> ValueT | DefaultT: ...
|
||||
|
||||
@overload
|
||||
def get(
|
||||
self,
|
||||
key: str,
|
||||
default: DefaultT,
|
||||
type: Callable[[ValueT], ConvertedT],
|
||||
) -> ConvertedT | DefaultT: ...
|
||||
|
||||
def get(self, key: str, default: Any = None, type: Callable | None = None):
|
||||
"""Return the last value for a key.
|
||||
|
||||
Args:
|
||||
key: Value key to read.
|
||||
default: Value returned when the key is missing or conversion fails.
|
||||
type: Optional callable used to convert the value.
|
||||
|
||||
Returns:
|
||||
The matching value, converted value, or default.
|
||||
"""
|
||||
for item_key, item_value in reversed(self._pairs):
|
||||
if item_key != key:
|
||||
continue
|
||||
if type is None:
|
||||
return item_value
|
||||
try:
|
||||
return type(item_value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return default
|
||||
|
||||
def getlist(self, key: str) -> list[ValueT]:
|
||||
"""Return all values for a key.
|
||||
|
||||
Args:
|
||||
key: Value key to read.
|
||||
|
||||
Returns:
|
||||
Values in request order.
|
||||
"""
|
||||
return [item_value for item_key, item_value in self._pairs if item_key == key]
|
||||
|
||||
def keys(self) -> KeysView[str]:
|
||||
return dict.fromkeys(item_key for item_key, _ in self._pairs).keys()
|
||||
|
||||
def values(self) -> list[ValueT]:
|
||||
return [self[key] for key in self.keys()]
|
||||
|
||||
def items(self) -> list[tuple[str, ValueT]]:
|
||||
return [(key, self[key]) for key in self.keys()]
|
||||
|
||||
def __contains__(self, key: str) -> bool:
|
||||
return any(item_key == key for item_key, _ in self._pairs)
|
||||
|
||||
def __getitem__(self, key: str) -> ValueT:
|
||||
value = self.get(key)
|
||||
if value is None and key not in self:
|
||||
raise KeyError(key)
|
||||
return value
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self._pairs)
|
||||
|
||||
|
||||
class PluginUploadFile:
|
||||
"""Uploaded file wrapper exposed to plugin Web API handlers."""
|
||||
|
||||
def __init__(self, upload_file: StarletteUploadFile) -> None:
|
||||
self._upload_file: StarletteUploadFile = upload_file
|
||||
self.filename: str | None = upload_file.filename
|
||||
self.content_type: str | None = upload_file.content_type
|
||||
self.headers: Headers = upload_file.headers
|
||||
self.content_length: int | None = self._resolve_content_length()
|
||||
|
||||
def _resolve_content_length(self) -> int | None:
|
||||
try:
|
||||
raw = self.headers.get("content-length")
|
||||
return int(raw) if raw else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
async def save(self, destination: str | Path) -> None:
|
||||
"""Save the uploaded file to disk.
|
||||
|
||||
Args:
|
||||
destination: Destination file path.
|
||||
"""
|
||||
path = Path(destination)
|
||||
try:
|
||||
await self._upload_file.seek(0)
|
||||
except Exception:
|
||||
pass
|
||||
with path.open("wb") as output:
|
||||
while True:
|
||||
chunk = await self._upload_file.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
output.write(chunk)
|
||||
|
||||
async def read(self, size: int = -1) -> bytes:
|
||||
"""Read bytes from the uploaded file.
|
||||
|
||||
Args:
|
||||
size: Maximum number of bytes to read. Use -1 to read all bytes.
|
||||
|
||||
Returns:
|
||||
File bytes.
|
||||
"""
|
||||
return await self._upload_file.read(size)
|
||||
|
||||
async def write(self, data: bytes) -> None:
|
||||
"""Write bytes to the uploaded file object.
|
||||
|
||||
Args:
|
||||
data: Bytes to write.
|
||||
"""
|
||||
await self._upload_file.write(data)
|
||||
|
||||
async def seek(self, offset: int) -> None:
|
||||
"""Move the uploaded file cursor.
|
||||
|
||||
Args:
|
||||
offset: Absolute byte offset.
|
||||
"""
|
||||
await self._upload_file.seek(offset)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the uploaded file."""
|
||||
await self._upload_file.close()
|
||||
|
||||
def __getattr__(self, key: str) -> Any:
|
||||
return getattr(self._upload_file, key)
|
||||
|
||||
|
||||
class PluginRequest:
|
||||
"""Request object exposed to plugin Web API handlers."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
request_: Any,
|
||||
*,
|
||||
path_params: dict[str, Any] | None = None,
|
||||
plugin_name: str | None = None,
|
||||
username: str | None = None,
|
||||
) -> None:
|
||||
self._request: Any = request_
|
||||
self.method: str = request_.method
|
||||
self.path: str = request_.url.path
|
||||
self.headers: Headers = request_.headers
|
||||
self.cookies: dict[str, str] = request_.cookies
|
||||
self.content_type: str | None = request_.headers.get("content-type")
|
||||
self.client_host: str | None = request_.client.host if request_.client else None
|
||||
self.path_params: dict[str, Any] = path_params or {}
|
||||
self.plugin_name: str | None = plugin_name
|
||||
self.username: str | None = username
|
||||
self.query: PluginMultiDict[str] = PluginMultiDict[str](
|
||||
list(request_.query_params.multi_items())
|
||||
)
|
||||
self._form_cache: PluginMultiDict[str] | None = None
|
||||
self._files_cache: PluginMultiDict[PluginUploadFile] | None = None
|
||||
|
||||
async def body(self) -> bytes:
|
||||
"""Read the raw request body.
|
||||
|
||||
Returns:
|
||||
Raw request body bytes.
|
||||
"""
|
||||
return await self._request.body()
|
||||
|
||||
async def json(self, default: DefaultT | None = None) -> Any | DefaultT | None:
|
||||
"""Read the JSON request body.
|
||||
|
||||
Args:
|
||||
default: Value returned when the request body cannot be parsed as JSON.
|
||||
|
||||
Returns:
|
||||
Parsed JSON data or default.
|
||||
"""
|
||||
try:
|
||||
return await self._request.json()
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
async def _load_form_parts(self) -> None:
|
||||
if self._form_cache is not None and self._files_cache is not None:
|
||||
return
|
||||
form = await self._request.form()
|
||||
form_pairs: list[tuple[str, str]] = []
|
||||
file_pairs: list[tuple[str, PluginUploadFile]] = []
|
||||
for key, value in form.multi_items():
|
||||
if isinstance(value, StarletteUploadFile):
|
||||
file_pairs.append((key, PluginUploadFile(value)))
|
||||
else:
|
||||
form_pairs.append((key, value))
|
||||
self._form_cache = PluginMultiDict(form_pairs)
|
||||
self._files_cache = PluginMultiDict(file_pairs)
|
||||
|
||||
async def form(self) -> PluginMultiDict[str]:
|
||||
"""Read form fields from a multipart or form-urlencoded request.
|
||||
|
||||
Returns:
|
||||
Form values without uploaded files.
|
||||
"""
|
||||
await self._load_form_parts()
|
||||
assert self._form_cache is not None
|
||||
return self._form_cache
|
||||
|
||||
async def files(self) -> PluginMultiDict[PluginUploadFile]:
|
||||
"""Read uploaded files from a multipart request.
|
||||
|
||||
Returns:
|
||||
Uploaded file values.
|
||||
"""
|
||||
await self._load_form_parts()
|
||||
assert self._files_cache is not None
|
||||
return self._files_cache
|
||||
|
||||
|
||||
_request_var: contextvars.ContextVar[PluginRequest] = contextvars.ContextVar(
|
||||
"astrbot_plugin_web_request"
|
||||
)
|
||||
|
||||
|
||||
class PluginRequestProxy:
|
||||
"""Typed proxy for the request bound to the current plugin Web handler."""
|
||||
|
||||
def _get_current(self) -> PluginRequest:
|
||||
try:
|
||||
return _request_var.get()
|
||||
except LookupError as exc:
|
||||
raise RuntimeError(
|
||||
"astrbot.api.web.request is only available inside a plugin Web API "
|
||||
"handler."
|
||||
) from exc
|
||||
|
||||
@property
|
||||
def method(self) -> str:
|
||||
return self._get_current().method
|
||||
|
||||
@property
|
||||
def path(self) -> str:
|
||||
return self._get_current().path
|
||||
|
||||
@property
|
||||
def headers(self) -> Headers:
|
||||
return self._get_current().headers
|
||||
|
||||
@property
|
||||
def cookies(self) -> dict[str, str]:
|
||||
return self._get_current().cookies
|
||||
|
||||
@property
|
||||
def content_type(self) -> str | None:
|
||||
return self._get_current().content_type
|
||||
|
||||
@property
|
||||
def client_host(self) -> str | None:
|
||||
return self._get_current().client_host
|
||||
|
||||
@property
|
||||
def path_params(self) -> dict[str, Any]:
|
||||
return self._get_current().path_params
|
||||
|
||||
@property
|
||||
def plugin_name(self) -> str | None:
|
||||
return self._get_current().plugin_name
|
||||
|
||||
@property
|
||||
def username(self) -> str | None:
|
||||
return self._get_current().username
|
||||
|
||||
@property
|
||||
def query(self) -> PluginMultiDict[str]:
|
||||
return self._get_current().query
|
||||
|
||||
async def body(self) -> bytes:
|
||||
return await self._get_current().body()
|
||||
|
||||
async def json(self, default: DefaultT | None = None) -> Any | DefaultT | None:
|
||||
return await self._get_current().json(default=default)
|
||||
|
||||
async def form(self) -> PluginMultiDict[str]:
|
||||
return await self._get_current().form()
|
||||
|
||||
async def files(self) -> PluginMultiDict[PluginUploadFile]:
|
||||
return await self._get_current().files()
|
||||
|
||||
def __getattr__(self, key: str) -> Any:
|
||||
return getattr(self._get_current(), key)
|
||||
|
||||
|
||||
request: PluginRequestProxy = PluginRequestProxy()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def bind_request_context(request_: PluginRequest):
|
||||
"""Bind a plugin Web request for the current async context.
|
||||
|
||||
Args:
|
||||
request_: Request object exposed through the module-level request proxy.
|
||||
|
||||
Yields:
|
||||
The bound request object.
|
||||
"""
|
||||
token = _request_var.set(request_)
|
||||
try:
|
||||
yield request_
|
||||
finally:
|
||||
_request_var.reset(token)
|
||||
|
||||
|
||||
def json_response(
|
||||
data: Any = None,
|
||||
*,
|
||||
status_code: int = 200,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> JSONResponse:
|
||||
"""Build a JSON response for plugin Web API handlers.
|
||||
|
||||
Args:
|
||||
data: JSON-serializable response body.
|
||||
status_code: HTTP status code.
|
||||
headers: Optional response headers.
|
||||
|
||||
Returns:
|
||||
A Starlette/FastAPI JSON response.
|
||||
"""
|
||||
return JSONResponse(
|
||||
jsonable_encoder({} if data is None else data),
|
||||
status_code=status_code,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
def error_response(
|
||||
message: str,
|
||||
*,
|
||||
status_code: int = 400,
|
||||
data: Any = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> JSONResponse:
|
||||
"""Build a standard error response for plugin bridge calls.
|
||||
|
||||
Args:
|
||||
message: Public error message.
|
||||
status_code: HTTP status code.
|
||||
data: Optional error details that are safe to expose.
|
||||
headers: Optional response headers.
|
||||
|
||||
Returns:
|
||||
A JSON response with the AstrBot error envelope.
|
||||
"""
|
||||
return json_response(
|
||||
{"status": "error", "message": message, "data": data},
|
||||
status_code=status_code,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
def file_response(
|
||||
path: str | Path,
|
||||
*,
|
||||
filename: str | None = None,
|
||||
content_type: str | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> FileResponse:
|
||||
"""Build a file download response for plugin Web API handlers.
|
||||
|
||||
Args:
|
||||
path: File path to send.
|
||||
filename: Optional download filename.
|
||||
content_type: Optional response media type.
|
||||
headers: Optional response headers.
|
||||
|
||||
Returns:
|
||||
A Starlette/FastAPI file response.
|
||||
"""
|
||||
return FileResponse(
|
||||
path,
|
||||
filename=filename,
|
||||
media_type=content_type,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
def stream_response(
|
||||
content: Any,
|
||||
*,
|
||||
content_type: str = "text/event-stream",
|
||||
status_code: int = 200,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> StreamingResponse:
|
||||
"""Build a streaming response for plugin Web API handlers.
|
||||
|
||||
Args:
|
||||
content: Sync or async iterable that yields response chunks.
|
||||
content_type: Response media type.
|
||||
status_code: HTTP status code.
|
||||
headers: Optional response headers.
|
||||
|
||||
Returns:
|
||||
A Starlette/FastAPI streaming response.
|
||||
"""
|
||||
return StreamingResponse(
|
||||
content,
|
||||
media_type=content_type,
|
||||
status_code=status_code,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PluginMultiDict",
|
||||
"PluginRequest",
|
||||
"PluginRequestProxy",
|
||||
"PluginUploadFile",
|
||||
"bind_request_context",
|
||||
"error_response",
|
||||
"file_response",
|
||||
"json_response",
|
||||
"request",
|
||||
"stream_response",
|
||||
]
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"metadata": {
|
||||
"display_name": "AstrBot",
|
||||
"desc": "AstrBot's internal plugin, providing some basic capabilities."
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"metadata": {
|
||||
"display_name": "AstrBot",
|
||||
"desc": "AstrBot 的内部插件,提供一些基础能力。"
|
||||
}
|
||||
}
|
||||
@@ -1,302 +0,0 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
import random
|
||||
import uuid
|
||||
from collections import defaultdict, deque
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.api import star
|
||||
from astrbot.api.event import AstrMessageEvent
|
||||
from astrbot.api.message_components import (
|
||||
At,
|
||||
AtAll,
|
||||
Face,
|
||||
File,
|
||||
Forward,
|
||||
Image,
|
||||
Plain,
|
||||
Record,
|
||||
Reply,
|
||||
Video,
|
||||
)
|
||||
from astrbot.api.platform import MessageType
|
||||
from astrbot.api.provider import Provider, ProviderRequest
|
||||
from astrbot.core.agent.message import TextPart
|
||||
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
|
||||
|
||||
"""
|
||||
Group chat context awareness.
|
||||
"""
|
||||
|
||||
GROUP_HISTORY_HEADER = (
|
||||
"<system_reminder>"
|
||||
"You are in a group chat. "
|
||||
"Belows are group chat context after your last reply:\n"
|
||||
"--- BEGIN CONTEXT---\n"
|
||||
)
|
||||
GROUP_HISTORY_FOOTER = "\n--- END CONTEXT ---\n</system_reminder>"
|
||||
DEFAULT_GROUP_MESSAGE_MAX_CNT = 300
|
||||
|
||||
|
||||
class GroupChatContext:
|
||||
def __init__(self, acm: AstrBotConfigManager, context: star.Context) -> None:
|
||||
self.acm = acm
|
||||
self.context = context
|
||||
self._locks: dict[str, asyncio.Lock] = {}
|
||||
self.raw_records: dict[str, deque[str]] = defaultdict(deque)
|
||||
self._record_ids: dict[str, deque[str]] = defaultdict(deque)
|
||||
|
||||
def _get_lock(self, umo: str) -> asyncio.Lock:
|
||||
lock = self._locks.get(umo)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
self._locks[umo] = lock
|
||||
return lock
|
||||
|
||||
def cfg(self, event: AstrMessageEvent):
|
||||
cfg = self.context.get_config(umo=event.unified_msg_origin)
|
||||
group_context_cfg = cfg["provider_ltm_settings"]
|
||||
image_caption_prompt = cfg["provider_settings"]["image_caption_prompt"]
|
||||
image_caption_provider_id = group_context_cfg.get("image_caption_provider_id")
|
||||
image_caption = group_context_cfg["image_caption"] and bool(
|
||||
image_caption_provider_id
|
||||
)
|
||||
active_reply = group_context_cfg["active_reply"]
|
||||
enable_active_reply = active_reply.get("enable", False)
|
||||
ar_method = active_reply["method"]
|
||||
ar_possibility = active_reply["possibility_reply"]
|
||||
ar_prompt = active_reply.get("prompt", "")
|
||||
ar_whitelist = active_reply.get("whitelist", [])
|
||||
return {
|
||||
"group_message_max_cnt": _positive_int(
|
||||
group_context_cfg.get(
|
||||
"group_message_max_cnt",
|
||||
DEFAULT_GROUP_MESSAGE_MAX_CNT,
|
||||
),
|
||||
DEFAULT_GROUP_MESSAGE_MAX_CNT,
|
||||
),
|
||||
"image_caption": image_caption,
|
||||
"image_caption_prompt": image_caption_prompt,
|
||||
"image_caption_provider_id": image_caption_provider_id,
|
||||
"enable_active_reply": enable_active_reply,
|
||||
"ar_method": ar_method,
|
||||
"ar_possibility": ar_possibility,
|
||||
"ar_prompt": ar_prompt,
|
||||
"ar_whitelist": ar_whitelist,
|
||||
}
|
||||
|
||||
async def get_image_caption(
|
||||
self,
|
||||
image_url: str,
|
||||
image_caption_provider_id: str,
|
||||
image_caption_prompt: str,
|
||||
) -> str:
|
||||
if not image_caption_provider_id:
|
||||
provider = self.context.get_using_provider()
|
||||
else:
|
||||
provider = self.context.get_provider_by_id(image_caption_provider_id)
|
||||
if not provider:
|
||||
raise Exception(f"没有找到 ID 为 {image_caption_provider_id} 的提供商")
|
||||
if not isinstance(provider, Provider):
|
||||
raise Exception(f"提供商类型错误({type(provider)}),无法获取图片描述")
|
||||
response = await provider.text_chat(
|
||||
prompt=image_caption_prompt,
|
||||
session_id=uuid.uuid4().hex,
|
||||
image_urls=[image_url],
|
||||
persist=False,
|
||||
)
|
||||
return response.completion_text
|
||||
|
||||
async def need_active_reply(self, event: AstrMessageEvent) -> bool:
|
||||
cfg = self.cfg(event)
|
||||
if not cfg["enable_active_reply"]:
|
||||
return False
|
||||
if event.get_message_type() != MessageType.GROUP_MESSAGE:
|
||||
return False
|
||||
if event.is_at_or_wake_command:
|
||||
return False
|
||||
if cfg["ar_whitelist"] and (
|
||||
event.unified_msg_origin not in cfg["ar_whitelist"]
|
||||
and (
|
||||
event.get_group_id() and event.get_group_id() not in cfg["ar_whitelist"]
|
||||
)
|
||||
):
|
||||
return False
|
||||
match cfg["ar_method"]:
|
||||
case "possibility_reply":
|
||||
return random.random() < cfg["ar_possibility"]
|
||||
return False
|
||||
|
||||
async def remove_session(self, event: AstrMessageEvent) -> int:
|
||||
umo = event.unified_msg_origin
|
||||
lock = self._get_lock(umo)
|
||||
async with lock:
|
||||
cnt = len(self.raw_records.get(umo, deque()))
|
||||
self.raw_records.pop(umo, None)
|
||||
self._record_ids.pop(umo, None)
|
||||
self._locks.pop(umo, None)
|
||||
return cnt
|
||||
|
||||
async def handle_message(self, event: AstrMessageEvent) -> None:
|
||||
if event.get_message_type() != MessageType.GROUP_MESSAGE:
|
||||
return
|
||||
|
||||
umo = event.unified_msg_origin
|
||||
cfg = self.cfg(event)
|
||||
final_message = await self._format_message(event, cfg)
|
||||
|
||||
async with self._get_lock(umo):
|
||||
records = self.raw_records[umo]
|
||||
record_ids = self._record_ids[umo]
|
||||
record_id = uuid.uuid4().hex
|
||||
records.append(final_message)
|
||||
record_ids.append(record_id)
|
||||
_trim_left(records, cfg["group_message_max_cnt"], record_ids)
|
||||
event.set_extra("_group_context_record_id", record_id)
|
||||
event.set_extra("_group_context_raw_idx", len(records) - 1)
|
||||
|
||||
logger.debug(f"group_chat_context | {umo} | {final_message}")
|
||||
|
||||
async def on_req_llm(self, event: AstrMessageEvent, req: ProviderRequest) -> None:
|
||||
umo = event.unified_msg_origin
|
||||
record_id = event.get_extra("_group_context_record_id", None)
|
||||
prompt_idx = event.get_extra("_group_context_raw_idx", -1)
|
||||
if not isinstance(record_id, str) and (
|
||||
not isinstance(prompt_idx, int) or prompt_idx < 0
|
||||
):
|
||||
return
|
||||
|
||||
async with self._get_lock(umo):
|
||||
records = self.raw_records.get(umo)
|
||||
if not records:
|
||||
return
|
||||
|
||||
raw_list = list(records)
|
||||
id_list = list(self._record_ids.get(umo, deque()))
|
||||
if isinstance(record_id, str) and record_id in id_list:
|
||||
prompt_idx = id_list.index(record_id)
|
||||
|
||||
if prompt_idx >= len(raw_list):
|
||||
return
|
||||
|
||||
records_to_inject = raw_list[:prompt_idx]
|
||||
remaining = raw_list[prompt_idx + 1 :]
|
||||
remaining_ids = id_list[prompt_idx + 1 :] if id_list else []
|
||||
records.clear()
|
||||
records.extend(remaining)
|
||||
if id_list:
|
||||
record_ids = self._record_ids[umo]
|
||||
record_ids.clear()
|
||||
record_ids.extend(remaining_ids)
|
||||
|
||||
if records_to_inject:
|
||||
req.extra_user_content_parts.append(
|
||||
TextPart(text=_format_group_history_block(records_to_inject))
|
||||
)
|
||||
|
||||
async def _format_message(self, event: AstrMessageEvent, cfg: dict) -> str:
|
||||
datetime_str = datetime.datetime.now().strftime("%H:%M:%S")
|
||||
parts = [f"[{event.message_obj.sender.nickname}/{datetime_str}]: "]
|
||||
|
||||
for comp in event.get_messages():
|
||||
if isinstance(comp, Plain):
|
||||
parts.append(f" {comp.text}")
|
||||
elif isinstance(comp, Image):
|
||||
if cfg["image_caption"]:
|
||||
try:
|
||||
url = comp.url if comp.url else comp.file
|
||||
if not url:
|
||||
raise Exception("图片 URL 为空")
|
||||
caption = await self.get_image_caption(
|
||||
url,
|
||||
cfg["image_caption_provider_id"],
|
||||
cfg["image_caption_prompt"],
|
||||
)
|
||||
parts.append(f" [Image: {caption}]")
|
||||
except Exception as e:
|
||||
logger.error(f"获取图片描述失败: {e}")
|
||||
else:
|
||||
parts.append(" [Image]")
|
||||
elif isinstance(comp, At):
|
||||
is_at_self = str(comp.qq) in (
|
||||
event.get_self_id(),
|
||||
"all",
|
||||
)
|
||||
if is_at_self:
|
||||
parts.insert(1, "⚠️[DIRECTED AT YOU] ")
|
||||
parts.append(f" [At: {comp.name}]")
|
||||
elif isinstance(comp, Reply):
|
||||
if comp.message_str:
|
||||
parts.append(
|
||||
f" [Quote({comp.sender_nickname}: {_truncate_reply_text(comp.message_str)})]"
|
||||
)
|
||||
elif comp.chain:
|
||||
chain_desc = _describe_chain(comp.chain)
|
||||
parts.append(f" [Quote({comp.sender_nickname}: {chain_desc})]")
|
||||
else:
|
||||
parts.append(" [Quote]")
|
||||
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
_MAX_REPLY_TEXT_LENGTH = 200
|
||||
|
||||
|
||||
def _describe_chain(chain: list) -> str:
|
||||
"""Summarize message chain content for quoted reply display."""
|
||||
desc = []
|
||||
for c in chain:
|
||||
if isinstance(c, Plain) and getattr(c, "text", None):
|
||||
desc.append(c.text)
|
||||
elif isinstance(c, Image):
|
||||
desc.append("[Image]")
|
||||
elif isinstance(c, At):
|
||||
name = getattr(c, "name", "") or getattr(c, "qq", "")
|
||||
desc.append(f"[At: {name}]")
|
||||
elif isinstance(c, Record):
|
||||
desc.append("[Voice]")
|
||||
elif isinstance(c, Video):
|
||||
desc.append("[Video]")
|
||||
elif isinstance(c, File):
|
||||
desc.append(f"[File: {getattr(c, 'name', '') or ''}]")
|
||||
elif isinstance(c, Forward):
|
||||
desc.append("[Forward]")
|
||||
elif isinstance(c, AtAll):
|
||||
desc.append("[At: All]")
|
||||
elif isinstance(c, Face):
|
||||
desc.append(f"[Sticker: {getattr(c, 'id', '')}]")
|
||||
elif isinstance(c, Reply):
|
||||
desc.append("[Quote]")
|
||||
else:
|
||||
desc.append(f"[{c.__class__.__name__}]")
|
||||
return "".join(desc) or "[Unknown]"
|
||||
|
||||
|
||||
def _truncate_reply_text(text: str) -> str:
|
||||
"""Truncate overly long quoted reply text."""
|
||||
if len(text) <= _MAX_REPLY_TEXT_LENGTH:
|
||||
return text
|
||||
return text[:_MAX_REPLY_TEXT_LENGTH] + "..."
|
||||
|
||||
|
||||
def _positive_int(value, fallback: int) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
return parsed if parsed > 0 else fallback
|
||||
|
||||
|
||||
def _trim_left(
|
||||
records: deque[str],
|
||||
max_records: int,
|
||||
record_ids: deque[str] | None = None,
|
||||
) -> None:
|
||||
while len(records) > max_records:
|
||||
records.popleft()
|
||||
if record_ids:
|
||||
record_ids.popleft()
|
||||
|
||||
|
||||
def _format_group_history_block(records: list[str]) -> str:
|
||||
return GROUP_HISTORY_HEADER + "\n".join(records) + GROUP_HISTORY_FOOTER
|
||||
188
astrbot/builtin_stars/astrbot/long_term_memory.py
Normal file
188
astrbot/builtin_stars/astrbot/long_term_memory.py
Normal file
@@ -0,0 +1,188 @@
|
||||
import datetime
|
||||
import random
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.api import star
|
||||
from astrbot.api.event import AstrMessageEvent
|
||||
from astrbot.api.message_components import At, Image, Plain
|
||||
from astrbot.api.platform import MessageType
|
||||
from astrbot.api.provider import LLMResponse, Provider, ProviderRequest
|
||||
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
|
||||
|
||||
"""
|
||||
聊天记忆增强
|
||||
"""
|
||||
|
||||
|
||||
class LongTermMemory:
|
||||
def __init__(self, acm: AstrBotConfigManager, context: star.Context) -> None:
|
||||
self.acm = acm
|
||||
self.context = context
|
||||
self.session_chats = defaultdict(list)
|
||||
"""记录群成员的群聊记录"""
|
||||
|
||||
def cfg(self, event: AstrMessageEvent):
|
||||
cfg = self.context.get_config(umo=event.unified_msg_origin)
|
||||
try:
|
||||
max_cnt = int(cfg["provider_ltm_settings"]["group_message_max_cnt"])
|
||||
except BaseException as e:
|
||||
logger.error(e)
|
||||
max_cnt = 300
|
||||
image_caption_prompt = cfg["provider_settings"]["image_caption_prompt"]
|
||||
image_caption_provider_id = cfg["provider_ltm_settings"].get(
|
||||
"image_caption_provider_id"
|
||||
)
|
||||
image_caption = cfg["provider_ltm_settings"]["image_caption"] and bool(
|
||||
image_caption_provider_id
|
||||
)
|
||||
active_reply = cfg["provider_ltm_settings"]["active_reply"]
|
||||
enable_active_reply = active_reply.get("enable", False)
|
||||
ar_method = active_reply["method"]
|
||||
ar_possibility = active_reply["possibility_reply"]
|
||||
ar_prompt = active_reply.get("prompt", "")
|
||||
ar_whitelist = active_reply.get("whitelist", [])
|
||||
ret = {
|
||||
"max_cnt": max_cnt,
|
||||
"image_caption": image_caption,
|
||||
"image_caption_prompt": image_caption_prompt,
|
||||
"image_caption_provider_id": image_caption_provider_id,
|
||||
"enable_active_reply": enable_active_reply,
|
||||
"ar_method": ar_method,
|
||||
"ar_possibility": ar_possibility,
|
||||
"ar_prompt": ar_prompt,
|
||||
"ar_whitelist": ar_whitelist,
|
||||
}
|
||||
return ret
|
||||
|
||||
async def remove_session(self, event: AstrMessageEvent) -> int:
|
||||
cnt = 0
|
||||
if event.unified_msg_origin in self.session_chats:
|
||||
cnt = len(self.session_chats[event.unified_msg_origin])
|
||||
del self.session_chats[event.unified_msg_origin]
|
||||
return cnt
|
||||
|
||||
async def get_image_caption(
|
||||
self,
|
||||
image_url: str,
|
||||
image_caption_provider_id: str,
|
||||
image_caption_prompt: str,
|
||||
) -> str:
|
||||
if not image_caption_provider_id:
|
||||
provider = self.context.get_using_provider()
|
||||
else:
|
||||
provider = self.context.get_provider_by_id(image_caption_provider_id)
|
||||
if not provider:
|
||||
raise Exception(f"没有找到 ID 为 {image_caption_provider_id} 的提供商")
|
||||
if not isinstance(provider, Provider):
|
||||
raise Exception(f"提供商类型错误({type(provider)}),无法获取图片描述")
|
||||
response = await provider.text_chat(
|
||||
prompt=image_caption_prompt,
|
||||
session_id=uuid.uuid4().hex,
|
||||
image_urls=[image_url],
|
||||
persist=False,
|
||||
)
|
||||
return response.completion_text
|
||||
|
||||
async def need_active_reply(self, event: AstrMessageEvent) -> bool:
|
||||
cfg = self.cfg(event)
|
||||
if not cfg["enable_active_reply"]:
|
||||
return False
|
||||
if event.get_message_type() != MessageType.GROUP_MESSAGE:
|
||||
return False
|
||||
|
||||
if event.is_at_or_wake_command:
|
||||
# if the message is a command, let it pass
|
||||
return False
|
||||
|
||||
if cfg["ar_whitelist"] and (
|
||||
event.unified_msg_origin not in cfg["ar_whitelist"]
|
||||
and (
|
||||
event.get_group_id() and event.get_group_id() not in cfg["ar_whitelist"]
|
||||
)
|
||||
):
|
||||
return False
|
||||
|
||||
match cfg["ar_method"]:
|
||||
case "possibility_reply":
|
||||
trig = random.random() < cfg["ar_possibility"]
|
||||
return trig
|
||||
|
||||
return False
|
||||
|
||||
async def handle_message(self, event: AstrMessageEvent) -> None:
|
||||
"""仅支持群聊"""
|
||||
if event.get_message_type() == MessageType.GROUP_MESSAGE:
|
||||
datetime_str = datetime.datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
parts = [f"[{event.message_obj.sender.nickname}/{datetime_str}]: "]
|
||||
|
||||
cfg = self.cfg(event)
|
||||
|
||||
for comp in event.get_messages():
|
||||
if isinstance(comp, Plain):
|
||||
parts.append(f" {comp.text}")
|
||||
elif isinstance(comp, Image):
|
||||
if cfg["image_caption"]:
|
||||
try:
|
||||
url = comp.url if comp.url else comp.file
|
||||
if not url:
|
||||
raise Exception("图片 URL 为空")
|
||||
caption = await self.get_image_caption(
|
||||
url,
|
||||
cfg["image_caption_provider_id"],
|
||||
cfg["image_caption_prompt"],
|
||||
)
|
||||
parts.append(f" [Image: {caption}]")
|
||||
except Exception as e:
|
||||
logger.error(f"获取图片描述失败: {e}")
|
||||
else:
|
||||
parts.append(" [Image]")
|
||||
elif isinstance(comp, At):
|
||||
parts.append(f" [At: {comp.name}]")
|
||||
|
||||
final_message = "".join(parts)
|
||||
logger.debug(f"ltm | {event.unified_msg_origin} | {final_message}")
|
||||
self.session_chats[event.unified_msg_origin].append(final_message)
|
||||
if len(self.session_chats[event.unified_msg_origin]) > cfg["max_cnt"]:
|
||||
self.session_chats[event.unified_msg_origin].pop(0)
|
||||
|
||||
async def on_req_llm(self, event: AstrMessageEvent, req: ProviderRequest) -> None:
|
||||
"""当触发 LLM 请求前,调用此方法修改 req"""
|
||||
if event.unified_msg_origin not in self.session_chats:
|
||||
return
|
||||
|
||||
chats_str = "\n---\n".join(self.session_chats[event.unified_msg_origin])
|
||||
|
||||
cfg = self.cfg(event)
|
||||
if cfg["enable_active_reply"]:
|
||||
prompt = req.prompt
|
||||
req.prompt = (
|
||||
f"You are now in a chatroom. The chat history is as follows:\n{chats_str}"
|
||||
f"\nNow, a new message is coming: `{prompt}`. "
|
||||
"Please react to it. Only output your response and do not output any other information. "
|
||||
"You MUST use the SAME language as the chatroom is using."
|
||||
)
|
||||
req.contexts = [] # 清空上下文,当使用了主动回复,所有聊天记录都在一个prompt中。
|
||||
else:
|
||||
req.system_prompt += (
|
||||
"You are now in a chatroom. The chat history is as follows: \n"
|
||||
)
|
||||
req.system_prompt += chats_str
|
||||
|
||||
async def after_req_llm(
|
||||
self, event: AstrMessageEvent, llm_resp: LLMResponse
|
||||
) -> None:
|
||||
if event.unified_msg_origin not in self.session_chats:
|
||||
return
|
||||
|
||||
if llm_resp.completion_text:
|
||||
final_message = f"[You/{datetime.datetime.now().strftime('%H:%M:%S')}]: {llm_resp.completion_text}"
|
||||
logger.debug(
|
||||
f"Recorded AI response: {event.unified_msg_origin} | {final_message}"
|
||||
)
|
||||
self.session_chats[event.unified_msg_origin].append(final_message)
|
||||
cfg = self.cfg(event)
|
||||
if len(self.session_chats[event.unified_msg_origin]) > cfg["max_cnt"]:
|
||||
self.session_chats[event.unified_msg_origin].pop(0)
|
||||
@@ -1,196 +1,66 @@
|
||||
import copy
|
||||
import traceback
|
||||
from collections.abc import Iterable
|
||||
from sys import maxsize
|
||||
|
||||
import astrbot.api.message_components as Comp
|
||||
from astrbot.api import star
|
||||
from astrbot.api.event import AstrMessageEvent, filter
|
||||
from astrbot.api.message_components import Image, Plain
|
||||
from astrbot.api.provider import ProviderRequest
|
||||
from astrbot.api.provider import LLMResponse, ProviderRequest
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.utils.session_waiter import (
|
||||
FILTERS,
|
||||
USER_SESSIONS,
|
||||
SessionController,
|
||||
SessionWaiter,
|
||||
session_waiter,
|
||||
)
|
||||
|
||||
from .group_chat_context import GroupChatContext
|
||||
|
||||
|
||||
def _iter_message_components(event: AstrMessageEvent):
|
||||
messages = getattr(getattr(event, "message_obj", None), "message", None)
|
||||
if not isinstance(messages, Iterable) or isinstance(messages, (str, bytes)):
|
||||
return ()
|
||||
return tuple(messages)
|
||||
from .long_term_memory import LongTermMemory
|
||||
|
||||
|
||||
class Main(star.Star):
|
||||
def __init__(self, context: star.Context) -> None:
|
||||
self.context = context
|
||||
self.group_chat_context = None
|
||||
self.ltm = None
|
||||
try:
|
||||
self.group_chat_context = GroupChatContext(
|
||||
self.context.astrbot_config_mgr,
|
||||
self.context,
|
||||
)
|
||||
self.ltm = LongTermMemory(self.context.astrbot_config_mgr, self.context)
|
||||
except BaseException as e:
|
||||
logger.error(f"group chat context init failed: {e}")
|
||||
logger.error(f"聊天增强 err: {e}")
|
||||
|
||||
@filter.event_message_type(filter.EventMessageType.ALL, priority=maxsize)
|
||||
async def handle_session_control_agent(self, event: AstrMessageEvent) -> None:
|
||||
"""会话控制代理"""
|
||||
for session_filter in FILTERS:
|
||||
session_id = session_filter.filter(event)
|
||||
if session_id in USER_SESSIONS:
|
||||
await SessionWaiter.trigger(session_id, event)
|
||||
event.stop_event()
|
||||
|
||||
@filter.event_message_type(filter.EventMessageType.ALL, priority=maxsize - 1)
|
||||
async def handle_empty_mention(self, event: AstrMessageEvent):
|
||||
"""处理只有一个 @ 或仅有唤醒前缀的消息,并等待用户下一条内容。"""
|
||||
try:
|
||||
messages = event.get_messages()
|
||||
cfg = self.context.get_config(umo=event.unified_msg_origin)
|
||||
p_settings = cfg["platform_settings"]
|
||||
wake_prefix = cfg.get("wake_prefix", [])
|
||||
if len(messages) != 1:
|
||||
return
|
||||
|
||||
is_empty_mention = (
|
||||
isinstance(messages[0], Comp.At)
|
||||
and str(messages[0].qq) == str(event.get_self_id())
|
||||
and p_settings.get("empty_mention_waiting", True)
|
||||
)
|
||||
is_wake_prefix_only = (
|
||||
isinstance(messages[0], Comp.Plain)
|
||||
and messages[0].text.strip() in wake_prefix
|
||||
)
|
||||
|
||||
if not (is_empty_mention or is_wake_prefix_only):
|
||||
return
|
||||
|
||||
if p_settings.get("empty_mention_waiting_need_reply", True):
|
||||
try:
|
||||
curr_cid = await self.context.conversation_manager.get_curr_conversation_id(
|
||||
event.unified_msg_origin,
|
||||
)
|
||||
conversation = None
|
||||
|
||||
if curr_cid:
|
||||
conversation = (
|
||||
await self.context.conversation_manager.get_conversation(
|
||||
event.unified_msg_origin,
|
||||
curr_cid,
|
||||
)
|
||||
)
|
||||
else:
|
||||
curr_cid = (
|
||||
await self.context.conversation_manager.new_conversation(
|
||||
event.unified_msg_origin,
|
||||
platform_id=event.get_platform_id(),
|
||||
)
|
||||
)
|
||||
|
||||
yield event.request_llm(
|
||||
prompt=(
|
||||
"注意,你正在社交媒体上中与用户进行聊天,用户只是通过@来唤醒你,但并未在这条消息中输入内容,他可能会在接下来一条发送他想发送的内容。"
|
||||
"你友好地询问用户想要聊些什么或者需要什么帮助,回复要符合人设,不要太过机械化。"
|
||||
"请注意,你仅需要输出要回复用户的内容,不要输出其他任何东西"
|
||||
),
|
||||
session_id=curr_cid,
|
||||
contexts=[],
|
||||
system_prompt="",
|
||||
conversation=conversation,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"LLM response failed: {e!s}")
|
||||
yield event.plain_result("想要问什么呢?😄")
|
||||
|
||||
@session_waiter(60)
|
||||
async def empty_mention_waiter(
|
||||
controller: SessionController,
|
||||
event: AstrMessageEvent,
|
||||
) -> None:
|
||||
if not event.message_str or not event.message_str.strip():
|
||||
return
|
||||
event.message_obj.message.insert(
|
||||
0,
|
||||
Comp.At(qq=event.get_self_id(), name=event.get_self_id()),
|
||||
)
|
||||
new_event = copy.copy(event)
|
||||
self.context.get_event_queue().put_nowait(new_event)
|
||||
event.stop_event()
|
||||
controller.stop()
|
||||
|
||||
try:
|
||||
await empty_mention_waiter(event)
|
||||
except TimeoutError:
|
||||
pass
|
||||
except Exception as e:
|
||||
yield event.plain_result("发生错误,请联系管理员: " + str(e))
|
||||
finally:
|
||||
event.stop_event()
|
||||
except Exception as e:
|
||||
logger.error("handle_empty_mention error: " + str(e))
|
||||
|
||||
def group_context_enabled(self, event: AstrMessageEvent):
|
||||
group_context_settings = self.context.get_config(umo=event.unified_msg_origin)[
|
||||
def ltm_enabled(self, event: AstrMessageEvent):
|
||||
ltmse = self.context.get_config(umo=event.unified_msg_origin)[
|
||||
"provider_ltm_settings"
|
||||
]
|
||||
return (
|
||||
group_context_settings["group_icl_enable"]
|
||||
or group_context_settings["active_reply"]["enable"]
|
||||
)
|
||||
return ltmse["group_icl_enable"] or ltmse["active_reply"]["enable"]
|
||||
|
||||
@filter.platform_adapter_type(filter.PlatformAdapterType.ALL)
|
||||
async def on_message(self, event: AstrMessageEvent):
|
||||
"""群聊上下文感知"""
|
||||
message_components = _iter_message_components(event)
|
||||
"""群聊记忆增强"""
|
||||
has_image_or_plain = False
|
||||
for comp in message_components:
|
||||
for comp in event.message_obj.message:
|
||||
if isinstance(comp, Plain) or isinstance(comp, Image):
|
||||
has_image_or_plain = True
|
||||
break
|
||||
|
||||
group_context_enabled = False
|
||||
if self.group_chat_context:
|
||||
try:
|
||||
group_context_enabled = self.group_context_enabled(event)
|
||||
except BaseException as e:
|
||||
logger.error(f"group chat context: {e}")
|
||||
if self.ltm_enabled(event) and self.ltm and has_image_or_plain:
|
||||
need_active = await self.ltm.need_active_reply(event)
|
||||
|
||||
if group_context_enabled and self.group_chat_context and has_image_or_plain:
|
||||
need_active = await self.group_chat_context.need_active_reply(event)
|
||||
|
||||
group_icl_enable = self.context.get_config(umo=event.unified_msg_origin)[
|
||||
"provider_ltm_settings"
|
||||
]["group_icl_enable"]
|
||||
group_icl_enable = self.context.get_config()["provider_ltm_settings"][
|
||||
"group_icl_enable"
|
||||
]
|
||||
if group_icl_enable:
|
||||
# Skip recording if a command handler matched (e.g. /reset,
|
||||
# /help, /new). Slash commands are bot instructions, not group
|
||||
# chat context that should be injected into future LLM requests.
|
||||
if not event.get_extra("handlers_parsed_params", {}):
|
||||
try:
|
||||
await self.group_chat_context.handle_message(event)
|
||||
except BaseException as e:
|
||||
logger.error(e)
|
||||
"""记录对话"""
|
||||
try:
|
||||
await self.ltm.handle_message(event)
|
||||
except BaseException as e:
|
||||
logger.error(e)
|
||||
|
||||
if need_active:
|
||||
"""主动回复"""
|
||||
provider = self.context.get_using_provider(event.unified_msg_origin)
|
||||
if not provider:
|
||||
logger.error("未找到任何 LLM 提供商。请先配置。无法主动回复")
|
||||
return
|
||||
try:
|
||||
conv = None
|
||||
session_curr_cid = await self.context.conversation_manager.get_curr_conversation_id(
|
||||
event.unified_msg_origin,
|
||||
)
|
||||
|
||||
if not session_curr_cid:
|
||||
logger.error(
|
||||
"当前未处于对话状态,无法主动回复,请确保 平台设置->会话隔离(unique_session) 未开启,并使用 /new 创建一个会话。",
|
||||
"当前未处于对话状态,无法主动回复,请确保 平台设置->会话隔离(unique_session) 未开启,并使用 /switch 序号 切换或者 /new 创建一个会话。",
|
||||
)
|
||||
return
|
||||
|
||||
@@ -199,23 +69,15 @@ class Main(star.Star):
|
||||
session_curr_cid,
|
||||
)
|
||||
|
||||
prompt = event.message_str
|
||||
|
||||
if not conv:
|
||||
logger.error("未找到对话,无法主动回复")
|
||||
return
|
||||
|
||||
prompt = event.message_str
|
||||
image_urls = []
|
||||
for comp in message_components:
|
||||
if isinstance(comp, Image):
|
||||
try:
|
||||
image_urls.append(await comp.convert_to_file_path())
|
||||
except Exception:
|
||||
logger.exception("主动回复处理图片失败")
|
||||
|
||||
yield event.request_llm(
|
||||
prompt=prompt,
|
||||
session_id=event.session_id,
|
||||
image_urls=image_urls,
|
||||
conversation=conv,
|
||||
)
|
||||
except BaseException as e:
|
||||
@@ -227,19 +89,30 @@ class Main(star.Star):
|
||||
self, event: AstrMessageEvent, req: ProviderRequest
|
||||
) -> None:
|
||||
"""在请求 LLM 前注入人格信息、Identifier、时间、回复内容等 System Prompt"""
|
||||
if self.group_chat_context and self.group_context_enabled(event):
|
||||
if self.ltm and self.ltm_enabled(event):
|
||||
try:
|
||||
await self.group_chat_context.on_req_llm(event, req)
|
||||
await self.ltm.on_req_llm(event, req)
|
||||
except BaseException as e:
|
||||
logger.error(f"group chat context: {e}")
|
||||
logger.error(f"ltm: {e}")
|
||||
|
||||
@filter.on_llm_response()
|
||||
async def record_llm_resp_to_ltm(
|
||||
self, event: AstrMessageEvent, resp: LLMResponse
|
||||
) -> None:
|
||||
"""在 LLM 响应后记录对话"""
|
||||
if self.ltm and self.ltm_enabled(event):
|
||||
try:
|
||||
await self.ltm.after_req_llm(event, resp)
|
||||
except Exception as e:
|
||||
logger.error(f"ltm: {e}")
|
||||
|
||||
@filter.after_message_sent()
|
||||
async def after_message_sent(self, event: AstrMessageEvent) -> None:
|
||||
"""消息发送后处理"""
|
||||
if self.group_chat_context and self.group_context_enabled(event):
|
||||
if self.ltm and self.ltm_enabled(event):
|
||||
try:
|
||||
clean_session = event.get_extra("_clean_group_context_session", False)
|
||||
clean_session = event.get_extra("_clean_ltm_session", False)
|
||||
if clean_session:
|
||||
await self.group_chat_context.remove_session(event)
|
||||
await self.ltm.remove_session(event)
|
||||
except Exception as e:
|
||||
logger.error(f"group chat context: {e}")
|
||||
logger.error(f"ltm: {e}")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: astrbot
|
||||
desc: AstrBot's internal plugin, providing some basic capabilities.
|
||||
author: AstrBot Team
|
||||
version: 4.1.0
|
||||
desc: AstrBot 自带插件,包含人格注入、思考内容注入、群聊上下文感知等功能的实现,禁用后将无法使用这些功能。
|
||||
author: Soulter
|
||||
version: 4.1.0
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"metadata": {
|
||||
"display_name": "Built-in Commands",
|
||||
"desc": "AstrBot's internal plugin, providing built-in commands such as /reset, /help, and /sid."
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"metadata": {
|
||||
"display_name": "内置指令",
|
||||
"desc": "AstrBot 自带插件,提供 /reset、/help、/sid 等内置指令。"
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ from .alter_cmd import AlterCmdCommands
|
||||
from .conversation import ConversationCommands
|
||||
from .help import HelpCommand
|
||||
from .llm import LLMCommands
|
||||
from .name import NameCommand
|
||||
from .persona import PersonaCommands
|
||||
from .plugin import PluginCommands
|
||||
from .provider import ProviderCommands
|
||||
from .setunset import SetUnsetCommands
|
||||
@@ -19,7 +19,7 @@ __all__ = [
|
||||
"ConversationCommands",
|
||||
"HelpCommand",
|
||||
"LLMCommands",
|
||||
"NameCommand",
|
||||
"PersonaCommands",
|
||||
"PluginCommands",
|
||||
"ProviderCommands",
|
||||
"SIDCommand",
|
||||
|
||||
@@ -9,56 +9,56 @@ class AdminCommands:
|
||||
self.context = context
|
||||
|
||||
async def op(self, event: AstrMessageEvent, admin_id: str = "") -> None:
|
||||
"""授权管理员。op <admin_id>"""
|
||||
"""授权管理员。op <admin_id>"""
|
||||
if not admin_id:
|
||||
event.set_result(
|
||||
MessageEventResult().message(
|
||||
"使用方法: /op <id> 授权管理员;/deop <id> 取消管理员。可通过 /sid 获取 ID。",
|
||||
"使用方法: /op <id> 授权管理员;/deop <id> 取消管理员。可通过 /sid 获取 ID。",
|
||||
),
|
||||
)
|
||||
return
|
||||
self.context.get_config()["admins_id"].append(str(admin_id))
|
||||
self.context.get_config().save_config()
|
||||
event.set_result(MessageEventResult().message("授权成功。"))
|
||||
event.set_result(MessageEventResult().message("授权成功。"))
|
||||
|
||||
async def deop(self, event: AstrMessageEvent, admin_id: str = "") -> None:
|
||||
"""取消授权管理员。deop <admin_id>"""
|
||||
"""取消授权管理员。deop <admin_id>"""
|
||||
if not admin_id:
|
||||
event.set_result(
|
||||
MessageEventResult().message(
|
||||
"使用方法: /deop <id> 取消管理员。可通过 /sid 获取 ID。",
|
||||
"使用方法: /deop <id> 取消管理员。可通过 /sid 获取 ID。",
|
||||
),
|
||||
)
|
||||
return
|
||||
try:
|
||||
self.context.get_config()["admins_id"].remove(str(admin_id))
|
||||
self.context.get_config().save_config()
|
||||
event.set_result(MessageEventResult().message("取消授权成功。"))
|
||||
event.set_result(MessageEventResult().message("取消授权成功。"))
|
||||
except ValueError:
|
||||
event.set_result(
|
||||
MessageEventResult().message("此用户 ID 不在管理员名单内。"),
|
||||
MessageEventResult().message("此用户 ID 不在管理员名单内。"),
|
||||
)
|
||||
|
||||
async def wl(self, event: AstrMessageEvent, sid: str = "") -> None:
|
||||
"""添加白名单。wl <sid>"""
|
||||
"""添加白名单。wl <sid>"""
|
||||
if not sid:
|
||||
event.set_result(
|
||||
MessageEventResult().message(
|
||||
"使用方法: /wl <id> 添加白名单;/dwl <id> 删除白名单。可通过 /sid 获取 ID。",
|
||||
"使用方法: /wl <id> 添加白名单;/dwl <id> 删除白名单。可通过 /sid 获取 ID。",
|
||||
),
|
||||
)
|
||||
return
|
||||
cfg = self.context.get_config(umo=event.unified_msg_origin)
|
||||
cfg["platform_settings"]["id_whitelist"].append(str(sid))
|
||||
cfg.save_config()
|
||||
event.set_result(MessageEventResult().message("添加白名单成功。"))
|
||||
event.set_result(MessageEventResult().message("添加白名单成功。"))
|
||||
|
||||
async def dwl(self, event: AstrMessageEvent, sid: str = "") -> None:
|
||||
"""删除白名单。dwl <sid>"""
|
||||
"""删除白名单。dwl <sid>"""
|
||||
if not sid:
|
||||
event.set_result(
|
||||
MessageEventResult().message(
|
||||
"使用方法: /dwl <id> 删除白名单。可通过 /sid 获取 ID。",
|
||||
"使用方法: /dwl <id> 删除白名单。可通过 /sid 获取 ID。",
|
||||
),
|
||||
)
|
||||
return
|
||||
@@ -66,12 +66,12 @@ class AdminCommands:
|
||||
cfg = self.context.get_config(umo=event.unified_msg_origin)
|
||||
cfg["platform_settings"]["id_whitelist"].remove(str(sid))
|
||||
cfg.save_config()
|
||||
event.set_result(MessageEventResult().message("删除白名单成功。"))
|
||||
event.set_result(MessageEventResult().message("删除白名单成功。"))
|
||||
except ValueError:
|
||||
event.set_result(MessageEventResult().message("此 SID 不在白名单内。"))
|
||||
event.set_result(MessageEventResult().message("此 SID 不在白名单内。"))
|
||||
|
||||
async def update_dashboard(self, event: AstrMessageEvent) -> None:
|
||||
"""更新管理面板"""
|
||||
await event.send(MessageChain().message("正在尝试更新管理面板..."))
|
||||
await download_dashboard(version=f"v{VERSION}", latest=False)
|
||||
await event.send(MessageChain().message("管理面板更新完成。"))
|
||||
await event.send(MessageChain().message("管理面板更新完成。"))
|
||||
|
||||
@@ -18,9 +18,7 @@ class AlterCmdCommands(CommandParserMixin):
|
||||
"""更新reset命令在特定场景下的权限设置"""
|
||||
from astrbot.api import sp
|
||||
|
||||
alter_cmd_cfg: dict[str, dict[str, dict[str, str]]] = (
|
||||
await sp.global_get("alter_cmd", {}) or {}
|
||||
)
|
||||
alter_cmd_cfg = await sp.global_get("alter_cmd", {})
|
||||
plugin_cfg = alter_cmd_cfg.get("astrbot", {})
|
||||
reset_cfg = plugin_cfg.get("reset", {})
|
||||
reset_cfg[scene_key] = perm_type
|
||||
@@ -33,7 +31,7 @@ class AlterCmdCommands(CommandParserMixin):
|
||||
if token.len < 3:
|
||||
await event.send(
|
||||
MessageChain().message(
|
||||
"该指令用于设置指令或指令组的权限。\n"
|
||||
"该指令用于设置指令或指令组的权限。\n"
|
||||
"格式: /alter_cmd <cmd_name> <admin/member>\n"
|
||||
"例1: /alter_cmd c1 admin 将 c1 设为管理员指令\n"
|
||||
"例2: /alter_cmd g1 c1 admin 将 g1 指令组的 c1 子指令设为管理员指令\n"
|
||||
@@ -49,9 +47,7 @@ class AlterCmdCommands(CommandParserMixin):
|
||||
if cmd_name == "reset" and cmd_type == "config":
|
||||
from astrbot.api import sp
|
||||
|
||||
alter_cmd_cfg: dict[str, dict[str, dict[str, str]]] = (
|
||||
await sp.global_get("alter_cmd", {}) or {}
|
||||
)
|
||||
alter_cmd_cfg = await sp.global_get("alter_cmd", {})
|
||||
plugin_ = alter_cmd_cfg.get("astrbot", {})
|
||||
reset_cfg = plugin_.get("reset", {})
|
||||
|
||||
@@ -60,11 +56,11 @@ class AlterCmdCommands(CommandParserMixin):
|
||||
private = reset_cfg.get("private", "member")
|
||||
|
||||
config_menu = f"""reset命令权限细粒度配置
|
||||
当前配置:
|
||||
当前配置:
|
||||
1. 群聊+会话隔离开: {group_unique_on}
|
||||
2. 群聊+会话隔离关: {group_unique_off}
|
||||
3. 私聊: {private}
|
||||
修改指令格式:
|
||||
修改指令格式:
|
||||
/alter_cmd reset scene <场景编号> <admin/member>
|
||||
例如: /alter_cmd reset scene 2 member"""
|
||||
await event.send(MessageChain().message(config_menu))
|
||||
@@ -86,12 +82,12 @@ class AlterCmdCommands(CommandParserMixin):
|
||||
|
||||
if perm_type not in ["admin", "member"]:
|
||||
await event.send(
|
||||
MessageChain().message("权限类型错误,只能是 admin 或 member"),
|
||||
MessageChain().message("权限类型错误,只能是 admin 或 member"),
|
||||
)
|
||||
return
|
||||
|
||||
scene_index = int(scene_num)
|
||||
scene = RstScene.from_index(scene_index)
|
||||
scene_num = int(scene_num)
|
||||
scene = RstScene.from_index(scene_num)
|
||||
scene_key = scene.key
|
||||
|
||||
await self.update_reset_permission(scene_key, perm_type)
|
||||
@@ -105,18 +101,13 @@ class AlterCmdCommands(CommandParserMixin):
|
||||
|
||||
if cmd_type not in ["admin", "member"]:
|
||||
await event.send(
|
||||
MessageChain().message("指令类型错误,可选类型有 admin, member"),
|
||||
MessageChain().message("指令类型错误,可选类型有 admin, member"),
|
||||
)
|
||||
return
|
||||
|
||||
# 查找指令
|
||||
cmd_name = " ".join(token.tokens[1:-1])
|
||||
permission_type = token.get(-1)
|
||||
if permission_type not in ["admin", "member"]:
|
||||
await event.send(
|
||||
MessageChain().message("指令类型错误,可选类型有 admin, member"),
|
||||
)
|
||||
return
|
||||
cmd_type = token.get(-1)
|
||||
found_command = None
|
||||
cmd_group = False
|
||||
for handler in star_handlers_registry:
|
||||
@@ -140,25 +131,20 @@ class AlterCmdCommands(CommandParserMixin):
|
||||
|
||||
from astrbot.api import sp
|
||||
|
||||
stored_alter_cmd_cfg: dict[str, dict[str, dict[str, str]]] = (
|
||||
await sp.global_get("alter_cmd", {}) or {}
|
||||
)
|
||||
if found_plugin.name is None:
|
||||
await event.send(MessageChain().message("未找到指令对应的插件名称"))
|
||||
return
|
||||
plugin_ = stored_alter_cmd_cfg.get(found_plugin.name, {})
|
||||
alter_cmd_cfg = await sp.global_get("alter_cmd", {})
|
||||
plugin_ = alter_cmd_cfg.get(found_plugin.name, {})
|
||||
cfg = plugin_.get(found_command.handler_name, {})
|
||||
cfg["permission"] = permission_type
|
||||
cfg["permission"] = cmd_type
|
||||
plugin_[found_command.handler_name] = cfg
|
||||
stored_alter_cmd_cfg[found_plugin.name] = plugin_
|
||||
alter_cmd_cfg[found_plugin.name] = plugin_
|
||||
|
||||
await sp.global_put("alter_cmd", stored_alter_cmd_cfg)
|
||||
await sp.global_put("alter_cmd", alter_cmd_cfg)
|
||||
|
||||
# 注入权限过滤器
|
||||
found_permission_filter = False
|
||||
for filter_ in found_command.event_filters:
|
||||
if isinstance(filter_, PermissionTypeFilter):
|
||||
if permission_type == "admin":
|
||||
if cmd_type == "admin":
|
||||
from astrbot.api.event import filter
|
||||
|
||||
filter_.permission_type = filter.PermissionType.ADMIN
|
||||
@@ -175,13 +161,13 @@ class AlterCmdCommands(CommandParserMixin):
|
||||
0,
|
||||
PermissionTypeFilter(
|
||||
filter.PermissionType.ADMIN
|
||||
if permission_type == "admin"
|
||||
if cmd_type == "admin"
|
||||
else filter.PermissionType.MEMBER,
|
||||
),
|
||||
)
|
||||
cmd_group_str = "指令组" if cmd_group else "指令"
|
||||
await event.send(
|
||||
MessageChain().message(
|
||||
f"已将「{cmd_name}」{cmd_group_str} 的权限级别调整为 {permission_type}。",
|
||||
f"已将「{cmd_name}」{cmd_group_str} 的权限级别调整为 {cmd_type}。",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
from sqlalchemy import case, func, select
|
||||
from sqlmodel import col
|
||||
import datetime
|
||||
|
||||
from astrbot.api import sp, star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.agent.runners.deerflow.constants import (
|
||||
DEERFLOW_AGENT_RUNNER_PROVIDER_ID_KEY,
|
||||
DEERFLOW_PROVIDER_TYPE,
|
||||
DEERFLOW_THREAD_ID_KEY,
|
||||
)
|
||||
from astrbot.core.agent.runners.deerflow.deerflow_api_client import DeerFlowAPIClient
|
||||
from astrbot.core.db.po import ProviderStat
|
||||
from astrbot.core.utils.active_event_registry import active_event_registry
|
||||
from astrbot.core.platform.astr_message_event import MessageSession
|
||||
from astrbot.core.platform.message_type import MessageType
|
||||
|
||||
from .utils.rst_scene import RstScene
|
||||
|
||||
@@ -19,90 +11,10 @@ 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())
|
||||
|
||||
|
||||
async def _cleanup_deerflow_thread_if_present(
|
||||
context: star.Context,
|
||||
umo: str,
|
||||
) -> None:
|
||||
try:
|
||||
thread_id = await sp.get_async(
|
||||
scope="umo",
|
||||
scope_id=umo,
|
||||
key=DEERFLOW_THREAD_ID_KEY,
|
||||
default="",
|
||||
)
|
||||
if not thread_id:
|
||||
return
|
||||
|
||||
cfg = context.get_config(umo=umo)
|
||||
provider_id = cfg["provider_settings"].get(
|
||||
DEERFLOW_AGENT_RUNNER_PROVIDER_ID_KEY,
|
||||
"",
|
||||
)
|
||||
if not provider_id:
|
||||
return
|
||||
|
||||
merged_provider_config = context.provider_manager.get_provider_config_by_id(
|
||||
provider_id,
|
||||
merged=True,
|
||||
)
|
||||
if not merged_provider_config:
|
||||
logger.warning(
|
||||
"Failed to resolve DeerFlow provider config for remote thread cleanup: provider_id=%s",
|
||||
provider_id,
|
||||
)
|
||||
return
|
||||
|
||||
client = DeerFlowAPIClient(
|
||||
api_base=merged_provider_config.get(
|
||||
"deerflow_api_base",
|
||||
"http://127.0.0.1:2026",
|
||||
),
|
||||
api_key=merged_provider_config.get("deerflow_api_key", ""),
|
||||
auth_header=merged_provider_config.get("deerflow_auth_header", ""),
|
||||
proxy=merged_provider_config.get("proxy", ""),
|
||||
)
|
||||
try:
|
||||
await client.delete_thread(thread_id)
|
||||
finally:
|
||||
try:
|
||||
await client.close()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to close DeerFlow API client after thread cleanup: %s",
|
||||
e,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to clean up DeerFlow thread for session %s: %s",
|
||||
umo,
|
||||
e,
|
||||
)
|
||||
|
||||
|
||||
async def _clear_third_party_agent_runner_state(
|
||||
context: star.Context,
|
||||
umo: str,
|
||||
agent_runner_type: str,
|
||||
) -> None:
|
||||
session_key = THIRD_PARTY_AGENT_RUNNER_KEY.get(agent_runner_type)
|
||||
if not session_key:
|
||||
return
|
||||
|
||||
if agent_runner_type == DEERFLOW_PROVIDER_TYPE:
|
||||
await _cleanup_deerflow_thread_if_present(context, umo)
|
||||
|
||||
await sp.remove_async(
|
||||
scope="umo",
|
||||
scope_id=umo,
|
||||
key=session_key,
|
||||
)
|
||||
|
||||
|
||||
class ConversationCommands:
|
||||
def __init__(self, context: star.Context) -> None:
|
||||
self.context = context
|
||||
@@ -142,30 +54,25 @@ class ConversationCommands:
|
||||
if required_perm == "admin" and message.role != "admin":
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
f"Reset command requires admin permission in {scene.name} scenario, "
|
||||
f"you (ID {message.get_sender_id()}) are not admin, cannot perform this action.",
|
||||
f"在{scene.name}场景下,reset命令需要管理员权限,"
|
||||
f"您 (ID {message.get_sender_id()}) 不是管理员,无法执行此操作。",
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
||||
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
|
||||
active_event_registry.stop_all(umo, exclude=message)
|
||||
await _clear_third_party_agent_runner_state(
|
||||
self.context,
|
||||
umo,
|
||||
agent_runner_type,
|
||||
)
|
||||
message.set_result(
|
||||
MessageEventResult().message("✅ Conversation reset successfully.")
|
||||
await sp.remove_async(
|
||||
scope="umo",
|
||||
scope_id=umo,
|
||||
key=THIRD_PARTY_AGENT_RUNNER_KEY[agent_runner_type],
|
||||
)
|
||||
message.set_result(MessageEventResult().message("重置对话成功。"))
|
||||
return
|
||||
|
||||
if not self.context.get_using_provider(umo):
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
"😕 Cannot find any LLM provider. Configure one first."
|
||||
),
|
||||
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"),
|
||||
)
|
||||
return
|
||||
|
||||
@@ -174,68 +81,154 @@ class ConversationCommands:
|
||||
if not cid:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
"😕 You are not in a conversation. Use /new to create one.",
|
||||
"当前未处于对话状态,请 /switch 切换或者 /new 创建。",
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
active_event_registry.stop_all(umo, exclude=message)
|
||||
|
||||
await self.context.conversation_manager.update_conversation(
|
||||
umo,
|
||||
cid,
|
||||
[],
|
||||
)
|
||||
|
||||
ret = "✅ Conversation reset successfully."
|
||||
ret = "清除聊天历史成功!"
|
||||
|
||||
message.set_extra("_clean_group_context_session", True)
|
||||
message.set_extra("_clean_ltm_session", True)
|
||||
|
||||
message.set_result(MessageEventResult().message(ret))
|
||||
|
||||
async def stop(self, message: AstrMessageEvent) -> None:
|
||||
"""停止当前会话正在运行的 Agent"""
|
||||
cfg = self.context.get_config(umo=message.unified_msg_origin)
|
||||
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
||||
umo = message.unified_msg_origin
|
||||
|
||||
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
|
||||
stopped_count = active_event_registry.stop_all(umo, exclude=message)
|
||||
else:
|
||||
stopped_count = active_event_registry.request_agent_stop_all(
|
||||
umo,
|
||||
exclude=message,
|
||||
)
|
||||
|
||||
if stopped_count > 0:
|
||||
async def his(self, message: AstrMessageEvent, page: int = 1) -> None:
|
||||
"""查看对话记录"""
|
||||
if not self.context.get_using_provider(message.unified_msg_origin):
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
f"✅ Requested to stop {stopped_count} running tasks."
|
||||
)
|
||||
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"),
|
||||
)
|
||||
return
|
||||
|
||||
message.set_result(
|
||||
MessageEventResult().message("✅ No running tasks in the current session.")
|
||||
size_per_page = 6
|
||||
|
||||
conv_mgr = self.context.conversation_manager
|
||||
umo = message.unified_msg_origin
|
||||
session_curr_cid = await conv_mgr.get_curr_conversation_id(umo)
|
||||
|
||||
if not session_curr_cid:
|
||||
session_curr_cid = await conv_mgr.new_conversation(
|
||||
umo,
|
||||
message.get_platform_id(),
|
||||
)
|
||||
|
||||
contexts, total_pages = await conv_mgr.get_human_readable_context(
|
||||
umo,
|
||||
session_curr_cid,
|
||||
page,
|
||||
size_per_page,
|
||||
)
|
||||
|
||||
parts = []
|
||||
for context in contexts:
|
||||
if len(context) > 150:
|
||||
context = context[:150] + "..."
|
||||
parts.append(f"{context}\n")
|
||||
|
||||
history = "".join(parts)
|
||||
ret = (
|
||||
f"当前对话历史记录:"
|
||||
f"{history or '无历史记录'}\n\n"
|
||||
f"第 {page} 页 | 共 {total_pages} 页\n"
|
||||
f"*输入 /history 2 跳转到第 2 页"
|
||||
)
|
||||
|
||||
message.set_result(MessageEventResult().message(ret).use_t2i(False))
|
||||
|
||||
async def convs(self, message: AstrMessageEvent, page: int = 1) -> None:
|
||||
"""查看对话列表"""
|
||||
cfg = self.context.get_config(umo=message.unified_msg_origin)
|
||||
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
||||
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
f"{THIRD_PARTY_AGENT_RUNNER_STR} 对话列表功能暂不支持。",
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
size_per_page = 6
|
||||
"""获取所有对话列表"""
|
||||
conversations_all = await self.context.conversation_manager.get_conversations(
|
||||
message.unified_msg_origin,
|
||||
)
|
||||
"""计算总页数"""
|
||||
total_pages = (len(conversations_all) + size_per_page - 1) // size_per_page
|
||||
"""确保页码有效"""
|
||||
page = max(1, min(page, total_pages))
|
||||
"""分页处理"""
|
||||
start_idx = (page - 1) * size_per_page
|
||||
end_idx = start_idx + size_per_page
|
||||
conversations_paged = conversations_all[start_idx:end_idx]
|
||||
|
||||
parts = ["对话列表:\n---\n"]
|
||||
"""全局序号从当前页的第一个开始"""
|
||||
global_index = start_idx + 1
|
||||
|
||||
"""生成所有对话的标题字典"""
|
||||
_titles = {}
|
||||
for conv in conversations_all:
|
||||
title = conv.title if conv.title else "新对话"
|
||||
_titles[conv.cid] = title
|
||||
|
||||
"""遍历分页后的对话生成列表显示"""
|
||||
for conv in conversations_paged:
|
||||
persona_id = conv.persona_id
|
||||
if not persona_id or persona_id == "[%None]":
|
||||
persona = await self.context.persona_manager.get_default_persona_v3(
|
||||
umo=message.unified_msg_origin,
|
||||
)
|
||||
persona_id = persona["name"]
|
||||
title = _titles.get(conv.cid, "新对话")
|
||||
parts.append(
|
||||
f"{global_index}. {title}({conv.cid[:4]})\n 人格情景: {persona_id}\n 上次更新: {datetime.datetime.fromtimestamp(conv.updated_at).strftime('%m-%d %H:%M')}\n"
|
||||
)
|
||||
global_index += 1
|
||||
|
||||
parts.append("---\n")
|
||||
ret = "".join(parts)
|
||||
curr_cid = await self.context.conversation_manager.get_curr_conversation_id(
|
||||
message.unified_msg_origin,
|
||||
)
|
||||
if curr_cid:
|
||||
"""从所有对话的标题字典中获取标题"""
|
||||
title = _titles.get(curr_cid, "新对话")
|
||||
ret += f"\n当前对话: {title}({curr_cid[:4]})"
|
||||
else:
|
||||
ret += "\n当前对话: 无"
|
||||
|
||||
cfg = self.context.get_config(umo=message.unified_msg_origin)
|
||||
unique_session = cfg["platform_settings"]["unique_session"]
|
||||
if unique_session:
|
||||
ret += "\n会话隔离粒度: 个人"
|
||||
else:
|
||||
ret += "\n会话隔离粒度: 群聊"
|
||||
|
||||
ret += f"\n第 {page} 页 | 共 {total_pages} 页"
|
||||
ret += "\n*输入 /ls 2 跳转到第 2 页"
|
||||
|
||||
message.set_result(MessageEventResult().message(ret).use_t2i(False))
|
||||
return
|
||||
|
||||
async def new_conv(self, message: AstrMessageEvent) -> None:
|
||||
"""创建新对话"""
|
||||
cfg = self.context.get_config(umo=message.unified_msg_origin)
|
||||
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
||||
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
|
||||
active_event_registry.stop_all(message.unified_msg_origin, exclude=message)
|
||||
await _clear_third_party_agent_runner_state(
|
||||
self.context,
|
||||
message.unified_msg_origin,
|
||||
agent_runner_type,
|
||||
)
|
||||
message.set_result(
|
||||
MessageEventResult().message("✅ New conversation created.")
|
||||
await sp.remove_async(
|
||||
scope="umo",
|
||||
scope_id=message.unified_msg_origin,
|
||||
key=THIRD_PARTY_AGENT_RUNNER_KEY[agent_runner_type],
|
||||
)
|
||||
message.set_result(MessageEventResult().message("已创建新对话。"))
|
||||
return
|
||||
|
||||
active_event_registry.stop_all(message.unified_msg_origin, exclude=message)
|
||||
cpersona = await self._get_current_persona_id(message.unified_msg_origin)
|
||||
cid = await self.context.conversation_manager.new_conversation(
|
||||
message.unified_msg_origin,
|
||||
@@ -243,69 +236,131 @@ class ConversationCommands:
|
||||
persona_id=cpersona,
|
||||
)
|
||||
|
||||
message.set_extra("_clean_group_context_session", True)
|
||||
message.set_extra("_clean_ltm_session", True)
|
||||
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
f"✅ Switched to new conversation: {cid[:4]}。"
|
||||
),
|
||||
MessageEventResult().message(f"切换到新对话: 新对话({cid[:4]})。"),
|
||||
)
|
||||
|
||||
async def stats(self, message: AstrMessageEvent) -> None:
|
||||
"""Show token usage statistics for the current conversation."""
|
||||
umo = message.unified_msg_origin
|
||||
cid = await self.context.conversation_manager.get_curr_conversation_id(umo)
|
||||
async def groupnew_conv(self, message: AstrMessageEvent, sid: str = "") -> None:
|
||||
"""创建新群聊对话"""
|
||||
if sid:
|
||||
session = str(
|
||||
MessageSession(
|
||||
platform_name=message.platform_meta.id,
|
||||
message_type=MessageType("GroupMessage"),
|
||||
session_id=sid,
|
||||
),
|
||||
)
|
||||
|
||||
if not cid:
|
||||
cpersona = await self._get_current_persona_id(session)
|
||||
cid = await self.context.conversation_manager.new_conversation(
|
||||
session,
|
||||
message.get_platform_id(),
|
||||
persona_id=cpersona,
|
||||
)
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
"❌ You are not in a conversation. Use /new to create one."
|
||||
f"群聊 {session} 已切换到新对话: 新对话({cid[:4]})。",
|
||||
),
|
||||
)
|
||||
else:
|
||||
message.set_result(
|
||||
MessageEventResult().message("请输入群聊 ID。/groupnew 群聊ID。"),
|
||||
)
|
||||
|
||||
async def switch_conv(
|
||||
self,
|
||||
message: AstrMessageEvent,
|
||||
index: int | None = None,
|
||||
) -> None:
|
||||
"""通过 /ls 前面的序号切换对话"""
|
||||
if not isinstance(index, int):
|
||||
message.set_result(
|
||||
MessageEventResult().message("类型错误,请输入数字对话序号。"),
|
||||
)
|
||||
return
|
||||
|
||||
if index is None:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
"请输入对话序号。/switch 对话序号。/ls 查看对话 /new 新建对话",
|
||||
),
|
||||
)
|
||||
return
|
||||
conversations = await self.context.conversation_manager.get_conversations(
|
||||
message.unified_msg_origin,
|
||||
)
|
||||
if index > len(conversations) or index < 1:
|
||||
message.set_result(
|
||||
MessageEventResult().message("对话序号错误,请使用 /ls 查看"),
|
||||
)
|
||||
else:
|
||||
conversation = conversations[index - 1]
|
||||
title = conversation.title if conversation.title else "新对话"
|
||||
await self.context.conversation_manager.switch_conversation(
|
||||
message.unified_msg_origin,
|
||||
conversation.cid,
|
||||
)
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
f"切换到对话: {title}({conversation.cid[:4]})。",
|
||||
),
|
||||
)
|
||||
|
||||
async def rename_conv(self, message: AstrMessageEvent, new_name: str = "") -> None:
|
||||
"""重命名对话"""
|
||||
if not new_name:
|
||||
message.set_result(MessageEventResult().message("请输入新的对话名称。"))
|
||||
return
|
||||
await self.context.conversation_manager.update_conversation_title(
|
||||
message.unified_msg_origin,
|
||||
new_name,
|
||||
)
|
||||
message.set_result(MessageEventResult().message("重命名对话成功。"))
|
||||
|
||||
async def del_conv(self, message: AstrMessageEvent) -> None:
|
||||
"""删除当前对话"""
|
||||
cfg = self.context.get_config(umo=message.unified_msg_origin)
|
||||
is_unique_session = cfg["platform_settings"]["unique_session"]
|
||||
if message.get_group_id() and not is_unique_session and message.role != "admin":
|
||||
# 群聊,没开独立会话,发送人不是管理员
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
f"会话处于群聊,并且未开启独立会话,并且您 (ID {message.get_sender_id()}) 不是管理员,因此没有权限删除当前对话。",
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
db = self.context.get_db()
|
||||
async with db.get_db() as session:
|
||||
result = await session.execute(
|
||||
select(
|
||||
func.count(case((col(ProviderStat.id).is_not(None), 1))).label(
|
||||
"record_count",
|
||||
),
|
||||
func.coalesce(func.sum(ProviderStat.token_input_other), 0).label(
|
||||
"total_input_other",
|
||||
),
|
||||
func.coalesce(func.sum(ProviderStat.token_input_cached), 0).label(
|
||||
"total_input_cached",
|
||||
),
|
||||
func.coalesce(func.sum(ProviderStat.token_output), 0).label(
|
||||
"total_output",
|
||||
),
|
||||
).where(
|
||||
col(ProviderStat.agent_type) == "internal",
|
||||
col(ProviderStat.conversation_id) == cid,
|
||||
)
|
||||
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
||||
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
|
||||
await sp.remove_async(
|
||||
scope="umo",
|
||||
scope_id=message.unified_msg_origin,
|
||||
key=THIRD_PARTY_AGENT_RUNNER_KEY[agent_runner_type],
|
||||
)
|
||||
stats = result.one()
|
||||
message.set_result(MessageEventResult().message("重置对话成功。"))
|
||||
return
|
||||
|
||||
if stats.record_count == 0:
|
||||
session_curr_cid = (
|
||||
await self.context.conversation_manager.get_curr_conversation_id(
|
||||
message.unified_msg_origin,
|
||||
)
|
||||
)
|
||||
|
||||
if not session_curr_cid:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
"📊 No stats available for this conversation yet."
|
||||
"当前未处于对话状态,请 /switch 序号 切换或 /new 创建。",
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
total_input_other = stats.total_input_other
|
||||
total_input_cached = stats.total_input_cached
|
||||
total_output = stats.total_output
|
||||
total_tokens = total_input_other + total_input_cached + total_output
|
||||
|
||||
ret = (
|
||||
f"📊 Conversation Token usage (ID: {cid[:8]}...)\n"
|
||||
f"Total: {total_tokens:,}\n"
|
||||
f"Input (cached): {total_input_cached:,}\n"
|
||||
f"Input (other): {total_input_other:,}\n"
|
||||
f"Output: {total_output:,}\n"
|
||||
await self.context.conversation_manager.delete_conversation(
|
||||
message.unified_msg_origin,
|
||||
session_curr_cid,
|
||||
)
|
||||
|
||||
ret = "删除当前对话成功。不再处于对话状态,使用 /switch 序号 切换到其他对话或 /new 创建。"
|
||||
message.set_extra("_clean_ltm_session", True)
|
||||
message.set_result(MessageEventResult().message(ret))
|
||||
|
||||
@@ -23,13 +23,16 @@ class HelpCommand:
|
||||
return ""
|
||||
|
||||
async def _build_reserved_command_lines(self) -> list[str]:
|
||||
"""使用实时指令配置生成内置指令清单,确保重命名/禁用后与实际生效状态保持一致。"""
|
||||
"""
|
||||
使用实时指令配置生成内置指令清单,确保重命名/禁用后与实际生效状态保持一致。
|
||||
"""
|
||||
try:
|
||||
commands = await command_management.list_commands()
|
||||
except BaseException:
|
||||
return []
|
||||
|
||||
lines: list[str] = []
|
||||
hidden_commands = {"set", "unset", "websearch"}
|
||||
|
||||
def walk(items: list[dict], indent: int = 0) -> None:
|
||||
for item in items:
|
||||
@@ -46,12 +49,9 @@ class HelpCommand:
|
||||
or item.get("original_command")
|
||||
or item.get("handler_name")
|
||||
)
|
||||
if not effective or effective in [
|
||||
"set",
|
||||
"unset",
|
||||
"help",
|
||||
"dashboard_update",
|
||||
]:
|
||||
if not effective:
|
||||
continue
|
||||
if effective in hidden_commands:
|
||||
continue
|
||||
|
||||
description = item.get("description") or ""
|
||||
@@ -73,13 +73,12 @@ class HelpCommand:
|
||||
dashboard_version = await get_dashboard_version()
|
||||
command_lines = await self._build_reserved_command_lines()
|
||||
commands_section = (
|
||||
"\n".join(command_lines)
|
||||
if command_lines
|
||||
else "No enabled built-in commands."
|
||||
"\n".join(command_lines) if command_lines else "暂无启用的内置指令"
|
||||
)
|
||||
|
||||
msg_parts = [
|
||||
f"AstrBot v{VERSION}(WebUI: {dashboard_version})",
|
||||
"内置指令:",
|
||||
commands_section,
|
||||
]
|
||||
if notice:
|
||||
|
||||
@@ -17,4 +17,4 @@ class LLMCommands:
|
||||
cfg["provider_settings"]["enable"] = True
|
||||
status = "开启"
|
||||
cfg.save_config()
|
||||
await event.send(MessageChain().message(f"{status} LLM 聊天功能。"))
|
||||
await event.send(MessageChain().message(f"{status} LLM 聊天功能。"))
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
from astrbot.api import star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
from astrbot.core.umo_alias import get_event_auto_name, normalize_umo_name
|
||||
|
||||
|
||||
class NameCommand:
|
||||
def __init__(self, context: star.Context) -> None:
|
||||
self.context = context
|
||||
|
||||
async def name(self, event: AstrMessageEvent, alias: str) -> None:
|
||||
umo = event.unified_msg_origin
|
||||
auto_name = get_event_auto_name(event)
|
||||
alias = normalize_umo_name(alias)
|
||||
if not alias:
|
||||
saved_alias = await self.context.get_db().get_umo_alias(umo)
|
||||
user_alias = normalize_umo_name(
|
||||
saved_alias.user_alias if saved_alias else ""
|
||||
)
|
||||
event.set_result(
|
||||
MessageEventResult()
|
||||
.message(
|
||||
"\n".join(
|
||||
[
|
||||
"Usage: /name <name>",
|
||||
f"UMO: {umo}",
|
||||
f"Auto name: {auto_name or '(empty)'}",
|
||||
f"Alias: {user_alias or '(empty)'}",
|
||||
]
|
||||
)
|
||||
)
|
||||
.use_t2i(False)
|
||||
)
|
||||
return
|
||||
|
||||
sender_id = str(event.get_sender_id() or "")
|
||||
|
||||
await self.context.get_db().upsert_umo_alias(
|
||||
umo=umo,
|
||||
creator_sender_id=sender_id,
|
||||
auto_name=auto_name,
|
||||
user_alias=alias,
|
||||
)
|
||||
|
||||
event.set_result(
|
||||
MessageEventResult()
|
||||
.message(f"UMO name set to: {alias}\nUMO: {umo}")
|
||||
.use_t2i(False)
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
import builtins
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from astrbot.api import star
|
||||
from astrbot.api import sp, star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -18,10 +18,10 @@ class PersonaCommands:
|
||||
all_personas: list["Persona"],
|
||||
depth: int = 0,
|
||||
) -> list[str]:
|
||||
"""递归构建树状输出,使用短线条表示层级"""
|
||||
"""递归构建树状输出,使用短线条表示层级"""
|
||||
lines: list[str] = []
|
||||
# 使用短线条作为缩进前缀,每层只用 "│" 加一个空格
|
||||
prefix = "│ " * depth
|
||||
# 使用短线条作为缩进前缀,每层只用 "│" 加一个空格
|
||||
prefix = "│ " * depth
|
||||
|
||||
for folder in folder_tree:
|
||||
# 输出文件夹
|
||||
@@ -31,7 +31,7 @@ class PersonaCommands:
|
||||
folder_personas = [
|
||||
p for p in all_personas if p.folder_id == folder["folder_id"]
|
||||
]
|
||||
child_prefix = "│ " * (depth + 1)
|
||||
child_prefix = "│ " * (depth + 1)
|
||||
|
||||
# 输出该文件夹下的人格
|
||||
for persona in folder_personas:
|
||||
@@ -45,13 +45,13 @@ class PersonaCommands:
|
||||
children,
|
||||
all_personas,
|
||||
depth + 1,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return lines
|
||||
|
||||
async def persona(self, message: AstrMessageEvent) -> None:
|
||||
parts = message.message_str.split(" ")
|
||||
l = message.message_str.split(" ") # noqa: E741
|
||||
umo = message.unified_msg_origin
|
||||
|
||||
curr_persona_name = "无"
|
||||
@@ -59,7 +59,12 @@ class PersonaCommands:
|
||||
default_persona = await self.context.persona_manager.get_default_persona_v3(
|
||||
umo=umo,
|
||||
)
|
||||
force_applied_persona_id = None
|
||||
|
||||
force_applied_persona_id = (
|
||||
await sp.get_async(
|
||||
scope="umo", scope_id=umo, key="session_service_config", default={}
|
||||
)
|
||||
).get("persona_id")
|
||||
|
||||
curr_cid_title = "无"
|
||||
if cid:
|
||||
@@ -71,39 +76,22 @@ class PersonaCommands:
|
||||
if conv is None:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
"当前对话不存在,请先使用 /new 新建一个对话。",
|
||||
"当前对话不存在,请先使用 /new 新建一个对话。",
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
provider_settings = self.context.get_config(umo=umo).get(
|
||||
"provider_settings",
|
||||
{},
|
||||
)
|
||||
(
|
||||
persona_id,
|
||||
_,
|
||||
force_applied_persona_id,
|
||||
_,
|
||||
) = await self.context.persona_manager.resolve_selected_persona(
|
||||
umo=umo,
|
||||
conversation_persona_id=conv.persona_id,
|
||||
platform_name=message.get_platform_name(),
|
||||
provider_settings=provider_settings,
|
||||
)
|
||||
|
||||
if persona_id == "[%None]":
|
||||
curr_persona_name = "无"
|
||||
elif persona_id:
|
||||
curr_persona_name = persona_id
|
||||
if not conv.persona_id and conv.persona_id != "[%None]":
|
||||
curr_persona_name = default_persona["name"]
|
||||
else:
|
||||
curr_persona_name = conv.persona_id
|
||||
|
||||
if force_applied_persona_id:
|
||||
curr_persona_name = f"{curr_persona_name} (自定义规则)"
|
||||
|
||||
curr_cid_title = conv.title or "新对话"
|
||||
curr_cid_title = conv.title if conv.title else "新对话"
|
||||
curr_cid_title += f"({cid[:4]})"
|
||||
|
||||
if len(parts) == 1:
|
||||
if len(l) == 1:
|
||||
message.set_result(
|
||||
MessageEventResult()
|
||||
.message(
|
||||
@@ -122,21 +110,21 @@ class PersonaCommands:
|
||||
)
|
||||
.use_t2i(False),
|
||||
)
|
||||
elif parts[1] == "list":
|
||||
elif l[1] == "list":
|
||||
# 获取文件夹树和所有人格
|
||||
folder_tree = await self.context.persona_manager.get_folder_tree()
|
||||
all_personas = self.context.persona_manager.personas
|
||||
|
||||
lines = ["📂 人格列表:\n"]
|
||||
lines = ["📂 人格列表:\n"]
|
||||
|
||||
# 构建树状输出
|
||||
tree_lines = self._build_tree_output(folder_tree, all_personas)
|
||||
lines.extend(tree_lines)
|
||||
|
||||
# 输出根目录下的人格(没有文件夹的)
|
||||
# 输出根目录下的人格(没有文件夹的)
|
||||
root_personas = [p for p in all_personas if p.folder_id is None]
|
||||
if root_personas:
|
||||
if tree_lines: # 如果有文件夹内容,加个空行
|
||||
if tree_lines: # 如果有文件夹内容,加个空行
|
||||
lines.append("")
|
||||
for persona in root_personas:
|
||||
lines.append(f"👤 {persona.persona_id}")
|
||||
@@ -149,44 +137,44 @@ class PersonaCommands:
|
||||
|
||||
msg = "\n".join(lines)
|
||||
message.set_result(MessageEventResult().message(msg).use_t2i(False))
|
||||
elif parts[1] == "view":
|
||||
if len(parts) == 2:
|
||||
elif l[1] == "view":
|
||||
if len(l) == 2:
|
||||
message.set_result(MessageEventResult().message("请输入人格情景名"))
|
||||
return
|
||||
ps = parts[2].strip()
|
||||
if persona_info := next(
|
||||
ps = l[2].strip()
|
||||
if persona := next(
|
||||
builtins.filter(
|
||||
lambda persona: persona["name"] == ps,
|
||||
self.context.provider_manager.personas,
|
||||
),
|
||||
None,
|
||||
):
|
||||
msg = f"人格{ps}的详细信息:\n"
|
||||
msg += f"{persona_info['prompt']}\n"
|
||||
msg = f"人格{ps}的详细信息:\n"
|
||||
msg += f"{persona['prompt']}\n"
|
||||
else:
|
||||
msg = f"人格{ps}不存在"
|
||||
message.set_result(MessageEventResult().message(msg))
|
||||
elif parts[1] == "unset":
|
||||
elif l[1] == "unset":
|
||||
if not cid:
|
||||
message.set_result(
|
||||
MessageEventResult().message("当前没有对话,无法取消人格。"),
|
||||
MessageEventResult().message("当前没有对话,无法取消人格。"),
|
||||
)
|
||||
return
|
||||
await self.context.conversation_manager.update_conversation_persona_id(
|
||||
message.unified_msg_origin,
|
||||
"[%None]",
|
||||
)
|
||||
message.set_result(MessageEventResult().message("取消人格成功。"))
|
||||
message.set_result(MessageEventResult().message("取消人格成功。"))
|
||||
else:
|
||||
ps = "".join(parts[1:]).strip()
|
||||
ps = "".join(l[1:]).strip()
|
||||
if not cid:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
"当前没有对话,请先开始对话或使用 /new 创建一个对话。",
|
||||
"当前没有对话,请先开始对话或使用 /new 创建一个对话。",
|
||||
),
|
||||
)
|
||||
return
|
||||
if persona_info := next(
|
||||
if persona := next(
|
||||
builtins.filter(
|
||||
lambda persona: persona["name"] == ps,
|
||||
self.context.provider_manager.personas,
|
||||
@@ -199,16 +187,18 @@ class PersonaCommands:
|
||||
)
|
||||
force_warn_msg = ""
|
||||
if force_applied_persona_id:
|
||||
force_warn_msg = "提醒:由于自定义规则,您现在切换的人格将不会生效。"
|
||||
force_warn_msg = (
|
||||
"提醒:由于自定义规则,您现在切换的人格将不会生效。"
|
||||
)
|
||||
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
f"设置成功。如果您正在切换到不同的人格,请注意使用 /reset 来清空上下文,防止原人格对话影响现人格。{force_warn_msg}",
|
||||
f"设置成功。如果您正在切换到不同的人格,请注意使用 /reset 来清空上下文,防止原人格对话影响现人格。{force_warn_msg}",
|
||||
),
|
||||
)
|
||||
else:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
"不存在该人格情景。使用 /persona list 查看所有。",
|
||||
"不存在该人格情景。使用 /persona list 查看所有。",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ from astrbot.core import DEMO_MODE, logger
|
||||
from astrbot.core.star.filter.command import CommandFilter
|
||||
from astrbot.core.star.filter.command_group import CommandGroupFilter
|
||||
from astrbot.core.star.star_handler import StarHandlerMetadata, star_handlers_registry
|
||||
from astrbot.core.star.star_manager import PluginManager
|
||||
|
||||
|
||||
class PluginCommands:
|
||||
@@ -11,8 +12,8 @@ class PluginCommands:
|
||||
self.context = context
|
||||
|
||||
async def plugin_ls(self, event: AstrMessageEvent) -> None:
|
||||
"""获取已经安装的插件列表。"""
|
||||
parts = ["已加载的插件:\n"]
|
||||
"""获取已经安装的插件列表。"""
|
||||
parts = ["已加载的插件:\n"]
|
||||
for plugin in self.context.get_all_stars():
|
||||
line = f"- `{plugin.name}` By {plugin.author}: {plugin.desc}"
|
||||
if not plugin.activated:
|
||||
@@ -20,11 +21,11 @@ class PluginCommands:
|
||||
parts.append(line + "\n")
|
||||
|
||||
if len(parts) == 1:
|
||||
plugin_list_info = "没有加载任何插件。"
|
||||
plugin_list_info = "没有加载任何插件。"
|
||||
else:
|
||||
plugin_list_info = "".join(parts)
|
||||
|
||||
plugin_list_info += "\n使用 /plugin help <插件名> 查看插件帮助和加载的指令。\n使用 /plugin on/off <插件名> 启用或者禁用插件。"
|
||||
plugin_list_info += "\n使用 /plugin help <插件名> 查看插件帮助和加载的指令。\n使用 /plugin on/off <插件名> 启用或者禁用插件。"
|
||||
event.set_result(
|
||||
MessageEventResult().message(f"{plugin_list_info}").use_t2i(False),
|
||||
)
|
||||
@@ -32,51 +33,45 @@ class PluginCommands:
|
||||
async def plugin_off(self, event: AstrMessageEvent, plugin_name: str = "") -> None:
|
||||
"""禁用插件"""
|
||||
if DEMO_MODE:
|
||||
event.set_result(MessageEventResult().message("演示模式下无法禁用插件。"))
|
||||
event.set_result(MessageEventResult().message("演示模式下无法禁用插件。"))
|
||||
return
|
||||
if not plugin_name:
|
||||
event.set_result(
|
||||
MessageEventResult().message("/plugin off <插件名> 禁用插件。"),
|
||||
MessageEventResult().message("/plugin off <插件名> 禁用插件。"),
|
||||
)
|
||||
return
|
||||
if self.context._star_manager is None:
|
||||
event.set_result(MessageEventResult().message("插件管理器未初始化。"))
|
||||
return
|
||||
await self.context._star_manager.turn_off_plugin(plugin_name)
|
||||
event.set_result(MessageEventResult().message(f"插件 {plugin_name} 已禁用。"))
|
||||
await self.context._star_manager.turn_off_plugin(plugin_name) # type: ignore
|
||||
event.set_result(MessageEventResult().message(f"插件 {plugin_name} 已禁用。"))
|
||||
|
||||
async def plugin_on(self, event: AstrMessageEvent, plugin_name: str = "") -> None:
|
||||
"""启用插件"""
|
||||
if DEMO_MODE:
|
||||
event.set_result(MessageEventResult().message("演示模式下无法启用插件。"))
|
||||
event.set_result(MessageEventResult().message("演示模式下无法启用插件。"))
|
||||
return
|
||||
if not plugin_name:
|
||||
event.set_result(
|
||||
MessageEventResult().message("/plugin on <插件名> 启用插件。"),
|
||||
MessageEventResult().message("/plugin on <插件名> 启用插件。"),
|
||||
)
|
||||
return
|
||||
if self.context._star_manager is None:
|
||||
event.set_result(MessageEventResult().message("插件管理器未初始化。"))
|
||||
return
|
||||
await self.context._star_manager.turn_on_plugin(plugin_name)
|
||||
event.set_result(MessageEventResult().message(f"插件 {plugin_name} 已启用。"))
|
||||
await self.context._star_manager.turn_on_plugin(plugin_name) # type: ignore
|
||||
event.set_result(MessageEventResult().message(f"插件 {plugin_name} 已启用。"))
|
||||
|
||||
async def plugin_get(self, event: AstrMessageEvent, plugin_repo: str = "") -> None:
|
||||
"""安装插件"""
|
||||
if DEMO_MODE:
|
||||
event.set_result(MessageEventResult().message("演示模式下无法安装插件。"))
|
||||
event.set_result(MessageEventResult().message("演示模式下无法安装插件。"))
|
||||
return
|
||||
if not plugin_repo:
|
||||
event.set_result(
|
||||
MessageEventResult().message("/plugin get <插件仓库地址> 安装插件"),
|
||||
)
|
||||
return
|
||||
logger.info(f"准备从 {plugin_repo} 安装插件。")
|
||||
logger.info(f"准备从 {plugin_repo} 安装插件。")
|
||||
if self.context._star_manager:
|
||||
star_mgr = self.context._star_manager
|
||||
star_mgr: PluginManager = self.context._star_manager
|
||||
try:
|
||||
await star_mgr.install_plugin(plugin_repo)
|
||||
event.set_result(MessageEventResult().message("安装插件成功。"))
|
||||
await star_mgr.install_plugin(plugin_repo) # type: ignore
|
||||
event.set_result(MessageEventResult().message("安装插件成功。"))
|
||||
except Exception as e:
|
||||
logger.error(f"安装插件失败: {e}")
|
||||
event.set_result(MessageEventResult().message(f"安装插件失败: {e}"))
|
||||
@@ -86,12 +81,12 @@ class PluginCommands:
|
||||
"""获取插件帮助"""
|
||||
if not plugin_name:
|
||||
event.set_result(
|
||||
MessageEventResult().message("/plugin help <插件名> 查看插件信息。"),
|
||||
MessageEventResult().message("/plugin help <插件名> 查看插件信息。"),
|
||||
)
|
||||
return
|
||||
plugin = self.context.get_registered_star(plugin_name)
|
||||
if plugin is None:
|
||||
event.set_result(MessageEventResult().message("未找到此插件。"))
|
||||
event.set_result(MessageEventResult().message("未找到此插件。"))
|
||||
return
|
||||
help_msg = ""
|
||||
help_msg += f"\n\n✨ 作者: {plugin.author}\n✨ 版本: {plugin.version}"
|
||||
@@ -111,15 +106,15 @@ class PluginCommands:
|
||||
command_names.append(filter_.group_name)
|
||||
|
||||
if len(command_handlers) > 0:
|
||||
parts = ["\n\n🔧 指令列表:\n"]
|
||||
parts = ["\n\n🔧 指令列表:\n"]
|
||||
for i in range(len(command_handlers)):
|
||||
line = f"- {command_names[i]}"
|
||||
if command_handlers[i].desc:
|
||||
line += f": {command_handlers[i].desc}"
|
||||
parts.append(line + "\n")
|
||||
parts.append("\nTip: 指令的触发需要添加唤醒前缀,默认为 /。")
|
||||
parts.append("\nTip: 指令的触发需要添加唤醒前缀,默认为 /。")
|
||||
help_msg += "".join(parts)
|
||||
|
||||
ret = f"🧩 插件 {plugin_name} 帮助信息:\n" + help_msg
|
||||
ret += "更多帮助信息请查看插件仓库 README。"
|
||||
ret = f"🧩 插件 {plugin_name} 帮助信息:\n" + help_msg
|
||||
ret += "更多帮助信息请查看插件仓库 README。"
|
||||
event.set_result(MessageEventResult().message(ret).use_t2i(False))
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
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
|
||||
|
||||
|
||||
class ProviderCommands:
|
||||
@@ -20,6 +18,7 @@ class ProviderCommands:
|
||||
err_code: str,
|
||||
err_reason: str,
|
||||
) -> None:
|
||||
"""记录不可达原因到日志。"""
|
||||
meta = provider.meta()
|
||||
logger.warning(
|
||||
"Provider reachability check failed: id=%s type=%s code=%s reason=%s",
|
||||
@@ -30,6 +29,7 @@ class ProviderCommands:
|
||||
)
|
||||
|
||||
async def _test_provider_capability(self, provider):
|
||||
"""测试单个 provider 的可用性"""
|
||||
meta = provider.meta()
|
||||
provider_capability_type = meta.provider_type
|
||||
|
||||
@@ -38,79 +38,12 @@ class ProviderCommands:
|
||||
return True, None, None
|
||||
except Exception as e:
|
||||
err_code = "TEST_FAILED"
|
||||
err_reason = safe_error("", e)
|
||||
err_reason = str(e)
|
||||
self._log_reachability_failure(
|
||||
provider,
|
||||
provider_capability_type,
|
||||
err_code,
|
||||
err_reason,
|
||||
provider, provider_capability_type, err_code, err_reason
|
||||
)
|
||||
return False, err_code, err_reason
|
||||
|
||||
async def _build_provider_display_data(
|
||||
self,
|
||||
providers,
|
||||
provider_type: str,
|
||||
reachability_check_enabled: bool,
|
||||
) -> list[dict]:
|
||||
if not providers:
|
||||
return []
|
||||
|
||||
if reachability_check_enabled:
|
||||
check_results = await asyncio.gather(
|
||||
*[self._test_provider_capability(provider) for provider in providers],
|
||||
return_exceptions=True,
|
||||
)
|
||||
else:
|
||||
check_results = [None for _ in providers]
|
||||
|
||||
display_data = []
|
||||
for provider, reachable in zip(providers, check_results, strict=False):
|
||||
meta = provider.meta()
|
||||
id_ = meta.id
|
||||
error_code = None
|
||||
|
||||
if isinstance(reachable, asyncio.CancelledError):
|
||||
raise reachable
|
||||
if isinstance(reachable, Exception):
|
||||
self._log_reachability_failure(
|
||||
provider,
|
||||
None,
|
||||
reachable.__class__.__name__,
|
||||
safe_error("", reachable),
|
||||
)
|
||||
reachable_flag = False
|
||||
error_code = reachable.__class__.__name__
|
||||
elif isinstance(reachable, tuple):
|
||||
reachable_flag, error_code, _ = reachable
|
||||
else:
|
||||
reachable_flag = reachable
|
||||
|
||||
if provider_type == "llm":
|
||||
info = f"{id_} ({meta.model})"
|
||||
else:
|
||||
info = f"{id_}"
|
||||
|
||||
if reachable_flag is True:
|
||||
mark = " ✅"
|
||||
elif reachable_flag is False:
|
||||
if error_code:
|
||||
mark = f" ❌(errcode: {error_code})"
|
||||
else:
|
||||
mark = " ❌"
|
||||
else:
|
||||
mark = ""
|
||||
|
||||
display_data.append(
|
||||
{
|
||||
"info": info,
|
||||
"mark": mark,
|
||||
"provider": provider,
|
||||
},
|
||||
)
|
||||
|
||||
return display_data
|
||||
|
||||
async def provider(
|
||||
self,
|
||||
event: AstrMessageEvent,
|
||||
@@ -123,82 +56,135 @@ class ProviderCommands:
|
||||
reachability_check_enabled = cfg.get("reachability_check", True)
|
||||
|
||||
if idx is None:
|
||||
parts = ["## LLM Providers\n"]
|
||||
parts = ["## 载入的 LLM 提供商\n"]
|
||||
|
||||
# 获取所有类型的提供商
|
||||
llms = list(self.context.get_all_providers())
|
||||
ttss = self.context.get_all_tts_providers()
|
||||
stts = self.context.get_all_stt_providers()
|
||||
|
||||
if reachability_check_enabled and (llms or ttss or stts):
|
||||
await event.send(
|
||||
MessageEventResult().message("👀 Testing provider reachability..."),
|
||||
# 构造待检测列表: [(provider, type_label), ...]
|
||||
all_providers = []
|
||||
all_providers.extend([(p, "llm") for p in llms])
|
||||
all_providers.extend([(p, "tts") for p in ttss])
|
||||
all_providers.extend([(p, "stt") for p in stts])
|
||||
|
||||
# 并发测试连通性
|
||||
if reachability_check_enabled:
|
||||
if all_providers:
|
||||
await event.send(
|
||||
MessageEventResult().message(
|
||||
"正在进行提供商可达性测试,请稍候..."
|
||||
)
|
||||
)
|
||||
check_results = await asyncio.gather(
|
||||
*[self._test_provider_capability(p) for p, _ in all_providers],
|
||||
return_exceptions=True,
|
||||
)
|
||||
else:
|
||||
# 用 None 表示未检测
|
||||
check_results = [None for _ in all_providers]
|
||||
|
||||
# 整合结果
|
||||
display_data = []
|
||||
for (p, p_type), reachable in zip(all_providers, check_results):
|
||||
meta = p.meta()
|
||||
id_ = meta.id
|
||||
error_code = None
|
||||
|
||||
if isinstance(reachable, Exception):
|
||||
# 异常情况下兜底处理,避免单个 provider 导致列表失败
|
||||
self._log_reachability_failure(
|
||||
p,
|
||||
None,
|
||||
reachable.__class__.__name__,
|
||||
str(reachable),
|
||||
)
|
||||
reachable_flag = False
|
||||
error_code = reachable.__class__.__name__
|
||||
elif isinstance(reachable, tuple):
|
||||
reachable_flag, error_code, _ = reachable
|
||||
else:
|
||||
reachable_flag = reachable
|
||||
|
||||
# 根据类型构建显示名称
|
||||
if p_type == "llm":
|
||||
info = f"{id_} ({meta.model})"
|
||||
else:
|
||||
info = f"{id_}"
|
||||
|
||||
# 确定状态标记
|
||||
if reachable_flag is True:
|
||||
mark = " ✅"
|
||||
elif reachable_flag is False:
|
||||
if error_code:
|
||||
mark = f" ❌(错误码: {error_code})"
|
||||
else:
|
||||
mark = " ❌"
|
||||
else:
|
||||
mark = "" # 不支持检测时不显示标记
|
||||
|
||||
display_data.append(
|
||||
{
|
||||
"type": p_type,
|
||||
"info": info,
|
||||
"mark": mark,
|
||||
"provider": p,
|
||||
}
|
||||
)
|
||||
|
||||
llm_data, tts_data, stt_data = await asyncio.gather(
|
||||
self._build_provider_display_data(
|
||||
llms,
|
||||
"llm",
|
||||
reachability_check_enabled,
|
||||
),
|
||||
self._build_provider_display_data(
|
||||
ttss,
|
||||
"tts",
|
||||
reachability_check_enabled,
|
||||
),
|
||||
self._build_provider_display_data(
|
||||
stts,
|
||||
"stt",
|
||||
reachability_check_enabled,
|
||||
),
|
||||
)
|
||||
|
||||
provider_using = self.context.get_using_provider(umo=umo)
|
||||
# 分组输出
|
||||
# 1. LLM
|
||||
llm_data = [d for d in display_data if d["type"] == "llm"]
|
||||
for i, d in enumerate(llm_data):
|
||||
line = f"{i + 1}. {d['info']}{d['mark']}"
|
||||
provider_using = self.context.get_using_provider(umo=umo)
|
||||
if (
|
||||
provider_using
|
||||
and provider_using.meta().id == d["provider"].meta().id
|
||||
):
|
||||
line += " 👈"
|
||||
line += " (当前使用)"
|
||||
parts.append(line + "\n")
|
||||
|
||||
# 2. TTS
|
||||
tts_data = [d for d in display_data if d["type"] == "tts"]
|
||||
if tts_data:
|
||||
parts.append("\n## TTS Providers\n")
|
||||
tts_using = self.context.get_using_tts_provider(umo=umo)
|
||||
parts.append("\n## 载入的 TTS 提供商\n")
|
||||
for i, d in enumerate(tts_data):
|
||||
line = f"{i + 1}. {d['info']}{d['mark']}"
|
||||
tts_using = self.context.get_using_tts_provider(umo=umo)
|
||||
if tts_using and tts_using.meta().id == d["provider"].meta().id:
|
||||
line += " 👈"
|
||||
line += " (当前使用)"
|
||||
parts.append(line + "\n")
|
||||
|
||||
# 3. STT
|
||||
stt_data = [d for d in display_data if d["type"] == "stt"]
|
||||
if stt_data:
|
||||
parts.append("\n## STT Providers\n")
|
||||
stt_using = self.context.get_using_stt_provider(umo=umo)
|
||||
parts.append("\n## 载入的 STT 提供商\n")
|
||||
for i, d in enumerate(stt_data):
|
||||
line = f"{i + 1}. {d['info']}{d['mark']}"
|
||||
stt_using = self.context.get_using_stt_provider(umo=umo)
|
||||
if stt_using and stt_using.meta().id == d["provider"].meta().id:
|
||||
line += " 👈"
|
||||
line += " (当前使用)"
|
||||
parts.append(line + "\n")
|
||||
|
||||
parts.append("\nUse /provider <idx> to switch LLM providers.")
|
||||
parts.append("\n使用 /provider <序号> 切换 LLM 提供商。")
|
||||
ret = "".join(parts)
|
||||
|
||||
if ttss:
|
||||
ret += "\nUse /provider tts <idx> to switch TTS providers."
|
||||
ret += "\n使用 /provider tts <序号> 切换 TTS 提供商。"
|
||||
if stts:
|
||||
ret += "\nUse /provider stt <idx> to switch STT providers."
|
||||
ret += "\n使用 /provider stt <序号> 切换 STT 提供商。"
|
||||
if not reachability_check_enabled:
|
||||
ret += "\n已跳过提供商可达性检测,如需检测请在配置文件中开启。"
|
||||
|
||||
event.set_result(MessageEventResult().message(ret))
|
||||
elif idx == "tts":
|
||||
if idx2 is None:
|
||||
event.set_result(
|
||||
MessageEventResult().message("Please enter the index."),
|
||||
)
|
||||
event.set_result(MessageEventResult().message("请输入序号。"))
|
||||
return
|
||||
if idx2 > len(self.context.get_all_tts_providers()) or idx2 < 1:
|
||||
event.set_result(
|
||||
MessageEventResult().message("❌ Invalid provider index."),
|
||||
)
|
||||
event.set_result(MessageEventResult().message("无效的提供商序号。"))
|
||||
return
|
||||
provider = self.context.get_all_tts_providers()[idx2 - 1]
|
||||
id_ = provider.meta().id
|
||||
@@ -207,19 +193,13 @@ class ProviderCommands:
|
||||
provider_type=ProviderType.TEXT_TO_SPEECH,
|
||||
umo=umo,
|
||||
)
|
||||
event.set_result(
|
||||
MessageEventResult().message(f"✅ Successfully switched to {id_}."),
|
||||
)
|
||||
event.set_result(MessageEventResult().message(f"成功切换到 {id_}。"))
|
||||
elif idx == "stt":
|
||||
if idx2 is None:
|
||||
event.set_result(
|
||||
MessageEventResult().message("Please enter the index."),
|
||||
)
|
||||
event.set_result(MessageEventResult().message("请输入序号。"))
|
||||
return
|
||||
if idx2 > len(self.context.get_all_stt_providers()) or idx2 < 1:
|
||||
event.set_result(
|
||||
MessageEventResult().message("❌ Invalid provider index."),
|
||||
)
|
||||
event.set_result(MessageEventResult().message("无效的提供商序号。"))
|
||||
return
|
||||
provider = self.context.get_all_stt_providers()[idx2 - 1]
|
||||
id_ = provider.meta().id
|
||||
@@ -228,14 +208,10 @@ class ProviderCommands:
|
||||
provider_type=ProviderType.SPEECH_TO_TEXT,
|
||||
umo=umo,
|
||||
)
|
||||
event.set_result(
|
||||
MessageEventResult().message(f"✅ Successfully switched to {id_}."),
|
||||
)
|
||||
event.set_result(MessageEventResult().message(f"成功切换到 {id_}。"))
|
||||
elif isinstance(idx, int):
|
||||
if idx > len(self.context.get_all_providers()) or idx < 1:
|
||||
event.set_result(
|
||||
MessageEventResult().message("❌ Invalid provider index."),
|
||||
)
|
||||
event.set_result(MessageEventResult().message("无效的提供商序号。"))
|
||||
return
|
||||
provider = self.context.get_all_providers()[idx - 1]
|
||||
id_ = provider.meta().id
|
||||
@@ -244,87 +220,110 @@ class ProviderCommands:
|
||||
provider_type=ProviderType.CHAT_COMPLETION,
|
||||
umo=umo,
|
||||
)
|
||||
event.set_result(
|
||||
MessageEventResult().message(f"✅ Successfully switched to {id_}."),
|
||||
)
|
||||
event.set_result(MessageEventResult().message(f"成功切换到 {id_}。"))
|
||||
else:
|
||||
event.set_result(MessageEventResult().message("❌ Invalid parameter."))
|
||||
event.set_result(MessageEventResult().message("无效的参数。"))
|
||||
|
||||
async def model_ls(
|
||||
self,
|
||||
event: AstrMessageEvent,
|
||||
message: AstrMessageEvent,
|
||||
idx_or_name: int | str | None = None,
|
||||
) -> None:
|
||||
"""查看或者切换当前 Provider 的模型。"""
|
||||
umo = event.unified_msg_origin
|
||||
provider = self.context.get_using_provider(umo=umo)
|
||||
if provider is None:
|
||||
event.set_result(
|
||||
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"),
|
||||
"""查看或者切换模型"""
|
||||
prov = self.context.get_using_provider(message.unified_msg_origin)
|
||||
if not prov:
|
||||
message.set_result(
|
||||
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"),
|
||||
)
|
||||
return
|
||||
# 定义正则表达式匹配 API 密钥
|
||||
api_key_pattern = re.compile(r"key=[^&'\" ]+")
|
||||
|
||||
try:
|
||||
models = await provider.get_models()
|
||||
except Exception as e:
|
||||
event.set_result(
|
||||
MessageEventResult().message(
|
||||
f"获取模型列表失败: {safe_error('', e)}",
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
current_model = provider.get_model()
|
||||
if idx_or_name is None:
|
||||
if not models:
|
||||
event.set_result(
|
||||
MessageEventResult().message(
|
||||
f"当前模型: {current_model}\n此提供商未返回可切换模型列表。",
|
||||
),
|
||||
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),
|
||||
)
|
||||
return
|
||||
parts = ["下面列出了此模型提供商可用模型:"]
|
||||
for i, model in enumerate(models, 1):
|
||||
parts.append(f"\n{i}. {model}")
|
||||
|
||||
parts = [f"当前模型: {current_model}\n\n可用模型:\n"]
|
||||
for index, model_name in enumerate(models, start=1):
|
||||
suffix = " 👈" if model_name == current_model else ""
|
||||
parts.append(f"{index}. {model_name}{suffix}\n")
|
||||
parts.append("\n使用 /model <序号> 或 /model <模型名> 切换模型。")
|
||||
event.set_result(MessageEventResult().message("".join(parts)))
|
||||
return
|
||||
curr_model = prov.get_model() or "无"
|
||||
parts.append(f"\n当前模型: [{curr_model}]")
|
||||
parts.append(
|
||||
"\nTips: 使用 /model <模型名/编号>,即可实时更换模型。如目标模型不存在于上表,请输入模型名。"
|
||||
)
|
||||
|
||||
selected_model: str | None = None
|
||||
if isinstance(idx_or_name, int):
|
||||
if 1 <= idx_or_name <= len(models):
|
||||
selected_model = models[idx_or_name - 1]
|
||||
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)),
|
||||
)
|
||||
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)),
|
||||
)
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
f"切换模型成功。当前提供商: [{prov.meta().id}] 当前模型: [{prov.get_model()}]",
|
||||
),
|
||||
)
|
||||
else:
|
||||
text = idx_or_name.strip()
|
||||
if text.isdigit():
|
||||
model_index = int(text)
|
||||
if 1 <= model_index <= len(models):
|
||||
selected_model = models[model_index - 1]
|
||||
elif text:
|
||||
selected_model = text
|
||||
prov.set_model(idx_or_name)
|
||||
message.set_result(
|
||||
MessageEventResult().message(f"切换模型到 {prov.get_model()}。"),
|
||||
)
|
||||
|
||||
if not selected_model:
|
||||
event.set_result(MessageEventResult().message("❌ Invalid model index."))
|
||||
async def key(self, message: AstrMessageEvent, index: int | None = None) -> None:
|
||||
prov = self.context.get_using_provider(message.unified_msg_origin)
|
||||
if not prov:
|
||||
message.set_result(
|
||||
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"),
|
||||
)
|
||||
return
|
||||
|
||||
provider.set_model(selected_model)
|
||||
provider.provider_config["model"] = selected_model
|
||||
if index is None:
|
||||
keys_data = prov.get_keys()
|
||||
curr_key = prov.get_current_key()
|
||||
parts = ["Key:"]
|
||||
for i, k in enumerate(keys_data, 1):
|
||||
parts.append(f"\n{i}. {k[:8]}")
|
||||
|
||||
cfg = self.context.get_config(umo)
|
||||
providers_config = cfg.get("provider", [])
|
||||
if isinstance(providers_config, list):
|
||||
for provider_config in providers_config:
|
||||
if not isinstance(provider_config, dict):
|
||||
continue
|
||||
if provider_config.get("id") == provider.meta().id:
|
||||
provider_config["model"] = selected_model
|
||||
break
|
||||
cfg.save_config()
|
||||
parts.append(f"\n当前 Key: {curr_key[:8]}")
|
||||
parts.append("\n当前模型: " + prov.get_model())
|
||||
parts.append("\n使用 /key <idx> 切换 Key。")
|
||||
|
||||
event.set_result(
|
||||
MessageEventResult().message(
|
||||
f"✅ Successfully switched model to {selected_model}.",
|
||||
),
|
||||
)
|
||||
ret = "".join(parts)
|
||||
message.set_result(MessageEventResult().message(ret).use_t2i(False))
|
||||
else:
|
||||
keys_data = prov.get_keys()
|
||||
if index > len(keys_data) or index < 1:
|
||||
message.set_result(MessageEventResult().message("Key 序号错误。"))
|
||||
else:
|
||||
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}"),
|
||||
)
|
||||
message.set_result(MessageEventResult().message("切换 Key 成功。"))
|
||||
|
||||
@@ -2,16 +2,6 @@ from astrbot.api import sp, star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
|
||||
|
||||
def _normalize_session_variables(value: object) -> dict[str, str]:
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
return {
|
||||
key: value
|
||||
for key, value in value.items()
|
||||
if isinstance(key, str) and isinstance(value, str)
|
||||
}
|
||||
|
||||
|
||||
class SetUnsetCommands:
|
||||
def __init__(self, context: star.Context) -> None:
|
||||
self.context = context
|
||||
@@ -19,32 +9,28 @@ class SetUnsetCommands:
|
||||
async def set_variable(self, event: AstrMessageEvent, key: str, value: str) -> None:
|
||||
"""设置会话变量"""
|
||||
uid = event.unified_msg_origin
|
||||
session_var = _normalize_session_variables(
|
||||
await sp.session_get(uid, "session_variables", {}),
|
||||
)
|
||||
session_var = await sp.session_get(uid, "session_variables", {})
|
||||
session_var[key] = value
|
||||
await sp.session_put(uid, "session_variables", session_var)
|
||||
|
||||
event.set_result(
|
||||
MessageEventResult().message(
|
||||
f"会话 {uid} 变量 {key} 存储成功。使用 /unset 移除。",
|
||||
f"会话 {uid} 变量 {key} 存储成功。使用 /unset 移除。",
|
||||
),
|
||||
)
|
||||
|
||||
async def unset_variable(self, event: AstrMessageEvent, key: str) -> None:
|
||||
"""移除会话变量"""
|
||||
uid = event.unified_msg_origin
|
||||
session_var = _normalize_session_variables(
|
||||
await sp.session_get(uid, "session_variables", {}),
|
||||
)
|
||||
session_var = await sp.session_get(uid, "session_variables", {})
|
||||
|
||||
if key not in session_var:
|
||||
event.set_result(
|
||||
MessageEventResult().message("没有那个变量名。格式 /unset 变量名。"),
|
||||
MessageEventResult().message("没有那个变量名。格式 /unset 变量名。"),
|
||||
)
|
||||
else:
|
||||
del session_var[key]
|
||||
await sp.session_put(uid, "session_variables", session_var)
|
||||
event.set_result(
|
||||
MessageEventResult().message(f"会话 {uid} 变量 {key} 移除成功。"),
|
||||
MessageEventResult().message(f"会话 {uid} 变量 {key} 移除成功。"),
|
||||
)
|
||||
|
||||
@@ -18,19 +18,19 @@ class SIDCommand:
|
||||
umo_msg_type = event.session.message_type.value
|
||||
umo_session_id = event.session.session_id
|
||||
ret = (
|
||||
f"UMO: 「{sid}」 此值可用于设置白名单。\n"
|
||||
f"UID: 「{user_id}」 此值可用于设置管理员。\n"
|
||||
f"UMO: 「{sid}」 此值可用于设置白名单。\n"
|
||||
f"UID: 「{user_id}」 此值可用于设置管理员。\n"
|
||||
f"消息会话来源信息:\n"
|
||||
f" 机器人 ID: 「{umo_platform}」\n"
|
||||
f" 消息类型: 「{umo_msg_type}」\n"
|
||||
f" 会话 ID: 「{umo_session_id}」\n"
|
||||
f"消息来源可用于配置机器人的配置文件路由。"
|
||||
f" 机器人 ID: 「{umo_platform}」\n"
|
||||
f" 消息类型: 「{umo_msg_type}」\n"
|
||||
f" 会话 ID: 「{umo_session_id}」\n"
|
||||
f"消息来源可用于配置机器人的配置文件路由。"
|
||||
)
|
||||
|
||||
if (
|
||||
self.context.get_config()["platform_settings"]["unique_session"]
|
||||
and event.get_group_id()
|
||||
):
|
||||
ret += f"\n\n当前处于独立会话模式, 此群 ID: 「{event.get_group_id()}」, 也可将此 ID 加入白名单来放行整个群聊。"
|
||||
ret += f"\n\n当前处于独立会话模式, 此群 ID: 「{event.get_group_id()}」, 也可将此 ID 加入白名单来放行整个群聊。"
|
||||
|
||||
event.set_result(MessageEventResult().message(ret).use_t2i(False))
|
||||
|
||||
@@ -16,8 +16,8 @@ class T2ICommand:
|
||||
if config["t2i"]:
|
||||
config["t2i"] = False
|
||||
config.save_config()
|
||||
event.set_result(MessageEventResult().message("已关闭文本转图片模式。"))
|
||||
event.set_result(MessageEventResult().message("已关闭文本转图片模式。"))
|
||||
return
|
||||
config["t2i"] = True
|
||||
config.save_config()
|
||||
event.set_result(MessageEventResult().message("已开启文本转图片模式。"))
|
||||
event.set_result(MessageEventResult().message("已开启文本转图片模式。"))
|
||||
|
||||
@@ -12,7 +12,7 @@ class TTSCommand:
|
||||
self.context = context
|
||||
|
||||
async def tts(self, event: AstrMessageEvent) -> None:
|
||||
"""开关文本转语音(会话级别)"""
|
||||
"""开关文本转语音(会话级别)"""
|
||||
umo = event.unified_msg_origin
|
||||
ses_tts = await SessionServiceManager.is_tts_enabled_for_session(umo)
|
||||
cfg = self.context.get_config(umo=umo)
|
||||
@@ -27,10 +27,10 @@ class TTSCommand:
|
||||
if new_status and not tts_enable:
|
||||
event.set_result(
|
||||
MessageEventResult().message(
|
||||
f"{status_text}当前会话的文本转语音。但 TTS 功能在配置中未启用,请前往 WebUI 开启。",
|
||||
f"{status_text}当前会话的文本转语音。但 TTS 功能在配置中未启用,请前往 WebUI 开启。",
|
||||
),
|
||||
)
|
||||
else:
|
||||
event.set_result(
|
||||
MessageEventResult().message(f"{status_text}当前会话的文本转语音。"),
|
||||
MessageEventResult().message(f"{status_text}当前会话的文本转语音。"),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from astrbot.api import star
|
||||
from astrbot.api.event import AstrMessageEvent, filter
|
||||
from astrbot.core.star.filter.command import GreedyStr
|
||||
|
||||
from .commands import (
|
||||
AdminCommands,
|
||||
@@ -8,7 +7,7 @@ from .commands import (
|
||||
ConversationCommands,
|
||||
HelpCommand,
|
||||
LLMCommands,
|
||||
NameCommand,
|
||||
PersonaCommands,
|
||||
PluginCommands,
|
||||
ProviderCommands,
|
||||
SetUnsetCommands,
|
||||
@@ -27,13 +26,13 @@ class Main(star.Star):
|
||||
self.plugin_c = PluginCommands(self.context)
|
||||
self.admin_c = AdminCommands(self.context)
|
||||
self.conversation_c = ConversationCommands(self.context)
|
||||
self.name_c = NameCommand(self.context)
|
||||
self.provider_c = ProviderCommands(self.context)
|
||||
self.persona_c = PersonaCommands(self.context)
|
||||
self.alter_cmd_c = AlterCmdCommands(self.context)
|
||||
self.setunset_c = SetUnsetCommands(self.context)
|
||||
self.t2i_c = T2ICommand(self.context)
|
||||
self.tts_c = TTSCommand(self.context)
|
||||
self.sid_c = SIDCommand(self.context)
|
||||
self.alter_cmd_c = AlterCmdCommands(self.context)
|
||||
|
||||
@filter.command("help")
|
||||
async def help(self, event: AstrMessageEvent) -> None:
|
||||
@@ -52,7 +51,7 @@ class Main(star.Star):
|
||||
|
||||
@plugin.command("ls")
|
||||
async def plugin_ls(self, event: AstrMessageEvent) -> None:
|
||||
"""获取已经安装的插件列表。"""
|
||||
"""获取已经安装的插件列表。"""
|
||||
await self.plugin_c.plugin_ls(event)
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@@ -85,7 +84,7 @@ class Main(star.Star):
|
||||
|
||||
@filter.command("tts")
|
||||
async def tts(self, event: AstrMessageEvent) -> None:
|
||||
"""开关文本转语音(会话级别)"""
|
||||
"""开关文本转语音(会话级别)"""
|
||||
await self.tts_c.tts(event)
|
||||
|
||||
@filter.command("sid")
|
||||
@@ -93,41 +92,30 @@ class Main(star.Star):
|
||||
"""获取会话 ID 和 管理员 ID"""
|
||||
await self.sid_c.sid(event)
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@filter.command("name")
|
||||
async def name(self, event: AstrMessageEvent, alias: GreedyStr) -> None:
|
||||
"""Set display name for current UMO"""
|
||||
await self.name_c.name(event, alias)
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@filter.command("op")
|
||||
async def op(self, event: AstrMessageEvent, admin_id: str = "") -> None:
|
||||
"""授权管理员。op <admin_id>"""
|
||||
"""授权管理员。op <admin_id>"""
|
||||
await self.admin_c.op(event, admin_id)
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@filter.command("deop")
|
||||
async def deop(self, event: AstrMessageEvent, admin_id: str) -> None:
|
||||
"""取消授权管理员。deop <admin_id>"""
|
||||
"""取消授权管理员。deop <admin_id>"""
|
||||
await self.admin_c.deop(event, admin_id)
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@filter.command("wl")
|
||||
async def wl(self, event: AstrMessageEvent, sid: str = "") -> None:
|
||||
"""添加白名单。wl <sid>"""
|
||||
"""添加白名单。wl <sid>"""
|
||||
await self.admin_c.wl(event, sid)
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@filter.command("dwl")
|
||||
async def dwl(self, event: AstrMessageEvent, sid: str) -> None:
|
||||
"""删除白名单。dwl <sid>"""
|
||||
"""删除白名单。dwl <sid>"""
|
||||
await self.admin_c.dwl(event, sid)
|
||||
|
||||
@filter.command("stats")
|
||||
async def stats(self, message: AstrMessageEvent) -> None:
|
||||
"""Show token usage statistics for the current conversation"""
|
||||
await self.conversation_c.stats(message)
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@filter.command("provider")
|
||||
async def provider(
|
||||
@@ -144,11 +132,6 @@ class Main(star.Star):
|
||||
"""重置 LLM 会话"""
|
||||
await self.conversation_c.reset(message)
|
||||
|
||||
@filter.command("stop")
|
||||
async def stop(self, message: AstrMessageEvent) -> None:
|
||||
"""停止当前会话中正在运行的 Agent"""
|
||||
await self.conversation_c.stop(message)
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@filter.command("model")
|
||||
async def model_ls(
|
||||
@@ -174,6 +157,41 @@ class Main(star.Star):
|
||||
"""创建新对话"""
|
||||
await self.conversation_c.new_conv(message)
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@filter.command("groupnew")
|
||||
async def groupnew_conv(self, message: AstrMessageEvent, sid: str) -> None:
|
||||
"""创建新群聊对话"""
|
||||
await self.conversation_c.groupnew_conv(message, sid)
|
||||
|
||||
@filter.command("switch")
|
||||
async def switch_conv(
|
||||
self, message: AstrMessageEvent, index: int | None = None
|
||||
) -> None:
|
||||
"""通过 /ls 前面的序号切换对话"""
|
||||
await self.conversation_c.switch_conv(message, index)
|
||||
|
||||
@filter.command("rename")
|
||||
async def rename_conv(self, message: AstrMessageEvent, new_name: str) -> None:
|
||||
"""重命名对话"""
|
||||
await self.conversation_c.rename_conv(message, new_name)
|
||||
|
||||
@filter.command("del")
|
||||
async def del_conv(self, message: AstrMessageEvent) -> None:
|
||||
"""删除当前对话"""
|
||||
await self.conversation_c.del_conv(message)
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@filter.command("key")
|
||||
async def key(self, message: AstrMessageEvent, index: int | None = None) -> None:
|
||||
"""查看或者切换 Key"""
|
||||
await self.provider_c.key(message, index)
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@filter.command("persona")
|
||||
async def persona(self, message: AstrMessageEvent) -> None:
|
||||
"""查看或者切换 Persona"""
|
||||
await self.persona_c.persona(message)
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@filter.command("dashboard_update")
|
||||
async def update_dashboard(self, event: AstrMessageEvent) -> None:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: builtin_commands
|
||||
desc: AstrBot's internal plugin, providing all built-in commands such as /reset.
|
||||
desc: AstrBot 自带指令,提供常用的对话管理、工具使用、插件管理等功能。
|
||||
author: Soulter
|
||||
version: 0.0.1
|
||||
@@ -72,9 +72,9 @@ class Main(Star):
|
||||
# 使用 LLM 生成回复
|
||||
yield event.request_llm(
|
||||
prompt=(
|
||||
"注意,你正在社交媒体上中与用户进行聊天,用户只是通过@来唤醒你,但并未在这条消息中输入内容,他可能会在接下来一条发送他想发送的内容。"
|
||||
"你友好地询问用户想要聊些什么或者需要什么帮助,回复要符合人设,不要太过机械化。"
|
||||
"请注意,你仅需要输出要回复用户的内容,不要输出其他任何东西"
|
||||
"注意,你正在社交媒体上中与用户进行聊天,用户只是通过@来唤醒你,但并未在这条消息中输入内容,他可能会在接下来一条发送他想发送的内容。"
|
||||
"你友好地询问用户想要聊些什么或者需要什么帮助,回复要符合人设,不要太过机械化。"
|
||||
"请注意,你仅需要输出要回复用户的内容,不要输出其他任何东西"
|
||||
),
|
||||
session_id=curr_cid,
|
||||
contexts=[],
|
||||
@@ -83,16 +83,14 @@ class Main(Star):
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"LLM response failed: {e!s}")
|
||||
# LLM 回复失败,使用原始预设回复
|
||||
yield event.plain_result("想要问什么呢?😄")
|
||||
# LLM 回复失败,使用原始预设回复
|
||||
yield event.plain_result("想要问什么呢?😄")
|
||||
|
||||
@session_waiter(60)
|
||||
async def empty_mention_waiter(
|
||||
controller: SessionController,
|
||||
event: AstrMessageEvent,
|
||||
) -> None:
|
||||
if not event.message_str or not event.message_str.strip():
|
||||
return
|
||||
event.message_obj.message.insert(
|
||||
0,
|
||||
Comp.At(qq=event.get_self_id(), name=event.get_self_id()),
|
||||
@@ -108,7 +106,7 @@ class Main(Star):
|
||||
except TimeoutError as _:
|
||||
pass
|
||||
except Exception as e:
|
||||
yield event.plain_result("发生错误,请联系管理员: " + str(e))
|
||||
yield event.plain_result("发生错误,请联系管理员: " + str(e))
|
||||
finally:
|
||||
event.stop_event()
|
||||
except Exception as e:
|
||||
|
||||
5
astrbot/builtin_stars/session_controller/metadata.yaml
Normal file
5
astrbot/builtin_stars/session_controller/metadata.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
name: session_controller
|
||||
desc: 为插件支持会话控制
|
||||
author: Cvandia & Soulter
|
||||
version: v1.0.1
|
||||
repo: https://astrbot.app
|
||||
@@ -1,9 +1,8 @@
|
||||
import random
|
||||
import urllib.parse
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from aiohttp import ClientSession, ClientTimeout
|
||||
from aiohttp import ClientSession
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
||||
HEADERS = {
|
||||
@@ -43,7 +42,7 @@ class SearchEngine:
|
||||
"""搜索引擎爬虫基类"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.TIMEOUT = ClientTimeout(total=10)
|
||||
self.TIMEOUT = 10
|
||||
self.page = 1
|
||||
self.headers = HEADERS
|
||||
|
||||
@@ -82,7 +81,7 @@ class SearchEngine:
|
||||
return ret
|
||||
|
||||
def tidy_text(self, text: str) -> str:
|
||||
"""清理文本,去除空格、换行符等"""
|
||||
"""清理文本,去除空格、换行符等"""
|
||||
return text.strip().replace("\n", " ").replace("\r", " ").replace(" ", " ")
|
||||
|
||||
def _get_url(self, tag: Tag) -> str:
|
||||
@@ -96,12 +95,6 @@ class SearchEngine:
|
||||
soup = BeautifulSoup(resp, "html.parser")
|
||||
links = soup.select(self._set_selector("links"))
|
||||
results = []
|
||||
try:
|
||||
text_selector = self._set_selector("text")
|
||||
except (KeyError, NotImplementedError):
|
||||
# Keep backward compatibility with engines that only expose
|
||||
# title/url/link selectors and do not provide snippets.
|
||||
text_selector = ""
|
||||
for link in links:
|
||||
# Safely get the title text (select_one may return None)
|
||||
title_elem = link.select_one(self._set_selector("title"))
|
||||
@@ -111,36 +104,9 @@ class SearchEngine:
|
||||
|
||||
url_tag = link.select_one(self._set_selector("url"))
|
||||
snippet = ""
|
||||
if text_selector:
|
||||
text_elem = link.select_one(text_selector)
|
||||
if text_elem is not None:
|
||||
snippet = self.tidy_text(text_elem.get_text())
|
||||
if title and url_tag:
|
||||
url = self._get_url(url_tag)
|
||||
if not url:
|
||||
continue
|
||||
if url.startswith("//"):
|
||||
url = f"https:{url}"
|
||||
results.append(SearchResult(title=title, url=url, snippet=snippet))
|
||||
return results[:num_results] if len(results) > num_results else results
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
async def _search_with_result_filter(
|
||||
self,
|
||||
query: str,
|
||||
num_results: int,
|
||||
predicate: Callable[[SearchResult], bool],
|
||||
) -> list[SearchResult]:
|
||||
if num_results <= 0:
|
||||
return []
|
||||
|
||||
rough_results = await SearchEngine.search(self, query, max(num_results * 2, 10))
|
||||
final_results: list[SearchResult] = []
|
||||
for result in rough_results:
|
||||
if not predicate(result):
|
||||
continue
|
||||
final_results.append(result)
|
||||
if len(final_results) >= num_results:
|
||||
break
|
||||
return final_results
|
||||
|
||||
@@ -2,12 +2,9 @@ from . import USER_AGENT_BING, SearchEngine
|
||||
|
||||
|
||||
class Bing(SearchEngine):
|
||||
NAME = "bing"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
# Prefer international Bing first, keep cn endpoint as compatibility fallback.
|
||||
self.base_urls = ["https://www.bing.com", "https://cn.bing.com"]
|
||||
self.base_urls = ["https://cn.bing.com", "https://www.bing.com"]
|
||||
self.headers.update({"User-Agent": USER_AGENT_BING})
|
||||
|
||||
def _set_selector(self, selector: str):
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
from urllib.parse import unquote, urlencode, urlparse
|
||||
|
||||
from bs4 import Tag
|
||||
|
||||
from . import SearchEngine, SearchResult
|
||||
|
||||
|
||||
class Comet(SearchEngine):
|
||||
"""Best-effort search via public Perplexity/Comet page.
|
||||
|
||||
Note:
|
||||
- This endpoint is often protected by anti-bot challenges.
|
||||
- We intentionally treat failures as non-fatal and rely on fallback engines.
|
||||
|
||||
"""
|
||||
|
||||
NAME = "comet"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.base_url = "https://www.perplexity.ai"
|
||||
|
||||
def _set_selector(self, selector: str):
|
||||
selectors = {
|
||||
"url": "a[href^='http'], a[href^='//']",
|
||||
"title": "main h1, main h2, main h3, h3, h2",
|
||||
"text": "main article, main div[role='article'], main section, main p, p",
|
||||
"links": "main article, main div[role='article'], main li, main div.result, article, div[role='article'], li, div.result",
|
||||
"next": "",
|
||||
}
|
||||
return selectors[selector]
|
||||
|
||||
async def _get_next_page(self, query: str) -> str:
|
||||
url = f"{self.base_url}/search?{urlencode({'q': unquote(query)})}"
|
||||
return await self._get_html(url, None)
|
||||
|
||||
def _get_url(self, tag: Tag) -> str:
|
||||
href = str(tag.get("href") or "")
|
||||
if href.startswith("//"):
|
||||
return f"https:{href}"
|
||||
return href
|
||||
|
||||
@staticmethod
|
||||
def _is_valid_result_url(url: str) -> bool:
|
||||
lowered = (url or "").strip().lower()
|
||||
if not lowered:
|
||||
return False
|
||||
if lowered.startswith(("#", "javascript:", "mailto:")):
|
||||
return False
|
||||
if not lowered.startswith(("http://", "https://")):
|
||||
return False
|
||||
netloc = urlparse(lowered).netloc
|
||||
if not netloc:
|
||||
return False
|
||||
if netloc.endswith("perplexity.ai"):
|
||||
return False
|
||||
return True
|
||||
|
||||
async def search(self, query: str, num_results: int) -> list[SearchResult]:
|
||||
return await self._search_with_result_filter(
|
||||
query=query,
|
||||
num_results=num_results,
|
||||
predicate=lambda result: self._is_valid_result_url(result.url),
|
||||
)
|
||||
@@ -1,43 +0,0 @@
|
||||
import urllib.parse
|
||||
|
||||
from bs4 import Tag
|
||||
|
||||
from . import SearchEngine, SearchResult
|
||||
|
||||
|
||||
class DuckDuckGo(SearchEngine):
|
||||
NAME = "duckduckgo"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.base_url = "https://html.duckduckgo.com/html"
|
||||
|
||||
def _set_selector(self, selector: str):
|
||||
selectors = {
|
||||
"url": "a.result__a, h2 a",
|
||||
"title": "a.result__a, h2",
|
||||
"text": "a.result__snippet, div.result__snippet",
|
||||
"links": "div.result, div.web-result",
|
||||
"next": "a.result--more__btn",
|
||||
}
|
||||
return selectors[selector]
|
||||
|
||||
async def _get_next_page(self, query: str) -> str:
|
||||
params = {"q": urllib.parse.unquote(query), "kl": "us-en"}
|
||||
url = f"{self.base_url}/?{urllib.parse.urlencode(params)}"
|
||||
return await self._get_html(url, None)
|
||||
|
||||
def _get_url(self, tag: Tag) -> str:
|
||||
href = str(tag.get("href") or "")
|
||||
if "duckduckgo.com/l/?" in href:
|
||||
parsed = urllib.parse.urlparse(href)
|
||||
target = urllib.parse.parse_qs(parsed.query).get("uddg", [""])[0]
|
||||
return urllib.parse.unquote(target)
|
||||
return href
|
||||
|
||||
async def search(self, query: str, num_results: int) -> list[SearchResult]:
|
||||
return await self._search_with_result_filter(
|
||||
query=query,
|
||||
num_results=num_results,
|
||||
predicate=lambda result: result.url.startswith("http"),
|
||||
)
|
||||
@@ -1,51 +0,0 @@
|
||||
import urllib.parse
|
||||
|
||||
from bs4 import Tag
|
||||
|
||||
from . import SearchEngine, SearchResult
|
||||
|
||||
|
||||
class Google(SearchEngine):
|
||||
NAME = "google"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.base_url = "https://www.google.com"
|
||||
|
||||
def _set_selector(self, selector: str):
|
||||
selectors = {
|
||||
"url": "a[href]",
|
||||
"title": "h3",
|
||||
"text": "div.VwiC3b, span.aCOpRe",
|
||||
"links": "div#search div.g, div#search div.MjjYud",
|
||||
"next": "a#pnnext",
|
||||
}
|
||||
return selectors[selector]
|
||||
|
||||
async def _get_next_page(self, query: str) -> str:
|
||||
params = {
|
||||
"q": urllib.parse.unquote(query),
|
||||
"hl": "en",
|
||||
"gl": "us",
|
||||
"pws": "0",
|
||||
"num": "10",
|
||||
}
|
||||
url = f"{self.base_url}/search?{urllib.parse.urlencode(params)}"
|
||||
return await self._get_html(url, None)
|
||||
|
||||
def _get_url(self, tag: Tag) -> str:
|
||||
href = str(tag.get("href") or "")
|
||||
if href.startswith("/url?"):
|
||||
parsed = urllib.parse.urlparse(href)
|
||||
q = urllib.parse.parse_qs(parsed.query).get("q", [""])[0]
|
||||
return urllib.parse.unquote(q)
|
||||
return href
|
||||
|
||||
async def search(self, query: str, num_results: int) -> list[SearchResult]:
|
||||
return await self._search_with_result_filter(
|
||||
query=query,
|
||||
num_results=num_results,
|
||||
predicate=lambda result: (
|
||||
result.url.startswith("http") and "google.com/search?" not in result.url
|
||||
),
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
import random
|
||||
import re
|
||||
from typing import cast
|
||||
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
||||
@@ -7,8 +8,6 @@ from . import USER_AGENTS, SearchEngine, SearchResult
|
||||
|
||||
|
||||
class Sogo(SearchEngine):
|
||||
NAME = "sogo"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.base_url = "https://www.sogou.com"
|
||||
@@ -29,7 +28,7 @@ class Sogo(SearchEngine):
|
||||
return await self._get_html(url, None)
|
||||
|
||||
def _get_url(self, tag: Tag) -> str:
|
||||
return str(tag.get("href") or "")
|
||||
return cast(str, tag.get("href"))
|
||||
|
||||
async def search(self, query: str, num_results: int) -> list[SearchResult]:
|
||||
results = await super().search(query, num_results)
|
||||
@@ -47,7 +46,7 @@ class Sogo(SearchEngine):
|
||||
script_text = (
|
||||
script.string if script.string is not None else script.get_text()
|
||||
)
|
||||
match = re.search('window.location.replace\\("(.+?)"\\)', script_text)
|
||||
match = re.search(r'window.location.replace\("(.+?)"\)', script_text)
|
||||
if match:
|
||||
url = match.group(1)
|
||||
return url
|
||||
|
||||
@@ -2,34 +2,23 @@ import asyncio
|
||||
import json
|
||||
import random
|
||||
import uuid
|
||||
from typing import ClassVar
|
||||
|
||||
import aiohttp
|
||||
from bs4 import BeautifulSoup
|
||||
from readability import Document
|
||||
|
||||
from astrbot.api import AstrBotConfig, llm_tool, logger, sp, star
|
||||
from astrbot.api.event import AstrMessageEvent, filter
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult, filter
|
||||
from astrbot.api.provider import ProviderRequest
|
||||
from astrbot.core.provider.func_tool_manager import FunctionToolManager
|
||||
|
||||
from .engines import HEADERS, USER_AGENTS, SearchResult
|
||||
from .engines.bing import Bing
|
||||
from .engines.comet import Comet
|
||||
from .engines.duckduckgo import DuckDuckGo
|
||||
from .engines.google import Google
|
||||
from .engines.sogo import Sogo
|
||||
from .provider_routing import (
|
||||
DEFAULT_WEB_SEARCH_PROVIDER,
|
||||
build_default_engine_order,
|
||||
normalize_websearch_provider,
|
||||
normalize_websearch_provider_for_tools,
|
||||
validate_default_engine_registry,
|
||||
)
|
||||
|
||||
|
||||
class Main(star.Star):
|
||||
TOOLS: ClassVar[list[str]] = [
|
||||
TOOLS = [
|
||||
"web_search",
|
||||
"fetch_url",
|
||||
"web_search_tavily",
|
||||
@@ -45,14 +34,14 @@ class Main(star.Star):
|
||||
self.bocha_key_index = 0
|
||||
self.bocha_key_lock = asyncio.Lock()
|
||||
|
||||
# 将 str 类型的 key 迁移至 list[str],并保存
|
||||
# 将 str 类型的 key 迁移至 list[str],并保存
|
||||
cfg = self.context.get_config()
|
||||
provider_settings = cfg.get("provider_settings")
|
||||
if provider_settings:
|
||||
tavily_key = provider_settings.get("websearch_tavily_key")
|
||||
if isinstance(tavily_key, str):
|
||||
logger.info(
|
||||
"检测到旧版 websearch_tavily_key (字符串格式),自动迁移为列表格式并保存。",
|
||||
"检测到旧版 websearch_tavily_key (字符串格式),自动迁移为列表格式并保存。",
|
||||
)
|
||||
if tavily_key:
|
||||
provider_settings["websearch_tavily_key"] = [tavily_key]
|
||||
@@ -68,26 +57,12 @@ class Main(star.Star):
|
||||
provider_settings["websearch_bocha_key"] = []
|
||||
cfg.save_config()
|
||||
|
||||
self.google_search = Google()
|
||||
self.bing_search = Bing()
|
||||
self.ddg_search = DuckDuckGo()
|
||||
self.comet_search = Comet()
|
||||
self.sogo_search = Sogo()
|
||||
self.default_search_engines = {
|
||||
engine.NAME: engine
|
||||
for engine in (
|
||||
self.google_search,
|
||||
self.bing_search,
|
||||
self.ddg_search,
|
||||
self.comet_search,
|
||||
self.sogo_search,
|
||||
)
|
||||
}
|
||||
validate_default_engine_registry(self.default_search_engines)
|
||||
self.baidu_initialized = False
|
||||
|
||||
async def _tidy_text(self, text: str) -> str:
|
||||
"""清理文本,去除空格、换行符等"""
|
||||
"""清理文本,去除空格、换行符等"""
|
||||
return text.strip().replace("\n", " ").replace("\r", " ").replace(" ", " ")
|
||||
|
||||
async def _get_from_url(self, url: str) -> str:
|
||||
@@ -95,7 +70,7 @@ class Main(star.Star):
|
||||
header = HEADERS
|
||||
header.update({"User-Agent": random.choice(USER_AGENTS)})
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.get(url, headers=header) as response:
|
||||
async with session.get(url, headers=header, timeout=6) as response:
|
||||
html = await response.text(encoding="utf-8")
|
||||
doc = Document(html)
|
||||
ret = doc.summary(html_partial=True)
|
||||
@@ -130,35 +105,29 @@ class Main(star.Star):
|
||||
self,
|
||||
query,
|
||||
num_results: int = 5,
|
||||
preferred_provider: str = DEFAULT_WEB_SEARCH_PROVIDER,
|
||||
) -> list[SearchResult]:
|
||||
for engine_name in build_default_engine_order(preferred_provider):
|
||||
engine = self.default_search_engines.get(engine_name)
|
||||
if not engine:
|
||||
continue
|
||||
results = []
|
||||
try:
|
||||
results = await self.bing_search.search(query, num_results)
|
||||
except Exception as e:
|
||||
logger.error(f"bing search error: {e}, try the next one...")
|
||||
if len(results) == 0:
|
||||
logger.debug("search bing failed")
|
||||
try:
|
||||
results = await engine.search(query, num_results)
|
||||
results = await self.sogo_search.search(query, num_results)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"{engine_name} search error: {e}, try the next one...",
|
||||
)
|
||||
continue
|
||||
logger.error(f"sogo search error: {e}")
|
||||
if len(results) == 0:
|
||||
logger.debug("search sogo failed")
|
||||
return []
|
||||
|
||||
if results:
|
||||
logger.info(
|
||||
f"web_searcher - provider `{engine_name}` success: {len(results)} results",
|
||||
)
|
||||
return results
|
||||
|
||||
logger.debug(f"search {engine_name} returned no results")
|
||||
|
||||
return []
|
||||
return results
|
||||
|
||||
async def _get_tavily_key(self, cfg: AstrBotConfig) -> str:
|
||||
"""并发安全的从列表中获取并轮换Tavily API密钥。"""
|
||||
"""并发安全的从列表中获取并轮换Tavily API密钥。"""
|
||||
tavily_keys = cfg.get("provider_settings", {}).get("websearch_tavily_key", [])
|
||||
if not tavily_keys:
|
||||
raise ValueError("错误:Tavily API密钥未在AstrBot中配置。")
|
||||
raise ValueError("错误:Tavily API密钥未在AstrBot中配置。")
|
||||
|
||||
async with self.tavily_key_lock:
|
||||
key = tavily_keys[self.tavily_key_index]
|
||||
@@ -182,6 +151,7 @@ class Main(star.Star):
|
||||
url,
|
||||
json=payload,
|
||||
headers=header,
|
||||
timeout=6,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
reason = await response.text()
|
||||
@@ -213,6 +183,7 @@ class Main(star.Star):
|
||||
url,
|
||||
json=payload,
|
||||
headers=header,
|
||||
timeout=6,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
reason = await response.text()
|
||||
@@ -227,6 +198,15 @@ 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,
|
||||
@@ -234,27 +214,18 @@ class Main(star.Star):
|
||||
query: str,
|
||||
max_results: int = 5,
|
||||
) -> str:
|
||||
"""搜索网络以回答用户的问题。当用户需要搜索网络以获取即时性的信息时调用此工具。
|
||||
"""搜索网络以回答用户的问题。当用户需要搜索网络以获取即时性的信息时调用此工具。
|
||||
|
||||
Args:
|
||||
query(string): 和用户的问题最相关的搜索关键词,用于在 Google 上搜索。
|
||||
max_results(number): 返回的最大搜索结果数量,默认为 5。
|
||||
query(string): 和用户的问题最相关的搜索关键词,用于在 Google 上搜索。
|
||||
max_results(number): 返回的最大搜索结果数量,默认为 5。
|
||||
|
||||
"""
|
||||
logger.info(f"web_searcher - search_from_search_engine: {query}")
|
||||
cfg = self.context.get_config(umo=event.unified_msg_origin)
|
||||
websearch_link = cfg["provider_settings"].get("web_search_link", False)
|
||||
preferred_provider = normalize_websearch_provider(
|
||||
cfg.get("provider_settings", {}).get(
|
||||
"websearch_provider",
|
||||
DEFAULT_WEB_SEARCH_PROVIDER,
|
||||
),
|
||||
)
|
||||
results = await self._web_search_default(
|
||||
query,
|
||||
max_results,
|
||||
preferred_provider=preferred_provider,
|
||||
)
|
||||
|
||||
results = await self._web_search_default(query, max_results)
|
||||
if not results:
|
||||
return "Error: web searcher does not return any results."
|
||||
|
||||
@@ -271,7 +242,7 @@ class Main(star.Star):
|
||||
ret += processed_result
|
||||
|
||||
if websearch_link:
|
||||
ret += "\n\n针对问题,请根据上面的结果分点总结,并且在结尾处附上对应内容的参考链接(如有)。"
|
||||
ret += "\n\n针对问题,请根据上面的结果分点总结,并且在结尾处附上对应内容的参考链接(如有)。"
|
||||
|
||||
return ret
|
||||
|
||||
@@ -294,7 +265,7 @@ class Main(star.Star):
|
||||
"transport": "sse",
|
||||
"url": f"http://appbuilder.baidu.com/v2/ai_search/mcp/sse?api_key={key}",
|
||||
"headers": {},
|
||||
"timeout": 600,
|
||||
"timeout": 30,
|
||||
},
|
||||
)
|
||||
self.baidu_initialized = True
|
||||
@@ -379,7 +350,7 @@ class Main(star.Star):
|
||||
"snippet": f"{result.snippet}",
|
||||
# TODO: do not need ref for non-webchat platform adapter
|
||||
"index": index,
|
||||
},
|
||||
}
|
||||
)
|
||||
if result.favicon:
|
||||
sp.temporary_cache["_ws_favicon"][result.url] = result.favicon
|
||||
@@ -424,10 +395,10 @@ class Main(star.Star):
|
||||
return ret
|
||||
|
||||
async def _get_bocha_key(self, cfg: AstrBotConfig) -> str:
|
||||
"""并发安全的从列表中获取并轮换BoCha API密钥。"""
|
||||
"""并发安全的从列表中获取并轮换BoCha API密钥。"""
|
||||
bocha_keys = cfg.get("provider_settings", {}).get("websearch_bocha_key", [])
|
||||
if not bocha_keys:
|
||||
raise ValueError("错误:BoCha API密钥未在AstrBot中配置。")
|
||||
raise ValueError("错误:BoCha API密钥未在AstrBot中配置。")
|
||||
|
||||
async with self.bocha_key_lock:
|
||||
key = bocha_keys[self.bocha_key_index]
|
||||
@@ -481,7 +452,8 @@ class Main(star.Star):
|
||||
exclude: str = "",
|
||||
count: int = 10,
|
||||
) -> str:
|
||||
"""A web search tool based on Bocha Search API, used to retrieve web pages
|
||||
"""
|
||||
A web search tool based on Bocha Search API, used to retrieve web pages
|
||||
related to the user's query.
|
||||
|
||||
Args:
|
||||
@@ -510,16 +482,14 @@ class Main(star.Star):
|
||||
include (string): Optional. Specifies the domains to include in
|
||||
the search. Multiple domains can be separated by "|" or ",".
|
||||
A maximum of 100 domains is allowed.
|
||||
|
||||
Examples:
|
||||
Examples:
|
||||
- "qq.com"
|
||||
- "qq.com|m.163.com"
|
||||
|
||||
exclude (string): Optional. Specifies the domains to exclude from
|
||||
the search. Multiple domains can be separated by "|" or ",".
|
||||
A maximum of 100 domains is allowed.
|
||||
|
||||
Examples:
|
||||
Examples:
|
||||
- "qq.com"
|
||||
- "qq.com|m.163.com"
|
||||
|
||||
@@ -528,7 +498,6 @@ class Main(star.Star):
|
||||
- Default: 10
|
||||
The actual number of returned results may be less than the
|
||||
specified count.
|
||||
|
||||
"""
|
||||
logger.info(f"web_searcher - search_from_bocha: {query}")
|
||||
cfg = self.context.get_config(umo=event.unified_msg_origin)
|
||||
@@ -542,18 +511,18 @@ class Main(star.Star):
|
||||
"count": count,
|
||||
}
|
||||
|
||||
# freshness:时间范围
|
||||
# freshness:时间范围
|
||||
if freshness:
|
||||
payload["freshness"] = freshness
|
||||
|
||||
# 是否返回摘要
|
||||
payload["summary"] = summary
|
||||
|
||||
# include:限制搜索域
|
||||
# include:限制搜索域
|
||||
if include:
|
||||
payload["include"] = include
|
||||
|
||||
# exclude:排除搜索域
|
||||
# exclude:排除搜索域
|
||||
if exclude:
|
||||
payload["exclude"] = exclude
|
||||
|
||||
@@ -571,7 +540,7 @@ class Main(star.Star):
|
||||
"url": f"{result.url}",
|
||||
"snippet": f"{result.snippet}",
|
||||
"index": index,
|
||||
},
|
||||
}
|
||||
)
|
||||
if result.favicon:
|
||||
sp.temporary_cache["_ws_favicon"][result.url] = result.favicon
|
||||
@@ -589,13 +558,7 @@ class Main(star.Star):
|
||||
cfg = self.context.get_config(umo=event.unified_msg_origin)
|
||||
prov_settings = cfg.get("provider_settings", {})
|
||||
websearch_enable = prov_settings.get("web_search", False)
|
||||
raw_provider = prov_settings.get(
|
||||
"websearch_provider",
|
||||
DEFAULT_WEB_SEARCH_PROVIDER,
|
||||
)
|
||||
branch_provider, is_known_provider = normalize_websearch_provider_for_tools(
|
||||
raw_provider,
|
||||
)
|
||||
provider = prov_settings.get("websearch_provider", "default")
|
||||
|
||||
tool_set = req.func_tool
|
||||
if isinstance(tool_set, FunctionToolManager):
|
||||
@@ -612,39 +575,35 @@ class Main(star.Star):
|
||||
return
|
||||
|
||||
func_tool_mgr = self.context.get_llm_tool_manager()
|
||||
if branch_provider == "default":
|
||||
if not is_known_provider:
|
||||
logger.warning(
|
||||
"Unsupported websearch_provider `%s`, fallback to default search tool branch.",
|
||||
raw_provider,
|
||||
)
|
||||
if provider == "default":
|
||||
web_search_t = func_tool_mgr.get_func("web_search")
|
||||
fetch_url_t = func_tool_mgr.get_func("fetch_url")
|
||||
if web_search_t and web_search_t.active:
|
||||
if web_search_t:
|
||||
tool_set.add_tool(web_search_t)
|
||||
if fetch_url_t and fetch_url_t.active:
|
||||
if fetch_url_t:
|
||||
tool_set.add_tool(fetch_url_t)
|
||||
tool_set.remove_tool("web_search_tavily")
|
||||
tool_set.remove_tool("tavily_extract_web_page")
|
||||
tool_set.remove_tool("AIsearch")
|
||||
tool_set.remove_tool("web_search_bocha")
|
||||
elif branch_provider == "tavily":
|
||||
elif provider == "tavily":
|
||||
web_search_tavily = func_tool_mgr.get_func("web_search_tavily")
|
||||
tavily_extract_web_page = func_tool_mgr.get_func("tavily_extract_web_page")
|
||||
if web_search_tavily and web_search_tavily.active:
|
||||
if web_search_tavily:
|
||||
tool_set.add_tool(web_search_tavily)
|
||||
if tavily_extract_web_page and tavily_extract_web_page.active:
|
||||
if tavily_extract_web_page:
|
||||
tool_set.add_tool(tavily_extract_web_page)
|
||||
tool_set.remove_tool("web_search")
|
||||
tool_set.remove_tool("fetch_url")
|
||||
tool_set.remove_tool("AIsearch")
|
||||
tool_set.remove_tool("web_search_bocha")
|
||||
elif branch_provider == "baidu_ai_search":
|
||||
elif provider == "baidu_ai_search":
|
||||
try:
|
||||
await self.ensure_baidu_ai_search_mcp(event.unified_msg_origin)
|
||||
aisearch_tool = func_tool_mgr.get_func("AIsearch")
|
||||
if aisearch_tool and aisearch_tool.active:
|
||||
tool_set.add_tool(aisearch_tool)
|
||||
if not aisearch_tool:
|
||||
raise ValueError("Cannot get Baidu AI Search MCP tool.")
|
||||
tool_set.add_tool(aisearch_tool)
|
||||
tool_set.remove_tool("web_search")
|
||||
tool_set.remove_tool("fetch_url")
|
||||
tool_set.remove_tool("web_search_tavily")
|
||||
@@ -652,9 +611,9 @@ class Main(star.Star):
|
||||
tool_set.remove_tool("web_search_bocha")
|
||||
except Exception as e:
|
||||
logger.error(f"Cannot Initialize Baidu AI Search MCP Server: {e}")
|
||||
elif branch_provider == "bocha":
|
||||
elif provider == "bocha":
|
||||
web_search_bocha = func_tool_mgr.get_func("web_search_bocha")
|
||||
if web_search_bocha and web_search_bocha.active:
|
||||
if web_search_bocha:
|
||||
tool_set.add_tool(web_search_bocha)
|
||||
tool_set.remove_tool("web_search")
|
||||
tool_set.remove_tool("fetch_url")
|
||||
|
||||
4
astrbot/builtin_stars/web_searcher/metadata.yaml
Normal file
4
astrbot/builtin_stars/web_searcher/metadata.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
name: astrbot-web-searcher
|
||||
desc: 让 LLM 具有网页检索能力
|
||||
author: Soulter
|
||||
version: 1.14.514
|
||||
@@ -1,24 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
DEFAULT_WEB_SEARCH_PROVIDER = "default"
|
||||
|
||||
# Canonical provider ids shown in config UI options.
|
||||
WEB_SEARCH_PROVIDER_OPTIONS: tuple[str, ...] = (
|
||||
DEFAULT_WEB_SEARCH_PROVIDER,
|
||||
"duckduckgo",
|
||||
"google",
|
||||
"bing",
|
||||
"comet",
|
||||
"sogo",
|
||||
"tavily",
|
||||
"baidu_ai_search",
|
||||
"bocha",
|
||||
)
|
||||
|
||||
# Provider ids that select non-default tool branches directly.
|
||||
WEB_SEARCH_TOOL_BRANCH_PROVIDERS: tuple[str, ...] = (
|
||||
DEFAULT_WEB_SEARCH_PROVIDER,
|
||||
"tavily",
|
||||
"baidu_ai_search",
|
||||
"bocha",
|
||||
)
|
||||
@@ -1,132 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .engines.bing import Bing
|
||||
from .engines.comet import Comet
|
||||
from .engines.duckduckgo import DuckDuckGo
|
||||
from .engines.google import Google
|
||||
from .engines.sogo import Sogo
|
||||
from .provider_constants import (
|
||||
DEFAULT_WEB_SEARCH_PROVIDER,
|
||||
WEB_SEARCH_PROVIDER_OPTIONS,
|
||||
WEB_SEARCH_TOOL_BRANCH_PROVIDERS,
|
||||
)
|
||||
|
||||
ENGINE_REGISTRY: tuple[tuple[str, type[object], bool], ...] = (
|
||||
(Bing.NAME, Bing, True),
|
||||
(Sogo.NAME, Sogo, True),
|
||||
# Compatibility first: DDG should stay as fallback and cannot become primary.
|
||||
(DuckDuckGo.NAME, DuckDuckGo, False),
|
||||
(Google.NAME, Google, True),
|
||||
(Comet.NAME, Comet, True),
|
||||
)
|
||||
|
||||
DEFAULT_ENGINE_ORDER: tuple[str, ...] = tuple(name for name, _, _ in ENGINE_REGISTRY)
|
||||
|
||||
_ENGINE_PROVIDER_SET = {name for name, _, _ in ENGINE_REGISTRY}
|
||||
_ENGINE_CAN_BE_PRIMARY = {
|
||||
name: can_be_primary for name, _, can_be_primary in ENGINE_REGISTRY
|
||||
}
|
||||
_TOOL_BRANCH_PROVIDER_SET = set(WEB_SEARCH_TOOL_BRANCH_PROVIDERS)
|
||||
_CANONICAL_PROVIDER_SET = _ENGINE_PROVIDER_SET | _TOOL_BRANCH_PROVIDER_SET
|
||||
|
||||
if not _CANONICAL_PROVIDER_SET.issubset(set(WEB_SEARCH_PROVIDER_OPTIONS)):
|
||||
raise RuntimeError(
|
||||
"web search provider options and routing providers are out of sync: "
|
||||
f"canonical={sorted(_CANONICAL_PROVIDER_SET)} options={list(WEB_SEARCH_PROVIDER_OPTIONS)}",
|
||||
)
|
||||
|
||||
_WEB_SEARCH_PROVIDER_ALIASES: dict[str, str] = {
|
||||
"": DEFAULT_WEB_SEARCH_PROVIDER,
|
||||
"default": DEFAULT_WEB_SEARCH_PROVIDER,
|
||||
"native": DEFAULT_WEB_SEARCH_PROVIDER,
|
||||
}
|
||||
_WEB_SEARCH_PROVIDER_ALIASES.update({name: name for name in _CANONICAL_PROVIDER_SET})
|
||||
_WEB_SEARCH_PROVIDER_ALIASES.update(
|
||||
{
|
||||
"duckduck_go": DuckDuckGo.NAME,
|
||||
"duckduck-go": DuckDuckGo.NAME,
|
||||
"ddg": DuckDuckGo.NAME,
|
||||
"baidu_ai": "baidu_ai_search",
|
||||
"baidu": "baidu_ai_search",
|
||||
"bochaai": "bocha",
|
||||
# ZeroClaw compatibility: AstrBot has no Brave provider yet, so downgrade to default.
|
||||
"brave": DEFAULT_WEB_SEARCH_PROVIDER,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NormalizedProvider:
|
||||
canonical: str
|
||||
tool_branch: str
|
||||
is_known: bool
|
||||
|
||||
|
||||
def _normalize_raw_provider(provider: object) -> str:
|
||||
return str(provider or "").strip().lower().replace(" ", "")
|
||||
|
||||
|
||||
def normalize_websearch(provider: object) -> NormalizedProvider:
|
||||
raw = _normalize_raw_provider(provider)
|
||||
alias = _WEB_SEARCH_PROVIDER_ALIASES.get(raw, raw)
|
||||
canonical = alias or DEFAULT_WEB_SEARCH_PROVIDER
|
||||
|
||||
is_engine = canonical in _ENGINE_PROVIDER_SET
|
||||
is_tool_branch = canonical in _TOOL_BRANCH_PROVIDER_SET
|
||||
is_known = is_engine or is_tool_branch
|
||||
tool_branch = canonical if is_tool_branch else DEFAULT_WEB_SEARCH_PROVIDER
|
||||
|
||||
return NormalizedProvider(
|
||||
canonical=canonical,
|
||||
tool_branch=tool_branch,
|
||||
is_known=is_known,
|
||||
)
|
||||
|
||||
|
||||
def normalize_websearch_provider(provider: object) -> str:
|
||||
return normalize_websearch(provider).canonical
|
||||
|
||||
|
||||
def normalize_websearch_provider_for_tools(provider: object) -> tuple[str, bool]:
|
||||
normalized = normalize_websearch(provider)
|
||||
return normalized.tool_branch, normalized.is_known
|
||||
|
||||
|
||||
def resolve_tool_branch_provider(provider: object) -> str:
|
||||
return normalize_websearch(provider).tool_branch
|
||||
|
||||
|
||||
def build_default_engine_order(provider: object) -> tuple[str, ...]:
|
||||
normalized = normalize_websearch(provider)
|
||||
engine_name = normalized.canonical
|
||||
|
||||
if engine_name not in _ENGINE_PROVIDER_SET:
|
||||
return DEFAULT_ENGINE_ORDER
|
||||
|
||||
if not _ENGINE_CAN_BE_PRIMARY.get(engine_name, False):
|
||||
return DEFAULT_ENGINE_ORDER
|
||||
|
||||
return (
|
||||
engine_name,
|
||||
*tuple(name for name in DEFAULT_ENGINE_ORDER if name != engine_name),
|
||||
)
|
||||
|
||||
|
||||
def is_known_websearch_provider(provider: object) -> bool:
|
||||
return normalize_websearch(provider).is_known
|
||||
|
||||
|
||||
def validate_default_engine_registry(engines_by_name: Mapping[str, object]) -> None:
|
||||
expected_names = {name for name, _, _ in ENGINE_REGISTRY}
|
||||
missing = [name for name in DEFAULT_ENGINE_ORDER if name not in engines_by_name]
|
||||
extra = [name for name in engines_by_name if name not in expected_names]
|
||||
if not missing and not extra:
|
||||
return
|
||||
|
||||
raise ValueError(
|
||||
"default search engine registry mismatch. "
|
||||
f"missing={missing}, extra={extra}, expected_order={list(DEFAULT_ENGINE_ORDER)}",
|
||||
)
|
||||
@@ -1,3 +1 @@
|
||||
from astrbot import __version__
|
||||
|
||||
__all__ = ["__version__"]
|
||||
__version__ = "4.17.2"
|
||||
|
||||
@@ -1,187 +1,59 @@
|
||||
"""AstrBot CLI entry point"""
|
||||
"""AstrBot CLI入口"""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from click.shell_completion import get_completion_class
|
||||
|
||||
from . import __version__
|
||||
from .commands import bk, config, init, password, plugin, run, service, uninstall
|
||||
from .i18n import t
|
||||
from .commands import conf, init, plug, run
|
||||
|
||||
logo_tmpl = r"""
|
||||
___ _______.___________..______ .______ ______ .___________.
|
||||
/ \ / | || _ \ | _ \ / __ \ | |
|
||||
/ ^ \ | (----`---| |----`| |_) | | |_) | | | | | `---| |----`
|
||||
/ /_\ \ \ \ | | | / | _ < | | | | | |
|
||||
/ _____ \ .----) | | | | |\ \----.| |_) | | `--' | | |
|
||||
/__/ \__\ |_______/ |__| | _| `._____||______/ \______/ |__|
|
||||
"""
|
||||
|
||||
|
||||
def print_version_detail() -> None:
|
||||
"""Print detailed version info (same for --version and version command)"""
|
||||
from astrbot.core.utils.astrbot_path import astrbot_paths
|
||||
|
||||
click.echo(f"AstrBot: {__version__}")
|
||||
click.echo(f"Python: {sys.version.split()[0]}")
|
||||
click.echo(f"System: {platform.system()} {platform.release()}")
|
||||
click.echo(f"Machine: {platform.machine()}")
|
||||
|
||||
git_root = Path(astrbot_paths.root) / ".git"
|
||||
if git_root.exists():
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
git_hash = subprocess.check_output(
|
||||
["git", "rev-parse", "--short", "HEAD"],
|
||||
cwd=str(astrbot_paths.root),
|
||||
text=True,
|
||||
).strip()
|
||||
git_branch = subprocess.check_output(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=str(astrbot_paths.root),
|
||||
text=True,
|
||||
).strip()
|
||||
click.echo(f"Git Branch: {git_branch}")
|
||||
click.echo(f"Git Commit: {git_hash}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
click.echo(f"AstrBot Root: {astrbot_paths.root}")
|
||||
click.echo(f"Platform: {platform.platform()}")
|
||||
|
||||
|
||||
def version_callback(ctx: click.Context, param: click.Parameter, value: bool) -> bool:
|
||||
"""Callback for --version to show detailed version and exit."""
|
||||
if not value:
|
||||
return value
|
||||
print_version_detail()
|
||||
ctx.exit()
|
||||
return value
|
||||
|
||||
|
||||
class AstrBotCLIGroup(click.Group):
|
||||
COMMAND_ALIASES = {
|
||||
"conf": "config",
|
||||
"plug": "plugin",
|
||||
}
|
||||
|
||||
def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
|
||||
command = super().get_command(ctx, cmd_name)
|
||||
if command is not None:
|
||||
return command
|
||||
alias_target = self.COMMAND_ALIASES.get(cmd_name)
|
||||
if alias_target is None:
|
||||
return None
|
||||
return super().get_command(ctx, alias_target)
|
||||
|
||||
|
||||
@click.group(cls=AstrBotCLIGroup)
|
||||
@click.group()
|
||||
@click.version_option(__version__, prog_name="AstrBot")
|
||||
def cli() -> None:
|
||||
"""Astrbot
|
||||
Agentic IM Chatbot infrastructure that integrates lots of IM platforms, LLMs, plugins and AI feature, and can be your openclaw alternative. ✨
|
||||
"""
|
||||
"""The AstrBot CLI"""
|
||||
click.echo(logo_tmpl)
|
||||
click.echo("Welcome to AstrBot CLI!")
|
||||
click.echo(f"AstrBot CLI version: {__version__}")
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("command_name", required=False, type=str)
|
||||
@click.option(
|
||||
"--all",
|
||||
"-a",
|
||||
is_flag=True,
|
||||
help="Show help for all commands recursively.",
|
||||
)
|
||||
def help(command_name: str | None, all: bool) -> None:
|
||||
"""Display help information for commands
|
||||
def help(command_name: str | None) -> None:
|
||||
"""显示命令的帮助信息
|
||||
|
||||
If COMMAND_NAME is provided, display detailed help for that command.
|
||||
Otherwise, display general help information.
|
||||
如果提供了 COMMAND_NAME,则显示该命令的详细帮助信息。
|
||||
否则,显示通用帮助信息。
|
||||
"""
|
||||
ctx = click.get_current_context()
|
||||
|
||||
if all:
|
||||
|
||||
def print_recursive_help(command, parent_ctx):
|
||||
name = command.name
|
||||
if parent_ctx is None:
|
||||
name = "astrbot"
|
||||
|
||||
cmd_ctx = click.Context(command, info_name=name, parent=parent_ctx)
|
||||
click.echo(command.get_help(cmd_ctx))
|
||||
click.echo("\n" + "-" * 50 + "\n")
|
||||
|
||||
if isinstance(command, click.Group):
|
||||
for subcommand in command.commands.values():
|
||||
print_recursive_help(subcommand, cmd_ctx)
|
||||
|
||||
print_recursive_help(cli, None)
|
||||
return
|
||||
|
||||
if command_name:
|
||||
# Find the specified command
|
||||
# 查找指定命令
|
||||
command = cli.get_command(ctx, command_name)
|
||||
if command:
|
||||
# Display help for the specific command
|
||||
parent = ctx.parent or ctx
|
||||
cmd_ctx = click.Context(command, info_name=command.name, parent=parent)
|
||||
click.echo(command.get_help(cmd_ctx))
|
||||
# 显示特定命令的帮助信息
|
||||
click.echo(command.get_help(ctx))
|
||||
else:
|
||||
click.echo(t("cli_unknown_command", command=command_name))
|
||||
click.echo(f"Unknown command: {command_name}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
# Display general help information
|
||||
# 显示通用帮助信息
|
||||
click.echo(cli.get_help(ctx))
|
||||
|
||||
|
||||
cli.add_command(init)
|
||||
cli.add_command(run)
|
||||
cli.add_command(help)
|
||||
cli.add_command(plugin)
|
||||
cli.add_command(config)
|
||||
cli.add_command(uninstall)
|
||||
cli.add_command(bk)
|
||||
cli.add_command(password)
|
||||
cli.add_command(service)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("shell", required=False, type=click.Choice(["bash", "zsh", "fish"]))
|
||||
def completion(shell: str | None) -> None:
|
||||
"""Generate shell completion script"""
|
||||
if shell is None:
|
||||
shell_path = os.environ.get("SHELL", "")
|
||||
if "zsh" in shell_path:
|
||||
shell = "zsh"
|
||||
elif "bash" in shell_path:
|
||||
shell = "bash"
|
||||
elif "fish" in shell_path:
|
||||
shell = "fish"
|
||||
else:
|
||||
click.echo(
|
||||
"Could not detect shell. Please specify one of: bash, zsh, fish",
|
||||
err=True,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
comp_cls = get_completion_class(shell)
|
||||
if comp_cls is None:
|
||||
click.echo(f"No completion support for shell: {shell}", err=True)
|
||||
sys.exit(1)
|
||||
comp = comp_cls(
|
||||
cli,
|
||||
ctx_args={},
|
||||
prog_name="astrbot",
|
||||
complete_var="_ASTRBOT_COMPLETE",
|
||||
)
|
||||
click.echo(comp.source())
|
||||
|
||||
|
||||
cli.add_command(completion)
|
||||
|
||||
|
||||
@click.command(name="version")
|
||||
def version_cmd() -> None:
|
||||
"""Display detailed version information"""
|
||||
print_version_detail()
|
||||
|
||||
|
||||
cli.add_command(version_cmd)
|
||||
cli.add_command(plug)
|
||||
cli.add_command(conf)
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
"""ASCII logo and interactive mode utilities for CLI"""
|
||||
|
||||
import sys
|
||||
|
||||
logo_tmpl = r"""
|
||||
___ _______.___________..______ .______ ______ .___________.
|
||||
/ \ / | || _ \ | _ \ / __ \ | |
|
||||
/ ^ \ | (----`---| |----`| |_) | | |_) | | | | | `---| |----`
|
||||
/ /_\ \ \ \ | | | / | _ < | | | | | |
|
||||
/ _____ \ .----) | | | | |\ \----.| |_) | | `--' | | |
|
||||
/__/ \__\ |_______/ |__| | _| `._____||______/ \______/ |__|
|
||||
"""
|
||||
|
||||
|
||||
def is_interactive() -> bool:
|
||||
"""Check if stdout is connected to a TTY (interactive terminal)"""
|
||||
try:
|
||||
return sys.stdout.isatty()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def print_logo() -> None:
|
||||
"""Print ASCII logo if in interactive mode"""
|
||||
import click
|
||||
|
||||
if is_interactive():
|
||||
click.echo(logo_tmpl)
|
||||
@@ -1,24 +1,6 @@
|
||||
from .cmd_bk import bk
|
||||
from .cmd_conf import conf as config
|
||||
from .cmd_conf import conf
|
||||
from .cmd_init import init
|
||||
from .cmd_password import password
|
||||
from .cmd_plug import plug as plugin
|
||||
from .cmd_plug import plug
|
||||
from .cmd_run import run
|
||||
from .cmd_service import service
|
||||
from .cmd_uninstall import uninstall
|
||||
|
||||
conf = config
|
||||
plug = plugin
|
||||
|
||||
__all__ = [
|
||||
"bk",
|
||||
"conf",
|
||||
"config",
|
||||
"init",
|
||||
"password",
|
||||
"plug",
|
||||
"plugin",
|
||||
"run",
|
||||
"service",
|
||||
"uninstall",
|
||||
]
|
||||
__all__ = ["conf", "init", "plug", "run"]
|
||||
|
||||
@@ -1,392 +0,0 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import anyio
|
||||
import click
|
||||
|
||||
from astrbot.core import db_helper
|
||||
from astrbot.core.backup import AstrBotExporter, AstrBotImporter
|
||||
|
||||
|
||||
async def _get_kb_manager():
|
||||
"""Initialize and return a KnowledgeBaseManager with full dependency chain."""
|
||||
from astrbot.core import astrbot_config, sp
|
||||
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
|
||||
from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
|
||||
from astrbot.core.persona_mgr import PersonaManager
|
||||
from astrbot.core.provider.manager import ProviderManager
|
||||
from astrbot.core.umop_config_router import UmopConfigRouter
|
||||
|
||||
ucr = UmopConfigRouter(sp=sp)
|
||||
await ucr.initialize()
|
||||
|
||||
acm = AstrBotConfigManager(
|
||||
default_config=astrbot_config,
|
||||
ucr=ucr,
|
||||
sp=sp,
|
||||
)
|
||||
|
||||
persona_mgr = PersonaManager(db_helper, acm)
|
||||
await persona_mgr.initialize()
|
||||
|
||||
provider_manager = ProviderManager(
|
||||
acm,
|
||||
db_helper,
|
||||
persona_mgr,
|
||||
)
|
||||
|
||||
kb_manager = KnowledgeBaseManager(provider_manager)
|
||||
await kb_manager.initialize()
|
||||
|
||||
return kb_manager
|
||||
|
||||
|
||||
@click.group(name="bk")
|
||||
def bk():
|
||||
"""Backup management (Export/Import)"""
|
||||
|
||||
|
||||
@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.",
|
||||
)
|
||||
|
||||
exporter = AstrBotExporter(db_helper)
|
||||
|
||||
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
|
||||
await anyio.Path(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
|
||||
async with await anyio.open_file(final_path, "rb") as f:
|
||||
while chunk := await f.read(8192):
|
||||
hash_func.update(chunk)
|
||||
|
||||
digest_val = hash_func.hexdigest()
|
||||
digest_file = final_path.with_name(final_path.name + f".{digest}")
|
||||
await anyio.Path(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 await anyio.Path(zip_path).exists():
|
||||
await anyio.Path(zip_path).unlink()
|
||||
click.echo(f"Cleaned up temporary file: {zip_path}")
|
||||
|
||||
asyncio.run(_run())
|
||||
@@ -1,95 +1,66 @@
|
||||
"""Configuration CLI for AstrBot.
|
||||
|
||||
This module provides:
|
||||
- secure hashing utilities for the dashboard password (argon2)
|
||||
- validators for commonly configurable items
|
||||
- click CLI group with `set`, `get`, and `password` subcommands
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import zoneinfo
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
from filelock import FileLock, Timeout
|
||||
|
||||
from astrbot.cli.i18n import t
|
||||
from astrbot.core.config.default import DEFAULT_CONFIG
|
||||
from astrbot.core.utils.astrbot_path import astrbot_paths
|
||||
from astrbot.core.utils.auth_password import (
|
||||
_is_argon2_hash,
|
||||
_is_pbkdf2_hash,
|
||||
hash_dashboard_password,
|
||||
hash_legacy_dashboard_password,
|
||||
is_legacy_dashboard_password,
|
||||
validate_dashboard_password,
|
||||
)
|
||||
|
||||
# --- Password hashing & validation utilities ---
|
||||
|
||||
|
||||
def is_dashboard_password_hash(value: str) -> bool:
|
||||
"""Heuristic: return True if `value` looks like a supported dashboard password hash."""
|
||||
if not isinstance(value, str) or not value:
|
||||
return False
|
||||
return _is_argon2_hash(value) or _is_pbkdf2_hash(value)
|
||||
|
||||
|
||||
# --- Validators for CLI configuration items ---
|
||||
from ..utils import check_astrbot_root, get_astrbot_root
|
||||
|
||||
|
||||
def _validate_log_level(value: str) -> str:
|
||||
value_up = value.upper()
|
||||
allowed = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
|
||||
if value_up not in allowed:
|
||||
raise click.ClickException(t("config_log_level_invalid"))
|
||||
return value_up
|
||||
|
||||
|
||||
def _validate_dashboard_port(value: str) -> int:
|
||||
try:
|
||||
port = int(value)
|
||||
except ValueError:
|
||||
raise click.ClickException(t("config_port_must_be_number")) from None
|
||||
if port < 1 or port > 65535:
|
||||
raise click.ClickException(t("config_port_range_invalid"))
|
||||
return port
|
||||
|
||||
|
||||
def _validate_dashboard_username(value: str) -> str:
|
||||
if value is None or value.strip() == "":
|
||||
raise click.ClickException(t("config_username_empty"))
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _validate_dashboard_password(value: str) -> str:
|
||||
if value is None or value == "":
|
||||
raise click.ClickException(t("config_password_empty"))
|
||||
try:
|
||||
validate_dashboard_password(value)
|
||||
except ValueError as e:
|
||||
raise click.ClickException(str(e)) from e
|
||||
# Return the plaintext value; callers hash it before storage.
|
||||
"""验证日志级别"""
|
||||
value = value.upper()
|
||||
if value not in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
|
||||
raise click.ClickException(
|
||||
"日志级别必须是 DEBUG/INFO/WARNING/ERROR/CRITICAL 之一",
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _validate_dashboard_port(value: str) -> int:
|
||||
"""验证 Dashboard 端口"""
|
||||
try:
|
||||
port = int(value)
|
||||
if port < 1 or port > 65535:
|
||||
raise click.ClickException("端口必须在 1-65535 范围内")
|
||||
return port
|
||||
except ValueError:
|
||||
raise click.ClickException("端口必须是数字")
|
||||
|
||||
|
||||
def _validate_dashboard_username(value: str) -> str:
|
||||
"""验证 Dashboard 用户名"""
|
||||
if not value:
|
||||
raise click.ClickException("用户名不能为空")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_dashboard_password(value: str) -> str:
|
||||
"""验证 Dashboard 密码"""
|
||||
if not value:
|
||||
raise click.ClickException("密码不能为空")
|
||||
return hashlib.md5(value.encode()).hexdigest()
|
||||
|
||||
|
||||
def _validate_timezone(value: str) -> str:
|
||||
"""验证时区"""
|
||||
try:
|
||||
zoneinfo.ZoneInfo(value)
|
||||
except Exception as e:
|
||||
raise click.ClickException(t("config_timezone_invalid", value=value)) from e
|
||||
except Exception:
|
||||
raise click.ClickException(f"无效的时区: {value},请使用有效的IANA时区名称")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_callback_api_base(value: str) -> str:
|
||||
if not (value.startswith("http://") or value.startswith("https://")):
|
||||
raise click.ClickException(t("config_callback_invalid"))
|
||||
"""验证回调接口基址"""
|
||||
if not value.startswith("http://") and not value.startswith("https://"):
|
||||
raise click.ClickException("回调接口基址必须以 http:// 或 https:// 开头")
|
||||
return value
|
||||
|
||||
|
||||
# 可通过CLI设置的配置项,配置键到验证器函数的映射
|
||||
CONFIG_VALIDATORS: dict[str, Callable[[str], Any]] = {
|
||||
"timezone": _validate_timezone,
|
||||
"log_level": _validate_log_level,
|
||||
@@ -100,22 +71,18 @@ CONFIG_VALIDATORS: dict[str, Callable[[str], Any]] = {
|
||||
}
|
||||
|
||||
|
||||
# --- Config file helpers ---
|
||||
|
||||
|
||||
def _load_config() -> dict[str, Any]:
|
||||
"""Load or initialize the CLI config file (data/cmd_config.json).
|
||||
Ensures the astrbot root is valid before proceeding.
|
||||
"""
|
||||
root = astrbot_paths.root
|
||||
if not astrbot_paths.is_root:
|
||||
"""加载或初始化配置文件"""
|
||||
root = get_astrbot_root()
|
||||
if not check_astrbot_root(root):
|
||||
raise click.ClickException(
|
||||
f"{root} is not a valid AstrBot root directory. Use 'astrbot init' to initialize",
|
||||
f"{root}不是有效的 AstrBot 根目录,如需初始化请使用 astrbot init",
|
||||
)
|
||||
|
||||
config_path = astrbot_paths.data / "cmd_config.json"
|
||||
config_path = root / "data" / "cmd_config.json"
|
||||
if not config_path.exists():
|
||||
# Write DEFAULT_CONFIG to disk if file missing
|
||||
from astrbot.core.config.default import DEFAULT_CONFIG
|
||||
|
||||
config_path.write_text(
|
||||
json.dumps(DEFAULT_CONFIG, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8-sig",
|
||||
@@ -124,123 +91,58 @@ 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"Failed to parse config file: {e!s}") from e
|
||||
raise click.ClickException(f"配置文件解析失败: {e!s}")
|
||||
|
||||
|
||||
def _save_config(config: dict[str, Any]) -> None:
|
||||
config_path = astrbot_paths.data / "cmd_config.json"
|
||||
"""保存配置文件"""
|
||||
config_path = get_astrbot_root() / "data" / "cmd_config.json"
|
||||
|
||||
config_path.write_text(
|
||||
json.dumps(config, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8-sig",
|
||||
)
|
||||
|
||||
|
||||
def ensure_config_file() -> dict[str, Any]:
|
||||
return _load_config()
|
||||
|
||||
|
||||
def _set_nested_item(obj: dict[str, Any], path: str, value: Any) -> None:
|
||||
"""设置嵌套字典中的值"""
|
||||
parts = path.split(".")
|
||||
cur = obj
|
||||
for part in parts[:-1]:
|
||||
if part not in cur:
|
||||
cur[part] = {}
|
||||
elif not isinstance(cur[part], dict):
|
||||
if part not in obj:
|
||||
obj[part] = {}
|
||||
elif not isinstance(obj[part], dict):
|
||||
raise click.ClickException(
|
||||
f"Config path conflict: {'.'.join(parts[: parts.index(part) + 1])} is not a dict",
|
||||
f"配置路径冲突: {'.'.join(parts[: parts.index(part) + 1])} 不是字典",
|
||||
)
|
||||
cur = cur[part]
|
||||
cur[parts[-1]] = value
|
||||
obj = obj[part]
|
||||
obj[parts[-1]] = value
|
||||
|
||||
|
||||
def _get_nested_item(obj: dict[str, Any], path: str) -> Any:
|
||||
"""获取嵌套字典中的值"""
|
||||
parts = path.split(".")
|
||||
cur = obj
|
||||
for part in parts:
|
||||
cur = cur[part]
|
||||
return cur
|
||||
obj = obj[part]
|
||||
return obj
|
||||
|
||||
|
||||
# --- CLI commands ---
|
||||
|
||||
|
||||
def prompt_dashboard_password(prompt: str = "Dashboard password") -> str:
|
||||
# 显示密码规则提示
|
||||
click.echo()
|
||||
click.echo("密码规则:")
|
||||
click.echo(" - 至少 12 个字符")
|
||||
click.echo(" - 必须包含至少一个大写字母")
|
||||
click.echo(" - 必须包含至少一个小写字母")
|
||||
click.echo(" - 必须包含至少一个数字")
|
||||
click.echo()
|
||||
|
||||
password = click.prompt(prompt, hide_input=True, confirmation_prompt=True, type=str)
|
||||
click.echo(f"密码长度: {len(password)} 字符")
|
||||
return _validate_dashboard_password(password)
|
||||
|
||||
|
||||
def set_dashboard_credentials(
|
||||
config: dict[str, Any],
|
||||
*,
|
||||
username: str | None = None,
|
||||
password_hash: str | None = None,
|
||||
) -> None:
|
||||
if username is not None:
|
||||
_set_nested_item(
|
||||
config,
|
||||
"dashboard.username",
|
||||
_validate_dashboard_username(username),
|
||||
)
|
||||
if password_hash is not None:
|
||||
if isinstance(password_hash, str) and is_dashboard_password_hash(password_hash):
|
||||
_set_nested_item(config, "dashboard.password", password_hash)
|
||||
else:
|
||||
if is_legacy_dashboard_password(password_hash):
|
||||
raise click.ClickException(
|
||||
"Storing legacy dashboard password hashes is no longer supported. "
|
||||
"Please provide the plaintext password (it will be hashed securely), "
|
||||
"or provide an Argon2-encoded hash string.",
|
||||
)
|
||||
validated = _validate_dashboard_password(password_hash)
|
||||
_set_nested_item(
|
||||
config,
|
||||
"dashboard.pbkdf2_password",
|
||||
hash_dashboard_password(validated),
|
||||
)
|
||||
_set_nested_item(
|
||||
config,
|
||||
"dashboard.password",
|
||||
hash_legacy_dashboard_password(validated),
|
||||
)
|
||||
|
||||
|
||||
def _set_dashboard_password(config: dict[str, Any], raw_password: str) -> None:
|
||||
"""Set dashboard password hashes and clear password migration flags."""
|
||||
_set_nested_item(
|
||||
config,
|
||||
"dashboard.pbkdf2_password",
|
||||
hash_dashboard_password(raw_password),
|
||||
)
|
||||
_set_nested_item(
|
||||
config,
|
||||
"dashboard.password",
|
||||
hash_legacy_dashboard_password(raw_password),
|
||||
)
|
||||
_set_nested_item(config, "dashboard.password_storage_upgraded", True)
|
||||
_set_nested_item(config, "dashboard.password_change_required", False)
|
||||
|
||||
|
||||
@click.group(name="config")
|
||||
@click.group(name="conf")
|
||||
def conf() -> None:
|
||||
"""Configuration management commands.
|
||||
"""配置管理命令
|
||||
|
||||
Supported config keys:
|
||||
- timezone
|
||||
- log_level
|
||||
- dashboard.port
|
||||
- dashboard.username
|
||||
- dashboard.password
|
||||
- callback_api_base
|
||||
支持的配置项:
|
||||
|
||||
- timezone: 时区设置 (例如: Asia/Shanghai)
|
||||
|
||||
- log_level: 日志级别 (DEBUG/INFO/WARNING/ERROR/CRITICAL)
|
||||
|
||||
- dashboard.port: Dashboard 端口
|
||||
|
||||
- dashboard.username: Dashboard 用户名
|
||||
|
||||
- dashboard.password: Dashboard 密码
|
||||
|
||||
- callback_api_base: 回调接口基址
|
||||
"""
|
||||
|
||||
|
||||
@@ -248,122 +150,60 @@ def conf() -> None:
|
||||
@click.argument("key")
|
||||
@click.argument("value")
|
||||
def set_config(key: str, value: str) -> None:
|
||||
"""设置配置项的值"""
|
||||
if key not in CONFIG_VALIDATORS:
|
||||
raise click.ClickException(f"Unsupported config key: {key}")
|
||||
raise click.ClickException(f"不支持的配置项: {key}")
|
||||
|
||||
config = _load_config()
|
||||
try:
|
||||
# Attempt to get old value (may raise KeyError)
|
||||
try:
|
||||
old_value = _get_nested_item(config, key)
|
||||
except Exception:
|
||||
old_value = "<not set>"
|
||||
|
||||
try:
|
||||
old_value = _get_nested_item(config, key)
|
||||
validated_value = CONFIG_VALIDATORS[key](value)
|
||||
if key == "dashboard.password":
|
||||
_set_dashboard_password(config, validated_value)
|
||||
else:
|
||||
_set_nested_item(config, key, validated_value)
|
||||
_set_nested_item(config, key, validated_value)
|
||||
_save_config(config)
|
||||
|
||||
click.echo(f"Config updated: {key}")
|
||||
click.echo(f" Old value: {old_value}")
|
||||
click.echo(f" New value: {validated_value}")
|
||||
except KeyError as e:
|
||||
raise click.ClickException(f"Unknown config key: {key}") from e
|
||||
except click.ClickException:
|
||||
raise
|
||||
click.echo(f"配置已更新: {key}")
|
||||
if key == "dashboard.password":
|
||||
click.echo(" 原值: ********")
|
||||
click.echo(" 新值: ********")
|
||||
else:
|
||||
click.echo(f" 原值: {old_value}")
|
||||
click.echo(f" 新值: {validated_value}")
|
||||
|
||||
except KeyError:
|
||||
raise click.ClickException(f"未知的配置项: {key}")
|
||||
except Exception as e:
|
||||
raise click.UsageError(f"Failed to set config: {e!s}") from e
|
||||
raise click.UsageError(f"设置配置失败: {e!s}")
|
||||
|
||||
|
||||
@conf.command(name="get")
|
||||
@click.argument("key", required=False)
|
||||
def get_config(key: str | None = None) -> None:
|
||||
"""获取配置项的值,不提供key则显示所有可配置项"""
|
||||
config = _load_config()
|
||||
|
||||
if key:
|
||||
if key not in CONFIG_VALIDATORS:
|
||||
raise click.ClickException(f"Unsupported config key: {key}")
|
||||
raise click.ClickException(f"不支持的配置项: {key}")
|
||||
|
||||
try:
|
||||
value = _get_nested_item(config, key)
|
||||
if key == "dashboard.password":
|
||||
value = "********"
|
||||
click.echo(f"{key}: {value}")
|
||||
except KeyError as e:
|
||||
raise click.ClickException(f"Unknown config key: {key}") from e
|
||||
except KeyError:
|
||||
raise click.ClickException(f"未知的配置项: {key}")
|
||||
except Exception as e:
|
||||
raise click.UsageError(f"Failed to get config: {e!s}") from e
|
||||
raise click.UsageError(f"获取配置失败: {e!s}")
|
||||
else:
|
||||
click.echo("Current config:")
|
||||
for k in CONFIG_VALIDATORS:
|
||||
click.echo("当前配置:")
|
||||
for key in CONFIG_VALIDATORS:
|
||||
try:
|
||||
v = (
|
||||
value = (
|
||||
"********"
|
||||
if k == "dashboard.password"
|
||||
else _get_nested_item(config, k)
|
||||
if key == "dashboard.password"
|
||||
else _get_nested_item(config, key)
|
||||
)
|
||||
click.echo(f" {k}: {v}")
|
||||
click.echo(f" {key}: {value}")
|
||||
except (KeyError, TypeError):
|
||||
# Missing or non-dict paths are simply skipped in listing
|
||||
pass
|
||||
|
||||
|
||||
def _check_astrbot_not_running() -> None:
|
||||
"""Refuse to proceed if astrbot is currently running (lock file held)."""
|
||||
lock_file = astrbot_paths.root / "astrbot.lock"
|
||||
if not lock_file.exists():
|
||||
return
|
||||
lock = FileLock(lock_file, timeout=1)
|
||||
try:
|
||||
lock.acquire()
|
||||
except Timeout:
|
||||
raise click.ClickException(
|
||||
"AstrBot is currently running. "
|
||||
"Please stop it first before changing the password via CLI.",
|
||||
) from None
|
||||
else:
|
||||
lock.release()
|
||||
|
||||
|
||||
@conf.command(name="admin")
|
||||
@click.option("-u", "--username", type=str, help="Update admain username as well")
|
||||
@click.option(
|
||||
"-p",
|
||||
"--password",
|
||||
type=str,
|
||||
help="Set admain password directly without interactive prompt",
|
||||
)
|
||||
def set_dashboard_password(username: str | None, password: str | None) -> None:
|
||||
"""Interactively set dashboard password (with confirmation) or set directly with -p.
|
||||
|
||||
Acceptable inputs:
|
||||
- Plaintext password (recommended): it will be hashed securely before storage.
|
||||
- Argon2 encoded hash (advanced): stored as-is.
|
||||
"""
|
||||
_check_astrbot_not_running()
|
||||
config = _load_config()
|
||||
|
||||
if password is not None:
|
||||
if isinstance(password, str) and is_dashboard_password_hash(password):
|
||||
password_hash = password
|
||||
else:
|
||||
if is_legacy_dashboard_password(password):
|
||||
raise click.ClickException(
|
||||
"Providing legacy dashboard password hashes is no longer supported. "
|
||||
"Please supply the plaintext password (it will be hashed securely), "
|
||||
"or provide an Argon2-encoded hash string.",
|
||||
)
|
||||
password_hash = _validate_dashboard_password(password)
|
||||
else:
|
||||
password_hash = prompt_dashboard_password()
|
||||
|
||||
set_dashboard_credentials(
|
||||
config,
|
||||
username=username.strip() if username is not None else None,
|
||||
password_hash=password_hash,
|
||||
)
|
||||
_save_config(config)
|
||||
|
||||
if username is not None:
|
||||
click.echo(f"Dashboard username updated: {username.strip()}")
|
||||
click.echo("Dashboard password updated.")
|
||||
|
||||
@@ -1,237 +1,56 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from filelock import FileLock, Timeout
|
||||
|
||||
from astrbot.cli.utils import DashboardManager
|
||||
from astrbot.core.config.default import DEFAULT_CONFIG
|
||||
from astrbot.core.utils.astrbot_path import astrbot_paths
|
||||
|
||||
from .cmd_conf import ensure_config_file, set_dashboard_credentials
|
||||
|
||||
DASHBOARD_INITIAL_PASSWORD_ENV = "ASTRBOT_DASHBOARD_INITIAL_PASSWORD"
|
||||
from ..utils import check_dashboard, get_astrbot_root
|
||||
|
||||
|
||||
def _initialize_config_from_env(astrbot_root: Path) -> None:
|
||||
if DASHBOARD_INITIAL_PASSWORD_ENV not in os.environ:
|
||||
return
|
||||
|
||||
from astrbot.core.config.astrbot_config import AstrBotConfig
|
||||
|
||||
AstrBotConfig(config_path=str(astrbot_root / "data" / "cmd_config.json"))
|
||||
click.echo("Initialized data/cmd_config.json with dashboard initial password.")
|
||||
|
||||
|
||||
async def initialize_astrbot(
|
||||
astrbot_root: Path,
|
||||
*,
|
||||
yes: bool,
|
||||
backend_only: bool,
|
||||
admin_username: str | None,
|
||||
admin_password: str | None,
|
||||
) -> None:
|
||||
"""Execute AstrBot initialization logic"""
|
||||
from astrbot.cli.banner import print_logo
|
||||
|
||||
click.echo("=" * 60)
|
||||
click.echo("AstrBot 初始化向导")
|
||||
click.echo("=" * 60)
|
||||
print_logo()
|
||||
click.echo()
|
||||
|
||||
async def initialize_astrbot(astrbot_root: Path) -> None:
|
||||
"""执行 AstrBot 初始化逻辑"""
|
||||
dot_astrbot = astrbot_root / ".astrbot"
|
||||
|
||||
if not dot_astrbot.exists():
|
||||
if yes or click.confirm(
|
||||
f"确定要将 AstrBot 安装到以下目录吗?\n {astrbot_root}",
|
||||
click.echo(f"Current Directory: {astrbot_root}")
|
||||
click.echo(
|
||||
"如果你确认这是 Astrbot root directory, 你需要在当前目录下创建一个 .astrbot 文件标记该目录为 AstrBot 的数据目录。",
|
||||
)
|
||||
if click.confirm(
|
||||
f"请检查当前目录是否正确,确认正确请回车: {astrbot_root}",
|
||||
default=True,
|
||||
abort=True,
|
||||
):
|
||||
dot_astrbot.touch()
|
||||
click.echo(f"[OK] 已创建: {dot_astrbot}")
|
||||
click.echo(f"Created {dot_astrbot}")
|
||||
|
||||
paths = {
|
||||
"data": astrbot_root / "data",
|
||||
"config": astrbot_root / "data" / "config",
|
||||
"plugins": astrbot_root / "data" / "plugins",
|
||||
"temp": astrbot_root / "data" / "temp",
|
||||
"skills": astrbot_root / "data" / "skills",
|
||||
}
|
||||
|
||||
for name, path in paths.items():
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
status = "Created" if not path.exists() else "Exists"
|
||||
click.echo(f" [{status}] {name.title()}: {path}")
|
||||
click.echo(f"{'Created' if not path.exists() else 'Directory exists'}: {path}")
|
||||
|
||||
_initialize_config_from_env(astrbot_root)
|
||||
|
||||
config_path = astrbot_root / "data" / "cmd_config.json"
|
||||
if not config_path.exists():
|
||||
config_path.write_text(
|
||||
json.dumps(DEFAULT_CONFIG, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8-sig",
|
||||
)
|
||||
click.echo(f"[OK] 配置文件已创建: {config_path}")
|
||||
ASTRBOT_ROOT = astrbot_root
|
||||
env_file = ASTRBOT_ROOT / ".env"
|
||||
if not env_file.exists():
|
||||
tmpl_candidates = [
|
||||
Path("/opt/astrbot/config.template"),
|
||||
getattr(astrbot_paths, "project_root", Path.cwd()) / "config.template",
|
||||
Path.cwd() / "config.template",
|
||||
]
|
||||
tmpl = None
|
||||
for t in tmpl_candidates:
|
||||
try:
|
||||
if t.exists():
|
||||
tmpl = t
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if tmpl is not None:
|
||||
try:
|
||||
txt = tmpl.read_text(encoding="utf-8")
|
||||
instance_name = astrbot_root.name or "astrbot"
|
||||
txt = re.sub("\\$\\{INSTANCE_NAME(:-[^}]*)?\\}", instance_name, txt)
|
||||
port_val = (
|
||||
os.environ.get("ASTRBOT_PORT") or os.environ.get("PORT") or "8000"
|
||||
)
|
||||
txt = re.sub("\\$\\{PORT(:-[^}]*)?\\}", str(port_val), txt)
|
||||
txt = re.sub("\\$\\{ASTRBOT_ROOT(:-[^}]*)?\\}", str(ASTRBOT_ROOT), txt)
|
||||
header = f"# Generated from config.template by astrbot init for instance: {instance_name}\n# This file will be auto-loaded by 'astrbot run'\n\n"
|
||||
env_file.write_text(header + txt, encoding="utf-8")
|
||||
env_file.chmod(420)
|
||||
click.echo(f"[OK] 环境变量文件已创建: {env_file}")
|
||||
except Exception as e:
|
||||
click.echo(f"[警告] 无法从模板生成 .env 文件: {e!s}")
|
||||
else:
|
||||
click.echo("[提示] 未找到 config.template 文件,跳过 .env 生成")
|
||||
if admin_password is not None:
|
||||
raise click.ClickException(
|
||||
"--admin-password is no longer supported during init. Run 'astrbot conf admin' after initialization.",
|
||||
)
|
||||
effective_admin_username = (
|
||||
admin_username.strip()
|
||||
if admin_username
|
||||
else str(DEFAULT_CONFIG["dashboard"]["username"])
|
||||
)
|
||||
if admin_username:
|
||||
config = ensure_config_file()
|
||||
set_dashboard_credentials(
|
||||
config,
|
||||
username=effective_admin_username,
|
||||
password_hash=None,
|
||||
)
|
||||
config_path.write_text(
|
||||
json.dumps(config, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8-sig",
|
||||
)
|
||||
click.echo(f"[OK] Dashboard admin 用户名已设置为: {effective_admin_username}")
|
||||
click.echo()
|
||||
click.echo("!" * 60)
|
||||
click.echo("重要提示:")
|
||||
click.echo(" 1. Dashboard 密码尚未设置!首次登录前必须先设置密码")
|
||||
click.echo(" 2. 设置命令: astrbot conf admin")
|
||||
click.echo(" 3. 登录地址: http://localhost:6185 或 http://服务器IP:6185")
|
||||
click.echo("!" * 60)
|
||||
click.echo()
|
||||
if not backend_only and (
|
||||
yes
|
||||
or click.confirm(
|
||||
"是否需要集成式 WebUI?(个人电脑推荐,服务器推荐使用后端模式)",
|
||||
default=True,
|
||||
)
|
||||
):
|
||||
await DashboardManager().ensure_installed(astrbot_root)
|
||||
else:
|
||||
click.echo()
|
||||
click.echo("[提示] 你选择了后端模式,可以使用以下方式管理 AstrBot:")
|
||||
click.echo(" - 使用在线 Dashboard: 在浏览器中访问远程服务器的 WebUI")
|
||||
click.echo(" - 使用 CLI 命令: astrbot conf / astrbot plug 等")
|
||||
click.echo()
|
||||
click.echo("!" * 60)
|
||||
click.echo("安全提示:")
|
||||
click.echo(" HTTPS 前端只能安全连接 localhost 的 HTTP 后端")
|
||||
click.echo(" 不支持远程 + HTTP 后端(不安全)")
|
||||
click.echo(" 如需远程访问,请使用 HTTPS 后端或通过反向代理")
|
||||
click.echo("!" * 60)
|
||||
click.echo()
|
||||
await check_dashboard(astrbot_root / "data")
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompts")
|
||||
@click.option("--backend-only", "-b", is_flag=True, help="Only initialize the backend")
|
||||
@click.option("--backup", "-f", help="Initialize from backup file", type=str)
|
||||
@click.option(
|
||||
"-u",
|
||||
"--admin-username",
|
||||
type=str,
|
||||
help="Set dashboard admin username during initialization",
|
||||
)
|
||||
@click.option(
|
||||
"-p",
|
||||
"--admin-password",
|
||||
type=str,
|
||||
help="Deprecated. Run `astrbot conf admin` after initialization.",
|
||||
)
|
||||
@click.option(
|
||||
"--root",
|
||||
help="ASTRBOT root directory to initialize (overrides ASTRBOT_ROOT env)",
|
||||
type=str,
|
||||
)
|
||||
def init(
|
||||
yes: bool,
|
||||
backend_only: bool,
|
||||
backup: str | None,
|
||||
admin_username: str | None,
|
||||
admin_password: str | None,
|
||||
root: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize AstrBot"""
|
||||
def init() -> None:
|
||||
"""初始化 AstrBot"""
|
||||
click.echo("Initializing AstrBot...")
|
||||
if os.environ.get("ASTRBOT_SYSTEMD") == "1":
|
||||
yes = True
|
||||
from astrbot.core.utils.astrbot_path import astrbot_paths
|
||||
|
||||
astrbot_root = Path(root) if root else astrbot_paths.root
|
||||
astrbot_root = get_astrbot_root()
|
||||
lock_file = astrbot_root / "astrbot.lock"
|
||||
lock = FileLock(lock_file, timeout=5)
|
||||
|
||||
try:
|
||||
with lock.acquire():
|
||||
asyncio.run(
|
||||
initialize_astrbot(
|
||||
astrbot_root,
|
||||
yes=yes,
|
||||
backend_only=backend_only,
|
||||
admin_username=admin_username,
|
||||
admin_password=admin_password,
|
||||
),
|
||||
)
|
||||
if backup:
|
||||
from .cmd_bk import import_data_command
|
||||
asyncio.run(initialize_astrbot(astrbot_root))
|
||||
except Timeout:
|
||||
raise click.ClickException("无法获取锁文件,请检查是否有其他实例正在运行")
|
||||
|
||||
click.echo(f"Restoring from backup: {backup}")
|
||||
click.get_current_context().invoke(
|
||||
import_data_command,
|
||||
backup_file=backup,
|
||||
yes=True,
|
||||
)
|
||||
click.echo()
|
||||
click.echo("=" * 60)
|
||||
click.echo("初始化完成!")
|
||||
click.echo("=" * 60)
|
||||
click.echo()
|
||||
click.echo("启动 AstrBot:")
|
||||
click.echo(" 完整模式(含 Dashboard): astrbot run")
|
||||
click.echo(" 仅后端模式: astrbot run --backend-only")
|
||||
click.echo()
|
||||
click.echo("首次使用前请先设置管理员密码:")
|
||||
click.echo(" astrbot conf admin")
|
||||
click.echo()
|
||||
except Timeout as err:
|
||||
raise click.ClickException(
|
||||
"Cannot acquire lock file. Please check if another instance is running",
|
||||
) from err
|
||||
except Exception as e:
|
||||
raise click.ClickException(f"Initialization failed: {e!s}") from e
|
||||
raise click.ClickException(f"初始化失败: {e!s}")
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import click
|
||||
|
||||
from .cmd_conf import (
|
||||
_load_config,
|
||||
_save_config,
|
||||
_set_dashboard_password,
|
||||
_set_nested_item,
|
||||
_validate_dashboard_password,
|
||||
_validate_dashboard_username,
|
||||
)
|
||||
|
||||
|
||||
@click.command(name="password")
|
||||
@click.option(
|
||||
"--username",
|
||||
help="Optional dashboard username to set together with the new password.",
|
||||
)
|
||||
def password(username: str | None) -> None:
|
||||
"""Change the AstrBot dashboard password."""
|
||||
config = _load_config()
|
||||
|
||||
new_password = click.prompt(
|
||||
"New dashboard password",
|
||||
hide_input=True,
|
||||
confirmation_prompt=True,
|
||||
)
|
||||
validated_password = _validate_dashboard_password(new_password)
|
||||
|
||||
if username is not None:
|
||||
validated_username = _validate_dashboard_username(username.strip())
|
||||
_set_nested_item(config, "dashboard.username", validated_username)
|
||||
|
||||
_set_dashboard_password(config, validated_password)
|
||||
_save_config(config)
|
||||
|
||||
click.echo("Dashboard password updated.")
|
||||
if username is not None:
|
||||
click.echo(f"Dashboard username updated: {validated_username}")
|
||||
@@ -1,29 +1,38 @@
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from astrbot.cli.i18n import t
|
||||
from astrbot.cli.utils import (
|
||||
from ..utils import (
|
||||
PluginStatus,
|
||||
build_plug_list,
|
||||
check_astrbot_root,
|
||||
get_astrbot_root,
|
||||
get_git_repo,
|
||||
manage_plugin,
|
||||
)
|
||||
|
||||
|
||||
@click.group(name="plugin")
|
||||
@click.group()
|
||||
def plug() -> None:
|
||||
"""Plugin management"""
|
||||
"""插件管理"""
|
||||
|
||||
|
||||
def _get_data_path() -> Path:
|
||||
base = get_astrbot_root()
|
||||
if not check_astrbot_root(base):
|
||||
raise click.ClickException(
|
||||
f"{base}不是有效的 AstrBot 根目录,如需初始化请使用 astrbot init",
|
||||
)
|
||||
return (base / "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"{'Name':<20} {'Version':<10} {'Status':<10} {'Author':<15} {'Description':<30}",
|
||||
)
|
||||
click.echo(f"{'名称':<20} {'版本':<10} {'状态':<10} {'作者':<15} {'描述':<30}")
|
||||
click.echo("-" * 85)
|
||||
|
||||
for p in plugins:
|
||||
@@ -37,32 +46,30 @@ def display_plugins(plugins, title=None, color=None) -> None:
|
||||
@plug.command()
|
||||
@click.argument("name")
|
||||
def new(name: str) -> None:
|
||||
"""Create a new plugin"""
|
||||
from astrbot.core.utils.astrbot_path import astrbot_paths
|
||||
|
||||
base_path = astrbot_paths.data
|
||||
"""创建新插件"""
|
||||
base_path = _get_data_path()
|
||||
plug_path = base_path / "plugins" / name
|
||||
|
||||
if plug_path.exists():
|
||||
raise click.ClickException(t("plugin_already_exists", name=name))
|
||||
raise click.ClickException(f"插件 {name} 已存在")
|
||||
|
||||
author = click.prompt("Enter plugin author", type=str)
|
||||
desc = click.prompt("Enter plugin description", type=str)
|
||||
version = click.prompt("Enter plugin version", type=str)
|
||||
author = click.prompt("请输入插件作者", type=str)
|
||||
desc = click.prompt("请输入插件描述", type=str)
|
||||
version = click.prompt("请输入插件版本", type=str)
|
||||
if not re.match(r"^\d+\.\d+(\.\d+)?$", version.lower().lstrip("v")):
|
||||
raise click.ClickException("Version must be in x.y or x.y.z format")
|
||||
repo = click.prompt("Enter plugin repository URL:", type=str)
|
||||
raise click.ClickException("版本号必须为 x.y 或 x.y.z 格式")
|
||||
repo = click.prompt("请输入插件仓库:", type=str)
|
||||
if not repo.startswith("http"):
|
||||
raise click.ClickException("Repository URL must start with http")
|
||||
raise click.ClickException("仓库地址必须以 http 开头")
|
||||
|
||||
click.echo("Downloading plugin template...")
|
||||
click.echo("下载插件模板...")
|
||||
get_git_repo(
|
||||
"https://github.com/Soulter/helloworld",
|
||||
plug_path,
|
||||
)
|
||||
|
||||
click.echo("Rewriting plugin metadata...")
|
||||
# Rewrite metadata.yaml
|
||||
click.echo("重写插件信息...")
|
||||
# 重写 metadata.yaml
|
||||
with open(plug_path / "metadata.yaml", "w", encoding="utf-8") as f:
|
||||
f.write(
|
||||
f"name: {name}\n"
|
||||
@@ -72,13 +79,11 @@ def new(name: str) -> None:
|
||||
f"repo: {repo}\n",
|
||||
)
|
||||
|
||||
# Rewrite README.md
|
||||
# 重写 README.md
|
||||
with open(plug_path / "README.md", "w", encoding="utf-8") as f:
|
||||
f.write(
|
||||
f"# {name}\n\n{desc}\n\n# Support\n\n[Documentation](https://docs.astrbot.app)\n",
|
||||
)
|
||||
f.write(f"# {name}\n\n{desc}\n\n# 支持\n\n[帮助文档](https://astrbot.app)\n")
|
||||
|
||||
# Rewrite main.py
|
||||
# 重写 main.py
|
||||
with open(plug_path / "main.py", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
@@ -90,59 +95,55 @@ def new(name: str) -> None:
|
||||
with open(plug_path / "main.py", "w", encoding="utf-8") as f:
|
||||
f.write(new_content)
|
||||
|
||||
click.echo(f"Plugin {name} created successfully")
|
||||
click.echo(f"插件 {name} 创建成功")
|
||||
|
||||
|
||||
@plug.command()
|
||||
@click.option("--all", "-a", is_flag=True, help="List uninstalled plugins")
|
||||
@click.option("--all", "-a", is_flag=True, help="列出未安装的插件")
|
||||
def list(all: bool) -> None:
|
||||
"""List plugins"""
|
||||
from astrbot.core.utils.astrbot_path import astrbot_paths
|
||||
|
||||
base_path = astrbot_paths.data
|
||||
"""列出插件"""
|
||||
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, "Unpublished Plugins", "red")
|
||||
display_plugins(not_published_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, "Plugins Needing Update", "yellow")
|
||||
display_plugins(need_update_plugins, "需要更新的插件", "yellow")
|
||||
|
||||
# Installed plugins
|
||||
# 已安装的插件
|
||||
installed_plugins = [p for p in plugins if p["status"] == PluginStatus.INSTALLED]
|
||||
if installed_plugins:
|
||||
display_plugins(installed_plugins, "Installed Plugins", "green")
|
||||
display_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, "Uninstalled Plugins", "blue")
|
||||
display_plugins(not_installed_plugins, "未安装的插件", "blue")
|
||||
|
||||
if (
|
||||
not any([not_published_plugins, need_update_plugins, installed_plugins])
|
||||
and not all
|
||||
):
|
||||
click.echo("No plugins installed")
|
||||
click.echo("未安装任何插件")
|
||||
|
||||
|
||||
@plug.command()
|
||||
@click.argument("name")
|
||||
@click.option("--proxy", help="Proxy server address")
|
||||
@click.option("--proxy", help="代理服务器地址")
|
||||
def install(name: str, proxy: str | None) -> None:
|
||||
"""Install a plugin"""
|
||||
from astrbot.core.utils.astrbot_path import astrbot_paths
|
||||
|
||||
base_path = astrbot_paths.data
|
||||
"""安装插件"""
|
||||
base_path = _get_data_path()
|
||||
plug_path = base_path / "plugins"
|
||||
plugins = build_plug_list(base_path / "plugins")
|
||||
|
||||
@@ -156,7 +157,7 @@ def install(name: str, proxy: str | None) -> None:
|
||||
)
|
||||
|
||||
if not plugin:
|
||||
raise click.ClickException(t("plugin_not_found_or_installed", name=name))
|
||||
raise click.ClickException(f"未找到可安装的插件 {name},可能是不存在或已安装")
|
||||
|
||||
manage_plugin(plugin, plug_path, is_update=False, proxy=proxy)
|
||||
|
||||
@@ -164,37 +165,31 @@ def install(name: str, proxy: str | None) -> None:
|
||||
@plug.command()
|
||||
@click.argument("name")
|
||||
def remove(name: str) -> None:
|
||||
"""Uninstall a plugin"""
|
||||
from astrbot.core.utils.astrbot_path import astrbot_paths
|
||||
|
||||
base_path = astrbot_paths.data
|
||||
"""卸载插件"""
|
||||
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(t("plugin_not_found_or_installed", name=name))
|
||||
raise click.ClickException(f"插件 {name} 不存在或未安装")
|
||||
|
||||
plugin_path = plugin["local_path"]
|
||||
|
||||
click.confirm(t("plugin_uninstall_confirm", name=name), default=False, abort=True)
|
||||
click.confirm(f"确定要卸载插件 {name} 吗?", default=False, abort=True)
|
||||
|
||||
try:
|
||||
shutil.rmtree(plugin_path)
|
||||
click.echo(t("plugin_uninstall_success", name=name))
|
||||
click.echo(f"插件 {name} 已卸载")
|
||||
except Exception as e:
|
||||
raise click.ClickException(
|
||||
t("plugin_uninstall_failed_ex", name=name, error=str(e)),
|
||||
) from e
|
||||
raise click.ClickException(f"卸载插件 {name} 失败: {e}")
|
||||
|
||||
|
||||
@plug.command()
|
||||
@click.argument("name", required=False)
|
||||
@click.option("--proxy", help="GitHub proxy address")
|
||||
@click.option("--proxy", help="Github代理地址")
|
||||
def update(name: str, proxy: str | None) -> None:
|
||||
"""Update plugins"""
|
||||
from astrbot.core.utils.astrbot_path import astrbot_paths
|
||||
|
||||
base_path = astrbot_paths.data
|
||||
"""更新插件"""
|
||||
base_path = _get_data_path()
|
||||
plug_path = base_path / "plugins"
|
||||
plugins = build_plug_list(base_path / "plugins")
|
||||
|
||||
@@ -209,9 +204,7 @@ def update(name: str, proxy: str | None) -> None:
|
||||
)
|
||||
|
||||
if not plugin:
|
||||
raise click.ClickException(
|
||||
f"Plugin {name} does not need updating or cannot be updated",
|
||||
)
|
||||
raise click.ClickException(f"插件 {name} 不需要更新或无法更新")
|
||||
|
||||
manage_plugin(plugin, plug_path, is_update=True, proxy=proxy)
|
||||
else:
|
||||
@@ -220,23 +213,21 @@ def update(name: str, proxy: str | None) -> None:
|
||||
]
|
||||
|
||||
if not need_update_plugins:
|
||||
click.echo(t("plugin_no_update_needed"))
|
||||
click.echo("没有需要更新的插件")
|
||||
return
|
||||
|
||||
click.echo(t("plugin_found_update", count=str(len(need_update_plugins))))
|
||||
click.echo(f"发现 {len(need_update_plugins)} 个插件需要更新")
|
||||
for plugin in need_update_plugins:
|
||||
plugin_name = plugin["name"]
|
||||
click.echo(t("plugin_updating", name=plugin_name))
|
||||
click.echo(f"正在更新插件 {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"""
|
||||
from astrbot.core.utils.astrbot_path import astrbot_paths
|
||||
|
||||
base_path = astrbot_paths.data
|
||||
"""搜索插件"""
|
||||
base_path = _get_data_path()
|
||||
plugins = build_plug_list(base_path / "plugins")
|
||||
|
||||
matched_plugins = [
|
||||
@@ -248,7 +239,7 @@ def search(query: str) -> None:
|
||||
]
|
||||
|
||||
if not matched_plugins:
|
||||
click.echo(t("plugin_search_no_result", query=query))
|
||||
click.echo(f"未找到匹配 '{query}' 的插件")
|
||||
return
|
||||
|
||||
display_plugins(matched_plugins, t("plugin_search_results", query=query), "cyan")
|
||||
display_plugins(matched_plugins, f"搜索结果: '{query}'", "cyan")
|
||||
|
||||
@@ -1,104 +1,21 @@
|
||||
"""AstrBot Run
|
||||
Environment Variables Used in Project:
|
||||
|
||||
Core:
|
||||
- `ASTRBOT_ROOT`: AstrBot root directory path.
|
||||
- `ASTRBOT_LOG_LEVEL`: Log level (e.g. INFO, DEBUG).
|
||||
- `ASTRBOT_CLI`: Flag indicating execution via CLI.
|
||||
- `ASTRBOT_DESKTOP_CLIENT`: Flag indicating execution via desktop client.
|
||||
- `ASTRBOT_SYSTEMD`: Flag indicating execution via systemd service.
|
||||
- `ASTRBOT_RELOAD`: Enable plugin auto-reload (set to "1").
|
||||
- `ASTRBOT_DISABLE_METRICS`: Disable metrics upload (set to "1").
|
||||
- `TESTING`: Enable testing mode.
|
||||
- `DEMO_MODE`: Enable demo mode.
|
||||
- `PYTHON`: Python executable path override (for local code execution).
|
||||
|
||||
Dashboard / Backend:
|
||||
- `ASTRBOT_DASHBOARD_ENABLE`: Enable/Disable Dashboard.
|
||||
- `ASTRBOT_HOST`: Dashboard bind host.
|
||||
- `ASTRBOT_PORT`: Dashboard bind port.
|
||||
|
||||
SSL (AstrBot-standard names):
|
||||
- `ASTRBOT_SSL_ENABLE`: Enable SSL for API.
|
||||
- `ASTRBOT_SSL_CERT`: SSL Certificate path for backend.
|
||||
- `ASTRBOT_SSL_KEY`: SSL Key path for backend.
|
||||
- `ASTRBOT_SSL_CA_CERTS`: SSL CA Certs path for backend.
|
||||
|
||||
Network:
|
||||
- `http_proxy` / `https_proxy`: Proxy URL.
|
||||
- `no_proxy`: No proxy list.
|
||||
|
||||
Internationalization:
|
||||
- `ASTRBOT_CLI_LANG`: CLI interface language (zh/en).
|
||||
|
||||
Integrations:
|
||||
- `DASHSCOPE_API_KEY`: Alibaba DashScope API Key (for Rerank).
|
||||
- `COZE_API_KEY` / `COZE_BOT_ID`: Coze integration.
|
||||
- `BAY_DATA_DIR`: Computer Use data directory.
|
||||
|
||||
Platform Specific:
|
||||
- `TEST_MODE`: Test mode for QQOfficial.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv
|
||||
from filelock import FileLock, Timeout
|
||||
|
||||
from astrbot.cli.utils import DashboardManager
|
||||
from astrbot.runtime_bootstrap import initialize_runtime_bootstrap
|
||||
|
||||
# Python version check: require 3.12 or 3.13
|
||||
if not (sys.version_info.major == 3 and sys.version_info.minor in (12, 13)):
|
||||
sys.exit(1)
|
||||
|
||||
# Regular expression to find bash-like parameter expansions:
|
||||
# ${VAR:-default} or ${VAR}
|
||||
_PARAM_EXPAND_RE = re.compile(r"\$\{([^}:]+?)(:-([^}]*))?\}")
|
||||
|
||||
|
||||
def _expand_parameter(
|
||||
match: re.Match,
|
||||
env: dict[str, str],
|
||||
local: dict[str, str],
|
||||
) -> str:
|
||||
"""Helper to expand a single ${VAR:-default} or ${VAR} occurrence.
|
||||
|
||||
Precedence:
|
||||
1. local dict (parsed from the same file, earlier entries)
|
||||
2. environment variables
|
||||
3. default provided in the expansion (if any)
|
||||
4. empty string
|
||||
"""
|
||||
var = match.group(1)
|
||||
default = match.group(3) if match.group(3) is not None else ""
|
||||
# Prefer 'local' parsed values first
|
||||
if var in local and local[var] != "":
|
||||
return local[var]
|
||||
val = env.get(var, "")
|
||||
if val != "":
|
||||
return val
|
||||
return default
|
||||
from ..utils import check_astrbot_root, check_dashboard, get_astrbot_root
|
||||
|
||||
|
||||
async def run_astrbot(astrbot_root: Path) -> None:
|
||||
"""Run AstrBot"""
|
||||
"""运行 AstrBot"""
|
||||
from astrbot.core import LogBroker, LogManager, db_helper, logger
|
||||
from astrbot.core.initial_loader import InitialLoader
|
||||
|
||||
if (
|
||||
os.environ.get("ASTRBOT_DASHBOARD_ENABLE", os.environ.get("DASHBOARD_ENABLE"))
|
||||
== "True"
|
||||
):
|
||||
await DashboardManager().ensure_installed(astrbot_root)
|
||||
await check_dashboard(astrbot_root / "data")
|
||||
|
||||
log_broker = LogBroker()
|
||||
LogManager.set_queue_handler(logger, log_broker)
|
||||
@@ -109,317 +26,37 @@ async def run_astrbot(astrbot_root: Path) -> None:
|
||||
await core_lifecycle.start()
|
||||
|
||||
|
||||
@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("--root", help="AstrBot root directory", required=False, type=str)
|
||||
@click.option(
|
||||
"--service-config",
|
||||
"-c",
|
||||
help="Service configuration file path (supports ${VAR:-default} style expansion)",
|
||||
required=False,
|
||||
type=str,
|
||||
)
|
||||
@click.option(
|
||||
"--backend-only",
|
||||
"-b",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Disable WebUI, run backend only",
|
||||
)
|
||||
@click.option(
|
||||
"--log-level",
|
||||
"-l",
|
||||
help="Log level",
|
||||
required=False,
|
||||
type=str,
|
||||
default="INFO",
|
||||
)
|
||||
@click.option(
|
||||
"--ssl-cert",
|
||||
help="SSL certificate file path for backend (preferred env name: ASTRBOT_SSL_CERT)",
|
||||
required=False,
|
||||
type=str,
|
||||
)
|
||||
@click.option(
|
||||
"--ssl-key",
|
||||
help="SSL private key file path for backend (preferred env name: ASTRBOT_SSL_KEY)",
|
||||
required=False,
|
||||
type=str,
|
||||
)
|
||||
@click.option(
|
||||
"--ssl-ca",
|
||||
help="SSL CA certificates file path for backend (preferred env name: ASTRBOT_SSL_CA_CERTS)",
|
||||
required=False,
|
||||
type=str,
|
||||
)
|
||||
@click.option("--debug", is_flag=True, help="Enable debug mode")
|
||||
@click.option("--reload", "-r", is_flag=True, help="插件自动重载")
|
||||
@click.option("--port", "-p", help="Astrbot Dashboard端口", required=False, type=str)
|
||||
@click.command()
|
||||
def run(
|
||||
reload: bool,
|
||||
host: str,
|
||||
port: str,
|
||||
root: str,
|
||||
service_config: str,
|
||||
backend_only: bool,
|
||||
log_level: str,
|
||||
ssl_cert: str,
|
||||
ssl_key: str,
|
||||
ssl_ca: str,
|
||||
debug: bool,
|
||||
) -> None:
|
||||
"""Run AstrBot"""
|
||||
initialize_runtime_bootstrap()
|
||||
def run(reload: bool, port: str) -> None:
|
||||
"""运行 AstrBot"""
|
||||
try:
|
||||
if debug:
|
||||
log_level = "DEBUG"
|
||||
|
||||
# --- Step 1: Resolve service-config path (if provided). We'll treat it as a .env file later. ---
|
||||
svc_path: Path | None = None
|
||||
if service_config:
|
||||
candidate = Path(service_config)
|
||||
if not candidate.exists():
|
||||
# Try to expand user and resolve
|
||||
candidate = Path(os.path.expanduser(service_config))
|
||||
if candidate.exists():
|
||||
svc_path = candidate
|
||||
|
||||
# NOTE:
|
||||
# Loading of common .env files (CWD/.env, packaged project .env, ASTRBOT_ROOT/.env)
|
||||
# has been moved to astrbot.core.utils.astrbot_path during import-time to avoid
|
||||
# early-initialization ordering issues. Those files are loaded there using
|
||||
# `override=False` so they do not clobber environment variables provided by the
|
||||
# systemd unit or the caller.
|
||||
#
|
||||
# Here we only load an explicit service-config file (if given). Service-config
|
||||
# should be able to override the common .env files, but CLI-provided values must
|
||||
# still win; the CLI will set/overwrite corresponding environment variables
|
||||
# below after this load.
|
||||
if svc_path and svc_path.exists():
|
||||
# Load service-config as an env file and allow it to override previously-loaded
|
||||
# .env values (those were loaded by astrbot_path). CLI variables are applied
|
||||
# after this point and will take precedence.
|
||||
load_dotenv(dotenv_path=str(svc_path), override=True)
|
||||
|
||||
# Mark CLI execution
|
||||
os.environ["ASTRBOT_CLI"] = "1"
|
||||
astrbot_root = get_astrbot_root()
|
||||
|
||||
from astrbot.core.utils.astrbot_path import astrbot_paths
|
||||
|
||||
# Resolve astrbot_root with the following precedence:
|
||||
# 1. CLI --root parameter (local variable `root`)
|
||||
# 2. ASTRBOT_ROOT environment variable (possibly from .env or parsed service config)
|
||||
# 3. packaged default astrbot_paths.root
|
||||
if root:
|
||||
os.environ["ASTRBOT_ROOT"] = root
|
||||
astrbot_root = Path(root)
|
||||
elif os.environ.get("ASTRBOT_ROOT"):
|
||||
astrbot_root = Path(os.environ["ASTRBOT_ROOT"])
|
||||
else:
|
||||
astrbot_root = astrbot_paths.root
|
||||
|
||||
if not astrbot_paths.is_root:
|
||||
if not check_astrbot_root(astrbot_root):
|
||||
raise click.ClickException(
|
||||
f"{astrbot_root} is not a valid AstrBot root directory. Use 'astrbot init' to initialize",
|
||||
f"{astrbot_root}不是有效的 AstrBot 根目录,如需初始化请使用 astrbot init",
|
||||
)
|
||||
|
||||
# Ensure ASTRBOT_ROOT env var is set to the resolved root (without overriding a CLI-provided root value above)
|
||||
os.environ["ASTRBOT_ROOT"] = str(astrbot_root)
|
||||
sys.path.insert(0, str(astrbot_root))
|
||||
|
||||
# Host/Port precedence: CLI args > parsed service config/env/.env > defaults.
|
||||
if port is not None:
|
||||
os.environ["ASTRBOT_PORT"] = port
|
||||
|
||||
if host is not None:
|
||||
os.environ["ASTRBOT_HOST"] = host
|
||||
|
||||
# CLI-provided SSL paths should set backend-standard env names.
|
||||
if ssl_cert is not None:
|
||||
os.environ["ASTRBOT_SSL_CERT"] = ssl_cert
|
||||
if ssl_key is not None:
|
||||
os.environ["ASTRBOT_SSL_KEY"] = ssl_key
|
||||
if ssl_ca is not None:
|
||||
os.environ["ASTRBOT_SSL_CA_CERTS"] = ssl_ca
|
||||
|
||||
# Dashboard enable is derived from CLI flag (--backend-only). CLI decision should win.
|
||||
os.environ["ASTRBOT_DASHBOARD_ENABLE"] = str(not backend_only)
|
||||
|
||||
os.environ["ASTRBOT_LOG_LEVEL"] = log_level
|
||||
if port:
|
||||
os.environ["DASHBOARD_PORT"] = port
|
||||
|
||||
if reload:
|
||||
click.echo("Plugin auto-reload enabled")
|
||||
click.echo("启用插件自动重载")
|
||||
os.environ["ASTRBOT_RELOAD"] = "1"
|
||||
|
||||
if debug:
|
||||
keys_to_print = [
|
||||
"ASTRBOT_ROOT",
|
||||
"ASTRBOT_LOG_LEVEL",
|
||||
"ASTRBOT_CLI",
|
||||
"ASTRBOT_DESKTOP_CLIENT",
|
||||
"ASTRBOT_SYSTEMD",
|
||||
"ASTRBOT_RELOAD",
|
||||
"ASTRBOT_DISABLE_METRICS",
|
||||
"TESTING",
|
||||
"DEMO_MODE",
|
||||
"PYTHON",
|
||||
"ASTRBOT_DASHBOARD_ENABLE",
|
||||
"DASHBOARD_ENABLE",
|
||||
"ASTRBOT_HOST",
|
||||
"DASHBOARD_HOST",
|
||||
"ASTRBOT_PORT",
|
||||
"DASHBOARD_PORT",
|
||||
# Dashboard SSL (legacy)
|
||||
"ASTRBOT_SSL_ENABLE",
|
||||
"DASHBOARD_SSL_ENABLE",
|
||||
"ASTRBOT_SSL_CERT",
|
||||
"DASHBOARD_SSL_CERT",
|
||||
"ASTRBOT_SSL_KEY",
|
||||
"DASHBOARD_SSL_KEY",
|
||||
"ASTRBOT_SSL_CA_CERTS",
|
||||
"DASHBOARD_SSL_CA_CERTS",
|
||||
# Backend-standard SSL (preferred)
|
||||
"ASTRBOT_SSL_ENABLE",
|
||||
"ASTRBOT_SSL_CERT",
|
||||
"ASTRBOT_SSL_KEY",
|
||||
"ASTRBOT_SSL_CA_CERTS",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"no_proxy",
|
||||
"DASHSCOPE_API_KEY",
|
||||
"COZE_API_KEY",
|
||||
"COZE_BOT_ID",
|
||||
"BAY_DATA_DIR",
|
||||
"TEST_MODE",
|
||||
]
|
||||
click.secho("\n[Debug Mode] Environment Variables:", fg="yellow", bold=True)
|
||||
for key in keys_to_print:
|
||||
if key in os.environ:
|
||||
val = os.environ[key]
|
||||
if "KEY" in key or "PASSWORD" in key or "SECRET" in key:
|
||||
if len(val) > 8:
|
||||
val = val[:4] + "****" + val[-4:]
|
||||
else:
|
||||
val = "****"
|
||||
click.echo(f" {click.style(key, fg='cyan')}: {val}")
|
||||
if svc_path:
|
||||
click.echo(
|
||||
f" {click.style('SERVICE_CONFIG', fg='cyan')}: {svc_path!s}",
|
||||
)
|
||||
click.echo("")
|
||||
|
||||
lock_file = astrbot_root / "astrbot.lock"
|
||||
lock = FileLock(lock_file, timeout=5)
|
||||
with lock.acquire():
|
||||
|
||||
async def run_with_logging() -> None:
|
||||
from astrbot.core import LogBroker, LogManager, db_helper, logger
|
||||
from astrbot.core.initial_loader import InitialLoader
|
||||
|
||||
if (
|
||||
os.environ.get(
|
||||
"ASTRBOT_DASHBOARD_ENABLE",
|
||||
os.environ.get("DASHBOARD_ENABLE"),
|
||||
)
|
||||
== "True"
|
||||
):
|
||||
await DashboardManager().ensure_installed(astrbot_root)
|
||||
|
||||
log_broker = LogBroker()
|
||||
LogManager.set_queue_handler(logger, log_broker)
|
||||
|
||||
# Register a stdout subscriber for real-time log streaming
|
||||
log_queue = log_broker.register()
|
||||
|
||||
db = db_helper
|
||||
initial_loader = InitialLoader(db, log_broker)
|
||||
|
||||
# Start a task to stream logs to stdout
|
||||
async def stream_logs() -> None:
|
||||
"""Stream logs from LogBroker to stdout."""
|
||||
while True:
|
||||
try:
|
||||
log_entry = await asyncio.wait_for(
|
||||
log_queue.get(),
|
||||
timeout=0.5,
|
||||
)
|
||||
# Format: [LEVEL] message
|
||||
level = log_entry.get("level_name", "INFO")
|
||||
message = log_entry.get("message", "")
|
||||
if message:
|
||||
level_color = {
|
||||
"DEBUG": "cyan",
|
||||
"INFO": "green",
|
||||
"WARNING": "yellow",
|
||||
"ERROR": "red",
|
||||
"CRITICAL": "red",
|
||||
}.get(level, "white")
|
||||
click.secho(
|
||||
f"[{level}]",
|
||||
fg=level_color,
|
||||
bold=False,
|
||||
nl=False,
|
||||
)
|
||||
click.echo(f" {message}")
|
||||
except TimeoutError:
|
||||
continue
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
|
||||
# Start streaming task
|
||||
stream_task = asyncio.create_task(stream_logs())
|
||||
|
||||
try:
|
||||
await initial_loader.start()
|
||||
finally:
|
||||
stream_task.cancel()
|
||||
try:
|
||||
await stream_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
click.echo()
|
||||
click.echo("=" * 60)
|
||||
click.echo("AstrBot 启动中...")
|
||||
click.echo("=" * 60)
|
||||
|
||||
from astrbot.cli.banner import print_logo
|
||||
|
||||
print_logo()
|
||||
click.echo()
|
||||
|
||||
if backend_only:
|
||||
click.echo("[模式] 仅后端模式(无本地 Dashboard)")
|
||||
click.echo()
|
||||
click.echo("[提示] 可以通过以下方式访问 WebUI:")
|
||||
click.echo(" - 使用远程服务器的在线 Dashboard")
|
||||
click.echo(" - 地址: http://服务器IP:6185")
|
||||
click.echo()
|
||||
else:
|
||||
dashboard_url = f"http://{host or 'localhost'}:{port or '6185'}"
|
||||
click.echo("[模式] 完整模式(含本地 Dashboard)")
|
||||
click.echo()
|
||||
click.echo(f"[Dashboard] 请访问: {dashboard_url}")
|
||||
click.echo()
|
||||
click.echo("!" * 60)
|
||||
click.echo("安全提示:")
|
||||
click.echo(" HTTPS 前端只能安全连接 localhost 的 HTTP 后端")
|
||||
click.echo(" 不支持远程 + HTTP 后端(不安全)")
|
||||
click.echo("!" * 60)
|
||||
click.echo()
|
||||
|
||||
click.echo("正在启动服务...(日志输出中)")
|
||||
click.echo()
|
||||
|
||||
asyncio.run(run_with_logging())
|
||||
asyncio.run(run_astrbot(astrbot_root))
|
||||
except KeyboardInterrupt:
|
||||
click.echo("AstrBot has been shut down.")
|
||||
click.echo("AstrBot 已关闭...")
|
||||
except Timeout:
|
||||
raise click.ClickException(
|
||||
"Cannot acquire lock file. Please check if another instance is running",
|
||||
) from None
|
||||
raise click.ClickException("无法获取锁文件,请检查是否有其他实例正在运行")
|
||||
except Exception as e:
|
||||
# Keep original traceback visible for diagnostics
|
||||
raise click.ClickException(
|
||||
f"Runtime error: {e}\n{traceback.format_exc()}",
|
||||
) from e
|
||||
raise click.ClickException(f"运行时出现错误: {e}\n{traceback.format_exc()}")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,69 +0,0 @@
|
||||
import os
|
||||
import shutil
|
||||
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:
|
||||
"""Remove AstrBot files from the current root directory."""
|
||||
if os.environ.get("ASTRBOT_SYSTEMD") == "1":
|
||||
yes = True
|
||||
|
||||
dot_astrbot = astrbot_paths.root / ".astrbot"
|
||||
lock_file = astrbot_paths.root / "astrbot.lock"
|
||||
data_dir = astrbot_paths.data
|
||||
removable_paths: list[Path] = [dot_astrbot, lock_file]
|
||||
|
||||
if not keep_data:
|
||||
removable_paths.insert(0, data_dir)
|
||||
|
||||
# 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 keep_data:
|
||||
click.echo("Keeping data directory as requested.")
|
||||
|
||||
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,
|
||||
):
|
||||
removed_any = False
|
||||
for path in removable_paths:
|
||||
if not path.exists():
|
||||
continue
|
||||
removed_any = True
|
||||
if path.is_dir():
|
||||
click.echo(f"Removing directory: {path}")
|
||||
shutil.rmtree(path)
|
||||
else:
|
||||
click.echo(f"Removing file: {path}")
|
||||
path.unlink()
|
||||
|
||||
if removed_any:
|
||||
click.echo("AstrBot files removed successfully.")
|
||||
else:
|
||||
click.echo("No removable AstrBot files were found.")
|
||||
|
||||
# TODO: Consider adding an explicit `--service` cleanup mode instead of
|
||||
# touching systemd or other service managers during normal uninstall.
|
||||
# TODO: Consider adding package-manager-specific uninstall helpers once
|
||||
# the CLI can reliably detect the installation source.
|
||||
click.echo("uv: uv tool uninstall astrbot")
|
||||
click.echo("paru/yay: paru -R astrbot")
|
||||
@@ -1,278 +0,0 @@
|
||||
"""Internationalization support for AstrBot CLI.
|
||||
|
||||
This module provides i18n support with Chinese and English languages.
|
||||
Language is auto-detected from environment or can be set manually.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from enum import Enum
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
class Language(Enum):
|
||||
"""Supported languages."""
|
||||
|
||||
ZH = "zh"
|
||||
EN = "en"
|
||||
|
||||
|
||||
# Translation dictionaries
|
||||
_TRANSLATIONS: dict[Language, dict[str, str]] = {
|
||||
Language.ZH: {
|
||||
# CLI welcome and general
|
||||
"cli_welcome": "欢迎使用 AstrBot CLI!",
|
||||
"cli_version": "AstrBot CLI 版本: {version}",
|
||||
"cli_unknown_command": "未知命令: {command}",
|
||||
"cli_help_available": "使用 astrbot help --all 查看所有命令",
|
||||
# Dashboard commands
|
||||
"dashboard_bundled": "Dashboard 已打包在安装包中 - 跳过下载",
|
||||
"dashboard_not_installed": "Dashboard 未安装",
|
||||
"dashboard_install_confirm": "是否安装 Dashboard?",
|
||||
"dashboard_installing": "正在安装 Dashboard...",
|
||||
"dashboard_install_success": "Dashboard 安装成功",
|
||||
"dashboard_install_failed": "Dashboard 安装失败: {error}",
|
||||
"dashboard_not_needed": "Dashboard 不需要安装",
|
||||
"dashboard_declined": "Dashboard 安装已取消",
|
||||
"dashboard_already_up_to_date": "Dashboard 已是最新版本",
|
||||
"dashboard_version": "Dashboard 版本: {version}",
|
||||
"dashboard_download_failed": "Dashboard 下载失败: {error}",
|
||||
"dashboard_init_dir": "正在初始化 Dashboard 目录...",
|
||||
"dashboard_init_success": "Dashboard 初始化成功",
|
||||
# Plugin commands
|
||||
"plugin_installing": "正在安装插件: {name}",
|
||||
"plugin_install_success": "插件安装成功: {name}",
|
||||
"plugin_install_failed": "插件安装失败: {name}",
|
||||
"plugin_uninstall_confirm": "确定要卸载插件 {name} 吗?",
|
||||
"plugin_uninstall_success": "插件卸载成功: {name}",
|
||||
"plugin_uninstall_failed": "插件卸载失败: {name}",
|
||||
"plugin_list_empty": "未安装任何插件",
|
||||
"plugin_already_installed": "插件已安装: {name}",
|
||||
"plugin_not_found": "插件未找到: {name}",
|
||||
"plugin_already_exists": "插件已存在: {name}",
|
||||
"plugin_not_found_or_installed": "插件未找到或已安装: {name}",
|
||||
"plugin_uninstall_failed_ex": "插件卸载失败 {name}: {error}",
|
||||
"plugin_no_update_needed": "没有需要更新的插件",
|
||||
"plugin_found_update": "发现 {count} 个插件需要更新",
|
||||
"plugin_updating": "正在更新插件 {name}...",
|
||||
"plugin_search_no_result": "未找到匹配 '{query}' 的插件",
|
||||
"plugin_search_results": "搜索结果: '{query}'",
|
||||
# Config commands
|
||||
"config_show": "显示配置",
|
||||
"config_set_success": "配置项已更新: {key} = {value}",
|
||||
"config_set_failed": "配置项更新失败: {key}",
|
||||
"config_set_failed_ex": "设置配置失败: {error}",
|
||||
"config_get_success": "{key} = {value}",
|
||||
"config_get_not_found": "配置项未找到: {key}",
|
||||
"config_reset_confirm": "确定要重置所有配置吗?",
|
||||
"config_reset_success": "配置已重置",
|
||||
# Config validators
|
||||
"config_log_level_invalid": "日志级别必须是 DEBUG/INFO/WARNING/ERROR/CRITICAL 之一",
|
||||
"config_port_must_be_number": "端口必须是数字",
|
||||
"config_port_range_invalid": "端口必须在 1-65535 范围内",
|
||||
"config_username_empty": "用户名不能为空",
|
||||
"config_password_empty": "密码不能为空",
|
||||
"config_timezone_invalid": "无效的时区: {value}。请使用有效的 IANA 时区名称",
|
||||
"config_callback_invalid": "回调 API 基础路径必须以 http:// 或 https:// 开头",
|
||||
"config_key_unsupported": "不支持的配置项: {key}",
|
||||
"config_key_unknown": "未知的配置项: {key}",
|
||||
"config_updated": "配置已更新: {key}",
|
||||
# Init command
|
||||
"init_creating": "正在创建配置目录...",
|
||||
"init_created": "配置目录已创建: {path}",
|
||||
"init_copying": "正在复制配置文件...",
|
||||
"init_copied": "配置文件已复制",
|
||||
"init_success": "AstrBot 初始化完成!",
|
||||
"init_failed": "初始化失败: {error}",
|
||||
# Run command
|
||||
"run_starting": "正在启动 AstrBot...",
|
||||
"run_started": "AstrBot 已启动!",
|
||||
"run_backend_only": "以无界面模式启动",
|
||||
"run_failed": "启动失败: {error}",
|
||||
"run_stopped": "AstrBot 已停止",
|
||||
# Common
|
||||
"yes": "是",
|
||||
"no": "否",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"error": "错误",
|
||||
"success": "成功",
|
||||
"warning": "警告",
|
||||
"info": "信息",
|
||||
"loading": "加载中...",
|
||||
"done": "完成",
|
||||
"failed": "失败",
|
||||
"retry": "重试",
|
||||
"exit": "退出",
|
||||
"continue": "继续",
|
||||
},
|
||||
Language.EN: {
|
||||
# CLI welcome and general
|
||||
"cli_welcome": "Welcome to AstrBot CLI!",
|
||||
"cli_version": "AstrBot CLI version: {version}",
|
||||
"cli_unknown_command": "Unknown command: {command}",
|
||||
"cli_help_available": "Use astrbot help --all to see all commands",
|
||||
# Dashboard commands
|
||||
"dashboard_bundled": "Dashboard is bundled with the package - skipping download",
|
||||
"dashboard_not_installed": "Dashboard is not installed",
|
||||
"dashboard_install_confirm": "Install Dashboard?",
|
||||
"dashboard_installing": "Installing Dashboard...",
|
||||
"dashboard_install_success": "Dashboard installed successfully",
|
||||
"dashboard_install_failed": "Failed to install dashboard: {error}",
|
||||
"dashboard_not_needed": "Dashboard not needed",
|
||||
"dashboard_declined": "Dashboard installation declined.",
|
||||
"dashboard_already_up_to_date": "Dashboard is already up to date",
|
||||
"dashboard_version": "Dashboard version: {version}",
|
||||
"dashboard_download_failed": "Failed to download dashboard: {error}",
|
||||
"dashboard_init_dir": "Initializing dashboard directory...",
|
||||
"dashboard_init_success": "Dashboard initialized successfully",
|
||||
# Plugin commands
|
||||
"plugin_installing": "Installing plugin: {name}",
|
||||
"plugin_install_success": "Plugin installed successfully: {name}",
|
||||
"plugin_install_failed": "Failed to install plugin: {name}",
|
||||
"plugin_uninstall_confirm": "Uninstall plugin {name}?",
|
||||
"plugin_uninstall_success": "Plugin uninstalled successfully: {name}",
|
||||
"plugin_uninstall_failed": "Failed to uninstall plugin: {name}",
|
||||
"plugin_list_empty": "No plugins installed",
|
||||
"plugin_already_installed": "Plugin already installed: {name}",
|
||||
"plugin_not_found": "Plugin not found: {name}",
|
||||
"plugin_already_exists": "Plugin {name} already exists",
|
||||
"plugin_not_found_or_installed": "Plugin {name} not found or already installed",
|
||||
"plugin_uninstall_failed_ex": "Failed to uninstall plugin {name}: {error}",
|
||||
"plugin_no_update_needed": "No plugins need updating",
|
||||
"plugin_found_update": "Found {count} plugin(s) needing update",
|
||||
"plugin_updating": "Updating plugin {name}...",
|
||||
"plugin_search_no_result": "No plugins matching '{query}' found",
|
||||
"plugin_search_results": "Search results: '{query}'",
|
||||
# Config commands
|
||||
"config_show": "Show configuration",
|
||||
"config_set_success": "Configuration updated: {key} = {value}",
|
||||
"config_set_failed": "Failed to update configuration: {key}",
|
||||
"config_set_failed_ex": "Failed to set config: {error}",
|
||||
"config_get_success": "{key} = {value}",
|
||||
"config_get_not_found": "Configuration key not found: {key}",
|
||||
"config_reset_confirm": "Reset all configuration?",
|
||||
"config_reset_success": "Configuration reset",
|
||||
# Config validators
|
||||
"config_log_level_invalid": "Log level must be one of DEBUG/INFO/WARNING/ERROR/CRITICAL",
|
||||
"config_port_must_be_number": "Port must be a number",
|
||||
"config_port_range_invalid": "Port must be in range 1-65535",
|
||||
"config_username_empty": "Username cannot be empty",
|
||||
"config_password_empty": "Password cannot be empty",
|
||||
"config_timezone_invalid": "Invalid timezone: {value}. Please use a valid IANA timezone name",
|
||||
"config_callback_invalid": "Callback API base must start with http:// or https://",
|
||||
"config_key_unsupported": "Unsupported config key: {key}",
|
||||
"config_key_unknown": "Unknown config key: {key}",
|
||||
"config_updated": "Config updated: {key}",
|
||||
# Init command
|
||||
"init_creating": "Creating config directory...",
|
||||
"init_created": "Config directory created: {path}",
|
||||
"init_copying": "Copying config files...",
|
||||
"init_copied": "Config files copied",
|
||||
"init_success": "AstrBot initialized successfully!",
|
||||
"init_failed": "Initialization failed: {error}",
|
||||
# Run command
|
||||
"run_starting": "Starting AstrBot...",
|
||||
"run_started": "AstrBot started!",
|
||||
"run_backend_only": "Starting in backend-only mode",
|
||||
"run_failed": "Failed to start: {error}",
|
||||
"run_stopped": "AstrBot stopped",
|
||||
# Common
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
"warning": "Warning",
|
||||
"info": "Info",
|
||||
"loading": "Loading...",
|
||||
"done": "Done",
|
||||
"failed": "Failed",
|
||||
"retry": "Retry",
|
||||
"exit": "Exit",
|
||||
"continue": "Continue",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_current_language() -> Language:
|
||||
"""Get the current language based on environment or default.
|
||||
|
||||
Detection order:
|
||||
1. ASTRBOT_CLI_LANG environment variable (zh/en)
|
||||
2. LANG environment variable (if contains zh/cn)
|
||||
3. LC_ALL environment variable (if contains zh/cn)
|
||||
4. Default to Chinese (most users are Chinese)
|
||||
"""
|
||||
# Check explicit override first
|
||||
explicit = os.environ.get("ASTRBOT_CLI_LANG", "").lower()
|
||||
if explicit in ("zh", "en"):
|
||||
return Language.ZH if explicit == "zh" else Language.EN
|
||||
|
||||
# Check LANG/LC_ALL for Chinese
|
||||
for env_var in ("LANG", "LC_ALL"):
|
||||
lang = os.environ.get(env_var, "").lower()
|
||||
if "zh" in lang or "cn" in lang:
|
||||
return Language.ZH
|
||||
|
||||
# Default to Chinese for broader appeal
|
||||
return Language.ZH
|
||||
|
||||
|
||||
def set_language(lang: Language) -> None:
|
||||
"""Set the current language (clears all translation caches)."""
|
||||
get_current_language.cache_clear()
|
||||
_t_cached.cache_clear()
|
||||
# Set environment variable for persistence
|
||||
os.environ["ASTRBOT_CLI_LANG"] = lang.value
|
||||
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def _t_cached(key: str, lang: Language) -> str:
|
||||
"""Cached translation lookup."""
|
||||
return _TRANSLATIONS.get(lang, {}).get(key, key)
|
||||
|
||||
|
||||
def t(translation_key: str, **kwargs: str) -> str:
|
||||
"""Get translation for the given key in the current language.
|
||||
|
||||
Args:
|
||||
translation_key: Translation key (e.g., "cli_welcome", "plugin_installing")
|
||||
**kwargs: Format arguments for the translation string
|
||||
|
||||
Returns:
|
||||
Translated string, or the key itself if not found
|
||||
|
||||
"""
|
||||
result = _t_cached(translation_key, get_current_language())
|
||||
if kwargs:
|
||||
result = result.format(**kwargs)
|
||||
return result
|
||||
|
||||
|
||||
def tr(key: str, **kwargs: str) -> str:
|
||||
"""Get translation (alias for t())."""
|
||||
return t(key, **kwargs)
|
||||
|
||||
|
||||
class CLITranslations:
|
||||
"""Translation accessor class for CLI contexts.
|
||||
|
||||
Usage:
|
||||
translations = CLITranslations()
|
||||
print(translations.cli_welcome)
|
||||
print(translations.plugin_installing(name="my_plugin"))
|
||||
"""
|
||||
|
||||
def __getattr__(self, key: str) -> str:
|
||||
return t(key)
|
||||
|
||||
def __call__(self, key: str, **kwargs: str) -> str:
|
||||
return t(key, **kwargs)
|
||||
|
||||
|
||||
# Convenience instance
|
||||
translations = CLITranslations()
|
||||
@@ -1,12 +1,18 @@
|
||||
from .dashboard import DashboardManager
|
||||
from .basic import (
|
||||
check_astrbot_root,
|
||||
check_dashboard,
|
||||
get_astrbot_root,
|
||||
)
|
||||
from .plugin import PluginStatus, build_plug_list, get_git_repo, manage_plugin
|
||||
from .version_comparator import VersionComparator
|
||||
|
||||
__all__ = [
|
||||
"DashboardManager",
|
||||
"PluginStatus",
|
||||
"VersionComparator",
|
||||
"build_plug_list",
|
||||
"check_astrbot_root",
|
||||
"check_dashboard",
|
||||
"get_astrbot_root",
|
||||
"get_git_repo",
|
||||
"manage_plugin",
|
||||
]
|
||||
|
||||
@@ -2,12 +2,9 @@ from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
# Static assets bundled inside the installed wheel (built by hatch_build.py).
|
||||
_BUNDLED_DIST = Path(__file__).parent.parent.parent / "dashboard" / "dist"
|
||||
|
||||
|
||||
def check_astrbot_root(path: str | Path) -> bool:
|
||||
"""Check if the path is an AstrBot root directory"""
|
||||
"""检查路径是否为 AstrBot 根目录"""
|
||||
if not isinstance(path, Path):
|
||||
path = Path(path)
|
||||
if not path.exists() or not path.is_dir():
|
||||
@@ -18,47 +15,43 @@ def check_astrbot_root(path: str | Path) -> bool:
|
||||
|
||||
|
||||
def get_astrbot_root() -> Path:
|
||||
"""Get the AstrBot root directory path"""
|
||||
"""获取Astrbot根目录路径"""
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
async def check_dashboard(astrbot_root: Path) -> None:
|
||||
"""Check if the dashboard is installed"""
|
||||
"""检查是否安装了dashboard"""
|
||||
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.exists():
|
||||
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("Dashboard is not installed")
|
||||
click.echo("未安装管理面板")
|
||||
if click.confirm(
|
||||
"Install dashboard?",
|
||||
"是否安装管理面板?",
|
||||
default=True,
|
||||
abort=True,
|
||||
):
|
||||
click.echo("Installing dashboard...")
|
||||
click.echo("正在安装管理面板...")
|
||||
await download_dashboard(
|
||||
path="data/dashboard.zip",
|
||||
extract_path=str(astrbot_root),
|
||||
version=f"v{VERSION}",
|
||||
latest=False,
|
||||
)
|
||||
click.echo("Dashboard installed successfully")
|
||||
click.echo("管理面板安装完成")
|
||||
|
||||
case str():
|
||||
if VersionComparator.compare_version(VERSION, dashboard_version) <= 0:
|
||||
click.echo("Dashboard is already up to date")
|
||||
click.echo("管理面板已是最新版本")
|
||||
return
|
||||
try:
|
||||
version = dashboard_version.split("v")[1]
|
||||
click.echo(f"Dashboard version: {version}")
|
||||
click.echo(f"管理面板版本: {version}")
|
||||
await download_dashboard(
|
||||
path="data/dashboard.zip",
|
||||
extract_path=str(astrbot_root),
|
||||
@@ -66,10 +59,10 @@ async def check_dashboard(astrbot_root: Path) -> None:
|
||||
latest=False,
|
||||
)
|
||||
except Exception as e:
|
||||
click.echo(f"Failed to download dashboard: {e}")
|
||||
click.echo(f"下载管理面板失败: {e}")
|
||||
return
|
||||
except FileNotFoundError:
|
||||
click.echo("Initializing dashboard directory...")
|
||||
click.echo("初始化管理面板目录...")
|
||||
try:
|
||||
await download_dashboard(
|
||||
path=str(astrbot_root / "dashboard.zip"),
|
||||
@@ -77,7 +70,7 @@ async def check_dashboard(astrbot_root: Path) -> None:
|
||||
version=f"v{VERSION}",
|
||||
latest=False,
|
||||
)
|
||||
click.echo("Dashboard initialized successfully")
|
||||
click.echo("管理面板初始化完成")
|
||||
except Exception as e:
|
||||
click.echo(f"Failed to download dashboard: {e}")
|
||||
click.echo(f"下载管理面板失败: {e}")
|
||||
return
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import sys
|
||||
from importlib import resources
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from astrbot.cli.i18n import t
|
||||
|
||||
from .version_comparator import VersionComparator
|
||||
|
||||
|
||||
class DashboardManager:
|
||||
_bundled_dist = resources.files("astrbot") / "dashboard" / "dist"
|
||||
|
||||
async def ensure_installed(self, astrbot_root: Path) -> None:
|
||||
"""Ensure the dashboard assets are installed and up to date."""
|
||||
from astrbot.core.config.default import VERSION
|
||||
from astrbot.core.utils.io import download_dashboard, get_dashboard_version
|
||||
|
||||
if self._bundled_dist.is_dir():
|
||||
click.echo(t("dashboard_bundled"))
|
||||
return
|
||||
|
||||
try:
|
||||
dashboard_version = await get_dashboard_version()
|
||||
match dashboard_version:
|
||||
case None:
|
||||
click.echo(t("dashboard_not_installed"))
|
||||
# Skip interactive prompt in non-interactive environments
|
||||
if not sys.stdin.isatty():
|
||||
click.echo(t("dashboard_not_needed"))
|
||||
return
|
||||
if click.confirm(t("dashboard_install_confirm"), default=True):
|
||||
click.echo(t("dashboard_installing"))
|
||||
try:
|
||||
await download_dashboard(
|
||||
path="data/dashboard.zip",
|
||||
extract_path=str(astrbot_root / "data"),
|
||||
version=f"v{VERSION}",
|
||||
latest=False,
|
||||
)
|
||||
click.echo(t("dashboard_install_success"))
|
||||
except Exception as e:
|
||||
click.echo(t("dashboard_install_failed", error=str(e)))
|
||||
else:
|
||||
click.echo(t("dashboard_declined"))
|
||||
|
||||
case str():
|
||||
if (
|
||||
VersionComparator.compare_version(VERSION, dashboard_version)
|
||||
<= 0
|
||||
):
|
||||
click.echo(t("dashboard_already_up_to_date"))
|
||||
return
|
||||
try:
|
||||
version = dashboard_version.split("v")[1]
|
||||
click.echo(t("dashboard_version", version=version))
|
||||
await download_dashboard(
|
||||
path="data/dashboard.zip",
|
||||
extract_path=str(astrbot_root / "data"),
|
||||
version=f"v{VERSION}",
|
||||
latest=False,
|
||||
)
|
||||
except Exception as e:
|
||||
click.echo(t("dashboard_download_failed", error=str(e)))
|
||||
return
|
||||
except FileNotFoundError:
|
||||
click.echo(t("dashboard_init_dir"))
|
||||
try:
|
||||
await download_dashboard(
|
||||
path=str(astrbot_root / "data" / "dashboard.zip"),
|
||||
extract_path=str(astrbot_root / "data"),
|
||||
version=f"v{VERSION}",
|
||||
latest=False,
|
||||
)
|
||||
click.echo(t("dashboard_init_success"))
|
||||
except Exception as e:
|
||||
click.echo(t("dashboard_download_failed", error=str(e)))
|
||||
return
|
||||
@@ -3,7 +3,6 @@ import tempfile
|
||||
from enum import Enum
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from zipfile import ZipFile
|
||||
|
||||
import click
|
||||
@@ -14,26 +13,26 @@ from .version_comparator import VersionComparator
|
||||
|
||||
|
||||
class PluginStatus(str, Enum):
|
||||
INSTALLED = "installed"
|
||||
NEED_UPDATE = "needs-update"
|
||||
NOT_INSTALLED = "not-installed"
|
||||
NOT_PUBLISHED = "unpublished"
|
||||
INSTALLED = "已安装"
|
||||
NEED_UPDATE = "需更新"
|
||||
NOT_INSTALLED = "未安装"
|
||||
NOT_PUBLISHED = "未发布"
|
||||
|
||||
|
||||
def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None:
|
||||
"""Download code from a Git repository and extract to the specified path"""
|
||||
"""从 Git 仓库下载代码并解压到指定路径"""
|
||||
temp_dir = Path(tempfile.mkdtemp())
|
||||
try:
|
||||
# Parse repository info
|
||||
# 解析仓库信息
|
||||
repo_namespace = url.split("/")[-2:]
|
||||
author = repo_namespace[0]
|
||||
repo = repo_namespace[1]
|
||||
|
||||
# Try to get the latest release
|
||||
# 尝试获取最新的 release
|
||||
release_url = f"https://api.github.com/repos/{author}/{repo}/releases"
|
||||
try:
|
||||
with httpx.Client(
|
||||
proxy=proxy or None,
|
||||
proxy=proxy if proxy else None,
|
||||
follow_redirects=True,
|
||||
) as client:
|
||||
resp = client.get(release_url)
|
||||
@@ -41,23 +40,23 @@ def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None:
|
||||
releases = resp.json()
|
||||
|
||||
if releases:
|
||||
# Use the latest release
|
||||
# 使用最新的 release
|
||||
download_url = releases[0]["zipball_url"]
|
||||
else:
|
||||
# No release found, use default branch
|
||||
click.echo(f"Downloading {author}/{repo} from default branch")
|
||||
# 没有 release,使用默认分支
|
||||
click.echo(f"正在从默认分支下载 {author}/{repo}")
|
||||
download_url = f"https://github.com/{author}/{repo}/archive/refs/heads/master.zip"
|
||||
except Exception as e:
|
||||
click.echo(f"Failed to get release info: {e}. Using provided URL directly")
|
||||
click.echo(f"获取 release 信息失败: {e},将直接使用提供的 URL")
|
||||
download_url = url
|
||||
|
||||
# Apply proxy
|
||||
# 应用代理
|
||||
if proxy:
|
||||
download_url = f"{proxy}/{download_url}"
|
||||
|
||||
# Download and extract
|
||||
# 下载并解压
|
||||
with httpx.Client(
|
||||
proxy=proxy or None,
|
||||
proxy=proxy if proxy else None,
|
||||
follow_redirects=True,
|
||||
) as client:
|
||||
resp = client.get(download_url)
|
||||
@@ -66,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("Branch 'master' not found, trying 'main' branch")
|
||||
click.echo("master 分支不存在,尝试下载 main 分支")
|
||||
resp = client.get(alt_url)
|
||||
resp.raise_for_status()
|
||||
else:
|
||||
@@ -84,51 +83,48 @@ def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None:
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def load_yaml_metadata(plugin_dir: Path) -> dict[str, Any]:
|
||||
"""Load plugin metadata from metadata.yaml file
|
||||
def load_yaml_metadata(plugin_dir: Path) -> dict:
|
||||
"""从 metadata.yaml 文件加载插件元数据
|
||||
|
||||
Args:
|
||||
plugin_dir: Plugin directory path
|
||||
plugin_dir: 插件目录路径
|
||||
|
||||
Returns:
|
||||
dict: Dictionary containing metadata, or empty dict if loading fails
|
||||
dict: 包含元数据的字典,如果读取失败则返回空字典
|
||||
|
||||
"""
|
||||
yaml_path = plugin_dir / "metadata.yaml"
|
||||
if yaml_path.exists():
|
||||
try:
|
||||
data = yaml.safe_load(yaml_path.read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict):
|
||||
return dict[str, Any](data)
|
||||
return {}
|
||||
return yaml.safe_load(yaml_path.read_text(encoding="utf-8")) or {}
|
||||
except Exception as e:
|
||||
click.echo(f"Failed to read {yaml_path}: {e}", err=True)
|
||||
click.echo(f"读取 {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): Plugin directory path
|
||||
plugins_dir (Path): 插件目录路径
|
||||
|
||||
Returns:
|
||||
list: List of dicts containing plugin information
|
||||
list: 包含插件信息的字典列表
|
||||
|
||||
"""
|
||||
# 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
|
||||
|
||||
# Load metadata from metadata.yaml
|
||||
# 从 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"]
|
||||
):
|
||||
@@ -144,7 +140,7 @@ def build_plug_list(plugins_dir: Path) -> list:
|
||||
},
|
||||
)
|
||||
|
||||
# Get online plugin list
|
||||
# 获取在线插件列表
|
||||
online_plugins = []
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
@@ -164,35 +160,32 @@ def build_plug_list(plugins_dir: Path) -> list:
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
click.echo(f"Failed to get online plugin list: {e}", err=True)
|
||||
click.echo(f"获取在线插件列表失败: {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"]
|
||||
)
|
||||
if (
|
||||
VersionComparator.compare_version(
|
||||
local_plugin["version"] or "",
|
||||
online_plugin["version"] or "",
|
||||
local_plugin["version"],
|
||||
online_plugin["version"],
|
||||
)
|
||||
< 0
|
||||
):
|
||||
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):
|
||||
clean: dict[str, str] = {
|
||||
k: v for k, v in online_plugin.items() if v is not None
|
||||
}
|
||||
result.append(clean)
|
||||
result.append(online_plugin)
|
||||
|
||||
return result
|
||||
|
||||
@@ -203,19 +196,19 @@ def manage_plugin(
|
||||
is_update: bool = False,
|
||||
proxy: str | None = None,
|
||||
) -> None:
|
||||
"""Install or update a plugin
|
||||
"""安装或更新插件
|
||||
|
||||
Args:
|
||||
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 (dict): 插件信息字典
|
||||
plugins_dir (Path): 插件目录
|
||||
is_update (bool, optional): 是否为更新操作. 默认为 False
|
||||
proxy (str, optional): 代理服务器地址
|
||||
|
||||
"""
|
||||
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:
|
||||
@@ -223,13 +216,11 @@ 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 {plugin_name} is not installed and cannot be updated",
|
||||
)
|
||||
raise click.ClickException(f"插件 {plugin_name} 未安装,无法更新")
|
||||
|
||||
# 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:
|
||||
@@ -237,21 +228,19 @@ def manage_plugin(
|
||||
|
||||
try:
|
||||
click.echo(
|
||||
f"{'Updating' if is_update else 'Downloading'} plugin {plugin_name} from {repo_url}...",
|
||||
f"正在从 {repo_url} {'更新' if is_update else '下载'}插件 {plugin_name}...",
|
||||
)
|
||||
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 {plugin_name} {'updated' if is_update else 'installed'} successfully",
|
||||
)
|
||||
click.echo(f"插件 {plugin_name} {'更新' if is_update else '安装'}成功")
|
||||
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"Error {'updating' if is_update else 'installing'} plugin {plugin_name}: {e}",
|
||||
) from e
|
||||
f"{'更新' if is_update else '安装'}插件 {plugin_name} 时出错: {e}",
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Copied from astrbot.core.utils.version_comparator"""
|
||||
"""拷贝自 astrbot.core.utils.version_comparator"""
|
||||
|
||||
import re
|
||||
|
||||
@@ -6,11 +6,11 @@ import re
|
||||
class VersionComparator:
|
||||
@staticmethod
|
||||
def compare_version(v1: str, v2: str) -> int:
|
||||
"""Compare version numbers according to Semver semantics. Supports version numbers with more than 3 digits and handles pre-release tags.
|
||||
"""根据 Semver 语义版本规范来比较版本号的大小。支持不仅局限于 3 个数字的版本号,并处理预发布标签。
|
||||
|
||||
Reference: https://semver.org/
|
||||
参考: https://semver.org/lang/zh-CN/
|
||||
|
||||
Returns 1 if v1 > v2, -1 if v1 < v2, 0 if v1 == v2.
|
||||
返回 1 表示 v1 > v2,返回 -1 表示 v1 < v2,返回 0 表示 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) # Build metadata is ignored in comparison
|
||||
# buildmetadata = match.group(3) # 构建元数据在比较时忽略
|
||||
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 # Version without pre-release tag is higher than one with it
|
||||
return 1 # 没有预发布标签的版本高于有预发布标签的版本
|
||||
if v1_prerelease is not None and v2_prerelease is None:
|
||||
return -1 # Version with pre-release tag is lower than one without it
|
||||
return -1 # 有预发布标签的版本低于没有预发布标签的版本
|
||||
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):
|
||||
@@ -62,16 +62,19 @@ class VersionComparator:
|
||||
return -1
|
||||
if isinstance(p1, str) and isinstance(p2, int):
|
||||
return 1
|
||||
if (isinstance(p1, int) and isinstance(p2, int)) or (
|
||||
isinstance(p1, str) and isinstance(p2, str)
|
||||
):
|
||||
if isinstance(p1, int) and isinstance(p2, int):
|
||||
if p1 > p2:
|
||||
return 1
|
||||
if p1 < p2:
|
||||
return -1
|
||||
return 0 # Pre-release tags are identical
|
||||
elif isinstance(p1, str) and isinstance(p2, str):
|
||||
if p1 > p2:
|
||||
return 1
|
||||
if p1 < p2:
|
||||
return -1
|
||||
return 0 # 预发布标签完全相同
|
||||
|
||||
return 0 # Both numeric parts and pre-release tags are equal
|
||||
return 0 # 数字部分和预发布标签都相同
|
||||
|
||||
@staticmethod
|
||||
def _split_prerelease(prerelease):
|
||||
|
||||
@@ -4,59 +4,23 @@ 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 (
|
||||
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.pip_installer import PipInstaller
|
||||
from astrbot.core.utils.shared_preferences import SharedPreferences
|
||||
from astrbot.core.utils.t2i.renderer import HtmlRenderer
|
||||
|
||||
from .log import LogBroker, LogManager
|
||||
from .utils.astrbot_path import (
|
||||
get_astrbot_config_path,
|
||||
get_astrbot_data_path,
|
||||
get_astrbot_knowledge_base_path,
|
||||
get_astrbot_plugin_path,
|
||||
get_astrbot_site_packages_path,
|
||||
get_astrbot_skills_path,
|
||||
get_astrbot_temp_path,
|
||||
)
|
||||
from .log import LogBroker, LogManager # noqa
|
||||
from .utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
# Initialize required data directories eagerly so later agent/tool flows do not
|
||||
# fail on missing paths when the runtime root resolves to a fresh location.
|
||||
for required_dir in (
|
||||
get_astrbot_data_path(),
|
||||
get_astrbot_config_path(),
|
||||
get_astrbot_plugin_path(),
|
||||
get_astrbot_temp_path(),
|
||||
get_astrbot_knowledge_base_path(),
|
||||
get_astrbot_skills_path(),
|
||||
get_astrbot_site_packages_path(),
|
||||
):
|
||||
os.makedirs(required_dir, exist_ok=True)
|
||||
# 初始化数据存储文件夹
|
||||
os.makedirs(get_astrbot_data_path(), exist_ok=True)
|
||||
|
||||
DEMO_MODE = os.getenv("DEMO_MODE", "False").strip().lower() in ("true", "1", "t")
|
||||
DEMO_MODE = os.getenv("DEMO_MODE", False)
|
||||
|
||||
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,
|
||||
override_level=os.getenv("ASTRBOT_LOG_LEVEL"),
|
||||
)
|
||||
LogManager.configure_logger(logger, astrbot_config)
|
||||
LogManager.configure_trace_logger(astrbot_config)
|
||||
db_helper = SQLiteDatabase(DB_PATH)
|
||||
# 简单的偏好设置存储, 这里后续应该存储到数据库中, 一些部分可以存储到配置中
|
||||
@@ -67,17 +31,3 @@ pip_installer = PipInstaller(
|
||||
astrbot_config.get("pip_install_arg", ""),
|
||||
astrbot_config.get("pypi_index_url", None),
|
||||
)
|
||||
__all__ = [
|
||||
"DEMO_MODE",
|
||||
"AstrBotConfig",
|
||||
"LogBroker",
|
||||
"LogManager",
|
||||
"astrbot_config",
|
||||
"db_helper",
|
||||
"file_token_service",
|
||||
"html_renderer",
|
||||
"logger",
|
||||
"pip_installer",
|
||||
"sp",
|
||||
"t2i_base_url",
|
||||
]
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
||||
|
||||
from ...provider.modalities import (
|
||||
log_context_sanitize_stats,
|
||||
sanitize_contexts_by_modalities,
|
||||
)
|
||||
from ..message import Message
|
||||
from .token_counter import EstimateTokenCounter, TokenCounter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from astrbot import logger
|
||||
@@ -101,58 +96,83 @@ class TruncateByTurnsCompressor:
|
||||
return truncated_messages
|
||||
|
||||
|
||||
def _extract_system_messages(messages: list[Message]) -> list[Message]:
|
||||
"""Return the leading system messages from a message list."""
|
||||
result = []
|
||||
for msg in messages:
|
||||
if msg.role == "system":
|
||||
result.append(msg)
|
||||
else:
|
||||
def split_history(
|
||||
messages: list[Message], keep_recent: int
|
||||
) -> tuple[list[Message], list[Message], list[Message]]:
|
||||
"""Split the message list into system messages, messages to summarize, and recent messages.
|
||||
|
||||
Ensures that the split point is between complete user-assistant pairs to maintain conversation flow.
|
||||
|
||||
Args:
|
||||
messages: The original message list.
|
||||
keep_recent: The number of latest messages to keep.
|
||||
|
||||
Returns:
|
||||
tuple: (system_messages, messages_to_summarize, recent_messages)
|
||||
"""
|
||||
# keep the system messages
|
||||
first_non_system = 0
|
||||
for i, msg in enumerate(messages):
|
||||
if msg.role != "system":
|
||||
first_non_system = i
|
||||
break
|
||||
return result
|
||||
|
||||
system_messages = messages[:first_non_system]
|
||||
non_system_messages = messages[first_non_system:]
|
||||
|
||||
if len(non_system_messages) <= keep_recent:
|
||||
return system_messages, [], non_system_messages
|
||||
|
||||
# Find the split point, ensuring recent_messages starts with a user message
|
||||
# This maintains complete conversation turns
|
||||
split_index = len(non_system_messages) - keep_recent
|
||||
|
||||
# Search backward from split_index to find the first user message
|
||||
# This ensures recent_messages starts with a user message (complete turn)
|
||||
while split_index > 0 and non_system_messages[split_index].role != "user":
|
||||
# TODO: +=1 or -=1 ? calculate by tokens
|
||||
split_index -= 1
|
||||
|
||||
# If we couldn't find a user message, keep all messages as recent
|
||||
if split_index == 0:
|
||||
return system_messages, [], non_system_messages
|
||||
|
||||
messages_to_summarize = non_system_messages[:split_index]
|
||||
recent_messages = non_system_messages[split_index:]
|
||||
|
||||
return system_messages, messages_to_summarize, recent_messages
|
||||
|
||||
|
||||
class LLMSummaryCompressor:
|
||||
"""LLM-based summary compressor.
|
||||
Uses LLM to summarize old conversation history while keeping a recent token
|
||||
budget as exact context.
|
||||
Uses LLM to summarize the old conversation history, keeping the latest messages.
|
||||
"""
|
||||
|
||||
TASK_CONTINUATION_INSTRUCTION = (
|
||||
"If a task appears to be in progress, end the summary with the latest "
|
||||
"known result and the concrete next step to continue the task."
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
provider: "Provider",
|
||||
keep_recent_ratio: float = 0.15,
|
||||
keep_recent: int = 4,
|
||||
instruction_text: str | None = None,
|
||||
compression_threshold: float = 0.82,
|
||||
token_counter: TokenCounter | None = None,
|
||||
) -> None:
|
||||
"""Initialize the LLM summary compressor.
|
||||
|
||||
Args:
|
||||
provider: The LLM provider instance.
|
||||
keep_recent_ratio: Ratio of current context tokens to keep as recent
|
||||
exact context. Clamped to 0-0.3.
|
||||
keep_recent: The number of latest messages to keep (default: 4).
|
||||
instruction_text: Custom instruction for summary generation.
|
||||
compression_threshold: The compression trigger threshold (default: 0.82).
|
||||
"""
|
||||
self.provider = provider
|
||||
self.keep_recent_ratio = min(max(float(keep_recent_ratio), 0.0), 0.3)
|
||||
self.keep_recent = keep_recent
|
||||
self.compression_threshold = compression_threshold
|
||||
self.token_counter = token_counter or EstimateTokenCounter()
|
||||
|
||||
self.instruction_text = instruction_text or (
|
||||
"Based on our full conversation history, produce a concise summary of key takeaways and/or project progress.\n"
|
||||
"The primary goal of this summary is to enable seamless continuation of the work that follows.\n"
|
||||
"1. Systematically cover all core topics discussed and the final conclusion/outcome for each; clearly highlight the latest primary focus.\n"
|
||||
"2. If any tools were used, summarize tool usage (total call count) and extract the most valuable insights from tool outputs.\n"
|
||||
"3. If any materials (files, documents, code, references) were read during the conversation that may be helpful for subsequent work, list each one with its scope and path.\n"
|
||||
"4. If there was an initial user goal, state it first and describe the current progress/status.\n"
|
||||
"5. Write the summary in the user's language.\n"
|
||||
"3. If there was an initial user goal, state it first and describe the current progress/status.\n"
|
||||
"4. Write the summary in the user's language.\n"
|
||||
)
|
||||
|
||||
def should_compress(
|
||||
@@ -173,120 +193,39 @@ class LLMSummaryCompressor:
|
||||
usage_rate = current_tokens / max_tokens
|
||||
return usage_rate > self.compression_threshold
|
||||
|
||||
def _split_recent_rounds_by_token_ratio(
|
||||
self,
|
||||
rounds: list[list[Message]],
|
||||
total_tokens: int,
|
||||
) -> tuple[list[list[Message]], list[list[Message]]]:
|
||||
"""Split rounds into summarised history and exact recent context.
|
||||
|
||||
The token budget is computed from the current context token count and
|
||||
`keep_recent_ratio`, then floored by `int(...)`. Mapping that budget to
|
||||
rounds is round-granular: a positive ratio always preserves the latest
|
||||
whole round, even if that round itself exceeds the budget. Earlier
|
||||
rounds are added only while the accumulated recent rounds stay within
|
||||
the budget. No round is split.
|
||||
"""
|
||||
if not rounds or self.keep_recent_ratio <= 0 or total_tokens <= 0:
|
||||
return rounds, []
|
||||
|
||||
budget = max(1, int(total_tokens * self.keep_recent_ratio))
|
||||
used = 0
|
||||
recent_start = len(rounds)
|
||||
|
||||
for idx in range(len(rounds) - 1, -1, -1):
|
||||
round_tokens = self.token_counter.count_tokens(rounds[idx])
|
||||
if used > 0 and used + round_tokens > budget:
|
||||
break
|
||||
used += round_tokens
|
||||
recent_start = idx
|
||||
|
||||
return rounds[:recent_start], rounds[recent_start:]
|
||||
|
||||
async def __call__(self, messages: list[Message]) -> list[Message]:
|
||||
"""Use LLM to generate a summary of the conversation history.
|
||||
|
||||
Uses round-based splitting to preserve user-assistant turn boundaries.
|
||||
On LLM failure, returns the original messages unchanged (caller should
|
||||
fall back to truncation).
|
||||
Process:
|
||||
1. Divide messages: keep the system message and the latest N messages.
|
||||
2. Send the old messages + the instruction message to the LLM.
|
||||
3. Reconstruct the message list: [system message, summary message, latest messages].
|
||||
"""
|
||||
from .round_utils import split_into_rounds
|
||||
if len(messages) <= self.keep_recent + 1:
|
||||
return messages
|
||||
|
||||
rounds = split_into_rounds(messages)
|
||||
message_rounds = [
|
||||
[seg for seg in rnd if isinstance(seg, Message)] for rnd in rounds
|
||||
]
|
||||
total_tokens = self.token_counter.count_tokens(messages)
|
||||
old_rounds, recent_rounds = self._split_recent_rounds_by_token_ratio(
|
||||
message_rounds,
|
||||
total_tokens,
|
||||
system_messages, messages_to_summarize, recent_messages = split_history(
|
||||
messages, self.keep_recent
|
||||
)
|
||||
|
||||
# The latest user message is the active request. Keep its whole round
|
||||
# exact even when the ratio is 0 or the ratio budget would otherwise
|
||||
# summarize every round.
|
||||
if messages and messages[-1].role == "user" and old_rounds:
|
||||
latest_old_round = old_rounds[-1]
|
||||
if latest_old_round and latest_old_round[-1] is messages[-1]:
|
||||
old_rounds = old_rounds[:-1]
|
||||
recent_rounds = [latest_old_round, *recent_rounds]
|
||||
if not messages_to_summarize:
|
||||
return messages
|
||||
|
||||
if not old_rounds:
|
||||
if recent_rounds and messages and messages[-1].role == "user":
|
||||
return messages
|
||||
old_rounds = message_rounds
|
||||
recent_rounds = []
|
||||
# build payload
|
||||
instruction_message = Message(role="user", content=self.instruction_text)
|
||||
llm_payload = messages_to_summarize + [instruction_message]
|
||||
|
||||
summary_contexts = [msg for rnd in old_rounds for msg in rnd]
|
||||
if not any(msg.role != "system" for msg in summary_contexts):
|
||||
if recent_rounds and messages and messages[-1].role == "user":
|
||||
return messages
|
||||
old_rounds = message_rounds
|
||||
recent_rounds = []
|
||||
summary_contexts = [msg for rnd in old_rounds for msg in rnd]
|
||||
if not any(msg.role != "system" for msg in summary_contexts):
|
||||
return messages
|
||||
|
||||
if summary_contexts[-1].role != "assistant":
|
||||
summary_contexts.append(
|
||||
Message(
|
||||
role="assistant",
|
||||
content="Acknowledged.",
|
||||
)
|
||||
)
|
||||
summary_contexts.append(
|
||||
Message(
|
||||
role="user",
|
||||
content=(
|
||||
"Generate a summary of our previous conversation history.\n"
|
||||
f"<extra_instruction>\n{self.instruction_text}\n\n"
|
||||
f"{self.TASK_CONTINUATION_INSTRUCTION}</extra_instruction>\n"
|
||||
"Respond ONLY with the summary content, without any additional text or formatting."
|
||||
),
|
||||
)
|
||||
)
|
||||
sanitized_summary_contexts, sanitize_stats = sanitize_contexts_by_modalities(
|
||||
summary_contexts,
|
||||
self.provider.provider_config.get("modalities", None),
|
||||
)
|
||||
log_context_sanitize_stats(sanitize_stats)
|
||||
|
||||
# Generate summary
|
||||
# generate summary
|
||||
try:
|
||||
response = await self.provider.text_chat(
|
||||
contexts=sanitized_summary_contexts,
|
||||
)
|
||||
summary_content = (response.completion_text or "").strip()
|
||||
response = await self.provider.text_chat(contexts=llm_payload)
|
||||
summary_content = response.completion_text
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate summary: {e}")
|
||||
return messages
|
||||
|
||||
if not summary_content:
|
||||
logger.warning("LLM context compression returned an empty summary.")
|
||||
return messages
|
||||
|
||||
# Build result: system messages + summary pair + recent rounds
|
||||
result = _extract_system_messages(messages)
|
||||
# build result
|
||||
result = []
|
||||
result.extend(system_messages)
|
||||
|
||||
result.append(
|
||||
Message(
|
||||
@@ -301,10 +240,6 @@ class LLMSummaryCompressor:
|
||||
)
|
||||
)
|
||||
|
||||
# Flatten recent rounds back to message list
|
||||
for rnd in recent_rounds:
|
||||
for seg in rnd:
|
||||
if isinstance(seg, Message):
|
||||
result.append(seg)
|
||||
result.extend(recent_messages)
|
||||
|
||||
return result
|
||||
|
||||
@@ -25,8 +25,8 @@ class ContextConfig:
|
||||
"""
|
||||
llm_compress_instruction: str | None = None
|
||||
"""Instruction prompt for LLM-based compression."""
|
||||
llm_compress_keep_recent_ratio: float = 0.15
|
||||
"""Percent of current context tokens to keep as exact recent context during LLM-based compression."""
|
||||
llm_compress_keep_recent: int = 0
|
||||
"""Number of recent messages to keep during LLM-based compression."""
|
||||
llm_compress_provider: "Provider | None" = None
|
||||
"""LLM provider used for compression tasks. If None, truncation strategy is used."""
|
||||
custom_token_counter: TokenCounter | None = None
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from astrbot import logger
|
||||
from astrbot.core.agent.message import Message
|
||||
|
||||
from ..message import Message
|
||||
from .compressor import LLMSummaryCompressor, TruncateByTurnsCompressor
|
||||
from .config import ContextConfig
|
||||
from .token_counter import EstimateTokenCounter
|
||||
@@ -22,7 +22,6 @@ class ContextManager:
|
||||
|
||||
Args:
|
||||
config: The context configuration.
|
||||
|
||||
"""
|
||||
self.config = config
|
||||
|
||||
@@ -34,19 +33,16 @@ class ContextManager:
|
||||
elif config.llm_compress_provider:
|
||||
self.compressor = LLMSummaryCompressor(
|
||||
provider=config.llm_compress_provider,
|
||||
keep_recent_ratio=config.llm_compress_keep_recent_ratio,
|
||||
keep_recent=config.llm_compress_keep_recent,
|
||||
instruction_text=config.llm_compress_instruction,
|
||||
token_counter=self.token_counter,
|
||||
)
|
||||
else:
|
||||
self.compressor = TruncateByTurnsCompressor(
|
||||
truncate_turns=config.truncate_turns,
|
||||
truncate_turns=config.truncate_turns
|
||||
)
|
||||
|
||||
async def process(
|
||||
self,
|
||||
messages: list[Message],
|
||||
trusted_token_usage: int = 0,
|
||||
self, messages: list[Message], trusted_token_usage: int = 0
|
||||
) -> list[Message]:
|
||||
"""Process the messages.
|
||||
|
||||
@@ -55,7 +51,6 @@ class ContextManager:
|
||||
|
||||
Returns:
|
||||
The processed message list.
|
||||
|
||||
"""
|
||||
try:
|
||||
result = messages
|
||||
@@ -71,14 +66,11 @@ class ContextManager:
|
||||
# 2. 基于 token 的压缩
|
||||
if self.config.max_context_tokens > 0:
|
||||
total_tokens = self.token_counter.count_tokens(
|
||||
result,
|
||||
trusted_token_usage,
|
||||
result, trusted_token_usage
|
||||
)
|
||||
|
||||
if self.compressor.should_compress(
|
||||
result,
|
||||
total_tokens,
|
||||
self.config.max_context_tokens,
|
||||
result, total_tokens, self.config.max_context_tokens
|
||||
):
|
||||
result = await self._run_compression(result, total_tokens)
|
||||
|
||||
@@ -88,11 +80,10 @@ class ContextManager:
|
||||
return messages
|
||||
|
||||
async def _run_compression(
|
||||
self,
|
||||
messages: list[Message],
|
||||
prev_tokens: int,
|
||||
self, messages: list[Message], prev_tokens: int
|
||||
) -> list[Message]:
|
||||
"""Compress/truncate the messages.
|
||||
"""
|
||||
Compress/truncate the messages.
|
||||
|
||||
Args:
|
||||
messages: The original message list.
|
||||
@@ -100,7 +91,6 @@ class ContextManager:
|
||||
|
||||
Returns:
|
||||
The compressed/truncated message list.
|
||||
|
||||
"""
|
||||
logger.debug("Compress triggered, starting compression...")
|
||||
|
||||
@@ -119,12 +109,10 @@ class ContextManager:
|
||||
|
||||
# last check
|
||||
if self.compressor.should_compress(
|
||||
messages,
|
||||
tokens_after_summary,
|
||||
self.config.max_context_tokens,
|
||||
messages, tokens_after_summary, self.config.max_context_tokens
|
||||
):
|
||||
logger.info(
|
||||
"Context still exceeds max tokens after compression, applying halving truncation...",
|
||||
"Context still exceeds max tokens after compression, applying halving truncation..."
|
||||
)
|
||||
# still need compress, truncate by half
|
||||
messages = self.truncator.truncate_by_halving(messages)
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
"""Round-based utilities shared by LTM compaction and LLMSummaryCompressor."""
|
||||
|
||||
import json
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from ..message import ContentPart, Message, ToolCall
|
||||
|
||||
RoundSegment = dict[str, Any] | Message
|
||||
|
||||
|
||||
def _segment_role(seg: RoundSegment) -> str:
|
||||
if isinstance(seg, Message):
|
||||
return seg.role
|
||||
return str(seg.get("role", "?"))
|
||||
|
||||
|
||||
def split_into_rounds(
|
||||
contexts: Sequence[RoundSegment],
|
||||
) -> list[list[RoundSegment]]:
|
||||
"""Split a flat contexts list into logical rounds.
|
||||
|
||||
A round begins at a ``user`` segment and includes all subsequent
|
||||
``assistant`` / ``tool`` segments until the next ``user`` segment.
|
||||
"""
|
||||
rounds: list[list[RoundSegment]] = []
|
||||
current: list[RoundSegment] = []
|
||||
for seg in contexts:
|
||||
if _segment_role(seg) == "user" and current:
|
||||
rounds.append(current)
|
||||
current = []
|
||||
current.append(seg)
|
||||
if current:
|
||||
rounds.append(current)
|
||||
return rounds
|
||||
|
||||
|
||||
def _content_to_text(content: Any) -> str:
|
||||
if isinstance(content, list):
|
||||
normalized = [
|
||||
part.model_dump_for_context() if isinstance(part, ContentPart) else part
|
||||
for part in content
|
||||
]
|
||||
return json.dumps(normalized, ensure_ascii=False)
|
||||
if isinstance(content, ContentPart):
|
||||
return json.dumps(content.model_dump_for_context(), ensure_ascii=False)
|
||||
return str(content or "")
|
||||
|
||||
|
||||
def _segment_content(seg: RoundSegment) -> Any:
|
||||
if isinstance(seg, Message):
|
||||
if seg.content is not None:
|
||||
return seg.content
|
||||
if seg.tool_calls:
|
||||
return [
|
||||
tc.model_dump() if isinstance(tc, ToolCall) else tc
|
||||
for tc in seg.tool_calls
|
||||
]
|
||||
return ""
|
||||
return seg.get("content") or seg.get("tool_calls") or ""
|
||||
|
||||
|
||||
def rounds_to_text(rounds: list[list[RoundSegment]]) -> str:
|
||||
"""Render rounds into a plain-text string for LLM summarisation."""
|
||||
lines: list[str] = []
|
||||
for i, rnd in enumerate(rounds, 1):
|
||||
lines.append(f"--- Round {i} ---")
|
||||
for seg in rnd:
|
||||
role = _segment_role(seg)
|
||||
content = _content_to_text(_segment_content(seg))
|
||||
lines.append(f"[{role}] {content}")
|
||||
return "\n".join(lines)
|
||||
@@ -1,25 +1,18 @@
|
||||
import json
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from astrbot.core.agent.message import (
|
||||
AudioURLPart,
|
||||
ImageURLPart,
|
||||
Message,
|
||||
TextPart,
|
||||
ThinkPart,
|
||||
)
|
||||
from ..message import Message, TextPart
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TokenCounter(Protocol):
|
||||
"""Protocol for token counters.
|
||||
"""
|
||||
Protocol for token counters.
|
||||
Provides an interface for counting tokens in message lists.
|
||||
"""
|
||||
|
||||
def count_tokens(
|
||||
self,
|
||||
messages: list[Message],
|
||||
trusted_token_usage: int = 0,
|
||||
self, messages: list[Message], trusted_token_usage: int = 0
|
||||
) -> int:
|
||||
"""Count the total tokens in the message list.
|
||||
|
||||
@@ -31,30 +24,17 @@ class TokenCounter(Protocol):
|
||||
|
||||
Returns:
|
||||
The total token count.
|
||||
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# 图片/音频 token 开销估算值,参考 OpenAI vision pricing:
|
||||
# low-res ~85 tokens, high-res ~170 per 512px tile, 通常几百到上千。
|
||||
# 这里取一个保守中位数,宁可偏高触发压缩也不要偏低导致 API 报错。
|
||||
IMAGE_TOKEN_ESTIMATE = 765
|
||||
AUDIO_TOKEN_ESTIMATE = 500
|
||||
|
||||
|
||||
class EstimateTokenCounter:
|
||||
"""Estimate token counter implementation.
|
||||
Provides a simple estimation of token count based on character types.
|
||||
|
||||
Supports multimodal content: images, audio, and thinking parts
|
||||
are all counted so that the context compressor can trigger in time.
|
||||
"""
|
||||
|
||||
def count_tokens(
|
||||
self,
|
||||
messages: list[Message],
|
||||
trusted_token_usage: int = 0,
|
||||
self, messages: list[Message], trusted_token_usage: int = 0
|
||||
) -> int:
|
||||
if trusted_token_usage > 0:
|
||||
return trusted_token_usage
|
||||
@@ -65,16 +45,12 @@ class EstimateTokenCounter:
|
||||
if isinstance(content, str):
|
||||
total += self._estimate_tokens(content)
|
||||
elif isinstance(content, list):
|
||||
# 处理多模态内容
|
||||
for part in content:
|
||||
if isinstance(part, TextPart):
|
||||
total += self._estimate_tokens(part.text)
|
||||
elif isinstance(part, ThinkPart):
|
||||
total += self._estimate_tokens(part.think)
|
||||
elif isinstance(part, ImageURLPart):
|
||||
total += IMAGE_TOKEN_ESTIMATE
|
||||
elif isinstance(part, AudioURLPart):
|
||||
total += AUDIO_TOKEN_ESTIMATE
|
||||
|
||||
# 处理 Tool Calls
|
||||
if msg.tool_calls:
|
||||
for tc in msg.tool_calls:
|
||||
tc_str = json.dumps(tc if isinstance(tc, dict) else tc.model_dump())
|
||||
|
||||
@@ -1,126 +1,22 @@
|
||||
from astrbot.core.agent.message import Message
|
||||
from ..message import Message
|
||||
|
||||
|
||||
class ContextTruncator:
|
||||
"""Context truncator."""
|
||||
|
||||
def _has_tool_calls(self, message: Message) -> bool:
|
||||
"""Check if a message contains tool calls."""
|
||||
return (
|
||||
message.role == "assistant"
|
||||
and message.tool_calls is not None
|
||||
and len(message.tool_calls) > 0
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _split_system_rest(
|
||||
messages: list[Message],
|
||||
) -> tuple[list[Message], list[Message]]:
|
||||
"""Split messages into system messages and the rest.
|
||||
|
||||
Returns:
|
||||
tuple: (system_messages, non_system_messages)
|
||||
|
||||
"""
|
||||
first_non_system = 0
|
||||
for i, msg in enumerate(messages):
|
||||
if msg.role != "system":
|
||||
first_non_system = i
|
||||
break
|
||||
return messages[:first_non_system], messages[first_non_system:]
|
||||
|
||||
@staticmethod
|
||||
def _ensure_user_message(
|
||||
system_messages: list[Message],
|
||||
truncated: list[Message],
|
||||
original_messages: list[Message],
|
||||
) -> list[Message]:
|
||||
"""Ensure the result always contains a `user` message immediately after
|
||||
system messages, as required by some LLM APIs.
|
||||
|
||||
Optimization strategy:
|
||||
- If `truncated` already begins with a `user` message, return it as-is.
|
||||
- If a `user` message exists later in `truncated`, move that message to
|
||||
be the first non-system message while preserving the relative order of
|
||||
the remaining truncated messages (without mutating the original list).
|
||||
- Otherwise, fall back to the first `user` message from
|
||||
`original_messages`.
|
||||
This reduces unnecessary duplication and ensures the required ordering.
|
||||
"""
|
||||
if truncated and truncated[0].role == "user":
|
||||
return system_messages + truncated
|
||||
|
||||
# If a user message exists inside the truncated list, promote it to the front.
|
||||
index_in_truncated = next(
|
||||
(i for i, m in enumerate(truncated) if m.role == "user"),
|
||||
None,
|
||||
)
|
||||
if index_in_truncated is not None:
|
||||
# Build a new truncated list that places the found user message first,
|
||||
# preserving the order of the other messages and avoiding in-place mutation.
|
||||
user_msg = truncated[index_in_truncated]
|
||||
new_truncated = [
|
||||
user_msg,
|
||||
*truncated[:index_in_truncated],
|
||||
*truncated[index_in_truncated + 1 :],
|
||||
]
|
||||
return system_messages + new_truncated
|
||||
|
||||
# Fallback: find the first user message in the original messages.
|
||||
first_user = next((m for m in original_messages if m.role == "user"), None)
|
||||
if first_user is None:
|
||||
# No user messages at all; return system messages + whatever was truncated.
|
||||
return system_messages + truncated
|
||||
|
||||
return [*system_messages, first_user, *truncated]
|
||||
|
||||
def fix_messages(self, messages: list[Message]) -> list[Message]:
|
||||
"""Fix the message list to ensure the validity of tool call and tool response pairing.
|
||||
|
||||
This method ensures that:
|
||||
1. Each `tool` message is preceded by an `assistant` message containing `tool_calls`.
|
||||
2. Each `assistant` message containing `tool_calls` is followed by corresponding `
|
||||
|
||||
This is a requirement of the OpenAI Chat Completions API specification (Gemini enforces this strictly).
|
||||
"""
|
||||
if not messages:
|
||||
return messages
|
||||
|
||||
fixed_messages: list[Message] = []
|
||||
pending_assistant: Message | None = None
|
||||
pending_tools: list[Message] = []
|
||||
|
||||
def flush_pending_if_valid() -> None:
|
||||
nonlocal pending_assistant, pending_tools
|
||||
if pending_assistant is not None and pending_tools:
|
||||
fixed_messages.append(pending_assistant)
|
||||
fixed_messages.extend(pending_tools)
|
||||
pending_assistant = None
|
||||
pending_tools = []
|
||||
|
||||
for msg in messages:
|
||||
if msg.role == "tool":
|
||||
# Only record tool responses when there is a pending assistant(tool_calls)
|
||||
if pending_assistant is not None:
|
||||
pending_tools.append(msg)
|
||||
# Isolated tool messages without a preceding assistant(tool_calls) are ignored
|
||||
continue
|
||||
|
||||
if self._has_tool_calls(msg):
|
||||
# When encountering a new assistant(tool_calls), first process the old pending chain
|
||||
flush_pending_if_valid()
|
||||
pending_assistant = msg
|
||||
continue
|
||||
|
||||
# Non-tool messages that do not contain tool_calls will break the pending chain.
|
||||
# Flush any pending chain first, then append the current message normally.
|
||||
flush_pending_if_valid()
|
||||
fixed_messages.append(msg)
|
||||
|
||||
# Flush the last pending chain at the end,
|
||||
# ensuring that any remaining valid assistant(tool_calls) and its tools are included in the final list.
|
||||
flush_pending_if_valid()
|
||||
|
||||
fixed_messages = []
|
||||
for message in messages:
|
||||
if message.role == "tool":
|
||||
# tool block 前面必须要有 user 和 assistant block
|
||||
if len(fixed_messages) < 2:
|
||||
# 这种情况可能是上下文被截断导致的
|
||||
# 我们直接将之前的上下文都清空
|
||||
fixed_messages = []
|
||||
else:
|
||||
fixed_messages.append(message)
|
||||
else:
|
||||
fixed_messages.append(message)
|
||||
return fixed_messages
|
||||
|
||||
def truncate_by_turns(
|
||||
@@ -129,23 +25,29 @@ class ContextTruncator:
|
||||
keep_most_recent_turns: int,
|
||||
drop_turns: int = 1,
|
||||
) -> list[Message]:
|
||||
"""Turn-based truncation strategy, which drops the oldest turns while keeping the most recent N turns.
|
||||
A turn consists of a user message and an assistant message.
|
||||
This method ensures that the truncated context list conforms to OpenAI's context format.
|
||||
"""截断上下文列表,确保不超过最大长度。
|
||||
一个 turn 包含一个 user 消息和一个 assistant 消息。
|
||||
这个方法会保证截断后的上下文列表符合 OpenAI 的上下文格式。
|
||||
|
||||
Args:
|
||||
messages: The original list of messages in the context.
|
||||
keep_most_recent_turns: The number of most recent turns to keep. If set to -1, it means keeping all turns (no truncation).
|
||||
drop_turns: The number of turns to drop from the beginning.
|
||||
messages: 上下文列表
|
||||
keep_most_recent_turns: 保留最近的对话轮数
|
||||
drop_turns: 一次性丢弃的对话轮数
|
||||
|
||||
Returns:
|
||||
The truncated list of messages.
|
||||
|
||||
截断后的上下文列表
|
||||
"""
|
||||
if keep_most_recent_turns == -1:
|
||||
return messages
|
||||
|
||||
system_messages, non_system_messages = self._split_system_rest(messages)
|
||||
first_non_system = 0
|
||||
for i, msg in enumerate(messages):
|
||||
if msg.role != "system":
|
||||
first_non_system = i
|
||||
break
|
||||
|
||||
system_messages = messages[:first_non_system]
|
||||
non_system_messages = messages[first_non_system:]
|
||||
|
||||
if len(non_system_messages) // 2 <= keep_most_recent_turns:
|
||||
return messages
|
||||
@@ -156,7 +58,7 @@ class ContextTruncator:
|
||||
else:
|
||||
truncated_contexts = non_system_messages[-num_to_keep * 2 :]
|
||||
|
||||
# Find the first user message
|
||||
# 找到第一个 role 为 user 的索引,确保上下文格式正确
|
||||
index = next(
|
||||
(i for i, item in enumerate(truncated_contexts) if item.role == "user"),
|
||||
None,
|
||||
@@ -164,11 +66,8 @@ class ContextTruncator:
|
||||
if index is not None and index > 0:
|
||||
truncated_contexts = truncated_contexts[index:]
|
||||
|
||||
result = self._ensure_user_message(
|
||||
system_messages,
|
||||
truncated_contexts,
|
||||
messages,
|
||||
)
|
||||
result = system_messages + truncated_contexts
|
||||
|
||||
return self.fix_messages(result)
|
||||
|
||||
def truncate_by_dropping_oldest_turns(
|
||||
@@ -176,41 +75,53 @@ class ContextTruncator:
|
||||
messages: list[Message],
|
||||
drop_turns: int = 1,
|
||||
) -> list[Message]:
|
||||
"""Drop the oldest N turns, regardless of the number of turns to keep."""
|
||||
"""丢弃最旧的 N 个对话轮次。"""
|
||||
if drop_turns <= 0:
|
||||
return messages
|
||||
|
||||
system_messages, non_system_messages = self._split_system_rest(messages)
|
||||
first_non_system = 0
|
||||
for i, msg in enumerate(messages):
|
||||
if msg.role != "system":
|
||||
first_non_system = i
|
||||
break
|
||||
|
||||
system_messages = messages[:first_non_system]
|
||||
non_system_messages = messages[first_non_system:]
|
||||
|
||||
if len(non_system_messages) // 2 <= drop_turns:
|
||||
truncated_non_system = []
|
||||
else:
|
||||
truncated_non_system = non_system_messages[drop_turns * 2 :]
|
||||
|
||||
# Find the first user message
|
||||
index = next(
|
||||
(i for i, item in enumerate(truncated_non_system) if item.role == "user"),
|
||||
None,
|
||||
)
|
||||
if index is not None:
|
||||
truncated_non_system = truncated_non_system[index:]
|
||||
elif truncated_non_system:
|
||||
truncated_non_system = []
|
||||
|
||||
result = system_messages + truncated_non_system
|
||||
|
||||
result = self._ensure_user_message(
|
||||
system_messages,
|
||||
truncated_non_system,
|
||||
messages,
|
||||
)
|
||||
return self.fix_messages(result)
|
||||
|
||||
def truncate_by_halving(
|
||||
self,
|
||||
messages: list[Message],
|
||||
) -> list[Message]:
|
||||
"""Halve the number of messages, keeping the most recent ones."""
|
||||
"""对半砍策略,删除 50% 的消息"""
|
||||
if len(messages) <= 2:
|
||||
return messages
|
||||
|
||||
system_messages, non_system_messages = self._split_system_rest(messages)
|
||||
first_non_system = 0
|
||||
for i, msg in enumerate(messages):
|
||||
if msg.role != "system":
|
||||
first_non_system = i
|
||||
break
|
||||
|
||||
system_messages = messages[:first_non_system]
|
||||
non_system_messages = messages[first_non_system:]
|
||||
|
||||
messages_to_delete = len(non_system_messages) // 2
|
||||
if messages_to_delete == 0:
|
||||
@@ -218,7 +129,6 @@ class ContextTruncator:
|
||||
|
||||
truncated_non_system = non_system_messages[messages_to_delete:]
|
||||
|
||||
# Find the first user message
|
||||
index = next(
|
||||
(i for i, item in enumerate(truncated_non_system) if item.role == "user"),
|
||||
None,
|
||||
@@ -226,9 +136,6 @@ class ContextTruncator:
|
||||
if index is not None:
|
||||
truncated_non_system = truncated_non_system[index:]
|
||||
|
||||
result = self._ensure_user_message(
|
||||
system_messages,
|
||||
truncated_non_system,
|
||||
messages,
|
||||
)
|
||||
result = system_messages + truncated_non_system
|
||||
|
||||
return self.fix_messages(result)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user