Compare commits

...

19 Commits

Author SHA1 Message Date
Soulter e7e0f84edf chore: bump vertion to 4.17.6 2026-02-20 18:40:45 +08:00
Soulter e19a282c59 fix: streamline error response for empty new username and password in account edit 2026-02-20 18:35:26 +08:00
Raven95676 fbc8667968 fix: simplify error messages for account edit validation 2026-02-20 16:27:28 +08:00
Soulter cda49c3a9a fix: remove additionalProperties from tool schema properties (#5253)
fixes: #5217
2026-02-20 16:13:20 +08:00
Soulter 4be1027444 fix: update tool status display and add localization for inactive tools 2026-02-20 16:01:55 +08:00
Soulter 46152d3faf fix: enhance PersonaForm layout and improve tool selection display 2026-02-20 15:54:06 +08:00
Soulter ed4cacfffb fix: all mcp tools exposed to main agent (#5252) 2026-02-20 15:40:13 +08:00
Soulter 52d1979937 chore: remove outdated heihe.md documentation file 2026-02-20 14:47:06 +08:00
NayukiMeko b30cb12133 fix(provider): 修复 dict 格式 content 导致的 JSON 残留问题 (#5250)
* fix(provider): 修复 dict 格式 content 导致的 JSON 残留问题

修复 _normalize_content 函数未处理 dict 类型 content 的问题。
当 LLM 返回 {"type": "text", "text": "..."} 格式的 content 时,
现在会正确提取 text 字段而非直接转为字符串。

同时改进 fallback 行为,对 None 值返回空字符串。

Fixes #5244

* Update warning message for unexpected dict format

---------

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
2026-02-20 13:48:41 +08:00
whatevertogo 31d4e304fc feat: add password confirmation when changing password (#5247)
* feat: add password confirmation when changing password

Fixes #5177

Adds a password confirmation field to prevent accidental password typos.

Changes:
- Backend: validate confirm_password matches new_password
- Frontend: add confirmation input with validation
- i18n: add labels and error messages for password mismatch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(auth): improve error message for password confirmation mismatch

* fix(auth): update password hashing logic and improve confirmation validation

---------

Co-authored-by: whatevertogo <whatevertogo@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 13:41:13 +08:00
Soulter 9a7a594cb5 feat: add support for plugin astrbot-version and platform requirement checks (#5235)
* feat: add support for plugin astrbot-version and platform requirement checks

* fix: remove unsupported platform and version constraints from metadata.yaml

* fix: remove restriction on 'v' in astrbot_version specification format

* ruff format
2026-02-20 13:35:45 +08:00
SnowNightt e469178a6b Feat/config leave confirm (#5249)
* feat: 配置文件增加未保存提示弹窗

* fix: 移除unsavedChangesDialog插件使用组件方式实现弹窗
2026-02-20 12:55:21 +08:00
Soulter 0a517980b7 fix: update feature request template for clarity and consistency in English and Chinese 2026-02-20 12:07:42 +08:00
エイカク 9c691b2266 chore: remove Electron desktop pipeline and switch to tauri repo (#5226)
* ci: remove Electron desktop build from release pipeline

* chore: remove electron desktop and switch to tauri release trigger

* ci: remove desktop workflow dispatch trigger

* refactor: migrate data paths to astrbot_path helpers

* fix: point desktop update prompt to AstrBot-desktop releases
2026-02-19 23:04:18 +09:00
雪語 3597726aad fix(core): terminate active events on reset/new/del to prevent stale responses (#5225)
* fix(core): terminate active events on reset/new/del to prevent stale responses

Closes #5222

* style: fix import sorting in scheduler.py
2026-02-19 19:26:47 +08:00
Soulter a4a37c268d docs: update related repo links 2026-02-19 18:11:07 +08:00
NanoRocky 651a0645c5 fix: 修复仅发送 JSON 消息段时的空消息回复报错 (#5208)
* Fix Register_Stage

· 补全 JSON 消息判断,修复发送 JSON 消息时遇到 “消息为空,跳过发送阶段” 的问题。
· 顺带补全其它消息类型判断。
Co-authored-by: Pizero <zhaory200707@outlook.com>

* Fix formatting and comments in stage.py

* Format stage.py

---------

Co-authored-by: Pizero <zhaory200707@outlook.com>
2026-02-19 17:47:08 +08:00
Dream Tokenizer bf3fa3e918 fix: 改进微信公众号被动回复处理机制,引入缓冲与分片回复,并优化超时行为 (#5224)
* 修复wechat official 被动回复功能

* ruff format

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-02-19 17:42:38 +08:00
Soulter 3b2ce9f500 feat: add admin permission checks for Python and Shell execution (#5214) 2026-02-19 01:48:48 +08:00
82 changed files with 2055 additions and 5472 deletions
+12 -14
View File
@@ -1,42 +1,40 @@
name: '🎉 功能建议'
name: '🎉 Feature Request / 功能建议'
title: "[Feature]"
description: 提交建议帮助我们改进。
description: Submit a suggestion to help us improve. / 提交建议帮助我们改进。
labels: [ "enhancement" ]
body:
- type: markdown
attributes:
value: |
感谢您抽出时间提出新功能建议,请准确解释您的想法。
Thank you for taking the time to suggest a new feature! Please explain your idea clearly and accurately. / 感谢您抽出时间提出新功能建议,请准确解释您的想法。
- type: textarea
attributes:
label: 描述
description: 简短描述您的功能建议
label: Description / 描述
description: Please describe the feature you want to be added in detail. / 请详细描述您希望添加的功能。
- type: textarea
attributes:
label: 使用场景
description: 你想要发生什么?
placeholder: >
一个清晰且具体的描述这个功能的使用场景。
label: Use Case / 使用场景
description: Please describe the use case for this feature. / 请描述这个功能的使用场景。
- type: checkboxes
attributes:
label: 愿意提交PR吗?
label: Willing to Submit PR? / 是否愿意提交PR
description: >
这不是必的,但我们欢迎您的贡献。
This is not required, but if you are willing to submit a PR to implement this feature, it would be greatly appreciated! / 这不是必的,但如果您愿意提交 PR 来实现这个功能,我们将不胜感激!
options:
- label: 是的, 我愿意提交PR!
- label: Yes, I am willing to submit a PR. / 是的,我愿意提交 PR
- type: checkboxes
attributes:
label: Code of Conduct
options:
- label: >
我已阅读并同意遵守该项目的 [行为准则](https://docs.github.com/zh/site-policy/github-terms/github-community-code-of-conduct)
I have read and agree to abide by the project's [Code of Conduct](https://docs.github.com/zh/site-policy/github-terms/github-community-code-of-conduct). /
required: true
- type: markdown
attributes:
value: "感谢您填写我们的表单!"
value: "Thank you for filling out our form!"
-165
View File
@@ -102,170 +102,11 @@ jobs:
cp "dashboard/AstrBot-${VERSION_TAG}-dashboard.zip" "dashboard/astrbot-webui-${VERSION_TAG}.zip"
rclone copy "dashboard/astrbot-webui-${VERSION_TAG}.zip" "r2:${R2_BUCKET_NAME}" --progress
build-desktop:
name: Build ${{ matrix.name }}
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- name: linux-x64
runner: ubuntu-24.04
os: linux
arch: amd64
- name: linux-arm64
runner: ubuntu-24.04-arm
os: linux
arch: arm64
- name: windows-x64
runner: windows-2022
os: win
arch: amd64
- name: windows-arm64
runner: windows-11-arm
os: win
arch: arm64
- name: macos-x64
runner: macos-15-intel
os: mac
arch: amd64
- name: macos-arm64
runner: macos-15
os: mac
arch: arm64
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ inputs.ref || github.ref }}
- name: Resolve tag
id: tag
shell: bash
run: |
if [ "${{ github.event_name }}" = "push" ]; then
tag="${GITHUB_REF_NAME}"
elif [ -n "${{ inputs.tag }}" ]; then
tag="${{ inputs.tag }}"
else
tag="$(git describe --tags --abbrev=0)"
fi
if [ -z "$tag" ]; then
echo "Failed to resolve tag." >&2
exit 1
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: Setup uv
uses: astral-sh/setup-uv@v7
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.28.2
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24.13.0'
cache: "pnpm"
cache-dependency-path: |
dashboard/pnpm-lock.yaml
desktop/pnpm-lock.yaml
- name: Prepare OpenSSL for Windows ARM64
if: ${{ matrix.os == 'win' && matrix.arch == 'arm64' }}
shell: pwsh
run: |
git clone https://github.com/microsoft/vcpkg.git C:\vcpkg
& C:\vcpkg\bootstrap-vcpkg.bat -disableMetrics
& C:\vcpkg\vcpkg.exe install openssl:arm64-windows
"VCPKG_ROOT=C:\vcpkg" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
"VCPKGRS_TRIPLET=arm64-windows" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
"OPENSSL_DIR=C:\vcpkg\installed\arm64-windows" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
"OPENSSL_ROOT_DIR=C:\vcpkg\installed\arm64-windows" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
"OPENSSL_LIB_DIR=C:\vcpkg\installed\arm64-windows\lib" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
"OPENSSL_INCLUDE_DIR=C:\vcpkg\installed\arm64-windows\include" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
- name: Install dependencies
shell: bash
run: |
uv sync
pnpm --dir dashboard install --frozen-lockfile
pnpm --dir desktop install --frozen-lockfile
- name: Build desktop package
shell: bash
run: |
pnpm --dir dashboard run build
pnpm --dir desktop run build:webui
pnpm --dir desktop run build:backend
pnpm --dir desktop run sync:version
pnpm --dir desktop exec electron-builder --publish never
- name: Normalize artifact names
shell: bash
env:
NAME_PREFIX: AstrBot-${{ steps.tag.outputs.tag }}-${{ matrix.arch }}-${{ matrix.os }}
run: |
shopt -s nullglob
out_dir="desktop/dist/release"
mkdir -p "$out_dir"
files=(
desktop/dist/*.AppImage
desktop/dist/*.dmg
desktop/dist/*.zip
desktop/dist/*.exe
)
if [ ${#files[@]} -eq 0 ]; then
echo "No desktop artifacts found to rename." >&2
exit 1
fi
for src in "${files[@]}"; do
file="$(basename "$src")"
case "$file" in
*.AppImage)
dest="$out_dir/${NAME_PREFIX}.AppImage"
;;
*.dmg)
dest="$out_dir/${NAME_PREFIX}.dmg"
;;
*.exe)
dest="$out_dir/${NAME_PREFIX}.exe"
;;
*.zip)
dest="$out_dir/${NAME_PREFIX}.zip"
;;
*)
continue
;;
esac
cp "$src" "$dest"
done
ls -la "$out_dir"
- name: Upload desktop artifacts
uses: actions/upload-artifact@v6
with:
name: AstrBot-${{ steps.tag.outputs.tag }}-${{ matrix.arch }}-${{ matrix.os }}
if-no-files-found: error
path: desktop/dist/release/*
publish-release:
name: Publish GitHub Release
runs-on: ubuntu-24.04
needs:
- build-dashboard
- build-desktop
steps:
- name: Checkout repository
uses: actions/checkout@v6
@@ -296,12 +137,6 @@ jobs:
name: Dashboard-${{ steps.tag.outputs.tag }}
path: release-assets
- name: Download desktop artifacts
uses: actions/download-artifact@v7
with:
pattern: AstrBot-${{ steps.tag.outputs.tag }}-*
path: release-assets
merge-multiple: true
- name: Resolve release notes
id: notes
-7
View File
@@ -33,13 +33,6 @@ tests/astrbot_plugin_openai
dashboard/node_modules/
dashboard/dist/
.pnpm-store/
desktop/node_modules/
desktop/dist/
desktop/out/
desktop/resources/backend/astrbot-backend*
desktop/resources/backend/*.exe
desktop/resources/webui/*
desktop/resources/.pyinstaller/
package-lock.json
yarn.lock
+12 -2
View File
@@ -146,9 +146,9 @@ yay -S astrbot-git
paru -S astrbot-git
```
#### 桌面端 Electron 打包
#### 桌面端Tauri
桌面端Electron 打包,`pnpm` 工作流)构建流程请参阅:[`desktop/README.md`](desktop/README.md)。
桌面端已迁移为独立仓库(Tauri):[https://github.com/AstrBotDevs/AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
## 支持的消息平台
@@ -269,6 +269,16 @@ pre-commit install
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - 伟大的猫猫框架
开源项目友情链接:
- [NoneBot2](https://github.com/nonebot/nonebot2) - 优秀的 Python 异步 ChatBot 框架
- [Koishi](https://github.com/koishijs/koishi) - 优秀的 Node.js ChatBot 框架
- [MaiBot](https://github.com/Mai-with-u/MaiBot) - 优秀的拟人化 AI ChatBot
- [nekro-agent](https://github.com/KroMiose/nekro-agent) - 优秀的 Agent ChatBot
- [LangBot](https://github.com/langbot-app/LangBot) - 优秀的多平台 AI ChatBot
- [ChatLuna](https://github.com/ChatLunaLab/chatluna) - 优秀的多平台 AI ChatBot Koishi 插件
- [Operit AI](https://github.com/AAswordman/Operit) - 优秀的 AI 智能助手 Android APP
## ⭐ Star History
> [!TIP]
+2 -2
View File
@@ -154,9 +154,9 @@ yay -S astrbot-git
paru -S astrbot-git
```
#### Desktop Electron Build
#### Desktop (Tauri)
For desktop build steps (Electron packaging, `pnpm` workflow), see [`desktop/README.md`](desktop/README.md).
Desktop packaging has moved to a standalone Tauri repository: [https://github.com/AstrBotDevs/AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
## Supported Messaging Platforms
@@ -4,6 +4,7 @@ from astrbot.api import sp, star
from astrbot.api.event import AstrMessageEvent, MessageEventResult
from astrbot.core.platform.astr_message_event import MessageSession
from astrbot.core.platform.message_type import MessageType
from astrbot.core.utils.active_event_registry import active_event_registry
from .utils.rst_scene import RstScene
@@ -62,6 +63,7 @@ class ConversationCommands:
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
active_event_registry.stop_all(umo, exclude=message)
await sp.remove_async(
scope="umo",
scope_id=umo,
@@ -86,6 +88,8 @@ class ConversationCommands:
)
return
active_event_registry.stop_all(umo, exclude=message)
await self.context.conversation_manager.update_conversation(
umo,
cid,
@@ -221,6 +225,7 @@ class ConversationCommands:
cfg = self.context.get_config(umo=message.unified_msg_origin)
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
active_event_registry.stop_all(message.unified_msg_origin, exclude=message)
await sp.remove_async(
scope="umo",
scope_id=message.unified_msg_origin,
@@ -229,6 +234,7 @@ class ConversationCommands:
message.set_result(MessageEventResult().message("已创建新对话。"))
return
active_event_registry.stop_all(message.unified_msg_origin, exclude=message)
cpersona = await self._get_current_persona_id(message.unified_msg_origin)
cid = await self.context.conversation_manager.new_conversation(
message.unified_msg_origin,
@@ -321,7 +327,8 @@ class ConversationCommands:
async def del_conv(self, message: AstrMessageEvent) -> None:
"""删除当前对话"""
cfg = self.context.get_config(umo=message.unified_msg_origin)
umo = message.unified_msg_origin
cfg = self.context.get_config(umo=umo)
is_unique_session = cfg["platform_settings"]["unique_session"]
if message.get_group_id() and not is_unique_session and message.role != "admin":
# 群聊,没开独立会话,发送人不是管理员
@@ -334,18 +341,17 @@ class ConversationCommands:
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
active_event_registry.stop_all(umo, exclude=message)
await sp.remove_async(
scope="umo",
scope_id=message.unified_msg_origin,
scope_id=umo,
key=THIRD_PARTY_AGENT_RUNNER_KEY[agent_runner_type],
)
message.set_result(MessageEventResult().message("重置对话成功。"))
return
session_curr_cid = (
await self.context.conversation_manager.get_curr_conversation_id(
message.unified_msg_origin,
)
await self.context.conversation_manager.get_curr_conversation_id(umo)
)
if not session_curr_cid:
@@ -356,8 +362,10 @@ class ConversationCommands:
)
return
active_event_registry.stop_all(umo, exclude=message)
await self.context.conversation_manager.delete_conversation(
message.unified_msg_origin,
umo,
session_curr_cid,
)
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.17.5"
__version__ = "4.17.6"
+3
View File
@@ -285,6 +285,9 @@ class ToolSet:
prop_value = convert_schema(value)
if "default" in prop_value:
del prop_value["default"]
# see #5217
if "additionalProperties" in prop_value:
del prop_value["additionalProperties"]
properties[key] = prop_value
if properties:
-9
View File
@@ -42,7 +42,6 @@ from astrbot.core.message.components import File, Image, Reply
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.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
@@ -770,14 +769,6 @@ 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(
+19 -6
View File
@@ -26,6 +26,21 @@ param_schema = {
}
def _check_admin_permission(context: ContextWrapper[AstrAgentContext]) -> str | None:
cfg = context.context.context.get_config(
umo=context.context.event.unified_msg_origin
)
provider_settings = cfg.get("provider_settings", {})
require_admin = provider_settings.get("computer_use_require_admin", True)
if require_admin and context.context.event.role != "admin":
return (
"error: Permission denied. Python execution is only allowed for admin users. "
"Tell user to set admins in `AstrBot WebUI -> Config -> General Config` by adding their user ID to the admins list if they need this feature."
f"User's ID is: {context.context.event.get_sender_id()}. User's ID can be found by using /sid command."
)
return None
async def handle_result(result: dict, event: AstrMessageEvent) -> ToolExecResult:
data = result.get("data", {})
output = data.get("output", {})
@@ -66,6 +81,8 @@ 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,
@@ -87,12 +104,8 @@ class LocalPythonTool(FunctionTool):
async def call(
self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False
) -> ToolExecResult:
if context.context.event.role != "admin":
return (
"error: Permission denied. Local Python execution is only allowed for admin users. "
"Tell user to set admins in `AstrBot WebUI -> 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."
)
if permission_error := _check_admin_permission(context):
return permission_error
sb = get_local_booter()
try:
result = await sb.python.exec(code, silent=silent)
+17 -6
View File
@@ -9,6 +9,21 @@ from astrbot.core.astr_agent_context import AstrAgentContext
from ..computer_client import get_booter, get_local_booter
def _check_admin_permission(context: ContextWrapper[AstrAgentContext]) -> str | None:
cfg = context.context.context.get_config(
umo=context.context.event.unified_msg_origin
)
provider_settings = cfg.get("provider_settings", {})
require_admin = provider_settings.get("computer_use_require_admin", True)
if require_admin and context.context.event.role != "admin":
return (
"error: Permission denied. Shell execution is only allowed for admin users. "
"Tell user to set admins in `AstrBot WebUI -> Config -> General Config` by adding their user ID to the admins list if they need this feature."
f"User's ID is: {context.context.event.get_sender_id()}. User's ID can be found by using /sid command."
)
return None
@dataclass
class ExecuteShellTool(FunctionTool):
name: str = "astrbot_execute_shell"
@@ -46,12 +61,8 @@ class ExecuteShellTool(FunctionTool):
background: bool = False,
env: dict = {},
) -> ToolExecResult:
if context.context.event.role != "admin":
return (
"error: Permission denied. Local 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."
)
if permission_error := _check_admin_permission(context):
return permission_error
if self.is_local:
sb = get_local_booter()
+7 -1
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.5"
VERSION = "4.17.6"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
WEBHOOK_SUPPORTED_PLATFORMS = [
@@ -128,6 +128,7 @@ DEFAULT_CONFIG = {
"add_cron_tools": True,
},
"computer_use_runtime": "local",
"computer_use_require_admin": True,
"sandbox": {
"booter": "shipyard",
"shipyard_endpoint": "",
@@ -2737,6 +2738,11 @@ CONFIG_METADATA_3 = {
"labels": ["", "本地", "沙箱"],
"hint": "选择 Computer Use 运行环境。",
},
"provider_settings.computer_use_require_admin": {
"description": "需要 AstrBot 管理员权限",
"type": "bool",
"hint": "开启后,需要 AstrBot 管理员权限才能调用使用电脑能力。在平台配置->管理员中可添加管理员。使用 /sid 指令查看管理员 ID。",
},
"provider_settings.sandbox.booter": {
"description": "沙箱环境驱动器",
"type": "string",
+5 -2
View File
@@ -13,16 +13,19 @@ from astrbot.core.knowledge_base.models import (
KBMedia,
KnowledgeBase,
)
from astrbot.core.utils.astrbot_path import get_astrbot_knowledge_base_path
class KBSQLiteDatabase:
def __init__(self, db_path: str = "data/knowledge_base/kb.db") -> None:
def __init__(self, db_path: str | None = None) -> None:
"""初始化知识库数据库
Args:
db_path: 数据库文件路径, 默认为 data/knowledge_base/kb.db
db_path: 数据库文件路径, 默认位于 AstrBot 数据目录下的 knowledge_base/kb.db
"""
if db_path is None:
db_path = str(Path(get_astrbot_knowledge_base_path()) / "kb.db")
self.db_path = db_path
self.DATABASE_URL = f"sqlite+aiosqlite:///{db_path}"
self.inited = False
+3 -2
View File
@@ -3,6 +3,7 @@ from pathlib import Path
from astrbot.core import logger
from astrbot.core.provider.manager import ProviderManager
from astrbot.core.utils.astrbot_path import get_astrbot_knowledge_base_path
# from .chunking.fixed_size import FixedSizeChunker
from .chunking.recursive import RecursiveCharacterChunker
@@ -13,7 +14,7 @@ from .retrieval.manager import RetrievalManager, RetrievalResult
from .retrieval.rank_fusion import RankFusion
from .retrieval.sparse_retriever import SparseRetriever
FILES_PATH = "data/knowledge_base"
FILES_PATH = get_astrbot_knowledge_base_path()
DB_PATH = Path(FILES_PATH) / "kb.db"
"""Knowledge Base storage root directory"""
CHUNKER = RecursiveCharacterChunker()
@@ -27,7 +28,7 @@ class KnowledgeBaseManager:
self,
provider_manager: ProviderManager,
) -> None:
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
self.provider_manager = provider_manager
self._session_deleted_callback_registered = False
+15
View File
@@ -33,6 +33,21 @@ class RespondStage(Stage):
Comp.Nodes: lambda comp: bool(comp.nodes), # 多个转发节点
Comp.File: lambda comp: bool(comp.file_ or comp.url),
Comp.WechatEmoji: lambda comp: comp.md5 is not None, # 微信表情
Comp.Json: lambda comp: bool(comp.data), # Json 卡片
Comp.Share: lambda comp: bool(comp.url) or bool(comp.title),
Comp.Music: lambda comp: (
(comp.id and comp._type and comp._type != "custom")
or (comp._type == "custom" and comp.url and comp.audio and comp.title)
), # 音乐分享
Comp.Forward: lambda comp: bool(comp.id), # 合并转发
Comp.Location: lambda comp: bool(
comp.lat is not None and comp.lon is not None
), # 位置
Comp.Contact: lambda comp: bool(comp._type and comp.id), # 推荐好友 or 群
Comp.Shake: lambda _: True, # 窗口抖动(戳一戳)
Comp.Dice: lambda _: True, # 掷骰子魔法表情
Comp.RPS: lambda _: True, # 猜拳魔法表情
Comp.Unknown: lambda comp: bool(comp.text and comp.text.strip()),
}
async def initialize(self, ctx: PipelineContext) -> None:
+10 -5
View File
@@ -6,6 +6,7 @@ from astrbot.core.platform.sources.webchat.webchat_event import WebChatMessageEv
from astrbot.core.platform.sources.wecom_ai_bot.wecomai_event import (
WecomAIBotMessageEvent,
)
from astrbot.core.utils.active_event_registry import active_event_registry
from . import STAGES_ORDER
from .context import PipelineContext
@@ -79,10 +80,14 @@ class PipelineScheduler:
event (AstrMessageEvent): 事件对象
"""
await self._process_stages(event)
active_event_registry.register(event)
try:
await self._process_stages(event)
# 如果没有发送操作, 则发送一个空消息, 以便于后续的处理
if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent):
await event.send(None)
# 如果没有发送操作, 则发送一个空消息, 以便于后续的处理
if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent):
await event.send(None)
logger.debug("pipeline 执行完毕。")
logger.debug("pipeline 执行完毕。")
finally:
active_event_registry.unregister(event)
@@ -1,13 +1,14 @@
import asyncio
import os
import sys
import time
import uuid
from collections.abc import Awaitable, Callable
from typing import Any, cast
import quart
from requests import Response
from wechatpy import WeChatClient, parse_message
from wechatpy import WeChatClient, create_reply, parse_message
from wechatpy.crypto import WeChatCrypto
from wechatpy.exceptions import InvalidSignatureException
from wechatpy.messages import BaseMessage, ImageMessage, TextMessage, VoiceMessage
@@ -38,7 +39,12 @@ else:
class WeixinOfficialAccountServer:
def __init__(self, event_queue: asyncio.Queue, config: dict) -> None:
def __init__(
self,
event_queue: asyncio.Queue,
config: dict,
user_buffer: dict[Any, dict[str, Any]],
) -> None:
self.server = quart.Quart(__name__)
self.port = int(cast(int | str, config.get("port")))
self.callback_server_host = config.get("callback_server_host", "0.0.0.0")
@@ -62,6 +68,10 @@ class WeixinOfficialAccountServer:
self.callback: Callable[[BaseMessage], Awaitable[None]] | None = None
self.shutdown_event = asyncio.Event()
self._wx_msg_time_out = 4.0 # 微信服务器要求 5 秒内回复
self.user_buffer: dict[str, dict[str, Any]] = user_buffer # from_user -> state
self.active_send_mode = False # 是否启用主动发送模式,启用后 callback 将直接返回回复内容,无需等待微信回调
async def verify(self):
"""内部服务器的 GET 验证入口"""
return await self.handle_verify(quart.request)
@@ -98,6 +108,22 @@ class WeixinOfficialAccountServer:
"""内部服务器的 POST 回调入口"""
return await self.handle_callback(quart.request)
def _maybe_encrypt(self, xml: str, nonce: str | None, timestamp: str | None) -> str:
if xml and "<Encrypt>" not in xml and nonce and timestamp:
return self.crypto.encrypt_message(xml, nonce, timestamp)
return xml or "success"
def _preview(self, msg: BaseMessage, limit: int = 24) -> str:
"""生成消息预览文本,供占位符使用"""
if isinstance(msg, TextMessage):
t = cast(str, msg.content).strip()
return (t[:limit] + "...") if len(t) > limit else (t or "空消息")
if isinstance(msg, ImageMessage):
return "图片"
if isinstance(msg, VoiceMessage):
return "语音"
return getattr(msg, "type", "未知消息")
async def handle_callback(self, request) -> str:
"""处理回调请求,可被统一 webhook 入口复用
@@ -123,14 +149,152 @@ class WeixinOfficialAccountServer:
raise
logger.info(f"解析成功: {msg}")
if self.callback:
if not self.callback:
return "success"
# by pass passive reply logic and return active reply directly.
if self.active_send_mode:
result_xml = await self.callback(msg)
if not result_xml:
return "success"
if isinstance(result_xml, str):
return result_xml
return "success"
# passive reply
from_user = str(getattr(msg, "source", ""))
msg_id = str(cast(str | int, getattr(msg, "id", "")))
state = self.user_buffer.get(from_user)
def _reply_text(text: str) -> str:
reply_obj = create_reply(text, msg)
reply_xml = reply_obj if isinstance(reply_obj, str) else str(reply_obj)
return self._maybe_encrypt(reply_xml, nonce, timestamp)
# if in cached state, return cached result or placeholder
if state:
logger.debug(f"用户消息缓冲状态: user={from_user} state={state}")
cached = state.get("cached_xml")
# send one cached each time, if cached is empty after pop, remove the buffer
if cached and len(cached) > 0:
logger.info(f"wx buffer hit on trigger: user={from_user}")
cached_xml = cached.pop(0)
if len(cached) == 0:
self.user_buffer.pop(from_user, None)
return _reply_text(cached_xml)
else:
return _reply_text(
cached_xml
+ "\n【后续消息还在缓冲中,回复任意文字继续获取】"
)
task: asyncio.Task | None = cast(asyncio.Task | None, state.get("task"))
placeholder = (
f"【正在思考'{state.get('preview', '...')}'中,已思考"
f"{int(time.monotonic() - state.get('started_at', time.monotonic()))}s,回复任意文字尝试获取回复】"
)
# same msgid => WeChat retry: wait a little; new msgid => user trigger: just placeholder
if task and state.get("msg_id") == msg_id:
done, _ = await asyncio.wait(
{task},
timeout=self._wx_msg_time_out,
return_when=asyncio.FIRST_COMPLETED,
)
if done:
try:
cached = state.get("cached_xml")
# send one cached each time, if cached is empty after pop, remove the buffer
if cached and len(cached) > 0:
logger.info(
f"wx buffer hit on retry window: user={from_user}"
)
cached_xml = cached.pop(0)
if len(cached) == 0:
self.user_buffer.pop(from_user, None)
logger.debug(
f"wx finished message sending in passive window: user={from_user} msg_id={msg_id} "
)
return _reply_text(cached_xml)
else:
logger.debug(
f"wx finished message sending in passive window but not final: user={from_user} msg_id={msg_id} "
)
return _reply_text(
cached_xml
+ "\n【后续消息还在缓冲中,回复任意文字继续获取】"
)
logger.info(
f"wx finished in window but not final; return placeholder: user={from_user} msg_id={msg_id} "
)
return _reply_text(placeholder)
except Exception:
logger.critical(
"wx task failed in passive window", exc_info=True
)
self.user_buffer.pop(from_user, None)
return _reply_text("处理消息失败,请稍后再试。")
logger.info(
f"wx passive window timeout: user={from_user} msg_id={msg_id}"
)
return _reply_text(placeholder)
logger.debug(f"wx trigger while thinking: user={from_user}")
return _reply_text(placeholder)
# create new trigger when state is empty, and store state in buffer
logger.debug(f"wx new trigger: user={from_user} msg_id={msg_id}")
preview = self._preview(msg)
placeholder = (
f"【正在思考'{preview}'中,已思考0s,回复任意文字尝试获取回复】"
)
logger.info(
f"wx start task: user={from_user} msg_id={msg_id} preview={preview}"
)
self.user_buffer[from_user] = state = {
"msg_id": msg_id,
"preview": preview,
"task": None, # set later after task created
"cached_xml": [], # for passive reply
"started_at": time.monotonic(),
}
self.user_buffer[from_user]["task"] = task = asyncio.create_task(
self.callback(msg)
)
# immediate return if done
done, _ = await asyncio.wait(
{task},
timeout=self._wx_msg_time_out,
return_when=asyncio.FIRST_COMPLETED,
)
if done:
try:
cached = state.get("cached_xml", None)
# send one cached each time, if cached is empty after pop, remove the buffer
if cached and len(cached) > 0:
logger.info(f"wx buffer hit immediately: user={from_user}")
cached_xml = cached.pop(0)
if len(cached) == 0:
self.user_buffer.pop(from_user, None)
return _reply_text(cached_xml)
else:
return _reply_text(
cached_xml
+ "\n【后续消息还在缓冲中,回复任意文字继续获取】"
)
logger.info(
f"wx not finished in first window; return placeholder: user={from_user} msg_id={msg_id} "
)
return _reply_text(placeholder)
except Exception:
logger.critical("wx task failed in first window", exc_info=True)
self.user_buffer.pop(from_user, None)
return _reply_text("处理消息失败,请稍后再试。")
logger.info(f"wx first window timeout: user={from_user} msg_id={msg_id}")
return _reply_text(placeholder)
async def start_polling(self) -> None:
logger.info(
@@ -176,7 +340,10 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
if not self.api_base_url.endswith("/"):
self.api_base_url += "/"
self.server = WeixinOfficialAccountServer(self._event_queue, self.config)
self.user_buffer: dict[str, dict[str, Any]] = {} # from_user -> state
self.server = WeixinOfficialAccountServer(
self._event_queue, self.config, self.user_buffer
)
self.client = WeChatClient(
self.config["appid"].strip(),
@@ -193,28 +360,33 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
try:
if self.active_send_mode:
await self.convert_message(msg, None)
return None
msg_id = str(cast(str | int, msg.id))
future = self.wexin_event_workers.get(msg_id)
if future:
logger.debug(f"duplicate message id checked: {msg.id}")
else:
if str(msg.id) in self.wexin_event_workers:
future = self.wexin_event_workers[str(cast(str | int, msg.id))]
logger.debug(f"duplicate message id checked: {msg.id}")
else:
future = asyncio.get_event_loop().create_future()
self.wexin_event_workers[str(cast(str | int, msg.id))] = future
await self.convert_message(msg, future)
future = asyncio.get_event_loop().create_future()
self.wexin_event_workers[msg_id] = future
await self.convert_message(msg, future)
# I love shield so much!
result = await asyncio.wait_for(
asyncio.shield(future),
60,
) # wait for 60s
logger.debug(f"Got future result: {result}")
self.wexin_event_workers.pop(str(cast(str | int, msg.id)), None)
return result # xml. see weixin_offacc_event.py
180,
) # wait for 180s
logger.debug(f"Got future result: {result}")
return result
except asyncio.TimeoutError:
pass
logger.info(f"callback 处理消息超时: message_id={msg.id}")
return create_reply("处理消息超时,请稍后再试。", msg)
except Exception as e:
logger.error(f"转换消息时出现异常: {e}")
finally:
self.wexin_event_workers.pop(str(cast(str | int, msg.id)), None)
self.server.callback = callback
self.server.active_send_mode = self.active_send_mode
@override
async def send_by_session(
@@ -336,12 +508,19 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
await self.handle_msg(abm)
async def handle_msg(self, message: AstrBotMessage) -> None:
buffer = self.user_buffer.get(message.sender.user_id, None)
if buffer is None:
logger.critical(
f"用户消息未找到缓冲状态,无法处理消息: user={message.sender.user_id} message_id={message.message_id}"
)
return
message_event = WeixinOfficialAccountPlatformEvent(
message_str=message.message_str,
message_obj=message,
platform_meta=self.meta(),
session_id=message.session_id,
client=self.client,
message_out=buffer,
)
self.commit_event(message_event)
@@ -1,9 +1,9 @@
import asyncio
import os
from typing import cast
from typing import Any, cast
from wechatpy import WeChatClient
from wechatpy.replies import ImageReply, TextReply, VoiceReply
from wechatpy.replies import ImageReply, VoiceReply
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
@@ -20,9 +20,11 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
platform_meta: PlatformMetadata,
session_id: str,
client: WeChatClient,
message_out: dict[Any, Any],
) -> None:
super().__init__(message_str, message_obj, platform_meta, session_id)
self.client = client
self.message_out = message_out
@staticmethod
async def send_with_client(
@@ -32,8 +34,8 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
) -> None:
pass
async def split_plain(self, plain: str) -> list[str]:
"""将长文本分割成多个小文本, 每个小文本长度不超过 2048 字符
async def split_plain(self, plain: str, max_length: int = 1024) -> list[str]:
"""将长文本分割成多个小文本, 每个小文本长度不超过 max_length 字符
Args:
plain (str): 要分割的长文本
@@ -41,18 +43,18 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
list[str]: 分割后的文本列表
"""
if len(plain) <= 2048:
if len(plain) <= max_length:
return [plain]
result = []
start = 0
while start < len(plain):
# 剩下的字符串长度<2048时结束
if start + 2048 >= len(plain):
# 剩下的字符串长度<max_length时结束
if start + max_length >= len(plain):
result.append(plain[start:])
break
# 向前搜索分割标点符号
end = min(start + 2048, len(plain))
end = min(start + max_length, len(plain))
cut_position = end
for i in range(end, start, -1):
if i < len(plain) and plain[i - 1] in [
@@ -87,19 +89,15 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
if isinstance(comp, Plain):
# Split long text messages if needed
plain_chunks = await self.split_plain(comp.text)
for chunk in plain_chunks:
if active_send_mode:
if active_send_mode:
for chunk in plain_chunks:
self.client.message.send_text(message_obj.sender.user_id, chunk)
else:
reply = TextReply(
content=chunk,
message=cast(dict, self.message_obj.raw_message)["message"],
)
xml = reply.render()
future = cast(dict, self.message_obj.raw_message)["future"]
assert isinstance(future, asyncio.Future)
future.set_result(xml)
await asyncio.sleep(0.5) # Avoid sending too fast
else:
# disable passive sending, just store the chunks in
logger.debug(
f"split plain into {len(plain_chunks)} chunks for passive reply. Message not sent."
)
self.message_out["cached_xml"] = plain_chunks
elif isinstance(comp, Image):
img_path = await comp.convert_to_file_path()
+12 -2
View File
@@ -381,13 +381,22 @@ class ProviderOpenAIOfficial(Provider):
plain string. This method handles both formats.
Args:
raw_content: The raw content from LLM response, can be str, list, or other.
raw_content: The raw content from LLM response, can be str, list, dict, or other.
strip: Whether to strip whitespace from the result. Set to False for
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
@@ -450,7 +459,8 @@ class ProviderOpenAIOfficial(Provider):
return "".join(text_parts)
return content
return str(raw_content)
# Fallback for other types (int, float, etc.)
return str(raw_content) if raw_content is not None else ""
async def _parse_openai_completion(
self, completion: ChatCompletion, tools: ToolSet | None
@@ -7,12 +7,14 @@ import asyncio
import os
import re
from datetime import datetime
from pathlib import Path
from typing import cast
from funasr_onnx import SenseVoiceSmall
from funasr_onnx.utils.postprocess_utils import rich_transcription_postprocess
from astrbot.core import logger
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from astrbot.core.utils.io import download_file
from astrbot.core.utils.tencent_record_helper import tencent_silk_to_wav
@@ -50,7 +52,9 @@ class ProviderSenseVoiceSTTSelfHost(STTProvider):
async def get_timestamped_path(self) -> str:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
return os.path.join("data", "temp", f"{timestamp}")
temp_dir = Path(get_astrbot_temp_path())
temp_dir.mkdir(parents=True, exist_ok=True)
return str(temp_dir / timestamp)
async def _is_silk_file(self, file_path) -> bool:
silk_header = b"SILK"
@@ -11,6 +11,7 @@ class PlatformAdapterType(enum.Flag):
QQOFFICIAL = enum.auto()
TELEGRAM = enum.auto()
WECOM = enum.auto()
WECOM_AI_BOT = enum.auto()
LARK = enum.auto()
DINGTALK = enum.auto()
DISCORD = enum.auto()
@@ -26,6 +27,7 @@ class PlatformAdapterType(enum.Flag):
| QQOFFICIAL
| TELEGRAM
| WECOM
| WECOM_AI_BOT
| LARK
| DINGTALK
| DISCORD
@@ -44,6 +46,7 @@ ADAPTER_NAME_2_TYPE = {
"qq_official": PlatformAdapterType.QQOFFICIAL,
"telegram": PlatformAdapterType.TELEGRAM,
"wecom": PlatformAdapterType.WECOM,
"wecom_ai_bot": PlatformAdapterType.WECOM_AI_BOT,
"lark": PlatformAdapterType.LARK,
"dingtalk": PlatformAdapterType.DINGTALK,
"discord": PlatformAdapterType.DISCORD,
+6
View File
@@ -61,6 +61,12 @@ class StarMetadata:
logo_path: str | None = None
"""插件 Logo 的路径"""
support_platforms: list[str] = field(default_factory=list)
"""插件声明支持的平台适配器 ID 列表(对应 ADAPTER_NAME_2_TYPE 的 key"""
astrbot_version: str | None = None
"""插件要求的 AstrBot 版本范围(PEP 440 specifier,如 >=4.13.0,<4.17.0"""
def __str__(self) -> str:
return f"Plugin {self.name} ({self.version}) by {self.author}: {self.desc}"
+103 -5
View File
@@ -11,10 +11,13 @@ import traceback
from types import ModuleType
import yaml
from packaging.specifiers import InvalidSpecifier, SpecifierSet
from packaging.version import InvalidVersion, Version
from astrbot.core import logger, pip_installer, sp
from astrbot.core.agent.handoff import FunctionTool, HandoffTool
from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.config.default import VERSION
from astrbot.core.platform.register import unregister_platform_adapters_by_module
from astrbot.core.provider.register import llm_tools
from astrbot.core.utils.astrbot_path import (
@@ -40,6 +43,10 @@ except ImportError:
logger.warning("未安装 watchfiles,无法实现插件的热重载。")
class PluginVersionIncompatibleError(Exception):
"""Raised when plugin astrbot_version is incompatible with current AstrBot."""
class PluginManager:
def __init__(self, context: Context, config: AstrBotConfig) -> None:
self.updator = PluginUpdator()
@@ -268,10 +275,58 @@ class PluginManager:
version=metadata["version"],
repo=metadata["repo"] if "repo" in metadata else None,
display_name=metadata.get("display_name", None),
support_platforms=(
[
platform_id
for platform_id in metadata["support_platforms"]
if isinstance(platform_id, str)
]
if isinstance(metadata.get("support_platforms"), list)
else []
),
astrbot_version=(
metadata["astrbot_version"]
if isinstance(metadata.get("astrbot_version"), str)
else None
),
)
return metadata
@staticmethod
def _validate_astrbot_version_specifier(
version_spec: str | None,
) -> tuple[bool, str | None]:
if not version_spec:
return True, None
normalized_spec = version_spec.strip()
if not normalized_spec:
return True, None
try:
specifier = SpecifierSet(normalized_spec)
except InvalidSpecifier:
return (
False,
"astrbot_version 格式无效,请使用 PEP 440 版本范围格式,例如 >=4.16,<5。",
)
try:
current_version = Version(VERSION)
except InvalidVersion:
return (
False,
f"AstrBot 当前版本 {VERSION} 无法被解析,无法校验插件版本范围。",
)
if current_version not in specifier:
return (
False,
f"当前 AstrBot 版本为 {VERSION},不满足插件要求的 astrbot_version: {normalized_spec}",
)
return True, None
@staticmethod
def _get_plugin_related_modules(
plugin_root_dir: str,
@@ -408,7 +463,12 @@ class PluginManager:
return result
async def load(self, specified_module_path=None, specified_dir_name=None):
async def load(
self,
specified_module_path=None,
specified_dir_name=None,
ignore_version_check: bool = False,
):
"""载入插件。
当 specified_module_path 或者 specified_dir_name 不为 None 时,只载入指定的插件。
@@ -507,10 +567,25 @@ 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("/", "_")
@@ -621,6 +696,19 @@ class PluginManager:
)
if not metadata:
raise Exception(f"无法找到插件 {plugin_dir_path} 的元数据。")
if not ignore_version_check:
is_valid, error_message = (
self._validate_astrbot_version_specifier(
metadata.astrbot_version,
)
)
if not is_valid:
raise PluginVersionIncompatibleError(
error_message
or "The plugin is not compatible with the current AstrBot version."
)
metadata.star_cls = obj
metadata.config = plugin_config
metadata.module = module
@@ -754,7 +842,9 @@ class PluginManager:
f"清理安装失败插件配置失败: {plugin_config_path},原因: {e!s}",
)
async def install_plugin(self, repo_url: str, proxy=""):
async def install_plugin(
self, repo_url: str, proxy: str = "", ignore_version_check: bool = False
):
"""从仓库 URL 安装插件
从指定的仓库 URL 下载并安装插件,然后加载该插件到系统中
@@ -788,7 +878,10 @@ class PluginManager:
# reload the plugin
dir_name = os.path.basename(plugin_path)
success, error_message = await self.load(specified_dir_name=dir_name)
success, error_message = await self.load(
specified_dir_name=dir_name,
ignore_version_check=ignore_version_check,
)
if not success:
raise Exception(
error_message
@@ -1092,7 +1185,9 @@ class PluginManager:
await self.reload(plugin_name)
async def install_plugin_from_file(self, zip_file_path: str):
async def install_plugin_from_file(
self, zip_file_path: str, ignore_version_check: bool = False
):
dir_name = os.path.basename(zip_file_path).replace(".zip", "")
dir_name = dir_name.removesuffix("-master").removesuffix("-main").lower()
desti_dir = os.path.join(self.plugin_store_path, dir_name)
@@ -1148,7 +1243,10 @@ class PluginManager:
except BaseException as e:
logger.warning(f"删除插件压缩包失败: {e!s}")
# await self.reload()
success, error_message = await self.load(specified_dir_name=dir_name)
success, error_message = await self.load(
specified_dir_name=dir_name,
ignore_version_check=ignore_version_check,
)
if not success:
raise Exception(
error_message
@@ -0,0 +1,50 @@
from __future__ import annotations
from collections import defaultdict
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from astrbot.core.platform import AstrMessageEvent
class ActiveEventRegistry:
"""维护 unified_msg_origin 到活跃事件的映射。
用于在 reset 等场景下终止该会话正在处理的事件。
"""
def __init__(self) -> None:
self._events: dict[str, set[AstrMessageEvent]] = defaultdict(set)
def register(self, event: AstrMessageEvent) -> None:
self._events[event.unified_msg_origin].add(event)
def unregister(self, event: AstrMessageEvent) -> None:
umo = event.unified_msg_origin
self._events[umo].discard(event)
if not self._events[umo]:
del self._events[umo]
def stop_all(
self,
umo: str,
exclude: AstrMessageEvent | None = None,
) -> int:
"""终止指定 UMO 的所有活跃事件。
Args:
umo: 统一消息来源标识符。
exclude: 需要排除的事件(通常是发起 reset 的事件本身)。
Returns:
被终止的事件数量。
"""
count = 0
for event in list(self._events.get(umo, [])):
if event is not exclude:
event.stop_event()
count += 1
return count
active_event_registry = ActiveEventRegistry()
+2 -2
View File
@@ -15,7 +15,7 @@ Skills 目录路径:固定为数据目录下的 skills 目录
import os
from astrbot.core.utils.runtime_env import is_packaged_electron_runtime
from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime
def get_astrbot_path() -> str:
@@ -29,7 +29,7 @@ def get_astrbot_root() -> str:
"""获取Astrbot根目录路径"""
if path := os.environ.get("ASTRBOT_ROOT"):
return os.path.realpath(path)
if is_packaged_electron_runtime():
if is_packaged_desktop_runtime():
return os.path.realpath(os.path.join(os.path.expanduser("~"), ".astrbot"))
return os.path.realpath(os.getcwd())
+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_electron_runtime
from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime
logger = logging.getLogger("astrbot")
@@ -35,7 +35,7 @@ def _get_pip_main():
"pip module is unavailable "
f"(sys.executable={sys.executable}, "
f"frozen={getattr(sys, 'frozen', False)}, "
f"ASTRBOT_ELECTRON_CLIENT={os.environ.get('ASTRBOT_ELECTRON_CLIENT')})"
f"ASTRBOT_DESKTOP_CLIENT={os.environ.get('ASTRBOT_DESKTOP_CLIENT')})"
) from exc
return pip_main
@@ -556,7 +556,7 @@ class PipInstaller:
args.extend(["--trusted-host", "mirrors.aliyun.com", "-i", index_url])
target_site_packages = None
if is_packaged_electron_runtime():
if is_packaged_desktop_runtime():
target_site_packages = get_astrbot_site_packages_path()
os.makedirs(target_site_packages, exist_ok=True)
_prepend_sys_path(target_site_packages)
@@ -582,7 +582,7 @@ class PipInstaller:
def prefer_installed_dependencies(self, requirements_path: str) -> None:
"""优先使用已安装在插件 site-packages 中的依赖,不执行安装。"""
if not is_packaged_electron_runtime():
if not is_packaged_desktop_runtime():
return
target_site_packages = get_astrbot_site_packages_path()
+2 -2
View File
@@ -6,5 +6,5 @@ def is_frozen_runtime() -> bool:
return bool(getattr(sys, "frozen", False))
def is_packaged_electron_runtime() -> bool:
return is_frozen_runtime() and os.environ.get("ASTRBOT_ELECTRON_CLIENT") == "1"
def is_packaged_desktop_runtime() -> bool:
return is_frozen_runtime() and os.environ.get("ASTRBOT_DESKTOP_CLIENT") == "1"
+5 -3
View File
@@ -64,11 +64,13 @@ class AuthRoute(Route):
new_pwd = post_data.get("new_password", None)
new_username = post_data.get("new_username", None)
if not new_pwd and not new_username:
return (
Response().error("新用户名和新密码不能同时为空,你改了个寂寞").__dict__
)
return Response().error("新用户名和新密码不能同时为空").__dict__
# Verify password confirmation
if new_pwd:
confirm_pwd = post_data.get("confirm_password", None)
if confirm_pwd != new_pwd:
return Response().error("两次输入的新密码不一致").__dict__
self.config["dashboard"]["password"] = new_pwd
if new_username:
self.config["dashboard"]["username"] = new_username
+68 -7
View File
@@ -19,8 +19,14 @@ from astrbot.core.star.filter.command_group import CommandGroupFilter
from astrbot.core.star.filter.permission import PermissionTypeFilter
from astrbot.core.star.filter.regex import RegexFilter
from astrbot.core.star.star_handler import EventType, star_handlers_registry
from astrbot.core.star.star_manager import PluginManager
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from astrbot.core.star.star_manager import (
PluginManager,
PluginVersionIncompatibleError,
)
from astrbot.core.utils.astrbot_path import (
get_astrbot_data_path,
get_astrbot_temp_path,
)
from .route import Response, Route, RouteContext
@@ -46,6 +52,7 @@ class PluginRoute(Route):
super().__init__(context)
self.routes = {
"/plugin/get": ("GET", self.get_plugins),
"/plugin/check-compat": ("POST", self.check_plugin_compatibility),
"/plugin/install": ("POST", self.install_plugin),
"/plugin/install-upload": ("POST", self.install_plugin_upload),
"/plugin/update": ("POST", self.update_plugin),
@@ -78,6 +85,27 @@ class PluginRoute(Route):
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 (
@@ -118,7 +146,7 @@ class PluginRoute(Route):
try:
success, message = await self.plugin_manager.reload(plugin_name)
if not success:
return Response().error(message).__dict__
return Response().error(message or "插件重载失败").__dict__
return Response().ok(None, "重载成功。").__dict__
except Exception as e:
logger.error(f"/api/plugin/reload: {traceback.format_exc()}")
@@ -196,10 +224,11 @@ class PluginRoute(Route):
def _build_registry_source(self, custom_url: str | None) -> RegistrySource:
"""构建注册表源信息"""
data_dir = get_astrbot_data_path()
if custom_url:
# 对自定义URL生成一个安全的文件名
url_hash = hashlib.md5(custom_url.encode()).hexdigest()[:8]
cache_file = f"data/plugins_custom_{url_hash}.json"
cache_file = os.path.join(data_dir, f"plugins_custom_{url_hash}.json")
# 更安全的后缀处理方式
if custom_url.endswith(".json"):
@@ -209,7 +238,7 @@ class PluginRoute(Route):
urls = [custom_url]
else:
cache_file = "data/plugins.json"
cache_file = os.path.join(data_dir, "plugins.json")
md5_url = "https://api.soulter.top/astrbot/plugins-md5"
urls = [
"https://api.soulter.top/astrbot/plugins",
@@ -345,6 +374,8 @@ class PluginRoute(Route):
),
"display_name": plugin.display_name,
"logo": f"/api/file/{logo_url}" if logo_url else None,
"support_platforms": plugin.support_platforms,
"astrbot_version": plugin.astrbot_version,
}
# 检查是否为全空的幽灵插件
if not any(
@@ -439,6 +470,7 @@ class PluginRoute(Route):
post_data = await request.get_json()
repo_url = post_data["url"]
ignore_version_check = bool(post_data.get("ignore_version_check", False))
proxy: str = post_data.get("proxy", None)
if proxy:
@@ -446,10 +478,23 @@ class PluginRoute(Route):
try:
logger.info(f"正在安装插件 {repo_url}")
plugin_info = await self.plugin_manager.install_plugin(repo_url, proxy)
plugin_info = await self.plugin_manager.install_plugin(
repo_url,
proxy,
ignore_version_check=ignore_version_check,
)
# self.core_lifecycle.restart()
logger.info(f"安装插件 {repo_url} 成功。")
return Response().ok(plugin_info, "安装成功。").__dict__
except PluginVersionIncompatibleError as e:
return {
"status": "warning",
"message": str(e),
"data": {
"warning_type": "astrbot_version_incompatible",
"can_ignore": True,
},
}
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
@@ -465,16 +510,32 @@ class PluginRoute(Route):
try:
file = await request.files
file = file["file"]
form_data = await request.form
ignore_version_check = (
str(form_data.get("ignore_version_check", "false")).lower() == "true"
)
logger.info(f"正在安装用户上传的插件 {file.filename}")
file_path = os.path.join(
get_astrbot_temp_path(),
f"plugin_upload_{file.filename}",
)
await file.save(file_path)
plugin_info = await self.plugin_manager.install_plugin_from_file(file_path)
plugin_info = await self.plugin_manager.install_plugin_from_file(
file_path,
ignore_version_check=ignore_version_check,
)
# self.core_lifecycle.restart()
logger.info(f"安装插件 {file.filename} 成功")
return Response().ok(plugin_info, "安装成功。").__dict__
except PluginVersionIncompatibleError as e:
return {
"status": "warning",
"message": str(e),
"data": {
"warning_type": "astrbot_version_incompatible",
"can_ignore": True,
},
}
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
+4 -5
View File
@@ -1,5 +1,4 @@
import base64
import os
import traceback
from io import BytesIO
@@ -51,14 +50,14 @@ async def generate_tsne_visualization(
return None
kb = kb_helper.kb
index_path = f"data/knowledge_base/{kb.kb_id}/index.faiss"
index_path = kb_helper.kb_dir / "index.faiss"
# 读取 FAISS 索引
if not os.path.exists(index_path):
logger.warning(f"FAISS 索引不存在: {index_path}")
if not index_path.exists():
logger.warning(f"FAISS 索引不存在: {index_path!s}")
return None
index = faiss.read_index(index_path)
index = faiss.read_index(str(index_path))
if index.ntotal == 0:
logger.warning("索引为空")
+47
View File
@@ -0,0 +1,47 @@
## What's Changed
### 新增
- 新增 Python / Shell 执行工具的管理员权限校验,提升高风险操作安全性 ([#5214](https://github.com/AstrBotDevs/AstrBot/issues/5214))。
- 新增插件 `astrbot-version` 与平台版本要求校验支持,增强插件兼容性管理能力 ([#5235](https://github.com/AstrBotDevs/AstrBot/issues/5235))。
- 账号密码修改流程新增“确认新密码”校验,减少误输导致的配置问题 ([#5247](https://github.com/AstrBotDevs/AstrBot/issues/5247))。
### 修复
- 改进微信公众号被动回复处理机制,引入缓冲与分片回复并优化超时行为,提升回复稳定性 ([#5224](https://github.com/AstrBotDevs/AstrBot/issues/5224))。
- 修复仅发送 JSON 消息段时可能触发空消息回复报错的问题 ([#5208](https://github.com/AstrBotDevs/AstrBot/issues/5208))。
- 修复会话重置/新建/删除时未终止活动事件导致的陈旧响应问题 ([#5225](https://github.com/AstrBotDevs/AstrBot/issues/5225))。
- 修复 provider 在 `dict` 格式 `content` 场景下可能残留 JSON 内容的问题 ([#5250](https://github.com/AstrBotDevs/AstrBot/issues/5250))。
- 修复 MCP 工具未完整暴露给主 Agent 的问题 ([#5252](https://github.com/AstrBotDevs/AstrBot/issues/5252))。
- 修复工具 schema 属性中的 `additionalProperties` 配置问题 ([#5253](https://github.com/AstrBotDevs/AstrBot/issues/5253))。
- 优化账号编辑校验错误提示,简化并统一用户名/密码为空场景返回信息。
### 优化
- 优化 PersonaForm 布局与工具选择展示,并完善工具停用状态的本地化显示。
### 其他
- 移除 Electron Desktop 流水线并迁移到 Tauri 仓库 ([#5226](https://github.com/AstrBotDevs/AstrBot/issues/5226))。
- 更新相关仓库链接与功能请求模板文案,统一中英文表达。
- 移除过时文档文件 `heihe.md`
## What's Changed (EN)
### New Features
- Added admin permission checks for Python/Shell execution tools to improve safety for high-risk operations ([#5214](https://github.com/AstrBotDevs/AstrBot/issues/5214)).
- Added support for `astrbot-version` and platform requirement checks for plugins to improve compatibility management ([#5235](https://github.com/AstrBotDevs/AstrBot/issues/5235)).
- Added password confirmation when changing account passwords to reduce misconfiguration caused by typos ([#5247](https://github.com/AstrBotDevs/AstrBot/issues/5247)).
### Fixes
- Improved passive reply handling for WeChat Official Accounts with buffering/chunking and timeout behavior optimizations for better stability ([#5224](https://github.com/AstrBotDevs/AstrBot/issues/5224)).
- Fixed an empty-message reply error when only JSON message segments were sent ([#5208](https://github.com/AstrBotDevs/AstrBot/issues/5208)).
- Fixed stale responses by terminating active events on reset/new/delete operations ([#5225](https://github.com/AstrBotDevs/AstrBot/issues/5225)).
- Fixed residual JSON content issues in provider handling when `content` was in `dict` format ([#5250](https://github.com/AstrBotDevs/AstrBot/issues/5250)).
- Fixed incomplete exposure of MCP tools to the main agent ([#5252](https://github.com/AstrBotDevs/AstrBot/issues/5252)).
- Fixed `additionalProperties` handling in tool schema properties ([#5253](https://github.com/AstrBotDevs/AstrBot/issues/5253)).
- Simplified and unified account-edit validation error responses for empty username/password scenarios.
### Improvements
- Enhanced PersonaForm layout and tool selection display, and improved localized labels for inactive tools.
### Others
- Removed the Electron desktop pipeline and switched to the Tauri repository ([#5226](https://github.com/AstrBotDevs/AstrBot/issues/5226)).
- Updated related repository links and refined feature request template wording in both Chinese and English.
- Removed outdated documentation file `heihe.md`.
@@ -0,0 +1,98 @@
<template>
<v-dialog v-model="isOpen" max-width="480" persistent>
<v-card>
<v-card-title class="dialog-title d-flex align-center justify-space-between">
<span>{{ title }}</span>
<v-btn icon="mdi-close" variant="text" @click="handleClose"></v-btn>
</v-card-title>
<v-card-text>
<div class="message-text">{{ message }}</div>
<div class="action-hints">
<span class="hint-item">{{ confirmHint }}</span>
<span class="hint-item">{{ cancelHint }}</span>
<span class="hint-item">{{ closeHint }}</span>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="gray" @click="handleCancel">{{ t('core.common.dialog.cancelButton') }}</v-btn>
<v-btn color="red" @click="handleConfirm" class="confirm-button">{{ t('core.common.dialog.confirmButton') }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { ref } from "vue";
import { useI18n } from '@/i18n/composables';
const { t } = useI18n();
const isOpen = ref(false);
const title = ref("");
const message = ref("");
const confirmHint = ref("");
const cancelHint = ref("");
const closeHint = ref("");
let resolvePromise = null;
const open = (options) => {
title.value = options.title || t('core.common.dialog.confirmTitle');
message.value = options.message || t('core.common.dialog.confirmMessage');
confirmHint.value = options.confirmHint || "";
cancelHint.value = options.cancelHint || "";
closeHint.value = options.closeHint || "";
isOpen.value = true;
return new Promise((resolve) => {
resolvePromise = resolve;
});
};
const handleConfirm = () => {
isOpen.value = false;
if (resolvePromise) resolvePromise(true);
};
const handleCancel = () => {
isOpen.value = false;
if (resolvePromise) resolvePromise(false);
};
const handleClose = () => {
isOpen.value = false;
if (resolvePromise) resolvePromise('close');
};
defineExpose({ open });
</script>
<style scoped>
.message-text {
margin-bottom: 8px;
line-height: 1.5;
font-size: 16px;
font-weight: 600;
}
.action-hints {
display: flex;
gap: 15px;
}
.hint-item {
color: var(--v-theme-secondaryText, #666);
font-size: 12px;
opacity: 0.7;
}
.dialog-title {
font-size: 20px;
font-weight: 500;
}
.confirm-button {
color: rgb(239, 68, 68);
}
</style>
@@ -1,5 +1,6 @@
<script setup>
import { useModuleI18n } from "@/i18n/composables";
import { getPlatformDisplayName } from "@/utils/platformUtils";
const { tm } = useModuleI18n("features/extension");
@@ -20,6 +21,17 @@ defineProps({
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);
};
@@ -29,7 +41,7 @@ const handleInstall = (plugin) => {
<v-card
class="rounded-lg d-flex flex-column plugin-card"
elevation="0"
style="height: 12rem; position: relative"
style="height: 13rem; position: relative"
>
<v-chip
v-if="plugin?.pinned"
@@ -152,6 +164,42 @@ const handleInstall = (plugin) => {
{{ 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"
@@ -4,6 +4,7 @@ import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
import { ref, computed } from 'vue'
import ConfigItemRenderer from './ConfigItemRenderer.vue'
import TemplateListEditor from './TemplateListEditor.vue'
import PersonaQuickPreview from './PersonaQuickPreview.vue'
import { useI18n, useModuleI18n } from '@/i18n/composables'
@@ -274,6 +275,16 @@ function getSpecialSubtype(value) {
</div>
</v-col>
</v-row>
<!-- Default Persona Quick Preview 全宽显示区域 -->
<v-row
v-if="!itemMeta?.invisible && itemMeta?._special === 'select_persona' && itemKey === 'provider_settings.default_personality'"
class="persona-preview-row"
>
<v-col cols="12" class="persona-preview-display">
<PersonaQuickPreview :model-value="createSelectorModel(itemKey).value" />
</v-col>
</v-row>
</template>
<v-divider class="config-divider"
v-if="shouldShowItem(itemMeta, itemKey) && hasVisibleItemsAfter(metadata[metadataKey].items, index)"></v-divider>
@@ -433,6 +444,15 @@ function getSpecialSubtype(value) {
padding: 0 8px;
}
.persona-preview-row {
margin: 16px;
margin-top: 0;
}
.persona-preview-display {
padding: 0 8px;
}
.selected-plugins-full-width {
background-color: rgba(var(--v-theme-primary), 0.05);
border: 1px solid rgba(var(--v-theme-primary), 0.1);
@@ -2,6 +2,7 @@
import { ref, computed, inject } from "vue";
import { useCustomizerStore } from "@/stores/customizer";
import { useModuleI18n } from "@/i18n/composables";
import { getPlatformDisplayName } from "@/utils/platformUtils";
import UninstallConfirmDialog from "./UninstallConfirmDialog.vue";
const props = defineProps({
@@ -38,6 +39,25 @@ const showUninstallDialog = ref(false);
// 国际化
const { tm } = useModuleI18n("features/extension");
const supportPlatforms = computed(() => {
const platforms = props.extension?.support_platforms;
if (!Array.isArray(platforms)) {
return [];
}
return platforms.filter((item) => typeof item === "string");
});
const supportPlatformDisplayNames = computed(() =>
supportPlatforms.value.map((platformId) => getPlatformDisplayName(platformId)),
);
const astrbotVersionRequirement = computed(() => {
const versionSpec = props.extension?.astrbot_version;
return typeof versionSpec === "string" && versionSpec.trim().length
? versionSpec.trim()
: "";
});
// 操作函数
const configure = () => {
emit("configure", props.extension);
@@ -316,6 +336,37 @@ const viewChangelog = () => {
>
{{ tag === "danger" ? tm("tags.danger") : tag }}
</v-chip>
<v-chip
v-if="supportPlatforms.length"
color="info"
variant="outlined"
label
size="small"
class="ml-2"
>
<v-tooltip location="top">
<template v-slot:activator="{ props: tooltipProps }">
<span v-bind="tooltipProps">
{{
tm("card.status.supportPlatformsCount", {
count: supportPlatformDisplayNames.length,
})
}}
</span>
</template>
<span>{{ supportPlatformDisplayNames.join(", ") }}</span>
</v-tooltip>
</v-chip>
<v-chip
v-if="astrbotVersionRequirement"
color="secondary"
variant="outlined"
label
size="small"
class="ml-2"
>
AstrBot: {{ astrbotVersionRequirement }}
</v-chip>
</div>
<div
+51 -35
View File
@@ -1,36 +1,30 @@
<template>
<v-dialog
v-model="showDialog"
:max-width="$vuetify.display.smAndDown ? undefined : '760px'"
scrollable
>
<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">
<v-card-title class="persona-form-title text-h2 px-6 pt-6 pl-6">
{{ editingPersona ? tm('dialog.edit.title') : tm('dialog.create.title') }}
</v-card-title>
<v-card-text class="persona-form-content">
<!-- 创建位置提示 -->
<v-alert
v-if="!editingPersona"
type="info"
variant="tonal"
density="compact"
class="mb-4"
icon="mdi-folder-outline"
>
<v-alert v-if="!editingPersona" type="info" variant="tonal" density="compact" class="mb-4"
icon="mdi-folder-outline">
{{ tm('form.createInFolder', { folder: folderDisplayName }) }}
</v-alert>
<v-form ref="personaForm" v-model="formValid">
<v-text-field v-model="personaForm.persona_id" :label="tm('form.personaId')"
:rules="personaIdRules" :disabled="editingPersona" variant="outlined" density="comfortable"
class="mb-4" />
<v-row class="persona-form-layout">
<v-col cols="12" md="6" class="persona-basic-col">
<v-text-field v-model="personaForm.persona_id" :label="tm('form.personaId')"
:rules="personaIdRules" :disabled="editingPersona" variant="outlined"
density="comfortable" class="mb-4" />
<v-textarea v-model="personaForm.system_prompt" :label="tm('form.systemPrompt')"
:rules="systemPromptRules" variant="outlined" rows="6" class="mb-4" />
<v-textarea v-model="personaForm.system_prompt" :label="tm('form.systemPrompt')"
:rules="systemPromptRules" variant="outlined" rows="16" class="mb-4" />
</v-col>
<v-expansion-panels v-model="expandedPanels" multiple>
<v-col cols="12" md="6" class="persona-panels-col">
<v-expansion-panels v-model="expandedPanels" multiple>
<!-- 工具选择面板 -->
<v-expansion-panel value="tools">
<v-expansion-panel-title>
@@ -69,8 +63,8 @@
<div class="d-flex flex-wrap ga-2">
<v-chip v-for="server in mcpServers" :key="server.name"
:color="isServerSelected(server) ? 'primary' : 'default'"
:variant="isServerSelected(server) ? 'flat' : 'outlined'"
size="small" clickable @click="toggleMcpServer(server)"
:variant="isServerSelected(server) ? 'flat' : 'outlined'" size="small"
clickable @click="toggleMcpServer(server)"
:disabled="!server.tools || server.tools.length === 0">
<v-icon start size="small">mdi-server</v-icon>
{{ server.name }}
@@ -83,7 +77,7 @@
<!-- 工具选择列表 -->
<div v-if="filteredTools.length > 0" class="tools-selection">
<v-virtual-scroll :items="filteredTools" height="300" item-height="48">
<v-virtual-scroll :items="filteredTools" height="300" item-height="72">
<template v-slot:default="{ item }">
<v-list-item :key="item.name" density="comfortable"
@click="toggleTool(item.name)">
@@ -94,10 +88,16 @@
<v-list-item-title>
{{ item.name }}
<v-chip v-if="item.mcp_server_name" size="x-small"
color="secondary" variant="tonal" class="ml-2">
{{ item.mcp_server_name }}
<v-chip v-if="item.origin" size="x-small" color="info" class="mr-2"
variant="tonal">
{{ item.origin }}
</v-chip>
<v-chip v-if="item.origin_name" size="x-small" color="info"
variant="outlined">
{{ item.origin_name }}
</v-chip>
</v-list-item-title>
<v-list-item-subtitle v-if="item.description">
@@ -112,7 +112,7 @@
class="text-center pa-4">
<v-icon size="48" color="grey-lighten-2" class="mb-2">mdi-tools</v-icon>
<p class="text-body-2 text-medium-emphasis">{{ tm('form.noToolsAvailable')
}}
}}
</p>
</div>
@@ -127,7 +127,7 @@
<div v-if="loadingTools" class="text-center pa-4">
<v-progress-circular indeterminate color="primary" />
<p class="text-body-2 text-medium-emphasis mt-2">{{ tm('form.loadingTools')
}}
}}
</p>
</div>
@@ -143,9 +143,9 @@
</span>
</h4>
<div v-if="Array.isArray(personaForm.tools) && personaForm.tools.length > 0"
class="d-flex flex-wrap ga-1" style="max-height: 100px; overflow-y: auto;">
<v-chip v-for="toolName in personaForm.tools" :key="toolName"
size="small" color="primary" variant="tonal" closable
class="d-flex flex-wrap ga-1" style="max-height: 100px; overflow-y: auto;">
<v-chip v-for="toolName in personaForm.tools" :key="toolName" size="small"
color="primary" variant="tonal" closable
@click:close="removeTool(toolName)">
{{ toolName }}
</v-chip>
@@ -209,7 +209,8 @@
<div v-else-if="!loadingSkills && availableSkills.length === 0"
class="text-center pa-4">
<v-icon size="48" color="grey-lighten-2" class="mb-2">mdi-lightning-bolt</v-icon>
<v-icon size="48" color="grey-lighten-2"
class="mb-2">mdi-lightning-bolt</v-icon>
<p class="text-body-2 text-medium-emphasis">{{ tm('form.noSkillsAvailable') }}
</p>
</div>
@@ -288,7 +289,9 @@
</v-btn>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-expansion-panels>
</v-col>
</v-row>
</v-form>
</v-card-text>
@@ -484,7 +487,7 @@ export default {
};
this.toolSelectValue = '0';
this.skillSelectValue = '0';
this.expandedPanels = [];
this.expandedPanels = this.getDefaultExpandedPanels();
},
initFormWithPersona(persona) {
@@ -499,7 +502,11 @@ export default {
// 根据 tools 的值设置 toolSelectValue
this.toolSelectValue = persona.tools === null ? '0' : '1';
this.skillSelectValue = persona.skills === null ? '0' : '1';
this.expandedPanels = [];
this.expandedPanels = this.getDefaultExpandedPanels();
},
getDefaultExpandedPanels() {
return this.$vuetify.display.smAndDown ? [] : ['tools', 'skills', 'dialogs'];
},
closeDialog() {
@@ -829,6 +836,10 @@ export default {
margin-left: 32px;
}
.persona-form-layout {
align-items: flex-start;
}
.tools-selection {
max-height: 300px;
overflow-y: auto;
@@ -853,6 +864,11 @@ export default {
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;
@@ -0,0 +1,301 @@
<template>
<div class="persona-preview-card">
<div class="preview-header">
<small>{{ tm('personaQuickPreview.title') }}</small>
</div>
<div v-if="loading" class="preview-loading">
<v-progress-circular indeterminate size="18" width="2" color="primary" class="mr-2" />
<small class="text-grey">{{ tm('personaQuickPreview.loading') }}</small>
</div>
<div v-else-if="!modelValue" class="preview-empty">
<small class="text-grey">{{ tm('personaQuickPreview.noPersonaSelected') }}</small>
</div>
<div v-else-if="!personaData" class="preview-empty">
<small class="text-grey">{{ tm('personaQuickPreview.personaNotFound') }}</small>
</div>
<div v-else class="preview-content">
<div class="section-title">{{ tm('personaQuickPreview.systemPromptLabel') }}</div>
<pre class="prompt-content">{{ personaData.system_prompt || '' }}</pre>
<div class="section-title mt-3">{{ tm('personaQuickPreview.toolsLabel') }}</div>
<div class="chip-wrap tools-wrap">
<v-chip
v-if="personaData.tools === null"
size="small"
color="success"
variant="tonal"
label
>
{{ tm('personaQuickPreview.allToolsWithCount', { count: allToolsCount }) }}
</v-chip>
<div v-for="tool in resolvedTools" v-else :key="tool.name" class="tool-item">
<v-chip
size="small"
:color="tool.active === false ? 'warning' : 'primary'"
variant="outlined"
label
>
{{ tool.name }}
</v-chip>
<v-tooltip v-if="tool.active === false" location="top">
<template v-slot:activator="{ props: tooltipProps }">
<small class="text-warning tool-inactive" v-bind="tooltipProps">
{{ tm('personaQuickPreview.toolInactive') }}
</small>
</template>
{{ tm('personaQuickPreview.toolInactiveTooltip') }}
</v-tooltip>
<small v-if="tool.origin || tool.origin_name" class="text-grey tool-meta">
<span v-if="tool.origin">{{ tm('personaQuickPreview.originLabel') }}: {{ tool.origin }}</span>
<span v-if="tool.origin_name"> | {{ tm('personaQuickPreview.originNameLabel') }}: {{ tool.origin_name }}</span>
</small>
</div>
<small v-if="personaData.tools !== null && normalizedTools.length === 0" class="text-grey">
{{ tm('personaQuickPreview.noTools') }}
</small>
</div>
<div class="section-title mt-3">{{ tm('personaQuickPreview.skillsLabel') }}</div>
<div class="chip-wrap">
<v-chip
v-if="personaData.skills === null"
size="small"
color="success"
variant="tonal"
label
>
{{ tm('personaQuickPreview.allSkillsWithCount', { count: allSkillsCount }) }}
</v-chip>
<v-chip
v-for="skillName in normalizedSkills"
v-else
:key="skillName"
size="small"
color="primary"
variant="outlined"
label
>
{{ skillName }}
</v-chip>
<small v-if="personaData.skills !== null && normalizedSkills.length === 0" class="text-grey">
{{ tm('personaQuickPreview.noSkills') }}
</small>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
import axios from 'axios'
import { useModuleI18n } from '@/i18n/composables'
const props = defineProps({
modelValue: {
type: String,
default: ''
}
})
const { tm } = useModuleI18n('core.shared')
const loading = ref(false)
const personaData = ref(null)
const toolMetaMap = ref({})
const availableSkills = ref([])
const defaultPersonaData = {
persona_id: 'default',
system_prompt: 'You are a helpful and friendly assistant.',
tools: null,
skills: null
}
const normalizedTools = computed(() => (Array.isArray(personaData.value?.tools) ? personaData.value.tools : []))
const normalizedSkills = computed(() => (Array.isArray(personaData.value?.skills) ? personaData.value.skills : []))
const allToolsCount = computed(() => Object.keys(toolMetaMap.value).length)
const allSkillsCount = computed(() => availableSkills.value.length)
const resolvedTools = computed(() =>
normalizedTools.value.map((toolName) => {
const meta = toolMetaMap.value[toolName] || {}
return {
name: toolName,
origin: meta.origin || '',
origin_name: meta.origin_name || '',
active: meta.active
}
})
)
async function loadToolsMeta() {
try {
const response = await axios.get('/api/tools/list')
if (response.data?.status === 'ok') {
const tools = response.data?.data || []
const nextMap = {}
for (const tool of tools) {
if (!tool?.name) {
continue
}
nextMap[tool.name] = {
origin: tool.origin || '',
origin_name: tool.origin_name || '',
active: tool.active
}
}
toolMetaMap.value = nextMap
}
} catch (error) {
console.error('Failed to load tools metadata:', error)
toolMetaMap.value = {}
}
}
async function loadSkillsMeta() {
try {
const response = await axios.get('/api/skills')
if (response.data?.status === 'ok') {
const payload = response.data?.data || []
if (Array.isArray(payload)) {
availableSkills.value = payload.filter((skill) => skill.active !== false)
} else {
const skills = payload.skills || []
availableSkills.value = skills.filter((skill) => skill.active !== false)
}
} else {
availableSkills.value = []
}
} catch (error) {
console.error('Failed to load skills metadata:', error)
availableSkills.value = []
}
}
async function loadPersonaPreview(personaId) {
if (!personaId) {
personaData.value = null
return
}
if (personaId === 'default') {
personaData.value = defaultPersonaData
return
}
loading.value = true
try {
const response = await axios.get('/api/persona/list')
if (response.data?.status === 'ok') {
const personas = response.data?.data || []
personaData.value = personas.find((item) => item.persona_id === personaId) || null
} else {
personaData.value = null
}
} catch (error) {
console.error('Failed to load persona preview:', error)
personaData.value = null
} finally {
loading.value = false
}
}
function handlePersonaSaved() {
if (props.modelValue) {
loadPersonaPreview(props.modelValue)
}
}
watch(
() => props.modelValue,
(newValue) => {
loadPersonaPreview(newValue)
},
{ immediate: true }
)
loadToolsMeta()
loadSkillsMeta()
onMounted(() => {
window.addEventListener('astrbot:persona-saved', handlePersonaSaved)
})
onBeforeUnmount(() => {
window.removeEventListener('astrbot:persona-saved', handlePersonaSaved)
})
</script>
<style scoped>
.persona-preview-card {
background-color: rgba(var(--v-theme-primary), 0.05);
border: 1px solid rgba(var(--v-theme-primary), 0.1);
border-radius: 8px;
padding: 12px;
}
.preview-header {
margin-bottom: 8px;
}
.preview-loading,
.preview-empty {
display: flex;
align-items: center;
min-height: 24px;
}
.section-title {
font-size: 0.75rem;
color: rgb(var(--v-theme-primaryText));
opacity: 0.85;
}
.prompt-content {
margin-top: 6px;
max-height: 180px;
overflow: auto;
font-size: 0.78rem;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
background: rgba(0, 0, 0, 0.03);
border-radius: 6px;
padding: 8px;
}
.chip-wrap {
display: grid;
gap: 6px;
margin-top: 6px;
}
.tools-wrap {
max-height: 160px;
overflow: auto;
}
.tool-item {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.tool-meta {
font-size: 0.74rem;
}
.tool-inactive {
font-size: 0.74rem;
}
@media (max-width: 600px) {
.tools-wrap {
max-height: 120px;
}
}
</style>
@@ -188,10 +188,16 @@ function openEditPersona(persona: Persona) {
// 人格保存成功(创建或编辑)
async function handlePersonaSaved(message: string) {
console.log('人格保存成功:', message)
const savedPersonaId = editingPersona.value?.persona_id || ''
showPersonaDialog.value = false
editingPersona.value = null
// 刷新当前文件夹的人格列表
await loadPersonasInFolder(currentFolderId.value)
window.dispatchEvent(
new CustomEvent('astrbot:persona-saved', {
detail: { persona_id: savedPersonaId }
})
)
}
// 错误处理
@@ -72,14 +72,17 @@
"form": {
"currentPassword": "Current Password",
"newPassword": "New Password",
"confirmPassword": "Confirm New Password",
"newUsername": "New Username (Optional)",
"passwordHint": "Password must be at least 8 characters",
"confirmPasswordHint": "Please enter new password again to confirm",
"usernameHint": "Leave blank to keep current username",
"defaultCredentials": "Default username and password are both astrbot"
},
"validation": {
"passwordRequired": "Please enter password",
"passwordMinLength": "Password must be at least 8 characters",
"passwordMatch": "Passwords do not match",
"usernameMinLength": "Username must be at least 3 characters"
},
"actions": {
@@ -62,6 +62,25 @@
"rootFolder": "All Personas",
"emptyFolder": "This folder is empty"
},
"personaQuickPreview": {
"title": "Quick Persona Preview",
"loading": "Loading...",
"noPersonaSelected": "No persona selected",
"personaNotFound": "Persona details not found",
"systemPromptLabel": "System Prompt",
"toolsLabel": "Tools",
"skillsLabel": "Skills",
"originLabel": "Origin",
"originNameLabel": "Origin Name",
"toolInactive": "Disabled",
"toolInactiveTooltip": "This tool is disabled. Re-enable it in Extensions -> Handlers -> Function Tools.",
"allTools": "All tools available",
"allToolsWithCount": "All tools available ({count})",
"noTools": "No tools configured",
"allSkills": "All Skills available",
"allSkillsWithCount": "All Skills available ({count})",
"noSkills": "No Skills configured"
},
"t2iTemplateEditor": {
"buttonText": "Customize T2I Template",
"dialogTitle": "Customize Text-to-Image HTML Template",
@@ -149,6 +149,10 @@
"description": "Computer Use Runtime",
"hint": "sandbox means running in a sandbox environment, local means running in a local environment, none means disabling Computer Use. If skills are uploaded, choosing none will cause them to not be usable by the Agent."
},
"computer_use_require_admin": {
"description": "Require AstrBot Admin Permission",
"hint": "When enabled, AstrBot admin permission is required to use computer capabilities. Admins can be added in Platform Config. Use the /sid command to view admin IDs."
},
"sandbox": {
"booter": {
"description": "Sandbox Environment Driver"
@@ -112,5 +112,18 @@
"addToConfig": "Added to config",
"fileCount": "Files: {count}",
"done": "Done"
},
"unsavedChangesWarning": {
"dialogTitle": "Unsaved changes",
"leavePage": "You have unsaved changes. Do you want to save before leaving?",
"switchConfig": "Switching config will discard unsaved changes. Do you want to save first?",
"options": {
"save": "Save",
"saveAndSwitch": "Save and switch",
"discardAndSwitch": "Discard changes and switch",
"closeCard": "Close the pop-up window",
"confirm": "confirm",
"cancel": "cancel"
}
}
}
@@ -143,7 +143,8 @@
"install": {
"title": "Install Extension",
"fromFile": "Install from File",
"fromUrl": "Install from URL"
"fromUrl": "Install from URL",
"supportPlatformsCount": "Supports {count} Platforms"
},
"danger_warning": {
"title": "Dangerous Plugin Warning",
@@ -151,6 +152,12 @@
"confirm": "Continue",
"cancel": "Cancel"
},
"versionCompatibility": {
"title": "Version Compatibility Warning",
"message": "This plugin declares an AstrBot version range that does not match your current version. You can ignore this warning and continue installation, but it may not work correctly.",
"confirm": "Ignore Warning and Install",
"cancel": "Cancel Installation"
},
"forceUpdate": {
"title": "No New Version Detected",
"message": "No new version detected for this plugin. Do you want to force reinstall? This will pull the latest code from the remote repository.",
@@ -230,7 +237,10 @@
"status": {
"hasUpdate": "New version available",
"disabled": "This extension is disabled",
"handlersCount": " handlers"
"handlersCount": " handlers",
"supportPlatform": "Supported Platform",
"supportPlatformsCount": "Supports {count} Platforms",
"astrbotVersion": "AstrBot Version Requirement"
},
"alt": {
"logo": "logo",
@@ -19,7 +19,8 @@
"enabled": "When on: the main LLM keeps its own tools and mounts transfer_to_* delegate tools. With deduplication, tools overlapping with SubAgents are removed from the main tool set."
},
"section": {
"title": "SubAgents"
"title": "SubAgents",
"globalSettings": "Global Settings"
},
"cards": {
"statusEnabled": "Enabled",
@@ -72,14 +72,17 @@
"form": {
"currentPassword": "当前密码",
"newPassword": "新密码",
"confirmPassword": "确认新密码",
"newUsername": "新用户名 (可选)",
"passwordHint": "密码长度至少 8 位",
"confirmPasswordHint": "请再次输入新密码以确认",
"usernameHint": "留空表示不修改用户名",
"defaultCredentials": "默认用户名和密码均为 astrbot"
},
"validation": {
"passwordRequired": "请输入密码",
"passwordMinLength": "密码长度至少 8 位",
"passwordMatch": "两次输入的密码不一致",
"usernameMinLength": "用户名长度至少3位"
},
"actions": {
@@ -90,4 +93,4 @@
"updateFailed": "修改失败,请重试"
}
}
}
}
@@ -62,6 +62,25 @@
"rootFolder": "全部人格",
"emptyFolder": "此文件夹为空"
},
"personaQuickPreview": {
"title": "快速预览",
"loading": "加载中...",
"noPersonaSelected": "未选择人格",
"personaNotFound": "未找到该人格的详情",
"systemPromptLabel": "系统提示词",
"toolsLabel": "工具",
"skillsLabel": "技能(Skills",
"originLabel": "来源",
"originNameLabel": "来源名称",
"toolInactive": "已禁用",
"toolInactiveTooltip": "该工具已被禁用。在插件->管理行为->函数工具中重新启用。",
"allTools": "全部工具可用",
"allToolsWithCount": "全部工具可用({count}",
"noTools": "未配置工具",
"allSkills": "全部 Skills 可用",
"allSkillsWithCount": "全部 Skills 可用({count}",
"noSkills": "未配置 Skills"
},
"t2iTemplateEditor": {
"buttonText": "自定义 T2I 模板",
"dialogTitle": "自定义文转图 HTML 模板",
@@ -152,6 +152,10 @@
"description": "运行环境",
"hint": "sandbox 代表在沙箱环境中运行, local 代表在本地环境中运行, none 代表不启用。如果上传了 skills,选择 none 会导致其无法被 Agent 正常使用。"
},
"computer_use_require_admin": {
"description": "需要 AstrBot 管理员权限",
"hint": "开启后,需要 AstrBot 管理员权限才能调用使用电脑能力。在平台配置->管理员中可添加管理员。使用 /sid 指令查看管理员 ID。"
},
"sandbox": {
"booter": {
"description": "沙箱环境驱动器"
@@ -112,5 +112,18 @@
"addToConfig": "已加入配置",
"fileCount": "文件:{count}",
"done": "完成"
},
"unsavedChangesWarning": {
"dialogTitle": "未保存的更改",
"leavePage": "当前配置有未保存的更改,切换前是否保存?",
"switchConfig": "切换配置文件会丢失当前未保存的更改,是否先保存?",
"options": {
"save": "保存",
"saveAndSwitch": "保存并切换",
"discardAndSwitch": "放弃更改并切换",
"closeCard": "关闭弹窗",
"confirm": "确定",
"cancel": "取消"
}
}
}
@@ -143,7 +143,8 @@
"install": {
"title": "安装插件",
"fromFile": "从文件安装",
"fromUrl": "从链接安装"
"fromUrl": "从链接安装",
"supportPlatformsCount": "支持 {count} 个平台"
},
"danger_warning": {
"title": "警告",
@@ -151,6 +152,12 @@
"confirm": "继续",
"cancel": "取消"
},
"versionCompatibility": {
"title": "版本兼容性警告",
"message": "该插件声明的 AstrBot 版本范围与当前版本不匹配。你可以无视警告继续安装,但可能无法正常运行。",
"confirm": "无视警告,继续安装",
"cancel": "取消安装"
},
"forceUpdate": {
"title": "未检测到新版本",
"message": "当前插件未检测到新版本,是否强制重新安装?这将从远程仓库拉取最新代码。",
@@ -230,7 +237,10 @@
"status": {
"hasUpdate": "有新版本可用",
"disabled": "该插件已经被禁用",
"handlersCount": "个行为"
"handlersCount": "个行为",
"supportPlatform": "支持平台",
"supportPlatformsCount": "支持 {count} 个平台",
"astrbotVersion": "AstrBot 版本要求"
},
"alt": {
"logo": "logo",
@@ -24,7 +24,7 @@
"presetDialogsHelp": "添加一些预设的对话来帮助机器人更好地理解角色设定。",
"userMessage": "用户消息",
"assistantMessage": "AI 回答",
"tools": "工具选择",
"tools": "工具 / MCP 工具选择",
"toolsHelp": "为这个人格选择可用的外部工具。外部工具给了 AI 接触外部环境的能力,如搜索、计算、获取信息等。",
"toolsSelection": "工具选择操作",
"selectAllTools": "选择所有工具",
@@ -19,7 +19,8 @@
"enabled": "启动:主 LLM 会保留自身工具并挂载 transfer_to_* 委派工具。若开启“去重重复工具”,与 SubAgent 指定的工具重叠部分会从主 LLM 工具集中移除。"
},
"section": {
"title": "SubAgents"
"title": "SubAgents 配置",
"globalSettings": "全局设置"
},
"cards": {
"statusEnabled": "启用",
@@ -28,7 +29,8 @@
"transferPrefix": "transfer_to_{name}",
"switchLabel": "启用",
"previewTitle": "预览:主 LLM 将看到的 handoff 工具",
"personaChip": "Persona: {id}"
"personaChip": "Persona: {id}",
"noDescription": "暂无描述"
},
"form": {
"nameLabel": "Agent 名称(用于 transfer_to_{name}",
@@ -33,6 +33,7 @@ let aboutDialog = ref(false);
const username = localStorage.getItem('user');
let password = ref('');
let newPassword = ref('');
let confirmPassword = ref('');
let newUsername = ref('');
let status = ref('');
let updateStatus = ref('')
@@ -51,7 +52,8 @@ const isElectronApp = ref(
const redirectConfirmDialog = ref(false);
const pendingRedirectUrl = ref('');
const resolvingReleaseTarget = ref(false);
const fallbackReleaseUrl = 'https://github.com/AstrBotDevs/AstrBot/releases/latest';
const desktopReleaseBaseUrl = 'https://github.com/AstrBotDevs/AstrBot-desktop/releases';
const fallbackReleaseUrl = desktopReleaseBaseUrl;
const getSelectedGitHubProxy = () => {
if (typeof window === "undefined" || !window.localStorage) return "";
@@ -88,6 +90,10 @@ const passwordRules = computed(() => [
(v: string) => !!v || t('core.header.accountDialog.validation.passwordRequired'),
(v: string) => v.length >= 8 || t('core.header.accountDialog.validation.passwordMinLength')
]);
const confirmPasswordRules = computed(() => [
(v: string) => !newPassword.value || !!v || t('core.header.accountDialog.validation.passwordRequired'),
(v: string) => !newPassword.value || v === newPassword.value || t('core.header.accountDialog.validation.passwordMatch')
]);
const usernameRules = computed(() => [
(v: string) => !v || v.length >= 3 || t('core.header.accountDialog.validation.usernameMinLength')
]);
@@ -95,6 +101,7 @@ const usernameRules = computed(() => [
//
const showPassword = ref(false);
const showNewPassword = ref(false);
const showConfirmPassword = ref(false);
//
const accountEditStatus = ref({
@@ -128,12 +135,15 @@ function confirmExternalRedirect() {
const getReleaseUrlForElectron = () => {
const firstRelease = (releases.value as any[])?.[0];
if (firstRelease?.html_url) return firstRelease.html_url as string;
if (firstRelease?.tag_name) {
const tag = firstRelease.tag_name as string;
return `${desktopReleaseBaseUrl}/tag/${tag}`;
}
if (hasNewVersion.value) return fallbackReleaseUrl;
const tag = botCurrVersion.value?.startsWith('v') ? botCurrVersion.value : 'latest';
return tag === 'latest'
? fallbackReleaseUrl
: `https://github.com/AstrBotDevs/AstrBot/releases/tag/${tag}`;
: `${desktopReleaseBaseUrl}/tag/${tag}`;
};
function handleUpdateClick() {
@@ -165,17 +175,14 @@ function accountEdit() {
accountEditStatus.value.error = false;
accountEditStatus.value.success = false;
// md5
// @ts-ignore
if (password.value != '') {
password.value = md5(password.value);
}
if (newPassword.value != '') {
newPassword.value = md5(newPassword.value);
}
const passwordHash = password.value ? md5(password.value) : '';
const newPasswordHash = newPassword.value ? md5(newPassword.value) : '';
const confirmPasswordHash = confirmPassword.value ? md5(confirmPassword.value) : '';
axios.post('/api/auth/account/edit', {
password: password.value,
new_password: newPassword.value,
password: passwordHash,
new_password: newPasswordHash,
confirm_password: confirmPasswordHash,
new_username: newUsername.value ? newUsername.value : username
})
.then((res) => {
@@ -184,6 +191,7 @@ function accountEdit() {
accountEditStatus.value.message = res.data.message;
password.value = '';
newPassword.value = '';
confirmPassword.value = '';
return;
}
accountEditStatus.value.success = true;
@@ -200,6 +208,7 @@ function accountEdit() {
accountEditStatus.value.message = typeof err === 'string' ? err : t('core.header.accountDialog.messages.updateFailed');
password.value = '';
newPassword.value = '';
confirmPassword.value = '';
})
.finally(() => {
accountEditStatus.value.loading = false;
@@ -730,10 +739,16 @@ onMounted(async () => {
<v-text-field v-model="newPassword" :append-inner-icon="showNewPassword ? 'mdi-eye-off' : 'mdi-eye'"
:type="showNewPassword ? 'text' : 'password'" :rules="passwordRules"
:label="t('core.header.accountDialog.form.newPassword')" variant="outlined" required clearable
:label="t('core.header.accountDialog.form.newPassword')" variant="outlined" clearable
@click:append-inner="showNewPassword = !showNewPassword" prepend-inner-icon="mdi-lock-plus-outline"
:hint="t('core.header.accountDialog.form.passwordHint')" persistent-hint class="mb-4"></v-text-field>
<v-text-field v-model="confirmPassword" :append-inner-icon="showConfirmPassword ? 'mdi-eye-off' : 'mdi-eye'"
:type="showConfirmPassword ? 'text' : 'password'" :rules="confirmPasswordRules"
:label="t('core.header.accountDialog.form.confirmPassword')" variant="outlined" clearable
@click:append-inner="showConfirmPassword = !showConfirmPassword" prepend-inner-icon="mdi-lock-check-outline"
:hint="t('core.header.accountDialog.form.confirmPasswordHint')" persistent-hint class="mb-4"></v-text-field>
<v-text-field v-model="newUsername" :rules="usernameRules"
:label="t('core.header.accountDialog.form.newUsername')" variant="outlined" clearable
prepend-inner-icon="mdi-account-edit-outline" :hint="t('core.header.accountDialog.form.usernameHint')"
+8
View File
@@ -177,6 +177,14 @@ 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,3 +80,29 @@ 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;
}
+160 -10
View File
@@ -7,7 +7,7 @@
<div class="config-toolbar 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;" v-model="selectedConfigID" :items="configSelectItems" item-title="name" :disabled="initialConfigId !== null"
<v-select class="config-select" style="min-width: 130px;" :model-value="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>
@@ -191,6 +191,10 @@
</div>
</v-card>
</v-overlay>
<!-- 未保存更改确认弹窗 -->
<UnsavedChangesConfirmDialog ref="unsavedChangesDialog" />
</template>
@@ -206,6 +210,7 @@ import {
askForConfirmation as askForConfirmationDialog,
useConfirmDialog
} from '@/utils/confirmDialog';
import UnsavedChangesConfirmDialog from '@/components/config/UnsavedChangesConfirmDialog.vue';
export default {
name: 'ConfigPage',
@@ -213,7 +218,8 @@ export default {
AstrBotCoreConfigWrapper,
VueMonacoEditor,
WaitingForRestart,
StandaloneChat
StandaloneChat,
UnsavedChangesConfirmDialog
},
props: {
initialConfigId: {
@@ -233,6 +239,40 @@ 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 {
@@ -243,6 +283,11 @@ 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);
},
@@ -269,8 +314,16 @@ export default {
config_data_str(val) {
this.config_data_has_changed = true;
},
'$route.fullPath'(newVal) {
this.syncConfigTypeFromHash(newVal);
config_data: {
deep: true,
handler() {
if (this.fetched) {
this.hasUnsavedChanges = this.configHasChanges;
}
}
},
async '$route.fullPath'(newVal) {
await this.syncConfigTypeFromHash(newVal);
},
initialConfigId(newVal) {
if (!newVal) {
@@ -309,6 +362,7 @@ export default {
//
selectedConfigID: null, //
currentConfigId: null, // id
configInfoList: [],
configFormData: {
name: '',
@@ -318,6 +372,11 @@ export default {
//
testChatDrawer: false,
testConfigId: null,
//
hasUnsavedChanges: false,
//
originalConfigData: null,
}
},
mounted() {
@@ -334,6 +393,13 @@ 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() {
@@ -362,14 +428,14 @@ export default {
const cleanHash = rawHash.slice(lastHashIndex + 1);
return cleanHash === 'system' || cleanHash === 'normal' ? cleanHash : null;
},
syncConfigTypeFromHash(hash) {
async syncConfigTypeFromHash(hash) {
const configType = this.extractConfigTypeFromHash(hash);
if (!configType || configType === this.configType) {
return false;
}
this.configType = configType;
this.onConfigTypeToggle();
await this.onConfigTypeToggle();
return true;
},
getConfigInfoList(abconf_id) {
@@ -382,6 +448,7 @@ 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;
@@ -391,6 +458,7 @@ export default {
if (!matched && this.configInfoList.length) {
//
this.selectedConfigID = this.configInfoList[0].id;
this.currentConfigId = this.configInfoList[0].id;
this.getConfig(this.selectedConfigID);
}
}
@@ -418,6 +486,14 @@ export default {
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;
@@ -437,27 +513,37 @@ export default {
postData.conf_id = this.selectedConfigID;
}
axios.post('/api/config/astrbot/update', postData).then((res) => {
return 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;
@@ -497,7 +583,7 @@ export default {
this.save_message_success = "error";
});
},
onConfigSelect(value) {
async onConfigSelect(value) {
if (value === '_%manage%_') {
this.configManageDialog = true;
//
@@ -506,7 +592,44 @@ export default {
this.getConfig(this.selectedConfigID);
});
} else {
this.getConfig(value);
//
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);
}
}
},
startCreateConfig() {
@@ -600,7 +723,34 @@ export default {
this.save_message_success = "error";
});
},
onConfigTypeToggle() {
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;
}
}
}
this.isSystemConfig = this.configType === 'system';
this.fetched = false; //
+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;
+252 -14
View File
@@ -14,6 +14,7 @@ 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";
@@ -149,6 +150,18 @@ 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);
@@ -250,10 +263,16 @@ 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)
plugin.author?.toLowerCase().includes(search) ||
supportPlatforms.includes(search) ||
astrbotVersion.includes(search)
);
});
});
@@ -758,6 +777,7 @@ 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";
@@ -767,6 +787,7 @@ 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";
@@ -958,9 +979,33 @@ 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);
@@ -978,7 +1023,21 @@ const checkAlreadyInstalled = () => {
pluginMarketData.value = notInstalled.concat(installed);
};
const newExtension = async () => {
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) => {
if (extension_url.value === "" && upload_file.value === null) {
toast(tm("messages.fillUrlOrFile"), "error");
return;
@@ -995,6 +1054,7 @@ const newExtension = async () => {
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: {
@@ -1003,6 +1063,14 @@ const newExtension = async () => {
})
.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;
@@ -1032,9 +1100,18 @@ const newExtension = async () => {
.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);
@@ -1060,6 +1137,53 @@ const newExtension = async () => {
}
};
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;
@@ -1165,6 +1289,19 @@ 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,
() => {
@@ -1451,18 +1588,54 @@ watch(activeTab, (newTab) => {
</template>
<template v-slot:item.desc="{ item }">
<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 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>
</template>
@@ -2357,6 +2530,31 @@ 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
@@ -2438,6 +2636,46 @@ 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>
+223 -227
View File
@@ -1,155 +1,249 @@
<template>
<div class="subagent-page">
<div class="d-flex align-center justify-space-between mb-4">
<div class="d-flex align-center justify-space-between mb-6">
<div>
<div class="d-flex align-center" style="gap: 8px;">
<div class="d-flex align-center gap-2 mb-1">
<h2 class="text-h5 font-weight-bold">{{ tm('page.title') }}</h2>
<v-chip size="x-small" color="orange-darken-2" variant="tonal" label>{{ tm('page.beta') }}</v-chip>
<v-chip size="x-small" color="orange-darken-2" variant="tonal" label class="font-weight-bold">
{{ 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" 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 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>
</div>
<v-card class="rounded-lg" variant="flat">
<!-- Global Settings Card -->
<v-card class="rounded-lg mb-6 border-thin" variant="flat" border>
<v-card-text>
<v-row>
<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-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>
<v-snackbar v-model="snackbar.show" :color="snackbar.color" timeout="3000">
<!-- 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">
{{ snackbar.message }}
<template #actions>
<v-btn variant="text" @click="snackbar.show = false">Close</v-btn>
</template>
</v-snackbar>
</div>
</template>
@@ -158,9 +252,12 @@
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
@@ -196,9 +293,6 @@ 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')
)
@@ -244,24 +338,6 @@ 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)}`,
@@ -333,7 +409,7 @@ async function save() {
}
async function reload() {
await Promise.all([loadConfig(), loadPersonas()])
await Promise.all([loadConfig()])
}
onMounted(() => {
@@ -343,101 +419,21 @@ onMounted(() => {
<style scoped>
.subagent-page {
padding: 20px;
padding-top: 8px;
padding-bottom: 40px;
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.subagent-panel-title {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
.subagent-panels :deep(.v-expansion-panel-text__wrapper) {
padding: 16px;
padding-bottom: 42px;
}
.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-2 {
gap: 8px;
}
.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;
.gap-4 {
gap: 16px;
}
</style>
+19 -1
View File
@@ -117,7 +117,18 @@
<v-card v-if="viewingPersona">
<v-card-title class="d-flex justify-space-between align-center">
<span class="text-h5">{{ viewingPersona.persona_id }}</span>
<v-btn icon="mdi-close" variant="text" @click="showViewDialog = false" />
<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-card-title>
<v-card-text>
@@ -414,6 +425,13 @@ 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
@@ -1,131 +0,0 @@
# 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

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

-821
View File
@@ -1,821 +0,0 @@
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn, spawnSync } = require('child_process');
const { BufferedRotatingLogger } = require('./buffered-rotating-logger');
const {
delay,
ensureDir,
formatLogTimestamp,
normalizeUrl,
parseLogBackupCount,
parseLogMaxBytes,
waitForProcessExit,
} = require('./common');
const PACKAGED_BACKEND_TIMEOUT_FALLBACK_MS = 5 * 60 * 1000;
const GRACEFUL_RESTART_WAIT_FALLBACK_MS = 20 * 1000;
const BACKEND_LOG_FLUSH_INTERVAL_MS = 120;
const BACKEND_LOG_MAX_BUFFER_BYTES = 128 * 1024;
function parseBackendTimeoutMs(app) {
const defaultTimeoutMs = app.isPackaged ? 0 : 20000;
const parsed = Number.parseInt(
process.env.ASTRBOT_BACKEND_TIMEOUT_MS || `${defaultTimeoutMs}`,
10,
);
if (Number.isFinite(parsed) && parsed >= 0) {
return parsed;
}
return defaultTimeoutMs;
}
class BackendManager {
constructor({ app, baseDir, log, shouldSkipStart }) {
this.app = app;
this.baseDir = baseDir;
this.log = typeof log === 'function' ? log : () => {};
this.shouldSkipStart =
typeof shouldSkipStart === 'function' ? shouldSkipStart : () => false;
this.backendUrl = normalizeUrl(
process.env.ASTRBOT_BACKEND_URL || 'http://127.0.0.1:6185/',
);
this.backendAutoStart = process.env.ASTRBOT_BACKEND_AUTO_START !== '0';
this.backendTimeoutMs = parseBackendTimeoutMs(app);
this.backendLogMaxBytes = parseLogMaxBytes(
process.env.ASTRBOT_BACKEND_LOG_MAX_MB,
);
this.backendLogBackupCount = parseLogBackupCount(
process.env.ASTRBOT_BACKEND_LOG_BACKUP_COUNT,
);
this.backendProcess = null;
this.backendConfig = null;
this.backendLogger = new BufferedRotatingLogger({
logPath: null,
maxBytes: this.backendLogMaxBytes,
backupCount: this.backendLogBackupCount,
flushIntervalMs: BACKEND_LOG_FLUSH_INTERVAL_MS,
maxBufferBytes: BACKEND_LOG_MAX_BUFFER_BYTES,
});
this.backendLastExitReason = null;
this.backendStartupFailureReason = null;
this.backendSpawning = false;
this.backendRestarting = false;
}
getBackendUrl() {
return this.backendUrl;
}
getBackendTimeoutMs() {
return this.backendTimeoutMs;
}
getRootDir() {
return (
process.env.ASTRBOT_ROOT ||
this.backendConfig?.rootDir ||
this.resolveBackendRoot()
);
}
getBackendLogPath() {
const rootDir = this.getRootDir();
if (!rootDir) {
return null;
}
return path.join(rootDir, 'logs', 'backend.log');
}
getStartupFailureReason() {
return this.backendStartupFailureReason;
}
isSpawning() {
return this.backendSpawning;
}
isRestarting() {
return this.backendRestarting;
}
resolveBackendRoot() {
if (!this.app.isPackaged) {
return null;
}
return path.join(os.homedir(), '.astrbot');
}
resolveBackendCwd() {
if (!this.app.isPackaged) {
return path.resolve(this.baseDir, '..');
}
return this.resolveBackendRoot();
}
resolveWebuiDir() {
if (process.env.ASTRBOT_WEBUI_DIR) {
return process.env.ASTRBOT_WEBUI_DIR;
}
if (!this.app.isPackaged) {
return null;
}
const candidate = path.join(process.resourcesPath, 'webui');
const indexPath = path.join(candidate, 'index.html');
return fs.existsSync(indexPath) ? candidate : null;
}
getPackagedBackendPath() {
if (!this.app.isPackaged) {
return null;
}
const filename =
process.platform === 'win32' ? 'astrbot-backend.exe' : 'astrbot-backend';
const candidate = path.join(process.resourcesPath, 'backend', filename);
return fs.existsSync(candidate) ? candidate : null;
}
buildDefaultBackendLaunch(webuiDir) {
if (this.app.isPackaged) {
const packagedBackend = this.getPackagedBackendPath();
if (!packagedBackend) {
return null;
}
const args = [];
if (webuiDir) {
args.push('--webui-dir', webuiDir);
}
return {
cmd: packagedBackend,
args,
shell: false,
};
}
const args = ['run', 'main.py'];
if (webuiDir) {
args.push('--webui-dir', webuiDir);
}
return {
cmd: 'uv',
args,
shell: process.platform === 'win32',
};
}
resolveBackendConfig() {
const webuiDir = this.resolveWebuiDir();
const customCmd = process.env.ASTRBOT_BACKEND_CMD;
const launch = customCmd
? {
cmd: customCmd,
args: [],
shell: true,
}
: this.buildDefaultBackendLaunch(webuiDir);
const cwd = process.env.ASTRBOT_BACKEND_CWD || this.resolveBackendCwd();
const rootDir = process.env.ASTRBOT_ROOT || this.resolveBackendRoot();
ensureDir(cwd);
if (rootDir) {
ensureDir(rootDir);
}
this.backendConfig = {
cmd: launch ? launch.cmd : null,
args: launch ? launch.args : [],
shell: launch ? launch.shell : true,
cwd,
webuiDir,
rootDir,
};
return this.backendConfig;
}
getBackendConfig() {
if (!this.backendConfig) {
return this.resolveBackendConfig();
}
return this.backendConfig;
}
getBackendPort() {
try {
const parsed = new URL(this.backendUrl);
if (parsed.port) {
const port = Number.parseInt(parsed.port, 10);
return Number.isFinite(port) ? port : null;
}
return parsed.protocol === 'https:' ? 443 : 80;
} catch {
return null;
}
}
canManageBackend() {
return Boolean(this.getBackendConfig().cmd);
}
async flushLogs() {
await this.backendLogger.flush();
}
async pingBackend(timeoutMs = 800) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
await fetch(this.backendUrl, {
signal: controller.signal,
redirect: 'manual',
});
return true;
} catch {
return false;
} finally {
clearTimeout(timeout);
}
}
getEffectiveWaitMs(maxWaitMs = 0) {
if (maxWaitMs > 0) {
return maxWaitMs;
}
if (this.app.isPackaged) {
return PACKAGED_BACKEND_TIMEOUT_FALLBACK_MS;
}
return 0;
}
async requestBackendJson(pathname, options = {}) {
const timeoutMs = options.timeoutMs || 2000;
const method = options.method || 'GET';
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
const requestUrl = new URL(pathname, this.backendUrl);
requestUrl.searchParams.set('_ts', `${Date.now()}`);
const authToken =
typeof options.authToken === 'string' && options.authToken
? options.authToken
: null;
try {
const response = await fetch(requestUrl.toString(), {
method,
signal: controller.signal,
redirect: 'manual',
headers: {
Accept: 'application/json',
...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
...(options.headers || {}),
},
});
if (!response.ok) {
return { ok: false, data: null };
}
const data = await response.json();
return { ok: true, data };
} catch {
return { ok: false, data: null };
} finally {
clearTimeout(timeout);
}
}
async getBackendStartTime() {
const result = await this.requestBackendJson('/api/stat/start-time', {
timeoutMs: 1800,
method: 'GET',
});
if (!result.ok || !result.data) {
return null;
}
const rawStartTime = result.data?.data?.start_time;
const numericStartTime = Number(rawStartTime);
return Number.isFinite(numericStartTime) ? numericStartTime : null;
}
async requestGracefulRestart(authToken = null) {
const result = await this.requestBackendJson('/api/stat/restart-core', {
timeoutMs: 2500,
method: 'POST',
authToken,
headers: {
'Content-Type': 'application/json',
},
});
return result.ok;
}
async waitForGracefulRestart(previousStartTime, maxWaitMs = 0) {
const effectiveMaxWaitMs = this.getEffectiveWaitMs(maxWaitMs);
const gracefulWaitMs =
effectiveMaxWaitMs > 0
? effectiveMaxWaitMs
: GRACEFUL_RESTART_WAIT_FALLBACK_MS;
const start = Date.now();
let sawBackendDown = false;
while (true) {
const reachable = await this.pingBackend(700);
if (!reachable) {
sawBackendDown = true;
} else {
const currentStartTime = await this.getBackendStartTime();
if (
previousStartTime !== null &&
currentStartTime !== null &&
currentStartTime !== previousStartTime
) {
return { ok: true, reason: null };
}
if (sawBackendDown && previousStartTime === null) {
return { ok: true, reason: null };
}
}
if (Date.now() - start >= gracefulWaitMs) {
return {
ok: false,
reason: `Timed out after ${gracefulWaitMs}ms waiting for graceful restart.`,
};
}
await delay(350);
}
}
async waitForBackend(maxWaitMs = 0, failOnProcessExit = false) {
const effectiveMaxWaitMs = this.getEffectiveWaitMs(maxWaitMs);
const start = Date.now();
while (true) {
if (await this.pingBackend()) {
return { ok: true, reason: null };
}
if (failOnProcessExit && !this.backendProcess) {
return {
ok: false,
reason:
this.backendLastExitReason ||
'Backend process exited before becoming reachable.',
};
}
if (effectiveMaxWaitMs > 0 && Date.now() - start >= effectiveMaxWaitMs) {
return {
ok: false,
reason: `Timed out after ${effectiveMaxWaitMs}ms waiting for backend startup.`,
};
}
await delay(600);
}
}
async startBackend() {
if (this.shouldSkipStart()) {
this.log('Skip backend start because app is quitting.');
return;
}
if (this.backendProcess) {
return;
}
const backendConfig = this.getBackendConfig();
if (!backendConfig.cmd) {
return;
}
this.backendLastExitReason = null;
const env = {
...process.env,
PYTHONUNBUFFERED: '1',
};
if (this.app.isPackaged) {
env.ASTRBOT_ELECTRON_CLIENT = '1';
const hasExplicitDashboardHost = Boolean(
process.env.DASHBOARD_HOST || process.env.ASTRBOT_DASHBOARD_HOST,
);
const hasExplicitDashboardPort = Boolean(
process.env.DASHBOARD_PORT || process.env.ASTRBOT_DASHBOARD_PORT,
);
if (!hasExplicitDashboardHost) {
env.DASHBOARD_HOST = '127.0.0.1';
}
if (!hasExplicitDashboardPort) {
env.DASHBOARD_PORT = '6185';
}
}
if (backendConfig.webuiDir) {
env.ASTRBOT_WEBUI_DIR = backendConfig.webuiDir;
}
let backendLogPath = null;
if (backendConfig.rootDir) {
env.ASTRBOT_ROOT = backendConfig.rootDir;
const logsDir = path.join(backendConfig.rootDir, 'logs');
ensureDir(logsDir);
backendLogPath = path.join(logsDir, 'backend.log');
}
await this.backendLogger.setLogPath(backendLogPath);
const usePipedLogging = Boolean(backendLogPath);
this.backendProcess = spawn(backendConfig.cmd, backendConfig.args || [], {
cwd: backendConfig.cwd,
env,
shell: backendConfig.shell,
stdio: usePipedLogging ? ['ignore', 'pipe', 'pipe'] : 'ignore',
windowsHide: true,
});
if (usePipedLogging) {
if (this.backendProcess.stdout) {
this.backendProcess.stdout.on('data', (chunk) => {
this.backendLogger.log(chunk);
});
}
if (this.backendProcess.stderr) {
this.backendProcess.stderr.on('data', (chunk) => {
this.backendLogger.log(chunk);
});
}
}
if (usePipedLogging) {
const launchLine = [backendConfig.cmd, ...(backendConfig.args || [])]
.map((item) => JSON.stringify(item))
.join(' ');
this.backendLogger.log(
`[${formatLogTimestamp()}] [Electron] Start backend ${launchLine}\n`,
);
}
this.backendProcess.on('error', (error) => {
this.backendLastExitReason =
error instanceof Error ? error.message : String(error);
this.backendLogger.log(
`[${formatLogTimestamp()}] [Electron] Backend spawn error: ${
error instanceof Error ? error.message : String(error)
}\n`,
);
void this.backendLogger.flush();
this.backendProcess = null;
});
this.backendProcess.on('exit', (code, signal) => {
this.backendLastExitReason = `Backend process exited (code=${code ?? 'null'}, signal=${signal ?? 'null'}).`;
void this.backendLogger.flush();
this.backendProcess = null;
});
}
async startBackendAndWait(maxWaitMs = this.backendTimeoutMs) {
if (!this.canManageBackend()) {
return {
ok: false,
reason: 'Backend command is not configured.',
};
}
this.backendSpawning = true;
try {
await this.startBackend();
return await this.waitForBackend(maxWaitMs, true);
} finally {
this.backendSpawning = false;
}
}
async stopManagedBackend() {
if (!this.backendProcess) {
return;
}
const processToStop = this.backendProcess;
const pid = processToStop.pid;
this.backendProcess = null;
this.log(`Stop backend requested pid=${pid ?? 'unknown'}`);
if (process.platform === 'win32' && pid) {
try {
// Synchronous taskkill is acceptable here because stop/restart is
// already a control-path operation and not latency-sensitive.
const result = spawnSync('taskkill', ['/pid', `${pid}`, '/t', '/f'], {
stdio: 'ignore',
windowsHide: true,
});
if (result.status !== 0) {
this.log(
`taskkill failed pid=${pid} status=${result.status} signal=${result.signal ?? 'null'}`,
);
} else {
this.log(`taskkill completed pid=${pid}`);
}
} catch (error) {
this.log(
`taskkill threw for pid=${pid}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
await waitForProcessExit(processToStop, 5000);
} else {
if (!processToStop.killed) {
try {
processToStop.kill('SIGTERM');
} catch (error) {
this.log(
`SIGTERM failed for pid=${pid ?? 'unknown'}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
const exitResult = await waitForProcessExit(processToStop, 5000);
if (exitResult === 'timeout' && !processToStop.killed) {
try {
processToStop.kill('SIGKILL');
} catch {}
await waitForProcessExit(processToStop, 1500);
}
}
await this.backendLogger.flush();
}
findListeningPidsOnWindows(port) {
// Synchronous netstat parsing is acceptable here because this helper is
// used only during shutdown/restart cleanup paths.
const result = spawnSync('netstat', ['-ano', '-p', 'tcp'], {
stdio: ['ignore', 'pipe', 'ignore'],
encoding: 'utf8',
windowsHide: true,
});
if (result.status !== 0 || !result.stdout) {
return [];
}
const pids = new Set();
const lines = result.stdout.split(/\r?\n/);
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || !trimmed.toUpperCase().startsWith('TCP')) {
continue;
}
const parts = trimmed.split(/\s+/);
if (parts.length < 5) {
continue;
}
const localAddress = parts[1] || '';
const state = (parts[3] || '').toUpperCase();
const pid = parts[parts.length - 1];
if (!/^\d+$/.test(pid)) {
continue;
}
if (state !== 'LISTENING') {
continue;
}
const cleanedLocalAddress = localAddress.replace(/\]$/, '');
const segments = cleanedLocalAddress.split(':');
const portStr = segments[segments.length - 1];
const portNum = Number(portStr);
if (Number.isInteger(portNum) && portNum === Number(port)) {
pids.add(pid);
}
}
return Array.from(pids);
}
getWindowsProcessInfo(pid) {
const result = spawnSync(
'tasklist',
['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'],
{
stdio: ['ignore', 'pipe', 'ignore'],
encoding: 'utf8',
windowsHide: true,
},
);
if (result.status !== 0 || !result.stdout) {
return null;
}
const firstLine = result.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0);
if (!firstLine || firstLine.startsWith('INFO:')) {
return null;
}
const fields = firstLine
.replace(/^"/, '')
.replace(/"$/, '')
.split('","');
const imageName = fields[0] || '';
const parsedPid = Number.parseInt(fields[1] || '', 10);
if (!imageName || !Number.isInteger(parsedPid) || parsedPid !== Number(pid)) {
return null;
}
return { imageName, pid: parsedPid };
}
async stopUnmanagedBackendByPort() {
if (!this.app.isPackaged || process.platform !== 'win32') {
return false;
}
const port = this.getBackendPort();
if (!port) {
return false;
}
const pids = this.findListeningPidsOnWindows(port);
if (!pids.length) {
return false;
}
this.log(
`Attempting unmanaged backend cleanup by port=${port} pids=${pids.join(',')}`,
);
const expectedImageName = (
path.basename(this.getPackagedBackendPath() || '') || 'astrbot-backend.exe'
).toLowerCase();
for (const pid of pids) {
const processInfo = this.getWindowsProcessInfo(pid);
if (!processInfo) {
this.log(`Skip unmanaged cleanup for pid=${pid}: unable to resolve process info.`);
continue;
}
const actualImageName = processInfo.imageName.toLowerCase();
if (actualImageName !== expectedImageName) {
this.log(
`Skip unmanaged cleanup for pid=${pid}: unexpected process image ${processInfo.imageName}.`,
);
continue;
}
try {
// Synchronous taskkill is acceptable here because unmanaged cleanup
// is performed only during shutdown/restart control flows.
spawnSync('taskkill', ['/pid', `${pid}`, '/t', '/f'], {
stdio: 'ignore',
windowsHide: true,
});
} catch {}
}
await delay(500);
return !(await this.pingBackend(1200));
}
async stopAnyBackend() {
if (this.backendProcess) {
await this.stopManagedBackend();
const running = await this.pingBackend();
if (!running) {
return { ok: true, reason: null };
}
} else {
const running = await this.pingBackend();
if (!running) {
return { ok: true, reason: null };
}
}
const cleaned = await this.stopUnmanagedBackendByPort();
if (cleaned) {
return { ok: true, reason: null };
}
return {
ok: false,
reason: 'Backend is running but not managed by Electron.',
};
}
async ensureBackend() {
this.backendStartupFailureReason = null;
const running = await this.pingBackend();
if (running) {
return true;
}
if (!this.backendAutoStart || !this.canManageBackend()) {
this.backendStartupFailureReason =
'Backend auto-start is disabled or backend command is not configured.';
return false;
}
const waitResult = await this.startBackendAndWait(this.backendTimeoutMs);
if (!waitResult.ok) {
this.backendStartupFailureReason = waitResult.reason;
return false;
}
return true;
}
async getState() {
return {
running: await this.pingBackend(),
spawning: this.backendSpawning,
restarting: this.backendRestarting,
canManage: this.canManageBackend(),
};
}
async restartBackend(authToken = null) {
if (!this.canManageBackend()) {
return {
ok: false,
reason: 'Backend command is not configured.',
};
}
if (this.backendSpawning || this.backendRestarting) {
return {
ok: false,
reason: 'Backend action already in progress.',
};
}
this.backendRestarting = true;
try {
const backendRunning = await this.pingBackend(900);
if (backendRunning) {
const previousStartTime = await this.getBackendStartTime();
const gracefulRequested = await this.requestGracefulRestart(authToken);
if (gracefulRequested) {
const gracefulResult = await this.waitForGracefulRestart(
previousStartTime,
this.backendTimeoutMs,
);
if (gracefulResult.ok) {
return {
ok: true,
reason: null,
};
}
this.log(
`Graceful restart did not complete: ${gracefulResult.reason || 'unknown reason'}`,
);
} else {
this.log(
'Graceful restart request failed; falling back to managed restart.',
);
}
}
await this.stopManagedBackend();
const startResult = await this.startBackendAndWait(this.backendTimeoutMs);
if (!startResult.ok) {
return {
ok: false,
reason: startResult.reason || 'Failed to restart backend.',
};
}
return {
ok: true,
reason: null,
};
} catch (error) {
return {
ok: false,
reason: error instanceof Error ? error.message : String(error),
};
} finally {
this.backendRestarting = false;
}
}
async stopBackendForIpc() {
if (!this.canManageBackend()) {
return {
ok: false,
reason: 'Backend command is not configured.',
};
}
if (this.backendSpawning || this.backendRestarting) {
return {
ok: false,
reason: 'Backend action already in progress.',
};
}
try {
return await this.stopAnyBackend();
} catch (error) {
return {
ok: false,
reason: error instanceof Error ? error.message : String(error),
};
}
}
}
module.exports = {
BackendManager,
};
-162
View File
@@ -1,162 +0,0 @@
'use strict';
const { RotatingLogWriter } = require('./rotating-log-writer');
const { parseEnvInt } = require('./common');
const DEFAULT_FLUSH_INTERVAL_MS = 120;
const DEFAULT_MAX_BUFFER_BYTES = 128 * 1024;
const MIN_FLUSH_INTERVAL_MS = 10;
const MIN_MAX_BUFFER_BYTES = 4 * 1024;
const MAX_MAX_BUFFER_BYTES = 16 * 1024 * 1024;
function clampIntOption(raw, { defaultValue, min, max }) {
const value = parseEnvInt(raw, defaultValue);
return Math.min(Math.max(value, min), max);
}
class BufferedRotatingLogger {
constructor({
logPath = null,
maxBytes,
backupCount,
flushIntervalMs,
maxBufferBytes,
label = 'buffered-log',
}) {
this.logPath = logPath || null;
this.flushIntervalMs = clampIntOption(flushIntervalMs, {
defaultValue: DEFAULT_FLUSH_INTERVAL_MS,
min: MIN_FLUSH_INTERVAL_MS,
max: 60 * 1000,
});
this.maxBufferBytes = clampIntOption(maxBufferBytes, {
defaultValue: DEFAULT_MAX_BUFFER_BYTES,
min: MIN_MAX_BUFFER_BYTES,
max: MAX_MAX_BUFFER_BYTES,
});
this.buffer = [];
this.bufferBytes = 0;
this.flushTimer = null;
this.pathSwitch = Promise.resolve();
this.writer = new RotatingLogWriter({
logPath: this.logPath,
maxBytes,
backupCount,
label,
});
}
setLogPath(logPath) {
const nextLogPath = logPath || null;
this.pathSwitch = this.pathSwitch.then(async () => {
if (nextLogPath === this.logPath) {
await this.flush();
return;
}
const previousLogPath = this.logPath;
if (previousLogPath) {
await this.flush();
}
this.logPath = null;
await this.writer.setLogPath(nextLogPath);
this.logPath = nextLogPath;
await this.flush();
});
return this.pathSwitch;
}
log(payload) {
if (payload === undefined || payload === null) {
return;
}
const chunk = Buffer.isBuffer(payload)
? payload
: Buffer.from(String(payload), 'utf8');
if (!chunk.length) {
return;
}
if (!this.logPath) {
const boundedChunk = this.clipChunkToBufferLimit(chunk);
this.dropOldestUntilWithinLimit(boundedChunk.length);
this.buffer.push(boundedChunk);
this.bufferBytes += boundedChunk.length;
return;
}
this.buffer.push(chunk);
this.bufferBytes += chunk.length;
if (this.bufferBytes >= this.maxBufferBytes) {
void this.flush();
return;
}
this.scheduleFlush();
}
flush() {
this.clearFlushTimer();
if (!this.buffer.length) {
return this.writer.flush();
}
if (!this.logPath) {
// Path is switching or temporarily unavailable; keep buffered data.
this.dropOldestUntilWithinLimit(0);
return this.writer.flush();
}
const chunks = this.buffer;
this.buffer = [];
this.bufferBytes = 0;
const payload = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks);
this.writer.append(payload);
return this.writer.flush();
}
dropOldestUntilWithinLimit(incomingBytes = 0) {
while (
this.buffer.length &&
this.bufferBytes + Math.max(0, incomingBytes) > this.maxBufferBytes
) {
const removed = this.buffer.shift();
if (removed) {
this.bufferBytes -= removed.length;
}
}
if (this.bufferBytes < 0) {
this.bufferBytes = 0;
}
}
clipChunkToBufferLimit(chunk) {
if (chunk.length <= this.maxBufferBytes) {
return chunk;
}
return chunk.subarray(chunk.length - this.maxBufferBytes);
}
scheduleFlush() {
if (this.flushTimer !== null) {
return;
}
this.flushTimer = setTimeout(() => {
this.flushTimer = null;
void this.flush();
}, this.flushIntervalMs);
this.flushTimer.unref?.();
}
clearFlushTimer() {
if (this.flushTimer === null) {
return;
}
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
}
module.exports = {
BufferedRotatingLogger,
};
-115
View File
@@ -1,115 +0,0 @@
'use strict';
const fs = require('fs');
const LOG_ROTATION_DEFAULT_MAX_MB = 20;
const LOG_ROTATION_DEFAULT_BACKUP_COUNT = 3;
function normalizeUrl(value) {
try {
const url = new URL(value);
if (!url.pathname.endsWith('/')) {
url.pathname += '/';
}
return url.toString();
} catch {
return 'http://127.0.0.1:6185/';
}
}
function ensureDir(value) {
if (!value) {
return;
}
if (fs.existsSync(value)) {
return;
}
fs.mkdirSync(value, { recursive: true });
}
function parseEnvInt(raw, defaultValue) {
const parsed = Number.parseInt(`${raw ?? ''}`, 10);
return Number.isFinite(parsed) ? parsed : defaultValue;
}
function isLogRotationDebugEnabled() {
return (
process.env.ASTRBOT_LOG_ROTATION_DEBUG === '1' ||
process.env.NODE_ENV === 'development'
);
}
function parseLogMaxBytes(envValue) {
const mb = parseEnvInt(envValue, LOG_ROTATION_DEFAULT_MAX_MB);
const maxMb = mb > 0 ? mb : LOG_ROTATION_DEFAULT_MAX_MB;
return maxMb * 1024 * 1024;
}
function parseLogBackupCount(envValue) {
const count = parseEnvInt(envValue, LOG_ROTATION_DEFAULT_BACKUP_COUNT);
return count >= 0 ? count : LOG_ROTATION_DEFAULT_BACKUP_COUNT;
}
function isIgnorableFsError(error) {
return Boolean(error && typeof error === 'object' && error.code === 'ENOENT');
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function waitForProcessExit(child, timeoutMs = 5000) {
if (!child) {
return Promise.resolve('missing');
}
if (child.exitCode !== null || child.signalCode !== null) {
return Promise.resolve('exited');
}
return new Promise((resolve) => {
let settled = false;
const finish = (reason) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
resolve(reason);
};
const timeout = setTimeout(() => finish('timeout'), timeoutMs);
child.once('exit', () => finish('exit'));
child.once('error', () => finish('error'));
});
}
function formatLogTimestamp(date = new Date()) {
const year = date.getFullYear();
const month = `${date.getMonth() + 1}`.padStart(2, '0');
const day = `${date.getDate()}`.padStart(2, '0');
const hour = `${date.getHours()}`.padStart(2, '0');
const minute = `${date.getMinutes()}`.padStart(2, '0');
const second = `${date.getSeconds()}`.padStart(2, '0');
const millisecond = `${date.getMilliseconds()}`.padStart(3, '0');
const offsetMinutes = -date.getTimezoneOffset();
const offsetSign = offsetMinutes >= 0 ? '+' : '-';
const absOffsetMinutes = Math.abs(offsetMinutes);
const offsetHour = `${Math.floor(absOffsetMinutes / 60)}`.padStart(2, '0');
const offsetMinute = `${absOffsetMinutes % 60}`.padStart(2, '0');
return `${year}-${month}-${day} ${hour}:${minute}:${second}.${millisecond} ${offsetSign}${offsetHour}${offsetMinute}`;
}
module.exports = {
LOG_ROTATION_DEFAULT_BACKUP_COUNT,
LOG_ROTATION_DEFAULT_MAX_MB,
delay,
ensureDir,
formatLogTimestamp,
isIgnorableFsError,
isLogRotationDebugEnabled,
normalizeUrl,
parseEnvInt,
parseLogBackupCount,
parseLogMaxBytes,
waitForProcessExit,
};
-30
View File
@@ -1,30 +0,0 @@
'use strict';
const { delay } = require('./common');
async function loadDashboard(mainWindow, backendUrl, maxWaitMs = 20000) {
if (!mainWindow) {
return false;
}
const loadUrl = new URL(backendUrl);
loadUrl.searchParams.set('_electron_ts', `${Date.now()}`);
const start = Date.now();
let lastError = null;
while (maxWaitMs <= 0 || Date.now() - start < maxWaitMs) {
try {
await mainWindow.loadURL(loadUrl.toString());
return true;
} catch (error) {
lastError = error;
await delay(600);
}
}
if (lastError) {
throw lastError;
}
throw new Error(`Timed out loading ${backendUrl}`);
}
module.exports = {
loadDashboard,
};
-53
View File
@@ -1,53 +0,0 @@
'use strict';
const path = require('path');
const { RotatingLogWriter } = require('./rotating-log-writer');
const {
formatLogTimestamp,
parseLogBackupCount,
parseLogMaxBytes,
} = require('./common');
function createElectronLogger({ app, getRootDir }) {
const electronLogMaxBytes = parseLogMaxBytes(
process.env.ASTRBOT_ELECTRON_LOG_MAX_MB,
);
const electronLogBackupCount = parseLogBackupCount(
process.env.ASTRBOT_ELECTRON_LOG_BACKUP_COUNT,
);
const writer = new RotatingLogWriter({
logPath: null,
maxBytes: electronLogMaxBytes,
backupCount: electronLogBackupCount,
label: 'electron-log',
});
function getElectronLogPath() {
const rootDir =
process.env.ASTRBOT_ROOT ||
(typeof getRootDir === 'function' ? getRootDir() : null) ||
app.getPath('userData');
return path.join(rootDir, 'logs', 'electron.log');
}
function logElectron(message) {
const logPath = getElectronLogPath();
const line = `[${formatLogTimestamp()}] ${message}\n`;
void writer.setLogPath(logPath);
void writer.append(line);
}
async function flushElectron() {
await writer.flush();
}
return {
getElectronLogPath,
logElectron,
flushElectron,
};
}
module.exports = {
createElectronLogger,
};
-174
View File
@@ -1,174 +0,0 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { delay, ensureDir } = require('./common');
const LOCALE_STORAGE_KEY = 'astrbot-locale';
const SUPPORTED_STARTUP_LOCALES = new Set(['zh-CN', 'en-US']);
function normalizeLocale(value) {
if (!value) {
return null;
}
const raw = String(value).trim();
if (!raw) {
return null;
}
if (SUPPORTED_STARTUP_LOCALES.has(raw)) {
return raw;
}
const lower = raw.toLowerCase();
if (lower.startsWith('zh')) {
return 'zh-CN';
}
if (lower.startsWith('en')) {
return 'en-US';
}
return null;
}
function getStartupTexts(locale) {
if (locale === 'zh-CN') {
return {
title: 'AstrBot 正在启动',
message: '界面很快就会加载完成。',
};
}
return {
title: 'AstrBot is starting',
message: 'The dashboard will be ready in a moment.',
};
}
function getShellTexts(locale) {
if (locale === 'zh-CN') {
return {
trayHide: '隐藏 AstrBot',
trayShow: '显示 AstrBot',
trayReload: '重新加载',
trayRestartBackend: '重启后端',
trayQuit: '退出',
startupFailTitle: 'AstrBot 启动失败',
startupFailMessage: 'AstrBot 后端不可达。',
startupFailReasonPrefix: '原因',
startupFailAction:
'请先启动 http://127.0.0.1:6185 的后端服务,然后重新打开 AstrBot。',
startupFailLogPrefix: '后端日志',
dashboardFailTitle: 'AstrBot 加载失败',
dashboardFailMessage: '无法加载 AstrBot 控制台页面。',
};
}
return {
trayHide: 'Hide AstrBot',
trayShow: 'Show AstrBot',
trayReload: 'Reload',
trayRestartBackend: 'Restart Backend',
trayQuit: 'Quit',
startupFailTitle: 'AstrBot startup failed',
startupFailMessage: 'AstrBot backend is not reachable.',
startupFailReasonPrefix: 'Reason',
startupFailAction:
'Please start the backend at http://127.0.0.1:6185 and relaunch AstrBot.',
startupFailLogPrefix: 'Backend log',
dashboardFailTitle: 'Failed to load AstrBot',
dashboardFailMessage: 'Unable to load the AstrBot dashboard.',
};
}
function createLocaleService({ app, getRootDir }) {
function resolveStateRoot() {
const callbackRoot = (() => {
try {
return getRootDir ? getRootDir() : null;
} catch {
return null;
}
})();
return process.env.ASTRBOT_ROOT || callbackRoot || app.getPath('userData');
}
function getDesktopStatePath() {
return path.join(resolveStateRoot(), 'data', 'desktop_state.json');
}
function readCachedLocale() {
const statePath = getDesktopStatePath();
try {
const raw = fs.readFileSync(statePath, 'utf8');
const parsed = JSON.parse(raw);
return normalizeLocale(parsed?.locale);
} catch {
return null;
}
}
function writeCachedLocale(locale) {
const normalized = normalizeLocale(locale);
if (!normalized) {
return;
}
const statePath = getDesktopStatePath();
ensureDir(path.dirname(statePath));
try {
fs.writeFileSync(
statePath,
`${JSON.stringify({ locale: normalized }, null, 2)}\n`,
'utf8',
);
} catch {}
}
function resolveStartupLocale() {
const cached = readCachedLocale();
if (cached) {
return cached;
}
return normalizeLocale(app.getLocale()) || 'zh-CN';
}
async function persistLocaleFromDashboard(
mainWindow,
backendUrl,
timeoutMs = 1200,
) {
if (!mainWindow || mainWindow.isDestroyed()) {
return;
}
const currentUrl = mainWindow.webContents.getURL();
if (!currentUrl || !currentUrl.startsWith(backendUrl)) {
return;
}
try {
const localeRaw = await Promise.race([
mainWindow.webContents.executeJavaScript(
`(() => {
try {
return window.localStorage.getItem('${LOCALE_STORAGE_KEY}') || '';
} catch {
return '';
}
})();`,
true,
),
delay(timeoutMs).then(() => null),
]);
const locale = normalizeLocale(localeRaw);
if (locale) {
writeCachedLocale(locale);
}
} catch {}
}
return {
getShellTexts,
getStartupTexts,
persistLocaleFromDashboard,
resolveStartupLocale,
};
}
module.exports = {
createLocaleService,
normalizeLocale,
};
-178
View File
@@ -1,178 +0,0 @@
'use strict';
const fs = require('fs/promises');
const path = require('path');
const { isIgnorableFsError, isLogRotationDebugEnabled } = require('./common');
class RotatingLogWriter {
constructor({ logPath = null, maxBytes = 0, backupCount = 0, label = 'log' }) {
this.logPath = logPath || null;
this.maxBytes = Number.isFinite(maxBytes) && maxBytes > 0 ? maxBytes : 0;
this.backupCount = Number.isFinite(backupCount) && backupCount >= 0 ? backupCount : 0;
this.label = label;
this.cachedSize = null;
this.dirReadyForPath = null;
this.queue = Promise.resolve();
}
setLogPath(logPath) {
const nextPath = logPath || null;
if (nextPath === this.logPath) {
return this.queue;
}
return this.enqueue(async () => {
this.logPath = nextPath;
this.cachedSize = null;
this.dirReadyForPath = null;
});
}
append(payload) {
if (payload === undefined || payload === null) {
return this.queue;
}
const content = Buffer.isBuffer(payload)
? payload
: Buffer.from(String(payload), 'utf8');
if (!content.length) {
return this.queue;
}
return this.enqueue(async () => {
if (!this.logPath) {
return;
}
await this.ensureDirReady();
await this.ensureSizeLoaded();
await this.rotateIfNeeded(content.length);
await fs.appendFile(this.logPath, content);
if (!Number.isFinite(this.cachedSize)) {
this.cachedSize = await this.readSize();
} else {
this.cachedSize += content.length;
}
});
}
flush() {
return this.queue;
}
enqueue(task) {
const run = async () => {
try {
await task();
} catch (error) {
this.reportError('write', this.logPath || 'unknown', error);
}
};
this.queue = this.queue.then(run, run);
return this.queue;
}
async ensureSizeLoaded() {
if (Number.isFinite(this.cachedSize)) {
return;
}
this.cachedSize = await this.readSize();
}
async ensureDirReady() {
if (!this.logPath) {
return;
}
if (this.dirReadyForPath === this.logPath) {
return;
}
const dirPath = path.dirname(this.logPath);
try {
await fs.mkdir(dirPath, { recursive: true });
this.dirReadyForPath = this.logPath;
} catch (error) {
this.reportError('mkdir', dirPath, error);
}
}
async readSize() {
if (!this.logPath) {
return 0;
}
try {
const stat = await fs.stat(this.logPath);
return stat.size;
} catch (error) {
if (isIgnorableFsError(error)) {
return 0;
}
this.reportError('stat', this.logPath, error);
return 0;
}
}
async rotateIfNeeded(incomingBytes) {
if (!this.logPath || this.maxBytes <= 0) {
return;
}
const currentSize = Number.isFinite(this.cachedSize) ? this.cachedSize : 0;
if (currentSize + Math.max(0, incomingBytes) <= this.maxBytes) {
return;
}
if (this.backupCount <= 0) {
try {
await fs.truncate(this.logPath, 0);
} catch (error) {
if (!isIgnorableFsError(error)) {
this.reportError('truncate', this.logPath, error);
}
}
this.cachedSize = await this.readSize();
return;
}
const oldestPath = `${this.logPath}.${this.backupCount}`;
try {
await fs.unlink(oldestPath);
} catch (error) {
if (!isIgnorableFsError(error)) {
this.reportError('unlink', oldestPath, error);
}
}
for (let index = this.backupCount - 1; index >= 1; index -= 1) {
const sourcePath = `${this.logPath}.${index}`;
const targetPath = `${this.logPath}.${index + 1}`;
try {
await fs.rename(sourcePath, targetPath);
} catch (error) {
if (!isIgnorableFsError(error)) {
this.reportError('rename', `${sourcePath} -> ${targetPath}`, error);
}
}
}
try {
await fs.rename(this.logPath, `${this.logPath}.1`);
} catch (error) {
if (!isIgnorableFsError(error)) {
this.reportError('rename', `${this.logPath} -> ${this.logPath}.1`, error);
}
}
this.cachedSize = await this.readSize();
}
reportError(action, targetPath, error) {
if (!isLogRotationDebugEnabled()) {
return;
}
const details = error instanceof Error ? error.message : String(error);
console.error(
`[astrbot][${this.label}] ${action} failed for ${targetPath}: ${details}`,
);
}
}
module.exports = {
RotatingLogWriter,
};
-116
View File
@@ -1,116 +0,0 @@
'use strict';
const fs = require('fs');
async function loadStartupScreen(mainWindow, { getAssetPath, startupTexts }) {
if (!mainWindow) {
return false;
}
let iconUrl = '';
try {
const iconBuffer = fs.readFileSync(getAssetPath('icon-no-shadow.svg'));
iconUrl = `data:image/svg+xml;base64,${iconBuffer.toString('base64')}`;
} catch {}
const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AstrBot</title>
<style>
:root {
color-scheme: light dark;
--primary: #3c96ca;
--bg: #f9fafc;
--surface: #ffffff;
--text: #1b1c1d;
--muted: #556170;
--border: #eeeeee;
}
body {
margin: 0;
min-height: 100vh;
font-family: "Poppins", "Segoe UI", -apple-system, BlinkMacSystemFont, sans-serif;
display: grid;
place-items: center;
background: var(--bg);
color: var(--text);
transition: background-color 0.2s ease, color 0.2s ease;
}
.card {
text-align: center;
padding: 28px 30px 24px;
border-radius: 14px;
background: var(--surface);
border: 1px solid var(--border);
width: min(360px, calc(100vw - 48px));
}
.logo {
width: 64px;
height: 64px;
display: block;
margin: 0 auto 12px;
}
.spinner {
width: 32px;
height: 32px;
margin: 0 auto 14px;
border: 3px solid rgba(60, 150, 202, 0.22);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.9s linear infinite;
}
h1 {
margin: 0 0 10px;
font-size: 19px;
font-weight: 700;
letter-spacing: 0.01em;
}
p {
margin: 0;
line-height: 1.55;
color: var(--muted);
font-size: 14px;
}
@media (prefers-color-scheme: dark) {
:root {
--primary: #1677ff;
--bg: #1d1d1d;
--surface: #1f1f1f;
--text: #ffffff;
--muted: #c8c8cc;
--border: #333333;
}
.card {
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.45);
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<div class="card">
${
iconUrl
? `<img class="logo" src="${iconUrl}" alt="AstrBot logo" />`
: '<div class="logo" aria-hidden="true"></div>'
}
<div class="spinner" aria-hidden="true"></div>
<h1>${startupTexts.title}</h1>
<p>${startupTexts.message}</p>
</div>
</body>
</html>`;
const startupUrl = `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
await mainWindow.loadURL(startupUrl);
return true;
}
module.exports = {
loadStartupScreen,
};
-420
View File
@@ -1,420 +0,0 @@
'use strict';
const fs = require('fs');
const path = require('path');
const {
app,
BrowserWindow,
Menu,
Tray,
nativeImage,
shell,
dialog,
ipcMain,
} = require('electron');
const { BackendManager } = require('./lib/backend-manager');
const { loadDashboard } = require('./lib/dashboard-loader');
const { createElectronLogger } = require('./lib/electron-logger');
const { createLocaleService } = require('./lib/locale-service');
const { loadStartupScreen } = require('./lib/startup-screen');
const isMac = process.platform === 'darwin';
const dashboardTimeoutMsParsed = Number.parseInt(
process.env.ASTRBOT_DASHBOARD_TIMEOUT_MS || '20000',
10,
);
const dashboardTimeoutMs = Number.isFinite(dashboardTimeoutMsParsed)
? dashboardTimeoutMsParsed
: 20000;
let mainWindow = null;
let tray = null;
let isQuitting = false;
let quitInProgress = false;
let backendManager = null;
app.commandLine.appendSwitch('disable-http-cache');
const { logElectron, flushElectron } = createElectronLogger({
app,
getRootDir: () => (backendManager ? backendManager.getRootDir() : null),
});
backendManager = new BackendManager({
app,
baseDir: __dirname,
log: logElectron,
shouldSkipStart: () => isQuitting || quitInProgress,
});
const localeService = createLocaleService({
app,
getRootDir: () => backendManager.getRootDir(),
});
function getAssetPath(filename) {
if (app.isPackaged) {
const packaged = path.join(process.resourcesPath, 'assets', filename);
if (fs.existsSync(packaged)) {
return packaged;
}
}
return path.join(__dirname, 'assets', filename);
}
function loadImageSafe(imagePath) {
try {
const image = nativeImage.createFromPath(imagePath);
if (!image.isEmpty()) {
return image;
}
} catch {}
return nativeImage.createEmpty();
}
function showWindow() {
if (!mainWindow) {
return;
}
mainWindow.show();
mainWindow.focus();
updateTrayMenu();
}
function toggleWindow() {
if (!mainWindow) {
return;
}
if (mainWindow.isVisible()) {
mainWindow.hide();
} else {
mainWindow.show();
mainWindow.focus();
}
updateTrayMenu();
}
function updateTrayMenu() {
if (!tray || !mainWindow) {
return;
}
const shellTexts = localeService.getShellTexts(
localeService.resolveStartupLocale(),
);
const isVisible = mainWindow.isVisible();
const contextMenu = Menu.buildFromTemplate([
{
label: isVisible ? shellTexts.trayHide : shellTexts.trayShow,
click: () => toggleWindow(),
},
{
label: shellTexts.trayReload,
click: () => {
if (mainWindow) {
mainWindow.reload();
}
},
},
{
label: shellTexts.trayRestartBackend,
click: async () => {
if (!backendManager) {
return;
}
if (mainWindow && !mainWindow.isDestroyed()) {
showWindow();
const currentUrl = mainWindow.webContents.getURL();
if (currentUrl.startsWith(backendManager.getBackendUrl())) {
mainWindow.webContents.send('astrbot-desktop:tray-restart-backend');
return;
}
}
const result = await backendManager.restartBackend();
if (!result.ok) {
logElectron(
`Tray restart backend fallback failed: ${result.reason || 'unknown reason'}`,
);
}
},
},
{ type: 'separator' },
{
label: shellTexts.trayQuit,
click: () => app.quit(),
},
]);
tray.setContextMenu(contextMenu);
}
function createTray() {
const traySize = isMac ? 18 : 16;
const trayPath = getAssetPath('tray.png');
let trayImage = loadImageSafe(trayPath);
if (trayImage.isEmpty()) {
trayImage = loadImageSafe(getAssetPath('icon.png'));
}
if (!trayImage.isEmpty()) {
trayImage = trayImage.resize({ width: traySize, height: traySize });
if (isMac) {
trayImage.setTemplateImage(true);
}
tray = new Tray(trayImage);
} else {
tray = new Tray(nativeImage.createEmpty());
}
tray.setToolTip('AstrBot');
tray.on('click', () => toggleWindow());
updateTrayMenu();
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 980,
minHeight: 680,
show: false,
backgroundColor: '#f9fafc',
autoHideMenuBar: !isMac,
icon: getAssetPath('icon.png'),
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
preload: path.join(__dirname, 'preload.js'),
...(isMac
? {
defaultFontFamily: {
standard: 'PingFang SC',
sansSerif: 'PingFang SC',
serif: 'Songti SC',
monospace: 'SF Mono',
},
}
: {}),
},
});
mainWindow.on('close', (event) => {
if (isQuitting) {
return;
}
event.preventDefault();
mainWindow.hide();
});
mainWindow.on('minimize', (event) => {
event.preventDefault();
mainWindow.hide();
});
mainWindow.on('show', () => updateTrayMenu());
mainWindow.on('hide', () => updateTrayMenu());
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});
mainWindow.webContents.on(
'did-fail-load',
(_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
if (!isMainFrame) {
return;
}
logElectron(
`did-fail-load main-frame code=${errorCode} desc=${errorDescription} url=${validatedURL}`,
);
},
);
mainWindow.webContents.on('did-finish-load', () => {
const currentUrl = mainWindow.webContents.getURL();
logElectron(`did-finish-load url=${currentUrl}`);
if (currentUrl.startsWith(backendManager.getBackendUrl())) {
void localeService.persistLocaleFromDashboard(
mainWindow,
backendManager.getBackendUrl(),
);
}
});
mainWindow.webContents.on('render-process-gone', (_event, details) => {
logElectron(
`render-process-gone reason=${details.reason} exitCode=${details.exitCode}`,
);
});
mainWindow.webContents.on(
'console-message',
(_event, level, message, line, sourceId) => {
if (level >= 2) {
logElectron(
`renderer-console level=${level} source=${sourceId}:${line} message=${message}`,
);
}
},
);
return mainWindow;
}
function registerIpcHandlers() {
ipcMain.handle('astrbot-desktop:is-electron-runtime', async () => true);
ipcMain.handle('astrbot-desktop:get-backend-state', async () => {
return backendManager.getState();
});
ipcMain.handle('astrbot-desktop:restart-backend', async (_event, authToken) => {
return backendManager.restartBackend(authToken);
});
ipcMain.handle('astrbot-desktop:stop-backend', async () => {
return backendManager.stopBackendForIpc();
});
}
async function startDesktopFlow() {
createWindow();
createTray();
try {
const startupTexts = localeService.getStartupTexts(
localeService.resolveStartupLocale(),
);
await loadStartupScreen(mainWindow, {
getAssetPath,
startupTexts,
});
} catch (error) {
logElectron(
`failed to load startup screen: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
showWindow();
const ready = await backendManager.ensureBackend();
if (isQuitting) {
return;
}
if (!ready) {
const shellTexts = localeService.getShellTexts(
localeService.resolveStartupLocale(),
);
const backendLogPath = backendManager.getBackendLogPath();
const detailLines = [];
const startupFailureReason = backendManager.getStartupFailureReason();
if (startupFailureReason) {
detailLines.push(
`${shellTexts.startupFailReasonPrefix}: ${startupFailureReason}`,
);
}
detailLines.push(shellTexts.startupFailAction);
if (backendLogPath) {
detailLines.push(`${shellTexts.startupFailLogPrefix}: ${backendLogPath}`);
}
await dialog.showMessageBox({
type: 'error',
title: shellTexts.startupFailTitle,
message: shellTexts.startupFailMessage,
detail: detailLines.join('\n'),
});
isQuitting = true;
app.quit();
return;
}
try {
await loadDashboard(
mainWindow,
backendManager.getBackendUrl(),
dashboardTimeoutMs,
);
showWindow();
} catch (error) {
const shellTexts = localeService.getShellTexts(
localeService.resolveStartupLocale(),
);
await dialog.showMessageBox({
type: 'error',
title: shellTexts.dashboardFailTitle,
message: shellTexts.dashboardFailMessage,
detail: error instanceof Error ? error.message : String(error),
});
isQuitting = true;
app.quit();
}
}
registerIpcHandlers();
app.setAppUserModelId('com.astrbot.desktop');
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.quit();
} else {
app.on('second-instance', () => {
showWindow();
});
}
app.on('before-quit', (event) => {
if (quitInProgress) {
event.preventDefault();
return;
}
event.preventDefault();
quitInProgress = true;
isQuitting = true;
logElectron('before-quit received, stopping backend.');
localeService
.persistLocaleFromDashboard(mainWindow, backendManager.getBackendUrl())
.catch(() => {})
.then(() =>
backendManager.stopAnyBackend().then((result) => {
if (!result.ok) {
logElectron(`stopBackend failed: ${result.reason || 'unknown reason'}`);
}
}),
)
.finally(async () => {
logElectron('Backend stop finished, exiting app.');
await Promise.allSettled([
flushElectron(),
backendManager ? backendManager.flushLogs() : Promise.resolve(),
]);
app.exit(0);
});
});
app.whenReady().then(async () => {
if (isMac && app.dock) {
const dockIcon = getAssetPath('icon.png');
if (fs.existsSync(dockIcon)) {
app.dock.setIcon(dockIcon);
}
}
await startDesktopFlow();
});
app.on('activate', () => {
if (mainWindow) {
showWindow();
}
});
app.on('window-all-closed', () => {
if (!isMac) {
app.quit();
}
});
-97
View File
@@ -1,97 +0,0 @@
{
"name": "astrbot-desktop",
"version": "4.17.5",
"description": "AstrBot desktop wrapper",
"private": true,
"main": "main.js",
"author": "AstrBot",
"packageManager": "pnpm@10.28.2",
"pnpm": {
"onlyBuiltDependencies": [
"electron"
]
},
"scripts": {
"dev": "electron .",
"start": "electron .",
"sync:version": "node scripts/sync-version.mjs",
"build:webui": "node scripts/prepare-webui.mjs",
"build:backend": "node scripts/build-backend.mjs",
"dist:full": "pnpm run build:webui && pnpm run build:backend && pnpm run dist",
"pack": "pnpm run sync:version && electron-builder --dir",
"dist": "pnpm run sync:version && electron-builder"
},
"devDependencies": {
"electron": "^40.3.0",
"electron-builder": "^24.13.0"
},
"build": {
"appId": "com.astrbot.desktop",
"productName": "AstrBot",
"icon": "assets/icon.png",
"extraResources": [
{
"from": "resources/backend",
"to": "backend",
"filter": [
"**/*",
"!**/*.map"
]
},
{
"from": "resources/webui",
"to": "webui",
"filter": [
"**/*",
"!**/*.map"
]
},
{
"from": "assets",
"to": "assets",
"filter": [
"**/*",
"!**/*.map"
]
}
],
"files": [
"**/*",
"!**/*.map",
"!**/*.d.ts",
"!**/{test,__tests__,tests,powered-test,example,examples}/**"
],
"compression": "maximum",
"electronLanguages": [
"en-US",
"zh-CN"
],
"asar": true,
"directories": {
"buildResources": "assets"
},
"linux": {
"target": [
"AppImage"
],
"category": "Utility"
},
"mac": {
"target": [
"dmg",
"zip"
],
"category": "public.app-category.productivity"
},
"win": {
"target": [
"nsis",
"zip"
]
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true
}
}
}
-2277
View File
File diff suppressed because it is too large Load Diff
-22
View File
@@ -1,22 +0,0 @@
'use strict';
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('astrbotDesktop', {
isElectron: true,
isElectronRuntime: () => ipcRenderer.invoke('astrbot-desktop:is-electron-runtime'),
getBackendState: () => ipcRenderer.invoke('astrbot-desktop:get-backend-state'),
restartBackend: (authToken) =>
ipcRenderer.invoke('astrbot-desktop:restart-backend', authToken),
stopBackend: () => ipcRenderer.invoke('astrbot-desktop:stop-backend'),
onTrayRestartBackend: (callback) => {
const listener = () => {
if (typeof callback === 'function') {
callback();
}
};
ipcRenderer.on('astrbot-desktop:tray-restart-backend', listener);
return () =>
ipcRenderer.removeListener('astrbot-desktop:tray-restart-backend', listener);
},
});
-86
View File
@@ -1,86 +0,0 @@
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, '..', '..');
const outputDir = path.join(rootDir, 'desktop', 'resources', 'backend');
const workDir = path.join(rootDir, 'desktop', 'resources', '.pyinstaller');
const dataSeparator = process.platform === 'win32' ? ';' : ':';
const kbStopwordsSrc = path.join(
rootDir,
'astrbot',
'core',
'knowledge_base',
'retrieval',
'hit_stopwords.txt',
);
const kbStopwordsDest = 'astrbot/core/knowledge_base/retrieval';
const builtinStarsSrc = path.join(rootDir, 'astrbot', 'builtin_stars');
const builtinStarsDest = 'astrbot/builtin_stars';
const args = [
'run',
'--with',
'pyinstaller',
'python',
'-m',
'PyInstaller',
'--noconfirm',
'--clean',
'--onefile',
'--name',
'astrbot-backend',
'--collect-all',
'aiosqlite',
'--collect-all',
'pip',
'--collect-all',
'bs4',
'--collect-all',
'readability',
'--collect-all',
'lxml',
'--collect-all',
'lxml_html_clean',
'--collect-all',
'rfc3987_syntax',
'--collect-submodules',
'astrbot.api',
'--collect-submodules',
'astrbot.builtin_stars',
'--collect-data',
'certifi',
'--add-data',
`${builtinStarsSrc}${dataSeparator}${builtinStarsDest}`,
'--add-data',
`${kbStopwordsSrc}${dataSeparator}${kbStopwordsDest}`,
'--distpath',
outputDir,
'--workpath',
workDir,
'--specpath',
workDir,
path.join(rootDir, 'main.py'),
];
const result = spawnSync('uv', args, {
cwd: rootDir,
stdio: 'inherit',
shell: process.platform === 'win32',
});
if (result.error) {
console.error(`Failed to run 'uv': ${result.error.message}`);
process.exit(typeof result.status === 'number' ? result.status : 1);
}
if (result.status !== 0) {
console.error(
`'uv' exited with status ${result.status} while running PyInstaller. ` +
'Verify that uv and pyinstaller are installed and that arguments are valid.',
);
process.exit(result.status ?? 1);
}
process.exit(0);
-20
View File
@@ -1,20 +0,0 @@
import { cp, mkdir, rm } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, '..', '..');
const distDir = path.join(rootDir, 'dashboard', 'dist');
const targetDir = path.join(rootDir, 'desktop', 'resources', 'webui');
if (!existsSync(distDir)) {
console.error('dashboard/dist is missing. Run `pnpm --dir dashboard build` first.');
process.exit(1);
}
await rm(targetDir, { recursive: true, force: true });
await mkdir(targetDir, { recursive: true });
await cp(distDir, targetDir, { recursive: true });
console.log(`Copied WebUI to ${targetDir}`);
-66
View File
@@ -1,66 +0,0 @@
import { readFile, writeFile } from 'node:fs/promises';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, '..', '..');
const desktopPackagePath = path.join(rootDir, 'desktop', 'package.json');
const pyprojectPath = path.join(rootDir, 'pyproject.toml');
function getGitTag() {
const result = spawnSync('git', ['describe', '--tags', '--abbrev=0'], {
cwd: rootDir,
encoding: 'utf8',
});
if (result.status === 0) {
const tag = result.stdout.trim();
return tag.length ? tag : null;
}
return null;
}
function normalizeTag(tag) {
return tag.replace(/^v/i, '');
}
async function getPyprojectVersion() {
try {
const data = await readFile(pyprojectPath, 'utf8');
const match = data.match(/^\s*version\s*=\s*"([^"]+)"/m);
return match ? match[1] : null;
} catch {
return null;
}
}
const pkgRaw = await readFile(desktopPackagePath, 'utf8');
const pkg = JSON.parse(pkgRaw);
const tag = getGitTag();
const versionFromTag = tag ? normalizeTag(tag) : null;
const versionFromPyproject = await getPyprojectVersion();
const version = versionFromPyproject || versionFromTag || pkg.version;
if (
versionFromPyproject &&
versionFromTag &&
versionFromPyproject !== versionFromTag
) {
console.log(
`Using pyproject version ${versionFromPyproject} (ignoring git tag ${versionFromTag}).`,
);
}
if (!version) {
console.warn('No version found to sync.');
process.exit(0);
}
if (pkg.version === version) {
console.log(`Desktop version already ${version}`);
process.exit(0);
}
pkg.version = version;
await writeFile(desktopPackagePath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8');
console.log(`Updated desktop version to ${version}`);
-93
View File
@@ -1,93 +0,0 @@
# 黑盒语音机器人帮助文档
codex resume 019c57d5-3b44-7a50-a514-1b1b3f0a4448
## Docs
- [教程](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5031038m0.md):
- [开发者服务协议](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5083727m0.md):
- [使用交流](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/4778396m0.md):
- [更新日志](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5029501m0.md):
- [开发计划](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5029504m0.md):
- [基础框架须知](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/7279187m0.md):
- 资源 [请求速率限制](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5192003m0.md):
- 资源 [Websocket](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5029558m0.md):
- 资源 [Bot命令](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5030757m0.md):
- HTTP接口 > 消息接口 [发送消息接口的参数](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5112305m0.md):
- HTTP接口 > 消息接口 [发送消息接口的返回值](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5156437m0.md):
- HTTP接口 > 消息接口 [发送图片形式的频道消息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5088949m0.md):
- HTTP接口 > 消息接口 [发送Markdown文档](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5324071m0.md):
- HTTP接口 > 消息接口 [更新指定频道消息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5274453m0.md):
- HTTP接口 > 消息接口 [删除指定的频道消息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5274471m0.md):
- HTTP接口 > 消息接口 [对某条频道消息增加/取消回应(小表情)](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5274495m0.md):
- HTTP接口 > 消息接口 [发送卡片消息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5430115m0.md):
- HTTP接口 > 消息接口 [给用户发送私聊消息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5722305m0.md):
- HTTP接口 > 媒体文件上传 [上传媒体文件的参数解析](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5156807m0.md):
- HTTP接口 > 房间角色接口 [权限相关说明](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/4781009m0.md):
- HTTP接口 > 房间角色接口 [接口说明](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5274618m0.md):
- HTTP接口 > 房间表情 [房间表情包](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5252750m0.md):
- HTTP接口 > 房间接口 [房间相关接口文档](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5650569m0.md):
- HTTP接口 > 在线媒体流 [在线媒体流说明文档](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/7020148m0.md):
- HTTP接口 > OAuth [OAuth使用说明](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/7145802m0.md):
- 服务端推送事件 [事件说明](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5243813m0.md):
- 服务端推送事件 [通用推送字段](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5254214m0.md):
- 服务端推送事件 > 机器人命令 [用户使用Bot命令](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5116164m0.md):
- 服务端推送事件 > 频道消息事件 [频道消息事件](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5243816m0.md):
- 服务端推送事件 > 房间消息事件 [房间消息事件](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5254078m0.md):
- 自定义卡片消息 [自定义卡片编辑器](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/6997428m0.md):
- 自定义卡片消息 > 物料组件 [卡片](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/6997517m0.md):
- 自定义卡片消息 > 物料组件 [文本](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/6997518m0.md):
- 自定义卡片消息 > 物料组件 [标题](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/6997729m0.md):
- 自定义卡片消息 > 物料组件 [图片](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/6997730m0.md):
- 自定义卡片消息 > 物料组件 [按钮组](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/6997731m0.md):
- 自定义卡片消息 > 物料组件 [分割线](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/6997733m0.md):
- 自定义卡片消息 > 物料组件 [倒计时](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/6997735m0.md):
## API Docs
- WEBSOCKET 连接请求 [连接到黑盒语音服务](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/3545540w0.md):
- HTTP接口 > 消息接口 [发送频道图片消息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/196181766e0.md):
- HTTP接口 > 消息接口 [发送频道消息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/195916005e0.md):
- HTTP接口 > 消息接口 [发送卡片消息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/231244234e0.md):
- HTTP接口 > 消息接口 [发送频道消息@全体成员/@在线成员](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/196225350e0.md):
- HTTP接口 > 消息接口 [更新指定的频道消息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/221115476e0.md):
- HTTP接口 > 消息接口 [删除指定的频道消息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/221117785e0.md):
- HTTP接口 > 消息接口 [对某条频道消息增加/取消回应(小表情)](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/220985915e0.md):
- HTTP接口 > 消息接口 [给用户发送私聊消息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/247164510e0.md):
- HTTP接口 > 媒体文件上传 [上传媒体文件](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/196172729e0.md):
- HTTP接口 > 房间角色接口 [获取房间角色列表](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/220721816e0.md):
- HTTP接口 > 房间角色接口 [创建角色](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/220860098e0.md):
- HTTP接口 > 房间角色接口 [更新角色](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/220893910e0.md):
- HTTP接口 > 房间角色接口 [删除角色](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/220864876e0.md):
- HTTP接口 > 房间角色接口 [对指定用户授予指定权限](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/195925401e0.md):
- HTTP接口 > 房间角色接口 [对指定用户剥夺指定权限](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/195927164e0.md):
- HTTP接口 > 房间表情 [获取房间上传的表情包](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/221092473e0.md):
- HTTP接口 > 房间表情 [房间删除表情包](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/221112168e0.md):
- HTTP接口 > 房间表情 [房间更新表情包名称](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/221346019e0.md):
- HTTP接口 > 房间接口 [修改房间内昵称](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/226373089e0.md):
- HTTP接口 > 房间接口 [分页获取加入的房间列表](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/226373523e0.md):
- HTTP接口 > 房间接口 [获取房间信息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/226373528e0.md):
- HTTP接口 > 房间接口 [退出房间](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/226373638e0.md):
- HTTP接口 > 房间接口 [房间踢人](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/226373709e0.md):
- HTTP接口 > 房间接口 [语音频道之间移动用户](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/318260744e0.md):
- HTTP接口 > 房间接口 [踢出语音频道中的用户](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/318266039e0.md):
- HTTP接口 > 房间接口 [禁言/解禁用户](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/325086722e0.md):
- HTTP接口 > 房间接口 [频道内麦克风静音/解禁](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/325092125e0.md): 对未静音对象调用时对其静音;对静音对象调用时解除静音
- HTTP接口 > 房间接口 [房间内麦克风静音/解禁](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/325104333e0.md):
- HTTP接口 > 房间接口 [房间内扬声器静音/解禁](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/325105640e0.md):
- HTTP接口 > 房间接口 [获取用户所在频道](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/325187362e0.md): bot需要在查询的房间中
- HTTP接口 > 房间接口 [获取语音频道内在线成员列表](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/325207647e0.md):
- HTTP接口 > 房间接口 [创建频道邀请链接](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/325223584e0.md): 需要 创建邀请 权限
- HTTP接口 > 房间接口 [频道设置修改](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/325259753e0.md): 需要 编辑频道 权限
- HTTP接口 > 房间接口 [频道名编辑](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/325264530e0.md): 需要 编辑频道 权限
- HTTP接口 > 房间接口 [设置频道密码](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/325512688e0.md):
- HTTP接口 > 房间接口 [修改权限组或成员权限](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/325672775e0.md): # 服务器权限管理文档
- HTTP接口 > 房间接口 [获取房间用户列表](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/326508787e0.md):
- HTTP接口 > 房间接口 [获取用户频道权限](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/339765173e0.md):
- HTTP接口 > 房间接口 [创建频道](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/340658298e0.md): 需要 管理频道(1<<2) 权限
- HTTP接口 > 房间接口 [删除频道](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/340660409e0.md): 需要 管理频道(1<<2) 权限
- HTTP接口 > 在线媒体流 [推流至语音频道](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/320947489e0.md):
- HTTP接口 > 在线媒体流 [停止推流至语音频道](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/320947513e0.md):
- HTTP接口 > OAuth [获取授权码](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/329051392e0.md): 获取授权码链接示例
- HTTP接口 > OAuth [获取AccessToken](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/329070402e0.md):
- HTTP接口 > OAuth [刷新AccessToken](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/329079907e0.md):
- HTTP接口 > OAuth [获取用户信息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/329599863e0.md):
- HTTP接口 > OAuth [获取用户房间内语音时长](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/332236185e0.md): 时间跨度不能超过30天
- HTTP接口 > OAuth [获取用户房间内语音游戏时长](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/332238065e0.md): 时间跨度不能超过30天
- HTTP接口 > OAuth [获取用户信息-自动触发授权](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/331602654e0.md): 在发起api请求时可以携带以下query作为参数 如果没有token且用户在线则会为用户唤起授权弹窗
+2
View File
@@ -15,6 +15,7 @@ from astrbot.core.initial_loader import InitialLoader # noqa: E402
from astrbot.core.utils.astrbot_path import ( # noqa: E402
get_astrbot_config_path,
get_astrbot_data_path,
get_astrbot_knowledge_base_path,
get_astrbot_plugin_path,
get_astrbot_root,
get_astrbot_site_packages_path,
@@ -55,6 +56,7 @@ def check_env() -> None:
os.makedirs(get_astrbot_config_path(), exist_ok=True)
os.makedirs(get_astrbot_plugin_path(), exist_ok=True)
os.makedirs(get_astrbot_temp_path(), exist_ok=True)
os.makedirs(get_astrbot_knowledge_base_path(), exist_ok=True)
os.makedirs(site_packages_path, exist_ok=True)
# 针对问题 #181 的临时解决方案
+2 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
version = "4.17.5"
version = "4.17.6"
description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md"
requires-python = ">=3.12"
@@ -62,6 +62,7 @@ dependencies = [
"tenacity>=9.1.2",
"shipyard-python-sdk>=0.2.4",
"python-socks>=2.8.0",
"packaging>=24.2",
]
[dependency-groups]
+1
View File
@@ -54,3 +54,4 @@ markitdown-no-magika[docx,xls,xlsx]>=0.1.2
xinference-client
tenacity>=9.1.2
shipyard-python-sdk>=0.2.4
packaging>=24.2