Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e7e0f84edf | |||
| e19a282c59 | |||
| fbc8667968 | |||
| cda49c3a9a | |||
| 4be1027444 | |||
| 46152d3faf | |||
| ed4cacfffb | |||
| 52d1979937 | |||
| b30cb12133 | |||
| 31d4e304fc | |||
| 9a7a594cb5 | |||
| e469178a6b | |||
| 0a517980b7 | |||
| 9c691b2266 | |||
| 3597726aad | |||
| a4a37c268d | |||
| 651a0645c5 | |||
| bf3fa3e918 | |||
| 3b2ce9f500 |
@@ -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!"
|
||||
@@ -102,170 +102,11 @@ jobs:
|
||||
cp "dashboard/AstrBot-${VERSION_TAG}-dashboard.zip" "dashboard/astrbot-webui-${VERSION_TAG}.zip"
|
||||
rclone copy "dashboard/astrbot-webui-${VERSION_TAG}.zip" "r2:${R2_BUCKET_NAME}" --progress
|
||||
|
||||
build-desktop:
|
||||
name: Build ${{ matrix.name }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: linux-x64
|
||||
runner: ubuntu-24.04
|
||||
os: linux
|
||||
arch: amd64
|
||||
- name: linux-arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
os: linux
|
||||
arch: arm64
|
||||
- name: windows-x64
|
||||
runner: windows-2022
|
||||
os: win
|
||||
arch: amd64
|
||||
- name: windows-arm64
|
||||
runner: windows-11-arm
|
||||
os: win
|
||||
arch: arm64
|
||||
- name: macos-x64
|
||||
runner: macos-15-intel
|
||||
os: mac
|
||||
arch: amd64
|
||||
- name: macos-arm64
|
||||
runner: macos-15
|
||||
os: mac
|
||||
arch: arm64
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Resolve tag
|
||||
id: tag
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
elif [ -n "${{ inputs.tag }}" ]; then
|
||||
tag="${{ inputs.tag }}"
|
||||
else
|
||||
tag="$(git describe --tags --abbrev=0)"
|
||||
fi
|
||||
if [ -z "$tag" ]; then
|
||||
echo "Failed to resolve tag." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.28.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.13.0'
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: |
|
||||
dashboard/pnpm-lock.yaml
|
||||
desktop/pnpm-lock.yaml
|
||||
|
||||
- name: Prepare OpenSSL for Windows ARM64
|
||||
if: ${{ matrix.os == 'win' && matrix.arch == 'arm64' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
git clone https://github.com/microsoft/vcpkg.git C:\vcpkg
|
||||
& C:\vcpkg\bootstrap-vcpkg.bat -disableMetrics
|
||||
& C:\vcpkg\vcpkg.exe install openssl:arm64-windows
|
||||
|
||||
"VCPKG_ROOT=C:\vcpkg" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"VCPKGRS_TRIPLET=arm64-windows" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"OPENSSL_DIR=C:\vcpkg\installed\arm64-windows" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"OPENSSL_ROOT_DIR=C:\vcpkg\installed\arm64-windows" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"OPENSSL_LIB_DIR=C:\vcpkg\installed\arm64-windows\lib" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"OPENSSL_INCLUDE_DIR=C:\vcpkg\installed\arm64-windows\include" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
uv sync
|
||||
pnpm --dir dashboard install --frozen-lockfile
|
||||
pnpm --dir desktop install --frozen-lockfile
|
||||
|
||||
- name: Build desktop package
|
||||
shell: bash
|
||||
run: |
|
||||
pnpm --dir dashboard run build
|
||||
pnpm --dir desktop run build:webui
|
||||
pnpm --dir desktop run build:backend
|
||||
pnpm --dir desktop run sync:version
|
||||
pnpm --dir desktop exec electron-builder --publish never
|
||||
|
||||
- name: Normalize artifact names
|
||||
shell: bash
|
||||
env:
|
||||
NAME_PREFIX: AstrBot-${{ steps.tag.outputs.tag }}-${{ matrix.arch }}-${{ matrix.os }}
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
out_dir="desktop/dist/release"
|
||||
mkdir -p "$out_dir"
|
||||
files=(
|
||||
desktop/dist/*.AppImage
|
||||
desktop/dist/*.dmg
|
||||
desktop/dist/*.zip
|
||||
desktop/dist/*.exe
|
||||
)
|
||||
if [ ${#files[@]} -eq 0 ]; then
|
||||
echo "No desktop artifacts found to rename." >&2
|
||||
exit 1
|
||||
fi
|
||||
for src in "${files[@]}"; do
|
||||
file="$(basename "$src")"
|
||||
case "$file" in
|
||||
*.AppImage)
|
||||
dest="$out_dir/${NAME_PREFIX}.AppImage"
|
||||
;;
|
||||
*.dmg)
|
||||
dest="$out_dir/${NAME_PREFIX}.dmg"
|
||||
;;
|
||||
*.exe)
|
||||
dest="$out_dir/${NAME_PREFIX}.exe"
|
||||
;;
|
||||
*.zip)
|
||||
dest="$out_dir/${NAME_PREFIX}.zip"
|
||||
;;
|
||||
*)
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
cp "$src" "$dest"
|
||||
done
|
||||
ls -la "$out_dir"
|
||||
|
||||
- name: Upload desktop artifacts
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: AstrBot-${{ steps.tag.outputs.tag }}-${{ matrix.arch }}-${{ matrix.os }}
|
||||
if-no-files-found: error
|
||||
path: desktop/dist/release/*
|
||||
|
||||
publish-release:
|
||||
name: Publish GitHub Release
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- build-dashboard
|
||||
- build-desktop
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
@@ -296,12 +137,6 @@ jobs:
|
||||
name: Dashboard-${{ steps.tag.outputs.tag }}
|
||||
path: release-assets
|
||||
|
||||
- name: Download desktop artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: AstrBot-${{ steps.tag.outputs.tag }}-*
|
||||
path: release-assets
|
||||
merge-multiple: true
|
||||
|
||||
- name: Resolve release notes
|
||||
id: notes
|
||||
|
||||
@@ -33,13 +33,6 @@ tests/astrbot_plugin_openai
|
||||
dashboard/node_modules/
|
||||
dashboard/dist/
|
||||
.pnpm-store/
|
||||
desktop/node_modules/
|
||||
desktop/dist/
|
||||
desktop/out/
|
||||
desktop/resources/backend/astrbot-backend*
|
||||
desktop/resources/backend/*.exe
|
||||
desktop/resources/webui/*
|
||||
desktop/resources/.pyinstaller/
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
|
||||
@@ -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
@@ -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 @@
|
||||
__version__ = "4.17.5"
|
||||
__version__ = "4.17.6"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -13,16 +13,19 @@ from astrbot.core.knowledge_base.models import (
|
||||
KBMedia,
|
||||
KnowledgeBase,
|
||||
)
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_knowledge_base_path
|
||||
|
||||
|
||||
class KBSQLiteDatabase:
|
||||
def __init__(self, db_path: str = "data/knowledge_base/kb.db") -> None:
|
||||
def __init__(self, db_path: str | None = None) -> None:
|
||||
"""初始化知识库数据库
|
||||
|
||||
Args:
|
||||
db_path: 数据库文件路径, 默认为 data/knowledge_base/kb.db
|
||||
db_path: 数据库文件路径, 默认位于 AstrBot 数据目录下的 knowledge_base/kb.db
|
||||
|
||||
"""
|
||||
if db_path is None:
|
||||
db_path = str(Path(get_astrbot_knowledge_base_path()) / "kb.db")
|
||||
self.db_path = db_path
|
||||
self.DATABASE_URL = f"sqlite+aiosqlite:///{db_path}"
|
||||
self.inited = False
|
||||
|
||||
@@ -3,6 +3,7 @@ from pathlib import Path
|
||||
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.provider.manager import ProviderManager
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_knowledge_base_path
|
||||
|
||||
# from .chunking.fixed_size import FixedSizeChunker
|
||||
from .chunking.recursive import RecursiveCharacterChunker
|
||||
@@ -13,7 +14,7 @@ from .retrieval.manager import RetrievalManager, RetrievalResult
|
||||
from .retrieval.rank_fusion import RankFusion
|
||||
from .retrieval.sparse_retriever import SparseRetriever
|
||||
|
||||
FILES_PATH = "data/knowledge_base"
|
||||
FILES_PATH = get_astrbot_knowledge_base_path()
|
||||
DB_PATH = Path(FILES_PATH) / "kb.db"
|
||||
"""Knowledge Base storage root directory"""
|
||||
CHUNKER = RecursiveCharacterChunker()
|
||||
@@ -27,7 +28,7 @@ class KnowledgeBaseManager:
|
||||
self,
|
||||
provider_manager: ProviderManager,
|
||||
) -> None:
|
||||
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.provider_manager = provider_manager
|
||||
self._session_deleted_callback_registered = False
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -15,7 +15,7 @@ Skills 目录路径:固定为数据目录下的 skills 目录
|
||||
|
||||
import os
|
||||
|
||||
from astrbot.core.utils.runtime_env import is_packaged_electron_runtime
|
||||
from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime
|
||||
|
||||
|
||||
def get_astrbot_path() -> str:
|
||||
@@ -29,7 +29,7 @@ def get_astrbot_root() -> str:
|
||||
"""获取Astrbot根目录路径"""
|
||||
if path := os.environ.get("ASTRBOT_ROOT"):
|
||||
return os.path.realpath(path)
|
||||
if is_packaged_electron_runtime():
|
||||
if is_packaged_desktop_runtime():
|
||||
return os.path.realpath(os.path.join(os.path.expanduser("~"), ".astrbot"))
|
||||
return os.path.realpath(os.getcwd())
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import threading
|
||||
from collections import deque
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_site_packages_path
|
||||
from astrbot.core.utils.runtime_env import is_packaged_electron_runtime
|
||||
from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime
|
||||
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
@@ -35,7 +35,7 @@ def _get_pip_main():
|
||||
"pip module is unavailable "
|
||||
f"(sys.executable={sys.executable}, "
|
||||
f"frozen={getattr(sys, 'frozen', False)}, "
|
||||
f"ASTRBOT_ELECTRON_CLIENT={os.environ.get('ASTRBOT_ELECTRON_CLIENT')})"
|
||||
f"ASTRBOT_DESKTOP_CLIENT={os.environ.get('ASTRBOT_DESKTOP_CLIENT')})"
|
||||
) from exc
|
||||
|
||||
return pip_main
|
||||
@@ -556,7 +556,7 @@ class PipInstaller:
|
||||
args.extend(["--trusted-host", "mirrors.aliyun.com", "-i", index_url])
|
||||
|
||||
target_site_packages = None
|
||||
if is_packaged_electron_runtime():
|
||||
if is_packaged_desktop_runtime():
|
||||
target_site_packages = get_astrbot_site_packages_path()
|
||||
os.makedirs(target_site_packages, exist_ok=True)
|
||||
_prepend_sys_path(target_site_packages)
|
||||
@@ -582,7 +582,7 @@ class PipInstaller:
|
||||
|
||||
def prefer_installed_dependencies(self, requirements_path: str) -> None:
|
||||
"""优先使用已安装在插件 site-packages 中的依赖,不执行安装。"""
|
||||
if not is_packaged_electron_runtime():
|
||||
if not is_packaged_desktop_runtime():
|
||||
return
|
||||
|
||||
target_site_packages = get_astrbot_site_packages_path()
|
||||
|
||||
@@ -6,5 +6,5 @@ def is_frozen_runtime() -> bool:
|
||||
return bool(getattr(sys, "frozen", False))
|
||||
|
||||
|
||||
def is_packaged_electron_runtime() -> bool:
|
||||
return is_frozen_runtime() and os.environ.get("ASTRBOT_ELECTRON_CLIENT") == "1"
|
||||
def is_packaged_desktop_runtime() -> bool:
|
||||
return is_frozen_runtime() and os.environ.get("ASTRBOT_DESKTOP_CLIENT") == "1"
|
||||
|
||||
@@ -64,11 +64,13 @@ class AuthRoute(Route):
|
||||
new_pwd = post_data.get("new_password", None)
|
||||
new_username = post_data.get("new_username", None)
|
||||
if not new_pwd and not new_username:
|
||||
return (
|
||||
Response().error("新用户名和新密码不能同时为空,你改了个寂寞").__dict__
|
||||
)
|
||||
return Response().error("新用户名和新密码不能同时为空").__dict__
|
||||
|
||||
# Verify password confirmation
|
||||
if new_pwd:
|
||||
confirm_pwd = post_data.get("confirm_password", None)
|
||||
if confirm_pwd != new_pwd:
|
||||
return Response().error("两次输入的新密码不一致").__dict__
|
||||
self.config["dashboard"]["password"] = new_pwd
|
||||
if new_username:
|
||||
self.config["dashboard"]["username"] = new_username
|
||||
|
||||
@@ -19,8 +19,14 @@ from astrbot.core.star.filter.command_group import CommandGroupFilter
|
||||
from astrbot.core.star.filter.permission import PermissionTypeFilter
|
||||
from astrbot.core.star.filter.regex import RegexFilter
|
||||
from astrbot.core.star.star_handler import EventType, star_handlers_registry
|
||||
from astrbot.core.star.star_manager import PluginManager
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
from astrbot.core.star.star_manager import (
|
||||
PluginManager,
|
||||
PluginVersionIncompatibleError,
|
||||
)
|
||||
from astrbot.core.utils.astrbot_path import (
|
||||
get_astrbot_data_path,
|
||||
get_astrbot_temp_path,
|
||||
)
|
||||
|
||||
from .route import Response, Route, RouteContext
|
||||
|
||||
@@ -46,6 +52,7 @@ class PluginRoute(Route):
|
||||
super().__init__(context)
|
||||
self.routes = {
|
||||
"/plugin/get": ("GET", self.get_plugins),
|
||||
"/plugin/check-compat": ("POST", self.check_plugin_compatibility),
|
||||
"/plugin/install": ("POST", self.install_plugin),
|
||||
"/plugin/install-upload": ("POST", self.install_plugin_upload),
|
||||
"/plugin/update": ("POST", self.update_plugin),
|
||||
@@ -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__
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import base64
|
||||
import os
|
||||
import traceback
|
||||
from io import BytesIO
|
||||
|
||||
@@ -51,14 +50,14 @@ async def generate_tsne_visualization(
|
||||
return None
|
||||
|
||||
kb = kb_helper.kb
|
||||
index_path = f"data/knowledge_base/{kb.kb_id}/index.faiss"
|
||||
index_path = kb_helper.kb_dir / "index.faiss"
|
||||
|
||||
# 读取 FAISS 索引
|
||||
if not os.path.exists(index_path):
|
||||
logger.warning(f"FAISS 索引不存在: {index_path}")
|
||||
if not index_path.exists():
|
||||
logger.warning(f"FAISS 索引不存在: {index_path!s}")
|
||||
return None
|
||||
|
||||
index = faiss.read_index(index_path)
|
||||
index = faiss.read_index(str(index_path))
|
||||
|
||||
if index.ntotal == 0:
|
||||
logger.warning("索引为空")
|
||||
|
||||
@@ -0,0 +1,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
|
||||
|
||||
@@ -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')"
|
||||
|
||||
@@ -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
|
||||
: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
// 把id设置回切换前的用于保存上一次的配置,保存完后恢复id为切换后的
|
||||
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; // 重置加载状态
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 |
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
-2277
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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}`);
|
||||
@@ -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}`);
|
||||
@@ -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且用户在线则会为用户唤起授权弹窗
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user