Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| f22221f781 | |||
| 4250d997b3 | |||
| 153d8cef6b | |||
| c9cdf47603 | |||
| 55ac878648 | |||
| 60abddada3 | |||
| bbc583cc8d | |||
| 7906030037 | |||
| 06b385697d | |||
| 059008a903 | |||
| 97c9e95211 | |||
| a4be369e43 | |||
| bdaca78750 | |||
| 6326d7e4ba | |||
| a809a09e55 | |||
| 52c4ef2d87 | |||
| 52c31fabe2 | |||
| 79e239ad97 | |||
| 8abaf1015d | |||
| 9a0c814fd4 | |||
| c64e1b42a4 | |||
| 2d23c36067 | |||
| 754144ad99 | |||
| 0faf109c2a | |||
| 7d1eff3ec4 | |||
| e295c470a5 | |||
| 935168c024 | |||
| f44961d065 | |||
| 0c7a95ccd8 |
@@ -1,42 +1,40 @@
|
||||
|
||||
name: '🎉 功能建议'
|
||||
name: '🎉 Feature Request / 功能建议'
|
||||
title: "[Feature]"
|
||||
description: 提交建议帮助我们改进。
|
||||
description: Submit a suggestion to help us improve. / 提交建议帮助我们改进。
|
||||
labels: [ "enhancement" ]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢您抽出时间提出新功能建议,请准确解释您的想法。
|
||||
Thank you for taking the time to suggest a new feature! Please explain your idea clearly and accurately. / 感谢您抽出时间提出新功能建议,请准确解释您的想法。
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 描述
|
||||
description: 简短描述您的功能建议。
|
||||
label: Description / 描述
|
||||
description: Please describe the feature you want to be added in detail. / 请详细描述您希望添加的功能。
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 使用场景
|
||||
description: 你想要发生什么?
|
||||
placeholder: >
|
||||
一个清晰且具体的描述这个功能的使用场景。
|
||||
label: Use Case / 使用场景
|
||||
description: Please describe the use case for this feature. / 请描述这个功能的使用场景。
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: 你愿意提交PR吗?
|
||||
label: Willing to Submit PR? / 是否愿意提交PR?
|
||||
description: >
|
||||
这不是必须的,但我们欢迎您的贡献。
|
||||
This is not required, but if you are willing to submit a PR to implement this feature, it would be greatly appreciated! / 这不是必需的,但如果您愿意提交 PR 来实现这个功能,我们将不胜感激!
|
||||
options:
|
||||
- label: 是的, 我愿意提交PR!
|
||||
- label: Yes, I am willing to submit a PR. / 是的,我愿意提交 PR。
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Code of Conduct
|
||||
options:
|
||||
- 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
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "感谢您填写我们的表单!"
|
||||
value: "Thank you for filling out our form!"
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.10'
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install UV
|
||||
run: pip install uv
|
||||
|
||||
@@ -102,170 +102,11 @@ jobs:
|
||||
cp "dashboard/AstrBot-${VERSION_TAG}-dashboard.zip" "dashboard/astrbot-webui-${VERSION_TAG}.zip"
|
||||
rclone copy "dashboard/astrbot-webui-${VERSION_TAG}.zip" "r2:${R2_BUCKET_NAME}" --progress
|
||||
|
||||
build-desktop:
|
||||
name: Build ${{ matrix.name }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: linux-x64
|
||||
runner: ubuntu-24.04
|
||||
os: linux
|
||||
arch: amd64
|
||||
- name: linux-arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
os: linux
|
||||
arch: arm64
|
||||
- name: windows-x64
|
||||
runner: windows-2022
|
||||
os: win
|
||||
arch: amd64
|
||||
- name: windows-arm64
|
||||
runner: windows-11-arm
|
||||
os: win
|
||||
arch: arm64
|
||||
- name: macos-x64
|
||||
runner: macos-15-intel
|
||||
os: mac
|
||||
arch: amd64
|
||||
- name: macos-arm64
|
||||
runner: macos-15
|
||||
os: mac
|
||||
arch: arm64
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Resolve tag
|
||||
id: tag
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
elif [ -n "${{ inputs.tag }}" ]; then
|
||||
tag="${{ inputs.tag }}"
|
||||
else
|
||||
tag="$(git describe --tags --abbrev=0)"
|
||||
fi
|
||||
if [ -z "$tag" ]; then
|
||||
echo "Failed to resolve tag." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.28.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.13.0'
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: |
|
||||
dashboard/pnpm-lock.yaml
|
||||
desktop/pnpm-lock.yaml
|
||||
|
||||
- name: Prepare OpenSSL for Windows ARM64
|
||||
if: ${{ matrix.os == 'win' && matrix.arch == 'arm64' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
git clone https://github.com/microsoft/vcpkg.git C:\vcpkg
|
||||
& C:\vcpkg\bootstrap-vcpkg.bat -disableMetrics
|
||||
& C:\vcpkg\vcpkg.exe install openssl:arm64-windows
|
||||
|
||||
"VCPKG_ROOT=C:\vcpkg" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"VCPKGRS_TRIPLET=arm64-windows" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"OPENSSL_DIR=C:\vcpkg\installed\arm64-windows" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"OPENSSL_ROOT_DIR=C:\vcpkg\installed\arm64-windows" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"OPENSSL_LIB_DIR=C:\vcpkg\installed\arm64-windows\lib" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"OPENSSL_INCLUDE_DIR=C:\vcpkg\installed\arm64-windows\include" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
uv sync
|
||||
pnpm --dir dashboard install --frozen-lockfile
|
||||
pnpm --dir desktop install --frozen-lockfile
|
||||
|
||||
- name: Build desktop package
|
||||
shell: bash
|
||||
run: |
|
||||
pnpm --dir dashboard run build
|
||||
pnpm --dir desktop run build:webui
|
||||
pnpm --dir desktop run build:backend
|
||||
pnpm --dir desktop run sync:version
|
||||
pnpm --dir desktop exec electron-builder --publish never
|
||||
|
||||
- name: Normalize artifact names
|
||||
shell: bash
|
||||
env:
|
||||
NAME_PREFIX: AstrBot-${{ steps.tag.outputs.tag }}-${{ matrix.arch }}-${{ matrix.os }}
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
out_dir="desktop/dist/release"
|
||||
mkdir -p "$out_dir"
|
||||
files=(
|
||||
desktop/dist/*.AppImage
|
||||
desktop/dist/*.dmg
|
||||
desktop/dist/*.zip
|
||||
desktop/dist/*.exe
|
||||
)
|
||||
if [ ${#files[@]} -eq 0 ]; then
|
||||
echo "No desktop artifacts found to rename." >&2
|
||||
exit 1
|
||||
fi
|
||||
for src in "${files[@]}"; do
|
||||
file="$(basename "$src")"
|
||||
case "$file" in
|
||||
*.AppImage)
|
||||
dest="$out_dir/${NAME_PREFIX}.AppImage"
|
||||
;;
|
||||
*.dmg)
|
||||
dest="$out_dir/${NAME_PREFIX}.dmg"
|
||||
;;
|
||||
*.exe)
|
||||
dest="$out_dir/${NAME_PREFIX}.exe"
|
||||
;;
|
||||
*.zip)
|
||||
dest="$out_dir/${NAME_PREFIX}.zip"
|
||||
;;
|
||||
*)
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
cp "$src" "$dest"
|
||||
done
|
||||
ls -la "$out_dir"
|
||||
|
||||
- name: Upload desktop artifacts
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: AstrBot-${{ steps.tag.outputs.tag }}-${{ matrix.arch }}-${{ matrix.os }}
|
||||
if-no-files-found: error
|
||||
path: desktop/dist/release/*
|
||||
|
||||
publish-release:
|
||||
name: Publish GitHub Release
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- build-dashboard
|
||||
- build-desktop
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
@@ -296,12 +137,6 @@ jobs:
|
||||
name: Dashboard-${{ steps.tag.outputs.tag }}
|
||||
path: release-assets
|
||||
|
||||
- name: Download desktop artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: AstrBot-${{ steps.tag.outputs.tag }}-*
|
||||
path: release-assets
|
||||
merge-multiple: true
|
||||
|
||||
- name: Resolve release notes
|
||||
id: notes
|
||||
|
||||
@@ -33,13 +33,6 @@ tests/astrbot_plugin_openai
|
||||
dashboard/node_modules/
|
||||
dashboard/dist/
|
||||
.pnpm-store/
|
||||
desktop/node_modules/
|
||||
desktop/dist/
|
||||
desktop/out/
|
||||
desktop/resources/backend/astrbot-backend*
|
||||
desktop/resources/backend/*.exe
|
||||
desktop/resources/webui/*
|
||||
desktop/resources/.pyinstaller/
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
RUN python -m pip install uv \
|
||||
&& echo "3.12" > .python-version \
|
||||
&& uv lock \
|
||||
&& uv export --format requirements.txt --output-file requirements.txt --frozen \
|
||||
&& uv pip install -r requirements.txt --no-cache-dir --system \
|
||||
&& uv pip install socksio uv pilk --no-cache-dir --system
|
||||
|
||||
@@ -81,6 +81,10 @@ uv tool install astrbot
|
||||
astrbot
|
||||
```
|
||||
|
||||
#### 启动器一键部署(AstrBot Launcher)
|
||||
|
||||
进入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 仓库,在 Releases 页最新版本下找到对应的系统安装包安装即可。
|
||||
|
||||
#### 宝塔面板部署
|
||||
|
||||
AstrBot 与宝塔面板合作,已上架至宝塔面板。
|
||||
@@ -142,15 +146,16 @@ yay -S astrbot-git
|
||||
paru -S astrbot-git
|
||||
```
|
||||
|
||||
#### 桌面端 Electron 打包
|
||||
#### 桌面端(Tauri)
|
||||
|
||||
桌面端(Electron 打包,`pnpm` 工作流)构建流程请参阅:[`desktop/README.md`](desktop/README.md)。
|
||||
桌面端已迁移为独立仓库(Tauri):[https://github.com/AstrBotDevs/AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
|
||||
|
||||
## 支持的消息平台
|
||||
|
||||
**官方维护**
|
||||
|
||||
- QQ (官方平台 & OneBot)
|
||||
- QQ
|
||||
- OneBot v11 协议实现
|
||||
- Telegram
|
||||
- 企微应用 & 企微智能机器人
|
||||
- 微信客服 & 微信公众号
|
||||
@@ -158,10 +163,10 @@ paru -S astrbot-git
|
||||
- 钉钉
|
||||
- Slack
|
||||
- Discord
|
||||
- LINE
|
||||
- Satori
|
||||
- Misskey
|
||||
- Whatsapp (将支持)
|
||||
- LINE (将支持)
|
||||
|
||||
**社区维护**
|
||||
|
||||
@@ -181,6 +186,7 @@ paru -S astrbot-git
|
||||
- DeepSeek
|
||||
- Ollama (本地部署)
|
||||
- LM Studio (本地部署)
|
||||
- [AIHubMix](https://aihubmix.com/?aff=4bfH)
|
||||
- [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74)
|
||||
- [302.AI](https://share.302.ai/rr1M3l)
|
||||
- [小马算力](https://www.tokenpony.cn/3YPyf)
|
||||
@@ -263,6 +269,16 @@ pre-commit install
|
||||
|
||||
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - 伟大的猫猫框架
|
||||
|
||||
开源项目友情链接:
|
||||
|
||||
- [NoneBot2](https://github.com/nonebot/nonebot2) - 优秀的 Python 异步 ChatBot 框架
|
||||
- [Koishi](https://github.com/koishijs/koishi) - 优秀的 Node.js ChatBot 框架
|
||||
- [MaiBot](https://github.com/Mai-with-u/MaiBot) - 优秀的拟人化 AI ChatBot
|
||||
- [nekro-agent](https://github.com/KroMiose/nekro-agent) - 优秀的 Agent ChatBot
|
||||
- [LangBot](https://github.com/langbot-app/LangBot) - 优秀的多平台 AI ChatBot
|
||||
- [ChatLuna](https://github.com/ChatLunaLab/chatluna) - 优秀的多平台 AI ChatBot Koishi 插件
|
||||
- [Operit AI](https://github.com/AAswordman/Operit) - 优秀的 AI 智能助手 Android APP
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
> [!TIP]
|
||||
|
||||
+3
-3
@@ -154,9 +154,9 @@ yay -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
|
||||
|
||||
@@ -172,8 +172,8 @@ For desktop build steps (Electron packaging, `pnpm` workflow), see [`desktop/REA
|
||||
- Discord
|
||||
- Satori
|
||||
- Misskey
|
||||
- LINE
|
||||
- WhatsApp (Coming Soon)
|
||||
- LINE (Coming Soon)
|
||||
|
||||
**Community Maintained**
|
||||
|
||||
|
||||
+1
-1
@@ -168,8 +168,8 @@ paru -S astrbot-git
|
||||
- Discord
|
||||
- Satori
|
||||
- Misskey
|
||||
- LINE
|
||||
- WhatsApp (Bientôt disponible)
|
||||
- LINE (Bientôt disponible)
|
||||
|
||||
**Maintenues par la communauté**
|
||||
|
||||
|
||||
+1
-1
@@ -168,8 +168,8 @@ paru -S astrbot-git
|
||||
- Discord
|
||||
- Satori
|
||||
- Misskey
|
||||
- LINE
|
||||
- WhatsApp (近日対応予定)
|
||||
- LINE (近日対応予定)
|
||||
|
||||
**コミュニティメンテナンス**
|
||||
|
||||
|
||||
+2
-1
@@ -158,8 +158,9 @@ paru -S astrbot-git
|
||||
- Discord
|
||||
- Satori
|
||||
- Misskey
|
||||
- LINE
|
||||
- WhatsApp (Скоро)
|
||||
- LINE (Скоро)
|
||||
|
||||
|
||||
**Поддерживаемые сообществом**
|
||||
|
||||
|
||||
+2
-1
@@ -158,8 +158,9 @@ paru -S astrbot-git
|
||||
- Discord
|
||||
- Satori
|
||||
- Misskey
|
||||
- LINE
|
||||
- Whatsapp(即將支援)
|
||||
- LINE(即將支援)
|
||||
|
||||
|
||||
**社群維護**
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ from astrbot.core.star.register import (
|
||||
register_on_llm_tool_respond as on_llm_tool_respond,
|
||||
)
|
||||
from astrbot.core.star.register import register_on_platform_loaded as on_platform_loaded
|
||||
from astrbot.core.star.register import register_on_plugin_error as on_plugin_error
|
||||
from astrbot.core.star.register import register_on_using_llm_tool as on_using_llm_tool
|
||||
from astrbot.core.star.register import (
|
||||
register_on_waiting_llm_request as on_waiting_llm_request,
|
||||
@@ -52,6 +53,7 @@ __all__ = [
|
||||
"on_decorating_result",
|
||||
"on_llm_request",
|
||||
"on_llm_response",
|
||||
"on_plugin_error",
|
||||
"on_platform_loaded",
|
||||
"on_waiting_llm_request",
|
||||
"permission_type",
|
||||
|
||||
@@ -4,6 +4,7 @@ from astrbot.api import sp, star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
from astrbot.core.platform.astr_message_event import MessageSession
|
||||
from astrbot.core.platform.message_type import MessageType
|
||||
from astrbot.core.utils.active_event_registry import active_event_registry
|
||||
|
||||
from .utils.rst_scene import RstScene
|
||||
|
||||
@@ -62,6 +63,7 @@ class ConversationCommands:
|
||||
|
||||
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
||||
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
|
||||
active_event_registry.stop_all(umo, exclude=message)
|
||||
await sp.remove_async(
|
||||
scope="umo",
|
||||
scope_id=umo,
|
||||
@@ -86,6 +88,8 @@ class ConversationCommands:
|
||||
)
|
||||
return
|
||||
|
||||
active_event_registry.stop_all(umo, exclude=message)
|
||||
|
||||
await self.context.conversation_manager.update_conversation(
|
||||
umo,
|
||||
cid,
|
||||
@@ -221,6 +225,7 @@ class ConversationCommands:
|
||||
cfg = self.context.get_config(umo=message.unified_msg_origin)
|
||||
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
||||
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
|
||||
active_event_registry.stop_all(message.unified_msg_origin, exclude=message)
|
||||
await sp.remove_async(
|
||||
scope="umo",
|
||||
scope_id=message.unified_msg_origin,
|
||||
@@ -229,6 +234,7 @@ class ConversationCommands:
|
||||
message.set_result(MessageEventResult().message("已创建新对话。"))
|
||||
return
|
||||
|
||||
active_event_registry.stop_all(message.unified_msg_origin, exclude=message)
|
||||
cpersona = await self._get_current_persona_id(message.unified_msg_origin)
|
||||
cid = await self.context.conversation_manager.new_conversation(
|
||||
message.unified_msg_origin,
|
||||
@@ -321,7 +327,8 @@ class ConversationCommands:
|
||||
|
||||
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"]
|
||||
if message.get_group_id() and not is_unique_session and message.role != "admin":
|
||||
# 群聊,没开独立会话,发送人不是管理员
|
||||
@@ -334,18 +341,17 @@ class ConversationCommands:
|
||||
|
||||
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
||||
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
|
||||
active_event_registry.stop_all(umo, exclude=message)
|
||||
await sp.remove_async(
|
||||
scope="umo",
|
||||
scope_id=message.unified_msg_origin,
|
||||
scope_id=umo,
|
||||
key=THIRD_PARTY_AGENT_RUNNER_KEY[agent_runner_type],
|
||||
)
|
||||
message.set_result(MessageEventResult().message("重置对话成功。"))
|
||||
return
|
||||
|
||||
session_curr_cid = (
|
||||
await self.context.conversation_manager.get_curr_conversation_id(
|
||||
message.unified_msg_origin,
|
||||
)
|
||||
await self.context.conversation_manager.get_curr_conversation_id(umo)
|
||||
)
|
||||
|
||||
if not session_curr_cid:
|
||||
@@ -356,8 +362,10 @@ class ConversationCommands:
|
||||
)
|
||||
return
|
||||
|
||||
active_event_registry.stop_all(umo, exclude=message)
|
||||
|
||||
await self.context.conversation_manager.delete_conversation(
|
||||
message.unified_msg_origin,
|
||||
umo,
|
||||
session_curr_cid,
|
||||
)
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "4.15.0"
|
||||
__version__ = "4.17.6"
|
||||
|
||||
@@ -91,6 +91,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
custom_token_counter: TokenCounter | None = None,
|
||||
custom_compressor: ContextCompressor | None = None,
|
||||
tool_schema_mode: str | None = "full",
|
||||
fallback_providers: list[Provider] | None = None,
|
||||
**kwargs: T.Any,
|
||||
) -> None:
|
||||
self.req = request
|
||||
@@ -120,6 +121,17 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
self.context_manager = ContextManager(self.context_config)
|
||||
|
||||
self.provider = provider
|
||||
self.fallback_providers: list[Provider] = []
|
||||
seen_provider_ids: set[str] = {str(provider.provider_config.get("id", ""))}
|
||||
for fallback_provider in fallback_providers or []:
|
||||
fallback_id = str(fallback_provider.provider_config.get("id", ""))
|
||||
if fallback_provider is provider:
|
||||
continue
|
||||
if fallback_id and fallback_id in seen_provider_ids:
|
||||
continue
|
||||
self.fallback_providers.append(fallback_provider)
|
||||
if fallback_id:
|
||||
seen_provider_ids.add(fallback_id)
|
||||
self.final_llm_resp = None
|
||||
self._state = AgentState.IDLE
|
||||
self.tool_executor = tool_executor
|
||||
@@ -166,16 +178,19 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
self.stats = AgentStats()
|
||||
self.stats.start_time = time.time()
|
||||
|
||||
async def _iter_llm_responses(self) -> T.AsyncGenerator[LLMResponse, None]:
|
||||
async def _iter_llm_responses(
|
||||
self, *, include_model: bool = True
|
||||
) -> T.AsyncGenerator[LLMResponse, None]:
|
||||
"""Yields chunks *and* a final LLMResponse."""
|
||||
payload = {
|
||||
"contexts": self.run_context.messages, # list[Message]
|
||||
"func_tool": self.req.func_tool,
|
||||
"model": self.req.model, # NOTE: in fact, this arg is None in most cases
|
||||
"session_id": self.req.session_id,
|
||||
"extra_user_content_parts": self.req.extra_user_content_parts, # list[ContentPart]
|
||||
}
|
||||
|
||||
if include_model:
|
||||
# For primary provider we keep explicit model selection if provided.
|
||||
payload["model"] = self.req.model
|
||||
if self.streaming:
|
||||
stream = self.provider.text_chat_stream(**payload)
|
||||
async for resp in stream: # type: ignore
|
||||
@@ -183,6 +198,77 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
else:
|
||||
yield await self.provider.text_chat(**payload)
|
||||
|
||||
async def _iter_llm_responses_with_fallback(
|
||||
self,
|
||||
) -> T.AsyncGenerator[LLMResponse, None]:
|
||||
"""Wrap _iter_llm_responses with provider fallback handling."""
|
||||
candidates = [self.provider, *self.fallback_providers]
|
||||
total_candidates = len(candidates)
|
||||
last_exception: Exception | None = None
|
||||
last_err_response: LLMResponse | None = None
|
||||
|
||||
for idx, candidate in enumerate(candidates):
|
||||
candidate_id = candidate.provider_config.get("id", "<unknown>")
|
||||
is_last_candidate = idx == total_candidates - 1
|
||||
if idx > 0:
|
||||
logger.warning(
|
||||
"Switched from %s to fallback chat provider: %s",
|
||||
self.provider.provider_config.get("id", "<unknown>"),
|
||||
candidate_id,
|
||||
)
|
||||
self.provider = candidate
|
||||
has_stream_output = False
|
||||
try:
|
||||
async for resp in self._iter_llm_responses(include_model=idx == 0):
|
||||
if resp.is_chunk:
|
||||
has_stream_output = True
|
||||
yield resp
|
||||
continue
|
||||
|
||||
if (
|
||||
resp.role == "err"
|
||||
and not has_stream_output
|
||||
and (not is_last_candidate)
|
||||
):
|
||||
last_err_response = resp
|
||||
logger.warning(
|
||||
"Chat Model %s returns error response, trying fallback to next provider.",
|
||||
candidate_id,
|
||||
)
|
||||
break
|
||||
|
||||
yield resp
|
||||
return
|
||||
|
||||
if has_stream_output:
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_exception = exc
|
||||
logger.warning(
|
||||
"Chat Model %s request error: %s",
|
||||
candidate_id,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
continue
|
||||
|
||||
if last_err_response:
|
||||
yield last_err_response
|
||||
return
|
||||
if last_exception:
|
||||
yield LLMResponse(
|
||||
role="err",
|
||||
completion_text=(
|
||||
"All chat models failed: "
|
||||
f"{type(last_exception).__name__}: {last_exception}"
|
||||
),
|
||||
)
|
||||
return
|
||||
yield LLMResponse(
|
||||
role="err",
|
||||
completion_text="All available chat models are unavailable.",
|
||||
)
|
||||
|
||||
def _simple_print_message_role(self, tag: str = ""):
|
||||
roles = []
|
||||
for message in self.run_context.messages:
|
||||
@@ -215,7 +301,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
)
|
||||
self._simple_print_message_role("[AftCompact]")
|
||||
|
||||
async for llm_response in self._iter_llm_responses():
|
||||
async for llm_response in self._iter_llm_responses_with_fallback():
|
||||
if llm_response.is_chunk:
|
||||
# update ttft
|
||||
if self.stats.time_to_first_token == 0:
|
||||
@@ -271,6 +357,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
),
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
if not llm_resp.tools_call_name:
|
||||
# 如果没有工具调用,转换到完成状态
|
||||
|
||||
@@ -285,6 +285,9 @@ class ToolSet:
|
||||
prop_value = convert_schema(value)
|
||||
if "default" in prop_value:
|
||||
del prop_value["default"]
|
||||
# see #5217
|
||||
if "additionalProperties" in prop_value:
|
||||
del prop_value["additionalProperties"]
|
||||
properties[key] = prop_value
|
||||
|
||||
if properties:
|
||||
|
||||
@@ -870,6 +870,41 @@ def _get_compress_provider(
|
||||
return provider
|
||||
|
||||
|
||||
def _get_fallback_chat_providers(
|
||||
provider: Provider, plugin_context: Context, provider_settings: dict
|
||||
) -> list[Provider]:
|
||||
fallback_ids = provider_settings.get("fallback_chat_models", [])
|
||||
if not isinstance(fallback_ids, list):
|
||||
logger.warning(
|
||||
"fallback_chat_models setting is not a list, skip fallback providers."
|
||||
)
|
||||
return []
|
||||
|
||||
provider_id = str(provider.provider_config.get("id", ""))
|
||||
seen_provider_ids: set[str] = {provider_id} if provider_id else set()
|
||||
fallbacks: list[Provider] = []
|
||||
|
||||
for fallback_id in fallback_ids:
|
||||
if not isinstance(fallback_id, str) or not fallback_id:
|
||||
continue
|
||||
if fallback_id in seen_provider_ids:
|
||||
continue
|
||||
fallback_provider = plugin_context.get_provider_by_id(fallback_id)
|
||||
if fallback_provider is None:
|
||||
logger.warning("Fallback chat provider `%s` not found, skip.", fallback_id)
|
||||
continue
|
||||
if not isinstance(fallback_provider, Provider):
|
||||
logger.warning(
|
||||
"Fallback chat provider `%s` is invalid type: %s, skip.",
|
||||
fallback_id,
|
||||
type(fallback_provider),
|
||||
)
|
||||
continue
|
||||
fallbacks.append(fallback_provider)
|
||||
seen_provider_ids.add(fallback_id)
|
||||
return fallbacks
|
||||
|
||||
|
||||
async def build_main_agent(
|
||||
*,
|
||||
event: AstrMessageEvent,
|
||||
@@ -1093,6 +1128,9 @@ async def build_main_agent(
|
||||
truncate_turns=config.dequeue_context_length,
|
||||
enforce_max_turns=config.max_context_length,
|
||||
tool_schema_mode=config.tool_schema_mode,
|
||||
fallback_providers=_get_fallback_chat_providers(
|
||||
provider, plugin_context, config.provider_settings
|
||||
),
|
||||
)
|
||||
|
||||
if apply_reset:
|
||||
|
||||
@@ -5,8 +5,9 @@ import mcp
|
||||
from astrbot.api import FunctionTool
|
||||
from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.agent.tool import ToolExecResult
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext, AstrMessageEvent
|
||||
from astrbot.core.computer.computer_client import get_booter, get_local_booter
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
|
||||
param_schema = {
|
||||
"type": "object",
|
||||
@@ -25,7 +26,22 @@ param_schema = {
|
||||
}
|
||||
|
||||
|
||||
def handle_result(result: dict) -> ToolExecResult:
|
||||
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:
|
||||
data = result.get("data", {})
|
||||
output = data.get("output", {})
|
||||
error = data.get("error", "")
|
||||
@@ -44,6 +60,9 @@ def handle_result(result: dict) -> ToolExecResult:
|
||||
type="image", data=img["image/png"], mimeType="image/png"
|
||||
)
|
||||
)
|
||||
|
||||
if event.get_platform_name() == "webchat":
|
||||
await event.send(message=MessageChain().base64_image(img["image/png"]))
|
||||
if text:
|
||||
resp.content.append(mcp.types.TextContent(type="text", text=text))
|
||||
|
||||
@@ -62,13 +81,15 @@ class PythonTool(FunctionTool):
|
||||
async def call(
|
||||
self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False
|
||||
) -> ToolExecResult:
|
||||
if permission_error := _check_admin_permission(context):
|
||||
return permission_error
|
||||
sb = await get_booter(
|
||||
context.context.context,
|
||||
context.context.event.unified_msg_origin,
|
||||
)
|
||||
try:
|
||||
result = await sb.python.exec(code, silent=silent)
|
||||
return handle_result(result)
|
||||
return await handle_result(result, context.context.event)
|
||||
except Exception as e:
|
||||
return f"Error executing code: {str(e)}"
|
||||
|
||||
@@ -83,12 +104,11 @@ class LocalPythonTool(FunctionTool):
|
||||
async def call(
|
||||
self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False
|
||||
) -> ToolExecResult:
|
||||
if context.context.event.role != "admin":
|
||||
return "error: Permission denied. Local Python execution is only allowed for admin users. Tell user to set admins in AstrBot WebUI."
|
||||
|
||||
if permission_error := _check_admin_permission(context):
|
||||
return permission_error
|
||||
sb = get_local_booter()
|
||||
try:
|
||||
result = await sb.python.exec(code, silent=silent)
|
||||
return handle_result(result)
|
||||
return await handle_result(result, context.context.event)
|
||||
except Exception as e:
|
||||
return f"Error executing code: {str(e)}"
|
||||
|
||||
@@ -9,6 +9,21 @@ from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
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
|
||||
class ExecuteShellTool(FunctionTool):
|
||||
name: str = "astrbot_execute_shell"
|
||||
@@ -46,8 +61,8 @@ class ExecuteShellTool(FunctionTool):
|
||||
background: bool = False,
|
||||
env: dict = {},
|
||||
) -> ToolExecResult:
|
||||
if context.context.event.role != "admin":
|
||||
return "error: Permission denied. Shell execution is only allowed for admin users. Tell user to Set admins in AstrBot WebUI."
|
||||
if permission_error := _check_admin_permission(context):
|
||||
return permission_error
|
||||
|
||||
if self.is_local:
|
||||
sb = get_local_booter()
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
VERSION = "4.15.0"
|
||||
VERSION = "4.17.6"
|
||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
||||
|
||||
WEBHOOK_SUPPORTED_PLATFORMS = [
|
||||
@@ -15,6 +15,7 @@ WEBHOOK_SUPPORTED_PLATFORMS = [
|
||||
"wecom_ai_bot",
|
||||
"slack",
|
||||
"lark",
|
||||
"line",
|
||||
]
|
||||
|
||||
# 默认配置
|
||||
@@ -67,6 +68,7 @@ DEFAULT_CONFIG = {
|
||||
"provider_settings": {
|
||||
"enable": True,
|
||||
"default_provider_id": "",
|
||||
"fallback_chat_models": [],
|
||||
"default_image_caption_provider_id": "",
|
||||
"image_caption_prompt": "Please describe the image using Chinese.",
|
||||
"provider_pool": ["*"], # "*" 表示使用所有可用的提供者
|
||||
@@ -126,6 +128,7 @@ DEFAULT_CONFIG = {
|
||||
"add_cron_tools": True,
|
||||
},
|
||||
"computer_use_runtime": "local",
|
||||
"computer_use_require_admin": True,
|
||||
"sandbox": {
|
||||
"booter": "shipyard",
|
||||
"shipyard_endpoint": "",
|
||||
@@ -194,6 +197,12 @@ DEFAULT_CONFIG = {
|
||||
"host": "0.0.0.0",
|
||||
"port": 6185,
|
||||
"disable_access_log": True,
|
||||
"ssl": {
|
||||
"enable": False,
|
||||
"cert_file": "",
|
||||
"key_file": "",
|
||||
"ca_certs": "",
|
||||
},
|
||||
},
|
||||
"platform": [],
|
||||
"platform_specific": {
|
||||
@@ -415,6 +424,7 @@ CONFIG_METADATA_2 = {
|
||||
"slack_webhook_port": 6197,
|
||||
"slack_webhook_path": "/astrbot-slack-webhook/callback",
|
||||
},
|
||||
# LINE's config is located in line_adapter.py
|
||||
"Satori": {
|
||||
"id": "satori",
|
||||
"type": "satori",
|
||||
@@ -1020,6 +1030,30 @@ CONFIG_METADATA_2 = {
|
||||
"proxy": "",
|
||||
"custom_headers": {},
|
||||
},
|
||||
"AIHubMix": {
|
||||
"id": "aihubmix",
|
||||
"provider": "aihubmix",
|
||||
"type": "aihubmix_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"timeout": 120,
|
||||
"api_base": "https://aihubmix.com/v1",
|
||||
"proxy": "",
|
||||
"custom_headers": {},
|
||||
},
|
||||
"NVIDIA": {
|
||||
"id": "nvidia",
|
||||
"provider": "nvidia",
|
||||
"type": "openai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"api_base": "https://integrate.api.nvidia.com/v1",
|
||||
"timeout": 120,
|
||||
"proxy": "",
|
||||
"custom_headers": {},
|
||||
},
|
||||
"Azure OpenAI": {
|
||||
"id": "azure_openai",
|
||||
"provider": "azure",
|
||||
@@ -2205,6 +2239,10 @@ CONFIG_METADATA_2 = {
|
||||
"default_provider_id": {
|
||||
"type": "string",
|
||||
},
|
||||
"fallback_chat_models": {
|
||||
"type": "list",
|
||||
"items": {"type": "string"},
|
||||
},
|
||||
"wake_prefix": {
|
||||
"type": "string",
|
||||
},
|
||||
@@ -2399,6 +2437,19 @@ CONFIG_METADATA_2 = {
|
||||
"type": "string",
|
||||
"options": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
||||
},
|
||||
"dashboard.ssl.enable": {"type": "bool"},
|
||||
"dashboard.ssl.cert_file": {
|
||||
"type": "string",
|
||||
"condition": {"dashboard.ssl.enable": True},
|
||||
},
|
||||
"dashboard.ssl.key_file": {
|
||||
"type": "string",
|
||||
"condition": {"dashboard.ssl.enable": True},
|
||||
},
|
||||
"dashboard.ssl.ca_certs": {
|
||||
"type": "string",
|
||||
"condition": {"dashboard.ssl.enable": True},
|
||||
},
|
||||
"log_file_enable": {"type": "bool"},
|
||||
"log_file_path": {"type": "string", "condition": {"log_file_enable": True}},
|
||||
"log_file_max_mb": {"type": "int", "condition": {"log_file_enable": True}},
|
||||
@@ -2502,15 +2553,22 @@ CONFIG_METADATA_3 = {
|
||||
},
|
||||
"ai": {
|
||||
"description": "模型",
|
||||
"hint": "当使用非内置 Agent 执行器时,默认聊天模型和默认图片转述模型可能会无效,但某些插件会依赖此配置项来调用 AI 能力。",
|
||||
"hint": "当使用非内置 Agent 执行器时,默认对话模型和默认图片转述模型可能会无效,但某些插件会依赖此配置项来调用 AI 能力。",
|
||||
"type": "object",
|
||||
"items": {
|
||||
"provider_settings.default_provider_id": {
|
||||
"description": "默认聊天模型",
|
||||
"description": "默认对话模型",
|
||||
"type": "string",
|
||||
"_special": "select_provider",
|
||||
"hint": "留空时使用第一个模型",
|
||||
},
|
||||
"provider_settings.fallback_chat_models": {
|
||||
"description": "回退对话模型列表",
|
||||
"type": "list",
|
||||
"items": {"type": "string"},
|
||||
"_special": "select_providers",
|
||||
"hint": "主聊天模型请求失败时,按顺序切换到这些模型。",
|
||||
},
|
||||
"provider_settings.default_image_caption_provider_id": {
|
||||
"description": "默认图片转述模型",
|
||||
"type": "string",
|
||||
@@ -2680,6 +2738,11 @@ CONFIG_METADATA_3 = {
|
||||
"labels": ["无", "本地", "沙箱"],
|
||||
"hint": "选择 Computer Use 运行环境。",
|
||||
},
|
||||
"provider_settings.computer_use_require_admin": {
|
||||
"description": "需要 AstrBot 管理员权限",
|
||||
"type": "bool",
|
||||
"hint": "开启后,需要 AstrBot 管理员权限才能调用使用电脑能力。在平台配置->管理员中可添加管理员。使用 /sid 指令查看管理员 ID。",
|
||||
},
|
||||
"provider_settings.sandbox.booter": {
|
||||
"description": "沙箱环境驱动器",
|
||||
"type": "string",
|
||||
@@ -3406,6 +3469,29 @@ CONFIG_METADATA_3_SYSTEM = {
|
||||
"hint": "控制台输出日志的级别。",
|
||||
"options": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
||||
},
|
||||
"dashboard.ssl.enable": {
|
||||
"description": "启用 WebUI HTTPS",
|
||||
"type": "bool",
|
||||
"hint": "启用后,WebUI 将直接使用 HTTPS 提供服务。",
|
||||
},
|
||||
"dashboard.ssl.cert_file": {
|
||||
"description": "SSL 证书文件路径",
|
||||
"type": "string",
|
||||
"hint": "证书文件路径(PEM)。支持绝对路径和相对路径(相对于当前工作目录)。",
|
||||
"condition": {"dashboard.ssl.enable": True},
|
||||
},
|
||||
"dashboard.ssl.key_file": {
|
||||
"description": "SSL 私钥文件路径",
|
||||
"type": "string",
|
||||
"hint": "私钥文件路径(PEM)。支持绝对路径和相对路径(相对于当前工作目录)。",
|
||||
"condition": {"dashboard.ssl.enable": True},
|
||||
},
|
||||
"dashboard.ssl.ca_certs": {
|
||||
"description": "SSL CA 证书文件路径",
|
||||
"type": "string",
|
||||
"hint": "可选。用于指定 CA 证书文件路径。",
|
||||
"condition": {"dashboard.ssl.enable": True},
|
||||
},
|
||||
"log_file_enable": {
|
||||
"description": "启用文件日志",
|
||||
"type": "bool",
|
||||
|
||||
@@ -13,16 +13,19 @@ from astrbot.core.knowledge_base.models import (
|
||||
KBMedia,
|
||||
KnowledgeBase,
|
||||
)
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_knowledge_base_path
|
||||
|
||||
|
||||
class KBSQLiteDatabase:
|
||||
def __init__(self, db_path: str = "data/knowledge_base/kb.db") -> None:
|
||||
def __init__(self, db_path: str | None = None) -> None:
|
||||
"""初始化知识库数据库
|
||||
|
||||
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.DATABASE_URL = f"sqlite+aiosqlite:///{db_path}"
|
||||
self.inited = False
|
||||
|
||||
@@ -3,6 +3,7 @@ from pathlib import Path
|
||||
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.provider.manager import ProviderManager
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_knowledge_base_path
|
||||
|
||||
# from .chunking.fixed_size import FixedSizeChunker
|
||||
from .chunking.recursive import RecursiveCharacterChunker
|
||||
@@ -13,7 +14,7 @@ from .retrieval.manager import RetrievalManager, RetrievalResult
|
||||
from .retrieval.rank_fusion import RankFusion
|
||||
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"
|
||||
"""Knowledge Base storage root directory"""
|
||||
CHUNKER = RecursiveCharacterChunker()
|
||||
@@ -27,7 +28,7 @@ class KnowledgeBaseManager:
|
||||
self,
|
||||
provider_manager: ProviderManager,
|
||||
) -> 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._session_deleted_callback_registered = False
|
||||
|
||||
|
||||
+266
-309
@@ -1,24 +1,4 @@
|
||||
"""日志系统, 用于支持核心组件和插件的日志记录, 提供了日志订阅功能
|
||||
|
||||
const:
|
||||
CACHED_SIZE: 日志缓存大小, 用于限制缓存的日志数量
|
||||
log_color_config: 日志颜色配置, 定义了不同日志级别的颜色
|
||||
|
||||
class:
|
||||
LogBroker: 日志代理类, 用于缓存和分发日志消息
|
||||
LogQueueHandler: 日志处理器, 用于将日志消息发送到 LogBroker
|
||||
LogManager: 日志管理器, 用于创建和配置日志记录器
|
||||
|
||||
function:
|
||||
is_plugin_path: 检查文件路径是否来自插件目录
|
||||
get_short_level_name: 将日志级别名称转换为四个字母的缩写
|
||||
|
||||
工作流程:
|
||||
1. 通过 LogManager.GetLogger() 获取日志器, 配置了控制台输出和多个格式化过滤器
|
||||
2. 通过 set_queue_handler() 设置日志处理器, 将日志消息发送到 LogBroker
|
||||
3. logBroker 维护一个订阅者列表, 负责将日志分发给所有订阅者
|
||||
4. 订阅者可以使用 register() 方法注册到 LogBroker, 订阅日志流
|
||||
"""
|
||||
"""日志系统,统一将标准 logging 输出转发到 loguru。"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
@@ -27,54 +7,59 @@ import sys
|
||||
import time
|
||||
from asyncio import Queue
|
||||
from collections import deque
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import colorlog
|
||||
from loguru import logger as _raw_loguru_logger
|
||||
|
||||
from astrbot.core.config.default import VERSION
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
# 日志缓存大小
|
||||
CACHED_SIZE = 500
|
||||
# 日志颜色配置
|
||||
log_color_config = {
|
||||
"DEBUG": "green",
|
||||
"INFO": "bold_cyan",
|
||||
"WARNING": "bold_yellow",
|
||||
"ERROR": "red",
|
||||
"CRITICAL": "bold_red",
|
||||
"RESET": "reset",
|
||||
"asctime": "green",
|
||||
}
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from loguru import Record
|
||||
|
||||
|
||||
def is_plugin_path(pathname):
|
||||
"""检查文件路径是否来自插件目录
|
||||
class _RecordEnricherFilter(logging.Filter):
|
||||
"""为 logging.LogRecord 注入 AstrBot 日志字段。"""
|
||||
|
||||
Args:
|
||||
pathname (str): 文件路径
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
record.plugin_tag = "[Plug]" if _is_plugin_path(record.pathname) else "[Core]"
|
||||
record.short_levelname = _get_short_level_name(record.levelname)
|
||||
record.astrbot_version_tag = (
|
||||
f" [v{VERSION}]" if record.levelno >= logging.WARNING else ""
|
||||
)
|
||||
record.source_file = _build_source_file(record.pathname)
|
||||
record.source_line = record.lineno
|
||||
record.is_trace = record.name == "astrbot.trace"
|
||||
return True
|
||||
|
||||
Returns:
|
||||
bool: 如果路径来自插件目录,则返回 True,否则返回 False
|
||||
|
||||
"""
|
||||
class _QueueAnsiColorFilter(logging.Filter):
|
||||
"""Attach ANSI color prefix for WebUI console rendering."""
|
||||
|
||||
_LEVEL_COLOR = {
|
||||
"DEBUG": "\u001b[1;34m",
|
||||
"INFO": "\u001b[1;36m",
|
||||
"WARNING": "\u001b[1;33m",
|
||||
"ERROR": "\u001b[31m",
|
||||
"CRITICAL": "\u001b[1;31m",
|
||||
}
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
record.ansi_prefix = self._LEVEL_COLOR.get(record.levelname, "\u001b[0m")
|
||||
record.ansi_reset = "\u001b[0m"
|
||||
return True
|
||||
|
||||
|
||||
def _is_plugin_path(pathname: str | None) -> bool:
|
||||
if not pathname:
|
||||
return False
|
||||
|
||||
norm_path = os.path.normpath(pathname)
|
||||
return ("data/plugins" in norm_path) or ("astrbot/builtin_stars/" in norm_path)
|
||||
|
||||
|
||||
def get_short_level_name(level_name):
|
||||
"""将日志级别名称转换为四个字母的缩写
|
||||
|
||||
Args:
|
||||
level_name (str): 日志级别名称, 如 "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"
|
||||
|
||||
Returns:
|
||||
str: 四个字母的日志级别缩写
|
||||
|
||||
"""
|
||||
def _get_short_level_name(level_name: str) -> str:
|
||||
level_map = {
|
||||
"DEBUG": "DBUG",
|
||||
"INFO": "INFO",
|
||||
@@ -85,44 +70,75 @@ def get_short_level_name(level_name):
|
||||
return level_map.get(level_name, level_name[:4].upper())
|
||||
|
||||
|
||||
class LogBroker:
|
||||
"""日志代理类, 用于缓存和分发日志消息
|
||||
def _build_source_file(pathname: str | None) -> str:
|
||||
if not pathname:
|
||||
return "unknown"
|
||||
dirname = os.path.dirname(pathname)
|
||||
return (
|
||||
os.path.basename(dirname) + "." + os.path.basename(pathname).replace(".py", "")
|
||||
)
|
||||
|
||||
发布-订阅模式
|
||||
"""
|
||||
|
||||
def _patch_record(record: "Record") -> None:
|
||||
extra = record["extra"]
|
||||
extra.setdefault("plugin_tag", "[Core]")
|
||||
extra.setdefault("short_levelname", _get_short_level_name(record["level"].name))
|
||||
level_no = record["level"].no
|
||||
extra.setdefault("astrbot_version_tag", f" [v{VERSION}]" if level_no >= 30 else "")
|
||||
extra.setdefault("source_file", _build_source_file(record["file"].path))
|
||||
extra.setdefault("source_line", record["line"])
|
||||
extra.setdefault("is_trace", False)
|
||||
|
||||
|
||||
_loguru = _raw_loguru_logger.patch(_patch_record)
|
||||
|
||||
|
||||
class _LoguruInterceptHandler(logging.Handler):
|
||||
"""将 logging 记录转发到 loguru。"""
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
try:
|
||||
level: str | int = _loguru.level(record.levelname).name
|
||||
except ValueError:
|
||||
level = record.levelno
|
||||
|
||||
payload = {
|
||||
"plugin_tag": getattr(record, "plugin_tag", "[Core]"),
|
||||
"short_levelname": getattr(
|
||||
record,
|
||||
"short_levelname",
|
||||
_get_short_level_name(record.levelname),
|
||||
),
|
||||
"astrbot_version_tag": getattr(record, "astrbot_version_tag", ""),
|
||||
"source_file": getattr(
|
||||
record, "source_file", _build_source_file(record.pathname)
|
||||
),
|
||||
"source_line": getattr(record, "source_line", record.lineno),
|
||||
"is_trace": getattr(record, "is_trace", record.name == "astrbot.trace"),
|
||||
}
|
||||
|
||||
_loguru.bind(**payload).opt(exception=record.exc_info).log(
|
||||
level,
|
||||
record.getMessage(),
|
||||
)
|
||||
|
||||
|
||||
class LogBroker:
|
||||
"""日志代理类,用于缓存和分发日志消息。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.log_cache = deque(maxlen=CACHED_SIZE) # 环形缓冲区, 保存最近的日志
|
||||
self.subscribers: list[Queue] = [] # 订阅者列表
|
||||
self.log_cache = deque(maxlen=CACHED_SIZE)
|
||||
self.subscribers: list[Queue] = []
|
||||
|
||||
def register(self) -> Queue:
|
||||
"""注册新的订阅者, 并给每个订阅者返回一个带有日志缓存的队列
|
||||
|
||||
Returns:
|
||||
Queue: 订阅者的队列, 可用于接收日志消息
|
||||
|
||||
"""
|
||||
q = Queue(maxsize=CACHED_SIZE + 10)
|
||||
self.subscribers.append(q)
|
||||
return q
|
||||
|
||||
def unregister(self, q: Queue) -> None:
|
||||
"""取消订阅
|
||||
|
||||
Args:
|
||||
q (Queue): 需要取消订阅的队列
|
||||
|
||||
"""
|
||||
self.subscribers.remove(q)
|
||||
|
||||
def publish(self, log_entry: dict) -> None:
|
||||
"""发布新日志到所有订阅者, 使用非阻塞方式投递, 避免一个订阅者阻塞整个系统
|
||||
|
||||
Args:
|
||||
log_entry (dict): 日志消息, 包含日志级别和日志内容.
|
||||
example: {"level": "INFO", "data": "This is a log message.", "time": "2023-10-01 12:00:00"}
|
||||
|
||||
"""
|
||||
self.log_cache.append(log_entry)
|
||||
for q in self.subscribers:
|
||||
try:
|
||||
@@ -132,23 +148,13 @@ class LogBroker:
|
||||
|
||||
|
||||
class LogQueueHandler(logging.Handler):
|
||||
"""日志处理器, 用于将日志消息发送到 LogBroker
|
||||
|
||||
继承自 logging.Handler
|
||||
"""
|
||||
"""日志处理器,用于将日志消息发送到 LogBroker。"""
|
||||
|
||||
def __init__(self, log_broker: LogBroker) -> None:
|
||||
super().__init__()
|
||||
self.log_broker = log_broker
|
||||
|
||||
def emit(self, record) -> None:
|
||||
"""日志处理的入口方法, 接受一个日志记录, 转换为字符串后由 LogBroker 发布
|
||||
这个方法会在每次日志记录时被调用
|
||||
|
||||
Args:
|
||||
record (logging.LogRecord): 日志记录对象, 包含日志信息
|
||||
|
||||
"""
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
log_entry = self.format(record)
|
||||
self.log_broker.publish(
|
||||
{
|
||||
@@ -160,117 +166,16 @@ class LogQueueHandler(logging.Handler):
|
||||
|
||||
|
||||
class LogManager:
|
||||
"""日志管理器, 用于创建和配置日志记录器
|
||||
_LOGGER_HANDLER_FLAG = "_astrbot_loguru_handler"
|
||||
_ENRICH_FILTER_FLAG = "_astrbot_enrich_filter"
|
||||
|
||||
提供了获取默认日志记录器logger和设置队列处理器的方法
|
||||
"""
|
||||
|
||||
_FILE_HANDLER_FLAG = "_astrbot_file_handler"
|
||||
_TRACE_FILE_HANDLER_FLAG = "_astrbot_trace_file_handler"
|
||||
|
||||
@classmethod
|
||||
def GetLogger(cls, log_name: str = "default"):
|
||||
"""获取指定名称的日志记录器logger
|
||||
|
||||
Args:
|
||||
log_name (str): 日志记录器的名称, 默认为 "default"
|
||||
|
||||
Returns:
|
||||
logging.Logger: 返回配置好的日志记录器
|
||||
|
||||
"""
|
||||
logger = logging.getLogger(log_name)
|
||||
# 检查该logger或父级logger是否已经有处理器, 如果已经有处理器, 直接返回该logger, 避免重复配置
|
||||
if logger.hasHandlers():
|
||||
return logger
|
||||
# 如果logger没有处理器
|
||||
console_handler = logging.StreamHandler(
|
||||
sys.stdout,
|
||||
) # 创建一个StreamHandler用于控制台输出
|
||||
console_handler.setLevel(
|
||||
logging.DEBUG,
|
||||
) # 将日志级别设置为DEBUG(最低级别, 显示所有日志), *如果插件没有设置级别, 默认为DEBUG
|
||||
|
||||
# 创建彩色日志格式化器, 输出日志格式为: [时间] [插件标签] [日志级别] [文件名:行号]: 日志消息
|
||||
console_formatter = colorlog.ColoredFormatter(
|
||||
fmt="%(log_color)s [%(asctime)s] %(plugin_tag)s [%(short_levelname)-4s]%(astrbot_version_tag)s [%(filename)s:%(lineno)d]: %(message)s %(reset)s",
|
||||
datefmt="%H:%M:%S",
|
||||
log_colors=log_color_config,
|
||||
)
|
||||
|
||||
class PluginFilter(logging.Filter):
|
||||
"""插件过滤器类, 用于标记日志来源是插件还是核心组件"""
|
||||
|
||||
def filter(self, record) -> bool:
|
||||
record.plugin_tag = (
|
||||
"[Plug]" if is_plugin_path(record.pathname) else "[Core]"
|
||||
)
|
||||
return True
|
||||
|
||||
class FileNameFilter(logging.Filter):
|
||||
"""文件名过滤器类, 用于修改日志记录的文件名格式
|
||||
例如: 将文件路径 /path/to/file.py 转换为 file.<file> 格式
|
||||
"""
|
||||
|
||||
# 获取这个文件和父文件夹的名字:<folder>.<file> 并且去除 .py
|
||||
def filter(self, record) -> bool:
|
||||
dirname = os.path.dirname(record.pathname)
|
||||
record.filename = (
|
||||
os.path.basename(dirname)
|
||||
+ "."
|
||||
+ os.path.basename(record.pathname).replace(".py", "")
|
||||
)
|
||||
return True
|
||||
|
||||
class LevelNameFilter(logging.Filter):
|
||||
"""短日志级别名称过滤器类, 用于将日志级别名称转换为四个字母的缩写"""
|
||||
|
||||
# 添加短日志级别名称
|
||||
def filter(self, record) -> bool:
|
||||
record.short_levelname = get_short_level_name(record.levelname)
|
||||
return True
|
||||
|
||||
class AstrBotVersionTagFilter(logging.Filter):
|
||||
"""在 WARNING 及以上级别日志后追加当前 AstrBot 版本号。"""
|
||||
|
||||
def filter(self, record) -> bool:
|
||||
if record.levelno >= logging.WARNING:
|
||||
record.astrbot_version_tag = f" [v{VERSION}]"
|
||||
else:
|
||||
record.astrbot_version_tag = ""
|
||||
return True
|
||||
|
||||
console_handler.setFormatter(console_formatter) # 设置处理器的格式化器
|
||||
logger.addFilter(PluginFilter()) # 添加插件过滤器
|
||||
logger.addFilter(FileNameFilter()) # 添加文件名过滤器
|
||||
logger.addFilter(LevelNameFilter()) # 添加级别名称过滤器
|
||||
logger.addFilter(AstrBotVersionTagFilter()) # 追加版本号(WARNING 及以上)
|
||||
logger.setLevel(logging.DEBUG) # 设置日志级别为DEBUG
|
||||
logger.addHandler(console_handler) # 添加处理器到logger
|
||||
|
||||
return logger
|
||||
|
||||
@classmethod
|
||||
def set_queue_handler(cls, logger: logging.Logger, log_broker: LogBroker) -> None:
|
||||
"""设置队列处理器, 用于将日志消息发送到 LogBroker
|
||||
|
||||
Args:
|
||||
logger (logging.Logger): 日志记录器
|
||||
log_broker (LogBroker): 日志代理类, 用于缓存和分发日志消息
|
||||
|
||||
"""
|
||||
handler = LogQueueHandler(log_broker)
|
||||
handler.setLevel(logging.DEBUG)
|
||||
if logger.handlers:
|
||||
handler.setFormatter(logger.handlers[0].formatter)
|
||||
else:
|
||||
# 为队列处理器设置相同格式的formatter
|
||||
handler.setFormatter(
|
||||
logging.Formatter(
|
||||
"[%(asctime)s] [%(short_levelname)s] %(plugin_tag)s[%(filename)s:%(lineno)d]: %(message)s",
|
||||
),
|
||||
)
|
||||
logger.addHandler(handler)
|
||||
_configured = False
|
||||
_console_sink_id: int | None = None
|
||||
_file_sink_id: int | None = None
|
||||
_trace_sink_id: int | None = None
|
||||
_NOISY_LOGGER_LEVELS: dict[str, int] = {
|
||||
"aiosqlite": logging.WARNING,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _default_log_path(cls) -> str:
|
||||
@@ -285,79 +190,147 @@ class LogManager:
|
||||
return os.path.join(get_astrbot_data_path(), configured_path)
|
||||
|
||||
@classmethod
|
||||
def _get_file_handlers(cls, logger: logging.Logger) -> list[logging.Handler]:
|
||||
return [
|
||||
handler
|
||||
for handler in logger.handlers
|
||||
if getattr(handler, cls._FILE_HANDLER_FLAG, False)
|
||||
]
|
||||
def _setup_loguru(cls) -> None:
|
||||
if cls._configured:
|
||||
return
|
||||
|
||||
@classmethod
|
||||
def _get_trace_file_handlers(cls, logger: logging.Logger) -> list[logging.Handler]:
|
||||
return [
|
||||
handler
|
||||
for handler in logger.handlers
|
||||
if getattr(handler, cls._TRACE_FILE_HANDLER_FLAG, False)
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def _remove_file_handlers(cls, logger: logging.Logger) -> None:
|
||||
for handler in cls._get_file_handlers(logger):
|
||||
logger.removeHandler(handler)
|
||||
try:
|
||||
handler.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def _remove_trace_file_handlers(cls, logger: logging.Logger) -> None:
|
||||
for handler in cls._get_trace_file_handlers(logger):
|
||||
logger.removeHandler(handler)
|
||||
try:
|
||||
handler.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def _add_file_handler(
|
||||
cls,
|
||||
logger: logging.Logger,
|
||||
file_path: str,
|
||||
max_mb: int | None = None,
|
||||
backup_count: int = 3,
|
||||
trace: bool = False,
|
||||
) -> None:
|
||||
os.makedirs(os.path.dirname(file_path) or ".", exist_ok=True)
|
||||
max_bytes = 0
|
||||
if max_mb and max_mb > 0:
|
||||
max_bytes = max_mb * 1024 * 1024
|
||||
if max_bytes > 0:
|
||||
file_handler = RotatingFileHandler(
|
||||
file_path,
|
||||
maxBytes=max_bytes,
|
||||
backupCount=backup_count,
|
||||
encoding="utf-8",
|
||||
)
|
||||
else:
|
||||
file_handler = logging.FileHandler(file_path, encoding="utf-8")
|
||||
file_handler.setLevel(logger.level)
|
||||
if trace:
|
||||
formatter = logging.Formatter(
|
||||
"[%(asctime)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
else:
|
||||
formatter = logging.Formatter(
|
||||
"[%(asctime)s] %(plugin_tag)s [%(short_levelname)s]%(astrbot_version_tag)s [%(filename)s:%(lineno)d]: %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
setattr(
|
||||
file_handler,
|
||||
cls._TRACE_FILE_HANDLER_FLAG if trace else cls._FILE_HANDLER_FLAG,
|
||||
True,
|
||||
_loguru.remove()
|
||||
cls._console_sink_id = _loguru.add(
|
||||
sys.stdout,
|
||||
level="DEBUG",
|
||||
colorize=True,
|
||||
filter=lambda record: not record["extra"].get("is_trace", False),
|
||||
format=(
|
||||
"<green>[{time:HH:mm:ss.SSS}]</green> {extra[plugin_tag]} "
|
||||
"<level>[{extra[short_levelname]}]</level>{extra[astrbot_version_tag]} "
|
||||
"[{extra[source_file]}:{extra[source_line]}]: <level>{message}</level>"
|
||||
),
|
||||
)
|
||||
cls._configured = True
|
||||
|
||||
@classmethod
|
||||
def _setup_root_bridge(cls) -> None:
|
||||
root_logger = logging.getLogger()
|
||||
|
||||
has_handler = any(
|
||||
getattr(handler, cls._LOGGER_HANDLER_FLAG, False)
|
||||
for handler in root_logger.handlers
|
||||
)
|
||||
if not has_handler:
|
||||
handler = _LoguruInterceptHandler()
|
||||
setattr(handler, cls._LOGGER_HANDLER_FLAG, True)
|
||||
root_logger.addHandler(handler)
|
||||
root_logger.setLevel(logging.DEBUG)
|
||||
for name, level in cls._NOISY_LOGGER_LEVELS.items():
|
||||
logging.getLogger(name).setLevel(level)
|
||||
|
||||
@classmethod
|
||||
def _ensure_logger_enricher_filter(cls, logger: logging.Logger) -> None:
|
||||
has_filter = any(
|
||||
getattr(existing_filter, cls._ENRICH_FILTER_FLAG, False)
|
||||
for existing_filter in logger.filters
|
||||
)
|
||||
if not has_filter:
|
||||
enrich_filter = _RecordEnricherFilter()
|
||||
setattr(enrich_filter, cls._ENRICH_FILTER_FLAG, True)
|
||||
logger.addFilter(enrich_filter)
|
||||
|
||||
@classmethod
|
||||
def _ensure_logger_intercept_handler(cls, logger: logging.Logger) -> None:
|
||||
has_handler = any(
|
||||
getattr(handler, cls._LOGGER_HANDLER_FLAG, False)
|
||||
for handler in logger.handlers
|
||||
)
|
||||
if not has_handler:
|
||||
handler = _LoguruInterceptHandler()
|
||||
setattr(handler, cls._LOGGER_HANDLER_FLAG, True)
|
||||
logger.addHandler(handler)
|
||||
|
||||
@classmethod
|
||||
def GetLogger(cls, log_name: str = "default") -> logging.Logger:
|
||||
cls._setup_loguru()
|
||||
cls._setup_root_bridge()
|
||||
|
||||
logger = logging.getLogger(log_name)
|
||||
cls._ensure_logger_enricher_filter(logger)
|
||||
cls._ensure_logger_intercept_handler(logger)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
logger.propagate = False
|
||||
return logger
|
||||
|
||||
@classmethod
|
||||
def set_queue_handler(cls, logger: logging.Logger, log_broker: LogBroker) -> None:
|
||||
cls._ensure_logger_enricher_filter(logger)
|
||||
|
||||
for handler in logger.handlers:
|
||||
if isinstance(handler, LogQueueHandler):
|
||||
return
|
||||
|
||||
handler = LogQueueHandler(log_broker)
|
||||
handler.setLevel(logging.DEBUG)
|
||||
handler.addFilter(_QueueAnsiColorFilter())
|
||||
handler.setFormatter(
|
||||
logging.Formatter(
|
||||
"%(ansi_prefix)s[%(asctime)s.%(msecs)03d] %(plugin_tag)s [%(short_levelname)s]%(astrbot_version_tag)s "
|
||||
"[%(source_file)s:%(source_line)d]: %(message)s%(ansi_reset)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
),
|
||||
)
|
||||
logger.addHandler(handler)
|
||||
|
||||
@classmethod
|
||||
def _remove_sink(cls, sink_id: int | None) -> None:
|
||||
if sink_id is None:
|
||||
return
|
||||
try:
|
||||
_loguru.remove(sink_id)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def _add_file_sink(
|
||||
cls,
|
||||
*,
|
||||
file_path: str,
|
||||
level: int,
|
||||
max_mb: int | None,
|
||||
backup_count: int,
|
||||
trace: bool,
|
||||
) -> int:
|
||||
os.makedirs(os.path.dirname(file_path) or ".", exist_ok=True)
|
||||
rotation = f"{max_mb} MB" if max_mb and max_mb > 0 else None
|
||||
retention = (
|
||||
backup_count if rotation and backup_count and backup_count > 0 else None
|
||||
)
|
||||
if trace:
|
||||
return _loguru.add(
|
||||
file_path,
|
||||
level="INFO",
|
||||
format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {message}",
|
||||
encoding="utf-8",
|
||||
rotation=rotation,
|
||||
retention=retention,
|
||||
enqueue=True,
|
||||
filter=lambda record: record["extra"].get("is_trace", False),
|
||||
)
|
||||
|
||||
logging_level_name = logging.getLevelName(level)
|
||||
if isinstance(logging_level_name, int):
|
||||
logging_level_name = "INFO"
|
||||
return _loguru.add(
|
||||
file_path,
|
||||
level=logging_level_name,
|
||||
format=(
|
||||
"[{time:YYYY-MM-DD HH:mm:ss.SSS}] {extra[plugin_tag]} "
|
||||
"[{extra[short_levelname]}]{extra[astrbot_version_tag]} "
|
||||
"[{extra[source_file]}:{extra[source_line]}]: {message}"
|
||||
),
|
||||
encoding="utf-8",
|
||||
rotation=rotation,
|
||||
retention=retention,
|
||||
enqueue=True,
|
||||
filter=lambda record: not record["extra"].get("is_trace", False),
|
||||
)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
@classmethod
|
||||
def configure_logger(
|
||||
@@ -366,13 +339,6 @@ class LogManager:
|
||||
config: dict | None,
|
||||
override_level: str | None = None,
|
||||
) -> None:
|
||||
"""根据配置设置日志级别和文件日志。
|
||||
|
||||
Args:
|
||||
logger: 需要配置的 logger
|
||||
config: 配置字典
|
||||
override_level: 若提供,将覆盖配置中的日志级别
|
||||
"""
|
||||
if not config:
|
||||
return
|
||||
|
||||
@@ -383,7 +349,6 @@ class LogManager:
|
||||
except Exception:
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
# 兼容旧版嵌套配置
|
||||
if "log_file" in config:
|
||||
file_conf = config.get("log_file") or {}
|
||||
enable_file = bool(file_conf.get("enable", False))
|
||||
@@ -394,27 +359,25 @@ class LogManager:
|
||||
file_path = config.get("log_file_path")
|
||||
max_mb = config.get("log_file_max_mb")
|
||||
|
||||
file_path = cls._resolve_log_path(file_path)
|
||||
cls._remove_sink(cls._file_sink_id)
|
||||
cls._file_sink_id = None
|
||||
|
||||
existing = cls._get_file_handlers(logger)
|
||||
if not enable_file:
|
||||
cls._remove_file_handlers(logger)
|
||||
return
|
||||
|
||||
# 如果已有文件处理器且路径一致,则仅同步级别
|
||||
if existing:
|
||||
handler = existing[0]
|
||||
base = getattr(handler, "baseFilename", "")
|
||||
if base and os.path.abspath(base) == os.path.abspath(file_path):
|
||||
handler.setLevel(logger.level)
|
||||
return
|
||||
cls._remove_file_handlers(logger)
|
||||
|
||||
cls._add_file_handler(logger, file_path, max_mb=max_mb)
|
||||
try:
|
||||
cls._file_sink_id = cls._add_file_sink(
|
||||
file_path=cls._resolve_log_path(file_path),
|
||||
level=logger.level,
|
||||
max_mb=max_mb,
|
||||
backup_count=3,
|
||||
trace=False,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add file sink: {e}")
|
||||
|
||||
@classmethod
|
||||
def configure_trace_logger(cls, config: dict | None) -> None:
|
||||
"""为 trace 事件配置独立的文件日志,不向控制台输出。"""
|
||||
if not config:
|
||||
return
|
||||
|
||||
@@ -429,28 +392,22 @@ class LogManager:
|
||||
path = path or legacy.get("trace_path")
|
||||
max_mb = max_mb or legacy.get("trace_max_mb")
|
||||
|
||||
if not enable:
|
||||
trace_logger = logging.getLogger("astrbot.trace")
|
||||
cls._remove_trace_file_handlers(trace_logger)
|
||||
return
|
||||
|
||||
file_path = cls._resolve_log_path(path or "logs/astrbot.trace.log")
|
||||
trace_logger = logging.getLogger("astrbot.trace")
|
||||
cls._ensure_logger_enricher_filter(trace_logger)
|
||||
cls._ensure_logger_intercept_handler(trace_logger)
|
||||
trace_logger.setLevel(logging.INFO)
|
||||
trace_logger.propagate = False
|
||||
|
||||
existing = cls._get_trace_file_handlers(trace_logger)
|
||||
if existing:
|
||||
handler = existing[0]
|
||||
base = getattr(handler, "baseFilename", "")
|
||||
if base and os.path.abspath(base) == os.path.abspath(file_path):
|
||||
handler.setLevel(trace_logger.level)
|
||||
return
|
||||
cls._remove_trace_file_handlers(trace_logger)
|
||||
cls._remove_sink(cls._trace_sink_id)
|
||||
cls._trace_sink_id = None
|
||||
|
||||
cls._add_file_handler(
|
||||
trace_logger,
|
||||
file_path,
|
||||
if not enable:
|
||||
return
|
||||
|
||||
cls._trace_sink_id = cls._add_file_sink(
|
||||
file_path=cls._resolve_log_path(path or "logs/astrbot.trace.log"),
|
||||
level=logging.INFO,
|
||||
max_mb=max_mb,
|
||||
backup_count=3,
|
||||
trace=True,
|
||||
)
|
||||
|
||||
@@ -25,10 +25,14 @@ import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from enum import Enum
|
||||
|
||||
from pydantic.v1 import BaseModel
|
||||
if sys.version_info >= (3, 14):
|
||||
from pydantic import BaseModel
|
||||
else:
|
||||
from pydantic.v1 import BaseModel
|
||||
|
||||
from astrbot.core import astrbot_config, file_token_service, logger
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
@@ -85,7 +89,7 @@ class BaseMessageComponent(BaseModel):
|
||||
|
||||
|
||||
class Plain(BaseMessageComponent):
|
||||
type = ComponentType.Plain
|
||||
type: ComponentType = ComponentType.Plain
|
||||
text: str
|
||||
convert: bool | None = True
|
||||
|
||||
@@ -100,7 +104,7 @@ class Plain(BaseMessageComponent):
|
||||
|
||||
|
||||
class Face(BaseMessageComponent):
|
||||
type = ComponentType.Face
|
||||
type: ComponentType = ComponentType.Face
|
||||
id: int
|
||||
|
||||
def __init__(self, **_) -> None:
|
||||
@@ -108,13 +112,15 @@ class Face(BaseMessageComponent):
|
||||
|
||||
|
||||
class Record(BaseMessageComponent):
|
||||
type = ComponentType.Record
|
||||
type: ComponentType = ComponentType.Record
|
||||
file: str | None = ""
|
||||
magic: bool | None = False
|
||||
url: str | None = ""
|
||||
cache: bool | None = True
|
||||
proxy: bool | None = True
|
||||
timeout: int | None = 0
|
||||
# Original text content (e.g. TTS source text), used as caption in fallback scenarios
|
||||
text: str | None = None
|
||||
# 额外
|
||||
path: str | None
|
||||
|
||||
@@ -215,7 +221,7 @@ class Record(BaseMessageComponent):
|
||||
|
||||
|
||||
class Video(BaseMessageComponent):
|
||||
type = ComponentType.Video
|
||||
type: ComponentType = ComponentType.Video
|
||||
file: str
|
||||
cover: str | None = ""
|
||||
c: int | None = 2
|
||||
@@ -301,7 +307,7 @@ class Video(BaseMessageComponent):
|
||||
|
||||
|
||||
class At(BaseMessageComponent):
|
||||
type = ComponentType.At
|
||||
type: ComponentType = ComponentType.At
|
||||
qq: int | str # 此处str为all时代表所有人
|
||||
name: str | None = ""
|
||||
|
||||
@@ -323,28 +329,28 @@ class AtAll(At):
|
||||
|
||||
|
||||
class RPS(BaseMessageComponent): # TODO
|
||||
type = ComponentType.RPS
|
||||
type: ComponentType = ComponentType.RPS
|
||||
|
||||
def __init__(self, **_) -> None:
|
||||
super().__init__(**_)
|
||||
|
||||
|
||||
class Dice(BaseMessageComponent): # TODO
|
||||
type = ComponentType.Dice
|
||||
type: ComponentType = ComponentType.Dice
|
||||
|
||||
def __init__(self, **_) -> None:
|
||||
super().__init__(**_)
|
||||
|
||||
|
||||
class Shake(BaseMessageComponent): # TODO
|
||||
type = ComponentType.Shake
|
||||
type: ComponentType = ComponentType.Shake
|
||||
|
||||
def __init__(self, **_) -> None:
|
||||
super().__init__(**_)
|
||||
|
||||
|
||||
class Share(BaseMessageComponent):
|
||||
type = ComponentType.Share
|
||||
type: ComponentType = ComponentType.Share
|
||||
url: str
|
||||
title: str
|
||||
content: str | None = ""
|
||||
@@ -355,7 +361,7 @@ class Share(BaseMessageComponent):
|
||||
|
||||
|
||||
class Contact(BaseMessageComponent): # TODO
|
||||
type = ComponentType.Contact
|
||||
type: ComponentType = ComponentType.Contact
|
||||
_type: str # type 字段冲突
|
||||
id: int | None = 0
|
||||
|
||||
@@ -364,7 +370,7 @@ class Contact(BaseMessageComponent): # TODO
|
||||
|
||||
|
||||
class Location(BaseMessageComponent): # TODO
|
||||
type = ComponentType.Location
|
||||
type: ComponentType = ComponentType.Location
|
||||
lat: float
|
||||
lon: float
|
||||
title: str | None = ""
|
||||
@@ -375,7 +381,7 @@ class Location(BaseMessageComponent): # TODO
|
||||
|
||||
|
||||
class Music(BaseMessageComponent):
|
||||
type = ComponentType.Music
|
||||
type: ComponentType = ComponentType.Music
|
||||
_type: str
|
||||
id: int | None = 0
|
||||
url: str | None = ""
|
||||
@@ -392,7 +398,7 @@ class Music(BaseMessageComponent):
|
||||
|
||||
|
||||
class Image(BaseMessageComponent):
|
||||
type = ComponentType.Image
|
||||
type: ComponentType = ComponentType.Image
|
||||
file: str | None = ""
|
||||
_type: str | None = ""
|
||||
subType: int | None = 0
|
||||
@@ -507,7 +513,7 @@ class Image(BaseMessageComponent):
|
||||
|
||||
|
||||
class Reply(BaseMessageComponent):
|
||||
type = ComponentType.Reply
|
||||
type: ComponentType = ComponentType.Reply
|
||||
id: str | int
|
||||
"""所引用的消息 ID"""
|
||||
chain: list["BaseMessageComponent"] | None = []
|
||||
@@ -543,7 +549,7 @@ class Poke(BaseMessageComponent):
|
||||
|
||||
|
||||
class Forward(BaseMessageComponent):
|
||||
type = ComponentType.Forward
|
||||
type: ComponentType = ComponentType.Forward
|
||||
id: str
|
||||
|
||||
def __init__(self, **_) -> None:
|
||||
@@ -553,7 +559,7 @@ class Forward(BaseMessageComponent):
|
||||
class Node(BaseMessageComponent):
|
||||
"""群合并转发消息"""
|
||||
|
||||
type = ComponentType.Node
|
||||
type: ComponentType = ComponentType.Node
|
||||
id: int | None = 0 # 忽略
|
||||
name: str | None = "" # qq昵称
|
||||
uin: str | None = "0" # qq号
|
||||
@@ -605,7 +611,7 @@ class Node(BaseMessageComponent):
|
||||
|
||||
|
||||
class Nodes(BaseMessageComponent):
|
||||
type = ComponentType.Nodes
|
||||
type: ComponentType = ComponentType.Nodes
|
||||
nodes: list[Node]
|
||||
|
||||
def __init__(self, nodes: list[Node], **_) -> None:
|
||||
@@ -631,7 +637,7 @@ class Nodes(BaseMessageComponent):
|
||||
|
||||
|
||||
class Json(BaseMessageComponent):
|
||||
type = ComponentType.Json
|
||||
type: ComponentType = ComponentType.Json
|
||||
data: dict
|
||||
|
||||
def __init__(self, data: str | dict, **_) -> None:
|
||||
@@ -641,14 +647,14 @@ class Json(BaseMessageComponent):
|
||||
|
||||
|
||||
class Unknown(BaseMessageComponent):
|
||||
type = ComponentType.Unknown
|
||||
type: ComponentType = ComponentType.Unknown
|
||||
text: str
|
||||
|
||||
|
||||
class File(BaseMessageComponent):
|
||||
"""文件消息段"""
|
||||
|
||||
type = ComponentType.File
|
||||
type: ComponentType = ComponentType.File
|
||||
name: str | None = "" # 名字
|
||||
file_: str | None = "" # 本地路径
|
||||
url: str | None = "" # url
|
||||
@@ -783,7 +789,7 @@ class File(BaseMessageComponent):
|
||||
|
||||
|
||||
class WechatEmoji(BaseMessageComponent):
|
||||
type = ComponentType.WechatEmoji
|
||||
type: ComponentType = ComponentType.WechatEmoji
|
||||
md5: str | None = ""
|
||||
md5_len: int | None = 0
|
||||
cdnurl: str | None = ""
|
||||
|
||||
@@ -8,9 +8,9 @@ from astrbot.core import logger
|
||||
from astrbot.core.message.message_event_result import MessageEventResult
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from astrbot.core.star.star import star_map
|
||||
from astrbot.core.star.star_handler import 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
|
||||
|
||||
|
||||
@@ -48,10 +48,20 @@ class StarRequestSubStage(Stage):
|
||||
yield ret
|
||||
event.clear_result() # 清除上一个 handler 的结果
|
||||
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}")
|
||||
|
||||
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}"
|
||||
event.set_result(MessageEventResult().message(ret))
|
||||
yield
|
||||
|
||||
@@ -33,6 +33,21 @@ class RespondStage(Stage):
|
||||
Comp.Nodes: lambda comp: bool(comp.nodes), # 多个转发节点
|
||||
Comp.File: lambda comp: bool(comp.file_ or comp.url),
|
||||
Comp.WechatEmoji: lambda comp: comp.md5 is not None, # 微信表情
|
||||
Comp.Json: lambda comp: bool(comp.data), # Json 卡片
|
||||
Comp.Share: lambda comp: bool(comp.url) or bool(comp.title),
|
||||
Comp.Music: lambda comp: (
|
||||
(comp.id and comp._type and comp._type != "custom")
|
||||
or (comp._type == "custom" and comp.url and comp.audio and comp.title)
|
||||
), # 音乐分享
|
||||
Comp.Forward: lambda comp: bool(comp.id), # 合并转发
|
||||
Comp.Location: lambda comp: bool(
|
||||
comp.lat is not None and comp.lon is not None
|
||||
), # 位置
|
||||
Comp.Contact: lambda comp: bool(comp._type and comp.id), # 推荐好友 or 群
|
||||
Comp.Shake: lambda _: True, # 窗口抖动(戳一戳)
|
||||
Comp.Dice: lambda _: True, # 掷骰子魔法表情
|
||||
Comp.RPS: lambda _: True, # 猜拳魔法表情
|
||||
Comp.Unknown: lambda comp: bool(comp.text and comp.text.strip()),
|
||||
}
|
||||
|
||||
async def initialize(self, ctx: PipelineContext) -> None:
|
||||
|
||||
@@ -315,6 +315,7 @@ class ResultDecorateStage(Stage):
|
||||
Record(
|
||||
file=url or audio_path,
|
||||
url=url or audio_path,
|
||||
text=comp.text,
|
||||
),
|
||||
)
|
||||
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 (
|
||||
WecomAIBotMessageEvent,
|
||||
)
|
||||
from astrbot.core.utils.active_event_registry import active_event_registry
|
||||
|
||||
from . import STAGES_ORDER
|
||||
from .context import PipelineContext
|
||||
@@ -79,10 +80,14 @@ class PipelineScheduler:
|
||||
event (AstrMessageEvent): 事件对象
|
||||
|
||||
"""
|
||||
await self._process_stages(event)
|
||||
active_event_registry.register(event)
|
||||
try:
|
||||
await self._process_stages(event)
|
||||
|
||||
# 如果没有发送操作, 则发送一个空消息, 以便于后续的处理
|
||||
if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent):
|
||||
await event.send(None)
|
||||
# 如果没有发送操作, 则发送一个空消息, 以便于后续的处理
|
||||
if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent):
|
||||
await event.send(None)
|
||||
|
||||
logger.debug("pipeline 执行完毕。")
|
||||
logger.debug("pipeline 执行完毕。")
|
||||
finally:
|
||||
active_event_registry.unregister(event)
|
||||
|
||||
@@ -176,6 +176,10 @@ class PlatformManager:
|
||||
from .sources.satori.satori_adapter import (
|
||||
SatoriPlatformAdapter, # noqa: F401
|
||||
)
|
||||
case "line":
|
||||
from .sources.line.line_adapter import (
|
||||
LinePlatformAdapter, # noqa: F401
|
||||
)
|
||||
except (ImportError, ModuleNotFoundError) as e:
|
||||
logger.error(
|
||||
f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->平台日志->安装Pip库 中安装依赖库。",
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
import asyncio
|
||||
import mimetypes
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import MessageChain
|
||||
from astrbot.api.message_components import At, File, Image, Plain, Record, Video
|
||||
from astrbot.api.platform import (
|
||||
AstrBotMessage,
|
||||
Group,
|
||||
MessageMember,
|
||||
MessageType,
|
||||
Platform,
|
||||
PlatformMetadata,
|
||||
)
|
||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
from astrbot.core.utils.webhook_utils import log_webhook_info
|
||||
|
||||
from ...register import register_platform_adapter
|
||||
from .line_api import LineAPIClient
|
||||
from .line_event import LineMessageEvent
|
||||
|
||||
LINE_CONFIG_METADATA = {
|
||||
"channel_access_token": {
|
||||
"description": "LINE Channel Access Token",
|
||||
"type": "string",
|
||||
"hint": "LINE Messaging API 的 channel access token。",
|
||||
},
|
||||
"channel_secret": {
|
||||
"description": "LINE Channel Secret",
|
||||
"type": "string",
|
||||
"hint": "用于校验 LINE Webhook 签名。",
|
||||
},
|
||||
}
|
||||
|
||||
LINE_I18N_RESOURCES = {
|
||||
"zh-CN": {
|
||||
"channel_access_token": {
|
||||
"description": "LINE Channel Access Token",
|
||||
"hint": "LINE Messaging API 的 channel access token。",
|
||||
},
|
||||
"channel_secret": {
|
||||
"description": "LINE Channel Secret",
|
||||
"hint": "用于校验 LINE Webhook 签名。",
|
||||
},
|
||||
},
|
||||
"en-US": {
|
||||
"channel_access_token": {
|
||||
"description": "LINE Channel Access Token",
|
||||
"hint": "Channel access token for LINE Messaging API.",
|
||||
},
|
||||
"channel_secret": {
|
||||
"description": "LINE Channel Secret",
|
||||
"hint": "Used to verify LINE webhook signatures.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@register_platform_adapter(
|
||||
"line",
|
||||
"LINE Messaging API 适配器",
|
||||
support_streaming_message=False,
|
||||
default_config_tmpl={
|
||||
"id": "line",
|
||||
"type": "line",
|
||||
"enable": False,
|
||||
"channel_access_token": "",
|
||||
"channel_secret": "",
|
||||
"unified_webhook_mode": True,
|
||||
"webhook_uuid": "",
|
||||
},
|
||||
config_metadata=LINE_CONFIG_METADATA,
|
||||
i18n_resources=LINE_I18N_RESOURCES,
|
||||
)
|
||||
class LinePlatformAdapter(Platform):
|
||||
def __init__(
|
||||
self,
|
||||
platform_config: dict,
|
||||
platform_settings: dict,
|
||||
event_queue: asyncio.Queue,
|
||||
) -> None:
|
||||
super().__init__(platform_config, event_queue)
|
||||
self.config["unified_webhook_mode"] = True
|
||||
self.destination = "unknown"
|
||||
self.settings = platform_settings
|
||||
self._event_id_timestamps: dict[str, float] = {}
|
||||
self.shutdown_event = asyncio.Event()
|
||||
|
||||
channel_access_token = str(platform_config.get("channel_access_token", ""))
|
||||
channel_secret = str(platform_config.get("channel_secret", ""))
|
||||
if not channel_access_token or not channel_secret:
|
||||
raise ValueError(
|
||||
"LINE 适配器需要 channel_access_token 和 channel_secret。",
|
||||
)
|
||||
|
||||
self.line_api = LineAPIClient(
|
||||
channel_access_token=channel_access_token,
|
||||
channel_secret=channel_secret,
|
||||
)
|
||||
|
||||
async def send_by_session(
|
||||
self,
|
||||
session: MessageSesion,
|
||||
message_chain: MessageChain,
|
||||
) -> None:
|
||||
messages = await LineMessageEvent.build_line_messages(message_chain)
|
||||
if messages:
|
||||
await self.line_api.push_message(session.session_id, messages)
|
||||
await super().send_by_session(session, message_chain)
|
||||
|
||||
def meta(self) -> PlatformMetadata:
|
||||
return PlatformMetadata(
|
||||
name="line",
|
||||
description="LINE Messaging API 适配器",
|
||||
id=cast(str, self.config.get("id", "line")),
|
||||
support_streaming_message=False,
|
||||
)
|
||||
|
||||
async def run(self) -> None:
|
||||
webhook_uuid = self.config.get("webhook_uuid")
|
||||
if webhook_uuid:
|
||||
log_webhook_info(f"{self.meta().id}(LINE)", webhook_uuid)
|
||||
else:
|
||||
logger.warning("[LINE] webhook_uuid 为空,统一 Webhook 可能无法接收消息。")
|
||||
await self.shutdown_event.wait()
|
||||
|
||||
async def terminate(self) -> None:
|
||||
self.shutdown_event.set()
|
||||
await self.line_api.close()
|
||||
|
||||
async def webhook_callback(self, request: Any) -> Any:
|
||||
raw_body = await request.get_data()
|
||||
signature = request.headers.get("x-line-signature")
|
||||
if not self.line_api.verify_signature(raw_body, signature):
|
||||
logger.warning("[LINE] invalid webhook signature")
|
||||
return "invalid signature", 400
|
||||
|
||||
try:
|
||||
payload = await request.get_json(force=True, silent=False)
|
||||
except Exception as e:
|
||||
logger.warning("[LINE] invalid webhook body: %s", e)
|
||||
return "bad request", 400
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return "bad request", 400
|
||||
|
||||
await self.handle_webhook_event(payload)
|
||||
return "ok", 200
|
||||
|
||||
async def handle_webhook_event(self, payload: dict[str, Any]) -> None:
|
||||
destination = str(payload.get("destination", "")).strip()
|
||||
if destination:
|
||||
self.destination = destination
|
||||
|
||||
events = payload.get("events")
|
||||
if not isinstance(events, list):
|
||||
return
|
||||
|
||||
for event in events:
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
|
||||
event_id = str(event.get("webhookEventId", ""))
|
||||
if event_id and self._is_duplicate_event(event_id):
|
||||
logger.debug("[LINE] duplicate event skipped: %s", event_id)
|
||||
continue
|
||||
|
||||
abm = await self.convert_message(event)
|
||||
if abm is None:
|
||||
continue
|
||||
await self.handle_msg(abm)
|
||||
|
||||
async def convert_message(self, event: dict[str, Any]) -> AstrBotMessage | None:
|
||||
if str(event.get("type", "")) != "message":
|
||||
return None
|
||||
if str(event.get("mode", "active")) == "standby":
|
||||
return None
|
||||
|
||||
source = event.get("source", {})
|
||||
if not isinstance(source, dict):
|
||||
return None
|
||||
|
||||
message = event.get("message", {})
|
||||
if not isinstance(message, dict):
|
||||
return None
|
||||
|
||||
source_type = str(source.get("type", ""))
|
||||
user_id = str(source.get("userId", "")).strip()
|
||||
group_id = str(source.get("groupId", "")).strip()
|
||||
room_id = str(source.get("roomId", "")).strip()
|
||||
|
||||
abm = AstrBotMessage()
|
||||
abm.self_id = self.destination or self.meta().id
|
||||
abm.message = []
|
||||
abm.raw_message = event
|
||||
abm.message_id = str(
|
||||
message.get("id")
|
||||
or event.get("webhookEventId")
|
||||
or event.get("deliveryContext", {}).get("deliveryId", "")
|
||||
or uuid.uuid4().hex
|
||||
)
|
||||
|
||||
event_timestamp = event.get("timestamp")
|
||||
if isinstance(event_timestamp, int):
|
||||
abm.timestamp = (
|
||||
event_timestamp // 1000
|
||||
if event_timestamp > 1_000_000_000_000
|
||||
else event_timestamp
|
||||
)
|
||||
else:
|
||||
abm.timestamp = int(time.time())
|
||||
|
||||
if source_type in {"group", "room"}:
|
||||
abm.type = MessageType.GROUP_MESSAGE
|
||||
container_id = group_id or room_id
|
||||
abm.group = Group(group_id=container_id, group_name=container_id)
|
||||
abm.session_id = container_id
|
||||
sender_id = user_id or container_id
|
||||
elif source_type == "user":
|
||||
abm.type = MessageType.FRIEND_MESSAGE
|
||||
abm.session_id = user_id
|
||||
sender_id = user_id
|
||||
else:
|
||||
abm.type = MessageType.OTHER_MESSAGE
|
||||
abm.session_id = user_id or group_id or room_id or "unknown"
|
||||
sender_id = abm.session_id
|
||||
|
||||
abm.sender = MessageMember(user_id=sender_id, nickname=sender_id[:8])
|
||||
|
||||
components = await self._parse_line_message_components(message)
|
||||
if not components:
|
||||
return None
|
||||
abm.message = components
|
||||
abm.message_str = self._build_message_str(components)
|
||||
return abm
|
||||
|
||||
async def _parse_line_message_components(
|
||||
self,
|
||||
message: dict[str, Any],
|
||||
) -> list:
|
||||
msg_type = str(message.get("type", ""))
|
||||
message_id = str(message.get("id", "")).strip()
|
||||
|
||||
if msg_type == "text":
|
||||
text = str(message.get("text", ""))
|
||||
mention = message.get("mention")
|
||||
if isinstance(mention, dict):
|
||||
return self._parse_text_with_mentions(text, mention)
|
||||
return [Plain(text=text)] if text else []
|
||||
|
||||
if msg_type == "image":
|
||||
image_component = await self._build_image_component(message_id, message)
|
||||
return [image_component] if image_component else [Plain(text="[image]")]
|
||||
|
||||
if msg_type == "video":
|
||||
video_component = await self._build_video_component(message_id, message)
|
||||
return [video_component] if video_component else [Plain(text="[video]")]
|
||||
|
||||
if msg_type == "audio":
|
||||
audio_component = await self._build_audio_component(message_id, message)
|
||||
return [audio_component] if audio_component else [Plain(text="[audio]")]
|
||||
|
||||
if msg_type == "file":
|
||||
file_component = await self._build_file_component(message_id, message)
|
||||
return [file_component] if file_component else [Plain(text="[file]")]
|
||||
|
||||
if msg_type == "sticker":
|
||||
return [Plain(text="[sticker]")]
|
||||
|
||||
return [Plain(text=f"[{msg_type}]")]
|
||||
|
||||
def _parse_text_with_mentions(self, text: str, mention_obj: dict[str, Any]) -> list:
|
||||
mentions = mention_obj.get("mentionees", [])
|
||||
if not isinstance(mentions, list) or not mentions:
|
||||
return [Plain(text=text)] if text else []
|
||||
|
||||
normalized = []
|
||||
for item in mentions:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
start = item.get("index")
|
||||
length = item.get("length")
|
||||
if not isinstance(start, int) or not isinstance(length, int):
|
||||
continue
|
||||
normalized.append((start, length, item))
|
||||
normalized.sort(key=lambda x: x[0])
|
||||
|
||||
ret = []
|
||||
cursor = 0
|
||||
for start, length, item in normalized:
|
||||
if start > cursor:
|
||||
part = text[cursor:start]
|
||||
if part:
|
||||
ret.append(Plain(text=part))
|
||||
|
||||
label = text[start : start + length] or "@user"
|
||||
mention_type = str(item.get("type", ""))
|
||||
if mention_type == "user":
|
||||
target_id = str(item.get("userId", "")).strip()
|
||||
ret.append(At(qq=target_id, name=label.lstrip("@")))
|
||||
else:
|
||||
ret.append(Plain(text=label))
|
||||
cursor = max(cursor, start + length)
|
||||
|
||||
if cursor < len(text):
|
||||
tail = text[cursor:]
|
||||
if tail:
|
||||
ret.append(Plain(text=tail))
|
||||
return ret
|
||||
|
||||
async def _build_image_component(
|
||||
self,
|
||||
message_id: str,
|
||||
message: dict[str, Any],
|
||||
) -> Image | None:
|
||||
external_url = self._get_external_content_url(message)
|
||||
if external_url:
|
||||
return Image.fromURL(external_url)
|
||||
|
||||
content = await self.line_api.get_message_content(message_id)
|
||||
if not content:
|
||||
return None
|
||||
content_bytes, _, _ = content
|
||||
return Image.fromBytes(content_bytes)
|
||||
|
||||
async def _build_video_component(
|
||||
self,
|
||||
message_id: str,
|
||||
message: dict[str, Any],
|
||||
) -> Video | None:
|
||||
external_url = self._get_external_content_url(message)
|
||||
if external_url:
|
||||
return Video.fromURL(external_url)
|
||||
|
||||
content = await self.line_api.get_message_content(message_id)
|
||||
if not content:
|
||||
return None
|
||||
content_bytes, content_type, _ = content
|
||||
suffix = self._guess_suffix(content_type, ".mp4")
|
||||
file_path = self._store_temp_content("video", message_id, content_bytes, suffix)
|
||||
return Video(file=file_path, path=file_path)
|
||||
|
||||
async def _build_audio_component(
|
||||
self,
|
||||
message_id: str,
|
||||
message: dict[str, Any],
|
||||
) -> Record | None:
|
||||
external_url = self._get_external_content_url(message)
|
||||
if external_url:
|
||||
return Record.fromURL(external_url)
|
||||
|
||||
content = await self.line_api.get_message_content(message_id)
|
||||
if not content:
|
||||
return None
|
||||
content_bytes, content_type, _ = content
|
||||
suffix = self._guess_suffix(content_type, ".m4a")
|
||||
file_path = self._store_temp_content("audio", message_id, content_bytes, suffix)
|
||||
return Record(file=file_path, url=file_path)
|
||||
|
||||
async def _build_file_component(
|
||||
self,
|
||||
message_id: str,
|
||||
message: dict[str, Any],
|
||||
) -> File | None:
|
||||
content = await self.line_api.get_message_content(message_id)
|
||||
if not content:
|
||||
return None
|
||||
content_bytes, content_type, filename = content
|
||||
default_name = str(message.get("fileName", "")).strip() or f"{message_id}.bin"
|
||||
suffix = Path(default_name).suffix or self._guess_suffix(content_type, ".bin")
|
||||
final_name = filename or default_name
|
||||
file_path = self._store_temp_content(
|
||||
"file",
|
||||
message_id,
|
||||
content_bytes,
|
||||
suffix,
|
||||
original_name=final_name,
|
||||
)
|
||||
return File(name=final_name, file=file_path, url=file_path)
|
||||
|
||||
@staticmethod
|
||||
def _get_external_content_url(message: dict[str, Any]) -> str:
|
||||
provider = message.get("contentProvider")
|
||||
if not isinstance(provider, dict):
|
||||
return ""
|
||||
if str(provider.get("type", "")) != "external":
|
||||
return ""
|
||||
return str(provider.get("originalContentUrl", "")).strip()
|
||||
|
||||
@staticmethod
|
||||
def _guess_suffix(content_type: str | None, fallback: str) -> str:
|
||||
if not content_type:
|
||||
return fallback
|
||||
base_type = content_type.split(";", 1)[0].strip().lower()
|
||||
guessed = mimetypes.guess_extension(base_type)
|
||||
if guessed:
|
||||
return guessed
|
||||
return fallback
|
||||
|
||||
@staticmethod
|
||||
def _store_temp_content(
|
||||
content_type: str,
|
||||
message_id: str,
|
||||
content: bytes,
|
||||
suffix: str,
|
||||
original_name: str = "",
|
||||
) -> str:
|
||||
temp_dir = Path(get_astrbot_temp_path())
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
name_prefix = f"line_{content_type}"
|
||||
if original_name:
|
||||
safe_stem = Path(original_name).stem.strip()
|
||||
safe_stem = "".join(
|
||||
ch if ch.isalnum() or ch in ("-", "_", ".") else "_" for ch in safe_stem
|
||||
)
|
||||
safe_stem = safe_stem.strip("._")
|
||||
if safe_stem:
|
||||
name_prefix = safe_stem[:64]
|
||||
file_path = temp_dir / f"{name_prefix}_{message_id}_{uuid.uuid4().hex[:6]}"
|
||||
file_path = file_path.with_suffix(suffix)
|
||||
file_path.write_bytes(content)
|
||||
return str(file_path.resolve())
|
||||
|
||||
@staticmethod
|
||||
def _build_message_str(components: list) -> str:
|
||||
parts: list[str] = []
|
||||
for comp in components:
|
||||
if isinstance(comp, Plain):
|
||||
parts.append(comp.text)
|
||||
elif isinstance(comp, At):
|
||||
parts.append(f"@{comp.name or comp.qq}")
|
||||
elif isinstance(comp, Image):
|
||||
parts.append("[image]")
|
||||
elif isinstance(comp, Video):
|
||||
parts.append("[video]")
|
||||
elif isinstance(comp, Record):
|
||||
parts.append("[audio]")
|
||||
elif isinstance(comp, File):
|
||||
parts.append(str(comp.name or "[file]"))
|
||||
else:
|
||||
parts.append(f"[{comp.type}]")
|
||||
return " ".join(i for i in parts if i).strip()
|
||||
|
||||
def _clean_expired_events(self) -> None:
|
||||
current = time.time()
|
||||
expired = [
|
||||
event_id
|
||||
for event_id, ts in self._event_id_timestamps.items()
|
||||
if current - ts > 1800
|
||||
]
|
||||
for event_id in expired:
|
||||
del self._event_id_timestamps[event_id]
|
||||
|
||||
def _is_duplicate_event(self, event_id: str) -> bool:
|
||||
self._clean_expired_events()
|
||||
if event_id in self._event_id_timestamps:
|
||||
return True
|
||||
self._event_id_timestamps[event_id] = time.time()
|
||||
return False
|
||||
|
||||
async def handle_msg(self, abm: AstrBotMessage) -> None:
|
||||
event = LineMessageEvent(
|
||||
message_str=abm.message_str,
|
||||
message_obj=abm,
|
||||
platform_meta=self.meta(),
|
||||
session_id=abm.session_id,
|
||||
line_api=self.line_api,
|
||||
)
|
||||
self._event_queue.put_nowait(event)
|
||||
@@ -0,0 +1,203 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import hmac
|
||||
import json
|
||||
from hashlib import sha256
|
||||
from typing import Any
|
||||
from urllib.parse import unquote
|
||||
|
||||
import aiohttp
|
||||
|
||||
from astrbot.api import logger
|
||||
|
||||
|
||||
class LineAPIClient:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
channel_access_token: str,
|
||||
channel_secret: str,
|
||||
timeout_seconds: int = 30,
|
||||
) -> None:
|
||||
self.channel_access_token = channel_access_token.strip()
|
||||
self.channel_secret = channel_secret.strip()
|
||||
self.timeout = aiohttp.ClientTimeout(total=timeout_seconds)
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = aiohttp.ClientSession(timeout=self.timeout)
|
||||
return self._session
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
|
||||
def verify_signature(self, raw_body: bytes, signature: str | None) -> bool:
|
||||
if not signature:
|
||||
return False
|
||||
digest = hmac.new(
|
||||
self.channel_secret.encode("utf-8"),
|
||||
raw_body,
|
||||
sha256,
|
||||
).digest()
|
||||
expected = base64.b64encode(digest).decode("utf-8")
|
||||
return hmac.compare_digest(expected, signature.strip())
|
||||
|
||||
@property
|
||||
def _auth_headers(self) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {self.channel_access_token}"}
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
reply_token: str,
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
notification_disabled: bool = False,
|
||||
) -> bool:
|
||||
payload = {
|
||||
"replyToken": reply_token,
|
||||
"messages": messages[:5],
|
||||
"notificationDisabled": notification_disabled,
|
||||
}
|
||||
return await self._post_json(
|
||||
"https://api.line.me/v2/bot/message/reply",
|
||||
payload=payload,
|
||||
op_name="reply",
|
||||
)
|
||||
|
||||
async def push_message(
|
||||
self,
|
||||
to: str,
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
notification_disabled: bool = False,
|
||||
) -> bool:
|
||||
payload = {
|
||||
"to": to,
|
||||
"messages": messages[:5],
|
||||
"notificationDisabled": notification_disabled,
|
||||
}
|
||||
return await self._post_json(
|
||||
"https://api.line.me/v2/bot/message/push",
|
||||
payload=payload,
|
||||
op_name="push",
|
||||
)
|
||||
|
||||
async def _post_json(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
payload: dict[str, Any],
|
||||
op_name: str,
|
||||
) -> bool:
|
||||
session = await self._get_session()
|
||||
headers = {
|
||||
**self._auth_headers,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
try:
|
||||
async with session.post(url, json=payload, headers=headers) as resp:
|
||||
if resp.status < 400:
|
||||
return True
|
||||
body = await resp.text()
|
||||
logger.error(
|
||||
"[LINE] %s message failed: status=%s body=%s",
|
||||
op_name,
|
||||
resp.status,
|
||||
body,
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error("[LINE] %s message request failed: %s", op_name, e)
|
||||
return False
|
||||
|
||||
async def get_message_content(
|
||||
self,
|
||||
message_id: str,
|
||||
) -> tuple[bytes, str | None, str | None] | None:
|
||||
session = await self._get_session()
|
||||
url = f"https://api-data.line.me/v2/bot/message/{message_id}/content"
|
||||
headers = self._auth_headers
|
||||
|
||||
async with session.get(url, headers=headers) as resp:
|
||||
if resp.status == 202:
|
||||
if not await self._wait_for_transcoding(message_id):
|
||||
return None
|
||||
async with session.get(url, headers=headers) as retry_resp:
|
||||
if retry_resp.status != 200:
|
||||
body = await retry_resp.text()
|
||||
logger.warning(
|
||||
"[LINE] get content retry failed: message_id=%s status=%s body=%s",
|
||||
message_id,
|
||||
retry_resp.status,
|
||||
body,
|
||||
)
|
||||
return None
|
||||
return await self._read_content_response(retry_resp)
|
||||
|
||||
if resp.status != 200:
|
||||
body = await resp.text()
|
||||
logger.warning(
|
||||
"[LINE] get content failed: message_id=%s status=%s body=%s",
|
||||
message_id,
|
||||
resp.status,
|
||||
body,
|
||||
)
|
||||
return None
|
||||
return await self._read_content_response(resp)
|
||||
|
||||
async def _read_content_response(
|
||||
self,
|
||||
resp: aiohttp.ClientResponse,
|
||||
) -> tuple[bytes, str | None, str | None]:
|
||||
content = await resp.read()
|
||||
content_type = resp.headers.get("Content-Type")
|
||||
disposition = resp.headers.get("Content-Disposition")
|
||||
filename = self._extract_filename_from_disposition(disposition)
|
||||
return content, content_type, filename
|
||||
|
||||
def _extract_filename_from_disposition(self, disposition: str | None) -> str | None:
|
||||
if not disposition:
|
||||
return None
|
||||
for part in disposition.split(";"):
|
||||
token = part.strip()
|
||||
if token.startswith("filename*="):
|
||||
val = token.split("=", 1)[1].strip().strip('"')
|
||||
if val.lower().startswith("utf-8''"):
|
||||
val = val[7:]
|
||||
return unquote(val)
|
||||
if token.startswith("filename="):
|
||||
return token.split("=", 1)[1].strip().strip('"')
|
||||
return None
|
||||
|
||||
async def _wait_for_transcoding(
|
||||
self,
|
||||
message_id: str,
|
||||
*,
|
||||
max_attempts: int = 10,
|
||||
interval_seconds: float = 1.0,
|
||||
) -> bool:
|
||||
session = await self._get_session()
|
||||
url = (
|
||||
f"https://api-data.line.me/v2/bot/message/{message_id}/content/transcoding"
|
||||
)
|
||||
headers = self._auth_headers
|
||||
|
||||
for _ in range(max_attempts):
|
||||
try:
|
||||
async with session.get(url, headers=headers) as resp:
|
||||
if resp.status != 200:
|
||||
await asyncio.sleep(interval_seconds)
|
||||
continue
|
||||
body = await resp.text()
|
||||
data = json.loads(body)
|
||||
status = str(data.get("status", "")).lower()
|
||||
if status == "succeeded":
|
||||
return True
|
||||
if status == "failed":
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(interval_seconds)
|
||||
return False
|
||||
@@ -0,0 +1,285 @@
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator
|
||||
from pathlib import Path
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.message_components import (
|
||||
At,
|
||||
BaseMessageComponent,
|
||||
File,
|
||||
Image,
|
||||
Plain,
|
||||
Record,
|
||||
Video,
|
||||
)
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
from astrbot.core.utils.media_utils import get_media_duration
|
||||
|
||||
from .line_api import LineAPIClient
|
||||
|
||||
|
||||
class LineMessageEvent(AstrMessageEvent):
|
||||
def __init__(
|
||||
self,
|
||||
message_str,
|
||||
message_obj,
|
||||
platform_meta,
|
||||
session_id,
|
||||
line_api: LineAPIClient,
|
||||
) -> None:
|
||||
super().__init__(message_str, message_obj, platform_meta, session_id)
|
||||
self.line_api = line_api
|
||||
|
||||
@staticmethod
|
||||
async def _component_to_message_object(
|
||||
segment: BaseMessageComponent,
|
||||
) -> dict | None:
|
||||
if isinstance(segment, Plain):
|
||||
text = segment.text.strip()
|
||||
if not text:
|
||||
return None
|
||||
return {"type": "text", "text": text[:5000]}
|
||||
|
||||
if isinstance(segment, At):
|
||||
name = str(segment.name or segment.qq or "").strip()
|
||||
if not name:
|
||||
return None
|
||||
return {"type": "text", "text": f"@{name}"[:5000]}
|
||||
|
||||
if isinstance(segment, Image):
|
||||
image_url = await LineMessageEvent._resolve_image_url(segment)
|
||||
if not image_url:
|
||||
return None
|
||||
return {
|
||||
"type": "image",
|
||||
"originalContentUrl": image_url,
|
||||
"previewImageUrl": image_url,
|
||||
}
|
||||
|
||||
if isinstance(segment, Record):
|
||||
audio_url = await LineMessageEvent._resolve_record_url(segment)
|
||||
if not audio_url:
|
||||
return None
|
||||
duration = await LineMessageEvent._resolve_record_duration(segment)
|
||||
return {
|
||||
"type": "audio",
|
||||
"originalContentUrl": audio_url,
|
||||
"duration": duration,
|
||||
}
|
||||
|
||||
if isinstance(segment, Video):
|
||||
video_url = await LineMessageEvent._resolve_video_url(segment)
|
||||
if not video_url:
|
||||
return None
|
||||
preview_url = await LineMessageEvent._resolve_video_preview_url(segment)
|
||||
if not preview_url:
|
||||
return None
|
||||
return {
|
||||
"type": "video",
|
||||
"originalContentUrl": video_url,
|
||||
"previewImageUrl": preview_url,
|
||||
}
|
||||
|
||||
if isinstance(segment, File):
|
||||
file_url = await LineMessageEvent._resolve_file_url(segment)
|
||||
if not file_url:
|
||||
return None
|
||||
file_name = str(segment.name or "").strip() or "file.bin"
|
||||
file_size = await LineMessageEvent._resolve_file_size(segment)
|
||||
if file_size <= 0:
|
||||
return None
|
||||
return {
|
||||
"type": "file",
|
||||
"fileName": file_name,
|
||||
"fileSize": file_size,
|
||||
"originalContentUrl": file_url,
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_image_url(segment: Image) -> str:
|
||||
candidate = (segment.url or segment.file or "").strip()
|
||||
if candidate.startswith("http://") or candidate.startswith("https://"):
|
||||
return candidate
|
||||
try:
|
||||
return await segment.register_to_file_service()
|
||||
except Exception as e:
|
||||
logger.debug("[LINE] resolve image url failed: %s", e)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_record_url(segment: Record) -> str:
|
||||
candidate = (segment.url or segment.file or "").strip()
|
||||
if candidate.startswith("http://") or candidate.startswith("https://"):
|
||||
return candidate
|
||||
try:
|
||||
return await segment.register_to_file_service()
|
||||
except Exception as e:
|
||||
logger.debug("[LINE] resolve record url failed: %s", e)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_record_duration(segment: Record) -> int:
|
||||
try:
|
||||
file_path = await segment.convert_to_file_path()
|
||||
duration_ms = await get_media_duration(file_path)
|
||||
if isinstance(duration_ms, int) and duration_ms > 0:
|
||||
return duration_ms
|
||||
except Exception as e:
|
||||
logger.debug("[LINE] resolve record duration failed: %s", e)
|
||||
return 1000
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_video_url(segment: Video) -> str:
|
||||
candidate = (segment.file or "").strip()
|
||||
if candidate.startswith("http://") or candidate.startswith("https://"):
|
||||
return candidate
|
||||
try:
|
||||
return await segment.register_to_file_service()
|
||||
except Exception as e:
|
||||
logger.debug("[LINE] resolve video url failed: %s", e)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_video_preview_url(segment: Video) -> str:
|
||||
cover_candidate = (segment.cover or "").strip()
|
||||
if cover_candidate.startswith("http://") or cover_candidate.startswith(
|
||||
"https://"
|
||||
):
|
||||
return cover_candidate
|
||||
|
||||
if cover_candidate:
|
||||
try:
|
||||
cover_seg = Image(file=cover_candidate)
|
||||
return await cover_seg.register_to_file_service()
|
||||
except Exception as e:
|
||||
logger.debug("[LINE] resolve video cover failed: %s", e)
|
||||
|
||||
try:
|
||||
video_path = await segment.convert_to_file_path()
|
||||
temp_dir = Path(get_astrbot_temp_path())
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
thumb_path = temp_dir / f"line_video_preview_{uuid.uuid4().hex}.jpg"
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-ss",
|
||||
"00:00:01",
|
||||
"-i",
|
||||
video_path,
|
||||
"-frames:v",
|
||||
"1",
|
||||
str(thumb_path),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
await process.communicate()
|
||||
if process.returncode != 0 or not thumb_path.exists():
|
||||
return ""
|
||||
|
||||
cover_seg = Image.fromFileSystem(str(thumb_path))
|
||||
return await cover_seg.register_to_file_service()
|
||||
except Exception as e:
|
||||
logger.debug("[LINE] generate video preview failed: %s", e)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_file_url(segment: File) -> str:
|
||||
if segment.url and segment.url.startswith(("http://", "https://")):
|
||||
return segment.url
|
||||
try:
|
||||
return await segment.register_to_file_service()
|
||||
except Exception as e:
|
||||
logger.debug("[LINE] resolve file url failed: %s", e)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_file_size(segment: File) -> int:
|
||||
try:
|
||||
file_path = await segment.get_file(allow_return_url=False)
|
||||
if file_path and os.path.exists(file_path):
|
||||
return int(os.path.getsize(file_path))
|
||||
except Exception as e:
|
||||
logger.debug("[LINE] resolve file size failed: %s", e)
|
||||
return 0
|
||||
|
||||
@classmethod
|
||||
async def build_line_messages(cls, message_chain: MessageChain) -> list[dict]:
|
||||
messages: list[dict] = []
|
||||
for segment in message_chain.chain:
|
||||
obj = await cls._component_to_message_object(segment)
|
||||
if obj:
|
||||
messages.append(obj)
|
||||
|
||||
if not messages:
|
||||
return []
|
||||
|
||||
if len(messages) > 5:
|
||||
logger.warning(
|
||||
"[LINE] message count exceeds 5, extra segments will be dropped."
|
||||
)
|
||||
messages = messages[:5]
|
||||
return messages
|
||||
|
||||
async def send(self, message: MessageChain) -> None:
|
||||
messages = await self.build_line_messages(message)
|
||||
if not messages:
|
||||
return
|
||||
|
||||
raw = self.message_obj.raw_message
|
||||
reply_token = ""
|
||||
if isinstance(raw, dict):
|
||||
reply_token = str(raw.get("replyToken") or "")
|
||||
|
||||
sent = False
|
||||
if reply_token:
|
||||
sent = await self.line_api.reply_message(reply_token, messages)
|
||||
|
||||
if not sent:
|
||||
target_id = self.get_group_id() or self.get_sender_id()
|
||||
if target_id:
|
||||
await self.line_api.push_message(target_id, messages)
|
||||
|
||||
await super().send(message)
|
||||
|
||||
async def send_streaming(
|
||||
self,
|
||||
generator: AsyncGenerator,
|
||||
use_fallback: bool = False,
|
||||
):
|
||||
if not use_fallback:
|
||||
buffer = None
|
||||
async for chain in generator:
|
||||
if not buffer:
|
||||
buffer = chain
|
||||
else:
|
||||
buffer.chain.extend(chain.chain)
|
||||
if not buffer:
|
||||
return None
|
||||
buffer.squash_plain()
|
||||
await self.send(buffer)
|
||||
return await super().send_streaming(generator, use_fallback)
|
||||
|
||||
buffer = ""
|
||||
pattern = re.compile(r"[^。?!~…]+[。?!~…]+")
|
||||
|
||||
async for chain in generator:
|
||||
if isinstance(chain, MessageChain):
|
||||
for comp in chain.chain:
|
||||
if isinstance(comp, Plain):
|
||||
buffer += comp.text
|
||||
if any(p in buffer for p in "。?!~…"):
|
||||
buffer = await self.process_buffer(buffer, pattern)
|
||||
else:
|
||||
await self.send(MessageChain(chain=[comp]))
|
||||
await asyncio.sleep(1.5)
|
||||
|
||||
if buffer.strip():
|
||||
await self.send(MessageChain([Plain(buffer)]))
|
||||
return await super().send_streaming(generator, use_fallback)
|
||||
@@ -7,13 +7,14 @@ from typing import cast
|
||||
|
||||
import aiofiles
|
||||
import botpy
|
||||
import botpy.errors
|
||||
import botpy.message
|
||||
import botpy.types
|
||||
import botpy.types.message
|
||||
from botpy import Client
|
||||
from botpy.http import Route
|
||||
from botpy.types import message
|
||||
from botpy.types.message import Media
|
||||
from botpy.types.message import MarkdownPayload, Media
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
@@ -25,6 +26,8 @@ from astrbot.core.utils.tencent_record_helper import wav_to_tencent_silk
|
||||
|
||||
|
||||
class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
MARKDOWN_NOT_ALLOWED_ERROR = "不允许发送原生 markdown"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message_str: str,
|
||||
@@ -114,7 +117,9 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
return None
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -145,9 +150,13 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
)
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
ret = await self.bot.api.post_group_message(
|
||||
group_openid=source.group_openid,
|
||||
**payload,
|
||||
ret = await self._send_with_markdown_fallback(
|
||||
send_func=lambda retry_payload: self.bot.api.post_group_message(
|
||||
group_openid=source.group_openid, # type: ignore
|
||||
**retry_payload,
|
||||
),
|
||||
payload=payload,
|
||||
plain_text=plain_text,
|
||||
)
|
||||
|
||||
case botpy.message.C2CMessage():
|
||||
@@ -168,30 +177,49 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
if stream:
|
||||
ret = await self.post_c2c_message(
|
||||
openid=source.author.user_openid,
|
||||
**payload,
|
||||
stream=stream,
|
||||
ret = await self._send_with_markdown_fallback(
|
||||
send_func=lambda retry_payload: self.post_c2c_message(
|
||||
openid=source.author.user_openid,
|
||||
**retry_payload,
|
||||
stream=stream,
|
||||
),
|
||||
payload=payload,
|
||||
plain_text=plain_text,
|
||||
)
|
||||
else:
|
||||
ret = await self.post_c2c_message(
|
||||
openid=source.author.user_openid,
|
||||
**payload,
|
||||
ret = await self._send_with_markdown_fallback(
|
||||
send_func=lambda retry_payload: self.post_c2c_message(
|
||||
openid=source.author.user_openid,
|
||||
**retry_payload,
|
||||
),
|
||||
payload=payload,
|
||||
plain_text=plain_text,
|
||||
)
|
||||
logger.debug(f"Message sent to C2C: {ret}")
|
||||
|
||||
case botpy.message.Message():
|
||||
if image_path:
|
||||
payload["file_image"] = image_path
|
||||
ret = await self.bot.api.post_message(
|
||||
channel_id=source.channel_id,
|
||||
**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():
|
||||
if image_path:
|
||||
payload["file_image"] = image_path
|
||||
ret = await self.bot.api.post_dms(guild_id=source.guild_id, **payload)
|
||||
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 _:
|
||||
pass
|
||||
@@ -202,6 +230,32 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
|
||||
return ret
|
||||
|
||||
async def _send_with_markdown_fallback(
|
||||
self,
|
||||
send_func,
|
||||
payload: dict,
|
||||
plain_text: str,
|
||||
):
|
||||
try:
|
||||
return await send_func(payload)
|
||||
except botpy.errors.ServerError as err:
|
||||
if (
|
||||
self.MARKDOWN_NOT_ALLOWED_ERROR not in str(err)
|
||||
or not payload.get("markdown")
|
||||
or not plain_text
|
||||
):
|
||||
raise
|
||||
|
||||
logger.warning(
|
||||
"[QQOfficial] markdown 发送被拒绝,回退到 content 模式重试。"
|
||||
)
|
||||
fallback_payload = payload.copy()
|
||||
fallback_payload["markdown"] = None
|
||||
fallback_payload["content"] = plain_text
|
||||
if fallback_payload.get("msg_type") == 2:
|
||||
fallback_payload["msg_type"] = 0
|
||||
return await send_func(fallback_payload)
|
||||
|
||||
async def upload_group_and_c2c_image(
|
||||
self,
|
||||
image_base64: str,
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any, cast
|
||||
import telegramify_markdown
|
||||
from telegram import ReactionTypeCustomEmoji, ReactionTypeEmoji
|
||||
from telegram.constants import ChatAction
|
||||
from telegram.error import BadRequest
|
||||
from telegram.ext import ExtBot
|
||||
|
||||
from astrbot import logger
|
||||
@@ -119,6 +120,65 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
client, user_name, ChatAction.TYPING, message_thread_id
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _send_voice_with_fallback(
|
||||
cls,
|
||||
client: ExtBot,
|
||||
path: str,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
caption: str | None = None,
|
||||
user_name: str = "",
|
||||
message_thread_id: str | None = None,
|
||||
use_media_action: bool = False,
|
||||
) -> None:
|
||||
"""Send a voice message, falling back to a document if the user's
|
||||
privacy settings forbid voice messages (``BadRequest`` with
|
||||
``Voice_messages_forbidden``).
|
||||
|
||||
When *use_media_action* is ``True`` the helper wraps the send calls
|
||||
with ``_send_media_with_action`` (used by the streaming path).
|
||||
"""
|
||||
try:
|
||||
if use_media_action:
|
||||
await cls._send_media_with_action(
|
||||
client,
|
||||
ChatAction.UPLOAD_VOICE,
|
||||
client.send_voice,
|
||||
user_name=user_name,
|
||||
message_thread_id=message_thread_id,
|
||||
voice=path,
|
||||
**cast(Any, payload),
|
||||
)
|
||||
else:
|
||||
await client.send_voice(voice=path, **cast(Any, payload))
|
||||
except BadRequest as e:
|
||||
# python-telegram-bot raises BadRequest for Voice_messages_forbidden;
|
||||
# distinguish the voice-privacy case via the API error message.
|
||||
if "Voice_messages_forbidden" not in e.message:
|
||||
raise
|
||||
logger.warning(
|
||||
"User privacy settings prevent receiving voice messages, falling back to sending an audio file. "
|
||||
"To enable voice messages, go to Telegram Settings → Privacy and Security → Voice Messages → set to 'Everyone'."
|
||||
)
|
||||
if use_media_action:
|
||||
await cls._send_media_with_action(
|
||||
client,
|
||||
ChatAction.UPLOAD_DOCUMENT,
|
||||
client.send_document,
|
||||
user_name=user_name,
|
||||
message_thread_id=message_thread_id,
|
||||
document=path,
|
||||
caption=caption,
|
||||
**cast(Any, payload),
|
||||
)
|
||||
else:
|
||||
await client.send_document(
|
||||
document=path,
|
||||
caption=caption,
|
||||
**cast(Any, payload),
|
||||
)
|
||||
|
||||
async def _ensure_typing(
|
||||
self,
|
||||
user_name: str,
|
||||
@@ -211,7 +271,13 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
)
|
||||
elif isinstance(i, Record):
|
||||
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:
|
||||
if self.get_message_type() == MessageType.GROUP_MESSAGE:
|
||||
@@ -330,14 +396,14 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
continue
|
||||
elif isinstance(i, Record):
|
||||
path = await i.convert_to_file_path()
|
||||
await self._send_media_with_action(
|
||||
await self._send_voice_with_fallback(
|
||||
self.client,
|
||||
ChatAction.UPLOAD_VOICE,
|
||||
self.client.send_voice,
|
||||
path,
|
||||
payload,
|
||||
caption=i.text or delta or None,
|
||||
user_name=user_name,
|
||||
message_thread_id=message_thread_id,
|
||||
voice=path,
|
||||
**cast(Any, payload),
|
||||
use_media_action=True,
|
||||
)
|
||||
continue
|
||||
else:
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, cast
|
||||
|
||||
import quart
|
||||
from requests import Response
|
||||
from wechatpy import WeChatClient, parse_message
|
||||
from wechatpy import WeChatClient, create_reply, parse_message
|
||||
from wechatpy.crypto import WeChatCrypto
|
||||
from wechatpy.exceptions import InvalidSignatureException
|
||||
from wechatpy.messages import BaseMessage, ImageMessage, TextMessage, VoiceMessage
|
||||
@@ -38,7 +39,12 @@ else:
|
||||
|
||||
|
||||
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.port = int(cast(int | str, config.get("port")))
|
||||
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.shutdown_event = asyncio.Event()
|
||||
|
||||
self._wx_msg_time_out = 4.0 # 微信服务器要求 5 秒内回复
|
||||
self.user_buffer: dict[str, dict[str, Any]] = user_buffer # from_user -> state
|
||||
self.active_send_mode = False # 是否启用主动发送模式,启用后 callback 将直接返回回复内容,无需等待微信回调
|
||||
|
||||
async def verify(self):
|
||||
"""内部服务器的 GET 验证入口"""
|
||||
return await self.handle_verify(quart.request)
|
||||
@@ -98,6 +108,22 @@ class WeixinOfficialAccountServer:
|
||||
"""内部服务器的 POST 回调入口"""
|
||||
return await self.handle_callback(quart.request)
|
||||
|
||||
def _maybe_encrypt(self, xml: str, nonce: str | None, timestamp: str | None) -> str:
|
||||
if xml and "<Encrypt>" not in xml and nonce and timestamp:
|
||||
return self.crypto.encrypt_message(xml, nonce, timestamp)
|
||||
return xml or "success"
|
||||
|
||||
def _preview(self, msg: BaseMessage, limit: int = 24) -> str:
|
||||
"""生成消息预览文本,供占位符使用"""
|
||||
if isinstance(msg, TextMessage):
|
||||
t = cast(str, msg.content).strip()
|
||||
return (t[:limit] + "...") if len(t) > limit else (t or "空消息")
|
||||
if isinstance(msg, ImageMessage):
|
||||
return "图片"
|
||||
if isinstance(msg, VoiceMessage):
|
||||
return "语音"
|
||||
return getattr(msg, "type", "未知消息")
|
||||
|
||||
async def handle_callback(self, request) -> str:
|
||||
"""处理回调请求,可被统一 webhook 入口复用
|
||||
|
||||
@@ -123,14 +149,152 @@ class WeixinOfficialAccountServer:
|
||||
raise
|
||||
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)
|
||||
if not result_xml:
|
||||
return "success"
|
||||
if isinstance(result_xml, str):
|
||||
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:
|
||||
logger.info(
|
||||
@@ -176,7 +340,10 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
|
||||
if not self.api_base_url.endswith("/"):
|
||||
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.config["appid"].strip(),
|
||||
@@ -193,28 +360,33 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
|
||||
try:
|
||||
if self.active_send_mode:
|
||||
await self.convert_message(msg, None)
|
||||
return None
|
||||
|
||||
msg_id = str(cast(str | int, msg.id))
|
||||
future = self.wexin_event_workers.get(msg_id)
|
||||
if future:
|
||||
logger.debug(f"duplicate message id checked: {msg.id}")
|
||||
else:
|
||||
if str(msg.id) in self.wexin_event_workers:
|
||||
future = self.wexin_event_workers[str(cast(str | int, msg.id))]
|
||||
logger.debug(f"duplicate message id checked: {msg.id}")
|
||||
else:
|
||||
future = asyncio.get_event_loop().create_future()
|
||||
self.wexin_event_workers[str(cast(str | int, msg.id))] = future
|
||||
await self.convert_message(msg, future)
|
||||
future = asyncio.get_event_loop().create_future()
|
||||
self.wexin_event_workers[msg_id] = future
|
||||
await self.convert_message(msg, future)
|
||||
# I love shield so much!
|
||||
result = await asyncio.wait_for(
|
||||
asyncio.shield(future),
|
||||
60,
|
||||
) # wait for 60s
|
||||
logger.debug(f"Got future result: {result}")
|
||||
self.wexin_event_workers.pop(str(cast(str | int, msg.id)), None)
|
||||
return result # xml. see weixin_offacc_event.py
|
||||
180,
|
||||
) # wait for 180s
|
||||
logger.debug(f"Got future result: {result}")
|
||||
return result
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
logger.info(f"callback 处理消息超时: message_id={msg.id}")
|
||||
return create_reply("处理消息超时,请稍后再试。", msg)
|
||||
except Exception as e:
|
||||
logger.error(f"转换消息时出现异常: {e}")
|
||||
finally:
|
||||
self.wexin_event_workers.pop(str(cast(str | int, msg.id)), None)
|
||||
|
||||
self.server.callback = callback
|
||||
self.server.active_send_mode = self.active_send_mode
|
||||
|
||||
@override
|
||||
async def send_by_session(
|
||||
@@ -336,12 +508,19 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
|
||||
await self.handle_msg(abm)
|
||||
|
||||
async def handle_msg(self, message: AstrBotMessage) -> None:
|
||||
buffer = self.user_buffer.get(message.sender.user_id, None)
|
||||
if buffer is None:
|
||||
logger.critical(
|
||||
f"用户消息未找到缓冲状态,无法处理消息: user={message.sender.user_id} message_id={message.message_id}"
|
||||
)
|
||||
return
|
||||
message_event = WeixinOfficialAccountPlatformEvent(
|
||||
message_str=message.message_str,
|
||||
message_obj=message,
|
||||
platform_meta=self.meta(),
|
||||
session_id=message.session_id,
|
||||
client=self.client,
|
||||
message_out=buffer,
|
||||
)
|
||||
self.commit_event(message_event)
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import asyncio
|
||||
import os
|
||||
from typing import cast
|
||||
from typing import Any, cast
|
||||
|
||||
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.event import AstrMessageEvent, MessageChain
|
||||
@@ -20,9 +20,11 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
|
||||
platform_meta: PlatformMetadata,
|
||||
session_id: str,
|
||||
client: WeChatClient,
|
||||
message_out: dict[Any, Any],
|
||||
) -> None:
|
||||
super().__init__(message_str, message_obj, platform_meta, session_id)
|
||||
self.client = client
|
||||
self.message_out = message_out
|
||||
|
||||
@staticmethod
|
||||
async def send_with_client(
|
||||
@@ -32,8 +34,8 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
async def split_plain(self, plain: str) -> list[str]:
|
||||
"""将长文本分割成多个小文本, 每个小文本长度不超过 2048 字符
|
||||
async def split_plain(self, plain: str, max_length: int = 1024) -> list[str]:
|
||||
"""将长文本分割成多个小文本, 每个小文本长度不超过 max_length 字符
|
||||
|
||||
Args:
|
||||
plain (str): 要分割的长文本
|
||||
@@ -41,18 +43,18 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
|
||||
list[str]: 分割后的文本列表
|
||||
|
||||
"""
|
||||
if len(plain) <= 2048:
|
||||
if len(plain) <= max_length:
|
||||
return [plain]
|
||||
result = []
|
||||
start = 0
|
||||
while start < len(plain):
|
||||
# 剩下的字符串长度<2048时结束
|
||||
if start + 2048 >= len(plain):
|
||||
# 剩下的字符串长度<max_length时结束
|
||||
if start + max_length >= len(plain):
|
||||
result.append(plain[start:])
|
||||
break
|
||||
|
||||
# 向前搜索分割标点符号
|
||||
end = min(start + 2048, len(plain))
|
||||
end = min(start + max_length, len(plain))
|
||||
cut_position = end
|
||||
for i in range(end, start, -1):
|
||||
if i < len(plain) and plain[i - 1] in [
|
||||
@@ -87,19 +89,15 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
|
||||
if isinstance(comp, Plain):
|
||||
# Split long text messages if needed
|
||||
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)
|
||||
else:
|
||||
reply = TextReply(
|
||||
content=chunk,
|
||||
message=cast(dict, self.message_obj.raw_message)["message"],
|
||||
)
|
||||
xml = reply.render()
|
||||
future = cast(dict, self.message_obj.raw_message)["future"]
|
||||
assert isinstance(future, asyncio.Future)
|
||||
future.set_result(xml)
|
||||
await asyncio.sleep(0.5) # Avoid sending too fast
|
||||
else:
|
||||
# disable passive sending, just store the chunks in
|
||||
logger.debug(
|
||||
f"split plain into {len(plain_chunks)} chunks for passive reply. Message not sent."
|
||||
)
|
||||
self.message_out["cached_xml"] = plain_chunks
|
||||
elif isinstance(comp, Image):
|
||||
img_path = await comp.convert_to_file_path()
|
||||
|
||||
|
||||
@@ -295,6 +295,12 @@ class ProviderManager:
|
||||
from .sources.zhipu_source import ProviderZhipu as ProviderZhipu
|
||||
case "groq_chat_completion":
|
||||
from .sources.groq_source import ProviderGroq as ProviderGroq
|
||||
case "xai_chat_completion":
|
||||
from .sources.xai_source import ProviderXAI as ProviderXAI
|
||||
case "aihubmix_chat_completion":
|
||||
from .sources.oai_aihubmix_source import (
|
||||
ProviderAIHubMix as ProviderAIHubMix,
|
||||
)
|
||||
case "anthropic_chat_completion":
|
||||
from .sources.anthropic_source import (
|
||||
ProviderAnthropic as ProviderAnthropic,
|
||||
|
||||
@@ -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
|
||||
@@ -323,7 +323,8 @@ class ProviderOpenAIOfficial(Provider):
|
||||
llm_response.reasoning_content = reasoning
|
||||
_y = True
|
||||
if delta.content:
|
||||
completion_text = delta.content
|
||||
# Don't strip streaming chunks to preserve spaces between words
|
||||
completion_text = self._normalize_content(delta.content, strip=False)
|
||||
llm_response.result_chain = MessageChain(
|
||||
chain=[Comp.Plain(completion_text)],
|
||||
)
|
||||
@@ -371,6 +372,96 @@ class ProviderOpenAIOfficial(Provider):
|
||||
output=completion_tokens,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_content(raw_content: Any, strip: bool = True) -> str:
|
||||
"""Normalize content from various formats to plain string.
|
||||
|
||||
Some LLM providers return content as list[dict] format
|
||||
like [{'type': 'text', 'text': '...'}] instead of
|
||||
plain string. This method handles both formats.
|
||||
|
||||
Args:
|
||||
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
|
||||
streaming chunks to preserve spaces between words.
|
||||
|
||||
Returns:
|
||||
Normalized plain text string.
|
||||
"""
|
||||
# Handle dict format (e.g., {"type": "text", "text": "..."})
|
||||
if isinstance(raw_content, dict):
|
||||
if "text" in raw_content:
|
||||
text_val = raw_content.get("text", "")
|
||||
return str(text_val) if text_val is not None else ""
|
||||
# For other dict formats, return empty string and log
|
||||
logger.warning(f"Unexpected dict format content: {raw_content}")
|
||||
return ""
|
||||
|
||||
if isinstance(raw_content, list):
|
||||
# Check if this looks like OpenAI content-part format
|
||||
# Only process if at least one item has {'type': 'text', 'text': ...} structure
|
||||
has_content_part = any(
|
||||
isinstance(part, dict) and part.get("type") == "text"
|
||||
for part in raw_content
|
||||
)
|
||||
if has_content_part:
|
||||
text_parts = []
|
||||
for part in raw_content:
|
||||
if isinstance(part, dict) and part.get("type") == "text":
|
||||
text_val = part.get("text", "")
|
||||
# Coerce to str in case text is null or non-string
|
||||
text_parts.append(str(text_val) if text_val is not None else "")
|
||||
return "".join(text_parts)
|
||||
# Not content-part format, return string representation
|
||||
return str(raw_content)
|
||||
|
||||
if isinstance(raw_content, str):
|
||||
content = raw_content.strip() if strip else raw_content
|
||||
# Check if the string is a JSON-encoded list (e.g., "[{'type': 'text', ...}]")
|
||||
# This can happen when streaming concatenates content that was originally list format
|
||||
# Only check if it looks like a complete JSON array (requires strip for check)
|
||||
check_content = raw_content.strip()
|
||||
if (
|
||||
check_content.startswith("[")
|
||||
and check_content.endswith("]")
|
||||
and len(check_content) < 8192
|
||||
):
|
||||
try:
|
||||
# First try standard JSON parsing
|
||||
parsed = json.loads(check_content)
|
||||
except json.JSONDecodeError:
|
||||
# If that fails, try parsing as Python literal (handles single quotes)
|
||||
# This is safer than blind replace("'", '"') which corrupts apostrophes
|
||||
try:
|
||||
import ast
|
||||
|
||||
parsed = ast.literal_eval(check_content)
|
||||
except (ValueError, SyntaxError):
|
||||
parsed = None
|
||||
|
||||
if isinstance(parsed, list):
|
||||
# Only convert if it matches OpenAI content-part schema
|
||||
# i.e., at least one item has {'type': 'text', 'text': ...}
|
||||
has_content_part = any(
|
||||
isinstance(part, dict) and part.get("type") == "text"
|
||||
for part in parsed
|
||||
)
|
||||
if has_content_part:
|
||||
text_parts = []
|
||||
for part in parsed:
|
||||
if isinstance(part, dict) and part.get("type") == "text":
|
||||
text_val = part.get("text", "")
|
||||
# Coerce to str in case text is null or non-string
|
||||
text_parts.append(
|
||||
str(text_val) if text_val is not None else ""
|
||||
)
|
||||
if text_parts:
|
||||
return "".join(text_parts)
|
||||
return content
|
||||
|
||||
# Fallback for other types (int, float, etc.)
|
||||
return str(raw_content) if raw_content is not None else ""
|
||||
|
||||
async def _parse_openai_completion(
|
||||
self, completion: ChatCompletion, tools: ToolSet | None
|
||||
) -> LLMResponse:
|
||||
@@ -383,8 +474,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
|
||||
# parse the text completion
|
||||
if choice.message.content is not None:
|
||||
# text completion
|
||||
completion_text = str(choice.message.content).strip()
|
||||
completion_text = self._normalize_content(choice.message.content)
|
||||
# specially, some providers may set <think> tags around reasoning content in the completion text,
|
||||
# we use regex to remove them, and store then in reasoning_content field
|
||||
reasoning_pattern = re.compile(r"<think>(.*?)</think>", re.DOTALL)
|
||||
@@ -394,6 +484,8 @@ class ProviderOpenAIOfficial(Provider):
|
||||
[match.strip() for match in matches],
|
||||
)
|
||||
completion_text = reasoning_pattern.sub("", completion_text).strip()
|
||||
# Also clean up orphan </think> tags that may leak from some models
|
||||
completion_text = re.sub(r"</think>\s*$", "", completion_text).strip()
|
||||
llm_response.result_chain = MessageChain().message(completion_text)
|
||||
|
||||
# parse the reasoning content if any
|
||||
|
||||
@@ -7,12 +7,14 @@ import asyncio
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
from funasr_onnx import SenseVoiceSmall
|
||||
from funasr_onnx.utils.postprocess_utils import rich_transcription_postprocess
|
||||
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
from astrbot.core.utils.io import download_file
|
||||
from astrbot.core.utils.tencent_record_helper import tencent_silk_to_wav
|
||||
|
||||
@@ -50,7 +52,9 @@ class ProviderSenseVoiceSTTSelfHost(STTProvider):
|
||||
|
||||
async def get_timestamped_path(self) -> str:
|
||||
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:
|
||||
silk_header = b"SILK"
|
||||
|
||||
@@ -11,6 +11,7 @@ class PlatformAdapterType(enum.Flag):
|
||||
QQOFFICIAL = enum.auto()
|
||||
TELEGRAM = enum.auto()
|
||||
WECOM = enum.auto()
|
||||
WECOM_AI_BOT = enum.auto()
|
||||
LARK = enum.auto()
|
||||
DINGTALK = enum.auto()
|
||||
DISCORD = enum.auto()
|
||||
@@ -20,11 +21,13 @@ class PlatformAdapterType(enum.Flag):
|
||||
WEIXIN_OFFICIAL_ACCOUNT = enum.auto()
|
||||
SATORI = enum.auto()
|
||||
MISSKEY = enum.auto()
|
||||
LINE = enum.auto()
|
||||
ALL = (
|
||||
AIOCQHTTP
|
||||
| QQOFFICIAL
|
||||
| TELEGRAM
|
||||
| WECOM
|
||||
| WECOM_AI_BOT
|
||||
| LARK
|
||||
| DINGTALK
|
||||
| DISCORD
|
||||
@@ -34,6 +37,7 @@ class PlatformAdapterType(enum.Flag):
|
||||
| WEIXIN_OFFICIAL_ACCOUNT
|
||||
| SATORI
|
||||
| MISSKEY
|
||||
| LINE
|
||||
)
|
||||
|
||||
|
||||
@@ -42,6 +46,7 @@ ADAPTER_NAME_2_TYPE = {
|
||||
"qq_official": PlatformAdapterType.QQOFFICIAL,
|
||||
"telegram": PlatformAdapterType.TELEGRAM,
|
||||
"wecom": PlatformAdapterType.WECOM,
|
||||
"wecom_ai_bot": PlatformAdapterType.WECOM_AI_BOT,
|
||||
"lark": PlatformAdapterType.LARK,
|
||||
"dingtalk": PlatformAdapterType.DINGTALK,
|
||||
"discord": PlatformAdapterType.DISCORD,
|
||||
@@ -51,6 +56,7 @@ ADAPTER_NAME_2_TYPE = {
|
||||
"weixin_official_account": PlatformAdapterType.WEIXIN_OFFICIAL_ACCOUNT,
|
||||
"satori": PlatformAdapterType.SATORI,
|
||||
"misskey": PlatformAdapterType.MISSKEY,
|
||||
"line": PlatformAdapterType.LINE,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from .star_handler import (
|
||||
register_on_llm_response,
|
||||
register_on_llm_tool_respond,
|
||||
register_on_platform_loaded,
|
||||
register_on_plugin_error,
|
||||
register_on_using_llm_tool,
|
||||
register_on_waiting_llm_request,
|
||||
register_permission_type,
|
||||
@@ -32,6 +33,7 @@ __all__ = [
|
||||
"register_on_decorating_result",
|
||||
"register_on_llm_request",
|
||||
"register_on_llm_response",
|
||||
"register_on_plugin_error",
|
||||
"register_on_platform_loaded",
|
||||
"register_on_waiting_llm_request",
|
||||
"register_permission_type",
|
||||
|
||||
@@ -339,6 +339,24 @@ def register_on_platform_loaded(**kwargs):
|
||||
return decorator
|
||||
|
||||
|
||||
def register_on_plugin_error(**kwargs):
|
||||
"""当插件处理消息异常时触发。
|
||||
|
||||
Hook 参数:
|
||||
event, plugin_name, handler_name, error, traceback_text
|
||||
|
||||
说明:
|
||||
在 hook 中调用 `event.stop_event()` 可屏蔽默认报错回显,
|
||||
并由插件自行决定是否转发到其他会话。
|
||||
"""
|
||||
|
||||
def decorator(awaitable):
|
||||
_ = get_handler_or_create(awaitable, EventType.OnPluginErrorEvent, **kwargs)
|
||||
return awaitable
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def register_on_waiting_llm_request(**kwargs):
|
||||
"""当等待调用 LLM 时的通知事件(在获取锁之前)
|
||||
|
||||
|
||||
@@ -61,6 +61,12 @@ class StarMetadata:
|
||||
logo_path: str | None = None
|
||||
"""插件 Logo 的路径"""
|
||||
|
||||
support_platforms: list[str] = field(default_factory=list)
|
||||
"""插件声明支持的平台适配器 ID 列表(对应 ADAPTER_NAME_2_TYPE 的 key)"""
|
||||
|
||||
astrbot_version: str | None = None
|
||||
"""插件要求的 AstrBot 版本范围(PEP 440 specifier,如 >=4.13.0,<4.17.0)"""
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Plugin {self.name} ({self.version}) by {self.author}: {self.desc}"
|
||||
|
||||
|
||||
@@ -97,6 +97,14 @@ class StarHandlerRegistry(Generic[T]):
|
||||
plugins_name: list[str] | None = None,
|
||||
) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ...
|
||||
|
||||
@overload
|
||||
def get_handlers_by_event_type(
|
||||
self,
|
||||
event_type: Literal[EventType.OnPluginErrorEvent],
|
||||
only_activated=True,
|
||||
plugins_name: list[str] | None = None,
|
||||
) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ...
|
||||
|
||||
@overload
|
||||
def get_handlers_by_event_type(
|
||||
self,
|
||||
@@ -192,6 +200,7 @@ class EventType(enum.Enum):
|
||||
OnUsingLLMToolEvent = enum.auto() # 使用 LLM 工具
|
||||
OnLLMToolRespondEvent = enum.auto() # 调用函数工具后
|
||||
OnAfterMessageSentEvent = enum.auto() # 发送消息后
|
||||
OnPluginErrorEvent = enum.auto() # 插件处理消息异常时
|
||||
|
||||
|
||||
H = TypeVar("H", bound=Callable[..., Any])
|
||||
|
||||
@@ -11,10 +11,13 @@ import traceback
|
||||
from types import ModuleType
|
||||
|
||||
import yaml
|
||||
from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
from astrbot.core import logger, pip_installer, sp
|
||||
from astrbot.core.agent.handoff import FunctionTool, HandoffTool
|
||||
from astrbot.core.config.astrbot_config import AstrBotConfig
|
||||
from astrbot.core.config.default import VERSION
|
||||
from astrbot.core.platform.register import unregister_platform_adapters_by_module
|
||||
from astrbot.core.provider.register import llm_tools
|
||||
from astrbot.core.utils.astrbot_path import (
|
||||
@@ -40,6 +43,10 @@ except ImportError:
|
||||
logger.warning("未安装 watchfiles,无法实现插件的热重载。")
|
||||
|
||||
|
||||
class PluginVersionIncompatibleError(Exception):
|
||||
"""Raised when plugin astrbot_version is incompatible with current AstrBot."""
|
||||
|
||||
|
||||
class PluginManager:
|
||||
def __init__(self, context: Context, config: AstrBotConfig) -> None:
|
||||
self.updator = PluginUpdator()
|
||||
@@ -62,6 +69,9 @@ class PluginManager:
|
||||
self._pm_lock = asyncio.Lock()
|
||||
"""StarManager操作互斥锁"""
|
||||
|
||||
self.failed_plugin_dict = {}
|
||||
"""加载失败插件的信息,用于后续可能的热重载"""
|
||||
|
||||
self.failed_plugin_info = ""
|
||||
if os.getenv("ASTRBOT_RELOAD", "0") == "1":
|
||||
asyncio.create_task(self._watch_plugins_changes())
|
||||
@@ -265,10 +275,58 @@ class PluginManager:
|
||||
version=metadata["version"],
|
||||
repo=metadata["repo"] if "repo" in metadata else None,
|
||||
display_name=metadata.get("display_name", None),
|
||||
support_platforms=(
|
||||
[
|
||||
platform_id
|
||||
for platform_id in metadata["support_platforms"]
|
||||
if isinstance(platform_id, str)
|
||||
]
|
||||
if isinstance(metadata.get("support_platforms"), list)
|
||||
else []
|
||||
),
|
||||
astrbot_version=(
|
||||
metadata["astrbot_version"]
|
||||
if isinstance(metadata.get("astrbot_version"), str)
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
return metadata
|
||||
|
||||
@staticmethod
|
||||
def _validate_astrbot_version_specifier(
|
||||
version_spec: str | None,
|
||||
) -> tuple[bool, str | None]:
|
||||
if not version_spec:
|
||||
return True, None
|
||||
|
||||
normalized_spec = version_spec.strip()
|
||||
if not normalized_spec:
|
||||
return True, None
|
||||
|
||||
try:
|
||||
specifier = SpecifierSet(normalized_spec)
|
||||
except InvalidSpecifier:
|
||||
return (
|
||||
False,
|
||||
"astrbot_version 格式无效,请使用 PEP 440 版本范围格式,例如 >=4.16,<5。",
|
||||
)
|
||||
|
||||
try:
|
||||
current_version = Version(VERSION)
|
||||
except InvalidVersion:
|
||||
return (
|
||||
False,
|
||||
f"AstrBot 当前版本 {VERSION} 无法被解析,无法校验插件版本范围。",
|
||||
)
|
||||
|
||||
if current_version not in specifier:
|
||||
return (
|
||||
False,
|
||||
f"当前 AstrBot 版本为 {VERSION},不满足插件要求的 astrbot_version: {normalized_spec}",
|
||||
)
|
||||
return True, None
|
||||
|
||||
@staticmethod
|
||||
def _get_plugin_related_modules(
|
||||
plugin_root_dir: str,
|
||||
@@ -327,6 +385,28 @@ class PluginManager:
|
||||
except KeyError:
|
||||
logger.warning(f"模块 {module_name} 未载入")
|
||||
|
||||
async def reload_failed_plugin(self, dir_name):
|
||||
"""
|
||||
重新加载未注册(加载失败)的插件
|
||||
Args:
|
||||
dir_name (str): 要重载的特定插件名称。
|
||||
Returns:
|
||||
tuple: 返回 load() 方法的结果,包含 (success, error_message)
|
||||
- success (bool): 重载是否成功
|
||||
- error_message (str|None): 错误信息,成功时为 None
|
||||
"""
|
||||
async with self._pm_lock:
|
||||
if dir_name in self.failed_plugin_dict:
|
||||
success, error = await self.load(specified_dir_name=dir_name)
|
||||
if success:
|
||||
self.failed_plugin_dict.pop(dir_name, None)
|
||||
if not self.failed_plugin_dict:
|
||||
self.failed_plugin_info = ""
|
||||
return success, None
|
||||
else:
|
||||
return False, error
|
||||
return False, "插件不存在于失败列表中"
|
||||
|
||||
async def reload(self, specified_plugin_name=None):
|
||||
"""重新加载插件
|
||||
|
||||
@@ -383,7 +463,12 @@ class PluginManager:
|
||||
|
||||
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 时,只载入指定的插件。
|
||||
|
||||
@@ -482,12 +567,37 @@ class PluginManager:
|
||||
metadata.version = metadata_yaml.version
|
||||
metadata.repo = metadata_yaml.repo
|
||||
metadata.display_name = metadata_yaml.display_name
|
||||
metadata.support_platforms = metadata_yaml.support_platforms
|
||||
metadata.astrbot_version = metadata_yaml.astrbot_version
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"插件 {root_dir_name} 元数据载入失败: {e!s}。使用默认元数据。",
|
||||
)
|
||||
|
||||
if not ignore_version_check:
|
||||
is_valid, error_message = (
|
||||
self._validate_astrbot_version_specifier(
|
||||
metadata.astrbot_version,
|
||||
)
|
||||
)
|
||||
if not is_valid:
|
||||
raise PluginVersionIncompatibleError(
|
||||
error_message
|
||||
or "The plugin is not compatible with the current AstrBot version."
|
||||
)
|
||||
|
||||
logger.info(metadata)
|
||||
metadata.config = plugin_config
|
||||
p_name = (metadata.name or "unknown").lower().replace("/", "_")
|
||||
p_author = (metadata.author or "unknown").lower().replace("/", "_")
|
||||
plugin_id = f"{p_author}/{p_name}"
|
||||
|
||||
# 在实例化前注入类属性,保证插件 __init__ 可读取这些值
|
||||
if metadata.star_cls_type:
|
||||
setattr(metadata.star_cls_type, "name", p_name)
|
||||
setattr(metadata.star_cls_type, "author", p_author)
|
||||
setattr(metadata.star_cls_type, "plugin_id", plugin_id)
|
||||
|
||||
if path not in inactivated_plugins:
|
||||
# 只有没有禁用插件时才实例化插件类
|
||||
if plugin_config and metadata.star_cls_type:
|
||||
@@ -505,17 +615,10 @@ class PluginManager:
|
||||
context=self.context,
|
||||
)
|
||||
|
||||
p_name = (metadata.name or "unknown").lower().replace("/", "_")
|
||||
p_author = (
|
||||
(metadata.author or "unknown").lower().replace("/", "_")
|
||||
)
|
||||
setattr(metadata.star_cls, "name", p_name)
|
||||
setattr(metadata.star_cls, "author", p_author)
|
||||
setattr(
|
||||
metadata.star_cls,
|
||||
"plugin_id",
|
||||
f"{p_author}/{p_name}",
|
||||
)
|
||||
if metadata.star_cls:
|
||||
setattr(metadata.star_cls, "name", p_name)
|
||||
setattr(metadata.star_cls, "author", p_author)
|
||||
setattr(metadata.star_cls, "plugin_id", plugin_id)
|
||||
else:
|
||||
logger.info(f"插件 {metadata.name} 已被禁用。")
|
||||
|
||||
@@ -593,6 +696,19 @@ class PluginManager:
|
||||
)
|
||||
if not metadata:
|
||||
raise Exception(f"无法找到插件 {plugin_dir_path} 的元数据。")
|
||||
|
||||
if not ignore_version_check:
|
||||
is_valid, error_message = (
|
||||
self._validate_astrbot_version_specifier(
|
||||
metadata.astrbot_version,
|
||||
)
|
||||
)
|
||||
if not is_valid:
|
||||
raise PluginVersionIncompatibleError(
|
||||
error_message
|
||||
or "The plugin is not compatible with the current AstrBot version."
|
||||
)
|
||||
|
||||
metadata.star_cls = obj
|
||||
metadata.config = plugin_config
|
||||
metadata.module = module
|
||||
@@ -663,6 +779,11 @@ class PluginManager:
|
||||
logger.error(f"| {line}")
|
||||
logger.error("----------------------------------")
|
||||
fail_rec += f"加载 {root_dir_name} 插件时出现问题,原因 {e!s}。\n"
|
||||
self.failed_plugin_dict[root_dir_name] = {
|
||||
"error": str(e),
|
||||
"traceback": errors,
|
||||
}
|
||||
# 记录注册失败的插件名称,以便后续重载插件
|
||||
|
||||
# 清除 pip.main 导致的多余的 logging handlers
|
||||
for handler in logging.root.handlers[:]:
|
||||
@@ -721,7 +842,9 @@ class PluginManager:
|
||||
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 下载并安装插件,然后加载该插件到系统中
|
||||
@@ -755,7 +878,10 @@ class PluginManager:
|
||||
|
||||
# reload the plugin
|
||||
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:
|
||||
raise Exception(
|
||||
error_message
|
||||
@@ -1059,7 +1185,9 @@ class PluginManager:
|
||||
|
||||
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 = dir_name.removesuffix("-master").removesuffix("-main").lower()
|
||||
desti_dir = os.path.join(self.plugin_store_path, dir_name)
|
||||
@@ -1115,7 +1243,10 @@ class PluginManager:
|
||||
except BaseException as e:
|
||||
logger.warning(f"删除插件压缩包失败: {e!s}")
|
||||
# await self.reload()
|
||||
success, error_message = await self.load(specified_dir_name=dir_name)
|
||||
success, error_message = await self.load(
|
||||
specified_dir_name=dir_name,
|
||||
ignore_version_check=ignore_version_check,
|
||||
)
|
||||
if not success:
|
||||
raise Exception(
|
||||
error_message
|
||||
|
||||
@@ -148,8 +148,8 @@ class AstrBotUpdator(RepoZipUpdator):
|
||||
update_data = await self.fetch_release_info(self.ASTRBOT_RELEASE_API, latest)
|
||||
file_url = None
|
||||
|
||||
if os.environ.get("ASTRBOT_CLI"):
|
||||
raise Exception("不支持更新CLI启动的AstrBot") # 避免版本管理混乱
|
||||
if os.environ.get("ASTRBOT_CLI") or os.environ.get("ASTRBOT_LAUNCHER"):
|
||||
raise Exception("不支持更新此方式启动的AstrBot") # 避免版本管理混乱
|
||||
|
||||
if latest:
|
||||
latest_version = update_data[0]["tag_name"]
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
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
|
||||
|
||||
|
||||
active_event_registry = ActiveEventRegistry()
|
||||
@@ -15,7 +15,7 @@ Skills 目录路径:固定为数据目录下的 skills 目录
|
||||
|
||||
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:
|
||||
@@ -29,7 +29,7 @@ def get_astrbot_root() -> str:
|
||||
"""获取Astrbot根目录路径"""
|
||||
if path := os.environ.get("ASTRBOT_ROOT"):
|
||||
return os.path.realpath(path)
|
||||
if is_packaged_electron_runtime():
|
||||
if is_packaged_desktop_runtime():
|
||||
return os.path.realpath(os.path.join(os.path.expanduser("~"), ".astrbot"))
|
||||
return os.path.realpath(os.getcwd())
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import threading
|
||||
from collections import deque
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_site_packages_path
|
||||
from astrbot.core.utils.runtime_env import is_packaged_electron_runtime
|
||||
from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime
|
||||
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
@@ -35,7 +35,7 @@ def _get_pip_main():
|
||||
"pip module is unavailable "
|
||||
f"(sys.executable={sys.executable}, "
|
||||
f"frozen={getattr(sys, 'frozen', False)}, "
|
||||
f"ASTRBOT_ELECTRON_CLIENT={os.environ.get('ASTRBOT_ELECTRON_CLIENT')})"
|
||||
f"ASTRBOT_DESKTOP_CLIENT={os.environ.get('ASTRBOT_DESKTOP_CLIENT')})"
|
||||
) from exc
|
||||
|
||||
return pip_main
|
||||
@@ -556,7 +556,7 @@ class PipInstaller:
|
||||
args.extend(["--trusted-host", "mirrors.aliyun.com", "-i", index_url])
|
||||
|
||||
target_site_packages = None
|
||||
if is_packaged_electron_runtime():
|
||||
if is_packaged_desktop_runtime():
|
||||
target_site_packages = get_astrbot_site_packages_path()
|
||||
os.makedirs(target_site_packages, exist_ok=True)
|
||||
_prepend_sys_path(target_site_packages)
|
||||
@@ -582,7 +582,7 @@ class PipInstaller:
|
||||
|
||||
def prefer_installed_dependencies(self, requirements_path: str) -> None:
|
||||
"""优先使用已安装在插件 site-packages 中的依赖,不执行安装。"""
|
||||
if not is_packaged_electron_runtime():
|
||||
if not is_packaged_desktop_runtime():
|
||||
return
|
||||
|
||||
target_site_packages = get_astrbot_site_packages_path()
|
||||
|
||||
@@ -6,5 +6,5 @@ def is_frozen_runtime() -> bool:
|
||||
return bool(getattr(sys, "frozen", False))
|
||||
|
||||
|
||||
def is_packaged_electron_runtime() -> bool:
|
||||
return is_frozen_runtime() and os.environ.get("ASTRBOT_ELECTRON_CLIENT") == "1"
|
||||
def is_packaged_desktop_runtime() -> bool:
|
||||
return is_frozen_runtime() and os.environ.get("ASTRBOT_DESKTOP_CLIENT") == "1"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from astrbot.core import astrbot_config, logger
|
||||
@@ -20,6 +21,20 @@ def _get_dashboard_port() -> int:
|
||||
return 6185
|
||||
|
||||
|
||||
def _is_dashboard_ssl_enabled() -> bool:
|
||||
env_ssl = os.environ.get("DASHBOARD_SSL_ENABLE") or os.environ.get(
|
||||
"ASTRBOT_DASHBOARD_SSL_ENABLE"
|
||||
)
|
||||
if env_ssl is not None:
|
||||
return env_ssl.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
try:
|
||||
return bool(astrbot_config.get("dashboard", {}).get("ssl", {}).get("enable"))
|
||||
except Exception as e:
|
||||
logger.error(f"获取 dashboard SSL 配置失败: {e!s}")
|
||||
return False
|
||||
|
||||
|
||||
def log_webhook_info(platform_name: str, webhook_uuid: str) -> None:
|
||||
"""打印美观的 webhook 信息日志
|
||||
|
||||
@@ -38,12 +53,13 @@ def log_webhook_info(platform_name: str, webhook_uuid: str) -> None:
|
||||
|
||||
callback_base = callback_base.rstrip("/")
|
||||
webhook_url = f"{callback_base}/api/platform/webhook/{webhook_uuid}"
|
||||
scheme = "https" if _is_dashboard_ssl_enabled() else "http"
|
||||
|
||||
display_log = (
|
||||
"\n====================\n"
|
||||
f"🔗 机器人平台 {platform_name} 已启用统一 Webhook 模式\n"
|
||||
f"📍 Webhook 回调地址: \n"
|
||||
f" ➜ http://<your-ip>:{_get_dashboard_port()}/api/platform/webhook/{webhook_uuid}\n"
|
||||
f" ➜ {scheme}://<your-ip>:{_get_dashboard_port()}/api/platform/webhook/{webhook_uuid}\n"
|
||||
f" ➜ {webhook_url}\n"
|
||||
"====================\n"
|
||||
)
|
||||
|
||||
@@ -64,11 +64,13 @@ class AuthRoute(Route):
|
||||
new_pwd = post_data.get("new_password", None)
|
||||
new_username = post_data.get("new_username", None)
|
||||
if not new_pwd and not new_username:
|
||||
return (
|
||||
Response().error("新用户名和新密码不能同时为空,你改了个寂寞").__dict__
|
||||
)
|
||||
return Response().error("新用户名和新密码不能同时为空").__dict__
|
||||
|
||||
# Verify password confirmation
|
||||
if new_pwd:
|
||||
confirm_pwd = post_data.get("confirm_password", None)
|
||||
if confirm_pwd != new_pwd:
|
||||
return Response().error("两次输入的新密码不一致").__dict__
|
||||
self.config["dashboard"]["password"] = new_pwd
|
||||
if new_username:
|
||||
self.config["dashboard"]["username"] = new_username
|
||||
|
||||
@@ -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.regex import RegexFilter
|
||||
from astrbot.core.star.star_handler import EventType, star_handlers_registry
|
||||
from astrbot.core.star.star_manager import PluginManager
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
from astrbot.core.star.star_manager import (
|
||||
PluginManager,
|
||||
PluginVersionIncompatibleError,
|
||||
)
|
||||
from astrbot.core.utils.astrbot_path import (
|
||||
get_astrbot_data_path,
|
||||
get_astrbot_temp_path,
|
||||
)
|
||||
|
||||
from .route import Response, Route, RouteContext
|
||||
|
||||
@@ -46,6 +52,7 @@ class PluginRoute(Route):
|
||||
super().__init__(context)
|
||||
self.routes = {
|
||||
"/plugin/get": ("GET", self.get_plugins),
|
||||
"/plugin/check-compat": ("POST", self.check_plugin_compatibility),
|
||||
"/plugin/install": ("POST", self.install_plugin),
|
||||
"/plugin/install-upload": ("POST", self.install_plugin_upload),
|
||||
"/plugin/update": ("POST", self.update_plugin),
|
||||
@@ -54,11 +61,13 @@ class PluginRoute(Route):
|
||||
"/plugin/market_list": ("GET", self.get_online_plugins),
|
||||
"/plugin/off": ("POST", self.off_plugin),
|
||||
"/plugin/on": ("POST", self.on_plugin),
|
||||
"/plugin/reload-failed": ("POST", self.reload_failed_plugins),
|
||||
"/plugin/reload": ("POST", self.reload_plugins),
|
||||
"/plugin/readme": ("GET", self.get_plugin_readme),
|
||||
"/plugin/changelog": ("GET", self.get_plugin_changelog),
|
||||
"/plugin/source/get": ("GET", self.get_custom_source),
|
||||
"/plugin/source/save": ("POST", self.save_custom_source),
|
||||
"/plugin/source/get-failed-plugins": ("GET", self.get_failed_plugins),
|
||||
}
|
||||
self.core_lifecycle = core_lifecycle
|
||||
self.plugin_manager = plugin_manager
|
||||
@@ -71,10 +80,59 @@ class PluginRoute(Route):
|
||||
EventType.OnDecoratingResultEvent: "回复消息前",
|
||||
EventType.OnCallingFuncToolEvent: "函数工具",
|
||||
EventType.OnAfterMessageSentEvent: "发送消息后",
|
||||
EventType.OnPluginErrorEvent: "插件报错时",
|
||||
}
|
||||
|
||||
self._logo_cache = {}
|
||||
|
||||
async def check_plugin_compatibility(self):
|
||||
try:
|
||||
data = await request.get_json()
|
||||
version_spec = data.get("astrbot_version", "")
|
||||
is_valid, message = self.plugin_manager._validate_astrbot_version_specifier(
|
||||
version_spec
|
||||
)
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
{
|
||||
"compatible": is_valid,
|
||||
"message": message,
|
||||
"astrbot_version": version_spec,
|
||||
}
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
except Exception as e:
|
||||
return Response().error(str(e)).__dict__
|
||||
|
||||
async def reload_failed_plugins(self):
|
||||
if DEMO_MODE:
|
||||
return (
|
||||
Response()
|
||||
.error("You are not permitted to do this operation in demo mode")
|
||||
.__dict__
|
||||
)
|
||||
try:
|
||||
data = await request.get_json()
|
||||
dir_name = data.get("dir_name") # 这里拿的是目录名,不是插件名
|
||||
|
||||
if not dir_name:
|
||||
return Response().error("缺少插件目录名").__dict__
|
||||
|
||||
# 调用 star_manager.py 中的函数
|
||||
# 注意:传入的是目录名
|
||||
success, err = await self.plugin_manager.reload_failed_plugin(dir_name)
|
||||
|
||||
if success:
|
||||
return Response().ok(None, f"插件 {dir_name} 重载成功。").__dict__
|
||||
else:
|
||||
return Response().error(f"重载失败: {err}").__dict__
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"/api/plugin/reload-failed: {traceback.format_exc()}")
|
||||
return Response().error(str(e)).__dict__
|
||||
|
||||
async def reload_plugins(self):
|
||||
if DEMO_MODE:
|
||||
return (
|
||||
@@ -88,7 +146,7 @@ class PluginRoute(Route):
|
||||
try:
|
||||
success, message = await self.plugin_manager.reload(plugin_name)
|
||||
if not success:
|
||||
return Response().error(message).__dict__
|
||||
return Response().error(message or "插件重载失败").__dict__
|
||||
return Response().ok(None, "重载成功。").__dict__
|
||||
except Exception as e:
|
||||
logger.error(f"/api/plugin/reload: {traceback.format_exc()}")
|
||||
@@ -166,10 +224,11 @@ class PluginRoute(Route):
|
||||
|
||||
def _build_registry_source(self, custom_url: str | None) -> RegistrySource:
|
||||
"""构建注册表源信息"""
|
||||
data_dir = get_astrbot_data_path()
|
||||
if custom_url:
|
||||
# 对自定义URL生成一个安全的文件名
|
||||
url_hash = hashlib.md5(custom_url.encode()).hexdigest()[:8]
|
||||
cache_file = 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"):
|
||||
@@ -179,7 +238,7 @@ class PluginRoute(Route):
|
||||
|
||||
urls = [custom_url]
|
||||
else:
|
||||
cache_file = "data/plugins.json"
|
||||
cache_file = os.path.join(data_dir, "plugins.json")
|
||||
md5_url = "https://api.soulter.top/astrbot/plugins-md5"
|
||||
urls = [
|
||||
"https://api.soulter.top/astrbot/plugins",
|
||||
@@ -315,6 +374,8 @@ class PluginRoute(Route):
|
||||
),
|
||||
"display_name": plugin.display_name,
|
||||
"logo": f"/api/file/{logo_url}" if logo_url else None,
|
||||
"support_platforms": plugin.support_platforms,
|
||||
"astrbot_version": plugin.astrbot_version,
|
||||
}
|
||||
# 检查是否为全空的幽灵插件
|
||||
if not any(
|
||||
@@ -334,6 +395,10 @@ class PluginRoute(Route):
|
||||
.__dict__
|
||||
)
|
||||
|
||||
async def get_failed_plugins(self):
|
||||
"""专门获取加载失败的插件列表(字典格式)"""
|
||||
return Response().ok(self.plugin_manager.failed_plugin_dict).__dict__
|
||||
|
||||
async def get_plugin_handlers_info(self, handler_full_names: list[str]):
|
||||
"""解析插件行为"""
|
||||
handlers = []
|
||||
@@ -405,6 +470,7 @@ class PluginRoute(Route):
|
||||
|
||||
post_data = await request.get_json()
|
||||
repo_url = post_data["url"]
|
||||
ignore_version_check = bool(post_data.get("ignore_version_check", False))
|
||||
|
||||
proxy: str = post_data.get("proxy", None)
|
||||
if proxy:
|
||||
@@ -412,10 +478,23 @@ class PluginRoute(Route):
|
||||
|
||||
try:
|
||||
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()
|
||||
logger.info(f"安装插件 {repo_url} 成功。")
|
||||
return Response().ok(plugin_info, "安装成功。").__dict__
|
||||
except PluginVersionIncompatibleError as e:
|
||||
return {
|
||||
"status": "warning",
|
||||
"message": str(e),
|
||||
"data": {
|
||||
"warning_type": "astrbot_version_incompatible",
|
||||
"can_ignore": True,
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(str(e)).__dict__
|
||||
@@ -431,16 +510,32 @@ class PluginRoute(Route):
|
||||
try:
|
||||
file = await request.files
|
||||
file = file["file"]
|
||||
form_data = await request.form
|
||||
ignore_version_check = (
|
||||
str(form_data.get("ignore_version_check", "false")).lower() == "true"
|
||||
)
|
||||
logger.info(f"正在安装用户上传的插件 {file.filename}")
|
||||
file_path = os.path.join(
|
||||
get_astrbot_temp_path(),
|
||||
f"plugin_upload_{file.filename}",
|
||||
)
|
||||
await file.save(file_path)
|
||||
plugin_info = await self.plugin_manager.install_plugin_from_file(file_path)
|
||||
plugin_info = await self.plugin_manager.install_plugin_from_file(
|
||||
file_path,
|
||||
ignore_version_check=ignore_version_check,
|
||||
)
|
||||
# self.core_lifecycle.restart()
|
||||
logger.info(f"安装插件 {file.filename} 成功")
|
||||
return Response().ok(plugin_info, "安装成功。").__dict__
|
||||
except PluginVersionIncompatibleError as e:
|
||||
return {
|
||||
"status": "warning",
|
||||
"message": str(e),
|
||||
"data": {
|
||||
"warning_type": "astrbot_version_incompatible",
|
||||
"can_ignore": True,
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(str(e)).__dict__
|
||||
|
||||
@@ -295,14 +295,15 @@ class StatRoute(Route):
|
||||
if locale:
|
||||
candidates.append(base_path / f"FIRST_NOTICE.{locale}.md")
|
||||
if locale.lower().startswith("zh"):
|
||||
candidates.append(base_path / "FIRST_NOTICE.md")
|
||||
candidates.append(base_path / "FIRST_NOTICE.zh-CN.md")
|
||||
elif locale.lower().startswith("en"):
|
||||
candidates.append(base_path / "FIRST_NOTICE.en-US.md")
|
||||
|
||||
candidates.extend(
|
||||
[
|
||||
base_path / "FIRST_NOTICE.en-US.md",
|
||||
base_path / "FIRST_NOTICE.md",
|
||||
base_path / "FIRST_NOTICE.en-US.md",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import asyncio
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
from pathlib import Path
|
||||
from typing import Protocol, cast
|
||||
|
||||
import jwt
|
||||
@@ -36,6 +37,12 @@ class _AddrWithPort(Protocol):
|
||||
APP: Quart
|
||||
|
||||
|
||||
def _parse_env_bool(value: str | None, default: bool) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
class AstrBotDashboard:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -201,23 +208,33 @@ class AstrBotDashboard:
|
||||
|
||||
def run(self):
|
||||
ip_addr = []
|
||||
dashboard_config = self.core_lifecycle.astrbot_config.get("dashboard", {})
|
||||
port = (
|
||||
os.environ.get("DASHBOARD_PORT")
|
||||
or os.environ.get("ASTRBOT_DASHBOARD_PORT")
|
||||
or self.core_lifecycle.astrbot_config["dashboard"].get("port", 6185)
|
||||
or dashboard_config.get("port", 6185)
|
||||
)
|
||||
host = (
|
||||
os.environ.get("DASHBOARD_HOST")
|
||||
or os.environ.get("ASTRBOT_DASHBOARD_HOST")
|
||||
or self.core_lifecycle.astrbot_config["dashboard"].get("host", "0.0.0.0")
|
||||
or dashboard_config.get("host", "0.0.0.0")
|
||||
)
|
||||
enable = self.core_lifecycle.astrbot_config["dashboard"].get("enable", True)
|
||||
enable = dashboard_config.get("enable", True)
|
||||
ssl_config = dashboard_config.get("ssl", {})
|
||||
if not isinstance(ssl_config, dict):
|
||||
ssl_config = {}
|
||||
ssl_enable = _parse_env_bool(
|
||||
os.environ.get("DASHBOARD_SSL_ENABLE")
|
||||
or os.environ.get("ASTRBOT_DASHBOARD_SSL_ENABLE"),
|
||||
bool(ssl_config.get("enable", False)),
|
||||
)
|
||||
scheme = "https" if ssl_enable else "http"
|
||||
|
||||
if not enable:
|
||||
logger.info("WebUI 已被禁用")
|
||||
return None
|
||||
|
||||
logger.info(f"正在启动 WebUI, 监听地址: http://{host}:{port}")
|
||||
logger.info(f"正在启动 WebUI, 监听地址: {scheme}://{host}:{port}")
|
||||
if host == "0.0.0.0":
|
||||
logger.info(
|
||||
"提示: WebUI 将监听所有网络接口,请注意安全。(可在 data/cmd_config.json 中配置 dashboard.host 以修改 host)",
|
||||
@@ -245,9 +262,9 @@ class AstrBotDashboard:
|
||||
raise Exception(f"端口 {port} 已被占用")
|
||||
|
||||
parts = [f"\n ✨✨✨\n AstrBot v{VERSION} WebUI 已启动,可访问\n\n"]
|
||||
parts.append(f" ➜ 本地: http://localhost:{port}\n")
|
||||
parts.append(f" ➜ 本地: {scheme}://localhost:{port}\n")
|
||||
for ip in ip_addr:
|
||||
parts.append(f" ➜ 网络: http://{ip}:{port}\n")
|
||||
parts.append(f" ➜ 网络: {scheme}://{ip}:{port}\n")
|
||||
parts.append(" ➜ 默认用户名和密码: astrbot\n ✨✨✨\n")
|
||||
display = "".join(parts)
|
||||
|
||||
@@ -261,11 +278,45 @@ class AstrBotDashboard:
|
||||
# 配置 Hypercorn
|
||||
config = HyperConfig()
|
||||
config.bind = [f"{host}:{port}"]
|
||||
if ssl_enable:
|
||||
cert_file = (
|
||||
os.environ.get("DASHBOARD_SSL_CERT")
|
||||
or os.environ.get("ASTRBOT_DASHBOARD_SSL_CERT")
|
||||
or ssl_config.get("cert_file", "")
|
||||
)
|
||||
key_file = (
|
||||
os.environ.get("DASHBOARD_SSL_KEY")
|
||||
or os.environ.get("ASTRBOT_DASHBOARD_SSL_KEY")
|
||||
or ssl_config.get("key_file", "")
|
||||
)
|
||||
ca_certs = (
|
||||
os.environ.get("DASHBOARD_SSL_CA_CERTS")
|
||||
or os.environ.get("ASTRBOT_DASHBOARD_SSL_CA_CERTS")
|
||||
or ssl_config.get("ca_certs", "")
|
||||
)
|
||||
|
||||
cert_path = Path(cert_file).expanduser()
|
||||
key_path = Path(key_file).expanduser()
|
||||
if not cert_file or not key_file:
|
||||
raise ValueError(
|
||||
"dashboard.ssl.enable 为 true 时,必须配置 cert_file 和 key_file。",
|
||||
)
|
||||
if not cert_path.is_file():
|
||||
raise ValueError(f"SSL 证书文件不存在: {cert_path}")
|
||||
if not key_path.is_file():
|
||||
raise ValueError(f"SSL 私钥文件不存在: {key_path}")
|
||||
|
||||
config.certfile = str(cert_path.resolve())
|
||||
config.keyfile = str(key_path.resolve())
|
||||
|
||||
if ca_certs:
|
||||
ca_path = Path(ca_certs).expanduser()
|
||||
if not ca_path.is_file():
|
||||
raise ValueError(f"SSL CA 证书文件不存在: {ca_path}")
|
||||
config.ca_certs = str(ca_path.resolve())
|
||||
|
||||
# 根据配置决定是否禁用访问日志
|
||||
disable_access_log = self.core_lifecycle.astrbot_config.get(
|
||||
"dashboard", {}
|
||||
).get("disable_access_log", True)
|
||||
disable_access_log = dashboard_config.get("disable_access_log", True)
|
||||
if disable_access_log:
|
||||
config.accesslog = None
|
||||
else:
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import base64
|
||||
import os
|
||||
import traceback
|
||||
from io import BytesIO
|
||||
|
||||
@@ -51,14 +50,14 @@ async def generate_tsne_visualization(
|
||||
return None
|
||||
|
||||
kb = kb_helper.kb
|
||||
index_path = f"data/knowledge_base/{kb.kb_id}/index.faiss"
|
||||
index_path = kb_helper.kb_dir / "index.faiss"
|
||||
|
||||
# 读取 FAISS 索引
|
||||
if not os.path.exists(index_path):
|
||||
logger.warning(f"FAISS 索引不存在: {index_path}")
|
||||
if not index_path.exists():
|
||||
logger.warning(f"FAISS 索引不存在: {index_path!s}")
|
||||
return None
|
||||
|
||||
index = faiss.read_index(index_path)
|
||||
index = faiss.read_index(str(index_path))
|
||||
|
||||
if index.ntotal == 0:
|
||||
logger.warning("索引为空")
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
## What's Changed
|
||||
|
||||
### 新增
|
||||
- QQ 官方机器人平台支持主动推送消息,私聊场景支持接收文件 ([#5066](https://github.com/AstrBotDevs/AstrBot/issues/5066))
|
||||
- 为 Telegram 平台适配器新增等待 AI 回复时自动展示 “正在输入”、“正在上传图片” 等状态的功能 ([#5037](https://github.com/AstrBotDevs/AstrBot/issues/5037))
|
||||
- 为飞书适配器增加接收文件、读取引用消息的内容(包括引用的图片、视频、文件、文字等) ([#5018](https://github.com/AstrBotDevs/AstrBot/issues/5018))
|
||||
- 新增自定义平台适配器 i18n 支持 ([#5045](https://github.com/AstrBotDevs/AstrBot/issues/5045))
|
||||
- 新增临时文件处理能力,可在系统配置中限制 data/temp 目录的最大大小。 ([#5026](https://github.com/AstrBotDevs/AstrBot/issues/5026))
|
||||
- 增加首次启动公告功能,支持多语言与 WebUI 集成
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复 OpenRouter DeepSeek 场景下的 chunk 错误 ([#5069](https://github.com/AstrBotDevs/AstrBot/issues/5069))
|
||||
- 修复备份时人格文件夹映射缺失问题 ([#5042](https://github.com/AstrBotDevs/AstrBot/issues/5042))
|
||||
- 修复更新日志与官方文档弹窗双滚动条问题 ([#5060](https://github.com/AstrBotDevs/AstrBot/issues/5060))
|
||||
- 修复 provider 额外参数弹窗 key 显示异常
|
||||
- 修复连接失败时错误日志提示不准确的问题
|
||||
- 修复提前返回时未等待 reset 协程导致的资源清理问题 ([#5033](https://github.com/AstrBotDevs/AstrBot/issues/5033))
|
||||
- 提升打包版桌面端启动稳定性并优化插件依赖处理 ([#5031](https://github.com/AstrBotDevs/AstrBot/issues/5031))
|
||||
- 为 Electron 与后端日志增加按大小轮转 ([#5029](https://github.com/AstrBotDevs/AstrBot/issues/5029))
|
||||
- 加固冻结运行时(frozen app runtime)插件依赖加载流程 ([#5015](https://github.com/AstrBotDevs/AstrBot/issues/5015))
|
||||
|
||||
### 优化
|
||||
- 完善合并消息、引用解析与图片回退,并支持配置化控制 ([#5054](https://github.com/AstrBotDevs/AstrBot/issues/5054))
|
||||
- 配置页面支持通过侧边栏子项切换普通配置/系统配置,并补充相关路由修复
|
||||
- 优化分段回复间隔时间初始化逻辑 ([#5068](https://github.com/AstrBotDevs/AstrBot/issues/5068))
|
||||
|
||||
### 文档与维护
|
||||
- 同步并修正 README 文档内容与拼写 ([#5055](https://github.com/AstrBotDevs/AstrBot/issues/5055), [#5014](https://github.com/AstrBotDevs/AstrBot/issues/5014))
|
||||
- 新增 AUR 安装方式说明 ([#4879](https://github.com/AstrBotDevs/AstrBot/issues/4879))
|
||||
- 执行代码格式化(ruff)
|
||||
|
||||
## What's Changed (EN)
|
||||
|
||||
### New Features
|
||||
- Added proactive message push and private-chat file receiving support for the QQ official bot adapter ([#5066](https://github.com/AstrBotDevs/AstrBot/issues/5066))
|
||||
- Added automatic "typing..." and "uploading image..." status display while waiting for AI response in the Telegram adapter ([#5037](https://github.com/AstrBotDevs/AstrBot/issues/5037))
|
||||
- Added file receiving and quoted message content reading (including quoted images, videos, files, text, etc.) for the Feishu adapter ([#5018](https://github.com/AstrBotDevs/AstrBot/issues/5018))
|
||||
- Added i18n support for custom platform adapters ([#5045](https://github.com/AstrBotDevs/AstrBot/issues/5045))
|
||||
- Introduced temporary file handling and `TempDirCleaner` ([#5026](https://github.com/AstrBotDevs/AstrBot/issues/5026))
|
||||
- Added a first-launch notice feature with multilingual content and WebUI integration
|
||||
|
||||
### Fixes
|
||||
- Added sidebar child-tab switching for normal/system config and fixed related routing behavior on the config page
|
||||
- Fixed chunk errors when using OpenRouter DeepSeek ([#5069](https://github.com/AstrBotDevs/AstrBot/issues/5069))
|
||||
- Improved forwarded-quote parsing and image fallback with configurable controls ([#5054](https://github.com/AstrBotDevs/AstrBot/issues/5054))
|
||||
- Fixed missing persona-folder mapping in backup exports ([#5042](https://github.com/AstrBotDevs/AstrBot/issues/5042))
|
||||
- Fixed double scrollbar issue in changelog and official docs dialogs ([#5060](https://github.com/AstrBotDevs/AstrBot/issues/5060))
|
||||
- Fixed key rendering issues in the provider extra-params dialog
|
||||
- Improved error log wording for connection failures
|
||||
- Fixed unawaited reset coroutine cleanup on early returns ([#5033](https://github.com/AstrBotDevs/AstrBot/issues/5033))
|
||||
- Improved packaged desktop startup stability and plugin dependency handling ([#5031](https://github.com/AstrBotDevs/AstrBot/issues/5031))
|
||||
- Added size-based log rotation for Electron and backend logs ([#5029](https://github.com/AstrBotDevs/AstrBot/issues/5029))
|
||||
- Hardened plugin dependency loading in frozen app runtime ([#5015](https://github.com/AstrBotDevs/AstrBot/issues/5015))
|
||||
|
||||
### Improvements
|
||||
- Optimized initialization logic for segmented-reply interval timing ([#5068](https://github.com/AstrBotDevs/AstrBot/issues/5068))
|
||||
|
||||
### Docs & Maintenance
|
||||
- Synced and fixed README docs and typos ([#5055](https://github.com/AstrBotDevs/AstrBot/issues/5055), [#5014](https://github.com/AstrBotDevs/AstrBot/issues/5014))
|
||||
- Added AUR installation instructions ([#4879](https://github.com/AstrBotDevs/AstrBot/issues/4879))
|
||||
- Applied code formatting with ruff
|
||||
@@ -0,0 +1,29 @@
|
||||
## What's Changed
|
||||
|
||||
### 新增
|
||||
- 新增 LINE 平台适配器与相关配置支持 ([#5085](https://github.com/AstrBotDevs/AstrBot/issues/5085))
|
||||
- 新增备用回退聊天模型列表,当主模型报错时自动切换到备用模型 ([#5109](https://github.com/AstrBotDevs/AstrBot/issues/5109))
|
||||
- 新增插件加载失败后的热重载支持,便于插件修复后快速恢复 ([#5043](https://github.com/AstrBotDevs/AstrBot/issues/5043))
|
||||
- WebUI 新增 SSL 配置选项并同步更新相关日志行为 ([#5117](https://github.com/AstrBotDevs/AstrBot/issues/5117))
|
||||
|
||||
### 修复
|
||||
- 修复 Dockerfile 中依赖导出流程,增加 `uv lock` 步骤并移除不必要的 `--frozen` 参数,提升构建稳定性 ([#5091](https://github.com/AstrBotDevs/AstrBot/issues/5091), [#5089](https://github.com/AstrBotDevs/AstrBot/issues/5089))
|
||||
- 修复首次启动公告 `FIRST_NOTICE.md` 的本地化路径解析问题,补充兼容路径处理 ([#5083](https://github.com/AstrBotDevs/AstrBot/issues/5083), [#5082](https://github.com/AstrBotDevs/AstrBot/issues/5082))
|
||||
|
||||
### 优化
|
||||
- 日志系统由 `colorlog` 切换为 `loguru`,增强日志输出与展示能力 ([#5115](https://github.com/AstrBotDevs/AstrBot/issues/5115))
|
||||
|
||||
## What's Changed (EN)
|
||||
|
||||
### New Features
|
||||
- Added LINE platform adapter support with related configuration options ([#5085](https://github.com/AstrBotDevs/AstrBot/issues/5085))
|
||||
- Added fallback chat model chain support in tool loop runner, with corresponding config and improved provider selection display ([#5109](https://github.com/AstrBotDevs/AstrBot/issues/5109))
|
||||
- Added hot reload support after plugin load failure for faster recovery during plugin development and maintenance ([#5043](https://github.com/AstrBotDevs/AstrBot/issues/5043))
|
||||
- Added SSL configuration options for WebUI and updated related logging behavior ([#5117](https://github.com/AstrBotDevs/AstrBot/issues/5117))
|
||||
|
||||
### Fixes
|
||||
- Fixed Dockerfile dependency export flow by adding a `uv lock` step and removing unnecessary `--frozen` flag to improve build stability ([#5091](https://github.com/AstrBotDevs/AstrBot/issues/5091), [#5089](https://github.com/AstrBotDevs/AstrBot/issues/5089))
|
||||
- Fixed locale path resolution for `FIRST_NOTICE.md` and added compatible fallback handling ([#5083](https://github.com/AstrBotDevs/AstrBot/issues/5083), [#5082](https://github.com/AstrBotDevs/AstrBot/issues/5082))
|
||||
|
||||
### Improvements
|
||||
- Replaced `colorlog` with `loguru` to improve logging capabilities and console display ([#5115](https://github.com/AstrBotDevs/AstrBot/issues/5115))
|
||||
@@ -0,0 +1,34 @@
|
||||
## What's Changed
|
||||
|
||||
hotfix of 4.17.0
|
||||
|
||||
- 修复:当开启了 “启用文件日志” 后,无法启动 AstrBot,报错 `ValueError: Invalid unit value while parsing duration: 'files'`。这是由于日志轮转设置中保留配置错误导致的,已通过根据备份数量正确设置保留参数进行修复。
|
||||
- fix: When "Enable file logging" is turned on, AstrBot fails to start with error `ValueError: Invalid unit value while parsing duration: 'files'`. This is due to an incorrect retention configuration in the log rotation setup, which has been fixed by properly setting the retention parameter based on backup count.
|
||||
|
||||
### 新增
|
||||
- 新增 LINE 平台适配器与相关配置支持 ([#5085](https://github.com/AstrBotDevs/AstrBot/issues/5085))
|
||||
- 新增备用回退聊天模型列表,当主模型报错时自动切换到备用模型 ([#5109](https://github.com/AstrBotDevs/AstrBot/issues/5109))
|
||||
- 新增插件加载失败后的热重载支持,便于插件修复后快速恢复 ([#5043](https://github.com/AstrBotDevs/AstrBot/issues/5043))
|
||||
- WebUI 新增 SSL 配置选项并同步更新相关日志行为 ([#5117](https://github.com/AstrBotDevs/AstrBot/issues/5117))
|
||||
|
||||
### 修复
|
||||
- 修复 Dockerfile 中依赖导出流程,增加 `uv lock` 步骤并移除不必要的 `--frozen` 参数,提升构建稳定性 ([#5091](https://github.com/AstrBotDevs/AstrBot/issues/5091), [#5089](https://github.com/AstrBotDevs/AstrBot/issues/5089))
|
||||
- 修复首次启动公告 `FIRST_NOTICE.md` 的本地化路径解析问题,补充兼容路径处理 ([#5083](https://github.com/AstrBotDevs/AstrBot/issues/5083), [#5082](https://github.com/AstrBotDevs/AstrBot/issues/5082))
|
||||
|
||||
### 优化
|
||||
- 日志系统由 `colorlog` 切换为 `loguru`,增强日志输出与展示能力 ([#5115](https://github.com/AstrBotDevs/AstrBot/issues/5115))
|
||||
|
||||
## What's Changed (EN)
|
||||
|
||||
### New Features
|
||||
- Added LINE platform adapter support with related configuration options ([#5085](https://github.com/AstrBotDevs/AstrBot/issues/5085))
|
||||
- Added fallback chat model chain support in tool loop runner, with corresponding config and improved provider selection display ([#5109](https://github.com/AstrBotDevs/AstrBot/issues/5109))
|
||||
- Added hot reload support after plugin load failure for faster recovery during plugin development and maintenance ([#5043](https://github.com/AstrBotDevs/AstrBot/issues/5043))
|
||||
- Added SSL configuration options for WebUI and updated related logging behavior ([#5117](https://github.com/AstrBotDevs/AstrBot/issues/5117))
|
||||
|
||||
### Fixes
|
||||
- Fixed Dockerfile dependency export flow by adding a `uv lock` step and removing unnecessary `--frozen` flag to improve build stability ([#5091](https://github.com/AstrBotDevs/AstrBot/issues/5091), [#5089](https://github.com/AstrBotDevs/AstrBot/issues/5089))
|
||||
- Fixed locale path resolution for `FIRST_NOTICE.md` and added compatible fallback handling ([#5083](https://github.com/AstrBotDevs/AstrBot/issues/5083), [#5082](https://github.com/AstrBotDevs/AstrBot/issues/5082))
|
||||
|
||||
### Improvements
|
||||
- Replaced `colorlog` with `loguru` to improve logging capabilities and console display ([#5115](https://github.com/AstrBotDevs/AstrBot/issues/5115))
|
||||
@@ -0,0 +1,8 @@
|
||||
## What's Changed
|
||||
|
||||
hotfix of 4.17.0
|
||||
|
||||
- 修复:MCP 服务器的 Tools 没有被正确添加到上下文中。
|
||||
- 修复:Electron 桌面应用部署时,系统自带插件未被正确加载的问题。
|
||||
- fix: Tools from MCP server were not properly added to context.
|
||||
- fix: built-in plugins were not properly loaded in Electron desktop application deployment.
|
||||
@@ -0,0 +1,27 @@
|
||||
## What's Changed
|
||||
|
||||
### 修复
|
||||
- ‼️ 修复 Python 3.14 环境下 `'Plain' object has no attribute 'text'` 报错问题 ([#5154](https://github.com/AstrBotDevs/AstrBot/issues/5154))。
|
||||
- ‼️ 修复插件元数据处理流程:在实例化前注入必要属性,避免初始化阶段元数据缺失 ([#5155](https://github.com/AstrBotDevs/AstrBot/issues/5155))。
|
||||
- 修复桌面端后端构建中 AstrBot 内置插件运行时依赖未打包的问题 ([#5146](https://github.com/AstrBotDevs/AstrBot/issues/5146))。
|
||||
- 修复通过 AstrBot Launcher 启动时仍被检测并触发更新的问题。
|
||||
|
||||
### 优化
|
||||
|
||||
- Webchat 下,使用 `astrbot_execute_ipython` 工具如果返回了图片,会自动将图片发送到聊天中。
|
||||
|
||||
### 其他
|
||||
- 执行 `ruff format` 代码格式整理。
|
||||
|
||||
## What's Changed (EN)
|
||||
|
||||
### Fixes
|
||||
- ‼️ Fixed plugin metadata handling by injecting required attributes before instantiation to avoid missing metadata during initialization ([#5155](https://github.com/AstrBotDevs/AstrBot/issues/5155)).
|
||||
- ‼️ Fixed `'Plain' object has no attribute 'text'` error when using Python 3.14 ([#5154](https://github.com/AstrBotDevs/AstrBot/issues/5154)).
|
||||
- Fixed missing runtime dependencies for built-in plugins in desktop backend builds ([#5146](https://github.com/AstrBotDevs/AstrBot/issues/5146)).
|
||||
- Fixed update checks being triggered when AstrBot is launched via AstrBot Launcher.
|
||||
|
||||
### Improvements
|
||||
- In Webchat, when using the `astrbot_execute_ipython` tool, if an image is returned, it will automatically be sent to the chat.
|
||||
### Others
|
||||
- Applied `ruff format` code formatting.
|
||||
@@ -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`.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
@@ -2,18 +2,22 @@
|
||||
<div :class="$vuetify.display.mobile ? '' : 'd-flex'">
|
||||
<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">
|
||||
<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">
|
||||
{{ tm(metadata[key]['name']) }}
|
||||
{{ tm(section.value['name']) }}
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
<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>
|
||||
<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 -->
|
||||
<AstrBotConfigV4 :metadata="{ [key2]: metadata[key]['metadata'][key2] }" :iterable="config_data"
|
||||
:metadataKey="key2">
|
||||
<AstrBotConfigV4
|
||||
:metadata="{ [key2]: section.value['metadata'][key2] }"
|
||||
:iterable="config_data"
|
||||
:metadataKey="key2"
|
||||
:search-keyword="searchKeyword"
|
||||
>
|
||||
</AstrBotConfigV4>
|
||||
</div>
|
||||
</v-container>
|
||||
@@ -31,6 +35,11 @@
|
||||
|
||||
</v-tabs-window>
|
||||
</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>
|
||||
|
||||
<script>
|
||||
@@ -56,6 +65,10 @@ export default {
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
searchKeyword: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
setup() {
|
||||
@@ -76,11 +89,63 @@ export default {
|
||||
},
|
||||
data() {
|
||||
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: {
|
||||
// 如果需要添加其他方法,可以在这里添加
|
||||
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>
|
||||
@@ -112,4 +177,4 @@ export default {
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -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,325 @@
|
||||
<script setup>
|
||||
import { useModuleI18n } from "@/i18n/composables";
|
||||
import { getPlatformDisplayName } from "@/utils/platformUtils";
|
||||
|
||||
const { tm } = useModuleI18n("features/extension");
|
||||
|
||||
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 getPlatformDisplayList = (platforms) => {
|
||||
return normalizePlatformList(platforms).map((platformId) =>
|
||||
getPlatformDisplayName(platformId),
|
||||
);
|
||||
};
|
||||
|
||||
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 || normalizePlatformList(plugin.support_platforms).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>
|
||||
<v-chip
|
||||
v-if="normalizePlatformList(plugin.support_platforms).length"
|
||||
size="x-small"
|
||||
color="info"
|
||||
variant="outlined"
|
||||
style="height: 20px"
|
||||
>
|
||||
<v-tooltip location="top">
|
||||
<template v-slot:activator="{ props: tooltipProps }">
|
||||
<span v-bind="tooltipProps">
|
||||
{{
|
||||
tm("card.status.supportPlatformsCount", {
|
||||
count: getPlatformDisplayList(plugin.support_platforms).length,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
<span>{{ getPlatformDisplayList(plugin.support_platforms).join(", ") }}</span>
|
||||
</v-tooltip>
|
||||
</v-chip>
|
||||
</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,7 +4,7 @@
|
||||
<div class="d-flex align-center ga-2">
|
||||
<h3 class="mb-0">{{ tm('providerSources.title') }}</h3>
|
||||
</div>
|
||||
<v-menu>
|
||||
<StyledMenu>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
@@ -17,19 +17,61 @@
|
||||
{{ tm('providerSources.add') }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
v-for="sourceType in availableSourceTypes"
|
||||
:key="sourceType.value"
|
||||
@click="emitAddSource(sourceType.value)"
|
||||
>
|
||||
<v-list-item-title>{{ sourceType.label }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<v-list-item
|
||||
v-for="sourceType in availableSourceTypes"
|
||||
:key="sourceType.value"
|
||||
class="styled-menu-item"
|
||||
@click="emitAddSource(sourceType.value)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-avatar size="18" rounded="0" class="me-2">
|
||||
<v-img v-if="sourceType.icon" :src="sourceType.icon" alt="provider icon" cover></v-img>
|
||||
<v-icon v-else size="16">mdi-shape-outline</v-icon>
|
||||
</v-avatar>
|
||||
</template>
|
||||
<v-list-item-title>{{ sourceType.label }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</StyledMenu>
|
||||
</div>
|
||||
|
||||
<div v-if="displayedProviderSources.length > 0">
|
||||
<div v-if="isMobile && displayedProviderSources.length > 0" class="px-4 pb-3">
|
||||
<div class="d-flex align-center ga-2">
|
||||
<v-select
|
||||
:model-value="selectedId"
|
||||
:items="mobileSourceItems"
|
||||
item-title="label"
|
||||
item-value="value"
|
||||
:label="tm('providerSources.selectCreated')"
|
||||
variant="solo-filled"
|
||||
density="comfortable"
|
||||
flat
|
||||
hide-details
|
||||
class="mobile-source-select"
|
||||
@update:model-value="onMobileSourceChange"
|
||||
>
|
||||
<template #item="{ props: itemProps, item }">
|
||||
<v-list-item v-bind="itemProps">
|
||||
<template #prepend>
|
||||
<v-avatar size="18" rounded="0" class="me-2">
|
||||
<v-img v-if="item.raw.icon" :src="item.raw.icon" alt="provider icon" cover></v-img>
|
||||
<v-icon v-else size="16">mdi-shape-outline</v-icon>
|
||||
</v-avatar>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-select>
|
||||
<v-btn
|
||||
v-if="selectedProviderSource"
|
||||
icon="mdi-delete"
|
||||
variant="text"
|
||||
size="small"
|
||||
color="error"
|
||||
@click.stop="emitDeleteSource(selectedProviderSource)"
|
||||
></v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="displayedProviderSources.length > 0">
|
||||
<v-list class="provider-source-list" nav density="compact" lines="two">
|
||||
<v-list-item
|
||||
v-for="source in displayedProviderSources"
|
||||
@@ -46,7 +88,7 @@
|
||||
<v-icon v-else size="32">mdi-creation</v-icon>
|
||||
</v-avatar>
|
||||
</template>
|
||||
<v-list-item-title class="font-weight-bold">{{ getSourceDisplayName(source) }}</v-list-item-title>
|
||||
<v-list-item-title class="font-weight-bold mb-1" style="font-family: Arial, Helvetica, sans-serif; font-size: 16px;">{{ getSourceDisplayName(source) }}</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-truncate">{{ source.api_base || 'N/A' }}</v-list-item-subtitle>
|
||||
<template #append>
|
||||
<div class="d-flex align-center ga-1">
|
||||
@@ -72,6 +114,8 @@
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import StyledMenu from '@/components/shared/StyledMenu.vue'
|
||||
|
||||
const props = defineProps({
|
||||
displayedProviderSources: {
|
||||
@@ -106,13 +150,30 @@ const emit = defineEmits([
|
||||
'delete-provider-source'
|
||||
])
|
||||
|
||||
const { smAndDown } = useDisplay()
|
||||
const selectedId = computed(() => props.selectedProviderSource?.id || null)
|
||||
const isMobile = computed(() => smAndDown.value)
|
||||
const mobileSourceItems = computed(() =>
|
||||
(props.displayedProviderSources || []).map((source) => ({
|
||||
value: source.id,
|
||||
label: props.getSourceDisplayName(source),
|
||||
icon: props.resolveSourceIcon(source),
|
||||
source
|
||||
}))
|
||||
)
|
||||
|
||||
const isActive = (source) => {
|
||||
if (source.isPlaceholder) return false
|
||||
return selectedId.value !== null && selectedId.value === source.id
|
||||
}
|
||||
|
||||
const onMobileSourceChange = (sourceId) => {
|
||||
const matched = mobileSourceItems.value.find((item) => item.value === sourceId)
|
||||
if (matched?.source) {
|
||||
emitSelectSource(matched.source)
|
||||
}
|
||||
}
|
||||
|
||||
const emitAddSource = (type) => emit('add-provider-source', type)
|
||||
const emitSelectSource = (source) => emit('select-provider-source', source)
|
||||
const emitDeleteSource = (source) => emit('delete-provider-source', source)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
|
||||
import { ref, computed } from 'vue'
|
||||
import ConfigItemRenderer from './ConfigItemRenderer.vue'
|
||||
import TemplateListEditor from './TemplateListEditor.vue'
|
||||
import PersonaQuickPreview from './PersonaQuickPreview.vue'
|
||||
import { useI18n, useModuleI18n } from '@/i18n/composables'
|
||||
|
||||
|
||||
@@ -19,6 +20,10 @@ const props = defineProps({
|
||||
metadataKey: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
searchKeyword: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
@@ -124,16 +129,27 @@ function saveEditedContent() {
|
||||
}
|
||||
|
||||
function shouldShowItem(itemMeta, itemKey) {
|
||||
if (!itemMeta?.condition) {
|
||||
return true
|
||||
}
|
||||
for (const [conditionKey, expectedValue] of Object.entries(itemMeta.condition)) {
|
||||
const actualValue = getValueBySelector(props.iterable, conditionKey)
|
||||
if (actualValue !== expectedValue) {
|
||||
return false
|
||||
if (itemMeta?.condition) {
|
||||
for (const [conditionKey, expectedValue] of Object.entries(itemMeta.condition)) {
|
||||
const actualValue = getValueBySelector(props.iterable, conditionKey)
|
||||
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 是否应该显示
|
||||
@@ -148,7 +164,10 @@ function shouldShowSection() {
|
||||
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) {
|
||||
@@ -256,6 +275,16 @@ function getSpecialSubtype(value) {
|
||||
</div>
|
||||
</v-col>
|
||||
</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>
|
||||
<v-divider class="config-divider"
|
||||
v-if="shouldShowItem(itemMeta, itemKey) && hasVisibleItemsAfter(metadata[metadataKey].items, index)"></v-divider>
|
||||
@@ -415,6 +444,15 @@ function getSpecialSubtype(value) {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.persona-preview-row {
|
||||
margin: 16px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.persona-preview-display {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.selected-plugins-full-width {
|
||||
background-color: rgba(var(--v-theme-primary), 0.05);
|
||||
border: 1px solid rgba(var(--v-theme-primary), 0.1);
|
||||
@@ -436,9 +474,13 @@ function getSpecialSubtype(value) {
|
||||
}
|
||||
|
||||
.property-info,
|
||||
.type-indicator,
|
||||
.type-indicator {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.config-input {
|
||||
padding: 4px;
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,6 +10,14 @@
|
||||
<template v-else-if="itemMeta?._special === 'select_provider_tts'">
|
||||
<ProviderSelector :model-value="modelValue" @update:model-value="emitUpdate" :provider-type="'text_to_speech'" />
|
||||
</template>
|
||||
<template v-else-if="itemMeta?._special === 'select_providers'">
|
||||
<ProviderSelector
|
||||
:model-value="modelValue"
|
||||
@update:model-value="emitUpdate"
|
||||
:provider-type="'chat_completion'"
|
||||
:multiple="true"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="getSpecialName(itemMeta?._special) === 'select_agent_runner_provider'">
|
||||
<ProviderSelector
|
||||
:model-value="modelValue"
|
||||
|
||||
@@ -27,7 +27,7 @@ export default {
|
||||
return {
|
||||
autoScroll: true,
|
||||
logColorAnsiMap: {
|
||||
'\u001b[1;34m': 'color: #0000FF; font-weight: bold;',
|
||||
'\u001b[1;34m': 'color: #39C5BB; font-weight: bold;',
|
||||
'\u001b[1;36m': 'color: #00FFFF; font-weight: bold;',
|
||||
'\u001b[1;33m': 'color: #FFFF00; font-weight: bold;',
|
||||
'\u001b[31m': 'color: #FF0000;',
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, computed, inject } from "vue";
|
||||
import { useCustomizerStore } from "@/stores/customizer";
|
||||
import { useModuleI18n } from "@/i18n/composables";
|
||||
import { getPlatformDisplayName } from "@/utils/platformUtils";
|
||||
import UninstallConfirmDialog from "./UninstallConfirmDialog.vue";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -38,6 +39,25 @@ const showUninstallDialog = ref(false);
|
||||
// 国际化
|
||||
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 = () => {
|
||||
emit("configure", props.extension);
|
||||
@@ -316,6 +336,37 @@ const viewChangelog = () => {
|
||||
>
|
||||
{{ tag === "danger" ? tm("tags.danger") : tag }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="supportPlatforms.length"
|
||||
color="info"
|
||||
variant="outlined"
|
||||
label
|
||||
size="small"
|
||||
class="ml-2"
|
||||
>
|
||||
<v-tooltip location="top">
|
||||
<template v-slot:activator="{ props: tooltipProps }">
|
||||
<span v-bind="tooltipProps">
|
||||
{{
|
||||
tm("card.status.supportPlatformsCount", {
|
||||
count: supportPlatformDisplayNames.length,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
<span>{{ supportPlatformDisplayNames.join(", ") }}</span>
|
||||
</v-tooltip>
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="astrbotVersionRequirement"
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
label
|
||||
size="small"
|
||||
class="ml-2"
|
||||
>
|
||||
AstrBot: {{ astrbotVersionRequirement }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@@ -1,32 +1,30 @@
|
||||
<template>
|
||||
<v-dialog v-model="showDialog" max-width="500px">
|
||||
<v-card>
|
||||
<v-card-title class="text-h2">
|
||||
<v-dialog v-model="showDialog" :max-width="$vuetify.display.smAndDown ? undefined : '1200px'" scrollable>
|
||||
<v-card class="persona-form-card" :class="{ 'persona-form-card-mobile': $vuetify.display.smAndDown }">
|
||||
<v-card-title class="persona-form-title text-h2 px-6 pt-6 pl-6">
|
||||
{{ editingPersona ? tm('dialog.edit.title') : tm('dialog.create.title') }}
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<v-card-text class="persona-form-content">
|
||||
<!-- 创建位置提示 -->
|
||||
<v-alert
|
||||
v-if="!editingPersona"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-4"
|
||||
icon="mdi-folder-outline"
|
||||
>
|
||||
<v-alert v-if="!editingPersona" type="info" variant="tonal" density="compact" class="mb-4"
|
||||
icon="mdi-folder-outline">
|
||||
{{ tm('form.createInFolder', { folder: folderDisplayName }) }}
|
||||
</v-alert>
|
||||
|
||||
<v-form ref="personaForm" v-model="formValid">
|
||||
<v-text-field v-model="personaForm.persona_id" :label="tm('form.personaId')"
|
||||
:rules="personaIdRules" :disabled="editingPersona" variant="outlined" density="comfortable"
|
||||
class="mb-4" />
|
||||
<v-row class="persona-form-layout">
|
||||
<v-col cols="12" md="6" class="persona-basic-col">
|
||||
<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')"
|
||||
:rules="systemPromptRules" variant="outlined" rows="6" class="mb-4" />
|
||||
<v-textarea v-model="personaForm.system_prompt" :label="tm('form.systemPrompt')"
|
||||
: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-title>
|
||||
@@ -51,7 +49,7 @@
|
||||
</v-radio>
|
||||
</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')"
|
||||
@@ -65,8 +63,8 @@
|
||||
<div class="d-flex flex-wrap ga-2">
|
||||
<v-chip v-for="server in mcpServers" :key="server.name"
|
||||
:color="isServerSelected(server) ? 'primary' : 'default'"
|
||||
:variant="isServerSelected(server) ? 'flat' : 'outlined'"
|
||||
size="small" clickable @click="toggleMcpServer(server)"
|
||||
:variant="isServerSelected(server) ? 'flat' : 'outlined'" size="small"
|
||||
clickable @click="toggleMcpServer(server)"
|
||||
:disabled="!server.tools || server.tools.length === 0">
|
||||
<v-icon start size="small">mdi-server</v-icon>
|
||||
{{ server.name }}
|
||||
@@ -79,7 +77,7 @@
|
||||
|
||||
<!-- 工具选择列表 -->
|
||||
<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 }">
|
||||
<v-list-item :key="item.name" density="comfortable"
|
||||
@click="toggleTool(item.name)">
|
||||
@@ -90,10 +88,16 @@
|
||||
|
||||
<v-list-item-title>
|
||||
{{ item.name }}
|
||||
<v-chip v-if="item.mcp_server_name" size="x-small"
|
||||
color="secondary" variant="tonal" class="ml-2">
|
||||
{{ item.mcp_server_name }}
|
||||
|
||||
<v-chip v-if="item.origin" size="x-small" color="info" class="mr-2"
|
||||
variant="tonal">
|
||||
{{ item.origin }}
|
||||
</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-subtitle v-if="item.description">
|
||||
@@ -108,7 +112,7 @@
|
||||
class="text-center pa-4">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -123,7 +127,7 @@
|
||||
<div v-if="loadingTools" class="text-center pa-4">
|
||||
<v-progress-circular indeterminate color="primary" />
|
||||
<p class="text-body-2 text-medium-emphasis mt-2">{{ tm('form.loadingTools')
|
||||
}}
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -139,9 +143,9 @@
|
||||
</span>
|
||||
</h4>
|
||||
<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;">
|
||||
<v-chip v-for="toolName in personaForm.tools" :key="toolName"
|
||||
size="small" color="primary" variant="tonal" closable
|
||||
class="d-flex flex-wrap ga-1" style="max-height: 100px; overflow-y: auto;">
|
||||
<v-chip v-for="toolName in personaForm.tools" :key="toolName" size="small"
|
||||
color="primary" variant="tonal" closable
|
||||
@click:close="removeTool(toolName)">
|
||||
{{ toolName }}
|
||||
</v-chip>
|
||||
@@ -178,7 +182,7 @@
|
||||
<v-radio :label="tm('form.skillsSelectSpecific')" value="1"></v-radio>
|
||||
</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')"
|
||||
prepend-inner-icon="mdi-magnify" variant="outlined" density="compact"
|
||||
hide-details clearable class="mb-3" />
|
||||
@@ -205,7 +209,8 @@
|
||||
|
||||
<div v-else-if="!loadingSkills && availableSkills.length === 0"
|
||||
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>
|
||||
</div>
|
||||
@@ -284,11 +289,13 @@
|
||||
</v-btn>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</v-expansion-panels>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-card-actions class="persona-form-actions">
|
||||
<v-btn v-if="editingPersona" color="error" variant="text" @click="deletePersona">
|
||||
{{ tm('buttons.delete') }}
|
||||
</v-btn>
|
||||
@@ -480,7 +487,7 @@ export default {
|
||||
};
|
||||
this.toolSelectValue = '0';
|
||||
this.skillSelectValue = '0';
|
||||
this.expandedPanels = [];
|
||||
this.expandedPanels = this.getDefaultExpandedPanels();
|
||||
},
|
||||
|
||||
initFormWithPersona(persona) {
|
||||
@@ -495,7 +502,11 @@ export default {
|
||||
// 根据 tools 的值设置 toolSelectValue
|
||||
this.toolSelectValue = persona.tools === 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() {
|
||||
@@ -799,6 +810,36 @@ export default {
|
||||
</script>
|
||||
|
||||
<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 {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
@@ -812,4 +853,43 @@ export default {
|
||||
.v-virtual-scroll {
|
||||
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>
|
||||
|
||||
@@ -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) {
|
||||
console.log('人格保存成功:', message)
|
||||
const savedPersonaId = editingPersona.value?.persona_id || ''
|
||||
showPersonaDialog.value = false
|
||||
editingPersona.value = null
|
||||
// 刷新当前文件夹的人格列表
|
||||
await loadPersonasInFolder(currentFolderId.value)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('astrbot:persona-saved', {
|
||||
detail: { persona_id: savedPersonaId }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// 错误处理
|
||||
|
||||
@@ -1,16 +1,35 @@
|
||||
<template>
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<span v-if="!modelValue" style="color: rgb(var(--v-theme-primaryText));">
|
||||
<span v-if="!hasSelection" style="color: rgb(var(--v-theme-primaryText));">
|
||||
{{ tm('providerSelector.notSelected') }}
|
||||
</span>
|
||||
<span v-else class="provider-name-text">
|
||||
{{ modelValue }}
|
||||
<template v-if="multiple">
|
||||
{{ tm('providerSelector.selectedCount', { count: selectedProviders.length }) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ modelValue }}
|
||||
</template>
|
||||
</span>
|
||||
<v-btn size="small" color="primary" variant="tonal" @click="openDialog">
|
||||
{{ buttonText || tm('providerSelector.buttonText') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div v-if="multiple && selectedProviders.length > 0" class="selected-preview mt-2">
|
||||
<v-chip
|
||||
v-for="providerId in selectedProviders"
|
||||
:key="`preview-${providerId}`"
|
||||
size="x-small"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
class="mr-1 mb-1"
|
||||
label
|
||||
>
|
||||
{{ providerId }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<!-- Provider Selection Dialog -->
|
||||
<v-dialog v-model="dialog" max-width="600px">
|
||||
<v-card>
|
||||
@@ -32,10 +51,52 @@
|
||||
|
||||
<v-card-text class="pa-0" style="max-height: 400px; overflow-y: auto;">
|
||||
<v-progress-linear v-if="loading" indeterminate color="primary"></v-progress-linear>
|
||||
|
||||
<div v-if="multiple && selectedProviders.length > 0" class="pa-3">
|
||||
<div class="text-caption text-medium-emphasis mb-2">
|
||||
{{ tm('providerSelector.selectedCount', { count: selectedProviders.length }) }}
|
||||
</div>
|
||||
<v-list density="compact" class="selected-order-list">
|
||||
<v-list-item
|
||||
v-for="(providerId, index) in selectedProviders"
|
||||
:key="`selected-${providerId}-${index}`"
|
||||
rounded="md"
|
||||
class="ma-1"
|
||||
>
|
||||
<v-list-item-title>{{ providerId }}</v-list-item-title>
|
||||
<template #append>
|
||||
<div class="d-flex ga-1">
|
||||
<v-btn
|
||||
icon="mdi-arrow-up"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
:disabled="index === 0"
|
||||
@click.stop="moveSelected(index, -1)"
|
||||
/>
|
||||
<v-btn
|
||||
icon="mdi-arrow-down"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
:disabled="index === selectedProviders.length - 1"
|
||||
@click.stop="moveSelected(index, 1)"
|
||||
/>
|
||||
<v-btn
|
||||
icon="mdi-close"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
@click.stop="removeSelected(providerId)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<v-divider class="ma-1"></v-divider>
|
||||
</div>
|
||||
|
||||
<v-list v-if="!loading && providerList.length > 0" density="compact">
|
||||
<!-- 不选择选项 -->
|
||||
<v-list-item
|
||||
v-if="!multiple"
|
||||
key="none"
|
||||
value=""
|
||||
@click="selectProvider({ id: '' })"
|
||||
@@ -57,7 +118,7 @@
|
||||
:key="provider.id"
|
||||
:value="provider.id"
|
||||
@click="selectProvider(provider)"
|
||||
:active="selectedProvider === provider.id"
|
||||
:active="isProviderSelected(provider.id)"
|
||||
rounded="md"
|
||||
class="ma-1">
|
||||
<v-list-item-title>{{ provider.id }}</v-list-item-title>
|
||||
@@ -67,7 +128,7 @@
|
||||
</v-list-item-subtitle>
|
||||
|
||||
<template v-slot:append>
|
||||
<v-icon v-if="selectedProvider === provider.id" color="primary">mdi-check-circle</v-icon>
|
||||
<v-icon v-if="isProviderSelected(provider.id)" color="primary">mdi-check-circle</v-icon>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
@@ -121,7 +182,7 @@ import ProviderPage from '@/views/ProviderPage.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
type: [String, Array],
|
||||
default: ''
|
||||
},
|
||||
providerType: {
|
||||
@@ -135,6 +196,10 @@ const props = defineProps({
|
||||
buttonText: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
@@ -145,8 +210,16 @@ const dialog = ref(false)
|
||||
const providerList = ref([])
|
||||
const loading = ref(false)
|
||||
const selectedProvider = ref('')
|
||||
const selectedProviders = ref([])
|
||||
const providerDrawer = ref(false)
|
||||
|
||||
const hasSelection = computed(() => {
|
||||
if (props.multiple) {
|
||||
return selectedProviders.value.length > 0
|
||||
}
|
||||
return Boolean(props.modelValue)
|
||||
})
|
||||
|
||||
const defaultTab = computed(() => {
|
||||
if (props.providerType === 'agent_runner' && props.providerSubtype) {
|
||||
return `select_agent_runner_provider:${props.providerSubtype}`
|
||||
@@ -156,7 +229,13 @@ const defaultTab = computed(() => {
|
||||
|
||||
// 监听 modelValue 变化,同步到 selectedProvider
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
selectedProvider.value = newValue || ''
|
||||
if (props.multiple) {
|
||||
selectedProviders.value = Array.isArray(newValue)
|
||||
? [...newValue.filter((v) => typeof v === 'string' && v)]
|
||||
: []
|
||||
return
|
||||
}
|
||||
selectedProvider.value = typeof newValue === 'string' ? newValue : ''
|
||||
}, { immediate: true })
|
||||
|
||||
watch(providerDrawer, (isOpen, wasOpen) => {
|
||||
@@ -166,7 +245,13 @@ watch(providerDrawer, (isOpen, wasOpen) => {
|
||||
})
|
||||
|
||||
async function openDialog() {
|
||||
selectedProvider.value = props.modelValue || ''
|
||||
if (props.multiple) {
|
||||
selectedProviders.value = Array.isArray(props.modelValue)
|
||||
? [...props.modelValue.filter((v) => typeof v === 'string' && v)]
|
||||
: []
|
||||
} else {
|
||||
selectedProvider.value = typeof props.modelValue === 'string' ? props.modelValue : ''
|
||||
}
|
||||
dialog.value = true
|
||||
await loadProviders()
|
||||
}
|
||||
@@ -205,19 +290,72 @@ function matchesProviderSubtype(provider, subtype) {
|
||||
}
|
||||
|
||||
function selectProvider(provider) {
|
||||
if (props.multiple) {
|
||||
if (!provider.id) {
|
||||
selectedProviders.value = []
|
||||
return
|
||||
}
|
||||
const idx = selectedProviders.value.indexOf(provider.id)
|
||||
if (idx >= 0) {
|
||||
selectedProviders.value.splice(idx, 1)
|
||||
} else {
|
||||
selectedProviders.value.push(provider.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
selectedProvider.value = provider.id
|
||||
}
|
||||
|
||||
function confirmSelection() {
|
||||
emit('update:modelValue', selectedProvider.value)
|
||||
if (props.multiple) {
|
||||
emit('update:modelValue', [...selectedProviders.value])
|
||||
} else {
|
||||
emit('update:modelValue', selectedProvider.value)
|
||||
}
|
||||
dialog.value = false
|
||||
}
|
||||
|
||||
function cancelSelection() {
|
||||
selectedProvider.value = props.modelValue || ''
|
||||
if (props.multiple) {
|
||||
selectedProviders.value = Array.isArray(props.modelValue)
|
||||
? [...props.modelValue.filter((v) => typeof v === 'string' && v)]
|
||||
: []
|
||||
} else {
|
||||
selectedProvider.value = typeof props.modelValue === 'string' ? props.modelValue : ''
|
||||
}
|
||||
dialog.value = false
|
||||
}
|
||||
|
||||
function isProviderSelected(providerId) {
|
||||
if (props.multiple) {
|
||||
return selectedProviders.value.includes(providerId)
|
||||
}
|
||||
return selectedProvider.value === providerId
|
||||
}
|
||||
|
||||
function removeSelected(providerId) {
|
||||
const idx = selectedProviders.value.indexOf(providerId)
|
||||
if (idx >= 0) {
|
||||
selectedProviders.value.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function moveSelected(index, delta) {
|
||||
const targetIndex = index + delta
|
||||
if (
|
||||
targetIndex < 0
|
||||
|| targetIndex >= selectedProviders.value.length
|
||||
|| index < 0
|
||||
|| index >= selectedProviders.value.length
|
||||
) {
|
||||
return
|
||||
}
|
||||
const copied = [...selectedProviders.value]
|
||||
const [item] = copied.splice(index, 1)
|
||||
copied.splice(targetIndex, 0, item)
|
||||
selectedProviders.value = copied
|
||||
}
|
||||
|
||||
function openProviderDrawer() {
|
||||
providerDrawer.value = true
|
||||
}
|
||||
@@ -236,6 +374,16 @@ function closeProviderDrawer() {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.selected-preview {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.selected-order-list {
|
||||
background: rgba(var(--v-theme-surface-variant), 0.15);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.v-list-item {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
@@ -81,10 +81,14 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
|
||||
return []
|
||||
}
|
||||
|
||||
const types: Array<{ value: string; label: string }> = []
|
||||
const types: Array<{ value: string; label: string; icon: string }> = []
|
||||
for (const [templateName, template] of Object.entries(providerTemplates.value)) {
|
||||
if (template.provider_type === selectedProviderType.value) {
|
||||
types.push({ value: templateName, label: templateName })
|
||||
types.push({
|
||||
value: templateName,
|
||||
label: templateName,
|
||||
icon: getProviderIcon(template.provider)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,14 +72,17 @@
|
||||
"form": {
|
||||
"currentPassword": "Current Password",
|
||||
"newPassword": "New Password",
|
||||
"confirmPassword": "Confirm New Password",
|
||||
"newUsername": "New Username (Optional)",
|
||||
"passwordHint": "Password must be at least 8 characters",
|
||||
"confirmPasswordHint": "Please enter new password again to confirm",
|
||||
"usernameHint": "Leave blank to keep current username",
|
||||
"defaultCredentials": "Default username and password are both astrbot"
|
||||
},
|
||||
"validation": {
|
||||
"passwordRequired": "Please enter password",
|
||||
"passwordMinLength": "Password must be at least 8 characters",
|
||||
"passwordMatch": "Passwords do not match",
|
||||
"usernameMinLength": "Username must be at least 3 characters"
|
||||
},
|
||||
"actions": {
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"settings": "Settings",
|
||||
"changelog": "Changelog",
|
||||
"documentation": "Documentation",
|
||||
"faq": "FAQ",
|
||||
"github": "GitHub",
|
||||
"drag": "Drag",
|
||||
"groups": {
|
||||
|
||||
@@ -45,7 +45,8 @@
|
||||
"unknownType": "Unknown type",
|
||||
"createProvider": "Create Provider",
|
||||
"manageProviders": "Provider Management",
|
||||
"selectProviderPool": "Select Provider Pool..."
|
||||
"selectProviderPool": "Select Provider Pool...",
|
||||
"selectedCount": "{count} provider(s) selected"
|
||||
},
|
||||
"personaSelector": {
|
||||
"notSelected": "Not selected",
|
||||
@@ -61,6 +62,25 @@
|
||||
"rootFolder": "All Personas",
|
||||
"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": {
|
||||
"buttonText": "Customize T2I Template",
|
||||
"dialogTitle": "Customize Text-to-Image HTML Template",
|
||||
|
||||
@@ -37,6 +37,10 @@
|
||||
"description": "Default Chat Model",
|
||||
"hint": "Uses the first model when left empty"
|
||||
},
|
||||
"fallback_chat_models": {
|
||||
"description": "Fallback chat model IDs",
|
||||
"hint": "When the primary chat model request fails, fallback to these chat models in order."
|
||||
},
|
||||
"default_image_caption_provider_id": {
|
||||
"description": "Default Image Caption Model",
|
||||
"hint": "Leave empty to disable; useful for non-multimodal models"
|
||||
@@ -145,6 +149,10 @@
|
||||
"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."
|
||||
},
|
||||
"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": {
|
||||
"booter": {
|
||||
"description": "Sandbox Environment Driver"
|
||||
@@ -869,6 +877,26 @@
|
||||
"description": "Externally Accessible Callback API Address",
|
||||
"hint": "External services may access AstrBot's backend through callback links generated by AstrBot (such as file download links). Since AstrBot cannot automatically determine the externally accessible host address in the deployment environment, this configuration item is needed to explicitly specify how external services should access AstrBot's address. Examples: [http://localhost:6185](http://localhost:6185), [https://example.com](https://example.com), etc."
|
||||
},
|
||||
"dashboard": {
|
||||
"ssl": {
|
||||
"enable": {
|
||||
"description": "Enable WebUI HTTPS",
|
||||
"hint": "When enabled, WebUI serves directly over HTTPS."
|
||||
},
|
||||
"cert_file": {
|
||||
"description": "SSL Certificate File Path",
|
||||
"hint": "Certificate file path (PEM). Supports absolute and relative paths (relative to current working directory)."
|
||||
},
|
||||
"key_file": {
|
||||
"description": "SSL Private Key File Path",
|
||||
"hint": "Private key file path (PEM). Supports absolute and relative paths (relative to current working directory)."
|
||||
},
|
||||
"ca_certs": {
|
||||
"description": "SSL CA Certificate File Path",
|
||||
"hint": "Optional. Path to CA certificate file."
|
||||
}
|
||||
}
|
||||
},
|
||||
"timezone": {
|
||||
"description": "Timezone",
|
||||
"hint": "Timezone setting. Please enter an IANA timezone name, such as Asia/Shanghai. Uses system default timezone when empty. For all timezones, see: [https://data.iana.org/time-zones/tzdb-2021a/zone1970.tab](https://data.iana.org/time-zones/tzdb-2021a/zone1970.tab)"
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"messages": {
|
||||
"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.",
|
||||
"unsavedChangesNotice": "You have unsaved configuration changes. Click the save button in the bottom-right corner to apply them.",
|
||||
"saveSuccess": "Configuration saved successfully",
|
||||
"saveError": "Failed to save configuration",
|
||||
"loadError": "Failed to load configuration",
|
||||
@@ -68,6 +69,10 @@
|
||||
"normalConfig": "Basic",
|
||||
"systemConfig": "System"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Search config items (key/description/hint)",
|
||||
"noResult": "No matching config items found"
|
||||
},
|
||||
"configManagement": {
|
||||
"title": "Configuration Management",
|
||||
"description": "AstrBot supports separate configuration files for different bots. The `default` configuration is used by default.",
|
||||
@@ -107,5 +112,18 @@
|
||||
"addToConfig": "Added to config",
|
||||
"fileCount": "Files: {count}",
|
||||
"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",
|
||||
"refresh": "Refresh",
|
||||
"updateAll": "Update All",
|
||||
"deleteSource": "Delete Source"
|
||||
"deleteSource": "Delete Source",
|
||||
"reshuffle": "Shuffle Again"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Enabled",
|
||||
@@ -103,7 +104,9 @@
|
||||
"sourceUpdated": "Source updated successfully",
|
||||
"defaultOfficialSource": "Default Official Source",
|
||||
"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": {
|
||||
"default": "Default",
|
||||
@@ -140,7 +143,8 @@
|
||||
"install": {
|
||||
"title": "Install Extension",
|
||||
"fromFile": "Install from File",
|
||||
"fromUrl": "Install from URL"
|
||||
"fromUrl": "Install from URL",
|
||||
"supportPlatformsCount": "Supports {count} Platforms"
|
||||
},
|
||||
"danger_warning": {
|
||||
"title": "Dangerous Plugin Warning",
|
||||
@@ -148,6 +152,12 @@
|
||||
"confirm": "Continue",
|
||||
"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": {
|
||||
"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.",
|
||||
@@ -227,7 +237,10 @@
|
||||
"status": {
|
||||
"hasUpdate": "New version available",
|
||||
"disabled": "This extension is disabled",
|
||||
"handlersCount": " handlers"
|
||||
"handlersCount": " handlers",
|
||||
"supportPlatform": "Supported Platform",
|
||||
"supportPlatformsCount": "Supports {count} Platforms",
|
||||
"astrbotVersion": "AstrBot Version Requirement"
|
||||
},
|
||||
"alt": {
|
||||
"logo": "logo",
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
"add": "Add",
|
||||
"empty": "No provider sources",
|
||||
"selectHint": "Please select a provider source",
|
||||
"selectCreated": "Select created provider source",
|
||||
"save": "Save Configuration",
|
||||
"saveAndFetchModels": "Save and Fetch Models",
|
||||
"fetchModels": "Fetch Model List",
|
||||
@@ -146,4 +147,4 @@
|
||||
"modelId": "Model ID"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
"section": {
|
||||
"title": "SubAgents"
|
||||
"title": "SubAgents",
|
||||
"globalSettings": "Global Settings"
|
||||
},
|
||||
"cards": {
|
||||
"statusEnabled": "Enabled",
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
"newYear": "Happy New Year!"
|
||||
},
|
||||
"subtitle": "You can complete the basic onboarding first. Platform and chat provider setup can both be skipped.",
|
||||
"announcement": {
|
||||
"title": "Announcement"
|
||||
},
|
||||
"onboard": {
|
||||
"title": "Quick Onboarding",
|
||||
"subtitle": "Complete initialization directly on the welcome page.",
|
||||
@@ -27,6 +30,8 @@
|
||||
"title": "Resources",
|
||||
"githubDesc": "Give us a Star!",
|
||||
"docsTitle": "Documentation",
|
||||
"docsDesc": "Read the official AstrBot documentation."
|
||||
"docsDesc": "Read the official AstrBot documentation.",
|
||||
"afdianTitle": "Afdian",
|
||||
"afdianDesc": "Support the AstrBot team on Afdian."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,14 +72,17 @@
|
||||
"form": {
|
||||
"currentPassword": "当前密码",
|
||||
"newPassword": "新密码",
|
||||
"confirmPassword": "确认新密码",
|
||||
"newUsername": "新用户名 (可选)",
|
||||
"passwordHint": "密码长度至少 8 位",
|
||||
"confirmPasswordHint": "请再次输入新密码以确认",
|
||||
"usernameHint": "留空表示不修改用户名",
|
||||
"defaultCredentials": "默认用户名和密码均为 astrbot"
|
||||
},
|
||||
"validation": {
|
||||
"passwordRequired": "请输入密码",
|
||||
"passwordMinLength": "密码长度至少 8 位",
|
||||
"passwordMatch": "两次输入的密码不一致",
|
||||
"usernameMinLength": "用户名长度至少3位"
|
||||
},
|
||||
"actions": {
|
||||
@@ -90,4 +93,4 @@
|
||||
"updateFailed": "修改失败,请重试"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"settings": "设置",
|
||||
"changelog": "更新日志",
|
||||
"documentation": "官方文档",
|
||||
"faq": "FAQ",
|
||||
"github": "GitHub",
|
||||
"drag": "拖拽",
|
||||
"groups": {
|
||||
|
||||
@@ -45,7 +45,8 @@
|
||||
"unknownType": "未知类型",
|
||||
"createProvider": "创建提供商",
|
||||
"manageProviders": "提供商管理",
|
||||
"selectProviderPool": "选择提供商池..."
|
||||
"selectProviderPool": "选择提供商池...",
|
||||
"selectedCount": "已选择 {count} 个提供商"
|
||||
},
|
||||
"personaSelector": {
|
||||
"notSelected": "未选择",
|
||||
@@ -61,6 +62,25 @@
|
||||
"rootFolder": "全部人格",
|
||||
"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": {
|
||||
"buttonText": "自定义 T2I 模板",
|
||||
"dialogTitle": "自定义文转图 HTML 模板",
|
||||
|
||||
@@ -31,12 +31,16 @@
|
||||
},
|
||||
"ai": {
|
||||
"description": "模型",
|
||||
"hint": "当使用非内置 Agent 执行器时,默认聊天模型和默认图片转述模型可能会无效,但某些插件会依赖此配置项来调用 AI 能力。",
|
||||
"hint": "当使用非内置 Agent 执行器时,默认对话模型和默认图片转述模型可能会无效,但某些插件会依赖此配置项来调用 AI 能力。",
|
||||
"provider_settings": {
|
||||
"default_provider_id": {
|
||||
"description": "默认聊天模型",
|
||||
"description": "默认对话模型",
|
||||
"hint": "留空时使用第一个模型"
|
||||
},
|
||||
"fallback_chat_models": {
|
||||
"description": "回退对话模型列表",
|
||||
"hint": "主对话模型请求失败时,按顺序切换到这些对话模型。"
|
||||
},
|
||||
"default_image_caption_provider_id": {
|
||||
"description": "默认图片转述模型",
|
||||
"hint": "留空代表不使用,可用于非多模态模型"
|
||||
@@ -148,6 +152,10 @@
|
||||
"description": "运行环境",
|
||||
"hint": "sandbox 代表在沙箱环境中运行, local 代表在本地环境中运行, none 代表不启用。如果上传了 skills,选择 none 会导致其无法被 Agent 正常使用。"
|
||||
},
|
||||
"computer_use_require_admin": {
|
||||
"description": "需要 AstrBot 管理员权限",
|
||||
"hint": "开启后,需要 AstrBot 管理员权限才能调用使用电脑能力。在平台配置->管理员中可添加管理员。使用 /sid 指令查看管理员 ID。"
|
||||
},
|
||||
"sandbox": {
|
||||
"booter": {
|
||||
"description": "沙箱环境驱动器"
|
||||
@@ -872,6 +880,26 @@
|
||||
"description": "对外可达的回调接口地址",
|
||||
"hint": "外部服务可能会通过 AstrBot 生成的回调链接(如文件下载链接)访问 AstrBot 后端。由于 AstrBot 无法自动判断部署环境中对外可达的主机地址(host),因此需要通过此配置项显式指定外部服务如何访问 AstrBot 的地址。如 [http://localhost:6185](http://localhost:6185),[https://example.com](https://example.com) 等。"
|
||||
},
|
||||
"dashboard": {
|
||||
"ssl": {
|
||||
"enable": {
|
||||
"description": "启用 WebUI HTTPS",
|
||||
"hint": "启用后,WebUI 将直接使用 HTTPS 提供服务。"
|
||||
},
|
||||
"cert_file": {
|
||||
"description": "SSL 证书文件路径",
|
||||
"hint": "证书文件路径(PEM)。支持绝对路径和相对路径(相对于当前工作目录)。"
|
||||
},
|
||||
"key_file": {
|
||||
"description": "SSL 私钥文件路径",
|
||||
"hint": "私钥文件路径(PEM)。支持绝对路径和相对路径(相对于当前工作目录)。"
|
||||
},
|
||||
"ca_certs": {
|
||||
"description": "SSL CA 证书文件路径",
|
||||
"hint": "可选。用于指定 CA 证书文件路径。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"timezone": {
|
||||
"description": "时区",
|
||||
"hint": "时区设置。请填写 IANA 时区名称, 如 Asia/Shanghai, 为空时使用系统默认时区。所有时区请查看: [https://data.iana.org/time-zones/tzdb-2021a/zone1970.tab](https://data.iana.org/time-zones/tzdb-2021a/zone1970.tab)"
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"messages": {
|
||||
"configApplied": "配置成功应用。如要保存,需再点击右下角保存按钮。",
|
||||
"configApplyError": "配置未应用,Json 格式错误。",
|
||||
"unsavedChangesNotice": "当前配置有未保存修改。请点击右下角保存按钮以生效。",
|
||||
"saveSuccess": "配置保存成功",
|
||||
"saveError": "配置保存失败",
|
||||
"loadError": "配置加载失败",
|
||||
@@ -68,6 +69,10 @@
|
||||
"normalConfig": "普通",
|
||||
"systemConfig": "系统"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "搜索配置项(字段名/描述/提示)",
|
||||
"noResult": "未找到匹配的配置项"
|
||||
},
|
||||
"configManagement": {
|
||||
"title": "配置文件管理",
|
||||
"description": "AstrBot 支持针对不同机器人分别设置配置文件。默认会使用 `default` 配置。",
|
||||
@@ -107,5 +112,18 @@
|
||||
"addToConfig": "已加入配置",
|
||||
"fileCount": "文件:{count}",
|
||||
"done": "完成"
|
||||
},
|
||||
"unsavedChangesWarning": {
|
||||
"dialogTitle": "未保存的更改",
|
||||
"leavePage": "当前配置有未保存的更改,切换前是否保存?",
|
||||
"switchConfig": "切换配置文件会丢失当前未保存的更改,是否先保存?",
|
||||
"options": {
|
||||
"save": "保存",
|
||||
"saveAndSwitch": "保存并切换",
|
||||
"discardAndSwitch": "放弃更改并切换",
|
||||
"closeCard": "关闭弹窗",
|
||||
"confirm": "确定",
|
||||
"cancel": "取消"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"subtitle": "管理和配置系统插件",
|
||||
"tabs": {
|
||||
"installedPlugins": "AstrBot 插件",
|
||||
"market": "AstrBot 插件市场",
|
||||
"market": "AstrBot 插件市场",
|
||||
"installedMcpServers": "MCP",
|
||||
"skills": "Skills",
|
||||
"handlersOperation": "管理行为"
|
||||
@@ -38,7 +38,8 @@
|
||||
"selectFile": "选择文件",
|
||||
"refresh": "刷新",
|
||||
"updateAll": "更新全部插件",
|
||||
"deleteSource": "删除源"
|
||||
"deleteSource": "删除源",
|
||||
"reshuffle": "随机一发"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "启用",
|
||||
@@ -103,7 +104,9 @@
|
||||
"sourceUpdated": "插件源更新成功",
|
||||
"defaultOfficialSource": "默认官方源",
|
||||
"sourceExists": "该插件源已存在",
|
||||
"installPlugin": "安装插件"
|
||||
"installPlugin": "安装插件",
|
||||
"randomPlugins": "🎲 随机插件",
|
||||
"sourceSafetyWarning": "即使是默认插件源,我们也不能完全保证插件的稳定性和安全性,使用前请谨慎核查。"
|
||||
},
|
||||
"sort": {
|
||||
"default": "默认排序",
|
||||
@@ -140,7 +143,8 @@
|
||||
"install": {
|
||||
"title": "安装插件",
|
||||
"fromFile": "从文件安装",
|
||||
"fromUrl": "从链接安装"
|
||||
"fromUrl": "从链接安装",
|
||||
"supportPlatformsCount": "支持 {count} 个平台"
|
||||
},
|
||||
"danger_warning": {
|
||||
"title": "警告",
|
||||
@@ -148,6 +152,12 @@
|
||||
"confirm": "继续",
|
||||
"cancel": "取消"
|
||||
},
|
||||
"versionCompatibility": {
|
||||
"title": "版本兼容性警告",
|
||||
"message": "该插件声明的 AstrBot 版本范围与当前版本不匹配。你可以无视警告继续安装,但可能无法正常运行。",
|
||||
"confirm": "无视警告,继续安装",
|
||||
"cancel": "取消安装"
|
||||
},
|
||||
"forceUpdate": {
|
||||
"title": "未检测到新版本",
|
||||
"message": "当前插件未检测到新版本,是否强制重新安装?这将从远程仓库拉取最新代码。",
|
||||
@@ -227,7 +237,10 @@
|
||||
"status": {
|
||||
"hasUpdate": "有新版本可用",
|
||||
"disabled": "该插件已经被禁用",
|
||||
"handlersCount": "个行为"
|
||||
"handlersCount": "个行为",
|
||||
"supportPlatform": "支持平台",
|
||||
"supportPlatformsCount": "支持 {count} 个平台",
|
||||
"astrbotVersion": "AstrBot 版本要求"
|
||||
},
|
||||
"alt": {
|
||||
"logo": "logo",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"presetDialogsHelp": "添加一些预设的对话来帮助机器人更好地理解角色设定。",
|
||||
"userMessage": "用户消息",
|
||||
"assistantMessage": "AI 回答",
|
||||
"tools": "工具选择",
|
||||
"tools": "工具 / MCP 工具选择",
|
||||
"toolsHelp": "为这个人格选择可用的外部工具。外部工具给了 AI 接触外部环境的能力,如搜索、计算、获取信息等。",
|
||||
"toolsSelection": "工具选择操作",
|
||||
"selectAllTools": "选择所有工具",
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
"add": "新增",
|
||||
"empty": "暂无提供商源",
|
||||
"selectHint": "请选择一个提供商源",
|
||||
"selectCreated": "选择已创建的提供商源",
|
||||
"save": "保存配置",
|
||||
"saveAndFetchModels": "保存并获取模型",
|
||||
"fetchModels": "获取模型列表",
|
||||
@@ -147,4 +148,4 @@
|
||||
"modelId": "模型 ID"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
"enabled": "启动:主 LLM 会保留自身工具并挂载 transfer_to_* 委派工具。若开启“去重重复工具”,与 SubAgent 指定的工具重叠部分会从主 LLM 工具集中移除。"
|
||||
},
|
||||
"section": {
|
||||
"title": "SubAgents"
|
||||
"title": "SubAgents 配置",
|
||||
"globalSettings": "全局设置"
|
||||
},
|
||||
"cards": {
|
||||
"statusEnabled": "启用",
|
||||
@@ -28,7 +29,8 @@
|
||||
"transferPrefix": "transfer_to_{name}",
|
||||
"switchLabel": "启用",
|
||||
"previewTitle": "预览:主 LLM 将看到的 handoff 工具",
|
||||
"personaChip": "Persona: {id}"
|
||||
"personaChip": "Persona: {id}",
|
||||
"noDescription": "暂无描述"
|
||||
},
|
||||
"form": {
|
||||
"nameLabel": "Agent 名称(用于 transfer_to_{name})",
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
"newYear": "新年快乐!"
|
||||
},
|
||||
"subtitle": "可以先完成基础引导,平台和对话提供商都支持稍后再配置。",
|
||||
"announcement": {
|
||||
"title": "公告"
|
||||
},
|
||||
"onboard": {
|
||||
"title": "快速引导",
|
||||
"subtitle": "欢迎页可直接完成初始化。",
|
||||
@@ -27,6 +30,8 @@
|
||||
"title": "相关资源",
|
||||
"githubDesc": "给 AstrBot 点个 Star 吧!",
|
||||
"docsTitle": "文档",
|
||||
"docsDesc": "查阅 AstrBot 的官方文档。"
|
||||
"docsDesc": "查阅 AstrBot 的官方文档。",
|
||||
"afdianTitle": "爱发电",
|
||||
"afdianDesc": "通过爱发电支持 AstrBot 团队。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ let aboutDialog = ref(false);
|
||||
const username = localStorage.getItem('user');
|
||||
let password = ref('');
|
||||
let newPassword = ref('');
|
||||
let confirmPassword = ref('');
|
||||
let newUsername = ref('');
|
||||
let status = ref('');
|
||||
let updateStatus = ref('')
|
||||
@@ -51,7 +52,8 @@ const isElectronApp = ref(
|
||||
const redirectConfirmDialog = ref(false);
|
||||
const pendingRedirectUrl = ref('');
|
||||
const resolvingReleaseTarget = ref(false);
|
||||
const fallbackReleaseUrl = 'https://github.com/AstrBotDevs/AstrBot/releases/latest';
|
||||
const desktopReleaseBaseUrl = 'https://github.com/AstrBotDevs/AstrBot-desktop/releases';
|
||||
const fallbackReleaseUrl = desktopReleaseBaseUrl;
|
||||
|
||||
const getSelectedGitHubProxy = () => {
|
||||
if (typeof window === "undefined" || !window.localStorage) return "";
|
||||
@@ -88,6 +90,10 @@ const passwordRules = computed(() => [
|
||||
(v: string) => !!v || t('core.header.accountDialog.validation.passwordRequired'),
|
||||
(v: string) => v.length >= 8 || t('core.header.accountDialog.validation.passwordMinLength')
|
||||
]);
|
||||
const confirmPasswordRules = computed(() => [
|
||||
(v: string) => !newPassword.value || !!v || t('core.header.accountDialog.validation.passwordRequired'),
|
||||
(v: string) => !newPassword.value || v === newPassword.value || t('core.header.accountDialog.validation.passwordMatch')
|
||||
]);
|
||||
const usernameRules = computed(() => [
|
||||
(v: string) => !v || v.length >= 3 || t('core.header.accountDialog.validation.usernameMinLength')
|
||||
]);
|
||||
@@ -95,6 +101,7 @@ const usernameRules = computed(() => [
|
||||
// 显示密码相关
|
||||
const showPassword = ref(false);
|
||||
const showNewPassword = ref(false);
|
||||
const showConfirmPassword = ref(false);
|
||||
|
||||
// 账户修改状态
|
||||
const accountEditStatus = ref({
|
||||
@@ -128,12 +135,15 @@ function confirmExternalRedirect() {
|
||||
|
||||
const getReleaseUrlForElectron = () => {
|
||||
const firstRelease = (releases.value as any[])?.[0];
|
||||
if (firstRelease?.html_url) return firstRelease.html_url as string;
|
||||
if (firstRelease?.tag_name) {
|
||||
const tag = firstRelease.tag_name as string;
|
||||
return `${desktopReleaseBaseUrl}/tag/${tag}`;
|
||||
}
|
||||
if (hasNewVersion.value) return fallbackReleaseUrl;
|
||||
const tag = botCurrVersion.value?.startsWith('v') ? botCurrVersion.value : 'latest';
|
||||
return tag === 'latest'
|
||||
? fallbackReleaseUrl
|
||||
: `https://github.com/AstrBotDevs/AstrBot/releases/tag/${tag}`;
|
||||
: `${desktopReleaseBaseUrl}/tag/${tag}`;
|
||||
};
|
||||
|
||||
function handleUpdateClick() {
|
||||
@@ -165,17 +175,14 @@ function accountEdit() {
|
||||
accountEditStatus.value.error = false;
|
||||
accountEditStatus.value.success = false;
|
||||
|
||||
// md5加密
|
||||
// @ts-ignore
|
||||
if (password.value != '') {
|
||||
password.value = md5(password.value);
|
||||
}
|
||||
if (newPassword.value != '') {
|
||||
newPassword.value = md5(newPassword.value);
|
||||
}
|
||||
const passwordHash = password.value ? md5(password.value) : '';
|
||||
const newPasswordHash = newPassword.value ? md5(newPassword.value) : '';
|
||||
const confirmPasswordHash = confirmPassword.value ? md5(confirmPassword.value) : '';
|
||||
|
||||
axios.post('/api/auth/account/edit', {
|
||||
password: password.value,
|
||||
new_password: newPassword.value,
|
||||
password: passwordHash,
|
||||
new_password: newPasswordHash,
|
||||
confirm_password: confirmPasswordHash,
|
||||
new_username: newUsername.value ? newUsername.value : username
|
||||
})
|
||||
.then((res) => {
|
||||
@@ -184,6 +191,7 @@ function accountEdit() {
|
||||
accountEditStatus.value.message = res.data.message;
|
||||
password.value = '';
|
||||
newPassword.value = '';
|
||||
confirmPassword.value = '';
|
||||
return;
|
||||
}
|
||||
accountEditStatus.value.success = true;
|
||||
@@ -200,6 +208,7 @@ function accountEdit() {
|
||||
accountEditStatus.value.message = typeof err === 'string' ? err : t('core.header.accountDialog.messages.updateFailed');
|
||||
password.value = '';
|
||||
newPassword.value = '';
|
||||
confirmPassword.value = '';
|
||||
})
|
||||
.finally(() => {
|
||||
accountEditStatus.value.loading = false;
|
||||
@@ -730,10 +739,16 @@ onMounted(async () => {
|
||||
|
||||
<v-text-field v-model="newPassword" :append-inner-icon="showNewPassword ? 'mdi-eye-off' : 'mdi-eye'"
|
||||
:type="showNewPassword ? 'text' : 'password'" :rules="passwordRules"
|
||||
:label="t('core.header.accountDialog.form.newPassword')" variant="outlined" required clearable
|
||||
:label="t('core.header.accountDialog.form.newPassword')" variant="outlined" clearable
|
||||
@click:append-inner="showNewPassword = !showNewPassword" prepend-inner-icon="mdi-lock-plus-outline"
|
||||
:hint="t('core.header.accountDialog.form.passwordHint')" persistent-hint class="mb-4"></v-text-field>
|
||||
|
||||
<v-text-field v-model="confirmPassword" :append-inner-icon="showConfirmPassword ? 'mdi-eye-off' : 'mdi-eye'"
|
||||
:type="showConfirmPassword ? 'text' : 'password'" :rules="confirmPasswordRules"
|
||||
:label="t('core.header.accountDialog.form.confirmPassword')" variant="outlined" clearable
|
||||
@click:append-inner="showConfirmPassword = !showConfirmPassword" prepend-inner-icon="mdi-lock-check-outline"
|
||||
:hint="t('core.header.accountDialog.form.confirmPasswordHint')" persistent-hint class="mb-4"></v-text-field>
|
||||
|
||||
<v-text-field v-model="newUsername" :rules="usernameRules"
|
||||
:label="t('core.header.accountDialog.form.newUsername')" variant="outlined" clearable
|
||||
prepend-inner-icon="mdi-account-edit-outline" :hint="t('core.header.accountDialog.form.usernameHint')"
|
||||
|
||||
@@ -7,7 +7,7 @@ import NavItem from './NavItem.vue';
|
||||
import { applySidebarCustomization } from '@/utils/sidebarCustomization';
|
||||
import ChangelogDialog from '@/components/shared/ChangelogDialog.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const customizer = useCustomizerStore();
|
||||
const sidebarMenu = shallowRef(sidebarItems);
|
||||
@@ -109,6 +109,13 @@ function openIframeLink(url) {
|
||||
}
|
||||
}
|
||||
|
||||
function openFaqLink() {
|
||||
const faqUrl = locale.value === 'en-US'
|
||||
? 'https://docs.astrbot.app/en/faq.html'
|
||||
: 'https://docs.astrbot.app/faq.html';
|
||||
openIframeLink(faqUrl);
|
||||
}
|
||||
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
let isDragging = false;
|
||||
@@ -264,6 +271,10 @@ function openChangelogDialog() {
|
||||
@click="toggleIframe">
|
||||
{{ t('core.navigation.documentation') }}
|
||||
</v-btn>
|
||||
<v-btn class="sidebar-footer-btn" size="small" variant="text" prepend-icon="mdi-frequently-asked-questions"
|
||||
@click="openFaqLink">
|
||||
{{ t('core.navigation.faq') }}
|
||||
</v-btn>
|
||||
<v-btn class="sidebar-footer-btn" size="small" variant="text" prepend-icon="mdi-github"
|
||||
@click="openIframeLink('https://github.com/AstrBotDevs/AstrBot')">
|
||||
{{ t('core.navigation.github') }}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user