Compare commits

...

102 Commits

Author SHA1 Message Date
Soulter ea64cebe2a ci: fix cloudflare r2 ci 2025-06-09 13:12:31 +08:00
Soulter ab2bbff369 Merge pull request #1746 from Seayon/fix-wechat-at-message-parsing
 feat(wechatpadpro): 增强群聊消息中的@消息处理逻辑
2025-06-09 12:51:08 +08:00
Soulter ec32825309 ci: fix cloudflare r2 upload 2025-06-09 12:41:20 +08:00
Soulter fd0c182087 ci: fix ghcr token 2025-06-09 12:32:38 +08:00
Soulter 49fcff1daf 📦 release: v3.5.14 2025-06-09 12:31:02 +08:00
Soulter 4c447aa648 perf: jwt token expire time change to 7 days 2025-06-09 11:52:48 +08:00
Soulter ccbfc3d274 perf: 强化强制修改默认密码逻辑 2025-06-09 11:47:23 +08:00
Soulter f83fe43bbb docs: alert 2025-06-09 10:12:09 +08:00
Seayon 19022d67f8 Merge branch 'master' into fix-wechat-at-message-parsing
# Conflicts:
#	astrbot/core/platform/sources/wechatpadpro/wechatpadpro_adapter.py
2025-06-09 09:30:09 +08:00
Soulter 58a815dd6b feat: ltm edge fact viewer 2025-06-08 20:34:41 +08:00
Soulter bc9fe82860 Merge pull request #1737 from zhx8702/feat-wehcatpro-voice-adapter
feat: wechatpadpro 添加语音接收和发送的适配
2025-06-07 15:13:10 +08:00
Soulter b3cd9bf2b9 Merge pull request #1743 from lvboda/hotfix-platform-page-iframe-style-issue-1741
fix(PlatformPage): iframe overflow style issue (#1741)
2025-06-07 15:11:16 +08:00
Soulter c5c2b829ec Merge pull request #1758 from RC-CHN/master
fix: 修复 asyncio.wait_for 参数顺序错误
2025-06-07 15:08:37 +08:00
Ruochen 11f35ebf96 fix: 修复 asyncio.wait_for 参数顺序错误 2025-06-07 09:50:30 +08:00
Soulter 7d403aa181 fix: syntax error 2025-06-07 01:20:56 +08:00
Soulter 64af810a4a Merge pull request #1736 from RC-CHN/master
fix:修复了部分模型供应商测试不可用,但实际可用的问题。
2025-06-06 21:37:19 +08:00
Soulter 30821905af perf: remove default list param,fix dashscope_source contexts params 2025-06-06 21:36:01 +08:00
Seayon a9dbff756b feat(wechatpadpro): 增强群聊消息中的@消息处理逻辑
添加对群聊消息中@机器人场景的精确识别和处理,提升了消息解析的准确性。
支持多种@格式的检测,包括 msg_source 和 push_content 的判断。
2025-06-06 16:53:31 +08:00
lvboda a6aba10d3d fix(PlatformPage): iframe overflow style issue (#1741) 2025-06-06 15:18:35 +08:00
RC-CHN 9c276c37fe Update astrbot/dashboard/routes/config.py
测试过对于dashscope类型供应商添加上下文是必要的,否则需要改动其_remove_image_from_context方法。

Co-authored-by: Soulter  <37870767+Soulter@users.noreply.github.com>
2025-06-06 14:01:58 +08:00
Soulter 6ab6c0fd4c Merge pull request #1735 from Flartiny/dev
feat: able to parse repo url of specific branch
2025-06-06 12:44:51 +08:00
Soulter b6b0fe3fff perf: 优化 GitHub 仓库解析和下载的逻辑 2025-06-06 12:02:46 +08:00
zhx 0d5825bda9 feat: wechatpadpro 添加语音接收和发送的适配 2025-06-06 10:30:06 +08:00
Ruochen cdfb64631a fix:修复dashscope类型供应商测试问题,延长了设置超时时间,改进prompt工程,修复了控制台打印日志超时时间不符 2025-06-06 09:21:09 +08:00
Ruochen d161c281c8 Merge branch 'master' of https://github.com/RC-CHN/AstrBot 2025-06-06 00:39:25 +08:00
Flartiny 8fed5bf2a1 feat: able to parse repo url of specific branch 2025-06-06 00:09:10 +08:00
Soulter a03af55edd ci 2025-06-05 13:38:20 +08:00
Soulter 86e2fd9aee ci: publish to ghcr.io 2025-06-05 13:35:14 +08:00
Soulter 97bd0e5e58 Merge pull request #1730 from lxfight/master
feat: 添加插件更新后自动刷新插件列表功能
2025-06-05 11:39:32 +08:00
Soulter ceaba21986 ci: publish to ghcr.io 2025-06-05 11:19:16 +08:00
Soulter 172a77d942 ci: publish to ghcr.io 2025-06-05 11:16:57 +08:00
Soulter 4f9d2d2a7d ci: publish to ghcr.io 2025-06-05 11:12:56 +08:00
lxfight 8c929f6e05 feat: 添加插件更新后自动刷新插件列表功能 2025-06-05 10:56:04 +08:00
Soulter 3319b71f5b Merge pull request #1721 from zhx8702/feat-add-wechat-47-49
feat: 添加wechatpadpro 消息类型47 49的适配
2025-06-04 22:52:29 +08:00
Soulter 46ec028a5b Merge pull request #1718 from Kwicxy/webui_enhancement
feat: webUI优化
2025-06-04 22:48:49 +08:00
Soulter 0ce0ef3e5c Merge pull request #1715 from Flartiny/dev
fix: residual configuration items after plugin configuration modification
2025-06-04 22:32:19 +08:00
kwicxy 375b071cb2 Merge remote-tracking branch 'origin/webui_enhancement' into webui_enhancement 2025-06-04 19:00:54 +08:00
kwicxy 29e1417ff2 feat: optional newUsername field in account editing 2025-06-04 18:59:38 +08:00
kwicxy 75db2bd366 fix(auth): bad localStorage keymapping 2025-06-04 18:58:53 +08:00
zhx 60ca1efbda feat: 添加wechatpadpro 消息类型47 49的适配 2025-06-04 14:36:16 +08:00
Richard X. 2692e4978b fix: remove console.log()
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2025-06-03 21:06:51 +08:00
Richard X. 91982eb002 Merge branch 'AstrBotDevs:master' into webui_enhancement 2025-06-03 20:36:51 +08:00
Soulter bb1dec76fa remove: wechat qr code
hahaha
2025-06-03 20:22:08 +08:00
Flartiny f618b8fcdc fix: residual configuration items after plugin configuration modification 2025-06-03 14:04:04 +08:00
Raven95676 9147cab75b fix: add additional routes for Alkaid knowledge base and long-term memory 2025-05-31 14:29:04 +08:00
Raven95676 5f07bcc8e6 feat: add Gemini embedding provider and update OpenAI provider to support timeout configuration 2025-05-31 14:13:58 +08:00
Soulter 705cf2ea1b docs(README.md): knowledge base 2025-05-31 14:08:01 +08:00
Soulter 42c4394484 ci: upload dashboard artifact to Cloudflare R2 when auto release 2025-05-31 13:50:40 +08:00
Soulter 221221a3c1 ci: upload dashboard artifact to Cloudflare R2 when auto release 2025-05-31 13:47:59 +08:00
Soulter 9564166297 perf: knowledge base displays console when installing 2025-05-31 11:52:24 +08:00
Soulter f5cf3c3c8e Merge pull request #1691 from AstrBotDevs/perf-pip-async
Feature: 将插件依赖检查和 pip 安装方法改为异步,以提高性能和响应速度
2025-05-31 11:51:39 +08:00
Soulter 18f919fb6b perf: pip_main wrapped in asyncio.to_thread
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-31 11:47:29 +08:00
Soulter 0924835253 feat: 将插件依赖检查和 pip 安装方法改为异步,以提高性能和响应速度 2025-05-31 11:44:58 +08:00
Soulter 20d2e5c578 perf: 优化日志流发送频率,防止积压超过 buffer size 导致前端显示异常 2025-05-31 11:25:51 +08:00
Soulter 907801605c 📦 release: v3.5.13 2025-05-31 11:02:56 +08:00
Soulter 93bc684e8c feat: 添加旧版本提供商类型映射以兼容性支持 2025-05-31 11:00:59 +08:00
Soulter a76c98d57e Merge pull request #1685 from RC-CHN/master
Feature: 添加测试文本生成供应商可用功能
2025-05-31 10:59:46 +08:00
Soulter d937a800d0 fix: provider name 2025-05-31 10:46:35 +08:00
Soulter d16f3a227f Merge branch 'master' into master 2025-05-31 10:46:15 +08:00
Soulter 80c9a3eeda style: code style 2025-05-31 09:25:18 +08:00
Soulter e68173b451 feat: knowledge-base 2025-05-30 23:18:48 +08:00
Soulter 40c27d87f5 feat: knowledge-base 2025-05-30 23:18:19 +08:00
Soulter 3c13b5049d feat: 支持知识库的分片、重叠设置等 2025-05-30 23:00:37 +08:00
Soulter 8288d5e51f feat: embedding provider 2025-05-30 18:07:52 +08:00
Ruochen 6e1449900a feat: 优化单个 provider 可用性测试的回退逻辑 2025-05-30 15:35:13 +08:00
RC-CHN 4ffbb18ab4 Merge branch 'AstrBotDevs:master' into master 2025-05-30 15:12:33 +08:00
Ruochen b27271b7a3 feat:添加测试文本生成供应商可用功能 2025-05-30 15:10:15 +08:00
Soulter ebb6665f64 feat: add open_config parameter handling and configuration button in KnowledgeBase 2025-05-30 14:30:04 +08:00
Soulter e4e5731ffd 📦 release: v3.5.13 2025-05-30 13:30:23 +08:00
Soulter 2ab5810f13 perf: improve transaction performance in vector db 2025-05-30 12:59:26 +08:00
Soulter af934c5d09 fix: correct dimension typo and enhance API registration logic 2025-05-30 11:42:39 +08:00
Soulter 1e0cf7c112 fix: update ExtensionCard actions and add readme link functionality 2025-05-30 10:50:54 +08:00
Soulter 46859c93c9 perf: improve WebUI 2025-05-30 10:45:05 +08:00
Richard X. ea1f9cb3b2 Merge branch 'AstrBotDevs:master' into master 2025-05-30 10:37:59 +08:00
Soulter 1641549016 perf: improve WebUI 2025-05-30 10:36:48 +08:00
鸦羽 716a5dbb8a chore: add nh3 to requirements.txt 2025-05-30 10:35:48 +08:00
鸦羽 af98cb11c5 fix: handle missing nh3 library in plugin.py 2025-05-30 10:35:48 +08:00
Soulter 9a4c2cf341 fix: downgrade faiss-cpu dependency to version 1.10.0 2025-05-30 10:21:31 +08:00
Soulter 2bc3bcd102 fix: handle missing nh3 library gracefully for README cleaning 2025-05-30 10:17:33 +08:00
Soulter d6c663f79d fix: do not display change password dialog in demo mode 2025-05-30 10:09:09 +08:00
kwicxy 9ed86e5f53 feat: Name trim of extension list to improve readability 2025-05-30 09:37:21 +08:00
kwicxy 303e0bc037 fix(dashboard): MessageStat chart tooltips now supports dark appearance 2025-05-30 09:36:06 +08:00
Richard X. 2cc24019f9 Merge branch 'AstrBotDevs:master' into master 2025-05-30 08:50:27 +08:00
kwicxy 83ce774d19 chore: Extension marketplace scroll behaviour updated 2025-05-30 00:01:53 +08:00
Soulter 2b4ee13b5e Merge pull request #1672 from Kwicxy/master
Feat: 暗黑主题功能初步实现
2025-05-29 23:41:10 +08:00
kwicxy 3a964561f0 style: minor code style changes 2025-05-29 22:57:50 +08:00
kwicxy 6959f86632 feat: Using localStorage to remember user's theme setting. 2025-05-29 22:46:02 +08:00
Raven95676 537d373e10 fix: Fix potential XSS risk in plugin README content 2025-05-29 22:35:24 +08:00
Soulter cceadf222c Merge pull request #1676 from AstrBotDevs/fix-chat-get-file-bug
Fix: fixed a potential vulnerability in `/api/chat/get_file` endpoint.
2025-05-29 21:41:55 +08:00
Soulter cf5a4af623 chore: remove duplicated auth header 2025-05-29 21:19:39 +08:00
Raven95676 39aea11c22 perf: enhance file access security in get_file method
Co-authored-by: anka-afk <1350989414@qq.com>
2025-05-29 21:03:51 +08:00
Raven95676 c2f1227700 fix: add authorization header to file download request in ChatPage.vue 2025-05-29 19:57:11 +08:00
Soulter 900f14d37c 🐛 fix: fixed a potential vulnerability in /api/chat/get_file endpoint.
I have fixed a potential vulnerability in the `/api/chat/get_file` endpoint that could allow unauthorized access to files by ensuring the request has a jwt token.
2025-05-29 19:17:31 +08:00
kwicxy 598249b1d6 Merge remote-tracking branch 'origin/master' 2025-05-29 18:26:53 +08:00
Richard X. 7ed15bdf04 Merge branch 'AstrBotDevs:master' into master 2025-05-29 18:17:39 +08:00
Raven95676 2fc0ec0f72 fix: update route 2025-05-29 17:28:33 +08:00
kwicxy 5e9c2a669b fix: Various bug fixes and improvements 2025-05-29 16:41:03 +08:00
kwicxy b884fe0e86 fix: Various bug fixes 2025-05-29 09:31:29 +08:00
kwicxy 855858c236 fix: Changed default theme to PurpleTheme 2025-05-29 09:31:15 +08:00
kwicxy c11a2a5419 feat: Login page darkened 2025-05-29 09:00:27 +08:00
kwicxy 773a6572af feat: WebUI Dark Appearance 2025-05-29 01:43:21 +08:00
kwicxy 88ad373c9b 深色主题切换功能初步实现 2025-05-29 01:28:45 +08:00
74 changed files with 3960 additions and 2017 deletions
+30
View File
@@ -23,6 +23,36 @@ jobs:
echo "COMMIT_SHA=$(git rev-parse HEAD)" >> $GITHUB_ENV
echo ${{ github.ref_name }} > dist/assets/version
zip -r dist.zip dist
- name: Upload to Cloudflare R2
env:
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_BUCKET_NAME: "astrbot"
R2_OBJECT_NAME: "astrbot-webui-latest.zip"
VERSION_TAG: ${{ github.ref_name }}
run: |
echo "Installing rclone..."
curl https://rclone.org/install.sh | sudo bash
echo "Configuring rclone remote..."
mkdir -p ~/.config/rclone
cat <<EOF > ~/.config/rclone/rclone.conf
[r2]
type = s3
provider = Cloudflare
access_key_id = $R2_ACCESS_KEY_ID
secret_access_key = $R2_SECRET_ACCESS_KEY
endpoint = https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com
EOF
echo "Uploading dist.zip to R2 bucket: $R2_BUCKET_NAME/$R2_OBJECT_NAME"
mv dashboard/dist.zip dashboard/$R2_OBJECT_NAME
rclone copy dashboard/$R2_OBJECT_NAME r2:$R2_BUCKET_NAME --progress
mv dashboard/$R2_OBJECT_NAME dashboard/astrbot-webui-${VERSION_TAG}.zip
rclone copy dashboard/astrbot-webui-${VERSION_TAG}.zip r2:$R2_BUCKET_NAME --progress
mv dashboard/astrbot-webui-${VERSION_TAG}.zip dashboard/dist.zip
- name: Fetch Changelog
run: |
+27 -8
View File
@@ -11,24 +11,42 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: 拉取源码
- name: Pull The Codes
uses: actions/checkout@v3
with:
fetch-depth: 1
fetch-depth: 0 # Must be 0 so we can fetch tags
- name: 设置 QEMU
- name: Get latest tag (only on manual trigger)
id: get-latest-tag
if: github.event_name == 'workflow_dispatch'
run: |
tag=$(git describe --tags --abbrev=0)
echo "latest_tag=$tag" >> $GITHUB_OUTPUT
- name: Checkout to latest tag (only on manual trigger)
if: github.event_name == 'workflow_dispatch'
run: git checkout ${{ steps.get-latest-tag.outputs.latest_tag }}
- name: Set QEMU
uses: docker/setup-qemu-action@v3
- name: 设置 Docker Buildx
- name: Set Docker Buildx
uses: docker/setup-buildx-action@v3
- name: 登录到 DockerHub
- name: Log in to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: 构建和推送 Docker hub
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: Soulter
password: ${{ secrets.GHCR_GITHUB_TOKEN }}
- name: Build and Push Docker to DockerHub and Github GHCR
uses: docker/build-push-action@v6
with:
context: .
@@ -36,8 +54,9 @@ jobs:
push: true
tags: |
${{ secrets.DOCKER_HUB_USERNAME }}/astrbot:latest
${{ secrets.DOCKER_HUB_USERNAME }}/astrbot:${{ github.ref_name }}
${{ secrets.DOCKER_HUB_USERNAME }}/astrbot:${{ github.event_name == 'workflow_dispatch' && steps.get-latest-tag.outputs.latest_tag || github.ref_name }}
ghcr.io/soulter/astrbot:latest
ghcr.io/soulter/astrbot:${{ github.event_name == 'workflow_dispatch' && steps.get-latest-tag.outputs.latest_tag || github.ref_name }}
- name: Post build notifications
run: echo "Docker image has been built and pushed successfully"
+11 -4
View File
@@ -31,13 +31,21 @@ AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用
<!-- [![codecov](https://img.shields.io/codecov/c/github/soulter/astrbot?style=for-the-badge)](https://codecov.io/gh/Soulter/AstrBot)
-->
> [!NOTE]
> [!WARNING]
>
> 个人微信接入所依赖的开源项目 Gewechat 近期已停止维护,`v3.5.10` 已经支持接入 WeChatPadPro 替换 gewechat 方式。详见文档 [WeChatPadPro](https://astrbot.app/deploy/platform/wechat/wechatpadpro.html)
> 请务必修改默认密码以及保证 AstrBot 版本 >= 3.5.13。
## ✨ 近期更新
1. AstrBot 现已支持接入 [MCP](https://modelcontextprotocol.io/) 服务器!
<details><summary>1. AstrBot 现已自带知识库能力</summary>
📚 详见[文档](https://astrbot.app/use/knowledge-base.html)
![image](https://github.com/user-attachments/assets/28b639b0-bb5c-4958-8e94-92ae8cfd1ab4)
</details>
2. AstrBot 现已支持接入 [MCP](https://modelcontextprotocol.io/) 服务器!
## ✨ 主要功能
@@ -171,7 +179,6 @@ pre-commit install
- Star 这个项目!
- 在[爱发电](https://afdian.com/a/soulter)支持我!
- 在[微信](https://drive.soulter.top/f/pYfA/d903f4fa49a496fda3f16d2be9e023b5.png)支持我~
## ✨ Demo
+7
View File
@@ -43,6 +43,7 @@ class AstrBotConfig(dict):
"""不存在时载入默认配置"""
with open(config_path, "w", encoding="utf-8-sig") as f:
json.dump(default_config, f, indent=4, ensure_ascii=False)
object.__setattr__(self, "first_deploy", True) # 标记第一次部署
with open(config_path, "r", encoding="utf-8-sig") as f:
conf_str = f.read()
@@ -99,6 +100,12 @@ class AstrBotConfig(dict):
has_new |= self.check_config_integrity(
value, conf[key], path + "." + key if path else key
)
for key in list(conf.keys()):
if key not in refer_conf:
path_ = path + "." + key if path else key
logger.info(f"检查到配置项 {path_} 不存在,将从当前配置中删除")
del conf[key]
has_new = True
return has_new
def save_config(self, replace_config: Dict = None):
+41 -1
View File
@@ -5,7 +5,7 @@
import os
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "3.5.12"
VERSION = "3.5.14"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v3.db")
# 默认配置
@@ -862,8 +862,48 @@ CONFIG_METADATA_2 = {
"api_base": "https://openspeech.bytedance.com/api/v1/tts",
"timeout": 20,
},
"OpenAI Embedding": {
"id": "openai_embedding",
"type": "openai_embedding",
"provider_type": "embedding",
"enable": True,
"embedding_api_key": "",
"embedding_api_base": "",
"embedding_model": "",
"embedding_dimensions": 1536,
"timeout": 20,
},
"Gemini Embedding": {
"id": "gemini_embedding",
"type": "gemini_embedding",
"provider_type": "embedding",
"enable": True,
"embedding_api_key": "",
"embedding_api_base": "",
"embedding_model": "gemini-embedding-exp-03-07",
"embedding_dimensions": 768,
"timeout": 20,
},
},
"items": {
"embedding_dimensions": {
"description": "嵌入维度",
"type": "int",
"hint": "嵌入向量的维度。根据模型不同,可能需要调整,请参考具体模型的文档。此配置项请务必填写正确,否则将导致向量数据库无法正常工作。",
},
"embedding_model": {
"description": "嵌入模型",
"type": "string",
"hint": "嵌入模型名称。",
},
"embedding_api_key": {
"description": "API Key",
"type": "string",
},
"embedding_api_base": {
"description": "API Base URL",
"type": "string",
},
"volcengine_cluster": {
"type": "string",
"description": "火山引擎集群",
@@ -29,9 +29,9 @@ class EmbeddingStorage:
Raises:
ValueError: 如果向量的维度与存储的维度不匹配
"""
if vector.shape[0] != self.dimention:
if vector.shape[0] != self.dimension:
raise ValueError(
f"向量维度不匹配, 期望: {self.dimention}, 实际: {vector.shape[0]}"
f"向量维度不匹配, 期望: {self.dimension}, 实际: {vector.shape[0]}"
)
self.index.add_with_ids(vector.reshape(1, -1), np.array([id]))
self.storage[id] = vector
+4 -10
View File
@@ -30,19 +30,13 @@ class FaissVecDB(BaseVecDB):
async def initialize(self):
await self.document_storage.initialize()
async def insert(
self,
content: str,
metadata: dict = None,
id: str = None,
) -> int:
async def insert(self, content: str, metadata: dict = None, id: str = None) -> int:
"""
插入一条文本和其对应向量,自动生成 ID 并保持一致性。
"""
metadata = metadata or {}
str_id = id or str(uuid.uuid4()) # 使用 UUID 作为原始 ID
# 获取向量
vector = await self.embedding_provider.get_embedding(content)
vector = np.array(vector, dtype=np.float32)
async with self.document_storage.connection.cursor() as cursor:
@@ -54,9 +48,9 @@ class FaissVecDB(BaseVecDB):
result = await self.document_storage.get_document_by_doc_id(str_id)
int_id = result["id"]
# 插入向量到 FAISS
await self.embedding_storage.insert(vector, int_id)
return int_id
# 插入向量到 FAISS
await self.embedding_storage.insert(vector, int_id)
return int_id
async def retrieve(
self, query: str, k: int = 5, fetch_k: int = 20, metadata_filters: dict = None
+1
View File
@@ -32,6 +32,7 @@ class RespondStage(Stage):
Comp.Node: lambda comp: bool(comp.content), # 转发节点
Comp.Nodes: lambda comp: bool(comp.nodes), # 多个转发节点
Comp.File: lambda comp: bool(comp.file_ or comp.url),
Comp.WechatEmoji: lambda comp: comp.md5 is not None, # 微信表情
}
async def initialize(self, ctx: PipelineContext):
@@ -1,14 +1,15 @@
import asyncio
import base64
import json
import os
import time
from typing import Optional
import aiohttp
import anyio
import websockets
from astrbot import logger
from astrbot.api.message_components import Plain, Image
from astrbot.api.message_components import Plain, Image, At, Record
from astrbot.api.platform import Platform, PlatformMetadata
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.platform.astrbot_message import (
@@ -22,6 +23,13 @@ from astrbot.core.platform.astr_message_event import MessageSesion
from ...register import register_platform_adapter
from .wechatpadpro_message_event import WeChatPadProMessageEvent
try:
from .xml_data_parser import GeweDataParser
except ImportError as e:
logger.warning(
f"警告: 可能未安装 defusedxml 依赖库,将导致无法解析微信的 表情包、引用 类型的消息: {str(e)}"
)
@register_platform_adapter("wechatpadpro", "WeChatPadPro 消息平台适配器")
class WeChatPadProAdapter(Platform):
@@ -59,6 +67,18 @@ class WeChatPadProAdapter(Platform):
) # 持久化文件路径
self.ws_handle_task = None
# 添加图片消息缓存,用于引用消息处理
self.cached_images = {}
"""缓存图片消息。key是NewMsgId (对应引用消息的svrid)value是图片的base64数据"""
# 设置缓存大小限制,避免内存占用过大
self.max_image_cache = 50
# 添加文本消息缓存,用于引用消息处理
self.cached_texts = {}
"""缓存文本消息。key是NewMsgId (对应引用消息的svrid)value是消息文本内容"""
# 设置文本缓存大小限制
self.max_text_cache = 100
async def run(self) -> None:
"""
启动平台适配器的运行实例。
@@ -102,7 +122,7 @@ class WeChatPadProAdapter(Platform):
logger.warning("登录失败或超时,WeChatPadPro 适配器将关闭。")
await self.terminate()
return
# 登录成功后,连接 WebSocket 接收消息
self.ws_handle_task = asyncio.create_task(self.connect_websocket())
@@ -161,27 +181,21 @@ class WeChatPadProAdapter(Platform):
return True
# login_state == 3 为离线状态
elif login_state == 3:
logger.info(
"WeChatPadPro 设备不在线。"
)
logger.info("WeChatPadPro 设备不在线。")
return False
else:
logger.error(
f"未知的在线状态: {login_state:}"
)
logger.error(f"未知的在线状态: {login_state:}")
return False
# Code == 300 为微信退出状态。
elif response.status == 200 and response_data.get("Code") == 300:
logger.info(
"WeChatPadPro 设备已退出。"
)
logger.info("WeChatPadPro 设备已退出。")
return False
else:
logger.error(
f"检查在线状态失败: {response.status}, {response_data}"
)
return False
except aiohttp.ClientConnectorError as e:
logger.error(f"连接到 WeChatPadPro 服务失败: {e}")
return False
@@ -364,7 +378,9 @@ class WeChatPadProAdapter(Platform):
logger.error(f"处理 WebSocket 消息时发生错误: {e}")
break
except Exception as e:
logger.error(f"WebSocket 连接失败: {e}, 请检查WeChatPadPro服务状态,或尝试重启WeChatPadPro适配器。")
logger.error(
f"WebSocket 连接失败: {e}, 请检查WeChatPadPro服务状态,或尝试重启WeChatPadPro适配器。"
)
await asyncio.sleep(5)
async def handle_websocket_message(self, message: str):
@@ -439,7 +455,7 @@ class WeChatPadProAdapter(Platform):
):
# 再根据消息类型处理消息内容
await self._process_message_content(abm, raw_message, msg_type, content)
return abm
return None
@@ -457,6 +473,7 @@ class WeChatPadProAdapter(Platform):
"""
if from_user_name == "weixin":
return False
at_me = False
if "@chatroom" in from_user_name:
abm.type = MessageType.GROUP_MESSAGE
abm.group_id = from_user_name
@@ -478,6 +495,14 @@ class WeChatPadProAdapter(Platform):
abm.session_id = f"{from_user_name}_{to_user_name}"
else:
abm.session_id = from_user_name
msg_source = raw_message.get("msg_source", "")
if self.wxid in msg_source:
at_me = True
if "在群聊中@了你" in raw_message.get("push_content", ""):
at_me = True
if at_me:
abm.message.insert(0, At(qq=abm.self_id, name=""))
else:
abm.type = MessageType.FRIEND_MESSAGE
abm.group_id = ""
@@ -558,6 +583,32 @@ class WeChatPadProAdapter(Platform):
logger.error(f"下载图片时发生错误: {e}")
return None
async def download_voice(
self, to_user_name: str, new_msg_id: str, bufid: str, length: int
):
"""下载原始音频。"""
url = f"{self.base_url}/message/GetMsgVoice"
params = {"key": self.auth_key}
payload = {
"Bufid": bufid,
"ToUserName": to_user_name,
"NewMsgId": new_msg_id,
"Length": length,
}
async with aiohttp.ClientSession() as session:
try:
async with session.post(url, params=params, json=payload) as response:
if response.status == 200:
return await response.json()
logger.error(f"下载音频失败: {response.status}")
return None
except aiohttp.ClientConnectorError as e:
logger.error(f"连接到 WeChatPadPro 服务失败: {e}")
return None
except Exception as e:
logger.error(f"下载音频时发生错误: {e}")
return None
async def _process_message_content(
self, abm: AstrBotMessage, raw_message: dict, msg_type: int, content: str
):
@@ -569,12 +620,69 @@ class WeChatPadProAdapter(Platform):
if abm.type == MessageType.GROUP_MESSAGE:
parts = content.split(":\n", 1)
if len(parts) == 2:
abm.message_str = parts[1]
abm.message.append(Plain(abm.message_str))
message_content = parts[1]
abm.message_str = message_content
# 检查是否@了机器人,参考 gewechat 的实现方式
# 微信大部分客户端在@用户昵称后面,紧接着是一个\u2005字符(四分之一空格)
at_me = False
# 检查 msg_source 中是否包含机器人的 wxid
# wechatpadpro 的格式: <atuserlist>wxid</atuserlist>
# gewechat 的格式: <atuserlist><![CDATA[wxid]]></atuserlist>
msg_source = raw_message.get("msg_source", "")
if f"<atuserlist>{abm.self_id}</atuserlist>" in msg_source or f"<atuserlist>{abm.self_id}," in msg_source or f",{abm.self_id}</atuserlist>" in msg_source:
at_me = True
# 也检查 push_content 中是否有@提示
push_content = raw_message.get("push_content", "")
if "在群聊中@了你" in push_content:
at_me = True
if at_me:
# 被@了,在消息开头插入At组件(参考gewechat的做法)
bot_nickname = await self._get_group_member_nickname(abm.group_id, abm.self_id)
abm.message.insert(0, At(qq=abm.self_id, name=bot_nickname or abm.self_id))
# 只有当消息内容不仅仅是@时才添加Plain组件
if "\u2005" in message_content:
# 检查@之后是否还有其他内容
parts = message_content.split("\u2005")
if len(parts) > 1 and any(part.strip() for part in parts[1:]):
abm.message.append(Plain(message_content))
else:
# 检查是否只包含@机器人
is_pure_at = False
if bot_nickname and message_content.strip() == f"@{bot_nickname}":
is_pure_at = True
if not is_pure_at:
abm.message.append(Plain(message_content))
else:
# 没有@机器人,作为普通文本处理
abm.message.append(Plain(message_content))
else:
abm.message.append(Plain(abm.message_str))
else: # 私聊消息
abm.message.append(Plain(abm.message_str))
# 缓存文本消息,以便引用消息可以查找
try:
# 获取msg_id作为缓存的key
new_msg_id = raw_message.get("new_msg_id")
if new_msg_id:
# 限制缓存大小
if (
len(self.cached_texts) >= self.max_text_cache
and self.cached_texts
):
# 删除最早的一条缓存
oldest_key = next(iter(self.cached_texts))
self.cached_texts.pop(oldest_key)
logger.debug(f"缓存文本消息,new_msg_id={new_msg_id}")
self.cached_texts[str(new_msg_id)] = content
except Exception as e:
logger.error(f"缓存文本消息失败: {e}")
elif msg_type == 3:
# 图片消息
from_user_name = raw_message.get("from_user_name", {}).get("str", "")
@@ -588,15 +696,87 @@ class WeChatPadProAdapter(Platform):
)
if image_bs64_data:
abm.message.append(Image.fromBase64(image_bs64_data))
# 缓存图片,以便引用消息可以查找
try:
# 获取msg_id作为缓存的key
new_msg_id = raw_message.get("new_msg_id")
if new_msg_id:
# 限制缓存大小
if (
len(self.cached_images) >= self.max_image_cache
and self.cached_images
):
# 删除最早的一条缓存
oldest_key = next(iter(self.cached_images))
self.cached_images.pop(oldest_key)
logger.debug(f"缓存图片消息,new_msg_id={new_msg_id}")
self.cached_images[str(new_msg_id)] = image_bs64_data
except Exception as e:
logger.error(f"缓存图片消息失败: {e}")
elif msg_type == 47:
# 视频消息 (注意:表情消息也是 47,需要区分)
logger.warning("收到视频消息,待实现。")
data_parser = GeweDataParser(
content=content,
is_private_chat=(abm.type != MessageType.GROUP_MESSAGE),
raw_message=raw_message,
)
emoji_message = data_parser.parse_emoji()
if emoji_message is not None:
abm.message.append(emoji_message)
elif msg_type == 50:
# 语音/视频
logger.warning("收到语音/视频消息,待实现。")
elif msg_type == 34:
# 语音消息
bufid = 0
to_user_name = raw_message.get("to_user_name", {}).get("str", "")
new_msg_id = raw_message.get("new_msg_id")
data_parser = GeweDataParser(
content=content,
is_private_chat=(abm.type != MessageType.GROUP_MESSAGE),
raw_message=raw_message,
)
voicemsg = data_parser._format_to_xml().find("voicemsg")
bufid = voicemsg.get("bufid") or "0"
length = int(voicemsg.get("length") or 0)
voice_resp = await self.download_voice(
to_user_name=to_user_name,
new_msg_id=new_msg_id,
bufid=bufid,
length=length,
)
voice_bs64_data = voice_resp.get("Data", {}).get("Base64", None)
if voice_bs64_data:
voice_bs64_data = base64.b64decode(voice_bs64_data)
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
file_path = os.path.join(
temp_dir, f"wechatpadpro_voice_{abm.message_id}.silk"
)
async with await anyio.open_file(file_path, "wb") as f:
await f.write(voice_bs64_data)
abm.message.append(Record(file=file_path, url=file_path))
elif msg_type == 49:
# 引用消息
logger.warning("收到引用消息,待实现。")
try:
parser = GeweDataParser(
content=content,
is_private_chat=(abm.type != MessageType.GROUP_MESSAGE),
cached_texts=self.cached_texts,
cached_images=self.cached_images,
raw_message=raw_message,
downloader=self._download_raw_image,
)
components = await parser.parse_mutil_49()
if components:
abm.message.extend(components)
abm.message_str = "\n".join(
c.text for c in components if isinstance(c, Plain)
)
except Exception as e:
logger.warning(f"msg_type 49 处理失败: {e}")
abm.message.append(Plain("[XML 消息处理失败]"))
abm.message_str = "[XML 消息处理失败]"
else:
logger.warning(f"收到未处理的消息类型: {msg_type}")
@@ -7,11 +7,17 @@ import aiohttp
from PIL import Image as PILImage # 使用别名避免冲突
from astrbot import logger
from astrbot.core.message.components import Image, Plain # Import Image
from astrbot.core.message.components import (
Image,
Plain,
WechatEmoji,
Record,
) # Import Image
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.platform.astrbot_message import AstrBotMessage, MessageType
from astrbot.core.platform.platform_metadata import PlatformMetadata
from astrbot.core.utils.tencent_record_helper import wav_to_tencent_silk_base64
if TYPE_CHECKING:
from .wechatpadpro_adapter import WeChatPadProAdapter
@@ -38,6 +44,10 @@ class WeChatPadProMessageEvent(AstrMessageEvent):
await self._send_text(session, comp.text)
elif isinstance(comp, Image):
await self._send_image(session, comp)
elif isinstance(comp, WechatEmoji):
await self._send_emoji(session, comp)
elif isinstance(comp, Record):
await self._send_voice(session, comp)
await super().send(message)
async def _send_image(self, session: aiohttp.ClientSession, comp: Image):
@@ -73,12 +83,42 @@ class WeChatPadProMessageEvent(AstrMessageEvent):
message_text = text
payload = {
"MsgItem": [
{"MsgType": 1, "TextContent": message_text, "ToUserName": self.session_id}
{
"MsgType": 1,
"TextContent": message_text,
"ToUserName": self.session_id,
}
]
}
url = f"{self.adapter.base_url}/message/SendTextMessage"
await self._post(session, url, payload)
async def _send_emoji(self, session: aiohttp.ClientSession, comp: WechatEmoji):
payload = {
"EmojiList": [
{
"EmojiMd5": comp.md5,
"EmojiSize": comp.md5_len,
"ToUserName": self.session_id,
}
]
}
url = f"{self.adapter.base_url}/message/SendEmojiMessage"
await self._post(session, url, payload)
async def _send_voice(self, session: aiohttp.ClientSession, comp: Record):
record_path = await comp.convert_to_file_path()
# 默认已经存在 data/temp 中
b64, duration = await wav_to_tencent_silk_base64(record_path)
payload = {
"ToUserName": self.session_id,
"VoiceData": b64,
"VoiceFormat": 4,
"VoiceSecond": duration,
}
url = f"{self.adapter.base_url}/message/SendVoice"
await self._post(session, url, payload)
@staticmethod
def _validate_base64(b64: str) -> bytes:
return base64.b64decode(b64, validate=True)
@@ -0,0 +1,160 @@
from defusedxml import ElementTree as eT
from astrbot.api import logger
from astrbot.api.message_components import (
WechatEmoji as Emoji,
Plain,
Image,
BaseMessageComponent,
)
class GeweDataParser:
def __init__(
self,
content: str,
is_private_chat: bool = False,
cached_texts=None,
cached_images=None,
raw_message: dict = None,
downloader=None,
):
self._xml = None
self.content = content
self.is_private_chat = is_private_chat
self.cached_texts = cached_texts or {}
self.cached_images = cached_images or {}
self.downloader = downloader
raw_message = raw_message or {}
self.from_user_name = raw_message.get("from_user_name", {}).get("str", "")
self.to_user_name = raw_message.get("to_user_name", {}).get("str", "")
self.msg_id = raw_message.get("msg_id", "")
def _format_to_xml(self):
if self._xml:
return self._xml
try:
msg_str = self.content
if not self.is_private_chat:
parts = self.content.split(":\n", 1)
msg_str = parts[1] if len(parts) == 2 else self.content
self._xml = eT.fromstring(msg_str)
return self._xml
except Exception as e:
logger.error(f"[XML解析失败] {e}")
raise
async def parse_mutil_49(self) -> list[BaseMessageComponent] | None:
"""
处理 msg_type == 49 的多种 appmsg 类型(目前支持 type==57
"""
try:
appmsg_type = self._format_to_xml().findtext(".//appmsg/type")
if appmsg_type == "57":
return await self.parse_reply()
except Exception as e:
logger.warning(f"[parse_mutil_49] 解析失败: {e}")
return None
async def parse_reply(self) -> list[BaseMessageComponent]:
"""
处理 type == 57 的引用消息:支持文本(1)、图片(3)、嵌套49(49)
"""
components = []
try:
appmsg = self._format_to_xml().find("appmsg")
if appmsg is None:
return [Plain("[引用消息解析失败]")]
refermsg = appmsg.find("refermsg")
if refermsg is None:
return [Plain("[引用消息解析失败]")]
quote_type = int(refermsg.findtext("type", "0"))
nickname = refermsg.findtext("displayname", "未知发送者")
quote_content = refermsg.findtext("content", "")
svrid = refermsg.findtext("svrid")
match quote_type:
case 1: # 文本引用
quoted_text = self.cached_texts.get(str(svrid), quote_content)
components.append(Plain(f"[引用] {nickname}: {quoted_text}"))
case 3: # 图片引用
quoted_image_b64 = self.cached_images.get(str(svrid))
if not quoted_image_b64:
try:
quote_xml = eT.fromstring(quote_content)
img = quote_xml.find("img")
cdn_url = (
img.get("cdnbigimgurl") or img.get("cdnmidimgurl")
if img is not None
else None
)
if cdn_url and self.downloader:
image_resp = await self.downloader(
self.from_user_name, self.to_user_name, self.msg_id
)
quoted_image_b64 = (
image_resp.get("Data", {})
.get("Data", {})
.get("Buffer")
)
except Exception as e:
logger.warning(f"[引用图片解析失败] svrid={svrid} err={e}")
if quoted_image_b64:
components.extend(
[
Image.fromBase64(quoted_image_b64),
Plain(f"[引用] {nickname}: [引用的图片]"),
]
)
else:
components.append(
Plain(f"[引用] {nickname}: [引用的图片 - 未能获取]")
)
case 49: # 嵌套引用
try:
nested_root = eT.fromstring(quote_content)
nested_title = nested_root.findtext(".//appmsg/title", "")
components.append(Plain(f"[引用] {nickname}: {nested_title}"))
except Exception as e:
logger.warning(f"[嵌套引用解析失败] err={e}")
components.append(Plain(f"[引用] {nickname}: [嵌套引用消息]"))
case _: # 其他未识别类型
logger.info(f"[未知引用类型] quote_type={quote_type}")
components.append(Plain(f"[引用] {nickname}: [不支持的引用类型]"))
# 主消息标题
title = appmsg.findtext("title", "")
if title:
components.append(Plain(title))
except Exception as e:
logger.error(f"[parse_reply] 总体解析失败: {e}")
return [Plain("[引用消息解析失败]")]
return components
def parse_emoji(self) -> Emoji | None:
"""
处理 msg_type == 47 的表情消息(emoji
"""
try:
emoji_element = self._format_to_xml().find(".//emoji")
if emoji_element is not None:
return Emoji(
md5=emoji_element.get("md5"),
md5_len=emoji_element.get("len"),
cdnurl=emoji_element.get("cdnurl"),
)
except Exception as e:
logger.error(f"[parse_emoji] 解析失败: {e}")
return None
+4 -1
View File
@@ -19,6 +19,7 @@ class ProviderType(enum.Enum):
CHAT_COMPLETION = "chat_completion"
SPEECH_TO_TEXT = "speech_to_text"
TEXT_TO_SPEECH = "text_to_speech"
EMBEDDING = "embedding"
@dataclass
@@ -155,7 +156,9 @@ class ProviderRequest:
if self.image_urls:
user_content = {
"role": "user",
"content": [{"type": "text", "text": self.prompt if self.prompt else "[图片]"}],
"content": [
{"type": "text", "text": self.prompt if self.prompt else "[图片]"}
],
}
for image_url in self.image_urls:
if image_url.startswith("http"):
+14
View File
@@ -98,6 +98,8 @@ class ProviderManager:
"""加载的 Speech To Text Provider 的实例"""
self.tts_provider_insts: List[TTSProvider] = []
"""加载的 Text To Speech Provider 的实例"""
self.embedding_provider_insts: List[Provider] = []
"""加载的 Embedding Provider 的实例"""
self.inst_map = {}
"""Provider 实例映射. key: provider_id, value: Provider 实例"""
self.llm_tools = llm_tools
@@ -211,6 +213,10 @@ class ProviderManager:
from .sources.volcengine_tts import (
ProviderVolcengineTTS as ProviderVolcengineTTS,
)
case "openai_embedding":
from .sources.openai_embedding_source import (
OpenAIEmbeddingProvider as OpenAIEmbeddingProvider,
)
except (ImportError, ModuleNotFoundError) as e:
logger.critical(
f"加载 {provider_config['type']}({provider_config['id']}) 提供商适配器失败:{e}。可能是因为有未安装的依赖。"
@@ -290,6 +296,14 @@ class ProviderManager:
if not self.curr_provider_inst:
self.curr_provider_inst = inst
elif provider_metadata.provider_type == ProviderType.EMBEDDING:
inst = provider_metadata.cls_type(
provider_config, self.provider_settings
)
if getattr(inst, "initialize", None):
await inst.initialize()
self.embedding_provider_insts.append(inst)
self.inst_map[provider_config["id"]] = inst
except Exception as e:
logger.error(traceback.format_exc())
+5
View File
@@ -192,6 +192,11 @@ class EmbeddingProvider(AbstractProvider):
"""获取文本的向量"""
...
@abc.abstractmethod
async def get_embeddings(self, text: list[str]) -> list[list[float]]:
"""批量获取文本的向量"""
...
@abc.abstractmethod
def get_dim(self) -> int:
"""获取向量的维度"""
@@ -104,11 +104,13 @@ class ProviderAnthropic(ProviderOpenAIOfficial):
session_id: str = None,
image_urls: List[str] = [],
func_tool: FuncCall = None,
contexts=[],
contexts=None,
system_prompt=None,
tool_calls_result: ToolCallsResult = None,
**kwargs,
) -> LLMResponse:
if contexts is None:
contexts = []
if not prompt:
prompt = "<image>"
@@ -74,6 +74,8 @@ class ProviderDashscope(ProviderOpenAIOfficial):
system_prompt: str = None,
**kwargs,
) -> LLMResponse:
if contexts is None:
contexts = []
# 获得会话变量
payload_vars = self.variables.copy()
# 动态变量
+3 -1
View File
@@ -61,12 +61,14 @@ class ProviderDify(Provider):
self,
prompt: str,
session_id: str = None,
image_urls: List[str] = [],
image_urls: List[str] = None,
func_tool: FuncCall = None,
contexts: List = None,
system_prompt: str = None,
**kwargs,
) -> LLMResponse:
if image_urls is None:
image_urls = []
result = ""
conversation_id = self.conversation_ids.get(session_id, "")
@@ -0,0 +1,63 @@
from google import genai
from google.genai import types
from google.genai.errors import APIError
from ..provider import EmbeddingProvider
from ..register import register_provider_adapter
from ..entities import ProviderType
@register_provider_adapter(
"gemini_embedding",
"Google Gemini Embedding 提供商适配器",
provider_type=ProviderType.EMBEDDING,
)
class GeminiEmbeddingProvider(EmbeddingProvider):
def __init__(self, provider_config: dict, provider_settings: dict) -> None:
super().__init__(provider_config, provider_settings)
self.provider_config = provider_config
self.provider_settings = provider_settings
api_key: str = provider_config.get("embedding_api_key")
api_base: str = provider_config.get("embedding_api_base", None)
timeout: int = int(provider_config.get("timeout", 20))
http_options = types.HttpOptions(timeout=timeout * 1000)
if api_base:
if api_base.endswith("/"):
api_base = api_base[:-1]
http_options.base_url = api_base
self.client = genai.Client(api_key=api_key, http_options=http_options).aio
self.model = provider_config.get(
"embedding_model", "gemini-embedding-exp-03-07"
)
self.dimension = provider_config.get("embedding_dimensions", 768)
async def get_embedding(self, text: str) -> list[float]:
"""
获取文本的嵌入
"""
try:
result = await self.client.models.embed_content(
model=self.model, contents=text
)
return result.embeddings[0].values
except APIError as e:
raise Exception(f"Gemini Embedding API请求失败: {e.message}")
async def get_embeddings(self, texts: list[str]) -> list[list[float]]:
"""
批量获取文本的嵌入
"""
try:
result = await self.client.models.embed_content(
model=self.model, contents=texts
)
return [embedding.values for embedding in result.embeddings]
except APIError as e:
raise Exception(f"Gemini Embedding API批量请求失败: {e.message}")
def get_dim(self) -> int:
"""获取向量的维度"""
return self.dimension
@@ -60,10 +60,12 @@ class LLMTunerModelLoader(Provider):
session_id: str = None,
image_urls: List[str] = None,
func_tool: FuncCall = None,
contexts: List = [],
contexts: List = None,
system_prompt: str = None,
**kwargs,
) -> LLMResponse:
if contexts is None:
contexts = []
system_prompt = ""
new_record = {"role": "user", "content": prompt}
query_context = [*contexts, new_record]
@@ -0,0 +1,43 @@
from openai import AsyncOpenAI
from ..provider import EmbeddingProvider
from ..register import register_provider_adapter
from ..entities import ProviderType
@register_provider_adapter(
"openai_embedding",
"OpenAI API Embedding 提供商适配器",
provider_type=ProviderType.EMBEDDING,
)
class OpenAIEmbeddingProvider(EmbeddingProvider):
def __init__(self, provider_config: dict, provider_settings: dict) -> None:
super().__init__(provider_config, provider_settings)
self.provider_config = provider_config
self.provider_settings = provider_settings
self.client = AsyncOpenAI(
api_key=provider_config.get("embedding_api_key"),
base_url=provider_config.get(
"embedding_api_base", "https://api.openai.com/v1"
),
timeout=int(provider_config.get("timeout", 20)),
)
self.model = provider_config.get("embedding_model", "text-embedding-3-small")
self.dimension = provider_config.get("embedding_dimensions", 1536)
async def get_embedding(self, text: str) -> list[float]:
"""
获取文本的嵌入
"""
embedding = await self.client.embeddings.create(input=text, model=self.model)
return embedding.data[0].embedding
async def get_embeddings(self, texts: list[str]) -> list[list[float]]:
"""
批量获取文本的嵌入
"""
embeddings = await self.client.embeddings.create(input=texts, model=self.model)
return [item.embedding for item in embeddings.data]
def get_dim(self) -> int:
"""获取向量的维度"""
return self.dimension
@@ -31,10 +31,12 @@ class ProviderZhipu(ProviderOpenAIOfficial):
session_id: str = None,
image_urls: List[str] = None,
func_tool: FuncCall = None,
contexts=[],
contexts=None,
system_prompt=None,
**kwargs,
) -> LLMResponse:
if contexts is None:
contexts = []
new_record = await self.assemble_context(prompt, image_urls)
context_query = []
+9 -6
View File
@@ -125,11 +125,8 @@ class Context:
self.provider_manager.provider_insts.append(provider)
def get_provider_by_id(self, provider_id: str) -> Provider:
"""通过 ID 获取用于文本生成任务的 LLM Provider(Chat_Completion 类型)。"""
for provider in self.provider_manager.provider_insts:
if provider.meta().id == provider_id:
return provider
return None
"""通过 ID 获取对应的 LLM Provider(Chat_Completion 类型)。"""
return self.provider_manager.inst_map.get(provider_id)
def get_all_providers(self) -> List[Provider]:
"""获取所有用于文本生成任务的 LLM Provider(Chat_Completion 类型)。"""
@@ -301,5 +298,11 @@ class Context:
"""
self._register_tasks.append(task)
def register_web_api(self, route: str, view_handler: Awaitable, methods: list, desc: str):
def register_web_api(
self, route: str, view_handler: Awaitable, methods: list, desc: str
):
for idx, api in enumerate(self.registered_web_apis):
if api[0] == route and methods == api[2]:
self.registered_web_apis[idx] = (route, view_handler, methods, desc)
return
self.registered_web_apis.append((route, view_handler, methods, desc))
+21 -12
View File
@@ -37,6 +37,12 @@ except ImportError:
if os.getenv("ASTRBOT_RELOAD", "0") == "1":
logger.warning("未安装 watchfiles,无法实现插件的热重载。")
try:
import nh3
except ImportError:
logger.warning("未安装 nh3 库,无法清理插件 README.md 中的 HTML 标签。")
nh3 = None
class PluginManager:
def __init__(self, context: Context, config: AstrBotConfig):
@@ -140,11 +146,13 @@ class PluginManager:
if os.path.exists(os.path.join(path, d, "main.py")) or os.path.exists(
os.path.join(path, d, d + ".py")
):
modules.append({
"pname": d,
"module": module_str,
"module_path": os.path.join(path, d, module_str),
})
modules.append(
{
"pname": d,
"module": module_str,
"module_path": os.path.join(path, d, module_str),
}
)
return modules
def _get_plugin_modules(self) -> List[dict]:
@@ -158,7 +166,7 @@ class PluginManager:
plugins.extend(_p)
return plugins
def _check_plugin_dept_update(self, target_plugin: str = None):
async def _check_plugin_dept_update(self, target_plugin: str = None):
"""检查插件的依赖
如果 target_plugin 为 None,则检查所有插件的依赖
"""
@@ -177,7 +185,7 @@ class PluginManager:
pth = os.path.join(plugin_path, "requirements.txt")
logger.info(f"正在安装插件 {p} 所需的依赖库: {pth}")
try:
pip_installer.install(requirements_path=pth)
await pip_installer.install(requirements_path=pth)
except Exception as e:
logger.error(f"更新插件 {p} 的依赖失败。Code: {str(e)}")
@@ -399,7 +407,7 @@ class PluginManager:
module = __import__(path, fromlist=[module_str])
except (ModuleNotFoundError, ImportError):
# 尝试安装依赖
self._check_plugin_dept_update(target_plugin=root_dir_name)
await self._check_plugin_dept_update(target_plugin=root_dir_name)
module = __import__(path, fromlist=[module_str])
except Exception as e:
logger.error(traceback.format_exc())
@@ -443,11 +451,11 @@ class PluginManager:
metadata.repo = metadata_yaml.repo
except Exception:
pass
metadata.config = plugin_config
if path not in inactivated_plugins:
# 只有没有禁用插件时才实例化插件类
if plugin_config:
metadata.config = plugin_config
# metadata.config = plugin_config
try:
metadata.star_cls = metadata.star_cls_type(
context=self.context, config=plugin_config
@@ -634,16 +642,17 @@ class PluginManager:
if not os.path.exists(readme_path):
readme_path = os.path.join(plugin_path, "readme.md")
if os.path.exists(readme_path):
if os.path.exists(readme_path) and nh3:
try:
with open(readme_path, "r", encoding="utf-8") as f:
readme_content = f.read()
cleaned_content = nh3.clean(readme_content)
except Exception as e:
logger.warning(f"读取插件 {dir_name} 的 README.md 文件失败: {str(e)}")
plugin_info = None
if plugin:
plugin_info = {"repo": plugin.repo, "readme": readme_content}
plugin_info = {"repo": plugin.repo, "readme": cleaned_content}
return plugin_info
+3 -2
View File
@@ -18,7 +18,8 @@ class PluginUpdator(RepoZipUpdator):
return self.plugin_store_path
async def install(self, repo_url: str, proxy="") -> str:
repo_name = self.format_repo_name(repo_url)
_, repo_name, _ = self.parse_github_url(repo_url)
repo_name = self.format_name(repo_name)
plugin_path = os.path.join(self.plugin_store_path, repo_name)
await self.download_from_repo_url(plugin_path, repo_url, proxy)
self.unzip_file(plugin_path + ".zip", plugin_path)
@@ -54,7 +55,7 @@ class PluginUpdator(RepoZipUpdator):
def unzip_file(self, zip_path: str, target_dir: str):
os.makedirs(target_dir, exist_ok=True)
update_dir = ""
logger.info(f"解压文件: {zip_path}")
logger.info(f"正在解压压缩包: {zip_path}")
with zipfile.ZipFile(zip_path, "r") as z:
update_dir = z.namelist()[0]
z.extractall(target_dir)
+25 -8
View File
@@ -1,5 +1,5 @@
import logging
from pip import main as pip_main
import asyncio
logger = logging.getLogger("astrbot")
@@ -9,7 +9,7 @@ class PipInstaller:
self.pip_install_arg = pip_install_arg
self.pypi_index_url = pypi_index_url
def install(
async def install(
self,
package_name: str = None,
requirements_path: str = None,
@@ -29,12 +29,29 @@ class PipInstaller:
args.extend(self.pip_install_arg.split())
logger.info(f"Pip 包管理器: pip {' '.join(args)}")
try:
process = await asyncio.create_subprocess_exec(
"pip", *args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
result_code = pip_main(args)
assert process.stdout is not None
async for line in process.stdout:
logger.info(line.decode().strip())
# 清除 pip.main 导致的多余的 logging handlers
for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler)
await process.wait()
if result_code != 0:
raise Exception(f"安装失败,错误码:{result_code}")
if process.returncode != 0:
raise Exception(f"安装失败,错误码:{process.returncode}")
except FileNotFoundError:
# 没有 pip
from pip import main as pip_main
result_code = await asyncio.to_thread(pip_main, args)
# 清除 pip.main 导致的多余的 logging handlers
for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler)
if result_code != 0:
raise Exception(f"安装失败,错误码:{result_code}")
@@ -1,5 +1,10 @@
import base64
import wave
import os
from io import BytesIO
import asyncio
import tempfile
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
async def tencent_silk_to_wav(silk_path: str, output_path: str) -> str:
@@ -50,3 +55,46 @@ async def wav_to_tencent_silk(wav_path: str, output_path: str) -> int:
rate = wav.getframerate()
duration = pilk.encode(wav_path, output_path, pcm_rate=rate, tencent=True)
return duration
async def wav_to_tencent_silk_base64(wav_path: str) -> str:
"""
将 WAV 文件转为 Silk,并返回 Base64 字符串。
默认采样率为 24000,输出临时文件为 temp/output.silk。
参数:
- wav_path: 输入 .wav 文件路径(需为 PCM 16bit
返回:
- Base64 编码的 Silk 字符串
- duration: 音频时长(秒)
"""
try:
import pilk
except ImportError as e:
raise Exception("pysilk 模块未安装,请安装 pysilk") from e
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
os.makedirs(temp_dir, exist_ok=True)
with wave.open(wav_path, "rb") as wav:
rate = wav.getframerate()
with tempfile.NamedTemporaryFile(
suffix=".silk", delete=False, dir=temp_dir
) as tmp_file:
silk_path = tmp_file.name
try:
duration = await asyncio.to_thread(
pilk.encode, wav_path, silk_path, pcm_rate=rate, tencent=True
)
with open(silk_path, "rb") as f:
silk_bytes = await asyncio.to_thread(f.read)
silk_b64 = base64.b64encode(silk_bytes).decode("utf-8")
return silk_b64, duration # 已是秒
finally:
if os.path.exists(silk_path):
os.remove(silk_path)
+44 -22
View File
@@ -1,5 +1,6 @@
import aiohttp
import os
import re
import zipfile
import shutil
@@ -119,28 +120,60 @@ class RepoZipUpdator:
)
async def download_from_repo_url(self, target_path: str, repo_url: str, proxy=""):
repo_namespace = repo_url.split("/")[-2:]
author = repo_namespace[0]
repo = repo_namespace[1]
author, repo, branch = self.parse_github_url(repo_url)
logger.info(f"正在下载更新 {repo} ...")
release_url = f"https://api.github.com/repos/{author}/{repo}/releases"
releases = await self.fetch_release_info(url=release_url)
if not releases:
# download from the default branch directly.
logger.info(f"正在从默认分支下载 {author}/{repo} ")
if branch:
logger.info(f"正在从指定分支 {branch} 下载 {author}/{repo}")
release_url = (
f"https://github.com/{author}/{repo}/archive/refs/heads/master.zip"
f"https://github.com/{author}/{repo}/archive/refs/heads/{branch}.zip"
)
else:
release_url = releases[0]["zipball_url"]
try:
release_url = f"https://api.github.com/repos/{author}/{repo}/releases"
releases = await self.fetch_release_info(url=release_url)
except Exception as e:
logger.warning(
f"获取 {author}/{repo} 的 GitHub Releases 失败: {e},将尝试下载默认分支"
)
releases = []
if not releases:
# 如果没有最新版本,下载默认分支
logger.info(f"正在从默认分支下载 {author}/{repo}")
release_url = (
f"https://github.com/{author}/{repo}/archive/refs/heads/master.zip"
)
else:
release_url = releases[0]["zipball_url"]
if proxy:
release_url = f"{proxy}/{release_url}"
logger.info(f"使用代理下载: {release_url}")
logger.info(
f"检查到设置了镜像站,将使用镜像站下载 {author}/{repo} 仓库源码: {release_url}"
)
await download_file(release_url, target_path + ".zip")
def parse_github_url(self, url: str):
"""使用正则表达式解析 GitHub 仓库 URL,支持 `.git` 后缀和 `tree/branch` 结构
Returns:
tuple[str, str, str]: 返回作者名、仓库名和分支名
Raises:
ValueError: 如果 URL 格式不正确
"""
cleaned_url = url.rstrip("/")
pattern = r"^https://github\.com/([a-zA-Z0-9_-]+)/([a-zA-Z0-9_-]+)(\.git)?(?:/tree/([a-zA-Z0-9_-]+))?$"
match = re.match(pattern, cleaned_url)
if match:
author = match.group(1)
repo = match.group(2)
branch = match.group(4)
return author, repo, branch
else:
raise ValueError("无效的 GitHub URL")
def unzip_file(self, zip_path: str, target_dir: str):
"""
解压缩文件, 并将压缩包内**第一个**文件夹内的文件移动到 target_dir
@@ -174,16 +207,5 @@ class RepoZipUpdator:
f"删除更新文件失败,可以手动删除 {zip_path}{os.path.join(target_dir, update_dir)}"
)
def format_repo_name(self, repo_url: str) -> str:
if repo_url.endswith("/"):
repo_url = repo_url[:-1]
repo_namespace = repo_url.split("/")[-2:]
repo = repo_namespace[1]
repo = self.format_name(repo)
return repo
def format_name(self, name: str) -> str:
return name.replace("-", "_").lower()
+8 -2
View File
@@ -1,5 +1,6 @@
import jwt
import datetime
import asyncio
from .route import Route, Response, RouteContext
from quart import request
from astrbot.core import WEBUI_SK, DEMO_MODE
@@ -21,7 +22,11 @@ class AuthRoute(Route):
post_data = await request.json
if post_data["username"] == username and post_data["password"] == password:
change_pwd_hint = False
if username == "astrbot" and password == "77b90590a8945a7d36c963981a307dc9":
if (
username == "astrbot"
and password == "77b90590a8945a7d36c963981a307dc9"
and not DEMO_MODE
):
change_pwd_hint = True
logger.warning("为了保证安全,请尽快修改默认密码。")
@@ -37,6 +42,7 @@ class AuthRoute(Route):
.__dict__
)
else:
await asyncio.sleep(3)
return Response().error("用户名或密码错误").__dict__
async def edit_account(self):
@@ -72,7 +78,7 @@ class AuthRoute(Route):
def generate_jwt(self, username):
payload = {
"username": username,
"exp": datetime.datetime.utcnow() + datetime.timedelta(days=30),
"exp": datetime.datetime.utcnow() + datetime.timedelta(days=7),
}
token = jwt.encode(payload, WEBUI_SK, algorithm="HS256")
return token
+23 -16
View File
@@ -61,16 +61,25 @@ class ChatRoute(Route):
return Response().error("Missing key: filename").__dict__
try:
with open(os.path.join(self.imgs_dir, filename), "rb") as f:
if filename.endswith(".wav"):
file_path = os.path.join(self.imgs_dir, os.path.basename(filename))
real_file_path = os.path.realpath(file_path)
real_imgs_dir = os.path.realpath(self.imgs_dir)
if not real_file_path.startswith(real_imgs_dir):
return Response().error("Invalid file path").__dict__
with open(real_file_path, "rb") as f:
filename_ext = os.path.splitext(filename)[1].lower()
if filename_ext == ".wav":
return QuartResponse(f.read(), mimetype="audio/wav")
elif filename.split(".")[-1] in self.supported_imgs:
elif filename_ext[1:] in self.supported_imgs:
return QuartResponse(f.read(), mimetype="image/jpeg")
else:
return QuartResponse(f.read())
except FileNotFoundError:
return Response().error("File not found").__dict__
except (FileNotFoundError, OSError):
return Response().error("File access error").__dict__
async def post_image(self):
post_data = await request.files
@@ -126,17 +135,15 @@ class ChatRoute(Route):
self.curr_user_cid[username] = conversation_id
await web_chat_queue.put(
(
username,
conversation_id,
{
"message": message,
"image_url": image_url, # list
"audio_url": audio_url,
},
)
)
await web_chat_queue.put((
username,
conversation_id,
{
"message": message,
"image_url": image_url, # list
"audio_url": audio_url,
},
))
# 持久化
conversation = self.db.get_conversation_by_user_id(username, conversation_id)
+88
View File
@@ -9,6 +9,7 @@ from astrbot.core.platform.register import platform_registry
from astrbot.core.provider.register import provider_registry
from astrbot.core.star.star import star_registry
from astrbot.core import logger
import asyncio
def try_cast(value: str, type_: str):
@@ -164,9 +165,85 @@ class ConfigRoute(Route):
"/config/provider/update": ("POST", self.post_update_provider),
"/config/provider/delete": ("POST", self.post_delete_provider),
"/config/llmtools": ("GET", self.get_llm_tools),
"/config/provider/check_status": ("GET", self.check_all_providers_status),
"/config/provider/list": ("GET", self.get_provider_config_list),
}
self.register_routes()
async def _test_single_provider(self, provider):
"""辅助函数:测试单个 provider 的可用性"""
meta = provider.meta()
provider_name = provider.provider_config.get("id", "Unknown Provider")
logger.debug(f"Got provider meta: {meta}")
if not provider_name and meta:
provider_name = meta.id
elif not provider_name:
provider_name = "Unknown Provider"
status_info = {
"id": getattr(meta, 'id', 'Unknown ID'),
"model": getattr(meta, 'model', 'Unknown Model'),
"type": getattr(meta, 'type', 'Unknown Type'),
"name": provider_name,
"status": "unavailable", # 默认为不可用
"error": None,
}
logger.debug(f"Attempting to check provider: {status_info['name']} (ID: {status_info['id']}, Type: {status_info['type']}, Model: {status_info['model']})")
try:
logger.debug(f"Sending 'Ping' to provider: {status_info['name']}")
response = await asyncio.wait_for(provider.text_chat(prompt="REPLY `PONG` ONLY"), timeout=45.0)
logger.debug(f"Received response from {status_info['name']}: {response}")
# 只要 text_chat 调用成功返回一个 LLMResponse 对象 (即 response 不为 None),就认为可用
if response is not None:
status_info["status"] = "available"
response_text_snippet = ""
if hasattr(response, 'completion_text') and response.completion_text:
response_text_snippet = response.completion_text[:70] + "..." if len(response.completion_text) > 70 else response.completion_text
elif hasattr(response, 'result_chain') and response.result_chain:
try:
response_text_snippet = response.result_chain.get_plain_text()[:70] + "..." if len(response.result_chain.get_plain_text()) > 70 else response.result_chain.get_plain_text()
except:
pass
logger.info(f"Provider {status_info['name']} (ID: {status_info['id']}) is available. Response snippet: '{response_text_snippet}'")
else:
# 这个分支理论上不应该被走到,除非 text_chat 实现可能返回 None
status_info["error"] = "Test call returned None, but expected an LLMResponse object."
logger.warning(f"Provider {status_info['name']} (ID: {status_info['id']}) test call returned None.")
except asyncio.TimeoutError:
status_info["error"] = "Connection timed out after 45 seconds during test call."
logger.warning(f"Provider {status_info['name']} (ID: {status_info['id']}) timed out.")
except Exception as e:
error_message = str(e)
status_info["error"] = error_message
logger.warning(f"Provider {status_info['name']} (ID: {status_info['id']}) is unavailable. Error: {error_message}")
logger.debug(f"Traceback for {status_info['name']}:\n{traceback.format_exc()}")
return status_info
async def check_all_providers_status(self):
"""
API 接口: 检查所有 LLM Providers 的状态
"""
logger.info("API call received: /config/provider/check_status")
try:
all_providers: typing.List = self.core_lifecycle.star_context.get_all_providers()
logger.debug(f"Found {len(all_providers)} providers to check.")
if not all_providers:
logger.info("No providers found to check.")
return Response().ok([]).__dict__
tasks = [self._test_single_provider(p) for p in all_providers]
logger.debug(f"Created {len(tasks)} tasks for concurrent provider checks.")
results = await asyncio.gather(*tasks)
logger.info(f"Provider status check completed. Results: {results}")
return Response().ok(results).__dict__
except Exception as e:
logger.error(f"Critical error in check_all_providers_status: {str(e)}")
logger.error(traceback.format_exc())
return Response().error(f"检查 Provider 状态时发生严重错误: {str(e)}").__dict__
async def get_configs(self):
# plugin_name 为空时返回 AstrBot 配置
# 否则返回指定 plugin_name 的插件配置
@@ -175,6 +252,17 @@ class ConfigRoute(Route):
return Response().ok(await self._get_astrbot_config()).__dict__
return Response().ok(await self._get_plugin_config(plugin_name)).__dict__
async def get_provider_config_list(self):
provider_type = request.args.get("provider_type", None)
if not provider_type:
return Response().error("缺少参数 provider_type").__dict__
provider_list = []
astrbot_config = self.core_lifecycle.astrbot_config
for provider in astrbot_config["provider"]:
if provider.get("provider_type", None) == provider_type:
provider_list.append(provider)
return Response().ok(provider_list).__dict__
async def post_astrbot_configs(self):
post_configs = await request.json
try:
+1
View File
@@ -23,6 +23,7 @@ class LogRoute(Route):
**message, # see astrbot/core/log.py
}
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
await asyncio.sleep(0.07) # 控制发送频率,避免过快
except asyncio.CancelledError:
pass
except BaseException as e:
+29 -26
View File
@@ -18,6 +18,12 @@ from astrbot.core.star.filter.regex import RegexFilter
from astrbot.core.star.star_handler import EventType
from astrbot.core import DEMO_MODE
try:
import nh3
except ImportError:
logger.warning("未安装 nh3 库,无法清理插件 README.md 中的 HTML 标签。")
nh3 = None
class PluginRoute(Route):
def __init__(
@@ -148,9 +154,7 @@ class PluginRoute(Route):
if handler.event_type == EventType.AdapterMessageEvent:
# 处理平台适配器消息事件
has_admin = False
for (
filter
) in (
for filter in (
handler.event_filters
): # 正常handler就只有 1~2 个 filter,因此这里时间复杂度不会太高
if isinstance(filter, CommandFilter):
@@ -328,6 +332,9 @@ class PluginRoute(Route):
return Response().error(str(e)).__dict__
async def get_plugin_readme(self):
if not nh3:
return Response().error("未安装 nh3 库").__dict__
plugin_name = request.args.get("name")
logger.debug(f"正在获取插件 {plugin_name} 的README文件内容")
@@ -363,9 +370,11 @@ class PluginRoute(Route):
with open(readme_path, "r", encoding="utf-8") as f:
readme_content = f.read()
cleaned_content = nh3.clean(readme_content)
return (
Response()
.ok({"content": readme_content}, "成功获取README内容")
.ok({"content": cleaned_content}, "成功获取README内容")
.__dict__
)
except Exception as e:
@@ -386,14 +395,12 @@ class PluginRoute(Route):
platform_type = platform.get("type", "")
platform_id = platform.get("id", "")
platforms.append(
{
"name": platform_id, # 使用type作为name,这是系统内部使用的平台名称
"id": platform_id, # 保留id字段以便前端可以显示
"type": platform_type,
"display_name": f"{platform_type}({platform_id})",
}
)
platforms.append({
"name": platform_id, # 使用type作为name,这是系统内部使用的平台名称
"id": platform_id, # 保留id字段以便前端可以显示
"type": platform_type,
"display_name": f"{platform_type}({platform_id})",
})
adjusted_platform_enable = {}
for platform_id, plugins in platform_enable.items():
@@ -402,13 +409,11 @@ class PluginRoute(Route):
# 获取所有插件,包括系统内部插件
plugins = []
for plugin in self.plugin_manager.context.get_all_stars():
plugins.append(
{
"name": plugin.name,
"desc": plugin.desc,
"reserved": plugin.reserved, # 添加reserved标志
}
)
plugins.append({
"name": plugin.name,
"desc": plugin.desc,
"reserved": plugin.reserved, # 添加reserved标志
})
logger.debug(
f"获取插件平台配置: 原始配置={platform_enable}, 调整后={adjusted_platform_enable}"
@@ -416,13 +421,11 @@ class PluginRoute(Route):
return (
Response()
.ok(
{
"platforms": platforms,
"plugins": plugins,
"platform_enable": adjusted_platform_enable,
}
)
.ok({
"platforms": platforms,
"plugins": plugins,
"platform_enable": adjusted_platform_enable,
})
.__dict__
)
except Exception as e:
+20 -4
View File
@@ -46,11 +46,27 @@ class StatRoute(Route):
h, m = divmod(m, 60)
return f"{h}小时{m}{s}"
def is_default_cred(self):
username = self.config["dashboard"]["username"]
password = self.config["dashboard"]["password"]
return (
username == "astrbot"
and password == "77b90590a8945a7d36c963981a307dc9"
and not DEMO_MODE
)
async def get_version(self):
return Response().ok({
"version": VERSION,
"dashboard_version": await get_dashboard_version(),
}).__dict__
return (
Response()
.ok(
{
"version": VERSION,
"dashboard_version": await get_dashboard_version(),
"change_pwd_hint": self.is_default_cred(),
}
)
.__dict__
)
async def get_start_time(self):
return Response().ok({"start_time": self.core_lifecycle.start_time}).__dict__
+4 -1
View File
@@ -12,7 +12,10 @@ class StaticFileRoute(Route):
"/logs",
"/extension",
"/dashboard/default",
"/project-atri",
"/alkaid",
"/alkaid/knowledge-base",
"/alkaid/long-term-memory",
"/alkaid/other",
"/console",
"/chat",
"/settings",
+2 -2
View File
@@ -91,7 +91,7 @@ class UpdateRoute(Route):
# pip 更新依赖
logger.info("更新依赖中...")
try:
pip_installer.install(requirements_path="requirements.txt")
await pip_installer.install(requirements_path="requirements.txt")
except Exception as e:
logger.error(f"更新依赖失败: {e}")
@@ -140,7 +140,7 @@ class UpdateRoute(Route):
if not package:
return Response().error("缺少参数 package 或不合法。").__dict__
try:
pip_installer.install(package, mirror=mirror)
await pip_installer.install(package, mirror=mirror)
return Response().ok(None, "安装成功。").__dict__
except Exception as e:
logger.error(f"/api/update_pip: {traceback.format_exc()}")
+2 -2
View File
@@ -70,13 +70,13 @@ class AstrBotDashboard:
for api in registered_web_apis:
route, view_handler, methods, _ = api
if route == f"/{subpath}" and request.method in methods:
return await view_handler(*args, **kwargs)
return await view_handler(*args, **kwargs)
return jsonify(Response().error("未找到该路由").__dict__)
async def auth_middleware(self):
if not request.path.startswith("/api"):
return
allowed_endpoints = ["/api/auth/login", "/api/chat/get_file", "/api/file"]
allowed_endpoints = ["/api/auth/login", "/api/file"]
if any(request.path.startswith(prefix) for prefix in allowed_endpoints):
return
# claim jwt
+9
View File
@@ -0,0 +1,9 @@
# What's Changed
1. 新增:WebUI 支持暗夜模式。
2. 修复:修复 WebUI Chat 接口的未授权访问安全漏洞、插件 README 可能存在的 XSS 注入漏洞。
3. 优化:优化 Vec DB 在 indexing 过程时的数据库事务处理。
4. 修复:WebUI 下,插件市场的推荐卡片无法点击帮助文档的问题。
5. 新增:知识库。
6. 新增:WebUI 提供商测试功能,一键检测可用性。
7. 新增:WebUI 提供商分类功能,按能力分类提供商。
+11
View File
@@ -0,0 +1,11 @@
# What's Changed
1. 优化:强化了 WebUI 安全性
2. 修复:测试文本生成提供商时可能出现的误报
3. 修复:刷新知识库页面时出现404
4. 新增:WeChatPadPro 支持获取引用、语音收发、视频等消息段
5. 优化:WebUI 账户修改页面的设计逻辑
6. 优化:插件更新后自动刷新插件列表
7. 新增:支持下载插件的指定分支
8. 修复:WeChatPadPro 群聊模式下 @ 不回复等问题
9. 其他更新、优化及修复
@@ -340,12 +340,12 @@ export default {
.config-title {
font-weight: 600;
font-size: 1rem;
color: var(--v-primary-darken1);
color: var(--v-theme-primaryText);
}
.config-hint {
font-size: 0.75rem;
color: rgba(0, 0, 0, 0.6);
color: var(--v-theme-secondaryText);
margin-top: 2px;
}
@@ -400,12 +400,12 @@ export default {
.property-name {
font-size: 0.875rem;
font-weight: 600;
color: rgba(0, 0, 0, 0.87);
color: var(--v-theme-primaryText);
}
.property-hint {
font-size: 0.75rem;
color: rgba(0, 0, 0, 0.6);
color: var(--v-theme-secondaryText);
margin-top: 2px;
}
@@ -5,7 +5,7 @@ import { useCommonStore } from '@/stores/common';
<template>
<div>
<!-- 添加筛选级别控件 -->
<div class="filter-controls mb-2">
<div class="filter-controls mb-2" v-if="showLevelBtns">
<v-chip-group v-model="selectedLevels" column multiple>
<v-chip v-for="level in logLevels" :key="level" :color="getLevelColor(level)" filter
:text-color="level === 'DEBUG' || level === 'INFO' ? 'black' : 'white'">
@@ -52,6 +52,10 @@ export default {
historyNum: {
type: String,
default: -1
},
showLevelBtns: {
type: Boolean,
default: true
}
},
watch: {
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref, computed, inject } from 'vue';
import {useCustomizerStore} from "@/stores/customizer";
const props = defineProps({
extension: {
@@ -75,7 +76,9 @@ const viewReadme = () => {
<template>
<v-card class="mx-auto d-flex flex-column" :elevation="highlight ? 0 : 1"
:style="{ height: $vuetify.display.xs ? '250px' : '220px', backgroundColor: highlight ? '#FAF0DB' : '#ffffff', color: highlight ? '#000' : '#000000' }">
:style="{ height: $vuetify.display.xs ? '250px' : '220px',
backgroundColor: useCustomizerStore().uiTheme==='PurpleTheme' ? marketMode ? '#f8f0dd' : '#ffffff' : '#282833',
color: useCustomizerStore().uiTheme==='PurpleTheme' ? '#000000dd' : '#ffffff'}">
<v-card-text style="padding: 16px; padding-bottom: 0px; display: flex; justify-content: space-between;">
<div class="flex-grow-1">
@@ -128,7 +131,7 @@ const viewReadme = () => {
</div>
</v-card-text>
<v-card-actions style="padding: 0px; margin-top: auto;">
<v-card-actions style="margin-left: 0px; gap: 2px;">
<v-btn color="teal-accent-4" text="查看文档" variant="text" @click="viewReadme"></v-btn>
<v-btn v-if="!marketMode" color="teal-accent-4" text="操作" variant="text" @click="reveal = true"></v-btn>
<v-btn v-if="marketMode && !extension?.installed" color="teal-accent-4" text="安装" variant="text"
@@ -104,11 +104,11 @@ export default {
<style scoped>
.list-config-item {
border: 1px solid #e0e0e0;
border: 1px solid var(--v-theme-border);
padding: 16px;
margin-bottom: 8px;
border-radius: 10px;
background-color: #ffffff;
background-color: var(--v-theme-background);
}
.v-list-item {
+13 -5
View File
@@ -5,15 +5,25 @@
<img width="110" src="@/assets/images/astrbot_logo_mini.webp" alt="AstrBot Logo">
</div>
<div class="logo-text">
<h2 class="text-secondary">AstrBot 仪表盘</h2>
<h4 class="text-disabled">登录以继续</h4>
<h2 class="text-secondary">{{ title }}</h2>
<!-- 父子组件传递css变量可能会出错暂时使用十六进制颜色值 -->
<h4 :style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#000000aa' : '#ffffffcc'}"
class="hint-text">{{ subtitle }}</h4>
</div>
</div>
</div>
</template>
<script setup lang="ts">
// No props or other logic needed for this simple component
import { useCustomizerStore } from "@/stores/customizer";
const props = withDefaults(defineProps<{
title?: string;
subtitle?: string;
}>(), {
title: 'AstrBot 仪表盘',
subtitle: '欢迎使用'
})
</script>
<style scoped>
@@ -56,7 +66,6 @@
margin: 0;
font-size: 1.8rem;
font-weight: 600;
color: #5e35b1;
letter-spacing: 0.5px;
}
@@ -64,7 +73,6 @@
margin: 4px 0 0 0;
font-size: 1rem;
font-weight: 400;
color: #616161;
letter-spacing: 0.3px;
}
</style>
+11
View File
@@ -3,14 +3,25 @@ export type ConfigProps = {
Customizer_drawer: boolean;
mini_sidebar: boolean;
fontTheme: string;
uiTheme: string;
inputBg: boolean;
};
function checkUITheme() {
/* 检查localStorage有无记忆的主题选项,如有则使用,否则使用默认值 */
const theme = localStorage.getItem("uiTheme");
if (!theme || !(['PurpleTheme', 'PurpleThemeDark'].includes(theme))) {
localStorage.setItem("uiTheme", "PurpleTheme"); // todo: 这部分可以根据vuetify.ts的默认主题动态调整
return 'PurpleTheme';
} else return theme;
}
const config: ConfigProps = {
Sidebar_drawer: true,
Customizer_drawer: false,
mini_sidebar: false,
fontTheme: 'Roboto',
uiTheme: checkUITheme(),
inputBg: false
};
+2 -3
View File
@@ -2,14 +2,13 @@
import { RouterView } from 'vue-router';
import VerticalSidebarVue from './vertical-sidebar/VerticalSidebar.vue';
import VerticalHeaderVue from './vertical-header/VerticalHeader.vue';
import { useCustomizerStore } from '../../stores/customizer';
import { useCustomizerStore } from '@/stores/customizer';
const customizer = useCustomizerStore();
</script>
<template>
<v-locale-provider>
<v-app
theme="PurpleTheme"
<v-app :theme="useCustomizerStore().uiTheme"
:class="[customizer.fontTheme, customizer.mini_sidebar ? 'mini-sidebar' : '', customizer.inputBg ? 'inputWithbg' : '']"
>
<VerticalHeaderVue />
@@ -1,16 +1,18 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useCustomizerStore } from '../../../stores/customizer';
import {ref, computed} from 'vue';
import {useCustomizerStore} from '@/stores/customizer';
import axios from 'axios';
import { md5 } from 'js-md5';
import { useAuthStore } from '@/stores/auth';
import { useCommonStore } from '@/stores/common';
import { marked } from 'marked';
import Logo from '@/components/shared/Logo.vue';
import {md5} from 'js-md5';
import {useAuthStore} from '@/stores/auth';
import {useCommonStore} from '@/stores/common';
import {marked} from 'marked';
const customizer = useCustomizerStore();
let dialog = ref(false);
let accountWarning = ref(false)
let updateStatusDialog = ref(false);
const username = localStorage.getItem('user');
let password = ref('');
let newPassword = ref('');
let newUsername = ref('');
@@ -23,26 +25,52 @@ let dashboardHasNewVersion = ref(false);
let dashboardCurrentVersion = ref('');
let version = ref('');
let releases = ref([]);
let devCommits = ref([]); // 新增的 ref
let devCommits = ref([]);
let installLoading = ref(false);
let tab = ref(0);
let releasesHeader = [
{ title: '标签', key: 'tag_name' },
{ title: '发布时间', key: 'published_at' },
{ title: '内容', key: 'body' },
{ title: '源码地址', key: 'zipball_url' },
{ title: '操作', key: 'switch' }
{title: '标签', key: 'tag_name'},
{title: '发布时间', key: 'published_at'},
{title: '内容', key: 'body'},
{title: '源码地址', key: 'zipball_url'},
{title: '操作', key: 'switch'}
];
// Form validation
const formValid = ref(true);
const passwordRules = [
(v: string) => !!v || '请输入密码',
(v: string) => v.length >= 8 || '密码长度至少 8 位'
];
const usernameRules = [
(v: string) => !v || v.length >= 3 || '用户名长度至少3位'
];
// 显示密码相关
const showPassword = ref(false);
const showNewPassword = ref(false);
// 账户修改状态
const accountEditStatus = ref({
loading: false,
success: false,
error: false,
message: ''
});
const open = (link: string) => {
window.open(link, '_blank');
};
// 账户修改
function accountEdit() {
accountEditStatus.value.loading = true;
accountEditStatus.value.error = false;
accountEditStatus.value.success = false;
// md5加密
// @ts-ignore
if (password.value != '') {
@@ -54,80 +82,92 @@ function accountEdit() {
axios.post('/api/auth/account/edit', {
password: password.value,
new_password: newPassword.value,
new_username: newUsername.value
new_username: newUsername.value ? newUsername.value : username
})
.then((res) => {
if (res.data.status == 'error') {
status.value = res.data.message;
.then((res) => {
if (res.data.status == 'error') {
accountEditStatus.value.error = true;
accountEditStatus.value.message = res.data.message;
password.value = '';
newPassword.value = '';
return;
}
accountEditStatus.value.success = true;
accountEditStatus.value.message = res.data.message;
setTimeout(() => {
dialog.value = !dialog.value;
const authStore = useAuthStore();
authStore.logout();
}, 2000);
})
.catch((err) => {
console.log(err);
accountEditStatus.value.error = true;
accountEditStatus.value.message = typeof err === 'string' ? err : '修改失败,请重试';
password.value = '';
newPassword.value = '';
return;
}
dialog.value = !dialog.value;
status.value = res.data.message;
setTimeout(() => {
const authStore = useAuthStore();
authStore.logout();
}, 1000);
})
.catch((err) => {
console.log(err);
status.value = err
password.value = '';
newPassword.value = '';
});
})
.finally(() => {
accountEditStatus.value.loading = false;
});
}
function getVersion() {
axios.get('/api/stat/version')
.then((res) => {
botCurrVersion.value = "v" + res.data.data.version;
dashboardCurrentVersion.value = res.data.data?.dashboard_version;
})
.catch((err) => {
console.log(err);
});
.then((res) => {
botCurrVersion.value = "v" + res.data.data.version;
dashboardCurrentVersion.value = res.data.data?.dashboard_version;
let change_pwd_hint = res.data.data?.change_pwd_hint;
if (change_pwd_hint) {
dialog.value = true;
accountWarning.value = true;
localStorage.setItem('change_pwd_hint', 'true');
} else {
localStorage.removeItem('change_pwd_hint');
}
})
.catch((err) => {
console.log(err);
});
}
function checkUpdate() {
updateStatus.value = '正在检查更新...';
axios.get('/api/update/check')
.then((res) => {
hasNewVersion.value = res.data.data.has_new_version;
.then((res) => {
hasNewVersion.value = res.data.data.has_new_version;
if (res.data.data.has_new_version) {
releaseMessage.value = res.data.message;
updateStatus.value = '有新版本!';
} else {
updateStatus.value = res.data.message;
}
dashboardHasNewVersion.value = res.data.data.dashboard_has_new_version;
})
.catch((err) => {
if (err.response.status == 401) {
console.log("401");
const authStore = useAuthStore();
authStore.logout();
return;
}
console.log(err);
updateStatus.value = err
});
if (res.data.data.has_new_version) {
releaseMessage.value = res.data.message;
updateStatus.value = '有新版本!';
} else {
updateStatus.value = res.data.message;
}
dashboardHasNewVersion.value = res.data.data.dashboard_has_new_version;
})
.catch((err) => {
if (err.response.status == 401) {
console.log("401");
const authStore = useAuthStore();
authStore.logout();
return;
}
console.log(err);
updateStatus.value = err
});
}
function getReleases() {
axios.get('/api/update/releases')
.then((res) => {
// releases.value = res.data.data;
// 更新 published_at 的时间为本地时间
releases.value = res.data.data.map((item: any) => {
item.published_at = new Date(item.published_at).toLocaleString();
return item;
.then((res) => {
releases.value = res.data.data.map((item: any) => {
item.published_at = new Date(item.published_at).toLocaleString();
return item;
})
})
})
.catch((err) => {
console.log(err);
});
.catch((err) => {
console.log(err);
});
}
function getDevCommits() {
@@ -137,17 +177,17 @@ function getDevCommits() {
'Referer': 'https://api.github.com'
}
})
.then(response => response.json())
.then(data => {
devCommits.value = data.map((commit: any) => ({
sha: commit.sha,
date: new Date(commit.commit.author.date).toLocaleString(),
message: commit.commit.message
}));
})
.catch(err => {
console.log(err);
});
.then(response => response.json())
.then(data => {
devCommits.value = data.map((commit: any) => ({
sha: commit.sha,
date: new Date(commit.commit.author.date).toLocaleString(),
message: commit.commit.message
}));
})
.catch(err => {
console.log(err);
});
}
function switchVersion(version: string) {
@@ -157,37 +197,41 @@ function switchVersion(version: string) {
version: version,
proxy: localStorage.getItem('selectedGitHubProxy') || ''
})
.then((res) => {
updateStatus.value = res.data.message;
if (res.data.status == 'ok') {
setTimeout(() => {
window.location.reload();
}, 1000);
}
})
.catch((err) => {
console.log(err);
updateStatus.value = err
}).finally(() => {
installLoading.value = false;
});
.then((res) => {
updateStatus.value = res.data.message;
if (res.data.status == 'ok') {
setTimeout(() => {
window.location.reload();
}, 1000);
}
})
.catch((err) => {
console.log(err);
updateStatus.value = err
}).finally(() => {
installLoading.value = false;
});
}
function updateDashboard() {
updateStatus.value = '正在更新...';
axios.post('/api/update/dashboard')
.then((res) => {
updateStatus.value = res.data.message;
if (res.data.status == 'ok') {
setTimeout(() => {
window.location.reload();
}, 1000);
}
})
.catch((err) => {
console.log(err);
updateStatus.value = err
});
.then((res) => {
updateStatus.value = res.data.message;
if (res.data.status == 'ok') {
setTimeout(() => {
window.location.reload();
}, 1000);
}
})
.catch((err) => {
console.log(err);
updateStatus.value = err
});
}
function toggleDarkMode() {
customizer.SET_UI_THEME(customizer.uiTheme === 'PurpleThemeDark' ? 'PurpleTheme' : 'PurpleThemeDark');
}
getVersion();
@@ -197,34 +241,35 @@ const commonStore = useCommonStore();
commonStore.createEventSource(); // log
commonStore.getStartTime();
if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('change_pwd_hint') == 'true') {
dialog.value = true;
accountWarning.value = true;
localStorage.removeItem('change_pwd_hint');
}
</script>
<template>
<v-app-bar elevation="0" height="55">
<v-btn style="margin-left: 22px;" class="hidden-md-and-down text-secondary" color="lightsecondary" icon rounded="sm"
variant="flat" @click.stop="customizer.SET_MINI_SIDEBAR(!customizer.mini_sidebar)" size="small">
<v-btn v-if="useCustomizerStore().uiTheme==='PurpleTheme'" style="margin-left: 22px;" class="hidden-md-and-down text-secondary" color="lightsecondary" icon rounded="sm"
variant="flat" @click.stop="customizer.SET_MINI_SIDEBAR(!customizer.mini_sidebar)" size="small">
<v-icon>mdi-menu</v-icon>
</v-btn>
<v-btn class="hidden-lg-and-up text-secondary ms-3" color="lightsecondary" icon rounded="sm" variant="flat"
@click.stop="customizer.SET_SIDEBAR_DRAWER" size="small">
<v-btn v-else style="margin-left: 22px; color: var(--v-theme-primaryText); background-color: var(--v-theme-secondary)" class="hidden-md-and-down" icon rounded="sm"
variant="flat" @click.stop="customizer.SET_MINI_SIDEBAR(!customizer.mini_sidebar)" size="small">
<v-icon>mdi-menu</v-icon>
</v-btn>
<v-btn v-if="useCustomizerStore().uiTheme==='PurpleTheme'" class="hidden-lg-and-up text-secondary ms-3" color="lightsecondary" icon rounded="sm" variant="flat"
@click.stop="customizer.SET_SIDEBAR_DRAWER" size="small">
<v-icon>mdi-menu</v-icon>
</v-btn>
<v-btn v-else class="hidden-lg-and-up ms-3" icon rounded="sm" variant="flat"
@click.stop="customizer.SET_SIDEBAR_DRAWER" size="small">
<v-icon>mdi-menu</v-icon>
</v-btn>
<div style="margin-left: 16px; display: flex; align-items: center; gap: 8px;">
<span style=" font-size: 24px; font-weight: 1000;">Astr<span style="font-weight: normal;">Bot</span>
</span>
<span style="font-size: 12px; color: #333333;">{{ botCurrVersion }}</span>
<span style="font-size: 12px; color: var(--v-theme-secondaryText);">{{ botCurrVersion }}</span>
</div>
<v-spacer />
<v-spacer/>
<div class="mr-4">
<small v-if="hasNewVersion">
@@ -235,11 +280,18 @@ if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('cha
</small>
</div>
<v-btn size="small" @click="toggleDarkMode();" class="text-primary mr-2" color="var(--v-theme-surface)"
variant="flat" rounded="sm">
<!-- 明暗主题切换按钮 -->
<v-icon v-if="useCustomizerStore().uiTheme === 'PurpleThemeDark'">mdi-weather-night</v-icon>
<v-icon v-else>mdi-white-balance-sunny</v-icon>
</v-btn>
<v-dialog v-model="updateStatusDialog" width="1000">
<template v-slot:activator="{ props }">
<v-btn size="small" @click="checkUpdate(); getReleases(); getDevCommits();" class="text-primary mr-2" color="lightprimary"
variant="flat" rounded="sm" v-bind="props">
<v-btn size="small" @click="checkUpdate(); getReleases(); getDevCommits();" class="text-primary mr-2"
color="var(--v-theme-surface)"
variant="flat" rounded="sm" v-bind="props">
更新
</v-btn>
</template>
@@ -257,15 +309,16 @@ if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('cha
</div>
<div
style="background-color: #646cff24; padding: 16px; border-radius: 10px; font-size: 14px; max-height: 400px; overflow-y: auto;"
v-html="marked(releaseMessage)" class="markdown-content">
style="background-color: #646cff24; padding: 16px; border-radius: 10px; font-size: 14px; max-height: 400px; overflow-y: auto;"
v-html="marked(releaseMessage)" class="markdown-content">
</div>
<div class="mb-4 mt-4">
<small>💡 TIP: 跳到旧版本或者切换到某个版本不会重新下载管理面板文件这可能会造成部分数据显示错误您可在 <a
href="https://github.com/Soulter/AstrBot/releases">此处</a>
找到对应的面板文件 dist.zip解压后替换 data/dist 文件夹即可当然前端源代码在 dashboard 目录下你也可以自己使用 npm install npm build
找到对应的面板文件 dist.zip解压后替换 data/dist 文件夹即可当然前端源代码在 dashboard 目录下你也可以自己使用
npm install npm build
构建</small>
</div>
@@ -278,12 +331,13 @@ if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('cha
<!-- 发行版 -->
<v-tabs-window-item key="0" v-show="tab == 0">
<v-btn class="mt-4 mb-4" @click="switchVersion('latest')" color="primary" style="border-radius: 10px;"
:disabled="!hasNewVersion">
:disabled="!hasNewVersion">
更新到最新版本
</v-btn>
<div class="mb-4">
<small>`更新到最新版本` 按钮会同时尝试更新机器人主程序和管理面板如果您正在使用 Docker 部署也可以重新拉取镜像或者使用 <a
href="https://containrrr.dev/watchtower/usage-overview/">watchtower</a> 来自动监控拉取</small>
<small>`更新到最新版本` 按钮会同时尝试更新机器人主程序和管理面板如果您正在使用 Docker
部署也可以重新拉取镜像或者使用 <a
href="https://containrrr.dev/watchtower/usage-overview/">watchtower</a> 来自动监控拉取</small>
</div>
<v-data-table :headers="releasesHeader" :items="releases" item-key="name">
@@ -306,8 +360,8 @@ if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('cha
<v-tabs-window-item key="1" v-show="tab == 1">
<div style="margin-top: 16px;">
<v-data-table
:headers="[{ title: 'SHA', key: 'sha' }, { title: '日期', key: 'date' }, { title: '信息', key: 'message' }, { title: '操作', key: 'switch' }]"
:items="devCommits" item-key="sha">
:headers="[{ title: 'SHA', key: 'sha' }, { title: '日期', key: 'date' }, { title: '信息', key: 'message' }, { title: '操作', key: 'switch' }]"
:items="devCommits" item-key="sha">
<template v-slot:item.switch="{ item }: { item: { sha: string } }">
<v-btn @click="switchVersion(item.sha)" rounded="xl" variant="plain" color="primary">
切换
@@ -322,12 +376,13 @@ if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('cha
<h3 class="mb-4">手动输入版本号或 Commit SHA</h3>
<v-text-field label="输入版本号或 master 分支下的 commit hash。" v-model="version" required
variant="outlined"></v-text-field>
variant="outlined"></v-text-field>
<div class="mb-4">
<small> v3.3.16 (不带 SHA) 42e5ec5d80b93b6bfe8b566754d45ffac4c3fe0b</small>
<br>
<a href="https://github.com/Soulter/AstrBot/commits/master"><small>查看 master 分支提交记录点击右边的 copy
即可复制</small></a>
<a href="https://github.com/Soulter/AstrBot/commits/master"><small>查看 master 分支提交记录点击右边的
copy
即可复制</small></a>
</div>
<v-btn color="error" style="border-radius: 10px;" @click="switchVersion(version)">
确定切换
@@ -352,7 +407,7 @@ if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('cha
</div>
<v-btn color="primary" style="border-radius: 10px;" @click="updateDashboard()"
:disabled="!dashboardHasNewVersion">
:disabled="!dashboardHasNewVersion">
下载并更新
</v-btn>
</div>
@@ -367,46 +422,118 @@ if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('cha
</v-card>
</v-dialog>
<v-dialog v-model="dialog" persistent width="700">
<v-dialog v-model="dialog" persistent max-width="500">
<template v-slot:activator="{ props }">
<v-btn size="small" class="text-primary mr-4" color="lightprimary" variant="flat" rounded="sm" v-bind="props">
<v-btn size="small" class="text-primary mr-4" color="var(--v-theme-surface)" variant="flat" rounded="sm" v-bind="props">
<v-icon class="mr-1">mdi-account</v-icon>
账户
</v-btn>
</template>
<v-card>
<v-card-title>
<span class="text-h5">账户</span>
</v-card-title>
<v-card-text>
<v-container>
<v-row>
<v-col cols="12">
<v-card class="account-dialog">
<v-card-text class="py-6">
<div class="d-flex flex-column align-center mb-6">
<logo title="AstrBot 仪表盘" subtitle="修改账户"></logo>
</div>
<v-alert
v-if="accountWarning"
type="warning"
variant="tonal"
border="start"
class="mb-4"
>
<strong>安全提醒:</strong> 请修改默认密码以确保账户安全
</v-alert>
<v-alert v-if="accountWarning" color="warning" style="margin-bottom: 16px;">
<div>为了安全请务必修改默认密码</div>
</v-alert>
<v-alert
v-if="accountEditStatus.success"
type="success"
variant="tonal"
border="start"
class="mb-4"
>
{{ accountEditStatus.message }}
</v-alert>
<v-text-field label="原密码*" type="password" v-model="password" required
variant="outlined"></v-text-field>
<v-alert
v-if="accountEditStatus.error"
type="error"
variant="tonal"
border="start"
class="mb-4"
>
{{ accountEditStatus.message }}
</v-alert>
<v-text-field label="新用户名" v-model="newUsername" required variant="outlined"></v-text-field>
<v-form v-model="formValid" @submit.prevent="accountEdit">
<v-text-field
v-model="password"
:append-inner-icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
:type="showPassword ? 'text' : 'password'"
label="当前密码"
variant="outlined"
required
clearable
@click:append-inner="showPassword = !showPassword"
prepend-inner-icon="mdi-lock-outline"
hide-details="auto"
class="mb-4"
></v-text-field>
<v-text-field label="新密码" type="password" v-model="newPassword" required
variant="outlined"></v-text-field>
</v-col>
</v-row>
</v-container>
<small>默认用户名和密码是 astrbot</small>
<br>
<small>{{ status }}</small>
<v-text-field
v-model="newPassword"
:append-inner-icon="showNewPassword ? 'mdi-eye-off' : 'mdi-eye'"
:type="showNewPassword ? 'text' : 'password'"
:rules="passwordRules"
label="新密码"
variant="outlined"
required
clearable
@click:append-inner="showNewPassword = !showNewPassword"
prepend-inner-icon="mdi-lock-plus-outline"
hint="密码长度至少 8 位"
persistent-hint
class="mb-4"
></v-text-field>
<v-text-field
v-model="newUsername"
:rules="usernameRules"
label="新用户名 (可选)"
variant="outlined"
clearable
prepend-inner-icon="mdi-account-edit-outline"
hint="留空表示不修改用户名"
persistent-hint
class="mb-3"
></v-text-field>
</v-form>
<div class="text-caption text-medium-emphasis mt-2">
默认用户名和密码均为 astrbot
</div>
</v-card-text>
<v-card-actions>
<v-divider></v-divider>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn v-if="!accountWarning" color="blue-darken-1" variant="text" @click="dialog = false">
关闭
<v-btn
v-if="!accountWarning"
variant="tonal"
color="secondary"
@click="dialog = false"
:disabled="accountEditStatus.loading"
>
取消
</v-btn>
<v-btn color="blue-darken-1" variant="text" @click="accountEdit">
提交
<v-btn
color="primary"
@click="accountEdit"
:loading="accountEditStatus.loading"
:disabled="!formValid"
prepend-icon="mdi-content-save"
>
保存修改
</v-btn>
</v-card-actions>
</v-card>
@@ -432,4 +559,27 @@ if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('cha
margin-top: 8px;
margin-bottom: 8px;
}
.account-dialog .v-card-text {
padding-top: 24px;
padding-bottom: 24px;
}
.account-dialog .v-alert {
margin-bottom: 20px;
}
.account-dialog .v-btn {
text-transform: none;
font-weight: 500;
border-radius: 8px;
}
.account-dialog .v-avatar {
transition: transform 0.3s ease;
}
.account-dialog .v-avatar:hover {
transform: scale(1.05);
}
</style>
@@ -65,11 +65,11 @@ const sidebarItem: menu[] = [
icon: 'mdi-console',
to: '/console'
},
// {
// title: 'Alkaid',
// icon: 'mdi-test-tube',
// to: '/alkaid'
// },
{
title: 'Alkaid',
icon: 'mdi-test-tube',
to: '/alkaid'
},
{
title: '关于',
icon: 'mdi-information',
+3 -1
View File
@@ -3,6 +3,7 @@ import '@mdi/font/css/materialdesignicons.css';
import * as components from 'vuetify/components';
import * as directives from 'vuetify/directives';
import { PurpleTheme } from '@/theme/LightTheme';
import { PurpleThemeDark } from "@/theme/DarkTheme";
export default createVuetify({
components,
@@ -11,7 +12,8 @@ export default createVuetify({
theme: {
defaultTheme: 'PurpleTheme',
themes: {
PurpleTheme
PurpleTheme,
PurpleThemeDark
}
},
defaults: {
+1 -1
View File
@@ -6,7 +6,7 @@
.listitem {
height: calc(100vh - 100px);
.v-list {
color: rgb(var(--v-theme-lightText));
color: rgb(var(--v-theme-secondaryText));
}
.v-list-group__items .v-list-item,
.v-list-item {
+1 -1
View File
@@ -32,7 +32,7 @@ export const useAuthStore = defineStore({
},
logout() {
this.username = '';
localStorage.removeItem('username');
localStorage.removeItem('user');
localStorage.removeItem('token');
router.push('/auth/login');
},
+6 -1
View File
@@ -8,6 +8,7 @@ export const useCustomizerStore = defineStore({
Customizer_drawer: config.Customizer_drawer,
mini_sidebar: config.mini_sidebar,
fontTheme: "Poppins",
uiTheme: config.uiTheme,
inputBg: config.inputBg
}),
@@ -21,6 +22,10 @@ export const useCustomizerStore = defineStore({
},
SET_FONT(payload: string) {
this.fontTheme = payload;
}
},
SET_UI_THEME(payload: string) {
this.uiTheme = payload;
localStorage.setItem("uiTheme", payload);
},
}
});
+46
View File
@@ -0,0 +1,46 @@
import type { ThemeTypes } from '@/types/themeTypes/ThemeType';
const PurpleThemeDark: ThemeTypes = {
name: 'PurpleThemeDark',
dark: true,
variables: {
'border-color': '#1677ff',
'carousel-control-size': 10
},
colors: {
primary: '#1677ff',
secondary: '#722ed1',
info: '#03c9d7',
success: '#52c41a',
accent: '#FFAB91',
warning: '#faad14',
error: '#ff4d4f',
lightprimary: '#eef2f6',
lightsecondary: '#ede7f6',
lightsuccess: '#b9f6ca',
lighterror: '#f9d8d8',
lightwarning: '#fff8e1',
primaryText: '#ffffff',
secondaryText: '#ffffffcc',
darkprimary: '#1565c0',
darksecondary: '#4527a0',
borderLight: '#d0d0d0',
border: '#333333ee',
inputBorder: '#787878',
containerBg: '#1a1a1a',
surface: '#1f1f1f',
'on-surface-variant': '#000',
facebook: '#4267b2',
twitter: '#1da1f2',
linkedin: '#0e76a8',
gray100: '#cccccccc',
primary200: '#90caf9',
secondary200: '#b39ddb',
background: '#111111',
overlay: '#111111aa',
codeBg: '#282833',
code: '#ffffffdd'
}
};
export { PurpleThemeDark };
+9 -4
View File
@@ -20,11 +20,12 @@ const PurpleTheme: ThemeTypes = {
lightsuccess: '#b9f6ca',
lighterror: '#f9d8d8',
lightwarning: '#fff8e1',
darkText: '#212121',
lightText: '#616161',
primaryText: '#000000dd',
secondaryText: '#000000aa',
darkprimary: '#1565c0',
darksecondary: '#4527a0',
borderLight: '#d0d0d0',
border: '#d0d0d0',
inputBorder: '#787878',
containerBg: '#eef2f6',
surface: '#fff',
@@ -32,9 +33,13 @@ const PurpleTheme: ThemeTypes = {
facebook: '#4267b2',
twitter: '#1da1f2',
linkedin: '#0e76a8',
gray100: '#fafafa',
gray100: '#fafafacc',
primary200: '#90caf9',
secondary200: '#b39ddb'
secondary200: '#b39ddb',
background: '#f9fafcf4',
overlay: '#ffffffaa',
codeBg: '#f5f0ff',
code: '#673ab7'
}
};
+6 -2
View File
@@ -17,13 +17,15 @@ export type ThemeTypes = {
lightwarning?: string;
darkprimary?: string;
darksecondary?: string;
darkText?: string;
lightText?: string;
primaryText?: string;
secondaryText?: string;
borderLight?: string;
border?: string;
inputBorder?: string;
containerBg?: string;
surface?: string;
background?: string;
overlay?: string;
'on-surface-variant'?: string;
facebook?: string;
twitter?: string;
@@ -31,5 +33,7 @@ export type ThemeTypes = {
gray100?: string;
primary200?: string;
secondary200?: string;
codeBg?: string;
code?: string;
};
};
+18 -7
View File
@@ -11,7 +11,7 @@
</div>
<div class="title-container">
<h1 class="text-h2 font-weight-bold">AstrBot</h1>
<p class="text-subtitle-1" style="color: #777;">A project out of interests and loves </p>
<p class="text-subtitle-1" style="color: var(--v-theme-secondaryText);">A project out of interests and loves </p>
<div class="action-buttons">
<v-btn @click="open('https://github.com/Soulter/AstrBot')"
color="primary" variant="elevated" prepend-icon="mdi-star">
@@ -32,16 +32,20 @@
<v-row justify="center" align="center">
<v-col cols="12" md="6" class="pr-md-8 contributors-info">
<h2 class="text-h4 font-weight-medium">贡献者</h2>
<p class="mb-4 text-body-1" style="color: #777;">
<p class="mb-4 text-body-1" style="color: var(--v-theme-secondaryText);">
本项目由众多开源社区成员共同维护感谢每一位贡献者的付出
</p>
<p class="text-body-1" style="color: #777;">
<p class="text-body-1" style="color: var(--v-theme-secondaryText);">
<a href="https://github.com/Soulter/AstrBot/graphs/contributors" class="text-decoration-none custom-link">查看 AstrBot 贡献者</a>
</p>
</v-col>
<v-col cols="12" md="6">
<v-card variant="outlined" class="overflow-hidden" elevation="2">
<v-img
<v-img v-if="useCustomizerStore().uiTheme==='PurpleThemeDark'"
alt="Active Contributors of Soulter/AstrBot"
src="https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=575865240&limit=365&image_size=auto&color_scheme=dark">
</v-img>
<v-img v-else
alt="Active Contributors of Soulter/AstrBot"
src="https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=575865240&limit=365&image_size=auto&color_scheme=light">
</v-img>
@@ -60,12 +64,16 @@
<div class="license-container mt-8">
<img v-bind="props" src="https://www.gnu.org/graphics/agplv3-with-text-100x42.png" style="cursor: pointer;"/>
<p class="text-caption mt-2" style="color: #777;">AstrBot 采用 AGPL v3 协议开源</p>
<p class="text-caption mt-2" style="color: var(--v-theme-secondaryText);">AstrBot 采用 AGPL v3 协议开源</p>
</div>
</v-col>
<v-col cols="12" md="6">
<v-card variant="outlined" class="overflow-hidden" elevation="2">
<v-img
<v-img v-if="useCustomizerStore().uiTheme==='PurpleThemeDark'"
alt="Stars Map of Soulter/AstrBot"
src="https://next.ossinsight.io/widgets/official/analyze-repo-stars-map/thumbnail.png?activity=stars&repo_id=575865240&image_size=auto&color_scheme=dark">
</v-img>
<v-img v-else
alt="Stars Map of Soulter/AstrBot"
src="https://next.ossinsight.io/widgets/official/analyze-repo-stars-map/thumbnail.png?activity=stars&repo_id=575865240&image_size=auto&color_scheme=light">
</v-img>
@@ -80,6 +88,8 @@
</template>
<script>
import {useCustomizerStore} from "@/stores/customizer";
export default {
name: 'AboutPage',
data() {
@@ -89,6 +99,7 @@ export default {
},
methods: {
useCustomizerStore,
open(url) {
window.open(url, '_blank');
}
@@ -137,7 +148,7 @@ export default {
}
.contributors-section {
background-color: #f9f9fb;
background-color: var(--v-theme-containerBg, #f9f9fb);
}
.contributors-info, .stats-info {
+124 -87
View File
@@ -13,27 +13,17 @@ marked.setOptions({
<v-card-text class="chat-page-container">
<div class="chat-layout">
<div class="sidebar-panel">
<div class="sidebar-header">
<v-btn icon variant="plain">
<v-icon icon="mdi-menu" color="deep-purple"></v-icon>
</v-btn>
</div>
<div style="padding: 16px; padding-top: 8px;">
<v-btn variant="elevated" rounded="lg" class="new-chat-btn" @click="newC" :disabled="!currCid"
prepend-icon="mdi-plus">
创建对话
</v-btn>
prepend-icon="mdi-plus">创建对话</v-btn>
</div>
<div class="conversations-container">
<v-card class="conversation-list-card" v-if="conversations.length > 0" flat>
<v-list density="compact" nav class="conversation-list"
@update:selected="getConversationMessages">
<v-list-item v-for="(item, i) in conversations" :key="item.cid" :value="item.cid"
color="primary" rounded="lg" class="conversation-item" active-color="primary">
rounded="lg" class="conversation-item" active-color="secondary">
<template v-slot:prepend>
<v-icon size="small" icon="mdi-message-text-outline"></v-icon>
</template>
@@ -168,7 +158,7 @@ marked.setOptions({
<template v-slot:activator="{ props }">
<v-btn v-bind="props" @click="sendMessage" class="send-btn" icon="mdi-send"
variant="text" color="deep-purple"
:disabled="!prompt && stagedImagesUrl.length === 0 && !stagedAudioUrl" />
:disabled="!prompt && stagedImagesName.length === 0 && !stagedAudioUrl" />
</template>
</v-tooltip>
@@ -218,7 +208,8 @@ export default {
messages: [],
conversations: [],
currCid: '',
stagedImagesUrl: [],
stagedImagesName: [], // 用于存储图片**文件名**的数组
stagedImagesUrl: [], // 用于存储图片的blob URL数组
loadingChat: false,
inputFieldLabel: '聊天吧!',
@@ -236,7 +227,9 @@ export default {
// Ctrl键长按相关变量
ctrlKeyDown: false,
ctrlKeyTimer: null,
ctrlKeyLongPressThreshold: 300 // 长按阈值,单位毫秒
ctrlKeyLongPressThreshold: 300, // 长按阈值,单位毫秒
mediaCache: {}, // Add a cache to store media blobs
}
},
@@ -265,9 +258,31 @@ export default {
// 移除keyup事件监听
document.removeEventListener('keyup', this.handleInputKeyUp);
// Cleanup blob URLs
this.cleanupMediaCache();
},
methods: {
async getMediaFile(filename) {
if (this.mediaCache[filename]) {
return this.mediaCache[filename];
}
try {
const response = await axios.get('/api/chat/get_file', {
params: { filename },
responseType: 'blob'
});
const blobUrl = URL.createObjectURL(response.data);
this.mediaCache[filename] = blobUrl;
return blobUrl;
} catch (error) {
console.error('Error fetching media file:', error);
return '';
}
},
async startListeningEvent() {
const response = await fetch('/api/chat/listen', {
@@ -328,17 +343,19 @@ export default {
if (chunk_json.type === 'image') {
let img = chunk_json.data.replace('[IMAGE]', '');
const imageUrl = await this.getMediaFile(img);
let bot_resp = {
type: 'bot',
message: `<img src="/api/chat/get_file?filename=${img}" style="max-width: 80%; border-radius: 8px; box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);"/>`
message: `<img src="${imageUrl}" style="max-width: 80%; border-radius: 8px; box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);"/>`
}
this.messages.push(bot_resp);
} else if (chunk_json.type === 'record') {
let audio = chunk_json.data.replace('[RECORD]', '');
const audioUrl = await this.getMediaFile(audio);
let bot_resp = {
type: 'bot',
message: `<audio controls class="audio-player">
<source src="/api/chat/get_file?filename=${audio}" type="audio/wav">
<source src="${audioUrl}" type="audio/wav">
您的浏览器不支持音频播放。
</audio>`
}
@@ -403,15 +420,14 @@ export default {
try {
const response = await axios.post('/api/chat/post_file', formData, {
headers: {
'Content-Type': 'multipart/form-data',
'Authorization': 'Bearer ' + localStorage.getItem('token')
'Content-Type': 'multipart/form-data'
}
});
const audio = response.data.data.filename;
console.log('Audio uploaded:', audio);
this.stagedAudioUrl = `/api/chat/get_file?filename=${audio}`;
this.stagedAudioUrl = audio; // Store just the filename
} catch (err) {
console.error('Error uploading audio:', err);
}
@@ -430,13 +446,13 @@ export default {
try {
const response = await axios.post('/api/chat/post_image', formData, {
headers: {
'Content-Type': 'multipart/form-data',
'Authorization': 'Bearer ' + localStorage.getItem('token')
'Content-Type': 'multipart/form-data'
}
});
const img = response.data.data.filename;
this.stagedImagesUrl.push(`/api/chat/get_file?filename=${img}`);
this.stagedImagesName.push(img); // Store just the filename
this.stagedImagesUrl.push(URL.createObjectURL(file)); // Create a blob URL for immediate display
} catch (err) {
console.error('Error uploading image:', err);
@@ -446,6 +462,7 @@ export default {
},
removeImage(index) {
this.stagedImagesName.splice(index, 1);
this.stagedImagesUrl.splice(index, 1);
},
@@ -462,28 +479,30 @@ export default {
getConversationMessages(cid) {
if (!cid[0])
return;
axios.get('/api/chat/get_conversation?conversation_id=' + cid[0]).then(response => {
axios.get('/api/chat/get_conversation?conversation_id=' + cid[0]).then(async response => {
this.currCid = cid[0];
let message = JSON.parse(response.data.data.history);
for (let i = 0; i < message.length; i++) {
if (message[i].message.startsWith('[IMAGE]')) {
let img = message[i].message.replace('[IMAGE]', '');
message[i].message = `<img src="/api/chat/get_file?filename=${img}" style="max-width: 80%; border-radius: 8px; box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);"/>`
const imageUrl = await this.getMediaFile(img);
message[i].message = `<img src="${imageUrl}" style="max-width: 80%; border-radius: 8px; box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);"/>`
}
if (message[i].message.startsWith('[RECORD]')) {
let audio = message[i].message.replace('[RECORD]', '');
const audioUrl = await this.getMediaFile(audio);
message[i].message = `<audio controls class="audio-player">
<source src="/api/chat/get_file?filename=${audio}" type="audio/wav">
<source src="${audioUrl}" type="audio/wav">
您的浏览器不支持音频播放。
</audio>`
}
if (message[i].image_url && message[i].image_url.length > 0) {
for (let j = 0; j < message[i].image_url.length; j++) {
message[i].image_url[j] = `/api/chat/get_file?filename=${message[i].image_url[j]}`;
message[i].image_url[j] = await this.getMediaFile(message[i].image_url[j]);
}
}
if (message[i].audio_url) {
message[i].audio_url = `/api/chat/get_file?filename=${message[i].audio_url}`;
message[i].audio_url = await this.getMediaFile(message[i].audio_url);
}
}
this.messages = message;
@@ -534,32 +553,41 @@ export default {
await this.newConversation();
}
this.messages.push({
// Create a message object with actual URLs for display
const userMessage = {
type: 'user',
message: this.prompt,
image_url: this.stagedImagesUrl,
audio_url: this.stagedAudioUrl
});
image_url: [],
audio_url: null
};
// Convert image filenames to blob URLs for display
if (this.stagedImagesName.length > 0) {
for (let i = 0; i < this.stagedImagesName.length; i++) {
// If it's just a filename, get the blob URL
if (!this.stagedImagesName[i].startsWith('blob:')) {
const imgUrl = await this.getMediaFile(this.stagedImagesName[i]);
userMessage.image_url.push(imgUrl);
} else {
userMessage.image_url.push(this.stagedImagesName[i]);
}
}
}
// Convert audio filename to blob URL for display
if (this.stagedAudioUrl) {
if (!this.stagedAudioUrl.startsWith('blob:')) {
userMessage.audio_url = await this.getMediaFile(this.stagedAudioUrl);
} else {
userMessage.audio_url = this.stagedAudioUrl;
}
}
this.messages.push(userMessage);
this.scrollToBottom();
// images
let image_filenames = [];
for (let i = 0; i < this.stagedImagesUrl.length; i++) {
let img = this.stagedImagesUrl[i].replace('/api/chat/get_file?filename=', '');
image_filenames.push(img);
}
// audio
let audio_filenames = [];
if (this.stagedAudioUrl) {
let audio = this.stagedAudioUrl.replace('/api/chat/get_file?filename=', '');
audio_filenames.push(audio);
}
this.loadingChat = true;
fetch('/api/chat/send', {
method: 'POST',
headers: {
@@ -569,20 +597,19 @@ export default {
body: JSON.stringify({
message: this.prompt,
conversation_id: this.currCid,
image_url: image_filenames,
audio_url: audio_filenames
}) // 发送请求体
})
.then(response => {
this.prompt = '';
this.stagedImagesUrl = [];
this.stagedAudioUrl = "";
this.loadingChat = false;
image_url: this.stagedImagesName, // Already contains just filenames
audio_url: this.stagedAudioUrl ? [this.stagedAudioUrl] : [] // Already contains just filename
})
.catch(err => {
console.error(err);
});
})
.then(response => {
this.prompt = '';
this.stagedImagesName = [];
this.stagedAudioUrl = "";
this.loadingChat = false;
})
.catch(err => {
console.error(err);
});
},
scrollToBottom() {
this.$nextTick(() => {
@@ -623,6 +650,15 @@ export default {
}
}
},
cleanupMediaCache() {
Object.values(this.mediaCache).forEach(url => {
if (url.startsWith('blob:')) {
URL.revokeObjectURL(url);
}
});
this.mediaCache = {};
},
},
}
</script>
@@ -668,13 +704,13 @@ export default {
}
/* 聊天页面布局 */
/* todo: 聊天页面背景颜色有问题 */
.chat-page-card {
margin-bottom: 16px;
width: 100%;
height: 100%;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05) !important;
background-color: #fff;
}
.chat-page-container {
@@ -697,7 +733,7 @@ export default {
flex-direction: column;
padding: 0;
border-right: 1px solid rgba(0, 0, 0, 0.05);
background-color: #fcfcfc;
background-color: var(--v-theme-containerBg);
height: 100%;
position: relative;
}
@@ -720,7 +756,7 @@ export default {
.sidebar-section-title {
font-size: 12px;
font-weight: 500;
color: #666;
color: var(--v-theme-secondaryText);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
@@ -745,9 +781,9 @@ export default {
}
.conversation-list-card {
border-radius: 12px;
border-radius: 8px;
box-shadow: none !important;
background-color: transparent;
background-color: var(--v-theme-containerBg);
}
.conversation-list {
@@ -778,7 +814,7 @@ export default {
.timestamp {
font-size: 11px;
color: #999;
color: var(--v-theme-secondaryText);
line-height: 1;
}
@@ -803,6 +839,7 @@ export default {
text-transform: none;
letter-spacing: 0.25px;
font-size: 12px;
line-height: 1.2em;
}
.delete-chat-btn:hover {
@@ -821,7 +858,7 @@ export default {
.no-conversations-text {
font-size: 14px;
color: #999;
color: var(--v-theme-secondaryText);
}
/* 聊天内容区域 */
@@ -857,21 +894,21 @@ export default {
.bot-name {
font-weight: 700;
margin-left: 8px;
color: #673ab7;
color: var(--v-theme-secondary);
}
.welcome-hint {
margin-top: 8px;
color: #666;
color: var(--v-theme-secondaryText);
font-size: 14px;
}
.welcome-hint code {
background-color: #f5f0ff;
background-color: var(--v-theme-codeBg);
padding: 2px 6px;
margin: 0 4px;
border-radius: 4px;
color: #673ab7;
color: var(--v-theme-code);
font-family: 'Fira Code', monospace;
font-size: 13px;
}
@@ -910,15 +947,15 @@ export default {
}
.user-bubble {
background-color: #f5f0ff;
color: #333;
background-color: var(--v-theme-background);
color: var(--v-theme-primaryText);
border-top-right-radius: 4px;
}
.bot-bubble {
background-color: #fff;
border: 1px solid #e8e8e8;
color: #333;
background-color: var(--v-theme-surface);
border: 1px solid var(--v-theme-border);
color: var(--v-theme-primaryText);
border-top-left-radius: 4px;
}
@@ -965,9 +1002,9 @@ export default {
/* 输入区域样式 */
.input-area {
padding: 16px;
background-color: #fff;
background-color: var(--v-theme-surface);
position: relative;
border-top: 1px solid #f5f5f5;
border-top: 1px solid var(--v-theme-border);
}
.message-input {
@@ -1037,12 +1074,12 @@ export default {
margin-top: 16px;
margin-bottom: 10px;
font-weight: 600;
color: #333;
color: var(--v-theme-primaryText);
}
.markdown-content h1 {
font-size: 1.8em;
border-bottom: 1px solid #eee;
border-bottom: 1px solid var(--v-theme-border);
padding-bottom: 6px;
}
@@ -1065,7 +1102,7 @@ export default {
}
.markdown-content pre {
background-color: #f8f8f8;
background-color: var(--v-theme-surface);
padding: 12px;
border-radius: 6px;
overflow-x: auto;
@@ -1073,12 +1110,12 @@ export default {
}
.markdown-content code {
background-color: #f5f0ff;
background-color: var(--v-theme-codeBg);
padding: 2px 4px;
border-radius: 4px;
font-family: 'Fira Code', monospace;
font-size: 0.9em;
color: #673ab7;
color: var(--v-theme-code);
}
.markdown-content img {
@@ -1088,9 +1125,9 @@ export default {
}
.markdown-content blockquote {
border-left: 4px solid #673ab7;
border-left: 4px solid var(--v-theme-secondary);
padding-left: 16px;
color: #666;
color: var(--v-theme-secondaryText);
margin: 16px 0;
}
@@ -1102,13 +1139,13 @@ export default {
.markdown-content th,
.markdown-content td {
border: 1px solid #eee;
border: 1px solid var(--v-theme-background);
padding: 8px 12px;
text-align: left;
}
.markdown-content th {
background-color: #f5f0ff;
background-color: var(--v-theme-containerBg);
}
/* 动画类 */
+2 -2
View File
@@ -42,7 +42,7 @@ import config from '@/config';
<div v-for="(val2, key2, index2) in metadata[key]['metadata']">
<!-- <h3>{{ metadata[key]['metadata'][key2]['description'] }}</h3> -->
<div v-if="metadata[key]['metadata'][key2]?.config_template"
v-show="key2 !== 'platform' && key2 !== 'provider'" style="border: 1px solid #e0e0e0; padding: 8px; margin-bottom: 16px; border-radius: 10px">
v-show="key2 !== 'platform' && key2 !== 'provider'" style="border: 1px solid var(--v-theme-border); padding: 8px; margin-bottom: 16px; border-radius: 10px">
<!-- 带有 config_template 的配置项 -->
<v-list-item-title style="font-weight: bold;">
{{ metadata[key]['metadata'][key2]['description'] }} ({{ key2 }})
@@ -88,7 +88,7 @@ import config from '@/config';
<div v-else>
<!-- 如果配置项是一个 object那么 iterable 需要取到这个 object 的值否则取到整个 config_data -->
<div v-if="metadata[key]['metadata'][key2]['type'] == 'object'" style="border: 1px solid #e0e0e0; padding: 8px; margin-bottom: 16px; border-radius: 10px">
<div v-if="metadata[key]['metadata'][key2]['type'] == 'object'" style="border: 1px solid var(--v-theme-border); padding: 8px; margin-bottom: 16px; border-radius: 10px">
<AstrBotConfig
:metadata="metadata[key]['metadata']" :iterable="config_data[key2]" :metadataKey="key2">
</AstrBotConfig>
+1 -1
View File
@@ -7,7 +7,7 @@ import axios from 'axios';
<template>
<div style="height: 100%;">
<div
style="background-color: white; padding: 8px; padding-left: 16px; border-radius: 8px; margin-bottom: 16px; display: flex; flex-direction: row; align-items: center; justify-content: space-between;">
style="background-color: var(--v-theme-surface); padding: 8px; padding-left: 16px; border-radius: 8px; margin-bottom: 16px; display: flex; flex-direction: row; align-items: center; justify-content: space-between;">
<h4>控制台</h4>
<div class="d-flex align-center">
<v-switch
+60 -40
View File
@@ -19,18 +19,15 @@ import 'highlight.js/styles/github.css';
<v-col cols="12" md="12">
<v-card>
<v-card-title>
<div class="pl-2 pt-2 d-flex align-center pe-2">
<h2> 插件市场</h2>
<v-btn icon size="small" style="margin-left: 8px" variant="plain" @click="jumpToPluginMarket()">
<v-icon size="small">mdi-help</v-icon>
<v-tooltip activator="parent" location="start">
<v-tooltip activator="parent" location="start" max-width="500" open-delay="500">
<span>
如无法显示请单击此按钮跳转至插件市场复制想安装插件对应的
`repo`
链接然后点击右下角 + 号安装或打开链接下载压缩包安装
repo链接然后点击右下角 + 号安装或打开链接下载压缩包安装<br/>
如果因为网络问题安装失败点击设置页选择 GitHub 加速地址或前往仓库下载压缩包然后本地上传
</span>
</v-tooltip>
@@ -41,24 +38,27 @@ import 'highlight.js/styles/github.css';
<v-icon>{{ isListView ? 'mdi-view-grid' : 'mdi-view-list' }}</v-icon>
</v-btn>
<v-spacer></v-spacer>
<v-spacer/>
<v-text-field v-model="marketSearch" density="compact" label="Search"
prepend-inner-icon="mdi-magnify" variant="solo-filled" flat hide-details
single-line></v-text-field>
</div>
</v-card-title>
<v-card-text>
<small style="color: #bbb;">每个插件都是作者无偿提供的的劳动成果如果您喜欢某个插件 Star</small>
<small style="color: var(--v-theme-secondaryText);">每个插件都是作者无偿提供的的劳动成果如果您喜欢某个插件 Star</small>
<div v-if="pinnedPlugins.length > 0" class="mt-4">
<h2>🥳 推荐</h2>
<v-row style="margin-top: 8px;">
<v-col cols="12" md="6" lg="6" v-for="plugin in pinnedPlugins">
<ExtensionCard :extension="plugin" market-mode="true" :highlight="true" @install="extension_url=plugin.repo; newExtension()">
<ExtensionCard :extension="plugin" class="h-120 rounded-lg"
market-mode="true" :highlight="true"
@install="extension_url=plugin.repo;
newExtension()"
@view-readme="open(plugin.repo)">
</ExtensionCard>
</v-col>
</v-row>
@@ -68,19 +68,25 @@ import 'highlight.js/styles/github.css';
<div v-if="isListView" class="mt-4">
<h2>📦 全部插件</h2>
<v-switch
v-model="showPluginFullName"
label="显示完整名称"
hide-details
density="compact"
style="margin-left: 12px"
/>
<v-col cols="12" md="12" style="padding: 0px;">
<v-data-table :headers="pluginMarketHeaders" :items="pluginMarketData" item-key="name"
:loading="loading_" v-model:search="marketSearch" :filter-keys="filterKeys">
<template v-slot:item.name="{ item }">
<div class="d-flex align-center" style="overflow-x: scroll;">
<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">
<span v-if="item?.repo"><a :href="item?.repo"
style="color: #000; text-decoration:none">{{
item.name }}</a></span>
<span v-else>{{ item.name }}</span>
style="color: var(--v-theme-primaryText, #000); text-decoration:none">{{
showPluginFullName ? item.name : item.trimmedName }}</a></span>
<span v-else>{{ showPluginFullName ? item.name : item.trimmedName }}</span>
</div>
</template>
@@ -107,18 +113,18 @@ import 'highlight.js/styles/github.css';
</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="primary" size="x-small">{{ tag
}}</v-chip>
<span v-if="item.tags.length === 0">-</span>
<v-chip v-for="tag in item.tags" :key="tag" color="primary" size="x-small">
{{ tag }}</v-chip>
</template>
<template v-slot:item.actions="{ item }">
<v-btn v-if="!item.installed" class="text-none mr-2" size="x-small"
variant="flat" border
@click="extension_url = item.repo; newExtension()">安装</v-btn>
<v-btn v-if="!item.installed" class="text-none mr-2" size="x-small"
variant="flat" @click="extension_url = item.repo; newExtension()">
<v-icon>mdi-download</v-icon></v-btn>
<v-btn v-else class="text-none mr-2" size="x-small" variant="flat" border
disabled>已安装</v-btn>
<v-btn class="text-none mr-2" size="x-small" variant="flat" border
@click="open(item.repo)">帮助</v-btn>
disabled><v-icon>mdi-check</v-icon></v-btn>
<v-btn class="text-none mr-2" size="x-small" variant="flat" border
@click="open(item.repo)"><v-icon>mdi-help</v-icon></v-btn>
</template>
</v-data-table>
</v-col>
@@ -261,6 +267,7 @@ export default {
loading_: false,
upload_file: null,
pluginMarketData: [],
showPluginFullName: false,
loadingDialog: {
show: false,
title: "加载中...",
@@ -279,8 +286,8 @@ export default {
pluginMarketHeaders: [
{ title: '名称', key: 'name', maxWidth: '200px' },
{ title: '描述', key: 'desc', maxWidth: '250px' },
{ title: '作者', key: 'author', maxWidth: '70px' },
{ title: 'Star数', key: 'stars', maxWidth: '100px' },
{ title: '作者', key: 'author', maxWidth: '90px' },
{ title: 'Star数', key: 'stars', maxWidth: '80px' },
{ title: '最近更新', key: 'updated_at', maxWidth: '100px' },
{ title: '标签', key: 'tags', maxWidth: '100px' },
{ title: '操作', key: 'actions', sortable: false }
@@ -315,6 +322,7 @@ export default {
this.loading_ = true
this.commonStore.getPluginCollections().then((data) => {
this.pluginMarketData = data;
this.trimExtensionName();
this.checkAlreadyInstalled();
this.checkUpdate();
this.loading_ = false
@@ -363,11 +371,23 @@ export default {
getExtensions() {
axios.get('/api/plugin/get').then((res) => {
this.extension_data = res.data;
this.trimExtensionName();
this.checkAlreadyInstalled();
this.checkUpdate()
});
},
trimExtensionName() {
this.pluginMarketData.forEach(plugin => {
if (plugin.name) {
let name = plugin.name.trim().toLowerCase();
if (name.startsWith("astrbot_plugin_")) {
plugin.trimmedName = name.substring(15);
} else if (name.startsWith("astrbot_") || name.startsWith("astrbot-")) {
plugin.trimmedName = name.substring(8);
} else plugin.trimmedName = plugin.name;
}
});
},
checkUpdate() {
// 创建在线插件的map
const onlinePluginsMap = new Map();
@@ -565,7 +585,7 @@ export default {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
line-height: 1.6;
padding: 8px 0;
color: #24292e;
color: var(--v-theme-secondaryText);
}
.markdown-body h1,
@@ -582,13 +602,13 @@ export default {
.markdown-body h1 {
font-size: 2em;
border-bottom: 1px solid #eaecef;
border-bottom: 1px solid var(--v-theme-border);
padding-bottom: 0.3em;
}
.markdown-body h2 {
font-size: 1.5em;
border-bottom: 1px solid #eaecef;
border-bottom: 1px solid var(--v-theme-border);
padding-bottom: 0.3em;
}
@@ -600,7 +620,7 @@ export default {
.markdown-body code {
padding: 0.2em 0.4em;
margin: 0;
background-color: rgba(27, 31, 35, 0.05);
background-color: var(--v-theme-codeBg);
border-radius: 3px;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 85%;
@@ -611,7 +631,7 @@ export default {
overflow: auto;
font-size: 85%;
line-height: 1.45;
background-color: #f6f8fa;
background-color: var(--v-theme-containerBg);
border-radius: 3px;
margin-bottom: 16px;
}
@@ -631,19 +651,19 @@ export default {
max-width: 100%;
margin: 8px 0;
box-sizing: border-box;
background-color: #fff;
background-color: var(--v-theme-background);
border-radius: 3px;
}
.markdown-body blockquote {
padding: 0 1em;
color: #6a737d;
border-left: 0.25em solid #dfe2e5;
color: var(--v-theme-secondaryText);
border-left: 0.25em solid var(--v-theme-border);
margin-bottom: 16px;
}
.markdown-body a {
color: #0366d6;
color: var(--v-theme-primary);
text-decoration: none;
}
@@ -662,23 +682,23 @@ export default {
.markdown-body table th,
.markdown-body table td {
padding: 6px 13px;
border: 1px solid #dfe2e5;
border: 1px solid var(--v-theme-background);
}
.markdown-body table tr {
background-color: #fff;
border-top: 1px solid #c6cbd1;
background-color: var(--v-theme-surface);
border-top: 1px solid var(--v-theme-border);
}
.markdown-body table tr:nth-child(2n) {
background-color: #f6f8fa;
background-color: var(--v-theme-background);
}
.markdown-body hr {
height: 0.25em;
padding: 0;
margin: 24px 0;
background-color: #e1e4e8;
background-color: var(--v-theme-containerBg);
border: 0;
}
</style>
+18
View File
@@ -191,6 +191,17 @@ const updateExtension = async (extension_name) => {
Object.assign(extension_data, res.data);
onLoadingDialogResult(1, res.data.message);
setTimeout(async () => {
toast(`正在刷新插件列表...`, "info", 2000);
try {
await getExtensions();
toast("插件列表已刷新!", "success");
} catch (error) {
const errorMsg = error.response?.data?.message || error.message || String(error);
toast(`刷新插件列表时发生错误: ${errorMsg}`, "error");
}
}, 1000);
} catch (err) {
toast(err, "error");
}
@@ -378,6 +389,13 @@ const toggleAllPluginsForPlatform = (platformName) => {
onMounted(async () => {
await getExtensions();
// 检查是否有 open_config 参数
const urlParams = new URLSearchParams(window.location.search);
const plugin_name = urlParams.get('open_config');
if (plugin_name) {
openExtensionConfig(plugin_name);
}
try {
const data = await commonStore.getPluginCollections();
pluginMarketData.value = data;
+3 -3
View File
@@ -110,14 +110,14 @@
:metadata="metadata['platform_group']?.metadata"
metadataKey="platform" />
</v-col>
<v-col cols="12" md="4">
<v-btn :loading="iframeLoading" @click="refreshIframe" variant="tonal" color="primary" style="float: right;">
<v-col cols="12" md="4" class="d-flex flex-column align-end">
<v-btn :loading="iframeLoading" @click="refreshIframe" variant="tonal" color="primary">
<v-icon>mdi-refresh</v-icon>
刷新
</v-btn>
<iframe v-show="!iframeLoading"
:src="store.getTutorialLink(newSelectedPlatformConfig.type)"
@load="iframeLoading = false" style="width: 100%; border: none; height: 100%; min-height: 400px;">
@load="iframeLoading = false" style="width: 100%; border: none; min-height: 400px; margin-top: 10px; flex: 1;">
</iframe>
</v-col>
</v-row>
+154 -4
View File
@@ -27,13 +27,39 @@
<v-divider></v-divider>
<!-- 添加分类标签页 -->
<v-card-text class="px-4 pt-3 pb-0">
<v-tabs v-model="activeProviderTypeTab" bg-color="transparent">
<v-tab value="all" class="font-weight-medium px-3">
<v-icon start>mdi-filter-variant</v-icon>
全部
</v-tab>
<v-tab value="chat_completion" class="font-weight-medium px-3">
<v-icon start>mdi-message-text</v-icon>
基本对话
</v-tab>
<v-tab value="speech_to_text" class="font-weight-medium px-3">
<v-icon start>mdi-microphone-message</v-icon>
语音转文字
</v-tab>
<v-tab value="text_to_speech" class="font-weight-medium px-3">
<v-icon start>mdi-volume-high</v-icon>
文字转语音
</v-tab>
<v-tab value="embedding" class="font-weight-medium px-3">
<v-icon start>mdi-code-json</v-icon>
Embedding
</v-tab>
</v-tabs>
</v-card-text>
<v-card-text class="px-4 py-3">
<item-card-grid
:items="config_data.provider || []"
:items="filteredProviders"
title-field="id"
enabled-field="enable"
empty-icon="mdi-api-off"
empty-text="暂无服务提供商点击 新增服务提供商 添加"
:empty-text="getEmptyText()"
@toggle-enabled="providerStatusChange"
@delete="deleteProvider"
@edit="configExistingProvider"
@@ -61,6 +87,51 @@
</v-card-text>
</v-card>
<!-- 供应商状态部分 -->
<v-card class="mb-6" elevation="2">
<v-card-title class="d-flex align-center py-3 px-4">
<v-icon color="primary" class="me-2">mdi-heart-pulse</v-icon>
<span class="text-h6">供应商可用性</span>
<v-spacer></v-spacer>
<v-btn color="primary" variant="tonal" :loading="loadingStatus" @click="fetchProviderStatus">
<v-icon left>mdi-refresh</v-icon>
刷新状态
</v-btn>
</v-card-title>
<v-card-subtitle class="px-4 py-1 text-caption text-medium-emphasis">
通过测试模型对话可用性判断可能产生API费用
</v-card-subtitle>
<v-divider></v-divider>
<v-card-text class="px-4 py-3">
<v-alert v-if="providerStatuses.length === 0" type="info" variant="tonal">
点击"刷新状态"按钮获取供应商可用性
</v-alert>
<v-container v-else class="pa-0">
<v-row>
<v-col v-for="status in providerStatuses" :key="status.id" cols="12" sm="6" md="4">
<v-card variant="outlined" class="status-card">
<v-card-item>
<v-icon :color="status.status === 'available' ? 'success' : 'error'" class="me-2">
{{ status.status === 'available' ? 'mdi-check-circle' : 'mdi-alert-circle' }}
</v-icon>
<span class="font-weight-bold">{{ status.id }}</span>
<v-chip :color="status.status === 'available' ? 'success' : 'error'" size="small" class="ml-2">
{{ status.status === 'available' ? '可用' : '不可用' }}
</v-chip>
</v-card-item>
<v-card-text v-if="status.status === 'unavailable'" class="text-caption text-medium-emphasis">
<span class="font-weight-bold">错误信息:</span> {{ status.error }}
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</v-card-text>
</v-card>
<!-- 日志部分 -->
<v-card elevation="2">
<v-card-title class="d-flex align-center py-3 px-4">
@@ -109,10 +180,14 @@
<v-icon start>mdi-volume-high</v-icon>
文字转语音
</v-tab>
<v-tab value="embedding" class="font-weight-medium px-3">
<v-icon start>mdi-code-json</v-icon>
Embedding
</v-tab>
</v-tabs>
<v-window v-model="activeProviderTab" class="mt-4">
<v-window-item v-for="tabType in ['chat_completion', 'speech_to_text', 'text_to_speech']"
<v-window-item v-for="tabType in ['chat_completion', 'speech_to_text', 'text_to_speech', 'embedding']"
:key="tabType"
:value="tabType">
<v-row class="mt-1">
@@ -221,10 +296,59 @@ export default {
save_message_success: "success",
showConsole: false,
// 供应商状态相关
providerStatuses: [],
loadingStatus: false,
// 新增提供商对话框相关
showAddProviderDialog: false,
activeProviderTab: 'chat_completion',
// 添加提供商类型分类
activeProviderTypeTab: 'all',
// 兼容旧版本(< v3.5.11)的 mapping,用于映射到对应的提供商能力类型
oldVersionProviderTypeMapping: {
"openai_chat_completion": "chat_completion",
"anthropic_chat_completion": "chat_completion",
"googlegenai_chat_completion": "chat_completion",
"zhipu_chat_completion": "chat_completion",
"llm_tuner": "chat_completion",
"dify": "chat_completion",
"dashscope": "chat_completion",
"openai_whisper_api": "speech_to_text",
"openai_whisper_selfhost": "speech_to_text",
"sensevoice_stt_selfhost": "speech_to_text",
"openai_tts_api": "text_to_speech",
"edge_tts": "text_to_speech",
"gsvi_tts_api": "text_to_speech",
"fishaudio_tts_api": "text_to_speech",
"dashscope_tts": "text_to_speech",
"azure_tts": "text_to_speech",
"minimax_tts_api": "text_to_speech",
"volcengine_tts": "text_to_speech",
}
}
},
computed: {
// 根据选择的标签过滤提供商列表
filteredProviders() {
if (!this.config_data.provider || this.activeProviderTypeTab === 'all') {
return this.config_data.provider || [];
}
return this.config_data.provider.filter(provider => {
// 如果provider.provider_type已经存在,直接使用它
if (provider.provider_type) {
return provider.provider_type === this.activeProviderTypeTab;
}
// 否则使用映射关系
const mappedType = this.oldVersionProviderTypeMapping[provider.type];
return mappedType === this.activeProviderTypeTab;
});
}
},
@@ -243,6 +367,15 @@ export default {
});
},
// 获取空列表文本
getEmptyText() {
if (this.activeProviderTypeTab === 'all') {
return "暂无服务提供商,点击 新增服务提供商 添加";
} else {
return `暂无${this.getTabTypeName(this.activeProviderTypeTab)}类型的服务提供商,点击 新增服务提供商 添加`;
}
},
// 按提供商类型获取模板列表
getTemplatesByType(type) {
const templates = this.metadata['provider_group']?.metadata?.provider?.config_template || {};
@@ -294,7 +427,8 @@ export default {
const names = {
'chat_completion': '基本对话',
'speech_to_text': '语音转文本',
'text_to_speech': '文本转语音'
'text_to_speech': '文本转语音',
'embedding': 'Embedding'
};
return names[tabType] || tabType;
},
@@ -442,6 +576,22 @@ export default {
this.save_message = message;
this.save_message_success = "error";
this.save_message_snack = true;
},
// 获取供应商状态
fetchProviderStatus() {
this.loadingStatus = true;
axios.get('/api/config/provider/check_status').then((res) => {
if (res.data && res.data.status === 'ok') {
this.providerStatuses = res.data.data || [];
} else {
this.showError(res.data?.message || "获取供应商状态失败");
}
this.loadingStatus = false;
}).catch((err) => {
this.loadingStatus = false;
this.showError(err.response?.data?.message || err.message);
});
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
<template>
<div style="background-color: white; padding: 8px; padding-left: 16px; border-radius: 8px; margin-bottom: 16px;">
<div style="background-color: var(--v-theme-surface, #fff); padding: 8px; padding-left: 16px; border-radius: 8px; margin-bottom: 16px;">
<v-list lines="two">
<v-list-subheader>网络</v-list-subheader>
+160 -16
View File
@@ -4,11 +4,15 @@
<!-- knowledge card -->
<div v-if="!installed" class="d-flex align-center justify-center flex-column"
style="flex-grow: 1; width: 100%; height: 100%;">
<h2>还没有安装知识库插件</h2>
<v-btn style="margin-top: 16px;" variant="tonal" color="primary"
@click="installPlugin" :loading="installing">
<h2>还没有安装知识库插件
<v-icon v-class="ml - 2" size="small" color="grey"
@click="openUrl('https://astrbot.app/use/knowledge-base.html')">mdi-information-outline</v-icon>
</h2>
<v-btn style="margin-top: 16px;" variant="tonal" color="primary" @click="installPlugin"
:loading="installing">
立即安装
</v-btn>
<ConsoleDisplayer v-show="installing" style="background-color: #fff; max-height: 300px; margin-top: 16px; max-width: 100%" :show-level-btns="false"></ConsoleDisplayer>
</div>
<div v-else-if="kbCollections.length == 0" class="d-flex align-center justify-center flex-column"
style="flex-grow: 1; width: 100%; height: 100%;">
@@ -18,10 +22,17 @@
</v-btn>
</div>
<div v-else>
<h2 class="mb-4">知识库列表</h2>
<h2 class="mb-4">知识库列表
<v-icon v-class="ml - 2" size="x-small" color="grey"
@click="openUrl('https://astrbot.app/use/knowledge-base.html')">mdi-information-outline</v-icon>
</h2>
<v-btn class="mb-4" prepend-icon="mdi-plus" variant="tonal" color="primary"
@click="showCreateDialog = true">
创建知识库
创建知识库
</v-btn>
<v-btn class="mb-4 ml-4" prepend-icon="mdi-cog" variant="tonal" color="success"
@click="$router.push('/extension?open_config=astrbot_plugin_knowledge_base')">
配置
</v-btn>
<div class="kb-grid">
@@ -45,9 +56,9 @@
<div style="padding: 16px; text-align: center;">
<small style="color: #a3a3a3">Tips: 在聊天页面通过 /kb 指令了解如何使用</small>
</div>
</div>
</div>
<!-- 创建知识库对话框 -->
@@ -68,6 +79,12 @@
<v-textarea v-model="newKB.description" label="描述" variant="outlined" placeholder="知识库的简短描述..."
rows="3"></v-textarea>
<v-select v-model="newKB.embedding_provider_id" :items="embeddingProviderConfigs"
:item-props="embeddingModelProps" label="Embedding(嵌入)模型" variant="outlined" class="mt-2">
</v-select>
<small>Tips: 一旦选择了一个知识库的嵌入模型请不要再修改该提供商的模型或者向量维度信息否则将严重影响该知识库的召回率甚至报错</small>
</v-form>
</v-card-text>
<v-card-actions>
@@ -114,6 +131,18 @@
</v-btn>
</v-card-title>
<div v-if="currentKB._embedding_provider_config" class="px-6 py-2">
<v-chip class="mr-2" color="primary" variant="tonal" size="small" rounded="sm">
<v-icon start size="small">mdi-database</v-icon>
嵌入模型: {{ currentKB._embedding_provider_config.embedding_model }}
</v-chip>
<v-chip color="secondary" variant="tonal" size="small" rounded="sm">
<v-icon start size="small">mdi-vector-point</v-icon>
向量维度: {{ currentKB._embedding_provider_config.embedding_dimensions }}
</v-chip>
<small style="margin-left: 8px;">💡 使用方式: 在聊天页中输入 /kb use {{ currentKB.collection_name }}</small>
</div>
<v-card-text>
<v-tabs v-model="activeTab">
<v-tab value="upload">上传文件</v-tab>
@@ -136,6 +165,38 @@
<p class="mt-2">拖放文件到这里或点击上传</p>
</div>
<!-- 优化后的分片长度和重叠长度设置 -->
<v-card class="mt-4 chunk-settings-card" variant="outlined" color="grey-lighten-4">
<v-card-title class="pa-4 pb-0 d-flex align-center">
<v-icon color="primary" class="mr-2">mdi-puzzle-outline</v-icon>
<span class="text-subtitle-1 font-weight-bold">分片设置</span>
<v-tooltip location="top">
<template v-slot:activator="{ props }">
<v-icon v-bind="props" class="ml-2" size="small" color="grey">
mdi-information-outline
</v-icon>
</template>
<span>
分片长度决定每块文本的大小重叠长度决定相邻文本块之间的重叠程度<br>
较小的分片更精确但会增加数量适当的重叠可提高检索准确性
</span>
</v-tooltip>
</v-card-title>
<v-card-text class="pa-4 pt-2">
<div class="d-flex flex-wrap" style="gap: 8px">
<v-text-field v-model="chunkSize" label="分片长度" type="number"
hint="控制每个文本块大小,留空使用默认值" persistent-hint variant="outlined"
density="comfortable" class="flex-grow-1 chunk-field"
prepend-inner-icon="mdi-text-box-outline" min="50"></v-text-field>
<v-text-field v-model="overlap" label="重叠长度" type="number"
hint="控制相邻文本块重叠度,留空使用默认值" persistent-hint variant="outlined"
density="comfortable" class="flex-grow-1 chunk-field"
prepend-inner-icon="mdi-vector-intersection" min="0"></v-text-field>
</div>
</v-card-text>
</v-card>
<div class="selected-files mt-4" v-if="selectedFile">
<div type="info" variant="tonal" class="d-flex align-center">
<div>
@@ -239,9 +300,13 @@
<script>
import axios from 'axios';
import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue';
export default {
name: 'KnowledgeBase',
components: {
ConsoleDisplayer,
},
data() {
return {
installed: true,
@@ -252,7 +317,8 @@ export default {
newKB: {
name: '',
emoji: '🙂',
description: ''
description: '',
embedding_provider_id: ''
},
snackbar: {
show: false,
@@ -292,6 +358,8 @@ export default {
},
activeTab: 'upload',
selectedFile: null,
chunkSize: null,
overlap: null,
uploading: false,
searchQuery: '',
searchResults: [],
@@ -302,13 +370,21 @@ export default {
deleteTarget: {
collection_name: ''
},
deleting: false
deleting: false,
embeddingProviderConfigs: []
}
},
mounted() {
this.checkPlugin();
this.getEmbeddingProviderList();
},
methods: {
embeddingModelProps(providerConfig) {
return {
title: providerConfig.embedding_model,
subtitle: `提供商 ID: ${providerConfig.id} | 嵌入模型维度: ${providerConfig.embedding_dimensions}`,
}
},
checkPlugin() {
axios.get('/api/plugin/get?name=astrbot_plugin_knowledge_base')
.then(response => {
@@ -331,7 +407,7 @@ export default {
installPlugin() {
this.installing = true;
axios.post('/api/plugin/install', {
url: "https://github.com/soulter/astrbot_plugin_knowledge_base",
url: "https://github.com/lxfight/astrbot_plugin_knowledge_base",
proxy: localStorage.getItem('selectedGitHubProxy') || ""
})
.then(response => {
@@ -361,10 +437,15 @@ export default {
},
createCollection(name, emoji, description) {
// 如果 this.newKB.embedding_provider_id 是 Object
if (typeof this.newKB.embedding_provider_id === 'object') {
this.newKB.embedding_provider_id = this.newKB.embedding_provider_id.id || '';
}
axios.post('/api/plug/alkaid/kb/create_collection', {
collection_name: name,
emoji: emoji,
description: description
description: description,
embedding_provider_id: this.newKB.embedding_provider_id || ''
})
.then(response => {
if (response.data.status === 'ok') {
@@ -390,7 +471,8 @@ export default {
this.createCollection(
this.newKB.name,
this.newKB.emoji || '🙂',
this.newKB.description
this.newKB.description,
this.newKB.embedding_provider_id || ''
);
},
@@ -398,7 +480,8 @@ export default {
this.newKB = {
name: '',
emoji: '🙂',
description: ''
description: '',
embedding_provider: ''
};
},
@@ -415,6 +498,9 @@ export default {
this.searchQuery = '';
this.searchResults = [];
this.searchPerformed = false;
// 重置分片长度和重叠长度参数
this.chunkSize = null;
this.overlap = null;
},
triggerFileInput() {
@@ -469,6 +555,15 @@ export default {
formData.append('file', this.selectedFile);
formData.append('collection_name', this.currentKB.collection_name);
// 添加可选的分片长度和重叠长度参数
if (this.chunkSize && this.chunkSize > 0) {
formData.append('chunk_size', this.chunkSize);
}
if (this.overlap && this.overlap >= 0) {
formData.append('chunk_overlap', this.overlap);
}
axios.post('/api/plug/alkaid/kb/collection/add_file', formData, {
headers: {
'Content-Type': 'multipart/form-data'
@@ -476,7 +571,7 @@ export default {
})
.then(response => {
if (response.data.status === 'ok') {
this.showSnackbar('文件上传成功');
this.showSnackbar('操作成功: ' + response.data.message);
this.selectedFile = null;
// 刷新知识库列表,获取更新的数量
@@ -578,6 +673,31 @@ export default {
this.deleting = false;
});
},
getEmbeddingProviderList() {
axios.get('/api/config/provider/list', {
params: {
provider_type: 'embedding'
}
})
.then(response => {
if (response.data.status === 'ok') {
this.embeddingProviderConfigs = response.data.data || [];
} else {
this.showSnackbar(response.data.message || '获取嵌入模型列表失败', 'error');
return [];
}
})
.catch(error => {
console.error('Error fetching embedding providers:', error);
this.showSnackbar('获取嵌入模型列表失败', 'error');
return [];
});
},
openUrl(url) {
window.open(url, '_blank');
}
}
}
</script>
@@ -597,7 +717,7 @@ export default {
position: relative;
cursor: pointer;
display: flex;
background-color: #ffffff;
background-color: var(--v-theme-background);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
@@ -630,7 +750,7 @@ export default {
display: flex;
align-items: center;
justify-content: center;
background-color: #ffffff;
background-color: var(--v-theme-background);
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 16px;
@@ -747,4 +867,28 @@ export default {
.kb-card:hover .kb-actions {
opacity: 1;
}
.chunk-settings-card {
border: 1px solid rgba(92, 107, 192, 0.2) !important;
transition: all 0.3s ease;
}
.chunk-settings-card:hover {
border-color: rgba(92, 107, 192, 0.4) !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.07) !important;
}
.chunk-field :deep(.v-field__input) {
padding-top: 8px;
padding-bottom: 8px;
}
.chunk-field :deep(.v-field__prepend-inner) {
padding-right: 8px;
opacity: 0.7;
}
.chunk-field:focus-within :deep(.v-field__prepend-inner) {
opacity: 1;
}
</style>
+473 -81
View File
@@ -1,7 +1,12 @@
<template>
<div id="long-term-memory" class="flex-grow-1" style="display: flex; flex-direction: row; ">
<div id="graph-container" style="flex-grow: 1; width: 100%; border: 1px solid #eee; border-radius: 8px; max-height: calc(100% - 40px);">
<div id="graph-container"
style="flex-grow: 1; width: 100%; border: 1px solid #eee; border-radius: 8px; max-height: calc(100% - 40px);">
</div>
<!-- <div id="graph-container-nonono"
style="display: flex; justify-content: center; align-items: center; width: 100%; font-weight: 1000; font-size: 24px;">
加速开发中...
</div> -->
<div id="graph-control-panel"
style="min-width: 450px; border: 1px solid #eee; border-radius: 8px; padding: 16px; padding-bottom: 0px; margin-left: 16px; max-height: calc(100% - 40px);">
<div>
@@ -31,42 +36,27 @@
<div class="mt-4">
<h3>搜索记忆</h3>
<v-card variant="outlined" class="mt-2 pa-3">
<div >
<v-text-field
v-model="searchMemoryUserId"
label="用户 ID"
variant="outlined"
density="compact"
hide-details
class="mb-2"
></v-text-field>
<v-text-field
v-model="searchQuery"
label="输入关键词"
variant="outlined"
density="compact"
hide-details
@keyup.enter="searchMemory"
class="mb-2"
></v-text-field>
<div>
<v-text-field v-model="searchMemoryUserId" label="用户 ID" variant="outlined" density="compact" hide-details
class="mb-2"></v-text-field>
<v-text-field v-model="searchQuery" label="输入关键词" variant="outlined" density="compact" hide-details
@keyup.enter="searchMemory" class="mb-2"></v-text-field>
<v-btn color="info" @click="searchMemory" :loading="isSearching" variant="tonal">
<v-icon start>mdi-text-search</v-icon>
搜索
</v-btn>
</div>
<!-- 新增搜索结果展示区域 -->
<div v-if="searchResults.length > 0" class="mt-3">
<v-divider class="mb-3"></v-divider>
<div class="text-subtitle-1 mb-2">搜索结果 ({{ searchResults.length }})</div>
<v-expansion-panels variant="accordion">
<v-expansion-panel
v-for="(result, index) in searchResults"
:key="index"
>
<v-expansion-panel v-for="(result, index) in searchResults" :key="index">
<v-expansion-panel-title>
<div>
<span class="text-truncate d-inline-block" style="max-width: 300px;">{{ result.text.substring(0, 30) }}...</span>
<span class="text-truncate d-inline-block" style="max-width: 300px;">{{ result.text.substring(0, 30)
}}...</span>
<span class="ms-2 text-caption text-grey">(相关度: {{ (result.score * 100).toFixed(1) }}%)</span>
</div>
</v-expansion-panel-title>
@@ -86,42 +76,21 @@
</div>
</v-card>
</div>
<!-- 新增添加记忆数据的表单 -->
<div class="mt-4">
<h3>添加记忆数据</h3>
<v-card variant="outlined" class="mt-2 pa-3">
<v-form @submit.prevent="addMemoryData">
<v-textarea
v-model="newMemoryText"
label="输入文本内容"
variant="outlined"
rows="4"
hide-details
class="mb-2"
></v-textarea>
<v-text-field
v-model="newMemoryUserId"
label="用户 ID"
variant="outlined"
density="compact"
hide-details
></v-text-field>
<v-switch
v-model="needSummarize"
color="primary"
label="需要摘要"
hide-details
></v-switch>
<v-btn
color="success"
type="submit"
:loading="isSubmitting"
:disabled="!newMemoryText || !newMemoryUserId"
>
<v-textarea v-model="newMemoryText" label="输入文本内容" variant="outlined" rows="4" hide-details
class="mb-2"></v-textarea>
<v-text-field v-model="newMemoryUserId" label="用户 ID" variant="outlined" density="compact"
hide-details></v-text-field>
<v-switch v-model="needSummarize" color="primary" label="需要摘要" hide-details></v-switch>
<v-btn color="success" type="submit" :loading="isSubmitting" :disabled="!newMemoryText || !newMemoryUserId">
<v-icon start>mdi-plus</v-icon>
添加数据
</v-btn>
@@ -184,6 +153,99 @@
</div>
</v-card>
</div>
<v-dialog v-model="showFactDialog" max-width="550" scrollable>
<v-card class="fact-detail-card">
<v-card-title class="d-flex align-center bg-primary text-white px-4 py-3">
<v-icon class="mr-2" color="white">mdi-memory</v-icon>
记忆事实
<v-spacer></v-spacer>
<v-btn icon variant="text" color="white" @click="showFactDialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="px-4 pt-4 pb-0">
<template v-if="selectedEdgeFactData">
<v-alert color="primary" variant="tonal" density="compact" class="mb-4">
<div class="text-body-1 font-weight-medium">{{ selectedEdgeFactData.text }}</div>
</v-alert>
<v-row>
<v-col cols="6">
<div class="d-flex align-center mb-2">
<v-icon size="small" color="primary" class="mr-2">mdi-identifier</v-icon>
<div class="text-subtitle-2">ID</div>
</div>
<div class="text-body-2 text-grey pa-1">{{ selectedEdgeFactData.id }}</div>
</v-col>
<v-col cols="6">
<div class="d-flex align-center mb-2">
<v-icon size="small" color="primary" class="mr-2">mdi-file-document-outline</v-icon>
<div class="text-subtitle-2">文档ID</div>
</div>
<div class="text-body-2 text-grey pa-1">{{ selectedEdgeFactData.doc_id }}</div>
</v-col>
</v-row>
<!-- 时间信息 -->
<v-row class="mt-2">
<v-col cols="6">
<div class="d-flex align-center mb-2">
<v-icon size="small" color="primary" class="mr-2">mdi-calendar-plus</v-icon>
<div class="text-subtitle-2">创建时间</div>
</div>
<div class="text-body-2 text-grey pa-1">{{ formatTime(selectedEdgeFactData.created_at) }}</div>
</v-col>
<v-col cols="6">
<div class="d-flex align-center mb-2">
<v-icon size="small" color="primary" class="mr-2">mdi-calendar-edit</v-icon>
<div class="text-subtitle-2">更新时间</div>
</div>
<div class="text-body-2 text-grey pa-1">{{ formatTime(selectedEdgeFactData.updated_at) }}</div>
</v-col>
</v-row>
<!-- 改进元数据展示解析为键值对 -->
<div v-if="parsedMetadata && Object.keys(parsedMetadata).length > 0" class="mt-4">
<div class="d-flex align-center mb-2">
<v-icon size="small" color="primary" class="mr-2">mdi-database-cog</v-icon>
<div class="text-subtitle-2">元数据</div>
</div>
<v-card variant="outlined" class="metadata-table">
<v-table density="compact" hover>
<thead>
<tr>
<th class="text-left"></th>
<th class="text-left"></th>
</tr>
</thead>
<tbody>
<tr v-for="(value, key) in parsedMetadata" :key="key">
<td class="font-weight-medium">{{ key }}</td>
<td>{{ formatMetadataValue(value) }}</td>
</tr>
</tbody>
</v-table>
</v-card>
</div>
</template>
<div v-else class="text-center py-6">
<v-progress-circular indeterminate color="primary" size="50" width="5"></v-progress-circular>
<div class="mt-3 text-body-1">加载中...</div>
</div>
</v-card-text>
<v-divider v-if="selectedEdgeFactData"></v-divider>
<v-card-actions class="pa-4" v-if="selectedEdgeFactData">
<v-btn block color="primary" variant="tonal" @click="showFactDialog = false">
关闭
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</div>
</template>
@@ -230,6 +292,16 @@ export default {
isSearching: false,
searchResults: [],
hasSearched: false,
// 添加边点击相关数据
selectedEdge: null,
selectedEdgeFactId: null,
selectedEdgeFactData: null,
showFactDialog: false,
isLoadingFactData: false,
// 改进元数据展示
parsedMetadata: null,
}
},
mounted() {
@@ -249,26 +321,26 @@ export default {
this.$toast.warning('请输入搜索关键词');
return;
}
this.isSearching = true;
this.hasSearched = true;
this.searchResults = [];
// 构建查询参数
const params = {
query: this.searchQuery
};
// 如果有选择用户ID,也加入查询参数
if (this.searchMemoryUserId) {
params.user_id = this.searchMemoryUserId;
}
axios.get('/api/plug/alkaid/ltm/graph/search', { params })
.then(response => {
if (response.data.status === 'ok') {
const data = response.data.data;
// 处理返回的文档数组
this.searchResults = Object.keys(data).map(doc_id => {
return {
@@ -277,7 +349,7 @@ export default {
score: data[doc_id].score || 0
};
});
if (this.searchResults.length === 0) {
this.$toast.info('未找到相关记忆内容');
} else {
@@ -295,7 +367,7 @@ export default {
this.isSearching = false;
});
},
// 添加新方法,用于提交记忆数据
addMemoryData() {
if (!this.newMemoryText || !this.newMemoryUserId) {
@@ -303,23 +375,23 @@ export default {
}
this.isSubmitting = true;
// 准备提交数据
const payload = {
text: this.newMemoryText,
user_id: this.newMemoryUserId,
need_summarize: this.needSummarize
};
axios.post('/api/plug/alkaid/ltm/graph/add', payload)
.then(response => {
// 成功添加后刷新图表
this.refreshGraph();
// 重置表单
// this.newMemoryText = '';
// this.needSummarize = false;
// 显示成功消息
this.$toast.success('记忆数据添加成功!');
})
@@ -331,7 +403,7 @@ export default {
this.isSubmitting = false;
});
},
ltmGetGraph(userId = null) {
this.isLoading = true;
const params = userId ? { user_id: userId } : {};
@@ -424,6 +496,83 @@ export default {
this.ltmGetGraph();
},
// 添加获取Fact详情的方法
getFactDetails(factId) {
if (!factId) return;
this.isLoadingFactData = true;
this.selectedEdgeFactData = null;
this.parsedMetadata = null;
axios.get('/api/plug/alkaid/ltm/graph/fact', {
params: { fact_id: factId }
})
.then(response => {
if (response.data.status === 'ok') {
this.selectedEdgeFactData = response.data.data;
// 解析元数据
this.parsedMetadata = this.parseMetadata(this.selectedEdgeFactData.metadata);
this.showFactDialog = true;
} else {
this.$toast.error('获取记忆详情失败: ' + response.data.message);
}
})
.catch(error => {
console.error('获取记忆详情失败:', error);
this.$toast.error('获取记忆详情失败: ' + (error.response?.data?.message || error.message));
})
.finally(() => {
this.isLoadingFactData = false;
});
},
// 添加元数据解析方法
parseMetadata(metadata) {
if (!metadata) return null;
try {
// 如果是字符串,尝试解析JSON
if (typeof metadata === 'string') {
try {
return JSON.parse(metadata);
} catch (e) {
return { value: metadata }; // 如果无法解析为JSON,则作为单个值返回
}
}
// 如果已经是对象,直接返回
if (typeof metadata === 'object') {
return metadata;
}
return { value: String(metadata) };
} catch (e) {
console.error('解析元数据出错:', e);
return { error: '无法解析元数据' };
}
},
// 格式化元数据值
formatMetadataValue(value) {
if (value === null || value === undefined) return '无';
if (typeof value === 'object') {
return JSON.stringify(value);
}
return String(value);
},
// 格式化时间戳的辅助方法
formatTime(timestamp) {
if (!timestamp) return '未知';
try {
return new Date(timestamp).toLocaleString();
} catch (e) {
return timestamp;
}
},
initD3Graph() {
const container = document.getElementById("graph-container");
if (!container) return;
@@ -462,6 +611,8 @@ export default {
if (!this.svg || !this.simulation) return;
const g = this.g;
g.selectAll("*").remove();
// 添加箭头定义
g.append("defs").append("marker")
.attr("id", "arrowhead")
.attr("viewBox", "0 -5 10 10")
@@ -473,13 +624,22 @@ export default {
.append("path")
.attr("d", "M0,-5L10,0L0,5")
.attr("fill", "#999");
// 预处理边数据,标识和处理重复边
const linkGroups = this.identifyParallelLinks(this.links);
// 使用路径替代直线来绘制边,以便支持曲线
const link = g.append("g")
.selectAll("line")
.selectAll("path")
.data(this.links)
.join("line")
.join("path")
.attr("stroke", d => d.color)
.attr("stroke-width", 1.5)
.attr("marker-end", "url(#arrowhead)");
.attr("fill", "none")
.attr("marker-end", "url(#arrowhead)")
.style("cursor", "pointer");
// 边标签需要相应调整位置
const edgeLabels = g.append("g")
.selectAll("text")
.data(this.links)
@@ -488,7 +648,22 @@ export default {
.attr("font-size", "8px")
.attr("text-anchor", "middle")
.attr("fill", "#666")
.attr("dy", -5);
.style("cursor", "pointer")
.on("click", (event, d) => {
event.stopPropagation();
// 检查边数据中是否有fact_id
const factId = d.originalData?.fact_id;
if (factId) {
this.selectedEdge = d;
this.selectedEdgeFactId = factId;
this.getFactDetails(factId);
} else {
this.$toast.info('该关系没有关联的记忆数据');
}
});
// 节点绘制部分保持不变
const node = g.append("g")
.selectAll("circle")
.data(this.nodes)
@@ -497,6 +672,7 @@ export default {
.attr("fill", d => d.color)
.style("cursor", "pointer")
.call(this.dragBehavior());
const nodeLabels = g.append("g")
.selectAll("text")
.data(this.nodes)
@@ -506,27 +682,33 @@ export default {
.attr("text-anchor", "middle")
.attr("fill", "#333")
.attr("dy", -12);
node.on("click", (event, d) => {
event.stopPropagation();
this.selectedNode = d.originalData;
});
// 给SVG添加全局点击事件,用于关闭气泡
this.svg.on("click", () => {
this.selectedNode = null;
});
this.simulation
.nodes(this.nodes)
.on("tick", () => {
link
.attr("x1", d => d.source.x)
.attr("y1", d => d.source.y)
.attr("x2", d => d.target.x)
.attr("y2", d => d.target.y);
// 更新边的路径
link.attr("d", d => this.generateLinkPath(d));
// 更新边标签位置
edgeLabels
.attr("x", d => (d.source.x + d.target.x) / 2)
.attr("y", d => (d.source.y + d.target.y) / 2);
.attr("x", d => this.getLinkLabelX(d))
.attr("y", d => this.getLinkLabelY(d));
// 更新节点位置
node
.attr("cx", d => d.x)
.attr("cy", d => d.y);
nodeLabels
.attr("x", d => d.x)
.attr("y", d => d.y);
@@ -537,6 +719,175 @@ export default {
this.simulation.alpha(1).restart();
},
// 识别并标记平行边(连接相同两个节点的多条边)
identifyParallelLinks(links) {
// 创建一个映射来存储连接相同节点对的边
const linkMap = new Map();
// 遍历所有边,按照起点和终点进行分组
links.forEach(link => {
// 创建边的键,确保无论边的方向如何,同一对节点生成的键都相同
const sourceId = typeof link.source === 'object' ? link.source.id : link.source;
const targetId = typeof link.target === 'object' ? link.target.id : link.target;
const forwardKey = `${sourceId}-${targetId}`;
const reverseKey = `${targetId}-${sourceId}`;
// 判断是从source到target的边还是反向边
const isForwardLink = sourceId < targetId;
const key = isForwardLink ? forwardKey : reverseKey;
// 使用方向信息
if (!linkMap.has(key)) {
linkMap.set(key, []);
}
// 存储边和其方向
linkMap.get(key).push({
link,
isForward: isForwardLink
});
});
// 处理每一组平行边,为它们分配曲率
linkMap.forEach((parallels, key) => {
if (parallels.length > 1) {
// 有多条平行边,分配不同曲率
parallels.forEach((item, index) => {
// 根据边的数量计算适当的曲率
const totalLinks = parallels.length;
// 基础曲率,可根据边数调整
const baseCurvature = 0.45;
// 根据边的索引计算曲率:中间的边较直,两侧的边较弯
let curvature;
if (totalLinks % 2 === 1) {
// 奇数条边,中间的边直线,其他边弯曲
const middleIndex = Math.floor(totalLinks / 2);
if (index === middleIndex) {
curvature = 0; // 中间的边为直线
} else {
// 到中间边的距离决定曲率大小
const distance = Math.abs(index - middleIndex);
const direction = index < middleIndex ? -1 : 1;
curvature = direction * baseCurvature * distance;
}
} else {
// 偶数条边,所有边都弯曲
const middleIndex = totalLinks / 2 - 0.5;
const distance = Math.abs(index - middleIndex);
const direction = index < middleIndex ? -1 : 1;
curvature = direction * baseCurvature * distance;
}
// 如果是反向边,翻转曲率方向
if (!item.isForward) {
curvature = -curvature;
}
// 存储曲率值到边对象
item.link.curvature = curvature;
});
} else {
// 只有一条边,不需要弯曲
parallels[0].link.curvature = 0;
}
});
return linkMap;
},
// 根据曲率生成边的路径
generateLinkPath(d) {
// 确保source和target是对象
const source = typeof d.source === 'object' ? d.source : this.nodes.find(n => n.id === d.source);
const target = typeof d.target === 'object' ? d.target : this.nodes.find(n => n.id === d.target);
if (!source || !target) return '';
// 如果是直线(无曲率)
if (!d.curvature || d.curvature === 0) {
return `M${source.x},${source.y}L${target.x},${target.y}`;
}
// 计算曲线的控制点
const dx = target.x - source.x;
const dy = target.y - source.y;
const dr = Math.sqrt(dx * dx + dy * dy);
// 控制点偏移距离,由曲率决定
const offset = dr * d.curvature;
// 计算中点
const midX = (source.x + target.x) / 2;
const midY = (source.y + target.y) / 2;
// 计算垂直于连线的方向向量
const nx = -dy / dr;
const ny = dx / dr;
// 计算控制点坐标
const cpx = midX + offset * nx;
const cpy = midY + offset * ny;
// 创建二次贝塞尔曲线路径
return `M${source.x},${source.y} Q${cpx},${cpy} ${target.x},${target.y}`;
},
// 新增方法:计算边标签的X坐标
getLinkLabelX(d) {
const source = typeof d.source === 'object' ? d.source : this.nodes.find(n => n.id === d.source);
const target = typeof d.target === 'object' ? d.target : this.nodes.find(n => n.id === d.target);
if (!source || !target) return 0;
// 如果是直线
if (!d.curvature || d.curvature === 0) {
return (source.x + target.x) / 2;
}
// 计算曲线上的点
const dx = target.x - source.x;
const dy = target.y - source.y;
const dr = Math.sqrt(dx * dx + dy * dy);
// 中点
const midX = (source.x + target.x) / 2;
// 垂直向量
const nx = -dy / dr;
// 曲线路径上的点,使用曲率进行调整
return midX + d.curvature * dr * nx * 0.5;
},
// 新增方法:计算边标签的Y坐标
getLinkLabelY(d) {
const source = typeof d.source === 'object' ? d.source : this.nodes.find(n => n.id === d.source);
const target = typeof d.target === 'object' ? d.target : this.nodes.find(n => n.id === d.target);
if (!source || !target) return 0;
// 如果是直线
if (!d.curvature || d.curvature === 0) {
return (source.y + target.y) / 2;
}
// 计算曲线上的点
const dx = target.x - source.x;
const dy = target.y - source.y;
const dr = Math.sqrt(dx * dx + dy * dy);
// 中点
const midY = (source.y + target.y) / 2;
// 垂直向量
const ny = dx / dr;
// 曲线路径上的点,使用曲率进行调整
return midY + d.curvature * dr * ny * 0.5;
},
dragBehavior() {
return d3.drag()
@@ -571,6 +922,7 @@ export default {
<style scoped>
#long-term-memory {
height: 100%;
max-height: 100%;
overflow: hidden;
display: flex;
flex-direction: row;
@@ -585,8 +937,8 @@ export default {
}
#graph-control-panel {
height: 100%;
overflow-y: auto; /* 让控制面板可滚动而不是整个页面滚动 */
overflow-y: auto;
/* 让控制面板可滚动而不是整个页面滚动 */
min-width: 450px;
max-width: 450px;
}
@@ -607,4 +959,44 @@ export default {
.d3-graph {
background-color: #f2f6f9;
}
/* 为连接线添加交互样式 */
#graph-container line {
transition: stroke-width 0.2s;
}
#graph-container line:hover {
stroke-width: 3px;
cursor: pointer;
}
/* 添加美化详情卡片的样式 */
.fact-detail-card :deep(.v-card-title) {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.fact-detail-card :deep(.metadata-table) {
border-radius: 8px;
overflow: hidden;
}
.fact-detail-card :deep(.v-table) {
background: transparent;
}
.fact-detail-card :deep(.v-table th) {
color: var(--v-primary-base);
font-weight: bold;
background-color: rgba(var(--v-theme-primary), 0.05);
}
.fact-detail-card :deep(pre) {
background-color: #f5f5f5;
padding: 8px;
border-radius: 4px;
max-height: 150px;
overflow: auto;
font-size: 12px;
}
</style>
@@ -4,6 +4,7 @@ import Logo from '@/components/shared/Logo.vue';
import { onMounted, ref } from 'vue';
import { useAuthStore } from '@/stores/auth';
import { useRouter } from 'vue-router';
import {useCustomizerStore} from "@/stores/customizer";
const cardVisible = ref(false);
const router = useRouter();
@@ -24,7 +25,7 @@ onMounted(() => {
</script>
<template>
<div class="login-page-container">
<div v-if="useCustomizerStore().uiTheme==='PurpleTheme'" class="login-page-container">
<div class="login-background"></div>
<v-card
variant="outlined"
@@ -42,6 +43,24 @@ onMounted(() => {
</v-card-text>
</v-card>
</div>
<div v-else class="login-page-container-dark">
<div class="login-background-dark"></div>
<v-card
variant="outlined"
class="login-card"
:class="{ 'card-visible': cardVisible }"
>
<v-card-text class="pa-10">
<div class="logo-wrapper">
<Logo />
</div>
<div class="divider-container">
<v-divider class="custom-divider"></v-divider>
</div>
<AuthLogin />
</v-card-text>
</v-card>
</div>
</template>
<style lang="scss">
@@ -56,6 +75,17 @@ onMounted(() => {
overflow: hidden;
}
.login-page-container-dark {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
width: 100%;
position: relative;
background: linear-gradient(135deg, #1a1b1c 0%, #1d1e21 100%);
overflow: hidden;
}
.login-background {
position: absolute;
width: 200%;
@@ -67,6 +97,17 @@ onMounted(() => {
animation: rotate 60s linear infinite;
}
.login-background-dark {
position: absolute;
width: 200%;
height: 200%;
top: -50%;
left: -50%;
background-color: var(--v-theme-surface);
z-index: 0;
animation: rotate 60s linear infinite;
}
@keyframes rotate {
0% {
transform: rotate(0deg);
@@ -79,9 +120,11 @@ onMounted(() => {
.login-card {
max-width: 520px;
width: 90%;
color: var(--v-theme-primaryText) !important;
border-radius: 12px !important;
border-color: var(--v-theme-border) !important;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.07) !important;
background-color: rgba(255, 255, 255, 0.98) !important;
background-color: var(--v-theme-surface) !important;
transform: translateY(20px);
opacity: 0;
transition: all 0.5s ease;
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { ref } 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";
const valid = ref(false);
const show1 = ref(false);
@@ -42,8 +43,8 @@ async function validate(values: any, { setErrors }: any) {
required
density="comfortable"
hide-details="auto"
variant="outlined"
color="primary"
variant="outlined"
:style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#000000dd' : '#ffffff'}"
prepend-inner-icon="mdi-account"
:disabled="loading"
></v-text-field>
@@ -54,7 +55,7 @@ async function validate(values: any, { setErrors }: any) {
required
density="comfortable"
variant="outlined"
color="primary"
:style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#000000dd' : '#ffffff'}"
hide-details="auto"
:append-icon="show1 ? 'mdi-eye' : 'mdi-eye-off'"
:type="show1 ? 'text' : 'password'"
@@ -63,16 +64,12 @@ async function validate(values: any, { setErrors }: any) {
prepend-inner-icon="mdi-lock"
:disabled="loading"
></v-text-field>
<div class="mt-1 mb-5 hint-text">
<small>默认用户名和密码为 astrbot</small>
</div>
<v-btn
color="secondary"
:loading="isSubmitting || loading"
block
class="login-btn"
class="login-btn mt-8"
variant="flat"
size="large"
:disabled="valid"
@@ -160,7 +157,7 @@ async function validate(values: any, { setErrors }: any) {
}
.hint-text {
color: rgba(0, 0, 0, 0.5);
color: var(--v-theme-secondaryText);
padding-left: 5px;
}
@@ -171,7 +168,7 @@ async function validate(values: any, { setErrors }: any) {
}
}
.custom-devider {
.custom-divider {
border-color: rgba(0, 0, 0, 0.08) !important;
}
</style>
@@ -155,7 +155,7 @@ export default {
<style scoped>
.dashboard-container {
padding: 16px;
background-color: #f9fafc;
background-color: var(--v-theme-background);
min-height: calc(100vh - 64px);
border-radius: 10px;
@@ -170,13 +170,13 @@ export default {
.dashboard-title {
font-size: 24px;
font-weight: 600;
color: #333;
color: var(--v-theme-primaryText);
margin-bottom: 4px;
}
.dashboard-subtitle {
font-size: 14px;
color: #666;
color: var(--v-theme-secondaryText);
}
.notice-row {
@@ -194,18 +194,18 @@ export default {
.plugin-card {
border-radius: 8px;
background-color: white;
background-color: var(--v-theme-surface);
}
.plugin-title {
font-size: 18px;
font-weight: 600;
color: #333;
color: var(--v-theme-primaryText);
}
.plugin-subtitle {
font-size: 12px;
color: #666;
color: var(--v-theme-secondaryText);
margin-top: 4px;
}
@@ -225,7 +225,7 @@ export default {
.plugin-version {
font-size: 12px;
color: #666;
color: var(--v-theme-secondaryText, #666);
}
.dashboard-footer {
@@ -69,6 +69,7 @@
<script>
import axios from 'axios';
import {useCustomizerStore} from "@/stores/customizer";
export default {
name: 'MessageStat',
@@ -129,7 +130,7 @@ export default {
}
},
tooltip: {
theme: 'light',
theme: useCustomizerStore().uiTheme==='PurpleTheme' ? 'light' : 'dark',
x: {
format: 'yyyy-MM-dd HH:mm'
},
@@ -167,7 +168,7 @@ export default {
},
},
grid: {
borderColor: '#f1f1f1',
borderColor: "gray100",
row: {
colors: ['transparent', 'transparent'],
opacity: 0.2
@@ -293,12 +294,12 @@ export default {
.chart-title {
font-size: 18px;
font-weight: 600;
color: #333;
color: var(--v-theme-primaryText);
}
.chart-subtitle {
font-size: 12px;
color: #666;
color: var(--v-theme-secondaryText);
margin-top: 4px;
}
@@ -315,35 +316,35 @@ export default {
.stat-box {
padding: 12px 16px;
background: #f5f5f5;
background: var(--v-theme-surface);
border-radius: 8px;
flex: 1;
}
.stat-label {
font-size: 12px;
color: #666;
color: var(--v-theme-secondaryText);
margin-bottom: 4px;
}
.stat-number {
font-size: 18px;
font-weight: 600;
color: #333;
color: var(--v-theme-primaryText);
display: flex;
align-items: center;
}
.trend-up .stat-number {
color: #4caf50;
color: var(--v-theme-success);
}
.trend-down .stat-number {
color: #f44336;
color: var(--v-theme-error);
}
.chart-container {
border-top: 1px solid #f0f0f0;
border-top: 1px solid var(--v-theme-border);
padding-top: 20px;
position: relative;
}
@@ -354,7 +355,7 @@ export default {
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.8);
background: var(--v-theme-overlay);
display: flex;
flex-direction: column;
justify-content: center;
@@ -365,6 +366,6 @@ export default {
.loading-text {
margin-top: 12px;
font-size: 14px;
color: #666;
color: var(--v-theme-secondaryText);
}
</style>
@@ -132,12 +132,12 @@ export default {
.platform-title {
font-size: 18px;
font-weight: 600;
color: #333;
color: var(--v-theme-primaryText);
}
.platform-subtitle {
font-size: 12px;
color: #666;
color: var(--v-theme-secondaryText);
margin-top: 4px;
}
@@ -171,16 +171,16 @@ export default {
width: 24px;
height: 24px;
border-radius: 50%;
background-color: #f0f0f0;
color: #333;
background-color: var(--v-theme-surface);
color: var(--v-theme-primaryText);
font-weight: 600;
font-size: 14px;
margin-right: 12px;
}
.top-rank {
background-color: #5e35b1;
color: white;
background-color: var(--v-theme-secondary);
color: var(--v-theme-surface);
}
.platform-name {
@@ -195,19 +195,19 @@ export default {
.count-value {
font-weight: 600;
font-size: 14px;
color: #5e35b1;
color: var(--v-theme-secondary);
margin-right: 4px;
}
.count-label {
font-size: 12px;
color: #666;
color: var(--v-theme-secondaryText);
}
.platform-stats-summary {
display: flex;
justify-content: space-between;
background-color: #f5f5f5;
background-color: var(--v-theme-containerBg);
border-radius: 8px;
padding: 12px;
margin-bottom: 16px;
@@ -220,13 +220,13 @@ export default {
.stat-label {
font-size: 12px;
color: #666;
color: var(--v-theme-secondaryText);
margin-bottom: 4px;
}
.stat-value {
font-weight: 600;
color: #333;
color: var(--v-theme-primaryText);
}
.platform-chart {
@@ -246,7 +246,7 @@ export default {
}
.no-data-text {
color: #999;
color: var(--v-theme-secondaryText);
margin-top: 16px;
font-size: 14px;
}
+2 -4
View File
@@ -92,14 +92,12 @@ class Waiter(Star):
async def empty_mention_waiter(
controller: SessionController, event: AstrMessageEvent
):
logger.info("empty_mention_waiter")
event.message_obj.message.insert(
0, Comp.At(qq=event.get_self_id(), name=event.get_self_id())
)
new_event = copy.copy(event)
self.context.get_event_queue().put_nowait(
new_event
) # 重新推入事件队列
# 重新推入事件队列
self.context.get_event_queue().put_nowait(new_event)
event.stop_event()
controller.stop()
+3 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
version = "3.5.12"
version = "3.5.14"
description = "易上手的多平台 LLM 聊天机器人及开发框架"
readme = "README.md"
requires-python = ">=3.10"
@@ -20,13 +20,14 @@ dependencies = [
"defusedxml>=0.7.1",
"dingtalk-stream>=0.22.1",
"docstring-parser>=0.16",
"faiss-cpu>=1.11.0",
"faiss-cpu>=1.10.0",
"filelock>=3.18.0",
"google-genai>=1.14.0",
"googlesearch-python>=1.3.0",
"lark-oapi>=1.4.15",
"lxml-html-clean>=0.4.2",
"mcp>=1.8.0",
"nh3>=0.2.21",
"openai>=1.78.0",
"ormsgpack>=1.9.1",
"pillow>=11.2.1",
+2 -1
View File
@@ -36,4 +36,5 @@ filelock
watchfiles
websockets
faiss-cpu
aiosqlite
aiosqlite
nh3
Generated
+1408 -1375
View File
File diff suppressed because it is too large Load Diff