Compare commits
145 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 16ec462abd | |||
| ca55465d3c | |||
| 7098c98dde | |||
| f56355da89 | |||
| 422160debd | |||
| 8062cf406a | |||
| 0e802232ec | |||
| f650a9205d | |||
| c85dbb2347 | |||
| a6a79128c8 | |||
| 42839627e8 | |||
| 267e68a894 | |||
| b32b444438 | |||
| 522d0f8313 | |||
| 5715e5de67 | |||
| cc6b05e8b3 | |||
| 417747d5d0 | |||
| a34f439226 | |||
| b7ca014fd0 | |||
| fa098d585a | |||
| c35a14e3ec | |||
| 60651736a5 | |||
| 581f9b7bd3 | |||
| 124eb04807 | |||
| 1d561da7fb | |||
| 16e3cd0784 | |||
| a6d91933dc | |||
| 445c40f758 | |||
| 725a841a3b | |||
| f77c453843 | |||
| ba6718d5bc | |||
| cdb7a1b3fa | |||
| a03c79b89d | |||
| 98800d3426 | |||
| a616adaac4 | |||
| ffb5605c99 | |||
| 621b556856 | |||
| a3ffecbb2a | |||
| ea64cebe2a | |||
| e79487dd5f | |||
| 7fe1c1ec89 | |||
| ab2bbff369 | |||
| ec32825309 | |||
| fd0c182087 | |||
| 49fcff1daf | |||
| 33b64ddf39 | |||
| 4c447aa648 | |||
| ccbfc3d274 | |||
| f83fe43bbb | |||
| 19022d67f8 | |||
| 58a815dd6b | |||
| bc9fe82860 | |||
| b3cd9bf2b9 | |||
| c5c2b829ec | |||
| 9713f96401 | |||
| 11f35ebf96 | |||
| 7d403aa181 | |||
| 64af810a4a | |||
| 30821905af | |||
| a9dbff756b | |||
| a6aba10d3d | |||
| 9c276c37fe | |||
| 6ab6c0fd4c | |||
| b6b0fe3fff | |||
| 0d5825bda9 | |||
| cdfb64631a | |||
| d161c281c8 | |||
| 8fed5bf2a1 | |||
| 98d2e9bd27 | |||
| a03af55edd | |||
| 86e2fd9aee | |||
| 97bd0e5e58 | |||
| ceaba21986 | |||
| 172a77d942 | |||
| 4f9d2d2a7d | |||
| 8c929f6e05 | |||
| 3319b71f5b | |||
| 46ec028a5b | |||
| 0ce0ef3e5c | |||
| 375b071cb2 | |||
| 29e1417ff2 | |||
| 75db2bd366 | |||
| 60ca1efbda | |||
| 2692e4978b | |||
| 91982eb002 | |||
| bb1dec76fa | |||
| f618b8fcdc | |||
| 9147cab75b | |||
| 5f07bcc8e6 | |||
| 705cf2ea1b | |||
| 42c4394484 | |||
| 221221a3c1 | |||
| 9564166297 | |||
| f5cf3c3c8e | |||
| 18f919fb6b | |||
| 0924835253 | |||
| 20d2e5c578 | |||
| 907801605c | |||
| 93bc684e8c | |||
| a76c98d57e | |||
| d937a800d0 | |||
| d16f3a227f | |||
| 80c9a3eeda | |||
| e68173b451 | |||
| 40c27d87f5 | |||
| 3c13b5049d | |||
| 8288d5e51f | |||
| 6e1449900a | |||
| 4ffbb18ab4 | |||
| b27271b7a3 | |||
| ebb6665f64 | |||
| e4e5731ffd | |||
| 2ab5810f13 | |||
| af934c5d09 | |||
| 1e0cf7c112 | |||
| 46859c93c9 | |||
| ea1f9cb3b2 | |||
| 1641549016 | |||
| 716a5dbb8a | |||
| af98cb11c5 | |||
| 9a4c2cf341 | |||
| 2bc3bcd102 | |||
| d6c663f79d | |||
| 9ed86e5f53 | |||
| 303e0bc037 | |||
| 2cc24019f9 | |||
| 83ce774d19 | |||
| 2b4ee13b5e | |||
| 3a964561f0 | |||
| 6959f86632 | |||
| 537d373e10 | |||
| cceadf222c | |||
| cf5a4af623 | |||
| 39aea11c22 | |||
| c2f1227700 | |||
| 900f14d37c | |||
| 598249b1d6 | |||
| 7ed15bdf04 | |||
| 2fc0ec0f72 | |||
| 5e9c2a669b | |||
| b884fe0e86 | |||
| 855858c236 | |||
| c11a2a5419 | |||
| 773a6572af | |||
| 88ad373c9b |
@@ -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"
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM python:3.10-slim
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /AstrBot
|
||||
|
||||
COPY . /AstrBot/
|
||||
|
||||
@@ -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()
|
||||
@@ -82,23 +83,61 @@ class AstrBotConfig(dict):
|
||||
return conf
|
||||
|
||||
def check_config_integrity(self, refer_conf: Dict, conf: Dict, path=""):
|
||||
"""检查配置完整性,如果有新的配置项则返回 True"""
|
||||
"""检查配置完整性,如果有新的配置项或顺序不一致则返回 True"""
|
||||
has_new = False
|
||||
|
||||
# 创建一个新的有序字典以保持参考配置的顺序
|
||||
new_conf = {}
|
||||
|
||||
# 先按照参考配置的顺序添加配置项
|
||||
for key, value in refer_conf.items():
|
||||
if key not in conf:
|
||||
# logger.info(f"检查到配置项 {path + "." + key if path else key} 不存在,已插入默认值 {value}")
|
||||
# 配置项不存在,插入默认值
|
||||
path_ = path + "." + key if path else key
|
||||
logger.info(f"检查到配置项 {path_} 不存在,已插入默认值 {value}")
|
||||
conf[key] = value
|
||||
new_conf[key] = value
|
||||
has_new = True
|
||||
else:
|
||||
if conf[key] is None:
|
||||
conf[key] = value
|
||||
# 配置项为 None,使用默认值
|
||||
new_conf[key] = value
|
||||
has_new = True
|
||||
elif isinstance(value, dict):
|
||||
has_new |= self.check_config_integrity(
|
||||
value, conf[key], path + "." + key if path else key
|
||||
)
|
||||
# 递归检查子配置项
|
||||
if not isinstance(conf[key], dict):
|
||||
# 类型不匹配,使用默认值
|
||||
new_conf[key] = value
|
||||
has_new = True
|
||||
else:
|
||||
# 递归检查并同步顺序
|
||||
child_has_new = self.check_config_integrity(
|
||||
value, conf[key], path + "." + key if path else key
|
||||
)
|
||||
new_conf[key] = conf[key]
|
||||
has_new |= child_has_new
|
||||
else:
|
||||
# 直接使用现有配置
|
||||
new_conf[key] = conf[key]
|
||||
|
||||
# 检查是否存在参考配置中没有的配置项
|
||||
for key in list(conf.keys()):
|
||||
if key not in refer_conf:
|
||||
path_ = path + "." + key if path else key
|
||||
logger.info(f"检查到配置项 {path_} 不存在,将从当前配置中删除")
|
||||
has_new = True
|
||||
|
||||
# 顺序不一致也算作变更
|
||||
if list(conf.keys()) != list(new_conf.keys()):
|
||||
if path:
|
||||
logger.info(f"检查到配置项 {path} 的子项顺序不一致,已重新排序")
|
||||
else:
|
||||
logger.info("检查到配置项顺序不一致,已重新排序")
|
||||
has_new = True
|
||||
|
||||
# 更新原始配置
|
||||
conf.clear()
|
||||
conf.update(new_conf)
|
||||
|
||||
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.12"
|
||||
VERSION = "3.5.15"
|
||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v3.db")
|
||||
|
||||
# 默认配置
|
||||
@@ -40,12 +40,15 @@ DEFAULT_CONFIG = {
|
||||
},
|
||||
"no_permission_reply": True,
|
||||
"empty_mention_waiting": True,
|
||||
"empty_mention_waiting_need_reply": True,
|
||||
"friend_message_needs_wake_prefix": False,
|
||||
"ignore_bot_self_message": False,
|
||||
"ignore_at_all": False,
|
||||
},
|
||||
"provider": [],
|
||||
"provider_settings": {
|
||||
"enable": True,
|
||||
"default_provider_id": "",
|
||||
"wake_prefix": "",
|
||||
"web_search": False,
|
||||
"web_search_link": False,
|
||||
@@ -57,6 +60,7 @@ DEFAULT_CONFIG = {
|
||||
"dequeue_context_length": 1,
|
||||
"streaming_response": False,
|
||||
"streaming_segmented": False,
|
||||
"separate_provider": False,
|
||||
},
|
||||
"provider_stt_settings": {
|
||||
"enable": False,
|
||||
@@ -355,9 +359,14 @@ CONFIG_METADATA_2 = {
|
||||
"hint": "启用后,当用户没有权限执行某个操作时,机器人会回复一条消息。",
|
||||
},
|
||||
"empty_mention_waiting": {
|
||||
"description": "只 @ 机器人是否触发等待回复",
|
||||
"description": "只 @ 机器人是否触发等待",
|
||||
"type": "bool",
|
||||
"hint": "启用后,当消息内容只有 @ 机器人时,会触发等待回复,在 60 秒内的该用户的任意一条消息均会唤醒机器人。这在某些平台不支持 @ 和语音/图片等消息同时发送时特别有用。",
|
||||
"hint": "启用后,当消息内容只有 @ 机器人时,会触发等待,在 60 秒内的该用户的任意一条消息均会唤醒机器人。这在某些平台不支持 @ 和语音/图片等消息同时发送时特别有用。",
|
||||
},
|
||||
"empty_mention_waiting_need_reply": {
|
||||
"description": "只 @ 机器人触发等待时是否需要回复提醒",
|
||||
"type": "bool",
|
||||
"hint": "在上面一个配置项中,如果启用了触发等待,启用此项后,机器人会使用 LLM 生成一条回复。否则,将不回复而只是等待。",
|
||||
},
|
||||
"friend_message_needs_wake_prefix": {
|
||||
"description": "私聊消息是否需要唤醒前缀",
|
||||
@@ -369,6 +378,11 @@ CONFIG_METADATA_2 = {
|
||||
"type": "bool",
|
||||
"hint": "某些平台如 gewechat 会将自身账号在其他 APP 端发送的消息也当做消息事件下发导致给自己发消息时唤醒机器人",
|
||||
},
|
||||
"ignore_at_all": {
|
||||
"description": "是否忽略 @ 全体成员",
|
||||
"type": "bool",
|
||||
"hint": "启用后,机器人会忽略 @ 全体成员 的消息事件。",
|
||||
},
|
||||
"segmented_reply": {
|
||||
"description": "分段回复",
|
||||
"type": "object",
|
||||
@@ -620,6 +634,7 @@ CONFIG_METADATA_2 = {
|
||||
"gm_resp_image_modal": False,
|
||||
"gm_native_search": False,
|
||||
"gm_native_coderunner": False,
|
||||
"gm_url_context": False,
|
||||
"gm_safety_settings": {
|
||||
"harassment": "BLOCK_MEDIUM_AND_ABOVE",
|
||||
"hate_speech": "BLOCK_MEDIUM_AND_ABOVE",
|
||||
@@ -862,8 +877,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": "火山引擎集群",
|
||||
@@ -984,6 +1039,12 @@ CONFIG_METADATA_2 = {
|
||||
"hint": "启用后所有函数工具将全部失效",
|
||||
"obvious_hint": True,
|
||||
},
|
||||
"gm_url_context": {
|
||||
"description": "启用URL上下文功能",
|
||||
"type": "bool",
|
||||
"hint": "启用后所有函数工具将全部失效",
|
||||
"obvious_hint": True,
|
||||
},
|
||||
"gm_safety_settings": {
|
||||
"description": "安全过滤器",
|
||||
"type": "object",
|
||||
@@ -1339,9 +1400,19 @@ CONFIG_METADATA_2 = {
|
||||
"enable": {
|
||||
"description": "启用大语言模型聊天",
|
||||
"type": "bool",
|
||||
"hint": "如需切换大语言模型提供商,请使用 `/provider` 命令。",
|
||||
"hint": "如需切换大语言模型提供商,请使用 /provider 命令。",
|
||||
"obvious_hint": True,
|
||||
},
|
||||
"separate_provider": {
|
||||
"description": "提供商会话隔离",
|
||||
"type": "bool",
|
||||
"hint": "启用后,每个会话支持独立选择文本生成、STT、TTS 等提供商。如果会话在使用 /provider 指令时提示无权限,可以将会话加入管理员名单或者使用 /alter_cmd provider member 将指令设为非管理员指令。",
|
||||
},
|
||||
"default_provider_id": {
|
||||
"description": "默认模型提供商 ID",
|
||||
"type": "string",
|
||||
"hint": "可选。每个聊天会话的默认提供商 ID。",
|
||||
},
|
||||
"wake_prefix": {
|
||||
"description": "LLM 聊天额外唤醒前缀",
|
||||
"type": "string",
|
||||
@@ -1454,7 +1525,7 @@ CONFIG_METADATA_2 = {
|
||||
"obvious_hint": True,
|
||||
},
|
||||
"provider_id": {
|
||||
"description": "提供商 ID,不填则默认第一个STT提供商",
|
||||
"description": "提供商 ID",
|
||||
"type": "string",
|
||||
"hint": "语音转文本提供商 ID。如果不填写将使用载入的第一个提供商。",
|
||||
},
|
||||
@@ -1471,7 +1542,7 @@ CONFIG_METADATA_2 = {
|
||||
"obvious_hint": True,
|
||||
},
|
||||
"provider_id": {
|
||||
"description": "提供商 ID,不填则默认第一个TTS提供商",
|
||||
"description": "提供商 ID",
|
||||
"type": "string",
|
||||
"hint": "文本转语音提供商 ID。如果不填写将使用载入的第一个提供商。",
|
||||
},
|
||||
|
||||
@@ -11,7 +11,9 @@ class SQLiteDatabase(BaseDatabase):
|
||||
super().__init__()
|
||||
self.db_path = db_path
|
||||
|
||||
with open(os.path.dirname(__file__) + "/sqlite_init.sql", "r") as f:
|
||||
with open(
|
||||
os.path.dirname(__file__) + "/sqlite_init.sql", "r", encoding="utf-8"
|
||||
) as f:
|
||||
sql = f.read()
|
||||
|
||||
# 初始化数据库
|
||||
|
||||
@@ -29,9 +29,9 @@ class EmbeddingStorage:
|
||||
Raises:
|
||||
ValueError: 如果向量的维度与存储的维度不匹配
|
||||
"""
|
||||
if vector.shape[0] != self.dimention:
|
||||
if vector.shape[0] != self.dimension:
|
||||
raise ValueError(
|
||||
f"向量维度不匹配, 期望: {self.dimention}, 实际: {vector.shape[0]}"
|
||||
f"向量维度不匹配, 期望: {self.dimension}, 实际: {vector.shape[0]}"
|
||||
)
|
||||
self.index.add_with_ids(vector.reshape(1, -1), np.array([id]))
|
||||
self.storage[id] = vector
|
||||
|
||||
@@ -30,19 +30,13 @@ class FaissVecDB(BaseVecDB):
|
||||
async def initialize(self):
|
||||
await self.document_storage.initialize()
|
||||
|
||||
async def insert(
|
||||
self,
|
||||
content: str,
|
||||
metadata: dict = None,
|
||||
id: str = None,
|
||||
) -> int:
|
||||
async def insert(self, content: str, metadata: dict = None, id: str = None) -> int:
|
||||
"""
|
||||
插入一条文本和其对应向量,自动生成 ID 并保持一致性。
|
||||
"""
|
||||
metadata = metadata or {}
|
||||
str_id = id or str(uuid.uuid4()) # 使用 UUID 作为原始 ID
|
||||
|
||||
# 获取向量
|
||||
vector = await self.embedding_provider.get_embedding(content)
|
||||
vector = np.array(vector, dtype=np.float32)
|
||||
async with self.document_storage.connection.cursor() as cursor:
|
||||
@@ -54,9 +48,9 @@ class FaissVecDB(BaseVecDB):
|
||||
result = await self.document_storage.get_document_by_doc_id(str_id)
|
||||
int_id = result["id"]
|
||||
|
||||
# 插入向量到 FAISS
|
||||
await self.embedding_storage.insert(vector, int_id)
|
||||
return int_id
|
||||
# 插入向量到 FAISS
|
||||
await self.embedding_storage.insert(vector, int_id)
|
||||
return int_id
|
||||
|
||||
async def retrieve(
|
||||
self, query: str, k: int = 5, fetch_k: int = 20, metadata_filters: dict = None
|
||||
|
||||
@@ -43,9 +43,8 @@ class PreProcessStage(Stage):
|
||||
# STT
|
||||
if self.stt_settings.get("enable", False):
|
||||
# TODO: 独立
|
||||
stt_provider = (
|
||||
self.plugin_manager.context.provider_manager.curr_stt_provider_inst
|
||||
)
|
||||
ctx = self.plugin_manager.context
|
||||
stt_provider = ctx.get_using_stt_provider(event.unified_msg_origin)
|
||||
if not stt_provider:
|
||||
return
|
||||
message_chain = event.get_messages()
|
||||
|
||||
@@ -33,6 +33,7 @@ from mcp.types import (
|
||||
TextResourceContents,
|
||||
BlobResourceContents,
|
||||
)
|
||||
from astrbot.core import web_chat_back_queue
|
||||
|
||||
|
||||
class LLMRequestSubStage(Stage):
|
||||
@@ -70,8 +71,8 @@ class LLMRequestSubStage(Stage):
|
||||
if not self.ctx.astrbot_config["provider_settings"]["enable"]:
|
||||
logger.debug("未启用 LLM 能力,跳过处理。")
|
||||
return
|
||||
|
||||
provider = self.ctx.plugin_manager.context.get_using_provider()
|
||||
umo = event.unified_msg_origin
|
||||
provider = self.ctx.plugin_manager.context.get_using_provider(umo=umo)
|
||||
if provider is None:
|
||||
return
|
||||
|
||||
@@ -287,7 +288,66 @@ class LLMRequestSubStage(Stage):
|
||||
if img_b64 := event.get_extra("tool_call_img_respond"):
|
||||
await event.send(MessageChain(chain=[Image.fromBase64(img_b64)]))
|
||||
event.set_extra("tool_call_img_respond", None)
|
||||
yield
|
||||
|
||||
if event.get_platform_name() == "webchat":
|
||||
# 异步处理 WebChat 特殊情况
|
||||
asyncio.create_task(self._handle_webchat(event, req))
|
||||
|
||||
async def _handle_webchat(self, event: AstrMessageEvent, req: ProviderRequest):
|
||||
"""处理 WebChat 平台的特殊情况,包括第一次 LLM 对话时总结对话内容生成 title"""
|
||||
conversation = await self.conv_manager.get_conversation(
|
||||
event.unified_msg_origin, req.conversation.cid
|
||||
)
|
||||
if conversation and not req.conversation.title:
|
||||
messages = json.loads(conversation.history)
|
||||
latest_pair = messages[-2:]
|
||||
if not latest_pair:
|
||||
return
|
||||
provider = self.ctx.plugin_manager.context.get_using_provider()
|
||||
cleaned_text = "User: " + latest_pair[0].get("content", "").strip()
|
||||
# if len(latest_pair) > 1:
|
||||
# cleaned_text += (
|
||||
# "\nAssistant: " + latest_pair[1].get("content", "").strip()
|
||||
# )
|
||||
logger.debug(f"WebChat 对话标题生成请求,清理后的文本: {cleaned_text}")
|
||||
llm_resp = await provider.text_chat(
|
||||
system_prompt="You are expert in summarizing user's query.",
|
||||
prompt=(
|
||||
f"Please summarize the following query of user:\n"
|
||||
f"{cleaned_text}\n"
|
||||
"Only output the summary within 10 words, DO NOT INCLUDE any other text."
|
||||
"You must use the same language as the user."
|
||||
"If you think the dialog is too short to summarize, only output a special mark: `None`"
|
||||
),
|
||||
)
|
||||
if llm_resp and llm_resp.completion_text:
|
||||
logger.debug(
|
||||
f"WebChat 对话标题生成响应: {llm_resp.completion_text.strip()}"
|
||||
)
|
||||
title = llm_resp.completion_text.strip()
|
||||
if not title or "None" == title:
|
||||
return
|
||||
await self.conv_manager.update_conversation_title(
|
||||
event.unified_msg_origin, title=title
|
||||
)
|
||||
# 由于 WebChat 平台特殊性,其有两个对话,因此我们要更新两个对话的标题
|
||||
# webchat adapter 中,session_id 的格式是 f"webchat!{username}!{cid}"
|
||||
# TODO: 优化 WebChat 适配器的对话管理
|
||||
if event.session_id:
|
||||
username, cid = event.session_id.split("!")[1:3]
|
||||
db_helper = self.ctx.plugin_manager.context._db
|
||||
db_helper.update_conversation_title(
|
||||
user_id=username,
|
||||
cid=cid,
|
||||
title=title,
|
||||
)
|
||||
web_chat_back_queue.put_nowait(
|
||||
{
|
||||
"type": "update_title",
|
||||
"cid": cid,
|
||||
"data": title,
|
||||
}
|
||||
)
|
||||
|
||||
async def _handle_llm_response(
|
||||
self,
|
||||
|
||||
@@ -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):
|
||||
@@ -190,6 +191,7 @@ class RespondStage(Stage):
|
||||
await asyncio.sleep(i)
|
||||
try:
|
||||
await event.send(MessageChain([*decorated_comps, comp]))
|
||||
decorated_comps = [] # 清空已发送的装饰组件
|
||||
except Exception as e:
|
||||
logger.error(f"发送消息失败: {e} chain: {result.chain}")
|
||||
break
|
||||
|
||||
@@ -169,8 +169,8 @@ class ResultDecorateStage(Stage):
|
||||
result.chain = new_chain
|
||||
|
||||
# TTS
|
||||
tts_provider = (
|
||||
self.ctx.plugin_manager.context.provider_manager.curr_tts_provider_inst
|
||||
tts_provider = self.ctx.plugin_manager.context.get_using_tts_provider(
|
||||
event.unified_msg_origin
|
||||
)
|
||||
if (
|
||||
self.ctx.astrbot_config["provider_tts_settings"]["enable"]
|
||||
|
||||
@@ -4,7 +4,7 @@ from astrbot import logger
|
||||
from typing import Union, AsyncGenerator
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from astrbot.core.message.message_event_result import MessageEventResult, MessageChain
|
||||
from astrbot.core.message.components import At
|
||||
from astrbot.core.message.components import At, AtAll
|
||||
from astrbot.core.star.star_handler import star_handlers_registry, EventType
|
||||
from astrbot.core.star.star import star_map
|
||||
from astrbot.core.star.filter.permission import PermissionTypeFilter
|
||||
@@ -39,6 +39,9 @@ class WakingCheckStage(Stage):
|
||||
self.ignore_bot_self_message = self.ctx.astrbot_config["platform_settings"].get(
|
||||
"ignore_bot_self_message", False
|
||||
)
|
||||
self.ignore_at_all = self.ctx.astrbot_config["platform_settings"].get(
|
||||
"ignore_at_all", False
|
||||
)
|
||||
|
||||
async def process(
|
||||
self, event: AstrMessageEvent
|
||||
@@ -79,10 +82,9 @@ class WakingCheckStage(Stage):
|
||||
if not is_wake:
|
||||
# 检查是否有 at 消息
|
||||
for message in messages:
|
||||
if isinstance(message, At) and (
|
||||
if (isinstance(message, At) and (
|
||||
str(message.qq) == str(event.get_self_id())
|
||||
or str(message.qq) == "all"
|
||||
):
|
||||
)) or (isinstance(message, AtAll) and not self.ignore_at_all):
|
||||
is_wake = True
|
||||
event.is_wake = True
|
||||
wake_prefix = ""
|
||||
|
||||
@@ -221,6 +221,9 @@ class AiocqhttpAdapter(Platform):
|
||||
a = None
|
||||
if t == "text":
|
||||
current_text = "".join(m["data"]["text"] for m in m_group).strip()
|
||||
if not current_text:
|
||||
# 如果文本段为空,则跳过
|
||||
continue
|
||||
message_str += current_text
|
||||
a = ComponentTypes[t](text=current_text) # noqa: F405
|
||||
abm.message.append(a)
|
||||
|
||||
@@ -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
|
||||
@@ -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"):
|
||||
|
||||
@@ -18,13 +18,6 @@ class ProviderManager:
|
||||
self.persona_configs: list = config.get("persona", [])
|
||||
self.astrbot_config = config
|
||||
|
||||
self.selected_provider_id = sp.get("curr_provider")
|
||||
self.selected_stt_provider_id = self.provider_stt_settings.get("provider_id")
|
||||
self.selected_tts_provider_id = self.provider_settings.get("provider_id")
|
||||
# self.provider_enabled = self.provider_settings.get("enable", False)
|
||||
# self.stt_enabled = self.provider_stt_settings.get("enable", False)
|
||||
# self.tts_enabled = self.provider_tts_settings.get("enable", False)
|
||||
|
||||
# 人格情景管理
|
||||
# 目前没有拆成独立的模块
|
||||
self.default_persona_name = self.provider_settings.get(
|
||||
@@ -98,17 +91,18 @@ 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
|
||||
self.default_provider_inst: Provider = None
|
||||
"""默认的 Provider 实例。第 0 个或者用户以前指定的 Provider 实例"""
|
||||
|
||||
self.curr_provider_inst: Provider = None
|
||||
"""当前使用的 Provider 实例"""
|
||||
"""默认的 Provider 实例"""
|
||||
self.curr_stt_provider_inst: STTProvider = None
|
||||
"""当前使用的 Speech To Text Provider 实例"""
|
||||
"""默认的 Speech To Text Provider 实例"""
|
||||
self.curr_tts_provider_inst: TTSProvider = None
|
||||
"""当前使用的 Text To Speech Provider 实例"""
|
||||
"""默认的 Text To Speech Provider 实例"""
|
||||
self.db_helper = db_helper
|
||||
|
||||
# kdb(experimental)
|
||||
@@ -117,13 +111,57 @@ class ProviderManager:
|
||||
if kdb_cfg and len(kdb_cfg):
|
||||
self.curr_kdb_name = list(kdb_cfg.keys())[0]
|
||||
|
||||
async def set_provider(
|
||||
self, provider_id: str, provider_type: ProviderType, umo: str = None
|
||||
):
|
||||
"""设置提供商。
|
||||
|
||||
Args:
|
||||
provider_id (str): 提供商 ID。
|
||||
provider_type (ProviderType): 提供商类型。
|
||||
umo (str, optional): 用户会话 ID,用于提供商会话隔离。当用户启用了提供商会话隔离时此参数才生效。
|
||||
"""
|
||||
if provider_id not in self.inst_map:
|
||||
raise ValueError(f"提供商 {provider_id} 不存在,无法设置。")
|
||||
if umo and self.provider_settings["separate_provider"]:
|
||||
perf = sp.get("session_provider_perf", {})
|
||||
session_perf = perf.get(umo, {})
|
||||
session_perf[provider_type.value] = provider_id
|
||||
perf[umo] = session_perf
|
||||
sp.put("session_provider_perf", perf)
|
||||
return
|
||||
# 不启用提供商会话隔离模式的情况
|
||||
self.curr_provider_inst = self.inst_map[provider_id]
|
||||
if provider_type == ProviderType.TEXT_TO_SPEECH:
|
||||
sp.put("curr_provider_tts", provider_id)
|
||||
elif provider_type == ProviderType.SPEECH_TO_TEXT:
|
||||
sp.put("curr_provider_stt", provider_id)
|
||||
elif provider_type == ProviderType.CHAT_COMPLETION:
|
||||
sp.put("curr_provider", provider_id)
|
||||
|
||||
async def initialize(self):
|
||||
# 逐个初始化提供商
|
||||
for provider_config in self.providers_config:
|
||||
await self.load_provider(provider_config)
|
||||
|
||||
self.default_provider_inst = self.inst_map.get(self.selected_provider_id)
|
||||
if not self.default_provider_inst and self.provider_insts:
|
||||
self.default_provider_inst = self.provider_insts[0]
|
||||
# 设置默认提供商
|
||||
self.curr_provider_inst = self.inst_map.get(
|
||||
self.provider_settings.get("default_provider_id")
|
||||
)
|
||||
if not self.curr_provider_inst and self.provider_insts:
|
||||
self.curr_provider_inst = self.provider_insts[0]
|
||||
|
||||
self.curr_stt_provider_inst = self.inst_map.get(
|
||||
self.provider_stt_settings.get("provider_id")
|
||||
)
|
||||
if not self.curr_stt_provider_inst and self.stt_provider_insts:
|
||||
self.curr_stt_provider_inst = self.stt_provider_insts[0]
|
||||
|
||||
self.curr_tts_provider_inst = self.inst_map.get(
|
||||
self.provider_tts_settings.get("provider_id")
|
||||
)
|
||||
if not self.curr_tts_provider_inst and self.tts_provider_insts:
|
||||
self.curr_tts_provider_inst = self.tts_provider_insts[0]
|
||||
|
||||
# 初始化 MCP Client 连接
|
||||
asyncio.create_task(
|
||||
@@ -211,6 +249,14 @@ class ProviderManager:
|
||||
from .sources.volcengine_tts import (
|
||||
ProviderVolcengineTTS as ProviderVolcengineTTS,
|
||||
)
|
||||
case "openai_embedding":
|
||||
from .sources.openai_embedding_source import (
|
||||
OpenAIEmbeddingProvider as OpenAIEmbeddingProvider,
|
||||
)
|
||||
case "gemini_embedding":
|
||||
from .sources.gemini_embedding_source import (
|
||||
GeminiEmbeddingProvider as GeminiEmbeddingProvider,
|
||||
)
|
||||
except (ImportError, ModuleNotFoundError) as e:
|
||||
logger.critical(
|
||||
f"加载 {provider_config['type']}({provider_config['id']}) 提供商适配器失败:{e}。可能是因为有未安装的依赖。"
|
||||
@@ -242,7 +288,10 @@ class ProviderManager:
|
||||
await inst.initialize()
|
||||
|
||||
self.stt_provider_insts.append(inst)
|
||||
if self.selected_stt_provider_id == provider_config["id"]:
|
||||
if (
|
||||
self.provider_stt_settings.get("provider_id")
|
||||
== provider_config["id"]
|
||||
):
|
||||
self.curr_stt_provider_inst = inst
|
||||
logger.info(
|
||||
f"已选择 {provider_config['type']}({provider_config['id']}) 作为当前语音转文本提供商适配器。"
|
||||
@@ -260,7 +309,7 @@ class ProviderManager:
|
||||
await inst.initialize()
|
||||
|
||||
self.tts_provider_insts.append(inst)
|
||||
if self.selected_tts_provider_id == provider_config["id"]:
|
||||
if self.provider_settings.get("provider_id") == provider_config["id"]:
|
||||
self.curr_tts_provider_inst = inst
|
||||
logger.info(
|
||||
f"已选择 {provider_config['type']}({provider_config['id']}) 作为当前文本转语音提供商适配器。"
|
||||
@@ -282,7 +331,10 @@ class ProviderManager:
|
||||
await inst.initialize()
|
||||
|
||||
self.provider_insts.append(inst)
|
||||
if self.selected_provider_id == provider_config["id"]:
|
||||
if (
|
||||
self.provider_settings.get("default_provider_id")
|
||||
== provider_config["id"]
|
||||
):
|
||||
self.curr_provider_inst = inst
|
||||
logger.info(
|
||||
f"已选择 {provider_config['type']}({provider_config['id']}) 作为当前提供商适配器。"
|
||||
@@ -290,6 +342,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())
|
||||
@@ -312,7 +372,6 @@ class ProviderManager:
|
||||
self.curr_provider_inst = None
|
||||
elif self.curr_provider_inst is None and len(self.provider_insts) > 0:
|
||||
self.curr_provider_inst = self.provider_insts[0]
|
||||
self.selected_provider_id = self.curr_provider_inst.meta().id
|
||||
logger.info(
|
||||
f"自动选择 {self.curr_provider_inst.meta().id} 作为当前提供商适配器。"
|
||||
)
|
||||
@@ -321,7 +380,6 @@ class ProviderManager:
|
||||
self.curr_stt_provider_inst = None
|
||||
elif self.curr_stt_provider_inst is None and len(self.stt_provider_insts) > 0:
|
||||
self.curr_stt_provider_inst = self.stt_provider_insts[0]
|
||||
self.selected_stt_provider_id = self.curr_stt_provider_inst.meta().id
|
||||
logger.info(
|
||||
f"自动选择 {self.curr_stt_provider_inst.meta().id} 作为当前语音转文本提供商适配器。"
|
||||
)
|
||||
@@ -330,7 +388,6 @@ class ProviderManager:
|
||||
self.curr_tts_provider_inst = None
|
||||
elif self.curr_tts_provider_inst is None and len(self.tts_provider_insts) > 0:
|
||||
self.curr_tts_provider_inst = self.tts_provider_insts[0]
|
||||
self.selected_tts_provider_id = self.curr_tts_provider_inst.meta().id
|
||||
logger.info(
|
||||
f"自动选择 {self.curr_tts_provider_inst.meta().id} 作为当前文本转语音提供商适配器。"
|
||||
)
|
||||
|
||||
@@ -192,6 +192,11 @@ class EmbeddingProvider(AbstractProvider):
|
||||
"""获取文本的向量"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_embeddings(self, text: list[str]) -> list[list[float]]:
|
||||
"""批量获取文本的向量"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_dim(self) -> int:
|
||||
"""获取向量的维度"""
|
||||
|
||||
@@ -104,11 +104,13 @@ class ProviderAnthropic(ProviderOpenAIOfficial):
|
||||
session_id: str = None,
|
||||
image_urls: List[str] = [],
|
||||
func_tool: FuncCall = None,
|
||||
contexts=[],
|
||||
contexts=None,
|
||||
system_prompt=None,
|
||||
tool_calls_result: ToolCallsResult = None,
|
||||
**kwargs,
|
||||
) -> LLMResponse:
|
||||
if contexts is None:
|
||||
contexts = []
|
||||
if not prompt:
|
||||
prompt = "<image>"
|
||||
|
||||
|
||||
@@ -74,6 +74,8 @@ class ProviderDashscope(ProviderOpenAIOfficial):
|
||||
system_prompt: str = None,
|
||||
**kwargs,
|
||||
) -> LLMResponse:
|
||||
if contexts is None:
|
||||
contexts = []
|
||||
# 获得会话变量
|
||||
payload_vars = self.variables.copy()
|
||||
# 动态变量
|
||||
|
||||
@@ -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
|
||||
@@ -141,24 +141,66 @@ class ProviderGoogleGenAI(Provider):
|
||||
logger.warning("流式输出不支持图片模态,已自动降级为文本模态")
|
||||
modalities = ["Text"]
|
||||
|
||||
tool_list = None
|
||||
tool_list = []
|
||||
model_name = self.get_model()
|
||||
native_coderunner = self.provider_config.get("gm_native_coderunner", False)
|
||||
native_search = self.provider_config.get("gm_native_search", False)
|
||||
url_context = self.provider_config.get("gm_url_context", False)
|
||||
|
||||
if native_coderunner:
|
||||
tool_list = [types.Tool(code_execution=types.ToolCodeExecution())]
|
||||
if native_search:
|
||||
logger.warning("已启用代码执行工具,搜索工具将被忽略")
|
||||
if tools:
|
||||
logger.warning("已启用代码执行工具,函数工具将被忽略")
|
||||
elif native_search:
|
||||
tool_list = [types.Tool(google_search=types.GoogleSearch())]
|
||||
if tools:
|
||||
logger.warning("已启用搜索工具,函数工具将被忽略")
|
||||
if "gemini-2.5" in model_name:
|
||||
if native_coderunner:
|
||||
tool_list.append(types.Tool(code_execution=types.ToolCodeExecution()))
|
||||
if native_search:
|
||||
logger.warning("代码执行工具与搜索工具互斥,已忽略搜索工具")
|
||||
if url_context:
|
||||
logger.warning(
|
||||
"代码执行工具与URL上下文工具互斥,已忽略URL上下文工具"
|
||||
)
|
||||
else:
|
||||
if native_search:
|
||||
tool_list.append(types.Tool(google_search=types.GoogleSearch()))
|
||||
|
||||
if url_context:
|
||||
if hasattr(types, "UrlContext"):
|
||||
tool_list.append(types.Tool(url_context=types.UrlContext()))
|
||||
else:
|
||||
logger.warning(
|
||||
"当前 SDK 版本不支持 URL 上下文工具,已忽略该设置,请升级 google-genai 包"
|
||||
)
|
||||
|
||||
elif "gemini-2.0-lite" in model_name:
|
||||
if native_coderunner or native_search or url_context:
|
||||
logger.warning(
|
||||
"gemini-2.0-lite 不支持代码执行、搜索工具和URL上下文,将忽略这些设置"
|
||||
)
|
||||
tool_list = None
|
||||
|
||||
else:
|
||||
if native_coderunner:
|
||||
tool_list.append(types.Tool(code_execution=types.ToolCodeExecution()))
|
||||
if native_search:
|
||||
logger.warning("代码执行工具与搜索工具互斥,已忽略搜索工具")
|
||||
elif native_search:
|
||||
tool_list.append(types.Tool(google_search=types.GoogleSearch()))
|
||||
|
||||
if url_context and not native_coderunner:
|
||||
if hasattr(types, "UrlContext"):
|
||||
tool_list.append(types.Tool(url_context=types.UrlContext()))
|
||||
else:
|
||||
logger.warning(
|
||||
"当前 SDK 版本不支持 URL 上下文工具,已忽略该设置,请升级 google-genai 包"
|
||||
)
|
||||
|
||||
if not tool_list:
|
||||
tool_list = None
|
||||
|
||||
if tools and tool_list:
|
||||
logger.warning("已启用原生工具,函数工具将被忽略")
|
||||
elif tools and (func_desc := tools.get_func_desc_google_genai_style()):
|
||||
tool_list = [
|
||||
types.Tool(function_declarations=func_desc["function_declarations"])
|
||||
]
|
||||
|
||||
return types.GenerateContentConfig(
|
||||
system_instruction=system_instruction,
|
||||
temperature=temperature,
|
||||
|
||||
@@ -60,10 +60,12 @@ class LLMTunerModelLoader(Provider):
|
||||
session_id: str = None,
|
||||
image_urls: List[str] = None,
|
||||
func_tool: FuncCall = None,
|
||||
contexts: List = [],
|
||||
contexts: List = None,
|
||||
system_prompt: str = None,
|
||||
**kwargs,
|
||||
) -> LLMResponse:
|
||||
if contexts is None:
|
||||
contexts = []
|
||||
system_prompt = ""
|
||||
new_record = {"role": "user", "content": prompt}
|
||||
query_context = [*contexts, new_record]
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
from openai import AsyncOpenAI
|
||||
from ..provider import EmbeddingProvider
|
||||
from ..register import register_provider_adapter
|
||||
from ..entities import ProviderType
|
||||
|
||||
|
||||
@register_provider_adapter(
|
||||
"openai_embedding",
|
||||
"OpenAI API Embedding 提供商适配器",
|
||||
provider_type=ProviderType.EMBEDDING,
|
||||
)
|
||||
class OpenAIEmbeddingProvider(EmbeddingProvider):
|
||||
def __init__(self, provider_config: dict, provider_settings: dict) -> None:
|
||||
super().__init__(provider_config, provider_settings)
|
||||
self.provider_config = provider_config
|
||||
self.provider_settings = provider_settings
|
||||
self.client = AsyncOpenAI(
|
||||
api_key=provider_config.get("embedding_api_key"),
|
||||
base_url=provider_config.get(
|
||||
"embedding_api_base", "https://api.openai.com/v1"
|
||||
),
|
||||
timeout=int(provider_config.get("timeout", 20)),
|
||||
)
|
||||
self.model = provider_config.get("embedding_model", "text-embedding-3-small")
|
||||
self.dimension = provider_config.get("embedding_dimensions", 1536)
|
||||
|
||||
async def get_embedding(self, text: str) -> list[float]:
|
||||
"""
|
||||
获取文本的嵌入
|
||||
"""
|
||||
embedding = await self.client.embeddings.create(input=text, model=self.model)
|
||||
return embedding.data[0].embedding
|
||||
|
||||
async def get_embeddings(self, texts: list[str]) -> list[list[float]]:
|
||||
"""
|
||||
批量获取文本的嵌入
|
||||
"""
|
||||
embeddings = await self.client.embeddings.create(input=texts, model=self.model)
|
||||
return [item.embedding for item in embeddings.data]
|
||||
|
||||
def get_dim(self) -> int:
|
||||
"""获取向量的维度"""
|
||||
return self.dimension
|
||||
@@ -31,10 +31,12 @@ class ProviderZhipu(ProviderOpenAIOfficial):
|
||||
session_id: str = None,
|
||||
image_urls: List[str] = None,
|
||||
func_tool: FuncCall = None,
|
||||
contexts=[],
|
||||
contexts=None,
|
||||
system_prompt=None,
|
||||
**kwargs,
|
||||
) -> LLMResponse:
|
||||
if contexts is None:
|
||||
contexts = []
|
||||
new_record = await self.assemble_context(prompt, image_urls)
|
||||
context_query = []
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from typing import List, Union
|
||||
|
||||
from astrbot.core import sp
|
||||
from astrbot.core.provider.provider import Provider, TTSProvider, STTProvider
|
||||
from astrbot.core.provider.entities import ProviderType
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.config.astrbot_config import AstrBotConfig
|
||||
from astrbot.core.provider.func_tool_manager import FuncCall
|
||||
@@ -125,11 +126,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 类型)。"""
|
||||
@@ -143,24 +141,46 @@ class Context:
|
||||
"""获取所有用于 STT 任务的 Provider。"""
|
||||
return self.provider_manager.stt_provider_insts
|
||||
|
||||
def get_using_provider(self) -> Provider:
|
||||
def get_using_provider(self, umo: str = None) -> Provider:
|
||||
"""
|
||||
获取当前使用的用于文本生成任务的 LLM Provider(Chat_Completion 类型)。
|
||||
获取当前使用的用于文本生成任务的 LLM Provider(Chat_Completion 类型)。通过 /provider 指令切换。
|
||||
|
||||
通过 /provider 指令切换。
|
||||
Args:
|
||||
umo(str): unified_message_origin 值,如果传入并且用户启用了提供商会话隔离,则使用该会话偏好的提供商。
|
||||
"""
|
||||
if umo and self._config["provider_settings"]["separate_provider"]:
|
||||
perf = sp.get("session_provider_perf", {})
|
||||
prov_id = perf.get(umo, {}).get(ProviderType.CHAT_COMPLETION.value, None)
|
||||
if inst := self.provider_manager.inst_map.get(prov_id, None):
|
||||
return inst
|
||||
return self.provider_manager.curr_provider_inst
|
||||
|
||||
def get_using_tts_provider(self) -> TTSProvider:
|
||||
def get_using_tts_provider(self, umo: str = None) -> TTSProvider:
|
||||
"""
|
||||
获取当前使用的用于 TTS 任务的 Provider。
|
||||
|
||||
Args:
|
||||
umo(str): unified_message_origin 值,如果传入,则使用该会话偏好的提供商。
|
||||
"""
|
||||
if umo and self._config["provider_settings"]["separate_provider"]:
|
||||
perf = sp.get("session_provider_perf", {})
|
||||
prov_id = perf.get(umo, {}).get(ProviderType.TEXT_TO_SPEECH.value, None)
|
||||
if inst := self.provider_manager.inst_map.get(prov_id, None):
|
||||
return inst
|
||||
return self.provider_manager.curr_tts_provider_inst
|
||||
|
||||
def get_using_stt_provider(self) -> STTProvider:
|
||||
def get_using_stt_provider(self, umo: str = None) -> STTProvider:
|
||||
"""
|
||||
获取当前使用的用于 STT 任务的 Provider。
|
||||
|
||||
Args:
|
||||
umo(str): unified_message_origin 值,如果传入,则使用该会话偏好的提供商。
|
||||
"""
|
||||
if umo and self._config["provider_settings"]["separate_provider"]:
|
||||
perf = sp.get("session_provider_perf", {})
|
||||
prov_id = perf.get(umo, {}).get(ProviderType.SPEECH_TO_TEXT.value, None)
|
||||
if inst := self.provider_manager.inst_map.get(prov_id, None):
|
||||
return inst
|
||||
return self.provider_manager.curr_stt_provider_inst
|
||||
|
||||
def get_config(self) -> AstrBotConfig:
|
||||
@@ -301,5 +321,11 @@ class Context:
|
||||
"""
|
||||
self._register_tasks.append(task)
|
||||
|
||||
def register_web_api(self, route: str, view_handler: Awaitable, methods: list, desc: str):
|
||||
def register_web_api(
|
||||
self, route: str, view_handler: Awaitable, methods: list, desc: str
|
||||
):
|
||||
for idx, api in enumerate(self.registered_web_apis):
|
||||
if api[0] == route and methods == api[2]:
|
||||
self.registered_web_apis[idx] = (route, view_handler, methods, desc)
|
||||
return
|
||||
self.registered_web_apis.append((route, view_handler, methods, desc))
|
||||
|
||||
@@ -7,6 +7,9 @@ from astrbot.core.config import AstrBotConfig
|
||||
from .custom_filter import CustomFilter
|
||||
from ..star_handler import StarHandlerMetadata
|
||||
|
||||
class GreedyStr(str):
|
||||
"""标记指令完成其他参数接收后的所有剩余文本。"""
|
||||
pass
|
||||
|
||||
# 标准指令受到 wake_prefix 的制约。
|
||||
class CommandFilter(HandlerFilter):
|
||||
@@ -68,7 +71,22 @@ class CommandFilter(HandlerFilter):
|
||||
) -> Dict[str, Any]:
|
||||
"""将参数列表 params 根据 param_type 转换为参数字典。"""
|
||||
result = {}
|
||||
for i, (param_name, param_type_or_default_val) in enumerate(param_type.items()):
|
||||
param_items = list(param_type.items())
|
||||
for i, (param_name, param_type_or_default_val) in enumerate(param_items):
|
||||
is_greedy = param_type_or_default_val is GreedyStr
|
||||
|
||||
if is_greedy:
|
||||
# GreedyStr 必须是最后一个参数
|
||||
if i != len(param_items) - 1:
|
||||
raise ValueError(
|
||||
f"参数 '{param_name}' (GreedyStr) 必须是最后一个参数。"
|
||||
)
|
||||
|
||||
# 将剩余的所有部分合并成一个字符串
|
||||
remaining_params = params[i:]
|
||||
result[param_name] = " ".join(remaining_params)
|
||||
break
|
||||
# 没有 GreedyStr 的情况
|
||||
if i >= len(params):
|
||||
if (
|
||||
isinstance(param_type_or_default_val, Type)
|
||||
|
||||
@@ -37,6 +37,12 @@ except ImportError:
|
||||
if os.getenv("ASTRBOT_RELOAD", "0") == "1":
|
||||
logger.warning("未安装 watchfiles,无法实现插件的热重载。")
|
||||
|
||||
try:
|
||||
import nh3
|
||||
except ImportError:
|
||||
logger.warning("未安装 nh3 库,无法清理插件 README.md 中的 HTML 标签。")
|
||||
nh3 = None
|
||||
|
||||
|
||||
class PluginManager:
|
||||
def __init__(self, context: Context, config: AstrBotConfig):
|
||||
@@ -140,11 +146,13 @@ class PluginManager:
|
||||
if os.path.exists(os.path.join(path, d, "main.py")) or os.path.exists(
|
||||
os.path.join(path, d, d + ".py")
|
||||
):
|
||||
modules.append({
|
||||
"pname": d,
|
||||
"module": module_str,
|
||||
"module_path": os.path.join(path, d, module_str),
|
||||
})
|
||||
modules.append(
|
||||
{
|
||||
"pname": d,
|
||||
"module": module_str,
|
||||
"module_path": os.path.join(path, d, module_str),
|
||||
}
|
||||
)
|
||||
return modules
|
||||
|
||||
def _get_plugin_modules(self) -> List[dict]:
|
||||
@@ -158,7 +166,7 @@ class PluginManager:
|
||||
plugins.extend(_p)
|
||||
return plugins
|
||||
|
||||
def _check_plugin_dept_update(self, target_plugin: str = None):
|
||||
async def _check_plugin_dept_update(self, target_plugin: str = None):
|
||||
"""检查插件的依赖
|
||||
如果 target_plugin 为 None,则检查所有插件的依赖
|
||||
"""
|
||||
@@ -177,7 +185,7 @@ class PluginManager:
|
||||
pth = os.path.join(plugin_path, "requirements.txt")
|
||||
logger.info(f"正在安装插件 {p} 所需的依赖库: {pth}")
|
||||
try:
|
||||
pip_installer.install(requirements_path=pth)
|
||||
await pip_installer.install(requirements_path=pth)
|
||||
except Exception as e:
|
||||
logger.error(f"更新插件 {p} 的依赖失败。Code: {str(e)}")
|
||||
|
||||
@@ -399,7 +407,7 @@ class PluginManager:
|
||||
module = __import__(path, fromlist=[module_str])
|
||||
except (ModuleNotFoundError, ImportError):
|
||||
# 尝试安装依赖
|
||||
self._check_plugin_dept_update(target_plugin=root_dir_name)
|
||||
await self._check_plugin_dept_update(target_plugin=root_dir_name)
|
||||
module = __import__(path, fromlist=[module_str])
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
@@ -443,11 +451,11 @@ class PluginManager:
|
||||
metadata.repo = metadata_yaml.repo
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
metadata.config = plugin_config
|
||||
if path not in inactivated_plugins:
|
||||
# 只有没有禁用插件时才实例化插件类
|
||||
if plugin_config:
|
||||
metadata.config = plugin_config
|
||||
# metadata.config = plugin_config
|
||||
try:
|
||||
metadata.star_cls = metadata.star_cls_type(
|
||||
context=self.context, config=plugin_config
|
||||
@@ -634,16 +642,17 @@ class PluginManager:
|
||||
if not os.path.exists(readme_path):
|
||||
readme_path = os.path.join(plugin_path, "readme.md")
|
||||
|
||||
if os.path.exists(readme_path):
|
||||
if os.path.exists(readme_path) and nh3:
|
||||
try:
|
||||
with open(readme_path, "r", encoding="utf-8") as f:
|
||||
readme_content = f.read()
|
||||
cleaned_content = nh3.clean(readme_content)
|
||||
except Exception as e:
|
||||
logger.warning(f"读取插件 {dir_name} 的 README.md 文件失败: {str(e)}")
|
||||
|
||||
plugin_info = None
|
||||
if plugin:
|
||||
plugin_info = {"repo": plugin.repo, "readme": readme_content}
|
||||
plugin_info = {"repo": plugin.repo, "readme": cleaned_content}
|
||||
|
||||
return plugin_info
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -31,10 +32,6 @@ class PluginUpdator(RepoZipUpdator):
|
||||
if not repo_url:
|
||||
raise Exception(f"插件 {plugin.name} 没有指定仓库地址。")
|
||||
|
||||
if proxy:
|
||||
proxy = proxy.removesuffix("/")
|
||||
repo_url = f"{proxy}/{repo_url}"
|
||||
|
||||
plugin_path = os.path.join(self.plugin_store_path, plugin.root_dir_name)
|
||||
|
||||
logger.info(f"正在更新插件,路径: {plugin_path},仓库地址: {repo_url}")
|
||||
@@ -54,7 +51,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,5 @@
|
||||
import logging
|
||||
from pip import main as pip_main
|
||||
import asyncio
|
||||
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
@@ -9,7 +9,7 @@ class PipInstaller:
|
||||
self.pip_install_arg = pip_install_arg
|
||||
self.pypi_index_url = pypi_index_url
|
||||
|
||||
def install(
|
||||
async def install(
|
||||
self,
|
||||
package_name: str = None,
|
||||
requirements_path: str = None,
|
||||
@@ -29,12 +29,29 @@ class PipInstaller:
|
||||
args.extend(self.pip_install_arg.split())
|
||||
|
||||
logger.info(f"Pip 包管理器: pip {' '.join(args)}")
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"pip", *args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
|
||||
result_code = pip_main(args)
|
||||
assert process.stdout is not None
|
||||
async for line in process.stdout:
|
||||
logger.info(line.decode().strip())
|
||||
|
||||
# 清除 pip.main 导致的多余的 logging handlers
|
||||
for handler in logging.root.handlers[:]:
|
||||
logging.root.removeHandler(handler)
|
||||
await process.wait()
|
||||
|
||||
if result_code != 0:
|
||||
raise Exception(f"安装失败,错误码:{result_code}")
|
||||
if process.returncode != 0:
|
||||
raise Exception(f"安装失败,错误码:{process.returncode}")
|
||||
except FileNotFoundError:
|
||||
# 没有 pip
|
||||
from pip import main as pip_main
|
||||
result_code = await asyncio.to_thread(pip_main, args)
|
||||
|
||||
# 清除 pip.main 导致的多余的 logging handlers
|
||||
for handler in logging.root.handlers[:]:
|
||||
logging.root.removeHandler(handler)
|
||||
|
||||
if result_code != 0:
|
||||
raise Exception(f"安装失败,错误码:{result_code}")
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import base64
|
||||
import wave
|
||||
import os
|
||||
from io import BytesIO
|
||||
import asyncio
|
||||
import tempfile
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
|
||||
async def tencent_silk_to_wav(silk_path: str, output_path: str) -> str:
|
||||
@@ -50,3 +55,46 @@ async def wav_to_tencent_silk(wav_path: str, output_path: str) -> int:
|
||||
rate = wav.getframerate()
|
||||
duration = pilk.encode(wav_path, output_path, pcm_rate=rate, tencent=True)
|
||||
return duration
|
||||
|
||||
|
||||
async def wav_to_tencent_silk_base64(wav_path: str) -> str:
|
||||
"""
|
||||
将 WAV 文件转为 Silk,并返回 Base64 字符串。
|
||||
默认采样率为 24000,输出临时文件为 temp/output.silk。
|
||||
|
||||
参数:
|
||||
- wav_path: 输入 .wav 文件路径(需为 PCM 16bit)
|
||||
|
||||
返回:
|
||||
- Base64 编码的 Silk 字符串
|
||||
- duration: 音频时长(秒)
|
||||
"""
|
||||
try:
|
||||
import pilk
|
||||
except ImportError as e:
|
||||
raise Exception("pysilk 模块未安装,请安装 pysilk") from e
|
||||
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
with wave.open(wav_path, "rb") as wav:
|
||||
rate = wav.getframerate()
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=".silk", delete=False, dir=temp_dir
|
||||
) as tmp_file:
|
||||
silk_path = tmp_file.name
|
||||
|
||||
try:
|
||||
duration = await asyncio.to_thread(
|
||||
pilk.encode, wav_path, silk_path, pcm_rate=rate, tencent=True
|
||||
)
|
||||
|
||||
with open(silk_path, "rb") as f:
|
||||
silk_bytes = await asyncio.to_thread(f.read)
|
||||
silk_b64 = base64.b64encode(silk_bytes).decode("utf-8")
|
||||
|
||||
return silk_b64, duration # 已是秒
|
||||
finally:
|
||||
if os.path.exists(silk_path):
|
||||
os.remove(silk_path)
|
||||
|
||||
+45
-22
@@ -1,5 +1,6 @@
|
||||
import aiohttp
|
||||
import os
|
||||
import re
|
||||
import zipfile
|
||||
import shutil
|
||||
|
||||
@@ -119,28 +120,61 @@ 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:
|
||||
proxy = proxy.rstrip("/")
|
||||
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 +208,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
|
||||
@@ -21,7 +22,11 @@ class AuthRoute(Route):
|
||||
post_data = await request.json
|
||||
if post_data["username"] == username and post_data["password"] == password:
|
||||
change_pwd_hint = False
|
||||
if username == "astrbot" and password == "77b90590a8945a7d36c963981a307dc9":
|
||||
if (
|
||||
username == "astrbot"
|
||||
and password == "77b90590a8945a7d36c963981a307dc9"
|
||||
and not DEMO_MODE
|
||||
):
|
||||
change_pwd_hint = True
|
||||
logger.warning("为了保证安全,请尽快修改默认密码。")
|
||||
|
||||
@@ -37,6 +42,7 @@ class AuthRoute(Route):
|
||||
.__dict__
|
||||
)
|
||||
else:
|
||||
await asyncio.sleep(3)
|
||||
return Response().error("用户名或密码错误").__dict__
|
||||
|
||||
async def edit_account(self):
|
||||
@@ -72,7 +78,7 @@ class AuthRoute(Route):
|
||||
def generate_jwt(self, username):
|
||||
payload = {
|
||||
"username": username,
|
||||
"exp": datetime.datetime.utcnow() + datetime.timedelta(days=30),
|
||||
"exp": datetime.datetime.utcnow() + datetime.timedelta(days=7),
|
||||
}
|
||||
token = jwt.encode(payload, WEBUI_SK, algorithm="HS256")
|
||||
return token
|
||||
|
||||
@@ -26,6 +26,7 @@ class ChatRoute(Route):
|
||||
"/chat/conversations": ("GET", self.get_conversations),
|
||||
"/chat/get_conversation": ("GET", self.get_conversation),
|
||||
"/chat/delete_conversation": ("GET", self.delete_conversation),
|
||||
"/chat/rename_conversation": ("POST", self.rename_conversation),
|
||||
"/chat/get_file": ("GET", self.get_file),
|
||||
"/chat/post_image": ("POST", self.post_image),
|
||||
"/chat/post_file": ("POST", self.post_file),
|
||||
@@ -61,16 +62,25 @@ class ChatRoute(Route):
|
||||
return Response().error("Missing key: filename").__dict__
|
||||
|
||||
try:
|
||||
with open(os.path.join(self.imgs_dir, filename), "rb") as f:
|
||||
if filename.endswith(".wav"):
|
||||
file_path = os.path.join(self.imgs_dir, os.path.basename(filename))
|
||||
real_file_path = os.path.realpath(file_path)
|
||||
real_imgs_dir = os.path.realpath(self.imgs_dir)
|
||||
|
||||
if not real_file_path.startswith(real_imgs_dir):
|
||||
return Response().error("Invalid file path").__dict__
|
||||
|
||||
with open(real_file_path, "rb") as f:
|
||||
filename_ext = os.path.splitext(filename)[1].lower()
|
||||
|
||||
if filename_ext == ".wav":
|
||||
return QuartResponse(f.read(), mimetype="audio/wav")
|
||||
elif filename.split(".")[-1] in self.supported_imgs:
|
||||
elif filename_ext[1:] in self.supported_imgs:
|
||||
return QuartResponse(f.read(), mimetype="image/jpeg")
|
||||
else:
|
||||
return QuartResponse(f.read())
|
||||
|
||||
except FileNotFoundError:
|
||||
return Response().error("File not found").__dict__
|
||||
except (FileNotFoundError, OSError):
|
||||
return Response().error("File access error").__dict__
|
||||
|
||||
async def post_image(self):
|
||||
post_data = await request.files
|
||||
@@ -91,7 +101,6 @@ class ChatRoute(Route):
|
||||
|
||||
file = post_data["file"]
|
||||
filename = f"{str(uuid.uuid4())}"
|
||||
print(file)
|
||||
# 通过文件格式判断文件类型
|
||||
if file.content_type.startswith("audio"):
|
||||
filename += ".wav"
|
||||
@@ -143,7 +152,7 @@ class ChatRoute(Route):
|
||||
try:
|
||||
history = json.loads(conversation.history)
|
||||
except BaseException as e:
|
||||
print(e)
|
||||
logger.error(f"Failed to parse conversation history: {e}")
|
||||
history = []
|
||||
new_his = {"type": "user", "message": message}
|
||||
if image_url:
|
||||
@@ -197,6 +206,9 @@ class ChatRoute(Route):
|
||||
if streaming and type != "end":
|
||||
continue
|
||||
|
||||
if type == "update_title":
|
||||
continue
|
||||
|
||||
if result_text:
|
||||
conversation = self.db.get_conversation_by_user_id(
|
||||
username, cid
|
||||
@@ -204,7 +216,7 @@ class ChatRoute(Route):
|
||||
try:
|
||||
history = json.loads(conversation.history)
|
||||
except BaseException as e:
|
||||
print(e)
|
||||
logger.error(f"Failed to parse conversation history: {e}")
|
||||
history = []
|
||||
history.append({"type": "bot", "message": result_text})
|
||||
self.db.update_conversation(
|
||||
@@ -242,6 +254,18 @@ class ChatRoute(Route):
|
||||
self.db.new_conversation(username, conversation_id)
|
||||
return Response().ok(data={"conversation_id": conversation_id}).__dict__
|
||||
|
||||
async def rename_conversation(self):
|
||||
username = g.get("username", "guest")
|
||||
post_data = await request.json
|
||||
if "conversation_id" not in post_data or "title" not in post_data:
|
||||
return Response().error("Missing key: conversation_id or title").__dict__
|
||||
|
||||
conversation_id = post_data["conversation_id"]
|
||||
title = post_data["title"]
|
||||
|
||||
self.db.update_conversation_title(username, conversation_id, title=title)
|
||||
return Response().ok(message="重命名成功!").__dict__
|
||||
|
||||
async def get_conversations(self):
|
||||
username = g.get("username", "guest")
|
||||
conversations = self.db.get_conversations(username)
|
||||
|
||||
@@ -9,6 +9,7 @@ from astrbot.core.platform.register import platform_registry
|
||||
from astrbot.core.provider.register import provider_registry
|
||||
from astrbot.core.star.star import star_registry
|
||||
from astrbot.core import logger
|
||||
import asyncio
|
||||
|
||||
|
||||
def try_cast(value: str, type_: str):
|
||||
@@ -153,6 +154,7 @@ class ConfigRoute(Route):
|
||||
) -> None:
|
||||
super().__init__(context)
|
||||
self.core_lifecycle = core_lifecycle
|
||||
self.config: AstrBotConfig = core_lifecycle.astrbot_config
|
||||
self.routes = {
|
||||
"/config/get": ("GET", self.get_configs),
|
||||
"/config/astrbot/update": ("POST", self.post_astrbot_configs),
|
||||
@@ -164,9 +166,125 @@ class ConfigRoute(Route):
|
||||
"/config/provider/update": ("POST", self.post_update_provider),
|
||||
"/config/provider/delete": ("POST", self.post_delete_provider),
|
||||
"/config/llmtools": ("GET", self.get_llm_tools),
|
||||
"/config/provider/check_status": ("GET", self.check_all_providers_status),
|
||||
"/config/provider/list": ("GET", self.get_provider_config_list),
|
||||
"/config/provider/get_session_seperate": (
|
||||
"GET",
|
||||
lambda: Response()
|
||||
.ok({"enable": self.config["provider_settings"]["separate_provider"]})
|
||||
.__dict__,
|
||||
),
|
||||
"/config/provider/set_session_seperate": (
|
||||
"POST",
|
||||
self.post_session_seperate,
|
||||
),
|
||||
}
|
||||
self.register_routes()
|
||||
|
||||
async def _test_single_provider(self, provider):
|
||||
"""辅助函数:测试单个 provider 的可用性"""
|
||||
meta = provider.meta()
|
||||
provider_name = provider.provider_config.get("id", "Unknown Provider")
|
||||
logger.debug(f"Got provider meta: {meta}")
|
||||
if not provider_name and meta:
|
||||
provider_name = meta.id
|
||||
elif not provider_name:
|
||||
provider_name = "Unknown Provider"
|
||||
status_info = {
|
||||
"id": getattr(meta, "id", "Unknown ID"),
|
||||
"model": getattr(meta, "model", "Unknown Model"),
|
||||
"type": getattr(meta, "type", "Unknown Type"),
|
||||
"name": provider_name,
|
||||
"status": "unavailable", # 默认为不可用
|
||||
"error": None,
|
||||
}
|
||||
logger.debug(
|
||||
f"Attempting to check provider: {status_info['name']} (ID: {status_info['id']}, Type: {status_info['type']}, Model: {status_info['model']})"
|
||||
)
|
||||
try:
|
||||
logger.debug(f"Sending 'Ping' to provider: {status_info['name']}")
|
||||
response = await asyncio.wait_for(
|
||||
provider.text_chat(prompt="REPLY `PONG` ONLY"), timeout=45.0
|
||||
)
|
||||
logger.debug(f"Received response from {status_info['name']}: {response}")
|
||||
# 只要 text_chat 调用成功返回一个 LLMResponse 对象 (即 response 不为 None),就认为可用
|
||||
if response is not None:
|
||||
status_info["status"] = "available"
|
||||
response_text_snippet = ""
|
||||
if hasattr(response, "completion_text") and response.completion_text:
|
||||
response_text_snippet = (
|
||||
response.completion_text[:70] + "..."
|
||||
if len(response.completion_text) > 70
|
||||
else response.completion_text
|
||||
)
|
||||
elif hasattr(response, "result_chain") and response.result_chain:
|
||||
try:
|
||||
response_text_snippet = (
|
||||
response.result_chain.get_plain_text()[:70] + "..."
|
||||
if len(response.result_chain.get_plain_text()) > 70
|
||||
else response.result_chain.get_plain_text()
|
||||
)
|
||||
except Exception as _:
|
||||
pass
|
||||
logger.info(
|
||||
f"Provider {status_info['name']} (ID: {status_info['id']}) is available. Response snippet: '{response_text_snippet}'"
|
||||
)
|
||||
else:
|
||||
# 这个分支理论上不应该被走到,除非 text_chat 实现可能返回 None
|
||||
status_info["error"] = (
|
||||
"Test call returned None, but expected an LLMResponse object."
|
||||
)
|
||||
logger.warning(
|
||||
f"Provider {status_info['name']} (ID: {status_info['id']}) test call returned None."
|
||||
)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
status_info["error"] = (
|
||||
"Connection timed out after 45 seconds during test call."
|
||||
)
|
||||
logger.warning(
|
||||
f"Provider {status_info['name']} (ID: {status_info['id']}) timed out."
|
||||
)
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
status_info["error"] = error_message
|
||||
logger.warning(
|
||||
f"Provider {status_info['name']} (ID: {status_info['id']}) is unavailable. Error: {error_message}"
|
||||
)
|
||||
logger.debug(
|
||||
f"Traceback for {status_info['name']}:\n{traceback.format_exc()}"
|
||||
)
|
||||
return status_info
|
||||
|
||||
async def check_all_providers_status(self):
|
||||
"""
|
||||
API 接口: 检查所有 LLM Providers 的状态
|
||||
"""
|
||||
logger.info("API call received: /config/provider/check_status")
|
||||
try:
|
||||
all_providers: typing.List = (
|
||||
self.core_lifecycle.star_context.get_all_providers()
|
||||
)
|
||||
logger.debug(f"Found {len(all_providers)} providers to check.")
|
||||
|
||||
if not all_providers:
|
||||
logger.info("No providers found to check.")
|
||||
return Response().ok([]).__dict__
|
||||
|
||||
tasks = [self._test_single_provider(p) for p in all_providers]
|
||||
logger.debug(f"Created {len(tasks)} tasks for concurrent provider checks.")
|
||||
|
||||
results = await asyncio.gather(*tasks)
|
||||
logger.info(f"Provider status check completed. Results: {results}")
|
||||
|
||||
return Response().ok(results).__dict__
|
||||
except Exception as e:
|
||||
logger.error(f"Critical error in check_all_providers_status: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
return (
|
||||
Response().error(f"检查 Provider 状态时发生严重错误: {str(e)}").__dict__
|
||||
)
|
||||
|
||||
async def get_configs(self):
|
||||
# plugin_name 为空时返回 AstrBot 配置
|
||||
# 否则返回指定 plugin_name 的插件配置
|
||||
@@ -175,6 +293,32 @@ 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 post_session_seperate(self):
|
||||
"""设置提供商会话隔离"""
|
||||
post_config = await request.json
|
||||
enable = post_config.get("enable", None)
|
||||
if enable is None:
|
||||
return Response().error("缺少参数 enable").__dict__
|
||||
|
||||
astrbot_config = self.core_lifecycle.astrbot_config
|
||||
astrbot_config["provider_settings"]["separate_provider"] = enable
|
||||
try:
|
||||
astrbot_config.save_config()
|
||||
except Exception as e:
|
||||
return Response().error(str(e)).__dict__
|
||||
return Response().ok(None, "设置成功~").__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:
|
||||
|
||||
@@ -18,6 +18,12 @@ from astrbot.core.star.filter.regex import RegexFilter
|
||||
from astrbot.core.star.star_handler import EventType
|
||||
from astrbot.core import DEMO_MODE
|
||||
|
||||
try:
|
||||
import nh3
|
||||
except ImportError:
|
||||
logger.warning("未安装 nh3 库,无法清理插件 README.md 中的 HTML 标签。")
|
||||
nh3 = None
|
||||
|
||||
|
||||
class PluginRoute(Route):
|
||||
def __init__(
|
||||
@@ -148,9 +154,7 @@ class PluginRoute(Route):
|
||||
if handler.event_type == EventType.AdapterMessageEvent:
|
||||
# 处理平台适配器消息事件
|
||||
has_admin = False
|
||||
for (
|
||||
filter
|
||||
) in (
|
||||
for filter in (
|
||||
handler.event_filters
|
||||
): # 正常handler就只有 1~2 个 filter,因此这里时间复杂度不会太高
|
||||
if isinstance(filter, CommandFilter):
|
||||
@@ -328,6 +332,9 @@ class PluginRoute(Route):
|
||||
return Response().error(str(e)).__dict__
|
||||
|
||||
async def get_plugin_readme(self):
|
||||
if not nh3:
|
||||
return Response().error("未安装 nh3 库").__dict__
|
||||
|
||||
plugin_name = request.args.get("name")
|
||||
logger.debug(f"正在获取插件 {plugin_name} 的README文件内容")
|
||||
|
||||
@@ -363,9 +370,11 @@ class PluginRoute(Route):
|
||||
with open(readme_path, "r", encoding="utf-8") as f:
|
||||
readme_content = f.read()
|
||||
|
||||
cleaned_content = nh3.clean(readme_content)
|
||||
|
||||
return (
|
||||
Response()
|
||||
.ok({"content": readme_content}, "成功获取README内容")
|
||||
.ok({"content": cleaned_content}, "成功获取README内容")
|
||||
.__dict__
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -386,14 +395,12 @@ class PluginRoute(Route):
|
||||
platform_type = platform.get("type", "")
|
||||
platform_id = platform.get("id", "")
|
||||
|
||||
platforms.append(
|
||||
{
|
||||
"name": platform_id, # 使用type作为name,这是系统内部使用的平台名称
|
||||
"id": platform_id, # 保留id字段以便前端可以显示
|
||||
"type": platform_type,
|
||||
"display_name": f"{platform_type}({platform_id})",
|
||||
}
|
||||
)
|
||||
platforms.append({
|
||||
"name": platform_id, # 使用type作为name,这是系统内部使用的平台名称
|
||||
"id": platform_id, # 保留id字段以便前端可以显示
|
||||
"type": platform_type,
|
||||
"display_name": f"{platform_type}({platform_id})",
|
||||
})
|
||||
|
||||
adjusted_platform_enable = {}
|
||||
for platform_id, plugins in platform_enable.items():
|
||||
@@ -402,13 +409,11 @@ class PluginRoute(Route):
|
||||
# 获取所有插件,包括系统内部插件
|
||||
plugins = []
|
||||
for plugin in self.plugin_manager.context.get_all_stars():
|
||||
plugins.append(
|
||||
{
|
||||
"name": plugin.name,
|
||||
"desc": plugin.desc,
|
||||
"reserved": plugin.reserved, # 添加reserved标志
|
||||
}
|
||||
)
|
||||
plugins.append({
|
||||
"name": plugin.name,
|
||||
"desc": plugin.desc,
|
||||
"reserved": plugin.reserved, # 添加reserved标志
|
||||
})
|
||||
|
||||
logger.debug(
|
||||
f"获取插件平台配置: 原始配置={platform_enable}, 调整后={adjusted_platform_enable}"
|
||||
@@ -416,13 +421,11 @@ class PluginRoute(Route):
|
||||
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
{
|
||||
"platforms": platforms,
|
||||
"plugins": plugins,
|
||||
"platform_enable": adjusted_platform_enable,
|
||||
}
|
||||
)
|
||||
.ok({
|
||||
"platforms": platforms,
|
||||
"plugins": plugins,
|
||||
"platform_enable": adjusted_platform_enable,
|
||||
})
|
||||
.__dict__
|
||||
)
|
||||
except Exception as e:
|
||||
|
||||
@@ -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__
|
||||
|
||||
@@ -12,7 +12,10 @@ class StaticFileRoute(Route):
|
||||
"/logs",
|
||||
"/extension",
|
||||
"/dashboard/default",
|
||||
"/project-atri",
|
||||
"/alkaid",
|
||||
"/alkaid/knowledge-base",
|
||||
"/alkaid/long-term-memory",
|
||||
"/alkaid/other",
|
||||
"/console",
|
||||
"/chat",
|
||||
"/settings",
|
||||
|
||||
@@ -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()}")
|
||||
|
||||
@@ -70,13 +70,13 @@ class AstrBotDashboard:
|
||||
for api in registered_web_apis:
|
||||
route, view_handler, methods, _ = api
|
||||
if route == f"/{subpath}" and request.method in methods:
|
||||
return await view_handler(*args, **kwargs)
|
||||
return await view_handler(*args, **kwargs)
|
||||
return jsonify(Response().error("未找到该路由").__dict__)
|
||||
|
||||
async def auth_middleware(self):
|
||||
if not request.path.startswith("/api"):
|
||||
return
|
||||
allowed_endpoints = ["/api/auth/login", "/api/chat/get_file", "/api/file"]
|
||||
allowed_endpoints = ["/api/auth/login", "/api/file"]
|
||||
if any(request.path.startswith(prefix) for prefix in allowed_endpoints):
|
||||
return
|
||||
# claim jwt
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# What's Changed
|
||||
|
||||
1. 新增:WebUI 支持暗夜模式。
|
||||
2. 修复:修复 WebUI Chat 接口的未授权访问安全漏洞、插件 README 可能存在的 XSS 注入漏洞。
|
||||
3. 优化:优化 Vec DB 在 indexing 过程时的数据库事务处理。
|
||||
4. 修复:WebUI 下,插件市场的推荐卡片无法点击帮助文档的问题。
|
||||
5. 新增:知识库。
|
||||
6. 新增:WebUI 提供商测试功能,一键检测可用性。
|
||||
7. 新增:WebUI 提供商分类功能,按能力分类提供商。
|
||||
@@ -0,0 +1,11 @@
|
||||
# What's Changed
|
||||
|
||||
1. 优化:强化了 WebUI 安全性
|
||||
2. 修复:测试文本生成提供商时可能出现的误报
|
||||
3. 修复:刷新知识库页面时出现404
|
||||
4. 新增:WeChatPadPro 支持获取引用、语音收发、视频等消息段
|
||||
5. 优化:WebUI 账户修改页面的设计逻辑
|
||||
6. 优化:插件更新后自动刷新插件列表
|
||||
7. 新增:支持下载插件的指定分支
|
||||
8. 修复:WeChatPadPro 群聊模式下 @ 不回复等问题
|
||||
9. 其他更新、优化及修复
|
||||
@@ -0,0 +1,13 @@
|
||||
# What's Changed
|
||||
|
||||
1. 修复:如果设置了 GitHub 加速地址,更新插件会报错
|
||||
2. 修复:部分场景下,`只@触发等待` 配置项功能无效的问题
|
||||
3. 新增:增加 `只@触发等待时是否回复` 配置项
|
||||
4. 新增:**支持模型提供商使用时会话隔离(需要手动开启配置项:提供商会话隔离)**
|
||||
5. 新增:Google Gemini 提供商支持 URL 上下文功能
|
||||
6. 新增:优化 WebChat 的 UI 显示,WebChat 支持修改标题和自动生成标题,支持 WebChatBox
|
||||
7. 新增:支持可配置是否忽略 @ 全体成员
|
||||
8. 优化:WebUI 顶栏移动端显示
|
||||
9. 优化:插件/AstrBot 配置项完整性检查的同时也保证**配置项相对顺序一致性**
|
||||
10. 优化:perf: 分段回复时,仅在输出的第一句话带上回复/引用
|
||||
11. 修复: Windows 下部署项目时可能出现的 UnicodeDecodeError。
|
||||
@@ -340,12 +340,12 @@ export default {
|
||||
.config-title {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
color: var(--v-primary-darken1);
|
||||
color: var(--v-theme-primaryText);
|
||||
}
|
||||
|
||||
.config-hint {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
color: var(--v-theme-secondaryText);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
@@ -400,12 +400,12 @@ export default {
|
||||
.property-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.87);
|
||||
color: var(--v-theme-primaryText);
|
||||
}
|
||||
|
||||
.property-hint {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
color: var(--v-theme-secondaryText);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useCommonStore } from '@/stores/common';
|
||||
<template>
|
||||
<div>
|
||||
<!-- 添加筛选级别控件 -->
|
||||
<div class="filter-controls mb-2">
|
||||
<div class="filter-controls mb-2" v-if="showLevelBtns">
|
||||
<v-chip-group v-model="selectedLevels" column multiple>
|
||||
<v-chip v-for="level in logLevels" :key="level" :color="getLevelColor(level)" filter
|
||||
:text-color="level === 'DEBUG' || level === 'INFO' ? 'black' : 'white'">
|
||||
@@ -52,6 +52,10 @@ export default {
|
||||
historyNum: {
|
||||
type: String,
|
||||
default: -1
|
||||
},
|
||||
showLevelBtns: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, inject } from 'vue';
|
||||
import {useCustomizerStore} from "@/stores/customizer";
|
||||
|
||||
const props = defineProps({
|
||||
extension: {
|
||||
@@ -75,7 +76,9 @@ const viewReadme = () => {
|
||||
|
||||
<template>
|
||||
<v-card class="mx-auto d-flex flex-column" :elevation="highlight ? 0 : 1"
|
||||
:style="{ height: $vuetify.display.xs ? '250px' : '220px', backgroundColor: highlight ? '#FAF0DB' : '#ffffff', color: highlight ? '#000' : '#000000' }">
|
||||
:style="{ height: $vuetify.display.xs ? '250px' : '220px',
|
||||
backgroundColor: useCustomizerStore().uiTheme==='PurpleTheme' ? marketMode ? '#f8f0dd' : '#ffffff' : '#282833',
|
||||
color: useCustomizerStore().uiTheme==='PurpleTheme' ? '#000000dd' : '#ffffff'}">
|
||||
<v-card-text style="padding: 16px; padding-bottom: 0px; display: flex; justify-content: space-between;">
|
||||
|
||||
<div class="flex-grow-1">
|
||||
@@ -128,7 +131,7 @@ const viewReadme = () => {
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions style="padding: 0px; margin-top: auto;">
|
||||
<v-card-actions style="margin-left: 0px; gap: 2px;">
|
||||
<v-btn color="teal-accent-4" text="查看文档" variant="text" @click="viewReadme"></v-btn>
|
||||
<v-btn v-if="!marketMode" color="teal-accent-4" text="操作" variant="text" @click="reveal = true"></v-btn>
|
||||
<v-btn v-if="marketMode && !extension?.installed" color="teal-accent-4" text="安装" variant="text"
|
||||
|
||||
@@ -104,11 +104,11 @@ export default {
|
||||
|
||||
<style scoped>
|
||||
.list-config-item {
|
||||
border: 1px solid #e0e0e0;
|
||||
border: 1px solid var(--v-theme-border);
|
||||
padding: 16px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 10px;
|
||||
background-color: #ffffff;
|
||||
background-color: var(--v-theme-background);
|
||||
}
|
||||
|
||||
.v-list-item {
|
||||
|
||||
@@ -5,15 +5,25 @@
|
||||
<img width="110" src="@/assets/images/astrbot_logo_mini.webp" alt="AstrBot Logo">
|
||||
</div>
|
||||
<div class="logo-text">
|
||||
<h2 class="text-secondary">AstrBot 仪表盘</h2>
|
||||
<h4 class="text-disabled">登录以继续</h4>
|
||||
<h2 class="text-secondary">{{ title }}</h2>
|
||||
<!-- 父子组件传递css变量可能会出错,暂时使用十六进制颜色值 -->
|
||||
<h4 :style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#000000aa' : '#ffffffcc'}"
|
||||
class="hint-text">{{ subtitle }}</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// No props or other logic needed for this simple component
|
||||
import { useCustomizerStore } from "@/stores/customizer";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}>(), {
|
||||
title: 'AstrBot 仪表盘',
|
||||
subtitle: '欢迎使用'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -56,7 +66,6 @@
|
||||
margin: 0;
|
||||
font-size: 1.8rem;
|
||||
font-weight: 600;
|
||||
color: #5e35b1;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
@@ -64,7 +73,6 @@
|
||||
margin: 4px 0 0 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
color: #616161;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,14 +3,25 @@ export type ConfigProps = {
|
||||
Customizer_drawer: boolean;
|
||||
mini_sidebar: boolean;
|
||||
fontTheme: string;
|
||||
uiTheme: string;
|
||||
inputBg: boolean;
|
||||
};
|
||||
|
||||
function checkUITheme() {
|
||||
/* 检查localStorage有无记忆的主题选项,如有则使用,否则使用默认值 */
|
||||
const theme = localStorage.getItem("uiTheme");
|
||||
if (!theme || !(['PurpleTheme', 'PurpleThemeDark'].includes(theme))) {
|
||||
localStorage.setItem("uiTheme", "PurpleTheme"); // todo: 这部分可以根据vuetify.ts的默认主题动态调整
|
||||
return 'PurpleTheme';
|
||||
} else return theme;
|
||||
}
|
||||
|
||||
const config: ConfigProps = {
|
||||
Sidebar_drawer: true,
|
||||
Customizer_drawer: false,
|
||||
mini_sidebar: false,
|
||||
fontTheme: 'Roboto',
|
||||
uiTheme: checkUITheme(),
|
||||
inputBg: false
|
||||
};
|
||||
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
import { RouterView } from 'vue-router';
|
||||
import VerticalSidebarVue from './vertical-sidebar/VerticalSidebar.vue';
|
||||
import VerticalHeaderVue from './vertical-header/VerticalHeader.vue';
|
||||
import { useCustomizerStore } from '../../stores/customizer';
|
||||
import { useCustomizerStore } from '@/stores/customizer';
|
||||
const customizer = useCustomizerStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-locale-provider>
|
||||
<v-app
|
||||
theme="PurpleTheme"
|
||||
<v-app :theme="useCustomizerStore().uiTheme"
|
||||
:class="[customizer.fontTheme, customizer.mini_sidebar ? 'mini-sidebar' : '', customizer.inputBg ? 'inputWithbg' : '']"
|
||||
>
|
||||
<VerticalHeaderVue />
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useCustomizerStore } from '../../../stores/customizer';
|
||||
import {ref, computed} from 'vue';
|
||||
import {useCustomizerStore} from '@/stores/customizer';
|
||||
import axios from 'axios';
|
||||
import { md5 } from 'js-md5';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useCommonStore } from '@/stores/common';
|
||||
import { marked } from 'marked';
|
||||
import Logo from '@/components/shared/Logo.vue';
|
||||
import {md5} from 'js-md5';
|
||||
import {useAuthStore} from '@/stores/auth';
|
||||
import {useCommonStore} from '@/stores/common';
|
||||
import {marked} from 'marked';
|
||||
|
||||
const customizer = useCustomizerStore();
|
||||
let dialog = ref(false);
|
||||
let accountWarning = ref(false)
|
||||
let updateStatusDialog = ref(false);
|
||||
const username = localStorage.getItem('user');
|
||||
let password = ref('');
|
||||
let newPassword = ref('');
|
||||
let newUsername = ref('');
|
||||
@@ -23,26 +25,52 @@ let dashboardHasNewVersion = ref(false);
|
||||
let dashboardCurrentVersion = ref('');
|
||||
let version = ref('');
|
||||
let releases = ref([]);
|
||||
let devCommits = ref([]); // 新增的 ref
|
||||
let devCommits = ref([]);
|
||||
|
||||
let installLoading = ref(false);
|
||||
|
||||
let tab = ref(0);
|
||||
|
||||
let releasesHeader = [
|
||||
{ title: '标签', key: 'tag_name' },
|
||||
{ title: '发布时间', key: 'published_at' },
|
||||
{ title: '内容', key: 'body' },
|
||||
{ title: '源码地址', key: 'zipball_url' },
|
||||
{ title: '操作', key: 'switch' }
|
||||
{title: '标签', key: 'tag_name'},
|
||||
{title: '发布时间', key: 'published_at'},
|
||||
{title: '内容', key: 'body'},
|
||||
{title: '源码地址', key: 'zipball_url'},
|
||||
{title: '操作', key: 'switch'}
|
||||
];
|
||||
|
||||
// Form validation
|
||||
const formValid = ref(true);
|
||||
const passwordRules = [
|
||||
(v: string) => !!v || '请输入密码',
|
||||
(v: string) => v.length >= 8 || '密码长度至少 8 位'
|
||||
];
|
||||
const usernameRules = [
|
||||
(v: string) => !v || v.length >= 3 || '用户名长度至少3位'
|
||||
];
|
||||
|
||||
// 显示密码相关
|
||||
const showPassword = ref(false);
|
||||
const showNewPassword = ref(false);
|
||||
|
||||
// 账户修改状态
|
||||
const accountEditStatus = ref({
|
||||
loading: false,
|
||||
success: false,
|
||||
error: false,
|
||||
message: ''
|
||||
});
|
||||
|
||||
const open = (link: string) => {
|
||||
window.open(link, '_blank');
|
||||
};
|
||||
|
||||
// 账户修改
|
||||
function accountEdit() {
|
||||
accountEditStatus.value.loading = true;
|
||||
accountEditStatus.value.error = false;
|
||||
accountEditStatus.value.success = false;
|
||||
|
||||
// md5加密
|
||||
// @ts-ignore
|
||||
if (password.value != '') {
|
||||
@@ -54,80 +82,92 @@ function accountEdit() {
|
||||
axios.post('/api/auth/account/edit', {
|
||||
password: password.value,
|
||||
new_password: newPassword.value,
|
||||
new_username: newUsername.value
|
||||
new_username: newUsername.value ? newUsername.value : username
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.data.status == 'error') {
|
||||
status.value = res.data.message;
|
||||
.then((res) => {
|
||||
if (res.data.status == 'error') {
|
||||
accountEditStatus.value.error = true;
|
||||
accountEditStatus.value.message = res.data.message;
|
||||
password.value = '';
|
||||
newPassword.value = '';
|
||||
return;
|
||||
}
|
||||
accountEditStatus.value.success = true;
|
||||
accountEditStatus.value.message = res.data.message;
|
||||
setTimeout(() => {
|
||||
dialog.value = !dialog.value;
|
||||
const authStore = useAuthStore();
|
||||
authStore.logout();
|
||||
}, 2000);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
accountEditStatus.value.error = true;
|
||||
accountEditStatus.value.message = typeof err === 'string' ? err : '修改失败,请重试';
|
||||
password.value = '';
|
||||
newPassword.value = '';
|
||||
return;
|
||||
}
|
||||
dialog.value = !dialog.value;
|
||||
status.value = res.data.message;
|
||||
setTimeout(() => {
|
||||
const authStore = useAuthStore();
|
||||
authStore.logout();
|
||||
}, 1000);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
status.value = err
|
||||
password.value = '';
|
||||
newPassword.value = '';
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
accountEditStatus.value.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
function getVersion() {
|
||||
axios.get('/api/stat/version')
|
||||
.then((res) => {
|
||||
botCurrVersion.value = "v" + res.data.data.version;
|
||||
dashboardCurrentVersion.value = res.data.data?.dashboard_version;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
.then((res) => {
|
||||
botCurrVersion.value = "v" + res.data.data.version;
|
||||
dashboardCurrentVersion.value = res.data.data?.dashboard_version;
|
||||
let change_pwd_hint = res.data.data?.change_pwd_hint;
|
||||
if (change_pwd_hint) {
|
||||
dialog.value = true;
|
||||
accountWarning.value = true;
|
||||
localStorage.setItem('change_pwd_hint', 'true');
|
||||
} else {
|
||||
localStorage.removeItem('change_pwd_hint');
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
}
|
||||
|
||||
function checkUpdate() {
|
||||
updateStatus.value = '正在检查更新...';
|
||||
axios.get('/api/update/check')
|
||||
.then((res) => {
|
||||
hasNewVersion.value = res.data.data.has_new_version;
|
||||
.then((res) => {
|
||||
hasNewVersion.value = res.data.data.has_new_version;
|
||||
|
||||
if (res.data.data.has_new_version) {
|
||||
releaseMessage.value = res.data.message;
|
||||
updateStatus.value = '有新版本!';
|
||||
} else {
|
||||
updateStatus.value = res.data.message;
|
||||
}
|
||||
dashboardHasNewVersion.value = res.data.data.dashboard_has_new_version;
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.response.status == 401) {
|
||||
console.log("401");
|
||||
const authStore = useAuthStore();
|
||||
authStore.logout();
|
||||
return;
|
||||
}
|
||||
console.log(err);
|
||||
updateStatus.value = err
|
||||
});
|
||||
if (res.data.data.has_new_version) {
|
||||
releaseMessage.value = res.data.message;
|
||||
updateStatus.value = '有新版本!';
|
||||
} else {
|
||||
updateStatus.value = res.data.message;
|
||||
}
|
||||
dashboardHasNewVersion.value = res.data.data.dashboard_has_new_version;
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.response.status == 401) {
|
||||
console.log("401");
|
||||
const authStore = useAuthStore();
|
||||
authStore.logout();
|
||||
return;
|
||||
}
|
||||
console.log(err);
|
||||
updateStatus.value = err
|
||||
});
|
||||
}
|
||||
|
||||
function getReleases() {
|
||||
axios.get('/api/update/releases')
|
||||
.then((res) => {
|
||||
// releases.value = res.data.data;
|
||||
// 更新 published_at 的时间为本地时间
|
||||
releases.value = res.data.data.map((item: any) => {
|
||||
item.published_at = new Date(item.published_at).toLocaleString();
|
||||
return item;
|
||||
.then((res) => {
|
||||
releases.value = res.data.data.map((item: any) => {
|
||||
item.published_at = new Date(item.published_at).toLocaleString();
|
||||
return item;
|
||||
})
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
}
|
||||
|
||||
function getDevCommits() {
|
||||
@@ -137,17 +177,17 @@ function getDevCommits() {
|
||||
'Referer': 'https://api.github.com'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
devCommits.value = data.map((commit: any) => ({
|
||||
sha: commit.sha,
|
||||
date: new Date(commit.commit.author.date).toLocaleString(),
|
||||
message: commit.commit.message
|
||||
}));
|
||||
})
|
||||
.catch(err => {
|
||||
console.log(err);
|
||||
});
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
devCommits.value = data.map((commit: any) => ({
|
||||
sha: commit.sha,
|
||||
date: new Date(commit.commit.author.date).toLocaleString(),
|
||||
message: commit.commit.message
|
||||
}));
|
||||
})
|
||||
.catch(err => {
|
||||
console.log(err);
|
||||
});
|
||||
}
|
||||
|
||||
function switchVersion(version: string) {
|
||||
@@ -157,37 +197,41 @@ function switchVersion(version: string) {
|
||||
version: version,
|
||||
proxy: localStorage.getItem('selectedGitHubProxy') || ''
|
||||
})
|
||||
.then((res) => {
|
||||
updateStatus.value = res.data.message;
|
||||
if (res.data.status == 'ok') {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
updateStatus.value = err
|
||||
}).finally(() => {
|
||||
installLoading.value = false;
|
||||
});
|
||||
.then((res) => {
|
||||
updateStatus.value = res.data.message;
|
||||
if (res.data.status == 'ok') {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
updateStatus.value = err
|
||||
}).finally(() => {
|
||||
installLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function updateDashboard() {
|
||||
updateStatus.value = '正在更新...';
|
||||
axios.post('/api/update/dashboard')
|
||||
.then((res) => {
|
||||
updateStatus.value = res.data.message;
|
||||
if (res.data.status == 'ok') {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
updateStatus.value = err
|
||||
});
|
||||
.then((res) => {
|
||||
updateStatus.value = res.data.message;
|
||||
if (res.data.status == 'ok') {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
updateStatus.value = err
|
||||
});
|
||||
}
|
||||
|
||||
function toggleDarkMode() {
|
||||
customizer.SET_UI_THEME(customizer.uiTheme === 'PurpleThemeDark' ? 'PurpleTheme' : 'PurpleThemeDark');
|
||||
}
|
||||
|
||||
getVersion();
|
||||
@@ -197,36 +241,37 @@ const commonStore = useCommonStore();
|
||||
commonStore.createEventSource(); // log
|
||||
commonStore.getStartTime();
|
||||
|
||||
|
||||
if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('change_pwd_hint') == 'true') {
|
||||
dialog.value = true;
|
||||
accountWarning.value = true;
|
||||
localStorage.removeItem('change_pwd_hint');
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-app-bar elevation="0" height="55">
|
||||
|
||||
<v-btn style="margin-left: 22px;" class="hidden-md-and-down text-secondary" color="lightsecondary" icon rounded="sm"
|
||||
variant="flat" @click.stop="customizer.SET_MINI_SIDEBAR(!customizer.mini_sidebar)" size="small">
|
||||
<v-btn v-if="useCustomizerStore().uiTheme==='PurpleTheme'" style="margin-left: 22px;" class="hidden-md-and-down text-secondary" color="lightsecondary" icon rounded="sm"
|
||||
variant="flat" @click.stop="customizer.SET_MINI_SIDEBAR(!customizer.mini_sidebar)" size="small">
|
||||
<v-icon>mdi-menu</v-icon>
|
||||
</v-btn>
|
||||
<v-btn class="hidden-lg-and-up text-secondary ms-3" color="lightsecondary" icon rounded="sm" variant="flat"
|
||||
@click.stop="customizer.SET_SIDEBAR_DRAWER" size="small">
|
||||
<v-btn v-else style="margin-left: 22px; color: var(--v-theme-primaryText); background-color: var(--v-theme-secondary)" class="hidden-md-and-down" icon rounded="sm"
|
||||
variant="flat" @click.stop="customizer.SET_MINI_SIDEBAR(!customizer.mini_sidebar)" size="small">
|
||||
<v-icon>mdi-menu</v-icon>
|
||||
</v-btn>
|
||||
<v-btn v-if="useCustomizerStore().uiTheme==='PurpleTheme'" class="hidden-lg-and-up ms-3" color="lightsecondary" icon rounded="sm" variant="flat"
|
||||
@click.stop="customizer.SET_SIDEBAR_DRAWER" size="small">
|
||||
<v-icon>mdi-menu</v-icon>
|
||||
</v-btn>
|
||||
<v-btn v-else class="hidden-lg-and-up ms-3" icon rounded="sm" variant="flat"
|
||||
@click.stop="customizer.SET_SIDEBAR_DRAWER" size="small">
|
||||
<v-icon>mdi-menu</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<div style="margin-left: 16px; display: flex; align-items: center; gap: 8px;">
|
||||
<span style=" font-size: 24px; font-weight: 1000;">Astr<span style="font-weight: normal;">Bot</span>
|
||||
</span>
|
||||
<span style="font-size: 12px; color: #333333;">{{ botCurrVersion }}</span>
|
||||
<div class="logo-container" :class="{'mobile-logo': $vuetify.display.xs}">
|
||||
<span class="logo-text">Astr<span class="logo-text-light">Bot</span></span>
|
||||
<span class="version-text hidden-xs">{{ botCurrVersion }}</span>
|
||||
</div>
|
||||
|
||||
<v-spacer />
|
||||
<v-spacer/>
|
||||
|
||||
<div class="mr-4">
|
||||
<!-- 版本提示信息 - 在手机上隐藏 -->
|
||||
<div class="mr-4 hidden-xs">
|
||||
<small v-if="hasNewVersion">
|
||||
AstrBot 有新版本!
|
||||
</small>
|
||||
@@ -235,17 +280,28 @@ if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('cha
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- 主题切换按钮 -->
|
||||
<v-btn size="small" @click="toggleDarkMode();" class="action-btn"
|
||||
color="var(--v-theme-surface)" variant="flat" rounded="sm">
|
||||
<v-icon v-if="useCustomizerStore().uiTheme === 'PurpleThemeDark'">mdi-weather-night</v-icon>
|
||||
<v-icon v-else>mdi-white-balance-sunny</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<v-dialog v-model="updateStatusDialog" width="1000">
|
||||
<!-- 更新对话框 -->
|
||||
<v-dialog v-model="updateStatusDialog" :width="$vuetify.display.smAndDown ? '100%' : '1000'" :fullscreen="$vuetify.display.xs">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn size="small" @click="checkUpdate(); getReleases(); getDevCommits();" class="text-primary mr-2" color="lightprimary"
|
||||
variant="flat" rounded="sm" v-bind="props">
|
||||
更新
|
||||
<v-btn size="small" @click="checkUpdate(); getReleases(); getDevCommits();" class="action-btn"
|
||||
color="var(--v-theme-surface)" variant="flat" rounded="sm" v-bind="props">
|
||||
<v-icon class="hidden-sm-and-up">mdi-update</v-icon>
|
||||
<span class="hidden-xs">更新</span>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
<v-card-title class="mobile-card-title">
|
||||
<span class="text-h5">更新 AstrBot</span>
|
||||
<v-btn v-if="$vuetify.display.xs" icon @click="updateStatusDialog = false">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-container>
|
||||
@@ -256,16 +312,16 @@ if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('cha
|
||||
<small style="margin-left: 4px;">{{ updateStatus }}</small>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style="background-color: #646cff24; padding: 16px; border-radius: 10px; font-size: 14px; max-height: 400px; overflow-y: auto;"
|
||||
v-html="marked(releaseMessage)" class="markdown-content">
|
||||
|
||||
<div v-if="releaseMessage"
|
||||
style="background-color: #646cff24; padding: 16px; border-radius: 10px; font-size: 14px; max-height: 400px; overflow-y: auto;"
|
||||
v-html="marked(releaseMessage)" class="markdown-content">
|
||||
</div>
|
||||
|
||||
<div class="mb-4 mt-4">
|
||||
<small>💡 TIP: 跳到旧版本或者切换到某个版本不会重新下载管理面板文件,这可能会造成部分数据显示错误。您可在 <a
|
||||
href="https://github.com/Soulter/AstrBot/releases">此处</a>
|
||||
找到对应的面板文件 dist.zip,解压后替换 data/dist 文件夹即可。当然,前端源代码在 dashboard 目录下,你也可以自己使用 npm install 和 npm build
|
||||
找到对应的面板文件 dist.zip,解压后替换 data/dist 文件夹即可。当然,前端源代码在 dashboard 目录下,你也可以自己使用
|
||||
npm install 和 npm build
|
||||
构建。</small>
|
||||
</div>
|
||||
|
||||
@@ -278,12 +334,13 @@ if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('cha
|
||||
<!-- 发行版 -->
|
||||
<v-tabs-window-item key="0" v-show="tab == 0">
|
||||
<v-btn class="mt-4 mb-4" @click="switchVersion('latest')" color="primary" style="border-radius: 10px;"
|
||||
:disabled="!hasNewVersion">
|
||||
:disabled="!hasNewVersion">
|
||||
更新到最新版本
|
||||
</v-btn>
|
||||
<div class="mb-4">
|
||||
<small>`更新到最新版本` 按钮会同时尝试更新机器人主程序和管理面板。如果您正在使用 Docker 部署,也可以重新拉取镜像或者使用 <a
|
||||
href="https://containrrr.dev/watchtower/usage-overview/">watchtower</a> 来自动监控拉取。</small>
|
||||
<small>`更新到最新版本` 按钮会同时尝试更新机器人主程序和管理面板。如果您正在使用 Docker
|
||||
部署,也可以重新拉取镜像或者使用 <a
|
||||
href="https://containrrr.dev/watchtower/usage-overview/">watchtower</a> 来自动监控拉取。</small>
|
||||
</div>
|
||||
|
||||
<v-data-table :headers="releasesHeader" :items="releases" item-key="name">
|
||||
@@ -306,8 +363,8 @@ if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('cha
|
||||
<v-tabs-window-item key="1" v-show="tab == 1">
|
||||
<div style="margin-top: 16px;">
|
||||
<v-data-table
|
||||
:headers="[{ title: 'SHA', key: 'sha' }, { title: '日期', key: 'date' }, { title: '信息', key: 'message' }, { title: '操作', key: 'switch' }]"
|
||||
:items="devCommits" item-key="sha">
|
||||
:headers="[{ title: 'SHA', key: 'sha' }, { title: '日期', key: 'date' }, { title: '信息', key: 'message' }, { title: '操作', key: 'switch' }]"
|
||||
:items="devCommits" item-key="sha">
|
||||
<template v-slot:item.switch="{ item }: { item: { sha: string } }">
|
||||
<v-btn @click="switchVersion(item.sha)" rounded="xl" variant="plain" color="primary">
|
||||
切换
|
||||
@@ -322,12 +379,13 @@ if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('cha
|
||||
<h3 class="mb-4">手动输入版本号或 Commit SHA</h3>
|
||||
|
||||
<v-text-field label="输入版本号或 master 分支下的 commit hash。" v-model="version" required
|
||||
variant="outlined"></v-text-field>
|
||||
variant="outlined"></v-text-field>
|
||||
<div class="mb-4">
|
||||
<small>如 v3.3.16 (不带 SHA) 或 42e5ec5d80b93b6bfe8b566754d45ffac4c3fe0b</small>
|
||||
<br>
|
||||
<a href="https://github.com/Soulter/AstrBot/commits/master"><small>查看 master 分支提交记录(点击右边的 copy
|
||||
即可复制)</small></a>
|
||||
<a href="https://github.com/Soulter/AstrBot/commits/master"><small>查看 master 分支提交记录(点击右边的
|
||||
copy
|
||||
即可复制)</small></a>
|
||||
</div>
|
||||
<v-btn color="error" style="border-radius: 10px;" @click="switchVersion(version)">
|
||||
确定切换
|
||||
@@ -352,7 +410,7 @@ if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('cha
|
||||
</div>
|
||||
|
||||
<v-btn color="primary" style="border-radius: 10px;" @click="updateDashboard()"
|
||||
:disabled="!dashboardHasNewVersion">
|
||||
:disabled="!dashboardHasNewVersion">
|
||||
下载并更新
|
||||
</v-btn>
|
||||
</div>
|
||||
@@ -367,46 +425,119 @@ 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="$vuetify.display.xs ? '90%' : '500'">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn size="small" class="text-primary mr-4" color="lightprimary" variant="flat" rounded="sm" v-bind="props">
|
||||
账户
|
||||
<v-btn size="small" class="action-btn mr-4" color="var(--v-theme-surface)" variant="flat" rounded="sm" v-bind="props">
|
||||
<v-icon>mdi-account</v-icon>
|
||||
<span class="hidden-xs ml-1">账户</span>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
<span class="text-h5">账户</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-container>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-card class="account-dialog">
|
||||
<v-card-text class="py-6">
|
||||
<div class="d-flex flex-column align-center mb-6">
|
||||
<logo title="AstrBot 仪表盘" subtitle="修改账户"></logo>
|
||||
</div>
|
||||
<v-alert
|
||||
v-if="accountWarning"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
border="start"
|
||||
class="mb-4"
|
||||
>
|
||||
<strong>安全提醒:</strong> 请修改默认密码以确保账户安全
|
||||
</v-alert>
|
||||
|
||||
<v-alert v-if="accountWarning" color="warning" style="margin-bottom: 16px;">
|
||||
<div>为了安全,请务必修改默认密码。</div>
|
||||
</v-alert>
|
||||
<v-alert
|
||||
v-if="accountEditStatus.success"
|
||||
type="success"
|
||||
variant="tonal"
|
||||
border="start"
|
||||
class="mb-4"
|
||||
>
|
||||
{{ accountEditStatus.message }}
|
||||
</v-alert>
|
||||
|
||||
<v-text-field label="原密码*" type="password" v-model="password" required
|
||||
variant="outlined"></v-text-field>
|
||||
<v-alert
|
||||
v-if="accountEditStatus.error"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
border="start"
|
||||
class="mb-4"
|
||||
>
|
||||
{{ accountEditStatus.message }}
|
||||
</v-alert>
|
||||
|
||||
<v-text-field label="新用户名" v-model="newUsername" required variant="outlined"></v-text-field>
|
||||
<v-form v-model="formValid" @submit.prevent="accountEdit">
|
||||
<v-text-field
|
||||
v-model="password"
|
||||
:append-inner-icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
label="当前密码"
|
||||
variant="outlined"
|
||||
required
|
||||
clearable
|
||||
@click:append-inner="showPassword = !showPassword"
|
||||
prepend-inner-icon="mdi-lock-outline"
|
||||
hide-details="auto"
|
||||
class="mb-4"
|
||||
></v-text-field>
|
||||
|
||||
<v-text-field label="新密码" type="password" v-model="newPassword" required
|
||||
variant="outlined"></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
<small>默认用户名和密码是 astrbot。</small>
|
||||
<br>
|
||||
<small>{{ status }}</small>
|
||||
<v-text-field
|
||||
v-model="newPassword"
|
||||
:append-inner-icon="showNewPassword ? 'mdi-eye-off' : 'mdi-eye'"
|
||||
:type="showNewPassword ? 'text' : 'password'"
|
||||
:rules="passwordRules"
|
||||
label="新密码"
|
||||
variant="outlined"
|
||||
required
|
||||
clearable
|
||||
@click:append-inner="showNewPassword = !showNewPassword"
|
||||
prepend-inner-icon="mdi-lock-plus-outline"
|
||||
hint="密码长度至少 8 位"
|
||||
persistent-hint
|
||||
class="mb-4"
|
||||
></v-text-field>
|
||||
|
||||
<v-text-field
|
||||
v-model="newUsername"
|
||||
:rules="usernameRules"
|
||||
label="新用户名 (可选)"
|
||||
variant="outlined"
|
||||
clearable
|
||||
prepend-inner-icon="mdi-account-edit-outline"
|
||||
hint="留空表示不修改用户名"
|
||||
persistent-hint
|
||||
class="mb-3"
|
||||
></v-text-field>
|
||||
</v-form>
|
||||
|
||||
<div class="text-caption text-medium-emphasis mt-2">
|
||||
默认用户名和密码均为 astrbot
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn v-if="!accountWarning" color="blue-darken-1" variant="text" @click="dialog = false">
|
||||
关闭
|
||||
<v-btn
|
||||
v-if="!accountWarning"
|
||||
variant="tonal"
|
||||
color="secondary"
|
||||
@click="dialog = false"
|
||||
:disabled="accountEditStatus.loading"
|
||||
>
|
||||
取消
|
||||
</v-btn>
|
||||
<v-btn color="blue-darken-1" variant="text" @click="accountEdit">
|
||||
提交
|
||||
<v-btn
|
||||
color="primary"
|
||||
@click="accountEdit"
|
||||
:loading="accountEditStatus.loading"
|
||||
:disabled="!formValid"
|
||||
prepend-icon="mdi-content-save"
|
||||
>
|
||||
保存修改
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
@@ -432,4 +563,91 @@ 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);
|
||||
}
|
||||
|
||||
/* 响应式布局样式 */
|
||||
.logo-container {
|
||||
margin-left: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mobile-logo {
|
||||
margin-left: 8px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 24px;
|
||||
font-weight: 1000;
|
||||
}
|
||||
|
||||
.logo-text-light {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.version-text {
|
||||
font-size: 12px;
|
||||
color: var(--v-theme-secondaryText);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
/* 移动端对话框标题样式 */
|
||||
.mobile-card-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 移动端样式优化 */
|
||||
@media (max-width: 600px) {
|
||||
.logo-text {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
margin-right: 4px;
|
||||
min-width: 32px !important;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.v-card-title {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.v-card-text {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.v-tabs .v-tab {
|
||||
padding: 0 10px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -65,11 +65,11 @@ const sidebarItem: menu[] = [
|
||||
icon: 'mdi-console',
|
||||
to: '/console'
|
||||
},
|
||||
// {
|
||||
// title: 'Alkaid',
|
||||
// icon: 'mdi-test-tube',
|
||||
// to: '/alkaid'
|
||||
// },
|
||||
{
|
||||
title: 'Alkaid',
|
||||
icon: 'mdi-test-tube',
|
||||
to: '/alkaid'
|
||||
},
|
||||
{
|
||||
title: '关于',
|
||||
icon: 'mdi-information',
|
||||
|
||||
@@ -3,6 +3,7 @@ import '@mdi/font/css/materialdesignicons.css';
|
||||
import * as components from 'vuetify/components';
|
||||
import * as directives from 'vuetify/directives';
|
||||
import { PurpleTheme } from '@/theme/LightTheme';
|
||||
import { PurpleThemeDark } from "@/theme/DarkTheme";
|
||||
|
||||
export default createVuetify({
|
||||
components,
|
||||
@@ -11,7 +12,8 @@ export default createVuetify({
|
||||
theme: {
|
||||
defaultTheme: 'PurpleTheme',
|
||||
themes: {
|
||||
PurpleTheme
|
||||
PurpleTheme,
|
||||
PurpleThemeDark
|
||||
}
|
||||
},
|
||||
defaults: {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
const ChatBoxRoutes = {
|
||||
path: '/chatbox',
|
||||
component: () => import('@/layouts/blank/BlankLayout.vue'),
|
||||
children: [
|
||||
{
|
||||
name: 'ChatBox',
|
||||
path: '/chatbox',
|
||||
component: () => import('@/views/ChatBoxPage.vue'),
|
||||
children: [
|
||||
{
|
||||
path: ':conversationId',
|
||||
name: 'ChatBoxDetail',
|
||||
component: () => import('@/views/ChatBoxPage.vue'),
|
||||
props: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export default ChatBoxRoutes;
|
||||
@@ -81,7 +81,15 @@ const MainRoutes = {
|
||||
{
|
||||
name: 'Chat',
|
||||
path: '/chat',
|
||||
component: () => import('@/views/ChatPage.vue')
|
||||
component: () => import('@/views/ChatPage.vue'),
|
||||
children: [
|
||||
{
|
||||
path: ':conversationId',
|
||||
name: 'ChatDetail',
|
||||
component: () => import('@/views/ChatPage.vue'),
|
||||
props: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import MainRoutes from './MainRoutes';
|
||||
import AuthRoutes from './AuthRoutes';
|
||||
import ChatBoxRoutes from './ChatBoxRoutes';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
MainRoutes,
|
||||
AuthRoutes
|
||||
AuthRoutes,
|
||||
ChatBoxRoutes
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
.listitem {
|
||||
height: calc(100vh - 100px);
|
||||
.v-list {
|
||||
color: rgb(var(--v-theme-lightText));
|
||||
color: rgb(var(--v-theme-secondaryText));
|
||||
}
|
||||
.v-list-group__items .v-list-item,
|
||||
.v-list-item {
|
||||
|
||||
@@ -32,7 +32,7 @@ export const useAuthStore = defineStore({
|
||||
},
|
||||
logout() {
|
||||
this.username = '';
|
||||
localStorage.removeItem('username');
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('token');
|
||||
router.push('/auth/login');
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ export const useCustomizerStore = defineStore({
|
||||
Customizer_drawer: config.Customizer_drawer,
|
||||
mini_sidebar: config.mini_sidebar,
|
||||
fontTheme: "Poppins",
|
||||
uiTheme: config.uiTheme,
|
||||
inputBg: config.inputBg
|
||||
}),
|
||||
|
||||
@@ -21,6 +22,10 @@ export const useCustomizerStore = defineStore({
|
||||
},
|
||||
SET_FONT(payload: string) {
|
||||
this.fontTheme = payload;
|
||||
}
|
||||
},
|
||||
SET_UI_THEME(payload: string) {
|
||||
this.uiTheme = payload;
|
||||
localStorage.setItem("uiTheme", payload);
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ThemeTypes } from '@/types/themeTypes/ThemeType';
|
||||
|
||||
const PurpleThemeDark: ThemeTypes = {
|
||||
name: 'PurpleThemeDark',
|
||||
dark: true,
|
||||
variables: {
|
||||
'border-color': '#1677ff',
|
||||
'carousel-control-size': 10
|
||||
},
|
||||
colors: {
|
||||
primary: '#1677ff',
|
||||
secondary: '#722ed1',
|
||||
info: '#03c9d7',
|
||||
success: '#52c41a',
|
||||
accent: '#FFAB91',
|
||||
warning: '#faad14',
|
||||
error: '#ff4d4f',
|
||||
lightprimary: '#eef2f6',
|
||||
lightsecondary: '#ede7f6',
|
||||
lightsuccess: '#b9f6ca',
|
||||
lighterror: '#f9d8d8',
|
||||
lightwarning: '#fff8e1',
|
||||
primaryText: '#ffffff',
|
||||
secondaryText: '#ffffffcc',
|
||||
darkprimary: '#1565c0',
|
||||
darksecondary: '#4527a0',
|
||||
borderLight: '#d0d0d0',
|
||||
border: '#333333ee',
|
||||
inputBorder: '#787878',
|
||||
containerBg: '#1a1a1a',
|
||||
surface: '#1f1f1f',
|
||||
'on-surface-variant': '#000',
|
||||
facebook: '#4267b2',
|
||||
twitter: '#1da1f2',
|
||||
linkedin: '#0e76a8',
|
||||
gray100: '#cccccccc',
|
||||
primary200: '#90caf9',
|
||||
secondary200: '#b39ddb',
|
||||
background: '#111111',
|
||||
overlay: '#111111aa',
|
||||
codeBg: '#282833',
|
||||
code: '#ffffffdd'
|
||||
}
|
||||
};
|
||||
|
||||
export { PurpleThemeDark };
|
||||
@@ -20,11 +20,12 @@ const PurpleTheme: ThemeTypes = {
|
||||
lightsuccess: '#b9f6ca',
|
||||
lighterror: '#f9d8d8',
|
||||
lightwarning: '#fff8e1',
|
||||
darkText: '#212121',
|
||||
lightText: '#616161',
|
||||
primaryText: '#000000dd',
|
||||
secondaryText: '#000000aa',
|
||||
darkprimary: '#1565c0',
|
||||
darksecondary: '#4527a0',
|
||||
borderLight: '#d0d0d0',
|
||||
border: '#d0d0d0',
|
||||
inputBorder: '#787878',
|
||||
containerBg: '#eef2f6',
|
||||
surface: '#fff',
|
||||
@@ -32,9 +33,13 @@ const PurpleTheme: ThemeTypes = {
|
||||
facebook: '#4267b2',
|
||||
twitter: '#1da1f2',
|
||||
linkedin: '#0e76a8',
|
||||
gray100: '#fafafa',
|
||||
gray100: '#fafafacc',
|
||||
primary200: '#90caf9',
|
||||
secondary200: '#b39ddb'
|
||||
secondary200: '#b39ddb',
|
||||
background: '#f9fafcf4',
|
||||
overlay: '#ffffffaa',
|
||||
codeBg: '#f5f0ff',
|
||||
code: '#673ab7'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -17,13 +17,15 @@ export type ThemeTypes = {
|
||||
lightwarning?: string;
|
||||
darkprimary?: string;
|
||||
darksecondary?: string;
|
||||
darkText?: string;
|
||||
lightText?: string;
|
||||
primaryText?: string;
|
||||
secondaryText?: string;
|
||||
borderLight?: string;
|
||||
border?: string;
|
||||
inputBorder?: string;
|
||||
containerBg?: string;
|
||||
surface?: string;
|
||||
background?: string;
|
||||
overlay?: string;
|
||||
'on-surface-variant'?: string;
|
||||
facebook?: string;
|
||||
twitter?: string;
|
||||
@@ -31,5 +33,7 @@ export type ThemeTypes = {
|
||||
gray100?: string;
|
||||
primary200?: string;
|
||||
secondary200?: string;
|
||||
codeBg?: string;
|
||||
code?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</div>
|
||||
<div class="title-container">
|
||||
<h1 class="text-h2 font-weight-bold">AstrBot</h1>
|
||||
<p class="text-subtitle-1" style="color: #777;">A project out of interests and loves ❤️</p>
|
||||
<p class="text-subtitle-1" style="color: var(--v-theme-secondaryText);">A project out of interests and loves ❤️</p>
|
||||
<div class="action-buttons">
|
||||
<v-btn @click="open('https://github.com/Soulter/AstrBot')"
|
||||
color="primary" variant="elevated" prepend-icon="mdi-star">
|
||||
@@ -32,16 +32,20 @@
|
||||
<v-row justify="center" align="center">
|
||||
<v-col cols="12" md="6" class="pr-md-8 contributors-info">
|
||||
<h2 class="text-h4 font-weight-medium">贡献者</h2>
|
||||
<p class="mb-4 text-body-1" style="color: #777;">
|
||||
<p class="mb-4 text-body-1" style="color: var(--v-theme-secondaryText);">
|
||||
本项目由众多开源社区成员共同维护。感谢每一位贡献者的付出!
|
||||
</p>
|
||||
<p class="text-body-1" style="color: #777;">
|
||||
<p class="text-body-1" style="color: var(--v-theme-secondaryText);">
|
||||
<a href="https://github.com/Soulter/AstrBot/graphs/contributors" class="text-decoration-none custom-link">查看 AstrBot 贡献者</a>
|
||||
</p>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-card variant="outlined" class="overflow-hidden" elevation="2">
|
||||
<v-img
|
||||
<v-img v-if="useCustomizerStore().uiTheme==='PurpleThemeDark'"
|
||||
alt="Active Contributors of Soulter/AstrBot"
|
||||
src="https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=575865240&limit=365&image_size=auto&color_scheme=dark">
|
||||
</v-img>
|
||||
<v-img v-else
|
||||
alt="Active Contributors of Soulter/AstrBot"
|
||||
src="https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=575865240&limit=365&image_size=auto&color_scheme=light">
|
||||
</v-img>
|
||||
@@ -60,12 +64,16 @@
|
||||
|
||||
<div class="license-container mt-8">
|
||||
<img v-bind="props" src="https://www.gnu.org/graphics/agplv3-with-text-100x42.png" style="cursor: pointer;"/>
|
||||
<p class="text-caption mt-2" style="color: #777;">AstrBot 采用 AGPL v3 协议开源</p>
|
||||
<p class="text-caption mt-2" style="color: var(--v-theme-secondaryText);">AstrBot 采用 AGPL v3 协议开源</p>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-card variant="outlined" class="overflow-hidden" elevation="2">
|
||||
<v-img
|
||||
<v-img v-if="useCustomizerStore().uiTheme==='PurpleThemeDark'"
|
||||
alt="Stars Map of Soulter/AstrBot"
|
||||
src="https://next.ossinsight.io/widgets/official/analyze-repo-stars-map/thumbnail.png?activity=stars&repo_id=575865240&image_size=auto&color_scheme=dark">
|
||||
</v-img>
|
||||
<v-img v-else
|
||||
alt="Stars Map of Soulter/AstrBot"
|
||||
src="https://next.ossinsight.io/widgets/official/analyze-repo-stars-map/thumbnail.png?activity=stars&repo_id=575865240&image_size=auto&color_scheme=light">
|
||||
</v-img>
|
||||
@@ -80,6 +88,8 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {useCustomizerStore} from "@/stores/customizer";
|
||||
|
||||
export default {
|
||||
name: 'AboutPage',
|
||||
data() {
|
||||
@@ -89,6 +99,7 @@ export default {
|
||||
},
|
||||
|
||||
methods: {
|
||||
useCustomizerStore,
|
||||
open(url) {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
@@ -137,7 +148,7 @@ export default {
|
||||
}
|
||||
|
||||
.contributors-section {
|
||||
background-color: #f9f9fb;
|
||||
background-color: var(--v-theme-containerBg, #f9f9fb);
|
||||
}
|
||||
|
||||
.contributors-info, .stats-info {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup>
|
||||
import ChatPage from './ChatPage.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="height: 100%; width: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center;">
|
||||
<div id="container">
|
||||
<ChatPage chatbox-mode="true"></ChatPage>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
#container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
#container {
|
||||
min-width: 600px;
|
||||
min-height: 370px;
|
||||
max-width: 1100px;
|
||||
max-height: 860px;
|
||||
padding: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
#container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+485
-136
@@ -1,45 +1,67 @@
|
||||
<script setup>
|
||||
import { router } from '@/router';
|
||||
import axios from 'axios';
|
||||
import { marked } from 'marked';
|
||||
import { ref } from 'vue';
|
||||
import { defineProps } from 'vue';
|
||||
|
||||
marked.setOptions({
|
||||
breaks: true
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
chatboxMode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card class="chat-page-card">
|
||||
<v-card-text class="chat-page-container">
|
||||
<div class="chat-layout">
|
||||
<div class="sidebar-panel">
|
||||
<div class="sidebar-header">
|
||||
<v-btn icon variant="plain">
|
||||
<v-icon icon="mdi-menu" color="deep-purple"></v-icon>
|
||||
<div class="sidebar-panel" :class="{ 'sidebar-collapsed': sidebarCollapsed }"
|
||||
@mouseenter="handleSidebarMouseEnter" @mouseleave="handleSidebarMouseLeave">
|
||||
|
||||
<div style="display: flex; align-items: center; justify-content: center; padding: 16px; padding-bottom: 0px;" v-if="props.chatboxMode">
|
||||
<img width="50" src="@/assets/images/astrbot_logo_mini.webp" alt="AstrBot Logo">
|
||||
<span v-if="!sidebarCollapsed" style="font-weight: 1000; font-size: 26px; margin-left: 8px;" class="text-secondary">AstrBot</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="sidebar-collapse-btn-container">
|
||||
<v-btn icon class="sidebar-collapse-btn" @click="toggleSidebar" variant="text"
|
||||
color="deep-purple">
|
||||
<v-icon>{{ (sidebarCollapsed || (!sidebarCollapsed && sidebarHoverExpanded)) ?
|
||||
'mdi-chevron-right' : 'mdi-chevron-left' }}</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div 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>
|
||||
<v-btn rounded="lg" class="new-chat-btn" @click="newC" :disabled="!currCid"
|
||||
v-if="!sidebarCollapsed" prepend-icon="mdi-plus">创建对话</v-btn>
|
||||
<v-btn icon="mdi-plus" rounded="lg" @click="newC" :disabled="!currCid" v-if="sidebarCollapsed"
|
||||
elevation="0"></v-btn>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="conversations-container">
|
||||
|
||||
<div style="overflow-y: auto;" :class="{ 'fade-in': sidebarHoverExpanded }"
|
||||
v-if="!sidebarCollapsed">
|
||||
<v-card class="conversation-list-card" v-if="conversations.length > 0" flat>
|
||||
<v-list density="compact" nav class="conversation-list"
|
||||
@update:selected="getConversationMessages">
|
||||
<v-list-item v-for="(item, i) in conversations" :key="item.cid" :value="item.cid"
|
||||
color="primary" rounded="lg" class="conversation-item" active-color="primary">
|
||||
<template v-slot:prepend>
|
||||
<v-icon size="small" icon="mdi-message-text-outline"></v-icon>
|
||||
rounded="lg" class="conversation-item" active-color="secondary">
|
||||
<v-list-item-title v-if="!sidebarCollapsed" class="conversation-title">{{ item.title
|
||||
|| '新对话' }}</v-list-item-title>
|
||||
<!-- <v-list-item-subtitle v-if="!sidebarCollapsed" class="timestamp">{{
|
||||
formatDate(item.updated_at)
|
||||
}}</v-list-item-subtitle> -->
|
||||
|
||||
<template v-if="!sidebarCollapsed" v-slot:append>
|
||||
<v-btn icon="mdi-pencil" size="x-small" variant="text" class="edit-title-btn"
|
||||
@click.stop="showEditTitleDialog(item.cid, item.title)" />
|
||||
</template>
|
||||
<v-list-item-title class="conversation-title">新对话</v-list-item-title>
|
||||
<v-list-item-subtitle class="timestamp">{{ formatDate(item.updated_at)
|
||||
}}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
@@ -47,12 +69,14 @@ marked.setOptions({
|
||||
<v-fade-transition>
|
||||
<div class="no-conversations" v-if="conversations.length === 0">
|
||||
<v-icon icon="mdi-message-text-outline" size="large" color="grey-lighten-1"></v-icon>
|
||||
<div class="no-conversations-text">暂无对话历史</div>
|
||||
<div class="no-conversations-text" v-if="!sidebarCollapsed || sidebarHoverExpanded">
|
||||
暂无对话历史</div>
|
||||
</div>
|
||||
</v-fade-transition>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div style="padding: 16px; padding-bottom: 0px;" :class="{ 'fade-in': sidebarHoverExpanded }"
|
||||
v-if="!sidebarCollapsed">
|
||||
<div class="sidebar-section-title">
|
||||
系统状态
|
||||
</div>
|
||||
@@ -63,7 +87,7 @@ marked.setOptions({
|
||||
<v-icon :icon="status?.llm_enabled ? 'mdi-check-circle' : 'mdi-alert-circle'"
|
||||
size="x-small"></v-icon>
|
||||
</template>
|
||||
LLM 服务
|
||||
<span>LLM 服务</span>
|
||||
</v-chip>
|
||||
|
||||
<v-chip class="status-chip" :color="status?.stt_enabled ? 'success' : 'grey-lighten-2'"
|
||||
@@ -72,7 +96,7 @@ marked.setOptions({
|
||||
<v-icon :icon="status?.stt_enabled ? 'mdi-check-circle' : 'mdi-alert-circle'"
|
||||
size="x-small"></v-icon>
|
||||
</template>
|
||||
语音转文本
|
||||
<span>语音转文本</span>
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
@@ -86,6 +110,20 @@ marked.setOptions({
|
||||
|
||||
<!-- 右侧聊天内容区域 -->
|
||||
<div class="chat-content-panel">
|
||||
|
||||
<div class="conversation-header fade-in">
|
||||
<div class="conversation-header-content" v-if="currCid && getCurrentConversation">
|
||||
<h2 class="conversation-header-title">{{ getCurrentConversation.title || '新对话' }}</h2>
|
||||
<div class="conversation-header-time">{{ formatDate(getCurrentConversation.updated_at) }}</div>
|
||||
</div>
|
||||
<div class="conversation-header-actions">
|
||||
<!-- router 推送到 /chatbox -->
|
||||
<v-icon @click="router.push('/chatbox')" v-if="!props.chatboxMode"
|
||||
class="fullscreen-icon">mdi-fullscreen</v-icon>
|
||||
</div>
|
||||
</div>
|
||||
<v-divider v-if="currCid && getCurrentConversation" class="conversation-divider"></v-divider>
|
||||
|
||||
<div class="messages-container" ref="messageContainer">
|
||||
<!-- 空聊天欢迎页 -->
|
||||
<div class="welcome-container fade-in" v-if="messages.length == 0">
|
||||
@@ -168,7 +206,7 @@ marked.setOptions({
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn v-bind="props" @click="sendMessage" class="send-btn" icon="mdi-send"
|
||||
variant="text" color="deep-purple"
|
||||
:disabled="!prompt && stagedImagesUrl.length === 0 && !stagedAudioUrl" />
|
||||
:disabled="!prompt && stagedImagesName.length === 0 && !stagedAudioUrl" />
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
@@ -205,6 +243,22 @@ marked.setOptions({
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- 编辑对话标题对话框 -->
|
||||
<v-dialog v-model="editTitleDialog" max-width="400">
|
||||
<v-card>
|
||||
<v-card-title class="dialog-title">编辑对话标题</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field v-model="editingTitle" label="对话标题" variant="outlined" hide-details class="mt-2"
|
||||
@keyup.enter="saveTitle" autofocus />
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn text @click="editTitleDialog = false" color="grey-darken-1">取消</v-btn>
|
||||
<v-btn text @click="saveTitle" color="primary">保存</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -218,7 +272,8 @@ export default {
|
||||
messages: [],
|
||||
conversations: [],
|
||||
currCid: '',
|
||||
stagedImagesUrl: [],
|
||||
stagedImagesName: [], // 用于存储图片**文件名**的数组
|
||||
stagedImagesUrl: [], // 用于存储图片的blob URL数组
|
||||
loadingChat: false,
|
||||
|
||||
inputFieldLabel: '聊天吧!',
|
||||
@@ -236,7 +291,71 @@ export default {
|
||||
// Ctrl键长按相关变量
|
||||
ctrlKeyDown: false,
|
||||
ctrlKeyTimer: null,
|
||||
ctrlKeyLongPressThreshold: 300 // 长按阈值,单位毫秒
|
||||
ctrlKeyLongPressThreshold: 300, // 长按阈值,单位毫秒
|
||||
|
||||
mediaCache: {}, // Add a cache to store media blobs
|
||||
|
||||
// 添加对话标题编辑相关变量
|
||||
editTitleDialog: false,
|
||||
editingTitle: '',
|
||||
editingCid: '',
|
||||
|
||||
// 侧边栏折叠状态
|
||||
sidebarCollapsed: false,
|
||||
sidebarHovered: false,
|
||||
sidebarHoverTimer: null,
|
||||
sidebarHoverExpanded: false,
|
||||
sidebarHoverDelay: 100, // 悬停延迟,单位毫秒
|
||||
|
||||
pendingCid: null, // Store pending conversation ID for route handling
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
// Get the current conversation from the conversations array
|
||||
getCurrentConversation() {
|
||||
if (!this.currCid) return null;
|
||||
return this.conversations.find(c => c.cid === this.currCid);
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
// Watch for route changes to handle direct navigation to /chat/<cid>
|
||||
'$route': {
|
||||
immediate: true,
|
||||
handler(to) {
|
||||
console.log('Route changed:', to.path);
|
||||
// Check if the route matches /chat/<cid> pattern
|
||||
if (to.path.startsWith('/chat/') || to.path.startsWith('/chatbox/')) {
|
||||
const pathCid = to.path.split('/')[2];
|
||||
console.log('Path CID:', pathCid);
|
||||
if (pathCid && pathCid !== this.currCid) {
|
||||
// If conversations are already loaded
|
||||
if (this.conversations.length > 0) {
|
||||
const conversation = this.conversations.find(c => c.cid === pathCid);
|
||||
if (conversation) {
|
||||
this.getConversationMessages([pathCid]);
|
||||
}
|
||||
} else {
|
||||
// Store the cid to be used after conversations are loaded
|
||||
this.pendingCid = pathCid;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Watch for conversations loaded to handle pending cid
|
||||
conversations: {
|
||||
handler(newConversations) {
|
||||
if (this.pendingCid && newConversations.length > 0) {
|
||||
const conversation = newConversations.find(c => c.cid === this.pendingCid);
|
||||
if (conversation) {
|
||||
this.getConversationMessages([this.pendingCid]);
|
||||
this.pendingCid = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -255,6 +374,12 @@ export default {
|
||||
|
||||
// 添加keyup事件监听
|
||||
document.addEventListener('keyup', this.handleInputKeyUp);
|
||||
|
||||
// 从 localStorage 获取侧边栏折叠状态
|
||||
const savedCollapseState = localStorage.getItem('sidebarCollapsed');
|
||||
if (savedCollapseState !== null) {
|
||||
this.sidebarCollapsed = JSON.parse(savedCollapseState);
|
||||
}
|
||||
},
|
||||
|
||||
beforeUnmount() {
|
||||
@@ -265,9 +390,106 @@ export default {
|
||||
|
||||
// 移除keyup事件监听
|
||||
document.removeEventListener('keyup', this.handleInputKeyUp);
|
||||
|
||||
// 清除悬停定时器
|
||||
if (this.sidebarHoverTimer) {
|
||||
clearTimeout(this.sidebarHoverTimer);
|
||||
}
|
||||
|
||||
// Cleanup blob URLs
|
||||
this.cleanupMediaCache();
|
||||
},
|
||||
|
||||
methods: {
|
||||
// 切换侧边栏折叠状态
|
||||
toggleSidebar() {
|
||||
if (this.sidebarHoverExpanded) {
|
||||
this.sidebarHoverExpanded = false;
|
||||
return
|
||||
}
|
||||
this.sidebarCollapsed = !this.sidebarCollapsed;
|
||||
// 保存折叠状态到 localStorage
|
||||
localStorage.setItem('sidebarCollapsed', JSON.stringify(this.sidebarCollapsed));
|
||||
},
|
||||
|
||||
// 侧边栏鼠标悬停处理
|
||||
handleSidebarMouseEnter() {
|
||||
if (!this.sidebarCollapsed) return;
|
||||
|
||||
this.sidebarHovered = true;
|
||||
|
||||
// 设置延迟定时器
|
||||
this.sidebarHoverTimer = setTimeout(() => {
|
||||
if (this.sidebarHovered) {
|
||||
this.sidebarHoverExpanded = true;
|
||||
this.sidebarCollapsed = false;
|
||||
}
|
||||
}, this.sidebarHoverDelay);
|
||||
},
|
||||
|
||||
handleSidebarMouseLeave() {
|
||||
this.sidebarHovered = false;
|
||||
|
||||
// 清除定时器
|
||||
if (this.sidebarHoverTimer) {
|
||||
clearTimeout(this.sidebarHoverTimer);
|
||||
this.sidebarHoverTimer = null;
|
||||
}
|
||||
|
||||
if (this.sidebarHoverExpanded) {
|
||||
this.sidebarCollapsed = true;
|
||||
}
|
||||
this.sidebarHoverExpanded = false;
|
||||
},
|
||||
|
||||
// 显示编辑对话标题对话框
|
||||
showEditTitleDialog(cid, title) {
|
||||
this.editingCid = cid;
|
||||
this.editingTitle = title || ''; // 如果标题为空,则设置为空字符串
|
||||
this.editTitleDialog = true;
|
||||
},
|
||||
|
||||
// 保存对话标题
|
||||
saveTitle() {
|
||||
if (!this.editingCid) return;
|
||||
|
||||
const trimmedTitle = this.editingTitle.trim();
|
||||
axios.post('/api/chat/rename_conversation', {
|
||||
conversation_id: this.editingCid,
|
||||
title: trimmedTitle
|
||||
})
|
||||
.then(response => {
|
||||
// 更新本地对话列表中的标题
|
||||
const conversation = this.conversations.find(c => c.cid === this.editingCid);
|
||||
if (conversation) {
|
||||
conversation.title = trimmedTitle;
|
||||
}
|
||||
this.editTitleDialog = false;
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('重命名对话失败:', err);
|
||||
});
|
||||
},
|
||||
|
||||
async getMediaFile(filename) {
|
||||
if (this.mediaCache[filename]) {
|
||||
return this.mediaCache[filename];
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get('/api/chat/get_file', {
|
||||
params: { filename },
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
const blobUrl = URL.createObjectURL(response.data);
|
||||
this.mediaCache[filename] = blobUrl;
|
||||
return blobUrl;
|
||||
} catch (error) {
|
||||
console.error('Error fetching media file:', error);
|
||||
return '';
|
||||
}
|
||||
},
|
||||
|
||||
async startListeningEvent() {
|
||||
const response = await fetch('/api/chat/listen', {
|
||||
@@ -328,17 +550,19 @@ export default {
|
||||
|
||||
if (chunk_json.type === 'image') {
|
||||
let img = chunk_json.data.replace('[IMAGE]', '');
|
||||
const imageUrl = await this.getMediaFile(img);
|
||||
let bot_resp = {
|
||||
type: 'bot',
|
||||
message: `<img src="/api/chat/get_file?filename=${img}" style="max-width: 80%; border-radius: 8px; box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);"/>`
|
||||
message: `<img src="${imageUrl}" style="max-width: 80%; border-radius: 8px; box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);"/>`
|
||||
}
|
||||
this.messages.push(bot_resp);
|
||||
} else if (chunk_json.type === 'record') {
|
||||
let audio = chunk_json.data.replace('[RECORD]', '');
|
||||
const audioUrl = await this.getMediaFile(audio);
|
||||
let bot_resp = {
|
||||
type: 'bot',
|
||||
message: `<audio controls class="audio-player">
|
||||
<source src="/api/chat/get_file?filename=${audio}" type="audio/wav">
|
||||
<source src="${audioUrl}" type="audio/wav">
|
||||
您的浏览器不支持音频播放。
|
||||
</audio>`
|
||||
}
|
||||
@@ -357,6 +581,14 @@ export default {
|
||||
} else if (chunk_json.type === 'end') {
|
||||
in_streaming = false;
|
||||
continue;
|
||||
} else if (chunk_json.type === 'update_title') {
|
||||
// 更新对话标题
|
||||
const conversation = this.conversations.find(c => c.cid === chunk_json.cid);
|
||||
if (conversation) {
|
||||
conversation.title = chunk_json.data;
|
||||
}
|
||||
} else {
|
||||
console.warn('未知数据类型:', chunk_json.type);
|
||||
}
|
||||
this.scrollToBottom();
|
||||
}
|
||||
@@ -403,15 +635,14 @@ export default {
|
||||
try {
|
||||
const response = await axios.post('/api/chat/post_file', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('token')
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
|
||||
const audio = response.data.data.filename;
|
||||
console.log('Audio uploaded:', audio);
|
||||
|
||||
this.stagedAudioUrl = `/api/chat/get_file?filename=${audio}`;
|
||||
this.stagedAudioUrl = audio; // Store just the filename
|
||||
} catch (err) {
|
||||
console.error('Error uploading audio:', err);
|
||||
}
|
||||
@@ -430,13 +661,13 @@ export default {
|
||||
try {
|
||||
const response = await axios.post('/api/chat/post_image', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('token')
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
|
||||
const img = response.data.data.filename;
|
||||
this.stagedImagesUrl.push(`/api/chat/get_file?filename=${img}`);
|
||||
this.stagedImagesName.push(img); // Store just the filename
|
||||
this.stagedImagesUrl.push(URL.createObjectURL(file)); // Create a blob URL for immediate display
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error uploading image:', err);
|
||||
@@ -446,6 +677,7 @@ export default {
|
||||
},
|
||||
|
||||
removeImage(index) {
|
||||
this.stagedImagesName.splice(index, 1);
|
||||
this.stagedImagesUrl.splice(index, 1);
|
||||
},
|
||||
|
||||
@@ -455,35 +687,60 @@ export default {
|
||||
getConversations() {
|
||||
axios.get('/api/chat/conversations').then(response => {
|
||||
this.conversations = response.data.data;
|
||||
|
||||
// If there's a pending conversation ID from the route
|
||||
if (this.pendingCid) {
|
||||
const conversation = this.conversations.find(c => c.cid === this.pendingCid);
|
||||
if (conversation) {
|
||||
this.getConversationMessages([this.pendingCid]);
|
||||
this.pendingCid = null;
|
||||
}
|
||||
}
|
||||
}).catch(err => {
|
||||
if (err.response.status === 401) {
|
||||
this.$router.push('/auth/login?redirect=/chatbox');
|
||||
}
|
||||
console.error(err);
|
||||
});
|
||||
},
|
||||
getConversationMessages(cid) {
|
||||
if (!cid[0])
|
||||
return;
|
||||
axios.get('/api/chat/get_conversation?conversation_id=' + cid[0]).then(response => {
|
||||
|
||||
// Update the URL to reflect the selected conversation
|
||||
if (this.$route.path !== `/chat/${cid[0]}` && this.$route.path !== `/chatbox/${cid[0]}`) {
|
||||
if (this.$route.path.startsWith('/chatbox')) {
|
||||
router.push(`/chatbox/${cid[0]}`);
|
||||
} else {
|
||||
router.push(`/chat/${cid[0]}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
axios.get('/api/chat/get_conversation?conversation_id=' + cid[0]).then(async response => {
|
||||
this.currCid = cid[0];
|
||||
let message = JSON.parse(response.data.data.history);
|
||||
for (let i = 0; i < message.length; i++) {
|
||||
if (message[i].message.startsWith('[IMAGE]')) {
|
||||
let img = message[i].message.replace('[IMAGE]', '');
|
||||
message[i].message = `<img src="/api/chat/get_file?filename=${img}" style="max-width: 80%; border-radius: 8px; box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);"/>`
|
||||
const imageUrl = await this.getMediaFile(img);
|
||||
message[i].message = `<img src="${imageUrl}" style="max-width: 80%; border-radius: 8px; box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);"/>`
|
||||
}
|
||||
if (message[i].message.startsWith('[RECORD]')) {
|
||||
let audio = message[i].message.replace('[RECORD]', '');
|
||||
const audioUrl = await this.getMediaFile(audio);
|
||||
message[i].message = `<audio controls class="audio-player">
|
||||
<source src="/api/chat/get_file?filename=${audio}" type="audio/wav">
|
||||
<source src="${audioUrl}" type="audio/wav">
|
||||
您的浏览器不支持音频播放。
|
||||
</audio>`
|
||||
}
|
||||
if (message[i].image_url && message[i].image_url.length > 0) {
|
||||
for (let j = 0; j < message[i].image_url.length; j++) {
|
||||
message[i].image_url[j] = `/api/chat/get_file?filename=${message[i].image_url[j]}`;
|
||||
message[i].image_url[j] = await this.getMediaFile(message[i].image_url[j]);
|
||||
}
|
||||
}
|
||||
if (message[i].audio_url) {
|
||||
message[i].audio_url = `/api/chat/get_file?filename=${message[i].audio_url}`;
|
||||
message[i].audio_url = await this.getMediaFile(message[i].audio_url);
|
||||
}
|
||||
}
|
||||
this.messages = message;
|
||||
@@ -492,17 +749,31 @@ export default {
|
||||
});
|
||||
},
|
||||
async newConversation() {
|
||||
await axios.get('/api/chat/new_conversation').then(response => {
|
||||
this.currCid = response.data.data.conversation_id;
|
||||
return axios.get('/api/chat/new_conversation').then(response => {
|
||||
const cid = response.data.data.conversation_id;
|
||||
this.currCid = cid;
|
||||
// Update the URL to reflect the new conversation
|
||||
if (this.$route.path.startsWith('/chatbox')) {
|
||||
router.push(`/chatbox/${cid}`);
|
||||
} else {
|
||||
router.push(`/chat/${cid}`);
|
||||
}
|
||||
this.getConversations();
|
||||
return cid;
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
throw err;
|
||||
});
|
||||
},
|
||||
|
||||
newC() {
|
||||
this.currCid = '';
|
||||
this.messages = [];
|
||||
if (this.$route.path.startsWith('/chatbox')) {
|
||||
router.push('/chatbox');
|
||||
} else {
|
||||
router.push('/chat');
|
||||
}
|
||||
},
|
||||
|
||||
formatDate(timestamp) {
|
||||
@@ -531,35 +802,45 @@ export default {
|
||||
|
||||
async sendMessage() {
|
||||
if (this.currCid == '') {
|
||||
await this.newConversation();
|
||||
const cid = await this.newConversation();
|
||||
// URL is already updated in newConversation method
|
||||
}
|
||||
|
||||
this.messages.push({
|
||||
// Create a message object with actual URLs for display
|
||||
const userMessage = {
|
||||
type: 'user',
|
||||
message: this.prompt,
|
||||
image_url: this.stagedImagesUrl,
|
||||
audio_url: this.stagedAudioUrl
|
||||
});
|
||||
image_url: [],
|
||||
audio_url: null
|
||||
};
|
||||
|
||||
// Convert image filenames to blob URLs for display
|
||||
if (this.stagedImagesName.length > 0) {
|
||||
for (let i = 0; i < this.stagedImagesName.length; i++) {
|
||||
// If it's just a filename, get the blob URL
|
||||
if (!this.stagedImagesName[i].startsWith('blob:')) {
|
||||
const imgUrl = await this.getMediaFile(this.stagedImagesName[i]);
|
||||
userMessage.image_url.push(imgUrl);
|
||||
} else {
|
||||
userMessage.image_url.push(this.stagedImagesName[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert audio filename to blob URL for display
|
||||
if (this.stagedAudioUrl) {
|
||||
if (!this.stagedAudioUrl.startsWith('blob:')) {
|
||||
userMessage.audio_url = await this.getMediaFile(this.stagedAudioUrl);
|
||||
} else {
|
||||
userMessage.audio_url = this.stagedAudioUrl;
|
||||
}
|
||||
}
|
||||
|
||||
this.messages.push(userMessage);
|
||||
this.scrollToBottom();
|
||||
|
||||
// images
|
||||
let image_filenames = [];
|
||||
for (let i = 0; i < this.stagedImagesUrl.length; i++) {
|
||||
let img = this.stagedImagesUrl[i].replace('/api/chat/get_file?filename=', '');
|
||||
image_filenames.push(img);
|
||||
}
|
||||
|
||||
// audio
|
||||
let audio_filenames = [];
|
||||
if (this.stagedAudioUrl) {
|
||||
let audio = this.stagedAudioUrl.replace('/api/chat/get_file?filename=', '');
|
||||
audio_filenames.push(audio);
|
||||
}
|
||||
|
||||
this.loadingChat = true;
|
||||
|
||||
|
||||
fetch('/api/chat/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -569,15 +850,14 @@ export default {
|
||||
body: JSON.stringify({
|
||||
message: this.prompt,
|
||||
conversation_id: this.currCid,
|
||||
image_url: image_filenames,
|
||||
audio_url: audio_filenames
|
||||
}) // 发送请求体
|
||||
image_url: this.stagedImagesName, // Already contains just filenames
|
||||
audio_url: this.stagedAudioUrl ? [this.stagedAudioUrl] : [] // Already contains just filename
|
||||
})
|
||||
})
|
||||
.then(response => {
|
||||
this.prompt = '';
|
||||
this.stagedImagesUrl = [];
|
||||
this.stagedImagesName = [];
|
||||
this.stagedAudioUrl = "";
|
||||
|
||||
this.loadingChat = false;
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -623,6 +903,15 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
cleanupMediaCache() {
|
||||
Object.values(this.mediaCache).forEach(url => {
|
||||
if (url.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
});
|
||||
this.mediaCache = {};
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -667,29 +956,43 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
/* 添加淡入动画 */
|
||||
@keyframes fadeInContent {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeInContent 0.2s ease-in forwards;
|
||||
}
|
||||
|
||||
/* 聊天页面布局 */
|
||||
/* todo: 聊天页面背景颜色有问题 */
|
||||
.chat-page-card {
|
||||
margin-bottom: 16px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 12px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05) !important;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.chat-page-container {
|
||||
width: 100%;
|
||||
height: calc(100vh - 120px);
|
||||
height: 100%;
|
||||
max-height: calc(100vh - 120px);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.chat-layout {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* 侧边栏样式 - 优化版 */
|
||||
.sidebar-panel {
|
||||
max-width: 270px;
|
||||
min-width: 240px;
|
||||
@@ -697,60 +1000,37 @@ export default {
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.05);
|
||||
background-color: #fcfcfc;
|
||||
background-color: var(--v-theme-containerBg);
|
||||
height: 100%;
|
||||
position: relative;
|
||||
transition: all 0.3s ease;
|
||||
overflow: hidden;
|
||||
/* 防止内容溢出 */
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 16px;
|
||||
/* 侧边栏折叠状态 */
|
||||
.sidebar-collapsed {
|
||||
max-width: 75px;
|
||||
min-width: 75px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.conversations-container {
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
/* 当悬停展开时 */
|
||||
.sidebar-collapsed.sidebar-hovered {
|
||||
max-width: 270px;
|
||||
min-width: 240px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 16px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.04);
|
||||
/* 侧边栏折叠按钮 */
|
||||
.sidebar-collapse-btn-container {
|
||||
margin: 16px;
|
||||
margin-bottom: 0px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.sidebar-section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 12px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.new-chat-btn {
|
||||
width: 100%;
|
||||
background-color: #673ab7 !important;
|
||||
color: white !important;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 2px 8px rgba(103, 58, 183, 0.25) !important;
|
||||
transition: all 0.2s ease;
|
||||
text-transform: none;
|
||||
letter-spacing: 0.25px;
|
||||
}
|
||||
|
||||
.new-chat-btn:hover {
|
||||
background-color: #7e57c2 !important;
|
||||
box-shadow: 0 4px 12px rgba(103, 58, 183, 0.3) !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.conversation-list-card {
|
||||
border-radius: 12px;
|
||||
box-shadow: none !important;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.conversation-list {
|
||||
.sidebar-collapse-btn {
|
||||
opacity: 0.6;
|
||||
max-height: none;
|
||||
overflow-y: visible;
|
||||
padding: 0;
|
||||
@@ -762,7 +1042,8 @@ export default {
|
||||
transition: all 0.2s ease;
|
||||
height: auto !important;
|
||||
min-height: 56px;
|
||||
padding: 8px 12px !important;
|
||||
padding: 8px 16px !important;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.conversation-item:hover {
|
||||
@@ -774,12 +1055,25 @@ export default {
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
margin-bottom: 2px;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
color: var(--v-theme-secondaryText);
|
||||
line-height: 1;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.sidebar-section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--v-theme-secondaryText);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 12px;
|
||||
padding-left: 4px;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.status-chips {
|
||||
@@ -787,6 +1081,7 @@ export default {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
@@ -803,6 +1098,8 @@ export default {
|
||||
text-transform: none;
|
||||
letter-spacing: 0.25px;
|
||||
font-size: 12px;
|
||||
line-height: 1.2em;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.delete-chat-btn:hover {
|
||||
@@ -821,7 +1118,8 @@ export default {
|
||||
|
||||
.no-conversations-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
color: var(--v-theme-secondaryText);
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
/* 聊天内容区域 */
|
||||
@@ -857,21 +1155,21 @@ export default {
|
||||
.bot-name {
|
||||
font-weight: 700;
|
||||
margin-left: 8px;
|
||||
color: #673ab7;
|
||||
color: var(--v-theme-secondary);
|
||||
}
|
||||
|
||||
.welcome-hint {
|
||||
margin-top: 8px;
|
||||
color: #666;
|
||||
color: var(--v-theme-secondaryText);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.welcome-hint code {
|
||||
background-color: #f5f0ff;
|
||||
background-color: var(--v-theme-codeBg);
|
||||
padding: 2px 6px;
|
||||
margin: 0 4px;
|
||||
border-radius: 4px;
|
||||
color: #673ab7;
|
||||
color: var(--v-theme-code);
|
||||
font-family: 'Fira Code', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -910,15 +1208,15 @@ export default {
|
||||
}
|
||||
|
||||
.user-bubble {
|
||||
background-color: #f5f0ff;
|
||||
color: #333;
|
||||
background-color: var(--v-theme-background);
|
||||
color: var(--v-theme-primaryText);
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
|
||||
.bot-bubble {
|
||||
background-color: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
color: #333;
|
||||
background-color: var(--v-theme-surface);
|
||||
border: 1px solid var(--v-theme-border);
|
||||
color: var(--v-theme-primaryText);
|
||||
border-top-left-radius: 4px;
|
||||
}
|
||||
|
||||
@@ -965,9 +1263,9 @@ export default {
|
||||
/* 输入区域样式 */
|
||||
.input-area {
|
||||
padding: 16px;
|
||||
background-color: #fff;
|
||||
background-color: var(--v-theme-surface);
|
||||
position: relative;
|
||||
border-top: 1px solid #f5f5f5;
|
||||
border-top: 1px solid var(--v-theme-border);
|
||||
}
|
||||
|
||||
.message-input {
|
||||
@@ -1037,12 +1335,12 @@ export default {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
color: var(--v-theme-primaryText);
|
||||
}
|
||||
|
||||
.markdown-content h1 {
|
||||
font-size: 1.8em;
|
||||
border-bottom: 1px solid #eee;
|
||||
border-bottom: 1px solid var(--v-theme-border);
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
@@ -1065,7 +1363,7 @@ export default {
|
||||
}
|
||||
|
||||
.markdown-content pre {
|
||||
background-color: #f8f8f8;
|
||||
background-color: var(--v-theme-surface);
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
@@ -1073,12 +1371,12 @@ export default {
|
||||
}
|
||||
|
||||
.markdown-content code {
|
||||
background-color: #f5f0ff;
|
||||
background-color: var(--v-theme-codeBg);
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Fira Code', monospace;
|
||||
font-size: 0.9em;
|
||||
color: #673ab7;
|
||||
color: var(--v-theme-code);
|
||||
}
|
||||
|
||||
.markdown-content img {
|
||||
@@ -1088,9 +1386,9 @@ export default {
|
||||
}
|
||||
|
||||
.markdown-content blockquote {
|
||||
border-left: 4px solid #673ab7;
|
||||
border-left: 4px solid var(--v-theme-secondary);
|
||||
padding-left: 16px;
|
||||
color: #666;
|
||||
color: var(--v-theme-secondaryText);
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
@@ -1102,17 +1400,68 @@ export default {
|
||||
|
||||
.markdown-content th,
|
||||
.markdown-content td {
|
||||
border: 1px solid #eee;
|
||||
border: 1px solid var(--v-theme-background);
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown-content th {
|
||||
background-color: #f5f0ff;
|
||||
background-color: var(--v-theme-containerBg);
|
||||
}
|
||||
|
||||
/* 动画类 */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
/* 对话框标题样式 */
|
||||
.dialog-title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
/* 对话标题和时间样式 */
|
||||
.conversation-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 16px 16px 16px;
|
||||
border-bottom: 1px solid var(--v-theme-border);
|
||||
width: 100%;
|
||||
padding-right: 32px;
|
||||
}
|
||||
|
||||
.conversation-header-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.conversation-header-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--v-theme-primaryText);
|
||||
}
|
||||
|
||||
.conversation-header-time {
|
||||
font-size: 12px;
|
||||
color: var(--v-theme-secondaryText);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.conversation-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fullscreen-icon {
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fullscreen-icon:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -42,7 +42,7 @@ import config from '@/config';
|
||||
<div v-for="(val2, key2, index2) in metadata[key]['metadata']">
|
||||
<!-- <h3>{{ metadata[key]['metadata'][key2]['description'] }}</h3> -->
|
||||
<div v-if="metadata[key]['metadata'][key2]?.config_template"
|
||||
v-show="key2 !== 'platform' && key2 !== 'provider'" style="border: 1px solid #e0e0e0; padding: 8px; margin-bottom: 16px; border-radius: 10px">
|
||||
v-show="key2 !== 'platform' && key2 !== 'provider'" style="border: 1px solid var(--v-theme-border); padding: 8px; margin-bottom: 16px; border-radius: 10px">
|
||||
<!-- 带有 config_template 的配置项 -->
|
||||
<v-list-item-title style="font-weight: bold;">
|
||||
{{ metadata[key]['metadata'][key2]['description'] }} ({{ key2 }})
|
||||
@@ -88,7 +88,7 @@ import config from '@/config';
|
||||
|
||||
<div v-else>
|
||||
<!-- 如果配置项是一个 object,那么 iterable 需要取到这个 object 的值,否则取到整个 config_data -->
|
||||
<div v-if="metadata[key]['metadata'][key2]['type'] == 'object'" style="border: 1px solid #e0e0e0; padding: 8px; margin-bottom: 16px; border-radius: 10px">
|
||||
<div v-if="metadata[key]['metadata'][key2]['type'] == 'object'" style="border: 1px solid var(--v-theme-border); padding: 8px; margin-bottom: 16px; border-radius: 10px">
|
||||
<AstrBotConfig
|
||||
:metadata="metadata[key]['metadata']" :iterable="config_data[key2]" :metadataKey="key2">
|
||||
</AstrBotConfig>
|
||||
|
||||
@@ -7,7 +7,7 @@ import axios from 'axios';
|
||||
<template>
|
||||
<div style="height: 100%;">
|
||||
<div
|
||||
style="background-color: white; padding: 8px; padding-left: 16px; border-radius: 8px; margin-bottom: 16px; display: flex; flex-direction: row; align-items: center; justify-content: space-between;">
|
||||
style="background-color: var(--v-theme-surface); padding: 8px; padding-left: 16px; border-radius: 8px; margin-bottom: 16px; display: flex; flex-direction: row; align-items: center; justify-content: space-between;">
|
||||
<h4>控制台</h4>
|
||||
<div class="d-flex align-center">
|
||||
<v-switch
|
||||
|
||||
@@ -19,18 +19,15 @@ import 'highlight.js/styles/github.css';
|
||||
|
||||
<v-col cols="12" md="12">
|
||||
<v-card>
|
||||
|
||||
<v-card-title>
|
||||
<div class="pl-2 pt-2 d-flex align-center pe-2">
|
||||
<h2>✨ 插件市场</h2>
|
||||
<v-btn icon size="small" style="margin-left: 8px" variant="plain" @click="jumpToPluginMarket()">
|
||||
<v-icon size="small">mdi-help</v-icon>
|
||||
<v-tooltip activator="parent" location="start">
|
||||
<v-tooltip activator="parent" location="start" max-width="500" open-delay="500">
|
||||
<span>
|
||||
如无法显示,请单击此按钮跳转至插件市场,复制想安装插件对应的
|
||||
`repo`
|
||||
链接然后点击右下角 + 号安装,或打开链接下载压缩包安装。
|
||||
|
||||
repo链接然后点击右下角 + 号安装,或打开链接下载压缩包安装。<br/>
|
||||
如果因为网络问题安装失败,点击设置页选择 GitHub 加速地址。或前往仓库下载压缩包然后本地上传。
|
||||
</span>
|
||||
</v-tooltip>
|
||||
@@ -41,24 +38,27 @@ import 'highlight.js/styles/github.css';
|
||||
<v-icon>{{ isListView ? 'mdi-view-grid' : 'mdi-view-list' }}</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
<v-spacer/>
|
||||
|
||||
<v-text-field v-model="marketSearch" density="compact" label="Search"
|
||||
prepend-inner-icon="mdi-magnify" variant="solo-filled" flat hide-details
|
||||
single-line></v-text-field>
|
||||
</div>
|
||||
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
|
||||
<small style="color: #bbb;">每个插件都是作者无偿提供的的劳动成果。如果您喜欢某个插件,请 Star!</small>
|
||||
<small style="color: var(--v-theme-secondaryText);">每个插件都是作者无偿提供的的劳动成果。如果您喜欢某个插件,请 Star!</small>
|
||||
<div v-if="pinnedPlugins.length > 0" class="mt-4">
|
||||
<h2>🥳 推荐</h2>
|
||||
|
||||
<v-row style="margin-top: 8px;">
|
||||
<v-col cols="12" md="6" lg="6" v-for="plugin in pinnedPlugins">
|
||||
<ExtensionCard :extension="plugin" market-mode="true" :highlight="true" @install="extension_url=plugin.repo; newExtension()">
|
||||
<ExtensionCard :extension="plugin" class="h-120 rounded-lg"
|
||||
market-mode="true" :highlight="true"
|
||||
@install="extension_url=plugin.repo;
|
||||
newExtension()"
|
||||
@view-readme="open(plugin.repo)">
|
||||
</ExtensionCard>
|
||||
</v-col>
|
||||
</v-row>
|
||||
@@ -68,19 +68,25 @@ import 'highlight.js/styles/github.css';
|
||||
|
||||
<div v-if="isListView" class="mt-4">
|
||||
<h2>📦 全部插件</h2>
|
||||
<v-switch
|
||||
v-model="showPluginFullName"
|
||||
label="显示完整名称"
|
||||
hide-details
|
||||
density="compact"
|
||||
style="margin-left: 12px"
|
||||
/>
|
||||
<v-col cols="12" md="12" style="padding: 0px;">
|
||||
<v-data-table :headers="pluginMarketHeaders" :items="pluginMarketData" item-key="name"
|
||||
:loading="loading_" v-model:search="marketSearch" :filter-keys="filterKeys">
|
||||
<template v-slot:item.name="{ item }">
|
||||
<div class="d-flex align-center" style="overflow-x: scroll;">
|
||||
<div class="d-flex align-center" style="overflow-x: auto; scrollbar-width: thin; scrollbar-track-color: transparent;">
|
||||
<img v-if="item.logo" :src="item.logo"
|
||||
style="height: 80px; width: 80px; margin-right: 8px; border-radius: 8px; margin-top: 8px; margin-bottom: 8px;"
|
||||
alt="logo">
|
||||
<span v-if="item?.repo"><a :href="item?.repo"
|
||||
style="color: #000; text-decoration:none">{{
|
||||
item.name }}</a></span>
|
||||
<span v-else>{{ item.name }}</span>
|
||||
|
||||
style="color: var(--v-theme-primaryText, #000); text-decoration:none">{{
|
||||
showPluginFullName ? item.name : item.trimmedName }}</a></span>
|
||||
<span v-else>{{ showPluginFullName ? item.name : item.trimmedName }}</span>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
@@ -107,18 +113,18 @@ import 'highlight.js/styles/github.css';
|
||||
</template>
|
||||
|
||||
<template v-slot:item.tags="{ item }">
|
||||
<span v-if="item.tags.length === 0">无</span>
|
||||
<v-chip v-for="tag in item.tags" :key="tag" color="primary" size="x-small">{{ tag
|
||||
}}</v-chip>
|
||||
<span v-if="item.tags.length === 0">-</span>
|
||||
<v-chip v-for="tag in item.tags" :key="tag" color="primary" size="x-small">
|
||||
{{ tag }}</v-chip>
|
||||
</template>
|
||||
<template v-slot:item.actions="{ item }">
|
||||
<v-btn v-if="!item.installed" class="text-none mr-2" size="x-small"
|
||||
variant="flat" border
|
||||
@click="extension_url = item.repo; newExtension()">安装</v-btn>
|
||||
<v-btn v-if="!item.installed" class="text-none mr-2" size="x-small"
|
||||
variant="flat" @click="extension_url = item.repo; newExtension()">
|
||||
<v-icon>mdi-download</v-icon></v-btn>
|
||||
<v-btn v-else class="text-none mr-2" size="x-small" variant="flat" border
|
||||
disabled>已安装</v-btn>
|
||||
<v-btn class="text-none mr-2" size="x-small" variant="flat" border
|
||||
@click="open(item.repo)">帮助</v-btn>
|
||||
disabled><v-icon>mdi-check</v-icon></v-btn>
|
||||
<v-btn class="text-none mr-2" size="x-small" variant="flat" border
|
||||
@click="open(item.repo)"><v-icon>mdi-help</v-icon></v-btn>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-col>
|
||||
@@ -261,6 +267,7 @@ export default {
|
||||
loading_: false,
|
||||
upload_file: null,
|
||||
pluginMarketData: [],
|
||||
showPluginFullName: false,
|
||||
loadingDialog: {
|
||||
show: false,
|
||||
title: "加载中...",
|
||||
@@ -279,8 +286,8 @@ export default {
|
||||
pluginMarketHeaders: [
|
||||
{ title: '名称', key: 'name', maxWidth: '200px' },
|
||||
{ title: '描述', key: 'desc', maxWidth: '250px' },
|
||||
{ title: '作者', key: 'author', maxWidth: '70px' },
|
||||
{ title: 'Star数', key: 'stars', maxWidth: '100px' },
|
||||
{ title: '作者', key: 'author', maxWidth: '90px' },
|
||||
{ title: 'Star数', key: 'stars', maxWidth: '80px' },
|
||||
{ title: '最近更新', key: 'updated_at', maxWidth: '100px' },
|
||||
{ title: '标签', key: 'tags', maxWidth: '100px' },
|
||||
{ title: '操作', key: 'actions', sortable: false }
|
||||
@@ -315,6 +322,7 @@ export default {
|
||||
this.loading_ = true
|
||||
this.commonStore.getPluginCollections().then((data) => {
|
||||
this.pluginMarketData = data;
|
||||
this.trimExtensionName();
|
||||
this.checkAlreadyInstalled();
|
||||
this.checkUpdate();
|
||||
this.loading_ = false
|
||||
@@ -363,11 +371,23 @@ export default {
|
||||
getExtensions() {
|
||||
axios.get('/api/plugin/get').then((res) => {
|
||||
this.extension_data = res.data;
|
||||
this.trimExtensionName();
|
||||
this.checkAlreadyInstalled();
|
||||
this.checkUpdate()
|
||||
});
|
||||
},
|
||||
|
||||
trimExtensionName() {
|
||||
this.pluginMarketData.forEach(plugin => {
|
||||
if (plugin.name) {
|
||||
let name = plugin.name.trim().toLowerCase();
|
||||
if (name.startsWith("astrbot_plugin_")) {
|
||||
plugin.trimmedName = name.substring(15);
|
||||
} else if (name.startsWith("astrbot_") || name.startsWith("astrbot-")) {
|
||||
plugin.trimmedName = name.substring(8);
|
||||
} else plugin.trimmedName = plugin.name;
|
||||
}
|
||||
});
|
||||
},
|
||||
checkUpdate() {
|
||||
// 创建在线插件的map
|
||||
const onlinePluginsMap = new Map();
|
||||
@@ -565,7 +585,7 @@ export default {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
padding: 8px 0;
|
||||
color: #24292e;
|
||||
color: var(--v-theme-secondaryText);
|
||||
}
|
||||
|
||||
.markdown-body h1,
|
||||
@@ -582,13 +602,13 @@ export default {
|
||||
|
||||
.markdown-body h1 {
|
||||
font-size: 2em;
|
||||
border-bottom: 1px solid #eaecef;
|
||||
border-bottom: 1px solid var(--v-theme-border);
|
||||
padding-bottom: 0.3em;
|
||||
}
|
||||
|
||||
.markdown-body h2 {
|
||||
font-size: 1.5em;
|
||||
border-bottom: 1px solid #eaecef;
|
||||
border-bottom: 1px solid var(--v-theme-border);
|
||||
padding-bottom: 0.3em;
|
||||
}
|
||||
|
||||
@@ -600,7 +620,7 @@ export default {
|
||||
.markdown-body code {
|
||||
padding: 0.2em 0.4em;
|
||||
margin: 0;
|
||||
background-color: rgba(27, 31, 35, 0.05);
|
||||
background-color: var(--v-theme-codeBg);
|
||||
border-radius: 3px;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 85%;
|
||||
@@ -611,7 +631,7 @@ export default {
|
||||
overflow: auto;
|
||||
font-size: 85%;
|
||||
line-height: 1.45;
|
||||
background-color: #f6f8fa;
|
||||
background-color: var(--v-theme-containerBg);
|
||||
border-radius: 3px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@@ -631,19 +651,19 @@ export default {
|
||||
max-width: 100%;
|
||||
margin: 8px 0;
|
||||
box-sizing: border-box;
|
||||
background-color: #fff;
|
||||
background-color: var(--v-theme-background);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.markdown-body blockquote {
|
||||
padding: 0 1em;
|
||||
color: #6a737d;
|
||||
border-left: 0.25em solid #dfe2e5;
|
||||
color: var(--v-theme-secondaryText);
|
||||
border-left: 0.25em solid var(--v-theme-border);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markdown-body a {
|
||||
color: #0366d6;
|
||||
color: var(--v-theme-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -662,23 +682,23 @@ export default {
|
||||
.markdown-body table th,
|
||||
.markdown-body table td {
|
||||
padding: 6px 13px;
|
||||
border: 1px solid #dfe2e5;
|
||||
border: 1px solid var(--v-theme-background);
|
||||
}
|
||||
|
||||
.markdown-body table tr {
|
||||
background-color: #fff;
|
||||
border-top: 1px solid #c6cbd1;
|
||||
background-color: var(--v-theme-surface);
|
||||
border-top: 1px solid var(--v-theme-border);
|
||||
}
|
||||
|
||||
.markdown-body table tr:nth-child(2n) {
|
||||
background-color: #f6f8fa;
|
||||
background-color: var(--v-theme-background);
|
||||
}
|
||||
|
||||
.markdown-body hr {
|
||||
height: 0.25em;
|
||||
padding: 0;
|
||||
margin: 24px 0;
|
||||
background-color: #e1e4e8;
|
||||
background-color: var(--v-theme-containerBg);
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -191,6 +191,17 @@ const updateExtension = async (extension_name) => {
|
||||
|
||||
Object.assign(extension_data, res.data);
|
||||
onLoadingDialogResult(1, res.data.message);
|
||||
setTimeout(async () => {
|
||||
toast(`正在刷新插件列表...`, "info", 2000);
|
||||
try {
|
||||
await getExtensions();
|
||||
toast("插件列表已刷新!", "success");
|
||||
|
||||
} catch (error) {
|
||||
const errorMsg = error.response?.data?.message || error.message || String(error);
|
||||
toast(`刷新插件列表时发生错误: ${errorMsg}`, "error");
|
||||
}
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
toast(err, "error");
|
||||
}
|
||||
@@ -378,6 +389,13 @@ const toggleAllPluginsForPlatform = (platformName) => {
|
||||
onMounted(async () => {
|
||||
await getExtensions();
|
||||
|
||||
// 检查是否有 open_config 参数
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const plugin_name = urlParams.get('open_config');
|
||||
if (plugin_name) {
|
||||
openExtensionConfig(plugin_name);
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await commonStore.getPluginCollections();
|
||||
pluginMarketData.value = data;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<v-icon size="x-large" color="primary" class="me-2">mdi-creation</v-icon>服务提供商管理
|
||||
</h1>
|
||||
<p class="text-subtitle-1 text-medium-emphasis mb-4">
|
||||
管理AI服务提供商,连接到不同的大语言模型
|
||||
管理模型服务提供商
|
||||
</p>
|
||||
</v-col>
|
||||
</v-row>
|
||||
@@ -20,6 +20,9 @@
|
||||
<span class="text-h6">服务提供商</span>
|
||||
<v-chip color="info" size="small" class="ml-2">{{ config_data.provider?.length || 0 }}</v-chip>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="success" prepend-icon="mdi-cog" variant="tonal" class="me-2" @click="showSettingsDialog = true">
|
||||
设置
|
||||
</v-btn>
|
||||
<v-btn color="primary" prepend-icon="mdi-plus" variant="tonal" @click="showAddProviderDialog = true">
|
||||
新增服务提供商
|
||||
</v-btn>
|
||||
@@ -27,13 +30,39 @@
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<!-- 添加分类标签页 -->
|
||||
<v-card-text class="px-4 pt-3 pb-0">
|
||||
<v-tabs v-model="activeProviderTypeTab" bg-color="transparent">
|
||||
<v-tab value="all" class="font-weight-medium px-3">
|
||||
<v-icon start>mdi-filter-variant</v-icon>
|
||||
全部
|
||||
</v-tab>
|
||||
<v-tab value="chat_completion" class="font-weight-medium px-3">
|
||||
<v-icon start>mdi-message-text</v-icon>
|
||||
基本对话
|
||||
</v-tab>
|
||||
<v-tab value="speech_to_text" class="font-weight-medium px-3">
|
||||
<v-icon start>mdi-microphone-message</v-icon>
|
||||
语音转文字
|
||||
</v-tab>
|
||||
<v-tab value="text_to_speech" class="font-weight-medium px-3">
|
||||
<v-icon start>mdi-volume-high</v-icon>
|
||||
文字转语音
|
||||
</v-tab>
|
||||
<v-tab value="embedding" class="font-weight-medium px-3">
|
||||
<v-icon start>mdi-code-json</v-icon>
|
||||
Embedding
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-text class="px-4 py-3">
|
||||
<item-card-grid
|
||||
:items="config_data.provider || []"
|
||||
:items="filteredProviders"
|
||||
title-field="id"
|
||||
enabled-field="enable"
|
||||
empty-icon="mdi-api-off"
|
||||
empty-text="暂无服务提供商,点击 新增服务提供商 添加"
|
||||
:empty-text="getEmptyText()"
|
||||
@toggle-enabled="providerStatusChange"
|
||||
@delete="deleteProvider"
|
||||
@edit="configExistingProvider"
|
||||
@@ -61,6 +90,51 @@
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- 供应商状态部分 -->
|
||||
<v-card class="mb-6" elevation="2">
|
||||
<v-card-title class="d-flex align-center py-3 px-4">
|
||||
<v-icon color="primary" class="me-2">mdi-heart-pulse</v-icon>
|
||||
<span class="text-h6">供应商可用性</span>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="primary" variant="tonal" :loading="loadingStatus" @click="fetchProviderStatus">
|
||||
<v-icon left>mdi-refresh</v-icon>
|
||||
刷新状态
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
<v-card-subtitle class="px-4 py-1 text-caption text-medium-emphasis">
|
||||
通过测试模型对话可用性判断,可能产生API费用
|
||||
</v-card-subtitle>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-text class="px-4 py-3">
|
||||
<v-alert v-if="providerStatuses.length === 0" type="info" variant="tonal">
|
||||
点击"刷新状态"按钮获取供应商可用性
|
||||
</v-alert>
|
||||
|
||||
<v-container v-else class="pa-0">
|
||||
<v-row>
|
||||
<v-col v-for="status in providerStatuses" :key="status.id" cols="12" sm="6" md="4">
|
||||
<v-card variant="outlined" class="status-card">
|
||||
<v-card-item>
|
||||
<v-icon :color="status.status === 'available' ? 'success' : 'error'" class="me-2">
|
||||
{{ status.status === 'available' ? 'mdi-check-circle' : 'mdi-alert-circle' }}
|
||||
</v-icon>
|
||||
<span class="font-weight-bold">{{ status.id }}</span>
|
||||
<v-chip :color="status.status === 'available' ? 'success' : 'error'" size="small" class="ml-2">
|
||||
{{ status.status === 'available' ? '可用' : '不可用' }}
|
||||
</v-chip>
|
||||
</v-card-item>
|
||||
<v-card-text v-if="status.status === 'unavailable'" class="text-caption text-medium-emphasis">
|
||||
<span class="font-weight-bold">错误信息:</span> {{ status.error }}
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- 日志部分 -->
|
||||
<v-card elevation="2">
|
||||
<v-card-title class="d-flex align-center py-3 px-4">
|
||||
@@ -109,10 +183,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">
|
||||
@@ -178,6 +256,49 @@
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 设置对话框 -->
|
||||
<v-dialog v-model="showSettingsDialog" max-width="600px">
|
||||
<v-card>
|
||||
<v-card-title class="bg-primary text-white py-3 px-4" style="display: flex; align-items: center;">
|
||||
<v-icon color="white" class="me-2">mdi-cog</v-icon>
|
||||
<span>服务提供商设置</span>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn icon variant="text" color="white" @click="showSettingsDialog = false">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pa-4">
|
||||
<v-list>
|
||||
<v-list-item>
|
||||
<v-switch
|
||||
style="padding: 12px;"
|
||||
v-model="sessionSeparationEnabled"
|
||||
color="primary"
|
||||
:loading="sessionSettingLoading"
|
||||
@change="updateSessionSeparation"
|
||||
hide-details
|
||||
>
|
||||
<template v-slot:label>
|
||||
<div>
|
||||
<div class="text-subtitle-1">启用提供商会话隔离</div>
|
||||
<div class="text-caption text-medium-emphasis">不同会话将可独立选择文本生成、TTS、STT 等服务提供商。</div>
|
||||
</div>
|
||||
</template>
|
||||
</v-switch>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn variant="text" @click="showSettingsDialog = false">
|
||||
关闭
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 消息提示 -->
|
||||
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack"
|
||||
location="top">
|
||||
@@ -210,6 +331,11 @@ export default {
|
||||
metadata: {},
|
||||
showProviderCfg: false,
|
||||
|
||||
// 设置对话框相关
|
||||
showSettingsDialog: false,
|
||||
sessionSeparationEnabled: false,
|
||||
sessionSettingLoading: false,
|
||||
|
||||
newSelectedProviderName: '',
|
||||
newSelectedProviderConfig: {},
|
||||
updatingMode: false,
|
||||
@@ -221,15 +347,65 @@ export default {
|
||||
save_message_success: "success",
|
||||
|
||||
showConsole: false,
|
||||
|
||||
// 供应商状态相关
|
||||
providerStatuses: [],
|
||||
loadingStatus: false,
|
||||
|
||||
// 新增提供商对话框相关
|
||||
showAddProviderDialog: false,
|
||||
activeProviderTab: 'chat_completion',
|
||||
|
||||
// 添加提供商类型分类
|
||||
activeProviderTypeTab: 'all',
|
||||
|
||||
// 兼容旧版本(< v3.5.11)的 mapping,用于映射到对应的提供商能力类型
|
||||
oldVersionProviderTypeMapping: {
|
||||
"openai_chat_completion": "chat_completion",
|
||||
"anthropic_chat_completion": "chat_completion",
|
||||
"googlegenai_chat_completion": "chat_completion",
|
||||
"zhipu_chat_completion": "chat_completion",
|
||||
"llm_tuner": "chat_completion",
|
||||
"dify": "chat_completion",
|
||||
"dashscope": "chat_completion",
|
||||
"openai_whisper_api": "speech_to_text",
|
||||
"openai_whisper_selfhost": "speech_to_text",
|
||||
"sensevoice_stt_selfhost": "speech_to_text",
|
||||
"openai_tts_api": "text_to_speech",
|
||||
"edge_tts": "text_to_speech",
|
||||
"gsvi_tts_api": "text_to_speech",
|
||||
"fishaudio_tts_api": "text_to_speech",
|
||||
"dashscope_tts": "text_to_speech",
|
||||
"azure_tts": "text_to_speech",
|
||||
"minimax_tts_api": "text_to_speech",
|
||||
"volcengine_tts": "text_to_speech",
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
// 根据选择的标签过滤提供商列表
|
||||
filteredProviders() {
|
||||
if (!this.config_data.provider || this.activeProviderTypeTab === 'all') {
|
||||
return this.config_data.provider || [];
|
||||
}
|
||||
|
||||
return this.config_data.provider.filter(provider => {
|
||||
// 如果provider.provider_type已经存在,直接使用它
|
||||
if (provider.provider_type) {
|
||||
return provider.provider_type === this.activeProviderTypeTab;
|
||||
}
|
||||
|
||||
// 否则使用映射关系
|
||||
const mappedType = this.oldVersionProviderTypeMapping[provider.type];
|
||||
return mappedType === this.activeProviderTypeTab;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.getConfig();
|
||||
this.getSessionSeparationStatus();
|
||||
},
|
||||
|
||||
methods: {
|
||||
@@ -243,6 +419,15 @@ export default {
|
||||
});
|
||||
},
|
||||
|
||||
// 获取空列表文本
|
||||
getEmptyText() {
|
||||
if (this.activeProviderTypeTab === 'all') {
|
||||
return "暂无服务提供商,点击 新增服务提供商 添加";
|
||||
} else {
|
||||
return `暂无${this.getTabTypeName(this.activeProviderTypeTab)}类型的服务提供商,点击 新增服务提供商 添加`;
|
||||
}
|
||||
},
|
||||
|
||||
// 按提供商类型获取模板列表
|
||||
getTemplatesByType(type) {
|
||||
const templates = this.metadata['provider_group']?.metadata?.provider?.config_template || {};
|
||||
@@ -294,7 +479,8 @@ export default {
|
||||
const names = {
|
||||
'chat_completion': '基本对话',
|
||||
'speech_to_text': '语音转文本',
|
||||
'text_to_speech': '文本转语音'
|
||||
'text_to_speech': '文本转语音',
|
||||
'embedding': 'Embedding'
|
||||
};
|
||||
return names[tabType] || tabType;
|
||||
},
|
||||
@@ -432,6 +618,32 @@ export default {
|
||||
});
|
||||
},
|
||||
|
||||
// 获取会话隔离配置状态
|
||||
getSessionSeparationStatus() {
|
||||
axios.get('/api/config/provider/get_session_seperate').then((res) => {
|
||||
if (res.data && res.data.status === 'ok') {
|
||||
this.sessionSeparationEnabled = res.data.data.enable;
|
||||
}
|
||||
}).catch((err) => {
|
||||
this.showError(err.response?.data?.message || "获取会话隔离配置失败");
|
||||
});
|
||||
},
|
||||
|
||||
// 更新会话隔离配置
|
||||
updateSessionSeparation() {
|
||||
this.sessionSettingLoading = true;
|
||||
axios.post('/api/config/provider/set_session_seperate', {
|
||||
enable: this.sessionSeparationEnabled
|
||||
}).then((res) => {
|
||||
this.showSuccess(res.data.message || "会话隔离设置已更新");
|
||||
this.sessionSettingLoading = false;
|
||||
}).catch((err) => {
|
||||
this.sessionSeparationEnabled = !this.sessionSeparationEnabled; // 发生错误时回滚状态
|
||||
this.showError(err.response?.data?.message || err.message);
|
||||
this.sessionSettingLoading = false;
|
||||
});
|
||||
},
|
||||
|
||||
showSuccess(message) {
|
||||
this.save_message = message;
|
||||
this.save_message_success = "success";
|
||||
@@ -442,6 +654,22 @@ export default {
|
||||
this.save_message = message;
|
||||
this.save_message_success = "error";
|
||||
this.save_message_snack = true;
|
||||
},
|
||||
|
||||
// 获取供应商状态
|
||||
fetchProviderStatus() {
|
||||
this.loadingStatus = true;
|
||||
axios.get('/api/config/provider/check_status').then((res) => {
|
||||
if (res.data && res.data.status === 'ok') {
|
||||
this.providerStatuses = res.data.data || [];
|
||||
} else {
|
||||
this.showError(res.data?.message || "获取供应商状态失败");
|
||||
}
|
||||
this.loadingStatus = false;
|
||||
}).catch((err) => {
|
||||
this.loadingStatus = false;
|
||||
this.showError(err.response?.data?.message || err.message);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
|
||||
<div style="background-color: white; padding: 8px; padding-left: 16px; border-radius: 8px; margin-bottom: 16px;">
|
||||
<div style="background-color: var(--v-theme-surface, #fff); padding: 8px; padding-left: 16px; border-radius: 8px; margin-bottom: 16px;">
|
||||
|
||||
<v-list lines="two">
|
||||
<v-list-subheader>网络</v-list-subheader>
|
||||
|
||||
@@ -4,11 +4,15 @@
|
||||
<!-- knowledge card -->
|
||||
<div v-if="!installed" class="d-flex align-center justify-center flex-column"
|
||||
style="flex-grow: 1; width: 100%; height: 100%;">
|
||||
<h2>还没有安装知识库插件</h2>
|
||||
<v-btn style="margin-top: 16px;" variant="tonal" color="primary"
|
||||
@click="installPlugin" :loading="installing">
|
||||
<h2>还没有安装知识库插件
|
||||
<v-icon v-class="ml - 2" size="small" color="grey"
|
||||
@click="openUrl('https://astrbot.app/use/knowledge-base.html')">mdi-information-outline</v-icon>
|
||||
</h2>
|
||||
<v-btn style="margin-top: 16px;" variant="tonal" color="primary" @click="installPlugin"
|
||||
:loading="installing">
|
||||
立即安装
|
||||
</v-btn>
|
||||
<ConsoleDisplayer v-show="installing" style="background-color: #fff; max-height: 300px; margin-top: 16px; max-width: 100%" :show-level-btns="false"></ConsoleDisplayer>
|
||||
</div>
|
||||
<div v-else-if="kbCollections.length == 0" class="d-flex align-center justify-center flex-column"
|
||||
style="flex-grow: 1; width: 100%; height: 100%;">
|
||||
@@ -18,10 +22,17 @@
|
||||
</v-btn>
|
||||
</div>
|
||||
<div v-else>
|
||||
<h2 class="mb-4">知识库列表</h2>
|
||||
<h2 class="mb-4">知识库列表
|
||||
<v-icon v-class="ml - 2" size="x-small" color="grey"
|
||||
@click="openUrl('https://astrbot.app/use/knowledge-base.html')">mdi-information-outline</v-icon>
|
||||
</h2>
|
||||
<v-btn class="mb-4" prepend-icon="mdi-plus" variant="tonal" color="primary"
|
||||
@click="showCreateDialog = true">
|
||||
创建新知识库
|
||||
创建知识库
|
||||
</v-btn>
|
||||
<v-btn class="mb-4 ml-4" prepend-icon="mdi-cog" variant="tonal" color="success"
|
||||
@click="$router.push('/extension?open_config=astrbot_plugin_knowledge_base')">
|
||||
配置
|
||||
</v-btn>
|
||||
|
||||
<div class="kb-grid">
|
||||
@@ -45,9 +56,9 @@
|
||||
<div style="padding: 16px; text-align: center;">
|
||||
<small style="color: #a3a3a3">Tips: 在聊天页面通过 /kb 指令了解如何使用!</small>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 创建知识库对话框 -->
|
||||
@@ -68,6 +79,12 @@
|
||||
|
||||
<v-textarea v-model="newKB.description" label="描述" variant="outlined" placeholder="知识库的简短描述..."
|
||||
rows="3"></v-textarea>
|
||||
|
||||
<v-select v-model="newKB.embedding_provider_id" :items="embeddingProviderConfigs"
|
||||
:item-props="embeddingModelProps" label="Embedding(嵌入)模型" variant="outlined" class="mt-2">
|
||||
</v-select>
|
||||
|
||||
<small>Tips: 一旦选择了一个知识库的嵌入模型,请不要再修改该提供商的模型或者向量维度信息,否则将严重影响该知识库的召回率甚至报错。</small>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
@@ -114,6 +131,18 @@
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<div v-if="currentKB._embedding_provider_config" class="px-6 py-2">
|
||||
<v-chip class="mr-2" color="primary" variant="tonal" size="small" rounded="sm">
|
||||
<v-icon start size="small">mdi-database</v-icon>
|
||||
嵌入模型: {{ currentKB._embedding_provider_config.embedding_model }}
|
||||
</v-chip>
|
||||
<v-chip color="secondary" variant="tonal" size="small" rounded="sm">
|
||||
<v-icon start size="small">mdi-vector-point</v-icon>
|
||||
向量维度: {{ currentKB._embedding_provider_config.embedding_dimensions }}
|
||||
</v-chip>
|
||||
<small style="margin-left: 8px;">💡 使用方式: 在聊天页中输入 “/kb use {{ currentKB.collection_name }}”</small>
|
||||
</div>
|
||||
|
||||
<v-card-text>
|
||||
<v-tabs v-model="activeTab">
|
||||
<v-tab value="upload">上传文件</v-tab>
|
||||
@@ -136,6 +165,38 @@
|
||||
<p class="mt-2">拖放文件到这里或点击上传</p>
|
||||
</div>
|
||||
|
||||
<!-- 优化后的分片长度和重叠长度设置 -->
|
||||
<v-card class="mt-4 chunk-settings-card" variant="outlined" color="grey-lighten-4">
|
||||
<v-card-title class="pa-4 pb-0 d-flex align-center">
|
||||
<v-icon color="primary" class="mr-2">mdi-puzzle-outline</v-icon>
|
||||
<span class="text-subtitle-1 font-weight-bold">分片设置</span>
|
||||
<v-tooltip location="top">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-icon v-bind="props" class="ml-2" size="small" color="grey">
|
||||
mdi-information-outline
|
||||
</v-icon>
|
||||
</template>
|
||||
<span>
|
||||
分片长度决定每块文本的大小,重叠长度决定相邻文本块之间的重叠程度。<br>
|
||||
较小的分片更精确但会增加数量,适当的重叠可提高检索准确性。
|
||||
</span>
|
||||
</v-tooltip>
|
||||
</v-card-title>
|
||||
<v-card-text class="pa-4 pt-2">
|
||||
<div class="d-flex flex-wrap" style="gap: 8px">
|
||||
<v-text-field v-model="chunkSize" label="分片长度" type="number"
|
||||
hint="控制每个文本块大小,留空使用默认值" persistent-hint variant="outlined"
|
||||
density="comfortable" class="flex-grow-1 chunk-field"
|
||||
prepend-inner-icon="mdi-text-box-outline" min="50"></v-text-field>
|
||||
|
||||
<v-text-field v-model="overlap" label="重叠长度" type="number"
|
||||
hint="控制相邻文本块重叠度,留空使用默认值" persistent-hint variant="outlined"
|
||||
density="comfortable" class="flex-grow-1 chunk-field"
|
||||
prepend-inner-icon="mdi-vector-intersection" min="0"></v-text-field>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<div class="selected-files mt-4" v-if="selectedFile">
|
||||
<div type="info" variant="tonal" class="d-flex align-center">
|
||||
<div>
|
||||
@@ -239,9 +300,13 @@
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue';
|
||||
|
||||
export default {
|
||||
name: 'KnowledgeBase',
|
||||
components: {
|
||||
ConsoleDisplayer,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
installed: true,
|
||||
@@ -252,7 +317,8 @@ export default {
|
||||
newKB: {
|
||||
name: '',
|
||||
emoji: '🙂',
|
||||
description: ''
|
||||
description: '',
|
||||
embedding_provider_id: ''
|
||||
},
|
||||
snackbar: {
|
||||
show: false,
|
||||
@@ -292,6 +358,8 @@ export default {
|
||||
},
|
||||
activeTab: 'upload',
|
||||
selectedFile: null,
|
||||
chunkSize: null,
|
||||
overlap: null,
|
||||
uploading: false,
|
||||
searchQuery: '',
|
||||
searchResults: [],
|
||||
@@ -302,13 +370,21 @@ export default {
|
||||
deleteTarget: {
|
||||
collection_name: ''
|
||||
},
|
||||
deleting: false
|
||||
deleting: false,
|
||||
embeddingProviderConfigs: []
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.checkPlugin();
|
||||
this.getEmbeddingProviderList();
|
||||
},
|
||||
methods: {
|
||||
embeddingModelProps(providerConfig) {
|
||||
return {
|
||||
title: providerConfig.embedding_model,
|
||||
subtitle: `提供商 ID: ${providerConfig.id} | 嵌入模型维度: ${providerConfig.embedding_dimensions}`,
|
||||
}
|
||||
},
|
||||
checkPlugin() {
|
||||
axios.get('/api/plugin/get?name=astrbot_plugin_knowledge_base')
|
||||
.then(response => {
|
||||
@@ -331,7 +407,7 @@ export default {
|
||||
installPlugin() {
|
||||
this.installing = true;
|
||||
axios.post('/api/plugin/install', {
|
||||
url: "https://github.com/soulter/astrbot_plugin_knowledge_base",
|
||||
url: "https://github.com/lxfight/astrbot_plugin_knowledge_base",
|
||||
proxy: localStorage.getItem('selectedGitHubProxy') || ""
|
||||
})
|
||||
.then(response => {
|
||||
@@ -361,10 +437,15 @@ export default {
|
||||
},
|
||||
|
||||
createCollection(name, emoji, description) {
|
||||
// 如果 this.newKB.embedding_provider_id 是 Object
|
||||
if (typeof this.newKB.embedding_provider_id === 'object') {
|
||||
this.newKB.embedding_provider_id = this.newKB.embedding_provider_id.id || '';
|
||||
}
|
||||
axios.post('/api/plug/alkaid/kb/create_collection', {
|
||||
collection_name: name,
|
||||
emoji: emoji,
|
||||
description: description
|
||||
description: description,
|
||||
embedding_provider_id: this.newKB.embedding_provider_id || ''
|
||||
})
|
||||
.then(response => {
|
||||
if (response.data.status === 'ok') {
|
||||
@@ -390,7 +471,8 @@ export default {
|
||||
this.createCollection(
|
||||
this.newKB.name,
|
||||
this.newKB.emoji || '🙂',
|
||||
this.newKB.description
|
||||
this.newKB.description,
|
||||
this.newKB.embedding_provider_id || ''
|
||||
);
|
||||
},
|
||||
|
||||
@@ -398,7 +480,8 @@ export default {
|
||||
this.newKB = {
|
||||
name: '',
|
||||
emoji: '🙂',
|
||||
description: ''
|
||||
description: '',
|
||||
embedding_provider: ''
|
||||
};
|
||||
},
|
||||
|
||||
@@ -415,6 +498,9 @@ export default {
|
||||
this.searchQuery = '';
|
||||
this.searchResults = [];
|
||||
this.searchPerformed = false;
|
||||
// 重置分片长度和重叠长度参数
|
||||
this.chunkSize = null;
|
||||
this.overlap = null;
|
||||
},
|
||||
|
||||
triggerFileInput() {
|
||||
@@ -469,6 +555,15 @@ export default {
|
||||
formData.append('file', this.selectedFile);
|
||||
formData.append('collection_name', this.currentKB.collection_name);
|
||||
|
||||
// 添加可选的分片长度和重叠长度参数
|
||||
if (this.chunkSize && this.chunkSize > 0) {
|
||||
formData.append('chunk_size', this.chunkSize);
|
||||
}
|
||||
|
||||
if (this.overlap && this.overlap >= 0) {
|
||||
formData.append('chunk_overlap', this.overlap);
|
||||
}
|
||||
|
||||
axios.post('/api/plug/alkaid/kb/collection/add_file', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
@@ -476,7 +571,7 @@ export default {
|
||||
})
|
||||
.then(response => {
|
||||
if (response.data.status === 'ok') {
|
||||
this.showSnackbar('文件上传成功');
|
||||
this.showSnackbar('操作成功: ' + response.data.message);
|
||||
this.selectedFile = null;
|
||||
|
||||
// 刷新知识库列表,获取更新的数量
|
||||
@@ -578,6 +673,31 @@ export default {
|
||||
this.deleting = false;
|
||||
});
|
||||
},
|
||||
|
||||
getEmbeddingProviderList() {
|
||||
axios.get('/api/config/provider/list', {
|
||||
params: {
|
||||
provider_type: 'embedding'
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.data.status === 'ok') {
|
||||
this.embeddingProviderConfigs = response.data.data || [];
|
||||
} else {
|
||||
this.showSnackbar(response.data.message || '获取嵌入模型列表失败', 'error');
|
||||
return [];
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching embedding providers:', error);
|
||||
this.showSnackbar('获取嵌入模型列表失败', 'error');
|
||||
return [];
|
||||
});
|
||||
},
|
||||
|
||||
openUrl(url) {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -597,7 +717,7 @@ export default {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
background-color: #ffffff;
|
||||
background-color: var(--v-theme-background);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
@@ -630,7 +750,7 @@ export default {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #ffffff;
|
||||
background-color: var(--v-theme-background);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 16px;
|
||||
@@ -747,4 +867,28 @@ export default {
|
||||
.kb-card:hover .kb-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.chunk-settings-card {
|
||||
border: 1px solid rgba(92, 107, 192, 0.2) !important;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.chunk-settings-card:hover {
|
||||
border-color: rgba(92, 107, 192, 0.4) !important;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.07) !important;
|
||||
}
|
||||
|
||||
.chunk-field :deep(.v-field__input) {
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.chunk-field :deep(.v-field__prepend-inner) {
|
||||
padding-right: 8px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.chunk-field:focus-within :deep(.v-field__prepend-inner) {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<template>
|
||||
<div id="long-term-memory" class="flex-grow-1" style="display: flex; flex-direction: row; ">
|
||||
<div id="graph-container" style="flex-grow: 1; width: 100%; border: 1px solid #eee; border-radius: 8px; max-height: calc(100% - 40px);">
|
||||
<div id="graph-container"
|
||||
style="flex-grow: 1; width: 100%; border: 1px solid #eee; border-radius: 8px; max-height: calc(100% - 40px);">
|
||||
</div>
|
||||
<!-- <div id="graph-container-nonono"
|
||||
style="display: flex; justify-content: center; align-items: center; width: 100%; font-weight: 1000; font-size: 24px;">
|
||||
加速开发中...
|
||||
</div> -->
|
||||
<div id="graph-control-panel"
|
||||
style="min-width: 450px; border: 1px solid #eee; border-radius: 8px; padding: 16px; padding-bottom: 0px; margin-left: 16px; max-height: calc(100% - 40px);">
|
||||
<div>
|
||||
@@ -31,42 +36,27 @@
|
||||
<div class="mt-4">
|
||||
<h3>搜索记忆</h3>
|
||||
<v-card variant="outlined" class="mt-2 pa-3">
|
||||
<div >
|
||||
<v-text-field
|
||||
v-model="searchMemoryUserId"
|
||||
label="用户 ID"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="mb-2"
|
||||
></v-text-field>
|
||||
<v-text-field
|
||||
v-model="searchQuery"
|
||||
label="输入关键词"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
@keyup.enter="searchMemory"
|
||||
class="mb-2"
|
||||
></v-text-field>
|
||||
<div>
|
||||
<v-text-field v-model="searchMemoryUserId" label="用户 ID" variant="outlined" density="compact" hide-details
|
||||
class="mb-2"></v-text-field>
|
||||
<v-text-field v-model="searchQuery" label="输入关键词" variant="outlined" density="compact" hide-details
|
||||
@keyup.enter="searchMemory" class="mb-2"></v-text-field>
|
||||
<v-btn color="info" @click="searchMemory" :loading="isSearching" variant="tonal">
|
||||
<v-icon start>mdi-text-search</v-icon>
|
||||
搜索
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 新增搜索结果展示区域 -->
|
||||
<div v-if="searchResults.length > 0" class="mt-3">
|
||||
<v-divider class="mb-3"></v-divider>
|
||||
<div class="text-subtitle-1 mb-2">搜索结果 ({{ searchResults.length }})</div>
|
||||
<v-expansion-panels variant="accordion">
|
||||
<v-expansion-panel
|
||||
v-for="(result, index) in searchResults"
|
||||
:key="index"
|
||||
>
|
||||
<v-expansion-panel v-for="(result, index) in searchResults" :key="index">
|
||||
<v-expansion-panel-title>
|
||||
<div>
|
||||
<span class="text-truncate d-inline-block" style="max-width: 300px;">{{ result.text.substring(0, 30) }}...</span>
|
||||
<span class="text-truncate d-inline-block" style="max-width: 300px;">{{ result.text.substring(0, 30)
|
||||
}}...</span>
|
||||
<span class="ms-2 text-caption text-grey">(相关度: {{ (result.score * 100).toFixed(1) }}%)</span>
|
||||
</div>
|
||||
</v-expansion-panel-title>
|
||||
@@ -86,42 +76,21 @@
|
||||
</div>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 新增添加记忆数据的表单 -->
|
||||
<div class="mt-4">
|
||||
<h3>添加记忆数据</h3>
|
||||
<v-card variant="outlined" class="mt-2 pa-3">
|
||||
<v-form @submit.prevent="addMemoryData">
|
||||
<v-textarea
|
||||
v-model="newMemoryText"
|
||||
label="输入文本内容"
|
||||
variant="outlined"
|
||||
rows="4"
|
||||
hide-details
|
||||
class="mb-2"
|
||||
></v-textarea>
|
||||
|
||||
<v-text-field
|
||||
v-model="newMemoryUserId"
|
||||
label="用户 ID"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
></v-text-field>
|
||||
|
||||
<v-switch
|
||||
v-model="needSummarize"
|
||||
color="primary"
|
||||
label="需要摘要"
|
||||
hide-details
|
||||
></v-switch>
|
||||
|
||||
<v-btn
|
||||
color="success"
|
||||
type="submit"
|
||||
:loading="isSubmitting"
|
||||
:disabled="!newMemoryText || !newMemoryUserId"
|
||||
>
|
||||
<v-textarea v-model="newMemoryText" label="输入文本内容" variant="outlined" rows="4" hide-details
|
||||
class="mb-2"></v-textarea>
|
||||
|
||||
<v-text-field v-model="newMemoryUserId" label="用户 ID" variant="outlined" density="compact"
|
||||
hide-details></v-text-field>
|
||||
|
||||
<v-switch v-model="needSummarize" color="primary" label="需要摘要" hide-details></v-switch>
|
||||
|
||||
<v-btn color="success" type="submit" :loading="isSubmitting" :disabled="!newMemoryText || !newMemoryUserId">
|
||||
<v-icon start>mdi-plus</v-icon>
|
||||
添加数据
|
||||
</v-btn>
|
||||
@@ -184,6 +153,99 @@
|
||||
</div>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<v-dialog v-model="showFactDialog" max-width="550" scrollable>
|
||||
<v-card class="fact-detail-card">
|
||||
<v-card-title class="d-flex align-center bg-primary text-white px-4 py-3">
|
||||
<v-icon class="mr-2" color="white">mdi-memory</v-icon>
|
||||
记忆事实
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn icon variant="text" color="white" @click="showFactDialog = false">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="px-4 pt-4 pb-0">
|
||||
<template v-if="selectedEdgeFactData">
|
||||
<v-alert color="primary" variant="tonal" density="compact" class="mb-4">
|
||||
<div class="text-body-1 font-weight-medium">{{ selectedEdgeFactData.text }}</div>
|
||||
</v-alert>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="6">
|
||||
<div class="d-flex align-center mb-2">
|
||||
<v-icon size="small" color="primary" class="mr-2">mdi-identifier</v-icon>
|
||||
<div class="text-subtitle-2">ID</div>
|
||||
</div>
|
||||
<div class="text-body-2 text-grey pa-1">{{ selectedEdgeFactData.id }}</div>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<div class="d-flex align-center mb-2">
|
||||
<v-icon size="small" color="primary" class="mr-2">mdi-file-document-outline</v-icon>
|
||||
<div class="text-subtitle-2">文档ID</div>
|
||||
</div>
|
||||
<div class="text-body-2 text-grey pa-1">{{ selectedEdgeFactData.doc_id }}</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 时间信息 -->
|
||||
<v-row class="mt-2">
|
||||
<v-col cols="6">
|
||||
<div class="d-flex align-center mb-2">
|
||||
<v-icon size="small" color="primary" class="mr-2">mdi-calendar-plus</v-icon>
|
||||
<div class="text-subtitle-2">创建时间</div>
|
||||
</div>
|
||||
<div class="text-body-2 text-grey pa-1">{{ formatTime(selectedEdgeFactData.created_at) }}</div>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<div class="d-flex align-center mb-2">
|
||||
<v-icon size="small" color="primary" class="mr-2">mdi-calendar-edit</v-icon>
|
||||
<div class="text-subtitle-2">更新时间</div>
|
||||
</div>
|
||||
<div class="text-body-2 text-grey pa-1">{{ formatTime(selectedEdgeFactData.updated_at) }}</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 改进元数据展示,解析为键值对 -->
|
||||
<div v-if="parsedMetadata && Object.keys(parsedMetadata).length > 0" class="mt-4">
|
||||
<div class="d-flex align-center mb-2">
|
||||
<v-icon size="small" color="primary" class="mr-2">mdi-database-cog</v-icon>
|
||||
<div class="text-subtitle-2">元数据</div>
|
||||
</div>
|
||||
<v-card variant="outlined" class="metadata-table">
|
||||
<v-table density="compact" hover>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-left">键</th>
|
||||
<th class="text-left">值</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(value, key) in parsedMetadata" :key="key">
|
||||
<td class="font-weight-medium">{{ key }}</td>
|
||||
<td>{{ formatMetadataValue(value) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="text-center py-6">
|
||||
<v-progress-circular indeterminate color="primary" size="50" width="5"></v-progress-circular>
|
||||
<div class="mt-3 text-body-1">加载中...</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider v-if="selectedEdgeFactData"></v-divider>
|
||||
|
||||
<v-card-actions class="pa-4" v-if="selectedEdgeFactData">
|
||||
<v-btn block color="primary" variant="tonal" @click="showFactDialog = false">
|
||||
关闭
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -230,6 +292,16 @@ export default {
|
||||
isSearching: false,
|
||||
searchResults: [],
|
||||
hasSearched: false,
|
||||
|
||||
// 添加边点击相关数据
|
||||
selectedEdge: null,
|
||||
selectedEdgeFactId: null,
|
||||
selectedEdgeFactData: null,
|
||||
showFactDialog: false,
|
||||
isLoadingFactData: false,
|
||||
|
||||
// 改进元数据展示
|
||||
parsedMetadata: null,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@@ -249,26 +321,26 @@ export default {
|
||||
this.$toast.warning('请输入搜索关键词');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
this.isSearching = true;
|
||||
this.hasSearched = true;
|
||||
this.searchResults = [];
|
||||
|
||||
|
||||
// 构建查询参数
|
||||
const params = {
|
||||
query: this.searchQuery
|
||||
};
|
||||
|
||||
|
||||
// 如果有选择用户ID,也加入查询参数
|
||||
if (this.searchMemoryUserId) {
|
||||
params.user_id = this.searchMemoryUserId;
|
||||
}
|
||||
|
||||
|
||||
axios.get('/api/plug/alkaid/ltm/graph/search', { params })
|
||||
.then(response => {
|
||||
if (response.data.status === 'ok') {
|
||||
const data = response.data.data;
|
||||
|
||||
|
||||
// 处理返回的文档数组
|
||||
this.searchResults = Object.keys(data).map(doc_id => {
|
||||
return {
|
||||
@@ -277,7 +349,7 @@ export default {
|
||||
score: data[doc_id].score || 0
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
if (this.searchResults.length === 0) {
|
||||
this.$toast.info('未找到相关记忆内容');
|
||||
} else {
|
||||
@@ -295,7 +367,7 @@ export default {
|
||||
this.isSearching = false;
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
// 添加新方法,用于提交记忆数据
|
||||
addMemoryData() {
|
||||
if (!this.newMemoryText || !this.newMemoryUserId) {
|
||||
@@ -303,23 +375,23 @@ export default {
|
||||
}
|
||||
|
||||
this.isSubmitting = true;
|
||||
|
||||
|
||||
// 准备提交数据
|
||||
const payload = {
|
||||
text: this.newMemoryText,
|
||||
user_id: this.newMemoryUserId,
|
||||
need_summarize: this.needSummarize
|
||||
};
|
||||
|
||||
|
||||
axios.post('/api/plug/alkaid/ltm/graph/add', payload)
|
||||
.then(response => {
|
||||
// 成功添加后刷新图表
|
||||
this.refreshGraph();
|
||||
|
||||
|
||||
// 重置表单
|
||||
// this.newMemoryText = '';
|
||||
// this.needSummarize = false;
|
||||
|
||||
|
||||
// 显示成功消息
|
||||
this.$toast.success('记忆数据添加成功!');
|
||||
})
|
||||
@@ -331,7 +403,7 @@ export default {
|
||||
this.isSubmitting = false;
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
ltmGetGraph(userId = null) {
|
||||
this.isLoading = true;
|
||||
const params = userId ? { user_id: userId } : {};
|
||||
@@ -424,6 +496,83 @@ export default {
|
||||
this.ltmGetGraph();
|
||||
},
|
||||
|
||||
// 添加获取Fact详情的方法
|
||||
getFactDetails(factId) {
|
||||
if (!factId) return;
|
||||
|
||||
this.isLoadingFactData = true;
|
||||
this.selectedEdgeFactData = null;
|
||||
this.parsedMetadata = null;
|
||||
|
||||
axios.get('/api/plug/alkaid/ltm/graph/fact', {
|
||||
params: { fact_id: factId }
|
||||
})
|
||||
.then(response => {
|
||||
if (response.data.status === 'ok') {
|
||||
this.selectedEdgeFactData = response.data.data;
|
||||
// 解析元数据
|
||||
this.parsedMetadata = this.parseMetadata(this.selectedEdgeFactData.metadata);
|
||||
this.showFactDialog = true;
|
||||
} else {
|
||||
this.$toast.error('获取记忆详情失败: ' + response.data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('获取记忆详情失败:', error);
|
||||
this.$toast.error('获取记忆详情失败: ' + (error.response?.data?.message || error.message));
|
||||
})
|
||||
.finally(() => {
|
||||
this.isLoadingFactData = false;
|
||||
});
|
||||
},
|
||||
|
||||
// 添加元数据解析方法
|
||||
parseMetadata(metadata) {
|
||||
if (!metadata) return null;
|
||||
|
||||
try {
|
||||
// 如果是字符串,尝试解析JSON
|
||||
if (typeof metadata === 'string') {
|
||||
try {
|
||||
return JSON.parse(metadata);
|
||||
} catch (e) {
|
||||
return { value: metadata }; // 如果无法解析为JSON,则作为单个值返回
|
||||
}
|
||||
}
|
||||
|
||||
// 如果已经是对象,直接返回
|
||||
if (typeof metadata === 'object') {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
return { value: String(metadata) };
|
||||
} catch (e) {
|
||||
console.error('解析元数据出错:', e);
|
||||
return { error: '无法解析元数据' };
|
||||
}
|
||||
},
|
||||
|
||||
// 格式化元数据值
|
||||
formatMetadataValue(value) {
|
||||
if (value === null || value === undefined) return '无';
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
return String(value);
|
||||
},
|
||||
|
||||
// 格式化时间戳的辅助方法
|
||||
formatTime(timestamp) {
|
||||
if (!timestamp) return '未知';
|
||||
try {
|
||||
return new Date(timestamp).toLocaleString();
|
||||
} catch (e) {
|
||||
return timestamp;
|
||||
}
|
||||
},
|
||||
|
||||
initD3Graph() {
|
||||
const container = document.getElementById("graph-container");
|
||||
if (!container) return;
|
||||
@@ -462,6 +611,8 @@ export default {
|
||||
if (!this.svg || !this.simulation) return;
|
||||
const g = this.g;
|
||||
g.selectAll("*").remove();
|
||||
|
||||
// 添加箭头定义
|
||||
g.append("defs").append("marker")
|
||||
.attr("id", "arrowhead")
|
||||
.attr("viewBox", "0 -5 10 10")
|
||||
@@ -473,13 +624,22 @@ export default {
|
||||
.append("path")
|
||||
.attr("d", "M0,-5L10,0L0,5")
|
||||
.attr("fill", "#999");
|
||||
|
||||
// 预处理边数据,标识和处理重复边
|
||||
const linkGroups = this.identifyParallelLinks(this.links);
|
||||
|
||||
// 使用路径替代直线来绘制边,以便支持曲线
|
||||
const link = g.append("g")
|
||||
.selectAll("line")
|
||||
.selectAll("path")
|
||||
.data(this.links)
|
||||
.join("line")
|
||||
.join("path")
|
||||
.attr("stroke", d => d.color)
|
||||
.attr("stroke-width", 1.5)
|
||||
.attr("marker-end", "url(#arrowhead)");
|
||||
.attr("fill", "none")
|
||||
.attr("marker-end", "url(#arrowhead)")
|
||||
.style("cursor", "pointer");
|
||||
|
||||
// 边标签需要相应调整位置
|
||||
const edgeLabels = g.append("g")
|
||||
.selectAll("text")
|
||||
.data(this.links)
|
||||
@@ -488,7 +648,22 @@ export default {
|
||||
.attr("font-size", "8px")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#666")
|
||||
.attr("dy", -5);
|
||||
.style("cursor", "pointer")
|
||||
.on("click", (event, d) => {
|
||||
event.stopPropagation();
|
||||
|
||||
// 检查边数据中是否有fact_id
|
||||
const factId = d.originalData?.fact_id;
|
||||
if (factId) {
|
||||
this.selectedEdge = d;
|
||||
this.selectedEdgeFactId = factId;
|
||||
this.getFactDetails(factId);
|
||||
} else {
|
||||
this.$toast.info('该关系没有关联的记忆数据');
|
||||
}
|
||||
});
|
||||
|
||||
// 节点绘制部分保持不变
|
||||
const node = g.append("g")
|
||||
.selectAll("circle")
|
||||
.data(this.nodes)
|
||||
@@ -497,6 +672,7 @@ export default {
|
||||
.attr("fill", d => d.color)
|
||||
.style("cursor", "pointer")
|
||||
.call(this.dragBehavior());
|
||||
|
||||
const nodeLabels = g.append("g")
|
||||
.selectAll("text")
|
||||
.data(this.nodes)
|
||||
@@ -506,27 +682,33 @@ export default {
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#333")
|
||||
.attr("dy", -12);
|
||||
|
||||
node.on("click", (event, d) => {
|
||||
event.stopPropagation();
|
||||
this.selectedNode = d.originalData;
|
||||
});
|
||||
|
||||
// 给SVG添加全局点击事件,用于关闭气泡
|
||||
this.svg.on("click", () => {
|
||||
this.selectedNode = null;
|
||||
});
|
||||
|
||||
this.simulation
|
||||
.nodes(this.nodes)
|
||||
.on("tick", () => {
|
||||
link
|
||||
.attr("x1", d => d.source.x)
|
||||
.attr("y1", d => d.source.y)
|
||||
.attr("x2", d => d.target.x)
|
||||
.attr("y2", d => d.target.y);
|
||||
// 更新边的路径
|
||||
link.attr("d", d => this.generateLinkPath(d));
|
||||
|
||||
// 更新边标签位置
|
||||
edgeLabels
|
||||
.attr("x", d => (d.source.x + d.target.x) / 2)
|
||||
.attr("y", d => (d.source.y + d.target.y) / 2);
|
||||
.attr("x", d => this.getLinkLabelX(d))
|
||||
.attr("y", d => this.getLinkLabelY(d));
|
||||
|
||||
// 更新节点位置
|
||||
node
|
||||
.attr("cx", d => d.x)
|
||||
.attr("cy", d => d.y);
|
||||
|
||||
nodeLabels
|
||||
.attr("x", d => d.x)
|
||||
.attr("y", d => d.y);
|
||||
@@ -537,6 +719,175 @@ export default {
|
||||
|
||||
this.simulation.alpha(1).restart();
|
||||
},
|
||||
|
||||
// 识别并标记平行边(连接相同两个节点的多条边)
|
||||
identifyParallelLinks(links) {
|
||||
// 创建一个映射来存储连接相同节点对的边
|
||||
const linkMap = new Map();
|
||||
|
||||
// 遍历所有边,按照起点和终点进行分组
|
||||
links.forEach(link => {
|
||||
// 创建边的键,确保无论边的方向如何,同一对节点生成的键都相同
|
||||
const sourceId = typeof link.source === 'object' ? link.source.id : link.source;
|
||||
const targetId = typeof link.target === 'object' ? link.target.id : link.target;
|
||||
|
||||
const forwardKey = `${sourceId}-${targetId}`;
|
||||
const reverseKey = `${targetId}-${sourceId}`;
|
||||
|
||||
// 判断是从source到target的边还是反向边
|
||||
const isForwardLink = sourceId < targetId;
|
||||
const key = isForwardLink ? forwardKey : reverseKey;
|
||||
|
||||
// 使用方向信息
|
||||
if (!linkMap.has(key)) {
|
||||
linkMap.set(key, []);
|
||||
}
|
||||
|
||||
// 存储边和其方向
|
||||
linkMap.get(key).push({
|
||||
link,
|
||||
isForward: isForwardLink
|
||||
});
|
||||
});
|
||||
|
||||
// 处理每一组平行边,为它们分配曲率
|
||||
linkMap.forEach((parallels, key) => {
|
||||
if (parallels.length > 1) {
|
||||
// 有多条平行边,分配不同曲率
|
||||
parallels.forEach((item, index) => {
|
||||
// 根据边的数量计算适当的曲率
|
||||
const totalLinks = parallels.length;
|
||||
// 基础曲率,可根据边数调整
|
||||
const baseCurvature = 0.45;
|
||||
// 根据边的索引计算曲率:中间的边较直,两侧的边较弯
|
||||
let curvature;
|
||||
|
||||
if (totalLinks % 2 === 1) {
|
||||
// 奇数条边,中间的边直线,其他边弯曲
|
||||
const middleIndex = Math.floor(totalLinks / 2);
|
||||
if (index === middleIndex) {
|
||||
curvature = 0; // 中间的边为直线
|
||||
} else {
|
||||
// 到中间边的距离决定曲率大小
|
||||
const distance = Math.abs(index - middleIndex);
|
||||
const direction = index < middleIndex ? -1 : 1;
|
||||
curvature = direction * baseCurvature * distance;
|
||||
}
|
||||
} else {
|
||||
// 偶数条边,所有边都弯曲
|
||||
const middleIndex = totalLinks / 2 - 0.5;
|
||||
const distance = Math.abs(index - middleIndex);
|
||||
const direction = index < middleIndex ? -1 : 1;
|
||||
curvature = direction * baseCurvature * distance;
|
||||
}
|
||||
|
||||
// 如果是反向边,翻转曲率方向
|
||||
if (!item.isForward) {
|
||||
curvature = -curvature;
|
||||
}
|
||||
|
||||
// 存储曲率值到边对象
|
||||
item.link.curvature = curvature;
|
||||
});
|
||||
} else {
|
||||
// 只有一条边,不需要弯曲
|
||||
parallels[0].link.curvature = 0;
|
||||
}
|
||||
});
|
||||
|
||||
return linkMap;
|
||||
},
|
||||
|
||||
// 根据曲率生成边的路径
|
||||
generateLinkPath(d) {
|
||||
// 确保source和target是对象
|
||||
const source = typeof d.source === 'object' ? d.source : this.nodes.find(n => n.id === d.source);
|
||||
const target = typeof d.target === 'object' ? d.target : this.nodes.find(n => n.id === d.target);
|
||||
|
||||
if (!source || !target) return '';
|
||||
|
||||
// 如果是直线(无曲率)
|
||||
if (!d.curvature || d.curvature === 0) {
|
||||
return `M${source.x},${source.y}L${target.x},${target.y}`;
|
||||
}
|
||||
|
||||
// 计算曲线的控制点
|
||||
const dx = target.x - source.x;
|
||||
const dy = target.y - source.y;
|
||||
const dr = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
// 控制点偏移距离,由曲率决定
|
||||
const offset = dr * d.curvature;
|
||||
|
||||
// 计算中点
|
||||
const midX = (source.x + target.x) / 2;
|
||||
const midY = (source.y + target.y) / 2;
|
||||
|
||||
// 计算垂直于连线的方向向量
|
||||
const nx = -dy / dr;
|
||||
const ny = dx / dr;
|
||||
|
||||
// 计算控制点坐标
|
||||
const cpx = midX + offset * nx;
|
||||
const cpy = midY + offset * ny;
|
||||
|
||||
// 创建二次贝塞尔曲线路径
|
||||
return `M${source.x},${source.y} Q${cpx},${cpy} ${target.x},${target.y}`;
|
||||
},
|
||||
|
||||
// 新增方法:计算边标签的X坐标
|
||||
getLinkLabelX(d) {
|
||||
const source = typeof d.source === 'object' ? d.source : this.nodes.find(n => n.id === d.source);
|
||||
const target = typeof d.target === 'object' ? d.target : this.nodes.find(n => n.id === d.target);
|
||||
|
||||
if (!source || !target) return 0;
|
||||
|
||||
// 如果是直线
|
||||
if (!d.curvature || d.curvature === 0) {
|
||||
return (source.x + target.x) / 2;
|
||||
}
|
||||
|
||||
// 计算曲线上的点
|
||||
const dx = target.x - source.x;
|
||||
const dy = target.y - source.y;
|
||||
const dr = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
// 中点
|
||||
const midX = (source.x + target.x) / 2;
|
||||
|
||||
// 垂直向量
|
||||
const nx = -dy / dr;
|
||||
|
||||
// 曲线路径上的点,使用曲率进行调整
|
||||
return midX + d.curvature * dr * nx * 0.5;
|
||||
},
|
||||
|
||||
// 新增方法:计算边标签的Y坐标
|
||||
getLinkLabelY(d) {
|
||||
const source = typeof d.source === 'object' ? d.source : this.nodes.find(n => n.id === d.source);
|
||||
const target = typeof d.target === 'object' ? d.target : this.nodes.find(n => n.id === d.target);
|
||||
|
||||
if (!source || !target) return 0;
|
||||
|
||||
// 如果是直线
|
||||
if (!d.curvature || d.curvature === 0) {
|
||||
return (source.y + target.y) / 2;
|
||||
}
|
||||
|
||||
// 计算曲线上的点
|
||||
const dx = target.x - source.x;
|
||||
const dy = target.y - source.y;
|
||||
const dr = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
// 中点
|
||||
const midY = (source.y + target.y) / 2;
|
||||
|
||||
// 垂直向量
|
||||
const ny = dx / dr;
|
||||
|
||||
// 曲线路径上的点,使用曲率进行调整
|
||||
return midY + d.curvature * dr * ny * 0.5;
|
||||
},
|
||||
|
||||
dragBehavior() {
|
||||
return d3.drag()
|
||||
@@ -571,6 +922,7 @@ export default {
|
||||
<style scoped>
|
||||
#long-term-memory {
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -585,8 +937,8 @@ export default {
|
||||
}
|
||||
|
||||
#graph-control-panel {
|
||||
height: 100%;
|
||||
overflow-y: auto; /* 让控制面板可滚动而不是整个页面滚动 */
|
||||
overflow-y: auto;
|
||||
/* 让控制面板可滚动而不是整个页面滚动 */
|
||||
min-width: 450px;
|
||||
max-width: 450px;
|
||||
}
|
||||
@@ -607,4 +959,44 @@ export default {
|
||||
.d3-graph {
|
||||
background-color: #f2f6f9;
|
||||
}
|
||||
|
||||
/* 为连接线添加交互样式 */
|
||||
#graph-container line {
|
||||
transition: stroke-width 0.2s;
|
||||
}
|
||||
|
||||
#graph-container line:hover {
|
||||
stroke-width: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 添加美化详情卡片的样式 */
|
||||
.fact-detail-card :deep(.v-card-title) {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.fact-detail-card :deep(.metadata-table) {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fact-detail-card :deep(.v-table) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.fact-detail-card :deep(.v-table th) {
|
||||
color: var(--v-primary-base);
|
||||
font-weight: bold;
|
||||
background-color: rgba(var(--v-theme-primary), 0.05);
|
||||
}
|
||||
|
||||
.fact-detail-card :deep(pre) {
|
||||
background-color: #f5f5f5;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
max-height: 150px;
|
||||
overflow: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,7 @@ import Logo from '@/components/shared/Logo.vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useRouter } from 'vue-router';
|
||||
import {useCustomizerStore} from "@/stores/customizer";
|
||||
|
||||
const cardVisible = ref(false);
|
||||
const router = useRouter();
|
||||
@@ -24,7 +25,7 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page-container">
|
||||
<div v-if="useCustomizerStore().uiTheme==='PurpleTheme'" class="login-page-container">
|
||||
<div class="login-background"></div>
|
||||
<v-card
|
||||
variant="outlined"
|
||||
@@ -42,6 +43,24 @@ onMounted(() => {
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
<div v-else class="login-page-container-dark">
|
||||
<div class="login-background-dark"></div>
|
||||
<v-card
|
||||
variant="outlined"
|
||||
class="login-card"
|
||||
:class="{ 'card-visible': cardVisible }"
|
||||
>
|
||||
<v-card-text class="pa-10">
|
||||
<div class="logo-wrapper">
|
||||
<Logo />
|
||||
</div>
|
||||
<div class="divider-container">
|
||||
<v-divider class="custom-divider"></v-divider>
|
||||
</div>
|
||||
<AuthLogin />
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@@ -56,6 +75,17 @@ onMounted(() => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-page-container-dark {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
background: linear-gradient(135deg, #1a1b1c 0%, #1d1e21 100%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-background {
|
||||
position: absolute;
|
||||
width: 200%;
|
||||
@@ -67,6 +97,17 @@ onMounted(() => {
|
||||
animation: rotate 60s linear infinite;
|
||||
}
|
||||
|
||||
.login-background-dark {
|
||||
position: absolute;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
background-color: var(--v-theme-surface);
|
||||
z-index: 0;
|
||||
animation: rotate 60s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
@@ -79,9 +120,11 @@ onMounted(() => {
|
||||
.login-card {
|
||||
max-width: 520px;
|
||||
width: 90%;
|
||||
color: var(--v-theme-primaryText) !important;
|
||||
border-radius: 12px !important;
|
||||
border-color: var(--v-theme-border) !important;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.07) !important;
|
||||
background-color: rgba(255, 255, 255, 0.98) !important;
|
||||
background-color: var(--v-theme-surface) !important;
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
transition: all 0.5s ease;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import {ref, useCssModule} from 'vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { Form } from 'vee-validate';
|
||||
import md5 from 'js-md5';
|
||||
import {useCustomizerStore} from "@/stores/customizer";
|
||||
|
||||
const valid = ref(false);
|
||||
const show1 = ref(false);
|
||||
@@ -22,6 +23,8 @@ async function validate(values: any, { setErrors }: any) {
|
||||
}
|
||||
|
||||
const authStore = useAuthStore();
|
||||
// @ts-ignore
|
||||
authStore.returnUrl = new URLSearchParams(window.location.search).get('redirect');
|
||||
return authStore.login(username.value, password_).then((res) => {
|
||||
console.log(res);
|
||||
loading.value = false;
|
||||
@@ -42,8 +45,8 @@ async function validate(values: any, { setErrors }: any) {
|
||||
required
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
:style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#000000dd' : '#ffffff'}"
|
||||
prepend-inner-icon="mdi-account"
|
||||
:disabled="loading"
|
||||
></v-text-field>
|
||||
@@ -54,7 +57,7 @@ async function validate(values: any, { setErrors }: any) {
|
||||
required
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
:style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#000000dd' : '#ffffff'}"
|
||||
hide-details="auto"
|
||||
:append-icon="show1 ? 'mdi-eye' : 'mdi-eye-off'"
|
||||
:type="show1 ? 'text' : 'password'"
|
||||
@@ -63,16 +66,12 @@ async function validate(values: any, { setErrors }: any) {
|
||||
prepend-inner-icon="mdi-lock"
|
||||
:disabled="loading"
|
||||
></v-text-field>
|
||||
|
||||
<div class="mt-1 mb-5 hint-text">
|
||||
<small>默认用户名和密码为 astrbot</small>
|
||||
</div>
|
||||
|
||||
<v-btn
|
||||
color="secondary"
|
||||
:loading="isSubmitting || loading"
|
||||
block
|
||||
class="login-btn"
|
||||
class="login-btn mt-8"
|
||||
variant="flat"
|
||||
size="large"
|
||||
:disabled="valid"
|
||||
@@ -160,7 +159,7 @@ async function validate(values: any, { setErrors }: any) {
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
color: var(--v-theme-secondaryText);
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
@@ -171,7 +170,7 @@ async function validate(values: any, { setErrors }: any) {
|
||||
}
|
||||
}
|
||||
|
||||
.custom-devider {
|
||||
.custom-divider {
|
||||
border-color: rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -155,7 +155,7 @@ export default {
|
||||
<style scoped>
|
||||
.dashboard-container {
|
||||
padding: 16px;
|
||||
background-color: #f9fafc;
|
||||
background-color: var(--v-theme-background);
|
||||
min-height: calc(100vh - 64px);
|
||||
border-radius: 10px;
|
||||
|
||||
@@ -170,13 +170,13 @@ export default {
|
||||
.dashboard-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
color: var(--v-theme-primaryText);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.dashboard-subtitle {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
color: var(--v-theme-secondaryText);
|
||||
}
|
||||
|
||||
.notice-row {
|
||||
@@ -194,18 +194,18 @@ export default {
|
||||
|
||||
.plugin-card {
|
||||
border-radius: 8px;
|
||||
background-color: white;
|
||||
background-color: var(--v-theme-surface);
|
||||
}
|
||||
|
||||
.plugin-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
color: var(--v-theme-primaryText);
|
||||
}
|
||||
|
||||
.plugin-subtitle {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
color: var(--v-theme-secondaryText);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ export default {
|
||||
|
||||
.plugin-version {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
color: var(--v-theme-secondaryText, #666);
|
||||
}
|
||||
|
||||
.dashboard-footer {
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
import {useCustomizerStore} from "@/stores/customizer";
|
||||
|
||||
export default {
|
||||
name: 'MessageStat',
|
||||
@@ -129,7 +130,7 @@ export default {
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
theme: 'light',
|
||||
theme: useCustomizerStore().uiTheme==='PurpleTheme' ? 'light' : 'dark',
|
||||
x: {
|
||||
format: 'yyyy-MM-dd HH:mm'
|
||||
},
|
||||
@@ -167,7 +168,7 @@ export default {
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
borderColor: '#f1f1f1',
|
||||
borderColor: "gray100",
|
||||
row: {
|
||||
colors: ['transparent', 'transparent'],
|
||||
opacity: 0.2
|
||||
@@ -293,12 +294,12 @@ export default {
|
||||
.chart-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
color: var(--v-theme-primaryText);
|
||||
}
|
||||
|
||||
.chart-subtitle {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
color: var(--v-theme-secondaryText);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@@ -315,35 +316,35 @@ export default {
|
||||
|
||||
.stat-box {
|
||||
padding: 12px 16px;
|
||||
background: #f5f5f5;
|
||||
background: var(--v-theme-surface);
|
||||
border-radius: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
color: var(--v-theme-secondaryText);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
color: var(--v-theme-primaryText);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.trend-up .stat-number {
|
||||
color: #4caf50;
|
||||
color: var(--v-theme-success);
|
||||
}
|
||||
|
||||
.trend-down .stat-number {
|
||||
color: #f44336;
|
||||
color: var(--v-theme-error);
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
border-top: 1px solid var(--v-theme-border);
|
||||
padding-top: 20px;
|
||||
position: relative;
|
||||
}
|
||||
@@ -354,7 +355,7 @@ export default {
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
background: var(--v-theme-overlay);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
@@ -365,6 +366,6 @@ export default {
|
||||
.loading-text {
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
color: var(--v-theme-secondaryText);
|
||||
}
|
||||
</style>
|
||||
@@ -132,12 +132,12 @@ export default {
|
||||
.platform-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
color: var(--v-theme-primaryText);
|
||||
}
|
||||
|
||||
.platform-subtitle {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
color: var(--v-theme-secondaryText);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@@ -171,16 +171,16 @@ export default {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
background-color: var(--v-theme-surface);
|
||||
color: var(--v-theme-primaryText);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.top-rank {
|
||||
background-color: #5e35b1;
|
||||
color: white;
|
||||
background-color: var(--v-theme-secondary);
|
||||
color: var(--v-theme-surface);
|
||||
}
|
||||
|
||||
.platform-name {
|
||||
@@ -195,19 +195,19 @@ export default {
|
||||
.count-value {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #5e35b1;
|
||||
color: var(--v-theme-secondary);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.count-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
color: var(--v-theme-secondaryText);
|
||||
}
|
||||
|
||||
.platform-stats-summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
background-color: #f5f5f5;
|
||||
background-color: var(--v-theme-containerBg);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
@@ -220,13 +220,13 @@ export default {
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
color: var(--v-theme-secondaryText);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
color: var(--v-theme-primaryText);
|
||||
}
|
||||
|
||||
.platform-chart {
|
||||
@@ -246,7 +246,7 @@ export default {
|
||||
}
|
||||
|
||||
.no-data-text {
|
||||
color: #999;
|
||||
color: var(--v-theme-secondaryText);
|
||||
margin-top: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
+55
-44
@@ -12,6 +12,7 @@ from astrbot.api import sp
|
||||
from astrbot.api.provider import ProviderRequest
|
||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
from astrbot.core.platform.message_type import MessageType
|
||||
from astrbot.core.provider.entities import ProviderType
|
||||
from astrbot.core.provider.sources.dify_source import ProviderDify
|
||||
from astrbot.core.utils.io import download_dashboard, get_dashboard_version
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
@@ -139,6 +140,7 @@ class Main(star.Star):
|
||||
{notice}"""
|
||||
|
||||
event.set_result(MessageEventResult().message(msg).use_t2i(False))
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@filter.command("llm")
|
||||
async def llm(self, event: AstrMessageEvent):
|
||||
@@ -413,20 +415,21 @@ UID: {user_id} 此 ID 可用于设置管理员。
|
||||
event.set_result(MessageEventResult().message("删除白名单成功。"))
|
||||
except ValueError:
|
||||
event.set_result(MessageEventResult().message("此 SID 不在白名单内。"))
|
||||
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@filter.command("provider")
|
||||
async def provider(
|
||||
self, event: AstrMessageEvent, idx: Union[str, int] = None, idx2: int = None
|
||||
):
|
||||
"""查看或者切换 LLM Provider"""
|
||||
umo = event.unified_msg_origin
|
||||
|
||||
if idx is None:
|
||||
ret = "## 载入的 LLM 提供商\n"
|
||||
for idx, llm in enumerate(self.context.get_all_providers()):
|
||||
id_ = llm.meta().id
|
||||
ret += f"{idx + 1}. {id_} ({llm.meta().model})"
|
||||
provider_using = self.context.get_using_provider()
|
||||
provider_using = self.context.get_using_provider(umo=umo)
|
||||
if provider_using and provider_using.meta().id == id_:
|
||||
ret += " (当前使用)"
|
||||
ret += "\n"
|
||||
@@ -437,7 +440,7 @@ UID: {user_id} 此 ID 可用于设置管理员。
|
||||
for idx, tts in enumerate(tts_providers):
|
||||
id_ = tts.meta().id
|
||||
ret += f"{idx + 1}. {id_}"
|
||||
tts_using = self.context.get_using_tts_provider()
|
||||
tts_using = self.context.get_using_tts_provider(umo=umo)
|
||||
if tts_using and tts_using.meta().id == id_:
|
||||
ret += " (当前使用)"
|
||||
ret += "\n"
|
||||
@@ -448,7 +451,7 @@ UID: {user_id} 此 ID 可用于设置管理员。
|
||||
for idx, stt in enumerate(stt_providers):
|
||||
id_ = stt.meta().id
|
||||
ret += f"{idx + 1}. {id_}"
|
||||
stt_using = self.context.get_using_stt_provider()
|
||||
stt_using = self.context.get_using_stt_provider(umo=umo)
|
||||
if stt_using and stt_using.meta().id == id_:
|
||||
ret += " (当前使用)"
|
||||
ret += "\n"
|
||||
@@ -461,46 +464,54 @@ UID: {user_id} 此 ID 可用于设置管理员。
|
||||
ret += "\n使用 /provider stt <切换> STT 提供商。"
|
||||
|
||||
event.set_result(MessageEventResult().message(ret))
|
||||
else:
|
||||
if idx == "tts":
|
||||
if idx2 is None:
|
||||
event.set_result(MessageEventResult().message("请输入序号。"))
|
||||
return
|
||||
else:
|
||||
if idx2 > len(self.context.get_all_tts_providers()) or idx2 < 1:
|
||||
event.set_result(MessageEventResult().message("无效的序号。"))
|
||||
provider = self.context.get_all_tts_providers()[idx2 - 1]
|
||||
id_ = provider.meta().id
|
||||
self.context.provider_manager.curr_tts_provider_inst = provider
|
||||
sp.put("curr_provider_tts", id_)
|
||||
event.set_result(
|
||||
MessageEventResult().message(f"成功切换到 {id_}。")
|
||||
)
|
||||
elif idx == "stt":
|
||||
if idx2 is None:
|
||||
event.set_result(MessageEventResult().message("请输入序号。"))
|
||||
return
|
||||
else:
|
||||
if idx2 > len(self.context.get_all_stt_providers()) or idx2 < 1:
|
||||
event.set_result(MessageEventResult().message("无效的序号。"))
|
||||
provider = self.context.get_all_stt_providers()[idx2 - 1]
|
||||
id_ = provider.meta().id
|
||||
self.context.provider_manager.curr_stt_provider_inst = provider
|
||||
sp.put("curr_provider_stt", id_)
|
||||
event.set_result(
|
||||
MessageEventResult().message(f"成功切换到 {id_}。")
|
||||
)
|
||||
elif isinstance(idx, int):
|
||||
if idx > len(self.context.get_all_providers()) or idx < 1:
|
||||
event.set_result(MessageEventResult().message("无效的序号。"))
|
||||
|
||||
provider = self.context.get_all_providers()[idx - 1]
|
||||
id_ = provider.meta().id
|
||||
self.context.provider_manager.curr_provider_inst = provider
|
||||
sp.put("curr_provider", id_)
|
||||
event.set_result(MessageEventResult().message(f"成功切换到 {id_}。"))
|
||||
elif idx == "tts":
|
||||
if idx2 is None:
|
||||
event.set_result(MessageEventResult().message("请输入序号。"))
|
||||
return
|
||||
else:
|
||||
event.set_result(MessageEventResult().message("无效的参数。"))
|
||||
if idx2 > len(self.context.get_all_tts_providers()) or idx2 < 1:
|
||||
event.set_result(MessageEventResult().message("无效的序号。"))
|
||||
provider = self.context.get_all_tts_providers()[idx2 - 1]
|
||||
id_ = provider.meta().id
|
||||
await self.context.provider_manager.set_provider(
|
||||
provider_id=id_,
|
||||
provider_type=ProviderType.TEXT_TO_SPEECH,
|
||||
umo=umo,
|
||||
)
|
||||
event.set_result(
|
||||
MessageEventResult().message(f"成功切换到 {id_}。")
|
||||
)
|
||||
elif idx == "stt":
|
||||
if idx2 is None:
|
||||
event.set_result(MessageEventResult().message("请输入序号。"))
|
||||
return
|
||||
else:
|
||||
if idx2 > len(self.context.get_all_stt_providers()) or idx2 < 1:
|
||||
event.set_result(MessageEventResult().message("无效的序号。"))
|
||||
provider = self.context.get_all_stt_providers()[idx2 - 1]
|
||||
id_ = provider.meta().id
|
||||
await self.context.provider_manager.set_provider(
|
||||
provider_id=id_,
|
||||
provider_type=ProviderType.SPEECH_TO_TEXT,
|
||||
umo=umo,
|
||||
)
|
||||
event.set_result(
|
||||
MessageEventResult().message(f"成功切换到 {id_}。")
|
||||
)
|
||||
elif isinstance(idx, int):
|
||||
if idx > len(self.context.get_all_providers()) or idx < 1:
|
||||
event.set_result(MessageEventResult().message("无效的序号。"))
|
||||
|
||||
provider = self.context.get_all_providers()[idx - 1]
|
||||
id_ = provider.meta().id
|
||||
await self.context.provider_manager.set_provider(
|
||||
provider_id=id_,
|
||||
provider_type=ProviderType.CHAT_COMPLETION,
|
||||
umo=umo,
|
||||
)
|
||||
event.set_result(MessageEventResult().message(f"成功切换到 {id_}。"))
|
||||
else:
|
||||
event.set_result(MessageEventResult().message("无效的参数。"))
|
||||
|
||||
@filter.command("reset")
|
||||
async def reset(self, message: AstrMessageEvent):
|
||||
@@ -572,7 +583,7 @@ UID: {user_id} 此 ID 可用于设置管理员。
|
||||
ret += f"\n聊天增强: 已清除 {cnt} 条聊天记录。"
|
||||
|
||||
message.set_result(MessageEventResult().message(ret))
|
||||
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@filter.command("model")
|
||||
async def model_ls(
|
||||
|
||||
@@ -26,9 +26,7 @@ class Waiter(Star):
|
||||
def __init__(self, context: Context):
|
||||
super().__init__(context)
|
||||
|
||||
self.empty_mention_waiting = self.context.get_config()["platform_settings"][
|
||||
"empty_mention_waiting"
|
||||
]
|
||||
self.p_settings: dict = self.context.get_config()["platform_settings"]
|
||||
self.wake_prefix = self.context.get_config()["wake_prefix"]
|
||||
|
||||
@filter.event_message_type(filter.EventMessageType.ALL, priority=maxsize)
|
||||
@@ -49,57 +47,60 @@ class Waiter(Star):
|
||||
if (
|
||||
isinstance(messages[0], Comp.At)
|
||||
and str(messages[0].qq) == str(event.get_self_id())
|
||||
and self.empty_mention_waiting
|
||||
and self.p_settings.get("empty_mention_waiting", True)
|
||||
) or (
|
||||
isinstance(messages[0], Comp.Plain)
|
||||
and messages[0].text.strip() in self.wake_prefix
|
||||
):
|
||||
try:
|
||||
# 尝试使用 LLM 生成更生动的回复
|
||||
func_tools_mgr = self.context.get_llm_tool_manager()
|
||||
if self.p_settings.get("empty_mention_waiting_need_reply", True):
|
||||
try:
|
||||
# 尝试使用 LLM 生成更生动的回复
|
||||
func_tools_mgr = self.context.get_llm_tool_manager()
|
||||
|
||||
# 获取用户当前的对话信息
|
||||
curr_cid = await self.context.conversation_manager.get_curr_conversation_id(
|
||||
event.unified_msg_origin
|
||||
)
|
||||
conversation = None
|
||||
|
||||
if curr_cid:
|
||||
conversation = await self.context.conversation_manager.get_conversation(
|
||||
event.unified_msg_origin, curr_cid
|
||||
)
|
||||
else:
|
||||
# 创建新对话
|
||||
curr_cid = await self.context.conversation_manager.new_conversation(
|
||||
# 获取用户当前的对话信息
|
||||
curr_cid = await self.context.conversation_manager.get_curr_conversation_id(
|
||||
event.unified_msg_origin
|
||||
)
|
||||
conversation = None
|
||||
|
||||
# 使用 LLM 生成回复
|
||||
yield event.request_llm(
|
||||
prompt="注意,你正在社交媒体上中与用户进行聊天,用户只是通过@来唤醒你,但并未在这条消息中输入内容,他可能会在接下来一条发送他想发送的内容。请你友好地询问用户想要聊些什么或者需要什么帮助,回复要符合人设,不要太过机械化。注意,你仅需要输出要回复用户的内容,不要输出其他任何东西",
|
||||
func_tool_manager=func_tools_mgr,
|
||||
session_id=curr_cid,
|
||||
contexts=[],
|
||||
system_prompt="",
|
||||
conversation=conversation,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"LLM response failed: {str(e)}")
|
||||
# LLM 回复失败,使用原始预设回复
|
||||
yield event.plain_result("想要问什么呢?😄")
|
||||
if curr_cid:
|
||||
conversation = await self.context.conversation_manager.get_conversation(
|
||||
event.unified_msg_origin, curr_cid
|
||||
)
|
||||
else:
|
||||
# 创建新对话
|
||||
curr_cid = await self.context.conversation_manager.new_conversation(
|
||||
event.unified_msg_origin
|
||||
)
|
||||
|
||||
# 使用 LLM 生成回复
|
||||
yield event.request_llm(
|
||||
prompt=(
|
||||
"注意,你正在社交媒体上中与用户进行聊天,用户只是通过@来唤醒你,但并未在这条消息中输入内容,他可能会在接下来一条发送他想发送的内容。"
|
||||
"你友好地询问用户想要聊些什么或者需要什么帮助,回复要符合人设,不要太过机械化。"
|
||||
"请注意,你仅需要输出要回复用户的内容,不要输出其他任何东西"
|
||||
),
|
||||
func_tool_manager=func_tools_mgr,
|
||||
session_id=curr_cid,
|
||||
contexts=[],
|
||||
system_prompt="",
|
||||
conversation=conversation,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"LLM response failed: {str(e)}")
|
||||
# LLM 回复失败,使用原始预设回复
|
||||
yield event.plain_result("想要问什么呢?😄")
|
||||
|
||||
@session_waiter(60)
|
||||
async def empty_mention_waiter(
|
||||
controller: SessionController, event: AstrMessageEvent
|
||||
):
|
||||
logger.info("empty_mention_waiter")
|
||||
event.message_obj.message.insert(
|
||||
0, Comp.At(qq=event.get_self_id(), name=event.get_self_id())
|
||||
)
|
||||
new_event = copy.copy(event)
|
||||
self.context.get_event_queue().put_nowait(
|
||||
new_event
|
||||
) # 重新推入事件队列
|
||||
# 重新推入事件队列
|
||||
self.context.get_event_queue().put_nowait(new_event)
|
||||
event.stop_event()
|
||||
controller.stop()
|
||||
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "AstrBot"
|
||||
version = "3.5.12"
|
||||
version = "3.5.15"
|
||||
description = "易上手的多平台 LLM 聊天机器人及开发框架"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -20,13 +20,14 @@ dependencies = [
|
||||
"defusedxml>=0.7.1",
|
||||
"dingtalk-stream>=0.22.1",
|
||||
"docstring-parser>=0.16",
|
||||
"faiss-cpu>=1.11.0",
|
||||
"faiss-cpu>=1.10.0",
|
||||
"filelock>=3.18.0",
|
||||
"google-genai>=1.14.0",
|
||||
"googlesearch-python>=1.3.0",
|
||||
"lark-oapi>=1.4.15",
|
||||
"lxml-html-clean>=0.4.2",
|
||||
"mcp>=1.8.0",
|
||||
"nh3>=0.2.21",
|
||||
"openai>=1.78.0",
|
||||
"ormsgpack>=1.9.1",
|
||||
"pillow>=11.2.1",
|
||||
|
||||
+2
-1
@@ -36,4 +36,5 @@ filelock
|
||||
watchfiles
|
||||
websockets
|
||||
faiss-cpu
|
||||
aiosqlite
|
||||
aiosqlite
|
||||
nh3
|
||||
Reference in New Issue
Block a user