Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea64cebe2a | |||
| ab2bbff369 | |||
| ec32825309 | |||
| fd0c182087 | |||
| 49fcff1daf | |||
| 4c447aa648 | |||
| ccbfc3d274 | |||
| f83fe43bbb | |||
| 19022d67f8 | |||
| 58a815dd6b | |||
| bc9fe82860 | |||
| b3cd9bf2b9 | |||
| c5c2b829ec | |||
| 11f35ebf96 | |||
| 7d403aa181 | |||
| 64af810a4a | |||
| 30821905af | |||
| a9dbff756b | |||
| a6aba10d3d | |||
| 9c276c37fe | |||
| 6ab6c0fd4c | |||
| b6b0fe3fff | |||
| 0d5825bda9 | |||
| cdfb64631a | |||
| d161c281c8 | |||
| 8fed5bf2a1 | |||
| a03af55edd | |||
| 86e2fd9aee | |||
| 97bd0e5e58 | |||
| ceaba21986 | |||
| 172a77d942 | |||
| 4f9d2d2a7d | |||
| 8c929f6e05 | |||
| 3319b71f5b | |||
| 46ec028a5b | |||
| 0ce0ef3e5c | |||
| 375b071cb2 | |||
| 29e1417ff2 | |||
| 75db2bd366 | |||
| 60ca1efbda | |||
| 2692e4978b | |||
| 91982eb002 | |||
| bb1dec76fa | |||
| f618b8fcdc | |||
| 9147cab75b | |||
| 5f07bcc8e6 | |||
| 705cf2ea1b | |||
| 42c4394484 | |||
| 221221a3c1 | |||
| 6e1449900a | |||
| ea1f9cb3b2 | |||
| 9ed86e5f53 | |||
| 303e0bc037 | |||
| 2cc24019f9 | |||
| 83ce774d19 | |||
| 3a964561f0 |
@@ -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: |
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -31,13 +31,21 @@ AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用
|
||||
<!-- [](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)
|
||||
|
||||

|
||||
|
||||
</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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import os
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
VERSION = "3.5.13"
|
||||
VERSION = "3.5.14"
|
||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v3.db")
|
||||
|
||||
# 默认配置
|
||||
@@ -873,6 +873,17 @@ CONFIG_METADATA_2 = {
|
||||
"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": {
|
||||
@@ -888,7 +899,10 @@ CONFIG_METADATA_2 = {
|
||||
"embedding_api_key": {
|
||||
"description": "API Key",
|
||||
"type": "string",
|
||||
"hint": "API Key",
|
||||
},
|
||||
"embedding_api_base": {
|
||||
"description": "API Base URL",
|
||||
"type": "string",
|
||||
},
|
||||
"volcengine_cluster": {
|
||||
"type": "string",
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
# 动态变量
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -19,6 +19,7 @@ class OpenAIEmbeddingProvider(EmbeddingProvider):
|
||||
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)
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
|
||||
@@ -451,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -41,6 +42,7 @@ class AuthRoute(Route):
|
||||
.__dict__
|
||||
)
|
||||
else:
|
||||
await asyncio.sleep(3)
|
||||
return Response().error("用户名或密码错误").__dict__
|
||||
|
||||
async def edit_account(self):
|
||||
@@ -76,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
|
||||
|
||||
@@ -174,14 +174,15 @@ class ConfigRoute(Route):
|
||||
"""辅助函数:测试单个 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": meta.id if meta else "Unknown ID",
|
||||
"model": meta.model if meta else "Unknown Model",
|
||||
"type": meta.type if meta else "Unknown Type",
|
||||
"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,
|
||||
@@ -189,7 +190,7 @@ class ConfigRoute(Route):
|
||||
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="Ping"), timeout=20.0) # 超时 20 秒
|
||||
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:
|
||||
@@ -209,7 +210,7 @@ class ConfigRoute(Route):
|
||||
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 10 seconds during test call."
|
||||
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)
|
||||
|
||||
@@ -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__
|
||||
|
||||
@@ -13,6 +13,9 @@ class StaticFileRoute(Route):
|
||||
"/extension",
|
||||
"/dashboard/default",
|
||||
"/alkaid",
|
||||
"/alkaid/knowledge-base",
|
||||
"/alkaid/long-term-memory",
|
||||
"/alkaid/other",
|
||||
"/console",
|
||||
"/chat",
|
||||
"/settings",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# What's Changed
|
||||
|
||||
1. 优化:强化了 WebUI 安全性
|
||||
2. 修复:测试文本生成提供商时可能出现的误报
|
||||
3. 修复:刷新知识库页面时出现404
|
||||
4. 新增:WeChatPadPro 支持获取引用、语音收发、视频等消息段
|
||||
5. 优化:WebUI 账户修改页面的设计逻辑
|
||||
6. 优化:插件更新后自动刷新插件列表
|
||||
7. 新增:支持下载插件的指定分支
|
||||
8. 修复:WeChatPadPro 群聊模式下 @ 不回复等问题
|
||||
9. 其他更新、优化及修复
|
||||
@@ -5,18 +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>
|
||||
<h2 class="text-secondary">{{ title }}</h2>
|
||||
<!-- 父子组件传递css变量可能会出错,暂时使用十六进制颜色值 -->
|
||||
<h4 :style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#000000aa' : '#ffffffcc'}"
|
||||
class="hint-text">登录以继续</h4>
|
||||
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";
|
||||
import { useCustomizerStore } from "@/stores/customizer";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}>(), {
|
||||
title: 'AstrBot 仪表盘',
|
||||
subtitle: '欢迎使用'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -68,5 +75,4 @@ import {useCustomizerStore} from "@/stores/customizer";
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -8,10 +8,10 @@ export type ConfigProps = {
|
||||
};
|
||||
|
||||
function checkUITheme() {
|
||||
/* 检查localStorage有无记忆的主题选项,如有则使用,否则使用默认值 */
|
||||
const theme = localStorage.getItem("uiTheme");
|
||||
console.log('memorized theme: ', theme);
|
||||
if (!theme || !(['PurpleTheme', 'PurpleThemeDark'].includes(theme))) {
|
||||
localStorage.setItem("uiTheme", "PurpleTheme");
|
||||
localStorage.setItem("uiTheme", "PurpleTheme"); // todo: 这部分可以根据vuetify.ts的默认主题动态调整
|
||||
return 'PurpleTheme';
|
||||
} else return theme;
|
||||
}
|
||||
|
||||
@@ -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="useCustomizerStore().uiTheme"
|
||||
<v-app :theme="useCustomizerStore().uiTheme"
|
||||
:class="[customizer.fontTheme, customizer.mini_sidebar ? 'mini-sidebar' : '', customizer.inputBg ? 'inputWithbg' : '']"
|
||||
>
|
||||
<VerticalHeaderVue />
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue';
|
||||
import {ref, computed} from 'vue';
|
||||
import {useCustomizerStore} from '@/stores/customizer';
|
||||
import axios from 'axios';
|
||||
import Logo from '@/components/shared/Logo.vue';
|
||||
import {md5} from 'js-md5';
|
||||
import {useAuthStore} from '@/stores/auth';
|
||||
import {useCommonStore} from '@/stores/common';
|
||||
@@ -11,6 +12,7 @@ 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,7 +25,7 @@ let dashboardHasNewVersion = ref(false);
|
||||
let dashboardCurrentVersion = ref('');
|
||||
let version = ref('');
|
||||
let releases = ref([]);
|
||||
let devCommits = ref([]); // 新增的 ref
|
||||
let devCommits = ref([]);
|
||||
|
||||
let installLoading = ref(false);
|
||||
|
||||
@@ -37,12 +39,38 @@ let releasesHeader = [
|
||||
{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,27 +82,33 @@ 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;
|
||||
accountEditStatus.value.error = true;
|
||||
accountEditStatus.value.message = res.data.message;
|
||||
password.value = '';
|
||||
newPassword.value = '';
|
||||
return;
|
||||
}
|
||||
dialog.value = !dialog.value;
|
||||
status.value = res.data.message;
|
||||
accountEditStatus.value.success = true;
|
||||
accountEditStatus.value.message = res.data.message;
|
||||
setTimeout(() => {
|
||||
dialog.value = !dialog.value;
|
||||
const authStore = useAuthStore();
|
||||
authStore.logout();
|
||||
}, 1000);
|
||||
}, 2000);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
status.value = err
|
||||
accountEditStatus.value.error = true;
|
||||
accountEditStatus.value.message = typeof err === 'string' ? err : '修改失败,请重试';
|
||||
password.value = '';
|
||||
newPassword.value = '';
|
||||
})
|
||||
.finally(() => {
|
||||
accountEditStatus.value.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -83,6 +117,14 @@ function getVersion() {
|
||||
.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);
|
||||
@@ -118,8 +160,6 @@ function checkUpdate() {
|
||||
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;
|
||||
@@ -201,13 +241,6 @@ 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>
|
||||
@@ -389,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="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>
|
||||
@@ -454,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>
|
||||
@@ -32,7 +32,7 @@ export const useAuthStore = defineStore({
|
||||
},
|
||||
logout() {
|
||||
this.username = '';
|
||||
localStorage.removeItem('username');
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('token');
|
||||
router.push('/auth/login');
|
||||
},
|
||||
|
||||
@@ -15,18 +15,15 @@ marked.setOptions({
|
||||
<div class="sidebar-panel">
|
||||
<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"
|
||||
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>
|
||||
@@ -707,6 +704,7 @@ export default {
|
||||
}
|
||||
|
||||
/* 聊天页面布局 */
|
||||
/* todo: 聊天页面背景颜色有问题 */
|
||||
.chat-page-card {
|
||||
margin-bottom: 16px;
|
||||
width: 100%;
|
||||
@@ -735,7 +733,7 @@ export default {
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.05);
|
||||
background-color: var(--v-theme-surface) !important;
|
||||
background-color: var(--v-theme-containerBg);
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
@@ -783,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 {
|
||||
@@ -841,6 +839,7 @@ export default {
|
||||
text-transform: none;
|
||||
letter-spacing: 0.25px;
|
||||
font-size: 12px;
|
||||
line-height: 1.2em;
|
||||
}
|
||||
|
||||
.delete-chat-btn:hover {
|
||||
|
||||
@@ -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,13 +38,12 @@ 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>
|
||||
@@ -72,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: var(--v-theme-primaryText, #000); text-decoration:none">{{
|
||||
item.name }}</a></span>
|
||||
<span v-else>{{ item.name }}</span>
|
||||
|
||||
showPluginFullName ? item.name : item.trimmedName }}</a></span>
|
||||
<span v-else>{{ showPluginFullName ? item.name : item.trimmedName }}</span>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
@@ -111,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>
|
||||
@@ -265,6 +267,7 @@ export default {
|
||||
loading_: false,
|
||||
upload_file: null,
|
||||
pluginMarketData: [],
|
||||
showPluginFullName: false,
|
||||
loadingDialog: {
|
||||
show: false,
|
||||
title: "加载中...",
|
||||
@@ -283,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 }
|
||||
@@ -319,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
|
||||
@@ -367,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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div id="long-term-memory" class="flex-grow-1" style="display: flex; flex-direction: row; ">
|
||||
<!-- <div id="graph-container"
|
||||
<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"
|
||||
</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> -->
|
||||
<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>
|
||||
@@ -153,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>
|
||||
@@ -199,6 +292,16 @@ export default {
|
||||
isSearching: false,
|
||||
searchResults: [],
|
||||
hasSearched: false,
|
||||
|
||||
// 添加边点击相关数据
|
||||
selectedEdge: null,
|
||||
selectedEdgeFactId: null,
|
||||
selectedEdgeFactData: null,
|
||||
showFactDialog: false,
|
||||
isLoadingFactData: false,
|
||||
|
||||
// 改进元数据展示
|
||||
parsedMetadata: null,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@@ -393,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;
|
||||
@@ -431,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")
|
||||
@@ -442,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)
|
||||
@@ -457,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)
|
||||
@@ -466,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)
|
||||
@@ -475,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);
|
||||
@@ -506,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()
|
||||
@@ -578,4 +960,43 @@ export default {
|
||||
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>
|
||||
|
||||
@@ -64,16 +64,12 @@ async function validate(values: any, { setErrors }: any) {
|
||||
prepend-inner-icon="mdi-lock"
|
||||
:disabled="loading"
|
||||
></v-text-field>
|
||||
|
||||
<v-label :style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#000000aa' : '#ffffffcc'}" class="mt-1 mb-5">
|
||||
<small>默认用户名和密码为 astrbot</small>
|
||||
</v-label>
|
||||
|
||||
<v-btn
|
||||
color="secondary"
|
||||
:loading="isSubmitting || loading"
|
||||
block
|
||||
class="login-btn"
|
||||
class="login-btn mt-8"
|
||||
variant="flat"
|
||||
size="large"
|
||||
:disabled="valid"
|
||||
|
||||
@@ -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'
|
||||
},
|
||||
@@ -343,7 +344,7 @@ export default {
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
border-top: 1px solid var(--v-theme-border);
|
||||
padding-top: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "AstrBot"
|
||||
version = "3.5.13"
|
||||
version = "3.5.14"
|
||||
description = "易上手的多平台 LLM 聊天机器人及开发框架"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
Reference in New Issue
Block a user