mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-02 02:30:16 +08:00
Compare commits
23 Commits
dev
...
feat/fs-gr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a79233384 | ||
|
|
ebc6273a1c | ||
|
|
5bd902793a | ||
|
|
31846cbba3 | ||
|
|
157749504a | ||
|
|
1745e9c4fb | ||
|
|
3acda6f77a | ||
|
|
cff148860a | ||
|
|
013ecacee9 | ||
|
|
7bf1d19332 | ||
|
|
5f049f2bb5 | ||
|
|
add5db6748 | ||
|
|
5ca2483a43 | ||
|
|
adc01e0c9d | ||
|
|
efc93a37b1 | ||
|
|
56a099bf90 | ||
|
|
006aedbd24 | ||
|
|
86ac40d944 | ||
|
|
20fed8ab62 | ||
|
|
a539deec91 | ||
|
|
11282c769f | ||
|
|
8e7d995fec | ||
|
|
fcf1b08455 |
@@ -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 / 模板结束
|
||||
24
.github/workflows/build-docs.yml
vendored
24
.github/workflows/build-docs.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Build and Deploy AstrBot Docs
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -11,22 +11,16 @@ 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@v6
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
4
.github/workflows/coverage_test.yml
vendored
4
.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
|
||||
|
||||
@@ -41,6 +41,6 @@ jobs:
|
||||
|
||||
- name: Upload results to Codecov
|
||||
if: github.repository == 'AstrBotDevs/AstrBot'
|
||||
uses: codecov/codecov-action@v7
|
||||
uses: codecov/codecov-action@v6
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
18
.github/workflows/dashboard_ci.yml
vendored
18
.github/workflows/dashboard_ci.yml
vendored
@@ -12,25 +12,19 @@ jobs:
|
||||
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
|
||||
|
||||
44
.github/workflows/docker-image.yml
vendored
44
.github/workflows/docker-image.yml
vendored
@@ -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.0.0
|
||||
|
||||
- name: Set Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4.1.0
|
||||
uses: docker/setup-buildx-action@v4.0.0
|
||||
|
||||
- name: Log in to DockerHub
|
||||
uses: docker/login-action@v4.2.0
|
||||
uses: docker/login-action@v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: env.HAS_GHCR_TOKEN == 'true'
|
||||
uses: docker/login-action@v4.2.0
|
||||
uses: docker/login-action@v4.1.0
|
||||
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.0.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@@ -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.0.0
|
||||
|
||||
- name: Set Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4.1.0
|
||||
uses: docker/setup-buildx-action@v4.0.0
|
||||
|
||||
- name: Log in to DockerHub
|
||||
uses: docker/login-action@v4.2.0
|
||||
uses: docker/login-action@v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: env.HAS_GHCR_TOKEN == 'true'
|
||||
uses: docker/login-action@v4.2.0
|
||||
uses: docker/login-action@v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ env.GHCR_OWNER }}
|
||||
password: ${{ secrets.GHCR_GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and Push Release Image
|
||||
uses: docker/build-push-action@v7.2.0
|
||||
uses: docker/build-push-action@v7.0.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
54
.github/workflows/pr-title-check.yml
vendored
Normal file
54
.github/workflows/pr-title-check.yml
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
name: PR Title Check
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, edited, reopened, synchronize]
|
||||
|
||||
jobs:
|
||||
title-format:
|
||||
if: github.repository == 'AstrBotDevs/AstrBot'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Validate PR title
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const title = (context.payload.pull_request.title || "").trim();
|
||||
// allow only:
|
||||
// feat: xxx
|
||||
// feat(scope): xxx
|
||||
const pattern = /^(feat)(\([a-z0-9-]+\))?:\s.+$/i;
|
||||
const isValid = pattern.test(title);
|
||||
const isSameRepo =
|
||||
context.payload.pull_request.head.repo.full_name === context.payload.repository.full_name;
|
||||
|
||||
if (!isValid) {
|
||||
if (isSameRepo) {
|
||||
try {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
body: [
|
||||
"⚠️ PR title format check failed.",
|
||||
"Required formats:",
|
||||
"- `feat: xxx`",
|
||||
"- `feat(scope): xxx`",
|
||||
"Please update your PR title and push again."
|
||||
].join("\n")
|
||||
});
|
||||
} catch (e) {
|
||||
core.warning(`Failed to post PR title comment: ${e.message}`);
|
||||
}
|
||||
} else {
|
||||
core.warning("Fork PR: comment permission is restricted; skip posting review comment.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
core.setFailed("Invalid PR title. Expected format: feat: xxx or feat(scope): xxx.");
|
||||
}
|
||||
46
.github/workflows/release.yml
vendored
46
.github/workflows/release.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Release AstrBot
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -28,7 +28,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 +51,26 @@ jobs:
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
uses: pnpm/action-setup@v5.0.0
|
||||
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 +78,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,18 +98,11 @@ 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'
|
||||
@@ -128,7 +111,7 @@ jobs:
|
||||
- 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 +139,7 @@ jobs:
|
||||
name: Dashboard-${{ steps.tag.outputs.tag }}
|
||||
path: release-assets
|
||||
|
||||
|
||||
- name: Resolve release notes
|
||||
id: notes
|
||||
shell: bash
|
||||
@@ -207,7 +191,7 @@ jobs:
|
||||
- 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..."
|
||||
|
||||
4
.github/workflows/sync-wiki.yml
vendored
4
.github/workflows/sync-wiki.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Sync AstrBot Docs to GitHub Wiki
|
||||
name: sync wiki
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -31,7 +31,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
|
||||
|
||||
2
.github/workflows/unit_tests.yml
vendored
2
.github/workflows/unit_tests.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
|
||||
33
.gitignore
vendored
33
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
# Python related
|
||||
__pycache__
|
||||
.mypy_cache
|
||||
.venv*
|
||||
.conda/
|
||||
uv.lock
|
||||
@@ -50,46 +51,16 @@ astrbot.lock
|
||||
chroma
|
||||
venv/*
|
||||
pytest.ini
|
||||
AGENTS.md
|
||||
IFLOW.md
|
||||
CLAUDE.md
|
||||
|
||||
# genie_tts data
|
||||
CharacterModels/
|
||||
GenieData/
|
||||
.agent/
|
||||
.codex/
|
||||
.claude/
|
||||
.opencode/
|
||||
.kilocode/
|
||||
.serena
|
||||
.worktrees/
|
||||
|
||||
.astrbot_sdk_testing/
|
||||
.env
|
||||
dashboard/warker.js
|
||||
dashboard/bun.lock
|
||||
.pua/
|
||||
|
||||
# Rust build artifacts
|
||||
rust/target/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
*.whl
|
||||
# 拓展模块
|
||||
*.so
|
||||
*.dll
|
||||
|
||||
# MDI font subset (generated by dashboard/scripts/subset-mdi-font.mjs)
|
||||
dashboard/src/assets/mdi-subset/*.woff
|
||||
dashboard/src/assets/mdi-subset/*.woff2
|
||||
.planning
|
||||
*cache
|
||||
node_modules
|
||||
|
||||
*pinokio*
|
||||
dashboard/pnpm-lock.yaml
|
||||
.obsidian
|
||||
dashboard/.codex
|
||||
.codex
|
||||
.zed/settings.json
|
||||
|
||||
@@ -6,20 +6,20 @@ ci:
|
||||
autoupdate_schedule: weekly
|
||||
autoupdate_commit_msg: ":balloon: pre-commit autoupdate"
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.15.7
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff-check
|
||||
types_or: [python, pyi]
|
||||
args: [--fix]
|
||||
# Run the formatter.
|
||||
- id: ruff-format
|
||||
types_or: [python, pyi]
|
||||
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v3.21.2
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py312-plus]
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.14.1
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff-check
|
||||
types_or: [ python, pyi ]
|
||||
args: [ --fix ]
|
||||
# Run the formatter.
|
||||
- id: ruff-format
|
||||
types_or: [ python, pyi ]
|
||||
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v3.21.0
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py310-plus]
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.12
|
||||
3.12
|
||||
249
AGENTS.md
249
AGENTS.md
@@ -3,10 +3,8 @@
|
||||
### Core
|
||||
|
||||
```
|
||||
uv tool install -e . --force
|
||||
astrbot init
|
||||
astrbot run # start the bot
|
||||
astrbot run --backend-only # start the backend only
|
||||
uv sync
|
||||
uv run main.py
|
||||
```
|
||||
|
||||
Exposed an API server on `http://localhost:6185` by default.
|
||||
@@ -15,243 +13,22 @@ Exposed an API server on `http://localhost:6185` by default.
|
||||
|
||||
```
|
||||
cd dashboard
|
||||
bun install # First time only.
|
||||
bun dev
|
||||
pnpm install # First time only. Use npm install -g pnpm if pnpm is not installed.
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Runs on `http://localhost:3000` by default.
|
||||
|
||||
## Pre-commit setup
|
||||
|
||||
AstrBot uses [pre-commit](https://pre-commit.com/) hooks to automatically format and lint Python code before each commit. The hooks run `ruff check`, `ruff format`, and `pyupgrade` (see [`.pre-commit-config.yaml`](.pre-commit-config.yaml) for details).
|
||||
|
||||
To set it up:
|
||||
|
||||
```bash
|
||||
pip install pre-commit
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
After installation, the hooks will run automatically on `git commit`. You can also run them manually at any time:
|
||||
|
||||
```bash
|
||||
ruff format .
|
||||
ruff check .
|
||||
```
|
||||
|
||||
> **Note:** If you use VSCode, install the `Ruff` extension for real-time formatting and linting in the editor.
|
||||
|
||||
## Dev environment tips
|
||||
|
||||
- **Main entry**: `astrbot/__main__.py` or via CLI `astrbot run`
|
||||
- **CLI commands**: `astrbot/cli/commands/`
|
||||
- **Core modules**: `astrbot/core/`
|
||||
- **Platform adapters**: `astrbot/core/platform/sources/`
|
||||
- **Star plugins**: `astrbot/builtin_stars/`
|
||||
- **Dashboard**: `dashboard/` (Vue.js frontend)
|
||||
1. When modifying the WebUI, be sure to maintain componentization and clean code. Avoid duplicate code.
|
||||
2. Do not add any report files such as xxx_SUMMARY.md.
|
||||
3. After finishing, use `ruff format .` and `ruff check .` to format and check the code.
|
||||
4. When committing, ensure to use conventional commits messages, such as `feat: add new agent for data analysis` or `fix: resolve bug in provider manager`.
|
||||
5. Use English for all new comments.
|
||||
6. For path handling, use `pathlib.Path` instead of string paths, and use `astrbot.core.utils.path_utils` to get the AstrBot data and temp directory.
|
||||
|
||||
## File Organization
|
||||
## PR instructions
|
||||
|
||||
```
|
||||
astrbot/
|
||||
├── __main__.py # Main entry point
|
||||
├── __init__.py # Package init, exports
|
||||
├── cli/ # CLI commands
|
||||
│ └── commands/ # Individual command modules
|
||||
├── core/ # Core functionality
|
||||
│ ├── agent/ # Agent execution
|
||||
│ ├── platform/ # Platform adapters
|
||||
│ ├── pipeline/ # Message processing
|
||||
│ ├── star/ # Plugin system
|
||||
│ └── config/ # Configuration
|
||||
├── builtin_stars/ # Built-in plugins
|
||||
├── dashboard/ # Vue.js frontend
|
||||
└── utils/ # Utilities
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
- `astrbot/core/` - Core bot functionality
|
||||
- `astrbot/core/platform/` - Platform adapter system
|
||||
- `astrbot/core/agent/` - Agent execution logic
|
||||
- `astrbot/core/star/` - Plugin/Star handler system
|
||||
- `astrbot/core/pipeline/` - Message processing pipeline
|
||||
- `astrbot/cli/` - Command-line interface
|
||||
|
||||
### Important Utilities
|
||||
|
||||
```python
|
||||
from astrbot.core.utils.astrbot_path import (
|
||||
get_astrbot_root, # AstrBot root directory
|
||||
get_astrbot_data_path, # Data directory
|
||||
get_astrbot_config_path, # Config directory
|
||||
get_astrbot_plugin_path, # Plugin directory
|
||||
get_astrbot_temp_path, # Temp directory
|
||||
get_astrbot_skills_path, # Skills directory
|
||||
)
|
||||
```
|
||||
|
||||
### Platform Adapters
|
||||
|
||||
Platform adapters are in `astrbot/core/platform/sources/`:
|
||||
- Each adapter extends base platform classes
|
||||
- Use `@register_platform_adapter` decorator
|
||||
- Events flow through `commit_event()` to message queue
|
||||
|
||||
### Star (Plugin) System
|
||||
|
||||
Stars are plugins in `astrbot/builtin_stars/`:
|
||||
- Extend `Star` base class
|
||||
- Use decorators for command handlers: `@star.on_command`, `@star.on_message`, etc.
|
||||
- Access via `context` object
|
||||
|
||||
## Code Style
|
||||
|
||||
1. **Type hints required** - Use Python 3.12+ syntax:
|
||||
- `list[str]` not `List[str]`
|
||||
- `int | None` not `Optional[int]`
|
||||
- Avoid `Any` when possible. Use proper `TypedDict`, `dataclass`, or `Protocol` instead.
|
||||
- When encountering dict access issues (e.g., `msg.get("key")` where type inference is wrong), define a `TypedDict` with `total=False` to explicitly declare allowed keys.
|
||||
|
||||
Good example:
|
||||
```python
|
||||
class MessageComponent(TypedDict, total=False):
|
||||
type: str
|
||||
text: str
|
||||
path: str
|
||||
```
|
||||
|
||||
Bad example (avoid):
|
||||
```python
|
||||
msg: Any = something
|
||||
msg = cast(dict, msg)
|
||||
```
|
||||
|
||||
2. **Path handling** - Always use `pathlib.Path`:
|
||||
```python
|
||||
from pathlib import Path
|
||||
# Use astrbot.core.utils.path_utils for data/temp directories
|
||||
from astrbot.core.utils.path_utils import get_astrbot_data_path
|
||||
```
|
||||
|
||||
3. **Formatting** - Run before committing:
|
||||
```bash
|
||||
ruff format .
|
||||
ruff check .
|
||||
```
|
||||
|
||||
4. **Comments** - Use English for all comments and docstrings
|
||||
|
||||
5. **Imports** - Use absolute imports via `astrbot.` prefix
|
||||
|
||||
### Environment Variables
|
||||
|
||||
When adding new environment variables:
|
||||
|
||||
1. Use `ASTRBOT_` prefix: `ASTRBOT_ENABLE_FEATURE`
|
||||
2. Add to `.env.example` with description
|
||||
3. Update `astrbot/cli/commands/cmd_run.py`:
|
||||
- Add to module docstring under "Environment Variables Used in Project"
|
||||
- Add to `keys_to_print` list for debug output
|
||||
|
||||
## Testing
|
||||
|
||||
1. Tests go in `tests/` directory
|
||||
2. Use `pytest` with `pytest-asyncio`
|
||||
3. Run: `uv sync --group dev && uv run pytest --cov=astrbot tests/`
|
||||
4. Test files: `test_*.py` or `*_test.py`
|
||||
|
||||
### Code Quality Scoring Test
|
||||
|
||||
The project enforces a **code quality score** via `tests/test_code_quality_typing.py`. All agents must treat this as a hard constraint when modifying code.
|
||||
|
||||
**Run the test:**
|
||||
```bash
|
||||
uv run pytest tests/test_code_quality_typing.py -v
|
||||
```
|
||||
|
||||
**Scoring rules (target: 100/100, threshold for PASS: 80/100):**
|
||||
|
||||
| Pattern | Cost |
|
||||
|---------|------|
|
||||
| `cast(Any, ...)` | -1 pt each |
|
||||
| `# type: ignore` | -0.5 pt each |
|
||||
| **BAD** `# type: ignore[...]` (unresolved-import, class-alias, no-name-module, attr-defined, etc.) | **-3 pt each** |
|
||||
| `bare except:` (no exception type) | -0.5 pt each |
|
||||
| Duplicate code block (5+ identical lines, ≥2 occurrences) | -2 pt each |
|
||||
|
||||
**Why bad type: ignore is heavily penalized:**
|
||||
- `# type: ignore[unresolved-import]` — hides missing module/stub issues
|
||||
- `# type: ignore[class-alias]` — hides improper type alias patterns
|
||||
- `# type: ignore[attr-defined]` — hides missing attribute errors
|
||||
- These are **workarounds, not fixes** — they paper over real type errors
|
||||
|
||||
**Scoring formula:**
|
||||
```
|
||||
score = max(0, 100 - cast_any - type_ignore*0.5 - bad_type_ignore*3 - bare_except*0.5 - dup_blocks*2)
|
||||
```
|
||||
|
||||
**Agent rules when modifying code:**
|
||||
1. **Do not add** `# type: ignore[unresolved-import]` or `# type: ignore[class-alias]` — fix the underlying issue instead
|
||||
2. **Do not use** `cast(Any, ...)` to suppress type errors — use proper type annotations
|
||||
3. **Do not add** bare `except:` clauses — use `except SomeSpecificException:`
|
||||
4. **Do not copy-paste** 5+ line blocks — extract to a shared helper function
|
||||
5. Before committing, run the scoring test and ensure score ≥ 80
|
||||
|
||||
## Git Conventions
|
||||
|
||||
### Commit Messages
|
||||
|
||||
Use conventional commits:
|
||||
```
|
||||
feat: add new feature
|
||||
fix: resolve bug
|
||||
docs: update documentation
|
||||
refactor: restructure code
|
||||
test: add tests
|
||||
chore: maintenance tasks
|
||||
```
|
||||
|
||||
### PR Guidelines
|
||||
|
||||
1. Title: conventional commit format
|
||||
2. Description: English
|
||||
3. Target branch: `dev`
|
||||
4. Keep changes focused and atomic
|
||||
|
||||
## Project-Specific Guidelines
|
||||
|
||||
1. **No report files** - Do not add `xxx_SUMMARY.md` or similar
|
||||
2. **Componentization** - Maintain clean code, avoid duplication in WebUI
|
||||
3. **Backward compatibility** - When deprecating, add warnings
|
||||
4. **CLI help** - Run `astrbot help --all` to see all commands
|
||||
5. When modifying frontend/dashboard code, use the project's custom request module `@/utils/request` for HTTP calls
|
||||
6. For fetch or SSE URLs, use `resolveApiUrl('/api/your-path')` so the configured `VITE_API_BASE` and dev proxy rules are respected
|
||||
7. Do not import the plain `axios` package directly in dashboard source files
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Adding a new platform adapter
|
||||
1. Create adapter in `astrbot/core/platform/sources/`
|
||||
2. Extend `Platform` base class
|
||||
3. Use `@register_platform_adapter` decorator
|
||||
4. Implement required methods: `run()`, `convert_message()`, `meta()`
|
||||
|
||||
### Adding a new command
|
||||
1. Add to appropriate module in `cli/commands/`
|
||||
2. Register with `@click.command()`
|
||||
3. Update `astrbot/cli/__main__.py` to add command
|
||||
|
||||
### Adding a new Star handler
|
||||
1. Create in `astrbot/builtin_stars/` or as plugin
|
||||
2. Extend `Star` class
|
||||
3. Use decorators: `@star.on_command()`, `@star.on_schedule()`, etc.
|
||||
|
||||
## Release versions
|
||||
|
||||
1. Replace current version name to specific version name.
|
||||
2. Write changelog in `changelogs/`, you can refer to the full commit messages between the latest tag to the latest commit.
|
||||
3. Make and push a commit into master branch with message format like: `chore: bump version to 4.25.0`
|
||||
4. Create a tag and push the tag. For example: `git tag v4.25.0 && git push origin v4.25.0`
|
||||
1. Title format: use conventional commit messages
|
||||
2. Use English to write PR title and descriptions.
|
||||
|
||||
@@ -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)
|
||||
28
README.md
28
README.md
@@ -12,7 +12,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>
|
||||
|
||||
@@ -77,21 +77,20 @@ 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
|
||||
```
|
||||
|
||||
> 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]
|
||||
@@ -101,7 +100,7 @@ uv tool upgrade astrbot --python 3.12
|
||||
|
||||
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 +138,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 +157,11 @@ 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
|
||||
@@ -234,6 +232,10 @@ pre-commit install
|
||||
|
||||
### QQ Groups
|
||||
|
||||
- Group 12: 916228568 (New)
|
||||
- Group 9: 1076659624 (Full)
|
||||
- Group 10: 1078079676 (Full)
|
||||
- Group 11: 704659519 (Full)
|
||||
- Group 1: 322154837 (Full)
|
||||
- Group 3: 630166526 (Full)
|
||||
- Group 4: 1077826412 (Full)
|
||||
@@ -241,12 +243,6 @@ pre-commit install
|
||||
- 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
|
||||
|
||||
- Developer Group(Chit-chat): 975206796
|
||||
- Developer Group(Formal): 1039761811
|
||||
@@ -260,7 +256,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:
|
||||
|
||||
17
README_fr.md
17
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
|
||||
```
|
||||
|
||||
> [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,7 +89,7 @@ astrbot run
|
||||
Mettre à jour `astrbot` :
|
||||
|
||||
```bash
|
||||
uv tool upgrade astrbot --python 3.12
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
@@ -100,7 +99,7 @@ uv tool upgrade astrbot --python 3.12
|
||||
|
||||
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 +137,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 +156,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
|
||||
@@ -248,7 +245,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 :
|
||||
|
||||
17
README_ja.md
17
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
|
||||
```
|
||||
|
||||
> [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,7 +89,7 @@ astrbot run
|
||||
`astrbot` の更新:
|
||||
|
||||
```bash
|
||||
uv tool upgrade astrbot --python 3.12
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
@@ -100,7 +99,7 @@ uv tool upgrade astrbot --python 3.12
|
||||
|
||||
コンテナ運用に慣れており、より安定した本番向けのデプロイ方法を求めるユーザーには、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 +137,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 +156,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) | コミュニティ |
|
||||
|
||||
|
||||
@@ -249,7 +246,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>
|
||||
|
||||
また、このプロジェクトの誕生は以下のオープンソースプロジェクトの助けなしには実現できませんでした:
|
||||
|
||||
17
README_ru.md
17
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
|
||||
```
|
||||
|
||||
> Требуется установленный [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,7 +89,7 @@ astrbot run
|
||||
Обновить `astrbot`:
|
||||
|
||||
```bash
|
||||
uv tool upgrade astrbot --python 3.12
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
@@ -100,7 +99,7 @@ uv tool upgrade astrbot --python 3.12
|
||||
|
||||
Для пользователей, знакомых с контейнерами и которым нужен более стабильный и подходящий для 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 +137,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 +156,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) | Сообщество |
|
||||
|
||||
## Поддерживаемые сервисы моделей
|
||||
@@ -248,7 +245,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
|
||||
```
|
||||
|
||||
> 需要安裝 [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,7 +89,7 @@ astrbot run
|
||||
更新 `astrbot`:
|
||||
|
||||
```bash
|
||||
uv tool upgrade astrbot --python 3.12
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
@@ -100,7 +99,7 @@ uv tool upgrade astrbot --python 3.12
|
||||
|
||||
對於熟悉容器、希望獲得更穩定且更適合正式環境部署方式的使用者,我們推薦使用 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 +137,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 +156,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) | 社群維護 |
|
||||
|
||||
## 支援的模型服務
|
||||
@@ -248,7 +245,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>
|
||||
|
||||
此外,本專案的誕生離不開以下開源專案的幫助:
|
||||
|
||||
72
README_zh.md
72
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 run
|
||||
```
|
||||
|
||||
> 需要安装 [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,7 +89,7 @@ uv tool install git+https://github.com/AstrBotDevs/AstrBot@dev
|
||||
更新 `astrbot`:
|
||||
|
||||
```bash
|
||||
uv tool upgrade astrbot --python 3.12
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
@@ -103,7 +99,7 @@ uv tool upgrade astrbot --python 3.12
|
||||
|
||||
对于熟悉容器、希望获得更稳定且更适合生产环境部署方式的用户,我们推荐使用 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 +137,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 +156,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) | 社区维护 |
|
||||
|
||||
## 支持的模型提供商
|
||||
@@ -207,25 +201,13 @@ yay -S astrbot-git
|
||||
| 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,26 +215,18 @@ 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 群组
|
||||
|
||||
- 12 群:916228568 (新)
|
||||
- 9 群:1076659624 (人满)
|
||||
- 10 群:1078079676 (人满)
|
||||
- 11 群:704659519 (人满)
|
||||
- 1 群:322154837 (人满)
|
||||
- 3 群:630166526 (人满)
|
||||
- 4 群:1077826412 (人满)
|
||||
@@ -260,14 +234,6 @@ bun dev
|
||||
- 6 群:753075035 (人满)
|
||||
- 7 群:743746109 (人满)
|
||||
- 8 群:1030353265 (人满)
|
||||
- 9 群:1076659624 (人满)
|
||||
- 10 群:1078079676 (人满)
|
||||
- 11 群:704659519 (人满)
|
||||
- 12 群:916228568 (人满)
|
||||
- 13 群:1092185289
|
||||
- 14 群:1103419483
|
||||
|
||||
|
||||
- 开发者群(偏闲聊吹水):975206796
|
||||
- 开发者群(正式):1039761811
|
||||
|
||||
@@ -280,7 +246,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,8 +83,8 @@ 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(
|
||||
@@ -108,7 +108,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,146 +0,0 @@
|
||||
import random
|
||||
import urllib.parse
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from aiohttp import ClientSession, ClientTimeout
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
||||
HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 6.1; rv:84.0) Gecko/20100101 Firefox/84.0",
|
||||
"Accept": "*/*",
|
||||
"Connection": "keep-alive",
|
||||
"Accept-Language": "en-GB,en;q=0.5",
|
||||
}
|
||||
|
||||
USER_AGENT_BING = "Mozilla/5.0 (Windows NT 6.1; rv:84.0) Gecko/20100101 Firefox/84.0"
|
||||
USER_AGENTS = [
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.131 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.131 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Version/14.1.2 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Version/14.1 Safari/537.36",
|
||||
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:89.0) Gecko/20100101 Firefox/89.0",
|
||||
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:88.0) Gecko/20100101 Firefox/88.0",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchResult:
|
||||
title: str
|
||||
url: str
|
||||
snippet: str
|
||||
favicon: str | None = None
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.title} - {self.url}\n{self.snippet}"
|
||||
|
||||
|
||||
class SearchEngine:
|
||||
"""搜索引擎爬虫基类"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.TIMEOUT = ClientTimeout(total=10)
|
||||
self.page = 1
|
||||
self.headers = HEADERS
|
||||
|
||||
def _set_selector(self, selector: str) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
async def _get_next_page(self, query: str) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
async def _get_html(self, url: str, data: dict | None = None) -> str:
|
||||
headers = self.headers
|
||||
headers["Referer"] = url
|
||||
headers["User-Agent"] = random.choice(USER_AGENTS)
|
||||
if data:
|
||||
async with (
|
||||
ClientSession() as session,
|
||||
session.post(
|
||||
url,
|
||||
headers=headers,
|
||||
data=data,
|
||||
timeout=self.TIMEOUT,
|
||||
) as resp,
|
||||
):
|
||||
ret = await resp.text(encoding="utf-8")
|
||||
return ret
|
||||
else:
|
||||
async with (
|
||||
ClientSession() as session,
|
||||
session.get(
|
||||
url,
|
||||
headers=headers,
|
||||
timeout=self.TIMEOUT,
|
||||
) as resp,
|
||||
):
|
||||
ret = await resp.text(encoding="utf-8")
|
||||
return ret
|
||||
|
||||
def tidy_text(self, text: str) -> str:
|
||||
"""清理文本,去除空格、换行符等"""
|
||||
return text.strip().replace("\n", " ").replace("\r", " ").replace(" ", " ")
|
||||
|
||||
def _get_url(self, tag: Tag) -> str:
|
||||
return self.tidy_text(tag.get_text())
|
||||
|
||||
async def search(self, query: str, num_results: int) -> list[SearchResult]:
|
||||
query = urllib.parse.quote(query)
|
||||
|
||||
try:
|
||||
resp = await self._get_next_page(query)
|
||||
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"))
|
||||
title = ""
|
||||
if title_elem is not None:
|
||||
title = self.tidy_text(title_elem.get_text())
|
||||
|
||||
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
|
||||
@@ -1,33 +0,0 @@
|
||||
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.headers.update({"User-Agent": USER_AGENT_BING})
|
||||
|
||||
def _set_selector(self, selector: str):
|
||||
selectors = {
|
||||
"url": "div.b_attribution cite",
|
||||
"title": "h2",
|
||||
"text": "p",
|
||||
"links": "ol#b_results > li.b_algo",
|
||||
"next": 'div#b_content nav[role="navigation"] a.sb_pagN',
|
||||
}
|
||||
return selectors[selector]
|
||||
|
||||
async def _get_next_page(self, query) -> str:
|
||||
# if self.page == 1:
|
||||
# await self._get_html(self.base_url)
|
||||
for base_url in self.base_urls:
|
||||
try:
|
||||
url = f"{base_url}/search?q={query}"
|
||||
return await self._get_html(url, None)
|
||||
except Exception as _:
|
||||
self.base_url = base_url
|
||||
continue
|
||||
raise Exception("Bing search failed")
|
||||
@@ -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,53 +0,0 @@
|
||||
import random
|
||||
import re
|
||||
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
||||
from . import USER_AGENTS, SearchEngine, SearchResult
|
||||
|
||||
|
||||
class Sogo(SearchEngine):
|
||||
NAME = "sogo"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.base_url = "https://www.sogou.com"
|
||||
self.headers["User-Agent"] = random.choice(USER_AGENTS)
|
||||
|
||||
def _set_selector(self, selector: str):
|
||||
selectors = {
|
||||
"url": "h3 > a",
|
||||
"title": "h3",
|
||||
"text": "",
|
||||
"links": "div.results > div.vrwrap:not(.middle-better-hintBox)",
|
||||
"next": "",
|
||||
}
|
||||
return selectors[selector]
|
||||
|
||||
async def _get_next_page(self, query) -> str:
|
||||
url = f"{self.base_url}/web?query={query}"
|
||||
return await self._get_html(url, None)
|
||||
|
||||
def _get_url(self, tag: Tag) -> str:
|
||||
return str(tag.get("href") or "")
|
||||
|
||||
async def search(self, query: str, num_results: int) -> list[SearchResult]:
|
||||
results = await super().search(query, num_results)
|
||||
for result in results:
|
||||
if result.url.startswith("/link?"):
|
||||
result.url = self.base_url + result.url
|
||||
result.url = await self._parse_url(result.url)
|
||||
return results
|
||||
|
||||
async def _parse_url(self, url) -> str:
|
||||
html = await self._get_html(url)
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
script = soup.find("script")
|
||||
if script:
|
||||
script_text = (
|
||||
script.string if script.string is not None else script.get_text()
|
||||
)
|
||||
match = re.search('window.location.replace\\("(.+?)"\\)', script_text)
|
||||
if match:
|
||||
url = match.group(1)
|
||||
return url
|
||||
@@ -1,663 +0,0 @@
|
||||
import asyncio
|
||||
import json
|
||||
import random
|
||||
import uuid
|
||||
from typing import ClassVar
|
||||
|
||||
import aiohttp
|
||||
from bs4 import BeautifulSoup
|
||||
from readability import Document
|
||||
|
||||
from astrbot.api import AstrBotConfig, llm_tool, logger, sp, star
|
||||
from astrbot.api.event import AstrMessageEvent, filter
|
||||
from astrbot.api.provider import ProviderRequest
|
||||
from astrbot.core.provider.func_tool_manager import FunctionToolManager
|
||||
|
||||
from .engines import HEADERS, USER_AGENTS, SearchResult
|
||||
from .engines.bing import Bing
|
||||
from .engines.comet import Comet
|
||||
from .engines.duckduckgo import DuckDuckGo
|
||||
from .engines.google import Google
|
||||
from .engines.sogo import Sogo
|
||||
from .provider_routing import (
|
||||
DEFAULT_WEB_SEARCH_PROVIDER,
|
||||
build_default_engine_order,
|
||||
normalize_websearch_provider,
|
||||
normalize_websearch_provider_for_tools,
|
||||
validate_default_engine_registry,
|
||||
)
|
||||
|
||||
|
||||
class Main(star.Star):
|
||||
TOOLS: ClassVar[list[str]] = [
|
||||
"web_search",
|
||||
"fetch_url",
|
||||
"web_search_tavily",
|
||||
"tavily_extract_web_page",
|
||||
"web_search_bocha",
|
||||
]
|
||||
|
||||
def __init__(self, context: star.Context) -> None:
|
||||
self.context = context
|
||||
self.tavily_key_index = 0
|
||||
self.tavily_key_lock = asyncio.Lock()
|
||||
|
||||
self.bocha_key_index = 0
|
||||
self.bocha_key_lock = asyncio.Lock()
|
||||
|
||||
# 将 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 (字符串格式),自动迁移为列表格式并保存。",
|
||||
)
|
||||
if tavily_key:
|
||||
provider_settings["websearch_tavily_key"] = [tavily_key]
|
||||
else:
|
||||
provider_settings["websearch_tavily_key"] = []
|
||||
cfg.save_config()
|
||||
|
||||
bocha_key = provider_settings.get("websearch_bocha_key")
|
||||
if isinstance(bocha_key, str):
|
||||
if bocha_key:
|
||||
provider_settings["websearch_bocha_key"] = [bocha_key]
|
||||
else:
|
||||
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:
|
||||
"""获取网页内容"""
|
||||
header = HEADERS
|
||||
header.update({"User-Agent": random.choice(USER_AGENTS)})
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.get(url, headers=header) as response:
|
||||
html = await response.text(encoding="utf-8")
|
||||
doc = Document(html)
|
||||
ret = doc.summary(html_partial=True)
|
||||
soup = BeautifulSoup(ret, "html.parser")
|
||||
ret = await self._tidy_text(soup.get_text())
|
||||
return ret
|
||||
|
||||
async def _process_search_result(
|
||||
self,
|
||||
result: SearchResult,
|
||||
idx: int,
|
||||
websearch_link: bool,
|
||||
) -> str:
|
||||
"""处理单个搜索结果"""
|
||||
logger.info(f"web_searcher - scraping web: {result.title} - {result.url}")
|
||||
try:
|
||||
site_result = await self._get_from_url(result.url)
|
||||
except BaseException:
|
||||
site_result = ""
|
||||
site_result = (
|
||||
f"{site_result[:700]}..." if len(site_result) > 700 else site_result
|
||||
)
|
||||
|
||||
header = f"{idx}. {result.title} "
|
||||
|
||||
if websearch_link and result.url:
|
||||
header += result.url
|
||||
|
||||
return f"{header}\n{result.snippet}\n{site_result}\n\n"
|
||||
|
||||
async def _web_search_default(
|
||||
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
|
||||
try:
|
||||
results = await engine.search(query, num_results)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"{engine_name} search error: {e}, try the next one...",
|
||||
)
|
||||
continue
|
||||
|
||||
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 []
|
||||
|
||||
async def _get_tavily_key(self, cfg: AstrBotConfig) -> str:
|
||||
"""并发安全的从列表中获取并轮换Tavily API密钥。"""
|
||||
tavily_keys = cfg.get("provider_settings", {}).get("websearch_tavily_key", [])
|
||||
if not tavily_keys:
|
||||
raise ValueError("错误:Tavily API密钥未在AstrBot中配置。")
|
||||
|
||||
async with self.tavily_key_lock:
|
||||
key = tavily_keys[self.tavily_key_index]
|
||||
self.tavily_key_index = (self.tavily_key_index + 1) % len(tavily_keys)
|
||||
return key
|
||||
|
||||
async def _web_search_tavily(
|
||||
self,
|
||||
cfg: AstrBotConfig,
|
||||
payload: dict,
|
||||
) -> list[SearchResult]:
|
||||
"""使用 Tavily 搜索引擎进行搜索"""
|
||||
tavily_key = await self._get_tavily_key(cfg)
|
||||
url = "https://api.tavily.com/search"
|
||||
header = {
|
||||
"Authorization": f"Bearer {tavily_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers=header,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
reason = await response.text()
|
||||
raise Exception(
|
||||
f"Tavily web search failed: {reason}, status: {response.status}",
|
||||
)
|
||||
data = await response.json()
|
||||
results = []
|
||||
for item in data.get("results", []):
|
||||
result = SearchResult(
|
||||
title=item.get("title"),
|
||||
url=item.get("url"),
|
||||
snippet=item.get("content"),
|
||||
favicon=item.get("favicon"),
|
||||
)
|
||||
results.append(result)
|
||||
return results
|
||||
|
||||
async def _extract_tavily(self, cfg: AstrBotConfig, payload: dict) -> list[dict]:
|
||||
"""使用 Tavily 提取网页内容"""
|
||||
tavily_key = await self._get_tavily_key(cfg)
|
||||
url = "https://api.tavily.com/extract"
|
||||
header = {
|
||||
"Authorization": f"Bearer {tavily_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers=header,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
reason = await response.text()
|
||||
raise Exception(
|
||||
f"Tavily web search failed: {reason}, status: {response.status}",
|
||||
)
|
||||
data = await response.json()
|
||||
results: list[dict] = data.get("results", [])
|
||||
if not results:
|
||||
raise ValueError(
|
||||
"Error: Tavily web searcher does not return any results.",
|
||||
)
|
||||
return results
|
||||
|
||||
@llm_tool(name="web_search")
|
||||
async def search_from_search_engine(
|
||||
self,
|
||||
event: AstrMessageEvent,
|
||||
query: str,
|
||||
max_results: int = 5,
|
||||
) -> str:
|
||||
"""搜索网络以回答用户的问题。当用户需要搜索网络以获取即时性的信息时调用此工具。
|
||||
|
||||
Args:
|
||||
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,
|
||||
)
|
||||
if not results:
|
||||
return "Error: web searcher does not return any results."
|
||||
|
||||
tasks = []
|
||||
for idx, result in enumerate(results, 1):
|
||||
task = self._process_search_result(result, idx, websearch_link)
|
||||
tasks.append(task)
|
||||
processed_results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
ret = ""
|
||||
for processed_result in processed_results:
|
||||
if isinstance(processed_result, BaseException):
|
||||
logger.error(f"Error processing search result: {processed_result}")
|
||||
continue
|
||||
ret += processed_result
|
||||
|
||||
if websearch_link:
|
||||
ret += "\n\n针对问题,请根据上面的结果分点总结,并且在结尾处附上对应内容的参考链接(如有)。"
|
||||
|
||||
return ret
|
||||
|
||||
async def ensure_baidu_ai_search_mcp(self, umo: str | None = None) -> None:
|
||||
if self.baidu_initialized:
|
||||
return
|
||||
cfg = self.context.get_config(umo=umo)
|
||||
key = cfg.get("provider_settings", {}).get(
|
||||
"websearch_baidu_app_builder_key",
|
||||
"",
|
||||
)
|
||||
if not key:
|
||||
raise ValueError(
|
||||
"Error: Baidu AI Search API key is not configured in AstrBot.",
|
||||
)
|
||||
func_tool_mgr = self.context.get_llm_tool_manager()
|
||||
await func_tool_mgr.enable_mcp_server(
|
||||
"baidu_ai_search",
|
||||
config={
|
||||
"transport": "sse",
|
||||
"url": f"http://appbuilder.baidu.com/v2/ai_search/mcp/sse?api_key={key}",
|
||||
"headers": {},
|
||||
"timeout": 600,
|
||||
},
|
||||
)
|
||||
self.baidu_initialized = True
|
||||
logger.info("Successfully initialized Baidu AI Search MCP server.")
|
||||
|
||||
@llm_tool(name="fetch_url")
|
||||
async def fetch_website_content(self, event: AstrMessageEvent, url: str) -> str:
|
||||
"""Fetch the content of a website with the given web url
|
||||
|
||||
Args:
|
||||
url(string): The url of the website to fetch content from
|
||||
|
||||
"""
|
||||
resp = await self._get_from_url(url)
|
||||
return resp
|
||||
|
||||
@llm_tool("web_search_tavily")
|
||||
async def search_from_tavily(
|
||||
self,
|
||||
event: AstrMessageEvent,
|
||||
query: str,
|
||||
max_results: int = 7,
|
||||
search_depth: str = "basic",
|
||||
topic: str = "general",
|
||||
days: int = 3,
|
||||
time_range: str = "",
|
||||
start_date: str = "",
|
||||
end_date: str = "",
|
||||
) -> str:
|
||||
"""A web search tool that uses Tavily to search the web for relevant content.
|
||||
Ideal for gathering current information, news, and detailed web content analysis.
|
||||
|
||||
Args:
|
||||
query(string): Required. Search query.
|
||||
max_results(number): Optional. The maximum number of results to return. Default is 7. Range is 5-20.
|
||||
search_depth(string): Optional. The depth of the search, must be one of 'basic', 'advanced'. Default is "basic".
|
||||
topic(string): Optional. The topic of the search, must be one of 'general', 'news'. Default is "general".
|
||||
days(number): Optional. The number of days back from the current date to include in the search results. Please note that this feature is only available when using the 'news' search topic.
|
||||
time_range(string): Optional. The time range back from the current date to include in the search results. This feature is available for both 'general' and 'news' search topics. Must be one of 'day', 'week', 'month', 'year'.
|
||||
start_date(string): Optional. The start date for the search results in the format 'YYYY-MM-DD'.
|
||||
end_date(string): Optional. The end date for the search results in the format 'YYYY-MM-DD'.
|
||||
|
||||
"""
|
||||
logger.info(f"web_searcher - search_from_tavily: {query}")
|
||||
cfg = self.context.get_config(umo=event.unified_msg_origin)
|
||||
# websearch_link = cfg["provider_settings"].get("web_search_link", False)
|
||||
if not cfg.get("provider_settings", {}).get("websearch_tavily_key", []):
|
||||
raise ValueError("Error: Tavily API key is not configured in AstrBot.")
|
||||
|
||||
# build payload
|
||||
payload = {"query": query, "max_results": max_results, "include_favicon": True}
|
||||
if search_depth not in ["basic", "advanced"]:
|
||||
search_depth = "basic"
|
||||
payload["search_depth"] = search_depth
|
||||
|
||||
if topic not in ["general", "news"]:
|
||||
topic = "general"
|
||||
payload["topic"] = topic
|
||||
|
||||
if topic == "news":
|
||||
payload["days"] = days
|
||||
|
||||
if time_range in ["day", "week", "month", "year"]:
|
||||
payload["time_range"] = time_range
|
||||
if start_date:
|
||||
payload["start_date"] = start_date
|
||||
if end_date:
|
||||
payload["end_date"] = end_date
|
||||
|
||||
results = await self._web_search_tavily(cfg, payload)
|
||||
if not results:
|
||||
return "Error: Tavily web searcher does not return any results."
|
||||
|
||||
ret_ls = []
|
||||
ref_uuid = str(uuid.uuid4())[:4]
|
||||
for idx, result in enumerate(results, 1):
|
||||
index = f"{ref_uuid}.{idx}"
|
||||
ret_ls.append(
|
||||
{
|
||||
"title": f"{result.title}",
|
||||
"url": f"{result.url}",
|
||||
"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
|
||||
# ret = "\n".join(ret_ls)
|
||||
ret = json.dumps({"results": ret_ls}, ensure_ascii=False)
|
||||
return ret
|
||||
|
||||
@llm_tool("tavily_extract_web_page")
|
||||
async def tavily_extract_web_page(
|
||||
self,
|
||||
event: AstrMessageEvent,
|
||||
url: str = "",
|
||||
extract_depth: str = "basic",
|
||||
) -> str:
|
||||
"""Extract the content of a web page using Tavily.
|
||||
|
||||
Args:
|
||||
url(string): Required. An URl to extract content from.
|
||||
extract_depth(string): Optional. The depth of the extraction, must be one of 'basic', 'advanced'. Default is "basic".
|
||||
|
||||
"""
|
||||
cfg = self.context.get_config(umo=event.unified_msg_origin)
|
||||
if not cfg.get("provider_settings", {}).get("websearch_tavily_key", []):
|
||||
raise ValueError("Error: Tavily API key is not configured in AstrBot.")
|
||||
|
||||
if not url:
|
||||
raise ValueError("Error: url must be a non-empty string.")
|
||||
if extract_depth not in ["basic", "advanced"]:
|
||||
extract_depth = "basic"
|
||||
payload = {
|
||||
"urls": [url],
|
||||
"extract_depth": extract_depth,
|
||||
}
|
||||
results = await self._extract_tavily(cfg, payload)
|
||||
ret_ls = []
|
||||
for result in results:
|
||||
ret_ls.append(f"URL: {result.get('url', 'No URL')}")
|
||||
ret_ls.append(f"Content: {result.get('raw_content', 'No content')}")
|
||||
ret = "\n".join(ret_ls)
|
||||
if not ret:
|
||||
return "Error: Tavily web searcher does not return any results."
|
||||
return ret
|
||||
|
||||
async def _get_bocha_key(self, cfg: AstrBotConfig) -> str:
|
||||
"""并发安全的从列表中获取并轮换BoCha API密钥。"""
|
||||
bocha_keys = cfg.get("provider_settings", {}).get("websearch_bocha_key", [])
|
||||
if not bocha_keys:
|
||||
raise ValueError("错误:BoCha API密钥未在AstrBot中配置。")
|
||||
|
||||
async with self.bocha_key_lock:
|
||||
key = bocha_keys[self.bocha_key_index]
|
||||
self.bocha_key_index = (self.bocha_key_index + 1) % len(bocha_keys)
|
||||
return key
|
||||
|
||||
async def _web_search_bocha(
|
||||
self,
|
||||
cfg: AstrBotConfig,
|
||||
payload: dict,
|
||||
) -> list[SearchResult]:
|
||||
"""使用 BoCha 搜索引擎进行搜索"""
|
||||
bocha_key = await self._get_bocha_key(cfg)
|
||||
url = "https://api.bochaai.com/v1/web-search"
|
||||
header = {
|
||||
"Authorization": f"Bearer {bocha_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers=header,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
reason = await response.text()
|
||||
raise Exception(
|
||||
f"BoCha web search failed: {reason}, status: {response.status}",
|
||||
)
|
||||
data = await response.json()
|
||||
data = data["data"]["webPages"]["value"]
|
||||
results = []
|
||||
for item in data:
|
||||
result = SearchResult(
|
||||
title=item.get("name"),
|
||||
url=item.get("url"),
|
||||
snippet=item.get("snippet"),
|
||||
favicon=item.get("siteIcon"),
|
||||
)
|
||||
results.append(result)
|
||||
return results
|
||||
|
||||
@llm_tool("web_search_bocha")
|
||||
async def search_from_bocha(
|
||||
self,
|
||||
event: AstrMessageEvent,
|
||||
query: str,
|
||||
freshness: str = "noLimit",
|
||||
summary: bool = False,
|
||||
include: str = "",
|
||||
exclude: str = "",
|
||||
count: int = 10,
|
||||
) -> str:
|
||||
"""A web search tool based on Bocha Search API, used to retrieve web pages
|
||||
related to the user's query.
|
||||
|
||||
Args:
|
||||
query (string): Required. User's search query.
|
||||
|
||||
freshness (string): Optional. Specifies the time range of the search.
|
||||
Supported values:
|
||||
- "noLimit": No time limit (default, recommended).
|
||||
- "oneDay": Within one day.
|
||||
- "oneWeek": Within one week.
|
||||
- "oneMonth": Within one month.
|
||||
- "oneYear": Within one year.
|
||||
- "YYYY-MM-DD..YYYY-MM-DD": Search within a specific date range.
|
||||
Example: "2025-01-01..2025-04-06".
|
||||
- "YYYY-MM-DD": Search on a specific date.
|
||||
Example: "2025-04-06".
|
||||
It is recommended to use "noLimit", as the search algorithm will
|
||||
automatically optimize time relevance. Manually restricting the
|
||||
time range may result in no search results.
|
||||
|
||||
summary (boolean): Optional. Whether to include a text summary
|
||||
for each search result.
|
||||
- True: Include summary.
|
||||
- False: Do not include summary (default).
|
||||
|
||||
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:
|
||||
- "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:
|
||||
- "qq.com"
|
||||
- "qq.com|m.163.com"
|
||||
|
||||
count (number): Optional. Number of search results to return.
|
||||
- Range: 1–50
|
||||
- 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)
|
||||
# websearch_link = cfg["provider_settings"].get("web_search_link", False)
|
||||
if not cfg.get("provider_settings", {}).get("websearch_bocha_key", []):
|
||||
raise ValueError("Error: BoCha API key is not configured in AstrBot.")
|
||||
|
||||
# build payload
|
||||
payload = {
|
||||
"query": query,
|
||||
"count": count,
|
||||
}
|
||||
|
||||
# freshness:时间范围
|
||||
if freshness:
|
||||
payload["freshness"] = freshness
|
||||
|
||||
# 是否返回摘要
|
||||
payload["summary"] = summary
|
||||
|
||||
# include:限制搜索域
|
||||
if include:
|
||||
payload["include"] = include
|
||||
|
||||
# exclude:排除搜索域
|
||||
if exclude:
|
||||
payload["exclude"] = exclude
|
||||
|
||||
results = await self._web_search_bocha(cfg, payload)
|
||||
if not results:
|
||||
return "Error: BoCha web searcher does not return any results."
|
||||
|
||||
ret_ls = []
|
||||
ref_uuid = str(uuid.uuid4())[:4]
|
||||
for idx, result in enumerate(results, 1):
|
||||
index = f"{ref_uuid}.{idx}"
|
||||
ret_ls.append(
|
||||
{
|
||||
"title": f"{result.title}",
|
||||
"url": f"{result.url}",
|
||||
"snippet": f"{result.snippet}",
|
||||
"index": index,
|
||||
},
|
||||
)
|
||||
if result.favicon:
|
||||
sp.temporary_cache["_ws_favicon"][result.url] = result.favicon
|
||||
# ret = "\n".join(ret_ls)
|
||||
ret = json.dumps({"results": ret_ls}, ensure_ascii=False)
|
||||
return ret
|
||||
|
||||
@filter.on_llm_request(priority=-10000)
|
||||
async def edit_web_search_tools(
|
||||
self,
|
||||
event: AstrMessageEvent,
|
||||
req: ProviderRequest,
|
||||
) -> None:
|
||||
"""Get the session conversation for the given event."""
|
||||
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,
|
||||
)
|
||||
|
||||
tool_set = req.func_tool
|
||||
if isinstance(tool_set, FunctionToolManager):
|
||||
req.func_tool = tool_set.get_full_tool_set()
|
||||
tool_set = req.func_tool
|
||||
|
||||
if not tool_set:
|
||||
return
|
||||
|
||||
if not websearch_enable:
|
||||
# pop tools
|
||||
for tool_name in self.TOOLS:
|
||||
tool_set.remove_tool(tool_name)
|
||||
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,
|
||||
)
|
||||
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:
|
||||
tool_set.add_tool(web_search_t)
|
||||
if fetch_url_t and fetch_url_t.active:
|
||||
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":
|
||||
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:
|
||||
tool_set.add_tool(web_search_tavily)
|
||||
if tavily_extract_web_page and tavily_extract_web_page.active:
|
||||
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":
|
||||
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)
|
||||
tool_set.remove_tool("web_search")
|
||||
tool_set.remove_tool("fetch_url")
|
||||
tool_set.remove_tool("web_search_tavily")
|
||||
tool_set.remove_tool("tavily_extract_web_page")
|
||||
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":
|
||||
web_search_bocha = func_tool_mgr.get_func("web_search_bocha")
|
||||
if web_search_bocha and web_search_bocha.active:
|
||||
tool_set.add_tool(web_search_bocha)
|
||||
tool_set.remove_tool("web_search")
|
||||
tool_set.remove_tool("fetch_url")
|
||||
tool_set.remove_tool("AIsearch")
|
||||
tool_set.remove_tool("web_search_tavily")
|
||||
tool_set.remove_tool("tavily_extract_web_page")
|
||||
@@ -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.22.3"
|
||||
|
||||
@@ -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 AudioURLPart, ImageURLPart, Message, TextPart, ThinkPart
|
||||
|
||||
|
||||
@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,14 +24,13 @@ class TokenCounter(Protocol):
|
||||
|
||||
Returns:
|
||||
The total token count.
|
||||
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# 图片/音频 token 开销估算值,参考 OpenAI vision pricing:
|
||||
# low-res ~85 tokens, high-res ~170 per 512px tile, 通常几百到上千。
|
||||
# 这里取一个保守中位数,宁可偏高触发压缩也不要偏低导致 API 报错。
|
||||
# 图片/音频 token 开销估算值,参考 OpenAI vision pricing:
|
||||
# low-res ~85 tokens, high-res ~170 per 512px tile, 通常几百到上千。
|
||||
# 这里取一个保守中位数,宁可偏高触发压缩也不要偏低导致 API 报错。
|
||||
IMAGE_TOKEN_ESTIMATE = 765
|
||||
AUDIO_TOKEN_ESTIMATE = 500
|
||||
|
||||
@@ -52,9 +44,7 @@ class EstimateTokenCounter:
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from astrbot.core.agent.message import Message
|
||||
from ..message import Message
|
||||
|
||||
|
||||
class ContextTruncator:
|
||||
@@ -20,7 +20,6 @@ class ContextTruncator:
|
||||
|
||||
Returns:
|
||||
tuple: (system_messages, non_system_messages)
|
||||
|
||||
"""
|
||||
first_non_system = 0
|
||||
for i, msg in enumerate(messages):
|
||||
@@ -35,44 +34,19 @@ class ContextTruncator:
|
||||
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.
|
||||
"""Ensure the result always contains the first user message right after
|
||||
system messages. This is required by many LLM APIs (e.g. Zhipu) that
|
||||
mandate a ``user`` message immediately following the ``system`` message.
|
||||
"""
|
||||
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.
|
||||
# Locate the first user message from the *original* list.
|
||||
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]
|
||||
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.
|
||||
@@ -129,7 +103,8 @@ 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.
|
||||
"""
|
||||
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.
|
||||
|
||||
@@ -140,7 +115,6 @@ class ContextTruncator:
|
||||
|
||||
Returns:
|
||||
The truncated list of messages.
|
||||
|
||||
"""
|
||||
if keep_most_recent_turns == -1:
|
||||
return messages
|
||||
@@ -165,9 +139,7 @@ class ContextTruncator:
|
||||
truncated_contexts = truncated_contexts[index:]
|
||||
|
||||
result = self._ensure_user_message(
|
||||
system_messages,
|
||||
truncated_contexts,
|
||||
messages,
|
||||
system_messages, truncated_contexts, messages
|
||||
)
|
||||
return self.fix_messages(result)
|
||||
|
||||
@@ -196,9 +168,7 @@ class ContextTruncator:
|
||||
truncated_non_system = truncated_non_system[index:]
|
||||
|
||||
result = self._ensure_user_message(
|
||||
system_messages,
|
||||
truncated_non_system,
|
||||
messages,
|
||||
system_messages, truncated_non_system, messages
|
||||
)
|
||||
return self.fix_messages(result)
|
||||
|
||||
@@ -227,8 +197,6 @@ class ContextTruncator:
|
||||
truncated_non_system = truncated_non_system[index:]
|
||||
|
||||
result = self._ensure_user_message(
|
||||
system_messages,
|
||||
truncated_non_system,
|
||||
messages,
|
||||
system_messages, truncated_non_system, messages
|
||||
)
|
||||
return self.fix_messages(result)
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
"""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 +14,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,117 +47,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":
|
||||
@@ -278,10 +59,10 @@ def _prepare_stdio_env(config: dict) -> dict:
|
||||
|
||||
|
||||
def _merge_environment_variables(env: dict) -> dict:
|
||||
"""Merge environment variables in case-insensitive systems."""
|
||||
"""合并环境变量,处理Windows不区分大小写的情况"""
|
||||
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():
|
||||
@@ -349,65 +130,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 +148,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 +155,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 +170,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,69 +202,45 @@ 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)
|
||||
@@ -573,35 +258,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 +294,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 +309,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 +327,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 +356,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 +377,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 +401,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,7 +13,7 @@ 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
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import abc
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator
|
||||
import typing as T
|
||||
from enum import Enum, auto
|
||||
from typing import Any, Generic
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core.agent.hooks import BaseAgentRunHooks
|
||||
from astrbot.core.agent.response import AgentResponse
|
||||
from astrbot.core.agent.run_context import ContextWrapper, TContext
|
||||
from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor
|
||||
from astrbot.core.provider.entities import LLMResponse, ProviderRequest
|
||||
from astrbot.core.provider.provider import Provider
|
||||
from astrbot.core.provider.entities import LLMResponse
|
||||
|
||||
from ..hooks import BaseAgentRunHooks
|
||||
from ..response import AgentResponse
|
||||
from ..run_context import ContextWrapper, TContext
|
||||
|
||||
|
||||
class AgentState(Enum):
|
||||
@@ -22,33 +19,13 @@ class AgentState(Enum):
|
||||
ERROR = auto() # Error state
|
||||
|
||||
|
||||
class BaseAgentRunner(Generic[TContext]):
|
||||
def __init__(
|
||||
self,
|
||||
):
|
||||
self.tasks: set[asyncio.Task[object]] = set()
|
||||
self._state = AgentState.IDLE
|
||||
|
||||
class BaseAgentRunner(T.Generic[TContext]):
|
||||
@abc.abstractmethod
|
||||
async def reset(
|
||||
self,
|
||||
provider: Provider,
|
||||
request: ProviderRequest,
|
||||
run_context: ContextWrapper[TContext],
|
||||
tool_executor: BaseFunctionToolExecutor[TContext],
|
||||
agent_hooks: BaseAgentRunHooks[TContext],
|
||||
streaming: bool = False,
|
||||
enforce_max_turns: int = -1,
|
||||
llm_compress_instruction: str | None = None,
|
||||
llm_compress_keep_recent: int = 0,
|
||||
llm_compress_provider: Provider | None = None,
|
||||
truncate_turns: int = 1,
|
||||
custom_token_counter: Any = None,
|
||||
custom_compressor: Any = None,
|
||||
tool_schema_mode: str | None = "full",
|
||||
fallback_providers: list[Provider] | None = None,
|
||||
provider_config: dict | None = None,
|
||||
**kwargs: Any,
|
||||
**kwargs: T.Any,
|
||||
) -> None:
|
||||
"""Reset the agent to its initial state.
|
||||
This method should be called before starting a new run.
|
||||
@@ -56,12 +33,14 @@ class BaseAgentRunner(Generic[TContext]):
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def step(self) -> AsyncGenerator[AgentResponse, None]:
|
||||
async def step(self) -> T.AsyncGenerator[AgentResponse, None]:
|
||||
"""Process a single step of the agent."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def step_until_done(self, max_step: int) -> AsyncGenerator[AgentResponse, None]:
|
||||
async def step_until_done(
|
||||
self, max_step: int
|
||||
) -> T.AsyncGenerator[AgentResponse, None]:
|
||||
"""Process steps until the agent is done."""
|
||||
...
|
||||
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
import base64
|
||||
import json
|
||||
from typing import Any, override
|
||||
import sys
|
||||
import typing as T
|
||||
|
||||
import astrbot.core.message.components as Comp
|
||||
from astrbot import logger
|
||||
from astrbot.core import sp
|
||||
from astrbot.core.agent.hooks import BaseAgentRunHooks
|
||||
from astrbot.core.agent.message import is_checkpoint_message
|
||||
from astrbot.core.agent.response import AgentResponse, AgentResponseData
|
||||
from astrbot.core.agent.run_context import ContextWrapper, TContext
|
||||
from astrbot.core.agent.runners.base import AgentState, BaseAgentRunner
|
||||
from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.provider.entities import (
|
||||
LLMResponse,
|
||||
ProviderRequest,
|
||||
)
|
||||
from astrbot.core.provider.provider import Provider
|
||||
|
||||
from ...hooks import BaseAgentRunHooks
|
||||
from ...response import AgentResponseData
|
||||
from ...run_context import ContextWrapper, TContext
|
||||
from ..base import AgentResponse, AgentState, BaseAgentRunner
|
||||
from .coze_api_client import CozeAPIClient
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override
|
||||
else:
|
||||
from typing_extensions import override
|
||||
|
||||
|
||||
class CozeAgentRunner(BaseAgentRunner[TContext]):
|
||||
"""Coze Agent Runner"""
|
||||
@@ -27,45 +30,32 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
|
||||
@override
|
||||
async def reset(
|
||||
self,
|
||||
provider: Provider,
|
||||
request: ProviderRequest,
|
||||
run_context: ContextWrapper[TContext],
|
||||
tool_executor: BaseFunctionToolExecutor[TContext],
|
||||
agent_hooks: BaseAgentRunHooks[TContext],
|
||||
streaming: bool = False,
|
||||
enforce_max_turns: int = -1,
|
||||
llm_compress_instruction: str | None = None,
|
||||
llm_compress_keep_recent: int = 0,
|
||||
llm_compress_provider: Provider | None = None,
|
||||
truncate_turns: int = 1,
|
||||
custom_token_counter: Any = None,
|
||||
custom_compressor: Any = None,
|
||||
tool_schema_mode: str | None = "full",
|
||||
fallback_providers: list[Provider] | None = None,
|
||||
provider_config: dict | None = None,
|
||||
**kwargs: Any,
|
||||
provider_config: dict,
|
||||
**kwargs: T.Any,
|
||||
) -> None:
|
||||
self.req = request
|
||||
self.streaming = streaming
|
||||
self.streaming = kwargs.get("streaming", False)
|
||||
self.final_llm_resp = None
|
||||
self._state = AgentState.IDLE
|
||||
self.agent_hooks = agent_hooks
|
||||
self.run_context = run_context
|
||||
|
||||
provider_config = provider_config or {}
|
||||
self.api_key = provider_config.get("coze_api_key", "")
|
||||
if not self.api_key:
|
||||
raise Exception("Coze API Key 不能为空。")
|
||||
raise Exception("Coze API Key 不能为空。")
|
||||
self.bot_id = provider_config.get("bot_id", "")
|
||||
if not self.bot_id:
|
||||
raise Exception("Coze Bot ID 不能为空。")
|
||||
raise Exception("Coze Bot ID 不能为空。")
|
||||
self.api_base: str = provider_config.get("coze_api_base", "https://api.coze.cn")
|
||||
|
||||
if not isinstance(self.api_base, str) or not self.api_base.startswith(
|
||||
("http://", "https://"),
|
||||
):
|
||||
raise Exception(
|
||||
"Coze API Base URL 格式不正确,必须以 http:// 或 https:// 开头。",
|
||||
"Coze API Base URL 格式不正确,必须以 http:// 或 https:// 开头。",
|
||||
)
|
||||
|
||||
self.timeout = provider_config.get("timeout", 120)
|
||||
@@ -81,7 +71,9 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
|
||||
|
||||
@override
|
||||
async def step(self):
|
||||
"""执行 Coze Agent 的一个步骤"""
|
||||
"""
|
||||
执行 Coze Agent 的一个步骤
|
||||
"""
|
||||
if not self.req:
|
||||
raise ValueError("Request is not set. Please call reset() first.")
|
||||
|
||||
@@ -91,7 +83,7 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
|
||||
except Exception as e:
|
||||
logger.error(f"Error in on_agent_begin hook: {e}", exc_info=True)
|
||||
|
||||
# 开始处理,转换到运行状态
|
||||
# 开始处理,转换到运行状态
|
||||
self._transition_state(AgentState.RUNNING)
|
||||
|
||||
try:
|
||||
@@ -99,23 +91,24 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
|
||||
async for response in self._execute_coze_request():
|
||||
yield response
|
||||
except Exception as e:
|
||||
logger.error(f"Coze 请求失败:{e!s}")
|
||||
logger.error(f"Coze 请求失败:{str(e)}")
|
||||
self._transition_state(AgentState.ERROR)
|
||||
self.final_llm_resp = LLMResponse(
|
||||
role="err",
|
||||
completion_text=f"Coze 请求失败:{e!s}",
|
||||
role="err", completion_text=f"Coze 请求失败:{str(e)}"
|
||||
)
|
||||
yield AgentResponse(
|
||||
type="err",
|
||||
data=AgentResponseData(
|
||||
chain=MessageChain().message(f"Coze 请求失败:{e!s}"),
|
||||
chain=MessageChain().message(f"Coze 请求失败:{str(e)}")
|
||||
),
|
||||
)
|
||||
finally:
|
||||
await self.api_client.close()
|
||||
|
||||
@override
|
||||
async def step_until_done(self, max_step: int):
|
||||
async def step_until_done(
|
||||
self, max_step: int = 30
|
||||
) -> T.AsyncGenerator[AgentResponse, None]:
|
||||
while not self.done():
|
||||
async for resp in self.step():
|
||||
yield resp
|
||||
@@ -155,13 +148,11 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
|
||||
# 处理历史上下文
|
||||
if not self.auto_save_history and contexts:
|
||||
for ctx in contexts:
|
||||
if is_checkpoint_message(ctx):
|
||||
continue
|
||||
if isinstance(ctx, dict) and "role" in ctx and "content" in ctx:
|
||||
# 处理上下文中的图片
|
||||
content = ctx["content"]
|
||||
if isinstance(content, list):
|
||||
# 多模态内容,需要处理图片
|
||||
# 多模态内容,需要处理图片
|
||||
processed_content = []
|
||||
for item in content:
|
||||
if isinstance(item, dict):
|
||||
@@ -175,8 +166,7 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
|
||||
if url:
|
||||
file_id = (
|
||||
await self._download_and_upload_image(
|
||||
url,
|
||||
session_id,
|
||||
url, session_id
|
||||
)
|
||||
)
|
||||
processed_content.append(
|
||||
@@ -184,7 +174,7 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
|
||||
"type": "file",
|
||||
"file_id": file_id,
|
||||
"file_url": url,
|
||||
},
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"处理上下文图片失败: {e}")
|
||||
@@ -196,7 +186,7 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
|
||||
"role": ctx["role"],
|
||||
"content": processed_content,
|
||||
"content_type": "object_string",
|
||||
},
|
||||
}
|
||||
)
|
||||
else:
|
||||
# 纯文本内容
|
||||
@@ -205,7 +195,7 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
|
||||
"role": ctx["role"],
|
||||
"content": content,
|
||||
"content_type": "text",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# 构建当前消息
|
||||
@@ -225,7 +215,7 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
|
||||
{
|
||||
"type": "image",
|
||||
"file_id": file_id,
|
||||
},
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"处理图片失败 {url}: {e}")
|
||||
@@ -238,7 +228,7 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
|
||||
"role": "user",
|
||||
"content": content,
|
||||
"content_type": "object_string",
|
||||
},
|
||||
}
|
||||
)
|
||||
elif prompt:
|
||||
# 纯文本
|
||||
@@ -287,12 +277,12 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
|
||||
accumulated_content += content
|
||||
message_started = True
|
||||
|
||||
# 如果是流式响应,发送增量数据
|
||||
# 如果是流式响应,发送增量数据
|
||||
if self.streaming:
|
||||
yield AgentResponse(
|
||||
type="streaming_delta",
|
||||
data=AgentResponseData(
|
||||
chain=MessageChain().message(content),
|
||||
chain=MessageChain().message(content)
|
||||
),
|
||||
)
|
||||
|
||||
@@ -338,7 +328,7 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
|
||||
image_url: str,
|
||||
session_id: str | None = None,
|
||||
) -> str:
|
||||
"""下载图片并上传到 Coze,返回 file_id"""
|
||||
"""下载图片并上传到 Coze,返回 file_id"""
|
||||
import hashlib
|
||||
|
||||
# 计算哈希实现缓存
|
||||
@@ -359,13 +349,13 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
|
||||
|
||||
if session_id:
|
||||
self.file_id_cache[session_id][cache_key] = file_id
|
||||
logger.debug(f"[Coze] 图片上传成功并缓存,file_id: {file_id}")
|
||||
logger.debug(f"[Coze] 图片上传成功并缓存,file_id: {file_id}")
|
||||
|
||||
return file_id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理图片失败 {image_url}: {e!s}")
|
||||
raise Exception(f"处理图片失败: {e!s}") from e
|
||||
raise Exception(f"处理图片失败: {e!s}")
|
||||
|
||||
@override
|
||||
def done(self) -> bool:
|
||||
|
||||
@@ -66,7 +66,7 @@ class CozeAPIClient:
|
||||
timeout=aiohttp.ClientTimeout(total=60),
|
||||
) as response:
|
||||
if response.status == 401:
|
||||
raise Exception("Coze API 认证失败,请检查 API Key 是否正确")
|
||||
raise Exception("Coze API 认证失败,请检查 API Key 是否正确")
|
||||
|
||||
response_text = await response.text()
|
||||
logger.debug(
|
||||
@@ -75,27 +75,27 @@ class CozeAPIClient:
|
||||
|
||||
if response.status != 200:
|
||||
raise Exception(
|
||||
f"文件上传失败,状态码: {response.status}, 响应: {response_text}",
|
||||
f"文件上传失败,状态码: {response.status}, 响应: {response_text}",
|
||||
)
|
||||
|
||||
try:
|
||||
result = await response.json()
|
||||
except json.JSONDecodeError:
|
||||
raise Exception(f"文件上传响应解析失败: {response_text}") from None
|
||||
raise Exception(f"文件上传响应解析失败: {response_text}")
|
||||
|
||||
if result.get("code") != 0:
|
||||
raise Exception(f"文件上传失败: {result.get('msg', '未知错误')}")
|
||||
|
||||
file_id = result["data"]["id"]
|
||||
logger.debug(f"[Coze] 图片上传成功,file_id: {file_id}")
|
||||
logger.debug(f"[Coze] 图片上传成功,file_id: {file_id}")
|
||||
return file_id
|
||||
|
||||
except TimeoutError:
|
||||
except asyncio.TimeoutError:
|
||||
logger.error("文件上传超时")
|
||||
raise Exception("文件上传超时") from None
|
||||
raise Exception("文件上传超时")
|
||||
except Exception as e:
|
||||
logger.error(f"文件上传失败: {e!s}")
|
||||
raise Exception(f"文件上传失败: {e!s}") from e
|
||||
raise Exception(f"文件上传失败: {e!s}")
|
||||
|
||||
async def download_image(self, image_url: str) -> bytes:
|
||||
"""下载图片并返回字节数据
|
||||
@@ -111,14 +111,14 @@ class CozeAPIClient:
|
||||
try:
|
||||
async with session.get(image_url) as response:
|
||||
if response.status != 200:
|
||||
raise Exception(f"下载图片失败,状态码: {response.status}")
|
||||
raise Exception(f"下载图片失败,状态码: {response.status}")
|
||||
|
||||
image_data = await response.read()
|
||||
return image_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"下载图片失败 {image_url}: {e!s}")
|
||||
raise Exception(f"下载图片失败: {e!s}") from e
|
||||
raise Exception(f"下载图片失败: {e!s}")
|
||||
|
||||
async def chat_messages(
|
||||
self,
|
||||
@@ -145,7 +145,7 @@ class CozeAPIClient:
|
||||
session = await self._ensure_session()
|
||||
url = f"{self.api_base}/v3/chat"
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
payload = {
|
||||
"bot_id": bot_id,
|
||||
"user_id": user_id,
|
||||
"stream": stream,
|
||||
@@ -169,10 +169,10 @@ class CozeAPIClient:
|
||||
timeout=aiohttp.ClientTimeout(total=timeout),
|
||||
) as response:
|
||||
if response.status == 401:
|
||||
raise Exception("Coze API 认证失败,请检查 API Key 是否正确")
|
||||
raise Exception("Coze API 认证失败,请检查 API Key 是否正确")
|
||||
|
||||
if response.status != 200:
|
||||
raise Exception(f"Coze API 流式请求失败,状态码: {response.status}")
|
||||
raise Exception(f"Coze API 流式请求失败,状态码: {response.status}")
|
||||
|
||||
# SSE
|
||||
buffer = ""
|
||||
@@ -203,10 +203,10 @@ class CozeAPIClient:
|
||||
except json.JSONDecodeError:
|
||||
event_data = {"content": data_str}
|
||||
|
||||
except TimeoutError:
|
||||
raise Exception(f"Coze API 流式请求超时 ({timeout}秒)") from None
|
||||
except asyncio.TimeoutError:
|
||||
raise Exception(f"Coze API 流式请求超时 ({timeout}秒)")
|
||||
except Exception as e:
|
||||
raise Exception(f"Coze API 流式请求失败: {e!s}") from e
|
||||
raise Exception(f"Coze API 流式请求失败: {e!s}")
|
||||
|
||||
async def clear_context(self, conversation_id: str):
|
||||
"""清空会话上下文
|
||||
@@ -226,20 +226,20 @@ class CozeAPIClient:
|
||||
response_text = await response.text()
|
||||
|
||||
if response.status == 401:
|
||||
raise Exception("Coze API 认证失败,请检查 API Key 是否正确")
|
||||
raise Exception("Coze API 认证失败,请检查 API Key 是否正确")
|
||||
|
||||
if response.status != 200:
|
||||
raise Exception(f"Coze API 请求失败,状态码: {response.status}")
|
||||
raise Exception(f"Coze API 请求失败,状态码: {response.status}")
|
||||
|
||||
try:
|
||||
return json.loads(response_text)
|
||||
except json.JSONDecodeError:
|
||||
raise Exception("Coze API 返回非JSON格式") from None
|
||||
raise Exception("Coze API 返回非JSON格式")
|
||||
|
||||
except TimeoutError:
|
||||
raise Exception("Coze API 请求超时") from None
|
||||
except asyncio.TimeoutError:
|
||||
raise Exception("Coze API 请求超时")
|
||||
except aiohttp.ClientError as e:
|
||||
raise Exception(f"Coze API 请求失败: {e!s}") from e
|
||||
raise Exception(f"Coze API 请求失败: {e!s}")
|
||||
|
||||
async def get_message_list(
|
||||
self,
|
||||
@@ -275,7 +275,7 @@ class CozeAPIClient:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取Coze消息列表失败: {e!s}")
|
||||
raise Exception(f"获取Coze消息列表失败: {e!s}") from e
|
||||
raise Exception(f"获取Coze消息列表失败: {e!s}")
|
||||
|
||||
async def close(self) -> None:
|
||||
"""关闭会话"""
|
||||
@@ -288,18 +288,17 @@ if __name__ == "__main__":
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
import anyio
|
||||
|
||||
async def test_coze_api_client() -> None:
|
||||
api_key = os.getenv("COZE_API_KEY", "")
|
||||
bot_id = os.getenv("COZE_BOT_ID", "")
|
||||
client = CozeAPIClient(api_key=api_key)
|
||||
|
||||
try:
|
||||
async with await anyio.open_file("README.md", "rb") as f:
|
||||
file_data = await f.read()
|
||||
with open("README.md", "rb") as f:
|
||||
file_data = f.read()
|
||||
file_id = await client.upload_file(file_data)
|
||||
async for _event in client.chat_messages(
|
||||
print(f"Uploaded file_id: {file_id}")
|
||||
async for event in client.chat_messages(
|
||||
bot_id=bot_id,
|
||||
user_id="test_user",
|
||||
additional_messages=[
|
||||
@@ -317,7 +316,7 @@ if __name__ == "__main__":
|
||||
],
|
||||
stream=True,
|
||||
):
|
||||
pass
|
||||
print(f"Event: {event}")
|
||||
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
@@ -2,26 +2,30 @@ import asyncio
|
||||
import functools
|
||||
import queue
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any, override
|
||||
import typing as T
|
||||
|
||||
from dashscope import Application
|
||||
from dashscope.app.application_response import ApplicationResponse
|
||||
|
||||
import astrbot.core.message.components as Comp
|
||||
from astrbot.core import logger, sp
|
||||
from astrbot.core.agent.hooks import BaseAgentRunHooks
|
||||
from astrbot.core.agent.response import AgentResponseData
|
||||
from astrbot.core.agent.run_context import ContextWrapper, TContext
|
||||
from astrbot.core.agent.runners.base import AgentResponse, AgentState, BaseAgentRunner
|
||||
from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.provider.entities import (
|
||||
LLMResponse,
|
||||
ProviderRequest,
|
||||
)
|
||||
from astrbot.core.provider.provider import Provider
|
||||
|
||||
from ...hooks import BaseAgentRunHooks
|
||||
from ...response import AgentResponseData
|
||||
from ...run_context import ContextWrapper, TContext
|
||||
from ..base import AgentResponse, AgentState, BaseAgentRunner
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override
|
||||
else:
|
||||
from typing_extensions import override
|
||||
|
||||
|
||||
class DashscopeAgentRunner(BaseAgentRunner[TContext]):
|
||||
@@ -30,41 +34,28 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
|
||||
@override
|
||||
async def reset(
|
||||
self,
|
||||
provider: Provider,
|
||||
request: ProviderRequest,
|
||||
run_context: ContextWrapper[TContext],
|
||||
tool_executor: BaseFunctionToolExecutor[TContext],
|
||||
agent_hooks: BaseAgentRunHooks[TContext],
|
||||
streaming: bool = False,
|
||||
enforce_max_turns: int = -1,
|
||||
llm_compress_instruction: str | None = None,
|
||||
llm_compress_keep_recent: int = 0,
|
||||
llm_compress_provider: Provider | None = None,
|
||||
truncate_turns: int = 1,
|
||||
custom_token_counter: Any = None,
|
||||
custom_compressor: Any = None,
|
||||
tool_schema_mode: str | None = "full",
|
||||
fallback_providers: list[Provider] | None = None,
|
||||
provider_config: dict | None = None,
|
||||
**kwargs: Any,
|
||||
provider_config: dict,
|
||||
**kwargs: T.Any,
|
||||
) -> None:
|
||||
self.req = request
|
||||
self.streaming = streaming
|
||||
self.final_llm_resp: LLMResponse | None = None
|
||||
self.streaming = kwargs.get("streaming", False)
|
||||
self.final_llm_resp = None
|
||||
self._state = AgentState.IDLE
|
||||
self.agent_hooks = agent_hooks
|
||||
self.run_context = run_context
|
||||
|
||||
provider_config = provider_config or {}
|
||||
self.api_key = provider_config.get("dashscope_api_key", "")
|
||||
if not self.api_key:
|
||||
raise Exception("阿里云百炼 API Key 不能为空。")
|
||||
raise Exception("阿里云百炼 API Key 不能为空。")
|
||||
self.app_id = provider_config.get("dashscope_app_id", "")
|
||||
if not self.app_id:
|
||||
raise Exception("阿里云百炼 APP ID 不能为空。")
|
||||
raise Exception("阿里云百炼 APP ID 不能为空。")
|
||||
self.dashscope_app_type = provider_config.get("dashscope_app_type", "")
|
||||
if not self.dashscope_app_type:
|
||||
raise Exception("阿里云百炼 APP 类型不能为空。")
|
||||
raise Exception("阿里云百炼 APP 类型不能为空。")
|
||||
|
||||
self.variables: dict = provider_config.get("variables", {}) or {}
|
||||
self.rag_options: dict = provider_config.get("rag_options", {})
|
||||
@@ -92,7 +83,9 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
|
||||
|
||||
@override
|
||||
async def step(self):
|
||||
"""执行 Dashscope Agent 的一个步骤"""
|
||||
"""
|
||||
执行 Dashscope Agent 的一个步骤
|
||||
"""
|
||||
if not self.req:
|
||||
raise ValueError("Request is not set. Please call reset() first.")
|
||||
|
||||
@@ -102,7 +95,7 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
|
||||
except Exception as e:
|
||||
logger.error(f"Error in on_agent_begin hook: {e}", exc_info=True)
|
||||
|
||||
# 开始处理,转换到运行状态
|
||||
# 开始处理,转换到运行状态
|
||||
self._transition_state(AgentState.RUNNING)
|
||||
|
||||
try:
|
||||
@@ -110,29 +103,28 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
|
||||
async for response in self._execute_dashscope_request():
|
||||
yield response
|
||||
except Exception as e:
|
||||
logger.error(f"阿里云百炼请求失败:{e!s}")
|
||||
logger.error(f"阿里云百炼请求失败:{str(e)}")
|
||||
self._transition_state(AgentState.ERROR)
|
||||
self.final_llm_resp = LLMResponse(
|
||||
role="err",
|
||||
completion_text=f"阿里云百炼请求失败:{e!s}",
|
||||
role="err", completion_text=f"阿里云百炼请求失败:{str(e)}"
|
||||
)
|
||||
yield AgentResponse(
|
||||
type="err",
|
||||
data=AgentResponseData(
|
||||
chain=MessageChain().message(f"阿里云百炼请求失败:{e!s}"),
|
||||
chain=MessageChain().message(f"阿里云百炼请求失败:{str(e)}")
|
||||
),
|
||||
)
|
||||
|
||||
@override
|
||||
async def step_until_done(self, max_step: int):
|
||||
async def step_until_done(
|
||||
self, max_step: int = 30
|
||||
) -> T.AsyncGenerator[AgentResponse, None]:
|
||||
while not self.done():
|
||||
async for resp in self.step():
|
||||
yield resp
|
||||
|
||||
def _consume_sync_generator(
|
||||
self,
|
||||
response: Any,
|
||||
response_queue: queue.Queue,
|
||||
self, response: T.Any, response_queue: queue.Queue
|
||||
) -> None:
|
||||
"""在线程中消费同步generator,将结果放入队列
|
||||
|
||||
@@ -153,9 +145,7 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
|
||||
response_queue.put(("done", None))
|
||||
|
||||
async def _process_stream_chunk(
|
||||
self,
|
||||
chunk: ApplicationResponse,
|
||||
output_text: str,
|
||||
self, chunk: ApplicationResponse, output_text: str
|
||||
) -> tuple[str, list | None, AgentResponse | None]:
|
||||
"""处理流式响应的单个chunk
|
||||
|
||||
@@ -171,7 +161,7 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
|
||||
|
||||
if chunk.status_code != 200:
|
||||
logger.error(
|
||||
f"阿里云百炼请求失败: request_id={chunk.request_id}, code={chunk.status_code}, message={chunk.message}, 请参考文档:https://help.aliyun.com/zh/model-studio/developer-reference/error-code",
|
||||
f"阿里云百炼请求失败: request_id={chunk.request_id}, code={chunk.status_code}, message={chunk.message}, 请参考文档:https://help.aliyun.com/zh/model-studio/developer-reference/error-code",
|
||||
)
|
||||
self._transition_state(AgentState.ERROR)
|
||||
error_msg = (
|
||||
@@ -190,8 +180,7 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
|
||||
),
|
||||
)
|
||||
|
||||
chunk_text_value = chunk.output.get("text", "")
|
||||
chunk_text = chunk_text_value if isinstance(chunk_text_value, str) else ""
|
||||
chunk_text = chunk.output.get("text", "") or ""
|
||||
# RAG 引用脚标格式化
|
||||
chunk_text = re.sub(r"<ref>\[(\d+)\]</ref>", r"[\1]", chunk_text)
|
||||
|
||||
@@ -204,10 +193,7 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
|
||||
)
|
||||
|
||||
# 获取文档引用
|
||||
raw_doc_references = chunk.output.get("doc_references")
|
||||
doc_references = (
|
||||
raw_doc_references if isinstance(raw_doc_references, list) else None
|
||||
)
|
||||
doc_references = chunk.output.get("doc_references", None)
|
||||
|
||||
return output_text, doc_references, response
|
||||
|
||||
@@ -231,11 +217,7 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
|
||||
return f"\n\n回答来源:\n{ref_str}"
|
||||
|
||||
async def _build_request_payload(
|
||||
self,
|
||||
prompt: str,
|
||||
session_id: str,
|
||||
contexts: list,
|
||||
system_prompt: str,
|
||||
self, prompt: str, session_id: str, contexts: list, system_prompt: str
|
||||
) -> dict:
|
||||
"""构建请求payload
|
||||
|
||||
@@ -256,17 +238,15 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
|
||||
default="",
|
||||
)
|
||||
# 获得会话变量
|
||||
payload_vars: dict = self.variables.copy()
|
||||
session_var: dict = (
|
||||
await sp.get_async(
|
||||
scope="umo",
|
||||
scope_id=session_id,
|
||||
key="session_variables",
|
||||
default={},
|
||||
)
|
||||
or {}
|
||||
payload_vars = self.variables.copy()
|
||||
session_var = await sp.get_async(
|
||||
scope="umo",
|
||||
scope_id=session_id,
|
||||
key="session_variables",
|
||||
default={},
|
||||
)
|
||||
payload_vars.update(session_var)
|
||||
|
||||
if (
|
||||
self.dashscope_app_type in ["agent", "dialog-workflow"]
|
||||
and not self.has_rag_options()
|
||||
@@ -283,24 +263,23 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
|
||||
if conversation_id:
|
||||
p["session_id"] = conversation_id
|
||||
return p
|
||||
# 不支持多轮对话的
|
||||
payload = {
|
||||
"app_id": self.app_id,
|
||||
"prompt": prompt,
|
||||
"api_key": self.api_key,
|
||||
"biz_params": payload_vars or None,
|
||||
"stream": self.streaming,
|
||||
"incremental_output": True,
|
||||
}
|
||||
if self.rag_options:
|
||||
payload["rag_options"] = self.rag_options
|
||||
return payload
|
||||
else:
|
||||
# 不支持多轮对话的
|
||||
payload = {
|
||||
"app_id": self.app_id,
|
||||
"prompt": prompt,
|
||||
"api_key": self.api_key,
|
||||
"biz_params": payload_vars or None,
|
||||
"stream": self.streaming,
|
||||
"incremental_output": True,
|
||||
}
|
||||
if self.rag_options:
|
||||
payload["rag_options"] = self.rag_options
|
||||
return payload
|
||||
|
||||
async def _handle_streaming_response(
|
||||
self,
|
||||
response: Any,
|
||||
session_id: str,
|
||||
) -> AsyncGenerator[AgentResponse, None]:
|
||||
self, response: T.Any, session_id: str
|
||||
) -> T.AsyncGenerator[AgentResponse, None]:
|
||||
"""处理流式响应
|
||||
|
||||
Args:
|
||||
@@ -310,7 +289,7 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
|
||||
AgentResponse 对象
|
||||
|
||||
"""
|
||||
response_queue: queue.Queue[tuple[str, Any]] = queue.Queue()
|
||||
response_queue = queue.Queue()
|
||||
consumer_thread = threading.Thread(
|
||||
target=self._consume_sync_generator,
|
||||
args=(response, response_queue),
|
||||
@@ -324,10 +303,7 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
|
||||
while True:
|
||||
try:
|
||||
item_type, item_data = await asyncio.get_running_loop().run_in_executor(
|
||||
None,
|
||||
response_queue.get,
|
||||
True,
|
||||
1,
|
||||
None, response_queue.get, True, 1
|
||||
)
|
||||
except queue.Empty:
|
||||
continue
|
||||
@@ -335,10 +311,6 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
|
||||
if item_type == "done":
|
||||
break
|
||||
elif item_type == "error":
|
||||
if not isinstance(item_data, BaseException):
|
||||
raise RuntimeError(
|
||||
f"Unexpected Dashscope error payload: {item_data!r}",
|
||||
)
|
||||
raise item_data
|
||||
elif item_type == "data":
|
||||
chunk = item_data
|
||||
@@ -347,14 +319,14 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
|
||||
(
|
||||
output_text,
|
||||
chunk_doc_refs,
|
||||
agent_response,
|
||||
response,
|
||||
) = await self._process_stream_chunk(chunk, output_text)
|
||||
|
||||
if agent_response:
|
||||
if agent_response.type == "err":
|
||||
yield agent_response
|
||||
if response:
|
||||
if response.type == "err":
|
||||
yield response
|
||||
return
|
||||
yield agent_response
|
||||
yield response
|
||||
|
||||
if chunk_doc_refs:
|
||||
doc_references = chunk_doc_refs
|
||||
@@ -380,12 +352,11 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
|
||||
|
||||
# 创建最终响应
|
||||
chain = MessageChain(chain=[Comp.Plain(output_text)])
|
||||
final_llm_resp = LLMResponse(role="assistant", result_chain=chain)
|
||||
self.final_llm_resp = final_llm_resp
|
||||
self.final_llm_resp = LLMResponse(role="assistant", result_chain=chain)
|
||||
self._transition_state(AgentState.DONE)
|
||||
|
||||
try:
|
||||
await self.agent_hooks.on_agent_done(self.run_context, final_llm_resp)
|
||||
await self.agent_hooks.on_agent_done(self.run_context, self.final_llm_resp)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in on_agent_done hook: {e}", exc_info=True)
|
||||
|
||||
@@ -405,14 +376,11 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
|
||||
|
||||
# 检查图片输入
|
||||
if image_urls:
|
||||
logger.warning("阿里云百炼暂不支持图片输入,将自动忽略图片内容。")
|
||||
logger.warning("阿里云百炼暂不支持图片输入,将自动忽略图片内容。")
|
||||
|
||||
# 构建请求payload
|
||||
payload = await self._build_request_payload(
|
||||
prompt,
|
||||
session_id,
|
||||
contexts,
|
||||
system_prompt,
|
||||
prompt, session_id, contexts, system_prompt
|
||||
)
|
||||
|
||||
if not self.streaming:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user