Compare commits

...

201 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
Soulter
cb02dfe1a4 perf: 优化超时时间 2025-05-16 20:00:14 +08:00
Soulter
b50739e1af perf: 优化登录超时时间 2025-05-16 19:33:37 +08:00
Soulter
8da1b0212d Update README.md 2025-05-16 18:46:26 +08:00
Soulter
ca1f2acb33 Merge pull request #1551 from GowayLee/master
Feature: 添加对 MiniMax TTS API的支持
2025-05-16 18:32:49 +08:00
Soulter
c15f966669 fix: 修复 minimax 相关问题 2025-05-16 18:32:08 +08:00
Soulter
7705b8781a 📦 release: v3.5.10 2025-05-16 17:50:56 +08:00
Soulter
b2502746f0 perf: QQ 下,屏蔽 QQ 管家的消息事件 2025-05-16 17:49:17 +08:00
Soulter
ab68094386 docs: update platform tutprial map 2025-05-16 17:33:57 +08:00
Soulter
bbec701223 Merge pull request #1569 from xiamuceer-j/master
适配一个个人微信适配器——wechatpadpro
2025-05-16 17:29:57 +08:00
Soulter
b29d14e600 perf: 优化适配器终止流程 2025-05-16 17:29:33 +08:00
Soulter
86e51c5cd1 perf: 改进 wechatpadpro 超时重连 2025-05-16 17:22:10 +08:00
Soulter
cb8267be3f feat: wechatpadpro 支持图片接收 2025-05-16 17:18:42 +08:00
xiamuceer
eaed43915c Merge remote-tracking branch 'origin/master' 2025-05-16 17:18:04 +08:00
xiamuceer
bd91fd2c38 Merge branch 'master' of https://github.com/xiamuceer-j/AstrBot 2025-05-16 17:17:51 +08:00
xiamuceer
1203b214cd Merge branch 'master' of https://github.com/xiamuceer-j/AstrBot 2025-05-16 17:05:16 +08:00
xiamuceer
c3fec15f11 update: 添加ws超时重连机制,避免过长时间收不到消息 2025-05-16 17:00:06 +08:00
Soulter
0545653494 feat: 支持轮询消息 2025-05-16 16:54:49 +08:00
Soulter
db2989bdb4 perf: guess private message username 2025-05-16 15:42:33 +08:00
xiamuceer
587bd00a19 update: 新增send_by_session方法,接受处理来自AstrBot核心的消息 2025-05-16 14:30:05 +08:00
Soulter
960ff438e8 🎈perf: 旧消息丢弃 2025-05-16 13:26:45 +08:00
Raven95676
98e7ea85d3 fix: 正确导入WeChatPadProAdapter 2025-05-16 12:39:14 +08:00
xiamuceer
2549e44710 fix: 移除错误引用 2025-05-16 12:26:54 +08:00
xiamuceer
4d32b563ca fix: 对auth_key授权码进行脱敏处理 2025-05-16 12:08:49 +08:00
xiamuceer
3a4b732977 fix: 修复@消息适配,并写明适配器 2025-05-16 11:52:54 +08:00
夏目侧耳
500909a28e Update astrbot/core/platform/sources/wechatpadpro/wechatpadpro_message_event.py
Co-authored-by: 鸦羽 <Raven95676@gmail.com>
2025-05-16 11:47:52 +08:00
Soulter
07753eb25b Merge pull request #1561 from Raven95676/Fix/1554
fix(tts): record组件单独发送以保证兼容性
2025-05-16 11:10:45 +08:00
Soulter
c6eaf3d010 refactor: use aiohttp 2025-05-16 11:04:01 +08:00
Soulter
6723fe8271 🐛 fix: cannot save value when fullscreen editor mode 2025-05-16 10:37:30 +08:00
Raven95676
3348b70435 chore: add dependency 2025-05-16 10:30:29 +08:00
Soulter
35a8527c16 🎈 perf: update defaule value of minimax-timber-weight 2025-05-16 10:29:46 +08:00
Soulter
7afc475290 🐛 fix: value cannot displayed when fullscreen editior mode 2025-05-16 10:29:22 +08:00
Soulter
789bceaa3a Merge remote-tracking branch 'origin/master' into GowayLee/master 2025-05-16 10:23:30 +08:00
Soulter
abbc043969 Merge pull request #1575 from AstrBotDevs/feat-code-editor
Feature: WebUI 配置项支持代码编辑器模式
2025-05-16 10:22:16 +08:00
Soulter
654e5762f1 🐛 fix: 修复 VueMonacoEditor 的 v-model 绑定方式 2025-05-16 10:20:03 +08:00
Soulter
507c3e3629 feat: 配置项支持代码编辑器模式 2025-05-16 10:14:16 +08:00
Raven95676
991dfeb2f2 style: format code, disable redundant logs 2025-05-16 09:28:15 +08:00
夏目侧耳
26482fc2d3 Update astrbot/core/platform/sources/wechatpadpro/wechatpadpro_adapter.py
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2025-05-15 20:59:53 +08:00
夏目侧耳
e0ce6d9688 Update astrbot/core/platform/sources/wechatpadpro/wechatpadpro_adapter.py
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2025-05-15 20:57:22 +08:00
xiamuceer
946595216a 优化wechapadpro代码结构 2025-05-15 20:43:33 +08:00
anka
864b6bc56d fix: 🤠 修复指令后有@导致无法触发指令的问题 2025-05-15 20:00:46 +08:00
夏目侧耳
6ea5b7581f Update astrbot/core/platform/sources/wechatpadpro/wechatpadpro_message_event.py
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2025-05-15 19:12:42 +08:00
夏目侧耳
f70b8f0c10 Update astrbot/core/platform/sources/wechatpadpro/wechatpadpro_adapter.py
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2025-05-15 19:09:56 +08:00
夏目侧耳
1593bcb537 Update astrbot/core/platform/sources/wechatpadpro/wechatpadpro_adapter.py
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2025-05-15 17:50:29 +08:00
xiamuceer
bf7fc02c8d 适配一个个人微信适配器——wechatpadpro 2025-05-15 17:26:31 +08:00
Raven95676
143702b92b fix(tts): record组件单独发送以保证兼容性 2025-05-15 10:18:05 +08:00
Raven95676
c5ccc1a084 feat(Video): 增加视频消息组件的文件转换和注册功能 2025-05-15 09:50:27 +08:00
Soulter
2ecb52a9b2 Merge pull request #1529 from anka-afk/1446-bug-mcp
feat: 😽将At字段(非唤起)添加至message_str,修正message_str构造方式
2025-05-14 23:06:25 +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
Li Haoyuan
25ef0039e4 refactor: Optimize MiniMax TTS API Provider 2025-05-14 20:59:45 +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
渡鸦95676
b6d1515d58 Merge pull request #1541 from Raven95676/fix/astrbot-reboot
fix: 回退至os.execl以兼容docker,改用双引号处理路径空格
2025-05-14 14:57:13 +08:00
Li Haoyuan
e01d4264e3 docs: Adjust MiniMax TTS timber_weights description 2025-05-14 14:40:25 +08:00
Li Haoyuan
2117b65487 feat: Support timber_weights for MiniMax TTS 2025-05-14 14:21:23 +08:00
Li Haoyuan
a7823b352f docs: Adjust MiniMax TTS configuration info 2025-05-14 13:09:09 +08:00
Li Haoyuan
c543b62a08 Merge branch 'AstrBotDevs:master' into master 2025-05-14 13:02:54 +08:00
Li Haoyuan
3923b87f08 feat: Add MiniMax TTS API provider 2025-05-14 13:02:31 +08:00
Soulter
b7ecdadb83 docs: update providers 2025-05-14 09:35:59 +08:00
Soulter
5ff121e1ed docs: PPIO 派欧云 2025-05-14 09:33:35 +08:00
Soulter
f486e5448f Merge pull request #1539 from Raven95676/Feature/ppio
feat: 接入PPIO派欧云
2025-05-14 09:07:38 +08:00
Raven95676
c5aae98558 fix: update reboot logic to handle executable paths correctly 2025-05-13 16:03:04 +08:00
Raven95676
6d8a3b9897 fix: 回退至os.execl以兼容docker,改用双引号处理路径空格 2025-05-13 10:18:11 +08:00
Raven95676
6d98780e19 feat: 接入PPIO派欧云 2025-05-12 18:22:02 +08:00
Raven95676
3ad2c46f3f perf: tg适配器同步aiocqhttp处理逻辑 2025-05-12 15:04:23 +08:00
Raven95676
a730cee7fd fix: at全体不加入message_str 2025-05-12 14:48:31 +08:00
anka
77c823c100 fix: 增加对全体成员的支持 2025-05-12 11:32:40 +08:00
anka
124f21c67a Merge remote-tracking branch 'origin/1446-bug-mcp' into 1446-bug-mcp 2025-05-12 11:24:09 +08:00
anka
e46cf20dd3 fix: 不再添加唤醒的@到message_str 2025-05-12 11:22:46 +08:00
Raven95676
4bef5e8313 fix: 避免message_str被覆盖 2025-05-12 00:21:48 +08:00
anka
22e93b0af4 Merge branch 'AstrBotDevs:master' into 1446-bug-mcp 2025-05-11 22:59:02 +08:00
anka
5aeca9662b feat: 对aiocqhttp中, At字段新增处理: 现在At字段同时也会被解析为文本信息(但消息链并没有修改, 只是在用于llm请求的文本中添加了At信息) 2025-05-11 22:57:50 +08:00
Raven95676
b996cf1f05 chore: update multiple dependencies 2025-05-11 22:16:16 +08:00
渡鸦95676
878a106877 fix changelog 2025-05-11 21:31:27 +08:00
Soulter
45d36f86fd fix: 优化限流逻辑,确保在达到限流阈值时正确处理请求 2025-05-11 21:22:14 +08:00
Soulter
b108ae403a docs: uvx 2025-05-11 20:31:46 +08:00
Soulter
887ed66768 docs: uvx 2025-05-11 20:30:30 +08:00
107 changed files with 7658 additions and 2711 deletions

View File

@@ -33,7 +33,7 @@ AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用
> [!NOTE] > [!NOTE]
> >
> 个人微信接入所依赖的开源项目 Gewechat 近期已停止维护,我们正在评估其他方案(如 xxxbot 等)并将在数日内接入(很快!)。目前推荐微信用户暂时使用**微信官方**推出的企业微信接入方式和微信客服接入方式(版本 >= v3.5.7)。详情请前往 [#1443](https://github.com/AstrBotDevs/AstrBot/issues/1443) 讨论。 > 个人微信接入所依赖的开源项目 Gewechat 近期已停止维护,`v3.5.10` 已经支持接入 WeChatPadPro 替换 gewechat 方式。详见文档 [WeChatPadPro](https://astrbot.app/deploy/platform/wechat/wechatpadpro.html)
## ✨ 近期更新 ## ✨ 近期更新
@@ -78,14 +78,29 @@ AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用
#### 手动部署 #### 手动部署
推荐使用 `uv` > 推荐使用 `uv`。
首先,安装 uv
```bash
pip install uv
```
通过 Git Clone 安装 AstrBot
```bash ```bash
git clone https://github.com/AstrBotDevs/AstrBot && cd AstrBot git clone https://github.com/AstrBotDevs/AstrBot && cd AstrBot
pip install uv
uv run main.py uv run main.py
``` ```
或者,直接通过 uvx 安装 AstrBot
```bash
mkdir astrbot && cd astrbot
uvx astrbot init
# uvx astrbot run
```
或者请参阅官方文档 [通过源码部署 AstrBot](https://astrbot.app/deploy/astrbot/cli.html) 。 或者请参阅官方文档 [通过源码部署 AstrBot](https://astrbot.app/deploy/astrbot/cli.html) 。
#### Replit 部署 #### Replit 部署
@@ -113,21 +128,26 @@ uv run main.py
| 名称 | 支持性 | 类型 | 备注 | | 名称 | 支持性 | 类型 | 备注 |
| -------- | ------- | ------- | ------- | | -------- | ------- | ------- | ------- |
| OpenAI API | ✔ | 文本生成 | 也支持 DeepSeek、Google Gemini、GLM、Kimi、硅基流动、xAI 等兼容 OpenAI API 的服务 | | OpenAI API | ✔ | 文本生成 | 也支持 DeepSeek、Google Gemini、GLM、Kimi、xAI 等兼容 OpenAI API 的服务 |
| Claude API | ✔ | 文本生成 | | | Claude API | ✔ | 文本生成 | |
| Google Gemini API | ✔ | 文本生成 | | | Google Gemini API | ✔ | 文本生成 | |
| Dify | ✔ | LLMOps | | | Dify | ✔ | LLMOps | |
| DashScope(阿里云百炼应用) | ✔ | LLMOps | | | 阿里云百炼应用 | ✔ | LLMOps | |
| Ollama | ✔ | 模型加载器 | 本地部署 DeepSeek、Llama 等开源语言模型 | | Ollama | ✔ | 模型加载器 | 本地部署 DeepSeek、Llama 等开源语言模型 |
| LM Studio | ✔ | 模型加载器 | 本地部署 DeepSeek、Llama 等开源语言模型 | | LM Studio | ✔ | 模型加载器 | 本地部署 DeepSeek、Llama 等开源语言模型 |
| LLMTuner | ✔ | 模型加载器 | 本地加载 lora 等微调模型 | | LLMTuner | ✔ | 模型加载器 | 本地加载 lora 等微调模型 |
| 硅基流动 | ✔ | 模型 API 服务平台 | |
| PPIO 派欧云 | ✔ | 模型 API 服务平台 | |
| OneAPI | ✔ | LLM 分发系统 | | | OneAPI | ✔ | LLM 分发系统 | |
| Whisper | ✔ | 语音转文本 | 支持 API、本地部署 | | Whisper | ✔ | 语音转文本 | 支持 API、本地部署 |
| SenseVoice | ✔ | 语音转文本 | 本地部署 | | SenseVoice | ✔ | 语音转文本 | 本地部署 |
| OpenAI TTS API | ✔ | 文本转语音 | | | OpenAI TTS API | ✔ | 文本转语音 | |
| GSVI | ✔ | 文本转语音 | GPT-Sovits-Inference | | GSVI | ✔ | 文本转语音 | GPT-Sovits-Inference |
| Fishaudio | ✔ | 文本转语音 | GPT-Sovits 作者参与的项目 | | FishAudio | ✔ | 文本转语音 | GPT-Sovits 作者参与的项目 |
| Edge-TTS | ✔ | 文本转语音 | Edge 浏览器的免费 TTS | | Edge TTS | ✔ | 文本转语音 | Edge 浏览器的免费 TTS |
| 阿里云百炼 TTS | ✔ | 文本转语音 | |
| Azure TTS | ✔ | 文本转语音 | Microsoft Azure TTS |
## ❤️ 贡献 ## ❤️ 贡献

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.9" 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,
@@ -155,6 +157,16 @@ CONFIG_METADATA_2 = {
"host": "这里填写你的局域网IP或者公网服务器IP", "host": "这里填写你的局域网IP或者公网服务器IP",
"port": 11451, "port": 11451,
}, },
"wechatpadpro(微信)": {
"id": "wechatpadpro",
"type": "wechatpadpro",
"enable": False,
"admin_key": "stay33",
"host": "这里填写你的局域网IP或者公网服务器IP",
"port": 8059,
"wpp_active_message_poll": False,
"wpp_active_message_poll_interval": 3,
},
"weixin_official_account(微信公众平台)": { "weixin_official_account(微信公众平台)": {
"id": "weixin_official_account", "id": "weixin_official_account",
"type": "weixin_official_account", "type": "weixin_official_account",
@@ -166,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",
@@ -210,10 +223,25 @@ CONFIG_METADATA_2 = {
}, },
}, },
"items": { "items": {
"active_send_mode": {
"description": "是否换用主动发送接口",
"type": "bool",
"desc": "只有企业认证的公众号才能主动发送。主动发送接口的限制会少一些。",
},
"wpp_active_message_poll": {
"description": "是否启用主动消息轮询",
"type": "bool",
"hint": "只有当你发现微信消息没有按时同步到 AstrBot 时,才需要启用这个功能,默认不启用。",
},
"wpp_active_message_poll_interval": {
"description": "主动消息轮询间隔",
"type": "int",
"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",
@@ -236,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": "适配器类型",
@@ -650,6 +678,18 @@ CONFIG_METADATA_2 = {
"model": "moonshot-v1-8k", "model": "moonshot-v1-8k",
}, },
}, },
"PPIO派欧云": {
"id": "ppio",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://api.ppinfra.com/v3/openai",
"timeout": 120,
"model_config": {
"model": "deepseek/deepseek-r1",
},
},
"LLMTuner": { "LLMTuner": {
"id": "llmtuner_default", "id": "llmtuner_default",
"type": "llm_tuner", "type": "llm_tuner",
@@ -786,46 +826,167 @@ 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)": {
"id": "minimax_tts",
"type": "minimax_tts_api",
"provider_type": "text_to_speech",
"enable": False,
"api_key": "",
"api_base": "https://api.minimax.chat/v1/t2a_v2",
"minimax-group-id": "",
"model": "speech-02-turbo",
"minimax-langboost": "auto",
"minimax-voice-speed": 1.0,
"minimax-voice-vol": 1.0,
"minimax-voice-pitch": 0,
"minimax-is-timber-weight": False,
"minimax-voice-id": "female-shaonv",
"minimax-timber-weight": '[\n {\n "voice_id": "Chinese (Mandarin)_Warm_Girl",\n "weight": 25\n },\n {\n "voice_id": "Chinese (Mandarin)_BashfulGirl",\n "weight": 50\n }\n]',
"minimax-voice-emotion": "neutral",
"minimax-voice-latex": False,
"minimax-voice-english-normalization": False,
"timeout": 20,
},
"火山引擎_TTS(API)": {
"id": "volcengine_tts",
"type": "volcengine_tts",
"provider_type": "text_to_speech",
"enable": False,
"api_key": "",
"appid": "",
"volcengine_cluster": "volcano_tts",
"volcengine_voice_type": "",
"volcengine_speed_ratio": 1.0,
"api_base": "https://openspeech.bytedance.com/api/v1/tts",
"timeout": 20,
},
"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": "语音合成模型",
@@ -911,6 +1072,98 @@ CONFIG_METADATA_2 = {
}, },
}, },
}, },
"minimax-group-id": {
"type": "string",
"description": "用户组",
"hint": "于账户管理->基本信息中可见",
},
"minimax-langboost": {
"type": "string",
"description": "指定语言/方言",
"hint": "增强对指定的小语种和方言的识别能力,设置后可以提升在指定小语种/方言场景下的语音表现",
"options": [
"Chinese",
"Chinese,Yue",
"English",
"Arabic",
"Russian",
"Spanish",
"French",
"Portuguese",
"German",
"Turkish",
"Dutch",
"Ukrainian",
"Vietnamese",
"Indonesian",
"Japanese",
"Italian",
"Korean",
"Thai",
"Polish",
"Romanian",
"Greek",
"Czech",
"Finnish",
"Hindi",
"auto",
],
},
"minimax-voice-speed": {
"type": "float",
"description": "语速",
"hint": "生成声音的语速, 取值[0.5, 2], 默认为1.0, 取值越大,语速越快",
},
"minimax-voice-vol": {
"type": "float",
"description": "音量",
"hint": "生成声音的音量, 取值(0, 10], 默认为1.0, 取值越大,音量越高",
},
"minimax-voice-pitch": {
"type": "int",
"description": "语调",
"hint": "生成声音的语调, 取值[-12, 12], 默认为0",
},
"minimax-is-timber-weight": {
"type": "bool",
"description": "启用混合音色",
"hint": "启用混合音色, 支持以自定义权重混合最多四种音色, 启用后自动忽略单一音色设置",
},
"minimax-timber-weight": {
"type": "string",
"description": "混合音色",
"editor_mode": True,
"hint": "混合音色及其权重, 最多支持四种音色, 权重为整数, 取值[1, 100]. 可在官网API语音调试台预览代码获得预设以及编写模板, 需要严格按照json字符串格式编写, 可以查看控制台判断是否解析成功. 具体结构可参照默认值以及官网代码预览.",
},
"minimax-voice-id": {
"type": "string",
"description": "单一音色",
"hint": "单一音色编号, 详见官网文档",
},
"minimax-voice-emotion": {
"type": "string",
"description": "情绪",
"hint": "控制合成语音的情绪",
"options": [
"happy",
"sad",
"angry",
"fearful",
"disgusted",
"surprised",
"neutral",
],
},
"minimax-voice-latex": {
"type": "bool",
"description": "支持朗读latex公式",
"hint": "朗读latex公式, 但是需要确保输入文本按官网要求格式化",
},
"minimax-voice-english-normalization": {
"type": "bool",
"description": "支持英语文本规范化",
"hint": "可提升数字阅读场景的性能,但会略微增加延迟",
},
"rag_options": { "rag_options": {
"description": "RAG 选项", "description": "RAG 选项",
"type": "object", "type": "object",
@@ -1254,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": {
@@ -1370,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": "控制台日志级别",
@@ -1389,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

@@ -58,33 +58,30 @@ class RateLimitStage(Stage):
now = datetime.now() now = datetime.now()
async with self.locks[session_id]: # 确保同一会话不会并发修改队列 async with self.locks[session_id]: # 确保同一会话不会并发修改队列
timestamps = self.event_timestamps[session_id] # 检查并处理限流,可能需要多次检查直到满足条件
while True:
timestamps = self.event_timestamps[session_id]
self._remove_expired_timestamps(timestamps, now)
self._remove_expired_timestamps(timestamps, now) if len(timestamps) < self.rate_limit_count:
timestamps.append(now)
break
else:
next_window_time = timestamps[0] + self.rate_limit_time
stall_duration = (next_window_time - now).total_seconds() + 0.3
if len(timestamps) >= self.rate_limit_count: match self.rl_strategy:
# 达到限流阈值,计算下一个窗口的时间 case RateLimitStrategy.STALL.value:
next_window_time = timestamps[0] + self.rate_limit_time logger.info(
stall_duration = (next_window_time - now).total_seconds() f"会话 {session_id} 被限流。根据限流策略,此会话处理将被暂停 {stall_duration:.2f} 秒。"
)
match self.rl_strategy: await asyncio.sleep(stall_duration)
case RateLimitStrategy.STALL.value: now = datetime.now()
logger.info( case RateLimitStrategy.DISCARD.value:
f"会话 {session_id} 被限流。根据限流策略,此会话处理将被暂停 {stall_duration:.2f} 秒。" logger.info(
) f"会话 {session_id} 被限流。根据限流策略,此请求已被丢弃,直到限额于 {stall_duration:.2f} 秒后重置。"
await asyncio.sleep(stall_duration) )
case RateLimitStrategy.DISCARD.value: return event.stop_event()
# event.set_result(MessageEventResult().message(f"会话 {session_id} 被限流。根据限流策略,此请求已被丢弃,直到您的限额于 {stall_duration:.2f} 秒后重置。"))
logger.info(
f"会话 {session_id} 被限流。根据限流策略,此请求已被丢弃,直到限额于 {stall_duration:.2f} 秒后重置。"
)
return event.stop_event()
self._remove_expired_timestamps(
timestamps, now + timedelta(seconds=stall_duration)
)
timestamps.append(now)
def _remove_expired_timestamps( def _remove_expired_timestamps(
self, timestamps: Deque[datetime], now: datetime self, timestamps: Deque[datetime], now: datetime

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),
} }
@@ -154,6 +152,11 @@ class RespondStage(Stage):
except Exception as e: except Exception as e:
logger.warning(f"空内容检查异常: {e}") logger.warning(f"空内容检查异常: {e}")
record_comps = [c for c in result.chain if isinstance(c, Comp.Record)]
non_record_comps = [
c for c in result.chain if not isinstance(c, Comp.Record)
]
if self.enable_seg and ( if self.enable_seg and (
(self.only_llm_result and result.is_llm_result()) (self.only_llm_result and result.is_llm_result())
or not self.only_llm_result or not self.only_llm_result
@@ -171,8 +174,18 @@ class RespondStage(Stage):
decorated_comps.append(comp) decorated_comps.append(comp)
result.chain.remove(comp) result.chain.remove(comp)
break break
for rcomp in record_comps:
i = await self._calc_comp_interval(rcomp)
await asyncio.sleep(i)
try:
await event.send(MessageChain([rcomp]))
except Exception as e:
logger.error(f"发送消息失败: {e} chain: {result.chain}")
break
# 分段回复 # 分段回复
for comp in result.chain: for comp in non_record_comps:
i = await self._calc_comp_interval(comp) i = await self._calc_comp_interval(comp)
await asyncio.sleep(i) await asyncio.sleep(i)
try: try:
@@ -181,11 +194,18 @@ class RespondStage(Stage):
logger.error(f"发送消息失败: {e} chain: {result.chain}") logger.error(f"发送消息失败: {e} chain: {result.chain}")
break break
else: else:
for rcomp in record_comps:
try:
await event.send(MessageChain([rcomp]))
except Exception as e:
logger.error(f"发送消息失败: {e} chain: {result.chain}")
try: try:
await event.send(result) await event.send(MessageChain(non_record_comps))
except Exception as e: except Exception as e:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
logger.error(f"发送消息失败: {e} chain: {result.chain}") logger.error(f"发送消息失败: {e} chain: {result.chain}")
await event._post_send() await event._post_send()
logger.info( logger.info(
f"AstrBot -> {event.get_sender_name()}/{event.get_sender_id()}: {event._outline_chain(result.chain)}" f"AstrBot -> {event.get_sender_name()}/{event.get_sender_id()}: {event._outline_chain(result.chain)}"

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

@@ -62,6 +62,10 @@ class PlatformManager:
from .sources.gewechat.gewechat_platform_adapter import ( from .sources.gewechat.gewechat_platform_adapter import (
GewechatPlatformAdapter, # noqa: F401 GewechatPlatformAdapter, # noqa: F401
) )
case "wechatpadpro":
from .sources.wechatpadpro.wechatpadpro_adapter import (
WeChatPadProAdapter, # noqa: F401
)
case "lark": case "lark":
from .sources.lark.lark_adapter import LarkPlatformAdapter # noqa: F401 from .sources.lark.lark_adapter import LarkPlatformAdapter # noqa: F401
case "dingtalk": case "dingtalk":

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

@@ -103,6 +103,9 @@ class AiocqhttpAdapter(Platform):
if event["post_type"] == "message": if event["post_type"] == "message":
abm = await self._convert_handle_message_event(event) abm = await self._convert_handle_message_event(event)
if abm.sender.user_id == "2854196310":
# 屏蔽 QQ 管家的消息
return
elif event["post_type"] == "notice": elif event["post_type"] == "notice":
abm = await self._convert_handle_notice_event(event) abm = await self._convert_handle_notice_event(event)
elif event["post_type"] == "request": elif event["post_type"] == "request":
@@ -217,9 +220,9 @@ class AiocqhttpAdapter(Platform):
for t, m_group in itertools.groupby(event.message, key=lambda x: x["type"]): for t, m_group in itertools.groupby(event.message, key=lambda x: x["type"]):
a = None a = None
if t == "text": if t == "text":
# 合并相邻文本段 current_text = "".join(m["data"]["text"] for m in m_group).strip()
message_str = "".join(m["data"]["text"] for m in m_group).strip() message_str += current_text
a = ComponentTypes[t](text=message_str) # noqa: F405 a = ComponentTypes[t](text=current_text) # noqa: F405
abm.message.append(a) abm.message.append(a)
elif t == "file": elif t == "file":
@@ -287,6 +290,42 @@ class AiocqhttpAdapter(Platform):
logger.error(f"获取引用消息失败: {e}") logger.error(f"获取引用消息失败: {e}")
a = ComponentTypes[t](**m["data"]) # noqa: F405 a = ComponentTypes[t](**m["data"]) # noqa: F405
abm.message.append(a) abm.message.append(a)
elif t == "at":
first_at_self_processed = False
for m in m_group:
try:
if m["data"]["qq"] == "all":
abm.message.append(At(qq="all", name="全体成员"))
continue
at_info = await self.bot.call_action(
action="get_stranger_info",
user_id=int(m["data"]["qq"]),
)
if at_info:
nickname = at_info.get("nick", "")
is_at_self = str(m["data"]["qq"]) in {abm.self_id, "all"}
abm.message.append(
At(
qq=m["data"]["qq"],
name=nickname,
)
)
if is_at_self and not first_at_self_processed:
# 第一个@是机器人不添加到message_str
first_at_self_processed = True
else:
# 非第一个@机器人或@其他用户添加到message_str
message_str += f" @{nickname} "
else:
abm.message.append(At(qq=str(m["data"]["qq"]), name=""))
except ActionFailed as e:
logger.error(f"获取 @ 用户信息失败: {e},此消息段将被忽略。")
except BaseException as e:
logger.error(f"获取 @ 用户信息失败: {e},此消息段将被忽略。")
else: else:
for m in m_group: for m in m_group:
a = ComponentTypes[t](**m["data"]) # noqa: F405 a = ComponentTypes[t](**m["data"]) # noqa: F405

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:
@@ -282,10 +282,12 @@ class TelegramPlatformAdapter(Platform):
entity.offset + 1 : entity.offset + entity.length entity.offset + 1 : entity.offset + entity.length
] ]
message.message.append(Comp.At(qq=name, name=name)) message.message.append(Comp.At(qq=name, name=name))
plain_text = ( # 如果mention是当前bot则移除否则保留
plain_text[: entity.offset] if name.lower() == context.bot.username.lower():
+ plain_text[entity.offset + entity.length :] plain_text = (
) plain_text[: entity.offset]
+ plain_text[entity.offset + entity.length :]
)
if plain_text: if plain_text:
message.message.append(Comp.Plain(plain_text)) message.message.append(Comp.Plain(plain_text))

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

@@ -0,0 +1,707 @@
import asyncio
import json
import os
import time
from typing import Optional
import aiohttp
import websockets
from astrbot import logger
from astrbot.api.message_components import Plain, Image
from astrbot.api.platform import Platform, PlatformMetadata
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.platform.astrbot_message import (
AstrBotMessage,
MessageMember,
MessageType,
)
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from astrbot.core.platform.astr_message_event import MessageSesion
from ...register import register_platform_adapter
from .wechatpadpro_message_event import WeChatPadProMessageEvent
@register_platform_adapter("wechatpadpro", "WeChatPadPro 消息平台适配器")
class WeChatPadProAdapter(Platform):
def __init__(
self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue
) -> None:
super().__init__(event_queue)
self._shutdown_event = None
self.wxnewpass = None
self.config = platform_config
self.settings = platform_settings
self.unique_session = platform_settings.get("unique_session", False)
self.metadata = PlatformMetadata(
name="wechatpadpro",
description="WeChatPadPro 消息平台适配器",
id=self.config.get("id", "wechatpadpro"),
)
# 保存配置信息
self.admin_key = self.config.get("admin_key")
self.host = self.config.get("host")
self.port = self.config.get("port")
self.active_mesasge_poll: bool = self.config.get(
"wpp_active_message_poll", False
)
self.active_message_poll_interval: int = self.config.get(
"wpp_active_message_poll_interval", 5
)
self.base_url = f"http://{self.host}:{self.port}"
self.auth_key = None # 用于保存生成的授权码
self.wxid = None # 用于保存登录成功后的 wxid
self.credentials_file = os.path.join(
get_astrbot_data_path(), "wechatpadpro_credentials.json"
) # 持久化文件路径
self.ws_handle_task = None
async def run(self) -> None:
"""
启动平台适配器的运行实例。
"""
logger.info("WeChatPadPro 适配器正在启动...")
if loaded_credentials := self.load_credentials():
self.auth_key = loaded_credentials.get("auth_key")
self.wxid = loaded_credentials.get("wxid")
isLoginIn = await self.check_online_status()
# 检查在线状态
if self.auth_key and isLoginIn:
logger.info("WeChatPadPro 设备已在线,凭据存在,跳过扫码登录。")
# 如果在线,连接 WebSocket 接收消息
self.ws_handle_task = asyncio.create_task(self.connect_websocket())
else:
# 1. 生成授权码
if not self.auth_key:
logger.info("WeChatPadPro 无可用凭据,将生成新的授权码。")
await self.generate_auth_key()
# 2. 获取登录二维码
if not isLoginIn:
logger.info("WeChatPadPro 设备已离线,开始扫码登录。")
qr_code_url = await self.get_login_qr_code()
if qr_code_url:
logger.info(f"请扫描以下二维码登录: {qr_code_url}")
else:
logger.error("无法获取登录二维码。")
return
# 3. 检测扫码状态
login_successful = await self.check_login_status()
if login_successful:
logger.info("登录成功WeChatPadPro适配器已连接。")
else:
logger.warning("登录失败或超时WeChatPadPro 适配器将关闭。")
await self.terminate()
return
# 登录成功后,连接 WebSocket 接收消息
self.ws_handle_task = asyncio.create_task(self.connect_websocket())
self._shutdown_event = asyncio.Event()
await self._shutdown_event.wait()
logger.info("WeChatPadPro 适配器已停止。")
def load_credentials(self):
"""
从文件中加载 auth_key 和 wxid。
"""
if os.path.exists(self.credentials_file):
try:
with open(self.credentials_file, "r") as f:
credentials = json.load(f)
logger.info("成功加载 WeChatPadPro 凭据。")
return credentials
except Exception as e:
logger.error(f"加载 WeChatPadPro 凭据失败: {e}")
return None
def save_credentials(self):
"""
将 auth_key 和 wxid 保存到文件。
"""
credentials = {
"auth_key": self.auth_key,
"wxid": self.wxid,
}
try:
# 确保数据目录存在
data_dir = os.path.dirname(self.credentials_file)
os.makedirs(data_dir, exist_ok=True)
with open(self.credentials_file, "w") as f:
json.dump(credentials, f)
logger.info("成功保存 WeChatPadPro 凭据。")
except Exception as e:
logger.error(f"保存 WeChatPadPro 凭据失败: {e}")
async def check_online_status(self):
"""
检查 WeChatPadPro 设备是否在线。
"""
url = f"{self.base_url}/login/GetLoginStatus"
params = {"key": self.auth_key}
async with aiohttp.ClientSession() as session:
try:
async with session.get(url, params=params) as response:
response_data = await response.json()
# 根据提供的在线接口返回示例,成功状态码是 200loginState 为 1 表示在线
if response.status == 200 and response_data.get("Code") == 200:
login_state = response_data.get("Data", {}).get("loginState")
if login_state == 1:
logger.info("WeChatPadPro 设备当前在线。")
return True
# login_state == 3 为离线状态
elif login_state == 3:
logger.info(
"WeChatPadPro 设备不在线。"
)
return False
else:
logger.error(
f"未知的在线状态: {login_state:}"
)
return False
# Code == 300 为微信退出状态。
elif response.status == 200 and response_data.get("Code") == 300:
logger.info(
"WeChatPadPro 设备已退出。"
)
return False
else:
logger.error(
f"检查在线状态失败: {response.status}, {response_data}"
)
return False
except aiohttp.ClientConnectorError as e:
logger.error(f"连接到 WeChatPadPro 服务失败: {e}")
return False
except Exception as e:
logger.error(f"检查在线状态时发生错误: {e}")
return False
async def generate_auth_key(self):
"""
生成授权码。
"""
url = f"{self.base_url}/admin/GenAuthKey1"
params = {"key": self.admin_key}
payload = {"Count": 1, "Days": 365} # 生成一个有效期365天的授权码
async with aiohttp.ClientSession() as session:
try:
async with session.post(url, params=params, json=payload) as response:
response_data = await response.json()
# 修正成功判断条件和授权码提取路径
if response.status == 200 and response_data.get("Code") == 200:
# 授权码在 Data 字段的列表中
if (
response_data.get("Data")
and isinstance(response_data["Data"], list)
and len(response_data["Data"]) > 0
):
self.auth_key = response_data["Data"][0]
logger.info("成功获取授权码")
else:
logger.error(
f"生成授权码成功但未找到授权码: {response_data}"
)
else:
logger.error(
f"生成授权码失败: {response.status}, {response_data}"
)
except aiohttp.ClientConnectorError as e:
logger.error(f"连接到 WeChatPadPro 服务失败: {e}")
except Exception as e:
logger.error(f"生成授权码时发生错误: {e}")
async def get_login_qr_code(self):
"""
获取登录二维码地址。
"""
url = f"{self.base_url}/login/GetLoginQrCodeNew"
params = {"key": self.auth_key}
payload = {} # 根据文档,这个接口的 body 可以为空
async with aiohttp.ClientSession() as session:
try:
async with session.post(url, params=params, json=payload) as response:
response_data = await response.json()
# 修正成功判断条件和数据提取路径
if response.status == 200 and response_data.get("Code") == 200:
# 二维码地址在 Data.QrCodeUrl 字段中
if response_data.get("Data") and response_data["Data"].get(
"QrCodeUrl"
):
return response_data["Data"]["QrCodeUrl"]
else:
logger.error(
f"获取登录二维码成功但未找到二维码地址: {response_data}"
)
return None
else:
logger.error(
f"获取登录二维码失败: {response.status}, {response_data}"
)
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 check_login_status(self):
"""
循环检测扫码状态。
尝试 6 次后跳出循环,添加倒计时。
返回 True 如果登录成功,否则返回 False。
"""
url = f"{self.base_url}/login/CheckLoginStatus"
params = {"key": self.auth_key}
attempts = 0 # 初始化尝试次数
max_attempts = 36 # 最大尝试次数
countdown = 180 # 倒计时时长
logger.info(f"请在 {countdown} 秒内扫码登录。")
while attempts < max_attempts:
async with aiohttp.ClientSession() as session:
try:
async with session.get(url, params=params) as response:
response_data = await response.json()
# 成功判断条件和数据提取路径
if response.status == 200 and response_data.get("Code") == 200:
if (
response_data.get("Data")
and response_data["Data"].get("state") is not None
):
status = response_data["Data"]["state"]
logger.info(
f"{attempts + 1} 次尝试,当前登录状态: {status},还剩{countdown - attempts * 5}"
)
if status == 2: # 状态 2 表示登录成功
self.wxid = response_data["Data"].get("wxid")
self.wxnewpass = response_data["Data"].get(
"wxnewpass"
)
logger.info(
f"登录成功wxid: {self.wxid}, wxnewpass: {self.wxnewpass}"
)
self.save_credentials() # 登录成功后保存凭据
return True
elif status == -2: # 二维码过期
logger.error("二维码已过期,请重新获取。")
return False
else:
logger.error(
f"检测登录状态成功但未找到登录状态: {response_data}"
)
elif response_data.get("Code") == 300:
# "不存在状态"
pass
else:
logger.info(
f"检测登录状态失败: {response.status}, {response_data}"
)
except aiohttp.ClientConnectorError as e:
logger.error(f"连接到 WeChatPadPro 服务失败: {e}")
await asyncio.sleep(5)
attempts += 1
continue
except Exception as e:
logger.error(f"检测登录状态时发生错误: {e}")
attempts += 1
continue
attempts += 1
await asyncio.sleep(5) # 每隔5秒检测一次
logger.warning("登录检测超过最大尝试次数,退出检测。")
return False
async def connect_websocket(self):
"""
建立 WebSocket 连接并处理接收到的消息。
"""
os.environ["no_proxy"] = f"localhost,127.0.0.1,{self.host}"
ws_url = f"ws://{self.host}:{self.port}/ws/GetSyncMsg?key={self.auth_key}"
logger.info(
f"正在连接 WebSocket: ws://{self.host}:{self.port}/ws/GetSyncMsg?key=***"
)
while True:
try:
async with websockets.connect(ws_url) as websocket:
logger.info("WebSocket 连接成功。")
# 设置空闲超时重连
wait_time = (
self.active_message_poll_interval
if self.active_mesasge_poll
else 120
)
while True:
try:
message = await asyncio.wait_for(
websocket.recv(), timeout=wait_time
)
# logger.debug(message) # 不显示原始消息内容
asyncio.create_task(self.handle_websocket_message(message))
except asyncio.TimeoutError:
logger.warning(f"WebSocket 连接空闲超过 {wait_time} s")
break
except websockets.exceptions.ConnectionClosedOK:
logger.info("WebSocket 连接正常关闭。")
break
except Exception as e:
logger.error(f"处理 WebSocket 消息时发生错误: {e}")
break
except Exception as e:
logger.error(f"WebSocket 连接失败: {e}, 请检查WeChatPadPro服务状态或尝试重启WeChatPadPro适配器。")
await asyncio.sleep(5)
async def handle_websocket_message(self, message: str):
"""
处理从 WebSocket 接收到的消息。
"""
logger.debug(f"收到 WebSocket 消息: {message}")
try:
message_data = json.loads(message)
if (
message_data.get("msg_id") is not None
and message_data.get("from_user_name") is not None
):
abm = await self.convert_message(message_data)
if abm:
# 创建 WeChatPadProMessageEvent 实例
message_event = WeChatPadProMessageEvent(
message_str=abm.message_str,
message_obj=abm,
platform_meta=self.meta(),
session_id=abm.session_id,
# 传递适配器实例,以便在事件中调用 send 方法
adapter=self,
)
# 提交事件到事件队列
self.commit_event(message_event)
else:
logger.warning(f"收到未知结构的 WebSocket 消息: {message_data}")
except json.JSONDecodeError:
logger.error(f"无法解析 WebSocket 消息为 JSON: {message}")
except Exception as e:
logger.error(f"处理 WebSocket 消息时发生错误: {e}")
async def convert_message(self, raw_message: dict) -> AstrBotMessage | None:
"""
将 WeChatPadPro 原始消息转换为 AstrBotMessage。
"""
abm = AstrBotMessage()
abm.raw_message = raw_message
abm.message_id = str(raw_message.get("msg_id"))
abm.timestamp = raw_message.get("create_time")
abm.self_id = self.wxid
if int(time.time()) - abm.timestamp > 180:
logger.warning(
f"忽略 3 分钟前的旧消息:消息时间戳 {abm.timestamp} 超过当前时间 {int(time.time())}"
)
return None
from_user_name = raw_message.get("from_user_name", {}).get("str", "")
to_user_name = raw_message.get("to_user_name", {}).get("str", "")
content = raw_message.get("content", {}).get("str", "")
push_content = raw_message.get("push_content", "")
msg_type = raw_message.get("msg_type")
abm.message_str = ""
abm.message = []
# 如果是机器人自己发送的消息、回显消息或系统消息,忽略
if from_user_name == self.wxid:
logger.info("忽略来自自己的消息。")
return None
if from_user_name in ["weixin", "newsapp", "newsapp_wechat"]:
logger.info("忽略来自微信团队的消息。")
return None
# 先判断群聊/私聊并设置基本属性
if await self._process_chat_type(
abm, raw_message, from_user_name, to_user_name, content, push_content
):
# 再根据消息类型处理消息内容
await self._process_message_content(abm, raw_message, msg_type, content)
return abm
return None
async def _process_chat_type(
self,
abm: AstrBotMessage,
raw_message: dict,
from_user_name: str,
to_user_name: str,
content: str,
push_content: str,
):
"""
判断消息是群聊还是私聊,并设置 AstrBotMessage 的基本属性。
"""
if from_user_name == "weixin":
return False
if "@chatroom" in from_user_name:
abm.type = MessageType.GROUP_MESSAGE
abm.group_id = from_user_name
parts = content.split(":\n", 1)
sender_wxid = parts[0] if len(parts) == 2 else ""
abm.sender = MessageMember(user_id=sender_wxid, nickname="")
# 获取群聊发送者的nickname
if sender_wxid:
accurate_nickname = await self._get_group_member_nickname(
abm.group_id, sender_wxid
)
if accurate_nickname:
abm.sender.nickname = accurate_nickname
# 对于群聊session_id 可以是群聊 ID 或发送者 ID + 群聊 ID (如果 unique_session 为 True)
if self.unique_session:
abm.session_id = f"{from_user_name}_{to_user_name}"
else:
abm.session_id = from_user_name
else:
abm.type = MessageType.FRIEND_MESSAGE
abm.group_id = ""
nick_name = ""
if push_content and " : " in push_content:
nick_name = push_content.split(" : ")[0]
abm.sender = MessageMember(user_id=from_user_name, nickname=nick_name)
abm.session_id = from_user_name
return True
async def _get_group_member_nickname(
self, group_id: str, member_wxid: str
) -> Optional[str]:
"""
通过接口获取群成员的昵称。
"""
url = f"{self.base_url}/group/GetChatroomMemberDetail"
params = {"key": self.auth_key}
payload = {
"ChatRoomName": group_id,
}
async with aiohttp.ClientSession() as session:
try:
async with session.post(url, params=params, json=payload) as response:
response_data = await response.json()
if response.status == 200 and response_data.get("Code") == 200:
# 从返回数据中查找对应成员的昵称
member_list = (
response_data.get("Data", {})
.get("member_data", {})
.get("chatroom_member_list", [])
)
for member in member_list:
if member.get("user_name") == member_wxid:
return member.get("nick_name")
logger.warning(
f"在群 {group_id} 中未找到成员 {member_wxid} 的昵称"
)
else:
logger.error(
f"获取群成员详情失败: {response.status}, {response_data}"
)
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 _download_raw_image(
self, from_user_name: str, to_user_name: str, msg_id: int
):
"""下载原始图片。"""
url = f"{self.base_url}/message/GetMsgBigImg"
params = {"key": self.auth_key}
payload = {
"CompressType": 0,
"FromUserName": from_user_name,
"MsgId": msg_id,
"Section": {"DataLen": 61440, "StartPos": 0},
"ToUserName": to_user_name,
"TotalLen": 0,
}
async with aiohttp.ClientSession() as session:
try:
async with session.post(url, params=params, json=payload) as response:
if response.status == 200:
return await response.json()
else:
logger.error(f"下载图片失败: {response.status}")
return None
except aiohttp.ClientConnectorError as e:
logger.error(f"连接到 WeChatPadPro 服务失败: {e}")
return None
except Exception as e:
logger.error(f"下载图片时发生错误: {e}")
return None
async def _process_message_content(
self, abm: AstrBotMessage, raw_message: dict, msg_type: int, content: str
):
"""
根据消息类型处理消息内容,填充 AstrBotMessage 的 message 列表。
"""
if msg_type == 1: # 文本消息
abm.message_str = content
if abm.type == MessageType.GROUP_MESSAGE:
parts = content.split(":\n", 1)
if len(parts) == 2:
abm.message_str = parts[1]
abm.message.append(Plain(abm.message_str))
else:
abm.message.append(Plain(abm.message_str))
else: # 私聊消息
abm.message.append(Plain(abm.message_str))
elif msg_type == 3:
# 图片消息
from_user_name = raw_message.get("from_user_name", {}).get("str", "")
to_user_name = raw_message.get("to_user_name", {}).get("str", "")
msg_id = raw_message.get("msg_id")
image_resp = await self._download_raw_image(
from_user_name, to_user_name, msg_id
)
image_bs64_data = (
image_resp.get("Data", {}).get("Data", {}).get("Buffer", None)
)
if image_bs64_data:
abm.message.append(Image.fromBase64(image_bs64_data))
elif msg_type == 47:
# 视频消息 (注意:表情消息也是 47需要区分)
logger.warning("收到视频消息,待实现。")
elif msg_type == 50:
# 语音/视频
logger.warning("收到语音/视频消息,待实现。")
elif msg_type == 49:
# 引用消息
logger.warning("收到引用消息,待实现。")
else:
logger.warning(f"收到未处理的消息类型: {msg_type}")
async def terminate(self):
"""
终止一个平台的运行实例。
"""
logger.info("终止 WeChatPadPro 适配器。")
try:
if self.ws_handle_task:
self.ws_handle_task.cancel()
self._shutdown_event.set()
except Exception:
pass
def meta(self) -> PlatformMetadata:
"""
得到一个平台的元数据。
"""
return self.metadata
async def send_by_session(
self, session: MessageSesion, message_chain: MessageChain
):
dummy_message_obj = AstrBotMessage()
dummy_message_obj.session_id = session.session_id
# 根据 session_id 判断消息类型
if "@chatroom" in session.session_id:
dummy_message_obj.type = MessageType.GROUP_MESSAGE
dummy_message_obj.group_id = session.session_id
dummy_message_obj.sender = MessageMember(user_id="", nickname="")
else:
dummy_message_obj.type = MessageType.FRIEND_MESSAGE
dummy_message_obj.group_id = ""
dummy_message_obj.sender = MessageMember(user_id="", nickname="")
sending_event = WeChatPadProMessageEvent(
message_str="",
message_obj=dummy_message_obj,
platform_meta=self.meta(),
session_id=session.session_id,
adapter=self,
)
# 调用实例方法 send
await sending_event.send(message_chain)
async def get_contact_list(self):
"""
获取联系人列表。
"""
url = f"{self.base_url}/friend/GetContactList"
params = {"key": self.auth_key}
payload = {"CurrentChatRoomContactSeq": 0, "CurrentWxcontactSeq": 0}
async with aiohttp.ClientSession() as session:
try:
async with session.post(url, params=params, json=payload) as response:
if response.status != 200:
logger.error(f"获取联系人列表失败: {response.status}")
return None
result = await response.json()
if result.get("Code") == 200 and result.get("Data"):
contact_list = (
result.get("Data", {})
.get("ContactList", {})
.get("contactUsernameList", [])
)
return contact_list
else:
logger.error(f"获取联系人列表失败: {result}")
return None
except aiohttp.ClientConnectorError as e:
logger.error(f"连接到 WeChatPadPro 服务失败: {e}")
return None
except Exception as e:
logger.error(f"获取联系人列表时发生错误: {e}")
return None
async def get_contact_details_list(
self, room_wx_id_list: list[str] = None, user_names: list[str] = None
) -> Optional[dict]:
"""
获取联系人详情列表。
"""
if room_wx_id_list is None:
room_wx_id_list = []
if user_names is None:
user_names = []
url = f"{self.base_url}/friend/GetContactDetailsList"
params = {"key": self.auth_key}
payload = {"RoomWxIDList": room_wx_id_list, "UserNames": user_names}
async with aiohttp.ClientSession() as session:
try:
async with session.post(url, params=params, json=payload) as response:
if response.status != 200:
logger.error(f"获取联系人详情列表失败: {response.status}")
return None
result = await response.json()
if result.get("Code") == 200 and result.get("Data"):
contact_list = result.get("Data", {}).get("contactList", {})
return contact_list
else:
logger.error(f"获取联系人详情列表失败: {result}")
return None
except aiohttp.ClientConnectorError as e:
logger.error(f"连接到 WeChatPadPro 服务失败: {e}")
return None
except Exception as e:
logger.error(f"获取联系人详情列表时发生错误: {e}")
return None

View File

@@ -0,0 +1,117 @@
import asyncio
import base64
import io
from typing import TYPE_CHECKING
import aiohttp
from PIL import Image as PILImage # 使用别名避免冲突
from astrbot import logger
from astrbot.core.message.components import Image, Plain # Import Image
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.platform.astrbot_message import AstrBotMessage, MessageType
from astrbot.core.platform.platform_metadata import PlatformMetadata
if TYPE_CHECKING:
from .wechatpadpro_adapter import WeChatPadProAdapter
class WeChatPadProMessageEvent(AstrMessageEvent):
def __init__(
self,
message_str: str,
message_obj: AstrBotMessage,
platform_meta: PlatformMetadata,
session_id: str,
adapter: "WeChatPadProAdapter", # 传递适配器实例
):
super().__init__(message_str, message_obj, platform_meta, session_id)
self.message_obj = message_obj # Save the full message object
self.adapter = adapter # Save the adapter instance
async def send(self, message: MessageChain):
async with aiohttp.ClientSession() as session:
for comp in message.chain:
await asyncio.sleep(1)
if isinstance(comp, Plain):
await self._send_text(session, comp.text)
elif isinstance(comp, Image):
await self._send_image(session, comp)
await super().send(message)
async def _send_image(self, session: aiohttp.ClientSession, comp: Image):
b64 = await comp.convert_to_base64()
raw = self._validate_base64(b64)
b64c = self._compress_image(raw)
payload = {
"MsgItem": [
{"ImageContent": b64c, "MsgType": 3, "ToUserName": self.session_id}
]
}
url = f"{self.adapter.base_url}/message/SendImageNewMessage"
await self._post(session, url, payload)
async def _send_text(self, session: aiohttp.ClientSession, text: str):
if (
self.message_obj.type == MessageType.GROUP_MESSAGE # 确保是群聊消息
and self.adapter.settings.get(
"reply_with_mention", False
) # 检查适配器设置是否启用 reply_with_mention
and self.message_obj.sender # 确保有发送者信息
and (
self.message_obj.sender.user_id or self.message_obj.sender.nickname
) # 确保发送者有 ID 或昵称
):
# 优先使用 nickname如果没有则使用 user_id
mention_text = (
self.message_obj.sender.nickname or self.message_obj.sender.user_id
)
message_text = f"@{mention_text} {text}"
# logger.info(f"已添加 @ 信息: {message_text}")
else:
message_text = text
payload = {
"MsgItem": [
{"MsgType": 1, "TextContent": message_text, "ToUserName": self.session_id}
]
}
url = f"{self.adapter.base_url}/message/SendTextMessage"
await self._post(session, url, payload)
@staticmethod
def _validate_base64(b64: str) -> bytes:
return base64.b64decode(b64, validate=True)
@staticmethod
def _compress_image(data: bytes) -> str:
img = PILImage.open(io.BytesIO(data))
buf = io.BytesIO()
if img.format == "JPEG":
img.save(buf, "JPEG", quality=80)
else:
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
img.save(buf, "JPEG", quality=80)
# logger.info("图片处理完成!!!")
return base64.b64encode(buf.getvalue()).decode()
async def _post(self, session, url, payload):
params = {"key": self.adapter.auth_key}
try:
async with session.post(url, params=params, json=payload) as resp:
data = await resp.json()
if resp.status != 200 or data.get("Code") != 200:
logger.error(f"{url} failed: {resp.status} {data}")
except Exception as e:
logger.error(f"{url} error: {e}")
# TODO: 添加对其他消息组件类型的处理 (Record, Video, At等)
# elif isinstance(component, Record):
# pass
# elif isinstance(component, Video):
# pass
# elif isinstance(component, At):
# pass
# ...

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(
@@ -206,6 +205,18 @@ class ProviderManager:
from .sources.azure_tts_source import ( from .sources.azure_tts_source import (
AzureTTSProvider as AzureTTSProvider, AzureTTSProvider as AzureTTSProvider,
) )
case "minimax_tts_api":
from .sources.minimax_tts_api_source import (
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}。可能是因为有未安装的依赖。"
@@ -237,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:
@@ -258,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:
@@ -283,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())
@@ -314,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(
@@ -327,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(
@@ -340,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,149 @@
import json
import os
import uuid
import aiohttp
from typing import Dict, List, Union, AsyncIterator
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from astrbot.api import logger
from ..entities import ProviderType
from ..provider import TTSProvider
from ..register import register_provider_adapter
@register_provider_adapter(
"minimax_tts_api", "MiniMax TTS API", provider_type=ProviderType.TEXT_TO_SPEECH
)
class ProviderMiniMaxTTSAPI(TTSProvider):
def __init__(
self,
provider_config: dict,
provider_settings: dict,
) -> None:
super().__init__(provider_config, provider_settings)
self.chosen_api_key: str = provider_config.get("api_key", "")
self.api_base: str = provider_config.get(
"api_base", "https://api.minimax.chat/v1/t2a_v2"
)
self.group_id: str = provider_config.get("minimax-group-id", "")
self.set_model(provider_config.get("model", ""))
self.lang_boost: str = provider_config.get("minimax-langboost", "auto")
self.is_timber_weight: bool = provider_config.get(
"minimax-is-timber-weight", False
)
self.timber_weight: List[Dict[str, Union[str, int]]] = json.loads(
provider_config.get(
"minimax-timber-weight",
'[{"voice_id": "Chinese (Mandarin)_Warm_Girl", "weight": 1}]',
)
)
self.voice_setting: dict = {
"speed": provider_config.get("minimax-voice-speed", 1.0),
"vol": provider_config.get("minimax-voice-vol", 1.0),
"pitch": provider_config.get("minimax-voice-pitch", 0),
"voice_id": ""
if self.is_timber_weight
else provider_config.get("minimax-voice-id", ""),
"emotion": provider_config.get("minimax-voice-emotion", "neutral"),
"latex_read": provider_config.get("minimax-voice-latex", False),
"english_normalization": provider_config.get(
"minimax-voice-english-normalization", False
),
}
self.audio_setting: dict = {
"sample_rate": 32000,
"bitrate": 128000,
"format": "mp3",
}
self.concat_base_url: str = f"{self.api_base}?GroupId={self.group_id}"
self.headers = {
"Authorization": f"Bearer {self.chosen_api_key}",
"accept": "application/json, text/plain, */*",
"content-type": "application/json",
}
def _build_tts_stream_body(self, text: str):
"""构建流式请求体"""
dict_body: Dict[str, object] = {
"model": self.model_name,
"text": text,
"stream": True,
"language_boost": self.lang_boost,
"voice_setting": self.voice_setting,
"audio_setting": self.audio_setting,
}
if self.is_timber_weight:
dict_body["timber_weights"] = self.timber_weight
return json.dumps(dict_body)
async def _call_tts_stream(self, text: str) -> AsyncIterator[bytes]:
"""进行流式请求"""
try:
async with aiohttp.ClientSession() as session:
async with session.post(
self.concat_base_url,
headers=self.headers,
data=self._build_tts_stream_body(text),
timeout=aiohttp.ClientTimeout(total=60),
) as response:
response.raise_for_status()
buffer = b""
while True:
chunk = await response.content.read(8192)
if not chunk:
break
buffer += chunk
while b"\n\n" in buffer:
try:
message, buffer = buffer.split(b"\n\n", 1)
if message.startswith(b"data: "):
try:
data = json.loads(message[6:])
if "extra_info" in data:
continue
audio = data.get("data", {}).get("audio")
if audio is not None:
yield audio
except json.JSONDecodeError:
logger.warning(
"Failed to parse JSON data from SSE message"
)
continue
except ValueError:
buffer = buffer[-1024:]
except aiohttp.ClientError as e:
raise Exception(f"MiniMax TTS API请求失败: {str(e)}")
async def _audio_play(self, audio_stream: AsyncIterator[str]) -> bytes:
"""解码数据流到 audio 比特流"""
chunks = []
async for chunk in audio_stream:
if chunk.strip():
chunks.append(bytes.fromhex(chunk.strip()))
return b"".join(chunks)
async def get_audio(self, text: str) -> str:
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
os.makedirs(temp_dir, exist_ok=True)
path = os.path.join(temp_dir, f"minimax_tts_api_{uuid.uuid4()}.mp3")
try:
# 直接将异步生成器传递给 _audio_play 方法
audio_stream = self._call_tts_stream(text)
audio = await self._audio_play(audio_stream)
# 结果保存至文件
with open(path, "wb") as file:
file.write(audio)
return path
except aiohttp.ClientError as e:
raise e

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

@@ -2,7 +2,6 @@ import os
import psutil import psutil
import sys import sys
import time import time
import subprocess
from .zip_updator import ReleaseInfo, RepoZipUpdator from .zip_updator import ReleaseInfo, RepoZipUpdator
from astrbot.core import logger from astrbot.core import logger
from astrbot.core.config.default import VERSION from astrbot.core.config.default import VERSION
@@ -43,28 +42,32 @@ class AstrBotUpdator(RepoZipUpdator):
pass pass
def _reboot(self, delay: int = 3): def _reboot(self, delay: int = 3):
""" """重启当前程序
重启当前程序,使用 subprocess.Popen 启动新进程并退出旧进程
在指定的延迟后,终止所有子进程并重新启动程序 在指定的延迟后,终止所有子进程并重新启动程序
这里只能使用 os.exec* 来重启程序
""" """
time.sleep(delay) time.sleep(delay)
self.terminate_child_processes() self.terminate_child_processes()
py = sys.executable if os.name == "nt":
py = f'"{sys.executable}"'
else:
py = sys.executable
try: try:
if "astrbot" in os.path.basename(sys.argv[0]): # 兼容cli if "astrbot" in os.path.basename(sys.argv[0]): # 兼容cli
cmd = [py, "-m", "astrbot.cli.__main__"] + sys.argv[1:] if os.name == "nt":
args = [
f'"{arg}"' if " " in arg else arg for arg in sys.argv[1:]
]
else:
args = sys.argv[1:]
os.execl(sys.executable, py, "-m", "astrbot.cli.__main__", *args)
else: else:
cmd = [py] + sys.argv os.execl(sys.executable, py, *sys.argv)
subprocess.Popen(cmd, start_new_session=True)
except Exception as e: except Exception as e:
logger.error(f"重启失败({py} {cmd},错误:{e}),请尝试手动重启。") logger.error(f"重启失败({py}, {e}),请尝试手动重启。")
raise e raise e
os._exit(0)
async def check_update(self, url: str, current_version: str) -> ReleaseInfo: async def check_update(self, url: str, current_version: str) -> ReleaseInfo:
"""检查更新""" """检查更新"""
return await super().check_update(self.ASTRBOT_RELEASE_API, VERSION) return await super().check_update(self.ASTRBOT_RELEASE_API, VERSION)

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

11
changelogs/v3.5.10.md Normal file
View File

@@ -0,0 +1,11 @@
# What's Changed
1. 新增: 支持接入个人微信WeChatPadPro替换 gewechat 方式。
2. 新增:接入 PPIO 派欧云
3. 新增:支持接入 Minimax TTS
3.修复Docker 下重启 AstrBot 会导致 astrbot 容器进程退出的问题。
4. 优化:速率限制功能
5. 优化QQ 和 Telegram 下,群聊的 @ 信息也将发送给模型以获得更好的回复、QQ 支持 @ 全体成员的解析。
6. 优化WebUI 配置项支持代码编辑器模式!
7. 优化:语音组件将单独发送以保证全平台兼容性
8. 优化QQ 下,屏蔽 QQ 管家(qq=2854196310) 的所有消息。

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

@@ -1,5 +1,5 @@
# What's Changed # What's Changed
1. 支持接入微信公众平台,详见 [AstrBot - 微信公众平台](https://astrbot.app/deploy/platform/weixin-official-account.html) @Soulter 1. 支持接入微信公众平台,详见 [AstrBot - 微信公众平台](https://astrbot.app/deploy/platform/weixin-official-account.html) @Soulter
2. 优化 gemini_source 方法默认参数 @Raven95678 2. 优化 gemini_source 方法默认参数 @Raven95676
3. 优化 persona 错误显示 @Soulter 3. 优化 persona 错误显示 @Soulter

View File

@@ -1,13 +1,13 @@
# What's Changed # What's Changed
3. 重构: 采用更好的方式将文件上传到 NapCat 协议端,无需映射路径。**(需要前往 配置->其他配置 中配置`对外可达的回调接口地址`** @Soulter @anka-afk 1. 重构: 采用更好的方式将文件上传到 NapCat 协议端,无需映射路径。**(需要前往 配置->其他配置 中配置`对外可达的回调接口地址`** @Soulter @anka-afk
1. 修复: 单独发送文件时被认为是空消息导致文件无法发送的问题 @Soulter 2. 修复: 单独发送文件时被认为是空消息导致文件无法发送的问题 @Soulter
2. 修复: Lagrange 下合并转发消息失败的问题 @Soulter 3. 修复: Lagrange 下合并转发消息失败的问题 @Soulter
3. 修复: CLI 模式下路径问题导致 WebUI 和 MCP Server 无法加载的问题 @Soulter 4. 修复: CLI 模式下路径问题导致 WebUI 和 MCP Server 无法加载的问题 @Soulter
4. 修复: 设置 Gemini 的 thinking_budget 前,先检查是否存在 @Raven95676 5. 修复: 设置 Gemini 的 thinking_budget 前,先检查是否存在 @Raven95676
8. 修复: 修复企业微信和微信公众平台下无法应用 api_base_url 的问题 @Soulter 6. 修复: 修复企业微信和微信公众平台下无法应用 api_base_url 的问题 @Soulter
6. 优化: 分离 plugin 指令为指令组,优化 plugin 指令权限控制 @Soulter 7. 优化: 分离 plugin 指令为指令组,优化 plugin 指令权限控制 @Soulter
7. 优化: WebUI 更直观的模型提供商选择 @Soulter 8. 优化: WebUI 更直观的模型提供商选择 @Soulter
9. 优化: AstrBot 的重启逻辑 @Anchor 9. 优化: AstrBot 的重启逻辑 @Anchor
5. 新增: CLI 支持部分配置文件项的设定 @Raven95676 10. 新增: CLI 支持部分配置文件项的设定、支持插件管理和检测到插件文件变化时自动热重载 @Raven95676
5. 新增: 现已支持 Azure TTS @NanoRocky 11. 新增: 现已支持 Azure TTS @NanoRocky

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

@@ -1,3 +1,26 @@
<script setup>
import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
import { ref } from 'vue'
const dialog = ref(false)
const currentEditingKey = ref('')
const currentEditingLanguage = ref('json')
const currentEditingTheme = ref('vs-light')
let currentEditingKeyIterable = null
function openEditorDialog(key, value, theme, language) {
currentEditingKey.value = key
currentEditingLanguage.value = language || 'json'
currentEditingTheme.value = theme || 'vs-light'
currentEditingKeyIterable = value
dialog.value = true
}
function saveEditedContent() {
dialog.value = false
}
</script>
<template> <template>
<div class="config-section" v-if="iterable && metadata[metadataKey]?.type === 'object'"> <div class="config-section" v-if="iterable && metadata[metadataKey]?.type === 'object'">
<v-list-item-title class="config-title"> <v-list-item-title class="config-title">
@@ -67,6 +90,28 @@
class="config-field" class="config-field"
hide-details hide-details
></v-select> ></v-select>
<!-- Code Editor with Full Screen Option -->
<div v-else-if="metadata[metadataKey].items[key]?.editor_mode && !metadata[metadataKey].items[key]?.invisible" class="editor-container">
<VueMonacoEditor
:theme="metadata[metadataKey].items[key]?.editor_theme || 'vs-light'"
:language="metadata[metadataKey].items[key]?.editor_language || 'json'"
style="min-height: 100px; flex-grow: 1; border: 1px solid rgba(0, 0, 0, 0.1);"
v-model:value="iterable[key]"
>
</VueMonacoEditor>
<v-btn
icon
size="small"
variant="text"
color="primary"
class="editor-fullscreen-btn"
@click="openEditorDialog(key, iterable, metadata[metadataKey].items[key]?.editor_theme, metadata[metadataKey].items[key]?.editor_language)"
title="全屏编辑"
>
<v-icon>mdi-fullscreen</v-icon>
</v-btn>
</div>
<!-- String input --> <!-- String input -->
<v-text-field <v-text-field
@@ -235,6 +280,31 @@
<v-divider class="my-2 config-divider"></v-divider> <v-divider class="my-2 config-divider"></v-divider>
</div> </div>
</v-card-text> </v-card-text>
<!-- Full Screen Editor Dialog -->
<v-dialog v-model="dialog" fullscreen transition="dialog-bottom-transition" scrollable>
<v-card>
<v-toolbar color="primary" dark>
<v-btn icon @click="dialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
<v-toolbar-title>编辑内容 - {{ currentEditingKey }}</v-toolbar-title>
<v-spacer></v-spacer>
<v-toolbar-items>
<v-btn variant="text" @click="saveEditedContent">保存</v-btn>
</v-toolbar-items>
</v-toolbar>
<v-card-text class="pa-0">
<VueMonacoEditor
:theme="currentEditingTheme"
:language="currentEditingLanguage"
style="height: calc(100vh - 64px);"
v-model:value="currentEditingKeyIterable[currentEditingKey]"
>
</VueMonacoEditor>
</v-card-text>
</v-card>
</v-dialog>
</template> </template>
<script> <script>
@@ -270,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;
} }
@@ -330,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;
} }
@@ -357,6 +427,25 @@ export default {
margin: 4px 0; margin: 4px 0;
} }
.editor-container {
position: relative;
display: flex;
width: 100%;
}
.editor-fullscreen-btn {
position: absolute;
top: 4px;
right: 4px;
z-index: 10;
background-color: rgba(0, 0, 0, 0.3);
border-radius: 4px;
}
.editor-fullscreen-btn:hover {
background-color: rgba(0, 0, 0, 0.5);
}
@media (max-width: 600px) { @media (max-width: 600px) {
.nested-object { .nested-object {
padding-left: 8px; padding-left: 8px;

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

@@ -17,10 +17,12 @@ export const useCommonStore = defineStore({
"qq_official": "https://astrbot.app/deploy/platform/qqofficial/websockets.html", "qq_official": "https://astrbot.app/deploy/platform/qqofficial/websockets.html",
"aiocqhttp": "https://astrbot.app/deploy/platform/aiocqhttp/napcat.html", "aiocqhttp": "https://astrbot.app/deploy/platform/aiocqhttp/napcat.html",
"wecom": "https://astrbot.app/deploy/platform/wecom.html", "wecom": "https://astrbot.app/deploy/platform/wecom.html",
"gewechat": "https://astrbot.app/deploy/platform/gewechat.html", "gewechat": "https://astrbot.app/deploy/platform/wechat/gewechat.html",
"lark": "https://astrbot.app/deploy/platform/lark.html", "lark": "https://astrbot.app/deploy/platform/lark.html",
"telegram": "https://astrbot.app/deploy/platform/telegram.html", "telegram": "https://astrbot.app/deploy/platform/telegram.html",
"dingtalk": "https://astrbot.app/deploy/platform/dingtalk.html", "dingtalk": "https://astrbot.app/deploy/platform/dingtalk.html",
"wechatpadpro": "https://astrbot.app/deploy/platform/wechat/wechatpadpro.html",
"weixin_official_account": "https://astrbot.app/deploy/platform/weixin-official-account.html",
}, },
pluginMarketData: [], pluginMarketData: [],

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">
<span style="color: #777; margin-left: 32px; margin-right: 32px" class="mt-4">By <a <v-container>
href="https://soulter.top">Soulter</a>, <a <v-row justify="center" align="center" class="flex-md-row-reverse">
href="https://github.com/Soulter/AstrBot/graphs/contributors">AstrBot Contributors</a> <v-col cols="12" md="6" class="pl-md-8 stats-info">
and <a href="https://github.com/Soulter/AstrBot_Plugins_Collection/graphs/contributors">AstrBot <h2 class="text-h4 font-weight-medium">全球部署</h2>
Plugin Authors</a>
</span> <div class="license-container mt-8">
<img v-bind="props" src="https://www.gnu.org/graphics/agplv3-with-text-100x42.png" style="cursor: pointer;"/>
<!-- Copy-paste in your Readme.md file --> <p class="text-caption mt-2" style="color: var(--v-theme-secondaryText);">AstrBot 采用 AGPL v3 协议开源</p>
</div>
<img style="margin-top: 16px; width: 50%; max-width: 500px; margin-left: 32px; margin-right: 32px" </v-col>
alt="Active Contributors of Soulter/AstrBot - Last 28 days" <v-col cols="12" md="6">
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-card variant="outlined" class="overflow-hidden" elevation="2">
<v-img v-if="useCustomizerStore().uiTheme==='PurpleThemeDark'"
<img style="margin-top: 16px; width: 50%; max-width: 500px; margin-left: 32px; margin-right: 32px" alt="Stars Map of Soulter/AstrBot"
alt="Active Contributors of Soulter/AstrBot - Last 28 days" src="https://next.ossinsight.io/widgets/official/analyze-repo-stars-map/thumbnail.png?activity=stars&repo_id=575865240&image_size=auto&color_scheme=dark">
src="https://next.ossinsight.io/widgets/official/analyze-repo-stars-map/thumbnail.png?activity=stars&repo_id=575865240&image_size=auto&color_scheme=light </v-img>
"> <v-img v-else
alt="Stars Map of Soulter/AstrBot"
src="https://next.ossinsight.io/widgets/official/analyze-repo-stars-map/thumbnail.png?activity=stars&repo_id=575865240&image_size=auto&color_scheme=light">
<!-- Made with [OSS Insight](https://ossinsight.io/) --> </v-img>
</v-card>
<v-btn class="text-primary mt-8" @click="open('https://github.com/Soulter/AstrBot')" </v-col>
color="lightprimary" variant="flat" rounded="sm"> </v-row>
Star 这个项目! 🌟 </v-container>
</v-btn> </section>
<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"
@@ -42,7 +68,7 @@
<div class="d-flex align-center mb-2"> <div class="d-flex align-center mb-2">
<v-icon size="small" color="grey" class="me-2">mdi-tag</v-icon> <v-icon size="small" color="grey" class="me-2">mdi-tag</v-icon>
<span class="text-caption text-medium-emphasis"> <span class="text-caption text-medium-emphasis">
提供商类型: 提供商类型:
<v-chip size="x-small" color="primary" class="ml-1">{{ item.type }}</v-chip> <v-chip size="x-small" color="primary" class="ml-1">{{ item.type }}</v-chip>
</span> </span>
</div> </div>
@@ -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">
@@ -94,7 +165,7 @@
<v-icon>mdi-close</v-icon> <v-icon>mdi-close</v-icon>
</v-btn> </v-btn>
</v-card-title> </v-card-title>
<v-card-text class="pa-4" style="overflow-y: auto;"> <v-card-text class="pa-4" style="overflow-y: auto;">
<v-tabs v-model="activeProviderTab" grow slider-color="primary" bg-color="background"> <v-tabs v-model="activeProviderTab" grow slider-color="primary" bg-color="background">
<v-tab value="chat_completion" class="font-weight-medium px-3"> <v-tab value="chat_completion" class="font-weight-medium px-3">
@@ -109,15 +180,19 @@
<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">
<v-col v-for="(template, name) in getTemplatesByType(tabType)" <v-col v-for="(template, name) in getTemplatesByType(tabType)"
:key="name" :key="name"
cols="12" sm="6" md="4"> cols="12" sm="6" md="4">
<v-card variant="outlined" hover class="provider-card" @click="selectProviderTemplate(name)"> <v-card variant="outlined" hover class="provider-card" @click="selectProviderTemplate(name)">
<v-card-item> <v-card-item>
@@ -155,17 +230,17 @@
<v-icon color="white" class="me-2">{{ updatingMode ? 'mdi-pencil' : 'mdi-plus' }}</v-icon> <v-icon color="white" class="me-2">{{ updatingMode ? 'mdi-pencil' : 'mdi-plus' }}</v-icon>
<span>{{ updatingMode ? '编辑' : '新增' }} {{ newSelectedProviderName }} 服务提供商</span> <span>{{ updatingMode ? '编辑' : '新增' }} {{ newSelectedProviderName }} 服务提供商</span>
</v-card-title> </v-card-title>
<v-card-text class="py-4"> <v-card-text class="py-4">
<AstrBotConfig <AstrBotConfig
:iterable="newSelectedProviderConfig" :iterable="newSelectedProviderConfig"
:metadata="metadata['provider_group']?.metadata" :metadata="metadata['provider_group']?.metadata"
metadataKey="provider" metadataKey="provider"
/> />
</v-card-text> </v-card-text>
<v-divider></v-divider> <v-divider></v-divider>
<v-card-actions class="pa-4"> <v-card-actions class="pa-4">
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-btn variant="text" @click="showProviderCfg = false" :disabled="loading"> <v-btn variant="text" @click="showProviderCfg = false" :disabled="loading">
@@ -183,7 +258,7 @@
location="top"> location="top">
{{ save_message }} {{ save_message }}
</v-snackbar> </v-snackbar>
<WaitingForRestart ref="wfr"></WaitingForRestart> <WaitingForRestart ref="wfr"></WaitingForRestart>
</div> </div>
</template> </template>
@@ -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,20 +367,29 @@ 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 || {};
const filtered = {}; const filtered = {};
for (const [name, template] of Object.entries(templates)) { for (const [name, template] of Object.entries(templates)) {
if (template.provider_type === type) { if (template.provider_type === type) {
filtered[name] = template; filtered[name] = template;
} }
} }
return filtered; return filtered;
}, },
// 获取提供商类型对应的图标 // 获取提供商类型对应的图标
getProviderIcon(type) { getProviderIcon(type) {
const icons = { const icons = {
@@ -272,12 +405,14 @@ export default {
'智谱 AI': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/zhipu.svg', '智谱 AI': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/zhipu.svg',
'硅基流动': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/siliconcloud.svg', '硅基流动': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/siliconcloud.svg',
'Kimi': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/kimi.svg', 'Kimi': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/kimi.svg',
'PPIO派欧云': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/ppio.svg',
'Dify': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/dify-color.svg', 'Dify': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/dify-color.svg',
'阿里云百炼': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/alibabacloud-color.svg', '阿里云百炼': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/alibabacloud-color.svg',
'FastGPT': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/fastgpt-color.svg', 'FastGPT': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/fastgpt-color.svg',
'LM Studio': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/lmstudio.svg', 'LM Studio': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/lmstudio.svg',
'FishAudio': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/fishaudio.svg', 'FishAudio': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/fishaudio.svg',
'Azure': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/azure.svg', 'Azure': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/azure.svg',
'MiniMax': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/minimax.svg',
}; };
for (const key in icons) { for (const key in icons) {
if (type.startsWith(key)) { if (type.startsWith(key)) {
@@ -292,11 +427,12 @@ 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;
}, },
// 获取提供商简介 // 获取提供商简介
getProviderDescription(template, name) { getProviderDescription(template, name) {
if (name == 'OpenAI') { if (name == 'OpenAI') {
@@ -304,7 +440,7 @@ export default {
} }
return `${template.type} 服务提供商`; return `${template.type} 服务提供商`;
}, },
// 选择提供商模板 // 选择提供商模板
selectProviderTemplate(name) { selectProviderTemplate(name) {
this.newSelectedProviderName = name; this.newSelectedProviderName = name;
@@ -334,7 +470,7 @@ export default {
break; break;
} }
} }
const mergeConfigWithOrder = (target, source, reference) => { const mergeConfigWithOrder = (target, source, reference) => {
// 首先复制所有source中的属性到target // 首先复制所有source中的属性到target
if (source && typeof source === 'object' && !Array.isArray(source)) { if (source && typeof source === 'object' && !Array.isArray(source)) {
@@ -348,7 +484,7 @@ export default {
} }
} }
} }
// 然后根据reference的结构添加或覆盖属性 // 然后根据reference的结构添加或覆盖属性
for (let key in reference) { for (let key in reference) {
if (typeof reference[key] === 'object' && reference[key] !== null) { if (typeof reference[key] === 'object' && reference[key] !== null) {
@@ -356,8 +492,8 @@ export default {
target[key] = Array.isArray(reference[key]) ? [] : {}; target[key] = Array.isArray(reference[key]) ? [] : {};
} }
mergeConfigWithOrder( mergeConfigWithOrder(
target[key], target[key],
source && source[key] ? source[key] : {}, source && source[key] ? source[key] : {},
reference[key] reference[key]
); );
} else if (!(key in target)) { } else if (!(key in target)) {
@@ -366,7 +502,7 @@ export default {
} }
} }
}; };
if (defaultConfig) { if (defaultConfig) {
mergeConfigWithOrder(this.newSelectedProviderConfig, provider, defaultConfig); mergeConfigWithOrder(this.newSelectedProviderConfig, provider, defaultConfig);
} }
@@ -417,7 +553,7 @@ export default {
providerStatusChange(provider) { providerStatusChange(provider) {
provider.enable = !provider.enable; // 切换状态 provider.enable = !provider.enable; // 切换状态
axios.post('/api/config/provider/update', { axios.post('/api/config/provider/update', {
id: provider.id, id: provider.id,
config: provider config: provider
@@ -429,17 +565,33 @@ export default {
this.showError(err.response?.data?.message || err.message); this.showError(err.response?.data?.message || err.message);
}); });
}, },
showSuccess(message) { showSuccess(message) {
this.save_message = message; this.save_message = message;
this.save_message_success = "success"; this.save_message_success = "success";
this.save_message_snack = true; this.save_message_snack = true;
}, },
showError(message) { showError(message) {
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);
});
} }
} }
} }
@@ -475,4 +627,4 @@ export default {
.v-window { .v-window {
border-radius: 4px; border-radius: 4px;
} }
</style> </style>

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>
<v-text-field
v-model="password"
label="密码"
required
density="comfortable"
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>
<small>默认用户名和密码为 astrbot</small> <v-label :style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#000000aa' : '#ffffffcc'}" class="mt-1 mb-5">
<v-btn color="secondary" :loading="isSubmitting" block class="mt-8" variant="flat" size="large" :disabled="valid" <small>默认用户名和密码为 astrbot</small>
type="submit"> </v-label>
登录</v-btn>
<div v-if="errors.apiError" class="mt-2"> <v-btn
<v-alert color="error">{{ errors.apiError }}</v-alert> 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 {

Some files were not shown because too many files have changed in this diff Show More