Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 90602dd97f | |||
| 1ab2fa1788 | |||
| 650a092cc1 | |||
| 6240125440 | |||
| aff92a48bf | |||
| d0998a9dfb | |||
| 3678688433 | |||
| 0c03177840 | |||
| 20ff719c00 | |||
| 8a8ec492d7 | |||
| 02c1443dd1 | |||
| 79301f192c | |||
| 4b2c854c42 | |||
| d02ee7be8b | |||
| dbeadb6833 | |||
| 478cc32de1 | |||
| 7b302445c2 | |||
| ae839ef6d8 | |||
| 144a53f4b3 | |||
| fa1d1e6034 | |||
| a404436f2c | |||
| bcb12a0717 | |||
| 5d0fc8ac7a | |||
| a4d37e2c20 | |||
| c599fb75ed | |||
| e7e0f84edf | |||
| e19a282c59 | |||
| fbc8667968 | |||
| cda49c3a9a | |||
| 4be1027444 | |||
| 46152d3faf | |||
| ed4cacfffb | |||
| 52d1979937 | |||
| b30cb12133 | |||
| 31d4e304fc | |||
| 9a7a594cb5 | |||
| e469178a6b | |||
| 0a517980b7 | |||
| 9c691b2266 | |||
| 3597726aad | |||
| a4a37c268d | |||
| 651a0645c5 | |||
| bf3fa3e918 | |||
| 3b2ce9f500 | |||
| 20d6ff4620 | |||
| a2b61e2ab8 | |||
| c6289d8f75 | |||
| 567390e27c | |||
| 0c0f8bf484 | |||
| ae0a9cb591 | |||
| 3f4d7255a0 | |||
| b8d2499475 | |||
| 8cb26d886f | |||
| 3ca8dd204f | |||
| 3476afce41 | |||
| 9b0e24ec49 | |||
| 92d71fffe9 | |||
| 80c22f4f72 | |||
| 6e22d266dd | |||
| 4c285fb521 | |||
| 51c3521aaa | |||
| 32112a3326 |
@@ -17,7 +17,6 @@ ENV/
|
|||||||
.conda/
|
.conda/
|
||||||
dashboard/
|
dashboard/
|
||||||
data/
|
data/
|
||||||
changelogs/
|
|
||||||
tests/
|
tests/
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
.astrbot
|
.astrbot
|
||||||
|
|||||||
@@ -1,42 +1,40 @@
|
|||||||
|
|
||||||
name: '🎉 功能建议'
|
name: '🎉 Feature Request / 功能建议'
|
||||||
title: "[Feature]"
|
title: "[Feature]"
|
||||||
description: 提交建议帮助我们改进。
|
description: Submit a suggestion to help us improve. / 提交建议帮助我们改进。
|
||||||
labels: [ "enhancement" ]
|
labels: [ "enhancement" ]
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
感谢您抽出时间提出新功能建议,请准确解释您的想法。
|
Thank you for taking the time to suggest a new feature! Please explain your idea clearly and accurately. / 感谢您抽出时间提出新功能建议,请准确解释您的想法。
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: 描述
|
label: Description / 描述
|
||||||
description: 简短描述您的功能建议。
|
description: Please describe the feature you want to be added in detail. / 请详细描述您希望添加的功能。
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: 使用场景
|
label: Use Case / 使用场景
|
||||||
description: 你想要发生什么?
|
description: Please describe the use case for this feature. / 请描述这个功能的使用场景。
|
||||||
placeholder: >
|
|
||||||
一个清晰且具体的描述这个功能的使用场景。
|
|
||||||
|
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
attributes:
|
attributes:
|
||||||
label: 你愿意提交PR吗?
|
label: Willing to Submit PR? / 是否愿意提交PR?
|
||||||
description: >
|
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:
|
options:
|
||||||
- label: 是的, 我愿意提交PR!
|
- label: Yes, I am willing to submit a PR. / 是的,我愿意提交 PR。
|
||||||
|
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
attributes:
|
attributes:
|
||||||
label: Code of Conduct
|
label: Code of Conduct
|
||||||
options:
|
options:
|
||||||
- label: >
|
- label: >
|
||||||
我已阅读并同意遵守该项目的 [行为准则](https://docs.github.com/zh/site-policy/github-terms/github-community-code-of-conduct)。
|
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). /
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: "感谢您填写我们的表单!"
|
value: "Thank you for filling out our form!"
|
||||||
@@ -102,170 +102,11 @@ jobs:
|
|||||||
cp "dashboard/AstrBot-${VERSION_TAG}-dashboard.zip" "dashboard/astrbot-webui-${VERSION_TAG}.zip"
|
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
|
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:
|
publish-release:
|
||||||
name: Publish GitHub Release
|
name: Publish GitHub Release
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
needs:
|
needs:
|
||||||
- build-dashboard
|
- build-dashboard
|
||||||
- build-desktop
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
@@ -296,12 +137,6 @@ jobs:
|
|||||||
name: Dashboard-${{ steps.tag.outputs.tag }}
|
name: Dashboard-${{ steps.tag.outputs.tag }}
|
||||||
path: release-assets
|
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
|
- name: Resolve release notes
|
||||||
id: notes
|
id: notes
|
||||||
|
|||||||
@@ -33,13 +33,6 @@ tests/astrbot_plugin_openai
|
|||||||
dashboard/node_modules/
|
dashboard/node_modules/
|
||||||
dashboard/dist/
|
dashboard/dist/
|
||||||
.pnpm-store/
|
.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
|
package-lock.json
|
||||||
yarn.lock
|
yarn.lock
|
||||||
|
|
||||||
|
|||||||
@@ -81,9 +81,15 @@ uv tool install astrbot
|
|||||||
astrbot
|
astrbot
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### 桌面应用部署(Tauri)
|
||||||
|
|
||||||
|
桌面应用仓库 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
|
||||||
|
|
||||||
|
支持多系统架构,安装包直接安装,开箱即用,最适合新手和懒人的一键桌面部署方案,不推荐服务器场景。
|
||||||
|
|
||||||
#### 启动器一键部署(AstrBot Launcher)
|
#### 启动器一键部署(AstrBot Launcher)
|
||||||
|
|
||||||
进入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 仓库,在 Releases 页最新版本下找到对应的系统安装包安装即可。
|
快速部署和多开方案,实现环境隔离,进入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 仓库,在 Releases 页最新版本下找到对应的系统安装包安装即可。
|
||||||
|
|
||||||
#### 宝塔面板部署
|
#### 宝塔面板部署
|
||||||
|
|
||||||
@@ -146,15 +152,12 @@ yay -S astrbot-git
|
|||||||
paru -S astrbot-git
|
paru -S astrbot-git
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 桌面端 Electron 打包
|
|
||||||
|
|
||||||
桌面端(Electron 打包,`pnpm` 工作流)构建流程请参阅:[`desktop/README.md`](desktop/README.md)。
|
|
||||||
|
|
||||||
## 支持的消息平台
|
## 支持的消息平台
|
||||||
|
|
||||||
**官方维护**
|
**官方维护**
|
||||||
|
|
||||||
- QQ (官方平台 & OneBot)
|
- QQ
|
||||||
|
- OneBot v11 协议实现
|
||||||
- Telegram
|
- Telegram
|
||||||
- 企微应用 & 企微智能机器人
|
- 企微应用 & 企微智能机器人
|
||||||
- 微信客服 & 微信公众号
|
- 微信客服 & 微信公众号
|
||||||
@@ -162,10 +165,10 @@ paru -S astrbot-git
|
|||||||
- 钉钉
|
- 钉钉
|
||||||
- Slack
|
- Slack
|
||||||
- Discord
|
- Discord
|
||||||
|
- LINE
|
||||||
- Satori
|
- Satori
|
||||||
- Misskey
|
- Misskey
|
||||||
- Whatsapp (将支持)
|
- Whatsapp (将支持)
|
||||||
- LINE (将支持)
|
|
||||||
|
|
||||||
**社区维护**
|
**社区维护**
|
||||||
|
|
||||||
@@ -185,6 +188,7 @@ paru -S astrbot-git
|
|||||||
- DeepSeek
|
- DeepSeek
|
||||||
- Ollama (本地部署)
|
- Ollama (本地部署)
|
||||||
- LM Studio (本地部署)
|
- LM Studio (本地部署)
|
||||||
|
- [AIHubMix](https://aihubmix.com/?aff=4bfH)
|
||||||
- [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74)
|
- [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74)
|
||||||
- [302.AI](https://share.302.ai/rr1M3l)
|
- [302.AI](https://share.302.ai/rr1M3l)
|
||||||
- [小马算力](https://www.tokenpony.cn/3YPyf)
|
- [小马算力](https://www.tokenpony.cn/3YPyf)
|
||||||
@@ -260,13 +264,23 @@ pre-commit install
|
|||||||
特别感谢所有 Contributors 和插件开发者对 AstrBot 的贡献 ❤️
|
特别感谢所有 Contributors 和插件开发者对 AstrBot 的贡献 ❤️
|
||||||
|
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
此外,本项目的诞生离不开以下开源项目的帮助:
|
此外,本项目的诞生离不开以下开源项目的帮助:
|
||||||
|
|
||||||
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - 伟大的猫猫框架
|
- [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
|
## ⭐ Star History
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
|
|||||||
+4
-4
@@ -154,9 +154,9 @@ yay -S astrbot-git
|
|||||||
paru -S astrbot-git
|
paru -S astrbot-git
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Desktop Electron Build
|
#### Desktop (Tauri)
|
||||||
|
|
||||||
For desktop build steps (Electron packaging, `pnpm` workflow), see [`desktop/README.md`](desktop/README.md).
|
Desktop packaging has moved to a standalone Tauri repository: [https://github.com/AstrBotDevs/AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
|
||||||
|
|
||||||
## Supported Messaging Platforms
|
## Supported Messaging Platforms
|
||||||
|
|
||||||
@@ -172,8 +172,8 @@ For desktop build steps (Electron packaging, `pnpm` workflow), see [`desktop/REA
|
|||||||
- Discord
|
- Discord
|
||||||
- Satori
|
- Satori
|
||||||
- Misskey
|
- Misskey
|
||||||
|
- LINE
|
||||||
- WhatsApp (Coming Soon)
|
- WhatsApp (Coming Soon)
|
||||||
- LINE (Coming Soon)
|
|
||||||
|
|
||||||
**Community Maintained**
|
**Community Maintained**
|
||||||
|
|
||||||
@@ -268,7 +268,7 @@ pre-commit install
|
|||||||
Special thanks to all Contributors and plugin developers for their contributions to AstrBot ❤️
|
Special thanks to all Contributors and plugin developers for their contributions to AstrBot ❤️
|
||||||
|
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
Additionally, the birth of this project would not have been possible without the help of the following open-source projects:
|
Additionally, the birth of this project would not have been possible without the help of the following open-source projects:
|
||||||
|
|||||||
+2
-2
@@ -168,8 +168,8 @@ paru -S astrbot-git
|
|||||||
- Discord
|
- Discord
|
||||||
- Satori
|
- Satori
|
||||||
- Misskey
|
- Misskey
|
||||||
|
- LINE
|
||||||
- WhatsApp (Bientôt disponible)
|
- WhatsApp (Bientôt disponible)
|
||||||
- LINE (Bientôt disponible)
|
|
||||||
|
|
||||||
**Maintenues par la communauté**
|
**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 ❤️
|
Un grand merci à tous les contributeurs et développeurs de plugins pour leurs contributions à AstrBot ❤️
|
||||||
|
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
De plus, la naissance de ce projet n'aurait pas été possible sans l'aide des projets open source suivants :
|
De plus, la naissance de ce projet n'aurait pas été possible sans l'aide des projets open source suivants :
|
||||||
|
|||||||
+2
-2
@@ -168,8 +168,8 @@ paru -S astrbot-git
|
|||||||
- Discord
|
- Discord
|
||||||
- Satori
|
- Satori
|
||||||
- Misskey
|
- Misskey
|
||||||
|
- LINE
|
||||||
- WhatsApp (近日対応予定)
|
- WhatsApp (近日対応予定)
|
||||||
- LINE (近日対応予定)
|
|
||||||
|
|
||||||
**コミュニティメンテナンス**
|
**コミュニティメンテナンス**
|
||||||
|
|
||||||
@@ -263,7 +263,7 @@ pre-commit install
|
|||||||
AstrBot への貢献をしていただいたすべてのコントリビューターとプラグイン開発者に特別な感謝を ❤️
|
AstrBot への貢献をしていただいたすべてのコントリビューターとプラグイン開発者に特別な感謝を ❤️
|
||||||
|
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
また、このプロジェクトの誕生は以下のオープンソースプロジェクトの助けなしには実現できませんでした:
|
また、このプロジェクトの誕生は以下のオープンソースプロジェクトの助けなしには実現できませんでした:
|
||||||
|
|||||||
+3
-2
@@ -158,8 +158,9 @@ paru -S astrbot-git
|
|||||||
- Discord
|
- Discord
|
||||||
- Satori
|
- Satori
|
||||||
- Misskey
|
- Misskey
|
||||||
|
- LINE
|
||||||
- WhatsApp (Скоро)
|
- WhatsApp (Скоро)
|
||||||
- LINE (Скоро)
|
|
||||||
|
|
||||||
**Поддерживаемые сообществом**
|
**Поддерживаемые сообществом**
|
||||||
|
|
||||||
@@ -252,7 +253,7 @@ pre-commit install
|
|||||||
Особая благодарность всем контрибьюторам и разработчикам плагинов за их вклад в AstrBot ❤️
|
Особая благодарность всем контрибьюторам и разработчикам плагинов за их вклад в AstrBot ❤️
|
||||||
|
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
Кроме того, рождение этого проекта было бы невозможно без помощи следующих проектов с открытым исходным кодом:
|
Кроме того, рождение этого проекта было бы невозможно без помощи следующих проектов с открытым исходным кодом:
|
||||||
|
|||||||
+3
-2
@@ -158,8 +158,9 @@ paru -S astrbot-git
|
|||||||
- Discord
|
- Discord
|
||||||
- Satori
|
- Satori
|
||||||
- Misskey
|
- Misskey
|
||||||
|
- LINE
|
||||||
- Whatsapp(即將支援)
|
- Whatsapp(即將支援)
|
||||||
- LINE(即將支援)
|
|
||||||
|
|
||||||
**社群維護**
|
**社群維護**
|
||||||
|
|
||||||
@@ -252,7 +253,7 @@ pre-commit install
|
|||||||
特別感謝所有 Contributors 和外掛開發者對 AstrBot 的貢獻 ❤️
|
特別感謝所有 Contributors 和外掛開發者對 AstrBot 的貢獻 ❤️
|
||||||
|
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
此外,本專案的誕生離不開以下開源專案的幫助:
|
此外,本專案的誕生離不開以下開源專案的幫助:
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from astrbot.core.star.register import (
|
|||||||
register_on_llm_tool_respond as on_llm_tool_respond,
|
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_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_using_llm_tool as on_using_llm_tool
|
from astrbot.core.star.register import register_on_using_llm_tool as on_using_llm_tool
|
||||||
from astrbot.core.star.register import (
|
from astrbot.core.star.register import (
|
||||||
register_on_waiting_llm_request as on_waiting_llm_request,
|
register_on_waiting_llm_request as on_waiting_llm_request,
|
||||||
@@ -52,6 +53,7 @@ __all__ = [
|
|||||||
"on_decorating_result",
|
"on_decorating_result",
|
||||||
"on_llm_request",
|
"on_llm_request",
|
||||||
"on_llm_response",
|
"on_llm_response",
|
||||||
|
"on_plugin_error",
|
||||||
"on_platform_loaded",
|
"on_platform_loaded",
|
||||||
"on_waiting_llm_request",
|
"on_waiting_llm_request",
|
||||||
"permission_type",
|
"permission_type",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from astrbot.api import sp, star
|
|||||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||||
from astrbot.core.platform.astr_message_event import MessageSession
|
from astrbot.core.platform.astr_message_event import MessageSession
|
||||||
from astrbot.core.platform.message_type import MessageType
|
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
|
from .utils.rst_scene import RstScene
|
||||||
|
|
||||||
@@ -62,6 +63,7 @@ class ConversationCommands:
|
|||||||
|
|
||||||
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
||||||
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
|
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
|
||||||
|
active_event_registry.stop_all(umo, exclude=message)
|
||||||
await sp.remove_async(
|
await sp.remove_async(
|
||||||
scope="umo",
|
scope="umo",
|
||||||
scope_id=umo,
|
scope_id=umo,
|
||||||
@@ -86,6 +88,8 @@ class ConversationCommands:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
active_event_registry.stop_all(umo, exclude=message)
|
||||||
|
|
||||||
await self.context.conversation_manager.update_conversation(
|
await self.context.conversation_manager.update_conversation(
|
||||||
umo,
|
umo,
|
||||||
cid,
|
cid,
|
||||||
@@ -98,6 +102,30 @@ class ConversationCommands:
|
|||||||
|
|
||||||
message.set_result(MessageEventResult().message(ret))
|
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:
|
async def his(self, message: AstrMessageEvent, page: int = 1) -> None:
|
||||||
"""查看对话记录"""
|
"""查看对话记录"""
|
||||||
if not self.context.get_using_provider(message.unified_msg_origin):
|
if not self.context.get_using_provider(message.unified_msg_origin):
|
||||||
@@ -221,6 +249,7 @@ class ConversationCommands:
|
|||||||
cfg = self.context.get_config(umo=message.unified_msg_origin)
|
cfg = self.context.get_config(umo=message.unified_msg_origin)
|
||||||
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
||||||
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
|
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(
|
await sp.remove_async(
|
||||||
scope="umo",
|
scope="umo",
|
||||||
scope_id=message.unified_msg_origin,
|
scope_id=message.unified_msg_origin,
|
||||||
@@ -229,6 +258,7 @@ class ConversationCommands:
|
|||||||
message.set_result(MessageEventResult().message("已创建新对话。"))
|
message.set_result(MessageEventResult().message("已创建新对话。"))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
active_event_registry.stop_all(message.unified_msg_origin, exclude=message)
|
||||||
cpersona = await self._get_current_persona_id(message.unified_msg_origin)
|
cpersona = await self._get_current_persona_id(message.unified_msg_origin)
|
||||||
cid = await self.context.conversation_manager.new_conversation(
|
cid = await self.context.conversation_manager.new_conversation(
|
||||||
message.unified_msg_origin,
|
message.unified_msg_origin,
|
||||||
@@ -321,7 +351,8 @@ class ConversationCommands:
|
|||||||
|
|
||||||
async def del_conv(self, message: AstrMessageEvent) -> None:
|
async def del_conv(self, message: AstrMessageEvent) -> None:
|
||||||
"""删除当前对话"""
|
"""删除当前对话"""
|
||||||
cfg = self.context.get_config(umo=message.unified_msg_origin)
|
umo = message.unified_msg_origin
|
||||||
|
cfg = self.context.get_config(umo=umo)
|
||||||
is_unique_session = cfg["platform_settings"]["unique_session"]
|
is_unique_session = cfg["platform_settings"]["unique_session"]
|
||||||
if message.get_group_id() and not is_unique_session and message.role != "admin":
|
if message.get_group_id() and not is_unique_session and message.role != "admin":
|
||||||
# 群聊,没开独立会话,发送人不是管理员
|
# 群聊,没开独立会话,发送人不是管理员
|
||||||
@@ -334,18 +365,17 @@ class ConversationCommands:
|
|||||||
|
|
||||||
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
||||||
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
|
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
|
||||||
|
active_event_registry.stop_all(umo, exclude=message)
|
||||||
await sp.remove_async(
|
await sp.remove_async(
|
||||||
scope="umo",
|
scope="umo",
|
||||||
scope_id=message.unified_msg_origin,
|
scope_id=umo,
|
||||||
key=THIRD_PARTY_AGENT_RUNNER_KEY[agent_runner_type],
|
key=THIRD_PARTY_AGENT_RUNNER_KEY[agent_runner_type],
|
||||||
)
|
)
|
||||||
message.set_result(MessageEventResult().message("重置对话成功。"))
|
message.set_result(MessageEventResult().message("重置对话成功。"))
|
||||||
return
|
return
|
||||||
|
|
||||||
session_curr_cid = (
|
session_curr_cid = (
|
||||||
await self.context.conversation_manager.get_curr_conversation_id(
|
await self.context.conversation_manager.get_curr_conversation_id(umo)
|
||||||
message.unified_msg_origin,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not session_curr_cid:
|
if not session_curr_cid:
|
||||||
@@ -356,8 +386,10 @@ class ConversationCommands:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
active_event_registry.stop_all(umo, exclude=message)
|
||||||
|
|
||||||
await self.context.conversation_manager.delete_conversation(
|
await self.context.conversation_manager.delete_conversation(
|
||||||
message.unified_msg_origin,
|
umo,
|
||||||
session_curr_cid,
|
session_curr_cid,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,11 @@ class Main(star.Star):
|
|||||||
"""重置 LLM 会话"""
|
"""重置 LLM 会话"""
|
||||||
await self.conversation_c.reset(message)
|
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.permission_type(filter.PermissionType.ADMIN)
|
||||||
@filter.command("model")
|
@filter.command("model")
|
||||||
async def model_ls(
|
async def model_ls(
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ class Main(star.Star):
|
|||||||
header = HEADERS
|
header = HEADERS
|
||||||
header.update({"User-Agent": random.choice(USER_AGENTS)})
|
header.update({"User-Agent": random.choice(USER_AGENTS)})
|
||||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||||
async with session.get(url, headers=header, timeout=6) as response:
|
async with session.get(url, headers=header) as response:
|
||||||
html = await response.text(encoding="utf-8")
|
html = await response.text(encoding="utf-8")
|
||||||
doc = Document(html)
|
doc = Document(html)
|
||||||
ret = doc.summary(html_partial=True)
|
ret = doc.summary(html_partial=True)
|
||||||
@@ -151,7 +151,6 @@ class Main(star.Star):
|
|||||||
url,
|
url,
|
||||||
json=payload,
|
json=payload,
|
||||||
headers=header,
|
headers=header,
|
||||||
timeout=6,
|
|
||||||
) as response:
|
) as response:
|
||||||
if response.status != 200:
|
if response.status != 200:
|
||||||
reason = await response.text()
|
reason = await response.text()
|
||||||
@@ -183,7 +182,6 @@ class Main(star.Star):
|
|||||||
url,
|
url,
|
||||||
json=payload,
|
json=payload,
|
||||||
headers=header,
|
headers=header,
|
||||||
timeout=6,
|
|
||||||
) as response:
|
) as response:
|
||||||
if response.status != 200:
|
if response.status != 200:
|
||||||
reason = await response.text()
|
reason = await response.text()
|
||||||
@@ -265,7 +263,7 @@ class Main(star.Star):
|
|||||||
"transport": "sse",
|
"transport": "sse",
|
||||||
"url": f"http://appbuilder.baidu.com/v2/ai_search/mcp/sse?api_key={key}",
|
"url": f"http://appbuilder.baidu.com/v2/ai_search/mcp/sse?api_key={key}",
|
||||||
"headers": {},
|
"headers": {},
|
||||||
"timeout": 30,
|
"timeout": 600,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.baidu_initialized = True
|
self.baidu_initialized = True
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "4.17.3"
|
__version__ = "4.18.1"
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ class HandoffTool(FunctionTool, Generic[TContext]):
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The input to be handed off to another agent. This should be a clear and concise request or task.",
|
"description": "The input to be handed off to another agent. This should be a clear and concise request or task.",
|
||||||
},
|
},
|
||||||
|
"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,6 +137,8 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
self.tool_executor = tool_executor
|
self.tool_executor = tool_executor
|
||||||
self.agent_hooks = agent_hooks
|
self.agent_hooks = agent_hooks
|
||||||
self.run_context = run_context
|
self.run_context = run_context
|
||||||
|
self._stop_requested = False
|
||||||
|
self._aborted = False
|
||||||
|
|
||||||
# These two are used for tool schema mode handling
|
# These two are used for tool schema mode handling
|
||||||
# We now have two modes:
|
# We now have two modes:
|
||||||
@@ -328,6 +330,14 @@ 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
|
continue
|
||||||
llm_resp_result = llm_response
|
llm_resp_result = llm_response
|
||||||
|
|
||||||
@@ -339,6 +349,48 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
break # got final response
|
break # got final response
|
||||||
|
|
||||||
if not llm_resp_result:
|
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
|
return
|
||||||
|
|
||||||
# 处理 LLM 响应
|
# 处理 LLM 响应
|
||||||
@@ -357,6 +409,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
return
|
||||||
|
|
||||||
if not llm_resp.tools_call_name:
|
if not llm_resp.tools_call_name:
|
||||||
# 如果没有工具调用,转换到完成状态
|
# 如果没有工具调用,转换到完成状态
|
||||||
@@ -847,5 +900,11 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
"""检查 Agent 是否已完成工作"""
|
"""检查 Agent 是否已完成工作"""
|
||||||
return self._state in (AgentState.DONE, AgentState.ERROR)
|
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:
|
def get_final_llm_resp(self) -> LLMResponse | None:
|
||||||
return self.final_llm_resp
|
return self.final_llm_resp
|
||||||
|
|||||||
@@ -285,6 +285,9 @@ class ToolSet:
|
|||||||
prop_value = convert_schema(value)
|
prop_value = convert_schema(value)
|
||||||
if "default" in prop_value:
|
if "default" in prop_value:
|
||||||
del prop_value["default"]
|
del prop_value["default"]
|
||||||
|
# see #5217
|
||||||
|
if "additionalProperties" in prop_value:
|
||||||
|
del prop_value["additionalProperties"]
|
||||||
properties[key] = prop_value
|
properties[key] = prop_value
|
||||||
|
|
||||||
if properties:
|
if properties:
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ from astrbot.core.provider.provider import TTSProvider
|
|||||||
AgentRunner = ToolLoopAgentRunner[AstrAgentContext]
|
AgentRunner = ToolLoopAgentRunner[AstrAgentContext]
|
||||||
|
|
||||||
|
|
||||||
|
def _should_stop_agent(astr_event) -> bool:
|
||||||
|
return astr_event.is_stopped() or bool(astr_event.get_extra("agent_stop_requested"))
|
||||||
|
|
||||||
|
|
||||||
async def run_agent(
|
async def run_agent(
|
||||||
agent_runner: AgentRunner,
|
agent_runner: AgentRunner,
|
||||||
max_step: int = 30,
|
max_step: int = 30,
|
||||||
@@ -48,10 +52,28 @@ async def run_agent(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
stop_watcher = asyncio.create_task(
|
||||||
|
_watch_agent_stop_signal(agent_runner, astr_event),
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
async for resp in agent_runner.step():
|
async for resp in agent_runner.step():
|
||||||
if astr_event.is_stopped():
|
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)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if _should_stop_agent(astr_event):
|
||||||
|
continue
|
||||||
|
|
||||||
if resp.type == "tool_call_result":
|
if resp.type == "tool_call_result":
|
||||||
msg_chain = resp.data["chain"]
|
msg_chain = resp.data["chain"]
|
||||||
|
|
||||||
@@ -120,6 +142,12 @@ async def run_agent(
|
|||||||
# display the reasoning content only when configured
|
# display the reasoning content only when configured
|
||||||
continue
|
continue
|
||||||
yield resp.data["chain"] # MessageChain
|
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():
|
if agent_runner.done():
|
||||||
# send agent stats to webchat
|
# send agent stats to webchat
|
||||||
if astr_event.get_platform_name() == "webchat":
|
if astr_event.get_platform_name() == "webchat":
|
||||||
@@ -133,6 +161,12 @@ async def run_agent(
|
|||||||
break
|
break
|
||||||
|
|
||||||
except Exception as e:
|
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())
|
logger.error(traceback.format_exc())
|
||||||
|
|
||||||
err_msg = f"\n\nAstrBot 请求失败。\n错误类型: {type(e).__name__}\n错误信息: {e!s}\n\n请在平台日志查看和分享错误详情。\n"
|
err_msg = f"\n\nAstrBot 请求失败。\n错误类型: {type(e).__name__}\n错误信息: {e!s}\n\n请在平台日志查看和分享错误详情。\n"
|
||||||
@@ -155,6 +189,14 @@ async def run_agent(
|
|||||||
return
|
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(
|
async def run_live_agent(
|
||||||
agent_runner: AgentRunner,
|
agent_runner: AgentRunner,
|
||||||
tts_provider: TTSProvider | None = None,
|
tts_provider: TTSProvider | None = None,
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
if isinstance(tool, HandoffTool):
|
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):
|
async for r in cls._execute_handoff(tool, run_context, **tool_args):
|
||||||
yield r
|
yield r
|
||||||
return
|
return
|
||||||
@@ -146,6 +153,86 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
|||||||
content=[mcp.types.TextContent(type="text", text=llm_resp.completion_text)]
|
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
|
@classmethod
|
||||||
async def _execute_background(
|
async def _execute_background(
|
||||||
cls,
|
cls,
|
||||||
@@ -154,12 +241,6 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
|||||||
task_id: str,
|
task_id: str,
|
||||||
**tool_args,
|
**tool_args,
|
||||||
) -> None:
|
) -> None:
|
||||||
from astrbot.core.astr_main_agent import (
|
|
||||||
MainAgentBuildConfig,
|
|
||||||
_get_session_conv,
|
|
||||||
build_main_agent,
|
|
||||||
)
|
|
||||||
|
|
||||||
# run the tool
|
# run the tool
|
||||||
result_text = ""
|
result_text = ""
|
||||||
try:
|
try:
|
||||||
@@ -177,21 +258,53 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
|||||||
f"error: Background task execution failed, internal error: {e!s}"
|
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
|
event = run_context.context.event
|
||||||
ctx = run_context.context.context
|
ctx = run_context.context.context
|
||||||
|
|
||||||
note = (
|
task_result = {
|
||||||
event.get_extra("background_note")
|
"task_id": task_id,
|
||||||
or f"Background task {tool.name} finished."
|
"tool_name": tool_name,
|
||||||
)
|
"result": result_text or "",
|
||||||
extras = {
|
"tool_args": tool_args,
|
||||||
"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)
|
session = MessageSession.from_str(event.unified_msg_origin)
|
||||||
cron_event = CronMessageEvent(
|
cron_event = CronMessageEvent(
|
||||||
context=ctx,
|
context=ctx,
|
||||||
@@ -222,8 +335,11 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
|||||||
)
|
)
|
||||||
req.prompt = (
|
req.prompt = (
|
||||||
"Proceed according to your system instructions. "
|
"Proceed according to your system instructions. "
|
||||||
"Output using same language as previous conversation."
|
"Output using same language as previous conversation. "
|
||||||
" After completing your task, summarize and output your actions and results."
|
"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. "
|
||||||
)
|
)
|
||||||
if not req.func_tool:
|
if not req.func_tool:
|
||||||
req.func_tool = ToolSet()
|
req.func_tool = ToolSet()
|
||||||
@@ -233,7 +349,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
|||||||
event=cron_event, plugin_context=ctx, config=config, req=req
|
event=cron_event, plugin_context=ctx, config=config, req=req
|
||||||
)
|
)
|
||||||
if not result:
|
if not result:
|
||||||
logger.error("Failed to build main agent for background task job.")
|
logger.error(f"Failed to build main agent for background task {tool_name}.")
|
||||||
return
|
return
|
||||||
|
|
||||||
runner = result.agent_runner
|
runner = result.agent_runner
|
||||||
@@ -243,7 +359,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
|||||||
llm_resp = runner.get_final_llm_resp()
|
llm_resp = runner.get_final_llm_resp()
|
||||||
task_meta = extras.get("background_task_result", {})
|
task_meta = extras.get("background_task_result", {})
|
||||||
summary_note = (
|
summary_note = (
|
||||||
f"[BackgroundTask] {task_meta.get('tool_name', tool.name)} "
|
f"[BackgroundTask] {summary_name} "
|
||||||
f"(task_id={task_meta.get('task_id', task_id)}) finished. "
|
f"(task_id={task_meta.get('task_id', task_id)}) finished. "
|
||||||
f"Result: {task_meta.get('result') or result_text or 'no content'}"
|
f"Result: {task_meta.get('result') or result_text or 'no content'}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ from astrbot.core.message.components import File, Image, Reply
|
|||||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||||
from astrbot.core.provider import Provider
|
from astrbot.core.provider import Provider
|
||||||
from astrbot.core.provider.entities import ProviderRequest
|
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.skills.skill_manager import SkillManager, build_skills_prompt
|
||||||
from astrbot.core.star.context import Context
|
from astrbot.core.star.context import Context
|
||||||
from astrbot.core.star.star_handler import star_map
|
from astrbot.core.star.star_handler import star_map
|
||||||
@@ -770,14 +769,6 @@ def _plugin_tool_fix(event: AstrMessageEvent, req: ProviderRequest) -> None:
|
|||||||
if plugin.name in event.plugins_name or plugin.reserved:
|
if plugin.name in event.plugins_name or plugin.reserved:
|
||||||
new_tool_set.add_tool(tool)
|
new_tool_set.add_tool(tool)
|
||||||
req.func_tool = new_tool_set
|
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(
|
async def _handle_webchat(
|
||||||
|
|||||||
@@ -26,6 +26,21 @@ param_schema = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _check_admin_permission(context: ContextWrapper[AstrAgentContext]) -> 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 (
|
||||||
|
"error: Permission denied. Python execution 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
|
||||||
|
|
||||||
|
|
||||||
async def handle_result(result: dict, event: AstrMessageEvent) -> ToolExecResult:
|
async def handle_result(result: dict, event: AstrMessageEvent) -> ToolExecResult:
|
||||||
data = result.get("data", {})
|
data = result.get("data", {})
|
||||||
output = data.get("output", {})
|
output = data.get("output", {})
|
||||||
@@ -66,6 +81,8 @@ class PythonTool(FunctionTool):
|
|||||||
async def call(
|
async def call(
|
||||||
self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False
|
self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False
|
||||||
) -> ToolExecResult:
|
) -> ToolExecResult:
|
||||||
|
if permission_error := _check_admin_permission(context):
|
||||||
|
return permission_error
|
||||||
sb = await get_booter(
|
sb = await get_booter(
|
||||||
context.context.context,
|
context.context.context,
|
||||||
context.context.event.unified_msg_origin,
|
context.context.event.unified_msg_origin,
|
||||||
@@ -87,12 +104,8 @@ class LocalPythonTool(FunctionTool):
|
|||||||
async def call(
|
async def call(
|
||||||
self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False
|
self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False
|
||||||
) -> ToolExecResult:
|
) -> ToolExecResult:
|
||||||
if context.context.event.role != "admin":
|
if permission_error := _check_admin_permission(context):
|
||||||
return (
|
return permission_error
|
||||||
"error: Permission denied. Local Python execution is only allowed for admin users. "
|
|
||||||
"Tell user to set admins in AstrBot WebUI 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."
|
|
||||||
)
|
|
||||||
sb = get_local_booter()
|
sb = get_local_booter()
|
||||||
try:
|
try:
|
||||||
result = await sb.python.exec(code, silent=silent)
|
result = await sb.python.exec(code, silent=silent)
|
||||||
|
|||||||
@@ -9,6 +9,21 @@ from astrbot.core.astr_agent_context import AstrAgentContext
|
|||||||
from ..computer_client import get_booter, get_local_booter
|
from ..computer_client import get_booter, get_local_booter
|
||||||
|
|
||||||
|
|
||||||
|
def _check_admin_permission(context: ContextWrapper[AstrAgentContext]) -> 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 (
|
||||||
|
"error: Permission denied. Shell execution 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
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ExecuteShellTool(FunctionTool):
|
class ExecuteShellTool(FunctionTool):
|
||||||
name: str = "astrbot_execute_shell"
|
name: str = "astrbot_execute_shell"
|
||||||
@@ -46,12 +61,8 @@ class ExecuteShellTool(FunctionTool):
|
|||||||
background: bool = False,
|
background: bool = False,
|
||||||
env: dict = {},
|
env: dict = {},
|
||||||
) -> ToolExecResult:
|
) -> ToolExecResult:
|
||||||
if context.context.event.role != "admin":
|
if permission_error := _check_admin_permission(context):
|
||||||
return (
|
return permission_error
|
||||||
"error: Permission denied. Local shell execution is only allowed for admin users. "
|
|
||||||
"Tell user to set admins in AstrBot WebUI 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."
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.is_local:
|
if self.is_local:
|
||||||
sb = get_local_booter()
|
sb = get_local_booter()
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
|
|||||||
|
|
||||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||||
|
|
||||||
VERSION = "4.17.3"
|
VERSION = "4.18.1"
|
||||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
||||||
|
|
||||||
WEBHOOK_SUPPORTED_PLATFORMS = [
|
WEBHOOK_SUPPORTED_PLATFORMS = [
|
||||||
@@ -128,6 +128,7 @@ DEFAULT_CONFIG = {
|
|||||||
"add_cron_tools": True,
|
"add_cron_tools": True,
|
||||||
},
|
},
|
||||||
"computer_use_runtime": "local",
|
"computer_use_runtime": "local",
|
||||||
|
"computer_use_require_admin": True,
|
||||||
"sandbox": {
|
"sandbox": {
|
||||||
"booter": "shipyard",
|
"booter": "shipyard",
|
||||||
"shipyard_endpoint": "",
|
"shipyard_endpoint": "",
|
||||||
@@ -978,7 +979,7 @@ CONFIG_METADATA_2 = {
|
|||||||
"api_base": "https://api.anthropic.com/v1",
|
"api_base": "https://api.anthropic.com/v1",
|
||||||
"timeout": 120,
|
"timeout": 120,
|
||||||
"proxy": "",
|
"proxy": "",
|
||||||
"anth_thinking_config": {"budget": 0},
|
"anth_thinking_config": {"type": "", "budget": 0, "effort": ""},
|
||||||
},
|
},
|
||||||
"Moonshot": {
|
"Moonshot": {
|
||||||
"id": "moonshot",
|
"id": "moonshot",
|
||||||
@@ -1029,6 +1030,30 @@ CONFIG_METADATA_2 = {
|
|||||||
"proxy": "",
|
"proxy": "",
|
||||||
"custom_headers": {},
|
"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": {
|
"NVIDIA": {
|
||||||
"id": "nvidia",
|
"id": "nvidia",
|
||||||
"provider": "nvidia",
|
"provider": "nvidia",
|
||||||
@@ -1939,13 +1964,25 @@ CONFIG_METADATA_2 = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"anth_thinking_config": {
|
"anth_thinking_config": {
|
||||||
"description": "Thinking Config",
|
"description": "思考配置",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"items": {
|
"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": {
|
"budget": {
|
||||||
"description": "Thinking Budget",
|
"description": "思考预算",
|
||||||
"type": "int",
|
"type": "int",
|
||||||
"hint": "Anthropic thinking.budget_tokens param. Must >= 1024. See: https://platform.claude.com/docs/en/build-with-claude/extended-thinking",
|
"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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -2725,6 +2762,11 @@ CONFIG_METADATA_3 = {
|
|||||||
"labels": ["无", "本地", "沙箱"],
|
"labels": ["无", "本地", "沙箱"],
|
||||||
"hint": "选择 Computer Use 运行环境。",
|
"hint": "选择 Computer Use 运行环境。",
|
||||||
},
|
},
|
||||||
|
"provider_settings.computer_use_require_admin": {
|
||||||
|
"description": "需要 AstrBot 管理员权限",
|
||||||
|
"type": "bool",
|
||||||
|
"hint": "开启后,需要 AstrBot 管理员权限才能调用使用电脑能力。在平台配置->管理员中可添加管理员。使用 /sid 指令查看管理员 ID。",
|
||||||
|
},
|
||||||
"provider_settings.sandbox.booter": {
|
"provider_settings.sandbox.booter": {
|
||||||
"description": "沙箱环境驱动器",
|
"description": "沙箱环境驱动器",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from deprecated import deprecated
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
from astrbot.core.db.po import (
|
from astrbot.core.db.po import (
|
||||||
|
ApiKey,
|
||||||
Attachment,
|
Attachment,
|
||||||
ChatUIProject,
|
ChatUIProject,
|
||||||
CommandConfig,
|
CommandConfig,
|
||||||
@@ -248,6 +249,55 @@ 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
|
@abc.abstractmethod
|
||||||
async def insert_persona(
|
async def insert_persona(
|
||||||
self,
|
self,
|
||||||
@@ -608,6 +658,22 @@ 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
|
@abc.abstractmethod
|
||||||
async def update_platform_session(
|
async def update_platform_session(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -288,6 +288,43 @@ 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):
|
class ChatUIProject(TimestampMixin, SQLModel, table=True):
|
||||||
"""This class represents projects for organizing ChatUI conversations.
|
"""This class represents projects for organizing ChatUI conversations.
|
||||||
|
|
||||||
|
|||||||
+180
-41
@@ -10,6 +10,7 @@ from sqlmodel import col, delete, desc, func, or_, select, text, update
|
|||||||
|
|
||||||
from astrbot.core.db import BaseDatabase
|
from astrbot.core.db import BaseDatabase
|
||||||
from astrbot.core.db.po import (
|
from astrbot.core.db.po import (
|
||||||
|
ApiKey,
|
||||||
Attachment,
|
Attachment,
|
||||||
ChatUIProject,
|
ChatUIProject,
|
||||||
CommandConfig,
|
CommandConfig,
|
||||||
@@ -573,6 +574,100 @@ class SQLiteDatabase(BaseDatabase):
|
|||||||
result = T.cast(CursorResult, await session.execute(query))
|
result = T.cast(CursorResult, await session.execute(query))
|
||||||
return result.rowcount
|
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(
|
async def insert_persona(
|
||||||
self,
|
self,
|
||||||
persona_id,
|
persona_id,
|
||||||
@@ -1317,58 +1412,102 @@ class SQLiteDatabase(BaseDatabase):
|
|||||||
|
|
||||||
Returns a list of dicts containing session info and project info (if session belongs to a project).
|
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:
|
async with self.get_db() as session:
|
||||||
session: AsyncSession
|
session: AsyncSession
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
|
|
||||||
# LEFT JOIN with SessionProjectRelation and ChatUIProject to get project info
|
base_query = self._build_platform_sessions_query(
|
||||||
query = (
|
creator=creator,
|
||||||
select(
|
platform_id=platform_id,
|
||||||
PlatformSession,
|
exclude_project_sessions=exclude_project_sessions,
|
||||||
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:
|
total_result = await session.execute(
|
||||||
query = query.where(PlatformSession.platform_id == platform_id)
|
select(func.count()).select_from(base_query.subquery())
|
||||||
|
)
|
||||||
|
total = int(total_result.scalar_one() or 0)
|
||||||
|
|
||||||
query = (
|
result_query = (
|
||||||
query.order_by(desc(PlatformSession.updated_at))
|
base_query.order_by(desc(PlatformSession.updated_at))
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.limit(page_size)
|
.limit(page_size)
|
||||||
)
|
)
|
||||||
result = await session.execute(query)
|
result = await session.execute(result_query)
|
||||||
|
|
||||||
# Convert to list of dicts with session and project info
|
sessions_with_projects = self._rows_to_session_dicts(result.all())
|
||||||
sessions_with_projects = []
|
return sessions_with_projects, total
|
||||||
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(
|
async def update_platform_session(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -13,16 +13,19 @@ from astrbot.core.knowledge_base.models import (
|
|||||||
KBMedia,
|
KBMedia,
|
||||||
KnowledgeBase,
|
KnowledgeBase,
|
||||||
)
|
)
|
||||||
|
from astrbot.core.utils.astrbot_path import get_astrbot_knowledge_base_path
|
||||||
|
|
||||||
|
|
||||||
class KBSQLiteDatabase:
|
class KBSQLiteDatabase:
|
||||||
def __init__(self, db_path: str = "data/knowledge_base/kb.db") -> None:
|
def __init__(self, db_path: str | None = None) -> None:
|
||||||
"""初始化知识库数据库
|
"""初始化知识库数据库
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db_path: 数据库文件路径, 默认为 data/knowledge_base/kb.db
|
db_path: 数据库文件路径, 默认位于 AstrBot 数据目录下的 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.db_path = db_path
|
||||||
self.DATABASE_URL = f"sqlite+aiosqlite:///{db_path}"
|
self.DATABASE_URL = f"sqlite+aiosqlite:///{db_path}"
|
||||||
self.inited = False
|
self.inited = False
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from astrbot.core import logger
|
from astrbot.core import logger
|
||||||
from astrbot.core.provider.manager import ProviderManager
|
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.fixed_size import FixedSizeChunker
|
||||||
from .chunking.recursive import RecursiveCharacterChunker
|
from .chunking.recursive import RecursiveCharacterChunker
|
||||||
@@ -13,7 +14,7 @@ from .retrieval.manager import RetrievalManager, RetrievalResult
|
|||||||
from .retrieval.rank_fusion import RankFusion
|
from .retrieval.rank_fusion import RankFusion
|
||||||
from .retrieval.sparse_retriever import SparseRetriever
|
from .retrieval.sparse_retriever import SparseRetriever
|
||||||
|
|
||||||
FILES_PATH = "data/knowledge_base"
|
FILES_PATH = get_astrbot_knowledge_base_path()
|
||||||
DB_PATH = Path(FILES_PATH) / "kb.db"
|
DB_PATH = Path(FILES_PATH) / "kb.db"
|
||||||
"""Knowledge Base storage root directory"""
|
"""Knowledge Base storage root directory"""
|
||||||
CHUNKER = RecursiveCharacterChunker()
|
CHUNKER = RecursiveCharacterChunker()
|
||||||
@@ -27,7 +28,7 @@ class KnowledgeBaseManager:
|
|||||||
self,
|
self,
|
||||||
provider_manager: ProviderManager,
|
provider_manager: ProviderManager,
|
||||||
) -> None:
|
) -> None:
|
||||||
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
self.provider_manager = provider_manager
|
self.provider_manager = provider_manager
|
||||||
self._session_deleted_callback_registered = False
|
self._session_deleted_callback_registered = False
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,8 @@ class Record(BaseMessageComponent):
|
|||||||
cache: bool | None = True
|
cache: bool | None = True
|
||||||
proxy: bool | None = True
|
proxy: bool | None = True
|
||||||
timeout: int | None = 0
|
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
|
path: str | None
|
||||||
|
|
||||||
|
|||||||
@@ -247,13 +247,16 @@ class InternalAgentSubStage(Stage):
|
|||||||
yield
|
yield
|
||||||
|
|
||||||
# 保存历史记录
|
# 保存历史记录
|
||||||
if not event.is_stopped() and agent_runner.done():
|
if agent_runner.done() and (
|
||||||
|
not event.is_stopped() or agent_runner.was_aborted()
|
||||||
|
):
|
||||||
await self._save_to_history(
|
await self._save_to_history(
|
||||||
event,
|
event,
|
||||||
req,
|
req,
|
||||||
agent_runner.get_final_llm_resp(),
|
agent_runner.get_final_llm_resp(),
|
||||||
agent_runner.run_context.messages,
|
agent_runner.run_context.messages,
|
||||||
agent_runner.stats,
|
agent_runner.stats,
|
||||||
|
user_aborted=agent_runner.was_aborted(),
|
||||||
)
|
)
|
||||||
|
|
||||||
elif streaming_response and not stream_to_general:
|
elif streaming_response and not stream_to_general:
|
||||||
@@ -308,13 +311,14 @@ class InternalAgentSubStage(Stage):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 检查事件是否被停止,如果被停止则不保存历史记录
|
# 检查事件是否被停止,如果被停止则不保存历史记录
|
||||||
if not event.is_stopped():
|
if not event.is_stopped() or agent_runner.was_aborted():
|
||||||
await self._save_to_history(
|
await self._save_to_history(
|
||||||
event,
|
event,
|
||||||
req,
|
req,
|
||||||
final_resp,
|
final_resp,
|
||||||
agent_runner.run_context.messages,
|
agent_runner.run_context.messages,
|
||||||
agent_runner.stats,
|
agent_runner.stats,
|
||||||
|
user_aborted=agent_runner.was_aborted(),
|
||||||
)
|
)
|
||||||
|
|
||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
@@ -340,16 +344,29 @@ class InternalAgentSubStage(Stage):
|
|||||||
llm_response: LLMResponse | None,
|
llm_response: LLMResponse | None,
|
||||||
all_messages: list[Message],
|
all_messages: list[Message],
|
||||||
runner_stats: AgentStats | None,
|
runner_stats: AgentStats | None,
|
||||||
|
user_aborted: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
if (
|
if not req or not req.conversation:
|
||||||
not req
|
|
||||||
or not req.conversation
|
|
||||||
or not llm_response
|
|
||||||
or llm_response.role != "assistant"
|
|
||||||
):
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if not llm_response.completion_text and not req.tool_calls_result:
|
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
|
||||||
|
):
|
||||||
logger.debug("LLM 响应为空,不保存记录。")
|
logger.debug("LLM 响应为空,不保存记录。")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -363,6 +380,14 @@ class InternalAgentSubStage(Stage):
|
|||||||
continue
|
continue
|
||||||
message_to_save.append(message.model_dump())
|
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
|
token_usage = None
|
||||||
if runner_stats:
|
if runner_stats:
|
||||||
# token_usage = runner_stats.token_usage.total
|
# token_usage = runner_stats.token_usage.total
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ from astrbot.core import logger
|
|||||||
from astrbot.core.message.message_event_result import MessageEventResult
|
from astrbot.core.message.message_event_result import MessageEventResult
|
||||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||||
from astrbot.core.star.star import star_map
|
from astrbot.core.star.star import star_map
|
||||||
from astrbot.core.star.star_handler import StarHandlerMetadata
|
from astrbot.core.star.star_handler import EventType, StarHandlerMetadata
|
||||||
|
|
||||||
from ...context import PipelineContext, call_handler
|
from ...context import PipelineContext, call_event_hook, call_handler
|
||||||
from ..stage import Stage
|
from ..stage import Stage
|
||||||
|
|
||||||
|
|
||||||
@@ -48,10 +48,20 @@ class StarRequestSubStage(Stage):
|
|||||||
yield ret
|
yield ret
|
||||||
event.clear_result() # 清除上一个 handler 的结果
|
event.clear_result() # 清除上一个 handler 的结果
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(traceback.format_exc())
|
traceback_text = traceback.format_exc()
|
||||||
|
logger.error(traceback_text)
|
||||||
logger.error(f"Star {handler.handler_full_name} handle error: {e}")
|
logger.error(f"Star {handler.handler_full_name} handle error: {e}")
|
||||||
|
|
||||||
if event.is_at_or_wake_command:
|
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:
|
||||||
ret = f":(\n\n在调用插件 {md.name} 的处理函数 {handler.handler_name} 时出现异常:{e}"
|
ret = f":(\n\n在调用插件 {md.name} 的处理函数 {handler.handler_name} 时出现异常:{e}"
|
||||||
event.set_result(MessageEventResult().message(ret))
|
event.set_result(MessageEventResult().message(ret))
|
||||||
yield
|
yield
|
||||||
|
|||||||
@@ -33,6 +33,21 @@ class RespondStage(Stage):
|
|||||||
Comp.Nodes: lambda comp: bool(comp.nodes), # 多个转发节点
|
Comp.Nodes: lambda comp: bool(comp.nodes), # 多个转发节点
|
||||||
Comp.File: lambda comp: bool(comp.file_ or comp.url),
|
Comp.File: lambda comp: bool(comp.file_ or comp.url),
|
||||||
Comp.WechatEmoji: lambda comp: comp.md5 is not None, # 微信表情
|
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:
|
async def initialize(self, ctx: PipelineContext) -> None:
|
||||||
|
|||||||
@@ -315,6 +315,7 @@ class ResultDecorateStage(Stage):
|
|||||||
Record(
|
Record(
|
||||||
file=url or audio_path,
|
file=url or audio_path,
|
||||||
url=url or audio_path,
|
url=url or audio_path,
|
||||||
|
text=comp.text,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if dual_output:
|
if dual_output:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from astrbot.core.platform.sources.webchat.webchat_event import WebChatMessageEv
|
|||||||
from astrbot.core.platform.sources.wecom_ai_bot.wecomai_event import (
|
from astrbot.core.platform.sources.wecom_ai_bot.wecomai_event import (
|
||||||
WecomAIBotMessageEvent,
|
WecomAIBotMessageEvent,
|
||||||
)
|
)
|
||||||
|
from astrbot.core.utils.active_event_registry import active_event_registry
|
||||||
|
|
||||||
from . import STAGES_ORDER
|
from . import STAGES_ORDER
|
||||||
from .context import PipelineContext
|
from .context import PipelineContext
|
||||||
@@ -79,10 +80,14 @@ class PipelineScheduler:
|
|||||||
event (AstrMessageEvent): 事件对象
|
event (AstrMessageEvent): 事件对象
|
||||||
|
|
||||||
"""
|
"""
|
||||||
await self._process_stages(event)
|
active_event_registry.register(event)
|
||||||
|
try:
|
||||||
|
await self._process_stages(event)
|
||||||
|
|
||||||
# 如果没有发送操作, 则发送一个空消息, 以便于后续的处理
|
# 如果没有发送操作, 则发送一个空消息, 以便于后续的处理
|
||||||
if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent):
|
if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent):
|
||||||
await event.send(None)
|
await event.send(None)
|
||||||
|
|
||||||
logger.debug("pipeline 执行完毕。")
|
logger.debug("pipeline 执行完毕。")
|
||||||
|
finally:
|
||||||
|
active_event_registry.unregister(event)
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ from typing import cast
|
|||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
import botpy
|
import botpy
|
||||||
|
import botpy.errors
|
||||||
import botpy.message
|
import botpy.message
|
||||||
import botpy.types
|
import botpy.types
|
||||||
import botpy.types.message
|
import botpy.types.message
|
||||||
from botpy import Client
|
from botpy import Client
|
||||||
from botpy.http import Route
|
from botpy.http import Route
|
||||||
from botpy.types import message
|
from botpy.types import message
|
||||||
from botpy.types.message import Media
|
from botpy.types.message import MarkdownPayload, Media
|
||||||
|
|
||||||
from astrbot.api import logger
|
from astrbot.api import logger
|
||||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||||
@@ -24,7 +25,29 @@ 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
|
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):
|
class QQOfficialMessageEvent(AstrMessageEvent):
|
||||||
|
MARKDOWN_NOT_ALLOWED_ERROR = "不允许发送原生 markdown"
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
message_str: str,
|
message_str: str,
|
||||||
@@ -114,7 +137,9 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
payload: dict = {
|
payload: dict = {
|
||||||
"content": plain_text,
|
# "content": plain_text,
|
||||||
|
"markdown": MarkdownPayload(content=plain_text) if plain_text else None,
|
||||||
|
"msg_type": 2,
|
||||||
"msg_id": self.message_obj.message_id,
|
"msg_id": self.message_obj.message_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,9 +170,13 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
|||||||
)
|
)
|
||||||
payload["media"] = media
|
payload["media"] = media
|
||||||
payload["msg_type"] = 7
|
payload["msg_type"] = 7
|
||||||
ret = await self.bot.api.post_group_message(
|
ret = await self._send_with_markdown_fallback(
|
||||||
group_openid=source.group_openid,
|
send_func=lambda retry_payload: self.bot.api.post_group_message(
|
||||||
**payload,
|
group_openid=source.group_openid, # type: ignore
|
||||||
|
**retry_payload,
|
||||||
|
),
|
||||||
|
payload=payload,
|
||||||
|
plain_text=plain_text,
|
||||||
)
|
)
|
||||||
|
|
||||||
case botpy.message.C2CMessage():
|
case botpy.message.C2CMessage():
|
||||||
@@ -168,30 +197,53 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
|||||||
payload["media"] = media
|
payload["media"] = media
|
||||||
payload["msg_type"] = 7
|
payload["msg_type"] = 7
|
||||||
if stream:
|
if stream:
|
||||||
ret = await self.post_c2c_message(
|
ret = await self._send_with_markdown_fallback(
|
||||||
openid=source.author.user_openid,
|
send_func=lambda retry_payload: self.post_c2c_message(
|
||||||
**payload,
|
openid=source.author.user_openid,
|
||||||
stream=stream,
|
**retry_payload,
|
||||||
|
stream=stream,
|
||||||
|
),
|
||||||
|
payload=payload,
|
||||||
|
plain_text=plain_text,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
ret = await self.post_c2c_message(
|
ret = await self._send_with_markdown_fallback(
|
||||||
openid=source.author.user_openid,
|
send_func=lambda retry_payload: self.post_c2c_message(
|
||||||
**payload,
|
openid=source.author.user_openid,
|
||||||
|
**retry_payload,
|
||||||
|
),
|
||||||
|
payload=payload,
|
||||||
|
plain_text=plain_text,
|
||||||
)
|
)
|
||||||
logger.debug(f"Message sent to C2C: {ret}")
|
logger.debug(f"Message sent to C2C: {ret}")
|
||||||
|
|
||||||
case botpy.message.Message():
|
case botpy.message.Message():
|
||||||
if image_path:
|
if image_path:
|
||||||
payload["file_image"] = image_path
|
payload["file_image"] = image_path
|
||||||
ret = await self.bot.api.post_message(
|
# Guild text-channel send API (/channels/{channel_id}/messages) does not use v2 msg_type.
|
||||||
channel_id=source.channel_id,
|
payload.pop("msg_type", None)
|
||||||
**payload,
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
case botpy.message.DirectMessage():
|
case botpy.message.DirectMessage():
|
||||||
if image_path:
|
if image_path:
|
||||||
payload["file_image"] = image_path
|
payload["file_image"] = image_path
|
||||||
ret = await self.bot.api.post_dms(guild_id=source.guild_id, **payload)
|
# 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,
|
||||||
|
)
|
||||||
|
|
||||||
case _:
|
case _:
|
||||||
pass
|
pass
|
||||||
@@ -202,6 +254,32 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
|||||||
|
|
||||||
return ret
|
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(
|
async def upload_group_and_c2c_image(
|
||||||
self,
|
self,
|
||||||
image_base64: str,
|
image_base64: str,
|
||||||
|
|||||||
@@ -174,14 +174,19 @@ class TelegramPlatformAdapter(Platform):
|
|||||||
if not handler_metadata.enabled:
|
if not handler_metadata.enabled:
|
||||||
continue
|
continue
|
||||||
for event_filter in handler_metadata.event_filters:
|
for event_filter in handler_metadata.event_filters:
|
||||||
cmd_info = self._extract_command_info(
|
cmd_info_list = self._extract_command_info(
|
||||||
event_filter,
|
event_filter,
|
||||||
handler_metadata,
|
handler_metadata,
|
||||||
skip_commands,
|
skip_commands,
|
||||||
)
|
)
|
||||||
if cmd_info:
|
if cmd_info_list:
|
||||||
cmd_name, description = cmd_info
|
for cmd_name, description in cmd_info_list:
|
||||||
command_dict.setdefault(cmd_name, description)
|
if cmd_name in command_dict:
|
||||||
|
logger.warning(
|
||||||
|
f"命令名 '{cmd_name}' 重复注册,将使用首次注册的定义: "
|
||||||
|
f"'{command_dict[cmd_name]}'"
|
||||||
|
)
|
||||||
|
command_dict.setdefault(cmd_name, description)
|
||||||
|
|
||||||
commands_a = sorted(command_dict.keys())
|
commands_a = sorted(command_dict.keys())
|
||||||
return [BotCommand(cmd, command_dict[cmd]) for cmd in commands_a]
|
return [BotCommand(cmd, command_dict[cmd]) for cmd in commands_a]
|
||||||
@@ -191,9 +196,9 @@ class TelegramPlatformAdapter(Platform):
|
|||||||
event_filter,
|
event_filter,
|
||||||
handler_metadata,
|
handler_metadata,
|
||||||
skip_commands: set,
|
skip_commands: set,
|
||||||
) -> tuple[str, str] | None:
|
) -> list[tuple[str, str]] | None:
|
||||||
"""从事件过滤器中提取指令信息"""
|
"""从事件过滤器中提取指令信息,包括所有别名"""
|
||||||
cmd_name = None
|
cmd_names = []
|
||||||
is_group = False
|
is_group = False
|
||||||
if isinstance(event_filter, CommandFilter) and event_filter.command_name:
|
if isinstance(event_filter, CommandFilter) and event_filter.command_name:
|
||||||
if (
|
if (
|
||||||
@@ -201,26 +206,32 @@ class TelegramPlatformAdapter(Platform):
|
|||||||
and event_filter.parent_command_names != [""]
|
and event_filter.parent_command_names != [""]
|
||||||
):
|
):
|
||||||
return None
|
return None
|
||||||
cmd_name = event_filter.command_name
|
# 收集主命令名和所有别名
|
||||||
|
cmd_names = [event_filter.command_name]
|
||||||
|
if event_filter.alias:
|
||||||
|
cmd_names.extend(event_filter.alias)
|
||||||
elif isinstance(event_filter, CommandGroupFilter):
|
elif isinstance(event_filter, CommandGroupFilter):
|
||||||
if event_filter.parent_group:
|
if event_filter.parent_group:
|
||||||
return None
|
return None
|
||||||
cmd_name = event_filter.group_name
|
cmd_names = [event_filter.group_name]
|
||||||
is_group = True
|
is_group = True
|
||||||
|
|
||||||
if not cmd_name or cmd_name in skip_commands:
|
result = []
|
||||||
return None
|
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 re.match(r"^[a-z0-9_]+$", cmd_name) or len(cmd_name) > 32:
|
# Build description.
|
||||||
return None
|
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))
|
||||||
|
|
||||||
# Build description.
|
return result if result else None
|
||||||
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:
|
async def start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
if not update.effective_chat:
|
if not update.effective_chat:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from typing import Any, cast
|
|||||||
import telegramify_markdown
|
import telegramify_markdown
|
||||||
from telegram import ReactionTypeCustomEmoji, ReactionTypeEmoji
|
from telegram import ReactionTypeCustomEmoji, ReactionTypeEmoji
|
||||||
from telegram.constants import ChatAction
|
from telegram.constants import ChatAction
|
||||||
|
from telegram.error import BadRequest
|
||||||
from telegram.ext import ExtBot
|
from telegram.ext import ExtBot
|
||||||
|
|
||||||
from astrbot import logger
|
from astrbot import logger
|
||||||
@@ -119,6 +120,65 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
|||||||
client, user_name, ChatAction.TYPING, message_thread_id
|
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(
|
async def _ensure_typing(
|
||||||
self,
|
self,
|
||||||
user_name: str,
|
user_name: str,
|
||||||
@@ -211,7 +271,13 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
|||||||
)
|
)
|
||||||
elif isinstance(i, Record):
|
elif isinstance(i, Record):
|
||||||
path = await i.convert_to_file_path()
|
path = await i.convert_to_file_path()
|
||||||
await client.send_voice(voice=path, **cast(Any, payload))
|
await cls._send_voice_with_fallback(
|
||||||
|
client,
|
||||||
|
path,
|
||||||
|
payload,
|
||||||
|
caption=i.text or None,
|
||||||
|
use_media_action=False,
|
||||||
|
)
|
||||||
|
|
||||||
async def send(self, message: MessageChain) -> None:
|
async def send(self, message: MessageChain) -> None:
|
||||||
if self.get_message_type() == MessageType.GROUP_MESSAGE:
|
if self.get_message_type() == MessageType.GROUP_MESSAGE:
|
||||||
@@ -330,14 +396,14 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
|||||||
continue
|
continue
|
||||||
elif isinstance(i, Record):
|
elif isinstance(i, Record):
|
||||||
path = await i.convert_to_file_path()
|
path = await i.convert_to_file_path()
|
||||||
await self._send_media_with_action(
|
await self._send_voice_with_fallback(
|
||||||
self.client,
|
self.client,
|
||||||
ChatAction.UPLOAD_VOICE,
|
path,
|
||||||
self.client.send_voice,
|
payload,
|
||||||
|
caption=i.text or delta or None,
|
||||||
user_name=user_name,
|
user_name=user_name,
|
||||||
message_thread_id=message_thread_id,
|
message_thread_id=message_thread_id,
|
||||||
voice=path,
|
use_media_action=True,
|
||||||
**cast(Any, payload),
|
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
|||||||
|
|
||||||
from .webchat_queue_mgr import webchat_queue_mgr
|
from .webchat_queue_mgr import webchat_queue_mgr
|
||||||
|
|
||||||
imgs_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs")
|
attachments_dir = os.path.join(get_astrbot_data_path(), "attachments")
|
||||||
|
|
||||||
|
|
||||||
class WebChatMessageEvent(AstrMessageEvent):
|
class WebChatMessageEvent(AstrMessageEvent):
|
||||||
def __init__(self, message_str, message_obj, platform_meta, session_id) -> None:
|
def __init__(self, message_str, message_obj, platform_meta, session_id) -> None:
|
||||||
super().__init__(message_str, message_obj, platform_meta, session_id)
|
super().__init__(message_str, message_obj, platform_meta, session_id)
|
||||||
os.makedirs(imgs_dir, exist_ok=True)
|
os.makedirs(attachments_dir, exist_ok=True)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _send(
|
async def _send(
|
||||||
@@ -69,7 +69,7 @@ class WebChatMessageEvent(AstrMessageEvent):
|
|||||||
elif isinstance(comp, Image):
|
elif isinstance(comp, Image):
|
||||||
# save image to local
|
# save image to local
|
||||||
filename = f"{str(uuid.uuid4())}.jpg"
|
filename = f"{str(uuid.uuid4())}.jpg"
|
||||||
path = os.path.join(imgs_dir, filename)
|
path = os.path.join(attachments_dir, filename)
|
||||||
image_base64 = await comp.convert_to_base64()
|
image_base64 = await comp.convert_to_base64()
|
||||||
with open(path, "wb") as f:
|
with open(path, "wb") as f:
|
||||||
f.write(base64.b64decode(image_base64))
|
f.write(base64.b64decode(image_base64))
|
||||||
@@ -85,7 +85,7 @@ class WebChatMessageEvent(AstrMessageEvent):
|
|||||||
elif isinstance(comp, Record):
|
elif isinstance(comp, Record):
|
||||||
# save record to local
|
# save record to local
|
||||||
filename = f"{str(uuid.uuid4())}.wav"
|
filename = f"{str(uuid.uuid4())}.wav"
|
||||||
path = os.path.join(imgs_dir, filename)
|
path = os.path.join(attachments_dir, filename)
|
||||||
record_base64 = await comp.convert_to_base64()
|
record_base64 = await comp.convert_to_base64()
|
||||||
with open(path, "wb") as f:
|
with open(path, "wb") as f:
|
||||||
f.write(base64.b64decode(record_base64))
|
f.write(base64.b64decode(record_base64))
|
||||||
@@ -104,7 +104,7 @@ class WebChatMessageEvent(AstrMessageEvent):
|
|||||||
original_name = comp.name or os.path.basename(file_path)
|
original_name = comp.name or os.path.basename(file_path)
|
||||||
ext = os.path.splitext(original_name)[1] or ""
|
ext = os.path.splitext(original_name)[1] or ""
|
||||||
filename = f"{uuid.uuid4()!s}{ext}"
|
filename = f"{uuid.uuid4()!s}{ext}"
|
||||||
dest_path = os.path.join(imgs_dir, filename)
|
dest_path = os.path.join(attachments_dir, filename)
|
||||||
shutil.copy2(file_path, dest_path)
|
shutil.copy2(file_path, dest_path)
|
||||||
data = f"[FILE]{filename}"
|
data = f"[FILE]{filename}"
|
||||||
await web_chat_back_queue.put(
|
await web_chat_back_queue.put(
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
import quart
|
import quart
|
||||||
from requests import Response
|
from requests import Response
|
||||||
from wechatpy import WeChatClient, parse_message
|
from wechatpy import WeChatClient, create_reply, parse_message
|
||||||
from wechatpy.crypto import WeChatCrypto
|
from wechatpy.crypto import WeChatCrypto
|
||||||
from wechatpy.exceptions import InvalidSignatureException
|
from wechatpy.exceptions import InvalidSignatureException
|
||||||
from wechatpy.messages import BaseMessage, ImageMessage, TextMessage, VoiceMessage
|
from wechatpy.messages import BaseMessage, ImageMessage, TextMessage, VoiceMessage
|
||||||
@@ -38,7 +39,12 @@ else:
|
|||||||
|
|
||||||
|
|
||||||
class WeixinOfficialAccountServer:
|
class WeixinOfficialAccountServer:
|
||||||
def __init__(self, event_queue: asyncio.Queue, config: dict) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
event_queue: asyncio.Queue,
|
||||||
|
config: dict,
|
||||||
|
user_buffer: dict[Any, dict[str, Any]],
|
||||||
|
) -> None:
|
||||||
self.server = quart.Quart(__name__)
|
self.server = quart.Quart(__name__)
|
||||||
self.port = int(cast(int | str, config.get("port")))
|
self.port = int(cast(int | str, config.get("port")))
|
||||||
self.callback_server_host = config.get("callback_server_host", "0.0.0.0")
|
self.callback_server_host = config.get("callback_server_host", "0.0.0.0")
|
||||||
@@ -62,6 +68,10 @@ class WeixinOfficialAccountServer:
|
|||||||
self.callback: Callable[[BaseMessage], Awaitable[None]] | None = None
|
self.callback: Callable[[BaseMessage], Awaitable[None]] | None = None
|
||||||
self.shutdown_event = asyncio.Event()
|
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):
|
async def verify(self):
|
||||||
"""内部服务器的 GET 验证入口"""
|
"""内部服务器的 GET 验证入口"""
|
||||||
return await self.handle_verify(quart.request)
|
return await self.handle_verify(quart.request)
|
||||||
@@ -98,6 +108,22 @@ class WeixinOfficialAccountServer:
|
|||||||
"""内部服务器的 POST 回调入口"""
|
"""内部服务器的 POST 回调入口"""
|
||||||
return await self.handle_callback(quart.request)
|
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:
|
async def handle_callback(self, request) -> str:
|
||||||
"""处理回调请求,可被统一 webhook 入口复用
|
"""处理回调请求,可被统一 webhook 入口复用
|
||||||
|
|
||||||
@@ -123,14 +149,152 @@ class WeixinOfficialAccountServer:
|
|||||||
raise
|
raise
|
||||||
logger.info(f"解析成功: {msg}")
|
logger.info(f"解析成功: {msg}")
|
||||||
|
|
||||||
if self.callback:
|
if not self.callback:
|
||||||
|
return "success"
|
||||||
|
|
||||||
|
# by pass passive reply logic and return active reply directly.
|
||||||
|
if self.active_send_mode:
|
||||||
result_xml = await self.callback(msg)
|
result_xml = await self.callback(msg)
|
||||||
if not result_xml:
|
if not result_xml:
|
||||||
return "success"
|
return "success"
|
||||||
if isinstance(result_xml, str):
|
if isinstance(result_xml, str):
|
||||||
return result_xml
|
return result_xml
|
||||||
|
|
||||||
return "success"
|
# 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)
|
||||||
|
|
||||||
async def start_polling(self) -> None:
|
async def start_polling(self) -> None:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -176,7 +340,10 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
|
|||||||
if not self.api_base_url.endswith("/"):
|
if not self.api_base_url.endswith("/"):
|
||||||
self.api_base_url += "/"
|
self.api_base_url += "/"
|
||||||
|
|
||||||
self.server = WeixinOfficialAccountServer(self._event_queue, self.config)
|
self.user_buffer: dict[str, dict[str, Any]] = {} # from_user -> state
|
||||||
|
self.server = WeixinOfficialAccountServer(
|
||||||
|
self._event_queue, self.config, self.user_buffer
|
||||||
|
)
|
||||||
|
|
||||||
self.client = WeChatClient(
|
self.client = WeChatClient(
|
||||||
self.config["appid"].strip(),
|
self.config["appid"].strip(),
|
||||||
@@ -193,28 +360,33 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
|
|||||||
try:
|
try:
|
||||||
if self.active_send_mode:
|
if self.active_send_mode:
|
||||||
await self.convert_message(msg, None)
|
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:
|
else:
|
||||||
if str(msg.id) in self.wexin_event_workers:
|
future = asyncio.get_event_loop().create_future()
|
||||||
future = self.wexin_event_workers[str(cast(str | int, msg.id))]
|
self.wexin_event_workers[msg_id] = future
|
||||||
logger.debug(f"duplicate message id checked: {msg.id}")
|
await self.convert_message(msg, future)
|
||||||
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!
|
# I love shield so much!
|
||||||
result = await asyncio.wait_for(
|
result = await asyncio.wait_for(
|
||||||
asyncio.shield(future),
|
asyncio.shield(future),
|
||||||
60,
|
180,
|
||||||
) # wait for 60s
|
) # wait for 180s
|
||||||
logger.debug(f"Got future result: {result}")
|
logger.debug(f"Got future result: {result}")
|
||||||
self.wexin_event_workers.pop(str(cast(str | int, msg.id)), None)
|
return result
|
||||||
return result # xml. see weixin_offacc_event.py
|
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
pass
|
logger.info(f"callback 处理消息超时: message_id={msg.id}")
|
||||||
|
return create_reply("处理消息超时,请稍后再试。", msg)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"转换消息时出现异常: {e}")
|
logger.error(f"转换消息时出现异常: {e}")
|
||||||
|
finally:
|
||||||
|
self.wexin_event_workers.pop(str(cast(str | int, msg.id)), None)
|
||||||
|
|
||||||
self.server.callback = callback
|
self.server.callback = callback
|
||||||
|
self.server.active_send_mode = self.active_send_mode
|
||||||
|
|
||||||
@override
|
@override
|
||||||
async def send_by_session(
|
async def send_by_session(
|
||||||
@@ -336,12 +508,19 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
|
|||||||
await self.handle_msg(abm)
|
await self.handle_msg(abm)
|
||||||
|
|
||||||
async def handle_msg(self, message: AstrBotMessage) -> None:
|
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_event = WeixinOfficialAccountPlatformEvent(
|
||||||
message_str=message.message_str,
|
message_str=message.message_str,
|
||||||
message_obj=message,
|
message_obj=message,
|
||||||
platform_meta=self.meta(),
|
platform_meta=self.meta(),
|
||||||
session_id=message.session_id,
|
session_id=message.session_id,
|
||||||
client=self.client,
|
client=self.client,
|
||||||
|
message_out=buffer,
|
||||||
)
|
)
|
||||||
self.commit_event(message_event)
|
self.commit_event(message_event)
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
from typing import cast
|
from typing import Any, cast
|
||||||
|
|
||||||
from wechatpy import WeChatClient
|
from wechatpy import WeChatClient
|
||||||
from wechatpy.replies import ImageReply, TextReply, VoiceReply
|
from wechatpy.replies import ImageReply, VoiceReply
|
||||||
|
|
||||||
from astrbot.api import logger
|
from astrbot.api import logger
|
||||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||||
@@ -20,9 +20,11 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
|
|||||||
platform_meta: PlatformMetadata,
|
platform_meta: PlatformMetadata,
|
||||||
session_id: str,
|
session_id: str,
|
||||||
client: WeChatClient,
|
client: WeChatClient,
|
||||||
|
message_out: dict[Any, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(message_str, message_obj, platform_meta, session_id)
|
super().__init__(message_str, message_obj, platform_meta, session_id)
|
||||||
self.client = client
|
self.client = client
|
||||||
|
self.message_out = message_out
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def send_with_client(
|
async def send_with_client(
|
||||||
@@ -32,8 +34,8 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
|
|||||||
) -> None:
|
) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def split_plain(self, plain: str) -> list[str]:
|
async def split_plain(self, plain: str, max_length: int = 1024) -> list[str]:
|
||||||
"""将长文本分割成多个小文本, 每个小文本长度不超过 2048 字符
|
"""将长文本分割成多个小文本, 每个小文本长度不超过 max_length 字符
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
plain (str): 要分割的长文本
|
plain (str): 要分割的长文本
|
||||||
@@ -41,18 +43,18 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
|
|||||||
list[str]: 分割后的文本列表
|
list[str]: 分割后的文本列表
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if len(plain) <= 2048:
|
if len(plain) <= max_length:
|
||||||
return [plain]
|
return [plain]
|
||||||
result = []
|
result = []
|
||||||
start = 0
|
start = 0
|
||||||
while start < len(plain):
|
while start < len(plain):
|
||||||
# 剩下的字符串长度<2048时结束
|
# 剩下的字符串长度<max_length时结束
|
||||||
if start + 2048 >= len(plain):
|
if start + max_length >= len(plain):
|
||||||
result.append(plain[start:])
|
result.append(plain[start:])
|
||||||
break
|
break
|
||||||
|
|
||||||
# 向前搜索分割标点符号
|
# 向前搜索分割标点符号
|
||||||
end = min(start + 2048, len(plain))
|
end = min(start + max_length, len(plain))
|
||||||
cut_position = end
|
cut_position = end
|
||||||
for i in range(end, start, -1):
|
for i in range(end, start, -1):
|
||||||
if i < len(plain) and plain[i - 1] in [
|
if i < len(plain) and plain[i - 1] in [
|
||||||
@@ -87,19 +89,15 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
|
|||||||
if isinstance(comp, Plain):
|
if isinstance(comp, Plain):
|
||||||
# Split long text messages if needed
|
# Split long text messages if needed
|
||||||
plain_chunks = await self.split_plain(comp.text)
|
plain_chunks = await self.split_plain(comp.text)
|
||||||
for chunk in plain_chunks:
|
if active_send_mode:
|
||||||
if active_send_mode:
|
for chunk in plain_chunks:
|
||||||
self.client.message.send_text(message_obj.sender.user_id, chunk)
|
self.client.message.send_text(message_obj.sender.user_id, chunk)
|
||||||
else:
|
else:
|
||||||
reply = TextReply(
|
# disable passive sending, just store the chunks in
|
||||||
content=chunk,
|
logger.debug(
|
||||||
message=cast(dict, self.message_obj.raw_message)["message"],
|
f"split plain into {len(plain_chunks)} chunks for passive reply. Message not sent."
|
||||||
)
|
)
|
||||||
xml = reply.render()
|
self.message_out["cached_xml"] = plain_chunks
|
||||||
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):
|
elif isinstance(comp, Image):
|
||||||
img_path = await comp.convert_to_file_path()
|
img_path = await comp.convert_to_file_path()
|
||||||
|
|
||||||
|
|||||||
@@ -295,6 +295,16 @@ class ProviderManager:
|
|||||||
from .sources.zhipu_source import ProviderZhipu as ProviderZhipu
|
from .sources.zhipu_source import ProviderZhipu as ProviderZhipu
|
||||||
case "groq_chat_completion":
|
case "groq_chat_completion":
|
||||||
from .sources.groq_source import ProviderGroq as ProviderGroq
|
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":
|
case "anthropic_chat_completion":
|
||||||
from .sources.anthropic_source import (
|
from .sources.anthropic_source import (
|
||||||
ProviderAnthropic as ProviderAnthropic,
|
ProviderAnthropic as ProviderAnthropic,
|
||||||
|
|||||||
@@ -33,20 +33,29 @@ class ProviderAnthropic(Provider):
|
|||||||
self,
|
self,
|
||||||
provider_config,
|
provider_config,
|
||||||
provider_settings,
|
provider_settings,
|
||||||
|
*,
|
||||||
|
use_api_key: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
provider_config,
|
provider_config,
|
||||||
provider_settings,
|
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.base_url = provider_config.get("api_base", "https://api.anthropic.com")
|
||||||
self.timeout = provider_config.get("timeout", 120)
|
self.timeout = provider_config.get("timeout", 120)
|
||||||
if isinstance(self.timeout, str):
|
if isinstance(self.timeout, str):
|
||||||
self.timeout = int(self.timeout)
|
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(
|
self.client = AsyncAnthropic(
|
||||||
api_key=self.chosen_api_key,
|
api_key=self.chosen_api_key,
|
||||||
timeout=self.timeout,
|
timeout=self.timeout,
|
||||||
@@ -54,15 +63,27 @@ class ProviderAnthropic(Provider):
|
|||||||
http_client=self._create_http_client(provider_config),
|
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:
|
def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient | None:
|
||||||
"""创建带代理的 HTTP 客户端"""
|
"""创建带代理的 HTTP 客户端"""
|
||||||
proxy = provider_config.get("proxy", "")
|
proxy = provider_config.get("proxy", "")
|
||||||
return create_proxy_client("Anthropic", 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]):
|
def _prepare_payload(self, messages: list[dict]):
|
||||||
"""准备 Anthropic API 的请求 payload
|
"""准备 Anthropic API 的请求 payload
|
||||||
|
|
||||||
@@ -213,11 +234,7 @@ class ProviderAnthropic(Provider):
|
|||||||
|
|
||||||
if "max_tokens" not in payloads:
|
if "max_tokens" not in payloads:
|
||||||
payloads["max_tokens"] = 1024
|
payloads["max_tokens"] = 1024
|
||||||
if self.thinking_config.get("budget"):
|
self._apply_thinking_config(payloads)
|
||||||
payloads["thinking"] = {
|
|
||||||
"budget_tokens": self.thinking_config.get("budget"),
|
|
||||||
"type": "enabled",
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
completion = await self.client.messages.create(
|
completion = await self.client.messages.create(
|
||||||
@@ -287,11 +304,7 @@ class ProviderAnthropic(Provider):
|
|||||||
|
|
||||||
if "max_tokens" not in payloads:
|
if "max_tokens" not in payloads:
|
||||||
payloads["max_tokens"] = 1024
|
payloads["max_tokens"] = 1024
|
||||||
if self.thinking_config.get("budget"):
|
self._apply_thinking_config(payloads)
|
||||||
payloads["thinking"] = {
|
|
||||||
"budget_tokens": self.thinking_config.get("budget"),
|
|
||||||
"type": "enabled",
|
|
||||||
}
|
|
||||||
|
|
||||||
async with self.client.messages.stream(
|
async with self.client.messages.stream(
|
||||||
**payloads, extra_body=extra_body
|
**payloads, extra_body=extra_body
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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
|
||||||
@@ -381,13 +381,22 @@ class ProviderOpenAIOfficial(Provider):
|
|||||||
plain string. This method handles both formats.
|
plain string. This method handles both formats.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
raw_content: The raw content from LLM response, can be str, list, or other.
|
raw_content: The raw content from LLM response, can be str, list, dict, or other.
|
||||||
strip: Whether to strip whitespace from the result. Set to False for
|
strip: Whether to strip whitespace from the result. Set to False for
|
||||||
streaming chunks to preserve spaces between words.
|
streaming chunks to preserve spaces between words.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Normalized plain text string.
|
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):
|
if isinstance(raw_content, list):
|
||||||
# Check if this looks like OpenAI content-part format
|
# Check if this looks like OpenAI content-part format
|
||||||
# Only process if at least one item has {'type': 'text', 'text': ...} structure
|
# Only process if at least one item has {'type': 'text', 'text': ...} structure
|
||||||
@@ -450,7 +459,8 @@ class ProviderOpenAIOfficial(Provider):
|
|||||||
return "".join(text_parts)
|
return "".join(text_parts)
|
||||||
return content
|
return content
|
||||||
|
|
||||||
return str(raw_content)
|
# Fallback for other types (int, float, etc.)
|
||||||
|
return str(raw_content) if raw_content is not None else ""
|
||||||
|
|
||||||
async def _parse_openai_completion(
|
async def _parse_openai_completion(
|
||||||
self, completion: ChatCompletion, tools: ToolSet | None
|
self, completion: ChatCompletion, tools: ToolSet | None
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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,12 +7,14 @@ import asyncio
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
from funasr_onnx import SenseVoiceSmall
|
from funasr_onnx import SenseVoiceSmall
|
||||||
from funasr_onnx.utils.postprocess_utils import rich_transcription_postprocess
|
from funasr_onnx.utils.postprocess_utils import rich_transcription_postprocess
|
||||||
|
|
||||||
from astrbot.core import logger
|
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.io import download_file
|
||||||
from astrbot.core.utils.tencent_record_helper import tencent_silk_to_wav
|
from astrbot.core.utils.tencent_record_helper import tencent_silk_to_wav
|
||||||
|
|
||||||
@@ -50,7 +52,9 @@ class ProviderSenseVoiceSTTSelfHost(STTProvider):
|
|||||||
|
|
||||||
async def get_timestamped_path(self) -> str:
|
async def get_timestamped_path(self) -> str:
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
return os.path.join("data", "temp", f"{timestamp}")
|
temp_dir = Path(get_astrbot_temp_path())
|
||||||
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
return str(temp_dir / timestamp)
|
||||||
|
|
||||||
async def _is_silk_file(self, file_path) -> bool:
|
async def _is_silk_file(self, file_path) -> bool:
|
||||||
silk_header = b"SILK"
|
silk_header = b"SILK"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class PlatformAdapterType(enum.Flag):
|
|||||||
QQOFFICIAL = enum.auto()
|
QQOFFICIAL = enum.auto()
|
||||||
TELEGRAM = enum.auto()
|
TELEGRAM = enum.auto()
|
||||||
WECOM = enum.auto()
|
WECOM = enum.auto()
|
||||||
|
WECOM_AI_BOT = enum.auto()
|
||||||
LARK = enum.auto()
|
LARK = enum.auto()
|
||||||
DINGTALK = enum.auto()
|
DINGTALK = enum.auto()
|
||||||
DISCORD = enum.auto()
|
DISCORD = enum.auto()
|
||||||
@@ -26,6 +27,7 @@ class PlatformAdapterType(enum.Flag):
|
|||||||
| QQOFFICIAL
|
| QQOFFICIAL
|
||||||
| TELEGRAM
|
| TELEGRAM
|
||||||
| WECOM
|
| WECOM
|
||||||
|
| WECOM_AI_BOT
|
||||||
| LARK
|
| LARK
|
||||||
| DINGTALK
|
| DINGTALK
|
||||||
| DISCORD
|
| DISCORD
|
||||||
@@ -44,6 +46,7 @@ ADAPTER_NAME_2_TYPE = {
|
|||||||
"qq_official": PlatformAdapterType.QQOFFICIAL,
|
"qq_official": PlatformAdapterType.QQOFFICIAL,
|
||||||
"telegram": PlatformAdapterType.TELEGRAM,
|
"telegram": PlatformAdapterType.TELEGRAM,
|
||||||
"wecom": PlatformAdapterType.WECOM,
|
"wecom": PlatformAdapterType.WECOM,
|
||||||
|
"wecom_ai_bot": PlatformAdapterType.WECOM_AI_BOT,
|
||||||
"lark": PlatformAdapterType.LARK,
|
"lark": PlatformAdapterType.LARK,
|
||||||
"dingtalk": PlatformAdapterType.DINGTALK,
|
"dingtalk": PlatformAdapterType.DINGTALK,
|
||||||
"discord": PlatformAdapterType.DISCORD,
|
"discord": PlatformAdapterType.DISCORD,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from .star_handler import (
|
|||||||
register_on_llm_response,
|
register_on_llm_response,
|
||||||
register_on_llm_tool_respond,
|
register_on_llm_tool_respond,
|
||||||
register_on_platform_loaded,
|
register_on_platform_loaded,
|
||||||
|
register_on_plugin_error,
|
||||||
register_on_using_llm_tool,
|
register_on_using_llm_tool,
|
||||||
register_on_waiting_llm_request,
|
register_on_waiting_llm_request,
|
||||||
register_permission_type,
|
register_permission_type,
|
||||||
@@ -32,6 +33,7 @@ __all__ = [
|
|||||||
"register_on_decorating_result",
|
"register_on_decorating_result",
|
||||||
"register_on_llm_request",
|
"register_on_llm_request",
|
||||||
"register_on_llm_response",
|
"register_on_llm_response",
|
||||||
|
"register_on_plugin_error",
|
||||||
"register_on_platform_loaded",
|
"register_on_platform_loaded",
|
||||||
"register_on_waiting_llm_request",
|
"register_on_waiting_llm_request",
|
||||||
"register_permission_type",
|
"register_permission_type",
|
||||||
|
|||||||
@@ -339,6 +339,24 @@ def register_on_platform_loaded(**kwargs):
|
|||||||
return decorator
|
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_waiting_llm_request(**kwargs):
|
def register_on_waiting_llm_request(**kwargs):
|
||||||
"""当等待调用 LLM 时的通知事件(在获取锁之前)
|
"""当等待调用 LLM 时的通知事件(在获取锁之前)
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ class StarMetadata:
|
|||||||
logo_path: str | None = None
|
logo_path: str | None = None
|
||||||
"""插件 Logo 的路径"""
|
"""插件 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:
|
def __str__(self) -> str:
|
||||||
return f"Plugin {self.name} ({self.version}) by {self.author}: {self.desc}"
|
return f"Plugin {self.name} ({self.version}) by {self.author}: {self.desc}"
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,14 @@ class StarHandlerRegistry(Generic[T]):
|
|||||||
plugins_name: list[str] | None = None,
|
plugins_name: list[str] | None = None,
|
||||||
) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ...
|
) -> 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
|
@overload
|
||||||
def get_handlers_by_event_type(
|
def get_handlers_by_event_type(
|
||||||
self,
|
self,
|
||||||
@@ -192,6 +200,7 @@ class EventType(enum.Enum):
|
|||||||
OnUsingLLMToolEvent = enum.auto() # 使用 LLM 工具
|
OnUsingLLMToolEvent = enum.auto() # 使用 LLM 工具
|
||||||
OnLLMToolRespondEvent = enum.auto() # 调用函数工具后
|
OnLLMToolRespondEvent = enum.auto() # 调用函数工具后
|
||||||
OnAfterMessageSentEvent = enum.auto() # 发送消息后
|
OnAfterMessageSentEvent = enum.auto() # 发送消息后
|
||||||
|
OnPluginErrorEvent = enum.auto() # 插件处理消息异常时
|
||||||
|
|
||||||
|
|
||||||
H = TypeVar("H", bound=Callable[..., Any])
|
H = TypeVar("H", bound=Callable[..., Any])
|
||||||
|
|||||||
@@ -11,10 +11,13 @@ import traceback
|
|||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
|
|
||||||
import yaml
|
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 import logger, pip_installer, sp
|
||||||
from astrbot.core.agent.handoff import FunctionTool, HandoffTool
|
from astrbot.core.agent.handoff import FunctionTool, HandoffTool
|
||||||
from astrbot.core.config.astrbot_config import AstrBotConfig
|
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.platform.register import unregister_platform_adapters_by_module
|
||||||
from astrbot.core.provider.register import llm_tools
|
from astrbot.core.provider.register import llm_tools
|
||||||
from astrbot.core.utils.astrbot_path import (
|
from astrbot.core.utils.astrbot_path import (
|
||||||
@@ -40,6 +43,10 @@ except ImportError:
|
|||||||
logger.warning("未安装 watchfiles,无法实现插件的热重载。")
|
logger.warning("未安装 watchfiles,无法实现插件的热重载。")
|
||||||
|
|
||||||
|
|
||||||
|
class PluginVersionIncompatibleError(Exception):
|
||||||
|
"""Raised when plugin astrbot_version is incompatible with current AstrBot."""
|
||||||
|
|
||||||
|
|
||||||
class PluginManager:
|
class PluginManager:
|
||||||
def __init__(self, context: Context, config: AstrBotConfig) -> None:
|
def __init__(self, context: Context, config: AstrBotConfig) -> None:
|
||||||
self.updator = PluginUpdator()
|
self.updator = PluginUpdator()
|
||||||
@@ -268,10 +275,58 @@ class PluginManager:
|
|||||||
version=metadata["version"],
|
version=metadata["version"],
|
||||||
repo=metadata["repo"] if "repo" in metadata else None,
|
repo=metadata["repo"] if "repo" in metadata else None,
|
||||||
display_name=metadata.get("display_name", 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
|
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
|
@staticmethod
|
||||||
def _get_plugin_related_modules(
|
def _get_plugin_related_modules(
|
||||||
plugin_root_dir: str,
|
plugin_root_dir: str,
|
||||||
@@ -408,7 +463,12 @@ class PluginManager:
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def load(self, specified_module_path=None, specified_dir_name=None):
|
async def load(
|
||||||
|
self,
|
||||||
|
specified_module_path=None,
|
||||||
|
specified_dir_name=None,
|
||||||
|
ignore_version_check: bool = False,
|
||||||
|
):
|
||||||
"""载入插件。
|
"""载入插件。
|
||||||
当 specified_module_path 或者 specified_dir_name 不为 None 时,只载入指定的插件。
|
当 specified_module_path 或者 specified_dir_name 不为 None 时,只载入指定的插件。
|
||||||
|
|
||||||
@@ -507,10 +567,25 @@ class PluginManager:
|
|||||||
metadata.version = metadata_yaml.version
|
metadata.version = metadata_yaml.version
|
||||||
metadata.repo = metadata_yaml.repo
|
metadata.repo = metadata_yaml.repo
|
||||||
metadata.display_name = metadata_yaml.display_name
|
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:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"插件 {root_dir_name} 元数据载入失败: {e!s}。使用默认元数据。",
|
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)
|
logger.info(metadata)
|
||||||
metadata.config = plugin_config
|
metadata.config = plugin_config
|
||||||
p_name = (metadata.name or "unknown").lower().replace("/", "_")
|
p_name = (metadata.name or "unknown").lower().replace("/", "_")
|
||||||
@@ -621,6 +696,19 @@ class PluginManager:
|
|||||||
)
|
)
|
||||||
if not metadata:
|
if not metadata:
|
||||||
raise Exception(f"无法找到插件 {plugin_dir_path} 的元数据。")
|
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.star_cls = obj
|
||||||
metadata.config = plugin_config
|
metadata.config = plugin_config
|
||||||
metadata.module = module
|
metadata.module = module
|
||||||
@@ -754,7 +842,9 @@ class PluginManager:
|
|||||||
f"清理安装失败插件配置失败: {plugin_config_path},原因: {e!s}",
|
f"清理安装失败插件配置失败: {plugin_config_path},原因: {e!s}",
|
||||||
)
|
)
|
||||||
|
|
||||||
async def install_plugin(self, repo_url: str, proxy=""):
|
async def install_plugin(
|
||||||
|
self, repo_url: str, proxy: str = "", ignore_version_check: bool = False
|
||||||
|
):
|
||||||
"""从仓库 URL 安装插件
|
"""从仓库 URL 安装插件
|
||||||
|
|
||||||
从指定的仓库 URL 下载并安装插件,然后加载该插件到系统中
|
从指定的仓库 URL 下载并安装插件,然后加载该插件到系统中
|
||||||
@@ -788,7 +878,10 @@ class PluginManager:
|
|||||||
|
|
||||||
# reload the plugin
|
# reload the plugin
|
||||||
dir_name = os.path.basename(plugin_path)
|
dir_name = os.path.basename(plugin_path)
|
||||||
success, error_message = await self.load(specified_dir_name=dir_name)
|
success, error_message = await self.load(
|
||||||
|
specified_dir_name=dir_name,
|
||||||
|
ignore_version_check=ignore_version_check,
|
||||||
|
)
|
||||||
if not success:
|
if not success:
|
||||||
raise Exception(
|
raise Exception(
|
||||||
error_message
|
error_message
|
||||||
@@ -1092,7 +1185,9 @@ class PluginManager:
|
|||||||
|
|
||||||
await self.reload(plugin_name)
|
await self.reload(plugin_name)
|
||||||
|
|
||||||
async def install_plugin_from_file(self, zip_file_path: str):
|
async def install_plugin_from_file(
|
||||||
|
self, zip_file_path: str, ignore_version_check: bool = False
|
||||||
|
):
|
||||||
dir_name = os.path.basename(zip_file_path).replace(".zip", "")
|
dir_name = os.path.basename(zip_file_path).replace(".zip", "")
|
||||||
dir_name = dir_name.removesuffix("-master").removesuffix("-main").lower()
|
dir_name = dir_name.removesuffix("-master").removesuffix("-main").lower()
|
||||||
desti_dir = os.path.join(self.plugin_store_path, dir_name)
|
desti_dir = os.path.join(self.plugin_store_path, dir_name)
|
||||||
@@ -1148,7 +1243,10 @@ class PluginManager:
|
|||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
logger.warning(f"删除插件压缩包失败: {e!s}")
|
logger.warning(f"删除插件压缩包失败: {e!s}")
|
||||||
# await self.reload()
|
# await self.reload()
|
||||||
success, error_message = await self.load(specified_dir_name=dir_name)
|
success, error_message = await self.load(
|
||||||
|
specified_dir_name=dir_name,
|
||||||
|
ignore_version_check=ignore_version_check,
|
||||||
|
)
|
||||||
if not success:
|
if not success:
|
||||||
raise Exception(
|
raise Exception(
|
||||||
error_message
|
error_message
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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
|
import os
|
||||||
|
|
||||||
from astrbot.core.utils.runtime_env import is_packaged_electron_runtime
|
from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime
|
||||||
|
|
||||||
|
|
||||||
def get_astrbot_path() -> str:
|
def get_astrbot_path() -> str:
|
||||||
@@ -29,7 +29,7 @@ def get_astrbot_root() -> str:
|
|||||||
"""获取Astrbot根目录路径"""
|
"""获取Astrbot根目录路径"""
|
||||||
if path := os.environ.get("ASTRBOT_ROOT"):
|
if path := os.environ.get("ASTRBOT_ROOT"):
|
||||||
return os.path.realpath(path)
|
return os.path.realpath(path)
|
||||||
if is_packaged_electron_runtime():
|
if is_packaged_desktop_runtime():
|
||||||
return os.path.realpath(os.path.join(os.path.expanduser("~"), ".astrbot"))
|
return os.path.realpath(os.path.join(os.path.expanduser("~"), ".astrbot"))
|
||||||
return os.path.realpath(os.getcwd())
|
return os.path.realpath(os.getcwd())
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import threading
|
|||||||
from collections import deque
|
from collections import deque
|
||||||
|
|
||||||
from astrbot.core.utils.astrbot_path import get_astrbot_site_packages_path
|
from astrbot.core.utils.astrbot_path import get_astrbot_site_packages_path
|
||||||
from astrbot.core.utils.runtime_env import is_packaged_electron_runtime
|
from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime
|
||||||
|
|
||||||
logger = logging.getLogger("astrbot")
|
logger = logging.getLogger("astrbot")
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ def _get_pip_main():
|
|||||||
"pip module is unavailable "
|
"pip module is unavailable "
|
||||||
f"(sys.executable={sys.executable}, "
|
f"(sys.executable={sys.executable}, "
|
||||||
f"frozen={getattr(sys, 'frozen', False)}, "
|
f"frozen={getattr(sys, 'frozen', False)}, "
|
||||||
f"ASTRBOT_ELECTRON_CLIENT={os.environ.get('ASTRBOT_ELECTRON_CLIENT')})"
|
f"ASTRBOT_DESKTOP_CLIENT={os.environ.get('ASTRBOT_DESKTOP_CLIENT')})"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
return pip_main
|
return pip_main
|
||||||
@@ -556,7 +556,7 @@ class PipInstaller:
|
|||||||
args.extend(["--trusted-host", "mirrors.aliyun.com", "-i", index_url])
|
args.extend(["--trusted-host", "mirrors.aliyun.com", "-i", index_url])
|
||||||
|
|
||||||
target_site_packages = None
|
target_site_packages = None
|
||||||
if is_packaged_electron_runtime():
|
if is_packaged_desktop_runtime():
|
||||||
target_site_packages = get_astrbot_site_packages_path()
|
target_site_packages = get_astrbot_site_packages_path()
|
||||||
os.makedirs(target_site_packages, exist_ok=True)
|
os.makedirs(target_site_packages, exist_ok=True)
|
||||||
_prepend_sys_path(target_site_packages)
|
_prepend_sys_path(target_site_packages)
|
||||||
@@ -582,7 +582,7 @@ class PipInstaller:
|
|||||||
|
|
||||||
def prefer_installed_dependencies(self, requirements_path: str) -> None:
|
def prefer_installed_dependencies(self, requirements_path: str) -> None:
|
||||||
"""优先使用已安装在插件 site-packages 中的依赖,不执行安装。"""
|
"""优先使用已安装在插件 site-packages 中的依赖,不执行安装。"""
|
||||||
if not is_packaged_electron_runtime():
|
if not is_packaged_desktop_runtime():
|
||||||
return
|
return
|
||||||
|
|
||||||
target_site_packages = get_astrbot_site_packages_path()
|
target_site_packages = get_astrbot_site_packages_path()
|
||||||
|
|||||||
@@ -6,5 +6,5 @@ def is_frozen_runtime() -> bool:
|
|||||||
return bool(getattr(sys, "frozen", False))
|
return bool(getattr(sys, "frozen", False))
|
||||||
|
|
||||||
|
|
||||||
def is_packaged_electron_runtime() -> bool:
|
def is_packaged_desktop_runtime() -> bool:
|
||||||
return is_frozen_runtime() and os.environ.get("ASTRBOT_ELECTRON_CLIENT") == "1"
|
return is_frozen_runtime() and os.environ.get("ASTRBOT_DESKTOP_CLIENT") == "1"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from .api_key import ApiKeyRoute
|
||||||
from .auth import AuthRoute
|
from .auth import AuthRoute
|
||||||
from .backup import BackupRoute
|
from .backup import BackupRoute
|
||||||
from .chat import ChatRoute
|
from .chat import ChatRoute
|
||||||
@@ -9,6 +10,7 @@ from .cron import CronRoute
|
|||||||
from .file import FileRoute
|
from .file import FileRoute
|
||||||
from .knowledge_base import KnowledgeBaseRoute
|
from .knowledge_base import KnowledgeBaseRoute
|
||||||
from .log import LogRoute
|
from .log import LogRoute
|
||||||
|
from .open_api import OpenApiRoute
|
||||||
from .persona import PersonaRoute
|
from .persona import PersonaRoute
|
||||||
from .platform import PlatformRoute
|
from .platform import PlatformRoute
|
||||||
from .plugin import PluginRoute
|
from .plugin import PluginRoute
|
||||||
@@ -21,6 +23,7 @@ from .tools import ToolsRoute
|
|||||||
from .update import UpdateRoute
|
from .update import UpdateRoute
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"ApiKeyRoute",
|
||||||
"AuthRoute",
|
"AuthRoute",
|
||||||
"BackupRoute",
|
"BackupRoute",
|
||||||
"ChatRoute",
|
"ChatRoute",
|
||||||
@@ -32,6 +35,7 @@ __all__ = [
|
|||||||
"FileRoute",
|
"FileRoute",
|
||||||
"KnowledgeBaseRoute",
|
"KnowledgeBaseRoute",
|
||||||
"LogRoute",
|
"LogRoute",
|
||||||
|
"OpenApiRoute",
|
||||||
"PersonaRoute",
|
"PersonaRoute",
|
||||||
"PlatformRoute",
|
"PlatformRoute",
|
||||||
"PluginRoute",
|
"PluginRoute",
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
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,11 +64,13 @@ class AuthRoute(Route):
|
|||||||
new_pwd = post_data.get("new_password", None)
|
new_pwd = post_data.get("new_password", None)
|
||||||
new_username = post_data.get("new_username", None)
|
new_username = post_data.get("new_username", None)
|
||||||
if not new_pwd and not new_username:
|
if not new_pwd and not new_username:
|
||||||
return (
|
return Response().error("新用户名和新密码不能同时为空").__dict__
|
||||||
Response().error("新用户名和新密码不能同时为空,你改了个寂寞").__dict__
|
|
||||||
)
|
|
||||||
|
|
||||||
|
# Verify password confirmation
|
||||||
if new_pwd:
|
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
|
self.config["dashboard"]["password"] = new_pwd
|
||||||
if new_username:
|
if new_username:
|
||||||
self.config["dashboard"]["username"] = new_username
|
self.config["dashboard"]["username"] = new_username
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ from quart import g, make_response, request, send_file
|
|||||||
from astrbot.core import logger, sp
|
from astrbot.core import logger, sp
|
||||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||||
from astrbot.core.db import BaseDatabase
|
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.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 astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||||
|
|
||||||
from .route import Response, Route, RouteContext
|
from .route import Response, Route, RouteContext
|
||||||
@@ -41,6 +43,7 @@ class ChatRoute(Route):
|
|||||||
"/chat/new_session": ("GET", self.new_session),
|
"/chat/new_session": ("GET", self.new_session),
|
||||||
"/chat/sessions": ("GET", self.get_sessions),
|
"/chat/sessions": ("GET", self.get_sessions),
|
||||||
"/chat/get_session": ("GET", self.get_session),
|
"/chat/get_session": ("GET", self.get_session),
|
||||||
|
"/chat/stop": ("POST", self.stop_session),
|
||||||
"/chat/delete_session": ("GET", self.delete_webchat_session),
|
"/chat/delete_session": ("GET", self.delete_webchat_session),
|
||||||
"/chat/update_session_display_name": (
|
"/chat/update_session_display_name": (
|
||||||
"POST",
|
"POST",
|
||||||
@@ -52,8 +55,9 @@ class ChatRoute(Route):
|
|||||||
}
|
}
|
||||||
self.core_lifecycle = core_lifecycle
|
self.core_lifecycle = core_lifecycle
|
||||||
self.register_routes()
|
self.register_routes()
|
||||||
self.imgs_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs")
|
self.attachments_dir = os.path.join(get_astrbot_data_path(), "attachments")
|
||||||
os.makedirs(self.imgs_dir, exist_ok=True)
|
self.legacy_img_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs")
|
||||||
|
os.makedirs(self.attachments_dir, exist_ok=True)
|
||||||
|
|
||||||
self.supported_imgs = ["jpg", "jpeg", "png", "gif", "webp"]
|
self.supported_imgs = ["jpg", "jpeg", "png", "gif", "webp"]
|
||||||
self.conv_mgr = core_lifecycle.conversation_manager
|
self.conv_mgr = core_lifecycle.conversation_manager
|
||||||
@@ -69,9 +73,18 @@ class ChatRoute(Route):
|
|||||||
return Response().error("Missing key: filename").__dict__
|
return Response().error("Missing key: filename").__dict__
|
||||||
|
|
||||||
try:
|
try:
|
||||||
file_path = os.path.join(self.imgs_dir, os.path.basename(filename))
|
file_path = os.path.join(self.attachments_dir, os.path.basename(filename))
|
||||||
real_file_path = os.path.realpath(file_path)
|
real_file_path = os.path.realpath(file_path)
|
||||||
real_imgs_dir = os.path.realpath(self.imgs_dir)
|
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)
|
||||||
|
|
||||||
if not real_file_path.startswith(real_imgs_dir):
|
if not real_file_path.startswith(real_imgs_dir):
|
||||||
return Response().error("Invalid file path").__dict__
|
return Response().error("Invalid file path").__dict__
|
||||||
@@ -125,7 +138,7 @@ class ChatRoute(Route):
|
|||||||
else:
|
else:
|
||||||
attach_type = "file"
|
attach_type = "file"
|
||||||
|
|
||||||
path = os.path.join(self.imgs_dir, filename)
|
path = os.path.join(self.attachments_dir, filename)
|
||||||
await file.save(path)
|
await file.save(path)
|
||||||
|
|
||||||
# 创建 attachment 记录
|
# 创建 attachment 记录
|
||||||
@@ -202,8 +215,13 @@ class ChatRoute(Route):
|
|||||||
filename: 存储的文件名
|
filename: 存储的文件名
|
||||||
attach_type: 附件类型 (image, record, file, video)
|
attach_type: 附件类型 (image, record, file, video)
|
||||||
"""
|
"""
|
||||||
file_path = os.path.join(self.imgs_dir, os.path.basename(filename))
|
basename = os.path.basename(filename)
|
||||||
if not os.path.exists(file_path):
|
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:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# guess mime type
|
# guess mime type
|
||||||
@@ -317,10 +335,13 @@ class ChatRoute(Route):
|
|||||||
)
|
)
|
||||||
return record
|
return record
|
||||||
|
|
||||||
async def chat(self):
|
async def chat(self, post_data: dict | None = None):
|
||||||
username = g.get("username", "guest")
|
username = g.get("username", "guest")
|
||||||
|
|
||||||
post_data = await request.json
|
if post_data is None:
|
||||||
|
post_data = await request.json
|
||||||
|
if post_data is None:
|
||||||
|
return Response().error("Missing JSON body").__dict__
|
||||||
if "message" not in post_data and "files" not in post_data:
|
if "message" not in post_data and "files" not in post_data:
|
||||||
return Response().error("Missing key: message or files").__dict__
|
return Response().error("Missing key: message or files").__dict__
|
||||||
|
|
||||||
@@ -373,6 +394,14 @@ class ChatRoute(Route):
|
|||||||
agent_stats = {}
|
agent_stats = {}
|
||||||
refs = {}
|
refs = {}
|
||||||
try:
|
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):
|
async with track_conversation(self.running_convs, webchat_conv_id):
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
@@ -445,13 +474,13 @@ class ChatRoute(Route):
|
|||||||
if tc_id in tool_calls:
|
if tc_id in tool_calls:
|
||||||
tool_calls[tc_id]["result"] = tcr.get("result")
|
tool_calls[tc_id]["result"] = tcr.get("result")
|
||||||
tool_calls[tc_id]["finished_ts"] = tcr.get("ts")
|
tool_calls[tc_id]["finished_ts"] = tcr.get("ts")
|
||||||
accumulated_parts.append(
|
accumulated_parts.append(
|
||||||
{
|
{
|
||||||
"type": "tool_call",
|
"type": "tool_call",
|
||||||
"tool_calls": [tool_calls[tc_id]],
|
"tool_calls": [tool_calls[tc_id]],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
tool_calls.pop(tc_id, None)
|
tool_calls.pop(tc_id, None)
|
||||||
elif chain_type == "reasoning":
|
elif chain_type == "reasoning":
|
||||||
accumulated_reasoning += result_text
|
accumulated_reasoning += result_text
|
||||||
elif streaming:
|
elif streaming:
|
||||||
@@ -582,6 +611,36 @@ class ChatRoute(Route):
|
|||||||
response.timeout = None # fix SSE auto disconnect issue
|
response.timeout = None # fix SSE auto disconnect issue
|
||||||
return response
|
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):
|
async def delete_webchat_session(self):
|
||||||
"""Delete a Platform session and all its related data."""
|
"""Delete a Platform session and all its related data."""
|
||||||
session_id = request.args.get("session_id")
|
session_id = request.args.get("session_id")
|
||||||
@@ -705,23 +764,18 @@ class ChatRoute(Route):
|
|||||||
# 获取可选的 platform_id 参数
|
# 获取可选的 platform_id 参数
|
||||||
platform_id = request.args.get("platform_id")
|
platform_id = request.args.get("platform_id")
|
||||||
|
|
||||||
sessions = await self.db.get_platform_sessions_by_creator(
|
sessions, _ = await self.db.get_platform_sessions_by_creator_paginated(
|
||||||
creator=username,
|
creator=username,
|
||||||
platform_id=platform_id,
|
platform_id=platform_id,
|
||||||
page=1,
|
page=1,
|
||||||
page_size=100, # 暂时返回前100个
|
page_size=100, # 暂时返回前100个
|
||||||
|
exclude_project_sessions=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 转换为字典格式,并添加项目信息
|
# 转换为字典格式
|
||||||
# get_platform_sessions_by_creator 现在返回 list[dict] 包含 session 和项目字段
|
|
||||||
sessions_data = []
|
sessions_data = []
|
||||||
for item in sessions:
|
for item in sessions:
|
||||||
session = item["session"]
|
session = item["session"]
|
||||||
project_id = item["project_id"]
|
|
||||||
|
|
||||||
# 跳过属于项目的会话(在侧边栏对话列表中不显示)
|
|
||||||
if project_id is not None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
sessions_data.append(
|
sessions_data.append(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
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,8 +19,14 @@ from astrbot.core.star.filter.command_group import CommandGroupFilter
|
|||||||
from astrbot.core.star.filter.permission import PermissionTypeFilter
|
from astrbot.core.star.filter.permission import PermissionTypeFilter
|
||||||
from astrbot.core.star.filter.regex import RegexFilter
|
from astrbot.core.star.filter.regex import RegexFilter
|
||||||
from astrbot.core.star.star_handler import EventType, star_handlers_registry
|
from astrbot.core.star.star_handler import EventType, star_handlers_registry
|
||||||
from astrbot.core.star.star_manager import PluginManager
|
from astrbot.core.star.star_manager import (
|
||||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
PluginManager,
|
||||||
|
PluginVersionIncompatibleError,
|
||||||
|
)
|
||||||
|
from astrbot.core.utils.astrbot_path import (
|
||||||
|
get_astrbot_data_path,
|
||||||
|
get_astrbot_temp_path,
|
||||||
|
)
|
||||||
|
|
||||||
from .route import Response, Route, RouteContext
|
from .route import Response, Route, RouteContext
|
||||||
|
|
||||||
@@ -46,6 +52,7 @@ class PluginRoute(Route):
|
|||||||
super().__init__(context)
|
super().__init__(context)
|
||||||
self.routes = {
|
self.routes = {
|
||||||
"/plugin/get": ("GET", self.get_plugins),
|
"/plugin/get": ("GET", self.get_plugins),
|
||||||
|
"/plugin/check-compat": ("POST", self.check_plugin_compatibility),
|
||||||
"/plugin/install": ("POST", self.install_plugin),
|
"/plugin/install": ("POST", self.install_plugin),
|
||||||
"/plugin/install-upload": ("POST", self.install_plugin_upload),
|
"/plugin/install-upload": ("POST", self.install_plugin_upload),
|
||||||
"/plugin/update": ("POST", self.update_plugin),
|
"/plugin/update": ("POST", self.update_plugin),
|
||||||
@@ -73,10 +80,32 @@ class PluginRoute(Route):
|
|||||||
EventType.OnDecoratingResultEvent: "回复消息前",
|
EventType.OnDecoratingResultEvent: "回复消息前",
|
||||||
EventType.OnCallingFuncToolEvent: "函数工具",
|
EventType.OnCallingFuncToolEvent: "函数工具",
|
||||||
EventType.OnAfterMessageSentEvent: "发送消息后",
|
EventType.OnAfterMessageSentEvent: "发送消息后",
|
||||||
|
EventType.OnPluginErrorEvent: "插件报错时",
|
||||||
}
|
}
|
||||||
|
|
||||||
self._logo_cache = {}
|
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):
|
async def reload_failed_plugins(self):
|
||||||
if DEMO_MODE:
|
if DEMO_MODE:
|
||||||
return (
|
return (
|
||||||
@@ -117,7 +146,7 @@ class PluginRoute(Route):
|
|||||||
try:
|
try:
|
||||||
success, message = await self.plugin_manager.reload(plugin_name)
|
success, message = await self.plugin_manager.reload(plugin_name)
|
||||||
if not success:
|
if not success:
|
||||||
return Response().error(message).__dict__
|
return Response().error(message or "插件重载失败").__dict__
|
||||||
return Response().ok(None, "重载成功。").__dict__
|
return Response().ok(None, "重载成功。").__dict__
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"/api/plugin/reload: {traceback.format_exc()}")
|
logger.error(f"/api/plugin/reload: {traceback.format_exc()}")
|
||||||
@@ -195,10 +224,11 @@ class PluginRoute(Route):
|
|||||||
|
|
||||||
def _build_registry_source(self, custom_url: str | None) -> RegistrySource:
|
def _build_registry_source(self, custom_url: str | None) -> RegistrySource:
|
||||||
"""构建注册表源信息"""
|
"""构建注册表源信息"""
|
||||||
|
data_dir = get_astrbot_data_path()
|
||||||
if custom_url:
|
if custom_url:
|
||||||
# 对自定义URL生成一个安全的文件名
|
# 对自定义URL生成一个安全的文件名
|
||||||
url_hash = hashlib.md5(custom_url.encode()).hexdigest()[:8]
|
url_hash = hashlib.md5(custom_url.encode()).hexdigest()[:8]
|
||||||
cache_file = f"data/plugins_custom_{url_hash}.json"
|
cache_file = os.path.join(data_dir, f"plugins_custom_{url_hash}.json")
|
||||||
|
|
||||||
# 更安全的后缀处理方式
|
# 更安全的后缀处理方式
|
||||||
if custom_url.endswith(".json"):
|
if custom_url.endswith(".json"):
|
||||||
@@ -208,7 +238,7 @@ class PluginRoute(Route):
|
|||||||
|
|
||||||
urls = [custom_url]
|
urls = [custom_url]
|
||||||
else:
|
else:
|
||||||
cache_file = "data/plugins.json"
|
cache_file = os.path.join(data_dir, "plugins.json")
|
||||||
md5_url = "https://api.soulter.top/astrbot/plugins-md5"
|
md5_url = "https://api.soulter.top/astrbot/plugins-md5"
|
||||||
urls = [
|
urls = [
|
||||||
"https://api.soulter.top/astrbot/plugins",
|
"https://api.soulter.top/astrbot/plugins",
|
||||||
@@ -344,6 +374,8 @@ class PluginRoute(Route):
|
|||||||
),
|
),
|
||||||
"display_name": plugin.display_name,
|
"display_name": plugin.display_name,
|
||||||
"logo": f"/api/file/{logo_url}" if logo_url else None,
|
"logo": f"/api/file/{logo_url}" if logo_url else None,
|
||||||
|
"support_platforms": plugin.support_platforms,
|
||||||
|
"astrbot_version": plugin.astrbot_version,
|
||||||
}
|
}
|
||||||
# 检查是否为全空的幽灵插件
|
# 检查是否为全空的幽灵插件
|
||||||
if not any(
|
if not any(
|
||||||
@@ -438,6 +470,7 @@ class PluginRoute(Route):
|
|||||||
|
|
||||||
post_data = await request.get_json()
|
post_data = await request.get_json()
|
||||||
repo_url = post_data["url"]
|
repo_url = post_data["url"]
|
||||||
|
ignore_version_check = bool(post_data.get("ignore_version_check", False))
|
||||||
|
|
||||||
proxy: str = post_data.get("proxy", None)
|
proxy: str = post_data.get("proxy", None)
|
||||||
if proxy:
|
if proxy:
|
||||||
@@ -445,10 +478,23 @@ class PluginRoute(Route):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(f"正在安装插件 {repo_url}")
|
logger.info(f"正在安装插件 {repo_url}")
|
||||||
plugin_info = await self.plugin_manager.install_plugin(repo_url, proxy)
|
plugin_info = await self.plugin_manager.install_plugin(
|
||||||
|
repo_url,
|
||||||
|
proxy,
|
||||||
|
ignore_version_check=ignore_version_check,
|
||||||
|
)
|
||||||
# self.core_lifecycle.restart()
|
# self.core_lifecycle.restart()
|
||||||
logger.info(f"安装插件 {repo_url} 成功。")
|
logger.info(f"安装插件 {repo_url} 成功。")
|
||||||
return Response().ok(plugin_info, "安装成功。").__dict__
|
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:
|
except Exception as e:
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
return Response().error(str(e)).__dict__
|
return Response().error(str(e)).__dict__
|
||||||
@@ -464,16 +510,32 @@ class PluginRoute(Route):
|
|||||||
try:
|
try:
|
||||||
file = await request.files
|
file = await request.files
|
||||||
file = file["file"]
|
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}")
|
logger.info(f"正在安装用户上传的插件 {file.filename}")
|
||||||
file_path = os.path.join(
|
file_path = os.path.join(
|
||||||
get_astrbot_temp_path(),
|
get_astrbot_temp_path(),
|
||||||
f"plugin_upload_{file.filename}",
|
f"plugin_upload_{file.filename}",
|
||||||
)
|
)
|
||||||
await file.save(file_path)
|
await file.save(file_path)
|
||||||
plugin_info = await self.plugin_manager.install_plugin_from_file(file_path)
|
plugin_info = await self.plugin_manager.install_plugin_from_file(
|
||||||
|
file_path,
|
||||||
|
ignore_version_check=ignore_version_check,
|
||||||
|
)
|
||||||
# self.core_lifecycle.restart()
|
# self.core_lifecycle.restart()
|
||||||
logger.info(f"安装插件 {file.filename} 成功")
|
logger.info(f"安装插件 {file.filename} 成功")
|
||||||
return Response().ok(plugin_info, "安装成功。").__dict__
|
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:
|
except Exception as e:
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
return Response().error(str(e)).__dict__
|
return Response().error(str(e)).__dict__
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
@@ -21,6 +22,7 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
|||||||
from astrbot.core.utils.io import get_local_ip_addresses
|
from astrbot.core.utils.io import get_local_ip_addresses
|
||||||
|
|
||||||
from .routes import *
|
from .routes import *
|
||||||
|
from .routes.api_key import ALL_OPEN_API_SCOPES
|
||||||
from .routes.backup import BackupRoute
|
from .routes.backup import BackupRoute
|
||||||
from .routes.live_chat import LiveChatRoute
|
from .routes.live_chat import LiveChatRoute
|
||||||
from .routes.platform import PlatformRoute
|
from .routes.platform import PlatformRoute
|
||||||
@@ -53,6 +55,7 @@ class AstrBotDashboard:
|
|||||||
) -> None:
|
) -> None:
|
||||||
self.core_lifecycle = core_lifecycle
|
self.core_lifecycle = core_lifecycle
|
||||||
self.config = core_lifecycle.astrbot_config
|
self.config = core_lifecycle.astrbot_config
|
||||||
|
self.db = db
|
||||||
|
|
||||||
# 参数指定webui目录
|
# 参数指定webui目录
|
||||||
if webui_dir and os.path.exists(webui_dir):
|
if webui_dir and os.path.exists(webui_dir):
|
||||||
@@ -88,7 +91,14 @@ class AstrBotDashboard:
|
|||||||
self.lr = LogRoute(self.context, core_lifecycle.log_broker)
|
self.lr = LogRoute(self.context, core_lifecycle.log_broker)
|
||||||
self.sfr = StaticFileRoute(self.context)
|
self.sfr = StaticFileRoute(self.context)
|
||||||
self.ar = AuthRoute(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.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.chatui_project_route = ChatUIProjectRoute(self.context, db)
|
||||||
self.tools_root = ToolsRoute(self.context, core_lifecycle)
|
self.tools_root = ToolsRoute(self.context, core_lifecycle)
|
||||||
self.subagent_route = SubAgentRoute(self.context, core_lifecycle)
|
self.subagent_route = SubAgentRoute(self.context, core_lifecycle)
|
||||||
@@ -130,6 +140,40 @@ class AstrBotDashboard:
|
|||||||
async def auth_middleware(self):
|
async def auth_middleware(self):
|
||||||
if not request.path.startswith("/api"):
|
if not request.path.startswith("/api"):
|
||||||
return None
|
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 = [
|
allowed_endpoints = [
|
||||||
"/api/auth/login",
|
"/api/auth/login",
|
||||||
"/api/file",
|
"/api/file",
|
||||||
@@ -158,6 +202,29 @@ class AstrBotDashboard:
|
|||||||
r.status_code = 401
|
r.status_code = 401
|
||||||
return r
|
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:
|
def check_port_in_use(self, port: int) -> bool:
|
||||||
"""跨平台检测端口是否被占用"""
|
"""跨平台检测端口是否被占用"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import base64
|
import base64
|
||||||
import os
|
|
||||||
import traceback
|
import traceback
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
@@ -51,14 +50,14 @@ async def generate_tsne_visualization(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
kb = kb_helper.kb
|
kb = kb_helper.kb
|
||||||
index_path = f"data/knowledge_base/{kb.kb_id}/index.faiss"
|
index_path = kb_helper.kb_dir / "index.faiss"
|
||||||
|
|
||||||
# 读取 FAISS 索引
|
# 读取 FAISS 索引
|
||||||
if not os.path.exists(index_path):
|
if not index_path.exists():
|
||||||
logger.warning(f"FAISS 索引不存在: {index_path}")
|
logger.warning(f"FAISS 索引不存在: {index_path!s}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
index = faiss.read_index(index_path)
|
index = faiss.read_index(str(index_path))
|
||||||
|
|
||||||
if index.ntotal == 0:
|
if index.ntotal == 0:
|
||||||
logger.warning("索引为空")
|
logger.warning("索引为空")
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
## 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)).
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
## 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.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
## 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`.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
## 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)).
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
## 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"`).
|
||||||
@@ -18,7 +18,6 @@ import { RouterView } from 'vue-router';
|
|||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue'
|
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue'
|
||||||
import { restartAstrBot } from '@/utils/restartAstrBot'
|
|
||||||
|
|
||||||
const toastStore = useToastStore()
|
const toastStore = useToastStore()
|
||||||
const globalWaitingRef = ref(null)
|
const globalWaitingRef = ref(null)
|
||||||
@@ -33,12 +32,12 @@ const snackbarShow = computed({
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const desktopBridge = window.astrbotDesktop
|
const desktopBridge = window.astrbotDesktop
|
||||||
if (!desktopBridge?.isElectron || !desktopBridge.onTrayRestartBackend) {
|
if (!desktopBridge?.onTrayRestartBackend) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
disposeTrayRestartListener = desktopBridge.onTrayRestartBackend(async () => {
|
disposeTrayRestartListener = desktopBridge.onTrayRestartBackend(async () => {
|
||||||
try {
|
try {
|
||||||
await restartAstrBot(globalWaitingRef.value)
|
await globalWaitingRef.value?.check?.()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
globalWaitingRef.value?.stop?.()
|
globalWaitingRef.value?.stop?.()
|
||||||
console.error('Tray restart backend failed:', error)
|
console.error('Tray restart backend failed:', error)
|
||||||
|
|||||||
@@ -77,12 +77,14 @@
|
|||||||
:stagedAudioUrl="stagedAudioUrl"
|
:stagedAudioUrl="stagedAudioUrl"
|
||||||
:stagedFiles="stagedNonImageFiles"
|
:stagedFiles="stagedNonImageFiles"
|
||||||
:disabled="isStreaming"
|
:disabled="isStreaming"
|
||||||
|
:is-running="isStreaming || isConvRunning"
|
||||||
:enableStreaming="enableStreaming"
|
:enableStreaming="enableStreaming"
|
||||||
:isRecording="isRecording"
|
:isRecording="isRecording"
|
||||||
:session-id="currSessionId || null"
|
:session-id="currSessionId || null"
|
||||||
:current-session="getCurrentSession"
|
:current-session="getCurrentSession"
|
||||||
:replyTo="replyTo"
|
:replyTo="replyTo"
|
||||||
@send="handleSendMessage"
|
@send="handleSendMessage"
|
||||||
|
@stop="handleStopMessage"
|
||||||
@toggleStreaming="toggleStreaming"
|
@toggleStreaming="toggleStreaming"
|
||||||
@removeImage="removeImage"
|
@removeImage="removeImage"
|
||||||
@removeAudio="removeAudio"
|
@removeAudio="removeAudio"
|
||||||
@@ -106,12 +108,14 @@
|
|||||||
:stagedAudioUrl="stagedAudioUrl"
|
:stagedAudioUrl="stagedAudioUrl"
|
||||||
:stagedFiles="stagedNonImageFiles"
|
:stagedFiles="stagedNonImageFiles"
|
||||||
:disabled="isStreaming"
|
:disabled="isStreaming"
|
||||||
|
:is-running="isStreaming || isConvRunning"
|
||||||
:enableStreaming="enableStreaming"
|
:enableStreaming="enableStreaming"
|
||||||
:isRecording="isRecording"
|
:isRecording="isRecording"
|
||||||
:session-id="currSessionId || null"
|
:session-id="currSessionId || null"
|
||||||
:current-session="getCurrentSession"
|
:current-session="getCurrentSession"
|
||||||
:replyTo="replyTo"
|
:replyTo="replyTo"
|
||||||
@send="handleSendMessage"
|
@send="handleSendMessage"
|
||||||
|
@stop="handleStopMessage"
|
||||||
@toggleStreaming="toggleStreaming"
|
@toggleStreaming="toggleStreaming"
|
||||||
@removeImage="removeImage"
|
@removeImage="removeImage"
|
||||||
@removeAudio="removeAudio"
|
@removeAudio="removeAudio"
|
||||||
@@ -134,12 +138,14 @@
|
|||||||
:stagedAudioUrl="stagedAudioUrl"
|
:stagedAudioUrl="stagedAudioUrl"
|
||||||
:stagedFiles="stagedNonImageFiles"
|
:stagedFiles="stagedNonImageFiles"
|
||||||
:disabled="isStreaming"
|
:disabled="isStreaming"
|
||||||
|
:is-running="isStreaming || isConvRunning"
|
||||||
:enableStreaming="enableStreaming"
|
:enableStreaming="enableStreaming"
|
||||||
:isRecording="isRecording"
|
:isRecording="isRecording"
|
||||||
:session-id="currSessionId || null"
|
:session-id="currSessionId || null"
|
||||||
:current-session="getCurrentSession"
|
:current-session="getCurrentSession"
|
||||||
:replyTo="replyTo"
|
:replyTo="replyTo"
|
||||||
@send="handleSendMessage"
|
@send="handleSendMessage"
|
||||||
|
@stop="handleStopMessage"
|
||||||
@toggleStreaming="toggleStreaming"
|
@toggleStreaming="toggleStreaming"
|
||||||
@removeImage="removeImage"
|
@removeImage="removeImage"
|
||||||
@removeAudio="removeAudio"
|
@removeAudio="removeAudio"
|
||||||
@@ -298,6 +304,7 @@ const {
|
|||||||
currentSessionProject,
|
currentSessionProject,
|
||||||
getSessionMessages: getSessionMsg,
|
getSessionMessages: getSessionMsg,
|
||||||
sendMessage: sendMsg,
|
sendMessage: sendMsg,
|
||||||
|
stopMessage: stopMsg,
|
||||||
toggleStreaming
|
toggleStreaming
|
||||||
} = useMessages(currSessionId, getMediaFile, updateSessionTitle, getSessions);
|
} = useMessages(currSessionId, getMediaFile, updateSessionTitle, getSessions);
|
||||||
|
|
||||||
@@ -631,6 +638,10 @@ async function handleSendMessage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleStopMessage() {
|
||||||
|
await stopMsg();
|
||||||
|
}
|
||||||
|
|
||||||
// 路由变化监听
|
// 路由变化监听
|
||||||
watch(
|
watch(
|
||||||
() => route.path,
|
() => route.path,
|
||||||
|
|||||||
@@ -94,8 +94,29 @@
|
|||||||
{{ isRecording ? tm('voice.speaking') : tm('voice.startRecording') }}
|
{{ isRecording ? tm('voice.speaking') : tm('voice.startRecording') }}
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn @click="$emit('send')" icon="mdi-send" variant="text" color="deep-purple"
|
<v-btn
|
||||||
:disabled="!canSend" class="send-btn" size="small" />
|
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"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -160,6 +181,7 @@ interface Props {
|
|||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
enableStreaming: boolean;
|
enableStreaming: boolean;
|
||||||
isRecording: boolean;
|
isRecording: boolean;
|
||||||
|
isRunning: boolean;
|
||||||
sessionId?: string | null;
|
sessionId?: string | null;
|
||||||
currentSession?: Session | null;
|
currentSession?: Session | null;
|
||||||
configId?: string | null;
|
configId?: string | null;
|
||||||
@@ -177,6 +199,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:prompt': [value: string];
|
'update:prompt': [value: string];
|
||||||
send: [];
|
send: [];
|
||||||
|
stop: [];
|
||||||
toggleStreaming: [];
|
toggleStreaming: [];
|
||||||
removeImage: [index: number];
|
removeImage: [index: number];
|
||||||
removeAudio: [];
|
removeAudio: [];
|
||||||
|
|||||||
@@ -77,6 +77,11 @@ import { computed, onMounted, ref, watch } from 'vue';
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { useToast } from '@/utils/toast';
|
import { useToast } from '@/utils/toast';
|
||||||
import { useModuleI18n } from '@/i18n/composables';
|
import { useModuleI18n } from '@/i18n/composables';
|
||||||
|
import {
|
||||||
|
getStoredDashboardUsername,
|
||||||
|
getStoredSelectedChatConfigId,
|
||||||
|
setStoredSelectedChatConfigId
|
||||||
|
} from '@/utils/chatConfigBinding';
|
||||||
|
|
||||||
interface ConfigInfo {
|
interface ConfigInfo {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -88,8 +93,6 @@ interface ConfigChangedPayload {
|
|||||||
agentRunnerType: string;
|
agentRunnerType: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = 'chat.selectedConfigId';
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
sessionId?: string | null;
|
sessionId?: string | null;
|
||||||
platformId?: string;
|
platformId?: string;
|
||||||
@@ -128,7 +131,7 @@ const hasActiveSession = computed(() => !!normalizedSessionId.value);
|
|||||||
|
|
||||||
const messageType = computed(() => (props.isGroup ? 'GroupMessage' : 'FriendMessage'));
|
const messageType = computed(() => (props.isGroup ? 'GroupMessage' : 'FriendMessage'));
|
||||||
|
|
||||||
const username = computed(() => localStorage.getItem('user') || 'guest');
|
const username = computed(() => getStoredDashboardUsername());
|
||||||
|
|
||||||
const sessionKey = computed(() => {
|
const sessionKey = computed(() => {
|
||||||
if (!normalizedSessionId.value) {
|
if (!normalizedSessionId.value) {
|
||||||
@@ -265,10 +268,10 @@ async function confirmSelection() {
|
|||||||
}
|
}
|
||||||
const previousId = selectedConfigId.value;
|
const previousId = selectedConfigId.value;
|
||||||
await setSelection(tempSelectedConfig.value);
|
await setSelection(tempSelectedConfig.value);
|
||||||
localStorage.setItem(STORAGE_KEY, tempSelectedConfig.value);
|
setStoredSelectedChatConfigId(tempSelectedConfig.value);
|
||||||
const applied = await applySelectionToBackend(tempSelectedConfig.value);
|
const applied = await applySelectionToBackend(tempSelectedConfig.value);
|
||||||
if (!applied) {
|
if (!applied) {
|
||||||
localStorage.setItem(STORAGE_KEY, previousId);
|
setStoredSelectedChatConfigId(previousId);
|
||||||
await setSelection(previousId);
|
await setSelection(previousId);
|
||||||
}
|
}
|
||||||
dialog.value = false;
|
dialog.value = false;
|
||||||
@@ -287,7 +290,7 @@ async function syncSelectionForSession() {
|
|||||||
await fetchRoutingEntries();
|
await fetchRoutingEntries();
|
||||||
const resolved = resolveConfigId(targetUmo.value);
|
const resolved = resolveConfigId(targetUmo.value);
|
||||||
await setSelection(resolved);
|
await setSelection(resolved);
|
||||||
localStorage.setItem(STORAGE_KEY, resolved);
|
setStoredSelectedChatConfigId(resolved);
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -299,7 +302,7 @@ watch(
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await fetchConfigList();
|
await fetchConfigList();
|
||||||
const stored = props.initialConfigId || localStorage.getItem(STORAGE_KEY) || 'default';
|
const stored = props.initialConfigId || getStoredSelectedChatConfigId();
|
||||||
selectedConfigId.value = stored;
|
selectedConfigId.value = stored;
|
||||||
await setSelection(stored);
|
await setSelection(stored);
|
||||||
await syncSelectionForSession();
|
await syncSelectionForSession();
|
||||||
|
|||||||
@@ -23,12 +23,14 @@
|
|||||||
:stagedImagesUrl="stagedImagesUrl"
|
:stagedImagesUrl="stagedImagesUrl"
|
||||||
:stagedAudioUrl="stagedAudioUrl"
|
:stagedAudioUrl="stagedAudioUrl"
|
||||||
:disabled="isStreaming"
|
:disabled="isStreaming"
|
||||||
|
:is-running="isStreaming || isConvRunning"
|
||||||
:enableStreaming="enableStreaming"
|
:enableStreaming="enableStreaming"
|
||||||
:isRecording="isRecording"
|
:isRecording="isRecording"
|
||||||
:session-id="currSessionId || null"
|
:session-id="currSessionId || null"
|
||||||
:current-session="getCurrentSession"
|
:current-session="getCurrentSession"
|
||||||
:config-id="configId"
|
:config-id="configId"
|
||||||
@send="handleSendMessage"
|
@send="handleSendMessage"
|
||||||
|
@stop="handleStopMessage"
|
||||||
@toggleStreaming="toggleStreaming"
|
@toggleStreaming="toggleStreaming"
|
||||||
@removeImage="removeImage"
|
@removeImage="removeImage"
|
||||||
@removeAudio="removeAudio"
|
@removeAudio="removeAudio"
|
||||||
@@ -70,6 +72,7 @@ import { useMessages } from '@/composables/useMessages';
|
|||||||
import { useMediaHandling } from '@/composables/useMediaHandling';
|
import { useMediaHandling } from '@/composables/useMediaHandling';
|
||||||
import { useRecording } from '@/composables/useRecording';
|
import { useRecording } from '@/composables/useRecording';
|
||||||
import { useToast } from '@/utils/toast';
|
import { useToast } from '@/utils/toast';
|
||||||
|
import { buildWebchatUmoDetails } from '@/utils/chatConfigBinding';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
configId?: string | null;
|
configId?: string | null;
|
||||||
@@ -82,6 +85,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { error: showError } = useToast();
|
const { error: showError } = useToast();
|
||||||
|
|
||||||
|
|
||||||
// UI 状态
|
// UI 状态
|
||||||
const imagePreviewDialog = ref(false);
|
const imagePreviewDialog = ref(false);
|
||||||
const previewImageUrl = ref('');
|
const previewImageUrl = ref('');
|
||||||
@@ -90,11 +94,33 @@ const previewImageUrl = ref('');
|
|||||||
const currSessionId = ref('');
|
const currSessionId = ref('');
|
||||||
const getCurrentSession = computed(() => null); // 独立测试模式不需要会话信息
|
const getCurrentSession = computed(() => null); // 独立测试模式不需要会话信息
|
||||||
|
|
||||||
|
async function bindConfigToSession(sessionId: string) {
|
||||||
|
const confId = (props.configId || '').trim();
|
||||||
|
if (!confId || confId === 'default') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const umoDetails = buildWebchatUmoDetails(sessionId, false);
|
||||||
|
|
||||||
|
await axios.post('/api/config/umo_abconf_route/update', {
|
||||||
|
umo: umoDetails.umo,
|
||||||
|
conf_id: confId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function newSession() {
|
async function newSession() {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get('/api/chat/new_session');
|
const response = await axios.get('/api/chat/new_session');
|
||||||
const sessionId = response.data.data.session_id;
|
const sessionId = response.data.data.session_id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await bindConfigToSession(sessionId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to bind config to session', err);
|
||||||
|
}
|
||||||
|
|
||||||
currSessionId.value = sessionId;
|
currSessionId.value = sessionId;
|
||||||
|
|
||||||
return sessionId;
|
return sessionId;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -132,6 +158,7 @@ const {
|
|||||||
enableStreaming,
|
enableStreaming,
|
||||||
getSessionMessages: getSessionMsg,
|
getSessionMessages: getSessionMsg,
|
||||||
sendMessage: sendMsg,
|
sendMessage: sendMsg,
|
||||||
|
stopMessage: stopMsg,
|
||||||
toggleStreaming
|
toggleStreaming
|
||||||
} = useMessages(currSessionId, getMediaFile, updateSessionTitle, getSessions);
|
} = useMessages(currSessionId, getMediaFile, updateSessionTitle, getSessions);
|
||||||
|
|
||||||
@@ -212,6 +239,10 @@ async function handleSendMessage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleStopMessage() {
|
||||||
|
await stopMsg();
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// 独立模式在挂载时创建新会话
|
// 独立模式在挂载时创建新会话
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,18 +2,22 @@
|
|||||||
<div :class="$vuetify.display.mobile ? '' : 'd-flex'">
|
<div :class="$vuetify.display.mobile ? '' : 'd-flex'">
|
||||||
<v-tabs v-model="tab" :direction="$vuetify.display.mobile ? 'horizontal' : 'vertical'"
|
<v-tabs v-model="tab" :direction="$vuetify.display.mobile ? 'horizontal' : 'vertical'"
|
||||||
:align-tabs="$vuetify.display.mobile ? 'left' : 'start'" color="deep-purple-accent-4" class="config-tabs">
|
:align-tabs="$vuetify.display.mobile ? 'left' : 'start'" color="deep-purple-accent-4" class="config-tabs">
|
||||||
<v-tab v-for="(val, key, index) in metadata" :key="index" :value="index"
|
<v-tab v-for="section in visibleSections" :key="section.key" :value="section.key"
|
||||||
style="font-weight: 1000; font-size: 15px">
|
style="font-weight: 1000; font-size: 15px">
|
||||||
{{ tm(metadata[key]['name']) }}
|
{{ tm(section.value['name']) }}
|
||||||
</v-tab>
|
</v-tab>
|
||||||
</v-tabs>
|
</v-tabs>
|
||||||
<v-tabs-window v-model="tab" class="config-tabs-window" :style="readonly ? 'pointer-events: none; opacity: 0.6;' : ''">
|
<v-tabs-window v-model="tab" class="config-tabs-window" :style="readonly ? 'pointer-events: none; opacity: 0.6;' : ''">
|
||||||
<v-tabs-window-item v-for="(val, key, index) in metadata" v-show="index == tab" :key="index">
|
<v-tabs-window-item v-for="section in visibleSections" :key="section.key" :value="section.key">
|
||||||
<v-container fluid>
|
<v-container fluid>
|
||||||
<div v-for="(val2, key2, index2) in metadata[key]['metadata']" :key="key2">
|
<div v-for="(val2, key2, index2) in section.value['metadata']" :key="key2">
|
||||||
<!-- Support both traditional and JSON selector metadata -->
|
<!-- Support both traditional and JSON selector metadata -->
|
||||||
<AstrBotConfigV4 :metadata="{ [key2]: metadata[key]['metadata'][key2] }" :iterable="config_data"
|
<AstrBotConfigV4
|
||||||
:metadataKey="key2">
|
:metadata="{ [key2]: section.value['metadata'][key2] }"
|
||||||
|
:iterable="config_data"
|
||||||
|
:metadataKey="key2"
|
||||||
|
:search-keyword="searchKeyword"
|
||||||
|
>
|
||||||
</AstrBotConfigV4>
|
</AstrBotConfigV4>
|
||||||
</div>
|
</div>
|
||||||
</v-container>
|
</v-container>
|
||||||
@@ -31,6 +35,11 @@
|
|||||||
|
|
||||||
</v-tabs-window>
|
</v-tabs-window>
|
||||||
</div>
|
</div>
|
||||||
|
<v-container v-if="visibleSections.length === 0" fluid class="px-0">
|
||||||
|
<v-alert type="info" variant="tonal">
|
||||||
|
{{ tm('search.noResult') }}
|
||||||
|
</v-alert>
|
||||||
|
</v-container>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -56,6 +65,10 @@ export default {
|
|||||||
readonly: {
|
readonly: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
|
},
|
||||||
|
searchKeyword: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
@@ -76,11 +89,63 @@ export default {
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
tab: 0, // 用于切换配置标签页
|
tab: null, // 当前激活的配置标签页 key
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
normalizedSearchKeyword() {
|
||||||
|
return String(this.searchKeyword || '').trim().toLowerCase();
|
||||||
|
},
|
||||||
|
visibleSections() {
|
||||||
|
if (!this.metadata || typeof this.metadata !== 'object') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const allSections = Object.entries(this.metadata).map(([key, value]) => ({ key, value }));
|
||||||
|
if (!this.normalizedSearchKeyword) {
|
||||||
|
return allSections;
|
||||||
|
}
|
||||||
|
return allSections.filter((section) => this.sectionHasSearchMatch(section.value));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
visibleSections(newSections) {
|
||||||
|
const sectionKeys = newSections.map((section) => section.key);
|
||||||
|
if (!sectionKeys.includes(this.tab)) {
|
||||||
|
this.tab = sectionKeys[0] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
const sectionKeys = this.visibleSections.map((section) => section.key);
|
||||||
|
this.tab = sectionKeys[0] ?? null;
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
// 如果需要添加其他方法,可以在这里添加
|
sectionHasSearchMatch(section) {
|
||||||
|
const keyword = this.normalizedSearchKeyword;
|
||||||
|
if (!keyword) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const sectionMetadata = section?.metadata || {};
|
||||||
|
return Object.values(sectionMetadata).some((metaItem) => this.metaObjectHasSearchMatch(metaItem, keyword));
|
||||||
|
},
|
||||||
|
metaObjectHasSearchMatch(metaObject, keyword) {
|
||||||
|
if (!metaObject || typeof metaObject !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const target = [
|
||||||
|
this.tm(metaObject.description || ''),
|
||||||
|
this.tm(metaObject.hint || ''),
|
||||||
|
...Object.entries(metaObject.items || {}).flatMap(([itemKey, itemMeta]) => ([
|
||||||
|
itemKey,
|
||||||
|
this.tm(itemMeta?.description || ''),
|
||||||
|
this.tm(itemMeta?.hint || '')
|
||||||
|
]))
|
||||||
|
]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
return target.includes(keyword);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<template>
|
||||||
|
<v-dialog v-model="isOpen" max-width="480" persistent>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="dialog-title d-flex align-center justify-space-between">
|
||||||
|
<span>{{ title }}</span>
|
||||||
|
<v-btn icon="mdi-close" variant="text" @click="handleClose"></v-btn>
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<div class="message-text">{{ message }}</div>
|
||||||
|
<div class="action-hints">
|
||||||
|
<span class="hint-item">{{ confirmHint }}</span>
|
||||||
|
<span class="hint-item">{{ cancelHint }}</span>
|
||||||
|
<span class="hint-item">{{ closeHint }}</span>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn color="gray" @click="handleCancel">{{ t('core.common.dialog.cancelButton') }}</v-btn>
|
||||||
|
<v-btn color="red" @click="handleConfirm" class="confirm-button">{{ t('core.common.dialog.confirmButton') }}</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { useI18n } from '@/i18n/composables';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const isOpen = ref(false);
|
||||||
|
const title = ref("");
|
||||||
|
const message = ref("");
|
||||||
|
const confirmHint = ref("");
|
||||||
|
const cancelHint = ref("");
|
||||||
|
const closeHint = ref("");
|
||||||
|
let resolvePromise = null;
|
||||||
|
|
||||||
|
const open = (options) => {
|
||||||
|
title.value = options.title || t('core.common.dialog.confirmTitle');
|
||||||
|
message.value = options.message || t('core.common.dialog.confirmMessage');
|
||||||
|
confirmHint.value = options.confirmHint || "";
|
||||||
|
cancelHint.value = options.cancelHint || "";
|
||||||
|
closeHint.value = options.closeHint || "";
|
||||||
|
isOpen.value = true;
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
resolvePromise = resolve;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
isOpen.value = false;
|
||||||
|
if (resolvePromise) resolvePromise(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
isOpen.value = false;
|
||||||
|
if (resolvePromise) resolvePromise(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
isOpen.value = false;
|
||||||
|
if (resolvePromise) resolvePromise('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ open });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.message-text {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-hints {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-item {
|
||||||
|
color: var(--v-theme-secondaryText, #666);
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-button {
|
||||||
|
color: rgb(239, 68, 68);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import { useModuleI18n } from "@/i18n/composables";
|
||||||
|
import PluginPlatformChip from "@/components/shared/PluginPlatformChip.vue";
|
||||||
|
|
||||||
|
const { tm } = useModuleI18n("features/extension");
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
plugin: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
defaultPluginIcon: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
showPluginFullName: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["install"]);
|
||||||
|
|
||||||
|
const normalizePlatformList = (platforms) => {
|
||||||
|
if (!Array.isArray(platforms)) return [];
|
||||||
|
return platforms.filter((item) => typeof item === "string");
|
||||||
|
};
|
||||||
|
|
||||||
|
const platformDisplayList = computed(() =>
|
||||||
|
normalizePlatformList(props.plugin?.support_platforms),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleInstall = (plugin) => {
|
||||||
|
emit("install", plugin);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-card
|
||||||
|
class="rounded-lg d-flex flex-column plugin-card"
|
||||||
|
elevation="0"
|
||||||
|
style="height: 13rem; position: relative"
|
||||||
|
>
|
||||||
|
<v-chip
|
||||||
|
v-if="plugin?.pinned"
|
||||||
|
color="warning"
|
||||||
|
size="x-small"
|
||||||
|
label
|
||||||
|
style="
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
top: 8px;
|
||||||
|
z-index: 10;
|
||||||
|
height: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ tm("market.recommended") }}
|
||||||
|
</v-chip>
|
||||||
|
|
||||||
|
<v-card-text
|
||||||
|
style="
|
||||||
|
padding: 12px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div style="flex-shrink: 0">
|
||||||
|
<img
|
||||||
|
:src="plugin?.logo || defaultPluginIcon"
|
||||||
|
:alt="plugin.name"
|
||||||
|
style="
|
||||||
|
height: 75px;
|
||||||
|
width: 75px;
|
||||||
|
border-radius: 8px;
|
||||||
|
object-fit: cover;
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="font-weight-bold"
|
||||||
|
style="
|
||||||
|
margin-bottom: 4px;
|
||||||
|
line-height: 1.3;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span style="overflow: hidden; text-overflow: ellipsis">
|
||||||
|
{{
|
||||||
|
plugin.display_name?.length
|
||||||
|
? plugin.display_name
|
||||||
|
: showPluginFullName
|
||||||
|
? plugin.name
|
||||||
|
: plugin.trimmedName
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-center" style="gap: 4px; margin-bottom: 6px">
|
||||||
|
<v-icon
|
||||||
|
icon="mdi-account"
|
||||||
|
size="x-small"
|
||||||
|
style="color: rgba(var(--v-theme-on-surface), 0.5)"
|
||||||
|
></v-icon>
|
||||||
|
<a
|
||||||
|
v-if="plugin?.social_link"
|
||||||
|
:href="plugin.social_link"
|
||||||
|
target="_blank"
|
||||||
|
class="text-subtitle-2 font-weight-medium"
|
||||||
|
style="
|
||||||
|
text-decoration: none;
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ plugin.author }}
|
||||||
|
</a>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="text-subtitle-2 font-weight-medium"
|
||||||
|
style="
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ plugin.author }}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class="d-flex align-center text-subtitle-2 ml-2"
|
||||||
|
style="color: rgba(var(--v-theme-on-surface), 0.7)"
|
||||||
|
>
|
||||||
|
<v-icon
|
||||||
|
icon="mdi-source-branch"
|
||||||
|
size="x-small"
|
||||||
|
style="margin-right: 2px"
|
||||||
|
></v-icon>
|
||||||
|
<span>{{ plugin.version }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-caption plugin-description">
|
||||||
|
{{ plugin.desc }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="plugin.astrbot_version || platformDisplayList.length"
|
||||||
|
class="d-flex align-center flex-wrap"
|
||||||
|
style="gap: 4px; margin-top: 4px; margin-bottom: 4px"
|
||||||
|
>
|
||||||
|
<v-chip
|
||||||
|
v-if="plugin.astrbot_version"
|
||||||
|
size="x-small"
|
||||||
|
color="secondary"
|
||||||
|
variant="outlined"
|
||||||
|
style="height: 20px"
|
||||||
|
>
|
||||||
|
AstrBot: {{ plugin.astrbot_version }}
|
||||||
|
</v-chip>
|
||||||
|
<PluginPlatformChip
|
||||||
|
:platforms="plugin.support_platforms"
|
||||||
|
size="x-small"
|
||||||
|
:chip-style="{ height: '20px' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-center" style="gap: 8px; margin-top: auto">
|
||||||
|
<div
|
||||||
|
v-if="plugin.stars !== undefined"
|
||||||
|
class="d-flex align-center text-subtitle-2"
|
||||||
|
style="color: rgba(var(--v-theme-on-surface), 0.7)"
|
||||||
|
>
|
||||||
|
<v-icon
|
||||||
|
icon="mdi-star"
|
||||||
|
size="x-small"
|
||||||
|
style="margin-right: 2px"
|
||||||
|
></v-icon>
|
||||||
|
<span>{{ plugin.stars }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="plugin.updated_at"
|
||||||
|
class="d-flex align-center text-subtitle-2"
|
||||||
|
style="color: rgba(var(--v-theme-on-surface), 0.7)"
|
||||||
|
>
|
||||||
|
<v-icon
|
||||||
|
icon="mdi-clock-outline"
|
||||||
|
size="x-small"
|
||||||
|
style="margin-right: 2px"
|
||||||
|
></v-icon>
|
||||||
|
<span>{{ new Date(plugin.updated_at).toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<v-card-actions style="gap: 6px; padding: 8px 12px; padding-top: 0">
|
||||||
|
<v-chip
|
||||||
|
v-for="tag in plugin.tags?.slice(0, 2)"
|
||||||
|
:key="tag"
|
||||||
|
:color="tag === 'danger' ? 'error' : 'primary'"
|
||||||
|
label
|
||||||
|
size="x-small"
|
||||||
|
style="height: 20px"
|
||||||
|
>
|
||||||
|
{{ tag === "danger" ? tm("tags.danger") : tag }}
|
||||||
|
</v-chip>
|
||||||
|
<v-menu v-if="plugin.tags && plugin.tags.length > 2" open-on-hover offset-y>
|
||||||
|
<template v-slot:activator="{ props: menuProps }">
|
||||||
|
<v-chip
|
||||||
|
v-bind="menuProps"
|
||||||
|
color="grey"
|
||||||
|
label
|
||||||
|
size="x-small"
|
||||||
|
style="height: 20px; cursor: pointer"
|
||||||
|
>
|
||||||
|
+{{ plugin.tags.length - 2 }}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
<v-list density="compact">
|
||||||
|
<v-list-item v-for="tag in plugin.tags.slice(2)" :key="tag">
|
||||||
|
<v-chip :color="tag === 'danger' ? 'error' : 'primary'" label size="small">
|
||||||
|
{{ tag === "danger" ? tm("tags.danger") : tag }}
|
||||||
|
</v-chip>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn
|
||||||
|
v-if="plugin?.repo"
|
||||||
|
color="secondary"
|
||||||
|
size="x-small"
|
||||||
|
variant="tonal"
|
||||||
|
:href="plugin.repo"
|
||||||
|
target="_blank"
|
||||||
|
style="height: 24px"
|
||||||
|
>
|
||||||
|
<v-icon icon="mdi-github" start size="x-small"></v-icon>
|
||||||
|
{{ tm("buttons.viewRepo") }}
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
v-if="!plugin?.installed"
|
||||||
|
color="primary"
|
||||||
|
size="x-small"
|
||||||
|
@click="handleInstall(plugin)"
|
||||||
|
variant="flat"
|
||||||
|
style="height: 24px"
|
||||||
|
>
|
||||||
|
{{ tm("buttons.install") }}
|
||||||
|
</v-btn>
|
||||||
|
<v-chip v-else color="success" size="x-small" label style="height: 20px">
|
||||||
|
✓ {{ tm("status.installed") }}
|
||||||
|
</v-chip>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.plugin-description {
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.6);
|
||||||
|
line-height: 1.3;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-card:hover .plugin-description {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-description::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-description::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-description::-webkit-scrollbar-thumb {
|
||||||
|
background-color: rgba(var(--v-theme-primary-rgb), 0.4);
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-description::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: rgba(var(--v-theme-primary-rgb), 0.6);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,6 +4,7 @@ import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import ConfigItemRenderer from './ConfigItemRenderer.vue'
|
import ConfigItemRenderer from './ConfigItemRenderer.vue'
|
||||||
import TemplateListEditor from './TemplateListEditor.vue'
|
import TemplateListEditor from './TemplateListEditor.vue'
|
||||||
|
import PersonaQuickPreview from './PersonaQuickPreview.vue'
|
||||||
import { useI18n, useModuleI18n } from '@/i18n/composables'
|
import { useI18n, useModuleI18n } from '@/i18n/composables'
|
||||||
|
|
||||||
|
|
||||||
@@ -19,6 +20,10 @@ const props = defineProps({
|
|||||||
metadataKey: {
|
metadataKey: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true
|
required: true
|
||||||
|
},
|
||||||
|
searchKeyword: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -124,16 +129,27 @@ function saveEditedContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function shouldShowItem(itemMeta, itemKey) {
|
function shouldShowItem(itemMeta, itemKey) {
|
||||||
if (!itemMeta?.condition) {
|
if (itemMeta?.condition) {
|
||||||
return true
|
for (const [conditionKey, expectedValue] of Object.entries(itemMeta.condition)) {
|
||||||
}
|
const actualValue = getValueBySelector(props.iterable, conditionKey)
|
||||||
for (const [conditionKey, expectedValue] of Object.entries(itemMeta.condition)) {
|
if (actualValue !== expectedValue) {
|
||||||
const actualValue = getValueBySelector(props.iterable, conditionKey)
|
return false
|
||||||
if (actualValue !== expectedValue) {
|
}
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
|
||||||
|
const keyword = String(props.searchKeyword || '').trim().toLowerCase()
|
||||||
|
if (!keyword) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchableText = [
|
||||||
|
itemKey,
|
||||||
|
translateIfKey(itemMeta?.description || ''),
|
||||||
|
translateIfKey(itemMeta?.hint || '')
|
||||||
|
].join(' ').toLowerCase()
|
||||||
|
|
||||||
|
return searchableText.includes(keyword)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查最外层的 object 是否应该显示
|
// 检查最外层的 object 是否应该显示
|
||||||
@@ -148,7 +164,10 @@ function shouldShowSection() {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
|
||||||
|
const sectionItems = props.metadata?.[props.metadataKey]?.items || {}
|
||||||
|
const hasVisibleItems = Object.entries(sectionItems).some(([itemKey, itemMeta]) => shouldShowItem(itemMeta, itemKey))
|
||||||
|
return hasVisibleItems
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasVisibleItemsAfter(items, currentIndex) {
|
function hasVisibleItemsAfter(items, currentIndex) {
|
||||||
@@ -256,6 +275,16 @@ function getSpecialSubtype(value) {
|
|||||||
</div>
|
</div>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
|
||||||
|
<!-- Default Persona Quick Preview 全宽显示区域 -->
|
||||||
|
<v-row
|
||||||
|
v-if="!itemMeta?.invisible && itemMeta?._special === 'select_persona' && itemKey === 'provider_settings.default_personality'"
|
||||||
|
class="persona-preview-row"
|
||||||
|
>
|
||||||
|
<v-col cols="12" class="persona-preview-display">
|
||||||
|
<PersonaQuickPreview :model-value="createSelectorModel(itemKey).value" />
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
</template>
|
</template>
|
||||||
<v-divider class="config-divider"
|
<v-divider class="config-divider"
|
||||||
v-if="shouldShowItem(itemMeta, itemKey) && hasVisibleItemsAfter(metadata[metadataKey].items, index)"></v-divider>
|
v-if="shouldShowItem(itemMeta, itemKey) && hasVisibleItemsAfter(metadata[metadataKey].items, index)"></v-divider>
|
||||||
@@ -415,6 +444,15 @@ function getSpecialSubtype(value) {
|
|||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.persona-preview-row {
|
||||||
|
margin: 16px;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.persona-preview-display {
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.selected-plugins-full-width {
|
.selected-plugins-full-width {
|
||||||
background-color: rgba(var(--v-theme-primary), 0.05);
|
background-color: rgba(var(--v-theme-primary), 0.05);
|
||||||
border: 1px solid rgba(var(--v-theme-primary), 0.1);
|
border: 1px solid rgba(var(--v-theme-primary), 0.1);
|
||||||
@@ -436,9 +474,13 @@ function getSpecialSubtype(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.property-info,
|
.property-info,
|
||||||
.type-indicator,
|
.type-indicator {
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.config-input {
|
.config-input {
|
||||||
padding: 4px;
|
padding-left: 24px;
|
||||||
|
padding-right: 24px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
import { ref, computed, inject } from "vue";
|
import { ref, computed, inject } from "vue";
|
||||||
import { useCustomizerStore } from "@/stores/customizer";
|
import { useCustomizerStore } from "@/stores/customizer";
|
||||||
import { useModuleI18n } from "@/i18n/composables";
|
import { useModuleI18n } from "@/i18n/composables";
|
||||||
|
import { getPlatformDisplayName, getPlatformIcon } from "@/utils/platformUtils";
|
||||||
import UninstallConfirmDialog from "./UninstallConfirmDialog.vue";
|
import UninstallConfirmDialog from "./UninstallConfirmDialog.vue";
|
||||||
|
import PluginPlatformChip from "./PluginPlatformChip.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
extension: {
|
extension: {
|
||||||
@@ -38,6 +40,25 @@ const showUninstallDialog = ref(false);
|
|||||||
// 国际化
|
// 国际化
|
||||||
const { tm } = useModuleI18n("features/extension");
|
const { tm } = useModuleI18n("features/extension");
|
||||||
|
|
||||||
|
const supportPlatforms = computed(() => {
|
||||||
|
const platforms = props.extension?.support_platforms;
|
||||||
|
if (!Array.isArray(platforms)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return platforms.filter((item) => typeof item === "string");
|
||||||
|
});
|
||||||
|
|
||||||
|
const supportPlatformDisplayNames = computed(() =>
|
||||||
|
supportPlatforms.value.map((platformId) => getPlatformDisplayName(platformId)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const astrbotVersionRequirement = computed(() => {
|
||||||
|
const versionSpec = props.extension?.astrbot_version;
|
||||||
|
return typeof versionSpec === "string" && versionSpec.trim().length
|
||||||
|
? versionSpec.trim()
|
||||||
|
: "";
|
||||||
|
});
|
||||||
|
|
||||||
// 操作函数
|
// 操作函数
|
||||||
const configure = () => {
|
const configure = () => {
|
||||||
emit("configure", props.extension);
|
emit("configure", props.extension);
|
||||||
@@ -87,8 +108,9 @@ const viewChangelog = () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-card
|
<v-card
|
||||||
class="mx-auto d-flex flex-column"
|
class="mx-auto d-flex flex-column h-100"
|
||||||
elevation="0"
|
elevation="0"
|
||||||
|
height="100%"
|
||||||
:style="{
|
:style="{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
@@ -316,6 +338,20 @@ const viewChangelog = () => {
|
|||||||
>
|
>
|
||||||
{{ tag === "danger" ? tm("tags.danger") : tag }}
|
{{ tag === "danger" ? tm("tags.danger") : tag }}
|
||||||
</v-chip>
|
</v-chip>
|
||||||
|
<PluginPlatformChip
|
||||||
|
:platforms="supportPlatforms"
|
||||||
|
class="ml-2"
|
||||||
|
/>
|
||||||
|
<v-chip
|
||||||
|
v-if="astrbotVersionRequirement"
|
||||||
|
color="secondary"
|
||||||
|
variant="outlined"
|
||||||
|
label
|
||||||
|
size="small"
|
||||||
|
class="ml-2"
|
||||||
|
>
|
||||||
|
AstrBot: {{ astrbotVersionRequirement }}
|
||||||
|
</v-chip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,32 +1,30 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-dialog v-model="showDialog" max-width="500px">
|
<v-dialog v-model="showDialog" :max-width="$vuetify.display.smAndDown ? undefined : '1200px'" scrollable>
|
||||||
<v-card>
|
<v-card class="persona-form-card" :class="{ 'persona-form-card-mobile': $vuetify.display.smAndDown }">
|
||||||
<v-card-title class="text-h2">
|
<v-card-title class="persona-form-title text-h2 px-6 pt-6 pl-6">
|
||||||
{{ editingPersona ? tm('dialog.edit.title') : tm('dialog.create.title') }}
|
{{ editingPersona ? tm('dialog.edit.title') : tm('dialog.create.title') }}
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
|
|
||||||
<v-card-text>
|
<v-card-text class="persona-form-content">
|
||||||
<!-- 创建位置提示 -->
|
<!-- 创建位置提示 -->
|
||||||
<v-alert
|
<v-alert v-if="!editingPersona" type="info" variant="tonal" density="compact" class="mb-4"
|
||||||
v-if="!editingPersona"
|
icon="mdi-folder-outline">
|
||||||
type="info"
|
|
||||||
variant="tonal"
|
|
||||||
density="compact"
|
|
||||||
class="mb-4"
|
|
||||||
icon="mdi-folder-outline"
|
|
||||||
>
|
|
||||||
{{ tm('form.createInFolder', { folder: folderDisplayName }) }}
|
{{ tm('form.createInFolder', { folder: folderDisplayName }) }}
|
||||||
</v-alert>
|
</v-alert>
|
||||||
|
|
||||||
<v-form ref="personaForm" v-model="formValid">
|
<v-form ref="personaForm" v-model="formValid">
|
||||||
<v-text-field v-model="personaForm.persona_id" :label="tm('form.personaId')"
|
<v-row class="persona-form-layout">
|
||||||
:rules="personaIdRules" :disabled="editingPersona" variant="outlined" density="comfortable"
|
<v-col cols="12" md="6" class="persona-basic-col">
|
||||||
class="mb-4" />
|
<v-text-field v-model="personaForm.persona_id" :label="tm('form.personaId')"
|
||||||
|
:rules="personaIdRules" :disabled="editingPersona" variant="outlined"
|
||||||
|
density="comfortable" class="mb-4" />
|
||||||
|
|
||||||
<v-textarea v-model="personaForm.system_prompt" :label="tm('form.systemPrompt')"
|
<v-textarea v-model="personaForm.system_prompt" :label="tm('form.systemPrompt')"
|
||||||
:rules="systemPromptRules" variant="outlined" rows="6" class="mb-4" />
|
:rules="systemPromptRules" variant="outlined" rows="16" class="mb-4" />
|
||||||
|
</v-col>
|
||||||
|
|
||||||
<v-expansion-panels v-model="expandedPanels" multiple>
|
<v-col cols="12" md="6" class="persona-panels-col">
|
||||||
|
<v-expansion-panels v-model="expandedPanels" multiple>
|
||||||
<!-- 工具选择面板 -->
|
<!-- 工具选择面板 -->
|
||||||
<v-expansion-panel value="tools">
|
<v-expansion-panel value="tools">
|
||||||
<v-expansion-panel-title>
|
<v-expansion-panel-title>
|
||||||
@@ -51,7 +49,7 @@
|
|||||||
</v-radio>
|
</v-radio>
|
||||||
</v-radio-group>
|
</v-radio-group>
|
||||||
|
|
||||||
<div v-if="toolSelectValue === '1'" class="mt-3 ml-8">
|
<div v-if="toolSelectValue === '1'" class="mt-3 selected-config-area">
|
||||||
|
|
||||||
<!-- 工具搜索 -->
|
<!-- 工具搜索 -->
|
||||||
<v-text-field v-model="toolSearch" :label="tm('form.searchTools')"
|
<v-text-field v-model="toolSearch" :label="tm('form.searchTools')"
|
||||||
@@ -65,8 +63,8 @@
|
|||||||
<div class="d-flex flex-wrap ga-2">
|
<div class="d-flex flex-wrap ga-2">
|
||||||
<v-chip v-for="server in mcpServers" :key="server.name"
|
<v-chip v-for="server in mcpServers" :key="server.name"
|
||||||
:color="isServerSelected(server) ? 'primary' : 'default'"
|
:color="isServerSelected(server) ? 'primary' : 'default'"
|
||||||
:variant="isServerSelected(server) ? 'flat' : 'outlined'"
|
:variant="isServerSelected(server) ? 'flat' : 'outlined'" size="small"
|
||||||
size="small" clickable @click="toggleMcpServer(server)"
|
clickable @click="toggleMcpServer(server)"
|
||||||
:disabled="!server.tools || server.tools.length === 0">
|
:disabled="!server.tools || server.tools.length === 0">
|
||||||
<v-icon start size="small">mdi-server</v-icon>
|
<v-icon start size="small">mdi-server</v-icon>
|
||||||
{{ server.name }}
|
{{ server.name }}
|
||||||
@@ -79,7 +77,7 @@
|
|||||||
|
|
||||||
<!-- 工具选择列表 -->
|
<!-- 工具选择列表 -->
|
||||||
<div v-if="filteredTools.length > 0" class="tools-selection">
|
<div v-if="filteredTools.length > 0" class="tools-selection">
|
||||||
<v-virtual-scroll :items="filteredTools" height="300" item-height="48">
|
<v-virtual-scroll :items="filteredTools" height="300" item-height="72">
|
||||||
<template v-slot:default="{ item }">
|
<template v-slot:default="{ item }">
|
||||||
<v-list-item :key="item.name" density="comfortable"
|
<v-list-item :key="item.name" density="comfortable"
|
||||||
@click="toggleTool(item.name)">
|
@click="toggleTool(item.name)">
|
||||||
@@ -90,10 +88,16 @@
|
|||||||
|
|
||||||
<v-list-item-title>
|
<v-list-item-title>
|
||||||
{{ item.name }}
|
{{ item.name }}
|
||||||
<v-chip v-if="item.mcp_server_name" size="x-small"
|
|
||||||
color="secondary" variant="tonal" class="ml-2">
|
<v-chip v-if="item.origin" size="x-small" color="info" class="mr-2"
|
||||||
{{ item.mcp_server_name }}
|
variant="tonal">
|
||||||
|
{{ item.origin }}
|
||||||
</v-chip>
|
</v-chip>
|
||||||
|
<v-chip v-if="item.origin_name" size="x-small" color="info"
|
||||||
|
variant="outlined">
|
||||||
|
{{ item.origin_name }}
|
||||||
|
</v-chip>
|
||||||
|
|
||||||
</v-list-item-title>
|
</v-list-item-title>
|
||||||
|
|
||||||
<v-list-item-subtitle v-if="item.description">
|
<v-list-item-subtitle v-if="item.description">
|
||||||
@@ -108,7 +112,7 @@
|
|||||||
class="text-center pa-4">
|
class="text-center pa-4">
|
||||||
<v-icon size="48" color="grey-lighten-2" class="mb-2">mdi-tools</v-icon>
|
<v-icon size="48" color="grey-lighten-2" class="mb-2">mdi-tools</v-icon>
|
||||||
<p class="text-body-2 text-medium-emphasis">{{ tm('form.noToolsAvailable')
|
<p class="text-body-2 text-medium-emphasis">{{ tm('form.noToolsAvailable')
|
||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -123,7 +127,7 @@
|
|||||||
<div v-if="loadingTools" class="text-center pa-4">
|
<div v-if="loadingTools" class="text-center pa-4">
|
||||||
<v-progress-circular indeterminate color="primary" />
|
<v-progress-circular indeterminate color="primary" />
|
||||||
<p class="text-body-2 text-medium-emphasis mt-2">{{ tm('form.loadingTools')
|
<p class="text-body-2 text-medium-emphasis mt-2">{{ tm('form.loadingTools')
|
||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -139,9 +143,9 @@
|
|||||||
</span>
|
</span>
|
||||||
</h4>
|
</h4>
|
||||||
<div v-if="Array.isArray(personaForm.tools) && personaForm.tools.length > 0"
|
<div v-if="Array.isArray(personaForm.tools) && personaForm.tools.length > 0"
|
||||||
class="d-flex flex-wrap ga-1" style="max-height: 100px; overflow-y: auto;">
|
class="d-flex flex-wrap ga-1" style="max-height: 100px; overflow-y: auto;">
|
||||||
<v-chip v-for="toolName in personaForm.tools" :key="toolName"
|
<v-chip v-for="toolName in personaForm.tools" :key="toolName" size="small"
|
||||||
size="small" color="primary" variant="tonal" closable
|
color="primary" variant="tonal" closable
|
||||||
@click:close="removeTool(toolName)">
|
@click:close="removeTool(toolName)">
|
||||||
{{ toolName }}
|
{{ toolName }}
|
||||||
</v-chip>
|
</v-chip>
|
||||||
@@ -178,7 +182,7 @@
|
|||||||
<v-radio :label="tm('form.skillsSelectSpecific')" value="1"></v-radio>
|
<v-radio :label="tm('form.skillsSelectSpecific')" value="1"></v-radio>
|
||||||
</v-radio-group>
|
</v-radio-group>
|
||||||
|
|
||||||
<div v-if="skillSelectValue === '1'" class="mt-3 ml-8">
|
<div v-if="skillSelectValue === '1'" class="mt-3 selected-config-area">
|
||||||
<v-text-field v-model="skillSearch" :label="tm('form.searchSkills')"
|
<v-text-field v-model="skillSearch" :label="tm('form.searchSkills')"
|
||||||
prepend-inner-icon="mdi-magnify" variant="outlined" density="compact"
|
prepend-inner-icon="mdi-magnify" variant="outlined" density="compact"
|
||||||
hide-details clearable class="mb-3" />
|
hide-details clearable class="mb-3" />
|
||||||
@@ -205,7 +209,8 @@
|
|||||||
|
|
||||||
<div v-else-if="!loadingSkills && availableSkills.length === 0"
|
<div v-else-if="!loadingSkills && availableSkills.length === 0"
|
||||||
class="text-center pa-4">
|
class="text-center pa-4">
|
||||||
<v-icon size="48" color="grey-lighten-2" class="mb-2">mdi-lightning-bolt</v-icon>
|
<v-icon size="48" color="grey-lighten-2"
|
||||||
|
class="mb-2">mdi-lightning-bolt</v-icon>
|
||||||
<p class="text-body-2 text-medium-emphasis">{{ tm('form.noSkillsAvailable') }}
|
<p class="text-body-2 text-medium-emphasis">{{ tm('form.noSkillsAvailable') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -284,11 +289,13 @@
|
|||||||
</v-btn>
|
</v-btn>
|
||||||
</v-expansion-panel-text>
|
</v-expansion-panel-text>
|
||||||
</v-expansion-panel>
|
</v-expansion-panel>
|
||||||
</v-expansion-panels>
|
</v-expansion-panels>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
</v-form>
|
</v-form>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
|
|
||||||
<v-card-actions>
|
<v-card-actions class="persona-form-actions">
|
||||||
<v-btn v-if="editingPersona" color="error" variant="text" @click="deletePersona">
|
<v-btn v-if="editingPersona" color="error" variant="text" @click="deletePersona">
|
||||||
{{ tm('buttons.delete') }}
|
{{ tm('buttons.delete') }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
@@ -480,7 +487,7 @@ export default {
|
|||||||
};
|
};
|
||||||
this.toolSelectValue = '0';
|
this.toolSelectValue = '0';
|
||||||
this.skillSelectValue = '0';
|
this.skillSelectValue = '0';
|
||||||
this.expandedPanels = [];
|
this.expandedPanels = this.getDefaultExpandedPanels();
|
||||||
},
|
},
|
||||||
|
|
||||||
initFormWithPersona(persona) {
|
initFormWithPersona(persona) {
|
||||||
@@ -495,7 +502,11 @@ export default {
|
|||||||
// 根据 tools 的值设置 toolSelectValue
|
// 根据 tools 的值设置 toolSelectValue
|
||||||
this.toolSelectValue = persona.tools === null ? '0' : '1';
|
this.toolSelectValue = persona.tools === null ? '0' : '1';
|
||||||
this.skillSelectValue = persona.skills === null ? '0' : '1';
|
this.skillSelectValue = persona.skills === null ? '0' : '1';
|
||||||
this.expandedPanels = [];
|
this.expandedPanels = this.getDefaultExpandedPanels();
|
||||||
|
},
|
||||||
|
|
||||||
|
getDefaultExpandedPanels() {
|
||||||
|
return this.$vuetify.display.smAndDown ? [] : ['tools', 'skills', 'dialogs'];
|
||||||
},
|
},
|
||||||
|
|
||||||
closeDialog() {
|
closeDialog() {
|
||||||
@@ -799,6 +810,36 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.persona-form-card {
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.persona-form-content {
|
||||||
|
max-height: min(78vh, 760px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.persona-form-title {
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.persona-form-actions {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background: rgb(var(--v-theme-surface));
|
||||||
|
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-config-area {
|
||||||
|
margin-left: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.persona-form-layout {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.tools-selection {
|
.tools-selection {
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -812,4 +853,43 @@ export default {
|
|||||||
.v-virtual-scroll {
|
.v-virtual-scroll {
|
||||||
padding-bottom: 16px;
|
padding-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.persona-form-card-mobile {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.persona-form-content {
|
||||||
|
max-height: calc(100vh - 128px);
|
||||||
|
padding: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.persona-basic-col,
|
||||||
|
.persona-panels-col {
|
||||||
|
padding-top: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.persona-form-title {
|
||||||
|
font-size: 1.15rem !important;
|
||||||
|
padding: 12px 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-config-area {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-selection,
|
||||||
|
.skills-selection {
|
||||||
|
max-height: 38vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.persona-form-actions {
|
||||||
|
padding: 12px 16px !important;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.persona-form-actions .v-btn {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
<template>
|
||||||
|
<div class="persona-preview-card">
|
||||||
|
<div class="preview-header">
|
||||||
|
<small>{{ tm('personaQuickPreview.title') }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="preview-loading">
|
||||||
|
<v-progress-circular indeterminate size="18" width="2" color="primary" class="mr-2" />
|
||||||
|
<small class="text-grey">{{ tm('personaQuickPreview.loading') }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!modelValue" class="preview-empty">
|
||||||
|
<small class="text-grey">{{ tm('personaQuickPreview.noPersonaSelected') }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!personaData" class="preview-empty">
|
||||||
|
<small class="text-grey">{{ tm('personaQuickPreview.personaNotFound') }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="preview-content">
|
||||||
|
<div class="section-title">{{ tm('personaQuickPreview.systemPromptLabel') }}</div>
|
||||||
|
<pre class="prompt-content">{{ personaData.system_prompt || '' }}</pre>
|
||||||
|
|
||||||
|
<div class="section-title mt-3">{{ tm('personaQuickPreview.toolsLabel') }}</div>
|
||||||
|
<div class="chip-wrap tools-wrap">
|
||||||
|
<v-chip
|
||||||
|
v-if="personaData.tools === null"
|
||||||
|
size="small"
|
||||||
|
color="success"
|
||||||
|
variant="tonal"
|
||||||
|
label
|
||||||
|
>
|
||||||
|
{{ tm('personaQuickPreview.allToolsWithCount', { count: allToolsCount }) }}
|
||||||
|
</v-chip>
|
||||||
|
<div v-for="tool in resolvedTools" v-else :key="tool.name" class="tool-item">
|
||||||
|
<v-chip
|
||||||
|
size="small"
|
||||||
|
:color="tool.active === false ? 'warning' : 'primary'"
|
||||||
|
variant="outlined"
|
||||||
|
label
|
||||||
|
>
|
||||||
|
{{ tool.name }}
|
||||||
|
</v-chip>
|
||||||
|
<v-tooltip v-if="tool.active === false" location="top">
|
||||||
|
<template v-slot:activator="{ props: tooltipProps }">
|
||||||
|
<small class="text-warning tool-inactive" v-bind="tooltipProps">
|
||||||
|
{{ tm('personaQuickPreview.toolInactive') }}
|
||||||
|
</small>
|
||||||
|
</template>
|
||||||
|
{{ tm('personaQuickPreview.toolInactiveTooltip') }}
|
||||||
|
</v-tooltip>
|
||||||
|
<small v-if="tool.origin || tool.origin_name" class="text-grey tool-meta">
|
||||||
|
<span v-if="tool.origin">{{ tm('personaQuickPreview.originLabel') }}: {{ tool.origin }}</span>
|
||||||
|
<span v-if="tool.origin_name"> | {{ tm('personaQuickPreview.originNameLabel') }}: {{ tool.origin_name }}</span>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<small v-if="personaData.tools !== null && normalizedTools.length === 0" class="text-grey">
|
||||||
|
{{ tm('personaQuickPreview.noTools') }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-title mt-3">{{ tm('personaQuickPreview.skillsLabel') }}</div>
|
||||||
|
<div class="chip-wrap">
|
||||||
|
<v-chip
|
||||||
|
v-if="personaData.skills === null"
|
||||||
|
size="small"
|
||||||
|
color="success"
|
||||||
|
variant="tonal"
|
||||||
|
label
|
||||||
|
>
|
||||||
|
{{ tm('personaQuickPreview.allSkillsWithCount', { count: allSkillsCount }) }}
|
||||||
|
</v-chip>
|
||||||
|
<v-chip
|
||||||
|
v-for="skillName in normalizedSkills"
|
||||||
|
v-else
|
||||||
|
:key="skillName"
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
label
|
||||||
|
>
|
||||||
|
{{ skillName }}
|
||||||
|
</v-chip>
|
||||||
|
<small v-if="personaData.skills !== null && normalizedSkills.length === 0" class="text-grey">
|
||||||
|
{{ tm('personaQuickPreview.noSkills') }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { useModuleI18n } from '@/i18n/composables'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { tm } = useModuleI18n('core.shared')
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const personaData = ref(null)
|
||||||
|
const toolMetaMap = ref({})
|
||||||
|
const availableSkills = ref([])
|
||||||
|
|
||||||
|
const defaultPersonaData = {
|
||||||
|
persona_id: 'default',
|
||||||
|
system_prompt: 'You are a helpful and friendly assistant.',
|
||||||
|
tools: null,
|
||||||
|
skills: null
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedTools = computed(() => (Array.isArray(personaData.value?.tools) ? personaData.value.tools : []))
|
||||||
|
const normalizedSkills = computed(() => (Array.isArray(personaData.value?.skills) ? personaData.value.skills : []))
|
||||||
|
const allToolsCount = computed(() => Object.keys(toolMetaMap.value).length)
|
||||||
|
const allSkillsCount = computed(() => availableSkills.value.length)
|
||||||
|
const resolvedTools = computed(() =>
|
||||||
|
normalizedTools.value.map((toolName) => {
|
||||||
|
const meta = toolMetaMap.value[toolName] || {}
|
||||||
|
return {
|
||||||
|
name: toolName,
|
||||||
|
origin: meta.origin || '',
|
||||||
|
origin_name: meta.origin_name || '',
|
||||||
|
active: meta.active
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
async function loadToolsMeta() {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/tools/list')
|
||||||
|
if (response.data?.status === 'ok') {
|
||||||
|
const tools = response.data?.data || []
|
||||||
|
const nextMap = {}
|
||||||
|
for (const tool of tools) {
|
||||||
|
if (!tool?.name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nextMap[tool.name] = {
|
||||||
|
origin: tool.origin || '',
|
||||||
|
origin_name: tool.origin_name || '',
|
||||||
|
active: tool.active
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toolMetaMap.value = nextMap
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load tools metadata:', error)
|
||||||
|
toolMetaMap.value = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSkillsMeta() {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/skills')
|
||||||
|
if (response.data?.status === 'ok') {
|
||||||
|
const payload = response.data?.data || []
|
||||||
|
if (Array.isArray(payload)) {
|
||||||
|
availableSkills.value = payload.filter((skill) => skill.active !== false)
|
||||||
|
} else {
|
||||||
|
const skills = payload.skills || []
|
||||||
|
availableSkills.value = skills.filter((skill) => skill.active !== false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
availableSkills.value = []
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load skills metadata:', error)
|
||||||
|
availableSkills.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPersonaPreview(personaId) {
|
||||||
|
if (!personaId) {
|
||||||
|
personaData.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (personaId === 'default') {
|
||||||
|
personaData.value = defaultPersonaData
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/persona/list')
|
||||||
|
if (response.data?.status === 'ok') {
|
||||||
|
const personas = response.data?.data || []
|
||||||
|
personaData.value = personas.find((item) => item.persona_id === personaId) || null
|
||||||
|
} else {
|
||||||
|
personaData.value = null
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load persona preview:', error)
|
||||||
|
personaData.value = null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePersonaSaved() {
|
||||||
|
if (props.modelValue) {
|
||||||
|
loadPersonaPreview(props.modelValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(newValue) => {
|
||||||
|
loadPersonaPreview(newValue)
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
loadToolsMeta()
|
||||||
|
loadSkillsMeta()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('astrbot:persona-saved', handlePersonaSaved)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('astrbot:persona-saved', handlePersonaSaved)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.persona-preview-card {
|
||||||
|
background-color: rgba(var(--v-theme-primary), 0.05);
|
||||||
|
border: 1px solid rgba(var(--v-theme-primary), 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-loading,
|
||||||
|
.preview-empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgb(var(--v-theme-primaryText));
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-content {
|
||||||
|
margin-top: 6px;
|
||||||
|
max-height: 180px;
|
||||||
|
overflow: auto;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
background: rgba(0, 0, 0, 0.03);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-wrap {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-wrap {
|
||||||
|
max-height: 160px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-meta {
|
||||||
|
font-size: 0.74rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-inactive {
|
||||||
|
font-size: 0.74rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.tools-wrap {
|
||||||
|
max-height: 120px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -188,10 +188,16 @@ function openEditPersona(persona: Persona) {
|
|||||||
// 人格保存成功(创建或编辑)
|
// 人格保存成功(创建或编辑)
|
||||||
async function handlePersonaSaved(message: string) {
|
async function handlePersonaSaved(message: string) {
|
||||||
console.log('人格保存成功:', message)
|
console.log('人格保存成功:', message)
|
||||||
|
const savedPersonaId = editingPersona.value?.persona_id || ''
|
||||||
showPersonaDialog.value = false
|
showPersonaDialog.value = false
|
||||||
editingPersona.value = null
|
editingPersona.value = null
|
||||||
// 刷新当前文件夹的人格列表
|
// 刷新当前文件夹的人格列表
|
||||||
await loadPersonasInFolder(currentFolderId.value)
|
await loadPersonasInFolder(currentFolderId.value)
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('astrbot:persona-saved', {
|
||||||
|
detail: { persona_id: savedPersonaId }
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 错误处理
|
// 错误处理
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import { getPlatformDisplayName, getPlatformIcon } from "@/utils/platformUtils";
|
||||||
|
import { useModuleI18n } from "@/i18n/composables";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
platforms: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: "small",
|
||||||
|
},
|
||||||
|
chipStyle: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { tm } = useModuleI18n("features/extension");
|
||||||
|
|
||||||
|
const showMenu = ref(false);
|
||||||
|
|
||||||
|
const platformDetails = computed(() => {
|
||||||
|
if (!Array.isArray(props.platforms)) return [];
|
||||||
|
return props.platforms
|
||||||
|
.filter((item) => typeof item === "string")
|
||||||
|
.map((platformId) => ({
|
||||||
|
name: getPlatformDisplayName(platformId as string),
|
||||||
|
icon: getPlatformIcon(platformId as string),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="d-inline-block">
|
||||||
|
<v-chip
|
||||||
|
v-if="platformDetails.length"
|
||||||
|
color="info"
|
||||||
|
variant="outlined"
|
||||||
|
label
|
||||||
|
:size="size"
|
||||||
|
class="plugin-platform-chip"
|
||||||
|
:style="{ cursor: 'pointer', ...chipStyle }"
|
||||||
|
@click.stop="showMenu = !showMenu"
|
||||||
|
>
|
||||||
|
<div class="d-flex align-center" style="gap: 2px">
|
||||||
|
<!-- 显示图标,最多 5 个 -->
|
||||||
|
<div class="d-flex align-center mr-1" v-if="platformDetails.some(p => p.icon)">
|
||||||
|
<v-avatar
|
||||||
|
v-for="(platform, index) in platformDetails.slice(0, 5)"
|
||||||
|
:key="index"
|
||||||
|
:size="size === 'x-small' ? 12 : 14"
|
||||||
|
class="platform-mini-icon"
|
||||||
|
:style="{ marginLeft: index > 0 ? '-4px' : '0', zIndex: 10 - index }"
|
||||||
|
>
|
||||||
|
<v-img v-if="platform.icon" :src="platform.icon"></v-img>
|
||||||
|
<v-icon v-else icon="mdi-circle-small" :size="size === 'x-small' ? 8 : 10"></v-icon>
|
||||||
|
</v-avatar>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="text-caption font-weight-bold">
|
||||||
|
{{
|
||||||
|
tm("card.status.supportPlatformsCount", {
|
||||||
|
count: platformDetails.length,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<v-icon
|
||||||
|
:icon="showMenu ? 'mdi-chevron-up' : 'mdi-chevron-down'"
|
||||||
|
:size="size === 'x-small' ? 14 : 16"
|
||||||
|
class="ml-n1"
|
||||||
|
></v-icon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-menu
|
||||||
|
v-model="showMenu"
|
||||||
|
activator="parent"
|
||||||
|
location="top"
|
||||||
|
:close-on-content-click="false"
|
||||||
|
transition="scale-transition"
|
||||||
|
open-on-hover
|
||||||
|
>
|
||||||
|
<v-list density="compact" border elevation="12" class="rounded-lg pa-1">
|
||||||
|
<v-list-item
|
||||||
|
v-for="platform in platformDetails"
|
||||||
|
:key="platform.name"
|
||||||
|
min-height="24"
|
||||||
|
class="px-2"
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<v-avatar size="14" class="mr-2" v-if="platform.icon">
|
||||||
|
<v-img :src="platform.icon"></v-img>
|
||||||
|
</v-avatar>
|
||||||
|
<v-icon v-else icon="mdi-platform" size="12" class="mr-2"></v-icon>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title class="text-caption font-weight-bold" style="font-size: 0.75rem !important">
|
||||||
|
{{ platform.name }}
|
||||||
|
</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.plugin-platform-chip {
|
||||||
|
padding-left: 6px !important;
|
||||||
|
padding-right: 4px !important;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-mini-icon {
|
||||||
|
border: 1px solid rgba(var(--v-theme-info), 0.3);
|
||||||
|
background: rgba(var(--v-theme-surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-platform-chip:hover {
|
||||||
|
background: rgba(var(--v-theme-info), 0.08);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -82,6 +82,10 @@ export function useMessages(
|
|||||||
const activeSSECount = ref(0);
|
const activeSSECount = ref(0);
|
||||||
const enableStreaming = ref(true);
|
const enableStreaming = ref(true);
|
||||||
const attachmentCache = new Map<string, string>(); // attachment_id -> blob URL
|
const attachmentCache = new Map<string, string>(); // attachment_id -> blob URL
|
||||||
|
const currentRequestController = ref<AbortController | null>(null);
|
||||||
|
const currentReader = ref<ReadableStreamDefaultReader<Uint8Array> | null>(null);
|
||||||
|
const currentRunningSessionId = ref('');
|
||||||
|
const userStopRequested = ref(false);
|
||||||
|
|
||||||
// 当前会话的项目信息
|
// 当前会话的项目信息
|
||||||
const currentSessionProject = ref<{ project_id: string; title: string; emoji: string } | null>(null);
|
const currentSessionProject = ref<{ project_id: string; title: string; emoji: string } | null>(null);
|
||||||
@@ -289,6 +293,8 @@ export function useMessages(
|
|||||||
if (activeSSECount.value === 1) {
|
if (activeSSECount.value === 1) {
|
||||||
isConvRunning.value = true;
|
isConvRunning.value = true;
|
||||||
}
|
}
|
||||||
|
userStopRequested.value = false;
|
||||||
|
currentRunningSessionId.value = currSessionId.value;
|
||||||
|
|
||||||
// 收集所有 attachment_id
|
// 收集所有 attachment_id
|
||||||
const files = stagedFiles.map(f => f.attachment_id);
|
const files = stagedFiles.map(f => f.attachment_id);
|
||||||
@@ -330,12 +336,15 @@ export function useMessages(
|
|||||||
messageToSend = prompt;
|
messageToSend = prompt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
currentRequestController.value = controller;
|
||||||
const response = await fetch('/api/chat/send', {
|
const response = await fetch('/api/chat/send', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': 'Bearer ' + localStorage.getItem('token')
|
'Authorization': 'Bearer ' + localStorage.getItem('token')
|
||||||
},
|
},
|
||||||
|
signal: controller.signal,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
message: messageToSend,
|
message: messageToSend,
|
||||||
session_id: currSessionId.value,
|
session_id: currSessionId.value,
|
||||||
@@ -350,6 +359,7 @@ export function useMessages(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const reader = response.body!.getReader();
|
const reader = response.body!.getReader();
|
||||||
|
currentReader.value = reader;
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let in_streaming = false;
|
let in_streaming = false;
|
||||||
let message_obj: MessageContent | null = null;
|
let message_obj: MessageContent | null = null;
|
||||||
@@ -388,6 +398,10 @@ export function useMessages(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (chunk_json.type === 'session_id') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const lastMsg = messages.value[messages.value.length - 1];
|
const lastMsg = messages.value[messages.value.length - 1];
|
||||||
if (lastMsg?.content?.isLoading) {
|
if (lastMsg?.content?.isLoading) {
|
||||||
messages.value.pop();
|
messages.value.pop();
|
||||||
@@ -556,7 +570,9 @@ export function useMessages(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (readError) {
|
} catch (readError) {
|
||||||
console.error('SSE读取错误:', readError);
|
if (!userStopRequested.value) {
|
||||||
|
console.error('SSE读取错误:', readError);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -565,7 +581,9 @@ export function useMessages(
|
|||||||
onSessionsUpdate();
|
onSessionsUpdate();
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('发送消息失败:', err);
|
if (!userStopRequested.value) {
|
||||||
|
console.error('发送消息失败:', err);
|
||||||
|
}
|
||||||
// 移除加载占位符
|
// 移除加载占位符
|
||||||
const lastMsg = messages.value[messages.value.length - 1];
|
const lastMsg = messages.value[messages.value.length - 1];
|
||||||
if (lastMsg?.content?.isLoading) {
|
if (lastMsg?.content?.isLoading) {
|
||||||
@@ -573,6 +591,10 @@ export function useMessages(
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isStreaming.value = false;
|
isStreaming.value = false;
|
||||||
|
currentReader.value = null;
|
||||||
|
currentRequestController.value = null;
|
||||||
|
currentRunningSessionId.value = '';
|
||||||
|
userStopRequested.value = false;
|
||||||
activeSSECount.value--;
|
activeSSECount.value--;
|
||||||
if (activeSSECount.value === 0) {
|
if (activeSSECount.value === 0) {
|
||||||
isConvRunning.value = false;
|
isConvRunning.value = false;
|
||||||
@@ -580,6 +602,33 @@ export function useMessages(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function stopMessage() {
|
||||||
|
const sessionId = currentRunningSessionId.value || currSessionId.value;
|
||||||
|
if (!sessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
userStopRequested.value = true;
|
||||||
|
try {
|
||||||
|
await axios.post('/api/chat/stop', {
|
||||||
|
session_id: sessionId
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('停止会话失败:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await currentReader.value?.cancel();
|
||||||
|
} catch (err) {
|
||||||
|
// ignore reader cancel failures
|
||||||
|
}
|
||||||
|
currentReader.value = null;
|
||||||
|
currentRequestController.value?.abort();
|
||||||
|
currentRequestController.value = null;
|
||||||
|
|
||||||
|
isStreaming.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages,
|
messages,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
@@ -588,6 +637,7 @@ export function useMessages(
|
|||||||
currentSessionProject,
|
currentSessionProject,
|
||||||
getSessionMessages,
|
getSessionMessages,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
|
stopMessage,
|
||||||
toggleStreaming,
|
toggleStreaming,
|
||||||
getAttachment
|
getAttachment
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ref, computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
import { buildWebchatUmoDetails, getStoredSelectedChatConfigId } from '@/utils/chatConfigBinding';
|
||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
session_id: string;
|
session_id: string;
|
||||||
@@ -62,10 +63,25 @@ export function useSessions(chatboxMode: boolean = false) {
|
|||||||
|
|
||||||
async function newSession() {
|
async function newSession() {
|
||||||
try {
|
try {
|
||||||
|
const selectedConfigId = getStoredSelectedChatConfigId();
|
||||||
const response = await axios.get('/api/chat/new_session');
|
const response = await axios.get('/api/chat/new_session');
|
||||||
const sessionId = response.data.data.session_id;
|
const sessionId = response.data.data.session_id;
|
||||||
|
const platformId = response.data.data.platform_id;
|
||||||
|
|
||||||
currSessionId.value = sessionId;
|
currSessionId.value = sessionId;
|
||||||
|
|
||||||
|
if (selectedConfigId && selectedConfigId !== 'default' && platformId === 'webchat') {
|
||||||
|
try {
|
||||||
|
const umoDetails = buildWebchatUmoDetails(sessionId, false);
|
||||||
|
await axios.post('/api/config/umo_abconf_route/update', {
|
||||||
|
umo: umoDetails.umo,
|
||||||
|
conf_id: selectedConfigId
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to bind config to session', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 更新 URL
|
// 更新 URL
|
||||||
const basePath = chatboxMode ? '/chatbox' : '/chat';
|
const basePath = chatboxMode ? '/chatbox' : '/chat';
|
||||||
router.push(`${basePath}/${sessionId}`);
|
router.push(`${basePath}/${sessionId}`);
|
||||||
|
|||||||
@@ -72,14 +72,17 @@
|
|||||||
"form": {
|
"form": {
|
||||||
"currentPassword": "Current Password",
|
"currentPassword": "Current Password",
|
||||||
"newPassword": "New Password",
|
"newPassword": "New Password",
|
||||||
|
"confirmPassword": "Confirm New Password",
|
||||||
"newUsername": "New Username (Optional)",
|
"newUsername": "New Username (Optional)",
|
||||||
"passwordHint": "Password must be at least 8 characters",
|
"passwordHint": "Password must be at least 8 characters",
|
||||||
|
"confirmPasswordHint": "Please enter new password again to confirm",
|
||||||
"usernameHint": "Leave blank to keep current username",
|
"usernameHint": "Leave blank to keep current username",
|
||||||
"defaultCredentials": "Default username and password are both astrbot"
|
"defaultCredentials": "Default username and password are both astrbot"
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"passwordRequired": "Please enter password",
|
"passwordRequired": "Please enter password",
|
||||||
"passwordMinLength": "Password must be at least 8 characters",
|
"passwordMinLength": "Password must be at least 8 characters",
|
||||||
|
"passwordMatch": "Passwords do not match",
|
||||||
"usernameMinLength": "Username must be at least 3 characters"
|
"usernameMinLength": "Username must be at least 3 characters"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"changelog": "Changelog",
|
"changelog": "Changelog",
|
||||||
"documentation": "Documentation",
|
"documentation": "Documentation",
|
||||||
|
"faq": "FAQ",
|
||||||
"github": "GitHub",
|
"github": "GitHub",
|
||||||
"drag": "Drag",
|
"drag": "Drag",
|
||||||
"groups": {
|
"groups": {
|
||||||
|
|||||||
@@ -62,6 +62,25 @@
|
|||||||
"rootFolder": "All Personas",
|
"rootFolder": "All Personas",
|
||||||
"emptyFolder": "This folder is empty"
|
"emptyFolder": "This folder is empty"
|
||||||
},
|
},
|
||||||
|
"personaQuickPreview": {
|
||||||
|
"title": "Quick Persona Preview",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"noPersonaSelected": "No persona selected",
|
||||||
|
"personaNotFound": "Persona details not found",
|
||||||
|
"systemPromptLabel": "System Prompt",
|
||||||
|
"toolsLabel": "Tools",
|
||||||
|
"skillsLabel": "Skills",
|
||||||
|
"originLabel": "Origin",
|
||||||
|
"originNameLabel": "Origin Name",
|
||||||
|
"toolInactive": "Disabled",
|
||||||
|
"toolInactiveTooltip": "This tool is disabled. Re-enable it in Extensions -> Handlers -> Function Tools.",
|
||||||
|
"allTools": "All tools available",
|
||||||
|
"allToolsWithCount": "All tools available ({count})",
|
||||||
|
"noTools": "No tools configured",
|
||||||
|
"allSkills": "All Skills available",
|
||||||
|
"allSkillsWithCount": "All Skills available ({count})",
|
||||||
|
"noSkills": "No Skills configured"
|
||||||
|
},
|
||||||
"t2iTemplateEditor": {
|
"t2iTemplateEditor": {
|
||||||
"buttonText": "Customize T2I Template",
|
"buttonText": "Customize T2I Template",
|
||||||
"dialogTitle": "Customize Text-to-Image HTML Template",
|
"dialogTitle": "Customize Text-to-Image HTML Template",
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
"voice": "Voice Input",
|
"voice": "Voice Input",
|
||||||
"recordingPrompt": "Recording, please speak...",
|
"recordingPrompt": "Recording, please speak...",
|
||||||
"chatPrompt": "Let's chat!",
|
"chatPrompt": "Let's chat!",
|
||||||
"dropToUpload": "Drop files to upload"
|
"dropToUpload": "Drop files to upload",
|
||||||
|
"stopGenerating": "Stop generating"
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"user": "User",
|
"user": "User",
|
||||||
|
|||||||
@@ -149,6 +149,10 @@
|
|||||||
"description": "Computer Use Runtime",
|
"description": "Computer Use Runtime",
|
||||||
"hint": "sandbox means running in a sandbox environment, local means running in a local environment, none means disabling Computer Use. If skills are uploaded, choosing none will cause them to not be usable by the Agent."
|
"hint": "sandbox means running in a sandbox environment, local means running in a local environment, none means disabling Computer Use. If skills are uploaded, choosing none will cause them to not be usable by the Agent."
|
||||||
},
|
},
|
||||||
|
"computer_use_require_admin": {
|
||||||
|
"description": "Require AstrBot Admin Permission",
|
||||||
|
"hint": "When enabled, AstrBot admin permission is required to use computer capabilities. Admins can be added in Platform Config. Use the /sid command to view admin IDs."
|
||||||
|
},
|
||||||
"sandbox": {
|
"sandbox": {
|
||||||
"booter": {
|
"booter": {
|
||||||
"description": "Sandbox Environment Driver"
|
"description": "Sandbox Environment Driver"
|
||||||
@@ -1174,9 +1178,17 @@
|
|||||||
},
|
},
|
||||||
"anth_thinking_config": {
|
"anth_thinking_config": {
|
||||||
"description": "Thinking Config",
|
"description": "Thinking Config",
|
||||||
|
"type": {
|
||||||
|
"description": "Thinking Type",
|
||||||
|
"hint": "Set 'adaptive' for Opus 4.6+ / Sonnet 4.6+ (recommended). Leave empty to use manual budget mode. See: https://platform.claude.com/docs/en/build-with-claude/adaptive-thinking"
|
||||||
|
},
|
||||||
"budget": {
|
"budget": {
|
||||||
"description": "Thinking Budget",
|
"description": "Thinking Budget",
|
||||||
"hint": "Anthropic thinking.budget_tokens param. Must >= 1024. See: https://platform.claude.com/docs/en/build-with-claude/extended-thinking"
|
"hint": "Anthropic thinking.budget_tokens param. Must >= 1024. Only used when type is empty. Deprecated on Opus 4.6 / Sonnet 4.6. See: https://platform.claude.com/docs/en/build-with-claude/extended-thinking"
|
||||||
|
},
|
||||||
|
"effort": {
|
||||||
|
"description": "Effort Level",
|
||||||
|
"hint": "Controls thinking depth when type is 'adaptive'. 'high' is the default. 'max' is Opus 4.6 only. See: https://platform.claude.com/docs/en/build-with-claude/effort"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"minimax-group-id": {
|
"minimax-group-id": {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"messages": {
|
"messages": {
|
||||||
"configApplied": "Configuration successfully applied. To save, you need to click the save button in the bottom right corner.",
|
"configApplied": "Configuration successfully applied. To save, you need to click the save button in the bottom right corner.",
|
||||||
"configApplyError": "Configuration not applied, JSON format error.",
|
"configApplyError": "Configuration not applied, JSON format error.",
|
||||||
|
"unsavedChangesNotice": "You have unsaved configuration changes. Click the save button in the bottom-right corner to apply them.",
|
||||||
"saveSuccess": "Configuration saved successfully",
|
"saveSuccess": "Configuration saved successfully",
|
||||||
"saveError": "Failed to save configuration",
|
"saveError": "Failed to save configuration",
|
||||||
"loadError": "Failed to load configuration",
|
"loadError": "Failed to load configuration",
|
||||||
@@ -68,6 +69,10 @@
|
|||||||
"normalConfig": "Basic",
|
"normalConfig": "Basic",
|
||||||
"systemConfig": "System"
|
"systemConfig": "System"
|
||||||
},
|
},
|
||||||
|
"search": {
|
||||||
|
"placeholder": "Search config items (key/description/hint)",
|
||||||
|
"noResult": "No matching config items found"
|
||||||
|
},
|
||||||
"configManagement": {
|
"configManagement": {
|
||||||
"title": "Configuration Management",
|
"title": "Configuration Management",
|
||||||
"description": "AstrBot supports separate configuration files for different bots. The `default` configuration is used by default.",
|
"description": "AstrBot supports separate configuration files for different bots. The `default` configuration is used by default.",
|
||||||
@@ -107,5 +112,18 @@
|
|||||||
"addToConfig": "Added to config",
|
"addToConfig": "Added to config",
|
||||||
"fileCount": "Files: {count}",
|
"fileCount": "Files: {count}",
|
||||||
"done": "Done"
|
"done": "Done"
|
||||||
|
},
|
||||||
|
"unsavedChangesWarning": {
|
||||||
|
"dialogTitle": "Unsaved changes",
|
||||||
|
"leavePage": "You have unsaved changes. Do you want to save before leaving?",
|
||||||
|
"switchConfig": "Switching config will discard unsaved changes. Do you want to save first?",
|
||||||
|
"options": {
|
||||||
|
"save": "Save",
|
||||||
|
"saveAndSwitch": "Save and switch",
|
||||||
|
"discardAndSwitch": "Discard changes and switch",
|
||||||
|
"closeCard": "Close the pop-up window",
|
||||||
|
"confirm": "confirm",
|
||||||
|
"cancel": "cancel"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,8 @@
|
|||||||
"selectFile": "Select File",
|
"selectFile": "Select File",
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
"updateAll": "Update All",
|
"updateAll": "Update All",
|
||||||
"deleteSource": "Delete Source"
|
"deleteSource": "Delete Source",
|
||||||
|
"reshuffle": "Shuffle Again"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
@@ -103,7 +104,9 @@
|
|||||||
"sourceUpdated": "Source updated successfully",
|
"sourceUpdated": "Source updated successfully",
|
||||||
"defaultOfficialSource": "Default Official Source",
|
"defaultOfficialSource": "Default Official Source",
|
||||||
"sourceExists": "This source already exists",
|
"sourceExists": "This source already exists",
|
||||||
"installPlugin": "Install Plugin"
|
"installPlugin": "Install Plugin",
|
||||||
|
"randomPlugins": "🎲 Random Plugins",
|
||||||
|
"sourceSafetyWarning": "Even with the default source, plugin stability and security cannot be fully guaranteed. Please verify carefully before use."
|
||||||
},
|
},
|
||||||
"sort": {
|
"sort": {
|
||||||
"default": "Default",
|
"default": "Default",
|
||||||
@@ -140,7 +143,8 @@
|
|||||||
"install": {
|
"install": {
|
||||||
"title": "Install Extension",
|
"title": "Install Extension",
|
||||||
"fromFile": "Install from File",
|
"fromFile": "Install from File",
|
||||||
"fromUrl": "Install from URL"
|
"fromUrl": "Install from URL",
|
||||||
|
"supportPlatformsCount": "Supports {count} Platforms"
|
||||||
},
|
},
|
||||||
"danger_warning": {
|
"danger_warning": {
|
||||||
"title": "Dangerous Plugin Warning",
|
"title": "Dangerous Plugin Warning",
|
||||||
@@ -148,6 +152,12 @@
|
|||||||
"confirm": "Continue",
|
"confirm": "Continue",
|
||||||
"cancel": "Cancel"
|
"cancel": "Cancel"
|
||||||
},
|
},
|
||||||
|
"versionCompatibility": {
|
||||||
|
"title": "Version Compatibility Warning",
|
||||||
|
"message": "This plugin declares an AstrBot version range that does not match your current version. You can ignore this warning and continue installation, but it may not work correctly.",
|
||||||
|
"confirm": "Ignore Warning and Install",
|
||||||
|
"cancel": "Cancel Installation"
|
||||||
|
},
|
||||||
"forceUpdate": {
|
"forceUpdate": {
|
||||||
"title": "No New Version Detected",
|
"title": "No New Version Detected",
|
||||||
"message": "No new version detected for this plugin. Do you want to force reinstall? This will pull the latest code from the remote repository.",
|
"message": "No new version detected for this plugin. Do you want to force reinstall? This will pull the latest code from the remote repository.",
|
||||||
@@ -227,7 +237,10 @@
|
|||||||
"status": {
|
"status": {
|
||||||
"hasUpdate": "New version available",
|
"hasUpdate": "New version available",
|
||||||
"disabled": "This extension is disabled",
|
"disabled": "This extension is disabled",
|
||||||
"handlersCount": " handlers"
|
"handlersCount": " handlers",
|
||||||
|
"supportPlatform": "Supported Platform",
|
||||||
|
"supportPlatformsCount": "Supports {count} Platforms",
|
||||||
|
"astrbotVersion": "AstrBot Version Requirement"
|
||||||
},
|
},
|
||||||
"alt": {
|
"alt": {
|
||||||
"logo": "logo",
|
"logo": "logo",
|
||||||
|
|||||||
@@ -128,5 +128,53 @@
|
|||||||
"renameFailed": "Rename failed",
|
"renameFailed": "Rename failed",
|
||||||
"ftpHint": "For large backup files, you can also upload directly to the data/backups directory via FTP/SFTP"
|
"ftpHint": "For large backup files, you can also upload directly to the data/backups directory via FTP/SFTP"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"apiKey": {
|
||||||
|
"title": "API Keys",
|
||||||
|
"manageTitle": "Developer Access Keys",
|
||||||
|
"subtitle": "Create API keys for external developers to call open HTTP APIs.",
|
||||||
|
"name": "Key Name",
|
||||||
|
"expiresInDays": "Expiration",
|
||||||
|
"expiryOptions": {
|
||||||
|
"day1": "1 day",
|
||||||
|
"day7": "7 days",
|
||||||
|
"day30": "30 days",
|
||||||
|
"day90": "90 days",
|
||||||
|
"permanent": "Permanent"
|
||||||
|
},
|
||||||
|
"permanentWarning": "Permanent API keys are high risk. Store them securely and use only when necessary.",
|
||||||
|
"scopes": "Scopes",
|
||||||
|
"create": "Create API Key",
|
||||||
|
"revoke": "Revoke",
|
||||||
|
"delete": "Delete",
|
||||||
|
"copy": "Copy",
|
||||||
|
"docsLink": "Open docs",
|
||||||
|
"plaintextHint": "Save this key now. The plaintext will not be shown again.",
|
||||||
|
"empty": "No API keys",
|
||||||
|
"status": {
|
||||||
|
"active": "Active",
|
||||||
|
"inactive": "Inactive"
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"name": "Name",
|
||||||
|
"prefix": "Prefix",
|
||||||
|
"scopes": "Scopes",
|
||||||
|
"status": "Status",
|
||||||
|
"lastUsed": "Last Used",
|
||||||
|
"createdAt": "Created At",
|
||||||
|
"actions": "Actions"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"loadFailed": "Failed to load API keys",
|
||||||
|
"scopeRequired": "Please select at least one scope",
|
||||||
|
"createSuccess": "API key created",
|
||||||
|
"createFailed": "Failed to create API key",
|
||||||
|
"revokeSuccess": "API key revoked",
|
||||||
|
"revokeFailed": "Failed to revoke API key",
|
||||||
|
"deleteSuccess": "API key deleted",
|
||||||
|
"deleteFailed": "Failed to delete API key",
|
||||||
|
"copySuccess": "API key copied",
|
||||||
|
"copyFailed": "Failed to copy API key"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,8 @@
|
|||||||
"enabled": "When on: the main LLM keeps its own tools and mounts transfer_to_* delegate tools. With deduplication, tools overlapping with SubAgents are removed from the main tool set."
|
"enabled": "When on: the main LLM keeps its own tools and mounts transfer_to_* delegate tools. With deduplication, tools overlapping with SubAgents are removed from the main tool set."
|
||||||
},
|
},
|
||||||
"section": {
|
"section": {
|
||||||
"title": "SubAgents"
|
"title": "SubAgents",
|
||||||
|
"globalSettings": "Global Settings"
|
||||||
},
|
},
|
||||||
"cards": {
|
"cards": {
|
||||||
"statusEnabled": "Enabled",
|
"statusEnabled": "Enabled",
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
"newYear": "Happy New Year!"
|
"newYear": "Happy New Year!"
|
||||||
},
|
},
|
||||||
"subtitle": "You can complete the basic onboarding first. Platform and chat provider setup can both be skipped.",
|
"subtitle": "You can complete the basic onboarding first. Platform and chat provider setup can both be skipped.",
|
||||||
|
"announcement": {
|
||||||
|
"title": "Announcement"
|
||||||
|
},
|
||||||
"onboard": {
|
"onboard": {
|
||||||
"title": "Quick Onboarding",
|
"title": "Quick Onboarding",
|
||||||
"subtitle": "Complete initialization directly on the welcome page.",
|
"subtitle": "Complete initialization directly on the welcome page.",
|
||||||
|
|||||||
@@ -72,14 +72,17 @@
|
|||||||
"form": {
|
"form": {
|
||||||
"currentPassword": "当前密码",
|
"currentPassword": "当前密码",
|
||||||
"newPassword": "新密码",
|
"newPassword": "新密码",
|
||||||
|
"confirmPassword": "确认新密码",
|
||||||
"newUsername": "新用户名 (可选)",
|
"newUsername": "新用户名 (可选)",
|
||||||
"passwordHint": "密码长度至少 8 位",
|
"passwordHint": "密码长度至少 8 位",
|
||||||
|
"confirmPasswordHint": "请再次输入新密码以确认",
|
||||||
"usernameHint": "留空表示不修改用户名",
|
"usernameHint": "留空表示不修改用户名",
|
||||||
"defaultCredentials": "默认用户名和密码均为 astrbot"
|
"defaultCredentials": "默认用户名和密码均为 astrbot"
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"passwordRequired": "请输入密码",
|
"passwordRequired": "请输入密码",
|
||||||
"passwordMinLength": "密码长度至少 8 位",
|
"passwordMinLength": "密码长度至少 8 位",
|
||||||
|
"passwordMatch": "两次输入的密码不一致",
|
||||||
"usernameMinLength": "用户名长度至少3位"
|
"usernameMinLength": "用户名长度至少3位"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
"changelog": "更新日志",
|
"changelog": "更新日志",
|
||||||
"documentation": "官方文档",
|
"documentation": "官方文档",
|
||||||
|
"faq": "FAQ",
|
||||||
"github": "GitHub",
|
"github": "GitHub",
|
||||||
"drag": "拖拽",
|
"drag": "拖拽",
|
||||||
"groups": {
|
"groups": {
|
||||||
|
|||||||
@@ -62,6 +62,25 @@
|
|||||||
"rootFolder": "全部人格",
|
"rootFolder": "全部人格",
|
||||||
"emptyFolder": "此文件夹为空"
|
"emptyFolder": "此文件夹为空"
|
||||||
},
|
},
|
||||||
|
"personaQuickPreview": {
|
||||||
|
"title": "快速预览",
|
||||||
|
"loading": "加载中...",
|
||||||
|
"noPersonaSelected": "未选择人格",
|
||||||
|
"personaNotFound": "未找到该人格的详情",
|
||||||
|
"systemPromptLabel": "系统提示词",
|
||||||
|
"toolsLabel": "工具",
|
||||||
|
"skillsLabel": "技能(Skills)",
|
||||||
|
"originLabel": "来源",
|
||||||
|
"originNameLabel": "来源名称",
|
||||||
|
"toolInactive": "已禁用",
|
||||||
|
"toolInactiveTooltip": "该工具已被禁用。在插件->管理行为->函数工具中重新启用。",
|
||||||
|
"allTools": "全部工具可用",
|
||||||
|
"allToolsWithCount": "全部工具可用({count})",
|
||||||
|
"noTools": "未配置工具",
|
||||||
|
"allSkills": "全部 Skills 可用",
|
||||||
|
"allSkillsWithCount": "全部 Skills 可用({count})",
|
||||||
|
"noSkills": "未配置 Skills"
|
||||||
|
},
|
||||||
"t2iTemplateEditor": {
|
"t2iTemplateEditor": {
|
||||||
"buttonText": "自定义 T2I 模板",
|
"buttonText": "自定义 T2I 模板",
|
||||||
"dialogTitle": "自定义文转图 HTML 模板",
|
"dialogTitle": "自定义文转图 HTML 模板",
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
"voice": "语音输入",
|
"voice": "语音输入",
|
||||||
"recordingPrompt": "录音中,请说话...",
|
"recordingPrompt": "录音中,请说话...",
|
||||||
"chatPrompt": "聊天吧!",
|
"chatPrompt": "聊天吧!",
|
||||||
"dropToUpload": "松开鼠标上传文件"
|
"dropToUpload": "松开鼠标上传文件",
|
||||||
|
"stopGenerating": "停止生成"
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"user": "用户",
|
"user": "用户",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user