Compare commits

...

16 Commits

Author SHA1 Message Date
Soulter
021ca8175b chore: bump version to 4.5.4 2025-11-07 14:28:51 +08:00
Soulter
39d6207fe1 chore: remove dynamic version 2025-11-07 14:26:56 +08:00
Soulter
23ce687229 chore: fix dockerfile 2025-11-07 14:23:49 +08:00
鸦羽
3715312fd2 fix: update project description to English (#3516) 2025-11-07 01:13:32 +08:00
Soulter
8196922cac docs: simplify README 2025-11-06 15:22:43 +08:00
Soulter
8089ad91da perf: improve extension market ui 2025-11-06 13:57:46 +08:00
Soulter
2930cc3fd8 chore: bump version to 4.5.3 2025-11-05 21:21:14 +08:00
Soulter
0e841a8b25 fix: correct tools dictionary comprehension in get_tool_list method 2025-11-05 21:19:10 +08:00
Soulter
67fa1611cc chore: bump version to 4.5.2 2025-11-05 19:02:51 +08:00
Soulter
91136bb9f7 fix: llm tool register error (#3493) 2025-11-05 14:27:37 +08:00
Copilot
7c050d1adc feat: add customizable sidebar module ordering (#3307)
* Initial plan

* Add sidebar customization feature with drag-and-drop support

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>

* Add dist/ to .gitignore to exclude build artifacts

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>

* Fix memory leak and improve code quality per code review

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>

* Fix i18n key format: use dot notation instead of colon notation

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>

* Fix drag-and-drop to empty list issue

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
2025-11-04 23:59:45 +08:00
Misaka Mikoto
a0690a6afc feat: support options to delete plugins config and data (#3280)
* - 为插件管理页面中,删除插件提供一致的二次确认(原本只有卡片视图有二次确认)
- 二次确认时可选删除插件配置和持久化数据
- 添加对应的i18n支持

* ruff

* 移除未使用的
const $confirm = inject('$confirm');
2025-11-04 11:48:48 +08:00
Dt8333
c51609b261 fix: typing error (#3267)
* fix: 修复一些小错误。

修复aiocqhttp和slack中部分逻辑缺失的await。修复discord中错误的异常捕获类型。

* fix(core.platform): 修复discord适配器中错误的message_chain赋值

* fix(aiocqhttp): 更新convert_message方法的返回类型为AstrBotMessage | None

---------

Co-authored-by: Soulter <905617992@qq.com>
2025-11-03 23:38:52 +08:00
Soulter
72148f66eb chore: nodejs in Dockerfile 2025-11-03 13:19:51 +08:00
Copilot
a04993a2bb Replace insecure random with secrets module in cryptographic contexts (#3248)
* Initial plan

* Security fixes: Replace insecure random with secrets module and improve SSL context

Co-authored-by: LIghtJUNction <106986785+LIghtJUNction@users.noreply.github.com>

* Address code review feedback: fix POST method and add named constants

Co-authored-by: LIghtJUNction <106986785+LIghtJUNction@users.noreply.github.com>

* Improve documentation for random number generation constants

Co-authored-by: LIghtJUNction <106986785+LIghtJUNction@users.noreply.github.com>

* Update astrbot/core/utils/io.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update astrbot/core/platform/sources/wecom_ai_bot/WXBizJsonMsgCrypt.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update tests/test_security_fixes.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update astrbot/core/utils/io.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update astrbot/core/utils/io.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fix: Handle path parameter in SSL fallback for download_image_by_url

Co-authored-by: LIghtJUNction <106986785+LIghtJUNction@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: LIghtJUNction <106986785+LIghtJUNction@users.noreply.github.com>
Co-authored-by: LIghtJUNction <lightjunction.me@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-03 02:43:00 +08:00
LIghtJUNction
74f845b06d Chore: Dockerfile (#3266)
* fix: Dockerfile

python main.py 改为uv run main.py

* fix(dockerfile): 减少重复安装

* fix: 修复一些细节问题

* fix(.dockerignore): 需要git文件夹以获取astrbot版本(带git commit hash后缀)

* fix(.dockerignore): uv run之前会uv sync
2025-11-03 02:41:40 +08:00
38 changed files with 1260 additions and 248 deletions

View File

@@ -1,9 +1,8 @@
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# github acions
# github actions
.github/
.*ignore
.git/
# User-specific stuff
.idea/
# Byte-compiled / optimized / DLL files
@@ -15,7 +14,6 @@ env/
venv*/
ENV/
.conda/
README*.md
dashboard/
data/
changelogs/

View File

@@ -12,19 +12,20 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
bash \
ffmpeg \
curl \
gnupg \
git \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN apt-get update && apt-get install -y curl gnupg && \
curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && \
apt-get install -y nodejs && \
rm -rf /var/lib/apt/lists/*
RUN python -m pip install uv
RUN uv pip install -r requirements.txt --no-cache-dir --system
RUN uv pip install socksio uv pilk --no-cache-dir --system
EXPOSE 6185
EXPOSE 6186
CMD [ "python", "main.py" ]
CMD ["python", "main.py"]

View File

@@ -1,35 +0,0 @@
FROM python:3.10-slim
WORKDIR /AstrBot
COPY . /AstrBot/
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
build-essential \
python3-dev \
libffi-dev \
libssl-dev \
curl \
unzip \
ca-certificates \
bash \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Installation of Node.js
ENV NVM_DIR="/root/.nvm"
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.2/install.sh | bash && \
. "$NVM_DIR/nvm.sh" && \
nvm install 22 && \
nvm use 22
RUN /bin/bash -c ". \"$NVM_DIR/nvm.sh\" && node -v && npm -v"
RUN python -m pip install uv
RUN uv pip install -r requirements.txt --no-cache-dir --system
RUN uv pip install socksio uv pyffmpeg --no-cache-dir --system
EXPOSE 6185
EXPOSE 6186
CMD ["python", "main.py"]

114
README.md
View File

@@ -119,83 +119,73 @@ uv run main.py
<a href="https://discord.gg/hAVk6tgV36"><img alt="Discord_community" src="https://img.shields.io/badge/Discord-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
## 消息平台支持情况
## 支持的消息平台
**官方维护**
| 平台 | 支持性 |
| -------- | ------- |
| QQ(官方平台) | ✔ |
| QQ(OneBot) | ✔ |
| Telegram | ✔ |
| 企微应用 | ✔ |
| 企微智能机器人 | ✔ |
| 微信客服 | ✔ |
| 微信公众号 | ✔ |
| 飞书 | ✔ |
| 钉钉 | ✔ |
| Slack | ✔ |
| Discord | ✔ |
| Satori | ✔ |
| Misskey | ✔ |
| Whatsapp | 将支持 |
| LINE | 将支持 |
- QQ (官方平台 & OneBot)
- Telegram
- 企微应用 & 企微智能机器人
- 微信客服 & 微信公众号
- 飞书
- 钉钉
- Slack
- Discord
- Satori
- Misskey
- Whatsapp (将支持)
- LINE (将支持)
**社区维护**
| 平台 | 支持性 |
| -------- | ------- |
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | ✔ |
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | ✔ |
| [Bilibili 私信](https://github.com/Hina-Chat/astrbot_plugin_bilibili_adapter) | ✔ |
| [wxauto](https://github.com/luosheng520qaq/wxauto-repost-onebotv11) | ✔ |
- [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter)
- [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat)
- [Bilibili 私信](https://github.com/Hina-Chat/astrbot_plugin_bilibili_adapter)
- [wxauto](https://github.com/luosheng520qaq/wxauto-repost-onebotv11)
## ⚡ 提供商支持情况
## 支持的模型服务
**大模型服务**
| 名称 | 支持性 | 备注 |
| -------- | ------- | ------- |
| OpenAI | ✔ | 支持任何兼容 OpenAI API 的服务 |
| Anthropic | ✔ | |
| Google Gemini | ✔ | |
| Moonshot AI | ✔ | |
| 智谱 AI | ✔ | |
| DeepSeek | ✔ | |
| Ollama | ✔ | 本地部署 DeepSeek 等开源语言模型 |
| LM Studio | ✔ | 本地部署 DeepSeek 等开源语言模型 |
| [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | ✔ | |
| [302.AI](https://share.302.ai/rr1M3l) | ✔ | |
| [小马算力](https://www.tokenpony.cn/3YPyf) | ✔ | |
| 硅基流动 | ✔ | |
| PPIO 派欧云 | ✔ | |
| ModelScope | ✔ | |
| OneAPI | ✔ | |
| Dify | ✔ | |
| 阿里云百炼应用 | ✔ | |
| Coze | ✔ | |
- OpenAI 及兼容服务
- Anthropic
- Google Gemini
- Moonshot AI
- 智谱 AI
- DeepSeek
- Ollama (本地部署)
- LM Studio (本地部署)
- [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74)
- [302.AI](https://share.302.ai/rr1M3l)
- [小马算力](https://www.tokenpony.cn/3YPyf)
- [硅基流动](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot)
- [PPIO 派欧云](https://ppio.com/user/register?invited_by=AIOONE)
- ModelScope
- OneAPI
**LLMOps 平台**
- Dify
- 阿里云百炼应用
- Coze
**语音转文本服务**
| 名称 | 支持性 | 备注 |
| -------- | ------- | ------- |
| Whisper | ✔ | 支持 API、本地部署 |
| SenseVoice | ✔ | 本地部署 |
- OpenAI Whisper
- SenseVoice
**文本转语音服务**
| 名称 | 支持性 | 备注 |
| -------- | ------- | ------- |
| OpenAI TTS | ✔ | |
| Gemini TTS | ✔ | |
| GSVI | ✔ | GPT-Sovits-Inference |
| GPT-SoVITs | ✔ | GPT-Sovits |
| FishAudio | ✔ | |
| Edge TTS | ✔ | Edge 浏览器的免费 TTS |
| 阿里云百炼 TTS | ✔ | |
| Azure TTS | ✔ | |
| Minimax TTS | ✔ | |
| 火山引擎 TTS | ✔ | |
- OpenAI TTS
- Gemini TTS
- GPT-Sovits-Inference
- GPT-Sovits
- FishAudio
- Edge TTS
- 阿里云百炼 TTS
- Azure TTS
- Minimax TTS
- 火山引擎 TTS
## ❤️ 贡献
@@ -229,7 +219,7 @@ pre-commit install
## ⭐ Star History
> [!TIP]
> [!TIP]
> 如果本项目对您的生活 / 工作产生了帮助,或者您关注本项目的未来发展,请给项目 Star这是我们维护这个开源项目的动力 <3
<div align="center">

View File

@@ -55,14 +55,6 @@ class FunctionTool(ToolSchema, Generic[TContext]):
def __repr__(self):
return f"FuncTool(name={self.name}, parameters={self.parameters}, description={self.description})"
def __dict__(self) -> dict[str, Any]:
return {
"name": self.name,
"parameters": self.parameters,
"description": self.description,
"active": self.active,
}
async def call(
self, context: ContextWrapper[TContext], **kwargs
) -> str | mcp.types.CallToolResult:

View File

@@ -2,9 +2,9 @@
import os
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from astrbot.cdefaore.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.5.1"
VERSION = "4.5.4"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
# 默认配置

View File

@@ -107,7 +107,7 @@ class AiocqhttpAdapter(Platform):
)
await super().send_by_session(session, message_chain)
async def convert_message(self, event: Event) -> AstrBotMessage:
async def convert_message(self, event: Event) -> AstrBotMessage | None:
logger.debug(f"[aiocqhttp] RawMessage {event}")
if event["post_type"] == "message":
@@ -222,7 +222,7 @@ class AiocqhttpAdapter(Platform):
err = f"aiocqhttp: 无法识别的消息类型: {event.message!s},此条消息将被忽略。如果您在使用 go-cqhttp请将其配置文件中的 message.post-format 更改为 array。"
logger.critical(err)
try:
self.bot.send(event, err)
await self.bot.send(event, err)
except BaseException as e:
logger.error(f"回复消息失败: {e}")
return None

View File

@@ -90,7 +90,7 @@ class DiscordPlatformAdapter(Platform):
)
message_obj.self_id = self.client_self_id
message_obj.session_id = session.session_id
message_obj.message = message_chain
message_obj.message = message_chain.chain
# 创建临时事件对象来发送消息
temp_event = DiscordPlatformEvent(

View File

@@ -1,5 +1,6 @@
import asyncio
import base64
import binascii
import sys
from io import BytesIO
from pathlib import Path
@@ -183,7 +184,7 @@ class DiscordPlatformEvent(AstrMessageEvent):
BytesIO(img_bytes),
filename=filename or "image.png",
)
except (ValueError, TypeError, base64.binascii.Error):
except (ValueError, TypeError, binascii.Error):
logger.debug(
f"[Discord] 裸 Base64 解码失败,作为本地路径处理: {file_content}",
)

View File

@@ -82,7 +82,7 @@ class SlackAdapter(Platform):
session: MessageSesion,
message_chain: MessageChain,
):
blocks, text = SlackMessageEvent._parse_slack_blocks(
blocks, text = await SlackMessageEvent._parse_slack_blocks(
message_chain=message_chain,
web_client=self.web_client,
)

View File

@@ -10,7 +10,7 @@ import base64
import hashlib
import json
import logging
import random
import secrets
import socket
import struct
import time
@@ -139,6 +139,12 @@ class PKCS7Encoder:
class Prpcrypt:
"""提供接收和推送给企业微信消息的加解密接口"""
# 16位随机字符串的范围常量
# randbelow(RANDOM_RANGE) 返回 [0, 8999999999999999]两端都包含即包含0和8999999999999999
# 加上 MIN_RANDOM_VALUE 后得到 [1000000000000000, 9999999999999999]两端都包含即16位数字
MIN_RANDOM_VALUE = 1000000000000000 # 最小值: 1000000000000000 (16位)
RANDOM_RANGE = 9000000000000000 # 范围大小: 确保最大值为 9999999999999999 (16位)
def __init__(self, key):
# self.key = base64.b64decode(key+"=")
self.key = key
@@ -207,7 +213,9 @@ class Prpcrypt:
"""随机生成16位字符串
@return: 16位字符串
"""
return str(random.randint(1000000000000000, 9999999999999999)).encode()
return str(
secrets.randbelow(self.RANDOM_RANGE) + self.MIN_RANDOM_VALUE
).encode()
class WXBizJsonMsgCrypt:

View File

@@ -5,7 +5,7 @@
import asyncio
import base64
import hashlib
import random
import secrets
import string
from typing import Any
@@ -53,7 +53,7 @@ def generate_random_string(length: int = 10) -> str:
"""
letters = string.ascii_letters + string.digits
return "".join(random.choice(letters) for _ in range(length))
return "".join(secrets.choice(letters) for _ in range(length))
def calculate_image_md5(image_data: bytes) -> str:

View File

@@ -1,8 +1,8 @@
import asyncio
import hashlib
import json
import random
import re
import secrets
import time
import uuid
from pathlib import Path
@@ -54,7 +54,9 @@ class OTTSProvider:
async def _generate_signature(self) -> str:
await self._sync_time()
timestamp = int(time.time()) + self.time_offset
nonce = "".join(random.choices("abcdefghijklmnopqrstuvwxyz0123456789", k=10))
nonce = "".join(
secrets.choice("abcdefghijklmnopqrstuvwxyz0123456789") for _ in range(10)
)
path = re.sub(r"^https?://[^/]+", "", self.api_url) or "/"
return f"{timestamp}-{nonce}-0-{hashlib.md5(f'{path}-{timestamp}-{nonce}-0-{self.skey}'.encode()).hexdigest()}"

View File

@@ -680,11 +680,18 @@ class PluginManager:
return plugin_info
async def uninstall_plugin(self, plugin_name: str):
async def uninstall_plugin(
self,
plugin_name: str,
delete_config: bool = False,
delete_data: bool = False,
):
"""卸载指定的插件。
Args:
plugin_name (str): 要卸载的插件名称
delete_config (bool): 是否删除插件配置文件,默认为 False
delete_data (bool): 是否删除插件数据,默认为 False
Raises:
Exception: 当插件不存在、是保留插件时,或删除插件文件夹失败时抛出异常
@@ -714,6 +721,7 @@ class PluginManager:
await self._unbind_plugin(plugin_name, plugin.module_path)
# 删除插件文件夹
try:
remove_dir(os.path.join(ppath, root_dir_name))
except Exception as e:
@@ -721,6 +729,51 @@ class PluginManager:
f"移除插件成功,但是删除插件文件夹失败: {e!s}。您可以手动删除该文件夹,位于 addons/plugins/ 下。",
)
# 删除插件配置文件
if delete_config and root_dir_name:
config_file = os.path.join(
self.plugin_config_path,
f"{root_dir_name}_config.json",
)
if os.path.exists(config_file):
try:
os.remove(config_file)
logger.info(f"已删除插件 {plugin_name} 的配置文件")
except Exception as e:
logger.warning(f"删除插件配置文件失败: {e!s}")
# 删除插件持久化数据
# 注意需要检查两个可能的目录名plugin_data 和 plugins_data
# data/temp 目录可能被多个插件共享,不自动删除以防误删
if delete_data and root_dir_name:
data_base_dir = os.path.dirname(ppath) # data/
# 删除 data/plugin_data 下的插件持久化数据(单数形式,新版本)
plugin_data_dir = os.path.join(
data_base_dir, "plugin_data", root_dir_name
)
if os.path.exists(plugin_data_dir):
try:
remove_dir(plugin_data_dir)
logger.info(
f"已删除插件 {plugin_name} 的持久化数据 (plugin_data)"
)
except Exception as e:
logger.warning(f"删除插件持久化数据失败 (plugin_data): {e!s}")
# 删除 data/plugins_data 下的插件持久化数据(复数形式,旧版本兼容)
plugins_data_dir = os.path.join(
data_base_dir, "plugins_data", root_dir_name
)
if os.path.exists(plugins_data_dir):
try:
remove_dir(plugins_data_dir)
logger.info(
f"已删除插件 {plugin_name} 的持久化数据 (plugins_data)"
)
except Exception as e:
logger.warning(f"删除插件持久化数据失败 (plugins_data): {e!s}")
async def _unbind_plugin(self, plugin_name: str, plugin_module_path: str):
"""解绑并移除一个插件。

View File

@@ -105,16 +105,31 @@ async def download_image_by_url(
f.write(await resp.read())
return path
except (aiohttp.ClientConnectorSSLError, aiohttp.ClientConnectorCertificateError):
# 关闭SSL验证
# 关闭SSL验证仅在证书验证失败时作为fallback
logger.warning(
f"SSL certificate verification failed for {url}. "
"Disabling SSL verification (CERT_NONE) as a fallback. "
"This is insecure and exposes the application to man-in-the-middle attacks. "
"Please investigate and resolve certificate issues."
)
ssl_context = ssl.create_default_context()
ssl_context.set_ciphers("DEFAULT")
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
async with aiohttp.ClientSession() as session:
if post:
async with session.get(url, ssl=ssl_context) as resp:
return save_temp_img(await resp.read())
async with session.post(url, json=post_data, ssl=ssl_context) as resp:
if not path:
return save_temp_img(await resp.read())
with open(path, "wb") as f:
f.write(await resp.read())
return path
else:
async with session.get(url, ssl=ssl_context) as resp:
return save_temp_img(await resp.read())
if not path:
return save_temp_img(await resp.read())
with open(path, "wb") as f:
f.write(await resp.read())
return path
except Exception as e:
raise e
@@ -157,9 +172,19 @@ async def download_file(url: str, path: str, show_progress: bool = False):
end="",
)
except (aiohttp.ClientConnectorSSLError, aiohttp.ClientConnectorCertificateError):
# 关闭SSL验证
# 关闭SSL验证仅在证书验证失败时作为fallback
logger.warning(
"SSL 证书验证失败,已关闭 SSL 验证(不安全,仅用于临时下载)。请检查目标服务器的证书配置。"
)
logger.warning(
f"SSL certificate verification failed for {url}. "
"Falling back to unverified connection (CERT_NONE). "
"This is insecure and exposes the application to man-in-the-middle attacks. "
"Please investigate certificate issues with the remote server."
)
ssl_context = ssl.create_default_context()
ssl_context.set_ciphers("DEFAULT")
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
async with aiohttp.ClientSession() as session:
async with session.get(url, ssl=ssl_context, timeout=120) as resp:
total_size = int(resp.headers.get("content-length", 0))

View File

@@ -395,9 +395,15 @@ class PluginRoute(Route):
post_data = await request.json
plugin_name = post_data["name"]
delete_config = post_data.get("delete_config", False)
delete_data = post_data.get("delete_data", False)
try:
logger.info(f"正在卸载插件 {plugin_name}")
await self.plugin_manager.uninstall_plugin(plugin_name)
await self.plugin_manager.uninstall_plugin(
plugin_name,
delete_config=delete_config,
delete_data=delete_data,
)
logger.info(f"卸载插件 {plugin_name} 成功")
return Response().ok(None, "卸载成功").__dict__
except Exception as e:

View File

@@ -296,7 +296,15 @@ class ToolsRoute(Route):
"""获取所有注册的工具列表"""
try:
tools = self.tool_mgr.func_list
tools_dict = [tool.__dict__() for tool in tools]
tools_dict = [
{
"name": tool.name,
"description": tool.description,
"parameters": tool.parameters,
"active": tool.active,
}
for tool in tools
]
return Response().ok(data=tools_dict).__dict__
except Exception as e:
logger.error(traceback.format_exc())

8
changelogs/v4.5.2.md Normal file
View File

@@ -0,0 +1,8 @@
## What's Changed
1. 修复:>= Python 3.12 版本下可能导致 LLM Tool 注册错误的问题。
2. 优化:更好地适配 Class 方式注册 LLM Tool 的场景。引入 `call` 方法。
3. 新增:`ConversationManager` 类支持 `add_message_pair` 方法,简化对话消息的添加操作。
4. 新增:增加对 Tool Parameters 的参数验证,确保工具参数符合 JSON Schema 标准。
5. 新增:增加 LLM Message Schema 定义,提升消息结构的规范性和一致性。
6. 新增:支持对 WebUI 的侧边栏模块进行自定义配置(入口在侧边栏下方的设置页中)。

5
changelogs/v4.5.3.md Normal file
View File

@@ -0,0 +1,5 @@
## What's Changed
> hotfix version of 4.5.2
1. 修复:修正 `get_tool_list` 方法中工具字典推导式的错误导致的 WebUI MCP 页面工具列表无法显示的问题。

5
changelogs/v4.5.4.md Normal file
View File

@@ -0,0 +1,5 @@
## What's Changed
1. 修复Docker 镜像部分依赖问题导致某些情况下无法启动容器的问题;
2. 优化:插件卡片样式
3. 修复:部分情况下 Windows 一键启动部署时,更新 / 部署失败的问题;

View File

@@ -1,2 +1,3 @@
node_modules/
.DS_Store
.DS_Store
dist/

View File

@@ -2,6 +2,7 @@
import { ref, computed, inject } from 'vue';
import { useCustomizerStore } from "@/stores/customizer";
import { useModuleI18n } from '@/i18n/composables';
import UninstallConfirmDialog from './UninstallConfirmDialog.vue';
const props = defineProps({
extension: {
@@ -31,6 +32,7 @@ const emit = defineEmits([
]);
const reveal = ref(false);
const showUninstallDialog = ref(false);
// 国际化
const { tm } = useModuleI18n('features/extension');
@@ -55,19 +57,11 @@ const installExtension = async () => {
};
const uninstallExtension = async () => {
if (typeof $confirm !== "function") {
console.error(tm("card.errors.confirmNotRegistered"));
return;
}
showUninstallDialog.value = true;
};
const confirmed = await $confirm({
title: tm("dialogs.uninstall.title"),
message: tm("dialogs.uninstall.message"),
});
if (confirmed) {
emit("uninstall", props.extension);
}
const handleUninstallConfirm = (options: { deleteConfig: boolean; deleteData: boolean }) => {
emit("uninstall", props.extension, options);
};
const toggleActivation = () => {
@@ -220,6 +214,12 @@ const viewReadme = () => {
</v-card-actions>
</v-card>
<!-- 卸载确认对话框 -->
<UninstallConfirmDialog
v-model="showUninstallDialog"
@confirm="handleUninstallConfirm"
/>
</template>
<style scoped>

View File

@@ -0,0 +1,290 @@
<template>
<div style="margin-top: 16px;">
<v-btn
color="primary"
variant="outlined"
size="small"
@click="openDialog"
style="margin-bottom: 8px;"
>
{{ t('features.settings.sidebar.customize.title') }}
</v-btn>
<v-dialog v-model="dialog" max-width="700px">
<v-card>
<v-card-title class="d-flex justify-space-between align-center">
<span>{{ t('features.settings.sidebar.customize.title') }}</span>
<v-btn
icon="mdi-close"
variant="text"
@click="dialog = false"
></v-btn>
</v-card-title>
<v-card-text>
<p class="text-body-2 mb-4">{{ t('features.settings.sidebar.customize.subtitle') }}</p>
<v-row>
<v-col cols="12" md="6">
<div class="mb-2 font-weight-medium">{{ t('features.settings.sidebar.customize.mainItems') }}</div>
<v-list
density="compact"
class="custom-list"
@dragover.prevent
@drop="handleDropToList($event, 'main')"
>
<v-list-item
v-for="(item, index) in mainItems"
:key="item.title"
class="mb-1 draggable-item"
draggable="true"
@dragstart="handleDragStart($event, 'main', index)"
@dragover.prevent
@drop.stop="handleDrop($event, 'main', index)"
>
<template v-slot:prepend>
<v-icon :icon="item.icon" size="small" class="mr-2"></v-icon>
</template>
<v-list-item-title>{{ t(item.title) }}</v-list-item-title>
<template v-slot:append>
<v-btn
icon="mdi-arrow-right"
variant="text"
size="x-small"
@click="moveToMore(index)"
></v-btn>
</template>
</v-list-item>
</v-list>
</v-col>
<v-col cols="12" md="6">
<div class="mb-2 font-weight-medium">{{ t('features.settings.sidebar.customize.moreItems') }}</div>
<v-list
density="compact"
class="custom-list"
@dragover.prevent
@drop="handleDropToList($event, 'more')"
>
<v-list-item
v-for="(item, index) in moreItems"
:key="item.title"
class="mb-1 draggable-item"
draggable="true"
@dragstart="handleDragStart($event, 'more', index)"
@dragover.prevent
@drop.stop="handleDrop($event, 'more', index)"
>
<template v-slot:prepend>
<v-icon :icon="item.icon" size="small" class="mr-2"></v-icon>
</template>
<v-list-item-title>{{ t(item.title) }}</v-list-item-title>
<template v-slot:append>
<v-btn
icon="mdi-arrow-left"
variant="text"
size="x-small"
@click="moveToMain(index)"
></v-btn>
</template>
</v-list-item>
</v-list>
</v-col>
</v-row>
</v-card-text>
<v-card-actions>
<v-btn
color="error"
variant="text"
@click="resetToDefault"
>
{{ t('features.settings.sidebar.customize.reset') }}
</v-btn>
<v-spacer></v-spacer>
<v-btn
color="primary"
@click="saveCustomization"
>
{{ t('core.actions.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useI18n } from '@/i18n/composables';
import sidebarItems from '@/layouts/full/vertical-sidebar/sidebarItem';
import {
getSidebarCustomization,
setSidebarCustomization,
clearSidebarCustomization
} from '@/utils/sidebarCustomization';
const { t } = useI18n();
const dialog = ref(false);
const mainItems = ref([]);
const moreItems = ref([]);
const draggedItem = ref(null);
function initializeItems() {
const customization = getSidebarCustomization();
if (customization) {
// Load from customization
const allItemsMap = new Map();
sidebarItems.forEach(item => {
if (item.children) {
item.children.forEach(child => {
allItemsMap.set(child.title, child);
});
} else {
allItemsMap.set(item.title, item);
}
});
mainItems.value = customization.mainItems
.map(title => allItemsMap.get(title))
.filter(item => item);
moreItems.value = customization.moreItems
.map(title => allItemsMap.get(title))
.filter(item => item);
} else {
// Load default structure
mainItems.value = sidebarItems.filter(item => !item.children);
const moreGroup = sidebarItems.find(item => item.title === 'core.navigation.groups.more');
moreItems.value = moreGroup ? [...moreGroup.children] : [];
}
}
function openDialog() {
initializeItems();
dialog.value = true;
}
function handleDragStart(event, listType, index) {
draggedItem.value = {
type: listType,
index: index,
item: listType === 'main' ? mainItems.value[index] : moreItems.value[index]
};
event.dataTransfer.effectAllowed = 'move';
}
function handleDrop(event, targetListType, targetIndex) {
event.preventDefault();
if (!draggedItem.value) return;
const sourceListType = draggedItem.value.type;
const sourceIndex = draggedItem.value.index;
const item = draggedItem.value.item;
// Remove from source
if (sourceListType === 'main') {
mainItems.value.splice(sourceIndex, 1);
} else {
moreItems.value.splice(sourceIndex, 1);
}
// Add to target
if (targetListType === 'main') {
mainItems.value.splice(targetIndex, 0, item);
} else {
moreItems.value.splice(targetIndex, 0, item);
}
draggedItem.value = null;
}
function handleDropToList(event, targetListType) {
event.preventDefault();
if (!draggedItem.value) return;
const sourceListType = draggedItem.value.type;
const sourceIndex = draggedItem.value.index;
const item = draggedItem.value.item;
// Remove from source
if (sourceListType === 'main') {
mainItems.value.splice(sourceIndex, 1);
} else {
moreItems.value.splice(sourceIndex, 1);
}
// Add to target list at the end
if (targetListType === 'main') {
mainItems.value.push(item);
} else {
moreItems.value.push(item);
}
draggedItem.value = null;
}
function moveToMore(index) {
const item = mainItems.value.splice(index, 1)[0];
moreItems.value.push(item);
}
function moveToMain(index) {
const item = moreItems.value.splice(index, 1)[0];
mainItems.value.push(item);
}
function saveCustomization() {
const config = {
mainItems: mainItems.value.map(item => item.title),
moreItems: moreItems.value.map(item => item.title)
};
setSidebarCustomization(config);
// Notify the sidebar to reload
window.dispatchEvent(new CustomEvent('sidebar-customization-changed'));
dialog.value = false;
}
function resetToDefault() {
clearSidebarCustomization();
initializeItems();
// Notify the sidebar to reload
window.dispatchEvent(new CustomEvent('sidebar-customization-changed'));
}
onMounted(() => {
initializeItems();
});
</script>
<style scoped>
.draggable-item {
cursor: move;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 4px;
background-color: rgba(var(--v-theme-surface));
transition: all 0.2s;
}
.draggable-item:hover {
background-color: rgba(var(--v-theme-primary), 0.1);
border-color: rgba(var(--v-theme-primary), 0.3);
}
.custom-list {
min-height: 200px;
border: 1px dashed rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 4px;
padding: 8px;
}
</style>

View File

@@ -0,0 +1,135 @@
<template>
<v-dialog
v-model="show"
max-width="500"
@click:outside="handleCancel"
@keydown.esc="handleCancel"
>
<v-card>
<v-card-title class="text-h5">
{{ tm('dialogs.uninstall.title') }}
</v-card-title>
<v-card-text>
<div class="mb-4">
{{ tm('dialogs.uninstall.message') }}
</div>
<v-divider class="my-4"></v-divider>
<div class="text-subtitle-2 mb-3">{{ t('core.common.actions') }}:</div>
<v-checkbox
v-model="deleteConfig"
:label="tm('dialogs.uninstall.deleteConfig')"
color="warning"
hide-details
class="mb-2"
>
<template v-slot:append>
<v-tooltip location="top">
<template v-slot:activator="{ props }">
<v-icon v-bind="props" size="small" color="grey">mdi-information-outline</v-icon>
</template>
<span>{{ tm('dialogs.uninstall.configHint') }}</span>
</v-tooltip>
</template>
</v-checkbox>
<v-checkbox
v-model="deleteData"
:label="tm('dialogs.uninstall.deleteData')"
color="error"
hide-details
>
<template v-slot:append>
<v-tooltip location="top">
<template v-slot:activator="{ props }">
<v-icon v-bind="props" size="small" color="grey">mdi-information-outline</v-icon>
</template>
<span>{{ tm('dialogs.uninstall.dataHint') }}</span>
</v-tooltip>
</template>
</v-checkbox>
<v-alert
v-if="deleteConfig || deleteData"
type="warning"
variant="tonal"
density="compact"
class="mt-4"
>
<template v-slot:prepend>
<v-icon>mdi-alert</v-icon>
</template>
{{ t('messages.validation.operation_cannot_be_undone') }}
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="grey"
variant="text"
@click="handleCancel"
>
{{ t('core.common.cancel') }}
</v-btn>
<v-btn
color="error"
variant="elevated"
@click="handleConfirm"
>
{{ t('core.common.confirm') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useI18n, useModuleI18n } from '@/i18n/composables';
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:modelValue', 'confirm', 'cancel']);
const { t } = useI18n();
const { tm } = useModuleI18n('features/extension');
const show = ref(props.modelValue);
const deleteConfig = ref(false);
const deleteData = ref(false);
watch(() => props.modelValue, (val) => {
show.value = val;
if (val) {
// 重置选项
deleteConfig.value = false;
deleteData.value = false;
}
});
watch(show, (val) => {
emit('update:modelValue', val);
});
const handleConfirm = () => {
emit('confirm', {
deleteConfig: deleteConfig.value,
deleteData: deleteData.value,
});
show.value = false;
};
const handleCancel = () => {
emit('cancel');
show.value = false;
};
</script>

View File

@@ -18,5 +18,6 @@
"refresh": "Refresh",
"submit": "Submit",
"reset": "Reset",
"clear": "Clear"
"clear": "Clear",
"save": "Save"
}

View File

@@ -79,6 +79,14 @@
"devDocs": "Extension Development Docs",
"submitRepo": "Submit Extension Repository"
},
"sort": {
"default": "Default",
"stars": "Stars",
"author": "Author",
"updated": "Last Updated",
"ascending": "Ascending",
"descending": "Descending"
},
"tags": {
"danger": "Danger"
},
@@ -97,7 +105,11 @@
},
"uninstall": {
"title": "Confirm Deletion",
"message": "Are you sure you want to delete this extension?"
"message": "Are you sure you want to delete this extension?",
"deleteConfig": "Also delete plugin configuration file",
"deleteData": "Also delete plugin persistent data",
"configHint": "Configuration file located in data/config directory",
"dataHint": "Deletes data in data/plugin_data and data/plugins_data"
},
"install": {
"title": "Install Extension",

View File

@@ -19,5 +19,15 @@
"subtitle": "If you encounter data compatibility issues, you can manually start the database migration assistant",
"button": "Start Migration Assistant"
}
},
"sidebar": {
"title": "Sidebar",
"customize": {
"title": "Customize Sidebar",
"subtitle": "Drag to reorder modules, or move modules in/out of the \"More Features\" group. Settings are saved locally in your browser.",
"reset": "Reset to Default",
"mainItems": "Main Modules",
"moreItems": "More Features"
}
}
}

View File

@@ -20,5 +20,6 @@
"invalid_date": "Please enter a valid date",
"date_range": "Invalid date range",
"upload_failed": "File upload failed",
"network_error": "Network connection error, please try again"
"network_error": "Network connection error, please try again",
"operation_cannot_be_undone": "⚠️ This operation cannot be undone, please choose carefully!"
}

View File

@@ -18,5 +18,6 @@
"refresh": "刷新",
"submit": "提交",
"reset": "重置",
"clear": "清空"
"clear": "清空",
"save": "保存"
}

View File

@@ -79,6 +79,14 @@
"devDocs": "插件开发文档",
"submitRepo": "提交插件仓库"
},
"sort": {
"default": "默认排序",
"stars": "Star数",
"author": "作者名",
"updated": "更新时间",
"ascending": "升序",
"descending": "降序"
},
"tags": {
"danger": "危险"
},
@@ -97,7 +105,11 @@
},
"uninstall": {
"title": "删除确认",
"message": "你确定要删除当前插件吗?"
"message": "你确定要删除当前插件吗?",
"deleteConfig": "同时删除插件配置文件",
"deleteData": "同时删除插件持久化数据",
"configHint": "配置文件位于 data/config 目录",
"dataHint": "删除 data/plugin_data 和 data/plugins_data 目录下的数据"
},
"install": {
"title": "安装插件",

View File

@@ -19,5 +19,15 @@
"subtitle": "如果您遇到数据兼容性问题,可以手动启动数据库迁移助手",
"button": "启动迁移助手"
}
},
"sidebar": {
"title": "侧边栏",
"customize": {
"title": "自定义侧边栏",
"subtitle": "拖拽以调整模块顺序,或者将模块移入/移出\"更多功能\"分组。设置仅保存在浏览器本地。",
"reset": "恢复默认",
"mainItems": "主要模块",
"moreItems": "更多功能"
}
}
}

View File

@@ -20,5 +20,6 @@
"invalid_date": "请输入有效的日期",
"date_range": "日期范围无效",
"upload_failed": "文件上传失败",
"network_error": "网络连接错误,请重试"
"network_error": "网络连接错误,请重试",
"operation_cannot_be_undone": "⚠️ 此操作无法撤销,请谨慎选择!"
}

View File

@@ -1,15 +1,39 @@
<script setup>
import { ref, shallowRef } from 'vue';
import { ref, shallowRef, onMounted, onUnmounted } from 'vue';
import { useCustomizerStore } from '../../../stores/customizer';
import { useI18n } from '@/i18n/composables';
import sidebarItems from './sidebarItem';
import NavItem from './NavItem.vue';
import { applySidebarCustomization } from '@/utils/sidebarCustomization';
const { t } = useI18n();
const customizer = useCustomizerStore();
const sidebarMenu = shallowRef(sidebarItems);
// Apply customization on mount and listen for storage changes
const handleStorageChange = (e) => {
if (e.key === 'astrbot_sidebar_customization') {
sidebarMenu.value = applySidebarCustomization(sidebarItems);
}
};
const handleCustomEvent = () => {
sidebarMenu.value = applySidebarCustomization(sidebarItems);
};
onMounted(() => {
sidebarMenu.value = applySidebarCustomization(sidebarItems);
window.addEventListener('storage', handleStorageChange);
window.addEventListener('sidebar-customization-changed', handleCustomEvent);
});
onUnmounted(() => {
window.removeEventListener('storage', handleStorageChange);
window.removeEventListener('sidebar-customization-changed', handleCustomEvent);
});
const showIframe = ref(false);
const starCount = ref(null);

View File

@@ -0,0 +1,99 @@
// Utility for managing sidebar customization in localStorage
const STORAGE_KEY = 'astrbot_sidebar_customization';
/**
* Get the customized sidebar configuration from localStorage
* @returns {Object|null} The customization config or null if not set
*/
export function getSidebarCustomization() {
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : null;
} catch (error) {
console.error('Error reading sidebar customization:', error);
return null;
}
}
/**
* Save the sidebar customization to localStorage
* @param {Object} config - The customization configuration
* @param {Array} config.mainItems - Array of item titles for main sidebar
* @param {Array} config.moreItems - Array of item titles for "More Features" group
*/
export function setSidebarCustomization(config) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
} catch (error) {
console.error('Error saving sidebar customization:', error);
}
}
/**
* Clear the sidebar customization (reset to default)
*/
export function clearSidebarCustomization() {
try {
localStorage.removeItem(STORAGE_KEY);
} catch (error) {
console.error('Error clearing sidebar customization:', error);
}
}
/**
* Apply customization to sidebar items
* @param {Array} defaultItems - Default sidebar items array
* @returns {Array} Customized sidebar items array (new array, doesn't mutate input)
*/
export function applySidebarCustomization(defaultItems) {
const customization = getSidebarCustomization();
if (!customization) {
return defaultItems;
}
const { mainItems, moreItems } = customization;
// Create a map of all items by title for quick lookup
// Deep clone items to avoid mutating originals
const allItemsMap = new Map();
defaultItems.forEach(item => {
if (item.children) {
// If it's the "More" group, add children to map
item.children.forEach(child => {
allItemsMap.set(child.title, { ...child });
});
} else {
allItemsMap.set(item.title, { ...item });
}
});
const customizedItems = [];
// Add main items in custom order
mainItems.forEach(title => {
const item = allItemsMap.get(title);
if (item) {
customizedItems.push(item);
}
});
// If there are items in moreItems, create the "More Features" group
if (moreItems && moreItems.length > 0) {
const moreGroup = {
title: 'core.navigation.groups.more',
icon: 'mdi-dots-horizontal',
children: []
};
moreItems.forEach(title => {
const item = allItemsMap.get(title);
if (item) {
moreGroup.children.push(item);
}
});
customizedItems.push(moreGroup);
}
return customizedItems;
}

View File

@@ -4,12 +4,13 @@ import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue';
import ReadmeDialog from '@/components/shared/ReadmeDialog.vue';
import ProxySelector from '@/components/shared/ProxySelector.vue';
import UninstallConfirmDialog from '@/components/shared/UninstallConfirmDialog.vue';
import axios from 'axios';
import { pinyin } from 'pinyin-pro';
import { useCommonStore } from '@/stores/common';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import { ref, computed, onMounted, reactive } from 'vue';
import { ref, computed, onMounted, reactive, inject, watch } from 'vue';
const commonStore = useCommonStore();
@@ -52,10 +53,18 @@ const isListView = ref(false);
const pluginSearch = ref("");
const loading_ = ref(false);
// 分页相关
const currentPage = ref(1);
const itemsPerPage = ref(6); // 每页显示6个卡片 (2行 x 3列避免滚动)
// 危险插件确认对话框
const dangerConfirmDialog = ref(false);
const selectedDangerPlugin = ref(null);
// 卸载插件确认对话框(列表模式用)
const showUninstallDialog = ref(false);
const pluginToUninstall = ref(null);
// 插件市场相关
const extension_url = ref("");
const dialog = ref(false);
@@ -63,8 +72,11 @@ const upload_file = ref(null);
const uploadTab = ref('file');
const showPluginFullName = ref(false);
const marketSearch = ref("");
const debouncedMarketSearch = ref("");
const filterKeys = ['name', 'desc', 'author'];
const refreshingMarket = ref(false);
const sortBy = ref('default'); // default, stars, author, updated
const sortOrder = ref('desc'); // desc (降序) or asc (升序)
// 插件市场拼音搜索
const normalizeStr = (s) => (s ?? '').toString().toLowerCase().trim();
@@ -148,6 +160,71 @@ const pinnedPlugins = computed(() => {
return pluginMarketData.value.filter(plugin => plugin?.pinned);
});
// 过滤后的插件市场数据(带搜索)
const filteredMarketPlugins = computed(() => {
if (!debouncedMarketSearch.value) {
return pluginMarketData.value;
}
const search = debouncedMarketSearch.value.toLowerCase();
return pluginMarketData.value.filter(plugin => {
// 使用自定义过滤器
return marketCustomFilter(plugin.name, search, plugin) ||
marketCustomFilter(plugin.desc, search, plugin) ||
marketCustomFilter(plugin.author, search, plugin);
});
});
// 所有插件列表,推荐插件排在前面
const sortedPlugins = computed(() => {
let plugins = [...filteredMarketPlugins.value];
// 根据排序选项排序
if (sortBy.value === 'stars') {
// 按 star 数排序
plugins.sort((a, b) => {
const starsA = a.stars ?? 0;
const starsB = b.stars ?? 0;
return sortOrder.value === 'desc' ? starsB - starsA : starsA - starsB;
});
} else if (sortBy.value === 'author') {
// 按作者名字典序排序
plugins.sort((a, b) => {
const authorA = (a.author ?? '').toLowerCase();
const authorB = (b.author ?? '').toLowerCase();
const result = authorA.localeCompare(authorB);
return sortOrder.value === 'desc' ? -result : result;
});
} else if (sortBy.value === 'updated') {
// 按更新时间排序
plugins.sort((a, b) => {
const dateA = a.updated_at ? new Date(a.updated_at).getTime() : 0;
const dateB = b.updated_at ? new Date(b.updated_at).getTime() : 0;
return sortOrder.value === 'desc' ? dateB - dateA : dateA - dateB;
});
} else {
// default: 推荐插件排在前面
const pinned = plugins.filter(plugin => plugin?.pinned);
const notPinned = plugins.filter(plugin => !plugin?.pinned);
return [...pinned, ...notPinned];
}
return plugins;
});
// 分页计算属性
const displayItemsPerPage = 9; // 固定每页显示6个卡片2行
const totalPages = computed(() => {
return Math.ceil(sortedPlugins.value.length / displayItemsPerPage);
});
const paginatedPlugins = computed(() => {
const start = (currentPage.value - 1) * displayItemsPerPage;
const end = start + displayItemsPerPage;
return sortedPlugins.value.slice(start, end);
});
// 方法
const toggleShowReserved = () => {
showReserved.value = !showReserved.value;
@@ -213,10 +290,35 @@ const checkUpdate = () => {
});
};
const uninstallExtension = async (extension_name) => {
const uninstallExtension = async (extension_name, optionsOrSkipConfirm = false) => {
let deleteConfig = false;
let deleteData = false;
let skipConfirm = false;
// 处理参数:可能是布尔值(旧的 skipConfirm或对象新的选项
if (typeof optionsOrSkipConfirm === 'boolean') {
skipConfirm = optionsOrSkipConfirm;
} else if (typeof optionsOrSkipConfirm === 'object' && optionsOrSkipConfirm !== null) {
deleteConfig = optionsOrSkipConfirm.deleteConfig || false;
deleteData = optionsOrSkipConfirm.deleteData || false;
skipConfirm = true; // 如果传递了选项对象,说明已经确认过了
}
// 如果没有跳过确认且没有传递选项对象,显示自定义卸载对话框
if (!skipConfirm) {
pluginToUninstall.value = extension_name;
showUninstallDialog.value = true;
return; // 等待对话框回调
}
// 执行卸载
toast(tm('messages.uninstalling') + " " + extension_name, "primary");
try {
const res = await axios.post('/api/plugin/uninstall', { name: extension_name });
const res = await axios.post('/api/plugin/uninstall', {
name: extension_name,
delete_config: deleteConfig,
delete_data: deleteData,
});
if (res.data.status === "error") {
toast(res.data.message, "error");
return;
@@ -229,6 +331,14 @@ const uninstallExtension = async (extension_name) => {
}
};
// 处理卸载确认对话框的确认事件
const handleUninstallConfirm = (options) => {
if (pluginToUninstall.value) {
uninstallExtension(pluginToUninstall.value, options);
pluginToUninstall.value = null;
}
};
const updateExtension = async (extension_name) => {
loadingDialog.title = tm('status.loading');
loadingDialog.show = true;
@@ -496,6 +606,7 @@ const refreshPluginMarket = async () => {
trimExtensionName();
checkAlreadyInstalled();
checkUpdate();
currentPage.value = 1; // 重置到第一页
toast(tm('messages.refreshSuccess'), "success");
} catch (err) {
@@ -537,6 +648,20 @@ onMounted(async () => {
}
});
// 搜索防抖处理
let searchDebounceTimer = null;
watch(marketSearch, (newVal) => {
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer);
}
searchDebounceTimer = setTimeout(() => {
debouncedMarketSearch.value = newVal;
// 搜索时重置到第一页
currentPage.value = 1;
}, 300); // 300ms 防抖延迟
});
</script>
@@ -750,8 +875,10 @@ onMounted(async () => {
<v-row>
<v-col cols="12" md="6" lg="4" v-for="extension in filteredPlugins" :key="extension.name"
class="pb-2">
<ExtensionCard :extension="extension" class="rounded-lg" style="background-color: rgb(var(--v-theme-mcpCardBg));"
@configure="openExtensionConfig(extension.name)" @uninstall="uninstallExtension(extension.name)"
<ExtensionCard :extension="extension" class="rounded-lg"
style="background-color: rgb(var(--v-theme-mcpCardBg));"
@configure="openExtensionConfig(extension.name)"
@uninstall="(ext, options) => uninstallExtension(ext.name, options)"
@update="updateExtension(extension.name)" @reload="reloadPlugin(extension.name)"
@toggle-activation="extension.activated ? pluginOff(extension) : pluginOn(extension)"
@view-handlers="showPluginInfo(extension)" @view-readme="viewReadme(extension)">
@@ -771,85 +898,158 @@ onMounted(async () => {
@click="dialog = true" color="darkprimary">
</v-btn>
<div v-if="pinnedPlugins.length > 0" class="mt-4">
<h2>{{ tm('market.recommended') }}</h2>
<v-row style="margin-top: 8px;">
<v-col cols="12" md="6" lg="6" v-for="plugin in pinnedPlugins" :key="plugin.name">
<ExtensionCard :extension="plugin" class="h-120 rounded-lg" market-mode="true" :highlight="true"
@install="handleInstallPlugin(plugin)" @view-readme="open(plugin.repo)">
</ExtensionCard>
</v-col>
</v-row>
</div>
<div class="mt-4">
<div class="d-flex align-center mb-2" style="justify-content: space-between;">
<h2>{{ tm('market.allPlugins') }}</h2>
<div class="d-flex align-center">
<v-btn variant="tonal" size="small" @click="refreshPluginMarket" :loading="refreshingMarket"
class="mr-2">
<div class="d-flex align-center mb-2" style="justify-content: space-between; flex-wrap: wrap; gap: 8px;">
<div class="d-flex align-center" style="gap: 6px;">
<h2>{{ tm('market.allPlugins') }}({{ filteredMarketPlugins.length }})</h2>
<v-btn icon variant="text" @click="refreshPluginMarket" :loading="refreshingMarket">
<v-icon>mdi-refresh</v-icon>
{{ tm('buttons.refresh') }}
</v-btn>
<v-switch v-model="showPluginFullName" :label="tm('market.showFullName')" hide-details
density="compact" style="margin-left: 12px" />
</div>
<div class="d-flex align-center" style="gap: 8px; flex-wrap: wrap;">
<v-pagination v-model="currentPage" :length="totalPages" :total-visible="5" size="small"
density="comfortable"></v-pagination>
<!-- 排序选择器 -->
<v-select v-model="sortBy" :items="[
{ title: tm('sort.default'), value: 'default' },
{ title: tm('sort.stars'), value: 'stars' },
{ title: tm('sort.author'), value: 'author' },
{ title: tm('sort.updated'), value: 'updated' }
]" density="compact" variant="outlined" hide-details style="max-width: 150px;">
<template v-slot:prepend-inner>
<v-icon size="small">mdi-sort</v-icon>
</template>
</v-select>
<!-- 排序方向切换按钮 -->
<v-btn icon v-if="sortBy !== 'default'" @click="sortOrder = sortOrder === 'desc' ? 'asc' : 'desc'"
variant="text" density="compact">
<v-icon>{{ sortOrder === 'desc' ? 'mdi-sort-descending' : 'mdi-sort-ascending'
}}</v-icon>
<v-tooltip activator="parent" location="top">
{{ sortOrder === 'desc' ? tm('sort.descending') : tm('sort.ascending') }}
</v-tooltip>
</v-btn>
<!-- <v-switch v-model="showPluginFullName" :label="tm('market.showFullName')" hide-details
density="compact" style="margin-left: 12px" /> -->
</div>
</div>
<v-col cols="12" md="12" style="padding: 0px;">
<v-data-table :headers="pluginMarketHeaders" :items="pluginMarketData" item-key="name" style="border-radius: 10px;"
:loading="loading_" v-model:search="marketSearch" :filter-keys="filterKeys"
:custom-filter="marketCustomFilter">
<template v-slot:item.name="{ item }">
<div class="d-flex align-center"
style="overflow-x: auto; scrollbar-width: thin; scrollbar-track-color: transparent;">
<img v-if="item.logo" :src="item.logo"
style="height: 80px; width: 80px; margin-right: 8px; border-radius: 8px; margin-top: 8px; margin-bottom: 8px;"
alt="logo">
<a :href="item?.repo" style="color: var(--v-theme-primaryText, #000);
text-decoration:none">
<div v-if="item.display_name">
<span class="d-block">{{ item.display_name }}</span>
<small style="color: grey; font-size: 60%;">({{ item.name }})</small>
<v-row style="min-height: 26rem;">
<v-col v-for="plugin in paginatedPlugins" :key="plugin.name" cols="12" md="6" lg="4">
<v-card class="rounded-lg d-flex flex-column" elevation="0"
style=" height: 12rem; position: relative;">
<!-- 推荐标记 -->
<v-chip v-if="plugin?.pinned" color="warning" size="x-small" label
style="position: absolute; right: 8px; top: 8px; z-index: 10; height: 20px; font-weight: bold;">
🥳 推荐
</v-chip>
<v-card-text
style="padding: 12px; padding-bottom: 8px; display: flex; gap: 12px; width: 100%; flex: 1; overflow: hidden;">
<div v-if="plugin?.logo" style="flex-shrink: 0;">
<img :src="plugin.logo" :alt="plugin.name"
style="height: 75px; width: 75px; border-radius: 8px; object-fit: cover;" />
</div>
<div style="flex: 1; overflow: hidden; display: flex; flex-direction: column;">
<!-- Display Name -->
<div class="font-weight-bold"
style="margin-bottom: 4px; line-height: 1.3; font-size: 1.2rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
<span style="overflow: hidden; text-overflow: ellipsis;">
{{ plugin.display_name?.length ? plugin.display_name :
(showPluginFullName ? plugin.name : plugin.trimmedName) }}
</span>
</div>
<!-- Author with link -->
<div class="d-flex align-center" style="gap: 4px; margin-bottom: 6px;">
<v-icon icon="mdi-account" size="x-small"
style="color: rgba(var(--v-theme-on-surface), 0.5);"></v-icon>
<a v-if="plugin?.social_link" :href="plugin.social_link" target="_blank"
class="text-subtitle-2 font-weight-medium"
style="text-decoration: none; color: rgb(var(--v-theme-primary)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
{{ plugin.author }}
</a>
<span v-else class="text-subtitle-2 font-weight-medium"
style="color: rgb(var(--v-theme-primary)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
{{ plugin.author }}
</span>
<div class="d-flex align-center text-subtitle-2 ml-2"
style="color: rgba(var(--v-theme-on-surface), 0.7);">
<v-icon icon="mdi-source-branch" size="x-small" style="margin-right: 2px;"></v-icon>
<span>{{ plugin.version }}</span>
</div>
<span v-else>{{ showPluginFullName ? item.name : item.trimmedName }}</span>
</a>
</div>
</template>
<template v-slot:item.desc="{ item }">
<small>
{{ item.desc }}
</small>
</template>
<template v-slot:item.author="{ item }">
<div style="font-size: 12px;">
<span v-if="item?.social_link"><a :href="item?.social_link">{{ item.author }}</a></span>
<span v-else>{{ item.author }}</span>
</div>
</template>
<template v-slot:item.stars="{ item }">
<span>{{ item.stars }}</span>
</template>
<template v-slot:item.updated_at="{ item }">
<small>{{ new Date(item.updated_at).toLocaleString() }}</small>
</template>
<template v-slot:item.tags="{ item }">
<span v-if="item.tags.length === 0">-</span>
<v-chip v-for="tag in item.tags" :key="tag" :color="tag === 'danger' ? 'error' : 'primary'"
size="x-small" v-show="tag !== 'danger'" class="ma-1">
{{ tag }}</v-chip>
</template>
<template v-slot:item.actions="{ item }">
<v-btn class="text-none mr-2" size="x-small" icon variant="text"
@click="open(item.repo)"><v-icon>mdi-github</v-icon></v-btn>
<v-btn v-if="!item.installed" class="text-none mr-2" size="x-small" icon variant="text"
@click="handleInstallPlugin(item)">
<v-icon>mdi-download</v-icon></v-btn>
<v-btn v-else class="text-none mr-2" size="x-small" icon variant="text"
disabled><v-icon>mdi-check</v-icon></v-btn>
</template>
</v-data-table>
</v-col>
</div>
<!-- Description -->
<div class="text-caption"
style="overflow: scroll; color: rgba(var(--v-theme-on-surface), 0.6); line-height: 1.3; margin-bottom: 6px; flex: 1;">
{{ plugin.desc }}
</div>
<!-- Stats: Stars & Updated & Version -->
<div class="d-flex align-center" style="gap: 8px; margin-top: auto;">
<div v-if="plugin.stars !== undefined" class="d-flex align-center text-subtitle-2"
style="color: rgba(var(--v-theme-on-surface), 0.7);">
<v-icon icon="mdi-star" size="x-small" style="margin-right: 2px;"></v-icon>
<span>{{ plugin.stars }}</span>
</div>
<div v-if="plugin.updated_at" class="d-flex align-center text-subtitle-2"
style="color: rgba(var(--v-theme-on-surface), 0.7);">
<v-icon icon="mdi-clock-outline" size="x-small" style="margin-right: 2px;"></v-icon>
<span>{{ new Date(plugin.updated_at).toLocaleString() }}</span>
</div>
</div>
</div>
</v-card-text>
<!-- Actions -->
<v-card-actions style="gap: 6px; padding: 8px 12px; padding-top: 0;">
<v-chip v-for="tag in plugin.tags?.slice(0, 2)" :key="tag"
:color="tag === 'danger' ? 'error' : 'primary'" label size="x-small" style="height: 20px;">
{{ tag === 'danger' ? tm('tags.danger') : tag }}
</v-chip>
<v-menu v-if="plugin.tags && plugin.tags.length > 2" open-on-hover offset-y>
<template v-slot:activator="{ props: menuProps }">
<v-chip v-bind="menuProps" color="grey" label size="x-small"
style="height: 20px; cursor: pointer;">
+{{ plugin.tags.length - 2 }}
</v-chip>
</template>
<v-list density="compact">
<v-list-item v-for="tag in plugin.tags.slice(2)" :key="tag">
<v-chip :color="tag === 'danger' ? 'error' : 'primary'" label size="small">
{{ tag === 'danger' ? tm('tags.danger') : tag }}
</v-chip>
</v-list-item>
</v-list>
</v-menu>
<v-spacer></v-spacer>
<v-btn v-if="plugin?.repo" color="secondary" size="x-small" variant="tonal" :href="plugin.repo"
target="_blank" style="height: 24px;">
<v-icon icon="mdi-github" start size="x-small"></v-icon>
仓库
</v-btn>
<v-btn v-if="!plugin?.installed" color="primary" size="x-small"
@click="handleInstallPlugin(plugin)" variant="flat" style="height: 24px;">
{{ tm('buttons.install') }}
</v-btn>
<v-chip v-else color="success" size="x-small" label style="height: 20px;">
{{ tm('status.installed') }}
</v-chip>
</v-card-actions>
</v-card>
</v-col>
</v-row>
<!-- 底部分页控件 -->
<div class="d-flex justify-center mt-4" v-if="totalPages > 1">
<v-pagination v-model="currentPage" :length="totalPages" :total-visible="7" size="small"></v-pagination>
</div>
</div>
</v-tab-item>
@@ -862,7 +1062,7 @@ onMounted(async () => {
</v-card>
</v-col>
<v-col v-if="activeTab === 'market'" style="margin-bottom: 16px;" cols="12" md="12">
<v-col v-if="activeTab === 'market'" cols="12" md="12">
<small><a href="https://astrbot.app/dev/plugin.html">{{ tm('market.devDocs') }}</a></small> |
<small> <a href="https://github.com/AstrBotDevs/AstrBot_Plugins_Collection">{{ tm('market.submitRepo')
}}</a></small>
@@ -958,6 +1158,9 @@ onMounted(async () => {
<ReadmeDialog v-model:show="readmeDialog.show" :plugin-name="readmeDialog.pluginName"
:repo-url="readmeDialog.repoUrl" />
<!-- 卸载插件确认对话框列表模式用 -->
<UninstallConfirmDialog v-model="showUninstallDialog" @confirm="handleUninstallConfirm" />
<!-- 危险插件确认对话框 -->
<v-dialog v-model="dangerConfirmDialog" width="500" persistent>
<v-card>

View File

@@ -9,6 +9,12 @@
<ProxySelector></ProxySelector>
</v-list-item>
<v-list-subheader>{{ tm('sidebar.title') }}</v-list-subheader>
<v-list-item :subtitle="tm('sidebar.customize.subtitle')" :title="tm('sidebar.customize.title')">
<SidebarCustomizer></SidebarCustomizer>
</v-list-item>
<v-list-subheader>{{ tm('system.title') }}</v-list-subheader>
<v-list-item :subtitle="tm('system.restart.subtitle')" :title="tm('system.restart.title')">
@@ -33,6 +39,7 @@ import axios from 'axios';
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
import ProxySelector from '@/components/shared/ProxySelector.vue';
import MigrationDialog from '@/components/shared/MigrationDialog.vue';
import SidebarCustomizer from '@/components/shared/SidebarCustomizer.vue';
import { useModuleI18n } from '@/i18n/composables';
const { tm } = useModuleI18n('features/settings');

View File

@@ -1,7 +1,7 @@
[project]
name = "AstrBot"
dynamic = ["version"]
description = "易上手的多平台 LLM 聊天机器人及开发框架"
version = "4.5.4"
description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md"
requires-python = ">=3.10"
@@ -111,16 +111,3 @@ reportMissingTypeStubs = false
reportMissingImports = false
include = ["astrbot","packages"]
exclude = ["dashboard", "node_modules", "dist", "data", "tests"]
[tool.hatch.version]
source = "uv-dynamic-versioning"
[tool.uv-dynamic-versioning]
vcs = "git"
style = "pep440"
bump = true
[build-system]
requires = ["hatchling", "uv-dynamic-versioning"]
build-backend = "hatchling.build"

View File

@@ -0,0 +1,151 @@
"""Tests for security fixes - cryptographic random number generation and SSL context."""
import os
import ssl
import sys
# Add project root to sys.path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
import pytest
def test_wecom_crypto_uses_secrets():
"""Test that WXBizJsonMsgCrypt uses secrets module instead of random."""
from astrbot.core.platform.sources.wecom_ai_bot.WXBizJsonMsgCrypt import Prpcrypt
# Create an instance and test that random string generation works
prpcrypt = Prpcrypt(b"test_key_32_bytes_long_value!")
# Generate multiple random strings and verify they are different and valid
random_strings = [prpcrypt.get_random_str() for _ in range(10)]
# All strings should be 16 bytes long
assert all(len(s) == 16 for s in random_strings)
# All strings should be different (extremely high probability with cryptographic random)
assert len(set(random_strings)) == 10
# All strings should be numeric when decoded
for s in random_strings:
decoded = s.decode()
assert decoded.isdigit()
assert 1000000000000000 <= int(decoded) <= 9999999999999999
def test_wecomai_utils_uses_secrets():
"""Test that wecomai_utils uses secrets module for random string generation."""
from astrbot.core.platform.sources.wecom_ai_bot.wecomai_utils import (
generate_random_string,
)
# Generate multiple random strings and verify they are different
random_strings = [generate_random_string(10) for _ in range(20)]
# All strings should be 10 characters long
assert all(len(s) == 10 for s in random_strings)
# All strings should be alphanumeric
for s in random_strings:
assert s.isalnum()
# All strings should be different (extremely high probability with cryptographic random)
assert len(set(random_strings)) >= 19 # Allow for 1 collision in 20 (very unlikely)
def test_azure_tts_signature_uses_secrets():
"""Test that Azure TTS signature generation uses secrets module."""
import asyncio
from astrbot.core.provider.sources.azure_tts_source import OTTSProvider
# Create a provider with test config
config = {
"OTTS_SKEY": "test_secret_key",
"OTTS_URL": "https://example.com/api/tts",
"OTTS_AUTH_TIME": "https://example.com/api/time",
}
async def test_nonce_generation():
async with OTTSProvider(config) as provider:
# Mock time sync to avoid actual API calls
provider.time_offset = 0
provider.last_sync_time = 9999999999
# Generate multiple signatures and extract nonces
signatures = []
for _ in range(10):
sig = await provider._generate_signature()
signatures.append(sig)
# Extract nonces (second field in signature format: timestamp-nonce-0-hash)
nonces = [sig.split("-")[1] for sig in signatures]
# All nonces should be 10 characters long
assert all(len(n) == 10 for n in nonces)
# All nonces should be alphanumeric (lowercase letters and digits)
for n in nonces:
assert all(c in "abcdefghijklmnopqrstuvwxyz0123456789" for c in n)
# All nonces should be different (cryptographic random ensures uniqueness)
assert len(set(nonces)) == 10
asyncio.run(test_nonce_generation())
def test_ssl_context_fallback_explicit():
"""Test that SSL context fallback is properly configured."""
# This test verifies the SSL context configuration
# We can't easily test the full io.py functions without network calls,
# but we can verify that ssl.CERT_NONE and check_hostname=False are valid settings
# Create a context similar to what's used in io.py
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
# Verify the settings are applied correctly
assert ssl_context.check_hostname is False
assert ssl_context.verify_mode == ssl.CERT_NONE
# This configuration should work but is intentionally insecure for fallback
# The actual code only uses this when certificate validation fails
def test_io_module_has_ssl_imports():
"""Verify that io.py properly imports ssl module."""
from astrbot.core.utils import io
# Check that ssl is available in the module
assert hasattr(io, "ssl")
# Check that CERT_NONE constant is accessible
assert hasattr(io.ssl, "CERT_NONE")
def test_secrets_module_randomness_quality():
"""Test that secrets module provides high-quality randomness."""
import secrets
# Generate a large set of random numbers
random_numbers = [secrets.randbelow(100) for _ in range(1000)]
# Basic statistical test: should have good distribution
unique_values = len(set(random_numbers))
# With 1000 random numbers from 0-99, we should see most values at least once
# This is a very basic test - real cryptographic random should pass this easily
assert unique_values >= 60 # Should see at least 60 different values out of 100
# Test secrets.choice for string generation
chars = "abcdefghijklmnopqrstuvwxyz0123456789"
random_chars = [secrets.choice(chars) for _ in range(1000)]
# Should have good character distribution
unique_chars = len(set(random_chars))
assert unique_chars >= 20 # Should see at least 20 different characters
if __name__ == "__main__":
pytest.main([__file__, "-v"])