Compare commits

..

174 Commits

Author SHA1 Message Date
Soulter
7cfcba29a6 feat: add loading state for dashboard update process 2025-06-29 21:55:13 +08:00
Soulter
9bf8aadca9 📦 release: v3.5.18 2025-06-29 21:52:45 +08:00
Soulter
714d4af63d Merge pull request #1963 from AstrBotDevs/refactor-llm-request
Refactor: 将 LLM Request 部分抽象为 AgentRunner 并优化多轮工具调用
2025-06-29 21:38:43 +08:00
Soulter
8203fdb4f0 fix: webchat show tool call 2025-06-29 21:35:39 +08:00
Soulter
5e1e2d1a4f perf: 优化 ChatPage UI 2025-06-29 21:19:52 +08:00
Soulter
2f941de65b feat: 支持展示工具使用过程 2025-06-29 21:19:40 +08:00
Raven95676
777c503002 perf: change logging level to debug for agent state transitions and LLM responses 2025-06-29 17:32:53 +08:00
Raven95676
e9b23f68fd perf: add AgentState Enum for improved state management 2025-06-29 17:19:53 +08:00
Soulter
efa45e6203 fix: validate and repair message contexts in LLMRequestSubStage 2025-06-29 16:36:08 +08:00
Raven95676
638f55f83c Merge branch 'refactor-llm-request' of https://github.com/AstrBotDevs/AstrBot into refactor-llm-request 2025-06-29 16:13:18 +08:00
Raven95676
8b2fc29d5b chore: remove accidentally committed file 2025-06-29 16:13:15 +08:00
Soulter
b516fb0550 chore: remove dump_plugins.py 2025-06-29 16:12:40 +08:00
Raven95676
efef34c01e style: format code 2025-06-29 16:06:44 +08:00
Soulter
5f1dfa7599 fix: handle LLM response and execute event hook in ToolLoopAgent 2025-06-29 15:58:22 +08:00
Soulter
8e9c7544cf fix: update type check for async generator in PipelineContext 2025-06-29 15:54:32 +08:00
Soulter
4e3d5641c8 chore: code quality 2025-06-29 15:51:56 +08:00
Soulter
20b760529e fix: anthropic api error when using tools 2025-06-29 15:33:08 +08:00
Soulter
a55a07c5ff remove: useless provider init params 2025-06-29 14:43:36 +08:00
Soulter
94ee8ea297 feat: 支持多轮次工具调用并且存储到数据库
移除了 llm tuner 适配器
2025-06-29 14:27:00 +08:00
Soulter
010f082fbb Merge pull request #1914 from HakimYu/master
fix(AiocqhttpAdapter): 修复at_info.get("nick", "")的错误
2025-06-28 21:52:01 +08:00
Soulter
073cdf6d51 perf: also consider nick 2025-06-28 21:51:10 +08:00
Soulter
71b233fe5f Merge pull request #1942 from QiChenSn/fix-CommandFilter-ParseForBool
fix:修复commandfilter对布尔类型的解析
2025-06-28 15:10:29 +08:00
Soulter
770dec9ed6 fix: handle boolean parameter parsing correctly in CommandFilter 2025-06-28 15:08:19 +08:00
Soulter
2ca95a988e fix: lint warnings 2025-06-28 15:05:57 +08:00
Soulter
cf1e7ee08a Merge pull request #1947 from RC-CHN/master
允许为html_render方法传递参数
2025-06-28 14:52:09 +08:00
Soulter
d14513ddfd fix: lint warnings 2025-06-28 14:51:35 +08:00
Soulter
9a9017bc6c perf: use union oper for merging dict
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2025-06-28 14:46:29 +08:00
Soulter
3c9b654713 Merge pull request #1923 from Magstic/patch-1
Fix: 仪表盘的『插件配置』中不显示 JSON 编辑窗
2025-06-28 14:45:14 +08:00
Magstic
80d2ad40bc fix: 仪表盘的『插件配置』中不显示 JSON 编辑窗
该提交与 #1919 关联。

精准定位错误 @Pine-Ln,Fix from Gemini 2.5 Pro.

这个问题是由两个错误叠加造成的:

1. **组件崩溃**:`AstrBotConfig.vue` 混用了 Vue 3 的 `<script setup>` 和旧式 `<script>` 写法,导致作用域冲突,模板无法访问国际化函数 `t`,引发 `ReferenceError: t is not defined`。

2. **设置项不显示**:原代码根据用户已保存的设置数据来渲染字段,导致新增的设置项(如 `editor_mode`)因为用户配置中没有初始值而不显示。

1. **统一 API 写法**:将整个组件重构为纯 `<script setup>` 写法,解决作用域冲突。

2. **修正渲染逻辑**:将 `v-for` 循环改为遍历设置蓝图 (metadata) 而不是用户数据,确保所有定义的设置项都能显示。
2025-06-28 14:42:06 +08:00
Ruochen
f5bff00b1f Merge branch 'master' of https://github.com/RC-CHN/AstrBot 2025-06-27 17:03:58 +08:00
Ruochen
27c9717445 feat:允许html_render方法传入配置参数 2025-06-27 17:03:26 +08:00
Soulter
863a1ba8ef Merge pull request #1922 from SXP-Simon/master
[feat] (discord_platform_adapter) 增加了对机器人 Role Mention 方法的响应,并且修复了控制面板上 Discord 平台无法优雅重载的 Bug
2025-06-27 14:59:37 +08:00
Soulter
cb04dd2b83 chore: remove unnecessary codes 2025-06-27 14:59:08 +08:00
Soulter
8c7cf51958 chore: code format 2025-06-27 14:46:23 +08:00
Soulter
244fb1fed6 chore: remove useless logger 2025-06-27 14:38:31 +08:00
Soulter
25f7a68a13 Merge pull request #1709 from shuiping233/fix-qq-offical-session-bug
fix: qq_official适配器使用SessionController(会话控制)功能时机器人回复消息无法发送到聊天平台
2025-06-27 14:35:54 +08:00
Soulter
62d8cf79ef fix: remove deprecated pre_send and post_send calls for specific platforms 2025-06-27 14:31:35 +08:00
QiChenSn
2f81b2e381 fix:修复commandfilter对布尔类型的解析 2025-06-27 02:32:10 +08:00
Soulter
1f5a7e7885 Merge pull request #1940 from AstrBotDevs/fix-tg-active-reply
fix: cannot make active reply in telegram
2025-06-27 00:05:10 +08:00
Soulter
80fca470f2 fix: cannot make active reply in telegram
Co-authored-by: youtiaoguagua <cloudcranesss@210625568+cloudcranesss@users.noreply.github.com>
2025-06-27 00:04:25 +08:00
Soulter
6e9d9ac856 Merge pull request #1907 from IGCrystal/Branch-2
🐞 fix(WebUI): 修复安装插件按钮不可见
2025-06-26 23:28:37 +08:00
Soulter
8d6fada1eb feat(ExtensionPage): show confirm dialog when click install plugin button 2025-06-26 23:25:59 +08:00
Soulter
3e715399a1 fix: 环境变量代理被忽略 (#1895) 2025-06-26 08:52:33 +08:00
Soulter
81cc8831f9 docs: update plugin issue template
docs: issue template

docs: update issue template

docs: update plugin issue template

fix: issue plugin template

docs: update plugin issue template
2025-06-26 08:28:28 +08:00
Soulter
f7370044a7 Merge pull request #1903 from IGCrystal/branch-1
 feat: 对PlatformPage使用翻译键
2025-06-25 22:49:03 +08:00
Soulter
51b015a629 Merge pull request #1830 from zhx8702/feat-wechat-tts-mp3towav
feat: wechatpadpro 触发tts时 添加对mp3格式音频支持
2025-06-25 22:46:10 +08:00
Soulter
392af7a553 fix: add pydub to requirements 2025-06-25 22:31:44 +08:00
鸦羽
d2dd07bad7 Merge pull request #1920 from AstrBotDevs/feat/gemini-tts
feat: 增加Gemini TTS API实现
2025-06-25 14:05:04 +08:00
回归天空
cebcd6925a [fix] (discord_platform_adapter) 解决了 “Discord 平台无法优雅重载” 的 bug
#### 问题现象(AI总结)

- 在通过 Web 面板或配置变更热重载 Discord 平台时,适配器的 terminate() 方法会被调用,但经常出现“卡死”或长时间无响应,导致 Discord 平台无法优雅重载。

- 日志显示停留在“正在清理已注册的斜杠指令...”等步骤,甚至出现超时或异常。

#### 2. 原因分析

- 适配器的 terminate() 方法中,涉及多个异步操作(如取消 polling 任务、清理斜杠指令、关闭客户端)。

- 某些 await 操作(如 await self.client.sync_commands() 或 await self.client.close())在网络异常、事件循环被取消等情况下,可能会阻塞或抛出 CancelledError,导致整个重载流程卡住。

- 之前的实现没有对这些 await 操作加超时保护,也没有分步日志,难以定位具体卡点。

#### 3. 修复措施

- 分步日志:在 terminate() 的每个关键步骤前后都加了详细日志,便于定位卡点。

- 超时保护:对所有关键 await 操作(如 polling 任务取消、指令清理、客户端关闭)都加了 asyncio.wait_for(..., timeout=10),防止无限阻塞。

- 健壮性提升:先 cancel polling 任务,再清理指令,最后关闭客户端。每一步都捕获异常并输出日志,保证即使某一步失败也能继续后续清理。

- 避免重复终止:移除了 run() 方法中的 finally: await self.terminate(),只允许外部统一调度,防止重复调用导致资源冲突或日志重复。

#### 4. 修复效果

- 现在 Discord 平台适配器在热重载或终止时,能优雅地依次完成所有清理步骤,不会因某一步阻塞导致整个流程卡死。
2025-06-25 11:46:49 +08:00
回归天空
e7b4357fc7 [feat] (discord_platform_adapter) 增加了对机器人 Role Mention 方法的响应 2025-06-25 11:41:55 +08:00
Raven95676
dc279dde4a fix: 简化get_audio方法中的提示文本生成逻辑,清除冗余判断逻辑 2025-06-25 10:55:51 +08:00
Raven95676
c0810a674f feat: 增加Gemini TTS API实现 2025-06-25 10:50:04 +08:00
HakimYu
0760cabbbe feat(AiocqhttpAdapter): 修复reply类型的 Event.from_payload报错 2025-06-24 17:20:30 +08:00
HakimYu
3b149c520b fix(AiocqhttpAdapter): 修复at_info.get("nick", "")的错误,并在message_str中针对At类型添加QQ号 2025-06-24 16:30:23 +08:00
Soulter
3d19fc89ff docs: 10k star banner 2025-06-24 02:07:23 +08:00
Soulter
cd1b1919f4 docs: 10k star banner 2025-06-24 01:51:46 +08:00
IGCrystal
0ed646eb27 🐞 fix(WebUI): 修复安装插件按钮不可见 2025-06-23 19:41:56 +08:00
邹永赫
c0c5859c99 Merge pull request #1905 from zouyonghe/master
使用定义的Plain类型代替原始基础类型str,保持代码统一性
2025-06-23 18:52:56 +09:00
邹永赫
a47121b849 使用定义的Plain类型代替原始基础类型str,保持代码统一性 2025-06-23 18:49:47 +09:00
邹永赫
d9dd20e89a Merge pull request #1904 from zouyonghe/master
修复代码重构造成的无法向前兼容在node中发送简单文本信息的问题
2025-06-23 18:20:52 +09:00
邹永赫
ed4609ebe5 修复代码重构造成的无法向前兼容在node中发送简单文本信息的问题 2025-06-23 18:17:37 +09:00
IGCrystal
01ef86d658 feat: 对PlatformPage使用翻译键 2025-06-23 14:44:06 +08:00
Soulter
cd4802da04 Merge pull request #1902 from railgun19457/master
修复plugin_enable配置无法保存的问题
2025-06-23 13:30:31 +08:00
Misaka Mikoto
2aca65780f Merge branch 'AstrBotDevs:master' into master 2025-06-23 13:29:31 +08:00
Soulter
2c435f7387 Merge pull request #1899 from IGCrystal/branch-1
🐞 fix: 显示运行时长国际化
2025-06-23 13:21:59 +08:00
Soulter
cc1afd1a9c Merge pull request #1900 from AstrBotDevs/fix-hc-jwt
Fix: JWT secret issue
2025-06-23 13:16:08 +08:00
railgun19457
6f098cdba6 修复plugin_enable配置无法保存的问题 2025-06-23 13:06:46 +08:00
Soulter
d03e9fb90a fix: jwt secret 2025-06-23 12:36:11 +08:00
IGCrystal
9f2966abe9 Merge branch 'branch-1' of https://github.com/IGCrystal/AstrBot into branch-1 2025-06-23 12:09:10 +08:00
IGCrystal
4e28ea1883 🐞 fix: 显示运行时长国际化 2025-06-23 12:08:27 +08:00
Soulter
289214e85c Merge pull request #1898 from IGCrystal/branch-1
🐞 fix(WebUI): 修复platform的logo路径问题
2025-06-23 11:59:58 +08:00
IGCrystal
a20d98bf93 🐞 fix(WebUI): 修复platform的logo路径问题 2025-06-23 11:57:20 +08:00
Soulter
7c3d98acbe 📦 release: v3.5.17
因为 pypi 不允许上传相同的文件名的 wheel
2025-06-23 01:17:38 +08:00
Soulter
7311786f48 fix(dependencies): remove optional 'speed' from py-cord dependency 2025-06-23 01:03:43 +08:00
Soulter
82de9c926e docs: update readme 2025-06-23 00:40:34 +08:00
Soulter
7fd86d4de3 docs: update readme 2025-06-23 00:38:52 +08:00
Soulter
724da29e2a 📦 release: bump to v3.5.16 2025-06-23 00:15:30 +08:00
Soulter
54113d7b94 Merge pull request #1896 from AstrBotDevs/perf-webui-dialog-logo
Improve: improve styles of creating adapter dialog
2025-06-23 00:03:50 +08:00
Soulter
66396e8290 perf(webui): improve styles of creating adapter dialog in platform and provider page 2025-06-23 00:01:04 +08:00
Soulter
72be76215f Merge pull request #1822 from IGCrystal/branch-1
 feat(WebUI): complete dashboard internationalization system refactor
2025-06-22 22:22:33 +08:00
Soulter
ace86703a9 Merge pull request #1888 from HakimYu/master
Discord 实现 SlashCommand 的注册、添加对 At 与 Reply 的支持、设置机器人 Activity
2025-06-22 22:19:19 +08:00
Soulter
7b25495463 style: code quality 2025-06-22 22:11:28 +08:00
HakimYu
3d4b651c1f fix: 修复 send_by_session 的 message_obj 为 None 的错误
fix: 修复 determine_messagee_type 会获取到服务器id的错误,并拆分成两个函数
2025-06-22 20:33:26 +08:00
HakimYu
d305ae064d Merge branch 'AstrBotDevs:master' into master 2025-06-22 16:29:38 +08:00
HakimYu
ac4f3d8907 feat: 添加 Discord 斜杠指令注册功能及相关配置项
feat: 添加 Activity 设置项
fix: 修复 At Reply 未处理的问题
2025-06-22 16:29:02 +08:00
Soulter
af2687771b ci: update dashboard ci to support pull request 2025-06-22 10:38:09 +08:00
Soulter
a67b7f909a Merge branch 'master' into branch-1 2025-06-22 10:28:44 +08:00
Soulter
f9c3e4cdb0 Merge pull request #1821 from Zhalslar/gsv-tts-selfhost
Feature: 新增 GPT_SoVIS 的 TTS 服务商
2025-06-21 23:58:07 +08:00
Soulter
dc62c1f8d4 style: code format 2025-06-21 23:56:06 +08:00
Soulter
0441b51a68 Merge pull request #1867 from lxfight/master
Feature: 添加 Discord 平台适配器及相关组件,支持 Discord Bot 功能
2025-06-21 23:52:54 +08:00
Soulter
5c0c9f687e style: code quality 2025-06-21 23:52:17 +08:00
Soulter
e049c54043 chore: update uv.lock 2025-06-21 23:33:58 +08:00
Soulter
99e47540d5 styles: code quality 2025-06-21 23:33:47 +08:00
Soulter
8e1885ffeb Merge branch 'master' into master 2025-06-21 23:21:37 +08:00
Soulter
8501a0c205 perf: replace slack requirements 2025-06-21 23:19:39 +08:00
Soulter
797f2a3173 Merge pull request #1877 from AstrBotDevs/feat-adapter-slack
Feature: Add platform adapter support for Slack
2025-06-21 23:13:37 +08:00
Soulter
1057b4bc35 style: code quality 2025-06-21 23:12:50 +08:00
Soulter
efc0116595 feat: Verify Slack request signature using HMAC 2025-06-21 23:07:34 +08:00
Soulter
cdc560fad0 chore: remove useless codes 2025-06-21 22:58:30 +08:00
lxfight
75a2803710 fix: 清空交互事件的 message_str,确保仅专门指令处理器响应;优化图片处理逻辑,支持多种图片来源
- 修复了@激活机器人时,指令无法正确处理的问题
- 修复了base64 图片无法发送的问题

注意:本次提交的代码功能还需要针对全部功能进行一次系统完整的测试,计划与6月22日下午完成。
2025-06-21 20:12:38 +08:00
Soulter
fb3169faa4 feat: add platform adapter support for Slack 2025-06-21 18:33:48 +08:00
Soulter
d587bd837e Merge pull request #1845 from RC-CHN/master
feat:在用户未为服务商配置key时添加二次警告确认
2025-06-20 23:27:27 +08:00
lxfight
b9fab74edc feat: 拆分Discord 适配器的部分代码,并处理一些小的问题。
- 基于最小权限原则,修改了 Bot 申请的权限范围
- 拆分了代码,使得文件结构更加清晰
2025-06-20 21:43:23 +08:00
lxfight
50c22bbadb feat: 在 requirements.txt 中添加 py-cord[speed] 依赖 2025-06-20 21:26:55 +08:00
lxfight
d0b10b9195 feat: 添加 Discord 平台适配器及相关组件,支持 Discord Bot 功能
- 添加了一个新的依赖 py-cord[speed]
- 添加了针对 Discord 平台的 Discord Bot 适配器
2025-06-20 21:22:04 +08:00
IGCrystal
c8fe4f4a3c Merge branch 'AstrBotDevs:master' into branch-1 2025-06-19 11:56:39 +08:00
IGCrystal
a8ba0720af 🎈 perf: 在更新弹窗中提高关闭按钮与控制台的间距
之前的按钮与控制台内容重叠了,就增加一点间距
2025-06-19 11:54:27 +08:00
IGCrystal
745a01246c 🎈 perf: 修改chat的弹窗样式 2025-06-19 10:30:33 +08:00
Zhalslar
bee5d3550f Merge branch 'gsv-tts-selfhost' of https://github.com/Zhalslar/AstrBot_Zhalslar into gsv-tts-selfhost 2025-06-19 00:52:16 +08:00
Zhalslar
1789393151 提供initialize和terminate方法对接上游 2025-06-19 00:52:03 +08:00
Soulter
345afe1338 fix: 修复 PipInstaller 中 pip 调用方式,确保使用当前 Python 解释器 2025-06-19 00:38:23 +08:00
Ruochen
65428aa49f perf: 优化服务商保存流程,并修复UI状态 2025-06-18 23:58:09 +08:00
Soulter
b251ee9322 perf: 优化空文本检测
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2025-06-18 23:45:59 +08:00
Ruochen
04f00682a0 Merge branch 'master' of https://github.com/RC-CHN/AstrBot 2025-06-18 23:43:09 +08:00
Ruochen
90dcda1475 feat:在用户未为服务商配置key时添加二次警告确认 2025-06-18 23:41:07 +08:00
IGCrystal
f1ee4eb89f 🐞 fix: 修改录音键位为Ctrl+B
Ctrl+A太常用了就修改了
2025-06-18 21:00:28 +08:00
IGCrystal
343fc22168 🎈 perf: 修改chat中录音的键位防止误触
修改键位为Ctrl + A ,以及还加入SSE断连提示
2025-06-18 17:58:15 +08:00
IGCrystal
00ef0d7e3d 🐞 fix: 修复无法实时显示消息
修复chat与chatbox之间切换后sse断开连接导致无法实时显示消息
2025-06-18 16:24:18 +08:00
IGCrystal
f2deaf6199 🎈 perf: 修改滚动条样式 2025-06-18 00:47:43 +08:00
IGCrystal
617a2c010e 🎈 perf: 优化登录页面样式
处理的是分隔线的样式
2025-06-17 22:20:48 +08:00
IGCrystal
38eae1d1ee 🐞 fix: 进一步的检查与校准 2025-06-17 12:22:00 +08:00
IGCrystal
7e4c89b0cb 🦄 refactor(i18n): replace manual types with auto-inference
- Migrate from manual TypeScript interfaces to automatic type generation
from JSON files. Eliminates sync issues and maintenance overhead.
2025-06-17 11:10:21 +08:00
Zhalslar
14c29f07bd 优化 2025-06-17 10:55:35 +08:00
Zhalslar
825e3dbcf5 Update default.py 2025-06-17 09:44:09 +08:00
IGCrystal
8275130f04 feat: 继续完成剩下的组件
- AlkaidPage_sigma.vue
- PlatformPage.vue
- LongTermMemory.vue
- KnowledgeBase.vue
2025-06-17 09:24:51 +08:00
Soulter
2c47abea95 fix: 修复 WeChatPadPro 下,开启了会话隔离后,无法发送群聊消息的问题
fixes: #1766
2025-06-16 23:36:11 +08:00
Soulter
85aa28d724 perf: print traceback 2025-06-16 23:27:29 +08:00
Soulter
53a3736b04 fix: 修复可能的类型错误
fixes: #1778
2025-06-16 23:26:22 +08:00
Soulter
86ba3c230e perf: 弱化 WeChatPadPro 的 WS 连接提示
fixes: #1779
2025-06-16 23:21:53 +08:00
Soulter
8d21126bd6 fix: 修复 WeChatPadPro 会话隔离模式下,会话 ID 显示为自身ID 的问题 2025-06-16 23:18:45 +08:00
Soulter
74ded91976 fix: 修复 WeChatPadPro 过期后无法正常的重新登录的问题。 2025-06-16 23:07:10 +08:00
IGCrystal
7c27520d57 feat: 继续完成剩下组件的国际化
ExtensionCard.vue - 插件卡片组件 WaitingForRestart.vue - 重启等待组件 ReadmeDialog.vue - README对话框组件 AstrBotConfig.vue - 配置编辑器组件 ListConfigItem.vue - 列表配置项组件 ItemCardGrid.vue - 卡片网格组件
ChatPage.vue - 聊天页面的录音提示文本 ConfigPage.vue - 配置页面的状态消息 ExtensionPage.vue - 插件页面的加载和状态文本 OnlineTime.vue - 仪表板运行时间组件
2025-06-16 22:44:44 +08:00
Soulter
b54bbc4c5a Merge pull request #1810 from Zhalslar/reply-bot-waking
feat:支持通过引用bot消息来唤醒bot
2025-06-16 21:56:17 +08:00
Soulter
3e09a4ddd4 Merge branch 'master' into reply-bot-waking 2025-06-16 21:55:50 +08:00
Zhalslar
f93f04a536 feat:支持通过引用bot消息来唤醒bot
Update dingtalk_event.py

Update stage.py
2025-06-16 21:54:13 +08:00
Soulter
b93f30b809 docs: update readme 2025-06-16 21:54:13 +08:00
Soulter
95bd2f26a5 Merge pull request #1812 from Zhalslar/dingtalk-image-to-url
feat:钉钉发图时自动将非HTTP图片注册成URL
2025-06-16 21:41:50 +08:00
IGCrystal
7cfcf056f9 🎈 perf: 使用 hash 路由模式以避免404 2025-06-16 21:36:23 +08:00
IGCrystal
96b565e1e8 🎈 perf: comprehensive dashboard improvements
- Enhance i18n error handling and code quality - Fix SSE data processing in chat page - Improve responsive design for extension page - Add better debugging tools for development"
2025-06-16 21:05:20 +08:00
IGCrystal
9d7ad7a18f 🐞 fix(i18n): resolve translation loading issues in production build 2025-06-16 20:14:00 +08:00
IGCrystal
9838c2758b 🐞 fix: resolve vue-i18n module augme 2025-06-16 19:08:19 +08:00
Soulter
1b1f5f5a5e docs(README.md): update logo 2025-06-16 19:06:46 +08:00
IGCrystal
0f95f62aa1 feat: 完成仪表板国际化系统重构
 核心特性:
- 实现模块化i18n架构,支持22个功能模块
- 完成中英双语翻译文件(44个翻译文件)
- 新增懒加载翻译模块,提升性能
- 类型安全的翻译键值验证系统

🌐 国际化覆盖:
- 所有主要页面(15+)完成国际化
- 导航侧边栏、顶栏、共享组件全部支持
- 仪表板统计组件完整国际化
- 登录页面及认证流程完整国际化

🎨 UI/UX 优化:
- 统一顶栏按钮样式(语言切换+主题切换)
- 移动端登录页采用全屏设计
- Logo组件智能换行支持中英文
- 响应式语言切换组件

📱 移动端适配:
- 登录卡片移动端全屏布局
- 悬浮工具栏底部固定定位
- 触摸友好的交互设计
- 多设备响应式支持

🔧 技术改进:
- 模块化翻译文件结构 (core/*, features/*)
- 懒加载机制减少初始包体积
- TypeScript类型定义完整
- 翻译键值自动验证
2025-06-16 13:53:33 +08:00
Zhalslar
9405ba7871 feat:新增GPT_SoVIS适配器 2025-06-16 13:45:50 +08:00
zhx
ccb95f803c feat: wechatpadpro 发送tts时 添加对mp3格式音频支持 2025-06-16 10:05:21 +08:00
IGCrystal
60b2ff0a7a 🐞 fix: 修复iframe跳转问题 2025-06-16 00:47:41 +08:00
IGCrystal
e6c8507379 📃 docs: 删除i18n的叙述 2025-06-15 23:19:46 +08:00
IGCrystal
420db5416e Merge branch 'branch-1' of https://github.com/IGCrystal/AstrBot into branch-1 2025-06-15 23:16:25 +08:00
IGCrystal
6e03218d54 feat: 多语言国际化支持 2025-06-15 23:10:44 +08:00
IGCrystal
5e4bd36b26 Merge branch 'master' of https://github.com/AstrBotDevs/AstrBot 2025-06-15 23:07:03 +08:00
Soulter
bbc039366e Merge pull request #1816 from AstrBotDevs/refactor-webui-merge-extension-page
refactor(webui): 将插件市场与插件管理合并
2025-06-15 22:51:55 +08:00
Soulter
e1ec7dbbba refactor(webui): 将插件市场与插件管理合并 2025-06-15 22:50:06 +08:00
IGCrystal
075b008740 🐞 fix: 错误修复和代码健壮性
- 在 KnowledgeBase.vue 中修正无效的 v-class 指令为 class 属性的问题
- 在 ConsoleDisplayer.vue 中修正 historyNum 属性类型不匹配的问题
- 解决控制台中的 Vue 警告信息
- 在访问 status 前对 err.response 进行空值检查
- 防止“无法读取未定义对象的属性”错误
- 提高 catch 块中错误处理的健壮性
- 对 API 响应数据进行空值检查
- 在处理之前确保数组类型验证
- 修复“无法读取 null 对象的属性”错误
- 改进 beforeUnmount 生命周期中的 D3.js 清理工作
- 对图形数据处理添加防御性编程
2025-06-15 22:45:28 +08:00
IGCrystal
b2c382fa01 feat: 多语言国际化支持 2025-06-15 22:42:43 +08:00
Soulter
c5f9b5861f Merge pull request #1804 from RC-CHN/master
feat:优化聊天页面的UI和UX
2025-06-15 21:22:23 +08:00
Soulter
2dace4c697 Merge pull request #1801 from IGCrystal/master
🎈 perf: 优化登录界面样式和侧边栏样式
2025-06-15 21:15:31 +08:00
Zhalslar
c7891385ca Update dingtalk_event.py 2025-06-14 21:44:37 +08:00
Zhalslar
2059ddcadf Update dingtalk_event.py 2025-06-14 21:39:33 +08:00
Zhalslar
ba1b68df20 Update dingtalk_event.py 2025-06-14 21:23:45 +08:00
Soulter
403b61836d docs: update readme 2025-06-14 02:09:06 +08:00
Ruochen
b5af7d1eb9 为chatbox模式添加了夜间模式切换 2025-06-13 23:11:09 +08:00
Ruochen
f453af6e4c feat:优化聊天页面的UI和UX 2025-06-13 21:30:56 +08:00
IGCrystal
64245d001c Merge branch 'AstrBotDevs:master' into master 2025-06-13 00:59:21 +08:00
IGCrystal
7d92965cae 🎈 perf: 优化侧边栏样式 2025-06-12 23:51:44 +08:00
IGCrystal
b4fa08c4e2 🎈 perf: 优化登录界面样式 2025-06-12 23:26:01 +08:00
Soulter
d4e9566851 Merge pull request #1800 from AstrBotDevs/feat-weixinkefu-record
feat: 微信客服支持语音的收发
2025-06-12 23:02:22 +08:00
Soulter
a26b494f7f feat: 微信客服支持语音的收发
fixes: #1794
2025-06-12 10:57:16 -04:00
Soulter
b84e22e41f fix: separate provider
fixes #1793
2025-06-12 14:07:23 +08:00
Soulter
cee6efab19 Merge pull request #1783 from Kwicxy/fix
fix(readmeDialog): 修复了readme对话框内markdown渲染样式问题
2025-06-11 22:33:14 +08:00
Soulter
30f71cb550 Merge pull request #1791 from AstrBotDevs/feat-dify-user-param
Feature: supports dify user param
2025-06-11 22:26:07 +08:00
Soulter
771e755a78 feat: supports dify user param 2025-06-11 22:25:10 +08:00
Richard X.
e7f35098e4 fix(readmeDialog): Fix readme dialog markdown rendering over different appearances.
Fix readme dialog markdown rendering over different appearances.
2025-06-10 21:46:35 +08:00
shuiping233
1ce95c473d fix : 在stage.py中专门对qq_official的会话控制器消息进行处理 2025-06-08 10:20:09 +08:00
shuiping233
eb365e398d fix: qq_official适配器使用SessionController(会话控制)功能时机器人回复消息无法发送到聊天平台 2025-06-08 10:20:09 +08:00
182 changed files with 12627 additions and 4604 deletions

View File

@@ -0,0 +1,30 @@
---
name: '🥳 发布插件'
title: "[Plugin] 插件名"
about: 提交插件到插件市场
labels: [ "plugin-publish" ]
assignees: ''
---
欢迎发布插件到插件市场!
## 插件基本信息
请将插件信息填写到下方的 Json 代码块中。`tags`(插件标签)和 `social_link`(社交链接)选填。
```json
{
"name": "插件名",
"desc": "插件介绍",
"repo": "插件仓库链接",
"tags": [],
"social_link": ""
}
```
## 检查
- [ ] 我的插件经过完整的测试
- [ ] 我的插件不包含恶意代码
- [ ] 我已阅读并同意遵守该项目的 [行为准则](https://docs.github.com/zh/site-policy/github-terms/github-community-code-of-conduct)。

View File

@@ -1,40 +0,0 @@
name: '🥳 发布插件'
title: "[Plugin] 插件名"
description: 提交插件到插件市场
labels: [ "plugin-publish" ]
body:
- type: markdown
attributes:
value: |
欢迎发布插件到插件市场!请确保您的插件经过**完整的**测试。
- type: textarea
attributes:
label: 插件仓库
description: 插件的 GitHub 仓库链接
placeholder: >
如 https://github.com/Soulter/astrbot-github-cards
- type: textarea
attributes:
label: 描述
value: |
插件名:
插件作者:
插件简介:
支持的消息平台:(必填,如 QQ、微信、飞书)
标签:(可选)
社交链接:(可选, 将会在插件市场作者名称上作为可点击的链接)
description: 必填。请以列表的字段按顺序将插件名、插件作者、插件简介放在这里。如果您不知道支持哪些消息平台,请填写测试过的消息平台。
- type: checkboxes
attributes:
label: Code of Conduct
options:
- label: >
我已阅读并同意遵守该项目的 [行为准则](https://docs.github.com/zh/site-policy/github-terms/github-community-code-of-conduct)。
required: true
- type: markdown
attributes:
value: "❤️"

View File

@@ -1,6 +1,10 @@
name: AstrBot Dashboard CI
on: [push]
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
jobs:
build:

View File

@@ -1,6 +1,6 @@
<p align="center">
![yjtp](https://github.com/user-attachments/assets/dcc74009-c57e-4b66-9ae3-0a81fc001255)
![AstrBot-Logo-Simplified](https://github.com/user-attachments/assets/ffd99b6b-3272-4682-beaa-6fe74250f7d9)
</p>
@@ -53,7 +53,7 @@ AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用
> 🪧 我们正基于前沿科研成果,设计并实现适用于角色扮演和情感陪伴的长短期记忆模型及情绪控制模型,旨在提升对话的真实性与情感表达能力。敬请期待 `v3.6.0` 版本!
1. **大语言模型对话**。支持各种大语言模型,包括 OpenAI API、Google Gemini、Llama、Deepseek、ChatGLM 等,支持接入本地部署的大模型,通过 Ollama、LLMTuner。具有多轮对话、人格情境、多模态能力支持图片理解、语音转文字Whisper
2. **多消息平台接入**。支持接入 QQOneBot、QQ 频道、微信Gewechat、飞书、Telegram。后续将支持钉钉、Discord、WhatsApp、小爱音响。支持速率限制、白名单、关键词过滤、百度内容审核。
2. **多消息平台接入**。支持接入 QQOneBot、QQ 官方机器人平台、QQ 频道、微信、企业微信、微信公众号、飞书、Telegram钉钉、Discord、KOOK、VoceChat。支持速率限制、白名单、关键词过滤、百度内容审核。
3. **Agent**。原生支持部分 Agent 能力,如代码执行器、自然语言待办、网页搜索。对接 [Dify 平台](https://dify.ai/),便捷接入 Dify 智能助手、知识库和 Dify 工作流。
4. **插件扩展**。深度优化的插件机制,支持[开发插件](https://astrbot.app/dev/plugin.html)扩展功能,极简开发。已支持安装多个插件。
5. **可视化管理面板**。支持可视化修改配置、插件管理、日志查看等功能,降低配置难度。集成 WebChat可在面板上与大模型对话。
@@ -117,20 +117,24 @@ uvx astrbot init
## ⚡ 消息平台支持情况
| 平台 | 支持性 | 详情 | 消息类型 |
| -------- | ------- | ------- | ------ |
| QQ(官方机器人接口) | ✔ | 私聊、群聊QQ 频道私聊、群聊 | 文字、图片 |
| QQ(OneBot) | ✔ | 私聊、群聊 | 文字、图片、语音 |
| 微信个人号 | ✔ | 微信个人号私聊、群聊 | 文字、图片、语音 |
| Telegram | ✔ | 私聊、群聊 | 文字、图片 |
| 企业微信 | ✔ | 私聊 | 文字、图片、语音 |
| 微信客服 | ✔ | 私聊 | 文字、图片 |
| 飞书 | ✔ | 私聊、群聊 | 文字、图片 |
| 钉钉 | ✔ | 私聊、群聊 | 文字、图片 |
| 微信对话开放平台 | 🚧 | 计划内 | - |
| Discord | 🚧 | 计划内 | - |
| WhatsApp | 🚧 | 计划内 | - |
| 小爱音响 | 🚧 | 计划内 | - |
| 平台 | 支持性 |
| -------- | ------- |
| QQ(官方机器人接口) | ✔ |
| QQ(OneBot) | ✔ |
| 微信个人号 | ✔ |
| Telegram | ✔ |
| 企业微信 | ✔ |
| 微信客服 | ✔ |
| 微信公众号 | ✔ |
| 飞书 | ✔ |
| 钉钉 | ✔ |
| Slack | |
| Discord | ✔ |
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | |
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | ✔ |
| 微信对话开放平台 | 🚧 |
| WhatsApp | 🚧 |
| 小爱音响 | 🚧 |
## ⚡ 提供商支持情况
@@ -151,6 +155,7 @@ uvx astrbot init
| SenseVoice | ✔ | 语音转文本 | 本地部署 |
| OpenAI TTS API | ✔ | 文本转语音 | |
| GSVI | ✔ | 文本转语音 | GPT-Sovits-Inference |
| GPT-SoVITs | ✔ | 文本转语音 | GPT-Sovits-Inference |
| FishAudio | ✔ | 文本转语音 | GPT-Sovits 作者参与的项目 |
| Edge TTS | ✔ | 文本转语音 | Edge 浏览器的免费 TTS |
| 阿里云百炼 TTS | ✔ | 文本转语音 | |
@@ -218,7 +223,7 @@ _✨ WebUI ✨_
此外,本项目的诞生离不开以下开源项目:
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ)
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - 伟大的猫猫框架
- [wechatpy/wechatpy](https://github.com/wechatpy/wechatpy)
## ⭐ Star History
@@ -232,6 +237,9 @@ _✨ WebUI ✨_
</div>
![10k-star-banner-credit-by-kevin](https://github.com/user-attachments/assets/c97fc5fb-20b9-4bc8-9998-c20b930ab097)
## Disclaimer
1. The project is protected under the `AGPL-v3` opensource license.

View File

@@ -13,7 +13,6 @@ from .utils.astrbot_path import get_astrbot_data_path
# 初始化数据存储文件夹
os.makedirs(get_astrbot_data_path(), exist_ok=True)
WEBUI_SK = "Advanced_System_for_Text_Response_and_Bot_Operations_Tool"
DEMO_MODE = os.getenv("DEMO_MODE", False)
astrbot_config = AstrBotConfig()
@@ -31,4 +30,3 @@ pip_installer = PipInstaller(
)
web_chat_queue = asyncio.Queue(maxsize=32)
web_chat_back_queue = asyncio.Queue(maxsize=32)

View File

@@ -3,15 +3,17 @@
"""
import os
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "3.5.15"
VERSION = "3.5.18"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v3.db")
# 默认配置
DEFAULT_CONFIG = {
"config_version": 2,
"platform_settings": {
"plugin_enable": [],
"unique_session": False,
"rate_limit": {
"time": 60,
@@ -59,6 +61,7 @@ DEFAULT_CONFIG = {
"max_context_length": -1,
"dequeue_context_length": 1,
"streaming_response": False,
"show_tool_use_status": False,
"streaming_segmented": False,
"separate_provider": False,
},
@@ -102,6 +105,7 @@ DEFAULT_CONFIG = {
"enable": True,
"username": "astrbot",
"password": "77b90590a8945a7d36c963981a307dc9",
"jwt_secret": "",
"host": "0.0.0.0",
"port": 6185,
},
@@ -126,7 +130,7 @@ CONFIG_METADATA_2 = {
"description": "消息平台适配器",
"type": "list",
"config_template": {
"qq_official(QQ)": {
"QQ 官方机器人(WebSocket)": {
"id": "default",
"type": "qq_official",
"enable": False,
@@ -135,7 +139,7 @@ CONFIG_METADATA_2 = {
"enable_group_c2c": True,
"enable_guild_direct_message": True,
},
"qq_official_webhook(QQ)": {
"QQ 官方机器人(Webhook)": {
"id": "default",
"type": "qq_official_webhook",
"enable": False,
@@ -144,7 +148,7 @@ CONFIG_METADATA_2 = {
"callback_server_host": "0.0.0.0",
"port": 6196,
},
"aiocqhttp(OneBotv11)": {
"QQ 个人号(aiocqhttp)": {
"id": "default",
"type": "aiocqhttp",
"enable": False,
@@ -152,7 +156,7 @@ CONFIG_METADATA_2 = {
"ws_reverse_port": 6199,
"ws_reverse_token": "",
},
"gewechat(微信)": {
"微信个人号(Gewechat)": {
"id": "gwchat",
"type": "gewechat",
"enable": False,
@@ -161,7 +165,7 @@ CONFIG_METADATA_2 = {
"host": "这里填写你的局域网IP或者公网服务器IP",
"port": 11451,
},
"wechatpadpro(微信)": {
"微信个人号(WeChatPadPro)": {
"id": "wechatpadpro",
"type": "wechatpadpro",
"enable": False,
@@ -171,7 +175,7 @@ CONFIG_METADATA_2 = {
"wpp_active_message_poll": False,
"wpp_active_message_poll_interval": 3,
},
"weixin_official_account(微信公众平台)": {
"微信公众平台": {
"id": "weixin_official_account",
"type": "weixin_official_account",
"enable": False,
@@ -184,7 +188,7 @@ CONFIG_METADATA_2 = {
"port": 6194,
"active_send_mode": False,
},
"wecom(企业微信)": {
"企业微信(含微信客服)": {
"id": "wecom",
"type": "wecom",
"enable": False,
@@ -197,7 +201,7 @@ CONFIG_METADATA_2 = {
"callback_server_host": "0.0.0.0",
"port": 6195,
},
"lark(飞书)": {
"飞书(Lark)": {
"id": "lark",
"type": "lark",
"enable": False,
@@ -206,14 +210,14 @@ CONFIG_METADATA_2 = {
"app_secret": "",
"domain": "https://open.feishu.cn",
},
"dingtalk(钉钉)": {
"钉钉(DingTalk)": {
"id": "dingtalk",
"type": "dingtalk",
"enable": False,
"client_id": "",
"client_secret": "",
},
"telegram": {
"Telegram": {
"id": "telegram",
"type": "telegram",
"enable": False,
@@ -225,8 +229,51 @@ CONFIG_METADATA_2 = {
"telegram_command_auto_refresh": True,
"telegram_command_register_interval": 300,
},
"Discord": {
"id": "discord",
"type": "discord",
"enable": False,
"discord_token": "",
"discord_proxy": "",
"discord_command_register": True,
"discord_guild_id_for_debug": "",
"discord_activity_name": "",
},
"Slack": {
"id": "slack",
"type": "slack",
"enable": False,
"bot_token": "",
"app_token": "",
"signing_secret": "",
"slack_connection_mode": "socket", # webhook, socket
"slack_webhook_host": "0.0.0.0",
"slack_webhook_port": 6197,
"slack_webhook_path": "/astrbot-slack-webhook/callback",
},
},
"items": {
"slack_connection_mode": {
"description": "Slack Connection Mode",
"type": "string",
"options": ["webhook", "socket"],
"hint": "The connection mode for Slack. `webhook` uses a webhook server, `socket` uses Slack's Socket Mode.",
},
"slack_webhook_host": {
"description": "Slack Webhook Host",
"type": "string",
"hint": "Only valid when Slack connection mode is `webhook`.",
},
"slack_webhook_port": {
"description": "Slack Webhook Port",
"type": "int",
"hint": "Only valid when Slack connection mode is `webhook`.",
},
"slack_webhook_path": {
"description": "Slack Webhook Path",
"type": "string",
"hint": "Only valid when Slack connection mode is `webhook`.",
},
"active_send_mode": {
"description": "是否换用主动发送接口",
"type": "bool",
@@ -324,6 +371,25 @@ CONFIG_METADATA_2 = {
"hint": "请务必填对,否则 @ 机器人将无法唤醒,只能通过前缀唤醒。",
"obvious_hint": True,
},
"discord_token": {
"description": "Discord Bot Token",
"type": "string",
"hint": "在此处填入你的Discord Bot Token",
},
"discord_proxy": {
"description": "Discord 代理地址",
"type": "string",
"hint": "可选的代理地址http://ip:port",
},
"discord_command_register": {
"description": "是否自动将插件指令注册为 Discord 斜杠指令",
"type": "bool",
},
"discord_activity_name": {
"description": "Discord 活动名称",
"type": "string",
"hint": "可选的 Discord 活动名称。留空则不设置活动。",
},
},
},
"platform_settings": {
@@ -376,7 +442,7 @@ CONFIG_METADATA_2 = {
"ignore_bot_self_message": {
"description": "是否忽略机器人自身的消息",
"type": "bool",
"hint": "某些平台如 gewechat 会将自身账号在其他 APP 端发送的消息也当做消息事件下发导致给自己发消息时唤醒机器人",
"hint": "某些平台会将自身账号在其他 APP 端发送的消息也当做消息事件下发导致给自己发消息时唤醒机器人",
},
"ignore_at_all": {
"description": "是否忽略 @ 全体成员",
@@ -705,17 +771,6 @@ CONFIG_METADATA_2 = {
"model": "deepseek/deepseek-r1",
},
},
"LLMTuner": {
"id": "llmtuner_default",
"type": "llm_tuner",
"provider_type": "chat_completion",
"enable": True,
"base_model_path": "",
"adapter_model_path": "",
"llmtuner_template": "",
"finetuning_type": "lora",
"quantization_bit": 4,
},
"Dify": {
"id": "dify_app_default",
"type": "dify",
@@ -800,6 +855,37 @@ CONFIG_METADATA_2 = {
"edge-tts-voice": "zh-CN-XiaoxiaoNeural",
"timeout": 20,
},
"GSV TTS(本地加载)": {
"id": "gsv_tts",
"enable": False,
"type": "gsv_tts_selfhost",
"provider_type": "text_to_speech",
"api_base": "http://127.0.0.1:9880",
"gpt_weights_path": "",
"sovits_weights_path": "",
"timeout": 60,
"gsv_default_parms": {
"gsv_ref_audio_path": "",
"gsv_prompt_text": "",
"gsv_prompt_lang": "zh",
"gsv_aux_ref_audio_paths": "",
"gsv_text_lang": "zh",
"gsv_top_k": 5,
"gsv_top_p": 1.0,
"gsv_temperature": 1.0,
"gsv_text_split_method": "cut3",
"gsv_batch_size": 1,
"gsv_batch_threshold": 0.75,
"gsv_split_bucket": True,
"gsv_speed_factor": 1,
"gsv_fragment_interval": 0.3,
"gsv_streaming_mode": False,
"gsv_seed": -1,
"gsv_parallel_infer": True,
"gsv_repetition_penalty": 1.35,
"gsv_media_type": "wav",
},
},
"GSVI TTS(API)": {
"id": "gsvi_tts",
"type": "gsvi_tts_api",
@@ -877,6 +963,18 @@ CONFIG_METADATA_2 = {
"api_base": "https://openspeech.bytedance.com/api/v1/tts",
"timeout": 20,
},
"Gemini TTS": {
"id": "gemini_tts",
"type": "gemini_tts",
"provider_type": "text_to_speech",
"enable": False,
"gemini_tts_api_key": "",
"gemini_tts_api_base": "",
"gemini_tts_timeout": 20,
"gemini_tts_model": "gemini-2.5-flash-preview-tts",
"gemini_tts_prefix": "",
"gemini_tts_voice_name": "Leda",
},
"OpenAI Embedding": {
"id": "openai_embedding",
"type": "openai_embedding",
@@ -901,6 +999,130 @@ CONFIG_METADATA_2 = {
},
},
"items": {
"gpt_weights_path": {
"description": "GPT模型文件路径",
"type": "string",
"hint": "即“.ckpt”后缀的文件请使用绝对路径路径两端不要带双引号不填则默认用GPT_SoVITS内置的SoVITS模型(建议直接在GPT_SoVITS中改默认模型)",
"obvious_hint": True,
},
"sovits_weights_path": {
"description": "SoVITS模型文件路径",
"type": "string",
"hint": "即“.pth”后缀的文件请使用绝对路径路径两端不要带双引号不填则默认用GPT_SoVITS内置的SoVITS模型(建议直接在GPT_SoVITS中改默认模型)",
"obvious_hint": True,
},
"gsv_default_parms": {
"description": "GPT_SoVITS默认参数",
"hint": "参考音频文件路径、参考音频文本必填,其他参数根据个人爱好自行填写",
"type": "object",
"items": {
"gsv_ref_audio_path": {
"description": "参考音频文件路径",
"type": "string",
"hint": "必填!请使用绝对路径!路径两端不要带双引号!",
"obvious_hint": True,
},
"gsv_prompt_text": {
"description": "参考音频文本",
"type": "string",
"hint": "必填!请填写参考音频讲述的文本",
"obvious_hint": True,
},
"gsv_prompt_lang": {
"description": "参考音频文本语言",
"type": "string",
"hint": "请填写参考音频讲述的文本的语言,默认为中文",
},
"gsv_aux_ref_audio_paths": {
"description": "辅助参考音频文件路径",
"type": "string",
"hint": "辅助参考音频文件,可不填",
},
"gsv_text_lang": {
"description": "文本语言",
"type": "string",
"hint": "默认为中文",
},
"gsv_top_k": {
"description": "生成语音的多样性",
"type": "int",
"hint": "",
},
"gsv_top_p": {
"description": "核采样的阈值",
"type": "float",
"hint": "",
},
"gsv_temperature": {
"description": "生成语音的随机性",
"type": "float",
"hint": "",
},
"gsv_text_split_method": {
"description": "切分文本的方法",
"type": "string",
"hint": "可选值: `cut0`:不切分 `cut1`:四句一切 `cut2`50字一切 `cut3`:按中文句号切 `cut4`:按英文句号切 `cut5`:按标点符号切",
"options": [
"cut0",
"cut1",
"cut2",
"cut3",
"cut4",
"cut5",
],
},
"gsv_batch_size": {
"description": "批处理大小",
"type": "int",
"hint": "",
},
"gsv_batch_threshold": {
"description": "批处理阈值",
"type": "float",
"hint": "",
},
"gsv_split_bucket": {
"description": "将文本分割成桶以便并行处理",
"type": "bool",
"hint": "",
},
"gsv_speed_factor": {
"description": "语音播放速度",
"type": "float",
"hint": "1为原始语速",
},
"gsv_fragment_interval": {
"description": "语音片段之间的间隔时间",
"type": "float",
"hint": "",
},
"gsv_streaming_mode": {
"description": "启用流模式",
"type": "bool",
"hint": "",
},
"gsv_seed": {
"description": "随机种子",
"type": "int",
"hint": "用于结果的可重复性",
},
"gsv_parallel_infer": {
"description": "并行执行推理",
"type": "bool",
"hint": "",
},
"gsv_repetition_penalty": {
"description": "重复惩罚因子",
"type": "float",
"hint": "",
},
"gsv_media_type": {
"description": "输出媒体的类型",
"type": "string",
"hint": "建议用wav",
},
},
},
"embedding_dimensions": {
"description": "嵌入维度",
"type": "int",
@@ -1467,10 +1689,15 @@ CONFIG_METADATA_2 = {
"type": "bool",
"hint": "启用后,将会流式输出 LLM 的响应。目前仅支持 OpenAI API提供商 以及 Telegram、QQ Official 私聊 两个平台",
},
"show_tool_use_status": {
"description": "函数调用状态输出",
"type": "bool",
"hint": "在触发函数调用时输出其函数名和内容。",
},
"streaming_segmented": {
"description": "不支持流式回复的平台分段输出",
"type": "bool",
"hint": "启用后,若平台不支持流式回复,会分段输出。目前仅支持 aiocqhttp 和 gewechat 两个平台,不支持或无需使用流式分段输出的平台会静默忽略此选项",
"hint": "启用后,若平台不支持流式回复,会分段输出。目前仅支持 aiocqhttp 两个平台,不支持或无需使用流式分段输出的平台会静默忽略此选项",
},
},
},

View File

@@ -46,9 +46,12 @@ class AstrBotCoreLifecycle:
self.astrbot_config = astrbot_config # 初始化配置
self.db = db # 初始化数据库
# 根据环境变量设置代理
os.environ["https_proxy"] = self.astrbot_config["http_proxy"]
os.environ["http_proxy"] = self.astrbot_config["http_proxy"]
# 设置代理
if self.astrbot_config.get("http_proxy", ""):
os.environ["https_proxy"] = self.astrbot_config["http_proxy"]
os.environ["http_proxy"] = self.astrbot_config["http_proxy"]
if proxy := os.environ.get("https_proxy"):
logger.debug(f"Using proxy: {proxy}")
os.environ["no_proxy"] = "localhost"
async def initialize(self):

View File

@@ -125,6 +125,8 @@ class Plain(BaseMessageComponent):
def toDict(self):
return {"type": "text", "data": {"text": self.text.strip()}}
async def to_dict(self):
return {"type": "text", "data": {"text": self.text}}
class Face(BaseMessageComponent):
type: ComponentType = "Face"
@@ -610,6 +612,10 @@ class Node(BaseMessageComponent):
"data": {"file": f"base64://{bs64}"},
}
)
elif isinstance(comp, Plain):
# For Plain segments, we need to handle the plain differently
d = await comp.to_dict()
data_content.append(d)
elif isinstance(comp, File):
# For File segments, we need to handle the file differently
d = await comp.to_dict()

View File

@@ -24,6 +24,8 @@ class MessageChain:
chain: List[BaseMessageComponent] = field(default_factory=list)
use_t2i_: Optional[bool] = None # None 为跟随用户设置
type: Optional[str] = None
"""消息链承载的消息的类型。可选,用于让消息平台区分不同业务场景的消息链。"""
def message(self, message: str):
"""添加一条文本消息到消息链 `chain` 中。
@@ -98,6 +100,15 @@ class MessageChain:
self.chain.append(Image.fromFileSystem(path))
return self
def base64_image(self, base64_str: str):
"""添加一条图片消息base64 编码字符串)到消息链 `chain` 中。
Example:
CommandResult().base64_image("iVBORw0KGgoAAAANSUhEUgAAAAUA...")
"""
self.chain.append(Image.fromBase64(base64_str))
return self
def use_t2i(self, use_t2i: bool):
"""设置是否使用文本转图片服务。
@@ -157,7 +168,7 @@ class ResultContentType(enum.Enum):
"""普通的消息结果"""
STREAMING_RESULT = enum.auto()
"""调用 LLM 产生的流式结果"""
STREAMING_FINISH= enum.auto()
STREAMING_FINISH = enum.auto()
"""流式输出完成"""

View File

@@ -1,6 +1,14 @@
import inspect
import traceback
import typing as T
from dataclasses import dataclass
from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.star import PluginManager
from astrbot.api import logger
from astrbot.core.star.star_handler import star_handlers_registry, EventType
from astrbot.core.star.star import star_map
from astrbot.core.message.message_event_result import MessageEventResult, CommandResult
@dataclass
@@ -9,3 +17,91 @@ class PipelineContext:
astrbot_config: AstrBotConfig # AstrBot 配置对象
plugin_manager: PluginManager # 插件管理器对象
async def call_event_hook(
self,
event: AstrMessageEvent,
hook_type: EventType,
*args,
):
platform_id = event.get_platform_id()
handlers = star_handlers_registry.get_handlers_by_event_type(
hook_type, platform_id=platform_id
)
for handler in handlers:
try:
logger.debug(
f"hook(on_llm_request) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}"
)
await handler.handler(event, *args)
except BaseException:
logger.error(traceback.format_exc())
if event.is_stopped():
logger.info(
f"{star_map[handler.handler_module_path].name} - {handler.handler_name} 终止了事件传播。"
)
return
async def call_handler(
self,
event: AstrMessageEvent,
handler: T.Awaitable,
*args,
**kwargs,
) -> T.AsyncGenerator[None, None]:
"""执行事件处理函数并处理其返回结果
该方法负责调用处理函数并处理不同类型的返回值。它支持两种类型的处理函数:
1. 异步生成器: 实现洋葱模型,每次 yield 都会将控制权交回上层
2. 协程: 执行一次并处理返回值
Args:
ctx (PipelineContext): 消息管道上下文对象
event (AstrMessageEvent): 事件对象
handler (Awaitable): 事件处理函数
Returns:
AsyncGenerator[None, None]: 异步生成器,用于在管道中传递控制流
"""
ready_to_call = None # 一个协程或者异步生成器
trace_ = None
try:
ready_to_call = handler(event, *args, **kwargs)
except TypeError as _:
# 向下兼容
trace_ = traceback.format_exc()
# 以前的 handler 会额外传入一个参数, 但是 context 对象实际上在插件实例中有一份
ready_to_call = handler(event, self.plugin_manager.context, *args, **kwargs)
if inspect.isasyncgen(ready_to_call):
_has_yielded = False
try:
async for ret in ready_to_call:
# 这里逐步执行异步生成器, 对于每个 yield 返回的 ret, 执行下面的代码
# 返回值只能是 MessageEventResult 或者 None无返回值
_has_yielded = True
if isinstance(ret, (MessageEventResult, CommandResult)):
# 如果返回值是 MessageEventResult, 设置结果并继续
event.set_result(ret)
yield
else:
# 如果返回值是 None, 则不设置结果并继续
# 继续执行后续阶段
yield ret
if not _has_yielded:
# 如果这个异步生成器没有执行到 yield 分支
yield
except Exception as e:
logger.error(f"Previous Error: {trace_}")
raise e
elif inspect.iscoroutine(ready_to_call):
# 如果只是一个协程, 直接执行
ret = await ready_to_call
if isinstance(ret, (MessageEventResult, CommandResult)):
event.set_result(ret)
yield
else:
yield ret

View File

@@ -0,0 +1,57 @@
import abc
import typing as T
from dataclasses import dataclass
from astrbot.core.provider.entities import LLMResponse
from ....message.message_event_result import MessageChain
from enum import Enum, auto
class AgentState(Enum):
"""Agent 状态枚举"""
IDLE = auto() # 初始状态
RUNNING = auto() # 运行中
DONE = auto() # 完成
ERROR = auto() # 错误状态
class AgentResponseData(T.TypedDict):
chain: MessageChain
@dataclass
class AgentResponse:
type: str
data: AgentResponseData
class BaseAgentRunner:
@abc.abstractmethod
async def reset(self) -> None:
"""
Reset the agent to its initial state.
This method should be called before starting a new run.
"""
...
@abc.abstractmethod
async def step(self) -> T.AsyncGenerator[AgentResponse, None]:
"""
Process a single step of the agent.
"""
...
@abc.abstractmethod
def done(self) -> bool:
"""
Check if the agent has completed its task.
Returns True if the agent is done, False otherwise.
"""
...
@abc.abstractmethod
def get_final_llm_resp(self) -> LLMResponse | None:
"""
Get the final observation from the agent.
This method should be called after the agent is done.
"""
...

View File

@@ -0,0 +1,300 @@
import sys
import traceback
import typing as T
from .base import BaseAgentRunner, AgentResponse, AgentResponseData, AgentState
from ...context import PipelineContext
from astrbot.core.provider.provider import Provider
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.message.message_event_result import (
MessageChain,
)
from astrbot.core.provider.entities import (
ProviderRequest,
LLMResponse,
ToolCallMessageSegment,
AssistantMessageSegment,
ToolCallsResult,
)
from mcp.types import (
TextContent,
ImageContent,
EmbeddedResource,
TextResourceContents,
BlobResourceContents,
)
from astrbot.core.star.star_handler import EventType
from astrbot import logger
if sys.version_info >= (3, 12):
from typing import override
else:
from typing_extensions import override
# TODO:
# 1. 处理平台不兼容的处理器
class ToolLoopAgent(BaseAgentRunner):
def __init__(
self, provider: Provider, event: AstrMessageEvent, pipeline_ctx: PipelineContext
) -> None:
self.provider = provider
self.req = None
self.event = event
self.pipeline_ctx = pipeline_ctx
self._state = AgentState.IDLE
self.final_llm_resp = None
self.streaming = False
@override
async def reset(self, req: ProviderRequest, streaming: bool) -> None:
self.req = req
self.streaming = streaming
self.final_llm_resp = None
self._state = AgentState.IDLE
def _transition_state(self, new_state: AgentState) -> None:
"""转换 Agent 状态"""
if self._state != new_state:
logger.debug(f"Agent state transition: {self._state} -> {new_state}")
self._state = new_state
async def _iter_llm_responses(self) -> T.AsyncGenerator[LLMResponse, None]:
"""Yields chunks *and* a final LLMResponse."""
if self.streaming:
stream = self.provider.text_chat_stream(**self.req.__dict__)
async for resp in stream: # type: ignore
yield resp
else:
yield await self.provider.text_chat(**self.req.__dict__)
@override
async def step(self):
"""
Process a single step of the agent.
This method should return the result of the step.
"""
if not self.req:
raise ValueError("Request is not set. Please call reset() first.")
# 开始处理,转换到运行状态
self._transition_state(AgentState.RUNNING)
llm_resp_result = None
async for llm_response in self._iter_llm_responses():
assert isinstance(llm_response, LLMResponse)
if llm_response.is_chunk:
if llm_response.result_chain:
yield AgentResponse(
type="streaming_delta",
data=AgentResponseData(chain=llm_response.result_chain),
)
else:
yield AgentResponse(
type="streaming_delta",
data=AgentResponseData(
chain=MessageChain().message(llm_response.completion_text)
),
)
continue
llm_resp_result = llm_response
break # got final response
if not llm_resp_result:
return
# 处理 LLM 响应
llm_resp = llm_resp_result
logger.debug(f"LLMResp: {llm_resp}")
if llm_resp.role == "err":
# 如果 LLM 响应错误,转换到错误状态
self.final_llm_resp = llm_resp
self._transition_state(AgentState.ERROR)
yield AgentResponse(
type="err",
data=AgentResponseData(
chain=MessageChain().message(
f"LLM 响应错误: {llm_resp.completion_text or '未知错误'}"
)
),
)
if not llm_resp.tools_call_name:
# 如果没有工具调用,转换到完成状态
self.final_llm_resp = llm_resp
self._transition_state(AgentState.DONE)
# 执行事件钩子
await self.pipeline_ctx.call_event_hook(
self.event, EventType.OnLLMResponseEvent, llm_resp
)
# 返回 LLM 结果
if llm_resp.result_chain:
yield AgentResponse(
type="llm_result",
data=AgentResponseData(chain=llm_resp.result_chain),
)
elif llm_resp.completion_text:
yield AgentResponse(
type="llm_result",
data=AgentResponseData(
chain=MessageChain().message(llm_resp.completion_text)
),
)
# 如果有工具调用,还需处理工具调用
if llm_resp.tools_call_name:
tool_call_result_blocks = []
for tool_call_name in llm_resp.tools_call_name:
yield AgentResponse(
type="tool_call",
data=AgentResponseData(
chain=MessageChain().message(f"🔨 调用工具: {tool_call_name}")
),
)
async for result in self._handle_function_tools(self.req, llm_resp):
if isinstance(result, list):
tool_call_result_blocks = result
elif isinstance(result, MessageChain):
yield AgentResponse(
type="tool_call_result",
data=AgentResponseData(chain=result),
)
# 将结果添加到上下文中
tool_calls_result = ToolCallsResult(
tool_calls_info=AssistantMessageSegment(
role="assistant",
tool_calls=llm_resp.to_openai_tool_calls(),
content=llm_resp.completion_text,
),
tool_calls_result=tool_call_result_blocks,
)
self.req.append_tool_calls_result(tool_calls_result)
async def _handle_function_tools(
self,
req: ProviderRequest,
llm_response: LLMResponse,
) -> T.AsyncGenerator[MessageChain | list[ToolCallMessageSegment], None]:
"""处理函数工具调用。"""
tool_call_result_blocks: list[ToolCallMessageSegment] = []
logger.info(f"Agent 使用工具: {llm_response.tools_call_name}")
# 执行函数调用
for func_tool_name, func_tool_args, func_tool_id in zip(
llm_response.tools_call_name,
llm_response.tools_call_args,
llm_response.tools_call_ids,
):
try:
if not req.func_tool:
return
func_tool = req.func_tool.get_func(func_tool_name)
if func_tool.origin == "mcp":
logger.info(
f"从 MCP 服务 {func_tool.mcp_server_name} 调用工具函数:{func_tool.name},参数:{func_tool_args}"
)
client = req.func_tool.mcp_client_dict[func_tool.mcp_server_name]
res = await client.session.call_tool(func_tool.name, func_tool_args)
if not res:
continue
if isinstance(res.content[0], TextContent):
tool_call_result_blocks.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content=res.content[0].text,
)
)
yield MessageChain().message(res.content[0].text)
elif isinstance(res.content[0], ImageContent):
tool_call_result_blocks.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content="返回了图片(已直接发送给用户)",
)
)
yield MessageChain().base64_image(res.content[0].data)
elif isinstance(res.content[0], EmbeddedResource):
resource = res.content[0].resource
if isinstance(resource, TextResourceContents):
tool_call_result_blocks.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content=resource.text,
)
)
yield MessageChain().message(resource.text)
elif (
isinstance(resource, BlobResourceContents)
and resource.mimeType
and resource.mimeType.startswith("image/")
):
tool_call_result_blocks.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content="返回了图片(已直接发送给用户)",
)
)
yield MessageChain().base64_image(res.content[0].data)
else:
tool_call_result_blocks.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content="返回的数据类型不受支持",
)
)
yield MessageChain().message("返回的数据类型不受支持。")
else:
logger.info(f"使用工具:{func_tool_name},参数:{func_tool_args}")
# 尝试调用工具函数
wrapper = self.pipeline_ctx.call_handler(
self.event, func_tool.handler, **func_tool_args
)
async for resp in wrapper:
if resp is not None:
# Tool 返回结果
tool_call_result_blocks.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content=resp,
)
)
yield MessageChain().message(resp)
else:
# Tool 直接请求发送消息给用户
# 这里我们将直接结束 Agent Loop。
self._transition_state(AgentState.DONE)
if res := self.event.get_result():
if res.chain:
yield MessageChain(chain=res.chain)
self.event.clear_result()
except Exception as e:
logger.warning(traceback.format_exc())
tool_call_result_blocks.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content=f"error: {str(e)}",
)
)
# 处理函数调用响应
if tool_call_result_blocks:
yield tool_call_result_blocks
def done(self) -> bool:
"""检查 Agent 是否已完成工作"""
return self._state in (AgentState.DONE, AgentState.ERROR)
def get_final_llm_resp(self) -> LLMResponse | None:
return self.final_llm_resp

View File

@@ -3,6 +3,7 @@
"""
import traceback
import copy
import asyncio
import json
from typing import Union, AsyncGenerator
@@ -20,39 +21,27 @@ from astrbot.core.utils.metrics import Metric
from astrbot.core.provider.entities import (
ProviderRequest,
LLMResponse,
ToolCallMessageSegment,
AssistantMessageSegment,
ToolCallsResult,
)
from astrbot.core.star.star_handler import star_handlers_registry, EventType
from astrbot.core.star.star import star_map
from mcp.types import (
TextContent,
ImageContent,
EmbeddedResource,
TextResourceContents,
BlobResourceContents,
)
from astrbot.core.star.star_handler import EventType
from astrbot.core import web_chat_back_queue
from ..agent_runner.tool_loop_agent import ToolLoopAgent
class LLMRequestSubStage(Stage):
async def initialize(self, ctx: PipelineContext) -> None:
self.ctx = ctx
self.bot_wake_prefixs = ctx.astrbot_config["wake_prefix"] # list
self.provider_wake_prefix = ctx.astrbot_config["provider_settings"][
"wake_prefix"
] # str
self.max_context_length = ctx.astrbot_config["provider_settings"][
"max_context_length"
] # int
self.dequeue_context_length = min(
max(1, ctx.astrbot_config["provider_settings"]["dequeue_context_length"]),
conf = ctx.astrbot_config
settings = conf["provider_settings"]
self.bot_wake_prefixs: list[str] = conf["wake_prefix"] # list
self.provider_wake_prefix: str = settings["wake_prefix"] # str
self.max_context_length = settings["max_context_length"] # int
self.dequeue_context_length: int = min(
max(1, settings["dequeue_context_length"]),
self.max_context_length - 1,
) # int
self.streaming_response = ctx.astrbot_config["provider_settings"][
"streaming_response"
] # bool
)
self.streaming_response: bool = settings["streaming_response"]
self.max_step: int = settings.get("max_agent_step", 10)
self.show_tool_use: bool = settings.get("show_tool_use_status", True)
for bwp in self.bot_wake_prefixs:
if self.provider_wake_prefix.startswith(bwp):
@@ -83,10 +72,7 @@ class LLMRequestSubStage(Stage):
)
if req.conversation:
all_contexts = json.loads(req.conversation.history)
req.contexts = self._process_tool_message_pairs(
all_contexts, remove_tags=True
)
req.contexts = json.loads(req.conversation.history)
else:
req = ProviderRequest(prompt="", image_urls=[])
@@ -127,26 +113,7 @@ class LLMRequestSubStage(Stage):
return
# 执行请求 LLM 前事件钩子。
# 装饰 system_prompt 等功能
# 获取当前平台ID
platform_id = event.get_platform_id()
handlers = star_handlers_registry.get_handlers_by_event_type(
EventType.OnLLMRequestEvent, platform_id=platform_id
)
for handler in handlers:
try:
logger.debug(
f"hook(on_llm_request) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}"
)
await handler.handler(event, req)
except BaseException:
logger.error(traceback.format_exc())
if event.is_stopped():
logger.info(
f"{star_map[handler.handler_module_path].name} - {handler.handler_name} 终止了事件传播。"
)
return
await self.ctx.call_event_hook(event, EventType.OnLLMRequestEvent, req)
if isinstance(req.contexts, str):
req.contexts = json.loads(req.contexts)
@@ -176,77 +143,62 @@ class LLMRequestSubStage(Stage):
if not req.session_id:
req.session_id = event.unified_msg_origin
async def requesting(req: ProviderRequest):
try:
need_loop = True
while need_loop:
need_loop = False
logger.debug(f"提供商请求 Payload: {req}")
# fix messages
req.contexts = self.fix_messages(req.contexts)
final_llm_response = None
# Call Agent
tool_loop_agent = ToolLoopAgent(
provider=provider,
event=event,
pipeline_ctx=self.ctx,
)
await tool_loop_agent.reset(req=req, streaming=self.streaming_response)
if self.streaming_response:
stream = provider.text_chat_stream(**req.__dict__)
async for llm_response in stream:
if llm_response.is_chunk:
if llm_response.result_chain:
yield llm_response.result_chain # MessageChain
else:
yield MessageChain().message(
llm_response.completion_text
)
else:
final_llm_response = llm_response
else:
final_llm_response = await provider.text_chat(
**req.__dict__
) # 请求 LLM
async def requesting():
step_idx = 0
while step_idx < self.max_step:
step_idx += 1
try:
async for resp in tool_loop_agent.step():
if resp.type == "tool_call_result":
continue # 跳过工具调用结果
if resp.type == "tool_call":
if self.streaming_response:
# 用来标记流式响应需要分节
yield MessageChain(chain=[], type="break")
if self.show_tool_use or event.get_platform_name() == "webchat":
resp.data["chain"].type = "tool_call"
await event.send(resp.data["chain"])
continue
if not final_llm_response:
raise Exception("LLM response is None.")
if not self.streaming_response:
content_typ = (
ResultContentType.LLM_RESULT
if resp.type == "llm_result"
else ResultContentType.GENERAL_RESULT
)
event.set_result(
MessageEventResult(
chain=resp.data["chain"].chain,
result_content_type=content_typ,
)
)
yield
event.clear_result()
else:
if resp.type == "streaming_delta":
yield resp.data["chain"] # MessageChain
if tool_loop_agent.done():
break
# 执行 LLM 响应后的事件钩子。
handlers = star_handlers_registry.get_handlers_by_event_type(
EventType.OnLLMResponseEvent
except Exception as e:
logger.error(traceback.format_exc())
event.set_result(
MessageEventResult().message(
f"AstrBot 请求失败。\n错误类型: {type(e).__name__}\n错误信息: {str(e)}"
)
)
for handler in handlers:
try:
logger.debug(
f"hook(on_llm_response) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}"
)
await handler.handler(event, final_llm_response)
except BaseException:
logger.error(traceback.format_exc())
if event.is_stopped():
logger.info(
f"{star_map[handler.handler_module_path].name} - {handler.handler_name} 终止了事件传播。"
)
return
if self.streaming_response:
# 流式输出的处理
async for result in self._handle_llm_stream_response(
event, req, final_llm_response
):
if isinstance(result, ProviderRequest):
# 有函数工具调用并且返回了结果,我们需要再次请求 LLM
req = result
need_loop = True
else:
yield
else:
# 非流式输出的处理
async for result in self._handle_llm_response(
event, req, final_llm_response
):
if isinstance(result, ProviderRequest):
# 有函数工具调用并且返回了结果,我们需要再次请求 LLM
req = result
need_loop = True
else:
yield
return
asyncio.create_task(
Metric.upload(
llm_tick=1,
@@ -255,44 +207,38 @@ class LLMRequestSubStage(Stage):
)
)
# 保存到历史记录
await self._save_to_history(event, req, final_llm_response)
except BaseException as e:
logger.error(traceback.format_exc())
event.set_result(
MessageEventResult().message(
f"AstrBot 请求失败。\n错误类型: {type(e).__name__}\n错误信息: {str(e)}"
)
)
if not self.streaming_response:
event.set_extra("tool_call_result", None)
async for _ in requesting(req):
yield
else:
if self.streaming_response:
# 流式响应
event.set_result(
MessageEventResult()
.set_result_content_type(ResultContentType.STREAMING_RESULT)
.set_async_stream(requesting(req))
.set_async_stream(requesting())
)
# 这里使用yield来暂停当前阶段等待流式输出完成后继续处理
yield
if event.get_extra("tool_call_result"):
event.set_result(event.get_extra("tool_call_result"))
event.set_extra("tool_call_result", None)
if tool_loop_agent.done():
if final_llm_resp := tool_loop_agent.get_final_llm_resp():
if final_llm_resp.completion_text:
chain = (
MessageChain().message(final_llm_resp.completion_text).chain
)
else:
chain = final_llm_resp.result_chain.chain
event.set_result(
MessageEventResult(
chain=chain,
result_content_type=ResultContentType.STREAMING_FINISH,
)
)
else:
async for _ in requesting():
yield
# 暂时直接发出去
if img_b64 := event.get_extra("tool_call_img_respond"):
await event.send(MessageChain(chain=[Image.fromBase64(img_b64)]))
event.set_extra("tool_call_img_respond", None)
# 异步处理 WebChat 特殊情况
if event.get_platform_name() == "webchat":
# 异步处理 WebChat 特殊情况
asyncio.create_task(self._handle_webchat(event, req))
await self._save_to_history(event, req, tool_loop_agent.get_final_llm_resp())
async def _handle_webchat(self, event: AstrMessageEvent, req: ProviderRequest):
"""处理 WebChat 平台的特殊情况,包括第一次 LLM 对话时总结对话内容生成 title"""
conversation = await self.conv_manager.get_conversation(
@@ -305,10 +251,6 @@ class LLMRequestSubStage(Stage):
return
provider = self.ctx.plugin_manager.context.get_using_provider()
cleaned_text = "User: " + latest_pair[0].get("content", "").strip()
# if len(latest_pair) > 1:
# cleaned_text += (
# "\nAssistant: " + latest_pair[1].get("content", "").strip()
# )
logger.debug(f"WebChat 对话标题生成请求,清理后的文本: {cleaned_text}")
llm_resp = await provider.text_chat(
system_prompt="You are expert in summarizing user's query.",
@@ -349,322 +291,50 @@ class LLMRequestSubStage(Stage):
}
)
async def _handle_llm_response(
self,
event: AstrMessageEvent,
req: ProviderRequest,
llm_response: LLMResponse,
) -> AsyncGenerator[Union[None, ProviderRequest], None]:
"""处理非流式 LLM 响应。
Returns:
AsyncGenerator[Union[None, ProviderRequest], None]: 如果返回 ProviderRequest表示需要再次调用 LLM
Yields:
Iterator[Union[None, ProviderRequest]]: 将 event 交付给下一个 stage 或者返回 ProviderRequest 表示需要再次调用 LLM
"""
if llm_response.role == "assistant":
# text completion
if llm_response.result_chain:
event.set_result(
MessageEventResult(
chain=llm_response.result_chain.chain
).set_result_content_type(ResultContentType.LLM_RESULT)
)
else:
event.set_result(
MessageEventResult()
.message(llm_response.completion_text)
.set_result_content_type(ResultContentType.LLM_RESULT)
)
elif llm_response.role == "err":
event.set_result(
MessageEventResult().message(
f"AstrBot 请求失败。\n错误信息: {llm_response.completion_text}"
)
)
elif llm_response.role == "tool":
# 处理函数工具调用
async for result in self._handle_function_tools(event, req, llm_response):
yield result
async def _handle_llm_stream_response(
self,
event: AstrMessageEvent,
req: ProviderRequest,
llm_response: LLMResponse,
) -> AsyncGenerator[Union[None, ProviderRequest], None]:
"""处理流式 LLM 响应。
专门用于处理流式输出完成后的响应,与非流式响应处理分离。
Returns:
AsyncGenerator[Union[None, ProviderRequest], None]: 如果返回 ProviderRequest表示需要再次调用 LLM
Yields:
Iterator[Union[None, ProviderRequest]]: 将 event 交付给下一个 stage 或者返回 ProviderRequest 表示需要再次调用 LLM
"""
if llm_response.role == "assistant":
# text completion
if llm_response.result_chain:
event.set_result(
MessageEventResult(
chain=llm_response.result_chain.chain
).set_result_content_type(ResultContentType.STREAMING_FINISH)
)
else:
event.set_result(
MessageEventResult()
.message(llm_response.completion_text)
.set_result_content_type(ResultContentType.STREAMING_FINISH)
)
elif llm_response.role == "err":
event.set_result(
MessageEventResult().message(
f"AstrBot 请求失败。\n错误信息: {llm_response.completion_text}"
)
)
elif llm_response.role == "tool":
# 处理函数工具调用
async for result in self._handle_function_tools(event, req, llm_response):
yield result
async def _handle_function_tools(
self,
event: AstrMessageEvent,
req: ProviderRequest,
llm_response: LLMResponse,
) -> AsyncGenerator[Union[None, ProviderRequest], None]:
"""处理函数工具调用。
Returns:
AsyncGenerator[Union[None, ProviderRequest], None]: 如果返回 ProviderRequest表示需要再次调用 LLM
"""
# function calling
tool_call_result: list[ToolCallMessageSegment] = []
logger.info(
f"触发 {len(llm_response.tools_call_name)} 个函数调用: {llm_response.tools_call_name}"
)
for func_tool_name, func_tool_args, func_tool_id in zip(
llm_response.tools_call_name,
llm_response.tools_call_args,
llm_response.tools_call_ids,
):
try:
func_tool = req.func_tool.get_func(func_tool_name)
if func_tool.origin == "mcp":
logger.info(
f"从 MCP 服务 {func_tool.mcp_server_name} 调用工具函数:{func_tool.name},参数:{func_tool_args}"
)
client = req.func_tool.mcp_client_dict[func_tool.mcp_server_name]
res = await client.session.call_tool(func_tool.name, func_tool_args)
if res:
# TODO 仅对ImageContent | EmbeddedResource进行了简单的Fallback
if isinstance(res.content[0], TextContent):
tool_call_result.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content=res.content[0].text,
)
)
elif isinstance(res.content[0], ImageContent):
tool_call_result.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content="返回了图片(已直接发送给用户)",
)
)
event.set_extra(
"tool_call_img_respond",
res.content[0].data,
)
elif isinstance(res.content[0], EmbeddedResource):
resource = res.content[0].resource
if isinstance(resource, TextResourceContents):
tool_call_result.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content=resource.text,
)
)
elif (
isinstance(resource, BlobResourceContents)
and resource.mimeType
and resource.mimeType.startswith("image/")
):
tool_call_result.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content="返回了图片(已直接发送给用户)",
)
)
event.set_extra(
"tool_call_img_respond",
res.content[0].data,
)
else:
tool_call_result.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content="返回的数据类型不受支持",
)
)
else:
# 获取处理器,过滤掉平台不兼容的处理器
platform_id = event.get_platform_id()
star_md = star_map.get(func_tool.handler_module_path)
if (
star_md
and platform_id in star_md.supported_platforms
and not star_md.supported_platforms[platform_id]
):
logger.debug(
f"处理器 {func_tool_name}({star_md.name}) 在当前平台不兼容或者被禁用,跳过执行"
)
# 直接跳过不添加任何消息到tool_call_result
continue
logger.info(
f"调用工具函数:{func_tool_name},参数:{func_tool_args}"
)
# 尝试调用工具函数
wrapper = self._call_handler(
self.ctx, event, func_tool.handler, **func_tool_args
)
async for resp in wrapper:
if resp is not None: # 有 return 返回
tool_call_result.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content=resp,
)
)
else:
res = event.get_result()
if res and res.chain:
event.set_extra("tool_call_result", res)
yield # 有生成器返回
event.clear_result() # 清除上一个 handler 的结果
except BaseException as e:
logger.warning(traceback.format_exc())
tool_call_result.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content=f"error: {str(e)}",
)
)
if tool_call_result:
# 函数调用结果
req.func_tool = None # 暂时不支持递归工具调用
assistant_msg_seg = AssistantMessageSegment(
role="assistant", tool_calls=llm_response.to_openai_tool_calls()
)
# 在多轮 Tool 调用的情况下,这里始终保持最新的 Tool 调用结果,减少上下文长度。
req.tool_calls_result = ToolCallsResult(
tool_calls_info=assistant_msg_seg,
tool_calls_result=tool_call_result,
)
yield req # 再次执行 LLM 请求
else:
if llm_response.completion_text:
event.set_result(
MessageEventResult().message(llm_response.completion_text)
)
async def _save_to_history(
self, event: AstrMessageEvent, req: ProviderRequest, llm_response: LLMResponse
self,
event: AstrMessageEvent,
req: ProviderRequest,
llm_response: LLMResponse | None,
):
if not req or not req.conversation or not llm_response:
if (
not req
or not req.conversation
or not llm_response
or llm_response.role != "assistant"
):
return
if llm_response.role == "assistant":
# 文本回复
contexts = req.contexts.copy()
contexts.append(await req.assemble_context())
# 历史上下文
messages = copy.deepcopy(req.contexts)
# 这一轮对话请求的用户输入
messages.append(await req.assemble_context())
# 这一轮对话的 LLM 响应
if req.tool_calls_result:
if not isinstance(req.tool_calls_result, list):
messages.extend(req.tool_calls_result.to_openai_messages())
elif isinstance(req.tool_calls_result, list):
for tcr in req.tool_calls_result:
messages.extend(tcr.to_openai_messages())
messages.append({"role": "assistant", "content": llm_response.completion_text})
messages = list(filter(lambda item: "_no_save" not in item, messages))
await self.conv_manager.update_conversation(
event.unified_msg_origin, req.conversation.cid, history=messages
)
logger.debug(f"messages persisted: {messages}")
# 记录并标记函数调用结果
if req.tool_calls_result:
tool_calls_messages = req.tool_calls_result.to_openai_messages()
# 添加标记
for message in tool_calls_messages:
message["_tool_call_history"] = True
processed_tool_messages = self._process_tool_message_pairs(
tool_calls_messages, remove_tags=False
)
contexts.extend(processed_tool_messages)
contexts.append(
{"role": "assistant", "content": llm_response.completion_text}
)
contexts_to_save = list(
filter(lambda item: "_no_save" not in item, contexts)
)
await self.conv_manager.update_conversation(
event.unified_msg_origin, req.conversation.cid, history=contexts_to_save
)
def _process_tool_message_pairs(self, messages, remove_tags=True):
"""处理工具调用消息确保assistant和tool消息成对出现
Args:
messages (list): 消息列表
remove_tags (bool): 是否移除_tool_call_history标记
Returns:
list: 处理后的消息列表保证了assistant和对应tool消息的成对出现
"""
result = []
i = 0
while i < len(messages):
current_msg = messages[i]
# 普通消息直接添加
if "_tool_call_history" not in current_msg:
result.append(current_msg.copy() if remove_tags else current_msg)
i += 1
continue
# 工具调用消息成对处理
if current_msg.get("role") == "assistant" and "tool_calls" in current_msg:
assistant_msg = current_msg.copy()
if remove_tags and "_tool_call_history" in assistant_msg:
del assistant_msg["_tool_call_history"]
related_tools = []
j = i + 1
while (
j < len(messages)
and messages[j].get("role") == "tool"
and "_tool_call_history" in messages[j]
):
tool_msg = messages[j].copy()
if remove_tags:
del tool_msg["_tool_call_history"]
related_tools.append(tool_msg)
j += 1
# 成对的时候添加到结果
if related_tools:
result.append(assistant_msg)
result.extend(related_tools)
i = j # 跳过已处理
def fix_messages(self, messages: list[dict]) -> list[dict]:
"""验证并且修复上下文"""
fixed_messages = []
for message in messages:
if message.get("role") == "tool":
# tool block 前面必须要有 user 和 assistant block
if len(fixed_messages) < 2:
# 这种情况可能是上下文被截断导致的
# 我们直接将之前的上下文都清空
fixed_messages = []
else:
fixed_messages.append(message)
else:
# 单独的tool消息
i += 1
return result
fixed_messages.append(message)
return fixed_messages

View File

@@ -50,7 +50,7 @@ class StarRequestSubStage(Stage):
logger.debug(
f"plugin -> {star_map.get(handler.handler_module_path).name} - {handler.handler_name}"
)
wrapper = self._call_handler(self.ctx, event, handler.handler, **params)
wrapper = self.ctx.call_handler(event, handler.handler, **params)
async for ret in wrapper:
yield ret
event.clear_result() # 清除上一个 handler 的结果

View File

@@ -128,9 +128,7 @@ class RespondStage(Stage):
"streaming_segmented", False
)
logger.info(f"应用流式输出({event.get_platform_name()})")
await event._pre_send()
await event.send_streaming(result.async_stream, use_fallback)
await event._post_send()
return
elif len(result.chain) > 0:
# 检查路径映射
@@ -141,8 +139,6 @@ class RespondStage(Stage):
component.file = path_Mapping(mappings, component.file)
event.get_result().chain[idx] = component
await event._pre_send()
# 检查消息链是否为空
try:
if await self._is_empty_message_chain(result.chain):
@@ -158,9 +154,14 @@ class RespondStage(Stage):
c for c in result.chain if not isinstance(c, Comp.Record)
]
if self.enable_seg and (
(self.only_llm_result and result.is_llm_result())
or not self.only_llm_result
if (
self.enable_seg
and (
(self.only_llm_result and result.is_llm_result())
or not self.only_llm_result
)
and event.get_platform_name()
not in ["qq_official", "weixin_official_account", "dingtalk"]
):
decorated_comps = []
if self.reply_with_mention:
@@ -208,7 +209,6 @@ class RespondStage(Stage):
logger.error(traceback.format_exc())
logger.error(f"发送消息失败: {e} chain: {result.chain}")
await event._post_send()
logger.info(
f"AstrBot -> {event.get_sender_name()}/{event.get_sender_id()}: {event._outline_chain(result.chain)}"
)

View File

@@ -141,7 +141,11 @@ class ResultDecorateStage(Stage):
break
# 分段回复
if self.enable_segmented_reply:
if self.enable_segmented_reply and event.get_platform_name() not in [
"qq_official",
"weixin_official_account",
"dingtalk",
]:
if (
self.only_llm_result and result.is_llm_result()
) or not self.only_llm_result:

View File

@@ -1,12 +1,8 @@
from __future__ import annotations
import abc
import inspect
import traceback
from astrbot.api import logger
from typing import List, AsyncGenerator, Union, Awaitable
from typing import List, AsyncGenerator, Union
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from .context import PipelineContext
from astrbot.core.message.message_event_result import MessageEventResult, CommandResult
registered_stages: List[Stage] = [] # 维护了所有已注册的 Stage 实现类
@@ -41,70 +37,3 @@ class Stage(abc.ABC):
Union[None, AsyncGenerator[None, None]]: 处理结果,可能是 None 或者异步生成器, 如果为 None 则表示不需要继续处理, 如果为异步生成器则表示需要继续处理(进入下一个阶段)
"""
raise NotImplementedError
async def _call_handler(
self,
ctx: PipelineContext,
event: AstrMessageEvent,
handler: Awaitable,
*args,
**kwargs,
) -> AsyncGenerator[None, None]:
"""执行事件处理函数并处理其返回结果
该方法负责调用处理函数并处理不同类型的返回值。它支持两种类型的处理函数:
1. 异步生成器: 实现洋葱模型每次yield都会将控制权交回上层
2. 协程: 执行一次并处理返回值
Args:
ctx (PipelineContext): 消息管道上下文对象
event (AstrMessageEvent): 待处理的事件对象
handler (Awaitable): 事件处理函数
*args: 传递给handler的位置参数
**kwargs: 传递给handler的关键字参数
Returns:
AsyncGenerator[None, None]: 异步生成器,用于在管道中传递控制流
"""
ready_to_call = None # 一个协程或者异步生成器(async def)
trace_ = None
try:
ready_to_call = handler(event, *args, **kwargs)
except TypeError as _:
# 向下兼容
trace_ = traceback.format_exc()
# 以前的handler会额外传入一个参数, 但是context对象实际上在插件实例中有一份
ready_to_call = handler(event, ctx.plugin_manager.context, *args, **kwargs)
if isinstance(ready_to_call, AsyncGenerator):
# 如果是一个异步生成器, 进入洋葱模型
_has_yielded = False # 是否返回过值
try:
async for ret in ready_to_call:
# 这里逐步执行异步生成器, 对于每个yield返回的ret, 执行下面的代码
# 返回值只能是 MessageEventResult 或者 None无返回值
_has_yielded = True
if isinstance(ret, (MessageEventResult, CommandResult)):
# 如果返回值是 MessageEventResult, 设置结果并继续
event.set_result(ret)
yield # 传递控制权给上一层的process函数
else:
# 如果返回值是 None, 则不设置结果并继续
# 继续执行后续阶段
yield ret # 传递控制权给上一层的process函数
if not _has_yielded:
# 如果这个异步生成器没有执行到yield分支
yield
except Exception as e:
logger.error(f"Previous Error: {trace_}")
raise e
elif inspect.iscoroutine(ready_to_call):
# 如果只是一个协程, 直接执行
ret = await ready_to_call
if isinstance(ret, (MessageEventResult, CommandResult)):
event.set_result(ret)
yield # 传递控制权给上一层的process函数
else:
yield ret # 传递控制权给上一层的process函数

View File

@@ -4,7 +4,7 @@ from astrbot import logger
from typing import Union, AsyncGenerator
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.message.message_event_result import MessageEventResult, MessageChain
from astrbot.core.message.components import At, AtAll
from astrbot.core.message.components import At, AtAll, Reply
from astrbot.core.star.star_handler import star_handlers_registry, EventType
from astrbot.core.star.star import star_map
from astrbot.core.star.filter.permission import PermissionTypeFilter
@@ -80,11 +80,19 @@ class WakingCheckStage(Stage):
event.message_str = event.message_str[len(wake_prefix) :].strip()
break
if not is_wake:
# 检查是否有 at 消息
# 检查是否有at消息 / at全体成员消息 / 引用了bot的消息
for message in messages:
if (isinstance(message, At) and (
str(message.qq) == str(event.get_self_id())
)) or (isinstance(message, AtAll) and not self.ignore_at_all):
if (
(
isinstance(message, At)
and (str(message.qq) == str(event.get_self_id()))
)
or (isinstance(message, AtAll) and not self.ignore_at_all)
or (
isinstance(message, Reply)
and str(message.sender_id) == str(event.get_self_id())
)
):
is_wake = True
event.is_wake = True
wake_prefix = ""
@@ -127,7 +135,6 @@ class WakingCheckStage(Stage):
f"插件 {star_map[handler.handler_module_path].name}: {e}"
)
)
await event._post_send()
event.stop_event()
passed = False
break
@@ -142,7 +149,6 @@ class WakingCheckStage(Stage):
f"您(ID: {event.get_sender_id()})的权限不足以使用此指令。通过 /sid 获取 ID 并请管理员添加。"
)
)
await event._post_send()
logger.info(
f"触发 {star_map[handler.handler_module_path].name} 时, 用户(ID={event.get_sender_id()}) 权限不足。"
)

View File

@@ -235,10 +235,10 @@ class AstrMessageEvent(abc.ABC):
self._has_send_oper = True
async def _pre_send(self):
"""调度器会在执行 send() 前调用该方法"""
"""调度器会在执行 send() 前调用该方法 deprecated in v3.5.18"""
async def _post_send(self):
"""调度器会在执行 send() 后调用该方法"""
"""调度器会在执行 send() 后调用该方法 deprecated in v3.5.18"""
def set_result(self, result: Union[MessageEventResult, str]):
"""设置消息事件的结果。

View File

@@ -77,7 +77,15 @@ class PlatformManager:
case "wecom":
from .sources.wecom.wecom_adapter import WecomPlatformAdapter # noqa: F401
case "weixin_official_account":
from .sources.weixin_official_account.weixin_offacc_adapter import WeixinOfficialAccountPlatformAdapter # noqa
from .sources.weixin_official_account.weixin_offacc_adapter import (
WeixinOfficialAccountPlatformAdapter, # noqa
)
case "discord":
from .sources.discord.discord_platform_adapter import (
DiscordPlatformAdapter, # noqa: F401
)
case "slack":
from .sources.slack.slack_adapter import SlackAdapter # noqa: F401
except (ImportError, ModuleNotFoundError) as e:
logger.error(
f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->控制台->安装Pip库 中安装依赖库。"

View File

@@ -168,9 +168,7 @@ class AiocqhttpAdapter(Platform):
if "sub_type" in event:
if event["sub_type"] == "poke" and "target_id" in event:
abm.message.append(
Poke(qq=str(event["target_id"]), type="poke")
) # noqa: F405
abm.message.append(Poke(qq=str(event["target_id"]), type="poke")) # noqa: F405
return abm
@@ -273,6 +271,8 @@ class AiocqhttpAdapter(Platform):
action="get_msg",
message_id=int(m["data"]["id"]),
)
# 添加必要的 post_type 字段,防止 Event.from_payload 报错
reply_event_data["post_type"] = "message"
abm_reply = await self._convert_handle_message_event(
Event.from_payload(reply_event_data), get_reply=False
)
@@ -307,7 +307,7 @@ class AiocqhttpAdapter(Platform):
user_id=int(m["data"]["qq"]),
)
if at_info:
nickname = at_info.get("nick", "")
nickname = at_info.get("nick", "") or at_info.get("nickname", "")
is_at_self = str(m["data"]["qq"]) in {abm.self_id, "all"}
abm.message.append(
@@ -322,7 +322,7 @@ class AiocqhttpAdapter(Platform):
first_at_self_processed = True
else:
# 非第一个@机器人或@其他用户添加到message_str
message_str += f" @{nickname} "
message_str += f" @{nickname}({m['data']['qq']}) "
else:
abm.message.append(At(qq=str(m["data"]["qq"]), name=""))
except ActionFailed as e:

View File

@@ -32,31 +32,31 @@ class DingtalkMessageEvent(AstrMessageEvent):
)
elif isinstance(segment, Comp.Image):
markdown_str = ""
if segment.file and segment.file.startswith("file:///"):
logger.warning(
"dingtalk only support url image, not: " + segment.file
)
continue
elif segment.file and segment.file.startswith("http"):
markdown_str += f"![image]({segment.file})\n\n"
elif segment.file and segment.file.startswith("base64://"):
logger.warning("dingtalk only support url image, not base64")
continue
else:
logger.warning(
"dingtalk only support url image, not: " + segment.file
)
continue
ret = await asyncio.get_event_loop().run_in_executor(
None,
client.reply_markdown,
"😄",
markdown_str,
self.message_obj.raw_message,
)
logger.debug(f"send image: {ret}")
try:
if not segment.file:
logger.warning("钉钉图片 segment 缺少 file 字段,跳过")
continue
if segment.file.startswith(("http://", "https://")):
image_url = segment.file
else:
image_url = await segment.register_to_file_service()
markdown_str = f"![image]({image_url})\n\n"
ret = await asyncio.get_event_loop().run_in_executor(
None,
client.reply_markdown,
"😄",
markdown_str,
self.message_obj.raw_message,
)
logger.debug(f"send image: {ret}")
except Exception as e:
logger.error(f"钉钉图片处理失败: {e}")
logger.warning(f"跳过图片发送: {image_path}")
continue
async def send(self, message: MessageChain):
await self.send_with_client(self.client, message)
await super().send(message)

View File

@@ -0,0 +1,126 @@
import discord
from astrbot import logger
import sys
if sys.version_info >= (3, 12):
from typing import override
else:
from typing_extensions import override
# Discord Bot客户端
class DiscordBotClient(discord.Bot):
"""Discord客户端封装"""
def __init__(self, token: str, proxy: str = None):
self.token = token
self.proxy = proxy
# 设置Intent权限遵循权限最小化原则
intents = discord.Intents.default()
intents.message_content = True # 订阅消息内容事件 (Privileged)
intents.members = True # 订阅成员事件 (Privileged)
# 初始化Bot
super().__init__(intents=intents, proxy=proxy)
# 回调函数
self.on_message_received = None
self.on_ready_once_callback = None
self._ready_once_fired = False
@override
async def on_ready(self):
"""当机器人成功连接并准备就绪时触发"""
logger.info(f"[Discord] 已作为 {self.user} (ID: {self.user.id}) 登录")
logger.info("[Discord] 客户端已准备就绪。")
if self.on_ready_once_callback and not self._ready_once_fired:
self._ready_once_fired = True
try:
await self.on_ready_once_callback()
except Exception as e:
logger.error(
f"[Discord] on_ready_once_callback 执行失败: {e}", exc_info=True)
def _create_message_data(self, message: discord.Message) -> dict:
"""从 discord.Message 创建数据字典"""
is_mentioned = self.user in message.mentions
return {
"message": message,
"bot_id": str(self.user.id),
"content": message.content,
"username": message.author.display_name,
"userid": str(message.author.id),
"message_id": str(message.id),
"channel_id": str(message.channel.id),
"guild_id": str(message.guild.id) if message.guild else None,
"type": "message",
"is_mentioned": is_mentioned,
"clean_content": message.clean_content,
}
def _create_interaction_data(self, interaction: discord.Interaction) -> dict:
"""从 discord.Interaction 创建数据字典"""
return {
"interaction": interaction,
"bot_id": str(self.user.id),
"content": self._extract_interaction_content(interaction),
"username": interaction.user.display_name,
"userid": str(interaction.user.id),
"message_id": str(interaction.id),
"channel_id": str(interaction.channel_id)
if interaction.channel_id
else None,
"guild_id": str(interaction.guild_id) if interaction.guild_id else None,
"type": "interaction",
}
@override
async def on_message(self, message: discord.Message):
"""当接收到消息时触发"""
if message.author.bot:
return
logger.debug(
f"[Discord] 收到原始消息 from {message.author.name}: {message.content}"
)
if self.on_message_received:
message_data = self._create_message_data(message)
await self.on_message_received(message_data)
def _extract_interaction_content(self, interaction: discord.Interaction) -> str:
"""从交互中提取内容"""
interaction_type = interaction.type
interaction_data = getattr(interaction, "data", {})
if not interaction_data:
return ""
if interaction_type == discord.InteractionType.application_command:
command_name = interaction_data.get("name", "")
if options := interaction_data.get("options", []):
params = " ".join(
[f"{opt['name']}:{opt.get('value', '')}" for opt in options]
)
return f"/{command_name} {params}"
return f"/{command_name}"
elif interaction_type == discord.InteractionType.component:
custom_id = interaction_data.get("custom_id", "")
component_type = interaction_data.get("component_type", "")
return f"component:{custom_id}:{component_type}"
return str(interaction_data)
async def start_polling(self):
"""开始轮询消息,这是个阻塞方法"""
await self.start(self.token)
@override
async def close(self):
"""关闭客户端"""
if not self.is_closed():
await super().close()

View File

@@ -0,0 +1,133 @@
import discord
from typing import List
from astrbot.api.message_components import BaseMessageComponent
# Discord专用组件
class DiscordEmbed(BaseMessageComponent):
"""Discord Embed消息组件"""
type: str = "discord_embed"
def __init__(
self,
title: str = None,
description: str = None,
color: int = None,
url: str = None,
thumbnail: str = None,
image: str = None,
footer: str = None,
fields: List[dict] = None,
):
self.title = title
self.description = description
self.color = color
self.url = url
self.thumbnail = thumbnail
self.image = image
self.footer = footer
self.fields = fields or []
def to_discord_embed(self) -> discord.Embed:
"""转换为Discord Embed对象"""
embed = discord.Embed()
if self.title:
embed.title = self.title
if self.description:
embed.description = self.description
if self.color:
embed.color = self.color
if self.url:
embed.url = self.url
if self.thumbnail:
embed.set_thumbnail(url=self.thumbnail)
if self.image:
embed.set_image(url=self.image)
if self.footer:
embed.set_footer(text=self.footer)
for field in self.fields:
embed.add_field(
name=field.get("name", ""),
value=field.get("value", ""),
inline=field.get("inline", False),
)
return embed
class DiscordButton(BaseMessageComponent):
"""Discord按钮组件"""
type: str = "discord_button"
def __init__(
self,
label: str,
custom_id: str = None,
style: str = "primary",
emoji: str = None,
url: str = None,
disabled: bool = False,
):
self.label = label
self.custom_id = custom_id
self.style = style
self.emoji = emoji
self.url = url
self.disabled = disabled
class DiscordReference(BaseMessageComponent):
"""Discord引用组件"""
type: str = "discord_reference"
def __init__(self, message_id: str, channel_id: str):
self.message_id = message_id
self.channel_id = channel_id
class DiscordView(BaseMessageComponent):
"""Discord视图组件包含按钮和选择菜单"""
type: str = "discord_view"
def __init__(
self, components: List[BaseMessageComponent] = None, timeout: float = None
):
self.components = components or []
self.timeout = timeout
def to_discord_view(self) -> discord.ui.View:
"""转换为Discord View对象"""
view = discord.ui.View(timeout=self.timeout)
for component in self.components:
if isinstance(component, DiscordButton):
button_style = getattr(
discord.ButtonStyle, component.style, discord.ButtonStyle.primary
)
if component.url:
# URL按钮
button = discord.ui.Button(
label=component.label,
style=discord.ButtonStyle.link,
url=component.url,
emoji=component.emoji,
disabled=component.disabled,
)
else:
# 普通按钮
button = discord.ui.Button(
label=component.label,
style=button_style,
custom_id=component.custom_id,
emoji=component.emoji,
disabled=component.disabled,
)
view.add_item(button)
return view

View File

@@ -0,0 +1,455 @@
import asyncio
import discord
import sys
import re
from discord.abc import Messageable
from discord.channel import DMChannel
from astrbot.api.platform import (
Platform,
AstrBotMessage,
MessageMember,
PlatformMetadata,
MessageType,
)
from astrbot.api.event import MessageChain
from astrbot.api.message_components import Plain, Image, File
from astrbot.core.platform.astr_message_event import MessageSesion
from astrbot.api.platform import register_platform_adapter
from astrbot import logger
from .client import DiscordBotClient
from .discord_platform_event import DiscordPlatformEvent
from typing import Any, Tuple
from astrbot.core.star.filter.command import CommandFilter
from astrbot.core.star.filter.command_group import CommandGroupFilter
from astrbot.core.star.star import star_map
from astrbot.core.star.star_handler import StarHandlerMetadata, star_handlers_registry
if sys.version_info >= (3, 12):
from typing import override
else:
from typing_extensions import override
# 注册平台适配器
@register_platform_adapter("discord", "Discord 适配器 (基于 Pycord)")
class DiscordPlatformAdapter(Platform):
def __init__(
self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue
) -> None:
super().__init__(event_queue)
self.config = platform_config
self.settings = platform_settings
self.client_self_id = None
self.registered_handlers = []
# 指令注册相关
self.enable_command_register = self.config.get("discord_command_register", True)
self.guild_id = self.config.get("discord_guild_id_for_debug", None)
self.activity_name = self.config.get("discord_activity_name", None)
self.shutdown_event = asyncio.Event()
self._polling_task = None
@override
async def send_by_session(
self, session: MessageSesion, message_chain: MessageChain
):
"""通过会话发送消息"""
# 创建一个 message_obj 以便在 event 中使用
message_obj = AstrBotMessage()
if "_" in session.session_id:
session.session_id = session.session_id.split("_")[1]
channel_id_str = session.session_id
channel = None
try:
channel_id = int(channel_id_str)
channel = self.client.get_channel(channel_id)
except (ValueError, TypeError):
logger.warning(f"[Discord] Invalid channel ID format: {channel_id_str}")
if channel:
message_obj.type = self._get_message_type(channel)
message_obj.group_id = self._get_channel_id(channel)
else:
logger.warning(
f"[Discord] Can't get channel info for {channel_id_str}, will guess message type."
)
message_obj.type = MessageType.GROUP_MESSAGE
message_obj.group_id = session.session_id
message_obj.message_str = message_chain.get_plain_text()
message_obj.sender = MessageMember(
user_id=str(self.client_self_id), nickname=self.client.user.display_name
)
message_obj.self_id = self.client_self_id
message_obj.session_id = session.session_id
message_obj.message = message_chain
# 创建临时事件对象来发送消息
temp_event = DiscordPlatformEvent(
message_str=message_chain.get_plain_text(),
message_obj=message_obj,
platform_meta=self.meta(),
session_id=session.session_id,
client=self.client,
)
await temp_event.send(message_chain)
await super().send_by_session(session, message_chain)
@override
def meta(self) -> PlatformMetadata:
"""返回平台元数据"""
return PlatformMetadata(
"discord",
"Discord 适配器",
id=self.config.get("id"),
default_config_tmpl=self.config,
)
@override
async def run(self):
"""主要运行逻辑"""
# 初始化回调函数
async def on_received(message_data):
logger.debug(f"[Discord] 收到消息: {message_data}")
if self.client_self_id is None:
self.client_self_id = message_data.get("bot_id")
abm = await self.convert_message(data=message_data)
await self.handle_msg(abm)
# 初始化 Discord 客户端
token = str(self.config.get("discord_token"))
if not token:
logger.error("[Discord] Bot Token 未配置。请在配置文件中正确设置 token。")
return
proxy = self.config.get("discord_proxy") or None
self.client = DiscordBotClient(token, proxy)
self.client.on_message_received = on_received
async def callback():
if self.enable_command_register:
await self._collect_and_register_commands()
if self.activity_name:
await self.client.change_presence(
status=discord.Status.online,
activity=discord.CustomActivity(name=self.activity_name),
)
self.client.on_ready_once_callback = callback
try:
self._polling_task = asyncio.create_task(self.client.start_polling())
await self.shutdown_event.wait()
except discord.errors.LoginFailure:
logger.error("[Discord] 登录失败。请检查你的 Bot Token 是否正确。")
except discord.errors.ConnectionClosed:
logger.warning("[Discord] 与 Discord 的连接已关闭。")
except Exception as e:
logger.error(f"[Discord] 适配器运行时发生意外错误: {e}", exc_info=True)
def _get_message_type(
self, channel: Messageable, guild_id: int | None = None
) -> MessageType:
"""根据 channel 对象和 guild_id 判断消息类型"""
if guild_id is not None:
return MessageType.GROUP_MESSAGE
if isinstance(channel, DMChannel) or getattr(channel, "guild", None) is None:
return MessageType.FRIEND_MESSAGE
return MessageType.GROUP_MESSAGE
def _get_channel_id(self, channel: Messageable) -> str:
"""根据 channel 对象获取ID"""
return str(getattr(channel, "id", None))
def _convert_message_to_abm(self, data: dict) -> AstrBotMessage:
"""将普通消息转换为 AstrBotMessage"""
message: discord.Message = data["message"]
content = message.content
# 如果机器人被@,移除@部分
# 剥离 User Mention (<@id>, <@!id>)
if self.client and self.client.user:
mention_str = f"<@{self.client.user.id}>"
mention_str_nickname = f"<@!{self.client.user.id}>"
if content.startswith(mention_str):
content = content[len(mention_str) :].lstrip()
elif content.startswith(mention_str_nickname):
content = content[len(mention_str_nickname) :].lstrip()
# 剥离 Role Mentionbot 拥有的任一角色被提及,<@&role_id>
if (
hasattr(message, "role_mentions")
and hasattr(message, "guild")
and message.guild
):
bot_member = (
message.guild.get_member(self.client.user.id)
if self.client and self.client.user
else None
)
if bot_member and hasattr(bot_member, "roles"):
for role in bot_member.roles:
role_mention_str = f"<@&{role.id}>"
if content.startswith(role_mention_str):
content = content[len(role_mention_str) :].lstrip()
break # 只剥离第一个匹配的角色 mention
abm = AstrBotMessage()
abm.type = self._get_message_type(message.channel)
abm.group_id = self._get_channel_id(message.channel)
abm.message_str = content
abm.sender = MessageMember(
user_id=str(message.author.id), nickname=message.author.display_name
)
message_chain = []
if abm.message_str:
message_chain.append(Plain(text=abm.message_str))
if message.attachments:
for attachment in message.attachments:
if attachment.content_type and attachment.content_type.startswith(
"image/"
):
message_chain.append(
Image(file=attachment.url, filename=attachment.filename)
)
else:
message_chain.append(
File(name=attachment.filename, url=attachment.url)
)
abm.message = message_chain
abm.raw_message = message
abm.self_id = self.client_self_id
abm.session_id = str(message.channel.id)
abm.message_id = str(message.id)
return abm
async def convert_message(self, data: dict) -> AstrBotMessage:
"""将平台消息转换成 AstrBotMessage"""
# 由于 on_interaction 已被禁用,我们只处理普通消息
return self._convert_message_to_abm(data)
async def handle_msg(self, message: AstrBotMessage, followup_webhook=None):
"""处理消息"""
message_event = DiscordPlatformEvent(
message_str=message.message_str,
message_obj=message,
platform_meta=self.meta(),
session_id=message.session_id,
client=self.client,
interaction_followup_webhook=followup_webhook,
)
# 检查是否为斜杠指令
is_slash_command = message_event.interaction_followup_webhook is not None
# 检查是否被@User Mention 或 Bot 拥有的 Role Mention
is_mention = False
# User Mention
if (
self.client
and self.client.user
and hasattr(message.raw_message, "mentions")
):
if self.client.user in message.raw_message.mentions:
is_mention = True
# Role MentionBot 拥有的角色被提及)
if not is_mention and hasattr(message.raw_message, "role_mentions"):
bot_member = None
if hasattr(message.raw_message, "guild") and message.raw_message.guild:
try:
bot_member = message.raw_message.guild.get_member(
self.client.user.id
)
except Exception:
bot_member = None
if bot_member and hasattr(bot_member, "roles"):
bot_roles = set(bot_member.roles)
mentioned_roles = set(message.raw_message.role_mentions)
if (
bot_roles
and mentioned_roles
and bot_roles.intersection(mentioned_roles)
):
is_mention = True
# 如果是斜杠指令或被@的消息,设置为唤醒状态
if is_slash_command or is_mention:
message_event.is_wake = True
message_event.is_at_or_wake_command = True
self.commit_event(message_event)
@override
async def terminate(self):
"""终止适配器"""
logger.info("[Discord] 正在终止适配器... (step 1: cancel polling task)")
self.shutdown_event.set()
# 优先 cancel polling_task
if self._polling_task:
self._polling_task.cancel()
try:
await asyncio.wait_for(self._polling_task, timeout=10)
except asyncio.CancelledError:
logger.info("[Discord] polling_task 已取消。")
except Exception as e:
logger.warning(f"[Discord] polling_task 取消异常: {e}")
logger.info("[Discord] 正在清理已注册的斜杠指令... (step 2)")
# 清理指令
if self.enable_command_register and self.client:
try:
await asyncio.wait_for(
self.client.sync_commands(
commands=[],
guild_ids=[self.guild_id] if self.guild_id else None,
),
timeout=10,
)
logger.info("[Discord] 指令清理完成。")
except Exception as e:
logger.error(f"[Discord] 清理指令时发生错误: {e}", exc_info=True)
logger.info("[Discord] 正在关闭 Discord 客户端... (step 3)")
if self.client and hasattr(self.client, "close"):
try:
await asyncio.wait_for(self.client.close(), timeout=10)
except Exception as e:
logger.warning(f"[Discord] 客户端关闭异常: {e}")
logger.info("[Discord] 适配器已终止。")
def register_handler(self, handler_info):
"""注册处理器信息"""
self.registered_handlers.append(handler_info)
async def _collect_and_register_commands(self):
"""收集所有指令并注册到Discord"""
logger.info("[Discord] 开始收集并注册斜杠指令...")
registered_commands = []
for handler_md in star_handlers_registry:
if not star_map[handler_md.handler_module_path].activated:
continue
for event_filter in handler_md.event_filters:
cmd_info = self._extract_command_info(event_filter, handler_md)
if not cmd_info:
continue
cmd_name, description, cmd_filter_instance = cmd_info
# 创建动态回调
callback = self._create_dynamic_callback(cmd_name)
# 创建一个通用的参数选项来接收所有文本输入
options = [
discord.Option(
name="params",
description="指令的所有参数",
type=discord.SlashCommandOptionType.string,
required=False,
)
]
# 创建SlashCommand
slash_command = discord.SlashCommand(
name=cmd_name,
description=description,
func=callback,
options=options,
guild_ids=[self.guild_id] if self.guild_id else None,
)
self.client.add_application_command(slash_command)
registered_commands.append(cmd_name)
if registered_commands:
logger.info(
f"[Discord] 准备同步 {len(registered_commands)} 个指令: {', '.join(registered_commands)}"
)
else:
logger.info("[Discord] 没有发现可注册的指令。")
# 使用 Pycord 的方法同步指令
# 注意:这可能需要一些时间,并且有频率限制
await self.client.sync_commands()
logger.info("[Discord] 指令同步完成。")
def _create_dynamic_callback(self, cmd_name: str):
"""为每个指令动态创建一个异步回调函数"""
async def dynamic_callback(ctx: discord.ApplicationContext, params: str = None):
# 将平台特定的前缀'/'剥离以适配通用的CommandFilter
logger.debug(f"[Discord] 回调函数触发: {cmd_name}")
logger.debug(f"[Discord] 回调函数参数: {ctx}")
logger.debug(f"[Discord] 回调函数参数: {params}")
message_str_for_filter = cmd_name
if params:
message_str_for_filter += f" {params}"
logger.debug(
f"[Discord] 斜杠指令 '{cmd_name}' 被触发。 "
f"原始参数: '{params}'. "
f"构建的指令字符串: '{message_str_for_filter}'"
)
# 尝试立即响应,防止超时
followup_webhook = None
try:
await ctx.defer()
followup_webhook = ctx.followup
except Exception as e:
logger.warning(f"[Discord] 指令 '{cmd_name}' defer 失败: {e}")
# 2. 构建 AstrBotMessage
abm = AstrBotMessage()
abm.type = self._get_message_type(ctx.channel, ctx.guild_id)
abm.group_id = self._get_channel_id(ctx.channel)
abm.message_str = message_str_for_filter
abm.sender = MessageMember(
user_id=str(ctx.author.id), nickname=ctx.author.display_name
)
abm.message = [Plain(text=message_str_for_filter)]
abm.raw_message = ctx.interaction
abm.self_id = self.client_self_id
abm.session_id = str(ctx.channel_id)
abm.message_id = str(ctx.interaction.id)
# 3. 将消息和 webhook 分别交给 handle_msg 处理
await self.handle_msg(abm, followup_webhook)
return dynamic_callback
@staticmethod
def _extract_command_info(
event_filter: Any, handler_metadata: StarHandlerMetadata
) -> Tuple[str, str, CommandFilter] | None:
"""从事件过滤器中提取指令信息"""
cmd_name = None
# is_group = False
cmd_filter_instance = None
if isinstance(event_filter, CommandFilter):
# 暂不支持子指令注册为斜杠指令
if (
event_filter.parent_command_names
and event_filter.parent_command_names != [""]
):
return None
cmd_name = event_filter.command_name
cmd_filter_instance = event_filter
elif isinstance(event_filter, CommandGroupFilter):
# 暂不支持指令组直接注册为斜杠指令,因为它们没有 handle 方法
return None
if not cmd_name:
return None
# Discord 斜杠指令名称规范
if not re.match(r"^[a-z0-9_-]{1,32}$", cmd_name):
logger.debug(f"[Discord] 跳过不符合规范的指令: {cmd_name}")
return None
description = handler_metadata.desc or f"指令: {cmd_name}"
if len(description) > 100:
description = f"{description[:97]}..."
return cmd_name, description, cmd_filter_instance

View File

@@ -0,0 +1,291 @@
import asyncio
import discord
import base64
from io import BytesIO
from pathlib import Path
from typing import Optional
import sys
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.platform import AstrBotMessage, PlatformMetadata, At
from astrbot.api.message_components import (
Plain,
Image,
File,
BaseMessageComponent,
Reply,
)
from astrbot import logger
from .client import DiscordBotClient
from .components import DiscordEmbed, DiscordView
if sys.version_info >= (3, 12):
from typing import override
else:
from typing_extensions import override
# 自定义Discord视图组件兼容旧版本
class DiscordViewComponent(BaseMessageComponent):
type: str = "discord_view"
def __init__(self, view: discord.ui.View):
self.view = view
class DiscordPlatformEvent(AstrMessageEvent):
def __init__(
self,
message_str: str,
message_obj: AstrBotMessage,
platform_meta: PlatformMetadata,
session_id: str,
client: DiscordBotClient,
interaction_followup_webhook: Optional[discord.Webhook] = None,
):
super().__init__(message_str, message_obj, platform_meta, session_id)
self.client = client
self.interaction_followup_webhook = interaction_followup_webhook
@override
async def send(self, message: MessageChain):
"""发送消息到Discord平台"""
# 解析消息链为 Discord 所需的对象
try:
content, files, view, embeds, reference_message_id = await self._parse_to_discord(message)
except Exception as e:
logger.error(f"[Discord] 解析消息链时失败: {e}", exc_info=True)
return
kwargs = {}
if content:
kwargs["content"] = content
if files:
kwargs["files"] = files
if view:
kwargs["view"] = view
if embeds:
kwargs["embeds"] = embeds
if reference_message_id and not self.interaction_followup_webhook:
kwargs["reference"] = self.client.get_message(int(reference_message_id))
if not kwargs:
logger.debug("[Discord] 尝试发送空消息,已忽略。")
return
# 根据上下文执行发送/回复操作
try:
# -- 斜杠指令/交互上下文 --
if self.interaction_followup_webhook:
await self.interaction_followup_webhook.send(**kwargs)
# -- 常规消息上下文 --
else:
channel = await self._get_channel()
if not channel:
return
else:
await channel.send(**kwargs)
except Exception as e:
logger.error(f"[Discord] 发送消息时发生未知错误: {e}", exc_info=True)
await super().send(message)
async def _get_channel(self) -> Optional[discord.abc.Messageable]:
"""获取当前事件对应的频道对象"""
try:
channel_id = int(self.session_id)
return self.client.get_channel(
channel_id
) or await self.client.fetch_channel(channel_id)
except (ValueError, discord.errors.NotFound, discord.errors.Forbidden):
logger.error(f"[Discord] 无法获取频道 {self.session_id}")
return None
async def _parse_to_discord(
self,
message: MessageChain,
) -> tuple[str, list[discord.File], Optional[discord.ui.View], list[discord.Embed]]:
"""将 MessageChain 解析为 Discord 发送所需的内容"""
content = ""
files = []
view = None
embeds = []
reference_message_id = None
for i in message.chain: # 遍历消息链
if isinstance(i, Plain): # 如果是文字类型的
content += i.text
elif isinstance(i, Reply):
reference_message_id = i.id
elif isinstance(i, At):
content += f"<@{i.qq}>"
elif isinstance(i, Image):
logger.debug(f"[Discord] 开始处理 Image 组件: {i}")
try:
filename = getattr(i, "filename", None)
file_content = getattr(i, "file", None)
if not file_content:
logger.warning(f"[Discord] Image 组件没有 file 属性: {i}")
continue
discord_file = None
# 1. URL
if file_content.startswith("http"):
logger.debug(f"[Discord] 处理 URL 图片: {file_content}")
embed = discord.Embed().set_image(url=file_content)
embeds.append(embed)
continue
# 2. File URI
elif file_content.startswith("file:///"):
logger.debug(f"[Discord] 处理 File URI: {file_content}")
path = Path(file_content[8:])
if await asyncio.to_thread(path.exists):
file_bytes = await asyncio.to_thread(path.read_bytes)
discord_file = discord.File(
BytesIO(file_bytes), filename=filename or path.name
)
else:
logger.warning(f"[Discord] 图片文件不存在: {path}")
# 3. Base64 URI
elif file_content.startswith("base64://"):
logger.debug("[Discord] 处理 Base64 URI")
b64_data = file_content.split("base64://", 1)[1]
missing_padding = len(b64_data) % 4
if missing_padding:
b64_data += "=" * (4 - missing_padding)
img_bytes = base64.b64decode(b64_data)
discord_file = discord.File(
BytesIO(img_bytes), filename=filename or "image.png"
)
# 4. 裸 Base64 或本地路径
else:
try:
logger.debug("[Discord] 尝试作为裸 Base64 处理")
b64_data = file_content
missing_padding = len(b64_data) % 4
if missing_padding:
b64_data += "=" * (4 - missing_padding)
img_bytes = base64.b64decode(b64_data)
discord_file = discord.File(
BytesIO(img_bytes), filename=filename or "image.png"
)
except (ValueError, TypeError, base64.binascii.Error):
logger.debug(
f"[Discord] 裸 Base64 解码失败,作为本地路径处理: {file_content}"
)
path = Path(file_content)
if await asyncio.to_thread(path.exists):
file_bytes = await asyncio.to_thread(path.read_bytes)
discord_file = discord.File(
BytesIO(file_bytes), filename=filename or path.name
)
else:
logger.warning(f"[Discord] 图片文件不存在: {path}")
if discord_file:
files.append(discord_file)
except Exception:
# 使用 getattr 来安全地访问 i.file以防 i 本身就是问题
file_info = getattr(i, "file", "未知")
logger.error(
f"[Discord] 处理图片时发生未知严重错误: {file_info}",
exc_info=True,
)
elif isinstance(i, File):
try:
file_path_str = await i.get_file()
if file_path_str:
path = Path(file_path_str)
if await asyncio.to_thread(path.exists):
file_bytes = await asyncio.to_thread(path.read_bytes)
files.append(
discord.File(BytesIO(file_bytes),
filename=i.name)
)
else:
logger.warning(
f"[Discord] 获取文件失败,路径不存在: {file_path_str}"
)
else:
logger.warning(f"[Discord] 获取文件失败: {i.name}")
except Exception as e:
logger.warning(f"[Discord] 处理文件失败: {i.name}, 错误: {e}")
elif isinstance(i, DiscordEmbed):
# Discord Embed消息
embeds.append(i.to_discord_embed())
elif isinstance(i, DiscordView):
# Discord视图组件按钮、选择菜单等
view = i.to_discord_view()
elif isinstance(i, DiscordViewComponent):
# 如果消息链中包含Discord视图组件兼容旧版本
if isinstance(i.view, discord.ui.View):
view = i.view
else:
logger.debug(f"[Discord] 忽略了不支持的消息组件: {i.type}")
if len(content) > 2000:
logger.warning("[Discord] 消息内容超过2000字符将被截断。")
content = content[:2000]
return content, files, view, embeds, reference_message_id
async def react(self, emoji: str):
"""对原消息添加反应"""
try:
if hasattr(self.message_obj, "raw_message") and hasattr(
self.message_obj.raw_message, "add_reaction"
):
await self.message_obj.raw_message.add_reaction(emoji)
except Exception as e:
logger.error(f"[Discord] 添加反应失败: {e}")
def is_slash_command(self) -> bool:
"""判断是否为斜杠命令"""
return (
hasattr(self.message_obj, "raw_message")
and hasattr(self.message_obj.raw_message, "type")
and self.message_obj.raw_message.type
== discord.InteractionType.application_command
)
def is_button_interaction(self) -> bool:
"""判断是否为按钮交互"""
return (
hasattr(self.message_obj, "raw_message")
and hasattr(self.message_obj.raw_message, "type")
and self.message_obj.raw_message.type == discord.InteractionType.component
)
def get_interaction_custom_id(self) -> str:
"""获取交互组件的custom_id"""
if self.is_button_interaction():
try:
return self.message_obj.raw_message.data.get("custom_id", "")
except Exception:
pass
return ""
def is_mentioned(self) -> bool:
"""判断机器人是否被@"""
if hasattr(self.message_obj, "raw_message") and hasattr(
self.message_obj.raw_message, "mentions"
):
return any(
mention.id == int(self.message_obj.self_id)
for mention in self.message_obj.raw_message.mentions
)
return False
def get_mention_clean_content(self) -> str:
"""获取去除@后的清洁内容"""
if hasattr(self.message_obj, "raw_message") and hasattr(
self.message_obj.raw_message, "clean_content"
):
return self.message_obj.raw_message.clean_content
return self.message_str

View File

@@ -28,10 +28,8 @@ class QQOfficialMessageEvent(AstrMessageEvent):
self.send_buffer = None
async def send(self, message: MessageChain):
if not self.send_buffer:
self.send_buffer = message
else:
self.send_buffer.chain.extend(message.chain)
self.send_buffer = message
await self._post_send()
async def send_streaming(self, generator, use_fallback: bool = False):
"""流式输出仅支持消息列表私聊"""

View File

@@ -0,0 +1,162 @@
import json
import hmac
import hashlib
import asyncio
import logging
from typing import Callable, Optional
from quart import Quart, request, Response
from slack_sdk.web.async_client import AsyncWebClient
from slack_sdk.socket_mode.aiohttp import SocketModeClient
from slack_sdk.socket_mode.request import SocketModeRequest
from slack_sdk.socket_mode.response import SocketModeResponse
from astrbot.api import logger
class SlackWebhookClient:
"""Slack Webhook 模式客户端,使用 Quart 作为 Web 服务器"""
def __init__(
self,
web_client: AsyncWebClient,
signing_secret: str,
host: str = "0.0.0.0",
port: int = 3000,
path: str = "/slack/events",
event_handler: Optional[Callable] = None,
):
self.web_client = web_client
self.signing_secret = signing_secret
self.host = host
self.port = port
self.path = path
self.event_handler = event_handler
self.app = Quart(__name__)
self._setup_routes()
# 禁用 Quart 的默认日志输出
logging.getLogger("quart.app").setLevel(logging.WARNING)
logging.getLogger("quart.serving").setLevel(logging.WARNING)
self.shutdown_event = asyncio.Event()
def _setup_routes(self):
"""设置路由"""
@self.app.route(self.path, methods=["POST"])
async def slack_events():
"""处理 Slack 事件"""
try:
# 获取请求体和头部
body = await request.get_data()
event_data = json.loads(body.decode("utf-8"))
# Verify Slack request signature
timestamp = request.headers.get("X-Slack-Request-Timestamp")
signature = request.headers.get("X-Slack-Signature")
if not timestamp or not signature:
return Response("Missing headers", status=400)
# Calculate the HMAC signature
sig_basestring = f"v0:{timestamp}:{body.decode('utf-8')}"
my_signature = (
"v0="
+ hmac.new(
self.signing_secret.encode("utf-8"),
sig_basestring.encode("utf-8"),
hashlib.sha256,
).hexdigest()
)
# Verify the signature
if not hmac.compare_digest(my_signature, signature):
logger.warning("Slack request signature verification failed")
return Response("Invalid signature", status=400)
logger.info(f"Received Slack event: {event_data}")
# 处理 URL 验证事件
if event_data.get("type") == "url_verification":
return {"challenge": event_data.get("challenge")}
# 处理事件
if self.event_handler and event_data.get("type") == "event_callback":
await self.event_handler(event_data)
return Response("", status=200)
except Exception as e:
logger.error(f"处理 Slack 事件时出错: {e}")
return Response("Internal Server Error", status=500)
@self.app.route("/health", methods=["GET"])
async def health_check():
"""健康检查端点"""
return {"status": "ok", "service": "slack-webhook"}
async def start(self):
"""启动 Webhook 服务器"""
logger.info(
f"Slack Webhook 服务器启动中,监听 {self.host}:{self.port}{self.path}..."
)
await self.app.run_task(
host=self.host,
port=self.port,
debug=False,
shutdown_trigger=self.shutdown_trigger,
)
async def shutdown_trigger(self):
await self.shutdown_event.wait()
async def stop(self):
"""停止 Webhook 服务器"""
self.shutdown_event.set()
logger.info("Slack Webhook 服务器已停止")
class SlackSocketClient:
"""Slack Socket 模式客户端"""
def __init__(
self,
web_client: AsyncWebClient,
app_token: str,
event_handler: Optional[Callable] = None,
):
self.web_client = web_client
self.app_token = app_token
self.event_handler = event_handler
self.socket_client = None
async def _handle_events(self, _: SocketModeClient, req: SocketModeRequest):
"""处理 Socket Mode 事件"""
try:
# 确认收到事件
response = SocketModeResponse(envelope_id=req.envelope_id)
await self.socket_client.send_socket_mode_response(response)
# 处理事件
if self.event_handler:
await self.event_handler(req)
except Exception as e:
logger.error(f"处理 Socket Mode 事件时出错: {e}")
async def start(self):
"""启动 Socket Mode 连接"""
self.socket_client = SocketModeClient(
app_token=self.app_token,
logger=logger,
web_client=self.web_client,
)
# 注册事件处理器
self.socket_client.socket_mode_request_listeners.append(self._handle_events)
logger.info("Slack Socket Mode 客户端启动中...")
await self.socket_client.connect()
async def stop(self):
"""停止 Socket Mode 连接"""
if self.socket_client:
await self.socket_client.disconnect()
await self.socket_client.close()
logger.info("Slack Socket Mode 客户端已停止")

View File

@@ -0,0 +1,396 @@
import time
import asyncio
import uuid
import aiohttp
import re
import base64
from typing import Awaitable, Any
from slack_sdk.web.async_client import AsyncWebClient
from slack_sdk.socket_mode.request import SocketModeRequest
from astrbot.api.platform import (
Platform,
AstrBotMessage,
MessageMember,
MessageType,
PlatformMetadata,
)
from astrbot.api.event import MessageChain
from .slack_event import SlackMessageEvent
from .client import SlackWebhookClient, SlackSocketClient
from astrbot.api.message_components import * # noqa: F403
from astrbot.api import logger
from astrbot.core.platform.astr_message_event import MessageSesion
from ...register import register_platform_adapter
@register_platform_adapter(
"slack", "适用于 Slack 的消息平台适配器,支持 Socket Mode 和 Webhook Mode。"
)
class SlackAdapter(Platform):
def __init__(
self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue
) -> None:
super().__init__(event_queue)
self.config = platform_config
self.settings = platform_settings
self.unique_session = platform_settings.get("unique_session", False)
self.bot_token = platform_config.get("bot_token")
self.app_token = platform_config.get("app_token")
self.signing_secret = platform_config.get("signing_secret")
self.connection_mode = platform_config.get("slack_connection_mode", "socket")
self.webhook_host = platform_config.get("slack_webhook_host", "0.0.0.0")
self.webhook_port = platform_config.get("slack_webhook_port", 3000)
self.webhook_path = platform_config.get(
"slack_webhook_path", "/astrbot-slack-webhook/callback"
)
if not self.bot_token:
raise ValueError("Slack bot_token 是必需的")
if self.connection_mode == "socket" and not self.app_token:
raise ValueError("Socket Mode 需要 app_token")
if self.connection_mode == "webhook" and not self.signing_secret:
raise ValueError("Webhook Mode 需要 signing_secret")
self.metadata = PlatformMetadata(
name="slack",
description="适用于 Slack 的消息平台适配器,支持 Socket Mode 和 Webhook Mode。",
id=self.config.get("id"),
)
# 初始化 Slack Web Client
self.web_client = AsyncWebClient(token=self.bot_token, logger=logger)
self.socket_client = None
self.webhook_client = None
self.bot_self_id = None
async def send_by_session(
self, session: MessageSesion, message_chain: MessageChain
):
blocks, text = SlackMessageEvent._parse_slack_blocks(
message_chain=message_chain, web_client=self.web_client
)
try:
if session.message_type == MessageType.GROUP_MESSAGE:
# 发送到频道
channel_id = (
session.session_id.split("_")[-1]
if "_" in session.session_id
else session.session_id
)
await self.web_client.chat_postMessage(
channel=channel_id,
text=text,
blocks=blocks if blocks else None,
)
else:
# 发送私信
await self.web_client.chat_postMessage(
channel=session.session_id,
text=text,
blocks=blocks if blocks else None,
)
except Exception as e:
logger.error(f"Slack 发送消息失败: {e}")
await super().send_by_session(session, message_chain)
async def convert_message(self, event: dict) -> AstrBotMessage:
logger.debug(f"[slack] RawMessage {event}")
abm = AstrBotMessage()
abm.self_id = self.bot_self_id
# 获取用户信息
user_id = event.get("user", "")
try:
user_info = await self.web_client.users_info(user=user_id)
user_data = user_info["user"]
user_name = user_data.get("real_name") or user_data.get("name", user_id)
except Exception:
user_name = user_id
abm.sender = MessageMember(user_id=user_id, nickname=user_name)
# 判断消息类型
channel_id = event.get("channel", "")
try:
channel_info = await self.web_client.conversations_info(channel=channel_id)
is_im = channel_info["channel"]["is_im"]
if is_im:
abm.type = MessageType.FRIEND_MESSAGE
else:
abm.type = MessageType.GROUP_MESSAGE
abm.group_id = channel_id
except Exception:
# 默认作为群组消息处理
abm.type = MessageType.GROUP_MESSAGE
abm.group_id = channel_id
# 设置会话ID
if self.unique_session and abm.type == MessageType.GROUP_MESSAGE:
abm.session_id = f"{user_id}_{channel_id}"
else:
abm.session_id = (
channel_id if abm.type == MessageType.GROUP_MESSAGE else user_id
)
abm.message_id = event.get("client_msg_id", uuid.uuid4().hex)
abm.timestamp = int(float(event.get("ts", time.time())))
# 处理消息内容
message_text = event.get("text", "")
abm.message_str = message_text
abm.message = []
# 优先使用 blocks 字段解析消息
if "blocks" in event and event["blocks"]:
abm.message = self._parse_blocks(event["blocks"])
# 更新 message_str
abm.message_str = ""
for component in abm.message:
if isinstance(component, Plain):
abm.message_str += component.text
elif message_text:
# 处理传统的文本消息
if "<@" in message_text:
mentions = re.findall(r"<@([^>]+)>", message_text)
for mention in mentions:
try:
mentioned_user = await self.web_client.users_info(user=mention)
user_data = mentioned_user["user"]
user_name = user_data.get("real_name") or user_data.get(
"name", mention
)
abm.message.append(At(qq=mention, name=user_name))
except Exception:
abm.message.append(At(qq=mention, name=""))
# 清理消息文本中的@标记
if clean_text := re.sub(r"<@[^>]+>", "", message_text).strip():
abm.message.append(Plain(text=clean_text))
else:
abm.message.append(Plain(text=message_text))
# 处理文件附件
if "files" in event:
for file_info in event["files"]:
file_name = file_info.get("name", "unknown")
file_url = file_info.get("url_private", "")
if file_info.get("mimetype", "").startswith("image/"):
file_url = await self.get_file_base64(file_url)
abm.message.append(Image.fromBase64(base64=file_url))
else:
# TODO: 下载鉴权
abm.message.append(
File(name=file_name, file=file_url, url=file_url)
)
abm.raw_message = event
return abm
def _parse_blocks(self, blocks: list) -> list:
"""解析 Slack blocks 格式的消息内容"""
message_components = []
for block in blocks:
block_type = block.get("type", "")
if block_type == "rich_text":
# 处理富文本块
elements = block.get("elements", [])
for element in elements:
if element.get("type") == "rich_text_section":
# 处理富文本段落
section_elements = element.get("elements", [])
text_content = ""
for section_element in section_elements:
element_type = section_element.get("type", "")
if element_type == "text":
# 普通文本
text_content += section_element.get("text", "")
elif element_type == "user":
# @用户提及
user_id = section_element.get("user_id", "")
if user_id:
# 将之前的文本内容先添加到组件中
if text_content.strip():
message_components.append(
Plain(text=text_content)
)
text_content = ""
# 添加@提及组件
message_components.append(At(qq=user_id, name=""))
elif element_type == "channel":
# #频道提及
channel_id = section_element.get("channel_id", "")
text_content += f"#{channel_id}"
elif element_type == "link":
# 链接
url = section_element.get("url", "")
link_text = section_element.get("text", url)
text_content += f"[{link_text}]({url})"
elif element_type == "emoji":
# 表情符号
emoji_name = section_element.get("name", "")
text_content += f":{emoji_name}:"
if text_content.strip():
message_components.append(Plain(text=text_content))
elif element.get("type") == "rich_text_list":
# 处理列表
list_items = element.get("elements", [])
list_text = ""
for item in list_items:
if item.get("type") == "rich_text_section":
item_elements = item.get("elements", [])
item_text = ""
for item_element in item_elements:
if item_element.get("type") == "text":
item_text += item_element.get("text", "")
list_text += f"{item_text}\n"
if list_text.strip():
message_components.append(Plain(text=list_text.strip()))
elif block_type == "section":
# 处理段落块
if "text" in block:
text_obj = block["text"]
if text_obj.get("type") == "mrkdwn":
text_content = text_obj.get("text", "")
message_components.append(Plain(text=text_content))
return message_components
async def _handle_socket_event(self, req: SocketModeRequest):
"""处理 Socket Mode 事件"""
if req.type == "events_api":
# 事件 API
event = req.payload.get("event", {})
# 忽略机器人自己的消息和消息编辑
if event.get("subtype") in [
"bot_message",
"message_changed",
"message_deleted",
]:
return
if event.get("bot_id"):
return
if event.get("type") in ["message", "app_mention"]:
abm = await self.convert_message(event)
if abm:
await self.handle_msg(abm)
async def get_bot_user_id(self):
auth_info = await self.web_client.auth_test()
return auth_info.get("user_id")
async def get_file_base64(self, url: str) -> str:
"""下载 Slack 文件并返回 Base64 编码的内容"""
headers = {"Authorization": f"Bearer {self.bot_token}"}
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=headers) as resp:
if resp.status == 200:
content = await resp.read()
base64_content = base64.b64encode(content).decode("utf-8")
return base64_content
else:
logger.error(f"Failed to download slack file: {resp.status} {await resp.text()}")
raise Exception(f"下载文件失败: {resp.status}")
async def run(self) -> Awaitable[Any]:
self.bot_self_id = await self.get_bot_user_id()
logger.info(f"Slack auth test OK. Bot ID: {self.bot_self_id}")
if self.connection_mode == "socket":
if not self.app_token:
raise ValueError("Socket Mode 需要 app_token")
# 创建 Socket 客户端
self.socket_client = SlackSocketClient(
self.web_client, self.app_token, self._handle_socket_event
)
logger.info("Slack 适配器 (Socket Mode) 启动中...")
await self.socket_client.start()
elif self.connection_mode == "webhook":
if not self.signing_secret:
raise ValueError("Webhook Mode 需要 signing_secret")
# 创建 Webhook 客户端
self.webhook_client = SlackWebhookClient(
self.web_client,
self.signing_secret,
self.webhook_host,
self.webhook_port,
self.webhook_path,
self._handle_webhook_event,
)
logger.info(
f"Slack 适配器 (Webhook Mode) 启动中,监听 {self.webhook_host}:{self.webhook_port}{self.webhook_path}..."
)
await self.webhook_client.start()
else:
raise ValueError(
f"不支持的连接模式: {self.connection_mode},请使用 'socket''webhook'"
)
async def _handle_webhook_event(self, event_data: dict):
"""处理 Webhook 事件"""
event = event_data.get("event", {})
# 忽略机器人自己的消息和消息编辑
if event.get("subtype") in [
"bot_message",
"message_changed",
"message_deleted",
]:
return
if event.get("bot_id"):
return
if event.get("type") in ["message", "app_mention"]:
abm = await self.convert_message(event)
if abm:
await self.handle_msg(abm)
async def terminate(self):
if self.socket_client:
await self.socket_client.stop()
if self.webhook_client:
await self.webhook_client.stop()
logger.info("Slack 适配器已被优雅地关闭")
def meta(self) -> PlatformMetadata:
return self.metadata
async def handle_msg(self, message: AstrBotMessage):
message_event = SlackMessageEvent(
message_str=message.message_str,
message_obj=message,
platform_meta=self.meta(),
session_id=message.session_id,
web_client=self.web_client,
)
self.commit_event(message_event)
def get_client(self):
return self.web_client

View File

@@ -0,0 +1,237 @@
import asyncio
import re
from typing import AsyncGenerator
from slack_sdk.web.async_client import AsyncWebClient
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import (
Image,
Plain,
File,
BaseMessageComponent,
)
from astrbot.api.platform import Group, MessageMember
from astrbot.api import logger
class SlackMessageEvent(AstrMessageEvent):
def __init__(
self,
message_str,
message_obj,
platform_meta,
session_id,
web_client: AsyncWebClient,
):
super().__init__(message_str, message_obj, platform_meta, session_id)
self.web_client = web_client
@staticmethod
async def _from_segment_to_slack_block(
segment: BaseMessageComponent, web_client: AsyncWebClient
) -> dict:
"""将消息段转换为 Slack 块格式"""
if isinstance(segment, Plain):
return {"type": "section", "text": {"type": "mrkdwn", "text": segment.text}}
elif isinstance(segment, Image):
# upload file
url = segment.url or segment.file
if url.startswith("http"):
return {
"type": "image",
"image_url": url,
"alt_text": "图片",
}
path = await segment.convert_to_file_path()
response = await web_client.files_upload_v2(
file=path,
filename="image.jpg",
)
if not response["ok"]:
logger.error(f"Slack file upload failed: {response['error']}")
return {
"type": "section",
"text": {"type": "mrkdwn", "text": "图片上传失败"},
}
image_url = response["files"][0]["url_private"]
logger.debug(f"Slack file upload response: {response}")
return {
"type": "image",
"slack_file": {
"url": image_url,
},
"alt_text": "图片",
}
elif isinstance(segment, File):
# upload file
url = segment.url or segment.file
response = await web_client.files_upload_v2(
file=url,
filename=segment.name or "file",
)
if not response["ok"]:
logger.error(f"Slack file upload failed: {response['error']}")
return {
"type": "section",
"text": {"type": "mrkdwn", "text": "文件上传失败"},
}
file_url = response["files"][0]["permalink"]
return {"type": "section", "text": {"type": "mrkdwn", "text": f"文件: <{file_url}|{segment.name or '文件'}>"}}
else:
return {"type": "section", "text": {"type": "mrkdwn", "text": str(segment)}}
@staticmethod
async def _parse_slack_blocks(
message_chain: MessageChain, web_client: AsyncWebClient
):
"""解析成 Slack 块格式"""
blocks = []
text_content = ""
for segment in message_chain.chain:
if isinstance(segment, Plain):
text_content += segment.text
else:
# 如果有文本内容,先添加文本块
if text_content.strip():
blocks.append(
{
"type": "section",
"text": {"type": "mrkdwn", "text": text_content},
}
)
text_content = ""
# 添加其他类型的块
block = await SlackMessageEvent._from_segment_to_slack_block(
segment, web_client
)
blocks.append(block)
# 如果最后还有文本内容
if text_content.strip():
blocks.append(
{"type": "section", "text": {"type": "mrkdwn", "text": text_content}}
)
return blocks, "" if blocks else text_content
async def send(self, message: MessageChain):
blocks, text = await SlackMessageEvent._parse_slack_blocks(
message, self.web_client
)
try:
if self.get_group_id():
# 发送到频道
await self.web_client.chat_postMessage(
channel=self.get_group_id(),
text=text,
blocks=blocks or None,
)
else:
# 发送私信
await self.web_client.chat_postMessage(
channel=self.get_sender_id(),
text=text,
blocks=blocks or None,
)
except Exception:
# 如果块发送失败,尝试只发送文本
fallback_text = ""
for segment in message.chain:
if isinstance(segment, Plain):
fallback_text += segment.text
elif isinstance(segment, File):
fallback_text += f" [文件: {segment.name}] "
elif isinstance(segment, Image):
fallback_text += " [图片] "
if self.get_group_id():
await self.web_client.chat_postMessage(
channel=self.get_group_id(), text=fallback_text
)
else:
await self.web_client.chat_postMessage(
channel=self.get_sender_id(), text=fallback_text
)
await super().send(message)
async def send_streaming(
self, generator: AsyncGenerator, use_fallback: bool = False
):
if not use_fallback:
buffer = None
async for chain in generator:
if not buffer:
buffer = chain
else:
buffer.chain.extend(chain.chain)
if not buffer:
return
buffer.squash_plain()
await self.send(buffer)
return await super().send_streaming(generator, use_fallback)
buffer = ""
pattern = re.compile(r"[^。?!~…]+[。?!~…]+")
async for chain in generator:
if isinstance(chain, MessageChain):
for comp in chain.chain:
if isinstance(comp, Plain):
buffer += comp.text
if any(p in buffer for p in "。?!~…"):
buffer = await self.process_buffer(buffer, pattern)
else:
await self.send(MessageChain(chain=[comp]))
await asyncio.sleep(1.5) # 限速
if buffer.strip():
await self.send(MessageChain([Plain(buffer)]))
return await super().send_streaming(generator, use_fallback)
async def get_group(self, group_id=None, **kwargs):
if group_id:
channel_id = group_id
elif self.get_group_id():
channel_id = self.get_group_id()
else:
return None
try:
# 获取频道信息
channel_info = await self.web_client.conversations_info(channel=channel_id)
# 获取频道成员
members_response = await self.web_client.conversations_members(
channel=channel_id
)
members = []
for member_id in members_response["members"]:
try:
user_info = await self.web_client.users_info(user=member_id)
user_data = user_info["user"]
members.append(
MessageMember(
user_id=member_id,
nickname=user_data.get("real_name")
or user_data.get("name", member_id),
)
)
except Exception:
# 如果获取用户信息失败,使用默认信息
members.append(MessageMember(user_id=member_id, nickname=member_id))
channel_data = channel_info["channel"]
return Group(
group_id=channel_id,
group_name=channel_data.get("name", ""),
group_avatar="",
group_admins=[], # Slack 的管理员信息需要特殊权限获取
group_owner=channel_data.get("creator", ""),
members=members,
)
except Exception:
return None

View File

@@ -40,20 +40,21 @@ class TelegramPlatformEvent(AstrMessageEvent):
super().__init__(message_str, message_obj, platform_meta, session_id)
self.client = client
def _split_message(self, text: str) -> list[str]:
if len(text) <= self.MAX_MESSAGE_LENGTH:
@classmethod
def _split_message(cls, text: str) -> list[str]:
if len(text) <= cls.MAX_MESSAGE_LENGTH:
return [text]
chunks = []
while text:
if len(text) <= self.MAX_MESSAGE_LENGTH:
if len(text) <= cls.MAX_MESSAGE_LENGTH:
chunks.append(text)
break
split_point = self.MAX_MESSAGE_LENGTH
segment = text[: self.MAX_MESSAGE_LENGTH]
split_point = cls.MAX_MESSAGE_LENGTH
segment = text[: cls.MAX_MESSAGE_LENGTH]
for _, pattern in self.SPLIT_PATTERNS.items():
for _, pattern in cls.SPLIT_PATTERNS.items():
if matches := list(pattern.finditer(segment)):
last_match = matches[-1]
split_point = last_match.end()
@@ -64,9 +65,8 @@ class TelegramPlatformEvent(AstrMessageEvent):
return chunks
async def send_with_client(
self, client: ExtBot, message: MessageChain, user_name: str
):
@classmethod
async def send_with_client(cls, client: ExtBot, message: MessageChain, user_name: str):
image_path = None
has_reply = False
@@ -97,7 +97,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
if at_user_id and not at_flag:
i.text = f"@{at_user_id} {i.text}"
at_flag = True
chunks = self._split_message(i.text)
chunks = cls._split_message(i.text)
for chunk in chunks:
try:
md_text = telegramify_markdown.markdownify(
@@ -158,6 +158,12 @@ class TelegramPlatformEvent(AstrMessageEvent):
async for chain in generator:
if isinstance(chain, MessageChain):
if chain.type == "break":
# 分割符
message_id = None # 重置消息 ID
delta = "" # 重置 delta
continue
# 处理消息链中的每个组件
for i in chain.chain:
if isinstance(i, Plain):

View File

@@ -35,6 +35,7 @@ class WebChatMessageEvent(AstrMessageEvent):
"cid": cid,
"data": data,
"streaming": streaming,
"chain_type": message.type,
}
)
elif isinstance(comp, Image):
@@ -110,6 +111,18 @@ class WebChatMessageEvent(AstrMessageEvent):
async def send_streaming(self, generator, use_fallback: bool = False):
final_data = ""
async for chain in generator:
if chain.type == "break" and final_data:
# 分割符
await web_chat_back_queue.put(
{
"type": "end",
"data": final_data,
"streaming": True,
"cid": self.session_id.split("!")[-1],
}
)
final_data = ""
continue
final_data += await WebChatMessageEvent._send(
chain, session_id=self.session_id, streaming=True
)

View File

@@ -2,6 +2,7 @@ import asyncio
import base64
import json
import os
import traceback
import time
from typing import Optional
@@ -158,7 +159,6 @@ class WeChatPadProAdapter(Platform):
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}")
@@ -166,6 +166,8 @@ class WeChatPadProAdapter(Platform):
"""
检查 WeChatPadPro 设备是否在线。
"""
if not self.auth_key:
return False
url = f"{self.base_url}/login/GetLoginStatus"
params = {"key": self.auth_key}
@@ -184,12 +186,16 @@ class WeChatPadProAdapter(Platform):
logger.info("WeChatPadPro 设备不在线。")
return False
else:
logger.error(f"未知的在线状态: {login_state:}")
logger.error(f"未知的在线状态: {response_data}")
return False
# Code == 300 为微信退出状态。
elif response.status == 200 and response_data.get("Code") == 300:
logger.info("WeChatPadPro 设备已退出。")
return False
elif response.status == 200 and response_data.get("Code") == -2:
# 该链接不存在
self.auth_key = None
return False
else:
logger.error(
f"检查在线状态失败: {response.status}, {response_data}"
@@ -201,6 +207,7 @@ class WeChatPadProAdapter(Platform):
return False
except Exception as e:
logger.error(f"检查在线状态时发生错误: {e}")
logger.error(traceback.format_exc())
return False
async def generate_auth_key(self):
@@ -224,7 +231,7 @@ class WeChatPadProAdapter(Platform):
and len(response_data["Data"]) > 0
):
self.auth_key = response_data["Data"][0]
logger.info("成功获取授权码")
logger.info(f"成功获取授权码 {self.auth_key[:8]}...")
else:
logger.error(
f"生成授权码成功但未找到授权码: {response_data}"
@@ -250,7 +257,6 @@ class WeChatPadProAdapter(Platform):
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(
@@ -262,6 +268,13 @@ class WeChatPadProAdapter(Platform):
f"获取登录二维码成功但未找到二维码地址: {response_data}"
)
return None
elif "该 key 无效" in response_data.get("Text"):
logger.error(
"授权码无效,已经清除。请重新启动 AstrBot 或者本消息适配器。原因也可能是 WeChatPadPro 的 MySQL 服务没有启动成功,请检查 WeChatPadPro 服务的日志。"
)
self.auth_key = None
self.save_credentials()
return None
else:
logger.error(
f"获取登录二维码失败: {response.status}, {response_data}"
@@ -354,7 +367,7 @@ class WeChatPadProAdapter(Platform):
while True:
try:
async with websockets.connect(ws_url) as websocket:
logger.info("WebSocket 连接成功。")
logger.debug("WebSocket 连接成功。")
# 设置空闲超时重连
wait_time = (
self.active_message_poll_interval
@@ -369,7 +382,7 @@ class WeChatPadProAdapter(Platform):
# logger.debug(message) # 不显示原始消息内容
asyncio.create_task(self.handle_websocket_message(message))
except asyncio.TimeoutError:
logger.warning(f"WebSocket 连接空闲超过 {wait_time} s")
logger.debug(f"WebSocket 连接空闲超过 {wait_time} s")
break
except websockets.exceptions.ConnectionClosedOK:
logger.info("WebSocket 连接正常关闭。")
@@ -492,7 +505,7 @@ class WeChatPadProAdapter(Platform):
# 对于群聊session_id 可以是群聊 ID 或发送者 ID + 群聊 ID (如果 unique_session 为 True)
if self.unique_session:
abm.session_id = f"{from_user_name}_{to_user_name}"
abm.session_id = f"{from_user_name}#{abm.sender.user_id}"
else:
abm.session_id = from_user_name
@@ -631,7 +644,11 @@ class WeChatPadProAdapter(Platform):
# wechatpadpro 的格式: <atuserlist>wxid</atuserlist>
# gewechat 的格式: <atuserlist><![CDATA[wxid]]></atuserlist>
msg_source = raw_message.get("msg_source", "")
if f"<atuserlist>{abm.self_id}</atuserlist>" in msg_source or f"<atuserlist>{abm.self_id}," in msg_source or f",{abm.self_id}</atuserlist>" in msg_source:
if (
f"<atuserlist>{abm.self_id}</atuserlist>" in msg_source
or f"<atuserlist>{abm.self_id}," in msg_source
or f",{abm.self_id}</atuserlist>" in msg_source
):
at_me = True
# 也检查 push_content 中是否有@提示
@@ -641,19 +658,28 @@ class WeChatPadProAdapter(Platform):
if at_me:
# 被@了在消息开头插入At组件参考gewechat的做法
bot_nickname = await self._get_group_member_nickname(abm.group_id, abm.self_id)
abm.message.insert(0, At(qq=abm.self_id, name=bot_nickname or abm.self_id))
bot_nickname = await self._get_group_member_nickname(
abm.group_id, abm.self_id
)
abm.message.insert(
0, At(qq=abm.self_id, name=bot_nickname or abm.self_id)
)
# 只有当消息内容不仅仅是@时才添加Plain组件
if "\u2005" in message_content:
# 检查@之后是否还有其他内容
parts = message_content.split("\u2005")
if len(parts) > 1 and any(part.strip() for part in parts[1:]):
if len(parts) > 1 and any(
part.strip() for part in parts[1:]
):
abm.message.append(Plain(message_content))
else:
# 检查是否只包含@机器人
is_pure_at = False
if bot_nickname and message_content.strip() == f"@{bot_nickname}":
if (
bot_nickname
and message_content.strip() == f"@{bot_nickname}"
):
is_pure_at = True
if not is_pure_at:
abm.message.append(Plain(message_content))
@@ -806,7 +832,10 @@ class WeChatPadProAdapter(Platform):
# 根据 session_id 判断消息类型
if "@chatroom" in session.session_id:
dummy_message_obj.type = MessageType.GROUP_MESSAGE
dummy_message_obj.group_id = session.session_id
if "#" in session.session_id:
dummy_message_obj.group_id = session.session_id.split("#")[0]
else:
dummy_message_obj.group_id = session.session_id
dummy_message_obj.sender = MessageMember(user_id="", nickname="")
else:
dummy_message_obj.type = MessageType.FRIEND_MESSAGE

View File

@@ -17,7 +17,7 @@ from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.platform.astrbot_message import AstrBotMessage, MessageType
from astrbot.core.platform.platform_metadata import PlatformMetadata
from astrbot.core.utils.tencent_record_helper import wav_to_tencent_silk_base64
from astrbot.core.utils.tencent_record_helper import audio_to_tencent_silk_base64
if TYPE_CHECKING:
from .wechatpadpro_adapter import WeChatPadProAdapter
@@ -81,12 +81,16 @@ class WeChatPadProMessageEvent(AstrMessageEvent):
# logger.info(f"已添加 @ 信息: {message_text}")
else:
message_text = text
if self.get_group_id() and "#" in self.session_id:
session_id = self.session_id.split("#")[0]
else:
session_id = self.session_id
payload = {
"MsgItem": [
{
"MsgType": 1,
"TextContent": message_text,
"ToUserName": self.session_id,
"ToUserName": session_id,
}
]
}
@@ -109,7 +113,7 @@ class WeChatPadProMessageEvent(AstrMessageEvent):
async def _send_voice(self, session: aiohttp.ClientSession, comp: Record):
record_path = await comp.convert_to_file_path()
# 默认已经存在 data/temp 中
b64, duration = await wav_to_tencent_silk_base64(record_path)
b64, duration = await audio_to_tencent_silk_base64(record_path)
payload = {
"ToUserName": self.session_id,
"VoiceData": b64,

View File

@@ -303,6 +303,7 @@ class WecomPlatformAdapter(Platform):
abm.session_id = external_userid
abm.type = MessageType.FRIEND_MESSAGE
abm.message_id = msg.get("msgid", uuid.uuid4().hex[:8])
abm.message_str = ""
if msgtype == "text":
text = msg.get("text", {}).get("content", "").strip()
abm.message = [Plain(text=text)]
@@ -316,7 +317,29 @@ class WecomPlatformAdapter(Platform):
with open(path, "wb") as f:
f.write(resp.content)
abm.message = [Image(file=path, url=path)]
abm.message_str = "[图片]"
elif msgtype == "voice":
media_id = msg.get("voice", {}).get("media_id", "")
resp: Response = await asyncio.get_event_loop().run_in_executor(
None, self.client.media.download, media_id
)
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
path = os.path.join(temp_dir, f"weixinkefu_{media_id}.amr")
with open(path, "wb") as f:
f.write(resp.content)
try:
from pydub import AudioSegment
path_wav = os.path.join(temp_dir, f"weixinkefu_{media_id}.wav")
audio = AudioSegment.from_file(path)
audio.export(path_wav, format="wav")
except Exception as e:
logger.error(f"转换音频失败: {e}。如果没有安装 ffmpeg 请先安装。")
path_wav = path
return
abm.message = [Record(file=path_wav, url=path_wav)]
else:
logger.warning(f"未实现的微信客服消息事件: {msg}")
return

View File

@@ -120,6 +120,30 @@ class WecomPlatformEvent(AstrMessageEvent):
self.get_self_id(),
response["media_id"],
)
elif isinstance(comp, Record):
record_path = await comp.convert_to_file_path()
# 转成amr
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
record_path_amr = os.path.join(temp_dir, f"{uuid.uuid4()}.amr")
pydub.AudioSegment.from_wav(record_path).export(
record_path_amr, format="amr"
)
with open(record_path_amr, "rb") as f:
try:
response = self.client.media.upload("voice", f)
except Exception as e:
logger.error(f"微信客服上传语音失败: {e}")
await self.send(
MessageChain().message(f"微信客服上传语音失败: {e}")
)
return
logger.info(f"微信客服上传语音返回: {response}")
kf_message_api.send_voice(
user_id,
self.get_self_id(),
response["media_id"],
)
else:
logger.warning(f"还没实现这个消息类型的发送逻辑: {comp.type}")
else:

View File

@@ -58,7 +58,7 @@ class AssistantMessageSegment:
"""OpenAI 格式的上下文中 role 为 assistant 的消息段。参考: https://platform.openai.com/docs/guides/function-calling"""
content: str = None
tool_calls: List[ChatCompletionMessageToolCall | Dict] = None
tool_calls: List[ChatCompletionMessageToolCall | Dict] = field(default_factory=list)
role: str = "assistant"
def to_dict(self):
@@ -67,7 +67,7 @@ class AssistantMessageSegment:
}
if self.content:
ret["content"] = self.content
elif self.tool_calls:
if self.tool_calls:
ret["tool_calls"] = self.tool_calls
return ret
@@ -95,19 +95,19 @@ class ProviderRequest:
"""提示词"""
session_id: str = ""
"""会话 ID"""
image_urls: List[str] = None
image_urls: list[str] = field(default_factory=list)
"""图片 URL 列表"""
func_tool: FuncCall = None
func_tool: FuncCall | None = None
"""可用的函数工具"""
contexts: List = None
contexts: list[dict] = field(default_factory=list)
"""上下文。格式与 openai 的上下文格式一致:
参考 https://platform.openai.com/docs/api-reference/chat/create#chat-create-messages
"""
system_prompt: str = ""
"""系统提示词"""
conversation: Conversation = None
conversation: Conversation | None = None
tool_calls_result: ToolCallsResult = None
tool_calls_result: list[ToolCallsResult] | ToolCallsResult | None = None
"""附加的上次请求后工具调用的结果。参考: https://platform.openai.com/docs/guides/function-calling#handling-function-calls"""
def __repr__(self):
@@ -116,6 +116,14 @@ class ProviderRequest:
def __str__(self):
return self.__repr__()
def append_tool_calls_result(self, tool_calls_result: ToolCallsResult):
"""添加工具调用结果到请求中"""
if not self.tool_calls_result:
self.tool_calls_result = []
if isinstance(self.tool_calls_result, ToolCallsResult):
self.tool_calls_result = [self.tool_calls_result]
self.tool_calls_result.append(tool_calls_result)
def _print_friendly_context(self):
"""打印友好的消息上下文。将 image_url 的值替换为 <Image>"""
if not self.contexts:

View File

@@ -1,12 +1,14 @@
import traceback
import asyncio
from astrbot.core.config.astrbot_config import AstrBotConfig
from .provider import Provider, STTProvider, TTSProvider, Personality
from .entities import ProviderType
import traceback
from typing import List
from astrbot.core.db import BaseDatabase
from .register import provider_cls_map, llm_tools
from astrbot.core import logger, sp
from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.db import BaseDatabase
from .entities import ProviderType
from .provider import Personality, Provider, STTProvider, TTSProvider
from .register import llm_tools, provider_cls_map
class ProviderManager:
@@ -38,13 +40,11 @@ class ProviderManager:
begin_dialogs = []
user_turn = True
for dialog in begin_dialogs:
bd_processed.append(
{
"role": "user" if user_turn else "assistant",
"content": dialog,
"_no_save": None, # 不持久化到 db
}
)
bd_processed.append({
"role": "user" if user_turn else "assistant",
"content": dialog,
"_no_save": None, # 不持久化到 db
})
user_turn = not user_turn
if mood_imitation_dialogs:
if len(mood_imitation_dialogs) % 2 != 0:
@@ -190,11 +190,6 @@ class ProviderManager:
from .sources.anthropic_source import (
ProviderAnthropic as ProviderAnthropic,
)
case "llm_tuner":
logger.info("加载 LLM Tuner 工具 ...")
from .sources.llmtuner_source import (
LLMTunerModelLoader as LLMTunerModelLoader,
)
case "dify":
from .sources.dify_source import ProviderDify as ProviderDify
case "dashscope":
@@ -225,6 +220,10 @@ class ProviderManager:
from .sources.edge_tts_source import (
ProviderEdgeTTS as ProviderEdgeTTS,
)
case "gsv_tts_selfhost":
from .sources.gsv_selfhosted_source import (
ProviderGSVTTS as ProviderGSVTTS,
)
case "gsvi_tts_api":
from .sources.gsvi_tts_source import (
ProviderGSVITTS as ProviderGSVITTS,
@@ -249,6 +248,10 @@ class ProviderManager:
from .sources.volcengine_tts import (
ProviderVolcengineTTS as ProviderVolcengineTTS,
)
case "gemini_tts":
from .sources.gemini_tts_source import (
ProviderGeminiTTSAPI as ProviderGeminiTTSAPI,
)
case "openai_embedding":
from .sources.openai_embedding_source import (
OpenAIEmbeddingProvider as OpenAIEmbeddingProvider,
@@ -322,8 +325,6 @@ class ProviderManager:
inst = provider_metadata.cls_type(
provider_config,
self.provider_settings,
self.db_helper,
self.provider_settings.get("persistant_history", True),
self.selected_default_persona,
)

View File

@@ -1,6 +1,5 @@
import abc
from typing import List
from astrbot.core.db import BaseDatabase
from typing import TypedDict, AsyncGenerator
from astrbot.core.provider.func_tool_manager import FuncCall
from astrbot.core.provider.entities import LLMResponse, ToolCallsResult
@@ -53,15 +52,13 @@ class Provider(AbstractProvider):
self,
provider_config: dict,
provider_settings: dict,
persistant_history: bool = True,
db_helper: BaseDatabase = None,
default_persona: Personality = None,
default_persona: Personality | None = None,
) -> None:
super().__init__(provider_config)
self.provider_settings = provider_settings
self.curr_personality: Personality = default_persona
self.curr_personality = default_persona
"""维护了当前的使用的 persona即人格。可能为 None"""
@abc.abstractmethod
@@ -86,11 +83,11 @@ class Provider(AbstractProvider):
self,
prompt: str,
session_id: str = None,
image_urls: List[str] = None,
image_urls: list[str] = None,
func_tool: FuncCall = None,
contexts: List = None,
contexts: list = None,
system_prompt: str = None,
tool_calls_result: ToolCallsResult = None,
tool_calls_result: ToolCallsResult | list[ToolCallsResult] = None,
**kwargs,
) -> LLMResponse:
"""获得 LLM 的文本对话结果。会使用当前的模型进行对话。
@@ -114,11 +111,11 @@ class Provider(AbstractProvider):
self,
prompt: str,
session_id: str = None,
image_urls: List[str] = None,
image_urls: list[str] = None,
func_tool: FuncCall = None,
contexts: List = None,
contexts: list = None,
system_prompt: str = None,
tool_calls_result: ToolCallsResult = None,
tool_calls_result: ToolCallsResult | list[ToolCallsResult] = None,
**kwargs,
) -> AsyncGenerator[LLMResponse, None]:
"""获得 LLM 的流式文本对话结果。会使用当前的模型进行对话。在生成的最后会返回一次完整的结果。

View File

@@ -1,3 +1,6 @@
import json
import anthropic
import base64
from typing import List
from mimetypes import guess_type
@@ -5,41 +8,33 @@ from anthropic import AsyncAnthropic
from anthropic.types import Message
from astrbot.core.utils.io import download_image_by_url
from astrbot.core.db import BaseDatabase
from astrbot.api.provider import Provider, Personality
from astrbot.api.provider import Provider
from astrbot import logger
from astrbot.core.provider.func_tool_manager import FuncCall
from ..register import register_provider_adapter
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import LLMResponse, ToolCallsResult
from .openai_source import ProviderOpenAIOfficial
from astrbot.core.provider.entities import LLMResponse
from typing import AsyncGenerator
@register_provider_adapter(
"anthropic_chat_completion", "Anthropic Claude API 提供商适配器"
)
class ProviderAnthropic(ProviderOpenAIOfficial):
class ProviderAnthropic(Provider):
def __init__(
self,
provider_config: dict,
provider_settings: dict,
db_helper: BaseDatabase,
persistant_history=True,
default_persona: Personality = None,
provider_config,
provider_settings,
default_persona=None,
) -> None:
# Skip OpenAI's __init__ and call Provider's __init__ directly
Provider.__init__(
self,
super().__init__(
provider_config,
provider_settings,
persistant_history,
db_helper,
default_persona,
)
self.chosen_api_key = None
self.chosen_api_key: str = ""
self.api_keys: List = provider_config.get("key", [])
self.chosen_api_key = self.api_keys[0] if len(self.api_keys) > 0 else None
self.chosen_api_key = self.api_keys[0] if len(self.api_keys) > 0 else ""
self.base_url = provider_config.get("api_base", "https://api.anthropic.com")
self.timeout = provider_config.get("timeout", 120)
if isinstance(self.timeout, str):
@@ -51,10 +46,63 @@ class ProviderAnthropic(ProviderOpenAIOfficial):
self.set_model(provider_config["model_config"]["model"])
def _prepare_payload(self, messages: list[dict]):
"""准备 Anthropic API 的请求 payload
Args:
messages: OpenAI 格式的消息列表,包含用户输入和系统提示等信息
Returns:
system_prompt: 系统提示内容
new_messages: 处理后的消息列表,去除系统提示
"""
system_prompt = ""
new_messages = []
for message in messages:
if message["role"] == "system":
system_prompt = message["content"]
elif message["role"] == "assistant":
blocks = []
if isinstance(message["content"], str):
blocks.append({"type": "text", "text": message["content"]})
if "tool_calls" in message:
for tool_call in message["tool_calls"]:
blocks.append( # noqa: PERF401
{
"type": "tool_use",
"name": tool_call["function"]["name"],
"input": json.loads(tool_call["function"]["arguments"])
if isinstance(tool_call["function"]["arguments"], str)
else tool_call["function"]["arguments"],
"id": tool_call["id"],
}
)
new_messages.append(
{
"role": "assistant",
"content": blocks,
}
)
elif message["role"] == "tool":
new_messages.append(
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": message["tool_call_id"],
"content": message["content"],
}
],
}
)
else:
new_messages.append(message)
return system_prompt, new_messages
async def _query(self, payloads: dict, tools: FuncCall) -> LLMResponse:
if tools:
tool_list = tools.get_func_desc_anthropic_style()
if tool_list:
if tool_list := tools.get_func_desc_anthropic_style():
payloads["tools"] = tool_list
completion = await self.client.messages.create(**payloads, stream=False)
@@ -64,70 +112,157 @@ class ProviderAnthropic(ProviderOpenAIOfficial):
if len(completion.content) == 0:
raise Exception("API 返回的 completion 为空。")
# TODO: 如果进行函数调用,思维链被截断,用户可能需要思维链的内容
# 选最后一条消息如果要进行函数调用anthropic会先返回文本消息的思维链然后再返回函数调用请求
content = completion.content[-1]
llm_response = LLMResponse("assistant")
llm_response = LLMResponse(role="assistant")
if content.type == "text":
# text completion
completion_text = str(content.text).strip()
# llm_response.completion_text = completion_text
llm_response.result_chain = MessageChain().message(completion_text)
# Anthropic每次只返回一个函数调用
if completion.stop_reason == "tool_use":
# tools call (function calling)
args_ls = []
func_name_ls = []
tool_use_ids = []
func_name_ls.append(content.name)
args_ls.append(content.input)
tool_use_ids.append(content.id)
llm_response.role = "tool"
llm_response.tools_call_args = args_ls
llm_response.tools_call_name = func_name_ls
llm_response.tools_call_ids = tool_use_ids
for content_block in completion.content:
if content_block.type == "text":
completion_text = str(content_block.text).strip()
llm_response.completion_text = completion_text
if content_block.type == "tool_use":
llm_response.tools_call_args.append(content_block.input)
llm_response.tools_call_name.append(content_block.name)
llm_response.tools_call_ids.append(content_block.id)
# TODO(Soulter): 处理 end_turn 情况
if not llm_response.completion_text and not llm_response.tools_call_args:
logger.error(f"API 返回的 completion 无法解析:{completion}")
raise Exception(f"API 返回的 completion 无法解析:{completion}")
llm_response.raw_completion = completion
raise Exception(f"Anthropic API 返回的 completion 无法解析:{completion}")
return llm_response
async def _query_stream(
self, payloads: dict, tools: FuncCall
) -> AsyncGenerator[LLMResponse, None]:
if tools:
if tool_list := tools.get_func_desc_anthropic_style():
payloads["tools"] = tool_list
# 用于累积工具调用信息
tool_use_buffer = {}
# 用于累积最终结果
final_text = ""
final_tool_calls = []
async with self.client.messages.stream(**payloads) as stream:
assert isinstance(stream, anthropic.AsyncMessageStream)
async for event in stream:
if event.type == "content_block_start":
if event.content_block.type == "text":
# 文本块开始
yield LLMResponse(
role="assistant", completion_text="", is_chunk=True
)
elif event.content_block.type == "tool_use":
# 工具使用块开始,初始化缓冲区
tool_use_buffer[event.index] = {
"id": event.content_block.id,
"name": event.content_block.name,
"input": {},
}
elif event.type == "content_block_delta":
if event.delta.type == "text_delta":
# 文本增量
final_text += event.delta.text
yield LLMResponse(
role="assistant",
completion_text=event.delta.text,
is_chunk=True,
)
elif event.delta.type == "input_json_delta":
# 工具调用参数增量
if event.index in tool_use_buffer:
# 累积 JSON 输入
if "input_json" not in tool_use_buffer[event.index]:
tool_use_buffer[event.index]["input_json"] = ""
tool_use_buffer[event.index]["input_json"] += (
event.delta.partial_json
)
elif event.type == "content_block_stop":
# 内容块结束
if event.index in tool_use_buffer:
# 解析完整的工具调用
tool_info = tool_use_buffer[event.index]
try:
if "input_json" in tool_info:
tool_info["input"] = json.loads(tool_info["input_json"])
# 添加到最终结果
final_tool_calls.append(
{
"id": tool_info["id"],
"name": tool_info["name"],
"input": tool_info["input"],
}
)
yield LLMResponse(
role="tool",
completion_text="",
tools_call_args=[tool_info["input"]],
tools_call_name=[tool_info["name"]],
tools_call_ids=[tool_info["id"]],
is_chunk=True,
)
except json.JSONDecodeError:
# JSON 解析失败,跳过这个工具调用
logger.warning(f"工具调用参数 JSON 解析失败: {tool_info}")
# 清理缓冲区
del tool_use_buffer[event.index]
# 返回最终的完整结果
final_response = LLMResponse(
role="assistant", completion_text=final_text, is_chunk=False
)
if final_tool_calls:
final_response.tools_call_args = [
call["input"] for call in final_tool_calls
]
final_response.tools_call_name = [call["name"] for call in final_tool_calls]
final_response.tools_call_ids = [call["id"] for call in final_tool_calls]
yield final_response
async def text_chat(
self,
prompt: str,
session_id: str = None,
image_urls: List[str] = [],
func_tool: FuncCall = None,
prompt,
session_id=None,
image_urls=None,
func_tool=None,
contexts=None,
system_prompt=None,
tool_calls_result: ToolCallsResult = None,
tool_calls_result=None,
**kwargs,
) -> LLMResponse:
if contexts is None:
contexts = []
if not prompt:
prompt = "<image>"
new_record = await self.assemble_context(prompt, image_urls)
context_query = [*contexts, new_record]
if system_prompt:
context_query.insert(0, {"role": "system", "content": system_prompt})
for part in context_query:
if "_no_save" in part:
del part["_no_save"]
# tool calls result
if tool_calls_result:
# 暂时这样写。
prompt += f"Here are the related results via using tools: {str(tool_calls_result.tool_calls_result)}"
if not isinstance(tool_calls_result, list):
context_query.extend(tool_calls_result.to_openai_messages())
else:
for tcr in tool_calls_result:
context_query.extend(tcr.to_openai_messages())
system_prompt, new_messages = self._prepare_payload(context_query)
model_config = self.provider_config.get("model_config", {})
model_config["model"] = self.get_model()
payloads = {"messages": new_messages, **model_config}
payloads = {"messages": context_query, **model_config}
# Anthropic has a different way of handling system prompts
if system_prompt:
payloads["system"] = system_prompt
@@ -135,32 +270,9 @@ class ProviderAnthropic(ProviderOpenAIOfficial):
llm_response = None
try:
llm_response = await self._query(payloads, func_tool)
except Exception as e:
if "maximum context length" in str(e):
retry_cnt = 20
while retry_cnt > 0:
logger.warning(
f"上下文长度超过限制。尝试弹出最早的记录然后重试。当前记录条数: {len(context_query)}"
)
try:
await self.pop_record(context_query)
response = await self.client.messages.create(
messages=context_query, **model_config
)
llm_response = LLMResponse("assistant")
llm_response.result_chain = MessageChain().message(response.content[0].text)
llm_response.raw_completion = response
return llm_response
except Exception as e:
if "maximum context length" in str(e):
retry_cnt -= 1
else:
raise e
return LLMResponse("err", "err: 请尝试 /reset 清除会话记录。")
else:
logger.error(f"发生了错误。Provider 配置如下: {model_config}")
raise e
logger.error(f"发生了错误。Provider 配置如下: {model_config}")
raise e
return llm_response
@@ -175,21 +287,34 @@ class ProviderAnthropic(ProviderOpenAIOfficial):
tool_calls_result=None,
**kwargs,
):
# raise NotImplementedError("This method is not implemented yet.")
# 调用 text_chat 模拟流式
llm_response = await self.text_chat(
prompt=prompt,
session_id=session_id,
image_urls=image_urls,
func_tool=func_tool,
contexts=contexts,
system_prompt=system_prompt,
tool_calls_result=tool_calls_result,
)
llm_response.is_chunk = True
yield llm_response
llm_response.is_chunk = False
yield llm_response
if contexts is None:
contexts = []
new_record = await self.assemble_context(prompt, image_urls)
context_query = [*contexts, new_record]
if system_prompt:
context_query.insert(0, {"role": "system", "content": system_prompt})
for part in context_query:
if "_no_save" in part:
del part["_no_save"]
# tool calls result
if tool_calls_result:
context_query.extend(tool_calls_result.to_openai_messages())
system_prompt, new_messages = self._prepare_payload(context_query)
model_config = self.provider_config.get("model_config", {})
model_config["model"] = self.get_model()
payloads = {"messages": new_messages, **model_config}
# Anthropic has a different way of handling system prompts
if system_prompt:
payloads["system"] = system_prompt
async for llm_response in self._query_stream(payloads, func_tool):
yield llm_response
async def assemble_context(self, text: str, image_urls: List[str] = None):
"""组装上下文,支持文本和图片"""
@@ -232,3 +357,28 @@ class ProviderAnthropic(ProviderOpenAIOfficial):
)
return {"role": "user", "content": content}
async def encode_image_bs64(self, image_url: str) -> str:
"""
将图片转换为 base64
"""
if image_url.startswith("base64://"):
return image_url.replace("base64://", "data:image/jpeg;base64,")
with open(image_url, "rb") as f:
image_bs64 = base64.b64encode(f.read()).decode("utf-8")
return "data:image/jpeg;base64," + image_bs64
return ""
def get_current_key(self) -> str:
return self.chosen_api_key
async def get_models(self) -> List[str]:
models_str = []
models = await self.client.models.list()
models = sorted(models.data, key=lambda x: x.id)
for model in models:
models_str.append(model.id)
return models_str
def set_key(self, key: str):
self.chosen_api_key = key

View File

@@ -5,7 +5,6 @@ from typing import List
from .. import Provider, Personality
from ..entities import LLMResponse
from ..func_tool_manager import FuncCall
from astrbot.core.db import BaseDatabase
from ..register import register_provider_adapter
from astrbot.core.message.message_event_result import MessageChain
from .openai_source import ProviderOpenAIOfficial
@@ -19,16 +18,12 @@ class ProviderDashscope(ProviderOpenAIOfficial):
self,
provider_config: dict,
provider_settings: dict,
db_helper: BaseDatabase,
persistant_history=False,
default_persona: Personality = None,
default_persona: Personality | None = None,
) -> None:
Provider.__init__(
self,
provider_config,
provider_settings,
persistant_history,
db_helper,
default_persona,
)
self.api_key = provider_config.get("dashscope_api_key", "")

View File

@@ -1,10 +1,9 @@
import astrbot.core.message.components as Comp
import os
from typing import List
from .. import Provider, Personality
from .. import Provider
from ..entities import LLMResponse
from ..func_tool_manager import FuncCall
from astrbot.core.db import BaseDatabase
from ..register import register_provider_adapter
from astrbot.core.utils.dify_api_client import DifyAPIClient
from astrbot.core.utils.io import download_image_by_url, download_file
@@ -17,17 +16,13 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path
class ProviderDify(Provider):
def __init__(
self,
provider_config: dict,
provider_settings: dict,
db_helper: BaseDatabase,
persistant_history=False,
default_persona: Personality = None,
provider_config,
provider_settings,
default_persona = None,
) -> None:
super().__init__(
provider_config,
provider_settings,
persistant_history,
db_helper,
default_persona,
)
self.api_key = provider_config.get("dify_api_key", "")
@@ -70,6 +65,7 @@ class ProviderDify(Provider):
if image_urls is None:
image_urls = []
result = ""
session_id = session_id or kwargs.get("user") # 1734
conversation_id = self.conversation_ids.get(session_id, "")
files_payload = []

View File

@@ -12,8 +12,7 @@ from google.genai.errors import APIError
import astrbot.core.message.components as Comp
from astrbot import logger
from astrbot.api.provider import Personality, Provider
from astrbot.core.db import BaseDatabase
from astrbot.api.provider import Provider
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import LLMResponse, ToolCallsResult
from astrbot.core.provider.func_tool_manager import FuncCall
@@ -52,17 +51,13 @@ class ProviderGoogleGenAI(Provider):
def __init__(
self,
provider_config: dict,
provider_settings: dict,
db_helper: BaseDatabase,
persistant_history=True,
default_persona: Personality = None,
provider_config,
provider_settings,
default_persona=None,
) -> None:
super().__init__(
provider_config,
provider_settings,
persistant_history,
db_helper,
default_persona,
)
self.api_keys: list = provider_config.get("key", [])
@@ -264,12 +259,10 @@ class ProviderGoogleGenAI(Provider):
contents.append(content_cls(parts=part))
gemini_contents: list[types.Content] = []
native_tool_enabled = any(
[
self.provider_config.get("gm_native_coderunner", False),
self.provider_config.get("gm_native_search", False),
]
)
native_tool_enabled = any([
self.provider_config.get("gm_native_coderunner", False),
self.provider_config.get("gm_native_search", False),
])
for message in payloads["messages"]:
role, content = message["role"], message.get("content")
@@ -506,12 +499,12 @@ class ProviderGoogleGenAI(Provider):
async def text_chat(
self,
prompt: str,
session_id: str = None,
image_urls: list[str] = None,
func_tool: FuncCall = None,
contexts: list = None,
system_prompt: str = None,
tool_calls_result: ToolCallsResult = None,
session_id=None,
image_urls=None,
func_tool=None,
contexts=None,
system_prompt=None,
tool_calls_result=None,
**kwargs,
) -> LLMResponse:
if contexts is None:
@@ -527,7 +520,11 @@ class ProviderGoogleGenAI(Provider):
# tool calls result
if tool_calls_result:
context_query.extend(tool_calls_result.to_openai_messages())
if not isinstance(tool_calls_result, list):
context_query.extend(tool_calls_result.to_openai_messages())
else:
for tcr in tool_calls_result:
context_query.extend(tcr.to_openai_messages())
model_config = self.provider_config.get("model_config", {})
model_config["model"] = self.get_model()
@@ -631,9 +628,10 @@ class ProviderGoogleGenAI(Provider):
if not image_data:
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
continue
user_content["content"].append(
{"type": "image_url", "image_url": {"url": image_data}}
)
user_content["content"].append({
"type": "image_url",
"image_url": {"url": image_data},
})
return user_content
else:
return {"role": "user", "content": text}

View File

@@ -0,0 +1,79 @@
import os
import uuid
import wave
from google import genai
from google.genai import types
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from ..entities import ProviderType
from ..provider import TTSProvider
from ..register import register_provider_adapter
@register_provider_adapter(
"gemini_tts", "Gemini TTS API", provider_type=ProviderType.TEXT_TO_SPEECH
)
class ProviderGeminiTTSAPI(TTSProvider):
def __init__(
self,
provider_config: dict,
provider_settings: dict,
) -> None:
super().__init__(provider_config, provider_settings)
api_key: str = provider_config.get("gemini_tts_api_key", "")
api_base: str | None = provider_config.get("gemini_tts_api_base")
timeout: int = int(provider_config.get("gemini_tts_timeout", 20))
http_options = types.HttpOptions(timeout=timeout * 1000)
if api_base:
if api_base.endswith("/"):
api_base = api_base[:-1]
http_options.base_url = api_base
self.client = genai.Client(api_key=api_key, http_options=http_options).aio
self.model: str = provider_config.get(
"gemini_tts_model", "gemini-2.5-flash-preview-tts"
)
self.prefix: str | None = provider_config.get(
"gemini_tts_prefix",
)
self.voice_name: str = provider_config.get("gemini_tts_voice_name", "Leda")
async def get_audio(self, text: str) -> str:
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
path = os.path.join(temp_dir, f"gemini_tts_{uuid.uuid4()}.wav")
prompt = f"{self.prefix}: {text}" if self.prefix else text
response = await self.client.models.generate_content(
model=self.model,
contents=prompt,
config=types.GenerateContentConfig(
response_modalities=["AUDIO"],
speech_config=types.SpeechConfig(
voice_config=types.VoiceConfig(
prebuilt_voice_config=types.PrebuiltVoiceConfig(
voice_name=self.voice_name,
)
)
),
),
)
# 不想看类型检查报错
if (
not response.candidates
or not response.candidates[0].content
or not response.candidates[0].content.parts
or not response.candidates[0].content.parts[0].inline_data
or not response.candidates[0].content.parts[0].inline_data.data
):
raise Exception("No audio content returned from Gemini TTS API.")
with wave.open(path, "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(24000)
wf.writeframes(response.candidates[0].content.parts[0].inline_data.data)
return path

View File

@@ -0,0 +1,148 @@
import asyncio
import os
import uuid
import aiohttp
from ..provider import TTSProvider
from ..entities import ProviderType
from ..register import register_provider_adapter
from astrbot import logger
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
@register_provider_adapter(
provider_type_name="gsv_tts_selfhost",
desc="GPT-SoVITS TTS(本地加载)",
provider_type=ProviderType.TEXT_TO_SPEECH,
)
class ProviderGSVTTS(TTSProvider):
def __init__(
self,
provider_config: dict,
provider_settings: dict,
) -> None:
super().__init__(provider_config, provider_settings)
self.api_base = provider_config.get("api_base", "http://127.0.0.1:9880").rstrip(
"/"
)
self.gpt_weights_path: str = provider_config.get("gpt_weights_path", "")
self.sovits_weights_path: str = provider_config.get("sovits_weights_path", "")
# TTS 请求的默认参数移除前缀gsv_
self.default_params: dict = {
key.removeprefix("gsv_"): str(value).lower()
for key, value in provider_config.get("gsv_default_parms", {}).items()
}
self.timeout = provider_config.get("timeout", 60)
self._session: aiohttp.ClientSession | None = None
async def initialize(self):
"""异步初始化:在 ProviderManager 中被调用"""
self._session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=self.timeout)
)
try:
await self._set_model_weights()
logger.info("[GSV TTS] 初始化完成")
except Exception as e:
logger.error(f"[GSV TTS] 初始化失败:{e}")
raise
def get_session(self) -> aiohttp.ClientSession:
if not self._session or self._session.closed:
raise RuntimeError(
"[GSV TTS] Provider HTTP session is not ready or closed."
)
return self._session
async def _make_request(
self, endpoint: str, params=None, retries: int = 3
) -> bytes | None:
"""发起请求"""
for attempt in range(retries):
logger.debug(f"[GSV TTS] 请求地址:{endpoint},参数:{params}")
try:
async with self.get_session().get(endpoint, params=params) as response:
if response.status != 200:
error_text = await response.text()
raise Exception(
f"[GSV TTS] Request to {endpoint} failed with status {response.status}: {error_text}"
)
return await response.read()
except Exception as e:
if attempt < retries - 1:
logger.warning(
f"[GSV TTS] 请求 {endpoint}{attempt + 1} 次失败:{e},重试中..."
)
await asyncio.sleep(1)
else:
logger.error(f"[GSV TTS] 请求 {endpoint} 最终失败:{e}")
raise
async def _set_model_weights(self):
"""设置模型路径"""
try:
if self.gpt_weights_path:
await self._make_request(
f"{self.api_base}/set_gpt_weights",
{"weights_path": self.gpt_weights_path},
)
logger.info(f"[GSV TTS] 成功设置 GPT 模型路径:{self.gpt_weights_path}")
else:
logger.info("[GSV TTS] GPT 模型路径未配置,将使用内置 GPT 模型")
if self.sovits_weights_path:
await self._make_request(
f"{self.api_base}/set_sovits_weights",
{"weights_path": self.sovits_weights_path},
)
logger.info(
f"[GSV TTS] 成功设置 SoVITS 模型路径:{self.sovits_weights_path}"
)
else:
logger.info("[GSV TTS] SoVITS 模型路径未配置,将使用内置 SoVITS 模型")
except aiohttp.ClientError as e:
logger.error(f"[GSV TTS] 设置模型路径时发生网络错误:{e}")
except Exception as e:
logger.error(f"[GSV TTS] 设置模型路径时发生未知错误:{e}")
async def get_audio(self, text: str) -> str:
"""实现 TTS 核心方法,根据文本内容自动切换情绪"""
if not text.strip():
raise ValueError("[GSV TTS] TTS 文本不能为空")
endpoint = f"{self.api_base}/tts"
params = self.build_synthesis_params(text)
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
os.makedirs(temp_dir, exist_ok=True)
path = os.path.join(temp_dir, f"gsv_tts_{uuid.uuid4().hex}.wav")
logger.debug(f"[GSV TTS] 正在调用语音合成接口,参数:{params}")
result = await self._make_request(endpoint, params)
if isinstance(result, bytes):
with open(path, "wb") as f:
f.write(result)
return path
else:
raise Exception(f"[GSV TTS] 合成失败,输入文本:{text},错误信息:{result}")
def build_synthesis_params(self, text: str) -> dict:
"""
构建语音合成所需的参数字典。
当前仅包含默认参数 + 文本,未来可在此基础上动态添加如情绪、角色等语义控制字段。
"""
params = self.default_params.copy()
params["text"] = text
# TODO: 在此处添加情绪分析,例如 params["emotion"] = detect_emotion(text)
return params
async def terminate(self):
"""终止释放资源:在 ProviderManager 中被调用"""
if self._session and not self._session.closed:
await self._session.close()
logger.info("[GSV TTS] Session 已关闭")

View File

@@ -1,134 +0,0 @@
import os
from llmtuner.chat import ChatModel
from typing import List
from .. import Provider
from ..entities import LLMResponse
from ..func_tool_manager import FuncCall
from astrbot.core.db import BaseDatabase
from ..register import register_provider_adapter
@register_provider_adapter(
"llm_tuner", "LLMTuner 适配器, 用于装载使用 LlamaFactory 微调后的模型"
)
class LLMTunerModelLoader(Provider):
def __init__(
self,
provider_config: dict,
provider_settings: dict,
db_helper: BaseDatabase,
persistant_history=True,
default_persona=None,
) -> None:
super().__init__(
provider_config,
provider_settings,
persistant_history,
db_helper,
default_persona,
)
if not os.path.exists(provider_config["base_model_path"]) or not os.path.exists(
provider_config["adapter_model_path"]
):
raise FileNotFoundError("模型文件路径不存在。")
self.base_model_path = provider_config["base_model_path"]
self.adapter_model_path = provider_config["adapter_model_path"]
self.model = ChatModel(
{
"model_name_or_path": self.base_model_path,
"adapter_name_or_path": self.adapter_model_path,
"template": provider_config["llmtuner_template"],
"finetuning_type": provider_config["finetuning_type"],
"quantization_bit": provider_config["quantization_bit"],
}
)
self.set_model(
os.path.basename(self.base_model_path)
+ "_"
+ os.path.basename(self.adapter_model_path)
)
async def assemble_context(self, text: str, image_urls: List[str] = None):
"""
组装上下文。
"""
return {"role": "user", "content": text}
async def text_chat(
self,
prompt: str,
session_id: str = None,
image_urls: List[str] = None,
func_tool: FuncCall = None,
contexts: List = None,
system_prompt: str = None,
**kwargs,
) -> LLMResponse:
if contexts is None:
contexts = []
system_prompt = ""
new_record = {"role": "user", "content": prompt}
query_context = [*contexts, new_record]
# 提取出系统提示
system_idxs = []
for idx, context in enumerate(query_context):
if context["role"] == "system":
system_idxs.append(idx)
if "_no_save" in context:
del context["_no_save"]
for idx in reversed(system_idxs):
system_prompt += " " + query_context.pop(idx)["content"]
conf = {
"messages": query_context,
"system": system_prompt,
}
if func_tool:
tool_list = func_tool.get_func_desc_openai_style()
if tool_list:
conf["tools"] = tool_list
responses = await self.model.achat(**conf)
llm_response = LLMResponse("assistant", responses[-1].response_text)
return llm_response
async def text_chat_stream(
self,
prompt,
session_id=None,
image_urls=...,
func_tool=None,
contexts=...,
system_prompt=None,
tool_calls_result=None,
**kwargs,
):
# raise NotImplementedError("This method is not implemented yet.")
# 调用 text_chat 模拟流式
llm_response = await self.text_chat(
prompt=prompt,
session_id=session_id,
image_urls=image_urls,
func_tool=func_tool,
contexts=contexts,
system_prompt=system_prompt,
tool_calls_result=tool_calls_result,
)
llm_response.is_chunk = True
yield llm_response
llm_response.is_chunk = False
yield llm_response
async def get_current_key(self):
return "none"
async def set_key(self, key):
pass
async def get_models(self):
return [self.get_model()]

View File

@@ -9,14 +9,12 @@ import astrbot.core.message.components as Comp
from openai import AsyncOpenAI, AsyncAzureOpenAI
from openai.types.chat.chat_completion import ChatCompletion
# from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
from openai._exceptions import NotFoundError, UnprocessableEntityError
from openai.lib.streaming.chat._completions import ChatCompletionStreamState
from astrbot.core.utils.io import download_image_by_url
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.db import BaseDatabase
from astrbot.api.provider import Provider, Personality
from astrbot.api.provider import Provider
from astrbot import logger
from astrbot.core.provider.func_tool_manager import FuncCall
from typing import List, AsyncGenerator
@@ -30,17 +28,13 @@ from astrbot.core.provider.entities import LLMResponse, ToolCallsResult
class ProviderOpenAIOfficial(Provider):
def __init__(
self,
provider_config: dict,
provider_settings: dict,
db_helper: BaseDatabase,
persistant_history=True,
default_persona: Personality = None,
provider_config,
provider_settings,
default_persona = None,
) -> None:
super().__init__(
provider_config,
provider_settings,
persistant_history,
db_helper,
default_persona,
)
self.chosen_api_key = None
@@ -224,12 +218,10 @@ class ProviderOpenAIOfficial(Provider):
async def _prepare_chat_payload(
self,
prompt: str,
session_id: str = None,
image_urls: list[str] = None,
func_tool: FuncCall = None,
contexts: list = None,
system_prompt: str = None,
tool_calls_result: ToolCallsResult = None,
image_urls: list[str] | None = None,
contexts: list | None = None,
system_prompt: str | None = None,
tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None,
**kwargs,
) -> tuple:
"""准备聊天所需的有效载荷和上下文"""
@@ -246,14 +238,18 @@ class ProviderOpenAIOfficial(Provider):
# tool calls result
if tool_calls_result:
context_query.extend(tool_calls_result.to_openai_messages())
if isinstance(tool_calls_result, ToolCallsResult):
context_query.extend(tool_calls_result.to_openai_messages())
else:
for tcr in tool_calls_result:
context_query.extend(tcr.to_openai_messages())
model_config = self.provider_config.get("model_config", {})
model_config["model"] = self.get_model()
payloads = {"messages": context_query, **model_config}
return payloads, context_query, func_tool
return payloads, context_query
async def _handle_api_error(
self,
@@ -352,11 +348,9 @@ class ProviderOpenAIOfficial(Provider):
tool_calls_result=None,
**kwargs,
) -> LLMResponse:
payloads, context_query, func_tool = await self._prepare_chat_payload(
payloads, context_query = await self._prepare_chat_payload(
prompt,
session_id,
image_urls,
func_tool,
contexts,
system_prompt,
tool_calls_result,
@@ -422,11 +416,9 @@ class ProviderOpenAIOfficial(Provider):
**kwargs,
) -> AsyncGenerator[LLMResponse, None]:
"""流式对话,与服务商交互并逐步返回结果"""
payloads, context_query, func_tool = await self._prepare_chat_payload(
payloads, context_query = await self._prepare_chat_payload(
prompt,
session_id,
image_urls,
func_tool,
contexts,
system_prompt,
tool_calls_result,

View File

@@ -1,4 +1,3 @@
from astrbot.core.db import BaseDatabase
from astrbot import logger
from astrbot.core.provider.func_tool_manager import FuncCall
from typing import List
@@ -13,15 +12,11 @@ class ProviderZhipu(ProviderOpenAIOfficial):
self,
provider_config: dict,
provider_settings: dict,
db_helper: BaseDatabase,
persistant_history=True,
default_persona=None,
) -> None:
super().__init__(
provider_config,
provider_settings,
db_helper,
persistant_history,
default_persona,
)

View File

@@ -18,10 +18,12 @@ class Star(CommandParserMixin):
"""将文本转换为图片"""
return await html_renderer.render_t2i(text, return_url=return_url)
async def html_render(self, tmpl: str, data: dict, return_url=True) -> str:
async def html_render(
self, tmpl: str, data: dict, return_url=True, options: dict = None
) -> str:
"""渲染 HTML"""
return await html_renderer.render_custom_template(
tmpl, data, return_url=return_url
tmpl, data, return_url=return_url, options=options
)
async def terminate(self):

View File

@@ -7,10 +7,13 @@ from astrbot.core.config import AstrBotConfig
from .custom_filter import CustomFilter
from ..star_handler import StarHandlerMetadata
class GreedyStr(str):
"""标记指令完成其他参数接收后的所有剩余文本。"""
pass
# 标准指令受到 wake_prefix 的制约。
class CommandFilter(HandlerFilter):
"""标准指令过滤器"""
@@ -18,8 +21,8 @@ class CommandFilter(HandlerFilter):
def __init__(
self,
command_name: str,
alias: set = None,
handler_md: StarHandlerMetadata = None,
alias: set | None = None,
handler_md: StarHandlerMetadata | None = None,
parent_command_names: List[str] = [""],
):
self.command_name = command_name
@@ -110,6 +113,17 @@ class CommandFilter(HandlerFilter):
elif isinstance(param_type_or_default_val, str):
# 如果 param_type_or_default_val 是字符串,直接赋值
result[param_name] = params[i]
elif isinstance(param_type_or_default_val, bool):
# 处理布尔类型
lower_param = str(params[i]).lower()
if lower_param in ["true", "yes", "1"]:
result[param_name] = True
elif lower_param in ["false", "no", "0"]:
result[param_name] = False
else:
raise ValueError(
f"参数 {param_name} 必须是布尔值true/false, yes/no, 1/0"
)
elif isinstance(param_type_or_default_val, int):
result[param_name] = int(params[i])
elif isinstance(param_type_or_default_val, float):

View File

@@ -652,7 +652,11 @@ class PluginManager:
plugin_info = None
if plugin:
plugin_info = {"repo": plugin.repo, "readme": cleaned_content}
plugin_info = {
"repo": plugin.repo,
"readme": cleaned_content,
"name": plugin.name,
}
return plugin_info
@@ -847,6 +851,10 @@ class PluginManager:
plugin_info = None
if plugin:
plugin_info = {"repo": plugin.repo, "readme": readme_content}
plugin_info = {
"repo": plugin.repo,
"readme": readme_content,
"name": plugin.name,
}
return plugin_info

View File

@@ -1,5 +1,6 @@
import logging
import asyncio
import sys
logger = logging.getLogger("astrbot")
@@ -31,7 +32,10 @@ class PipInstaller:
logger.info(f"Pip 包管理器: pip {' '.join(args)}")
try:
process = await asyncio.create_subprocess_exec(
"pip", *args,
sys.executable,
"-m",
"pip",
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
@@ -47,6 +51,7 @@ class PipInstaller:
except FileNotFoundError:
# 没有 pip
from pip import main as pip_main
result_code = await asyncio.to_thread(pip_main, args)
# 清除 pip.main 导致的多余的 logging handlers

View File

@@ -11,7 +11,7 @@ ASTRBOT_T2I_DEFAULT_ENDPOINT = "https://t2i.soulter.top/text2img"
class NetworkRenderStrategy(RenderStrategy):
def __init__(self, base_url: str = ASTRBOT_T2I_DEFAULT_ENDPOINT) -> None:
def __init__(self, base_url: str | None = None) -> None:
super().__init__()
if not base_url:
base_url = ASTRBOT_T2I_DEFAULT_ENDPOINT
@@ -34,18 +34,22 @@ class NetworkRenderStrategy(RenderStrategy):
self.BASE_RENDER_URL += "/text2img"
async def render_custom_template(
self, tmpl_str: str, tmpl_data: dict, return_url: bool = True
self,
tmpl_str: str,
tmpl_data: dict,
return_url: bool = True,
options: dict | None = None,
) -> str:
"""使用自定义文转图模板"""
default_options = {"full_page": True, "type": "jpeg", "quality": 40}
if options:
default_options |= options
post_data = {
"tmpl": tmpl_str,
"json": return_url,
"tmpldata": tmpl_data,
"options": {
"full_page": True,
"type": "jpeg",
"quality": 40,
},
"options": default_options,
}
if return_url:
ssl_context = ssl.create_default_context(cafile=certifi.where())

View File

@@ -6,7 +6,7 @@ logger = LogManager.GetLogger(log_name="astrbot")
class HtmlRenderer:
def __init__(self, endpoint_url: str = None):
def __init__(self, endpoint_url: str | None = None):
self.network_strategy = NetworkRenderStrategy(endpoint_url)
self.local_strategy = LocalRenderStrategy()
@@ -16,19 +16,24 @@ class HtmlRenderer:
self.network_strategy.set_endpoint(endpoint_url)
async def render_custom_template(
self, tmpl_str: str, tmpl_data: dict, return_url: bool = False
self,
tmpl_str: str,
tmpl_data: dict,
return_url: bool = False,
options: dict | None = None,
):
"""使用自定义文转图模板。该方法会通过网络调用 t2i 终结点图文渲染API。
@param tmpl_str: HTML Jinja2 模板。
@param tmpl_data: jinja2 模板数据。
@param options: 渲染选项。
@return: 图片 URL 或者文件路径,取决于 return_url 参数。
@example: 参见 https://astrbot.app 插件开发部分。
"""
local = locals()
local.pop("self")
return await self.network_strategy.render_custom_template(**local)
return await self.network_strategy.render_custom_template(
tmpl_str, tmpl_data, return_url, options
)
async def render_t2i(
self, text: str, use_network: bool = True, return_url: bool = False

View File

@@ -1,9 +1,11 @@
import base64
import wave
import os
import subprocess
from io import BytesIO
import asyncio
import tempfile
from astrbot.core import logger
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
@@ -57,33 +59,89 @@ async def wav_to_tencent_silk(wav_path: str, output_path: str) -> int:
return duration
async def wav_to_tencent_silk_base64(wav_path: str) -> str:
async def convert_to_pcm_wav(input_path: str, output_path: str) -> str:
"""
WAV 文件转为 Silk并返回 Base64 字符串
默认采样率为 24000输出临时文件为 temp/output.silk
MP3 或其他音频格式转换为 PCM 16bit WAV采样率24000Hz单声道
若转换失败则抛出异常
"""
try:
from pyffmpeg import FFmpeg
ff = FFmpeg()
ff.convert(input=input_path, output=output_path)
except Exception as e:
logger.debug(f"pyffmpeg 转换失败: {e}, 尝试使用 ffmpeg 命令行进行转换")
p = await asyncio.create_subprocess_exec(
"ffmpeg",
"-y",
"-i",
input_path,
"-acodec",
"pcm_s16le",
"-ar",
"24000",
"-ac",
"1",
"-af",
"apad=pad_dur=2",
"-fflags",
"+genpts",
"-hide_banner",
output_path,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, stderr = await p.communicate()
logger.info(f"[FFmpeg] stdout: {stdout.decode().strip()}")
logger.debug(f"[FFmpeg] stderr: {stderr.decode().strip()}")
logger.info(f"[FFmpeg] return code: {p.returncode}")
if os.path.exists(output_path) and os.path.getsize(output_path) > 0:
return output_path
else:
raise RuntimeError("生成的WAV文件不存在或为空")
async def audio_to_tencent_silk_base64(audio_path: str) -> tuple[str, float]:
"""
将 MP3/WAV 文件转为 Tencent Silk 并返回 base64 编码与时长(秒)。
参数:
- wav_path: 输入 .wav 文件路径(需为 PCM 16bit
- audio_path: 输入音频文件路径(.mp3 或 .wav
返回:
- Base64 编码的 Silk 字符串
- silk_b64: Base64 编码的 Silk 字符串
- duration: 音频时长(秒)
"""
try:
import pilk
except ImportError as e:
raise Exception("pysilk 模块未安装,请安装 pysilk") from e
raise Exception("未安装 pysilk,请执行: pip install pysilk") from e
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
os.makedirs(temp_dir, exist_ok=True)
with wave.open(wav_path, "rb") as wav:
rate = wav.getframerate()
# 是否需要转换为 WAV
ext = os.path.splitext(audio_path)[1].lower()
temp_wav = tempfile.NamedTemporaryFile(
suffix=".wav", delete=False, dir=temp_dir
).name
with tempfile.NamedTemporaryFile(
if ext != ".wav":
await convert_to_pcm_wav(audio_path, temp_wav)
# 删除原文件
os.remove(audio_path)
wav_path = temp_wav
else:
wav_path = audio_path
with wave.open(wav_path, "rb") as wav_file:
rate = wav_file.getframerate()
silk_path = tempfile.NamedTemporaryFile(
suffix=".silk", delete=False, dir=temp_dir
) as tmp_file:
silk_path = tmp_file.name
).name
try:
duration = await asyncio.to_thread(
@@ -96,5 +154,7 @@ async def wav_to_tencent_silk_base64(wav_path: str) -> str:
return silk_b64, duration # 已是秒
finally:
if os.path.exists(wav_path) and wav_path != audio_path:
os.remove(wav_path)
if os.path.exists(silk_path):
os.remove(silk_path)

View File

@@ -3,7 +3,7 @@ import datetime
import asyncio
from .route import Route, Response, RouteContext
from quart import request
from astrbot.core import WEBUI_SK, DEMO_MODE
from astrbot.core import DEMO_MODE
from astrbot import logger
@@ -80,5 +80,8 @@ class AuthRoute(Route):
"username": username,
"exp": datetime.datetime.utcnow() + datetime.timedelta(days=7),
}
token = jwt.encode(payload, WEBUI_SK, algorithm="HS256")
jwt_token = self.config["dashboard"].get("jwt_secret", None)
if not jwt_token:
raise ValueError("JWT secret is not set in the cmd_config.")
token = jwt.encode(payload, jwt_token, algorithm="HS256")
return token

View File

@@ -41,10 +41,15 @@ class StatRoute(Route):
await self.core_lifecycle.restart()
return Response().ok().__dict__
def format_sec(self, sec: int):
m, s = divmod(sec, 60)
h, m = divmod(m, 60)
return f"{h}小时{m}{s}"
def _get_running_time_components(self, total_seconds: int):
"""将总秒数转换为时分秒组件"""
minutes, seconds = divmod(total_seconds, 60)
hours, minutes = divmod(minutes, 60)
return {
"hours": hours,
"minutes": minutes,
"seconds": seconds
}
def is_default_cred(self):
username = self.config["dashboard"]["username"]
@@ -107,6 +112,11 @@ class StatRoute(Route):
}
plugin_info.append(info)
# 计算运行时长组件
running_time = self._get_running_time_components(
int(time.time()) - self.core_lifecycle.start_time
)
stat_dict.update(
{
"platform": self.db_helper.get_grouped_base_stats(
@@ -119,9 +129,7 @@ class StatRoute(Route):
"plugin_count": len(plugins),
"plugins": plugin_info,
"message_time_series": message_time_based_stats,
"running": self.format_sec(
int(time.time()) - self.core_lifecycle.start_time
),
"running": running_time, # 现在返回时间组件而不是格式化的字符串
"memory": {
"process": psutil.Process().memory_info().rss >> 20,
"system": psutil.virtual_memory().total >> 20,

View File

@@ -10,7 +10,7 @@ from quart.logging import default_handler
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from .routes import *
from .routes.route import RouteContext, Response
from astrbot.core import logger, WEBUI_SK
from astrbot.core import logger
from astrbot.core.db import BaseDatabase
from astrbot.core.utils.io import get_local_ip_addresses
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
@@ -62,6 +62,8 @@ class AstrBotDashboard:
self.shutdown_event = shutdown_event
self._init_jwt_secret()
async def srv_plug_route(self, subpath, *args, **kwargs):
"""
插件路由
@@ -88,7 +90,7 @@ class AstrBotDashboard:
if token.startswith("Bearer "):
token = token[7:]
try:
payload = jwt.decode(token, WEBUI_SK, algorithms=["HS256"])
payload = jwt.decode(token, self._jwt_secret, algorithms=["HS256"])
g.username = payload["username"]
except jwt.ExpiredSignatureError:
r = jsonify(Response().error("Token 过期").__dict__)
@@ -140,6 +142,15 @@ class AstrBotDashboard:
except Exception as e:
return f"获取进程信息失败: {str(e)}"
def _init_jwt_secret(self):
if not self.config.get("dashboard", {}).get("jwt_secret", None):
# 如果没有设置 JWT 密钥,则生成一个新的密钥
jwt_secret = os.urandom(32).hex()
self.config["dashboard"]["jwt_secret"] = jwt_secret
self.config.save_config()
logger.info("Initialized random JWT secret for dashboard.")
self._jwt_secret = self.config["dashboard"]["jwt_secret"]
def run(self):
ip_addr = []
if p := os.environ.get("DASHBOARD_PORT"):

18
changelogs/v3.5.16.md Normal file
View File

@@ -0,0 +1,18 @@
# What's Changed
1. 新增:支持接入 Slack
2. 新增:支持接入 Discord
3. 新增:支持接入 KOOK
4. 新增:支持接入 VoceChat
5. 新增:微信客服支持语音的收发
6. 新增:实现 WebUI 的 i18n 模型WebUI 现已支持 English。
7. 新增:支持接入 GPT SoVITS
8. 优化:支持通过引用 Bot 消息来唤醒 Bot
9. 优化WebUI 滚动条、侧边栏样式优化
10. 优化WebUI ChatBox 的样式优化,添加切换夜间模式按钮
11. 优化WebUI Chat 页面的 SSE 连接优化及一些其他样式优化
12. 优化:钉钉发送图片支持使用 AstrBot 自带的文件服务器
13. 优化:新建服务提供商时,如果没有添加 Key会弹出警告提示框
14. 修复会话隔离模式下WeChatPadPro 会话 ID 为自身 ID
15. 修复会话隔离模式下WeChatPadPro 无法回复群聊消息
16. 修复:使用 uvx 启动 AstrBot 时,插件依赖无法正常安装

20
changelogs/v3.5.17.md Normal file
View File

@@ -0,0 +1,20 @@
# What's Changed
> 对 v3.5.16 的修订版本
1. 新增:支持接入 Slack
2. 新增:支持接入 Discord
3. 新增:支持接入 KOOK
4. 新增:支持接入 VoceChat
5. 新增:微信客服支持语音的收发
6. 新增:实现 WebUI 的 i18n 模型WebUI 现已支持 English。
7. 新增:支持接入 GPT SoVITS
8. 优化:支持通过引用 Bot 消息来唤醒 Bot
9. 优化WebUI 滚动条、侧边栏样式优化
10. 优化WebUI ChatBox 的样式优化,添加切换夜间模式按钮
11. 优化WebUI Chat 页面的 SSE 连接优化及一些其他样式优化
12. 优化:钉钉发送图片支持使用 AstrBot 自带的文件服务器
13. 优化:新建服务提供商时,如果没有添加 Key会弹出警告提示框
14. 修复会话隔离模式下WeChatPadPro 会话 ID 为自身 ID
15. 修复会话隔离模式下WeChatPadPro 无法回复群聊消息
16. 修复:使用 uvx 启动 AstrBot 时,插件依赖无法正常安装

18
changelogs/v3.5.18.md Normal file
View File

@@ -0,0 +1,18 @@
# What's Changed
> 重构了大模型请求部分,如果发现此部分使用时有问题请提交 issue
1. 修复: 安装插件按钮被删除、无法自定义安装插件
2. 修复: 环境变量中的代理地址无法生效
1. 修复: randomize jwt secret
2. 修复: 在 Node 消息段发送简单文本信息的问题
1. 修复: QQ 官方机器人适配器使用 SessionController(会话控制)功能时机器人回复消息无法发送到聊天平台
4. 修复: Discord 适配器无法优雅重载
1. 修复: Telegram 适配器无法主动回复
1. 修复: 仪表盘的『插件配置』中不显示代码编辑器
3. 新增: Gemini TTS API
1. 新增: 允许 html_render 方法传入 Playwright.screenshot 配置参数
1. 优化: 修复 CommandFilter 支持对布尔类型进行解析
4. 新增: WechatPadPro 发送 TTS 时 添加对 MP3 格式音频支持
1. 重构: 将大模型请求部分抽象成 AgentRunner提高可读性和可扩展性工具调用结果支持持久化保存到数据库完善 Agent 的多轮工具调用能力。
1. 移除: LLMTuner 模型提供商适配器。请使用 Ollama 来加载微调模型

View File

@@ -31,6 +31,7 @@
"vee-validate": "4.11.3",
"vite-plugin-vuetify": "1.0.2",
"vue": "3.3.4",
"vue-i18n": "^11.1.5",
"vue-router": "4.2.4",
"vue3-apexcharts": "1.4.4",
"vue3-print-nb": "0.1.4",
@@ -41,11 +42,11 @@
"@mdi/font": "7.2.96",
"@rushstack/eslint-patch": "1.3.3",
"@types/chance": "1.1.3",
"@types/node": "20.5.7",
"@types/node": "^20.5.7",
"@vitejs/plugin-vue": "4.3.3",
"@vue/eslint-config-prettier": "8.0.0",
"@vue/eslint-config-typescript": "11.0.3",
"@vue/tsconfig": "0.4.0",
"@vue/tsconfig": "^0.4.0",
"eslint": "8.48.0",
"eslint-plugin-vue": "9.17.0",
"prettier": "3.0.2",

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="2500" height="2500"><path fill="#4285F4" d="M1024 155.733C1024 70.4 953.6 0 868.267 0H155.733C70.4 0 0 70.4 0 155.733v712.534C0 953.6 70.4 1024 155.733 1024h712.534C953.6 1024 1024 953.6 1024 868.267V155.733z"/><path fill="#FFF" d="M810.667 422.4c-2.134 6.4-4.267 14.933-8.534 23.467C774.4 505.6 701.867 620.8 701.867 620.8l-21.334 36.267h102.4L588.8 915.2l44.8-174.933h-78.933l27.733-115.2c-23.467 6.4-49.067 12.8-81.067 23.466 0 0-42.666 25.6-121.6-46.933 0 0-53.333-46.933-23.466-59.733 12.8-4.267 64-10.667 104.533-17.067 55.467-6.4 87.467-10.667 87.467-10.667s-168.534 2.134-206.934-4.266c-40.533-6.4-89.6-72.534-100.266-132.267 0 0-17.067-32 36.266-17.067s268.8 59.734 268.8 59.734-281.6-87.467-300.8-108.8c-19.2-21.334-55.466-115.2-51.2-172.8 0 0 2.134-14.934 17.067-10.667 0 0 209.067 96 352 147.2 140.8 53.333 264.533 81.067 247.467 147.2z"/></svg>

After

Width:  |  Height:  |  Size: 928 B

View File

@@ -0,0 +1 @@
<svg height="1828" viewBox="-10.63 -.07077792 823.87 610.06955549" width="2500" xmlns="http://www.w3.org/2000/svg"><path d="m678.27 51.62c90.35 132.84 134.97 282.68 118.29 455.18-.07.73-.45 1.4-1.05 1.84-68.42 50.24-134.71 80.73-200.07 100.95a2.55 2.55 0 0 1 -2.81-.95c-15.1-21.01-28.82-43.16-40.84-66.42-.69-1.37-.06-3.02 1.36-3.56 21.79-8.21 42.51-18.05 62.44-29.7 1.57-.92 1.67-3.17.22-4.25-4.23-3.14-8.42-6.44-12.43-9.74-.75-.61-1.76-.73-2.61-.32-129.39 59.75-271.13 59.75-402.05 0-.85-.38-1.86-.25-2.59.35-4 3.3-8.2 6.57-12.39 9.71-1.45 1.08-1.33 3.33.25 4.25 19.93 11.43 40.65 21.49 62.41 29.74 1.41.54 2.08 2.15 1.38 3.52-11.76 23.29-25.48 45.44-40.86 66.45-.67.85-1.77 1.24-2.81.92-65.05-20.22-131.34-50.71-199.76-100.95-.57-.44-.98-1.14-1.04-1.87-13.94-149.21 14.47-300.29 118.18-455.18.25-.41.63-.73 1.07-.92 51.03-23.42 105.7-40.65 162.84-50.49 1.04-.16 2.08.32 2.62 1.24 7.06 12.5 15.13 28.53 20.59 41.63 60.23-9.2 121.4-9.2 182.89 0 5.46-12.82 13.25-29.13 20.28-41.63a2.47 2.47 0 0 1 2.62-1.24c57.17 9.87 111.84 27.1 162.83 50.49.45.19.82.51 1.04.95zm-339.04 283.7c.63-44.11-31.53-80.61-71.9-80.61-40.04 0-71.89 36.18-71.89 80.61 0 44.42 32.48 80.6 71.89 80.6 40.05 0 71.9-36.18 71.9-80.6zm265.82 0c.63-44.11-31.53-80.61-71.89-80.61-40.05 0-71.9 36.18-71.9 80.61 0 44.42 32.48 80.6 71.9 80.6 40.36 0 71.89-36.18 71.89-80.6z" fill="#5865f2"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

@@ -0,0 +1 @@
<svg enable-background="new 0 0 2447.6 2452.5" viewBox="0 0 2447.6 2452.5" xmlns="http://www.w3.org/2000/svg"><g clip-rule="evenodd" fill-rule="evenodd"><path d="m897.4 0c-135.3.1-244.8 109.9-244.7 245.2-.1 135.3 109.5 245.1 244.8 245.2h244.8v-245.1c.1-135.3-109.5-245.1-244.9-245.3.1 0 .1 0 0 0m0 654h-652.6c-135.3.1-244.9 109.9-244.8 245.2-.2 135.3 109.4 245.1 244.7 245.3h652.7c135.3-.1 244.9-109.9 244.8-245.2.1-135.4-109.5-245.2-244.8-245.3z" fill="#36c5f0"/><path d="m2447.6 899.2c.1-135.3-109.5-245.1-244.8-245.2-135.3.1-244.9 109.9-244.8 245.2v245.3h244.8c135.3-.1 244.9-109.9 244.8-245.3zm-652.7 0v-654c.1-135.2-109.4-245-244.7-245.2-135.3.1-244.9 109.9-244.8 245.2v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.3z" fill="#2eb67d"/><path d="m1550.1 2452.5c135.3-.1 244.9-109.9 244.8-245.2.1-135.3-109.5-245.1-244.8-245.2h-244.8v245.2c-.1 135.2 109.5 245 244.8 245.2zm0-654.1h652.7c135.3-.1 244.9-109.9 244.8-245.2.2-135.3-109.4-245.1-244.7-245.3h-652.7c-135.3.1-244.9 109.9-244.8 245.2-.1 135.4 109.4 245.2 244.7 245.3z" fill="#ecb22e"/><path d="m0 1553.2c-.1 135.3 109.5 245.1 244.8 245.2 135.3-.1 244.9-109.9 244.8-245.2v-245.2h-244.8c-135.3.1-244.9 109.9-244.8 245.2zm652.7 0v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.2v-653.9c.2-135.3-109.4-245.1-244.7-245.3-135.4 0-244.9 109.8-244.8 245.1 0 0 0 .1 0 0" fill="#e01e5a"/></g></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1 @@
<svg id="svg2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" width="2500" height="2500"><style>.st0{fill:url(#path2995-1-0_1_)}.st1{fill:#c8daea}.st2{fill:#a9c9dd}.st3{fill:url(#path2991_1_)}</style><linearGradient id="path2995-1-0_1_" gradientUnits="userSpaceOnUse" x1="-683.305" y1="534.845" x2="-693.305" y2="511.512" gradientTransform="matrix(6 0 0 -6 4255 3247)"><stop offset="0" stop-color="#37aee2"/><stop offset="1" stop-color="#1e96c8"/></linearGradient><path id="path2995-1-0" class="st0" d="M240 120c0 66.3-53.7 120-120 120S0 186.3 0 120 53.7 0 120 0s120 53.7 120 120z"/><path id="path2993" class="st1" d="M98 175c-3.9 0-3.2-1.5-4.6-5.2L82 132.2 152.8 88l8.3 2.2-6.9 18.8L98 175z"/><path id="path2989" class="st2" d="M98 175c3 0 4.3-1.4 6-3 2.6-2.5 36-35 36-35l-20.5-5-19 12-2.5 30v1z"/><linearGradient id="path2991_1_" gradientUnits="userSpaceOnUse" x1="128.991" y1="118.245" x2="153.991" y2="78.245" gradientTransform="matrix(1 0 0 -1 0 242)"><stop offset="0" stop-color="#eff7fc"/><stop offset="1" stop-color="#fff"/></linearGradient><path id="path2991" class="st3" d="M100 144.4l48.4 35.7c5.5 3 9.5 1.5 10.9-5.1L179 82.2c2-8.1-3.1-11.7-8.4-9.3L55 117.5c-7.9 3.2-7.8 7.6-1.4 9.5l29.7 9.3L152 93c3.2-2 6.2-.9 3.8 1.3L100 144.4z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -5,8 +5,8 @@
<v-card-text>{{ message }}</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="gray" @click="handleCancel">取消</v-btn>
<v-btn color="red" @click="handleConfirm">确定</v-btn>
<v-btn color="gray" @click="handleCancel">{{ t('core.common.dialog.cancelButton') }}</v-btn>
<v-btn color="red" @click="handleConfirm">{{ t('core.common.dialog.confirmButton') }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
@@ -14,6 +14,9 @@
<script setup>
import { ref } from "vue";
import { useI18n } from '@/i18n/composables';
const { t } = useI18n();
const isOpen = ref(false);
const title = ref("");
@@ -21,8 +24,8 @@ const message = ref("");
let resolvePromise = null; // ✅ 确保 Promise 句柄可用
const open = (options) => {
title.value = options.title || "确认操作";
message.value = options.message || "你确定要执行此操作吗?";
title.value = options.title || t('core.common.dialog.confirmTitle');
message.value = options.message || t('core.common.dialog.confirmMessage');
isOpen.value = true;
return new Promise((resolve) => {

View File

@@ -1,6 +1,25 @@
<script setup>
import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
import { ref } from 'vue'
import ListConfigItem from './ListConfigItem.vue'
import { useI18n } from '@/i18n/composables'
defineProps({
metadata: {
type: Object,
required: true
},
iterable: {
type: Object,
required: true
},
metadataKey: {
type: String,
required: true
}
})
const { t } = useI18n()
const dialog = ref(false)
const currentEditingKey = ref('')
@@ -107,7 +126,7 @@ function saveEditedContent() {
color="primary"
class="editor-fullscreen-btn"
@click="openEditorDialog(key, iterable, metadata[metadataKey].items[key]?.editor_theme, metadata[metadataKey].items[key]?.editor_language)"
title="全屏编辑"
:title="t('core.common.editor.fullscreen')"
>
<v-icon>mdi-fullscreen</v-icon>
</v-btn>
@@ -288,10 +307,10 @@ function saveEditedContent() {
<v-btn icon @click="dialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
<v-toolbar-title>编辑内容 - {{ currentEditingKey }}</v-toolbar-title>
<v-toolbar-title>{{ t('core.common.editor.editingTitle') }} - {{ currentEditingKey }}</v-toolbar-title>
<v-spacer></v-spacer>
<v-toolbar-items>
<v-btn variant="text" @click="saveEditedContent">保存</v-btn>
<v-btn variant="text" @click="saveEditedContent">{{ t('core.common.save') }}</v-btn>
</v-toolbar-items>
</v-toolbar>
<v-card-text class="pa-0">
@@ -307,30 +326,7 @@ function saveEditedContent() {
</v-dialog>
</template>
<script>
import ListConfigItem from './ListConfigItem.vue';
export default {
name: 'AstrBotConfig',
components: {
ListConfigItem
},
props: {
metadata: {
type: Object,
required: true
},
iterable: {
type: Object,
required: true
},
metadataKey: {
type: String,
required: true
}
}
}
</script>
<style scoped>
.config-section {
@@ -459,4 +455,4 @@ export default {
padding: 4px;
}
}
</style>
</style>

View File

@@ -51,7 +51,7 @@ export default {
props: {
historyNum: {
type: String,
default: -1
default: "-1"
},
showLevelBtns: {
type: Boolean,

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, computed, inject } from 'vue';
import {useCustomizerStore} from "@/stores/customizer";
import { useModuleI18n } from '@/i18n/composables';
const props = defineProps({
extension: {
@@ -31,6 +32,9 @@ const emit = defineEmits([
const reveal = ref(false);
// 国际化
const { tm } = useModuleI18n('features/extension');
// 操作函数
const configure = () => {
emit('configure', props.extension);
@@ -47,13 +51,13 @@ const reloadExtension = () => {
const $confirm = inject("$confirm");
const uninstallExtension = async () => {
if (typeof $confirm !== "function") {
console.error("$confirm 未正确注册");
console.error(tm("card.errors.confirmNotRegistered"));
return;
}
const confirmed = await $confirm({
title: "删除确认",
message: "你确定要删除当前插件吗?",
title: tm("dialogs.uninstall.title"),
message: tm("dialogs.uninstall.message"),
});
if (confirmed) {
@@ -90,13 +94,13 @@ const viewReadme = () => {
<template v-slot:activator="{ props: tooltipProps }">
<v-icon v-bind="tooltipProps" color="warning" class="ml-2" icon="mdi-update" size="small"></v-icon>
</template>
<span>有新版本可用: {{ extension.online_version }}</span>
<span>{{ tm("card.status.hasUpdate") }}: {{ extension.online_version }}</span>
</v-tooltip>
<v-tooltip location="top" v-if="!extension.activated && !marketMode">
<template v-slot:activator="{ props: tooltipProps }">
<v-icon v-bind="tooltipProps" color="error" class="ml-2" icon="mdi-cancel" size="small"></v-icon>
</template>
<span>该插件已经被禁用</span>
<span>{{ tm("card.status.disabled") }}</span>
</v-tooltip>
</p>
@@ -111,7 +115,7 @@ const viewReadme = () => {
</v-chip>
<v-chip color="primary" label size="small" class="ml-2" v-if="extension.handlers?.length">
<v-icon icon="mdi-cogs" start></v-icon>
{{ extension.handlers?.length }}个行为
{{ extension.handlers?.length }}{{ tm("card.status.handlersCount") }}
</v-chip>
</div>
@@ -127,16 +131,16 @@ const viewReadme = () => {
borderRadius: '8px',
objectFit: 'cover',
objectPosition: 'center'
}" alt="logo" />
}" :alt="tm('card.alt.logo')" />
</div>
</v-card-text>
<v-card-actions style="margin-left: 0px; gap: 2px;">
<v-btn color="teal-accent-4" text="查看文档" variant="text" @click="viewReadme"></v-btn>
<v-btn v-if="!marketMode" color="teal-accent-4" text="操作" variant="text" @click="reveal = true"></v-btn>
<v-btn v-if="marketMode && !extension?.installed" color="teal-accent-4" text="安装" variant="text"
<v-btn color="teal-accent-4" :text="tm('buttons.viewDocs')" variant="text" @click="viewReadme"></v-btn>
<v-btn v-if="!marketMode" color="teal-accent-4" :text="tm('buttons.actions')" variant="text" @click="reveal = true"></v-btn>
<v-btn v-if="marketMode && !extension?.installed" color="teal-accent-4" :text="tm('buttons.install')" variant="text"
@click="emit('install', extension)"></v-btn>
<v-btn v-if="marketMode && extension?.installed" color="teal-accent-4" text="已安装" variant="text" disabled></v-btn>
<v-btn v-if="marketMode && extension?.installed" color="teal-accent-4" :text="tm('status.installed')" variant="text" disabled></v-btn>
</v-card-actions>
<v-expand-transition v-if="!marketMode">
@@ -145,7 +149,7 @@ const viewReadme = () => {
<v-card-text style="overflow-y: auto;">
<div class="d-flex align-center mb-4">
<img v-if="extension.logo" :src="extension.logo"
style="height: 50px; width: 50px; border-radius: 8px; margin-right: 16px;" alt="扩展图标" />
style="height: 50px; width: 50px; border-radius: 8px; margin-right: 16px;" :alt="tm('card.alt.extensionIcon')" />
<h3>{{ extension.name }}</h3>
</div>
@@ -159,39 +163,39 @@ const viewReadme = () => {
}">
<v-btn prepend-icon="mdi-cog" color="primary" variant="tonal" @click="configure"
:block="$vuetify.display.xs">
插件配置
{{ tm("card.actions.pluginConfig") }}
</v-btn>
<v-btn prepend-icon="mdi-delete" color="error" variant="tonal" @click="uninstallExtension"
:block="$vuetify.display.xs">
卸载插件
{{ tm("card.actions.uninstallPlugin") }}
</v-btn>
<v-btn prepend-icon="mdi-reload" color="primary" variant="tonal" @click="reloadExtension"
:block="$vuetify.display.xs">
重载插件
{{ tm("card.actions.reloadPlugin") }}
</v-btn>
<v-btn :prepend-icon="extension.activated ? 'mdi-cancel' : 'mdi-check-circle'"
:color="extension.activated ? 'error' : 'success'" variant="tonal" @click="toggleActivation"
:block="$vuetify.display.xs">
{{ extension.activated ? '禁用' : '启用' }}插件
{{ extension.activated ? tm('buttons.disable') : tm('buttons.enable') }}{{ tm("card.actions.togglePlugin") }}
</v-btn>
<v-btn prepend-icon="mdi-cogs" color="info" variant="tonal" @click="viewHandlers"
:block="$vuetify.display.xs">
查看行为 ({{ extension.handlers.length }})
{{ tm("card.actions.viewHandlers") }} ({{ extension.handlers.length }})
</v-btn>
<v-btn prepend-icon="mdi-update" color="primary" variant="tonal" :disabled="!extension?.has_update "
@click="updateExtension" :block="$vuetify.display.xs">
更新到 {{ extension.online_version || extension.version }}
{{ tm("card.actions.updateTo") }} {{ extension.online_version || extension.version }}
</v-btn>
</div>
</v-card-text>
<v-card-actions class="pt-0 d-flex justify-center">
<v-btn color="teal-accent-4" text="返回" variant="text" @click="reveal = false"></v-btn>
<v-btn color="teal-accent-4" :text="tm('buttons.back')" variant="text" @click="reveal = false"></v-btn>
</v-card-actions>
</v-card>
</v-expand-transition>

View File

@@ -3,7 +3,7 @@
<v-row v-if="items.length === 0">
<v-col cols="12" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1">{{ emptyIcon }}</v-icon>
<p class="text-grey mt-4">{{ emptyText }}</p>
<p class="text-grey mt-4">{{ displayEmptyText }}</p>
</v-col>
</v-row>
@@ -24,7 +24,7 @@
@update:model-value="toggleEnabled(item)"
></v-switch>
</template>
<span>{{ getItemEnabled(item) ? '已启用' : '已禁用' }}</span>
<span>{{ getItemEnabled(item) ? t('core.common.itemCard.enabled') : t('core.common.itemCard.disabled') }}</span>
</v-tooltip>
</v-card-title>
@@ -43,7 +43,7 @@
prepend-icon="mdi-delete"
@click="$emit('delete', item)"
>
删除
{{ t('core.common.itemCard.delete') }}
</v-btn>
<v-btn
variant="text"
@@ -52,7 +52,7 @@
prepend-icon="mdi-pencil"
@click="$emit('edit', item)"
>
编辑
{{ t('core.common.itemCard.edit') }}
</v-btn>
</v-card-actions>
</v-card>
@@ -62,8 +62,14 @@
</template>
<script>
import { useI18n } from '@/i18n/composables';
export default {
name: 'ItemCardGrid',
setup() {
const { t } = useI18n();
return { t };
},
props: {
items: {
type: Array,
@@ -83,10 +89,15 @@ export default {
},
emptyText: {
type: String,
default: '暂无数据'
default: null
}
},
emits: ['toggle-enabled', 'delete', 'edit'],
computed: {
displayEmptyText() {
return this.emptyText || this.t('core.common.itemCard.noData');
}
},
methods: {
getItemTitle(item) {
return item[this.titleField];

View File

@@ -0,0 +1,158 @@
<template>
<v-menu offset="12" location="bottom center">
<template v-slot:activator="{ props: activatorProps }">
<v-btn
v-bind="activatorProps"
:variant="(props.variant === 'header' || props.variant === 'chatbox') ? 'flat' : 'text'"
:color="(props.variant === 'header' || props.variant === 'chatbox') ? 'var(--v-theme-surface)' : undefined"
:rounded="(props.variant === 'header' || props.variant === 'chatbox') ? 'sm' : undefined"
icon
size="small"
:class="['language-switcher', `language-switcher--${props.variant}`, (props.variant === 'header' || props.variant === 'chatbox') ? 'action-btn' : '']"
>
<v-icon
size="18"
:color="props.variant === 'default' ? (useCustomizerStore().uiTheme === 'PurpleTheme' ? '#5e35b1' : '#d7c5fa') : undefined"
>
mdi-translate
</v-icon>
<v-tooltip activator="parent" location="top">
{{ t('core.common.language') }}
</v-tooltip>
</v-btn>
</template>
<v-card class="language-dropdown" elevation="8" rounded="lg">
<v-list density="compact" class="pa-1">
<v-list-item
v-for="lang in languages"
:key="lang.code"
:value="lang.code"
@click="changeLanguage(lang.code)"
:class="{ 'v-list-item--active': currentLocale === lang.code, 'language-item-selected': currentLocale === lang.code }"
class="language-item"
rounded="md"
>
<template v-slot:prepend>
<span class="language-flag">{{ lang.flag }}</span>
</template>
<v-list-item-title>{{ lang.name }}</v-list-item-title>
</v-list-item>
</v-list>
</v-card>
</v-menu>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n, useLanguageSwitcher } from '@/i18n/composables'
import { useCustomizerStore } from '@/stores/customizer'
import type { Locale } from '@/i18n/types'
// 定义props来控制样式变体
const props = withDefaults(defineProps<{
variant?: 'default' | 'header' | 'chatbox'
}>(), {
variant: 'default'
})
// 使用新的i18n系统
const { t } = useI18n()
const { languageOptions, currentLanguage, switchLanguage, locale } = useLanguageSwitcher()
const languages = computed(() =>
languageOptions.value.map(lang => ({
code: lang.value,
name: lang.label,
flag: lang.flag
}))
)
const currentLocale = computed(() => locale.value)
const changeLanguage = async (langCode: string) => {
await switchLanguage(langCode as Locale)
}
</script>
<style scoped>
.language-flag {
font-size: 16px;
margin-right: 8px;
}
/* 默认变体样式 - 圆形按钮用于登录页 */
.language-switcher--default {
margin: 0 4px;
transition: all 0.3s ease;
border-radius: 50% !important;
min-width: 32px !important;
width: 32px !important;
height: 32px !important;
}
.language-switcher--default:hover {
transform: scale(1.05);
background: rgba(94, 53, 177, 0.08) !important;
}
/* Header变体样式 - 完全继承Vuetify和action-btn的默认样式 */
.language-switcher--header {
/* action-btn类已经处理了margin-right: 6px不需要额外样式 */
}
/* ChatBox变体样式 - 与Header保持一致 */
.language-switcher--chatbox {
/* 继承action-btn样式与工具栏主题按钮保持一致 */
}
/* 深色模式下的悬停效果仅对default变体 */
:deep(.v-theme--PurpleThemeDark) .language-switcher--default:hover {
background: rgba(114, 46, 209, 0.12) !important;
}
.language-dropdown {
min-width: 100px;
width: fit-content;
border: 1px solid rgba(94, 53, 177, 0.15) !important;
background: #f8f6fc !important;
backdrop-filter: blur(10px);
}
/* 深色模式下的下拉框样式 */
:deep(.v-theme--PurpleThemeDark) .language-dropdown {
background: #2a2733 !important;
border: 1px solid rgba(110, 60, 180, 0.692) !important;
}
.language-item {
margin: 2px 0;
transition: all 0.2s ease;
}
.language-item:hover {
background: rgba(94, 53, 177, 0.08) !important;
}
.language-item-selected {
background: rgba(94, 53, 177, 0.15) !important;
font-weight: 500;
}
.language-item-selected:hover {
background: rgba(94, 53, 177, 0.2) !important;
}
/* 深色模式下的列表项悬停效果 */
:deep(.v-theme--PurpleThemeDark) .language-item:hover {
background: rgba(114, 46, 209, 0.12) !important;
}
:deep(.v-theme--PurpleThemeDark) .language-item-selected {
background: rgba(114, 46, 209, 0.2) !important;
}
:deep(.v-theme--PurpleThemeDark) .language-item-selected:hover {
background: rgba(114, 46, 209, 0.25) !important;
}
</style>

View File

@@ -37,11 +37,11 @@
</v-list-item>
</v-list>
<div style="display: flex; align-items: center;">
<v-text-field v-model="newItem" label="添加新项,按回车确认添加" @keyup.enter="addItem" clearable dense hide-details
<v-text-field v-model="newItem" :label="t('core.common.list.addItemPlaceholder')" @keyup.enter="addItem" clearable dense hide-details
variant="outlined" density="compact"></v-text-field>
<v-btn @click="addItem" text variant="tonal">
<v-icon>mdi-plus</v-icon>
添加
{{ t('core.common.list.addButton') }}
</v-btn>
</div>
@@ -49,8 +49,14 @@
</template>
<script>
import { useI18n } from '@/i18n/composables';
export default {
name: 'ListConfigItem',
setup() {
const { t } = useI18n();
return { t };
},
props: {
value: {
type: Array,

View File

@@ -5,10 +5,13 @@
<img width="110" src="@/assets/images/astrbot_logo_mini.webp" alt="AstrBot Logo">
</div>
<div class="logo-text">
<h2 class="text-secondary">{{ title }}</h2>
<h2
:style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#5e35b1' : '#d7c5fa'}"
v-html="formatTitle(title || t('core.header.logoTitle'))"
></h2>
<!-- 父子组件传递css变量可能会出错暂时使用十六进制颜色值 -->
<h4 :style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#000000aa' : '#ffffffcc'}"
class="hint-text">{{ subtitle }}</h4>
class="hint-text">{{ subtitle || t('core.header.accountDialog.title') }}</h4>
</div>
</div>
</div>
@@ -16,14 +19,27 @@
<script setup lang="ts">
import { useCustomizerStore } from "@/stores/customizer";
import { useI18n } from '@/i18n/composables';
const { t } = useI18n();
const props = withDefaults(defineProps<{
title?: string;
subtitle?: string;
}>(), {
title: 'AstrBot 仪表盘',
subtitle: '欢迎使用'
title: '', // 默认为空,组件会使用翻译值
subtitle: ''
})
// 智能格式化标题,在小屏幕上允许在合适位置换行
const formatTitle = (title: string) => {
// 如果标题包含 "AstrBot" 和其他文字,在它们之间添加换行机会
if (title.includes('AstrBot ') || title.includes('AstrBot')) {
// 处理 "AstrBot 仪表盘" 或 "AstrBot Dashboard" 等格式
return title.replace(/(AstrBot)\s+(.+)/, '$1<wbr> $2');
}
return title;
}
</script>
<style scoped>
@@ -40,6 +56,8 @@ const props = withDefaults(defineProps<{
align-items: center;
gap: 20px;
padding: 10px;
max-width: 100%;
overflow: visible;
}
.logo-image {
@@ -60,6 +78,8 @@ const props = withDefaults(defineProps<{
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 0;
flex: 1;
}
.logo-text h2 {
@@ -67,6 +87,15 @@ const props = withDefaults(defineProps<{
font-size: 1.8rem;
font-weight: 600;
letter-spacing: 0.5px;
white-space: nowrap;
min-width: fit-content;
}
/* 在小屏幕上允许在指定位置换行 */
@media (max-width: 420px) {
.logo-text h2 {
line-height: 1.3;
}
}
.logo-text h4 {
@@ -74,5 +103,25 @@ const props = withDefaults(defineProps<{
font-size: 1rem;
font-weight: 400;
letter-spacing: 0.3px;
white-space: nowrap;
}
/* 响应式处理 */
@media (max-width: 520px) {
.logo-content {
gap: 15px;
}
.logo-text h2 {
font-size: 1.6rem;
}
.logo-text h4 {
font-size: 0.9rem;
}
.logo-image img {
width: 90px;
}
}
</style>

View File

@@ -1,9 +1,10 @@
<script setup>
import { ref, watch, onMounted } from 'vue';
import { ref, watch, onMounted, computed } from 'vue';
import axios from 'axios';
import { marked } from 'marked';
import hljs from 'highlight.js';
import 'highlight.js/styles/github.css';
import { useI18n } from '@/i18n/composables';
const props = defineProps({
show: {
@@ -22,6 +23,9 @@ const props = defineProps({
const emit = defineEmits(['update:show']);
// 国际化
const { t } = useI18n();
const content = ref(null);
const error = ref(null);
const loading = ref(false);
@@ -54,10 +58,10 @@ async function fetchReadme() {
if (res.data.status === 'ok') {
content.value = res.data.data.content;
} else {
error.value = res.data.message || '获取README失败';
error.value = res.data.message || t('core.common.readme.errors.fetchFailed');
}
} catch (err) {
error.value = err.message || '获取README时发生错误';
error.value = err.message || t('core.common.readme.errors.fetchError');
} finally {
loading.value = false;
}
@@ -99,13 +103,23 @@ function renderMarkdown(content) {
function refreshReadme() {
fetchReadme();
}
// 计算属性处理双向绑定
const _show = computed({
get() {
return props.show;
},
set(value) {
emit('update:show', value);
}
});
</script>
<template>
<v-dialog v-model="_show" width="800" persistent>
<v-card>
<v-card-title class="d-flex justify-space-between align-center">
<span class="text-h5">插件说明文档</span>
<span class="text-h5">{{ t('core.common.readme.title') }}</span>
<v-btn icon @click="$emit('update:show', false)">
<v-icon>mdi-close</v-icon>
</v-btn>
@@ -119,21 +133,21 @@ function refreshReadme() {
prepend-icon="mdi-github"
@click="openRepoInNewTab()"
>
GitHub中查看仓库
{{ t('core.common.readme.buttons.viewOnGithub') }}
</v-btn>
<v-btn
color="secondary"
prepend-icon="mdi-refresh"
@click="refreshReadme()"
>
刷新文档
{{ t('core.common.readme.buttons.refresh') }}
</v-btn>
</div>
<!-- 加载中 -->
<div v-if="loading" class="d-flex flex-column align-center justify-center" style="height: 100%;">
<v-progress-circular indeterminate color="primary" size="64" class="mb-4"></v-progress-circular>
<p class="text-body-1 text-center">正在加载README文档...</p>
<p class="text-body-1 text-center">{{ t('core.common.readme.loading') }}</p>
</div>
<!-- 内容显示 -->
@@ -148,14 +162,14 @@ function refreshReadme() {
<!-- 无内容提示 -->
<div v-else class="d-flex flex-column align-center justify-center" style="height: 100%;">
<v-icon size="64" color="warning" class="mb-4">mdi-file-question-outline</v-icon>
<p class="text-body-1 text-center mb-4">该插件未提供文档链接或GitHub仓库地址<br>请查看插件市场或联系插件作者获取更多信息</p>
<p class="text-body-1 text-center mb-4">{{ t('core.common.readme.empty.title') }}<br>{{ t('core.common.readme.empty.subtitle') }}</p>
</div>
</v-card-text>
<v-divider></v-divider>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" variant="tonal" @click="$emit('update:show', false)">
关闭
{{ t('core.common.close') }}
</v-btn>
</v-card-actions>
</v-card>
@@ -164,124 +178,124 @@ function refreshReadme() {
<style>
.markdown-body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
line-height: 1.6;
padding: 8px 0;
color: #24292e;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
line-height: 1.6;
padding: 8px 0;
color: var(--v-theme-secondaryText);
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
}
.markdown-body h1 {
font-size: 2em;
border-bottom: 1px solid #eaecef;
padding-bottom: 0.3em;
font-size: 2em;
border-bottom: 1px solid var(--v-theme-border);
padding-bottom: 0.3em;
}
.markdown-body h2 {
font-size: 1.5em;
border-bottom: 1px solid #eaecef;
padding-bottom: 0.3em;
font-size: 1.5em;
border-bottom: 1px solid var(--v-theme-border);
padding-bottom: 0.3em;
}
.markdown-body p {
margin-top: 0;
margin-bottom: 16px;
margin-top: 0;
margin-bottom: 16px;
}
.markdown-body code {
padding: 0.2em 0.4em;
margin: 0;
background-color: rgba(27, 31, 35, 0.05);
border-radius: 3px;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 85%;
padding: 0.2em 0.4em;
margin: 0;
background-color: var(--v-theme-codeBg);
border-radius: 3px;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 85%;
}
.markdown-body pre {
padding: 16px;
overflow: auto;
font-size: 85%;
line-height: 1.45;
background-color: #f6f8fa;
border-radius: 3px;
margin-bottom: 16px;
padding: 16px;
overflow: auto;
font-size: 85%;
line-height: 1.45;
background-color: var(--v-theme-containerBg);
border-radius: 3px;
margin-bottom: 16px;
}
.markdown-body pre code {
background-color: transparent;
padding: 0;
background-color: transparent;
padding: 0;
}
.markdown-body ul,
.markdown-body ol {
padding-left: 2em;
margin-bottom: 16px;
padding-left: 2em;
margin-bottom: 16px;
}
.markdown-body img {
max-width: 100%;
margin: 8px 0;
box-sizing: border-box;
background-color: #fff;
border-radius: 3px;
max-width: 100%;
margin: 8px 0;
box-sizing: border-box;
background-color: var(--v-theme-background);
border-radius: 3px;
}
.markdown-body blockquote {
padding: 0 1em;
color: #6a737d;
border-left: 0.25em solid #dfe2e5;
margin-bottom: 16px;
padding: 0 1em;
color: var(--v-theme-secondaryText);
border-left: 0.25em solid var(--v-theme-border);
margin-bottom: 16px;
}
.markdown-body a {
color: #0366d6;
text-decoration: none;
color: var(--v-theme-primary);
text-decoration: none;
}
.markdown-body a:hover {
text-decoration: underline;
text-decoration: underline;
}
.markdown-body table {
border-spacing: 0;
border-collapse: collapse;
width: 100%;
overflow: auto;
margin-bottom: 16px;
border-spacing: 0;
border-collapse: collapse;
width: 100%;
overflow: auto;
margin-bottom: 16px;
}
.markdown-body table th,
.markdown-body table td {
padding: 6px 13px;
border: 1px solid #dfe2e5;
padding: 6px 13px;
border: 1px solid var(--v-theme-background);
}
.markdown-body table tr {
background-color: #fff;
border-top: 1px solid #c6cbd1;
background-color: var(--v-theme-surface);
border-top: 1px solid var(--v-theme-border);
}
.markdown-body table tr:nth-child(2n) {
background-color: #f6f8fa;
background-color: var(--v-theme-background);
}
.markdown-body hr {
height: 0.25em;
padding: 0;
margin: 24px 0;
background-color: #e1e4e8;
border: 0;
height: 0.25em;
padding: 0;
margin: 24px 0;
background-color: var(--v-theme-containerBg);
border: 0;
}
</style>
@@ -299,4 +313,4 @@ export default {
}
}
}
</script>
</script>

View File

@@ -1,7 +1,7 @@
<template>
<v-dialog v-model="visible" persistent max-width="400">
<v-card>
<v-card-title>正在等待 AstrBot 重启...</v-card-title>
<v-card-title>{{ t('core.common.restart.waiting') }}</v-card-title>
<v-card-text>
<v-progress-linear indeterminate color="primary"></v-progress-linear>
</v-card-text>
@@ -11,12 +11,16 @@
<script>
import axios from 'axios'
import { useCommonStore } from '@/stores/common';
import { useI18n } from '@/i18n/composables';
export default {
name: 'WaitingForRestart',
setup() {
const { t } = useI18n();
return { t };
},
data() {
return {
visible: false,
@@ -47,7 +51,7 @@ export default {
}, 1000)
} else {
if (this.cnt == 10) {
this.status = '拉取状态达到最大次数,请手动检查。'
this.status = this.t('core.common.restart.maxRetriesReached')
}
this.cnt = 0
setTimeout(() => {

View File

@@ -0,0 +1,165 @@
import { ref, computed } from 'vue';
import { translations as staticTranslations } from './translations';
import type { Locale } from './types';
// 全局状态
const currentLocale = ref<Locale>('zh-CN');
const translations = ref<Record<string, any>>({});
/**
* 初始化i18n系统
*/
export async function initI18n(locale: Locale = 'zh-CN') {
currentLocale.value = locale;
// 加载静态翻译数据
loadTranslations(locale);
}
/**
* 加载翻译数据(现在从静态导入获取)
*/
function loadTranslations(locale: Locale) {
try {
const data = staticTranslations[locale];
if (data) {
translations.value = data;
} else {
console.warn(`Translations not found for locale: ${locale}`);
// 回退到中文
if (locale !== 'zh-CN') {
console.log('Falling back to zh-CN');
translations.value = staticTranslations['zh-CN'];
}
}
} catch (error) {
console.error(`Failed to load translations for ${locale}:`, error);
// 回退到中文
if (locale !== 'zh-CN') {
console.log('Falling back to zh-CN');
translations.value = staticTranslations['zh-CN'];
}
}
}
/**
* 主要的翻译函数组合
*/
export function useI18n() {
// 翻译函数
const t = (key: string, params?: Record<string, string | number>): string => {
const keys = key.split('.');
let value: any = translations.value;
// 遍历键路径
for (const k of keys) {
if (value && typeof value === 'object' && k in value) {
value = value[k];
} else {
console.warn(`Translation key not found: ${key}`);
// 返回带括号的键名,便于在开发时识别缺失的翻译
return `[MISSING: ${key}]`;
}
}
if (typeof value !== 'string') {
console.warn(`Translation value is not string: ${key}`, value);
// 返回带括号的键名,便于在开发时识别类型错误的翻译
return `[INVALID: ${key}]`;
}
// 此时value确定是string类型
let result: string = value;
// 处理参数插值
if (params) {
result = result.replace(/\{(\w+)\}/g, (match: string, paramKey: string) => {
return params[paramKey]?.toString() || match;
});
}
return result;
};
// 切换语言
const setLocale = async (newLocale: Locale) => {
if (newLocale !== currentLocale.value) {
currentLocale.value = newLocale;
loadTranslations(newLocale);
// 保存到localStorage
localStorage.setItem('astrbot-locale', newLocale);
}
};
// 获取当前语言
const locale = computed(() => currentLocale.value);
// 获取可用语言列表
const availableLocales: Locale[] = ['zh-CN', 'en-US'];
// 检查是否已加载
const isLoaded = computed(() => Object.keys(translations.value).length > 0);
return {
t,
locale,
setLocale,
availableLocales,
isLoaded
};
}
/**
* 模块特定的翻译函数
*/
export function useModuleI18n(moduleName: string) {
const { t } = useI18n();
const tm = (key: string, params?: Record<string, string | number>): string => {
// 将斜杠转换为点号以匹配嵌套对象结构
const normalizedModuleName = moduleName.replace(/\//g, '.');
return t(`${normalizedModuleName}.${key}`, params);
};
return { tm };
}
/**
* 语言切换器组合函数
*/
export function useLanguageSwitcher() {
const { locale, setLocale, availableLocales } = useI18n();
const languageOptions = computed(() => [
{ value: 'zh-CN', label: '简体中文', flag: '🇨🇳' },
{ value: 'en-US', label: 'English', flag: '🇺🇸' }
]);
const currentLanguage = computed(() => {
return languageOptions.value.find(lang => lang.value === locale.value);
});
const switchLanguage = async (newLocale: Locale) => {
await setLocale(newLocale);
};
return {
locale,
languageOptions,
currentLanguage,
switchLanguage,
availableLocales
};
}
// 初始化函数(在应用启动时调用)
export async function setupI18n() {
// 从localStorage获取保存的语言设置
const savedLocale = localStorage.getItem('astrbot-locale') as Locale;
const initialLocale = savedLocale && ['zh-CN', 'en-US'].includes(savedLocale)
? savedLocale
: 'zh-CN';
await initI18n(initialLocale);
}

View File

@@ -0,0 +1,293 @@
/**
* Dynamic I18n Loader
* 动态国际化加载器,支持按需加载和缓存机制
*/
export interface LoaderCache {
[key: string]: any;
}
export interface ModuleInfo {
name: string;
path: string;
loaded: boolean;
data?: any;
}
export class I18nLoader {
private cache: Map<string, any> = new Map();
private moduleRegistry: Map<string, ModuleInfo> = new Map();
constructor() {
this.registerModules();
}
/**
* 注册所有可用的翻译模块
*/
private registerModules(): void {
const modules = [
// 核心模块
{ name: 'core/common', path: 'core/common.json' },
{ name: 'core/actions', path: 'core/actions.json' },
{ name: 'core/status', path: 'core/status.json' },
{ name: 'core/navigation', path: 'core/navigation.json' },
{ name: 'core/header', path: 'core/header.json' },
// 功能模块
{ name: 'features/chat', path: 'features/chat.json' },
{ name: 'features/extension', path: 'features/extension.json' },
{ name: 'features/conversation', path: 'features/conversation.json' },
{ name: 'features/tooluse', path: 'features/tool-use.json' },
{ name: 'features/provider', path: 'features/provider.json' },
{ name: 'features/platform', path: 'features/platform.json' },
{ name: 'features/config', path: 'features/config.json' },
{ name: 'features/console', path: 'features/console.json' },
{ name: 'features/about', path: 'features/about.json' },
{ name: 'features/settings', path: 'features/settings.json' },
{ name: 'features/auth', path: 'features/auth.json' },
{ name: 'features/chart', path: 'features/chart.json' },
{ name: 'features/dashboard', path: 'features/dashboard.json' },
{ name: 'features/alkaid/index', path: 'features/alkaid/index.json' },
{ name: 'features/alkaid/knowledge-base', path: 'features/alkaid/knowledge-base.json' },
{ name: 'features/alkaid/memory', path: 'features/alkaid/memory.json' },
// 消息模块
{ name: 'messages/errors', path: 'messages/errors.json' },
{ name: 'messages/success', path: 'messages/success.json' },
{ name: 'messages/validation', path: 'messages/validation.json' }
];
modules.forEach(module => {
this.moduleRegistry.set(module.name, {
name: module.name,
path: module.path,
loaded: false
});
});
}
/**
* 加载单个模块
*/
async loadModule(locale: string, moduleName: string): Promise<any> {
const cacheKey = `${locale}:${moduleName}`;
// 检查缓存
if (this.cache.has(cacheKey)) {
return this.cache.get(cacheKey);
}
const moduleInfo = this.moduleRegistry.get(moduleName);
if (!moduleInfo) {
console.warn(`模块 ${moduleName} 未注册`);
return {};
}
try {
// 使用动态import加载JSON文件兼容构建和开发环境
const modulePath = `../locales/${locale}/${moduleInfo.path}`;
const module = await import(/* @vite-ignore */ modulePath);
const data = module.default || module;
// 缓存结果
this.cache.set(cacheKey, data);
// 更新模块信息
moduleInfo.loaded = true;
moduleInfo.data = data;
return data;
} catch (error) {
console.error(`加载模块 ${moduleName} 失败:`, error);
// 回退方案尝试使用fetch开发环境
try {
const modulePath = `/src/i18n/locales/${locale}/${moduleInfo.path}`;
const response = await fetch(modulePath);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
// 缓存结果
this.cache.set(cacheKey, data);
// 更新模块信息
moduleInfo.loaded = true;
moduleInfo.data = data;
return data;
} catch (fetchError) {
console.error(`回退fetch加载也失败:`, fetchError);
return {};
}
}
}
/**
* 通用模块加载器 - 减少重复代码,提高可维护性
*/
private async loadModules(
locale: string,
prefix: string,
overrideList: string[] = []
): Promise<any> {
// 使用覆盖列表或从注册表中筛选符合前缀的模块名
const moduleNames = overrideList.length > 0
? overrideList
: Array.from(this.moduleRegistry.keys()).filter(key => key.startsWith(prefix));
const results = await Promise.all(
moduleNames.map(module => this.loadModule(locale, module))
);
return this.mergeModules(results, moduleNames);
}
/**
* 加载核心模块(最高优先级)
*/
async loadCoreModules(locale: string): Promise<any> {
return this.loadModules(locale, 'core');
}
/**
* 加载功能模块
*/
async loadFeatureModules(locale: string, features?: string[]): Promise<any> {
return this.loadModules(locale, 'features', features || []);
}
/**
* 加载消息模块
*/
async loadMessageModules(locale: string): Promise<any> {
return this.loadModules(locale, 'messages');
}
/**
* 加载所有模块
*/
async loadAllModules(locale: string): Promise<any> {
const [core, features, messages] = await Promise.all([
this.loadCoreModules(locale),
this.loadFeatureModules(locale),
this.loadMessageModules(locale)
]);
return {
...core,
...features,
...messages
};
}
/**
* 加载完整语言包(所有模块合并)
*/
async loadLocale(locale: string): Promise<any> {
return this.loadAllModules(locale);
}
/**
* 合并多个模块数据
*/
private mergeModules(modules: any[], moduleNames: string[]): any {
const result: any = {};
const pathRegistry = new Map<string, string>();
modules.forEach((module, index) => {
const moduleName = moduleNames[index];
const nameParts = moduleName.split('/');
// 构建嵌套对象结构(对所有模块统一处理)
let current = result;
for (let i = 0; i < nameParts.length - 1; i++) {
if (!current[nameParts[i]]) {
current[nameParts[i]] = {};
}
current = current[nameParts[i]];
}
// 冲突检测:检查最终键是否已存在
const finalKey = nameParts[nameParts.length - 1];
const fullPath = nameParts.join('.');
if (current[finalKey] && pathRegistry.has(fullPath)) {
const existingModule = pathRegistry.get(fullPath);
console.warn(`⚠️ I18n模块路径冲突: "${fullPath}" 已被模块 "${existingModule}" 占用,模块 "${moduleName}" 可能会覆盖部分键值`);
}
// 记录路径和模块名的映射
pathRegistry.set(fullPath, moduleName);
// 设置最终值(保持原有的浅合并行为)
current[finalKey] = { ...current[finalKey], ...module };
});
return result;
}
/**
* 预加载关键模块
*/
async preloadEssentials(locale: string): Promise<void> {
const essentials = [
'core/common',
'core/navigation',
'features/chat'
];
await Promise.all(
essentials.map(module => this.loadModule(locale, module))
);
}
/**
* 清理缓存
*/
clearCache(locale?: string): void {
if (locale) {
// 清理特定语言的缓存
const keys = Array.from(this.cache.keys()).filter((key: string) => key.startsWith(`${locale}:`));
keys.forEach((key: string) => this.cache.delete(key));
} else {
// 清理所有缓存
this.cache.clear();
}
}
/**
* 获取加载状态
*/
getLoadingStatus(): { total: number; loaded: number; modules: ModuleInfo[] } {
const modules = Array.from(this.moduleRegistry.values());
const loaded = modules.filter(m => m.loaded).length;
return {
total: modules.length,
loaded,
modules
};
}
/**
* 热重载模块
*/
async reloadModule(locale: string, moduleName: string): Promise<any> {
const cacheKey = `${locale}:${moduleName}`;
this.cache.delete(cacheKey);
const moduleInfo = this.moduleRegistry.get(moduleName);
if (moduleInfo) {
moduleInfo.loaded = false;
}
return this.loadModule(locale, moduleName);
}
}

View File

@@ -0,0 +1,22 @@
{
"create": "Create",
"read": "Read",
"update": "Update",
"delete": "Delete",
"search": "Search",
"filter": "Filter",
"sort": "Sort",
"export": "Export",
"import": "Import",
"backup": "Backup",
"restore": "Restore",
"copy": "Copy",
"paste": "Paste",
"cut": "Cut",
"undo": "Undo",
"redo": "Redo",
"refresh": "Refresh",
"submit": "Submit",
"reset": "Reset",
"clear": "Clear"
}

View File

@@ -0,0 +1,76 @@
{
"save": "Save",
"cancel": "Cancel",
"close": "Close",
"delete": "Delete",
"edit": "Edit",
"add": "Add",
"confirm": "Confirm",
"loading": "Loading...",
"success": "Success",
"error": "Error",
"warning": "Warning",
"info": "Info",
"name": "Name",
"description": "Description",
"author": "Author",
"status": "Status",
"actions": "Actions",
"enable": "Enable",
"disable": "Disable",
"enabled": "Enabled",
"disabled": "Disabled",
"reload": "Reload",
"configure": "Configure",
"install": "Install",
"uninstall": "Uninstall",
"update": "Update",
"language": "Language",
"locale": "en-US",
"type": "Type",
"press": "Press",
"longPress": "Long press",
"yes": "Yes",
"no": "No",
"dialog": {
"confirmTitle": "Confirm Action",
"confirmMessage": "Are you sure you want to perform this action?",
"confirmButton": "Confirm",
"cancelButton": "Cancel"
},
"restart": {
"waiting": "Waiting for AstrBot to restart...",
"maxRetriesReached": "Maximum retry attempts reached, please check manually."
},
"readme": {
"title": "Extension Documentation",
"buttons": {
"viewOnGithub": "View Repository on GitHub",
"refresh": "Refresh Documentation"
},
"loading": "Loading README documentation...",
"errors": {
"fetchFailed": "Failed to fetch README",
"fetchError": "Error occurred while fetching README"
},
"empty": {
"title": "This extension does not provide documentation link or GitHub repository address.",
"subtitle": "Please check the extension marketplace or contact the extension author for more information."
}
},
"editor": {
"fullscreen": "Fullscreen Edit",
"editingTitle": "Editing Content"
},
"list": {
"addItemPlaceholder": "Add new item, press Enter to confirm",
"addButton": "Add"
},
"itemCard": {
"enabled": "Enabled",
"disabled": "Disabled",
"delete": "Delete",
"edit": "Edit",
"noData": "No data available"
}
}

View File

@@ -0,0 +1,85 @@
{
"logoTitle": "AstrBot Dashboard",
"version": {
"hasNewVersion": "AstrBot has a new version!",
"dashboardHasNewVersion": "WebUI has a new version!"
},
"buttons": {
"update": "Update",
"account": "Account",
"theme": {
"light": "Light Mode",
"dark": "Dark Mode"
}
},
"updateDialog": {
"title": "Update AstrBot",
"currentVersion": "Current Version",
"status": {
"checking": "Checking for updates...",
"switching": "Switching version...",
"updating": "Updating..."
},
"tabs": {
"release": "😊 Release",
"dev": "🧐 Development (master branch)"
},
"updateToLatest": "Update to Latest Version",
"tip": "💡 TIP: Switching to an older version or a specific version will not re-download the dashboard files, which may cause some data display errors. You can find the corresponding dashboard files dist.zip at",
"tipLink": "here",
"tipContinue": ", extract and replace the data/dist folder. Of course, the frontend source code is in the dashboard directory, you can also build it yourself using npm install and npm build.",
"dockerTip": "The `Update to Latest Version` button will try to update both the bot main program and the dashboard. If you are using Docker deployment, you can also re-pull the image or use",
"dockerTipLink": "watchtower",
"dockerTipContinue": "to automatically monitor and pull.",
"table": {
"tag": "Tag",
"publishDate": "Publish Date",
"content": "Content",
"sourceUrl": "Source URL",
"actions": "Actions",
"sha": "SHA",
"date": "Date",
"message": "Message",
"view": "View",
"switch": "Switch"
},
"manualInput": {
"title": "Manual Input Version or Commit SHA",
"placeholder": "Enter version number or commit hash from master branch.",
"hint": "e.g. v3.3.16 (without SHA) or 42e5ec5d80b93b6bfe8b566754d45ffac4c3fe0b",
"linkText": "View master branch commit history (click copy on the right to copy)",
"confirm": "Confirm Switch"
},
"dashboardUpdate": {
"title": "Update Dashboard to Latest Version Only",
"currentVersion": "Current Version",
"hasNewVersion": "New version available!",
"isLatest": "Already the latest version.",
"downloadAndUpdate": "Download and Update"
}
},
"accountDialog": {
"title": "Modify Account",
"securityWarning": "Security Reminder: Please change the default password to ensure account security",
"form": {
"currentPassword": "Current Password",
"newPassword": "New Password",
"newUsername": "New Username (Optional)",
"passwordHint": "Password must be at least 8 characters",
"usernameHint": "Leave blank to keep current username",
"defaultCredentials": "Default username and password are both astrbot"
},
"validation": {
"passwordRequired": "Please enter password",
"passwordMinLength": "Password must be at least 8 characters",
"usernameMinLength": "Username must be at least 3 characters"
},
"actions": {
"save": "Save Changes",
"cancel": "Cancel"
},
"messages": {
"updateFailed": "Update failed, please try again"
}
}
}

View File

@@ -0,0 +1,18 @@
{
"dashboard": "Dashboard",
"platforms": "Platforms",
"providers": "Providers",
"toolUse": "MCP Tools",
"config": "Config",
"extension": "Extensions",
"extensionMarketplace": "Extension Market",
"chat": "Chat",
"conversation": "Conversations",
"console": "Console",
"alkaid": "Alkaid Lab",
"about": "About",
"settings": "Settings",
"documentation": "Documentation",
"github": "GitHub",
"drag": "Drag"
}

View File

@@ -0,0 +1,22 @@
{
"loading": "Loading",
"success": "Success",
"error": "Error",
"warning": "Warning",
"info": "Info",
"pending": "Pending",
"processing": "Processing",
"completed": "Completed",
"failed": "Failed",
"cancelled": "Cancelled",
"timeout": "Timeout",
"connecting": "Connecting",
"connected": "Connected",
"disconnected": "Disconnected",
"online": "Online",
"offline": "Offline",
"active": "Active",
"inactive": "Inactive",
"ready": "Ready",
"busy": "Busy"
}

View File

@@ -0,0 +1,17 @@
{
"hero": {
"title": "AstrBot",
"subtitle": "A project out of interests and loves ❤️",
"starButton": "Star this project! 🌟",
"issueButton": "Submit Issue"
},
"contributors": {
"title": "Contributors",
"description": "This project is maintained by many open source community members. Thanks to every contributor for their dedication!",
"viewLink": "View AstrBot Contributors"
},
"stats": {
"title": "Global Deployment",
"license": "AstrBot is open source under AGPL v3 license"
}
}

View File

@@ -0,0 +1,44 @@
{
"title": "Alkaid Laboratory",
"subtitle": "Explore cutting-edge AI features",
"comingSoon": "The world ahead, let's explore it later!",
"page": {
"title": "The Alkaid Project.",
"subtitle": "AstrBot Alpha Project",
"navigation": {
"knowledgeBase": "Knowledge Base",
"longTermMemory": "Long-term Memory",
"other": "..."
}
},
"features": {
"knowledgeBase": "Knowledge Base",
"longTermMemory": "Long-term Memory",
"advancedChat": "Advanced Chat",
"multiModal": "Multi-modal Interaction"
},
"status": {
"experimental": "Experimental",
"beta": "Beta",
"stable": "Stable",
"deprecated": "Deprecated"
},
"sigma": {
"subtitle": "AstrBot Experimental Project",
"visualization": "Visualization",
"filterUserId": "Filter User ID",
"filter": "Filter",
"resetFilter": "Reset Filter",
"refreshGraph": "Refresh Graph",
"nodeDetails": "Node Details",
"id": "ID",
"type": "Type",
"name": "Name",
"userId": "User ID",
"timestamp": "Timestamp",
"graphStats": "Graph Statistics",
"nodeCount": "Node Count",
"edgeCount": "Edge Count",
"inDevelopment": "Under Development"
}
}

View File

@@ -0,0 +1,136 @@
{
"title": "Knowledge Base",
"subtitle": "Manage and query knowledge base content",
"upload": {
"title": "Upload Documents",
"selectFiles": "Select Files",
"supportedFormats": "Supported Formats",
"dragDrop": "Drag files here",
"processing": "Processing...",
"success": "Upload Successful",
"error": "Upload Failed"
},
"search": {
"placeholder": "Search knowledge base...",
"results": "Search Results",
"noResults": "No relevant content found",
"searching": "Searching..."
},
"documents": {
"title": "Document List",
"name": "Document Name",
"size": "Size",
"uploadTime": "Upload Time",
"status": "Status",
"actions": "Actions"
},
"management": {
"delete": "Delete",
"preview": "Preview",
"download": "Download",
"reindex": "Reindex"
},
"notInstalled": {
"title": "Knowledge base plugin is not installed yet",
"install": "Install now"
},
"empty": {
"title": "No knowledge base yet, create one now! 🙂",
"create": "Create Knowledge Base"
},
"list": {
"title": "Knowledge Base List",
"create": "Create Knowledge Base",
"config": "Configure",
"knowledgeCount": "knowledge items",
"tips": "Tips: Learn how to use through /kb command in chat page!"
},
"createDialog": {
"title": "Create New Knowledge Base",
"nameLabel": "Knowledge Base Name",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Brief description of the knowledge base...",
"embeddingModelLabel": "Embedding Model",
"providerInfo": "Provider ID: {id} | Embedding Model Dimensions: {dimensions}",
"tips": "Tips: Once you choose an embedding model for a knowledge base, please do not modify the provider's model or vector dimension information, otherwise it will seriously affect the recall rate of the knowledge base or even cause errors.",
"cancel": "Cancel",
"create": "Create"
},
"emojiPicker": {
"title": "Select Emoji",
"close": "Close",
"categories": {
"emotions": "Smileys and Emotions",
"animals": "Animals and Nature",
"food": "Food and Drink",
"activities": "Activities and Objects",
"travel": "Travel and Places",
"symbols": "Symbols and Flags"
}
},
"contentDialog": {
"title": "Knowledge Base Management",
"embeddingModel": "Embedding Model",
"vectorDimension": "Vector Dimension",
"usage": "Usage: Enter \"/kb use {name}\" in the chat page",
"tabs": {
"upload": "Upload Files",
"search": "Search Content"
}
},
"upload": {
"title": "Upload Files to Knowledge Base",
"subtitle": "Supports txt, pdf, word, excel and other formats",
"dropzone": "Drag and drop files here or click to upload",
"chunkSettings": {
"title": "Chunk Settings",
"tooltip": "Chunk size determines the size of each text block, overlap length determines the overlap between adjacent text blocks.\nSmaller chunks are more precise but increase quantity, appropriate overlap can improve retrieval accuracy.",
"chunkSizeLabel": "Chunk Size",
"chunkSizeHint": "Control the size of each text block, leave empty to use default value",
"overlapLabel": "Overlap Length",
"overlapHint": "Control the overlap between adjacent text blocks, leave empty to use default value"
},
"upload": "Upload File",
"uploading": "Uploading..."
},
"search": {
"queryLabel": "Search Knowledge Base Content",
"queryPlaceholder": "Enter keywords to search knowledge base content...",
"resultCountLabel": "Result Count",
"searching": "Searching...",
"resultsTitle": "Search Results",
"relevance": "Relevance",
"noResults": "No matching content found"
},
"deleteDialog": {
"title": "Confirm Delete",
"confirmText": "Are you sure you want to delete knowledge base {name}?",
"warning": "This operation is irreversible, all knowledge base content will be permanently deleted.",
"cancel": "Cancel",
"delete": "Delete"
},
"messages": {
"pluginNotAvailable": "Plugin not installed or unavailable",
"checkPluginFailed": "Failed to check plugin",
"installFailed": "Installation failed",
"installPluginFailed": "Failed to install plugin",
"getKnowledgeBaseListFailed": "Failed to get knowledge base list",
"knowledgeBaseCreated": "Knowledge base created successfully",
"createFailed": "Creation failed",
"createKnowledgeBaseFailed": "Failed to create knowledge base",
"pleaseEnterKnowledgeBaseName": "Please enter knowledge base name",
"pleaseSelectFile": "Please select a file first",
"operationSuccess": "Operation successful: {message}",
"uploadFailed": "Upload failed",
"fileUploadFailed": "File upload failed",
"pleaseEnterSearchContent": "Please enter search content",
"noMatchingContent": "No matching content found",
"searchFailed": "Search failed",
"searchKnowledgeBaseFailed": "Failed to search knowledge base",
"deleteTargetNotExists": "Delete target does not exist",
"knowledgeBaseDeleted": "Knowledge base deleted successfully",
"deleteFailed": "Deletion failed",
"deleteKnowledgeBaseFailed": "Failed to delete knowledge base",
"getEmbeddingModelListFailed": "Failed to get embedding model list"
}
}

View File

@@ -0,0 +1,97 @@
{
"title": "Long-term Memory",
"subtitle": "AI assistant's long-term memory management",
"memories": {
"title": "Memory List",
"content": "Memory Content",
"importance": "Importance Level",
"createTime": "Create Time",
"lastAccess": "Last Access",
"category": "Category"
},
"categories": {
"personal": "Personal Information",
"preferences": "Preference Settings",
"conversations": "Conversation History",
"facts": "Factual Information",
"skills": "Skill Knowledge"
},
"importance": {
"high": "High",
"medium": "Medium",
"low": "Low"
},
"actions": {
"view": "View Details",
"edit": "Edit",
"delete": "Delete",
"pin": "Pin",
"unpin": "Unpin"
},
"filters": {
"all": "All",
"category": "By Category",
"importance": "By Importance",
"dateRange": "By Date Range",
"title": "Filters",
"userIdLabel": "Filter by User ID",
"filterButton": "Filter",
"resetButton": "Reset Filter",
"refreshButton": "Refresh Graph"
},
"search": {
"title": "Search Memory",
"userIdLabel": "User ID",
"queryLabel": "Enter keywords",
"searchButton": "Search",
"resultsTitle": "Search Results",
"noResults": "No relevant memory content found",
"similarity": "Relevance",
"noTextContent": "No text content"
},
"addMemory": {
"title": "Add Memory Data",
"textLabel": "Enter text content",
"userIdLabel": "User ID",
"summarizeLabel": "Need summary",
"addButton": "Add Data"
},
"nodeDetails": {
"title": "Node Details",
"id": "ID",
"type": "Type",
"name": "Name",
"userId": "User ID",
"timestamp": "Timestamp"
},
"graphStats": {
"title": "Graph Statistics",
"nodeCount": "Node Count",
"edgeCount": "Edge Count"
},
"factDialog": {
"title": "Memory Fact",
"id": "ID",
"docId": "Document ID",
"createdAt": "Created At",
"updatedAt": "Updated At",
"metadata": "Metadata",
"metadataKey": "Key",
"metadataValue": "Value",
"loading": "Loading...",
"close": "Close",
"noValue": "None",
"unknown": "Unknown"
},
"messages": {
"searchQueryRequired": "Please enter search keywords",
"searchSuccess": "Found {count} relevant memories",
"searchNoResults": "No relevant memory content found",
"searchError": "Search failed",
"addSuccess": "Memory data added successfully!",
"addError": "Failed to add memory data",
"factDetailsError": "Failed to get memory details",
"metadataParseError": "Unable to parse metadata",
"relationNoMemoryData": "This relation has no associated memory data"
}
}

View File

@@ -0,0 +1,13 @@
{
"login": "Login",
"username": "Username",
"password": "Password",
"logo": {
"title": "AstrBot Dashboard",
"subtitle": "Welcome"
},
"theme": {
"switchToDark": "Switch to Dark Theme",
"switchToLight": "Switch to Light Theme"
}
}

View File

@@ -0,0 +1,4 @@
{
"messageCount": "Message Count",
"time": "Time"
}

View File

@@ -0,0 +1,75 @@
{
"title": "Let's Chat!",
"subtitle": "Chat with AI Assistant",
"input": {
"placeholder": "Start typing...",
"send": "Send",
"clear": "Clear",
"upload": "Upload File",
"voice": "Voice Input",
"recordingPrompt": "Recording, please speak...",
"chatPrompt": "Let's chat!"
},
"message": {
"user": "User",
"assistant": "Assistant",
"system": "System",
"error": "Error Message",
"loading": "Thinking..."
},
"voice": {
"start": "Start Recording",
"stop": "Stop Recording",
"recording": "New Recording",
"processing": "Processing...",
"error": "Recording Failed"
},
"welcome": {
"title": "Welcome to AstrBot",
"subtitle": "Your Intelligent Chat Assistant",
"quickActions": "Quick Actions",
"examples": "Example Questions"
},
"actions": {
"copy": "Copy",
"regenerate": "Regenerate",
"like": "Like",
"dislike": "Dislike",
"share": "Share",
"newChat": "New Chat",
"deleteChat": "Delete this conversation",
"editTitle": "Edit Title",
"fullscreen": "Fullscreen Mode",
"exitFullscreen": "Exit Fullscreen"
},
"conversation": {
"newConversation": "New Conversation",
"noHistory": "No conversation history",
"systemStatus": "System Status",
"llmService": "LLM Service",
"speechToText": "Speech to Text"
},
"modes": {
"darkMode": "Switch to Dark Mode",
"lightMode": "Switch to Light Mode"
}, "shortcuts": {
"help": "Get Help",
"voiceRecord": "Record Voice",
"pasteImage": "Paste Image"
},
"connection": {
"title": "Connection Status Notice",
"message": "The system detected that the chat connection needs to be re-established.",
"reasons": "This may be due to:",
"reasonWindowResize": "Switching chat window size (normal behavior)",
"reasonMultipleTabs": "Opening chat pages in other tabs",
"reasonNetworkIssue": "Temporary network interruption",
"notice": "Note: To ensure proper message delivery, the system only allows one active chat connection at a time. If you're using chat in multiple tabs, please keep only one page open.",
"understand": "Got it",
"status": {
"reconnecting": "Reconnecting...",
"reconnected": "Chat connection re-established",
"failed": "Connection failed, please refresh the page"
}
}
}

View File

@@ -0,0 +1,62 @@
{
"title": "Configuration",
"subtitle": "Manage system configuration and settings",
"editor": {
"visual": "Visual Editor",
"code": "Code Editor",
"revertCode": "Revert to Previous Code",
"applyConfig": "Apply This Configuration",
"applyTip": "`Apply This Configuration` will stage and apply the configuration to the visual editor. To save, you need to click the save button in the bottom right corner."
},
"actions": {
"save": "Save Configuration",
"delete": "Delete This Item",
"add": "Add",
"reset": "Reset to Default",
"export": "Export Configuration",
"import": "Import Configuration",
"validate": "Validate Configuration"
},
"help": {
"documentation": "Official Documentation",
"support": "Join Group for Help",
"helpText": "Don't understand the configuration? Please see {documentation} or {support}.",
"helpPrefix": "Don't understand the configuration? Please see",
"helpMiddle": "or",
"helpSuffix": "."
},
"messages": {
"configApplied": "Configuration successfully applied. To save, you need to click the save button in the bottom right corner.",
"configApplyError": "Configuration not applied, JSON format error.",
"saveSuccess": "Configuration saved successfully",
"saveError": "Failed to save configuration",
"loadError": "Failed to load configuration"
},
"sections": {
"general": "General Settings",
"advanced": "Advanced Settings",
"security": "Security Settings",
"appearance": "Appearance Settings",
"notification": "Notification Settings"
},
"general": {
"botName": "Bot Name",
"language": "Interface Language",
"timezone": "Timezone",
"autoSave": "Auto Save",
"debugMode": "Debug Mode"
},
"advanced": {
"logLevel": "Log Level",
"maxConnections": "Max Connections",
"timeout": "Timeout",
"retryAttempts": "Retry Attempts",
"cacheSize": "Cache Size"
},
"security": {
"apiKey": "API Key",
"allowedHosts": "Allowed Hosts",
"rateLimit": "Rate Limit",
"encryption": "Encryption Settings"
}
}

View File

@@ -0,0 +1,15 @@
{
"title": "Console",
"autoScroll": {
"enabled": "Auto-scroll enabled",
"disabled": "Auto-scroll disabled"
},
"pipInstall": {
"button": "Install pip Package",
"dialogTitle": "Install Pip Package",
"packageLabel": "*Package name, e.g. llmtuner",
"mirrorLabel": "Force PyPI repository URL (optional)",
"mirrorHint": "Force PyPI repository URL > Config item `PyPI Repository Address`",
"installButton": "Install"
}
}

View File

@@ -0,0 +1,77 @@
{
"title": "Conversation Management",
"subtitle": "Manage and view user conversation history",
"filters": {
"title": "Filter Conditions",
"platform": "Platform",
"type": "Type",
"search": "Search Keywords",
"reset": "Reset"
},
"history": {
"title": "Conversation History",
"refresh": "Refresh"
},
"table": {
"headers": {
"title": "Conversation Title",
"platform": "Platform",
"type": "Type",
"sessionId": "ID",
"createdAt": "Created At",
"updatedAt": "Updated At",
"actions": "Actions"
}
},
"actions": {
"view": "View",
"edit": "Edit",
"delete": "Delete"
},
"messageTypes": {
"group": "Group Chat",
"friend": "Private Chat",
"unknown": "Unknown"
},
"status": {
"noTitle": "Untitled Conversation",
"unknown": "Unknown",
"noData": "No conversation records",
"emptyContent": "Conversation content is empty",
"audioNotSupported": "Your browser does not support audio playback."
},
"dialogs": {
"view": {
"title": "Conversation Details",
"editMode": "Edit Conversation",
"previewMode": "Preview Mode",
"saveChanges": "Save Changes",
"close": "Close",
"confirmClose": "You have unsaved changes, are you sure you want to close?"
},
"edit": {
"title": "Edit Conversation Information",
"titleLabel": "Conversation Title",
"titlePlaceholder": "Enter conversation title",
"cancel": "Cancel",
"save": "Save"
},
"delete": {
"title": "Confirm Delete",
"message": "Are you sure you want to delete conversation {title}? This action cannot be undone.",
"cancel": "Cancel",
"confirm": "Delete"
}
},
"messages": {
"fetchError": "Failed to fetch conversation list",
"saveSuccess": "Save successful",
"saveError": "Save failed",
"deleteSuccess": "Delete successful",
"deleteError": "Delete failed",
"historyError": "Failed to fetch conversation history",
"historySaveSuccess": "Conversation history saved successfully",
"historySaveError": "Failed to save conversation history",
"invalidJson": "Invalid JSON format"
}
}

View File

@@ -0,0 +1,65 @@
{
"title": "Dashboard",
"subtitle": "Real-time monitoring and statistics",
"lastUpdate": "Last updated",
"status": {
"loading": "Loading...",
"dataError": "Failed to fetch data",
"noticeError": "Failed to fetch notice",
"online": "Online",
"uptime": "Uptime",
"memoryUsage": "Memory Usage"
},
"stats": {
"totalMessage": {
"title": "Total Messages",
"subtitle": "Total messages sent from all platforms"
},
"onlinePlatform": {
"title": "Platforms",
"subtitle": "Number of connected platforms"
},
"runningTime": {
"title": "Uptime",
"subtitle": "System uptime duration",
"format": "{hours}h {minutes}m {seconds}s"
},
"memoryUsage": {
"title": "Memory Usage",
"subtitle": "System memory usage status",
"cpuLoad": "CPU Load",
"status": {
"good": "Good",
"normal": "Normal",
"high": "High"
}
}
},
"charts": {
"messageTrend": {
"title": "Message Trend Analysis",
"subtitle": "Track message count changes over time",
"totalMessages": "Total Messages",
"dailyAverage": "Daily Average",
"growthRate": "Growth Rate",
"timeLabel": "Time",
"messageCount": "Message Count",
"timeRanges": {
"1day": "Past 1 Day",
"3days": "Past 3 Days",
"1week": "Past 1 Week",
"1month": "Past 1 Month"
}
},
"platformStat": {
"title": "Platform Message Statistics",
"subtitle": "Message count distribution by platform",
"total": "Total",
"noData": "No platform data available",
"messageUnit": "msgs",
"platformCount": "Platforms",
"mostActive": "Most Active",
"totalPercentage": "Total Percentage"
}
}
}

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