Compare commits

...

113 Commits

Author SHA1 Message Date
Soulter
7c3f5431ba chore: bump version to 4.0.0 2025-09-07 21:19:19 +08:00
anka
d98cf16a4c feat: 增加根据qq号/群号主动发送消息的封装, 增加事件构造 (#2629)
* feat: 增加根据qq号/群号主动发送消息的封装, 增加事件构造

* fix: 增加不支持平台提示, 修正文档字符串

* chore: lint

---------

Co-authored-by: Soulter <905617992@qq.com>
2025-09-07 17:04:29 +08:00
Soulter
2c3c3ae546 fix: 移除无用的调试日志以简化命令注册逻辑 2025-09-07 11:37:34 +08:00
Soulter
905eef48e3 feat: 增加 OneBot 服务 Token 为空时的安全提醒 (#2648) 2025-09-07 00:51:46 +08:00
RC-CHN
b31b520c7c feat: 支持管理 T2I 模版 (#2638)
* feat:添加t2i模板管理后端api,移除config.py中重复功能

* feat: 添加T2I模板管理功能前端,支持模板的创建、应用和重置

* refactor: 修复错误的保存逻辑,将t2i注册时打印路由信息部分移到基类实现

* remove:移除了路由注册时的打印

* chore: format code

* fix: update input variant from solo to outlined for better UI consistency

---------

Co-authored-by: Soulter <905617992@qq.com>
2025-09-07 00:14:28 +08:00
shangxue
17aee086a3 feat: 添加 Satori 协议适配器支持 (#2633)
* Create satori_adapter.py

* Add files via upload

* Update default.py

* Update manager.py

* Update platform_adapter_type.py

* Update PlatformPage.vue

* Add files via upload

* Update default.py

* Update manager.py

* Update platform_adapter_type.py

* Update PlatformPage.vue

* Add files via upload

* Update default.py

* chore: format code

* feat: 修复 Image, Audio 的解析,修复 message_str 的解析

* perf: 增强鲁棒性

* feat: 添加 Satori 配置项描述,移除适配器默认配置

---------

Co-authored-by: Soulter <905617992@qq.com>
2025-09-06 23:52:00 +08:00
Zhalslar
c1756e5767 fix: 修复组件 type 属性为枚举值 (#2628)
当前components.py中每个组件的type属性都是直接字符串赋值,IDE会爆红。
修正为使用本就定义好的ComponentType枚举类
用时修正多个组件中当url为空时convert_to_base64检查路径导致的报错
2025-09-06 19:22:49 +08:00
anka
2920279c64 Fix: 修正 QQ 群成员昵称获取 (#2626)
* feat: 修正群昵称获取

* fix: 增加兜底机制
2025-09-06 19:16:57 +08:00
Soulter
1f0f985b01 docs: update readme
Updated README to improve clarity and organization. Changed section titles and removed unnecessary content.
2025-09-06 16:21:33 +08:00
Soulter
0762c81633 Update Discord link in README.md 2025-09-06 11:49:05 +08:00
Soulter
28ef301ccc docs: update readme 2025-09-06 11:48:37 +08:00
Soulter
26c6a2950f 📦 release: bump version to v4.0.0-beta.5 2025-09-05 17:42:38 +08:00
Soulter
5082876de3 fix: 修复 v4.0.0 版本下可能无法得到 LLM 的响应的问题
closes: #2622
2025-09-05 17:20:29 +08:00
Soulter
e50e7ad3d5 fix: ensure deep copy of config_data before posting 2025-09-05 16:45:34 +08:00
卢小辉
45a4a6b6da feat: 给添加 edge_tts 新增 rate, volume, pitch 参数 (#2625)
* 修复python执行器文件上传qq提示参数错误问题,修改策略为本地url

* 给edge_tts 添加3个默认参数,方便通过ui配置
2025-09-05 15:23:21 +08:00
Soulter
02918b7267 perf: 增加 abconf_data 缓存,优化性能 2025-09-04 23:48:33 +08:00
Soulter
6c662a36c1 fix: 适配 qwen3 的 thinking 类模型
fixes: #2631
2025-09-04 20:26:52 +08:00
Soulter
b78fe3822a perf: 完善对 rerank model 的可用性检测 2025-09-04 15:46:23 +08:00
Soulter
35eda37e83 Merge remote-tracking branch 'origin/releases/v3.5.27' 2025-09-04 15:30:15 +08:00
Soulter
176a8e7067 chore: add no_proxy config item 2025-09-04 15:23:58 +08:00
Soulter
61d4f1fd4b 📦 release: v3.5.27 2025-09-04 15:01:44 +08:00
Soulter
121b68995e chore: update changelog 2025-09-04 14:34:23 +08:00
Soulter
d11f1d8dae perf: enhance update checks to consider pre-release versions 2025-09-04 14:33:19 +08:00
Soulter
c0ef2b5064 📦 release: v3.5.27 2025-09-04 13:56:47 +08:00
Soulter
2a7308363e fix: 下载 WebUI 时,明确版本号 2025-09-04 13:54:16 +08:00
Soulter
dc0c556f96 ci: build docker image 时同时 build webui,并放入 image 中 2025-09-04 13:42:26 +08:00
Soulter
ba2ee1c0aa fix: 初次下载 webui 构建文件时下载指定版本而非 latest 2025-09-04 13:27:55 +08:00
Zhalslar
0f8b550d68 fix: aiocqhttp优先使用session_id发送消息 (#2623)
* fix: aiocqhttp优先使用session_id发送消息

当前aiocqhttp依赖raw_message来发送消息,raw_message为空时也无法有效回退到用group_id或user_id来发送,更符合逻辑的应该:优先使用session_id(group_id or user_id),raw_message兜底

* Update aiocqhttp_message_event.py

* fix: validate session_id as integer and improve send_message docstring

---------

Co-authored-by: Soulter <905617992@qq.com>
2025-09-04 11:34:40 +08:00
Soulter
ed1fc98821 Merge pull request #2621 from AstrBotDevs/fix/gemini-api-error-handle
Fix: 修复 e.message 为 None 时报错的问题和部分 lint error
2025-09-04 11:20:05 +08:00
Soulter
fa53b468fd fix: ensure function call name and args are not None before processing 2025-09-04 11:18:58 +08:00
Soulter
4e2533d320 feat: add pre-release check for Docker image tagging 2025-09-04 09:30:21 +08:00
Soulter
388ae49e55 fix: 修复 e.message 为 None 时报错的问题和一些 lint error 2025-09-03 22:25:18 +08:00
Soulter
f3f347dcba 📦 release: bump verstion to v4.0.0-beta.4 2025-09-03 13:29:20 +08:00
Soulter
655be3519c perf: 数据迁移完毕之后引导重启程序
closes: #2613
2025-09-03 13:21:56 +08:00
Soulter
06df2940af chore: change identifier description 2025-09-03 12:46:10 +08:00
Soulter
4149549e42 fix: KeyError arprompt 2025-09-03 12:45:34 +08:00
Soulter
da351991f8 📦 release: bump verstion to v4.0.0-beta.3 2025-09-03 01:01:48 +08:00
Soulter
3305152e50 fix: 修复当人格 ID 为中文时,不可保存的问题 2025-09-03 00:59:07 +08:00
Soulter
bea7bae674 fix: dict read 2025-09-03 00:56:41 +08:00
Soulter
45773d38ed 📦 release: bump verstion to v4.0.0-beta.2 2025-09-03 00:32:49 +08:00
Soulter
8d4c176314 fix: correct image_caption logic and remove redundant config call 2025-09-03 00:31:18 +08:00
Soulter
9ca5c87c4c fix: complete requirements.txt 2025-09-03 00:05:43 +08:00
Soulter
36a6f00e5f Merge pull request #2610 from AstrBotDevs/releases/4.0.0 (#2610)
Release: v4.0.0-beta.1
2025-09-02 23:47:21 +08:00
Soulter
e24a5b4cb5 Revert "Release: v4.0.0-beta.1 (#2509)" (#2609)
This reverts commit f88031b0c9.
2025-09-02 23:44:36 +08:00
Soulter
f88031b0c9 Release: v4.0.0-beta.1 (#2509)
* Refactor: using sqlmodel(sqlchemy+pydantic) as ORM framework and switch to async-based sqlite operation (#2294)

* stage

* stage

* refactor: using sqlchemy as ORM framework, switch to async-based sqlite operation

- using sqlmodel as ORM(based on sqlchemy and pydantic)
- add Persona, Preference, PlatformMessageHistory table

* fix: conversation

* fix: remove redundant explicit session.commit, and fix some type error

* fix: conversation context issue

* chore: remove comments

* chore: remove exclude_content param

* Fix: 当多个相同消息平台实例部署时上下文可能混乱(共享) (#2298)

* perf: update astrbot event session format, using platfrom id to ensure uniqueness

fixes: #1000

* fix: 更新 MessageSession 类以使用 platform_id 作为唯一标识符,并调整相关方法以确保一致性

* fix: 更新 MessageSession 文档以明确 platform_id 的赋值规则,并调整 get_platform 和 get_platform_inst 方法的返回类型

* Improve: 引入全新的人格管理模式以及重构函数工具管理器 (#2305)

* feat: add persona management

* refactor:  重构函数工具管理器,引入 ToolSet,并让 Persona 支持绑定 Tools

* feat: 更新 Persona 工具选择逻辑,支持全选和指定工具的切换

* feat: 更新 BaseDatabase 中的 persona 方法返回类型,支持返回 None

* fix: platform id

* feat: add support to sync mcp servers from ModelScope (#2313)

* fix: 修复访问令牌的空格问题

* chore: 移除 MCP 市场相关逻辑 (#2314)

* chore: 移除 MCP 市场相关路由

* Refactor: 重构配置文件管理,以支持更灵活的、会话粒度的(基于 umo part)配置文件隔离 (#2328)

* refactor: 重构配置文件管理,以支持更灵活的、基于 umo part 的配置文件隔离

* Refactor: 重构配置前端页面,新增数个配置项 (#2331)

* refactor: 重构配置前端页面,新增数个配置项

* feat: 完善多配置文件结构

* perf: 系统配置入口

* fix: normal config item list not display

* fix: 修复 axios 请求中的上下文引用问题

* chore: remove status checking in chat page

* fix: 修复 stage 在不同 pipeline 中被重复使用的问题和 persona 相关问题

* Feature: 增加图片转述提供商配置、支持用户自定义模型模态能力 (#2422)

* feat: 增加图片转述提供商配置、支持用户自定义模型模态能力

* fix: 修复 LLMRequestSubStage 中会话管理方法参数不一致的问题,简化方法调用

* Feature: 优化 WebSearch 的爬取网页速度并且支持使用 Tavily 作为搜索引擎 (#2427)

* feat: 优化了 websearch 的速度;支持 Tavily 作为搜索引擎

* fix: 优化日志记录格式,修复搜索结果处理中的索引和内容显示问题

* feat: 添加对话选中状态管理,优化默认对话加载逻辑

* feat: 支持通过解析URL 的方式导入网页数据到知识库 (#2280)

* feat:为webchat页面添加一个手动上传文件按钮(目前只处理图片)

* fix:上传后清空value,允许触发change事件以多次上传同一张图片

* perf:webchat页面消息发送后清空图片预览缩略图,维持与文本信息行为一致

* perf:将文件输入的值重置为空字符串以提升浏览器兼容性

* feat:webchat文件上传按钮支持多选文件上传

* fix:释放blob URL以防止内存泄漏

* perf:并行化sendMessage中的图片获取逻辑

* feat:完成从url获取部分的UI

* feat: 添加从URL导入功能的组件

* fix: 优化导入结果处理,添加整体摘要和主题摘要的文件命名

* perf: 更新url导入选项添加默认值

* perf: 在导入url的部分配置项未启用时隐藏暂不使用的下拉框选项

* feat: 添加上传前提提示信息至导入url至知识库功能

* feat: 更新导入功能提示信息,添加上传状态通知

* fix: 优化url转知识库错误处理

* feat: 合并知识库的上传文件和 URL 标签页

* feat: 删除导入URL至知识库功能的相关组件

---------

Co-authored-by: Soulter <905617992@qq.com>

* feat: 添加条件显示逻辑以优化插件配置项的可见性管理 (#2433)

* Feature: 支持在 WebUI 配置文件页中配置默认知识库 (#2437)

* feat: 支持配置默认知识库

* chore: clean code

* refactor: 重构 Function Tool 管理并初步引入 Multi Agent 及 Agent Handsoff 机制  (#2454)

* stage

* refactor: 重构 Function Tool 管理并引入 multi agent handsoff 机制

- Updated `star_request.py` to use the global `call_handler` instead of context-specific calls.
- Modified `entities.py` to remove the dependency on `FunctionToolManager` and streamline the function tool handling.
- Refactored `func_tool_manager.py` to simplify the `FunctionTool` class and its methods, removing deprecated code and enhancing clarity.
- Adjusted `provider.py` to align with the new function tool structure, removing unnecessary type unions.
- Enhanced `star_handler.py` to support agent registration and tool association, introducing `RegisteringAgent` for better encapsulation.
- Updated `star_manager.py` to handle tool registration for agents, ensuring proper binding of handlers.
- Revised `main.py` in the web searcher package to utilize the new agent registration system for web search tools.

* chore: websearch

* perf: 减少嵌套

* chore: 移除未使用的 mcp 导入

* feat: 添加 WebUI 迁移助手以及相关迁移方法 (#2477)

* fix: 修复迁移对话时的一些问题

* feat: 增加工具使用模型能力选项

* feat: 添加知识库插件更新检查和更新功能

* perf: 调整 WebUI sidebar 顺序

* refactor: 重构 SharedPreference 类并采用数据库存储替换 json 存储 (#2482)

* perf: 使用 run_coroutine_threadsafe

Co-authored-by: Raven95676 <raven95676@gmail.com>

* Feature: 支持配置重排序模型(vLLM API 格式)用于 score 任务 (#2496)

* feat: 支持添加重排序模型(vLLM API 格式)用于 score 任务

* fix: update rerank API base URL to use localhost

* feat: 知识库支持配置重排序模型

* fix: remove debug print statement for reranked results in FaissVecDB

* fix: 移除知识库中的提示文本

* Feature: 支持在配置文件配置可用的插件组 (#2505)

* feat: 增加可用插件集合配置项

* remove: 旧版平台可用性配置

已经基于多配置文件实现。

* feat: 应用配置文件插件可用性配置

* perf: hoist if from if

* feat: llm_tool 装饰器返回值支持返回 mcp 库中 tool 的返回值类型(mcp.type.CallToolResult) (#2507)

* fix: add type definition for migrationDialog and ensure open method exists before calling

* chore: update project version to 4.0.0

* feat: 多 t2i 服务的随机负载均衡 (#2529)

* fix: bugfixes

* Improve: 扩大配置文件生效范围的自定义程度到会话粒度 (#2532)

* feat: 扩大配置文件生效范围的自定义程度

* perf: 冲突检测

* refactor: simplify config form validation and improve conflict message clarity

* chore: clean code

* feat: 插件配置支持多个快捷魔法配置项

* chore: 修复当自动更新 webchat title 时,history 被重置的问题

* bugfixes

* feat: add custom T2I template editor (#2581)

* perf: add option to clear provider selection in ProviderSelector component

* 📦 release: bump verstion to v4.0.0-beta.1

* chore: delete uv.lock

---------

Co-authored-by: RC-CHN <67079377+RC-CHN@users.noreply.github.com>
Co-authored-by: Raven95676 <raven95676@gmail.com>
2025-09-02 23:39:24 +08:00
Soulter
830151e6da chore: delete uv.lock 2025-09-02 23:31:51 +08:00
Soulter
1e14fba81a 📦 release: bump verstion to v4.0.0-beta.1 2025-09-02 23:27:55 +08:00
Soulter
7b8800c4eb perf: add option to clear provider selection in ProviderSelector component 2025-09-02 21:49:11 +08:00
Soulter
8f4625f53b Merge remote-tracking branch 'origin/master' into releases/4.0.0 2025-08-31 20:37:53 +08:00
Soulter
1e5f243edb 📦 release: v3.5.26 2025-08-31 20:25:05 +08:00
Soulter
e5eab2af34 fix: specify type for devCommits to enhance type safety 2025-08-31 20:16:18 +08:00
Soulter
c10973e160 fix: update getDevCommits function to support GitHub proxy and handle errors more gracefully 2025-08-31 20:06:37 +08:00
Soulter
b1e4bff3ec feat: 支持升级的同时更新到指定版本的 WebUI 2025-08-31 19:55:46 +08:00
Soulter
c1202cda63 fix: update GitHub release action to use correct commit SHA variable 2025-08-31 11:52:42 +08:00
Soulter
32d6cd7776 fix: update GitHub release action parameters for clarity 2025-08-31 11:50:26 +08:00
Soulter
2f78d30e93 feat: automated release from every commit in master branch 2025-08-31 11:42:28 +08:00
Junhua Don
33407c9f0d fix: 修复编辑会话名称窗口的圆角和左右边距问题 (#2583) 2025-08-31 11:12:25 +08:00
Soulter
d2d5ef1c5c feat: add custom T2I template editor (#2581) 2025-08-31 11:11:55 +08:00
RC-CHN
98d8eaee02 feat: 添加 no_proxy 配置支持以优化代理设置 (#2564) 2025-08-26 21:08:46 +08:00
ZvZPvz
10b9228060 feat: 调用 deepseek-reasoner 时自动移除 tools (#2531)
* 调用DeepSeek为思考模式时自动移除tools

* Update astrbot/core/provider/sources/openai_source.py

Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>

* Update openai_source.py

* Update openai_source.py

---------

Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2025-08-26 20:44:56 +08:00
xiewoc
5872f1e017 feat: 支持官方 QQ 接口发送语音 (#2525)
* Update dingtalk_event.py

* Add files via upload

* Add files via upload

* Update qqofficial_platform_adapter.py

* Add files via upload

* chore: clean comments

* chore: clean code

---------

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
2025-08-26 20:40:59 +08:00
Soulter
5073f21002 bugfixes 2025-08-24 23:40:17 +08:00
Soulter
69aaf09ac8 chore: 修复当自动更新 webchat title 时,history 被重置的问题 2025-08-24 00:23:08 +08:00
Soulter
6e61ee81d8 feat: 插件配置支持多个快捷魔法配置项 2025-08-23 21:53:26 +08:00
Soulter
cfd05a8d17 chore: clean code 2025-08-23 21:46:59 +08:00
Raven95676
29845fcc4c feat: Gemini添加对LLMResponse的raw_completion支持 2025-08-23 14:14:25 +08:00
Soulter
e204b180a8 Improve: 扩大配置文件生效范围的自定义程度到会话粒度 (#2532)
* feat: 扩大配置文件生效范围的自定义程度

* perf: 冲突检测

* refactor: simplify config form validation and improve conflict message clarity
2025-08-22 19:31:55 +08:00
Soulter
563972fd29 fix: bugfixes 2025-08-22 17:41:06 +08:00
AkkoYK
cbe94b84fc feat: 为 FishAudio TTS 添加可选的 reference_id 直接指定功能 (#2513)
* 移除TTS提供商:FishAudio TTS的角色名称查询机制,改为直接使用参考模型ID

/// 修改内容 ///
- 移除复杂的角色查询逻辑
删除了 get_reference_id_by_character 方法
移除了通过角色名称搜索模型ID的API调用逻辑
- 简化配置字段
将 fishaudio-tts-character 字段替换为 fishaudio-tts-reference-id
设置默认值为可莉的模型ID:626bb6d3f3364c9cbc3aa6a67300a664
- 优化代码结构
直接在初始化时获取reference_id
简化请求生成逻辑,直接使用配置的模型ID

/// 修改原因 ///
避免同名冲突:不同模型可能使用相同的角色名称,导致获取错误的模型
提高性能:移除了额外的API查询步骤,减少延迟
增强可靠性:用户直接指定准确的模型ID,避免搜索失败的情况
简化维护:减少了代码复杂度,降低维护成本

/// 新的使用方式 ///
用户需要从 FishAudio 模型的详情页面/URL 中获取具体的模型ID(如 626bb6d3f3364c9cbc3aa6a67300a664),并在配置中直接填入 fishaudio-tts-reference-id 字段。

这个修改使得FishAudio TTS的配置更加直观和可靠,同时提升了系统的整体性能。

* Refactor: 添加FishAudio TTS reference_id格式验证

添加ID格式验证逻辑,防止无效的reference_id调用API失败。
验证32位十六进制格式并提供详细错误提示。

* Feat: 添加FishAudio TTS可选reference_id配置实现向前兼容

新增可选的reference_id字段,优先使用直接ID,未配置时回退到角色名称查询。
保持完全向前兼容,现有配置无需修改。
2025-08-22 16:55:07 +08:00
Soulter
aa6f73574d feat: 多 t2i 服务的随机负载均衡 (#2529) 2025-08-22 16:43:59 +08:00
Soulter
94f0419ef7 docs: update readme 2025-08-20 16:41:22 +08:00
Soulter
cefd2d7f49 chore: update project version to 4.0.0 2025-08-20 15:48:37 +08:00
Soulter
81e1e545fb fix: add type definition for migrationDialog and ensure open method exists before calling 2025-08-20 15:48:12 +08:00
Soulter
d516920e72 Merge remote-tracking branch 'origin/master' into releases/4.0.0 2025-08-20 15:43:54 +08:00
Soulter
2171372246 feat: llm_tool 装饰器返回值支持返回 mcp 库中 tool 的返回值类型(mcp.type.CallToolResult) (#2507) 2025-08-20 15:33:46 +08:00
Soulter
d2df4d0cce Feature: 支持在配置文件配置可用的插件组 (#2505)
* feat: 增加可用插件集合配置项

* remove: 旧版平台可用性配置

已经基于多配置文件实现。

* feat: 应用配置文件插件可用性配置

* perf: hoist if from if
2025-08-20 15:25:41 +08:00
Soulter
6ab90fc123 fix: 移除知识库中的提示文本 2025-08-20 11:27:02 +08:00
Soulter
1a84ebbb1e fix: remove debug print statement for reranked results in FaissVecDB 2025-08-19 17:57:16 +08:00
Soulter
c9c0352369 feat: 知识库支持配置重排序模型 2025-08-19 17:51:01 +08:00
Soulter
9903b028a3 Feature: 支持配置重排序模型(vLLM API 格式)用于 score 任务 (#2496)
* feat: 支持添加重排序模型(vLLM API 格式)用于 score 任务

* fix: update rerank API base URL to use localhost
2025-08-19 16:15:31 +08:00
Soulter
49def5d883 📦 release: v3.5.25 2025-08-19 01:32:24 +08:00
Soulter
6975525b70 feat: 添加预发布版本提醒和检测功能 2025-08-19 01:15:56 +08:00
Soulter
fbc4f8527b Merge remote-tracking branch 'origin/master' into releases/4.0.0 2025-08-19 00:53:36 +08:00
Soulter
90cb5a1951 fix: 当返回文本为空并且存在工具调用时错误地被终止事件,导致工具调用结果未被返回 (#2491)
fixes: #2448 #2379
2025-08-19 00:52:13 +08:00
Soulter
ac71d9f034 perf: 使用 run_coroutine_threadsafe
Co-authored-by: Raven95676 <raven95676@gmail.com>
2025-08-18 19:32:35 +08:00
Soulter
64bcbc9fc0 refactor: 重构 SharedPreference 类并采用数据库存储替换 json 存储 (#2482) 2025-08-18 19:12:26 +08:00
Soulter
9e7d46f956 perf: 调整 WebUI sidebar 顺序 2025-08-18 11:57:01 +08:00
Soulter
e911896cfb feat: 添加知识库插件更新检查和更新功能 2025-08-18 11:27:48 +08:00
Soulter
9c6d66093f feat: 增加工具使用模型能力选项 2025-08-18 10:37:10 +08:00
Soulter
b2e39b9701 fix: 修复迁移对话时的一些问题 2025-08-17 23:44:08 +08:00
Soulter
e95ad4049b feat: 添加 WebUI 迁移助手以及相关迁移方法 (#2477) 2025-08-17 23:24:30 +08:00
Soulter
1df49d1d6f refactor: 重构 Function Tool 管理并初步引入 Multi Agent 及 Agent Handsoff 机制 (#2454)
* stage

* refactor: 重构 Function Tool 管理并引入 multi agent handsoff 机制

- Updated `star_request.py` to use the global `call_handler` instead of context-specific calls.
- Modified `entities.py` to remove the dependency on `FunctionToolManager` and streamline the function tool handling.
- Refactored `func_tool_manager.py` to simplify the `FunctionTool` class and its methods, removing deprecated code and enhancing clarity.
- Adjusted `provider.py` to align with the new function tool structure, removing unnecessary type unions.
- Enhanced `star_handler.py` to support agent registration and tool association, introducing `RegisteringAgent` for better encapsulation.
- Updated `star_manager.py` to handle tool registration for agents, ensuring proper binding of handlers.
- Revised `main.py` in the web searcher package to utilize the new agent registration system for web search tools.

* chore: websearch

* perf: 减少嵌套

* chore: 移除未使用的 mcp 导入
2025-08-17 10:57:25 +08:00
Junhua Don
b71000e2f3 fix: 修复无法清空 http_proxy 代理的问题 (#2434)
* fix: 修复无法清空http_proxy代理的问题

* perf: 将“127.0.0.1”和“::1”添加到“no_proxy”以确保所有本地流量绕过代理。

Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2025-08-17 10:49:23 +08:00
Soulter
47e6ed455e Feature: 支持在 WebUI 配置文件页中配置默认知识库 (#2437)
* feat: 支持配置默认知识库

* chore: clean code
2025-08-15 12:40:46 +08:00
Soulter
92592fb9d9 Merge remote-tracking branch 'origin/master' into releases/4.0.0 2025-08-14 23:55:08 +08:00
Soulter
be8a0991ed feat: 添加条件显示逻辑以优化插件配置项的可见性管理 (#2433) 2025-08-14 14:56:31 +08:00
RC-CHN
61aac9c80c feat: 支持通过解析URL 的方式导入网页数据到知识库 (#2280)
* feat:为webchat页面添加一个手动上传文件按钮(目前只处理图片)

* fix:上传后清空value,允许触发change事件以多次上传同一张图片

* perf:webchat页面消息发送后清空图片预览缩略图,维持与文本信息行为一致

* perf:将文件输入的值重置为空字符串以提升浏览器兼容性

* feat:webchat文件上传按钮支持多选文件上传

* fix:释放blob URL以防止内存泄漏

* perf:并行化sendMessage中的图片获取逻辑

* feat:完成从url获取部分的UI

* feat: 添加从URL导入功能的组件

* fix: 优化导入结果处理,添加整体摘要和主题摘要的文件命名

* perf: 更新url导入选项添加默认值

* perf: 在导入url的部分配置项未启用时隐藏暂不使用的下拉框选项

* feat: 添加上传前提提示信息至导入url至知识库功能

* feat: 更新导入功能提示信息,添加上传状态通知

* fix: 优化url转知识库错误处理

* feat: 合并知识库的上传文件和 URL 标签页

* feat: 删除导入URL至知识库功能的相关组件

---------

Co-authored-by: Soulter <905617992@qq.com>
2025-08-14 14:01:11 +08:00
Soulter
60af83cfee feat: 添加对话选中状态管理,优化默认对话加载逻辑 2025-08-14 13:53:36 +08:00
Soulter
cf64e6c231 Merge remote-tracking branch 'origin/master' into releases/4.0.0 2025-08-14 13:36:19 +08:00
Soulter
b711140f26 Feature: 优化 WebSearch 的爬取网页速度并且支持使用 Tavily 作为搜索引擎 (#2427)
* feat: 优化了 websearch 的速度;支持 Tavily 作为搜索引擎

* fix: 优化日志记录格式,修复搜索结果处理中的索引和内容显示问题
2025-08-14 10:52:35 +08:00
Soulter
1d766001bb Feature: 增加图片转述提供商配置、支持用户自定义模型模态能力 (#2422)
* feat: 增加图片转述提供商配置、支持用户自定义模型模态能力

* fix: 修复 LLMRequestSubStage 中会话管理方法参数不一致的问题,简化方法调用
2025-08-13 19:11:17 +08:00
Soulter
0759a11a85 fix: 修复 stage 在不同 pipeline 中被重复使用的问题和 persona 相关问题 2025-08-13 13:13:04 +08:00
Soulter
cb749a38ab chore: remove status checking in chat page 2025-08-13 10:45:50 +08:00
Soulter
369eab18ab Refactor: 重构配置文件管理,以支持更灵活的、会话粒度的(基于 umo part)配置文件隔离 (#2328)
* refactor: 重构配置文件管理,以支持更灵活的、基于 umo part 的配置文件隔离

* Refactor: 重构配置前端页面,新增数个配置项 (#2331)

* refactor: 重构配置前端页面,新增数个配置项

* feat: 完善多配置文件结构

* perf: 系统配置入口

* fix: normal config item list not display

* fix: 修复 axios 请求中的上下文引用问题
2025-08-13 09:18:49 +08:00
Soulter
6c1f540170 chore: 移除 MCP 市场相关路由 2025-08-04 19:21:27 +08:00
Soulter
d026a9f009 chore: 移除 MCP 市场相关逻辑 (#2314) 2025-08-04 17:40:21 +08:00
Soulter
a8e7dadd39 fix: 修复访问令牌的空格问题 2025-08-04 17:26:22 +08:00
Soulter
2f8d921adf feat: add support to sync mcp servers from ModelScope (#2313) 2025-08-04 17:24:07 +08:00
Soulter
0c6e526f94 fix: platform id 2025-08-04 13:10:37 +08:00
Soulter
b1e3018b6b Improve: 引入全新的人格管理模式以及重构函数工具管理器 (#2305)
* feat: add persona management

* refactor:  重构函数工具管理器,引入 ToolSet,并让 Persona 支持绑定 Tools

* feat: 更新 Persona 工具选择逻辑,支持全选和指定工具的切换

* feat: 更新 BaseDatabase 中的 persona 方法返回类型,支持返回 None
2025-08-04 00:56:26 +08:00
Soulter
87f05fce66 Fix: 当多个相同消息平台实例部署时上下文可能混乱(共享) (#2298)
* perf: update astrbot event session format, using platfrom id to ensure uniqueness

fixes: #1000

* fix: 更新 MessageSession 类以使用 platform_id 作为唯一标识符,并调整相关方法以确保一致性

* fix: 更新 MessageSession 文档以明确 platform_id 的赋值规则,并调整 get_platform 和 get_platform_inst 方法的返回类型
2025-08-02 21:38:55 +08:00
Soulter
1b37530c96 Merge remote-tracking branch 'origin/master' into releases/4.0.0 2025-08-02 20:14:18 +08:00
Soulter
842c3c8ea9 Refactor: using sqlmodel(sqlchemy+pydantic) as ORM framework and switch to async-based sqlite operation (#2294)
* stage

* stage

* refactor: using sqlchemy as ORM framework, switch to async-based sqlite operation

- using sqlmodel as ORM(based on sqlchemy and pydantic)
- add Persona, Preference, PlatformMessageHistory table

* fix: conversation

* fix: remove redundant explicit session.commit, and fix some type error

* fix: conversation context issue

* chore: remove comments

* chore: remove exclude_content param
2025-08-02 15:44:00 +08:00
174 changed files with 16378 additions and 6516 deletions

View File

@@ -25,6 +25,8 @@ jobs:
echo "COMMIT_SHA=$(git rev-parse HEAD)" >> $GITHUB_ENV
mkdir -p dashboard/dist/assets
echo $COMMIT_SHA > dashboard/dist/assets/version
cd dashboard
zip -r dist.zip dist
- name: Archive production artifacts
uses: actions/upload-artifact@v4
@@ -33,3 +35,13 @@ jobs:
path: |
dashboard/dist
!dist/**/*.md
- name: Create GitHub Release
uses: ncipollo/release-action@v1
with:
tag: release-${{ github.sha }}
owner: AstrBotDevs
repo: astrbot-release-harbour
body: "Automated release from commit ${{ github.sha }}"
token: ${{ secrets.ASTRBOT_HARBOUR_TOKEN }}
artifacts: "dashboard/dist.zip"

View File

@@ -27,6 +27,33 @@ jobs:
if: github.event_name == 'workflow_dispatch'
run: git checkout ${{ steps.get-latest-tag.outputs.latest_tag }}
- name: Check if version is pre-release
id: check-prerelease
run: |
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
version="${{ steps.get-latest-tag.outputs.latest_tag }}"
else
version="${{ github.ref_name }}"
fi
if [[ "$version" == *"beta"* ]] || [[ "$version" == *"alpha"* ]]; then
echo "is_prerelease=true" >> $GITHUB_OUTPUT
echo "Version $version is a pre-release, will not push latest tag"
else
echo "is_prerelease=false" >> $GITHUB_OUTPUT
echo "Version $version is a stable release, will push latest tag"
fi
- name: Build Dashboard
run: |
cd dashboard
npm install
npm run build
mkdir -p dist/assets
echo $(git rev-parse HEAD) > dist/assets/version
cd ..
mkdir -p data
cp -r dashboard/dist data/
- name: Set QEMU
uses: docker/setup-qemu-action@v3
@@ -53,9 +80,9 @@ jobs:
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ secrets.DOCKER_HUB_USERNAME }}/astrbot:latest
${{ steps.check-prerelease.outputs.is_prerelease == 'false' && format('{0}/astrbot:latest', secrets.DOCKER_HUB_USERNAME) || '' }}
${{ secrets.DOCKER_HUB_USERNAME }}/astrbot:${{ github.event_name == 'workflow_dispatch' && steps.get-latest-tag.outputs.latest_tag || github.ref_name }}
ghcr.io/soulter/astrbot:latest
${{ steps.check-prerelease.outputs.is_prerelease == 'false' && 'ghcr.io/soulter/astrbot:latest' || '' }}
ghcr.io/soulter/astrbot:${{ github.event_name == 'workflow_dispatch' && steps.get-latest-tag.outputs.latest_tag || github.ref_name }}
- name: Post build notifications

View File

@@ -1,4 +1,4 @@
<p align="center">
<img width="430" height="31" alt="image" src="https://github.com/user-attachments/assets/474c822c-fab7-41be-8c23-6dae252823ed" /><p align="center">
![AstrBot-Logo-Simplified](https://github.com/user-attachments/assets/ffd99b6b-3272-4682-beaa-6fe74250f7d9)
@@ -6,8 +6,6 @@
<div align="center">
_✨ 易上手的多平台 LLM 聊天机器人及开发框架 ✨_
<a href="https://trendshift.io/repositories/12875" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12875" alt="Soulter%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/Soulter/AstrBot?style=for-the-badge&color=76bad9)](https://github.com/Soulter/AstrBot/releases/latest)
@@ -25,9 +23,9 @@ _✨ 易上手的多平台 LLM 聊天机器人及开发框架 ✨_
<a href="https://github.com/Soulter/AstrBot/issues">问题提交</a>
</div>
AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用的插件系统和完善的大语言模型LLM接入功能的聊天机器人及开发框架。
AstrBot 是一个开源的一站式 Agentic 聊天机器人平台及开发框架。
## 主要功能
## 主要功能
1. **大模型对话**。支持接入多种大模型服务。支持多模态、工具调用、MCP、原生知识库、人设等功能。
2. **多消息平台支持**。支持接入 QQ、企业微信、微信公众号、飞书、Telegram、钉钉、Discord、KOOK 等平台。支持速率限制、白名单、百度内容审核。
@@ -35,7 +33,7 @@ AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用
4. **插件扩展**。深度优化的插件机制,支持[开发插件](https://astrbot.app/dev/plugin.html)扩展功能,社区插件生态丰富。
5. **WebUI**。可视化配置和管理机器人,功能齐全。
## ✨ 使用方式
## 部署方式
#### Docker 部署
@@ -79,9 +77,7 @@ AstrBot 已由雨云官方上架至云应用平台,可一键部署。
#### 手动部署
> 推荐使用 `uv`。
首先,安装 uv
首先安装 uv
```bash
pip install uv
@@ -96,6 +92,26 @@ uv run main.py
或者请参阅官方文档 [通过源码部署 AstrBot](https://astrbot.app/deploy/astrbot/cli.html) 。
## 🌍 社区
### QQ 群组
- 1 群322154837
- 3 群630166526
- 5 群822130018
- 6 群753075035
- 开发者群753075035
- 开发者群备份295657329
### Telegram 群组
<a href="https://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
### Discord 群组
<a href="https://discord.gg/hAVk6tgV36"><img alt="Discord_community" src="https://img.shields.io/badge/Discord-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
## ⚡ 消息平台支持情况
| 平台 | 支持性 |
@@ -112,22 +128,18 @@ uv run main.py
| Discord | ✔ |
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | ✔ |
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | ✔ |
| 微信对话开放平台 | 🚧 |
| WhatsApp | 🚧 |
| 小爱音响 | 🚧 |
## ⚡ 提供商支持情况
| 名称 | 支持性 | 类型 | 备注 |
| -------- | ------- | ------- | ------- |
| OpenAI API | ✔ | 文本生成 | 支持 DeepSeek、Gemini、Kimi、xAI 等兼容 OpenAI API 的服务 |
| Claude API | ✔ | 文本生成 | |
| Google Gemini API | ✔ | 文本生成 | |
| OpenAI | ✔ | 文本生成 | 支持任何兼容 OpenAI API 的服务 |
| Anthropic | ✔ | 文本生成 | |
| Google Gemini | ✔ | 文本生成 | |
| Dify | ✔ | LLMOps | |
| 阿里云百炼应用 | ✔ | LLMOps | |
| Ollama | ✔ | 模型加载器 | 本地部署 DeepSeek、Llama 等开源语言模型 |
| LM Studio | ✔ | 模型加载器 | 本地部署 DeepSeek、Llama 等开源语言模型 |
| LLMTuner | ✔ | 模型加载器 | 本地加载 lora 等微调模型 |
| [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | ✔ | 模型 API 及算力服务平台 | |
| [302.AI](https://share.302.ai/rr1M3l) | ✔ | 模型 API 服务平台 | |
| 硅基流动 | ✔ | 模型 API 服务平台 | |
@@ -143,7 +155,6 @@ uv run main.py
| 阿里云百炼 TTS | ✔ | 文本转语音 | |
| Azure TTS | ✔ | 文本转语音 | Microsoft Azure TTS |
## ❤️ 贡献
欢迎任何 Issues/Pull Requests只需要将你的更改提交到此项目 )
@@ -162,38 +173,6 @@ pip install pre-commit
pre-commit install
```
## 🌟 支持
- Star 这个项目!
- 在[爱发电](https://afdian.com/a/soulter)支持我!
## ✨ Demo
<details><summary>👉 点击展开多张 Demo 截图 👈</summary>
<div align='center'>
<img src="https://github.com/user-attachments/assets/4ee688d9-467d-45c8-99d6-368f9a8a92d8" width="600">
_✨基于 Docker 的沙箱化代码执行器Beta 测试✨_
<img src="https://github.com/user-attachments/assets/0378f407-6079-4f64-ae4c-e97ab20611d2" height=500>
_✨ 多模态、网页搜索、长文本转图片(可配置) ✨_
<img src="https://github.com/user-attachments/assets/e137a9e1-340a-4bf2-bb2b-771132780735" height=150>
<img src="https://github.com/user-attachments/assets/480f5e82-cf6a-4955-a869-0d73137aa6e1" height=150>
_✨ 插件系统——部分插件展示 ✨_
<img src="https://github.com/user-attachments/assets/0cdbf564-2f59-4da5-b524-ce0e7ef3d978" width=600>
_✨ WebUI ✨_
</div>
</details>
## ❤️ Special Thanks
@@ -219,7 +198,8 @@ _✨ WebUI ✨_
</div>
![10k-star-banner-credit-by-kevin](https://github.com/user-attachments/assets/c97fc5fb-20b9-4bc8-9998-c20b930ab097)
</details>
_私は、高性能ですから!_

View File

@@ -3,5 +3,18 @@ from astrbot import logger
from astrbot.core import html_renderer
from astrbot.core import sp
from astrbot.core.star.register import register_llm_tool as llm_tool
from astrbot.core.star.register import register_agent as agent
from astrbot.core.agent.tool import ToolSet, FunctionTool
from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor
__all__ = ["AstrBotConfig", "logger", "html_renderer", "llm_tool", "sp"]
__all__ = [
"AstrBotConfig",
"logger",
"html_renderer",
"llm_tool",
"agent",
"sp",
"ToolSet",
"FunctionTool",
"BaseFunctionToolExecutor",
]

View File

@@ -37,7 +37,10 @@ async def check_dashboard(astrbot_root: Path) -> None:
):
click.echo("正在安装管理面板...")
await download_dashboard(
path="data/dashboard.zip", extract_path=str(astrbot_root)
path="data/dashboard.zip",
extract_path=str(astrbot_root),
version=f"v{VERSION}",
latest=False,
)
click.echo("管理面板安装完成")
@@ -50,7 +53,10 @@ async def check_dashboard(astrbot_root: Path) -> None:
version = dashboard_version.split("v")[1]
click.echo(f"管理面板版本: {version}")
await download_dashboard(
path="data/dashboard.zip", extract_path=str(astrbot_root)
path="data/dashboard.zip",
extract_path=str(astrbot_root),
version=f"v{VERSION}",
latest=False,
)
except Exception as e:
click.echo(f"下载管理面板失败: {e}")
@@ -59,7 +65,10 @@ async def check_dashboard(astrbot_root: Path) -> None:
click.echo("初始化管理面板目录...")
try:
await download_dashboard(
path=str(astrbot_root / "dashboard.zip"), extract_path=str(astrbot_root)
path=str(astrbot_root / "dashboard.zip"),
extract_path=str(astrbot_root),
version=f"v{VERSION}",
latest=False,
)
click.echo("管理面板初始化完成")
except Exception as e:

View File

@@ -1,5 +1,4 @@
import os
import asyncio
from .log import LogManager, LogBroker # noqa
from astrbot.core.utils.t2i.renderer import HtmlRenderer
from astrbot.core.utils.shared_preferences import SharedPreferences
@@ -21,7 +20,7 @@ html_renderer = HtmlRenderer(t2i_base_url)
logger = LogManager.GetLogger(log_name="astrbot")
db_helper = SQLiteDatabase(DB_PATH)
# 简单的偏好设置存储, 这里后续应该存储到数据库中, 一些部分可以存储到配置中
sp = SharedPreferences()
sp = SharedPreferences(db_helper=db_helper)
# 文件令牌服务
file_token_service = FileTokenService()
pip_installer = PipInstaller(

View File

@@ -0,0 +1,13 @@
from dataclasses import dataclass
from .tool import FunctionTool
from typing import Generic
from .run_context import TContext
from .hooks import BaseAgentRunHooks
@dataclass
class Agent(Generic[TContext]):
name: str
instructions: str | None = None
tools: list[str, FunctionTool] | None = None
run_hooks: BaseAgentRunHooks[TContext] | None = None

View File

@@ -0,0 +1,34 @@
from typing import Generic
from .tool import FunctionTool
from .agent import Agent
from .run_context import TContext
class HandoffTool(FunctionTool, Generic[TContext]):
"""Handoff tool for delegating tasks to another agent."""
def __init__(
self, agent: Agent[TContext], parameters: dict | None = None, **kwargs
):
self.agent = agent
super().__init__(
name=f"transfer_to_{agent.name}",
parameters=parameters or self.default_parameters(),
description=agent.instructions or self.default_description(agent.name),
**kwargs,
)
def default_parameters(self) -> dict:
return {
"type": "object",
"properties": {
"input": {
"type": "string",
"description": "The input to be handed off to another agent. This should be a clear and concise request or task.",
},
},
}
def default_description(self, agent_name: str | None) -> str:
agent_name = agent_name or "another"
return f"Delegate tasks to {self.name} agent to handle the request."

View File

@@ -0,0 +1,27 @@
import mcp
from dataclasses import dataclass
from .run_context import ContextWrapper, TContext
from typing import Generic
from astrbot.core.provider.entities import LLMResponse
from astrbot.core.agent.tool import FunctionTool
@dataclass
class BaseAgentRunHooks(Generic[TContext]):
async def on_agent_begin(self, run_context: ContextWrapper[TContext]): ...
async def on_tool_start(
self,
run_context: ContextWrapper[TContext],
tool: FunctionTool,
tool_args: dict | None,
): ...
async def on_tool_end(
self,
run_context: ContextWrapper[TContext],
tool: FunctionTool,
tool_args: dict | None,
tool_result: mcp.types.CallToolResult | None,
): ...
async def on_agent_done(
self, run_context: ContextWrapper[TContext], llm_response: LLMResponse
): ...

View File

@@ -0,0 +1,208 @@
import asyncio
import logging
from datetime import timedelta
from typing import Optional
from contextlib import AsyncExitStack
from astrbot import logger
from astrbot.core.utils.log_pipe import LogPipe
try:
import mcp
from mcp.client.sse import sse_client
except (ModuleNotFoundError, ImportError):
logger.warning("警告: 缺少依赖库 'mcp',将无法使用 MCP 服务。")
try:
from mcp.client.streamable_http import streamablehttp_client
except (ModuleNotFoundError, ImportError):
logger.warning(
"警告: 缺少依赖库 'mcp' 或者 mcp 库版本过低,无法使用 Streamable HTTP 连接方式。"
)
def _prepare_config(config: dict) -> dict:
"""准备配置,处理嵌套格式"""
if "mcpServers" in config and config["mcpServers"]:
first_key = next(iter(config["mcpServers"]))
config = config["mcpServers"][first_key]
config.pop("active", None)
return config
async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]:
"""快速测试 MCP 服务器可达性"""
import aiohttp
cfg = _prepare_config(config.copy())
url = cfg["url"]
headers = cfg.get("headers", {})
timeout = cfg.get("timeout", 10)
try:
async with aiohttp.ClientSession() as session:
if cfg.get("transport") == "streamable_http":
test_payload = {
"jsonrpc": "2.0",
"method": "initialize",
"id": 0,
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "test-client", "version": "1.2.3"},
},
}
async with session.post(
url,
headers={
**headers,
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
},
json=test_payload,
timeout=aiohttp.ClientTimeout(total=timeout),
) as response:
if response.status == 200:
return True, ""
else:
return False, f"HTTP {response.status}: {response.reason}"
else:
async with session.get(
url,
headers={
**headers,
"Accept": "application/json, text/event-stream",
},
timeout=aiohttp.ClientTimeout(total=timeout),
) as response:
if response.status == 200:
return True, ""
else:
return False, f"HTTP {response.status}: {response.reason}"
except asyncio.TimeoutError:
return False, f"连接超时: {timeout}"
except Exception as e:
return False, f"{e!s}"
class MCPClient:
def __init__(self):
# Initialize session and client objects
self.session: Optional[mcp.ClientSession] = None
self.exit_stack = AsyncExitStack()
self.name = None
self.active: bool = True
self.tools: list[mcp.Tool] = []
self.server_errlogs: list[str] = []
self.running_event = asyncio.Event()
async def connect_to_server(self, mcp_server_config: dict, name: str):
"""连接到 MCP 服务器
如果 `url` 参数存在:
1. 当 transport 指定为 `streamable_http` 时,使用 Streamable HTTP 连接方式。
1. 当 transport 指定为 `sse` 时,使用 SSE 连接方式。
2. 如果没有指定,默认使用 SSE 的方式连接到 MCP 服务。
Args:
mcp_server_config (dict): Configuration for the MCP server. See https://modelcontextprotocol.io/quickstart/server
"""
cfg = _prepare_config(mcp_server_config.copy())
def logging_callback(msg: str):
# 处理 MCP 服务的错误日志
print(f"MCP Server {name} Error: {msg}")
self.server_errlogs.append(msg)
if "url" in cfg:
success, error_msg = await _quick_test_mcp_connection(cfg)
if not success:
raise Exception(error_msg)
if cfg.get("transport") != "streamable_http":
# SSE transport method
self._streams_context = sse_client(
url=cfg["url"],
headers=cfg.get("headers", {}),
timeout=cfg.get("timeout", 5),
sse_read_timeout=cfg.get("sse_read_timeout", 60 * 5),
)
streams = await self.exit_stack.enter_async_context(
self._streams_context
)
# Create a new client session
read_timeout = timedelta(seconds=cfg.get("session_read_timeout", 20))
self.session = await self.exit_stack.enter_async_context(
mcp.ClientSession(
*streams,
read_timeout_seconds=read_timeout,
logging_callback=logging_callback, # type: ignore
)
)
else:
timeout = timedelta(seconds=cfg.get("timeout", 30))
sse_read_timeout = timedelta(
seconds=cfg.get("sse_read_timeout", 60 * 5)
)
self._streams_context = streamablehttp_client(
url=cfg["url"],
headers=cfg.get("headers", {}),
timeout=timeout,
sse_read_timeout=sse_read_timeout,
terminate_on_close=cfg.get("terminate_on_close", True),
)
read_s, write_s, _ = await self.exit_stack.enter_async_context(
self._streams_context
)
# Create a new client session
read_timeout = timedelta(seconds=cfg.get("session_read_timeout", 20))
self.session = await self.exit_stack.enter_async_context(
mcp.ClientSession(
read_stream=read_s,
write_stream=write_s,
read_timeout_seconds=read_timeout,
logging_callback=logging_callback, # type: ignore
)
)
else:
server_params = mcp.StdioServerParameters(
**cfg,
)
def callback(msg: str):
# 处理 MCP 服务的错误日志
self.server_errlogs.append(msg)
stdio_transport = await self.exit_stack.enter_async_context(
mcp.stdio_client(
server_params,
errlog=LogPipe(
level=logging.ERROR,
logger=logger,
identifier=f"MCPServer-{name}",
callback=callback,
), # type: ignore
),
)
# Create a new client session
self.session = await self.exit_stack.enter_async_context(
mcp.ClientSession(*stdio_transport)
)
await self.session.initialize()
async def list_tools_and_save(self) -> mcp.ListToolsResult:
"""List all tools from the server and save them to self.tools"""
response = await self.session.list_tools()
self.tools = response.tools
return response
async def cleanup(self):
"""Clean up resources"""
await self.exit_stack.aclose()
self.running_event.set() # Set the running event to indicate cleanup is done

View File

@@ -0,0 +1,12 @@
from dataclasses import dataclass
import typing as T
from astrbot.core.message.message_event_result import MessageChain
class AgentResponseData(T.TypedDict):
chain: MessageChain
@dataclass
class AgentResponse:
type: str
data: AgentResponseData

View File

@@ -0,0 +1,17 @@
from dataclasses import dataclass
from typing import Any, Generic
from typing_extensions import TypeVar
from astrbot.core.platform.astr_message_event import AstrMessageEvent
TContext = TypeVar("TContext", default=Any)
@dataclass
class ContextWrapper(Generic[TContext]):
"""A context for running an agent, which can be used to pass additional data or state."""
context: TContext
event: AstrMessageEvent
NoContext = ContextWrapper[None]

View File

@@ -0,0 +1,3 @@
from .base import BaseAgentRunner
__all__ = ["BaseAgentRunner"]

View File

@@ -1,32 +1,33 @@
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
from ..run_context import ContextWrapper, TContext
from ..response import AgentResponse
from ..hooks import BaseAgentRunHooks
from ..tool_executor import BaseFunctionToolExecutor
from astrbot.core.provider import Provider
from astrbot.core.provider.entities import LLMResponse
class AgentState(Enum):
"""Agent 状态枚举"""
IDLE = auto() # 初始状态
RUNNING = auto() # 运行中
DONE = auto() # 完成
ERROR = auto() # 错误状态
"""Defines the state of the agent."""
IDLE = auto() # Initial state
RUNNING = auto() # Currently processing
DONE = auto() # Completed
ERROR = auto() # Error state
class AgentResponseData(T.TypedDict):
chain: MessageChain
@dataclass
class AgentResponse:
type: str
data: AgentResponseData
class BaseAgentRunner:
class BaseAgentRunner(T.Generic[TContext]):
@abc.abstractmethod
async def reset(self) -> None:
async def reset(
self,
provider: Provider,
run_context: ContextWrapper[TContext],
tool_executor: BaseFunctionToolExecutor[TContext],
agent_hooks: BaseAgentRunHooks[TContext],
**kwargs: T.Any,
) -> None:
"""
Reset the agent to its initial state.
This method should be called before starting a new run.

View File

@@ -1,10 +1,12 @@
import sys
import traceback
import typing as T
from .base import BaseAgentRunner, AgentResponse, AgentResponseData, AgentState
from ...context import PipelineContext
from .base import BaseAgentRunner, AgentResponse, AgentState
from ..hooks import BaseAgentRunHooks
from ..tool_executor import BaseFunctionToolExecutor
from ..run_context import ContextWrapper, TContext
from ..response import AgentResponseData
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,
)
@@ -21,8 +23,8 @@ from mcp.types import (
EmbeddedResource,
TextResourceContents,
BlobResourceContents,
CallToolResult,
)
from astrbot.core.star.star_handler import EventType
from astrbot import logger
if sys.version_info >= (3, 12):
@@ -31,28 +33,25 @@ 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
class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
@override
async def reset(self, req: ProviderRequest, streaming: bool) -> None:
self.req = req
self.streaming = streaming
async def reset(
self,
provider: Provider,
request: ProviderRequest,
run_context: ContextWrapper[TContext],
tool_executor: BaseFunctionToolExecutor[TContext],
agent_hooks: BaseAgentRunHooks[TContext],
**kwargs: T.Any,
) -> None:
self.req = request
self.streaming = kwargs.get("streaming", False)
self.provider = provider
self.final_llm_resp = None
self._state = AgentState.IDLE
self.tool_executor = tool_executor
self.agent_hooks = agent_hooks
self.run_context = run_context
def _transition_state(self, new_state: AgentState) -> None:
"""转换 Agent 状态"""
@@ -78,6 +77,12 @@ class ToolLoopAgent(BaseAgentRunner):
if not self.req:
raise ValueError("Request is not set. Please call reset() first.")
if self._state == AgentState.IDLE:
try:
await self.agent_hooks.on_agent_begin(self.run_context)
except Exception as e:
logger.error(f"Error in on_agent_begin hook: {e}", exc_info=True)
# 开始处理,转换到运行状态
self._transition_state(AgentState.RUNNING)
llm_resp_result = None
@@ -124,12 +129,10 @@ class ToolLoopAgent(BaseAgentRunner):
# 如果没有工具调用,转换到完成状态
self.final_llm_resp = llm_resp
self._transition_state(AgentState.DONE)
# 执行事件钩子
if await self.pipeline_ctx.call_event_hook(
self.event, EventType.OnLLMResponseEvent, llm_resp
):
return
try:
await self.agent_hooks.on_agent_done(self.run_context, llm_resp)
except Exception as e:
logger.error(f"Error in on_agent_done hook: {e}", exc_info=True)
# 返回 LLM 结果
if llm_resp.result_chain:
@@ -193,50 +196,33 @@ class ToolLoopAgent(BaseAgentRunner):
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}"
logger.info(f"使用工具:{func_tool_name},参数:{func_tool_args}")
try:
await self.agent_hooks.on_tool_start(
self.run_context, func_tool, 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(type="tool_direct_result").base64_image(
res.content[0].data
)
elif isinstance(res.content[0], EmbeddedResource):
resource = res.content[0].resource
if isinstance(resource, TextResourceContents):
except Exception as e:
logger.error(f"Error in on_tool_start hook: {e}", exc_info=True)
executor = self.tool_executor.execute(
tool=func_tool,
run_context=self.run_context,
**func_tool_args,
)
async for resp in executor:
if isinstance(resp, CallToolResult):
res = resp
if isinstance(res.content[0], TextContent):
tool_call_result_blocks.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content=resource.text,
content=res.content[0].text,
)
)
yield MessageChain().message(resource.text)
elif (
isinstance(resource, BlobResourceContents)
and resource.mimeType
and resource.mimeType.startswith("image/")
):
yield MessageChain().message(res.content[0].text)
elif isinstance(res.content[0], ImageContent):
tool_call_result_blocks.append(
ToolCallMessageSegment(
role="tool",
@@ -247,43 +233,85 @@ class ToolLoopAgent(BaseAgentRunner):
yield MessageChain(type="tool_direct_result").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, type="tool_direct_result"
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(
type="tool_direct_result"
).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("返回的数据类型不受支持。")
self.event.clear_result()
try:
await self.agent_hooks.on_tool_end(
self.run_context,
func_tool_name,
func_tool_args,
resp,
)
except Exception as e:
logger.error(
f"Error in on_tool_end hook: {e}", exc_info=True
)
elif resp is None:
# Tool 直接请求发送消息给用户
# 这里我们将直接结束 Agent Loop。
self._transition_state(AgentState.DONE)
if res := self.run_context.event.get_result():
if res.chain:
yield MessageChain(
chain=res.chain, type="tool_direct_result"
)
try:
await self.agent_hooks.on_tool_end(
self.run_context, func_tool_name, func_tool_args, None
)
except Exception as e:
logger.error(
f"Error in on_tool_end hook: {e}", exc_info=True
)
else:
logger.warning(
f"Tool 返回了不支持的类型: {type(resp)},将忽略。"
)
try:
await self.agent_hooks.on_tool_end(
self.run_context, func_tool_name, func_tool_args, None
)
except Exception as e:
logger.error(
f"Error in on_tool_end hook: {e}", exc_info=True
)
self.run_context.event.clear_result()
except Exception as e:
logger.warning(traceback.format_exc())
tool_call_result_blocks.append(

256
astrbot/core/agent/tool.py Normal file
View File

@@ -0,0 +1,256 @@
from dataclasses import dataclass
from deprecated import deprecated
from typing import Awaitable, Literal, Any, Optional
from .mcp_client import MCPClient
@dataclass
class FunctionTool:
"""A class representing a function tool that can be used in function calling."""
name: str | None = None
parameters: dict | None = None
description: str | None = None
handler: Awaitable | None = None
"""处理函数, 当 origin 为 mcp 时,这个为空"""
handler_module_path: str | None = None
"""处理函数的模块路径,当 origin 为 mcp 时,这个为空
必须要保留这个字段, handler 在初始化会被 functools.partial 包装,导致 handler 的 __module__ 为 functools
"""
active: bool = True
"""是否激活"""
origin: Literal["local", "mcp"] = "local"
"""函数工具的来源, local 为本地函数工具, mcp 为 MCP 服务"""
# MCP 相关字段
mcp_server_name: str | None = None
"""MCP 服务名称,当 origin 为 mcp 时有效"""
mcp_client: MCPClient | None = None
"""MCP 客户端,当 origin 为 mcp 时有效"""
def __repr__(self):
return f"FuncTool(name={self.name}, parameters={self.parameters}, description={self.description}, active={self.active}, origin={self.origin})"
def __dict__(self) -> dict[str, Any]:
"""将 FunctionTool 转换为字典格式"""
return {
"name": self.name,
"parameters": self.parameters,
"description": self.description,
"active": self.active,
"origin": self.origin,
"mcp_server_name": self.mcp_server_name,
}
class ToolSet:
"""A set of function tools that can be used in function calling.
This class provides methods to add, remove, and retrieve tools, as well as
convert the tools to different API formats (OpenAI, Anthropic, Google GenAI)."""
def __init__(self, tools: list[FunctionTool] = None):
self.tools: list[FunctionTool] = tools or []
def empty(self) -> bool:
"""Check if the tool set is empty."""
return len(self.tools) == 0
def add_tool(self, tool: FunctionTool):
"""Add a tool to the set."""
# 检查是否已存在同名工具
for i, existing_tool in enumerate(self.tools):
if existing_tool.name == tool.name:
self.tools[i] = tool
return
self.tools.append(tool)
def remove_tool(self, name: str):
"""Remove a tool by its name."""
self.tools = [tool for tool in self.tools if tool.name != name]
def get_tool(self, name: str) -> Optional[FunctionTool]:
"""Get a tool by its name."""
for tool in self.tools:
if tool.name == name:
return tool
return None
@deprecated(reason="Use add_tool() instead", version="4.0.0")
def add_func(self, name: str, func_args: list, desc: str, handler: Awaitable):
"""Add a function tool to the set."""
params = {
"type": "object", # hard-coded here
"properties": {},
}
for param in func_args:
params["properties"][param["name"]] = {
"type": param["type"],
"description": param["description"],
}
_func = FunctionTool(
name=name,
parameters=params,
description=desc,
handler=handler,
)
self.add_tool(_func)
@deprecated(reason="Use remove_tool() instead", version="4.0.0")
def remove_func(self, name: str):
"""Remove a function tool by its name."""
self.remove_tool(name)
@deprecated(reason="Use get_tool() instead", version="4.0.0")
def get_func(self, name: str) -> list[FunctionTool]:
"""Get all function tools."""
return self.get_tool(name)
@property
def func_list(self) -> list[FunctionTool]:
"""Get the list of function tools."""
return self.tools
def openai_schema(self, omit_empty_parameter_field: bool = False) -> list[dict]:
"""Convert tools to OpenAI API function calling schema format."""
result = []
for tool in self.tools:
func_def = {
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
},
}
if tool.parameters.get("properties") or not omit_empty_parameter_field:
func_def["function"]["parameters"] = tool.parameters
result.append(func_def)
return result
def anthropic_schema(self) -> list[dict]:
"""Convert tools to Anthropic API format."""
result = []
for tool in self.tools:
tool_def = {
"name": tool.name,
"description": tool.description,
"input_schema": {
"type": "object",
"properties": tool.parameters.get("properties", {}),
"required": tool.parameters.get("required", []),
},
}
result.append(tool_def)
return result
def google_schema(self) -> dict:
"""Convert tools to Google GenAI API format."""
def convert_schema(schema: dict) -> dict:
"""Convert schema to Gemini API format."""
supported_types = {
"string",
"number",
"integer",
"boolean",
"array",
"object",
"null",
}
supported_formats = {
"string": {"enum", "date-time"},
"integer": {"int32", "int64"},
"number": {"float", "double"},
}
if "anyOf" in schema:
return {"anyOf": [convert_schema(s) for s in schema["anyOf"]]}
result = {}
if "type" in schema and schema["type"] in supported_types:
result["type"] = schema["type"]
if "format" in schema and schema["format"] in supported_formats.get(
result["type"], set()
):
result["format"] = schema["format"]
else:
result["type"] = "null"
support_fields = {
"title",
"description",
"enum",
"minimum",
"maximum",
"maxItems",
"minItems",
"nullable",
"required",
}
result.update({k: schema[k] for k in support_fields if k in schema})
if "properties" in schema:
properties = {}
for key, value in schema["properties"].items():
prop_value = convert_schema(value)
if "default" in prop_value:
del prop_value["default"]
properties[key] = prop_value
if properties:
result["properties"] = properties
if "items" in schema:
result["items"] = convert_schema(schema["items"])
return result
tools = [
{
"name": tool.name,
"description": tool.description,
"parameters": convert_schema(tool.parameters),
}
for tool in self.tools
]
declarations = {}
if tools:
declarations["function_declarations"] = tools
return declarations
@deprecated(reason="Use openai_schema() instead", version="4.0.0")
def get_func_desc_openai_style(self, omit_empty_parameter_field: bool = False):
return self.openai_schema(omit_empty_parameter_field)
@deprecated(reason="Use anthropic_schema() instead", version="4.0.0")
def get_func_desc_anthropic_style(self):
return self.anthropic_schema()
@deprecated(reason="Use google_schema() instead", version="4.0.0")
def get_func_desc_google_genai_style(self):
return self.google_schema()
def names(self) -> list[str]:
"""获取所有工具的名称列表"""
return [tool.name for tool in self.tools]
def __len__(self):
return len(self.tools)
def __bool__(self):
return len(self.tools) > 0
def __iter__(self):
return iter(self.tools)
def __repr__(self):
return f"ToolSet(tools={self.tools})"
def __str__(self):
return f"ToolSet(tools={self.tools})"

View File

@@ -0,0 +1,11 @@
import mcp
from typing import Any, Generic, AsyncGenerator
from .run_context import TContext, ContextWrapper
from .tool import FunctionTool
class BaseFunctionToolExecutor(Generic[TContext]):
@classmethod
async def execute(
cls, tool: FunctionTool, run_context: ContextWrapper[TContext], **tool_args
) -> AsyncGenerator[Any | mcp.types.CallToolResult, None]: ...

View File

@@ -0,0 +1,11 @@
from dataclasses import dataclass
from astrbot.core.provider import Provider
from astrbot.core.provider.entities import ProviderRequest
@dataclass
class AstrAgentContext:
provider: Provider
first_provider_request: ProviderRequest
curr_provider_request: ProviderRequest
streaming: bool

View File

@@ -0,0 +1,283 @@
import os
import uuid
from astrbot.core import AstrBotConfig, logger
from astrbot.core.utils.shared_preferences import SharedPreferences
from astrbot.core.config.astrbot_config import ASTRBOT_CONFIG_PATH
from astrbot.core.config.default import DEFAULT_CONFIG
from astrbot.core.platform.message_session import MessageSession
from astrbot.core.utils.astrbot_path import get_astrbot_config_path
from typing import TypeVar, TypedDict
_VT = TypeVar("_VT")
class ConfInfo(TypedDict):
"""Configuration information for a specific session or platform."""
id: str # UUID of the configuration or "default"
umop: list[str] # Unified Message Origin Pattern
name: str
path: str # File name to the configuration file
DEFAULT_CONFIG_CONF_INFO = ConfInfo(
id="default",
umop=["::"],
name="default",
path=ASTRBOT_CONFIG_PATH,
)
class AstrBotConfigManager:
"""A class to manage the system configuration of AstrBot, aka ACM"""
def __init__(self, default_config: AstrBotConfig, sp: SharedPreferences):
self.sp = sp
self.confs: dict[str, AstrBotConfig] = {}
"""uuid / "default" -> AstrBotConfig"""
self.confs["default"] = default_config
self.abconf_data = None
self._load_all_configs()
def _get_abconf_data(self) -> dict:
"""获取所有的 abconf 数据"""
if self.abconf_data is None:
self.abconf_data = self.sp.get(
"abconf_mapping", {}, scope="global", scope_id="global"
)
return self.abconf_data
def _load_all_configs(self):
"""Load all configurations from the shared preferences."""
abconf_data = self._get_abconf_data()
self.abconf_data = abconf_data
for uuid_, meta in abconf_data.items():
filename = meta["path"]
conf_path = os.path.join(get_astrbot_config_path(), filename)
if os.path.exists(conf_path):
conf = AstrBotConfig(config_path=conf_path)
self.confs[uuid_] = conf
else:
logger.warning(
f"Config file {conf_path} for UUID {uuid_} does not exist, skipping."
)
continue
def _is_umo_match(self, p1: str, p2: str) -> bool:
"""判断 p2 umo 是否逻辑包含于 p1 umo"""
p1_ls = p1.split(":")
p2_ls = p2.split(":")
if len(p1_ls) != 3 or len(p2_ls) != 3:
return False # 非法格式
return all(p == "" or p == "*" or p == t for p, t in zip(p1_ls, p2_ls))
def _load_conf_mapping(self, umo: str | MessageSession) -> ConfInfo:
"""获取指定 umo 的配置文件 uuid, 如果不存在则返回默认配置(返回 "default")
Returns:
ConfInfo: 包含配置文件的 uuid, 路径和名称等信息, 是一个 dict 类型
"""
# uuid -> { "umop": list, "path": str, "name": str }
abconf_data = self._get_abconf_data()
if isinstance(umo, MessageSession):
umo = str(umo)
else:
try:
umo = str(MessageSession.from_str(umo)) # validate
except Exception:
return DEFAULT_CONFIG_CONF_INFO
for uuid_, meta in abconf_data.items():
for pattern in meta["umop"]:
if self._is_umo_match(pattern, umo):
return ConfInfo(**meta, id=uuid_)
return DEFAULT_CONFIG_CONF_INFO
def _save_conf_mapping(
self,
abconf_path: str,
abconf_id: str,
umo_parts: list[str] | list[MessageSession],
abconf_name: str | None = None,
) -> None:
"""保存配置文件的映射关系"""
for part in umo_parts:
if isinstance(part, MessageSession):
part = str(part)
elif not isinstance(part, str):
raise ValueError(
"umo_parts must be a list of strings or MessageSession instances"
)
abconf_data = self.sp.get(
"abconf_mapping", {}, scope="global", scope_id="global"
)
random_word = abconf_name or uuid.uuid4().hex[:8]
abconf_data[abconf_id] = {
"umop": umo_parts,
"path": abconf_path,
"name": random_word,
}
self.sp.put("abconf_mapping", abconf_data, scope="global", scope_id="global")
self.abconf_data = abconf_data
def get_conf(self, umo: str | MessageSession | None) -> AstrBotConfig:
"""获取指定 umo 的配置文件。如果不存在,则 fallback 到默认配置文件。"""
if not umo:
return self.confs["default"]
if isinstance(umo, MessageSession):
umo = f"{umo.platform_id}:{umo.message_type}:{umo.session_id}"
uuid_ = self._load_conf_mapping(umo)["id"]
conf = self.confs.get(uuid_)
if not conf:
conf = self.confs["default"] # default MUST exists
return conf
@property
def default_conf(self) -> AstrBotConfig:
"""获取默认配置文件"""
return self.confs["default"]
def get_conf_info(self, umo: str | MessageSession) -> ConfInfo:
"""获取指定 umo 的配置文件元数据"""
if isinstance(umo, MessageSession):
umo = f"{umo.platform_id}:{umo.message_type}:{umo.session_id}"
return self._load_conf_mapping(umo)
def get_conf_list(self) -> list[ConfInfo]:
"""获取所有配置文件的元数据列表"""
conf_list = []
conf_list.append(DEFAULT_CONFIG_CONF_INFO)
abconf_mapping = self._get_abconf_data()
for uuid_, meta in abconf_mapping.items():
conf_list.append(ConfInfo(**meta, id=uuid_))
return conf_list
def create_conf(
self,
umo_parts: list[str] | list[MessageSession],
config: dict = DEFAULT_CONFIG,
name: str | None = None,
) -> str:
"""
umo 由三个部分组成 [platform_id]:[message_type]:[session_id]。
umo_parts 可以是 "::" (代表所有), 可以是 "[platform_id]::" (代表指定平台下的所有类型消息和会话)。
"""
conf_uuid = str(uuid.uuid4())
conf_file_name = f"abconf_{conf_uuid}.json"
conf_path = os.path.join(get_astrbot_config_path(), conf_file_name)
conf = AstrBotConfig(config_path=conf_path, default_config=config)
conf.save_config()
self._save_conf_mapping(conf_file_name, conf_uuid, umo_parts, abconf_name=name)
self.confs[conf_uuid] = conf
return conf_uuid
def delete_conf(self, conf_id: str) -> bool:
"""删除指定配置文件
Args:
conf_id: 配置文件的 UUID
Returns:
bool: 删除是否成功
Raises:
ValueError: 如果试图删除默认配置文件
"""
if conf_id == "default":
raise ValueError("不能删除默认配置文件")
# 从映射中移除
abconf_data = self.sp.get(
"abconf_mapping", {}, scope="global", scope_id="global"
)
if conf_id not in abconf_data:
logger.warning(f"配置文件 {conf_id} 不存在于映射中")
return False
# 获取配置文件路径
conf_path = os.path.join(
get_astrbot_config_path(), abconf_data[conf_id]["path"]
)
# 删除配置文件
try:
if os.path.exists(conf_path):
os.remove(conf_path)
logger.info(f"已删除配置文件: {conf_path}")
except Exception as e:
logger.error(f"删除配置文件 {conf_path} 失败: {e}")
return False
# 从内存中移除
if conf_id in self.confs:
del self.confs[conf_id]
# 从映射中移除
del abconf_data[conf_id]
self.sp.put("abconf_mapping", abconf_data, scope="global", scope_id="global")
self.abconf_data = abconf_data
logger.info(f"成功删除配置文件 {conf_id}")
return True
def update_conf_info(
self, conf_id: str, name: str | None = None, umo_parts: list[str] | None = None
) -> bool:
"""更新配置文件信息
Args:
conf_id: 配置文件的 UUID
name: 新的配置文件名称 (可选)
umo_parts: 新的 UMO 部分列表 (可选)
Returns:
bool: 更新是否成功
"""
if conf_id == "default":
raise ValueError("不能更新默认配置文件的信息")
abconf_data = self.sp.get(
"abconf_mapping", {}, scope="global", scope_id="global"
)
if conf_id not in abconf_data:
logger.warning(f"配置文件 {conf_id} 不存在于映射中")
return False
# 更新名称
if name is not None:
abconf_data[conf_id]["name"] = name
# 更新 UMO 部分
if umo_parts is not None:
# 验证 UMO 部分格式
for part in umo_parts:
if isinstance(part, MessageSession):
part = str(part)
elif not isinstance(part, str):
raise ValueError(
"umo_parts must be a list of strings or MessageSession instances"
)
abconf_data[conf_id]["umop"] = umo_parts
# 保存更新
self.sp.put("abconf_mapping", abconf_data, scope="global", scope_id="global")
self.abconf_data = abconf_data
logger.info(f"成功更新配置文件 {conf_id} 的信息")
return True
def g(
self, umo: str | None = None, key: str | None = None, default: _VT = None
) -> _VT:
"""获取配置项。umo 为 None 时使用默认配置"""
if umo is None:
return self.confs["default"].get(key, default)
conf = self.get_conf(umo)
return conf.get(key, default)

File diff suppressed because it is too large Load Diff

View File

@@ -5,40 +5,44 @@ AstrBot 会话-对话管理器, 维护两个本地存储, 其中一个是 json
在一个会话中可以建立多个对话, 并且支持对话的切换和删除
"""
import uuid
import json
import asyncio
from astrbot.core import sp
from typing import Dict, List
from astrbot.core.db import BaseDatabase
from astrbot.core.db.po import Conversation
from astrbot.core.db.po import Conversation, ConversationV2
class ConversationManager:
"""负责管理会话与 LLM 的对话,某个会话当前正在用哪个对话。"""
def __init__(self, db_helper: BaseDatabase):
# session_conversations 字典记录会话ID-对话ID 映射关系
self.session_conversations: Dict[str, str] = sp.get("session_conversation", {})
self.session_conversations: Dict[str, str] = {}
self.db = db_helper
self.save_interval = 60 # 每 60 秒保存一次
self._start_periodic_save()
def _start_periodic_save(self):
"""启动定时保存任务"""
asyncio.create_task(self._periodic_save())
def _convert_conv_from_v2_to_v1(self, conv_v2: ConversationV2) -> Conversation:
"""将 ConversationV2 对象转换为 Conversation 对象"""
created_at = int(conv_v2.created_at.timestamp())
updated_at = int(conv_v2.updated_at.timestamp())
return Conversation(
platform_id=conv_v2.platform_id,
user_id=conv_v2.user_id,
cid=conv_v2.conversation_id,
history=json.dumps(conv_v2.content or []),
title=conv_v2.title,
persona_id=conv_v2.persona_id,
created_at=created_at,
updated_at=updated_at,
)
async def _periodic_save(self):
"""定时保存会话对话映射关系到存储中"""
while True:
await asyncio.sleep(self.save_interval)
self._save_to_storage()
def _save_to_storage(self):
"""保存会话对话映射关系到存储中"""
sp.put("session_conversation", self.session_conversations)
async def new_conversation(self, unified_msg_origin: str) -> str:
async def new_conversation(
self,
unified_msg_origin: str,
platform_id: str | None = None,
content: list[dict] | None = None,
title: str | None = None,
persona_id: str | None = None,
) -> str:
"""新建对话,并将当前会话的对话转移到新对话
Args:
@@ -46,11 +50,23 @@ class ConversationManager:
Returns:
conversation_id (str): 对话 ID, 是 uuid 格式的字符串
"""
conversation_id = str(uuid.uuid4())
self.db.new_conversation(user_id=unified_msg_origin, cid=conversation_id)
self.session_conversations[unified_msg_origin] = conversation_id
sp.put("session_conversation", self.session_conversations)
return conversation_id
if not platform_id:
# 如果没有提供 platform_id则从 unified_msg_origin 中解析
parts = unified_msg_origin.split(":")
if len(parts) >= 3:
platform_id = parts[0]
if not platform_id:
platform_id = "unknown"
conv = await self.db.create_conversation(
user_id=unified_msg_origin,
platform_id=platform_id,
content=content,
title=title,
persona_id=persona_id,
)
self.session_conversations[unified_msg_origin] = conv.conversation_id
await sp.session_put(unified_msg_origin, "sel_conv_id", conv.conversation_id)
return conv.conversation_id
async def switch_conversation(self, unified_msg_origin: str, conversation_id: str):
"""切换会话的对话
@@ -60,10 +76,10 @@ class ConversationManager:
conversation_id (str): 对话 ID, 是 uuid 格式的字符串
"""
self.session_conversations[unified_msg_origin] = conversation_id
sp.put("session_conversation", self.session_conversations)
await sp.session_put(unified_msg_origin, "sel_conv_id", conversation_id)
async def delete_conversation(
self, unified_msg_origin: str, conversation_id: str = None
self, unified_msg_origin: str, conversation_id: str | None = None
):
"""删除会话的对话,当 conversation_id 为 None 时删除会话当前的对话
@@ -71,13 +87,18 @@ class ConversationManager:
unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id
conversation_id (str): 对话 ID, 是 uuid 格式的字符串
"""
conversation_id = self.session_conversations.get(unified_msg_origin)
f = False
if not conversation_id:
conversation_id = self.session_conversations.get(unified_msg_origin)
if conversation_id:
f = True
if conversation_id:
self.db.delete_conversation(user_id=unified_msg_origin, cid=conversation_id)
del self.session_conversations[unified_msg_origin]
sp.put("session_conversation", self.session_conversations)
await self.db.delete_conversation(cid=conversation_id)
if f:
self.session_conversations.pop(unified_msg_origin, None)
await sp.session_remove(unified_msg_origin, "sel_conv_id")
async def get_curr_conversation_id(self, unified_msg_origin: str) -> str:
async def get_curr_conversation_id(self, unified_msg_origin: str) -> str | None:
"""获取会话当前的对话 ID
Args:
@@ -85,14 +106,19 @@ class ConversationManager:
Returns:
conversation_id (str): 对话 ID, 是 uuid 格式的字符串
"""
return self.session_conversations.get(unified_msg_origin, None)
ret = self.session_conversations.get(unified_msg_origin, None)
if not ret:
ret = await sp.session_get(unified_msg_origin, "sel_conv_id", None)
if ret:
self.session_conversations[unified_msg_origin] = ret
return ret
async def get_conversation(
self,
unified_msg_origin: str,
conversation_id: str,
create_if_not_exists: bool = False,
) -> Conversation:
) -> Conversation | None:
"""获取会话的对话
Args:
@@ -101,27 +127,74 @@ class ConversationManager:
Returns:
conversation (Conversation): 对话对象
"""
conv = self.db.get_conversation_by_user_id(unified_msg_origin, conversation_id)
conv = await self.db.get_conversation_by_id(cid=conversation_id)
if not conv and create_if_not_exists:
# 如果对话不存在且需要创建,则新建一个对话
conversation_id = await self.new_conversation(unified_msg_origin)
return self.db.get_conversation_by_user_id(
unified_msg_origin, conversation_id
)
return self.db.get_conversation_by_user_id(unified_msg_origin, conversation_id)
conv = await self.db.get_conversation_by_id(cid=conversation_id)
conv_res = None
if conv:
conv_res = self._convert_conv_from_v2_to_v1(conv)
return conv_res
async def get_conversations(self, unified_msg_origin: str) -> List[Conversation]:
"""获取会话的所有对话
async def get_conversations(
self, unified_msg_origin: str | None = None, platform_id: str | None = None
) -> List[Conversation]:
"""获取对话列表
Args:
unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id
unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id,可选
platform_id (str): 平台 ID, 可选参数, 用于过滤对话
Returns:
conversations (List[Conversation]): 对话对象列表
"""
return self.db.get_conversations(unified_msg_origin)
convs = await self.db.get_conversations(
user_id=unified_msg_origin, platform_id=platform_id
)
convs_res = []
for conv in convs:
conv_res = self._convert_conv_from_v2_to_v1(conv)
convs_res.append(conv_res)
return convs_res
async def get_filtered_conversations(
self,
page: int = 1,
page_size: int = 20,
platform_ids: list[str] | None = None,
search_query: str = "",
**kwargs,
) -> tuple[list[Conversation], int]:
"""获取过滤后的对话列表
Args:
page (int): 页码, 默认为 1
page_size (int): 每页大小, 默认为 20
platform_ids (list[str]): 平台 ID 列表, 可选
search_query (str): 搜索查询字符串, 可选
Returns:
conversations (list[Conversation]): 对话对象列表
"""
convs, cnt = await self.db.get_filtered_conversations(
page=page,
page_size=page_size,
platform_ids=platform_ids,
search_query=search_query,
**kwargs,
)
convs_res = []
for conv in convs:
conv_res = self._convert_conv_from_v2_to_v1(conv)
convs_res.append(conv_res)
return convs_res, cnt
async def update_conversation(
self, unified_msg_origin: str, conversation_id: str, history: List[Dict]
self,
unified_msg_origin: str,
conversation_id: str | None = None,
history: list[dict] | None = None,
title: str | None = None,
persona_id: str | None = None,
):
"""更新会话的对话
@@ -130,40 +203,55 @@ class ConversationManager:
conversation_id (str): 对话 ID, 是 uuid 格式的字符串
history (List[Dict]): 对话历史记录, 是一个字典列表, 每个字典包含 role 和 content 字段
"""
if not conversation_id:
# 如果没有提供 conversation_id则获取当前的
conversation_id = await self.get_curr_conversation_id(unified_msg_origin)
if conversation_id:
self.db.update_conversation(
user_id=unified_msg_origin,
await self.db.update_conversation(
cid=conversation_id,
history=json.dumps(history),
title=title,
persona_id=persona_id,
content=history,
)
async def update_conversation_title(self, unified_msg_origin: str, title: str):
async def update_conversation_title(
self, unified_msg_origin: str, title: str, conversation_id: str | None = None
):
"""更新会话的对话标题
Args:
unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id
title (str): 对话标题
Deprecated:
Use `update_conversation` with `title` parameter instead.
"""
conversation_id = self.session_conversations.get(unified_msg_origin)
if conversation_id:
self.db.update_conversation_title(
user_id=unified_msg_origin, cid=conversation_id, title=title
)
await self.update_conversation(
unified_msg_origin=unified_msg_origin,
conversation_id=conversation_id,
title=title,
)
async def update_conversation_persona_id(
self, unified_msg_origin: str, persona_id: str
self,
unified_msg_origin: str,
persona_id: str,
conversation_id: str | None = None,
):
"""更新会话的对话 Persona ID
Args:
unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id
persona_id (str): 对话 Persona ID
Deprecated:
Use `update_conversation` with `persona_id` parameter instead.
"""
conversation_id = self.session_conversations.get(unified_msg_origin)
if conversation_id:
self.db.update_conversation_persona_id(
user_id=unified_msg_origin, cid=conversation_id, persona_id=persona_id
)
await self.update_conversation(
unified_msg_origin=unified_msg_origin,
conversation_id=conversation_id,
persona_id=persona_id,
)
async def get_human_readable_context(
self, unified_msg_origin, conversation_id, page=1, page_size=10

View File

@@ -15,20 +15,23 @@ import time
import threading
import os
from .event_bus import EventBus
from . import astrbot_config
from . import astrbot_config, html_renderer
from asyncio import Queue
from typing import List
from astrbot.core.pipeline.scheduler import PipelineScheduler, PipelineContext
from astrbot.core.star import PluginManager
from astrbot.core.platform.manager import PlatformManager
from astrbot.core.star.context import Context
from astrbot.core.persona_mgr import PersonaManager
from astrbot.core.provider.manager import ProviderManager
from astrbot.core import LogBroker
from astrbot.core.db import BaseDatabase
from astrbot.core.updator import AstrBotUpdator
from astrbot.core import logger
from astrbot.core import logger, sp
from astrbot.core.config.default import VERSION
from astrbot.core.conversation_mgr import ConversationManager
from astrbot.core.platform_message_history_mgr import PlatformMessageHistoryManager
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
from astrbot.core.star.star_handler import star_handlers_registry, EventType
from astrbot.core.star.star_handler import star_map
@@ -47,12 +50,23 @@ class AstrBotCoreLifecycle:
self.db = db # 初始化数据库
# 设置代理
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"
proxy_config = self.astrbot_config.get("http_proxy", "")
if proxy_config != "":
os.environ["https_proxy"] = proxy_config
os.environ["http_proxy"] = proxy_config
logger.debug(f"Using proxy: {proxy_config}")
# 设置 no_proxy
no_proxy_list = self.astrbot_config.get("no_proxy", [])
os.environ["no_proxy"] = ",".join(no_proxy_list)
else:
# 清空代理环境变量
if "https_proxy" in os.environ:
del os.environ["https_proxy"]
if "http_proxy" in os.environ:
del os.environ["http_proxy"]
if "no_proxy" in os.environ:
del os.environ["no_proxy"]
logger.debug("HTTP proxy cleared")
async def initialize(self):
"""
@@ -66,11 +80,26 @@ class AstrBotCoreLifecycle:
else:
logger.setLevel(self.astrbot_config["log_level"]) # 设置日志级别
await self.db.initialize()
await html_renderer.initialize()
# 初始化 AstrBot 配置管理器
self.astrbot_config_mgr = AstrBotConfigManager(
default_config=self.astrbot_config, sp=sp
)
# 初始化事件队列
self.event_queue = Queue()
# 初始化人格管理器
self.persona_mgr = PersonaManager(self.db, self.astrbot_config_mgr)
await self.persona_mgr.initialize()
# 初始化供应商管理器
self.provider_manager = ProviderManager(self.astrbot_config, self.db)
self.provider_manager = ProviderManager(
self.astrbot_config_mgr, self.db, self.persona_mgr
)
# 初始化平台管理器
self.platform_manager = PlatformManager(self.astrbot_config, self.event_queue)
@@ -78,6 +107,9 @@ class AstrBotCoreLifecycle:
# 初始化对话管理器
self.conversation_manager = ConversationManager(self.db)
# 初始化平台消息历史管理器
self.platform_message_history_manager = PlatformMessageHistoryManager(self.db)
# 初始化提供给插件的上下文
self.star_context = Context(
self.event_queue,
@@ -86,6 +118,9 @@ class AstrBotCoreLifecycle:
self.provider_manager,
self.platform_manager,
self.conversation_manager,
self.platform_message_history_manager,
self.persona_mgr,
self.astrbot_config_mgr,
)
# 初始化插件管理器
@@ -98,16 +133,16 @@ class AstrBotCoreLifecycle:
await self.provider_manager.initialize()
# 初始化消息事件流水线调度器
self.pipeline_scheduler = PipelineScheduler(
PipelineContext(self.astrbot_config, self.plugin_manager)
)
await self.pipeline_scheduler.initialize()
self.pipeline_scheduler_mapping = await self.load_pipeline_scheduler()
# 初始化更新器
self.astrbot_updator = AstrBotUpdator()
# 初始化事件总线
self.event_bus = EventBus(self.event_queue, self.pipeline_scheduler)
self.event_bus = EventBus(
self.event_queue, self.pipeline_scheduler_mapping, self.astrbot_config_mgr
)
# 记录启动时间
self.start_time = int(time.time())
@@ -224,6 +259,39 @@ class AstrBotCoreLifecycle:
platform_insts = self.platform_manager.get_insts()
for platform_inst in platform_insts:
tasks.append(
asyncio.create_task(platform_inst.run(), name=platform_inst.meta().name)
asyncio.create_task(
platform_inst.run(),
name=f"{platform_inst.meta().id}({platform_inst.meta().name})",
)
)
return tasks
async def load_pipeline_scheduler(self) -> dict[str, PipelineScheduler]:
"""加载消息事件流水线调度器
Returns:
dict[str, PipelineScheduler]: 平台 ID 到流水线调度器的映射
"""
mapping = {}
for conf_id, ab_config in self.astrbot_config_mgr.confs.items():
scheduler = PipelineScheduler(
PipelineContext(ab_config, self.plugin_manager, conf_id)
)
await scheduler.initialize()
mapping[conf_id] = scheduler
return mapping
async def reload_pipeline_scheduler(self, conf_id: str):
"""重新加载消息事件流水线调度器
Returns:
dict[str, PipelineScheduler]: 平台 ID 到流水线调度器的映射
"""
ab_config = self.astrbot_config_mgr.confs.get(conf_id)
if not ab_config:
raise ValueError(f"配置文件 {conf_id} 不存在")
scheduler = PipelineScheduler(
PipelineContext(ab_config, self.plugin_manager, conf_id)
)
await scheduler.initialize()
self.pipeline_scheduler_mapping[conf_id] = scheduler

View File

@@ -1,7 +1,20 @@
import abc
import datetime
import typing as T
from deprecated import deprecated
from dataclasses import dataclass
from typing import List, Dict, Any, Tuple
from astrbot.core.db.po import Stats, LLMHistory, ATRIVision, Conversation
from astrbot.core.db.po import (
Stats,
PlatformStat,
ConversationV2,
PlatformMessageHistory,
Attachment,
Persona,
Preference,
)
from contextlib import asynccontextmanager
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
@dataclass
@@ -10,152 +23,262 @@ class BaseDatabase(abc.ABC):
数据库基类
"""
DATABASE_URL = ""
def __init__(self) -> None:
self.engine = create_async_engine(
self.DATABASE_URL,
echo=False,
future=True,
)
self.AsyncSessionLocal = sessionmaker(
self.engine, class_=AsyncSession, expire_on_commit=False
)
async def initialize(self):
"""初始化数据库连接"""
pass
def insert_base_metrics(self, metrics: dict):
"""插入基础指标数据"""
self.insert_platform_metrics(metrics["platform_stats"])
self.insert_plugin_metrics(metrics["plugin_stats"])
self.insert_command_metrics(metrics["command_stats"])
self.insert_llm_metrics(metrics["llm_stats"])
@abc.abstractmethod
def insert_platform_metrics(self, metrics: dict):
"""插入平台指标数据"""
raise NotImplementedError
@abc.abstractmethod
def insert_plugin_metrics(self, metrics: dict):
"""插入插件指标数据"""
raise NotImplementedError
@abc.abstractmethod
def insert_command_metrics(self, metrics: dict):
"""插入指令指标数据"""
raise NotImplementedError
@abc.abstractmethod
def insert_llm_metrics(self, metrics: dict):
"""插入 LLM 指标数据"""
raise NotImplementedError
@abc.abstractmethod
def update_llm_history(self, session_id: str, content: str, provider_type: str):
"""更新 LLM 历史记录。当不存在 session_id 时插入"""
raise NotImplementedError
@abc.abstractmethod
def get_llm_history(
self, session_id: str = None, provider_type: str = None
) -> List[LLMHistory]:
"""获取 LLM 历史记录, 如果 session_id 为 None, 返回所有"""
raise NotImplementedError
@asynccontextmanager
async def get_db(self) -> T.AsyncGenerator[AsyncSession, None]:
"""Get a database session."""
if not self.inited:
await self.initialize()
self.inited = True
async with self.AsyncSessionLocal() as session:
yield session
@deprecated(version="4.0.0", reason="Use get_platform_stats instead")
@abc.abstractmethod
def get_base_stats(self, offset_sec: int = 86400) -> Stats:
"""获取基础统计数据"""
raise NotImplementedError
@deprecated(version="4.0.0", reason="Use get_platform_stats instead")
@abc.abstractmethod
def get_total_message_count(self) -> int:
"""获取总消息数"""
raise NotImplementedError
@deprecated(version="4.0.0", reason="Use get_platform_stats instead")
@abc.abstractmethod
def get_grouped_base_stats(self, offset_sec: int = 86400) -> Stats:
"""获取基础统计数据(合并)"""
raise NotImplementedError
@abc.abstractmethod
def insert_atri_vision_data(self, vision_data: ATRIVision):
"""插入 ATRI 视觉数据"""
raise NotImplementedError
# New methods in v4.0.0
@abc.abstractmethod
def get_atri_vision_data(self) -> List[ATRIVision]:
"""获取 ATRI 视觉数据"""
raise NotImplementedError
async def insert_platform_stats(
self,
platform_id: str,
platform_type: str,
count: int = 1,
timestamp: datetime.datetime | None = None,
) -> None:
"""Insert a new platform statistic record."""
...
@abc.abstractmethod
def get_atri_vision_data_by_path_or_id(
self, url_or_path: str, id: str
) -> ATRIVision:
"""通过 url 或 path 获取 ATRI 视觉数据"""
raise NotImplementedError
async def count_platform_stats(self) -> int:
"""Count the number of platform statistics records."""
...
@abc.abstractmethod
def get_conversation_by_user_id(self, user_id: str, cid: str) -> Conversation:
"""通过 user_id 和 cid 获取 Conversation"""
raise NotImplementedError
async def get_platform_stats(self, offset_sec: int = 86400) -> list[PlatformStat]:
"""Get platform statistics within the specified offset in seconds and group by platform_id."""
...
@abc.abstractmethod
def new_conversation(self, user_id: str, cid: str):
"""新建 Conversation"""
raise NotImplementedError
async def get_conversations(
self, user_id: str | None = None, platform_id: str | None = None
) -> list[ConversationV2]:
"""Get all conversations for a specific user and platform_id(optional).
@abc.abstractmethod
def get_conversations(self, user_id: str) -> List[Conversation]:
raise NotImplementedError
@abc.abstractmethod
def update_conversation(self, user_id: str, cid: str, history: str):
"""更新 Conversation"""
raise NotImplementedError
@abc.abstractmethod
def delete_conversation(self, user_id: str, cid: str):
"""删除 Conversation"""
raise NotImplementedError
@abc.abstractmethod
def update_conversation_title(self, user_id: str, cid: str, title: str):
"""更新 Conversation 标题"""
raise NotImplementedError
@abc.abstractmethod
def update_conversation_persona_id(self, user_id: str, cid: str, persona_id: str):
"""更新 Conversation Persona ID"""
raise NotImplementedError
@abc.abstractmethod
def get_all_conversations(
self, page: int = 1, page_size: int = 20
) -> Tuple[List[Dict[str, Any]], int]:
"""获取所有对话,支持分页
Args:
page: 页码从1开始
page_size: 每页数量
Returns:
Tuple[List[Dict[str, Any]], int]: 返回一个元组,包含对话列表和总对话数
content is not included in the result.
"""
raise NotImplementedError
...
@abc.abstractmethod
def get_filtered_conversations(
async def get_conversation_by_id(self, cid: str) -> ConversationV2:
"""Get a specific conversation by its ID."""
...
@abc.abstractmethod
async def get_all_conversations(
self, page: int = 1, page_size: int = 20
) -> list[ConversationV2]:
"""Get all conversations with pagination."""
...
@abc.abstractmethod
async def get_filtered_conversations(
self,
page: int = 1,
page_size: int = 20,
platforms: List[str] = None,
message_types: List[str] = None,
search_query: str = None,
exclude_ids: List[str] = None,
exclude_platforms: List[str] = None,
) -> Tuple[List[Dict[str, Any]], int]:
"""获取筛选后的对话列表
platform_ids: list[str] | None = None,
search_query: str = "",
**kwargs,
) -> tuple[list[ConversationV2], int]:
"""Get conversations filtered by platform IDs and search query."""
...
Args:
page: 页码
page_size: 每页数量
platforms: 平台筛选列表
message_types: 消息类型筛选列表
search_query: 搜索关键词
exclude_ids: 排除的用户ID列表
exclude_platforms: 排除的平台列表
@abc.abstractmethod
async def create_conversation(
self,
user_id: str,
platform_id: str,
content: list[dict] | None = None,
title: str | None = None,
persona_id: str | None = None,
cid: str | None = None,
created_at: datetime.datetime | None = None,
updated_at: datetime.datetime | None = None,
) -> ConversationV2:
"""Create a new conversation."""
...
Returns:
Tuple[List[Dict[str, Any]], int]: 返回一个元组,包含对话列表和总对话数
"""
raise NotImplementedError
@abc.abstractmethod
async def update_conversation(
self,
cid: str,
title: str | None = None,
persona_id: str | None = None,
content: list[dict] | None = None,
) -> None:
"""Update a conversation's history."""
...
@abc.abstractmethod
async def delete_conversation(self, cid: str) -> None:
"""Delete a conversation by its ID."""
...
@abc.abstractmethod
async def insert_platform_message_history(
self,
platform_id: str,
user_id: str,
content: list[dict],
sender_id: str | None = None,
sender_name: str | None = None,
) -> None:
"""Insert a new platform message history record."""
...
@abc.abstractmethod
async def delete_platform_message_offset(
self, platform_id: str, user_id: str, offset_sec: int = 86400
) -> None:
"""Delete platform message history records older than the specified offset."""
...
@abc.abstractmethod
async def get_platform_message_history(
self,
platform_id: str,
user_id: str,
page: int = 1,
page_size: int = 20,
) -> list[PlatformMessageHistory]:
"""Get platform message history for a specific user."""
...
@abc.abstractmethod
async def insert_attachment(
self,
path: str,
type: str,
mime_type: str,
):
"""Insert a new attachment record."""
...
@abc.abstractmethod
async def get_attachment_by_id(self, attachment_id: str) -> Attachment:
"""Get an attachment by its ID."""
...
@abc.abstractmethod
async def insert_persona(
self,
persona_id: str,
system_prompt: str,
begin_dialogs: list[str] | None = None,
tools: list[str] | None = None,
) -> Persona:
"""Insert a new persona record."""
...
@abc.abstractmethod
async def get_persona_by_id(self, persona_id: str) -> Persona:
"""Get a persona by its ID."""
...
@abc.abstractmethod
async def get_personas(self) -> list[Persona]:
"""Get all personas for a specific bot."""
...
@abc.abstractmethod
async def update_persona(
self,
persona_id: str,
system_prompt: str | None = None,
begin_dialogs: list[str] | None = None,
tools: list[str] | None = None,
) -> Persona | None:
"""Update a persona's system prompt or begin dialogs."""
...
@abc.abstractmethod
async def delete_persona(self, persona_id: str) -> None:
"""Delete a persona by its ID."""
...
@abc.abstractmethod
async def insert_preference_or_update(
self, scope: str, scope_id: str, key: str, value: dict
) -> Preference:
"""Insert a new preference record."""
...
@abc.abstractmethod
async def get_preference(self, scope: str, scope_id: str, key: str) -> Preference:
"""Get a preference by scope ID and key."""
...
@abc.abstractmethod
async def get_preferences(
self, scope: str, scope_id: str | None = None, key: str | None = None
) -> list[Preference]:
"""Get all preferences for a specific scope ID or key."""
...
@abc.abstractmethod
async def remove_preference(self, scope: str, scope_id: str, key: str) -> None:
"""Remove a preference by scope ID and key."""
...
@abc.abstractmethod
async def clear_preferences(self, scope: str, scope_id: str) -> None:
"""Clear all preferences for a specific scope ID."""
...
# @abc.abstractmethod
# async def insert_llm_message(
# self,
# cid: str,
# role: str,
# content: list,
# tool_calls: list = None,
# tool_call_id: str = None,
# parent_id: str = None,
# ) -> LLMMessage:
# """Insert a new LLM message into the conversation."""
# ...
# @abc.abstractmethod
# async def get_llm_messages(self, cid: str) -> list[LLMMessage]:
# """Get all LLM messages for a specific conversation."""
# ...

View File

@@ -0,0 +1,64 @@
import os
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from astrbot.core.db import BaseDatabase
from astrbot.core.config import AstrBotConfig
from astrbot.api import logger, sp
from .migra_3_to_4 import (
migration_conversation_table,
migration_platform_table,
migration_webchat_data,
migration_persona_data,
migration_preferences,
)
async def check_migration_needed_v4(db_helper: BaseDatabase) -> bool:
"""
检查是否需要进行数据库迁移
如果存在 data_v3.db 并且 preference 中没有 migration_done_v4则需要进行迁移。
"""
data_v3_exists = os.path.exists(get_astrbot_data_path())
if not data_v3_exists:
return False
migration_done = await db_helper.get_preference(
"global", "global", "migration_done_v4"
)
if migration_done:
return False
return True
async def do_migration_v4(
db_helper: BaseDatabase,
platform_id_map: dict[str, dict[str, str]],
astrbot_config: AstrBotConfig,
):
"""
执行数据库迁移
迁移旧的 webchat_conversation 表到新的 conversation 表。
迁移旧的 platform 到新的 platform_stats 表。
"""
if not await check_migration_needed_v4(db_helper):
return
logger.info("开始执行数据库迁移...")
# 执行会话表迁移
await migration_conversation_table(db_helper, platform_id_map)
# 执行人格数据迁移
await migration_persona_data(db_helper, astrbot_config)
# 执行 WebChat 数据迁移
await migration_webchat_data(db_helper, platform_id_map)
# 执行偏好设置迁移
await migration_preferences(db_helper,platform_id_map)
# 执行平台统计表迁移
await migration_platform_table(db_helper, platform_id_map)
# 标记迁移完成
await sp.put_async("global", "global", "migration_done_v4", True)
logger.info("数据库迁移完成。")

View File

@@ -0,0 +1,338 @@
import json
import datetime
from .. import BaseDatabase
from .sqlite_v3 import SQLiteDatabase as SQLiteV3DatabaseV3
from .shared_preferences_v3 import sp as sp_v3
from astrbot.core.config.default import DB_PATH
from astrbot.api import logger, sp
from astrbot.core.config import AstrBotConfig
from astrbot.core.platform.astr_message_event import MessageSesion
from sqlalchemy.ext.asyncio import AsyncSession
from astrbot.core.db.po import ConversationV2, PlatformMessageHistory
from sqlalchemy import text
"""
1. 迁移旧的 webchat_conversation 表到新的 conversation 表。
2. 迁移旧的 platform 到新的 platform_stats 表。
"""
def get_platform_id(
platform_id_map: dict[str, dict[str, str]], old_platform_name: str
) -> str:
return platform_id_map.get(
old_platform_name,
{"platform_id": old_platform_name, "platform_type": old_platform_name},
).get("platform_id", old_platform_name)
def get_platform_type(
platform_id_map: dict[str, dict[str, str]], old_platform_name: str
) -> str:
return platform_id_map.get(
old_platform_name,
{"platform_id": old_platform_name, "platform_type": old_platform_name},
).get("platform_type", old_platform_name)
async def migration_conversation_table(
db_helper: BaseDatabase, platform_id_map: dict[str, dict[str, str]]
):
db_helper_v3 = SQLiteV3DatabaseV3(
db_path=DB_PATH.replace("data_v4.db", "data_v3.db")
)
conversations, total_cnt = db_helper_v3.get_all_conversations(
page=1, page_size=10000000
)
logger.info(f"迁移 {total_cnt} 条旧的会话数据到新的表中...")
async with db_helper.get_db() as dbsession:
dbsession: AsyncSession
async with dbsession.begin():
for idx, conversation in enumerate(conversations):
if total_cnt > 0 and (idx + 1) % max(1, total_cnt // 10) == 0:
progress = int((idx + 1) / total_cnt * 100)
if progress % 10 == 0:
logger.info(f"进度: {progress}% ({idx + 1}/{total_cnt})")
try:
conv = db_helper_v3.get_conversation_by_user_id(
user_id=conversation.get("user_id", "unknown"),
cid=conversation.get("cid", "unknown"),
)
if not conv:
logger.info(
f"未找到该条旧会话对应的具体数据: {conversation}, 跳过。"
)
if ":" not in conv.user_id:
continue
session = MessageSesion.from_str(session_str=conv.user_id)
platform_id = get_platform_id(
platform_id_map, session.platform_name
)
session.platform_id = platform_id # 更新平台名称为新的 ID
conv_v2 = ConversationV2(
user_id=str(session),
content=json.loads(conv.history) if conv.history else [],
platform_id=platform_id,
title=conv.title,
persona_id=conv.persona_id,
conversation_id=conv.cid,
created_at=datetime.datetime.fromtimestamp(conv.created_at),
updated_at=datetime.datetime.fromtimestamp(conv.updated_at),
)
dbsession.add(conv_v2)
except Exception as e:
logger.error(
f"迁移旧会话 {conversation.get('cid', 'unknown')} 失败: {e}",
exc_info=True,
)
logger.info(f"成功迁移 {total_cnt} 条旧的会话数据到新表。")
async def migration_platform_table(
db_helper: BaseDatabase, platform_id_map: dict[str, dict[str, str]]
):
db_helper_v3 = SQLiteV3DatabaseV3(
db_path=DB_PATH.replace("data_v4.db", "data_v3.db")
)
secs_from_2023_4_10_to_now = (
datetime.datetime.now(datetime.timezone.utc)
- datetime.datetime(2023, 4, 10, tzinfo=datetime.timezone.utc)
).total_seconds()
offset_sec = int(secs_from_2023_4_10_to_now)
logger.info(f"迁移旧平台数据offset_sec: {offset_sec} 秒。")
stats = db_helper_v3.get_base_stats(offset_sec=offset_sec)
logger.info(f"迁移 {len(stats.platform)} 条旧的平台数据到新的表中...")
platform_stats_v3 = stats.platform
if not platform_stats_v3:
logger.info("没有找到旧平台数据,跳过迁移。")
return
first_time_stamp = platform_stats_v3[0].timestamp
end_time_stamp = platform_stats_v3[-1].timestamp
start_time = first_time_stamp - (first_time_stamp % 3600) # 向下取整到小时
end_time = end_time_stamp + (3600 - (end_time_stamp % 3600)) # 向上取整到小时
idx = 0
async with db_helper.get_db() as dbsession:
dbsession: AsyncSession
async with dbsession.begin():
total_buckets = (end_time - start_time) // 3600
for bucket_idx, bucket_end in enumerate(range(start_time, end_time, 3600)):
if bucket_idx % 500 == 0:
progress = int((bucket_idx + 1) / total_buckets * 100)
logger.info(f"进度: {progress}% ({bucket_idx + 1}/{total_buckets})")
cnt = 0
while (
idx < len(platform_stats_v3)
and platform_stats_v3[idx].timestamp < bucket_end
):
cnt += platform_stats_v3[idx].count
idx += 1
if cnt == 0:
continue
platform_id = get_platform_id(
platform_id_map, platform_stats_v3[idx].name
)
platform_type = get_platform_type(
platform_id_map, platform_stats_v3[idx].name
)
try:
await dbsession.execute(
text("""
INSERT INTO platform_stats (timestamp, platform_id, platform_type, count)
VALUES (:timestamp, :platform_id, :platform_type, :count)
ON CONFLICT(timestamp, platform_id, platform_type) DO UPDATE SET
count = platform_stats.count + EXCLUDED.count
"""),
{
"timestamp": datetime.datetime.fromtimestamp(
bucket_end, tz=datetime.timezone.utc
),
"platform_id": platform_id,
"platform_type": platform_type,
"count": cnt,
},
)
except Exception:
logger.error(
f"迁移平台统计数据失败: {platform_id}, {platform_type}, 时间戳: {bucket_end}",
exc_info=True,
)
logger.info(f"成功迁移 {len(platform_stats_v3)} 条旧的平台数据到新表。")
async def migration_webchat_data(
db_helper: BaseDatabase, platform_id_map: dict[str, dict[str, str]]
):
"""迁移 WebChat 的历史记录到新的 PlatformMessageHistory 表中"""
db_helper_v3 = SQLiteV3DatabaseV3(
db_path=DB_PATH.replace("data_v4.db", "data_v3.db")
)
conversations, total_cnt = db_helper_v3.get_all_conversations(
page=1, page_size=10000000
)
logger.info(f"迁移 {total_cnt} 条旧的 WebChat 会话数据到新的表中...")
async with db_helper.get_db() as dbsession:
dbsession: AsyncSession
async with dbsession.begin():
for idx, conversation in enumerate(conversations):
if total_cnt > 0 and (idx + 1) % max(1, total_cnt // 10) == 0:
progress = int((idx + 1) / total_cnt * 100)
if progress % 10 == 0:
logger.info(f"进度: {progress}% ({idx + 1}/{total_cnt})")
try:
conv = db_helper_v3.get_conversation_by_user_id(
user_id=conversation.get("user_id", "unknown"),
cid=conversation.get("cid", "unknown"),
)
if not conv:
logger.info(
f"未找到该条旧会话对应的具体数据: {conversation}, 跳过。"
)
if ":" in conv.user_id:
continue
platform_id = "webchat"
history = json.loads(conv.history) if conv.history else []
for msg in history:
type_ = msg.get("type") # user type, "bot" or "user"
new_history = PlatformMessageHistory(
platform_id=platform_id,
user_id=conv.cid, # we use conv.cid as user_id for webchat
content=msg,
sender_id=type_,
sender_name=type_,
)
dbsession.add(new_history)
except Exception:
logger.error(
f"迁移旧 WebChat 会话 {conversation.get('cid', 'unknown')} 失败",
exc_info=True,
)
logger.info(f"成功迁移 {total_cnt} 条旧的 WebChat 会话数据到新表。")
async def migration_persona_data(
db_helper: BaseDatabase, astrbot_config: AstrBotConfig
):
"""
迁移 Persona 数据到新的表中。
旧的 Persona 数据存储在 preference 中,新的 Persona 数据存储在 persona 表中。
"""
v3_persona_config: list[dict] = astrbot_config.get("persona", [])
total_personas = len(v3_persona_config)
logger.info(f"迁移 {total_personas} 个 Persona 配置到新表中...")
for idx, persona in enumerate(v3_persona_config):
if total_personas > 0 and (idx + 1) % max(1, total_personas // 10) == 0:
progress = int((idx + 1) / total_personas * 100)
if progress % 10 == 0:
logger.info(f"进度: {progress}% ({idx + 1}/{total_personas})")
try:
begin_dialogs = persona.get("begin_dialogs", [])
mood_imitation_dialogs = persona.get("mood_imitation_dialogs", [])
mood_prompt = ""
user_turn = True
for mood_dialog in mood_imitation_dialogs:
if user_turn:
mood_prompt += f"A: {mood_dialog}\n"
else:
mood_prompt += f"B: {mood_dialog}\n"
user_turn = not user_turn
system_prompt = persona.get("prompt", "")
if mood_prompt:
system_prompt += f"Here are few shots of dialogs, you need to imitate the tone of 'B' in the following dialogs to respond:\n {mood_prompt}"
persona_new = await db_helper.insert_persona(
persona_id=persona["name"],
system_prompt=system_prompt,
begin_dialogs=begin_dialogs,
)
logger.info(
f"迁移 Persona {persona['name']}({persona_new.system_prompt[:30]}...) 到新表成功。"
)
except Exception as e:
logger.error(f"解析 Persona 配置失败:{e}")
async def migration_preferences(
db_helper: BaseDatabase, platform_id_map: dict[str, dict[str, str]]
):
# 1. global scope migration
keys = [
"inactivated_llm_tools",
"inactivated_plugins",
"curr_provider",
"curr_provider_tts",
"curr_provider_stt",
"alter_cmd",
]
for key in keys:
value = sp_v3.get(key)
if value is not None:
await sp.put_async("global", "global", key, value)
logger.info(f"迁移全局偏好设置 {key} 成功,值: {value}")
# 2. umo scope migration
session_conversation = sp_v3.get("session_conversation", default={})
for umo, conversation_id in session_conversation.items():
if not umo or not conversation_id:
continue
try:
session = MessageSesion.from_str(session_str=umo)
platform_id = get_platform_id(platform_id_map, session.platform_name)
session.platform_id = platform_id
await sp.put_async("umo", str(session), "sel_conv_id", conversation_id)
logger.info(f"迁移会话 {umo} 的对话数据到新表成功,平台 ID: {platform_id}")
except Exception as e:
logger.error(f"迁移会话 {umo} 的对话数据失败: {e}", exc_info=True)
session_service_config = sp_v3.get("session_service_config", default={})
for umo, config in session_service_config.items():
if not umo or not config:
continue
try:
session = MessageSesion.from_str(session_str=umo)
platform_id = get_platform_id(platform_id_map, session.platform_name)
session.platform_id = platform_id
await sp.put_async("umo", str(session), "session_service_config", config)
logger.info(f"迁移会话 {umo} 的服务配置到新表成功,平台 ID: {platform_id}")
except Exception as e:
logger.error(f"迁移会话 {umo} 的服务配置失败: {e}", exc_info=True)
session_variables = sp_v3.get("session_variables", default={})
for umo, variables in session_variables.items():
if not umo or not variables:
continue
try:
session = MessageSesion.from_str(session_str=umo)
platform_id = get_platform_id(platform_id_map, session.platform_name)
session.platform_id = platform_id
await sp.put_async("umo", str(session), "session_variables", variables)
except Exception as e:
logger.error(f"迁移会话 {umo} 的变量失败: {e}", exc_info=True)
session_provider_perf = sp_v3.get("session_provider_perf", default={})
for umo, perf in session_provider_perf.items():
if not umo or not perf:
continue
try:
session = MessageSesion.from_str(session_str=umo)
platform_id = get_platform_id(platform_id_map, session.platform_name)
session.platform_id = platform_id
for provider_type, provider_id in perf.items():
await sp.put_async(
"umo", str(session), f"provider_perf_{provider_type}", provider_id
)
logger.info(
f"迁移会话 {umo} 的提供商偏好到新表成功,平台 ID: {platform_id}"
)
except Exception as e:
logger.error(f"迁移会话 {umo} 的提供商偏好失败: {e}", exc_info=True)

View File

@@ -0,0 +1,45 @@
import json
import os
from typing import TypeVar
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
_VT = TypeVar("_VT")
class SharedPreferences:
def __init__(self, path=None):
if path is None:
path = os.path.join(get_astrbot_data_path(), "shared_preferences.json")
self.path = path
self._data = self._load_preferences()
def _load_preferences(self):
if os.path.exists(self.path):
try:
with open(self.path, "r") as f:
return json.load(f)
except json.JSONDecodeError:
os.remove(self.path)
return {}
def _save_preferences(self):
with open(self.path, "w") as f:
json.dump(self._data, f, indent=4, ensure_ascii=False)
f.flush()
def get(self, key, default: _VT = None) -> _VT:
return self._data.get(key, default)
def put(self, key, value):
self._data[key] = value
self._save_preferences()
def remove(self, key):
if key in self._data:
del self._data[key]
self._save_preferences()
def clear(self):
self._data.clear()
self._save_preferences()
sp = SharedPreferences()

View File

@@ -0,0 +1,493 @@
import sqlite3
import time
from astrbot.core.db.po import Platform, Stats
from typing import Tuple, List, Dict, Any
from dataclasses import dataclass
@dataclass
class Conversation:
"""LLM 对话存储
对于网页聊天history 存储了包括指令、回复、图片等在内的所有消息。
对于其他平台的聊天,不存储非 LLM 的回复(因为考虑到已经存储在各自的平台上)。
"""
user_id: str
cid: str
history: str = ""
"""字符串格式的列表。"""
created_at: int = 0
updated_at: int = 0
title: str = ""
persona_id: str = ""
INIT_SQL = """
CREATE TABLE IF NOT EXISTS platform(
name VARCHAR(32),
count INTEGER,
timestamp INTEGER
);
CREATE TABLE IF NOT EXISTS llm(
name VARCHAR(32),
count INTEGER,
timestamp INTEGER
);
CREATE TABLE IF NOT EXISTS plugin(
name VARCHAR(32),
count INTEGER,
timestamp INTEGER
);
CREATE TABLE IF NOT EXISTS command(
name VARCHAR(32),
count INTEGER,
timestamp INTEGER
);
CREATE TABLE IF NOT EXISTS llm_history(
provider_type VARCHAR(32),
session_id VARCHAR(32),
content TEXT
);
-- ATRI
CREATE TABLE IF NOT EXISTS atri_vision(
id TEXT,
url_or_path TEXT,
caption TEXT,
is_meme BOOLEAN,
keywords TEXT,
platform_name VARCHAR(32),
session_id VARCHAR(32),
sender_nickname VARCHAR(32),
timestamp INTEGER
);
CREATE TABLE IF NOT EXISTS webchat_conversation(
user_id TEXT, -- 会话 id
cid TEXT, -- 对话 id
history TEXT,
created_at INTEGER,
updated_at INTEGER,
title TEXT,
persona_id TEXT
);
PRAGMA encoding = 'UTF-8';
"""
class SQLiteDatabase():
def __init__(self, db_path: str) -> None:
super().__init__()
self.db_path = db_path
sql = INIT_SQL
# 初始化数据库
self.conn = self._get_conn(self.db_path)
c = self.conn.cursor()
c.executescript(sql)
self.conn.commit()
# 检查 webchat_conversation 的 title 字段是否存在
c.execute(
"""
PRAGMA table_info(webchat_conversation)
"""
)
res = c.fetchall()
has_title = False
has_persona_id = False
for row in res:
if row[1] == "title":
has_title = True
if row[1] == "persona_id":
has_persona_id = True
if not has_title:
c.execute(
"""
ALTER TABLE webchat_conversation ADD COLUMN title TEXT;
"""
)
self.conn.commit()
if not has_persona_id:
c.execute(
"""
ALTER TABLE webchat_conversation ADD COLUMN persona_id TEXT;
"""
)
self.conn.commit()
c.close()
def _get_conn(self, db_path: str) -> sqlite3.Connection:
conn = sqlite3.connect(self.db_path)
conn.text_factory = str
return conn
def _exec_sql(self, sql: str, params: Tuple = None):
conn = self.conn
try:
c = self.conn.cursor()
except sqlite3.ProgrammingError:
conn = self._get_conn(self.db_path)
c = conn.cursor()
if params:
c.execute(sql, params)
c.close()
else:
c.execute(sql)
c.close()
conn.commit()
def insert_platform_metrics(self, metrics: dict):
for k, v in metrics.items():
self._exec_sql(
"""
INSERT INTO platform(name, count, timestamp) VALUES (?, ?, ?)
""",
(k, v, int(time.time())),
)
def insert_llm_metrics(self, metrics: dict):
for k, v in metrics.items():
self._exec_sql(
"""
INSERT INTO llm(name, count, timestamp) VALUES (?, ?, ?)
""",
(k, v, int(time.time())),
)
def get_base_stats(self, offset_sec: int = 86400) -> Stats:
"""获取 offset_sec 秒前到现在的基础统计数据"""
where_clause = f" WHERE timestamp >= {int(time.time()) - offset_sec}"
try:
c = self.conn.cursor()
except sqlite3.ProgrammingError:
c = self._get_conn(self.db_path).cursor()
c.execute(
"""
SELECT * FROM platform
"""
+ where_clause
)
platform = []
for row in c.fetchall():
platform.append(Platform(*row))
c.close()
return Stats(platform=platform)
def get_total_message_count(self) -> int:
try:
c = self.conn.cursor()
except sqlite3.ProgrammingError:
c = self._get_conn(self.db_path).cursor()
c.execute(
"""
SELECT SUM(count) FROM platform
"""
)
res = c.fetchone()
c.close()
return res[0]
def get_grouped_base_stats(self, offset_sec: int = 86400) -> Stats:
"""获取 offset_sec 秒前到现在的基础统计数据(合并)"""
where_clause = f" WHERE timestamp >= {int(time.time()) - offset_sec}"
try:
c = self.conn.cursor()
except sqlite3.ProgrammingError:
c = self._get_conn(self.db_path).cursor()
c.execute(
"""
SELECT name, SUM(count), timestamp FROM platform
"""
+ where_clause
+ " GROUP BY name"
)
platform = []
for row in c.fetchall():
platform.append(Platform(*row))
c.close()
return Stats(platform, [], [])
def get_conversation_by_user_id(self, user_id: str, cid: str) -> Conversation:
try:
c = self.conn.cursor()
except sqlite3.ProgrammingError:
c = self._get_conn(self.db_path).cursor()
c.execute(
"""
SELECT * FROM webchat_conversation WHERE user_id = ? AND cid = ?
""",
(user_id, cid),
)
res = c.fetchone()
c.close()
if not res:
return
return Conversation(*res)
def new_conversation(self, user_id: str, cid: str):
history = "[]"
updated_at = int(time.time())
created_at = updated_at
self._exec_sql(
"""
INSERT INTO webchat_conversation(user_id, cid, history, updated_at, created_at) VALUES (?, ?, ?, ?, ?)
""",
(user_id, cid, history, updated_at, created_at),
)
def get_conversations(self, user_id: str) -> Tuple:
try:
c = self.conn.cursor()
except sqlite3.ProgrammingError:
c = self._get_conn(self.db_path).cursor()
c.execute(
"""
SELECT cid, created_at, updated_at, title, persona_id FROM webchat_conversation WHERE user_id = ? ORDER BY updated_at DESC
""",
(user_id,),
)
res = c.fetchall()
c.close()
conversations = []
for row in res:
cid = row[0]
created_at = row[1]
updated_at = row[2]
title = row[3]
persona_id = row[4]
conversations.append(
Conversation("", cid, "[]", created_at, updated_at, title, persona_id)
)
return conversations
def update_conversation(self, user_id: str, cid: str, history: str):
"""更新对话,并且同时更新时间"""
updated_at = int(time.time())
self._exec_sql(
"""
UPDATE webchat_conversation SET history = ?, updated_at = ? WHERE user_id = ? AND cid = ?
""",
(history, updated_at, user_id, cid),
)
def update_conversation_title(self, user_id: str, cid: str, title: str):
self._exec_sql(
"""
UPDATE webchat_conversation SET title = ? WHERE user_id = ? AND cid = ?
""",
(title, user_id, cid),
)
def update_conversation_persona_id(self, user_id: str, cid: str, persona_id: str):
self._exec_sql(
"""
UPDATE webchat_conversation SET persona_id = ? WHERE user_id = ? AND cid = ?
""",
(persona_id, user_id, cid),
)
def delete_conversation(self, user_id: str, cid: str):
self._exec_sql(
"""
DELETE FROM webchat_conversation WHERE user_id = ? AND cid = ?
""",
(user_id, cid),
)
def get_all_conversations(
self, page: int = 1, page_size: int = 20
) -> Tuple[List[Dict[str, Any]], int]:
"""获取所有对话,支持分页,按更新时间降序排序"""
try:
c = self.conn.cursor()
except sqlite3.ProgrammingError:
c = self._get_conn(self.db_path).cursor()
try:
# 获取总记录数
c.execute("""
SELECT COUNT(*) FROM webchat_conversation
""")
total_count = c.fetchone()[0]
# 计算偏移量
offset = (page - 1) * page_size
# 获取分页数据,按更新时间降序排序
c.execute(
"""
SELECT user_id, cid, created_at, updated_at, title, persona_id
FROM webchat_conversation
ORDER BY updated_at DESC
LIMIT ? OFFSET ?
""",
(page_size, offset),
)
rows = c.fetchall()
conversations = []
for row in rows:
user_id, cid, created_at, updated_at, title, persona_id = row
# 确保 cid 是字符串类型且至少有8个字符否则使用一个默认值
safe_cid = str(cid) if cid else "unknown"
display_cid = safe_cid[:8] if len(safe_cid) >= 8 else safe_cid
conversations.append(
{
"user_id": user_id or "",
"cid": safe_cid,
"title": title or f"对话 {display_cid}",
"persona_id": persona_id or "",
"created_at": created_at or 0,
"updated_at": updated_at or 0,
}
)
return conversations, total_count
except Exception as _:
# 返回空列表和0确保即使出错也有有效的返回值
return [], 0
finally:
c.close()
def get_filtered_conversations(
self,
page: int = 1,
page_size: int = 20,
platforms: List[str] = None,
message_types: List[str] = None,
search_query: str = None,
exclude_ids: List[str] = None,
exclude_platforms: List[str] = None,
) -> Tuple[List[Dict[str, Any]], int]:
"""获取筛选后的对话列表"""
try:
c = self.conn.cursor()
except sqlite3.ProgrammingError:
c = self._get_conn(self.db_path).cursor()
try:
# 构建查询条件
where_clauses = []
params = []
# 平台筛选
if platforms and len(platforms) > 0:
platform_conditions = []
for platform in platforms:
platform_conditions.append("user_id LIKE ?")
params.append(f"{platform}:%")
if platform_conditions:
where_clauses.append(f"({' OR '.join(platform_conditions)})")
# 消息类型筛选
if message_types and len(message_types) > 0:
message_type_conditions = []
for msg_type in message_types:
message_type_conditions.append("user_id LIKE ?")
params.append(f"%:{msg_type}:%")
if message_type_conditions:
where_clauses.append(f"({' OR '.join(message_type_conditions)})")
# 搜索关键词
if search_query:
search_query = search_query.encode("unicode_escape").decode("utf-8")
where_clauses.append(
"(title LIKE ? OR user_id LIKE ? OR cid LIKE ? OR history LIKE ?)"
)
search_param = f"%{search_query}%"
params.extend([search_param, search_param, search_param, search_param])
# 排除特定用户ID
if exclude_ids and len(exclude_ids) > 0:
for exclude_id in exclude_ids:
where_clauses.append("user_id NOT LIKE ?")
params.append(f"{exclude_id}%")
# 排除特定平台
if exclude_platforms and len(exclude_platforms) > 0:
for exclude_platform in exclude_platforms:
where_clauses.append("user_id NOT LIKE ?")
params.append(f"{exclude_platform}:%")
# 构建完整的 WHERE 子句
where_sql = " WHERE " + " AND ".join(where_clauses) if where_clauses else ""
# 构建计数查询
count_sql = f"SELECT COUNT(*) FROM webchat_conversation{where_sql}"
# 获取总记录数
c.execute(count_sql, params)
total_count = c.fetchone()[0]
# 计算偏移量
offset = (page - 1) * page_size
# 构建分页数据查询
data_sql = f"""
SELECT user_id, cid, created_at, updated_at, title, persona_id
FROM webchat_conversation
{where_sql}
ORDER BY updated_at DESC
LIMIT ? OFFSET ?
"""
query_params = params + [page_size, offset]
# 获取分页数据
c.execute(data_sql, query_params)
rows = c.fetchall()
conversations = []
for row in rows:
user_id, cid, created_at, updated_at, title, persona_id = row
# 确保 cid 是字符串类型,否则使用一个默认值
safe_cid = str(cid) if cid else "unknown"
display_cid = safe_cid[:8] if len(safe_cid) >= 8 else safe_cid
conversations.append(
{
"user_id": user_id or "",
"cid": safe_cid,
"title": title or f"对话 {display_cid}",
"persona_id": persona_id or "",
"created_at": created_at or 0,
"updated_at": updated_at or 0,
}
)
return conversations, total_count
except Exception as _:
# 返回空列表和0确保即使出错也有有效的返回值
return [], 0
finally:
c.close()

View File

@@ -1,7 +1,233 @@
"""指标数据"""
import uuid
from datetime import datetime, timezone
from dataclasses import dataclass, field
from typing import List
from sqlmodel import (
SQLModel,
Text,
JSON,
UniqueConstraint,
Field,
)
from typing import Optional, TypedDict
class PlatformStat(SQLModel, table=True):
"""This class represents the statistics of bot usage across different platforms.
Note: In astrbot v4, we moved `platform` table to here.
"""
__tablename__ = "platform_stats"
id: int = Field(primary_key=True, sa_column_kwargs={"autoincrement": True})
timestamp: datetime = Field(nullable=False)
platform_id: str = Field(nullable=False)
platform_type: str = Field(nullable=False) # such as "aiocqhttp", "slack", etc.
count: int = Field(default=0, nullable=False)
__table_args__ = (
UniqueConstraint(
"timestamp",
"platform_id",
"platform_type",
name="uix_platform_stats",
),
)
class ConversationV2(SQLModel, table=True):
__tablename__ = "conversations"
inner_conversation_id: int = Field(
primary_key=True, sa_column_kwargs={"autoincrement": True}
)
conversation_id: str = Field(
max_length=36,
nullable=False,
unique=True,
default_factory=lambda: str(uuid.uuid4()),
)
platform_id: str = Field(nullable=False)
user_id: str = Field(nullable=False)
content: Optional[list] = Field(default=None, sa_type=JSON)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
)
title: Optional[str] = Field(default=None, max_length=255)
persona_id: Optional[str] = Field(default=None)
__table_args__ = (
UniqueConstraint(
"conversation_id",
name="uix_conversation_id",
),
)
class Persona(SQLModel, table=True):
"""Persona is a set of instructions for LLMs to follow.
It can be used to customize the behavior of LLMs.
"""
__tablename__ = "personas"
id: int = Field(primary_key=True, sa_column_kwargs={"autoincrement": True})
persona_id: str = Field(max_length=255, nullable=False)
system_prompt: str = Field(sa_type=Text, nullable=False)
begin_dialogs: Optional[list] = Field(default=None, sa_type=JSON)
"""a list of strings, each representing a dialog to start with"""
tools: Optional[list] = Field(default=None, sa_type=JSON)
"""None means use ALL tools for default, empty list means no tools, otherwise a list of tool names."""
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
)
__table_args__ = (
UniqueConstraint(
"persona_id",
name="uix_persona_id",
),
)
class Preference(SQLModel, table=True):
"""This class represents preferences for bots."""
__tablename__ = "preferences"
id: int | None = Field(
default=None, primary_key=True, sa_column_kwargs={"autoincrement": True}
)
scope: str = Field(nullable=False)
"""Scope of the preference, such as 'global', 'umo', 'plugin'."""
scope_id: str = Field(nullable=False)
"""ID of the scope, such as 'global', 'umo', 'plugin_name'."""
key: str = Field(nullable=False)
value: dict = Field(sa_type=JSON, nullable=False)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
)
__table_args__ = (
UniqueConstraint(
"scope",
"scope_id",
"key",
name="uix_preference_scope_scope_id_key",
),
)
class PlatformMessageHistory(SQLModel, table=True):
"""This class represents the message history for a specific platform.
It is used to store messages that are not LLM-generated, such as user messages
or platform-specific messages.
"""
__tablename__ = "platform_message_history"
id: int = Field(primary_key=True, sa_column_kwargs={"autoincrement": True})
platform_id: str = Field(nullable=False)
user_id: str = Field(nullable=False) # An id of group, user in platform
sender_id: Optional[str] = Field(default=None) # ID of the sender in the platform
sender_name: Optional[str] = Field(
default=None
) # Name of the sender in the platform
content: dict = Field(sa_type=JSON, nullable=False) # a message chain list
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
)
class Attachment(SQLModel, table=True):
"""This class represents attachments for messages in AstrBot.
Attachments can be images, files, or other media types.
"""
__tablename__ = "attachments"
inner_attachment_id: int = Field(
primary_key=True, sa_column_kwargs={"autoincrement": True}
)
attachment_id: str = Field(
max_length=36,
nullable=False,
unique=True,
default_factory=lambda: str(uuid.uuid4()),
)
path: str = Field(nullable=False) # Path to the file on disk
type: str = Field(nullable=False) # Type of the file (e.g., 'image', 'file')
mime_type: str = Field(nullable=False) # MIME type of the file
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
)
__table_args__ = (
UniqueConstraint(
"attachment_id",
name="uix_attachment_id",
),
)
@dataclass
class Conversation:
"""LLM 对话类
对于 WebChathistory 存储了包括指令、回复、图片等在内的所有消息。
对于其他平台的聊天,不存储非 LLM 的回复(因为考虑到已经存储在各自的平台上)。
在 v4.0.0 版本及之后WebChat 的历史记录被迁移至 `PlatformMessageHistory` 表中,
"""
platform_id: str
user_id: str
cid: str
"""对话 ID, 是 uuid 格式的字符串"""
history: str = ""
"""字符串格式的对话列表。"""
title: str | None = ""
persona_id: str | None = ""
created_at: int = 0
updated_at: int = 0
class Personality(TypedDict):
"""LLM 人格类。
在 v4.0.0 版本及之后,推荐使用上面的 Persona 类。并且, mood_imitation_dialogs 字段已被废弃。
"""
prompt: str = ""
name: str = ""
begin_dialogs: list[str] = []
mood_imitation_dialogs: list[str] = []
"""情感模拟对话预设。在 v4.0.0 版本及之后,已被废弃。"""
tools: list[str] | None = None
"""工具列表。None 表示使用所有工具,空列表表示不使用任何工具"""
# cache
_begin_dialogs_processed: list[dict] = []
_mood_imitation_dialogs_processed: str = ""
# ====
# Deprecated, and will be removed in future versions.
# ====
@dataclass
@@ -13,77 +239,6 @@ class Platform:
timestamp: int
@dataclass
class Provider:
"""供应商使用统计数据"""
name: str
count: int
timestamp: int
@dataclass
class Plugin:
"""插件使用统计数据"""
name: str
count: int
timestamp: int
@dataclass
class Command:
"""命令使用统计数据"""
name: str
count: int
timestamp: int
@dataclass
class Stats:
platform: List[Platform] = field(default_factory=list)
command: List[Command] = field(default_factory=list)
llm: List[Provider] = field(default_factory=list)
@dataclass
class LLMHistory:
"""LLM 聊天时持久化的信息"""
provider_type: str
session_id: str
content: str
@dataclass
class ATRIVision:
"""Deprecated"""
id: str
url_or_path: str
caption: str
is_meme: bool
keywords: List[str]
platform_name: str
session_id: str
sender_nickname: str
timestamp: int = -1
@dataclass
class Conversation:
"""LLM 对话存储
对于网页聊天history 存储了包括指令、回复、图片等在内的所有消息。
对于其他平台的聊天,不存储非 LLM 的回复(因为考虑到已经存储在各自的平台上)。
"""
user_id: str
cid: str
history: str = ""
"""字符串格式的列表。"""
created_at: int = 0
updated_at: int = 0
title: str = ""
persona_id: str = ""
platform: list[Platform] = field(default_factory=list)

File diff suppressed because it is too large Load Diff

View File

@@ -1,50 +0,0 @@
CREATE TABLE IF NOT EXISTS platform(
name VARCHAR(32),
count INTEGER,
timestamp INTEGER
);
CREATE TABLE IF NOT EXISTS llm(
name VARCHAR(32),
count INTEGER,
timestamp INTEGER
);
CREATE TABLE IF NOT EXISTS plugin(
name VARCHAR(32),
count INTEGER,
timestamp INTEGER
);
CREATE TABLE IF NOT EXISTS command(
name VARCHAR(32),
count INTEGER,
timestamp INTEGER
);
CREATE TABLE IF NOT EXISTS llm_history(
provider_type VARCHAR(32),
session_id VARCHAR(32),
content TEXT
);
-- ATRI
CREATE TABLE IF NOT EXISTS atri_vision(
id TEXT,
url_or_path TEXT,
caption TEXT,
is_meme BOOLEAN,
keywords TEXT,
platform_name VARCHAR(32),
session_id VARCHAR(32),
sender_nickname VARCHAR(32),
timestamp INTEGER
);
CREATE TABLE IF NOT EXISTS webchat_conversation(
user_id TEXT, -- 会话 id
cid TEXT, -- 对话 id
history TEXT,
created_at INTEGER,
updated_at INTEGER,
title TEXT,
persona_id TEXT
);
PRAGMA encoding = 'UTF-8';

View File

@@ -5,6 +5,7 @@ from .document_storage import DocumentStorage
from .embedding_storage import EmbeddingStorage
from ..base import Result, BaseVecDB
from astrbot.core.provider.provider import EmbeddingProvider
from astrbot.core.provider.provider import RerankProvider
class FaissVecDB(BaseVecDB):
@@ -17,6 +18,7 @@ class FaissVecDB(BaseVecDB):
doc_store_path: str,
index_store_path: str,
embedding_provider: EmbeddingProvider,
rerank_provider: RerankProvider | None = None,
):
self.doc_store_path = doc_store_path
self.index_store_path = index_store_path
@@ -26,11 +28,14 @@ class FaissVecDB(BaseVecDB):
embedding_provider.get_dim(), index_store_path
)
self.embedding_provider = embedding_provider
self.rerank_provider = rerank_provider
async def initialize(self):
await self.document_storage.initialize()
async def insert(self, content: str, metadata: dict = None, id: str = None) -> int:
async def insert(
self, content: str, metadata: dict | None = None, id: str | None = None
) -> int:
"""
插入一条文本和其对应向量,自动生成 ID 并保持一致性。
"""
@@ -53,7 +58,12 @@ class FaissVecDB(BaseVecDB):
return int_id
async def retrieve(
self, query: str, k: int = 5, fetch_k: int = 20, metadata_filters: dict = None
self,
query: str,
k: int = 5,
fetch_k: int = 20,
rerank: bool = False,
metadata_filters: dict | None = None,
) -> list[Result]:
"""
搜索最相似的文档。
@@ -62,6 +72,7 @@ class FaissVecDB(BaseVecDB):
query (str): 查询文本
k (int): 返回的最相似文档的数量
fetch_k (int): 在根据 metadata 过滤前从 FAISS 中获取的数量
rerank (bool): 是否使用重排序。这需要在实例化时提供 rerank_provider, 如果未提供并且 rerank 为 True, 不会抛出异常。
metadata_filters (dict): 元数据过滤器
Returns:
@@ -72,7 +83,6 @@ class FaissVecDB(BaseVecDB):
vector=np.array([embedding]).astype("float32"),
k=fetch_k if metadata_filters else k,
)
# TODO: rerank
if len(indices[0]) == 0 or indices[0][0] == -1:
return []
# normalize scores
@@ -83,7 +93,7 @@ class FaissVecDB(BaseVecDB):
)
if not fetched_docs:
return []
result_docs = []
result_docs: list[Result] = []
idx_pos = {fetch_doc["id"]: idx for idx, fetch_doc in enumerate(fetched_docs)}
for i, indice_idx in enumerate(indices[0]):
@@ -93,7 +103,20 @@ class FaissVecDB(BaseVecDB):
fetch_doc = fetched_docs[pos]
score = scores[0][i]
result_docs.append(Result(similarity=float(score), data=fetch_doc))
return result_docs[:k]
top_k_results = result_docs[:k]
if rerank and self.rerank_provider:
documents = [doc.data["text"] for doc in top_k_results]
reranked_results = await self.rerank_provider.rerank(query, documents)
reranked_results = sorted(
reranked_results, key=lambda x: x.relevance_score, reverse=True
)
top_k_results = [
top_k_results[reranked_result.index] for reranked_result in reranked_results
]
return top_k_results
async def delete(self, doc_id: int):
"""

View File

@@ -16,30 +16,32 @@ from asyncio import Queue
from astrbot.core.pipeline.scheduler import PipelineScheduler
from astrbot.core import logger
from .platform import AstrMessageEvent
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
class EventBus:
"""事件总线: 用于处理事件的分发和处理
"""用于处理事件的分发和处理"""
维护一个异步队列, 来接受各种消息事件
"""
def __init__(self, event_queue: Queue, pipeline_scheduler: PipelineScheduler):
def __init__(
self,
event_queue: Queue,
pipeline_scheduler_mapping: dict[str, PipelineScheduler],
astrbot_config_mgr: AstrBotConfigManager = None,
):
self.event_queue = event_queue # 事件队列
self.pipeline_scheduler = pipeline_scheduler # 管道调度器
# abconf uuid -> scheduler
self.pipeline_scheduler_mapping = pipeline_scheduler_mapping
self.astrbot_config_mgr = astrbot_config_mgr
async def dispatch(self):
"""无限循环的调度函数, 从事件队列中获取新的事件, 打印日志并创建一个新的异步任务来执行管道调度器的处理逻辑"""
while True:
event: AstrMessageEvent = (
await self.event_queue.get()
) # 从事件队列中获取新的事件
self._print_event(event) # 打印日志
asyncio.create_task(
self.pipeline_scheduler.execute(event)
) # 创建新的异步任务来执行管道调度器的处理逻辑
event: AstrMessageEvent = await self.event_queue.get()
conf_info = self.astrbot_config_mgr.get_conf_info(event.unified_msg_origin)
self._print_event(event, conf_info["name"])
scheduler = self.pipeline_scheduler_mapping.get(conf_info["id"])
asyncio.create_task(scheduler.execute(event))
def _print_event(self, event: AstrMessageEvent):
def _print_event(self, event: AstrMessageEvent, conf_name: str):
"""用于记录事件信息
Args:
@@ -48,10 +50,10 @@ class EventBus:
# 如果有发送者名称: [平台名] 发送者名称/发送者ID: 消息概要
if event.get_sender_name():
logger.info(
f"[{event.get_platform_name()}] {event.get_sender_name()}/{event.get_sender_id()}: {event.get_message_outline()}"
f"[{conf_name}] [{event.get_platform_id()}({event.get_platform_name()})] {event.get_sender_name()}/{event.get_sender_id()}: {event.get_message_outline()}"
)
# 没有发送者名称: [平台名] 发送者ID: 消息概要
else:
logger.info(
f"[{event.get_platform_name()}] {event.get_sender_id()}: {event.get_message_outline()}"
f"[{conf_name}] [{event.get_platform_id()}({event.get_platform_name()})] {event.get_sender_id()}: {event.get_message_outline()}"
)

View File

@@ -37,7 +37,7 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from astrbot.core.utils.io import download_file, download_image_by_url, file_to_base64
class ComponentType(Enum):
class ComponentType(str, Enum):
Plain = "Plain" # 纯文本消息
Face = "Face" # QQ表情
Record = "Record" # 语音
@@ -108,7 +108,7 @@ class BaseMessageComponent(BaseModel):
class Plain(BaseMessageComponent):
type: ComponentType = "Plain"
type = ComponentType.Plain
text: str
convert: T.Optional[bool] = True # 若为 False 则直接发送未转换 CQ 码的消息
@@ -128,8 +128,9 @@ class Plain(BaseMessageComponent):
async def to_dict(self):
return {"type": "text", "data": {"text": self.text}}
class Face(BaseMessageComponent):
type: ComponentType = "Face"
type = ComponentType.Face
id: int
def __init__(self, **_):
@@ -137,7 +138,7 @@ class Face(BaseMessageComponent):
class Record(BaseMessageComponent):
type: ComponentType = "Record"
type = ComponentType.Record
file: T.Optional[str] = ""
magic: T.Optional[bool] = False
url: T.Optional[str] = ""
@@ -164,19 +165,24 @@ class Record(BaseMessageComponent):
return Record(file=url, **_)
raise Exception("not a valid url")
@staticmethod
def fromBase64(bs64_data: str, **_):
return Record(file=f"base64://{bs64_data}", **_)
async def convert_to_file_path(self) -> str:
"""将这个语音统一转换为本地文件路径。这个方法避免了手动判断语音数据类型,直接返回语音数据的本地路径(如果是网络 URL, 则会自动进行下载)。
Returns:
str: 语音的本地路径,以绝对路径表示。
"""
if self.file and self.file.startswith("file:///"):
file_path = self.file[8:]
return file_path
elif self.file and self.file.startswith("http"):
if not self.file:
raise Exception(f"not a valid file: {self.file}")
if self.file.startswith("file:///"):
return self.file[8:]
elif self.file.startswith("http"):
file_path = await download_image_by_url(self.file)
return os.path.abspath(file_path)
elif self.file and self.file.startswith("base64://"):
elif self.file.startswith("base64://"):
bs64_data = self.file.removeprefix("base64://")
image_bytes = base64.b64decode(bs64_data)
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
@@ -185,8 +191,7 @@ class Record(BaseMessageComponent):
f.write(image_bytes)
return os.path.abspath(file_path)
elif os.path.exists(self.file):
file_path = self.file
return os.path.abspath(file_path)
return os.path.abspath(self.file)
else:
raise Exception(f"not a valid file: {self.file}")
@@ -197,12 +202,14 @@ class Record(BaseMessageComponent):
str: 语音的 base64 编码,不以 base64:// 或者 data:image/jpeg;base64, 开头。
"""
# convert to base64
if self.file and self.file.startswith("file:///"):
if not self.file:
raise Exception(f"not a valid file: {self.file}")
if self.file.startswith("file:///"):
bs64_data = file_to_base64(self.file[8:])
elif self.file and self.file.startswith("http"):
elif self.file.startswith("http"):
file_path = await download_image_by_url(self.file)
bs64_data = file_to_base64(file_path)
elif self.file and self.file.startswith("base64://"):
elif self.file.startswith("base64://"):
bs64_data = self.file
elif os.path.exists(self.file):
bs64_data = file_to_base64(self.file)
@@ -236,7 +243,7 @@ class Record(BaseMessageComponent):
class Video(BaseMessageComponent):
type: ComponentType = "Video"
type = ComponentType.Video
file: str
cover: T.Optional[str] = ""
c: T.Optional[int] = 2
@@ -322,7 +329,7 @@ class Video(BaseMessageComponent):
class At(BaseMessageComponent):
type: ComponentType = "At"
type = ComponentType.At
qq: T.Union[int, str] # 此处str为all时代表所有人
name: T.Optional[str] = ""
@@ -344,28 +351,28 @@ class AtAll(At):
class RPS(BaseMessageComponent): # TODO
type: ComponentType = "RPS"
type = ComponentType.RPS
def __init__(self, **_):
super().__init__(**_)
class Dice(BaseMessageComponent): # TODO
type: ComponentType = "Dice"
type = ComponentType.Dice
def __init__(self, **_):
super().__init__(**_)
class Shake(BaseMessageComponent): # TODO
type: ComponentType = "Shake"
type = ComponentType.Shake
def __init__(self, **_):
super().__init__(**_)
class Anonymous(BaseMessageComponent): # TODO
type: ComponentType = "Anonymous"
type = ComponentType.Anonymous
ignore: T.Optional[bool] = False
def __init__(self, **_):
@@ -373,7 +380,7 @@ class Anonymous(BaseMessageComponent): # TODO
class Share(BaseMessageComponent):
type: ComponentType = "Share"
type = ComponentType.Share
url: str
title: str
content: T.Optional[str] = ""
@@ -384,7 +391,7 @@ class Share(BaseMessageComponent):
class Contact(BaseMessageComponent): # TODO
type: ComponentType = "Contact"
type = ComponentType.Contact
_type: str # type 字段冲突
id: T.Optional[int] = 0
@@ -393,7 +400,7 @@ class Contact(BaseMessageComponent): # TODO
class Location(BaseMessageComponent): # TODO
type: ComponentType = "Location"
type = ComponentType.Location
lat: float
lon: float
title: T.Optional[str] = ""
@@ -404,7 +411,7 @@ class Location(BaseMessageComponent): # TODO
class Music(BaseMessageComponent):
type: ComponentType = "Music"
type = ComponentType.Music
_type: str
id: T.Optional[int] = 0
url: T.Optional[str] = ""
@@ -421,7 +428,7 @@ class Music(BaseMessageComponent):
class Image(BaseMessageComponent):
type: ComponentType = "Image"
type = ComponentType.Image
file: T.Optional[str] = ""
_type: T.Optional[str] = ""
subType: T.Optional[int] = 0
@@ -464,14 +471,15 @@ class Image(BaseMessageComponent):
Returns:
str: 图片的本地路径,以绝对路径表示。
"""
url = self.url if self.url else self.file
if url and url.startswith("file:///"):
image_file_path = url[8:]
return image_file_path
elif url and url.startswith("http"):
url = self.url or self.file
if not url:
raise ValueError("No valid file or URL provided")
if url.startswith("file:///"):
return url[8:]
elif url.startswith("http"):
image_file_path = await download_image_by_url(url)
return os.path.abspath(image_file_path)
elif url and url.startswith("base64://"):
elif url.startswith("base64://"):
bs64_data = url.removeprefix("base64://")
image_bytes = base64.b64decode(bs64_data)
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
@@ -480,8 +488,7 @@ class Image(BaseMessageComponent):
f.write(image_bytes)
return os.path.abspath(image_file_path)
elif os.path.exists(url):
image_file_path = url
return os.path.abspath(image_file_path)
return os.path.abspath(url)
else:
raise Exception(f"not a valid file: {url}")
@@ -492,13 +499,15 @@ class Image(BaseMessageComponent):
str: 图片的 base64 编码,不以 base64:// 或者 data:image/jpeg;base64, 开头。
"""
# convert to base64
url = self.url if self.url else self.file
if url and url.startswith("file:///"):
url = self.url or self.file
if not url:
raise ValueError("No valid file or URL provided")
if url.startswith("file:///"):
bs64_data = file_to_base64(url[8:])
elif url and url.startswith("http"):
elif url.startswith("http"):
image_file_path = await download_image_by_url(url)
bs64_data = file_to_base64(image_file_path)
elif url and url.startswith("base64://"):
elif url.startswith("base64://"):
bs64_data = url
elif os.path.exists(url):
bs64_data = file_to_base64(url)
@@ -532,7 +541,7 @@ class Image(BaseMessageComponent):
class Reply(BaseMessageComponent):
type: ComponentType = "Reply"
type = ComponentType.Reply
id: T.Union[str, int]
"""所引用的消息 ID"""
chain: T.Optional[T.List["BaseMessageComponent"]] = []
@@ -558,7 +567,7 @@ class Reply(BaseMessageComponent):
class RedBag(BaseMessageComponent):
type: ComponentType = "RedBag"
type = ComponentType.RedBag
title: str
def __init__(self, **_):
@@ -566,7 +575,7 @@ class RedBag(BaseMessageComponent):
class Poke(BaseMessageComponent):
type: str = ""
type: str = ComponentType.Poke
id: T.Optional[int] = 0
qq: T.Optional[int] = 0
@@ -576,7 +585,7 @@ class Poke(BaseMessageComponent):
class Forward(BaseMessageComponent):
type: ComponentType = "Forward"
type = ComponentType.Forward
id: str
def __init__(self, **_):
@@ -586,7 +595,7 @@ class Forward(BaseMessageComponent):
class Node(BaseMessageComponent):
"""群合并转发消息"""
type: ComponentType = "Node"
type = ComponentType.Node
id: T.Optional[int] = 0 # 忽略
name: T.Optional[str] = "" # qq昵称
uin: T.Optional[str] = "0" # qq号
@@ -638,7 +647,7 @@ class Node(BaseMessageComponent):
class Nodes(BaseMessageComponent):
type: ComponentType = "Nodes"
type = ComponentType.Nodes
nodes: T.List[Node]
def __init__(self, nodes: T.List[Node], **_):
@@ -664,7 +673,7 @@ class Nodes(BaseMessageComponent):
class Xml(BaseMessageComponent):
type: ComponentType = "Xml"
type = ComponentType.Xml
data: str
resid: T.Optional[int] = 0
@@ -673,7 +682,7 @@ class Xml(BaseMessageComponent):
class Json(BaseMessageComponent):
type: ComponentType = "Json"
type = ComponentType.Json
data: T.Union[str, dict]
resid: T.Optional[int] = 0
@@ -684,7 +693,7 @@ class Json(BaseMessageComponent):
class CardImage(BaseMessageComponent):
type: ComponentType = "CardImage"
type = ComponentType.CardImage
file: str
cache: T.Optional[bool] = True
minwidth: T.Optional[int] = 400
@@ -703,7 +712,7 @@ class CardImage(BaseMessageComponent):
class TTS(BaseMessageComponent):
type: ComponentType = "TTS"
type = ComponentType.TTS
text: str
def __init__(self, **_):
@@ -711,7 +720,7 @@ class TTS(BaseMessageComponent):
class Unknown(BaseMessageComponent):
type: ComponentType = "Unknown"
type = ComponentType.Unknown
text: str
def toString(self):
@@ -723,7 +732,7 @@ class File(BaseMessageComponent):
文件消息段
"""
type: ComponentType = "File"
type = ComponentType.File
name: T.Optional[str] = "" # 名字
file_: T.Optional[str] = "" # 本地路径
url: T.Optional[str] = "" # url
@@ -853,7 +862,7 @@ class File(BaseMessageComponent):
class WechatEmoji(BaseMessageComponent):
type: ComponentType = "WechatEmoji"
type = ComponentType.WechatEmoji
md5: T.Optional[str] = ""
md5_len: T.Optional[int] = 0
cdnurl: T.Optional[str] = ""

183
astrbot/core/persona_mgr.py Normal file
View File

@@ -0,0 +1,183 @@
from astrbot.core.db import BaseDatabase
from astrbot.core.db.po import Persona, Personality
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
from astrbot.core.platform.message_session import MessageSession
from astrbot import logger
DEFAULT_PERSONALITY = Personality(
prompt="You are a helpful and friendly assistant.",
name="default",
begin_dialogs=[],
mood_imitation_dialogs=[],
tools=None,
_begin_dialogs_processed=[],
_mood_imitation_dialogs_processed="",
)
class PersonaManager:
def __init__(self, db_helper: BaseDatabase, acm: AstrBotConfigManager):
self.db = db_helper
self.acm = acm
default_ps = acm.default_conf.get("provider_settings", {})
self.default_persona: str = default_ps.get("default_personality", "default")
self.personas: list[Persona] = []
self.selected_default_persona: Persona | None = None
self.personas_v3: list[Personality] = []
self.selected_default_persona_v3: Personality | None = None
self.persona_v3_config: list[dict] = []
async def initialize(self):
self.personas = await self.get_all_personas()
self.get_v3_persona_data()
logger.info(f"已加载 {len(self.personas)} 个人格。")
async def get_persona(self, persona_id: str):
"""获取指定 persona 的信息"""
persona = await self.db.get_persona_by_id(persona_id)
if not persona:
raise ValueError(f"Persona with ID {persona_id} does not exist.")
return persona
async def get_default_persona_v3(
self, umo: str | MessageSession | None = None
) -> Personality:
"""获取默认 persona"""
cfg = self.acm.get_conf(umo)
default_persona_id = cfg.get("provider_settings", {}).get(
"default_personality", "default"
)
if not default_persona_id or default_persona_id == "default":
return DEFAULT_PERSONALITY
try:
return next(p for p in self.personas_v3 if p["name"] == default_persona_id)
except Exception:
return DEFAULT_PERSONALITY
async def delete_persona(self, persona_id: str):
"""删除指定 persona"""
if not await self.db.get_persona_by_id(persona_id):
raise ValueError(f"Persona with ID {persona_id} does not exist.")
await self.db.delete_persona(persona_id)
self.personas = [p for p in self.personas if p.persona_id != persona_id]
self.get_v3_persona_data()
async def update_persona(
self,
persona_id: str,
system_prompt: str = None,
begin_dialogs: list[str] = None,
tools: list[str] = None,
):
"""更新指定 persona 的信息。tools 参数为 None 时表示使用所有工具,空列表表示不使用任何工具"""
existing_persona = await self.db.get_persona_by_id(persona_id)
if not existing_persona:
raise ValueError(f"Persona with ID {persona_id} does not exist.")
persona = await self.db.update_persona(
persona_id, system_prompt, begin_dialogs, tools=tools
)
if persona:
for i, p in enumerate(self.personas):
if p.persona_id == persona_id:
self.personas[i] = persona
break
self.get_v3_persona_data()
return persona
async def get_all_personas(self) -> list[Persona]:
"""获取所有 personas"""
return await self.db.get_personas()
async def create_persona(
self,
persona_id: str,
system_prompt: str,
begin_dialogs: list[str] = None,
tools: list[str] = None,
) -> Persona:
"""创建新的 persona。tools 参数为 None 时表示使用所有工具,空列表表示不使用任何工具"""
if await self.db.get_persona_by_id(persona_id):
raise ValueError(f"Persona with ID {persona_id} already exists.")
new_persona = await self.db.insert_persona(
persona_id, system_prompt, begin_dialogs, tools=tools
)
self.personas.append(new_persona)
self.get_v3_persona_data()
return new_persona
def get_v3_persona_data(
self,
) -> tuple[list[dict], list[Personality], Personality]:
"""获取 AstrBot <4.0.0 版本的 persona 数据。
Returns:
- list[dict]: 包含 persona 配置的字典列表。
- list[Personality]: 包含 Personality 对象的列表。
- Personality: 默认选择的 Personality 对象。
"""
v3_persona_config = [
{
"prompt": persona.system_prompt,
"name": persona.persona_id,
"begin_dialogs": persona.begin_dialogs or [],
"mood_imitation_dialogs": [], # deprecated
"tools": persona.tools,
}
for persona in self.personas
]
personas_v3: list[Personality] = []
selected_default_persona: Personality | None = None
for persona_cfg in v3_persona_config:
begin_dialogs = persona_cfg.get("begin_dialogs", [])
bd_processed = []
if begin_dialogs:
if len(begin_dialogs) % 2 != 0:
logger.error(
f"{persona_cfg['name']} 人格情景预设对话格式不对,条数应该为偶数。"
)
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
}
)
user_turn = not user_turn
try:
persona = Personality(
**persona_cfg,
_begin_dialogs_processed=bd_processed,
_mood_imitation_dialogs_processed="", # deprecated
)
if persona["name"] == self.default_persona:
selected_default_persona = persona
personas_v3.append(persona)
except Exception as e:
logger.error(f"解析 Persona 配置失败:{e}")
if not selected_default_persona and len(personas_v3) > 0:
# 默认选择第一个
selected_default_persona = personas_v3[0]
if not selected_default_persona:
selected_default_persona = DEFAULT_PERSONALITY
personas_v3.append(selected_default_persona)
self.personas_v3 = personas_v3
self.selected_default_persona_v3 = selected_default_persona
self.persona_v3_config = v3_persona_config
self.selected_default_persona = Persona(
persona_id=selected_default_persona["name"],
system_prompt=selected_default_persona["prompt"],
begin_dialogs=selected_default_persona["begin_dialogs"],
tools=selected_default_persona["tools"] or None,
)
return v3_persona_config, personas_v3, selected_default_persona

View File

@@ -4,7 +4,6 @@ from astrbot.core.message.message_event_result import (
)
from .content_safety_check.stage import ContentSafetyCheckStage
from .platform_compatibility.stage import PlatformCompatibilityStage
from .preprocess_stage.stage import PreProcessStage
from .process_stage.stage import ProcessStage
from .rate_limit_check.stage import RateLimitStage
@@ -21,7 +20,6 @@ STAGES_ORDER = [
"SessionStatusCheckStage", # 检查会话是否整体启用
"RateLimitStage", # 检查会话是否超过频率限制
"ContentSafetyCheckStage", # 检查内容安全
"PlatformCompatibilityStage", # 检查所有处理器的平台兼容性
"PreProcessStage", # 预处理
"ProcessStage", # 交由 Stars 处理a.k.a 插件),或者 LLM 调用
"ResultDecorateStage", # 处理结果比如添加回复前缀、t2i、转换为语音 等
@@ -34,7 +32,6 @@ __all__ = [
"SessionStatusCheckStage",
"RateLimitStage",
"ContentSafetyCheckStage",
"PlatformCompatibilityStage",
"PreProcessStage",
"ProcessStage",
"ResultDecorateStage",

View File

@@ -1,14 +1,7 @@
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.config import AstrBotConfig
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
from .context_utils import call_handler, call_event_hook
@dataclass
@@ -17,97 +10,6 @@ class PipelineContext:
astrbot_config: AstrBotConfig # AstrBot 配置对象
plugin_manager: PluginManager # 插件管理器对象
async def call_event_hook(
self,
event: AstrMessageEvent,
hook_type: EventType,
*args,
) -> bool:
"""调用事件钩子函数
Returns:
bool: 如果事件被终止,返回 True
"""
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 event.is_stopped()
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
astrbot_config_id: str
call_handler = call_handler
call_event_hook = call_event_hook

View File

@@ -0,0 +1,98 @@
import inspect
import traceback
import typing as T
from astrbot 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
from astrbot.core.platform.astr_message_event import AstrMessageEvent
async def call_handler(
event: AstrMessageEvent,
handler: T.Awaitable,
*args,
**kwargs,
) -> T.AsyncGenerator[T.Any, None]:
"""执行事件处理函数并处理其返回结果
该方法负责调用处理函数并处理不同类型的返回值。它支持两种类型的处理函数:
1. 异步生成器: 实现洋葱模型,每次 yield 都会将控制权交回上层
2. 协程: 执行一次并处理返回值
Args:
event (AstrMessageEvent): 事件对象
handler (Awaitable): 事件处理函数
Returns:
AsyncGenerator[None, None]: 异步生成器,用于在管道中传递控制流
"""
ready_to_call = None # 一个协程或者异步生成器
trace_ = None
try:
ready_to_call = handler(event, *args, **kwargs)
except TypeError:
logger.error("处理函数参数不匹配,请检查 handler 的定义。", exc_info=True)
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
async def call_event_hook(
event: AstrMessageEvent,
hook_type: EventType,
*args,
**kwargs,
) -> bool:
"""调用事件钩子函数
Returns:
bool: 如果事件被终止,返回 True
# """
handlers = star_handlers_registry.get_handlers_by_event_type(
hook_type, plugins_name=event.plugins_name
)
for handler in handlers:
try:
logger.debug(
f"hook({hook_type.name}) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}"
)
await handler.handler(event, *args, **kwargs)
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 event.is_stopped()

View File

@@ -1,56 +0,0 @@
from ..stage import Stage, register_stage
from ..context import PipelineContext
from typing import Union, AsyncGenerator
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.star.star import star_map
from astrbot.core.star.star_handler import StarHandlerMetadata
from astrbot.core import logger
@register_stage
class PlatformCompatibilityStage(Stage):
"""检查所有处理器的平台兼容性。
这个阶段会检查所有处理器是否在当前平台启用如果未启用则设置platform_compatible属性为False。
"""
async def initialize(self, ctx: PipelineContext) -> None:
"""初始化平台兼容性检查阶段
Args:
ctx (PipelineContext): 消息管道上下文对象, 包括配置和插件管理器
"""
self.ctx = ctx
async def process(
self, event: AstrMessageEvent
) -> Union[None, AsyncGenerator[None, None]]:
# 获取当前平台ID
platform_id = event.get_platform_id()
# 获取已激活的处理器
activated_handlers = event.get_extra("activated_handlers")
if activated_handlers is None:
activated_handlers = []
# 标记不兼容的处理器
for handler in activated_handlers:
if not isinstance(handler, StarHandlerMetadata):
continue
# 检查处理器是否在当前平台启用
enabled = handler.is_enabled_for_platform(platform_id)
if not enabled:
if handler.handler_module_path in star_map:
plugin_name = star_map[handler.handler_module_path].name
logger.debug(
f"[PlatformCompatibilityStage] 插件 {plugin_name} 在平台 {platform_id} 未启用,标记处理器 {handler.handler_name} 为平台不兼容"
)
# 设置处理器为平台不兼容状态
# TODO: 更好的标记方式
handler.platform_compatible = False
else:
# 确保处理器为平台兼容状态
handler.platform_compatible = True
# 更新已激活的处理器列表
event.set_extra("activated_handlers", activated_handlers)

View File

@@ -20,12 +20,270 @@ from astrbot.core.provider.entities import (
LLMResponse,
ProviderRequest,
)
from astrbot.core.agent.hooks import BaseAgentRunHooks
from astrbot.core.agent.runners.tool_loop_agent_runner import ToolLoopAgentRunner
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.tool import ToolSet, FunctionTool
from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor
from astrbot.core.agent.handoff import HandoffTool
from astrbot.core.star.session_llm_manager import SessionServiceManager
from astrbot.core.star.star_handler import EventType
from astrbot.core.utils.metrics import Metric
from ...context import PipelineContext
from ..agent_runner.tool_loop_agent import ToolLoopAgent
from ...context import PipelineContext, call_event_hook, call_handler
from ..stage import Stage
from astrbot.core.provider.register import llm_tools
from astrbot.core.star.star_handler import star_map
from astrbot.core.astr_agent_context import AstrAgentContext
try:
import mcp
except (ModuleNotFoundError, ImportError):
logger.warning("警告: 缺少依赖库 'mcp',将无法使用 MCP 服务。")
AgentContextWrapper = ContextWrapper[AstrAgentContext]
AgentRunner = ToolLoopAgentRunner[AgentContextWrapper]
class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
@classmethod
async def execute(cls, tool, run_context, **tool_args):
"""执行函数调用。
Args:
event (AstrMessageEvent): 事件对象, 当 origin 为 local 时必须提供。
**kwargs: 函数调用的参数。
Returns:
AsyncGenerator[None | mcp.types.CallToolResult, None]
"""
if isinstance(tool, HandoffTool):
async for r in cls._execute_handoff(tool, run_context, **tool_args):
yield r
return
if tool.origin == "local":
async for r in cls._execute_local(tool, run_context, **tool_args):
yield r
return
elif tool.origin == "mcp":
async for r in cls._execute_mcp(tool, run_context, **tool_args):
yield r
return
raise Exception(f"Unknown function origin: {tool.origin}")
@classmethod
async def _execute_handoff(
cls,
tool: HandoffTool,
run_context: ContextWrapper[AstrAgentContext],
**tool_args,
):
input_ = tool_args.get("input", "agent")
agent_runner = AgentRunner()
# make toolset for the agent
tools = tool.agent.tools
if tools:
toolset = ToolSet()
for t in tools:
if isinstance(t, str):
_t = llm_tools.get_func(t)
if _t:
toolset.add_tool(_t)
elif isinstance(t, FunctionTool):
toolset.add_tool(t)
else:
toolset = None
request = ProviderRequest(
prompt=input_,
system_prompt=tool.description,
image_urls=[], # 暂时不传递原始 agent 的上下文
contexts=[], # 暂时不传递原始 agent 的上下文
func_tool=toolset,
)
astr_agent_ctx = AstrAgentContext(
provider=run_context.context.provider,
first_provider_request=run_context.context.first_provider_request,
curr_provider_request=request,
streaming=run_context.context.streaming,
)
logger.debug(f"正在将任务委托给 Agent: {tool.agent.name}, input: {input_}")
await run_context.event.send(
MessageChain().message("✨ 正在将任务委托给 Agent: " + tool.agent.name)
)
await agent_runner.reset(
provider=run_context.context.provider,
request=request,
run_context=AgentContextWrapper(
context=astr_agent_ctx, event=run_context.event
),
tool_executor=FunctionToolExecutor(),
agent_hooks=tool.agent.run_hooks or BaseAgentRunHooks[AstrAgentContext](),
streaming=run_context.context.streaming,
)
async for _ in run_agent(agent_runner, 15, True):
pass
if agent_runner.done():
llm_response = agent_runner.get_final_llm_resp()
logger.debug(
f"Agent {tool.agent.name} 任务完成, response: {llm_response.completion_text}"
)
result = (
f"Agent {tool.agent.name} respond with: {llm_response.completion_text}\n\n"
"Note: If the result is error or need user provide more information, please provide more information to the agent(you can ask user for more information first)."
)
text_content = mcp.types.TextContent(
type="text",
text=result,
)
yield mcp.types.CallToolResult(content=[text_content])
else:
yield mcp.types.TextContent(
type="text",
text=f"error when deligate task to {tool.agent.name}",
)
yield mcp.types.CallToolResult(content=[text_content])
return
@classmethod
async def _execute_local(
cls,
tool: FunctionTool,
run_context: ContextWrapper[AstrAgentContext],
**tool_args,
):
if not run_context.event:
raise ValueError("Event must be provided for local function tools.")
# 检查 tool 下有没有 run 方法
if not tool.handler and not hasattr(tool, "run"):
raise ValueError("Tool must have a valid handler or 'run' method.")
awaitable = tool.handler or getattr(tool, "run")
wrapper = call_handler(
event=run_context.event,
handler=awaitable,
**tool_args,
)
async for resp in wrapper:
if resp is not None:
if isinstance(resp, mcp.types.CallToolResult):
yield resp
else:
text_content = mcp.types.TextContent(
type="text",
text=str(resp),
)
yield mcp.types.CallToolResult(content=[text_content])
else:
# NOTE: Tool 在这里直接请求发送消息给用户
# TODO: 是否需要判断 event.get_result() 是否为空?
# 如果为空,则说明没有发送消息给用户,并且返回值为空,将返回一个特殊的 TextContent,其内容如"工具没有返回内容"
yield None
@classmethod
async def _execute_mcp(
cls,
tool: FunctionTool,
run_context: ContextWrapper[AstrAgentContext],
**tool_args,
):
if not tool.mcp_client:
raise ValueError("MCP client is not available for MCP function tools.")
res = await tool.mcp_client.session.call_tool(
name=tool.name,
arguments=tool_args,
)
if not res:
return
yield res
class MainAgentHooks(BaseAgentRunHooks[AgentContextWrapper]):
async def on_agent_done(self, run_context, llm_response):
# 执行事件钩子
await call_event_hook(
run_context.event, EventType.OnLLMResponseEvent, llm_response
)
MAIN_AGENT_HOOKS = MainAgentHooks()
async def run_agent(
agent_runner: AgentRunner, max_step: int = 30, show_tool_use: bool = True
) -> AsyncGenerator[MessageChain, None]:
step_idx = 0
astr_event = agent_runner.run_context.event
while step_idx < max_step:
step_idx += 1
try:
async for resp in agent_runner.step():
if astr_event.is_stopped():
return
if resp.type == "tool_call_result":
msg_chain = resp.data["chain"]
if msg_chain.type == "tool_direct_result":
# tool_direct_result 用于标记 llm tool 需要直接发送给用户的内容
resp.data["chain"].type = "tool_call_result"
await astr_event.send(resp.data["chain"])
continue
# 对于其他情况,暂时先不处理
continue
elif resp.type == "tool_call":
if agent_runner.streaming:
# 用来标记流式响应需要分节
yield MessageChain(chain=[], type="break")
if show_tool_use or astr_event.get_platform_name() == "webchat":
resp.data["chain"].type = "tool_call"
await astr_event.send(resp.data["chain"])
continue
if not agent_runner.streaming:
content_typ = (
ResultContentType.LLM_RESULT
if resp.type == "llm_result"
else ResultContentType.GENERAL_RESULT
)
astr_event.set_result(
MessageEventResult(
chain=resp.data["chain"].chain,
result_content_type=content_typ,
)
)
yield
astr_event.clear_result()
else:
if resp.type == "streaming_delta":
yield resp.data["chain"] # MessageChain
if agent_runner.done():
break
except Exception as e:
logger.error(traceback.format_exc())
astr_event.set_result(
MessageEventResult().message(
f"AstrBot 请求失败。\n错误类型: {type(e).__name__}\n错误信息: {str(e)}\n\n请在控制台查看和分享错误详情。\n"
)
)
return
asyncio.create_task(
Metric.upload(
llm_tick=1,
model_name=agent_runner.provider.get_model(),
provider_type=agent_runner.provider.meta().type,
)
)
class LLMRequestSubStage(Stage):
@@ -41,7 +299,9 @@ class LLMRequestSubStage(Stage):
self.max_context_length - 1,
)
self.streaming_response: bool = settings["streaming_response"]
self.max_step: int = settings.get("max_agent_step", 10)
self.max_step: int = settings.get("max_agent_step", 30)
if isinstance(self.max_step, bool): # workaround: #2622
self.max_step = 30
self.show_tool_use: bool = settings.get("show_tool_use_status", True)
for bwp in self.bot_wake_prefixs:
@@ -65,6 +325,20 @@ class LLMRequestSubStage(Stage):
return _ctx.get_using_provider(umo=event.unified_msg_origin)
async def _get_session_conv(self, event: AstrMessageEvent):
umo = event.unified_msg_origin
conv_mgr = self.conv_manager
# 获取对话上下文
cid = await conv_mgr.get_curr_conversation_id(umo)
if not cid:
cid = await conv_mgr.new_conversation(umo, event.get_platform_id())
conversation = await conv_mgr.get_conversation(umo, cid)
if not conversation:
cid = await conv_mgr.new_conversation(umo, event.get_platform_id())
conversation = await conv_mgr.get_conversation(umo, cid)
return conversation
async def process(
self, event: AstrMessageEvent, _nested: bool = False
) -> Union[None, AsyncGenerator[None, None]]:
@@ -100,30 +374,14 @@ class LLMRequestSubStage(Stage):
if not event.message_str.startswith(self.provider_wake_prefix):
return
req.prompt = event.message_str[len(self.provider_wake_prefix) :]
req.func_tool = self.ctx.plugin_manager.context.get_llm_tool_manager()
# func_tool selection 现在已经转移到 packages/astrbot 插件中进行选择。
# req.func_tool = self.ctx.plugin_manager.context.get_llm_tool_manager()
for comp in event.message_obj.message:
if isinstance(comp, Image):
image_path = await comp.convert_to_file_path()
req.image_urls.append(image_path)
# 获取对话上下文
conversation_id = await self.conv_manager.get_curr_conversation_id(
event.unified_msg_origin
)
if not conversation_id:
conversation_id = await self.conv_manager.new_conversation(
event.unified_msg_origin
)
conversation = await self.conv_manager.get_conversation(
event.unified_msg_origin, conversation_id
)
if not conversation:
conversation_id = await self.conv_manager.new_conversation(
event.unified_msg_origin
)
conversation = await self.conv_manager.get_conversation(
event.unified_msg_origin, conversation_id
)
conversation = await self._get_session_conv(event)
req.conversation = conversation
req.contexts = json.loads(conversation.history)
@@ -133,7 +391,7 @@ class LLMRequestSubStage(Stage):
return
# 执行请求 LLM 前事件钩子。
if await self.ctx.call_event_hook(event, EventType.OnLLMRequestEvent, req):
if await call_event_hook(event, EventType.OnLLMRequestEvent, req):
return
if isinstance(req.contexts, str):
@@ -167,92 +425,64 @@ class LLMRequestSubStage(Stage):
# fix messages
req.contexts = self.fix_messages(req.contexts)
# Call Agent
tool_loop_agent = ToolLoopAgent(
provider=provider,
event=event,
pipeline_ctx=self.ctx,
)
# check provider modalities
# 如果提供商不支持图像/工具使用,但请求中包含图像/工具列表,则清空。图片转述等的检测和调用发生在这之前,因此这里可以这样处理。
if req.image_urls:
provider_cfg = provider.provider_config.get("modalities", ["image"])
if "image" not in provider_cfg:
logger.debug(f"用户设置提供商 {provider} 不支持图像,清空图像列表。")
req.image_urls = []
if req.func_tool:
provider_cfg = provider.provider_config.get("modalities", ["tool_use"])
# 如果模型不支持工具使用,但请求中包含工具列表,则清空。
if "tool_use" not in provider_cfg:
logger.debug(
f"用户设置提供商 {provider} 不支持工具使用,清空工具列表。"
)
req.func_tool = None
# 插件可用性设置
if event.plugins_name is not None and req.func_tool:
new_tool_set = ToolSet()
for tool in req.func_tool.tools:
plugin = star_map.get(tool.handler_module_path)
if not plugin:
continue
if plugin.name in event.plugins_name or plugin.reserved:
new_tool_set.add_tool(tool)
req.func_tool = new_tool_set
# run agent
agent_runner = AgentRunner()
logger.debug(
f"handle provider[id: {provider.provider_config['id']}] request: {req}"
)
await tool_loop_agent.reset(req=req, streaming=self.streaming_response)
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 event.is_stopped():
return
if resp.type == "tool_call_result":
msg_chain = resp.data["chain"]
if msg_chain.type == "tool_direct_result":
# tool_direct_result 用于标记 llm tool 需要直接发送给用户的内容
resp.data["chain"].type = "tool_call_result"
await event.send(resp.data["chain"])
continue
# 对于其他情况,暂时先不处理
continue
elif 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 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
except Exception as e:
logger.error(traceback.format_exc())
event.set_result(
MessageEventResult().message(
f"AstrBot 请求失败。\n错误类型: {type(e).__name__}\n错误信息: {str(e)}\n\n请在控制台查看和分享错误详情。\n"
)
)
return
asyncio.create_task(
Metric.upload(
llm_tick=1,
model_name=provider.get_model(),
provider_type=provider.meta().type,
)
)
astr_agent_ctx = AstrAgentContext(
provider=provider,
first_provider_request=req,
curr_provider_request=req,
streaming=self.streaming_response,
)
await agent_runner.reset(
provider=provider,
request=req,
run_context=AgentContextWrapper(context=astr_agent_ctx, event=event),
tool_executor=FunctionToolExecutor(),
agent_hooks=MAIN_AGENT_HOOKS,
streaming=self.streaming_response,
)
if self.streaming_response:
# 流式响应
event.set_result(
MessageEventResult()
.set_result_content_type(ResultContentType.STREAMING_RESULT)
.set_async_stream(requesting())
.set_async_stream(
run_agent(agent_runner, self.max_step, self.show_tool_use)
)
)
yield
if tool_loop_agent.done():
if final_llm_resp := tool_loop_agent.get_final_llm_resp():
if agent_runner.done():
if final_llm_resp := agent_runner.get_final_llm_resp():
if final_llm_resp.completion_text:
chain = (
MessageChain().message(final_llm_resp.completion_text).chain
@@ -266,15 +496,15 @@ class LLMRequestSubStage(Stage):
)
)
else:
async for _ in requesting():
async for _ in run_agent(agent_runner, self.max_step, self.show_tool_use):
yield
await self._save_to_history(event, req, agent_runner.get_final_llm_resp())
# 异步处理 WebChat 特殊情况
if event.get_platform_name() == "webchat":
asyncio.create_task(self._handle_webchat(event, req, provider))
await self._save_to_history(event, req, tool_loop_agent.get_final_llm_resp())
async def _handle_webchat(
self, event: AstrMessageEvent, req: ProviderRequest, prov: Provider
):
@@ -307,19 +537,10 @@ class LLMRequestSubStage(Stage):
if not title or "<None>" in title:
return
await self.conv_manager.update_conversation_title(
event.unified_msg_origin, title=title
unified_msg_origin=event.unified_msg_origin,
title=title,
conversation_id=req.conversation.cid,
)
# 由于 WebChat 平台特殊性,其有两个对话,因此我们要更新两个对话的标题
# webchat adapter 中session_id 的格式是 f"webchat!{username}!{cid}"
# TODO: 优化 WebChat 适配器的对话管理
if event.session_id:
username, cid = event.session_id.split("!")[1:3]
db_helper = self.ctx.plugin_manager.context._db
db_helper.update_conversation_title(
user_id=username,
cid=cid,
title=title,
)
async def _save_to_history(
self,
@@ -335,6 +556,10 @@ class LLMRequestSubStage(Stage):
):
return
if not llm_response.completion_text and not req.tool_calls_result:
logger.debug("LLM 响应为空,不保存记录。")
return
# 历史上下文
messages = copy.deepcopy(req.contexts)
# 这一轮对话请求的用户输入

View File

@@ -2,7 +2,7 @@
本地 Agent 模式的 AstrBot 插件调用 Stage
"""
from ...context import PipelineContext
from ...context import PipelineContext, call_handler
from ..stage import Stage
from typing import Dict, Any, List, AsyncGenerator, Union
from astrbot.core.platform.astr_message_event import AstrMessageEvent
@@ -33,16 +33,6 @@ class StarRequestSubStage(Stage):
handlers_parsed_params = {}
for handler in activated_handlers:
# 检查处理器是否在当前平台兼容
if (
hasattr(handler, "platform_compatible")
and handler.platform_compatible is False
):
logger.debug(
f"处理器 {handler.handler_name} 在当前平台不兼容,跳过执行"
)
continue
params = handlers_parsed_params.get(handler.handler_full_name, {})
try:
if handler.handler_module_path not in star_map:
@@ -50,7 +40,7 @@ class StarRequestSubStage(Stage):
logger.debug(
f"plugin -> {star_map.get(handler.handler_module_path).name} - {handler.handler_name}"
)
wrapper = self.ctx.call_handler(event, handler.handler, **params)
wrapper = call_handler(event, handler.handler, **params)
async for ret in wrapper:
yield ret
event.clear_result() # 清除上一个 handler 的结果

View File

@@ -128,7 +128,7 @@ class RespondStage(Stage):
use_fallback = self.config.get("provider_settings", {}).get(
"streaming_segmented", False
)
logger.info(f"应用流式输出({event.get_platform_name()})")
logger.info(f"应用流式输出({event.get_platform_id()})")
await event.send_streaming(result.async_stream, use_fallback)
return
elif len(result.chain) > 0:
@@ -144,8 +144,6 @@ class RespondStage(Stage):
try:
if await self._is_empty_message_chain(result.chain):
logger.info("消息为空,跳过发送阶段")
event.clear_result()
event.stop_event()
return
except Exception as e:
logger.warning(f"空内容检查异常: {e}")
@@ -216,7 +214,7 @@ class RespondStage(Stage):
)
handlers = star_handlers_registry.get_handlers_by_event_type(
EventType.OnAfterMessageSentEvent, platform_id=event.get_platform_id()
EventType.OnAfterMessageSentEvent, plugins_name=event.plugins_name
)
for handler in handlers:
try:

View File

@@ -36,6 +36,7 @@ class ResultDecorateStage(Stage):
self.t2i_word_threshold = 150
self.t2i_strategy = ctx.astrbot_config["t2i_strategy"]
self.t2i_use_network = self.t2i_strategy == "remote"
self.t2i_active_template = ctx.astrbot_config["t2i_active_template"]
self.forward_threshold = ctx.astrbot_config["platform_settings"][
"forward_threshold"
@@ -64,9 +65,10 @@ class ResultDecorateStage(Stage):
]
self.content_safe_check_stage = None
if self.content_safe_check_reply:
for stage in registered_stages:
if stage.__class__.__name__ == "ContentSafetyCheckStage":
self.content_safe_check_stage = stage
for stage_cls in registered_stages:
if stage_cls.__name__ == "ContentSafetyCheckStage":
self.content_safe_check_stage = stage_cls()
await self.content_safe_check_stage.initialize(ctx)
async def process(
self, event: AstrMessageEvent
@@ -98,7 +100,7 @@ class ResultDecorateStage(Stage):
# 发送消息前事件钩子
handlers = star_handlers_registry.get_handlers_by_event_type(
EventType.OnDecoratingResultEvent, platform_id=event.get_platform_id()
EventType.OnDecoratingResultEvent, plugins_name=event.plugins_name
)
for handler in handlers:
try:
@@ -246,7 +248,10 @@ class ResultDecorateStage(Stage):
render_start = time.time()
try:
url = await html_renderer.render_t2i(
plain_str, return_url=True, use_network=self.t2i_use_network
plain_str,
return_url=True,
use_network=self.t2i_use_network,
template_name=self.t2i_active_template,
)
except BaseException:
logger.error("文本转图片失败,使用文本发送。")

View File

@@ -11,16 +11,17 @@ class PipelineScheduler:
def __init__(self, context: PipelineContext):
registered_stages.sort(
key=lambda x: STAGES_ORDER.index(x.__class__.__name__)
key=lambda x: STAGES_ORDER.index(x.__name__)
) # 按照顺序排序
self.ctx = context # 上下文对象
self.stages = [] # 存储阶段实例
async def initialize(self):
"""初始化管道调度器时, 初始化所有阶段"""
for stage in registered_stages:
# logger.debug(f"初始化阶段 {stage.__class__ .__name__}")
await stage.initialize(self.ctx)
for stage_cls in registered_stages:
stage_instance = stage_cls() # 创建实例
await stage_instance.initialize(self.ctx)
self.stages.append(stage_instance)
async def _process_stages(self, event: AstrMessageEvent, from_stage=0):
"""依次执行各个阶段
@@ -29,9 +30,9 @@ class PipelineScheduler:
event (AstrMessageEvent): 事件对象
from_stage (int): 从第几个阶段开始执行, 默认从0开始
"""
for i in range(from_stage, len(registered_stages)):
stage = registered_stages[i] # 获取当前要执行的阶段
# logger.debug(f"执行阶段 {stage.__class__ .__name__}")
for i in range(from_stage, len(self.stages)):
stage = self.stages[i] # 获取当前要执行的阶段
# logger.debug(f"执行阶段 {stage.__class__.__name__}")
coroutine = stage.process(
event
) # 调用阶段的process方法, 返回协程或者异步生成器

View File

@@ -1,15 +1,15 @@
from __future__ import annotations
import abc
from typing import List, AsyncGenerator, Union
from typing import List, AsyncGenerator, Union, Type
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from .context import PipelineContext
registered_stages: List[Stage] = [] # 维护了所有已注册的 Stage 实现类
registered_stages: List[Type[Stage]] = [] # 维护了所有已注册的 Stage 实现类类型
def register_stage(cls):
"""一个简单的装饰器,用于注册 pipeline 包下的 Stage 实现类"""
registered_stages.append(cls())
registered_stages.append(cls)
return cls

View File

@@ -112,8 +112,17 @@ class WakingCheckStage(Stage):
activated_handlers = []
handlers_parsed_params = {} # 注册了指令的 handler
# 将 plugins_name 设置到 event 中
enabled_plugins_name = self.ctx.astrbot_config.get("plugin_set", ["*"])
if enabled_plugins_name == ["*"]:
# 如果是 *,则表示所有插件都启用
event.plugins_name = None
else:
event.plugins_name = enabled_plugins_name
logger.debug(f"enabled_plugins_name: {enabled_plugins_name}")
for handler in star_handlers_registry.get_handlers_by_event_type(
EventType.AdapterMessageEvent
EventType.AdapterMessageEvent, plugins_name=event.plugins_name
):
# filter 需满足 AND 逻辑关系
passed = True

View File

@@ -3,9 +3,10 @@ import asyncio
import re
import hashlib
import uuid
from dataclasses import dataclass
from typing import List, Union, Optional, AsyncGenerator
from astrbot import logger
from astrbot.core.db.po import Conversation
from astrbot.core.message.components import (
Plain,
@@ -23,21 +24,7 @@ from astrbot.core.provider.entities import ProviderRequest
from astrbot.core.utils.metrics import Metric
from .astrbot_message import AstrBotMessage, Group
from .platform_metadata import PlatformMetadata
@dataclass
class MessageSesion:
platform_name: str
message_type: MessageType
session_id: str
def __str__(self):
return f"{self.platform_name}:{self.message_type.value}:{self.session_id}"
@staticmethod
def from_str(session_str: str):
platform_name, message_type, session_id = session_str.split(":")
return MessageSesion(platform_name, MessageType(message_type), session_id)
from .message_session import MessageSession, MessageSesion # noqa
class AstrMessageEvent(abc.ABC):
@@ -64,7 +51,7 @@ class AstrMessageEvent(abc.ABC):
"""是否是 At 机器人或者带有唤醒词或者是私聊(插件注册的事件监听器会让 is_wake 设为 True, 但是不会让这个属性置为 True)"""
self._extras = {}
self.session = MessageSesion(
platform_name=platform_meta.name,
platform_name=platform_meta.id,
message_type=message_obj.type,
session_id=session_id,
)
@@ -78,13 +65,23 @@ class AstrMessageEvent(abc.ABC):
self.call_llm = False
"""是否在此消息事件中禁止默认的 LLM 请求"""
self.plugins_name: list[str] | None = None
"""该事件启用的插件名称列表。None 表示所有插件都启用。空列表表示没有启用任何插件。"""
# back_compability
self.platform = platform_meta
def get_platform_name(self):
"""获取这个事件所属的平台的类型(如 aiocqhttp, slack, discord 等)。
NOTE: 用户可能会同时运行多个相同类型的平台适配器。"""
return self.platform_meta.name
def get_platform_id(self):
"""获取这个事件所属的平台的 ID。
NOTE: 用户可能会同时运行多个相同类型的平台适配器,但能确定的是 ID 是唯一的。
"""
return self.platform_meta.id
def get_message_str(self) -> str:
@@ -188,6 +185,7 @@ class AstrMessageEvent(abc.ABC):
"""
清除额外的信息。
"""
logger.info(f"清除 {self.get_platform_name()} 的额外信息: {self._extras}")
self._extras.clear()
def is_private_chat(self) -> bool:

View File

@@ -18,6 +18,9 @@ class PlatformManager:
self.platforms_config = config["platform"]
self.settings = config["platform_settings"]
"""NOTE: 这里是 default 的配置文件,以保证最大的兼容性;
这个配置中的 unique_session 需要特殊处理,
约定整个项目中对 unique_session 的引用都从 default 的配置中获取"""
self.event_queue = event_queue
async def initialize(self):
@@ -82,6 +85,8 @@ class PlatformManager:
)
case "slack":
from .sources.slack.slack_adapter import SlackAdapter # noqa: F401
case "satori":
from .sources.satori.satori_adapter import SatoriPlatformAdapter # noqa: F401
except (ImportError, ModuleNotFoundError) as e:
logger.error(
f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->控制台->安装Pip库 中安装依赖库。"

View File

@@ -0,0 +1,28 @@
from astrbot.core.platform.message_type import MessageType
from dataclasses import dataclass
@dataclass
class MessageSession:
"""描述一条消息在 AstrBot 中对应的会话的唯一标识。
如果您需要实例化 MessageSession请不要给 platform_id 赋值(或者同时给 platform_name 和 platform_id 赋值相同值)。它会在 __post_init__ 中自动设置为 platform_name 的值。"""
platform_name: str
"""平台适配器实例的唯一标识符。自 AstrBot v4.0.0 起,该字段实际为 platform_id。"""
message_type: MessageType
session_id: str
platform_id: str = None
def __str__(self):
return f"{self.platform_id}:{self.message_type.value}:{self.session_id}"
def __post_init__(self):
self.platform_id = self.platform_name
@staticmethod
def from_str(session_str: str):
platform_id, message_type, session_id = session_str.split(":")
return MessageSession(platform_id, MessageType(message_type), session_id)
MessageSesion = MessageSession # back compatibility

View File

@@ -5,7 +5,7 @@ from asyncio import Queue
from .platform_metadata import PlatformMetadata
from .astr_message_event import AstrMessageEvent
from astrbot.core.message.message_event_result import MessageChain
from .astr_message_event import MessageSesion
from .message_session import MessageSesion
from astrbot.core.utils.metrics import Metric

View File

@@ -4,7 +4,7 @@ from dataclasses import dataclass
@dataclass
class PlatformMetadata:
name: str
"""平台的名称"""
"""平台的名称,即平台的类型,如 aiocqhttp, discord, slack"""
description: str
"""平台的描述"""
id: str = None

View File

@@ -67,12 +67,19 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
session_id: str,
messages: list[dict],
):
if event:
await bot.send(event=event, message=messages)
elif is_group:
# session_id 必须是纯数字字符串
session_id = int(session_id) if session_id.isdigit() else None
if is_group and isinstance(session_id, int):
await bot.send_group_msg(group_id=session_id, message=messages)
else:
elif not is_group and isinstance(session_id, int):
await bot.send_private_msg(user_id=session_id, message=messages)
elif isinstance(event, Event): # 最后兜底
await bot.send(event=event, message=messages)
else:
raise ValueError(
f"无法发送消息:缺少有效的数字 session_id({session_id}) 或 event({event})"
)
@classmethod
async def send_message(
@@ -83,7 +90,15 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
is_group: bool = False,
session_id: str = None,
):
"""发送消息"""
"""发送消息至 QQ 协议端aiocqhttp
Args:
bot (CQHttp): aiocqhttp 机器人实例
message_chain (MessageChain): 要发送的消息链
event (Event | None, optional): aiocqhttp 事件对象.
is_group (bool, optional): 是否为群消息.
session_id (str | None, optional): 会话 ID群号或 QQ 号
"""
# 转发消息、文件消息不能和普通消息混在一起发送
send_one_by_one = any(
@@ -122,18 +137,15 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
async def send(self, message: MessageChain):
"""发送消息"""
event = self.message_obj.raw_message
assert isinstance(event, Event), "Event must be an instance of aiocqhttp.Event"
is_group = False
if self.get_group_id():
is_group = True
session_id = self.get_group_id()
else:
session_id = self.get_sender_id()
event = getattr(self.message_obj, "raw_message", None)
is_group = bool(self.get_group_id())
session_id = self.get_group_id() if is_group else self.get_sender_id()
await self.send_message(
bot=self.bot,
message_chain=message,
event=event,
event=event, # 不强制要求一定是 Event
is_group=is_group,
session_id=session_id,
)

View File

@@ -308,13 +308,20 @@ class AiocqhttpAdapter(Platform):
continue
at_info = await self.bot.call_action(
action="get_stranger_info",
action="get_group_member_info",
group_id=event.group_id,
user_id=int(m["data"]["qq"]),
no_cache=False,
)
if at_info:
nickname = at_info.get("nick", "") or at_info.get(
"nickname", ""
)
nickname = at_info.get("card", "")
if nickname == "":
at_info = await self.bot.call_action(
action="get_stranger_info",
user_id=int(m["data"]["qq"]),
no_cache=False,
)
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(

View File

@@ -26,7 +26,7 @@ class DingtalkMessageEvent(AstrMessageEvent):
await asyncio.get_event_loop().run_in_executor(
None,
client.reply_markdown,
"AstrBot",
segment.text,
segment.text,
self.message_obj.raw_message,
)

View File

@@ -3,15 +3,23 @@ import botpy.message
import botpy.types
import botpy.types.message
import asyncio
import base64
import aiofiles
from astrbot.core.utils.io import file_to_base64, download_image_by_url
from astrbot.core.utils.tencent_record_helper import wav_to_tencent_silk
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
from astrbot.api.message_components import Plain, Image
from astrbot.api.message_components import Plain, Image, Record
from botpy import Client
from botpy.http import Route
from astrbot.api import logger
from botpy.types.message import Media
from botpy.types import message
from typing import Optional
import random
import uuid
import os
class QQOfficialMessageEvent(AstrMessageEvent):
@@ -36,6 +44,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
stream_payload = {"state": 1, "id": None, "index": 0, "reset": False}
last_edit_time = 0 # 上次编辑消息的时间
throttle_interval = 1 # 编辑消息的间隔时间 (秒)
ret = None
try:
async for chain in generator:
source = self.message_obj.raw_message
@@ -85,9 +94,10 @@ class QQOfficialMessageEvent(AstrMessageEvent):
plain_text,
image_base64,
image_path,
record_file_path
) = await QQOfficialMessageEvent._parse_to_qqofficial(self.send_buffer)
if not plain_text and not image_base64 and not image_path:
if not plain_text and not image_base64 and not image_path and not record_file_path:
return
payload = {
@@ -98,6 +108,8 @@ class QQOfficialMessageEvent(AstrMessageEvent):
if not isinstance(source, (botpy.message.Message, botpy.message.DirectMessage)):
payload["msg_seq"] = random.randint(1, 10000)
ret = None
match type(source):
case botpy.message.GroupMessage:
if image_base64:
@@ -106,6 +118,12 @@ class QQOfficialMessageEvent(AstrMessageEvent):
)
payload["media"] = media
payload["msg_type"] = 7
if record_file_path: # group record msg
media = await self.upload_group_and_c2c_record(
record_file_path, 3, group_openid=source.group_openid
)
payload["media"] = media
payload["msg_type"] = 7
ret = await self.bot.api.post_group_message(
group_openid=source.group_openid, **payload
)
@@ -116,6 +134,12 @@ class QQOfficialMessageEvent(AstrMessageEvent):
)
payload["media"] = media
payload["msg_type"] = 7
if record_file_path: # c2c record
media = await self.upload_group_and_c2c_record(
record_file_path, 3, openid = source.author.user_openid
)
payload["media"] = media
payload["msg_type"] = 7
if stream:
ret = await self.post_c2c_message(
openid=source.author.user_openid,
@@ -165,6 +189,59 @@ class QQOfficialMessageEvent(AstrMessageEvent):
)
return await self.bot.api._http.request(route, json=payload)
async def upload_group_and_c2c_record(
self,
file_source: str,
file_type: int,
srv_send_msg: bool = False,
**kwargs
) -> Optional[Media]:
"""
上传媒体文件
"""
# 构建基础payload
payload = {
"file_type": file_type,
"srv_send_msg": srv_send_msg
}
# 处理文件数据
if os.path.exists(file_source):
# 读取本地文件
async with aiofiles.open(file_source, 'rb') as f:
file_content = await f.read()
# use base64 encode
payload["file_data"] = base64.b64encode(file_content).decode('utf-8')
else:
# 使用URL
payload["url"] = file_source
# 添加接收者信息和确定路由
if "openid" in kwargs:
payload["openid"] = kwargs["openid"]
route = Route("POST", "/v2/users/{openid}/files", openid=kwargs["openid"])
elif "group_openid" in kwargs:
payload["group_openid"] =kwargs["group_openid"]
route = Route("POST", "/v2/groups/{group_openid}/files", group_openid=kwargs["group_openid"])
else:
return None
try:
# 使用底层HTTP请求
result = await self.bot.api._http.request(route, json=payload)
if result:
return Media(
file_uuid=result.get("file_uuid"),
file_info=result.get("file_info"),
ttl=result.get("ttl", 0),
file_id=result.get("id", "")
)
except Exception as e:
logger.error(f"上传请求错误: {e}")
return None
async def post_c2c_message(
self,
openid: str,
@@ -191,6 +268,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
plain_text = ""
image_base64 = None # only one img supported
image_file_path = None
record_file_path = None
for i in message.chain:
if isinstance(i, Plain):
plain_text += i.text
@@ -206,6 +284,21 @@ class QQOfficialMessageEvent(AstrMessageEvent):
else:
image_base64 = file_to_base64(i.file)
image_base64 = image_base64.removeprefix("base64://")
elif isinstance(i, Record):
if i.file:
record_wav_path = await i.convert_to_file_path() # wav 路径
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
record_tecent_silk_path = os.path.join(temp_dir, f"{uuid.uuid4()}.silk")
try:
duration = await wav_to_tencent_silk(record_wav_path, record_tecent_silk_path)
if duration > 0:
record_file_path = record_tecent_silk_path
else:
record_file_path = None
logger.error("转换音频格式时出错音频时长不大于0")
except Exception as e:
logger.error(f"处理语音时出错: {e}")
record_file_path = None
else:
logger.debug(f"qq_official 忽略 {i.type}")
return plain_text, image_base64, image_file_path
return plain_text, image_base64, image_file_path, record_file_path

View File

@@ -0,0 +1,482 @@
import asyncio
import json
import time
import websockets
from websockets.asyncio.client import connect
from typing import Optional
from aiohttp import ClientSession, ClientTimeout
from websockets.asyncio.client import ClientConnection
from astrbot.api import logger
from astrbot.api.event import MessageChain
from astrbot.api.platform import (
AstrBotMessage,
MessageMember,
MessageType,
Platform,
PlatformMetadata,
register_platform_adapter,
)
from astrbot.core.platform.astr_message_event import MessageSession
from astrbot.api.message_components import Plain, Image, At, File, Record
from xml.etree import ElementTree as ET
@register_platform_adapter(
"satori",
"Satori 协议适配器",
)
class SatoriPlatformAdapter(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.api_base_url = self.config.get(
"satori_api_base_url", "http://localhost:5140/satori/v1"
)
self.token = self.config.get("satori_token", "")
self.endpoint = self.config.get(
"satori_endpoint", "ws://127.0.0.1:5140/satori/v1/events"
)
self.auto_reconnect = self.config.get("satori_auto_reconnect", True)
self.heartbeat_interval = self.config.get("satori_heartbeat_interval", 10)
self.reconnect_delay = self.config.get("satori_reconnect_delay", 5)
self.ws: Optional[ClientConnection] = None
self.session: Optional[ClientSession] = None
self.sequence = 0
self.logins = []
self.running = False
self.heartbeat_task: Optional[asyncio.Task] = None
self.ready_received = False
async def send_by_session(
self, session: MessageSession, message_chain: MessageChain
):
from .satori_event import SatoriPlatformEvent
await SatoriPlatformEvent.send_with_adapter(
self, message_chain, session.session_id
)
await super().send_by_session(session, message_chain)
def meta(self) -> PlatformMetadata:
return PlatformMetadata(name="satori", description="Satori 通用协议适配器")
def _is_websocket_closed(self, ws) -> bool:
"""检查WebSocket连接是否已关闭"""
if not ws:
return True
try:
if hasattr(ws, "closed"):
return ws.closed
elif hasattr(ws, "close_code"):
return ws.close_code is not None
else:
return False
except AttributeError:
return False
async def run(self):
self.running = True
self.session = ClientSession(timeout=ClientTimeout(total=30))
retry_count = 0
max_retries = 10
while self.running:
try:
await self.connect_websocket()
retry_count = 0
except websockets.exceptions.ConnectionClosed as e:
logger.warning(f"Satori WebSocket 连接关闭: {e}")
retry_count += 1
except Exception as e:
logger.error(f"Satori WebSocket 连接失败: {e}")
retry_count += 1
if not self.running:
break
if retry_count >= max_retries:
logger.error(f"达到最大重试次数 ({max_retries}),停止重试")
break
if not self.auto_reconnect:
break
delay = min(self.reconnect_delay * (2 ** (retry_count - 1)), 60)
await asyncio.sleep(delay)
if self.session:
await self.session.close()
async def connect_websocket(self):
logger.info(f"Satori 适配器正在连接到 WebSocket: {self.endpoint}")
logger.info(f"Satori 适配器 HTTP API 地址: {self.api_base_url}")
if not self.endpoint.startswith(("ws://", "wss://")):
logger.error(f"无效的WebSocket URL: {self.endpoint}")
raise ValueError(f"WebSocket URL必须以ws://或wss://开头: {self.endpoint}")
try:
websocket = await connect(self.endpoint, additional_headers={})
self.ws = websocket
await asyncio.sleep(0.1)
await self.send_identify()
self.heartbeat_task = asyncio.create_task(self.heartbeat_loop())
async for message in websocket:
try:
await self.handle_message(message) # type: ignore
except Exception as e:
logger.error(f"Satori 处理消息异常: {e}")
except websockets.exceptions.ConnectionClosed as e:
logger.warning(f"Satori WebSocket 连接关闭: {e}")
raise
except Exception as e:
logger.error(f"Satori WebSocket 连接异常: {e}")
raise
finally:
if self.heartbeat_task:
self.heartbeat_task.cancel()
try:
await self.heartbeat_task
except asyncio.CancelledError:
pass
if self.ws:
try:
await self.ws.close()
except Exception as e:
logger.error(f"Satori WebSocket 关闭异常: {e}")
async def send_identify(self):
if not self.ws:
raise Exception("WebSocket连接未建立")
if self._is_websocket_closed(self.ws):
raise Exception("WebSocket连接已关闭")
identify_payload = {
"op": 3, # IDENTIFY
"body": {
"token": str(self.token) if self.token else "", # 字符串
},
}
# 只有在有序列号时才添加sn字段
if self.sequence > 0:
identify_payload["body"]["sn"] = self.sequence
try:
message_str = json.dumps(identify_payload, ensure_ascii=False)
await self.ws.send(message_str)
except websockets.exceptions.ConnectionClosed as e:
logger.error(f"发送 IDENTIFY 信令时连接关闭: {e}")
raise
except Exception as e:
logger.error(f"发送 IDENTIFY 信令失败: {e}")
raise
async def heartbeat_loop(self):
try:
while self.running and self.ws:
await asyncio.sleep(self.heartbeat_interval)
if self.ws and not self._is_websocket_closed(self.ws):
try:
ping_payload = {
"op": 1, # PING
"body": {},
}
await self.ws.send(json.dumps(ping_payload, ensure_ascii=False))
except websockets.exceptions.ConnectionClosed as e:
logger.error(f"Satori WebSocket 连接关闭: {e}")
break
except Exception as e:
logger.error(f"Satori WebSocket 发送心跳失败: {e}")
break
else:
break
except asyncio.CancelledError:
pass
except Exception as e:
logger.error(f"心跳任务异常: {e}")
async def handle_message(self, message: str):
try:
data = json.loads(message)
op = data.get("op")
body = data.get("body", {})
if op == 4: # READY
self.logins = body.get("logins", [])
self.ready_received = True
# 输出连接成功的bot信息
if self.logins:
for i, login in enumerate(self.logins):
platform = login.get("platform", "")
user = login.get("user", {})
user_id = user.get("id", "")
user_name = user.get("name", "")
logger.info(
f"Satori 连接成功 - Bot {i + 1}: platform={platform}, user_id={user_id}, user_name={user_name}"
)
if "sn" in body:
self.sequence = body["sn"]
elif op == 2: # PONG
pass
elif op == 0: # EVENT
await self.handle_event(body)
if "sn" in body:
self.sequence = body["sn"]
elif op == 5: # META
if "sn" in body:
self.sequence = body["sn"]
except json.JSONDecodeError as e:
logger.error(f"解析 WebSocket 消息失败: {e}, 消息内容: {message}")
except Exception as e:
logger.error(f"处理 WebSocket 消息异常: {e}")
async def handle_event(self, event_data: dict):
try:
event_type = event_data.get("type")
sn = event_data.get("sn")
if sn:
self.sequence = sn
if event_type == "message-created":
message = event_data.get("message", {})
user = event_data.get("user", {})
channel = event_data.get("channel", {})
guild = event_data.get("guild")
login = event_data.get("login", {})
timestamp = event_data.get("timestamp")
if user.get("id") == login.get("user", {}).get("id"):
return
abm = await self.convert_satori_message(
message, user, channel, guild, login, timestamp
)
if abm:
await self.handle_msg(abm)
except Exception as e:
logger.error(f"处理事件失败: {e}")
async def convert_satori_message(
self,
message: dict,
user: dict,
channel: dict,
guild: Optional[dict],
login: dict,
timestamp: Optional[int] = None,
) -> Optional[AstrBotMessage]:
try:
abm = AstrBotMessage()
abm.message_id = message.get("id", "")
abm.raw_message = {
"message": message,
"user": user,
"channel": channel,
"guild": guild,
"login": login,
}
if guild and guild.get("id"):
abm.type = MessageType.GROUP_MESSAGE
abm.group_id = guild.get("id", "")
abm.session_id = channel.get("id", "")
else:
abm.type = MessageType.FRIEND_MESSAGE
abm.session_id = channel.get("id", "")
abm.sender = MessageMember(
user_id=user.get("id", ""),
nickname=user.get("nick", user.get("name", "")),
)
abm.self_id = login.get("user", {}).get("id", "")
content = message.get("content", "")
abm.message = await self.parse_satori_elements(content)
# parse message_str
abm.message_str = ""
for comp in abm.message:
if isinstance(comp, Plain):
abm.message_str += comp.text
# 优先使用Satori事件中的时间戳
if timestamp is not None:
abm.timestamp = timestamp
else:
abm.timestamp = int(time.time())
return abm
except Exception as e:
logger.error(f"转换 Satori 消息失败: {e}")
return None
async def parse_satori_elements(self, content: str) -> list:
"""解析 Satori 消息元素"""
elements = []
if not content:
return elements
try:
wrapped_content = f"<root>{content}</root>"
root = ET.fromstring(wrapped_content)
await self._parse_xml_node(root, elements)
except ET.ParseError as e:
raise ValueError(f"解析 Satori 元素时发生解析错误: {e}")
except Exception as e:
raise e
# 如果没有解析到任何元素,将整个内容当作纯文本
if not elements and content.strip():
elements.append(Plain(text=content))
return elements
async def _parse_xml_node(self, node: ET.Element, elements: list) -> None:
"""递归解析 XML 节点"""
if node.text and node.text.strip():
elements.append(Plain(text=node.text))
for child in node:
tag_name = child.tag.lower()
attrs = child.attrib
if tag_name == "at":
user_id = attrs.get("id") or attrs.get("name", "")
elements.append(At(qq=user_id, name=user_id))
elif tag_name in ("img", "image"):
src = attrs.get("src", "")
if not src:
continue
if src.startswith("data:image/"):
src = src.split(",")[1]
elements.append(Image.fromBase64(src))
elif src.startswith("http"):
elements.append(Image.fromURL(src))
else:
logger.error(f"未知的图片 src 格式: {str(src)[:16]}")
elif tag_name == "file":
src = attrs.get("src", "")
name = attrs.get("name", "文件")
if src:
elements.append(File(file=src, name=name))
elif tag_name in ("audio", "record"):
src = attrs.get("src", "")
if not src:
continue
if src.startswith("data:audio/"):
src = src.split(",")[1]
elements.append(Record.fromBase64(src))
elif src.startswith("http"):
elements.append(Record.fromURL(src))
else:
logger.error(f"未知的音频 src 格式: {str(src)[:16]}")
else:
# 未知标签,递归处理其内容
if child.text and child.text.strip():
elements.append(Plain(text=child.text))
await self._parse_xml_node(child, elements)
# 处理标签后的文本
if child.tail and child.tail.strip():
elements.append(Plain(text=child.tail))
async def handle_msg(self, message: AstrBotMessage):
from .satori_event import SatoriPlatformEvent
message_event = SatoriPlatformEvent(
message_str=message.message_str,
message_obj=message,
platform_meta=self.meta(),
session_id=message.session_id,
adapter=self,
)
self.commit_event(message_event)
async def send_http_request(
self,
method: str,
path: str,
data: dict | None = None,
platform: str | None = None,
user_id: str | None = None,
) -> dict:
if not self.session:
raise Exception("HTTP session 未初始化")
headers = {
"Content-Type": "application/json",
}
if self.token:
headers["Authorization"] = f"Bearer {self.token}"
if platform and user_id:
headers["satori-platform"] = platform
headers["satori-user-id"] = user_id
elif self.logins:
current_login = self.logins[0]
headers["satori-platform"] = current_login.get("platform", "")
user = current_login.get("user", {})
headers["satori-user-id"] = user.get("id", "") if user else ""
if not path.startswith("/"):
path = "/" + path
# 使用新的API地址配置
url = f"{self.api_base_url.rstrip('/')}{path}"
try:
async with self.session.request(
method, url, json=data, headers=headers
) as response:
if response.status == 200:
result = await response.json()
return result
else:
return {}
except Exception as e:
logger.error(f"Satori HTTP 请求异常: {e}")
return {}
async def terminate(self):
self.running = False
if self.heartbeat_task:
self.heartbeat_task.cancel()
if self.ws:
try:
await self.ws.close()
except Exception as e:
logger.error(f"Satori WebSocket 关闭异常: {e}")
if self.session:
await self.session.close()

View File

@@ -0,0 +1,221 @@
from typing import TYPE_CHECKING
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
from astrbot.api.message_components import Plain, Image, At, File, Record
if TYPE_CHECKING:
from .satori_adapter import SatoriPlatformAdapter
class SatoriPlatformEvent(AstrMessageEvent):
def __init__(
self,
message_str: str,
message_obj: AstrBotMessage,
platform_meta: PlatformMetadata,
session_id: str,
adapter: "SatoriPlatformAdapter",
):
super().__init__(message_str, message_obj, platform_meta, session_id)
self.adapter = adapter
self.platform = None
self.user_id = None
if (
hasattr(message_obj, "raw_message")
and message_obj.raw_message
and isinstance(message_obj.raw_message, dict)
):
login = message_obj.raw_message.get("login", {})
self.platform = login.get("platform")
user = login.get("user", {})
self.user_id = user.get("id") if user else None
@classmethod
async def send_with_adapter(
cls, adapter: "SatoriPlatformAdapter", message: MessageChain, session_id: str
):
try:
content_parts = []
for component in message.chain:
if isinstance(component, Plain):
text = (
component.text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
content_parts.append(text)
elif isinstance(component, At):
if component.qq:
content_parts.append(f'<at id="{component.qq}"/>')
elif component.name:
content_parts.append(f'<at name="{component.name}"/>')
elif isinstance(component, Image):
try:
image_base64 = await component.convert_to_base64()
if image_base64:
content_parts.append(
f'<img src="data:image/jpeg;base64,{image_base64}"/>'
)
except Exception as e:
logger.error(f"图片转换为base64失败: {e}")
elif isinstance(component, File):
content_parts.append(
f'<file src="{component.file}" name="{component.name or "文件"}"/>'
)
elif isinstance(component, Record):
try:
record_base64 = await component.convert_to_base64()
if record_base64:
content_parts.append(
f'<audio src="data:audio/wav;base64,{record_base64}"/>'
)
except Exception as e:
logger.error(f"语音转换为base64失败: {e}")
content = "".join(content_parts)
channel_id = session_id
data = {"channel_id": channel_id, "content": content}
platform = None
user_id = None
if hasattr(adapter, "logins") and adapter.logins:
current_login = adapter.logins[0]
platform = current_login.get("platform", "")
user = current_login.get("user", {})
user_id = user.get("id", "") if user else ""
result = await adapter.send_http_request(
"POST", "/message.create", data, platform, user_id
)
if result:
return result
else:
return None
except Exception as e:
logger.error(f"Satori 消息发送异常: {e}")
return None
async def send(self, message: MessageChain):
platform = getattr(self, "platform", None)
user_id = getattr(self, "user_id", None)
if not platform or not user_id:
if hasattr(self.adapter, "logins") and self.adapter.logins:
current_login = self.adapter.logins[0]
platform = current_login.get("platform", "")
user = current_login.get("user", {})
user_id = user.get("id", "") if user else ""
try:
content_parts = []
for component in message.chain:
if isinstance(component, Plain):
text = (
component.text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
content_parts.append(text)
elif isinstance(component, At):
if component.qq:
content_parts.append(f'<at id="{component.qq}"/>')
elif component.name:
content_parts.append(f'<at name="{component.name}"/>')
elif isinstance(component, Image):
try:
image_base64 = await component.convert_to_base64()
if image_base64:
content_parts.append(
f'<img src="data:image/jpeg;base64,{image_base64}"/>'
)
except Exception as e:
logger.error(f"图片转换为base64失败: {e}")
elif isinstance(component, File):
content_parts.append(
f'<file src="{component.file}" name="{component.name or "文件"}"/>'
)
elif isinstance(component, Record):
try:
record_base64 = await component.convert_to_base64()
if record_base64:
content_parts.append(
f'<audio src="data:audio/wav;base64,{record_base64}"/>'
)
except Exception as e:
logger.error(f"语音转换为base64失败: {e}")
content = "".join(content_parts)
channel_id = self.session_id
data = {"channel_id": channel_id, "content": content}
result = await self.adapter.send_http_request(
"POST", "/message.create", data, platform, user_id
)
if not result:
logger.error("Satori 消息发送失败")
except Exception as e:
logger.error(f"Satori 消息发送异常: {e}")
await super().send(message)
async def send_streaming(self, generator, use_fallback: bool = False):
try:
content_parts = []
async for chain in generator:
if isinstance(chain, MessageChain):
if chain.type == "break":
if content_parts:
content = "".join(content_parts)
temp_chain = MessageChain([Plain(text=content)])
await self.send(temp_chain)
content_parts = []
continue
for component in chain.chain:
if isinstance(component, Plain):
content_parts.append(component.text)
elif isinstance(component, Image):
if content_parts:
content = "".join(content_parts)
temp_chain = MessageChain([Plain(text=content)])
await self.send(temp_chain)
content_parts = []
try:
image_base64 = await component.convert_to_base64()
if image_base64:
img_chain = MessageChain(
[
Plain(
text=f'<img src="data:image/jpeg;base64,{image_base64}"/>'
)
]
)
await self.send(img_chain)
except Exception as e:
logger.error(f"图片转换为base64失败: {e}")
else:
content_parts.append(str(component))
if content_parts:
content = "".join(content_parts)
temp_chain = MessageChain([Plain(text=content)])
await self.send(temp_chain)
except Exception as e:
logger.error(f"Satori 流式消息发送异常: {e}")
return await super().send_streaming(generator, use_fallback)

View File

@@ -183,7 +183,6 @@ class TelegramPlatformAdapter(Platform):
return None
if not re.match(r"^[a-z0-9_]+$", cmd_name) or len(cmd_name) > 32:
logger.debug(f"跳过无法注册的命令: {cmd_name}")
return None
# Build description.

View File

@@ -77,7 +77,7 @@ class WebChatAdapter(Platform):
os.makedirs(self.imgs_dir, exist_ok=True)
self.metadata = PlatformMetadata(
name="webchat", description="webchat", id=self.config.get("id", "")
name="webchat", description="webchat", id="webchat"
)
async def send_by_session(

View File

@@ -0,0 +1,47 @@
from astrbot.core.db import BaseDatabase
from astrbot.core.db.po import PlatformMessageHistory
class PlatformMessageHistoryManager:
def __init__(self, db_helper: BaseDatabase):
self.db = db_helper
async def insert(
self,
platform_id: str,
user_id: str,
content: list[dict], # TODO: parse from message chain
sender_id: str = None,
sender_name: str = None,
):
"""Insert a new platform message history record."""
await self.db.insert_platform_message_history(
platform_id=platform_id,
user_id=user_id,
content=content,
sender_id=sender_id,
sender_name=sender_name,
)
async def get(
self,
platform_id: str,
user_id: str,
page: int = 1,
page_size: int = 200,
) -> list[PlatformMessageHistory]:
"""Get platform message history for a specific user."""
history = await self.db.get_platform_message_history(
platform_id=platform_id,
user_id=user_id,
page=page,
page_size=page_size,
)
history.reverse()
return history
async def delete(self, platform_id: str, user_id: str, offset_sec: int = 86400):
"""Delete platform message history records older than the specified offset."""
await self.db.delete_platform_message_offset(
platform_id=platform_id, user_id=user_id, offset_sec=offset_sec
)

View File

@@ -4,9 +4,11 @@ import json
from astrbot.core.utils.io import download_image_by_url
from astrbot import logger
from dataclasses import dataclass, field
from typing import List, Dict, Type
from .func_tool_manager import FuncCall
from typing import List, Dict, Type, Any
from astrbot.core.agent.tool import ToolSet
from openai.types.chat.chat_completion import ChatCompletion
from google.genai.types import GenerateContentResponse
from anthropic.types import Message
from openai.types.chat.chat_completion_message_tool_call import (
ChatCompletionMessageToolCall,
)
@@ -20,6 +22,7 @@ class ProviderType(enum.Enum):
SPEECH_TO_TEXT = "speech_to_text"
TEXT_TO_SPEECH = "text_to_speech"
EMBEDDING = "embedding"
RERANK = "rerank"
@dataclass
@@ -29,11 +32,11 @@ class ProviderMetaData:
desc: str = ""
"""提供商适配器描述."""
provider_type: ProviderType = ProviderType.CHAT_COMPLETION
cls_type: Type = None
cls_type: Type | None = None
default_config_tmpl: dict = None
default_config_tmpl: dict | None = None
"""平台的默认配置模板"""
provider_display_name: str = None
provider_display_name: str | None = None
"""显示在 WebUI 配置页中的提供商名称,如空则是 type"""
@@ -57,7 +60,7 @@ class ToolCallMessageSegment:
class AssistantMessageSegment:
"""OpenAI 格式的上下文中 role 为 assistant 的消息段。参考: https://platform.openai.com/docs/guides/function-calling"""
content: str = None
content: str | None = None
tool_calls: List[ChatCompletionMessageToolCall | Dict] = field(default_factory=list)
role: str = "assistant"
@@ -97,7 +100,7 @@ class ProviderRequest:
"""会话 ID"""
image_urls: list[str] = field(default_factory=list)
"""图片 URL 列表"""
func_tool: FuncCall | None = None
func_tool: ToolSet | None = None
"""可用的函数工具"""
contexts: list[dict] = field(default_factory=list)
"""上下文。格式与 openai 的上下文格式一致:
@@ -204,17 +207,17 @@ class ProviderRequest:
class LLMResponse:
role: str
"""角色, assistant, tool, err"""
result_chain: MessageChain = None
result_chain: MessageChain | None = None
"""返回的消息链"""
tools_call_args: List[Dict[str, any]] = field(default_factory=list)
tools_call_args: List[Dict[str, Any]] = field(default_factory=list)
"""工具调用参数"""
tools_call_name: List[str] = field(default_factory=list)
"""工具调用名称"""
tools_call_ids: List[str] = field(default_factory=list)
"""工具调用 ID"""
raw_completion: ChatCompletion = None
_new_record: Dict[str, any] = None
raw_completion: ChatCompletion | GenerateContentResponse | Message | None = None
_new_record: Dict[str, Any] | None = None
_completion_text: str = ""
@@ -225,12 +228,12 @@ class LLMResponse:
self,
role: str,
completion_text: str = "",
result_chain: MessageChain = None,
tools_call_args: List[Dict[str, any]] = None,
tools_call_name: List[str] = None,
tools_call_ids: List[str] = None,
raw_completion: ChatCompletion = None,
_new_record: Dict[str, any] = None,
result_chain: MessageChain | None = None,
tools_call_args: List[Dict[str, Any]] | None = None,
tools_call_name: List[str] | None = None,
tools_call_ids: List[str] | None = None,
raw_completion: ChatCompletion | None = None,
_new_record: Dict[str, Any] | None = None,
is_chunk: bool = False,
):
"""初始化 LLMResponse
@@ -293,3 +296,10 @@ class LLMResponse:
}
)
return ret
@dataclass
class RerankResult:
index: int
"""在候选列表中的索引位置"""
relevance_score: float
"""相关性分数"""

View File

@@ -1,32 +1,17 @@
from __future__ import annotations
import json
import textwrap
import os
import asyncio
import logging
from datetime import timedelta
import aiohttp
from typing import Dict, List, Awaitable, Literal, Any
from dataclasses import dataclass
from typing import Optional
from contextlib import AsyncExitStack
from typing import Dict, List, Awaitable
from astrbot import logger
from astrbot.core.utils.log_pipe import LogPipe
from astrbot.core import sp
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from astrbot.core.agent.mcp_client import MCPClient
from astrbot.core.agent.tool import ToolSet, FunctionTool
try:
import mcp
from mcp.client.sse import sse_client
except (ModuleNotFoundError, ImportError):
logger.warning("警告: 缺少依赖库 'mcp',将无法使用 MCP 服务。")
try:
from mcp.client.streamable_http import streamablehttp_client
except (ModuleNotFoundError, ImportError):
logger.warning(
"警告: 缺少依赖库 'mcp' 或者 mcp 库版本过低,无法使用 Streamable HTTP 连接方式。"
)
DEFAULT_MCP_CONFIG = {"mcpServers": {}}
@@ -39,6 +24,10 @@ SUPPORTED_TYPES = [
] # json schema 支持的数据类型
# alias
FuncTool = FunctionTool
def _prepare_config(config: dict) -> dict:
"""准备配置,处理嵌套格式"""
if "mcpServers" in config and config["mcpServers"]:
@@ -105,181 +94,9 @@ async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]:
return False, f"{e!s}"
@dataclass
class FuncTool:
"""
用于描述一个函数调用工具。
"""
name: str
parameters: Dict
description: str
handler: Awaitable = None
"""处理函数, 当 origin 为 mcp 时,这个为空"""
handler_module_path: str = None
"""处理函数的模块路径,当 origin 为 mcp 时,这个为空
必须要保留这个字段, handler 在初始化会被 functools.partial 包装,导致 handler 的 __module__ 为 functools
"""
active: bool = True
"""是否激活"""
origin: Literal["local", "mcp"] = "local"
"""函数工具的来源, local 为本地函数工具, mcp 为 MCP 服务"""
# MCP 相关字段
mcp_server_name: str = None
"""MCP 服务名称,当 origin 为 mcp 时有效"""
mcp_client: MCPClient = None
"""MCP 客户端,当 origin 为 mcp 时有效"""
def __repr__(self):
return f"FuncTool(name={self.name}, parameters={self.parameters}, description={self.description}, active={self.active}, origin={self.origin})"
async def execute(self, **args) -> Any:
"""执行函数调用"""
if self.origin == "local":
if not self.handler:
raise Exception(f"Local function {self.name} has no handler")
return await self.handler(**args)
elif self.origin == "mcp":
if not self.mcp_client or not self.mcp_client.session:
raise Exception(f"MCP client for {self.name} is not available")
# 使用name属性而不是额外的mcp_tool_name
actual_tool_name = (
self.name.split(":")[-1] if ":" in self.name else self.name
)
return await self.mcp_client.session.call_tool(actual_tool_name, args)
else:
raise Exception(f"Unknown function origin: {self.origin}")
class MCPClient:
def __init__(self):
# Initialize session and client objects
self.session: Optional[mcp.ClientSession] = None
self.exit_stack = AsyncExitStack()
self.name = None
self.active: bool = True
self.tools: List[mcp.Tool] = []
self.server_errlogs: List[str] = []
self.running_event = asyncio.Event()
async def connect_to_server(self, mcp_server_config: dict, name: str):
"""连接到 MCP 服务器
如果 `url` 参数存在:
1. 当 transport 指定为 `streamable_http` 时,使用 Streamable HTTP 连接方式。
1. 当 transport 指定为 `sse` 时,使用 SSE 连接方式。
2. 如果没有指定,默认使用 SSE 的方式连接到 MCP 服务。
Args:
mcp_server_config (dict): Configuration for the MCP server. See https://modelcontextprotocol.io/quickstart/server
"""
cfg = _prepare_config(mcp_server_config.copy())
def logging_callback(msg: str):
# 处理 MCP 服务的错误日志
print(f"MCP Server {name} Error: {msg}")
self.server_errlogs.append(msg)
if "url" in cfg:
success, error_msg = await _quick_test_mcp_connection(cfg)
if not success:
raise Exception(error_msg)
if cfg.get("transport") != "streamable_http":
# SSE transport method
self._streams_context = sse_client(
url=cfg["url"],
headers=cfg.get("headers", {}),
timeout=cfg.get("timeout", 5),
sse_read_timeout=cfg.get("sse_read_timeout", 60 * 5),
)
streams = await self.exit_stack.enter_async_context(
self._streams_context
)
# Create a new client session
read_timeout = timedelta(seconds=cfg.get("session_read_timeout", 20))
self.session = await self.exit_stack.enter_async_context(
mcp.ClientSession(
*streams,
read_timeout_seconds=read_timeout,
logging_callback=logging_callback, # type: ignore
)
)
else:
timeout = timedelta(seconds=cfg.get("timeout", 30))
sse_read_timeout = timedelta(
seconds=cfg.get("sse_read_timeout", 60 * 5)
)
self._streams_context = streamablehttp_client(
url=cfg["url"],
headers=cfg.get("headers", {}),
timeout=timeout,
sse_read_timeout=sse_read_timeout,
terminate_on_close=cfg.get("terminate_on_close", True),
)
read_s, write_s, _ = await self.exit_stack.enter_async_context(
self._streams_context
)
# Create a new client session
read_timeout = timedelta(seconds=cfg.get("session_read_timeout", 20))
self.session = await self.exit_stack.enter_async_context(
mcp.ClientSession(
read_stream=read_s,
write_stream=write_s,
read_timeout_seconds=read_timeout,
logging_callback=logging_callback, # type: ignore
)
)
else:
server_params = mcp.StdioServerParameters(
**cfg,
)
def callback(msg: str):
# 处理 MCP 服务的错误日志
self.server_errlogs.append(msg)
stdio_transport = await self.exit_stack.enter_async_context(
mcp.stdio_client(
server_params,
errlog=LogPipe(
level=logging.ERROR,
logger=logger,
identifier=f"MCPServer-{name}",
callback=callback,
), # type: ignore
),
)
# Create a new client session
self.session = await self.exit_stack.enter_async_context(
mcp.ClientSession(*stdio_transport)
)
await self.session.initialize()
async def list_tools_and_save(self) -> mcp.ListToolsResult:
"""List all tools from the server and save them to self.tools"""
response = await self.session.list_tools()
self.tools = response.tools
return response
async def cleanup(self):
"""Clean up resources"""
await self.exit_stack.aclose()
self.running_event.set() # Set the running event to indicate cleanup is done
class FuncCall:
class FunctionToolManager:
def __init__(self) -> None:
self.func_list: List[FuncTool] = []
"""内部加载的 func tools"""
self.mcp_client_dict: Dict[str, MCPClient] = {}
"""MCP 服务列表"""
self.mcp_client_event: Dict[str, asyncio.Event] = {}
@@ -287,6 +104,29 @@ class FuncCall:
def empty(self) -> bool:
return len(self.func_list) == 0
def spec_to_func(
self,
name: str,
func_args: list,
desc: str,
handler: Awaitable,
) -> FuncTool:
params = {
"type": "object", # hard-coded here
"properties": {},
}
for param in func_args:
params["properties"][param["name"]] = {
"type": param["type"],
"description": param["description"],
}
return FuncTool(
name=name,
parameters=params,
description=desc,
handler=handler,
)
def add_func(
self,
name: str,
@@ -304,22 +144,14 @@ class FuncCall:
# check if the tool has been added before
self.remove_func(name)
params = {
"type": "object", # hard-coded here
"properties": {},
}
for param in func_args:
params["properties"][param["name"]] = {
"type": param["type"],
"description": param["description"],
}
_func = FuncTool(
name=name,
parameters=params,
description=desc,
handler=handler,
self.func_list.append(
self.spec_to_func(
name=name,
func_args=func_args,
desc=desc,
handler=handler,
)
)
self.func_list.append(_func)
logger.info(f"添加函数调用工具: {name}")
def remove_func(self, name: str) -> None:
@@ -331,11 +163,15 @@ class FuncCall:
self.func_list.pop(i)
break
def get_func(self, name) -> FuncTool:
def get_func(self, name) -> FuncTool | None:
for f in self.func_list:
if f.name == name:
return f
return None
def get_full_tool_set(self) -> ToolSet:
"""获取完整工具集"""
tool_set = ToolSet(self.func_list.copy())
return tool_set
async def init_mcp_clients(self) -> None:
"""从项目根目录读取 mcp_server.json 文件,初始化 MCP 服务列表。文件格式如下:
@@ -556,203 +392,179 @@ class FuncCall:
"""
获得 OpenAI API 风格的**已经激活**的工具描述
"""
_l = []
# 处理所有工具包括本地和MCP工具
for f in self.func_list:
if not f.active:
continue
func_ = {
"type": "function",
"function": {
"name": f.name,
# "parameters": f.parameters,
"description": f.description,
},
}
func_["function"]["parameters"] = f.parameters
if not f.parameters.get("properties") and omit_empty_parameter_field:
# 如果 properties 为空,并且 omit_empty_parameter_field 为 True则删除 parameters 字段
del func_["function"]["parameters"]
_l.append(func_)
return _l
tools = [f for f in self.func_list if f.active]
toolset = ToolSet(tools)
return toolset.openai_schema(
omit_empty_parameter_field=omit_empty_parameter_field
)
def get_func_desc_anthropic_style(self) -> list:
"""
获得 Anthropic API 风格的**已经激活**的工具描述
"""
tools = []
for f in self.func_list:
if not f.active:
continue
# Convert internal format to Anthropic style
tool = {
"name": f.name,
"description": f.description,
"input_schema": {
"type": "object",
"properties": f.parameters.get("properties", {}),
# Keep the required field from the original parameters if it exists
"required": f.parameters.get("required", []),
},
}
tools.append(tool)
return tools
tools = [f for f in self.func_list if f.active]
toolset = ToolSet(tools)
return toolset.anthropic_schema()
def get_func_desc_google_genai_style(self) -> dict:
"""
获得 Google GenAI API 风格的**已经激活**的工具描述
"""
tools = [f for f in self.func_list if f.active]
toolset = ToolSet(tools)
return toolset.google_schema()
# Gemini API 支持的数据类型和格式
supported_types = {
"string",
"number",
"integer",
"boolean",
"array",
"object",
"null",
}
supported_formats = {
"string": {"enum", "date-time"},
"integer": {"int32", "int64"},
"number": {"float", "double"},
}
def deactivate_llm_tool(self, name: str) -> bool:
"""停用一个已经注册的函数调用工具。
def convert_schema(schema: dict) -> dict:
"""转换 schema 为 Gemini API 格式"""
Returns:
如果没找到,会返回 False"""
func_tool = self.get_func(name)
if func_tool is not None:
func_tool.active = False
# 如果 schema 包含 anyOf则只返回 anyOf 字段
if "anyOf" in schema:
return {"anyOf": [convert_schema(s) for s in schema["anyOf"]]}
result = {}
if "type" in schema and schema["type"] in supported_types:
result["type"] = schema["type"]
if "format" in schema and schema["format"] in supported_formats.get(
result["type"], set()
):
result["format"] = schema["format"]
else:
# 暂时指定默认为null
result["type"] = "null"
support_fields = {
"title",
"description",
"enum",
"minimum",
"maximum",
"maxItems",
"minItems",
"nullable",
"required",
}
result.update({k: schema[k] for k in support_fields if k in schema})
if "properties" in schema:
properties = {}
for key, value in schema["properties"].items():
prop_value = convert_schema(value)
if "default" in prop_value:
del prop_value["default"]
properties[key] = prop_value
if properties: # 只在有非空属性时添加
result["properties"] = properties
if "items" in schema:
result["items"] = convert_schema(schema["items"])
return result
tools = [
{
"name": f.name,
"description": f.description,
**({"parameters": convert_schema(f.parameters)}),
}
for f in self.func_list
if f.active
]
declarations = {}
if tools:
declarations["function_declarations"] = tools
return declarations
async def func_call(self, question: str, session_id: str, provider) -> tuple:
_l = []
for f in self.func_list:
if not f.active:
continue
_l.append(
{
"name": f.name,
"parameters": f.parameters,
"description": f.description,
}
inactivated_llm_tools: list = sp.get(
"inactivated_llm_tools", [], scope="global", scope_id="global"
)
func_definition = json.dumps(_l, ensure_ascii=False)
if name not in inactivated_llm_tools:
inactivated_llm_tools.append(name)
sp.put(
"inactivated_llm_tools",
inactivated_llm_tools,
scope="global",
scope_id="global",
)
prompt = textwrap.dedent(f"""
ROLE:
你是一个 Function calling AI Agent, 你的任务是将用户的提问转化为函数调用。
return True
return False
TOOLS:
可用的函数列表:
# 因为不想解决循环引用,所以这里直接传入 star_map 先了...
def activate_llm_tool(self, name: str, star_map: dict) -> bool:
func_tool = self.get_func(name)
if func_tool is not None:
if func_tool.handler_module_path in star_map:
if not star_map[func_tool.handler_module_path].activated:
raise ValueError(
f"此函数调用工具所属的插件 {star_map[func_tool.handler_module_path].name} 已被禁用,请先在管理面板启用再激活此工具。"
)
{func_definition}
func_tool.active = True
LIMIT:
1. 你返回的内容应当能够被 Python 的 json 模块解析的 Json 格式字符串。
2. 你的 Json 返回的格式如下:`[{{"name": "<func_name>", "args": <arg_dict>}}, ...]`。参数根据上面提供的函数列表中的参数来填写。
3. 允许必要时返回多个函数调用,但需保证这些函数调用的顺序正确。
4. 如果用户的提问中不需要用到给定的函数,请直接返回 `{{"res": False}}`。
inactivated_llm_tools: list = sp.get(
"inactivated_llm_tools", [], scope="global", scope_id="global"
)
if name in inactivated_llm_tools:
inactivated_llm_tools.remove(name)
sp.put(
"inactivated_llm_tools",
inactivated_llm_tools,
scope="global",
scope_id="global",
)
EXAMPLE:
1. `用户提问`:请问一下天气怎么样? `函数调用`[{{"name": "get_weather", "args": {{"city": "北京"}}}}]
return True
return False
用户的提问是:{question}
""")
@property
def mcp_config_path(self):
data_dir = get_astrbot_data_path()
return os.path.join(data_dir, "mcp_server.json")
_c = 0
while _c < 3:
try:
res = await provider.text_chat(prompt, session_id)
if res.find("```") != -1:
res = res[res.find("```json") + 7 : res.rfind("```")]
res = json.loads(res)
break
except Exception as e:
_c += 1
if _c == 3:
raise e
if "The message you submitted was too long" in str(e):
raise e
def load_mcp_config(self):
if not os.path.exists(self.mcp_config_path):
# 配置文件不存在,创建默认配置
os.makedirs(os.path.dirname(self.mcp_config_path), exist_ok=True)
with open(self.mcp_config_path, "w", encoding="utf-8") as f:
json.dump(DEFAULT_MCP_CONFIG, f, ensure_ascii=False, indent=4)
return DEFAULT_MCP_CONFIG
if "res" in res and not res["res"]:
return "", False
try:
with open(self.mcp_config_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
logger.error(f"加载 MCP 配置失败: {e}")
return DEFAULT_MCP_CONFIG
tool_call_result = []
for tool in res:
# 说明有函数调用
func_name = tool["name"]
args = tool["args"]
# 调用函数
func_tool = self.get_func(func_name)
if not func_tool:
raise Exception(f"Request function {func_name} not found.")
def save_mcp_config(self, config: dict):
try:
with open(self.mcp_config_path, "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=4)
return True
except Exception as e:
logger.error(f"保存 MCP 配置失败: {e}")
return False
ret = await func_tool.execute(**args)
if ret:
tool_call_result.append(str(ret))
return tool_call_result, True
async def sync_modelscope_mcp_servers(self, access_token: str) -> None:
"""从 ModelScope 平台同步 MCP 服务器配置"""
base_url = "https://www.modelscope.cn/openapi/v1"
url = f"{base_url}/mcp/servers/operational"
headers = {
"Authorization": f"Bearer {access_token.strip()}",
"Content-Type": "application/json",
}
try:
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=headers) as response:
if response.status == 200:
data = await response.json()
mcp_server_list = data.get("data", {}).get(
"mcp_server_list", []
)
local_mcp_config = self.load_mcp_config()
synced_count = 0
for server in mcp_server_list:
server_name = server["name"]
operational_urls = server.get("operational_urls", [])
if not operational_urls:
continue
url_info = operational_urls[0]
server_url = url_info.get("url")
if not server_url:
continue
# 添加到配置中(同名会覆盖)
local_mcp_config["mcpServers"][server_name] = {
"url": server_url,
"transport": "sse",
"active": True,
"provider": "modelscope",
}
synced_count += 1
if synced_count > 0:
self.save_mcp_config(local_mcp_config)
tasks = []
for server in mcp_server_list:
name = server["name"]
tasks.append(
self.enable_mcp_server(
name=name,
config=local_mcp_config["mcpServers"][name],
)
)
await asyncio.gather(*tasks)
logger.info(
f"从 ModelScope 同步了 {synced_count} 个 MCP 服务器"
)
else:
logger.warning("没有找到可用的 ModelScope MCP 服务器")
else:
raise Exception(
f"ModelScope API 请求失败: HTTP {response.status}"
)
except aiohttp.ClientError as e:
raise Exception(f"网络连接错误: {str(e)}")
except Exception as e:
raise Exception(f"同步 ModelScope MCP 服务器时发生错误: {str(e)}")
def __str__(self):
return str(self.func_list)
def __repr__(self):
return str(self.func_list)
# alias
FuncCall = FunctionToolManager

View File

@@ -3,89 +3,32 @@ import traceback
from typing import List
from astrbot.core import logger, sp
from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
from astrbot.core.db import BaseDatabase
from .entities import ProviderType
from .provider import Personality, Provider, STTProvider, TTSProvider, EmbeddingProvider
from .provider import Provider, STTProvider, TTSProvider, EmbeddingProvider
from .register import llm_tools, provider_cls_map
from ..persona_mgr import PersonaManager
class ProviderManager:
def __init__(self, config: AstrBotConfig, db_helper: BaseDatabase):
def __init__(
self,
acm: AstrBotConfigManager,
db_helper: BaseDatabase,
persona_mgr: PersonaManager,
):
self.persona_mgr = persona_mgr
self.acm = acm
config = acm.confs["default"]
self.providers_config: List = config["provider"]
self.provider_settings: dict = config["provider_settings"]
self.provider_stt_settings: dict = config.get("provider_stt_settings", {})
self.provider_tts_settings: dict = config.get("provider_tts_settings", {})
self.persona_configs: list = config.get("persona", [])
self.astrbot_config = config
# 人格情景管理
# 目前没有拆成独立的模块
self.default_persona_name = self.provider_settings.get(
"default_personality", "default"
)
self.personas: List[Personality] = []
self.selected_default_persona = None
for persona in self.persona_configs:
begin_dialogs = persona.get("begin_dialogs", [])
mood_imitation_dialogs = persona.get("mood_imitation_dialogs", [])
bd_processed = []
mid_processed = ""
if begin_dialogs:
if len(begin_dialogs) % 2 != 0:
logger.error(
f"{persona['name']} 人格情景预设对话格式不对,条数应该为偶数。"
)
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
}
)
user_turn = not user_turn
if mood_imitation_dialogs:
if len(mood_imitation_dialogs) % 2 != 0:
logger.error(
f"{persona['name']} 对话风格对话格式不对,条数应该为偶数。"
)
mood_imitation_dialogs = []
user_turn = True
for dialog in mood_imitation_dialogs:
role = "A" if user_turn else "B"
mid_processed += f"{role}: {dialog}\n"
if not user_turn:
mid_processed += "\n"
user_turn = not user_turn
try:
persona = Personality(
**persona,
_begin_dialogs_processed=bd_processed,
_mood_imitation_dialogs_processed=mid_processed,
)
if persona["name"] == self.default_persona_name:
self.selected_default_persona = persona
self.personas.append(persona)
except Exception as e:
logger.error(f"解析 Persona 配置失败:{e}")
if not self.selected_default_persona and len(self.personas) > 0:
# 默认选择第一个
self.selected_default_persona = self.personas[0]
if not self.selected_default_persona:
self.selected_default_persona = Personality(
prompt="You are a helpful and friendly assistant.",
name="default",
_begin_dialogs_processed=[],
_mood_imitation_dialogs_processed="",
)
self.personas.append(self.selected_default_persona)
# 人格相关属性v4.0.0 版本后被废弃,推荐使用 PersonaManager
self.default_persona_name = persona_mgr.default_persona
self.provider_insts: List[Provider] = []
"""加载的 Provider 的实例"""
@@ -100,46 +43,111 @@ class ProviderManager:
self.llm_tools = llm_tools
self.curr_provider_inst: Provider | None = None
"""默认的 Provider 实例"""
"""默认的 Provider 实例。已弃用,请使用 get_using_provider() 方法获取当前使用的 Provider 实例。"""
self.curr_stt_provider_inst: STTProvider | None = None
"""默认的 Speech To Text Provider 实例"""
"""默认的 Speech To Text Provider 实例。已弃用,请使用 get_using_provider() 方法获取当前使用的 Provider 实例。"""
self.curr_tts_provider_inst: TTSProvider | None = None
"""默认的 Text To Speech Provider 实例"""
"""默认的 Text To Speech Provider 实例。已弃用,请使用 get_using_provider() 方法获取当前使用的 Provider 实例。"""
self.db_helper = db_helper
# kdb(experimental)
self.curr_kdb_name = ""
kdb_cfg = config.get("knowledge_db", {})
if kdb_cfg and len(kdb_cfg):
self.curr_kdb_name = list(kdb_cfg.keys())[0]
@property
def persona_configs(self) -> list:
"""动态获取最新的 persona 配置"""
return self.persona_mgr.persona_v3_config
@property
def personas(self) -> list:
"""动态获取最新的 personas 列表"""
return self.persona_mgr.personas_v3
@property
def selected_default_persona(self):
"""动态获取最新的默认选中 persona。已弃用请使用 context.persona_mgr.get_default_persona_v3()"""
return self.persona_mgr.selected_default_persona_v3
async def set_provider(
self, provider_id: str, provider_type: ProviderType, umo: str = None
self, provider_id: str, provider_type: ProviderType, umo: str | None = None
):
"""设置提供商。
Args:
provider_id (str): 提供商 ID。
provider_type (ProviderType): 提供商类型。
umo (str, optional): 用户会话 ID用于提供商会话隔离。当用户启用了提供商会话隔离时此参数才生效。
umo (str, optional): 用户会话 ID用于提供商会话隔离。
Version 4.0.0: 这个版本下已经默认隔离提供商
"""
if provider_id not in self.inst_map:
raise ValueError(f"提供商 {provider_id} 不存在,无法设置。")
if umo and self.provider_settings["separate_provider"]:
perf = sp.get("session_provider_perf", {})
session_perf = perf.get(umo, {})
session_perf[provider_type.value] = provider_id
perf[umo] = session_perf
sp.put("session_provider_perf", perf)
if umo:
await sp.session_put(
umo,
f"provider_perf_{provider_type.value}",
provider_id,
)
return
# 不启用提供商会话隔离模式的情况
self.curr_provider_inst = self.inst_map[provider_id]
if provider_type == ProviderType.TEXT_TO_SPEECH:
sp.put("curr_provider_tts", provider_id)
sp.put("curr_provider_tts", provider_id, scope="global", scope_id="global")
elif provider_type == ProviderType.SPEECH_TO_TEXT:
sp.put("curr_provider_stt", provider_id)
sp.put("curr_provider_stt", provider_id, scope="global", scope_id="global")
elif provider_type == ProviderType.CHAT_COMPLETION:
sp.put("curr_provider", provider_id)
sp.put("curr_provider", provider_id, scope="global", scope_id="global")
async def get_provider_by_id(self, provider_id: str) -> Provider | None:
"""根据提供商 ID 获取提供商实例"""
return self.inst_map.get(provider_id)
def get_using_provider(self, provider_type: ProviderType, umo=None):
"""获取正在使用的提供商实例。
Args:
provider_type (ProviderType): 提供商类型。
umo (str, optional): 用户会话 ID用于提供商会话隔离。
Returns:
Provider: 正在使用的提供商实例。
"""
provider = None
if umo:
provider_id = sp.get(
f"provider_perf_{provider_type.value}",
None,
scope="umo",
scope_id=umo,
)
if provider_id:
provider = self.inst_map.get(provider_id)
if not provider:
# default setting
config = self.acm.get_conf(umo)
if provider_type == ProviderType.CHAT_COMPLETION:
provider_id = config["provider_settings"].get("default_provider_id")
provider = self.inst_map.get(provider_id)
if not provider:
provider = self.provider_insts[0] if self.provider_insts else None
elif provider_type == ProviderType.SPEECH_TO_TEXT:
provider_id = config["provider_stt_settings"].get("provider_id")
if not provider_id:
return None
provider = self.inst_map.get(provider_id)
if not provider:
provider = (
self.stt_provider_insts[0] if self.stt_provider_insts else None
)
elif provider_type == ProviderType.TEXT_TO_SPEECH:
provider_id = config["provider_tts_settings"].get("provider_id")
if not provider_id:
return None
provider = self.inst_map.get(provider_id)
if not provider:
provider = (
self.tts_provider_insts[0] if self.tts_provider_insts else None
)
else:
raise ValueError(f"Unknown provider type: {provider_type}")
return provider
async def initialize(self):
# 逐个初始化提供商
@@ -148,13 +156,22 @@ class ProviderManager:
# 设置默认提供商
selected_provider_id = sp.get(
"curr_provider", self.provider_settings.get("default_provider_id")
"curr_provider",
self.provider_settings.get("default_provider_id"),
scope="global",
scope_id="global",
)
selected_stt_provider_id = sp.get(
"curr_provider_stt", self.provider_stt_settings.get("provider_id")
"curr_provider_stt",
self.provider_stt_settings.get("provider_id"),
scope="global",
scope_id="global",
)
selected_tts_provider_id = sp.get(
"curr_provider_tts", self.provider_tts_settings.get("provider_id")
"curr_provider_tts",
self.provider_tts_settings.get("provider_id"),
scope="global",
scope_id="global",
)
self.curr_provider_inst = self.inst_map.get(selected_provider_id)
if not self.curr_provider_inst and self.provider_insts:
@@ -262,6 +279,10 @@ class ProviderManager:
from .sources.gemini_embedding_source import (
GeminiEmbeddingProvider as GeminiEmbeddingProvider,
)
case "vllm_rerank":
from .sources.vllm_rerank_source import (
VLLMRerankProvider as VLLMRerankProvider,
)
except (ImportError, ModuleNotFoundError) as e:
logger.critical(
f"加载 {provider_config['type']}({provider_config['id']}) 提供商适配器失败:{e}。可能是因为有未安装的依赖。"
@@ -345,7 +366,7 @@ class ProviderManager:
if not self.curr_provider_inst:
self.curr_provider_inst = inst
elif provider_metadata.provider_type == ProviderType.EMBEDDING:
elif provider_metadata.provider_type in [ProviderType.EMBEDDING, ProviderType.RERANK]:
inst = provider_metadata.cls_type(
provider_config, self.provider_settings
)

View File

@@ -1,23 +1,18 @@
import abc
from typing import List
from typing import TypedDict, AsyncGenerator
from astrbot.core.provider.func_tool_manager import FuncCall
from astrbot.core.provider.entities import LLMResponse, ToolCallsResult, ProviderType
from typing import AsyncGenerator
from astrbot.core.agent.tool import ToolSet
from astrbot.core.provider.entities import (
LLMResponse,
ToolCallsResult,
ProviderType,
RerankResult,
)
from astrbot.core.provider.register import provider_cls_map
from astrbot.core.db.po import Personality
from dataclasses import dataclass
class Personality(TypedDict):
prompt: str = ""
name: str = ""
begin_dialogs: List[str] = []
mood_imitation_dialogs: List[str] = []
# cache
_begin_dialogs_processed: List[dict] = []
_mood_imitation_dialogs_processed: str = ""
@dataclass
class ProviderMeta:
id: str
@@ -90,7 +85,7 @@ class Provider(AbstractProvider):
prompt: str,
session_id: str = None,
image_urls: list[str] = None,
func_tool: FuncCall = None,
func_tool: ToolSet = None,
contexts: list = None,
system_prompt: str = None,
tool_calls_result: ToolCallsResult | list[ToolCallsResult] = None,
@@ -119,7 +114,7 @@ class Provider(AbstractProvider):
prompt: str,
session_id: str = None,
image_urls: list[str] = None,
func_tool: FuncCall = None,
func_tool: ToolSet = None,
contexts: list = None,
system_prompt: str = None,
tool_calls_result: ToolCallsResult | list[ToolCallsResult] = None,
@@ -206,3 +201,17 @@ class EmbeddingProvider(AbstractProvider):
def get_dim(self) -> int:
"""获取向量的维度"""
...
class RerankProvider(AbstractProvider):
def __init__(self, provider_config: dict, provider_settings: dict) -> None:
super().__init__(provider_config)
self.provider_config = provider_config
self.provider_settings = provider_settings
@abc.abstractmethod
async def rerank(
self, query: str, documents: list[str], top_n: int | None = None
) -> list[RerankResult]:
"""获取查询和文档的重排序分数"""
...

View File

@@ -75,8 +75,7 @@ class ProviderDashscope(ProviderOpenAIOfficial):
# 获得会话变量
payload_vars = self.variables.copy()
# 动态变量
session_vars = sp.get("session_variables", {})
session_var = session_vars.get(session_id, {})
session_var = await sp.session_get(session_id, "session_variables", default={})
payload_vars.update(session_var)
if (

View File

@@ -97,8 +97,7 @@ class ProviderDify(Provider):
# 获得会话变量
payload_vars = self.variables.copy()
# 动态变量
session_vars = sp.get("session_variables", {})
session_var = session_vars.get(session_id, {})
session_var = await sp.session_get(session_id, "session_variables", default={})
payload_vars.update(session_var)
payload_vars["system_prompt"] = system_prompt

View File

@@ -1,5 +1,6 @@
import os
import uuid
import re
import ormsgpack
from pydantic import BaseModel, conint
from httpx import AsyncClient
@@ -24,8 +25,8 @@ class ServeTTSRequest(BaseModel):
# 参考音频
references: list[ServeReferenceAudio] = []
# 参考模型 ID
# 例如 https://fish.audio/m/7f92f8afb8ec43bf81429cc1c9199cb1/
# 其中reference_id为 7f92f8afb8ec43bf81429cc1c9199cb1
# 例如 https://fish.audio/m/626bb6d3f3364c9cbc3aa6a67300a664/
# 其中reference_id为 626bb6d3f3364c9cbc3aa6a67300a664
reference_id: str | None = None
# 对中英文文本进行标准化,这可以提高数字的稳定性
normalize: bool = True
@@ -44,6 +45,7 @@ class ProviderFishAudioTTSAPI(TTSProvider):
) -> None:
super().__init__(provider_config, provider_settings)
self.chosen_api_key: str = provider_config.get("api_key", "")
self.reference_id: str = provider_config.get("fishaudio-tts-reference-id", "")
self.character: str = provider_config.get("fishaudio-tts-character", "可莉")
self.api_base: str = provider_config.get(
"api_base", "https://api.fish-audio.cn/v1"
@@ -81,11 +83,43 @@ class ProviderFishAudioTTSAPI(TTSProvider):
return item["_id"]
return None
def _validate_reference_id(self, reference_id: str) -> bool:
"""
验证reference_id格式是否有效
Args:
reference_id: 参考模型ID
Returns:
bool: ID是否有效
"""
if not reference_id or not reference_id.strip():
return False
# FishAudio的reference_id通常是32位十六进制字符串
# 例如: 626bb6d3f3364c9cbc3aa6a67300a664
pattern = r'^[a-fA-F0-9]{32}$'
return bool(re.match(pattern, reference_id.strip()))
async def _generate_request(self, text: str) -> dict:
# 向前兼容逻辑优先使用reference_id如果没有则使用角色名称查询
if self.reference_id and self.reference_id.strip():
# 验证reference_id格式
if not self._validate_reference_id(self.reference_id):
raise ValueError(
f"无效的FishAudio参考模型ID: '{self.reference_id}'. "
f"请确保ID是32位十六进制字符串例如: 626bb6d3f3364c9cbc3aa6a67300a664"
f"您可以从 https://fish.audio/zh-CN/discovery 获取有效的模型ID。"
)
reference_id = self.reference_id.strip()
else:
# 回退到原来的角色名称查询逻辑
reference_id = await self._get_reference_id_by_character(self.character)
return ServeTTSRequest(
text=text,
format="wav",
reference_id=await self._get_reference_id_by_character(self.character),
reference_id=reference_id,
)
async def get_audio(self, text: str) -> str:

View File

@@ -15,7 +15,7 @@ from astrbot import logger
from astrbot.api.provider import Provider
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import LLMResponse
from astrbot.core.provider.func_tool_manager import FuncCall
from astrbot.core.provider.func_tool_manager import ToolSet
from astrbot.core.utils.io import download_image_by_url
from ..register import register_provider_adapter
@@ -61,7 +61,7 @@ class ProviderGoogleGenAI(Provider):
default_persona,
)
self.api_keys: list = provider_config.get("key", [])
self.chosen_api_key: str = self.api_keys[0] if len(self.api_keys) > 0 else None
self.chosen_api_key: str = self.api_keys[0] if len(self.api_keys) > 0 else ""
self.timeout: int = int(provider_config.get("timeout", 180))
self.api_base: Optional[str] = provider_config.get("api_base", None)
@@ -96,6 +96,9 @@ class ProviderGoogleGenAI(Provider):
async def _handle_api_error(self, e: APIError, keys: list[str]) -> bool:
"""处理API错误返回是否需要重试"""
if e.message is None:
e.message = ""
if e.code == 429 or "API key not valid" in e.message:
keys.remove(self.chosen_api_key)
if len(keys) > 0:
@@ -119,7 +122,7 @@ class ProviderGoogleGenAI(Provider):
async def _prepare_query_config(
self,
payloads: dict,
tools: Optional[FuncCall] = None,
tools: Optional[ToolSet] = None,
system_instruction: Optional[str] = None,
modalities: Optional[list[str]] = None,
temperature: float = 0.7,
@@ -321,11 +324,15 @@ class ProviderGoogleGenAI(Provider):
@staticmethod
def _process_content_parts(
result: types.GenerateContentResponse, llm_response: LLMResponse
candidate: types.Candidate, llm_response: LLMResponse
) -> MessageChain:
"""处理内容部分并构建消息链"""
finish_reason = result.candidates[0].finish_reason
result_parts: Optional[types.Part] = result.candidates[0].content.parts
if not candidate.content:
logger.warning(f"收到的 candidate.content 为空: {candidate}")
raise Exception("API 返回的 candidate.content 为空。")
finish_reason = candidate.finish_reason
result_parts: list[types.Part] | None = candidate.content.parts
if finish_reason == types.FinishReason.SAFETY:
raise Exception("模型生成内容未通过 Gemini 平台的安全检查")
@@ -343,22 +350,28 @@ class ProviderGoogleGenAI(Provider):
raise Exception("模型生成内容违反 Gemini 平台政策")
if not result_parts:
logger.debug(result.candidates)
raise Exception("API 返回的内容为空。")
logger.warning(f"收到的 candidate.content.parts 为空: {candidate}")
raise Exception("API 返回的 candidate.content.parts 为空。")
chain = []
part: types.Part
# 暂时这样Fallback
if all(
part.inline_data and part.inline_data.mime_type.startswith("image/")
part.inline_data
and part.inline_data.mime_type
and part.inline_data.mime_type.startswith("image/")
for part in result_parts
):
chain.append(Comp.Plain("这是图片"))
for part in result_parts:
if part.text:
chain.append(Comp.Plain(part.text))
elif part.function_call:
elif (
part.function_call
and part.function_call.name is not None
and part.function_call.args is not None
):
llm_response.role = "tool"
llm_response.tools_call_name.append(part.function_call.name)
llm_response.tools_call_args.append(part.function_call.args)
@@ -366,11 +379,16 @@ class ProviderGoogleGenAI(Provider):
llm_response.tools_call_ids.append(
part.function_call.id or part.function_call.name
)
elif part.inline_data and part.inline_data.mime_type.startswith("image/"):
elif (
part.inline_data
and part.inline_data.mime_type
and part.inline_data.mime_type.startswith("image/")
and part.inline_data.data
):
chain.append(Comp.Image.fromBytes(part.inline_data.data))
return MessageChain(chain=chain)
async def _query(self, payloads: dict, tools: FuncCall) -> LLMResponse:
async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse:
"""非流式请求 Gemini API"""
system_instruction = next(
(msg["content"] for msg in payloads["messages"] if msg["role"] == "system"),
@@ -396,6 +414,10 @@ class ProviderGoogleGenAI(Provider):
config=config,
)
if not result.candidates:
logger.error(f"请求失败, 返回的 candidates 为空: {result}")
raise Exception("请求失败, 返回的 candidates 为空。")
if result.candidates[0].finish_reason == types.FinishReason.RECITATION:
if temperature > 2:
raise Exception("温度参数已超过最大值2仍然发生recitation")
@@ -408,6 +430,8 @@ class ProviderGoogleGenAI(Provider):
break
except APIError as e:
if e.message is None:
e.message = ""
if "Developer instruction is not enabled" in e.message:
logger.warning(
f"{self.get_model()} 不支持 system prompt已自动去除(影响人格设置)"
@@ -431,11 +455,14 @@ class ProviderGoogleGenAI(Provider):
continue
llm_response = LLMResponse("assistant")
llm_response.result_chain = self._process_content_parts(result, llm_response)
llm_response.raw_completion = result
llm_response.result_chain = self._process_content_parts(
result.candidates[0], llm_response
)
return llm_response
async def _query_stream(
self, payloads: dict, tools: FuncCall
self, payloads: dict, tools: ToolSet | None
) -> AsyncGenerator[LLMResponse, None]:
"""流式请求 Gemini API"""
system_instruction = next(
@@ -458,6 +485,8 @@ class ProviderGoogleGenAI(Provider):
)
break
except APIError as e:
if e.message is None:
e.message = ""
if "Developer instruction is not enabled" in e.message:
logger.warning(
f"{self.get_model()} 不支持 system prompt已自动去除(影响人格设置)"
@@ -477,12 +506,20 @@ class ProviderGoogleGenAI(Provider):
async for chunk in result:
llm_response = LLMResponse("assistant", is_chunk=True)
if not chunk.candidates:
logger.warning(f"收到的 chunk 中 candidates 为空: {chunk}")
continue
if not chunk.candidates[0].content:
logger.warning(f"收到的 chunk 中 content 为空: {chunk}")
continue
if chunk.candidates[0].content.parts and any(
part.function_call for part in chunk.candidates[0].content.parts
):
llm_response = LLMResponse("assistant", is_chunk=False)
llm_response.raw_completion = chunk
llm_response.result_chain = self._process_content_parts(
chunk, llm_response
chunk.candidates[0], llm_response
)
yield llm_response
return
@@ -496,8 +533,9 @@ class ProviderGoogleGenAI(Provider):
# Process the final chunk for potential tool calls or other content
if chunk.candidates[0].content.parts:
final_response = LLMResponse("assistant", is_chunk=False)
final_response.raw_completion = chunk
final_response.result_chain = self._process_content_parts(
chunk, final_response
chunk.candidates[0], final_response
)
break
@@ -563,6 +601,8 @@ class ProviderGoogleGenAI(Provider):
continue
break
raise Exception("请求失败。")
async def text_chat_stream(
self,
prompt,
@@ -618,7 +658,9 @@ class ProviderGoogleGenAI(Provider):
return [
m.name.replace("models/", "")
for m in models
if "generateContent" in m.supported_actions
if m.supported_actions
and "generateContent" in m.supported_actions
and m.name
]
except APIError as e:
raise Exception(f"获取模型列表失败: {e.message}")
@@ -633,7 +675,7 @@ class ProviderGoogleGenAI(Provider):
self.chosen_api_key = key
self._init_client()
async def assemble_context(self, text: str, image_urls: list[str] = None):
async def assemble_context(self, text: str, image_urls: list[str] | None = None):
"""
组装上下文。
"""

View File

@@ -99,10 +99,13 @@ class ProviderOpenAIOfficial(Provider):
for key in to_del:
del payloads[key]
# 针对 qwen3 模型的特殊处理:非流式调用必须设置 enable_thinking=false
model = payloads.get("model", "")
if "qwen3" in model.lower():
# 针对 qwen3 非 thinking 模型的特殊处理:非流式调用必须设置 enable_thinking=false
if "qwen3" in model.lower() and "thinking" not in model.lower():
extra_body["enable_thinking"] = False
# 针对 deepseek 模型的特殊处理deepseek-reasoner调用必须移除 tools ,否则将被切换至 deepseek-chat
elif model == "deepseek-reasoner" and "tools" in payloads:
del payloads["tools"]
completion = await self.client.chat.completions.create(
**payloads, stream=False, extra_body=extra_body

View File

@@ -0,0 +1,59 @@
import aiohttp
from ..provider import RerankProvider
from ..register import register_provider_adapter
from ..entities import ProviderType, RerankResult
@register_provider_adapter(
"vllm_rerank",
"VLLM Rerank 适配器",
provider_type=ProviderType.RERANK,
)
class VLLMRerankProvider(RerankProvider):
def __init__(self, provider_config: dict, provider_settings: dict) -> None:
super().__init__(provider_config, provider_settings)
self.provider_config = provider_config
self.provider_settings = provider_settings
self.auth_key = provider_config.get("rerank_api_key", "")
self.base_url = provider_config.get("rerank_api_base", "http://127.0.0.1:8000")
self.base_url = self.base_url.rstrip("/")
self.timeout = provider_config.get("timeout", 20)
self.model = provider_config.get("rerank_model", "BAAI/bge-reranker-base")
h = {}
if self.auth_key:
h["Authorization"] = f"Bearer {self.auth_key}"
self.client = aiohttp.ClientSession(
headers=h,
timeout=aiohttp.ClientTimeout(total=self.timeout),
)
async def rerank(
self, query: str, documents: list[str], top_n: int | None = None
) -> list[RerankResult]:
payload = {
"query": query,
"documents": documents,
"model": self.model,
}
if top_n is not None:
payload["top_n"] = top_n
async with self.client.post(
f"{self.base_url}/v1/rerank", json=payload
) as response:
response_data = await response.json()
results = response_data.get("results", [])
return [
RerankResult(
index=result["index"],
relevance_score=result["relevance_score"],
)
for result in results
]
async def terminate(self) -> None:
"""关闭客户端会话"""
if self.client:
await self.client.close()
self.client = None

View File

@@ -27,14 +27,16 @@ class Star(CommandParserMixin):
star_map[cls.__module__].star_cls_type = cls
star_map[cls.__module__].module_path = cls.__module__
@staticmethod
async def text_to_image(text: str, return_url=True) -> str:
async def text_to_image(self, text: str, return_url=True) -> str:
"""将文本转换为图片"""
return await html_renderer.render_t2i(text, return_url=return_url)
return await html_renderer.render_t2i(
text,
return_url=return_url,
template_name=self.context._config.get("t2i_active_template"),
)
@staticmethod
async def html_render(
tmpl: str, data: dict, return_url=True, options: dict = None
self, tmpl: str, data: dict, return_url=True, options: dict | None = None
) -> str:
"""渲染 HTML"""
return await html_renderer.render_custom_template(

View File

@@ -1,17 +1,24 @@
from asyncio import Queue
from typing import List, Union
from astrbot.core import sp
from astrbot.core.provider.provider import Provider, TTSProvider, STTProvider, EmbeddingProvider
from astrbot.core.provider.provider import (
Provider,
TTSProvider,
STTProvider,
EmbeddingProvider,
)
from astrbot.core.provider.entities import ProviderType
from astrbot.core.db import BaseDatabase
from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.provider.func_tool_manager import FuncCall
from astrbot.core.provider.func_tool_manager import FunctionToolManager
from astrbot.core.platform.astr_message_event import MessageSesion
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.manager import ProviderManager
from astrbot.core.platform import Platform
from astrbot.core.platform.manager import PlatformManager
from astrbot.core.platform_message_history_mgr import PlatformMessageHistoryManager
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
from astrbot.core.persona_mgr import PersonaManager
from .star import star_registry, StarMetadata, star_map
from .star_handler import star_handlers_registry, StarHandlerMetadata, EventType
from .filter.command import CommandFilter
@@ -22,6 +29,7 @@ from astrbot.core.star.filter.platform_adapter_type import (
PlatformAdapterType,
ADAPTER_NAME_2_TYPE,
)
from deprecated import deprecated
class Context:
@@ -29,19 +37,6 @@ class Context:
暴露给插件的接口上下文。
"""
_event_queue: Queue = None
"""事件队列。消息平台通过事件队列传递消息事件。"""
_config: AstrBotConfig = None
"""AstrBot 配置信息"""
_db: BaseDatabase = None
"""AstrBot 数据库"""
provider_manager: ProviderManager = None
platform_manager: PlatformManager = None
registered_web_apis: list = []
# back compatibility
@@ -53,18 +48,27 @@ class Context:
event_queue: Queue,
config: AstrBotConfig,
db: BaseDatabase,
provider_manager: ProviderManager = None,
platform_manager: PlatformManager = None,
conversation_manager: ConversationManager = None,
provider_manager: ProviderManager,
platform_manager: PlatformManager,
conversation_manager: ConversationManager,
message_history_manager: PlatformMessageHistoryManager,
persona_manager: PersonaManager,
astrbot_config_mgr: AstrBotConfigManager,
):
self._event_queue = event_queue
"""事件队列。消息平台通过事件队列传递消息事件。"""
self._config = config
"""AstrBot 默认配置"""
self._db = db
"""AstrBot 数据库"""
self.provider_manager = provider_manager
self.platform_manager = platform_manager
self.conversation_manager = conversation_manager
self.message_history_manager = message_history_manager
self.persona_manager = persona_manager
self.astrbot_config_mgr = astrbot_config_mgr
def get_registered_star(self, star_name: str) -> StarMetadata:
def get_registered_star(self, star_name: str) -> StarMetadata | None:
"""根据插件名获取插件的 Metadata"""
for star in star_registry:
if star.name == star_name:
@@ -74,7 +78,7 @@ class Context:
"""获取当前载入的所有插件 Metadata 的列表"""
return star_registry
def get_llm_tool_manager(self) -> FuncCall:
def get_llm_tool_manager(self) -> FunctionToolManager:
"""获取 LLM Tool Manager其用于管理注册的所有的 Function-calling tools"""
return self.provider_manager.llm_tools
@@ -84,40 +88,14 @@ class Context:
Returns:
如果没找到,会返回 False
"""
func_tool = self.provider_manager.llm_tools.get_func(name)
if func_tool is not None:
if func_tool.handler_module_path in star_map:
if not star_map[func_tool.handler_module_path].activated:
raise ValueError(
f"此函数调用工具所属的插件 {star_map[func_tool.handler_module_path].name} 已被禁用,请先在管理面板启用再激活此工具。"
)
func_tool.active = True
inactivated_llm_tools: list = sp.get("inactivated_llm_tools", [])
if name in inactivated_llm_tools:
inactivated_llm_tools.remove(name)
sp.put("inactivated_llm_tools", inactivated_llm_tools)
return True
return False
return self.provider_manager.llm_tools.activate_llm_tool(name, star_map)
def deactivate_llm_tool(self, name: str) -> bool:
"""停用一个已经注册的函数调用工具。
Returns:
如果没找到,会返回 False"""
func_tool = self.provider_manager.llm_tools.get_func(name)
if func_tool is not None:
func_tool.active = False
inactivated_llm_tools: list = sp.get("inactivated_llm_tools", [])
if name not in inactivated_llm_tools:
inactivated_llm_tools.append(name)
sp.put("inactivated_llm_tools", inactivated_llm_tools)
return True
return False
return self.provider_manager.llm_tools.deactivate_llm_tool(name)
def register_provider(self, provider: Provider):
"""
@@ -125,7 +103,7 @@ class Context:
"""
self.provider_manager.provider_insts.append(provider)
def get_provider_by_id(self, provider_id: str) -> Provider:
def get_provider_by_id(self, provider_id: str) -> Provider | None:
"""通过 ID 获取对应的 LLM Provider(Chat_Completion 类型)。"""
return self.provider_manager.inst_map.get(provider_id)
@@ -145,51 +123,49 @@ class Context:
"""获取所有用于 Embedding 任务的 Provider。"""
return self.provider_manager.embedding_provider_insts
def get_using_provider(self, umo: str = None) -> Provider:
def get_using_provider(self, umo: str | None = None) -> Provider | None:
"""
获取当前使用的用于文本生成任务的 LLM Provider(Chat_Completion 类型)。通过 /provider 指令切换。
Args:
umo(str): unified_message_origin 值,如果传入并且用户启用了提供商会话隔离,则使用该会话偏好的提供商。
"""
if umo and self._config["provider_settings"]["separate_provider"]:
perf = sp.get("session_provider_perf", {})
prov_id = perf.get(umo, {}).get(ProviderType.CHAT_COMPLETION.value, None)
if inst := self.provider_manager.inst_map.get(prov_id, None):
return inst
return self.provider_manager.curr_provider_inst
return self.provider_manager.get_using_provider(
provider_type=ProviderType.CHAT_COMPLETION,
umo=umo,
)
def get_using_tts_provider(self, umo: str = None) -> TTSProvider:
def get_using_tts_provider(self, umo: str | None = None) -> TTSProvider:
"""
获取当前使用的用于 TTS 任务的 Provider。
Args:
umo(str): unified_message_origin 值,如果传入,则使用该会话偏好的提供商。
"""
if umo and self._config["provider_settings"]["separate_provider"]:
perf = sp.get("session_provider_perf", {})
prov_id = perf.get(umo, {}).get(ProviderType.TEXT_TO_SPEECH.value, None)
if inst := self.provider_manager.inst_map.get(prov_id, None):
return inst
return self.provider_manager.curr_tts_provider_inst
return self.provider_manager.get_using_provider(
provider_type=ProviderType.TEXT_TO_SPEECH,
umo=umo,
)
def get_using_stt_provider(self, umo: str = None) -> STTProvider:
def get_using_stt_provider(self, umo: str | None = None) -> STTProvider:
"""
获取当前使用的用于 STT 任务的 Provider。
Args:
umo(str): unified_message_origin 值,如果传入,则使用该会话偏好的提供商。
"""
if umo and self._config["provider_settings"]["separate_provider"]:
perf = sp.get("session_provider_perf", {})
prov_id = perf.get(umo, {}).get(ProviderType.SPEECH_TO_TEXT.value, None)
if inst := self.provider_manager.inst_map.get(prov_id, None):
return inst
return self.provider_manager.curr_stt_provider_inst
return self.provider_manager.get_using_provider(
provider_type=ProviderType.SPEECH_TO_TEXT,
umo=umo,
)
def get_config(self) -> AstrBotConfig:
def get_config(self, umo: str | None = None) -> AstrBotConfig:
"""获取 AstrBot 的配置。"""
return self._config
if not umo:
# using default config
return self._config
else:
return self.astrbot_config_mgr.get_conf(umo)
def get_db(self) -> BaseDatabase:
"""获取 AstrBot 数据库。"""
@@ -201,9 +177,14 @@ class Context:
"""
return self._event_queue
def get_platform(self, platform_type: Union[PlatformAdapterType, str]) -> Platform:
@deprecated(version="4.0.0", reason="Use get_platform_inst instead")
def get_platform(
self, platform_type: Union[PlatformAdapterType, str]
) -> Platform | None:
"""
获取指定类型的平台适配器。
该方法已经过时,请使用 get_platform_inst 方法。(>= AstrBot v4.0.0)
"""
for platform in self.platform_manager.platform_insts:
name = platform.meta().name
@@ -217,6 +198,20 @@ class Context:
):
return platform
def get_platform_inst(self, platform_id: str) -> Platform | None:
"""
获取指定 ID 的平台适配器实例。
Args:
platform_id (str): 平台适配器的唯一标识符。你可以通过 event.get_platform_id() 获取。
Returns:
Platform: 平台适配器实例,如果未找到则返回 None。
"""
for platform in self.platform_manager.platform_insts:
if platform.meta().id == platform_id:
return platform
async def send_message(
self, session: Union[str, MessageSesion], message_chain: MessageChain
) -> bool:
@@ -240,7 +235,7 @@ class Context:
raise ValueError("不合法的 session 字符串: " + str(e))
for platform in self.platform_manager.platform_insts:
if platform.meta().name == session.platform_name:
if platform.meta().id == session.platform_name:
await platform.send_by_session(session, message_chain)
return True
return False

View File

@@ -18,6 +18,7 @@ class PlatformAdapterType(enum.Flag):
KOOK = enum.auto()
VOCECHAT = enum.auto()
WEIXIN_OFFICIAL_ACCOUNT = enum.auto()
SATORI = enum.auto()
ALL = (
AIOCQHTTP
| QQOFFICIAL
@@ -31,6 +32,7 @@ class PlatformAdapterType(enum.Flag):
| KOOK
| VOCECHAT
| WEIXIN_OFFICIAL_ACCOUNT
| SATORI
)
@@ -47,6 +49,7 @@ ADAPTER_NAME_2_TYPE = {
"wechatpadpro": PlatformAdapterType.WECHATPADPRO,
"vocechat": PlatformAdapterType.VOCECHAT,
"weixin_official_account": PlatformAdapterType.WEIXIN_OFFICIAL_ACCOUNT,
"satori": PlatformAdapterType.SATORI,
}

View File

@@ -11,6 +11,7 @@ from .star_handler import (
register_on_llm_request,
register_on_llm_response,
register_llm_tool,
register_agent,
register_on_decorating_result,
register_after_message_sent,
)
@@ -28,6 +29,7 @@ __all__ = [
"register_on_llm_request",
"register_on_llm_response",
"register_llm_tool",
"register_agent",
"register_on_decorating_result",
"register_after_message_sent",
]

View File

@@ -15,6 +15,11 @@ from ..filter.regex import RegexFilter
from typing import Awaitable
from astrbot.core.provider.func_tool_manager import SUPPORTED_TYPES
from astrbot.core.provider.register import llm_tools
from astrbot.core.agent.agent import Agent
from astrbot.core.agent.tool import FunctionTool
from astrbot.core.agent.handoff import HandoffTool
from astrbot.core.agent.hooks import BaseAgentRunHooks
from astrbot.core.astr_agent_context import AstrAgentContext
def get_handler_full_name(awaitable: Awaitable) -> str:
@@ -306,7 +311,7 @@ def register_on_llm_response(**kwargs):
return decorator
def register_llm_tool(name: str = None):
def register_llm_tool(name: str = None, **kwargs):
"""为函数调用function-calling / tools-use添加工具。
请务必按照以下格式编写一个工具包括函数注释AstrBot 会尝试解析该函数注释)
@@ -340,6 +345,9 @@ def register_llm_tool(name: str = None):
"""
name_ = name
registering_agent = None
if kwargs.get("registering_agent"):
registering_agent = kwargs["registering_agent"]
def decorator(awaitable: Awaitable):
llm_tool_name = name_ if name_ else awaitable.__name__
@@ -357,15 +365,69 @@ def register_llm_tool(name: str = None):
"description": arg.description,
}
)
md = get_handler_or_create(awaitable, EventType.OnCallingFuncToolEvent)
llm_tools.add_func(
llm_tool_name, args, docstring.description.strip(), md.handler
)
# print(llm_tool_name, registering_agent)
if not registering_agent:
md = get_handler_or_create(awaitable, EventType.OnCallingFuncToolEvent)
llm_tools.add_func(
llm_tool_name, args, docstring.description.strip(), md.handler
)
else:
assert isinstance(registering_agent, RegisteringAgent)
# print(f"Registering tool {llm_tool_name} for agent", registering_agent._agent.name)
if registering_agent._agent.tools is None:
registering_agent._agent.tools = []
registering_agent._agent.tools.append(llm_tools.spec_to_func(
llm_tool_name, args, docstring.description.strip(), awaitable
))
return awaitable
return decorator
class RegisteringAgent:
"""用于 Agent 注册"""
def llm_tool(self, *args, **kwargs):
kwargs["registering_agent"] = self
return register_llm_tool(*args, **kwargs)
def __init__(self, agent: Agent[AstrAgentContext]):
self._agent = agent
def register_agent(
name: str,
instruction: str,
tools: list[str | FunctionTool] = None,
run_hooks: BaseAgentRunHooks[AstrAgentContext] = None,
):
"""注册一个 Agent
Args:
name: Agent 的名称
instruction: Agent 的指令
tools: Agent 使用的工具列表
run_hooks: Agent 运行时的钩子函数
"""
tools_ = tools or []
def decorator(awaitable: Awaitable):
AstrAgent = Agent[AstrAgentContext]
agent = AstrAgent(
name=name,
instructions=instruction,
tools=tools_,
run_hooks=run_hooks or BaseAgentRunHooks[AstrAgentContext](),
)
handoff_tool = HandoffTool(agent=agent)
handoff_tool.handler=awaitable
llm_tools.func_list.append(handoff_tool)
return RegisteringAgent(agent)
return decorator
def register_on_decorating_result(**kwargs):
"""在发送消息前的事件"""

View File

@@ -2,8 +2,6 @@
会话服务管理器 - 负责管理每个会话的LLM、TTS等服务的启停状态
"""
from typing import Dict
from astrbot.core import logger, sp
from astrbot.core.platform.astr_message_event import AstrMessageEvent
@@ -26,8 +24,9 @@ class SessionServiceManager:
bool: True表示启用False表示禁用
"""
# 获取会话服务配置
session_config = sp.get("session_service_config", {}) or {}
session_services = session_config.get(session_id, {})
session_services = sp.get(
"session_service_config", {}, scope="umo", scope_id=session_id
)
# 如果配置了该会话的LLM状态返回该状态
llm_enabled = session_services.get("llm_enabled")
@@ -45,16 +44,13 @@ class SessionServiceManager:
session_id: 会话ID (unified_msg_origin)
enabled: True表示启用False表示禁用
"""
# 获取当前配置
session_config = sp.get("session_service_config", {}) or {}
if session_id not in session_config:
session_config[session_id] = {}
# 设置LLM状态
session_config[session_id]["llm_enabled"] = enabled
# 保存配置
sp.put("session_service_config", session_config)
session_config = (
sp.get("session_service_config", {}, scope="umo", scope_id=session_id) or {}
)
session_config["llm_enabled"] = enabled
sp.put(
"session_service_config", session_config, scope="umo", scope_id=session_id
)
logger.info(
f"会话 {session_id} 的LLM状态已更新为: {'启用' if enabled else '禁用'}"
@@ -88,8 +84,9 @@ class SessionServiceManager:
bool: True表示启用False表示禁用
"""
# 获取会话服务配置
session_config = sp.get("session_service_config", {}) or {}
session_services = session_config.get(session_id, {})
session_services = sp.get(
"session_service_config", {}, scope="umo", scope_id=session_id
)
# 如果配置了该会话的TTS状态返回该状态
tts_enabled = session_services.get("tts_enabled")
@@ -107,16 +104,13 @@ class SessionServiceManager:
session_id: 会话ID (unified_msg_origin)
enabled: True表示启用False表示禁用
"""
# 获取当前配置
session_config = sp.get("session_service_config", {}) or {}
if session_id not in session_config:
session_config[session_id] = {}
# 设置TTS状态
session_config[session_id]["tts_enabled"] = enabled
# 保存配置
sp.put("session_service_config", session_config)
session_config = (
sp.get("session_service_config", {}, scope="umo", scope_id=session_id) or {}
)
session_config["tts_enabled"] = enabled
sp.put(
"session_service_config", session_config, scope="umo", scope_id=session_id
)
logger.info(
f"会话 {session_id} 的TTS状态已更新为: {'启用' if enabled else '禁用'}"
@@ -150,8 +144,9 @@ class SessionServiceManager:
bool: True表示启用False表示禁用
"""
# 获取会话服务配置
session_config = sp.get("session_service_config", {}) or {}
session_services = session_config.get(session_id, {})
session_services = sp.get(
"session_service_config", {}, scope="umo", scope_id=session_id
)
# 如果配置了该会话的整体状态,返回该状态
session_enabled = session_services.get("session_enabled")
@@ -169,16 +164,13 @@ class SessionServiceManager:
session_id: 会话ID (unified_msg_origin)
enabled: True表示启用False表示禁用
"""
# 获取当前配置
session_config = sp.get("session_service_config", {}) or {}
if session_id not in session_config:
session_config[session_id] = {}
# 设置会话整体状态
session_config[session_id]["session_enabled"] = enabled
# 保存配置
sp.put("session_service_config", session_config)
session_config = (
sp.get("session_service_config", {}, scope="umo", scope_id=session_id) or {}
)
session_config["session_enabled"] = enabled
sp.put(
"session_service_config", session_config, scope="umo", scope_id=session_id
)
logger.info(
f"会话 {session_id} 的整体状态已更新为: {'启用' if enabled else '禁用'}"
@@ -202,7 +194,7 @@ class SessionServiceManager:
# =============================================================================
@staticmethod
def get_session_custom_name(session_id: str) -> str:
def get_session_custom_name(session_id: str) -> str | None:
"""获取会话的自定义名称
Args:
@@ -211,8 +203,9 @@ class SessionServiceManager:
Returns:
str: 自定义名称如果没有设置则返回None
"""
session_config = sp.get("session_service_config", {}) or {}
session_services = session_config.get(session_id, {})
session_services = sp.get(
"session_service_config", {}, scope="umo", scope_id=session_id
)
return session_services.get("custom_name")
@staticmethod
@@ -223,20 +216,17 @@ class SessionServiceManager:
session_id: 会话ID (unified_msg_origin)
custom_name: 自定义名称,可以为空字符串来清除名称
"""
# 获取当前配置
session_config = sp.get("session_service_config", {}) or {}
if session_id not in session_config:
session_config[session_id] = {}
# 设置自定义名称
session_config = (
sp.get("session_service_config", {}, scope="umo", scope_id=session_id) or {}
)
if custom_name and custom_name.strip():
session_config[session_id]["custom_name"] = custom_name.strip()
session_config["custom_name"] = custom_name.strip()
else:
# 如果传入空名称,则删除自定义名称
session_config[session_id].pop("custom_name", None)
# 保存配置
sp.put("session_service_config", session_config)
session_config.pop("custom_name", None)
sp.put(
"session_service_config", session_config, scope="umo", scope_id=session_id
)
logger.info(
f"会话 {session_id} 的自定义名称已更新为: {custom_name.strip() if custom_name and custom_name.strip() else '已清除'}"
@@ -258,36 +248,3 @@ class SessionServiceManager:
# 如果没有自定义名称返回session_id的最后一段
return session_id.split(":")[2] if session_id.count(":") >= 2 else session_id
# =============================================================================
# 通用配置方法
# =============================================================================
@staticmethod
def get_session_service_config(session_id: str) -> Dict[str, bool]:
"""获取指定会话的服务配置
Args:
session_id: 会话ID (unified_msg_origin)
Returns:
Dict[str, bool]: 包含session_enabled、llm_enabled、tts_enabled的字典
"""
session_config = sp.get("session_service_config", {}) or {}
return session_config.get(
session_id,
{
"session_enabled": True, # 默认启用
"llm_enabled": True, # 默认启用
"tts_enabled": True, # 默认启用
},
)
@staticmethod
def get_all_session_configs() -> Dict[str, Dict[str, bool]]:
"""获取所有会话的服务配置
Returns:
Dict[str, Dict[str, bool]]: 所有会话的服务配置
"""
return sp.get("session_service_config", {}) or {}

View File

@@ -22,7 +22,9 @@ class SessionPluginManager:
bool: True表示启用False表示禁用
"""
# 获取会话插件配置
session_plugin_config = sp.get("session_plugin_config", {}) or {}
session_plugin_config = sp.get(
"session_plugin_config", {}, scope="umo", scope_id=session_id
)
session_config = session_plugin_config.get(session_id, {})
enabled_plugins = session_config.get("enabled_plugins", [])
@@ -51,7 +53,9 @@ class SessionPluginManager:
enabled: True表示启用False表示禁用
"""
# 获取当前配置
session_plugin_config = sp.get("session_plugin_config", {}) or {}
session_plugin_config = sp.get(
"session_plugin_config", {}, scope="umo", scope_id=session_id
)
if session_id not in session_plugin_config:
session_plugin_config[session_id] = {
"enabled_plugins": [],
@@ -79,7 +83,9 @@ class SessionPluginManager:
session_config["enabled_plugins"] = enabled_plugins
session_config["disabled_plugins"] = disabled_plugins
session_plugin_config[session_id] = session_config
sp.put("session_plugin_config", session_plugin_config)
sp.put(
"session_plugin_config", session_plugin_config, scope="umo", scope_id=session_id
)
logger.info(
f"会话 {session_id} 的插件 {plugin_name} 状态已更新为: {'启用' if enabled else '禁用'}"
@@ -95,7 +101,9 @@ class SessionPluginManager:
Returns:
Dict[str, List[str]]: 包含enabled_plugins和disabled_plugins的字典
"""
session_plugin_config = sp.get("session_plugin_config", {}) or {}
session_plugin_config = sp.get(
"session_plugin_config", {}, scope="umo", scope_id=session_id
)
return session_plugin_config.get(
session_id, {"enabled_plugins": [], "disabled_plugins": []}
)

View File

@@ -56,32 +56,8 @@ class StarMetadata:
star_handler_full_names: list[str] = field(default_factory=list)
"""注册的 Handler 的全名列表"""
supported_platforms: dict[str, bool] = field(default_factory=dict)
"""插件支持的平台ID字典key为平台IDvalue为是否支持"""
def __str__(self) -> str:
return f"Plugin {self.name} ({self.version}) by {self.author}: {self.desc}"
def __repr__(self) -> str:
return f"Plugin {self.name} ({self.version}) by {self.author}: {self.desc}"
def update_platform_compatibility(self, plugin_enable_config: dict) -> None:
"""更新插件支持的平台列表
Args:
plugin_enable_config: 平台插件启用配置即platform_settings.plugin_enable配置项
"""
if not plugin_enable_config:
return
# 清空之前的配置
self.supported_platforms.clear()
# 遍历所有平台配置
for platform_id, plugins in plugin_enable_config.items():
# 检查该插件在当前平台的配置
if self.name in plugins:
self.supported_platforms[platform_id] = plugins[self.name]
else:
# 如果没有明确配置,默认为启用
self.supported_platforms[platform_id] = True

View File

@@ -7,6 +7,7 @@ from .star import star_map
T = TypeVar("T", bound="StarHandlerMetadata")
class StarHandlerRegistry(Generic[T]):
def __init__(self):
self.star_handlers_map: Dict[str, StarHandlerMetadata] = {}
@@ -26,7 +27,10 @@ class StarHandlerRegistry(Generic[T]):
print(handler.handler_full_name)
def get_handlers_by_event_type(
self, event_type: EventType, only_activated=True, platform_id=None
self,
event_type: EventType,
only_activated=True,
plugins_name: list[str] | None = None,
) -> List[StarHandlerMetadata]:
handlers = []
for handler in self._handlers:
@@ -36,8 +40,15 @@ class StarHandlerRegistry(Generic[T]):
plugin = star_map.get(handler.handler_module_path)
if not (plugin and plugin.activated):
continue
if platform_id and event_type != EventType.OnAstrBotLoadedEvent:
if not handler.is_enabled_for_platform(platform_id):
if plugins_name is not None and plugins_name != ["*"]:
plugin = star_map.get(handler.handler_module_path)
if not plugin:
continue
if (
plugin.name not in plugins_name
and event_type != EventType.OnAstrBotLoadedEvent
and not plugin.reserved
):
continue
handlers.append(handler)
return handlers
@@ -49,7 +60,8 @@ class StarHandlerRegistry(Generic[T]):
self, module_name: str
) -> List[StarHandlerMetadata]:
return [
handler for handler in self._handlers
handler
for handler in self._handlers
if handler.handler_module_path == module_name
]
@@ -67,6 +79,7 @@ class StarHandlerRegistry(Generic[T]):
def __len__(self):
return len(self._handlers)
star_handlers_registry = StarHandlerRegistry()
@@ -119,32 +132,3 @@ class StarHandlerMetadata:
return self.extras_configs.get("priority", 0) < other.extras_configs.get(
"priority", 0
)
def is_enabled_for_platform(self, platform_id: str) -> bool:
"""检查插件是否在指定平台启用
Args:
platform_id: 平台ID这是从event.get_platform_id()获取的,用于唯一标识平台实例
Returns:
bool: 是否启用True表示启用False表示禁用
"""
plugin = star_map.get(self.handler_module_path)
# 如果插件元数据不存在,默认允许执行
if not plugin or not plugin.name:
return True
# 先检查插件是否被激活
if not plugin.activated:
return False
# 直接使用StarMetadata中缓存的supported_platforms判断平台兼容性
if (
hasattr(plugin, "supported_platforms")
and platform_id in plugin.supported_platforms
):
return plugin.supported_platforms[platform_id]
# 如果没有缓存数据,默认允许执行
return True

View File

@@ -22,6 +22,7 @@ from astrbot.core.utils.astrbot_path import (
get_astrbot_plugin_path,
)
from astrbot.core.utils.io import remove_dir
from astrbot.core.agent.handoff import HandoffTool, FunctionTool
from . import StarMetadata
from .context import Context
@@ -336,30 +337,8 @@ class PluginManager:
result = await self.load(specified_module_path)
# 更新所有插件的平台兼容性
await self.update_all_platform_compatibility()
return result
async def update_all_platform_compatibility(self):
"""更新所有插件的平台兼容性设置"""
# 获取最新的平台插件启用配置
plugin_enable_config = self.config.get("platform_settings", {}).get(
"plugin_enable", {}
)
logger.debug(
f"更新所有插件的平台兼容性设置,平台数量: {len(plugin_enable_config)}"
)
# 遍历所有插件,更新平台兼容性
for plugin in self.context.get_all_stars():
plugin.update_platform_compatibility(plugin_enable_config)
logger.debug(
f"插件 {plugin.name} 支持的平台: {list(plugin.supported_platforms.keys())}"
)
return True
async def load(self, specified_module_path=None, specified_dir_name=None):
"""载入插件。
当 specified_module_path 或者 specified_dir_name 不为 None 时,只载入指定的插件。
@@ -373,10 +352,9 @@ class PluginManager:
- success (bool): 是否全部加载成功
- error_message (str|None): 错误信息,成功时为 None
"""
inactivated_plugins: list = sp.get("inactivated_plugins", [])
inactivated_llm_tools: list = sp.get("inactivated_llm_tools", [])
alter_cmd = sp.get("alter_cmd", {})
inactivated_plugins = await sp.global_get("inactivated_plugins", [])
inactivated_llm_tools = await sp.global_get("inactivated_llm_tools", [])
alter_cmd = await sp.global_get("alter_cmd", {})
plugin_modules = self._get_plugin_modules()
if plugin_modules is None:
@@ -480,12 +458,6 @@ class PluginManager:
metadata.root_dir_name = root_dir_name
metadata.reserved = reserved
# 更新插件的平台兼容性
plugin_enable_config = self.config.get("platform_settings", {}).get(
"plugin_enable", {}
)
metadata.update_platform_compatibility(plugin_enable_config)
assert metadata.module_path is not None, (
f"插件 {metadata.name} 的模块路径为空。"
)
@@ -503,17 +475,27 @@ class PluginManager:
)
# 绑定 llm_tool handler
for func_tool in llm_tools.func_list:
if (
func_tool.handler
and func_tool.handler.__module__ == metadata.module_path
):
func_tool.handler_module_path = metadata.module_path
func_tool.handler = functools.partial(
func_tool.handler,
metadata.star_cls, # type: ignore
)
if func_tool.name in inactivated_llm_tools:
func_tool.active = False
if isinstance(func_tool, HandoffTool):
need_apply = []
sub_tools = func_tool.agent.tools
for sub_tool in sub_tools:
if isinstance(sub_tool, FunctionTool):
need_apply.append(sub_tool)
else:
need_apply = [func_tool]
for ft in need_apply:
if (
ft.handler
and ft.handler.__module__ == metadata.module_path
):
ft.handler_module_path = metadata.module_path
ft.handler = functools.partial(
ft.handler,
metadata.star_cls, # type: ignore
)
if ft.name in inactivated_llm_tools:
ft.active = False
else:
# v3.4.0 以前的方式注册插件
@@ -776,12 +758,12 @@ class PluginManager:
await self._terminate_plugin(plugin)
# 加入到 shared_preferences 中
inactivated_plugins: list = sp.get("inactivated_plugins", [])
inactivated_plugins: list = await sp.global_get("inactivated_plugins", [])
if plugin.module_path not in inactivated_plugins:
inactivated_plugins.append(plugin.module_path)
inactivated_llm_tools: list = list(
set(sp.get("inactivated_llm_tools", []))
set(await sp.global_get("inactivated_llm_tools", []))
) # 后向兼容
# 禁用插件启用的 llm_tool
@@ -791,8 +773,8 @@ class PluginManager:
if func_tool.name not in inactivated_llm_tools:
inactivated_llm_tools.append(func_tool.name)
sp.put("inactivated_plugins", inactivated_plugins)
sp.put("inactivated_llm_tools", inactivated_llm_tools)
await sp.global_put("inactivated_plugins", inactivated_plugins)
await sp.global_put("inactivated_llm_tools", inactivated_llm_tools)
plugin.activated = False
@@ -818,11 +800,11 @@ class PluginManager:
async def turn_on_plugin(self, plugin_name: str):
plugin = self.context.get_registered_star(plugin_name)
inactivated_plugins: list = sp.get("inactivated_plugins", [])
inactivated_llm_tools: list = sp.get("inactivated_llm_tools", [])
inactivated_plugins: list = await sp.global_get("inactivated_plugins", [])
inactivated_llm_tools: list = await sp.global_get("inactivated_llm_tools", [])
if plugin.module_path in inactivated_plugins:
inactivated_plugins.remove(plugin.module_path)
sp.put("inactivated_plugins", inactivated_plugins)
await sp.global_put("inactivated_plugins", inactivated_plugins)
# 启用插件启用的 llm_tool
for func_tool in llm_tools.func_list:
@@ -832,7 +814,7 @@ class PluginManager:
):
inactivated_llm_tools.remove(func_tool.name)
func_tool.active = True
sp.put("inactivated_llm_tools", inactivated_llm_tools)
await sp.global_put("inactivated_llm_tools", inactivated_llm_tools)
await self.reload(plugin_name)

View File

@@ -1,15 +1,37 @@
"""
插件开发工具集
封装了许多常用的操作,方便插件开发者使用
说明:
主动发送消息: send_message(session, message_chain)
根据 session (unified_msg_origin) 主动发送消息, 前提是需要提前获得或构造 session
根据id直接主动发送消息: send_message_by_id(type, id, message_chain, platform="aiocqhttp")
根据 id (例如 qq 号, 群号等) 直接, 主动地发送消息
以上两种方式需要构造消息链, 也就是消息组件的列表
构造事件:
首先需要构造一个 AstrBotMessage 对象, 使用 create_message 方法
然后使用 create_event 方法提交事件到指定平台
"""
import inspect
import os
import uuid
from pathlib import Path
from typing import Union, Awaitable, List, Optional, ClassVar
from astrbot.core.message.components import BaseMessageComponent
from astrbot.core.message.message_event_result import MessageChain
from astrbot.api.platform import MessageMember, AstrBotMessage
from astrbot.api.platform import MessageMember, AstrBotMessage, MessageType
from astrbot.core.platform.astr_message_event import MessageSesion
from astrbot.core.star.context import Context
from astrbot.core.star.star import star_map
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from astrbot.core.platform.sources.aiocqhttp.aiocqhttp_message_event import AiocqhttpMessageEvent
from astrbot.core.platform.sources.aiocqhttp.aiocqhttp_platform_adapter import AiocqhttpAdapter
class StarTools:
"""
@@ -49,42 +71,76 @@ class StarTools:
Note:
qq_official(QQ官方API平台)不支持此方法
"""
if cls._context is None:
raise ValueError("StarTools not initialized")
return await cls._context.send_message(session, message_chain)
@classmethod
async def send_message_by_id(
cls, type: str, id: str, message_chain: MessageChain, platform: str = "aiocqhttp"
):
"""
根据 id(例如qq号, 群号等) 直接, 主动地发送消息
Args:
type (str): 消息类型, 可选: PrivateMessage, GroupMessage
id (str): 目标ID, 例如QQ号, 群号等
message_chain (MessageChain): 消息链
platform (str): 可选的平台名称,默认平台(aiocqhttp), 目前只支持 aiocqhttp
"""
if cls._context is None:
raise ValueError("StarTools not initialized")
platforms = cls._context.platform_manager.get_insts()
if platform == "aiocqhttp":
adapter = next((p for p in platforms if isinstance(p, AiocqhttpAdapter)), None)
if adapter is None:
raise ValueError("未找到适配器: AiocqhttpAdapter")
await AiocqhttpMessageEvent.send_message(
bot=adapter.bot,
message_chain=message_chain,
is_group=(type == "GroupMessage"),
session_id=id,
)
else:
raise ValueError(f"不支持的平台: {platform}")
@classmethod
async def create_message(
cls,
type: str,
self_id: str,
session_id: str,
message_id: str,
sender: MessageMember,
message: List[BaseMessageComponent],
message_str: str,
raw_message: object,
group_id: str = "",
):
message_id: str = "",
raw_message: object = None,
group_id: str = ""
) -> AstrBotMessage:
"""
创建一个AstrBot消息对象
Args:
type (str): 消息类型
type (str): 消息类型, 例如 "GroupMessage" "FriendMessage" "OtherMessage"
self_id (str): 机器人自身ID
session_id (str): 会话ID(通常为用户ID)(QQ号, 群号等)
message_id (str): 消息ID
sender (MessageMember): 发送者信息
message (List[BaseMessageComponent]): 消息组件列表
message_str (str): 消息字符串
raw_message (object): 原始消息对象
sender (MessageMember): 发送者信息, 例如 MessageMember(user_id="123456", nickname="昵称")
message (List[BaseMessageComponent]): 消息组件列表, 也就是消息链, 这个不会发给 llm, 但是会经过其他处理
message_str (str): 消息字符串, 也就是纯文本消息, 也就是发送给 llm 的消息, 与消息链一致
message_id (str): 消息ID, 构造消息时可以随意填写也可不填
raw_message (object): 原始消息对象, 可以随意填写也可不填
group_id (str, optional): 群组ID, 如果为私聊则为空. Defaults to "".
Returns:
AstrBotMessage: 创建的消息对象
"""
abm = AstrBotMessage()
abm.type = type
abm.type = MessageType(type)
abm.self_id = self_id
abm.session_id = session_id
if message_id == "":
message_id = uuid.uuid4().hex
abm.message_id = message_id
abm.sender = sender
abm.message = message
@@ -93,13 +149,38 @@ class StarTools:
abm.group_id = group_id
return abm
# todo: 添加构造事件的方法
# async def create_event(
# self, platform: str, umo: str, sender_id: str, session_id: str
# ):
# platform = self._context.get_platform(platform)
@classmethod
async def create_event(
cls, abm: AstrBotMessage, platform: str = "aiocqhttp", is_wake: bool = True
# todo: 添加找到对应平台并提交对应事件的方法
) -> None:
"""
创建并提交事件到指定平台
当有需要创建一个事件, 触发某些处理流程时, 使用该方法
Args:
abm (AstrBotMessage): 要提交的消息对象, 请先使用 create_message 创建
platform (str): 可选的平台名称,默认平台(aiocqhttp), 目前只支持 aiocqhttp
is_wake (bool): 是否标记为唤醒事件, 默认为 True, 只有唤醒事件才会被 llm 响应
"""
if cls._context is None:
raise ValueError("StarTools not initialized")
platforms = cls._context.platform_manager.get_insts()
if platform == "aiocqhttp":
adapter = next((p for p in platforms if isinstance(p, AiocqhttpAdapter)), None)
if adapter is None:
raise ValueError("未找到适配器: AiocqhttpAdapter")
event = AiocqhttpMessageEvent(
message_str=abm.message_str,
message_obj=abm,
platform_meta=adapter.metadata,
session_id=abm.session_id,
bot=adapter.bot,
)
event.is_wake = is_wake
adapter.commit_event(event)
else:
raise ValueError(f"不支持的平台: {platform}")
@classmethod
def activate_llm_tool(cls, name: str) -> bool:
@@ -110,6 +191,8 @@ class StarTools:
Args:
name (str): 工具名称
"""
if cls._context is None:
raise ValueError("StarTools not initialized")
return cls._context.activate_llm_tool(name)
@classmethod
@@ -120,6 +203,8 @@ class StarTools:
Args:
name (str): 工具名称
"""
if cls._context is None:
raise ValueError("StarTools not initialized")
return cls._context.deactivate_llm_tool(name)
@classmethod
@@ -135,6 +220,8 @@ class StarTools:
desc (str): 工具描述
func_obj (Awaitable): 函数对象,必须是异步函数
"""
if cls._context is None:
raise ValueError("StarTools not initialized")
cls._context.register_llm_tool(name, func_args, desc, func_obj)
@classmethod
@@ -146,6 +233,8 @@ class StarTools:
Args:
name (str): 工具名称
"""
if cls._context is None:
raise ValueError("StarTools not initialized")
cls._context.unregister_llm_tool(name)
@classmethod
@@ -169,8 +258,11 @@ class StarTools:
- 创建目录失败权限不足或其他IO错误
"""
if not plugin_name:
frame = inspect.currentframe().f_back
module = inspect.getmodule(frame)
frame = inspect.currentframe()
module = None
if frame:
frame = frame.f_back
module = inspect.getmodule(frame)
if not module:
raise RuntimeError("无法获取调用者模块信息")
@@ -182,6 +274,9 @@ class StarTools:
plugin_name = metadata.name
if not plugin_name:
raise ValueError("无法获取插件名称")
data_dir = Path(os.path.join(get_astrbot_data_path(), "plugin_data", plugin_name))
try:

View File

@@ -56,9 +56,7 @@ class AstrBotUpdator(RepoZipUpdator):
try:
if "astrbot" in os.path.basename(sys.argv[0]): # 兼容cli
if os.name == "nt":
args = [
f'"{arg}"' if " " in arg else arg for arg in sys.argv[1:]
]
args = [f'"{arg}"' if " " in arg else arg for arg in sys.argv[1:]]
else:
args = sys.argv[1:]
os.execl(sys.executable, py, "-m", "astrbot.cli.__main__", *args)
@@ -68,9 +66,13 @@ class AstrBotUpdator(RepoZipUpdator):
logger.error(f"重启失败({py}, {e}),请尝试手动重启。")
raise e
async def check_update(self, url: str, current_version: str) -> ReleaseInfo:
async def check_update(
self, url: str, current_version: str, consider_prerelease: bool = True
) -> ReleaseInfo:
"""检查更新"""
return await super().check_update(self.ASTRBOT_RELEASE_API, VERSION)
return await super().check_update(
self.ASTRBOT_RELEASE_API, VERSION, consider_prerelease
)
async def get_releases(self) -> list:
return await self.fetch_release_info(self.ASTRBOT_RELEASE_API)
@@ -89,7 +91,6 @@ class AstrBotUpdator(RepoZipUpdator):
file_url = update_data[0]["zipball_url"]
elif str(version).startswith("v"):
# 更新到指定版本
logger.info(f"正在更新到指定版本: {version}")
for data in update_data:
if data["tag_name"] == version:
file_url = data["zipball_url"]
@@ -98,8 +99,8 @@ class AstrBotUpdator(RepoZipUpdator):
else:
if len(str(version)) != 40:
raise Exception("commit hash 长度不正确,应为 40")
logger.info(f"正在尝试更新到指定 commit: {version}")
file_url = "https://github.com/Soulter/AstrBot/archive/" + version + ".zip"
file_url = f"https://github.com/Soulter/AstrBot/archive/{version}.zip"
logger.info(f"准备更新至指定版本的 AstrBot Core: {version}")
if proxy:
proxy = proxy.removesuffix("/")
@@ -107,6 +108,7 @@ class AstrBotUpdator(RepoZipUpdator):
try:
await download_file(file_url, "temp.zip")
logger.info("下载 AstrBot Core 更新文件完成,正在执行解压...")
self.unzip_file("temp.zip", self.MAIN_PATH)
except BaseException as e:
raise e

View File

@@ -8,6 +8,7 @@ import base64
import zipfile
import uuid
import psutil
import logging
import certifi
@@ -16,6 +17,8 @@ from typing import Union
from PIL import Image
from .astrbot_path import get_astrbot_data_path
logger = logging.getLogger("astrbot")
def on_error(func, path, exc_info):
"""
@@ -212,19 +215,50 @@ async def get_dashboard_version():
return None
async def download_dashboard(path: str = None, extract_path: str = "data"):
async def download_dashboard(
path: str | None = None,
extract_path: str = "data",
latest: bool = True,
version: str | None = None,
proxy: str | None = None,
):
"""下载管理面板文件"""
if path is None:
path = os.path.join(get_astrbot_data_path(), "dashboard.zip")
dashboard_release_url = "https://astrbot-registry.soulter.top/download/astrbot-dashboard/latest/dist.zip"
try:
await download_file(dashboard_release_url, path, show_progress=True)
except BaseException as _:
dashboard_release_url = (
"https://github.com/Soulter/AstrBot/releases/latest/download/dist.zip"
if latest or len(str(version)) != 40:
logger.info(f"准备下载 {version} 发行版本的 AstrBot WebUI 文件")
ver_name = "latest" if latest else version
dashboard_release_url = f"https://astrbot-registry.soulter.top/download/astrbot-dashboard/{ver_name}/dist.zip"
try:
await download_file(dashboard_release_url, path, show_progress=True)
except BaseException as _:
if latest:
dashboard_release_url = "https://github.com/Soulter/AstrBot/releases/latest/download/dist.zip"
else:
dashboard_release_url = f"https://github.com/Soulter/AstrBot/releases/download/{version}/dist.zip"
if proxy:
dashboard_release_url = f"{proxy}/{dashboard_release_url}"
await download_file(dashboard_release_url, path, show_progress=True)
else:
logger.info(f"准备下载指定版本的 AstrBot WebUI: {version}")
url = (
"https://api.github.com/repos/AstrBotDevs/astrbot-release-harbour/releases"
)
await download_file(dashboard_release_url, path, show_progress=True)
print("解压管理面板文件中...")
if proxy:
url = f"{proxy}/{url}"
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get(url) as resp:
if resp.status == 200:
releases = await resp.json()
for release in releases:
if version in release["tag_name"]:
download_url = release["assets"][0]["browser_download_url"]
await download_file(download_url, path, show_progress=True)
else:
logger.warning(f"未找到指定的版本的 Dashboard 构建文件: {version}")
return
with zipfile.ZipFile(path, "r") as z:
z.extractall(extract_path)

View File

@@ -58,9 +58,10 @@ class Metric:
pass
try:
if "adapter_name" in kwargs:
db_helper.insert_platform_metrics({kwargs["adapter_name"]: 1})
if "llm_name" in kwargs:
db_helper.insert_llm_metrics({kwargs["llm_name"]: 1})
await db_helper.insert_platform_stats(
platform_id=kwargs["adapter_name"],
platform_type=kwargs.get("adapter_type", "unknown"),
)
except Exception as e:
logger.error(f"保存指标到数据库失败: {e}")
pass

View File

@@ -1,43 +1,180 @@
import json
from astrbot.core.db import BaseDatabase
from astrbot.core.db.po import Preference
import threading
import asyncio
import os
from typing import TypeVar
from typing import TypeVar, Any, overload
from .astrbot_path import get_astrbot_data_path
_VT = TypeVar("_VT")
class SharedPreferences:
def __init__(self, path=None):
if path is None:
path = os.path.join(get_astrbot_data_path(), "shared_preferences.json")
self.path = path
self._data = self._load_preferences()
def __init__(self, db_helper: BaseDatabase, json_storage_path=None):
if json_storage_path is None:
json_storage_path = os.path.join(
get_astrbot_data_path(), "shared_preferences.json"
)
self.path = json_storage_path
self.db_helper = db_helper
def _load_preferences(self):
if os.path.exists(self.path):
try:
with open(self.path, "r") as f:
return json.load(f)
except json.JSONDecodeError:
os.remove(self.path)
return {}
self._sync_loop = asyncio.new_event_loop()
t = threading.Thread(target=self._sync_loop.run_forever, daemon=True)
t.start()
def _save_preferences(self):
with open(self.path, "w") as f:
json.dump(self._data, f, indent=4, ensure_ascii=False)
f.flush()
async def get_async(
self,
scope: str,
scope_id: str,
key: str,
default: _VT = None,
) -> _VT:
"""获取指定范围和键的偏好设置"""
if scope_id is not None and key is not None:
result = await self.db_helper.get_preference(scope, scope_id, key)
if result:
ret = result.value["val"]
else:
ret = default
return ret
else:
raise ValueError(
"scope_id and key cannot be None when getting a specific preference."
)
def get(self, key, default: _VT = None) -> _VT:
return self._data.get(key, default)
async def range_get_async(
self, scope: str, scope_id: str | None = None, key: str | None = None
) -> list[Preference]:
"""获取指定范围的偏好设置
Note: 返回 Preference 列表,其中的 value 属性是一个 dictvalue["val"] 为值。scope_id 和 key 可以为 None这时返回该范围下所有的偏好设置。
"""
ret = await self.db_helper.get_preferences(scope, scope_id, key)
return ret
def put(self, key, value):
self._data[key] = value
self._save_preferences()
@overload
async def session_get(
self, umo: None, key: str, default: Any = None
) -> list[Preference]: ...
def remove(self, key):
if key in self._data:
del self._data[key]
self._save_preferences()
@overload
async def session_get(
self, umo: str, key: None, default: Any = None
) -> list[Preference]: ...
def clear(self):
self._data.clear()
self._save_preferences()
@overload
async def session_get(
self, umo: None, key: None, default: Any = None
) -> list[Preference]: ...
async def session_get(
self, umo: str | None, key: str | None = None, default: _VT = None
) -> _VT | list[Preference]:
"""获取会话范围的偏好设置
Note: 当 scope_id 或者 key 为 None返回 Preference 列表,其中的 value 属性是一个 dictvalue["val"] 为值。
"""
if umo is None or key is None:
return await self.range_get_async("umo", umo, key)
return await self.get_async("umo", umo, key, default)
@overload
async def global_get(self, key: None, default: Any = None) -> list[Preference]: ...
@overload
async def global_get(self, key: str, default: _VT = None) -> _VT: ...
async def global_get(
self, key: str | None, default: _VT = None
) -> _VT | list[Preference]:
"""获取全局范围的偏好设置
Note: 当 scope_id 或者 key 为 None返回 Preference 列表,其中的 value 属性是一个 dictvalue["val"] 为值。
"""
if key is None:
return await self.range_get_async("global", "global", key)
return await self.get_async("global", "global", key, default)
async def put_async(self, scope: str, scope_id: str, key: str, value: Any):
"""设置指定范围和键的偏好设置"""
await self.db_helper.insert_preference_or_update(
scope, scope_id, key, {"val": value}
)
async def session_put(self, umo: str, key: str, value: Any):
await self.put_async("umo", umo, key, value)
async def global_put(self, key: str, value: Any):
await self.put_async("global", "global", key, value)
async def remove_async(self, scope: str, scope_id: str, key: str):
"""删除指定范围和键的偏好设置"""
await self.db_helper.remove_preference(scope, scope_id, key)
async def session_remove(self, umo: str, key: str):
await self.remove_async("umo", umo, key)
async def global_remove(self, key: str):
"""删除全局偏好设置"""
await self.remove_async("global", "global", key)
async def clear_async(self, scope: str, scope_id: str):
"""清空指定范围的所有偏好设置"""
await self.db_helper.clear_preferences(scope, scope_id)
# ====
# DEPRECATED METHODS
# ====
def get(
self,
key: str,
default: _VT = None,
scope: str | None = None,
scope_id: str | None = "",
) -> _VT:
"""获取偏好设置(已弃用)"""
if scope_id == "":
scope_id = "unknown"
if scope_id is None or key is None:
# result = asyncio.run(self.range_get_async(scope, scope_id, key))
raise ValueError(
"scope_id and key cannot be None when getting a specific preference."
)
result = asyncio.run_coroutine_threadsafe(
self.get_async(scope or "unknown", scope_id or "unknown", key, default),
self._sync_loop,
).result()
return result if result is not None else default
def range_get(
self, scope: str, scope_id: str | None = None, key: str | None = None
) -> list[Preference]:
"""获取指定范围的偏好设置(已弃用)"""
result = asyncio.run_coroutine_threadsafe(
self.range_get_async(scope, scope_id, key), self._sync_loop
).result()
return result
def put(self, key, value, scope: str | None = None, scope_id: str | None = None):
"""设置偏好设置(已弃用)"""
asyncio.run_coroutine_threadsafe(
self.put_async(scope or "unknown", scope_id or "unknown", key, value),
self._sync_loop,
).result()
def remove(self, key, scope: str | None = None, scope_id: str | None = None):
"""删除偏好设置(已弃用)"""
asyncio.run_coroutine_threadsafe(
self.remove_async(scope or "unknown", scope_id or "unknown", key),
self._sync_loop,
).result()
def clear(self, scope: str | None = None, scope_id: str | None = None):
"""清空偏好设置(已弃用)"""
asyncio.run_coroutine_threadsafe(
self.clear_async(scope or "unknown", scope_id or "unknown"),
self._sync_loop,
).result()

View File

@@ -1,37 +1,65 @@
import aiohttp
import os
import asyncio
import ssl
import certifi
import logging
import random
from . import RenderStrategy
from astrbot.core.config import VERSION
from astrbot.core.utils.io import download_image_by_url
from astrbot.core.utils.t2i.template_manager import TemplateManager
ASTRBOT_T2I_DEFAULT_ENDPOINT = "https://t2i.soulter.top/text2img"
logger = logging.getLogger("astrbot")
class NetworkRenderStrategy(RenderStrategy):
def __init__(self, base_url: str | None = None) -> None:
super().__init__()
if not base_url:
base_url = ASTRBOT_T2I_DEFAULT_ENDPOINT
self.BASE_RENDER_URL = base_url
self.TEMPLATE_PATH = os.path.join(os.path.dirname(__file__), "template")
self.BASE_RENDER_URL = ASTRBOT_T2I_DEFAULT_ENDPOINT
else:
self.BASE_RENDER_URL = self._clean_url(base_url)
if self.BASE_RENDER_URL.endswith("/"):
self.BASE_RENDER_URL = self.BASE_RENDER_URL[:-1]
if not self.BASE_RENDER_URL.endswith("text2img"):
self.BASE_RENDER_URL += "/text2img"
self.endpoints = [self.BASE_RENDER_URL]
self.template_manager = TemplateManager()
def set_endpoint(self, base_url: str):
if not base_url:
base_url = ASTRBOT_T2I_DEFAULT_ENDPOINT
self.BASE_RENDER_URL = base_url
async def initialize(self):
if self.BASE_RENDER_URL == ASTRBOT_T2I_DEFAULT_ENDPOINT:
asyncio.create_task(self.get_official_endpoints())
if self.BASE_RENDER_URL.endswith("/"):
self.BASE_RENDER_URL = self.BASE_RENDER_URL[:-1]
if not self.BASE_RENDER_URL.endswith("text2img"):
self.BASE_RENDER_URL += "/text2img"
async def get_template(self, name: str = "base") -> str:
"""通过名称获取文转图 HTML 模板"""
return self.template_manager.get_template(name)
async def get_official_endpoints(self):
"""获取官方的 t2i 端点列表。"""
try:
async with aiohttp.ClientSession() as session:
async with session.get(
"https://api.soulter.top/astrbot/t2i-endpoints"
) as resp:
if resp.status == 200:
data = await resp.json()
all_endpoints: list[dict] = data.get("data", [])
self.endpoints = [
ep.get("url")
for ep in all_endpoints
if ep.get("active") and ep.get("url")
]
logger.info(
f"Successfully got {len(self.endpoints)} official T2I endpoints."
)
except Exception as e:
logger.error(f"Failed to get official endpoints: {e}")
def _clean_url(self, url: str):
if url.endswith("/"):
url = url[:-1]
if not url.endswith("text2img"):
url += "/text2img"
return url
async def render_custom_template(
self,
@@ -41,6 +69,7 @@ class NetworkRenderStrategy(RenderStrategy):
options: dict | None = None,
) -> str:
"""使用自定义文转图模板"""
default_options = {"full_page": True, "type": "jpeg", "quality": 40}
if options:
default_options |= options
@@ -51,30 +80,48 @@ class NetworkRenderStrategy(RenderStrategy):
"tmpldata": tmpl_data,
"options": default_options,
}
if return_url:
ssl_context = ssl.create_default_context(cafile=certifi.where())
connector = aiohttp.TCPConnector(ssl=ssl_context)
async with aiohttp.ClientSession(
trust_env=True, connector=connector
) as session:
async with session.post(
f"{self.BASE_RENDER_URL}/generate", json=post_data
) as resp:
ret = await resp.json()
return f"{self.BASE_RENDER_URL}/{ret['data']['id']}"
return await download_image_by_url(
f"{self.BASE_RENDER_URL}/generate", post=True, post_data=post_data
)
async def render(self, text: str, return_url: bool = False) -> str:
endpoints = self.endpoints.copy() if self.endpoints else [self.BASE_RENDER_URL]
random.shuffle(endpoints)
last_exception = None
for endpoint in endpoints:
try:
if return_url:
ssl_context = ssl.create_default_context(cafile=certifi.where())
connector = aiohttp.TCPConnector(ssl=ssl_context)
async with aiohttp.ClientSession(
trust_env=True, connector=connector
) as session:
async with session.post(
f"{endpoint}/generate", json=post_data
) as resp:
if resp.status == 200:
ret = await resp.json()
return f"{endpoint}/{ret['data']['id']}"
else:
raise Exception(f"HTTP {resp.status}")
else:
# download_image_by_url 失败时抛异常
return await download_image_by_url(
f"{endpoint}/generate", post=True, post_data=post_data
)
except Exception as e:
last_exception = e
logger.warning(f"Endpoint {endpoint} failed: {e}, trying next...")
continue
# 全部失败
logger.error(f"All endpoints failed: {last_exception}")
raise RuntimeError(f"All endpoints failed: {last_exception}")
async def render(
self, text: str, return_url: bool = False, template_name: str | None = "base"
) -> str:
"""
返回图像的文件路径
"""
with open(
os.path.join(self.TEMPLATE_PATH, "base.html"), "r", encoding="utf-8"
) as f:
tmpl_str = f.read()
assert tmpl_str
if not template_name:
template_name = "base"
tmpl_str = await self.get_template(name=template_name)
text = text.replace("`", "\\`")
return await self.render_custom_template(
tmpl_str, {"text": text, "version": f"v{VERSION}"}, return_url

View File

@@ -10,10 +10,8 @@ class HtmlRenderer:
self.network_strategy = NetworkRenderStrategy(endpoint_url)
self.local_strategy = LocalRenderStrategy()
def set_network_endpoint(self, endpoint_url: str):
"""设置 t2i 的网络端点。"""
logger.info("文本转图像服务接口: " + endpoint_url)
self.network_strategy.set_endpoint(endpoint_url)
async def initialize(self):
await self.network_strategy.initialize()
async def render_custom_template(
self,
@@ -36,12 +34,18 @@ class HtmlRenderer:
)
async def render_t2i(
self, text: str, use_network: bool = True, return_url: bool = False
self,
text: str,
use_network: bool = True,
return_url: bool = False,
template_name: str | None = None,
):
"""使用默认文转图模板。"""
if use_network:
try:
return await self.network_strategy.render(text, return_url=return_url)
return await self.network_strategy.render(
text, return_url=return_url, template_name=template_name
)
except BaseException as e:
logger.error(
f"Failed to render image via AstrBot API: {e}. Falling back to local rendering."

View File

@@ -0,0 +1,184 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<title>Astrbot PowerShell {{ version }} </title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.css" integrity="sha384-wcIxkf4k558AjM3Yz3BBFQUbk/zgIYC2R0QpeeYb+TwlBVMrlgLqwRjRtGZiK7ww" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/lib/common.min.js"></script>
<script>hljs.highlightAll();</script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.js" integrity="sha384-hIoBPJpTUs74ddyc4bFZSM1TVlQDA60VBbJS0oA934VSz82sBx1X7kSx2ATBDIyd" crossorigin="anonymous"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/contrib/auto-render.min.js" integrity="sha384-43gviWU0YVjaDtb/GhzOouOXtZMP/7XUzwPTstBeZFe/+rCMvRwr4yROQP43s0Xk" crossorigin="anonymous"
onload="renderMathInElement(document.getElementById('content'),{delimiters: [{left: '$$', right: '$$', display: true},{left: '$', right: '$', display: false}]});"></script>
<style>
:root {
--bg-color: #010409;
--text-color: #e6edf3;
--title-bar-color: #161b22;
--title-text-color: #e6edf3;
--font-family: 'Consolas', 'Microsoft YaHei Mono', 'Dengxian Mono', 'Courier New', monospace;
--glow-color: rgba(200, 220, 255, 0.7);
}
@keyframes scanline {
0% {
background-position: 0 0;
}
100% {
background-position: 0 100%;
}
}
body {
background-color: var(--bg-color);
color: var(--text-color);
font-family: var(--font-family);
margin: 0;
padding: 0;
line-height: 1.6;
font-size: 18px;
/* The CRT glow effect from the image */
text-shadow: 0 0 15px var(--glow-color), 0 0 7px rgba(255, 255, 255, 1);
position: relative;
overflow: hidden;
}
body::after {
content: " ";
display: block;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(to bottom, transparent 50%, rgba(0, 0, 0, 0.3) 50%);
background-size: 100% 4px;
z-index: 2;
pointer-events: none;
animation: scanline 8s linear infinite;
}
.header {
background-color: var(--title-bar-color);
padding: 12px 18px;
color: var(--title-text-color);
font-size: 16px;
border-bottom: 1px solid #30363d;
text-shadow: none; /* No glow for title bar */
}
.header .title {
font-weight: bold;
font-size: 28px;
}
.header .version {
opacity: 0.8;
margin-left: 1rem;
}
main {
padding: 1rem 1.5rem;
}
#content {
/* min-width and max-width removed as per request */
}
/* --- Markdown Styles adjusted for terminal look --- */
h1, h2, h3, h4, h5, h6 {
line-height: 1.4;
margin-top: 20px;
margin-bottom: 10px;
padding-bottom: 5px;
border-bottom: 1px solid #30363d;
color: var(--text-color);
}
h1 { font-size: 2rem; }
h2 { font-size: 1.7rem; }
h3 { font-size: 1.4rem; }
p {
margin-top: 1rem;
margin-bottom: 1rem;
}
strong {
color: var(--text-color);
font-weight: bold;
}
img {
max-width: 100%;
border: 1px solid #30363d;
display: block;
margin: 1rem auto;
}
hr {
border: 0;
border-top: 1px dashed #30363d;
margin: 2rem 0;
}
code {
font-family: var(--font-family);
padding: 0.2em 0.4em;
margin: 0;
font-size: 90%;
background-color: #161b22;
border-radius: 4px;
}
pre {
font-family: var(--font-family);
border-radius: 4px;
background: #0d1117;
padding: 1rem;
overflow-x: auto;
border: 1px solid #30363d;
}
pre > code {
padding: 0;
margin: 0;
font-size: 100%;
background-color: transparent;
border-radius: 0;
text-shadow: none; /* Disable glow inside code blocks for clarity */
}
a {
color: #58a6ff;
text-decoration: underline;
}
a:hover {
text-decoration: underline;
}
blockquote {
border-left: 4px solid #30363d;
padding: 0.5rem 1rem;
margin: 1.5rem 0;
color: #8b949e;
background-color: #161b22;
}
</style>
</head>
<body>
<div class="header">
<span class="title">> Astrbot PowerShell</span>
<span class="version">{{ version }}</span>
</div>
<main>
<div id="content"></div>
</main>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>
document.getElementById('content').innerHTML = marked.parse(`{{ text | safe }}`);
</script>
</body>
</html>

View File

@@ -0,0 +1,247 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.css" integrity="sha384-wcIxkf4k558AjM3Yz3BBFQUbk/zgIYC2R0QpeeYb+TwlBVMrlgLqwRjRtGZiK7ww" crossorigin="anonymous">
<link rel="stylesheet" href="/path/to/styles/default.min.css">
<script src="/path/to/highlight.min.js"></script>
<script>hljs.highlightAll();</script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.js" integrity="sha384-hIoBPJpTUs74ddyc4bFZSM1TVlQDA60VBbJS0oA934VSz82sBx1X7kSx2ATBDIyd" crossorigin="anonymous"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/contrib/auto-render.min.js" integrity="sha384-43gviWU0YVjaDtb/GhzOouOXtZMP/7XUzwPTstBeZFe/+rCMvRwr4yROQP43s0Xk" crossorigin="anonymous"
onload="renderMathInElement(document.getElementById('content'),{delimiters: [{left: '$$', right: '$$', display: true},{left: '$', right: '$', display: false}]});"></script>
</head>
<body>
<div style="background-color: #3276dc; color: #fff; font-size: 64px; ">
<span style="font-weight: bold; margin-left: 16px"># AstrBot</span>
<span>{{ version }}</span>
</div>
<article style="margin-top: 32px" id="content"></article>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>
document.getElementById('content').innerHTML = marked.parse(`{{ text | safe}}`);
</script>
</body>
</html>
<style>
#content {
min-width: 200px;
max-width: 85%;
margin: 0 auto;
padding: 2rem 1em 1em;
}
body {
word-break: break-word;
line-height: 1.75;
font-weight: 400;
font-size: 32px;
margin: 0;
padding: 0;
overflow-x: hidden;
color: #333;
font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji;
}
h1, h2, h3, h4, h5, h6 {
line-height: 1.5;
margin-top: 35px;
margin-bottom: 10px;
padding-bottom: 5px;
}
h1:first-child, h2:first-child, h3:first-child, h4:first-child, h5:first-child, h6:first-child {
margin-top: -1.5rem;
margin-bottom: 1rem;
}
h1::before, h2::before, h3::before, h4::before, h5::before, h6::before {
content: "#";
display: inline-block;
color: #3eaf7c;
padding-right: 0.23em;
}
h1 {
position: relative;
font-size: 2.5rem;
margin-bottom: 5px;
}
h1::before {
font-size: 2.5rem;
}
h2 {
padding-bottom: 0.5rem;
font-size: 2.2rem;
border-bottom: 1px solid #ececec;
}
h3 {
font-size: 1.5rem;
padding-bottom: 0;
}
h4 {
font-size: 1.25rem;
}
h5 {
font-size: 1rem;
}
h6 {
margin-top: 5px;
}
p {
line-height: inherit;
margin-top: 22px;
margin-bottom: 22px;
}
strong {
color: #3eaf7c;
}
img {
max-width: 100%;
border-radius: 2px;
display: block;
margin: auto;
border: 3px solid rgba(62, 175, 124, 0.2);
}
hr {
border-top: 1px solid #3eaf7c;
border-bottom: none;
border-left: none;
border-right: none;
margin-top: 32px;
margin-bottom: 32px;
}
code {
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
word-break: break-word;
overflow-x: auto;
padding: 0.2rem 0.5rem;
margin: 0;
color: #3eaf7c;
font-size: 0.85em;
background-color: rgba(27, 31, 35, 0.05);
border-radius: 3px;
}
pre {
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
overflow: auto;
position: relative;
line-height: 1.75;
border-radius: 6px;
border: 2px solid #3eaf7c;
}
pre > code {
font-size: 12px;
padding: 15px 12px;
margin: 0;
word-break: normal;
display: block;
overflow-x: auto;
color: #333;
background: #f8f8f8;
}
a {
font-weight: 500;
text-decoration: none;
color: #3eaf7c;
}
a:hover, a:active {
border-bottom: 1.5px solid #3eaf7c;
}
a:before {
content: "⇲";
}
table {
display: inline-block !important;
font-size: 12px;
width: auto;
max-width: 100%;
overflow: auto;
border: solid 1px #3eaf7c;
}
thead {
background: #3eaf7c;
color: #fff;
text-align: left;
}
tr:nth-child(2n) {
background-color: rgba(62, 175, 124, 0.2);
}
th, td {
padding: 12px 7px;
line-height: 24px;
}
td {
min-width: 120px;
}
blockquote {
color: #666;
padding: 1px 23px;
margin: 22px 0;
border-left: 0.5rem solid rgba(62, 175, 124, 0.6);
border-color: #42b983;
background-color: #f8f8f8;
}
blockquote::after {
display: block;
content: "";
}
blockquote > p {
margin: 10px 0;
}
details {
border: none;
outline: none;
border-left: 4px solid #3eaf7c;
padding-left: 10px;
margin-left: 4px;
}
details summary {
cursor: pointer;
border: none;
outline: none;
background: white;
margin: 0px -17px;
}
details summary::-webkit-details-marker {
color: #3eaf7c;
}
ol, ul {
padding-left: 28px;
}
ol li, ul li {
margin-bottom: 0;
list-style: inherit;
}
ol li .task-list-item, ul li .task-list-item {
list-style: none;
}
ol li .task-list-item ul, ul li .task-list-item ul, ol li .task-list-item ol, ul li .task-list-item ol {
margin-top: 0;
}
ol ul, ul ul, ol ol, ul ol {
margin-top: 3px;
}
ol li {
padding-left: 6px;
}
ol li::marker {
color: #3eaf7c;
}
ul li {
list-style: none;
}
ul li:before {
content: "•";
margin-right: 4px;
color: #3eaf7c;
}
@media (max-width: 720px) {
h1 {
font-size: 24px;
}
h2 {
font-size: 20px;
}
h3 {
font-size: 18px;
}
}
</style>

View File

@@ -0,0 +1,95 @@
# astrbot/core/utils/t2i/template_manager.py
import os
import shutil
from astrbot.core.utils.astrbot_path import get_astrbot_path
class TemplateManager:
"""
负责管理 t2i HTML 模板的 CRUD 和重置操作。
"""
def __init__(self):
# 修正路径拼接,加入缺失的 'astrbot' 目录
self.template_dir = os.path.join(
get_astrbot_path(), "astrbot", "core", "utils", "t2i", "template"
)
self.backup_template_path = os.path.join(
self.template_dir, "default_template.html.bak"
)
# 确保模板目录存在
os.makedirs(self.template_dir, exist_ok=True)
# 检查模板目录中是否有 .html 文件
html_files = [f for f in os.listdir(self.template_dir) if f.endswith(".html")]
if not html_files and os.path.exists(self.backup_template_path):
self.reset_default_template()
def _get_template_path(self, name: str) -> str:
"""获取模板的完整路径,防止路径遍历漏洞。"""
if ".." in name or "/" in name or "\\" in name:
raise ValueError("模板名称包含非法字符。")
return os.path.join(self.template_dir, f"{name}.html")
def list_templates(self) -> list[dict]:
"""列出所有可用的模板。"""
templates = []
for filename in os.listdir(self.template_dir):
if filename.endswith(".html"):
templates.append(
{
"name": os.path.splitext(filename)[0],
"is_default": filename == "base.html",
}
)
return templates
def get_template(self, name: str) -> str:
"""获取指定模板的内容。"""
path = self._get_template_path(name)
if not os.path.exists(path):
raise FileNotFoundError("模板不存在。")
with open(path, "r", encoding="utf-8") as f:
return f.read()
def create_template(self, name: str, content: str):
"""创建一个新的模板文件。"""
path = self._get_template_path(name)
if os.path.exists(path):
raise FileExistsError("同名模板已存在。")
with open(path, "w", encoding="utf-8") as f:
f.write(content)
def update_template(self, name: str, content: str):
"""更新一个已存在的模板文件。"""
path = self._get_template_path(name)
if not os.path.exists(path):
raise FileNotFoundError("模板不存在。")
with open(path, "w", encoding="utf-8") as f:
f.write(content)
def delete_template(self, name: str):
"""删除一个模板文件。"""
if name == "base":
raise ValueError("不能删除默认的 base 模板。")
path = self._get_template_path(name)
if not os.path.exists(path):
raise FileNotFoundError("模板不存在。")
os.remove(path)
def backup_default_template_if_not_exist(self):
"""如果备份不存在,则创建默认模板的备份。"""
default_path = os.path.join(self.template_dir, "base.html")
if not os.path.exists(self.backup_template_path) and os.path.exists(
default_path
):
shutil.copyfile(default_path, self.backup_template_path)
def reset_default_template(self):
"""重置默认模板。"""
if not os.path.exists(self.backup_template_path):
raise FileNotFoundError("默认模板的备份文件不存在,无法重置。")
default_path = os.path.join(self.template_dir, "base.html")
shutil.copyfile(self.backup_template_path, default_path)

View File

@@ -107,16 +107,38 @@ class RepoZipUpdator:
"""Semver 版本比较"""
return VersionComparator.compare_version(v1, v2)
async def check_update(self, url: str, current_version: str) -> ReleaseInfo | None:
async def check_update(
self, url: str, current_version: str, consider_prerelease: bool = True
) -> ReleaseInfo | None:
update_data = await self.fetch_release_info(url)
tag_name = update_data[0]["tag_name"]
sel_release_data = None
if consider_prerelease:
tag_name = update_data[0]["tag_name"]
sel_release_data = update_data[0]
else:
for data in update_data:
# 跳过带有 alpha、beta 等预发布标签的版本
if re.search(
r"[\-_.]?(alpha|beta|rc|dev)[\-_.]?\d*$",
data["tag_name"],
re.IGNORECASE,
):
continue
tag_name = data["tag_name"]
sel_release_data = data
break
if not sel_release_data or not tag_name:
logger.error("未找到合适的发布版本")
return None
if self.compare_version(current_version, tag_name) >= 0:
return None
return ReleaseInfo(
version=tag_name,
published_at=update_data[0]["published_at"],
body=update_data[0]["body"],
published_at=sel_release_data["published_at"],
body=f"{tag_name}\n\n{sel_release_data['body']}",
)
async def download_from_repo_url(self, target_path: str, repo_url: str, proxy=""):
@@ -181,14 +203,13 @@ class RepoZipUpdator:
"""
os.makedirs(target_dir, exist_ok=True)
update_dir = ""
logger.info(f"解压文件: {zip_path}")
with zipfile.ZipFile(zip_path, "r") as z:
update_dir = z.namelist()[0]
z.extractall(target_dir)
logger.debug(f"解压文件完成: {zip_path}")
files = os.listdir(os.path.join(target_dir, update_dir))
for f in files:
logger.info(f"移动更新文件/目录: {f}")
if os.path.isdir(os.path.join(target_dir, update_dir, f)):
if os.path.exists(os.path.join(target_dir, f)):
shutil.rmtree(os.path.join(target_dir, f), onerror=on_error)
@@ -198,13 +219,13 @@ class RepoZipUpdator:
shutil.move(os.path.join(target_dir, update_dir, f), target_dir)
try:
logger.info(
logger.debug(
f"删除临时更新文件: {zip_path}{os.path.join(target_dir, update_dir)}"
)
shutil.rmtree(os.path.join(target_dir, update_dir), onerror=on_error)
os.remove(zip_path)
except BaseException:
logger.warn(
logger.warning(
f"删除更新文件失败,可以手动删除 {zip_path}{os.path.join(target_dir, update_dir)}"
)

View File

@@ -6,11 +6,11 @@ from .stat import StatRoute
from .log import LogRoute
from .static_file import StaticFileRoute
from .chat import ChatRoute
from .tools import ToolsRoute # 导入新的ToolsRoute
from .tools import ToolsRoute
from .conversation import ConversationRoute
from .file import FileRoute
from .session_management import SessionManagementRoute
from .persona import PersonaRoute
__all__ = [
"AuthRoute",
@@ -25,4 +25,5 @@ __all__ = [
"ConversationRoute",
"FileRoute",
"SessionManagementRoute",
"PersonaRoute",
]

View File

@@ -9,6 +9,7 @@ import asyncio
from astrbot.core import logger
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from astrbot.core.platform.astr_message_event import MessageSession
class ChatRoute(Route):
@@ -29,28 +30,15 @@ class ChatRoute(Route):
"/chat/get_file": ("GET", self.get_file),
"/chat/post_image": ("POST", self.post_image),
"/chat/post_file": ("POST", self.post_file),
"/chat/status": ("GET", self.status),
}
self.db = db
self.core_lifecycle = core_lifecycle
self.register_routes()
self.imgs_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs")
os.makedirs(self.imgs_dir, exist_ok=True)
self.supported_imgs = ["jpg", "jpeg", "png", "gif", "webp"]
async def status(self):
has_llm_enabled = (
self.core_lifecycle.provider_manager.curr_provider_inst is not None
)
has_stt_enabled = (
self.core_lifecycle.provider_manager.curr_stt_provider_inst is not None
)
return (
Response()
.ok(data={"llm_enabled": has_llm_enabled, "stt_enabled": has_stt_enabled})
.__dict__
)
self.conv_mgr = core_lifecycle.conversation_manager
self.platform_history_mgr = core_lifecycle.platform_message_history_manager
async def get_file(self):
filename = request.args.get("filename")
@@ -131,24 +119,23 @@ class ChatRoute(Route):
if not conversation_id:
return Response().error("conversation_id is empty").__dict__
# Get conversation-specific queues
back_queue = webchat_queue_mgr.get_or_create_back_queue(conversation_id)
# append user message
conversation = self.db.get_conversation_by_user_id(username, conversation_id)
try:
history = json.loads(conversation.history)
except BaseException as e:
logger.error(f"Failed to parse conversation history: {e}")
history = []
webchat_conv_id = await self._get_webchat_conv_id_from_conv_id(conversation_id)
# Get conversation-specific queues
back_queue = webchat_queue_mgr.get_or_create_back_queue(webchat_conv_id)
new_his = {"type": "user", "message": message}
if image_url:
new_his["image_url"] = image_url
if audio_url:
new_his["audio_url"] = audio_url
history.append(new_his)
self.db.update_conversation(
username, conversation_id, history=json.dumps(history)
await self.platform_history_mgr.insert(
platform_id="webchat",
user_id=webchat_conv_id,
content=new_his,
sender_id=username,
sender_name=username,
)
async def stream():
@@ -164,7 +151,6 @@ class ChatRoute(Route):
result_text = result["data"]
type = result.get("type")
cid = result.get("cid")
streaming = result.get("streaming", False)
yield f"data: {json.dumps(result, ensure_ascii=False)}\n\n"
await asyncio.sleep(0.05)
@@ -173,17 +159,13 @@ class ChatRoute(Route):
break
elif (streaming and type == "complete") or not streaming:
# append bot message
conversation = self.db.get_conversation_by_user_id(
username, cid
)
try:
history = json.loads(conversation.history)
except BaseException as e:
logger.error(f"Failed to parse conversation history: {e}")
history = []
history.append({"type": "bot", "message": result_text})
self.db.update_conversation(
username, cid, history=json.dumps(history)
new_his = {"type": "bot", "message": result_text}
await self.platform_history_mgr.insert(
platform_id="webchat",
user_id=webchat_conv_id,
content=new_his,
sender_id="bot",
sender_name="bot",
)
except BaseException as _:
@@ -191,11 +173,11 @@ class ChatRoute(Route):
return
# Put message to conversation-specific queue
chat_queue = webchat_queue_mgr.get_or_create_queue(conversation_id)
chat_queue = webchat_queue_mgr.get_or_create_queue(webchat_conv_id)
await chat_queue.put(
(
username,
conversation_id,
webchat_conv_id,
{
"message": message,
"image_url": image_url, # list
@@ -217,25 +199,51 @@ class ChatRoute(Route):
)
return response
async def _get_webchat_conv_id_from_conv_id(self, conversation_id: str) -> str:
"""从对话 ID 中提取 WebChat 会话 ID
NOTE: 关于这里为什么要单独做一个 WebChat 的 Conversation ID 出来,这个是为了向前兼容。
"""
conversation = await self.conv_mgr.get_conversation(
unified_msg_origin="webchat", conversation_id=conversation_id
)
if not conversation:
raise ValueError(f"Conversation with ID {conversation_id} not found.")
conv_user_id = conversation.user_id
webchat_session_id = MessageSession.from_str(conv_user_id).session_id
if "!" not in webchat_session_id:
raise ValueError(f"Invalid conv user ID: {conv_user_id}")
return webchat_session_id.split("!")[-1]
async def delete_conversation(self):
username = g.get("username", "guest")
conversation_id = request.args.get("conversation_id")
if not conversation_id:
return Response().error("Missing key: conversation_id").__dict__
username = g.get("username", "guest")
# Clean up queues when deleting conversation
webchat_queue_mgr.remove_queues(conversation_id)
self.db.delete_conversation(username, conversation_id)
webchat_conv_id = await self._get_webchat_conv_id_from_conv_id(conversation_id)
await self.conv_mgr.delete_conversation(
unified_msg_origin=f"webchat:FriendMessage:webchat!{username}!{webchat_conv_id}",
conversation_id=conversation_id,
)
await self.platform_history_mgr.delete(
platform_id="webchat", user_id=webchat_conv_id, offset_sec=99999999
)
return Response().ok().__dict__
async def new_conversation(self):
username = g.get("username", "guest")
conversation_id = str(uuid.uuid4())
self.db.new_conversation(username, conversation_id)
return Response().ok(data={"conversation_id": conversation_id}).__dict__
webchat_conv_id = str(uuid.uuid4())
conv_id = await self.conv_mgr.new_conversation(
unified_msg_origin=f"webchat:FriendMessage:webchat!{username}!{webchat_conv_id}",
platform_id="webchat",
content=[],
)
return Response().ok(data={"conversation_id": conv_id}).__dict__
async def rename_conversation(self):
username = g.get("username", "guest")
post_data = await request.json
if "conversation_id" not in post_data or "title" not in post_data:
return Response().error("Missing key: conversation_id or title").__dict__
@@ -243,20 +251,42 @@ class ChatRoute(Route):
conversation_id = post_data["conversation_id"]
title = post_data["title"]
self.db.update_conversation_title(username, conversation_id, title=title)
await self.conv_mgr.update_conversation(
unified_msg_origin="webchat", # fake
conversation_id=conversation_id,
title=title,
)
return Response().ok(message="重命名成功!").__dict__
async def get_conversations(self):
username = g.get("username", "guest")
conversations = self.db.get_conversations(username)
return Response().ok(data=conversations).__dict__
conversations = await self.conv_mgr.get_conversations(platform_id="webchat")
# remove content
conversations_ = []
for conv in conversations:
conv.history = None
conversations_.append(conv)
return Response().ok(data=conversations_).__dict__
async def get_conversation(self):
username = g.get("username", "guest")
conversation_id = request.args.get("conversation_id")
if not conversation_id:
return Response().error("Missing key: conversation_id").__dict__
conversation = self.db.get_conversation_by_user_id(username, conversation_id)
webchat_conv_id = await self._get_webchat_conv_id_from_conv_id(conversation_id)
return Response().ok(data=conversation).__dict__
# Get platform message history
history_ls = await self.platform_history_mgr.get(
platform_id="webchat", user_id=webchat_conv_id, page=1, page_size=1000
)
history_res = [history.model_dump() for history in history_ls]
return (
Response()
.ok(
data={
"history": history_res,
}
)
.__dict__
)

View File

@@ -1,10 +1,16 @@
import typing
import traceback
import os
import copy
from .route import Route, Response, RouteContext
from astrbot.core.provider.entities import ProviderType
from quart import request
from astrbot.core.config.default import CONFIG_METADATA_2, DEFAULT_VALUE_MAP
from astrbot.core.config.default import (
CONFIG_METADATA_2,
DEFAULT_VALUE_MAP,
CONFIG_METADATA_3,
CONFIG_METADATA_3_SYSTEM,
)
from astrbot.core.utils.astrbot_path import get_astrbot_path
from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
@@ -13,6 +19,7 @@ from astrbot.core.provider.register import provider_registry
from astrbot.core.star.star import star_registry
from astrbot.core import logger
from astrbot.core.provider import Provider
from astrbot.core.provider.provider import RerankProvider
import asyncio
@@ -149,6 +156,7 @@ def save_config(post_config: dict, config: AstrBotConfig, is_core: bool = False)
raise ValueError(f"验证配置时出现异常: {e}")
if errors:
raise ValueError(f"格式校验未通过: {errors}")
config.save_config(post_config)
@@ -159,33 +167,119 @@ class ConfigRoute(Route):
super().__init__(context)
self.core_lifecycle = core_lifecycle
self.config: AstrBotConfig = core_lifecycle.astrbot_config
self.acm = core_lifecycle.astrbot_config_mgr
self.routes = {
"/config/abconf/new": ("POST", self.create_abconf),
"/config/abconf": ("GET", self.get_abconf),
"/config/abconfs": ("GET", self.get_abconf_list),
"/config/abconf/delete": ("POST", self.delete_abconf),
"/config/abconf/update": ("POST", self.update_abconf),
"/config/get": ("GET", self.get_configs),
"/config/astrbot/update": ("POST", self.post_astrbot_configs),
"/config/plugin/update": ("POST", self.post_plugin_configs),
"/config/platform/new": ("POST", self.post_new_platform),
"/config/platform/update": ("POST", self.post_update_platform),
"/config/platform/delete": ("POST", self.post_delete_platform),
"/config/platform/list": ("GET", self.get_platform_list),
"/config/provider/new": ("POST", self.post_new_provider),
"/config/provider/update": ("POST", self.post_update_provider),
"/config/provider/delete": ("POST", self.post_delete_provider),
"/config/llmtools": ("GET", self.get_llm_tools),
"/config/provider/check_one": ("GET", self.check_one_provider_status),
"/config/provider/list": ("GET", self.get_provider_config_list),
"/config/provider/model_list": ("GET", self.get_provider_model_list),
"/config/provider/get_session_seperate": (
"GET",
lambda: Response()
.ok({"enable": self.config["provider_settings"]["separate_provider"]})
.__dict__,
),
"/config/provider/set_session_seperate": (
"POST",
self.post_session_seperate,
),
}
self.register_routes()
async def get_abconf_list(self):
"""获取所有 AstrBot 配置文件的列表"""
abconf_list = self.acm.get_conf_list()
return Response().ok({"info_list": abconf_list}).__dict__
async def create_abconf(self):
"""创建新的 AstrBot 配置文件"""
post_data = await request.json
if not post_data:
return Response().error("缺少配置数据").__dict__
umo_parts = post_data["umo_parts"]
name = post_data.get("name", None)
try:
conf_id = self.acm.create_conf(umo_parts=umo_parts, name=name)
return Response().ok(message="创建成功", data={"conf_id": conf_id}).__dict__
except ValueError as e:
return Response().error(str(e)).__dict__
async def get_abconf(self):
"""获取指定 AstrBot 配置文件"""
abconf_id = request.args.get("id")
system_config = request.args.get("system_config", "0").lower() == "1"
if not abconf_id and not system_config:
return Response().error("缺少配置文件 ID").__dict__
try:
if system_config:
abconf = self.acm.confs["default"]
return (
Response()
.ok({"config": abconf, "metadata": CONFIG_METADATA_3_SYSTEM})
.__dict__
)
abconf = self.acm.confs[abconf_id]
return (
Response()
.ok({"config": abconf, "metadata": CONFIG_METADATA_3})
.__dict__
)
except ValueError as e:
return Response().error(str(e)).__dict__
async def delete_abconf(self):
"""删除指定 AstrBot 配置文件"""
post_data = await request.json
if not post_data:
return Response().error("缺少配置数据").__dict__
conf_id = post_data.get("id")
if not conf_id:
return Response().error("缺少配置文件 ID").__dict__
try:
success = self.acm.delete_conf(conf_id)
if success:
return Response().ok(message="删除成功").__dict__
else:
return Response().error("删除失败").__dict__
except ValueError as e:
return Response().error(str(e)).__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"删除配置文件失败: {str(e)}").__dict__
async def update_abconf(self):
"""更新指定 AstrBot 配置文件信息"""
post_data = await request.json
if not post_data:
return Response().error("缺少配置数据").__dict__
conf_id = post_data.get("id")
if not conf_id:
return Response().error("缺少配置文件 ID").__dict__
name = post_data.get("name")
umo_parts = post_data.get("umo_parts")
try:
success = self.acm.update_conf_info(conf_id, name=name, umo_parts=umo_parts)
if success:
return Response().ok(message="更新成功").__dict__
else:
return Response().error("更新失败").__dict__
except ValueError as e:
return Response().error(str(e)).__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"更新配置文件失败: {str(e)}").__dict__
async def _test_single_provider(self, provider):
"""辅助函数:测试单个 provider 的可用性"""
meta = provider.meta()
@@ -210,11 +304,16 @@ class ConfigRoute(Route):
response = await asyncio.wait_for(
provider.text_chat(prompt="REPLY `PONG` ONLY"), timeout=45.0
)
logger.debug(f"Received response from {status_info['name']}: {response}")
logger.debug(
f"Received response from {status_info['name']}: {response}"
)
if response is not None:
status_info["status"] = "available"
response_text_snippet = ""
if hasattr(response, "completion_text") and response.completion_text:
if (
hasattr(response, "completion_text")
and response.completion_text
):
response_text_snippet = (
response.completion_text[:70] + "..."
if len(response.completion_text) > 70
@@ -233,29 +332,48 @@ class ConfigRoute(Route):
f"Provider {status_info['name']} (ID: {status_info['id']}) is available. Response snippet: '{response_text_snippet}'"
)
else:
status_info["error"] = "Test call returned None, but expected an LLMResponse object."
logger.warning(f"Provider {status_info['name']} (ID: {status_info['id']}) test call returned None.")
status_info["error"] = (
"Test call returned None, but expected an LLMResponse object."
)
logger.warning(
f"Provider {status_info['name']} (ID: {status_info['id']}) test call returned None."
)
except asyncio.TimeoutError:
status_info["error"] = "Connection timed out after 45 seconds during test call."
logger.warning(f"Provider {status_info['name']} (ID: {status_info['id']}) timed out.")
status_info["error"] = (
"Connection timed out after 45 seconds during test call."
)
logger.warning(
f"Provider {status_info['name']} (ID: {status_info['id']}) timed out."
)
except Exception as e:
error_message = str(e)
status_info["error"] = error_message
logger.warning(f"Provider {status_info['name']} (ID: {status_info['id']}) is unavailable. Error: {error_message}")
logger.debug(f"Traceback for {status_info['name']}:\n{traceback.format_exc()}")
logger.warning(
f"Provider {status_info['name']} (ID: {status_info['id']}) is unavailable. Error: {error_message}"
)
logger.debug(
f"Traceback for {status_info['name']}:\n{traceback.format_exc()}"
)
elif provider_capability_type == ProviderType.EMBEDDING:
try:
# For embedding, we can call the get_embedding method with a short prompt.
embedding_result = await provider.get_embedding("health_check")
if isinstance(embedding_result, list) and (not embedding_result or isinstance(embedding_result[0], float)):
if isinstance(embedding_result, list) and (
not embedding_result or isinstance(embedding_result[0], float)
):
status_info["status"] = "available"
else:
status_info["status"] = "unavailable"
status_info["error"] = f"Embedding test failed: unexpected result type {type(embedding_result)}"
status_info["error"] = (
f"Embedding test failed: unexpected result type {type(embedding_result)}"
)
except Exception as e:
logger.error(f"Error testing embedding provider {provider_name}: {e}", exc_info=True)
logger.error(
f"Error testing embedding provider {provider_name}: {e}",
exc_info=True,
)
status_info["status"] = "unavailable"
status_info["error"] = f"Embedding test failed: {str(e)}"
@@ -267,41 +385,84 @@ class ConfigRoute(Route):
status_info["status"] = "available"
else:
status_info["status"] = "unavailable"
status_info["error"] = f"TTS test failed: unexpected result type {type(audio_result)}"
status_info["error"] = (
f"TTS test failed: unexpected result type {type(audio_result)}"
)
except Exception as e:
logger.error(f"Error testing TTS provider {provider_name}: {e}", exc_info=True)
logger.error(
f"Error testing TTS provider {provider_name}: {e}", exc_info=True
)
status_info["status"] = "unavailable"
status_info["error"] = f"TTS test failed: {str(e)}"
elif provider_capability_type == ProviderType.SPEECH_TO_TEXT:
try:
logger.debug(f"Sending health check audio to provider: {status_info['name']}")
sample_audio_path = os.path.join(get_astrbot_path(), "samples", "stt_health_check.wav")
logger.debug(
f"Sending health check audio to provider: {status_info['name']}"
)
sample_audio_path = os.path.join(
get_astrbot_path(), "samples", "stt_health_check.wav"
)
if not os.path.exists(sample_audio_path):
status_info["status"] = "unavailable"
status_info["error"] = "STT test failed: sample audio file not found."
logger.warning(f"STT test for {status_info['name']} failed: sample audio file not found at {sample_audio_path}")
status_info["error"] = (
"STT test failed: sample audio file not found."
)
logger.warning(
f"STT test for {status_info['name']} failed: sample audio file not found at {sample_audio_path}"
)
else:
text_result = await provider.get_text(sample_audio_path)
if isinstance(text_result, str) and text_result:
status_info["status"] = "available"
snippet = text_result[:70] + "..." if len(text_result) > 70 else text_result
logger.info(f"Provider {status_info['name']} (ID: {status_info['id']}) is available. Response snippet: '{snippet}'")
snippet = (
text_result[:70] + "..."
if len(text_result) > 70
else text_result
)
logger.info(
f"Provider {status_info['name']} (ID: {status_info['id']}) is available. Response snippet: '{snippet}'"
)
else:
status_info["status"] = "unavailable"
status_info["error"] = f"STT test failed: unexpected result type {type(text_result)}"
logger.warning(f"STT test for {status_info['name']} failed: unexpected result type {type(text_result)}")
status_info["error"] = (
f"STT test failed: unexpected result type {type(text_result)}"
)
logger.warning(
f"STT test for {status_info['name']} failed: unexpected result type {type(text_result)}"
)
except Exception as e:
logger.error(f"Error testing STT provider {provider_name}: {e}", exc_info=True)
logger.error(
f"Error testing STT provider {provider_name}: {e}", exc_info=True
)
status_info["status"] = "unavailable"
status_info["error"] = f"STT test failed: {str(e)}"
elif provider_capability_type == ProviderType.RERANK:
try:
assert isinstance(provider, RerankProvider)
await provider.rerank("Apple", documents=["apple", "banana"])
status_info["status"] = "available"
except Exception as e:
logger.error(
f"Error testing rerank provider {provider_name}: {e}",
exc_info=True,
)
status_info["status"] = "unavailable"
status_info["error"] = f"Rerank test failed: {str(e)}"
else:
logger.debug(f"Provider {provider_name} is not a Chat Completion or Embedding provider. Marking as available without test. Meta: {meta}")
logger.debug(
f"Provider {provider_name} is not a Chat Completion or Embedding provider. Marking as available without test. Meta: {meta}"
)
status_info["status"] = "available"
status_info["error"] = "This provider type is not tested and is assumed to be available."
status_info["error"] = (
"This provider type is not tested and is assumed to be available."
)
return status_info
def _error_response(self, message: str, status_code: int = 500, log_fn=logger.error):
def _error_response(
self, message: str, status_code: int = 500, log_fn=logger.error
):
log_fn(message)
# 记录更详细的traceback信息但只在是严重错误时
if status_code == 500:
@@ -312,7 +473,9 @@ class ConfigRoute(Route):
"""API: check a single LLM Provider's status by id"""
provider_id = request.args.get("id")
if not provider_id:
return self._error_response("Missing provider_id parameter", 400, logger.warning)
return self._error_response(
"Missing provider_id parameter", 400, logger.warning
)
logger.info(f"API call: /config/provider/check_one id={provider_id}")
try:
@@ -320,16 +483,21 @@ class ConfigRoute(Route):
target = prov_mgr.inst_map.get(provider_id)
if not target:
logger.warning(f"Provider with id '{provider_id}' not found in provider_manager.")
return Response().error(f"Provider with id '{provider_id}' not found").__dict__
logger.warning(
f"Provider with id '{provider_id}' not found in provider_manager."
)
return (
Response()
.error(f"Provider with id '{provider_id}' not found")
.__dict__
)
result = await self._test_single_provider(target)
return Response().ok(result).__dict__
except Exception as e:
return self._error_response(
f"Critical error checking provider {provider_id}: {e}",
500
f"Critical error checking provider {provider_id}: {e}", 500
)
async def get_configs(self):
@@ -340,29 +508,15 @@ class ConfigRoute(Route):
return Response().ok(await self._get_astrbot_config()).__dict__
return Response().ok(await self._get_plugin_config(plugin_name)).__dict__
async def post_session_seperate(self):
"""设置提供商会话隔离"""
post_config = await request.json
enable = post_config.get("enable", None)
if enable is None:
return Response().error("缺少参数 enable").__dict__
astrbot_config = self.core_lifecycle.astrbot_config
astrbot_config["provider_settings"]["separate_provider"] = enable
try:
astrbot_config.save_config()
except Exception as e:
return Response().error(str(e)).__dict__
return Response().ok(None, "设置成功~").__dict__
async def get_provider_config_list(self):
provider_type = request.args.get("provider_type", None)
if not provider_type:
return Response().error("缺少参数 provider_type").__dict__
provider_type_ls = provider_type.split(",")
provider_list = []
astrbot_config = self.core_lifecycle.astrbot_config
for provider in astrbot_config["provider"]:
if provider.get("provider_type", None) == provider_type:
if provider.get("provider_type", None) in provider_type_ls:
provider_list.append(provider)
return Response().ok(provider_list).__dict__
@@ -388,11 +542,21 @@ class ConfigRoute(Route):
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
async def get_platform_list(self):
"""获取所有平台的列表"""
platform_list = []
for platform in self.config["platform"]:
platform_list.append(platform)
return Response().ok({"platforms": platform_list}).__dict__
async def post_astrbot_configs(self):
post_configs = await request.json
data = await request.json
config = data.get("config", None)
conf_id = data.get("conf_id", None)
try:
await self._save_astrbot_configs(post_configs)
return Response().ok(None, "保存成功~ 机器人正在重载配置。").__dict__
await self._save_astrbot_configs(config, conf_id)
await self.core_lifecycle.reload_pipeline_scheduler(conf_id)
return Response().ok(None, "保存成功~").__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
@@ -509,12 +673,6 @@ class ConfigRoute(Route):
return Response().error(str(e)).__dict__
return Response().ok(None, "删除成功,已经实时生效~").__dict__
async def get_llm_tools(self):
"""获取函数调用工具。包含了本地加载的以及 MCP 服务的工具"""
tool_mgr = self.core_lifecycle.provider_manager.llm_tools
tools = tool_mgr.get_func_desc_openai_style()
return Response().ok(tools).__dict__
async def _get_astrbot_config(self):
config = self.config
@@ -557,10 +715,19 @@ class ConfigRoute(Route):
return ret
async def _save_astrbot_configs(self, post_configs: dict):
async def _save_astrbot_configs(self, post_configs: dict, conf_id: str = None):
try:
save_config(post_configs, self.config, is_core=True)
await self.core_lifecycle.restart()
if conf_id not in self.acm.confs:
raise ValueError(f"配置文件 {conf_id} 不存在")
astrbot_config = self.acm.confs[conf_id]
# 保留服务端的 t2i_active_template 值
if "t2i_active_template" in astrbot_config:
post_configs["t2i_active_template"] = astrbot_config[
"t2i_active_template"
]
save_config(post_configs, astrbot_config, is_core=True)
except Exception as e:
raise e

View File

@@ -29,6 +29,7 @@ class ConversationRoute(Route):
),
}
self.db_helper = db_helper
self.conv_mgr = core_lifecycle.conversation_manager
self.core_lifecycle = core_lifecycle
self.register_routes()
@@ -54,7 +55,6 @@ class ConversationRoute(Route):
exclude_platforms.split(",") if exclude_platforms else []
)
# 限制页面大小,防止请求过大数据
if page < 1:
page = 1
if page_size < 1:
@@ -62,9 +62,11 @@ class ConversationRoute(Route):
if page_size > 100:
page_size = 100
# 使用数据库的分页方法获取会话列表和总数,传入筛选条件
try:
conversations, total_count = self.db_helper.get_filtered_conversations(
(
conversations,
total_count,
) = await self.conv_mgr.get_filtered_conversations(
page=page,
page_size=page_size,
platforms=platform_list,
@@ -108,7 +110,9 @@ class ConversationRoute(Route):
if not user_id or not cid:
return Response().error("缺少必要参数: user_id 和 cid").__dict__
conversation = self.db_helper.get_conversation_by_user_id(user_id, cid)
conversation = await self.conv_mgr.get_conversation(
unified_msg_origin=user_id, conversation_id=cid
)
if not conversation:
return Response().error("对话不存在").__dict__
@@ -143,14 +147,18 @@ class ConversationRoute(Route):
if not user_id or not cid:
return Response().error("缺少必要参数: user_id 和 cid").__dict__
conversation = self.db_helper.get_conversation_by_user_id(user_id, cid)
conversation = await self.conv_mgr.get_conversation(
unified_msg_origin=user_id, conversation_id=cid
)
if not conversation:
return Response().error("对话不存在").__dict__
if title is not None:
self.db_helper.update_conversation_title(user_id, cid, title)
if persona_id is not None:
self.db_helper.update_conversation_persona_id(user_id, cid, persona_id)
if title is not None or persona_id is not None:
await self.conv_mgr.update_conversation(
unified_msg_origin=user_id,
conversation_id=cid,
title=title,
persona_id=persona_id,
)
return Response().ok({"message": "对话信息更新成功"}).__dict__
except Exception as e:
@@ -201,11 +209,17 @@ class ConversationRoute(Route):
Response().error("history 必须是有效的 JSON 字符串或数组").__dict__
)
conversation = self.db_helper.get_conversation_by_user_id(user_id, cid)
conversation = await self.conv_mgr.get_conversation(
unified_msg_origin=user_id, conversation_id=cid
)
if not conversation:
return Response().error("对话不存在").__dict__
self.db_helper.update_conversation(user_id, cid, history)
history = json.loads(history) if isinstance(history, str) else history
await self.conv_mgr.update_conversation(
unified_msg_origin=user_id, conversation_id=cid, history=history
)
return Response().ok({"message": "对话历史更新成功"}).__dict__

View File

@@ -0,0 +1,199 @@
import traceback
from .route import Route, Response, RouteContext
from astrbot.core import logger
from quart import request
from astrbot.core.db import BaseDatabase
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
class PersonaRoute(Route):
def __init__(
self,
context: RouteContext,
db_helper: BaseDatabase,
core_lifecycle: AstrBotCoreLifecycle,
) -> None:
super().__init__(context)
self.routes = {
"/persona/list": ("GET", self.list_personas),
"/persona/detail": ("POST", self.get_persona_detail),
"/persona/create": ("POST", self.create_persona),
"/persona/update": ("POST", self.update_persona),
"/persona/delete": ("POST", self.delete_persona),
}
self.db_helper = db_helper
self.persona_mgr = core_lifecycle.persona_mgr
self.register_routes()
async def list_personas(self):
"""获取所有人格列表"""
try:
personas = await self.persona_mgr.get_all_personas()
return (
Response()
.ok(
[
{
"persona_id": persona.persona_id,
"system_prompt": persona.system_prompt,
"begin_dialogs": persona.begin_dialogs or [],
"tools": persona.tools,
"created_at": persona.created_at.isoformat()
if persona.created_at
else None,
"updated_at": persona.updated_at.isoformat()
if persona.updated_at
else None,
}
for persona in personas
]
)
.__dict__
)
except Exception as e:
logger.error(f"获取人格列表失败: {str(e)}\n{traceback.format_exc()}")
return Response().error(f"获取人格列表失败: {str(e)}").__dict__
async def get_persona_detail(self):
"""获取指定人格的详细信息"""
try:
data = await request.get_json()
persona_id = data.get("persona_id")
if not persona_id:
return Response().error("缺少必要参数: persona_id").__dict__
persona = await self.persona_mgr.get_persona(persona_id)
if not persona:
return Response().error("人格不存在").__dict__
return (
Response()
.ok(
{
"persona_id": persona.persona_id,
"system_prompt": persona.system_prompt,
"begin_dialogs": persona.begin_dialogs or [],
"tools": persona.tools,
"created_at": persona.created_at.isoformat()
if persona.created_at
else None,
"updated_at": persona.updated_at.isoformat()
if persona.updated_at
else None,
}
)
.__dict__
)
except Exception as e:
logger.error(f"获取人格详情失败: {str(e)}\n{traceback.format_exc()}")
return Response().error(f"获取人格详情失败: {str(e)}").__dict__
async def create_persona(self):
"""创建新人格"""
try:
data = await request.get_json()
persona_id = data.get("persona_id", "").strip()
system_prompt = data.get("system_prompt", "").strip()
begin_dialogs = data.get("begin_dialogs", [])
tools = data.get("tools")
if not persona_id:
return Response().error("人格ID不能为空").__dict__
if not system_prompt:
return Response().error("系统提示词不能为空").__dict__
# 验证 begin_dialogs 格式
if begin_dialogs and len(begin_dialogs) % 2 != 0:
return (
Response()
.error("预设对话数量必须为偶数(用户和助手轮流对话)")
.__dict__
)
persona = await self.persona_mgr.create_persona(
persona_id=persona_id,
system_prompt=system_prompt,
begin_dialogs=begin_dialogs if begin_dialogs else None,
tools=tools if tools else None,
)
return (
Response()
.ok(
{
"message": "人格创建成功",
"persona": {
"persona_id": persona.persona_id,
"system_prompt": persona.system_prompt,
"begin_dialogs": persona.begin_dialogs or [],
"tools": persona.tools or [],
"created_at": persona.created_at.isoformat()
if persona.created_at
else None,
"updated_at": persona.updated_at.isoformat()
if persona.updated_at
else None,
},
}
)
.__dict__
)
except ValueError as e:
return Response().error(str(e)).__dict__
except Exception as e:
logger.error(f"创建人格失败: {str(e)}\n{traceback.format_exc()}")
return Response().error(f"创建人格失败: {str(e)}").__dict__
async def update_persona(self):
"""更新人格信息"""
try:
data = await request.get_json()
persona_id = data.get("persona_id")
system_prompt = data.get("system_prompt")
begin_dialogs = data.get("begin_dialogs")
tools = data.get("tools")
if not persona_id:
return Response().error("缺少必要参数: persona_id").__dict__
# 验证 begin_dialogs 格式
if begin_dialogs is not None and len(begin_dialogs) % 2 != 0:
return (
Response()
.error("预设对话数量必须为偶数(用户和助手轮流对话)")
.__dict__
)
await self.persona_mgr.update_persona(
persona_id=persona_id,
system_prompt=system_prompt,
begin_dialogs=begin_dialogs,
tools=tools,
)
return Response().ok({"message": "人格更新成功"}).__dict__
except ValueError as e:
return Response().error(str(e)).__dict__
except Exception as e:
logger.error(f"更新人格失败: {str(e)}\n{traceback.format_exc()}")
return Response().error(f"更新人格失败: {str(e)}").__dict__
async def delete_persona(self):
"""删除人格"""
try:
data = await request.get_json()
persona_id = data.get("persona_id")
if not persona_id:
return Response().error("缺少必要参数: persona_id").__dict__
await self.persona_mgr.delete_persona(persona_id)
return Response().ok({"message": "人格删除成功"}).__dict__
except ValueError as e:
return Response().error(str(e)).__dict__
except Exception as e:
logger.error(f"删除人格失败: {str(e)}\n{traceback.format_exc()}")
return Response().error(f"删除人格失败: {str(e)}").__dict__

View File

@@ -40,8 +40,6 @@ class PluginRoute(Route):
"/plugin/on": ("POST", self.on_plugin),
"/plugin/reload": ("POST", self.reload_plugins),
"/plugin/readme": ("GET", self.get_plugin_readme),
"/plugin/platform_enable/get": ("GET", self.get_plugin_platform_enable),
"/plugin/platform_enable/set": ("POST", self.set_plugin_platform_enable),
}
self.core_lifecycle = core_lifecycle
self.plugin_manager = plugin_manager
@@ -286,14 +284,6 @@ class PluginRoute(Route):
f"{filter.parent_command_names[0]} {filter.command_name}"
)
info["cmd"] = info["cmd"].strip()
if (
self.core_lifecycle.astrbot_config["wake_prefix"]
and len(self.core_lifecycle.astrbot_config["wake_prefix"])
> 0
):
info["cmd"] = (
f"{self.core_lifecycle.astrbot_config['wake_prefix'][0]}{info['cmd']}"
)
elif isinstance(filter, CommandGroupFilter):
info["type"] = "指令组"
info["cmd"] = filter.get_complete_command_names()[0]
@@ -301,14 +291,6 @@ class PluginRoute(Route):
info["sub_command"] = filter.print_cmd_tree(
filter.sub_command_filters
)
if (
self.core_lifecycle.astrbot_config["wake_prefix"]
and len(self.core_lifecycle.astrbot_config["wake_prefix"])
> 0
):
info["cmd"] = (
f"{self.core_lifecycle.astrbot_config['wake_prefix'][0]}{info['cmd']}"
)
elif isinstance(filter, RegexFilter):
info["type"] = "正则匹配"
info["cmd"] = filter.regex_str
@@ -498,90 +480,3 @@ class PluginRoute(Route):
except Exception as e:
logger.error(f"/api/plugin/readme: {traceback.format_exc()}")
return Response().error(f"读取README文件失败: {str(e)}").__dict__
async def get_plugin_platform_enable(self):
"""获取插件在各平台的可用性配置"""
try:
platform_enable = self.core_lifecycle.astrbot_config.get(
"platform_settings", {}
).get("plugin_enable", {})
# 获取所有可用平台
platforms = []
for platform in self.core_lifecycle.astrbot_config.get("platform", []):
platform_type = platform.get("type", "")
platform_id = platform.get("id", "")
platforms.append(
{
"name": platform_id, # 使用type作为name这是系统内部使用的平台名称
"id": platform_id, # 保留id字段以便前端可以显示
"type": platform_type,
"display_name": f"{platform_type}({platform_id})",
}
)
adjusted_platform_enable = {}
for platform_id, plugins in platform_enable.items():
adjusted_platform_enable[platform_id] = plugins
# 获取所有插件,包括系统内部插件
plugins = []
for plugin in self.plugin_manager.context.get_all_stars():
plugins.append(
{
"name": plugin.name,
"desc": plugin.desc,
"reserved": plugin.reserved, # 添加reserved标志
}
)
logger.debug(
f"获取插件平台配置: 原始配置={platform_enable}, 调整后={adjusted_platform_enable}"
)
return (
Response()
.ok(
{
"platforms": platforms,
"plugins": plugins,
"platform_enable": adjusted_platform_enable,
}
)
.__dict__
)
except Exception as e:
logger.error(f"/api/plugin/platform_enable/get: {traceback.format_exc()}")
return Response().error(str(e)).__dict__
async def set_plugin_platform_enable(self):
"""设置插件在各平台的可用性配置"""
if DEMO_MODE:
return (
Response()
.error("You are not permitted to do this operation in demo mode")
.__dict__
)
try:
data = await request.json
platform_enable = data.get("platform_enable", {})
# 更新配置
config = self.core_lifecycle.astrbot_config
platform_settings = config.get("platform_settings", {})
platform_settings["plugin_enable"] = platform_enable
config["platform_settings"] = platform_settings
config.save_config()
# 更新插件的平台兼容性缓存
await self.plugin_manager.update_all_platform_compatibility()
logger.info(f"插件平台可用性配置已更新: {platform_enable}")
return Response().ok(None, "插件平台可用性配置已更新").__dict__
except Exception as e:
logger.error(f"/api/plugin/platform_enable/set: {traceback.format_exc()}")
return Response().error(str(e)).__dict__

View File

@@ -1,3 +1,4 @@
from astrbot.core import logger
from astrbot.core.config.astrbot_config import AstrBotConfig
from dataclasses import dataclass
from quart import Quart
@@ -15,23 +16,41 @@ class Route:
self.config = context.config
def register_routes(self):
for route, (method, func) in self.routes.items():
self.app.add_url_rule(f"/api{route}", view_func=func, methods=[method])
def _add_rule(path, method, func):
# 统一添加 /api 前缀
full_path = f"/api{path}"
self.app.add_url_rule(full_path, view_func=func, methods=[method])
# 兼容字典和列表两种格式
routes_to_register = (
self.routes.items() if isinstance(self.routes, dict) else self.routes
)
for route, definition in routes_to_register:
# 兼容一个路由多个方法
if isinstance(definition, list):
for method, func in definition:
_add_rule(route, method, func)
else:
method, func = definition
_add_rule(route, method, func)
@dataclass
class Response:
status: str = None
message: str = None
data: dict = None
status: str | None = None
message: str | None = None
data: dict | list | None = None
def error(self, message: str):
self.status = "error"
self.message = message
return self
def ok(self, data: dict = {}, message: str = None):
def ok(self, data: dict | list | None = None, message: str | None = None):
self.status = "ok"
if data is None:
data = {}
self.data = data
self.message = message
return self

View File

@@ -24,7 +24,6 @@ class SessionManagementRoute(Route):
"/session/list": ("GET", self.list_sessions),
"/session/update_persona": ("POST", self.update_session_persona),
"/session/update_provider": ("POST", self.update_session_provider),
"/session/get_session_info": ("POST", self.get_session_info),
"/session/plugins": ("GET", self.get_session_plugins),
"/session/update_plugin": ("POST", self.update_session_plugin),
"/session/update_llm": ("POST", self.update_session_llm),
@@ -32,24 +31,20 @@ class SessionManagementRoute(Route):
"/session/update_name": ("POST", self.update_session_name),
"/session/update_status": ("POST", self.update_session_status),
}
self.db_helper = db_helper
self.conv_mgr = core_lifecycle.conversation_manager
self.core_lifecycle = core_lifecycle
self.register_routes()
async def list_sessions(self):
"""获取所有会话的列表,包括 persona 和 provider 信息"""
try:
# 获取会话对话映射
session_conversations = sp.get("session_conversation", {}) or {}
# 获取会话提供商偏好设置
session_provider_perf = sp.get("session_provider_perf", {}) or {}
# 获取可用的 personas
personas = self.core_lifecycle.star_context.provider_manager.personas
# 获取可用的 providers
provider_manager = self.core_lifecycle.star_context.provider_manager
preferences = await sp.session_get(umo=None, key="sel_conv_id", default=[])
session_conversations = {}
for pref in preferences:
session_conversations[pref.scope_id] = pref.value["val"]
provider_manager = self.core_lifecycle.provider_manager
persona_mgr = self.core_lifecycle.persona_mgr
personas = persona_mgr.personas_v3
sessions = []
@@ -59,13 +54,9 @@ class SessionManagementRoute(Route):
"session_id": session_id,
"conversation_id": conversation_id,
"persona_id": None,
"persona_name": None,
"chat_provider_id": None,
"chat_provider_name": None,
"stt_provider_id": None,
"stt_provider_name": None,
"tts_provider_id": None,
"tts_provider_name": None,
"session_enabled": SessionServiceManager.is_session_enabled(
session_id
),
@@ -90,74 +81,46 @@ class SessionManagementRoute(Route):
}
# 获取对话信息
conversation = self.db_helper.get_conversation_by_user_id(
session_id, conversation_id
conversation = await self.conv_mgr.get_conversation(
unified_msg_origin=session_id, conversation_id=conversation_id
)
if conversation:
session_info["persona_id"] = conversation.persona_id
# 查找 persona 名称
if conversation.persona_id and conversation.persona_id != "[%None]":
for persona in personas:
if persona["name"] == conversation.persona_id:
session_info["persona_name"] = persona["name"]
session_info["persona_id"] = persona["name"]
break
elif conversation.persona_id == "[%None]":
session_info["persona_name"] = "无人格"
session_info["persona_id"] = "无人格"
else:
# 使用默认人格
default_persona = provider_manager.selected_default_persona
default_persona = persona_mgr.selected_default_persona_v3
if default_persona:
session_info["persona_id"] = default_persona["name"]
session_info["persona_name"] = default_persona["name"]
# 获取会话的 provider 偏好设置
session_perf = session_provider_perf.get(session_id, {})
# Chat completion provider
chat_provider_id = session_perf.get(ProviderType.CHAT_COMPLETION.value)
if chat_provider_id:
chat_provider = provider_manager.inst_map.get(chat_provider_id)
if chat_provider:
session_info["chat_provider_id"] = chat_provider_id
session_info["chat_provider_name"] = chat_provider.meta().id
else:
# 使用默认 provider
default_provider = provider_manager.curr_provider_inst
if default_provider:
session_info["chat_provider_id"] = default_provider.meta().id
session_info["chat_provider_name"] = default_provider.meta().id
# STT provider
stt_provider_id = session_perf.get(ProviderType.SPEECH_TO_TEXT.value)
if stt_provider_id:
stt_provider = provider_manager.inst_map.get(stt_provider_id)
if stt_provider:
session_info["stt_provider_id"] = stt_provider_id
session_info["stt_provider_name"] = stt_provider.meta().id
else:
# 使用默认 STT provider
default_stt_provider = provider_manager.curr_stt_provider_inst
if default_stt_provider:
session_info["stt_provider_id"] = default_stt_provider.meta().id
session_info["stt_provider_name"] = (
default_stt_provider.meta().id
)
# TTS provider
tts_provider_id = session_perf.get(ProviderType.TEXT_TO_SPEECH.value)
if tts_provider_id:
tts_provider = provider_manager.inst_map.get(tts_provider_id)
if tts_provider:
session_info["tts_provider_id"] = tts_provider_id
session_info["tts_provider_name"] = tts_provider.meta().id
else:
# 使用默认 TTS provider
default_tts_provider = provider_manager.curr_tts_provider_inst
if default_tts_provider:
session_info["tts_provider_id"] = default_tts_provider.meta().id
session_info["tts_provider_name"] = (
default_tts_provider.meta().id
)
# 获取 provider 信息
provider_manager = self.core_lifecycle.provider_manager
chat_provider = provider_manager.get_using_provider(
provider_type=ProviderType.CHAT_COMPLETION, umo=session_id
)
tts_provider = provider_manager.get_using_provider(
provider_type=ProviderType.TEXT_TO_SPEECH, umo=session_id
)
stt_provider = provider_manager.get_using_provider(
provider_type=ProviderType.SPEECH_TO_TEXT, umo=session_id
)
if chat_provider:
meta = chat_provider.meta()
session_info["chat_provider_id"] = meta.id
if tts_provider:
meta = tts_provider.meta()
session_info["tts_provider_id"] = meta.id
if stt_provider:
meta = stt_provider.meta()
session_info["stt_provider_id"] = meta.id
sessions.append(session_info)
@@ -311,133 +274,6 @@ class SessionManagementRoute(Route):
logger.error(error_msg)
return Response().error(f"更新会话提供商失败: {str(e)}").__dict__
async def get_session_info(self):
"""获取指定会话的详细信息"""
try:
data = await request.get_json()
session_id = data.get("session_id")
if not session_id:
return Response().error("缺少必要参数: session_id").__dict__
# 获取会话对话信息
session_conversations = sp.get("session_conversation", {}) or {}
conversation_id = session_conversations.get(session_id)
if not conversation_id:
return Response().error(f"会话 {session_id} 未找到对话").__dict__
session_info = {
"session_id": session_id,
"conversation_id": conversation_id,
"persona_id": None,
"persona_name": None,
"chat_provider_id": None,
"chat_provider_name": None,
"stt_provider_id": None,
"stt_provider_name": None,
"tts_provider_id": None,
"tts_provider_name": None,
"llm_enabled": SessionServiceManager.is_llm_enabled_for_session(
session_id
),
"tts_enabled": None, # 将在下面设置
"platform": session_id.split(":")[0]
if ":" in session_id
else "unknown",
"message_type": session_id.split(":")[1]
if session_id.count(":") >= 1
else "unknown",
"session_name": session_id.split(":")[2]
if session_id.count(":") >= 2
else session_id,
}
# 获取TTS状态
session_info["tts_enabled"] = (
SessionServiceManager.is_tts_enabled_for_session(session_id)
)
# 获取对话信息
conversation = self.db_helper.get_conversation_by_user_id(
session_id, conversation_id
)
if conversation:
session_info["persona_id"] = conversation.persona_id
# 查找 persona 名称
provider_manager = self.core_lifecycle.star_context.provider_manager
personas = provider_manager.personas
if conversation.persona_id and conversation.persona_id != "[%None]":
for persona in personas:
if persona["name"] == conversation.persona_id:
session_info["persona_name"] = persona["name"]
break
elif conversation.persona_id == "[%None]":
session_info["persona_name"] = "无人格"
else:
# 使用默认人格
default_persona = provider_manager.selected_default_persona
if default_persona:
session_info["persona_id"] = default_persona["name"]
session_info["persona_name"] = default_persona["name"]
# 获取会话的 provider 偏好设置
session_provider_perf = sp.get("session_provider_perf", {}) or {}
session_perf = session_provider_perf.get(session_id, {})
# 获取 provider 信息
provider_manager = self.core_lifecycle.star_context.provider_manager
# Chat completion provider
chat_provider_id = session_perf.get(ProviderType.CHAT_COMPLETION.value)
if chat_provider_id:
chat_provider = provider_manager.inst_map.get(chat_provider_id)
if chat_provider:
session_info["chat_provider_id"] = chat_provider_id
session_info["chat_provider_name"] = chat_provider.meta().id
else:
# 使用默认 provider
default_provider = provider_manager.curr_provider_inst
if default_provider:
session_info["chat_provider_id"] = default_provider.meta().id
session_info["chat_provider_name"] = default_provider.meta().id
# STT provider
stt_provider_id = session_perf.get(ProviderType.SPEECH_TO_TEXT.value)
if stt_provider_id:
stt_provider = provider_manager.inst_map.get(stt_provider_id)
if stt_provider:
session_info["stt_provider_id"] = stt_provider_id
session_info["stt_provider_name"] = stt_provider.meta().id
else:
# 使用默认 STT provider
default_stt_provider = provider_manager.curr_stt_provider_inst
if default_stt_provider:
session_info["stt_provider_id"] = default_stt_provider.meta().id
session_info["stt_provider_name"] = default_stt_provider.meta().id
# TTS provider
tts_provider_id = session_perf.get(ProviderType.TEXT_TO_SPEECH.value)
if tts_provider_id:
tts_provider = provider_manager.inst_map.get(tts_provider_id)
if tts_provider:
session_info["tts_provider_id"] = tts_provider_id
session_info["tts_provider_name"] = tts_provider.meta().id
else:
# 使用默认 TTS provider
default_tts_provider = provider_manager.curr_tts_provider_inst
if default_tts_provider:
session_info["tts_provider_id"] = default_tts_provider.meta().id
session_info["tts_provider_name"] = default_tts_provider.meta().id
return Response().ok(session_info).__dict__
except Exception as e:
error_msg = f"获取会话信息失败: {str(e)}\n{traceback.format_exc()}"
logger.error(error_msg)
return Response().error(f"获取会话信息失败: {str(e)}").__dict__
async def get_session_plugins(self):
"""获取指定会话的插件配置信息"""
try:

View File

@@ -11,6 +11,7 @@ from astrbot.core.db import BaseDatabase
from astrbot.core.config import VERSION
from astrbot.core.utils.io import get_dashboard_version
from astrbot.core import DEMO_MODE
from astrbot.core.db.migration.helper import check_migration_needed_v4
class StatRoute(Route):
@@ -59,6 +60,8 @@ class StatRoute(Route):
)
async def get_version(self):
need_migration = await check_migration_needed_v4(self.core_lifecycle.db)
return (
Response()
.ok(
@@ -66,6 +69,7 @@ class StatRoute(Route):
"version": VERSION,
"dashboard_version": await get_dashboard_version(),
"change_pwd_hint": self.is_default_cred(),
"need_migration": need_migration,
}
)
.__dict__
@@ -84,7 +88,7 @@ class StatRoute(Route):
message_time_based_stats = []
idx = 0
for bucket_end in range(start_time, now, 1800):
for bucket_end in range(start_time, now, 3600):
cnt = 0
while (
idx < len(stat.platform)

View File

@@ -0,0 +1,232 @@
# astrbot/dashboard/routes/t2i.py
from dataclasses import asdict
from quart import jsonify, request
from astrbot.core import logger
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.utils.t2i.template_manager import TemplateManager
from .route import Response, Route, RouteContext
class T2iRoute(Route):
def __init__(self, context: RouteContext, core_lifecycle: AstrBotCoreLifecycle):
super().__init__(context)
self.core_lifecycle = core_lifecycle
self.config = core_lifecycle.astrbot_config
self.manager = TemplateManager()
# 使用列表保证路由注册顺序,避免 /<name> 路由优先匹配 /reset_default
self.routes = [
("/t2i/templates", ("GET", self.list_templates)),
("/t2i/templates/active", ("GET", self.get_active_template)),
("/t2i/templates/create", ("POST", self.create_template)),
("/t2i/templates/reset_default", ("POST", self.reset_default_template)),
("/t2i/templates/set_active", ("POST", self.set_active_template)),
# 动态路由应该在静态路由之后注册
(
"/t2i/templates/<name>",
[
("GET", self.get_template),
("PUT", self.update_template),
("DELETE", self.delete_template),
],
),
]
# 应用启动时,确保备份存在
self.manager.backup_default_template_if_not_exist()
self.register_routes()
async def list_templates(self):
"""获取所有T2I模板列表"""
try:
templates = self.manager.list_templates()
return jsonify(asdict(Response().ok(data=templates)))
except Exception as e:
response = jsonify(asdict(Response().error(str(e))))
response.status_code = 500
return response
async def get_active_template(self):
"""获取当前激活的T2I模板"""
try:
active_template = self.config.get("t2i_active_template", "base")
return jsonify(
asdict(Response().ok(data={"active_template": active_template}))
)
except Exception as e:
logger.error("Error in get_active_template", exc_info=True)
response = jsonify(asdict(Response().error(str(e))))
response.status_code = 500
return response
async def get_template(self, name: str):
"""获取指定名称的T2I模板内容"""
try:
content = self.manager.get_template(name)
return jsonify(
asdict(Response().ok(data={"name": name, "content": content}))
)
except FileNotFoundError:
response = jsonify(asdict(Response().error("Template not found")))
response.status_code = 404
return response
except Exception as e:
response = jsonify(asdict(Response().error(str(e))))
response.status_code = 500
return response
async def create_template(self):
"""创建一个新的T2I模板"""
try:
data = await request.json
name = data.get("name")
content = data.get("content")
if not name or not content:
response = jsonify(
asdict(Response().error("Name and content are required."))
)
response.status_code = 400
return response
self.manager.create_template(name, content)
response = jsonify(
asdict(
Response().ok(
data={"name": name}, message="Template created successfully."
)
)
)
response.status_code = 201
return response
except FileExistsError:
response = jsonify(
asdict(Response().error("Template with this name already exists."))
)
response.status_code = 409
return response
except ValueError as e:
response = jsonify(asdict(Response().error(str(e))))
response.status_code = 400
return response
except Exception as e:
response = jsonify(asdict(Response().error(str(e))))
response.status_code = 500
return response
async def update_template(self, name: str):
"""更新一个已存在的T2I模板"""
try:
data = await request.json
content = data.get("content")
if content is None:
response = jsonify(asdict(Response().error("Content is required.")))
response.status_code = 400
return response
self.manager.update_template(name, content)
return jsonify(
asdict(
Response().ok(
data={"name": name}, message="Template updated successfully."
)
)
)
except FileNotFoundError:
response = jsonify(asdict(Response().error("Template not found.")))
response.status_code = 404
return response
except ValueError as e:
response = jsonify(asdict(Response().error(str(e))))
response.status_code = 400
return response
except Exception as e:
response = jsonify(asdict(Response().error(str(e))))
response.status_code = 500
return response
async def delete_template(self, name: str):
"""删除一个T2I模板"""
try:
self.manager.delete_template(name)
return jsonify(
asdict(Response().ok(message="Template deleted successfully."))
)
except FileNotFoundError:
response = jsonify(asdict(Response().error("Template not found.")))
response.status_code = 404
return response
except ValueError as e:
response = jsonify(asdict(Response().error(str(e))))
response.status_code = 400
return response
except Exception as e:
response = jsonify(asdict(Response().error(str(e))))
response.status_code = 500
return response
async def set_active_template(self):
"""设置当前活动的T2I模板"""
try:
data = await request.json
name = data.get("name")
if not name:
response = jsonify(asdict(Response().error("模板名称(name)不能为空。")))
response.status_code = 400
return response
# 验证模板文件是否存在
self.manager.get_template(name)
# 更新配置
config = self.config
config["t2i_active_template"] = name
config.save_config(config)
# 热重载以应用更改
await self.core_lifecycle.reload_pipeline_scheduler("default")
return jsonify(asdict(Response().ok(message=f"模板 '{name}' 已成功应用。")))
except FileNotFoundError:
response = jsonify(
asdict(Response().error(f"模板 '{name}' 不存在,无法应用。"))
)
response.status_code = 404
return response
except Exception as e:
logger.error("Error in set_active_template", exc_info=True)
response = jsonify(asdict(Response().error(str(e))))
response.status_code = 500
return response
async def reset_default_template(self):
"""重置默认的'base'模板"""
try:
self.manager.reset_default_template()
# 更新配置,将激活模板也重置为'base'
config = self.config
config["t2i_active_template"] = "base"
config.save_config(config)
# 热重载以应用更改
await self.core_lifecycle.reload_pipeline_scheduler("default")
return jsonify(
asdict(
Response().ok(
message="Default template has been reset and activated."
)
)
)
except FileNotFoundError as e:
response = jsonify(asdict(Response().error(str(e))))
response.status_code = 404
return response
except Exception as e:
logger.error("Error in reset_default_template", exc_info=True)
response = jsonify(asdict(Response().error(str(e))))
response.status_code = 500
return response

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