mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-02 02:30:16 +08:00
Compare commits
2 Commits
dev
...
feat/sendt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8faaa4b2be | ||
|
|
7f5bd942b3 |
@@ -16,11 +16,8 @@ venv*/
|
||||
ENV/
|
||||
.conda/
|
||||
dashboard/
|
||||
!astrbot/dashboard/
|
||||
!astrbot/dashboard/dist/
|
||||
!astrbot/dashboard/dist/**
|
||||
data/
|
||||
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 / 模板结束
|
||||
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.
|
||||
|
||||
28
.github/workflows/build-docs.yml
vendored
28
.github/workflows/build-docs.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Build and Deploy AstrBot Docs
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -11,25 +11,19 @@ jobs:
|
||||
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/checkout@master
|
||||
- name: nodejs installation
|
||||
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
|
||||
node-version: "18"
|
||||
- name: npm install
|
||||
run: npm add -D vitepress
|
||||
working-directory: './docs' # working-directory 指定 shell 命令运行目录
|
||||
- name: npm run build
|
||||
run: npm run docs:build
|
||||
working-directory: './docs'
|
||||
- name: scp
|
||||
uses: appleboy/scp-action@v1.0.0
|
||||
uses: appleboy/scp-action@master
|
||||
with:
|
||||
host: ${{ secrets.HOST_NEKO }}
|
||||
username: ${{ secrets.USERNAME }}
|
||||
@@ -37,7 +31,7 @@ jobs:
|
||||
source: 'docs/.vitepress/dist/*'
|
||||
target: '/tmp/'
|
||||
- name: script
|
||||
uses: appleboy/ssh-action@v1.2.5
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: ${{ secrets.HOST_NEKO }}
|
||||
username: ${{ secrets.USERNAME }}
|
||||
|
||||
2
.github/workflows/code-format.yml
vendored
2
.github/workflows/code-format.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
|
||||
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
|
||||
|
||||
5
.github/workflows/coverage_test.yml
vendored
5
.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
|
||||
|
||||
@@ -40,7 +40,6 @@ jobs:
|
||||
pytest --cov=astrbot -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 }}
|
||||
|
||||
21
.github/workflows/dashboard_ci.yml
vendored
21
.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
|
||||
@@ -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@v4
|
||||
|
||||
- name: Set Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4.1.0
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to DockerHub
|
||||
uses: docker/login-action@v4.2.0
|
||||
uses: docker/login-action@v4
|
||||
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@v4
|
||||
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@v7
|
||||
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@v4
|
||||
|
||||
- name: Set Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4.1.0
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to DockerHub
|
||||
uses: docker/login-action@v4.2.0
|
||||
uses: docker/login-action@v4
|
||||
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@v4
|
||||
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@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
49
.github/workflows/release.yml
vendored
49
.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,35 +50,26 @@ 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
|
||||
with:
|
||||
@@ -87,12 +77,11 @@ jobs:
|
||||
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,19 @@ 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
|
||||
|
||||
publish-release:
|
||||
name: Publish GitHub Release
|
||||
if: github.repository == 'AstrBotDevs/AstrBot'
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- build-dashboard
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
@@ -156,6 +137,7 @@ jobs:
|
||||
name: Dashboard-${{ steps.tag.outputs.tag }}
|
||||
path: release-assets
|
||||
|
||||
|
||||
- name: Resolve release notes
|
||||
id: notes
|
||||
shell: bash
|
||||
@@ -201,13 +183,12 @@ jobs:
|
||||
|
||||
publish-pypi:
|
||||
name: Publish PyPI
|
||||
if: github.repository == 'AstrBotDevs/AstrBot'
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- publish-release
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
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
|
||||
|
||||
5
.github/workflows/sync-wiki.yml
vendored
5
.github/workflows/sync-wiki.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Sync AstrBot Docs to GitHub Wiki
|
||||
name: sync wiki
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -18,7 +18,6 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
if: github.repository == 'AstrBotDevs/AstrBot'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -31,7 +30,7 @@ jobs:
|
||||
exit 1
|
||||
|
||||
- name: Check out docs repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
|
||||
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
|
||||
36
.gitignore
vendored
36
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
# Python related
|
||||
__pycache__
|
||||
.mypy_cache
|
||||
.venv*
|
||||
.conda/
|
||||
uv.lock
|
||||
@@ -50,46 +51,15 @@ 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
|
||||
docs/plans/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
52
README.md
52
README.md
@@ -1,5 +1,4 @@
|
||||

|
||||
|
||||

|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -12,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>
|
||||
|
||||
@@ -33,7 +32,7 @@
|
||||
<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> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/issues">Issue Tracker</a>
|
||||
<a href="mailto:community@astrbot.app">Email Support</a>
|
||||
</div>
|
||||
|
||||
@@ -77,31 +76,27 @@ AstrBot is an open-source all-in-one Agent chatbot platform that integrates with
|
||||
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 ⚡️:
|
||||
|
||||
```bash
|
||||
uv tool install astrbot --python 3.12
|
||||
uv tool install astrbot
|
||||
astrbot init # Only execute this command for the first time to initialize the environment
|
||||
astrbot run
|
||||
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.
|
||||
|
||||
> [!NOTE]
|
||||
> For macOS users: due to macOS security checks, the first run of the `astrbot` command may take longer (about 10-20s).
|
||||
> For macOS user: due to macOS security checks, the first run of the `astrbot` command may take longer (about 10-20s).
|
||||
|
||||
Update `astrbot`:
|
||||
|
||||
```bash
|
||||
uv tool upgrade astrbot --python 3.12
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> AstrBot deployed via `uv` **does not support upgrading through the WebUI**. To update, please run the command above from the command line.
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
For users familiar with containers and looking for a more stable, production-ready deployment method, we recommend deploying AstrBot with Docker / Docker Compose.
|
||||
|
||||
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).
|
||||
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).
|
||||
|
||||
### Deploy on RainYun
|
||||
|
||||
@@ -139,7 +134,7 @@ yay -S astrbot-git
|
||||
|
||||
**More deployment methods**
|
||||
|
||||
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`.
|
||||
If you need panel-based management or deeper customization, see [BT-Panel Deployment](https://astrbot.app/deploy/astrbot/btpanel.html) for BT Panel app-store setup, [1Panel Deployment](https://astrbot.app/deploy/astrbot/1panel.html) for 1Panel app-market deployment, [CasaOS Deployment](https://astrbot.app/deploy/astrbot/casaos.html) for NAS/home-server visual deployment, and [Manual Deployment](https://astrbot.app/deploy/astrbot/cli.html) for fully custom source-based installation with `uv`.
|
||||
|
||||
## Supported Messaging Platforms
|
||||
|
||||
@@ -158,12 +153,10 @@ Connect AstrBot to your favorite chat platform.
|
||||
| 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 |
|
||||
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | Community |
|
||||
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | Community |
|
||||
|
||||
## Supported Model Services
|
||||
@@ -191,7 +184,6 @@ Connect AstrBot to your favorite chat platform.
|
||||
| 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 |
|
||||
@@ -201,7 +193,6 @@ Connect AstrBot to your favorite chat platform.
|
||||
| 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 |
|
||||
|
||||
## ❤️ Sponsors
|
||||
@@ -234,19 +225,14 @@ pre-commit install
|
||||
|
||||
### QQ Groups
|
||||
|
||||
- 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
|
||||
- Group 9: 1076659624 (New)
|
||||
- Group 10: 1078079676 (New)
|
||||
- Group 1: 322154837
|
||||
- Group 3: 630166526
|
||||
- Group 5: 822130018
|
||||
- Group 6: 753075035
|
||||
- Group 7: 743746109
|
||||
- Group 8: 1030353265
|
||||
|
||||
- Developer Group(Chit-chat): 975206796
|
||||
- Developer Group(Formal): 1039761811
|
||||
@@ -260,7 +246,7 @@ pre-commit install
|
||||
Special thanks to all Contributors and plugin developers for their contributions to AstrBot ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=300&columns=15" />
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||
</a>
|
||||
|
||||
Additionally, the birth of this project would not have been possible without the help of the following open-source projects:
|
||||
|
||||
39
README_fr.md
39
README_fr.md
@@ -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>
|
||||
|
||||
@@ -76,13 +76,12 @@ AstrBot est une plateforme de chatbot Agent tout-en-un open source qui s'intègr
|
||||
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` ⚡️ :
|
||||
|
||||
```bash
|
||||
uv tool install astrbot --python 3.12
|
||||
uv tool install astrbot
|
||||
astrbot init # Exécutez cette commande uniquement la première fois pour initialiser l'environnement
|
||||
astrbot run
|
||||
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.
|
||||
|
||||
> [!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).
|
||||
@@ -90,17 +89,14 @@ astrbot run
|
||||
Mettre à jour `astrbot` :
|
||||
|
||||
```bash
|
||||
uv tool upgrade astrbot --python 3.12
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
> [!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).
|
||||
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éployer sur RainYun
|
||||
|
||||
@@ -138,7 +134,7 @@ yay -S astrbot-git
|
||||
|
||||
**Autres méthodes de déploiement**
|
||||
|
||||
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`.
|
||||
Si vous avez besoin d'une gestion par panneau ou d'une personnalisation plus poussée, consultez [Déploiement BT-Panel](https://astrbot.app/deploy/astrbot/btpanel.html) pour une installation via BT Panel, [Déploiement 1Panel](https://astrbot.app/deploy/astrbot/1panel.html) pour le marketplace 1Panel, [Déploiement CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) pour un déploiement visuel sur NAS/serveur domestique, et [Déploiement manuel](https://astrbot.app/deploy/astrbot/cli.html) pour une installation complète depuis les sources avec `uv`.
|
||||
|
||||
## Plateformes de messagerie prises en charge
|
||||
|
||||
@@ -157,12 +153,10 @@ Connectez AstrBot à vos plateformes de chat préférées.
|
||||
| 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é |
|
||||
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | Communauté |
|
||||
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | Communauté |
|
||||
|
||||
## Services de modèles pris en charge
|
||||
@@ -190,7 +184,6 @@ Connectez AstrBot à vos plateformes de chat préférées.
|
||||
| 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 |
|
||||
@@ -200,7 +193,6 @@ Connectez AstrBot à vos plateformes de chat préférées.
|
||||
| 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 |
|
||||
|
||||
## ❤️ Contribuer
|
||||
@@ -225,17 +217,10 @@ 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
|
||||
|
||||
@@ -248,7 +233,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&max=200&columns=14" />
|
||||
</a>
|
||||
|
||||
De plus, la naissance de ce projet n'aurait pas été possible sans l'aide des projets open source suivants :
|
||||
|
||||
39
README_ja.md
39
README_ja.md
@@ -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>
|
||||
|
||||
@@ -76,13 +76,12 @@ AstrBot は、主要なインスタントメッセージングアプリと統合
|
||||
AstrBot を素早く試したいユーザーで、コマンドラインに慣れており `uv` 環境を自分でインストールできる場合は、`uv` のワンクリックデプロイをおすすめします ⚡️:
|
||||
|
||||
```bash
|
||||
uv tool install astrbot --python 3.12
|
||||
uv tool install astrbot
|
||||
astrbot init # 初回のみ実行して環境を初期化します
|
||||
astrbot run
|
||||
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 秒)。
|
||||
@@ -90,17 +89,14 @@ astrbot run
|
||||
`astrbot` の更新:
|
||||
|
||||
```bash
|
||||
uv tool upgrade astrbot --python 3.12
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
> [!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) をご参照ください。
|
||||
公式ドキュメント [Docker を使用した AstrBot のデプロイ](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot) をご参照ください。
|
||||
|
||||
### 雨云でのデプロイ
|
||||
|
||||
@@ -138,7 +134,7 @@ 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` とソースベースのフルカスタム導入)を参照してください。
|
||||
パネル操作での導入やより高度なカスタマイズが必要な場合は、[宝塔パネルデプロイ](https://astrbot.app/deploy/astrbot/btpanel.html)(BT Panel 経由の導入)、[1Panel デプロイ](https://astrbot.app/deploy/astrbot/1panel.html)(1Panel アプリマーケット経由)、[CasaOS デプロイ](https://astrbot.app/deploy/astrbot/casaos.html)(NAS / ホームサーバー向け可視化導入)、[手動デプロイ](https://astrbot.app/deploy/astrbot/cli.html)(`uv` とソースベースのフルカスタム導入)を参照してください。
|
||||
|
||||
## サポートされているメッセージプラットフォーム
|
||||
|
||||
@@ -157,12 +153,10 @@ AstrBot をよく使うチャットプラットフォームに接続できます
|
||||
| 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) | コミュニティ |
|
||||
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | コミュニティ |
|
||||
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | コミュニティ |
|
||||
|
||||
|
||||
@@ -191,7 +185,6 @@ AstrBot をよく使うチャットプラットフォームに接続できます
|
||||
| Coze | LLMOps プラットフォーム |
|
||||
| OpenAI Whisper | 音声認識サービス |
|
||||
| SenseVoice | 音声認識サービス |
|
||||
| Xiaomi MiMo Omni | 音声認識サービス |
|
||||
| OpenAI TTS | 音声合成サービス |
|
||||
| Gemini TTS | 音声合成サービス |
|
||||
| GPT-Sovits-Inference | 音声合成サービス |
|
||||
@@ -201,7 +194,6 @@ AstrBot をよく使うチャットプラットフォームに接続できます
|
||||
| Alibaba Cloud 百炼 TTS | 音声合成サービス |
|
||||
| Azure TTS | 音声合成サービス |
|
||||
| Minimax TTS | 音声合成サービス |
|
||||
| Xiaomi MiMo TTS | 音声合成サービス |
|
||||
| Volcano Engine TTS | 音声合成サービス |
|
||||
|
||||
## ❤️ コントリビューション
|
||||
@@ -226,17 +218,10 @@ 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
|
||||
|
||||
@@ -249,7 +234,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&max=200&columns=14" />
|
||||
</a>
|
||||
|
||||
また、このプロジェクトの誕生は以下のオープンソースプロジェクトの助けなしには実現できませんでした:
|
||||
|
||||
39
README_ru.md
39
README_ru.md
@@ -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>
|
||||
|
||||
@@ -76,13 +76,12 @@ AstrBot — это универсальная платформа Agent-чатб
|
||||
Для пользователей, которые хотят быстро попробовать AstrBot, знакомы с командной строкой и могут самостоятельно установить окружение `uv`, мы рекомендуем использовать развёртывание в один клик через `uv` ⚡️:
|
||||
|
||||
```bash
|
||||
uv tool install astrbot --python 3.12
|
||||
uv tool install astrbot
|
||||
astrbot init # Выполните эту команду только при первом запуске для инициализации окружения
|
||||
astrbot run
|
||||
astrbot
|
||||
```
|
||||
|
||||
> Требуется установленный [uv](https://docs.astral.sh/uv/).
|
||||
> Для AstrBot требуется Python 3.12 или новее. Параметр `--python 3.12` гарантирует, что `uv` создаст tool-окружение с Python 3.12.
|
||||
|
||||
> [!NOTE]
|
||||
> Для пользователей macOS: из-за проверок безопасности macOS первый запуск команды `astrbot` может занять больше времени (около 10-20 секунд).
|
||||
@@ -90,17 +89,14 @@ astrbot run
|
||||
Обновить `astrbot`:
|
||||
|
||||
```bash
|
||||
uv tool upgrade astrbot --python 3.12
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> AstrBot, развёрнутый через `uv`, **не поддерживает обновление через WebUI**. Для обновления выполните указанную выше команду из командной строки.
|
||||
|
||||
### Развёртывание Docker
|
||||
|
||||
Для пользователей, знакомых с контейнерами и которым нужен более стабильный и подходящий для production способ, мы рекомендуем разворачивать AstrBot через Docker / Docker Compose.
|
||||
|
||||
См. официальную документацию [Развёртывание AstrBot с Docker](https://docs.astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
|
||||
См. официальную документацию [Развёртывание AstrBot с Docker](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
|
||||
|
||||
### Развёртывание на RainYun
|
||||
|
||||
@@ -138,7 +134,7 @@ yay -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`).
|
||||
Если вам нужна панельная установка или более глубокая кастомизация, смотрите [Развёртывание BT-Panel](https://astrbot.app/deploy/astrbot/btpanel.html) (установка через BT Panel), [Развёртывание 1Panel](https://astrbot.app/deploy/astrbot/1panel.html) (развёртывание через маркетплейс 1Panel), [Развёртывание CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) (визуальный вариант для NAS и домашних серверов) и [Ручное развёртывание](https://astrbot.app/deploy/astrbot/cli.html) (полностью настраиваемая установка из исходников через `uv`).
|
||||
|
||||
## Поддерживаемые платформы обмена сообщениями
|
||||
|
||||
@@ -157,12 +153,10 @@ yay -S astrbot-git
|
||||
| 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) | Сообщество |
|
||||
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | Сообщество |
|
||||
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | Сообщество |
|
||||
|
||||
## Поддерживаемые сервисы моделей
|
||||
@@ -190,7 +184,6 @@ yay -S astrbot-git
|
||||
| Coze | Платформы LLMOps |
|
||||
| OpenAI Whisper | Сервисы распознавания речи |
|
||||
| SenseVoice | Сервисы распознавания речи |
|
||||
| Xiaomi MiMo Omni | Сервисы распознавания речи |
|
||||
| OpenAI TTS | Сервисы синтеза речи |
|
||||
| Gemini TTS | Сервисы синтеза речи |
|
||||
| GPT-Sovits-Inference | Сервисы синтеза речи |
|
||||
@@ -200,7 +193,6 @@ yay -S astrbot-git
|
||||
| Alibaba Cloud Bailian TTS | Сервисы синтеза речи |
|
||||
| Azure TTS | Сервисы синтеза речи |
|
||||
| Minimax TTS | Сервисы синтеза речи |
|
||||
| Xiaomi MiMo TTS | Сервисы синтеза речи |
|
||||
| Volcano Engine TTS | Сервисы синтеза речи |
|
||||
|
||||
## ❤️ Вклад в проект
|
||||
@@ -225,17 +217,10 @@ 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
|
||||
|
||||
@@ -248,7 +233,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&max=200&columns=14" />
|
||||
</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,7 +32,7 @@
|
||||
<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="https://github.com/AstrBotDevs/AstrBot/issues">問題回報</a>
|
||||
<a href="mailto:community@astrbot.app">Email</a>
|
||||
</div>
|
||||
|
||||
@@ -76,13 +76,12 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主
|
||||
對於想快速體驗 AstrBot、且熟悉命令列並能自行安裝 `uv` 環境的使用者,我們推薦使用 `uv` 一鍵部署方式 ⚡️。
|
||||
|
||||
```bash
|
||||
uv tool install astrbot --python 3.12
|
||||
uv tool install astrbot
|
||||
astrbot init # 僅首次執行此命令以初始化環境
|
||||
astrbot run
|
||||
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 秒)。
|
||||
@@ -90,17 +89,14 @@ astrbot run
|
||||
更新 `astrbot`:
|
||||
|
||||
```bash
|
||||
uv tool upgrade astrbot --python 3.12
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
> [!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)。
|
||||
請參考官方文件 [使用 Docker 部署 AstrBot](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot)。
|
||||
|
||||
### 在雨雲上部署
|
||||
|
||||
@@ -138,7 +134,7 @@ 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` 的完整自訂安裝)。
|
||||
若你需要面板化或更高自訂程度的部署,可參考 [寶塔面板](https://astrbot.app/deploy/astrbot/btpanel.html)(BT Panel 應用商店安裝)、[1Panel](https://astrbot.app/deploy/astrbot/1panel.html)(1Panel 應用商店安裝)、[CasaOS](https://astrbot.app/deploy/astrbot/casaos.html)(NAS / 家用伺服器可視化部署)與 [手動部署](https://astrbot.app/deploy/astrbot/cli.html)(基於原始碼與 `uv` 的完整自訂安裝)。
|
||||
|
||||
## 支援的訊息平台
|
||||
|
||||
@@ -157,12 +153,10 @@ yay -S astrbot-git
|
||||
| Discord | 官方維護 |
|
||||
| LINE | 官方維護 |
|
||||
| Satori | 官方維護 |
|
||||
| KOOK | 官方維護 |
|
||||
| Misskey | 官方維護 |
|
||||
| Mattermost | 官方維護 |
|
||||
| WhatsApp(即將支援) | 官方維護 |
|
||||
| Whatsapp(即將支援) | 官方維護 |
|
||||
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | 社群維護 |
|
||||
| [Rocket.Chat](https://github.com/NET-Homeless/astrbot_plugin_rocket_chat_adapter) | 社群維護 |
|
||||
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | 社群維護 |
|
||||
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | 社群維護 |
|
||||
|
||||
## 支援的模型服務
|
||||
@@ -190,7 +184,6 @@ yay -S astrbot-git
|
||||
| Coze | LLMOps 平台 |
|
||||
| OpenAI Whisper | 語音轉文字服務 |
|
||||
| SenseVoice | 語音轉文字服務 |
|
||||
| Xiaomi MiMo Omni | 語音轉文字服務 |
|
||||
| OpenAI TTS | 文字轉語音服務 |
|
||||
| Gemini TTS | 文字轉語音服務 |
|
||||
| GPT-Sovits-Inference | 文字轉語音服務 |
|
||||
@@ -200,7 +193,6 @@ yay -S astrbot-git
|
||||
| 阿里雲百煉 TTS | 文字轉語音服務 |
|
||||
| Azure TTS | 文字轉語音服務 |
|
||||
| Minimax TTS | 文字轉語音服務 |
|
||||
| Xiaomi MiMo TTS | 文字轉語音服務 |
|
||||
| 火山引擎 TTS | 文字轉語音服務 |
|
||||
|
||||
## ❤️ 貢獻
|
||||
@@ -225,17 +217,14 @@ 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 (人滿)
|
||||
- 9 群: 1076659624 (新)
|
||||
- 10 群: 1078079676 (新)
|
||||
- 1 群:322154837
|
||||
- 3 群:630166526
|
||||
- 5 群:822130018
|
||||
- 6 群:753075035
|
||||
- 7 群:743746109
|
||||
- 8 群:1030353265
|
||||
- 開發者群(闲聊吹水):975206796
|
||||
- 開發者群(正式):1039761811
|
||||
|
||||
@@ -248,7 +237,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&max=200&columns=14" />
|
||||
</a>
|
||||
|
||||
此外,本專案的誕生離不開以下開源專案的幫助:
|
||||
|
||||
88
README_zh.md
88
README_zh.md
@@ -9,7 +9,7 @@
|
||||
<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://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>
|
||||
|
||||
@@ -31,12 +31,12 @@
|
||||
<a href="https://astrbot.app/">文档</a> |
|
||||
<a href="https://blog.astrbot.app/">博客</a> |
|
||||
<a href="https://astrbot.featurebase.app/roadmap">路线图</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/issues">问题提交</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/issues">问题提交</a>
|
||||
<a href="mailto:community@astrbot.app">Email</a>
|
||||
|
||||
</div>
|
||||
|
||||
AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、Telegram、企业微信、飞书、钉钉、Slack 等数十款主流即时通讯软件上部署,此外还内置类似 OpenWebUI 的轻量化 ChatUI,为个人、开发者和团队打造可靠、可扩展的对话式智能基础设施。无论是个人 AI 伙伴、智能客服、自动化助手,还是企业知识库,AstrBot 都能在你的即时通讯软件平台的工作流中快速构建 AI 应用。
|
||||
AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、Telegram、企业微信、飞书、钉钉、Slack、等数十款主流即时通讯软件上部署,此外还内置类似 OpenWebUI 的轻量化 ChatUI,为个人、开发者和团队打造可靠、可扩展的对话式智能基础设施。无论是个人 AI 伙伴、智能客服、自动化助手,还是企业知识库,AstrBot 都能在你的即时通讯软件平台的工作流中快速构建 AI 应用。
|
||||
|
||||

|
||||
|
||||
@@ -76,16 +76,12 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、
|
||||
对于想快速体验 AstrBot、且熟悉命令行并能够自行安装 `uv` 环境的用户,我们推荐使用 `uv` 一键部署方式 ⚡️。
|
||||
|
||||
```bash
|
||||
uv tool install astrbot --python 3.12
|
||||
uv tool install astrbot
|
||||
astrbot init # 仅首次执行此命令以初始化环境
|
||||
astrbot run # astrbot run --backend-only 仅启动后端服务
|
||||
|
||||
# 安装开发版本(更多修复,新功能,但不够稳定,适合开发者)
|
||||
uv tool install git+https://github.com/AstrBotDevs/AstrBot@dev
|
||||
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 秒)。
|
||||
@@ -93,17 +89,14 @@ uv tool install git+https://github.com/AstrBotDevs/AstrBot@dev
|
||||
更新 `astrbot`:
|
||||
|
||||
```bash
|
||||
uv tool upgrade astrbot --python 3.12
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
> [!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)。
|
||||
请参考官方文档 [使用 Docker 部署 AstrBot](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot)。
|
||||
|
||||
### 在 雨云 上部署
|
||||
|
||||
@@ -141,7 +134,7 @@ 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` 的完整自定义安装)。
|
||||
若你需要面板化或更高自定义部署,可参考 [宝塔面板](https://astrbot.app/deploy/astrbot/btpanel.html)(BT Panel 应用商店安装)、[1Panel](https://astrbot.app/deploy/astrbot/1panel.html)(1Panel 应用商店安装)、[CasaOS](https://astrbot.app/deploy/astrbot/casaos.html)(NAS / 家庭服务器可视化部署)和 [手动部署](https://astrbot.app/deploy/astrbot/cli.html)(基于源码与 `uv` 的完整自定义安装)。
|
||||
|
||||
## 支持的消息平台
|
||||
|
||||
@@ -160,12 +153,10 @@ yay -S astrbot-git
|
||||
| **Discord** | 官方维护 |
|
||||
| **LINE** | 官方维护 |
|
||||
| **Satori** | 官方维护 |
|
||||
| **KOOK** | 官方维护 |
|
||||
| **Misskey** | 官方维护 |
|
||||
| **Mattermost** | 官方维护 |
|
||||
| **WhatsApp(将支持)** | 官方维护 |
|
||||
| **Whatsapp (将支持)** | 官方维护 |
|
||||
| [**Matrix**](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | 社区维护 |
|
||||
| [**Rocket.Chat**](https://github.com/NET-Homeless/astrbot_plugin_rocket_chat_adapter) | 社区维护 |
|
||||
| [**KOOK**](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | 社区维护 |
|
||||
| [**VoceChat**](https://github.com/HikariFroya/astrbot_plugin_vocechat) | 社区维护 |
|
||||
|
||||
## 支持的模型提供商
|
||||
@@ -194,7 +185,6 @@ yay -S astrbot-git
|
||||
| Coze | LLMOps 平台 |
|
||||
| OpenAI Whisper | 语音转文本 |
|
||||
| SenseVoice | 语音转文本 |
|
||||
| Xiaomi MiMo Omni | 语音转文本 |
|
||||
| OpenAI TTS | 文本转语音 |
|
||||
| Gemini TTS | 文本转语音 |
|
||||
| GPT-Sovits-Inference | 文本转语音 |
|
||||
@@ -204,28 +194,15 @@ yay -S astrbot-git
|
||||
| 阿里云百炼 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!只需要将你的更改提交到此项目 :)
|
||||
欢迎任何 Issues/Pull Requests!只需要将你的更改提交到此项目 :)
|
||||
|
||||
### 如何贡献
|
||||
|
||||
你可以通过查看问题或帮助审核 PR(拉取请求)来贡献。任何问题或 PR 都欢迎参与,以促进社区贡献。当然,这些只是建议,你可以以任何方式进行贡献。对于新功能的添加,请先通过 Issue 讨论。
|
||||
建议将功能性PR合并至dev分支,将在测试修改后合并到主分支并发布新版本。
|
||||
为了减少冲突,建议如下:
|
||||
1. 工作分支最好基于 `dev` 分支创建,避免直接在 `main` 分支上工作。
|
||||
2. 提交 PR 时,选择 `dev` 分支作为目标分支。
|
||||
3. 定期同步 `dev` 分支到本地,多使用git pull。
|
||||
|
||||
### 开发环境
|
||||
|
||||
@@ -233,41 +210,22 @@ AstrBot 使用 `ruff` 进行代码格式化和检查。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AstrBotDevs/AstrBot
|
||||
git switch dev # 切换到开发分支
|
||||
pip install pre-commit # 或者uv tool install pre-commit
|
||||
pip 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
|
||||
|
||||
|
||||
- 9 群: 1076659624 (新)
|
||||
- 10 群: 1078079676 (新)
|
||||
- 1 群:322154837
|
||||
- 3 群:630166526
|
||||
- 5 群:822130018
|
||||
- 6 群:753075035
|
||||
- 7 群:743746109
|
||||
- 8 群:1030353265
|
||||
- 开发者群(偏闲聊吹水):975206796
|
||||
- 开发者群(正式):1039761811
|
||||
|
||||
@@ -280,7 +238,7 @@ bun dev
|
||||
特别感谢所有 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&max=200&columns=14" />
|
||||
</a>
|
||||
|
||||
此外,本项目的诞生离不开以下开源项目的帮助:
|
||||
|
||||
@@ -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,
|
||||
@@ -53,20 +51,18 @@ __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_platform_loaded",
|
||||
"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,16 +1,13 @@
|
||||
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.platform.astr_message_event import MessageSession
|
||||
from astrbot.core.platform.message_type import MessageType
|
||||
from astrbot.core.utils.active_event_registry import active_event_registry
|
||||
|
||||
from .utils.rst_scene import RstScene
|
||||
@@ -24,85 +21,6 @@ THIRD_PARTY_AGENT_RUNNER_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,8 +60,8 @@ 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
|
||||
@@ -151,21 +69,17 @@ class ConversationCommands:
|
||||
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,7 +88,7 @@ class ConversationCommands:
|
||||
if not cid:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
"😕 You are not in a conversation. Use /new to create one.",
|
||||
"当前未处于对话状态,请 /switch 切换或者 /new 创建。",
|
||||
),
|
||||
)
|
||||
return
|
||||
@@ -187,9 +101,9 @@ class ConversationCommands:
|
||||
[],
|
||||
)
|
||||
|
||||
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))
|
||||
|
||||
@@ -210,29 +124,160 @@ class ConversationCommands:
|
||||
if stopped_count > 0:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
f"✅ Requested to stop {stopped_count} running tasks."
|
||||
f"已请求停止 {stopped_count} 个运行中的任务。"
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
message.set_result(
|
||||
MessageEventResult().message("✅ No running tasks in the current session.")
|
||||
message.set_result(MessageEventResult().message("当前会话没有运行中的任务。"))
|
||||
|
||||
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("未找到任何 LLM 提供商。请先配置。"),
|
||||
)
|
||||
return
|
||||
|
||||
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
|
||||
|
||||
"""遍历分页后的对话生成列表显示"""
|
||||
provider_settings = cfg.get("provider_settings", {})
|
||||
platform_name = message.get_platform_name()
|
||||
for conv in conversations_paged:
|
||||
(
|
||||
persona_id,
|
||||
_,
|
||||
force_applied_persona_id,
|
||||
_,
|
||||
) = await self.context.persona_manager.resolve_selected_persona(
|
||||
umo=message.unified_msg_origin,
|
||||
conversation_persona_id=conv.persona_id,
|
||||
platform_name=platform_name,
|
||||
provider_settings=provider_settings,
|
||||
)
|
||||
if persona_id == "[%None]":
|
||||
persona_name = "无"
|
||||
elif persona_id:
|
||||
persona_name = persona_id
|
||||
else:
|
||||
persona_name = "无"
|
||||
|
||||
if force_applied_persona_id:
|
||||
persona_name = f"{persona_name} (自定义规则)"
|
||||
|
||||
title = _titles.get(conv.cid, "新对话")
|
||||
parts.append(
|
||||
f"{global_index}. {title}({conv.cid[:4]})\n 人格情景: {persona_name}\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)
|
||||
@@ -243,69 +288,133 @@ 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."""
|
||||
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,
|
||||
),
|
||||
)
|
||||
|
||||
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(
|
||||
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:
|
||||
"""删除当前对话"""
|
||||
umo = message.unified_msg_origin
|
||||
cid = await self.context.conversation_manager.get_curr_conversation_id(umo)
|
||||
|
||||
if not cid:
|
||||
cfg = self.context.get_config(umo=umo)
|
||||
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(
|
||||
"❌ You are not in a conversation. Use /new to create one."
|
||||
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,
|
||||
)
|
||||
)
|
||||
stats = result.one()
|
||||
|
||||
if stats.record_count == 0:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
"📊 No stats available for this conversation yet."
|
||||
),
|
||||
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 sp.remove_async(
|
||||
scope="umo",
|
||||
scope_id=umo,
|
||||
key=THIRD_PARTY_AGENT_RUNNER_KEY[agent_runner_type],
|
||||
)
|
||||
message.set_result(MessageEventResult().message("重置对话成功。"))
|
||||
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"
|
||||
session_curr_cid = (
|
||||
await self.context.conversation_manager.get_curr_conversation_id(umo)
|
||||
)
|
||||
|
||||
if not session_curr_cid:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
"当前未处于对话状态,请 /switch 序号 切换或 /new 创建。",
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
active_event_registry.stop_all(umo, exclude=message)
|
||||
|
||||
await self.context.conversation_manager.delete_conversation(
|
||||
umo,
|
||||
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)
|
||||
)
|
||||
@@ -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 = "无"
|
||||
@@ -71,7 +71,7 @@ class PersonaCommands:
|
||||
if conv is None:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
"当前对话不存在,请先使用 /new 新建一个对话。",
|
||||
"当前对话不存在,请先使用 /new 新建一个对话。",
|
||||
),
|
||||
)
|
||||
return
|
||||
@@ -100,10 +100,10 @@ class PersonaCommands:
|
||||
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 +122,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 +149,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 +199,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,6 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.api import star
|
||||
@@ -8,10 +12,251 @@ from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
from astrbot.core.provider.entities import ProviderType
|
||||
from astrbot.core.utils.error_redaction import safe_error
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from astrbot.core.provider.provider import Provider
|
||||
|
||||
|
||||
MODEL_LIST_CACHE_TTL_SECONDS_DEFAULT = 30.0
|
||||
MODEL_LOOKUP_MAX_CONCURRENCY_DEFAULT = 4
|
||||
MODEL_LOOKUP_MAX_CONCURRENCY_UPPER_BOUND = 16
|
||||
MODEL_LIST_CACHE_TTL_KEY = "model_list_cache_ttl_seconds"
|
||||
MODEL_LOOKUP_MAX_CONCURRENCY_KEY = "model_lookup_max_concurrency"
|
||||
MODEL_CACHE_MAX_ENTRIES = 512
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ModelLookupConfig:
|
||||
umo: str | None
|
||||
cache_ttl_seconds: float
|
||||
max_concurrency: int
|
||||
|
||||
|
||||
class _ModelCache:
|
||||
def __init__(self) -> None:
|
||||
self._store: dict[tuple[str, str | None], tuple[float, list[str]]] = {}
|
||||
|
||||
def get(self, provider_id: str, umo: str | None, ttl: float) -> list[str] | None:
|
||||
if ttl <= 0:
|
||||
return None
|
||||
entry = self._store.get((provider_id, umo))
|
||||
if not entry:
|
||||
return None
|
||||
timestamp, models = entry
|
||||
if time.monotonic() - timestamp > ttl:
|
||||
self._store.pop((provider_id, umo), None)
|
||||
return None
|
||||
return models
|
||||
|
||||
def set(
|
||||
self, provider_id: str, umo: str | None, models: list[str], ttl: float
|
||||
) -> None:
|
||||
if ttl <= 0:
|
||||
return
|
||||
self._store[(provider_id, umo)] = (time.monotonic(), list(models))
|
||||
self._evict_if_needed()
|
||||
|
||||
def _evict_if_needed(self) -> None:
|
||||
if len(self._store) <= MODEL_CACHE_MAX_ENTRIES:
|
||||
return
|
||||
# Drop oldest entries first when cache grows too large.
|
||||
overflow = len(self._store) - MODEL_CACHE_MAX_ENTRIES
|
||||
for key, _ in sorted(
|
||||
self._store.items(),
|
||||
key=lambda item: item[1][0],
|
||||
)[:overflow]:
|
||||
self._store.pop(key, None)
|
||||
|
||||
def invalidate(
|
||||
self, provider_id: str | None = None, *, umo: str | None = None
|
||||
) -> None:
|
||||
if provider_id is None:
|
||||
self._store.clear()
|
||||
return
|
||||
if umo is not None:
|
||||
self._store.pop((provider_id, umo), None)
|
||||
return
|
||||
stale_keys = [
|
||||
cache_key for cache_key in self._store if cache_key[0] == provider_id
|
||||
]
|
||||
for cache_key in stale_keys:
|
||||
self._store.pop(cache_key, None)
|
||||
|
||||
|
||||
class ProviderCommands:
|
||||
def __init__(self, context: star.Context) -> None:
|
||||
self.context = context
|
||||
self._model_cache = _ModelCache()
|
||||
self._register_provider_change_hook()
|
||||
|
||||
def _register_provider_change_hook(self) -> None:
|
||||
set_change_callback = getattr(
|
||||
self.context.provider_manager,
|
||||
"set_provider_change_callback",
|
||||
None,
|
||||
)
|
||||
if callable(set_change_callback):
|
||||
set_change_callback(self._on_provider_manager_changed)
|
||||
return
|
||||
register_change_hook = getattr(
|
||||
self.context.provider_manager,
|
||||
"register_provider_change_hook",
|
||||
None,
|
||||
)
|
||||
if callable(register_change_hook):
|
||||
register_change_hook(self._on_provider_manager_changed)
|
||||
|
||||
def invalidate_provider_models_cache(
|
||||
self, provider_id: str | None = None, *, umo: str | None = None
|
||||
) -> None:
|
||||
"""Public hook for cache invalidation on external provider config changes."""
|
||||
self._model_cache.invalidate(provider_id, umo=umo)
|
||||
|
||||
def _on_provider_manager_changed(
|
||||
self,
|
||||
provider_id: str,
|
||||
provider_type: ProviderType,
|
||||
umo: str | None,
|
||||
) -> None:
|
||||
if provider_type == ProviderType.CHAT_COMPLETION:
|
||||
self.invalidate_provider_models_cache(provider_id, umo=umo)
|
||||
|
||||
def _get_provider_settings(self, umo: str | None) -> dict:
|
||||
if not umo:
|
||||
return {}
|
||||
try:
|
||||
return self.context.get_config(umo).get("provider_settings", {}) or {}
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"读取 provider_settings 失败,使用默认值: %s",
|
||||
safe_error("", e),
|
||||
)
|
||||
return {}
|
||||
|
||||
def _get_model_cache_ttl(self, umo: str | None) -> float:
|
||||
settings = self._get_provider_settings(umo)
|
||||
raw = settings.get(
|
||||
MODEL_LIST_CACHE_TTL_KEY,
|
||||
MODEL_LIST_CACHE_TTL_SECONDS_DEFAULT,
|
||||
)
|
||||
try:
|
||||
return max(float(raw), 0.0)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"读取 %s 失败,回退默认值 %r: %s",
|
||||
MODEL_LIST_CACHE_TTL_KEY,
|
||||
MODEL_LIST_CACHE_TTL_SECONDS_DEFAULT,
|
||||
safe_error("", e),
|
||||
)
|
||||
return MODEL_LIST_CACHE_TTL_SECONDS_DEFAULT
|
||||
|
||||
def _get_model_lookup_concurrency(self, umo: str | None) -> int:
|
||||
settings = self._get_provider_settings(umo)
|
||||
raw = settings.get(
|
||||
MODEL_LOOKUP_MAX_CONCURRENCY_KEY,
|
||||
MODEL_LOOKUP_MAX_CONCURRENCY_DEFAULT,
|
||||
)
|
||||
try:
|
||||
value = int(raw)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"读取 %s 失败,回退默认值 %r: %s",
|
||||
MODEL_LOOKUP_MAX_CONCURRENCY_KEY,
|
||||
MODEL_LOOKUP_MAX_CONCURRENCY_DEFAULT,
|
||||
safe_error("", e),
|
||||
)
|
||||
value = MODEL_LOOKUP_MAX_CONCURRENCY_DEFAULT
|
||||
return min(max(value, 1), MODEL_LOOKUP_MAX_CONCURRENCY_UPPER_BOUND)
|
||||
|
||||
def _get_model_lookup_config(self, umo: str | None) -> _ModelLookupConfig:
|
||||
return _ModelLookupConfig(
|
||||
umo=umo,
|
||||
cache_ttl_seconds=self._get_model_cache_ttl(umo),
|
||||
max_concurrency=self._get_model_lookup_concurrency(umo),
|
||||
)
|
||||
|
||||
def _resolve_model_name(
|
||||
self,
|
||||
model_name: str,
|
||||
models: Sequence[str],
|
||||
) -> str | None:
|
||||
"""Resolve model name with precedence:
|
||||
exact > case-insensitive > provider-qualified suffix.
|
||||
"""
|
||||
requested = model_name.strip()
|
||||
if not requested:
|
||||
return None
|
||||
|
||||
requested_norm = requested.casefold()
|
||||
|
||||
# exact / case-insensitive match
|
||||
for candidate in models:
|
||||
if candidate == requested or candidate.casefold() == requested_norm:
|
||||
return candidate
|
||||
|
||||
# provider-qualified suffix match:
|
||||
# e.g. candidate `openai/gpt-4o` should match requested `gpt-4o`.
|
||||
for candidate in models:
|
||||
cand_norm = candidate.casefold()
|
||||
if cand_norm.endswith(f"/{requested_norm}") or cand_norm.endswith(
|
||||
f":{requested_norm}"
|
||||
):
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
def _apply_model(
|
||||
self, prov: Provider, model_name: str, *, umo: str | None = None
|
||||
) -> str:
|
||||
prov.set_model(model_name)
|
||||
self.invalidate_provider_models_cache(prov.meta().id, umo=umo)
|
||||
return f"切换模型成功。当前提供商: [{prov.meta().id}] 当前模型: [{prov.get_model()}]"
|
||||
|
||||
async def _get_provider_models(
|
||||
self,
|
||||
provider: Provider,
|
||||
*,
|
||||
config: _ModelLookupConfig,
|
||||
use_cache: bool = True,
|
||||
) -> list[str]:
|
||||
provider_id = provider.meta().id
|
||||
ttl_seconds = config.cache_ttl_seconds
|
||||
umo = config.umo
|
||||
if use_cache:
|
||||
cached = self._model_cache.get(provider_id, umo, ttl_seconds)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
models = list(await provider.get_models())
|
||||
if use_cache:
|
||||
self._model_cache.set(provider_id, umo, models, ttl_seconds)
|
||||
return models
|
||||
|
||||
async def _get_models_or_reply_error(
|
||||
self,
|
||||
message: AstrMessageEvent,
|
||||
prov: Provider,
|
||||
config: _ModelLookupConfig,
|
||||
*,
|
||||
error_prefix: str,
|
||||
disable_t2i: bool = False,
|
||||
warning_log: str | None = None,
|
||||
) -> list[str] | None:
|
||||
try:
|
||||
return await self._get_provider_models(prov, config=config)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
if warning_log is not None:
|
||||
logger.warning(
|
||||
warning_log,
|
||||
prov.meta().id,
|
||||
safe_error("", e),
|
||||
)
|
||||
result = MessageEventResult().message(safe_error(error_prefix, e))
|
||||
if disable_t2i:
|
||||
result = result.use_t2i(False)
|
||||
message.set_result(result)
|
||||
return None
|
||||
|
||||
def _log_reachability_failure(
|
||||
self,
|
||||
@@ -20,6 +265,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 +276,7 @@ class ProviderCommands:
|
||||
)
|
||||
|
||||
async def _test_provider_capability(self, provider):
|
||||
"""测试单个 provider 的可用性"""
|
||||
meta = provider.meta()
|
||||
provider_capability_type = meta.provider_type
|
||||
|
||||
@@ -40,76 +287,93 @@ class ProviderCommands:
|
||||
err_code = "TEST_FAILED"
|
||||
err_reason = safe_error("", 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(
|
||||
async def _find_provider_for_model(
|
||||
self,
|
||||
providers,
|
||||
provider_type: str,
|
||||
reachability_check_enabled: bool,
|
||||
) -> list[dict]:
|
||||
if not providers:
|
||||
return []
|
||||
model_name: str,
|
||||
*,
|
||||
exclude_provider_id: str | None = None,
|
||||
config: _ModelLookupConfig,
|
||||
use_cache: bool = True,
|
||||
) -> tuple[Provider | None, str | None]:
|
||||
all_providers = []
|
||||
for provider in self.context.get_all_providers():
|
||||
provider_meta = provider.meta()
|
||||
if provider_meta.provider_type != ProviderType.CHAT_COMPLETION:
|
||||
continue
|
||||
if (
|
||||
exclude_provider_id is not None
|
||||
and provider_meta.id == exclude_provider_id
|
||||
):
|
||||
continue
|
||||
all_providers.append(provider)
|
||||
if not all_providers:
|
||||
return None, None
|
||||
|
||||
if reachability_check_enabled:
|
||||
check_results = await asyncio.gather(
|
||||
*[self._test_provider_capability(provider) for provider in providers],
|
||||
return_exceptions=True,
|
||||
semaphore = asyncio.Semaphore(config.max_concurrency)
|
||||
|
||||
async def fetch_models(
|
||||
provider: Provider,
|
||||
) -> tuple[Provider, list[str] | None, str | None]:
|
||||
async with semaphore:
|
||||
try:
|
||||
models = await self._get_provider_models(
|
||||
provider,
|
||||
config=config,
|
||||
use_cache=use_cache,
|
||||
)
|
||||
return provider, models, None
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
err = safe_error("", e)
|
||||
logger.debug(
|
||||
"跨提供商查找模型 %s 获取 %s 模型列表失败: %s",
|
||||
model_name,
|
||||
provider.meta().id,
|
||||
err,
|
||||
)
|
||||
return provider, None, err
|
||||
|
||||
results = await asyncio.gather(
|
||||
*(fetch_models(provider) for provider in all_providers)
|
||||
)
|
||||
failed_provider_errors: list[tuple[str, str]] = []
|
||||
for provider, models, err in results:
|
||||
if err is not None:
|
||||
failed_provider_errors.append((provider.meta().id, err))
|
||||
continue
|
||||
if models is None:
|
||||
continue
|
||||
|
||||
matched_model_name = self._resolve_model_name(model_name, models)
|
||||
if matched_model_name is not None:
|
||||
return provider, matched_model_name
|
||||
|
||||
if failed_provider_errors and len(failed_provider_errors) == len(all_providers):
|
||||
failed_ids = ",".join(
|
||||
provider_id for provider_id, _ in failed_provider_errors
|
||||
)
|
||||
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,
|
||||
},
|
||||
logger.error(
|
||||
"跨提供商查找模型 %s 时,所有 %d 个提供商的 get_models() 均失败: %s。请检查配置或网络",
|
||||
model_name,
|
||||
len(all_providers),
|
||||
failed_ids,
|
||||
)
|
||||
|
||||
return display_data
|
||||
elif failed_provider_errors:
|
||||
logger.debug(
|
||||
"跨提供商查找模型 %s 时有 %d 个提供商获取模型失败: %s",
|
||||
model_name,
|
||||
len(failed_provider_errors),
|
||||
",".join(
|
||||
f"{provider_id}({error})"
|
||||
for provider_id, error in failed_provider_errors
|
||||
),
|
||||
)
|
||||
return None, None
|
||||
|
||||
async def provider(
|
||||
self,
|
||||
@@ -123,82 +387,137 @@ 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, asyncio.CancelledError):
|
||||
raise reachable
|
||||
if isinstance(reachable, Exception):
|
||||
# 异常情况下兜底处理,避免单个 provider 导致列表失败
|
||||
self._log_reachability_failure(
|
||||
p,
|
||||
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 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 +526,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 +541,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 +553,184 @@ 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,
|
||||
idx_or_name: int | str | None = None,
|
||||
async def _switch_model_by_name(
|
||||
self, message: AstrMessageEvent, model_name: str, prov: Provider
|
||||
) -> 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 提供商。请先配置。"),
|
||||
)
|
||||
model_name = model_name.strip()
|
||||
if not model_name:
|
||||
message.set_result(MessageEventResult().message("模型名不能为空。"))
|
||||
return
|
||||
|
||||
try:
|
||||
models = await provider.get_models()
|
||||
except Exception as e:
|
||||
event.set_result(
|
||||
umo = message.unified_msg_origin
|
||||
config = self._get_model_lookup_config(umo)
|
||||
curr_provider_id = prov.meta().id
|
||||
|
||||
models = await self._get_models_or_reply_error(
|
||||
message,
|
||||
prov,
|
||||
config,
|
||||
error_prefix="获取当前提供商模型列表失败: ",
|
||||
warning_log="获取当前提供商 %s 模型列表失败,停止跨提供商查找: %s",
|
||||
)
|
||||
if models is None:
|
||||
return
|
||||
|
||||
matched_model_name = self._resolve_model_name(model_name, models)
|
||||
if matched_model_name is not None:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
f"获取模型列表失败: {safe_error('', e)}",
|
||||
self._apply_model(prov, matched_model_name, umo=umo)
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
current_model = provider.get_model()
|
||||
if idx_or_name is None:
|
||||
if not models:
|
||||
event.set_result(
|
||||
MessageEventResult().message(
|
||||
f"当前模型: {current_model}\n此提供商未返回可切换模型列表。",
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
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
|
||||
|
||||
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]
|
||||
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
|
||||
|
||||
if not selected_model:
|
||||
event.set_result(MessageEventResult().message("❌ Invalid model index."))
|
||||
return
|
||||
|
||||
provider.set_model(selected_model)
|
||||
provider.provider_config["model"] = selected_model
|
||||
|
||||
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()
|
||||
|
||||
event.set_result(
|
||||
MessageEventResult().message(
|
||||
f"✅ Successfully switched model to {selected_model}.",
|
||||
),
|
||||
target_prov, matched_target_model_name = await self._find_provider_for_model(
|
||||
model_name,
|
||||
exclude_provider_id=curr_provider_id,
|
||||
config=config,
|
||||
)
|
||||
|
||||
if target_prov is None or matched_target_model_name is None:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
f"模型 [{model_name}] 未在任何已配置的提供商中找到,或所有提供商模型列表获取失败,请检查配置或网络后重试。",
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
target_id = target_prov.meta().id
|
||||
try:
|
||||
await self.context.provider_manager.set_provider(
|
||||
provider_id=target_id,
|
||||
provider_type=ProviderType.CHAT_COMPLETION,
|
||||
umo=umo,
|
||||
)
|
||||
self._apply_model(target_prov, matched_target_model_name, umo=umo)
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
f"检测到模型 [{matched_target_model_name}] 属于提供商 [{target_id}],已自动切换提供商并设置模型。",
|
||||
),
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
safe_error("跨提供商切换并设置模型失败: ", e)
|
||||
),
|
||||
)
|
||||
|
||||
async def model_ls(
|
||||
self,
|
||||
message: AstrMessageEvent,
|
||||
idx_or_name: int | str | None = None,
|
||||
) -> None:
|
||||
"""查看或者切换模型"""
|
||||
prov = self.context.get_using_provider(message.unified_msg_origin)
|
||||
if not prov:
|
||||
message.set_result(
|
||||
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"),
|
||||
)
|
||||
return
|
||||
config = self._get_model_lookup_config(message.unified_msg_origin)
|
||||
|
||||
if idx_or_name is None:
|
||||
models = await self._get_models_or_reply_error(
|
||||
message,
|
||||
prov,
|
||||
config,
|
||||
error_prefix="获取模型列表失败: ",
|
||||
disable_t2i=True,
|
||||
)
|
||||
if models is None:
|
||||
return
|
||||
parts = ["下面列出了此模型提供商可用模型:"]
|
||||
for i, model in enumerate(models, 1):
|
||||
parts.append(f"\n{i}. {model}")
|
||||
|
||||
curr_model = prov.get_model() or "无"
|
||||
parts.append(f"\n当前模型: [{curr_model}]")
|
||||
parts.append(
|
||||
"\nTips: 使用 /model <模型名/编号> 切换模型。输入模型名时可自动跨提供商查找并切换;跨提供商也可使用 /provider 切换。"
|
||||
)
|
||||
|
||||
ret = "".join(parts)
|
||||
message.set_result(MessageEventResult().message(ret).use_t2i(False))
|
||||
elif isinstance(idx_or_name, int):
|
||||
models = await self._get_models_or_reply_error(
|
||||
message,
|
||||
prov,
|
||||
config,
|
||||
error_prefix="获取模型列表失败: ",
|
||||
)
|
||||
if models is None:
|
||||
return
|
||||
if idx_or_name > len(models) or idx_or_name < 1:
|
||||
message.set_result(MessageEventResult().message("模型序号错误。"))
|
||||
else:
|
||||
try:
|
||||
new_model = models[idx_or_name - 1]
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
self._apply_model(
|
||||
prov,
|
||||
new_model,
|
||||
umo=message.unified_msg_origin,
|
||||
)
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
safe_error("切换模型未知错误: ", e)
|
||||
),
|
||||
)
|
||||
return
|
||||
else:
|
||||
await self._switch_model_by_name(message, idx_or_name, prov)
|
||||
|
||||
async def key(self, message: AstrMessageEvent, index: int | None = None) -> None:
|
||||
prov = self.context.get_using_provider(message.unified_msg_origin)
|
||||
if not prov:
|
||||
message.set_result(
|
||||
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"),
|
||||
)
|
||||
return
|
||||
|
||||
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]}")
|
||||
|
||||
parts.append(f"\n当前 Key: {curr_key[:8]}")
|
||||
parts.append("\n当前模型: " + prov.get_model())
|
||||
parts.append("\n使用 /key <idx> 切换 Key。")
|
||||
|
||||
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)
|
||||
self.invalidate_provider_models_cache(
|
||||
prov.meta().id,
|
||||
umo=message.unified_msg_origin,
|
||||
)
|
||||
message.set_result(MessageEventResult().message("切换 Key 成功。"))
|
||||
except Exception as e:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
safe_error("切换 Key 未知错误: ", e)
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
@@ -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(
|
||||
@@ -174,6 +162,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,7 +2,6 @@ import asyncio
|
||||
import json
|
||||
import random
|
||||
import uuid
|
||||
from typing import ClassVar
|
||||
|
||||
import aiohttp
|
||||
from bs4 import BeautifulSoup
|
||||
@@ -15,21 +14,11 @@ 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:
|
||||
@@ -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]
|
||||
@@ -234,27 +203,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 +231,7 @@ class Main(star.Star):
|
||||
ret += processed_result
|
||||
|
||||
if websearch_link:
|
||||
ret += "\n\n针对问题,请根据上面的结果分点总结,并且在结尾处附上对应内容的参考链接(如有)。"
|
||||
ret += "\n\n针对问题,请根据上面的结果分点总结,并且在结尾处附上对应内容的参考链接(如有)。"
|
||||
|
||||
return ret
|
||||
|
||||
@@ -379,7 +339,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 +384,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 +441,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 +471,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 +487,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 +500,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 +529,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 +547,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 +564,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 +600,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.19.5"
|
||||
|
||||
@@ -1,128 +1,48 @@
|
||||
"""AstrBot CLI entry point"""
|
||||
|
||||
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:
|
||||
def help(command_name: str | None) -> None:
|
||||
"""Display help information for commands
|
||||
|
||||
If COMMAND_NAME is provided, display detailed help for that command.
|
||||
Otherwise, display general help information.
|
||||
"""
|
||||
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
|
||||
@@ -132,56 +52,8 @@ def help(command_name: str | None, all: bool) -> None:
|
||||
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,70 @@
|
||||
"""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.
|
||||
"""Validate log level"""
|
||||
value = value.upper()
|
||||
if value not in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
|
||||
raise click.ClickException(
|
||||
"Log level must be one of DEBUG/INFO/WARNING/ERROR/CRITICAL",
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _validate_dashboard_port(value: str) -> int:
|
||||
"""Validate Dashboard port"""
|
||||
try:
|
||||
port = int(value)
|
||||
if port < 1 or port > 65535:
|
||||
raise click.ClickException("Port must be in range 1-65535")
|
||||
return port
|
||||
except ValueError:
|
||||
raise click.ClickException("Port must be a number")
|
||||
|
||||
|
||||
def _validate_dashboard_username(value: str) -> str:
|
||||
"""Validate Dashboard username"""
|
||||
if not value:
|
||||
raise click.ClickException("Username cannot be empty")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_dashboard_password(value: str) -> str:
|
||||
"""Validate Dashboard password"""
|
||||
if not value:
|
||||
raise click.ClickException("Password cannot be empty")
|
||||
return hashlib.md5(value.encode()).hexdigest()
|
||||
|
||||
|
||||
def _validate_timezone(value: str) -> str:
|
||||
"""Validate timezone"""
|
||||
try:
|
||||
zoneinfo.ZoneInfo(value)
|
||||
except Exception as e:
|
||||
raise click.ClickException(t("config_timezone_invalid", value=value)) from e
|
||||
except Exception:
|
||||
raise click.ClickException(
|
||||
f"Invalid timezone: {value}. Please use a valid IANA timezone name"
|
||||
)
|
||||
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"))
|
||||
"""Validate callback API base URL"""
|
||||
if not value.startswith("http://") and not value.startswith("https://"):
|
||||
raise click.ClickException(
|
||||
"Callback API base must start with http:// or https://"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
# Configuration items settable via CLI, mapping config keys to validator functions
|
||||
CONFIG_VALIDATORS: dict[str, Callable[[str], Any]] = {
|
||||
"timezone": _validate_timezone,
|
||||
"log_level": _validate_log_level,
|
||||
@@ -100,22 +75,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:
|
||||
"""Load or initialize config file"""
|
||||
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",
|
||||
)
|
||||
|
||||
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 +95,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"Failed to parse config file: {e!s}")
|
||||
|
||||
|
||||
def _save_config(config: dict[str, Any]) -> None:
|
||||
config_path = astrbot_paths.data / "cmd_config.json"
|
||||
"""Save config file"""
|
||||
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:
|
||||
"""Set a value in a nested dictionary"""
|
||||
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",
|
||||
)
|
||||
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:
|
||||
"""Get a value from a nested dictionary"""
|
||||
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.
|
||||
"""Configuration management commands
|
||||
|
||||
Supported config keys:
|
||||
- timezone
|
||||
- log_level
|
||||
- dashboard.port
|
||||
- dashboard.username
|
||||
- dashboard.password
|
||||
- callback_api_base
|
||||
|
||||
- timezone: Timezone setting (e.g. Asia/Shanghai)
|
||||
|
||||
- log_level: Log level (DEBUG/INFO/WARNING/ERROR/CRITICAL)
|
||||
|
||||
- dashboard.port: Dashboard port
|
||||
|
||||
- dashboard.username: Dashboard username
|
||||
|
||||
- dashboard.password: Dashboard password
|
||||
|
||||
- callback_api_base: Callback API base URL
|
||||
"""
|
||||
|
||||
|
||||
@@ -248,122 +154,60 @@ def conf() -> None:
|
||||
@click.argument("key")
|
||||
@click.argument("value")
|
||||
def set_config(key: str, value: str) -> None:
|
||||
"""Set the value of a config item"""
|
||||
if key not in CONFIG_VALIDATORS:
|
||||
raise click.ClickException(f"Unsupported config key: {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
|
||||
if key == "dashboard.password":
|
||||
click.echo(" Old value: ********")
|
||||
click.echo(" New value: ********")
|
||||
else:
|
||||
click.echo(f" Old value: {old_value}")
|
||||
click.echo(f" New value: {validated_value}")
|
||||
|
||||
except KeyError:
|
||||
raise click.ClickException(f"Unknown config key: {key}")
|
||||
except Exception as e:
|
||||
raise click.UsageError(f"Failed to set config: {e!s}") from e
|
||||
raise click.UsageError(f"Failed to set config: {e!s}")
|
||||
|
||||
|
||||
@conf.command(name="get")
|
||||
@click.argument("key", required=False)
|
||||
def get_config(key: str | None = None) -> None:
|
||||
"""Get the value of a config item. If no key is provided, show all configurable items"""
|
||||
config = _load_config()
|
||||
|
||||
if key:
|
||||
if key not in CONFIG_VALIDATORS:
|
||||
raise click.ClickException(f"Unsupported config key: {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"Unknown config key: {key}")
|
||||
except Exception as e:
|
||||
raise click.UsageError(f"Failed to get config: {e!s}") from e
|
||||
raise click.UsageError(f"Failed to get config: {e!s}")
|
||||
else:
|
||||
click.echo("Current config:")
|
||||
for k in CONFIG_VALIDATORS:
|
||||
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,55 @@
|
||||
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:
|
||||
async def initialize_astrbot(astrbot_root: Path) -> 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()
|
||||
|
||||
dot_astrbot = astrbot_root / ".astrbot"
|
||||
|
||||
if not dot_astrbot.exists():
|
||||
if yes or click.confirm(
|
||||
f"确定要将 AstrBot 安装到以下目录吗?\n {astrbot_root}",
|
||||
if click.confirm(
|
||||
f"Install AstrBot to this directory? {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:
|
||||
def init() -> None:
|
||||
"""Initialize 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
|
||||
|
||||
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:
|
||||
asyncio.run(initialize_astrbot(astrbot_root))
|
||||
click.echo("Done! You can now run 'astrbot run' to start AstrBot")
|
||||
except Timeout:
|
||||
raise click.ClickException(
|
||||
"Cannot acquire lock file. Please check if another instance is running",
|
||||
) from err
|
||||
"Cannot acquire lock file. Please check if another instance is running"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise click.ClickException(f"Initialization failed: {e!s}") from e
|
||||
raise click.ClickException(f"Initialization failed: {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,28 +1,39 @@
|
||||
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} is not a valid AstrBot root directory. Use 'astrbot init' to initialize",
|
||||
)
|
||||
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}",
|
||||
f"{'Name':<20} {'Version':<10} {'Status':<10} {'Author':<15} {'Description':<30}"
|
||||
)
|
||||
click.echo("-" * 85)
|
||||
|
||||
@@ -38,13 +49,11 @@ def display_plugins(plugins, title=None, color=None) -> None:
|
||||
@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"Plugin {name} already exists")
|
||||
|
||||
author = click.prompt("Enter plugin author", type=str)
|
||||
desc = click.prompt("Enter plugin description", type=str)
|
||||
@@ -75,7 +84,7 @@ def new(name: str) -> None:
|
||||
# Rewrite 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"# {name}\n\n{desc}\n\n# Support\n\n[Documentation](https://astrbot.app)\n"
|
||||
)
|
||||
|
||||
# Rewrite main.py
|
||||
@@ -97,9 +106,7 @@ def new(name: str) -> None:
|
||||
@click.option("--all", "-a", is_flag=True, help="List uninstalled plugins")
|
||||
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
|
||||
@@ -140,9 +147,7 @@ def list(all: bool) -> None:
|
||||
@click.option("--proxy", help="Proxy server address")
|
||||
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 +161,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"Plugin {name} not found or already installed")
|
||||
|
||||
manage_plugin(plugin, plug_path, is_update=False, proxy=proxy)
|
||||
|
||||
@@ -165,26 +170,24 @@ def install(name: str, proxy: str | None) -> None:
|
||||
@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"Plugin {name} does not exist or is not installed")
|
||||
|
||||
plugin_path = plugin["local_path"]
|
||||
|
||||
click.confirm(t("plugin_uninstall_confirm", name=name), default=False, abort=True)
|
||||
click.confirm(
|
||||
f"Are you sure you want to uninstall plugin {name}?", default=False, abort=True
|
||||
)
|
||||
|
||||
try:
|
||||
shutil.rmtree(plugin_path)
|
||||
click.echo(t("plugin_uninstall_success", name=name))
|
||||
click.echo(f"Plugin {name} has been uninstalled")
|
||||
except Exception as e:
|
||||
raise click.ClickException(
|
||||
t("plugin_uninstall_failed_ex", name=name, error=str(e)),
|
||||
) from e
|
||||
raise click.ClickException(f"Failed to uninstall plugin {name}: {e}")
|
||||
|
||||
|
||||
@plug.command()
|
||||
@@ -192,9 +195,7 @@ def remove(name: str) -> None:
|
||||
@click.option("--proxy", help="GitHub proxy address")
|
||||
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")
|
||||
|
||||
@@ -210,7 +211,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",
|
||||
f"Plugin {name} does not need updating or cannot be updated"
|
||||
)
|
||||
|
||||
manage_plugin(plugin, plug_path, is_update=True, proxy=proxy)
|
||||
@@ -220,13 +221,13 @@ def update(name: str, proxy: str | None) -> None:
|
||||
]
|
||||
|
||||
if not need_update_plugins:
|
||||
click.echo(t("plugin_no_update_needed"))
|
||||
click.echo("No plugins need updating")
|
||||
return
|
||||
|
||||
click.echo(t("plugin_found_update", count=str(len(need_update_plugins))))
|
||||
click.echo(f"Found {len(need_update_plugins)} plugin(s) needing update")
|
||||
for plugin in need_update_plugins:
|
||||
plugin_name = plugin["name"]
|
||||
click.echo(t("plugin_updating", name=plugin_name))
|
||||
click.echo(f"Updating plugin {plugin_name}...")
|
||||
manage_plugin(plugin, plug_path, is_update=True, proxy=proxy)
|
||||
|
||||
|
||||
@@ -234,9 +235,7 @@ def update(name: str, proxy: str | None) -> None:
|
||||
@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 +247,7 @@ def search(query: str) -> None:
|
||||
]
|
||||
|
||||
if not matched_plugins:
|
||||
click.echo(t("plugin_search_no_result", query=query))
|
||||
click.echo(f"No plugins matching '{query}' found")
|
||||
return
|
||||
|
||||
display_plugins(matched_plugins, t("plugin_search_results", query=query), "cyan")
|
||||
display_plugins(matched_plugins, f"Search results: '{query}'", "cyan")
|
||||
|
||||
@@ -1,92 +1,13 @@
|
||||
"""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:
|
||||
@@ -94,11 +15,7 @@ async def run_astrbot(astrbot_root: Path) -> 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)
|
||||
await check_dashboard(astrbot_root / "data")
|
||||
|
||||
log_broker = LogBroker()
|
||||
LogManager.set_queue_handler(logger, log_broker)
|
||||
@@ -110,316 +27,38 @@ async def run_astrbot(astrbot_root: Path) -> None:
|
||||
|
||||
|
||||
@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.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:
|
||||
def run(reload: bool, port: str) -> None:
|
||||
"""Run AstrBot"""
|
||||
initialize_runtime_bootstrap()
|
||||
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",
|
||||
)
|
||||
|
||||
# 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")
|
||||
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.")
|
||||
except Timeout:
|
||||
raise click.ClickException(
|
||||
"Cannot acquire lock file. Please check if another instance is running",
|
||||
) from None
|
||||
"Cannot acquire lock file. Please check if another instance is running"
|
||||
)
|
||||
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"Runtime error: {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",
|
||||
]
|
||||
|
||||
@@ -42,6 +42,7 @@ async def check_dashboard(astrbot_root: Path) -> None:
|
||||
if click.confirm(
|
||||
"Install dashboard?",
|
||||
default=True,
|
||||
abort=True,
|
||||
):
|
||||
click.echo("Installing dashboard...")
|
||||
await download_dashboard(
|
||||
|
||||
@@ -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
|
||||
@@ -33,7 +32,7 @@ def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None:
|
||||
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)
|
||||
@@ -57,7 +56,7 @@ def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None:
|
||||
|
||||
# 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)
|
||||
@@ -84,7 +83,7 @@ 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]:
|
||||
def load_yaml_metadata(plugin_dir: Path) -> dict:
|
||||
"""Load plugin metadata from metadata.yaml file
|
||||
|
||||
Args:
|
||||
@@ -97,10 +96,7 @@ def load_yaml_metadata(plugin_dir: Path) -> dict[str, Any]:
|
||||
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)
|
||||
return {}
|
||||
@@ -176,8 +172,8 @@ def build_plug_list(plugins_dir: Path) -> list:
|
||||
)
|
||||
if (
|
||||
VersionComparator.compare_version(
|
||||
local_plugin["version"] or "",
|
||||
online_plugin["version"] or "",
|
||||
local_plugin["version"],
|
||||
online_plugin["version"],
|
||||
)
|
||||
< 0
|
||||
):
|
||||
@@ -189,10 +185,7 @@ def build_plug_list(plugins_dir: Path) -> list:
|
||||
# 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
|
||||
|
||||
@@ -226,7 +219,7 @@ def manage_plugin(
|
||||
# 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",
|
||||
f"Plugin {plugin_name} is not installed and cannot be updated"
|
||||
)
|
||||
|
||||
# Backup existing plugin
|
||||
@@ -245,7 +238,7 @@ def manage_plugin(
|
||||
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",
|
||||
f"Plugin {plugin_name} {'updated' if is_update else 'installed'} successfully"
|
||||
)
|
||||
except Exception as e:
|
||||
if target_path.exists():
|
||||
@@ -254,4 +247,4 @@ def manage_plugin(
|
||||
shutil.move(backup_path, target_path)
|
||||
raise click.ClickException(
|
||||
f"Error {'updating' if is_update else 'installing'} plugin {plugin_name}: {e}",
|
||||
) from e
|
||||
)
|
||||
|
||||
@@ -62,9 +62,12 @@ 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
|
||||
elif isinstance(p1, str) and isinstance(p2, str):
|
||||
if p1 > p2:
|
||||
return 1
|
||||
if p1 < p2:
|
||||
|
||||
@@ -22,29 +22,11 @@ from astrbot.core.utils.requirements_utils import (
|
||||
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")
|
||||
|
||||
@@ -52,11 +34,7 @@ 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 +45,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,4 +1,4 @@
|
||||
from astrbot.core.agent.message import Message
|
||||
from ..message import Message
|
||||
|
||||
|
||||
class ContextTruncator:
|
||||
@@ -12,76 +12,14 @@ class ContextTruncator:
|
||||
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.
|
||||
"""修复消息列表,确保 tool call 和 tool response 的配对关系有效。
|
||||
|
||||
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 `
|
||||
此方法确保:
|
||||
1. 每个 `tool` 消息前面都有一个包含 tool_calls 的 `assistant` 消息
|
||||
2. 每个包含 tool_calls 的 `assistant` 消息后面都有对应的 `tool` 响应
|
||||
|
||||
This is a requirement of the OpenAI Chat Completions API specification (Gemini enforces this strictly).
|
||||
这是 OpenAI Chat Completions API 规范的要求(Gemini 对此执行严格检查)。
|
||||
"""
|
||||
if not messages:
|
||||
return messages
|
||||
@@ -100,25 +38,24 @@ class ContextTruncator:
|
||||
|
||||
for msg in messages:
|
||||
if msg.role == "tool":
|
||||
# Only record tool responses when there is a pending assistant(tool_calls)
|
||||
# 只有在有挂起的 assistant(tool_calls) 时才记录 tool 响应
|
||||
if pending_assistant is not None:
|
||||
pending_tools.append(msg)
|
||||
# Isolated tool messages without a preceding assistant(tool_calls) are ignored
|
||||
# else: 孤立的 tool 消息,直接忽略
|
||||
continue
|
||||
|
||||
if self._has_tool_calls(msg):
|
||||
# When encountering a new assistant(tool_calls), first process the old pending chain
|
||||
# 遇到新的 assistant(tool_calls) 前,先处理旧的 pending 链
|
||||
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.
|
||||
# 非 tool,且不含 tool_calls 的消息
|
||||
# 先结束任何 pending 链,再正常追加
|
||||
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.
|
||||
# 结束时处理最后一个 pending 链
|
||||
flush_pending_if_valid()
|
||||
|
||||
return fixed_messages
|
||||
@@ -129,23 +66,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 +99,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 +107,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 +116,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 +170,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 +177,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)
|
||||
|
||||
@@ -15,6 +15,7 @@ class HandoffTool(FunctionTool, Generic[TContext]):
|
||||
tool_description: str | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
|
||||
# Avoid passing duplicate `description` to the FunctionTool dataclass.
|
||||
# Some call sites (e.g. SubAgentOrchestrator) pass `description` via kwargs
|
||||
# to override what the main agent sees, while we also compute a default
|
||||
@@ -61,4 +62,4 @@ class HandoffTool(FunctionTool, Generic[TContext]):
|
||||
|
||||
def default_description(self, agent_name: str | None) -> str:
|
||||
agent_name = agent_name or "another"
|
||||
return f"Delegate tasks to {agent_name} agent to handle the request."
|
||||
return f"Delegate tasks to {self.name} agent to handle the request."
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
"""MCP client
|
||||
This file exists solely for backward compatibility and will be removed in a future version.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from contextlib import AsyncExitStack
|
||||
from datetime import timedelta
|
||||
from pathlib import Path, PureWindowsPath
|
||||
from typing import Any, Generic, TextIO
|
||||
from typing import Generic
|
||||
|
||||
import httpx
|
||||
from tenacity import (
|
||||
before_sleep_log,
|
||||
retry,
|
||||
@@ -22,128 +12,28 @@ from tenacity import (
|
||||
wait_exponential,
|
||||
)
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.utils.log_pipe import LogPipe
|
||||
|
||||
from .run_context import TContext
|
||||
from .tool import FunctionTool
|
||||
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
_DEFAULT_STDIO_COMMAND_ALLOWLIST = frozenset(
|
||||
{
|
||||
"python",
|
||||
"python3",
|
||||
"py",
|
||||
"node",
|
||||
"npx",
|
||||
"npm",
|
||||
"pnpm",
|
||||
"yarn",
|
||||
"bun",
|
||||
"bunx",
|
||||
"deno",
|
||||
"uv",
|
||||
"uvx",
|
||||
}
|
||||
)
|
||||
_DENIED_STDIO_COMMANDS = frozenset(
|
||||
{
|
||||
"bash",
|
||||
"sh",
|
||||
"zsh",
|
||||
"fish",
|
||||
"cmd",
|
||||
"cmd.exe",
|
||||
"powershell",
|
||||
"powershell.exe",
|
||||
"pwsh",
|
||||
"pwsh.exe",
|
||||
"osascript",
|
||||
"open",
|
||||
"curl",
|
||||
"wget",
|
||||
"nc",
|
||||
"netcat",
|
||||
"telnet",
|
||||
"ssh",
|
||||
"scp",
|
||||
"rm",
|
||||
"mv",
|
||||
"cp",
|
||||
"dd",
|
||||
"mkfs",
|
||||
"sudo",
|
||||
"su",
|
||||
"chmod",
|
||||
"chown",
|
||||
"kill",
|
||||
"killall",
|
||||
"shutdown",
|
||||
"reboot",
|
||||
"poweroff",
|
||||
"halt",
|
||||
}
|
||||
)
|
||||
_SHELL_META_RE = re.compile(r"[\r\n\x00;&|<>`$]")
|
||||
_PYTHON_INLINE_CODE_FLAGS = frozenset({"-c"})
|
||||
_JS_INLINE_CODE_FLAGS = frozenset({"-e", "--eval", "-p", "--print"})
|
||||
_DENIED_DOCKER_ARGS = frozenset(
|
||||
{
|
||||
"--privileged",
|
||||
"--pid=host",
|
||||
"--network=host",
|
||||
"--net=host",
|
||||
"--ipc=host",
|
||||
}
|
||||
)
|
||||
_STDIO_ALLOWLIST_ENV = "ASTRBOT_MCP_STDIO_ALLOWED_COMMANDS"
|
||||
|
||||
try:
|
||||
import anyio
|
||||
import mcp
|
||||
from mcp.client.sse import sse_client
|
||||
except (ModuleNotFoundError, ImportError):
|
||||
logger.warning(
|
||||
"Warning: Missing 'mcp' dependency, MCP services will be unavailable.",
|
||||
"Warning: Missing 'mcp' dependency, MCP services will be unavailable."
|
||||
)
|
||||
|
||||
streamable_http_client_legacy = None
|
||||
streamable_http_client = None
|
||||
|
||||
try:
|
||||
from mcp.client.streamable_http import (
|
||||
streamablehttp_client as streamable_http_client_legacy,
|
||||
)
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
except (ModuleNotFoundError, ImportError):
|
||||
try:
|
||||
from mcp.client.streamable_http import (
|
||||
streamable_http_client as streamable_http_client,
|
||||
)
|
||||
except (ModuleNotFoundError, ImportError):
|
||||
logger.warning(
|
||||
"Warning: Missing 'mcp' dependency or MCP library version too old, Streamable HTTP connection unavailable.",
|
||||
)
|
||||
|
||||
|
||||
class TenacityLogger:
|
||||
"""Wraps a logging.Logger to satisfy tenacity's LoggerProtocol."""
|
||||
|
||||
__slots__ = ("_logger",)
|
||||
_logger: logging.Logger
|
||||
|
||||
def __init__(self, logger: logging.Logger) -> None:
|
||||
self._logger = logger
|
||||
|
||||
def log(
|
||||
self,
|
||||
level: int,
|
||||
msg: str,
|
||||
/,
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
self._logger.log(level, msg, *args, **kwargs)
|
||||
logger.warning(
|
||||
"Warning: Missing 'mcp' dependency or MCP library version too old, Streamable HTTP connection unavailable.",
|
||||
)
|
||||
|
||||
|
||||
def _prepare_config(config: dict) -> dict:
|
||||
@@ -155,144 +45,6 @@ def _prepare_config(config: dict) -> dict:
|
||||
return config
|
||||
|
||||
|
||||
def _normalize_stdio_command_name(command: str) -> str:
|
||||
command = command.strip()
|
||||
if "\\" in command:
|
||||
command_name = PureWindowsPath(command).name
|
||||
else:
|
||||
command_name = Path(command).name
|
||||
command_name = command_name.lower()
|
||||
for suffix in (".exe", ".cmd", ".bat"):
|
||||
if command_name.endswith(suffix):
|
||||
return command_name[: -len(suffix)]
|
||||
return command_name
|
||||
|
||||
|
||||
def _get_stdio_command_allowlist() -> set[str]:
|
||||
allowed = set(_DEFAULT_STDIO_COMMAND_ALLOWLIST)
|
||||
configured = os.environ.get(_STDIO_ALLOWLIST_ENV, "")
|
||||
if configured.strip():
|
||||
allowed = {
|
||||
_normalize_stdio_command_name(item)
|
||||
for item in configured.split(",")
|
||||
if item.strip()
|
||||
}
|
||||
return allowed
|
||||
|
||||
|
||||
def _validate_stdio_args(command_name: str, args: object) -> None:
|
||||
if args is None:
|
||||
return
|
||||
if not isinstance(args, list) or not all(isinstance(arg, str) for arg in args):
|
||||
raise ValueError("MCP stdio args must be a list of strings.")
|
||||
|
||||
for arg in args:
|
||||
if "\x00" in arg or "\r" in arg or "\n" in arg:
|
||||
raise ValueError("MCP stdio args cannot contain control characters.")
|
||||
|
||||
if command_name.startswith("python") or command_name == "py":
|
||||
if any(
|
||||
arg == "-c"
|
||||
or (arg.startswith("-") and not arg.startswith("--") and "c" in arg)
|
||||
for arg in args
|
||||
):
|
||||
raise ValueError(
|
||||
"MCP stdio Python servers must be launched from a module or file; inline code flags such as -c are not allowed."
|
||||
)
|
||||
elif command_name in {"node", "deno", "bun"} or command_name.startswith("node"):
|
||||
if any(
|
||||
arg in _JS_INLINE_CODE_FLAGS
|
||||
or arg == "eval"
|
||||
or (
|
||||
arg.startswith("-")
|
||||
and not arg.startswith("--")
|
||||
and any(c in arg for c in "ep")
|
||||
)
|
||||
for arg in args
|
||||
):
|
||||
raise ValueError(
|
||||
"MCP stdio JavaScript servers must be launched from a package or file; inline eval flags are not allowed."
|
||||
)
|
||||
elif command_name == "docker":
|
||||
denied = []
|
||||
for i, arg in enumerate(args):
|
||||
if arg in _DENIED_DOCKER_ARGS:
|
||||
denied.append(arg)
|
||||
elif (
|
||||
arg in {"--network", "--net", "--pid", "--ipc"}
|
||||
and i + 1 < len(args)
|
||||
and args[i + 1] == "host"
|
||||
):
|
||||
denied.append(f"{arg} {args[i + 1]}")
|
||||
if denied:
|
||||
raise ValueError(
|
||||
f"MCP stdio Docker args are unsafe and not allowed: {', '.join(denied)}."
|
||||
)
|
||||
|
||||
|
||||
def validate_mcp_stdio_config(config: dict) -> None:
|
||||
"""Validate MCP stdio configuration in a backward-compatible way."""
|
||||
cfg = _prepare_config(config.copy())
|
||||
if "url" in cfg:
|
||||
return
|
||||
|
||||
command = cfg.get("command")
|
||||
if not isinstance(command, str) or not command.strip():
|
||||
raise ValueError("MCP stdio server requires a non-empty command.")
|
||||
if _SHELL_META_RE.search(command):
|
||||
raise ValueError("MCP stdio command contains unsafe shell metacharacters.")
|
||||
|
||||
command_name = _normalize_stdio_command_name(command)
|
||||
if command_name in _DENIED_STDIO_COMMANDS:
|
||||
raise ValueError(f"MCP stdio command `{command_name}` is not allowed.")
|
||||
|
||||
allowed = _get_stdio_command_allowlist()
|
||||
if command_name not in allowed:
|
||||
allowed_display = ", ".join(sorted(allowed))
|
||||
raise ValueError(
|
||||
f"MCP stdio command `{command_name}` is not allowed. "
|
||||
f"Allowed commands: {allowed_display}. "
|
||||
f"Set {_STDIO_ALLOWLIST_ENV} to override this list if you trust another launcher."
|
||||
)
|
||||
|
||||
_validate_stdio_args(command_name, cfg.get("args"))
|
||||
|
||||
env = cfg.get("env")
|
||||
if env is not None and not isinstance(env, dict):
|
||||
raise ValueError("MCP stdio env must be an object.")
|
||||
if isinstance(env, dict) and not all(
|
||||
isinstance(key, str) and isinstance(value, str) for key, value in env.items()
|
||||
):
|
||||
raise ValueError("MCP stdio env keys and values must be strings.")
|
||||
|
||||
|
||||
def _prepare_stdio_env(config: dict) -> dict:
|
||||
"""Preserve Windows executable resolution for stdio subprocesses."""
|
||||
if sys.platform != "win32":
|
||||
return config
|
||||
prepared = config.copy()
|
||||
env = dict(prepared.get("env") or {})
|
||||
env = _merge_environment_variables(env)
|
||||
prepared["env"] = env
|
||||
return prepared
|
||||
|
||||
|
||||
def _merge_environment_variables(env: dict) -> dict:
|
||||
"""Merge environment variables in case-insensitive systems."""
|
||||
merged = env.copy()
|
||||
|
||||
# Use lower-case keys for case-insensitive matching on Windows.
|
||||
user_keys_lower = {k.lower(): k for k in merged.keys()}
|
||||
|
||||
for sys_key, sys_value in os.environ.items():
|
||||
sys_key_lower = sys_key.lower()
|
||||
if sys_key_lower not in user_keys_lower:
|
||||
# 使用系统环境变量中的原始大小写
|
||||
merged[sys_key] = sys_value
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]:
|
||||
"""Quick test MCP server connectivity"""
|
||||
import aiohttp
|
||||
@@ -349,65 +101,12 @@ async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]:
|
||||
return True, ""
|
||||
return False, f"HTTP {response.status}: {response.reason}"
|
||||
|
||||
except TimeoutError:
|
||||
except asyncio.TimeoutError:
|
||||
return False, f"Connection timeout: {timeout} seconds"
|
||||
except Exception as e:
|
||||
return False, f"{e!s}"
|
||||
|
||||
|
||||
def _normalize_mcp_input_schema(schema: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Normalize common non-standard MCP JSON Schema variants.
|
||||
|
||||
Some MCP servers incorrectly mark required properties with a boolean
|
||||
`required: true` on the property schema itself. Draft 2020-12 requires the
|
||||
parent object to declare `required` as an array of property names instead.
|
||||
We lift those booleans to the parent object so the schema remains usable
|
||||
without disabling validation entirely.
|
||||
"""
|
||||
|
||||
def _normalize(node: Any) -> Any:
|
||||
if isinstance(node, list):
|
||||
return [_normalize(item) for item in node]
|
||||
|
||||
if not isinstance(node, dict):
|
||||
return node
|
||||
|
||||
normalized = {key: _normalize(value) for key, value in node.items()}
|
||||
|
||||
properties = normalized.get("properties")
|
||||
if isinstance(properties, dict):
|
||||
original_properties = node.get("properties")
|
||||
if not isinstance(original_properties, dict):
|
||||
original_properties = {}
|
||||
required = normalized.get("required")
|
||||
required_list = required[:] if isinstance(required, list) else []
|
||||
|
||||
for prop_name, prop_schema in properties.items():
|
||||
if not isinstance(prop_schema, dict):
|
||||
continue
|
||||
|
||||
original_prop_schema = (original_properties or {}).get(prop_name, {})
|
||||
prop_required = (
|
||||
original_prop_schema.get("required")
|
||||
if isinstance(original_prop_schema, dict)
|
||||
else None
|
||||
)
|
||||
if isinstance(prop_required, bool):
|
||||
if prop_schema.get("required") is prop_required:
|
||||
prop_schema.pop("required", None)
|
||||
if prop_required:
|
||||
required_list.append(prop_name)
|
||||
|
||||
if required_list:
|
||||
normalized["required"] = list(dict.fromkeys(required_list))
|
||||
elif isinstance(required, list):
|
||||
normalized.pop("required", None)
|
||||
|
||||
return normalized
|
||||
|
||||
return _normalize(copy.deepcopy(schema))
|
||||
|
||||
|
||||
class MCPClient:
|
||||
def __init__(self) -> None:
|
||||
# Initialize session and client objects
|
||||
@@ -420,7 +119,6 @@ class MCPClient:
|
||||
self.tools: list[mcp.Tool] = []
|
||||
self.server_errlogs: list[str] = []
|
||||
self.running_event = asyncio.Event()
|
||||
self.process_pid: int | None = None
|
||||
|
||||
# Store connection config for reconnection
|
||||
self._mcp_server_config: dict | None = None
|
||||
@@ -428,24 +126,6 @@ class MCPClient:
|
||||
self._reconnect_lock = asyncio.Lock() # Lock for thread-safe reconnection
|
||||
self._reconnecting: bool = False # For logging and debugging
|
||||
|
||||
@staticmethod
|
||||
def _extract_stdio_process_pid(streams_context: object) -> int | None:
|
||||
"""Best-effort extraction for stdio subprocess PID used by lease cleanup.
|
||||
|
||||
TODO(refactor): replace this async-generator frame introspection with a
|
||||
stable MCP library hook once the upstream transport exposes process PID.
|
||||
"""
|
||||
generator = getattr(streams_context, "gen", None)
|
||||
frame = getattr(generator, "ag_frame", None)
|
||||
if frame is None:
|
||||
return None
|
||||
process = frame.f_locals.get("process")
|
||||
pid = getattr(process, "pid", None)
|
||||
try:
|
||||
return int(pid) if pid is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
async def connect_to_server(self, mcp_server_config: dict, name: str) -> None:
|
||||
"""Connect to MCP server
|
||||
|
||||
@@ -461,17 +141,17 @@ class MCPClient:
|
||||
# Store config for reconnection
|
||||
self._mcp_server_config = mcp_server_config
|
||||
self._server_name = name
|
||||
self.process_pid = None
|
||||
|
||||
cfg = _prepare_config(mcp_server_config.copy())
|
||||
|
||||
async def logging_callback(
|
||||
params: mcp.types.LoggingMessageNotificationParams,
|
||||
def logging_callback(
|
||||
msg: str | mcp.types.LoggingMessageNotificationParams,
|
||||
) -> None:
|
||||
# Handle MCP service error logs
|
||||
if params.level in ("warning", "error", "critical", "alert", "emergency"):
|
||||
log_msg = f"[{params.level.upper()}] {params.data!s}"
|
||||
self.server_errlogs.append(log_msg)
|
||||
if isinstance(msg, mcp.types.LoggingMessageNotificationParams):
|
||||
if msg.level in ("warning", "error", "critical", "alert", "emergency"):
|
||||
log_msg = f"[{msg.level.upper()}] {str(msg.data)}"
|
||||
self.server_errlogs.append(log_msg)
|
||||
|
||||
if "url" in cfg:
|
||||
success, error_msg = await _quick_test_mcp_connection(cfg)
|
||||
@@ -493,72 +173,47 @@ class MCPClient:
|
||||
timeout=cfg.get("timeout", 5),
|
||||
sse_read_timeout=cfg.get("sse_read_timeout", 60 * 5),
|
||||
)
|
||||
read_stream, write_stream = await self.exit_stack.enter_async_context(
|
||||
streams = await self.exit_stack.enter_async_context(
|
||||
self._streams_context,
|
||||
)
|
||||
|
||||
# Create a new client session
|
||||
read_timeout = timedelta(seconds=cfg.get("session_read_timeout", 60))
|
||||
session = await self.exit_stack.enter_async_context(
|
||||
self.session = await self.exit_stack.enter_async_context(
|
||||
mcp.ClientSession(
|
||||
read_stream=read_stream,
|
||||
write_stream=write_stream,
|
||||
*streams,
|
||||
read_timeout_seconds=read_timeout,
|
||||
logging_callback=logging_callback,
|
||||
logging_callback=logging_callback, # type: ignore
|
||||
),
|
||||
)
|
||||
self.session = session
|
||||
else:
|
||||
timeout_seconds = cfg.get("timeout", 30)
|
||||
sse_read_timeout_seconds = cfg.get("sse_read_timeout", 60 * 5)
|
||||
if streamable_http_client_legacy:
|
||||
timeout = timedelta(seconds=timeout_seconds)
|
||||
sse_read_timeout = timedelta(seconds=sse_read_timeout_seconds)
|
||||
self._streams_context = streamable_http_client_legacy(
|
||||
url=cfg["url"],
|
||||
headers=cfg.get("headers", {}),
|
||||
timeout=timeout,
|
||||
sse_read_timeout=sse_read_timeout,
|
||||
terminate_on_close=cfg.get("terminate_on_close", True),
|
||||
)
|
||||
elif streamable_http_client:
|
||||
http_client = await self.exit_stack.enter_async_context(
|
||||
httpx.AsyncClient(
|
||||
headers=cfg.get("headers", {}),
|
||||
timeout=httpx.Timeout(
|
||||
timeout_seconds,
|
||||
read=sse_read_timeout_seconds,
|
||||
),
|
||||
follow_redirects=True,
|
||||
),
|
||||
)
|
||||
self._streams_context = streamable_http_client(
|
||||
url=cfg["url"],
|
||||
http_client=http_client,
|
||||
terminate_on_close=cfg.get("terminate_on_close", True),
|
||||
)
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"Streamable HTTP transport is not available in the installed MCP library version."
|
||||
)
|
||||
timeout = timedelta(seconds=cfg.get("timeout", 30))
|
||||
sse_read_timeout = timedelta(
|
||||
seconds=cfg.get("sse_read_timeout", 60 * 5),
|
||||
)
|
||||
self._streams_context = streamablehttp_client(
|
||||
url=cfg["url"],
|
||||
headers=cfg.get("headers", {}),
|
||||
timeout=timeout,
|
||||
sse_read_timeout=sse_read_timeout,
|
||||
terminate_on_close=cfg.get("terminate_on_close", True),
|
||||
)
|
||||
read_s, write_s, _ = await self.exit_stack.enter_async_context(
|
||||
self._streams_context,
|
||||
)
|
||||
|
||||
# Create a new client session
|
||||
read_timeout = timedelta(seconds=cfg.get("session_read_timeout", 60))
|
||||
session = await self.exit_stack.enter_async_context(
|
||||
self.session = await self.exit_stack.enter_async_context(
|
||||
mcp.ClientSession(
|
||||
read_stream=read_s,
|
||||
write_stream=write_s,
|
||||
read_timeout_seconds=read_timeout,
|
||||
logging_callback=logging_callback,
|
||||
logging_callback=logging_callback, # type: ignore
|
||||
),
|
||||
)
|
||||
self.session = session
|
||||
|
||||
else:
|
||||
cfg = _prepare_stdio_env(cfg)
|
||||
server_params = mcp.StdioServerParameters(
|
||||
**cfg,
|
||||
)
|
||||
@@ -573,35 +228,25 @@ class MCPClient:
|
||||
"alert",
|
||||
"emergency",
|
||||
):
|
||||
log_msg = f"[{msg.level.upper()}] {msg.data!s}"
|
||||
log_msg = f"[{msg.level.upper()}] {str(msg.data)}"
|
||||
self.server_errlogs.append(log_msg)
|
||||
|
||||
log_pipe = self.exit_stack.enter_context(
|
||||
LogPipe(
|
||||
level=logging.INFO,
|
||||
logger=logger,
|
||||
identifier=f"MCPServer-{name}",
|
||||
callback=callback,
|
||||
),
|
||||
)
|
||||
errlog_stream: TextIO = self.exit_stack.enter_context(
|
||||
os.fdopen(os.dup(log_pipe.fileno()), "w"),
|
||||
)
|
||||
stdio_transport = await self.exit_stack.enter_async_context(
|
||||
mcp.stdio_client(
|
||||
server_params,
|
||||
errlog=errlog_stream,
|
||||
errlog=LogPipe(
|
||||
level=logging.INFO,
|
||||
logger=logger,
|
||||
identifier=f"MCPServer-{name}",
|
||||
callback=callback,
|
||||
), # type: ignore
|
||||
),
|
||||
)
|
||||
self.process_pid = self._extract_stdio_process_pid(stdio_transport)
|
||||
|
||||
# Create a new client session
|
||||
session = await self.exit_stack.enter_async_context(
|
||||
self.session = await self.exit_stack.enter_async_context(
|
||||
mcp.ClientSession(*stdio_transport),
|
||||
)
|
||||
self.session = session
|
||||
|
||||
assert self.session is not None
|
||||
await self.session.initialize()
|
||||
|
||||
async def list_tools_and_save(self) -> mcp.ListToolsResult:
|
||||
@@ -619,13 +264,12 @@ class MCPClient:
|
||||
|
||||
Raises:
|
||||
Exception: raised when reconnection fails
|
||||
|
||||
"""
|
||||
async with self._reconnect_lock:
|
||||
# Check if already reconnecting (useful for logging)
|
||||
if self._reconnecting:
|
||||
logger.debug(
|
||||
f"MCP Client {self._server_name} is already reconnecting, skipping",
|
||||
f"MCP Client {self._server_name} is already reconnecting, skipping"
|
||||
)
|
||||
return
|
||||
|
||||
@@ -635,7 +279,7 @@ class MCPClient:
|
||||
self._reconnecting = True
|
||||
try:
|
||||
logger.info(
|
||||
f"Attempting to reconnect to MCP server {self._server_name}...",
|
||||
f"Attempting to reconnect to MCP server {self._server_name}..."
|
||||
)
|
||||
|
||||
# Save old exit_stack for later cleanup (don't close it now to avoid cancel scope issues)
|
||||
@@ -653,11 +297,11 @@ class MCPClient:
|
||||
await self.list_tools_and_save()
|
||||
|
||||
logger.info(
|
||||
f"Successfully reconnected to MCP server {self._server_name}",
|
||||
f"Successfully reconnected to MCP server {self._server_name}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to reconnect to MCP server {self._server_name}: {e}",
|
||||
f"Failed to reconnect to MCP server {self._server_name}: {e}"
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
@@ -682,14 +326,13 @@ class MCPClient:
|
||||
Raises:
|
||||
ValueError: MCP session is not available
|
||||
anyio.ClosedResourceError: raised after reconnection failure
|
||||
|
||||
"""
|
||||
|
||||
@retry(
|
||||
retry=retry_if_exception_type(anyio.ClosedResourceError),
|
||||
stop=stop_after_attempt(2),
|
||||
wait=wait_exponential(multiplier=1, min=1, max=3),
|
||||
before_sleep=before_sleep_log(TenacityLogger(logger), logging.WARNING),
|
||||
before_sleep=before_sleep_log(logger, logging.WARNING),
|
||||
reraise=True,
|
||||
)
|
||||
async def _call_with_retry():
|
||||
@@ -704,7 +347,7 @@ class MCPClient:
|
||||
)
|
||||
except anyio.ClosedResourceError:
|
||||
logger.warning(
|
||||
f"MCP tool {tool_name} call failed (ClosedResourceError), attempting to reconnect...",
|
||||
f"MCP tool {tool_name} call failed (ClosedResourceError), attempting to reconnect..."
|
||||
)
|
||||
# Attempt to reconnect
|
||||
await self._reconnect()
|
||||
@@ -728,33 +371,25 @@ class MCPClient:
|
||||
|
||||
# Set running_event first to unblock any waiting tasks
|
||||
self.running_event.set()
|
||||
self.process_pid = None
|
||||
|
||||
|
||||
class MCPTool(FunctionTool, Generic[TContext]):
|
||||
"""A function tool that calls an MCP service."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mcp_tool: mcp.Tool,
|
||||
mcp_client: MCPClient,
|
||||
mcp_server_name: str,
|
||||
**kwargs,
|
||||
self, mcp_tool: mcp.Tool, mcp_client: MCPClient, mcp_server_name: str, **kwargs
|
||||
) -> None:
|
||||
super().__init__(
|
||||
name=mcp_tool.name,
|
||||
description=mcp_tool.description or "",
|
||||
parameters=_normalize_mcp_input_schema(mcp_tool.inputSchema),
|
||||
parameters=mcp_tool.inputSchema,
|
||||
)
|
||||
self.mcp_tool = mcp_tool
|
||||
self.mcp_client = mcp_client
|
||||
self.mcp_server_name = mcp_server_name
|
||||
self.source = "mcp"
|
||||
|
||||
async def call(
|
||||
self,
|
||||
context: ContextWrapper[TContext],
|
||||
**kwargs,
|
||||
self, context: ContextWrapper[TContext], **kwargs
|
||||
) -> mcp.types.CallToolResult:
|
||||
return await self.mcp_client.call_tool_with_reconnect(
|
||||
tool_name=self.mcp_tool.name,
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
# Inspired by MoonshotAI/kosong, credits to MoonshotAI/kosong authors for the original implementation.
|
||||
# License: Apache License 2.0
|
||||
|
||||
from typing import Any, ClassVar, Literal, Self, TypeVar, cast
|
||||
from typing import Any, ClassVar, Literal, cast
|
||||
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
GetCoreSchemaHandler,
|
||||
PrivateAttr,
|
||||
ValidationError,
|
||||
model_serializer,
|
||||
model_validator,
|
||||
)
|
||||
from pydantic_core import core_schema
|
||||
|
||||
ContentPartT = TypeVar("ContentPartT", bound="ContentPart")
|
||||
|
||||
|
||||
class ContentPart(BaseModel):
|
||||
"""A part of the content in a message."""
|
||||
@@ -22,7 +19,6 @@ class ContentPart(BaseModel):
|
||||
__content_part_registry: ClassVar[dict[str, type["ContentPart"]]] = {}
|
||||
|
||||
type: Literal["text", "think", "image_url", "audio_url"]
|
||||
_no_save: bool = PrivateAttr(default=False)
|
||||
|
||||
def __init_subclass__(cls, **kwargs: Any) -> None:
|
||||
super().__init_subclass__(**kwargs)
|
||||
@@ -37,9 +33,7 @@ class ContentPart(BaseModel):
|
||||
|
||||
@classmethod
|
||||
def __get_pydantic_core_schema__(
|
||||
cls,
|
||||
source_type: Any,
|
||||
handler: GetCoreSchemaHandler,
|
||||
cls, source_type: Any, handler: GetCoreSchemaHandler
|
||||
) -> core_schema.CoreSchema:
|
||||
# If we're dealing with the base ContentPart class, use custom validation
|
||||
if cls.__name__ == "ContentPart":
|
||||
@@ -51,14 +45,11 @@ class ContentPart(BaseModel):
|
||||
|
||||
# if it's a dict with a type field, dispatch to the appropriate subclass
|
||||
if isinstance(value, dict) and "type" in value:
|
||||
type_value: Any | None = cast("dict[str, Any]", value).get("type")
|
||||
type_value: Any | None = cast(dict[str, Any], value).get("type")
|
||||
if not isinstance(type_value, str):
|
||||
raise ValueError(f"Cannot validate {value} as ContentPart")
|
||||
target_class = cls.__content_part_registry[type_value]
|
||||
part = target_class.model_validate(value)
|
||||
if cast("dict[str, Any]", value).get("_no_save"):
|
||||
part._no_save = True
|
||||
return part
|
||||
return target_class.model_validate(value)
|
||||
|
||||
raise ValueError(f"Cannot validate {value} as ContentPart")
|
||||
|
||||
@@ -67,20 +58,10 @@ class ContentPart(BaseModel):
|
||||
# for subclasses, use the default schema
|
||||
return handler(source_type)
|
||||
|
||||
def mark_as_temp(self) -> Self:
|
||||
"""Mark this content part as provider-facing only, not persisted."""
|
||||
self._no_save = True
|
||||
return self
|
||||
|
||||
def model_dump_for_context(self) -> dict[str, Any]:
|
||||
data = self.model_dump()
|
||||
if self._no_save:
|
||||
data["_no_save"] = True
|
||||
return data
|
||||
|
||||
|
||||
class TextPart(ContentPart):
|
||||
"""TextPart(text="Hello, world!").model_dump()
|
||||
"""
|
||||
>>> TextPart(text="Hello, world!").model_dump()
|
||||
{'type': 'text', 'text': 'Hello, world!'}
|
||||
"""
|
||||
|
||||
@@ -89,7 +70,8 @@ class TextPart(ContentPart):
|
||||
|
||||
|
||||
class ThinkPart(ContentPart):
|
||||
"""ThinkPart(think="I think I need to think about this.").model_dump()
|
||||
"""
|
||||
>>> ThinkPart(think="I think I need to think about this.").model_dump()
|
||||
{'type': 'think', 'think': 'I think I need to think about this.', 'encrypted': None}
|
||||
"""
|
||||
|
||||
@@ -110,7 +92,8 @@ class ThinkPart(ContentPart):
|
||||
|
||||
|
||||
class ImageURLPart(ContentPart):
|
||||
"""ImageURLPart(image_url="http://example.com/image.jpg").model_dump()
|
||||
"""
|
||||
>>> ImageURLPart(image_url="http://example.com/image.jpg").model_dump()
|
||||
{'type': 'image_url', 'image_url': 'http://example.com/image.jpg'}
|
||||
"""
|
||||
|
||||
@@ -125,7 +108,8 @@ class ImageURLPart(ContentPart):
|
||||
|
||||
|
||||
class AudioURLPart(ContentPart):
|
||||
"""AudioURLPart(audio_url=AudioURLPart.AudioURL(url="https://example.com/audio.mp3")).model_dump()
|
||||
"""
|
||||
>>> AudioURLPart(audio_url=AudioURLPart.AudioURL(url="https://example.com/audio.mp3")).model_dump()
|
||||
{'type': 'audio_url', 'audio_url': {'url': 'https://example.com/audio.mp3', 'id': None}}
|
||||
"""
|
||||
|
||||
@@ -140,9 +124,10 @@ class AudioURLPart(ContentPart):
|
||||
|
||||
|
||||
class ToolCall(BaseModel):
|
||||
"""A tool call requested by the assistant.
|
||||
"""
|
||||
A tool call requested by the assistant.
|
||||
|
||||
ToolCall(
|
||||
>>> ToolCall(
|
||||
... id="123",
|
||||
... function=ToolCall.FunctionBody(
|
||||
... name="function",
|
||||
@@ -180,15 +165,6 @@ class ToolCallPart(BaseModel):
|
||||
"""A part of the arguments of the tool call."""
|
||||
|
||||
|
||||
class CheckpointData(BaseModel):
|
||||
"""Internal checkpoint data for linking LLM turns to platform history."""
|
||||
|
||||
id: str
|
||||
|
||||
|
||||
CHECKPOINT_ROLE = "_checkpoint"
|
||||
|
||||
|
||||
class Message(BaseModel):
|
||||
"""A message in a conversation."""
|
||||
|
||||
@@ -197,10 +173,9 @@ class Message(BaseModel):
|
||||
"user",
|
||||
"assistant",
|
||||
"tool",
|
||||
"_checkpoint",
|
||||
]
|
||||
|
||||
content: str | list[ContentPart] | CheckpointData | None = None
|
||||
content: str | list[ContentPart] | None = None
|
||||
"""The content of the message."""
|
||||
|
||||
tool_calls: list[ToolCall] | list[dict] | None = None
|
||||
@@ -210,18 +185,9 @@ class Message(BaseModel):
|
||||
"""The ID of the tool call."""
|
||||
|
||||
_no_save: bool = PrivateAttr(default=False)
|
||||
_checkpoint_after: CheckpointData | None = PrivateAttr(default=None)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_content_required(self):
|
||||
if self.role == CHECKPOINT_ROLE:
|
||||
if not isinstance(self.content, CheckpointData):
|
||||
raise ValueError("checkpoint message content must be CheckpointData")
|
||||
return self
|
||||
|
||||
if isinstance(self.content, CheckpointData):
|
||||
raise ValueError("CheckpointData is only allowed for role='_checkpoint'")
|
||||
|
||||
# assistant + tool_calls is not None: allow content to be None
|
||||
if self.role == "assistant" and self.tool_calls is not None:
|
||||
return self
|
||||
@@ -229,7 +195,7 @@ class Message(BaseModel):
|
||||
# other all cases: content is required
|
||||
if self.content is None:
|
||||
raise ValueError(
|
||||
"content is required unless role='assistant' and tool_calls is not None",
|
||||
"content is required unless role='assistant' and tool_calls is not None"
|
||||
)
|
||||
return self
|
||||
|
||||
@@ -265,96 +231,3 @@ class SystemMessageSegment(Message):
|
||||
"""A message segment from the system."""
|
||||
|
||||
role: Literal["system"] = "system"
|
||||
|
||||
|
||||
class CheckpointMessageSegment(Message):
|
||||
"""Internal checkpoint segment for persisted conversation history."""
|
||||
|
||||
role: Literal["_checkpoint"] = "_checkpoint"
|
||||
content: CheckpointData | None = None
|
||||
|
||||
|
||||
def is_checkpoint_message(message: Message | dict) -> bool:
|
||||
"""Return whether a message is an internal checkpoint."""
|
||||
if isinstance(message, Message):
|
||||
return message.role == CHECKPOINT_ROLE
|
||||
return isinstance(message, dict) and message.get("role") == CHECKPOINT_ROLE
|
||||
|
||||
|
||||
def get_checkpoint_id(message: Message | dict) -> str | None:
|
||||
"""Return the checkpoint id from an internal checkpoint message."""
|
||||
if not is_checkpoint_message(message):
|
||||
return None
|
||||
|
||||
content = (
|
||||
message.content if isinstance(message, Message) else message.get("content")
|
||||
)
|
||||
if isinstance(content, CheckpointData):
|
||||
return content.id
|
||||
if isinstance(content, dict):
|
||||
checkpoint_id = content.get("id")
|
||||
return (
|
||||
checkpoint_id if isinstance(checkpoint_id, str) and checkpoint_id else None
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def strip_checkpoint_messages(history: list[dict]) -> list[dict]:
|
||||
"""Remove internal checkpoint messages from provider-facing history."""
|
||||
return [message for message in history if not is_checkpoint_message(message)]
|
||||
|
||||
|
||||
def _get_checkpoint_data(message: Message | dict) -> CheckpointData | None:
|
||||
if not is_checkpoint_message(message):
|
||||
return None
|
||||
|
||||
content = (
|
||||
message.content if isinstance(message, Message) else message.get("content")
|
||||
)
|
||||
if isinstance(content, CheckpointData):
|
||||
return content
|
||||
if isinstance(content, dict):
|
||||
try:
|
||||
return CheckpointData.model_validate(content)
|
||||
except ValidationError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def bind_checkpoint_messages(history: list[dict]) -> list[Message]:
|
||||
"""Load persisted history and bind checkpoint segments to prior messages."""
|
||||
messages: list[Message] = []
|
||||
for item in history:
|
||||
if is_checkpoint_message(item):
|
||||
checkpoint = _get_checkpoint_data(item)
|
||||
if checkpoint is not None and messages:
|
||||
messages[-1]._checkpoint_after = checkpoint
|
||||
continue
|
||||
|
||||
message = Message.model_validate(item)
|
||||
if item.get("_no_save"):
|
||||
message._no_save = True
|
||||
messages.append(message)
|
||||
|
||||
return messages
|
||||
|
||||
|
||||
def dump_messages_with_checkpoints(messages: list[Message]) -> list[dict]:
|
||||
"""Dump runtime messages and reinsert bound checkpoint segments."""
|
||||
dumped: list[dict] = []
|
||||
for message in messages:
|
||||
message_data = message.model_dump()
|
||||
if isinstance(message.content, list):
|
||||
message_data["content"] = [
|
||||
part.model_dump()
|
||||
for part in message.content
|
||||
if not getattr(part, "_no_save", False)
|
||||
]
|
||||
dumped.append(message_data)
|
||||
if message._checkpoint_after is not None:
|
||||
dumped.append(
|
||||
CheckpointMessageSegment(
|
||||
content=message._checkpoint_after,
|
||||
).model_dump(),
|
||||
)
|
||||
return dumped
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Any, Generic, cast
|
||||
from typing import Any, Generic
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic.dataclasses import dataclass
|
||||
@@ -13,10 +13,10 @@ TContext = TypeVar("TContext", default=Any)
|
||||
class ContextWrapper(Generic[TContext]):
|
||||
"""A context for running an agent, which can be used to pass additional data or state."""
|
||||
|
||||
context: TContext = cast("TContext", None)
|
||||
context: TContext
|
||||
messages: list[Message] = Field(default_factory=list)
|
||||
"""This field stores the llm message context for the agent run, agent runners will maintain this field automatically."""
|
||||
tool_call_timeout: int = 120 # Default tool call timeout in seconds
|
||||
tool_call_timeout: int = 60 # Default tool call timeout in seconds
|
||||
|
||||
|
||||
NoContext = ContextWrapper[None]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user