Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 572689b416 |
@@ -17,6 +17,7 @@ ENV/
|
||||
.conda/
|
||||
dashboard/
|
||||
data/
|
||||
changelogs/
|
||||
tests/
|
||||
.ruff_cache/
|
||||
.astrbot
|
||||
|
||||
@@ -1,40 +1,42 @@
|
||||
|
||||
name: '🎉 Feature Request / 功能建议'
|
||||
name: '🎉 功能建议'
|
||||
title: "[Feature]"
|
||||
description: Submit a suggestion to help us improve. / 提交建议帮助我们改进。
|
||||
description: 提交建议帮助我们改进。
|
||||
labels: [ "enhancement" ]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for taking the time to suggest a new feature! Please explain your idea clearly and accurately. / 感谢您抽出时间提出新功能建议,请准确解释您的想法。
|
||||
感谢您抽出时间提出新功能建议,请准确解释您的想法。
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description / 描述
|
||||
description: Please describe the feature you want to be added in detail. / 请详细描述您希望添加的功能。
|
||||
label: 描述
|
||||
description: 简短描述您的功能建议。
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Use Case / 使用场景
|
||||
description: Please describe the use case for this feature. / 请描述这个功能的使用场景。
|
||||
label: 使用场景
|
||||
description: 你想要发生什么?
|
||||
placeholder: >
|
||||
一个清晰且具体的描述这个功能的使用场景。
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Willing to Submit PR? / 是否愿意提交PR?
|
||||
label: 你愿意提交PR吗?
|
||||
description: >
|
||||
This is not required, but if you are willing to submit a PR to implement this feature, it would be greatly appreciated! / 这不是必需的,但如果您愿意提交 PR 来实现这个功能,我们将不胜感激!
|
||||
这不是必须的,但我们欢迎您的贡献。
|
||||
options:
|
||||
- label: Yes, I am willing to submit a PR. / 是的,我愿意提交 PR。
|
||||
- label: 是的, 我愿意提交PR!
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Code of Conduct
|
||||
options:
|
||||
- label: >
|
||||
I have read and agree to abide by the project's [Code of Conduct](https://docs.github.com/zh/site-policy/github-terms/github-community-code-of-conduct). /
|
||||
我已阅读并同意遵守该项目的 [行为准则](https://docs.github.com/zh/site-policy/github-terms/github-community-code-of-conduct)。
|
||||
required: true
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "Thank you for filling out our form!"
|
||||
value: "感谢您填写我们的表单!"
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Install UV
|
||||
run: pip install uv
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
mkdir -p data/temp
|
||||
export TESTING=true
|
||||
export ZHIPU_API_KEY=${{ secrets.OPENAI_API_KEY }}
|
||||
pytest --cov=astrbot -v -o log_cli=true -o log_level=DEBUG
|
||||
pytest --cov=. -v -o log_cli=true -o log_level=DEBUG
|
||||
|
||||
- name: Upload results to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
|
||||
@@ -102,11 +102,170 @@ jobs:
|
||||
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
|
||||
|
||||
build-desktop:
|
||||
name: Build ${{ matrix.name }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: linux-x64
|
||||
runner: ubuntu-24.04
|
||||
os: linux
|
||||
arch: amd64
|
||||
- name: linux-arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
os: linux
|
||||
arch: arm64
|
||||
- name: windows-x64
|
||||
runner: windows-2022
|
||||
os: win
|
||||
arch: amd64
|
||||
- name: windows-arm64
|
||||
runner: windows-11-arm
|
||||
os: win
|
||||
arch: arm64
|
||||
- name: macos-x64
|
||||
runner: macos-15-intel
|
||||
os: mac
|
||||
arch: amd64
|
||||
- name: macos-arm64
|
||||
runner: macos-15
|
||||
os: mac
|
||||
arch: arm64
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Resolve tag
|
||||
id: tag
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
elif [ -n "${{ inputs.tag }}" ]; then
|
||||
tag="${{ inputs.tag }}"
|
||||
else
|
||||
tag="$(git describe --tags --abbrev=0)"
|
||||
fi
|
||||
if [ -z "$tag" ]; then
|
||||
echo "Failed to resolve tag." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.28.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.13.0'
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: |
|
||||
dashboard/pnpm-lock.yaml
|
||||
desktop/pnpm-lock.yaml
|
||||
|
||||
- name: Prepare OpenSSL for Windows ARM64
|
||||
if: ${{ matrix.os == 'win' && matrix.arch == 'arm64' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
git clone https://github.com/microsoft/vcpkg.git C:\vcpkg
|
||||
& C:\vcpkg\bootstrap-vcpkg.bat -disableMetrics
|
||||
& C:\vcpkg\vcpkg.exe install openssl:arm64-windows
|
||||
|
||||
"VCPKG_ROOT=C:\vcpkg" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"VCPKGRS_TRIPLET=arm64-windows" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"OPENSSL_DIR=C:\vcpkg\installed\arm64-windows" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"OPENSSL_ROOT_DIR=C:\vcpkg\installed\arm64-windows" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"OPENSSL_LIB_DIR=C:\vcpkg\installed\arm64-windows\lib" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"OPENSSL_INCLUDE_DIR=C:\vcpkg\installed\arm64-windows\include" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
uv sync
|
||||
pnpm --dir dashboard install --frozen-lockfile
|
||||
pnpm --dir desktop install --frozen-lockfile
|
||||
|
||||
- name: Build desktop package
|
||||
shell: bash
|
||||
run: |
|
||||
pnpm --dir dashboard run build
|
||||
pnpm --dir desktop run build:webui
|
||||
pnpm --dir desktop run build:backend
|
||||
pnpm --dir desktop run sync:version
|
||||
pnpm --dir desktop exec electron-builder --publish never
|
||||
|
||||
- name: Normalize artifact names
|
||||
shell: bash
|
||||
env:
|
||||
NAME_PREFIX: AstrBot-${{ steps.tag.outputs.tag }}-${{ matrix.arch }}-${{ matrix.os }}
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
out_dir="desktop/dist/release"
|
||||
mkdir -p "$out_dir"
|
||||
files=(
|
||||
desktop/dist/*.AppImage
|
||||
desktop/dist/*.dmg
|
||||
desktop/dist/*.zip
|
||||
desktop/dist/*.exe
|
||||
)
|
||||
if [ ${#files[@]} -eq 0 ]; then
|
||||
echo "No desktop artifacts found to rename." >&2
|
||||
exit 1
|
||||
fi
|
||||
for src in "${files[@]}"; do
|
||||
file="$(basename "$src")"
|
||||
case "$file" in
|
||||
*.AppImage)
|
||||
dest="$out_dir/${NAME_PREFIX}.AppImage"
|
||||
;;
|
||||
*.dmg)
|
||||
dest="$out_dir/${NAME_PREFIX}.dmg"
|
||||
;;
|
||||
*.exe)
|
||||
dest="$out_dir/${NAME_PREFIX}.exe"
|
||||
;;
|
||||
*.zip)
|
||||
dest="$out_dir/${NAME_PREFIX}.zip"
|
||||
;;
|
||||
*)
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
cp "$src" "$dest"
|
||||
done
|
||||
ls -la "$out_dir"
|
||||
|
||||
- name: Upload desktop artifacts
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: AstrBot-${{ steps.tag.outputs.tag }}-${{ matrix.arch }}-${{ matrix.os }}
|
||||
if-no-files-found: error
|
||||
path: desktop/dist/release/*
|
||||
|
||||
publish-release:
|
||||
name: Publish GitHub Release
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- build-dashboard
|
||||
- build-desktop
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
@@ -137,6 +296,12 @@ jobs:
|
||||
name: Dashboard-${{ steps.tag.outputs.tag }}
|
||||
path: release-assets
|
||||
|
||||
- name: Download desktop artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: AstrBot-${{ steps.tag.outputs.tag }}-*
|
||||
path: release-assets
|
||||
merge-multiple: true
|
||||
|
||||
- name: Resolve release notes
|
||||
id: notes
|
||||
|
||||
@@ -33,6 +33,13 @@ tests/astrbot_plugin_openai
|
||||
dashboard/node_modules/
|
||||
dashboard/dist/
|
||||
.pnpm-store/
|
||||
desktop/node_modules/
|
||||
desktop/dist/
|
||||
desktop/out/
|
||||
desktop/resources/backend/astrbot-backend*
|
||||
desktop/resources/backend/*.exe
|
||||
desktop/resources/webui/*
|
||||
desktop/resources/.pyinstaller/
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、
|
||||
2. ✨ AI 大模型对话,多模态,Agent,MCP,Skills,知识库,人格设定,自动压缩对话。
|
||||
3. 🤖 支持接入 Dify、阿里云百炼、Coze 等智能体平台。
|
||||
4. 🌐 多平台,支持 QQ、企业微信、飞书、钉钉、微信公众号、Telegram、Slack 以及[更多](#支持的消息平台)。
|
||||
5. 📦 插件扩展,已有 1000+ 个插件可一键安装。
|
||||
5. 📦 插件扩展,已有近 800 个插件可一键安装。
|
||||
6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) 隔离化环境,安全地执行任何代码、调用 Shell、会话级资源复用。
|
||||
7. 💻 WebUI 支持。
|
||||
8. 🌈 Web ChatUI 支持,ChatUI 内置代理沙盒、网页搜索等。
|
||||
@@ -56,7 +56,7 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、
|
||||
<th>💙 角色扮演 & 情感陪伴</th>
|
||||
<th>✨ 主动式 Agent</th>
|
||||
<th>🚀 通用 Agentic 能力</th>
|
||||
<th>🧩 1000+ 社区插件</th>
|
||||
<th>🧩 900+ 社区插件</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
|
||||
@@ -81,15 +81,9 @@ uv tool install astrbot
|
||||
astrbot
|
||||
```
|
||||
|
||||
#### 桌面应用部署(Tauri)
|
||||
|
||||
桌面应用仓库 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
|
||||
|
||||
支持多系统架构,安装包直接安装,开箱即用,最适合新手和懒人的一键桌面部署方案,不推荐服务器场景。
|
||||
|
||||
#### 启动器一键部署(AstrBot Launcher)
|
||||
|
||||
快速部署和多开方案,实现环境隔离,进入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 仓库,在 Releases 页最新版本下找到对应的系统安装包安装即可。
|
||||
进入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 仓库,在 Releases 页最新版本下找到对应的系统安装包安装即可。
|
||||
|
||||
#### 宝塔面板部署
|
||||
|
||||
@@ -152,12 +146,15 @@ yay -S astrbot-git
|
||||
paru -S astrbot-git
|
||||
```
|
||||
|
||||
#### 桌面端 Electron 打包
|
||||
|
||||
桌面端(Electron 打包,`pnpm` 工作流)构建流程请参阅:[`desktop/README.md`](desktop/README.md)。
|
||||
|
||||
## 支持的消息平台
|
||||
|
||||
**官方维护**
|
||||
|
||||
- QQ
|
||||
- OneBot v11 协议实现
|
||||
- QQ (官方平台 & OneBot)
|
||||
- Telegram
|
||||
- 企微应用 & 企微智能机器人
|
||||
- 微信客服 & 微信公众号
|
||||
@@ -165,10 +162,10 @@ paru -S astrbot-git
|
||||
- 钉钉
|
||||
- Slack
|
||||
- Discord
|
||||
- LINE
|
||||
- Satori
|
||||
- Misskey
|
||||
- Whatsapp (将支持)
|
||||
- LINE (将支持)
|
||||
|
||||
**社区维护**
|
||||
|
||||
@@ -188,7 +185,6 @@ paru -S astrbot-git
|
||||
- DeepSeek
|
||||
- Ollama (本地部署)
|
||||
- LM Studio (本地部署)
|
||||
- [AIHubMix](https://aihubmix.com/?aff=4bfH)
|
||||
- [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74)
|
||||
- [302.AI](https://share.302.ai/rr1M3l)
|
||||
- [小马算力](https://www.tokenpony.cn/3YPyf)
|
||||
@@ -264,23 +260,13 @@ pre-commit install
|
||||
特别感谢所有 Contributors 和插件开发者对 AstrBot 的贡献 ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
||||
</a>
|
||||
|
||||
此外,本项目的诞生离不开以下开源项目的帮助:
|
||||
|
||||
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - 伟大的猫猫框架
|
||||
|
||||
开源项目友情链接:
|
||||
|
||||
- [NoneBot2](https://github.com/nonebot/nonebot2) - 优秀的 Python 异步 ChatBot 框架
|
||||
- [Koishi](https://github.com/koishijs/koishi) - 优秀的 Node.js ChatBot 框架
|
||||
- [MaiBot](https://github.com/Mai-with-u/MaiBot) - 优秀的拟人化 AI ChatBot
|
||||
- [nekro-agent](https://github.com/KroMiose/nekro-agent) - 优秀的 Agent ChatBot
|
||||
- [LangBot](https://github.com/langbot-app/LangBot) - 优秀的多平台 AI ChatBot
|
||||
- [ChatLuna](https://github.com/ChatLunaLab/chatluna) - 优秀的多平台 AI ChatBot Koishi 插件
|
||||
- [Operit AI](https://github.com/AAswordman/Operit) - 优秀的 AI 智能助手 Android APP
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
> [!TIP]
|
||||
|
||||
+19
-15
@@ -37,7 +37,7 @@
|
||||
|
||||
AstrBot is an open-source all-in-one Agent chatbot platform that integrates with mainstream instant messaging apps. It provides reliable and scalable conversational AI infrastructure for individuals, developers, and teams. Whether you're building a personal AI companion, intelligent customer service, automation assistant, or enterprise knowledge base, AstrBot enables you to quickly build production-ready AI applications within your IM platform workflows.
|
||||
|
||||

|
||||

|
||||
|
||||
## Key Features
|
||||
|
||||
@@ -45,7 +45,7 @@ AstrBot is an open-source all-in-one Agent chatbot platform that integrates with
|
||||
2. ✨ AI LLM Conversations, Multimodal, Agent, MCP, Skills, Knowledge Base, Persona Settings, Auto Context Compression.
|
||||
3. 🤖 Supports integration with Dify, Alibaba Cloud Bailian, Coze, and other agent platforms.
|
||||
4. 🌐 Multi-Platform: QQ, WeChat Work, Feishu, DingTalk, WeChat Official Accounts, Telegram, Slack, and [more](#supported-messaging-platforms).
|
||||
5. 📦 Plugin Extensions with 1000+ plugins available for one-click installation.
|
||||
5. 📦 Plugin Extensions with nearly 800 plugins available for one-click installation.
|
||||
6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) for isolated, safe execution of code, shell calls, and session-level resource reuse.
|
||||
7. 💻 WebUI Support.
|
||||
8. 🌈 Web ChatUI Support with built-in agent sandbox and web search.
|
||||
@@ -58,7 +58,7 @@ AstrBot is an open-source all-in-one Agent chatbot platform that integrates with
|
||||
<th>💙 Role-playing & Emotional Companionship</th>
|
||||
<th>✨ Proactive Agent</th>
|
||||
<th>🚀 General Agentic Capabilities</th>
|
||||
<th>🧩 1000+ Community Plugins</th>
|
||||
<th>🧩 900+ Community Plugins</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
|
||||
@@ -93,16 +93,6 @@ yay -S astrbot-git
|
||||
paru -S astrbot-git
|
||||
```
|
||||
|
||||
#### Desktop Application (Tauri)
|
||||
|
||||
Desktop repository: [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
|
||||
|
||||
Supports multiple system architectures, direct installation, out-of-the-box experience. Ideal for beginners.
|
||||
|
||||
#### AstrBot Launcher
|
||||
|
||||
Quick deployment and multi-instance solution. Visit the [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) repository and find the latest release for your system.
|
||||
|
||||
#### BT-Panel Deployment
|
||||
|
||||
AstrBot has partnered with BT-Panel and is now available in their marketplace.
|
||||
@@ -154,6 +144,20 @@ uv run main.py
|
||||
|
||||
Or refer to the official documentation: [Deploy AstrBot from Source](https://astrbot.app/deploy/astrbot/cli.html).
|
||||
|
||||
#### System Package Manager Installation
|
||||
|
||||
##### Arch Linux
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
# or use paru
|
||||
paru -S astrbot-git
|
||||
```
|
||||
|
||||
#### Desktop Electron Build
|
||||
|
||||
For desktop build steps (Electron packaging, `pnpm` workflow), see [`desktop/README.md`](desktop/README.md).
|
||||
|
||||
## Supported Messaging Platforms
|
||||
|
||||
**Officially Maintained**
|
||||
@@ -168,8 +172,8 @@ Or refer to the official documentation: [Deploy AstrBot from Source](https://ast
|
||||
- Discord
|
||||
- Satori
|
||||
- Misskey
|
||||
- LINE
|
||||
- WhatsApp (Coming Soon)
|
||||
- LINE (Coming Soon)
|
||||
|
||||
**Community Maintained**
|
||||
|
||||
@@ -264,7 +268,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=200&columns=14" />
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
||||
</a>
|
||||
|
||||
Additionally, the birth of this project would not have been possible without the help of the following open-source projects:
|
||||
|
||||
+16
-16
@@ -21,9 +21,9 @@
|
||||
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest">
|
||||
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
|
||||
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
|
||||
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFZIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
|
||||
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
|
||||
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?color=76bad9"/></a>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%20&label=Marketplace&cacheSeconds=3600">
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%E4%B8%AA&label=%E6%8F%92%E4%BB%B6%E5%B8%82%E5%9C%BA&cacheSeconds=3600">
|
||||
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
AstrBot est une plateforme de chatbot Agent tout-en-un open source qui s'intègre aux principales applications de messagerie instantanée. Elle fournit une infrastructure d'IA conversationnelle fiable et évolutive pour les particuliers, les développeurs et les équipes. Que vous construisiez un compagnon IA personnel, un service client intelligent, un assistant d'automatisation ou une base de connaissances d'entreprise, AstrBot vous permet de créer rapidement des applications d'IA prêtes pour la production dans les flux de travail de votre plateforme de messagerie.
|
||||
|
||||

|
||||
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" />
|
||||
|
||||
## Fonctionnalités principales
|
||||
|
||||
@@ -45,7 +45,7 @@ AstrBot est une plateforme de chatbot Agent tout-en-un open source qui s'intègr
|
||||
2. ✨ Dialogue avec de grands modèles d'IA, multimodal, Agent, MCP, Skills, Base de connaissances, Paramétrage de personnalité, compression automatique des dialogues.
|
||||
3. 🤖 Prise en charge de l'accès aux plateformes d'Agents telles que Dify, Alibaba Cloud Bailian, Coze, etc.
|
||||
4. 🌐 Multiplateforme : supporte QQ, WeChat Enterprise, Feishu, DingTalk, Comptes officiels WeChat, Telegram, Slack et [plus encore](#plateformes-de-messagerie-prises-en-charge).
|
||||
5. 📦 Extension par plugins, avec plus de 1000 plugins déjà disponibles pour une installation en un clic.
|
||||
5. 📦 Extension par plugins, avec près de 800 plugins déjà disponibles pour une installation en un clic.
|
||||
6. 🛡️ Environnement isolé [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) : exécution sécurisée de code, appels Shell et réutilisation des ressources au niveau de la session.
|
||||
7. 💻 Support WebUI.
|
||||
8. 🌈 Support Web ChatUI, avec sandbox d'agent intégrée, recherche web, etc.
|
||||
@@ -58,7 +58,7 @@ AstrBot est une plateforme de chatbot Agent tout-en-un open source qui s'intègr
|
||||
<th>💙 Jeux de rôle & Accompagnement émotionnel</th>
|
||||
<th>✨ Agent proactif</th>
|
||||
<th>🚀 Capacités agentiques générales</th>
|
||||
<th>🧩 1000+ Plugins de communauté</th>
|
||||
<th>🧩 900+ Plugins de communauté</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
|
||||
@@ -83,15 +83,15 @@ uv tool install astrbot
|
||||
astrbot
|
||||
```
|
||||
|
||||
#### Application de bureau (Tauri)
|
||||
#### Installation via le gestionnaire de paquets du système
|
||||
|
||||
Dépôt de l'application de bureau : [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
|
||||
##### Arch Linux
|
||||
|
||||
Prend en charge plusieurs architectures système, installation directe, prête à l'emploi. La solution de déploiement de bureau en un clic la plus adaptée aux débutants. Non recommandée pour les serveurs.
|
||||
|
||||
#### Déploiement en un clic avec le lanceur (AstrBot Launcher)
|
||||
|
||||
Déploiement rapide et solution multi-instances, isolation de l'environnement. Accédez au dépôt [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher), trouvez le package d'installation correspondant à votre système sous la dernière version sur la page Releases.
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
# ou utiliser paru
|
||||
paru -S astrbot-git
|
||||
```
|
||||
|
||||
#### Déploiement BT-Panel
|
||||
|
||||
@@ -144,13 +144,13 @@ uv run main.py
|
||||
|
||||
Ou consultez la documentation officielle : [Déployer AstrBot depuis les sources](https://astrbot.app/deploy/astrbot/cli.html).
|
||||
|
||||
#### Installation via le gestionnaire de paquets du système
|
||||
#### Установка через системный пакетный менеджер
|
||||
|
||||
##### Arch Linux
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
# ou utiliser paru
|
||||
# или используйте paru
|
||||
paru -S astrbot-git
|
||||
```
|
||||
|
||||
@@ -168,8 +168,8 @@ paru -S astrbot-git
|
||||
- Discord
|
||||
- Satori
|
||||
- Misskey
|
||||
- LINE
|
||||
- WhatsApp (Bientôt disponible)
|
||||
- LINE (Bientôt disponible)
|
||||
|
||||
**Maintenues par la communauté**
|
||||
|
||||
@@ -262,7 +262,7 @@ pre-commit install
|
||||
Un grand merci à tous les contributeurs et développeurs de plugins pour leurs contributions à AstrBot ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
||||
</a>
|
||||
|
||||
De plus, la naissance de ce projet n'aurait pas été possible sans l'aide des projets open source suivants :
|
||||
|
||||
+16
-16
@@ -21,9 +21,9 @@
|
||||
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest">
|
||||
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
|
||||
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
|
||||
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFZIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0LjYxNTZDNS4zMTUwMiAxNC4zOTk5IDUuNjAxNTYgMTQuMTEzNCA1LjYwMTU2IDEzLjc1OTlWMTEuMDM5OUM1LjYwMTU2IDEwLjY4NjQgNS4zMTUwMiAxMC4zOTk5IDQuOTYxNTYgMTAuMzk5OVoiIGZpbGw9IiNmZmYiLz4KPHBhdGggZD0iTTEzLjc1ODQgMS42MDAxSDExLjAzODRDMTAuNjg1IDEuNjAwMSAxMC4zOTg0IDEuODg2NjQgMTAuMzk4NCAyLjI0MDFWNC45NjAxQzEwLjM5ODQgNS4zMTM1NiAxMC42ODUgNS42MDAxIDExLjAzODQgNS42MDAxSDEzLjc1ODRDMTQuMTExOSA1LjYwMDEgMTQuMzk4NCA1LjMxMzU2IDE0LjM5ODQgNC45NjAxVjIuMjQwMUMxNC4zOTg0IDEuODg2NjQgMTQuMTExOSAxLjYwMDEgMTMuNzU4NCAxLjYwMDFZIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDRMNCAxMlpFIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
|
||||
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
|
||||
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?color=76bad9"/></a>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%20&label=%E3%83%97%E3%83%A9%E3%82%B0%E3%82%A4%E3%83%B3%E3%83%9E%E3%83%BC%E3%82%B1%E3%83%83%E3%83%88&cacheSeconds=3600">
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%E4%B8%AA&label=%E6%8F%92%E4%BB%B6%E5%B8%82%E5%9C%BA&cacheSeconds=3600">
|
||||
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
AstrBot は、主要なインスタントメッセージングアプリと統合できるオープンソースのオールインワン Agent チャットボットプラットフォームです。個人、開発者、チームに信頼性が高くスケーラブルな会話型 AI インフラストラクチャを提供します。パーソナル AI コンパニオン、インテリジェントカスタマーサービス、オートメーションアシスタント、エンタープライズナレッジベースなど、AstrBot を使用すると、IM プラットフォームのワークフロー内で本番環境対応の AI アプリケーションを迅速に構築できます。
|
||||
|
||||

|
||||
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" />
|
||||
|
||||
## 主な機能
|
||||
|
||||
@@ -45,7 +45,7 @@ AstrBot は、主要なインスタントメッセージングアプリと統合
|
||||
2. ✨ AI大規模言語モデル対話、マルチモーダル、Agent、MCP、Skills、ナレッジベース、ペルソナ設定、対話の自動圧縮。
|
||||
3. 🤖 Dify、Alibaba Cloud Bailian(百煉)、Coze などのAgentプラットフォームへの接続をサポート。
|
||||
4. 🌐 マルチプラットフォーム:QQ、企業微信(WeCom)、飛書(Lark)、釘釘(DingTalk)、WeChat公式アカウント、Telegram、Slack、[その他](#サポートされているメッセージプラットフォーム)に対応。
|
||||
5. 📦 プラグイン拡張:1000を超える既存プラグインをワンクリックでインストール可能。
|
||||
5. 📦 プラグイン拡張:800近い既存プラグインをワンクリックでインストール可能。
|
||||
6. 🛡️ 隔離環境[Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html):コードの安全な実行、Shell呼び出し、セッションレベルのリソース再利用。
|
||||
7. 💻 WebUI 対応。
|
||||
8. 🌈 Web ChatUI 対応:ChatUI内にAgent Sandboxやウェブ検索などを内蔵。
|
||||
@@ -58,7 +58,7 @@ AstrBot は、主要なインスタントメッセージングアプリと統合
|
||||
<th>💙 ロールプレイ & 感情的な対話</th>
|
||||
<th>✨ プロアクティブ・エージェント (Proactive Agent)</th>
|
||||
<th>🚀 汎用 エージェント的能力</th>
|
||||
<th>🧩 1000+ コミュニティプラグイン</th>
|
||||
<th>🧩 900+ コミュニティプラグイン</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
|
||||
@@ -83,15 +83,15 @@ uv tool install astrbot
|
||||
astrbot
|
||||
```
|
||||
|
||||
#### デスクトップアプリのデプロイ(Tauri)
|
||||
#### システムパッケージマネージャーでのインストール
|
||||
|
||||
デスクトップアプリのリポジトリ [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
|
||||
##### Arch Linux
|
||||
|
||||
マルチシステムアーキテクチャをサポートし、インストールしてすぐに使用可能。初心者や手軽さを求める人に最適なワンクリックデスクトップデプロイソリューションです。サーバー環境での使用は推奨されません。
|
||||
|
||||
#### ランチャーによるワンクリックデプロイ(AstrBot Launcher)
|
||||
|
||||
迅速なデプロイとマルチインスタンス対応、環境の隔離が可能。[AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) リポジトリにアクセスし、Releases ページから最新バージョンのシステム対応パッケージをダウンロードしてインストールしてください。
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
# または paru を使用
|
||||
paru -S astrbot-git
|
||||
```
|
||||
|
||||
#### 宝塔パネルデプロイ
|
||||
|
||||
@@ -144,13 +144,13 @@ uv run main.py
|
||||
|
||||
または、公式ドキュメント [ソースコードから AstrBot をデプロイ](https://astrbot.app/deploy/astrbot/cli.html) をご参照ください。
|
||||
|
||||
#### システムパッケージマネージャーでのインストール
|
||||
#### Установка через системный пакетный менеджер
|
||||
|
||||
##### Arch Linux
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
# または paru を使用
|
||||
# или используйте paru
|
||||
paru -S astrbot-git
|
||||
```
|
||||
|
||||
@@ -168,8 +168,8 @@ paru -S astrbot-git
|
||||
- Discord
|
||||
- Satori
|
||||
- Misskey
|
||||
- LINE
|
||||
- WhatsApp (近日対応予定)
|
||||
- LINE (近日対応予定)
|
||||
|
||||
**コミュニティメンテナンス**
|
||||
|
||||
@@ -263,7 +263,7 @@ pre-commit install
|
||||
AstrBot への貢献をしていただいたすべてのコントリビューターとプラグイン開発者に特別な感謝を ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
||||
</a>
|
||||
|
||||
また、このプロジェクトの誕生は以下のオープンソースプロジェクトの助けなしには実現できませんでした:
|
||||
|
||||
+9
-20
@@ -21,9 +21,9 @@
|
||||
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest">
|
||||
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
|
||||
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
|
||||
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFZIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjczODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
|
||||
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
|
||||
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?color=76bad9"/></a>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%20&label=%D0%9C%D0%B0%D1%80%D0%BA%D0%B5%D1%82%D0%BF%D0%BB%D0%B5%D0%B9%D1%81&cacheSeconds=3600">
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%E4%B8%AA&label=%E6%8F%92%E4%BB%B6%E5%B8%82%E5%9C%BA&cacheSeconds=3600">
|
||||
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
AstrBot — это универсальная платформа Agent-чатботов с открытым исходным кодом, которая интегрируется с основными приложениями для обмена мгновенными сообщениями. Она предоставляет надёжную и масштабируемую инфраструктуру разговорного ИИ для частных лиц, разработчиков и команд. Будь то персональный ИИ-компаньон, интеллектуальная служба поддержки, автоматизированный помощник или корпоративная база знаний — AstrBot позволяет быстро создавать готовые к использованию ИИ-приложения в рабочих процессах вашей платформы обмена сообщениями.
|
||||
|
||||

|
||||
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" />
|
||||
|
||||
## Основные возможности
|
||||
|
||||
@@ -45,7 +45,7 @@ AstrBot — это универсальная платформа Agent-чатб
|
||||
2. ✨ Диалоги с ИИ-моделями, мультимодальность, Agent, MCP, Skills, База знаний, Настройка личности, автоматическое сжатие диалогов.
|
||||
3. 🤖 Поддержка интеграции с платформами Agents, такими как Dify, Alibaba Cloud Bailian, Coze и др.
|
||||
4. 🌐 Мультиплатформенность: поддержка QQ, WeChat для предприятий, Feishu, DingTalk, публичных аккаунтов WeChat, Telegram, Slack и [других](#Поддерживаемые-платформы-обмена-сообщениями).
|
||||
5. 📦 Расширение плагинами: доступно более 1000 плагинов для установки в один клик.
|
||||
5. 📦 Расширение плагинами: доступно почти 800 плагинов для установки в один клик.
|
||||
6. 🛡️ Изолированная среда[Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html): безопасное выполнение любого кода, вызов Shell, повторное использование ресурсов на уровне сессии.
|
||||
7. 💻 Поддержка WebUI.
|
||||
8. 🌈 Поддержка Web ChatUI: встроенная песочница агента, веб-поиск и др.
|
||||
@@ -56,9 +56,9 @@ AstrBot — это универсальная платформа Agent-чатб
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<th>💙 Ролевые игры & Эмоциональная поддержка</th>
|
||||
<th>✨ Проактивный Агент (Agent)</th>
|
||||
<th>🚀 Универсальные возможности Агента</th>
|
||||
<th>🧩 1000+ плагинов сообщества</th>
|
||||
<th>✨ Проактивный Агент(Agent)</th>
|
||||
<th>🚀 Универсальные Агентные возможности</th>
|
||||
<th>🧩 Универсальные Агентные (Agentic) возможности</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
|
||||
@@ -83,16 +83,6 @@ uv tool install astrbot
|
||||
astrbot
|
||||
```
|
||||
|
||||
#### Десктопное приложение (Tauri)
|
||||
|
||||
Репозиторий десктопного приложения: [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
|
||||
|
||||
Поддерживает различные системные архитектуры, устанавливается напрямую, "из коробки", лучшее настольное решение в один клик для новичков и тех, кто ценит простоту. Не рекомендуется для серверных сценариев.
|
||||
|
||||
#### Установка в один клик через лаунчер (AstrBot Launcher)
|
||||
|
||||
Быстрое развёртывание и поддержка нескольких экземпляров, изоляция среды. Перейдите в репозиторий [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher), найдите последнюю версию на странице Releases и установите соответствующий пакет для вашей системы.
|
||||
|
||||
#### Развёртывание BT-Panel
|
||||
|
||||
AstrBot в партнёрстве с BT-Panel теперь доступен на их маркетплейсе.
|
||||
@@ -168,9 +158,8 @@ paru -S astrbot-git
|
||||
- Discord
|
||||
- Satori
|
||||
- Misskey
|
||||
- LINE
|
||||
- WhatsApp (Скоро)
|
||||
|
||||
- LINE (Скоро)
|
||||
|
||||
**Поддерживаемые сообществом**
|
||||
|
||||
@@ -263,7 +252,7 @@ pre-commit install
|
||||
Особая благодарность всем контрибьюторам и разработчикам плагинов за их вклад в AstrBot ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
||||
</a>
|
||||
|
||||
Кроме того, рождение этого проекта было бы невозможно без помощи следующих проектов с открытым исходным кодом:
|
||||
|
||||
+5
-16
@@ -37,7 +37,7 @@
|
||||
|
||||
AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主流即時通訊軟體,為個人、開發者和團隊打造可靠、可擴展的對話式智慧基礎設施。無論是個人 AI 夥伴、智慧客服、自動化助手,還是企業知識庫,AstrBot 都能在您的即時通訊軟體平台的工作流程中快速構建生產可用的 AI 應用程式。
|
||||
|
||||

|
||||
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" />
|
||||
|
||||
## 主要功能
|
||||
|
||||
@@ -45,7 +45,7 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主
|
||||
2. ✨ AI 大模型對話,多模態,Agent,MCP,Skills,知識庫,人格設定,自動壓縮對話。
|
||||
3. 🤖 支援接入 Dify、阿里雲百煉、Coze 等智慧體 (Agent) 平台。
|
||||
4. 🌐 多平台,支援 QQ、企業微信、飛書、釘釘、微信公眾號、Telegram、Slack 以及[更多](#支援的訊息平台)。
|
||||
5. 📦 插件擴展,已有 1000+ 個插件可一鍵安裝。
|
||||
5. 📦 插件擴展,已有近 800 個插件可一鍵安裝。
|
||||
6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) 隔離化環境,安全地執行任何代碼、調用 Shell、會話級資源複用。
|
||||
7. 💻 WebUI 支援。
|
||||
8. 🌈 Web ChatUI 支援,ChatUI 內置代理沙盒 (Agent Sandbox)、網頁搜尋等。
|
||||
@@ -58,7 +58,7 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主
|
||||
<th>💙 角色扮演 & 情感陪伴</th>
|
||||
<th>✨ 主動式 Agent</th>
|
||||
<th>🚀 通用 Agentic 能力</th>
|
||||
<th>🧩 1000+ 社區外掛程式</th>
|
||||
<th>🧩 900+ 社區外掛程式</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
|
||||
@@ -83,16 +83,6 @@ uv tool install astrbot
|
||||
astrbot
|
||||
```
|
||||
|
||||
#### 桌面應用部署(Tauri)
|
||||
|
||||
桌面應用倉庫 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
|
||||
|
||||
支援多系統架構,安裝包直接安裝,開箱即用,最適合新手和懶人的一鍵桌面部署方案,不推薦伺服器場景。
|
||||
|
||||
#### 啟動器一鍵部署(AstrBot Launcher)
|
||||
|
||||
快速部署和多開方案,實現環境隔離,進入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 倉庫,在 Releases 頁最新版本下找到對應的系統安裝包安裝即可。
|
||||
|
||||
#### 寶塔面板部署
|
||||
|
||||
AstrBot 與寶塔面板合作,已上架至寶塔面板。
|
||||
@@ -168,9 +158,8 @@ paru -S astrbot-git
|
||||
- Discord
|
||||
- Satori
|
||||
- Misskey
|
||||
- LINE
|
||||
- Whatsapp(即將支援)
|
||||
|
||||
- LINE(即將支援)
|
||||
|
||||
**社群維護**
|
||||
|
||||
@@ -263,7 +252,7 @@ pre-commit install
|
||||
特別感謝所有 Contributors 和外掛開發者對 AstrBot 的貢獻 ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
||||
</a>
|
||||
|
||||
此外,本專案的誕生離不開以下開源專案的幫助:
|
||||
|
||||
@@ -24,9 +24,6 @@ from astrbot.core.star.register import (
|
||||
register_on_llm_tool_respond as on_llm_tool_respond,
|
||||
)
|
||||
from astrbot.core.star.register import register_on_platform_loaded as on_platform_loaded
|
||||
from astrbot.core.star.register import register_on_plugin_error as on_plugin_error
|
||||
from astrbot.core.star.register import register_on_plugin_loaded as on_plugin_loaded
|
||||
from astrbot.core.star.register import register_on_plugin_unloaded as on_plugin_unloaded
|
||||
from astrbot.core.star.register import register_on_using_llm_tool as on_using_llm_tool
|
||||
from astrbot.core.star.register import (
|
||||
register_on_waiting_llm_request as on_waiting_llm_request,
|
||||
@@ -55,9 +52,6 @@ __all__ = [
|
||||
"on_decorating_result",
|
||||
"on_llm_request",
|
||||
"on_llm_response",
|
||||
"on_plugin_error",
|
||||
"on_plugin_loaded",
|
||||
"on_plugin_unloaded",
|
||||
"on_platform_loaded",
|
||||
"on_waiting_llm_request",
|
||||
"permission_type",
|
||||
|
||||
@@ -4,7 +4,6 @@ from astrbot.api import sp, star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
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
|
||||
|
||||
@@ -63,7 +62,6 @@ 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 sp.remove_async(
|
||||
scope="umo",
|
||||
scope_id=umo,
|
||||
@@ -88,8 +86,6 @@ class ConversationCommands:
|
||||
)
|
||||
return
|
||||
|
||||
active_event_registry.stop_all(umo, exclude=message)
|
||||
|
||||
await self.context.conversation_manager.update_conversation(
|
||||
umo,
|
||||
cid,
|
||||
@@ -102,30 +98,6 @@ class ConversationCommands:
|
||||
|
||||
message.set_result(MessageEventResult().message(ret))
|
||||
|
||||
async def stop(self, message: AstrMessageEvent) -> None:
|
||||
"""停止当前会话正在运行的 Agent"""
|
||||
cfg = self.context.get_config(umo=message.unified_msg_origin)
|
||||
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
||||
umo = message.unified_msg_origin
|
||||
|
||||
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
|
||||
stopped_count = active_event_registry.stop_all(umo, exclude=message)
|
||||
else:
|
||||
stopped_count = active_event_registry.request_agent_stop_all(
|
||||
umo,
|
||||
exclude=message,
|
||||
)
|
||||
|
||||
if stopped_count > 0:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
f"已请求停止 {stopped_count} 个运行中的任务。"
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
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):
|
||||
@@ -249,7 +221,6 @@ class ConversationCommands:
|
||||
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 sp.remove_async(
|
||||
scope="umo",
|
||||
scope_id=message.unified_msg_origin,
|
||||
@@ -258,7 +229,6 @@ class ConversationCommands:
|
||||
message.set_result(MessageEventResult().message("已创建新对话。"))
|
||||
return
|
||||
|
||||
active_event_registry.stop_all(message.unified_msg_origin, exclude=message)
|
||||
cpersona = await self._get_current_persona_id(message.unified_msg_origin)
|
||||
cid = await self.context.conversation_manager.new_conversation(
|
||||
message.unified_msg_origin,
|
||||
@@ -351,8 +321,7 @@ class ConversationCommands:
|
||||
|
||||
async def del_conv(self, message: AstrMessageEvent) -> None:
|
||||
"""删除当前对话"""
|
||||
umo = message.unified_msg_origin
|
||||
cfg = self.context.get_config(umo=umo)
|
||||
cfg = self.context.get_config(umo=message.unified_msg_origin)
|
||||
is_unique_session = cfg["platform_settings"]["unique_session"]
|
||||
if message.get_group_id() and not is_unique_session and message.role != "admin":
|
||||
# 群聊,没开独立会话,发送人不是管理员
|
||||
@@ -365,17 +334,18 @@ 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 sp.remove_async(
|
||||
scope="umo",
|
||||
scope_id=umo,
|
||||
scope_id=message.unified_msg_origin,
|
||||
key=THIRD_PARTY_AGENT_RUNNER_KEY[agent_runner_type],
|
||||
)
|
||||
message.set_result(MessageEventResult().message("重置对话成功。"))
|
||||
return
|
||||
|
||||
session_curr_cid = (
|
||||
await self.context.conversation_manager.get_curr_conversation_id(umo)
|
||||
await self.context.conversation_manager.get_curr_conversation_id(
|
||||
message.unified_msg_origin,
|
||||
)
|
||||
)
|
||||
|
||||
if not session_curr_cid:
|
||||
@@ -386,10 +356,8 @@ class ConversationCommands:
|
||||
)
|
||||
return
|
||||
|
||||
active_event_registry.stop_all(umo, exclude=message)
|
||||
|
||||
await self.context.conversation_manager.delete_conversation(
|
||||
umo,
|
||||
message.unified_msg_origin,
|
||||
session_curr_cid,
|
||||
)
|
||||
|
||||
|
||||
@@ -132,11 +132,6 @@ class Main(star.Star):
|
||||
"""重置 LLM 会话"""
|
||||
await self.conversation_c.reset(message)
|
||||
|
||||
@filter.command("stop")
|
||||
async def stop(self, message: AstrMessageEvent) -> None:
|
||||
"""停止当前会话中正在运行的 Agent"""
|
||||
await self.conversation_c.stop(message)
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@filter.command("model")
|
||||
async def model_ls(
|
||||
|
||||
@@ -70,7 +70,7 @@ class Main(star.Star):
|
||||
header = HEADERS
|
||||
header.update({"User-Agent": random.choice(USER_AGENTS)})
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.get(url, headers=header) as response:
|
||||
async with session.get(url, headers=header, timeout=6) as response:
|
||||
html = await response.text(encoding="utf-8")
|
||||
doc = Document(html)
|
||||
ret = doc.summary(html_partial=True)
|
||||
@@ -151,6 +151,7 @@ class Main(star.Star):
|
||||
url,
|
||||
json=payload,
|
||||
headers=header,
|
||||
timeout=6,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
reason = await response.text()
|
||||
@@ -182,6 +183,7 @@ class Main(star.Star):
|
||||
url,
|
||||
json=payload,
|
||||
headers=header,
|
||||
timeout=6,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
reason = await response.text()
|
||||
@@ -263,7 +265,7 @@ class Main(star.Star):
|
||||
"transport": "sse",
|
||||
"url": f"http://appbuilder.baidu.com/v2/ai_search/mcp/sse?api_key={key}",
|
||||
"headers": {},
|
||||
"timeout": 600,
|
||||
"timeout": 30,
|
||||
},
|
||||
)
|
||||
self.baidu_initialized = True
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "4.18.2"
|
||||
__version__ = "4.17.2"
|
||||
|
||||
@@ -44,19 +44,6 @@ class HandoffTool(FunctionTool, Generic[TContext]):
|
||||
"type": "string",
|
||||
"description": "The input to be handed off to another agent. This should be a clear and concise request or task.",
|
||||
},
|
||||
"image_urls": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional: An array of image sources (public HTTP URLs or local file paths) used as references in multimodal tasks such as video generation.",
|
||||
},
|
||||
"background_task": {
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
"Defaults to false. "
|
||||
"Set to true if the task may take noticeable time, involves external tools, or the user does not need to wait. "
|
||||
"Use false only for quick, immediate tasks."
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -137,8 +137,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
self.tool_executor = tool_executor
|
||||
self.agent_hooks = agent_hooks
|
||||
self.run_context = run_context
|
||||
self._stop_requested = False
|
||||
self._aborted = False
|
||||
|
||||
# These two are used for tool schema mode handling
|
||||
# We now have two modes:
|
||||
@@ -330,14 +328,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
),
|
||||
),
|
||||
)
|
||||
if self._stop_requested:
|
||||
llm_resp_result = LLMResponse(
|
||||
role="assistant",
|
||||
completion_text="[SYSTEM: User actively interrupted the response generation. Partial output before interruption is preserved.]",
|
||||
reasoning_content=llm_response.reasoning_content,
|
||||
reasoning_signature=llm_response.reasoning_signature,
|
||||
)
|
||||
break
|
||||
continue
|
||||
llm_resp_result = llm_response
|
||||
|
||||
@@ -349,48 +339,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
break # got final response
|
||||
|
||||
if not llm_resp_result:
|
||||
if self._stop_requested:
|
||||
llm_resp_result = LLMResponse(role="assistant", completion_text="")
|
||||
else:
|
||||
return
|
||||
|
||||
if self._stop_requested:
|
||||
logger.info("Agent execution was requested to stop by user.")
|
||||
llm_resp = llm_resp_result
|
||||
if llm_resp.role != "assistant":
|
||||
llm_resp = LLMResponse(
|
||||
role="assistant",
|
||||
completion_text="[SYSTEM: User actively interrupted the response generation. Partial output before interruption is preserved.]",
|
||||
)
|
||||
self.final_llm_resp = llm_resp
|
||||
self._aborted = True
|
||||
self._transition_state(AgentState.DONE)
|
||||
self.stats.end_time = time.time()
|
||||
|
||||
parts = []
|
||||
if llm_resp.reasoning_content or llm_resp.reasoning_signature:
|
||||
parts.append(
|
||||
ThinkPart(
|
||||
think=llm_resp.reasoning_content,
|
||||
encrypted=llm_resp.reasoning_signature,
|
||||
)
|
||||
)
|
||||
if llm_resp.completion_text:
|
||||
parts.append(TextPart(text=llm_resp.completion_text))
|
||||
if parts:
|
||||
self.run_context.messages.append(
|
||||
Message(role="assistant", content=parts)
|
||||
)
|
||||
|
||||
try:
|
||||
await self.agent_hooks.on_agent_done(self.run_context, llm_resp)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in on_agent_done hook: {e}", exc_info=True)
|
||||
|
||||
yield AgentResponse(
|
||||
type="aborted",
|
||||
data=AgentResponseData(chain=MessageChain(type="aborted")),
|
||||
)
|
||||
return
|
||||
|
||||
# 处理 LLM 响应
|
||||
@@ -409,7 +357,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
),
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
if not llm_resp.tools_call_name:
|
||||
# 如果没有工具调用,转换到完成状态
|
||||
@@ -900,11 +847,5 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
"""检查 Agent 是否已完成工作"""
|
||||
return self._state in (AgentState.DONE, AgentState.ERROR)
|
||||
|
||||
def request_stop(self) -> None:
|
||||
self._stop_requested = True
|
||||
|
||||
def was_aborted(self) -> bool:
|
||||
return self._aborted
|
||||
|
||||
def get_final_llm_resp(self) -> LLMResponse | None:
|
||||
return self.final_llm_resp
|
||||
|
||||
@@ -285,9 +285,6 @@ class ToolSet:
|
||||
prop_value = convert_schema(value)
|
||||
if "default" in prop_value:
|
||||
del prop_value["default"]
|
||||
# see #5217
|
||||
if "additionalProperties" in prop_value:
|
||||
del prop_value["additionalProperties"]
|
||||
properties[key] = prop_value
|
||||
|
||||
if properties:
|
||||
|
||||
@@ -20,81 +20,15 @@ from astrbot.core.provider.provider import TTSProvider
|
||||
AgentRunner = ToolLoopAgentRunner[AstrAgentContext]
|
||||
|
||||
|
||||
def _should_stop_agent(astr_event) -> bool:
|
||||
return astr_event.is_stopped() or bool(astr_event.get_extra("agent_stop_requested"))
|
||||
|
||||
|
||||
def _truncate_tool_result(text: str, limit: int = 70) -> str:
|
||||
if limit <= 0:
|
||||
return ""
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
if limit <= 3:
|
||||
return text[:limit]
|
||||
return f"{text[: limit - 3]}..."
|
||||
|
||||
|
||||
def _extract_chain_json_data(msg_chain: MessageChain) -> dict | None:
|
||||
if not msg_chain.chain:
|
||||
return None
|
||||
first_comp = msg_chain.chain[0]
|
||||
if isinstance(first_comp, Json) and isinstance(first_comp.data, dict):
|
||||
return first_comp.data
|
||||
return None
|
||||
|
||||
|
||||
def _record_tool_call_name(
|
||||
tool_info: dict | None, tool_name_by_call_id: dict[str, str]
|
||||
) -> None:
|
||||
if not isinstance(tool_info, dict):
|
||||
return
|
||||
tool_call_id = tool_info.get("id")
|
||||
tool_name = tool_info.get("name")
|
||||
if tool_call_id is None or tool_name is None:
|
||||
return
|
||||
tool_name_by_call_id[str(tool_call_id)] = str(tool_name)
|
||||
|
||||
|
||||
def _build_tool_call_status_message(tool_info: dict | None) -> str:
|
||||
if tool_info:
|
||||
return f"🔨 调用工具: {tool_info.get('name', 'unknown')}"
|
||||
return "🔨 调用工具..."
|
||||
|
||||
|
||||
def _build_tool_result_status_message(
|
||||
msg_chain: MessageChain, tool_name_by_call_id: dict[str, str]
|
||||
) -> str:
|
||||
tool_name = "unknown"
|
||||
tool_result = ""
|
||||
|
||||
result_data = _extract_chain_json_data(msg_chain)
|
||||
if result_data:
|
||||
tool_call_id = result_data.get("id")
|
||||
if tool_call_id is not None:
|
||||
tool_name = tool_name_by_call_id.pop(str(tool_call_id), "unknown")
|
||||
tool_result = str(result_data.get("result", ""))
|
||||
|
||||
if not tool_result:
|
||||
tool_result = msg_chain.get_plain_text(with_other_comps_mark=True)
|
||||
tool_result = _truncate_tool_result(tool_result, 70)
|
||||
|
||||
status_msg = f"🔨 调用工具: {tool_name}"
|
||||
if tool_result:
|
||||
status_msg = f"{status_msg}\n📎 返回结果: {tool_result}"
|
||||
return status_msg
|
||||
|
||||
|
||||
async def run_agent(
|
||||
agent_runner: AgentRunner,
|
||||
max_step: int = 30,
|
||||
show_tool_use: bool = True,
|
||||
show_tool_call_result: bool = False,
|
||||
stream_to_general: bool = False,
|
||||
show_reasoning: bool = False,
|
||||
) -> AsyncGenerator[MessageChain | None, None]:
|
||||
step_idx = 0
|
||||
astr_event = agent_runner.run_context.context.event
|
||||
tool_name_by_call_id: dict[str, str] = {}
|
||||
while step_idx < max_step + 1:
|
||||
step_idx += 1
|
||||
|
||||
@@ -114,28 +48,10 @@ async def run_agent(
|
||||
)
|
||||
)
|
||||
|
||||
stop_watcher = asyncio.create_task(
|
||||
_watch_agent_stop_signal(agent_runner, astr_event),
|
||||
)
|
||||
try:
|
||||
async for resp in agent_runner.step():
|
||||
if _should_stop_agent(astr_event):
|
||||
agent_runner.request_stop()
|
||||
|
||||
if resp.type == "aborted":
|
||||
if not stop_watcher.done():
|
||||
stop_watcher.cancel()
|
||||
try:
|
||||
await stop_watcher
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
astr_event.set_extra("agent_user_aborted", True)
|
||||
astr_event.set_extra("agent_stop_requested", False)
|
||||
if astr_event.is_stopped():
|
||||
return
|
||||
|
||||
if _should_stop_agent(astr_event):
|
||||
continue
|
||||
|
||||
if resp.type == "tool_call_result":
|
||||
msg_chain = resp.data["chain"]
|
||||
|
||||
@@ -152,13 +68,6 @@ async def run_agent(
|
||||
continue
|
||||
if astr_event.get_platform_id() == "webchat":
|
||||
await astr_event.send(msg_chain)
|
||||
elif show_tool_use and show_tool_call_result:
|
||||
status_msg = _build_tool_result_status_message(
|
||||
msg_chain, tool_name_by_call_id
|
||||
)
|
||||
await astr_event.send(
|
||||
MessageChain(type="tool_call").message(status_msg)
|
||||
)
|
||||
# 对于其他情况,暂时先不处理
|
||||
continue
|
||||
elif resp.type == "tool_call":
|
||||
@@ -166,22 +75,25 @@ async def run_agent(
|
||||
# 用来标记流式响应需要分节
|
||||
yield MessageChain(chain=[], type="break")
|
||||
|
||||
tool_info = _extract_chain_json_data(resp.data["chain"])
|
||||
astr_event.trace.record(
|
||||
"agent_tool_call",
|
||||
tool_name=tool_info if tool_info else "unknown",
|
||||
)
|
||||
_record_tool_call_name(tool_info, tool_name_by_call_id)
|
||||
tool_info = None
|
||||
|
||||
if resp.data["chain"].chain:
|
||||
json_comp = resp.data["chain"].chain[0]
|
||||
if isinstance(json_comp, Json):
|
||||
tool_info = json_comp.data
|
||||
astr_event.trace.record(
|
||||
"agent_tool_call",
|
||||
tool_name=tool_info if tool_info else "unknown",
|
||||
)
|
||||
|
||||
if astr_event.get_platform_name() == "webchat":
|
||||
await astr_event.send(resp.data["chain"])
|
||||
elif show_tool_use:
|
||||
if show_tool_call_result and isinstance(tool_info, dict):
|
||||
# Delay tool status notification until tool_call_result.
|
||||
continue
|
||||
chain = MessageChain(type="tool_call").message(
|
||||
_build_tool_call_status_message(tool_info)
|
||||
)
|
||||
if tool_info:
|
||||
m = f"🔨 调用工具: {tool_info.get('name', 'unknown')}"
|
||||
else:
|
||||
m = "🔨 调用工具..."
|
||||
chain = MessageChain(type="tool_call").message(m)
|
||||
await astr_event.send(chain)
|
||||
continue
|
||||
|
||||
@@ -208,12 +120,6 @@ async def run_agent(
|
||||
# display the reasoning content only when configured
|
||||
continue
|
||||
yield resp.data["chain"] # MessageChain
|
||||
if not stop_watcher.done():
|
||||
stop_watcher.cancel()
|
||||
try:
|
||||
await stop_watcher
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if agent_runner.done():
|
||||
# send agent stats to webchat
|
||||
if astr_event.get_platform_name() == "webchat":
|
||||
@@ -227,12 +133,6 @@ async def run_agent(
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
if "stop_watcher" in locals() and not stop_watcher.done():
|
||||
stop_watcher.cancel()
|
||||
try:
|
||||
await stop_watcher
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
err_msg = f"\n\nAstrBot 请求失败。\n错误类型: {type(e).__name__}\n错误信息: {e!s}\n\n请在平台日志查看和分享错误详情。\n"
|
||||
@@ -255,20 +155,11 @@ async def run_agent(
|
||||
return
|
||||
|
||||
|
||||
async def _watch_agent_stop_signal(agent_runner: AgentRunner, astr_event) -> None:
|
||||
while not agent_runner.done():
|
||||
if _should_stop_agent(astr_event):
|
||||
agent_runner.request_stop()
|
||||
return
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
|
||||
async def run_live_agent(
|
||||
agent_runner: AgentRunner,
|
||||
tts_provider: TTSProvider | None = None,
|
||||
max_step: int = 30,
|
||||
show_tool_use: bool = True,
|
||||
show_tool_call_result: bool = False,
|
||||
show_reasoning: bool = False,
|
||||
) -> AsyncGenerator[MessageChain | None, None]:
|
||||
"""Live Mode 的 Agent 运行器,支持流式 TTS
|
||||
@@ -278,7 +169,6 @@ async def run_live_agent(
|
||||
tts_provider: TTS Provider 实例
|
||||
max_step: 最大步数
|
||||
show_tool_use: 是否显示工具使用
|
||||
show_tool_call_result: 是否显示工具返回结果
|
||||
show_reasoning: 是否显示推理过程
|
||||
|
||||
Yields:
|
||||
@@ -290,7 +180,6 @@ async def run_live_agent(
|
||||
agent_runner,
|
||||
max_step=max_step,
|
||||
show_tool_use=show_tool_use,
|
||||
show_tool_call_result=show_tool_call_result,
|
||||
stream_to_general=False,
|
||||
show_reasoning=show_reasoning,
|
||||
):
|
||||
@@ -319,12 +208,7 @@ async def run_live_agent(
|
||||
# 1. 启动 Agent Feeder 任务:负责运行 Agent 并将文本分句喂给 text_queue
|
||||
feeder_task = asyncio.create_task(
|
||||
_run_agent_feeder(
|
||||
agent_runner,
|
||||
text_queue,
|
||||
max_step,
|
||||
show_tool_use,
|
||||
show_tool_call_result,
|
||||
show_reasoning,
|
||||
agent_runner, text_queue, max_step, show_tool_use, show_reasoning
|
||||
)
|
||||
)
|
||||
|
||||
@@ -410,7 +294,6 @@ async def _run_agent_feeder(
|
||||
text_queue: asyncio.Queue,
|
||||
max_step: int,
|
||||
show_tool_use: bool,
|
||||
show_tool_call_result: bool,
|
||||
show_reasoning: bool,
|
||||
) -> None:
|
||||
"""运行 Agent 并将文本输出分句放入队列"""
|
||||
@@ -420,7 +303,6 @@ async def _run_agent_feeder(
|
||||
agent_runner,
|
||||
max_step=max_step,
|
||||
show_tool_use=show_tool_use,
|
||||
show_tool_call_result=show_tool_call_result,
|
||||
stream_to_general=False,
|
||||
show_reasoning=show_reasoning,
|
||||
):
|
||||
|
||||
@@ -17,12 +17,6 @@ from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
from astrbot.core.astr_main_agent_resources import (
|
||||
BACKGROUND_TASK_RESULT_WOKE_SYSTEM_PROMPT,
|
||||
EXECUTE_SHELL_TOOL,
|
||||
FILE_DOWNLOAD_TOOL,
|
||||
FILE_UPLOAD_TOOL,
|
||||
LOCAL_EXECUTE_SHELL_TOOL,
|
||||
LOCAL_PYTHON_TOOL,
|
||||
PYTHON_TOOL,
|
||||
SEND_MESSAGE_TO_USER_TOOL,
|
||||
)
|
||||
from astrbot.core.cron.events import CronMessageEvent
|
||||
@@ -51,13 +45,6 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
|
||||
"""
|
||||
if isinstance(tool, HandoffTool):
|
||||
is_bg = tool_args.pop("background_task", False)
|
||||
if is_bg:
|
||||
async for r in cls._execute_handoff_background(
|
||||
tool, run_context, **tool_args
|
||||
):
|
||||
yield r
|
||||
return
|
||||
async for r in cls._execute_handoff(tool, run_context, **tool_args):
|
||||
yield r
|
||||
return
|
||||
@@ -97,65 +84,6 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
yield r
|
||||
return
|
||||
|
||||
@classmethod
|
||||
def _get_runtime_computer_tools(cls, runtime: str) -> dict[str, FunctionTool]:
|
||||
if runtime == "sandbox":
|
||||
return {
|
||||
EXECUTE_SHELL_TOOL.name: EXECUTE_SHELL_TOOL,
|
||||
PYTHON_TOOL.name: PYTHON_TOOL,
|
||||
FILE_UPLOAD_TOOL.name: FILE_UPLOAD_TOOL,
|
||||
FILE_DOWNLOAD_TOOL.name: FILE_DOWNLOAD_TOOL,
|
||||
}
|
||||
if runtime == "local":
|
||||
return {
|
||||
LOCAL_EXECUTE_SHELL_TOOL.name: LOCAL_EXECUTE_SHELL_TOOL,
|
||||
LOCAL_PYTHON_TOOL.name: LOCAL_PYTHON_TOOL,
|
||||
}
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def _build_handoff_toolset(
|
||||
cls,
|
||||
run_context: ContextWrapper[AstrAgentContext],
|
||||
tools: list[str | FunctionTool] | None,
|
||||
) -> ToolSet | None:
|
||||
ctx = run_context.context.context
|
||||
event = run_context.context.event
|
||||
cfg = ctx.get_config(umo=event.unified_msg_origin)
|
||||
provider_settings = cfg.get("provider_settings", {})
|
||||
runtime = str(provider_settings.get("computer_use_runtime", "local"))
|
||||
runtime_computer_tools = cls._get_runtime_computer_tools(runtime)
|
||||
|
||||
# Keep persona semantics aligned with the main agent: tools=None means
|
||||
# "all tools", including runtime computer-use tools.
|
||||
if tools is None:
|
||||
toolset = ToolSet()
|
||||
for registered_tool in llm_tools.func_list:
|
||||
if isinstance(registered_tool, HandoffTool):
|
||||
continue
|
||||
if registered_tool.active:
|
||||
toolset.add_tool(registered_tool)
|
||||
for runtime_tool in runtime_computer_tools.values():
|
||||
toolset.add_tool(runtime_tool)
|
||||
return None if toolset.empty() else toolset
|
||||
|
||||
if not tools:
|
||||
return None
|
||||
|
||||
toolset = ToolSet()
|
||||
for tool_name_or_obj in tools:
|
||||
if isinstance(tool_name_or_obj, str):
|
||||
registered_tool = llm_tools.get_func(tool_name_or_obj)
|
||||
if registered_tool and registered_tool.active:
|
||||
toolset.add_tool(registered_tool)
|
||||
continue
|
||||
runtime_tool = runtime_computer_tools.get(tool_name_or_obj)
|
||||
if runtime_tool:
|
||||
toolset.add_tool(runtime_tool)
|
||||
elif isinstance(tool_name_or_obj, FunctionTool):
|
||||
toolset.add_tool(tool_name_or_obj)
|
||||
return None if toolset.empty() else toolset
|
||||
|
||||
@classmethod
|
||||
async def _execute_handoff(
|
||||
cls,
|
||||
@@ -164,10 +92,20 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
**tool_args,
|
||||
):
|
||||
input_ = tool_args.get("input")
|
||||
image_urls = tool_args.get("image_urls")
|
||||
|
||||
# Build handoff toolset from registered tools plus runtime computer tools.
|
||||
toolset = cls._build_handoff_toolset(run_context, tool.agent.tools)
|
||||
# make toolset for the agent
|
||||
tools = tool.agent.tools
|
||||
if tools:
|
||||
toolset = ToolSet()
|
||||
for t in tools:
|
||||
if isinstance(t, str):
|
||||
_t = llm_tools.get_func(t)
|
||||
if _t:
|
||||
toolset.add_tool(_t)
|
||||
elif isinstance(t, FunctionTool):
|
||||
toolset.add_tool(t)
|
||||
else:
|
||||
toolset = None
|
||||
|
||||
ctx = run_context.context.context
|
||||
event = run_context.context.event
|
||||
@@ -198,98 +136,16 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
event=event,
|
||||
chat_provider_id=prov_id,
|
||||
prompt=input_,
|
||||
image_urls=image_urls,
|
||||
system_prompt=tool.agent.instructions,
|
||||
tools=toolset,
|
||||
contexts=contexts,
|
||||
max_steps=30,
|
||||
run_hooks=tool.agent.run_hooks,
|
||||
stream=ctx.get_config().get("provider_settings", {}).get("stream", False),
|
||||
)
|
||||
yield mcp.types.CallToolResult(
|
||||
content=[mcp.types.TextContent(type="text", text=llm_resp.completion_text)]
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _execute_handoff_background(
|
||||
cls,
|
||||
tool: HandoffTool,
|
||||
run_context: ContextWrapper[AstrAgentContext],
|
||||
**tool_args,
|
||||
):
|
||||
"""Execute a handoff as a background task.
|
||||
|
||||
Immediately yields a success response with a task_id, then runs
|
||||
the subagent asynchronously. When the subagent finishes, a
|
||||
``CronMessageEvent`` is created so the main LLM can inform the
|
||||
user of the result – the same pattern used by
|
||||
``_execute_background`` for regular background tasks.
|
||||
"""
|
||||
task_id = uuid.uuid4().hex
|
||||
|
||||
async def _run_handoff_in_background() -> None:
|
||||
try:
|
||||
await cls._do_handoff_background(
|
||||
tool=tool,
|
||||
run_context=run_context,
|
||||
task_id=task_id,
|
||||
**tool_args,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error(
|
||||
f"Background handoff {task_id} ({tool.name}) failed: {e!s}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
asyncio.create_task(_run_handoff_in_background())
|
||||
|
||||
text_content = mcp.types.TextContent(
|
||||
type="text",
|
||||
text=(
|
||||
f"Background task dedicated to subagent '{tool.agent.name}' submitted. task_id={task_id}. "
|
||||
f"The subagent '{tool.agent.name}' is working on the task on hehalf you. "
|
||||
f"You will be notified when it finishes."
|
||||
),
|
||||
)
|
||||
yield mcp.types.CallToolResult(content=[text_content])
|
||||
|
||||
@classmethod
|
||||
async def _do_handoff_background(
|
||||
cls,
|
||||
tool: HandoffTool,
|
||||
run_context: ContextWrapper[AstrAgentContext],
|
||||
task_id: str,
|
||||
**tool_args,
|
||||
) -> None:
|
||||
"""Run the subagent handoff and, on completion, wake the main agent."""
|
||||
result_text = ""
|
||||
try:
|
||||
async for r in cls._execute_handoff(tool, run_context, **tool_args):
|
||||
if isinstance(r, mcp.types.CallToolResult):
|
||||
for content in r.content:
|
||||
if isinstance(content, mcp.types.TextContent):
|
||||
result_text += content.text + "\n"
|
||||
except Exception as e:
|
||||
result_text = (
|
||||
f"error: Background task execution failed, internal error: {e!s}"
|
||||
)
|
||||
|
||||
event = run_context.context.event
|
||||
|
||||
await cls._wake_main_agent_for_background_result(
|
||||
run_context=run_context,
|
||||
task_id=task_id,
|
||||
tool_name=tool.name,
|
||||
result_text=result_text,
|
||||
tool_args=tool_args,
|
||||
note=(
|
||||
event.get_extra("background_note")
|
||||
or f"Background task for subagent '{tool.agent.name}' finished."
|
||||
),
|
||||
summary_name=f"Dedicated to subagent `{tool.agent.name}`",
|
||||
extra_result_fields={"subagent_name": tool.agent.name},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _execute_background(
|
||||
cls,
|
||||
@@ -298,6 +154,12 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
task_id: str,
|
||||
**tool_args,
|
||||
) -> None:
|
||||
from astrbot.core.astr_main_agent import (
|
||||
MainAgentBuildConfig,
|
||||
_get_session_conv,
|
||||
build_main_agent,
|
||||
)
|
||||
|
||||
# run the tool
|
||||
result_text = ""
|
||||
try:
|
||||
@@ -315,53 +177,21 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
f"error: Background task execution failed, internal error: {e!s}"
|
||||
)
|
||||
|
||||
event = run_context.context.event
|
||||
|
||||
await cls._wake_main_agent_for_background_result(
|
||||
run_context=run_context,
|
||||
task_id=task_id,
|
||||
tool_name=tool.name,
|
||||
result_text=result_text,
|
||||
tool_args=tool_args,
|
||||
note=(
|
||||
event.get_extra("background_note")
|
||||
or f"Background task {tool.name} finished."
|
||||
),
|
||||
summary_name=tool.name,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _wake_main_agent_for_background_result(
|
||||
cls,
|
||||
run_context: ContextWrapper[AstrAgentContext],
|
||||
*,
|
||||
task_id: str,
|
||||
tool_name: str,
|
||||
result_text: str,
|
||||
tool_args: dict[str, T.Any],
|
||||
note: str,
|
||||
summary_name: str,
|
||||
extra_result_fields: dict[str, T.Any] | None = None,
|
||||
) -> None:
|
||||
from astrbot.core.astr_main_agent import (
|
||||
MainAgentBuildConfig,
|
||||
_get_session_conv,
|
||||
build_main_agent,
|
||||
)
|
||||
|
||||
event = run_context.context.event
|
||||
ctx = run_context.context.context
|
||||
|
||||
task_result = {
|
||||
"task_id": task_id,
|
||||
"tool_name": tool_name,
|
||||
"result": result_text or "",
|
||||
"tool_args": tool_args,
|
||||
note = (
|
||||
event.get_extra("background_note")
|
||||
or f"Background task {tool.name} finished."
|
||||
)
|
||||
extras = {
|
||||
"background_task_result": {
|
||||
"task_id": task_id,
|
||||
"tool_name": tool.name,
|
||||
"result": result_text or "",
|
||||
"tool_args": tool_args,
|
||||
}
|
||||
}
|
||||
if extra_result_fields:
|
||||
task_result.update(extra_result_fields)
|
||||
extras = {"background_task_result": task_result}
|
||||
|
||||
session = MessageSession.from_str(event.unified_msg_origin)
|
||||
cron_event = CronMessageEvent(
|
||||
context=ctx,
|
||||
@@ -371,12 +201,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
message_type=session.message_type,
|
||||
)
|
||||
cron_event.role = event.role
|
||||
config = MainAgentBuildConfig(
|
||||
tool_call_timeout=3600,
|
||||
streaming_response=ctx.get_config()
|
||||
.get("provider_settings", {})
|
||||
.get("stream", False),
|
||||
)
|
||||
config = MainAgentBuildConfig(tool_call_timeout=3600)
|
||||
|
||||
req = ProviderRequest()
|
||||
conv = await _get_session_conv(event=cron_event, plugin_context=ctx)
|
||||
@@ -397,11 +222,8 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
)
|
||||
req.prompt = (
|
||||
"Proceed according to your system instructions. "
|
||||
"Output using same language as previous conversation. "
|
||||
"If you need to deliver the result to the user immediately, "
|
||||
"you MUST use `send_message_to_user` tool to send the message directly to the user, "
|
||||
"otherwise the user will not see the result. "
|
||||
"After completing your task, summarize and output your actions and results. "
|
||||
"Output using same language as previous conversation."
|
||||
" After completing your task, summarize and output your actions and results."
|
||||
)
|
||||
if not req.func_tool:
|
||||
req.func_tool = ToolSet()
|
||||
@@ -411,7 +233,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
event=cron_event, plugin_context=ctx, config=config, req=req
|
||||
)
|
||||
if not result:
|
||||
logger.error(f"Failed to build main agent for background task {tool_name}.")
|
||||
logger.error("Failed to build main agent for background task job.")
|
||||
return
|
||||
|
||||
runner = result.agent_runner
|
||||
@@ -421,7 +243,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
llm_resp = runner.get_final_llm_resp()
|
||||
task_meta = extras.get("background_task_result", {})
|
||||
summary_note = (
|
||||
f"[BackgroundTask] {summary_name} "
|
||||
f"[BackgroundTask] {task_meta.get('tool_name', tool.name)} "
|
||||
f"(task_id={task_meta.get('task_id', task_id)}) finished. "
|
||||
f"Result: {task_meta.get('result') or result_text or 'no content'}"
|
||||
)
|
||||
|
||||
@@ -42,6 +42,7 @@ from astrbot.core.message.components import File, Image, Reply
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from astrbot.core.provider import Provider
|
||||
from astrbot.core.provider.entities import ProviderRequest
|
||||
from astrbot.core.provider.manager import llm_tools
|
||||
from astrbot.core.skills.skill_manager import SkillManager, build_skills_prompt
|
||||
from astrbot.core.star.context import Context
|
||||
from astrbot.core.star.star_handler import star_map
|
||||
@@ -769,6 +770,14 @@ def _plugin_tool_fix(event: AstrMessageEvent, req: ProviderRequest) -> None:
|
||||
if plugin.name in event.plugins_name or plugin.reserved:
|
||||
new_tool_set.add_tool(tool)
|
||||
req.func_tool = new_tool_set
|
||||
else:
|
||||
# mcp tools
|
||||
tool_set = req.func_tool
|
||||
if not tool_set:
|
||||
tool_set = ToolSet()
|
||||
for tool in llm_tools.func_list:
|
||||
if isinstance(tool, MCPTool):
|
||||
tool_set.add_tool(tool)
|
||||
|
||||
|
||||
async def _handle_webchat(
|
||||
|
||||
@@ -11,7 +11,6 @@ from astrbot.core.message.components import File
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
|
||||
from ..computer_client import get_booter
|
||||
from .permissions import check_admin_permission
|
||||
|
||||
# @dataclass
|
||||
# class CreateFileTool(FunctionTool):
|
||||
@@ -103,8 +102,6 @@ class FileUploadTool(FunctionTool):
|
||||
context: ContextWrapper[AstrAgentContext],
|
||||
local_path: str,
|
||||
) -> str | None:
|
||||
if permission_error := check_admin_permission(context, "File upload/download"):
|
||||
return permission_error
|
||||
sb = await get_booter(
|
||||
context.context.context,
|
||||
context.context.event.unified_msg_origin,
|
||||
@@ -164,8 +161,6 @@ class FileDownloadTool(FunctionTool):
|
||||
remote_path: str,
|
||||
also_send_to_user: bool = True,
|
||||
) -> ToolExecResult:
|
||||
if permission_error := check_admin_permission(context, "File upload/download"):
|
||||
return permission_error
|
||||
sb = await get_booter(
|
||||
context.context.context,
|
||||
context.context.event.unified_msg_origin,
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
|
||||
|
||||
def check_admin_permission(
|
||||
context: ContextWrapper[AstrAgentContext], operation_name: str
|
||||
) -> str | None:
|
||||
cfg = context.context.context.get_config(
|
||||
umo=context.context.event.unified_msg_origin
|
||||
)
|
||||
provider_settings = cfg.get("provider_settings", {})
|
||||
require_admin = provider_settings.get("computer_use_require_admin", True)
|
||||
if require_admin and context.context.event.role != "admin":
|
||||
return (
|
||||
f"error: Permission denied. {operation_name} is only allowed for admin users. "
|
||||
"Tell user to set admins in `AstrBot WebUI -> Config -> General Config` by adding their user ID to the admins list if they need this feature. "
|
||||
f"User's ID is: {context.context.event.get_sender_id()}. User's ID can be found by using /sid command."
|
||||
)
|
||||
return None
|
||||
@@ -5,10 +5,8 @@ import mcp
|
||||
from astrbot.api import FunctionTool
|
||||
from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.agent.tool import ToolExecResult
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext, AstrMessageEvent
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
from astrbot.core.computer.computer_client import get_booter, get_local_booter
|
||||
from astrbot.core.computer.tools.permissions import check_admin_permission
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
|
||||
param_schema = {
|
||||
"type": "object",
|
||||
@@ -27,7 +25,7 @@ param_schema = {
|
||||
}
|
||||
|
||||
|
||||
async def handle_result(result: dict, event: AstrMessageEvent) -> ToolExecResult:
|
||||
def handle_result(result: dict) -> ToolExecResult:
|
||||
data = result.get("data", {})
|
||||
output = data.get("output", {})
|
||||
error = data.get("error", "")
|
||||
@@ -46,9 +44,6 @@ async def handle_result(result: dict, event: AstrMessageEvent) -> ToolExecResult
|
||||
type="image", data=img["image/png"], mimeType="image/png"
|
||||
)
|
||||
)
|
||||
|
||||
if event.get_platform_name() == "webchat":
|
||||
await event.send(message=MessageChain().base64_image(img["image/png"]))
|
||||
if text:
|
||||
resp.content.append(mcp.types.TextContent(type="text", text=text))
|
||||
|
||||
@@ -67,15 +62,13 @@ class PythonTool(FunctionTool):
|
||||
async def call(
|
||||
self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False
|
||||
) -> ToolExecResult:
|
||||
if permission_error := check_admin_permission(context, "Python execution"):
|
||||
return permission_error
|
||||
sb = await get_booter(
|
||||
context.context.context,
|
||||
context.context.event.unified_msg_origin,
|
||||
)
|
||||
try:
|
||||
result = await sb.python.exec(code, silent=silent)
|
||||
return await handle_result(result, context.context.event)
|
||||
return handle_result(result)
|
||||
except Exception as e:
|
||||
return f"Error executing code: {str(e)}"
|
||||
|
||||
@@ -90,11 +83,12 @@ class LocalPythonTool(FunctionTool):
|
||||
async def call(
|
||||
self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False
|
||||
) -> ToolExecResult:
|
||||
if permission_error := check_admin_permission(context, "Python execution"):
|
||||
return permission_error
|
||||
if context.context.event.role != "admin":
|
||||
return "error: Permission denied. Local Python execution is only allowed for admin users. Tell user to set admins in AstrBot WebUI."
|
||||
|
||||
sb = get_local_booter()
|
||||
try:
|
||||
result = await sb.python.exec(code, silent=silent)
|
||||
return await handle_result(result, context.context.event)
|
||||
return handle_result(result)
|
||||
except Exception as e:
|
||||
return f"Error executing code: {str(e)}"
|
||||
|
||||
@@ -7,7 +7,6 @@ from astrbot.core.agent.tool import ToolExecResult
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
|
||||
from ..computer_client import get_booter, get_local_booter
|
||||
from .permissions import check_admin_permission
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -47,8 +46,8 @@ class ExecuteShellTool(FunctionTool):
|
||||
background: bool = False,
|
||||
env: dict = {},
|
||||
) -> ToolExecResult:
|
||||
if permission_error := check_admin_permission(context, "Shell execution"):
|
||||
return permission_error
|
||||
if context.context.event.role != "admin":
|
||||
return "error: Permission denied. Shell execution is only allowed for admin users. Tell user to Set admins in AstrBot WebUI."
|
||||
|
||||
if self.is_local:
|
||||
sb = get_local_booter()
|
||||
|
||||
@@ -52,9 +52,6 @@ class AstrBotConfig(dict):
|
||||
|
||||
with open(config_path, encoding="utf-8-sig") as f:
|
||||
conf_str = f.read()
|
||||
# Handle UTF-8 BOM if present
|
||||
if conf_str.startswith("\ufeff"):
|
||||
conf_str = conf_str[1:]
|
||||
conf = json.loads(conf_str)
|
||||
|
||||
# 检查配置完整性,并插入
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
VERSION = "4.18.2"
|
||||
VERSION = "4.17.2"
|
||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
||||
|
||||
WEBHOOK_SUPPORTED_PLATFORMS = [
|
||||
@@ -100,7 +100,6 @@ DEFAULT_CONFIG = {
|
||||
"dequeue_context_length": 1,
|
||||
"streaming_response": False,
|
||||
"show_tool_use_status": False,
|
||||
"show_tool_call_result": False,
|
||||
"sanitize_context_by_modalities": False,
|
||||
"max_quoted_fallback_images": 20,
|
||||
"quoted_message_parser": {
|
||||
@@ -129,7 +128,6 @@ DEFAULT_CONFIG = {
|
||||
"add_cron_tools": True,
|
||||
},
|
||||
"computer_use_runtime": "local",
|
||||
"computer_use_require_admin": True,
|
||||
"sandbox": {
|
||||
"booter": "shipyard",
|
||||
"shipyard_endpoint": "",
|
||||
@@ -980,7 +978,7 @@ CONFIG_METADATA_2 = {
|
||||
"api_base": "https://api.anthropic.com/v1",
|
||||
"timeout": 120,
|
||||
"proxy": "",
|
||||
"anth_thinking_config": {"type": "", "budget": 0, "effort": ""},
|
||||
"anth_thinking_config": {"budget": 0},
|
||||
},
|
||||
"Moonshot": {
|
||||
"id": "moonshot",
|
||||
@@ -1031,42 +1029,6 @@ CONFIG_METADATA_2 = {
|
||||
"proxy": "",
|
||||
"custom_headers": {},
|
||||
},
|
||||
"AIHubMix": {
|
||||
"id": "aihubmix",
|
||||
"provider": "aihubmix",
|
||||
"type": "aihubmix_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"timeout": 120,
|
||||
"api_base": "https://aihubmix.com/v1",
|
||||
"proxy": "",
|
||||
"custom_headers": {},
|
||||
},
|
||||
"OpenRouter": {
|
||||
"id": "openrouter",
|
||||
"provider": "openrouter",
|
||||
"type": "openrouter_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"timeout": 120,
|
||||
"api_base": "https://openrouter.ai/v1",
|
||||
"proxy": "",
|
||||
"custom_headers": {},
|
||||
},
|
||||
"NVIDIA": {
|
||||
"id": "nvidia",
|
||||
"provider": "nvidia",
|
||||
"type": "openai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"api_base": "https://integrate.api.nvidia.com/v1",
|
||||
"timeout": 120,
|
||||
"proxy": "",
|
||||
"custom_headers": {},
|
||||
},
|
||||
"Azure OpenAI": {
|
||||
"id": "azure_openai",
|
||||
"provider": "azure",
|
||||
@@ -1965,25 +1927,13 @@ CONFIG_METADATA_2 = {
|
||||
},
|
||||
},
|
||||
"anth_thinking_config": {
|
||||
"description": "思考配置",
|
||||
"description": "Thinking Config",
|
||||
"type": "object",
|
||||
"items": {
|
||||
"type": {
|
||||
"description": "思考类型",
|
||||
"type": "string",
|
||||
"options": ["", "adaptive"],
|
||||
"hint": "Opus 4.6+ / Sonnet 4.6+ 推荐设为 'adaptive'。留空则使用手动 budget 模式。参见: https://platform.claude.com/docs/en/build-with-claude/adaptive-thinking",
|
||||
},
|
||||
"budget": {
|
||||
"description": "思考预算",
|
||||
"description": "Thinking Budget",
|
||||
"type": "int",
|
||||
"hint": "手动 budget_tokens,需 >= 1024。仅在 type 为空时生效。Opus 4.6 / Sonnet 4.6 上已弃用。参见: https://platform.claude.com/docs/en/build-with-claude/extended-thinking",
|
||||
},
|
||||
"effort": {
|
||||
"description": "思考深度",
|
||||
"type": "string",
|
||||
"options": ["", "low", "medium", "high", "max"],
|
||||
"hint": "type 为 'adaptive' 时控制思考深度。默认 'high'。'max' 仅限 Opus 4.6。参见: https://platform.claude.com/docs/en/build-with-claude/effort",
|
||||
"hint": "Anthropic thinking.budget_tokens param. Must >= 1024. See: https://platform.claude.com/docs/en/build-with-claude/extended-thinking",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -2307,9 +2257,6 @@ CONFIG_METADATA_2 = {
|
||||
"show_tool_use_status": {
|
||||
"type": "bool",
|
||||
},
|
||||
"show_tool_call_result": {
|
||||
"type": "bool",
|
||||
},
|
||||
"unsupported_streaming_strategy": {
|
||||
"type": "string",
|
||||
},
|
||||
@@ -2766,11 +2713,6 @@ CONFIG_METADATA_3 = {
|
||||
"labels": ["无", "本地", "沙箱"],
|
||||
"hint": "选择 Computer Use 运行环境。",
|
||||
},
|
||||
"provider_settings.computer_use_require_admin": {
|
||||
"description": "需要 AstrBot 管理员权限",
|
||||
"type": "bool",
|
||||
"hint": "开启后,需要 AstrBot 管理员权限才能调用使用电脑能力。在平台配置->管理员中可添加管理员。使用 /sid 指令查看管理员 ID。",
|
||||
},
|
||||
"provider_settings.sandbox.booter": {
|
||||
"description": "沙箱环境驱动器",
|
||||
"type": "string",
|
||||
@@ -2998,15 +2940,6 @@ CONFIG_METADATA_3 = {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
},
|
||||
"provider_settings.show_tool_call_result": {
|
||||
"description": "输出函数调用返回结果",
|
||||
"type": "bool",
|
||||
"hint": "仅在输出函数调用状态启用时生效,展示结果前 70 个字符。",
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
"provider_settings.show_tool_use_status": True,
|
||||
},
|
||||
},
|
||||
"provider_settings.sanitize_context_by_modalities": {
|
||||
"description": "按模型能力清理历史上下文",
|
||||
"type": "bool",
|
||||
|
||||
@@ -8,7 +8,6 @@ from deprecated import deprecated
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from astrbot.core.db.po import (
|
||||
ApiKey,
|
||||
Attachment,
|
||||
ChatUIProject,
|
||||
CommandConfig,
|
||||
@@ -249,55 +248,6 @@ class BaseDatabase(abc.ABC):
|
||||
"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def create_api_key(
|
||||
self,
|
||||
name: str,
|
||||
key_hash: str,
|
||||
key_prefix: str,
|
||||
scopes: list[str] | None,
|
||||
created_by: str,
|
||||
expires_at: datetime.datetime | None = None,
|
||||
) -> ApiKey:
|
||||
"""Create a new API key record."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def list_api_keys(self) -> list[ApiKey]:
|
||||
"""List all API keys."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_api_key_by_id(self, key_id: str) -> ApiKey | None:
|
||||
"""Get an API key by key_id."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_active_api_key_by_hash(self, key_hash: str) -> ApiKey | None:
|
||||
"""Get an active API key by hash (not revoked, not expired)."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def touch_api_key(self, key_id: str) -> None:
|
||||
"""Update last_used_at of an API key."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def revoke_api_key(self, key_id: str) -> bool:
|
||||
"""Revoke an API key.
|
||||
|
||||
Returns True when the key exists and is updated.
|
||||
"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def delete_api_key(self, key_id: str) -> bool:
|
||||
"""Delete an API key.
|
||||
|
||||
Returns True when the key exists and is deleted.
|
||||
"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def insert_persona(
|
||||
self,
|
||||
@@ -658,22 +608,6 @@ class BaseDatabase(abc.ABC):
|
||||
"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_platform_sessions_by_creator_paginated(
|
||||
self,
|
||||
creator: str,
|
||||
platform_id: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
exclude_project_sessions: bool = False,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""Get paginated platform sessions and total count for a creator.
|
||||
|
||||
Returns:
|
||||
tuple[list[dict], int]: (sessions_with_project_info, total_count)
|
||||
"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def update_platform_session(
|
||||
self,
|
||||
|
||||
@@ -288,43 +288,6 @@ class Attachment(TimestampMixin, SQLModel, table=True):
|
||||
)
|
||||
|
||||
|
||||
class ApiKey(TimestampMixin, SQLModel, table=True):
|
||||
"""API keys used by external developers to access Open APIs."""
|
||||
|
||||
__tablename__: str = "api_keys"
|
||||
|
||||
inner_id: int | None = Field(
|
||||
primary_key=True,
|
||||
sa_column_kwargs={"autoincrement": True},
|
||||
default=None,
|
||||
)
|
||||
key_id: str = Field(
|
||||
max_length=36,
|
||||
nullable=False,
|
||||
unique=True,
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
)
|
||||
name: str = Field(max_length=255, nullable=False)
|
||||
key_hash: str = Field(max_length=128, nullable=False, unique=True)
|
||||
key_prefix: str = Field(max_length=24, nullable=False)
|
||||
scopes: list | None = Field(default=None, sa_type=JSON)
|
||||
created_by: str = Field(max_length=255, nullable=False)
|
||||
last_used_at: datetime | None = Field(default=None)
|
||||
expires_at: datetime | None = Field(default=None)
|
||||
revoked_at: datetime | None = Field(default=None)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"key_id",
|
||||
name="uix_api_key_id",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"key_hash",
|
||||
name="uix_api_key_hash",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ChatUIProject(TimestampMixin, SQLModel, table=True):
|
||||
"""This class represents projects for organizing ChatUI conversations.
|
||||
|
||||
|
||||
+41
-180
@@ -10,7 +10,6 @@ from sqlmodel import col, delete, desc, func, or_, select, text, update
|
||||
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.db.po import (
|
||||
ApiKey,
|
||||
Attachment,
|
||||
ChatUIProject,
|
||||
CommandConfig,
|
||||
@@ -574,100 +573,6 @@ class SQLiteDatabase(BaseDatabase):
|
||||
result = T.cast(CursorResult, await session.execute(query))
|
||||
return result.rowcount
|
||||
|
||||
async def create_api_key(
|
||||
self,
|
||||
name: str,
|
||||
key_hash: str,
|
||||
key_prefix: str,
|
||||
scopes: list[str] | None,
|
||||
created_by: str,
|
||||
expires_at: datetime | None = None,
|
||||
) -> ApiKey:
|
||||
"""Create a new API key record."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
async with session.begin():
|
||||
api_key = ApiKey(
|
||||
name=name,
|
||||
key_hash=key_hash,
|
||||
key_prefix=key_prefix,
|
||||
scopes=scopes,
|
||||
created_by=created_by,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
session.add(api_key)
|
||||
await session.flush()
|
||||
await session.refresh(api_key)
|
||||
return api_key
|
||||
|
||||
async def list_api_keys(self) -> list[ApiKey]:
|
||||
"""List all API keys."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
result = await session.execute(
|
||||
select(ApiKey).order_by(desc(ApiKey.created_at))
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_api_key_by_id(self, key_id: str) -> ApiKey | None:
|
||||
"""Get an API key by key_id."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
result = await session.execute(
|
||||
select(ApiKey).where(ApiKey.key_id == key_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_active_api_key_by_hash(self, key_hash: str) -> ApiKey | None:
|
||||
"""Get an active API key by hash (not revoked, not expired)."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
now = datetime.now(timezone.utc)
|
||||
query = select(ApiKey).where(
|
||||
ApiKey.key_hash == key_hash,
|
||||
col(ApiKey.revoked_at).is_(None),
|
||||
or_(col(ApiKey.expires_at).is_(None), ApiKey.expires_at > now),
|
||||
)
|
||||
result = await session.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def touch_api_key(self, key_id: str) -> None:
|
||||
"""Update last_used_at of an API key."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
async with session.begin():
|
||||
await session.execute(
|
||||
update(ApiKey)
|
||||
.where(ApiKey.key_id == key_id)
|
||||
.values(last_used_at=datetime.now(timezone.utc)),
|
||||
)
|
||||
|
||||
async def revoke_api_key(self, key_id: str) -> bool:
|
||||
"""Revoke an API key."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
async with session.begin():
|
||||
query = (
|
||||
update(ApiKey)
|
||||
.where(ApiKey.key_id == key_id)
|
||||
.values(revoked_at=datetime.now(timezone.utc))
|
||||
)
|
||||
result = T.cast(CursorResult, await session.execute(query))
|
||||
return result.rowcount > 0
|
||||
|
||||
async def delete_api_key(self, key_id: str) -> bool:
|
||||
"""Delete an API key."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
async with session.begin():
|
||||
result = T.cast(
|
||||
CursorResult,
|
||||
await session.execute(
|
||||
delete(ApiKey).where(ApiKey.key_id == key_id)
|
||||
),
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
async def insert_persona(
|
||||
self,
|
||||
persona_id,
|
||||
@@ -1412,102 +1317,58 @@ class SQLiteDatabase(BaseDatabase):
|
||||
|
||||
Returns a list of dicts containing session info and project info (if session belongs to a project).
|
||||
"""
|
||||
(
|
||||
sessions_with_projects,
|
||||
_,
|
||||
) = await self.get_platform_sessions_by_creator_paginated(
|
||||
creator=creator,
|
||||
platform_id=platform_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
exclude_project_sessions=False,
|
||||
)
|
||||
return sessions_with_projects
|
||||
|
||||
@staticmethod
|
||||
def _build_platform_sessions_query(
|
||||
creator: str,
|
||||
platform_id: str | None = None,
|
||||
exclude_project_sessions: bool = False,
|
||||
):
|
||||
query = (
|
||||
select(
|
||||
PlatformSession,
|
||||
col(ChatUIProject.project_id),
|
||||
col(ChatUIProject.title).label("project_title"),
|
||||
col(ChatUIProject.emoji).label("project_emoji"),
|
||||
)
|
||||
.outerjoin(
|
||||
SessionProjectRelation,
|
||||
col(PlatformSession.session_id)
|
||||
== col(SessionProjectRelation.session_id),
|
||||
)
|
||||
.outerjoin(
|
||||
ChatUIProject,
|
||||
col(SessionProjectRelation.project_id) == col(ChatUIProject.project_id),
|
||||
)
|
||||
.where(col(PlatformSession.creator) == creator)
|
||||
)
|
||||
|
||||
if platform_id:
|
||||
query = query.where(PlatformSession.platform_id == platform_id)
|
||||
if exclude_project_sessions:
|
||||
query = query.where(col(ChatUIProject.project_id).is_(None))
|
||||
|
||||
return query
|
||||
|
||||
@staticmethod
|
||||
def _rows_to_session_dicts(rows: list[tuple]) -> list[dict]:
|
||||
sessions_with_projects = []
|
||||
for row in rows:
|
||||
platform_session = row[0]
|
||||
project_id = row[1]
|
||||
project_title = row[2]
|
||||
project_emoji = row[3]
|
||||
|
||||
session_dict = {
|
||||
"session": platform_session,
|
||||
"project_id": project_id,
|
||||
"project_title": project_title,
|
||||
"project_emoji": project_emoji,
|
||||
}
|
||||
sessions_with_projects.append(session_dict)
|
||||
|
||||
return sessions_with_projects
|
||||
|
||||
async def get_platform_sessions_by_creator_paginated(
|
||||
self,
|
||||
creator: str,
|
||||
platform_id: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
exclude_project_sessions: bool = False,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""Get paginated Platform sessions for a creator with total count."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
base_query = self._build_platform_sessions_query(
|
||||
creator=creator,
|
||||
platform_id=platform_id,
|
||||
exclude_project_sessions=exclude_project_sessions,
|
||||
# LEFT JOIN with SessionProjectRelation and ChatUIProject to get project info
|
||||
query = (
|
||||
select(
|
||||
PlatformSession,
|
||||
col(ChatUIProject.project_id),
|
||||
col(ChatUIProject.title).label("project_title"),
|
||||
col(ChatUIProject.emoji).label("project_emoji"),
|
||||
)
|
||||
.outerjoin(
|
||||
SessionProjectRelation,
|
||||
col(PlatformSession.session_id)
|
||||
== col(SessionProjectRelation.session_id),
|
||||
)
|
||||
.outerjoin(
|
||||
ChatUIProject,
|
||||
col(SessionProjectRelation.project_id)
|
||||
== col(ChatUIProject.project_id),
|
||||
)
|
||||
.where(col(PlatformSession.creator) == creator)
|
||||
)
|
||||
|
||||
total_result = await session.execute(
|
||||
select(func.count()).select_from(base_query.subquery())
|
||||
)
|
||||
total = int(total_result.scalar_one() or 0)
|
||||
if platform_id:
|
||||
query = query.where(PlatformSession.platform_id == platform_id)
|
||||
|
||||
result_query = (
|
||||
base_query.order_by(desc(PlatformSession.updated_at))
|
||||
query = (
|
||||
query.order_by(desc(PlatformSession.updated_at))
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
result = await session.execute(result_query)
|
||||
result = await session.execute(query)
|
||||
|
||||
sessions_with_projects = self._rows_to_session_dicts(result.all())
|
||||
return sessions_with_projects, total
|
||||
# Convert to list of dicts with session and project info
|
||||
sessions_with_projects = []
|
||||
for row in result.all():
|
||||
platform_session = row[0]
|
||||
project_id = row[1]
|
||||
project_title = row[2]
|
||||
project_emoji = row[3]
|
||||
|
||||
session_dict = {
|
||||
"session": platform_session,
|
||||
"project_id": project_id,
|
||||
"project_title": project_title,
|
||||
"project_emoji": project_emoji,
|
||||
}
|
||||
sessions_with_projects.append(session_dict)
|
||||
|
||||
return sessions_with_projects
|
||||
|
||||
async def update_platform_session(
|
||||
self,
|
||||
|
||||
@@ -13,19 +13,16 @@ from astrbot.core.knowledge_base.models import (
|
||||
KBMedia,
|
||||
KnowledgeBase,
|
||||
)
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_knowledge_base_path
|
||||
|
||||
|
||||
class KBSQLiteDatabase:
|
||||
def __init__(self, db_path: str | None = None) -> None:
|
||||
def __init__(self, db_path: str = "data/knowledge_base/kb.db") -> None:
|
||||
"""初始化知识库数据库
|
||||
|
||||
Args:
|
||||
db_path: 数据库文件路径, 默认位于 AstrBot 数据目录下的 knowledge_base/kb.db
|
||||
db_path: 数据库文件路径, 默认为 data/knowledge_base/kb.db
|
||||
|
||||
"""
|
||||
if db_path is None:
|
||||
db_path = str(Path(get_astrbot_knowledge_base_path()) / "kb.db")
|
||||
self.db_path = db_path
|
||||
self.DATABASE_URL = f"sqlite+aiosqlite:///{db_path}"
|
||||
self.inited = False
|
||||
|
||||
@@ -3,7 +3,6 @@ from pathlib import Path
|
||||
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.provider.manager import ProviderManager
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_knowledge_base_path
|
||||
|
||||
# from .chunking.fixed_size import FixedSizeChunker
|
||||
from .chunking.recursive import RecursiveCharacterChunker
|
||||
@@ -14,7 +13,7 @@ from .retrieval.manager import RetrievalManager, RetrievalResult
|
||||
from .retrieval.rank_fusion import RankFusion
|
||||
from .retrieval.sparse_retriever import SparseRetriever
|
||||
|
||||
FILES_PATH = get_astrbot_knowledge_base_path()
|
||||
FILES_PATH = "data/knowledge_base"
|
||||
DB_PATH = Path(FILES_PATH) / "kb.db"
|
||||
"""Knowledge Base storage root directory"""
|
||||
CHUNKER = RecursiveCharacterChunker()
|
||||
@@ -28,7 +27,7 @@ class KnowledgeBaseManager:
|
||||
self,
|
||||
provider_manager: ProviderManager,
|
||||
) -> None:
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||
self.provider_manager = provider_manager
|
||||
self._session_deleted_callback_registered = False
|
||||
|
||||
|
||||
@@ -25,14 +25,10 @@ import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from enum import Enum
|
||||
|
||||
if sys.version_info >= (3, 14):
|
||||
from pydantic import BaseModel
|
||||
else:
|
||||
from pydantic.v1 import BaseModel
|
||||
from pydantic.v1 import BaseModel
|
||||
|
||||
from astrbot.core import astrbot_config, file_token_service, logger
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
@@ -89,7 +85,7 @@ class BaseMessageComponent(BaseModel):
|
||||
|
||||
|
||||
class Plain(BaseMessageComponent):
|
||||
type: ComponentType = ComponentType.Plain
|
||||
type = ComponentType.Plain
|
||||
text: str
|
||||
convert: bool | None = True
|
||||
|
||||
@@ -104,7 +100,7 @@ class Plain(BaseMessageComponent):
|
||||
|
||||
|
||||
class Face(BaseMessageComponent):
|
||||
type: ComponentType = ComponentType.Face
|
||||
type = ComponentType.Face
|
||||
id: int
|
||||
|
||||
def __init__(self, **_) -> None:
|
||||
@@ -112,15 +108,13 @@ class Face(BaseMessageComponent):
|
||||
|
||||
|
||||
class Record(BaseMessageComponent):
|
||||
type: ComponentType = ComponentType.Record
|
||||
type = ComponentType.Record
|
||||
file: str | None = ""
|
||||
magic: bool | None = False
|
||||
url: str | None = ""
|
||||
cache: bool | None = True
|
||||
proxy: bool | None = True
|
||||
timeout: int | None = 0
|
||||
# Original text content (e.g. TTS source text), used as caption in fallback scenarios
|
||||
text: str | None = None
|
||||
# 额外
|
||||
path: str | None
|
||||
|
||||
@@ -221,7 +215,7 @@ class Record(BaseMessageComponent):
|
||||
|
||||
|
||||
class Video(BaseMessageComponent):
|
||||
type: ComponentType = ComponentType.Video
|
||||
type = ComponentType.Video
|
||||
file: str
|
||||
cover: str | None = ""
|
||||
c: int | None = 2
|
||||
@@ -307,7 +301,7 @@ class Video(BaseMessageComponent):
|
||||
|
||||
|
||||
class At(BaseMessageComponent):
|
||||
type: ComponentType = ComponentType.At
|
||||
type = ComponentType.At
|
||||
qq: int | str # 此处str为all时代表所有人
|
||||
name: str | None = ""
|
||||
|
||||
@@ -329,28 +323,28 @@ class AtAll(At):
|
||||
|
||||
|
||||
class RPS(BaseMessageComponent): # TODO
|
||||
type: ComponentType = ComponentType.RPS
|
||||
type = ComponentType.RPS
|
||||
|
||||
def __init__(self, **_) -> None:
|
||||
super().__init__(**_)
|
||||
|
||||
|
||||
class Dice(BaseMessageComponent): # TODO
|
||||
type: ComponentType = ComponentType.Dice
|
||||
type = ComponentType.Dice
|
||||
|
||||
def __init__(self, **_) -> None:
|
||||
super().__init__(**_)
|
||||
|
||||
|
||||
class Shake(BaseMessageComponent): # TODO
|
||||
type: ComponentType = ComponentType.Shake
|
||||
type = ComponentType.Shake
|
||||
|
||||
def __init__(self, **_) -> None:
|
||||
super().__init__(**_)
|
||||
|
||||
|
||||
class Share(BaseMessageComponent):
|
||||
type: ComponentType = ComponentType.Share
|
||||
type = ComponentType.Share
|
||||
url: str
|
||||
title: str
|
||||
content: str | None = ""
|
||||
@@ -361,7 +355,7 @@ class Share(BaseMessageComponent):
|
||||
|
||||
|
||||
class Contact(BaseMessageComponent): # TODO
|
||||
type: ComponentType = ComponentType.Contact
|
||||
type = ComponentType.Contact
|
||||
_type: str # type 字段冲突
|
||||
id: int | None = 0
|
||||
|
||||
@@ -370,7 +364,7 @@ class Contact(BaseMessageComponent): # TODO
|
||||
|
||||
|
||||
class Location(BaseMessageComponent): # TODO
|
||||
type: ComponentType = ComponentType.Location
|
||||
type = ComponentType.Location
|
||||
lat: float
|
||||
lon: float
|
||||
title: str | None = ""
|
||||
@@ -381,7 +375,7 @@ class Location(BaseMessageComponent): # TODO
|
||||
|
||||
|
||||
class Music(BaseMessageComponent):
|
||||
type: ComponentType = ComponentType.Music
|
||||
type = ComponentType.Music
|
||||
_type: str
|
||||
id: int | None = 0
|
||||
url: str | None = ""
|
||||
@@ -398,7 +392,7 @@ class Music(BaseMessageComponent):
|
||||
|
||||
|
||||
class Image(BaseMessageComponent):
|
||||
type: ComponentType = ComponentType.Image
|
||||
type = ComponentType.Image
|
||||
file: str | None = ""
|
||||
_type: str | None = ""
|
||||
subType: int | None = 0
|
||||
@@ -513,7 +507,7 @@ class Image(BaseMessageComponent):
|
||||
|
||||
|
||||
class Reply(BaseMessageComponent):
|
||||
type: ComponentType = ComponentType.Reply
|
||||
type = ComponentType.Reply
|
||||
id: str | int
|
||||
"""所引用的消息 ID"""
|
||||
chain: list["BaseMessageComponent"] | None = []
|
||||
@@ -549,7 +543,7 @@ class Poke(BaseMessageComponent):
|
||||
|
||||
|
||||
class Forward(BaseMessageComponent):
|
||||
type: ComponentType = ComponentType.Forward
|
||||
type = ComponentType.Forward
|
||||
id: str
|
||||
|
||||
def __init__(self, **_) -> None:
|
||||
@@ -559,7 +553,7 @@ class Forward(BaseMessageComponent):
|
||||
class Node(BaseMessageComponent):
|
||||
"""群合并转发消息"""
|
||||
|
||||
type: ComponentType = ComponentType.Node
|
||||
type = ComponentType.Node
|
||||
id: int | None = 0 # 忽略
|
||||
name: str | None = "" # qq昵称
|
||||
uin: str | None = "0" # qq号
|
||||
@@ -611,7 +605,7 @@ class Node(BaseMessageComponent):
|
||||
|
||||
|
||||
class Nodes(BaseMessageComponent):
|
||||
type: ComponentType = ComponentType.Nodes
|
||||
type = ComponentType.Nodes
|
||||
nodes: list[Node]
|
||||
|
||||
def __init__(self, nodes: list[Node], **_) -> None:
|
||||
@@ -637,7 +631,7 @@ class Nodes(BaseMessageComponent):
|
||||
|
||||
|
||||
class Json(BaseMessageComponent):
|
||||
type: ComponentType = ComponentType.Json
|
||||
type = ComponentType.Json
|
||||
data: dict
|
||||
|
||||
def __init__(self, data: str | dict, **_) -> None:
|
||||
@@ -647,14 +641,14 @@ class Json(BaseMessageComponent):
|
||||
|
||||
|
||||
class Unknown(BaseMessageComponent):
|
||||
type: ComponentType = ComponentType.Unknown
|
||||
type = ComponentType.Unknown
|
||||
text: str
|
||||
|
||||
|
||||
class File(BaseMessageComponent):
|
||||
"""文件消息段"""
|
||||
|
||||
type: ComponentType = ComponentType.File
|
||||
type = ComponentType.File
|
||||
name: str | None = "" # 名字
|
||||
file_: str | None = "" # 本地路径
|
||||
url: str | None = "" # url
|
||||
@@ -720,38 +714,13 @@ class File(BaseMessageComponent):
|
||||
if allow_return_url and self.url:
|
||||
return self.url
|
||||
|
||||
if self.file_:
|
||||
path = self.file_
|
||||
if path.startswith("file://"):
|
||||
# 处理 file:// (2 slashes) 或 file:/// (3 slashes)
|
||||
# pathlib.as_uri() 通常生成 file:///
|
||||
path = path[7:]
|
||||
# 兼容 Windows: file:///C:/path -> /C:/path -> C:/path
|
||||
if (
|
||||
os.name == "nt"
|
||||
and len(path) > 2
|
||||
and path[0] == "/"
|
||||
and path[2] == ":"
|
||||
):
|
||||
path = path[1:]
|
||||
|
||||
if os.path.exists(path):
|
||||
return os.path.abspath(path)
|
||||
if self.file_ and os.path.exists(self.file_):
|
||||
return os.path.abspath(self.file_)
|
||||
|
||||
if self.url:
|
||||
await self._download_file()
|
||||
if self.file_:
|
||||
path = self.file_
|
||||
if path.startswith("file://"):
|
||||
path = path[7:]
|
||||
if (
|
||||
os.name == "nt"
|
||||
and len(path) > 2
|
||||
and path[0] == "/"
|
||||
and path[2] == ":"
|
||||
):
|
||||
path = path[1:]
|
||||
return os.path.abspath(path)
|
||||
return os.path.abspath(self.file_)
|
||||
|
||||
return ""
|
||||
|
||||
@@ -814,7 +783,7 @@ class File(BaseMessageComponent):
|
||||
|
||||
|
||||
class WechatEmoji(BaseMessageComponent):
|
||||
type: ComponentType = ComponentType.WechatEmoji
|
||||
type = ComponentType.WechatEmoji
|
||||
md5: str | None = ""
|
||||
md5_len: int | None = 0
|
||||
cdnurl: str | None = ""
|
||||
|
||||
@@ -1,60 +1,30 @@
|
||||
"""Pipeline package exports.
|
||||
|
||||
This module intentionally avoids eager imports of all pipeline stage modules to
|
||||
prevent import-time cycles. Stage classes remain available via lazy attribute
|
||||
resolution for backward compatibility.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
from astrbot.core.message.message_event_result import (
|
||||
EventResultType,
|
||||
MessageEventResult,
|
||||
)
|
||||
|
||||
from .stage_order import STAGES_ORDER
|
||||
from .content_safety_check.stage import ContentSafetyCheckStage
|
||||
from .preprocess_stage.stage import PreProcessStage
|
||||
from .process_stage.stage import ProcessStage
|
||||
from .rate_limit_check.stage import RateLimitStage
|
||||
from .respond.stage import RespondStage
|
||||
from .result_decorate.stage import ResultDecorateStage
|
||||
from .session_status_check.stage import SessionStatusCheckStage
|
||||
from .waking_check.stage import WakingCheckStage
|
||||
from .whitelist_check.stage import WhitelistCheckStage
|
||||
|
||||
_LAZY_EXPORTS = {
|
||||
"ContentSafetyCheckStage": (
|
||||
"astrbot.core.pipeline.content_safety_check.stage",
|
||||
"ContentSafetyCheckStage",
|
||||
),
|
||||
"PreProcessStage": (
|
||||
"astrbot.core.pipeline.preprocess_stage.stage",
|
||||
"PreProcessStage",
|
||||
),
|
||||
"ProcessStage": (
|
||||
"astrbot.core.pipeline.process_stage.stage",
|
||||
"ProcessStage",
|
||||
),
|
||||
"RateLimitStage": (
|
||||
"astrbot.core.pipeline.rate_limit_check.stage",
|
||||
"RateLimitStage",
|
||||
),
|
||||
"RespondStage": (
|
||||
"astrbot.core.pipeline.respond.stage",
|
||||
"RespondStage",
|
||||
),
|
||||
"ResultDecorateStage": (
|
||||
"astrbot.core.pipeline.result_decorate.stage",
|
||||
"ResultDecorateStage",
|
||||
),
|
||||
"SessionStatusCheckStage": (
|
||||
"astrbot.core.pipeline.session_status_check.stage",
|
||||
"SessionStatusCheckStage",
|
||||
),
|
||||
"WakingCheckStage": (
|
||||
"astrbot.core.pipeline.waking_check.stage",
|
||||
"WakingCheckStage",
|
||||
),
|
||||
"WhitelistCheckStage": (
|
||||
"astrbot.core.pipeline.whitelist_check.stage",
|
||||
"WhitelistCheckStage",
|
||||
),
|
||||
}
|
||||
# 管道阶段顺序
|
||||
STAGES_ORDER = [
|
||||
"WakingCheckStage", # 检查是否需要唤醒
|
||||
"WhitelistCheckStage", # 检查是否在群聊/私聊白名单
|
||||
"SessionStatusCheckStage", # 检查会话是否整体启用
|
||||
"RateLimitStage", # 检查会话是否超过频率限制
|
||||
"ContentSafetyCheckStage", # 检查内容安全
|
||||
"PreProcessStage", # 预处理
|
||||
"ProcessStage", # 交由 Stars 处理(a.k.a 插件),或者 LLM 调用
|
||||
"ResultDecorateStage", # 处理结果,比如添加回复前缀、t2i、转换为语音 等
|
||||
"RespondStage", # 发送消息
|
||||
]
|
||||
|
||||
__all__ = [
|
||||
"ContentSafetyCheckStage",
|
||||
@@ -66,21 +36,6 @@ __all__ = [
|
||||
"RespondStage",
|
||||
"ResultDecorateStage",
|
||||
"SessionStatusCheckStage",
|
||||
"STAGES_ORDER",
|
||||
"WakingCheckStage",
|
||||
"WhitelistCheckStage",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name not in _LAZY_EXPORTS:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
module_path, attr_name = _LAZY_EXPORTS[name]
|
||||
module = import_module(module_path)
|
||||
value = getattr(module, attr_name)
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return sorted(set(globals()) | set(__all__))
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
"""Pipeline bootstrap utilities."""
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
from .stage import registered_stages
|
||||
|
||||
_BUILTIN_STAGE_MODULES = (
|
||||
"astrbot.core.pipeline.waking_check.stage",
|
||||
"astrbot.core.pipeline.whitelist_check.stage",
|
||||
"astrbot.core.pipeline.session_status_check.stage",
|
||||
"astrbot.core.pipeline.rate_limit_check.stage",
|
||||
"astrbot.core.pipeline.content_safety_check.stage",
|
||||
"astrbot.core.pipeline.preprocess_stage.stage",
|
||||
"astrbot.core.pipeline.process_stage.stage",
|
||||
"astrbot.core.pipeline.result_decorate.stage",
|
||||
"astrbot.core.pipeline.respond.stage",
|
||||
)
|
||||
|
||||
_EXPECTED_STAGE_NAMES = {
|
||||
"WakingCheckStage",
|
||||
"WhitelistCheckStage",
|
||||
"SessionStatusCheckStage",
|
||||
"RateLimitStage",
|
||||
"ContentSafetyCheckStage",
|
||||
"PreProcessStage",
|
||||
"ProcessStage",
|
||||
"ResultDecorateStage",
|
||||
"RespondStage",
|
||||
}
|
||||
|
||||
_builtin_stages_registered = False
|
||||
|
||||
|
||||
def ensure_builtin_stages_registered() -> None:
|
||||
"""Ensure built-in pipeline stages are imported and registered."""
|
||||
global _builtin_stages_registered
|
||||
|
||||
if _builtin_stages_registered:
|
||||
return
|
||||
|
||||
stage_names = {stage_cls.__name__ for stage_cls in registered_stages}
|
||||
if _EXPECTED_STAGE_NAMES.issubset(stage_names):
|
||||
_builtin_stages_registered = True
|
||||
return
|
||||
|
||||
for module_path in _BUILTIN_STAGE_MODULES:
|
||||
import_module(module_path)
|
||||
|
||||
_builtin_stages_registered = True
|
||||
|
||||
|
||||
__all__ = ["ensure_builtin_stages_registered"]
|
||||
@@ -1,9 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from astrbot.core.config import AstrBotConfig
|
||||
from astrbot.core.star import PluginManager
|
||||
|
||||
from .context_utils import call_event_hook, call_handler
|
||||
|
||||
@@ -13,7 +11,7 @@ class PipelineContext:
|
||||
"""上下文对象,包含管道执行所需的上下文信息"""
|
||||
|
||||
astrbot_config: AstrBotConfig # AstrBot 配置对象
|
||||
plugin_manager: Any # 插件管理器对象
|
||||
plugin_manager: PluginManager # 插件管理器对象
|
||||
astrbot_config_id: str
|
||||
call_handler = call_handler
|
||||
call_event_hook = call_event_hook
|
||||
|
||||
@@ -19,7 +19,6 @@ from astrbot.core.message.message_event_result import (
|
||||
MessageEventResult,
|
||||
ResultContentType,
|
||||
)
|
||||
from astrbot.core.pipeline.stage import Stage
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from astrbot.core.provider.entities import (
|
||||
LLMResponse,
|
||||
@@ -31,6 +30,7 @@ from astrbot.core.utils.session_lock import session_lock_manager
|
||||
|
||||
from .....astr_agent_run_util import run_agent, run_live_agent
|
||||
from ....context import PipelineContext, call_event_hook
|
||||
from ...stage import Stage
|
||||
|
||||
|
||||
class InternalAgentSubStage(Stage):
|
||||
@@ -54,7 +54,6 @@ class InternalAgentSubStage(Stage):
|
||||
if isinstance(self.max_step, bool): # workaround: #2622
|
||||
self.max_step = 30
|
||||
self.show_tool_use: bool = settings.get("show_tool_use_status", True)
|
||||
self.show_tool_call_result: bool = settings.get("show_tool_call_result", False)
|
||||
self.show_reasoning = settings.get("display_reasoning_text", False)
|
||||
self.sanitize_context_by_modalities: bool = settings.get(
|
||||
"sanitize_context_by_modalities",
|
||||
@@ -241,7 +240,6 @@ class InternalAgentSubStage(Stage):
|
||||
tts_provider,
|
||||
self.max_step,
|
||||
self.show_tool_use,
|
||||
self.show_tool_call_result,
|
||||
show_reasoning=self.show_reasoning,
|
||||
),
|
||||
),
|
||||
@@ -249,16 +247,13 @@ class InternalAgentSubStage(Stage):
|
||||
yield
|
||||
|
||||
# 保存历史记录
|
||||
if agent_runner.done() and (
|
||||
not event.is_stopped() or agent_runner.was_aborted()
|
||||
):
|
||||
if not event.is_stopped() and agent_runner.done():
|
||||
await self._save_to_history(
|
||||
event,
|
||||
req,
|
||||
agent_runner.get_final_llm_resp(),
|
||||
agent_runner.run_context.messages,
|
||||
agent_runner.stats,
|
||||
user_aborted=agent_runner.was_aborted(),
|
||||
)
|
||||
|
||||
elif streaming_response and not stream_to_general:
|
||||
@@ -271,7 +266,6 @@ class InternalAgentSubStage(Stage):
|
||||
agent_runner,
|
||||
self.max_step,
|
||||
self.show_tool_use,
|
||||
self.show_tool_call_result,
|
||||
show_reasoning=self.show_reasoning,
|
||||
),
|
||||
),
|
||||
@@ -300,7 +294,6 @@ class InternalAgentSubStage(Stage):
|
||||
agent_runner,
|
||||
self.max_step,
|
||||
self.show_tool_use,
|
||||
self.show_tool_call_result,
|
||||
stream_to_general,
|
||||
show_reasoning=self.show_reasoning,
|
||||
):
|
||||
@@ -315,14 +308,13 @@ class InternalAgentSubStage(Stage):
|
||||
)
|
||||
|
||||
# 检查事件是否被停止,如果被停止则不保存历史记录
|
||||
if not event.is_stopped() or agent_runner.was_aborted():
|
||||
if not event.is_stopped():
|
||||
await self._save_to_history(
|
||||
event,
|
||||
req,
|
||||
final_resp,
|
||||
agent_runner.run_context.messages,
|
||||
agent_runner.stats,
|
||||
user_aborted=agent_runner.was_aborted(),
|
||||
)
|
||||
|
||||
asyncio.create_task(
|
||||
@@ -348,29 +340,16 @@ class InternalAgentSubStage(Stage):
|
||||
llm_response: LLMResponse | None,
|
||||
all_messages: list[Message],
|
||||
runner_stats: AgentStats | None,
|
||||
user_aborted: bool = False,
|
||||
) -> None:
|
||||
if not req or not req.conversation:
|
||||
return
|
||||
|
||||
if not llm_response and not user_aborted:
|
||||
return
|
||||
|
||||
if llm_response and llm_response.role != "assistant":
|
||||
if not user_aborted:
|
||||
return
|
||||
llm_response = LLMResponse(
|
||||
role="assistant",
|
||||
completion_text=llm_response.completion_text or "",
|
||||
)
|
||||
elif llm_response is None:
|
||||
llm_response = LLMResponse(role="assistant", completion_text="")
|
||||
|
||||
if (
|
||||
not llm_response.completion_text
|
||||
and not req.tool_calls_result
|
||||
and not user_aborted
|
||||
not req
|
||||
or not req.conversation
|
||||
or not llm_response
|
||||
or llm_response.role != "assistant"
|
||||
):
|
||||
return
|
||||
|
||||
if not llm_response.completion_text and not req.tool_calls_result:
|
||||
logger.debug("LLM 响应为空,不保存记录。")
|
||||
return
|
||||
|
||||
@@ -384,14 +363,6 @@ class InternalAgentSubStage(Stage):
|
||||
continue
|
||||
message_to_save.append(message.model_dump())
|
||||
|
||||
# if user_aborted:
|
||||
# message_to_save.append(
|
||||
# Message(
|
||||
# role="assistant",
|
||||
# content="[User aborted this request. Partial output before abort was preserved.]",
|
||||
# ).model_dump()
|
||||
# )
|
||||
|
||||
token_usage = None
|
||||
if runner_stats:
|
||||
# token_usage = runner_stats.token_usage.total
|
||||
|
||||
@@ -8,7 +8,6 @@ from astrbot.core.agent.runners.dashscope.dashscope_agent_runner import (
|
||||
DashscopeAgentRunner,
|
||||
)
|
||||
from astrbot.core.agent.runners.dify.dify_agent_runner import DifyAgentRunner
|
||||
from astrbot.core.astr_agent_hooks import MAIN_AGENT_HOOKS
|
||||
from astrbot.core.message.components import Image
|
||||
from astrbot.core.message.message_event_result import (
|
||||
MessageChain,
|
||||
@@ -18,7 +17,6 @@ from astrbot.core.message.message_event_result import (
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from astrbot.core.agent.runners.base import BaseAgentRunner
|
||||
from astrbot.core.pipeline.stage import Stage
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from astrbot.core.provider.entities import (
|
||||
ProviderRequest,
|
||||
@@ -27,7 +25,9 @@ from astrbot.core.star.star_handler import EventType
|
||||
from astrbot.core.utils.metrics import Metric
|
||||
|
||||
from .....astr_agent_context import AgentContextWrapper, AstrAgentContext
|
||||
from .....astr_agent_hooks import MAIN_AGENT_HOOKS
|
||||
from ....context import PipelineContext, call_event_hook
|
||||
from ...stage import Stage
|
||||
|
||||
AGENT_RUNNER_TYPE_KEY = {
|
||||
"dify": "dify_agent_runner_provider_id",
|
||||
|
||||
@@ -8,9 +8,9 @@ from astrbot.core import logger
|
||||
from astrbot.core.message.message_event_result import MessageEventResult
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from astrbot.core.star.star import star_map
|
||||
from astrbot.core.star.star_handler import EventType, StarHandlerMetadata
|
||||
from astrbot.core.star.star_handler import StarHandlerMetadata
|
||||
|
||||
from ...context import PipelineContext, call_event_hook, call_handler
|
||||
from ...context import PipelineContext, call_handler
|
||||
from ..stage import Stage
|
||||
|
||||
|
||||
@@ -48,20 +48,10 @@ class StarRequestSubStage(Stage):
|
||||
yield ret
|
||||
event.clear_result() # 清除上一个 handler 的结果
|
||||
except Exception as e:
|
||||
traceback_text = traceback.format_exc()
|
||||
logger.error(traceback_text)
|
||||
logger.error(traceback.format_exc())
|
||||
logger.error(f"Star {handler.handler_full_name} handle error: {e}")
|
||||
|
||||
await call_event_hook(
|
||||
event,
|
||||
EventType.OnPluginErrorEvent,
|
||||
md.name,
|
||||
handler.handler_name,
|
||||
e,
|
||||
traceback_text,
|
||||
)
|
||||
|
||||
if not event.is_stopped() and event.is_at_or_wake_command:
|
||||
if event.is_at_or_wake_command:
|
||||
ret = f":(\n\n在调用插件 {md.name} 的处理函数 {handler.handler_name} 时出现异常:{e}"
|
||||
event.set_result(MessageEventResult().message(ret))
|
||||
yield
|
||||
|
||||
@@ -33,21 +33,6 @@ class RespondStage(Stage):
|
||||
Comp.Nodes: lambda comp: bool(comp.nodes), # 多个转发节点
|
||||
Comp.File: lambda comp: bool(comp.file_ or comp.url),
|
||||
Comp.WechatEmoji: lambda comp: comp.md5 is not None, # 微信表情
|
||||
Comp.Json: lambda comp: bool(comp.data), # Json 卡片
|
||||
Comp.Share: lambda comp: bool(comp.url) or bool(comp.title),
|
||||
Comp.Music: lambda comp: (
|
||||
(comp.id and comp._type and comp._type != "custom")
|
||||
or (comp._type == "custom" and comp.url and comp.audio and comp.title)
|
||||
), # 音乐分享
|
||||
Comp.Forward: lambda comp: bool(comp.id), # 合并转发
|
||||
Comp.Location: lambda comp: bool(
|
||||
comp.lat is not None and comp.lon is not None
|
||||
), # 位置
|
||||
Comp.Contact: lambda comp: bool(comp._type and comp.id), # 推荐好友 or 群
|
||||
Comp.Shake: lambda _: True, # 窗口抖动(戳一戳)
|
||||
Comp.Dice: lambda _: True, # 掷骰子魔法表情
|
||||
Comp.RPS: lambda _: True, # 猜拳魔法表情
|
||||
Comp.Unknown: lambda comp: bool(comp.text and comp.text.strip()),
|
||||
}
|
||||
|
||||
async def initialize(self, ctx: PipelineContext) -> None:
|
||||
|
||||
@@ -315,7 +315,6 @@ class ResultDecorateStage(Stage):
|
||||
Record(
|
||||
file=url or audio_path,
|
||||
url=url or audio_path,
|
||||
text=comp.text,
|
||||
),
|
||||
)
|
||||
if dual_output:
|
||||
|
||||
@@ -6,19 +6,16 @@ from astrbot.core.platform.sources.webchat.webchat_event import WebChatMessageEv
|
||||
from astrbot.core.platform.sources.wecom_ai_bot.wecomai_event import (
|
||||
WecomAIBotMessageEvent,
|
||||
)
|
||||
from astrbot.core.utils.active_event_registry import active_event_registry
|
||||
|
||||
from .bootstrap import ensure_builtin_stages_registered
|
||||
from . import STAGES_ORDER
|
||||
from .context import PipelineContext
|
||||
from .stage import registered_stages
|
||||
from .stage_order import STAGES_ORDER
|
||||
|
||||
|
||||
class PipelineScheduler:
|
||||
"""管道调度器,负责调度各个阶段的执行"""
|
||||
|
||||
def __init__(self, context: PipelineContext) -> None:
|
||||
ensure_builtin_stages_registered()
|
||||
registered_stages.sort(
|
||||
key=lambda x: STAGES_ORDER.index(x.__name__),
|
||||
) # 按照顺序排序
|
||||
@@ -82,14 +79,10 @@ class PipelineScheduler:
|
||||
event (AstrMessageEvent): 事件对象
|
||||
|
||||
"""
|
||||
active_event_registry.register(event)
|
||||
try:
|
||||
await self._process_stages(event)
|
||||
await self._process_stages(event)
|
||||
|
||||
# 如果没有发送操作, 则发送一个空消息, 以便于后续的处理
|
||||
if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent):
|
||||
await event.send(None)
|
||||
# 如果没有发送操作, 则发送一个空消息, 以便于后续的处理
|
||||
if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent):
|
||||
await event.send(None)
|
||||
|
||||
logger.debug("pipeline 执行完毕。")
|
||||
finally:
|
||||
active_event_registry.unregister(event)
|
||||
logger.debug("pipeline 执行完毕。")
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
"""Pipeline stage execution order."""
|
||||
|
||||
STAGES_ORDER = [
|
||||
"WakingCheckStage", # 检查是否需要唤醒
|
||||
"WhitelistCheckStage", # 检查是否在群聊/私聊白名单
|
||||
"SessionStatusCheckStage", # 检查会话是否整体启用
|
||||
"RateLimitStage", # 检查会话是否超过频率限制
|
||||
"ContentSafetyCheckStage", # 检查内容安全
|
||||
"PreProcessStage", # 预处理
|
||||
"ProcessStage", # 交由 Stars 处理(a.k.a 插件),或者 LLM 调用
|
||||
"ResultDecorateStage", # 处理结果,比如添加回复前缀、t2i、转换为语音 等
|
||||
"RespondStage", # 发送消息
|
||||
]
|
||||
|
||||
__all__ = ["STAGES_ORDER"]
|
||||
@@ -52,19 +52,9 @@ class AstrMessageEvent(abc.ABC):
|
||||
self.is_at_or_wake_command = False
|
||||
"""是否是 At 机器人或者带有唤醒词或者是私聊(插件注册的事件监听器会让 is_wake 设为 True, 但是不会让这个属性置为 True)"""
|
||||
self._extras: dict[str, Any] = {}
|
||||
message_type = getattr(message_obj, "type", None)
|
||||
if not isinstance(message_type, MessageType):
|
||||
try:
|
||||
message_type = MessageType(str(message_type))
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
logger.warning(
|
||||
f"Failed to convert message type {message_obj.type!r} to MessageType. "
|
||||
f"Falling back to FRIEND_MESSAGE."
|
||||
)
|
||||
message_type = MessageType.FRIEND_MESSAGE
|
||||
self.session = MessageSession(
|
||||
platform_name=platform_meta.id,
|
||||
message_type=message_type,
|
||||
message_type=message_obj.type,
|
||||
session_id=session_id,
|
||||
)
|
||||
# self.unified_msg_origin = str(self.session)
|
||||
@@ -169,18 +159,15 @@ class AstrMessageEvent(abc.ABC):
|
||||
|
||||
除了文本消息外,其他消息类型会被转换为对应的占位符。如图片消息会被转换为 [图片]。
|
||||
"""
|
||||
return self._outline_chain(getattr(self.message_obj, "message", None))
|
||||
return self._outline_chain(self.message_obj.message)
|
||||
|
||||
def get_messages(self) -> list[BaseMessageComponent]:
|
||||
"""获取消息链。"""
|
||||
return getattr(self.message_obj, "message", [])
|
||||
return self.message_obj.message
|
||||
|
||||
def get_message_type(self) -> MessageType:
|
||||
"""获取消息类型。"""
|
||||
message_type = getattr(self.message_obj, "type", None)
|
||||
if isinstance(message_type, MessageType):
|
||||
return message_type
|
||||
return self.session.message_type
|
||||
return self.message_obj.type
|
||||
|
||||
def get_session_id(self) -> str:
|
||||
"""获取会话id。"""
|
||||
@@ -188,30 +175,21 @@ class AstrMessageEvent(abc.ABC):
|
||||
|
||||
def get_group_id(self) -> str:
|
||||
"""获取群组id。如果不是群组消息,返回空字符串。"""
|
||||
return getattr(self.message_obj, "group_id", "")
|
||||
return self.message_obj.group_id
|
||||
|
||||
def get_self_id(self) -> str:
|
||||
"""获取机器人自身的id。"""
|
||||
return getattr(self.message_obj, "self_id", "")
|
||||
return self.message_obj.self_id
|
||||
|
||||
def get_sender_id(self) -> str:
|
||||
"""获取消息发送者的id。"""
|
||||
sender = getattr(self.message_obj, "sender", None)
|
||||
if sender and isinstance(getattr(sender, "user_id", None), str):
|
||||
return sender.user_id
|
||||
return ""
|
||||
return self.message_obj.sender.user_id
|
||||
|
||||
def get_sender_name(self) -> str:
|
||||
"""获取消息发送者的名称。(可能会返回空字符串)"""
|
||||
sender = getattr(self.message_obj, "sender", None)
|
||||
if not sender:
|
||||
return ""
|
||||
nickname = getattr(sender, "nickname", None)
|
||||
if nickname is None:
|
||||
return ""
|
||||
if isinstance(nickname, str):
|
||||
return nickname
|
||||
return str(nickname)
|
||||
if isinstance(self.message_obj.sender.nickname, str):
|
||||
return self.message_obj.sender.nickname
|
||||
return ""
|
||||
|
||||
def set_extra(self, key, value) -> None:
|
||||
"""设置额外的信息。"""
|
||||
@@ -230,7 +208,7 @@ class AstrMessageEvent(abc.ABC):
|
||||
|
||||
def is_private_chat(self) -> bool:
|
||||
"""是否是私聊。"""
|
||||
return self.get_message_type() == MessageType.FRIEND_MESSAGE
|
||||
return self.message_obj.type.value == (MessageType.FRIEND_MESSAGE).value
|
||||
|
||||
def is_wake_up(self) -> bool:
|
||||
"""是否是唤醒机器人的事件。"""
|
||||
|
||||
@@ -45,19 +45,6 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
|
||||
if isinstance(segment, File):
|
||||
# For File segments, we need to handle the file differently
|
||||
d = await segment.to_dict()
|
||||
file_val = d.get("data", {}).get("file", "")
|
||||
if file_val:
|
||||
import pathlib
|
||||
|
||||
try:
|
||||
# 使用 pathlib 处理路径,能更好地处理 Windows/Linux 差异
|
||||
path_obj = pathlib.Path(file_val)
|
||||
# 如果是绝对路径且不包含协议头 (://),则转换为标准的 file: URI
|
||||
if path_obj.is_absolute() and "://" not in file_val:
|
||||
d["data"]["file"] = path_obj.as_uri()
|
||||
except Exception:
|
||||
# 如果不是合法路径(例如已经是特定的特殊字符串),则跳过转换
|
||||
pass
|
||||
return d
|
||||
if isinstance(segment, Video):
|
||||
d = await segment.to_dict()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import asyncio
|
||||
import inspect
|
||||
import itertools
|
||||
import logging
|
||||
import time
|
||||
@@ -437,42 +436,7 @@ class AiocqhttpAdapter(Platform):
|
||||
return coro
|
||||
|
||||
async def terminate(self) -> None:
|
||||
if hasattr(self, "shutdown_event"):
|
||||
self.shutdown_event.set()
|
||||
await self._close_reverse_ws_connections()
|
||||
|
||||
async def _close_reverse_ws_connections(self) -> None:
|
||||
api_clients = getattr(self.bot, "_wsr_api_clients", None)
|
||||
event_clients = getattr(self.bot, "_wsr_event_clients", None)
|
||||
|
||||
ws_clients: set[Any] = set()
|
||||
if isinstance(api_clients, dict):
|
||||
ws_clients.update(api_clients.values())
|
||||
if isinstance(event_clients, set):
|
||||
ws_clients.update(event_clients)
|
||||
|
||||
close_tasks: list[Awaitable[Any]] = []
|
||||
for ws in ws_clients:
|
||||
close_func = getattr(ws, "close", None)
|
||||
if not callable(close_func):
|
||||
continue
|
||||
try:
|
||||
close_result = close_func(code=1000, reason="Adapter shutdown")
|
||||
except TypeError:
|
||||
close_result = close_func()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if inspect.isawaitable(close_result):
|
||||
close_tasks.append(close_result)
|
||||
|
||||
if close_tasks:
|
||||
await asyncio.gather(*close_tasks, return_exceptions=True)
|
||||
|
||||
if isinstance(api_clients, dict):
|
||||
api_clients.clear()
|
||||
if isinstance(event_clients, set):
|
||||
event_clients.clear()
|
||||
self.shutdown_event.set()
|
||||
|
||||
async def shutdown_trigger_placeholder(self) -> None:
|
||||
await self.shutdown_event.wait()
|
||||
|
||||
@@ -7,14 +7,13 @@ from typing import cast
|
||||
|
||||
import aiofiles
|
||||
import botpy
|
||||
import botpy.errors
|
||||
import botpy.message
|
||||
import botpy.types
|
||||
import botpy.types.message
|
||||
from botpy import Client
|
||||
from botpy.http import Route
|
||||
from botpy.types import message
|
||||
from botpy.types.message import MarkdownPayload, Media
|
||||
from botpy.types.message import Media
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
@@ -25,29 +24,7 @@ from astrbot.core.utils.io import download_image_by_url, file_to_base64
|
||||
from astrbot.core.utils.tencent_record_helper import wav_to_tencent_silk
|
||||
|
||||
|
||||
def _patch_qq_botpy_formdata() -> None:
|
||||
"""Patch qq-botpy for aiohttp>=3.12 compatibility.
|
||||
|
||||
qq-botpy 1.2.1 defines botpy.http._FormData._gen_form_data() and expects
|
||||
aiohttp.FormData to have a private flag named _is_processed, which is no
|
||||
longer present in newer aiohttp versions.
|
||||
"""
|
||||
|
||||
try:
|
||||
from botpy.http import _FormData # type: ignore
|
||||
|
||||
if not hasattr(_FormData, "_is_processed"):
|
||||
setattr(_FormData, "_is_processed", False)
|
||||
except Exception:
|
||||
logger.debug("[QQOfficial] Skip botpy FormData patch.")
|
||||
|
||||
|
||||
_patch_qq_botpy_formdata()
|
||||
|
||||
|
||||
class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
MARKDOWN_NOT_ALLOWED_ERROR = "不允许发送原生 markdown"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message_str: str,
|
||||
@@ -137,9 +114,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
return None
|
||||
|
||||
payload: dict = {
|
||||
# "content": plain_text,
|
||||
"markdown": MarkdownPayload(content=plain_text) if plain_text else None,
|
||||
"msg_type": 2,
|
||||
"content": plain_text,
|
||||
"msg_id": self.message_obj.message_id,
|
||||
}
|
||||
|
||||
@@ -170,13 +145,9 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
)
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
ret = await self._send_with_markdown_fallback(
|
||||
send_func=lambda retry_payload: self.bot.api.post_group_message(
|
||||
group_openid=source.group_openid, # type: ignore
|
||||
**retry_payload,
|
||||
),
|
||||
payload=payload,
|
||||
plain_text=plain_text,
|
||||
ret = await self.bot.api.post_group_message(
|
||||
group_openid=source.group_openid,
|
||||
**payload,
|
||||
)
|
||||
|
||||
case botpy.message.C2CMessage():
|
||||
@@ -197,53 +168,30 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
if stream:
|
||||
ret = await self._send_with_markdown_fallback(
|
||||
send_func=lambda retry_payload: self.post_c2c_message(
|
||||
openid=source.author.user_openid,
|
||||
**retry_payload,
|
||||
stream=stream,
|
||||
),
|
||||
payload=payload,
|
||||
plain_text=plain_text,
|
||||
ret = await self.post_c2c_message(
|
||||
openid=source.author.user_openid,
|
||||
**payload,
|
||||
stream=stream,
|
||||
)
|
||||
else:
|
||||
ret = await self._send_with_markdown_fallback(
|
||||
send_func=lambda retry_payload: self.post_c2c_message(
|
||||
openid=source.author.user_openid,
|
||||
**retry_payload,
|
||||
),
|
||||
payload=payload,
|
||||
plain_text=plain_text,
|
||||
ret = await self.post_c2c_message(
|
||||
openid=source.author.user_openid,
|
||||
**payload,
|
||||
)
|
||||
logger.debug(f"Message sent to C2C: {ret}")
|
||||
|
||||
case botpy.message.Message():
|
||||
if image_path:
|
||||
payload["file_image"] = image_path
|
||||
# Guild text-channel send API (/channels/{channel_id}/messages) does not use v2 msg_type.
|
||||
payload.pop("msg_type", None)
|
||||
ret = await self._send_with_markdown_fallback(
|
||||
send_func=lambda retry_payload: self.bot.api.post_message(
|
||||
channel_id=source.channel_id,
|
||||
**retry_payload,
|
||||
),
|
||||
payload=payload,
|
||||
plain_text=plain_text,
|
||||
ret = await self.bot.api.post_message(
|
||||
channel_id=source.channel_id,
|
||||
**payload,
|
||||
)
|
||||
|
||||
case botpy.message.DirectMessage():
|
||||
if image_path:
|
||||
payload["file_image"] = image_path
|
||||
# Guild DM send API (/dms/{guild_id}/messages) does not use v2 msg_type.
|
||||
payload.pop("msg_type", None)
|
||||
ret = await self._send_with_markdown_fallback(
|
||||
send_func=lambda retry_payload: self.bot.api.post_dms(
|
||||
guild_id=source.guild_id,
|
||||
**retry_payload,
|
||||
),
|
||||
payload=payload,
|
||||
plain_text=plain_text,
|
||||
)
|
||||
ret = await self.bot.api.post_dms(guild_id=source.guild_id, **payload)
|
||||
|
||||
case _:
|
||||
pass
|
||||
@@ -254,32 +202,6 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
|
||||
return ret
|
||||
|
||||
async def _send_with_markdown_fallback(
|
||||
self,
|
||||
send_func,
|
||||
payload: dict,
|
||||
plain_text: str,
|
||||
):
|
||||
try:
|
||||
return await send_func(payload)
|
||||
except botpy.errors.ServerError as err:
|
||||
if (
|
||||
self.MARKDOWN_NOT_ALLOWED_ERROR not in str(err)
|
||||
or not payload.get("markdown")
|
||||
or not plain_text
|
||||
):
|
||||
raise
|
||||
|
||||
logger.warning(
|
||||
"[QQOfficial] markdown 发送被拒绝,回退到 content 模式重试。"
|
||||
)
|
||||
fallback_payload = payload.copy()
|
||||
fallback_payload["markdown"] = None
|
||||
fallback_payload["content"] = plain_text
|
||||
if fallback_payload.get("msg_type") == 2:
|
||||
fallback_payload["msg_type"] = 0
|
||||
return await send_func(fallback_payload)
|
||||
|
||||
async def upload_group_and_c2c_image(
|
||||
self,
|
||||
image_base64: str,
|
||||
|
||||
@@ -174,19 +174,14 @@ class TelegramPlatformAdapter(Platform):
|
||||
if not handler_metadata.enabled:
|
||||
continue
|
||||
for event_filter in handler_metadata.event_filters:
|
||||
cmd_info_list = self._extract_command_info(
|
||||
cmd_info = self._extract_command_info(
|
||||
event_filter,
|
||||
handler_metadata,
|
||||
skip_commands,
|
||||
)
|
||||
if cmd_info_list:
|
||||
for cmd_name, description in cmd_info_list:
|
||||
if cmd_name in command_dict:
|
||||
logger.warning(
|
||||
f"命令名 '{cmd_name}' 重复注册,将使用首次注册的定义: "
|
||||
f"'{command_dict[cmd_name]}'"
|
||||
)
|
||||
command_dict.setdefault(cmd_name, description)
|
||||
if cmd_info:
|
||||
cmd_name, description = cmd_info
|
||||
command_dict.setdefault(cmd_name, description)
|
||||
|
||||
commands_a = sorted(command_dict.keys())
|
||||
return [BotCommand(cmd, command_dict[cmd]) for cmd in commands_a]
|
||||
@@ -196,9 +191,9 @@ class TelegramPlatformAdapter(Platform):
|
||||
event_filter,
|
||||
handler_metadata,
|
||||
skip_commands: set,
|
||||
) -> list[tuple[str, str]] | None:
|
||||
"""从事件过滤器中提取指令信息,包括所有别名"""
|
||||
cmd_names = []
|
||||
) -> tuple[str, str] | None:
|
||||
"""从事件过滤器中提取指令信息"""
|
||||
cmd_name = None
|
||||
is_group = False
|
||||
if isinstance(event_filter, CommandFilter) and event_filter.command_name:
|
||||
if (
|
||||
@@ -206,32 +201,26 @@ class TelegramPlatformAdapter(Platform):
|
||||
and event_filter.parent_command_names != [""]
|
||||
):
|
||||
return None
|
||||
# 收集主命令名和所有别名
|
||||
cmd_names = [event_filter.command_name]
|
||||
if event_filter.alias:
|
||||
cmd_names.extend(event_filter.alias)
|
||||
cmd_name = event_filter.command_name
|
||||
elif isinstance(event_filter, CommandGroupFilter):
|
||||
if event_filter.parent_group:
|
||||
return None
|
||||
cmd_names = [event_filter.group_name]
|
||||
cmd_name = event_filter.group_name
|
||||
is_group = True
|
||||
|
||||
result = []
|
||||
for cmd_name in cmd_names:
|
||||
if not cmd_name or cmd_name in skip_commands:
|
||||
continue
|
||||
if not re.match(r"^[a-z0-9_]+$", cmd_name) or len(cmd_name) > 32:
|
||||
continue
|
||||
if not cmd_name or cmd_name in skip_commands:
|
||||
return None
|
||||
|
||||
# Build description.
|
||||
description = handler_metadata.desc or (
|
||||
f"Command group: {cmd_name}" if is_group else f"Command: {cmd_name}"
|
||||
)
|
||||
if len(description) > 30:
|
||||
description = description[:30] + "..."
|
||||
result.append((cmd_name, description))
|
||||
if not re.match(r"^[a-z0-9_]+$", cmd_name) or len(cmd_name) > 32:
|
||||
return None
|
||||
|
||||
return result if result else None
|
||||
# Build description.
|
||||
description = handler_metadata.desc or (
|
||||
f"指令组: {cmd_name} (包含多个子指令)" if is_group else f"指令: {cmd_name}"
|
||||
)
|
||||
if len(description) > 30:
|
||||
description = description[:30] + "..."
|
||||
return cmd_name, description
|
||||
|
||||
async def start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
if not update.effective_chat:
|
||||
|
||||
@@ -6,7 +6,6 @@ from typing import Any, cast
|
||||
import telegramify_markdown
|
||||
from telegram import ReactionTypeCustomEmoji, ReactionTypeEmoji
|
||||
from telegram.constants import ChatAction
|
||||
from telegram.error import BadRequest
|
||||
from telegram.ext import ExtBot
|
||||
|
||||
from astrbot import logger
|
||||
@@ -120,65 +119,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
client, user_name, ChatAction.TYPING, message_thread_id
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _send_voice_with_fallback(
|
||||
cls,
|
||||
client: ExtBot,
|
||||
path: str,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
caption: str | None = None,
|
||||
user_name: str = "",
|
||||
message_thread_id: str | None = None,
|
||||
use_media_action: bool = False,
|
||||
) -> None:
|
||||
"""Send a voice message, falling back to a document if the user's
|
||||
privacy settings forbid voice messages (``BadRequest`` with
|
||||
``Voice_messages_forbidden``).
|
||||
|
||||
When *use_media_action* is ``True`` the helper wraps the send calls
|
||||
with ``_send_media_with_action`` (used by the streaming path).
|
||||
"""
|
||||
try:
|
||||
if use_media_action:
|
||||
await cls._send_media_with_action(
|
||||
client,
|
||||
ChatAction.UPLOAD_VOICE,
|
||||
client.send_voice,
|
||||
user_name=user_name,
|
||||
message_thread_id=message_thread_id,
|
||||
voice=path,
|
||||
**cast(Any, payload),
|
||||
)
|
||||
else:
|
||||
await client.send_voice(voice=path, **cast(Any, payload))
|
||||
except BadRequest as e:
|
||||
# python-telegram-bot raises BadRequest for Voice_messages_forbidden;
|
||||
# distinguish the voice-privacy case via the API error message.
|
||||
if "Voice_messages_forbidden" not in e.message:
|
||||
raise
|
||||
logger.warning(
|
||||
"User privacy settings prevent receiving voice messages, falling back to sending an audio file. "
|
||||
"To enable voice messages, go to Telegram Settings → Privacy and Security → Voice Messages → set to 'Everyone'."
|
||||
)
|
||||
if use_media_action:
|
||||
await cls._send_media_with_action(
|
||||
client,
|
||||
ChatAction.UPLOAD_DOCUMENT,
|
||||
client.send_document,
|
||||
user_name=user_name,
|
||||
message_thread_id=message_thread_id,
|
||||
document=path,
|
||||
caption=caption,
|
||||
**cast(Any, payload),
|
||||
)
|
||||
else:
|
||||
await client.send_document(
|
||||
document=path,
|
||||
caption=caption,
|
||||
**cast(Any, payload),
|
||||
)
|
||||
|
||||
async def _ensure_typing(
|
||||
self,
|
||||
user_name: str,
|
||||
@@ -271,13 +211,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
)
|
||||
elif isinstance(i, Record):
|
||||
path = await i.convert_to_file_path()
|
||||
await cls._send_voice_with_fallback(
|
||||
client,
|
||||
path,
|
||||
payload,
|
||||
caption=i.text or None,
|
||||
use_media_action=False,
|
||||
)
|
||||
await client.send_voice(voice=path, **cast(Any, payload))
|
||||
|
||||
async def send(self, message: MessageChain) -> None:
|
||||
if self.get_message_type() == MessageType.GROUP_MESSAGE:
|
||||
@@ -396,14 +330,14 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
continue
|
||||
elif isinstance(i, Record):
|
||||
path = await i.convert_to_file_path()
|
||||
await self._send_voice_with_fallback(
|
||||
await self._send_media_with_action(
|
||||
self.client,
|
||||
path,
|
||||
payload,
|
||||
caption=i.text or delta or None,
|
||||
ChatAction.UPLOAD_VOICE,
|
||||
self.client.send_voice,
|
||||
user_name=user_name,
|
||||
message_thread_id=message_thread_id,
|
||||
use_media_action=True,
|
||||
voice=path,
|
||||
**cast(Any, payload),
|
||||
)
|
||||
continue
|
||||
else:
|
||||
|
||||
@@ -11,13 +11,13 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
from .webchat_queue_mgr import webchat_queue_mgr
|
||||
|
||||
attachments_dir = os.path.join(get_astrbot_data_path(), "attachments")
|
||||
imgs_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs")
|
||||
|
||||
|
||||
class WebChatMessageEvent(AstrMessageEvent):
|
||||
def __init__(self, message_str, message_obj, platform_meta, session_id) -> None:
|
||||
super().__init__(message_str, message_obj, platform_meta, session_id)
|
||||
os.makedirs(attachments_dir, exist_ok=True)
|
||||
os.makedirs(imgs_dir, exist_ok=True)
|
||||
|
||||
@staticmethod
|
||||
async def _send(
|
||||
@@ -69,7 +69,7 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
elif isinstance(comp, Image):
|
||||
# save image to local
|
||||
filename = f"{str(uuid.uuid4())}.jpg"
|
||||
path = os.path.join(attachments_dir, filename)
|
||||
path = os.path.join(imgs_dir, filename)
|
||||
image_base64 = await comp.convert_to_base64()
|
||||
with open(path, "wb") as f:
|
||||
f.write(base64.b64decode(image_base64))
|
||||
@@ -85,7 +85,7 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
elif isinstance(comp, Record):
|
||||
# save record to local
|
||||
filename = f"{str(uuid.uuid4())}.wav"
|
||||
path = os.path.join(attachments_dir, filename)
|
||||
path = os.path.join(imgs_dir, filename)
|
||||
record_base64 = await comp.convert_to_base64()
|
||||
with open(path, "wb") as f:
|
||||
f.write(base64.b64decode(record_base64))
|
||||
@@ -104,7 +104,7 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
original_name = comp.name or os.path.basename(file_path)
|
||||
ext = os.path.splitext(original_name)[1] or ""
|
||||
filename = f"{uuid.uuid4()!s}{ext}"
|
||||
dest_path = os.path.join(attachments_dir, filename)
|
||||
dest_path = os.path.join(imgs_dir, filename)
|
||||
shutil.copy2(file_path, dest_path)
|
||||
data = f"[FILE]{filename}"
|
||||
await web_chat_back_queue.put(
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, cast
|
||||
|
||||
import quart
|
||||
from requests import Response
|
||||
from wechatpy import WeChatClient, create_reply, parse_message
|
||||
from wechatpy import WeChatClient, parse_message
|
||||
from wechatpy.crypto import WeChatCrypto
|
||||
from wechatpy.exceptions import InvalidSignatureException
|
||||
from wechatpy.messages import BaseMessage, ImageMessage, TextMessage, VoiceMessage
|
||||
@@ -39,12 +38,7 @@ else:
|
||||
|
||||
|
||||
class WeixinOfficialAccountServer:
|
||||
def __init__(
|
||||
self,
|
||||
event_queue: asyncio.Queue,
|
||||
config: dict,
|
||||
user_buffer: dict[Any, dict[str, Any]],
|
||||
) -> None:
|
||||
def __init__(self, event_queue: asyncio.Queue, config: dict) -> None:
|
||||
self.server = quart.Quart(__name__)
|
||||
self.port = int(cast(int | str, config.get("port")))
|
||||
self.callback_server_host = config.get("callback_server_host", "0.0.0.0")
|
||||
@@ -68,10 +62,6 @@ class WeixinOfficialAccountServer:
|
||||
self.callback: Callable[[BaseMessage], Awaitable[None]] | None = None
|
||||
self.shutdown_event = asyncio.Event()
|
||||
|
||||
self._wx_msg_time_out = 4.0 # 微信服务器要求 5 秒内回复
|
||||
self.user_buffer: dict[str, dict[str, Any]] = user_buffer # from_user -> state
|
||||
self.active_send_mode = False # 是否启用主动发送模式,启用后 callback 将直接返回回复内容,无需等待微信回调
|
||||
|
||||
async def verify(self):
|
||||
"""内部服务器的 GET 验证入口"""
|
||||
return await self.handle_verify(quart.request)
|
||||
@@ -108,22 +98,6 @@ class WeixinOfficialAccountServer:
|
||||
"""内部服务器的 POST 回调入口"""
|
||||
return await self.handle_callback(quart.request)
|
||||
|
||||
def _maybe_encrypt(self, xml: str, nonce: str | None, timestamp: str | None) -> str:
|
||||
if xml and "<Encrypt>" not in xml and nonce and timestamp:
|
||||
return self.crypto.encrypt_message(xml, nonce, timestamp)
|
||||
return xml or "success"
|
||||
|
||||
def _preview(self, msg: BaseMessage, limit: int = 24) -> str:
|
||||
"""生成消息预览文本,供占位符使用"""
|
||||
if isinstance(msg, TextMessage):
|
||||
t = cast(str, msg.content).strip()
|
||||
return (t[:limit] + "...") if len(t) > limit else (t or "空消息")
|
||||
if isinstance(msg, ImageMessage):
|
||||
return "图片"
|
||||
if isinstance(msg, VoiceMessage):
|
||||
return "语音"
|
||||
return getattr(msg, "type", "未知消息")
|
||||
|
||||
async def handle_callback(self, request) -> str:
|
||||
"""处理回调请求,可被统一 webhook 入口复用
|
||||
|
||||
@@ -149,152 +123,14 @@ class WeixinOfficialAccountServer:
|
||||
raise
|
||||
logger.info(f"解析成功: {msg}")
|
||||
|
||||
if not self.callback:
|
||||
return "success"
|
||||
|
||||
# by pass passive reply logic and return active reply directly.
|
||||
if self.active_send_mode:
|
||||
if self.callback:
|
||||
result_xml = await self.callback(msg)
|
||||
if not result_xml:
|
||||
return "success"
|
||||
if isinstance(result_xml, str):
|
||||
return result_xml
|
||||
|
||||
# passive reply
|
||||
from_user = str(getattr(msg, "source", ""))
|
||||
msg_id = str(cast(str | int, getattr(msg, "id", "")))
|
||||
state = self.user_buffer.get(from_user)
|
||||
|
||||
def _reply_text(text: str) -> str:
|
||||
reply_obj = create_reply(text, msg)
|
||||
reply_xml = reply_obj if isinstance(reply_obj, str) else str(reply_obj)
|
||||
return self._maybe_encrypt(reply_xml, nonce, timestamp)
|
||||
|
||||
# if in cached state, return cached result or placeholder
|
||||
if state:
|
||||
logger.debug(f"用户消息缓冲状态: user={from_user} state={state}")
|
||||
cached = state.get("cached_xml")
|
||||
# send one cached each time, if cached is empty after pop, remove the buffer
|
||||
if cached and len(cached) > 0:
|
||||
logger.info(f"wx buffer hit on trigger: user={from_user}")
|
||||
cached_xml = cached.pop(0)
|
||||
if len(cached) == 0:
|
||||
self.user_buffer.pop(from_user, None)
|
||||
return _reply_text(cached_xml)
|
||||
else:
|
||||
return _reply_text(
|
||||
cached_xml
|
||||
+ "\n【后续消息还在缓冲中,回复任意文字继续获取】"
|
||||
)
|
||||
|
||||
task: asyncio.Task | None = cast(asyncio.Task | None, state.get("task"))
|
||||
placeholder = (
|
||||
f"【正在思考'{state.get('preview', '...')}'中,已思考"
|
||||
f"{int(time.monotonic() - state.get('started_at', time.monotonic()))}s,回复任意文字尝试获取回复】"
|
||||
)
|
||||
|
||||
# same msgid => WeChat retry: wait a little; new msgid => user trigger: just placeholder
|
||||
if task and state.get("msg_id") == msg_id:
|
||||
done, _ = await asyncio.wait(
|
||||
{task},
|
||||
timeout=self._wx_msg_time_out,
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
if done:
|
||||
try:
|
||||
cached = state.get("cached_xml")
|
||||
# send one cached each time, if cached is empty after pop, remove the buffer
|
||||
if cached and len(cached) > 0:
|
||||
logger.info(
|
||||
f"wx buffer hit on retry window: user={from_user}"
|
||||
)
|
||||
cached_xml = cached.pop(0)
|
||||
if len(cached) == 0:
|
||||
self.user_buffer.pop(from_user, None)
|
||||
logger.debug(
|
||||
f"wx finished message sending in passive window: user={from_user} msg_id={msg_id} "
|
||||
)
|
||||
return _reply_text(cached_xml)
|
||||
else:
|
||||
logger.debug(
|
||||
f"wx finished message sending in passive window but not final: user={from_user} msg_id={msg_id} "
|
||||
)
|
||||
return _reply_text(
|
||||
cached_xml
|
||||
+ "\n【后续消息还在缓冲中,回复任意文字继续获取】"
|
||||
)
|
||||
logger.info(
|
||||
f"wx finished in window but not final; return placeholder: user={from_user} msg_id={msg_id} "
|
||||
)
|
||||
return _reply_text(placeholder)
|
||||
except Exception:
|
||||
logger.critical(
|
||||
"wx task failed in passive window", exc_info=True
|
||||
)
|
||||
self.user_buffer.pop(from_user, None)
|
||||
return _reply_text("处理消息失败,请稍后再试。")
|
||||
|
||||
logger.info(
|
||||
f"wx passive window timeout: user={from_user} msg_id={msg_id}"
|
||||
)
|
||||
return _reply_text(placeholder)
|
||||
|
||||
logger.debug(f"wx trigger while thinking: user={from_user}")
|
||||
return _reply_text(placeholder)
|
||||
|
||||
# create new trigger when state is empty, and store state in buffer
|
||||
logger.debug(f"wx new trigger: user={from_user} msg_id={msg_id}")
|
||||
preview = self._preview(msg)
|
||||
placeholder = (
|
||||
f"【正在思考'{preview}'中,已思考0s,回复任意文字尝试获取回复】"
|
||||
)
|
||||
logger.info(
|
||||
f"wx start task: user={from_user} msg_id={msg_id} preview={preview}"
|
||||
)
|
||||
|
||||
self.user_buffer[from_user] = state = {
|
||||
"msg_id": msg_id,
|
||||
"preview": preview,
|
||||
"task": None, # set later after task created
|
||||
"cached_xml": [], # for passive reply
|
||||
"started_at": time.monotonic(),
|
||||
}
|
||||
self.user_buffer[from_user]["task"] = task = asyncio.create_task(
|
||||
self.callback(msg)
|
||||
)
|
||||
|
||||
# immediate return if done
|
||||
done, _ = await asyncio.wait(
|
||||
{task},
|
||||
timeout=self._wx_msg_time_out,
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
if done:
|
||||
try:
|
||||
cached = state.get("cached_xml", None)
|
||||
# send one cached each time, if cached is empty after pop, remove the buffer
|
||||
if cached and len(cached) > 0:
|
||||
logger.info(f"wx buffer hit immediately: user={from_user}")
|
||||
cached_xml = cached.pop(0)
|
||||
if len(cached) == 0:
|
||||
self.user_buffer.pop(from_user, None)
|
||||
return _reply_text(cached_xml)
|
||||
else:
|
||||
return _reply_text(
|
||||
cached_xml
|
||||
+ "\n【后续消息还在缓冲中,回复任意文字继续获取】"
|
||||
)
|
||||
logger.info(
|
||||
f"wx not finished in first window; return placeholder: user={from_user} msg_id={msg_id} "
|
||||
)
|
||||
return _reply_text(placeholder)
|
||||
except Exception:
|
||||
logger.critical("wx task failed in first window", exc_info=True)
|
||||
self.user_buffer.pop(from_user, None)
|
||||
return _reply_text("处理消息失败,请稍后再试。")
|
||||
|
||||
logger.info(f"wx first window timeout: user={from_user} msg_id={msg_id}")
|
||||
return _reply_text(placeholder)
|
||||
return "success"
|
||||
|
||||
async def start_polling(self) -> None:
|
||||
logger.info(
|
||||
@@ -340,10 +176,7 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
|
||||
if not self.api_base_url.endswith("/"):
|
||||
self.api_base_url += "/"
|
||||
|
||||
self.user_buffer: dict[str, dict[str, Any]] = {} # from_user -> state
|
||||
self.server = WeixinOfficialAccountServer(
|
||||
self._event_queue, self.config, self.user_buffer
|
||||
)
|
||||
self.server = WeixinOfficialAccountServer(self._event_queue, self.config)
|
||||
|
||||
self.client = WeChatClient(
|
||||
self.config["appid"].strip(),
|
||||
@@ -360,33 +193,28 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
|
||||
try:
|
||||
if self.active_send_mode:
|
||||
await self.convert_message(msg, None)
|
||||
return None
|
||||
|
||||
msg_id = str(cast(str | int, msg.id))
|
||||
future = self.wexin_event_workers.get(msg_id)
|
||||
if future:
|
||||
logger.debug(f"duplicate message id checked: {msg.id}")
|
||||
else:
|
||||
future = asyncio.get_event_loop().create_future()
|
||||
self.wexin_event_workers[msg_id] = future
|
||||
await self.convert_message(msg, future)
|
||||
if str(msg.id) in self.wexin_event_workers:
|
||||
future = self.wexin_event_workers[str(cast(str | int, msg.id))]
|
||||
logger.debug(f"duplicate message id checked: {msg.id}")
|
||||
else:
|
||||
future = asyncio.get_event_loop().create_future()
|
||||
self.wexin_event_workers[str(cast(str | int, msg.id))] = future
|
||||
await self.convert_message(msg, future)
|
||||
# I love shield so much!
|
||||
result = await asyncio.wait_for(
|
||||
asyncio.shield(future),
|
||||
180,
|
||||
) # wait for 180s
|
||||
logger.debug(f"Got future result: {result}")
|
||||
return result
|
||||
60,
|
||||
) # wait for 60s
|
||||
logger.debug(f"Got future result: {result}")
|
||||
self.wexin_event_workers.pop(str(cast(str | int, msg.id)), None)
|
||||
return result # xml. see weixin_offacc_event.py
|
||||
except asyncio.TimeoutError:
|
||||
logger.info(f"callback 处理消息超时: message_id={msg.id}")
|
||||
return create_reply("处理消息超时,请稍后再试。", msg)
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"转换消息时出现异常: {e}")
|
||||
finally:
|
||||
self.wexin_event_workers.pop(str(cast(str | int, msg.id)), None)
|
||||
|
||||
self.server.callback = callback
|
||||
self.server.active_send_mode = self.active_send_mode
|
||||
|
||||
@override
|
||||
async def send_by_session(
|
||||
@@ -508,19 +336,12 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
|
||||
await self.handle_msg(abm)
|
||||
|
||||
async def handle_msg(self, message: AstrBotMessage) -> None:
|
||||
buffer = self.user_buffer.get(message.sender.user_id, None)
|
||||
if buffer is None:
|
||||
logger.critical(
|
||||
f"用户消息未找到缓冲状态,无法处理消息: user={message.sender.user_id} message_id={message.message_id}"
|
||||
)
|
||||
return
|
||||
message_event = WeixinOfficialAccountPlatformEvent(
|
||||
message_str=message.message_str,
|
||||
message_obj=message,
|
||||
platform_meta=self.meta(),
|
||||
session_id=message.session_id,
|
||||
client=self.client,
|
||||
message_out=buffer,
|
||||
)
|
||||
self.commit_event(message_event)
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import asyncio
|
||||
import os
|
||||
from typing import Any, cast
|
||||
from typing import cast
|
||||
|
||||
from wechatpy import WeChatClient
|
||||
from wechatpy.replies import ImageReply, VoiceReply
|
||||
from wechatpy.replies import ImageReply, TextReply, VoiceReply
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
@@ -20,11 +20,9 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
|
||||
platform_meta: PlatformMetadata,
|
||||
session_id: str,
|
||||
client: WeChatClient,
|
||||
message_out: dict[Any, Any],
|
||||
) -> None:
|
||||
super().__init__(message_str, message_obj, platform_meta, session_id)
|
||||
self.client = client
|
||||
self.message_out = message_out
|
||||
|
||||
@staticmethod
|
||||
async def send_with_client(
|
||||
@@ -34,8 +32,8 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
async def split_plain(self, plain: str, max_length: int = 1024) -> list[str]:
|
||||
"""将长文本分割成多个小文本, 每个小文本长度不超过 max_length 字符
|
||||
async def split_plain(self, plain: str) -> list[str]:
|
||||
"""将长文本分割成多个小文本, 每个小文本长度不超过 2048 字符
|
||||
|
||||
Args:
|
||||
plain (str): 要分割的长文本
|
||||
@@ -43,18 +41,18 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
|
||||
list[str]: 分割后的文本列表
|
||||
|
||||
"""
|
||||
if len(plain) <= max_length:
|
||||
if len(plain) <= 2048:
|
||||
return [plain]
|
||||
result = []
|
||||
start = 0
|
||||
while start < len(plain):
|
||||
# 剩下的字符串长度<max_length时结束
|
||||
if start + max_length >= len(plain):
|
||||
# 剩下的字符串长度<2048时结束
|
||||
if start + 2048 >= len(plain):
|
||||
result.append(plain[start:])
|
||||
break
|
||||
|
||||
# 向前搜索分割标点符号
|
||||
end = min(start + max_length, len(plain))
|
||||
end = min(start + 2048, len(plain))
|
||||
cut_position = end
|
||||
for i in range(end, start, -1):
|
||||
if i < len(plain) and plain[i - 1] in [
|
||||
@@ -89,15 +87,19 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
|
||||
if isinstance(comp, Plain):
|
||||
# Split long text messages if needed
|
||||
plain_chunks = await self.split_plain(comp.text)
|
||||
if active_send_mode:
|
||||
for chunk in plain_chunks:
|
||||
for chunk in plain_chunks:
|
||||
if active_send_mode:
|
||||
self.client.message.send_text(message_obj.sender.user_id, chunk)
|
||||
else:
|
||||
# disable passive sending, just store the chunks in
|
||||
logger.debug(
|
||||
f"split plain into {len(plain_chunks)} chunks for passive reply. Message not sent."
|
||||
)
|
||||
self.message_out["cached_xml"] = plain_chunks
|
||||
else:
|
||||
reply = TextReply(
|
||||
content=chunk,
|
||||
message=cast(dict, self.message_obj.raw_message)["message"],
|
||||
)
|
||||
xml = reply.render()
|
||||
future = cast(dict, self.message_obj.raw_message)["future"]
|
||||
assert isinstance(future, asyncio.Future)
|
||||
future.set_result(xml)
|
||||
await asyncio.sleep(0.5) # Avoid sending too fast
|
||||
elif isinstance(comp, Image):
|
||||
img_path = await comp.convert_to_file_path()
|
||||
|
||||
|
||||
@@ -295,16 +295,6 @@ class ProviderManager:
|
||||
from .sources.zhipu_source import ProviderZhipu as ProviderZhipu
|
||||
case "groq_chat_completion":
|
||||
from .sources.groq_source import ProviderGroq as ProviderGroq
|
||||
case "xai_chat_completion":
|
||||
from .sources.xai_source import ProviderXAI as ProviderXAI
|
||||
case "aihubmix_chat_completion":
|
||||
from .sources.oai_aihubmix_source import (
|
||||
ProviderAIHubMix as ProviderAIHubMix,
|
||||
)
|
||||
case "openrouter_chat_completion":
|
||||
from .sources.openrouter_source import (
|
||||
ProviderOpenRouter as ProviderOpenRouter,
|
||||
)
|
||||
case "anthropic_chat_completion":
|
||||
from .sources.anthropic_source import (
|
||||
ProviderAnthropic as ProviderAnthropic,
|
||||
|
||||
@@ -22,6 +22,7 @@ from astrbot.core.utils.network_utils import (
|
||||
)
|
||||
|
||||
from ..register import register_provider_adapter
|
||||
from .default import with_model_request_retry
|
||||
|
||||
|
||||
@register_provider_adapter(
|
||||
@@ -33,29 +34,20 @@ class ProviderAnthropic(Provider):
|
||||
self,
|
||||
provider_config,
|
||||
provider_settings,
|
||||
*,
|
||||
use_api_key: bool = True,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
provider_config,
|
||||
provider_settings,
|
||||
)
|
||||
|
||||
self.chosen_api_key: str = ""
|
||||
self.api_keys: list = super().get_keys()
|
||||
self.chosen_api_key = self.api_keys[0] if len(self.api_keys) > 0 else ""
|
||||
self.base_url = provider_config.get("api_base", "https://api.anthropic.com")
|
||||
self.timeout = provider_config.get("timeout", 120)
|
||||
if isinstance(self.timeout, str):
|
||||
self.timeout = int(self.timeout)
|
||||
self.thinking_config = provider_config.get("anth_thinking_config", {})
|
||||
|
||||
if use_api_key:
|
||||
self._init_api_key(provider_config)
|
||||
|
||||
self.set_model(provider_config.get("model", "unknown"))
|
||||
|
||||
def _init_api_key(self, provider_config: dict) -> None:
|
||||
self.chosen_api_key: str = ""
|
||||
self.api_keys: list = super().get_keys()
|
||||
self.chosen_api_key = self.api_keys[0] if len(self.api_keys) > 0 else ""
|
||||
self.client = AsyncAnthropic(
|
||||
api_key=self.chosen_api_key,
|
||||
timeout=self.timeout,
|
||||
@@ -63,27 +55,15 @@ class ProviderAnthropic(Provider):
|
||||
http_client=self._create_http_client(provider_config),
|
||||
)
|
||||
|
||||
self.thinking_config = provider_config.get("anth_thinking_config", {})
|
||||
|
||||
self.set_model(provider_config.get("model", "unknown"))
|
||||
|
||||
def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient | None:
|
||||
"""创建带代理的 HTTP 客户端"""
|
||||
proxy = provider_config.get("proxy", "")
|
||||
return create_proxy_client("Anthropic", proxy)
|
||||
|
||||
def _apply_thinking_config(self, payloads: dict) -> None:
|
||||
thinking_type = self.thinking_config.get("type", "")
|
||||
if thinking_type == "adaptive":
|
||||
payloads["thinking"] = {"type": "adaptive"}
|
||||
effort = self.thinking_config.get("effort", "")
|
||||
output_cfg = dict(payloads.get("output_config", {}))
|
||||
if effort:
|
||||
output_cfg["effort"] = effort
|
||||
if output_cfg:
|
||||
payloads["output_config"] = output_cfg
|
||||
elif not thinking_type and self.thinking_config.get("budget"):
|
||||
payloads["thinking"] = {
|
||||
"budget_tokens": self.thinking_config.get("budget"),
|
||||
"type": "enabled",
|
||||
}
|
||||
|
||||
def _prepare_payload(self, messages: list[dict]):
|
||||
"""准备 Anthropic API 的请求 payload
|
||||
|
||||
@@ -225,6 +205,7 @@ class ProviderAnthropic(Provider):
|
||||
if usage.output_tokens is not None:
|
||||
token_usage.output = usage.output_tokens
|
||||
|
||||
@with_model_request_retry()
|
||||
async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse:
|
||||
if tools:
|
||||
if tool_list := tools.get_func_desc_anthropic_style():
|
||||
@@ -234,7 +215,11 @@ class ProviderAnthropic(Provider):
|
||||
|
||||
if "max_tokens" not in payloads:
|
||||
payloads["max_tokens"] = 1024
|
||||
self._apply_thinking_config(payloads)
|
||||
if self.thinking_config.get("budget"):
|
||||
payloads["thinking"] = {
|
||||
"budget_tokens": self.thinking_config.get("budget"),
|
||||
"type": "enabled",
|
||||
}
|
||||
|
||||
try:
|
||||
completion = await self.client.messages.create(
|
||||
@@ -282,6 +267,10 @@ class ProviderAnthropic(Provider):
|
||||
|
||||
return llm_response
|
||||
|
||||
@with_model_request_retry()
|
||||
async def _create_message_stream(self, payloads: dict, extra_body: dict):
|
||||
return self.client.messages.stream(**payloads, extra_body=extra_body)
|
||||
|
||||
async def _query_stream(
|
||||
self,
|
||||
payloads: dict,
|
||||
@@ -304,11 +293,14 @@ class ProviderAnthropic(Provider):
|
||||
|
||||
if "max_tokens" not in payloads:
|
||||
payloads["max_tokens"] = 1024
|
||||
self._apply_thinking_config(payloads)
|
||||
if self.thinking_config.get("budget"):
|
||||
payloads["thinking"] = {
|
||||
"budget_tokens": self.thinking_config.get("budget"),
|
||||
"type": "enabled",
|
||||
}
|
||||
|
||||
async with self.client.messages.stream(
|
||||
**payloads, extra_body=extra_body
|
||||
) as stream:
|
||||
stream_ctx = await self._create_message_stream(payloads, extra_body)
|
||||
async with stream_ctx as stream:
|
||||
assert isinstance(stream, anthropic.AsyncMessageStream)
|
||||
async for event in stream:
|
||||
if event.type == "message_start":
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
from tenacity import (
|
||||
AsyncRetrying,
|
||||
retry,
|
||||
retry_if_exception_type,
|
||||
stop_after_attempt,
|
||||
wait_exponential,
|
||||
)
|
||||
|
||||
MODEL_REQUEST_RETRY_ATTEMPTS = 5
|
||||
MODEL_REQUEST_RETRY_WAIT_MAX_SECONDS = 15
|
||||
MODEL_REQUEST_RETRY_WAIT_MIN_SECONDS = 1
|
||||
MODEL_REQUEST_RETRY_WAIT_MULTIPLIER = 1
|
||||
|
||||
|
||||
def with_model_request_retry():
|
||||
return retry(
|
||||
retry=retry_if_exception_type(Exception),
|
||||
stop=stop_after_attempt(MODEL_REQUEST_RETRY_ATTEMPTS),
|
||||
wait=wait_exponential(
|
||||
multiplier=MODEL_REQUEST_RETRY_WAIT_MULTIPLIER,
|
||||
min=MODEL_REQUEST_RETRY_WAIT_MIN_SECONDS,
|
||||
max=MODEL_REQUEST_RETRY_WAIT_MAX_SECONDS,
|
||||
),
|
||||
reraise=True,
|
||||
)
|
||||
|
||||
|
||||
def get_model_request_async_retrying() -> AsyncRetrying:
|
||||
return AsyncRetrying(
|
||||
retry=retry_if_exception_type(Exception),
|
||||
stop=stop_after_attempt(MODEL_REQUEST_RETRY_ATTEMPTS),
|
||||
wait=wait_exponential(
|
||||
multiplier=MODEL_REQUEST_RETRY_WAIT_MULTIPLIER,
|
||||
min=MODEL_REQUEST_RETRY_WAIT_MIN_SECONDS,
|
||||
max=MODEL_REQUEST_RETRY_WAIT_MAX_SECONDS,
|
||||
),
|
||||
reraise=True,
|
||||
)
|
||||
@@ -48,9 +48,6 @@ class GeminiEmbeddingProvider(EmbeddingProvider):
|
||||
result = await self.client.models.embed_content(
|
||||
model=self.model,
|
||||
contents=text,
|
||||
config=types.EmbedContentConfig(
|
||||
output_dimensionality=self.get_dim(),
|
||||
),
|
||||
)
|
||||
assert result.embeddings is not None
|
||||
assert result.embeddings[0].values is not None
|
||||
@@ -64,9 +61,6 @@ class GeminiEmbeddingProvider(EmbeddingProvider):
|
||||
result = await self.client.models.embed_content(
|
||||
model=self.model,
|
||||
contents=cast(types.ContentListUnion, text),
|
||||
config=types.EmbedContentConfig(
|
||||
output_dimensionality=self.get_dim(),
|
||||
),
|
||||
)
|
||||
assert result.embeddings is not None
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from astrbot.core.utils.io import download_image_by_url
|
||||
from astrbot.core.utils.network_utils import is_connection_error, log_connection_failure
|
||||
|
||||
from ..register import register_provider_adapter
|
||||
from .default import get_model_request_async_retrying, with_model_request_retry
|
||||
|
||||
|
||||
class SuppressNonTextPartsWarning(logging.Filter):
|
||||
@@ -513,6 +514,7 @@ class ProviderGoogleGenAI(Provider):
|
||||
llm_response.reasoning_signature = base64.b64encode(ts).decode("utf-8")
|
||||
return MessageChain(chain=chain)
|
||||
|
||||
@with_model_request_retry()
|
||||
async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse:
|
||||
"""非流式请求 Gemini API"""
|
||||
system_instruction = next(
|
||||
@@ -601,6 +603,17 @@ class ProviderGoogleGenAI(Provider):
|
||||
self,
|
||||
payloads: dict,
|
||||
tools: ToolSet | None,
|
||||
) -> AsyncGenerator[LLMResponse, None]:
|
||||
async for attempt in get_model_request_async_retrying():
|
||||
with attempt:
|
||||
async for response in self._query_stream_once(payloads, tools):
|
||||
yield response
|
||||
return
|
||||
|
||||
async def _query_stream_once(
|
||||
self,
|
||||
payloads: dict,
|
||||
tools: ToolSet | None,
|
||||
) -> AsyncGenerator[LLMResponse, None]:
|
||||
"""流式请求 Gemini API"""
|
||||
system_instruction = next(
|
||||
@@ -759,18 +772,7 @@ class ProviderGoogleGenAI(Provider):
|
||||
|
||||
payloads = {"messages": context_query, "model": model}
|
||||
|
||||
retry = 10
|
||||
keys = self.api_keys.copy()
|
||||
|
||||
for _ in range(retry):
|
||||
try:
|
||||
return await self._query(payloads, func_tool)
|
||||
except APIError as e:
|
||||
if await self._handle_api_error(e, keys):
|
||||
continue
|
||||
break
|
||||
|
||||
raise Exception("请求失败。")
|
||||
return await self._query(payloads, func_tool)
|
||||
|
||||
async def text_chat_stream(
|
||||
self,
|
||||
@@ -814,18 +816,8 @@ class ProviderGoogleGenAI(Provider):
|
||||
|
||||
payloads = {"messages": context_query, "model": model}
|
||||
|
||||
retry = 10
|
||||
keys = self.api_keys.copy()
|
||||
|
||||
for _ in range(retry):
|
||||
try:
|
||||
async for response in self._query_stream(payloads, func_tool):
|
||||
yield response
|
||||
break
|
||||
except APIError as e:
|
||||
if await self._handle_api_error(e, keys):
|
||||
continue
|
||||
break
|
||||
async for response in self._query_stream(payloads, func_tool):
|
||||
yield response
|
||||
|
||||
async def get_models(self):
|
||||
try:
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
from ..register import register_provider_adapter
|
||||
from .openai_source import ProviderOpenAIOfficial
|
||||
|
||||
|
||||
@register_provider_adapter(
|
||||
"aihubmix_chat_completion", "AIHubMix Chat Completion Provider Adapter"
|
||||
)
|
||||
class ProviderAIHubMix(ProviderOpenAIOfficial):
|
||||
def __init__(
|
||||
self,
|
||||
provider_config: dict,
|
||||
provider_settings: dict,
|
||||
) -> None:
|
||||
super().__init__(provider_config, provider_settings)
|
||||
# Reference to: https://aihubmix.com/appstore
|
||||
# Use this code can enjoy 10% off prices for AIHubMix API calls.
|
||||
self.client._custom_headers["APP-Code"] = "KRLC5702" # type: ignore
|
||||
@@ -36,20 +36,12 @@ class OpenAIEmbeddingProvider(EmbeddingProvider):
|
||||
|
||||
async def get_embedding(self, text: str) -> list[float]:
|
||||
"""获取文本的嵌入"""
|
||||
embedding = await self.client.embeddings.create(
|
||||
input=text,
|
||||
model=self.model,
|
||||
dimensions=self.get_dim(),
|
||||
)
|
||||
embedding = await self.client.embeddings.create(input=text, model=self.model)
|
||||
return embedding.data[0].embedding
|
||||
|
||||
async def get_embeddings(self, text: list[str]) -> list[list[float]]:
|
||||
"""批量获取文本的嵌入"""
|
||||
embeddings = await self.client.embeddings.create(
|
||||
input=text,
|
||||
model=self.model,
|
||||
dimensions=self.get_dim(),
|
||||
)
|
||||
embeddings = await self.client.embeddings.create(input=text, model=self.model)
|
||||
return [item.embedding for item in embeddings.data]
|
||||
|
||||
def get_dim(self) -> int:
|
||||
|
||||
@@ -31,6 +31,7 @@ from astrbot.core.utils.network_utils import (
|
||||
from astrbot.core.utils.string_utils import normalize_and_dedupe_strings
|
||||
|
||||
from ..register import register_provider_adapter
|
||||
from .default import get_model_request_async_retrying, with_model_request_retry
|
||||
|
||||
|
||||
@register_provider_adapter(
|
||||
@@ -221,6 +222,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
except NotFoundError as e:
|
||||
raise Exception(f"获取模型列表失败:{e}")
|
||||
|
||||
@with_model_request_retry()
|
||||
async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse:
|
||||
if tools:
|
||||
model = payloads.get("model", "").lower()
|
||||
@@ -246,8 +248,6 @@ class ProviderOpenAIOfficial(Provider):
|
||||
if isinstance(custom_extra_body, dict):
|
||||
extra_body.update(custom_extra_body)
|
||||
|
||||
model = payloads.get("model", "").lower()
|
||||
|
||||
completion = await self.client.chat.completions.create(
|
||||
**payloads,
|
||||
stream=False,
|
||||
@@ -269,6 +269,17 @@ class ProviderOpenAIOfficial(Provider):
|
||||
self,
|
||||
payloads: dict,
|
||||
tools: ToolSet | None,
|
||||
) -> AsyncGenerator[LLMResponse, None]:
|
||||
async for attempt in get_model_request_async_retrying():
|
||||
with attempt:
|
||||
async for response in self._query_stream_once(payloads, tools):
|
||||
yield response
|
||||
return
|
||||
|
||||
async def _query_stream_once(
|
||||
self,
|
||||
payloads: dict,
|
||||
tools: ToolSet | None,
|
||||
) -> AsyncGenerator[LLMResponse, None]:
|
||||
"""流式查询API,逐步返回结果"""
|
||||
if tools:
|
||||
@@ -381,22 +392,13 @@ class ProviderOpenAIOfficial(Provider):
|
||||
plain string. This method handles both formats.
|
||||
|
||||
Args:
|
||||
raw_content: The raw content from LLM response, can be str, list, dict, or other.
|
||||
raw_content: The raw content from LLM response, can be str, list, or other.
|
||||
strip: Whether to strip whitespace from the result. Set to False for
|
||||
streaming chunks to preserve spaces between words.
|
||||
|
||||
Returns:
|
||||
Normalized plain text string.
|
||||
"""
|
||||
# Handle dict format (e.g., {"type": "text", "text": "..."})
|
||||
if isinstance(raw_content, dict):
|
||||
if "text" in raw_content:
|
||||
text_val = raw_content.get("text", "")
|
||||
return str(text_val) if text_val is not None else ""
|
||||
# For other dict formats, return empty string and log
|
||||
logger.warning(f"Unexpected dict format content: {raw_content}")
|
||||
return ""
|
||||
|
||||
if isinstance(raw_content, list):
|
||||
# Check if this looks like OpenAI content-part format
|
||||
# Only process if at least one item has {'type': 'text', 'text': ...} structure
|
||||
@@ -459,8 +461,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
return "".join(text_parts)
|
||||
return content
|
||||
|
||||
# Fallback for other types (int, float, etc.)
|
||||
return str(raw_content) if raw_content is not None else ""
|
||||
return str(raw_content)
|
||||
|
||||
async def _parse_openai_completion(
|
||||
self, completion: ChatCompletion, tools: ToolSet | None
|
||||
@@ -726,7 +727,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
extra_user_content_parts=None,
|
||||
**kwargs,
|
||||
) -> LLMResponse:
|
||||
payloads, context_query = await self._prepare_chat_payload(
|
||||
payloads, _ = await self._prepare_chat_payload(
|
||||
prompt,
|
||||
image_urls,
|
||||
contexts,
|
||||
@@ -738,47 +739,9 @@ class ProviderOpenAIOfficial(Provider):
|
||||
)
|
||||
|
||||
llm_response = None
|
||||
max_retries = 10
|
||||
available_api_keys = self.api_keys.copy()
|
||||
chosen_key = random.choice(available_api_keys)
|
||||
image_fallback_used = False
|
||||
|
||||
last_exception = None
|
||||
retry_cnt = 0
|
||||
for retry_cnt in range(max_retries):
|
||||
try:
|
||||
self.client.api_key = chosen_key
|
||||
llm_response = await self._query(payloads, func_tool)
|
||||
break
|
||||
except Exception as e:
|
||||
last_exception = e
|
||||
(
|
||||
success,
|
||||
chosen_key,
|
||||
available_api_keys,
|
||||
payloads,
|
||||
context_query,
|
||||
func_tool,
|
||||
image_fallback_used,
|
||||
) = await self._handle_api_error(
|
||||
e,
|
||||
payloads,
|
||||
context_query,
|
||||
func_tool,
|
||||
chosen_key,
|
||||
available_api_keys,
|
||||
retry_cnt,
|
||||
max_retries,
|
||||
image_fallback_used=image_fallback_used,
|
||||
)
|
||||
if success:
|
||||
break
|
||||
|
||||
if retry_cnt == max_retries - 1 or llm_response is None:
|
||||
logger.error(f"API 调用失败,重试 {max_retries} 次仍然失败。")
|
||||
if last_exception is None:
|
||||
raise Exception("未知错误")
|
||||
raise last_exception
|
||||
if self.api_keys:
|
||||
self.client.api_key = random.choice(self.api_keys)
|
||||
llm_response = await self._query(payloads, func_tool)
|
||||
return llm_response
|
||||
|
||||
async def text_chat_stream(
|
||||
@@ -794,7 +757,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
**kwargs,
|
||||
) -> AsyncGenerator[LLMResponse, None]:
|
||||
"""流式对话,与服务商交互并逐步返回结果"""
|
||||
payloads, context_query = await self._prepare_chat_payload(
|
||||
payloads, _ = await self._prepare_chat_payload(
|
||||
prompt,
|
||||
image_urls,
|
||||
contexts,
|
||||
@@ -804,48 +767,10 @@ class ProviderOpenAIOfficial(Provider):
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
max_retries = 10
|
||||
available_api_keys = self.api_keys.copy()
|
||||
chosen_key = random.choice(available_api_keys)
|
||||
image_fallback_used = False
|
||||
|
||||
last_exception = None
|
||||
retry_cnt = 0
|
||||
for retry_cnt in range(max_retries):
|
||||
try:
|
||||
self.client.api_key = chosen_key
|
||||
async for response in self._query_stream(payloads, func_tool):
|
||||
yield response
|
||||
break
|
||||
except Exception as e:
|
||||
last_exception = e
|
||||
(
|
||||
success,
|
||||
chosen_key,
|
||||
available_api_keys,
|
||||
payloads,
|
||||
context_query,
|
||||
func_tool,
|
||||
image_fallback_used,
|
||||
) = await self._handle_api_error(
|
||||
e,
|
||||
payloads,
|
||||
context_query,
|
||||
func_tool,
|
||||
chosen_key,
|
||||
available_api_keys,
|
||||
retry_cnt,
|
||||
max_retries,
|
||||
image_fallback_used=image_fallback_used,
|
||||
)
|
||||
if success:
|
||||
break
|
||||
|
||||
if retry_cnt == max_retries - 1:
|
||||
logger.error(f"API 调用失败,重试 {max_retries} 次仍然失败。")
|
||||
if last_exception is None:
|
||||
raise Exception("未知错误")
|
||||
raise last_exception
|
||||
if self.api_keys:
|
||||
self.client.api_key = random.choice(self.api_keys)
|
||||
async for response in self._query_stream(payloads, func_tool):
|
||||
yield response
|
||||
|
||||
async def _remove_image_from_context(self, contexts: list):
|
||||
"""从上下文中删除所有带有 image 的记录"""
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
from ..register import register_provider_adapter
|
||||
from .openai_source import ProviderOpenAIOfficial
|
||||
|
||||
|
||||
@register_provider_adapter(
|
||||
"openrouter_chat_completion", "OpenRouter Chat Completion Provider Adapter"
|
||||
)
|
||||
class ProviderOpenRouter(ProviderOpenAIOfficial):
|
||||
def __init__(
|
||||
self,
|
||||
provider_config: dict,
|
||||
provider_settings: dict,
|
||||
) -> None:
|
||||
super().__init__(provider_config, provider_settings)
|
||||
# Reference to: https://openrouter.ai/docs/api/reference/overview#headers
|
||||
self.client._custom_headers["HTTP-Referer"] = ( # type: ignore
|
||||
"https://github.com/AstrBotDevs/AstrBot"
|
||||
)
|
||||
self.client._custom_headers["X-TITLE"] = "AstrBot" # type: ignore
|
||||
@@ -7,14 +7,12 @@ import asyncio
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
from funasr_onnx import SenseVoiceSmall
|
||||
from funasr_onnx.utils.postprocess_utils import rich_transcription_postprocess
|
||||
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
from astrbot.core.utils.io import download_file
|
||||
from astrbot.core.utils.tencent_record_helper import tencent_silk_to_wav
|
||||
|
||||
@@ -52,9 +50,7 @@ class ProviderSenseVoiceSTTSelfHost(STTProvider):
|
||||
|
||||
async def get_timestamped_path(self) -> str:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
temp_dir = Path(get_astrbot_temp_path())
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
return str(temp_dir / timestamp)
|
||||
return os.path.join("data", "temp", f"{timestamp}")
|
||||
|
||||
async def _is_silk_file(self, file_path) -> bool:
|
||||
silk_header = b"SILK"
|
||||
|
||||
@@ -1,19 +1,68 @@
|
||||
# 兼容导出: Provider 从 provider 模块重新导出
|
||||
from astrbot.core import html_renderer
|
||||
from astrbot.core.provider import Provider
|
||||
from astrbot.core.star.star_tools import StarTools
|
||||
from astrbot.core.utils.command_parser import CommandParserMixin
|
||||
from astrbot.core.utils.plugin_kv_store import PluginKVStoreMixin
|
||||
|
||||
from .base import Star
|
||||
from .context import Context
|
||||
from .star import StarMetadata, star_map, star_registry
|
||||
from .star_manager import PluginManager
|
||||
from .star_tools import StarTools
|
||||
|
||||
__all__ = [
|
||||
"Context",
|
||||
"PluginManager",
|
||||
"Provider",
|
||||
"Star",
|
||||
"StarMetadata",
|
||||
"StarTools",
|
||||
"star_map",
|
||||
"star_registry",
|
||||
]
|
||||
|
||||
class Star(CommandParserMixin, PluginKVStoreMixin):
|
||||
"""所有插件(Star)的父类,所有插件都应该继承于这个类"""
|
||||
|
||||
author: str
|
||||
name: str
|
||||
|
||||
def __init__(self, context: Context, config: dict | None = None) -> None:
|
||||
StarTools.initialize(context)
|
||||
self.context = context
|
||||
|
||||
def __init_subclass__(cls, **kwargs):
|
||||
super().__init_subclass__(**kwargs)
|
||||
if not star_map.get(cls.__module__):
|
||||
metadata = StarMetadata(
|
||||
star_cls_type=cls,
|
||||
module_path=cls.__module__,
|
||||
)
|
||||
star_map[cls.__module__] = metadata
|
||||
star_registry.append(metadata)
|
||||
else:
|
||||
star_map[cls.__module__].star_cls_type = cls
|
||||
star_map[cls.__module__].module_path = cls.__module__
|
||||
|
||||
async def text_to_image(self, text: str, return_url=True) -> str:
|
||||
"""将文本转换为图片"""
|
||||
return await html_renderer.render_t2i(
|
||||
text,
|
||||
return_url=return_url,
|
||||
template_name=self.context._config.get("t2i_active_template"),
|
||||
)
|
||||
|
||||
async def html_render(
|
||||
self,
|
||||
tmpl: str,
|
||||
data: dict,
|
||||
return_url=True,
|
||||
options: dict | None = None,
|
||||
) -> str:
|
||||
"""渲染 HTML"""
|
||||
return await html_renderer.render_custom_template(
|
||||
tmpl,
|
||||
data,
|
||||
return_url=return_url,
|
||||
options=options,
|
||||
)
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""当插件被激活时会调用这个方法"""
|
||||
|
||||
async def terminate(self) -> None:
|
||||
"""当插件被禁用、重载插件时会调用这个方法"""
|
||||
|
||||
def __del__(self) -> None:
|
||||
"""[Deprecated] 当插件被禁用、重载插件时会调用这个方法"""
|
||||
|
||||
|
||||
__all__ = ["Context", "PluginManager", "Provider", "Star", "StarMetadata", "StarTools"]
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Protocol
|
||||
|
||||
from astrbot.core import html_renderer
|
||||
from astrbot.core.utils.command_parser import CommandParserMixin
|
||||
from astrbot.core.utils.plugin_kv_store import PluginKVStoreMixin
|
||||
|
||||
from .star import StarMetadata, star_map, star_registry
|
||||
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
|
||||
class Star(CommandParserMixin, PluginKVStoreMixin):
|
||||
"""所有插件(Star)的父类,所有插件都应该继承于这个类"""
|
||||
|
||||
author: str
|
||||
name: str
|
||||
|
||||
class _ContextLike(Protocol):
|
||||
def get_config(self, umo: str | None = None) -> Any: ...
|
||||
|
||||
def __init__(self, context: _ContextLike, config: dict | None = None) -> None:
|
||||
self.context = context
|
||||
|
||||
def _get_context_config(self) -> Any:
|
||||
get_config = getattr(self.context, "get_config", None)
|
||||
if callable(get_config):
|
||||
try:
|
||||
return get_config()
|
||||
except Exception as e:
|
||||
logger.debug(f"get_config() failed: {e}")
|
||||
return None
|
||||
return getattr(self.context, "_config", None)
|
||||
|
||||
def __init_subclass__(cls, **kwargs):
|
||||
super().__init_subclass__(**kwargs)
|
||||
if not star_map.get(cls.__module__):
|
||||
metadata = StarMetadata(
|
||||
star_cls_type=cls,
|
||||
module_path=cls.__module__,
|
||||
)
|
||||
star_map[cls.__module__] = metadata
|
||||
star_registry.append(metadata)
|
||||
else:
|
||||
star_map[cls.__module__].star_cls_type = cls
|
||||
star_map[cls.__module__].module_path = cls.__module__
|
||||
|
||||
async def text_to_image(self, text: str, return_url=True) -> str:
|
||||
"""将文本转换为图片"""
|
||||
config_obj = self._get_context_config()
|
||||
template_name = None
|
||||
if hasattr(config_obj, "get"):
|
||||
try:
|
||||
template_name = config_obj.get("t2i_active_template")
|
||||
except Exception:
|
||||
template_name = None
|
||||
return await html_renderer.render_t2i(
|
||||
text,
|
||||
return_url=return_url,
|
||||
template_name=template_name,
|
||||
)
|
||||
|
||||
async def html_render(
|
||||
self,
|
||||
tmpl: str,
|
||||
data: dict,
|
||||
return_url=True,
|
||||
options: dict | None = None,
|
||||
) -> str:
|
||||
"""渲染 HTML"""
|
||||
return await html_renderer.render_custom_template(
|
||||
tmpl,
|
||||
data,
|
||||
return_url=return_url,
|
||||
options=options,
|
||||
)
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""当插件被激活时会调用这个方法"""
|
||||
|
||||
async def terminate(self) -> None:
|
||||
"""当插件被禁用、重载插件时会调用这个方法"""
|
||||
|
||||
def __del__(self) -> None:
|
||||
"""[Deprecated] 当插件被禁用、重载插件时会调用这个方法"""
|
||||
@@ -1,9 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from asyncio import Queue
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import TYPE_CHECKING, Any, Protocol
|
||||
from typing import Any
|
||||
|
||||
from deprecated import deprecated
|
||||
|
||||
@@ -14,12 +12,14 @@ from astrbot.core.agent.tool import ToolSet
|
||||
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
|
||||
from astrbot.core.config.astrbot_config import AstrBotConfig
|
||||
from astrbot.core.conversation_mgr import ConversationManager
|
||||
from astrbot.core.cron.manager import CronJobManager
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.persona_mgr import PersonaManager
|
||||
from astrbot.core.platform import Platform
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent, MessageSesion
|
||||
from astrbot.core.platform.manager import PlatformManager
|
||||
from astrbot.core.platform_message_history_mgr import PlatformMessageHistoryManager
|
||||
from astrbot.core.provider.entities import LLMResponse, ProviderRequest, ProviderType
|
||||
from astrbot.core.provider.func_tool_manager import FunctionTool, FunctionToolManager
|
||||
@@ -45,15 +45,6 @@ from .star_handler import EventType, StarHandlerMetadata, star_handlers_registry
|
||||
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from astrbot.core.cron.manager import CronJobManager
|
||||
else:
|
||||
CronJobManager = Any
|
||||
|
||||
|
||||
class PlatformManagerProtocol(Protocol):
|
||||
platform_insts: list[Platform]
|
||||
|
||||
|
||||
class Context:
|
||||
"""暴露给插件的接口上下文。"""
|
||||
@@ -70,7 +61,7 @@ class Context:
|
||||
config: AstrBotConfig,
|
||||
db: BaseDatabase,
|
||||
provider_manager: ProviderManager,
|
||||
platform_manager: PlatformManagerProtocol,
|
||||
platform_manager: PlatformManager,
|
||||
conversation_manager: ConversationManager,
|
||||
message_history_manager: PlatformMessageHistoryManager,
|
||||
persona_manager: PersonaManager,
|
||||
@@ -457,9 +448,6 @@ class Context:
|
||||
if platform.meta().id == session.platform_name:
|
||||
await platform.send_by_session(session, message_chain)
|
||||
return True
|
||||
logger.warning(
|
||||
f"cannot find platform for session {str(session)}, message not sent"
|
||||
)
|
||||
return False
|
||||
|
||||
def add_llm_tools(self, *tools: FunctionTool) -> None:
|
||||
|
||||
@@ -11,7 +11,6 @@ class PlatformAdapterType(enum.Flag):
|
||||
QQOFFICIAL = enum.auto()
|
||||
TELEGRAM = enum.auto()
|
||||
WECOM = enum.auto()
|
||||
WECOM_AI_BOT = enum.auto()
|
||||
LARK = enum.auto()
|
||||
DINGTALK = enum.auto()
|
||||
DISCORD = enum.auto()
|
||||
@@ -27,7 +26,6 @@ class PlatformAdapterType(enum.Flag):
|
||||
| QQOFFICIAL
|
||||
| TELEGRAM
|
||||
| WECOM
|
||||
| WECOM_AI_BOT
|
||||
| LARK
|
||||
| DINGTALK
|
||||
| DISCORD
|
||||
@@ -46,7 +44,6 @@ ADAPTER_NAME_2_TYPE = {
|
||||
"qq_official": PlatformAdapterType.QQOFFICIAL,
|
||||
"telegram": PlatformAdapterType.TELEGRAM,
|
||||
"wecom": PlatformAdapterType.WECOM,
|
||||
"wecom_ai_bot": PlatformAdapterType.WECOM_AI_BOT,
|
||||
"lark": PlatformAdapterType.LARK,
|
||||
"dingtalk": PlatformAdapterType.DINGTALK,
|
||||
"discord": PlatformAdapterType.DISCORD,
|
||||
|
||||
@@ -13,9 +13,6 @@ from .star_handler import (
|
||||
register_on_llm_response,
|
||||
register_on_llm_tool_respond,
|
||||
register_on_platform_loaded,
|
||||
register_on_plugin_error,
|
||||
register_on_plugin_loaded,
|
||||
register_on_plugin_unloaded,
|
||||
register_on_using_llm_tool,
|
||||
register_on_waiting_llm_request,
|
||||
register_permission_type,
|
||||
@@ -35,9 +32,6 @@ __all__ = [
|
||||
"register_on_decorating_result",
|
||||
"register_on_llm_request",
|
||||
"register_on_llm_response",
|
||||
"register_on_plugin_error",
|
||||
"register_on_plugin_loaded",
|
||||
"register_on_plugin_unloaded",
|
||||
"register_on_platform_loaded",
|
||||
"register_on_waiting_llm_request",
|
||||
"register_permission_type",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import warnings
|
||||
|
||||
from astrbot.core.star.star import StarMetadata, star_map
|
||||
from astrbot.core.star import StarMetadata, star_map
|
||||
|
||||
_warned_register_star = False
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from astrbot.core.agent.agent import Agent
|
||||
from astrbot.core.agent.handoff import HandoffTool
|
||||
from astrbot.core.agent.hooks import BaseAgentRunHooks
|
||||
from astrbot.core.agent.tool import FunctionTool
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
from astrbot.core.message.message_event_result import MessageEventResult
|
||||
from astrbot.core.provider.func_tool_manager import PY_TO_JSON_TYPE, SUPPORTED_TYPES
|
||||
from astrbot.core.provider.register import llm_tools
|
||||
@@ -338,58 +339,6 @@ def register_on_platform_loaded(**kwargs):
|
||||
return decorator
|
||||
|
||||
|
||||
def register_on_plugin_error(**kwargs):
|
||||
"""当插件处理消息异常时触发。
|
||||
|
||||
Hook 参数:
|
||||
event, plugin_name, handler_name, error, traceback_text
|
||||
|
||||
说明:
|
||||
在 hook 中调用 `event.stop_event()` 可屏蔽默认报错回显,
|
||||
并由插件自行决定是否转发到其他会话。
|
||||
"""
|
||||
|
||||
def decorator(awaitable):
|
||||
_ = get_handler_or_create(awaitable, EventType.OnPluginErrorEvent, **kwargs)
|
||||
return awaitable
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def register_on_plugin_loaded(**kwargs):
|
||||
"""当有插件加载完成时
|
||||
|
||||
Hook 参数:
|
||||
metadata
|
||||
|
||||
说明:
|
||||
当有插件加载完成时,触发该事件并获取到该插件的元数据
|
||||
"""
|
||||
|
||||
def decorator(awaitable):
|
||||
_ = get_handler_or_create(awaitable, EventType.OnPluginLoadedEvent, **kwargs)
|
||||
return awaitable
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def register_on_plugin_unloaded(**kwargs):
|
||||
"""当有插件卸载完成时
|
||||
|
||||
Hook 参数:
|
||||
metadata
|
||||
|
||||
说明:
|
||||
当有插件卸载完成时,触发该事件并获取到该插件的元数据
|
||||
"""
|
||||
|
||||
def decorator(awaitable):
|
||||
_ = get_handler_or_create(awaitable, EventType.OnPluginUnloadedEvent, **kwargs)
|
||||
return awaitable
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def register_on_waiting_llm_request(**kwargs):
|
||||
"""当等待调用 LLM 时的通知事件(在获取锁之前)
|
||||
|
||||
@@ -616,7 +565,7 @@ class RegisteringAgent:
|
||||
kwargs["registering_agent"] = self
|
||||
return register_llm_tool(*args, **kwargs)
|
||||
|
||||
def __init__(self, agent: Agent[Any]) -> None:
|
||||
def __init__(self, agent: Agent[AstrAgentContext]) -> None:
|
||||
self._agent = agent
|
||||
|
||||
|
||||
@@ -624,7 +573,7 @@ def register_agent(
|
||||
name: str,
|
||||
instruction: str,
|
||||
tools: list[str | FunctionTool] | None = None,
|
||||
run_hooks: BaseAgentRunHooks[Any] | None = None,
|
||||
run_hooks: BaseAgentRunHooks[AstrAgentContext] | None = None,
|
||||
):
|
||||
"""注册一个 Agent
|
||||
|
||||
@@ -638,12 +587,12 @@ def register_agent(
|
||||
tools_ = tools or []
|
||||
|
||||
def decorator(awaitable: Callable[..., Awaitable[Any]]):
|
||||
AstrAgent = Agent[Any]
|
||||
AstrAgent = Agent[AstrAgentContext]
|
||||
agent = AstrAgent(
|
||||
name=name,
|
||||
instructions=instruction,
|
||||
tools=tools_,
|
||||
run_hooks=run_hooks or BaseAgentRunHooks[Any](),
|
||||
run_hooks=run_hooks or BaseAgentRunHooks[AstrAgentContext](),
|
||||
)
|
||||
handoff_tool = HandoffTool(agent=agent)
|
||||
handoff_tool.handler = awaitable
|
||||
|
||||
@@ -61,12 +61,6 @@ class StarMetadata:
|
||||
logo_path: str | None = None
|
||||
"""插件 Logo 的路径"""
|
||||
|
||||
support_platforms: list[str] = field(default_factory=list)
|
||||
"""插件声明支持的平台适配器 ID 列表(对应 ADAPTER_NAME_2_TYPE 的 key)"""
|
||||
|
||||
astrbot_version: str | None = None
|
||||
"""插件要求的 AstrBot 版本范围(PEP 440 specifier,如 >=4.13.0,<4.17.0)"""
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Plugin {self.name} ({self.version}) by {self.author}: {self.desc}"
|
||||
|
||||
|
||||
@@ -97,14 +97,6 @@ class StarHandlerRegistry(Generic[T]):
|
||||
plugins_name: list[str] | None = None,
|
||||
) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ...
|
||||
|
||||
@overload
|
||||
def get_handlers_by_event_type(
|
||||
self,
|
||||
event_type: Literal[EventType.OnPluginErrorEvent],
|
||||
only_activated=True,
|
||||
plugins_name: list[str] | None = None,
|
||||
) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ...
|
||||
|
||||
@overload
|
||||
def get_handlers_by_event_type(
|
||||
self,
|
||||
@@ -144,8 +136,6 @@ class StarHandlerRegistry(Generic[T]):
|
||||
not in (
|
||||
EventType.OnAstrBotLoadedEvent,
|
||||
EventType.OnPlatformLoadedEvent,
|
||||
EventType.OnPluginLoadedEvent,
|
||||
EventType.OnPluginUnloadedEvent,
|
||||
)
|
||||
and not plugin.reserved
|
||||
):
|
||||
@@ -202,9 +192,6 @@ class EventType(enum.Enum):
|
||||
OnUsingLLMToolEvent = enum.auto() # 使用 LLM 工具
|
||||
OnLLMToolRespondEvent = enum.auto() # 调用函数工具后
|
||||
OnAfterMessageSentEvent = enum.auto() # 发送消息后
|
||||
OnPluginErrorEvent = enum.auto() # 插件处理消息异常时
|
||||
OnPluginLoadedEvent = enum.auto() # 插件加载完成
|
||||
OnPluginUnloadedEvent = enum.auto() # 插件卸载完成
|
||||
|
||||
|
||||
H = TypeVar("H", bound=Callable[..., Any])
|
||||
|
||||
@@ -11,13 +11,10 @@ import traceback
|
||||
from types import ModuleType
|
||||
|
||||
import yaml
|
||||
from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
from astrbot.core import logger, pip_installer, sp
|
||||
from astrbot.core.agent.handoff import FunctionTool, HandoffTool
|
||||
from astrbot.core.config.astrbot_config import AstrBotConfig
|
||||
from astrbot.core.config.default import VERSION
|
||||
from astrbot.core.platform.register import unregister_platform_adapters_by_module
|
||||
from astrbot.core.provider.register import llm_tools
|
||||
from astrbot.core.utils.astrbot_path import (
|
||||
@@ -33,7 +30,7 @@ from .command_management import sync_command_configs
|
||||
from .context import Context
|
||||
from .filter.permission import PermissionType, PermissionTypeFilter
|
||||
from .star import star_map, star_registry
|
||||
from .star_handler import EventType, star_handlers_registry
|
||||
from .star_handler import star_handlers_registry
|
||||
from .updator import PluginUpdator
|
||||
|
||||
try:
|
||||
@@ -43,19 +40,12 @@ except ImportError:
|
||||
logger.warning("未安装 watchfiles,无法实现插件的热重载。")
|
||||
|
||||
|
||||
class PluginVersionIncompatibleError(Exception):
|
||||
"""Raised when plugin astrbot_version is incompatible with current AstrBot."""
|
||||
|
||||
|
||||
class PluginManager:
|
||||
def __init__(self, context: Context, config: AstrBotConfig) -> None:
|
||||
from .star_tools import StarTools
|
||||
|
||||
self.updator = PluginUpdator()
|
||||
|
||||
self.context = context
|
||||
self.context._star_manager = self # type: ignore
|
||||
StarTools.initialize(context)
|
||||
|
||||
self.config = config
|
||||
self.plugin_store_path = get_astrbot_plugin_path()
|
||||
@@ -278,58 +268,10 @@ class PluginManager:
|
||||
version=metadata["version"],
|
||||
repo=metadata["repo"] if "repo" in metadata else None,
|
||||
display_name=metadata.get("display_name", None),
|
||||
support_platforms=(
|
||||
[
|
||||
platform_id
|
||||
for platform_id in metadata["support_platforms"]
|
||||
if isinstance(platform_id, str)
|
||||
]
|
||||
if isinstance(metadata.get("support_platforms"), list)
|
||||
else []
|
||||
),
|
||||
astrbot_version=(
|
||||
metadata["astrbot_version"]
|
||||
if isinstance(metadata.get("astrbot_version"), str)
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
return metadata
|
||||
|
||||
@staticmethod
|
||||
def _validate_astrbot_version_specifier(
|
||||
version_spec: str | None,
|
||||
) -> tuple[bool, str | None]:
|
||||
if not version_spec:
|
||||
return True, None
|
||||
|
||||
normalized_spec = version_spec.strip()
|
||||
if not normalized_spec:
|
||||
return True, None
|
||||
|
||||
try:
|
||||
specifier = SpecifierSet(normalized_spec)
|
||||
except InvalidSpecifier:
|
||||
return (
|
||||
False,
|
||||
"astrbot_version 格式无效,请使用 PEP 440 版本范围格式,例如 >=4.16,<5。",
|
||||
)
|
||||
|
||||
try:
|
||||
current_version = Version(VERSION)
|
||||
except InvalidVersion:
|
||||
return (
|
||||
False,
|
||||
f"AstrBot 当前版本 {VERSION} 无法被解析,无法校验插件版本范围。",
|
||||
)
|
||||
|
||||
if current_version not in specifier:
|
||||
return (
|
||||
False,
|
||||
f"当前 AstrBot 版本为 {VERSION},不满足插件要求的 astrbot_version: {normalized_spec}",
|
||||
)
|
||||
return True, None
|
||||
|
||||
@staticmethod
|
||||
def _get_plugin_related_modules(
|
||||
plugin_root_dir: str,
|
||||
@@ -466,12 +408,7 @@ class PluginManager:
|
||||
|
||||
return result
|
||||
|
||||
async def load(
|
||||
self,
|
||||
specified_module_path=None,
|
||||
specified_dir_name=None,
|
||||
ignore_version_check: bool = False,
|
||||
):
|
||||
async def load(self, specified_module_path=None, specified_dir_name=None):
|
||||
"""载入插件。
|
||||
当 specified_module_path 或者 specified_dir_name 不为 None 时,只载入指定的插件。
|
||||
|
||||
@@ -532,19 +469,8 @@ class PluginManager:
|
||||
requirements_path=requirements_path,
|
||||
)
|
||||
except Exception as e:
|
||||
error_trace = traceback.format_exc()
|
||||
logger.error(error_trace)
|
||||
logger.error(traceback.format_exc())
|
||||
logger.error(f"插件 {root_dir_name} 导入失败。原因:{e!s}")
|
||||
fail_rec += f"加载 {root_dir_name} 插件时出现问题,原因 {e!s}。\n"
|
||||
self.failed_plugin_dict[root_dir_name] = {
|
||||
"error": str(e),
|
||||
"traceback": error_trace,
|
||||
}
|
||||
if path in star_map:
|
||||
logger.info("失败插件依旧在插件列表中,正在清理...")
|
||||
metadata = star_map.pop(path)
|
||||
if metadata in star_registry:
|
||||
star_registry.remove(metadata)
|
||||
continue
|
||||
|
||||
# 检查 _conf_schema.json
|
||||
@@ -581,37 +507,12 @@ class PluginManager:
|
||||
metadata.version = metadata_yaml.version
|
||||
metadata.repo = metadata_yaml.repo
|
||||
metadata.display_name = metadata_yaml.display_name
|
||||
metadata.support_platforms = metadata_yaml.support_platforms
|
||||
metadata.astrbot_version = metadata_yaml.astrbot_version
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"插件 {root_dir_name} 元数据载入失败: {e!s}。使用默认元数据。",
|
||||
)
|
||||
|
||||
if not ignore_version_check:
|
||||
is_valid, error_message = (
|
||||
self._validate_astrbot_version_specifier(
|
||||
metadata.astrbot_version,
|
||||
)
|
||||
)
|
||||
if not is_valid:
|
||||
raise PluginVersionIncompatibleError(
|
||||
error_message
|
||||
or "The plugin is not compatible with the current AstrBot version."
|
||||
)
|
||||
|
||||
logger.info(metadata)
|
||||
metadata.config = plugin_config
|
||||
p_name = (metadata.name or "unknown").lower().replace("/", "_")
|
||||
p_author = (metadata.author or "unknown").lower().replace("/", "_")
|
||||
plugin_id = f"{p_author}/{p_name}"
|
||||
|
||||
# 在实例化前注入类属性,保证插件 __init__ 可读取这些值
|
||||
if metadata.star_cls_type:
|
||||
setattr(metadata.star_cls_type, "name", p_name)
|
||||
setattr(metadata.star_cls_type, "author", p_author)
|
||||
setattr(metadata.star_cls_type, "plugin_id", plugin_id)
|
||||
|
||||
if path not in inactivated_plugins:
|
||||
# 只有没有禁用插件时才实例化插件类
|
||||
if plugin_config and metadata.star_cls_type:
|
||||
@@ -629,10 +530,17 @@ class PluginManager:
|
||||
context=self.context,
|
||||
)
|
||||
|
||||
if metadata.star_cls:
|
||||
setattr(metadata.star_cls, "name", p_name)
|
||||
setattr(metadata.star_cls, "author", p_author)
|
||||
setattr(metadata.star_cls, "plugin_id", plugin_id)
|
||||
p_name = (metadata.name or "unknown").lower().replace("/", "_")
|
||||
p_author = (
|
||||
(metadata.author or "unknown").lower().replace("/", "_")
|
||||
)
|
||||
setattr(metadata.star_cls, "name", p_name)
|
||||
setattr(metadata.star_cls, "author", p_author)
|
||||
setattr(
|
||||
metadata.star_cls,
|
||||
"plugin_id",
|
||||
f"{p_author}/{p_name}",
|
||||
)
|
||||
else:
|
||||
logger.info(f"插件 {metadata.name} 已被禁用。")
|
||||
|
||||
@@ -710,19 +618,6 @@ class PluginManager:
|
||||
)
|
||||
if not metadata:
|
||||
raise Exception(f"无法找到插件 {plugin_dir_path} 的元数据。")
|
||||
|
||||
if not ignore_version_check:
|
||||
is_valid, error_message = (
|
||||
self._validate_astrbot_version_specifier(
|
||||
metadata.astrbot_version,
|
||||
)
|
||||
)
|
||||
if not is_valid:
|
||||
raise PluginVersionIncompatibleError(
|
||||
error_message
|
||||
or "The plugin is not compatible with the current AstrBot version."
|
||||
)
|
||||
|
||||
metadata.star_cls = obj
|
||||
metadata.config = plugin_config
|
||||
metadata.module = module
|
||||
@@ -786,19 +681,6 @@ class PluginManager:
|
||||
if hasattr(metadata.star_cls, "initialize") and metadata.star_cls:
|
||||
await metadata.star_cls.initialize()
|
||||
|
||||
# 触发插件加载事件
|
||||
handlers = star_handlers_registry.get_handlers_by_event_type(
|
||||
EventType.OnPluginLoadedEvent,
|
||||
)
|
||||
for handler in handlers:
|
||||
try:
|
||||
logger.info(
|
||||
f"hook(on_plugin_loaded) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}",
|
||||
)
|
||||
await handler.handler(metadata)
|
||||
except Exception:
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
except BaseException as e:
|
||||
logger.error(f"----- 插件 {root_dir_name} 载入失败 -----")
|
||||
errors = traceback.format_exc()
|
||||
@@ -811,11 +693,6 @@ class PluginManager:
|
||||
"traceback": errors,
|
||||
}
|
||||
# 记录注册失败的插件名称,以便后续重载插件
|
||||
if path in star_map:
|
||||
logger.info("失败插件依旧在插件列表中,正在清理...")
|
||||
metadata = star_map.pop(path)
|
||||
if metadata in star_registry:
|
||||
star_registry.remove(metadata)
|
||||
|
||||
# 清除 pip.main 导致的多余的 logging handlers
|
||||
for handler in logging.root.handlers[:]:
|
||||
@@ -874,9 +751,7 @@ class PluginManager:
|
||||
f"清理安装失败插件配置失败: {plugin_config_path},原因: {e!s}",
|
||||
)
|
||||
|
||||
async def install_plugin(
|
||||
self, repo_url: str, proxy: str = "", ignore_version_check: bool = False
|
||||
):
|
||||
async def install_plugin(self, repo_url: str, proxy=""):
|
||||
"""从仓库 URL 安装插件
|
||||
|
||||
从指定的仓库 URL 下载并安装插件,然后加载该插件到系统中
|
||||
@@ -910,10 +785,7 @@ class PluginManager:
|
||||
|
||||
# reload the plugin
|
||||
dir_name = os.path.basename(plugin_path)
|
||||
success, error_message = await self.load(
|
||||
specified_dir_name=dir_name,
|
||||
ignore_version_check=ignore_version_check,
|
||||
)
|
||||
success, error_message = await self.load(specified_dir_name=dir_name)
|
||||
if not success:
|
||||
raise Exception(
|
||||
error_message
|
||||
@@ -1191,19 +1063,6 @@ class PluginManager:
|
||||
elif "terminate" in star_metadata.star_cls_type.__dict__:
|
||||
await star_metadata.star_cls.terminate()
|
||||
|
||||
# 触发插件卸载事件
|
||||
handlers = star_handlers_registry.get_handlers_by_event_type(
|
||||
EventType.OnPluginUnloadedEvent,
|
||||
)
|
||||
for handler in handlers:
|
||||
try:
|
||||
logger.info(
|
||||
f"hook(on_plugin_unloaded) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}",
|
||||
)
|
||||
await handler.handler(star_metadata)
|
||||
except Exception:
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
async def turn_on_plugin(self, plugin_name: str) -> None:
|
||||
plugin = self.context.get_registered_star(plugin_name)
|
||||
if plugin is None:
|
||||
@@ -1230,9 +1089,7 @@ class PluginManager:
|
||||
|
||||
await self.reload(plugin_name)
|
||||
|
||||
async def install_plugin_from_file(
|
||||
self, zip_file_path: str, ignore_version_check: bool = False
|
||||
):
|
||||
async def install_plugin_from_file(self, zip_file_path: str):
|
||||
dir_name = os.path.basename(zip_file_path).replace(".zip", "")
|
||||
dir_name = dir_name.removesuffix("-master").removesuffix("-main").lower()
|
||||
desti_dir = os.path.join(self.plugin_store_path, dir_name)
|
||||
@@ -1288,10 +1145,7 @@ class PluginManager:
|
||||
except BaseException as e:
|
||||
logger.warning(f"删除插件压缩包失败: {e!s}")
|
||||
# await self.reload()
|
||||
success, error_message = await self.load(
|
||||
specified_dir_name=dir_name,
|
||||
ignore_version_check=ignore_version_check,
|
||||
)
|
||||
success, error_message = await self.load(specified_dir_name=dir_name)
|
||||
if not success:
|
||||
raise Exception(
|
||||
error_message
|
||||
|
||||
@@ -148,8 +148,8 @@ class AstrBotUpdator(RepoZipUpdator):
|
||||
update_data = await self.fetch_release_info(self.ASTRBOT_RELEASE_API, latest)
|
||||
file_url = None
|
||||
|
||||
if os.environ.get("ASTRBOT_CLI") or os.environ.get("ASTRBOT_LAUNCHER"):
|
||||
raise Exception("不支持更新此方式启动的AstrBot") # 避免版本管理混乱
|
||||
if os.environ.get("ASTRBOT_CLI"):
|
||||
raise Exception("不支持更新CLI启动的AstrBot") # 避免版本管理混乱
|
||||
|
||||
if latest:
|
||||
latest_version = update_data[0]["tag_name"]
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from astrbot.core.platform import AstrMessageEvent
|
||||
|
||||
|
||||
class ActiveEventRegistry:
|
||||
"""维护 unified_msg_origin 到活跃事件的映射。
|
||||
|
||||
用于在 reset 等场景下终止该会话正在处理的事件。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._events: dict[str, set[AstrMessageEvent]] = defaultdict(set)
|
||||
|
||||
def register(self, event: AstrMessageEvent) -> None:
|
||||
self._events[event.unified_msg_origin].add(event)
|
||||
|
||||
def unregister(self, event: AstrMessageEvent) -> None:
|
||||
umo = event.unified_msg_origin
|
||||
self._events[umo].discard(event)
|
||||
if not self._events[umo]:
|
||||
del self._events[umo]
|
||||
|
||||
def stop_all(
|
||||
self,
|
||||
umo: str,
|
||||
exclude: AstrMessageEvent | None = None,
|
||||
) -> int:
|
||||
"""终止指定 UMO 的所有活跃事件。
|
||||
|
||||
Args:
|
||||
umo: 统一消息来源标识符。
|
||||
exclude: 需要排除的事件(通常是发起 reset 的事件本身)。
|
||||
|
||||
Returns:
|
||||
被终止的事件数量。
|
||||
"""
|
||||
count = 0
|
||||
for event in list(self._events.get(umo, [])):
|
||||
if event is not exclude:
|
||||
event.stop_event()
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def request_agent_stop_all(
|
||||
self,
|
||||
umo: str,
|
||||
exclude: AstrMessageEvent | None = None,
|
||||
) -> int:
|
||||
"""请求停止指定 UMO 的所有活跃事件中的 Agent 运行。
|
||||
|
||||
与 stop_all 不同,这里不会调用 event.stop_event(),
|
||||
因此不会中断事件传播,后续流程(如历史记录保存)仍可继续。
|
||||
"""
|
||||
count = 0
|
||||
for event in list(self._events.get(umo, [])):
|
||||
if event is not exclude:
|
||||
event.set_extra("agent_stop_requested", True)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
active_event_registry = ActiveEventRegistry()
|
||||
@@ -15,7 +15,7 @@ Skills 目录路径:固定为数据目录下的 skills 目录
|
||||
|
||||
import os
|
||||
|
||||
from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime
|
||||
from astrbot.core.utils.runtime_env import is_packaged_electron_runtime
|
||||
|
||||
|
||||
def get_astrbot_path() -> str:
|
||||
@@ -29,7 +29,7 @@ def get_astrbot_root() -> str:
|
||||
"""获取Astrbot根目录路径"""
|
||||
if path := os.environ.get("ASTRBOT_ROOT"):
|
||||
return os.path.realpath(path)
|
||||
if is_packaged_desktop_runtime():
|
||||
if is_packaged_electron_runtime():
|
||||
return os.path.realpath(os.path.join(os.path.expanduser("~"), ".astrbot"))
|
||||
return os.path.realpath(os.getcwd())
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import threading
|
||||
from collections import deque
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_site_packages_path
|
||||
from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime
|
||||
from astrbot.core.utils.runtime_env import is_packaged_electron_runtime
|
||||
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
@@ -35,7 +35,7 @@ def _get_pip_main():
|
||||
"pip module is unavailable "
|
||||
f"(sys.executable={sys.executable}, "
|
||||
f"frozen={getattr(sys, 'frozen', False)}, "
|
||||
f"ASTRBOT_DESKTOP_CLIENT={os.environ.get('ASTRBOT_DESKTOP_CLIENT')})"
|
||||
f"ASTRBOT_ELECTRON_CLIENT={os.environ.get('ASTRBOT_ELECTRON_CLIENT')})"
|
||||
) from exc
|
||||
|
||||
return pip_main
|
||||
@@ -556,7 +556,7 @@ class PipInstaller:
|
||||
args.extend(["--trusted-host", "mirrors.aliyun.com", "-i", index_url])
|
||||
|
||||
target_site_packages = None
|
||||
if is_packaged_desktop_runtime():
|
||||
if is_packaged_electron_runtime():
|
||||
target_site_packages = get_astrbot_site_packages_path()
|
||||
os.makedirs(target_site_packages, exist_ok=True)
|
||||
_prepend_sys_path(target_site_packages)
|
||||
@@ -582,7 +582,7 @@ class PipInstaller:
|
||||
|
||||
def prefer_installed_dependencies(self, requirements_path: str) -> None:
|
||||
"""优先使用已安装在插件 site-packages 中的依赖,不执行安装。"""
|
||||
if not is_packaged_desktop_runtime():
|
||||
if not is_packaged_electron_runtime():
|
||||
return
|
||||
|
||||
target_site_packages = get_astrbot_site_packages_path()
|
||||
|
||||
@@ -6,5 +6,5 @@ def is_frozen_runtime() -> bool:
|
||||
return bool(getattr(sys, "frozen", False))
|
||||
|
||||
|
||||
def is_packaged_desktop_runtime() -> bool:
|
||||
return is_frozen_runtime() and os.environ.get("ASTRBOT_DESKTOP_CLIENT") == "1"
|
||||
def is_packaged_electron_runtime() -> bool:
|
||||
return is_frozen_runtime() and os.environ.get("ASTRBOT_ELECTRON_CLIENT") == "1"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from .api_key import ApiKeyRoute
|
||||
from .auth import AuthRoute
|
||||
from .backup import BackupRoute
|
||||
from .chat import ChatRoute
|
||||
@@ -10,7 +9,6 @@ from .cron import CronRoute
|
||||
from .file import FileRoute
|
||||
from .knowledge_base import KnowledgeBaseRoute
|
||||
from .log import LogRoute
|
||||
from .open_api import OpenApiRoute
|
||||
from .persona import PersonaRoute
|
||||
from .platform import PlatformRoute
|
||||
from .plugin import PluginRoute
|
||||
@@ -23,7 +21,6 @@ from .tools import ToolsRoute
|
||||
from .update import UpdateRoute
|
||||
|
||||
__all__ = [
|
||||
"ApiKeyRoute",
|
||||
"AuthRoute",
|
||||
"BackupRoute",
|
||||
"ChatRoute",
|
||||
@@ -35,7 +32,6 @@ __all__ = [
|
||||
"FileRoute",
|
||||
"KnowledgeBaseRoute",
|
||||
"LogRoute",
|
||||
"OpenApiRoute",
|
||||
"PersonaRoute",
|
||||
"PlatformRoute",
|
||||
"PluginRoute",
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from quart import g, request
|
||||
|
||||
from astrbot.core.db import BaseDatabase
|
||||
|
||||
from .route import Response, Route, RouteContext
|
||||
|
||||
ALL_OPEN_API_SCOPES = ("chat", "config", "file", "im")
|
||||
|
||||
|
||||
class ApiKeyRoute(Route):
|
||||
def __init__(self, context: RouteContext, db: BaseDatabase) -> None:
|
||||
super().__init__(context)
|
||||
self.db = db
|
||||
self.routes = {
|
||||
"/apikey/list": ("GET", self.list_api_keys),
|
||||
"/apikey/create": ("POST", self.create_api_key),
|
||||
"/apikey/revoke": ("POST", self.revoke_api_key),
|
||||
"/apikey/delete": ("POST", self.delete_api_key),
|
||||
}
|
||||
self.register_routes()
|
||||
|
||||
@staticmethod
|
||||
def _normalize_utc(dt: datetime | None) -> datetime | None:
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
@classmethod
|
||||
def _serialize_datetime(cls, dt: datetime | None) -> str | None:
|
||||
normalized = cls._normalize_utc(dt)
|
||||
if normalized is None:
|
||||
return None
|
||||
return normalized.astimezone().isoformat()
|
||||
|
||||
@staticmethod
|
||||
def _hash_key(raw_key: str) -> str:
|
||||
return hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
raw_key.encode("utf-8"),
|
||||
b"astrbot_api_key",
|
||||
100_000,
|
||||
).hex()
|
||||
|
||||
@staticmethod
|
||||
def _serialize_api_key(key) -> dict:
|
||||
expires_at = ApiKeyRoute._normalize_utc(key.expires_at)
|
||||
return {
|
||||
"key_id": key.key_id,
|
||||
"name": key.name,
|
||||
"key_prefix": key.key_prefix,
|
||||
"scopes": key.scopes or [],
|
||||
"created_by": key.created_by,
|
||||
"created_at": ApiKeyRoute._serialize_datetime(key.created_at),
|
||||
"updated_at": ApiKeyRoute._serialize_datetime(key.updated_at),
|
||||
"last_used_at": ApiKeyRoute._serialize_datetime(key.last_used_at),
|
||||
"expires_at": ApiKeyRoute._serialize_datetime(key.expires_at),
|
||||
"revoked_at": ApiKeyRoute._serialize_datetime(key.revoked_at),
|
||||
"is_revoked": key.revoked_at is not None,
|
||||
"is_expired": bool(expires_at and expires_at < datetime.now(timezone.utc)),
|
||||
}
|
||||
|
||||
async def list_api_keys(self):
|
||||
keys = await self.db.list_api_keys()
|
||||
return (
|
||||
Response().ok(data=[self._serialize_api_key(key) for key in keys]).__dict__
|
||||
)
|
||||
|
||||
async def create_api_key(self):
|
||||
post_data = await request.json or {}
|
||||
|
||||
name = str(post_data.get("name", "")).strip() or "Untitled API Key"
|
||||
scopes = post_data.get("scopes")
|
||||
if scopes is None:
|
||||
normalized_scopes = list(ALL_OPEN_API_SCOPES)
|
||||
elif isinstance(scopes, list):
|
||||
normalized_scopes = [
|
||||
scope
|
||||
for scope in scopes
|
||||
if isinstance(scope, str) and scope in ALL_OPEN_API_SCOPES
|
||||
]
|
||||
normalized_scopes = list(dict.fromkeys(normalized_scopes))
|
||||
if not normalized_scopes:
|
||||
return Response().error("At least one valid scope is required").__dict__
|
||||
else:
|
||||
return Response().error("Invalid scopes").__dict__
|
||||
|
||||
expires_at = None
|
||||
expires_in_days = post_data.get("expires_in_days")
|
||||
if expires_in_days is not None:
|
||||
try:
|
||||
expires_in_days_int = int(expires_in_days)
|
||||
except (TypeError, ValueError):
|
||||
return Response().error("expires_in_days must be an integer").__dict__
|
||||
if expires_in_days_int <= 0:
|
||||
return (
|
||||
Response().error("expires_in_days must be greater than 0").__dict__
|
||||
)
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(
|
||||
days=expires_in_days_int
|
||||
)
|
||||
|
||||
raw_key = f"abk_{secrets.token_urlsafe(32)}"
|
||||
key_hash = self._hash_key(raw_key)
|
||||
key_prefix = raw_key[:12]
|
||||
created_by = g.get("username", "unknown")
|
||||
|
||||
api_key = await self.db.create_api_key(
|
||||
name=name,
|
||||
key_hash=key_hash,
|
||||
key_prefix=key_prefix,
|
||||
scopes=normalized_scopes, # type: ignore
|
||||
created_by=created_by,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
payload = self._serialize_api_key(api_key)
|
||||
payload["api_key"] = raw_key
|
||||
return Response().ok(data=payload).__dict__
|
||||
|
||||
async def revoke_api_key(self):
|
||||
post_data = await request.json or {}
|
||||
key_id = post_data.get("key_id")
|
||||
if not key_id:
|
||||
return Response().error("Missing key: key_id").__dict__
|
||||
|
||||
success = await self.db.revoke_api_key(key_id)
|
||||
if not success:
|
||||
return Response().error("API key not found").__dict__
|
||||
return Response().ok().__dict__
|
||||
|
||||
async def delete_api_key(self):
|
||||
post_data = await request.json or {}
|
||||
key_id = post_data.get("key_id")
|
||||
if not key_id:
|
||||
return Response().error("Missing key: key_id").__dict__
|
||||
|
||||
success = await self.db.delete_api_key(key_id)
|
||||
if not success:
|
||||
return Response().error("API key not found").__dict__
|
||||
return Response().ok().__dict__
|
||||
@@ -64,13 +64,11 @@ class AuthRoute(Route):
|
||||
new_pwd = post_data.get("new_password", None)
|
||||
new_username = post_data.get("new_username", None)
|
||||
if not new_pwd and not new_username:
|
||||
return Response().error("新用户名和新密码不能同时为空").__dict__
|
||||
return (
|
||||
Response().error("新用户名和新密码不能同时为空,你改了个寂寞").__dict__
|
||||
)
|
||||
|
||||
# Verify password confirmation
|
||||
if new_pwd:
|
||||
confirm_pwd = post_data.get("confirm_password", None)
|
||||
if confirm_pwd != new_pwd:
|
||||
return Response().error("两次输入的新密码不一致").__dict__
|
||||
self.config["dashboard"]["password"] = new_pwd
|
||||
if new_username:
|
||||
self.config["dashboard"]["username"] = new_username
|
||||
|
||||
@@ -13,9 +13,7 @@ from quart import g, make_response, request, send_file
|
||||
from astrbot.core import logger, sp
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.platform.message_type import MessageType
|
||||
from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr
|
||||
from astrbot.core.utils.active_event_registry import active_event_registry
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
from .route import Response, Route, RouteContext
|
||||
@@ -43,7 +41,6 @@ class ChatRoute(Route):
|
||||
"/chat/new_session": ("GET", self.new_session),
|
||||
"/chat/sessions": ("GET", self.get_sessions),
|
||||
"/chat/get_session": ("GET", self.get_session),
|
||||
"/chat/stop": ("POST", self.stop_session),
|
||||
"/chat/delete_session": ("GET", self.delete_webchat_session),
|
||||
"/chat/update_session_display_name": (
|
||||
"POST",
|
||||
@@ -55,9 +52,8 @@ class ChatRoute(Route):
|
||||
}
|
||||
self.core_lifecycle = core_lifecycle
|
||||
self.register_routes()
|
||||
self.attachments_dir = os.path.join(get_astrbot_data_path(), "attachments")
|
||||
self.legacy_img_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs")
|
||||
os.makedirs(self.attachments_dir, exist_ok=True)
|
||||
self.imgs_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs")
|
||||
os.makedirs(self.imgs_dir, exist_ok=True)
|
||||
|
||||
self.supported_imgs = ["jpg", "jpeg", "png", "gif", "webp"]
|
||||
self.conv_mgr = core_lifecycle.conversation_manager
|
||||
@@ -73,18 +69,9 @@ class ChatRoute(Route):
|
||||
return Response().error("Missing key: filename").__dict__
|
||||
|
||||
try:
|
||||
file_path = os.path.join(self.attachments_dir, os.path.basename(filename))
|
||||
file_path = os.path.join(self.imgs_dir, os.path.basename(filename))
|
||||
real_file_path = os.path.realpath(file_path)
|
||||
real_imgs_dir = os.path.realpath(self.attachments_dir)
|
||||
|
||||
if not os.path.exists(real_file_path):
|
||||
# try legacy
|
||||
file_path = os.path.join(
|
||||
self.legacy_img_dir, os.path.basename(filename)
|
||||
)
|
||||
if os.path.exists(file_path):
|
||||
real_file_path = os.path.realpath(file_path)
|
||||
real_imgs_dir = os.path.realpath(self.legacy_img_dir)
|
||||
real_imgs_dir = os.path.realpath(self.imgs_dir)
|
||||
|
||||
if not real_file_path.startswith(real_imgs_dir):
|
||||
return Response().error("Invalid file path").__dict__
|
||||
@@ -138,7 +125,7 @@ class ChatRoute(Route):
|
||||
else:
|
||||
attach_type = "file"
|
||||
|
||||
path = os.path.join(self.attachments_dir, filename)
|
||||
path = os.path.join(self.imgs_dir, filename)
|
||||
await file.save(path)
|
||||
|
||||
# 创建 attachment 记录
|
||||
@@ -215,13 +202,8 @@ class ChatRoute(Route):
|
||||
filename: 存储的文件名
|
||||
attach_type: 附件类型 (image, record, file, video)
|
||||
"""
|
||||
basename = os.path.basename(filename)
|
||||
candidate_paths = [
|
||||
os.path.join(self.attachments_dir, basename),
|
||||
os.path.join(self.legacy_img_dir, basename),
|
||||
]
|
||||
file_path = next((p for p in candidate_paths if os.path.exists(p)), None)
|
||||
if not file_path:
|
||||
file_path = os.path.join(self.imgs_dir, os.path.basename(filename))
|
||||
if not os.path.exists(file_path):
|
||||
return None
|
||||
|
||||
# guess mime type
|
||||
@@ -335,13 +317,10 @@ class ChatRoute(Route):
|
||||
)
|
||||
return record
|
||||
|
||||
async def chat(self, post_data: dict | None = None):
|
||||
async def chat(self):
|
||||
username = g.get("username", "guest")
|
||||
|
||||
if post_data is None:
|
||||
post_data = await request.json
|
||||
if post_data is None:
|
||||
return Response().error("Missing JSON body").__dict__
|
||||
post_data = await request.json
|
||||
if "message" not in post_data and "files" not in post_data:
|
||||
return Response().error("Missing key: message or files").__dict__
|
||||
|
||||
@@ -394,14 +373,6 @@ class ChatRoute(Route):
|
||||
agent_stats = {}
|
||||
refs = {}
|
||||
try:
|
||||
# Emit session_id first so clients can bind the stream immediately.
|
||||
session_info = {
|
||||
"type": "session_id",
|
||||
"data": None,
|
||||
"session_id": webchat_conv_id,
|
||||
}
|
||||
yield f"data: {json.dumps(session_info, ensure_ascii=False)}\n\n"
|
||||
|
||||
async with track_conversation(self.running_convs, webchat_conv_id):
|
||||
while True:
|
||||
try:
|
||||
@@ -474,13 +445,13 @@ class ChatRoute(Route):
|
||||
if tc_id in tool_calls:
|
||||
tool_calls[tc_id]["result"] = tcr.get("result")
|
||||
tool_calls[tc_id]["finished_ts"] = tcr.get("ts")
|
||||
accumulated_parts.append(
|
||||
{
|
||||
"type": "tool_call",
|
||||
"tool_calls": [tool_calls[tc_id]],
|
||||
}
|
||||
)
|
||||
tool_calls.pop(tc_id, None)
|
||||
accumulated_parts.append(
|
||||
{
|
||||
"type": "tool_call",
|
||||
"tool_calls": [tool_calls[tc_id]],
|
||||
}
|
||||
)
|
||||
tool_calls.pop(tc_id, None)
|
||||
elif chain_type == "reasoning":
|
||||
accumulated_reasoning += result_text
|
||||
elif streaming:
|
||||
@@ -611,36 +582,6 @@ class ChatRoute(Route):
|
||||
response.timeout = None # fix SSE auto disconnect issue
|
||||
return response
|
||||
|
||||
async def stop_session(self):
|
||||
"""Stop active agent runs for a session."""
|
||||
post_data = await request.json
|
||||
if post_data is None:
|
||||
return Response().error("Missing JSON body").__dict__
|
||||
|
||||
session_id = post_data.get("session_id")
|
||||
if not session_id:
|
||||
return Response().error("Missing key: session_id").__dict__
|
||||
|
||||
username = g.get("username", "guest")
|
||||
session = await self.db.get_platform_session_by_id(session_id)
|
||||
if not session:
|
||||
return Response().error(f"Session {session_id} not found").__dict__
|
||||
if session.creator != username:
|
||||
return Response().error("Permission denied").__dict__
|
||||
|
||||
message_type = (
|
||||
MessageType.GROUP_MESSAGE.value
|
||||
if session.is_group
|
||||
else MessageType.FRIEND_MESSAGE.value
|
||||
)
|
||||
umo = (
|
||||
f"{session.platform_id}:{message_type}:"
|
||||
f"{session.platform_id}!{username}!{session_id}"
|
||||
)
|
||||
stopped_count = active_event_registry.request_agent_stop_all(umo)
|
||||
|
||||
return Response().ok(data={"stopped_count": stopped_count}).__dict__
|
||||
|
||||
async def delete_webchat_session(self):
|
||||
"""Delete a Platform session and all its related data."""
|
||||
session_id = request.args.get("session_id")
|
||||
@@ -764,18 +705,23 @@ class ChatRoute(Route):
|
||||
# 获取可选的 platform_id 参数
|
||||
platform_id = request.args.get("platform_id")
|
||||
|
||||
sessions, _ = await self.db.get_platform_sessions_by_creator_paginated(
|
||||
sessions = await self.db.get_platform_sessions_by_creator(
|
||||
creator=username,
|
||||
platform_id=platform_id,
|
||||
page=1,
|
||||
page_size=100, # 暂时返回前100个
|
||||
exclude_project_sessions=True,
|
||||
)
|
||||
|
||||
# 转换为字典格式
|
||||
# 转换为字典格式,并添加项目信息
|
||||
# get_platform_sessions_by_creator 现在返回 list[dict] 包含 session 和项目字段
|
||||
sessions_data = []
|
||||
for item in sessions:
|
||||
session = item["session"]
|
||||
project_id = item["project_id"]
|
||||
|
||||
# 跳过属于项目的会话(在侧边栏对话列表中不显示)
|
||||
if project_id is not None:
|
||||
continue
|
||||
|
||||
sessions_data.append(
|
||||
{
|
||||
|
||||
@@ -1,388 +0,0 @@
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from quart import g, request
|
||||
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.message.components import File, Image, Plain, Record, Reply, Video
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.platform.message_session import MessageSesion
|
||||
|
||||
from .chat import ChatRoute
|
||||
from .route import Response, Route, RouteContext
|
||||
|
||||
|
||||
class OpenApiRoute(Route):
|
||||
def __init__(
|
||||
self,
|
||||
context: RouteContext,
|
||||
db: BaseDatabase,
|
||||
core_lifecycle: AstrBotCoreLifecycle,
|
||||
chat_route: ChatRoute,
|
||||
) -> None:
|
||||
super().__init__(context)
|
||||
self.db = db
|
||||
self.core_lifecycle = core_lifecycle
|
||||
self.platform_manager = core_lifecycle.platform_manager
|
||||
self.chat_route = chat_route
|
||||
|
||||
self.routes = {
|
||||
"/v1/chat": ("POST", self.chat_send),
|
||||
"/v1/chat/sessions": ("GET", self.get_chat_sessions),
|
||||
"/v1/configs": ("GET", self.get_chat_configs),
|
||||
"/v1/file": ("POST", self.upload_file),
|
||||
"/v1/im/message": ("POST", self.send_message),
|
||||
"/v1/im/bots": ("GET", self.get_bots),
|
||||
}
|
||||
self.register_routes()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_open_username(
|
||||
raw_username: str | None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
if raw_username is None:
|
||||
return None, "Missing key: username"
|
||||
username = str(raw_username).strip()
|
||||
if not username:
|
||||
return None, "username is empty"
|
||||
return username, None
|
||||
|
||||
def _get_chat_config_list(self) -> list[dict]:
|
||||
conf_list = self.core_lifecycle.astrbot_config_mgr.get_conf_list()
|
||||
|
||||
result = []
|
||||
for conf_info in conf_list:
|
||||
conf_id = str(conf_info.get("id", "")).strip()
|
||||
result.append(
|
||||
{
|
||||
"id": conf_id,
|
||||
"name": str(conf_info.get("name", "")).strip(),
|
||||
"path": str(conf_info.get("path", "")).strip(),
|
||||
"is_default": conf_id == "default",
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
def _resolve_chat_config_id(self, post_data: dict) -> tuple[str | None, str | None]:
|
||||
raw_config_id = post_data.get("config_id")
|
||||
raw_config_name = post_data.get("config_name")
|
||||
config_id = str(raw_config_id).strip() if raw_config_id is not None else ""
|
||||
config_name = (
|
||||
str(raw_config_name).strip() if raw_config_name is not None else ""
|
||||
)
|
||||
|
||||
if not config_id and not config_name:
|
||||
return None, None
|
||||
|
||||
conf_list = self._get_chat_config_list()
|
||||
conf_map = {item["id"]: item for item in conf_list}
|
||||
|
||||
if config_id:
|
||||
if config_id not in conf_map:
|
||||
return None, f"config_id not found: {config_id}"
|
||||
return config_id, None
|
||||
|
||||
if not config_name:
|
||||
return None, "config_name is empty"
|
||||
|
||||
matched = [item for item in conf_list if item["name"] == config_name]
|
||||
if not matched:
|
||||
return None, f"config_name not found: {config_name}"
|
||||
if len(matched) > 1:
|
||||
return (
|
||||
None,
|
||||
f"config_name is ambiguous, please use config_id: {config_name}",
|
||||
)
|
||||
|
||||
return matched[0]["id"], None
|
||||
|
||||
async def _ensure_chat_session(
|
||||
self,
|
||||
username: str,
|
||||
session_id: str,
|
||||
) -> str | None:
|
||||
session = await self.db.get_platform_session_by_id(session_id)
|
||||
if session:
|
||||
if session.creator != username:
|
||||
return "session_id belongs to another username"
|
||||
return None
|
||||
|
||||
try:
|
||||
await self.db.create_platform_session(
|
||||
creator=username,
|
||||
platform_id="webchat",
|
||||
session_id=session_id,
|
||||
is_group=0,
|
||||
)
|
||||
except Exception as e:
|
||||
# Handle rare race when same session_id is created concurrently.
|
||||
existing = await self.db.get_platform_session_by_id(session_id)
|
||||
if existing and existing.creator == username:
|
||||
return None
|
||||
logger.error("Failed to create chat session %s: %s", session_id, e)
|
||||
return f"Failed to create session: {e}"
|
||||
|
||||
return None
|
||||
|
||||
async def chat_send(self):
|
||||
post_data = await request.get_json(silent=True) or {}
|
||||
effective_username, username_err = self._resolve_open_username(
|
||||
post_data.get("username")
|
||||
)
|
||||
if username_err:
|
||||
return Response().error(username_err).__dict__
|
||||
if not effective_username:
|
||||
return Response().error("Invalid username").__dict__
|
||||
|
||||
raw_session_id = post_data.get("session_id", post_data.get("conversation_id"))
|
||||
session_id = str(raw_session_id).strip() if raw_session_id is not None else ""
|
||||
if not session_id:
|
||||
session_id = str(uuid4())
|
||||
post_data["session_id"] = session_id
|
||||
ensure_session_err = await self._ensure_chat_session(
|
||||
effective_username,
|
||||
session_id,
|
||||
)
|
||||
if ensure_session_err:
|
||||
return Response().error(ensure_session_err).__dict__
|
||||
|
||||
config_id, resolve_err = self._resolve_chat_config_id(post_data)
|
||||
if resolve_err:
|
||||
return Response().error(resolve_err).__dict__
|
||||
|
||||
original_username = g.get("username", "guest")
|
||||
g.username = effective_username
|
||||
if config_id:
|
||||
umo = f"webchat:FriendMessage:webchat!{effective_username}!{session_id}"
|
||||
try:
|
||||
if config_id == "default":
|
||||
await self.core_lifecycle.umop_config_router.delete_route(umo)
|
||||
else:
|
||||
await self.core_lifecycle.umop_config_router.update_route(
|
||||
umo, config_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to update chat config route for %s with %s: %s",
|
||||
umo,
|
||||
config_id,
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
return (
|
||||
Response()
|
||||
.error(f"Failed to update chat config route: {e}")
|
||||
.__dict__
|
||||
)
|
||||
try:
|
||||
return await self.chat_route.chat(post_data=post_data)
|
||||
finally:
|
||||
g.username = original_username
|
||||
|
||||
async def upload_file(self):
|
||||
return await self.chat_route.post_file()
|
||||
|
||||
async def get_chat_sessions(self):
|
||||
username, username_err = self._resolve_open_username(
|
||||
request.args.get("username")
|
||||
)
|
||||
if username_err:
|
||||
return Response().error(username_err).__dict__
|
||||
|
||||
assert username is not None # for type checker
|
||||
|
||||
try:
|
||||
page = int(request.args.get("page", 1))
|
||||
page_size = int(request.args.get("page_size", 20))
|
||||
except ValueError:
|
||||
return Response().error("page and page_size must be integers").__dict__
|
||||
|
||||
if page < 1:
|
||||
page = 1
|
||||
if page_size < 1:
|
||||
page_size = 1
|
||||
if page_size > 100:
|
||||
page_size = 100
|
||||
|
||||
platform_id = request.args.get("platform_id")
|
||||
|
||||
(
|
||||
paginated_sessions,
|
||||
total,
|
||||
) = await self.db.get_platform_sessions_by_creator_paginated(
|
||||
creator=username,
|
||||
platform_id=platform_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
exclude_project_sessions=True,
|
||||
)
|
||||
|
||||
sessions_data = []
|
||||
for item in paginated_sessions:
|
||||
session = item["session"]
|
||||
sessions_data.append(
|
||||
{
|
||||
"session_id": session.session_id,
|
||||
"platform_id": session.platform_id,
|
||||
"creator": session.creator,
|
||||
"display_name": session.display_name,
|
||||
"is_group": session.is_group,
|
||||
"created_at": session.created_at.astimezone().isoformat(),
|
||||
"updated_at": session.updated_at.astimezone().isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
data={
|
||||
"sessions": sessions_data,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": total,
|
||||
}
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
|
||||
async def get_chat_configs(self):
|
||||
conf_list = self._get_chat_config_list()
|
||||
return Response().ok(data={"configs": conf_list}).__dict__
|
||||
|
||||
async def _build_message_chain_from_payload(
|
||||
self,
|
||||
message_payload: str | list,
|
||||
) -> MessageChain:
|
||||
if isinstance(message_payload, str):
|
||||
text = message_payload.strip()
|
||||
if not text:
|
||||
raise ValueError("Message is empty")
|
||||
return MessageChain(chain=[Plain(text=text)])
|
||||
|
||||
if not isinstance(message_payload, list):
|
||||
raise ValueError("message must be a string or list")
|
||||
|
||||
components = []
|
||||
has_content = False
|
||||
|
||||
for part in message_payload:
|
||||
if not isinstance(part, dict):
|
||||
raise ValueError("message part must be an object")
|
||||
|
||||
part_type = str(part.get("type", "")).strip()
|
||||
if part_type == "plain":
|
||||
text = str(part.get("text", ""))
|
||||
if text:
|
||||
has_content = True
|
||||
components.append(Plain(text=text))
|
||||
continue
|
||||
|
||||
if part_type == "reply":
|
||||
message_id = part.get("message_id")
|
||||
if message_id is None:
|
||||
raise ValueError("reply part missing message_id")
|
||||
components.append(
|
||||
Reply(
|
||||
id=str(message_id),
|
||||
message_str=str(part.get("selected_text", "")),
|
||||
chain=[],
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if part_type not in {"image", "record", "file", "video"}:
|
||||
raise ValueError(f"unsupported message part type: {part_type}")
|
||||
|
||||
has_content = True
|
||||
file_path: Path | None = None
|
||||
resolved_type = part_type
|
||||
filename = str(part.get("filename", "")).strip()
|
||||
|
||||
attachment_id = part.get("attachment_id")
|
||||
if attachment_id:
|
||||
attachment = await self.db.get_attachment_by_id(str(attachment_id))
|
||||
if not attachment:
|
||||
raise ValueError(f"attachment not found: {attachment_id}")
|
||||
file_path = Path(attachment.path)
|
||||
resolved_type = attachment.type
|
||||
if not filename:
|
||||
filename = file_path.name
|
||||
else:
|
||||
raise ValueError(f"{part_type} part missing attachment_id")
|
||||
|
||||
if not file_path.exists():
|
||||
raise ValueError(f"file not found: {file_path!s}")
|
||||
|
||||
file_path_str = str(file_path.resolve())
|
||||
if resolved_type == "image":
|
||||
components.append(Image.fromFileSystem(file_path_str))
|
||||
elif resolved_type == "record":
|
||||
components.append(Record.fromFileSystem(file_path_str))
|
||||
elif resolved_type == "video":
|
||||
components.append(Video.fromFileSystem(file_path_str))
|
||||
else:
|
||||
components.append(
|
||||
File(name=filename or file_path.name, file=file_path_str)
|
||||
)
|
||||
|
||||
if not components or not has_content:
|
||||
raise ValueError("Message content is empty (reply only is not allowed)")
|
||||
|
||||
return MessageChain(chain=components)
|
||||
|
||||
async def send_message(self):
|
||||
post_data = await request.json or {}
|
||||
message_payload = post_data.get("message", {})
|
||||
umo = post_data.get("umo")
|
||||
|
||||
if message_payload is None:
|
||||
return Response().error("Missing key: message").__dict__
|
||||
if not umo:
|
||||
return Response().error("Missing key: umo").__dict__
|
||||
|
||||
try:
|
||||
session = MessageSesion.from_str(str(umo))
|
||||
except Exception as e:
|
||||
return Response().error(f"Invalid umo: {e}").__dict__
|
||||
|
||||
platform_id = session.platform_name
|
||||
platform_inst = next(
|
||||
(
|
||||
inst
|
||||
for inst in self.platform_manager.platform_insts
|
||||
if inst.meta().id == platform_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not platform_inst:
|
||||
return (
|
||||
Response()
|
||||
.error(f"Bot not found or not running for platform: {platform_id}")
|
||||
.__dict__
|
||||
)
|
||||
|
||||
try:
|
||||
message_chain = await self._build_message_chain_from_payload(
|
||||
message_payload
|
||||
)
|
||||
await platform_inst.send_by_session(session, message_chain)
|
||||
return Response().ok().__dict__
|
||||
except ValueError as e:
|
||||
return Response().error(str(e)).__dict__
|
||||
except Exception as e:
|
||||
logger.error(f"Open API send_message failed: {e}", exc_info=True)
|
||||
return Response().error(f"Failed to send message: {e}").__dict__
|
||||
|
||||
async def get_bots(self):
|
||||
bot_ids = []
|
||||
for platform in self.core_lifecycle.astrbot_config.get("platform", []):
|
||||
platform_id = platform.get("id") if isinstance(platform, dict) else None
|
||||
if (
|
||||
isinstance(platform_id, str)
|
||||
and platform_id
|
||||
and platform_id not in bot_ids
|
||||
):
|
||||
bot_ids.append(platform_id)
|
||||
return Response().ok(data={"bot_ids": bot_ids}).__dict__
|
||||
@@ -19,14 +19,8 @@ from astrbot.core.star.filter.command_group import CommandGroupFilter
|
||||
from astrbot.core.star.filter.permission import PermissionTypeFilter
|
||||
from astrbot.core.star.filter.regex import RegexFilter
|
||||
from astrbot.core.star.star_handler import EventType, star_handlers_registry
|
||||
from astrbot.core.star.star_manager import (
|
||||
PluginManager,
|
||||
PluginVersionIncompatibleError,
|
||||
)
|
||||
from astrbot.core.utils.astrbot_path import (
|
||||
get_astrbot_data_path,
|
||||
get_astrbot_temp_path,
|
||||
)
|
||||
from astrbot.core.star.star_manager import PluginManager
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
|
||||
from .route import Response, Route, RouteContext
|
||||
|
||||
@@ -52,7 +46,6 @@ class PluginRoute(Route):
|
||||
super().__init__(context)
|
||||
self.routes = {
|
||||
"/plugin/get": ("GET", self.get_plugins),
|
||||
"/plugin/check-compat": ("POST", self.check_plugin_compatibility),
|
||||
"/plugin/install": ("POST", self.install_plugin),
|
||||
"/plugin/install-upload": ("POST", self.install_plugin_upload),
|
||||
"/plugin/update": ("POST", self.update_plugin),
|
||||
@@ -80,32 +73,10 @@ class PluginRoute(Route):
|
||||
EventType.OnDecoratingResultEvent: "回复消息前",
|
||||
EventType.OnCallingFuncToolEvent: "函数工具",
|
||||
EventType.OnAfterMessageSentEvent: "发送消息后",
|
||||
EventType.OnPluginErrorEvent: "插件报错时",
|
||||
}
|
||||
|
||||
self._logo_cache = {}
|
||||
|
||||
async def check_plugin_compatibility(self):
|
||||
try:
|
||||
data = await request.get_json()
|
||||
version_spec = data.get("astrbot_version", "")
|
||||
is_valid, message = self.plugin_manager._validate_astrbot_version_specifier(
|
||||
version_spec
|
||||
)
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
{
|
||||
"compatible": is_valid,
|
||||
"message": message,
|
||||
"astrbot_version": version_spec,
|
||||
}
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
except Exception as e:
|
||||
return Response().error(str(e)).__dict__
|
||||
|
||||
async def reload_failed_plugins(self):
|
||||
if DEMO_MODE:
|
||||
return (
|
||||
@@ -146,7 +117,7 @@ class PluginRoute(Route):
|
||||
try:
|
||||
success, message = await self.plugin_manager.reload(plugin_name)
|
||||
if not success:
|
||||
return Response().error(message or "插件重载失败").__dict__
|
||||
return Response().error(message).__dict__
|
||||
return Response().ok(None, "重载成功。").__dict__
|
||||
except Exception as e:
|
||||
logger.error(f"/api/plugin/reload: {traceback.format_exc()}")
|
||||
@@ -224,11 +195,10 @@ class PluginRoute(Route):
|
||||
|
||||
def _build_registry_source(self, custom_url: str | None) -> RegistrySource:
|
||||
"""构建注册表源信息"""
|
||||
data_dir = get_astrbot_data_path()
|
||||
if custom_url:
|
||||
# 对自定义URL生成一个安全的文件名
|
||||
url_hash = hashlib.md5(custom_url.encode()).hexdigest()[:8]
|
||||
cache_file = os.path.join(data_dir, f"plugins_custom_{url_hash}.json")
|
||||
cache_file = f"data/plugins_custom_{url_hash}.json"
|
||||
|
||||
# 更安全的后缀处理方式
|
||||
if custom_url.endswith(".json"):
|
||||
@@ -238,7 +208,7 @@ class PluginRoute(Route):
|
||||
|
||||
urls = [custom_url]
|
||||
else:
|
||||
cache_file = os.path.join(data_dir, "plugins.json")
|
||||
cache_file = "data/plugins.json"
|
||||
md5_url = "https://api.soulter.top/astrbot/plugins-md5"
|
||||
urls = [
|
||||
"https://api.soulter.top/astrbot/plugins",
|
||||
@@ -374,8 +344,6 @@ class PluginRoute(Route):
|
||||
),
|
||||
"display_name": plugin.display_name,
|
||||
"logo": f"/api/file/{logo_url}" if logo_url else None,
|
||||
"support_platforms": plugin.support_platforms,
|
||||
"astrbot_version": plugin.astrbot_version,
|
||||
}
|
||||
# 检查是否为全空的幽灵插件
|
||||
if not any(
|
||||
@@ -470,7 +438,6 @@ class PluginRoute(Route):
|
||||
|
||||
post_data = await request.get_json()
|
||||
repo_url = post_data["url"]
|
||||
ignore_version_check = bool(post_data.get("ignore_version_check", False))
|
||||
|
||||
proxy: str = post_data.get("proxy", None)
|
||||
if proxy:
|
||||
@@ -478,23 +445,10 @@ class PluginRoute(Route):
|
||||
|
||||
try:
|
||||
logger.info(f"正在安装插件 {repo_url}")
|
||||
plugin_info = await self.plugin_manager.install_plugin(
|
||||
repo_url,
|
||||
proxy,
|
||||
ignore_version_check=ignore_version_check,
|
||||
)
|
||||
plugin_info = await self.plugin_manager.install_plugin(repo_url, proxy)
|
||||
# self.core_lifecycle.restart()
|
||||
logger.info(f"安装插件 {repo_url} 成功。")
|
||||
return Response().ok(plugin_info, "安装成功。").__dict__
|
||||
except PluginVersionIncompatibleError as e:
|
||||
return {
|
||||
"status": "warning",
|
||||
"message": str(e),
|
||||
"data": {
|
||||
"warning_type": "astrbot_version_incompatible",
|
||||
"can_ignore": True,
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(str(e)).__dict__
|
||||
@@ -510,32 +464,16 @@ class PluginRoute(Route):
|
||||
try:
|
||||
file = await request.files
|
||||
file = file["file"]
|
||||
form_data = await request.form
|
||||
ignore_version_check = (
|
||||
str(form_data.get("ignore_version_check", "false")).lower() == "true"
|
||||
)
|
||||
logger.info(f"正在安装用户上传的插件 {file.filename}")
|
||||
file_path = os.path.join(
|
||||
get_astrbot_temp_path(),
|
||||
f"plugin_upload_{file.filename}",
|
||||
)
|
||||
await file.save(file_path)
|
||||
plugin_info = await self.plugin_manager.install_plugin_from_file(
|
||||
file_path,
|
||||
ignore_version_check=ignore_version_check,
|
||||
)
|
||||
plugin_info = await self.plugin_manager.install_plugin_from_file(file_path)
|
||||
# self.core_lifecycle.restart()
|
||||
logger.info(f"安装插件 {file.filename} 成功")
|
||||
return Response().ok(plugin_info, "安装成功。").__dict__
|
||||
except PluginVersionIncompatibleError as e:
|
||||
return {
|
||||
"status": "warning",
|
||||
"message": str(e),
|
||||
"data": {
|
||||
"warning_type": "astrbot_version_incompatible",
|
||||
"can_ignore": True,
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(str(e)).__dict__
|
||||
@@ -698,16 +636,10 @@ class PluginRoute(Route):
|
||||
logger.warning(f"插件 {plugin_name} 目录不存在")
|
||||
return Response().error(f"插件 {plugin_name} 目录不存在").__dict__
|
||||
|
||||
if plugin_obj.reserved:
|
||||
plugin_dir = os.path.join(
|
||||
self.plugin_manager.reserved_plugin_path,
|
||||
plugin_obj.root_dir_name,
|
||||
)
|
||||
else:
|
||||
plugin_dir = os.path.join(
|
||||
self.plugin_manager.plugin_store_path,
|
||||
plugin_obj.root_dir_name,
|
||||
)
|
||||
plugin_dir = os.path.join(
|
||||
self.plugin_manager.plugin_store_path,
|
||||
plugin_obj.root_dir_name or "",
|
||||
)
|
||||
|
||||
if not os.path.isdir(plugin_dir):
|
||||
logger.warning(f"无法找到插件目录: {plugin_dir}")
|
||||
@@ -741,7 +673,6 @@ class PluginRoute(Route):
|
||||
logger.debug(f"正在获取插件 {plugin_name} 的更新日志")
|
||||
|
||||
if not plugin_name:
|
||||
logger.warning("插件名称为空")
|
||||
return Response().error("插件名称不能为空").__dict__
|
||||
|
||||
# 查找插件
|
||||
@@ -752,27 +683,15 @@ class PluginRoute(Route):
|
||||
break
|
||||
|
||||
if not plugin_obj:
|
||||
logger.warning(f"插件 {plugin_name} 不存在")
|
||||
return Response().error(f"插件 {plugin_name} 不存在").__dict__
|
||||
|
||||
if not plugin_obj.root_dir_name:
|
||||
logger.warning(f"插件 {plugin_name} 目录不存在")
|
||||
return Response().error(f"插件 {plugin_name} 目录不存在").__dict__
|
||||
|
||||
if plugin_obj.reserved:
|
||||
plugin_dir = os.path.join(
|
||||
self.plugin_manager.reserved_plugin_path,
|
||||
plugin_obj.root_dir_name,
|
||||
)
|
||||
else:
|
||||
plugin_dir = os.path.join(
|
||||
self.plugin_manager.plugin_store_path,
|
||||
plugin_obj.root_dir_name,
|
||||
)
|
||||
|
||||
if not os.path.isdir(plugin_dir):
|
||||
logger.warning(f"无法找到插件目录: {plugin_dir}")
|
||||
return Response().error(f"无法找到插件 {plugin_name} 的目录").__dict__
|
||||
plugin_dir = os.path.join(
|
||||
self.plugin_manager.plugin_store_path,
|
||||
plugin_obj.root_dir_name,
|
||||
)
|
||||
|
||||
# 尝试多种可能的文件名
|
||||
changelog_names = ["CHANGELOG.md", "changelog.md", "CHANGELOG", "changelog"]
|
||||
@@ -792,7 +711,6 @@ class PluginRoute(Route):
|
||||
return Response().error(f"读取更新日志失败: {e!s}").__dict__
|
||||
|
||||
# 没有找到 changelog 文件,返回 ok 但 content 为 null
|
||||
logger.warning(f"插件 {plugin_name} 没有更新日志文件")
|
||||
return Response().ok({"content": None}, "该插件没有更新日志文件").__dict__
|
||||
|
||||
async def get_custom_source(self):
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
@@ -22,7 +21,6 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.utils.io import get_local_ip_addresses
|
||||
|
||||
from .routes import *
|
||||
from .routes.api_key import ALL_OPEN_API_SCOPES
|
||||
from .routes.backup import BackupRoute
|
||||
from .routes.live_chat import LiveChatRoute
|
||||
from .routes.platform import PlatformRoute
|
||||
@@ -55,7 +53,6 @@ class AstrBotDashboard:
|
||||
) -> None:
|
||||
self.core_lifecycle = core_lifecycle
|
||||
self.config = core_lifecycle.astrbot_config
|
||||
self.db = db
|
||||
|
||||
# 参数指定webui目录
|
||||
if webui_dir and os.path.exists(webui_dir):
|
||||
@@ -91,14 +88,7 @@ class AstrBotDashboard:
|
||||
self.lr = LogRoute(self.context, core_lifecycle.log_broker)
|
||||
self.sfr = StaticFileRoute(self.context)
|
||||
self.ar = AuthRoute(self.context)
|
||||
self.api_key_route = ApiKeyRoute(self.context, db)
|
||||
self.chat_route = ChatRoute(self.context, db, core_lifecycle)
|
||||
self.open_api_route = OpenApiRoute(
|
||||
self.context,
|
||||
db,
|
||||
core_lifecycle,
|
||||
self.chat_route,
|
||||
)
|
||||
self.chatui_project_route = ChatUIProjectRoute(self.context, db)
|
||||
self.tools_root = ToolsRoute(self.context, core_lifecycle)
|
||||
self.subagent_route = SubAgentRoute(self.context, core_lifecycle)
|
||||
@@ -140,40 +130,6 @@ class AstrBotDashboard:
|
||||
async def auth_middleware(self):
|
||||
if not request.path.startswith("/api"):
|
||||
return None
|
||||
if request.path.startswith("/api/v1"):
|
||||
raw_key = self._extract_raw_api_key()
|
||||
if not raw_key:
|
||||
r = jsonify(Response().error("Missing API key").__dict__)
|
||||
r.status_code = 401
|
||||
return r
|
||||
key_hash = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
raw_key.encode("utf-8"),
|
||||
b"astrbot_api_key",
|
||||
100_000,
|
||||
).hex()
|
||||
api_key = await self.db.get_active_api_key_by_hash(key_hash)
|
||||
if not api_key:
|
||||
r = jsonify(Response().error("Invalid API key").__dict__)
|
||||
r.status_code = 401
|
||||
return r
|
||||
|
||||
if isinstance(api_key.scopes, list):
|
||||
scopes = api_key.scopes
|
||||
else:
|
||||
scopes = list(ALL_OPEN_API_SCOPES)
|
||||
required_scope = self._get_required_open_api_scope(request.path)
|
||||
if required_scope and "*" not in scopes and required_scope not in scopes:
|
||||
r = jsonify(Response().error("Insufficient API key scope").__dict__)
|
||||
r.status_code = 403
|
||||
return r
|
||||
|
||||
g.api_key_id = api_key.key_id
|
||||
g.api_key_scopes = scopes
|
||||
g.username = f"api_key:{api_key.key_id}"
|
||||
await self.db.touch_api_key(api_key.key_id)
|
||||
return None
|
||||
|
||||
allowed_endpoints = [
|
||||
"/api/auth/login",
|
||||
"/api/file",
|
||||
@@ -202,29 +158,6 @@ class AstrBotDashboard:
|
||||
r.status_code = 401
|
||||
return r
|
||||
|
||||
@staticmethod
|
||||
def _extract_raw_api_key() -> str | None:
|
||||
if key := request.headers.get("X-API-Key"):
|
||||
return key.strip()
|
||||
auth_header = request.headers.get("Authorization", "").strip()
|
||||
if auth_header.startswith("Bearer "):
|
||||
return auth_header.removeprefix("Bearer ").strip()
|
||||
if auth_header.startswith("ApiKey "):
|
||||
return auth_header.removeprefix("ApiKey ").strip()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _get_required_open_api_scope(path: str) -> str | None:
|
||||
scope_map = {
|
||||
"/api/v1/chat": "chat",
|
||||
"/api/v1/chat/sessions": "chat",
|
||||
"/api/v1/configs": "config",
|
||||
"/api/v1/file": "file",
|
||||
"/api/v1/im/message": "im",
|
||||
"/api/v1/im/bots": "im",
|
||||
}
|
||||
return scope_map.get(path)
|
||||
|
||||
def check_port_in_use(self, port: int) -> bool:
|
||||
"""跨平台检测端口是否被占用"""
|
||||
try:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import base64
|
||||
import os
|
||||
import traceback
|
||||
from io import BytesIO
|
||||
|
||||
@@ -50,14 +51,14 @@ async def generate_tsne_visualization(
|
||||
return None
|
||||
|
||||
kb = kb_helper.kb
|
||||
index_path = kb_helper.kb_dir / "index.faiss"
|
||||
index_path = f"data/knowledge_base/{kb.kb_id}/index.faiss"
|
||||
|
||||
# 读取 FAISS 索引
|
||||
if not index_path.exists():
|
||||
logger.warning(f"FAISS 索引不存在: {index_path!s}")
|
||||
if not os.path.exists(index_path):
|
||||
logger.warning(f"FAISS 索引不存在: {index_path}")
|
||||
return None
|
||||
|
||||
index = faiss.read_index(str(index_path))
|
||||
index = faiss.read_index(index_path)
|
||||
|
||||
if index.ntotal == 0:
|
||||
logger.warning("索引为空")
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
## What's Changed
|
||||
|
||||
### 修复
|
||||
- ‼️ 修复 Python 3.14 环境下 `'Plain' object has no attribute 'text'` 报错问题 ([#5154](https://github.com/AstrBotDevs/AstrBot/issues/5154))。
|
||||
- ‼️ 修复插件元数据处理流程:在实例化前注入必要属性,避免初始化阶段元数据缺失 ([#5155](https://github.com/AstrBotDevs/AstrBot/issues/5155))。
|
||||
- 修复桌面端后端构建中 AstrBot 内置插件运行时依赖未打包的问题 ([#5146](https://github.com/AstrBotDevs/AstrBot/issues/5146))。
|
||||
- 修复通过 AstrBot Launcher 启动时仍被检测并触发更新的问题。
|
||||
|
||||
### 优化
|
||||
|
||||
- Webchat 下,使用 `astrbot_execute_ipython` 工具如果返回了图片,会自动将图片发送到聊天中。
|
||||
|
||||
### 其他
|
||||
- 执行 `ruff format` 代码格式整理。
|
||||
|
||||
## What's Changed (EN)
|
||||
|
||||
### Fixes
|
||||
- ‼️ Fixed plugin metadata handling by injecting required attributes before instantiation to avoid missing metadata during initialization ([#5155](https://github.com/AstrBotDevs/AstrBot/issues/5155)).
|
||||
- ‼️ Fixed `'Plain' object has no attribute 'text'` error when using Python 3.14 ([#5154](https://github.com/AstrBotDevs/AstrBot/issues/5154)).
|
||||
- Fixed missing runtime dependencies for built-in plugins in desktop backend builds ([#5146](https://github.com/AstrBotDevs/AstrBot/issues/5146)).
|
||||
- Fixed update checks being triggered when AstrBot is launched via AstrBot Launcher.
|
||||
|
||||
### Improvements
|
||||
- In Webchat, when using the `astrbot_execute_ipython` tool, if an image is returned, it will automatically be sent to the chat.
|
||||
### Others
|
||||
- Applied `ruff format` code formatting.
|
||||
@@ -1,32 +0,0 @@
|
||||
## What's Changed
|
||||
|
||||
### 新增
|
||||
- 新增 NVIDIA Provider 模板,便于快速接入 NVIDIA 模型服务 ([#5157](https://github.com/AstrBotDevs/AstrBot/issues/5157))。
|
||||
- 支持在 WebUI 搜索配置
|
||||
|
||||
### 修复
|
||||
- 修复 CronJob 页面操作列按钮重叠问题,提升任务管理可用性 ([#5163](https://github.com/AstrBotDevs/AstrBot/issues/5163))。
|
||||
|
||||
### 优化
|
||||
- 优化 Python / Shell 本地执行工具的权限拒绝提示信息引导,提升排障可读性。
|
||||
- Provider 来源面板样式升级,新增菜单交互并完善移动端适配。
|
||||
- PersonaForm 组件增强响应式布局与样式细节,改进不同屏幕下的编辑体验 ([#5162](https://github.com/AstrBotDevs/AstrBot/issues/5162))。
|
||||
- 配置页面新增未保存变更提示,减少误操作导致的配置丢失。
|
||||
- 配置相关组件新增搜索能力并同步更新界面交互,提升配置项定位效率 ([#5168](https://github.com/AstrBotDevs/AstrBot/issues/5168))。
|
||||
|
||||
## What's Changed (EN)
|
||||
|
||||
### New Features
|
||||
- Added an NVIDIA provider template for faster integration with NVIDIA model services ([#5157](https://github.com/AstrBotDevs/AstrBot/issues/5157)).
|
||||
- Added an announcement section to the Welcome page, with localized announcement title support.
|
||||
- Added an FAQ link to the vertical sidebar and updated navigation for localization.
|
||||
|
||||
### Fixes
|
||||
- Fixed overlapping action buttons in the CronJob page action column to improve task management usability ([#5163](https://github.com/AstrBotDevs/AstrBot/issues/5163)).
|
||||
- Improved permission-denied messages for local execution in Python and shell tools for better troubleshooting clarity.
|
||||
|
||||
### Improvements
|
||||
- Enhanced the provider sources panel with a refined menu style and better mobile support.
|
||||
- Improved PersonaForm with responsive layout and styling updates for better editing experience across screen sizes ([#5162](https://github.com/AstrBotDevs/AstrBot/issues/5162)).
|
||||
- Added an unsaved-changes notice on the configuration page to reduce accidental config loss.
|
||||
- Added search functionality to configuration components and updated related UI interactions for faster settings discovery ([#5168](https://github.com/AstrBotDevs/AstrBot/issues/5168)).
|
||||
@@ -1,37 +0,0 @@
|
||||
## What's Changed
|
||||
|
||||
### 新增
|
||||
- 支持 QQ 官方机器人平台发送 Markdown 消息,提升富文本消息呈现能力 ([#5173](https://github.com/AstrBotDevs/AstrBot/issues/5173))。
|
||||
- 新增在插件市场中集成随机插件推荐能力 ([#5190](https://github.com/AstrBotDevs/AstrBot/issues/5190))。
|
||||
- 新增插件错误钩子(plugin error hook),支持自定义错误路由处理,便于插件统一异常控制 ([#5192](https://github.com/AstrBotDevs/AstrBot/issues/5192))。
|
||||
|
||||
### 修复
|
||||
- 修复全部 LLM Provider 失败时重复显示错误信息的问题,减少冗余报错干扰 ([#5183](https://github.com/AstrBotDevs/AstrBot/issues/5183))。
|
||||
- 修复从“选择配置文件”进入配置管理后直接关闭弹窗时,显示配置文件不正确的问题 ([#5174](https://github.com/AstrBotDevs/AstrBot/issues/5174))。
|
||||
|
||||
### 优化
|
||||
- 重构 telegram `Voice_messages_forbidden` 回退逻辑,提取为共享辅助方法并引入类型化 `BadRequest` 异常,提升异常处理一致性 ([#5204](https://github.com/AstrBotDevs/AstrBot/issues/5204))。
|
||||
|
||||
### 其他
|
||||
- 更新 README 相关文档内容。
|
||||
- 执行 `ruff format` 代码格式整理。
|
||||
|
||||
## What's Changed (EN)
|
||||
|
||||
### New Features
|
||||
- Added a plugin error hook for custom error routing, enabling unified exception handling in plugins ([#5192](https://github.com/AstrBotDevs/AstrBot/issues/5192)).
|
||||
- Added Markdown message sending support for `qqofficial` to improve rich-text delivery ([#5173](https://github.com/AstrBotDevs/AstrBot/issues/5173)).
|
||||
- Added the `MarketPluginCard` component and integrated random plugin recommendations in the extension marketplace ([#5190](https://github.com/AstrBotDevs/AstrBot/issues/5190)).
|
||||
- Added support for the `aihubmix` provider.
|
||||
- Added LINE support notes to multilingual README files.
|
||||
|
||||
### Fixes
|
||||
- Fixed duplicate error messages when all LLM providers fail, reducing noisy error output ([#5183](https://github.com/AstrBotDevs/AstrBot/issues/5183)).
|
||||
- Fixed incorrect displayed profile after opening configuration management from profile selection and closing the dialog directly ([#5174](https://github.com/AstrBotDevs/AstrBot/issues/5174)).
|
||||
|
||||
### Improvements
|
||||
- Refactored `Voice_messages_forbidden` fallback logic into a shared helper and introduced a typed `BadRequest` exception for more consistent error handling ([#5204](https://github.com/AstrBotDevs/AstrBot/issues/5204)).
|
||||
|
||||
### Others
|
||||
- Updated README documentation.
|
||||
- Applied `ruff format` code formatting.
|
||||
@@ -1,47 +0,0 @@
|
||||
## What's Changed
|
||||
|
||||
### 新增
|
||||
- 新增 Python / Shell 执行工具的管理员权限校验,提升高风险操作安全性 ([#5214](https://github.com/AstrBotDevs/AstrBot/issues/5214))。
|
||||
- 新增插件 `astrbot-version` 与平台版本要求校验支持,增强插件兼容性管理能力 ([#5235](https://github.com/AstrBotDevs/AstrBot/issues/5235))。
|
||||
- 账号密码修改流程新增“确认新密码”校验,减少误输导致的配置问题 ([#5247](https://github.com/AstrBotDevs/AstrBot/issues/5247))。
|
||||
|
||||
### 修复
|
||||
- 改进微信公众号被动回复处理机制,引入缓冲与分片回复并优化超时行为,提升回复稳定性 ([#5224](https://github.com/AstrBotDevs/AstrBot/issues/5224))。
|
||||
- 修复仅发送 JSON 消息段时可能触发空消息回复报错的问题 ([#5208](https://github.com/AstrBotDevs/AstrBot/issues/5208))。
|
||||
- 修复会话重置/新建/删除时未终止活动事件导致的陈旧响应问题 ([#5225](https://github.com/AstrBotDevs/AstrBot/issues/5225))。
|
||||
- 修复 provider 在 `dict` 格式 `content` 场景下可能残留 JSON 内容的问题 ([#5250](https://github.com/AstrBotDevs/AstrBot/issues/5250))。
|
||||
- 修复 MCP 工具未完整暴露给主 Agent 的问题 ([#5252](https://github.com/AstrBotDevs/AstrBot/issues/5252))。
|
||||
- 修复工具 schema 属性中的 `additionalProperties` 配置问题 ([#5253](https://github.com/AstrBotDevs/AstrBot/issues/5253))。
|
||||
- 优化账号编辑校验错误提示,简化并统一用户名/密码为空场景返回信息。
|
||||
|
||||
### 优化
|
||||
- 优化 PersonaForm 布局与工具选择展示,并完善工具停用状态的本地化显示。
|
||||
|
||||
### 其他
|
||||
- 移除 Electron Desktop 流水线并迁移到 Tauri 仓库 ([#5226](https://github.com/AstrBotDevs/AstrBot/issues/5226))。
|
||||
- 更新相关仓库链接与功能请求模板文案,统一中英文表达。
|
||||
- 移除过时文档文件 `heihe.md`。
|
||||
|
||||
## What's Changed (EN)
|
||||
|
||||
### New Features
|
||||
- Added admin permission checks for Python/Shell execution tools to improve safety for high-risk operations ([#5214](https://github.com/AstrBotDevs/AstrBot/issues/5214)).
|
||||
- Added support for `astrbot-version` and platform requirement checks for plugins to improve compatibility management ([#5235](https://github.com/AstrBotDevs/AstrBot/issues/5235)).
|
||||
- Added password confirmation when changing account passwords to reduce misconfiguration caused by typos ([#5247](https://github.com/AstrBotDevs/AstrBot/issues/5247)).
|
||||
|
||||
### Fixes
|
||||
- Improved passive reply handling for WeChat Official Accounts with buffering/chunking and timeout behavior optimizations for better stability ([#5224](https://github.com/AstrBotDevs/AstrBot/issues/5224)).
|
||||
- Fixed an empty-message reply error when only JSON message segments were sent ([#5208](https://github.com/AstrBotDevs/AstrBot/issues/5208)).
|
||||
- Fixed stale responses by terminating active events on reset/new/delete operations ([#5225](https://github.com/AstrBotDevs/AstrBot/issues/5225)).
|
||||
- Fixed residual JSON content issues in provider handling when `content` was in `dict` format ([#5250](https://github.com/AstrBotDevs/AstrBot/issues/5250)).
|
||||
- Fixed incomplete exposure of MCP tools to the main agent ([#5252](https://github.com/AstrBotDevs/AstrBot/issues/5252)).
|
||||
- Fixed `additionalProperties` handling in tool schema properties ([#5253](https://github.com/AstrBotDevs/AstrBot/issues/5253)).
|
||||
- Simplified and unified account-edit validation error responses for empty username/password scenarios.
|
||||
|
||||
### Improvements
|
||||
- Enhanced PersonaForm layout and tool selection display, and improved localized labels for inactive tools.
|
||||
|
||||
### Others
|
||||
- Removed the Electron desktop pipeline and switched to the Tauri repository ([#5226](https://github.com/AstrBotDevs/AstrBot/issues/5226)).
|
||||
- Updated related repository links and refined feature request template wording in both Chinese and English.
|
||||
- Removed outdated documentation file `heihe.md`.
|
||||
@@ -1,29 +0,0 @@
|
||||
## What's Changed
|
||||
|
||||
### 新增
|
||||
- 新增 AstrBot HTTP API,支持基于 API Key 的对话、会话查询、配置查询、文件上传与 IM 消息发送能力。详见[AstrBot HTTP API (Beta)](https://docs.astrbot.app/dev/openapi.html) ([#5280](https://github.com/AstrBotDevs/AstrBot/issues/5280))。
|
||||
- 新增 Telegram 指令别名注册能力,别名可同步展示在 Telegram 指令菜单中 ([#5234](https://github.com/AstrBotDevs/AstrBot/issues/5234))。
|
||||
- 新增 Anthropic 自适应思考参数配置(type/effort),增强思考策略可控性 ([#5209](https://github.com/AstrBotDevs/AstrBot/issues/5209))。
|
||||
|
||||
### 修复
|
||||
- 修复 QQ 官方频道消息发送异常问题,提升消息下发稳定性 ([#5287](https://github.com/AstrBotDevs/AstrBot/issues/5287))。
|
||||
- 修复 ChatUI 使用非 default 配置文件对话时仍然使用 default 配置的问题 ([#5292](https://github.com/AstrBotDevs/AstrBot/issues/5292))。
|
||||
|
||||
### 优化
|
||||
- 优化插件市场卡片的平台支持展示,改进移动端可用性与交互体验 ([#5271](https://github.com/AstrBotDevs/AstrBot/issues/5271))。
|
||||
- 重构 Dashboard 桌面运行时桥接字段,从 `isElectron` 统一迁移至 `isDesktop`,提升跨端语义一致性 ([#5269](https://github.com/AstrBotDevs/AstrBot/issues/5269))。
|
||||
|
||||
## What's Changed (EN)
|
||||
|
||||
### New Features
|
||||
- Added AstrBot HTTP API with API Key support for chat, session listing, config listing, file upload, and IM message sending. See [AstrBot HTTP API (Beta)](https://docs.astrbot.app/en/dev/openapi.html) ([#5280](https://github.com/AstrBotDevs/AstrBot/issues/5280)).
|
||||
- Added Telegram command alias registration so aliases can also appear in the Telegram command menu ([#5234](https://github.com/AstrBotDevs/AstrBot/issues/5234)).
|
||||
- Added Anthropic adaptive thinking parameters (`type`/`effort`) for more flexible reasoning strategy control ([#5209](https://github.com/AstrBotDevs/AstrBot/issues/5209)).
|
||||
|
||||
### Fixes
|
||||
- Fixed QQ official guild message sending errors to improve delivery stability ([#5287](https://github.com/AstrBotDevs/AstrBot/issues/5287)).
|
||||
- Fixed chat config binding failures caused by missing session IDs when creating new chats, and improved localStorage fault tolerance ([#5292](https://github.com/AstrBotDevs/AstrBot/issues/5292)).
|
||||
|
||||
### Improvements
|
||||
- Improved plugin marketplace card display for platform compatibility, with better mobile accessibility and interaction ([#5271](https://github.com/AstrBotDevs/AstrBot/issues/5271)).
|
||||
- Refactored desktop runtime bridge fields in the dashboard from `isElectron` to `isDesktop` for clearer cross-platform semantics ([#5269](https://github.com/AstrBotDevs/AstrBot/issues/5269)).
|
||||
@@ -1,17 +0,0 @@
|
||||
## What's Changed
|
||||
|
||||
### 修复
|
||||
- fix: 修复插件市场出现插件显示为空白的 bug;纠正已安装插件卡片的排版,统一大小 ([#5309](https://github.com/AstrBotDevs/AstrBot/issues/5309))
|
||||
|
||||
### 新增
|
||||
- SubAgent 支持后台执行模式配置:当 `background: true` 时,子代理将在后台运行,主对话无需等待子代理完成即可继续进行。当子代理完成后,会收到通知。适用于长时间运行或用户不需要立即结果的任务。([#5081](https://github.com/AstrBotDevs/AstrBot/issues/5081))
|
||||
- 配置 Schema 新增密码渲染支持:`string` 与 `text` 类型可通过 `password: true`(或 `render_type: "password"`)在 WebUI 中按密码输入方式显示。
|
||||
|
||||
## What's Changed (EN)
|
||||
|
||||
### Fixes
|
||||
- fix: Fixed a bug where the plugin marketplace would show blank cards for plugins; corrected the layout of installed plugin cards for consistent sizing ([#5309](https://github.com/AstrBotDevs/AstrBot/issues/5309))
|
||||
|
||||
### New Features
|
||||
- Added background execution mode support for sub-agents: when `background: true` is set, the sub-agent will run in the background, allowing the main conversation to continue without waiting for the sub-agent to finish. You will be notified when the sub-agent completes. This is suitable for long-running tasks or when the user does not need immediate results. ([#5081](https://github.com/AstrBotDevs/AstrBot/issues/5081))
|
||||
- Added password rendering support in config schema: `string` and `text` fields can be rendered as password inputs in WebUI with `password: true` (or `render_type: "password"`).
|
||||
@@ -1,60 +0,0 @@
|
||||
## What's Changed
|
||||
|
||||
### 新增
|
||||
- 新增 Agent 会话停止能力,并优化 stop 请求处理流程,支持 /stop 指令终止 Agent 运行并尽量不丢失已运行输出的结果。 ([#5380](https://github.com/AstrBotDevs/AstrBot/issues/5380))。
|
||||
- 新增 SubAgent 交接场景下的 computer-use 工具支持 ([#5399](https://github.com/AstrBotDevs/AstrBot/issues/5399))。
|
||||
- 新增 Agent 执行过程中展示工具调用结果的能力,提升执行过程可观测性 ([#5388](https://github.com/AstrBotDevs/AstrBot/issues/5388))。
|
||||
- 新增插件加载/卸载 Hook,扩展插件生命周期能力 ([#5331](https://github.com/AstrBotDevs/AstrBot/issues/5331))。
|
||||
- 新增插件加载失败后的热重载能力,提升插件开发与恢复效率 ([#5334](https://github.com/AstrBotDevs/AstrBot/issues/5334))。
|
||||
- 新增 SubAgent 图片 URL/本地路径输入支持 ([#5348](https://github.com/AstrBotDevs/AstrBot/issues/5348))。
|
||||
- 新增 Dashboard 发布跳转基础 URL 可配置项 ([#5330](https://github.com/AstrBotDevs/AstrBot/issues/5330))。
|
||||
|
||||
### 修复
|
||||
- 修复 Tavily 请求的硬编码 6 秒超时。
|
||||
- 修复 OneBot v11 适配器关闭之后仍然在连接的问题([#5412](https://github.com/AstrBotDevs/AstrBot/issues/5412))。
|
||||
- 修复上下文会话中平台缺失时的日志处理,补充 warning 并改进排查信息。
|
||||
- 修复 embedding 维度未透传到 provider API 的问题 ([#5411](https://github.com/AstrBotDevs/AstrBot/issues/5411))。
|
||||
- 修复 File 组件处理逻辑并增强 OneBot 驱动层路径兼容性 ([#5391](https://github.com/AstrBotDevs/AstrBot/issues/5391))。
|
||||
- 修复 sandbox 文件传输工具缺少管理员权限校验的问题 ([#5402](https://github.com/AstrBotDevs/AstrBot/issues/5402))。
|
||||
- 修复 pipeline 与 `from ... import *` 引发的循环依赖问题 ([#5353](https://github.com/AstrBotDevs/AstrBot/issues/5353))。
|
||||
- 修复配置文件存在 UTF-8 BOM 时的解析问题 ([#5376](https://github.com/AstrBotDevs/AstrBot/issues/5376))。
|
||||
- 修复 ChatUI 复制回滚路径缺失与错误提示不清晰的问题 ([#5352](https://github.com/AstrBotDevs/AstrBot/issues/5352))。
|
||||
- 修复保留插件目录处理逻辑,避免插件目录行为异常 ([#5369](https://github.com/AstrBotDevs/AstrBot/issues/5369))。
|
||||
- 修复 ChatUI 文件消息段无法持久化的问题 ([#5386](https://github.com/AstrBotDevs/AstrBot/issues/5386))。
|
||||
- 修复 `.dockerignore` 误排除 `changelogs` 目录的问题。
|
||||
- 修复 aiohttp 版本过新导致 qq-botpy 报错的问题 ([#5316](https://github.com/AstrBotDevs/AstrBot/issues/5316))。
|
||||
|
||||
### 优化
|
||||
- 完成 SubAgent 编排页面国际化,补齐多语言支持 ([#5400](https://github.com/AstrBotDevs/AstrBot/issues/5400))。
|
||||
- 增补消息事件处理相关测试,并完善测试框架的 fixtures/mocks 覆盖 ([#5355](https://github.com/AstrBotDevs/AstrBot/issues/5355), [#5354](https://github.com/AstrBotDevs/AstrBot/issues/5354))。
|
||||
|
||||
## What's Changed (EN)
|
||||
|
||||
### New Features
|
||||
- Added computer-use tools support in sub-agent handoff scenarios ([#5399](https://github.com/AstrBotDevs/AstrBot/issues/5399)).
|
||||
- Added support for displaying tool call results during agent execution for better observability ([#5388](https://github.com/AstrBotDevs/AstrBot/issues/5388)).
|
||||
- Added plugin load/unload hooks to extend plugin lifecycle capabilities ([#5331](https://github.com/AstrBotDevs/AstrBot/issues/5331)).
|
||||
- Added hot reload support when plugin loading fails, improving recovery during plugin development ([#5334](https://github.com/AstrBotDevs/AstrBot/issues/5334)).
|
||||
- Added image URL/local path input support for sub-agents ([#5348](https://github.com/AstrBotDevs/AstrBot/issues/5348)).
|
||||
- Added stop control for active agent sessions and improved stop request handling ([#5380](https://github.com/AstrBotDevs/AstrBot/issues/5380)).
|
||||
- Added configurable base URL for dashboard release redirects ([#5330](https://github.com/AstrBotDevs/AstrBot/issues/5330)).
|
||||
|
||||
### Fixes
|
||||
- Fixed logging behavior when platform information is missing in context sessions, with clearer warning and diagnostics.
|
||||
- Fixed missing embedding dimensions being passed to provider APIs ([#5411](https://github.com/AstrBotDevs/AstrBot/issues/5411)).
|
||||
- Fixed shutdown stability issues in the aiocqhttp adapter ([#5412](https://github.com/AstrBotDevs/AstrBot/issues/5412)).
|
||||
- Fixed File component handling and improved path compatibility in the OneBot driver layer ([#5391](https://github.com/AstrBotDevs/AstrBot/issues/5391)).
|
||||
- Fixed missing admin guard for sandbox file transfer tools ([#5402](https://github.com/AstrBotDevs/AstrBot/issues/5402)).
|
||||
- Fixed circular import issues related to pipeline and `from ... import *` usage ([#5353](https://github.com/AstrBotDevs/AstrBot/issues/5353)).
|
||||
- Fixed config parsing issues when files contain UTF-8 BOM ([#5376](https://github.com/AstrBotDevs/AstrBot/issues/5376)).
|
||||
- Fixed missing copy rollback path and unclear error messaging in ChatUI ([#5352](https://github.com/AstrBotDevs/AstrBot/issues/5352)).
|
||||
- Fixed reserved plugin directory handling to avoid abnormal plugin path behavior ([#5369](https://github.com/AstrBotDevs/AstrBot/issues/5369)).
|
||||
- Fixed ChatUI file segment persistence issues ([#5386](https://github.com/AstrBotDevs/AstrBot/issues/5386)).
|
||||
- Fixed accidental exclusion of the `changelogs` directory in `.dockerignore`.
|
||||
- Fixed compatibility issues caused by a hard-coded 6-second timeout in Tavily requests.
|
||||
- Fixed qq-botpy runtime errors caused by overly new aiohttp versions ([#5316](https://github.com/AstrBotDevs/AstrBot/issues/5316)).
|
||||
|
||||
### Improvements
|
||||
- Completed internationalization for the sub-agent orchestration page ([#5400](https://github.com/AstrBotDevs/AstrBot/issues/5400)).
|
||||
- Added broader message-event test coverage and improved fixtures/mocks in the test framework ([#5355](https://github.com/AstrBotDevs/AstrBot/issues/5355), [#5354](https://github.com/AstrBotDevs/AstrBot/issues/5354)).
|
||||
- Updated README content and applied repository-wide formatting cleanup (ruff format) ([#5375](https://github.com/AstrBotDevs/AstrBot/issues/5375)).
|
||||
+1
-8
@@ -1,10 +1,3 @@
|
||||
# AstrBot 管理面板
|
||||
|
||||
基于 CodedThemes/Berry 模板开发。
|
||||
|
||||
## 环境变量
|
||||
|
||||
- `VITE_ASTRBOT_RELEASE_BASE_URL`(可选)
|
||||
- 默认值:`https://github.com/AstrBotDevs/AstrBot/releases`
|
||||
- 用途:管理面板内“更新到最新版本”外部跳转所使用的 release 基地址。集成方可按需覆盖(例如 Desktop 指向其自身发布页)。
|
||||
- 建议传入仓库的 `.../releases` 基地址(不带 `/latest`)。
|
||||
基于 CodedThemes/Berry 模板开发。
|
||||
Vendored
-8
@@ -1,9 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_ASTRBOT_RELEASE_BASE_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { RouterView } from 'vue-router';
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue'
|
||||
import { restartAstrBot } from '@/utils/restartAstrBot'
|
||||
|
||||
const toastStore = useToastStore()
|
||||
const globalWaitingRef = ref(null)
|
||||
@@ -32,12 +33,12 @@ const snackbarShow = computed({
|
||||
|
||||
onMounted(() => {
|
||||
const desktopBridge = window.astrbotDesktop
|
||||
if (!desktopBridge?.onTrayRestartBackend) {
|
||||
if (!desktopBridge?.isElectron || !desktopBridge.onTrayRestartBackend) {
|
||||
return
|
||||
}
|
||||
disposeTrayRestartListener = desktopBridge.onTrayRestartBackend(async () => {
|
||||
try {
|
||||
await globalWaitingRef.value?.check?.()
|
||||
await restartAstrBot(globalWaitingRef.value)
|
||||
} catch (error) {
|
||||
globalWaitingRef.value?.stop?.()
|
||||
console.error('Tray restart backend failed:', error)
|
||||
|
||||
@@ -77,14 +77,12 @@
|
||||
:stagedAudioUrl="stagedAudioUrl"
|
||||
:stagedFiles="stagedNonImageFiles"
|
||||
:disabled="isStreaming"
|
||||
:is-running="isStreaming || isConvRunning"
|
||||
:enableStreaming="enableStreaming"
|
||||
:isRecording="isRecording"
|
||||
:session-id="currSessionId || null"
|
||||
:current-session="getCurrentSession"
|
||||
:replyTo="replyTo"
|
||||
@send="handleSendMessage"
|
||||
@stop="handleStopMessage"
|
||||
@toggleStreaming="toggleStreaming"
|
||||
@removeImage="removeImage"
|
||||
@removeAudio="removeAudio"
|
||||
@@ -108,14 +106,12 @@
|
||||
:stagedAudioUrl="stagedAudioUrl"
|
||||
:stagedFiles="stagedNonImageFiles"
|
||||
:disabled="isStreaming"
|
||||
:is-running="isStreaming || isConvRunning"
|
||||
:enableStreaming="enableStreaming"
|
||||
:isRecording="isRecording"
|
||||
:session-id="currSessionId || null"
|
||||
:current-session="getCurrentSession"
|
||||
:replyTo="replyTo"
|
||||
@send="handleSendMessage"
|
||||
@stop="handleStopMessage"
|
||||
@toggleStreaming="toggleStreaming"
|
||||
@removeImage="removeImage"
|
||||
@removeAudio="removeAudio"
|
||||
@@ -138,14 +134,12 @@
|
||||
:stagedAudioUrl="stagedAudioUrl"
|
||||
:stagedFiles="stagedNonImageFiles"
|
||||
:disabled="isStreaming"
|
||||
:is-running="isStreaming || isConvRunning"
|
||||
:enableStreaming="enableStreaming"
|
||||
:isRecording="isRecording"
|
||||
:session-id="currSessionId || null"
|
||||
:current-session="getCurrentSession"
|
||||
:replyTo="replyTo"
|
||||
@send="handleSendMessage"
|
||||
@stop="handleStopMessage"
|
||||
@toggleStreaming="toggleStreaming"
|
||||
@removeImage="removeImage"
|
||||
@removeAudio="removeAudio"
|
||||
@@ -304,7 +298,6 @@ const {
|
||||
currentSessionProject,
|
||||
getSessionMessages: getSessionMsg,
|
||||
sendMessage: sendMsg,
|
||||
stopMessage: stopMsg,
|
||||
toggleStreaming
|
||||
} = useMessages(currSessionId, getMediaFile, updateSessionTitle, getSessions);
|
||||
|
||||
@@ -638,10 +631,6 @@ async function handleSendMessage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStopMessage() {
|
||||
await stopMsg();
|
||||
}
|
||||
|
||||
// 路由变化监听
|
||||
watch(
|
||||
() => route.path,
|
||||
|
||||
@@ -94,29 +94,8 @@
|
||||
{{ isRecording ? tm('voice.speaking') : tm('voice.startRecording') }}
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
icon
|
||||
v-if="isRunning"
|
||||
@click="$emit('stop')"
|
||||
variant="text"
|
||||
class="send-btn"
|
||||
size="small"
|
||||
>
|
||||
<v-icon icon="mdi-stop" variant="text" plain></v-icon>
|
||||
<v-tooltip activator="parent" location="top">
|
||||
{{ tm('input.stopGenerating') }}
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-else
|
||||
@click="$emit('send')"
|
||||
icon="mdi-send"
|
||||
variant="text"
|
||||
color="deep-purple"
|
||||
:disabled="!canSend"
|
||||
class="send-btn"
|
||||
size="small"
|
||||
/>
|
||||
<v-btn @click="$emit('send')" icon="mdi-send" variant="text" color="deep-purple"
|
||||
:disabled="!canSend" class="send-btn" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -181,7 +160,6 @@ interface Props {
|
||||
disabled: boolean;
|
||||
enableStreaming: boolean;
|
||||
isRecording: boolean;
|
||||
isRunning: boolean;
|
||||
sessionId?: string | null;
|
||||
currentSession?: Session | null;
|
||||
configId?: string | null;
|
||||
@@ -199,7 +177,6 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
const emit = defineEmits<{
|
||||
'update:prompt': [value: string];
|
||||
send: [];
|
||||
stop: [];
|
||||
toggleStreaming: [];
|
||||
removeImage: [index: number];
|
||||
removeAudio: [];
|
||||
|
||||
@@ -77,11 +77,6 @@ import { computed, onMounted, ref, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useToast } from '@/utils/toast';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import {
|
||||
getStoredDashboardUsername,
|
||||
getStoredSelectedChatConfigId,
|
||||
setStoredSelectedChatConfigId
|
||||
} from '@/utils/chatConfigBinding';
|
||||
|
||||
interface ConfigInfo {
|
||||
id: string;
|
||||
@@ -93,6 +88,8 @@ interface ConfigChangedPayload {
|
||||
agentRunnerType: string;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'chat.selectedConfigId';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
sessionId?: string | null;
|
||||
platformId?: string;
|
||||
@@ -131,7 +128,7 @@ const hasActiveSession = computed(() => !!normalizedSessionId.value);
|
||||
|
||||
const messageType = computed(() => (props.isGroup ? 'GroupMessage' : 'FriendMessage'));
|
||||
|
||||
const username = computed(() => getStoredDashboardUsername());
|
||||
const username = computed(() => localStorage.getItem('user') || 'guest');
|
||||
|
||||
const sessionKey = computed(() => {
|
||||
if (!normalizedSessionId.value) {
|
||||
@@ -268,10 +265,10 @@ async function confirmSelection() {
|
||||
}
|
||||
const previousId = selectedConfigId.value;
|
||||
await setSelection(tempSelectedConfig.value);
|
||||
setStoredSelectedChatConfigId(tempSelectedConfig.value);
|
||||
localStorage.setItem(STORAGE_KEY, tempSelectedConfig.value);
|
||||
const applied = await applySelectionToBackend(tempSelectedConfig.value);
|
||||
if (!applied) {
|
||||
setStoredSelectedChatConfigId(previousId);
|
||||
localStorage.setItem(STORAGE_KEY, previousId);
|
||||
await setSelection(previousId);
|
||||
}
|
||||
dialog.value = false;
|
||||
@@ -290,7 +287,7 @@ async function syncSelectionForSession() {
|
||||
await fetchRoutingEntries();
|
||||
const resolved = resolveConfigId(targetUmo.value);
|
||||
await setSelection(resolved);
|
||||
setStoredSelectedChatConfigId(resolved);
|
||||
localStorage.setItem(STORAGE_KEY, resolved);
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -302,7 +299,7 @@ watch(
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchConfigList();
|
||||
const stored = props.initialConfigId || getStoredSelectedChatConfigId();
|
||||
const stored = props.initialConfigId || localStorage.getItem(STORAGE_KEY) || 'default';
|
||||
selectedConfigId.value = stored;
|
||||
await setSelection(stored);
|
||||
await syncSelectionForSession();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user