Compare commits
83 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b310521884 | ||
|
|
288945bf7e | ||
|
|
4fc07cff36 | ||
|
|
51666464b9 | ||
|
|
5af9cf2f52 | ||
|
|
12c4ae4b10 | ||
|
|
4e1bef414a | ||
|
|
e896c18644 | ||
|
|
c852685e74 | ||
|
|
1e99797df8 | ||
|
|
52a4c986a8 | ||
|
|
c501728204 | ||
|
|
6b067fa6a7 | ||
|
|
a1cd5c53a9 | ||
|
|
a46d487e03 | ||
|
|
3deb6d3ab3 | ||
|
|
af34cdd5d2 | ||
|
|
6e1393235a | ||
|
|
343e0b54b9 | ||
|
|
ecb70cb6f7 | ||
|
|
ca50618af6 | ||
|
|
29c07ba83e | ||
|
|
45fbb83a9f | ||
|
|
ae7ba2df25 | ||
|
|
c3ef57cc32 | ||
|
|
7bb4ca5a14 | ||
|
|
063783d81d | ||
|
|
42116c9b65 | ||
|
|
a36e11973d | ||
|
|
5125568ea2 | ||
|
|
0fa164e50d | ||
|
|
cf814e81ee | ||
|
|
43a45f18ce | ||
|
|
ad51381063 | ||
|
|
0b0e4ce904 | ||
|
|
6a3e04d688 | ||
|
|
4107a17370 | ||
|
|
06b4d8f169 | ||
|
|
1c0c820746 | ||
|
|
d061403a28 | ||
|
|
5c092321a6 | ||
|
|
bdd3f61c1f | ||
|
|
8023557d6e | ||
|
|
074b0ced7a | ||
|
|
3864b1ac9b | ||
|
|
6e9b43457d | ||
|
|
ca1aec8920 | ||
|
|
acac580862 | ||
|
|
673e1b2980 | ||
|
|
f62157be72 | ||
|
|
f894ecf3b6 | ||
|
|
66dd4e28ad | ||
|
|
939dc1b0fb | ||
|
|
56bf5d38a1 | ||
|
|
d09b70b295 | ||
|
|
205180387a | ||
|
|
39c8cfeda5 | ||
|
|
f38a329be5 | ||
|
|
a0cd069539 | ||
|
|
bf306a2f01 | ||
|
|
c31f93a8d1 | ||
|
|
4730ab6309 | ||
|
|
1ae78ca98c | ||
|
|
d2379da478 | ||
|
|
0f64981b20 | ||
|
|
0002e49bb5 | ||
|
|
db13a60274 | ||
|
|
db0f11a359 | ||
|
|
ac7f43520b | ||
|
|
f67b9f5f6e | ||
|
|
c75156c4ce | ||
|
|
10270b5595 | ||
|
|
f7458572ed | ||
|
|
d57b7222b2 | ||
|
|
62e70a673a | ||
|
|
5e9eba6478 | ||
|
|
c5ccc1a084 | ||
|
|
6439917cbe | ||
|
|
d21c18f657 | ||
|
|
e6981290bc | ||
|
|
75c3d8abbd | ||
|
|
d88683f498 | ||
|
|
40b9aa3a4c |
@@ -3,7 +3,6 @@ import tempfile
|
||||
|
||||
import httpx
|
||||
import yaml
|
||||
import re
|
||||
from enum import Enum
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
@@ -59,7 +58,16 @@ def get_git_repo(url: str, target_path: Path, proxy: str | None = None):
|
||||
proxy=proxy if proxy else None, follow_redirects=True
|
||||
) as client:
|
||||
resp = client.get(download_url)
|
||||
resp.raise_for_status()
|
||||
if (
|
||||
resp.status_code == 404
|
||||
and "archive/refs/heads/master.zip" in download_url
|
||||
):
|
||||
alt_url = download_url.replace("master.zip", "main.zip")
|
||||
click.echo("master 分支不存在,尝试下载 main 分支")
|
||||
resp = client.get(alt_url)
|
||||
resp.raise_for_status()
|
||||
else:
|
||||
resp.raise_for_status()
|
||||
zip_content = BytesIO(resp.content)
|
||||
with ZipFile(zip_content) as z:
|
||||
z.extractall(temp_dir)
|
||||
@@ -91,39 +99,6 @@ def load_yaml_metadata(plugin_dir: Path) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def extract_py_metadata(plugin_dir: Path) -> dict:
|
||||
"""从 Python 文件中提取插件元数据
|
||||
|
||||
Args:
|
||||
plugin_dir: 插件目录路径
|
||||
|
||||
Returns:
|
||||
dict: 包含元数据的字典,如果提取失败则返回空字典
|
||||
"""
|
||||
# 检查 main.py 或与目录同名的 py 文件
|
||||
for pattern in ["main.py", f"{plugin_dir.name}.py"]:
|
||||
for py_file in plugin_dir.glob(pattern):
|
||||
try:
|
||||
content = py_file.read_text(encoding="utf-8")
|
||||
register_match = re.search(
|
||||
r'@register_star\s*\(\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*"([^"]+)"(?:\s*,\s*"?([^")]+)"?)?\s*\)',
|
||||
content,
|
||||
)
|
||||
if register_match:
|
||||
# 映射匹配组到元数据键
|
||||
metadata = {}
|
||||
keys = ["name", "author", "desc", "version", "repo"]
|
||||
for i, key in enumerate(keys):
|
||||
if i + 1 <= len(
|
||||
register_match.groups()
|
||||
) and register_match.group(i + 1):
|
||||
metadata[key] = register_match.group(i + 1)
|
||||
return metadata
|
||||
except Exception as e:
|
||||
click.echo(f"读取 {py_file} 失败: {e}", err=True)
|
||||
return {}
|
||||
|
||||
|
||||
def build_plug_list(plugins_dir: Path) -> list:
|
||||
"""构建插件列表,包含本地和在线插件信息
|
||||
|
||||
@@ -139,31 +114,22 @@ def build_plug_list(plugins_dir: Path) -> list:
|
||||
for plugin_name in [d.name for d in plugins_dir.glob("*") if d.is_dir()]:
|
||||
plugin_dir = plugins_dir / plugin_name
|
||||
|
||||
# 从不同来源加载元数据
|
||||
# 从 metadata.yaml 加载元数据
|
||||
metadata = load_yaml_metadata(plugin_dir)
|
||||
|
||||
# 如果元数据不完整,尝试从 Python 文件提取
|
||||
if not metadata or not all(
|
||||
# 如果成功加载元数据,添加到结果列表
|
||||
if metadata and all(
|
||||
k in metadata for k in ["name", "desc", "version", "author", "repo"]
|
||||
):
|
||||
py_metadata = extract_py_metadata(plugin_dir)
|
||||
# 合并元数据,保留已有的值
|
||||
for key, value in py_metadata.items():
|
||||
if key not in metadata or not metadata[key]:
|
||||
metadata[key] = value
|
||||
# 如果成功提取元数据,添加到结果列表
|
||||
if metadata:
|
||||
result.append(
|
||||
{
|
||||
"name": str(metadata.get("name", "")),
|
||||
"desc": str(metadata.get("desc", "")),
|
||||
"version": str(metadata.get("version", "")),
|
||||
"author": str(metadata.get("author", "")),
|
||||
"repo": str(metadata.get("repo", "")),
|
||||
"status": PluginStatus.INSTALLED,
|
||||
"local_path": str(plugin_dir),
|
||||
}
|
||||
)
|
||||
result.append({
|
||||
"name": str(metadata.get("name", "")),
|
||||
"desc": str(metadata.get("desc", "")),
|
||||
"version": str(metadata.get("version", "")),
|
||||
"author": str(metadata.get("author", "")),
|
||||
"repo": str(metadata.get("repo", "")),
|
||||
"status": PluginStatus.INSTALLED,
|
||||
"local_path": str(plugin_dir),
|
||||
})
|
||||
|
||||
# 获取在线插件列表
|
||||
online_plugins = []
|
||||
@@ -173,17 +139,15 @@ def build_plug_list(plugins_dir: Path) -> list:
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
for plugin_id, plugin_info in data.items():
|
||||
online_plugins.append(
|
||||
{
|
||||
"name": str(plugin_id),
|
||||
"desc": str(plugin_info.get("desc", "")),
|
||||
"version": str(plugin_info.get("version", "")),
|
||||
"author": str(plugin_info.get("author", "")),
|
||||
"repo": str(plugin_info.get("repo", "")),
|
||||
"status": PluginStatus.NOT_INSTALLED,
|
||||
"local_path": None,
|
||||
}
|
||||
)
|
||||
online_plugins.append({
|
||||
"name": str(plugin_id),
|
||||
"desc": str(plugin_info.get("desc", "")),
|
||||
"version": str(plugin_info.get("version", "")),
|
||||
"author": str(plugin_info.get("author", "")),
|
||||
"repo": str(plugin_info.get("repo", "")),
|
||||
"status": PluginStatus.NOT_INSTALLED,
|
||||
"local_path": None,
|
||||
})
|
||||
except Exception as e:
|
||||
click.echo(f"获取在线插件列表失败: {e}", err=True)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import os
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
VERSION = "3.5.10"
|
||||
VERSION = "3.5.12"
|
||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v3.db")
|
||||
|
||||
# 默认配置
|
||||
@@ -66,6 +66,7 @@ DEFAULT_CONFIG = {
|
||||
"enable": False,
|
||||
"provider_id": "",
|
||||
"dual_output": False,
|
||||
"use_file_service": False,
|
||||
},
|
||||
"provider_ltm_settings": {
|
||||
"group_icl_enable": False,
|
||||
@@ -91,6 +92,7 @@ DEFAULT_CONFIG = {
|
||||
"t2i_word_threshold": 150,
|
||||
"t2i_strategy": "remote",
|
||||
"t2i_endpoint": "",
|
||||
"t2i_use_file_service": False,
|
||||
"http_proxy": "",
|
||||
"dashboard": {
|
||||
"enable": True,
|
||||
@@ -176,6 +178,7 @@ CONFIG_METADATA_2 = {
|
||||
"api_base_url": "https://api.weixin.qq.com/cgi-bin/",
|
||||
"callback_server_host": "0.0.0.0",
|
||||
"port": 6194,
|
||||
"active_send_mode": False,
|
||||
},
|
||||
"wecom(企业微信)": {
|
||||
"id": "wecom",
|
||||
@@ -220,20 +223,25 @@ CONFIG_METADATA_2 = {
|
||||
},
|
||||
},
|
||||
"items": {
|
||||
"active_send_mode": {
|
||||
"description": "是否换用主动发送接口",
|
||||
"type": "bool",
|
||||
"desc": "只有企业认证的公众号才能主动发送。主动发送接口的限制会少一些。",
|
||||
},
|
||||
"wpp_active_message_poll": {
|
||||
"description": "是否启用主动消息轮询",
|
||||
"type": "bool",
|
||||
"hint": "只有当你发现微信消息没有按时同步到 AstrBot 时,才需要启用这个功能,默认不启用。"
|
||||
"description": "是否启用主动消息轮询",
|
||||
"type": "bool",
|
||||
"hint": "只有当你发现微信消息没有按时同步到 AstrBot 时,才需要启用这个功能,默认不启用。",
|
||||
},
|
||||
"wpp_active_message_poll_interval": {
|
||||
"description": "主动消息轮询间隔",
|
||||
"type": "int",
|
||||
"hint": "主动消息轮询间隔,单位为秒,默认 3 秒,最大不要超过 60 秒,否则可能被认为是旧消息。"
|
||||
"description": "主动消息轮询间隔",
|
||||
"type": "int",
|
||||
"hint": "主动消息轮询间隔,单位为秒,默认 3 秒,最大不要超过 60 秒,否则可能被认为是旧消息。",
|
||||
},
|
||||
"kf_name": {
|
||||
"description": "微信客服账号名",
|
||||
"type": "string",
|
||||
"hint": "可选。微信客服账号名(不是 ID)。可在 https://kf.weixin.qq.com/kf/frame#/accounts 获取"
|
||||
"description": "微信客服账号名",
|
||||
"type": "string",
|
||||
"hint": "可选。微信客服账号名(不是 ID)。可在 https://kf.weixin.qq.com/kf/frame#/accounts 获取",
|
||||
},
|
||||
"telegram_token": {
|
||||
"description": "Bot Token",
|
||||
@@ -256,10 +264,10 @@ CONFIG_METADATA_2 = {
|
||||
"hint": "Telegram 命令自动刷新间隔,单位为秒。",
|
||||
},
|
||||
"id": {
|
||||
"description": "ID",
|
||||
"description": "机器人名称",
|
||||
"type": "string",
|
||||
"obvious_hint": True,
|
||||
"hint": "ID 不能和其它的平台适配器重复,否则将发生严重冲突。",
|
||||
"hint": "机器人名称(ID)不能和其它的平台适配器重复。",
|
||||
},
|
||||
"type": {
|
||||
"description": "适配器类型",
|
||||
@@ -818,7 +826,7 @@ CONFIG_METADATA_2 = {
|
||||
"azure_tts_rate": "1",
|
||||
"azure_tts_volume": "100",
|
||||
"azure_tts_subscription_key": "",
|
||||
"azure_tts_region": "eastus"
|
||||
"azure_tts_region": "eastus",
|
||||
},
|
||||
"MiniMax TTS(API)": {
|
||||
"id": "minimax_tts",
|
||||
@@ -841,44 +849,118 @@ CONFIG_METADATA_2 = {
|
||||
"minimax-voice-english-normalization": False,
|
||||
"timeout": 20,
|
||||
},
|
||||
"火山引擎_TTS(API)": {
|
||||
"id": "volcengine_tts",
|
||||
"type": "volcengine_tts",
|
||||
"provider_type": "text_to_speech",
|
||||
"enable": False,
|
||||
"api_key": "",
|
||||
"appid": "",
|
||||
"volcengine_cluster": "volcano_tts",
|
||||
"volcengine_voice_type": "",
|
||||
"volcengine_speed_ratio": 1.0,
|
||||
"api_base": "https://openspeech.bytedance.com/api/v1/tts",
|
||||
"timeout": 20,
|
||||
},
|
||||
},
|
||||
"items": {
|
||||
"volcengine_cluster": {
|
||||
"type": "string",
|
||||
"description": "火山引擎集群",
|
||||
"hint": "若使用语音复刻大模型,可选volcano_icl或volcano_icl_concurr,默认使用volcano_tts",
|
||||
},
|
||||
"volcengine_voice_type": {
|
||||
"type": "string",
|
||||
"description": "火山引擎音色",
|
||||
"hint": "输入声音id(Voice_type)",
|
||||
},
|
||||
"volcengine_speed_ratio": {
|
||||
"type": "float",
|
||||
"description": "语速设置",
|
||||
"hint": "语速设置,范围为 0.2 到 3.0,默认值为 1.0",
|
||||
},
|
||||
"volcengine_volume_ratio": {
|
||||
"type": "float",
|
||||
"description": "音量设置",
|
||||
"hint": "音量设置,范围为 0.0 到 2.0,默认值为 1.0",
|
||||
},
|
||||
"azure_tts_voice": {
|
||||
"type": "string",
|
||||
"description": "音色设置",
|
||||
"hint": "API 音色"
|
||||
"hint": "API 音色",
|
||||
},
|
||||
"azure_tts_style": {
|
||||
"type": "string",
|
||||
"description": "风格设置",
|
||||
"hint": "声音特定的讲话风格。 可以表达快乐、同情和平静等情绪。"
|
||||
"hint": "声音特定的讲话风格。 可以表达快乐、同情和平静等情绪。",
|
||||
},
|
||||
"azure_tts_role": {
|
||||
"type": "string",
|
||||
"description": "模仿设置(可选)",
|
||||
"hint": "讲话角色扮演。 声音可以模仿不同的年龄和性别,但声音名称不会更改。 例如,男性语音可以提高音调和改变语调来模拟女性语音,但语音名称不会更改。 如果角色缺失或不受声音的支持,则会忽略此属性。",
|
||||
"options": ["Boy","Girl","YoungAdultFemale","YoungAdultMale","OlderAdultFemale","OlderAdultMale","SeniorFemale","SeniorMale","禁用"]
|
||||
"options": [
|
||||
"Boy",
|
||||
"Girl",
|
||||
"YoungAdultFemale",
|
||||
"YoungAdultMale",
|
||||
"OlderAdultFemale",
|
||||
"OlderAdultMale",
|
||||
"SeniorFemale",
|
||||
"SeniorMale",
|
||||
"禁用",
|
||||
],
|
||||
},
|
||||
"azure_tts_rate": {
|
||||
"type": "string",
|
||||
"description": "语速设置",
|
||||
"hint": "指示文本的讲出速率。可在字词或句子层面应用语速。 速率变化应为原始音频的 0.5 到 2 倍。"
|
||||
"hint": "指示文本的讲出速率。可在字词或句子层面应用语速。 速率变化应为原始音频的 0.5 到 2 倍。",
|
||||
},
|
||||
"azure_tts_volume": {
|
||||
"type": "string",
|
||||
"description": "语音音量设置",
|
||||
"hint": "指示语音的音量级别。 可在句子层面应用音量的变化。以从 0.0 到 100.0(从最安静到最大声,例如 75)的数字表示。 默认值为 100.0。"
|
||||
"hint": "指示语音的音量级别。 可在句子层面应用音量的变化。以从 0.0 到 100.0(从最安静到最大声,例如 75)的数字表示。 默认值为 100.0。",
|
||||
},
|
||||
"azure_tts_region": {
|
||||
"type": "string",
|
||||
"description": "API 地区",
|
||||
"hint": "Azure_TTS 处理数据所在区域,具体参考 https://learn.microsoft.com/zh-cn/azure/ai-services/speech-service/regions",
|
||||
"options": ["southafricanorth", "eastasia", "southeastasia", "australiaeast", "centralindia", "japaneast", "japanwest", "koreacentral", "canadacentral", "northeurope", "westeurope", "francecentral", "germanywestcentral", "norwayeast", "swedencentral", "switzerlandnorth", "switzerlandwest", "uksouth", "uaenorth", "brazilsouth", "qatarcentral", "centralus", "eastus", "eastus2", "northcentralus", "southcentralus", "westcentralus", "westus", "westus2", "westus3"]
|
||||
"options": [
|
||||
"southafricanorth",
|
||||
"eastasia",
|
||||
"southeastasia",
|
||||
"australiaeast",
|
||||
"centralindia",
|
||||
"japaneast",
|
||||
"japanwest",
|
||||
"koreacentral",
|
||||
"canadacentral",
|
||||
"northeurope",
|
||||
"westeurope",
|
||||
"francecentral",
|
||||
"germanywestcentral",
|
||||
"norwayeast",
|
||||
"swedencentral",
|
||||
"switzerlandnorth",
|
||||
"switzerlandwest",
|
||||
"uksouth",
|
||||
"uaenorth",
|
||||
"brazilsouth",
|
||||
"qatarcentral",
|
||||
"centralus",
|
||||
"eastus",
|
||||
"eastus2",
|
||||
"northcentralus",
|
||||
"southcentralus",
|
||||
"westcentralus",
|
||||
"westus",
|
||||
"westus2",
|
||||
"westus3",
|
||||
],
|
||||
},
|
||||
"azure_tts_subscription_key": {
|
||||
"type": "string",
|
||||
"description": "服务订阅密钥",
|
||||
"hint": "Azure_TTS 服务的订阅密钥(注意不是令牌)"
|
||||
"hint": "Azure_TTS 服务的订阅密钥(注意不是令牌)",
|
||||
},
|
||||
"dashscope_tts_voice": {
|
||||
"description": "语音合成模型",
|
||||
@@ -973,7 +1055,33 @@ CONFIG_METADATA_2 = {
|
||||
"type": "string",
|
||||
"description": "指定语言/方言",
|
||||
"hint": "增强对指定的小语种和方言的识别能力,设置后可以提升在指定小语种/方言场景下的语音表现",
|
||||
"options": [ "Chinese","Chinese,Yue","English","Arabic","Russian","Spanish","French","Portuguese","German","Turkish","Dutch","Ukrainian","Vietnamese","Indonesian","Japanese","Italian","Korean","Thai","Polish","Romanian","Greek","Czech","Finnish","Hindi","auto",],
|
||||
"options": [
|
||||
"Chinese",
|
||||
"Chinese,Yue",
|
||||
"English",
|
||||
"Arabic",
|
||||
"Russian",
|
||||
"Spanish",
|
||||
"French",
|
||||
"Portuguese",
|
||||
"German",
|
||||
"Turkish",
|
||||
"Dutch",
|
||||
"Ukrainian",
|
||||
"Vietnamese",
|
||||
"Indonesian",
|
||||
"Japanese",
|
||||
"Italian",
|
||||
"Korean",
|
||||
"Thai",
|
||||
"Polish",
|
||||
"Romanian",
|
||||
"Greek",
|
||||
"Czech",
|
||||
"Finnish",
|
||||
"Hindi",
|
||||
"auto",
|
||||
],
|
||||
},
|
||||
"minimax-voice-speed": {
|
||||
"type": "float",
|
||||
@@ -1010,7 +1118,15 @@ CONFIG_METADATA_2 = {
|
||||
"type": "string",
|
||||
"description": "情绪",
|
||||
"hint": "控制合成语音的情绪",
|
||||
"options": ["happy","sad","angry","fearful","disgusted","surprised","neutral",],
|
||||
"options": [
|
||||
"happy",
|
||||
"sad",
|
||||
"angry",
|
||||
"fearful",
|
||||
"disgusted",
|
||||
"surprised",
|
||||
"neutral",
|
||||
],
|
||||
},
|
||||
"minimax-voice-latex": {
|
||||
"type": "bool",
|
||||
@@ -1365,6 +1481,11 @@ CONFIG_METADATA_2 = {
|
||||
"hint": "启用后,Bot 将同时输出语音和文字消息。",
|
||||
"obvious_hint": True,
|
||||
},
|
||||
"use_file_service": {
|
||||
"description": "使用文件服务提供 TTS 语音文件",
|
||||
"type": "bool",
|
||||
"hint": "启用后,如已配置 callback_api_base ,将会使用文件服务提供TTS语音文件",
|
||||
},
|
||||
},
|
||||
},
|
||||
"provider_ltm_settings": {
|
||||
@@ -1481,7 +1602,7 @@ CONFIG_METADATA_2 = {
|
||||
"description": "对外可达的回调接口地址",
|
||||
"type": "string",
|
||||
"obvious_hint": True,
|
||||
"hint": "外部服务可能会通过 AstrBot 生成的回调链接(如文件下载链接)访问 AstrBot 后端。由于 AstrBot 无法自动判断部署环境中对外可达的主机地址(host),因此需要通过此配置项显式指定 “外部服务如何访问 AstrBot” 的地址。如 http://localhost:6185,https://example.com 等。"
|
||||
"hint": "外部服务可能会通过 AstrBot 生成的回调链接(如文件下载链接)访问 AstrBot 后端。由于 AstrBot 无法自动判断部署环境中对外可达的主机地址(host),因此需要通过此配置项显式指定 “外部服务如何访问 AstrBot” 的地址。如 http://localhost:6185,https://example.com 等。",
|
||||
},
|
||||
"log_level": {
|
||||
"description": "控制台日志级别",
|
||||
@@ -1500,6 +1621,11 @@ CONFIG_METADATA_2 = {
|
||||
"type": "string",
|
||||
"hint": "当 t2i_strategy 为 remote 时生效。为空时使用 AstrBot API 服务",
|
||||
},
|
||||
"t2i_use_file_service": {
|
||||
"description": "本地文本转图像使用文件服务提供文件",
|
||||
"type": "bool",
|
||||
"hint": "当 t2i_strategy 为 local 并且配置 callback_api_base 时生效。是否使用文件服务提供文件。",
|
||||
},
|
||||
"pip_install_arg": {
|
||||
"description": "pip 安装参数",
|
||||
"type": "string",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
Astrbot 核心生命周期管理类, 负责管理 AstrBot 的启动、停止、重启等操作。
|
||||
该类负责初始化各个组件, 包括 ProviderManager、PlatformManager、KnowledgeDBManager、ConversationManager、PluginManager、PipelineScheduler、EventBus等。
|
||||
该类负责初始化各个组件, 包括 ProviderManager、PlatformManager、ConversationManager、PluginManager、PipelineScheduler、EventBus等。
|
||||
该类还负责加载和执行插件, 以及处理事件总线的分发。
|
||||
|
||||
工作流程:
|
||||
@@ -28,7 +28,6 @@ from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.updator import AstrBotUpdator
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.config.default import VERSION
|
||||
from astrbot.core.rag.knowledge_db_mgr import KnowledgeDBManager
|
||||
from astrbot.core.conversation_mgr import ConversationManager
|
||||
from astrbot.core.star.star_handler import star_handlers_registry, EventType
|
||||
from astrbot.core.star.star_handler import star_map
|
||||
@@ -37,7 +36,7 @@ from astrbot.core.star.star_handler import star_map
|
||||
class AstrBotCoreLifecycle:
|
||||
"""
|
||||
AstrBot 核心生命周期管理类, 负责管理 AstrBot 的启动、停止、重启等操作。
|
||||
该类负责初始化各个组件, 包括 ProviderManager、PlatformManager、KnowledgeDBManager、ConversationManager、PluginManager、PipelineScheduler、
|
||||
该类负责初始化各个组件, 包括 ProviderManager、PlatformManager、ConversationManager、PluginManager、PipelineScheduler、
|
||||
EventBus 等。
|
||||
该类还负责加载和执行插件, 以及处理事件总线的分发。
|
||||
"""
|
||||
@@ -54,7 +53,7 @@ class AstrBotCoreLifecycle:
|
||||
|
||||
async def initialize(self):
|
||||
"""
|
||||
初始化 AstrBot 核心生命周期管理类, 负责初始化各个组件, 包括 ProviderManager、PlatformManager、KnowledgeDBManager、ConversationManager、PluginManager、PipelineScheduler、EventBus、AstrBotUpdator等。
|
||||
初始化 AstrBot 核心生命周期管理类, 负责初始化各个组件, 包括 ProviderManager、PlatformManager、ConversationManager、PluginManager、PipelineScheduler、EventBus、AstrBotUpdator等。
|
||||
"""
|
||||
|
||||
# 初始化日志代理
|
||||
@@ -73,9 +72,6 @@ class AstrBotCoreLifecycle:
|
||||
# 初始化平台管理器
|
||||
self.platform_manager = PlatformManager(self.astrbot_config, self.event_queue)
|
||||
|
||||
# 初始化知识库管理器
|
||||
self.knowledge_db_manager = KnowledgeDBManager(self.astrbot_config)
|
||||
|
||||
# 初始化对话管理器
|
||||
self.conversation_manager = ConversationManager(self.db)
|
||||
|
||||
@@ -87,7 +83,6 @@ class AstrBotCoreLifecycle:
|
||||
self.provider_manager,
|
||||
self.platform_manager,
|
||||
self.conversation_manager,
|
||||
self.knowledge_db_manager,
|
||||
)
|
||||
|
||||
# 初始化插件管理器
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import json
|
||||
import aiosqlite
|
||||
import os
|
||||
from typing import Any
|
||||
from .plugin_storage import PluginStorage
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
DBPATH = os.path.join(get_astrbot_data_path(), "plugin_data", "sqlite", "plugin_data.db")
|
||||
|
||||
|
||||
class SQLitePluginStorage(PluginStorage):
|
||||
"""插件数据的 SQLite 存储实现类。
|
||||
|
||||
该类提供异步方式将插件数据存储到 SQLite 数据库中,支持数据的增删改查操作。
|
||||
所有数据以 (plugin, key) 作为复合主键进行索引。
|
||||
"""
|
||||
|
||||
_instance = None # Standalone instance of the class
|
||||
_db_conn = None
|
||||
db_path = None
|
||||
|
||||
def __new__(cls):
|
||||
"""
|
||||
创建或获取 SQLitePluginStorage 的单例实例。
|
||||
如果实例已存在,则返回现有实例;否则创建一个新实例。
|
||||
数据在 `data/plugin_data/sqlite/plugin_data.db` 下。
|
||||
"""
|
||||
os.makedirs(os.path.dirname(DBPATH), exist_ok=True)
|
||||
if cls._instance is None:
|
||||
cls._instance = super(SQLitePluginStorage, cls).__new__(cls)
|
||||
cls._instance.db_path = DBPATH
|
||||
return cls._instance
|
||||
|
||||
async def _init_db(self):
|
||||
"""初始化数据库连接(只执行一次)"""
|
||||
if SQLitePluginStorage._db_conn is None:
|
||||
SQLitePluginStorage._db_conn = await aiosqlite.connect(self.db_path)
|
||||
await self._setup_db()
|
||||
|
||||
async def _setup_db(self):
|
||||
"""
|
||||
异步初始化数据库。
|
||||
|
||||
创建插件数据表,如果表不存在则创建,表结构包含 plugin、key 和 value 字段,
|
||||
其中 plugin 和 key 组合作为主键。
|
||||
"""
|
||||
await self._db_conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS plugin_data (
|
||||
plugin TEXT,
|
||||
key TEXT,
|
||||
value TEXT,
|
||||
PRIMARY KEY (plugin, key)
|
||||
)
|
||||
""")
|
||||
await self._db_conn.commit()
|
||||
|
||||
async def set(self, plugin: str, key: str, value: Any):
|
||||
"""
|
||||
异步存储数据。
|
||||
|
||||
将指定插件的键值对存入数据库,如果键已存在则更新值。
|
||||
值会被序列化为 JSON 字符串后存储。
|
||||
|
||||
Args:
|
||||
plugin: 插件标识符
|
||||
key: 数据键名
|
||||
value: 要存储的数据值(任意类型,将被 JSON 序列化)
|
||||
"""
|
||||
await self._init_db()
|
||||
await self._db_conn.execute(
|
||||
"INSERT INTO plugin_data (plugin, key, value) VALUES (?, ?, ?) "
|
||||
"ON CONFLICT(plugin, key) DO UPDATE SET value = excluded.value",
|
||||
(plugin, key, json.dumps(value)),
|
||||
)
|
||||
await self._db_conn.commit()
|
||||
|
||||
async def get(self, plugin: str, key: str) -> Any:
|
||||
"""
|
||||
异步获取数据。
|
||||
|
||||
从数据库中获取指定插件和键名对应的值,
|
||||
返回的值会从 JSON 字符串反序列化为原始数据类型。
|
||||
|
||||
Args:
|
||||
plugin: 插件标识符
|
||||
key: 数据键名
|
||||
|
||||
Returns:
|
||||
Any: 存储的数据值,如果未找到则返回 None
|
||||
"""
|
||||
await self._init_db()
|
||||
async with self._db_conn.execute(
|
||||
"SELECT value FROM plugin_data WHERE plugin = ? AND key = ?",
|
||||
(plugin, key),
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
return json.loads(row[0]) if row else None
|
||||
|
||||
async def delete(self, plugin: str, key: str):
|
||||
"""
|
||||
异步删除数据。
|
||||
|
||||
从数据库中删除指定插件和键名对应的数据项。
|
||||
|
||||
Args:
|
||||
plugin: 插件标识符
|
||||
key: 要删除的数据键名
|
||||
"""
|
||||
await self._init_db()
|
||||
await self._db_conn.execute(
|
||||
"DELETE FROM plugin_data WHERE plugin = ? AND key = ?", (plugin, key)
|
||||
)
|
||||
await self._db_conn.commit()
|
||||
46
astrbot/core/db/vec_db/base.py
Normal file
46
astrbot/core/db/vec_db/base.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import abc
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Result:
|
||||
similarity: float
|
||||
data: dict
|
||||
|
||||
|
||||
class BaseVecDB:
|
||||
async def initialize(self):
|
||||
"""
|
||||
初始化向量数据库
|
||||
"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
async def insert(self, content: str, metadata: dict = None, id: str = None) -> int:
|
||||
"""
|
||||
插入一条文本和其对应向量,自动生成 ID 并保持一致性。
|
||||
"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def retrieve(self, query: str, top_k: int = 5) -> list[Result]:
|
||||
"""
|
||||
搜索最相似的文档。
|
||||
Args:
|
||||
query (str): 查询文本
|
||||
top_k (int): 返回的最相似文档的数量
|
||||
Returns:
|
||||
List[Result]: 查询结果
|
||||
"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def delete(self, doc_id: str) -> bool:
|
||||
"""
|
||||
删除指定文档。
|
||||
Args:
|
||||
doc_id (str): 要删除的文档 ID
|
||||
Returns:
|
||||
bool: 删除是否成功
|
||||
"""
|
||||
...
|
||||
3
astrbot/core/db/vec_db/faiss_impl/__init__.py
Normal file
3
astrbot/core/db/vec_db/faiss_impl/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .vec_db import FaissVecDB
|
||||
|
||||
__all__ = ["FaissVecDB"]
|
||||
121
astrbot/core/db/vec_db/faiss_impl/document_storage.py
Normal file
121
astrbot/core/db/vec_db/faiss_impl/document_storage.py
Normal file
@@ -0,0 +1,121 @@
|
||||
import aiosqlite
|
||||
import os
|
||||
|
||||
|
||||
class DocumentStorage:
|
||||
def __init__(self, db_path: str):
|
||||
self.db_path = db_path
|
||||
self.connection = None
|
||||
self.sqlite_init_path = os.path.join(
|
||||
os.path.dirname(__file__), "sqlite_init.sql"
|
||||
)
|
||||
|
||||
async def initialize(self):
|
||||
"""Initialize the SQLite database and create the documents table if it doesn't exist."""
|
||||
if not os.path.exists(self.db_path):
|
||||
await self.connect()
|
||||
async with self.connection.cursor() as cursor:
|
||||
with open(self.sqlite_init_path, "r", encoding="utf-8") as f:
|
||||
sql_script = f.read()
|
||||
await cursor.executescript(sql_script)
|
||||
await self.connection.commit()
|
||||
else:
|
||||
await self.connect()
|
||||
|
||||
async def connect(self):
|
||||
"""Connect to the SQLite database."""
|
||||
self.connection = await aiosqlite.connect(self.db_path)
|
||||
|
||||
async def get_documents(self, metadata_filters: dict, ids: list = None):
|
||||
"""Retrieve documents by metadata filters and ids.
|
||||
|
||||
Args:
|
||||
metadata_filters (dict): The metadata filters to apply.
|
||||
|
||||
Returns:
|
||||
list: The list of document IDs(primary key, not doc_id) that match the filters.
|
||||
"""
|
||||
# metadata filter -> SQL WHERE clause
|
||||
where_clauses = []
|
||||
values = []
|
||||
for key, val in metadata_filters.items():
|
||||
where_clauses.append(f"json_extract(metadata, '$.{key}') = ?")
|
||||
values.append(val)
|
||||
if ids is not None and len(ids) > 0:
|
||||
ids = [str(i) for i in ids if i != -1]
|
||||
where_clauses.append("id IN ({})".format(",".join("?" * len(ids))))
|
||||
values.extend(ids)
|
||||
where_sql = " AND ".join(where_clauses) or "1=1"
|
||||
|
||||
result = []
|
||||
async with self.connection.cursor() as cursor:
|
||||
sql = "SELECT * FROM documents WHERE " + where_sql
|
||||
await cursor.execute(sql, values)
|
||||
for row in await cursor.fetchall():
|
||||
result.append(await self.tuple_to_dict(row))
|
||||
return result
|
||||
|
||||
async def get_document_by_doc_id(self, doc_id: str):
|
||||
"""Retrieve a document by its doc_id.
|
||||
|
||||
Args:
|
||||
doc_id (str): The doc_id of the document to retrieve.
|
||||
|
||||
Returns:
|
||||
dict: The document data.
|
||||
"""
|
||||
async with self.connection.cursor() as cursor:
|
||||
await cursor.execute("SELECT * FROM documents WHERE doc_id = ?", (doc_id,))
|
||||
row = await cursor.fetchone()
|
||||
if row:
|
||||
return await self.tuple_to_dict(row)
|
||||
else:
|
||||
return None
|
||||
|
||||
async def update_document_by_doc_id(self, doc_id: str, new_text: str):
|
||||
"""Retrieve a document by its doc_id.
|
||||
|
||||
Args:
|
||||
doc_id (str): The doc_id.
|
||||
new_text (str): The new text to update the document with.
|
||||
"""
|
||||
async with self.connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"UPDATE documents SET text = ? WHERE doc_id = ?", (new_text, doc_id)
|
||||
)
|
||||
await self.connection.commit()
|
||||
|
||||
async def get_user_ids(self) -> list[str]:
|
||||
"""Retrieve all user IDs from the documents table.
|
||||
|
||||
Returns:
|
||||
list: A list of user IDs.
|
||||
"""
|
||||
async with self.connection.cursor() as cursor:
|
||||
await cursor.execute("SELECT DISTINCT user_id FROM documents")
|
||||
rows = await cursor.fetchall()
|
||||
return [row[0] for row in rows]
|
||||
|
||||
async def tuple_to_dict(self, row):
|
||||
"""Convert a tuple to a dictionary.
|
||||
|
||||
Args:
|
||||
row (tuple): The row to convert.
|
||||
|
||||
Returns:
|
||||
dict: The converted dictionary.
|
||||
"""
|
||||
return {
|
||||
"id": row[0],
|
||||
"doc_id": row[1],
|
||||
"text": row[2],
|
||||
"metadata": row[3],
|
||||
"created_at": row[4],
|
||||
"updated_at": row[5],
|
||||
}
|
||||
|
||||
async def close(self):
|
||||
"""Close the connection to the SQLite database."""
|
||||
if self.connection:
|
||||
await self.connection.close()
|
||||
self.connection = None
|
||||
59
astrbot/core/db/vec_db/faiss_impl/embedding_storage.py
Normal file
59
astrbot/core/db/vec_db/faiss_impl/embedding_storage.py
Normal file
@@ -0,0 +1,59 @@
|
||||
try:
|
||||
import faiss
|
||||
except ModuleNotFoundError:
|
||||
raise ImportError(
|
||||
"faiss 未安装。请使用 'pip install faiss-cpu' 或 'pip install faiss-gpu' 安装。"
|
||||
)
|
||||
import os
|
||||
import numpy as np
|
||||
|
||||
|
||||
class EmbeddingStorage:
|
||||
def __init__(self, dimension: int, path: str = None):
|
||||
self.dimension = dimension
|
||||
self.path = path
|
||||
self.index = None
|
||||
if path and os.path.exists(path):
|
||||
self.index = faiss.read_index(path)
|
||||
else:
|
||||
base_index = faiss.IndexFlatL2(dimension)
|
||||
self.index = faiss.IndexIDMap(base_index)
|
||||
self.storage = {}
|
||||
|
||||
async def insert(self, vector: np.ndarray, id: int):
|
||||
"""插入向量
|
||||
|
||||
Args:
|
||||
vector (np.ndarray): 要插入的向量
|
||||
id (int): 向量的ID
|
||||
Raises:
|
||||
ValueError: 如果向量的维度与存储的维度不匹配
|
||||
"""
|
||||
if vector.shape[0] != self.dimention:
|
||||
raise ValueError(
|
||||
f"向量维度不匹配, 期望: {self.dimention}, 实际: {vector.shape[0]}"
|
||||
)
|
||||
self.index.add_with_ids(vector.reshape(1, -1), np.array([id]))
|
||||
self.storage[id] = vector
|
||||
await self.save_index()
|
||||
|
||||
async def search(self, vector: np.ndarray, k: int) -> tuple:
|
||||
"""搜索最相似的向量
|
||||
|
||||
Args:
|
||||
vector (np.ndarray): 查询向量
|
||||
k (int): 返回的最相似向量的数量
|
||||
Returns:
|
||||
tuple: (距离, 索引)
|
||||
"""
|
||||
faiss.normalize_L2(vector)
|
||||
distances, indices = self.index.search(vector, k)
|
||||
return distances, indices
|
||||
|
||||
async def save_index(self):
|
||||
"""保存索引
|
||||
|
||||
Args:
|
||||
path (str): 保存索引的路径
|
||||
"""
|
||||
faiss.write_index(self.index, self.path)
|
||||
17
astrbot/core/db/vec_db/faiss_impl/sqlite_init.sql
Normal file
17
astrbot/core/db/vec_db/faiss_impl/sqlite_init.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- 创建文档存储表,包含 faiss 中文档的 id,文档文本,create_at,updated_at
|
||||
CREATE TABLE documents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
doc_id TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
metadata TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
ALTER TABLE documents
|
||||
ADD COLUMN group_id TEXT GENERATED ALWAYS AS (json_extract(metadata, '$.group_id')) STORED;
|
||||
ALTER TABLE documents
|
||||
ADD COLUMN user_id TEXT GENERATED ALWAYS AS (json_extract(metadata, '$.user_id')) STORED;
|
||||
|
||||
CREATE INDEX idx_documents_user_id ON documents(user_id);
|
||||
CREATE INDEX idx_documents_group_id ON documents(group_id);
|
||||
123
astrbot/core/db/vec_db/faiss_impl/vec_db.py
Normal file
123
astrbot/core/db/vec_db/faiss_impl/vec_db.py
Normal file
@@ -0,0 +1,123 @@
|
||||
import uuid
|
||||
import json
|
||||
import numpy as np
|
||||
from .document_storage import DocumentStorage
|
||||
from .embedding_storage import EmbeddingStorage
|
||||
from ..base import Result, BaseVecDB
|
||||
from astrbot.core.provider.provider import EmbeddingProvider
|
||||
|
||||
|
||||
class FaissVecDB(BaseVecDB):
|
||||
"""
|
||||
A class to represent a vector database.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
doc_store_path: str,
|
||||
index_store_path: str,
|
||||
embedding_provider: EmbeddingProvider,
|
||||
):
|
||||
self.doc_store_path = doc_store_path
|
||||
self.index_store_path = index_store_path
|
||||
self.embedding_provider = embedding_provider
|
||||
self.document_storage = DocumentStorage(doc_store_path)
|
||||
self.embedding_storage = EmbeddingStorage(
|
||||
embedding_provider.get_dim(), index_store_path
|
||||
)
|
||||
self.embedding_provider = embedding_provider
|
||||
|
||||
async def initialize(self):
|
||||
await self.document_storage.initialize()
|
||||
|
||||
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:
|
||||
await cursor.execute(
|
||||
"INSERT INTO documents (doc_id, text, metadata) VALUES (?, ?, ?)",
|
||||
(str_id, content, json.dumps(metadata)),
|
||||
)
|
||||
await self.document_storage.connection.commit()
|
||||
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
|
||||
|
||||
async def retrieve(
|
||||
self, query: str, k: int = 5, fetch_k: int = 20, metadata_filters: dict = None
|
||||
) -> list[Result]:
|
||||
"""
|
||||
搜索最相似的文档。
|
||||
|
||||
Args:
|
||||
query (str): 查询文本
|
||||
k (int): 返回的最相似文档的数量
|
||||
fetch_k (int): 在根据 metadata 过滤前从 FAISS 中获取的数量
|
||||
metadata_filters (dict): 元数据过滤器
|
||||
|
||||
Returns:
|
||||
List[Result]: 查询结果
|
||||
"""
|
||||
embedding = await self.embedding_provider.get_embedding(query)
|
||||
scores, indices = await self.embedding_storage.search(
|
||||
vector=np.array([embedding]).astype("float32"),
|
||||
k=fetch_k if metadata_filters else k,
|
||||
)
|
||||
# TODO: rerank
|
||||
if len(indices[0]) == 0 or indices[0][0] == -1:
|
||||
return []
|
||||
# normalize scores
|
||||
scores[0] = 1.0 - (scores[0] / 2.0)
|
||||
# NOTE: maybe the size is less than k.
|
||||
fetched_docs = await self.document_storage.get_documents(
|
||||
metadata_filters=metadata_filters or {}, ids=indices[0]
|
||||
)
|
||||
if not fetched_docs:
|
||||
return []
|
||||
result_docs = []
|
||||
|
||||
idx_pos = {fetch_doc["id"]: idx for idx, fetch_doc in enumerate(fetched_docs)}
|
||||
for i, indice_idx in enumerate(indices[0]):
|
||||
pos = idx_pos.get(indice_idx)
|
||||
if pos is None:
|
||||
continue
|
||||
fetch_doc = fetched_docs[pos]
|
||||
score = scores[0][i]
|
||||
result_docs.append(Result(similarity=float(score), data=fetch_doc))
|
||||
return result_docs[:k]
|
||||
|
||||
async def delete(self, doc_id: int):
|
||||
"""
|
||||
删除一条文档
|
||||
"""
|
||||
await self.document_storage.connection.execute(
|
||||
"DELETE FROM documents WHERE doc_id = ?", (doc_id,)
|
||||
)
|
||||
await self.document_storage.connection.commit()
|
||||
|
||||
async def close(self):
|
||||
await self.document_storage.close()
|
||||
|
||||
async def count_documents(self) -> int:
|
||||
"""
|
||||
计算文档数量
|
||||
"""
|
||||
async with self.document_storage.connection.cursor() as cursor:
|
||||
await cursor.execute("SELECT COUNT(*) FROM documents")
|
||||
count = await cursor.fetchone()
|
||||
return count[0] if count else 0
|
||||
@@ -26,13 +26,14 @@ class InitialLoader:
|
||||
async def start(self):
|
||||
core_lifecycle = AstrBotCoreLifecycle(self.log_broker, self.db)
|
||||
|
||||
core_task = []
|
||||
try:
|
||||
await core_lifecycle.initialize()
|
||||
core_task = core_lifecycle.start()
|
||||
except Exception as e:
|
||||
logger.critical(traceback.format_exc())
|
||||
logger.critical(f"😭 初始化 AstrBot 失败:{e} !!!")
|
||||
return
|
||||
|
||||
core_task = core_lifecycle.start()
|
||||
|
||||
self.dashboard_server = AstrBotDashboard(
|
||||
core_lifecycle, self.db, core_lifecycle.dashboard_shutdown_event
|
||||
|
||||
@@ -102,6 +102,10 @@ class BaseMessageComponent(BaseModel):
|
||||
data[k] = v
|
||||
return {"type": self.type.lower(), "data": data}
|
||||
|
||||
async def to_dict(self) -> dict:
|
||||
# 默认情况下,回退到旧的同步 toDict()
|
||||
return self.toDict()
|
||||
|
||||
|
||||
class Plain(BaseMessageComponent):
|
||||
type: ComponentType = "Plain"
|
||||
@@ -118,6 +122,9 @@ class Plain(BaseMessageComponent):
|
||||
self.text.replace("&", "&").replace("[", "[").replace("]", "]")
|
||||
)
|
||||
|
||||
def toDict(self):
|
||||
return {"type": "text", "data": {"text": self.text.strip()}}
|
||||
|
||||
|
||||
class Face(BaseMessageComponent):
|
||||
type: ComponentType = "Face"
|
||||
@@ -235,9 +242,6 @@ class Video(BaseMessageComponent):
|
||||
path: T.Optional[str] = ""
|
||||
|
||||
def __init__(self, file: str, **_):
|
||||
# for k in _.keys():
|
||||
# if k == "c" and _[k] not in [2, 3]:
|
||||
# logger.warn(f"Protocol: {k}={_[k]} doesn't match values")
|
||||
super().__init__(file=file, **_)
|
||||
|
||||
@staticmethod
|
||||
@@ -250,6 +254,70 @@ class Video(BaseMessageComponent):
|
||||
return Video(file=url, **_)
|
||||
raise Exception("not a valid url")
|
||||
|
||||
async def convert_to_file_path(self) -> str:
|
||||
"""将这个视频统一转换为本地文件路径。这个方法避免了手动判断视频数据类型,直接返回视频数据的本地路径(如果是网络 URL,则会自动进行下载)。
|
||||
|
||||
Returns:
|
||||
str: 视频的本地路径,以绝对路径表示。
|
||||
"""
|
||||
url = self.file
|
||||
if url and url.startswith("file:///"):
|
||||
return url[8:]
|
||||
elif url and url.startswith("http"):
|
||||
download_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
video_file_path = os.path.join(download_dir, f"{uuid.uuid4().hex}")
|
||||
await download_file(url, video_file_path)
|
||||
if os.path.exists(video_file_path):
|
||||
return os.path.abspath(video_file_path)
|
||||
else:
|
||||
raise Exception(f"download failed: {url}")
|
||||
elif os.path.exists(url):
|
||||
return os.path.abspath(url)
|
||||
else:
|
||||
raise Exception(f"not a valid file: {url}")
|
||||
|
||||
async def register_to_file_service(self):
|
||||
"""
|
||||
将视频注册到文件服务。
|
||||
|
||||
Returns:
|
||||
str: 注册后的URL
|
||||
|
||||
Raises:
|
||||
Exception: 如果未配置 callback_api_base
|
||||
"""
|
||||
callback_host = astrbot_config.get("callback_api_base")
|
||||
|
||||
if not callback_host:
|
||||
raise Exception("未配置 callback_api_base,文件服务不可用")
|
||||
|
||||
file_path = await self.convert_to_file_path()
|
||||
|
||||
token = await file_token_service.register_file(file_path)
|
||||
|
||||
logger.debug(f"已注册:{callback_host}/api/file/{token}")
|
||||
|
||||
return f"{callback_host}/api/file/{token}"
|
||||
|
||||
async def to_dict(self):
|
||||
"""需要和 toDict 区分开,toDict 是同步方法"""
|
||||
url_or_path = self.file
|
||||
if url_or_path.startswith("http"):
|
||||
payload_file = url_or_path
|
||||
elif callback_host := astrbot_config.get("callback_api_base"):
|
||||
callback_host = str(callback_host).removesuffix("/")
|
||||
token = await file_token_service.register_file(url_or_path)
|
||||
payload_file = f"{callback_host}/api/file/{token}"
|
||||
logger.debug(f"Generated video file callback link: {payload_file}")
|
||||
else:
|
||||
payload_file = url_or_path
|
||||
return {
|
||||
"type": "video",
|
||||
"data": {
|
||||
"file": payload_file,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class At(BaseMessageComponent):
|
||||
type: ComponentType = "At"
|
||||
@@ -259,6 +327,12 @@ class At(BaseMessageComponent):
|
||||
def __init__(self, **_):
|
||||
super().__init__(**_)
|
||||
|
||||
def toDict(self):
|
||||
return {
|
||||
"type": "at",
|
||||
"data": {"qq": str(self.qq)},
|
||||
}
|
||||
|
||||
|
||||
class AtAll(At):
|
||||
qq: str = "all"
|
||||
@@ -514,27 +588,47 @@ class Node(BaseMessageComponent):
|
||||
id: T.Optional[int] = 0 # 忽略
|
||||
name: T.Optional[str] = "" # qq昵称
|
||||
uin: T.Optional[str] = "0" # qq号
|
||||
content: T.Optional[T.Union[str, list, dict]] = "" # 子消息段列表
|
||||
content: T.Optional[list[BaseMessageComponent]] = []
|
||||
seq: T.Optional[T.Union[str, list]] = "" # 忽略
|
||||
time: T.Optional[int] = 0 # 忽略
|
||||
|
||||
def __init__(self, content: T.Union[str, list, dict, "Node", T.List["Node"]], **_):
|
||||
if isinstance(content, list):
|
||||
_content = None
|
||||
if all(isinstance(item, Node) for item in content):
|
||||
_content = [node.toDict() for node in content]
|
||||
else:
|
||||
_content = ""
|
||||
for chain in content:
|
||||
_content += chain.toString()
|
||||
content = _content
|
||||
elif isinstance(content, Node):
|
||||
content = content.toDict()
|
||||
def __init__(self, content: list[BaseMessageComponent], **_):
|
||||
if isinstance(content, Node):
|
||||
# back
|
||||
content = [content]
|
||||
super().__init__(content=content, **_)
|
||||
|
||||
def toString(self):
|
||||
# logger.warn("Protocol: node doesn't support stringify")
|
||||
return ""
|
||||
async def to_dict(self):
|
||||
data_content = []
|
||||
for comp in self.content:
|
||||
if isinstance(comp, (Image, Record)):
|
||||
# For Image and Record segments, we convert them to base64
|
||||
bs64 = await comp.convert_to_base64()
|
||||
data_content.append(
|
||||
{
|
||||
"type": comp.type.lower(),
|
||||
"data": {"file": f"base64://{bs64}"},
|
||||
}
|
||||
)
|
||||
elif isinstance(comp, File):
|
||||
# For File segments, we need to handle the file differently
|
||||
d = await comp.to_dict()
|
||||
data_content.append(d)
|
||||
elif isinstance(comp, (Node, Nodes)):
|
||||
# For Node segments, we recursively convert them to dict
|
||||
d = await comp.to_dict()
|
||||
data_content.append(d)
|
||||
else:
|
||||
d = comp.toDict()
|
||||
data_content.append(d)
|
||||
return {
|
||||
"type": "node",
|
||||
"data": {
|
||||
"user_id": str(self.uin),
|
||||
"nickname": self.name,
|
||||
"content": data_content,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class Nodes(BaseMessageComponent):
|
||||
@@ -545,12 +639,20 @@ class Nodes(BaseMessageComponent):
|
||||
super().__init__(nodes=nodes, **_)
|
||||
|
||||
def toDict(self):
|
||||
"""Deprecated. Use to_dict instead"""
|
||||
ret = {
|
||||
"messages": [],
|
||||
}
|
||||
for node in self.nodes:
|
||||
d = node.toDict()
|
||||
d["data"]["uin"] = str(node.uin) # 转为字符串
|
||||
ret["messages"].append(d)
|
||||
return ret
|
||||
|
||||
async def to_dict(self):
|
||||
"""将 Nodes 转换为字典格式,适用于 OneBot JSON 格式"""
|
||||
ret = {"messages": []}
|
||||
for node in self.nodes:
|
||||
d = await node.to_dict()
|
||||
ret["messages"].append(d)
|
||||
return ret
|
||||
|
||||
@@ -723,6 +825,26 @@ class File(BaseMessageComponent):
|
||||
|
||||
return f"{callback_host}/api/file/{token}"
|
||||
|
||||
async def to_dict(self):
|
||||
"""需要和 toDict 区分开,toDict 是同步方法"""
|
||||
url_or_path = await self.get_file(allow_return_url=True)
|
||||
if url_or_path.startswith("http"):
|
||||
payload_file = url_or_path
|
||||
elif callback_host := astrbot_config.get("callback_api_base"):
|
||||
callback_host = str(callback_host).removesuffix("/")
|
||||
token = await file_token_service.register_file(url_or_path)
|
||||
payload_file = f"{callback_host}/api/file/{token}"
|
||||
logger.debug(f"Generated file callback link: {payload_file}")
|
||||
else:
|
||||
payload_file = url_or_path
|
||||
return {
|
||||
"type": "file",
|
||||
"data": {
|
||||
"name": self.name,
|
||||
"file": payload_file,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class WechatEmoji(BaseMessageComponent):
|
||||
type: ComponentType = "WechatEmoji"
|
||||
|
||||
@@ -46,28 +46,29 @@ class PreProcessStage(Stage):
|
||||
stt_provider = (
|
||||
self.plugin_manager.context.provider_manager.curr_stt_provider_inst
|
||||
)
|
||||
if stt_provider:
|
||||
message_chain = event.get_messages()
|
||||
for idx, component in enumerate(message_chain):
|
||||
if isinstance(component, Record) and component.url:
|
||||
path = component.url.removeprefix("file://")
|
||||
retry = 5
|
||||
for i in range(retry):
|
||||
try:
|
||||
result = await stt_provider.get_text(audio_url=path)
|
||||
if result:
|
||||
logger.info("语音转文本结果: " + result)
|
||||
message_chain[idx] = Plain(result)
|
||||
event.message_str += result
|
||||
event.message_obj.message_str += result
|
||||
break
|
||||
except FileNotFoundError as e:
|
||||
# napcat workaround
|
||||
logger.warning(e)
|
||||
logger.warning(f"重试中: {i + 1}/{retry}")
|
||||
await asyncio.sleep(0.5)
|
||||
continue
|
||||
except BaseException as e:
|
||||
logger.error(traceback.format_exc())
|
||||
logger.error(f"语音转文本失败: {e}")
|
||||
break
|
||||
if not stt_provider:
|
||||
return
|
||||
message_chain = event.get_messages()
|
||||
for idx, component in enumerate(message_chain):
|
||||
if isinstance(component, Record) and component.url:
|
||||
path = component.url.removeprefix("file://")
|
||||
retry = 5
|
||||
for i in range(retry):
|
||||
try:
|
||||
result = await stt_provider.get_text(audio_url=path)
|
||||
if result:
|
||||
logger.info("语音转文本结果: " + result)
|
||||
message_chain[idx] = Plain(result)
|
||||
event.message_str += result
|
||||
event.message_obj.message_str += result
|
||||
break
|
||||
except FileNotFoundError as e:
|
||||
# napcat workaround
|
||||
logger.warning(e)
|
||||
logger.warning(f"重试中: {i + 1}/{retry}")
|
||||
await asyncio.sleep(0.5)
|
||||
continue
|
||||
except BaseException as e:
|
||||
logger.error(traceback.format_exc())
|
||||
logger.error(f"语音转文本失败: {e}")
|
||||
break
|
||||
|
||||
@@ -67,6 +67,10 @@ class LLMRequestSubStage(Stage):
|
||||
) -> Union[None, AsyncGenerator[None, None]]:
|
||||
req: ProviderRequest = None
|
||||
|
||||
if not self.ctx.astrbot_config["provider_settings"]["enable"]:
|
||||
logger.debug("未启用 LLM 能力,跳过处理。")
|
||||
return
|
||||
|
||||
provider = self.ctx.plugin_manager.context.get_using_provider()
|
||||
if provider is None:
|
||||
return
|
||||
|
||||
@@ -29,9 +29,7 @@ class RespondStage(Stage):
|
||||
Comp.Image: lambda comp: bool(comp.file), # 图片
|
||||
Comp.Reply: lambda comp: bool(comp.id) and comp.sender_id is not None, # 回复
|
||||
Comp.Poke: lambda comp: comp.id != 0 and comp.qq != 0, # 戳一戳
|
||||
Comp.Node: lambda comp: bool(comp.name)
|
||||
and comp.uin != 0
|
||||
and bool(comp.content), # 一个转发节点
|
||||
Comp.Node: lambda comp: bool(comp.content), # 转发节点
|
||||
Comp.Nodes: lambda comp: bool(comp.nodes), # 多个转发节点
|
||||
Comp.File: lambda comp: bool(comp.file_ or comp.url),
|
||||
}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import time
|
||||
import re
|
||||
import time
|
||||
import traceback
|
||||
from typing import Union, AsyncGenerator
|
||||
from ..stage import Stage, register_stage, registered_stages
|
||||
from ..context import PipelineContext
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from typing import AsyncGenerator, Union
|
||||
|
||||
from astrbot.core import html_renderer, logger, file_token_service
|
||||
from astrbot.core.message.components import At, File, Image, Node, Plain, Record, Reply
|
||||
from astrbot.core.message.message_event_result import ResultContentType
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from astrbot.core.platform.message_type import MessageType
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.message.components import Plain, Image, At, Reply, Record, File, Node
|
||||
from astrbot.core import html_renderer
|
||||
from astrbot.core.star.star_handler import star_handlers_registry, EventType
|
||||
from astrbot.core.star.star import star_map
|
||||
from astrbot.core.star.star_handler import EventType, star_handlers_registry
|
||||
|
||||
from ..context import PipelineContext
|
||||
from ..stage import Stage, register_stage, registered_stages
|
||||
|
||||
|
||||
@register_stage
|
||||
@@ -168,30 +169,55 @@ class ResultDecorateStage(Stage):
|
||||
result.chain = new_chain
|
||||
|
||||
# TTS
|
||||
tts_provider = (
|
||||
self.ctx.plugin_manager.context.provider_manager.curr_tts_provider_inst
|
||||
)
|
||||
if (
|
||||
self.ctx.astrbot_config["provider_tts_settings"]["enable"]
|
||||
and result.is_llm_result()
|
||||
and tts_provider
|
||||
):
|
||||
tts_provider = self.ctx.plugin_manager.context.provider_manager.curr_tts_provider_inst
|
||||
new_chain = []
|
||||
for comp in result.chain:
|
||||
if isinstance(comp, Plain) and len(comp.text) > 1:
|
||||
try:
|
||||
logger.info("TTS 请求: " + comp.text)
|
||||
logger.info(f"TTS 请求: {comp.text}")
|
||||
audio_path = await tts_provider.get_audio(comp.text)
|
||||
logger.info("TTS 结果: " + audio_path)
|
||||
if audio_path:
|
||||
new_chain.append(
|
||||
Record(file=audio_path, url=audio_path)
|
||||
)
|
||||
if(self.ctx.astrbot_config["provider_tts_settings"]["dual_output"]):
|
||||
new_chain.append(comp)
|
||||
else:
|
||||
logger.info(f"TTS 结果: {audio_path}")
|
||||
if not audio_path:
|
||||
logger.error(
|
||||
f"由于 TTS 音频文件没找到,消息段转语音失败: {comp.text}"
|
||||
f"由于 TTS 音频文件未找到,消息段转语音失败: {comp.text}"
|
||||
)
|
||||
new_chain.append(comp)
|
||||
except BaseException:
|
||||
continue
|
||||
|
||||
use_file_service = self.ctx.astrbot_config[
|
||||
"provider_tts_settings"
|
||||
]["use_file_service"]
|
||||
callback_api_base = self.ctx.astrbot_config[
|
||||
"callback_api_base"
|
||||
]
|
||||
dual_output = self.ctx.astrbot_config[
|
||||
"provider_tts_settings"
|
||||
]["dual_output"]
|
||||
|
||||
url = None
|
||||
if use_file_service and callback_api_base:
|
||||
token = await file_token_service.register_file(
|
||||
audio_path
|
||||
)
|
||||
url = f"{callback_api_base}/api/file/{token}"
|
||||
logger.debug(f"已注册:{url}")
|
||||
|
||||
new_chain.append(
|
||||
Record(
|
||||
file=url or audio_path,
|
||||
url=url or audio_path,
|
||||
)
|
||||
)
|
||||
if dual_output:
|
||||
new_chain.append(comp)
|
||||
except Exception:
|
||||
logger.error(traceback.format_exc())
|
||||
logger.error("TTS 失败,使用文本发送。")
|
||||
new_chain.append(comp)
|
||||
@@ -225,6 +251,14 @@ class ResultDecorateStage(Stage):
|
||||
if url:
|
||||
if url.startswith("http"):
|
||||
result.chain = [Image.fromURL(url)]
|
||||
elif (
|
||||
self.ctx.astrbot_config["t2i_use_file_service"]
|
||||
and self.ctx.astrbot_config["callback_api_base"]
|
||||
):
|
||||
token = await file_token_service.register_file(url)
|
||||
url = f"{self.ctx.astrbot_config['callback_api_base']}/api/file/{token}"
|
||||
logger.debug(f"已注册:{url}")
|
||||
result.chain = [Image.fromURL(url)]
|
||||
else:
|
||||
result.chain = [Image.fromFileSystem(url)]
|
||||
|
||||
|
||||
@@ -3,9 +3,17 @@ import re
|
||||
from typing import AsyncGenerator, Dict, List
|
||||
from aiocqhttp import CQHttp
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.message_components import At, Image, Node, Nodes, Plain, Record, File
|
||||
from astrbot.api.message_components import (
|
||||
Image,
|
||||
Node,
|
||||
Nodes,
|
||||
Plain,
|
||||
Record,
|
||||
Video,
|
||||
File,
|
||||
BaseMessageComponent,
|
||||
)
|
||||
from astrbot.api.platform import Group, MessageMember
|
||||
from astrbot.core import file_token_service, astrbot_config, logger
|
||||
|
||||
|
||||
class AiocqhttpMessageEvent(AstrMessageEvent):
|
||||
@@ -15,28 +23,38 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
|
||||
super().__init__(message_str, message_obj, platform_meta, session_id)
|
||||
self.bot = bot
|
||||
|
||||
@staticmethod
|
||||
async def _from_segment_to_dict(segment: BaseMessageComponent) -> dict:
|
||||
"""修复部分字段"""
|
||||
if isinstance(segment, (Image, Record)):
|
||||
# For Image and Record segments, we convert them to base64
|
||||
bs64 = await segment.convert_to_base64()
|
||||
return {
|
||||
"type": segment.type.lower(),
|
||||
"data": {
|
||||
"file": f"base64://{bs64}",
|
||||
},
|
||||
}
|
||||
elif isinstance(segment, File):
|
||||
# For File segments, we need to handle the file differently
|
||||
d = await segment.to_dict()
|
||||
return d
|
||||
elif isinstance(segment, Video):
|
||||
d = await segment.to_dict()
|
||||
return d
|
||||
else:
|
||||
# For other segments, we simply convert them to a dict by calling toDict
|
||||
return segment.toDict()
|
||||
|
||||
@staticmethod
|
||||
async def _parse_onebot_json(message_chain: MessageChain):
|
||||
"""解析成 OneBot json 格式"""
|
||||
ret = []
|
||||
for segment in message_chain.chain:
|
||||
d = segment.toDict()
|
||||
if isinstance(segment, Plain):
|
||||
d["type"] = "text"
|
||||
d["data"]["text"] = segment.text.strip()
|
||||
# 如果是空文本或者只带换行符的文本,不发送
|
||||
if not d["data"]["text"]:
|
||||
if not segment.text.strip():
|
||||
continue
|
||||
elif isinstance(segment, (Image, Record)):
|
||||
# convert to base64
|
||||
bs64 = await segment.convert_to_base64()
|
||||
d["data"] = {
|
||||
"file": f"base64://{bs64}",
|
||||
}
|
||||
elif isinstance(segment, At):
|
||||
d["data"] = {
|
||||
"qq": str(segment.qq), # 转换为字符串
|
||||
}
|
||||
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
|
||||
ret.append(d)
|
||||
return ret
|
||||
|
||||
@@ -54,7 +72,8 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
|
||||
nodes = Nodes([seg])
|
||||
seg = nodes
|
||||
|
||||
payload = seg.toDict()
|
||||
payload = await seg.to_dict()
|
||||
|
||||
if self.get_group_id():
|
||||
payload["group_id"] = self.get_group_id()
|
||||
await self.bot.call_action("send_group_forward_msg", **payload)
|
||||
@@ -64,21 +83,7 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
|
||||
"send_private_forward_msg", **payload
|
||||
)
|
||||
elif isinstance(seg, File):
|
||||
d = seg.toDict()
|
||||
url_or_path = await seg.get_file(allow_return_url=True)
|
||||
if url_or_path.startswith("http"):
|
||||
payload_file = url_or_path
|
||||
elif callback_host := astrbot_config.get("callback_api_base"):
|
||||
callback_host = str(callback_host).removesuffix("/")
|
||||
token = await file_token_service.register_file(url_or_path)
|
||||
payload_file = f"{callback_host}/api/file/{token}"
|
||||
logger.debug(f"Generated file callback link: {payload_file}")
|
||||
else:
|
||||
payload_file = url_or_path
|
||||
d["data"] = {
|
||||
"name": seg.name,
|
||||
"file": payload_file,
|
||||
}
|
||||
d = await AiocqhttpMessageEvent._from_segment_to_dict(seg)
|
||||
await self.bot.send(
|
||||
self.message_obj.raw_message,
|
||||
[d],
|
||||
|
||||
@@ -144,8 +144,8 @@ class TelegramPlatformAdapter(Platform):
|
||||
command_dict = {}
|
||||
skip_commands = {"start"}
|
||||
|
||||
for handler_md in star_handlers_registry._handlers:
|
||||
handler_metadata = handler_md[1]
|
||||
for handler_md in star_handlers_registry:
|
||||
handler_metadata = handler_md
|
||||
if not star_map[handler_metadata.handler_module_path].activated:
|
||||
continue
|
||||
for event_filter in handler_metadata.event_filters:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import re
|
||||
import asyncio
|
||||
import telegramify_markdown
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
@@ -18,6 +19,16 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
|
||||
class TelegramPlatformEvent(AstrMessageEvent):
|
||||
# Telegram 的最大消息长度限制
|
||||
MAX_MESSAGE_LENGTH = 4096
|
||||
|
||||
SPLIT_PATTERNS = {
|
||||
"paragraph": re.compile(r"\n\n"),
|
||||
"line": re.compile(r"\n"),
|
||||
"sentence": re.compile(r"[.!?。!?]"),
|
||||
"word": re.compile(r"\s"),
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message_str: str,
|
||||
@@ -29,8 +40,33 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
super().__init__(message_str, message_obj, platform_meta, session_id)
|
||||
self.client = client
|
||||
|
||||
@staticmethod
|
||||
async def send_with_client(client: ExtBot, message: MessageChain, user_name: str):
|
||||
def _split_message(self, text: str) -> list[str]:
|
||||
if len(text) <= self.MAX_MESSAGE_LENGTH:
|
||||
return [text]
|
||||
|
||||
chunks = []
|
||||
while text:
|
||||
if len(text) <= self.MAX_MESSAGE_LENGTH:
|
||||
chunks.append(text)
|
||||
break
|
||||
|
||||
split_point = self.MAX_MESSAGE_LENGTH
|
||||
segment = text[: self.MAX_MESSAGE_LENGTH]
|
||||
|
||||
for _, pattern in self.SPLIT_PATTERNS.items():
|
||||
if matches := list(pattern.finditer(segment)):
|
||||
last_match = matches[-1]
|
||||
split_point = last_match.end()
|
||||
break
|
||||
|
||||
chunks.append(text[:split_point])
|
||||
text = text[split_point:].lstrip()
|
||||
|
||||
return chunks
|
||||
|
||||
async def send_with_client(
|
||||
self, client: ExtBot, message: MessageChain, user_name: str
|
||||
):
|
||||
image_path = None
|
||||
|
||||
has_reply = False
|
||||
@@ -59,19 +95,22 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
|
||||
if isinstance(i, Plain):
|
||||
if at_user_id and not at_flag:
|
||||
i.text = f"@{at_user_id} " + i.text
|
||||
i.text = f"@{at_user_id} {i.text}"
|
||||
at_flag = True
|
||||
text = i.text
|
||||
try:
|
||||
text = telegramify_markdown.markdownify(
|
||||
i.text, max_line_length=None, normalize_whitespace=False
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"MarkdownV2 conversion failed: {e}. Using plain text instead."
|
||||
)
|
||||
return
|
||||
await client.send_message(text=text, parse_mode="MarkdownV2", **payload)
|
||||
chunks = self._split_message(i.text)
|
||||
for chunk in chunks:
|
||||
try:
|
||||
md_text = telegramify_markdown.markdownify(
|
||||
chunk, max_line_length=None, normalize_whitespace=False
|
||||
)
|
||||
await client.send_message(
|
||||
text=md_text, parse_mode="MarkdownV2", **payload
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"MarkdownV2 send failed: {e}. Using plain text instead."
|
||||
)
|
||||
await client.send_message(text=chunk, **payload)
|
||||
elif isinstance(i, Image):
|
||||
image_path = await i.convert_to_file_path()
|
||||
await client.send_photo(photo=image_path, **payload)
|
||||
@@ -147,17 +186,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
continue
|
||||
|
||||
# Plain
|
||||
if not message_id:
|
||||
try:
|
||||
msg = await self.client.send_message(text=delta, **payload)
|
||||
current_content = delta
|
||||
except Exception as e:
|
||||
logger.warning(f"发送消息失败(streaming): {e!s}")
|
||||
message_id = msg.message_id
|
||||
last_edit_time = (
|
||||
asyncio.get_event_loop().time()
|
||||
) # 记录初始消息发送时间
|
||||
else:
|
||||
if message_id and len(delta) <= self.MAX_MESSAGE_LENGTH:
|
||||
current_time = asyncio.get_event_loop().time()
|
||||
time_since_last_edit = current_time - last_edit_time
|
||||
|
||||
@@ -176,6 +205,18 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
last_edit_time = (
|
||||
asyncio.get_event_loop().time()
|
||||
) # 更新上次编辑的时间
|
||||
else:
|
||||
# delta 长度一般不会大于 4096,因此这里直接发送
|
||||
try:
|
||||
msg = await self.client.send_message(text=delta, **payload)
|
||||
current_content = delta
|
||||
delta = ""
|
||||
except Exception as e:
|
||||
logger.warning(f"发送消息失败(streaming): {e!s}")
|
||||
message_id = msg.message_id
|
||||
last_edit_time = (
|
||||
asyncio.get_event_loop().time()
|
||||
) # 记录初始消息发送时间
|
||||
|
||||
try:
|
||||
if delta and current_content != delta:
|
||||
|
||||
@@ -69,39 +69,42 @@ class WeChatPadProAdapter(Platform):
|
||||
self.auth_key = loaded_credentials.get("auth_key")
|
||||
self.wxid = loaded_credentials.get("wxid")
|
||||
|
||||
isLoginIn = await self.check_online_status()
|
||||
|
||||
# 检查在线状态
|
||||
if self.auth_key and await self.check_online_status():
|
||||
logger.info("WeChatPadPro 设备已在线,跳过扫码登录。")
|
||||
if self.auth_key and isLoginIn:
|
||||
logger.info("WeChatPadPro 设备已在线,凭据存在,跳过扫码登录。")
|
||||
# 如果在线,连接 WebSocket 接收消息
|
||||
self.ws_handle_task = asyncio.create_task(self.connect_websocket())
|
||||
else:
|
||||
logger.info("WeChatPadPro 设备不在线或无可用凭据,开始扫码登录流程。")
|
||||
# 1. 生成授权码
|
||||
await self.generate_auth_key()
|
||||
|
||||
if not self.auth_key:
|
||||
logger.error("无法获取授权码,WeChatPadPro 适配器启动失败。")
|
||||
return
|
||||
logger.info("WeChatPadPro 无可用凭据,将生成新的授权码。")
|
||||
await self.generate_auth_key()
|
||||
|
||||
# 2. 获取登录二维码
|
||||
qr_code_url = await self.get_login_qr_code()
|
||||
if not isLoginIn:
|
||||
logger.info("WeChatPadPro 设备已离线,开始扫码登录。")
|
||||
qr_code_url = await self.get_login_qr_code()
|
||||
|
||||
if qr_code_url:
|
||||
logger.info(f"请扫描以下二维码登录: {qr_code_url}")
|
||||
else:
|
||||
logger.error("无法获取登录二维码。")
|
||||
return
|
||||
if qr_code_url:
|
||||
logger.info(f"请扫描以下二维码登录: {qr_code_url}")
|
||||
else:
|
||||
logger.error("无法获取登录二维码。")
|
||||
return
|
||||
|
||||
# 3. 检测扫码状态
|
||||
login_successful = await self.check_login_status()
|
||||
# 3. 检测扫码状态
|
||||
login_successful = await self.check_login_status()
|
||||
|
||||
if login_successful:
|
||||
# 登录成功后,连接 WebSocket 接收消息
|
||||
self.ws_handle_task = asyncio.create_task(self.connect_websocket())
|
||||
else:
|
||||
logger.warning("登录失败或超时,WeChatPadPro 适配器将关闭。")
|
||||
await self.terminate()
|
||||
return
|
||||
if login_successful:
|
||||
logger.info("登录成功,WeChatPadPro适配器已连接。")
|
||||
else:
|
||||
logger.warning("登录失败或超时,WeChatPadPro 适配器将关闭。")
|
||||
await self.terminate()
|
||||
return
|
||||
|
||||
# 登录成功后,连接 WebSocket 接收消息
|
||||
self.ws_handle_task = asyncio.create_task(self.connect_websocket())
|
||||
|
||||
self._shutdown_event = asyncio.Event()
|
||||
await self._shutdown_event.wait()
|
||||
@@ -156,16 +159,29 @@ class WeChatPadProAdapter(Platform):
|
||||
if login_state == 1:
|
||||
logger.info("WeChatPadPro 设备当前在线。")
|
||||
return True
|
||||
else:
|
||||
# login_state == 3 为离线状态
|
||||
elif login_state == 3:
|
||||
logger.info(
|
||||
f"WeChatPadPro 设备不在线,登录状态: {login_state}"
|
||||
"WeChatPadPro 设备不在线。"
|
||||
)
|
||||
return False
|
||||
else:
|
||||
logger.error(
|
||||
f"未知的在线状态: {login_state:}"
|
||||
)
|
||||
return False
|
||||
# Code == 300 为微信退出状态。
|
||||
elif response.status == 200 and response_data.get("Code") == 300:
|
||||
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
|
||||
@@ -179,7 +195,7 @@ class WeChatPadProAdapter(Platform):
|
||||
"""
|
||||
url = f"{self.base_url}/admin/GenAuthKey1"
|
||||
params = {"key": self.admin_key}
|
||||
payload = {"Count": 1, "Days": 30} # 生成一个有效期30天的授权码
|
||||
payload = {"Count": 1, "Days": 365} # 生成一个有效期365天的授权码
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
try:
|
||||
@@ -336,12 +352,10 @@ class WeChatPadProAdapter(Platform):
|
||||
message = await asyncio.wait_for(
|
||||
websocket.recv(), timeout=wait_time
|
||||
)
|
||||
logger.info(message)
|
||||
# logger.debug(message) # 不显示原始消息内容
|
||||
asyncio.create_task(self.handle_websocket_message(message))
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
f"WebSocket 连接空闲超过 {wait_time} s"
|
||||
)
|
||||
logger.warning(f"WebSocket 连接空闲超过 {wait_time} s")
|
||||
break
|
||||
except websockets.exceptions.ConnectionClosedOK:
|
||||
logger.info("WebSocket 连接正常关闭。")
|
||||
@@ -350,7 +364,7 @@ class WeChatPadProAdapter(Platform):
|
||||
logger.error(f"处理 WebSocket 消息时发生错误: {e}")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"WebSocket 连接失败: {e}")
|
||||
logger.error(f"WebSocket 连接失败: {e}, 请检查WeChatPadPro服务状态,或尝试重启WeChatPadPro适配器。")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def handle_websocket_message(self, message: str):
|
||||
@@ -425,7 +439,7 @@ class WeChatPadProAdapter(Platform):
|
||||
):
|
||||
# 再根据消息类型处理消息内容
|
||||
await self._process_message_content(abm, raw_message, msg_type, content)
|
||||
|
||||
|
||||
return abm
|
||||
return None
|
||||
|
||||
@@ -627,3 +641,67 @@ class WeChatPadProAdapter(Platform):
|
||||
)
|
||||
# 调用实例方法 send
|
||||
await sending_event.send(message_chain)
|
||||
|
||||
async def get_contact_list(self):
|
||||
"""
|
||||
获取联系人列表。
|
||||
"""
|
||||
url = f"{self.base_url}/friend/GetContactList"
|
||||
params = {"key": self.auth_key}
|
||||
payload = {"CurrentChatRoomContactSeq": 0, "CurrentWxcontactSeq": 0}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
try:
|
||||
async with session.post(url, params=params, json=payload) as response:
|
||||
if response.status != 200:
|
||||
logger.error(f"获取联系人列表失败: {response.status}")
|
||||
return None
|
||||
result = await response.json()
|
||||
if result.get("Code") == 200 and result.get("Data"):
|
||||
contact_list = (
|
||||
result.get("Data", {})
|
||||
.get("ContactList", {})
|
||||
.get("contactUsernameList", [])
|
||||
)
|
||||
return contact_list
|
||||
else:
|
||||
logger.error(f"获取联系人列表失败: {result}")
|
||||
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 get_contact_details_list(
|
||||
self, room_wx_id_list: list[str] = None, user_names: list[str] = None
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
获取联系人详情列表。
|
||||
"""
|
||||
if room_wx_id_list is None:
|
||||
room_wx_id_list = []
|
||||
if user_names is None:
|
||||
user_names = []
|
||||
url = f"{self.base_url}/friend/GetContactDetailsList"
|
||||
params = {"key": self.auth_key}
|
||||
payload = {"RoomWxIDList": room_wx_id_list, "UserNames": user_names}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
try:
|
||||
async with session.post(url, params=params, json=payload) as response:
|
||||
if response.status != 200:
|
||||
logger.error(f"获取联系人详情列表失败: {response.status}")
|
||||
return None
|
||||
result = await response.json()
|
||||
if result.get("Code") == 200 and result.get("Data"):
|
||||
contact_list = result.get("Data", {}).get("contactList", {})
|
||||
return contact_list
|
||||
else:
|
||||
logger.error(f"获取联系人详情列表失败: {result}")
|
||||
return None
|
||||
except aiohttp.ClientConnectorError as e:
|
||||
logger.error(f"连接到 WeChatPadPro 服务失败: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"获取联系人详情列表时发生错误: {e}")
|
||||
return None
|
||||
|
||||
@@ -20,7 +20,7 @@ from requests import Response
|
||||
from wechatpy.utils import check_signature
|
||||
from wechatpy.crypto import WeChatCrypto
|
||||
from wechatpy import WeChatClient
|
||||
from wechatpy.messages import TextMessage, ImageMessage, VoiceMessage
|
||||
from wechatpy.messages import TextMessage, ImageMessage, VoiceMessage, BaseMessage
|
||||
from wechatpy.exceptions import InvalidSignatureException
|
||||
from wechatpy import parse_message
|
||||
from .weixin_offacc_event import WeixinOfficialAccountPlatformEvent
|
||||
@@ -87,7 +87,11 @@ class WecomServer:
|
||||
logger.info(f"解析成功: {msg}")
|
||||
|
||||
if self.callback:
|
||||
await self.callback(msg)
|
||||
result_xml = await self.callback(msg)
|
||||
if not result_xml:
|
||||
return "success"
|
||||
if isinstance(result_xml, str):
|
||||
return result_xml
|
||||
|
||||
return "success"
|
||||
|
||||
@@ -117,6 +121,7 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
|
||||
self.api_base_url = platform_config.get(
|
||||
"api_base_url", "https://api.weixin.qq.com/cgi-bin/"
|
||||
)
|
||||
self.active_send_mode = self.config.get("active_send_mode", False)
|
||||
|
||||
if not self.api_base_url:
|
||||
self.api_base_url = "https://api.weixin.qq.com/cgi-bin/"
|
||||
@@ -138,9 +143,29 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
|
||||
|
||||
self.client.API_BASE_URL = self.api_base_url
|
||||
|
||||
async def callback(msg):
|
||||
# 微信公众号必须 5 秒内进行回复,否则会重试 3 次,我们需要对其进行消息排重
|
||||
# msgid -> Future
|
||||
self.wexin_event_workers: dict[str, asyncio.Future] = {}
|
||||
|
||||
async def callback(msg: BaseMessage):
|
||||
try:
|
||||
await self.convert_message(msg)
|
||||
if self.active_send_mode:
|
||||
await self.convert_message(msg, None)
|
||||
else:
|
||||
if msg.id in self.wexin_event_workers:
|
||||
future = self.wexin_event_workers[msg.id]
|
||||
logger.debug(f"duplicate message id checked: {msg.id}")
|
||||
else:
|
||||
future = asyncio.get_event_loop().create_future()
|
||||
self.wexin_event_workers[msg.id] = future
|
||||
await self.convert_message(msg, future)
|
||||
# I love shield so much!
|
||||
result = await asyncio.wait_for(asyncio.shield(future), 60) # wait for 60s
|
||||
logger.debug(f"Got future result: {result}")
|
||||
self.wexin_event_workers.pop(msg.id, None)
|
||||
return result # xml. see weixin_offacc_event.py
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"转换消息时出现异常: {e}")
|
||||
|
||||
@@ -163,7 +188,9 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
|
||||
async def run(self):
|
||||
await self.server.start_polling()
|
||||
|
||||
async def convert_message(self, msg) -> AstrBotMessage | None:
|
||||
async def convert_message(
|
||||
self, msg, future: asyncio.Future = None
|
||||
) -> AstrBotMessage | None:
|
||||
abm = AstrBotMessage()
|
||||
if isinstance(msg, TextMessage):
|
||||
abm.message_str = msg.content
|
||||
@@ -177,7 +204,6 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
|
||||
abm.message_id = msg.id
|
||||
abm.timestamp = msg.time
|
||||
abm.session_id = abm.sender.user_id
|
||||
abm.raw_message = msg
|
||||
elif msg.type == "image":
|
||||
assert isinstance(msg, ImageMessage)
|
||||
abm.message_str = "[图片]"
|
||||
@@ -191,7 +217,6 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
|
||||
abm.message_id = msg.id
|
||||
abm.timestamp = msg.time
|
||||
abm.session_id = abm.sender.user_id
|
||||
abm.raw_message = msg
|
||||
elif msg.type == "voice":
|
||||
assert isinstance(msg, VoiceMessage)
|
||||
|
||||
@@ -209,7 +234,9 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
|
||||
audio = AudioSegment.from_file(path)
|
||||
audio.export(path_wav, format="wav")
|
||||
except Exception as e:
|
||||
logger.error(f"转换音频失败: {e}。如果没有安装 pydub 和 ffmpeg 请先安装。")
|
||||
logger.error(
|
||||
f"转换音频失败: {e}。如果没有安装 pydub 和 ffmpeg 请先安装。"
|
||||
)
|
||||
path_wav = path
|
||||
return
|
||||
|
||||
@@ -224,11 +251,16 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
|
||||
abm.message_id = msg.id
|
||||
abm.timestamp = msg.time
|
||||
abm.session_id = abm.sender.user_id
|
||||
abm.raw_message = msg
|
||||
else:
|
||||
logger.warning(f"暂未实现的事件: {msg.type}")
|
||||
future.set_result(None)
|
||||
return
|
||||
|
||||
# 很不优雅 :(
|
||||
abm.raw_message = {
|
||||
"message": msg,
|
||||
"future": future,
|
||||
"active_send_mode": self.active_send_mode,
|
||||
}
|
||||
logger.info(f"abm: {abm}")
|
||||
await self.handle_msg(abm)
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
|
||||
from astrbot.api.message_components import Plain, Image, Record
|
||||
from wechatpy import WeChatClient
|
||||
from wechatpy.replies import TextReply, ImageReply, VoiceReply
|
||||
|
||||
|
||||
from astrbot.api import logger
|
||||
|
||||
@@ -82,12 +84,23 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
|
||||
|
||||
async def send(self, message: MessageChain):
|
||||
message_obj = self.message_obj
|
||||
active_send_mode = message_obj.raw_message.get("active_send_mode", False)
|
||||
for comp in message.chain:
|
||||
if isinstance(comp, Plain):
|
||||
# Split long text messages if needed
|
||||
plain_chunks = await self.split_plain(comp.text)
|
||||
for chunk in plain_chunks:
|
||||
self.client.message.send_text(message_obj.sender.user_id, chunk)
|
||||
if active_send_mode:
|
||||
self.client.message.send_text(message_obj.sender.user_id, chunk)
|
||||
else:
|
||||
reply = TextReply(
|
||||
content=chunk,
|
||||
message=self.message_obj.raw_message["message"],
|
||||
)
|
||||
xml = reply.render()
|
||||
future = self.message_obj.raw_message["future"]
|
||||
assert isinstance(future, asyncio.Future)
|
||||
future.set_result(xml)
|
||||
await asyncio.sleep(0.5) # Avoid sending too fast
|
||||
elif isinstance(comp, Image):
|
||||
img_path = await comp.convert_to_file_path()
|
||||
@@ -102,10 +115,22 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
|
||||
)
|
||||
return
|
||||
logger.debug(f"微信公众平台上传图片返回: {response}")
|
||||
self.client.message.send_image(
|
||||
message_obj.sender.user_id,
|
||||
response["media_id"],
|
||||
)
|
||||
|
||||
if active_send_mode:
|
||||
self.client.message.send_image(
|
||||
message_obj.sender.user_id,
|
||||
response["media_id"],
|
||||
)
|
||||
else:
|
||||
reply = ImageReply(
|
||||
media_id=response["media_id"],
|
||||
message=self.message_obj.raw_message["message"],
|
||||
)
|
||||
xml = reply.render()
|
||||
future = self.message_obj.raw_message["future"]
|
||||
assert isinstance(future, asyncio.Future)
|
||||
future.set_result(xml)
|
||||
|
||||
elif isinstance(comp, Record):
|
||||
record_path = await comp.convert_to_file_path()
|
||||
# 转成amr
|
||||
@@ -124,10 +149,23 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
|
||||
)
|
||||
return
|
||||
logger.info(f"微信公众平台上传语音返回: {response}")
|
||||
self.client.message.send_voice(
|
||||
message_obj.sender.user_id,
|
||||
response["media_id"],
|
||||
)
|
||||
|
||||
|
||||
if active_send_mode:
|
||||
self.client.message.send_voice(
|
||||
message_obj.sender.user_id,
|
||||
response["media_id"],
|
||||
)
|
||||
else:
|
||||
reply = VoiceReply(
|
||||
media_id=response["media_id"],
|
||||
message=self.message_obj.raw_message["message"],
|
||||
)
|
||||
xml = reply.render()
|
||||
future = self.message_obj.raw_message["future"]
|
||||
assert isinstance(future, asyncio.Future)
|
||||
future.set_result(xml)
|
||||
|
||||
else:
|
||||
logger.warning(f"还没实现这个消息类型的发送逻辑: {comp.type}。")
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import textwrap
|
||||
import os
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from typing import Dict, List, Awaitable, Literal, Any
|
||||
from dataclasses import dataclass
|
||||
@@ -20,6 +21,13 @@ try:
|
||||
except (ModuleNotFoundError, ImportError):
|
||||
logger.warning("警告: 缺少依赖库 'mcp',将无法使用 MCP 服务。")
|
||||
|
||||
try:
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
except (ModuleNotFoundError, ImportError):
|
||||
logger.warning(
|
||||
"警告: 缺少依赖库 'mcp' 或者 mcp 库版本过低,无法使用 Streamable HTTP 连接方式。"
|
||||
)
|
||||
|
||||
DEFAULT_MCP_CONFIG = {"mcpServers": {}}
|
||||
|
||||
SUPPORTED_TYPES = [
|
||||
@@ -96,7 +104,10 @@ class MCPClient:
|
||||
async def connect_to_server(self, mcp_server_config: dict, name: str):
|
||||
"""连接到 MCP 服务器
|
||||
|
||||
如果 `url` 参数存在,则使用 SSE 的方式连接到 MCP 服务。
|
||||
如果 `url` 参数存在:
|
||||
1. 当 transport 指定为 `streamable_http` 时,使用 Streamable HTTP 连接方式。
|
||||
1. 当 transport 指定为 `sse` 时,使用 SSE 连接方式。
|
||||
2. 如果没有指定,默认使用 SSE 的方式连接到 MCP 服务。
|
||||
|
||||
Args:
|
||||
mcp_server_config (dict): Configuration for the MCP server. See https://modelcontextprotocol.io/quickstart/server
|
||||
@@ -108,15 +119,41 @@ class MCPClient:
|
||||
cfg.pop("active", None) # Remove active flag from config
|
||||
|
||||
if "url" in cfg:
|
||||
# SSE transport method
|
||||
self._streams_context = sse_client(url=cfg["url"])
|
||||
streams = await self._streams_context.__aenter__()
|
||||
is_sse = True
|
||||
if cfg.get("transport") == "streamable_http":
|
||||
is_sse = False
|
||||
if is_sse:
|
||||
# SSE transport method
|
||||
self._streams_context = sse_client(
|
||||
url=cfg["url"],
|
||||
headers=cfg.get("headers", {}),
|
||||
timeout=cfg.get("timeout", 5),
|
||||
sse_read_timeout=cfg.get("sse_read_timeout", 60 * 5),
|
||||
)
|
||||
streams = await self._streams_context.__aenter__()
|
||||
|
||||
# Create a new client session
|
||||
# self.session = await self._session_context.__aenter__()
|
||||
self.session = await self.exit_stack.enter_async_context(
|
||||
mcp.ClientSession(*streams)
|
||||
)
|
||||
# Create a new client session
|
||||
self.session = await self.exit_stack.enter_async_context(
|
||||
mcp.ClientSession(*streams)
|
||||
)
|
||||
else:
|
||||
timeout = timedelta(seconds=cfg.get("timeout", 30))
|
||||
sse_read_timeout = timedelta(
|
||||
seconds=cfg.get("sse_read_timeout", 60 * 5)
|
||||
)
|
||||
self._streams_context = streamablehttp_client(
|
||||
url=cfg["url"],
|
||||
headers=cfg.get("headers", {}),
|
||||
timeout=timeout,
|
||||
sse_read_timeout=sse_read_timeout,
|
||||
terminate_on_close=cfg.get("terminate_on_close", True),
|
||||
)
|
||||
read_s, write_s, _ = await self._streams_context.__aenter__()
|
||||
|
||||
# Create a new client session
|
||||
self.session = await self.exit_stack.enter_async_context(
|
||||
mcp.ClientSession(read_stream=read_s, write_stream=write_s)
|
||||
)
|
||||
|
||||
else:
|
||||
server_params = mcp.StdioServerParameters(
|
||||
|
||||
@@ -21,9 +21,9 @@ class ProviderManager:
|
||||
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.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)
|
||||
|
||||
# 人格情景管理
|
||||
# 目前没有拆成独立的模块
|
||||
@@ -101,6 +101,8 @@ class ProviderManager:
|
||||
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 实例"""
|
||||
self.curr_stt_provider_inst: STTProvider = None
|
||||
@@ -119,14 +121,9 @@ class ProviderManager:
|
||||
for provider_config in self.providers_config:
|
||||
await self.load_provider(provider_config)
|
||||
|
||||
if not self.curr_provider_inst:
|
||||
logger.warning("未启用任何用于 文本生成 的提供商适配器。")
|
||||
|
||||
if self.stt_enabled and not self.curr_stt_provider_inst:
|
||||
logger.warning("未启用任何用于 语音转文本 的提供商适配器。")
|
||||
|
||||
if self.tts_enabled and not self.curr_tts_provider_inst:
|
||||
logger.warning("未启用任何用于 文本转语音 的提供商适配器。")
|
||||
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]
|
||||
|
||||
# 初始化 MCP Client 连接
|
||||
asyncio.create_task(
|
||||
@@ -210,6 +207,10 @@ class ProviderManager:
|
||||
from .sources.minimax_tts_api_source import (
|
||||
ProviderMiniMaxTTSAPI as ProviderMiniMaxTTSAPI,
|
||||
)
|
||||
case "volcengine_tts":
|
||||
from .sources.volcengine_tts import (
|
||||
ProviderVolcengineTTS as ProviderVolcengineTTS,
|
||||
)
|
||||
except (ImportError, ModuleNotFoundError) as e:
|
||||
logger.critical(
|
||||
f"加载 {provider_config['type']}({provider_config['id']}) 提供商适配器失败:{e}。可能是因为有未安装的依赖。"
|
||||
@@ -241,15 +242,12 @@ class ProviderManager:
|
||||
await inst.initialize()
|
||||
|
||||
self.stt_provider_insts.append(inst)
|
||||
if (
|
||||
self.selected_stt_provider_id == provider_config["id"]
|
||||
and self.stt_enabled
|
||||
):
|
||||
if self.selected_stt_provider_id == provider_config["id"]:
|
||||
self.curr_stt_provider_inst = inst
|
||||
logger.info(
|
||||
f"已选择 {provider_config['type']}({provider_config['id']}) 作为当前语音转文本提供商适配器。"
|
||||
)
|
||||
if not self.curr_stt_provider_inst and self.stt_enabled:
|
||||
if not self.curr_stt_provider_inst:
|
||||
self.curr_stt_provider_inst = inst
|
||||
|
||||
elif provider_metadata.provider_type == ProviderType.TEXT_TO_SPEECH:
|
||||
@@ -262,15 +260,12 @@ class ProviderManager:
|
||||
await inst.initialize()
|
||||
|
||||
self.tts_provider_insts.append(inst)
|
||||
if (
|
||||
self.selected_tts_provider_id == provider_config["id"]
|
||||
and self.tts_enabled
|
||||
):
|
||||
if self.selected_tts_provider_id == provider_config["id"]:
|
||||
self.curr_tts_provider_inst = inst
|
||||
logger.info(
|
||||
f"已选择 {provider_config['type']}({provider_config['id']}) 作为当前文本转语音提供商适配器。"
|
||||
)
|
||||
if not self.curr_tts_provider_inst and self.tts_enabled:
|
||||
if not self.curr_tts_provider_inst:
|
||||
self.curr_tts_provider_inst = inst
|
||||
|
||||
elif provider_metadata.provider_type == ProviderType.CHAT_COMPLETION:
|
||||
@@ -287,15 +282,12 @@ class ProviderManager:
|
||||
await inst.initialize()
|
||||
|
||||
self.provider_insts.append(inst)
|
||||
if (
|
||||
self.selected_provider_id == provider_config["id"]
|
||||
and self.provider_enabled
|
||||
):
|
||||
if self.selected_provider_id == provider_config["id"]:
|
||||
self.curr_provider_inst = inst
|
||||
logger.info(
|
||||
f"已选择 {provider_config['type']}({provider_config['id']}) 作为当前提供商适配器。"
|
||||
)
|
||||
if not self.curr_provider_inst and self.provider_enabled:
|
||||
if not self.curr_provider_inst:
|
||||
self.curr_provider_inst = inst
|
||||
|
||||
self.inst_map[provider_config["id"]] = inst
|
||||
@@ -318,11 +310,7 @@ class ProviderManager:
|
||||
|
||||
if len(self.provider_insts) == 0:
|
||||
self.curr_provider_inst = None
|
||||
elif (
|
||||
self.curr_provider_inst is None
|
||||
and len(self.provider_insts) > 0
|
||||
and self.provider_enabled
|
||||
):
|
||||
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(
|
||||
@@ -331,11 +319,7 @@ class ProviderManager:
|
||||
|
||||
if len(self.stt_provider_insts) == 0:
|
||||
self.curr_stt_provider_inst = None
|
||||
elif (
|
||||
self.curr_stt_provider_inst is None
|
||||
and len(self.stt_provider_insts) > 0
|
||||
and self.stt_enabled
|
||||
):
|
||||
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(
|
||||
@@ -344,11 +328,7 @@ class ProviderManager:
|
||||
|
||||
if len(self.tts_provider_insts) == 0:
|
||||
self.curr_tts_provider_inst = None
|
||||
elif (
|
||||
self.curr_tts_provider_inst is None
|
||||
and len(self.tts_provider_insts) > 0
|
||||
and self.tts_enabled
|
||||
):
|
||||
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(
|
||||
|
||||
@@ -179,3 +179,20 @@ class TTSProvider(AbstractProvider):
|
||||
async def get_audio(self, text: str) -> str:
|
||||
"""获取文本的音频,返回音频文件路径"""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class EmbeddingProvider(AbstractProvider):
|
||||
def __init__(self, provider_config: dict, provider_settings: dict) -> None:
|
||||
super().__init__(provider_config)
|
||||
self.provider_config = provider_config
|
||||
self.provider_settings = provider_settings
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_embedding(self, text: str) -> list[float]:
|
||||
"""获取文本的向量"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_dim(self) -> int:
|
||||
"""获取向量的维度"""
|
||||
...
|
||||
|
||||
@@ -53,8 +53,8 @@ class OTTSProvider:
|
||||
async def _generate_signature(self) -> str:
|
||||
await self._sync_time()
|
||||
timestamp = int(time.time()) + self.time_offset
|
||||
nonce = ''.join(random.choices('abcdefghijklmnopqrstuvwxyz0123456789', k=10))
|
||||
path = re.sub(r'^https?://[^/]+', '', self.api_url) or '/'
|
||||
nonce = "".join(random.choices("abcdefghijklmnopqrstuvwxyz0123456789", k=10))
|
||||
path = re.sub(r"^https?://[^/]+", "", self.api_url) or "/"
|
||||
return f"{timestamp}-{nonce}-0-{hashlib.md5(f'{path}-{timestamp}-{nonce}-0-{self.skey}'.encode()).hexdigest()}"
|
||||
|
||||
async def get_audio(self, text: str, voice_params: Dict) -> str:
|
||||
@@ -92,7 +92,7 @@ class AzureNativeProvider(TTSProvider):
|
||||
def __init__(self, provider_config: dict, provider_settings: dict):
|
||||
super().__init__(provider_config, provider_settings)
|
||||
self.subscription_key = provider_config.get("azure_tts_subscription_key", "").strip()
|
||||
if not re.fullmatch(r'^[a-zA-Z0-9]{32}$', self.subscription_key):
|
||||
if not re.fullmatch(r"^[a-zA-Z0-9]{32}$", self.subscription_key):
|
||||
raise ValueError("无效的Azure订阅密钥")
|
||||
self.region = provider_config.get("azure_tts_region", "eastus").strip()
|
||||
self.endpoint = f"https://{self.region}.tts.speech.microsoft.com/cognitiveservices/v1"
|
||||
@@ -188,7 +188,7 @@ class AzureTTSProvider(TTSProvider):
|
||||
raise ValueError(error_msg) from e
|
||||
except KeyError as e:
|
||||
raise ValueError(f"配置错误: 缺少必要参数 {e}") from e
|
||||
if re.fullmatch(r'^[a-zA-Z0-9]{32}$', key_value):
|
||||
if re.fullmatch(r"^[a-zA-Z0-9]{32}$", key_value):
|
||||
return AzureNativeProvider(config, self.provider_settings)
|
||||
raise ValueError("订阅密钥格式无效,应为32位字母数字或other[...]格式")
|
||||
|
||||
|
||||
@@ -291,19 +291,19 @@ class ProviderGoogleGenAI(Provider):
|
||||
result_parts: Optional[types.Part] = result.candidates[0].content.parts
|
||||
|
||||
if finish_reason == types.FinishReason.SAFETY:
|
||||
raise Exception("模型生成内容未通过用户定义的内容安全检查")
|
||||
raise Exception("模型生成内容未通过 Gemini 平台的安全检查")
|
||||
|
||||
if finish_reason in {
|
||||
types.FinishReason.PROHIBITED_CONTENT,
|
||||
types.FinishReason.SPII,
|
||||
types.FinishReason.BLOCKLIST,
|
||||
}:
|
||||
raise Exception("模型生成内容违反Gemini平台政策")
|
||||
raise Exception("模型生成内容违反 Gemini 平台政策")
|
||||
|
||||
# 防止旧版本SDK不存在IMAGE_SAFETY
|
||||
if hasattr(types.FinishReason, "IMAGE_SAFETY"):
|
||||
if finish_reason == types.FinishReason.IMAGE_SAFETY:
|
||||
raise Exception("模型生成内容违反Gemini平台政策")
|
||||
raise Exception("模型生成内容违反 Gemini 平台政策")
|
||||
|
||||
if not result_parts:
|
||||
logger.debug(result.candidates)
|
||||
|
||||
@@ -195,7 +195,11 @@ class ProviderOpenAIOfficial(Provider):
|
||||
for tool_call in choice.message.tool_calls:
|
||||
for tool in tools.func_list:
|
||||
if tool.name == tool_call.function.name:
|
||||
args = json.loads(tool_call.function.arguments)
|
||||
# workaround for #1454
|
||||
if isinstance(tool_call.function.arguments, str):
|
||||
args = json.loads(tool_call.function.arguments)
|
||||
else:
|
||||
args = tool_call.function.arguments
|
||||
args_ls.append(args)
|
||||
func_name_ls.append(tool_call.function.name)
|
||||
tool_call_ids.append(tool_call.id)
|
||||
@@ -223,9 +227,9 @@ class ProviderOpenAIOfficial(Provider):
|
||||
session_id: str = None,
|
||||
image_urls: list[str] = None,
|
||||
func_tool: FuncCall = None,
|
||||
contexts: list=None,
|
||||
system_prompt: str=None,
|
||||
tool_calls_result: ToolCallsResult=None,
|
||||
contexts: list = None,
|
||||
system_prompt: str = None,
|
||||
tool_calls_result: ToolCallsResult = None,
|
||||
**kwargs,
|
||||
) -> tuple:
|
||||
"""准备聊天所需的有效载荷和上下文"""
|
||||
@@ -340,9 +344,9 @@ class ProviderOpenAIOfficial(Provider):
|
||||
async def text_chat(
|
||||
self,
|
||||
prompt,
|
||||
session_id = None,
|
||||
image_urls = None,
|
||||
func_tool = None,
|
||||
session_id=None,
|
||||
image_urls=None,
|
||||
func_tool=None,
|
||||
contexts=None,
|
||||
system_prompt=None,
|
||||
tool_calls_result=None,
|
||||
|
||||
107
astrbot/core/provider/sources/volcengine_tts.py
Normal file
107
astrbot/core/provider/sources/volcengine_tts.py
Normal file
@@ -0,0 +1,107 @@
|
||||
import uuid
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
import asyncio
|
||||
import aiohttp
|
||||
import requests
|
||||
from ..provider import TTSProvider
|
||||
from ..entities import ProviderType
|
||||
from ..register import register_provider_adapter
|
||||
from astrbot import logger
|
||||
|
||||
@register_provider_adapter(
|
||||
"volcengine_tts", "火山引擎 TTS", provider_type=ProviderType.TEXT_TO_SPEECH
|
||||
)
|
||||
class ProviderVolcengineTTS(TTSProvider):
|
||||
def __init__(self, provider_config: dict, provider_settings: dict) -> None:
|
||||
super().__init__(provider_config, provider_settings)
|
||||
self.api_key = provider_config.get("api_key", "")
|
||||
self.appid = provider_config.get("appid", "")
|
||||
self.cluster = provider_config.get("volcengine_cluster", "")
|
||||
self.voice_type = provider_config.get("volcengine_voice_type", "")
|
||||
self.speed_ratio = provider_config.get("volcengine_speed_ratio", 1.0)
|
||||
self.api_base = provider_config.get("api_base", f"https://openspeech.bytedance.com/api/v1/tts")
|
||||
self.timeout = provider_config.get("timeout", 20)
|
||||
|
||||
def _build_request_payload(self, text: str) -> dict:
|
||||
return {
|
||||
"app": {
|
||||
"appid": self.appid,
|
||||
"token": self.api_key,
|
||||
"cluster": self.cluster
|
||||
},
|
||||
"user": {
|
||||
"uid": str(uuid.uuid4())
|
||||
},
|
||||
"audio": {
|
||||
"voice_type": self.voice_type,
|
||||
"encoding": "mp3",
|
||||
"speed_ratio": self.speed_ratio,
|
||||
"volume_ratio": 1.0,
|
||||
"pitch_ratio": 1.0,
|
||||
},
|
||||
"request": {
|
||||
"reqid": str(uuid.uuid4()),
|
||||
"text": text,
|
||||
"text_type": "plain",
|
||||
"operation": "query",
|
||||
"with_frontend": 1,
|
||||
"frontend_type": "unitTson"
|
||||
}
|
||||
}
|
||||
|
||||
async def get_audio(self, text: str) -> str:
|
||||
"""异步方法获取语音文件路径"""
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer; {self.api_key}"
|
||||
}
|
||||
|
||||
payload = self._build_request_payload(text)
|
||||
|
||||
logger.debug(f"请求头: {headers}")
|
||||
logger.debug(f"请求 URL: {self.api_base}")
|
||||
logger.debug(f"请求体: {json.dumps(payload, ensure_ascii=False)[:100]}...")
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
self.api_base,
|
||||
data=json.dumps(payload),
|
||||
headers=headers,
|
||||
timeout=self.timeout
|
||||
) as response:
|
||||
logger.debug(f"响应状态码: {response.status}")
|
||||
|
||||
response_text = await response.text()
|
||||
logger.debug(f"响应内容: {response_text[:200]}...")
|
||||
|
||||
if response.status == 200:
|
||||
resp_data = json.loads(response_text)
|
||||
|
||||
if "data" in resp_data:
|
||||
audio_data = base64.b64decode(resp_data["data"])
|
||||
|
||||
os.makedirs("data/temp", exist_ok=True)
|
||||
|
||||
file_path = f"data/temp/volcengine_tts_{uuid.uuid4()}.mp3"
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: open(file_path, "wb").write(audio_data)
|
||||
)
|
||||
|
||||
return file_path
|
||||
else:
|
||||
error_msg = resp_data.get("message", "未知错误")
|
||||
raise Exception(f"火山引擎 TTS API 返回错误: {error_msg}")
|
||||
else:
|
||||
raise Exception(f"火山引擎 TTS API 请求失败: {response.status}, {response_text}")
|
||||
|
||||
except Exception as e:
|
||||
error_details = traceback.format_exc()
|
||||
logger.debug(f"火山引擎 TTS 异常详情: {error_details}")
|
||||
raise Exception(f"火山引擎 TTS 异常: {str(e)}")
|
||||
@@ -1,20 +0,0 @@
|
||||
from typing import List
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
|
||||
class SimpleOpenAIEmbedding:
|
||||
def __init__(
|
||||
self,
|
||||
model,
|
||||
api_key,
|
||||
api_base=None,
|
||||
) -> None:
|
||||
self.client = AsyncOpenAI(api_key=api_key, base_url=api_base)
|
||||
self.model = model
|
||||
|
||||
async def get_embedding(self, text) -> List[float]:
|
||||
"""
|
||||
获取文本的嵌入
|
||||
"""
|
||||
embedding = await self.client.embeddings.create(input=text, model=self.model)
|
||||
return embedding.data[0].embedding
|
||||
@@ -1,95 +0,0 @@
|
||||
import os
|
||||
from typing import List, Dict
|
||||
from astrbot.core import logger
|
||||
from .store import Store
|
||||
from astrbot.core.config import AstrBotConfig
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
|
||||
class KnowledgeDBManager:
|
||||
def __init__(self, astrbot_config: AstrBotConfig) -> None:
|
||||
self.db_path = os.path.join(get_astrbot_data_path(), "knowledge_db")
|
||||
self.config = astrbot_config.get("knowledge_db", {})
|
||||
self.astrbot_config = astrbot_config
|
||||
if not os.path.exists(self.db_path):
|
||||
os.makedirs(self.db_path)
|
||||
self.store_insts: Dict[str, Store] = {}
|
||||
for name, cfg in self.config.items():
|
||||
if cfg["strategy"] == "embedding":
|
||||
logger.info(f"加载 Chroma Vector Store:{name}")
|
||||
try:
|
||||
from .store.chroma_db import ChromaVectorStore
|
||||
except ImportError as ie:
|
||||
logger.error(f"{ie} 可能未安装 chromadb 库。")
|
||||
continue
|
||||
self.store_insts[name] = ChromaVectorStore(
|
||||
name, cfg["embedding_config"]
|
||||
)
|
||||
else:
|
||||
logger.error(f"不支持的策略:{cfg['strategy']}")
|
||||
|
||||
async def list_knowledge_db(self) -> List[str]:
|
||||
return [
|
||||
f
|
||||
for f in os.listdir(self.db_path)
|
||||
if os.path.isfile(os.path.join(self.db_path, f))
|
||||
]
|
||||
|
||||
async def create_knowledge_db(self, name: str, config: Dict):
|
||||
"""
|
||||
config 格式:
|
||||
```
|
||||
{
|
||||
"strategy": "embedding", # 目前只支持 embedding
|
||||
"chunk_method": {
|
||||
"strategy": "fixed",
|
||||
"chunk_size": 100,
|
||||
"overlap_size": 10
|
||||
},
|
||||
"embedding_config": {
|
||||
"strategy": "openai",
|
||||
"base_url": "",
|
||||
"model": "",
|
||||
"api_key": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
"""
|
||||
if name in self.config:
|
||||
raise ValueError(f"知识库已存在:{name}")
|
||||
|
||||
self.config[name] = config
|
||||
self.astrbot_config["knowledge_db"] = self.config
|
||||
self.astrbot_config.save_config()
|
||||
|
||||
async def insert_record(self, name: str, text: str):
|
||||
if name not in self.store_insts:
|
||||
raise ValueError(f"未找到知识库:{name}")
|
||||
|
||||
ret = []
|
||||
match self.config[name]["chunk_method"]["strategy"]:
|
||||
case "fixed":
|
||||
chunk_size = self.config[name]["chunk_method"]["chunk_size"]
|
||||
chunk_overlap = self.config[name]["chunk_method"]["overlap_size"]
|
||||
ret = self._fixed_chunk(text, chunk_size, chunk_overlap)
|
||||
case _:
|
||||
pass
|
||||
|
||||
for chunk in ret:
|
||||
await self.store_insts[name].save(chunk)
|
||||
|
||||
async def retrive_records(self, name: str, query: str, top_n: int = 3) -> List[str]:
|
||||
if name not in self.store_insts:
|
||||
raise ValueError(f"未找到知识库:{name}")
|
||||
|
||||
inst = self.store_insts[name]
|
||||
return await inst.query(query, top_n)
|
||||
|
||||
def _fixed_chunk(self, text: str, chunk_size: int, chunk_overlap: int) -> List[str]:
|
||||
chunks = []
|
||||
start = 0
|
||||
while start < len(text):
|
||||
end = start + chunk_size
|
||||
chunks.append(text[start:end])
|
||||
start += chunk_size - chunk_overlap
|
||||
return chunks
|
||||
@@ -1,9 +0,0 @@
|
||||
from typing import List
|
||||
|
||||
|
||||
class Store:
|
||||
async def save(self, text: str):
|
||||
pass
|
||||
|
||||
async def query(self, query: str, top_n: int = 3) -> List[str]:
|
||||
pass
|
||||
@@ -1,44 +0,0 @@
|
||||
import chromadb
|
||||
import uuid
|
||||
from typing import List, Dict
|
||||
from astrbot.api import logger
|
||||
from ..embedding.openai_source import SimpleOpenAIEmbedding
|
||||
from . import Store
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
|
||||
class ChromaVectorStore(Store):
|
||||
def __init__(self, name: str, embedding_cfg: Dict) -> None:
|
||||
import os
|
||||
self.chroma_client = chromadb.PersistentClient(
|
||||
path=os.path.join(get_astrbot_data_path(), "long_term_memory_chroma.db")
|
||||
)
|
||||
self.collection = self.chroma_client.get_or_create_collection(name=name)
|
||||
self.embedding = None
|
||||
if embedding_cfg["strategy"] == "openai":
|
||||
self.embedding = SimpleOpenAIEmbedding(
|
||||
model=embedding_cfg["model"],
|
||||
api_key=embedding_cfg["api_key"],
|
||||
api_base=embedding_cfg.get("base_url", None),
|
||||
)
|
||||
|
||||
async def save(self, text: str, metadata: Dict = None):
|
||||
logger.debug(f"Saving text: {text}")
|
||||
embedding = await self.embedding.get_embedding(text)
|
||||
|
||||
self.collection.upsert(
|
||||
documents=text,
|
||||
metadatas=metadata,
|
||||
ids=str(uuid.uuid4()),
|
||||
embeddings=embedding,
|
||||
)
|
||||
|
||||
async def query(
|
||||
self, query: str, top_n=3, metadata_filter: Dict = None
|
||||
) -> List[str]:
|
||||
embedding = await self.embedding.get_embedding(query)
|
||||
|
||||
results = self.collection.query(
|
||||
query_embeddings=embedding, n_results=top_n, where=metadata_filter
|
||||
)
|
||||
return results["documents"][0]
|
||||
@@ -16,7 +16,6 @@ from .star_handler import star_handlers_registry, StarHandlerMetadata, EventType
|
||||
from .filter.command import CommandFilter
|
||||
from .filter.regex import RegexFilter
|
||||
from typing import Awaitable
|
||||
from astrbot.core.rag.knowledge_db_mgr import KnowledgeDBManager
|
||||
from astrbot.core.conversation_mgr import ConversationManager
|
||||
from astrbot.core.star.filter.platform_adapter_type import (
|
||||
PlatformAdapterType,
|
||||
@@ -42,6 +41,8 @@ class Context:
|
||||
|
||||
platform_manager: PlatformManager = None
|
||||
|
||||
registered_web_apis: list = []
|
||||
|
||||
# back compatibility
|
||||
_register_tasks: List[Awaitable] = []
|
||||
_star_manager = None
|
||||
@@ -54,14 +55,12 @@ class Context:
|
||||
provider_manager: ProviderManager = None,
|
||||
platform_manager: PlatformManager = None,
|
||||
conversation_manager: ConversationManager = None,
|
||||
knowledge_db_manager: KnowledgeDBManager = None,
|
||||
):
|
||||
self._event_queue = event_queue
|
||||
self._config = config
|
||||
self._db = db
|
||||
self.provider_manager = provider_manager
|
||||
self.platform_manager = platform_manager
|
||||
self.knowledge_db_manager = knowledge_db_manager
|
||||
self.conversation_manager = conversation_manager
|
||||
|
||||
def get_registered_star(self, star_name: str) -> StarMetadata:
|
||||
@@ -301,3 +300,6 @@ class Context:
|
||||
注册一个异步任务。
|
||||
"""
|
||||
self._register_tasks.append(task)
|
||||
|
||||
def register_web_api(self, route: str, view_handler: Awaitable, methods: list, desc: str):
|
||||
self.registered_web_apis.append((route, view_handler, methods, desc))
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
import enum
|
||||
import heapq
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Awaitable, List, Dict, TypeVar, Generic
|
||||
from .filter import HandlerFilter
|
||||
@@ -8,100 +7,66 @@ from .star import star_map
|
||||
|
||||
T = TypeVar("T", bound="StarHandlerMetadata")
|
||||
|
||||
|
||||
class StarHandlerRegistry(Generic[T]):
|
||||
"""用于存储所有的 Star Handler"""
|
||||
|
||||
star_handlers_map: Dict[str, StarHandlerMetadata] = {}
|
||||
"""用于快速查找。key 是 handler_full_name"""
|
||||
_handlers = []
|
||||
def __init__(self):
|
||||
self.star_handlers_map: Dict[str, StarHandlerMetadata] = {}
|
||||
self._handlers: List[StarHandlerMetadata] = []
|
||||
|
||||
def append(self, handler: StarHandlerMetadata):
|
||||
"""添加一个 Handler"""
|
||||
"""添加一个 Handler,并保持按优先级有序"""
|
||||
if "priority" not in handler.extras_configs:
|
||||
handler.extras_configs["priority"] = 0
|
||||
|
||||
heapq.heappush(self._handlers, (-handler.extras_configs["priority"], handler))
|
||||
self.star_handlers_map[handler.handler_full_name] = handler
|
||||
self._handlers.append(handler)
|
||||
self._handlers.sort(key=lambda h: -h.extras_configs["priority"])
|
||||
|
||||
def _print_handlers(self):
|
||||
"""打印所有的 Handler"""
|
||||
for _, handler in self._handlers:
|
||||
for handler in self._handlers:
|
||||
print(handler.handler_full_name)
|
||||
|
||||
def get_handlers_by_event_type(
|
||||
self, event_type: EventType, only_activated=True, platform_id=None
|
||||
) -> List[StarHandlerMetadata]:
|
||||
"""通过事件类型获取 Handler
|
||||
|
||||
Args:
|
||||
event_type: 事件类型
|
||||
only_activated: 是否只返回已激活的插件的处理器
|
||||
platform_id: 平台ID,如果提供此参数,将过滤掉在此平台不兼容的处理器
|
||||
|
||||
Returns:
|
||||
List[StarHandlerMetadata]: 处理器列表
|
||||
"""
|
||||
handlers = []
|
||||
for _, handler in self._handlers:
|
||||
for handler in self._handlers:
|
||||
if handler.event_type != event_type:
|
||||
continue
|
||||
|
||||
# 只激活的插件处理器
|
||||
if only_activated:
|
||||
plugin = star_map.get(handler.handler_module_path)
|
||||
if not (plugin and plugin.activated):
|
||||
continue
|
||||
|
||||
# 平台兼容性过滤
|
||||
if platform_id and event_type != EventType.OnAstrBotLoadedEvent:
|
||||
if not handler.is_enabled_for_platform(platform_id):
|
||||
continue
|
||||
|
||||
handlers.append(handler)
|
||||
|
||||
return handlers
|
||||
|
||||
def get_handler_by_full_name(self, full_name: str) -> StarHandlerMetadata:
|
||||
"""通过 Handler 的全名获取 Handler"""
|
||||
return self.star_handlers_map.get(full_name, None)
|
||||
|
||||
def get_handlers_by_module_name(
|
||||
self, module_name: str
|
||||
) -> List[StarHandlerMetadata]:
|
||||
"""通过模块名获取 Handler"""
|
||||
return [
|
||||
handler
|
||||
for _, handler in self._handlers
|
||||
handler for handler in self._handlers
|
||||
if handler.handler_module_path == module_name
|
||||
]
|
||||
|
||||
def clear(self):
|
||||
"""清空所有的 Handler"""
|
||||
self.star_handlers_map.clear()
|
||||
self._handlers.clear()
|
||||
|
||||
def remove(self, handler: StarHandlerMetadata):
|
||||
"""删除一个 Handler"""
|
||||
# self._handlers.remove(handler)
|
||||
for i, h in enumerate(self._handlers):
|
||||
if h[1] == handler:
|
||||
self._handlers.pop(i)
|
||||
break
|
||||
try:
|
||||
del self.star_handlers_map[handler.handler_full_name]
|
||||
except KeyError:
|
||||
pass
|
||||
self.star_handlers_map.pop(handler.handler_full_name, None)
|
||||
self._handlers = [h for h in self._handlers if h != handler]
|
||||
|
||||
def __iter__(self):
|
||||
"""使 StarHandlerRegistry 支持迭代"""
|
||||
return (handler for _, handler in self._handlers)
|
||||
return iter(self._handlers)
|
||||
|
||||
def __len__(self):
|
||||
"""返回 Handler 的数量"""
|
||||
return len(self._handlers)
|
||||
|
||||
|
||||
star_handlers_registry = StarHandlerRegistry()
|
||||
|
||||
|
||||
|
||||
@@ -102,7 +102,10 @@ class PluginRoute(Route):
|
||||
|
||||
async def get_plugins(self):
|
||||
_plugin_resp = []
|
||||
plugin_name = request.args.get("name")
|
||||
for plugin in self.plugin_manager.context.get_all_stars():
|
||||
if plugin_name and plugin.name != plugin_name:
|
||||
continue
|
||||
_t = {
|
||||
"name": plugin.name,
|
||||
"repo": "" if plugin.repo is None else plugin.repo,
|
||||
|
||||
@@ -8,6 +8,7 @@ from quart import request
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.config import VERSION
|
||||
from astrbot.core.utils.io import get_dashboard_version
|
||||
from astrbot.core import DEMO_MODE
|
||||
|
||||
|
||||
@@ -46,7 +47,10 @@ class StatRoute(Route):
|
||||
return f"{h}小时{m}分{s}秒"
|
||||
|
||||
async def get_version(self):
|
||||
return Response().ok({"version": VERSION}).__dict__
|
||||
return Response().ok({
|
||||
"version": VERSION,
|
||||
"dashboard_version": await get_dashboard_version(),
|
||||
}).__dict__
|
||||
|
||||
async def get_start_time(self):
|
||||
return Response().ok({"start_time": self.core_lifecycle.start_time}).__dict__
|
||||
|
||||
@@ -15,6 +15,8 @@ from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.utils.io import get_local_ip_addresses
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
APP: Quart = None
|
||||
|
||||
|
||||
class AstrBotDashboard:
|
||||
def __init__(
|
||||
@@ -27,6 +29,7 @@ class AstrBotDashboard:
|
||||
self.config = core_lifecycle.astrbot_config
|
||||
self.data_path = os.path.abspath(os.path.join(get_astrbot_data_path(), "dist"))
|
||||
self.app = Quart("dashboard", static_folder=self.data_path, static_url_path="/")
|
||||
APP = self.app # noqa
|
||||
self.app.config["MAX_CONTENT_LENGTH"] = (
|
||||
128 * 1024 * 1024
|
||||
) # 将 Flask 允许的最大上传文件体大小设置为 128 MB
|
||||
@@ -51,8 +54,25 @@ class AstrBotDashboard:
|
||||
self.conversation_route = ConversationRoute(self.context, db, core_lifecycle)
|
||||
self.file_route = FileRoute(self.context)
|
||||
|
||||
self.app.add_url_rule(
|
||||
"/api/plug/<path:subpath>",
|
||||
view_func=self.srv_plug_route,
|
||||
methods=["GET", "POST"],
|
||||
)
|
||||
|
||||
self.shutdown_event = shutdown_event
|
||||
|
||||
async def srv_plug_route(self, subpath, *args, **kwargs):
|
||||
"""
|
||||
插件路由
|
||||
"""
|
||||
registered_web_apis = self.core_lifecycle.star_context.registered_web_apis
|
||||
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 jsonify(Response().error("未找到该路由").__dict__)
|
||||
|
||||
async def auth_middleware(self):
|
||||
if not request.path.startswith("/api"):
|
||||
return
|
||||
|
||||
7
changelogs/v3.5.11.md
Normal file
7
changelogs/v3.5.11.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# What's Changed
|
||||
|
||||
1. 新增:火山引擎 TTS
|
||||
2. 修复:修复了 WeChatPadPro 在重新登录时为新设备的问题
|
||||
2. ‼️修复:微信公众号(个人认证或者未认证)的情况下能接收但无法回复消息的问题
|
||||
3. 修复:Minimax TTS 相关问题
|
||||
4. 优化:登录界面侧边栏、关于页面样式,修复如果此前已经登录但未自行跳转的问题
|
||||
18
changelogs/v3.5.12.md
Normal file
18
changelogs/v3.5.12.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# What's Changed
|
||||
|
||||
1. 新增:支持 MCP 的 Streamable HTTP 传输方式。详见 [#1637](https://github.com/Soulter/AstrBot/issues/1637)
|
||||
2. 新增:支持 MCP 的 SSE 传输方式的自定义请求头。详见 [#1659](https://github.com/Soulter/AstrBot/issues/1659)
|
||||
3. 优化:将 /llm 和 /model 和 /provider 指令设置为管理员指令
|
||||
4. 修复:修复插件的 priority 部分失效的问题
|
||||
5. 修复:修复 QQ 下合并转发消息内无法发送文件等问题,尽可能修复了各种文件、语音、视频、图片无法发送的问题
|
||||
6. 优化:Telegram 支持长消息分段发送,优化消息编辑的逻辑
|
||||
7. 优化:WebUI 强制默认修改密码
|
||||
8. 优化:移除了 vpet
|
||||
9. 新增:插件接口:支持动态路由注册
|
||||
10. 优化:CLI 模式下的插件下载
|
||||
11. 新增:WeChatPadPro 对接获取联系人接口
|
||||
12. 新增:T2I、语音、视频支持文件服务
|
||||
13. 优化:硅基流动下某些工具调用返回的 argument 格式适配
|
||||
14. 优化:在使用 /llm 指令关闭后重启 AstrBot 后,模型提供商未被加载
|
||||
15. 新增:新增基于 FAISS + SQLite 的向量存储接口
|
||||
16. 新增:Alkaid Page
|
||||
@@ -20,6 +20,7 @@
|
||||
"axios": "^1.6.2",
|
||||
"axios-mock-adapter": "^1.22.0",
|
||||
"chance": "1.1.11",
|
||||
"d3": "^7.9.0",
|
||||
"date-fns": "2.30.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"js-md5": "^0.8.3",
|
||||
|
||||
BIN
dashboard/src/assets/images/astrbot_logo_mini.webp
Normal file
BIN
dashboard/src/assets/images/astrbot_logo_mini.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
70
dashboard/src/components/shared/Logo.vue
Normal file
70
dashboard/src/components/shared/Logo.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div class="logo-container">
|
||||
<div class="logo-content">
|
||||
<div class="logo-image">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// No props or other logic needed for this simple component
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.logo-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.logo-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.logo-image {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo-image img {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.logo-image img:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.logo-text h2 {
|
||||
margin: 0;
|
||||
font-size: 1.8rem;
|
||||
font-weight: 600;
|
||||
color: #5e35b1;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.logo-text h4 {
|
||||
margin: 4px 0 0 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
color: #616161;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
</style>
|
||||
@@ -78,6 +78,17 @@ function accountEdit() {
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
function checkUpdate() {
|
||||
updateStatus.value = '正在检查更新...';
|
||||
axios.get('/api/update/check')
|
||||
@@ -90,8 +101,6 @@ function checkUpdate() {
|
||||
} else {
|
||||
updateStatus.value = res.data.message;
|
||||
}
|
||||
botCurrVersion.value = res.data.data.version;
|
||||
dashboardCurrentVersion.value = res.data.data.dashboard_version;
|
||||
dashboardHasNewVersion.value = res.data.data.dashboard_has_new_version;
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -181,6 +190,7 @@ function updateDashboard() {
|
||||
});
|
||||
}
|
||||
|
||||
getVersion();
|
||||
checkUpdate();
|
||||
|
||||
const commonStore = useCommonStore();
|
||||
@@ -208,23 +218,29 @@ if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('cha
|
||||
<v-icon>mdi-menu</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<span style="margin-left: 16px; font-size: 24px; font-weight: 1000;">Astr<span
|
||||
style="font-weight: normal;">Bot</span></span>
|
||||
<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>
|
||||
|
||||
<v-spacer />
|
||||
|
||||
<div class="mr-4">
|
||||
<small v-if="hasNewVersion">
|
||||
有新版本!
|
||||
AstrBot 有新版本!
|
||||
</small>
|
||||
<small v-else-if="dashboardHasNewVersion">
|
||||
WebUI 有新版本!
|
||||
</small>
|
||||
</div>
|
||||
|
||||
|
||||
<v-dialog v-model="updateStatusDialog" width="1000">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn @click="checkUpdate(); getReleases(); getDevCommits();" class="text-primary mr-4" color="lightprimary"
|
||||
<v-btn size="small" @click="checkUpdate(); getReleases(); getDevCommits();" class="text-primary mr-2" color="lightprimary"
|
||||
variant="flat" rounded="sm" v-bind="props">
|
||||
更新 🔄
|
||||
更新
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-card>
|
||||
@@ -353,8 +369,8 @@ if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('cha
|
||||
|
||||
<v-dialog v-model="dialog" persistent width="700">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn class="text-primary mr-4" color="lightprimary" variant="flat" rounded="sm" v-bind="props">
|
||||
账户 📰
|
||||
<v-btn size="small" class="text-primary mr-4" color="lightprimary" variant="flat" rounded="sm" v-bind="props">
|
||||
账户
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-card>
|
||||
@@ -367,7 +383,7 @@ if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('cha
|
||||
<v-col cols="12">
|
||||
|
||||
<v-alert v-if="accountWarning" color="warning" style="margin-bottom: 16px;">
|
||||
<div>为了安全,请尽快修改默认密码。</div>
|
||||
<div>为了安全,请务必修改默认密码。</div>
|
||||
</v-alert>
|
||||
|
||||
<v-text-field label="原密码*" type="password" v-model="password" required
|
||||
@@ -386,7 +402,7 @@ if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('cha
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="blue-darken-1" variant="text" @click="dialog = false">
|
||||
<v-btn v-if="!accountWarning" color="blue-darken-1" variant="text" @click="dialog = false">
|
||||
关闭
|
||||
</v-btn>
|
||||
<v-btn color="blue-darken-1" variant="text" @click="accountEdit">
|
||||
|
||||
@@ -9,9 +9,6 @@ const customizer = useCustomizerStore();
|
||||
const sidebarMenu = shallowRef(sidebarItems);
|
||||
|
||||
const showIframe = ref(false);
|
||||
const version = ref("");
|
||||
const buildVer = ref("");
|
||||
const hasWebUIUpdate = ref(false);
|
||||
|
||||
// 默认桌面端 iframe 样式
|
||||
const iframeStyle = ref({
|
||||
@@ -68,9 +65,10 @@ function toggleIframe() {
|
||||
showIframe.value = !showIframe.value;
|
||||
}
|
||||
|
||||
function openIframeLink() {
|
||||
function openIframeLink(url) {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.open("https://astrbot.app", "_blank");
|
||||
let url_ = url || "https://astrbot.app";
|
||||
window.open(url_, "_blank");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,25 +147,6 @@ function endDrag() {
|
||||
document.removeEventListener('touchend', onTouchEnd);
|
||||
}
|
||||
|
||||
// 获取版本和更新信息
|
||||
onMounted(() => {
|
||||
axios.get('/api/stat/version')
|
||||
.then((res) => {
|
||||
version.value = "v" + res.data.data.version;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
|
||||
axios.get('/api/update/check?type=dashboard')
|
||||
.then((res) => {
|
||||
hasWebUIUpdate.value = res.data.data.has_new_version;
|
||||
buildVer.value = res.data.data.current_version;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -186,27 +165,23 @@ onMounted(() => {
|
||||
<NavItem :item="item" class="leftPadding" />
|
||||
</template>
|
||||
</v-list>
|
||||
<div class="text-center">
|
||||
<v-chip color="inputBorder" size="small"> {{ version }} </v-chip>
|
||||
</div>
|
||||
<div style="position: absolute; bottom: 32px; width: 100%; font-size: 13px;" class="text-center">
|
||||
<v-list-item v-if="!customizer.mini_sidebar" @click="toggleIframe">
|
||||
<v-btn variant="plain" size="small">
|
||||
🤔 点击此处 查看/关闭 悬浮文档!
|
||||
</v-btn>
|
||||
</v-list-item>
|
||||
<small style="display: block;" v-if="buildVer">WebUI 版本: {{ buildVer }}</small>
|
||||
<small style="display: block;" v-else>构建: embedded</small>
|
||||
<v-tooltip text="使用 /dashboard_update 指令更新管理面板">
|
||||
<template v-slot:activator="{ props }">
|
||||
<small v-bind="props" v-if="hasWebUIUpdate" style="display: block; margin-top: 4px;">面板有更新</small>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
<small style="display: block; margin-top: 8px;">AGPL-3.0</small>
|
||||
<div style="position: absolute; bottom: 16px; width: 100%; font-size: 13px;" class="text-center">
|
||||
<v-btn style="margin-bottom: 8px;" size="small" variant="primary" v-if="!customizer.mini_sidebar" to="/settings">
|
||||
🔧 设置
|
||||
</v-btn>
|
||||
<br/>
|
||||
<v-btn style="margin-bottom: 8px;" size="small" variant="plain" v-if="!customizer.mini_sidebar" @click="toggleIframe">
|
||||
官方文档
|
||||
</v-btn>
|
||||
<br/>
|
||||
<v-btn style="margin-bottom: 8px;" size="small" variant="plain" v-if="!customizer.mini_sidebar" @click="openIframeLink('https://github.com/AstrBotDevs/AstrBot')">
|
||||
GitHub
|
||||
</v-btn>
|
||||
<br/>
|
||||
|
||||
</div>
|
||||
</v-navigation-drawer>
|
||||
|
||||
<!-- 优化后的悬浮 iframe -->
|
||||
<div
|
||||
v-if="showIframe"
|
||||
id="draggable-iframe"
|
||||
|
||||
@@ -65,11 +65,11 @@ const sidebarItem: menu[] = [
|
||||
icon: 'mdi-console',
|
||||
to: '/console'
|
||||
},
|
||||
{
|
||||
title: '设置',
|
||||
icon: 'mdi-wrench',
|
||||
to: '/settings'
|
||||
},
|
||||
// {
|
||||
// title: 'Alkaid',
|
||||
// icon: 'mdi-test-tube',
|
||||
// to: '/alkaid'
|
||||
// },
|
||||
{
|
||||
title: '关于',
|
||||
icon: 'mdi-information',
|
||||
|
||||
@@ -57,9 +57,26 @@ const MainRoutes = {
|
||||
component: () => import('@/views/ConsolePage.vue')
|
||||
},
|
||||
{
|
||||
name: 'Project ATRI',
|
||||
path: '/project-atri',
|
||||
component: () => import('@/views/ATRIProject.vue')
|
||||
name: 'Alkaid',
|
||||
path: '/alkaid',
|
||||
component: () => import('@/views/AlkaidPage.vue'),
|
||||
children: [
|
||||
{
|
||||
path: 'knowledge-base',
|
||||
name: 'KnowledgeBase',
|
||||
component: () => import('@/views/alkaid/KnowledgeBase.vue')
|
||||
},
|
||||
{
|
||||
path: 'long-term-memory',
|
||||
name: 'LongTermMemory',
|
||||
component: () => import('@/views/alkaid/LongTermMemory.vue')
|
||||
},
|
||||
{
|
||||
path: 'other',
|
||||
name: 'OtherFeatures',
|
||||
component: () => import('@/views/alkaid/Other.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Chat',
|
||||
|
||||
@@ -24,6 +24,11 @@ router.beforeEach(async (to, from, next) => {
|
||||
const authRequired = !publicPages.includes(to.path);
|
||||
const auth: AuthStore = useAuthStore();
|
||||
|
||||
// 如果用户已登录且试图访问登录页面,则重定向到首页或之前尝试访问的页面
|
||||
if (to.path === '/auth/login' && auth.has_token()) {
|
||||
return next(auth.returnUrl || '/');
|
||||
}
|
||||
|
||||
if (to.matched.some((record) => record.meta.requiresAuth)) {
|
||||
if (authRequired && !auth.has_token()) {
|
||||
auth.returnUrl = to.fullPath;
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<v-alert style="margin-bottom: 16px"
|
||||
text="这是一个长期实验性功能,目标是实现更具人类机能的 LLM 对话。推荐使用 gpt-4o-mini 作为文本生成和视觉理解模型,成本很低。推荐使用 text-embedding-3-small 作为 Embedding 模型,成本忽略不计。"
|
||||
title="💡实验性功能" type="info" variant="tonal">
|
||||
</v-alert>
|
||||
<v-card>
|
||||
<v-card-text>
|
||||
<v-container fluid>
|
||||
<AstrBotConfig :metadata="project_atri_config_metadata" :iterable="project_atri_config?.project_atri"
|
||||
metadataKey="project_atri">
|
||||
</AstrBotConfig>
|
||||
</v-container>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-btn icon="mdi-content-save" size="x-large" style="position: fixed; right: 52px; bottom: 52px;" color="darkprimary"
|
||||
@click="updateConfig">
|
||||
</v-btn>
|
||||
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack">
|
||||
{{ save_message }}
|
||||
</v-snackbar>
|
||||
<WaitingForRestart ref="wfr"></WaitingForRestart>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
|
||||
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
|
||||
export default {
|
||||
name: 'AtriProject',
|
||||
components: {
|
||||
AstrBotConfig,
|
||||
WaitingForRestart
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
project_atri_config: {},
|
||||
fetched: false,
|
||||
project_atri_config_metadata: {},
|
||||
save_message_snack: false,
|
||||
save_message: "",
|
||||
save_message_success: "",
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.getConfig();
|
||||
},
|
||||
methods: {
|
||||
getConfig() {
|
||||
// 获取配置
|
||||
axios.get('/api/config/get').then((res) => {
|
||||
this.project_atri_config = res.data.data.config;
|
||||
this.fetched = true
|
||||
this.project_atri_config_metadata = res.data.data.metadata;
|
||||
}).catch((err) => {
|
||||
save_message = err;
|
||||
save_message_snack = true;
|
||||
save_message_success = "error";
|
||||
});
|
||||
},
|
||||
updateConfig() {
|
||||
if (!this.fetched) return;
|
||||
axios.post('/api/config/astrbot/update', this.project_atri_config).then((res) => {
|
||||
if (res.data.status === "ok") {
|
||||
this.save_message = res.data.message;
|
||||
this.save_message_snack = true;
|
||||
this.save_message_success = "success";
|
||||
this.$refs.wfr.check();
|
||||
} else {
|
||||
this.save_message = res.data.message;
|
||||
this.save_message_snack = true;
|
||||
this.save_message_success = "error";
|
||||
}
|
||||
}).catch((err) => {
|
||||
this.save_message = err;
|
||||
this.save_message_snack = true;
|
||||
this.save_message_success = "error";
|
||||
});
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
</script>
|
||||
@@ -1,54 +1,84 @@
|
||||
<template>
|
||||
<v-card style="height: 100%;">
|
||||
<v-card-text style="padding: 0; height: 100%; overflow-y: auto;">
|
||||
<div
|
||||
style="display: flex; justify-content: center; align-items: center; height: 100%; flex-direction: column;">
|
||||
<div @click="selectedLogo = selectedLogo == 0 ? 1 : 0" style="height: 300px;">
|
||||
<img v-if="selectedLogo == 0" width="300" src="@/assets/images/logo-waifu.png" alt="AstrBot Logo"
|
||||
class="fade-in">
|
||||
<img v-if="selectedLogo == 1" width="300" src="@/assets/images/logo-normal.svg" alt="AstrBot Logo"
|
||||
class="fade-in">
|
||||
</div>
|
||||
<v-card style="height: 100%;" elevation="0" class="bg-surface">
|
||||
<v-card-text style="padding: 0; height: 100%; overflow-y: hidden;">
|
||||
<div class="about-wrapper">
|
||||
<!-- Hero Section -->
|
||||
<section class="hero-section">
|
||||
<div class="logo-title-container">
|
||||
<div @click="selectedLogo = selectedLogo == 0 ? 1 : 0" class="logo-container">
|
||||
<img v-if="selectedLogo == 0" width="280" src="@/assets/images/logo-waifu.png" alt="AstrBot Logo" class="fade-in">
|
||||
<img v-if="selectedLogo == 1" width="280" src="@/assets/images/logo-normal.svg" alt="AstrBot Logo" class="fade-in">
|
||||
</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>
|
||||
<div class="action-buttons">
|
||||
<v-btn @click="open('https://github.com/Soulter/AstrBot')"
|
||||
color="primary" variant="elevated" prepend-icon="mdi-star">
|
||||
Star 这个项目! 🌟
|
||||
</v-btn>
|
||||
<v-btn class="ml-4" @click="open('https://github.com/Soulter/AstrBot/issues')"
|
||||
color="secondary" variant="elevated" prepend-icon="mdi-comment-question">
|
||||
提交 Issue
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<h1 class="mt-8">AstrBot</h1>
|
||||
<!-- Contributors Section -->
|
||||
<section class="contributors-section">
|
||||
<v-container>
|
||||
<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>
|
||||
<p class="text-body-1" style="color: #777;">
|
||||
<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
|
||||
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>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</section>
|
||||
|
||||
<span class="mt-2" style="color: #777;">A project out of interests and loves ❤️</span>
|
||||
|
||||
<span style="color: #777; margin-left: 32px; margin-right: 32px" class="mt-4">By <a
|
||||
href="https://soulter.top">Soulter</a>, <a
|
||||
href="https://github.com/Soulter/AstrBot/graphs/contributors">AstrBot Contributors</a>
|
||||
and <a href="https://github.com/Soulter/AstrBot_Plugins_Collection/graphs/contributors">AstrBot
|
||||
Plugin Authors</a>
|
||||
</span>
|
||||
|
||||
<!-- Copy-paste in your Readme.md file -->
|
||||
|
||||
<img style="margin-top: 16px; width: 50%; max-width: 500px; margin-left: 32px; margin-right: 32px"
|
||||
alt="Active Contributors of Soulter/AstrBot - Last 28 days"
|
||||
src="https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=575865240&limit=365&image_size=auto&color_scheme=light">
|
||||
|
||||
<img style="margin-top: 16px; width: 50%; max-width: 500px; margin-left: 32px; margin-right: 32px"
|
||||
alt="Active Contributors of Soulter/AstrBot - Last 28 days"
|
||||
src="https://next.ossinsight.io/widgets/official/analyze-repo-stars-map/thumbnail.png?activity=stars&repo_id=575865240&image_size=auto&color_scheme=light
|
||||
">
|
||||
|
||||
|
||||
<!-- Made with [OSS Insight](https://ossinsight.io/) -->
|
||||
|
||||
<v-btn class="text-primary mt-8" @click="open('https://github.com/Soulter/AstrBot')"
|
||||
color="lightprimary" variant="flat" rounded="sm">
|
||||
Star 这个项目! 🌟
|
||||
</v-btn>
|
||||
|
||||
<v-btn class="text-primary mt-4" @click="open('https://github.com/Soulter/AstrBot/issues')"
|
||||
color="lightprimary" variant="flat" rounded="sm">
|
||||
有使用问题或者功能建议?提交 Issue!
|
||||
</v-btn>
|
||||
<!-- Stats Section -->
|
||||
<section class="stats-section">
|
||||
<v-container>
|
||||
<v-row justify="center" align="center" class="flex-md-row-reverse">
|
||||
<v-col cols="12" md="6" class="pl-md-8 stats-info">
|
||||
<h2 class="text-h4 font-weight-medium">全球部署</h2>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-card variant="outlined" class="overflow-hidden" elevation="2">
|
||||
<v-img
|
||||
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>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'AboutPage',
|
||||
@@ -64,21 +94,135 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
<style scoped>
|
||||
.about-wrapper {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
.hero-section {
|
||||
padding: 40px 20px;
|
||||
background: linear-gradient(to right bottom, rgba(255,255,255,0.7), rgba(240,240,250,0.3));
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo-title-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
max-width: 900px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logo-container:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.title-container {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.contributors-section, .stats-section {
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
.contributors-section {
|
||||
background-color: #f9f9fb;
|
||||
}
|
||||
|
||||
.contributors-info, .stats-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.custom-link {
|
||||
display: inline-block;
|
||||
padding: 5px 0;
|
||||
position: relative;
|
||||
color: var(--v-primary-base);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.custom-link::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
transform: scaleX(0);
|
||||
height: 2px;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background-color: var(--v-primary-base);
|
||||
transform-origin: bottom right;
|
||||
transition: transform 0.25s ease-out;
|
||||
}
|
||||
|
||||
.custom-link:hover::after {
|
||||
transform: scaleX(1);
|
||||
transform-origin: bottom left;
|
||||
}
|
||||
|
||||
.license-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.logo-title-container {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title-container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.license-container {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.contributors-section, .stats-section {
|
||||
padding: 40px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-buttons .v-btn + .v-btn {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
80
dashboard/src/views/AlkaidPage.vue
Normal file
80
dashboard/src/views/AlkaidPage.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<v-card style="height: 100%; width: 100%;">
|
||||
<v-card-text class="pa-4" style="height: 100%;">
|
||||
<v-container fluid class="d-flex flex-column" style="height: 100%;">
|
||||
<div style="margin-bottom: 32px;">
|
||||
<h1 class="gradient-text">The Alkaid Project.</h1>
|
||||
<small style="color: #a3a3a3;">AstrBot Alpha 项目</small>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
|
||||
<v-btn size="large" :variant="isActive('knowledge-base') ? 'flat' : 'tonal'"
|
||||
:color="isActive('knowledge-base') ? '#9b72cb' : ''" rounded="lg"
|
||||
@click="navigateTo('knowledge-base')">
|
||||
<v-icon start>mdi-text-box-search</v-icon>
|
||||
知识库
|
||||
</v-btn>
|
||||
<v-btn size="large" :variant="isActive('long-term-memory') ? 'flat' : 'tonal'"
|
||||
:color="isActive('long-term-memory') ? '#9b72cb' : ''" rounded="lg"
|
||||
@click="navigateTo('long-term-memory')">
|
||||
<v-icon start>mdi-dots-hexagon</v-icon>
|
||||
长期记忆层
|
||||
</v-btn>
|
||||
<v-btn size="large" :variant="isActive('other') ? 'flat' : 'tonal'"
|
||||
:color="isActive('other') ? '#9b72cb' : ''" rounded="lg"
|
||||
@click="navigateTo('other')">
|
||||
<v-icon start>mdi-tools</v-icon>
|
||||
...
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div id="sub-view" class="flex-grow-1" style="max-height: 100%;">
|
||||
<router-view></router-view>
|
||||
</div>
|
||||
</v-container>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'AlkaidPage',
|
||||
components: {},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
methods: {
|
||||
navigateTo(tab) {
|
||||
this.$router.push(`/alkaid/${tab}`);
|
||||
},
|
||||
isActive(tab) {
|
||||
return this.$route.path.includes(`/alkaid/${tab}`);
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// 如果在根路径 /alkaid,默认跳转到知识库页面
|
||||
if (this.$route.path === '/alkaid') {
|
||||
this.navigateTo('knowledge-base');
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gradient-text {
|
||||
background: linear-gradient(74deg, #2abfe1 0, #9b72cb 25%, #b55908 50%, #d93025 100%);
|
||||
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#subview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
432
dashboard/src/views/AlkaidPage_sigma.vue
Normal file
432
dashboard/src/views/AlkaidPage_sigma.vue
Normal file
@@ -0,0 +1,432 @@
|
||||
<script setup>
|
||||
import Graph from "graphology";
|
||||
import Sigma from "sigma";
|
||||
import ForceSupervisor from "graphology-layout-force/worker";
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<v-card style="height: 100%; width: 100%;">
|
||||
<v-card-text class="pa-4" style="height: 100%;">
|
||||
<v-container fluid class="d-flex flex-column" style="height: 100%;">
|
||||
<div style="margin-bottom: 32px;">
|
||||
<h1 class="gradient-text">The Alkaid Project.</h1>
|
||||
<small style="color: #a3a3a3;">AstrBot 实验性项目</small>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
|
||||
<v-btn size="large" :variant="activeTab === 'long-term-memory' ? 'flat' : 'tonal'"
|
||||
:color="activeTab === 'long-term-memory' ? '#9b72cb' : ''" rounded="lg"
|
||||
@click="activeTab = 'long-term-memory'">
|
||||
<v-icon start>mdi-dots-hexagon</v-icon>
|
||||
长期记忆层
|
||||
</v-btn>
|
||||
<v-btn size="large" :variant="activeTab === 'other' ? 'flat' : 'tonal'"
|
||||
:color="activeTab === 'other' ? '#9b72cb' : ''" rounded="lg" @click="activeTab = 'other'">
|
||||
<v-icon start>mdi-dots-horizontal</v-icon>
|
||||
其他
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'long-term-memory'" 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;">
|
||||
</div>
|
||||
<div id="graph-control-panel"
|
||||
style="min-width: 450px; border: 1px solid #eee; border-radius: 8px; padding: 16px; margin-left: 16px;">
|
||||
<div>
|
||||
<span style="color: #333333;">可视化</span>
|
||||
<div style="margin-top: 8px;">
|
||||
<v-autocomplete v-model="searchUserId" :items="userIdList" variant="outlined"
|
||||
label="筛选用户 ID"></v-autocomplete>
|
||||
<v-btn color="primary" @click="onNodeSelect" variant="tonal" style="margin-top: 8px;">
|
||||
<v-icon start>mdi-magnify</v-icon>
|
||||
筛选
|
||||
</v-btn>
|
||||
<v-btn color="secondary" @click="resetFilter" variant="tonal"
|
||||
style="margin-top: 8px; margin-left: 8px;">
|
||||
<v-icon start>mdi-filter-remove</v-icon>
|
||||
重置筛选
|
||||
</v-btn>
|
||||
</div>
|
||||
<div style="margin-top: 16px;">
|
||||
<v-btn color="primary" @click="refreshGraph" variant="tonal">
|
||||
<v-icon start>mdi-refresh</v-icon>
|
||||
刷新图形
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-divider class="my-4"></v-divider>
|
||||
|
||||
<div v-if="selectedNode" class="mt-4">
|
||||
<h3>节点详情</h3>
|
||||
<v-card variant="outlined" class="mt-2 pa-3">
|
||||
<div v-if="selectedNode.id">
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-subtitle-2">ID:</span>
|
||||
<span>{{ selectedNode.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedNode._label">
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-subtitle-2">类型:</span>
|
||||
<span>{{ selectedNode._label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedNode.name">
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-subtitle-2">名称:</span>
|
||||
<span>{{ selectedNode.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedNode.user_id">
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-subtitle-2">用户ID:</span>
|
||||
<span>{{ selectedNode.user_id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedNode.ts">
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-subtitle-2">时间戳:</span>
|
||||
<span>{{ selectedNode.ts }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedNode.type">
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-subtitle-2">类型:</span>
|
||||
<span>{{ selectedNode.type }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<div v-if="graphStats" class="mt-4">
|
||||
<h3>图形统计</h3>
|
||||
<v-card variant="outlined" class="mt-2 pa-3">
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-subtitle-2">节点数:</span>
|
||||
<span>{{ graphStats.nodeCount }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-subtitle-2">边数:</span>
|
||||
<span>{{ graphStats.edgeCount }}</span>
|
||||
</div>
|
||||
</v-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'other'" class="flex-grow-1" style="display: flex; flex-direction: column;">
|
||||
<div class="d-flex align-center justify-center"
|
||||
style="flex-grow: 1; width: 100%; border: 1px solid #eee; border-radius: 8px;">
|
||||
<v-icon size="64" color="grey-lighten-1">mdi-tools</v-icon>
|
||||
<p class="text-h6 text-grey ml-4">功能开发中</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</v-container>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
|
||||
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
|
||||
export default {
|
||||
name: 'AlkaidPage',
|
||||
components: {
|
||||
AstrBotConfig,
|
||||
WaitingForRestart
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
renderer: null,
|
||||
graph: null,
|
||||
layout: null,
|
||||
activeTab: 'long-term-memory',
|
||||
node_data: [],
|
||||
edge_data: [],
|
||||
searchUserId: null,
|
||||
userIdList: [],
|
||||
selectedNode: null,
|
||||
graphStats: null,
|
||||
nodeColors: {
|
||||
'PhaseNode': '#4CAF50', // 绿色
|
||||
'PassageNode': '#2196F3', // 蓝色
|
||||
'FactNode': '#FF9800', // 橙色
|
||||
'default': '#9C27B0' // 紫色作为默认
|
||||
},
|
||||
edgeColors: {
|
||||
'_include_': '#607D8B',
|
||||
'_related_': '#9E9E9E',
|
||||
'default': '#BDBDBD'
|
||||
},
|
||||
isLoading: false
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.initSigma();
|
||||
this.ltmGetGraph();
|
||||
this.ltmGetUserIds();
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.renderer) {
|
||||
this.renderer.kill();
|
||||
}
|
||||
if (this.layout) {
|
||||
this.layout.stop();
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
activeTab(newVal) {
|
||||
if (newVal === 'long-term-memory') {
|
||||
this.$nextTick(() => {
|
||||
if (!this.renderer) {
|
||||
this.initSigma();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (this.renderer) {
|
||||
this.renderer.kill();
|
||||
this.renderer = null;
|
||||
}
|
||||
if (this.layout) {
|
||||
this.layout.stop();
|
||||
this.layout = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
ltmGetGraph(userId = null) {
|
||||
this.isLoading = true;
|
||||
const params = userId ? { user_id: userId } : {};
|
||||
|
||||
axios.get('/api/plug/alkaid/ltm/graph', { params })
|
||||
.then(response => {
|
||||
let nodes = response.data.data.nodes;
|
||||
let edges = response.data.data.edges;
|
||||
|
||||
this.node_data = nodes;
|
||||
this.edge_data = edges;
|
||||
|
||||
if (this.graph) {
|
||||
this.graph.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
nodes.forEach(node => {
|
||||
const nodeId = node[0];
|
||||
const nodeData = node[1];
|
||||
|
||||
if (!this.graph.hasNode(nodeId)) {
|
||||
const nodeType = nodeData._label || 'default';
|
||||
const color = this.nodeColors[nodeType] || this.nodeColors['default'];
|
||||
|
||||
this.graph.addNode(nodeId, {
|
||||
x: Math.random(),
|
||||
y: Math.random(),
|
||||
size: 5,
|
||||
label: nodeData.name || nodeId.split('_')[0],
|
||||
color: color,
|
||||
originalData: nodeData
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 添加边
|
||||
edges.forEach(edge => {
|
||||
const sourceId = edge[0];
|
||||
const targetId = edge[1];
|
||||
const edgeData = edge[2];
|
||||
|
||||
if (this.graph.hasNode(sourceId) && this.graph.hasNode(targetId)) {
|
||||
const edgeId = `${sourceId}->${targetId}`;
|
||||
const relationType = edgeData.relation_type || 'default';
|
||||
const color = this.edgeColors[relationType] || this.edgeColors['default'];
|
||||
this.graph.addEdge(sourceId, targetId, {
|
||||
size: 1,
|
||||
color: color,
|
||||
originalData: edgeData,
|
||||
label: relationType,
|
||||
type: "line"
|
||||
});
|
||||
} else {
|
||||
console.warn(`Edge ${sourceId} -> ${targetId} has missing nodes.`);
|
||||
}
|
||||
});
|
||||
|
||||
this.updateGraphStats();
|
||||
|
||||
console.log('Graph initialized with', nodes.length, 'nodes and', edges.length, 'edges');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching graph data:', error);
|
||||
})
|
||||
.finally(() => {
|
||||
this.isLoading = false;
|
||||
});
|
||||
|
||||
if (this.layout) {
|
||||
this.layout.start();
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
ltmGetUserIds() {
|
||||
axios.get('/api/plug/alkaid/ltm/user_ids')
|
||||
.then(response => {
|
||||
this.userIdList = response.data.data;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching user IDs:', error);
|
||||
});
|
||||
},
|
||||
|
||||
updateGraphStats() {
|
||||
if (this.graph) {
|
||||
this.graphStats = {
|
||||
nodeCount: this.graph.order,
|
||||
edgeCount: this.graph.size
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
refreshGraph() {
|
||||
this.ltmGetGraph(this.searchUserId);
|
||||
},
|
||||
|
||||
onNodeSelect() {
|
||||
console.log('Selected user ID:', this.searchUserId);
|
||||
if (!this.searchUserId || !this.graph) return;
|
||||
|
||||
// 使用API的user_id参数筛选数据
|
||||
this.ltmGetGraph(this.searchUserId);
|
||||
},
|
||||
|
||||
resetFilter() {
|
||||
this.searchUserId = null;
|
||||
this.ltmGetGraph();
|
||||
},
|
||||
|
||||
initSigma() {
|
||||
const container = document.getElementById("graph-container");
|
||||
if (!container) return;
|
||||
|
||||
if (this.renderer) {
|
||||
this.renderer.kill();
|
||||
this.renderer = null;
|
||||
}
|
||||
if (this.layout) {
|
||||
this.layout.stop();
|
||||
this.layout = null;
|
||||
}
|
||||
|
||||
const graph = new Graph({
|
||||
multi: true,
|
||||
});
|
||||
|
||||
const layout = new ForceSupervisor(graph, {
|
||||
isNodeFixed: (_, attr) => attr.highlighted, settings: {
|
||||
gravity: 0.0001,
|
||||
repulsion: 0.001
|
||||
}
|
||||
});
|
||||
layout.start();
|
||||
|
||||
this.layout = layout;
|
||||
this.graph = graph;
|
||||
const renderer = new Sigma(graph, container, {
|
||||
minCameraRatio: 0.01,
|
||||
maxCameraRatio: 2,
|
||||
labelRenderedSizeThreshold: 1,
|
||||
renderLabels: true,
|
||||
renderEdgeLabels: true,
|
||||
labelSize: 14,
|
||||
labelColor: "#333333",
|
||||
});
|
||||
this.renderer = renderer;
|
||||
|
||||
let draggedNode = null;
|
||||
let isDragging = false;
|
||||
|
||||
renderer.on("downNode", (e) => {
|
||||
isDragging = true;
|
||||
draggedNode = e.node;
|
||||
graph.setNodeAttribute(draggedNode, "highlighted", true);
|
||||
if (!renderer.getCustomBBox()) renderer.setCustomBBox(renderer.getBBox());
|
||||
});
|
||||
|
||||
renderer.on("moveBody", ({ event }) => {
|
||||
if (!isDragging || !draggedNode) return;
|
||||
const pos = renderer.viewportToGraph(event);
|
||||
|
||||
graph.setNodeAttribute(draggedNode, "x", pos.x);
|
||||
graph.setNodeAttribute(draggedNode, "y", pos.y);
|
||||
event.preventSigmaDefault();
|
||||
event.original.preventDefault();
|
||||
event.original.stopPropagation();
|
||||
});
|
||||
const handleUp = () => {
|
||||
if (draggedNode) {
|
||||
graph.removeNodeAttribute(draggedNode, "highlighted");
|
||||
}
|
||||
isDragging = false;
|
||||
draggedNode = null;
|
||||
};
|
||||
renderer.on("upNode", handleUp);
|
||||
renderer.on("upStage", handleUp);
|
||||
|
||||
renderer.on("clickNode", (e) => {
|
||||
const nodeId = e.node;
|
||||
const nodeAttributes = graph.getNodeAttributes(nodeId);
|
||||
this.selectedNode = nodeAttributes.originalData;
|
||||
});
|
||||
|
||||
renderer.on("clickStage", () => {
|
||||
this.selectedNode = null;
|
||||
});
|
||||
|
||||
},
|
||||
|
||||
getRandomColor() {
|
||||
const letters = '0123456789ABCDEF';
|
||||
let color = '#';
|
||||
for (let i = 0; i < 6; i++) {
|
||||
color += letters[Math.floor(Math.random() * 16)];
|
||||
}
|
||||
return color;
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gradient-text {
|
||||
background: linear-gradient(74deg, #2abfe1 0, #9b72cb 25%, #b55908 50%, #d93025 100%);
|
||||
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#graph-container {
|
||||
position: relative;
|
||||
background-color: #f2f6f9;
|
||||
overflow: hidden;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
#graph-container:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.memory-header {
|
||||
padding: 0 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -12,19 +12,22 @@ marked.setOptions({
|
||||
<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 variant="elevated" rounded="lg" class="new-chat-btn" @click="newC" :disabled="!currCid"
|
||||
prepend-icon="mdi-plus">
|
||||
创建对话
|
||||
<v-btn icon variant="plain">
|
||||
<v-icon icon="mdi-menu" color="deep-purple"></v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div style="padding: 16px; padding-top: 8px;">
|
||||
<v-btn variant="elevated" rounded="lg" class="new-chat-btn" @click="newC" :disabled="!currCid"
|
||||
prepend-icon="mdi-plus">
|
||||
创建对话
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="conversations-container">
|
||||
<div class="sidebar-section-title" v-if="conversations.length > 0">
|
||||
对话历史
|
||||
</div>
|
||||
|
||||
<v-card class="conversation-list-card" v-if="conversations.length > 0" flat>
|
||||
<v-list density="compact" nav class="conversation-list"
|
||||
@@ -36,7 +39,7 @@ marked.setOptions({
|
||||
</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-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
@@ -151,10 +154,10 @@ marked.setOptions({
|
||||
|
||||
<!-- 输入区域 -->
|
||||
<div class="input-area fade-in">
|
||||
<v-text-field id="input-field" variant="outlined" v-model="prompt" :label="inputFieldLabel"
|
||||
placeholder="开始输入..." :loading="loadingChat" clear-icon="mdi-close-circle" clearable
|
||||
@click:clear="clearMessage" class="message-input" @keydown="handleInputKeyDown"
|
||||
hide-details>
|
||||
<v-text-field autocomplete="off" id="input-field" variant="outlined" v-model="prompt"
|
||||
:label="inputFieldLabel" placeholder="开始输入..." :loading="loadingChat"
|
||||
clear-icon="mdi-close-circle" clearable @click:clear="clearMessage" class="message-input"
|
||||
@keydown="handleInputKeyDown" hide-details>
|
||||
<template v-slot:loader>
|
||||
<v-progress-linear :active="loadingChat" height="3" color="deep-purple"
|
||||
indeterminate></v-progress-linear>
|
||||
@@ -701,7 +704,6 @@ export default {
|
||||
|
||||
.sidebar-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.conversations-container {
|
||||
|
||||
@@ -58,7 +58,7 @@ import 'highlight.js/styles/github.css';
|
||||
|
||||
<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">
|
||||
<ExtensionCard :extension="plugin" market-mode="true" :highlight="true" @install="extension_url=plugin.repo; newExtension()">
|
||||
</ExtensionCard>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
@@ -327,7 +327,7 @@
|
||||
</div>
|
||||
<small>1. 某些 MCP 服务器可能需要按照其要求在 env 中填充 `API_KEY` 或 `TOKEN` 等信息,请注意检查是否填写。</small>
|
||||
<br>
|
||||
<small>2. 当配置中带有 url 参数时,将使用 SSE 的方式连接到服务器。</small>
|
||||
<small>2. 当配置中指定 url 参数时:如果还同时指定 `transport` 参数的值为 `streamable_http`,则使用 Steamable HTTP,否则使用 SSE 连接。</small>
|
||||
|
||||
<div class="monaco-container">
|
||||
<VueMonacoEditor v-model:value="serverConfigJson" theme="vs-dark" language="json" :options="{
|
||||
|
||||
750
dashboard/src/views/alkaid/KnowledgeBase.vue
Normal file
750
dashboard/src/views/alkaid/KnowledgeBase.vue
Normal file
@@ -0,0 +1,750 @@
|
||||
<template>
|
||||
<div class="flex-grow-1" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div style="flex-grow: 1; width: 100%; border: 1px solid #eee; border-radius: 8px; padding: 16px">
|
||||
<!-- 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">
|
||||
立即安装
|
||||
</v-btn>
|
||||
</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%;">
|
||||
<h2>还没有知识库,快创建一个吧!🙂</h2>
|
||||
<v-btn style="margin-top: 16px;" variant="tonal" color="primary" @click="showCreateDialog = true">
|
||||
创建知识库
|
||||
</v-btn>
|
||||
</div>
|
||||
<div v-else>
|
||||
<h2 class="mb-4">知识库列表</h2>
|
||||
<v-btn class="mb-4" prepend-icon="mdi-plus" variant="tonal" color="primary"
|
||||
@click="showCreateDialog = true">
|
||||
创建新知识库
|
||||
</v-btn>
|
||||
|
||||
<div class="kb-grid">
|
||||
<div v-for="(kb, index) in kbCollections" :key="index" class="kb-card"
|
||||
@click="openKnowledgeBase(kb)">
|
||||
<div class="book-spine"></div>
|
||||
<div class="book-content">
|
||||
<div class="emoji-container">
|
||||
<span class="kb-emoji">{{ kb.emoji || '🙂' }}</span>
|
||||
</div>
|
||||
<div class="kb-name">{{ kb.collection_name }}</div>
|
||||
<div class="kb-count">{{ kb.count || 0 }} 条知识</div>
|
||||
<div class="kb-actions">
|
||||
<v-btn icon variant="text" size="small" color="error" @click.stop="confirmDelete(kb)">
|
||||
<v-icon>mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding: 16px; text-align: center;">
|
||||
<small style="color: #a3a3a3">Tips: 在聊天页面通过 /kb 指令了解如何使用!</small>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 创建知识库对话框 -->
|
||||
<v-dialog v-model="showCreateDialog" max-width="500px">
|
||||
<v-card>
|
||||
<v-card-title class="text-h4">创建新知识库</v-card-title>
|
||||
<v-card-text>
|
||||
|
||||
<div style="width: 100%; display: flex; align-items: center; justify-content: center;">
|
||||
<span id="emoji-display" @click="showEmojiPicker = true">
|
||||
{{ newKB.emoji || '🙂' }}
|
||||
</span>
|
||||
</div>
|
||||
<v-form @submit.prevent="submitCreateForm">
|
||||
|
||||
|
||||
<v-text-field variant="outlined" v-model="newKB.name" label="知识库名称" required></v-text-field>
|
||||
|
||||
<v-textarea v-model="newKB.description" label="描述" variant="outlined" placeholder="知识库的简短描述..."
|
||||
rows="3"></v-textarea>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="error" variant="text" @click="showCreateDialog = false">取消</v-btn>
|
||||
<v-btn color="primary" variant="text" @click="submitCreateForm">创建</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 表情选择器对话框 -->
|
||||
<v-dialog v-model="showEmojiPicker" max-width="400px">
|
||||
<v-card>
|
||||
<v-card-title class="text-h6">选择表情</v-card-title>
|
||||
<v-card-text>
|
||||
<div class="emoji-picker">
|
||||
<div v-for="(category, catIndex) in emojiCategories" :key="catIndex" class="mb-4">
|
||||
<div class="text-subtitle-2 mb-2">{{ category.name }}</div>
|
||||
<div class="emoji-grid">
|
||||
<div v-for="(emoji, emojiIndex) in category.emojis" :key="emojiIndex" class="emoji-item"
|
||||
@click="selectEmoji(emoji)">
|
||||
{{ emoji }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="primary" variant="text" @click="showEmojiPicker = false">关闭</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 知识库内容管理对话框 -->
|
||||
<v-dialog v-model="showContentDialog" max-width="1000px">
|
||||
<v-card>
|
||||
<v-card-title class="d-flex align-center">
|
||||
<div class="me-2 emoji-sm">{{ currentKB.emoji || '🙂' }}</div>
|
||||
<span>{{ currentKB.collection_name }} - 知识库管理</span>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn variant="plain" icon @click="showContentDialog = false">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<v-tabs v-model="activeTab">
|
||||
<v-tab value="upload">上传文件</v-tab>
|
||||
<v-tab value="search">搜索内容</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<v-window v-model="activeTab" class="mt-4">
|
||||
<!-- 上传文件标签页 -->
|
||||
<v-window-item value="upload">
|
||||
<div class="upload-container pa-4">
|
||||
<div class="text-center mb-4">
|
||||
<h3>上传文件到知识库</h3>
|
||||
<p class="text-subtitle-1">支持 txt、pdf、word、excel 等多种格式</p>
|
||||
</div>
|
||||
|
||||
<div class="upload-zone" @dragover.prevent @drop.prevent="onFileDrop"
|
||||
@click="triggerFileInput">
|
||||
<input type="file" ref="fileInput" style="display: none" @change="onFileSelected" />
|
||||
<v-icon size="48" color="primary">mdi-cloud-upload</v-icon>
|
||||
<p class="mt-2">拖放文件到这里或点击上传</p>
|
||||
</div>
|
||||
|
||||
<div class="selected-files mt-4" v-if="selectedFile">
|
||||
<div type="info" variant="tonal" class="d-flex align-center">
|
||||
<div>
|
||||
<v-icon class="me-2">{{ getFileIcon(selectedFile.name) }}</v-icon>
|
||||
<span style="font-weight: 1000;">{{ selectedFile.name }}</span>
|
||||
</div>
|
||||
<v-btn size="small" color="error" variant="text" @click="selectedFile = null">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<v-btn color="primary" variant="elevated" :loading="uploading"
|
||||
:disabled="!selectedFile" @click="uploadFile">
|
||||
上传到知识库
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="upload-progress mt-4" v-if="uploading">
|
||||
<v-progress-linear indeterminate color="primary"></v-progress-linear>
|
||||
</div>
|
||||
</div>
|
||||
</v-window-item>
|
||||
|
||||
<!-- 搜索内容标签页 -->
|
||||
<v-window-item value="search">
|
||||
<div class="search-container pa-4">
|
||||
<v-form @submit.prevent="searchKnowledgeBase" class="d-flex align-center">
|
||||
<v-text-field v-model="searchQuery" label="搜索知识库内容" append-icon="mdi-magnify"
|
||||
variant="outlined" class="flex-grow-1 me-2" @click:append="searchKnowledgeBase"
|
||||
@keyup.enter="searchKnowledgeBase" placeholder="输入关键词搜索知识库内容..."
|
||||
hide-details></v-text-field>
|
||||
|
||||
<v-select v-model="topK" :items="[3, 5, 10, 20]" label="结果数量" variant="outlined"
|
||||
style="max-width: 120px;" hide-details></v-select>
|
||||
</v-form>
|
||||
|
||||
<div class="search-results mt-4">
|
||||
<div v-if="searching">
|
||||
<v-progress-linear indeterminate color="primary"></v-progress-linear>
|
||||
<p class="text-center mt-4">正在搜索...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="searchResults.length > 0">
|
||||
<h3 class="mb-2">搜索结果</h3>
|
||||
<v-card v-for="(result, index) in searchResults" :key="index"
|
||||
class="mb-4 search-result-card" variant="outlined">
|
||||
<v-card-text>
|
||||
<div class="d-flex align-center mb-2">
|
||||
<v-icon class="me-2" size="small"
|
||||
color="primary">mdi-file-document-outline</v-icon>
|
||||
<span class="text-caption text-medium-emphasis">{{
|
||||
result.metadata.source }}</span>
|
||||
<v-spacer></v-spacer>
|
||||
<v-chip v-if="result.score" size="small" color="primary"
|
||||
variant="tonal">
|
||||
相关度: {{ Math.round(result.score * 100) }}%
|
||||
</v-chip>
|
||||
</div>
|
||||
<div class="search-content">{{ result.content }}</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<div v-else-if="searchPerformed">
|
||||
<v-alert type="info" variant="tonal">
|
||||
没有找到匹配的内容
|
||||
</v-alert>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-window-item>
|
||||
</v-window>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 删除知识库确认对话框 -->
|
||||
<v-dialog v-model="showDeleteDialog" max-width="400px">
|
||||
<v-card>
|
||||
<v-card-title class="text-h5">确认删除</v-card-title>
|
||||
<v-card-text>
|
||||
<p>您确定要删除知识库 <span class="font-weight-bold">{{ deleteTarget.collection_name }}</span> 吗?</p>
|
||||
<p class="text-red">此操作不可逆,所有知识库内容将被永久删除。</p>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="grey-darken-1" variant="text" @click="showDeleteDialog = false">取消</v-btn>
|
||||
<v-btn color="error" variant="text" @click="deleteKnowledgeBase" :loading="deleting">删除</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 消息提示 -->
|
||||
<v-snackbar v-model="snackbar.show" :color="snackbar.color">
|
||||
{{ snackbar.text }}
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
|
||||
export default {
|
||||
name: 'KnowledgeBase',
|
||||
data() {
|
||||
return {
|
||||
installed: true,
|
||||
installing: false,
|
||||
kbCollections: [],
|
||||
showCreateDialog: false,
|
||||
showEmojiPicker: false,
|
||||
newKB: {
|
||||
name: '',
|
||||
emoji: '🙂',
|
||||
description: ''
|
||||
},
|
||||
snackbar: {
|
||||
show: false,
|
||||
text: '',
|
||||
color: 'success'
|
||||
},
|
||||
emojiCategories: [
|
||||
{
|
||||
name: '笑脸和情感',
|
||||
emojis: ['😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂', '🙂', '🙃', '😉', '😊', '😇', '🥰', '😍', '🤩', '😘']
|
||||
},
|
||||
{
|
||||
name: '动物和自然',
|
||||
emojis: ['🐶', '🐱', '🐭', '🐹', '🐰', '🦊', '🐻', '🐼', '🐨', '🐯', '🦁', '🐮', '🐷', '🐸', '🐵']
|
||||
},
|
||||
{
|
||||
name: '食物和饮料',
|
||||
emojis: ['🍏', '🍎', '🍐', '🍊', '🍋', '🍌', '🍉', '🍇', '🍓', '🍈', '🍒', '🍑', '🥭', '🍍', '🥥']
|
||||
},
|
||||
{
|
||||
name: '活动和物品',
|
||||
emojis: ['⚽', '🏀', '🏈', '⚾', '🥎', '🎾', '🏐', '🏉', '🎱', '🏓', '🏸', '🥅', '🏒', '🏑', '🥍']
|
||||
},
|
||||
{
|
||||
name: '旅行和地点',
|
||||
emojis: ['🚗', '🚕', '🚙', '🚌', '🚎', '🏎️', '🚓', '🚑', '🚒', '🚐', '🚚', '🚛', '🚜', '🛴', '🚲']
|
||||
},
|
||||
{
|
||||
name: '符号和旗帜',
|
||||
emojis: ['❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔', '❣️', '💕', '💞', '💓', '💗']
|
||||
}
|
||||
],
|
||||
showContentDialog: false,
|
||||
currentKB: {
|
||||
collection_name: '',
|
||||
emoji: ''
|
||||
},
|
||||
activeTab: 'upload',
|
||||
selectedFile: null,
|
||||
uploading: false,
|
||||
searchQuery: '',
|
||||
searchResults: [],
|
||||
searching: false,
|
||||
searchPerformed: false,
|
||||
topK: 5,
|
||||
showDeleteDialog: false,
|
||||
deleteTarget: {
|
||||
collection_name: ''
|
||||
},
|
||||
deleting: false
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.checkPlugin();
|
||||
},
|
||||
methods: {
|
||||
checkPlugin() {
|
||||
axios.get('/api/plugin/get?name=astrbot_plugin_knowledge_base')
|
||||
.then(response => {
|
||||
if (response.data.status !== 'ok') {
|
||||
this.showSnackbar('插件未安装或不可用', 'error');
|
||||
}
|
||||
if (response.data.data.length > 0) {
|
||||
this.installed = true;
|
||||
this.getKBCollections();
|
||||
} else {
|
||||
this.installed = false;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error checking plugin:', error);
|
||||
this.showSnackbar('检查插件失败', 'error');
|
||||
})
|
||||
},
|
||||
|
||||
installPlugin() {
|
||||
this.installing = true;
|
||||
axios.post('/api/plugin/install', {
|
||||
url: "https://github.com/soulter/astrbot_plugin_knowledge_base",
|
||||
proxy: localStorage.getItem('selectedGitHubProxy') || ""
|
||||
})
|
||||
.then(response => {
|
||||
if (response.data.status === 'ok') {
|
||||
this.checkPlugin();
|
||||
} else {
|
||||
this.showSnackbar(response.data.message || '安装失败', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error installing plugin:', error);
|
||||
this.showSnackbar('安装插件失败', 'error');
|
||||
}).finally(() => {
|
||||
this.installing = false;
|
||||
});
|
||||
},
|
||||
|
||||
getKBCollections() {
|
||||
axios.get('/api/plug/alkaid/kb/collections')
|
||||
.then(response => {
|
||||
this.kbCollections = response.data.data;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching knowledge base collections:', error);
|
||||
this.showSnackbar('获取知识库列表失败', 'error');
|
||||
});
|
||||
},
|
||||
|
||||
createCollection(name, emoji, description) {
|
||||
axios.post('/api/plug/alkaid/kb/create_collection', {
|
||||
collection_name: name,
|
||||
emoji: emoji,
|
||||
description: description
|
||||
})
|
||||
.then(response => {
|
||||
if (response.data.status === 'ok') {
|
||||
this.showSnackbar('知识库创建成功');
|
||||
this.getKBCollections();
|
||||
this.showCreateDialog = false;
|
||||
this.resetNewKB();
|
||||
} else {
|
||||
this.showSnackbar(response.data.message || '创建失败', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error creating knowledge base collection:', error);
|
||||
this.showSnackbar('创建知识库失败', 'error');
|
||||
});
|
||||
},
|
||||
|
||||
submitCreateForm() {
|
||||
if (!this.newKB.name) {
|
||||
this.showSnackbar('请输入知识库名称', 'warning');
|
||||
return;
|
||||
}
|
||||
this.createCollection(
|
||||
this.newKB.name,
|
||||
this.newKB.emoji || '🙂',
|
||||
this.newKB.description
|
||||
);
|
||||
},
|
||||
|
||||
resetNewKB() {
|
||||
this.newKB = {
|
||||
name: '',
|
||||
emoji: '🙂',
|
||||
description: ''
|
||||
};
|
||||
},
|
||||
|
||||
openKnowledgeBase(kb) {
|
||||
// 不再跳转路由,而是打开对话框
|
||||
this.currentKB = kb;
|
||||
this.showContentDialog = true;
|
||||
this.resetContentDialog();
|
||||
},
|
||||
|
||||
resetContentDialog() {
|
||||
this.activeTab = 'upload';
|
||||
this.selectedFile = null;
|
||||
this.searchQuery = '';
|
||||
this.searchResults = [];
|
||||
this.searchPerformed = false;
|
||||
},
|
||||
|
||||
triggerFileInput() {
|
||||
this.$refs.fileInput.click();
|
||||
},
|
||||
|
||||
onFileSelected(event) {
|
||||
const files = event.target.files;
|
||||
if (files.length > 0) {
|
||||
this.selectedFile = files[0];
|
||||
}
|
||||
},
|
||||
|
||||
onFileDrop(event) {
|
||||
const files = event.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
this.selectedFile = files[0];
|
||||
}
|
||||
},
|
||||
|
||||
getFileIcon(filename) {
|
||||
const extension = filename.split('.').pop().toLowerCase();
|
||||
|
||||
switch (extension) {
|
||||
case 'pdf':
|
||||
return 'mdi-file-pdf-box';
|
||||
case 'doc':
|
||||
case 'docx':
|
||||
return 'mdi-file-word-box';
|
||||
case 'xls':
|
||||
case 'xlsx':
|
||||
return 'mdi-file-excel-box';
|
||||
case 'ppt':
|
||||
case 'pptx':
|
||||
return 'mdi-file-powerpoint-box';
|
||||
case 'txt':
|
||||
return 'mdi-file-document-outline';
|
||||
default:
|
||||
return 'mdi-file-outline';
|
||||
}
|
||||
},
|
||||
|
||||
uploadFile() {
|
||||
if (!this.selectedFile) {
|
||||
this.showSnackbar('请先选择文件', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
this.uploading = true;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', this.selectedFile);
|
||||
formData.append('collection_name', this.currentKB.collection_name);
|
||||
|
||||
axios.post('/api/plug/alkaid/kb/collection/add_file', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.data.status === 'ok') {
|
||||
this.showSnackbar('文件上传成功');
|
||||
this.selectedFile = null;
|
||||
|
||||
// 刷新知识库列表,获取更新的数量
|
||||
this.getKBCollections();
|
||||
} else {
|
||||
this.showSnackbar(response.data.message || '上传失败', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error uploading file:', error);
|
||||
this.showSnackbar('文件上传失败', 'error');
|
||||
})
|
||||
.finally(() => {
|
||||
this.uploading = false;
|
||||
});
|
||||
},
|
||||
|
||||
searchKnowledgeBase() {
|
||||
if (!this.searchQuery.trim()) {
|
||||
this.showSnackbar('请输入搜索内容', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
this.searching = true;
|
||||
this.searchPerformed = true;
|
||||
|
||||
axios.get(`/api/plug/alkaid/kb/collection/search`, {
|
||||
params: {
|
||||
collection_name: this.currentKB.collection_name,
|
||||
query: this.searchQuery,
|
||||
top_k: this.topK
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.data.status === 'ok') {
|
||||
this.searchResults = response.data.data || [];
|
||||
|
||||
if (this.searchResults.length === 0) {
|
||||
this.showSnackbar('没有找到匹配的内容', 'info');
|
||||
}
|
||||
} else {
|
||||
this.showSnackbar(response.data.message || '搜索失败', 'error');
|
||||
this.searchResults = [];
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error searching knowledge base:', error);
|
||||
this.showSnackbar('搜索知识库失败', 'error');
|
||||
this.searchResults = [];
|
||||
})
|
||||
.finally(() => {
|
||||
this.searching = false;
|
||||
});
|
||||
},
|
||||
|
||||
showSnackbar(text, color = 'success') {
|
||||
this.snackbar.text = text;
|
||||
this.snackbar.color = color;
|
||||
this.snackbar.show = true;
|
||||
},
|
||||
|
||||
selectEmoji(emoji) {
|
||||
this.newKB.emoji = emoji;
|
||||
this.showEmojiPicker = false;
|
||||
},
|
||||
|
||||
confirmDelete(kb) {
|
||||
this.deleteTarget = kb;
|
||||
this.showDeleteDialog = true;
|
||||
},
|
||||
|
||||
deleteKnowledgeBase() {
|
||||
if (!this.deleteTarget.collection_name) {
|
||||
this.showSnackbar('删除目标不存在', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.deleting = true;
|
||||
|
||||
axios.get('/api/plug/alkaid/kb/collection/delete', {
|
||||
params: {
|
||||
collection_name: this.deleteTarget.collection_name
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.data.status === 'ok') {
|
||||
this.showSnackbar('知识库删除成功');
|
||||
this.getKBCollections(); // 刷新列表
|
||||
this.showDeleteDialog = false;
|
||||
} else {
|
||||
this.showSnackbar(response.data.message || '删除失败', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error deleting knowledge base:', error);
|
||||
this.showSnackbar('删除知识库失败', 'error');
|
||||
})
|
||||
.finally(() => {
|
||||
this.deleting = false;
|
||||
});
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.kb-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 24px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.kb-card {
|
||||
height: 280px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.kb-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.book-spine {
|
||||
width: 12px;
|
||||
background-color: #5c6bc0;
|
||||
height: 100%;
|
||||
border-radius: 2px 0 0 2px;
|
||||
}
|
||||
|
||||
.book-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: linear-gradient(145deg, #f5f7fa 0%, #e4e8f0 100%);
|
||||
}
|
||||
|
||||
.emoji-container {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #ffffff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.kb-emoji {
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.kb-name {
|
||||
font-weight: bold;
|
||||
font-size: 18px;
|
||||
margin-bottom: 8px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.kb-count {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.emoji-picker {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.emoji-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.emoji-item {
|
||||
font-size: 24px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.emoji-item:hover {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
#emoji-display {
|
||||
font-size: 64px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
#emoji-display:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.emoji-sm {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.upload-zone {
|
||||
border: 2px dashed #ccc;
|
||||
border-radius: 8px;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.upload-zone:hover {
|
||||
border-color: #5c6bc0;
|
||||
background-color: rgba(92, 107, 192, 0.05);
|
||||
}
|
||||
|
||||
.search-container {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.search-result-card {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.search-result-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.search-content {
|
||||
white-space: pre-line;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
padding: 8px;
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.kb-actions {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.kb-card {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.kb-card:hover .kb-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
610
dashboard/src/views/alkaid/LongTermMemory.vue
Normal file
610
dashboard/src/views/alkaid/LongTermMemory.vue
Normal file
@@ -0,0 +1,610 @@
|
||||
<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>
|
||||
<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>
|
||||
<!-- <span style="color: #333333;">可视化</span> -->
|
||||
<h3>筛选</h3>
|
||||
<div style="margin-top: 8px;">
|
||||
<v-autocomplete v-model="searchUserId" density="compact" :items="userIdList" variant="outlined"
|
||||
label="筛选用户 ID"></v-autocomplete>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<v-btn color="primary" @click="onNodeSelect" variant="tonal">
|
||||
<v-icon start>mdi-magnify</v-icon>
|
||||
筛选
|
||||
</v-btn>
|
||||
<v-btn color="secondary" @click="resetFilter" variant="tonal">
|
||||
<v-icon start>mdi-filter-remove</v-icon>
|
||||
重置筛选
|
||||
</v-btn>
|
||||
<v-btn color="primary" @click="refreshGraph" variant="tonal">
|
||||
<v-icon start>mdi-refresh</v-icon>
|
||||
刷新图形
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增搜索记忆功能 -->
|
||||
<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>
|
||||
<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-title>
|
||||
<div>
|
||||
<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>
|
||||
<v-expansion-panel-text>
|
||||
<div>
|
||||
<div class="mb-2 text-body-1">{{ result.text }}</div>
|
||||
<div class="d-flex">
|
||||
<span class="text-caption text-grey">文档ID: {{ result.doc_id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</div>
|
||||
<div v-else-if="hasSearched" class="mt-3 text-center text-body-1 text-grey">
|
||||
未找到相关记忆内容
|
||||
</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-icon start>mdi-plus</v-icon>
|
||||
添加数据
|
||||
</v-btn>
|
||||
</v-form>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedNode" class="mt-4">
|
||||
<h3>节点详情</h3>
|
||||
<v-card variant="outlined" class="mt-2 pa-3">
|
||||
<div v-if="selectedNode.id">
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-subtitle-2">ID:</span>
|
||||
<span>{{ selectedNode.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedNode._label">
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-subtitle-2">类型:</span>
|
||||
<span>{{ selectedNode._label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedNode.name">
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-subtitle-2">名称:</span>
|
||||
<span>{{ selectedNode.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedNode.user_id">
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-subtitle-2">用户ID:</span>
|
||||
<span>{{ selectedNode.user_id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedNode.ts">
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-subtitle-2">时间戳:</span>
|
||||
<span>{{ selectedNode.ts }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedNode.type">
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-subtitle-2">类型:</span>
|
||||
<span>{{ selectedNode.type }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<div v-if="graphStats" class="mt-4">
|
||||
<h3>图形统计</h3>
|
||||
<v-card variant="outlined" class="mt-2 pa-3">
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-subtitle-2">节点数:</span>
|
||||
<span>{{ graphStats.nodeCount }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-subtitle-2">边数:</span>
|
||||
<span>{{ graphStats.edgeCount }}</span>
|
||||
</div>
|
||||
</v-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
import * as d3 from "d3"; // npm install d3
|
||||
|
||||
export default {
|
||||
name: 'LongTermMemory',
|
||||
data() {
|
||||
return {
|
||||
simulation: null,
|
||||
svg: null,
|
||||
zoom: null,
|
||||
node_data: [],
|
||||
edge_data: [],
|
||||
nodes: [],
|
||||
links: [],
|
||||
searchUserId: null,
|
||||
userIdList: [],
|
||||
selectedNode: null,
|
||||
graphStats: null,
|
||||
nodeColors: {
|
||||
'PhaseNode': '#4CAF50', // 绿色
|
||||
'PassageNode': '#2196F3', // 蓝色
|
||||
'FactNode': '#FF9800', // 橙色
|
||||
'default': '#9C27B0' // 紫色作为默认
|
||||
},
|
||||
edgeColors: {
|
||||
'_include_': '#607D8B',
|
||||
'_related_': '#9E9E9E',
|
||||
'default': '#BDBDBD'
|
||||
},
|
||||
isLoading: false,
|
||||
// 添加新的数据属性
|
||||
newMemoryText: '',
|
||||
newMemoryUserId: null,
|
||||
needSummarize: false,
|
||||
isSubmitting: false,
|
||||
// 搜索记忆相关属性
|
||||
searchMemoryUserId: null,
|
||||
searchQuery: '',
|
||||
isSearching: false,
|
||||
searchResults: [],
|
||||
hasSearched: false,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.initD3Graph();
|
||||
this.ltmGetGraph();
|
||||
this.ltmGetUserIds();
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.simulation) {
|
||||
this.simulation.stop();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 添加搜索记忆方法
|
||||
searchMemory() {
|
||||
if (!this.searchQuery.trim()) {
|
||||
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 {
|
||||
doc_id: doc_id,
|
||||
text: data[doc_id].text || '无文本内容',
|
||||
score: data[doc_id].score || 0
|
||||
};
|
||||
});
|
||||
|
||||
if (this.searchResults.length === 0) {
|
||||
this.$toast.info('未找到相关记忆内容');
|
||||
} else {
|
||||
this.$toast.success(`找到 ${this.searchResults.length} 条相关记忆`);
|
||||
}
|
||||
} else {
|
||||
this.$toast.error('搜索失败: ' + response.data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('搜索记忆数据失败:', error);
|
||||
this.$toast.error('搜索失败: ' + (error.response?.data?.message || error.message));
|
||||
})
|
||||
.finally(() => {
|
||||
this.isSearching = false;
|
||||
});
|
||||
},
|
||||
|
||||
// 添加新方法,用于提交记忆数据
|
||||
addMemoryData() {
|
||||
if (!this.newMemoryText || !this.newMemoryUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
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('记忆数据添加成功!');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('添加记忆数据失败:', error);
|
||||
this.$toast.error('添加记忆数据失败: ' + (error.response?.data?.message || error.message));
|
||||
})
|
||||
.finally(() => {
|
||||
this.isSubmitting = false;
|
||||
});
|
||||
},
|
||||
|
||||
ltmGetGraph(userId = null) {
|
||||
this.isLoading = true;
|
||||
const params = userId ? { user_id: userId } : {};
|
||||
|
||||
axios.get('/api/plug/alkaid/ltm/graph', { params })
|
||||
.then(response => {
|
||||
let nodesRaw = response.data.data.nodes;
|
||||
let edgesRaw = response.data.data.edges;
|
||||
|
||||
this.node_data = nodesRaw;
|
||||
this.edge_data = edgesRaw;
|
||||
|
||||
// 转换为D3所需的数据格式
|
||||
this.nodes = nodesRaw.map(node => {
|
||||
const nodeId = node[0];
|
||||
const nodeData = node[1];
|
||||
const nodeType = nodeData._label || 'default';
|
||||
const color = this.nodeColors[nodeType] || this.nodeColors['default'];
|
||||
|
||||
return {
|
||||
id: nodeId,
|
||||
label: nodeData.name || nodeId.split('_')[0],
|
||||
color: color,
|
||||
originalData: nodeData
|
||||
};
|
||||
});
|
||||
|
||||
this.links = edgesRaw.map(edge => {
|
||||
const sourceId = edge[0];
|
||||
const targetId = edge[1];
|
||||
const edgeData = edge[2];
|
||||
const relationType = edgeData.relation_type || 'default';
|
||||
const color = this.edgeColors[relationType] || this.edgeColors['default'];
|
||||
|
||||
return {
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
color: color,
|
||||
originalData: edgeData,
|
||||
label: relationType
|
||||
};
|
||||
});
|
||||
|
||||
this.updateD3Graph();
|
||||
this.updateGraphStats();
|
||||
console.log('Graph initialized with', this.nodes.length, 'nodes and', this.links.length, 'links');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching graph data:', error);
|
||||
})
|
||||
.finally(() => {
|
||||
this.isLoading = false;
|
||||
});
|
||||
},
|
||||
|
||||
ltmGetUserIds() {
|
||||
axios.get('/api/plug/alkaid/ltm/user_ids')
|
||||
.then(response => {
|
||||
this.userIdList = response.data.data;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching user IDs:', error);
|
||||
});
|
||||
},
|
||||
|
||||
updateGraphStats() {
|
||||
this.graphStats = {
|
||||
nodeCount: this.nodes.length,
|
||||
edgeCount: this.links.length
|
||||
};
|
||||
},
|
||||
|
||||
refreshGraph() {
|
||||
this.ltmGetGraph(this.searchUserId);
|
||||
},
|
||||
|
||||
onNodeSelect() {
|
||||
console.log('Selected user ID:', this.searchUserId);
|
||||
if (!this.searchUserId) return;
|
||||
|
||||
// 使用API的user_id参数筛选数据
|
||||
this.ltmGetGraph(this.searchUserId);
|
||||
},
|
||||
|
||||
resetFilter() {
|
||||
this.searchUserId = null;
|
||||
this.searchQuery = ''; // 重置搜索关键词
|
||||
this.searchResults = []; // 清空搜索结果
|
||||
this.hasSearched = false; // 重置搜索状态
|
||||
this.ltmGetGraph();
|
||||
},
|
||||
|
||||
initD3Graph() {
|
||||
const container = document.getElementById("graph-container");
|
||||
if (!container) return;
|
||||
d3.select("#graph-container svg").remove();
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
const svg = d3.select("#graph-container")
|
||||
.append("svg")
|
||||
.attr("width", "100%")
|
||||
.attr("height", "100%")
|
||||
.attr("viewBox", [0, 0, width, height])
|
||||
.classed("d3-graph", true);
|
||||
const g = svg.append("g");
|
||||
const zoom = d3.zoom()
|
||||
.scaleExtent([0.1, 10])
|
||||
.on("zoom", (event) => {
|
||||
g.attr("transform", event.transform);
|
||||
});
|
||||
|
||||
svg.call(zoom);
|
||||
const simulation = d3.forceSimulation()
|
||||
.force("link", d3.forceLink().id(d => d.id).distance(100))
|
||||
.force("charge", d3.forceManyBody().strength(-300))
|
||||
.force("center", d3.forceCenter(width / 2, height / 2))
|
||||
.force("collision", d3.forceCollide().radius(30));
|
||||
|
||||
this.svg = svg;
|
||||
this.g = g;
|
||||
this.zoom = zoom;
|
||||
this.simulation = simulation;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
},
|
||||
|
||||
updateD3Graph() {
|
||||
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")
|
||||
.attr("refX", 20)
|
||||
.attr("refY", 0)
|
||||
.attr("orient", "auto")
|
||||
.attr("markerWidth", 6)
|
||||
.attr("markerHeight", 6)
|
||||
.append("path")
|
||||
.attr("d", "M0,-5L10,0L0,5")
|
||||
.attr("fill", "#999");
|
||||
const link = g.append("g")
|
||||
.selectAll("line")
|
||||
.data(this.links)
|
||||
.join("line")
|
||||
.attr("stroke", d => d.color)
|
||||
.attr("stroke-width", 1.5)
|
||||
.attr("marker-end", "url(#arrowhead)");
|
||||
const edgeLabels = g.append("g")
|
||||
.selectAll("text")
|
||||
.data(this.links)
|
||||
.join("text")
|
||||
.text(d => d.label)
|
||||
.attr("font-size", "8px")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#666")
|
||||
.attr("dy", -5);
|
||||
const node = g.append("g")
|
||||
.selectAll("circle")
|
||||
.data(this.nodes)
|
||||
.join("circle")
|
||||
.attr("r", 8)
|
||||
.attr("fill", d => d.color)
|
||||
.style("cursor", "pointer")
|
||||
.call(this.dragBehavior());
|
||||
const nodeLabels = g.append("g")
|
||||
.selectAll("text")
|
||||
.data(this.nodes)
|
||||
.join("text")
|
||||
.text(d => d.label)
|
||||
.attr("font-size", "10px")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#333")
|
||||
.attr("dy", -12);
|
||||
node.on("click", (event, d) => {
|
||||
event.stopPropagation();
|
||||
this.selectedNode = d.originalData;
|
||||
});
|
||||
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);
|
||||
edgeLabels
|
||||
.attr("x", d => (d.source.x + d.target.x) / 2)
|
||||
.attr("y", d => (d.source.y + d.target.y) / 2);
|
||||
node
|
||||
.attr("cx", d => d.x)
|
||||
.attr("cy", d => d.y);
|
||||
nodeLabels
|
||||
.attr("x", d => d.x)
|
||||
.attr("y", d => d.y);
|
||||
});
|
||||
|
||||
this.simulation.force("link")
|
||||
.links(this.links);
|
||||
|
||||
this.simulation.alpha(1).restart();
|
||||
},
|
||||
|
||||
dragBehavior() {
|
||||
return d3.drag()
|
||||
.on("start", (event, d) => {
|
||||
if (!event.active) this.simulation.alphaTarget(0.3).restart();
|
||||
d.fx = d.x;
|
||||
d.fy = d.y;
|
||||
})
|
||||
.on("drag", (event, d) => {
|
||||
d.fx = event.x;
|
||||
d.fy = event.y;
|
||||
})
|
||||
.on("end", (event, d) => {
|
||||
if (!event.active) this.simulation.alphaTarget(0);
|
||||
d.fx = null;
|
||||
d.fy = null;
|
||||
});
|
||||
},
|
||||
|
||||
getRandomColor() {
|
||||
const letters = '0123456789ABCDEF';
|
||||
let color = '#';
|
||||
for (let i = 0; i < 6; i++) {
|
||||
color += letters[Math.floor(Math.random() * 16)];
|
||||
}
|
||||
return color;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#long-term-memory {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
#graph-container {
|
||||
position: relative;
|
||||
background-color: #f2f6f9;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
#graph-control-panel {
|
||||
height: 100%;
|
||||
overflow-y: auto; /* 让控制面板可滚动而不是整个页面滚动 */
|
||||
min-width: 450px;
|
||||
max-width: 450px;
|
||||
}
|
||||
|
||||
#graph-container:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.memory-header {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
#graph-container svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.d3-graph {
|
||||
background-color: #f2f6f9;
|
||||
}
|
||||
</style>
|
||||
15
dashboard/src/views/alkaid/Other.vue
Normal file
15
dashboard/src/views/alkaid/Other.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div class="flex-grow-1" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div class="d-flex align-center justify-center"
|
||||
style="flex-grow: 1; width: 100%; border: 1px solid #eee; border-radius: 8px;">
|
||||
<span size="64">🌍</span>
|
||||
<p class="text-h6 text-grey ml-4">前面的世界,以后再来探索吧!</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'OtherFeatures'
|
||||
}
|
||||
</script>
|
||||
@@ -1,23 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
import AuthLogin from '../authForms/AuthLogin.vue';
|
||||
import Logo from '@/components/shared/Logo.vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const cardVisible = ref(false);
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
onMounted(() => {
|
||||
// 检查用户是否已登录,如果已登录则重定向
|
||||
if (authStore.has_token()) {
|
||||
router.push(authStore.returnUrl || '/');
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加一个小延迟以获得更好的动画效果
|
||||
setTimeout(() => {
|
||||
cardVisible.value = true;
|
||||
}, 100);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="display: flex; justify-content: center; flex-direction: column; align-items: center; height: 100vh; background-color: aliceblue;">
|
||||
<v-card variant="outlined" style="max-width: 500px; box-shadow: 0 0 3px rgba(0, 0, 0, 0.1);">
|
||||
<v-card-text class="pa-9">
|
||||
<div class="text-center">
|
||||
<div class="login-page-container">
|
||||
<div class="login-background"></div>
|
||||
<v-card
|
||||
variant="outlined"
|
||||
class="login-card"
|
||||
:class="{ 'card-visible': cardVisible }"
|
||||
>
|
||||
<v-card-text class="pa-10">
|
||||
<div class="logo-wrapper">
|
||||
<Logo />
|
||||
<h2 class="text-secondary text-h2 mt-4">AstrBot 仪表盘</h2>
|
||||
<h4 class="text-disabled text-h4 mt-3">登录以继续</h4>
|
||||
</div>
|
||||
<div class="divider-container">
|
||||
<v-divider class="custom-divider"></v-divider>
|
||||
</div>
|
||||
<AuthLogin />
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.login-page-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
background: linear-gradient(135deg, #ebf5fd 0%, #e0e9f8 100%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-background {
|
||||
position: absolute;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
background: radial-gradient(circle, rgba(94, 53, 177, 0.03) 0%, rgba(30, 136, 229, 0.06) 70%);
|
||||
z-index: 0;
|
||||
animation: rotate 60s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.login-card {
|
||||
max-width: 520px;
|
||||
width: 90%;
|
||||
border-radius: 12px !important;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.07) !important;
|
||||
background-color: rgba(255, 255, 255, 0.98) !important;
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
transition: all 0.5s ease;
|
||||
z-index: 1;
|
||||
|
||||
&.card-visible {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.logo-wrapper {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.divider-container {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.custom-divider {
|
||||
border-color: rgba(0, 0, 0, 0.05) !important;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.loginBox {
|
||||
max-width: 475px;
|
||||
margin: 0 auto;
|
||||
|
||||
@@ -8,9 +8,12 @@ const valid = ref(false);
|
||||
const show1 = ref(false);
|
||||
const password = ref('');
|
||||
const username = ref('');
|
||||
const loading = ref(false);
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
async function validate(values: any, { setErrors }: any) {
|
||||
loading.value = true;
|
||||
|
||||
// md5加密
|
||||
let password_ = password.value;
|
||||
if (password.value != '') {
|
||||
@@ -21,67 +24,154 @@ async function validate(values: any, { setErrors }: any) {
|
||||
const authStore = useAuthStore();
|
||||
return authStore.login(username.value, password_).then((res) => {
|
||||
console.log(res);
|
||||
loading.value = false;
|
||||
}).catch((err) => {
|
||||
setErrors({ apiError: err });
|
||||
loading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Form @submit="validate" class="mt-7 loginForm" v-slot="{ errors, isSubmitting }">
|
||||
<v-text-field v-model="username" label="用户名" class="mt-4 mb-8" required density="comfortable"
|
||||
hide-details="auto" variant="outlined" color="primary"></v-text-field>
|
||||
<v-text-field v-model="password" label="密码" required density="comfortable" variant="outlined"
|
||||
color="primary" hide-details="auto" :append-icon="show1 ? 'mdi-eye' : 'mdi-eye-off'"
|
||||
:type="show1 ? 'text' : 'password'" @click:append="show1 = !show1" class="pwdInput"></v-text-field>
|
||||
<Form @submit="validate" class="mt-4 login-form" v-slot="{ errors, isSubmitting }">
|
||||
<v-text-field
|
||||
v-model="username"
|
||||
label="用户名"
|
||||
class="mb-6 input-field"
|
||||
required
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
prepend-inner-icon="mdi-account"
|
||||
:disabled="loading"
|
||||
></v-text-field>
|
||||
|
||||
<v-text-field
|
||||
v-model="password"
|
||||
label="密码"
|
||||
required
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
hide-details="auto"
|
||||
:append-icon="show1 ? 'mdi-eye' : 'mdi-eye-off'"
|
||||
:type="show1 ? 'text' : 'password'"
|
||||
@click:append="show1 = !show1"
|
||||
class="pwd-input"
|
||||
prepend-inner-icon="mdi-lock"
|
||||
:disabled="loading"
|
||||
></v-text-field>
|
||||
|
||||
<small>默认用户名和密码为 astrbot。</small>
|
||||
<v-btn color="secondary" :loading="isSubmitting" block class="mt-8" variant="flat" size="large" :disabled="valid"
|
||||
type="submit">
|
||||
登录</v-btn>
|
||||
<div v-if="errors.apiError" class="mt-2">
|
||||
<v-alert color="error">{{ errors.apiError }}</v-alert>
|
||||
<div class="mt-1 mb-5 hint-text">
|
||||
<small>默认用户名和密码为 astrbot</small>
|
||||
</div>
|
||||
|
||||
<v-btn
|
||||
color="secondary"
|
||||
:loading="isSubmitting || loading"
|
||||
block
|
||||
class="login-btn"
|
||||
variant="flat"
|
||||
size="large"
|
||||
:disabled="valid"
|
||||
type="submit"
|
||||
elevation="2"
|
||||
>
|
||||
<span class="login-btn-text">登录</span>
|
||||
</v-btn>
|
||||
|
||||
<div v-if="errors.apiError" class="mt-4 error-container">
|
||||
<v-alert
|
||||
color="error"
|
||||
variant="tonal"
|
||||
density="comfortable"
|
||||
icon="mdi-alert-circle"
|
||||
border="start"
|
||||
>
|
||||
{{ errors.apiError }}
|
||||
</v-alert>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.custom-devider {
|
||||
border-color: rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
|
||||
.googleBtn {
|
||||
border-color: rgba(0, 0, 0, 0.08);
|
||||
margin: 30px 0 20px 0;
|
||||
}
|
||||
|
||||
.outlinedInput .v-field {
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.orbtn {
|
||||
padding: 2px 40px;
|
||||
border-color: rgba(0, 0, 0, 0.08);
|
||||
margin: 20px 15px;
|
||||
}
|
||||
|
||||
.pwdInput {
|
||||
position: relative;
|
||||
|
||||
.v-input__append {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.loginForm {
|
||||
.login-form {
|
||||
.v-text-field .v-field--active input {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.input-field, .pwd-input {
|
||||
.v-field__field {
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.v-field__outline {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&:hover .v-field__outline {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.v-field--focused .v-field__outline {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.v-field__prepend-inner {
|
||||
padding-right: 8px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.pwd-input {
|
||||
position: relative;
|
||||
|
||||
.v-input__append {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
margin-top: 12px;
|
||||
height: 48px;
|
||||
transition: all 0.3s ease;
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(94, 53, 177, 0.2) !important;
|
||||
}
|
||||
|
||||
.login-btn-text {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
.v-alert {
|
||||
border-left-width: 4px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-devider {
|
||||
border-color: rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -61,7 +61,6 @@ class RstScene(Enum):
|
||||
version="4.0.0",
|
||||
)
|
||||
class Main(star.Star):
|
||||
|
||||
def __init__(self, context: star.Context) -> None:
|
||||
self.context = context
|
||||
cfg = context.get_config()
|
||||
@@ -140,7 +139,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):
|
||||
"""开启/关闭 LLM"""
|
||||
@@ -216,9 +215,7 @@ class Main(star.Star):
|
||||
"""获取已经安装的插件列表。"""
|
||||
plugin_list_info = "已加载的插件:\n"
|
||||
for plugin in self.context.get_all_stars():
|
||||
plugin_list_info += (
|
||||
f"- `{plugin.name}` By {plugin.author}: {plugin.desc}"
|
||||
)
|
||||
plugin_list_info += f"- `{plugin.name}` By {plugin.author}: {plugin.desc}"
|
||||
if not plugin.activated:
|
||||
plugin_list_info += " (未启用)"
|
||||
plugin_list_info += "\n"
|
||||
@@ -271,9 +268,7 @@ class Main(star.Star):
|
||||
event.set_result(MessageEventResult().message("安装插件成功。"))
|
||||
except Exception as e:
|
||||
logger.error(f"安装插件失败: {e}")
|
||||
event.set_result(
|
||||
MessageEventResult().message(f"安装插件失败: {e}")
|
||||
)
|
||||
event.set_result(MessageEventResult().message(f"安装插件失败: {e}"))
|
||||
return
|
||||
|
||||
@plugin.command("help")
|
||||
@@ -319,7 +314,6 @@ class Main(star.Star):
|
||||
ret += "更多帮助信息请查看插件仓库 README。"
|
||||
event.set_result(MessageEventResult().message(ret).use_t2i(False))
|
||||
|
||||
|
||||
@filter.command("t2i")
|
||||
async def t2i(self, event: AstrMessageEvent):
|
||||
"""开关文本转图片"""
|
||||
@@ -419,25 +413,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"""
|
||||
|
||||
if not self.context.get_using_provider():
|
||||
event.set_result(
|
||||
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。")
|
||||
)
|
||||
return
|
||||
|
||||
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})"
|
||||
if self.context.get_using_provider().meta().id == id_:
|
||||
provider_using = self.context.get_using_provider()
|
||||
if provider_using and provider_using.meta().id == id_:
|
||||
ret += " (当前使用)"
|
||||
ret += "\n"
|
||||
|
||||
@@ -483,8 +473,6 @@ UID: {user_id} 此 ID 可用于设置管理员。
|
||||
id_ = provider.meta().id
|
||||
self.context.provider_manager.curr_tts_provider_inst = provider
|
||||
sp.put("curr_provider_tts", id_)
|
||||
if not self.context.provider_manager.tts_enabled:
|
||||
self.context.provider_manager.tts_enabled = True
|
||||
event.set_result(
|
||||
MessageEventResult().message(f"成功切换到 {id_}。")
|
||||
)
|
||||
@@ -499,8 +487,6 @@ UID: {user_id} 此 ID 可用于设置管理员。
|
||||
id_ = provider.meta().id
|
||||
self.context.provider_manager.curr_stt_provider_inst = provider
|
||||
sp.put("curr_provider_stt", id_)
|
||||
if not self.context.provider_manager.stt_enabled:
|
||||
self.context.provider_manager.stt_enabled = True
|
||||
event.set_result(
|
||||
MessageEventResult().message(f"成功切换到 {id_}。")
|
||||
)
|
||||
@@ -512,8 +498,6 @@ UID: {user_id} 此 ID 可用于设置管理员。
|
||||
id_ = provider.meta().id
|
||||
self.context.provider_manager.curr_provider_inst = provider
|
||||
sp.put("curr_provider", id_)
|
||||
if not self.context.provider_manager.provider_enabled:
|
||||
self.context.provider_manager.provider_enabled = True
|
||||
event.set_result(MessageEventResult().message(f"成功切换到 {id_}。"))
|
||||
else:
|
||||
event.set_result(MessageEventResult().message("无效的参数。"))
|
||||
@@ -588,7 +572,8 @@ 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(
|
||||
self, message: AstrMessageEvent, idx_or_name: Union[int, str] = None
|
||||
@@ -1033,7 +1018,11 @@ UID: {user_id} 此 ID 可用于设置管理员。
|
||||
message.unified_msg_origin, cid
|
||||
)
|
||||
if not conversation:
|
||||
message.set_result(MessageEventResult().message("请先进入一个对话。可以使用 /new 创建。"))
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
"请先进入一个对话。可以使用 /new 创建。"
|
||||
)
|
||||
)
|
||||
if not conversation.persona_id and not conversation.persona_id == "[%None]":
|
||||
curr_persona_name = (
|
||||
self.context.provider_manager.selected_default_persona["name"]
|
||||
@@ -1176,7 +1165,7 @@ UID: {user_id} 此 ID 可用于设置管理员。
|
||||
@filter.command("gewe_code")
|
||||
async def gewe_code(self, event: AstrMessageEvent, code: str):
|
||||
"""保存 gewechat 验证码"""
|
||||
code_path = os.path.join(get_astrbot_data_path(), "temp","gewe_code")
|
||||
code_path = os.path.join(get_astrbot_data_path(), "temp", "gewe_code")
|
||||
with open(code_path, "w", encoding="utf-8") as f:
|
||||
f.write(code)
|
||||
yield event.plain_result("验证码已保存。")
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
from astrbot.api.event import filter, AstrMessageEvent
|
||||
from astrbot.api.star import Context, Star, register
|
||||
from astrbot.api import logger
|
||||
|
||||
@register("vpet", "AstrBot Team", "虚拟桌宠", "0.0.1")
|
||||
class VPet(Star):
|
||||
def __init__(self, context: Context):
|
||||
super().__init__(context)
|
||||
|
||||
async def initialize(self):
|
||||
"""可选择实现异步的插件初始化方法,当实例化该插件类之后会自动调用该方法。"""
|
||||
|
||||
@filter.llm_tool("screenshot")
|
||||
async def screenshot(self, event: AstrMessageEvent):
|
||||
"""Capture the screen and return the image."""
|
||||
|
||||
|
||||
async def terminate(self):
|
||||
"""可选择实现异步的插件销毁方法,当插件被卸载/停用时会调用。"""
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "AstrBot"
|
||||
version = "3.5.10"
|
||||
version = "3.5.12"
|
||||
description = "易上手的多平台 LLM 聊天机器人及开发框架"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -8,6 +8,7 @@ dependencies = [
|
||||
"aiocqhttp>=1.4.4",
|
||||
"aiodocker>=0.24.0",
|
||||
"aiohttp>=3.11.18",
|
||||
"aiosqlite>=0.21.0",
|
||||
"anthropic>=0.51.0",
|
||||
"apscheduler>=3.11.0",
|
||||
"beautifulsoup4>=4.13.4",
|
||||
@@ -19,6 +20,7 @@ dependencies = [
|
||||
"defusedxml>=0.7.1",
|
||||
"dingtalk-stream>=0.22.1",
|
||||
"docstring-parser>=0.16",
|
||||
"faiss-cpu>=1.11.0",
|
||||
"filelock>=3.18.0",
|
||||
"google-genai>=1.14.0",
|
||||
"googlesearch-python>=1.3.0",
|
||||
|
||||
@@ -34,4 +34,6 @@ google-genai
|
||||
click
|
||||
filelock
|
||||
watchfiles
|
||||
websockets
|
||||
websockets
|
||||
faiss-cpu
|
||||
aiosqlite
|
||||
Reference in New Issue
Block a user