Compare commits

..

1 Commits

Author SHA1 Message Date
Soulter 572689b416 feat: implement retry mechanism for model requests in anthropic and openai providers 2026-02-17 13:18:00 +08:00
118 changed files with 5867 additions and 3385 deletions
+14 -12
View File
@@ -1,40 +1,42 @@
name: '🎉 Feature Request / 功能建议'
name: '🎉 功能建议'
title: "[Feature]"
description: Submit a suggestion to help us improve. / 提交建议帮助我们改进。
description: 提交建议帮助我们改进。
labels: [ "enhancement" ]
body:
- type: markdown
attributes:
value: |
Thank you for taking the time to suggest a new feature! Please explain your idea clearly and accurately. / 感谢您抽出时间提出新功能建议,请准确解释您的想法。
感谢您抽出时间提出新功能建议,请准确解释您的想法。
- type: textarea
attributes:
label: Description / 描述
description: Please describe the feature you want to be added in detail. / 请详细描述您希望添加的功能。
label: 描述
description: 简短描述您的功能建议
- type: textarea
attributes:
label: Use Case / 使用场景
description: Please describe the use case for this feature. / 请描述这个功能的使用场景。
label: 使用场景
description: 你想要发生什么?
placeholder: >
一个清晰且具体的描述这个功能的使用场景。
- type: checkboxes
attributes:
label: Willing to Submit PR? / 是否愿意提交PR
label: 愿意提交PR吗?
description: >
This is not required, but if you are willing to submit a PR to implement this feature, it would be greatly appreciated! / 这不是必的,但如果您愿意提交 PR 来实现这个功能,我们将不胜感激!
这不是必的,但我们欢迎您的贡献。
options:
- label: Yes, I am willing to submit a PR. / 是的,我愿意提交 PR
- label: 是的, 我愿意提交PR!
- type: checkboxes
attributes:
label: Code of Conduct
options:
- label: >
I have read and agree to abide by the project's [Code of Conduct](https://docs.github.com/zh/site-policy/github-terms/github-community-code-of-conduct). /
我已阅读并同意遵守该项目的 [行为准则](https://docs.github.com/zh/site-policy/github-terms/github-community-code-of-conduct)
required: true
- type: markdown
attributes:
value: "Thank you for filling out our form!"
value: "感谢您填写我们的表单!"
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.12'
python-version: '3.10'
- name: Install UV
run: pip install uv
+165
View File
@@ -102,11 +102,170 @@ jobs:
cp "dashboard/AstrBot-${VERSION_TAG}-dashboard.zip" "dashboard/astrbot-webui-${VERSION_TAG}.zip"
rclone copy "dashboard/astrbot-webui-${VERSION_TAG}.zip" "r2:${R2_BUCKET_NAME}" --progress
build-desktop:
name: Build ${{ matrix.name }}
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- name: linux-x64
runner: ubuntu-24.04
os: linux
arch: amd64
- name: linux-arm64
runner: ubuntu-24.04-arm
os: linux
arch: arm64
- name: windows-x64
runner: windows-2022
os: win
arch: amd64
- name: windows-arm64
runner: windows-11-arm
os: win
arch: arm64
- name: macos-x64
runner: macos-15-intel
os: mac
arch: amd64
- name: macos-arm64
runner: macos-15
os: mac
arch: arm64
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ inputs.ref || github.ref }}
- name: Resolve tag
id: tag
shell: bash
run: |
if [ "${{ github.event_name }}" = "push" ]; then
tag="${GITHUB_REF_NAME}"
elif [ -n "${{ inputs.tag }}" ]; then
tag="${{ inputs.tag }}"
else
tag="$(git describe --tags --abbrev=0)"
fi
if [ -z "$tag" ]; then
echo "Failed to resolve tag." >&2
exit 1
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: Setup uv
uses: astral-sh/setup-uv@v7
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.28.2
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24.13.0'
cache: "pnpm"
cache-dependency-path: |
dashboard/pnpm-lock.yaml
desktop/pnpm-lock.yaml
- name: Prepare OpenSSL for Windows ARM64
if: ${{ matrix.os == 'win' && matrix.arch == 'arm64' }}
shell: pwsh
run: |
git clone https://github.com/microsoft/vcpkg.git C:\vcpkg
& C:\vcpkg\bootstrap-vcpkg.bat -disableMetrics
& C:\vcpkg\vcpkg.exe install openssl:arm64-windows
"VCPKG_ROOT=C:\vcpkg" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
"VCPKGRS_TRIPLET=arm64-windows" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
"OPENSSL_DIR=C:\vcpkg\installed\arm64-windows" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
"OPENSSL_ROOT_DIR=C:\vcpkg\installed\arm64-windows" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
"OPENSSL_LIB_DIR=C:\vcpkg\installed\arm64-windows\lib" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
"OPENSSL_INCLUDE_DIR=C:\vcpkg\installed\arm64-windows\include" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
- name: Install dependencies
shell: bash
run: |
uv sync
pnpm --dir dashboard install --frozen-lockfile
pnpm --dir desktop install --frozen-lockfile
- name: Build desktop package
shell: bash
run: |
pnpm --dir dashboard run build
pnpm --dir desktop run build:webui
pnpm --dir desktop run build:backend
pnpm --dir desktop run sync:version
pnpm --dir desktop exec electron-builder --publish never
- name: Normalize artifact names
shell: bash
env:
NAME_PREFIX: AstrBot-${{ steps.tag.outputs.tag }}-${{ matrix.arch }}-${{ matrix.os }}
run: |
shopt -s nullglob
out_dir="desktop/dist/release"
mkdir -p "$out_dir"
files=(
desktop/dist/*.AppImage
desktop/dist/*.dmg
desktop/dist/*.zip
desktop/dist/*.exe
)
if [ ${#files[@]} -eq 0 ]; then
echo "No desktop artifacts found to rename." >&2
exit 1
fi
for src in "${files[@]}"; do
file="$(basename "$src")"
case "$file" in
*.AppImage)
dest="$out_dir/${NAME_PREFIX}.AppImage"
;;
*.dmg)
dest="$out_dir/${NAME_PREFIX}.dmg"
;;
*.exe)
dest="$out_dir/${NAME_PREFIX}.exe"
;;
*.zip)
dest="$out_dir/${NAME_PREFIX}.zip"
;;
*)
continue
;;
esac
cp "$src" "$dest"
done
ls -la "$out_dir"
- name: Upload desktop artifacts
uses: actions/upload-artifact@v6
with:
name: AstrBot-${{ steps.tag.outputs.tag }}-${{ matrix.arch }}-${{ matrix.os }}
if-no-files-found: error
path: desktop/dist/release/*
publish-release:
name: Publish GitHub Release
runs-on: ubuntu-24.04
needs:
- build-dashboard
- build-desktop
steps:
- name: Checkout repository
uses: actions/checkout@v6
@@ -137,6 +296,12 @@ jobs:
name: Dashboard-${{ steps.tag.outputs.tag }}
path: release-assets
- name: Download desktop artifacts
uses: actions/download-artifact@v7
with:
pattern: AstrBot-${{ steps.tag.outputs.tag }}-*
path: release-assets
merge-multiple: true
- name: Resolve release notes
id: notes
+7
View File
@@ -33,6 +33,13 @@ tests/astrbot_plugin_openai
dashboard/node_modules/
dashboard/dist/
.pnpm-store/
desktop/node_modules/
desktop/dist/
desktop/out/
desktop/resources/backend/astrbot-backend*
desktop/resources/backend/*.exe
desktop/resources/webui/*
desktop/resources/.pyinstaller/
package-lock.json
yarn.lock
+4 -16
View File
@@ -146,16 +146,15 @@ yay -S astrbot-git
paru -S astrbot-git
```
#### 桌面端Tauri
#### 桌面端 Electron 打包
桌面端已迁移为独立仓库(Tauri):[https://github.com/AstrBotDevs/AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
桌面端Electron 打包,`pnpm` 工作流)构建流程请参阅:[`desktop/README.md`](desktop/README.md)。
## 支持的消息平台
**官方维护**
- QQ
- OneBot v11 协议实现
- QQ (官方平台 & OneBot)
- Telegram
- 企微应用 & 企微智能机器人
- 微信客服 & 微信公众号
@@ -163,10 +162,10 @@ paru -S astrbot-git
- 钉钉
- Slack
- Discord
- LINE
- Satori
- Misskey
- Whatsapp (将支持)
- LINE (将支持)
**社区维护**
@@ -186,7 +185,6 @@ paru -S astrbot-git
- DeepSeek
- Ollama (本地部署)
- LM Studio (本地部署)
- [AIHubMix](https://aihubmix.com/?aff=4bfH)
- [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74)
- [302.AI](https://share.302.ai/rr1M3l)
- [小马算力](https://www.tokenpony.cn/3YPyf)
@@ -269,16 +267,6 @@ 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
View File
@@ -154,9 +154,9 @@ yay -S astrbot-git
paru -S astrbot-git
```
#### Desktop (Tauri)
#### Desktop Electron Build
Desktop packaging has moved to a standalone Tauri repository: [https://github.com/AstrBotDevs/AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
For desktop build steps (Electron packaging, `pnpm` workflow), see [`desktop/README.md`](desktop/README.md).
## Supported Messaging Platforms
@@ -172,8 +172,8 @@ Desktop packaging has moved to a standalone Tauri repository: [https://github.co
- Discord
- Satori
- Misskey
- LINE
- WhatsApp (Coming Soon)
- LINE (Coming Soon)
**Community Maintained**
+1 -1
View File
@@ -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
View File
@@ -168,8 +168,8 @@ paru -S astrbot-git
- Discord
- Satori
- Misskey
- LINE
- WhatsApp (近日対応予定)
- LINE (近日対応予定)
**コミュニティメンテナンス**
+1 -2
View File
@@ -158,9 +158,8 @@ paru -S astrbot-git
- Discord
- Satori
- Misskey
- LINE
- WhatsApp (Скоро)
- LINE (Скоро)
**Поддерживаемые сообществом**
+1 -2
View File
@@ -158,9 +158,8 @@ paru -S astrbot-git
- Discord
- Satori
- Misskey
- LINE
- Whatsapp(即將支援)
- LINE(即將支援)
**社群維護**
-2
View File
@@ -24,7 +24,6 @@ from astrbot.core.star.register import (
register_on_llm_tool_respond as on_llm_tool_respond,
)
from astrbot.core.star.register import register_on_platform_loaded as on_platform_loaded
from astrbot.core.star.register import register_on_plugin_error as on_plugin_error
from astrbot.core.star.register import register_on_using_llm_tool as on_using_llm_tool
from astrbot.core.star.register import (
register_on_waiting_llm_request as on_waiting_llm_request,
@@ -53,7 +52,6 @@ __all__ = [
"on_decorating_result",
"on_llm_request",
"on_llm_response",
"on_plugin_error",
"on_platform_loaded",
"on_waiting_llm_request",
"permission_type",
@@ -4,7 +4,6 @@ from astrbot.api import sp, star
from astrbot.api.event import AstrMessageEvent, MessageEventResult
from astrbot.core.platform.astr_message_event import MessageSession
from astrbot.core.platform.message_type import MessageType
from astrbot.core.utils.active_event_registry import active_event_registry
from .utils.rst_scene import RstScene
@@ -63,7 +62,6 @@ class ConversationCommands:
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
active_event_registry.stop_all(umo, exclude=message)
await sp.remove_async(
scope="umo",
scope_id=umo,
@@ -88,8 +86,6 @@ class ConversationCommands:
)
return
active_event_registry.stop_all(umo, exclude=message)
await self.context.conversation_manager.update_conversation(
umo,
cid,
@@ -225,7 +221,6 @@ class ConversationCommands:
cfg = self.context.get_config(umo=message.unified_msg_origin)
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
active_event_registry.stop_all(message.unified_msg_origin, exclude=message)
await sp.remove_async(
scope="umo",
scope_id=message.unified_msg_origin,
@@ -234,7 +229,6 @@ class ConversationCommands:
message.set_result(MessageEventResult().message("已创建新对话。"))
return
active_event_registry.stop_all(message.unified_msg_origin, exclude=message)
cpersona = await self._get_current_persona_id(message.unified_msg_origin)
cid = await self.context.conversation_manager.new_conversation(
message.unified_msg_origin,
@@ -327,8 +321,7 @@ class ConversationCommands:
async def del_conv(self, message: AstrMessageEvent) -> None:
"""删除当前对话"""
umo = message.unified_msg_origin
cfg = self.context.get_config(umo=umo)
cfg = self.context.get_config(umo=message.unified_msg_origin)
is_unique_session = cfg["platform_settings"]["unique_session"]
if message.get_group_id() and not is_unique_session and message.role != "admin":
# 群聊,没开独立会话,发送人不是管理员
@@ -341,17 +334,18 @@ class ConversationCommands:
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
active_event_registry.stop_all(umo, exclude=message)
await sp.remove_async(
scope="umo",
scope_id=umo,
scope_id=message.unified_msg_origin,
key=THIRD_PARTY_AGENT_RUNNER_KEY[agent_runner_type],
)
message.set_result(MessageEventResult().message("重置对话成功。"))
return
session_curr_cid = (
await self.context.conversation_manager.get_curr_conversation_id(umo)
await self.context.conversation_manager.get_curr_conversation_id(
message.unified_msg_origin,
)
)
if not session_curr_cid:
@@ -362,10 +356,8 @@ class ConversationCommands:
)
return
active_event_registry.stop_all(umo, exclude=message)
await self.context.conversation_manager.delete_conversation(
umo,
message.unified_msg_origin,
session_curr_cid,
)
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.17.6"
__version__ = "4.17.2"
@@ -357,7 +357,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
),
),
)
return
if not llm_resp.tools_call_name:
# 如果没有工具调用,转换到完成状态
-3
View File
@@ -285,9 +285,6 @@ class ToolSet:
prop_value = convert_schema(value)
if "default" in prop_value:
del prop_value["default"]
# see #5217
if "additionalProperties" in prop_value:
del prop_value["additionalProperties"]
properties[key] = prop_value
if properties:
+9
View File
@@ -42,6 +42,7 @@ from astrbot.core.message.components import File, Image, Reply
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.provider import Provider
from astrbot.core.provider.entities import ProviderRequest
from astrbot.core.provider.manager import llm_tools
from astrbot.core.skills.skill_manager import SkillManager, build_skills_prompt
from astrbot.core.star.context import Context
from astrbot.core.star.star_handler import star_map
@@ -769,6 +770,14 @@ def _plugin_tool_fix(event: AstrMessageEvent, req: ProviderRequest) -> None:
if plugin.name in event.plugins_name or plugin.reserved:
new_tool_set.add_tool(tool)
req.func_tool = new_tool_set
else:
# mcp tools
tool_set = req.func_tool
if not tool_set:
tool_set = ToolSet()
for tool in llm_tools.func_list:
if isinstance(tool, MCPTool):
tool_set.add_tool(tool)
async def _handle_webchat(
+7 -27
View File
@@ -5,9 +5,8 @@ import mcp
from astrbot.api import FunctionTool
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.tool import ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext, AstrMessageEvent
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.computer.computer_client import get_booter, get_local_booter
from astrbot.core.message.message_event_result import MessageChain
param_schema = {
"type": "object",
@@ -26,22 +25,7 @@ param_schema = {
}
def _check_admin_permission(context: ContextWrapper[AstrAgentContext]) -> str | None:
cfg = context.context.context.get_config(
umo=context.context.event.unified_msg_origin
)
provider_settings = cfg.get("provider_settings", {})
require_admin = provider_settings.get("computer_use_require_admin", True)
if require_admin and context.context.event.role != "admin":
return (
"error: Permission denied. Python execution is only allowed for admin users. "
"Tell user to set admins in `AstrBot WebUI -> Config -> General Config` by adding their user ID to the admins list if they need this feature."
f"User's ID is: {context.context.event.get_sender_id()}. User's ID can be found by using /sid command."
)
return None
async def handle_result(result: dict, event: AstrMessageEvent) -> ToolExecResult:
def handle_result(result: dict) -> ToolExecResult:
data = result.get("data", {})
output = data.get("output", {})
error = data.get("error", "")
@@ -60,9 +44,6 @@ async def handle_result(result: dict, event: AstrMessageEvent) -> ToolExecResult
type="image", data=img["image/png"], mimeType="image/png"
)
)
if event.get_platform_name() == "webchat":
await event.send(message=MessageChain().base64_image(img["image/png"]))
if text:
resp.content.append(mcp.types.TextContent(type="text", text=text))
@@ -81,15 +62,13 @@ class PythonTool(FunctionTool):
async def call(
self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False
) -> ToolExecResult:
if permission_error := _check_admin_permission(context):
return permission_error
sb = await get_booter(
context.context.context,
context.context.event.unified_msg_origin,
)
try:
result = await sb.python.exec(code, silent=silent)
return await handle_result(result, context.context.event)
return handle_result(result)
except Exception as e:
return f"Error executing code: {str(e)}"
@@ -104,11 +83,12 @@ class LocalPythonTool(FunctionTool):
async def call(
self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False
) -> ToolExecResult:
if permission_error := _check_admin_permission(context):
return permission_error
if context.context.event.role != "admin":
return "error: Permission denied. Local Python execution is only allowed for admin users. Tell user to set admins in AstrBot WebUI."
sb = get_local_booter()
try:
result = await sb.python.exec(code, silent=silent)
return await handle_result(result, context.context.event)
return handle_result(result)
except Exception as e:
return f"Error executing code: {str(e)}"
+2 -17
View File
@@ -9,21 +9,6 @@ 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"
@@ -61,8 +46,8 @@ class ExecuteShellTool(FunctionTool):
background: bool = False,
env: dict = {},
) -> ToolExecResult:
if permission_error := _check_admin_permission(context):
return permission_error
if context.context.event.role != "admin":
return "error: Permission denied. Shell execution is only allowed for admin users. Tell user to Set admins in AstrBot WebUI."
if self.is_local:
sb = get_local_booter()
+1 -31
View File
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.17.6"
VERSION = "4.17.2"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
WEBHOOK_SUPPORTED_PLATFORMS = [
@@ -128,7 +128,6 @@ DEFAULT_CONFIG = {
"add_cron_tools": True,
},
"computer_use_runtime": "local",
"computer_use_require_admin": True,
"sandbox": {
"booter": "shipyard",
"shipyard_endpoint": "",
@@ -1030,30 +1029,6 @@ CONFIG_METADATA_2 = {
"proxy": "",
"custom_headers": {},
},
"AIHubMix": {
"id": "aihubmix",
"provider": "aihubmix",
"type": "aihubmix_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"timeout": 120,
"api_base": "https://aihubmix.com/v1",
"proxy": "",
"custom_headers": {},
},
"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",
@@ -2738,11 +2713,6 @@ CONFIG_METADATA_3 = {
"labels": ["", "本地", "沙箱"],
"hint": "选择 Computer Use 运行环境。",
},
"provider_settings.computer_use_require_admin": {
"description": "需要 AstrBot 管理员权限",
"type": "bool",
"hint": "开启后,需要 AstrBot 管理员权限才能调用使用电脑能力。在平台配置->管理员中可添加管理员。使用 /sid 指令查看管理员 ID。",
},
"provider_settings.sandbox.booter": {
"description": "沙箱环境驱动器",
"type": "string",
+2 -5
View File
@@ -13,19 +13,16 @@ from astrbot.core.knowledge_base.models import (
KBMedia,
KnowledgeBase,
)
from astrbot.core.utils.astrbot_path import get_astrbot_knowledge_base_path
class KBSQLiteDatabase:
def __init__(self, db_path: str | None = None) -> None:
def __init__(self, db_path: str = "data/knowledge_base/kb.db") -> None:
"""初始化知识库数据库
Args:
db_path: 数据库文件路径, 默认位于 AstrBot 数据目录下的 knowledge_base/kb.db
db_path: 数据库文件路径, 默认为 data/knowledge_base/kb.db
"""
if db_path is None:
db_path = str(Path(get_astrbot_knowledge_base_path()) / "kb.db")
self.db_path = db_path
self.DATABASE_URL = f"sqlite+aiosqlite:///{db_path}"
self.inited = False
+2 -3
View File
@@ -3,7 +3,6 @@ from pathlib import Path
from astrbot.core import logger
from astrbot.core.provider.manager import ProviderManager
from astrbot.core.utils.astrbot_path import get_astrbot_knowledge_base_path
# from .chunking.fixed_size import FixedSizeChunker
from .chunking.recursive import RecursiveCharacterChunker
@@ -14,7 +13,7 @@ from .retrieval.manager import RetrievalManager, RetrievalResult
from .retrieval.rank_fusion import RankFusion
from .retrieval.sparse_retriever import SparseRetriever
FILES_PATH = get_astrbot_knowledge_base_path()
FILES_PATH = "data/knowledge_base"
DB_PATH = Path(FILES_PATH) / "kb.db"
"""Knowledge Base storage root directory"""
CHUNKER = RecursiveCharacterChunker()
@@ -28,7 +27,7 @@ class KnowledgeBaseManager:
self,
provider_manager: ProviderManager,
) -> None:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
self.provider_manager = provider_manager
self._session_deleted_callback_registered = False
+22 -28
View File
@@ -25,14 +25,10 @@ import asyncio
import base64
import json
import os
import sys
import uuid
from enum import Enum
if sys.version_info >= (3, 14):
from pydantic import BaseModel
else:
from pydantic.v1 import BaseModel
from pydantic.v1 import BaseModel
from astrbot.core import astrbot_config, file_token_service, logger
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
@@ -89,7 +85,7 @@ class BaseMessageComponent(BaseModel):
class Plain(BaseMessageComponent):
type: ComponentType = ComponentType.Plain
type = ComponentType.Plain
text: str
convert: bool | None = True
@@ -104,7 +100,7 @@ class Plain(BaseMessageComponent):
class Face(BaseMessageComponent):
type: ComponentType = ComponentType.Face
type = ComponentType.Face
id: int
def __init__(self, **_) -> None:
@@ -112,15 +108,13 @@ class Face(BaseMessageComponent):
class Record(BaseMessageComponent):
type: ComponentType = ComponentType.Record
type = ComponentType.Record
file: str | None = ""
magic: bool | None = False
url: str | None = ""
cache: bool | None = True
proxy: bool | None = True
timeout: int | None = 0
# Original text content (e.g. TTS source text), used as caption in fallback scenarios
text: str | None = None
# 额外
path: str | None
@@ -221,7 +215,7 @@ class Record(BaseMessageComponent):
class Video(BaseMessageComponent):
type: ComponentType = ComponentType.Video
type = ComponentType.Video
file: str
cover: str | None = ""
c: int | None = 2
@@ -307,7 +301,7 @@ class Video(BaseMessageComponent):
class At(BaseMessageComponent):
type: ComponentType = ComponentType.At
type = ComponentType.At
qq: int | str # 此处str为all时代表所有人
name: str | None = ""
@@ -329,28 +323,28 @@ class AtAll(At):
class RPS(BaseMessageComponent): # TODO
type: ComponentType = ComponentType.RPS
type = ComponentType.RPS
def __init__(self, **_) -> None:
super().__init__(**_)
class Dice(BaseMessageComponent): # TODO
type: ComponentType = ComponentType.Dice
type = ComponentType.Dice
def __init__(self, **_) -> None:
super().__init__(**_)
class Shake(BaseMessageComponent): # TODO
type: ComponentType = ComponentType.Shake
type = ComponentType.Shake
def __init__(self, **_) -> None:
super().__init__(**_)
class Share(BaseMessageComponent):
type: ComponentType = ComponentType.Share
type = ComponentType.Share
url: str
title: str
content: str | None = ""
@@ -361,7 +355,7 @@ class Share(BaseMessageComponent):
class Contact(BaseMessageComponent): # TODO
type: ComponentType = ComponentType.Contact
type = ComponentType.Contact
_type: str # type 字段冲突
id: int | None = 0
@@ -370,7 +364,7 @@ class Contact(BaseMessageComponent): # TODO
class Location(BaseMessageComponent): # TODO
type: ComponentType = ComponentType.Location
type = ComponentType.Location
lat: float
lon: float
title: str | None = ""
@@ -381,7 +375,7 @@ class Location(BaseMessageComponent): # TODO
class Music(BaseMessageComponent):
type: ComponentType = ComponentType.Music
type = ComponentType.Music
_type: str
id: int | None = 0
url: str | None = ""
@@ -398,7 +392,7 @@ class Music(BaseMessageComponent):
class Image(BaseMessageComponent):
type: ComponentType = ComponentType.Image
type = ComponentType.Image
file: str | None = ""
_type: str | None = ""
subType: int | None = 0
@@ -513,7 +507,7 @@ class Image(BaseMessageComponent):
class Reply(BaseMessageComponent):
type: ComponentType = ComponentType.Reply
type = ComponentType.Reply
id: str | int
"""所引用的消息 ID"""
chain: list["BaseMessageComponent"] | None = []
@@ -549,7 +543,7 @@ class Poke(BaseMessageComponent):
class Forward(BaseMessageComponent):
type: ComponentType = ComponentType.Forward
type = ComponentType.Forward
id: str
def __init__(self, **_) -> None:
@@ -559,7 +553,7 @@ class Forward(BaseMessageComponent):
class Node(BaseMessageComponent):
"""群合并转发消息"""
type: ComponentType = ComponentType.Node
type = ComponentType.Node
id: int | None = 0 # 忽略
name: str | None = "" # qq昵称
uin: str | None = "0" # qq号
@@ -611,7 +605,7 @@ class Node(BaseMessageComponent):
class Nodes(BaseMessageComponent):
type: ComponentType = ComponentType.Nodes
type = ComponentType.Nodes
nodes: list[Node]
def __init__(self, nodes: list[Node], **_) -> None:
@@ -637,7 +631,7 @@ class Nodes(BaseMessageComponent):
class Json(BaseMessageComponent):
type: ComponentType = ComponentType.Json
type = ComponentType.Json
data: dict
def __init__(self, data: str | dict, **_) -> None:
@@ -647,14 +641,14 @@ class Json(BaseMessageComponent):
class Unknown(BaseMessageComponent):
type: ComponentType = ComponentType.Unknown
type = ComponentType.Unknown
text: str
class File(BaseMessageComponent):
"""文件消息段"""
type: ComponentType = ComponentType.File
type = ComponentType.File
name: str | None = "" # 名字
file_: str | None = "" # 本地路径
url: str | None = "" # url
@@ -789,7 +783,7 @@ class File(BaseMessageComponent):
class WechatEmoji(BaseMessageComponent):
type: ComponentType = ComponentType.WechatEmoji
type = ComponentType.WechatEmoji
md5: str | None = ""
md5_len: int | None = 0
cdnurl: str | None = ""
@@ -8,9 +8,9 @@ from astrbot.core import logger
from astrbot.core.message.message_event_result import MessageEventResult
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.star.star import star_map
from astrbot.core.star.star_handler import EventType, StarHandlerMetadata
from astrbot.core.star.star_handler import StarHandlerMetadata
from ...context import PipelineContext, call_event_hook, call_handler
from ...context import PipelineContext, call_handler
from ..stage import Stage
@@ -48,20 +48,10 @@ class StarRequestSubStage(Stage):
yield ret
event.clear_result() # 清除上一个 handler 的结果
except Exception as e:
traceback_text = traceback.format_exc()
logger.error(traceback_text)
logger.error(traceback.format_exc())
logger.error(f"Star {handler.handler_full_name} handle error: {e}")
await call_event_hook(
event,
EventType.OnPluginErrorEvent,
md.name,
handler.handler_name,
e,
traceback_text,
)
if not event.is_stopped() and event.is_at_or_wake_command:
if event.is_at_or_wake_command:
ret = f":(\n\n在调用插件 {md.name} 的处理函数 {handler.handler_name} 时出现异常:{e}"
event.set_result(MessageEventResult().message(ret))
yield
-15
View File
@@ -33,21 +33,6 @@ class RespondStage(Stage):
Comp.Nodes: lambda comp: bool(comp.nodes), # 多个转发节点
Comp.File: lambda comp: bool(comp.file_ or comp.url),
Comp.WechatEmoji: lambda comp: comp.md5 is not None, # 微信表情
Comp.Json: lambda comp: bool(comp.data), # Json 卡片
Comp.Share: lambda comp: bool(comp.url) or bool(comp.title),
Comp.Music: lambda comp: (
(comp.id and comp._type and comp._type != "custom")
or (comp._type == "custom" and comp.url and comp.audio and comp.title)
), # 音乐分享
Comp.Forward: lambda comp: bool(comp.id), # 合并转发
Comp.Location: lambda comp: bool(
comp.lat is not None and comp.lon is not None
), # 位置
Comp.Contact: lambda comp: bool(comp._type and comp.id), # 推荐好友 or 群
Comp.Shake: lambda _: True, # 窗口抖动(戳一戳)
Comp.Dice: lambda _: True, # 掷骰子魔法表情
Comp.RPS: lambda _: True, # 猜拳魔法表情
Comp.Unknown: lambda comp: bool(comp.text and comp.text.strip()),
}
async def initialize(self, ctx: PipelineContext) -> None:
@@ -315,7 +315,6 @@ class ResultDecorateStage(Stage):
Record(
file=url or audio_path,
url=url or audio_path,
text=comp.text,
),
)
if dual_output:
+5 -10
View File
@@ -6,7 +6,6 @@ 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
@@ -80,14 +79,10 @@ class PipelineScheduler:
event (AstrMessageEvent): 事件对象
"""
active_event_registry.register(event)
try:
await self._process_stages(event)
await self._process_stages(event)
# 如果没有发送操作, 则发送一个空消息, 以便于后续的处理
if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent):
await event.send(None)
# 如果没有发送操作, 则发送一个空消息, 以便于后续的处理
if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent):
await event.send(None)
logger.debug("pipeline 执行完毕。")
finally:
active_event_registry.unregister(event)
logger.debug("pipeline 执行完毕。")
@@ -7,14 +7,13 @@ from typing import cast
import aiofiles
import botpy
import botpy.errors
import botpy.message
import botpy.types
import botpy.types.message
from botpy import Client
from botpy.http import Route
from botpy.types import message
from botpy.types.message import MarkdownPayload, Media
from botpy.types.message import Media
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
@@ -26,8 +25,6 @@ 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,
@@ -117,9 +114,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
return None
payload: dict = {
# "content": plain_text,
"markdown": MarkdownPayload(content=plain_text) if plain_text else None,
"msg_type": 2,
"content": plain_text,
"msg_id": self.message_obj.message_id,
}
@@ -150,13 +145,9 @@ class QQOfficialMessageEvent(AstrMessageEvent):
)
payload["media"] = media
payload["msg_type"] = 7
ret = await self._send_with_markdown_fallback(
send_func=lambda retry_payload: self.bot.api.post_group_message(
group_openid=source.group_openid, # type: ignore
**retry_payload,
),
payload=payload,
plain_text=plain_text,
ret = await self.bot.api.post_group_message(
group_openid=source.group_openid,
**payload,
)
case botpy.message.C2CMessage():
@@ -177,49 +168,30 @@ class QQOfficialMessageEvent(AstrMessageEvent):
payload["media"] = media
payload["msg_type"] = 7
if stream:
ret = await self._send_with_markdown_fallback(
send_func=lambda retry_payload: self.post_c2c_message(
openid=source.author.user_openid,
**retry_payload,
stream=stream,
),
payload=payload,
plain_text=plain_text,
ret = await self.post_c2c_message(
openid=source.author.user_openid,
**payload,
stream=stream,
)
else:
ret = await self._send_with_markdown_fallback(
send_func=lambda retry_payload: self.post_c2c_message(
openid=source.author.user_openid,
**retry_payload,
),
payload=payload,
plain_text=plain_text,
ret = await self.post_c2c_message(
openid=source.author.user_openid,
**payload,
)
logger.debug(f"Message sent to C2C: {ret}")
case botpy.message.Message():
if image_path:
payload["file_image"] = image_path
ret = await self._send_with_markdown_fallback(
send_func=lambda retry_payload: self.bot.api.post_message(
channel_id=source.channel_id,
**retry_payload,
),
payload=payload,
plain_text=plain_text,
ret = await self.bot.api.post_message(
channel_id=source.channel_id,
**payload,
)
case botpy.message.DirectMessage():
if image_path:
payload["file_image"] = image_path
ret = await self._send_with_markdown_fallback(
send_func=lambda retry_payload: self.bot.api.post_dms(
guild_id=source.guild_id,
**retry_payload,
),
payload=payload,
plain_text=plain_text,
)
ret = await self.bot.api.post_dms(guild_id=source.guild_id, **payload)
case _:
pass
@@ -230,32 +202,6 @@ class QQOfficialMessageEvent(AstrMessageEvent):
return ret
async def _send_with_markdown_fallback(
self,
send_func,
payload: dict,
plain_text: str,
):
try:
return await send_func(payload)
except botpy.errors.ServerError as err:
if (
self.MARKDOWN_NOT_ALLOWED_ERROR not in str(err)
or not payload.get("markdown")
or not plain_text
):
raise
logger.warning(
"[QQOfficial] markdown 发送被拒绝,回退到 content 模式重试。"
)
fallback_payload = payload.copy()
fallback_payload["markdown"] = None
fallback_payload["content"] = plain_text
if fallback_payload.get("msg_type") == 2:
fallback_payload["msg_type"] = 0
return await send_func(fallback_payload)
async def upload_group_and_c2c_image(
self,
image_base64: str,
@@ -6,7 +6,6 @@ from typing import Any, cast
import telegramify_markdown
from telegram import ReactionTypeCustomEmoji, ReactionTypeEmoji
from telegram.constants import ChatAction
from telegram.error import BadRequest
from telegram.ext import ExtBot
from astrbot import logger
@@ -120,65 +119,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
client, user_name, ChatAction.TYPING, message_thread_id
)
@classmethod
async def _send_voice_with_fallback(
cls,
client: ExtBot,
path: str,
payload: dict[str, Any],
*,
caption: str | None = None,
user_name: str = "",
message_thread_id: str | None = None,
use_media_action: bool = False,
) -> None:
"""Send a voice message, falling back to a document if the user's
privacy settings forbid voice messages (``BadRequest`` with
``Voice_messages_forbidden``).
When *use_media_action* is ``True`` the helper wraps the send calls
with ``_send_media_with_action`` (used by the streaming path).
"""
try:
if use_media_action:
await cls._send_media_with_action(
client,
ChatAction.UPLOAD_VOICE,
client.send_voice,
user_name=user_name,
message_thread_id=message_thread_id,
voice=path,
**cast(Any, payload),
)
else:
await client.send_voice(voice=path, **cast(Any, payload))
except BadRequest as e:
# python-telegram-bot raises BadRequest for Voice_messages_forbidden;
# distinguish the voice-privacy case via the API error message.
if "Voice_messages_forbidden" not in e.message:
raise
logger.warning(
"User privacy settings prevent receiving voice messages, falling back to sending an audio file. "
"To enable voice messages, go to Telegram Settings → Privacy and Security → Voice Messages → set to 'Everyone'."
)
if use_media_action:
await cls._send_media_with_action(
client,
ChatAction.UPLOAD_DOCUMENT,
client.send_document,
user_name=user_name,
message_thread_id=message_thread_id,
document=path,
caption=caption,
**cast(Any, payload),
)
else:
await client.send_document(
document=path,
caption=caption,
**cast(Any, payload),
)
async def _ensure_typing(
self,
user_name: str,
@@ -271,13 +211,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
)
elif isinstance(i, Record):
path = await i.convert_to_file_path()
await cls._send_voice_with_fallback(
client,
path,
payload,
caption=i.text or None,
use_media_action=False,
)
await client.send_voice(voice=path, **cast(Any, payload))
async def send(self, message: MessageChain) -> None:
if self.get_message_type() == MessageType.GROUP_MESSAGE:
@@ -396,14 +330,14 @@ class TelegramPlatformEvent(AstrMessageEvent):
continue
elif isinstance(i, Record):
path = await i.convert_to_file_path()
await self._send_voice_with_fallback(
await self._send_media_with_action(
self.client,
path,
payload,
caption=i.text or delta or None,
ChatAction.UPLOAD_VOICE,
self.client.send_voice,
user_name=user_name,
message_thread_id=message_thread_id,
use_media_action=True,
voice=path,
**cast(Any, payload),
)
continue
else:
@@ -1,14 +1,13 @@
import asyncio
import os
import sys
import time
import uuid
from collections.abc import Awaitable, Callable
from typing import Any, cast
import quart
from requests import Response
from wechatpy import WeChatClient, create_reply, parse_message
from wechatpy import WeChatClient, parse_message
from wechatpy.crypto import WeChatCrypto
from wechatpy.exceptions import InvalidSignatureException
from wechatpy.messages import BaseMessage, ImageMessage, TextMessage, VoiceMessage
@@ -39,12 +38,7 @@ else:
class WeixinOfficialAccountServer:
def __init__(
self,
event_queue: asyncio.Queue,
config: dict,
user_buffer: dict[Any, dict[str, Any]],
) -> None:
def __init__(self, event_queue: asyncio.Queue, config: dict) -> None:
self.server = quart.Quart(__name__)
self.port = int(cast(int | str, config.get("port")))
self.callback_server_host = config.get("callback_server_host", "0.0.0.0")
@@ -68,10 +62,6 @@ class WeixinOfficialAccountServer:
self.callback: Callable[[BaseMessage], Awaitable[None]] | None = None
self.shutdown_event = asyncio.Event()
self._wx_msg_time_out = 4.0 # 微信服务器要求 5 秒内回复
self.user_buffer: dict[str, dict[str, Any]] = user_buffer # from_user -> state
self.active_send_mode = False # 是否启用主动发送模式,启用后 callback 将直接返回回复内容,无需等待微信回调
async def verify(self):
"""内部服务器的 GET 验证入口"""
return await self.handle_verify(quart.request)
@@ -108,22 +98,6 @@ class WeixinOfficialAccountServer:
"""内部服务器的 POST 回调入口"""
return await self.handle_callback(quart.request)
def _maybe_encrypt(self, xml: str, nonce: str | None, timestamp: str | None) -> str:
if xml and "<Encrypt>" not in xml and nonce and timestamp:
return self.crypto.encrypt_message(xml, nonce, timestamp)
return xml or "success"
def _preview(self, msg: BaseMessage, limit: int = 24) -> str:
"""生成消息预览文本,供占位符使用"""
if isinstance(msg, TextMessage):
t = cast(str, msg.content).strip()
return (t[:limit] + "...") if len(t) > limit else (t or "空消息")
if isinstance(msg, ImageMessage):
return "图片"
if isinstance(msg, VoiceMessage):
return "语音"
return getattr(msg, "type", "未知消息")
async def handle_callback(self, request) -> str:
"""处理回调请求,可被统一 webhook 入口复用
@@ -149,152 +123,14 @@ class WeixinOfficialAccountServer:
raise
logger.info(f"解析成功: {msg}")
if not self.callback:
return "success"
# by pass passive reply logic and return active reply directly.
if self.active_send_mode:
if self.callback:
result_xml = await self.callback(msg)
if not result_xml:
return "success"
if isinstance(result_xml, str):
return result_xml
# passive reply
from_user = str(getattr(msg, "source", ""))
msg_id = str(cast(str | int, getattr(msg, "id", "")))
state = self.user_buffer.get(from_user)
def _reply_text(text: str) -> str:
reply_obj = create_reply(text, msg)
reply_xml = reply_obj if isinstance(reply_obj, str) else str(reply_obj)
return self._maybe_encrypt(reply_xml, nonce, timestamp)
# if in cached state, return cached result or placeholder
if state:
logger.debug(f"用户消息缓冲状态: user={from_user} state={state}")
cached = state.get("cached_xml")
# send one cached each time, if cached is empty after pop, remove the buffer
if cached and len(cached) > 0:
logger.info(f"wx buffer hit on trigger: user={from_user}")
cached_xml = cached.pop(0)
if len(cached) == 0:
self.user_buffer.pop(from_user, None)
return _reply_text(cached_xml)
else:
return _reply_text(
cached_xml
+ "\n【后续消息还在缓冲中,回复任意文字继续获取】"
)
task: asyncio.Task | None = cast(asyncio.Task | None, state.get("task"))
placeholder = (
f"【正在思考'{state.get('preview', '...')}'中,已思考"
f"{int(time.monotonic() - state.get('started_at', time.monotonic()))}s,回复任意文字尝试获取回复】"
)
# same msgid => WeChat retry: wait a little; new msgid => user trigger: just placeholder
if task and state.get("msg_id") == msg_id:
done, _ = await asyncio.wait(
{task},
timeout=self._wx_msg_time_out,
return_when=asyncio.FIRST_COMPLETED,
)
if done:
try:
cached = state.get("cached_xml")
# send one cached each time, if cached is empty after pop, remove the buffer
if cached and len(cached) > 0:
logger.info(
f"wx buffer hit on retry window: user={from_user}"
)
cached_xml = cached.pop(0)
if len(cached) == 0:
self.user_buffer.pop(from_user, None)
logger.debug(
f"wx finished message sending in passive window: user={from_user} msg_id={msg_id} "
)
return _reply_text(cached_xml)
else:
logger.debug(
f"wx finished message sending in passive window but not final: user={from_user} msg_id={msg_id} "
)
return _reply_text(
cached_xml
+ "\n【后续消息还在缓冲中,回复任意文字继续获取】"
)
logger.info(
f"wx finished in window but not final; return placeholder: user={from_user} msg_id={msg_id} "
)
return _reply_text(placeholder)
except Exception:
logger.critical(
"wx task failed in passive window", exc_info=True
)
self.user_buffer.pop(from_user, None)
return _reply_text("处理消息失败,请稍后再试。")
logger.info(
f"wx passive window timeout: user={from_user} msg_id={msg_id}"
)
return _reply_text(placeholder)
logger.debug(f"wx trigger while thinking: user={from_user}")
return _reply_text(placeholder)
# create new trigger when state is empty, and store state in buffer
logger.debug(f"wx new trigger: user={from_user} msg_id={msg_id}")
preview = self._preview(msg)
placeholder = (
f"【正在思考'{preview}'中,已思考0s,回复任意文字尝试获取回复】"
)
logger.info(
f"wx start task: user={from_user} msg_id={msg_id} preview={preview}"
)
self.user_buffer[from_user] = state = {
"msg_id": msg_id,
"preview": preview,
"task": None, # set later after task created
"cached_xml": [], # for passive reply
"started_at": time.monotonic(),
}
self.user_buffer[from_user]["task"] = task = asyncio.create_task(
self.callback(msg)
)
# immediate return if done
done, _ = await asyncio.wait(
{task},
timeout=self._wx_msg_time_out,
return_when=asyncio.FIRST_COMPLETED,
)
if done:
try:
cached = state.get("cached_xml", None)
# send one cached each time, if cached is empty after pop, remove the buffer
if cached and len(cached) > 0:
logger.info(f"wx buffer hit immediately: user={from_user}")
cached_xml = cached.pop(0)
if len(cached) == 0:
self.user_buffer.pop(from_user, None)
return _reply_text(cached_xml)
else:
return _reply_text(
cached_xml
+ "\n【后续消息还在缓冲中,回复任意文字继续获取】"
)
logger.info(
f"wx not finished in first window; return placeholder: user={from_user} msg_id={msg_id} "
)
return _reply_text(placeholder)
except Exception:
logger.critical("wx task failed in first window", exc_info=True)
self.user_buffer.pop(from_user, None)
return _reply_text("处理消息失败,请稍后再试。")
logger.info(f"wx first window timeout: user={from_user} msg_id={msg_id}")
return _reply_text(placeholder)
return "success"
async def start_polling(self) -> None:
logger.info(
@@ -340,10 +176,7 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
if not self.api_base_url.endswith("/"):
self.api_base_url += "/"
self.user_buffer: dict[str, dict[str, Any]] = {} # from_user -> state
self.server = WeixinOfficialAccountServer(
self._event_queue, self.config, self.user_buffer
)
self.server = WeixinOfficialAccountServer(self._event_queue, self.config)
self.client = WeChatClient(
self.config["appid"].strip(),
@@ -360,33 +193,28 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
try:
if self.active_send_mode:
await self.convert_message(msg, None)
return None
msg_id = str(cast(str | int, msg.id))
future = self.wexin_event_workers.get(msg_id)
if future:
logger.debug(f"duplicate message id checked: {msg.id}")
else:
future = asyncio.get_event_loop().create_future()
self.wexin_event_workers[msg_id] = future
await self.convert_message(msg, future)
if str(msg.id) in self.wexin_event_workers:
future = self.wexin_event_workers[str(cast(str | int, msg.id))]
logger.debug(f"duplicate message id checked: {msg.id}")
else:
future = asyncio.get_event_loop().create_future()
self.wexin_event_workers[str(cast(str | int, msg.id))] = future
await self.convert_message(msg, future)
# I love shield so much!
result = await asyncio.wait_for(
asyncio.shield(future),
180,
) # wait for 180s
logger.debug(f"Got future result: {result}")
return result
60,
) # wait for 60s
logger.debug(f"Got future result: {result}")
self.wexin_event_workers.pop(str(cast(str | int, msg.id)), None)
return result # xml. see weixin_offacc_event.py
except asyncio.TimeoutError:
logger.info(f"callback 处理消息超时: message_id={msg.id}")
return create_reply("处理消息超时,请稍后再试。", msg)
pass
except Exception as e:
logger.error(f"转换消息时出现异常: {e}")
finally:
self.wexin_event_workers.pop(str(cast(str | int, msg.id)), None)
self.server.callback = callback
self.server.active_send_mode = self.active_send_mode
@override
async def send_by_session(
@@ -508,19 +336,12 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
await self.handle_msg(abm)
async def handle_msg(self, message: AstrBotMessage) -> None:
buffer = self.user_buffer.get(message.sender.user_id, None)
if buffer is None:
logger.critical(
f"用户消息未找到缓冲状态,无法处理消息: user={message.sender.user_id} message_id={message.message_id}"
)
return
message_event = WeixinOfficialAccountPlatformEvent(
message_str=message.message_str,
message_obj=message,
platform_meta=self.meta(),
session_id=message.session_id,
client=self.client,
message_out=buffer,
)
self.commit_event(message_event)
@@ -1,9 +1,9 @@
import asyncio
import os
from typing import Any, cast
from typing import cast
from wechatpy import WeChatClient
from wechatpy.replies import ImageReply, VoiceReply
from wechatpy.replies import ImageReply, TextReply, VoiceReply
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
@@ -20,11 +20,9 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
platform_meta: PlatformMetadata,
session_id: str,
client: WeChatClient,
message_out: dict[Any, Any],
) -> None:
super().__init__(message_str, message_obj, platform_meta, session_id)
self.client = client
self.message_out = message_out
@staticmethod
async def send_with_client(
@@ -34,8 +32,8 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
) -> None:
pass
async def split_plain(self, plain: str, max_length: int = 1024) -> list[str]:
"""将长文本分割成多个小文本, 每个小文本长度不超过 max_length 字符
async def split_plain(self, plain: str) -> list[str]:
"""将长文本分割成多个小文本, 每个小文本长度不超过 2048 字符
Args:
plain (str): 要分割的长文本
@@ -43,18 +41,18 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
list[str]: 分割后的文本列表
"""
if len(plain) <= max_length:
if len(plain) <= 2048:
return [plain]
result = []
start = 0
while start < len(plain):
# 剩下的字符串长度<max_length时结束
if start + max_length >= len(plain):
# 剩下的字符串长度<2048时结束
if start + 2048 >= len(plain):
result.append(plain[start:])
break
# 向前搜索分割标点符号
end = min(start + max_length, len(plain))
end = min(start + 2048, len(plain))
cut_position = end
for i in range(end, start, -1):
if i < len(plain) and plain[i - 1] in [
@@ -89,15 +87,19 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
if isinstance(comp, Plain):
# Split long text messages if needed
plain_chunks = await self.split_plain(comp.text)
if active_send_mode:
for chunk in plain_chunks:
for chunk in plain_chunks:
if active_send_mode:
self.client.message.send_text(message_obj.sender.user_id, chunk)
else:
# disable passive sending, just store the chunks in
logger.debug(
f"split plain into {len(plain_chunks)} chunks for passive reply. Message not sent."
)
self.message_out["cached_xml"] = plain_chunks
else:
reply = TextReply(
content=chunk,
message=cast(dict, self.message_obj.raw_message)["message"],
)
xml = reply.render()
future = cast(dict, self.message_obj.raw_message)["future"]
assert isinstance(future, asyncio.Future)
future.set_result(xml)
await asyncio.sleep(0.5) # Avoid sending too fast
elif isinstance(comp, Image):
img_path = await comp.convert_to_file_path()
-6
View File
@@ -295,12 +295,6 @@ class ProviderManager:
from .sources.zhipu_source import ProviderZhipu as ProviderZhipu
case "groq_chat_completion":
from .sources.groq_source import ProviderGroq as ProviderGroq
case "xai_chat_completion":
from .sources.xai_source import ProviderXAI as ProviderXAI
case "aihubmix_chat_completion":
from .sources.oai_aihubmix_source import (
ProviderAIHubMix as ProviderAIHubMix,
)
case "anthropic_chat_completion":
from .sources.anthropic_source import (
ProviderAnthropic as ProviderAnthropic,
@@ -22,6 +22,7 @@ from astrbot.core.utils.network_utils import (
)
from ..register import register_provider_adapter
from .default import with_model_request_retry
@register_provider_adapter(
@@ -204,6 +205,7 @@ class ProviderAnthropic(Provider):
if usage.output_tokens is not None:
token_usage.output = usage.output_tokens
@with_model_request_retry()
async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse:
if tools:
if tool_list := tools.get_func_desc_anthropic_style():
@@ -265,6 +267,10 @@ class ProviderAnthropic(Provider):
return llm_response
@with_model_request_retry()
async def _create_message_stream(self, payloads: dict, extra_body: dict):
return self.client.messages.stream(**payloads, extra_body=extra_body)
async def _query_stream(
self,
payloads: dict,
@@ -293,9 +299,8 @@ class ProviderAnthropic(Provider):
"type": "enabled",
}
async with self.client.messages.stream(
**payloads, extra_body=extra_body
) as stream:
stream_ctx = await self._create_message_stream(payloads, extra_body)
async with stream_ctx as stream:
assert isinstance(stream, anthropic.AsyncMessageStream)
async for event in stream:
if event.type == "message_start":
+38
View File
@@ -0,0 +1,38 @@
from tenacity import (
AsyncRetrying,
retry,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)
MODEL_REQUEST_RETRY_ATTEMPTS = 5
MODEL_REQUEST_RETRY_WAIT_MAX_SECONDS = 15
MODEL_REQUEST_RETRY_WAIT_MIN_SECONDS = 1
MODEL_REQUEST_RETRY_WAIT_MULTIPLIER = 1
def with_model_request_retry():
return retry(
retry=retry_if_exception_type(Exception),
stop=stop_after_attempt(MODEL_REQUEST_RETRY_ATTEMPTS),
wait=wait_exponential(
multiplier=MODEL_REQUEST_RETRY_WAIT_MULTIPLIER,
min=MODEL_REQUEST_RETRY_WAIT_MIN_SECONDS,
max=MODEL_REQUEST_RETRY_WAIT_MAX_SECONDS,
),
reraise=True,
)
def get_model_request_async_retrying() -> AsyncRetrying:
return AsyncRetrying(
retry=retry_if_exception_type(Exception),
stop=stop_after_attempt(MODEL_REQUEST_RETRY_ATTEMPTS),
wait=wait_exponential(
multiplier=MODEL_REQUEST_RETRY_WAIT_MULTIPLIER,
min=MODEL_REQUEST_RETRY_WAIT_MIN_SECONDS,
max=MODEL_REQUEST_RETRY_WAIT_MAX_SECONDS,
),
reraise=True,
)
+16 -24
View File
@@ -21,6 +21,7 @@ from astrbot.core.utils.io import download_image_by_url
from astrbot.core.utils.network_utils import is_connection_error, log_connection_failure
from ..register import register_provider_adapter
from .default import get_model_request_async_retrying, with_model_request_retry
class SuppressNonTextPartsWarning(logging.Filter):
@@ -513,6 +514,7 @@ class ProviderGoogleGenAI(Provider):
llm_response.reasoning_signature = base64.b64encode(ts).decode("utf-8")
return MessageChain(chain=chain)
@with_model_request_retry()
async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse:
"""非流式请求 Gemini API"""
system_instruction = next(
@@ -601,6 +603,17 @@ class ProviderGoogleGenAI(Provider):
self,
payloads: dict,
tools: ToolSet | None,
) -> AsyncGenerator[LLMResponse, None]:
async for attempt in get_model_request_async_retrying():
with attempt:
async for response in self._query_stream_once(payloads, tools):
yield response
return
async def _query_stream_once(
self,
payloads: dict,
tools: ToolSet | None,
) -> AsyncGenerator[LLMResponse, None]:
"""流式请求 Gemini API"""
system_instruction = next(
@@ -759,18 +772,7 @@ class ProviderGoogleGenAI(Provider):
payloads = {"messages": context_query, "model": model}
retry = 10
keys = self.api_keys.copy()
for _ in range(retry):
try:
return await self._query(payloads, func_tool)
except APIError as e:
if await self._handle_api_error(e, keys):
continue
break
raise Exception("请求失败。")
return await self._query(payloads, func_tool)
async def text_chat_stream(
self,
@@ -814,18 +816,8 @@ class ProviderGoogleGenAI(Provider):
payloads = {"messages": context_query, "model": model}
retry = 10
keys = self.api_keys.copy()
for _ in range(retry):
try:
async for response in self._query_stream(payloads, func_tool):
yield response
break
except APIError as e:
if await self._handle_api_error(e, keys):
continue
break
async for response in self._query_stream(payloads, func_tool):
yield response
async def get_models(self):
try:
@@ -1,17 +0,0 @@
from ..register import register_provider_adapter
from .openai_source import ProviderOpenAIOfficial
@register_provider_adapter(
"aihubmix_chat_completion", "AIHubMix Chat Completion Provider Adapter"
)
class ProviderAIHubMix(ProviderOpenAIOfficial):
def __init__(
self,
provider_config: dict,
provider_settings: dict,
) -> None:
super().__init__(provider_config, provider_settings)
# Reference to: https://aihubmix.com/appstore
# Use this code can enjoy 10% off prices for AIHubMix API calls.
self.client._custom_headers["APP-Code"] = "KRLC5702" # type: ignore
+24 -99
View File
@@ -31,6 +31,7 @@ from astrbot.core.utils.network_utils import (
from astrbot.core.utils.string_utils import normalize_and_dedupe_strings
from ..register import register_provider_adapter
from .default import get_model_request_async_retrying, with_model_request_retry
@register_provider_adapter(
@@ -221,6 +222,7 @@ class ProviderOpenAIOfficial(Provider):
except NotFoundError as e:
raise Exception(f"获取模型列表失败:{e}")
@with_model_request_retry()
async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse:
if tools:
model = payloads.get("model", "").lower()
@@ -246,8 +248,6 @@ class ProviderOpenAIOfficial(Provider):
if isinstance(custom_extra_body, dict):
extra_body.update(custom_extra_body)
model = payloads.get("model", "").lower()
completion = await self.client.chat.completions.create(
**payloads,
stream=False,
@@ -269,6 +269,17 @@ class ProviderOpenAIOfficial(Provider):
self,
payloads: dict,
tools: ToolSet | None,
) -> AsyncGenerator[LLMResponse, None]:
async for attempt in get_model_request_async_retrying():
with attempt:
async for response in self._query_stream_once(payloads, tools):
yield response
return
async def _query_stream_once(
self,
payloads: dict,
tools: ToolSet | None,
) -> AsyncGenerator[LLMResponse, None]:
"""流式查询API,逐步返回结果"""
if tools:
@@ -381,22 +392,13 @@ class ProviderOpenAIOfficial(Provider):
plain string. This method handles both formats.
Args:
raw_content: The raw content from LLM response, can be str, list, dict, or other.
raw_content: The raw content from LLM response, can be str, list, or other.
strip: Whether to strip whitespace from the result. Set to False for
streaming chunks to preserve spaces between words.
Returns:
Normalized plain text string.
"""
# Handle dict format (e.g., {"type": "text", "text": "..."})
if isinstance(raw_content, dict):
if "text" in raw_content:
text_val = raw_content.get("text", "")
return str(text_val) if text_val is not None else ""
# For other dict formats, return empty string and log
logger.warning(f"Unexpected dict format content: {raw_content}")
return ""
if isinstance(raw_content, list):
# Check if this looks like OpenAI content-part format
# Only process if at least one item has {'type': 'text', 'text': ...} structure
@@ -459,8 +461,7 @@ class ProviderOpenAIOfficial(Provider):
return "".join(text_parts)
return content
# Fallback for other types (int, float, etc.)
return str(raw_content) if raw_content is not None else ""
return str(raw_content)
async def _parse_openai_completion(
self, completion: ChatCompletion, tools: ToolSet | None
@@ -726,7 +727,7 @@ class ProviderOpenAIOfficial(Provider):
extra_user_content_parts=None,
**kwargs,
) -> LLMResponse:
payloads, context_query = await self._prepare_chat_payload(
payloads, _ = await self._prepare_chat_payload(
prompt,
image_urls,
contexts,
@@ -738,47 +739,9 @@ class ProviderOpenAIOfficial(Provider):
)
llm_response = None
max_retries = 10
available_api_keys = self.api_keys.copy()
chosen_key = random.choice(available_api_keys)
image_fallback_used = False
last_exception = None
retry_cnt = 0
for retry_cnt in range(max_retries):
try:
self.client.api_key = chosen_key
llm_response = await self._query(payloads, func_tool)
break
except Exception as e:
last_exception = e
(
success,
chosen_key,
available_api_keys,
payloads,
context_query,
func_tool,
image_fallback_used,
) = await self._handle_api_error(
e,
payloads,
context_query,
func_tool,
chosen_key,
available_api_keys,
retry_cnt,
max_retries,
image_fallback_used=image_fallback_used,
)
if success:
break
if retry_cnt == max_retries - 1 or llm_response is None:
logger.error(f"API 调用失败,重试 {max_retries} 次仍然失败。")
if last_exception is None:
raise Exception("未知错误")
raise last_exception
if self.api_keys:
self.client.api_key = random.choice(self.api_keys)
llm_response = await self._query(payloads, func_tool)
return llm_response
async def text_chat_stream(
@@ -794,7 +757,7 @@ class ProviderOpenAIOfficial(Provider):
**kwargs,
) -> AsyncGenerator[LLMResponse, None]:
"""流式对话,与服务商交互并逐步返回结果"""
payloads, context_query = await self._prepare_chat_payload(
payloads, _ = await self._prepare_chat_payload(
prompt,
image_urls,
contexts,
@@ -804,48 +767,10 @@ class ProviderOpenAIOfficial(Provider):
**kwargs,
)
max_retries = 10
available_api_keys = self.api_keys.copy()
chosen_key = random.choice(available_api_keys)
image_fallback_used = False
last_exception = None
retry_cnt = 0
for retry_cnt in range(max_retries):
try:
self.client.api_key = chosen_key
async for response in self._query_stream(payloads, func_tool):
yield response
break
except Exception as e:
last_exception = e
(
success,
chosen_key,
available_api_keys,
payloads,
context_query,
func_tool,
image_fallback_used,
) = await self._handle_api_error(
e,
payloads,
context_query,
func_tool,
chosen_key,
available_api_keys,
retry_cnt,
max_retries,
image_fallback_used=image_fallback_used,
)
if success:
break
if retry_cnt == max_retries - 1:
logger.error(f"API 调用失败,重试 {max_retries} 次仍然失败。")
if last_exception is None:
raise Exception("未知错误")
raise last_exception
if self.api_keys:
self.client.api_key = random.choice(self.api_keys)
async for response in self._query_stream(payloads, func_tool):
yield response
async def _remove_image_from_context(self, contexts: list):
"""从上下文中删除所有带有 image 的记录"""
@@ -7,14 +7,12 @@ import asyncio
import os
import re
from datetime import datetime
from pathlib import Path
from typing import cast
from funasr_onnx import SenseVoiceSmall
from funasr_onnx.utils.postprocess_utils import rich_transcription_postprocess
from astrbot.core import logger
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from astrbot.core.utils.io import download_file
from astrbot.core.utils.tencent_record_helper import tencent_silk_to_wav
@@ -52,9 +50,7 @@ class ProviderSenseVoiceSTTSelfHost(STTProvider):
async def get_timestamped_path(self) -> str:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
temp_dir = Path(get_astrbot_temp_path())
temp_dir.mkdir(parents=True, exist_ok=True)
return str(temp_dir / timestamp)
return os.path.join("data", "temp", f"{timestamp}")
async def _is_silk_file(self, file_path) -> bool:
silk_header = b"SILK"
@@ -11,7 +11,6 @@ class PlatformAdapterType(enum.Flag):
QQOFFICIAL = enum.auto()
TELEGRAM = enum.auto()
WECOM = enum.auto()
WECOM_AI_BOT = enum.auto()
LARK = enum.auto()
DINGTALK = enum.auto()
DISCORD = enum.auto()
@@ -27,7 +26,6 @@ class PlatformAdapterType(enum.Flag):
| QQOFFICIAL
| TELEGRAM
| WECOM
| WECOM_AI_BOT
| LARK
| DINGTALK
| DISCORD
@@ -46,7 +44,6 @@ ADAPTER_NAME_2_TYPE = {
"qq_official": PlatformAdapterType.QQOFFICIAL,
"telegram": PlatformAdapterType.TELEGRAM,
"wecom": PlatformAdapterType.WECOM,
"wecom_ai_bot": PlatformAdapterType.WECOM_AI_BOT,
"lark": PlatformAdapterType.LARK,
"dingtalk": PlatformAdapterType.DINGTALK,
"discord": PlatformAdapterType.DISCORD,
-2
View File
@@ -13,7 +13,6 @@ from .star_handler import (
register_on_llm_response,
register_on_llm_tool_respond,
register_on_platform_loaded,
register_on_plugin_error,
register_on_using_llm_tool,
register_on_waiting_llm_request,
register_permission_type,
@@ -33,7 +32,6 @@ __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,24 +339,6 @@ def register_on_platform_loaded(**kwargs):
return decorator
def register_on_plugin_error(**kwargs):
"""当插件处理消息异常时触发。
Hook 参数:
event, plugin_name, handler_name, error, traceback_text
说明:
hook 中调用 `event.stop_event()` 可屏蔽默认报错回显
并由插件自行决定是否转发到其他会话
"""
def decorator(awaitable):
_ = get_handler_or_create(awaitable, EventType.OnPluginErrorEvent, **kwargs)
return awaitable
return decorator
def register_on_waiting_llm_request(**kwargs):
"""当等待调用 LLM 时的通知事件(在获取锁之前)
-6
View File
@@ -61,12 +61,6 @@ class StarMetadata:
logo_path: str | None = None
"""插件 Logo 的路径"""
support_platforms: list[str] = field(default_factory=list)
"""插件声明支持的平台适配器 ID 列表(对应 ADAPTER_NAME_2_TYPE 的 key"""
astrbot_version: str | None = None
"""插件要求的 AstrBot 版本范围(PEP 440 specifier,如 >=4.13.0,<4.17.0"""
def __str__(self) -> str:
return f"Plugin {self.name} ({self.version}) by {self.author}: {self.desc}"
-9
View File
@@ -97,14 +97,6 @@ class StarHandlerRegistry(Generic[T]):
plugins_name: list[str] | None = None,
) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ...
@overload
def get_handlers_by_event_type(
self,
event_type: Literal[EventType.OnPluginErrorEvent],
only_activated=True,
plugins_name: list[str] | None = None,
) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ...
@overload
def get_handlers_by_event_type(
self,
@@ -200,7 +192,6 @@ class EventType(enum.Enum):
OnUsingLLMToolEvent = enum.auto() # 使用 LLM 工具
OnLLMToolRespondEvent = enum.auto() # 调用函数工具后
OnAfterMessageSentEvent = enum.auto() # 发送消息后
OnPluginErrorEvent = enum.auto() # 插件处理消息异常时
H = TypeVar("H", bound=Callable[..., Any])
+16 -117
View File
@@ -11,13 +11,10 @@ import traceback
from types import ModuleType
import yaml
from packaging.specifiers import InvalidSpecifier, SpecifierSet
from packaging.version import InvalidVersion, Version
from astrbot.core import logger, pip_installer, sp
from astrbot.core.agent.handoff import FunctionTool, HandoffTool
from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.config.default import VERSION
from astrbot.core.platform.register import unregister_platform_adapters_by_module
from astrbot.core.provider.register import llm_tools
from astrbot.core.utils.astrbot_path import (
@@ -43,10 +40,6 @@ 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()
@@ -275,58 +268,10 @@ class PluginManager:
version=metadata["version"],
repo=metadata["repo"] if "repo" in metadata else None,
display_name=metadata.get("display_name", None),
support_platforms=(
[
platform_id
for platform_id in metadata["support_platforms"]
if isinstance(platform_id, str)
]
if isinstance(metadata.get("support_platforms"), list)
else []
),
astrbot_version=(
metadata["astrbot_version"]
if isinstance(metadata.get("astrbot_version"), str)
else None
),
)
return metadata
@staticmethod
def _validate_astrbot_version_specifier(
version_spec: str | None,
) -> tuple[bool, str | None]:
if not version_spec:
return True, None
normalized_spec = version_spec.strip()
if not normalized_spec:
return True, None
try:
specifier = SpecifierSet(normalized_spec)
except InvalidSpecifier:
return (
False,
"astrbot_version 格式无效,请使用 PEP 440 版本范围格式,例如 >=4.16,<5。",
)
try:
current_version = Version(VERSION)
except InvalidVersion:
return (
False,
f"AstrBot 当前版本 {VERSION} 无法被解析,无法校验插件版本范围。",
)
if current_version not in specifier:
return (
False,
f"当前 AstrBot 版本为 {VERSION},不满足插件要求的 astrbot_version: {normalized_spec}",
)
return True, None
@staticmethod
def _get_plugin_related_modules(
plugin_root_dir: str,
@@ -463,12 +408,7 @@ class PluginManager:
return result
async def load(
self,
specified_module_path=None,
specified_dir_name=None,
ignore_version_check: bool = False,
):
async def load(self, specified_module_path=None, specified_dir_name=None):
"""载入插件。
specified_module_path 或者 specified_dir_name 不为 None 只载入指定的插件
@@ -567,37 +507,12 @@ class PluginManager:
metadata.version = metadata_yaml.version
metadata.repo = metadata_yaml.repo
metadata.display_name = metadata_yaml.display_name
metadata.support_platforms = metadata_yaml.support_platforms
metadata.astrbot_version = metadata_yaml.astrbot_version
except Exception as e:
logger.warning(
f"插件 {root_dir_name} 元数据载入失败: {e!s}。使用默认元数据。",
)
if not ignore_version_check:
is_valid, error_message = (
self._validate_astrbot_version_specifier(
metadata.astrbot_version,
)
)
if not is_valid:
raise PluginVersionIncompatibleError(
error_message
or "The plugin is not compatible with the current AstrBot version."
)
logger.info(metadata)
metadata.config = plugin_config
p_name = (metadata.name or "unknown").lower().replace("/", "_")
p_author = (metadata.author or "unknown").lower().replace("/", "_")
plugin_id = f"{p_author}/{p_name}"
# 在实例化前注入类属性,保证插件 __init__ 可读取这些值
if metadata.star_cls_type:
setattr(metadata.star_cls_type, "name", p_name)
setattr(metadata.star_cls_type, "author", p_author)
setattr(metadata.star_cls_type, "plugin_id", plugin_id)
if path not in inactivated_plugins:
# 只有没有禁用插件时才实例化插件类
if plugin_config and metadata.star_cls_type:
@@ -615,10 +530,17 @@ class PluginManager:
context=self.context,
)
if metadata.star_cls:
setattr(metadata.star_cls, "name", p_name)
setattr(metadata.star_cls, "author", p_author)
setattr(metadata.star_cls, "plugin_id", plugin_id)
p_name = (metadata.name or "unknown").lower().replace("/", "_")
p_author = (
(metadata.author or "unknown").lower().replace("/", "_")
)
setattr(metadata.star_cls, "name", p_name)
setattr(metadata.star_cls, "author", p_author)
setattr(
metadata.star_cls,
"plugin_id",
f"{p_author}/{p_name}",
)
else:
logger.info(f"插件 {metadata.name} 已被禁用。")
@@ -696,19 +618,6 @@ class PluginManager:
)
if not metadata:
raise Exception(f"无法找到插件 {plugin_dir_path} 的元数据。")
if not ignore_version_check:
is_valid, error_message = (
self._validate_astrbot_version_specifier(
metadata.astrbot_version,
)
)
if not is_valid:
raise PluginVersionIncompatibleError(
error_message
or "The plugin is not compatible with the current AstrBot version."
)
metadata.star_cls = obj
metadata.config = plugin_config
metadata.module = module
@@ -842,9 +751,7 @@ class PluginManager:
f"清理安装失败插件配置失败: {plugin_config_path},原因: {e!s}",
)
async def install_plugin(
self, repo_url: str, proxy: str = "", ignore_version_check: bool = False
):
async def install_plugin(self, repo_url: str, proxy=""):
"""从仓库 URL 安装插件
从指定的仓库 URL 下载并安装插件然后加载该插件到系统中
@@ -878,10 +785,7 @@ class PluginManager:
# reload the plugin
dir_name = os.path.basename(plugin_path)
success, error_message = await self.load(
specified_dir_name=dir_name,
ignore_version_check=ignore_version_check,
)
success, error_message = await self.load(specified_dir_name=dir_name)
if not success:
raise Exception(
error_message
@@ -1185,9 +1089,7 @@ class PluginManager:
await self.reload(plugin_name)
async def install_plugin_from_file(
self, zip_file_path: str, ignore_version_check: bool = False
):
async def install_plugin_from_file(self, zip_file_path: str):
dir_name = os.path.basename(zip_file_path).replace(".zip", "")
dir_name = dir_name.removesuffix("-master").removesuffix("-main").lower()
desti_dir = os.path.join(self.plugin_store_path, dir_name)
@@ -1243,10 +1145,7 @@ class PluginManager:
except BaseException as e:
logger.warning(f"删除插件压缩包失败: {e!s}")
# await self.reload()
success, error_message = await self.load(
specified_dir_name=dir_name,
ignore_version_check=ignore_version_check,
)
success, error_message = await self.load(specified_dir_name=dir_name)
if not success:
raise Exception(
error_message
+2 -2
View File
@@ -148,8 +148,8 @@ class AstrBotUpdator(RepoZipUpdator):
update_data = await self.fetch_release_info(self.ASTRBOT_RELEASE_API, latest)
file_url = None
if os.environ.get("ASTRBOT_CLI") or os.environ.get("ASTRBOT_LAUNCHER"):
raise Exception("不支持更新此方式启动的AstrBot") # 避免版本管理混乱
if os.environ.get("ASTRBOT_CLI"):
raise Exception("不支持更新CLI启动的AstrBot") # 避免版本管理混乱
if latest:
latest_version = update_data[0]["tag_name"]
@@ -1,50 +0,0 @@
from __future__ import annotations
from collections import defaultdict
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from astrbot.core.platform import AstrMessageEvent
class ActiveEventRegistry:
"""维护 unified_msg_origin 到活跃事件的映射。
用于在 reset 等场景下终止该会话正在处理的事件
"""
def __init__(self) -> None:
self._events: dict[str, set[AstrMessageEvent]] = defaultdict(set)
def register(self, event: AstrMessageEvent) -> None:
self._events[event.unified_msg_origin].add(event)
def unregister(self, event: AstrMessageEvent) -> None:
umo = event.unified_msg_origin
self._events[umo].discard(event)
if not self._events[umo]:
del self._events[umo]
def stop_all(
self,
umo: str,
exclude: AstrMessageEvent | None = None,
) -> int:
"""终止指定 UMO 的所有活跃事件。
Args:
umo: 统一消息来源标识符
exclude: 需要排除的事件通常是发起 reset 的事件本身
Returns:
被终止的事件数量
"""
count = 0
for event in list(self._events.get(umo, [])):
if event is not exclude:
event.stop_event()
count += 1
return count
active_event_registry = ActiveEventRegistry()
+2 -2
View File
@@ -15,7 +15,7 @@ Skills 目录路径:固定为数据目录下的 skills 目录
import os
from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime
from astrbot.core.utils.runtime_env import is_packaged_electron_runtime
def get_astrbot_path() -> str:
@@ -29,7 +29,7 @@ def get_astrbot_root() -> str:
"""获取Astrbot根目录路径"""
if path := os.environ.get("ASTRBOT_ROOT"):
return os.path.realpath(path)
if is_packaged_desktop_runtime():
if is_packaged_electron_runtime():
return os.path.realpath(os.path.join(os.path.expanduser("~"), ".astrbot"))
return os.path.realpath(os.getcwd())
+4 -4
View File
@@ -12,7 +12,7 @@ import threading
from collections import deque
from astrbot.core.utils.astrbot_path import get_astrbot_site_packages_path
from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime
from astrbot.core.utils.runtime_env import is_packaged_electron_runtime
logger = logging.getLogger("astrbot")
@@ -35,7 +35,7 @@ def _get_pip_main():
"pip module is unavailable "
f"(sys.executable={sys.executable}, "
f"frozen={getattr(sys, 'frozen', False)}, "
f"ASTRBOT_DESKTOP_CLIENT={os.environ.get('ASTRBOT_DESKTOP_CLIENT')})"
f"ASTRBOT_ELECTRON_CLIENT={os.environ.get('ASTRBOT_ELECTRON_CLIENT')})"
) from exc
return pip_main
@@ -556,7 +556,7 @@ class PipInstaller:
args.extend(["--trusted-host", "mirrors.aliyun.com", "-i", index_url])
target_site_packages = None
if is_packaged_desktop_runtime():
if is_packaged_electron_runtime():
target_site_packages = get_astrbot_site_packages_path()
os.makedirs(target_site_packages, exist_ok=True)
_prepend_sys_path(target_site_packages)
@@ -582,7 +582,7 @@ class PipInstaller:
def prefer_installed_dependencies(self, requirements_path: str) -> None:
"""优先使用已安装在插件 site-packages 中的依赖,不执行安装。"""
if not is_packaged_desktop_runtime():
if not is_packaged_electron_runtime():
return
target_site_packages = get_astrbot_site_packages_path()
+2 -2
View File
@@ -6,5 +6,5 @@ def is_frozen_runtime() -> bool:
return bool(getattr(sys, "frozen", False))
def is_packaged_desktop_runtime() -> bool:
return is_frozen_runtime() and os.environ.get("ASTRBOT_DESKTOP_CLIENT") == "1"
def is_packaged_electron_runtime() -> bool:
return is_frozen_runtime() and os.environ.get("ASTRBOT_ELECTRON_CLIENT") == "1"
+3 -5
View File
@@ -64,13 +64,11 @@ class AuthRoute(Route):
new_pwd = post_data.get("new_password", None)
new_username = post_data.get("new_username", None)
if not new_pwd and not new_username:
return Response().error("新用户名和新密码不能同时为空").__dict__
return (
Response().error("新用户名和新密码不能同时为空,你改了个寂寞").__dict__
)
# Verify password confirmation
if new_pwd:
confirm_pwd = post_data.get("confirm_password", None)
if confirm_pwd != new_pwd:
return Response().error("两次输入的新密码不一致").__dict__
self.config["dashboard"]["password"] = new_pwd
if new_username:
self.config["dashboard"]["username"] = new_username
+7 -69
View File
@@ -19,14 +19,8 @@ from astrbot.core.star.filter.command_group import CommandGroupFilter
from astrbot.core.star.filter.permission import PermissionTypeFilter
from astrbot.core.star.filter.regex import RegexFilter
from astrbot.core.star.star_handler import EventType, star_handlers_registry
from astrbot.core.star.star_manager import (
PluginManager,
PluginVersionIncompatibleError,
)
from astrbot.core.utils.astrbot_path import (
get_astrbot_data_path,
get_astrbot_temp_path,
)
from astrbot.core.star.star_manager import PluginManager
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from .route import Response, Route, RouteContext
@@ -52,7 +46,6 @@ class PluginRoute(Route):
super().__init__(context)
self.routes = {
"/plugin/get": ("GET", self.get_plugins),
"/plugin/check-compat": ("POST", self.check_plugin_compatibility),
"/plugin/install": ("POST", self.install_plugin),
"/plugin/install-upload": ("POST", self.install_plugin_upload),
"/plugin/update": ("POST", self.update_plugin),
@@ -80,32 +73,10 @@ class PluginRoute(Route):
EventType.OnDecoratingResultEvent: "回复消息前",
EventType.OnCallingFuncToolEvent: "函数工具",
EventType.OnAfterMessageSentEvent: "发送消息后",
EventType.OnPluginErrorEvent: "插件报错时",
}
self._logo_cache = {}
async def check_plugin_compatibility(self):
try:
data = await request.get_json()
version_spec = data.get("astrbot_version", "")
is_valid, message = self.plugin_manager._validate_astrbot_version_specifier(
version_spec
)
return (
Response()
.ok(
{
"compatible": is_valid,
"message": message,
"astrbot_version": version_spec,
}
)
.__dict__
)
except Exception as e:
return Response().error(str(e)).__dict__
async def reload_failed_plugins(self):
if DEMO_MODE:
return (
@@ -146,7 +117,7 @@ class PluginRoute(Route):
try:
success, message = await self.plugin_manager.reload(plugin_name)
if not success:
return Response().error(message or "插件重载失败").__dict__
return Response().error(message).__dict__
return Response().ok(None, "重载成功。").__dict__
except Exception as e:
logger.error(f"/api/plugin/reload: {traceback.format_exc()}")
@@ -224,11 +195,10 @@ class PluginRoute(Route):
def _build_registry_source(self, custom_url: str | None) -> RegistrySource:
"""构建注册表源信息"""
data_dir = get_astrbot_data_path()
if custom_url:
# 对自定义URL生成一个安全的文件名
url_hash = hashlib.md5(custom_url.encode()).hexdigest()[:8]
cache_file = os.path.join(data_dir, f"plugins_custom_{url_hash}.json")
cache_file = f"data/plugins_custom_{url_hash}.json"
# 更安全的后缀处理方式
if custom_url.endswith(".json"):
@@ -238,7 +208,7 @@ class PluginRoute(Route):
urls = [custom_url]
else:
cache_file = os.path.join(data_dir, "plugins.json")
cache_file = "data/plugins.json"
md5_url = "https://api.soulter.top/astrbot/plugins-md5"
urls = [
"https://api.soulter.top/astrbot/plugins",
@@ -374,8 +344,6 @@ class PluginRoute(Route):
),
"display_name": plugin.display_name,
"logo": f"/api/file/{logo_url}" if logo_url else None,
"support_platforms": plugin.support_platforms,
"astrbot_version": plugin.astrbot_version,
}
# 检查是否为全空的幽灵插件
if not any(
@@ -470,7 +438,6 @@ class PluginRoute(Route):
post_data = await request.get_json()
repo_url = post_data["url"]
ignore_version_check = bool(post_data.get("ignore_version_check", False))
proxy: str = post_data.get("proxy", None)
if proxy:
@@ -478,23 +445,10 @@ class PluginRoute(Route):
try:
logger.info(f"正在安装插件 {repo_url}")
plugin_info = await self.plugin_manager.install_plugin(
repo_url,
proxy,
ignore_version_check=ignore_version_check,
)
plugin_info = await self.plugin_manager.install_plugin(repo_url, proxy)
# self.core_lifecycle.restart()
logger.info(f"安装插件 {repo_url} 成功。")
return Response().ok(plugin_info, "安装成功。").__dict__
except PluginVersionIncompatibleError as e:
return {
"status": "warning",
"message": str(e),
"data": {
"warning_type": "astrbot_version_incompatible",
"can_ignore": True,
},
}
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
@@ -510,32 +464,16 @@ class PluginRoute(Route):
try:
file = await request.files
file = file["file"]
form_data = await request.form
ignore_version_check = (
str(form_data.get("ignore_version_check", "false")).lower() == "true"
)
logger.info(f"正在安装用户上传的插件 {file.filename}")
file_path = os.path.join(
get_astrbot_temp_path(),
f"plugin_upload_{file.filename}",
)
await file.save(file_path)
plugin_info = await self.plugin_manager.install_plugin_from_file(
file_path,
ignore_version_check=ignore_version_check,
)
plugin_info = await self.plugin_manager.install_plugin_from_file(file_path)
# self.core_lifecycle.restart()
logger.info(f"安装插件 {file.filename} 成功")
return Response().ok(plugin_info, "安装成功。").__dict__
except PluginVersionIncompatibleError as e:
return {
"status": "warning",
"message": str(e),
"data": {
"warning_type": "astrbot_version_incompatible",
"can_ignore": True,
},
}
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
+5 -4
View File
@@ -1,4 +1,5 @@
import base64
import os
import traceback
from io import BytesIO
@@ -50,14 +51,14 @@ async def generate_tsne_visualization(
return None
kb = kb_helper.kb
index_path = kb_helper.kb_dir / "index.faiss"
index_path = f"data/knowledge_base/{kb.kb_id}/index.faiss"
# 读取 FAISS 索引
if not index_path.exists():
logger.warning(f"FAISS 索引不存在: {index_path!s}")
if not os.path.exists(index_path):
logger.warning(f"FAISS 索引不存在: {index_path}")
return None
index = faiss.read_index(str(index_path))
index = faiss.read_index(index_path)
if index.ntotal == 0:
logger.warning("索引为空")
-27
View File
@@ -1,27 +0,0 @@
## What's Changed
### 修复
- ‼️ 修复 Python 3.14 环境下 `'Plain' object has no attribute 'text'` 报错问题 ([#5154](https://github.com/AstrBotDevs/AstrBot/issues/5154))。
- ‼️ 修复插件元数据处理流程:在实例化前注入必要属性,避免初始化阶段元数据缺失 ([#5155](https://github.com/AstrBotDevs/AstrBot/issues/5155))。
- 修复桌面端后端构建中 AstrBot 内置插件运行时依赖未打包的问题 ([#5146](https://github.com/AstrBotDevs/AstrBot/issues/5146))。
- 修复通过 AstrBot Launcher 启动时仍被检测并触发更新的问题。
### 优化
- Webchat 下,使用 `astrbot_execute_ipython` 工具如果返回了图片,会自动将图片发送到聊天中。
### 其他
- 执行 `ruff format` 代码格式整理。
## What's Changed (EN)
### Fixes
- ‼️ Fixed plugin metadata handling by injecting required attributes before instantiation to avoid missing metadata during initialization ([#5155](https://github.com/AstrBotDevs/AstrBot/issues/5155)).
- ‼️ Fixed `'Plain' object has no attribute 'text'` error when using Python 3.14 ([#5154](https://github.com/AstrBotDevs/AstrBot/issues/5154)).
- Fixed missing runtime dependencies for built-in plugins in desktop backend builds ([#5146](https://github.com/AstrBotDevs/AstrBot/issues/5146)).
- Fixed update checks being triggered when AstrBot is launched via AstrBot Launcher.
### Improvements
- In Webchat, when using the `astrbot_execute_ipython` tool, if an image is returned, it will automatically be sent to the chat.
### Others
- Applied `ruff format` code formatting.
-32
View File
@@ -1,32 +0,0 @@
## What's Changed
### 新增
- 新增 NVIDIA Provider 模板,便于快速接入 NVIDIA 模型服务 ([#5157](https://github.com/AstrBotDevs/AstrBot/issues/5157))。
- 支持在 WebUI 搜索配置
### 修复
- 修复 CronJob 页面操作列按钮重叠问题,提升任务管理可用性 ([#5163](https://github.com/AstrBotDevs/AstrBot/issues/5163))。
### 优化
- 优化 Python / Shell 本地执行工具的权限拒绝提示信息引导,提升排障可读性。
- Provider 来源面板样式升级,新增菜单交互并完善移动端适配。
- PersonaForm 组件增强响应式布局与样式细节,改进不同屏幕下的编辑体验 ([#5162](https://github.com/AstrBotDevs/AstrBot/issues/5162))。
- 配置页面新增未保存变更提示,减少误操作导致的配置丢失。
- 配置相关组件新增搜索能力并同步更新界面交互,提升配置项定位效率 ([#5168](https://github.com/AstrBotDevs/AstrBot/issues/5168))。
## What's Changed (EN)
### New Features
- Added an NVIDIA provider template for faster integration with NVIDIA model services ([#5157](https://github.com/AstrBotDevs/AstrBot/issues/5157)).
- Added an announcement section to the Welcome page, with localized announcement title support.
- Added an FAQ link to the vertical sidebar and updated navigation for localization.
### Fixes
- Fixed overlapping action buttons in the CronJob page action column to improve task management usability ([#5163](https://github.com/AstrBotDevs/AstrBot/issues/5163)).
- Improved permission-denied messages for local execution in Python and shell tools for better troubleshooting clarity.
### Improvements
- Enhanced the provider sources panel with a refined menu style and better mobile support.
- Improved PersonaForm with responsive layout and styling updates for better editing experience across screen sizes ([#5162](https://github.com/AstrBotDevs/AstrBot/issues/5162)).
- Added an unsaved-changes notice on the configuration page to reduce accidental config loss.
- Added search functionality to configuration components and updated related UI interactions for faster settings discovery ([#5168](https://github.com/AstrBotDevs/AstrBot/issues/5168)).
-37
View File
@@ -1,37 +0,0 @@
## What's Changed
### 新增
- 支持 QQ 官方机器人平台发送 Markdown 消息,提升富文本消息呈现能力 ([#5173](https://github.com/AstrBotDevs/AstrBot/issues/5173))。
- 新增在插件市场中集成随机插件推荐能力 ([#5190](https://github.com/AstrBotDevs/AstrBot/issues/5190))。
- 新增插件错误钩子(plugin error hook),支持自定义错误路由处理,便于插件统一异常控制 ([#5192](https://github.com/AstrBotDevs/AstrBot/issues/5192))。
### 修复
- 修复全部 LLM Provider 失败时重复显示错误信息的问题,减少冗余报错干扰 ([#5183](https://github.com/AstrBotDevs/AstrBot/issues/5183))。
- 修复从“选择配置文件”进入配置管理后直接关闭弹窗时,显示配置文件不正确的问题 ([#5174](https://github.com/AstrBotDevs/AstrBot/issues/5174))。
### 优化
- 重构 telegram `Voice_messages_forbidden` 回退逻辑,提取为共享辅助方法并引入类型化 `BadRequest` 异常,提升异常处理一致性 ([#5204](https://github.com/AstrBotDevs/AstrBot/issues/5204))。
### 其他
- 更新 README 相关文档内容。
- 执行 `ruff format` 代码格式整理。
## What's Changed (EN)
### New Features
- Added a plugin error hook for custom error routing, enabling unified exception handling in plugins ([#5192](https://github.com/AstrBotDevs/AstrBot/issues/5192)).
- Added Markdown message sending support for `qqofficial` to improve rich-text delivery ([#5173](https://github.com/AstrBotDevs/AstrBot/issues/5173)).
- Added the `MarketPluginCard` component and integrated random plugin recommendations in the extension marketplace ([#5190](https://github.com/AstrBotDevs/AstrBot/issues/5190)).
- Added support for the `aihubmix` provider.
- Added LINE support notes to multilingual README files.
### Fixes
- Fixed duplicate error messages when all LLM providers fail, reducing noisy error output ([#5183](https://github.com/AstrBotDevs/AstrBot/issues/5183)).
- Fixed incorrect displayed profile after opening configuration management from profile selection and closing the dialog directly ([#5174](https://github.com/AstrBotDevs/AstrBot/issues/5174)).
### Improvements
- Refactored `Voice_messages_forbidden` fallback logic into a shared helper and introduced a typed `BadRequest` exception for more consistent error handling ([#5204](https://github.com/AstrBotDevs/AstrBot/issues/5204)).
### Others
- Updated README documentation.
- Applied `ruff format` code formatting.
-47
View File
@@ -1,47 +0,0 @@
## What's Changed
### 新增
- 新增 Python / Shell 执行工具的管理员权限校验,提升高风险操作安全性 ([#5214](https://github.com/AstrBotDevs/AstrBot/issues/5214))。
- 新增插件 `astrbot-version` 与平台版本要求校验支持,增强插件兼容性管理能力 ([#5235](https://github.com/AstrBotDevs/AstrBot/issues/5235))。
- 账号密码修改流程新增“确认新密码”校验,减少误输导致的配置问题 ([#5247](https://github.com/AstrBotDevs/AstrBot/issues/5247))。
### 修复
- 改进微信公众号被动回复处理机制,引入缓冲与分片回复并优化超时行为,提升回复稳定性 ([#5224](https://github.com/AstrBotDevs/AstrBot/issues/5224))。
- 修复仅发送 JSON 消息段时可能触发空消息回复报错的问题 ([#5208](https://github.com/AstrBotDevs/AstrBot/issues/5208))。
- 修复会话重置/新建/删除时未终止活动事件导致的陈旧响应问题 ([#5225](https://github.com/AstrBotDevs/AstrBot/issues/5225))。
- 修复 provider 在 `dict` 格式 `content` 场景下可能残留 JSON 内容的问题 ([#5250](https://github.com/AstrBotDevs/AstrBot/issues/5250))。
- 修复 MCP 工具未完整暴露给主 Agent 的问题 ([#5252](https://github.com/AstrBotDevs/AstrBot/issues/5252))。
- 修复工具 schema 属性中的 `additionalProperties` 配置问题 ([#5253](https://github.com/AstrBotDevs/AstrBot/issues/5253))。
- 优化账号编辑校验错误提示,简化并统一用户名/密码为空场景返回信息。
### 优化
- 优化 PersonaForm 布局与工具选择展示,并完善工具停用状态的本地化显示。
### 其他
- 移除 Electron Desktop 流水线并迁移到 Tauri 仓库 ([#5226](https://github.com/AstrBotDevs/AstrBot/issues/5226))。
- 更新相关仓库链接与功能请求模板文案,统一中英文表达。
- 移除过时文档文件 `heihe.md`
## What's Changed (EN)
### New Features
- Added admin permission checks for Python/Shell execution tools to improve safety for high-risk operations ([#5214](https://github.com/AstrBotDevs/AstrBot/issues/5214)).
- Added support for `astrbot-version` and platform requirement checks for plugins to improve compatibility management ([#5235](https://github.com/AstrBotDevs/AstrBot/issues/5235)).
- Added password confirmation when changing account passwords to reduce misconfiguration caused by typos ([#5247](https://github.com/AstrBotDevs/AstrBot/issues/5247)).
### Fixes
- Improved passive reply handling for WeChat Official Accounts with buffering/chunking and timeout behavior optimizations for better stability ([#5224](https://github.com/AstrBotDevs/AstrBot/issues/5224)).
- Fixed an empty-message reply error when only JSON message segments were sent ([#5208](https://github.com/AstrBotDevs/AstrBot/issues/5208)).
- Fixed stale responses by terminating active events on reset/new/delete operations ([#5225](https://github.com/AstrBotDevs/AstrBot/issues/5225)).
- Fixed residual JSON content issues in provider handling when `content` was in `dict` format ([#5250](https://github.com/AstrBotDevs/AstrBot/issues/5250)).
- Fixed incomplete exposure of MCP tools to the main agent ([#5252](https://github.com/AstrBotDevs/AstrBot/issues/5252)).
- Fixed `additionalProperties` handling in tool schema properties ([#5253](https://github.com/AstrBotDevs/AstrBot/issues/5253)).
- Simplified and unified account-edit validation error responses for empty username/password scenarios.
### Improvements
- Enhanced PersonaForm layout and tool selection display, and improved localized labels for inactive tools.
### Others
- Removed the Electron desktop pipeline and switched to the Tauri repository ([#5226](https://github.com/AstrBotDevs/AstrBot/issues/5226)).
- Updated related repository links and refined feature request template wording in both Chinese and English.
- Removed outdated documentation file `heihe.md`.
@@ -2,22 +2,18 @@
<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="section in visibleSections" :key="section.key" :value="section.key"
<v-tab v-for="(val, key, index) in metadata" :key="index" :value="index"
style="font-weight: 1000; font-size: 15px">
{{ tm(section.value['name']) }}
{{ tm(metadata[key]['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="section in visibleSections" :key="section.key" :value="section.key">
<v-tabs-window-item v-for="(val, key, index) in metadata" v-show="index == tab" :key="index">
<v-container fluid>
<div v-for="(val2, key2, index2) in section.value['metadata']" :key="key2">
<div v-for="(val2, key2, index2) in metadata[key]['metadata']" :key="key2">
<!-- Support both traditional and JSON selector metadata -->
<AstrBotConfigV4
:metadata="{ [key2]: section.value['metadata'][key2] }"
:iterable="config_data"
:metadataKey="key2"
:search-keyword="searchKeyword"
>
<AstrBotConfigV4 :metadata="{ [key2]: metadata[key]['metadata'][key2] }" :iterable="config_data"
:metadataKey="key2">
</AstrBotConfigV4>
</div>
</v-container>
@@ -35,11 +31,6 @@
</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>
@@ -65,10 +56,6 @@ export default {
readonly: {
type: Boolean,
default: false
},
searchKeyword: {
type: String,
default: ''
}
},
setup() {
@@ -89,63 +76,11 @@ export default {
},
data() {
return {
tab: null, // key
tab: 0, //
}
},
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>
@@ -177,4 +112,4 @@ export default {
margin-top: 16px;
}
}
</style>
</style>
@@ -1,98 +0,0 @@
<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>
@@ -1,325 +0,0 @@
<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>
<StyledMenu>
<v-menu>
<template #activator="{ props }">
<v-btn
v-bind="props"
@@ -17,61 +17,19 @@
{{ tm('providerSources.add') }}
</v-btn>
</template>
<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>
<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>
</div>
<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">
<div v-if="displayedProviderSources.length > 0">
<v-list class="provider-source-list" nav density="compact" lines="two">
<v-list-item
v-for="source in displayedProviderSources"
@@ -88,7 +46,7 @@
<v-icon v-else size="32">mdi-creation</v-icon>
</v-avatar>
</template>
<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-title class="font-weight-bold">{{ 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">
@@ -114,8 +72,6 @@
<script setup>
import { computed } from 'vue'
import { useDisplay } from 'vuetify'
import StyledMenu from '@/components/shared/StyledMenu.vue'
const props = defineProps({
displayedProviderSources: {
@@ -150,30 +106,13 @@ 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,7 +4,6 @@ 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'
@@ -20,10 +19,6 @@ const props = defineProps({
metadataKey: {
type: String,
required: true
},
searchKeyword: {
type: String,
default: ''
}
})
@@ -129,27 +124,16 @@ function saveEditedContent() {
}
function shouldShowItem(itemMeta, itemKey) {
if (itemMeta?.condition) {
for (const [conditionKey, expectedValue] of Object.entries(itemMeta.condition)) {
const actualValue = getValueBySelector(props.iterable, conditionKey)
if (actualValue !== expectedValue) {
return false
}
}
}
const keyword = String(props.searchKeyword || '').trim().toLowerCase()
if (!keyword) {
if (!itemMeta?.condition) {
return true
}
const searchableText = [
itemKey,
translateIfKey(itemMeta?.description || ''),
translateIfKey(itemMeta?.hint || '')
].join(' ').toLowerCase()
return searchableText.includes(keyword)
for (const [conditionKey, expectedValue] of Object.entries(itemMeta.condition)) {
const actualValue = getValueBySelector(props.iterable, conditionKey)
if (actualValue !== expectedValue) {
return false
}
}
return true
}
// object
@@ -164,10 +148,7 @@ function shouldShowSection() {
return false
}
}
const sectionItems = props.metadata?.[props.metadataKey]?.items || {}
const hasVisibleItems = Object.entries(sectionItems).some(([itemKey, itemMeta]) => shouldShowItem(itemMeta, itemKey))
return hasVisibleItems
return true
}
function hasVisibleItemsAfter(items, currentIndex) {
@@ -275,16 +256,6 @@ 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>
@@ -444,15 +415,6 @@ 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);
@@ -474,13 +436,9 @@ function getSpecialSubtype(value) {
}
.property-info,
.type-indicator {
padding: 4px 8px;
}
.type-indicator,
.config-input {
padding-left: 24px;
padding-right: 24px;
padding: 4px;
}
}
</style>
@@ -2,7 +2,6 @@
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({
@@ -39,25 +38,6 @@ 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);
@@ -336,37 +316,6 @@ 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
+36 -116
View File
@@ -1,30 +1,32 @@
<template>
<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">
<v-dialog v-model="showDialog" max-width="500px">
<v-card>
<v-card-title class="text-h2">
{{ editingPersona ? tm('dialog.edit.title') : tm('dialog.create.title') }}
</v-card-title>
<v-card-text class="persona-form-content">
<v-card-text>
<!-- 创建位置提示 -->
<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-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-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="16" class="mb-4" />
</v-col>
<v-textarea v-model="personaForm.system_prompt" :label="tm('form.systemPrompt')"
:rules="systemPromptRules" variant="outlined" rows="6" class="mb-4" />
<v-col cols="12" md="6" class="persona-panels-col">
<v-expansion-panels v-model="expandedPanels" multiple>
<v-expansion-panels v-model="expandedPanels" multiple>
<!-- 工具选择面板 -->
<v-expansion-panel value="tools">
<v-expansion-panel-title>
@@ -49,7 +51,7 @@
</v-radio>
</v-radio-group>
<div v-if="toolSelectValue === '1'" class="mt-3 selected-config-area">
<div v-if="toolSelectValue === '1'" class="mt-3 ml-8">
<!-- 工具搜索 -->
<v-text-field v-model="toolSearch" :label="tm('form.searchTools')"
@@ -63,8 +65,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 }}
@@ -77,7 +79,7 @@
<!-- 工具选择列表 -->
<div v-if="filteredTools.length > 0" class="tools-selection">
<v-virtual-scroll :items="filteredTools" height="300" item-height="72">
<v-virtual-scroll :items="filteredTools" height="300" item-height="48">
<template v-slot:default="{ item }">
<v-list-item :key="item.name" density="comfortable"
@click="toggleTool(item.name)">
@@ -88,16 +90,10 @@
<v-list-item-title>
{{ item.name }}
<v-chip v-if="item.origin" size="x-small" color="info" class="mr-2"
variant="tonal">
{{ item.origin }}
<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-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">
@@ -112,7 +108,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>
@@ -127,7 +123,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>
@@ -143,9 +139,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>
@@ -182,7 +178,7 @@
<v-radio :label="tm('form.skillsSelectSpecific')" value="1"></v-radio>
</v-radio-group>
<div v-if="skillSelectValue === '1'" class="mt-3 selected-config-area">
<div v-if="skillSelectValue === '1'" class="mt-3 ml-8">
<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" />
@@ -209,8 +205,7 @@
<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>
@@ -289,13 +284,11 @@
</v-btn>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-col>
</v-row>
</v-expansion-panels>
</v-form>
</v-card-text>
<v-card-actions class="persona-form-actions">
<v-card-actions>
<v-btn v-if="editingPersona" color="error" variant="text" @click="deletePersona">
{{ tm('buttons.delete') }}
</v-btn>
@@ -487,7 +480,7 @@ export default {
};
this.toolSelectValue = '0';
this.skillSelectValue = '0';
this.expandedPanels = this.getDefaultExpandedPanels();
this.expandedPanels = [];
},
initFormWithPersona(persona) {
@@ -502,11 +495,7 @@ export default {
// tools toolSelectValue
this.toolSelectValue = persona.tools === null ? '0' : '1';
this.skillSelectValue = persona.skills === null ? '0' : '1';
this.expandedPanels = this.getDefaultExpandedPanels();
},
getDefaultExpandedPanels() {
return this.$vuetify.display.smAndDown ? [] : ['tools', 'skills', 'dialogs'];
this.expandedPanels = [];
},
closeDialog() {
@@ -810,36 +799,6 @@ 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;
@@ -853,43 +812,4 @@ 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>
@@ -1,301 +0,0 @@
<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,16 +188,10 @@ 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 }
})
)
}
//
@@ -81,14 +81,10 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
return []
}
const types: Array<{ value: string; label: string; icon: string }> = []
const types: Array<{ value: string; label: string }> = []
for (const [templateName, template] of Object.entries(providerTemplates.value)) {
if (template.provider_type === selectedProviderType.value) {
types.push({
value: templateName,
label: templateName,
icon: getProviderIcon(template.provider)
})
types.push({ value: templateName, label: templateName })
}
}
@@ -72,17 +72,14 @@
"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,7 +28,6 @@
"settings": "Settings",
"changelog": "Changelog",
"documentation": "Documentation",
"faq": "FAQ",
"github": "GitHub",
"drag": "Drag",
"groups": {
@@ -62,25 +62,6 @@
"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",
@@ -149,10 +149,6 @@
"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"
@@ -28,7 +28,6 @@
"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",
@@ -69,10 +68,6 @@
"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.",
@@ -112,18 +107,5 @@
"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,8 +38,7 @@
"selectFile": "Select File",
"refresh": "Refresh",
"updateAll": "Update All",
"deleteSource": "Delete Source",
"reshuffle": "Shuffle Again"
"deleteSource": "Delete Source"
},
"status": {
"enabled": "Enabled",
@@ -104,9 +103,7 @@
"sourceUpdated": "Source updated successfully",
"defaultOfficialSource": "Default Official Source",
"sourceExists": "This source already exists",
"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."
"installPlugin": "Install Plugin"
},
"sort": {
"default": "Default",
@@ -143,8 +140,7 @@
"install": {
"title": "Install Extension",
"fromFile": "Install from File",
"fromUrl": "Install from URL",
"supportPlatformsCount": "Supports {count} Platforms"
"fromUrl": "Install from URL"
},
"danger_warning": {
"title": "Dangerous Plugin Warning",
@@ -152,12 +148,6 @@
"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.",
@@ -237,10 +227,7 @@
"status": {
"hasUpdate": "New version available",
"disabled": "This extension is disabled",
"handlersCount": " handlers",
"supportPlatform": "Supported Platform",
"supportPlatformsCount": "Supports {count} Platforms",
"astrbotVersion": "AstrBot Version Requirement"
"handlersCount": " handlers"
},
"alt": {
"logo": "logo",
@@ -94,7 +94,6 @@
"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",
@@ -147,4 +146,4 @@
"modelId": "Model ID"
}
}
}
}
@@ -19,8 +19,7 @@
"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",
"globalSettings": "Global Settings"
"title": "SubAgents"
},
"cards": {
"statusEnabled": "Enabled",
@@ -6,9 +6,6 @@
"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.",
@@ -72,17 +72,14 @@
"form": {
"currentPassword": "当前密码",
"newPassword": "新密码",
"confirmPassword": "确认新密码",
"newUsername": "新用户名 (可选)",
"passwordHint": "密码长度至少 8 位",
"confirmPasswordHint": "请再次输入新密码以确认",
"usernameHint": "留空表示不修改用户名",
"defaultCredentials": "默认用户名和密码均为 astrbot"
},
"validation": {
"passwordRequired": "请输入密码",
"passwordMinLength": "密码长度至少 8 位",
"passwordMatch": "两次输入的密码不一致",
"usernameMinLength": "用户名长度至少3位"
},
"actions": {
@@ -93,4 +90,4 @@
"updateFailed": "修改失败,请重试"
}
}
}
}
@@ -28,7 +28,6 @@
"settings": "设置",
"changelog": "更新日志",
"documentation": "官方文档",
"faq": "FAQ",
"github": "GitHub",
"drag": "拖拽",
"groups": {
@@ -62,25 +62,6 @@
"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 模板",
@@ -152,10 +152,6 @@
"description": "运行环境",
"hint": "sandbox 代表在沙箱环境中运行, local 代表在本地环境中运行, none 代表不启用。如果上传了 skills,选择 none 会导致其无法被 Agent 正常使用。"
},
"computer_use_require_admin": {
"description": "需要 AstrBot 管理员权限",
"hint": "开启后,需要 AstrBot 管理员权限才能调用使用电脑能力。在平台配置->管理员中可添加管理员。使用 /sid 指令查看管理员 ID。"
},
"sandbox": {
"booter": {
"description": "沙箱环境驱动器"
@@ -28,7 +28,6 @@
"messages": {
"configApplied": "配置成功应用。如要保存,需再点击右下角保存按钮。",
"configApplyError": "配置未应用,Json 格式错误。",
"unsavedChangesNotice": "当前配置有未保存修改。请点击右下角保存按钮以生效。",
"saveSuccess": "配置保存成功",
"saveError": "配置保存失败",
"loadError": "配置加载失败",
@@ -69,10 +68,6 @@
"normalConfig": "普通",
"systemConfig": "系统"
},
"search": {
"placeholder": "搜索配置项(字段名/描述/提示)",
"noResult": "未找到匹配的配置项"
},
"configManagement": {
"title": "配置文件管理",
"description": "AstrBot 支持针对不同机器人分别设置配置文件。默认会使用 `default` 配置。",
@@ -112,18 +107,5 @@
"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,8 +38,7 @@
"selectFile": "选择文件",
"refresh": "刷新",
"updateAll": "更新全部插件",
"deleteSource": "删除源",
"reshuffle": "随机一发"
"deleteSource": "删除源"
},
"status": {
"enabled": "启用",
@@ -104,9 +103,7 @@
"sourceUpdated": "插件源更新成功",
"defaultOfficialSource": "默认官方源",
"sourceExists": "该插件源已存在",
"installPlugin": "安装插件",
"randomPlugins": "🎲 随机插件",
"sourceSafetyWarning": "即使是默认插件源,我们也不能完全保证插件的稳定性和安全性,使用前请谨慎核查。"
"installPlugin": "安装插件"
},
"sort": {
"default": "默认排序",
@@ -143,8 +140,7 @@
"install": {
"title": "安装插件",
"fromFile": "从文件安装",
"fromUrl": "从链接安装",
"supportPlatformsCount": "支持 {count} 个平台"
"fromUrl": "从链接安装"
},
"danger_warning": {
"title": "警告",
@@ -152,12 +148,6 @@
"confirm": "继续",
"cancel": "取消"
},
"versionCompatibility": {
"title": "版本兼容性警告",
"message": "该插件声明的 AstrBot 版本范围与当前版本不匹配。你可以无视警告继续安装,但可能无法正常运行。",
"confirm": "无视警告,继续安装",
"cancel": "取消安装"
},
"forceUpdate": {
"title": "未检测到新版本",
"message": "当前插件未检测到新版本,是否强制重新安装?这将从远程仓库拉取最新代码。",
@@ -237,10 +227,7 @@
"status": {
"hasUpdate": "有新版本可用",
"disabled": "该插件已经被禁用",
"handlersCount": "个行为",
"supportPlatform": "支持平台",
"supportPlatformsCount": "支持 {count} 个平台",
"astrbotVersion": "AstrBot 版本要求"
"handlersCount": "个行为"
},
"alt": {
"logo": "logo",
@@ -24,7 +24,7 @@
"presetDialogsHelp": "添加一些预设的对话来帮助机器人更好地理解角色设定。",
"userMessage": "用户消息",
"assistantMessage": "AI 回答",
"tools": "工具 / MCP 工具选择",
"tools": "工具选择",
"toolsHelp": "为这个人格选择可用的外部工具。外部工具给了 AI 接触外部环境的能力,如搜索、计算、获取信息等。",
"toolsSelection": "工具选择操作",
"selectAllTools": "选择所有工具",
@@ -95,7 +95,6 @@
"add": "新增",
"empty": "暂无提供商源",
"selectHint": "请选择一个提供商源",
"selectCreated": "选择已创建的提供商源",
"save": "保存配置",
"saveAndFetchModels": "保存并获取模型",
"fetchModels": "获取模型列表",
@@ -148,4 +147,4 @@
"modelId": "模型 ID"
}
}
}
}
@@ -19,8 +19,7 @@
"enabled": "启动:主 LLM 会保留自身工具并挂载 transfer_to_* 委派工具。若开启“去重重复工具”,与 SubAgent 指定的工具重叠部分会从主 LLM 工具集中移除。"
},
"section": {
"title": "SubAgents 配置",
"globalSettings": "全局设置"
"title": "SubAgents"
},
"cards": {
"statusEnabled": "启用",
@@ -29,8 +28,7 @@
"transferPrefix": "transfer_to_{name}",
"switchLabel": "启用",
"previewTitle": "预览:主 LLM 将看到的 handoff 工具",
"personaChip": "Persona: {id}",
"noDescription": "暂无描述"
"personaChip": "Persona: {id}"
},
"form": {
"nameLabel": "Agent 名称(用于 transfer_to_{name}",
@@ -6,9 +6,6 @@
"newYear": "新年快乐!"
},
"subtitle": "可以先完成基础引导,平台和对话提供商都支持稍后再配置。",
"announcement": {
"title": "公告"
},
"onboard": {
"title": "快速引导",
"subtitle": "欢迎页可直接完成初始化。",
@@ -33,7 +33,6 @@ 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('')
@@ -52,8 +51,7 @@ const isElectronApp = ref(
const redirectConfirmDialog = ref(false);
const pendingRedirectUrl = ref('');
const resolvingReleaseTarget = ref(false);
const desktopReleaseBaseUrl = 'https://github.com/AstrBotDevs/AstrBot-desktop/releases';
const fallbackReleaseUrl = desktopReleaseBaseUrl;
const fallbackReleaseUrl = 'https://github.com/AstrBotDevs/AstrBot/releases/latest';
const getSelectedGitHubProxy = () => {
if (typeof window === "undefined" || !window.localStorage) return "";
@@ -90,10 +88,6 @@ 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')
]);
@@ -101,7 +95,6 @@ const usernameRules = computed(() => [
//
const showPassword = ref(false);
const showNewPassword = ref(false);
const showConfirmPassword = ref(false);
//
const accountEditStatus = ref({
@@ -135,15 +128,12 @@ function confirmExternalRedirect() {
const getReleaseUrlForElectron = () => {
const firstRelease = (releases.value as any[])?.[0];
if (firstRelease?.tag_name) {
const tag = firstRelease.tag_name as string;
return `${desktopReleaseBaseUrl}/tag/${tag}`;
}
if (firstRelease?.html_url) return firstRelease.html_url as string;
if (hasNewVersion.value) return fallbackReleaseUrl;
const tag = botCurrVersion.value?.startsWith('v') ? botCurrVersion.value : 'latest';
return tag === 'latest'
? fallbackReleaseUrl
: `${desktopReleaseBaseUrl}/tag/${tag}`;
: `https://github.com/AstrBotDevs/AstrBot/releases/tag/${tag}`;
};
function handleUpdateClick() {
@@ -175,14 +165,17 @@ function accountEdit() {
accountEditStatus.value.error = false;
accountEditStatus.value.success = false;
const passwordHash = password.value ? md5(password.value) : '';
const newPasswordHash = newPassword.value ? md5(newPassword.value) : '';
const confirmPasswordHash = confirmPassword.value ? md5(confirmPassword.value) : '';
// md5
// @ts-ignore
if (password.value != '') {
password.value = md5(password.value);
}
if (newPassword.value != '') {
newPassword.value = md5(newPassword.value);
}
axios.post('/api/auth/account/edit', {
password: passwordHash,
new_password: newPasswordHash,
confirm_password: confirmPasswordHash,
password: password.value,
new_password: newPassword.value,
new_username: newUsername.value ? newUsername.value : username
})
.then((res) => {
@@ -191,7 +184,6 @@ function accountEdit() {
accountEditStatus.value.message = res.data.message;
password.value = '';
newPassword.value = '';
confirmPassword.value = '';
return;
}
accountEditStatus.value.success = true;
@@ -208,7 +200,6 @@ 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;
@@ -739,16 +730,10 @@ 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" clearable
:label="t('core.header.accountDialog.form.newPassword')" variant="outlined" required 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, locale } = useI18n();
const { t } = useI18n();
const customizer = useCustomizerStore();
const sidebarMenu = shallowRef(sidebarItems);
@@ -109,13 +109,6 @@ 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;
@@ -271,10 +264,6 @@ 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') }}
-8
View File
@@ -177,14 +177,6 @@ export const useCommonStore = defineStore({
"stars": pluginData?.stars ? pluginData.stars : 0,
"updated_at": pluginData?.updated_at ? pluginData.updated_at : "",
"display_name": pluginData?.display_name ? pluginData.display_name : "",
"astrbot_version": pluginData?.astrbot_version ? pluginData.astrbot_version : "",
"support_platforms": Array.isArray(pluginData?.support_platforms)
? pluginData.support_platforms
: Array.isArray(pluginData?.support_platform)
? pluginData.support_platform
: Array.isArray(pluginData?.platform)
? pluginData.platform
: [],
})
}
}
-26
View File
@@ -80,29 +80,3 @@ export function getPlatformDescription(template, name) {
}
return '';
}
/**
* 获取平台展示名用于插件支持平台显示
* @param {string} platformId - 平台适配器 ID
* @returns {string}
*/
export function getPlatformDisplayName(platformId) {
const displayNameMap = {
aiocqhttp: 'aiocqhttp (OneBot v11)',
qq_official: 'qq_official (QQ 官方机器人平台)',
weixin_official_account: 'weixin_official_account (微信公众号)',
wecom: 'wecom (企业微信应用)',
wecom_ai_bot: 'wecom_ai_bot (企业微信智能机器人)',
lark: 'lark (飞书)',
dingtalk: 'dingtalk (钉钉)',
telegram: 'telegram (Telegram)',
discord: 'discord (Discord)',
misskey: 'misskey (Misskey)',
slack: 'slack (Slack)',
kook: 'kook (KOOK)',
vocechat: 'vocechat (VoceChat)',
satori: 'satori (Satori)',
line: 'line (LINE)',
};
return displayNameMap[platformId] || platformId;
}
-2
View File
@@ -18,7 +18,6 @@ export function getProviderIcon(type) {
'deepseek': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/deepseek.svg',
'modelscope': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/modelscope.svg',
'zhipu': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/zhipu.svg',
'nvidia': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/nvidia-color.svg',
'siliconflow': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/siliconcloud.svg',
'moonshot': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/kimi.svg',
'ppio': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/ppio.svg',
@@ -33,7 +32,6 @@ export function getProviderIcon(type) {
'microsoft': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/microsoft.svg',
'vllm': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/vllm.svg',
'groq': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/groq.svg',
'aihubmix': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/aihubmix-color.svg',
"tokenpony": "https://tokenpony.cn/tokenpony-web/logo.png",
"compshare": "https://compshare.cn/favicon.ico"
};
+12 -234
View File
@@ -4,39 +4,17 @@
<div v-if="selectedConfigID || isSystemConfig" class="mt-4 config-panel"
style="display: flex; flex-direction: column; align-items: start;">
<div class="config-toolbar d-flex flex-row pr-4"
<div class="d-flex flex-row pr-4"
style="margin-bottom: 16px; align-items: center; gap: 12px; width: 100%; justify-content: space-between;">
<div class="config-toolbar-controls d-flex flex-row align-center" style="gap: 12px;">
<v-select class="config-select" style="min-width: 130px;" :model-value="selectedConfigID" :items="configSelectItems" item-title="name" :disabled="initialConfigId !== null"
<div class="d-flex flex-row align-center" style="gap: 12px;">
<v-select style="min-width: 130px;" v-model="selectedConfigID" :items="configSelectItems" item-title="name" :disabled="initialConfigId !== null"
v-if="!isSystemConfig" item-value="id" :label="tm('configSelection.selectConfig')" hide-details density="compact" rounded="md"
variant="outlined" @update:model-value="onConfigSelect">
</v-select>
<v-text-field
class="config-search-input"
v-model="configSearchKeyword"
prepend-inner-icon="mdi-magnify"
:label="tm('search.placeholder')"
hide-details
density="compact"
rounded="md"
variant="outlined"
style="min-width: 280px;"
/>
<!-- <a style="color: inherit;" href="https://blog.astrbot.app/posts/what-is-changed-in-4.0.0/#%E5%A4%9A%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6" target="_blank"><v-btn icon="mdi-help-circle" size="small" variant="plain"></v-btn></a> -->
</div>
</div>
<v-slide-y-transition>
<div v-if="fetched && hasUnsavedChanges" class="unsaved-changes-banner-wrap">
<v-banner
icon="$warning"
lines="one"
class="unsaved-changes-banner my-4"
>
{{ tm('messages.unsavedChangesNotice') }}
</v-banner>
</div>
</v-slide-y-transition>
<!-- <v-progress-linear v-if="!fetched" indeterminate color="primary"></v-progress-linear> -->
<v-slide-y-transition mode="out-in">
@@ -45,7 +23,6 @@
<AstrBotCoreConfigWrapper
:metadata="metadata"
:config_data="config_data"
:search-keyword="configSearchKeyword"
/>
<v-tooltip :text="tm('actions.save')" location="left">
@@ -191,10 +168,6 @@
</div>
</v-card>
</v-overlay>
<!-- 未保存更改确认弹窗 -->
<UnsavedChangesConfirmDialog ref="unsavedChangesDialog" />
</template>
@@ -210,7 +183,6 @@ import {
askForConfirmation as askForConfirmationDialog,
useConfirmDialog
} from '@/utils/confirmDialog';
import UnsavedChangesConfirmDialog from '@/components/config/UnsavedChangesConfirmDialog.vue';
export default {
name: 'ConfigPage',
@@ -218,8 +190,7 @@ export default {
AstrBotCoreConfigWrapper,
VueMonacoEditor,
WaitingForRestart,
StandaloneChat,
UnsavedChangesConfirmDialog
StandaloneChat
},
props: {
initialConfigId: {
@@ -239,40 +210,6 @@ export default {
};
},
//
async beforeRouteLeave(to, from, next) {
if (this.hasUnsavedChanges) {
const confirmed = await this.$refs.unsavedChangesDialog?.open({
title: this.tm('unsavedChangesWarning.dialogTitle'),
message: this.tm('unsavedChangesWarning.leavePage'),
confirmHint: `${this.tm('unsavedChangesWarning.options.saveAndSwitch')}:${this.tm('unsavedChangesWarning.options.confirm')}`,
cancelHint: `${this.tm('unsavedChangesWarning.options.discardAndSwitch')}:${this.tm('unsavedChangesWarning.options.cancel')}`,
closeHint: `${this.tm('unsavedChangesWarning.options.closeCard')}:"x"`
});
//
if (confirmed === 'close') {
next(false);
} else if (confirmed) {
const result = await this.updateConfig();
if (this.isSystemConfig) {
next(false);
} else {
if (result?.success) {
await new Promise(resolve => setTimeout(resolve, 800));
next();
} else {
next(false);
}
}
} else {
this.hasUnsavedChanges = false;
next();
}
} else {
next();
}
},
computed: {
messages() {
return {
@@ -283,11 +220,6 @@ export default {
configApplyError: this.tm('messages.configApplyError')
};
},
//
configHasChanges() {
if (!this.originalConfigData || !this.config_data) return false;
return JSON.stringify(this.originalConfigData) !== JSON.stringify(this.config_data);
},
configInfoNameList() {
return this.configInfoList.map(info => info.name);
},
@@ -303,27 +235,13 @@ export default {
});
return items;
},
hasUnsavedChanges() {
if (!this.fetched) {
return false;
}
return this.getConfigSnapshot(this.config_data) !== this.lastSavedConfigSnapshot;
}
},
watch: {
config_data_str(val) {
this.config_data_has_changed = true;
},
config_data: {
deep: true,
handler() {
if (this.fetched) {
this.hasUnsavedChanges = this.configHasChanges;
}
}
},
async '$route.fullPath'(newVal) {
await this.syncConfigTypeFromHash(newVal);
'$route.fullPath'(newVal) {
this.syncConfigTypeFromHash(newVal);
},
initialConfigId(newVal) {
if (!newVal) {
@@ -351,18 +269,15 @@ export default {
save_message: "",
save_message_success: "",
configContentKey: 0,
lastSavedConfigSnapshot: '',
//
configType: 'normal', // 'normal' 'system'
configSearchKeyword: '',
//
isSystemConfig: false,
//
selectedConfigID: null, //
currentConfigId: null, // id
configInfoList: [],
configFormData: {
name: '',
@@ -372,11 +287,6 @@ export default {
//
testChatDrawer: false,
testConfigId: null,
//
hasUnsavedChanges: false,
//
originalConfigData: null,
}
},
mounted() {
@@ -393,13 +303,6 @@ export default {
// i18n
window.addEventListener('astrbot-locale-changed', this.handleLocaleChange);
//
this.$watch('config_data', (newVal) => {
if (!this.originalConfigData && newVal) {
this.originalConfigData = JSON.parse(JSON.stringify(newVal));
}
}, { immediate: false, deep: true });
},
beforeUnmount() {
@@ -428,14 +331,14 @@ export default {
const cleanHash = rawHash.slice(lastHashIndex + 1);
return cleanHash === 'system' || cleanHash === 'normal' ? cleanHash : null;
},
async syncConfigTypeFromHash(hash) {
syncConfigTypeFromHash(hash) {
const configType = this.extractConfigTypeFromHash(hash);
if (!configType || configType === this.configType) {
return false;
}
this.configType = configType;
await this.onConfigTypeToggle();
this.onConfigTypeToggle();
return true;
},
getConfigInfoList(abconf_id) {
@@ -448,7 +351,6 @@ export default {
for (let i = 0; i < this.configInfoList.length; i++) {
if (this.configInfoList[i].id === abconf_id) {
this.selectedConfigID = this.configInfoList[i].id;
this.currentConfigId = this.configInfoList[i].id;
this.getConfig(abconf_id);
matched = true;
break;
@@ -458,7 +360,6 @@ export default {
if (!matched && this.configInfoList.length) {
//
this.selectedConfigID = this.configInfoList[0].id;
this.currentConfigId = this.configInfoList[0].id;
this.getConfig(this.selectedConfigID);
}
}
@@ -482,18 +383,9 @@ export default {
params: params
}).then((res) => {
this.config_data = res.data.data.config;
this.lastSavedConfigSnapshot = this.getConfigSnapshot(this.config_data);
this.fetched = true
this.metadata = res.data.data.metadata;
this.configContentKey += 1;
//
this.$nextTick(() => {
this.originalConfigData = JSON.parse(JSON.stringify(this.config_data));
this.hasUnsavedChanges = false;
if (!this.isSystemConfig) {
this.currentConfigId = abconf_id || this.selectedConfigID;
}
});
}).catch((err) => {
this.save_message = this.messages.loadError;
this.save_message_snack = true;
@@ -513,37 +405,26 @@ export default {
postData.conf_id = this.selectedConfigID;
}
return axios.post('/api/config/astrbot/update', postData).then((res) => {
axios.post('/api/config/astrbot/update', postData).then((res) => {
if (res.data.status === "ok") {
this.lastSavedConfigSnapshot = this.getConfigSnapshot(this.config_data);
this.save_message = res.data.message || this.messages.saveSuccess;
this.save_message_snack = true;
this.save_message_success = "success";
this.onConfigSaved();
if (this.isSystemConfig) {
restartAstrBotRuntime(this.$refs.wfr).catch(() => {})
}
return { success: true };
} else {
this.save_message = res.data.message || this.messages.saveError;
this.save_message_snack = true;
this.save_message_success = "error";
return { success: false };
}
}).catch((err) => {
this.save_message = this.messages.saveError;
this.save_message_snack = true;
this.save_message_success = "error";
return { success: false };
});
},
//
onConfigSaved() {
this.hasUnsavedChanges = false;
this.originalConfigData = JSON.parse(JSON.stringify(this.config_data));
},
configToString() {
this.config_data_str = JSON.stringify(this.config_data, null, 2);
this.config_data_has_changed = false;
@@ -583,53 +464,15 @@ export default {
this.save_message_success = "error";
});
},
async onConfigSelect(value) {
onConfigSelect(value) {
if (value === '_%manage%_') {
this.configManageDialog = true;
//
this.$nextTick(() => {
this.selectedConfigID = this.selectedConfigInfo.id || 'default';
this.getConfig(this.selectedConfigID);
});
} else {
//
if (this.hasUnsavedChanges) {
// id
const prevConfigId = this.isSystemConfig ? 'default' : (this.currentConfigId || this.selectedConfigID || 'default');
const message = this.tm('unsavedChangesWarning.switchConfig');
const saveAndSwitch = await this.$refs.unsavedChangesDialog?.open({
title: this.tm('unsavedChangesWarning.dialogTitle'),
message: message,
confirmHint: `${this.tm('unsavedChangesWarning.options.saveAndSwitch')}:${this.tm('unsavedChangesWarning.options.confirm')}`,
cancelHint: `${this.tm('unsavedChangesWarning.options.discardAndSwitch')}:${this.tm('unsavedChangesWarning.options.cancel')}`,
closeHint: `${this.tm('unsavedChangesWarning.options.closeCard')}:"x"`
});
//
if (saveAndSwitch === 'close') {
return;
}
if (saveAndSwitch) {
// id
const currentSelectedId = this.selectedConfigID;
// idid
this.selectedConfigID = prevConfigId;
const result = await this.updateConfig();
this.selectedConfigID = currentSelectedId;
if (result?.success) {
this.selectedConfigID = value;
this.getConfig(value);
}
return;
} else {
//
this.selectedConfigID = value;
this.getConfig(value);
}
} else {
//
this.selectedConfigID = value;
this.getConfig(value);
}
this.getConfig(value);
}
},
startCreateConfig() {
@@ -723,34 +566,7 @@ export default {
this.save_message_success = "error";
});
},
async onConfigTypeToggle() {
//
if (this.hasUnsavedChanges) {
const message = this.tm('unsavedChangesWarning.leavePage');
const saveAndSwitch = await this.$refs.unsavedChangesDialog?.open({
title: this.tm('unsavedChangesWarning.dialogTitle'),
message: message,
confirmHint: `${this.tm('unsavedChangesWarning.options.saveAndSwitch')}:${this.tm('unsavedChangesWarning.options.confirm')}`,
cancelHint: `${this.tm('unsavedChangesWarning.options.discardAndSwitch')}:${this.tm('unsavedChangesWarning.options.cancel')}`,
closeHint: `${this.tm('unsavedChangesWarning.options.closeCard')}:"x"`
});
//
if (saveAndSwitch === 'close') {
//
const originalHash = this.isSystemConfig ? '#system' : '#normal';
this.$router.replace('/config' + originalHash);
this.configType = this.isSystemConfig ? 'system' : 'normal';
return;
}
if (saveAndSwitch) {
await this.updateConfig();
//
if (this.isSystemConfig) {
this.$router.replace('/config#system');
return;
}
}
}
onConfigTypeToggle() {
this.isSystemConfig = this.configType === 'system';
this.fetched = false; //
@@ -785,9 +601,6 @@ export default {
closeTestChat() {
this.testChatDrawer = false;
this.testConfigId = null;
},
getConfigSnapshot(config) {
return JSON.stringify(config ?? {});
}
},
}
@@ -799,26 +612,6 @@ export default {
text-transform: none !important;
}
.unsaved-changes-banner {
border-radius: 8px;
}
.v-theme--light .unsaved-changes-banner {
background-color: #f1f4f9 !important;
}
.v-theme--dark .unsaved-changes-banner {
background-color: #2d2d2d !important;
}
.unsaved-changes-banner-wrap {
position: sticky;
top: calc(var(--v-layout-top, 64px));
z-index: 20;
width: 100%;
margin-bottom: 6px;
}
/* 按钮切换样式优化 */
.v-btn-toggle .v-btn {
transition: all 0.3s ease !important;
@@ -866,21 +659,6 @@ export default {
.config-panel {
width: 100%;
}
.config-toolbar {
padding-right: 0 !important;
}
.config-toolbar-controls {
width: 100%;
flex-wrap: wrap;
}
.config-select,
.config-search-input {
width: 100%;
min-width: 0 !important;
}
}
/* 测试聊天抽屉样式 */
+1 -1
View File
@@ -1121,7 +1121,7 @@ export default {
.text-truncate {
display: inline-block;
/* max-width: 100px; */
max-width: 100px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
+4 -5
View File
@@ -55,12 +55,11 @@
<template #item.last_run_at="{ item }">{{ formatTime(item.last_run_at) }}</template>
<template #item.note="{ item }">{{ item.note || tm('table.notAvailable') }}</template>
<template #item.actions="{ item }">
<div class="d-flex align-center flex-nowrap" style="gap: 12px; min-width: 140px;">
<div class="d-flex" style="gap: 8px;">
<v-switch v-model="item.enabled" inset density="compact" hide-details color="primary"
class="mt-0" @change="toggleJob(item)" />
<v-btn size="small" variant="text" color="error" @click="deleteJob(item)">
{{ tm('actions.delete') }}
</v-btn>
@change="toggleJob(item)" />
<v-btn size="small" variant="text" color="primary" @click="deleteJob(item)">{{ tm('actions.delete')
}}</v-btn>
</div>
</template>
</v-data-table>
+312 -349
View File
@@ -7,14 +7,12 @@ import ProxySelector from "@/components/shared/ProxySelector.vue";
import UninstallConfirmDialog from "@/components/shared/UninstallConfirmDialog.vue";
import McpServersSection from "@/components/extension/McpServersSection.vue";
import SkillsSection from "@/components/extension/SkillsSection.vue";
import MarketPluginCard from "@/components/extension/MarketPluginCard.vue";
import ComponentPanel from "@/components/extension/componentPanel/index.vue";
import axios from "axios";
import { pinyin } from "pinyin-pro";
import { useCommonStore } from "@/stores/common";
import { useI18n, useModuleI18n } from "@/i18n/composables";
import defaultPluginIcon from "@/assets/images/plugin_icon.png";
import { getPlatformDisplayName } from "@/utils/platformUtils";
import { ref, computed, onMounted, onUnmounted, reactive, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
@@ -150,18 +148,6 @@ const currentPage = ref(1);
//
const dangerConfirmDialog = ref(false);
const selectedDangerPlugin = ref(null);
const selectedMarketInstallPlugin = ref(null);
const installCompat = reactive({
checked: false,
compatible: true,
message: "",
});
// AstrBot
const versionCompatibilityDialog = reactive({
show: false,
message: "",
});
//
const showUninstallDialog = ref(false);
@@ -189,7 +175,6 @@ const debouncedMarketSearch = ref("");
const refreshingMarket = ref(false);
const sortBy = ref("default"); // default, stars, author, updated
const sortOrder = ref("desc"); // desc () or asc ()
const randomPluginNames = ref([]);
//
const normalizeStr = (s) => (s ?? "").toString().toLowerCase().trim();
@@ -263,16 +248,10 @@ const filteredPlugins = computed(() => {
const search = pluginSearch.value.toLowerCase();
return filteredExtensions.value.filter((plugin) => {
const supportPlatforms = Array.isArray(plugin.support_platforms)
? plugin.support_platforms.join(" ").toLowerCase()
: "";
const astrbotVersion = (plugin.astrbot_version ?? "").toLowerCase();
return (
plugin.name?.toLowerCase().includes(search) ||
plugin.desc?.toLowerCase().includes(search) ||
plugin.author?.toLowerCase().includes(search) ||
supportPlatforms.includes(search) ||
astrbotVersion.includes(search)
plugin.author?.toLowerCase().includes(search)
);
});
});
@@ -331,42 +310,8 @@ const sortedPlugins = computed(() => {
return plugins;
});
const RANDOM_PLUGINS_COUNT = 6;
const randomPlugins = computed(() => {
const allPlugins = pluginMarketData.value;
if (allPlugins.length === 0) return [];
const pluginsByName = new Map(allPlugins.map((plugin) => [plugin.name, plugin]));
const selected = randomPluginNames.value
.map((name) => pluginsByName.get(name))
.filter(Boolean);
if (selected.length > 0) {
return selected;
}
return allPlugins.slice(0, Math.min(RANDOM_PLUGINS_COUNT, allPlugins.length));
});
const shufflePlugins = (plugins) => {
const shuffled = [...plugins];
for (let i = shuffled.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
};
const refreshRandomPlugins = () => {
const shuffled = shufflePlugins(pluginMarketData.value);
randomPluginNames.value = shuffled
.slice(0, Math.min(RANDOM_PLUGINS_COUNT, shuffled.length))
.map((plugin) => plugin.name);
};
//
const displayItemsPerPage = 9; // 93
const displayItemsPerPage = 9; // 62
const totalPages = computed(() => {
return Math.ceil(sortedPlugins.value.length / displayItemsPerPage);
@@ -777,7 +722,6 @@ const handleInstallPlugin = async (plugin) => {
selectedDangerPlugin.value = plugin;
dangerConfirmDialog.value = true;
} else {
selectedMarketInstallPlugin.value = plugin;
extension_url.value = plugin.repo;
dialog.value = true;
uploadTab.value = "url";
@@ -787,7 +731,6 @@ const handleInstallPlugin = async (plugin) => {
//
const confirmDangerInstall = () => {
if (selectedDangerPlugin.value) {
selectedMarketInstallPlugin.value = selectedDangerPlugin.value;
extension_url.value = selectedDangerPlugin.value.repo;
dialog.value = true;
uploadTab.value = "url";
@@ -979,33 +922,9 @@ const checkAlreadyInstalled = () => {
const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
const installedRepos = new Set(data.map((ext) => ext.repo?.toLowerCase()));
const installedNames = new Set(data.map((ext) => ext.name));
const installedByRepo = new Map(
data
.filter((ext) => ext.repo)
.map((ext) => [ext.repo.toLowerCase(), ext]),
);
const installedByName = new Map(data.map((ext) => [ext.name, ext]));
for (let i = 0; i < pluginMarketData.value.length; i++) {
const plugin = pluginMarketData.value[i];
const matchedInstalled =
(plugin.repo && installedByRepo.get(plugin.repo.toLowerCase())) ||
installedByName.get(plugin.name);
// 便
if (matchedInstalled) {
if (
(!Array.isArray(plugin.support_platforms) ||
plugin.support_platforms.length === 0) &&
Array.isArray(matchedInstalled.support_platforms)
) {
plugin.support_platforms = matchedInstalled.support_platforms;
}
if (!plugin.astrbot_version && matchedInstalled.astrbot_version) {
plugin.astrbot_version = matchedInstalled.astrbot_version;
}
}
plugin.installed =
installedRepos.has(plugin.repo?.toLowerCase()) ||
installedNames.has(plugin.name);
@@ -1023,21 +942,7 @@ const checkAlreadyInstalled = () => {
pluginMarketData.value = notInstalled.concat(installed);
};
const showVersionCompatibilityWarning = (message) => {
versionCompatibilityDialog.message = message;
versionCompatibilityDialog.show = true;
};
const continueInstallIgnoringVersionWarning = async () => {
versionCompatibilityDialog.show = false;
await newExtension(true);
};
const cancelInstallOnVersionWarning = () => {
versionCompatibilityDialog.show = false;
};
const newExtension = async (ignoreVersionCheck = false) => {
const newExtension = async () => {
if (extension_url.value === "" && upload_file.value === null) {
toast(tm("messages.fillUrlOrFile"), "error");
return;
@@ -1054,7 +959,6 @@ const newExtension = async (ignoreVersionCheck = false) => {
toast(tm("messages.installing"), "primary");
const formData = new FormData();
formData.append("file", upload_file.value);
formData.append("ignore_version_check", String(ignoreVersionCheck));
axios
.post("/api/plugin/install-upload", formData, {
headers: {
@@ -1063,14 +967,6 @@ const newExtension = async (ignoreVersionCheck = false) => {
})
.then(async (res) => {
loading_.value = false;
if (
res.data.status === "warning" &&
res.data.data?.warning_type === "astrbot_version_incompatible"
) {
onLoadingDialogResult(2, res.data.message, -1);
showVersionCompatibilityWarning(res.data.message);
return;
}
if (res.data.status === "error") {
onLoadingDialogResult(2, res.data.message, -1);
return;
@@ -1100,18 +996,9 @@ const newExtension = async (ignoreVersionCheck = false) => {
.post("/api/plugin/install", {
url: extension_url.value,
proxy: getSelectedGitHubProxy(),
ignore_version_check: ignoreVersionCheck,
})
.then(async (res) => {
loading_.value = false;
if (
res.data.status === "warning" &&
res.data.data?.warning_type === "astrbot_version_incompatible"
) {
onLoadingDialogResult(2, res.data.message, -1);
showVersionCompatibilityWarning(res.data.message);
return;
}
toast(res.data.message, res.data.status === "ok" ? "success" : "error");
if (res.data.status === "error") {
onLoadingDialogResult(2, res.data.message, -1);
@@ -1137,53 +1024,6 @@ const newExtension = async (ignoreVersionCheck = false) => {
}
};
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 resolveSelectedInstallPlugin = () => {
if (
selectedMarketInstallPlugin.value &&
selectedMarketInstallPlugin.value.repo === extension_url.value
) {
return selectedMarketInstallPlugin.value;
}
return pluginMarketData.value.find((plugin) => plugin.repo === extension_url.value) || null;
};
const selectedInstallPlugin = computed(() => resolveSelectedInstallPlugin());
const checkInstallCompatibility = async () => {
installCompat.checked = false;
installCompat.compatible = true;
installCompat.message = "";
const plugin = selectedInstallPlugin.value;
if (!plugin?.astrbot_version || uploadTab.value !== "url") {
return;
}
try {
const res = await axios.post("/api/plugin/check-compat", {
astrbot_version: plugin.astrbot_version,
});
if (res.data.status === "ok") {
installCompat.checked = true;
installCompat.compatible = !!res.data.data?.compatible;
installCompat.message = res.data.data?.message || "";
}
} catch (err) {
console.debug("Failed to check plugin compatibility:", err);
}
};
//
const refreshPluginMarket = async () => {
refreshingMarket.value = true;
@@ -1197,7 +1037,6 @@ const refreshPluginMarket = async () => {
trimExtensionName();
checkAlreadyInstalled();
checkUpdate();
refreshRandomPlugins();
currentPage.value = 1; //
toast(tm("messages.refreshSuccess"), "success");
@@ -1246,7 +1085,6 @@ onMounted(async () => {
trimExtensionName();
checkAlreadyInstalled();
checkUpdate();
refreshRandomPlugins();
} catch (err) {
toast(tm("messages.getMarketDataFailed") + " " + err, "error");
}
@@ -1289,19 +1127,6 @@ watch(isListView, (newVal) => {
}
});
watch(
[() => dialog.value, () => extension_url.value, () => uploadTab.value],
async ([dialogOpen, _, currentUploadTab]) => {
if (!dialogOpen || currentUploadTab !== "url") {
installCompat.checked = false;
installCompat.compatible = true;
installCompat.message = "";
return;
}
await checkInstallCompatibility();
},
);
watch(
() => route.fullPath,
() => {
@@ -1588,54 +1413,18 @@ watch(activeTab, (newTab) => {
</template>
<template v-slot:item.desc="{ item }">
<div class="py-2">
<div
class="text-body-2 text-medium-emphasis"
style="
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
"
>
{{ item.desc }}
</div>
<div
v-if="item.support_platforms?.length"
class="d-flex align-center flex-wrap mt-2"
>
<span class="text-caption text-medium-emphasis mr-2">
{{ tm("card.status.supportPlatform") }}:
</span>
<v-chip
v-for="platformId in item.support_platforms"
:key="platformId"
size="x-small"
color="info"
variant="outlined"
class="mr-1 mb-1"
>
{{ platformId }}
</v-chip>
</div>
<div
v-if="item.astrbot_version"
class="d-flex align-center flex-wrap mt-1"
>
<span class="text-caption text-medium-emphasis mr-2">
{{ tm("card.status.astrbotVersion") }}:
</span>
<v-chip
size="x-small"
color="secondary"
variant="outlined"
class="mr-1 mb-1"
>
{{ item.astrbot_version }}
</v-chip>
</div>
<div
class="text-body-2 text-medium-emphasis mt-2 mb-2"
style="
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
"
>
{{ item.desc }}
</div>
</template>
@@ -1999,22 +1788,18 @@ watch(activeTab, (newTab) => {
</v-list-item>
</v-list>
</v-menu>
<div
class="d-flex align-center ml-2"
style="
color: grey;
font-size: 12px;
line-height: 1.3;
white-space: normal;
text-align: left;
"
>
<v-icon size="16" class="mr-1">mdi-alert-outline</v-icon>
<span>{{ tm("market.sourceSafetyWarning") }}</span>
</div>
</div>
<!-- 垂直分隔线 -->
<div
style="
height: 20px;
width: 1px;
background-color: rgba(var(--v-border-color), 0.15);
margin: 0 8px;
"
></div>
<!--右侧操作按钮组-->
<div class="d-flex align-center">
<v-tooltip location="top" :text="tm('market.addSource')">
@@ -2098,42 +1883,6 @@ watch(activeTab, (newTab) => {
</v-tooltip>
<div class="mt-4">
<div
class="d-flex align-center mb-2"
style="justify-content: space-between; flex-wrap: wrap; gap: 8px"
>
<h2>
{{ tm("market.randomPlugins") }}
</h2>
<v-btn
color="primary"
variant="tonal"
prepend-icon="mdi-shuffle-variant"
:disabled="pluginMarketData.length === 0"
@click="refreshRandomPlugins"
>
{{ tm("buttons.reshuffle") }}
</v-btn>
</div>
<v-row class="mb-6" dense>
<v-col
v-for="plugin in randomPlugins"
:key="`random-${plugin.name}`"
cols="12"
md="6"
lg="4"
class="pb-2"
>
<MarketPluginCard
:plugin="plugin"
:default-plugin-icon="defaultPluginIcon"
:show-plugin-full-name="showPluginFullName"
@install="handleInstallPlugin"
/>
</v-col>
</v-row>
<div
class="d-flex align-center mb-2"
style="
@@ -2170,6 +1919,7 @@ watch(activeTab, (newTab) => {
density="comfortable"
></v-pagination>
<!-- 排序选择器 -->
<v-select
v-model="sortBy"
:items="[
@@ -2188,6 +1938,7 @@ watch(activeTab, (newTab) => {
</template>
</v-select>
<!-- 排序方向切换按钮 -->
<v-btn
icon
v-if="sortBy !== 'default'"
@@ -2208,27 +1959,272 @@ watch(activeTab, (newTab) => {
}}
</v-tooltip>
</v-btn>
<!-- <v-switch v-model="showPluginFullName" :label="tm('market.showFullName')" hide-details
density="compact" style="margin-left: 12px" /> -->
</div>
</div>
<v-row style="min-height: 26rem" dense>
<v-row style="min-height: 26rem">
<v-col
v-for="plugin in paginatedPlugins"
:key="plugin.name"
cols="12"
md="6"
lg="4"
class="pb-2"
>
<MarketPluginCard
:plugin="plugin"
:default-plugin-icon="defaultPluginIcon"
:show-plugin-full-name="showPluginFullName"
@install="handleInstallPlugin"
/>
<v-card
class="rounded-lg d-flex flex-column plugin-card"
elevation="0"
style="height: 12rem; 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;
"
>
🥳 推荐
</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;
"
>
<!-- Display Name -->
<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>
<!-- Author with link -->
<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>
<!-- Description -->
<div class="text-caption plugin-description">
{{ plugin.desc }}
</div>
<!-- Stats: Stars & Updated & Version -->
<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>
<!-- Actions -->
<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="handleInstallPlugin(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>
</v-col>
</v-row>
<!-- 底部分页控件 -->
<div class="d-flex justify-center mt-4" v-if="totalPages > 1">
<v-pagination
v-model="currentPage"
@@ -2530,31 +2526,6 @@ watch(activeTab, (newTab) => {
</v-card>
</v-dialog>
<!-- 版本不兼容警告对话框 -->
<v-dialog v-model="versionCompatibilityDialog.show" width="520" persistent>
<v-card>
<v-card-title class="text-h5 d-flex align-center">
<v-icon color="warning" class="mr-2">mdi-alert</v-icon>
{{ tm("dialogs.versionCompatibility.title") }}
</v-card-title>
<v-card-text>
<div class="mb-2">{{ tm("dialogs.versionCompatibility.message") }}</div>
<div class="text-medium-emphasis">
{{ versionCompatibilityDialog.message }}
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" @click="cancelInstallOnVersionWarning">
{{ tm("dialogs.versionCompatibility.cancel") }}
</v-btn>
<v-btn color="warning" @click="continueInstallIgnoringVersionWarning">
{{ tm("dialogs.versionCompatibility.confirm") }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 上传插件对话框 -->
<v-dialog v-model="dialog" width="500">
<div
@@ -2636,46 +2607,6 @@ watch(activeTab, (newTab) => {
placeholder="https://github.com/username/repo"
></v-text-field>
<div v-if="selectedInstallPlugin" class="mb-3">
<v-chip
v-if="selectedInstallPlugin.astrbot_version"
size="small"
color="secondary"
variant="outlined"
class="mr-2 mb-2"
>
{{ tm("card.status.astrbotVersion") }}:
{{ selectedInstallPlugin.astrbot_version }}
</v-chip>
<v-chip
v-if="normalizePlatformList(selectedInstallPlugin.support_platforms).length"
size="small"
color="info"
variant="outlined"
class="mb-2"
>
{{ tm("card.status.supportPlatform") }}:
{{
getPlatformDisplayList(selectedInstallPlugin.support_platforms).join(
", ",
)
}}
</v-chip>
<v-alert
v-if="
selectedInstallPlugin.astrbot_version &&
installCompat.checked &&
!installCompat.compatible
"
type="warning"
variant="tonal"
density="comfortable"
class="mt-2"
>
{{ installCompat.message }}
</v-alert>
</div>
<ProxySelector></ProxySelector>
</div>
</v-window-item>
@@ -2798,6 +2729,38 @@ watch(activeTab, (newTab) => {
background-color: #f5f5f5;
}
.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);
}
.fab-button {
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+227 -223
View File
@@ -1,249 +1,155 @@
<template>
<div class="subagent-page">
<div class="d-flex align-center justify-space-between mb-6">
<div class="d-flex align-center justify-space-between mb-4">
<div>
<div class="d-flex align-center gap-2 mb-1">
<div class="d-flex align-center" style="gap: 8px;">
<h2 class="text-h5 font-weight-bold">{{ tm('page.title') }}</h2>
<v-chip size="x-small" color="orange-darken-2" variant="tonal" label class="font-weight-bold">
{{ tm('page.beta') }}
</v-chip>
<v-chip size="x-small" color="orange-darken-2" variant="tonal" label>{{ tm('page.beta') }}</v-chip>
</div>
<div class="text-body-2 text-medium-emphasis">
{{ tm('page.subtitle') }}
</div>
</div>
<div class="d-flex align-center gap-2">
<v-btn
variant="text"
color="primary"
prepend-icon="mdi-refresh"
:loading="loading"
@click="reload"
>
{{ tm('actions.refresh') }}
</v-btn>
<v-btn
variant="flat"
color="primary"
prepend-icon="mdi-content-save"
:loading="saving"
@click="save"
>
{{ tm('actions.save') }}
</v-btn>
<div class="d-flex align-center" style="gap: 8px;">
<v-btn variant="tonal" color="primary" :loading="loading" @click="reload">{{ tm('actions.refresh') }}</v-btn>
<v-btn variant="flat" color="primary" :loading="saving" @click="save">{{ tm('actions.save') }}</v-btn>
</div>
</div>
<!-- Global Settings Card -->
<v-card class="rounded-lg mb-6 border-thin" variant="flat" border>
<v-card class="rounded-lg" variant="flat">
<v-card-text>
<div class="d-flex align-center justify-space-between">
<div>
<div class="text-subtitle-1 font-weight-bold mb-1">{{ tm('section.globalSettings') || 'Global Settings' }}</div>
<div class="text-caption text-medium-emphasis">
{{ mainStateDescription }}
</div>
</div>
</div>
<v-divider class="my-4" />
<v-row dense>
<v-row>
<v-col cols="12" md="6">
<v-switch
v-model="cfg.main_enable"
:label="tm('switches.enable')"
inset
color="primary"
hide-details
inset
density="comfortable"
>
<template #label>
<div class="d-flex flex-column">
<span class="text-body-2 font-weight-medium">{{ tm('switches.enable') }}</span>
<span class="text-caption text-medium-emphasis">Enable sub-agent functionality</span>
</div>
</template>
</v-switch>
/>
</v-col>
<v-col cols="12" md="6">
<v-switch
v-model="cfg.remove_main_duplicate_tools"
:disabled="!cfg.main_enable"
:label="tm('switches.dedupe')"
inset
color="primary"
hide-details
inset
density="comfortable"
>
<template #label>
<div class="d-flex flex-column">
<span class="text-body-2 font-weight-medium">{{ tm('switches.dedupe') }}</span>
<span class="text-caption text-medium-emphasis">Remove duplicate tools from main agent</span>
</div>
</template>
</v-switch>
/>
</v-col>
</v-row>
<div class="text-caption text-medium-emphasis mt-1">
{{ mainStateDescription }}
</div>
<div class="d-flex align-center justify-space-between mt-6 mb-2">
<div class="text-subtitle-1 font-weight-bold">{{ tm('section.title') }}</div>
<v-btn size="small" variant="tonal" color="primary" @click="addAgent">
{{ tm('actions.add') }}
</v-btn>
</div>
<v-expansion-panels variant="accordion" multiple>
<v-expansion-panel v-for="(agent, idx) in cfg.agents" :key="agent.__key">
<v-expansion-panel-title>
<div class="subagent-panel-title">
<div class="subagent-title-left">
<v-chip :color="agent.enabled ? 'success' : 'grey'" size="small" variant="tonal">
{{ agent.enabled ? tm('cards.statusEnabled') : tm('cards.statusDisabled') }}
</v-chip>
<div class="subagent-title-text">
<div class="subagent-title-name">{{ agent.name || tm('cards.unnamed') }}</div>
<div class="subagent-title-sub">
{{ tm('cards.transferPrefix', { name: agent.name || '...' }) }}
</div>
</div>
</div>
<div class="subagent-title-right">
<v-switch
v-model="agent.enabled"
inset
color="primary"
hide-details
class="subagent-enabled-inline"
@click.stop
>
<template #label>{{ tm('cards.switchLabel') }}</template>
</v-switch>
<v-btn size="small" variant="text" color="error" @click.stop="removeAgent(idx)">
{{ tm('actions.delete') }}
</v-btn>
</div>
</div>
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-row class="subagent-grid">
<v-col cols="12" md="5">
<v-text-field
v-model="agent.name"
:label="tm('form.nameLabel')"
variant="outlined"
density="comfortable"
:hint="tm('form.nameHint')"
persistent-hint
/>
</v-col>
<v-col cols="12" md="7" class="subagent-actions">
<ProviderSelector
v-model="agent.provider_id"
provider-type="chat_completion"
:label="tm('form.providerLabel')"
:hint="tm('form.providerHint')"
persistent-hint
clearable
class="subagent-provider"
/>
</v-col>
<v-col cols="12" md="6">
<v-autocomplete
v-model="agent.persona_id"
:items="personaOptions"
item-title="title"
item-value="value"
:label="tm('form.personaLabel')"
variant="outlined"
density="comfortable"
clearable
:loading="personaLoading"
:disabled="personaLoading"
:hint="tm('form.personaHint')"
persistent-hint
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="agent.public_description"
:label="tm('form.descriptionLabel')"
variant="outlined"
density="comfortable"
:hint="tm('form.descriptionHint')"
persistent-hint
/>
</v-col>
</v-row>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card-text>
</v-card>
<!-- Agents List Section -->
<div class="d-flex align-center justify-space-between mb-4">
<div class="d-flex align-center gap-2">
<v-icon icon="mdi-robot" color="primary" size="small" />
<div class="text-h6 font-weight-bold">{{ tm('section.title') }}</div>
<v-chip size="small" variant="tonal" color="primary" class="ml-2">
{{ cfg.agents.length }}
</v-chip>
</div>
<v-btn
prepend-icon="mdi-plus"
color="primary"
@click="addAgent"
>
{{ tm('actions.add') }}
</v-btn>
</div>
<v-expansion-panels variant="popout" class="subagent-panels">
<v-expansion-panel
v-for="(agent, idx) in cfg.agents"
:key="agent.__key"
elevation="0"
class="border-thin mb-2 rounded-lg"
:class="{ 'border-primary': agent.enabled }"
>
<v-expansion-panel-title class="py-3">
<div class="d-flex align-center w-100 gap-4">
<!-- Status Indicator -->
<v-badge
dot
:color="agent.enabled ? 'success' : 'grey'"
inline
class="mr-2"
/>
<!-- Agent Info -->
<div class="d-flex flex-column flex-grow-1" style="min-width: 0;">
<div class="d-flex align-center gap-2">
<span class="text-subtitle-1 font-weight-bold text-truncate">
{{ agent.name || tm('cards.unnamed') }}
</span>
</div>
<div class="text-caption text-medium-emphasis text-truncate">
{{ agent.public_description || tm('cards.noDescription') }}
</div>
</div>
<!-- Controls (stop propagation on clicks) -->
<div class="d-flex align-center gap-2" @click.stop>
<v-switch
v-model="agent.enabled"
color="success"
hide-details
inset
density="compact"
class="mr-2"
/>
<v-btn
icon="mdi-delete-outline"
variant="text"
color="error"
density="comfortable"
@click="removeAgent(idx)"
/>
</div>
</div>
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-divider class="mb-4" />
<v-row>
<!-- Left Column: Form -->
<v-col cols="12" md="6">
<div class="d-flex flex-column gap-4">
<v-text-field
v-model="agent.name"
:label="tm('form.nameLabel')"
:rules="[v => !!v || 'Name is required', v => /^[a-z][a-z0-9_]*$/.test(v) || 'Lowercase letters, numbers, underscore only']"
variant="outlined"
density="comfortable"
hide-details="auto"
prepend-inner-icon="mdi-account"
/>
<div class="d-flex flex-column gap-1">
<div class="text-caption text-medium-emphasis ml-1">{{ tm('form.providerLabel') }}</div>
<v-card variant="outlined" class="pa-0 border-thin rounded bg-transparent" style="border-color: rgba(var(--v-border-color), var(--v-border-opacity));">
<div class="pa-3">
<ProviderSelector
v-model="agent.provider_id"
provider-type="chat_completion"
variant="outlined"
density="comfortable"
clearable
/>
</div>
</v-card>
</div>
<div class="d-flex flex-column gap-1">
<div class="text-caption text-medium-emphasis ml-1">{{ tm('form.personaLabel') }}</div>
<v-card variant="outlined" class="pa-0 border-thin rounded bg-transparent" style="border-color: rgba(var(--v-border-color), var(--v-border-opacity));">
<div class="pa-3">
<PersonaSelector
v-model="agent.persona_id"
/>
</div>
</v-card>
</div>
<v-textarea
v-model="agent.public_description"
:label="tm('form.descriptionLabel')"
variant="outlined"
density="comfortable"
auto-grow
hide-details="auto"
prepend-inner-icon="mdi-text"
/>
</div>
</v-col>
<!-- Right Column: Preview -->
<v-col cols="12" md="6">
<div class="h-100">
<div class="text-caption font-weight-bold text-medium-emphasis mb-2 ml-1">
PERSONA PREVIEW
</div>
<PersonaQuickPreview
:model-value="agent.persona_id"
class="h-100"
/>
</div>
</v-col>
</v-row>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
<!-- Empty State -->
<div v-if="cfg.agents.length === 0" class="d-flex flex-column align-center justify-center py-12 text-medium-emphasis">
<v-icon icon="mdi-robot-off" size="64" class="mb-4 opacity-50" />
<div class="text-h6">No Agents Configured</div>
<div class="text-body-2 mb-4">Add a new sub-agent to get started</div>
<v-btn color="primary" variant="tonal" @click="addAgent">
Create First Agent
</v-btn>
</div>
<v-snackbar v-model="snackbar.show" :color="snackbar.color" timeout="3000" location="top">
<v-snackbar v-model="snackbar.show" :color="snackbar.color" timeout="3000">
{{ snackbar.message }}
<template #actions>
<v-btn variant="text" @click="snackbar.show = false">Close</v-btn>
</template>
</v-snackbar>
</div>
</template>
@@ -252,12 +158,9 @@
import { computed, onMounted, ref } from 'vue'
import axios from 'axios'
import ProviderSelector from '@/components/shared/ProviderSelector.vue'
import PersonaSelector from '@/components/shared/PersonaSelector.vue'
import PersonaQuickPreview from '@/components/shared/PersonaQuickPreview.vue'
import { useModuleI18n } from '@/i18n/composables'
type SubAgentItem = {
__key: string
name: string
persona_id: string
@@ -293,6 +196,9 @@ const cfg = ref<SubAgentConfig>({
agents: []
})
const personaOptions = ref<{ title: string; value: string }[]>([])
const personaLoading = ref(false)
const mainStateDescription = computed(() =>
cfg.value.main_enable ? tm('description.enabled') : tm('description.disabled')
)
@@ -338,6 +244,24 @@ async function loadConfig() {
}
}
async function loadPersonas() {
personaLoading.value = true
try {
const res = await axios.get('/api/persona/list')
if (res.data.status === 'ok') {
const list = Array.isArray(res.data.data) ? res.data.data : []
personaOptions.value = list.map((p: any) => ({
title: p.persona_id,
value: p.persona_id
}))
}
} catch (e: any) {
toast(e?.response?.data?.message || tm('messages.loadPersonaFailed'), 'error')
} finally {
personaLoading.value = false
}
}
function addAgent() {
cfg.value.agents.push({
__key: `${Date.now()}_${Math.random().toString(16).slice(2)}`,
@@ -409,7 +333,7 @@ async function save() {
}
async function reload() {
await Promise.all([loadConfig()])
await Promise.all([loadConfig(), loadPersonas()])
}
onMounted(() => {
@@ -419,21 +343,101 @@ onMounted(() => {
<style scoped>
.subagent-page {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
padding-top: 8px;
padding-bottom: 40px;
}
.subagent-panels :deep(.v-expansion-panel-text__wrapper) {
padding: 16px;
padding-bottom: 42px;
.subagent-panel-title {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.gap-2 {
.subagent-title-left {
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
}
.subagent-title-text {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.subagent-title-name {
font-weight: 600;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 520px;
}
.subagent-title-sub {
font-size: 12px;
opacity: 0.72;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 520px;
}
.subagent-title-right {
display: flex;
align-items: center;
gap: 8px;
}
.gap-4 {
gap: 16px;
.subagent-actions {
display: flex;
align-items: flex-start;
gap: 14px;
}
.subagent-provider {
flex: 1;
min-width: 260px;
}
.subagent-enabled-inline {
margin-right: 2px;
}
/* Keep the switch compact inside the expansion-panel title row. */
.subagent-enabled-inline :deep(.v-input__details) {
display: none;
}
.subagent-enabled-inline :deep(.v-selection-control) {
min-height: 32px;
}
</style>
<style>
/*
Vuetify renders selected chips inside the input control and will grow the
field height as chips wrap. For subagent tool assignment this quickly becomes
unwieldy, so we cap the chip area height and allow scrolling.
Note: this must be a non-scoped style so it can reach Vuetify's internal
elements.
*/
.subagent-tools .v-field__input {
max-height: 160px;
overflow-y: auto;
align-content: flex-start;
}
/* Small breathing room so the scrollbar doesn't overlap chip close icons. */
.subagent-tools .v-field__input {
padding-right: 6px;
}
</style>
+1 -68
View File
@@ -116,21 +116,6 @@
</v-card>
</v-col>
</v-row>
<v-row v-if="showAnnouncement" class="px-4 mb-4">
<v-col cols="12">
<v-card class="welcome-card pa-6" elevation="0" border>
<div class="mb-4 text-h3 font-weight-bold">
{{ tm('announcement.title') }}
</div>
<MarkdownRender
:content="welcomeAnnouncement"
:typewriter="false"
class="welcome-announcement-markdown markdown-content"
/>
</v-card>
</v-col>
</v-row>
</v-container>
<AddNewPlatform v-model:show="showAddPlatformDialog" :metadata="platformMetadata" :config_data="platformConfigData"
@@ -144,16 +129,12 @@ import { computed, ref, watch, onMounted } from 'vue';
import axios from 'axios';
import AddNewPlatform from '@/components/platform/AddNewPlatform.vue';
import ProviderConfigDialog from '@/components/chat/ProviderConfigDialog.vue';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import { useModuleI18n } from '@/i18n/composables';
import { useToast } from '@/utils/toast';
import { MarkdownRender } from 'markstream-vue';
import 'markstream-vue/index.css';
import 'highlight.js/styles/github.css';
type StepState = 'pending' | 'completed' | 'skipped';
const { tm } = useModuleI18n('features/welcome');
const { locale } = useI18n();
const { success: showSuccess, error: showError } = useToast();
const showAddPlatformDialog = ref(false);
@@ -167,38 +148,6 @@ const providerCountBeforeOpen = ref(0);
const platformStepState = ref<StepState>('pending');
const providerStepState = ref<StepState>('pending');
const welcomeAnnouncementRaw = ref<unknown>(null);
function resolveWelcomeAnnouncement(raw: unknown, currentLocale: string) {
if (typeof raw === 'string') {
return raw.trim();
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
return '';
}
const localeMap = raw as Record<string, unknown>;
const normalized = currentLocale.replace('-', '_');
const preferredKeys =
normalized.startsWith('zh')
? [normalized, 'zh_CN', 'zh-CN', 'zh', 'en_US', 'en-US', 'en']
: [normalized, 'en_US', 'en-US', 'en', 'zh_CN', 'zh-CN', 'zh'];
for (const key of preferredKeys) {
const value = localeMap[key];
if (typeof value === 'string' && value.trim().length > 0) {
return value.trim();
}
}
return '';
}
const welcomeAnnouncement = computed(() =>
resolveWelcomeAnnouncement(welcomeAnnouncementRaw.value, locale.value)
);
const showAnnouncement = computed(() => welcomeAnnouncement.value.length > 0);
const springFestivalDates: Record<number, string> = {
2025: '01-29',
@@ -336,19 +285,7 @@ async function syncDefaultConfigProviderIfNeeded() {
showSuccess(tm('onboard.providerDefaultUpdated', { id: targetProviderId }));
}
async function loadWelcomeAnnouncement() {
try {
const res = await axios.get('https://cloud.astrbot.app/api/v1/announcement');
welcomeAnnouncementRaw.value = res?.data?.data?.notice?.welcome_page ?? null;
} catch (e) {
welcomeAnnouncementRaw.value = null;
console.error(e);
}
}
onMounted(async () => {
await loadWelcomeAnnouncement();
try {
await loadPlatformConfigBase();
if ((platformConfigData.value.platform || []).length > 0) {
@@ -426,8 +363,4 @@ watch(showProviderDialog, async (visible, wasVisible) => {
.welcome-card {
border-radius: 16px;
}
.welcome-announcement-markdown {
line-height: 1.7;
}
</style>
+1 -19
View File
@@ -117,18 +117,7 @@
<v-card v-if="viewingPersona">
<v-card-title class="d-flex justify-space-between align-center">
<span class="text-h5">{{ viewingPersona.persona_id }}</span>
<div class="d-flex align-center ga-1">
<v-btn
color="primary"
variant="tonal"
size="small"
prepend-icon="mdi-pencil"
@click="openEditFromViewDialog"
>
{{ tm('buttons.edit') }}
</v-btn>
<v-btn icon="mdi-close" variant="text" @click="showViewDialog = false" />
</div>
<v-btn icon="mdi-close" variant="text" @click="showViewDialog = false" />
</v-card-title>
<v-card-text>
@@ -425,13 +414,6 @@ export default defineComponent({
this.showViewDialog = true;
},
openEditFromViewDialog() {
if (!this.viewingPersona) return;
this.editingPersona = this.viewingPersona;
this.showViewDialog = false;
this.showPersonaDialog = true;
},
handlePersonaSaved(message: string) {
this.showSuccess(message);
this.refreshCurrentFolder();
+131
View File
@@ -0,0 +1,131 @@
# AstrBot Desktop (Electron)
This document describes how to build the Electron desktop app from source.
## What This Package Contains
- Electron desktop shell (`desktop/main.js`)
- Bundled WebUI static files (`desktop/resources/webui`)
- App assets (`desktop/assets`)
Current behavior:
- Backend executable is bundled in the installer/package.
- App startup checks backend availability and auto-starts bundled backend when needed.
- Runtime data is stored under `~/.astrbot` by default, not as a full AstrBot source project.
## Prerequisites
- Python environment ready in repository root (`uv` available)
- Node.js available
- `pnpm` available
Desktop dependency management uses `pnpm` with a lockfile:
- `desktop/pnpm-lock.yaml`
- `pnpm --dir desktop install --frozen-lockfile`
## Build From Scratch
Run commands from repository root:
```bash
uv sync
pnpm --dir dashboard install
pnpm --dir dashboard build
pnpm --dir desktop install --frozen-lockfile
pnpm --dir desktop run dist:full
```
Output files are generated under:
- `desktop/dist/`
## Local Run (Development)
Start backend first:
```bash
uv run main.py
```
Start Electron shell:
```bash
pnpm --dir desktop run dev
```
## Notes
- `dist:full` runs WebUI build + backend build + Electron packaging.
- In packaged app mode, backend data root defaults to `~/.astrbot` (can be overridden by `ASTRBOT_ROOT`).
- Backend build uses `uv run --with pyinstaller ...`, so no manual `PyInstaller` install is required.
## Runtime Directory Layout
By default (`ASTRBOT_ROOT` not set), packaged desktop app uses this layout:
```text
~/.astrbot/
data/
config/ # Main configuration
plugins/ # Installed plugins
plugin_data/ # Plugin persistent data
site-packages/ # Plugin dependency installation target in packaged mode
temp/ # Runtime temp files
skills/ # Skill-related runtime data
knowledge_base/ # Knowledge base files
backups/ # Backup data
```
The app does not store a full AstrBot source tree in home directory.
## Troubleshooting
Startup behavior:
- Packaged app shows a local startup page first, then switches to dashboard after backend is reachable.
- If startup page never switches, check logs and timeout settings below.
Runtime logs:
- Electron shell log: `~/.astrbot/logs/electron.log`
- Backend stdout/stderr log: `~/.astrbot/logs/backend.log`
- Both files rotate by size by default: `20MB` per file, keep `3` backups.
- Electron log rotation envs:
- `ASTRBOT_ELECTRON_LOG_MAX_MB`
- `ASTRBOT_ELECTRON_LOG_BACKUP_COUNT`
- Backend log rotation envs:
- `ASTRBOT_BACKEND_LOG_MAX_MB`
- `ASTRBOT_BACKEND_LOG_BACKUP_COUNT`
- Rotation debug logging:
- `ASTRBOT_LOG_ROTATION_DEBUG=1` (or `NODE_ENV=development`) to print filesystem errors from rotation operations.
- On backend startup failure, the app dialog also shows the backend reason and backend log path.
Timeout and loading controls:
- `ASTRBOT_BACKEND_TIMEOUT_MS` controls how long Electron waits for backend reachability.
- In packaged mode, default is `0` (auto mode with a 5-minute safety cap).
- In development mode, default is `20000`.
- If backend startup times out, app shows startup failure dialog and exits.
- `ASTRBOT_DASHBOARD_TIMEOUT_MS` controls dashboard page load wait time after backend is ready (default `20000`).
- If you see `Unable to load the AstrBot dashboard.`, increase `ASTRBOT_DASHBOARD_TIMEOUT_MS`.
Startup page locale:
- Startup page language follows cached dashboard locale in `~/.astrbot/data/desktop_state.json`.
- Supported startup locales are `zh-CN` and `en-US`.
- Remove that file to reset locale fallback behavior.
Backend auto-start:
- `ASTRBOT_BACKEND_AUTO_START=0` disables Electron-managed backend startup.
- When disabled, backend must already be running at `ASTRBOT_BACKEND_URL` before launching app.
If Electron download times out on restricted networks, configure mirrors before install:
```bash
export ELECTRON_MIRROR="https://npmmirror.com/mirrors/electron/"
export ELECTRON_BUILDER_BINARIES_MIRROR="https://npmmirror.com/mirrors/electron-builder-binaries/"
pnpm --dir desktop install --frozen-lockfile
```
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Some files were not shown because too many files have changed in this diff Show More