Compare commits
29 Commits
refactor/a
...
copilot/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d8dd68b59 | ||
|
|
e99258a276 | ||
|
|
6d00717655 | ||
|
|
bb5f06498e | ||
|
|
aca5743ab6 | ||
|
|
6903032f7e | ||
|
|
1ce0ff87bd | ||
|
|
e39d6bae0b | ||
|
|
8028e9e9a6 | ||
|
|
817f20ea01 | ||
|
|
ad5579a2f4 | ||
|
|
81a689a79b | ||
|
|
1893dd8336 | ||
|
|
021ca8175b | ||
|
|
39d6207fe1 | ||
|
|
23ce687229 | ||
|
|
3715312fd2 | ||
|
|
8196922cac | ||
|
|
8089ad91da | ||
|
|
2930cc3fd8 | ||
|
|
0e841a8b25 | ||
|
|
67fa1611cc | ||
|
|
91136bb9f7 | ||
|
|
7c050d1adc | ||
|
|
a0690a6afc | ||
|
|
c51609b261 | ||
|
|
72148f66eb | ||
|
|
a04993a2bb | ||
|
|
74f845b06d |
@@ -1,9 +1,9 @@
|
||||
# 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
|
||||
.git
|
||||
.github/
|
||||
.*ignore
|
||||
.git/
|
||||
# User-specific stuff
|
||||
.idea/
|
||||
# Byte-compiled / optimized / DLL files
|
||||
@@ -15,10 +15,10 @@ env/
|
||||
venv*/
|
||||
ENV/
|
||||
.conda/
|
||||
README*.md
|
||||
dashboard/
|
||||
data/
|
||||
changelogs/
|
||||
tests/
|
||||
.ruff_cache/
|
||||
.astrbot
|
||||
.astrbot
|
||||
astrbot.lock
|
||||
2
.github/ISSUE_TEMPLATE/PLUGIN_PUBLISH.yml
vendored
2
.github/ISSUE_TEMPLATE/PLUGIN_PUBLISH.yml
vendored
@@ -16,7 +16,7 @@ body:
|
||||
|
||||
请将插件信息填写到下方的 JSON 代码块中。其中 `tags`(插件标签)和 `social_link`(社交链接)选填。
|
||||
|
||||
不熟悉 JSON ?可以从 [此处](https://plugins.astrbot.app/submit) 生成 JSON ,生成后记得复制粘贴过来.
|
||||
不熟悉 JSON ?可以从 [此站](https://plugins.astrbot.app) 右下角提交。
|
||||
|
||||
- type: textarea
|
||||
id: plugin-info
|
||||
|
||||
18
Dockerfile
18
Dockerfile
@@ -12,19 +12,21 @@ 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 apt-get update && apt-get install -y curl gnupg \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \
|
||||
&& apt-get install -y nodejs
|
||||
|
||||
RUN python -m pip install uv
|
||||
RUN python -m pip install uv \
|
||||
&& echo "3.11" > .python-version
|
||||
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"]
|
||||
|
||||
@@ -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"]
|
||||
116
README.md
116
README.md
@@ -8,7 +8,7 @@
|
||||
|
||||
<div>
|
||||
<a href="https://trendshift.io/repositories/12875" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12875" alt="Soulter%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<a href="https://hellogithub.com/repository/AstrBotDevs/AstrBot" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=d127d50cd5e54c5382328acc3bb25483&claim_uid=ZO9by7qCXgSd6Lp&t=1" alt="Featured|HelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
<a href="https://hellogithub.com/repository/AstrBotDevs/AstrBot" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=d127d50cd5e54c5382328acc3bb25483&claim_uid=ZO9by7qCXgSd6Lp&t=2" alt="Featured|HelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
</div>
|
||||
|
||||
<br>
|
||||
@@ -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">
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -4,7 +4,7 @@ import os
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
VERSION = "4.5.1"
|
||||
VERSION = "4.5.6"
|
||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
||||
|
||||
# 默认配置
|
||||
@@ -740,6 +740,7 @@ CONFIG_METADATA_2 = {
|
||||
"api_base": "https://api.openai.com/v1",
|
||||
"timeout": 120,
|
||||
"model_config": {"model": "gpt-4o-mini", "temperature": 0.4},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
"hint": "也兼容所有与 OpenAI API 兼容的服务。",
|
||||
@@ -755,6 +756,7 @@ CONFIG_METADATA_2 = {
|
||||
"api_base": "",
|
||||
"timeout": 120,
|
||||
"model_config": {"model": "gpt-4o-mini", "temperature": 0.4},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
@@ -768,6 +770,7 @@ CONFIG_METADATA_2 = {
|
||||
"api_base": "https://api.x.ai/v1",
|
||||
"timeout": 120,
|
||||
"model_config": {"model": "grok-2-latest", "temperature": 0.4},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"xai_native_search": False,
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
@@ -799,6 +802,7 @@ CONFIG_METADATA_2 = {
|
||||
"key": ["ollama"], # ollama 的 key 默认是 ollama
|
||||
"api_base": "http://localhost:11434/v1",
|
||||
"model_config": {"model": "llama3.1-8b", "temperature": 0.4},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
@@ -813,6 +817,7 @@ CONFIG_METADATA_2 = {
|
||||
"model_config": {
|
||||
"model": "llama-3.1-8b",
|
||||
},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
@@ -829,6 +834,7 @@ CONFIG_METADATA_2 = {
|
||||
"model": "gemini-1.5-flash",
|
||||
"temperature": 0.4,
|
||||
},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
@@ -870,6 +876,7 @@ CONFIG_METADATA_2 = {
|
||||
"api_base": "https://api.deepseek.com/v1",
|
||||
"timeout": 120,
|
||||
"model_config": {"model": "deepseek-chat", "temperature": 0.4},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "tool_use"],
|
||||
},
|
||||
@@ -883,6 +890,7 @@ CONFIG_METADATA_2 = {
|
||||
"api_base": "https://api.302.ai/v1",
|
||||
"timeout": 120,
|
||||
"model_config": {"model": "gpt-4.1-mini", "temperature": 0.4},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
@@ -899,6 +907,7 @@ CONFIG_METADATA_2 = {
|
||||
"model": "deepseek-ai/DeepSeek-V3",
|
||||
"temperature": 0.4,
|
||||
},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
@@ -915,6 +924,7 @@ CONFIG_METADATA_2 = {
|
||||
"model": "deepseek/deepseek-r1",
|
||||
"temperature": 0.4,
|
||||
},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
},
|
||||
"小马算力": {
|
||||
@@ -930,6 +940,7 @@ CONFIG_METADATA_2 = {
|
||||
"model": "kimi-k2-instruct-0905",
|
||||
"temperature": 0.7,
|
||||
},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
},
|
||||
"优云智算": {
|
||||
@@ -944,6 +955,7 @@ CONFIG_METADATA_2 = {
|
||||
"model_config": {
|
||||
"model": "moonshotai/Kimi-K2-Instruct",
|
||||
},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
@@ -957,6 +969,7 @@ CONFIG_METADATA_2 = {
|
||||
"timeout": 120,
|
||||
"api_base": "https://api.moonshot.cn/v1",
|
||||
"model_config": {"model": "moonshot-v1-8k", "temperature": 0.4},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
@@ -972,6 +985,8 @@ CONFIG_METADATA_2 = {
|
||||
"model_config": {
|
||||
"model": "glm-4-flash",
|
||||
},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
"Dify": {
|
||||
@@ -1028,6 +1043,7 @@ CONFIG_METADATA_2 = {
|
||||
"timeout": 120,
|
||||
"api_base": "https://api-inference.modelscope.cn/v1",
|
||||
"model_config": {"model": "Qwen/Qwen3-32B", "temperature": 0.4},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
@@ -1040,6 +1056,7 @@ CONFIG_METADATA_2 = {
|
||||
"key": [],
|
||||
"api_base": "https://api.fastgpt.in/api/v1",
|
||||
"timeout": 60,
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
},
|
||||
"Whisper(API)": {
|
||||
@@ -1321,6 +1338,12 @@ CONFIG_METADATA_2 = {
|
||||
"render_type": "checkbox",
|
||||
"hint": "模型支持的模态。如所填写的模型不支持图像,请取消勾选图像。",
|
||||
},
|
||||
"custom_headers": {
|
||||
"description": "自定义添加请求头",
|
||||
"type": "dict",
|
||||
"items": {},
|
||||
"hint": "此处添加的键值对将被合并到 OpenAI SDK 的 default_headers 中,用于自定义 HTTP 请求头。值必须为字符串。",
|
||||
},
|
||||
"custom_extra_body": {
|
||||
"description": "自定义请求体参数",
|
||||
"type": "dict",
|
||||
|
||||
@@ -429,6 +429,10 @@ class LLMRequestSubStage(Stage):
|
||||
logger.error(f"选择的提供商类型无效({type(provider)}),跳过 LLM 请求处理。")
|
||||
return
|
||||
|
||||
streaming_response = self.streaming_response
|
||||
if (enable_streaming := event.get_extra("enable_streaming")) is not None:
|
||||
streaming_response = bool(enable_streaming)
|
||||
|
||||
if event.get_extra("provider_request"):
|
||||
req = event.get_extra("provider_request")
|
||||
assert isinstance(req, ProviderRequest), (
|
||||
@@ -548,7 +552,7 @@ class LLMRequestSubStage(Stage):
|
||||
provider=provider,
|
||||
first_provider_request=req,
|
||||
curr_provider_request=req,
|
||||
streaming=self.streaming_response,
|
||||
streaming=streaming_response,
|
||||
event=event,
|
||||
)
|
||||
await agent_runner.reset(
|
||||
@@ -560,10 +564,10 @@ class LLMRequestSubStage(Stage):
|
||||
),
|
||||
tool_executor=FunctionToolExecutor(),
|
||||
agent_hooks=MAIN_AGENT_HOOKS,
|
||||
streaming=self.streaming_response,
|
||||
streaming=streaming_response,
|
||||
)
|
||||
|
||||
if self.streaming_response:
|
||||
if streaming_response:
|
||||
# 流式响应
|
||||
event.set_result(
|
||||
MessageEventResult()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import sys
|
||||
import binascii
|
||||
from collections.abc import AsyncGenerator
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
@@ -20,11 +21,6 @@ from astrbot.api.platform import AstrBotMessage, At, PlatformMetadata
|
||||
from .client import DiscordBotClient
|
||||
from .components import DiscordEmbed, DiscordView
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override
|
||||
else:
|
||||
from typing_extensions import override
|
||||
|
||||
|
||||
# 自定义Discord视图组件(兼容旧版本)
|
||||
class DiscordViewComponent(BaseMessageComponent):
|
||||
@@ -48,7 +44,6 @@ class DiscordPlatformEvent(AstrMessageEvent):
|
||||
self.client = client
|
||||
self.interaction_followup_webhook = interaction_followup_webhook
|
||||
|
||||
@override
|
||||
async def send(self, message: MessageChain):
|
||||
"""发送消息到Discord平台"""
|
||||
# 解析消息链为 Discord 所需的对象
|
||||
@@ -97,6 +92,21 @@ class DiscordPlatformEvent(AstrMessageEvent):
|
||||
|
||||
await super().send(message)
|
||||
|
||||
async def send_streaming(
|
||||
self, generator: AsyncGenerator[MessageChain, None], use_fallback: bool = False
|
||||
):
|
||||
buffer = None
|
||||
async for chain in generator:
|
||||
if not buffer:
|
||||
buffer = chain
|
||||
else:
|
||||
buffer.chain.extend(chain.chain)
|
||||
if not buffer:
|
||||
return None
|
||||
buffer.squash_plain()
|
||||
await self.send(buffer)
|
||||
return await super().send_streaming(generator, use_fallback)
|
||||
|
||||
async def _get_channel(self) -> discord.abc.Messageable | None:
|
||||
"""获取当前事件对应的频道对象"""
|
||||
try:
|
||||
@@ -183,7 +193,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}",
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -163,6 +163,9 @@ class WebChatAdapter(Platform):
|
||||
_, _, payload = message.raw_message # type: ignore
|
||||
message_event.set_extra("selected_provider", payload.get("selected_provider"))
|
||||
message_event.set_extra("selected_model", payload.get("selected_model"))
|
||||
message_event.set_extra(
|
||||
"enable_streaming", payload.get("enable_streaming", True)
|
||||
)
|
||||
|
||||
self.commit_event(message_event)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import aiohttp
|
||||
@@ -50,6 +51,21 @@ class WeChatPadProMessageEvent(AstrMessageEvent):
|
||||
await self._send_voice(session, comp)
|
||||
await super().send(message)
|
||||
|
||||
async def send_streaming(
|
||||
self, generator: AsyncGenerator[MessageChain, None], use_fallback: bool = False
|
||||
):
|
||||
buffer = None
|
||||
async for chain in generator:
|
||||
if not buffer:
|
||||
buffer = chain
|
||||
else:
|
||||
buffer.chain.extend(chain.chain)
|
||||
if not buffer:
|
||||
return None
|
||||
buffer.squash_plain()
|
||||
await self.send(buffer)
|
||||
return await super().send_streaming(generator, use_fallback)
|
||||
|
||||
async def _send_image(self, session: aiohttp.ClientSession, comp: Image):
|
||||
b64 = await comp.convert_to_base64()
|
||||
raw = self._validate_base64(b64)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()}"
|
||||
|
||||
|
||||
@@ -205,21 +205,165 @@ class ProviderDify(Provider):
|
||||
model=None,
|
||||
**kwargs,
|
||||
):
|
||||
# raise NotImplementedError("This method is not implemented yet.")
|
||||
# 调用 text_chat 模拟流式
|
||||
llm_response = await self.text_chat(
|
||||
prompt=prompt,
|
||||
session_id=session_id,
|
||||
image_urls=image_urls,
|
||||
func_tool=func_tool,
|
||||
contexts=contexts,
|
||||
system_prompt=system_prompt,
|
||||
tool_calls_result=tool_calls_result,
|
||||
)
|
||||
llm_response.is_chunk = True
|
||||
yield llm_response
|
||||
llm_response.is_chunk = False
|
||||
yield llm_response
|
||||
if image_urls is None:
|
||||
image_urls = []
|
||||
session_id = session_id or kwargs.get("user") or "unknown"
|
||||
conversation_id = self.conversation_ids.get(session_id, "")
|
||||
|
||||
files_payload = []
|
||||
for image_url in image_urls:
|
||||
image_path = (
|
||||
await download_image_by_url(image_url)
|
||||
if image_url.startswith("http")
|
||||
else image_url
|
||||
)
|
||||
file_response = await self.api_client.file_upload(
|
||||
image_path,
|
||||
user=session_id,
|
||||
)
|
||||
logger.debug(f"Dify 上传图片响应:{file_response}")
|
||||
if "id" not in file_response:
|
||||
logger.warning(
|
||||
f"上传图片后得到未知的 Dify 响应:{file_response},图片将忽略。",
|
||||
)
|
||||
continue
|
||||
files_payload.append(
|
||||
{
|
||||
"type": "image",
|
||||
"transfer_method": "local_file",
|
||||
"upload_file_id": file_response["id"],
|
||||
},
|
||||
)
|
||||
|
||||
# 获得会话变量
|
||||
payload_vars = self.variables.copy()
|
||||
# 动态变量
|
||||
session_var = await sp.session_get(session_id, "session_variables", default={})
|
||||
payload_vars.update(session_var)
|
||||
payload_vars["system_prompt"] = system_prompt
|
||||
|
||||
try:
|
||||
match self.api_type:
|
||||
case "chat" | "agent" | "chatflow":
|
||||
if not prompt:
|
||||
prompt = "请描述这张图片。"
|
||||
|
||||
accumulated_text = ""
|
||||
async for chunk in self.api_client.chat_messages(
|
||||
inputs={
|
||||
**payload_vars,
|
||||
},
|
||||
query=prompt,
|
||||
user=session_id,
|
||||
conversation_id=conversation_id,
|
||||
files=files_payload,
|
||||
timeout=self.timeout,
|
||||
):
|
||||
logger.debug(f"dify resp chunk: {chunk}")
|
||||
if (
|
||||
chunk["event"] == "message"
|
||||
or chunk["event"] == "agent_message"
|
||||
):
|
||||
accumulated_text += chunk["answer"]
|
||||
if not conversation_id:
|
||||
self.conversation_ids[session_id] = chunk[
|
||||
"conversation_id"
|
||||
]
|
||||
conversation_id = chunk["conversation_id"]
|
||||
|
||||
# Yield streaming chunk
|
||||
llm_response = LLMResponse(
|
||||
role="assistant",
|
||||
result_chain=MessageChain(
|
||||
chain=[Comp.Plain(chunk["answer"])]
|
||||
),
|
||||
is_chunk=True,
|
||||
)
|
||||
yield llm_response
|
||||
|
||||
elif chunk["event"] == "message_end":
|
||||
logger.debug("Dify message end")
|
||||
break
|
||||
elif chunk["event"] == "error":
|
||||
logger.error(f"Dify 出现错误:{chunk}")
|
||||
yield LLMResponse(
|
||||
role="err",
|
||||
completion_text=f"Dify 出现错误 status: {chunk['status']} message: {chunk['message']}",
|
||||
)
|
||||
return
|
||||
|
||||
# Yield final complete result
|
||||
chain = MessageChain(chain=[Comp.Plain(accumulated_text)])
|
||||
yield LLMResponse(
|
||||
role="assistant", result_chain=chain, is_chunk=False
|
||||
)
|
||||
|
||||
case "workflow":
|
||||
workflow_result = None
|
||||
async for chunk in self.api_client.workflow_run(
|
||||
inputs={
|
||||
self.dify_query_input_key: prompt,
|
||||
"astrbot_session_id": session_id,
|
||||
**payload_vars,
|
||||
},
|
||||
user=session_id,
|
||||
files=files_payload,
|
||||
timeout=self.timeout,
|
||||
):
|
||||
match chunk["event"]:
|
||||
case "workflow_started":
|
||||
logger.info(
|
||||
f"Dify 工作流(ID: {chunk['workflow_run_id']})开始运行。",
|
||||
)
|
||||
case "node_finished":
|
||||
logger.debug(
|
||||
f"Dify 工作流节点(ID: {chunk['data']['node_id']} Title: {chunk['data'].get('title', '')})运行结束。",
|
||||
)
|
||||
case "workflow_finished":
|
||||
logger.info(
|
||||
f"Dify 工作流(ID: {chunk['workflow_run_id']})运行结束",
|
||||
)
|
||||
logger.debug(f"Dify 工作流结果:{chunk}")
|
||||
if chunk["data"]["error"]:
|
||||
logger.error(
|
||||
f"Dify 工作流出现错误:{chunk['data']['error']}",
|
||||
)
|
||||
yield LLMResponse(
|
||||
role="err",
|
||||
completion_text=f"Dify 工作流出现错误:{chunk['data']['error']}",
|
||||
)
|
||||
return
|
||||
if (
|
||||
self.workflow_output_key
|
||||
not in chunk["data"]["outputs"]
|
||||
):
|
||||
yield LLMResponse(
|
||||
role="err",
|
||||
completion_text=f"Dify 工作流的输出不包含指定的键名:{self.workflow_output_key}",
|
||||
)
|
||||
return
|
||||
workflow_result = chunk
|
||||
|
||||
if workflow_result:
|
||||
chain = await self.parse_dify_result(workflow_result)
|
||||
yield LLMResponse(
|
||||
role="assistant", result_chain=chain, is_chunk=False
|
||||
)
|
||||
else:
|
||||
logger.warning("Dify 工作流请求结果为空,请查看 Debug 日志。")
|
||||
yield LLMResponse(
|
||||
role="err", completion_text="Dify 工作流请求结果为空"
|
||||
)
|
||||
|
||||
case _:
|
||||
yield LLMResponse(
|
||||
role="err",
|
||||
completion_text=f"未知的 Dify API 类型:{self.api_type}",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Dify 请求失败:{e!s}")
|
||||
yield LLMResponse(role="err", completion_text=f"Dify 请求失败:{e!s}")
|
||||
|
||||
async def parse_dify_result(self, chunk: dict | str) -> MessageChain:
|
||||
if isinstance(chunk, str):
|
||||
|
||||
@@ -43,14 +43,23 @@ class ProviderOpenAIOfficial(Provider):
|
||||
self.api_keys: list = super().get_keys()
|
||||
self.chosen_api_key = self.api_keys[0] if len(self.api_keys) > 0 else None
|
||||
self.timeout = provider_config.get("timeout", 120)
|
||||
self.custom_headers = provider_config.get("custom_headers", {})
|
||||
if isinstance(self.timeout, str):
|
||||
self.timeout = int(self.timeout)
|
||||
|
||||
if not isinstance(self.custom_headers, dict) or not self.custom_headers:
|
||||
self.custom_headers = None
|
||||
else:
|
||||
for key in self.custom_headers:
|
||||
self.custom_headers[key] = str(self.custom_headers[key])
|
||||
|
||||
# 适配 azure openai #332
|
||||
if "api_version" in provider_config:
|
||||
# 使用 azure api
|
||||
self.client = AsyncAzureOpenAI(
|
||||
api_key=self.chosen_api_key,
|
||||
api_version=provider_config.get("api_version", None),
|
||||
default_headers=self.custom_headers,
|
||||
base_url=provider_config.get("api_base", ""),
|
||||
timeout=self.timeout,
|
||||
)
|
||||
@@ -59,6 +68,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
self.client = AsyncOpenAI(
|
||||
api_key=self.chosen_api_key,
|
||||
base_url=provider_config.get("api_base", None),
|
||||
default_headers=self.custom_headers,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
|
||||
|
||||
@@ -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):
|
||||
"""解绑并移除一个插件。
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -125,6 +125,8 @@ class ChatRoute(Route):
|
||||
audio_url = post_data.get("audio_url")
|
||||
selected_provider = post_data.get("selected_provider")
|
||||
selected_model = post_data.get("selected_model")
|
||||
enable_streaming = post_data.get("enable_streaming", True) # 默认为 True
|
||||
|
||||
if not message and not image_url and not audio_url:
|
||||
return (
|
||||
Response()
|
||||
@@ -224,6 +226,7 @@ class ChatRoute(Route):
|
||||
"audio_url": audio_url,
|
||||
"selected_provider": selected_provider,
|
||||
"selected_model": selected_model,
|
||||
"enable_streaming": enable_streaming,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
8
changelogs/v4.5.2.md
Normal 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
5
changelogs/v4.5.3.md
Normal 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
5
changelogs/v4.5.4.md
Normal file
@@ -0,0 +1,5 @@
|
||||
## What's Changed
|
||||
|
||||
1. 修复:Docker 镜像部分依赖问题导致某些情况下无法启动容器的问题;
|
||||
2. 优化:插件卡片样式
|
||||
3. 修复:部分情况下 Windows 一键启动部署时,更新 / 部署失败的问题;
|
||||
3
changelogs/v4.5.5.md
Normal file
3
changelogs/v4.5.5.md
Normal file
@@ -0,0 +1,3 @@
|
||||
## What's Changed
|
||||
|
||||
1. 修复:部署失败
|
||||
3
changelogs/v4.5.6.md
Normal file
3
changelogs/v4.5.6.md
Normal file
@@ -0,0 +1,3 @@
|
||||
## What's Changed
|
||||
|
||||
1. 修复:构建失败
|
||||
3
dashboard/.gitignore
vendored
3
dashboard/.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
node_modules/
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
dist/
|
||||
1
dashboard/src/assets/images/icon-no-shadow.svg
Normal file
1
dashboard/src/assets/images/icon-no-shadow.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 58 KiB |
@@ -1,55 +1,70 @@
|
||||
<template>
|
||||
<v-card class="chat-page-card">
|
||||
<v-card class="chat-page-card" elevation="0" rounded="0">
|
||||
<v-card-text class="chat-page-container">
|
||||
<!-- 遮罩层 (手机端) -->
|
||||
<div class="mobile-overlay" v-if="isMobile && mobileMenuOpen" @click="closeMobileSidebar"></div>
|
||||
|
||||
<div class="chat-layout">
|
||||
<div class="sidebar-panel" :class="{ 'sidebar-collapsed': sidebarCollapsed }"
|
||||
:style="{ 'background-color': isDark ? sidebarCollapsed ? '#1e1e1e' : '#2d2d2d' : sidebarCollapsed ? '#ffffff' : '#f5f5f5' }"
|
||||
<div class="sidebar-panel"
|
||||
:class="{
|
||||
'sidebar-collapsed': sidebarCollapsed && !isMobile,
|
||||
'mobile-sidebar-open': isMobile && mobileMenuOpen,
|
||||
'mobile-sidebar': isMobile
|
||||
}"
|
||||
:style="{ 'background-color': isDark ? sidebarCollapsed ? '#1e1e1e' : '#2d2d2d' : sidebarCollapsed ? '#ffffff' : '#f1f4f9' }"
|
||||
@mouseenter="handleSidebarMouseEnter" @mouseleave="handleSidebarMouseLeave">
|
||||
|
||||
<div style="display: flex; align-items: center; justify-content: center; padding: 16px; padding-bottom: 0px;"
|
||||
v-if="chatboxMode">
|
||||
<img width="50" src="@/assets/images/astrbot_logo_mini.webp" alt="AstrBot Logo">
|
||||
<img width="50" src="@/assets/images/icon-no-shadow.svg" alt="AstrBot Logo">
|
||||
<span v-if="!sidebarCollapsed"
|
||||
style="font-weight: 1000; font-size: 26px; margin-left: 8px;">AstrBot</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="sidebar-collapse-btn-container">
|
||||
<div class="sidebar-collapse-btn-container" v-if="!isMobile">
|
||||
<v-btn icon class="sidebar-collapse-btn" @click="toggleSidebar" variant="text"
|
||||
color="deep-purple">
|
||||
<v-icon>{{ (sidebarCollapsed || (!sidebarCollapsed && sidebarHoverExpanded)) ?
|
||||
'mdi-chevron-right' : 'mdi-chevron-left' }}</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
<!-- 手机端关闭按钮 -->
|
||||
<div class="sidebar-collapse-btn-container" v-if="isMobile">
|
||||
<v-btn icon class="sidebar-collapse-btn" @click="closeMobileSidebar" variant="text"
|
||||
color="deep-purple">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div style="padding: 16px; padding-top: 8px;">
|
||||
<v-btn block variant="text" class="new-chat-btn" @click="newC" :disabled="!currCid"
|
||||
v-if="!sidebarCollapsed" prepend-icon="mdi-plus"
|
||||
v-if="!sidebarCollapsed || isMobile" prepend-icon="mdi-plus"
|
||||
style="background-color: transparent !important; border-radius: 4px;">{{
|
||||
tm('actions.newChat') }}</v-btn>
|
||||
<v-btn icon="mdi-plus" rounded="lg" @click="newC" :disabled="!currCid" v-if="sidebarCollapsed"
|
||||
<v-btn icon="mdi-plus" rounded="lg" @click="newC" :disabled="!currCid" v-if="sidebarCollapsed && !isMobile"
|
||||
elevation="0"></v-btn>
|
||||
</div>
|
||||
<div v-if="!sidebarCollapsed">
|
||||
<div v-if="!sidebarCollapsed || isMobile">
|
||||
<v-divider class="mx-4"></v-divider>
|
||||
</div>
|
||||
|
||||
|
||||
<div style="overflow-y: auto; flex-grow: 1;" :class="{ 'fade-in': sidebarHoverExpanded }"
|
||||
v-if="!sidebarCollapsed">
|
||||
v-if="!sidebarCollapsed || isMobile">
|
||||
<v-card v-if="conversations.length > 0" flat style="background-color: transparent;">
|
||||
<v-list density="compact" nav class="conversation-list"
|
||||
style="background-color: transparent;" v-model:selected="selectedConversations"
|
||||
@update:selected="getConversationMessages">
|
||||
<v-list-item v-for="(item, i) in conversations" :key="item.cid" :value="item.cid"
|
||||
rounded="lg" class="conversation-item" active-color="secondary">
|
||||
<v-list-item-title v-if="!sidebarCollapsed" class="conversation-title">{{ item.title
|
||||
<v-list-item-title v-if="!sidebarCollapsed || isMobile" class="conversation-title">{{ item.title
|
||||
|| tm('conversation.newConversation') }}</v-list-item-title>
|
||||
<v-list-item-subtitle v-if="!sidebarCollapsed" class="timestamp">{{
|
||||
<v-list-item-subtitle v-if="!sidebarCollapsed || isMobile" class="timestamp">{{
|
||||
formatDate(item.updated_at)
|
||||
}}</v-list-item-subtitle>
|
||||
}}</v-list-item-subtitle>
|
||||
|
||||
<template v-if="!sidebarCollapsed" v-slot:append>
|
||||
<template v-if="!sidebarCollapsed || isMobile" v-slot:append>
|
||||
<div class="conversation-actions">
|
||||
<v-btn icon="mdi-pencil" size="x-small" variant="text"
|
||||
class="edit-title-btn"
|
||||
@@ -66,7 +81,7 @@
|
||||
<v-fade-transition>
|
||||
<div class="no-conversations" v-if="conversations.length === 0">
|
||||
<v-icon icon="mdi-message-text-outline" size="large" color="grey-lighten-1"></v-icon>
|
||||
<div class="no-conversations-text" v-if="!sidebarCollapsed || sidebarHoverExpanded">
|
||||
<div class="no-conversations-text" v-if="!sidebarCollapsed || sidebarHoverExpanded || isMobile">
|
||||
{{ tm('conversation.noHistory') }}</div>
|
||||
</div>
|
||||
</v-fade-transition>
|
||||
@@ -78,12 +93,17 @@
|
||||
<div class="chat-content-panel">
|
||||
|
||||
<div class="conversation-header fade-in">
|
||||
<div v-if="currCid && getCurrentConversation">
|
||||
<!-- 手机端菜单按钮 -->
|
||||
<v-btn icon class="mobile-menu-btn" @click="toggleMobileSidebar" v-if="isMobile" variant="text">
|
||||
<v-icon>mdi-menu</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<!-- <div v-if="currCid && getCurrentConversation">
|
||||
<h3
|
||||
style="max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
||||
{{ getCurrentConversation.title || tm('conversation.newConversation') }}</h3>
|
||||
<span style="font-size: 12px;">{{ formatDate(getCurrentConversation.updated_at) }}</span>
|
||||
</div>
|
||||
</div> -->
|
||||
<div class="conversation-header-actions">
|
||||
<!-- router 推送到 /chatbox -->
|
||||
<v-tooltip :text="tm('actions.fullscreen')" v-if="!chatboxMode">
|
||||
@@ -117,7 +137,6 @@
|
||||
</v-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<v-divider v-if="currCid && getCurrentConversation" class="conversation-divider"></v-divider>
|
||||
|
||||
<MessageList v-if="messages && messages.length > 0" :messages="messages" :isDark="isDark"
|
||||
:isStreaming="isStreaming || isConvRunning" @openImagePreview="openImagePreview"
|
||||
@@ -146,17 +165,30 @@
|
||||
|
||||
<!-- 输入区域 -->
|
||||
<div class="input-area fade-in">
|
||||
<div
|
||||
<div class="input-container"
|
||||
style="width: 85%; max-width: 900px; margin: 0 auto; border: 1px solid #e0e0e0; border-radius: 24px;">
|
||||
<textarea id="input-field" v-model="prompt" @keydown="handleInputKeyDown"
|
||||
:disabled="isStreaming" @click:clear="clearMessage"
|
||||
placeholder="Ask AstrBot..."
|
||||
:disabled="isStreaming" @click:clear="clearMessage" placeholder="Ask AstrBot..."
|
||||
style="width: 100%; resize: none; outline: none; border: 1px solid var(--v-theme-border); border-radius: 12px; padding: 8px 16px; min-height: 40px; font-family: inherit; font-size: 16px; background-color: var(--v-theme-surface);"></textarea>
|
||||
<div
|
||||
style="display: flex; justify-content: space-between; align-items: center; padding: 0px 8px;">
|
||||
<div style="display: flex; justify-content: flex-start; margin-top: 4px;">
|
||||
style="display: flex; justify-content: space-between; align-items: center; padding: 0px 12px;">
|
||||
<div
|
||||
style="display: flex; justify-content: flex-start; margin-top: 4px; align-items: center; gap: 8px;">
|
||||
<!-- 选择提供商和模型 -->
|
||||
<ProviderModelSelector ref="providerModelSelector" />
|
||||
<!-- 流式响应开关 -->
|
||||
<v-tooltip
|
||||
:text="enableStreaming ? tm('streaming.enabled') : tm('streaming.disabled')"
|
||||
location="top">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-chip v-bind="props" @click="toggleStreaming" size="x-small"
|
||||
class="streaming-toggle-chip">
|
||||
<v-icon start :icon="enableStreaming ? 'mdi-flash' : 'mdi-flash-off'"
|
||||
size="small"></v-icon>
|
||||
{{ enableStreaming ? tm('streaming.on') : tm('streaming.off') }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
<div
|
||||
style="display: flex; justify-content: flex-end; margin-top: 8px; align-items: center;">
|
||||
@@ -175,7 +207,6 @@
|
||||
class="send-btn" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 附件预览区 -->
|
||||
@@ -242,6 +273,7 @@ import ProviderModelSelector from '@/components/chat/ProviderModelSelector.vue';
|
||||
import MessageList from '@/components/chat/MessageList.vue';
|
||||
import 'highlight.js/styles/github.css';
|
||||
import { useToast } from '@/utils/toast';
|
||||
import { useTheme } from 'vuetify';
|
||||
|
||||
export default {
|
||||
name: 'ChatPage',
|
||||
@@ -258,10 +290,12 @@ export default {
|
||||
}, setup() {
|
||||
const { t } = useI18n();
|
||||
const { tm } = useModuleI18n('features/chat');
|
||||
const theme = useTheme();
|
||||
|
||||
return {
|
||||
t,
|
||||
tm,
|
||||
theme,
|
||||
router,
|
||||
ref
|
||||
};
|
||||
@@ -287,7 +321,7 @@ export default {
|
||||
// Ctrl键长按相关变量
|
||||
ctrlKeyDown: false,
|
||||
ctrlKeyTimer: null,
|
||||
ctrlKeyLongPressThreshold: 300, // 长按阈值,单位毫秒
|
||||
ctrlKeyLongPressThreshold: 300, // 长按阈值,单位毫秒
|
||||
|
||||
mediaCache: {}, // Add a cache to store media blobs
|
||||
|
||||
@@ -313,6 +347,13 @@ export default {
|
||||
|
||||
isToastedRunningInfo: false, // To avoid multiple toasts
|
||||
activeSSECount: 0, // Track number of active SSE connections
|
||||
|
||||
// 流式响应开关
|
||||
enableStreaming: true, // 默认开启流式响应
|
||||
|
||||
// 手机端相关变量
|
||||
isMobile: false,
|
||||
mobileMenuOpen: false,
|
||||
}
|
||||
},
|
||||
|
||||
@@ -391,6 +432,18 @@ export default {
|
||||
this.sidebarCollapsed = true; // 默认折叠状态
|
||||
}
|
||||
|
||||
// 从 localStorage 读取流式响应开关状态,默认为 true(开启)
|
||||
const savedStreamingState = localStorage.getItem('enableStreaming');
|
||||
if (savedStreamingState !== null) {
|
||||
this.enableStreaming = JSON.parse(savedStreamingState);
|
||||
} else {
|
||||
this.enableStreaming = true; // 默认开启
|
||||
}
|
||||
|
||||
// 检测是否为手机端
|
||||
this.checkMobile();
|
||||
window.addEventListener('resize', this.checkMobile);
|
||||
|
||||
// 设置输入框标签
|
||||
this.inputFieldLabel = this.tm('input.chatPrompt');
|
||||
this.getConversations();
|
||||
@@ -413,6 +466,9 @@ export default {
|
||||
beforeUnmount() {
|
||||
// 移除keyup事件监听
|
||||
document.removeEventListener('keyup', this.handleInputKeyUp);
|
||||
|
||||
// 移除resize事件监听
|
||||
window.removeEventListener('resize', this.checkMobile);
|
||||
|
||||
// 清除悬停定时器
|
||||
if (this.sidebarHoverTimer) {
|
||||
@@ -427,6 +483,28 @@ export default {
|
||||
const customizer = useCustomizerStore();
|
||||
const newTheme = customizer.uiTheme === 'PurpleTheme' ? 'PurpleThemeDark' : 'PurpleTheme';
|
||||
customizer.SET_UI_THEME(newTheme);
|
||||
this.theme.global.name.value = newTheme;
|
||||
},
|
||||
// 检测是否为手机端
|
||||
checkMobile() {
|
||||
this.isMobile = window.innerWidth <= 768;
|
||||
// 如果切换到桌面端,关闭手机菜单
|
||||
if (!this.isMobile) {
|
||||
this.mobileMenuOpen = false;
|
||||
}
|
||||
},
|
||||
// 切换手机端菜单
|
||||
toggleMobileSidebar() {
|
||||
this.mobileMenuOpen = !this.mobileMenuOpen;
|
||||
},
|
||||
// 关闭手机端菜单
|
||||
closeMobileSidebar() {
|
||||
this.mobileMenuOpen = false;
|
||||
},
|
||||
// 切换流式响应
|
||||
toggleStreaming() {
|
||||
this.enableStreaming = !this.enableStreaming;
|
||||
localStorage.setItem('enableStreaming', JSON.stringify(this.enableStreaming));
|
||||
},
|
||||
// 切换侧边栏折叠状态
|
||||
toggleSidebar() {
|
||||
@@ -441,7 +519,7 @@ export default {
|
||||
|
||||
// 侧边栏鼠标悬停处理
|
||||
handleSidebarMouseEnter() {
|
||||
if (!this.sidebarCollapsed) return;
|
||||
if (!this.sidebarCollapsed || this.isMobile) return;
|
||||
|
||||
this.sidebarHovered = true;
|
||||
|
||||
@@ -668,6 +746,11 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
// 手机端关闭侧边栏
|
||||
if (this.isMobile) {
|
||||
this.closeMobileSidebar();
|
||||
}
|
||||
|
||||
axios.get('/api/chat/get_conversation?conversation_id=' + cid[0]).then(async response => {
|
||||
this.currCid = cid[0];
|
||||
// Update the selected conversation in the sidebar
|
||||
@@ -748,6 +831,10 @@ export default {
|
||||
this.currCid = '';
|
||||
this.selectedConversations = []; // 清除选中状态
|
||||
this.messages = [];
|
||||
// 手机端关闭侧边栏
|
||||
if (this.isMobile) {
|
||||
this.closeMobileSidebar();
|
||||
}
|
||||
if (this.$route.path.startsWith('/chatbox')) {
|
||||
this.$router.push('/chatbox');
|
||||
} else {
|
||||
@@ -867,7 +954,8 @@ export default {
|
||||
image_url: imageNamesToSend,
|
||||
audio_url: audioNameToSend ? [audioNameToSend] : [],
|
||||
selected_provider: selectedProviderId,
|
||||
selected_model: selectedModelName
|
||||
selected_model: selectedModelName,
|
||||
enable_streaming: this.enableStreaming
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1101,6 +1189,17 @@ export default {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 流式响应开关芯片样式 */
|
||||
.streaming-toggle-chip {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.streaming-toggle-chip:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-size: 28px;
|
||||
margin-bottom: 16px;
|
||||
@@ -1141,7 +1240,6 @@ export default {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05) !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -1166,7 +1264,7 @@ export default {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.05);
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.04);
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
position: relative;
|
||||
@@ -1188,6 +1286,77 @@ export default {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* 手机端菜单按钮 */
|
||||
.mobile-menu-btn {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* 手机端遮罩层 */
|
||||
.mobile-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 999;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
/* 手机端侧边栏 */
|
||||
.mobile-sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
max-width: 280px !important;
|
||||
min-width: 280px !important;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.mobile-sidebar-open {
|
||||
transform: translateX(0) !important;
|
||||
}
|
||||
|
||||
/* 手机端样式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar-panel:not(.mobile-sidebar) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-content-panel {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 手机端去掉容器padding */
|
||||
.chat-page-container {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* 手机端输入区域样式 */
|
||||
.input-area {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
margin: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
border-left: none !important;
|
||||
border-right: none !important;
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
#input-field {
|
||||
border-radius: 0 !important;
|
||||
border-left: none !important;
|
||||
border-right: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 侧边栏折叠按钮 */
|
||||
.sidebar-collapse-btn-container {
|
||||
margin: 16px;
|
||||
@@ -1267,25 +1436,12 @@ export default {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-chips {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.status-chips .v-chip {
|
||||
.v-chip {
|
||||
flex: 1 1 0;
|
||||
justify-content: center;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
font-size: 12px;
|
||||
height: 24px !important;
|
||||
}
|
||||
|
||||
.no-conversations {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<v-avatar class="bot-avatar" size="36">
|
||||
<v-progress-circular :index="index" v-if="isStreaming && index === messages.length - 1" indeterminate size="28"
|
||||
width="2"></v-progress-circular>
|
||||
<span v-else-if="messages[index - 1]?.content.type !== 'bot'" class="text-h2">✨</span>
|
||||
<v-icon v-else-if="messages[index - 1]?.content.type !== 'bot'" size="64" color="#8fb6d2">mdi-star-four-points-small</v-icon>
|
||||
</v-avatar>
|
||||
<div class="bot-message-content">
|
||||
<div class="message-bubble bot-bubble">
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- 选择提供商和模型按钮 -->
|
||||
<v-btn class="text-none" variant="tonal" rounded="xl" size="small"
|
||||
<v-chip class="text-none" variant="tonal" size="x-small"
|
||||
v-if="selectedProviderId && selectedModelName" @click="openDialog">
|
||||
{{ selectedProviderId }} / {{ selectedModelName }}
|
||||
</v-btn>
|
||||
<v-btn variant="tonal" rounded="xl" size="small" v-else @click="openDialog">
|
||||
选择模型
|
||||
</v-btn>
|
||||
</v-chip>
|
||||
|
||||
<!-- 选择提供商和模型对话框 -->
|
||||
<v-dialog v-model="showDialog" max-width="800" persistent>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
<template>
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<span v-if="!modelValue || (Array.isArray(modelValue) && modelValue.length === 0)"
|
||||
style="color: rgb(var(--v-theme-primaryText));">
|
||||
未选择
|
||||
</span>
|
||||
<div v-else class="d-flex flex-wrap gap-1">
|
||||
<v-chip
|
||||
v-for="name in modelValue"
|
||||
:key="name"
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
closable
|
||||
@click:close="removeKnowledgeBase(name)">
|
||||
{{ name }}
|
||||
</v-chip>
|
||||
<div class="d-flex align-center justify-space-between" style="gap: 8px;">
|
||||
<div style="flex: 1; min-width: 0; overflow: hidden;">
|
||||
<span v-if="!modelValue || (Array.isArray(modelValue) && modelValue.length === 0)"
|
||||
style="color: rgb(var(--v-theme-primaryText));">
|
||||
未选择
|
||||
</span>
|
||||
<div v-else class="d-flex flex-wrap gap-1">
|
||||
<v-chip
|
||||
v-for="name in modelValue"
|
||||
:key="name"
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
closable
|
||||
@click:close="removeKnowledgeBase(name)"
|
||||
style="max-width: 100%;">
|
||||
<span class="text-truncate" style="max-width: 200px;">{{ name }}</span>
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
<v-btn size="small" color="primary" variant="tonal" @click="openDialog">
|
||||
<v-btn size="small" color="primary" variant="tonal" @click="openDialog" style="flex-shrink: 0;">
|
||||
{{ buttonText }}
|
||||
</v-btn>
|
||||
</div>
|
||||
@@ -220,4 +223,11 @@ function goToKnowledgeBasePage() {
|
||||
.gap-1 {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.text-truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -70,10 +70,6 @@ const formatTitle = (title: string) => {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.logo-image img:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
290
dashboard/src/components/shared/SidebarCustomizer.vue
Normal file
290
dashboard/src/components/shared/SidebarCustomizer.vue
Normal 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>
|
||||
135
dashboard/src/components/shared/UninstallConfirmDialog.vue
Normal file
135
dashboard/src/components/shared/UninstallConfirmDialog.vue
Normal 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>
|
||||
@@ -18,5 +18,6 @@
|
||||
"refresh": "Refresh",
|
||||
"submit": "Submit",
|
||||
"reset": "Reset",
|
||||
"clear": "Clear"
|
||||
"clear": "Clear",
|
||||
"save": "Save"
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
"login": "Login",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"defaultHint": "Default username and password: astrbot",
|
||||
"logo": {
|
||||
"title": "AstrBot Dashboard",
|
||||
"subtitle": "Welcome"
|
||||
|
||||
@@ -57,6 +57,12 @@
|
||||
"voiceRecord": "Record Voice",
|
||||
"pasteImage": "Paste Image"
|
||||
},
|
||||
"streaming": {
|
||||
"enabled": "Streaming enabled",
|
||||
"disabled": "Streaming disabled",
|
||||
"on": "Stream",
|
||||
"off": "Normal"
|
||||
},
|
||||
"connection": {
|
||||
"title": "Connection Status Notice",
|
||||
"message": "The system detected that the chat connection needs to be re-established.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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!"
|
||||
}
|
||||
@@ -18,5 +18,6 @@
|
||||
"refresh": "刷新",
|
||||
"submit": "提交",
|
||||
"reset": "重置",
|
||||
"clear": "清空"
|
||||
"clear": "清空",
|
||||
"save": "保存"
|
||||
}
|
||||
@@ -2,8 +2,9 @@
|
||||
"login": "登录",
|
||||
"username": "用户名",
|
||||
"password": "密码",
|
||||
"defaultHint": "默认账户和密码均为:astrbot",
|
||||
"logo": {
|
||||
"title": "AstrBot 仪表盘",
|
||||
"title": "AstrBot WebUI",
|
||||
"subtitle": "欢迎使用"
|
||||
},
|
||||
"theme": {
|
||||
|
||||
@@ -57,6 +57,12 @@
|
||||
"voiceRecord": "录制语音",
|
||||
"pasteImage": "粘贴图片"
|
||||
},
|
||||
"streaming": {
|
||||
"enabled": "流式响应已开启",
|
||||
"disabled": "流式响应已关闭",
|
||||
"on": "流式",
|
||||
"off": "普通"
|
||||
},
|
||||
"connection": {
|
||||
"title": "连接状态提醒",
|
||||
"message": "系统检测到聊天连接需要重新建立。",
|
||||
|
||||
@@ -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": "安装插件",
|
||||
|
||||
@@ -19,5 +19,15 @@
|
||||
"subtitle": "如果您遇到数据兼容性问题,可以手动启动数据库迁移助手",
|
||||
"button": "启动迁移助手"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"title": "侧边栏",
|
||||
"customize": {
|
||||
"title": "自定义侧边栏",
|
||||
"subtitle": "拖拽以调整模块顺序,或者将模块移入/移出\"更多功能\"分组。设置仅保存在浏览器本地。",
|
||||
"reset": "恢复默认",
|
||||
"mainItems": "主要模块",
|
||||
"moreItems": "更多功能"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,5 +20,6 @@
|
||||
"invalid_date": "请输入有效的日期",
|
||||
"date_range": "日期范围无效",
|
||||
"upload_failed": "文件上传失败",
|
||||
"network_error": "网络连接错误,请重试"
|
||||
"network_error": "网络连接错误,请重试",
|
||||
"operation_cannot_be_undone": "⚠️ 此操作无法撤销,请谨慎选择!"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { RouterView, useRoute } from 'vue-router';
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import axios from 'axios';
|
||||
import VerticalSidebarVue from './vertical-sidebar/VerticalSidebar.vue';
|
||||
import VerticalHeaderVue from './vertical-header/VerticalHeader.vue';
|
||||
@@ -8,6 +8,12 @@ import MigrationDialog from '@/components/shared/MigrationDialog.vue';
|
||||
import { useCustomizerStore } from '@/stores/customizer';
|
||||
|
||||
const customizer = useCustomizerStore();
|
||||
const route = useRoute();
|
||||
|
||||
// 计算是否在聊天页面(非全屏模式)
|
||||
const isChatPage = computed(() => {
|
||||
return route.path.startsWith('/chat');
|
||||
});
|
||||
const migrationDialog = ref<InstanceType<typeof MigrationDialog> | null>(null);
|
||||
|
||||
// 检查是否需要迁移
|
||||
@@ -45,7 +51,10 @@ onMounted(() => {
|
||||
<VerticalHeaderVue />
|
||||
<VerticalSidebarVue />
|
||||
<v-main>
|
||||
<v-container fluid class="page-wrapper" style="height: calc(100% - 8px)">
|
||||
<v-container fluid class="page-wrapper" :style="{
|
||||
height: 'calc(100% - 8px)',
|
||||
padding: isChatPage ? '0' : undefined
|
||||
}">
|
||||
<div style="height: 100%;">
|
||||
<RouterView />
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useCommonStore } from '@/stores/common';
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import { useI18n } from '@/i18n/composables';
|
||||
import { router } from '@/router';
|
||||
import { useTheme } from 'vuetify';
|
||||
|
||||
// 配置markdown-it,默认安全设置
|
||||
const md = new MarkdownIt({
|
||||
@@ -20,6 +21,7 @@ const md = new MarkdownIt({
|
||||
});
|
||||
|
||||
const customizer = useCustomizerStore();
|
||||
const theme = useTheme();
|
||||
const { t } = useI18n();
|
||||
let dialog = ref(false);
|
||||
let accountWarning = ref(false)
|
||||
@@ -276,7 +278,9 @@ function updateDashboard() {
|
||||
}
|
||||
|
||||
function toggleDarkMode() {
|
||||
customizer.SET_UI_THEME(customizer.uiTheme === 'PurpleThemeDark' ? 'PurpleTheme' : 'PurpleThemeDark');
|
||||
const newTheme = customizer.uiTheme === 'PurpleThemeDark' ? 'PurpleTheme' : 'PurpleThemeDark';
|
||||
customizer.SET_UI_THEME(newTheme);
|
||||
theme.global.name.value = newTheme;
|
||||
}
|
||||
|
||||
getVersion();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -18,24 +18,38 @@ setupI18n().then(() => {
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(router);
|
||||
app.use(createPinia());
|
||||
const pinia = createPinia();
|
||||
app.use(pinia);
|
||||
app.use(print);
|
||||
app.use(VueApexCharts);
|
||||
app.use(vuetify);
|
||||
app.use(confirmPlugin);
|
||||
app.mount('#app');
|
||||
|
||||
// 挂载后同步 Vuetify 主题
|
||||
import('./stores/customizer').then(({ useCustomizerStore }) => {
|
||||
const customizer = useCustomizerStore(pinia);
|
||||
vuetify.theme.global.name.value = customizer.uiTheme;
|
||||
});
|
||||
}).catch(error => {
|
||||
console.error('❌ 新i18n系统初始化失败:', error);
|
||||
|
||||
// 即使i18n初始化失败,也要挂载应用(使用回退机制)
|
||||
const app = createApp(App);
|
||||
app.use(router);
|
||||
app.use(createPinia());
|
||||
const pinia = createPinia();
|
||||
app.use(pinia);
|
||||
app.use(print);
|
||||
app.use(VueApexCharts);
|
||||
app.use(vuetify);
|
||||
app.use(confirmPlugin);
|
||||
app.mount('#app');
|
||||
|
||||
// 挂载后同步 Vuetify 主题
|
||||
import('./stores/customizer').then(({ useCustomizerStore }) => {
|
||||
const customizer = useCustomizerStore(pinia);
|
||||
vuetify.theme.global.name.value = customizer.uiTheme;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ html {
|
||||
|
||||
.page-wrapper {
|
||||
min-height: calc(100vh - 100px);
|
||||
padding: 15px;
|
||||
padding: 8px;
|
||||
border-radius: $border-radius-root;
|
||||
background: rgb(var(--v-theme-containerBg));
|
||||
}
|
||||
|
||||
99
dashboard/src/utils/sidebarCustomization.js
Normal file
99
dashboard/src/utils/sidebarCustomization.js
Normal 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;
|
||||
}
|
||||
@@ -10,6 +10,6 @@ import Chat from '@/components/chat/Chat.vue'
|
||||
|
||||
<style scoped>
|
||||
.chat-container {
|
||||
height: calc(100vh - 88px)
|
||||
height: calc(100vh - 60px)
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import AuthLogin from '../authForms/AuthLogin.vue';
|
||||
import Logo from '@/components/shared/Logo.vue';
|
||||
import LanguageSwitcher from '@/components/shared/LanguageSwitcher.vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useRouter } from 'vue-router';
|
||||
import {useCustomizerStore} from "@/stores/customizer";
|
||||
import { useCustomizerStore } from "@/stores/customizer";
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import { useTheme } from 'vuetify';
|
||||
|
||||
const cardVisible = ref(false);
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const customizer = useCustomizerStore();
|
||||
const { tm: t } = useModuleI18n('features/auth');
|
||||
const theme = useTheme();
|
||||
|
||||
// 主题切换函数
|
||||
function toggleTheme() {
|
||||
customizer.SET_UI_THEME(
|
||||
customizer.uiTheme === 'PurpleThemeDark' ? 'PurpleTheme' : 'PurpleThemeDark'
|
||||
);
|
||||
const newTheme = customizer.uiTheme === 'PurpleThemeDark' ? 'PurpleTheme' : 'PurpleThemeDark';
|
||||
customizer.SET_UI_THEME(newTheme);
|
||||
theme.global.name.value = newTheme;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -27,7 +28,7 @@ onMounted(() => {
|
||||
router.push(authStore.returnUrl || '/');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 添加一个小延迟以获得更好的动画效果
|
||||
setTimeout(() => {
|
||||
cardVisible.value = true;
|
||||
@@ -36,139 +37,17 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="useCustomizerStore().uiTheme==='PurpleTheme'" class="login-page-container">
|
||||
<div class="login-background"></div>
|
||||
|
||||
<div class="login-container">
|
||||
<!-- 桌面端:卡片样式 -->
|
||||
<v-card
|
||||
v-if="!$vuetify.display.xs"
|
||||
variant="outlined"
|
||||
class="login-card"
|
||||
:class="{ 'card-visible': cardVisible }"
|
||||
>
|
||||
<v-card-text class="pa-10">
|
||||
<div class="logo-wrapper">
|
||||
<Logo :title="t('logo.title')" :subtitle="t('logo.subtitle')" />
|
||||
</div>
|
||||
<div class="divider-container">
|
||||
<v-divider class="custom-divider"></v-divider>
|
||||
</div>
|
||||
<AuthLogin />
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- 移动端:全屏样式 -->
|
||||
<div
|
||||
v-else
|
||||
class="mobile-login-container"
|
||||
:class="{ 'mobile-visible': cardVisible }"
|
||||
>
|
||||
<div class="mobile-content">
|
||||
<div class="logo-wrapper">
|
||||
<Logo :title="t('logo.title')" :subtitle="t('logo.subtitle')" />
|
||||
</div>
|
||||
<div class="divider-container">
|
||||
<v-divider class="custom-divider"></v-divider>
|
||||
</div>
|
||||
<AuthLogin />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 悬浮式圆角工具栏 -->
|
||||
<v-card
|
||||
class="floating-toolbar"
|
||||
:class="{ 'toolbar-visible': cardVisible }"
|
||||
elevation="8"
|
||||
rounded="xl"
|
||||
>
|
||||
<v-card-text class="pa-2">
|
||||
<div class="login-page-container">
|
||||
<v-card class="login-card" elevation="1">
|
||||
<v-card-title>
|
||||
<div class="d-flex justify-space-between align-center w-100">
|
||||
<img width="80" src="@/assets/images/icon-no-shadow.svg" alt="AstrBot Logo">
|
||||
<div class="d-flex align-center gap-1">
|
||||
<LanguageSwitcher />
|
||||
<v-divider vertical class="mx-1" style="height: 24px !important; opacity: 0.7 !important; align-self: center !important; border-color: rgba(94, 53, 177, 0.4) !important;"></v-divider>
|
||||
<v-btn
|
||||
@click="toggleTheme"
|
||||
class="theme-toggle-btn"
|
||||
icon
|
||||
variant="text"
|
||||
size="small"
|
||||
>
|
||||
<v-icon
|
||||
size="18"
|
||||
:color="useCustomizerStore().uiTheme === 'PurpleTheme' ? '#5e35b1' : '#d7c5fa'"
|
||||
>
|
||||
mdi-weather-night
|
||||
</v-icon>
|
||||
<v-tooltip activator="parent" location="top">
|
||||
{{ t('theme.switchToDark') }}
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="login-page-container-dark">
|
||||
<div class="login-background-dark"></div>
|
||||
|
||||
<div class="login-container">
|
||||
<!-- 桌面端:卡片样式 -->
|
||||
<v-card
|
||||
v-if="!$vuetify.display.xs"
|
||||
variant="outlined"
|
||||
class="login-card"
|
||||
:class="{ 'card-visible': cardVisible }"
|
||||
>
|
||||
<v-card-text class="pa-10">
|
||||
<div class="logo-wrapper">
|
||||
<Logo :title="t('logo.title')" :subtitle="t('logo.subtitle')" />
|
||||
</div>
|
||||
<div class="divider-container">
|
||||
<v-divider class="custom-divider"></v-divider>
|
||||
</div>
|
||||
<AuthLogin />
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- 移动端:全屏样式 -->
|
||||
<div
|
||||
v-else
|
||||
class="mobile-login-container"
|
||||
:class="{ 'mobile-visible': cardVisible }"
|
||||
>
|
||||
<div class="mobile-content">
|
||||
<div class="logo-wrapper">
|
||||
<Logo :title="t('logo.title')" :subtitle="t('logo.subtitle')" />
|
||||
</div>
|
||||
<div class="divider-container">
|
||||
<v-divider class="custom-divider"></v-divider>
|
||||
</div>
|
||||
<AuthLogin />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 悬浮式圆角工具栏 -->
|
||||
<v-card
|
||||
class="floating-toolbar"
|
||||
:class="{ 'toolbar-visible': cardVisible }"
|
||||
elevation="8"
|
||||
rounded="xl"
|
||||
>
|
||||
<v-card-text class="pa-2">
|
||||
<div class="d-flex align-center gap-1">
|
||||
<LanguageSwitcher />
|
||||
<v-divider vertical class="mx-1" style="height: 24px !important; opacity: 0.9 !important; align-self: center !important; border-color: rgba(180, 148, 246, 0.8) !important;"></v-divider>
|
||||
<v-btn
|
||||
@click="toggleTheme"
|
||||
class="theme-toggle-btn"
|
||||
icon
|
||||
variant="text"
|
||||
size="small"
|
||||
>
|
||||
<v-icon
|
||||
size="18"
|
||||
:color="useCustomizerStore().uiTheme === 'PurpleTheme' ? '#5e35b1' : '#d7c5fa'"
|
||||
>
|
||||
<v-divider vertical class="mx-1"
|
||||
style="height: 24px !important; opacity: 0.9 !important; align-self: center !important; border-color: rgba(180, 148, 246, 0.8) !important;"></v-divider>
|
||||
<v-btn @click="toggleTheme" class="theme-toggle-btn" icon variant="text" size="small">
|
||||
<v-icon size="18" :color="useCustomizerStore().uiTheme === 'PurpleTheme' ? '#5e35b1' : '#d7c5fa'">
|
||||
mdi-white-balance-sunny
|
||||
</v-icon>
|
||||
<v-tooltip activator="parent" location="top">
|
||||
@@ -176,288 +55,31 @@ onMounted(() => {
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-2" style="font-size: 26px;">{{ t('logo.title') }}</div>
|
||||
<div class="mt-2 ml-2" style="font-size: 14px; color: grey;">{{ t('logo.subtitle') }}</div>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<AuthLogin />
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.login-page-container {
|
||||
background-color: rgb(var(--v-theme-containerBg));
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
background:
|
||||
linear-gradient(-45deg,
|
||||
#faf9f7 0%,
|
||||
#f9f2f1 25%,
|
||||
#f1f9f9 50%,
|
||||
#f9f3f7 75%,
|
||||
#faf9f7 100%
|
||||
);
|
||||
background-size: 400% 400%;
|
||||
animation: gradientShift 15s ease infinite;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-page-container-dark {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
background:
|
||||
linear-gradient(-45deg,
|
||||
#1e1f21 0%,
|
||||
#221e25 25%,
|
||||
#1e2225 50%,
|
||||
#221f23 75%,
|
||||
#1e1f21 100%
|
||||
);
|
||||
background-size: 400% 400%;
|
||||
animation: gradientShift 15s ease infinite;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@keyframes gradientShift {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.login-background {
|
||||
position: absolute;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
background: radial-gradient(circle, rgba(94, 53, 177, 0.02) 0%, rgba(94, 53, 177, 0.03) 70%);
|
||||
z-index: 0;
|
||||
animation: rotate 60s linear infinite;
|
||||
}
|
||||
|
||||
.login-background-dark {
|
||||
position: absolute;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
background: radial-gradient(circle, rgba(114, 46, 209, 0.03) 0%, rgba(114, 46, 209, 0.04) 70%);
|
||||
z-index: 0;
|
||||
animation: rotate 60s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.login-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.floating-toolbar {
|
||||
background: #f8f6fc !important;
|
||||
border: 1px solid rgba(94, 53, 177, 0.15) !important;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08) !important;
|
||||
backdrop-filter: blur(10px);
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
transition: transform 0.6s ease 0.2s, opacity 0.6s ease 0.2s, border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
min-width: auto !important;
|
||||
width: fit-content;
|
||||
|
||||
&.toolbar-visible {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(158, 126, 222, 0.99) !important;
|
||||
box-shadow: 0 12px 40px rgba(175, 145, 230, 0.741) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.login-page-container-dark .floating-toolbar {
|
||||
background: #2a2733 !important;
|
||||
border: 1px solid rgba(110, 60, 180, 0.692) !important;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3) !important;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(160, 118, 219, 0.782) !important;
|
||||
box-shadow: 0 12px 40px rgba(99, 44, 175, 0.462) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-toggle-btn {
|
||||
transition: all 0.3s ease;
|
||||
border-radius: 50% !important;
|
||||
min-width: 32px !important;
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
background: rgba(94, 53, 177, 0.08) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.login-page-container-dark .theme-toggle-btn:hover {
|
||||
background: rgba(114, 46, 209, 0.12) !important;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
max-width: 520px;
|
||||
width: 90%;
|
||||
position: relative;
|
||||
color: var(--v-theme-primaryText) !important;
|
||||
border-radius: 16px !important;
|
||||
border: 1px solid rgba(94, 53, 177, 0.15) !important;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08) !important;
|
||||
background: #f8f6fc !important;
|
||||
backdrop-filter: blur(10px);
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
transition: transform 0.5s ease, opacity 0.5s ease, border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
z-index: 1;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: calc(100% + 4px);
|
||||
height: calc(100% + 4px);
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 18px;
|
||||
border: 2px solid rgba(94, 53, 177, 0);
|
||||
transition: border-color 0.3s ease;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
&.card-visible {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(158, 126, 222, 0.99) !important;
|
||||
box-shadow: 0 12px 40px rgba(175, 145, 230, 0.741) !important;
|
||||
transform: translateY(-2px);
|
||||
|
||||
&::before {
|
||||
border-color: rgba(156, 114, 239, 0.907);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.login-page-container-dark .login-card {
|
||||
border: 1px solid rgba(110, 60, 180, 0.692) !important;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3) !important;
|
||||
background: #2a2733 !important;
|
||||
|
||||
&::before {
|
||||
border: 2px solid rgba(114, 46, 209, 0);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(160, 118, 219, 0.782) !important;
|
||||
box-shadow: 0 12px 40px rgba(99, 44, 175, 0.462) !important;
|
||||
transform: translateY(-2px);
|
||||
|
||||
&::before {
|
||||
border-color: rgba(114, 46, 209, 0.15);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logo-wrapper {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.divider-container {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.custom-divider {
|
||||
border-color: rgba(94, 53, 177, 0.3) !important;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.login-page-container-dark .custom-divider {
|
||||
border-color: rgba(180, 148, 246, 0.4) !important;
|
||||
}
|
||||
|
||||
.loginBox {
|
||||
max-width: 475px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 移动端全屏登录样式 */
|
||||
.mobile-login-container {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
transition: transform 0.5s ease, opacity 0.5s ease;
|
||||
z-index: 1;
|
||||
|
||||
&.mobile-visible {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-content {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
/* 移动端调整工具栏位置 */
|
||||
@media (max-width: 599px) {
|
||||
.floating-toolbar {
|
||||
position: fixed;
|
||||
bottom: 30px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
z-index: 1000;
|
||||
|
||||
&.toolbar-visible {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateX(-50%) translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
.login-container {
|
||||
gap: 0;
|
||||
}
|
||||
width: 400px;
|
||||
padding: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import {ref, useCssModule} from 'vue';
|
||||
import { ref, useCssModule } from 'vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { Form } from 'vee-validate';
|
||||
import md5 from 'js-md5';
|
||||
import {useCustomizerStore} from "@/stores/customizer";
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
|
||||
const { tm: t } = useModuleI18n('features/auth');
|
||||
@@ -17,7 +16,7 @@ const loading = ref(false);
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
async function validate(values: any, { setErrors }: any) {
|
||||
loading.value = true;
|
||||
|
||||
|
||||
// md5加密
|
||||
let password_ = password.value;
|
||||
if (password.value != '') {
|
||||
@@ -41,58 +40,25 @@ async function validate(values: any, { setErrors }: any) {
|
||||
|
||||
<template>
|
||||
<Form @submit="validate" class="mt-4 login-form" v-slot="{ errors, isSubmitting }">
|
||||
<v-text-field
|
||||
v-model="username"
|
||||
:label="t('username')"
|
||||
class="mb-6 input-field"
|
||||
required
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
variant="outlined"
|
||||
:style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#000000dd' : '#ffffff'}"
|
||||
prepend-inner-icon="mdi-account"
|
||||
:disabled="loading"
|
||||
></v-text-field>
|
||||
|
||||
<v-text-field
|
||||
v-model="password"
|
||||
:label="t('password')"
|
||||
required
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
:style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#000000dd' : '#ffffff'}"
|
||||
hide-details="auto"
|
||||
:append-icon="show1 ? 'mdi-eye' : 'mdi-eye-off'"
|
||||
:type="show1 ? 'text' : 'password'"
|
||||
@click:append="show1 = !show1"
|
||||
class="pwd-input"
|
||||
prepend-inner-icon="mdi-lock"
|
||||
:disabled="loading"
|
||||
></v-text-field>
|
||||
|
||||
<v-btn
|
||||
color="secondary"
|
||||
:loading="isSubmitting || loading"
|
||||
block
|
||||
class="login-btn mt-8"
|
||||
variant="flat"
|
||||
size="large"
|
||||
:disabled="valid"
|
||||
type="submit"
|
||||
elevation="2"
|
||||
<v-text-field v-model="username" :label="t('username')" class="mb-6 input-field" required hide-details="auto"
|
||||
variant="outlined" prepend-inner-icon="mdi-account" :disabled="loading"></v-text-field>
|
||||
|
||||
>
|
||||
<v-text-field v-model="password" :label="t('password')" required variant="outlined" hide-details="auto"
|
||||
:append-icon="show1 ? 'mdi-eye' : 'mdi-eye-off'" :type="show1 ? 'text' : 'password'"
|
||||
@click:append="show1 = !show1" class="pwd-input" prepend-inner-icon="mdi-lock" :disabled="loading"></v-text-field>
|
||||
|
||||
<div class="mt-2">
|
||||
<small style="color: grey;">{{ t('defaultHint') }}</small>
|
||||
</div>
|
||||
|
||||
|
||||
<v-btn color="secondary" :loading="isSubmitting || loading" block class="login-btn mt-8" variant="flat" size="large"
|
||||
:disabled="valid" type="submit">
|
||||
<span class="login-btn-text">{{ t('login') }}</span>
|
||||
</v-btn>
|
||||
|
||||
|
||||
<div v-if="errors.apiError" class="mt-4 error-container">
|
||||
<v-alert
|
||||
color="error"
|
||||
variant="tonal"
|
||||
density="comfortable"
|
||||
icon="mdi-alert-circle"
|
||||
border="start"
|
||||
>
|
||||
<v-alert color="error" variant="tonal" icon="mdi-alert-circle" border="start">
|
||||
{{ errors.apiError }}
|
||||
</v-alert>
|
||||
</div>
|
||||
@@ -105,24 +71,25 @@ async function validate(values: any, { setErrors }: any) {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.input-field, .pwd-input {
|
||||
.input-field,
|
||||
.pwd-input {
|
||||
.v-field__field {
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
|
||||
.v-field__outline {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
|
||||
&:hover .v-field__outline {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
|
||||
.v-field--focused .v-field__outline {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
.v-field__prepend-inner {
|
||||
padding-right: 8px;
|
||||
opacity: 0.7;
|
||||
@@ -138,36 +105,36 @@ async function validate(values: any, { setErrors }: any) {
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
opacity: 0.7;
|
||||
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.login-btn {
|
||||
margin-top: 12px;
|
||||
height: 48px;
|
||||
transition: all 0.3s ease;
|
||||
letter-spacing: 0.5px;
|
||||
border-radius: 8px !important;
|
||||
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(94, 53, 177, 0.2) !important;
|
||||
}
|
||||
|
||||
|
||||
.login-btn-text {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.hint-text {
|
||||
color: var(--v-theme-secondaryText);
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
|
||||
.error-container {
|
||||
.v-alert {
|
||||
border-left-width: 4px !important;
|
||||
|
||||
164
pyproject.toml
164
pyproject.toml
@@ -1,107 +1,100 @@
|
||||
[project]
|
||||
name = "AstrBot"
|
||||
dynamic = ["version"]
|
||||
description = "易上手的多平台 LLM 聊天机器人及开发框架"
|
||||
version = "4.5.6"
|
||||
description = "Easy-to-use multi-platform LLM chatbot and development framework"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
keywords = [
|
||||
"Astrbot",
|
||||
"Astrbot Module",
|
||||
"Astrbot Plugin"
|
||||
]
|
||||
keywords = ["Astrbot", "Astrbot Module", "Astrbot Plugin"]
|
||||
|
||||
dependencies = [
|
||||
"aiocqhttp>=1.4.4",
|
||||
"aiodocker>=0.24.0",
|
||||
"aiohttp>=3.11.18",
|
||||
"aiosqlite>=0.21.0",
|
||||
"anthropic>=0.51.0",
|
||||
"apscheduler>=3.11.0",
|
||||
"beautifulsoup4>=4.13.4",
|
||||
"certifi>=2025.4.26",
|
||||
"chardet~=5.1.0",
|
||||
"colorlog>=6.9.0",
|
||||
"cryptography>=44.0.3",
|
||||
"dashscope>=1.23.2",
|
||||
"defusedxml>=0.7.1",
|
||||
"deprecated>=1.2.18",
|
||||
"dingtalk-stream>=0.22.1",
|
||||
"docstring-parser>=0.16",
|
||||
"faiss-cpu==1.10.0",
|
||||
"filelock>=3.18.0",
|
||||
"google-genai>=1.14.0",
|
||||
"lark-oapi>=1.4.15",
|
||||
"lxml-html-clean>=0.4.2",
|
||||
"mcp>=1.8.0",
|
||||
"openai>=1.78.0",
|
||||
"ormsgpack>=1.9.1",
|
||||
"pillow>=11.2.1",
|
||||
"pip>=25.1.1",
|
||||
"psutil>=5.8.0",
|
||||
"py-cord>=2.6.1",
|
||||
"pydantic~=2.10.3",
|
||||
"pydub>=0.25.1",
|
||||
"pyjwt>=2.10.1",
|
||||
"python-telegram-bot>=22.0",
|
||||
"qq-botpy>=1.2.1",
|
||||
"quart>=0.20.0",
|
||||
"readability-lxml>=0.8.4.1",
|
||||
"silk-python>=0.2.6",
|
||||
"slack-sdk>=3.35.0",
|
||||
"sqlalchemy[asyncio]>=2.0.41",
|
||||
"sqlmodel>=0.0.24",
|
||||
"telegramify-markdown>=0.5.1",
|
||||
"watchfiles>=1.0.5",
|
||||
"websockets>=15.0.1",
|
||||
"wechatpy>=1.8.18",
|
||||
"audioop-lts ; python_full_version >= '3.13'",
|
||||
"click>=8.2.1",
|
||||
"pypdf>=6.1.1",
|
||||
"aiofiles>=25.1.0",
|
||||
"rank-bm25>=0.2.2",
|
||||
"jieba>=0.42.1",
|
||||
"markitdown-no-magika[docx,xls,xlsx]>=0.1.2",
|
||||
"xinference-client",
|
||||
"aiocqhttp>=1.4.4",
|
||||
"aiodocker>=0.24.0",
|
||||
"aiohttp>=3.11.18",
|
||||
"aiosqlite>=0.21.0",
|
||||
"anthropic>=0.51.0",
|
||||
"apscheduler>=3.11.0",
|
||||
"beautifulsoup4>=4.13.4",
|
||||
"certifi>=2025.4.26",
|
||||
"chardet~=5.1.0",
|
||||
"colorlog>=6.9.0",
|
||||
"cryptography>=44.0.3",
|
||||
"dashscope>=1.23.2",
|
||||
"defusedxml>=0.7.1",
|
||||
"deprecated>=1.2.18",
|
||||
"dingtalk-stream>=0.22.1",
|
||||
"docstring-parser>=0.16",
|
||||
"faiss-cpu==1.10.0",
|
||||
"filelock>=3.18.0",
|
||||
"google-genai>=1.14.0",
|
||||
"lark-oapi>=1.4.15",
|
||||
"lxml-html-clean>=0.4.2",
|
||||
"mcp>=1.8.0",
|
||||
"openai>=1.78.0",
|
||||
"ormsgpack>=1.9.1",
|
||||
"pillow>=11.2.1",
|
||||
"pip>=25.1.1",
|
||||
"psutil>=5.8.0",
|
||||
"py-cord>=2.6.1",
|
||||
"pydantic~=2.10.3",
|
||||
"pydub>=0.25.1",
|
||||
"pyjwt>=2.10.1",
|
||||
"python-telegram-bot>=22.0",
|
||||
"qq-botpy>=1.2.1",
|
||||
"quart>=0.20.0",
|
||||
"readability-lxml>=0.8.4.1",
|
||||
"silk-python>=0.2.6",
|
||||
"slack-sdk>=3.35.0",
|
||||
"sqlalchemy[asyncio]>=2.0.41",
|
||||
"sqlmodel>=0.0.24",
|
||||
"telegramify-markdown>=0.5.1",
|
||||
"watchfiles>=1.0.5",
|
||||
"websockets>=15.0.1",
|
||||
"wechatpy>=1.8.18",
|
||||
"audioop-lts ; python_full_version >= '3.13'",
|
||||
"click>=8.2.1",
|
||||
"pypdf>=6.1.1",
|
||||
"aiofiles>=25.1.0",
|
||||
"rank-bm25>=0.2.2",
|
||||
"jieba>=0.42.1",
|
||||
"markitdown-no-magika[docx,xls,xlsx]>=0.1.2",
|
||||
"xinference-client",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"commitizen>=4.9.1",
|
||||
"pytest>=8.4.1",
|
||||
"pytest-asyncio>=1.1.0",
|
||||
"pytest-cov>=6.2.1",
|
||||
"ruff>=0.12.8",
|
||||
"commitizen>=4.9.1",
|
||||
"pytest>=8.4.1",
|
||||
"pytest-asyncio>=1.1.0",
|
||||
"pytest-cov>=6.2.1",
|
||||
"ruff>=0.12.8",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
astrbot = "astrbot.cli.__main__:cli"
|
||||
|
||||
[tool.ruff]
|
||||
exclude = [
|
||||
"astrbot/core/utils/t2i/local_strategy.py",
|
||||
"astrbot/api/all.py",
|
||||
]
|
||||
exclude = ["astrbot/core/utils/t2i/local_strategy.py", "astrbot/api/all.py"]
|
||||
line-length = 88
|
||||
target-version = "py310"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"F", # Pyflakes
|
||||
"W", # pycodestyle warnings
|
||||
"E", # pycodestyle errors
|
||||
"F", # Pyflakes
|
||||
"W", # pycodestyle warnings
|
||||
"E", # pycodestyle errors
|
||||
"ASYNC", # flake8-async
|
||||
"C4", # flake8-comprehensions
|
||||
"Q", # flake8-quotes
|
||||
"I", # import-order
|
||||
"UP", # pyupgrade
|
||||
"C4", # flake8-comprehensions
|
||||
"Q", # flake8-quotes
|
||||
"I", # import-order
|
||||
"UP", # pyupgrade
|
||||
# "SIM", # flake8-simplify
|
||||
]
|
||||
ignore = [
|
||||
"F403",
|
||||
"F405",
|
||||
"E501",
|
||||
"ASYNC230" # TODO: handle ASYNC230 in AstrBot
|
||||
"F403",
|
||||
"F405",
|
||||
"E501",
|
||||
"ASYNC230", # TODO: handle ASYNC230 in AstrBot
|
||||
]
|
||||
|
||||
[tool.pyright]
|
||||
@@ -109,18 +102,9 @@ typeCheckingMode = "basic"
|
||||
pythonVersion = "3.10"
|
||||
reportMissingTypeStubs = false
|
||||
reportMissingImports = false
|
||||
include = ["astrbot","packages"]
|
||||
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"
|
||||
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
151
tests/test_security_fixes.py
Normal file
151
tests/test_security_fixes.py
Normal 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"])
|
||||
Reference in New Issue
Block a user