Merge branch 'master' of https://github.com/RC-CHN/AstrBot
This commit is contained in:
@@ -23,6 +23,33 @@ 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"
|
||||
rclone copy dashboard/dist.zip r2:$R2_BUCKET_NAME/$R2_OBJECT_NAME --progress
|
||||
rclone copy dashboard/dist.zip r2:$R2_BUCKET_NAME/astrbot-webui-${VERSION_TAG}.zip --progress
|
||||
|
||||
- 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.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"
|
||||
|
||||
|
||||
@@ -37,7 +37,15 @@ AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用
|
||||
|
||||
## ✨ 近期更新
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -99,6 +99,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):
|
||||
|
||||
@@ -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": "火山引擎集群",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -6,9 +6,8 @@ from typing import Optional
|
||||
|
||||
import aiohttp
|
||||
import websockets
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.api.message_components import Plain, Image
|
||||
from astrbot.api.message_components import Plain, Image, At
|
||||
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 +21,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 +65,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 +120,7 @@ class WeChatPadProAdapter(Platform):
|
||||
logger.warning("登录失败或超时,WeChatPadPro 适配器将关闭。")
|
||||
await self.terminate()
|
||||
return
|
||||
|
||||
|
||||
# 登录成功后,连接 WebSocket 接收消息
|
||||
self.ws_handle_task = asyncio.create_task(self.connect_websocket())
|
||||
|
||||
@@ -161,27 +179,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 +376,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 +453,7 @@ class WeChatPadProAdapter(Platform):
|
||||
):
|
||||
# 再根据消息类型处理消息内容
|
||||
await self._process_message_content(abm, raw_message, msg_type, content)
|
||||
|
||||
|
||||
return abm
|
||||
return None
|
||||
|
||||
@@ -457,6 +471,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 +493,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 = ""
|
||||
@@ -575,6 +598,25 @@ class WeChatPadProAdapter(Platform):
|
||||
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 +630,57 @@ 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 == 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,7 +7,7 @@ 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 # 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
|
||||
@@ -38,6 +38,8 @@ 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)
|
||||
await super().send(message)
|
||||
|
||||
async def _send_image(self, session: aiohttp.ClientSession, comp: Image):
|
||||
@@ -73,12 +75,29 @@ 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)
|
||||
|
||||
@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
|
||||
@@ -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"):
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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:
|
||||
"""获取向量的维度"""
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 类型)。"""
|
||||
|
||||
@@ -166,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,则检查所有插件的依赖
|
||||
"""
|
||||
@@ -185,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)}")
|
||||
|
||||
@@ -407,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())
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -9,7 +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 # 用于并发执行获取供应商请求
|
||||
import asyncio
|
||||
|
||||
|
||||
def try_cast(value: str, type_: str):
|
||||
@@ -166,15 +166,18 @@ class ConfigRoute(Route):
|
||||
"/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_name = provider.provider_config.get("name") or getattr(meta, 'id', 'Unknown Provider')
|
||||
|
||||
provider_name = provider.provider_config.get("id", "Unknown Provider")
|
||||
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'),
|
||||
@@ -186,7 +189,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) #超时二十秒
|
||||
response = await asyncio.wait_for(provider.text_chat(prompt="Ping"), timeout=20.0) # 超时 20 秒
|
||||
logger.debug(f"Received response from {status_info['name']}: {response}")
|
||||
# 只要 text_chat 调用成功返回一个 LLMResponse 对象 (即 response 不为 None),就认为可用
|
||||
if response is not None:
|
||||
@@ -248,6 +251,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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -13,6 +13,9 @@ class StaticFileRoute(Route):
|
||||
"/extension",
|
||||
"/dashboard/default",
|
||||
"/alkaid",
|
||||
"/alkaid/knowledge-base",
|
||||
"/alkaid/long-term-memory",
|
||||
"/alkaid/other",
|
||||
"/console",
|
||||
"/chat",
|
||||
"/settings",
|
||||
|
||||
@@ -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()}")
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# What's Changed
|
||||
|
||||
1. 新增:WebUI 支持暗夜模式
|
||||
2. 修复:修复 WebUI Chat 接口的未授权访问安全漏洞、插件 README 可能存在的 XSS 注入漏洞
|
||||
3. 优化:优化 Vec DB 在 indexing 过程时的数据库事务处理
|
||||
4. 修复:WebUI 下,插件市场的推荐卡片无法点击帮助文档的问题
|
||||
1. 新增:WebUI 支持暗夜模式。
|
||||
2. 修复:修复 WebUI Chat 接口的未授权访问安全漏洞、插件 README 可能存在的 XSS 注入漏洞。
|
||||
3. 优化:优化 Vec DB 在 indexing 过程时的数据库事务处理。
|
||||
4. 修复:WebUI 下,插件市场的推荐卡片无法点击帮助文档的问题。
|
||||
5. 新增:知识库。
|
||||
6. 新增:WebUI 提供商测试功能,一键检测可用性。
|
||||
7. 新增:WebUI 提供商分类功能,按能力分类提供商。
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -11,6 +11,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('');
|
||||
@@ -54,7 +55,7 @@ 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') {
|
||||
@@ -408,13 +409,15 @@ if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('cha
|
||||
<div>为了安全,请务必修改默认密码。</div>
|
||||
</v-alert>
|
||||
|
||||
<v-text-field label="原密码*" type="password" v-model="password" required
|
||||
variant="outlined"></v-text-field>
|
||||
<v-text-field label="原密码(*)" type="password" v-model="password" required
|
||||
variant="outlined"/>
|
||||
|
||||
<v-text-field label="新密码(*)" type="password" v-model="newPassword" required
|
||||
variant="outlined"/>
|
||||
|
||||
<v-text-field label="新用户名(可选)" v-model="newUsername" variant="outlined"/>
|
||||
|
||||
<v-text-field label="新用户名" v-model="newUsername" required variant="outlined"></v-text-field>
|
||||
|
||||
<v-text-field label="新密码" type="password" v-model="newPassword" required
|
||||
variant="outlined"></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -154,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">
|
||||
@@ -274,6 +304,51 @@ export default {
|
||||
// 新增提供商对话框相关
|
||||
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;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -292,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 || {};
|
||||
@@ -343,7 +427,8 @@ export default {
|
||||
const names = {
|
||||
'chat_completion': '基本对话',
|
||||
'speech_to_text': '语音转文本',
|
||||
'text_to_speech': '文本转语音'
|
||||
'text_to_speech': '文本转语音',
|
||||
'embedding': 'Embedding'
|
||||
};
|
||||
return names[tabType] || tabType;
|
||||
},
|
||||
|
||||
@@ -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,7 +22,10 @@
|
||||
</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">
|
||||
创建知识库
|
||||
@@ -49,9 +56,9 @@
|
||||
<div style="padding: 16px; text-align: center;">
|
||||
<small style="color: #a3a3a3">Tips: 在聊天页面通过 /kb 指令了解如何使用!</small>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 创建知识库对话框 -->
|
||||
@@ -72,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>
|
||||
@@ -118,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>
|
||||
@@ -140,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>
|
||||
@@ -243,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,
|
||||
@@ -256,7 +317,8 @@ export default {
|
||||
newKB: {
|
||||
name: '',
|
||||
emoji: '🙂',
|
||||
description: ''
|
||||
description: '',
|
||||
embedding_provider_id: ''
|
||||
},
|
||||
snackbar: {
|
||||
show: false,
|
||||
@@ -296,6 +358,8 @@ export default {
|
||||
},
|
||||
activeTab: 'upload',
|
||||
selectedFile: null,
|
||||
chunkSize: null,
|
||||
overlap: null,
|
||||
uploading: false,
|
||||
searchQuery: '',
|
||||
searchResults: [],
|
||||
@@ -306,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 => {
|
||||
@@ -335,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 => {
|
||||
@@ -365,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') {
|
||||
@@ -394,7 +471,8 @@ export default {
|
||||
this.createCollection(
|
||||
this.newKB.name,
|
||||
this.newKB.emoji || '🙂',
|
||||
this.newKB.description
|
||||
this.newKB.description,
|
||||
this.newKB.embedding_provider_id || ''
|
||||
);
|
||||
},
|
||||
|
||||
@@ -402,7 +480,8 @@ export default {
|
||||
this.newKB = {
|
||||
name: '',
|
||||
emoji: '🙂',
|
||||
description: ''
|
||||
description: '',
|
||||
embedding_provider: ''
|
||||
};
|
||||
},
|
||||
|
||||
@@ -419,6 +498,9 @@ export default {
|
||||
this.searchQuery = '';
|
||||
this.searchResults = [];
|
||||
this.searchPerformed = false;
|
||||
// 重置分片长度和重叠长度参数
|
||||
this.chunkSize = null;
|
||||
this.overlap = null;
|
||||
},
|
||||
|
||||
triggerFileInput() {
|
||||
@@ -473,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'
|
||||
@@ -480,7 +571,7 @@ export default {
|
||||
})
|
||||
.then(response => {
|
||||
if (response.data.status === 'ok') {
|
||||
this.showSnackbar('文件上传成功');
|
||||
this.showSnackbar('操作成功: ' + response.data.message);
|
||||
this.selectedFile = null;
|
||||
|
||||
// 刷新知识库列表,获取更新的数量
|
||||
@@ -582,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>
|
||||
@@ -751,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>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<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);">
|
||||
@@ -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>
|
||||
@@ -249,26 +218,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 +246,7 @@ export default {
|
||||
score: data[doc_id].score || 0
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
if (this.searchResults.length === 0) {
|
||||
this.$toast.info('未找到相关记忆内容');
|
||||
} else {
|
||||
@@ -295,7 +264,7 @@ export default {
|
||||
this.isSearching = false;
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
// 添加新方法,用于提交记忆数据
|
||||
addMemoryData() {
|
||||
if (!this.newMemoryText || !this.newMemoryUserId) {
|
||||
@@ -303,23 +272,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 +300,7 @@ export default {
|
||||
this.isSubmitting = false;
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
ltmGetGraph(userId = null) {
|
||||
this.isLoading = true;
|
||||
const params = userId ? { user_id: userId } : {};
|
||||
@@ -571,6 +540,7 @@ export default {
|
||||
<style scoped>
|
||||
#long-term-memory {
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -585,8 +555,8 @@ export default {
|
||||
}
|
||||
|
||||
#graph-control-panel {
|
||||
height: 100%;
|
||||
overflow-y: auto; /* 让控制面板可滚动而不是整个页面滚动 */
|
||||
overflow-y: auto;
|
||||
/* 让控制面板可滚动而不是整个页面滚动 */
|
||||
min-width: 450px;
|
||||
max-width: 450px;
|
||||
}
|
||||
@@ -607,4 +577,5 @@ export default {
|
||||
.d3-graph {
|
||||
background-color: #f2f6f9;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user