Compare commits

...

129 Commits

Author SHA1 Message Date
Soulter
9564166297 perf: knowledge base displays console when installing 2025-05-31 11:52:24 +08:00
Soulter
f5cf3c3c8e Merge pull request #1691 from AstrBotDevs/perf-pip-async
Feature: 将插件依赖检查和 pip 安装方法改为异步,以提高性能和响应速度
2025-05-31 11:51:39 +08:00
Soulter
18f919fb6b perf: pip_main wrapped in asyncio.to_thread
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-31 11:47:29 +08:00
Soulter
0924835253 feat: 将插件依赖检查和 pip 安装方法改为异步,以提高性能和响应速度 2025-05-31 11:44:58 +08:00
Soulter
20d2e5c578 perf: 优化日志流发送频率,防止积压超过 buffer size 导致前端显示异常 2025-05-31 11:25:51 +08:00
Soulter
907801605c 📦 release: v3.5.13 2025-05-31 11:02:56 +08:00
Soulter
93bc684e8c feat: 添加旧版本提供商类型映射以兼容性支持 2025-05-31 11:00:59 +08:00
Soulter
a76c98d57e Merge pull request #1685 from RC-CHN/master
Feature: 添加测试文本生成供应商可用功能
2025-05-31 10:59:46 +08:00
Soulter
d937a800d0 fix: provider name 2025-05-31 10:46:35 +08:00
Soulter
d16f3a227f Merge branch 'master' into master 2025-05-31 10:46:15 +08:00
Soulter
80c9a3eeda style: code style 2025-05-31 09:25:18 +08:00
Soulter
e68173b451 feat: knowledge-base 2025-05-30 23:18:48 +08:00
Soulter
40c27d87f5 feat: knowledge-base 2025-05-30 23:18:19 +08:00
Soulter
3c13b5049d feat: 支持知识库的分片、重叠设置等 2025-05-30 23:00:37 +08:00
Soulter
8288d5e51f feat: embedding provider 2025-05-30 18:07:52 +08:00
RC-CHN
4ffbb18ab4 Merge branch 'AstrBotDevs:master' into master 2025-05-30 15:12:33 +08:00
Ruochen
b27271b7a3 feat:添加测试文本生成供应商可用功能 2025-05-30 15:10:15 +08:00
Soulter
ebb6665f64 feat: add open_config parameter handling and configuration button in KnowledgeBase 2025-05-30 14:30:04 +08:00
Soulter
e4e5731ffd 📦 release: v3.5.13 2025-05-30 13:30:23 +08:00
Soulter
2ab5810f13 perf: improve transaction performance in vector db 2025-05-30 12:59:26 +08:00
Soulter
af934c5d09 fix: correct dimension typo and enhance API registration logic 2025-05-30 11:42:39 +08:00
Soulter
1e0cf7c112 fix: update ExtensionCard actions and add readme link functionality 2025-05-30 10:50:54 +08:00
Soulter
46859c93c9 perf: improve WebUI 2025-05-30 10:45:05 +08:00
Soulter
1641549016 perf: improve WebUI 2025-05-30 10:36:48 +08:00
鸦羽
716a5dbb8a chore: add nh3 to requirements.txt 2025-05-30 10:35:48 +08:00
鸦羽
af98cb11c5 fix: handle missing nh3 library in plugin.py 2025-05-30 10:35:48 +08:00
Soulter
9a4c2cf341 fix: downgrade faiss-cpu dependency to version 1.10.0 2025-05-30 10:21:31 +08:00
Soulter
2bc3bcd102 fix: handle missing nh3 library gracefully for README cleaning 2025-05-30 10:17:33 +08:00
Soulter
d6c663f79d fix: do not display change password dialog in demo mode 2025-05-30 10:09:09 +08:00
Soulter
2b4ee13b5e Merge pull request #1672 from Kwicxy/master
Feat: 暗黑主题功能初步实现
2025-05-29 23:41:10 +08:00
kwicxy
6959f86632 feat: Using localStorage to remember user's theme setting. 2025-05-29 22:46:02 +08:00
Raven95676
537d373e10 fix: Fix potential XSS risk in plugin README content 2025-05-29 22:35:24 +08:00
Soulter
cceadf222c Merge pull request #1676 from AstrBotDevs/fix-chat-get-file-bug
Fix: fixed a potential vulnerability in `/api/chat/get_file` endpoint.
2025-05-29 21:41:55 +08:00
Soulter
cf5a4af623 chore: remove duplicated auth header 2025-05-29 21:19:39 +08:00
Raven95676
39aea11c22 perf: enhance file access security in get_file method
Co-authored-by: anka-afk <1350989414@qq.com>
2025-05-29 21:03:51 +08:00
Raven95676
c2f1227700 fix: add authorization header to file download request in ChatPage.vue 2025-05-29 19:57:11 +08:00
Soulter
900f14d37c 🐛 fix: fixed a potential vulnerability in /api/chat/get_file endpoint.
I have fixed a potential vulnerability in the `/api/chat/get_file` endpoint that could allow unauthorized access to files by ensuring the request has a jwt token.
2025-05-29 19:17:31 +08:00
kwicxy
598249b1d6 Merge remote-tracking branch 'origin/master' 2025-05-29 18:26:53 +08:00
Richard X.
7ed15bdf04 Merge branch 'AstrBotDevs:master' into master 2025-05-29 18:17:39 +08:00
Raven95676
2fc0ec0f72 fix: update route 2025-05-29 17:28:33 +08:00
kwicxy
5e9c2a669b fix: Various bug fixes and improvements 2025-05-29 16:41:03 +08:00
Soulter
b310521884 📦 release: v3.5.12 2025-05-29 15:55:25 +08:00
Soulter
288945bf7e chore: aiosqlite to requirements.txt 2025-05-29 15:48:21 +08:00
Soulter
4fc07cff36 📦 release: v3.5.12 2025-05-29 15:46:40 +08:00
kwicxy
b884fe0e86 fix: Various bug fixes 2025-05-29 09:31:29 +08:00
kwicxy
855858c236 fix: Changed default theme to PurpleTheme 2025-05-29 09:31:15 +08:00
kwicxy
c11a2a5419 feat: Login page darkened 2025-05-29 09:00:27 +08:00
kwicxy
773a6572af feat: WebUI Dark Appearance 2025-05-29 01:43:21 +08:00
kwicxy
88ad373c9b 深色主题切换功能初步实现 2025-05-29 01:28:45 +08:00
Soulter
51666464b9 Merge pull request #1667 from AstrBotDevs/fix-priority
Fix: plugin priority was not properly applied
2025-05-28 15:34:50 +08:00
Soulter
5af9cf2f52 Merge pull request #1668 from AstrBotDevs/refactor-segment
Refactor: 重构转发节点等消息段的 toDict 相关逻辑
2025-05-28 15:33:32 +08:00
Soulter
12c4ae4b10 perf: to_dict in the base class 2025-05-28 03:26:42 -04:00
Soulter
4e1bef414a perf: empty array 2025-05-28 03:25:19 -04:00
Soulter
e896c18644 perf: video 2025-05-28 15:12:21 +08:00
Soulter
c852685e74 fix: typeerror 2025-05-28 01:18:45 -04:00
Soulter
1e99797df8 refactor: improve message segment handle 2025-05-28 12:53:00 +08:00
Soulter
52a4c986a8 fix: update star_handlers_registry iteration in TelegramPlatformAdapter 2025-05-28 00:31:04 +08:00
Soulter
c501728204 fix: plugin priority
fixes: #1662
2025-05-28 00:23:02 +08:00
Soulter
6b067fa6a7 Merge pull request #1665 from Raven95676/master
fix(telegram): 支持长消息分段发送并优化消息编辑逻辑
2025-05-27 23:39:14 +08:00
Soulter
a1cd5c53a9 chore: add comments 2025-05-27 23:38:35 +08:00
Soulter
a46d487e03 Merge pull request #1644 from RC-CHN/master
fix:为llm和model和provider指令添加了管理员权限检查
2025-05-27 23:25:40 +08:00
Raven95676
3deb6d3ab3 fix: clean code 2025-05-27 20:52:40 +08:00
Raven95676
af34cdd5d2 fix(telegram): 支持长消息分段发送并优化消息编辑逻辑 2025-05-27 20:15:16 +08:00
Soulter
6e1393235a 🐛 fix: provider command error 2025-05-27 17:20:57 +08:00
Soulter
343e0b54b9 feat: MCP supports Streamable HTTP transport method
fixes: #1637 #1342
2025-05-27 15:39:02 +08:00
Soulter
ecb70cb6f7 feat: add support for custom headers in SSE client configuration
fixes: #1659
2025-05-27 15:05:42 +08:00
Soulter
ca50618af6 perf: load providers when llm config is off and rebooting astrbot
fixes: #1466
2025-05-27 15:01:58 +08:00
Soulter
29c07ba83e 🐛 fix: function tools argument type issue
fixes: #1454
2025-05-27 13:54:16 +08:00
Ruochen
45fbb83a9f fix:为llm和model和provider指令添加了管理员权限检查 2025-05-25 00:24:20 +08:00
Soulter
ae7ba2df25 Merge pull request #1553 from Raven95676/Feature/use-file-service
Feature: T2I、TTS使用文件服务
2025-05-23 17:10:38 +08:00
Soulter
c3ef57cc32 Merge pull request #1588 from Zhenyi-Wang/feat/extend-wechatpadpro-for-timetask
feat: wechatpadpro对接获取联系人信息的2个接口
2025-05-23 17:02:54 +08:00
Soulter
7bb4ca5a14 perf: code quality 2025-05-23 17:01:57 +08:00
Soulter
063783d81d Merge pull request #1599 from HendricksJudy/master
Fix initialization bug and improve plugin utility
2025-05-23 16:58:25 +08:00
Soulter
42116c9b65 Merge pull request #1631 from AstrBotDevs/feat/alkaid
[WIP] Feature: 提供 AstrBot 后端服务插件接口、试验性嵌入式知识库(Alkaid)、移除不必要的包
2025-05-23 16:57:04 +08:00
Soulter
a36e11973d perf: code quality
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2025-05-23 16:56:09 +08:00
Soulter
5125568ea2 perf: 交换 if/else 表达式的分支以删除否定
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2025-05-23 16:49:08 +08:00
Soulter
0fa164e50d perf: 使用 HTML autocomplete 属性禁用浏览器自动填充
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2025-05-23 16:48:29 +08:00
Soulter
cf814e81ee chore: delete alkaid route 2025-05-23 16:41:33 +08:00
Soulter
43a45f18ce perf: knowledgebase delete 2025-05-23 15:50:10 +08:00
Soulter
ad51381063 perf: 动态路由注册 2025-05-23 15:18:16 +08:00
Soulter
0b0e4ce904 remove: vpet 2025-05-23 14:22:34 +08:00
Soulter
6a3e04d688 Merge remote-tracking branch 'origin/master' into feat/alkaid 2025-05-23 14:22:06 +08:00
Soulter
4107a17370 chore: add faiss and aiosqlite deps 2025-05-23 14:04:13 +08:00
Soulter
06b4d8f169 perf: vecdb similarity type 2025-05-23 13:45:00 +08:00
Soulter
1c0c820746 remove: loguru 2025-05-23 13:42:17 +08:00
Soulter
d061403a28 remove: loguru 2025-05-23 13:39:20 +08:00
Soulter
5c092321a6 feat: faiss vecdb implementation
remove: old knowledgedb deps
2025-05-23 13:16:24 +08:00
Soulter
bdd3f61c1f remove: old knowledge db impl and useless impls 2025-05-23 11:43:26 +08:00
Raven95676
8023557d6e feat: 强制修改默认密码 2025-05-22 18:30:29 +08:00
Raven95676
074b0ced7a perf: 移除冗余逻辑
经与@Soulter确认,metadata.yaml是必须有的文件,故在建议下删除
2025-05-22 18:21:41 +08:00
Soulter
3864b1ac9b Merge pull request #1620 from YOOkoishi/feat-add-volcengine-support
🐛 fix : 修改description,适配火山引擎基础的语音合成
2025-05-22 17:52:39 +08:00
YOO_koishi
6e9b43457d Merge branch 'master' of https://github.com/AstrBotDevs/AstrBot into feat-add-volcengine-support 2025-05-22 08:09:59 +08:00
YOO_koishi
ca1aec8920 🐛 fix : 修改description,适配火山引擎基础的语音合成 2025-05-22 08:09:36 +08:00
Soulter
acac580862 feat: ltm and kb 2025-05-20 20:50:22 +08:00
Soulter
673e1b2980 remove: vpet 2025-05-20 15:03:40 +08:00
Soulter
f62157be72 📦 release: v3.5.11 2025-05-20 02:00:54 -04:00
Soulter
f894ecf3b6 Merge pull request #1592 from YOOkoishi/feat-add-volcengine-support
 feat: add volcengine support
2025-05-20 13:58:44 +08:00
Soulter
66dd4e28ad Merge pull request #1604 from Siztas/fix-refresh-device-when-login-WeChatPadPro
fix:修复了WeChatPadPro在重新登录时为新设备的问题,延长初始化Auth_Key有效期至365天
2025-05-20 13:57:40 +08:00
YOO_koishi
939dc1b0fb Merge branch 'master' of https://github.com/AstrBotDevs/AstrBot into feat-add-volcengine-support 2025-05-20 13:52:03 +08:00
YOO_koishi
56bf5d38a1 🔧fix: 修改logger输出等级为debug级别 2025-05-20 13:51:11 +08:00
Soulter
d09b70b295 fix: 修复微信公众号(个人认证)下无法回复消息的问题 2025-05-20 01:38:13 -04:00
MiSeya
205180387a Fix:修复了WeChatPadPro在重新登录时为新设备的问题,延长初始化Auth_Key有效期至365天 2025-05-19 21:12:09 +08:00
HendricksJudy
39c8cfeda5 Merge pull request #2 from HendricksJudy/codex/fix-core-initialization-failure-handling-in-initialloader
Fix initialization bug and improve plugin utility
2025-05-19 01:43:22 -07:00
HendricksJudy
f38a329be5 Fix initialization and plugin download 2025-05-19 01:43:07 -07:00
YOO_koishi
a0cd069539 Merge branch 'master' of https://github.com/AstrBotDevs/AstrBot into feat-add-volcengine-support 2025-05-19 16:17:43 +08:00
YOO_koishi
bf306a2f01 🩹fix: 修改添加logger函数,添加speed_ratio选项,为一些选项添加description 2025-05-19 16:16:25 +08:00
Soulter
c31f93a8d1 Merge pull request #1595 from HendricksJudy/master
Fix lint issues and highlight typos
2025-05-19 09:29:02 +08:00
HendricksJudy
4730ab6309 Merge pull request #1 from HendricksJudy/codex/find-bugs-or-typos
Fix lint issues and highlight typos
2025-05-18 02:31:17 -07:00
HendricksJudy
1ae78ca98c chore: fix lint issues 2025-05-18 02:30:31 -07:00
Soulter
d2379da478 chore: use d3 2025-05-18 16:43:47 +08:00
Soulter
0f64981b20 feat: alkaid long term memory graph visualize 2025-05-18 13:26:44 +08:00
YOO_koishi
0002e49bb5 Merge branch 'master' of https://github.com/AstrBotDevs/AstrBot into feat-add-volcengine-support 2025-05-18 03:20:05 +08:00
YOO_koishi
db13a60274 feat: add-volcengine-tts-support 2025-05-18 03:18:36 +08:00
Soulter
db0f11a359 Merge pull request #1589 from Larch-C/master
🎈 perf: 优化了登录界面,解决了登录未自行跳转的问题
2025-05-17 21:40:14 +08:00
Soulter
ac7f43520b 🎈 perf: adjust login input padding style 2025-05-17 21:30:05 +08:00
Larch-C
f67b9f5f6e 🐞 fix: 解决了如果此前已经登录但未自行跳转的问题 2025-05-17 18:09:49 +08:00
Larch-C
c75156c4ce 🎈 perf: 优化了登录界面样式 2025-05-17 18:08:55 +08:00
Soulter
10270b5595 feat: alkaid framework and supports to customize webapi endpoint 2025-05-17 15:38:51 +08:00
Zhenyi Wang
f7458572ed feat: wechatpadpro对接获取联系人信息接口 2025-05-17 15:31:12 +08:00
Soulter
d57b7222b2 perf: 优化 WebUI About 页面、侧边栏和顶栏 2025-05-17 13:30:33 +08:00
Soulter
62e70a673a perf: 优化 Gemini 报错提示 2025-05-17 12:04:36 +08:00
Soulter
5e9eba6478 fix: extension market plugin card cannot apply installation 2025-05-16 22:43:38 -04:00
Raven95676
c5ccc1a084 feat(Video): 增加视频消息组件的文件转换和注册功能 2025-05-15 09:50:27 +08:00
YOO_koishi
6439917cbe Merge branch 'master' of https://github.com/AstrBotDevs/AstrBot into feat-add-volcengine-support 2025-05-14 22:45:02 +08:00
YOO_koishi
d21c18f657 change defualt.py 2025-05-14 22:43:40 +08:00
Raven95676
e6981290bc perf: 优化 Record 对象的文件和 URL 字段赋值逻辑 2025-05-14 20:05:38 +08:00
Raven95676
75c3d8abbd feat(t2i): 为本地文本转图像功能添加文件服务支持 2025-05-14 19:28:23 +08:00
Raven95676
d88683f498 feat(tts): 增加使用文件服务提供 TTS 语音文件的功能 2025-05-14 19:28:23 +08:00
Raven95676
40b9aa3a4c style: format code 2025-05-14 19:15:13 +08:00
96 changed files with 5047 additions and 1323 deletions

View File

@@ -3,7 +3,6 @@ import tempfile
import httpx import httpx
import yaml import yaml
import re
from enum import Enum from enum import Enum
from io import BytesIO from io import BytesIO
from pathlib import Path 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 proxy=proxy if proxy else None, follow_redirects=True
) as client: ) as client:
resp = client.get(download_url) 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) zip_content = BytesIO(resp.content)
with ZipFile(zip_content) as z: with ZipFile(zip_content) as z:
z.extractall(temp_dir) z.extractall(temp_dir)
@@ -91,39 +99,6 @@ def load_yaml_metadata(plugin_dir: Path) -> dict:
return {} 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: 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()]: for plugin_name in [d.name for d in plugins_dir.glob("*") if d.is_dir()]:
plugin_dir = plugins_dir / plugin_name plugin_dir = plugins_dir / plugin_name
# 从不同来源加载元数据 # 从 metadata.yaml 加载元数据
metadata = load_yaml_metadata(plugin_dir) 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"] k in metadata for k in ["name", "desc", "version", "author", "repo"]
): ):
py_metadata = extract_py_metadata(plugin_dir) result.append({
# 合并元数据,保留已有的值 "name": str(metadata.get("name", "")),
for key, value in py_metadata.items(): "desc": str(metadata.get("desc", "")),
if key not in metadata or not metadata[key]: "version": str(metadata.get("version", "")),
metadata[key] = value "author": str(metadata.get("author", "")),
# 如果成功提取元数据,添加到结果列表 "repo": str(metadata.get("repo", "")),
if metadata: "status": PluginStatus.INSTALLED,
result.append( "local_path": str(plugin_dir),
{ })
"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 = [] online_plugins = []
@@ -173,17 +139,15 @@ def build_plug_list(plugins_dir: Path) -> list:
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
for plugin_id, plugin_info in data.items(): for plugin_id, plugin_info in data.items():
online_plugins.append( online_plugins.append({
{ "name": str(plugin_id),
"name": str(plugin_id), "desc": str(plugin_info.get("desc", "")),
"desc": str(plugin_info.get("desc", "")), "version": str(plugin_info.get("version", "")),
"version": str(plugin_info.get("version", "")), "author": str(plugin_info.get("author", "")),
"author": str(plugin_info.get("author", "")), "repo": str(plugin_info.get("repo", "")),
"repo": str(plugin_info.get("repo", "")), "status": PluginStatus.NOT_INSTALLED,
"status": PluginStatus.NOT_INSTALLED, "local_path": None,
"local_path": None, })
}
)
except Exception as e: except Exception as e:
click.echo(f"获取在线插件列表失败: {e}", err=True) click.echo(f"获取在线插件列表失败: {e}", err=True)

View File

@@ -5,7 +5,7 @@
import os import os
from astrbot.core.utils.astrbot_path import get_astrbot_data_path from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "3.5.10" VERSION = "3.5.13"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v3.db") DB_PATH = os.path.join(get_astrbot_data_path(), "data_v3.db")
# 默认配置 # 默认配置
@@ -66,6 +66,7 @@ DEFAULT_CONFIG = {
"enable": False, "enable": False,
"provider_id": "", "provider_id": "",
"dual_output": False, "dual_output": False,
"use_file_service": False,
}, },
"provider_ltm_settings": { "provider_ltm_settings": {
"group_icl_enable": False, "group_icl_enable": False,
@@ -91,6 +92,7 @@ DEFAULT_CONFIG = {
"t2i_word_threshold": 150, "t2i_word_threshold": 150,
"t2i_strategy": "remote", "t2i_strategy": "remote",
"t2i_endpoint": "", "t2i_endpoint": "",
"t2i_use_file_service": False,
"http_proxy": "", "http_proxy": "",
"dashboard": { "dashboard": {
"enable": True, "enable": True,
@@ -176,6 +178,7 @@ CONFIG_METADATA_2 = {
"api_base_url": "https://api.weixin.qq.com/cgi-bin/", "api_base_url": "https://api.weixin.qq.com/cgi-bin/",
"callback_server_host": "0.0.0.0", "callback_server_host": "0.0.0.0",
"port": 6194, "port": 6194,
"active_send_mode": False,
}, },
"wecom(企业微信)": { "wecom(企业微信)": {
"id": "wecom", "id": "wecom",
@@ -220,20 +223,25 @@ CONFIG_METADATA_2 = {
}, },
}, },
"items": { "items": {
"active_send_mode": {
"description": "是否换用主动发送接口",
"type": "bool",
"desc": "只有企业认证的公众号才能主动发送。主动发送接口的限制会少一些。",
},
"wpp_active_message_poll": { "wpp_active_message_poll": {
"description": "是否启用主动消息轮询", "description": "是否启用主动消息轮询",
"type": "bool", "type": "bool",
"hint": "只有当你发现微信消息没有按时同步到 AstrBot 时,才需要启用这个功能,默认不启用。" "hint": "只有当你发现微信消息没有按时同步到 AstrBot 时,才需要启用这个功能,默认不启用。",
}, },
"wpp_active_message_poll_interval": { "wpp_active_message_poll_interval": {
"description": "主动消息轮询间隔", "description": "主动消息轮询间隔",
"type": "int", "type": "int",
"hint": "主动消息轮询间隔,单位为秒,默认 3 秒,最大不要超过 60 秒,否则可能被认为是旧消息。" "hint": "主动消息轮询间隔,单位为秒,默认 3 秒,最大不要超过 60 秒,否则可能被认为是旧消息。",
}, },
"kf_name": { "kf_name": {
"description": "微信客服账号名", "description": "微信客服账号名",
"type": "string", "type": "string",
"hint": "可选。微信客服账号名(不是 ID)。可在 https://kf.weixin.qq.com/kf/frame#/accounts 获取" "hint": "可选。微信客服账号名(不是 ID)。可在 https://kf.weixin.qq.com/kf/frame#/accounts 获取",
}, },
"telegram_token": { "telegram_token": {
"description": "Bot Token", "description": "Bot Token",
@@ -256,10 +264,10 @@ CONFIG_METADATA_2 = {
"hint": "Telegram 命令自动刷新间隔,单位为秒。", "hint": "Telegram 命令自动刷新间隔,单位为秒。",
}, },
"id": { "id": {
"description": "ID", "description": "机器人名称",
"type": "string", "type": "string",
"obvious_hint": True, "obvious_hint": True,
"hint": "ID 不能和其它的平台适配器重复,否则将发生严重冲突", "hint": "机器人名称(ID)不能和其它的平台适配器重复。",
}, },
"type": { "type": {
"description": "适配器类型", "description": "适配器类型",
@@ -818,7 +826,7 @@ CONFIG_METADATA_2 = {
"azure_tts_rate": "1", "azure_tts_rate": "1",
"azure_tts_volume": "100", "azure_tts_volume": "100",
"azure_tts_subscription_key": "", "azure_tts_subscription_key": "",
"azure_tts_region": "eastus" "azure_tts_region": "eastus",
}, },
"MiniMax TTS(API)": { "MiniMax TTS(API)": {
"id": "minimax_tts", "id": "minimax_tts",
@@ -841,44 +849,144 @@ CONFIG_METADATA_2 = {
"minimax-voice-english-normalization": False, "minimax-voice-english-normalization": False,
"timeout": 20, "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,
},
"OpenAI Embedding": {
"id": "openai_embedding",
"type": "openai_embedding",
"provider_type": "embedding",
"enable": True,
"embedding_api_key": "",
"embedding_api_base": "",
"embedding_model": "",
"embedding_dimensions": 1536,
"timeout": 20,
},
}, },
"items": { "items": {
"embedding_dimensions": {
"description": "嵌入维度",
"type": "int",
"hint": "嵌入向量的维度。根据模型不同,可能需要调整,请参考具体模型的文档。此配置项请务必填写正确,否则将导致向量数据库无法正常工作。",
},
"embedding_model": {
"description": "嵌入模型",
"type": "string",
"hint": "嵌入模型名称。",
},
"embedding_api_key": {
"description": "API Key",
"type": "string",
"hint": "API Key",
},
"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": { "azure_tts_voice": {
"type": "string", "type": "string",
"description": "音色设置", "description": "音色设置",
"hint": "API 音色" "hint": "API 音色",
}, },
"azure_tts_style": { "azure_tts_style": {
"type": "string", "type": "string",
"description": "风格设置", "description": "风格设置",
"hint": "声音特定的讲话风格。 可以表达快乐、同情和平静等情绪。" "hint": "声音特定的讲话风格。 可以表达快乐、同情和平静等情绪。",
}, },
"azure_tts_role": { "azure_tts_role": {
"type": "string", "type": "string",
"description": "模仿设置(可选)", "description": "模仿设置(可选)",
"hint": "讲话角色扮演。 声音可以模仿不同的年龄和性别,但声音名称不会更改。 例如,男性语音可以提高音调和改变语调来模拟女性语音,但语音名称不会更改。 如果角色缺失或不受声音的支持,则会忽略此属性。", "hint": "讲话角色扮演。 声音可以模仿不同的年龄和性别,但声音名称不会更改。 例如,男性语音可以提高音调和改变语调来模拟女性语音,但语音名称不会更改。 如果角色缺失或不受声音的支持,则会忽略此属性。",
"options": ["Boy","Girl","YoungAdultFemale","YoungAdultMale","OlderAdultFemale","OlderAdultMale","SeniorFemale","SeniorMale","禁用"] "options": [
"Boy",
"Girl",
"YoungAdultFemale",
"YoungAdultMale",
"OlderAdultFemale",
"OlderAdultMale",
"SeniorFemale",
"SeniorMale",
"禁用",
],
}, },
"azure_tts_rate": { "azure_tts_rate": {
"type": "string", "type": "string",
"description": "语速设置", "description": "语速设置",
"hint": "指示文本的讲出速率。可在字词或句子层面应用语速。 速率变化应为原始音频的 0.5 到 2 倍。" "hint": "指示文本的讲出速率。可在字词或句子层面应用语速。 速率变化应为原始音频的 0.5 到 2 倍。",
}, },
"azure_tts_volume": { "azure_tts_volume": {
"type": "string", "type": "string",
"description": "语音音量设置", "description": "语音音量设置",
"hint": "指示语音的音量级别。 可在句子层面应用音量的变化。以从 0.0 到 100.0(从最安静到最大声,例如 75的数字表示。 默认值为 100.0。" "hint": "指示语音的音量级别。 可在句子层面应用音量的变化。以从 0.0 到 100.0(从最安静到最大声,例如 75的数字表示。 默认值为 100.0。",
}, },
"azure_tts_region": { "azure_tts_region": {
"type": "string", "type": "string",
"description": "API 地区", "description": "API 地区",
"hint": "Azure_TTS 处理数据所在区域,具体参考 https://learn.microsoft.com/zh-cn/azure/ai-services/speech-service/regions", "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": { "azure_tts_subscription_key": {
"type": "string", "type": "string",
"description": "服务订阅密钥", "description": "服务订阅密钥",
"hint": "Azure_TTS 服务的订阅密钥(注意不是令牌)" "hint": "Azure_TTS 服务的订阅密钥(注意不是令牌)",
}, },
"dashscope_tts_voice": { "dashscope_tts_voice": {
"description": "语音合成模型", "description": "语音合成模型",
@@ -973,7 +1081,33 @@ CONFIG_METADATA_2 = {
"type": "string", "type": "string",
"description": "指定语言/方言", "description": "指定语言/方言",
"hint": "增强对指定的小语种和方言的识别能力,设置后可以提升在指定小语种/方言场景下的语音表现", "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": { "minimax-voice-speed": {
"type": "float", "type": "float",
@@ -1010,7 +1144,15 @@ CONFIG_METADATA_2 = {
"type": "string", "type": "string",
"description": "情绪", "description": "情绪",
"hint": "控制合成语音的情绪", "hint": "控制合成语音的情绪",
"options": ["happy","sad","angry","fearful","disgusted","surprised","neutral",], "options": [
"happy",
"sad",
"angry",
"fearful",
"disgusted",
"surprised",
"neutral",
],
}, },
"minimax-voice-latex": { "minimax-voice-latex": {
"type": "bool", "type": "bool",
@@ -1365,6 +1507,11 @@ CONFIG_METADATA_2 = {
"hint": "启用后Bot 将同时输出语音和文字消息。", "hint": "启用后Bot 将同时输出语音和文字消息。",
"obvious_hint": True, "obvious_hint": True,
}, },
"use_file_service": {
"description": "使用文件服务提供 TTS 语音文件",
"type": "bool",
"hint": "启用后,如已配置 callback_api_base 将会使用文件服务提供TTS语音文件",
},
}, },
}, },
"provider_ltm_settings": { "provider_ltm_settings": {
@@ -1481,7 +1628,7 @@ CONFIG_METADATA_2 = {
"description": "对外可达的回调接口地址", "description": "对外可达的回调接口地址",
"type": "string", "type": "string",
"obvious_hint": True, "obvious_hint": True,
"hint": "外部服务可能会通过 AstrBot 生成的回调链接(如文件下载链接)访问 AstrBot 后端。由于 AstrBot 无法自动判断部署环境中对外可达的主机地址host因此需要通过此配置项显式指定 “外部服务如何访问 AstrBot” 的地址。如 http://localhost:6185https://example.com 等。" "hint": "外部服务可能会通过 AstrBot 生成的回调链接(如文件下载链接)访问 AstrBot 后端。由于 AstrBot 无法自动判断部署环境中对外可达的主机地址host因此需要通过此配置项显式指定 “外部服务如何访问 AstrBot” 的地址。如 http://localhost:6185https://example.com 等。",
}, },
"log_level": { "log_level": {
"description": "控制台日志级别", "description": "控制台日志级别",
@@ -1500,6 +1647,11 @@ CONFIG_METADATA_2 = {
"type": "string", "type": "string",
"hint": "当 t2i_strategy 为 remote 时生效。为空时使用 AstrBot API 服务", "hint": "当 t2i_strategy 为 remote 时生效。为空时使用 AstrBot API 服务",
}, },
"t2i_use_file_service": {
"description": "本地文本转图像使用文件服务提供文件",
"type": "bool",
"hint": "当 t2i_strategy 为 local 并且配置 callback_api_base 时生效。是否使用文件服务提供文件。",
},
"pip_install_arg": { "pip_install_arg": {
"description": "pip 安装参数", "description": "pip 安装参数",
"type": "string", "type": "string",

View File

@@ -1,6 +1,6 @@
""" """
Astrbot 核心生命周期管理类, 负责管理 AstrBot 的启动、停止、重启等操作。 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.updator import AstrBotUpdator
from astrbot.core import logger from astrbot.core import logger
from astrbot.core.config.default import VERSION 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.conversation_mgr import ConversationManager
from astrbot.core.star.star_handler import star_handlers_registry, EventType from astrbot.core.star.star_handler import star_handlers_registry, EventType
from astrbot.core.star.star_handler import star_map from astrbot.core.star.star_handler import star_map
@@ -37,7 +36,7 @@ from astrbot.core.star.star_handler import star_map
class AstrBotCoreLifecycle: class AstrBotCoreLifecycle:
""" """
AstrBot 核心生命周期管理类, 负责管理 AstrBot 的启动、停止、重启等操作。 AstrBot 核心生命周期管理类, 负责管理 AstrBot 的启动、停止、重启等操作。
该类负责初始化各个组件, 包括 ProviderManager、PlatformManager、KnowledgeDBManager、ConversationManager、PluginManager、PipelineScheduler、 该类负责初始化各个组件, 包括 ProviderManager、PlatformManager、ConversationManager、PluginManager、PipelineScheduler、
EventBus 等。 EventBus 等。
该类还负责加载和执行插件, 以及处理事件总线的分发。 该类还负责加载和执行插件, 以及处理事件总线的分发。
""" """
@@ -54,7 +53,7 @@ class AstrBotCoreLifecycle:
async def initialize(self): 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.platform_manager = PlatformManager(self.astrbot_config, self.event_queue)
# 初始化知识库管理器
self.knowledge_db_manager = KnowledgeDBManager(self.astrbot_config)
# 初始化对话管理器 # 初始化对话管理器
self.conversation_manager = ConversationManager(self.db) self.conversation_manager = ConversationManager(self.db)
@@ -87,7 +83,6 @@ class AstrBotCoreLifecycle:
self.provider_manager, self.provider_manager,
self.platform_manager, self.platform_manager,
self.conversation_manager, self.conversation_manager,
self.knowledge_db_manager,
) )
# 初始化插件管理器 # 初始化插件管理器

View File

@@ -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()

View 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: 删除是否成功
"""
...

View File

@@ -0,0 +1,3 @@
from .vec_db import FaissVecDB
__all__ = ["FaissVecDB"]

View 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

View 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.dimension:
raise ValueError(
f"向量维度不匹配, 期望: {self.dimension}, 实际: {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)

View File

@@ -0,0 +1,17 @@
-- 创建文档存储表,包含 faiss 中文档的 id文档文本create_atupdated_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);

View File

@@ -0,0 +1,117 @@
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

View File

@@ -26,13 +26,14 @@ class InitialLoader:
async def start(self): async def start(self):
core_lifecycle = AstrBotCoreLifecycle(self.log_broker, self.db) core_lifecycle = AstrBotCoreLifecycle(self.log_broker, self.db)
core_task = []
try: try:
await core_lifecycle.initialize() await core_lifecycle.initialize()
core_task = core_lifecycle.start()
except Exception as e: except Exception as e:
logger.critical(traceback.format_exc()) logger.critical(traceback.format_exc())
logger.critical(f"😭 初始化 AstrBot 失败:{e} !!!") logger.critical(f"😭 初始化 AstrBot 失败:{e} !!!")
return
core_task = core_lifecycle.start()
self.dashboard_server = AstrBotDashboard( self.dashboard_server = AstrBotDashboard(
core_lifecycle, self.db, core_lifecycle.dashboard_shutdown_event core_lifecycle, self.db, core_lifecycle.dashboard_shutdown_event

View File

@@ -102,6 +102,10 @@ class BaseMessageComponent(BaseModel):
data[k] = v data[k] = v
return {"type": self.type.lower(), "data": data} return {"type": self.type.lower(), "data": data}
async def to_dict(self) -> dict:
# 默认情况下,回退到旧的同步 toDict()
return self.toDict()
class Plain(BaseMessageComponent): class Plain(BaseMessageComponent):
type: ComponentType = "Plain" type: ComponentType = "Plain"
@@ -118,6 +122,9 @@ class Plain(BaseMessageComponent):
self.text.replace("&", "&amp;").replace("[", "&#91;").replace("]", "&#93;") self.text.replace("&", "&amp;").replace("[", "&#91;").replace("]", "&#93;")
) )
def toDict(self):
return {"type": "text", "data": {"text": self.text.strip()}}
class Face(BaseMessageComponent): class Face(BaseMessageComponent):
type: ComponentType = "Face" type: ComponentType = "Face"
@@ -235,9 +242,6 @@ class Video(BaseMessageComponent):
path: T.Optional[str] = "" path: T.Optional[str] = ""
def __init__(self, file: 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, **_) super().__init__(file=file, **_)
@staticmethod @staticmethod
@@ -250,6 +254,70 @@ class Video(BaseMessageComponent):
return Video(file=url, **_) return Video(file=url, **_)
raise Exception("not a valid 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): class At(BaseMessageComponent):
type: ComponentType = "At" type: ComponentType = "At"
@@ -259,6 +327,12 @@ class At(BaseMessageComponent):
def __init__(self, **_): def __init__(self, **_):
super().__init__(**_) super().__init__(**_)
def toDict(self):
return {
"type": "at",
"data": {"qq": str(self.qq)},
}
class AtAll(At): class AtAll(At):
qq: str = "all" qq: str = "all"
@@ -514,27 +588,47 @@ class Node(BaseMessageComponent):
id: T.Optional[int] = 0 # 忽略 id: T.Optional[int] = 0 # 忽略
name: T.Optional[str] = "" # qq昵称 name: T.Optional[str] = "" # qq昵称
uin: T.Optional[str] = "0" # 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]] = "" # 忽略 seq: T.Optional[T.Union[str, list]] = "" # 忽略
time: T.Optional[int] = 0 # 忽略 time: T.Optional[int] = 0 # 忽略
def __init__(self, content: T.Union[str, list, dict, "Node", T.List["Node"]], **_): def __init__(self, content: list[BaseMessageComponent], **_):
if isinstance(content, list): if isinstance(content, Node):
_content = None # back
if all(isinstance(item, Node) for item in content): content = [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()
super().__init__(content=content, **_) super().__init__(content=content, **_)
def toString(self): async def to_dict(self):
# logger.warn("Protocol: node doesn't support stringify") data_content = []
return "" 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): class Nodes(BaseMessageComponent):
@@ -545,12 +639,20 @@ class Nodes(BaseMessageComponent):
super().__init__(nodes=nodes, **_) super().__init__(nodes=nodes, **_)
def toDict(self): def toDict(self):
"""Deprecated. Use to_dict instead"""
ret = { ret = {
"messages": [], "messages": [],
} }
for node in self.nodes: for node in self.nodes:
d = node.toDict() 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) ret["messages"].append(d)
return ret return ret
@@ -723,6 +825,26 @@ class File(BaseMessageComponent):
return f"{callback_host}/api/file/{token}" 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): class WechatEmoji(BaseMessageComponent):
type: ComponentType = "WechatEmoji" type: ComponentType = "WechatEmoji"

View File

@@ -46,28 +46,29 @@ class PreProcessStage(Stage):
stt_provider = ( stt_provider = (
self.plugin_manager.context.provider_manager.curr_stt_provider_inst self.plugin_manager.context.provider_manager.curr_stt_provider_inst
) )
if stt_provider: if not stt_provider:
message_chain = event.get_messages() return
for idx, component in enumerate(message_chain): message_chain = event.get_messages()
if isinstance(component, Record) and component.url: for idx, component in enumerate(message_chain):
path = component.url.removeprefix("file://") if isinstance(component, Record) and component.url:
retry = 5 path = component.url.removeprefix("file://")
for i in range(retry): retry = 5
try: for i in range(retry):
result = await stt_provider.get_text(audio_url=path) try:
if result: result = await stt_provider.get_text(audio_url=path)
logger.info("语音转文本结果: " + result) if result:
message_chain[idx] = Plain(result) logger.info("语音转文本结果: " + result)
event.message_str += result message_chain[idx] = Plain(result)
event.message_obj.message_str += result event.message_str += result
break event.message_obj.message_str += result
except FileNotFoundError as e: break
# napcat workaround except FileNotFoundError as e:
logger.warning(e) # napcat workaround
logger.warning(f"重试中: {i + 1}/{retry}") logger.warning(e)
await asyncio.sleep(0.5) logger.warning(f"重试中: {i + 1}/{retry}")
continue await asyncio.sleep(0.5)
except BaseException as e: continue
logger.error(traceback.format_exc()) except BaseException as e:
logger.error(f"语音转文本失败: {e}") logger.error(traceback.format_exc())
break logger.error(f"语音转文本失败: {e}")
break

View File

@@ -67,6 +67,10 @@ class LLMRequestSubStage(Stage):
) -> Union[None, AsyncGenerator[None, None]]: ) -> Union[None, AsyncGenerator[None, None]]:
req: ProviderRequest = 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() provider = self.ctx.plugin_manager.context.get_using_provider()
if provider is None: if provider is None:
return return

View File

@@ -29,9 +29,7 @@ class RespondStage(Stage):
Comp.Image: lambda comp: bool(comp.file), # 图片 Comp.Image: lambda comp: bool(comp.file), # 图片
Comp.Reply: lambda comp: bool(comp.id) and comp.sender_id is not None, # 回复 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.Poke: lambda comp: comp.id != 0 and comp.qq != 0, # 戳一戳
Comp.Node: lambda comp: bool(comp.name) Comp.Node: lambda comp: bool(comp.content), # 转发节点
and comp.uin != 0
and bool(comp.content), # 一个转发节点
Comp.Nodes: lambda comp: bool(comp.nodes), # 多个转发节点 Comp.Nodes: lambda comp: bool(comp.nodes), # 多个转发节点
Comp.File: lambda comp: bool(comp.file_ or comp.url), Comp.File: lambda comp: bool(comp.file_ or comp.url),
} }

View File

@@ -1,17 +1,18 @@
import time
import re import re
import time
import traceback import traceback
from typing import Union, AsyncGenerator from typing import AsyncGenerator, Union
from ..stage import Stage, register_stage, registered_stages
from ..context import PipelineContext from astrbot.core import html_renderer, logger, file_token_service
from astrbot.core.platform.astr_message_event import AstrMessageEvent 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.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.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 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 @register_stage
@@ -168,30 +169,55 @@ class ResultDecorateStage(Stage):
result.chain = new_chain result.chain = new_chain
# TTS # TTS
tts_provider = (
self.ctx.plugin_manager.context.provider_manager.curr_tts_provider_inst
)
if ( if (
self.ctx.astrbot_config["provider_tts_settings"]["enable"] self.ctx.astrbot_config["provider_tts_settings"]["enable"]
and result.is_llm_result() and result.is_llm_result()
and tts_provider
): ):
tts_provider = self.ctx.plugin_manager.context.provider_manager.curr_tts_provider_inst
new_chain = [] new_chain = []
for comp in result.chain: for comp in result.chain:
if isinstance(comp, Plain) and len(comp.text) > 1: if isinstance(comp, Plain) and len(comp.text) > 1:
try: try:
logger.info("TTS 请求: " + comp.text) logger.info(f"TTS 请求: {comp.text}")
audio_path = await tts_provider.get_audio(comp.text) audio_path = await tts_provider.get_audio(comp.text)
logger.info("TTS 结果: " + audio_path) logger.info(f"TTS 结果: {audio_path}")
if audio_path: if not 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.error( logger.error(
f"由于 TTS 音频文件找到,消息段转语音失败: {comp.text}" f"由于 TTS 音频文件找到,消息段转语音失败: {comp.text}"
) )
new_chain.append(comp) 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(traceback.format_exc())
logger.error("TTS 失败,使用文本发送。") logger.error("TTS 失败,使用文本发送。")
new_chain.append(comp) new_chain.append(comp)
@@ -225,6 +251,14 @@ class ResultDecorateStage(Stage):
if url: if url:
if url.startswith("http"): if url.startswith("http"):
result.chain = [Image.fromURL(url)] 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: else:
result.chain = [Image.fromFileSystem(url)] result.chain = [Image.fromFileSystem(url)]

View File

@@ -3,9 +3,17 @@ import re
from typing import AsyncGenerator, Dict, List from typing import AsyncGenerator, Dict, List
from aiocqhttp import CQHttp from aiocqhttp import CQHttp
from astrbot.api.event import AstrMessageEvent, MessageChain 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.api.platform import Group, MessageMember
from astrbot.core import file_token_service, astrbot_config, logger
class AiocqhttpMessageEvent(AstrMessageEvent): class AiocqhttpMessageEvent(AstrMessageEvent):
@@ -15,28 +23,38 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
super().__init__(message_str, message_obj, platform_meta, session_id) super().__init__(message_str, message_obj, platform_meta, session_id)
self.bot = bot 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 @staticmethod
async def _parse_onebot_json(message_chain: MessageChain): async def _parse_onebot_json(message_chain: MessageChain):
"""解析成 OneBot json 格式""" """解析成 OneBot json 格式"""
ret = [] ret = []
for segment in message_chain.chain: for segment in message_chain.chain:
d = segment.toDict()
if isinstance(segment, Plain): if isinstance(segment, Plain):
d["type"] = "text" if not segment.text.strip():
d["data"]["text"] = segment.text.strip()
# 如果是空文本或者只带换行符的文本,不发送
if not d["data"]["text"]:
continue continue
elif isinstance(segment, (Image, Record)): d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
# 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), # 转换为字符串
}
ret.append(d) ret.append(d)
return ret return ret
@@ -54,7 +72,8 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
nodes = Nodes([seg]) nodes = Nodes([seg])
seg = nodes seg = nodes
payload = seg.toDict() payload = await seg.to_dict()
if self.get_group_id(): if self.get_group_id():
payload["group_id"] = self.get_group_id() payload["group_id"] = self.get_group_id()
await self.bot.call_action("send_group_forward_msg", **payload) await self.bot.call_action("send_group_forward_msg", **payload)
@@ -64,21 +83,7 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
"send_private_forward_msg", **payload "send_private_forward_msg", **payload
) )
elif isinstance(seg, File): elif isinstance(seg, File):
d = seg.toDict() d = await AiocqhttpMessageEvent._from_segment_to_dict(seg)
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,
}
await self.bot.send( await self.bot.send(
self.message_obj.raw_message, self.message_obj.raw_message,
[d], [d],

View File

@@ -144,8 +144,8 @@ class TelegramPlatformAdapter(Platform):
command_dict = {} command_dict = {}
skip_commands = {"start"} skip_commands = {"start"}
for handler_md in star_handlers_registry._handlers: for handler_md in star_handlers_registry:
handler_metadata = handler_md[1] handler_metadata = handler_md
if not star_map[handler_metadata.handler_module_path].activated: if not star_map[handler_metadata.handler_module_path].activated:
continue continue
for event_filter in handler_metadata.event_filters: for event_filter in handler_metadata.event_filters:

View File

@@ -1,4 +1,5 @@
import os import os
import re
import asyncio import asyncio
import telegramify_markdown import telegramify_markdown
from astrbot.api.event import AstrMessageEvent, MessageChain 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): 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__( def __init__(
self, self,
message_str: str, message_str: str,
@@ -29,8 +40,33 @@ class TelegramPlatformEvent(AstrMessageEvent):
super().__init__(message_str, message_obj, platform_meta, session_id) super().__init__(message_str, message_obj, platform_meta, session_id)
self.client = client self.client = client
@staticmethod def _split_message(self, text: str) -> list[str]:
async def send_with_client(client: ExtBot, message: MessageChain, user_name: 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 image_path = None
has_reply = False has_reply = False
@@ -59,19 +95,22 @@ class TelegramPlatformEvent(AstrMessageEvent):
if isinstance(i, Plain): if isinstance(i, Plain):
if at_user_id and not at_flag: 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 at_flag = True
text = i.text chunks = self._split_message(i.text)
try: for chunk in chunks:
text = telegramify_markdown.markdownify( try:
i.text, max_line_length=None, normalize_whitespace=False md_text = telegramify_markdown.markdownify(
) chunk, max_line_length=None, normalize_whitespace=False
except Exception as e: )
logger.warning( await client.send_message(
f"MarkdownV2 conversion failed: {e}. Using plain text instead." text=md_text, parse_mode="MarkdownV2", **payload
) )
return except Exception as e:
await client.send_message(text=text, parse_mode="MarkdownV2", **payload) logger.warning(
f"MarkdownV2 send failed: {e}. Using plain text instead."
)
await client.send_message(text=chunk, **payload)
elif isinstance(i, Image): elif isinstance(i, Image):
image_path = await i.convert_to_file_path() image_path = await i.convert_to_file_path()
await client.send_photo(photo=image_path, **payload) await client.send_photo(photo=image_path, **payload)
@@ -147,17 +186,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
continue continue
# Plain # Plain
if not message_id: if message_id and len(delta) <= self.MAX_MESSAGE_LENGTH:
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:
current_time = asyncio.get_event_loop().time() current_time = asyncio.get_event_loop().time()
time_since_last_edit = current_time - last_edit_time time_since_last_edit = current_time - last_edit_time
@@ -176,6 +205,18 @@ class TelegramPlatformEvent(AstrMessageEvent):
last_edit_time = ( last_edit_time = (
asyncio.get_event_loop().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: try:
if delta and current_content != delta: if delta and current_content != delta:

View File

@@ -69,39 +69,42 @@ class WeChatPadProAdapter(Platform):
self.auth_key = loaded_credentials.get("auth_key") self.auth_key = loaded_credentials.get("auth_key")
self.wxid = loaded_credentials.get("wxid") self.wxid = loaded_credentials.get("wxid")
isLoginIn = await self.check_online_status()
# 检查在线状态 # 检查在线状态
if self.auth_key and await self.check_online_status(): if self.auth_key and isLoginIn:
logger.info("WeChatPadPro 设备已在线,跳过扫码登录。") logger.info("WeChatPadPro 设备已在线,凭据存在,跳过扫码登录。")
# 如果在线,连接 WebSocket 接收消息 # 如果在线,连接 WebSocket 接收消息
self.ws_handle_task = asyncio.create_task(self.connect_websocket()) self.ws_handle_task = asyncio.create_task(self.connect_websocket())
else: else:
logger.info("WeChatPadPro 设备不在线或无可用凭据,开始扫码登录流程。")
# 1. 生成授权码 # 1. 生成授权码
await self.generate_auth_key()
if not self.auth_key: if not self.auth_key:
logger.error("无法获取授权码,WeChatPadPro 适配器启动失败") logger.info("WeChatPadPro 无可用凭据,将生成新的授权码")
return await self.generate_auth_key()
# 2. 获取登录二维码 # 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: if qr_code_url:
logger.info(f"请扫描以下二维码登录: {qr_code_url}") logger.info(f"请扫描以下二维码登录: {qr_code_url}")
else: else:
logger.error("无法获取登录二维码。") logger.error("无法获取登录二维码。")
return return
# 3. 检测扫码状态 # 3. 检测扫码状态
login_successful = await self.check_login_status() login_successful = await self.check_login_status()
if login_successful: if login_successful:
# 登录成功后,连接 WebSocket 接收消息 logger.info("登录成功WeChatPadPro适配器已连接。")
self.ws_handle_task = asyncio.create_task(self.connect_websocket()) else:
else: logger.warning("登录失败或超时WeChatPadPro 适配器将关闭。")
logger.warning("登录失败或超时WeChatPadPro 适配器将关闭。") await self.terminate()
await self.terminate() return
return
# 登录成功后,连接 WebSocket 接收消息
self.ws_handle_task = asyncio.create_task(self.connect_websocket())
self._shutdown_event = asyncio.Event() self._shutdown_event = asyncio.Event()
await self._shutdown_event.wait() await self._shutdown_event.wait()
@@ -156,16 +159,29 @@ class WeChatPadProAdapter(Platform):
if login_state == 1: if login_state == 1:
logger.info("WeChatPadPro 设备当前在线。") logger.info("WeChatPadPro 设备当前在线。")
return True return True
else: # login_state == 3 为离线状态
elif login_state == 3:
logger.info( logger.info(
f"WeChatPadPro 设备不在线,登录状态: {login_state}" "WeChatPadPro 设备不在线"
) )
return False 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: else:
logger.error( logger.error(
f"检查在线状态失败: {response.status}, {response_data}" f"检查在线状态失败: {response.status}, {response_data}"
) )
return False return False
except aiohttp.ClientConnectorError as e: except aiohttp.ClientConnectorError as e:
logger.error(f"连接到 WeChatPadPro 服务失败: {e}") logger.error(f"连接到 WeChatPadPro 服务失败: {e}")
return False return False
@@ -179,7 +195,7 @@ class WeChatPadProAdapter(Platform):
""" """
url = f"{self.base_url}/admin/GenAuthKey1" url = f"{self.base_url}/admin/GenAuthKey1"
params = {"key": self.admin_key} params = {"key": self.admin_key}
payload = {"Count": 1, "Days": 30} # 生成一个有效期30天的授权码 payload = {"Count": 1, "Days": 365} # 生成一个有效期365天的授权码
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
try: try:
@@ -336,12 +352,10 @@ class WeChatPadProAdapter(Platform):
message = await asyncio.wait_for( message = await asyncio.wait_for(
websocket.recv(), timeout=wait_time websocket.recv(), timeout=wait_time
) )
logger.info(message) # logger.debug(message) # 不显示原始消息内容
asyncio.create_task(self.handle_websocket_message(message)) asyncio.create_task(self.handle_websocket_message(message))
except asyncio.TimeoutError: except asyncio.TimeoutError:
logger.warning( logger.warning(f"WebSocket 连接空闲超过 {wait_time} s")
f"WebSocket 连接空闲超过 {wait_time} s"
)
break break
except websockets.exceptions.ConnectionClosedOK: except websockets.exceptions.ConnectionClosedOK:
logger.info("WebSocket 连接正常关闭。") logger.info("WebSocket 连接正常关闭。")
@@ -350,7 +364,7 @@ class WeChatPadProAdapter(Platform):
logger.error(f"处理 WebSocket 消息时发生错误: {e}") logger.error(f"处理 WebSocket 消息时发生错误: {e}")
break break
except Exception as e: except Exception as e:
logger.error(f"WebSocket 连接失败: {e}") logger.error(f"WebSocket 连接失败: {e}, 请检查WeChatPadPro服务状态或尝试重启WeChatPadPro适配器。")
await asyncio.sleep(5) await asyncio.sleep(5)
async def handle_websocket_message(self, message: str): async def handle_websocket_message(self, message: str):
@@ -627,3 +641,67 @@ class WeChatPadProAdapter(Platform):
) )
# 调用实例方法 send # 调用实例方法 send
await sending_event.send(message_chain) 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

View File

@@ -20,7 +20,7 @@ from requests import Response
from wechatpy.utils import check_signature from wechatpy.utils import check_signature
from wechatpy.crypto import WeChatCrypto from wechatpy.crypto import WeChatCrypto
from wechatpy import WeChatClient 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.exceptions import InvalidSignatureException
from wechatpy import parse_message from wechatpy import parse_message
from .weixin_offacc_event import WeixinOfficialAccountPlatformEvent from .weixin_offacc_event import WeixinOfficialAccountPlatformEvent
@@ -87,7 +87,11 @@ class WecomServer:
logger.info(f"解析成功: {msg}") logger.info(f"解析成功: {msg}")
if self.callback: 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" return "success"
@@ -117,6 +121,7 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
self.api_base_url = platform_config.get( self.api_base_url = platform_config.get(
"api_base_url", "https://api.weixin.qq.com/cgi-bin/" "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: if not self.api_base_url:
self.api_base_url = "https://api.weixin.qq.com/cgi-bin/" 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 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: 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: except Exception as e:
logger.error(f"转换消息时出现异常: {e}") logger.error(f"转换消息时出现异常: {e}")
@@ -163,7 +188,9 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
async def run(self): async def run(self):
await self.server.start_polling() 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() abm = AstrBotMessage()
if isinstance(msg, TextMessage): if isinstance(msg, TextMessage):
abm.message_str = msg.content abm.message_str = msg.content
@@ -177,7 +204,6 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
abm.message_id = msg.id abm.message_id = msg.id
abm.timestamp = msg.time abm.timestamp = msg.time
abm.session_id = abm.sender.user_id abm.session_id = abm.sender.user_id
abm.raw_message = msg
elif msg.type == "image": elif msg.type == "image":
assert isinstance(msg, ImageMessage) assert isinstance(msg, ImageMessage)
abm.message_str = "[图片]" abm.message_str = "[图片]"
@@ -191,7 +217,6 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
abm.message_id = msg.id abm.message_id = msg.id
abm.timestamp = msg.time abm.timestamp = msg.time
abm.session_id = abm.sender.user_id abm.session_id = abm.sender.user_id
abm.raw_message = msg
elif msg.type == "voice": elif msg.type == "voice":
assert isinstance(msg, VoiceMessage) assert isinstance(msg, VoiceMessage)
@@ -209,7 +234,9 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
audio = AudioSegment.from_file(path) audio = AudioSegment.from_file(path)
audio.export(path_wav, format="wav") audio.export(path_wav, format="wav")
except Exception as e: except Exception as e:
logger.error(f"转换音频失败: {e}。如果没有安装 pydub 和 ffmpeg 请先安装。") logger.error(
f"转换音频失败: {e}。如果没有安装 pydub 和 ffmpeg 请先安装。"
)
path_wav = path path_wav = path
return return
@@ -224,11 +251,16 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
abm.message_id = msg.id abm.message_id = msg.id
abm.timestamp = msg.time abm.timestamp = msg.time
abm.session_id = abm.sender.user_id abm.session_id = abm.sender.user_id
abm.raw_message = msg
else: else:
logger.warning(f"暂未实现的事件: {msg.type}") logger.warning(f"暂未实现的事件: {msg.type}")
future.set_result(None)
return return
# 很不优雅 :(
abm.raw_message = {
"message": msg,
"future": future,
"active_send_mode": self.active_send_mode,
}
logger.info(f"abm: {abm}") logger.info(f"abm: {abm}")
await self.handle_msg(abm) await self.handle_msg(abm)

View File

@@ -4,6 +4,8 @@ from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.platform import AstrBotMessage, PlatformMetadata from astrbot.api.platform import AstrBotMessage, PlatformMetadata
from astrbot.api.message_components import Plain, Image, Record from astrbot.api.message_components import Plain, Image, Record
from wechatpy import WeChatClient from wechatpy import WeChatClient
from wechatpy.replies import TextReply, ImageReply, VoiceReply
from astrbot.api import logger from astrbot.api import logger
@@ -82,12 +84,23 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
async def send(self, message: MessageChain): async def send(self, message: MessageChain):
message_obj = self.message_obj message_obj = self.message_obj
active_send_mode = message_obj.raw_message.get("active_send_mode", False)
for comp in message.chain: for comp in message.chain:
if isinstance(comp, Plain): if isinstance(comp, Plain):
# Split long text messages if needed # Split long text messages if needed
plain_chunks = await self.split_plain(comp.text) plain_chunks = await self.split_plain(comp.text)
for chunk in plain_chunks: 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 await asyncio.sleep(0.5) # Avoid sending too fast
elif isinstance(comp, Image): elif isinstance(comp, Image):
img_path = await comp.convert_to_file_path() img_path = await comp.convert_to_file_path()
@@ -102,10 +115,22 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
) )
return return
logger.debug(f"微信公众平台上传图片返回: {response}") logger.debug(f"微信公众平台上传图片返回: {response}")
self.client.message.send_image(
message_obj.sender.user_id, if active_send_mode:
response["media_id"], 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): elif isinstance(comp, Record):
record_path = await comp.convert_to_file_path() record_path = await comp.convert_to_file_path()
# 转成amr # 转成amr
@@ -124,10 +149,23 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
) )
return return
logger.info(f"微信公众平台上传语音返回: {response}") 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: else:
logger.warning(f"还没实现这个消息类型的发送逻辑: {comp.type}") logger.warning(f"还没实现这个消息类型的发送逻辑: {comp.type}")

View File

@@ -19,6 +19,7 @@ class ProviderType(enum.Enum):
CHAT_COMPLETION = "chat_completion" CHAT_COMPLETION = "chat_completion"
SPEECH_TO_TEXT = "speech_to_text" SPEECH_TO_TEXT = "speech_to_text"
TEXT_TO_SPEECH = "text_to_speech" TEXT_TO_SPEECH = "text_to_speech"
EMBEDDING = "embedding"
@dataclass @dataclass
@@ -155,7 +156,9 @@ class ProviderRequest:
if self.image_urls: if self.image_urls:
user_content = { user_content = {
"role": "user", "role": "user",
"content": [{"type": "text", "text": self.prompt if self.prompt else "[图片]"}], "content": [
{"type": "text", "text": self.prompt if self.prompt else "[图片]"}
],
} }
for image_url in self.image_urls: for image_url in self.image_urls:
if image_url.startswith("http"): if image_url.startswith("http"):

View File

@@ -4,6 +4,7 @@ import textwrap
import os import os
import asyncio import asyncio
import logging import logging
from datetime import timedelta
from typing import Dict, List, Awaitable, Literal, Any from typing import Dict, List, Awaitable, Literal, Any
from dataclasses import dataclass from dataclasses import dataclass
@@ -20,6 +21,13 @@ try:
except (ModuleNotFoundError, ImportError): except (ModuleNotFoundError, ImportError):
logger.warning("警告: 缺少依赖库 'mcp',将无法使用 MCP 服务。") 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": {}} DEFAULT_MCP_CONFIG = {"mcpServers": {}}
SUPPORTED_TYPES = [ SUPPORTED_TYPES = [
@@ -96,7 +104,10 @@ class MCPClient:
async def connect_to_server(self, mcp_server_config: dict, name: str): async def connect_to_server(self, mcp_server_config: dict, name: str):
"""连接到 MCP 服务器 """连接到 MCP 服务器
如果 `url` 参数存在,则使用 SSE 的方式连接到 MCP 服务。 如果 `url` 参数存在
1. 当 transport 指定为 `streamable_http` 时,使用 Streamable HTTP 连接方式。
1. 当 transport 指定为 `sse` 时,使用 SSE 连接方式。
2. 如果没有指定,默认使用 SSE 的方式连接到 MCP 服务。
Args: Args:
mcp_server_config (dict): Configuration for the MCP server. See https://modelcontextprotocol.io/quickstart/server 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 cfg.pop("active", None) # Remove active flag from config
if "url" in cfg: if "url" in cfg:
# SSE transport method is_sse = True
self._streams_context = sse_client(url=cfg["url"]) if cfg.get("transport") == "streamable_http":
streams = await self._streams_context.__aenter__() 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 # Create a new client session
# self.session = await self._session_context.__aenter__() self.session = await self.exit_stack.enter_async_context(
self.session = await self.exit_stack.enter_async_context( mcp.ClientSession(*streams)
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: else:
server_params = mcp.StdioServerParameters( server_params = mcp.StdioServerParameters(

View File

@@ -21,9 +21,9 @@ class ProviderManager:
self.selected_provider_id = sp.get("curr_provider") self.selected_provider_id = sp.get("curr_provider")
self.selected_stt_provider_id = self.provider_stt_settings.get("provider_id") self.selected_stt_provider_id = self.provider_stt_settings.get("provider_id")
self.selected_tts_provider_id = self.provider_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.provider_enabled = self.provider_settings.get("enable", False)
self.stt_enabled = self.provider_stt_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.tts_enabled = self.provider_tts_settings.get("enable", False)
# 人格情景管理 # 人格情景管理
# 目前没有拆成独立的模块 # 目前没有拆成独立的模块
@@ -98,9 +98,13 @@ class ProviderManager:
"""加载的 Speech To Text Provider 的实例""" """加载的 Speech To Text Provider 的实例"""
self.tts_provider_insts: List[TTSProvider] = [] self.tts_provider_insts: List[TTSProvider] = []
"""加载的 Text To Speech Provider 的实例""" """加载的 Text To Speech Provider 的实例"""
self.embedding_provider_insts: List[Provider] = []
"""加载的 Embedding Provider 的实例"""
self.inst_map = {} self.inst_map = {}
"""Provider 实例映射. key: provider_id, value: Provider 实例""" """Provider 实例映射. key: provider_id, value: Provider 实例"""
self.llm_tools = llm_tools self.llm_tools = llm_tools
self.default_provider_inst: Provider = None
"""默认的 Provider 实例。第 0 个或者用户以前指定的 Provider 实例"""
self.curr_provider_inst: Provider = None self.curr_provider_inst: Provider = None
"""当前使用的 Provider 实例""" """当前使用的 Provider 实例"""
self.curr_stt_provider_inst: STTProvider = None self.curr_stt_provider_inst: STTProvider = None
@@ -119,14 +123,9 @@ class ProviderManager:
for provider_config in self.providers_config: for provider_config in self.providers_config:
await self.load_provider(provider_config) await self.load_provider(provider_config)
if not self.curr_provider_inst: self.default_provider_inst = self.inst_map.get(self.selected_provider_id)
logger.warning("未启用任何用于 文本生成 的提供商适配器。") if not self.default_provider_inst and self.provider_insts:
self.default_provider_inst = self.provider_insts[0]
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("未启用任何用于 文本转语音 的提供商适配器。")
# 初始化 MCP Client 连接 # 初始化 MCP Client 连接
asyncio.create_task( asyncio.create_task(
@@ -210,6 +209,14 @@ class ProviderManager:
from .sources.minimax_tts_api_source import ( from .sources.minimax_tts_api_source import (
ProviderMiniMaxTTSAPI as ProviderMiniMaxTTSAPI, ProviderMiniMaxTTSAPI as ProviderMiniMaxTTSAPI,
) )
case "volcengine_tts":
from .sources.volcengine_tts import (
ProviderVolcengineTTS as ProviderVolcengineTTS,
)
case "openai_embedding":
from .sources.openai_embedding_source import (
OpenAIEmbeddingProvider as OpenAIEmbeddingProvider,
)
except (ImportError, ModuleNotFoundError) as e: except (ImportError, ModuleNotFoundError) as e:
logger.critical( logger.critical(
f"加载 {provider_config['type']}({provider_config['id']}) 提供商适配器失败:{e}。可能是因为有未安装的依赖。" f"加载 {provider_config['type']}({provider_config['id']}) 提供商适配器失败:{e}。可能是因为有未安装的依赖。"
@@ -241,15 +248,12 @@ class ProviderManager:
await inst.initialize() await inst.initialize()
self.stt_provider_insts.append(inst) self.stt_provider_insts.append(inst)
if ( if self.selected_stt_provider_id == provider_config["id"]:
self.selected_stt_provider_id == provider_config["id"]
and self.stt_enabled
):
self.curr_stt_provider_inst = inst self.curr_stt_provider_inst = inst
logger.info( logger.info(
f"已选择 {provider_config['type']}({provider_config['id']}) 作为当前语音转文本提供商适配器。" 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 self.curr_stt_provider_inst = inst
elif provider_metadata.provider_type == ProviderType.TEXT_TO_SPEECH: elif provider_metadata.provider_type == ProviderType.TEXT_TO_SPEECH:
@@ -262,15 +266,12 @@ class ProviderManager:
await inst.initialize() await inst.initialize()
self.tts_provider_insts.append(inst) self.tts_provider_insts.append(inst)
if ( if self.selected_tts_provider_id == provider_config["id"]:
self.selected_tts_provider_id == provider_config["id"]
and self.tts_enabled
):
self.curr_tts_provider_inst = inst self.curr_tts_provider_inst = inst
logger.info( logger.info(
f"已选择 {provider_config['type']}({provider_config['id']}) 作为当前文本转语音提供商适配器。" 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 self.curr_tts_provider_inst = inst
elif provider_metadata.provider_type == ProviderType.CHAT_COMPLETION: elif provider_metadata.provider_type == ProviderType.CHAT_COMPLETION:
@@ -287,17 +288,22 @@ class ProviderManager:
await inst.initialize() await inst.initialize()
self.provider_insts.append(inst) self.provider_insts.append(inst)
if ( if self.selected_provider_id == provider_config["id"]:
self.selected_provider_id == provider_config["id"]
and self.provider_enabled
):
self.curr_provider_inst = inst self.curr_provider_inst = inst
logger.info( logger.info(
f"已选择 {provider_config['type']}({provider_config['id']}) 作为当前提供商适配器。" 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.curr_provider_inst = inst
elif provider_metadata.provider_type == ProviderType.EMBEDDING:
inst = provider_metadata.cls_type(
provider_config, self.provider_settings
)
if getattr(inst, "initialize", None):
await inst.initialize()
self.embedding_provider_insts.append(inst)
self.inst_map[provider_config["id"]] = inst self.inst_map[provider_config["id"]] = inst
except Exception as e: except Exception as e:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
@@ -318,11 +324,7 @@ class ProviderManager:
if len(self.provider_insts) == 0: if len(self.provider_insts) == 0:
self.curr_provider_inst = None self.curr_provider_inst = None
elif ( elif self.curr_provider_inst is None and len(self.provider_insts) > 0:
self.curr_provider_inst is None
and len(self.provider_insts) > 0
and self.provider_enabled
):
self.curr_provider_inst = self.provider_insts[0] self.curr_provider_inst = self.provider_insts[0]
self.selected_provider_id = self.curr_provider_inst.meta().id self.selected_provider_id = self.curr_provider_inst.meta().id
logger.info( logger.info(
@@ -331,11 +333,7 @@ class ProviderManager:
if len(self.stt_provider_insts) == 0: if len(self.stt_provider_insts) == 0:
self.curr_stt_provider_inst = None self.curr_stt_provider_inst = None
elif ( elif self.curr_stt_provider_inst is None and len(self.stt_provider_insts) > 0:
self.curr_stt_provider_inst is None
and len(self.stt_provider_insts) > 0
and self.stt_enabled
):
self.curr_stt_provider_inst = 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 self.selected_stt_provider_id = self.curr_stt_provider_inst.meta().id
logger.info( logger.info(
@@ -344,11 +342,7 @@ class ProviderManager:
if len(self.tts_provider_insts) == 0: if len(self.tts_provider_insts) == 0:
self.curr_tts_provider_inst = None self.curr_tts_provider_inst = None
elif ( elif self.curr_tts_provider_inst is None and len(self.tts_provider_insts) > 0:
self.curr_tts_provider_inst is None
and len(self.tts_provider_insts) > 0
and self.tts_enabled
):
self.curr_tts_provider_inst = 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 self.selected_tts_provider_id = self.curr_tts_provider_inst.meta().id
logger.info( logger.info(

View File

@@ -179,3 +179,25 @@ class TTSProvider(AbstractProvider):
async def get_audio(self, text: str) -> str: async def get_audio(self, text: str) -> str:
"""获取文本的音频,返回音频文件路径""" """获取文本的音频,返回音频文件路径"""
raise NotImplementedError() 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
async def get_embeddings(self, text: list[str]) -> list[list[float]]:
"""批量获取文本的向量"""
...
@abc.abstractmethod
def get_dim(self) -> int:
"""获取向量的维度"""
...

View File

@@ -53,8 +53,8 @@ class OTTSProvider:
async def _generate_signature(self) -> str: async def _generate_signature(self) -> str:
await self._sync_time() await self._sync_time()
timestamp = int(time.time()) + self.time_offset timestamp = int(time.time()) + self.time_offset
nonce = ''.join(random.choices('abcdefghijklmnopqrstuvwxyz0123456789', k=10)) nonce = "".join(random.choices("abcdefghijklmnopqrstuvwxyz0123456789", k=10))
path = re.sub(r'^https?://[^/]+', '', self.api_url) or '/' 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()}" 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: 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): def __init__(self, provider_config: dict, provider_settings: dict):
super().__init__(provider_config, provider_settings) super().__init__(provider_config, provider_settings)
self.subscription_key = provider_config.get("azure_tts_subscription_key", "").strip() 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订阅密钥") raise ValueError("无效的Azure订阅密钥")
self.region = provider_config.get("azure_tts_region", "eastus").strip() self.region = provider_config.get("azure_tts_region", "eastus").strip()
self.endpoint = f"https://{self.region}.tts.speech.microsoft.com/cognitiveservices/v1" 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 raise ValueError(error_msg) from e
except KeyError as e: except KeyError as e:
raise ValueError(f"配置错误: 缺少必要参数 {e}") from 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) return AzureNativeProvider(config, self.provider_settings)
raise ValueError("订阅密钥格式无效应为32位字母数字或other[...]格式") raise ValueError("订阅密钥格式无效应为32位字母数字或other[...]格式")

View File

@@ -291,19 +291,19 @@ class ProviderGoogleGenAI(Provider):
result_parts: Optional[types.Part] = result.candidates[0].content.parts result_parts: Optional[types.Part] = result.candidates[0].content.parts
if finish_reason == types.FinishReason.SAFETY: if finish_reason == types.FinishReason.SAFETY:
raise Exception("模型生成内容未通过用户定义的内容安全检查") raise Exception("模型生成内容未通过 Gemini 平台的安全检查")
if finish_reason in { if finish_reason in {
types.FinishReason.PROHIBITED_CONTENT, types.FinishReason.PROHIBITED_CONTENT,
types.FinishReason.SPII, types.FinishReason.SPII,
types.FinishReason.BLOCKLIST, types.FinishReason.BLOCKLIST,
}: }:
raise Exception("模型生成内容违反Gemini平台政策") raise Exception("模型生成内容违反 Gemini 平台政策")
# 防止旧版本SDK不存在IMAGE_SAFETY # 防止旧版本SDK不存在IMAGE_SAFETY
if hasattr(types.FinishReason, "IMAGE_SAFETY"): if hasattr(types.FinishReason, "IMAGE_SAFETY"):
if finish_reason == types.FinishReason.IMAGE_SAFETY: if finish_reason == types.FinishReason.IMAGE_SAFETY:
raise Exception("模型生成内容违反Gemini平台政策") raise Exception("模型生成内容违反 Gemini 平台政策")
if not result_parts: if not result_parts:
logger.debug(result.candidates) logger.debug(result.candidates)

View File

@@ -0,0 +1,42 @@
from openai import AsyncOpenAI
from ..provider import EmbeddingProvider
from ..register import register_provider_adapter
from ..entities import ProviderType
@register_provider_adapter(
"openai_embedding",
"OpenAI API Embedding 提供商适配器",
provider_type=ProviderType.EMBEDDING,
)
class OpenAIEmbeddingProvider(EmbeddingProvider):
def __init__(self, provider_config: dict, provider_settings: dict) -> None:
super().__init__(provider_config, provider_settings)
self.provider_config = provider_config
self.provider_settings = provider_settings
self.client = AsyncOpenAI(
api_key=provider_config.get("embedding_api_key"),
base_url=provider_config.get(
"embedding_api_base", "https://api.openai.com/v1"
),
)
self.model = provider_config.get("embedding_model", "text-embedding-3-small")
self.dimension = provider_config.get("embedding_dimensions", 1536)
async def get_embedding(self, text: str) -> list[float]:
"""
获取文本的嵌入
"""
embedding = await self.client.embeddings.create(input=text, model=self.model)
return embedding.data[0].embedding
async def get_embeddings(self, texts: list[str]) -> list[list[float]]:
"""
批量获取文本的嵌入
"""
embeddings = await self.client.embeddings.create(input=texts, model=self.model)
return [item.embedding for item in embeddings.data]
def get_dim(self) -> int:
"""获取向量的维度"""
return self.dimension

View File

@@ -195,7 +195,11 @@ class ProviderOpenAIOfficial(Provider):
for tool_call in choice.message.tool_calls: for tool_call in choice.message.tool_calls:
for tool in tools.func_list: for tool in tools.func_list:
if tool.name == tool_call.function.name: 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) args_ls.append(args)
func_name_ls.append(tool_call.function.name) func_name_ls.append(tool_call.function.name)
tool_call_ids.append(tool_call.id) tool_call_ids.append(tool_call.id)
@@ -223,9 +227,9 @@ class ProviderOpenAIOfficial(Provider):
session_id: str = None, session_id: str = None,
image_urls: list[str] = None, image_urls: list[str] = None,
func_tool: FuncCall = None, func_tool: FuncCall = None,
contexts: list=None, contexts: list = None,
system_prompt: str=None, system_prompt: str = None,
tool_calls_result: ToolCallsResult=None, tool_calls_result: ToolCallsResult = None,
**kwargs, **kwargs,
) -> tuple: ) -> tuple:
"""准备聊天所需的有效载荷和上下文""" """准备聊天所需的有效载荷和上下文"""
@@ -340,9 +344,9 @@ class ProviderOpenAIOfficial(Provider):
async def text_chat( async def text_chat(
self, self,
prompt, prompt,
session_id = None, session_id=None,
image_urls = None, image_urls=None,
func_tool = None, func_tool=None,
contexts=None, contexts=None,
system_prompt=None, system_prompt=None,
tool_calls_result=None, tool_calls_result=None,

View 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)}")

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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]

View File

@@ -16,7 +16,6 @@ from .star_handler import star_handlers_registry, StarHandlerMetadata, EventType
from .filter.command import CommandFilter from .filter.command import CommandFilter
from .filter.regex import RegexFilter from .filter.regex import RegexFilter
from typing import Awaitable from typing import Awaitable
from astrbot.core.rag.knowledge_db_mgr import KnowledgeDBManager
from astrbot.core.conversation_mgr import ConversationManager from astrbot.core.conversation_mgr import ConversationManager
from astrbot.core.star.filter.platform_adapter_type import ( from astrbot.core.star.filter.platform_adapter_type import (
PlatformAdapterType, PlatformAdapterType,
@@ -42,6 +41,8 @@ class Context:
platform_manager: PlatformManager = None platform_manager: PlatformManager = None
registered_web_apis: list = []
# back compatibility # back compatibility
_register_tasks: List[Awaitable] = [] _register_tasks: List[Awaitable] = []
_star_manager = None _star_manager = None
@@ -54,14 +55,12 @@ class Context:
provider_manager: ProviderManager = None, provider_manager: ProviderManager = None,
platform_manager: PlatformManager = None, platform_manager: PlatformManager = None,
conversation_manager: ConversationManager = None, conversation_manager: ConversationManager = None,
knowledge_db_manager: KnowledgeDBManager = None,
): ):
self._event_queue = event_queue self._event_queue = event_queue
self._config = config self._config = config
self._db = db self._db = db
self.provider_manager = provider_manager self.provider_manager = provider_manager
self.platform_manager = platform_manager self.platform_manager = platform_manager
self.knowledge_db_manager = knowledge_db_manager
self.conversation_manager = conversation_manager self.conversation_manager = conversation_manager
def get_registered_star(self, star_name: str) -> StarMetadata: def get_registered_star(self, star_name: str) -> StarMetadata:
@@ -126,11 +125,8 @@ class Context:
self.provider_manager.provider_insts.append(provider) self.provider_manager.provider_insts.append(provider)
def get_provider_by_id(self, provider_id: str) -> Provider: def get_provider_by_id(self, provider_id: str) -> Provider:
"""通过 ID 获取用于文本生成任务的 LLM Provider(Chat_Completion 类型)。""" """通过 ID 获取对应的 LLM Provider(Chat_Completion 类型)。"""
for provider in self.provider_manager.provider_insts: return self.provider_manager.inst_map.get(provider_id)
if provider.meta().id == provider_id:
return provider
return None
def get_all_providers(self) -> List[Provider]: def get_all_providers(self) -> List[Provider]:
"""获取所有用于文本生成任务的 LLM Provider(Chat_Completion 类型)。""" """获取所有用于文本生成任务的 LLM Provider(Chat_Completion 类型)。"""
@@ -301,3 +297,12 @@ class Context:
注册一个异步任务。 注册一个异步任务。
""" """
self._register_tasks.append(task) self._register_tasks.append(task)
def register_web_api(
self, route: str, view_handler: Awaitable, methods: list, desc: str
):
for idx, api in enumerate(self.registered_web_apis):
if api[0] == route and methods == api[2]:
self.registered_web_apis[idx] = (route, view_handler, methods, desc)
return
self.registered_web_apis.append((route, view_handler, methods, desc))

View File

@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import enum import enum
import heapq
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Awaitable, List, Dict, TypeVar, Generic from typing import Awaitable, List, Dict, TypeVar, Generic
from .filter import HandlerFilter from .filter import HandlerFilter
@@ -8,100 +7,66 @@ from .star import star_map
T = TypeVar("T", bound="StarHandlerMetadata") T = TypeVar("T", bound="StarHandlerMetadata")
class StarHandlerRegistry(Generic[T]): class StarHandlerRegistry(Generic[T]):
"""用于存储所有的 Star Handler""" def __init__(self):
self.star_handlers_map: Dict[str, StarHandlerMetadata] = {}
star_handlers_map: Dict[str, StarHandlerMetadata] = {} self._handlers: List[StarHandlerMetadata] = []
"""用于快速查找。key 是 handler_full_name"""
_handlers = []
def append(self, handler: StarHandlerMetadata): def append(self, handler: StarHandlerMetadata):
"""添加一个 Handler""" """添加一个 Handler,并保持按优先级有序"""
if "priority" not in handler.extras_configs: if "priority" not in handler.extras_configs:
handler.extras_configs["priority"] = 0 handler.extras_configs["priority"] = 0
heapq.heappush(self._handlers, (-handler.extras_configs["priority"], handler))
self.star_handlers_map[handler.handler_full_name] = 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): def _print_handlers(self):
"""打印所有的 Handler""" for handler in self._handlers:
for _, handler in self._handlers:
print(handler.handler_full_name) print(handler.handler_full_name)
def get_handlers_by_event_type( def get_handlers_by_event_type(
self, event_type: EventType, only_activated=True, platform_id=None self, event_type: EventType, only_activated=True, platform_id=None
) -> List[StarHandlerMetadata]: ) -> List[StarHandlerMetadata]:
"""通过事件类型获取 Handler
Args:
event_type: 事件类型
only_activated: 是否只返回已激活的插件的处理器
platform_id: 平台ID如果提供此参数将过滤掉在此平台不兼容的处理器
Returns:
List[StarHandlerMetadata]: 处理器列表
"""
handlers = [] handlers = []
for _, handler in self._handlers: for handler in self._handlers:
if handler.event_type != event_type: if handler.event_type != event_type:
continue continue
# 只激活的插件处理器
if only_activated: if only_activated:
plugin = star_map.get(handler.handler_module_path) plugin = star_map.get(handler.handler_module_path)
if not (plugin and plugin.activated): if not (plugin and plugin.activated):
continue continue
# 平台兼容性过滤
if platform_id and event_type != EventType.OnAstrBotLoadedEvent: if platform_id and event_type != EventType.OnAstrBotLoadedEvent:
if not handler.is_enabled_for_platform(platform_id): if not handler.is_enabled_for_platform(platform_id):
continue continue
handlers.append(handler) handlers.append(handler)
return handlers return handlers
def get_handler_by_full_name(self, full_name: str) -> StarHandlerMetadata: def get_handler_by_full_name(self, full_name: str) -> StarHandlerMetadata:
"""通过 Handler 的全名获取 Handler"""
return self.star_handlers_map.get(full_name, None) return self.star_handlers_map.get(full_name, None)
def get_handlers_by_module_name( def get_handlers_by_module_name(
self, module_name: str self, module_name: str
) -> List[StarHandlerMetadata]: ) -> List[StarHandlerMetadata]:
"""通过模块名获取 Handler"""
return [ return [
handler handler for handler in self._handlers
for _, handler in self._handlers
if handler.handler_module_path == module_name if handler.handler_module_path == module_name
] ]
def clear(self): def clear(self):
"""清空所有的 Handler"""
self.star_handlers_map.clear() self.star_handlers_map.clear()
self._handlers.clear() self._handlers.clear()
def remove(self, handler: StarHandlerMetadata): def remove(self, handler: StarHandlerMetadata):
"""删除一个 Handler""" self.star_handlers_map.pop(handler.handler_full_name, None)
# self._handlers.remove(handler) self._handlers = [h for h in self._handlers if h != 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
def __iter__(self): def __iter__(self):
"""使 StarHandlerRegistry 支持迭代""" return iter(self._handlers)
return (handler for _, handler in self._handlers)
def __len__(self): def __len__(self):
"""返回 Handler 的数量"""
return len(self._handlers) return len(self._handlers)
star_handlers_registry = StarHandlerRegistry() star_handlers_registry = StarHandlerRegistry()

View File

@@ -37,6 +37,12 @@ except ImportError:
if os.getenv("ASTRBOT_RELOAD", "0") == "1": if os.getenv("ASTRBOT_RELOAD", "0") == "1":
logger.warning("未安装 watchfiles无法实现插件的热重载。") logger.warning("未安装 watchfiles无法实现插件的热重载。")
try:
import nh3
except ImportError:
logger.warning("未安装 nh3 库,无法清理插件 README.md 中的 HTML 标签。")
nh3 = None
class PluginManager: class PluginManager:
def __init__(self, context: Context, config: AstrBotConfig): def __init__(self, context: Context, config: AstrBotConfig):
@@ -140,11 +146,13 @@ class PluginManager:
if os.path.exists(os.path.join(path, d, "main.py")) or os.path.exists( if os.path.exists(os.path.join(path, d, "main.py")) or os.path.exists(
os.path.join(path, d, d + ".py") os.path.join(path, d, d + ".py")
): ):
modules.append({ modules.append(
"pname": d, {
"module": module_str, "pname": d,
"module_path": os.path.join(path, d, module_str), "module": module_str,
}) "module_path": os.path.join(path, d, module_str),
}
)
return modules return modules
def _get_plugin_modules(self) -> List[dict]: def _get_plugin_modules(self) -> List[dict]:
@@ -158,7 +166,7 @@ class PluginManager:
plugins.extend(_p) plugins.extend(_p)
return plugins return plugins
def _check_plugin_dept_update(self, target_plugin: str = None): async def _check_plugin_dept_update(self, target_plugin: str = None):
"""检查插件的依赖 """检查插件的依赖
如果 target_plugin 为 None则检查所有插件的依赖 如果 target_plugin 为 None则检查所有插件的依赖
""" """
@@ -177,7 +185,7 @@ class PluginManager:
pth = os.path.join(plugin_path, "requirements.txt") pth = os.path.join(plugin_path, "requirements.txt")
logger.info(f"正在安装插件 {p} 所需的依赖库: {pth}") logger.info(f"正在安装插件 {p} 所需的依赖库: {pth}")
try: try:
pip_installer.install(requirements_path=pth) await pip_installer.install(requirements_path=pth)
except Exception as e: except Exception as e:
logger.error(f"更新插件 {p} 的依赖失败。Code: {str(e)}") logger.error(f"更新插件 {p} 的依赖失败。Code: {str(e)}")
@@ -399,7 +407,7 @@ class PluginManager:
module = __import__(path, fromlist=[module_str]) module = __import__(path, fromlist=[module_str])
except (ModuleNotFoundError, ImportError): except (ModuleNotFoundError, ImportError):
# 尝试安装依赖 # 尝试安装依赖
self._check_plugin_dept_update(target_plugin=root_dir_name) await self._check_plugin_dept_update(target_plugin=root_dir_name)
module = __import__(path, fromlist=[module_str]) module = __import__(path, fromlist=[module_str])
except Exception as e: except Exception as e:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
@@ -634,16 +642,17 @@ class PluginManager:
if not os.path.exists(readme_path): if not os.path.exists(readme_path):
readme_path = os.path.join(plugin_path, "readme.md") readme_path = os.path.join(plugin_path, "readme.md")
if os.path.exists(readme_path): if os.path.exists(readme_path) and nh3:
try: try:
with open(readme_path, "r", encoding="utf-8") as f: with open(readme_path, "r", encoding="utf-8") as f:
readme_content = f.read() readme_content = f.read()
cleaned_content = nh3.clean(readme_content)
except Exception as e: except Exception as e:
logger.warning(f"读取插件 {dir_name} 的 README.md 文件失败: {str(e)}") logger.warning(f"读取插件 {dir_name} 的 README.md 文件失败: {str(e)}")
plugin_info = None plugin_info = None
if plugin: if plugin:
plugin_info = {"repo": plugin.repo, "readme": readme_content} plugin_info = {"repo": plugin.repo, "readme": cleaned_content}
return plugin_info return plugin_info

View File

@@ -1,5 +1,5 @@
import logging import logging
from pip import main as pip_main import asyncio
logger = logging.getLogger("astrbot") logger = logging.getLogger("astrbot")
@@ -9,7 +9,7 @@ class PipInstaller:
self.pip_install_arg = pip_install_arg self.pip_install_arg = pip_install_arg
self.pypi_index_url = pypi_index_url self.pypi_index_url = pypi_index_url
def install( async def install(
self, self,
package_name: str = None, package_name: str = None,
requirements_path: str = None, requirements_path: str = None,
@@ -29,12 +29,29 @@ class PipInstaller:
args.extend(self.pip_install_arg.split()) args.extend(self.pip_install_arg.split())
logger.info(f"Pip 包管理器: pip {' '.join(args)}") logger.info(f"Pip 包管理器: pip {' '.join(args)}")
try:
process = await asyncio.create_subprocess_exec(
"pip", *args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
result_code = pip_main(args) assert process.stdout is not None
async for line in process.stdout:
logger.info(line.decode().strip())
# 清除 pip.main 导致的多余的 logging handlers await process.wait()
for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler)
if result_code != 0: if process.returncode != 0:
raise Exception(f"安装失败,错误码:{result_code}") raise Exception(f"安装失败,错误码:{process.returncode}")
except FileNotFoundError:
# 没有 pip
from pip import main as pip_main
result_code = await asyncio.to_thread(pip_main, args)
# 清除 pip.main 导致的多余的 logging handlers
for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler)
if result_code != 0:
raise Exception(f"安装失败,错误码:{result_code}")

View File

@@ -21,7 +21,11 @@ class AuthRoute(Route):
post_data = await request.json post_data = await request.json
if post_data["username"] == username and post_data["password"] == password: if post_data["username"] == username and post_data["password"] == password:
change_pwd_hint = False change_pwd_hint = False
if username == "astrbot" and password == "77b90590a8945a7d36c963981a307dc9": if (
username == "astrbot"
and password == "77b90590a8945a7d36c963981a307dc9"
and not DEMO_MODE
):
change_pwd_hint = True change_pwd_hint = True
logger.warning("为了保证安全,请尽快修改默认密码。") logger.warning("为了保证安全,请尽快修改默认密码。")

View File

@@ -61,16 +61,25 @@ class ChatRoute(Route):
return Response().error("Missing key: filename").__dict__ return Response().error("Missing key: filename").__dict__
try: try:
with open(os.path.join(self.imgs_dir, filename), "rb") as f: file_path = os.path.join(self.imgs_dir, os.path.basename(filename))
if filename.endswith(".wav"): real_file_path = os.path.realpath(file_path)
real_imgs_dir = os.path.realpath(self.imgs_dir)
if not real_file_path.startswith(real_imgs_dir):
return Response().error("Invalid file path").__dict__
with open(real_file_path, "rb") as f:
filename_ext = os.path.splitext(filename)[1].lower()
if filename_ext == ".wav":
return QuartResponse(f.read(), mimetype="audio/wav") return QuartResponse(f.read(), mimetype="audio/wav")
elif filename.split(".")[-1] in self.supported_imgs: elif filename_ext[1:] in self.supported_imgs:
return QuartResponse(f.read(), mimetype="image/jpeg") return QuartResponse(f.read(), mimetype="image/jpeg")
else: else:
return QuartResponse(f.read()) return QuartResponse(f.read())
except FileNotFoundError: except (FileNotFoundError, OSError):
return Response().error("File not found").__dict__ return Response().error("File access error").__dict__
async def post_image(self): async def post_image(self):
post_data = await request.files post_data = await request.files
@@ -126,17 +135,15 @@ class ChatRoute(Route):
self.curr_user_cid[username] = conversation_id self.curr_user_cid[username] = conversation_id
await web_chat_queue.put( await web_chat_queue.put((
( username,
username, conversation_id,
conversation_id, {
{ "message": message,
"message": message, "image_url": image_url, # list
"image_url": image_url, # list "audio_url": audio_url,
"audio_url": audio_url, },
}, ))
)
)
# 持久化 # 持久化
conversation = self.db.get_conversation_by_user_id(username, conversation_id) conversation = self.db.get_conversation_by_user_id(username, conversation_id)

View File

@@ -9,6 +9,7 @@ from astrbot.core.platform.register import platform_registry
from astrbot.core.provider.register import provider_registry from astrbot.core.provider.register import provider_registry
from astrbot.core.star.star import star_registry from astrbot.core.star.star import star_registry
from astrbot.core import logger from astrbot.core import logger
import asyncio
def try_cast(value: str, type_: str): def try_cast(value: str, type_: str):
@@ -164,9 +165,84 @@ class ConfigRoute(Route):
"/config/provider/update": ("POST", self.post_update_provider), "/config/provider/update": ("POST", self.post_update_provider),
"/config/provider/delete": ("POST", self.post_delete_provider), "/config/provider/delete": ("POST", self.post_delete_provider),
"/config/llmtools": ("GET", self.get_llm_tools), "/config/llmtools": ("GET", self.get_llm_tools),
"/config/provider/check_status": ("GET", self.check_all_providers_status),
"/config/provider/list": ("GET", self.get_provider_config_list),
} }
self.register_routes() self.register_routes()
async def _test_single_provider(self, provider):
"""辅助函数:测试单个 provider 的可用性"""
meta = provider.meta()
provider_name = provider.provider_config.get("id", "Unknown Provider")
if not provider_name and meta:
provider_name = meta.id
elif not provider_name:
provider_name = "Unknown Provider"
status_info = {
"id": meta.id if meta else "Unknown ID",
"model": meta.model if meta else "Unknown Model",
"type": meta.type if meta else "Unknown Type",
"name": provider_name,
"status": "unavailable", # 默认为不可用
"error": None,
}
logger.debug(f"Attempting to check provider: {status_info['name']} (ID: {status_info['id']}, Type: {status_info['type']}, Model: {status_info['model']})")
try:
logger.debug(f"Sending 'Ping' to provider: {status_info['name']}")
response = await asyncio.wait_for(provider.text_chat(prompt="Ping"), timeout=20.0) # 超时 20 秒
logger.debug(f"Received response from {status_info['name']}: {response}")
# 只要 text_chat 调用成功返回一个 LLMResponse 对象 (即 response 不为 None),就认为可用
if response is not None:
status_info["status"] = "available"
response_text_snippet = ""
if hasattr(response, 'completion_text') and response.completion_text:
response_text_snippet = response.completion_text[:70] + "..." if len(response.completion_text) > 70 else response.completion_text
elif hasattr(response, 'result_chain') and response.result_chain:
try:
response_text_snippet = response.result_chain.get_plain_text()[:70] + "..." if len(response.result_chain.get_plain_text()) > 70 else response.result_chain.get_plain_text()
except:
pass
logger.info(f"Provider {status_info['name']} (ID: {status_info['id']}) is available. Response snippet: '{response_text_snippet}'")
else:
# 这个分支理论上不应该被走到,除非 text_chat 实现可能返回 None
status_info["error"] = "Test call returned None, but expected an LLMResponse object."
logger.warning(f"Provider {status_info['name']} (ID: {status_info['id']}) test call returned None.")
except asyncio.TimeoutError:
status_info["error"] = "Connection timed out after 10 seconds during test call."
logger.warning(f"Provider {status_info['name']} (ID: {status_info['id']}) timed out.")
except Exception as e:
error_message = str(e)
status_info["error"] = error_message
logger.warning(f"Provider {status_info['name']} (ID: {status_info['id']}) is unavailable. Error: {error_message}")
logger.debug(f"Traceback for {status_info['name']}:\n{traceback.format_exc()}")
return status_info
async def check_all_providers_status(self):
"""
API 接口: 检查所有 LLM Providers 的状态
"""
logger.info("API call received: /config/provider/check_status")
try:
all_providers: typing.List = self.core_lifecycle.star_context.get_all_providers()
logger.debug(f"Found {len(all_providers)} providers to check.")
if not all_providers:
logger.info("No providers found to check.")
return Response().ok([]).__dict__
tasks = [self._test_single_provider(p) for p in all_providers]
logger.debug(f"Created {len(tasks)} tasks for concurrent provider checks.")
results = await asyncio.gather(*tasks)
logger.info(f"Provider status check completed. Results: {results}")
return Response().ok(results).__dict__
except Exception as e:
logger.error(f"Critical error in check_all_providers_status: {str(e)}")
logger.error(traceback.format_exc())
return Response().error(f"检查 Provider 状态时发生严重错误: {str(e)}").__dict__
async def get_configs(self): async def get_configs(self):
# plugin_name 为空时返回 AstrBot 配置 # plugin_name 为空时返回 AstrBot 配置
# 否则返回指定 plugin_name 的插件配置 # 否则返回指定 plugin_name 的插件配置
@@ -175,6 +251,17 @@ class ConfigRoute(Route):
return Response().ok(await self._get_astrbot_config()).__dict__ return Response().ok(await self._get_astrbot_config()).__dict__
return Response().ok(await self._get_plugin_config(plugin_name)).__dict__ return Response().ok(await self._get_plugin_config(plugin_name)).__dict__
async def get_provider_config_list(self):
provider_type = request.args.get("provider_type", None)
if not provider_type:
return Response().error("缺少参数 provider_type").__dict__
provider_list = []
astrbot_config = self.core_lifecycle.astrbot_config
for provider in astrbot_config["provider"]:
if provider.get("provider_type", None) == provider_type:
provider_list.append(provider)
return Response().ok(provider_list).__dict__
async def post_astrbot_configs(self): async def post_astrbot_configs(self):
post_configs = await request.json post_configs = await request.json
try: try:

View File

@@ -23,6 +23,7 @@ class LogRoute(Route):
**message, # see astrbot/core/log.py **message, # see astrbot/core/log.py
} }
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
await asyncio.sleep(0.07) # 控制发送频率,避免过快
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
except BaseException as e: except BaseException as e:

View File

@@ -18,6 +18,12 @@ from astrbot.core.star.filter.regex import RegexFilter
from astrbot.core.star.star_handler import EventType from astrbot.core.star.star_handler import EventType
from astrbot.core import DEMO_MODE from astrbot.core import DEMO_MODE
try:
import nh3
except ImportError:
logger.warning("未安装 nh3 库,无法清理插件 README.md 中的 HTML 标签。")
nh3 = None
class PluginRoute(Route): class PluginRoute(Route):
def __init__( def __init__(
@@ -102,7 +108,10 @@ class PluginRoute(Route):
async def get_plugins(self): async def get_plugins(self):
_plugin_resp = [] _plugin_resp = []
plugin_name = request.args.get("name")
for plugin in self.plugin_manager.context.get_all_stars(): for plugin in self.plugin_manager.context.get_all_stars():
if plugin_name and plugin.name != plugin_name:
continue
_t = { _t = {
"name": plugin.name, "name": plugin.name,
"repo": "" if plugin.repo is None else plugin.repo, "repo": "" if plugin.repo is None else plugin.repo,
@@ -145,9 +154,7 @@ class PluginRoute(Route):
if handler.event_type == EventType.AdapterMessageEvent: if handler.event_type == EventType.AdapterMessageEvent:
# 处理平台适配器消息事件 # 处理平台适配器消息事件
has_admin = False has_admin = False
for ( for filter in (
filter
) in (
handler.event_filters handler.event_filters
): # 正常handler就只有 1~2 个 filter因此这里时间复杂度不会太高 ): # 正常handler就只有 1~2 个 filter因此这里时间复杂度不会太高
if isinstance(filter, CommandFilter): if isinstance(filter, CommandFilter):
@@ -325,6 +332,9 @@ class PluginRoute(Route):
return Response().error(str(e)).__dict__ return Response().error(str(e)).__dict__
async def get_plugin_readme(self): async def get_plugin_readme(self):
if not nh3:
return Response().error("未安装 nh3 库").__dict__
plugin_name = request.args.get("name") plugin_name = request.args.get("name")
logger.debug(f"正在获取插件 {plugin_name} 的README文件内容") logger.debug(f"正在获取插件 {plugin_name} 的README文件内容")
@@ -360,9 +370,11 @@ class PluginRoute(Route):
with open(readme_path, "r", encoding="utf-8") as f: with open(readme_path, "r", encoding="utf-8") as f:
readme_content = f.read() readme_content = f.read()
cleaned_content = nh3.clean(readme_content)
return ( return (
Response() Response()
.ok({"content": readme_content}, "成功获取README内容") .ok({"content": cleaned_content}, "成功获取README内容")
.__dict__ .__dict__
) )
except Exception as e: except Exception as e:
@@ -383,14 +395,12 @@ class PluginRoute(Route):
platform_type = platform.get("type", "") platform_type = platform.get("type", "")
platform_id = platform.get("id", "") platform_id = platform.get("id", "")
platforms.append( platforms.append({
{ "name": platform_id, # 使用type作为name这是系统内部使用的平台名称
"name": platform_id, # 使用type作为name这是系统内部使用的平台名称 "id": platform_id, # 保留id字段以便前端可以显示
"id": platform_id, # 保留id字段以便前端可以显示 "type": platform_type,
"type": platform_type, "display_name": f"{platform_type}({platform_id})",
"display_name": f"{platform_type}({platform_id})", })
}
)
adjusted_platform_enable = {} adjusted_platform_enable = {}
for platform_id, plugins in platform_enable.items(): for platform_id, plugins in platform_enable.items():
@@ -399,13 +409,11 @@ class PluginRoute(Route):
# 获取所有插件,包括系统内部插件 # 获取所有插件,包括系统内部插件
plugins = [] plugins = []
for plugin in self.plugin_manager.context.get_all_stars(): for plugin in self.plugin_manager.context.get_all_stars():
plugins.append( plugins.append({
{ "name": plugin.name,
"name": plugin.name, "desc": plugin.desc,
"desc": plugin.desc, "reserved": plugin.reserved, # 添加reserved标志
"reserved": plugin.reserved, # 添加reserved标志 })
}
)
logger.debug( logger.debug(
f"获取插件平台配置: 原始配置={platform_enable}, 调整后={adjusted_platform_enable}" f"获取插件平台配置: 原始配置={platform_enable}, 调整后={adjusted_platform_enable}"
@@ -413,13 +421,11 @@ class PluginRoute(Route):
return ( return (
Response() Response()
.ok( .ok({
{ "platforms": platforms,
"platforms": platforms, "plugins": plugins,
"plugins": plugins, "platform_enable": adjusted_platform_enable,
"platform_enable": adjusted_platform_enable, })
}
)
.__dict__ .__dict__
) )
except Exception as e: except Exception as e:

View File

@@ -8,6 +8,7 @@ from quart import request
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.db import BaseDatabase from astrbot.core.db import BaseDatabase
from astrbot.core.config import VERSION from astrbot.core.config import VERSION
from astrbot.core.utils.io import get_dashboard_version
from astrbot.core import DEMO_MODE from astrbot.core import DEMO_MODE
@@ -46,7 +47,10 @@ class StatRoute(Route):
return f"{h}小时{m}{s}" return f"{h}小时{m}{s}"
async def get_version(self): 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): async def get_start_time(self):
return Response().ok({"start_time": self.core_lifecycle.start_time}).__dict__ return Response().ok({"start_time": self.core_lifecycle.start_time}).__dict__

View File

@@ -12,7 +12,7 @@ class StaticFileRoute(Route):
"/logs", "/logs",
"/extension", "/extension",
"/dashboard/default", "/dashboard/default",
"/project-atri", "/alkaid",
"/console", "/console",
"/chat", "/chat",
"/settings", "/settings",

View File

@@ -91,7 +91,7 @@ class UpdateRoute(Route):
# pip 更新依赖 # pip 更新依赖
logger.info("更新依赖中...") logger.info("更新依赖中...")
try: try:
pip_installer.install(requirements_path="requirements.txt") await pip_installer.install(requirements_path="requirements.txt")
except Exception as e: except Exception as e:
logger.error(f"更新依赖失败: {e}") logger.error(f"更新依赖失败: {e}")
@@ -140,7 +140,7 @@ class UpdateRoute(Route):
if not package: if not package:
return Response().error("缺少参数 package 或不合法。").__dict__ return Response().error("缺少参数 package 或不合法。").__dict__
try: try:
pip_installer.install(package, mirror=mirror) await pip_installer.install(package, mirror=mirror)
return Response().ok(None, "安装成功。").__dict__ return Response().ok(None, "安装成功。").__dict__
except Exception as e: except Exception as e:
logger.error(f"/api/update_pip: {traceback.format_exc()}") logger.error(f"/api/update_pip: {traceback.format_exc()}")

View File

@@ -15,6 +15,8 @@ from astrbot.core.db import BaseDatabase
from astrbot.core.utils.io import get_local_ip_addresses from astrbot.core.utils.io import get_local_ip_addresses
from astrbot.core.utils.astrbot_path import get_astrbot_data_path from astrbot.core.utils.astrbot_path import get_astrbot_data_path
APP: Quart = None
class AstrBotDashboard: class AstrBotDashboard:
def __init__( def __init__(
@@ -27,6 +29,7 @@ class AstrBotDashboard:
self.config = core_lifecycle.astrbot_config self.config = core_lifecycle.astrbot_config
self.data_path = os.path.abspath(os.path.join(get_astrbot_data_path(), "dist")) 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="/") self.app = Quart("dashboard", static_folder=self.data_path, static_url_path="/")
APP = self.app # noqa
self.app.config["MAX_CONTENT_LENGTH"] = ( self.app.config["MAX_CONTENT_LENGTH"] = (
128 * 1024 * 1024 128 * 1024 * 1024
) # 将 Flask 允许的最大上传文件体大小设置为 128 MB ) # 将 Flask 允许的最大上传文件体大小设置为 128 MB
@@ -51,12 +54,29 @@ class AstrBotDashboard:
self.conversation_route = ConversationRoute(self.context, db, core_lifecycle) self.conversation_route = ConversationRoute(self.context, db, core_lifecycle)
self.file_route = FileRoute(self.context) 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 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): async def auth_middleware(self):
if not request.path.startswith("/api"): if not request.path.startswith("/api"):
return return
allowed_endpoints = ["/api/auth/login", "/api/chat/get_file", "/api/file"] allowed_endpoints = ["/api/auth/login", "/api/file"]
if any(request.path.startswith(prefix) for prefix in allowed_endpoints): if any(request.path.startswith(prefix) for prefix in allowed_endpoints):
return return
# claim jwt # claim jwt

7
changelogs/v3.5.11.md Normal file
View 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
View 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

9
changelogs/v3.5.13.md Normal file
View File

@@ -0,0 +1,9 @@
# What's Changed
1. 新增WebUI 支持暗夜模式。
2. 修复:修复 WebUI Chat 接口的未授权访问安全漏洞、插件 README 可能存在的 XSS 注入漏洞。
3. 优化:优化 Vec DB 在 indexing 过程时的数据库事务处理。
4. 修复WebUI 下,插件市场的推荐卡片无法点击帮助文档的问题。
5. 新增:知识库。
6. 新增WebUI 提供商测试功能,一键检测可用性。
7. 新增WebUI 提供商分类功能,按能力分类提供商。

View File

@@ -20,6 +20,7 @@
"axios": "^1.6.2", "axios": "^1.6.2",
"axios-mock-adapter": "^1.22.0", "axios-mock-adapter": "^1.22.0",
"chance": "1.1.11", "chance": "1.1.11",
"d3": "^7.9.0",
"date-fns": "2.30.0", "date-fns": "2.30.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"js-md5": "^0.8.3", "js-md5": "^0.8.3",

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -340,12 +340,12 @@ export default {
.config-title { .config-title {
font-weight: 600; font-weight: 600;
font-size: 1rem; font-size: 1rem;
color: var(--v-primary-darken1); color: var(--v-theme-primaryText);
} }
.config-hint { .config-hint {
font-size: 0.75rem; font-size: 0.75rem;
color: rgba(0, 0, 0, 0.6); color: var(--v-theme-secondaryText);
margin-top: 2px; margin-top: 2px;
} }
@@ -400,12 +400,12 @@ export default {
.property-name { .property-name {
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
color: rgba(0, 0, 0, 0.87); color: var(--v-theme-primaryText);
} }
.property-hint { .property-hint {
font-size: 0.75rem; font-size: 0.75rem;
color: rgba(0, 0, 0, 0.6); color: var(--v-theme-secondaryText);
margin-top: 2px; margin-top: 2px;
} }

View File

@@ -5,7 +5,7 @@ import { useCommonStore } from '@/stores/common';
<template> <template>
<div> <div>
<!-- 添加筛选级别控件 --> <!-- 添加筛选级别控件 -->
<div class="filter-controls mb-2"> <div class="filter-controls mb-2" v-if="showLevelBtns">
<v-chip-group v-model="selectedLevels" column multiple> <v-chip-group v-model="selectedLevels" column multiple>
<v-chip v-for="level in logLevels" :key="level" :color="getLevelColor(level)" filter <v-chip v-for="level in logLevels" :key="level" :color="getLevelColor(level)" filter
:text-color="level === 'DEBUG' || level === 'INFO' ? 'black' : 'white'"> :text-color="level === 'DEBUG' || level === 'INFO' ? 'black' : 'white'">
@@ -52,6 +52,10 @@ export default {
historyNum: { historyNum: {
type: String, type: String,
default: -1 default: -1
},
showLevelBtns: {
type: Boolean,
default: true
} }
}, },
watch: { watch: {

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, inject } from 'vue'; import { ref, computed, inject } from 'vue';
import {useCustomizerStore} from "@/stores/customizer";
const props = defineProps({ const props = defineProps({
extension: { extension: {
@@ -75,7 +76,9 @@ const viewReadme = () => {
<template> <template>
<v-card class="mx-auto d-flex flex-column" :elevation="highlight ? 0 : 1" <v-card class="mx-auto d-flex flex-column" :elevation="highlight ? 0 : 1"
:style="{ height: $vuetify.display.xs ? '250px' : '220px', backgroundColor: highlight ? '#FAF0DB' : '#ffffff', color: highlight ? '#000' : '#000000' }"> :style="{ height: $vuetify.display.xs ? '250px' : '220px',
backgroundColor: useCustomizerStore().uiTheme==='PurpleTheme' ? marketMode ? '#f8f0dd' : '#ffffff' : '#282833',
color: useCustomizerStore().uiTheme==='PurpleTheme' ? '#000000dd' : '#ffffff'}">
<v-card-text style="padding: 16px; padding-bottom: 0px; display: flex; justify-content: space-between;"> <v-card-text style="padding: 16px; padding-bottom: 0px; display: flex; justify-content: space-between;">
<div class="flex-grow-1"> <div class="flex-grow-1">
@@ -128,7 +131,7 @@ const viewReadme = () => {
</div> </div>
</v-card-text> </v-card-text>
<v-card-actions style="padding: 0px; margin-top: auto;"> <v-card-actions style="margin-left: 0px; gap: 2px;">
<v-btn color="teal-accent-4" text="查看文档" variant="text" @click="viewReadme"></v-btn> <v-btn color="teal-accent-4" text="查看文档" variant="text" @click="viewReadme"></v-btn>
<v-btn v-if="!marketMode" color="teal-accent-4" text="操作" variant="text" @click="reveal = true"></v-btn> <v-btn v-if="!marketMode" color="teal-accent-4" text="操作" variant="text" @click="reveal = true"></v-btn>
<v-btn v-if="marketMode && !extension?.installed" color="teal-accent-4" text="安装" variant="text" <v-btn v-if="marketMode && !extension?.installed" color="teal-accent-4" text="安装" variant="text"

View File

@@ -104,11 +104,11 @@ export default {
<style scoped> <style scoped>
.list-config-item { .list-config-item {
border: 1px solid #e0e0e0; border: 1px solid var(--v-theme-border);
padding: 16px; padding: 16px;
margin-bottom: 8px; margin-bottom: 8px;
border-radius: 10px; border-radius: 10px;
background-color: #ffffff; background-color: var(--v-theme-background);
} }
.v-list-item { .v-list-item {

View File

@@ -0,0 +1,72 @@
<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>
<!-- 父子组件传递css变量可能会出错暂时使用十六进制颜色值 -->
<h4 :style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#000000aa' : '#ffffffcc'}"
class="hint-text">登录以继续</h4>
</div>
</div>
</div>
</template>
<script setup lang="ts">
// No props or other logic needed for this simple component
import {useCustomizerStore} from "@/stores/customizer";
</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;
letter-spacing: 0.5px;
}
.logo-text h4 {
margin: 4px 0 0 0;
font-size: 1rem;
font-weight: 400;
letter-spacing: 0.3px;
}
</style>

View File

@@ -3,14 +3,25 @@ export type ConfigProps = {
Customizer_drawer: boolean; Customizer_drawer: boolean;
mini_sidebar: boolean; mini_sidebar: boolean;
fontTheme: string; fontTheme: string;
uiTheme: string;
inputBg: boolean; inputBg: boolean;
}; };
function checkUITheme() {
const theme = localStorage.getItem("uiTheme");
console.log('memorized theme: ', theme);
if (!theme || !(['PurpleTheme', 'PurpleThemeDark'].includes(theme))) {
localStorage.setItem("uiTheme", "PurpleTheme");
return 'PurpleTheme';
} else return theme;
}
const config: ConfigProps = { const config: ConfigProps = {
Sidebar_drawer: true, Sidebar_drawer: true,
Customizer_drawer: false, Customizer_drawer: false,
mini_sidebar: false, mini_sidebar: false,
fontTheme: 'Roboto', fontTheme: 'Roboto',
uiTheme: checkUITheme(),
inputBg: false inputBg: false
}; };

View File

@@ -9,7 +9,7 @@ const customizer = useCustomizerStore();
<template> <template>
<v-locale-provider> <v-locale-provider>
<v-app <v-app
theme="PurpleTheme" :theme="useCustomizerStore().uiTheme"
:class="[customizer.fontTheme, customizer.mini_sidebar ? 'mini-sidebar' : '', customizer.inputBg ? 'inputWithbg' : '']" :class="[customizer.fontTheme, customizer.mini_sidebar ? 'mini-sidebar' : '', customizer.inputBg ? 'inputWithbg' : '']"
> >
<VerticalHeaderVue /> <VerticalHeaderVue />

View File

@@ -1,11 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import {ref} from 'vue';
import { useCustomizerStore } from '../../../stores/customizer'; import {useCustomizerStore} from '@/stores/customizer';
import axios from 'axios'; import axios from 'axios';
import { md5 } from 'js-md5'; import {md5} from 'js-md5';
import { useAuthStore } from '@/stores/auth'; import {useAuthStore} from '@/stores/auth';
import { useCommonStore } from '@/stores/common'; import {useCommonStore} from '@/stores/common';
import { marked } from 'marked'; import {marked} from 'marked';
const customizer = useCustomizerStore(); const customizer = useCustomizerStore();
let dialog = ref(false); let dialog = ref(false);
@@ -30,11 +30,11 @@ let installLoading = ref(false);
let tab = ref(0); let tab = ref(0);
let releasesHeader = [ let releasesHeader = [
{ title: '标签', key: 'tag_name' }, {title: '标签', key: 'tag_name'},
{ title: '发布时间', key: 'published_at' }, {title: '发布时间', key: 'published_at'},
{ title: '内容', key: 'body' }, {title: '内容', key: 'body'},
{ title: '源码地址', key: 'zipball_url' }, {title: '源码地址', key: 'zipball_url'},
{ title: '操作', key: 'switch' } {title: '操作', key: 'switch'}
]; ];
const open = (link: string) => { const open = (link: string) => {
@@ -56,69 +56,78 @@ function accountEdit() {
new_password: newPassword.value, new_password: newPassword.value,
new_username: newUsername.value new_username: newUsername.value
}) })
.then((res) => { .then((res) => {
if (res.data.status == 'error') { if (res.data.status == 'error') {
status.value = res.data.message;
password.value = '';
newPassword.value = '';
return;
}
dialog.value = !dialog.value;
status.value = res.data.message; status.value = res.data.message;
setTimeout(() => {
const authStore = useAuthStore();
authStore.logout();
}, 1000);
})
.catch((err) => {
console.log(err);
status.value = err
password.value = ''; password.value = '';
newPassword.value = ''; newPassword.value = '';
return; });
} }
dialog.value = !dialog.value;
status.value = res.data.message; function getVersion() {
setTimeout(() => { axios.get('/api/stat/version')
const authStore = useAuthStore(); .then((res) => {
authStore.logout(); botCurrVersion.value = "v" + res.data.data.version;
}, 1000); dashboardCurrentVersion.value = res.data.data?.dashboard_version;
}) })
.catch((err) => { .catch((err) => {
console.log(err); console.log(err);
status.value = err });
password.value = '';
newPassword.value = '';
});
} }
function checkUpdate() { function checkUpdate() {
updateStatus.value = '正在检查更新...'; updateStatus.value = '正在检查更新...';
axios.get('/api/update/check') axios.get('/api/update/check')
.then((res) => { .then((res) => {
hasNewVersion.value = res.data.data.has_new_version; hasNewVersion.value = res.data.data.has_new_version;
if (res.data.data.has_new_version) { if (res.data.data.has_new_version) {
releaseMessage.value = res.data.message; releaseMessage.value = res.data.message;
updateStatus.value = '有新版本!'; updateStatus.value = '有新版本!';
} else { } else {
updateStatus.value = res.data.message; updateStatus.value = res.data.message;
} }
botCurrVersion.value = res.data.data.version; dashboardHasNewVersion.value = res.data.data.dashboard_has_new_version;
dashboardCurrentVersion.value = res.data.data.dashboard_version; })
dashboardHasNewVersion.value = res.data.data.dashboard_has_new_version; .catch((err) => {
}) if (err.response.status == 401) {
.catch((err) => { console.log("401");
if (err.response.status == 401) { const authStore = useAuthStore();
console.log("401"); authStore.logout();
const authStore = useAuthStore(); return;
authStore.logout(); }
return; console.log(err);
} updateStatus.value = err
console.log(err); });
updateStatus.value = err
});
} }
function getReleases() { function getReleases() {
axios.get('/api/update/releases') axios.get('/api/update/releases')
.then((res) => { .then((res) => {
// releases.value = res.data.data; // releases.value = res.data.data;
// 更新 published_at 的时间为本地时间 // 更新 published_at 的时间为本地时间
releases.value = res.data.data.map((item: any) => { releases.value = res.data.data.map((item: any) => {
item.published_at = new Date(item.published_at).toLocaleString(); item.published_at = new Date(item.published_at).toLocaleString();
return item; return item;
})
}) })
}) .catch((err) => {
.catch((err) => { console.log(err);
console.log(err); });
});
} }
function getDevCommits() { function getDevCommits() {
@@ -128,17 +137,17 @@ function getDevCommits() {
'Referer': 'https://api.github.com' 'Referer': 'https://api.github.com'
} }
}) })
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
devCommits.value = data.map((commit: any) => ({ devCommits.value = data.map((commit: any) => ({
sha: commit.sha, sha: commit.sha,
date: new Date(commit.commit.author.date).toLocaleString(), date: new Date(commit.commit.author.date).toLocaleString(),
message: commit.commit.message message: commit.commit.message
})); }));
}) })
.catch(err => { .catch(err => {
console.log(err); console.log(err);
}); });
} }
function switchVersion(version: string) { function switchVersion(version: string) {
@@ -148,39 +157,44 @@ function switchVersion(version: string) {
version: version, version: version,
proxy: localStorage.getItem('selectedGitHubProxy') || '' proxy: localStorage.getItem('selectedGitHubProxy') || ''
}) })
.then((res) => { .then((res) => {
updateStatus.value = res.data.message; updateStatus.value = res.data.message;
if (res.data.status == 'ok') { if (res.data.status == 'ok') {
setTimeout(() => { setTimeout(() => {
window.location.reload(); window.location.reload();
}, 1000); }, 1000);
} }
}) })
.catch((err) => { .catch((err) => {
console.log(err); console.log(err);
updateStatus.value = err updateStatus.value = err
}).finally(() => { }).finally(() => {
installLoading.value = false; installLoading.value = false;
}); });
} }
function updateDashboard() { function updateDashboard() {
updateStatus.value = '正在更新...'; updateStatus.value = '正在更新...';
axios.post('/api/update/dashboard') axios.post('/api/update/dashboard')
.then((res) => { .then((res) => {
updateStatus.value = res.data.message; updateStatus.value = res.data.message;
if (res.data.status == 'ok') { if (res.data.status == 'ok') {
setTimeout(() => { setTimeout(() => {
window.location.reload(); window.location.reload();
}, 1000); }, 1000);
} }
}) })
.catch((err) => { .catch((err) => {
console.log(err); console.log(err);
updateStatus.value = err updateStatus.value = err
}); });
} }
function toggleDarkMode() {
customizer.SET_UI_THEME(customizer.uiTheme === 'PurpleThemeDark' ? 'PurpleTheme' : 'PurpleThemeDark');
}
getVersion();
checkUpdate(); checkUpdate();
const commonStore = useCommonStore(); const commonStore = useCommonStore();
@@ -199,32 +213,53 @@ if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('cha
<template> <template>
<v-app-bar elevation="0" height="55"> <v-app-bar elevation="0" height="55">
<v-btn style="margin-left: 22px;" class="hidden-md-and-down text-secondary" color="lightsecondary" icon rounded="sm" <v-btn v-if="useCustomizerStore().uiTheme==='PurpleTheme'" style="margin-left: 22px;" class="hidden-md-and-down text-secondary" color="lightsecondary" icon rounded="sm"
variant="flat" @click.stop="customizer.SET_MINI_SIDEBAR(!customizer.mini_sidebar)" size="small"> variant="flat" @click.stop="customizer.SET_MINI_SIDEBAR(!customizer.mini_sidebar)" size="small">
<v-icon>mdi-menu</v-icon> <v-icon>mdi-menu</v-icon>
</v-btn> </v-btn>
<v-btn class="hidden-lg-and-up text-secondary ms-3" color="lightsecondary" icon rounded="sm" variant="flat" <v-btn v-else style="margin-left: 22px; color: var(--v-theme-primaryText); background-color: var(--v-theme-secondary)" class="hidden-md-and-down" icon rounded="sm"
@click.stop="customizer.SET_SIDEBAR_DRAWER" size="small"> variant="flat" @click.stop="customizer.SET_MINI_SIDEBAR(!customizer.mini_sidebar)" size="small">
<v-icon>mdi-menu</v-icon>
</v-btn>
<v-btn v-if="useCustomizerStore().uiTheme==='PurpleTheme'" class="hidden-lg-and-up text-secondary ms-3" color="lightsecondary" icon rounded="sm" variant="flat"
@click.stop="customizer.SET_SIDEBAR_DRAWER" size="small">
<v-icon>mdi-menu</v-icon>
</v-btn>
<v-btn v-else class="hidden-lg-and-up ms-3" icon rounded="sm" variant="flat"
@click.stop="customizer.SET_SIDEBAR_DRAWER" size="small">
<v-icon>mdi-menu</v-icon> <v-icon>mdi-menu</v-icon>
</v-btn> </v-btn>
<span style="margin-left: 16px; font-size: 24px; font-weight: 1000;">Astr<span <div style="margin-left: 16px; display: flex; align-items: center; gap: 8px;">
style="font-weight: normal;">Bot</span></span> <span style=" font-size: 24px; font-weight: 1000;">Astr<span style="font-weight: normal;">Bot</span>
</span>
<span style="font-size: 12px; color: var(--v-theme-secondaryText);">{{ botCurrVersion }}</span>
</div>
<v-spacer /> <v-spacer/>
<div class="mr-4"> <div class="mr-4">
<small v-if="hasNewVersion"> <small v-if="hasNewVersion">
有新版本 AstrBot 有新版本
</small>
<small v-else-if="dashboardHasNewVersion">
WebUI 有新版本
</small> </small>
</div> </div>
<v-btn size="small" @click="toggleDarkMode();" class="text-primary mr-2" color="var(--v-theme-surface)"
variant="flat" rounded="sm">
<!-- 明暗主题切换按钮 -->
<v-icon v-if="useCustomizerStore().uiTheme === 'PurpleThemeDark'">mdi-weather-night</v-icon>
<v-icon v-else>mdi-white-balance-sunny</v-icon>
</v-btn>
<v-dialog v-model="updateStatusDialog" width="1000"> <v-dialog v-model="updateStatusDialog" width="1000">
<template v-slot:activator="{ props }"> <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"
variant="flat" rounded="sm" v-bind="props"> color="var(--v-theme-surface)"
更新 🔄 variant="flat" rounded="sm" v-bind="props">
更新
</v-btn> </v-btn>
</template> </template>
<v-card> <v-card>
@@ -241,15 +276,16 @@ if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('cha
</div> </div>
<div <div
style="background-color: #646cff24; padding: 16px; border-radius: 10px; font-size: 14px; max-height: 400px; overflow-y: auto;" style="background-color: #646cff24; padding: 16px; border-radius: 10px; font-size: 14px; max-height: 400px; overflow-y: auto;"
v-html="marked(releaseMessage)" class="markdown-content"> v-html="marked(releaseMessage)" class="markdown-content">
</div> </div>
<div class="mb-4 mt-4"> <div class="mb-4 mt-4">
<small>💡 TIP: 跳到旧版本或者切换到某个版本不会重新下载管理面板文件这可能会造成部分数据显示错误您可在 <a <small>💡 TIP: 跳到旧版本或者切换到某个版本不会重新下载管理面板文件这可能会造成部分数据显示错误您可在 <a
href="https://github.com/Soulter/AstrBot/releases">此处</a> href="https://github.com/Soulter/AstrBot/releases">此处</a>
找到对应的面板文件 dist.zip解压后替换 data/dist 文件夹即可当然前端源代码在 dashboard 目录下你也可以自己使用 npm install npm build 找到对应的面板文件 dist.zip解压后替换 data/dist 文件夹即可当然前端源代码在 dashboard 目录下你也可以自己使用
npm install npm build
构建</small> 构建</small>
</div> </div>
@@ -262,12 +298,13 @@ if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('cha
<!-- 发行版 --> <!-- 发行版 -->
<v-tabs-window-item key="0" v-show="tab == 0"> <v-tabs-window-item key="0" v-show="tab == 0">
<v-btn class="mt-4 mb-4" @click="switchVersion('latest')" color="primary" style="border-radius: 10px;" <v-btn class="mt-4 mb-4" @click="switchVersion('latest')" color="primary" style="border-radius: 10px;"
:disabled="!hasNewVersion"> :disabled="!hasNewVersion">
更新到最新版本 更新到最新版本
</v-btn> </v-btn>
<div class="mb-4"> <div class="mb-4">
<small>`更新到最新版本` 按钮会同时尝试更新机器人主程序和管理面板如果您正在使用 Docker 部署也可以重新拉取镜像或者使用 <a <small>`更新到最新版本` 按钮会同时尝试更新机器人主程序和管理面板如果您正在使用 Docker
href="https://containrrr.dev/watchtower/usage-overview/">watchtower</a> 来自动监控拉取</small> 部署也可以重新拉取镜像或者使用 <a
href="https://containrrr.dev/watchtower/usage-overview/">watchtower</a> 来自动监控拉取</small>
</div> </div>
<v-data-table :headers="releasesHeader" :items="releases" item-key="name"> <v-data-table :headers="releasesHeader" :items="releases" item-key="name">
@@ -290,8 +327,8 @@ if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('cha
<v-tabs-window-item key="1" v-show="tab == 1"> <v-tabs-window-item key="1" v-show="tab == 1">
<div style="margin-top: 16px;"> <div style="margin-top: 16px;">
<v-data-table <v-data-table
:headers="[{ title: 'SHA', key: 'sha' }, { title: '日期', key: 'date' }, { title: '信息', key: 'message' }, { title: '操作', key: 'switch' }]" :headers="[{ title: 'SHA', key: 'sha' }, { title: '日期', key: 'date' }, { title: '信息', key: 'message' }, { title: '操作', key: 'switch' }]"
:items="devCommits" item-key="sha"> :items="devCommits" item-key="sha">
<template v-slot:item.switch="{ item }: { item: { sha: string } }"> <template v-slot:item.switch="{ item }: { item: { sha: string } }">
<v-btn @click="switchVersion(item.sha)" rounded="xl" variant="plain" color="primary"> <v-btn @click="switchVersion(item.sha)" rounded="xl" variant="plain" color="primary">
切换 切换
@@ -306,12 +343,13 @@ if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('cha
<h3 class="mb-4">手动输入版本号或 Commit SHA</h3> <h3 class="mb-4">手动输入版本号或 Commit SHA</h3>
<v-text-field label="输入版本号或 master 分支下的 commit hash。" v-model="version" required <v-text-field label="输入版本号或 master 分支下的 commit hash。" v-model="version" required
variant="outlined"></v-text-field> variant="outlined"></v-text-field>
<div class="mb-4"> <div class="mb-4">
<small> v3.3.16 (不带 SHA) 42e5ec5d80b93b6bfe8b566754d45ffac4c3fe0b</small> <small> v3.3.16 (不带 SHA) 42e5ec5d80b93b6bfe8b566754d45ffac4c3fe0b</small>
<br> <br>
<a href="https://github.com/Soulter/AstrBot/commits/master"><small>查看 master 分支提交记录点击右边的 copy <a href="https://github.com/Soulter/AstrBot/commits/master"><small>查看 master 分支提交记录点击右边的
即可复制</small></a> copy
即可复制</small></a>
</div> </div>
<v-btn color="error" style="border-radius: 10px;" @click="switchVersion(version)"> <v-btn color="error" style="border-radius: 10px;" @click="switchVersion(version)">
确定切换 确定切换
@@ -336,7 +374,7 @@ if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('cha
</div> </div>
<v-btn color="primary" style="border-radius: 10px;" @click="updateDashboard()" <v-btn color="primary" style="border-radius: 10px;" @click="updateDashboard()"
:disabled="!dashboardHasNewVersion"> :disabled="!dashboardHasNewVersion">
下载并更新 下载并更新
</v-btn> </v-btn>
</div> </div>
@@ -353,8 +391,8 @@ if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('cha
<v-dialog v-model="dialog" persistent width="700"> <v-dialog v-model="dialog" persistent width="700">
<template v-slot:activator="{ props }"> <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="var(--v-theme-surface)" variant="flat" rounded="sm" v-bind="props">
账户 📰 账户
</v-btn> </v-btn>
</template> </template>
<v-card> <v-card>
@@ -367,16 +405,16 @@ if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('cha
<v-col cols="12"> <v-col cols="12">
<v-alert v-if="accountWarning" color="warning" style="margin-bottom: 16px;"> <v-alert v-if="accountWarning" color="warning" style="margin-bottom: 16px;">
<div>为了安全尽快修改默认密码</div> <div>为了安全务必修改默认密码</div>
</v-alert> </v-alert>
<v-text-field label="原密码*" type="password" v-model="password" required <v-text-field label="原密码*" type="password" v-model="password" required
variant="outlined"></v-text-field> variant="outlined"></v-text-field>
<v-text-field label="新用户名" v-model="newUsername" required variant="outlined"></v-text-field> <v-text-field label="新用户名" v-model="newUsername" required variant="outlined"></v-text-field>
<v-text-field label="新密码" type="password" v-model="newPassword" required <v-text-field label="新密码" type="password" v-model="newPassword" required
variant="outlined"></v-text-field> variant="outlined"></v-text-field>
</v-col> </v-col>
</v-row> </v-row>
</v-container> </v-container>
@@ -386,7 +424,7 @@ if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('cha
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
<v-spacer></v-spacer> <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>
<v-btn color="blue-darken-1" variant="text" @click="accountEdit"> <v-btn color="blue-darken-1" variant="text" @click="accountEdit">

View File

@@ -9,9 +9,6 @@ const customizer = useCustomizerStore();
const sidebarMenu = shallowRef(sidebarItems); const sidebarMenu = shallowRef(sidebarItems);
const showIframe = ref(false); const showIframe = ref(false);
const version = ref("");
const buildVer = ref("");
const hasWebUIUpdate = ref(false);
// 默认桌面端 iframe 样式 // 默认桌面端 iframe 样式
const iframeStyle = ref({ const iframeStyle = ref({
@@ -68,9 +65,10 @@ function toggleIframe() {
showIframe.value = !showIframe.value; showIframe.value = !showIframe.value;
} }
function openIframeLink() { function openIframeLink(url) {
if (typeof window !== 'undefined') { 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); 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> </script>
<template> <template>
@@ -186,27 +165,23 @@ onMounted(() => {
<NavItem :item="item" class="leftPadding" /> <NavItem :item="item" class="leftPadding" />
</template> </template>
</v-list> </v-list>
<div class="text-center"> <div style="position: absolute; bottom: 16px; width: 100%; font-size: 13px;" class="text-center">
<v-chip color="inputBorder" size="small"> {{ version }} </v-chip> <v-btn style="margin-bottom: 8px;" size="small" variant="primary" v-if="!customizer.mini_sidebar" to="/settings">
</div> 🔧 设置
<div style="position: absolute; bottom: 32px; width: 100%; font-size: 13px;" class="text-center"> </v-btn>
<v-list-item v-if="!customizer.mini_sidebar" @click="toggleIframe"> <br/>
<v-btn variant="plain" size="small"> <v-btn style="margin-bottom: 8px;" size="small" variant="plain" v-if="!customizer.mini_sidebar" @click="toggleIframe">
🤔 点击此处 查看/关闭 悬浮文档 官方文档
</v-btn> </v-btn>
</v-list-item> <br/>
<small style="display: block;" v-if="buildVer">WebUI 版本: {{ buildVer }}</small> <v-btn style="margin-bottom: 8px;" size="small" variant="plain" v-if="!customizer.mini_sidebar" @click="openIframeLink('https://github.com/AstrBotDevs/AstrBot')">
<small style="display: block;" v-else>构建: embedded</small> GitHub
<v-tooltip text="使用 /dashboard_update 指令更新管理面板"> </v-btn>
<template v-slot:activator="{ props }"> <br/>
<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> </div>
</v-navigation-drawer> </v-navigation-drawer>
<!-- 优化后的悬浮 iframe -->
<div <div
v-if="showIframe" v-if="showIframe"
id="draggable-iframe" id="draggable-iframe"

View File

@@ -66,9 +66,9 @@ const sidebarItem: menu[] = [
to: '/console' to: '/console'
}, },
{ {
title: '设置', title: 'Alkaid',
icon: 'mdi-wrench', icon: 'mdi-test-tube',
to: '/settings' to: '/alkaid'
}, },
{ {
title: '关于', title: '关于',

View File

@@ -3,6 +3,7 @@ import '@mdi/font/css/materialdesignicons.css';
import * as components from 'vuetify/components'; import * as components from 'vuetify/components';
import * as directives from 'vuetify/directives'; import * as directives from 'vuetify/directives';
import { PurpleTheme } from '@/theme/LightTheme'; import { PurpleTheme } from '@/theme/LightTheme';
import { PurpleThemeDark } from "@/theme/DarkTheme";
export default createVuetify({ export default createVuetify({
components, components,
@@ -11,7 +12,8 @@ export default createVuetify({
theme: { theme: {
defaultTheme: 'PurpleTheme', defaultTheme: 'PurpleTheme',
themes: { themes: {
PurpleTheme PurpleTheme,
PurpleThemeDark
} }
}, },
defaults: { defaults: {

View File

@@ -57,9 +57,26 @@ const MainRoutes = {
component: () => import('@/views/ConsolePage.vue') component: () => import('@/views/ConsolePage.vue')
}, },
{ {
name: 'Project ATRI', name: 'Alkaid',
path: '/project-atri', path: '/alkaid',
component: () => import('@/views/ATRIProject.vue') 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', name: 'Chat',

View File

@@ -24,6 +24,11 @@ router.beforeEach(async (to, from, next) => {
const authRequired = !publicPages.includes(to.path); const authRequired = !publicPages.includes(to.path);
const auth: AuthStore = useAuthStore(); 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 (to.matched.some((record) => record.meta.requiresAuth)) {
if (authRequired && !auth.has_token()) { if (authRequired && !auth.has_token()) {
auth.returnUrl = to.fullPath; auth.returnUrl = to.fullPath;

View File

@@ -6,7 +6,7 @@
.listitem { .listitem {
height: calc(100vh - 100px); height: calc(100vh - 100px);
.v-list { .v-list {
color: rgb(var(--v-theme-lightText)); color: rgb(var(--v-theme-secondaryText));
} }
.v-list-group__items .v-list-item, .v-list-group__items .v-list-item,
.v-list-item { .v-list-item {

View File

@@ -8,6 +8,7 @@ export const useCustomizerStore = defineStore({
Customizer_drawer: config.Customizer_drawer, Customizer_drawer: config.Customizer_drawer,
mini_sidebar: config.mini_sidebar, mini_sidebar: config.mini_sidebar,
fontTheme: "Poppins", fontTheme: "Poppins",
uiTheme: config.uiTheme,
inputBg: config.inputBg inputBg: config.inputBg
}), }),
@@ -21,6 +22,10 @@ export const useCustomizerStore = defineStore({
}, },
SET_FONT(payload: string) { SET_FONT(payload: string) {
this.fontTheme = payload; this.fontTheme = payload;
} },
SET_UI_THEME(payload: string) {
this.uiTheme = payload;
localStorage.setItem("uiTheme", payload);
},
} }
}); });

View File

@@ -0,0 +1,46 @@
import type { ThemeTypes } from '@/types/themeTypes/ThemeType';
const PurpleThemeDark: ThemeTypes = {
name: 'PurpleThemeDark',
dark: true,
variables: {
'border-color': '#1677ff',
'carousel-control-size': 10
},
colors: {
primary: '#1677ff',
secondary: '#722ed1',
info: '#03c9d7',
success: '#52c41a',
accent: '#FFAB91',
warning: '#faad14',
error: '#ff4d4f',
lightprimary: '#eef2f6',
lightsecondary: '#ede7f6',
lightsuccess: '#b9f6ca',
lighterror: '#f9d8d8',
lightwarning: '#fff8e1',
primaryText: '#ffffff',
secondaryText: '#ffffffcc',
darkprimary: '#1565c0',
darksecondary: '#4527a0',
borderLight: '#d0d0d0',
border: '#333333ee',
inputBorder: '#787878',
containerBg: '#1a1a1a',
surface: '#1f1f1f',
'on-surface-variant': '#000',
facebook: '#4267b2',
twitter: '#1da1f2',
linkedin: '#0e76a8',
gray100: '#cccccccc',
primary200: '#90caf9',
secondary200: '#b39ddb',
background: '#111111',
overlay: '#111111aa',
codeBg: '#282833',
code: '#ffffffdd'
}
};
export { PurpleThemeDark };

View File

@@ -20,11 +20,12 @@ const PurpleTheme: ThemeTypes = {
lightsuccess: '#b9f6ca', lightsuccess: '#b9f6ca',
lighterror: '#f9d8d8', lighterror: '#f9d8d8',
lightwarning: '#fff8e1', lightwarning: '#fff8e1',
darkText: '#212121', primaryText: '#000000dd',
lightText: '#616161', secondaryText: '#000000aa',
darkprimary: '#1565c0', darkprimary: '#1565c0',
darksecondary: '#4527a0', darksecondary: '#4527a0',
borderLight: '#d0d0d0', borderLight: '#d0d0d0',
border: '#d0d0d0',
inputBorder: '#787878', inputBorder: '#787878',
containerBg: '#eef2f6', containerBg: '#eef2f6',
surface: '#fff', surface: '#fff',
@@ -32,9 +33,13 @@ const PurpleTheme: ThemeTypes = {
facebook: '#4267b2', facebook: '#4267b2',
twitter: '#1da1f2', twitter: '#1da1f2',
linkedin: '#0e76a8', linkedin: '#0e76a8',
gray100: '#fafafa', gray100: '#fafafacc',
primary200: '#90caf9', primary200: '#90caf9',
secondary200: '#b39ddb' secondary200: '#b39ddb',
background: '#f9fafcf4',
overlay: '#ffffffaa',
codeBg: '#f5f0ff',
code: '#673ab7'
} }
}; };

View File

@@ -17,13 +17,15 @@ export type ThemeTypes = {
lightwarning?: string; lightwarning?: string;
darkprimary?: string; darkprimary?: string;
darksecondary?: string; darksecondary?: string;
darkText?: string; primaryText?: string;
lightText?: string; secondaryText?: string;
borderLight?: string; borderLight?: string;
border?: string;
inputBorder?: string; inputBorder?: string;
containerBg?: string; containerBg?: string;
surface?: string; surface?: string;
background?: string; background?: string;
overlay?: string;
'on-surface-variant'?: string; 'on-surface-variant'?: string;
facebook?: string; facebook?: string;
twitter?: string; twitter?: string;
@@ -31,5 +33,7 @@ export type ThemeTypes = {
gray100?: string; gray100?: string;
primary200?: string; primary200?: string;
secondary200?: string; secondary200?: string;
codeBg?: string;
code?: string;
}; };
}; };

View File

@@ -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>

View File

@@ -1,55 +1,95 @@
<template> <template>
<v-card style="height: 100%;"> <v-card style="height: 100%;" elevation="0" class="bg-surface">
<v-card-text style="padding: 0; height: 100%; overflow-y: auto;"> <v-card-text style="padding: 0; height: 100%; overflow-y: hidden;">
<div <div class="about-wrapper">
style="display: flex; justify-content: center; align-items: center; height: 100%; flex-direction: column;"> <!-- Hero Section -->
<div @click="selectedLogo = selectedLogo == 0 ? 1 : 0" style="height: 300px;"> <section class="hero-section">
<img v-if="selectedLogo == 0" width="300" src="@/assets/images/logo-waifu.png" alt="AstrBot Logo" <div class="logo-title-container">
class="fade-in"> <div @click="selectedLogo = selectedLogo == 0 ? 1 : 0" class="logo-container">
<img v-if="selectedLogo == 1" width="300" src="@/assets/images/logo-normal.svg" alt="AstrBot Logo" <img v-if="selectedLogo == 0" width="280" src="@/assets/images/logo-waifu.png" alt="AstrBot Logo" class="fade-in">
class="fade-in"> <img v-if="selectedLogo == 1" width="280" src="@/assets/images/logo-normal.svg" alt="AstrBot Logo" class="fade-in">
</div> </div>
<div class="title-container">
<h1 class="text-h2 font-weight-bold">AstrBot</h1>
<p class="text-subtitle-1" style="color: var(--v-theme-secondaryText);">A project out of interests and loves </p>
<div class="action-buttons">
<v-btn @click="open('https://github.com/Soulter/AstrBot')"
color="primary" variant="elevated" prepend-icon="mdi-star">
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: var(--v-theme-secondaryText);">
本项目由众多开源社区成员共同维护感谢每一位贡献者的付出
</p>
<p class="text-body-1" style="color: var(--v-theme-secondaryText);">
<a href="https://github.com/Soulter/AstrBot/graphs/contributors" class="text-decoration-none custom-link">查看 AstrBot 贡献者</a>
</p>
</v-col>
<v-col cols="12" md="6">
<v-card variant="outlined" class="overflow-hidden" elevation="2">
<v-img v-if="useCustomizerStore().uiTheme==='PurpleThemeDark'"
alt="Active Contributors of Soulter/AstrBot"
src="https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=575865240&limit=365&image_size=auto&color_scheme=dark">
</v-img>
<v-img v-else
alt="Active Contributors of Soulter/AstrBot"
src="https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=575865240&limit=365&image_size=auto&color_scheme=light">
</v-img>
</v-card>
</v-col>
</v-row>
</v-container>
</section>
<span class="mt-2" style="color: #777;">A project out of interests and loves </span> <!-- 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>
<span style="color: #777; margin-left: 32px; margin-right: 32px" class="mt-4">By <a <div class="license-container mt-8">
href="https://soulter.top">Soulter</a>, <a <img v-bind="props" src="https://www.gnu.org/graphics/agplv3-with-text-100x42.png" style="cursor: pointer;"/>
href="https://github.com/Soulter/AstrBot/graphs/contributors">AstrBot Contributors</a> <p class="text-caption mt-2" style="color: var(--v-theme-secondaryText);">AstrBot 采用 AGPL v3 协议开源</p>
and <a href="https://github.com/Soulter/AstrBot_Plugins_Collection/graphs/contributors">AstrBot </div>
Plugin Authors</a> </v-col>
</span> <v-col cols="12" md="6">
<v-card variant="outlined" class="overflow-hidden" elevation="2">
<!-- Copy-paste in your Readme.md file --> <v-img v-if="useCustomizerStore().uiTheme==='PurpleThemeDark'"
alt="Stars Map of Soulter/AstrBot"
<img style="margin-top: 16px; width: 50%; max-width: 500px; margin-left: 32px; margin-right: 32px" src="https://next.ossinsight.io/widgets/official/analyze-repo-stars-map/thumbnail.png?activity=stars&repo_id=575865240&image_size=auto&color_scheme=dark">
alt="Active Contributors of Soulter/AstrBot - Last 28 days" </v-img>
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-else
alt="Stars Map of Soulter/AstrBot"
<img style="margin-top: 16px; width: 50%; max-width: 500px; margin-left: 32px; margin-right: 32px" src="https://next.ossinsight.io/widgets/official/analyze-repo-stars-map/thumbnail.png?activity=stars&repo_id=575865240&image_size=auto&color_scheme=light">
alt="Active Contributors of Soulter/AstrBot - Last 28 days" </v-img>
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-card>
"> </v-col>
</v-row>
</v-container>
<!-- Made with [OSS Insight](https://ossinsight.io/) --> </section>
<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>
</div> </div>
</v-card-text> </v-card-text>
</v-card> </v-card>
</template> </template>
<script> <script>
import {useCustomizerStore} from "@/stores/customizer";
export default { export default {
name: 'AboutPage', name: 'AboutPage',
data() { data() {
@@ -59,26 +99,141 @@ export default {
}, },
methods: { methods: {
useCustomizerStore,
open(url) { open(url) {
window.open(url, '_blank'); window.open(url, '_blank');
} }
} }
} }
</script> </script>
<style> <style scoped>
@keyframes fadeIn { .about-wrapper {
from { min-height: 100%;
opacity: 0; }
}
to { .hero-section {
opacity: 1; 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: var(--v-theme-containerBg, #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 { .fade-in {
animation: fadeIn 0.2s ease-in-out; 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> </style>

View 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>

View 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>

View File

@@ -12,31 +12,27 @@ marked.setOptions({
<v-card class="chat-page-card"> <v-card class="chat-page-card">
<v-card-text class="chat-page-container"> <v-card-text class="chat-page-container">
<div class="chat-layout"> <div class="chat-layout">
<!-- 左侧对话列表面板 - 优化版 -->
<div class="sidebar-panel"> <div class="sidebar-panel">
<div class="sidebar-header"> <div style="padding: 16px; padding-top: 8px;">
<v-btn variant="elevated" rounded="lg" class="new-chat-btn" @click="newC" :disabled="!currCid" <v-btn variant="elevated" rounded="lg" class="new-chat-btn" @click="newC" :disabled="!currCid"
prepend-icon="mdi-plus"> prepend-icon="mdi-plus">
创建对话 创建对话
</v-btn> </v-btn>
</div> </div>
<div class="conversations-container"> <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-card class="conversation-list-card" v-if="conversations.length > 0" flat>
<v-list density="compact" nav class="conversation-list" <v-list density="compact" nav class="conversation-list"
@update:selected="getConversationMessages"> @update:selected="getConversationMessages">
<v-list-item v-for="(item, i) in conversations" :key="item.cid" :value="item.cid" <v-list-item v-for="(item, i) in conversations" :key="item.cid" :value="item.cid"
color="primary" rounded="lg" class="conversation-item" active-color="primary"> rounded="lg" class="conversation-item" active-color="primary">
<template v-slot:prepend> <template v-slot:prepend>
<v-icon size="small" icon="mdi-message-text-outline"></v-icon> <v-icon size="small" icon="mdi-message-text-outline"></v-icon>
</template> </template>
<v-list-item-title class="conversation-title">新对话</v-list-item-title> <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 class="timestamp">{{ formatDate(item.updated_at)
}}</v-list-item-subtitle> }}</v-list-item-subtitle>
</v-list-item> </v-list-item>
</v-list> </v-list>
</v-card> </v-card>
@@ -151,10 +147,10 @@ marked.setOptions({
<!-- 输入区域 --> <!-- 输入区域 -->
<div class="input-area fade-in"> <div class="input-area fade-in">
<v-text-field id="input-field" variant="outlined" v-model="prompt" :label="inputFieldLabel" <v-text-field autocomplete="off" id="input-field" variant="outlined" v-model="prompt"
placeholder="开始输入..." :loading="loadingChat" clear-icon="mdi-close-circle" clearable :label="inputFieldLabel" placeholder="开始输入..." :loading="loadingChat"
@click:clear="clearMessage" class="message-input" @keydown="handleInputKeyDown" clear-icon="mdi-close-circle" clearable @click:clear="clearMessage" class="message-input"
hide-details> @keydown="handleInputKeyDown" hide-details>
<template v-slot:loader> <template v-slot:loader>
<v-progress-linear :active="loadingChat" height="3" color="deep-purple" <v-progress-linear :active="loadingChat" height="3" color="deep-purple"
indeterminate></v-progress-linear> indeterminate></v-progress-linear>
@@ -165,7 +161,7 @@ marked.setOptions({
<template v-slot:activator="{ props }"> <template v-slot:activator="{ props }">
<v-btn v-bind="props" @click="sendMessage" class="send-btn" icon="mdi-send" <v-btn v-bind="props" @click="sendMessage" class="send-btn" icon="mdi-send"
variant="text" color="deep-purple" variant="text" color="deep-purple"
:disabled="!prompt && stagedImagesUrl.length === 0 && !stagedAudioUrl" /> :disabled="!prompt && stagedImagesName.length === 0 && !stagedAudioUrl" />
</template> </template>
</v-tooltip> </v-tooltip>
@@ -215,7 +211,8 @@ export default {
messages: [], messages: [],
conversations: [], conversations: [],
currCid: '', currCid: '',
stagedImagesUrl: [], stagedImagesName: [], // 用于存储图片**文件名**的数组
stagedImagesUrl: [], // 用于存储图片的blob URL数组
loadingChat: false, loadingChat: false,
inputFieldLabel: '聊天吧!', inputFieldLabel: '聊天吧!',
@@ -233,7 +230,9 @@ export default {
// Ctrl键长按相关变量 // Ctrl键长按相关变量
ctrlKeyDown: false, ctrlKeyDown: false,
ctrlKeyTimer: null, ctrlKeyTimer: null,
ctrlKeyLongPressThreshold: 300 // 长按阈值,单位毫秒 ctrlKeyLongPressThreshold: 300, // 长按阈值,单位毫秒
mediaCache: {}, // Add a cache to store media blobs
} }
}, },
@@ -262,9 +261,31 @@ export default {
// 移除keyup事件监听 // 移除keyup事件监听
document.removeEventListener('keyup', this.handleInputKeyUp); document.removeEventListener('keyup', this.handleInputKeyUp);
// Cleanup blob URLs
this.cleanupMediaCache();
}, },
methods: { methods: {
async getMediaFile(filename) {
if (this.mediaCache[filename]) {
return this.mediaCache[filename];
}
try {
const response = await axios.get('/api/chat/get_file', {
params: { filename },
responseType: 'blob'
});
const blobUrl = URL.createObjectURL(response.data);
this.mediaCache[filename] = blobUrl;
return blobUrl;
} catch (error) {
console.error('Error fetching media file:', error);
return '';
}
},
async startListeningEvent() { async startListeningEvent() {
const response = await fetch('/api/chat/listen', { const response = await fetch('/api/chat/listen', {
@@ -325,17 +346,19 @@ export default {
if (chunk_json.type === 'image') { if (chunk_json.type === 'image') {
let img = chunk_json.data.replace('[IMAGE]', ''); let img = chunk_json.data.replace('[IMAGE]', '');
const imageUrl = await this.getMediaFile(img);
let bot_resp = { let bot_resp = {
type: 'bot', type: 'bot',
message: `<img src="/api/chat/get_file?filename=${img}" style="max-width: 80%; border-radius: 8px; box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);"/>` message: `<img src="${imageUrl}" style="max-width: 80%; border-radius: 8px; box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);"/>`
} }
this.messages.push(bot_resp); this.messages.push(bot_resp);
} else if (chunk_json.type === 'record') { } else if (chunk_json.type === 'record') {
let audio = chunk_json.data.replace('[RECORD]', ''); let audio = chunk_json.data.replace('[RECORD]', '');
const audioUrl = await this.getMediaFile(audio);
let bot_resp = { let bot_resp = {
type: 'bot', type: 'bot',
message: `<audio controls class="audio-player"> message: `<audio controls class="audio-player">
<source src="/api/chat/get_file?filename=${audio}" type="audio/wav"> <source src="${audioUrl}" type="audio/wav">
您的浏览器不支持音频播放。 您的浏览器不支持音频播放。
</audio>` </audio>`
} }
@@ -400,15 +423,14 @@ export default {
try { try {
const response = await axios.post('/api/chat/post_file', formData, { const response = await axios.post('/api/chat/post_file', formData, {
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data'
'Authorization': 'Bearer ' + localStorage.getItem('token')
} }
}); });
const audio = response.data.data.filename; const audio = response.data.data.filename;
console.log('Audio uploaded:', audio); console.log('Audio uploaded:', audio);
this.stagedAudioUrl = `/api/chat/get_file?filename=${audio}`; this.stagedAudioUrl = audio; // Store just the filename
} catch (err) { } catch (err) {
console.error('Error uploading audio:', err); console.error('Error uploading audio:', err);
} }
@@ -427,13 +449,13 @@ export default {
try { try {
const response = await axios.post('/api/chat/post_image', formData, { const response = await axios.post('/api/chat/post_image', formData, {
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data'
'Authorization': 'Bearer ' + localStorage.getItem('token')
} }
}); });
const img = response.data.data.filename; const img = response.data.data.filename;
this.stagedImagesUrl.push(`/api/chat/get_file?filename=${img}`); this.stagedImagesName.push(img); // Store just the filename
this.stagedImagesUrl.push(URL.createObjectURL(file)); // Create a blob URL for immediate display
} catch (err) { } catch (err) {
console.error('Error uploading image:', err); console.error('Error uploading image:', err);
@@ -443,6 +465,7 @@ export default {
}, },
removeImage(index) { removeImage(index) {
this.stagedImagesName.splice(index, 1);
this.stagedImagesUrl.splice(index, 1); this.stagedImagesUrl.splice(index, 1);
}, },
@@ -459,28 +482,30 @@ export default {
getConversationMessages(cid) { getConversationMessages(cid) {
if (!cid[0]) if (!cid[0])
return; return;
axios.get('/api/chat/get_conversation?conversation_id=' + cid[0]).then(response => { axios.get('/api/chat/get_conversation?conversation_id=' + cid[0]).then(async response => {
this.currCid = cid[0]; this.currCid = cid[0];
let message = JSON.parse(response.data.data.history); let message = JSON.parse(response.data.data.history);
for (let i = 0; i < message.length; i++) { for (let i = 0; i < message.length; i++) {
if (message[i].message.startsWith('[IMAGE]')) { if (message[i].message.startsWith('[IMAGE]')) {
let img = message[i].message.replace('[IMAGE]', ''); let img = message[i].message.replace('[IMAGE]', '');
message[i].message = `<img src="/api/chat/get_file?filename=${img}" style="max-width: 80%; border-radius: 8px; box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);"/>` const imageUrl = await this.getMediaFile(img);
message[i].message = `<img src="${imageUrl}" style="max-width: 80%; border-radius: 8px; box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);"/>`
} }
if (message[i].message.startsWith('[RECORD]')) { if (message[i].message.startsWith('[RECORD]')) {
let audio = message[i].message.replace('[RECORD]', ''); let audio = message[i].message.replace('[RECORD]', '');
const audioUrl = await this.getMediaFile(audio);
message[i].message = `<audio controls class="audio-player"> message[i].message = `<audio controls class="audio-player">
<source src="/api/chat/get_file?filename=${audio}" type="audio/wav"> <source src="${audioUrl}" type="audio/wav">
您的浏览器不支持音频播放。 您的浏览器不支持音频播放。
</audio>` </audio>`
} }
if (message[i].image_url && message[i].image_url.length > 0) { if (message[i].image_url && message[i].image_url.length > 0) {
for (let j = 0; j < message[i].image_url.length; j++) { for (let j = 0; j < message[i].image_url.length; j++) {
message[i].image_url[j] = `/api/chat/get_file?filename=${message[i].image_url[j]}`; message[i].image_url[j] = await this.getMediaFile(message[i].image_url[j]);
} }
} }
if (message[i].audio_url) { if (message[i].audio_url) {
message[i].audio_url = `/api/chat/get_file?filename=${message[i].audio_url}`; message[i].audio_url = await this.getMediaFile(message[i].audio_url);
} }
} }
this.messages = message; this.messages = message;
@@ -531,32 +556,41 @@ export default {
await this.newConversation(); await this.newConversation();
} }
this.messages.push({ // Create a message object with actual URLs for display
const userMessage = {
type: 'user', type: 'user',
message: this.prompt, message: this.prompt,
image_url: this.stagedImagesUrl, image_url: [],
audio_url: this.stagedAudioUrl audio_url: null
}); };
// Convert image filenames to blob URLs for display
if (this.stagedImagesName.length > 0) {
for (let i = 0; i < this.stagedImagesName.length; i++) {
// If it's just a filename, get the blob URL
if (!this.stagedImagesName[i].startsWith('blob:')) {
const imgUrl = await this.getMediaFile(this.stagedImagesName[i]);
userMessage.image_url.push(imgUrl);
} else {
userMessage.image_url.push(this.stagedImagesName[i]);
}
}
}
// Convert audio filename to blob URL for display
if (this.stagedAudioUrl) {
if (!this.stagedAudioUrl.startsWith('blob:')) {
userMessage.audio_url = await this.getMediaFile(this.stagedAudioUrl);
} else {
userMessage.audio_url = this.stagedAudioUrl;
}
}
this.messages.push(userMessage);
this.scrollToBottom(); this.scrollToBottom();
// images
let image_filenames = [];
for (let i = 0; i < this.stagedImagesUrl.length; i++) {
let img = this.stagedImagesUrl[i].replace('/api/chat/get_file?filename=', '');
image_filenames.push(img);
}
// audio
let audio_filenames = [];
if (this.stagedAudioUrl) {
let audio = this.stagedAudioUrl.replace('/api/chat/get_file?filename=', '');
audio_filenames.push(audio);
}
this.loadingChat = true; this.loadingChat = true;
fetch('/api/chat/send', { fetch('/api/chat/send', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -566,20 +600,19 @@ export default {
body: JSON.stringify({ body: JSON.stringify({
message: this.prompt, message: this.prompt,
conversation_id: this.currCid, conversation_id: this.currCid,
image_url: image_filenames, image_url: this.stagedImagesName, // Already contains just filenames
audio_url: audio_filenames audio_url: this.stagedAudioUrl ? [this.stagedAudioUrl] : [] // Already contains just filename
}) // 发送请求体
})
.then(response => {
this.prompt = '';
this.stagedImagesUrl = [];
this.stagedAudioUrl = "";
this.loadingChat = false;
}) })
.catch(err => { })
console.error(err); .then(response => {
}); this.prompt = '';
this.stagedImagesName = [];
this.stagedAudioUrl = "";
this.loadingChat = false;
})
.catch(err => {
console.error(err);
});
}, },
scrollToBottom() { scrollToBottom() {
this.$nextTick(() => { this.$nextTick(() => {
@@ -620,6 +653,15 @@ export default {
} }
} }
}, },
cleanupMediaCache() {
Object.values(this.mediaCache).forEach(url => {
if (url.startsWith('blob:')) {
URL.revokeObjectURL(url);
}
});
this.mediaCache = {};
},
}, },
} }
</script> </script>
@@ -671,7 +713,6 @@ export default {
height: 100%; height: 100%;
border-radius: 12px; border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05) !important; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05) !important;
background-color: #fff;
} }
.chat-page-container { .chat-page-container {
@@ -694,14 +735,13 @@ export default {
flex-direction: column; flex-direction: column;
padding: 0; padding: 0;
border-right: 1px solid rgba(0, 0, 0, 0.05); border-right: 1px solid rgba(0, 0, 0, 0.05);
background-color: #fcfcfc; background-color: var(--v-theme-surface) !important;
height: 100%; height: 100%;
position: relative; position: relative;
} }
.sidebar-header { .sidebar-header {
padding: 16px; padding: 16px;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
} }
.conversations-container { .conversations-container {
@@ -718,7 +758,7 @@ export default {
.sidebar-section-title { .sidebar-section-title {
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
color: #666; color: var(--v-theme-secondaryText);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
margin-bottom: 12px; margin-bottom: 12px;
@@ -776,7 +816,7 @@ export default {
.timestamp { .timestamp {
font-size: 11px; font-size: 11px;
color: #999; color: var(--v-theme-secondaryText);
line-height: 1; line-height: 1;
} }
@@ -819,7 +859,7 @@ export default {
.no-conversations-text { .no-conversations-text {
font-size: 14px; font-size: 14px;
color: #999; color: var(--v-theme-secondaryText);
} }
/* 聊天内容区域 */ /* 聊天内容区域 */
@@ -855,21 +895,21 @@ export default {
.bot-name { .bot-name {
font-weight: 700; font-weight: 700;
margin-left: 8px; margin-left: 8px;
color: #673ab7; color: var(--v-theme-secondary);
} }
.welcome-hint { .welcome-hint {
margin-top: 8px; margin-top: 8px;
color: #666; color: var(--v-theme-secondaryText);
font-size: 14px; font-size: 14px;
} }
.welcome-hint code { .welcome-hint code {
background-color: #f5f0ff; background-color: var(--v-theme-codeBg);
padding: 2px 6px; padding: 2px 6px;
margin: 0 4px; margin: 0 4px;
border-radius: 4px; border-radius: 4px;
color: #673ab7; color: var(--v-theme-code);
font-family: 'Fira Code', monospace; font-family: 'Fira Code', monospace;
font-size: 13px; font-size: 13px;
} }
@@ -908,15 +948,15 @@ export default {
} }
.user-bubble { .user-bubble {
background-color: #f5f0ff; background-color: var(--v-theme-background);
color: #333; color: var(--v-theme-primaryText);
border-top-right-radius: 4px; border-top-right-radius: 4px;
} }
.bot-bubble { .bot-bubble {
background-color: #fff; background-color: var(--v-theme-surface);
border: 1px solid #e8e8e8; border: 1px solid var(--v-theme-border);
color: #333; color: var(--v-theme-primaryText);
border-top-left-radius: 4px; border-top-left-radius: 4px;
} }
@@ -963,9 +1003,9 @@ export default {
/* 输入区域样式 */ /* 输入区域样式 */
.input-area { .input-area {
padding: 16px; padding: 16px;
background-color: #fff; background-color: var(--v-theme-surface);
position: relative; position: relative;
border-top: 1px solid #f5f5f5; border-top: 1px solid var(--v-theme-border);
} }
.message-input { .message-input {
@@ -1035,12 +1075,12 @@ export default {
margin-top: 16px; margin-top: 16px;
margin-bottom: 10px; margin-bottom: 10px;
font-weight: 600; font-weight: 600;
color: #333; color: var(--v-theme-primaryText);
} }
.markdown-content h1 { .markdown-content h1 {
font-size: 1.8em; font-size: 1.8em;
border-bottom: 1px solid #eee; border-bottom: 1px solid var(--v-theme-border);
padding-bottom: 6px; padding-bottom: 6px;
} }
@@ -1063,7 +1103,7 @@ export default {
} }
.markdown-content pre { .markdown-content pre {
background-color: #f8f8f8; background-color: var(--v-theme-surface);
padding: 12px; padding: 12px;
border-radius: 6px; border-radius: 6px;
overflow-x: auto; overflow-x: auto;
@@ -1071,12 +1111,12 @@ export default {
} }
.markdown-content code { .markdown-content code {
background-color: #f5f0ff; background-color: var(--v-theme-codeBg);
padding: 2px 4px; padding: 2px 4px;
border-radius: 4px; border-radius: 4px;
font-family: 'Fira Code', monospace; font-family: 'Fira Code', monospace;
font-size: 0.9em; font-size: 0.9em;
color: #673ab7; color: var(--v-theme-code);
} }
.markdown-content img { .markdown-content img {
@@ -1086,9 +1126,9 @@ export default {
} }
.markdown-content blockquote { .markdown-content blockquote {
border-left: 4px solid #673ab7; border-left: 4px solid var(--v-theme-secondary);
padding-left: 16px; padding-left: 16px;
color: #666; color: var(--v-theme-secondaryText);
margin: 16px 0; margin: 16px 0;
} }
@@ -1100,13 +1140,13 @@ export default {
.markdown-content th, .markdown-content th,
.markdown-content td { .markdown-content td {
border: 1px solid #eee; border: 1px solid var(--v-theme-background);
padding: 8px 12px; padding: 8px 12px;
text-align: left; text-align: left;
} }
.markdown-content th { .markdown-content th {
background-color: #f5f0ff; background-color: var(--v-theme-containerBg);
} }
/* 动画类 */ /* 动画类 */

View File

@@ -42,7 +42,7 @@ import config from '@/config';
<div v-for="(val2, key2, index2) in metadata[key]['metadata']"> <div v-for="(val2, key2, index2) in metadata[key]['metadata']">
<!-- <h3>{{ metadata[key]['metadata'][key2]['description'] }}</h3> --> <!-- <h3>{{ metadata[key]['metadata'][key2]['description'] }}</h3> -->
<div v-if="metadata[key]['metadata'][key2]?.config_template" <div v-if="metadata[key]['metadata'][key2]?.config_template"
v-show="key2 !== 'platform' && key2 !== 'provider'" style="border: 1px solid #e0e0e0; padding: 8px; margin-bottom: 16px; border-radius: 10px"> v-show="key2 !== 'platform' && key2 !== 'provider'" style="border: 1px solid var(--v-theme-border); padding: 8px; margin-bottom: 16px; border-radius: 10px">
<!-- 带有 config_template 的配置项 --> <!-- 带有 config_template 的配置项 -->
<v-list-item-title style="font-weight: bold;"> <v-list-item-title style="font-weight: bold;">
{{ metadata[key]['metadata'][key2]['description'] }} ({{ key2 }}) {{ metadata[key]['metadata'][key2]['description'] }} ({{ key2 }})
@@ -88,7 +88,7 @@ import config from '@/config';
<div v-else> <div v-else>
<!-- 如果配置项是一个 object那么 iterable 需要取到这个 object 的值否则取到整个 config_data --> <!-- 如果配置项是一个 object那么 iterable 需要取到这个 object 的值否则取到整个 config_data -->
<div v-if="metadata[key]['metadata'][key2]['type'] == 'object'" style="border: 1px solid #e0e0e0; padding: 8px; margin-bottom: 16px; border-radius: 10px"> <div v-if="metadata[key]['metadata'][key2]['type'] == 'object'" style="border: 1px solid var(--v-theme-border); padding: 8px; margin-bottom: 16px; border-radius: 10px">
<AstrBotConfig <AstrBotConfig
:metadata="metadata[key]['metadata']" :iterable="config_data[key2]" :metadataKey="key2"> :metadata="metadata[key]['metadata']" :iterable="config_data[key2]" :metadataKey="key2">
</AstrBotConfig> </AstrBotConfig>

View File

@@ -7,7 +7,7 @@ import axios from 'axios';
<template> <template>
<div style="height: 100%;"> <div style="height: 100%;">
<div <div
style="background-color: white; padding: 8px; padding-left: 16px; border-radius: 8px; margin-bottom: 16px; display: flex; flex-direction: row; align-items: center; justify-content: space-between;"> style="background-color: var(--v-theme-surface); padding: 8px; padding-left: 16px; border-radius: 8px; margin-bottom: 16px; display: flex; flex-direction: row; align-items: center; justify-content: space-between;">
<h4>控制台</h4> <h4>控制台</h4>
<div class="d-flex align-center"> <div class="d-flex align-center">
<v-switch <v-switch

View File

@@ -52,13 +52,17 @@ import 'highlight.js/styles/github.css';
<v-card-text> <v-card-text>
<small style="color: #bbb;">每个插件都是作者无偿提供的的劳动成果如果您喜欢某个插件 Star</small> <small style="color: var(--v-theme-secondaryText);">每个插件都是作者无偿提供的的劳动成果如果您喜欢某个插件 Star</small>
<div v-if="pinnedPlugins.length > 0" class="mt-4"> <div v-if="pinnedPlugins.length > 0" class="mt-4">
<h2>🥳 推荐</h2> <h2>🥳 推荐</h2>
<v-row style="margin-top: 8px;"> <v-row style="margin-top: 8px;">
<v-col cols="12" md="6" lg="6" v-for="plugin in pinnedPlugins"> <v-col cols="12" md="6" lg="6" v-for="plugin in pinnedPlugins">
<ExtensionCard :extension="plugin" market-mode="true" :highlight="true"> <ExtensionCard :extension="plugin" class="h-120 rounded-lg"
market-mode="true" :highlight="true"
@install="extension_url=plugin.repo;
newExtension()"
@view-readme="open(plugin.repo)">
</ExtensionCard> </ExtensionCard>
</v-col> </v-col>
</v-row> </v-row>
@@ -77,7 +81,7 @@ import 'highlight.js/styles/github.css';
style="height: 80px; width: 80px; margin-right: 8px; border-radius: 8px; margin-top: 8px; margin-bottom: 8px;" style="height: 80px; width: 80px; margin-right: 8px; border-radius: 8px; margin-top: 8px; margin-bottom: 8px;"
alt="logo"> alt="logo">
<span v-if="item?.repo"><a :href="item?.repo" <span v-if="item?.repo"><a :href="item?.repo"
style="color: #000; text-decoration:none">{{ style="color: var(--v-theme-primaryText, #000); text-decoration:none">{{
item.name }}</a></span> item.name }}</a></span>
<span v-else>{{ item.name }}</span> <span v-else>{{ item.name }}</span>
@@ -565,7 +569,7 @@ export default {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
line-height: 1.6; line-height: 1.6;
padding: 8px 0; padding: 8px 0;
color: #24292e; color: var(--v-theme-secondaryText);
} }
.markdown-body h1, .markdown-body h1,
@@ -582,13 +586,13 @@ export default {
.markdown-body h1 { .markdown-body h1 {
font-size: 2em; font-size: 2em;
border-bottom: 1px solid #eaecef; border-bottom: 1px solid var(--v-theme-border);
padding-bottom: 0.3em; padding-bottom: 0.3em;
} }
.markdown-body h2 { .markdown-body h2 {
font-size: 1.5em; font-size: 1.5em;
border-bottom: 1px solid #eaecef; border-bottom: 1px solid var(--v-theme-border);
padding-bottom: 0.3em; padding-bottom: 0.3em;
} }
@@ -600,7 +604,7 @@ export default {
.markdown-body code { .markdown-body code {
padding: 0.2em 0.4em; padding: 0.2em 0.4em;
margin: 0; margin: 0;
background-color: rgba(27, 31, 35, 0.05); background-color: var(--v-theme-codeBg);
border-radius: 3px; border-radius: 3px;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 85%; font-size: 85%;
@@ -611,7 +615,7 @@ export default {
overflow: auto; overflow: auto;
font-size: 85%; font-size: 85%;
line-height: 1.45; line-height: 1.45;
background-color: #f6f8fa; background-color: var(--v-theme-containerBg);
border-radius: 3px; border-radius: 3px;
margin-bottom: 16px; margin-bottom: 16px;
} }
@@ -631,19 +635,19 @@ export default {
max-width: 100%; max-width: 100%;
margin: 8px 0; margin: 8px 0;
box-sizing: border-box; box-sizing: border-box;
background-color: #fff; background-color: var(--v-theme-background);
border-radius: 3px; border-radius: 3px;
} }
.markdown-body blockquote { .markdown-body blockquote {
padding: 0 1em; padding: 0 1em;
color: #6a737d; color: var(--v-theme-secondaryText);
border-left: 0.25em solid #dfe2e5; border-left: 0.25em solid var(--v-theme-border);
margin-bottom: 16px; margin-bottom: 16px;
} }
.markdown-body a { .markdown-body a {
color: #0366d6; color: var(--v-theme-primary);
text-decoration: none; text-decoration: none;
} }
@@ -662,23 +666,23 @@ export default {
.markdown-body table th, .markdown-body table th,
.markdown-body table td { .markdown-body table td {
padding: 6px 13px; padding: 6px 13px;
border: 1px solid #dfe2e5; border: 1px solid var(--v-theme-background);
} }
.markdown-body table tr { .markdown-body table tr {
background-color: #fff; background-color: var(--v-theme-surface);
border-top: 1px solid #c6cbd1; border-top: 1px solid var(--v-theme-border);
} }
.markdown-body table tr:nth-child(2n) { .markdown-body table tr:nth-child(2n) {
background-color: #f6f8fa; background-color: var(--v-theme-background);
} }
.markdown-body hr { .markdown-body hr {
height: 0.25em; height: 0.25em;
padding: 0; padding: 0;
margin: 24px 0; margin: 24px 0;
background-color: #e1e4e8; background-color: var(--v-theme-containerBg);
border: 0; border: 0;
} }
</style> </style>

View File

@@ -378,6 +378,13 @@ const toggleAllPluginsForPlatform = (platformName) => {
onMounted(async () => { onMounted(async () => {
await getExtensions(); await getExtensions();
// 检查是否有 open_config 参数
const urlParams = new URLSearchParams(window.location.search);
const plugin_name = urlParams.get('open_config');
if (plugin_name) {
openExtensionConfig(plugin_name);
}
try { try {
const data = await commonStore.getPluginCollections(); const data = await commonStore.getPluginCollections();
pluginMarketData.value = data; pluginMarketData.value = data;

View File

@@ -27,13 +27,39 @@
<v-divider></v-divider> <v-divider></v-divider>
<!-- 添加分类标签页 -->
<v-card-text class="px-4 pt-3 pb-0">
<v-tabs v-model="activeProviderTypeTab" bg-color="transparent">
<v-tab value="all" class="font-weight-medium px-3">
<v-icon start>mdi-filter-variant</v-icon>
全部
</v-tab>
<v-tab value="chat_completion" class="font-weight-medium px-3">
<v-icon start>mdi-message-text</v-icon>
基本对话
</v-tab>
<v-tab value="speech_to_text" class="font-weight-medium px-3">
<v-icon start>mdi-microphone-message</v-icon>
语音转文字
</v-tab>
<v-tab value="text_to_speech" class="font-weight-medium px-3">
<v-icon start>mdi-volume-high</v-icon>
文字转语音
</v-tab>
<v-tab value="embedding" class="font-weight-medium px-3">
<v-icon start>mdi-code-json</v-icon>
Embedding
</v-tab>
</v-tabs>
</v-card-text>
<v-card-text class="px-4 py-3"> <v-card-text class="px-4 py-3">
<item-card-grid <item-card-grid
:items="config_data.provider || []" :items="filteredProviders"
title-field="id" title-field="id"
enabled-field="enable" enabled-field="enable"
empty-icon="mdi-api-off" empty-icon="mdi-api-off"
empty-text="暂无服务提供商点击 新增服务提供商 添加" :empty-text="getEmptyText()"
@toggle-enabled="providerStatusChange" @toggle-enabled="providerStatusChange"
@delete="deleteProvider" @delete="deleteProvider"
@edit="configExistingProvider" @edit="configExistingProvider"
@@ -61,6 +87,51 @@
</v-card-text> </v-card-text>
</v-card> </v-card>
<!-- 供应商状态部分 -->
<v-card class="mb-6" elevation="2">
<v-card-title class="d-flex align-center py-3 px-4">
<v-icon color="primary" class="me-2">mdi-heart-pulse</v-icon>
<span class="text-h6">供应商可用性</span>
<v-spacer></v-spacer>
<v-btn color="primary" variant="tonal" :loading="loadingStatus" @click="fetchProviderStatus">
<v-icon left>mdi-refresh</v-icon>
刷新状态
</v-btn>
</v-card-title>
<v-card-subtitle class="px-4 py-1 text-caption text-medium-emphasis">
通过测试模型对话可用性判断可能产生API费用
</v-card-subtitle>
<v-divider></v-divider>
<v-card-text class="px-4 py-3">
<v-alert v-if="providerStatuses.length === 0" type="info" variant="tonal">
点击"刷新状态"按钮获取供应商可用性
</v-alert>
<v-container v-else class="pa-0">
<v-row>
<v-col v-for="status in providerStatuses" :key="status.id" cols="12" sm="6" md="4">
<v-card variant="outlined" class="status-card">
<v-card-item>
<v-icon :color="status.status === 'available' ? 'success' : 'error'" class="me-2">
{{ status.status === 'available' ? 'mdi-check-circle' : 'mdi-alert-circle' }}
</v-icon>
<span class="font-weight-bold">{{ status.id }}</span>
<v-chip :color="status.status === 'available' ? 'success' : 'error'" size="small" class="ml-2">
{{ status.status === 'available' ? '可用' : '不可用' }}
</v-chip>
</v-card-item>
<v-card-text v-if="status.status === 'unavailable'" class="text-caption text-medium-emphasis">
<span class="font-weight-bold">错误信息:</span> {{ status.error }}
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</v-card-text>
</v-card>
<!-- 日志部分 --> <!-- 日志部分 -->
<v-card elevation="2"> <v-card elevation="2">
<v-card-title class="d-flex align-center py-3 px-4"> <v-card-title class="d-flex align-center py-3 px-4">
@@ -109,10 +180,14 @@
<v-icon start>mdi-volume-high</v-icon> <v-icon start>mdi-volume-high</v-icon>
文字转语音 文字转语音
</v-tab> </v-tab>
<v-tab value="embedding" class="font-weight-medium px-3">
<v-icon start>mdi-code-json</v-icon>
Embedding
</v-tab>
</v-tabs> </v-tabs>
<v-window v-model="activeProviderTab" class="mt-4"> <v-window v-model="activeProviderTab" class="mt-4">
<v-window-item v-for="tabType in ['chat_completion', 'speech_to_text', 'text_to_speech']" <v-window-item v-for="tabType in ['chat_completion', 'speech_to_text', 'text_to_speech', 'embedding']"
:key="tabType" :key="tabType"
:value="tabType"> :value="tabType">
<v-row class="mt-1"> <v-row class="mt-1">
@@ -222,9 +297,58 @@ export default {
showConsole: false, showConsole: false,
// 供应商状态相关
providerStatuses: [],
loadingStatus: false,
// 新增提供商对话框相关 // 新增提供商对话框相关
showAddProviderDialog: false, showAddProviderDialog: false,
activeProviderTab: 'chat_completion', activeProviderTab: 'chat_completion',
// 添加提供商类型分类
activeProviderTypeTab: 'all',
// 兼容旧版本(< v3.5.11)的 mapping用于映射到对应的提供商能力类型
oldVersionProviderTypeMapping: {
"openai_chat_completion": "chat_completion",
"anthropic_chat_completion": "chat_completion",
"googlegenai_chat_completion": "chat_completion",
"zhipu_chat_completion": "chat_completion",
"llm_tuner": "chat_completion",
"dify": "chat_completion",
"dashscope": "chat_completion",
"openai_whisper_api": "speech_to_text",
"openai_whisper_selfhost": "speech_to_text",
"sensevoice_stt_selfhost": "speech_to_text",
"openai_tts_api": "text_to_speech",
"edge_tts": "text_to_speech",
"gsvi_tts_api": "text_to_speech",
"fishaudio_tts_api": "text_to_speech",
"dashscope_tts": "text_to_speech",
"azure_tts": "text_to_speech",
"minimax_tts_api": "text_to_speech",
"volcengine_tts": "text_to_speech",
}
}
},
computed: {
// 根据选择的标签过滤提供商列表
filteredProviders() {
if (!this.config_data.provider || this.activeProviderTypeTab === 'all') {
return this.config_data.provider || [];
}
return this.config_data.provider.filter(provider => {
// 如果provider.provider_type已经存在直接使用它
if (provider.provider_type) {
return provider.provider_type === this.activeProviderTypeTab;
}
// 否则使用映射关系
const mappedType = this.oldVersionProviderTypeMapping[provider.type];
return mappedType === this.activeProviderTypeTab;
});
} }
}, },
@@ -243,6 +367,15 @@ export default {
}); });
}, },
// 获取空列表文本
getEmptyText() {
if (this.activeProviderTypeTab === 'all') {
return "暂无服务提供商,点击 新增服务提供商 添加";
} else {
return `暂无${this.getTabTypeName(this.activeProviderTypeTab)}类型的服务提供商,点击 新增服务提供商 添加`;
}
},
// 按提供商类型获取模板列表 // 按提供商类型获取模板列表
getTemplatesByType(type) { getTemplatesByType(type) {
const templates = this.metadata['provider_group']?.metadata?.provider?.config_template || {}; const templates = this.metadata['provider_group']?.metadata?.provider?.config_template || {};
@@ -294,7 +427,8 @@ export default {
const names = { const names = {
'chat_completion': '基本对话', 'chat_completion': '基本对话',
'speech_to_text': '语音转文本', 'speech_to_text': '语音转文本',
'text_to_speech': '文本转语音' 'text_to_speech': '文本转语音',
'embedding': 'Embedding'
}; };
return names[tabType] || tabType; return names[tabType] || tabType;
}, },
@@ -442,6 +576,22 @@ export default {
this.save_message = message; this.save_message = message;
this.save_message_success = "error"; this.save_message_success = "error";
this.save_message_snack = true; this.save_message_snack = true;
},
// 获取供应商状态
fetchProviderStatus() {
this.loadingStatus = true;
axios.get('/api/config/provider/check_status').then((res) => {
if (res.data && res.data.status === 'ok') {
this.providerStatuses = res.data.data || [];
} else {
this.showError(res.data?.message || "获取供应商状态失败");
}
this.loadingStatus = false;
}).catch((err) => {
this.loadingStatus = false;
this.showError(err.response?.data?.message || err.message);
});
} }
} }
} }

View File

@@ -1,6 +1,6 @@
<template> <template>
<div style="background-color: white; padding: 8px; padding-left: 16px; border-radius: 8px; margin-bottom: 16px;"> <div style="background-color: var(--v-theme-surface, #fff); padding: 8px; padding-left: 16px; border-radius: 8px; margin-bottom: 16px;">
<v-list lines="two"> <v-list lines="two">
<v-list-subheader>网络</v-list-subheader> <v-list-subheader>网络</v-list-subheader>

View File

@@ -327,7 +327,7 @@
</div> </div>
<small>1. 某些 MCP 服务器可能需要按照其要求在 env 中填充 `API_KEY` `TOKEN` 等信息请注意检查是否填写</small> <small>1. 某些 MCP 服务器可能需要按照其要求在 env 中填充 `API_KEY` `TOKEN` 等信息请注意检查是否填写</small>
<br> <br>
<small>2. 当配置中带有 url 参数时使用 SSE 的方式连接到服务器</small> <small>2. 当配置中指定 url 参数时如果还同时指定 `transport` 参数的值为 `streamable_http`则使用 Steamable HTTP否则使用 SSE 连接</small>
<div class="monaco-container"> <div class="monaco-container">
<VueMonacoEditor v-model:value="serverConfigJson" theme="vs-dark" language="json" :options="{ <VueMonacoEditor v-model:value="serverConfigJson" theme="vs-dark" language="json" :options="{

View File

@@ -0,0 +1,894 @@
<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>还没有安装知识库插件
<v-icon v-class="ml - 2" size="small" color="grey"
@click="openUrl('https://astrbot.app/use/knowledge-base.html')">mdi-information-outline</v-icon>
</h2>
<v-btn style="margin-top: 16px;" variant="tonal" color="primary" @click="installPlugin"
:loading="installing">
立即安装
</v-btn>
<ConsoleDisplayer v-show="installing" style="background-color: #fff; max-height: 300px; margin-top: 16px; max-width: 100%" :show-level-btns="false"></ConsoleDisplayer>
</div>
<div v-else-if="kbCollections.length == 0" class="d-flex align-center justify-center flex-column"
style="flex-grow: 1; width: 100%; height: 100%;">
<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">知识库列表
<v-icon v-class="ml - 2" size="x-small" color="grey"
@click="openUrl('https://astrbot.app/use/knowledge-base.html')">mdi-information-outline</v-icon>
</h2>
<v-btn class="mb-4" prepend-icon="mdi-plus" variant="tonal" color="primary"
@click="showCreateDialog = true">
创建知识库
</v-btn>
<v-btn class="mb-4 ml-4" prepend-icon="mdi-cog" variant="tonal" color="success"
@click="$router.push('/extension?open_config=astrbot_plugin_knowledge_base')">
配置
</v-btn>
<div class="kb-grid">
<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-select v-model="newKB.embedding_provider_id" :items="embeddingProviderConfigs"
:item-props="embeddingModelProps" label="Embedding(嵌入)模型" variant="outlined" class="mt-2">
</v-select>
<small>Tips: 一旦选择了一个知识库的嵌入模型请不要再修改该提供商的模型或者向量维度信息否则将严重影响该知识库的召回率甚至报错</small>
</v-form>
</v-card-text>
<v-card-actions>
<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>
<div v-if="currentKB._embedding_provider_config" class="px-6 py-2">
<v-chip class="mr-2" color="primary" variant="tonal" size="small" rounded="sm">
<v-icon start size="small">mdi-database</v-icon>
嵌入模型: {{ currentKB._embedding_provider_config.embedding_model }}
</v-chip>
<v-chip color="secondary" variant="tonal" size="small" rounded="sm">
<v-icon start size="small">mdi-vector-point</v-icon>
向量维度: {{ currentKB._embedding_provider_config.embedding_dimensions }}
</v-chip>
<small style="margin-left: 8px;">💡 使用方式: 在聊天页中输入 /kb use {{ currentKB.collection_name }}</small>
</div>
<v-card-text>
<v-tabs v-model="activeTab">
<v-tab value="upload">上传文件</v-tab>
<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">支持 txtpdfwordexcel 等多种格式</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>
<!-- 优化后的分片长度和重叠长度设置 -->
<v-card class="mt-4 chunk-settings-card" variant="outlined" color="grey-lighten-4">
<v-card-title class="pa-4 pb-0 d-flex align-center">
<v-icon color="primary" class="mr-2">mdi-puzzle-outline</v-icon>
<span class="text-subtitle-1 font-weight-bold">分片设置</span>
<v-tooltip location="top">
<template v-slot:activator="{ props }">
<v-icon v-bind="props" class="ml-2" size="small" color="grey">
mdi-information-outline
</v-icon>
</template>
<span>
分片长度决定每块文本的大小重叠长度决定相邻文本块之间的重叠程度<br>
较小的分片更精确但会增加数量适当的重叠可提高检索准确性
</span>
</v-tooltip>
</v-card-title>
<v-card-text class="pa-4 pt-2">
<div class="d-flex flex-wrap" style="gap: 8px">
<v-text-field v-model="chunkSize" label="分片长度" type="number"
hint="控制每个文本块大小,留空使用默认值" persistent-hint variant="outlined"
density="comfortable" class="flex-grow-1 chunk-field"
prepend-inner-icon="mdi-text-box-outline" min="50"></v-text-field>
<v-text-field v-model="overlap" label="重叠长度" type="number"
hint="控制相邻文本块重叠度,留空使用默认值" persistent-hint variant="outlined"
density="comfortable" class="flex-grow-1 chunk-field"
prepend-inner-icon="mdi-vector-intersection" min="0"></v-text-field>
</div>
</v-card-text>
</v-card>
<div class="selected-files mt-4" v-if="selectedFile">
<div type="info" variant="tonal" class="d-flex align-center">
<div>
<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';
import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue';
export default {
name: 'KnowledgeBase',
components: {
ConsoleDisplayer,
},
data() {
return {
installed: true,
installing: false,
kbCollections: [],
showCreateDialog: false,
showEmojiPicker: false,
newKB: {
name: '',
emoji: '🙂',
description: '',
embedding_provider_id: ''
},
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,
chunkSize: null,
overlap: null,
uploading: false,
searchQuery: '',
searchResults: [],
searching: false,
searchPerformed: false,
topK: 5,
showDeleteDialog: false,
deleteTarget: {
collection_name: ''
},
deleting: false,
embeddingProviderConfigs: []
}
},
mounted() {
this.checkPlugin();
this.getEmbeddingProviderList();
},
methods: {
embeddingModelProps(providerConfig) {
return {
title: providerConfig.embedding_model,
subtitle: `提供商 ID: ${providerConfig.id} | 嵌入模型维度: ${providerConfig.embedding_dimensions}`,
}
},
checkPlugin() {
axios.get('/api/plugin/get?name=astrbot_plugin_knowledge_base')
.then(response => {
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/lxfight/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) {
// 如果 this.newKB.embedding_provider_id 是 Object
if (typeof this.newKB.embedding_provider_id === 'object') {
this.newKB.embedding_provider_id = this.newKB.embedding_provider_id.id || '';
}
axios.post('/api/plug/alkaid/kb/create_collection', {
collection_name: name,
emoji: emoji,
description: description,
embedding_provider_id: this.newKB.embedding_provider_id || ''
})
.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,
this.newKB.embedding_provider_id || ''
);
},
resetNewKB() {
this.newKB = {
name: '',
emoji: '🙂',
description: '',
embedding_provider: ''
};
},
openKnowledgeBase(kb) {
// 不再跳转路由,而是打开对话框
this.currentKB = kb;
this.showContentDialog = true;
this.resetContentDialog();
},
resetContentDialog() {
this.activeTab = 'upload';
this.selectedFile = null;
this.searchQuery = '';
this.searchResults = [];
this.searchPerformed = false;
// 重置分片长度和重叠长度参数
this.chunkSize = null;
this.overlap = null;
},
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);
// 添加可选的分片长度和重叠长度参数
if (this.chunkSize && this.chunkSize > 0) {
formData.append('chunk_size', this.chunkSize);
}
if (this.overlap && this.overlap >= 0) {
formData.append('chunk_overlap', this.overlap);
}
axios.post('/api/plug/alkaid/kb/collection/add_file', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
.then(response => {
if (response.data.status === 'ok') {
this.showSnackbar('操作成功: ' + response.data.message);
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;
});
},
getEmbeddingProviderList() {
axios.get('/api/config/provider/list', {
params: {
provider_type: 'embedding'
}
})
.then(response => {
if (response.data.status === 'ok') {
this.embeddingProviderConfigs = response.data.data || [];
} else {
this.showSnackbar(response.data.message || '获取嵌入模型列表失败', 'error');
return [];
}
})
.catch(error => {
console.error('Error fetching embedding providers:', error);
this.showSnackbar('获取嵌入模型列表失败', 'error');
return [];
});
},
openUrl(url) {
window.open(url, '_blank');
}
}
}
</script>
<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: var(--v-theme-background);
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: var(--v-theme-background);
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;
}
.chunk-settings-card {
border: 1px solid rgba(92, 107, 192, 0.2) !important;
transition: all 0.3s ease;
}
.chunk-settings-card:hover {
border-color: rgba(92, 107, 192, 0.4) !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.07) !important;
}
.chunk-field :deep(.v-field__input) {
padding-top: 8px;
padding-bottom: 8px;
}
.chunk-field :deep(.v-field__prepend-inner) {
padding-right: 8px;
opacity: 0.7;
}
.chunk-field:focus-within :deep(.v-field__prepend-inner) {
opacity: 1;
}
</style>

View File

@@ -0,0 +1,581 @@
<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-container-nonono"
style="display: flex; justify-content: center; align-items: center; width: 100%; font-weight: 1000; font-size: 24px;">
加速开发中...
</div>
<div id="graph-control-panel"
style="min-width: 450px; border: 1px solid #eee; border-radius: 8px; padding: 16px; padding-bottom: 0px; margin-left: 16px; max-height: calc(100% - 40px);">
<div>
<!-- <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%;
max-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 {
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>

View 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>

View File

@@ -1,23 +1,154 @@
<script setup lang="ts"> <script setup lang="ts">
import AuthLogin from '../authForms/AuthLogin.vue'; 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';
import {useCustomizerStore} from "@/stores/customizer";
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> </script>
<template> <template>
<div style="display: flex; justify-content: center; flex-direction: column; align-items: center; height: 100vh; background-color: aliceblue;"> <div v-if="useCustomizerStore().uiTheme==='PurpleTheme'" class="login-page-container">
<v-card variant="outlined" style="max-width: 500px; box-shadow: 0 0 3px rgba(0, 0, 0, 0.1);"> <div class="login-background"></div>
<v-card-text class="pa-9"> <v-card
<div class="text-center"> variant="outlined"
class="login-card"
:class="{ 'card-visible': cardVisible }"
>
<v-card-text class="pa-10">
<div class="logo-wrapper">
<Logo /> <Logo />
<h2 class="text-secondary text-h2 mt-4">AstrBot 仪表盘</h2> </div>
<h4 class="text-disabled text-h4 mt-3">登录以继续</h4> <div class="divider-container">
<v-divider class="custom-divider"></v-divider>
</div>
<AuthLogin />
</v-card-text>
</v-card>
</div>
<div v-else class="login-page-container-dark">
<div class="login-background-dark"></div>
<v-card
variant="outlined"
class="login-card"
:class="{ 'card-visible': cardVisible }"
>
<v-card-text class="pa-10">
<div class="logo-wrapper">
<Logo />
</div>
<div class="divider-container">
<v-divider class="custom-divider"></v-divider>
</div> </div>
<AuthLogin /> <AuthLogin />
</v-card-text> </v-card-text>
</v-card> </v-card>
</div> </div>
</template> </template>
<style lang="scss"> <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-page-container-dark {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
width: 100%;
position: relative;
background: linear-gradient(135deg, #1a1b1c 0%, #1d1e21 100%);
overflow: hidden;
}
.login-background {
position: absolute;
width: 200%;
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;
}
.login-background-dark {
position: absolute;
width: 200%;
height: 200%;
top: -50%;
left: -50%;
background-color: var(--v-theme-surface);
z-index: 0;
animation: rotate 60s linear infinite;
}
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.login-card {
max-width: 520px;
width: 90%;
color: var(--v-theme-primaryText) !important;
border-radius: 12px !important;
border-color: var(--v-theme-border) !important;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.07) !important;
background-color: var(--v-theme-surface) !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 { .loginBox {
max-width: 475px; max-width: 475px;
margin: 0 auto; margin: 0 auto;

View File

@@ -1,16 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import {ref, useCssModule} from 'vue';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { Form } from 'vee-validate'; import { Form } from 'vee-validate';
import md5 from 'js-md5'; import md5 from 'js-md5';
import {useCustomizerStore} from "@/stores/customizer";
const valid = ref(false); const valid = ref(false);
const show1 = ref(false); const show1 = ref(false);
const password = ref(''); const password = ref('');
const username = ref(''); const username = ref('');
const loading = ref(false);
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
async function validate(values: any, { setErrors }: any) { async function validate(values: any, { setErrors }: any) {
loading.value = true;
// md5加密 // md5加密
let password_ = password.value; let password_ = password.value;
if (password.value != '') { if (password.value != '') {
@@ -21,67 +25,154 @@ async function validate(values: any, { setErrors }: any) {
const authStore = useAuthStore(); const authStore = useAuthStore();
return authStore.login(username.value, password_).then((res) => { return authStore.login(username.value, password_).then((res) => {
console.log(res); console.log(res);
loading.value = false;
}).catch((err) => { }).catch((err) => {
setErrors({ apiError: err }); setErrors({ apiError: err });
loading.value = false;
}); });
} }
</script> </script>
<template> <template>
<Form @submit="validate" class="mt-7 loginForm" v-slot="{ errors, isSubmitting }"> <Form @submit="validate" class="mt-4 login-form" v-slot="{ errors, isSubmitting }">
<v-text-field v-model="username" label="用户名" class="mt-4 mb-8" required density="comfortable" <v-text-field
hide-details="auto" variant="outlined" color="primary"></v-text-field> v-model="username"
<v-text-field v-model="password" label="密码" required density="comfortable" variant="outlined" label="用户名"
color="primary" hide-details="auto" :append-icon="show1 ? 'mdi-eye' : 'mdi-eye-off'" class="mb-6 input-field"
:type="show1 ? 'text' : 'password'" @click:append="show1 = !show1" class="pwdInput"></v-text-field> required
density="comfortable"
hide-details="auto"
variant="outlined"
:style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#000000dd' : '#ffffff'}"
prepend-inner-icon="mdi-account"
:disabled="loading"
></v-text-field>
<small>默认用户名和密码为 astrbot</small> <v-text-field
<v-btn color="secondary" :loading="isSubmitting" block class="mt-8" variant="flat" size="large" :disabled="valid" v-model="password"
type="submit"> label="密码"
登录</v-btn> required
<div v-if="errors.apiError" class="mt-2"> density="comfortable"
<v-alert color="error">{{ errors.apiError }}</v-alert> variant="outlined"
:style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#000000dd' : '#ffffff'}"
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>
<v-label :style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#000000aa' : '#ffffffcc'}" class="mt-1 mb-5">
<small>默认用户名和密码为 astrbot</small>
</v-label>
<v-btn
color="secondary"
:loading="isSubmitting || loading"
block
class="login-btn"
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> </div>
</Form> </Form>
</template> </template>
<style lang="scss"> <style lang="scss">
.custom-devider { .login-form {
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 {
.v-text-field .v-field--active input { .v-text-field .v-field--active input {
font-weight: 500; 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: var(--v-theme-secondaryText);
padding-left: 5px;
}
.error-container {
.v-alert {
border-left-width: 4px !important;
}
}
}
.custom-divider {
border-color: rgba(0, 0, 0, 0.08) !important;
} }
</style> </style>

View File

@@ -155,7 +155,7 @@ export default {
<style scoped> <style scoped>
.dashboard-container { .dashboard-container {
padding: 16px; padding: 16px;
background-color: #f9fafc; background-color: var(--v-theme-background);
min-height: calc(100vh - 64px); min-height: calc(100vh - 64px);
border-radius: 10px; border-radius: 10px;
@@ -170,13 +170,13 @@ export default {
.dashboard-title { .dashboard-title {
font-size: 24px; font-size: 24px;
font-weight: 600; font-weight: 600;
color: #333; color: var(--v-theme-primaryText);
margin-bottom: 4px; margin-bottom: 4px;
} }
.dashboard-subtitle { .dashboard-subtitle {
font-size: 14px; font-size: 14px;
color: #666; color: var(--v-theme-secondaryText);
} }
.notice-row { .notice-row {
@@ -194,18 +194,18 @@ export default {
.plugin-card { .plugin-card {
border-radius: 8px; border-radius: 8px;
background-color: white; background-color: var(--v-theme-surface);
} }
.plugin-title { .plugin-title {
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
color: #333; color: var(--v-theme-primaryText);
} }
.plugin-subtitle { .plugin-subtitle {
font-size: 12px; font-size: 12px;
color: #666; color: var(--v-theme-secondaryText);
margin-top: 4px; margin-top: 4px;
} }
@@ -225,7 +225,7 @@ export default {
.plugin-version { .plugin-version {
font-size: 12px; font-size: 12px;
color: #666; color: var(--v-theme-secondaryText, #666);
} }
.dashboard-footer { .dashboard-footer {

View File

@@ -167,7 +167,7 @@ export default {
}, },
}, },
grid: { grid: {
borderColor: '#f1f1f1', borderColor: "gray100",
row: { row: {
colors: ['transparent', 'transparent'], colors: ['transparent', 'transparent'],
opacity: 0.2 opacity: 0.2
@@ -293,12 +293,12 @@ export default {
.chart-title { .chart-title {
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
color: #333; color: var(--v-theme-primaryText);
} }
.chart-subtitle { .chart-subtitle {
font-size: 12px; font-size: 12px;
color: #666; color: var(--v-theme-secondaryText);
margin-top: 4px; margin-top: 4px;
} }
@@ -315,31 +315,31 @@ export default {
.stat-box { .stat-box {
padding: 12px 16px; padding: 12px 16px;
background: #f5f5f5; background: var(--v-theme-surface);
border-radius: 8px; border-radius: 8px;
flex: 1; flex: 1;
} }
.stat-label { .stat-label {
font-size: 12px; font-size: 12px;
color: #666; color: var(--v-theme-secondaryText);
margin-bottom: 4px; margin-bottom: 4px;
} }
.stat-number { .stat-number {
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
color: #333; color: var(--v-theme-primaryText);
display: flex; display: flex;
align-items: center; align-items: center;
} }
.trend-up .stat-number { .trend-up .stat-number {
color: #4caf50; color: var(--v-theme-success);
} }
.trend-down .stat-number { .trend-down .stat-number {
color: #f44336; color: var(--v-theme-error);
} }
.chart-container { .chart-container {
@@ -354,7 +354,7 @@ export default {
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
background: rgba(255, 255, 255, 0.8); background: var(--v-theme-overlay);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
@@ -365,6 +365,6 @@ export default {
.loading-text { .loading-text {
margin-top: 12px; margin-top: 12px;
font-size: 14px; font-size: 14px;
color: #666; color: var(--v-theme-secondaryText);
} }
</style> </style>

View File

@@ -132,12 +132,12 @@ export default {
.platform-title { .platform-title {
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
color: #333; color: var(--v-theme-primaryText);
} }
.platform-subtitle { .platform-subtitle {
font-size: 12px; font-size: 12px;
color: #666; color: var(--v-theme-secondaryText);
margin-top: 4px; margin-top: 4px;
} }
@@ -171,16 +171,16 @@ export default {
width: 24px; width: 24px;
height: 24px; height: 24px;
border-radius: 50%; border-radius: 50%;
background-color: #f0f0f0; background-color: var(--v-theme-surface);
color: #333; color: var(--v-theme-primaryText);
font-weight: 600; font-weight: 600;
font-size: 14px; font-size: 14px;
margin-right: 12px; margin-right: 12px;
} }
.top-rank { .top-rank {
background-color: #5e35b1; background-color: var(--v-theme-secondary);
color: white; color: var(--v-theme-surface);
} }
.platform-name { .platform-name {
@@ -195,19 +195,19 @@ export default {
.count-value { .count-value {
font-weight: 600; font-weight: 600;
font-size: 14px; font-size: 14px;
color: #5e35b1; color: var(--v-theme-secondary);
margin-right: 4px; margin-right: 4px;
} }
.count-label { .count-label {
font-size: 12px; font-size: 12px;
color: #666; color: var(--v-theme-secondaryText);
} }
.platform-stats-summary { .platform-stats-summary {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
background-color: #f5f5f5; background-color: var(--v-theme-containerBg);
border-radius: 8px; border-radius: 8px;
padding: 12px; padding: 12px;
margin-bottom: 16px; margin-bottom: 16px;
@@ -220,13 +220,13 @@ export default {
.stat-label { .stat-label {
font-size: 12px; font-size: 12px;
color: #666; color: var(--v-theme-secondaryText);
margin-bottom: 4px; margin-bottom: 4px;
} }
.stat-value { .stat-value {
font-weight: 600; font-weight: 600;
color: #333; color: var(--v-theme-primaryText);
} }
.platform-chart { .platform-chart {
@@ -246,7 +246,7 @@ export default {
} }
.no-data-text { .no-data-text {
color: #999; color: var(--v-theme-secondaryText);
margin-top: 16px; margin-top: 16px;
font-size: 14px; font-size: 14px;
} }

View File

@@ -61,7 +61,6 @@ class RstScene(Enum):
version="4.0.0", version="4.0.0",
) )
class Main(star.Star): class Main(star.Star):
def __init__(self, context: star.Context) -> None: def __init__(self, context: star.Context) -> None:
self.context = context self.context = context
cfg = context.get_config() cfg = context.get_config()
@@ -140,7 +139,7 @@ class Main(star.Star):
{notice}""" {notice}"""
event.set_result(MessageEventResult().message(msg).use_t2i(False)) event.set_result(MessageEventResult().message(msg).use_t2i(False))
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("llm") @filter.command("llm")
async def llm(self, event: AstrMessageEvent): async def llm(self, event: AstrMessageEvent):
"""开启/关闭 LLM""" """开启/关闭 LLM"""
@@ -216,9 +215,7 @@ class Main(star.Star):
"""获取已经安装的插件列表。""" """获取已经安装的插件列表。"""
plugin_list_info = "已加载的插件:\n" plugin_list_info = "已加载的插件:\n"
for plugin in self.context.get_all_stars(): for plugin in self.context.get_all_stars():
plugin_list_info += ( plugin_list_info += f"- `{plugin.name}` By {plugin.author}: {plugin.desc}"
f"- `{plugin.name}` By {plugin.author}: {plugin.desc}"
)
if not plugin.activated: if not plugin.activated:
plugin_list_info += " (未启用)" plugin_list_info += " (未启用)"
plugin_list_info += "\n" plugin_list_info += "\n"
@@ -271,9 +268,7 @@ class Main(star.Star):
event.set_result(MessageEventResult().message("安装插件成功。")) event.set_result(MessageEventResult().message("安装插件成功。"))
except Exception as e: except Exception as e:
logger.error(f"安装插件失败: {e}") logger.error(f"安装插件失败: {e}")
event.set_result( event.set_result(MessageEventResult().message(f"安装插件失败: {e}"))
MessageEventResult().message(f"安装插件失败: {e}")
)
return return
@plugin.command("help") @plugin.command("help")
@@ -319,7 +314,6 @@ class Main(star.Star):
ret += "更多帮助信息请查看插件仓库 README。" ret += "更多帮助信息请查看插件仓库 README。"
event.set_result(MessageEventResult().message(ret).use_t2i(False)) event.set_result(MessageEventResult().message(ret).use_t2i(False))
@filter.command("t2i") @filter.command("t2i")
async def t2i(self, event: AstrMessageEvent): async def t2i(self, event: AstrMessageEvent):
"""开关文本转图片""" """开关文本转图片"""
@@ -420,24 +414,20 @@ UID: {user_id} 此 ID 可用于设置管理员。
except ValueError: except ValueError:
event.set_result(MessageEventResult().message("此 SID 不在白名单内。")) event.set_result(MessageEventResult().message("此 SID 不在白名单内。"))
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("provider") @filter.command("provider")
async def provider( async def provider(
self, event: AstrMessageEvent, idx: Union[str, int] = None, idx2: int = None self, event: AstrMessageEvent, idx: Union[str, int] = None, idx2: int = None
): ):
"""查看或者切换 LLM Provider""" """查看或者切换 LLM Provider"""
if not self.context.get_using_provider():
event.set_result(
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。")
)
return
if idx is None: if idx is None:
ret = "## 载入的 LLM 提供商\n" ret = "## 载入的 LLM 提供商\n"
for idx, llm in enumerate(self.context.get_all_providers()): for idx, llm in enumerate(self.context.get_all_providers()):
id_ = llm.meta().id id_ = llm.meta().id
ret += f"{idx + 1}. {id_} ({llm.meta().model})" 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 += " (当前使用)"
ret += "\n" ret += "\n"
@@ -483,8 +473,6 @@ UID: {user_id} 此 ID 可用于设置管理员。
id_ = provider.meta().id id_ = provider.meta().id
self.context.provider_manager.curr_tts_provider_inst = provider self.context.provider_manager.curr_tts_provider_inst = provider
sp.put("curr_provider_tts", id_) sp.put("curr_provider_tts", id_)
if not self.context.provider_manager.tts_enabled:
self.context.provider_manager.tts_enabled = True
event.set_result( event.set_result(
MessageEventResult().message(f"成功切换到 {id_}") MessageEventResult().message(f"成功切换到 {id_}")
) )
@@ -499,8 +487,6 @@ UID: {user_id} 此 ID 可用于设置管理员。
id_ = provider.meta().id id_ = provider.meta().id
self.context.provider_manager.curr_stt_provider_inst = provider self.context.provider_manager.curr_stt_provider_inst = provider
sp.put("curr_provider_stt", id_) sp.put("curr_provider_stt", id_)
if not self.context.provider_manager.stt_enabled:
self.context.provider_manager.stt_enabled = True
event.set_result( event.set_result(
MessageEventResult().message(f"成功切换到 {id_}") MessageEventResult().message(f"成功切换到 {id_}")
) )
@@ -512,8 +498,6 @@ UID: {user_id} 此 ID 可用于设置管理员。
id_ = provider.meta().id id_ = provider.meta().id
self.context.provider_manager.curr_provider_inst = provider self.context.provider_manager.curr_provider_inst = provider
sp.put("curr_provider", id_) 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_}")) event.set_result(MessageEventResult().message(f"成功切换到 {id_}"))
else: else:
event.set_result(MessageEventResult().message("无效的参数。")) event.set_result(MessageEventResult().message("无效的参数。"))
@@ -589,6 +573,7 @@ UID: {user_id} 此 ID 可用于设置管理员。
message.set_result(MessageEventResult().message(ret)) message.set_result(MessageEventResult().message(ret))
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("model") @filter.command("model")
async def model_ls( async def model_ls(
self, message: AstrMessageEvent, idx_or_name: Union[int, str] = None self, message: AstrMessageEvent, idx_or_name: Union[int, str] = None
@@ -1033,7 +1018,11 @@ UID: {user_id} 此 ID 可用于设置管理员。
message.unified_msg_origin, cid message.unified_msg_origin, cid
) )
if not conversation: 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]": if not conversation.persona_id and not conversation.persona_id == "[%None]":
curr_persona_name = ( curr_persona_name = (
self.context.provider_manager.selected_default_persona["name"] self.context.provider_manager.selected_default_persona["name"]
@@ -1176,7 +1165,7 @@ UID: {user_id} 此 ID 可用于设置管理员。
@filter.command("gewe_code") @filter.command("gewe_code")
async def gewe_code(self, event: AstrMessageEvent, code: str): async def gewe_code(self, event: AstrMessageEvent, code: str):
"""保存 gewechat 验证码""" """保存 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: with open(code_path, "w", encoding="utf-8") as f:
f.write(code) f.write(code)
yield event.plain_result("验证码已保存。") yield event.plain_result("验证码已保存。")

View File

@@ -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):
"""可选择实现异步的插件销毁方法,当插件被卸载/停用时会调用。"""

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "AstrBot" name = "AstrBot"
version = "3.5.10" version = "3.5.13"
description = "易上手的多平台 LLM 聊天机器人及开发框架" description = "易上手的多平台 LLM 聊天机器人及开发框架"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
@@ -8,6 +8,7 @@ dependencies = [
"aiocqhttp>=1.4.4", "aiocqhttp>=1.4.4",
"aiodocker>=0.24.0", "aiodocker>=0.24.0",
"aiohttp>=3.11.18", "aiohttp>=3.11.18",
"aiosqlite>=0.21.0",
"anthropic>=0.51.0", "anthropic>=0.51.0",
"apscheduler>=3.11.0", "apscheduler>=3.11.0",
"beautifulsoup4>=4.13.4", "beautifulsoup4>=4.13.4",
@@ -19,12 +20,14 @@ dependencies = [
"defusedxml>=0.7.1", "defusedxml>=0.7.1",
"dingtalk-stream>=0.22.1", "dingtalk-stream>=0.22.1",
"docstring-parser>=0.16", "docstring-parser>=0.16",
"faiss-cpu>=1.10.0",
"filelock>=3.18.0", "filelock>=3.18.0",
"google-genai>=1.14.0", "google-genai>=1.14.0",
"googlesearch-python>=1.3.0", "googlesearch-python>=1.3.0",
"lark-oapi>=1.4.15", "lark-oapi>=1.4.15",
"lxml-html-clean>=0.4.2", "lxml-html-clean>=0.4.2",
"mcp>=1.8.0", "mcp>=1.8.0",
"nh3>=0.2.21",
"openai>=1.78.0", "openai>=1.78.0",
"ormsgpack>=1.9.1", "ormsgpack>=1.9.1",
"pillow>=11.2.1", "pillow>=11.2.1",

View File

@@ -35,3 +35,6 @@ click
filelock filelock
watchfiles watchfiles
websockets websockets
faiss-cpu
aiosqlite
nh3

154
uv.lock generated
View File

@@ -136,6 +136,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/6a/bc7e17a3e87a2985d3e8f4da4cd0f481060eb78fb08596c42be62c90a4d9/aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5", size = 7597, upload-time = "2024-12-13T17:10:38.469Z" }, { url = "https://files.pythonhosted.org/packages/ec/6a/bc7e17a3e87a2985d3e8f4da4cd0f481060eb78fb08596c42be62c90a4d9/aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5", size = 7597, upload-time = "2024-12-13T17:10:38.469Z" },
] ]
[[package]]
name = "aiosqlite"
version = "0.21.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/13/7d/8bca2bf9a247c2c5dfeec1d7a5f40db6518f88d314b8bca9da29670d2671/aiosqlite-0.21.0.tar.gz", hash = "sha256:131bb8056daa3bc875608c631c678cda73922a2d4ba8aec373b19f18c17e7aa3", size = 13454, upload-time = "2025-02-03T07:30:16.235Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f5/10/6c25ed6de94c49f88a91fa5018cb4c0f3625f31d5be9f771ebe5cc7cd506/aiosqlite-0.21.0-py3-none-any.whl", hash = "sha256:2549cf4057f95f53dcba16f2b64e8e2791d7e1adedb13197dd8ed77bb226d7d0", size = 15792, upload-time = "2025-02-03T07:30:13.6Z" },
]
[[package]] [[package]]
name = "annotated-types" name = "annotated-types"
version = "0.7.0" version = "0.7.0"
@@ -192,12 +204,13 @@ wheels = [
[[package]] [[package]]
name = "astrbot" name = "astrbot"
version = "3.5.9" version = "3.5.12"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiocqhttp" }, { name = "aiocqhttp" },
{ name = "aiodocker" }, { name = "aiodocker" },
{ name = "aiohttp" }, { name = "aiohttp" },
{ name = "aiosqlite" },
{ name = "anthropic" }, { name = "anthropic" },
{ name = "apscheduler" }, { name = "apscheduler" },
{ name = "beautifulsoup4" }, { name = "beautifulsoup4" },
@@ -209,12 +222,14 @@ dependencies = [
{ name = "defusedxml" }, { name = "defusedxml" },
{ name = "dingtalk-stream" }, { name = "dingtalk-stream" },
{ name = "docstring-parser" }, { name = "docstring-parser" },
{ name = "faiss-cpu" },
{ name = "filelock" }, { name = "filelock" },
{ name = "google-genai" }, { name = "google-genai" },
{ name = "googlesearch-python" }, { name = "googlesearch-python" },
{ name = "lark-oapi" }, { name = "lark-oapi" },
{ name = "lxml-html-clean" }, { name = "lxml-html-clean" },
{ name = "mcp" }, { name = "mcp" },
{ name = "nh3" },
{ name = "openai" }, { name = "openai" },
{ name = "ormsgpack" }, { name = "ormsgpack" },
{ name = "pillow" }, { name = "pillow" },
@@ -239,6 +254,7 @@ requires-dist = [
{ name = "aiocqhttp", specifier = ">=1.4.4" }, { name = "aiocqhttp", specifier = ">=1.4.4" },
{ name = "aiodocker", specifier = ">=0.24.0" }, { name = "aiodocker", specifier = ">=0.24.0" },
{ name = "aiohttp", specifier = ">=3.11.18" }, { name = "aiohttp", specifier = ">=3.11.18" },
{ name = "aiosqlite", specifier = ">=0.21.0" },
{ name = "anthropic", specifier = ">=0.51.0" }, { name = "anthropic", specifier = ">=0.51.0" },
{ name = "apscheduler", specifier = ">=3.11.0" }, { name = "apscheduler", specifier = ">=3.11.0" },
{ name = "beautifulsoup4", specifier = ">=4.13.4" }, { name = "beautifulsoup4", specifier = ">=4.13.4" },
@@ -250,12 +266,14 @@ requires-dist = [
{ name = "defusedxml", specifier = ">=0.7.1" }, { name = "defusedxml", specifier = ">=0.7.1" },
{ name = "dingtalk-stream", specifier = ">=0.22.1" }, { name = "dingtalk-stream", specifier = ">=0.22.1" },
{ name = "docstring-parser", specifier = ">=0.16" }, { name = "docstring-parser", specifier = ">=0.16" },
{ name = "faiss-cpu", specifier = ">=1.11.0" },
{ name = "filelock", specifier = ">=3.18.0" }, { name = "filelock", specifier = ">=3.18.0" },
{ name = "google-genai", specifier = ">=1.14.0" }, { name = "google-genai", specifier = ">=1.14.0" },
{ name = "googlesearch-python", specifier = ">=1.3.0" }, { name = "googlesearch-python", specifier = ">=1.3.0" },
{ name = "lark-oapi", specifier = ">=1.4.15" }, { name = "lark-oapi", specifier = ">=1.4.15" },
{ name = "lxml-html-clean", specifier = ">=0.4.2" }, { name = "lxml-html-clean", specifier = ">=0.4.2" },
{ name = "mcp", specifier = ">=1.8.0" }, { name = "mcp", specifier = ">=1.8.0" },
{ name = "nh3", specifier = ">=0.2.21" },
{ name = "openai", specifier = ">=1.78.0" }, { name = "openai", specifier = ">=1.78.0" },
{ name = "ormsgpack", specifier = ">=1.9.1" }, { name = "ormsgpack", specifier = ">=1.9.1" },
{ name = "pillow", specifier = ">=11.2.1" }, { name = "pillow", specifier = ">=11.2.1" },
@@ -612,6 +630,38 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453, upload-time = "2024-07-12T22:25:58.476Z" }, { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453, upload-time = "2024-07-12T22:25:58.476Z" },
] ]
[[package]]
name = "faiss-cpu"
version = "1.11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e7/9a/e33fc563f007924dd4ec3c5101fe5320298d6c13c158a24a9ed849058569/faiss_cpu-1.11.0.tar.gz", hash = "sha256:44877b896a2b30a61e35ea4970d008e8822545cb340eca4eff223ac7f40a1db9", size = 70218, upload-time = "2025-04-28T07:48:30.459Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ed/e5/7490368ec421e44efd60a21aa88d244653c674d8d6ee6bc455d8ee3d02ed/faiss_cpu-1.11.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:1995119152928c68096b0c1e5816e3ee5b1eebcf615b80370874523be009d0f6", size = 3307996, upload-time = "2025-04-28T07:47:29.126Z" },
{ url = "https://files.pythonhosted.org/packages/dd/ac/a94fbbbf4f38c2ad11862af92c071ff346630ebf33f3d36fe75c3817c2f0/faiss_cpu-1.11.0-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:788d7bf24293fdecc1b93f1414ca5cc62ebd5f2fecfcbb1d77f0e0530621c95d", size = 7886309, upload-time = "2025-04-28T07:47:31.668Z" },
{ url = "https://files.pythonhosted.org/packages/63/48/ad79f34f1b9eba58c32399ad4fbedec3f2a717d72fb03648e906aab48a52/faiss_cpu-1.11.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:73408d52429558f67889581c0c6d206eedcf6fabe308908f2bdcd28fd5e8be4a", size = 3778443, upload-time = "2025-04-28T07:47:33.685Z" },
{ url = "https://files.pythonhosted.org/packages/95/67/3c6b94dd3223a8ecaff1c10c11b4ac6f3f13f1ba8ab6b6109c24b6e9b23d/faiss_cpu-1.11.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:1f53513682ca94c76472544fa5f071553e428a1453e0b9755c9673f68de45f12", size = 31295174, upload-time = "2025-04-28T07:47:36.309Z" },
{ url = "https://files.pythonhosted.org/packages/a4/2c/d843256aabdb7f20f0f87f61efe3fb7c2c8e7487915f560ba523cfcbab57/faiss_cpu-1.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:30489de0356d3afa0b492ca55da164d02453db2f7323c682b69334fde9e8d48e", size = 15003860, upload-time = "2025-04-28T07:47:39.381Z" },
{ url = "https://files.pythonhosted.org/packages/ed/83/8aefc4d07624a868e046cc23ede8a59bebda57f09f72aee2150ef0855a82/faiss_cpu-1.11.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a90d1c81d0ecf2157e1d2576c482d734d10760652a5b2fcfa269916611e41f1c", size = 3307997, upload-time = "2025-04-28T07:47:41.905Z" },
{ url = "https://files.pythonhosted.org/packages/2b/64/f97e91d89dc6327e08f619fe387d7d9945bc4be3b0f1ca1e494a41c92ebe/faiss_cpu-1.11.0-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:2c39a388b059fb82cd97fbaa7310c3580ced63bf285be531453bfffbe89ea3dd", size = 7886308, upload-time = "2025-04-28T07:47:44.677Z" },
{ url = "https://files.pythonhosted.org/packages/44/0a/7c17b6df017b0bc127c6aa4066b028281e67ab83d134c7433c4e75cd6bb6/faiss_cpu-1.11.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:a4e3433ffc7f9b8707a7963db04f8676a5756868d325644db2db9d67a618b7a0", size = 3778441, upload-time = "2025-04-28T07:47:46.914Z" },
{ url = "https://files.pythonhosted.org/packages/53/45/7c85551025d9f0237d891b5cffdc5d4a366011d53b4b0a423b972cc52cea/faiss_cpu-1.11.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:926645f1b6829623bc88e93bc8ca872504d604718ada3262e505177939aaee0a", size = 31295136, upload-time = "2025-04-28T07:47:49.299Z" },
{ url = "https://files.pythonhosted.org/packages/7f/9a/accade34b8668b21206c0c4cf0b96cd0b750b693ba5b255c1c10cfee460f/faiss_cpu-1.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:931db6ed2197c03a7fdf833b057c13529afa2cec8a827aa081b7f0543e4e671b", size = 15003710, upload-time = "2025-04-28T07:47:52.226Z" },
{ url = "https://files.pythonhosted.org/packages/3b/d3/7178fa07047fd770964a83543329bb5e3fc1447004cfd85186ccf65ec3ee/faiss_cpu-1.11.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:356437b9a46f98c25831cdae70ca484bd6c05065af6256d87f6505005e9135b9", size = 3313807, upload-time = "2025-04-28T07:47:54.533Z" },
{ url = "https://files.pythonhosted.org/packages/9e/71/25f5f7b70a9f22a3efe19e7288278da460b043a3b60ad98e4e47401ed5aa/faiss_cpu-1.11.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c4a3d35993e614847f3221c6931529c0bac637a00eff0d55293e1db5cb98c85f", size = 7913537, upload-time = "2025-04-28T07:47:56.723Z" },
{ url = "https://files.pythonhosted.org/packages/b0/c8/a5cb8466c981ad47750e1d5fda3d4223c82f9da947538749a582b3a2d35c/faiss_cpu-1.11.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:8f9af33e0b8324e8199b93eb70ac4a951df02802a9dcff88e9afc183b11666f0", size = 3785180, upload-time = "2025-04-28T07:47:59.004Z" },
{ url = "https://files.pythonhosted.org/packages/7f/37/eaf15a7d80e1aad74f56cf737b31b4547a1a664ad3c6e4cfaf90e82454a8/faiss_cpu-1.11.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:48b7e7876829e6bdf7333041800fa3c1753bb0c47e07662e3ef55aca86981430", size = 31287630, upload-time = "2025-04-28T07:48:01.248Z" },
{ url = "https://files.pythonhosted.org/packages/ff/5c/902a78347e9c47baaf133e47863134e564c39f9afe105795b16ee986b0df/faiss_cpu-1.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:bdc199311266d2be9d299da52361cad981393327b2b8aa55af31a1b75eaaf522", size = 15005398, upload-time = "2025-04-28T07:48:04.232Z" },
{ url = "https://files.pythonhosted.org/packages/92/90/d2329ce56423cc61f4c20ae6b4db001c6f88f28bf5a7ef7f8bbc246fd485/faiss_cpu-1.11.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:0c98e5feff83b87348e44eac4d578d6f201780dae6f27f08a11d55536a20b3a8", size = 3313807, upload-time = "2025-04-28T07:48:06.486Z" },
{ url = "https://files.pythonhosted.org/packages/24/14/8af8f996d54e6097a86e6048b1a2c958c52dc985eb4f935027615079939e/faiss_cpu-1.11.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:796e90389427b1c1fb06abdb0427bb343b6350f80112a2e6090ac8f176ff7416", size = 7913539, upload-time = "2025-04-28T07:48:08.338Z" },
{ url = "https://files.pythonhosted.org/packages/b2/2b/437c2f36c3aa3cffe041479fced1c76420d3e92e1f434f1da3be3e6f32b1/faiss_cpu-1.11.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:2b6e355dda72b3050991bc32031b558b8f83a2b3537a2b9e905a84f28585b47e", size = 3785181, upload-time = "2025-04-28T07:48:10.594Z" },
{ url = "https://files.pythonhosted.org/packages/66/75/955527414371843f558234df66fa0b62c6e86e71e4022b1be9333ac6004c/faiss_cpu-1.11.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6c482d07194638c169b4422774366e7472877d09181ea86835e782e6304d4185", size = 31287635, upload-time = "2025-04-28T07:48:12.93Z" },
{ url = "https://files.pythonhosted.org/packages/50/51/35b7a3f47f7859363a367c344ae5d415ea9eda65db0a7d497c7ea2c0b576/faiss_cpu-1.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:13eac45299532b10e911bff1abbb19d1bf5211aa9e72afeade653c3f1e50e042", size = 15005455, upload-time = "2025-04-28T07:48:16.173Z" },
]
[[package]] [[package]]
name = "filelock" name = "filelock"
version = "3.18.0" version = "3.18.0"
@@ -1260,6 +1310,99 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/96/10/7d526c8974f017f1e7ca584c71ee62a638e9334d8d33f27d7cdfc9ae79e4/multidict-6.4.3-py3-none-any.whl", hash = "sha256:59fe01ee8e2a1e8ceb3f6dbb216b09c8d9f4ef1c22c4fc825d045a147fa2ebc9", size = 10400, upload-time = "2025-04-10T22:20:16.445Z" }, { url = "https://files.pythonhosted.org/packages/96/10/7d526c8974f017f1e7ca584c71ee62a638e9334d8d33f27d7cdfc9ae79e4/multidict-6.4.3-py3-none-any.whl", hash = "sha256:59fe01ee8e2a1e8ceb3f6dbb216b09c8d9f4ef1c22c4fc825d045a147fa2ebc9", size = 10400, upload-time = "2025-04-10T22:20:16.445Z" },
] ]
[[package]]
name = "nh3"
version = "0.2.21"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/37/30/2f81466f250eb7f591d4d193930df661c8c23e9056bdc78e365b646054d8/nh3-0.2.21.tar.gz", hash = "sha256:4990e7ee6a55490dbf00d61a6f476c9a3258e31e711e13713b2ea7d6616f670e", size = 16581, upload-time = "2025-02-25T13:38:44.619Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/81/b83775687fcf00e08ade6d4605f0be9c4584cb44c4973d9f27b7456a31c9/nh3-0.2.21-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:fcff321bd60c6c5c9cb4ddf2554e22772bb41ebd93ad88171bbbb6f271255286", size = 1297678, upload-time = "2025-02-25T13:37:56.063Z" },
{ url = "https://files.pythonhosted.org/packages/22/ee/d0ad8fb4b5769f073b2df6807f69a5e57ca9cea504b78809921aef460d20/nh3-0.2.21-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31eedcd7d08b0eae28ba47f43fd33a653b4cdb271d64f1aeda47001618348fde", size = 733774, upload-time = "2025-02-25T13:37:58.419Z" },
{ url = "https://files.pythonhosted.org/packages/ea/76/b450141e2d384ede43fe53953552f1c6741a499a8c20955ad049555cabc8/nh3-0.2.21-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d426d7be1a2f3d896950fe263332ed1662f6c78525b4520c8e9861f8d7f0d243", size = 760012, upload-time = "2025-02-25T13:38:01.017Z" },
{ url = "https://files.pythonhosted.org/packages/97/90/1182275db76cd8fbb1f6bf84c770107fafee0cb7da3e66e416bcb9633da2/nh3-0.2.21-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9d67709bc0d7d1f5797b21db26e7a8b3d15d21c9c5f58ccfe48b5328483b685b", size = 923619, upload-time = "2025-02-25T13:38:02.617Z" },
{ url = "https://files.pythonhosted.org/packages/29/c7/269a7cfbec9693fad8d767c34a755c25ccb8d048fc1dfc7a7d86bc99375c/nh3-0.2.21-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:55823c5ea1f6b267a4fad5de39bc0524d49a47783e1fe094bcf9c537a37df251", size = 1000384, upload-time = "2025-02-25T13:38:04.402Z" },
{ url = "https://files.pythonhosted.org/packages/68/a9/48479dbf5f49ad93f0badd73fbb48b3d769189f04c6c69b0df261978b009/nh3-0.2.21-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:818f2b6df3763e058efa9e69677b5a92f9bc0acff3295af5ed013da544250d5b", size = 918908, upload-time = "2025-02-25T13:38:06.693Z" },
{ url = "https://files.pythonhosted.org/packages/d7/da/0279c118f8be2dc306e56819880b19a1cf2379472e3b79fc8eab44e267e3/nh3-0.2.21-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b3b5c58161e08549904ac4abd450dacd94ff648916f7c376ae4b2c0652b98ff9", size = 909180, upload-time = "2025-02-25T13:38:10.941Z" },
{ url = "https://files.pythonhosted.org/packages/26/16/93309693f8abcb1088ae143a9c8dbcece9c8f7fb297d492d3918340c41f1/nh3-0.2.21-cp313-cp313t-win32.whl", hash = "sha256:637d4a10c834e1b7d9548592c7aad760611415fcd5bd346f77fd8a064309ae6d", size = 532747, upload-time = "2025-02-25T13:38:12.548Z" },
{ url = "https://files.pythonhosted.org/packages/a2/3a/96eb26c56cbb733c0b4a6a907fab8408ddf3ead5d1b065830a8f6a9c3557/nh3-0.2.21-cp313-cp313t-win_amd64.whl", hash = "sha256:713d16686596e556b65e7f8c58328c2df63f1a7abe1277d87625dcbbc012ef82", size = 528908, upload-time = "2025-02-25T13:38:14.059Z" },
{ url = "https://files.pythonhosted.org/packages/ba/1d/b1ef74121fe325a69601270f276021908392081f4953d50b03cbb38b395f/nh3-0.2.21-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a772dec5b7b7325780922dd904709f0f5f3a79fbf756de5291c01370f6df0967", size = 1316133, upload-time = "2025-02-25T13:38:16.601Z" },
{ url = "https://files.pythonhosted.org/packages/b8/f2/2c7f79ce6de55b41e7715f7f59b159fd59f6cdb66223c05b42adaee2b645/nh3-0.2.21-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d002b648592bf3033adfd875a48f09b8ecc000abd7f6a8769ed86b6ccc70c759", size = 758328, upload-time = "2025-02-25T13:38:18.972Z" },
{ url = "https://files.pythonhosted.org/packages/6d/ad/07bd706fcf2b7979c51b83d8b8def28f413b090cf0cb0035ee6b425e9de5/nh3-0.2.21-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2a5174551f95f2836f2ad6a8074560f261cf9740a48437d6151fd2d4d7d617ab", size = 747020, upload-time = "2025-02-25T13:38:20.571Z" },
{ url = "https://files.pythonhosted.org/packages/75/99/06a6ba0b8a0d79c3d35496f19accc58199a1fb2dce5e711a31be7e2c1426/nh3-0.2.21-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:b8d55ea1fc7ae3633d758a92aafa3505cd3cc5a6e40470c9164d54dff6f96d42", size = 944878, upload-time = "2025-02-25T13:38:22.204Z" },
{ url = "https://files.pythonhosted.org/packages/79/d4/dc76f5dc50018cdaf161d436449181557373869aacf38a826885192fc587/nh3-0.2.21-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ae319f17cd8960d0612f0f0ddff5a90700fa71926ca800e9028e7851ce44a6f", size = 903460, upload-time = "2025-02-25T13:38:25.951Z" },
{ url = "https://files.pythonhosted.org/packages/cd/c3/d4f8037b2ab02ebf5a2e8637bd54736ed3d0e6a2869e10341f8d9085f00e/nh3-0.2.21-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ca02ac6f27fc80f9894409eb61de2cb20ef0a23740c7e29f9ec827139fa578", size = 839369, upload-time = "2025-02-25T13:38:28.174Z" },
{ url = "https://files.pythonhosted.org/packages/11/a9/1cd3c6964ec51daed7b01ca4686a5c793581bf4492cbd7274b3f544c9abe/nh3-0.2.21-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5f77e62aed5c4acad635239ac1290404c7e940c81abe561fd2af011ff59f585", size = 739036, upload-time = "2025-02-25T13:38:30.539Z" },
{ url = "https://files.pythonhosted.org/packages/fd/04/bfb3ff08d17a8a96325010ae6c53ba41de6248e63cdb1b88ef6369a6cdfc/nh3-0.2.21-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:087ffadfdcd497658c3adc797258ce0f06be8a537786a7217649fc1c0c60c293", size = 768712, upload-time = "2025-02-25T13:38:32.992Z" },
{ url = "https://files.pythonhosted.org/packages/9e/aa/cfc0bf545d668b97d9adea4f8b4598667d2b21b725d83396c343ad12bba7/nh3-0.2.21-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ac7006c3abd097790e611fe4646ecb19a8d7f2184b882f6093293b8d9b887431", size = 930559, upload-time = "2025-02-25T13:38:35.204Z" },
{ url = "https://files.pythonhosted.org/packages/78/9d/6f5369a801d3a1b02e6a9a097d56bcc2f6ef98cffebf03c4bb3850d8e0f0/nh3-0.2.21-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:6141caabe00bbddc869665b35fc56a478eb774a8c1dfd6fba9fe1dfdf29e6efa", size = 1008591, upload-time = "2025-02-25T13:38:37.099Z" },
{ url = "https://files.pythonhosted.org/packages/a6/df/01b05299f68c69e480edff608248313cbb5dbd7595c5e048abe8972a57f9/nh3-0.2.21-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:20979783526641c81d2f5bfa6ca5ccca3d1e4472474b162c6256745fbfe31cd1", size = 925670, upload-time = "2025-02-25T13:38:38.696Z" },
{ url = "https://files.pythonhosted.org/packages/3d/79/bdba276f58d15386a3387fe8d54e980fb47557c915f5448d8c6ac6f7ea9b/nh3-0.2.21-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a7ea28cd49293749d67e4fcf326c554c83ec912cd09cd94aa7ec3ab1921c8283", size = 917093, upload-time = "2025-02-25T13:38:40.249Z" },
{ url = "https://files.pythonhosted.org/packages/e7/d8/c6f977a5cd4011c914fb58f5ae573b071d736187ccab31bfb1d539f4af9f/nh3-0.2.21-cp38-abi3-win32.whl", hash = "sha256:6c9c30b8b0d291a7c5ab0967ab200598ba33208f754f2f4920e9343bdd88f79a", size = 537623, upload-time = "2025-02-25T13:38:41.893Z" },
{ url = "https://files.pythonhosted.org/packages/23/fc/8ce756c032c70ae3dd1d48a3552577a325475af2a2f629604b44f571165c/nh3-0.2.21-cp38-abi3-win_amd64.whl", hash = "sha256:bb0014948f04d7976aabae43fcd4cb7f551f9f8ce785a4c9ef66e6c2590f8629", size = 535283, upload-time = "2025-02-25T13:38:43.355Z" },
]
[[package]]
name = "numpy"
version = "2.2.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" },
{ url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" },
{ url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" },
{ url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" },
{ url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" },
{ url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" },
{ url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" },
{ url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" },
{ url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" },
{ url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" },
{ url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" },
{ url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" },
{ url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" },
{ url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" },
{ url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" },
{ url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" },
{ url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" },
{ url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" },
{ url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" },
{ url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" },
{ url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" },
{ url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" },
{ url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" },
{ url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" },
{ url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" },
{ url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" },
{ url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" },
{ url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" },
{ url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" },
{ url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" },
{ url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" },
{ url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" },
{ url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" },
{ url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" },
{ url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" },
{ url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" },
{ url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" },
{ url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" },
{ url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" },
{ url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" },
{ url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" },
{ url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" },
{ url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" },
{ url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" },
{ url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" },
{ url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" },
{ url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" },
{ url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" },
{ url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" },
{ url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" },
{ url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" },
{ url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" },
{ url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" },
]
[[package]] [[package]]
name = "openai" name = "openai"
version = "1.78.0" version = "1.78.0"
@@ -1328,6 +1471,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/60/0ee5d790f13507e1f75ac21fc82dc1ef29afe1f520bd0f249d65b2f4839b/ormsgpack-1.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:a4bc63fb30db94075611cedbbc3d261dd17cf2aa8ff75a0fd684cd45ca29cb1b", size = 125371, upload-time = "2025-03-28T07:14:25.176Z" }, { url = "https://files.pythonhosted.org/packages/b0/60/0ee5d790f13507e1f75ac21fc82dc1ef29afe1f520bd0f249d65b2f4839b/ormsgpack-1.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:a4bc63fb30db94075611cedbbc3d261dd17cf2aa8ff75a0fd684cd45ca29cb1b", size = 125371, upload-time = "2025-03-28T07:14:25.176Z" },
] ]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]] [[package]]
name = "pillow" name = "pillow"
version = "11.2.1" version = "11.2.1"